From 10c5a9d382ca16de331958675f301be845ee0c0a Mon Sep 17 00:00:00 2001 From: Karim Jerbi Date: Fri, 16 Oct 2026 15:19:13 +0100 Subject: [PATCH 1/4] feat(block-editor): author Lookup tables and wire Basepoint insertion Add native Basepoint placement and relocation, shared insertion and Test Block anchor behavior, and attribute-safe coordinate reframing. Wire native Lookup parameter placement and the Property Lookup Table editor through the existing palettes and BLOOKUPTABLE. Stage input columns, rows, reverse lookup and audit across multiple tables; apply all drafts atomically as one undo entry. Persist native Lookup actions and graph dependencies while preserving unrelated objects and IDs. New inputs cover Linear and Polar distances; reference Lookup evaluation and geometry actions remain separate work. Pin codec c418d92, kernel 0f6f225 and graph dcb9eda for correct R2010 inline constraint-group decoding. The codec regression uses a checked-in synthetic DWG and reproducible generator, with normal self-contained loading and edited DWG/DXF tests. Keep dock control tests independent of scene adapter warning dialogs in software snapshots. Validation covers native round trips, all 17 research fixtures, graph preservation, insertion and attributes, staged editing, undo/cancel, and real palette/dialog controls. --- Cargo.lock | 8 +- Cargo.toml | 8 +- crates/ocs_plugin_api/Cargo.toml | 4 +- crates/ocs_save/Cargo.toml | 2 +- crates/ocs_web_worker/Cargo.toml | 2 +- docs/plugin-template-v2/Cargo.toml | 2 +- plugins/opencad-python/Cargo.lock | 2 +- src/app/block_editor_tests.rs | 195 +++++++++ src/app/block_lookup.rs | 219 ++++++++++ src/app/block_test.rs | 3 +- src/app/command_driver/mod.rs | 3 + src/app/commands/bvisibility.rs | 22 +- src/app/commands/mod.rs | 6 + src/app/dock/tests.rs | 81 +++- src/app/dock/view.rs | 1 + src/app/mod.rs | 5 + src/app/update/mod.rs | 5 + src/app/view/mod.rs | 35 ++ src/app/view/modal.rs | 7 + src/command.rs | 7 + src/modules/block_editor/mod.rs | 1 + src/modules/block_editor/single_parameter.rs | 71 +++ .../block_editor/visibility_parameter.rs | 8 +- src/scene/block_authoring.rs | 25 ++ src/scene/block_basepoint.rs | 154 +++++++ src/scene/block_distance_parameter.rs | 2 + src/scene/block_lookup.rs | 405 ++++++++++++++++++ src/scene/block_lookup/graph.rs | 220 ++++++++++ src/scene/block_lookup/tests.rs | 365 ++++++++++++++++ src/scene/camera_ops.rs | 12 + src/scene/mod.rs | 2 + src/scene/modify.rs | 14 +- src/scene/render_graph.rs | 4 + src/ui/overlay.rs | 31 ++ src/ui/window/authoring_palettes.rs | 30 +- src/ui/window/block_lookup.rs | 319 ++++++++++++++ src/ui/window/mod.rs | 1 + 37 files changed, 2248 insertions(+), 33 deletions(-) create mode 100644 src/app/block_lookup.rs create mode 100644 src/modules/block_editor/single_parameter.rs create mode 100644 src/scene/block_basepoint.rs create mode 100644 src/scene/block_lookup.rs create mode 100644 src/scene/block_lookup/graph.rs create mode 100644 src/scene/block_lookup/tests.rs create mode 100644 src/ui/window/block_lookup.rs diff --git a/Cargo.lock b/Cargo.lock index 9e0cbba2d..16b5c0f54 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4382,7 +4382,7 @@ dependencies = [ [[package]] name = "opencadcodec" version = "0.6.0" -source = "git+https://github.com/HakanSeven12/opencadcodec.git?rev=325fa98#325fa98d2b881f48e106a830d5cf161624f63131" +source = "git+https://github.com/HakanSeven12/opencadcodec.git?rev=c418d92#c418d92615508c8b0393eeac6b84e756c0b21f1e" dependencies = [ "anyhow", "bitflags 2.13.1", @@ -4406,7 +4406,7 @@ dependencies = [ [[package]] name = "opencadgraph" version = "0.1.0" -source = "git+https://github.com/HakanSeven12/opencadgraph.git?rev=5d4e713#5d4e713968bd515be15302079d9edc1fd3df4df0" +source = "git+https://github.com/HakanSeven12/opencadgraph.git?rev=dcb9eda#dcb9eda84b33cd87907c3e1bb04fc75a3883a7d1" dependencies = [ "opencadkernel", "serde_json", @@ -4415,7 +4415,7 @@ dependencies = [ [[package]] name = "opencadkernel" version = "0.1.0" -source = "git+https://github.com/HakanSeven12/opencadkernel.git?rev=dfca391#dfca3912228b19d4df25c8695b628ccb13497a78" +source = "git+https://github.com/HakanSeven12/opencadkernel.git?rev=0f6f225#0f6f225d021a28c6218be7424606aa174a835ba8" dependencies = [ "cavalier_contours", "opencadcodec", @@ -4426,7 +4426,7 @@ dependencies = [ [[package]] name = "opencadkernel-constraints" version = "0.1.0" -source = "git+https://github.com/HakanSeven12/opencadkernel.git?rev=dfca391#dfca3912228b19d4df25c8695b628ccb13497a78" +source = "git+https://github.com/HakanSeven12/opencadkernel.git?rev=0f6f225#0f6f225d021a28c6218be7424606aa174a835ba8" dependencies = [ "nalgebra", ] diff --git a/Cargo.toml b/Cargo.toml index dedd35878..2c68bfb03 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,10 +37,10 @@ rfd = "0.17" clap = { version = "4", features = ["derive", "string"] } env_logger = "0.11" log = "0.4" -codec = { package = "opencadcodec", git = "https://github.com/HakanSeven12/opencadcodec.git", rev = "325fa98", features = ["serde"] } -kernel = { package = "opencadkernel", git = "https://github.com/HakanSeven12/opencadkernel.git", rev = "dfca391", features = ["acis", "offset"] } -kernel-constraints = { package = "opencadkernel-constraints", git = "https://github.com/HakanSeven12/opencadkernel.git", rev = "dfca391" } -graph = { package = "opencadgraph", git = "https://github.com/HakanSeven12/opencadgraph.git", rev = "5d4e713" } +codec = { package = "opencadcodec", git = "https://github.com/HakanSeven12/opencadcodec.git", rev = "c418d92", features = ["serde"] } +kernel = { package = "opencadkernel", git = "https://github.com/HakanSeven12/opencadkernel.git", rev = "0f6f225", features = ["acis", "offset"] } +kernel-constraints = { package = "opencadkernel-constraints", git = "https://github.com/HakanSeven12/opencadkernel.git", rev = "0f6f225" } +graph = { package = "opencadgraph", git = "https://github.com/HakanSeven12/opencadgraph.git", rev = "dcb9eda" } dwg-thumbnailer = { path = "crates/dwg-thumbnailer" } # Shared SAVE serialization dispatch + web-worker wire format: one # implementation for the inline (fallback/native) path and the worker path. diff --git a/crates/ocs_plugin_api/Cargo.toml b/crates/ocs_plugin_api/Cargo.toml index dc495b6eb..0c7ff45da 100644 --- a/crates/ocs_plugin_api/Cargo.toml +++ b/crates/ocs_plugin_api/Cargo.toml @@ -15,7 +15,7 @@ serde_json = { version = "1", optional = true } # Pulled in only by the `host` feature, which adds the `opencadcodec`-typed # `HostApi` runtime surface. The default crate stays dependency-free so engine # crates and external tooling can depend on the manifest/ribbon contract cheaply. -codec = { package = "opencadcodec", git = "https://github.com/HakanSeven12/opencadcodec.git", rev = "325fa98", optional = true, features = ["serde"] } +codec = { package = "opencadcodec", git = "https://github.com/HakanSeven12/opencadcodec.git", rev = "c418d92", optional = true, features = ["serde"] } # Runtime IPC and serialization (host feature only). interprocess = { version = "2", optional = true } @@ -39,7 +39,7 @@ serde_json = "1" serde = { version = "1", features = ["derive"] } cargo-lock = "11" # opencadcodec is scanned at build time to generate the embedded type registry. -codec = { package = "opencadcodec", git = "https://github.com/HakanSeven12/opencadcodec.git", rev = "325fa98", features = ["serde"] } +codec = { package = "opencadcodec", git = "https://github.com/HakanSeven12/opencadcodec.git", rev = "c418d92", features = ["serde"] } [dev-dependencies] serde_json = "1" diff --git a/crates/ocs_save/Cargo.toml b/crates/ocs_save/Cargo.toml index a64f550ef..cb45d7b26 100644 --- a/crates/ocs_save/Cargo.toml +++ b/crates/ocs_save/Cargo.toml @@ -10,6 +10,6 @@ publish = false # exactly one implementation. No UI/app dependencies — only the codec + serde. [dependencies] -codec = { package = "opencadcodec", git = "https://github.com/HakanSeven12/opencadcodec.git", rev = "325fa98", features = ["serde"] } +codec = { package = "opencadcodec", git = "https://github.com/HakanSeven12/opencadcodec.git", rev = "c418d92", features = ["serde"] } bincode = "1.3" serde = { version = "1", features = ["derive"] } diff --git a/crates/ocs_web_worker/Cargo.toml b/crates/ocs_web_worker/Cargo.toml index bc91d4438..98fbf7abb 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] -codec = { package = "opencadcodec", git = "https://github.com/HakanSeven12/opencadcodec.git", rev = "325fa98", features = ["serde"] } +codec = { package = "opencadcodec", git = "https://github.com/HakanSeven12/opencadcodec.git", rev = "c418d92", features = ["serde"] } bincode = "1.3" serde = { version = "1", features = ["derive"] } console_error_panic_hook = "0.1" diff --git a/docs/plugin-template-v2/Cargo.toml b/docs/plugin-template-v2/Cargo.toml index 63a19d6b5..6fa4fe33b 100644 --- a/docs/plugin-template-v2/Cargo.toml +++ b/docs/plugin-template-v2/Cargo.toml @@ -16,4 +16,4 @@ ocs_plugin_api = { path = "../../crates/ocs_plugin_api", features = ["host"] } # entry): the plugin's entities and the host's re-exports are the same types # only when both resolve the same revision. The registration `rustc` / # `acadrust_source` gates enforce this at load time too. -acadrust = { package = "opencadcodec", git = "https://github.com/HakanSeven12/opencadcodec.git", rev = "325fa98" } +acadrust = { package = "opencadcodec", git = "https://github.com/HakanSeven12/opencadcodec.git", rev = "c418d92" } diff --git a/plugins/opencad-python/Cargo.lock b/plugins/opencad-python/Cargo.lock index 11cfbc9aa..4c1a7b07c 100644 --- a/plugins/opencad-python/Cargo.lock +++ b/plugins/opencad-python/Cargo.lock @@ -1273,7 +1273,7 @@ dependencies = [ [[package]] name = "opencadcodec" version = "0.6.0" -source = "git+https://github.com/HakanSeven12/opencadcodec.git?rev=325fa98#325fa98d2b881f48e106a830d5cf161624f63131" +source = "git+https://github.com/HakanSeven12/opencadcodec.git?rev=c418d92#c418d92615508c8b0393eeac6b84e756c0b21f1e" dependencies = [ "anyhow", "bitflags", diff --git a/src/app/block_editor_tests.rs b/src/app/block_editor_tests.rs index 839e9613e..4a6255703 100644 --- a/src/app/block_editor_tests.rs +++ b/src/app/block_editor_tests.rs @@ -98,6 +98,201 @@ fn orientation_parameter_count(app: &OpenCADStudio) -> usize { crate::scene::block_orientation_parameter::parameters(&tab.scene.document, definition).len() } +#[test] +fn lookup_basepoint_placement_history_test_block_and_saved_parent_undo() { + use crate::scene::{block_basepoint, block_lookup, render_graph}; + let (mut app, reference) = fixture(); + begin(&mut app, reference); + let i = app.active_tab; + let definition = app.tabs[i].active_block_edit_session().unwrap().br_handle; + let members_before = members(&app); + let _ = app.run_command_line("BPARAMETER BASEPOINT 3,4"); + let (handle, p) = block_basepoint::parameter(&app.tabs[i].scene.document, definition).unwrap(); + assert_eq!(p.point, Vector3::new(3.0, 4.0, 0.0)); + let depth = app.tabs[i].history.undo_stack.len(); + assert_eq!(depth, 1); + let _ = app.run_command_line("BPARAMETER BASEPOINT 3,4"); + assert_eq!( + app.tabs[i].history.undo_stack.len(), + depth, + "same anchor is a no-op" + ); + let _ = app.run_command_line("BPARAMETER BASEPOINT 5,6"); + assert_eq!( + block_basepoint::parameter(&app.tabs[i].scene.document, definition) + .unwrap() + .0, + handle + ); + app.undo_steps(1); + assert_eq!( + block_basepoint::parameter(&app.tabs[i].scene.document, definition) + .unwrap() + .1 + .point, + Vector3::new(3.0, 4.0, 0.0) + ); + app.redo_steps(1); + let _ = app.dispatch_command("BPARAMETER"); + feed(&mut app, "K"); + feed(&mut app, "2,2"); + assert_eq!( + block_lookup::parameters(&app.tabs[i].scene.document, definition).len(), + 1 + ); + assert_eq!( + members(&app), + members_before, + "authoring objects are not drawing entities" + ); + let before_test = app.tabs[i].scene.document.clone(); + let _ = app.dispatch_command("BTESTBLOCK"); + let doc = &app.tabs[app.active_tab].scene.document; + let insert = doc + .entities() + .find_map(|e| match e { + EntityType::Insert(i) => Some(i), + _ => None, + }) + .unwrap(); + assert!( + (render_graph::insert_transform(doc, insert).apply(Vector3::new(5.0, 6.0, 0.0)) + - insert.insert_point) + .length() + < 1e-9 + ); + let _ = app.dispatch_command("BCLOSETESTBLOCK"); + assert_eq!(app.tabs[i].scene.document, before_test); + let _ = app.dispatch_command("BSAVE"); + let _ = app.dispatch_command("BEDIT_DISCARD"); + assert!(block_basepoint::parameter(&app.tabs[i].scene.document, definition).is_some()); + app.undo_steps(1); + assert!(block_basepoint::parameter(&app.tabs[i].scene.document, definition).is_none()); + app.redo_steps(1); + assert!(block_basepoint::parameter(&app.tabs[i].scene.document, definition).is_some()); +} + +fn lookup_editor_fixture() -> (OpenCADStudio, Handle, Handle) { + let (mut app, reference) = fixture(); + begin(&mut app, reference); + let _ = app.run_command_line("BPARAMETER LINEAR 0,0 2,0 0,1"); + let _ = app.run_command_line("BPARAMETER LOOKUP 1,1"); + let i = app.active_tab; + let definition = app.tabs[i].active_block_edit_session().unwrap().br_handle; + let parameter = + crate::scene::block_lookup::parameters(&app.tabs[i].scene.document, definition)[0].0; + (app, definition, parameter) +} +fn edit_lookup(app: &mut OpenCADStudio) { + use crate::ui::window::block_lookup::Action as A; + let _ = app.update(Message::BlockLookup(A::Property(0))); + let _ = app.update(Message::BlockLookup(A::AddProperty)); + let _ = app.update(Message::BlockLookup(A::AddRow)); + let _ = app.update(Message::BlockLookup(A::Cell(0, 1, "Small".into()))); +} +#[test] +fn lookup_editor_cancel_apply_one_undo_and_stale_guard() { + use crate::ui::window::block_lookup::Action as A; + use crate::scene::block_lookup; + let (mut app, definition, parameter) = lookup_editor_fixture(); + let i = app.active_tab; + let before = app.tabs[i].scene.document.clone(); + let depth = app.tabs[i].history.undo_stack.len(); + for cancel in [Message::CloseModal, Message::CommandEscape] { + let _ = app.dispatch_command("BLOOKUPTABLE"); + edit_lookup(&mut app); + assert_eq!(app.tabs[i].scene.document, before, "draft is isolated"); + let _ = app.update(cancel); + assert!(app.block_lookup.is_none()); + assert_eq!(app.tabs[i].scene.document, before); + assert_eq!(app.tabs[i].history.undo_stack.len(), depth); + } + let _ = app.dispatch_command("BLOOKUPTABLE"); + let _ = app.update(Message::BlockLookup(A::Apply)); + assert_eq!( + app.tabs[i].history.undo_stack.len(), + depth, + "unchanged OK is a no-op" + ); + let _ = app.dispatch_command("BLOOKUPTABLE"); + edit_lookup(&mut app); + let _ = app.update(Message::BlockLookup(A::Audit)); + assert!(app.block_lookup.as_ref().unwrap().error.is_none()); + let _ = app.update(Message::BlockLookup(A::Apply)); + assert!(app.block_lookup.is_none()); + assert_eq!(app.tabs[i].history.undo_stack.len(), depth + 1); + assert_eq!( + block_lookup::table(&app.tabs[i].scene.document, definition, parameter) + .unwrap() + .unwrap() + .1 + .expressions, + vec!["2", "Small"] + ); + let saved = app.tabs[i].scene.document.clone(); + app.undo_steps(1); + assert_eq!(app.tabs[i].scene.document, before); + app.redo_steps(1); + assert_eq!(app.tabs[i].scene.document, saved); + let _ = app.dispatch_command("BLOOKUPTABLE Lookup1"); + let _ = app.update(Message::BlockLookup(A::Cell(0, 1, "Changed".into()))); + app.tabs[i].scene.bump_geometry(); + let _ = app.update(Message::BlockLookup(A::Apply)); + assert!(app + .block_lookup + .as_ref() + .unwrap() + .error + .as_ref() + .unwrap() + .contains("changed")); + assert_eq!(app.tabs[i].scene.document, saved); + let _ = app.update(Message::CloseModal); +} + +#[test] +fn lookup_editor_multiple_tables_apply_atomically_and_parameter_cancel_is_clean() { + use crate::ui::window::block_lookup::Action as A; + let (mut app, definition, _) = lookup_editor_fixture(); + let i = app.active_tab; + let _ = app.run_command_line("BPARAMETER LOOKUP 3,3"); + let before = app.tabs[i].scene.document.clone(); + let depth = app.tabs[i].history.undo_stack.len(); + for kind in ["BASEPOINT", "LOOKUP"] { + let _ = app.dispatch_command(&format!("BPARAMETER {kind}")); + let _ = app.update(Message::CommandEscape); + assert_eq!(app.tabs[i].scene.document, before); + assert_eq!(app.tabs[i].history.undo_stack.len(), depth); + } + let _ = app.dispatch_command("BLOOKUPTABLE"); + edit_lookup(&mut app); + let _ = app.update(Message::BlockLookup(A::Select(1))); + edit_lookup(&mut app); + let _ = app.update(Message::BlockLookup(A::Cell(0, 0, "NaN".into()))); + let _ = app.update(Message::BlockLookup(A::Apply)); + assert!(app.block_lookup.as_ref().unwrap().error.is_some()); + assert_eq!(app.tabs[i].scene.document, before); + let _ = app.update(Message::BlockLookup(A::Cell(0, 0, "5".into()))); + let _ = app.update(Message::BlockLookup(A::Apply)); + assert!(app.block_lookup.is_none()); + assert_eq!(app.tabs[i].history.undo_stack.len(), depth + 1); + for (h, _) in crate::scene::block_lookup::parameters(&app.tabs[i].scene.document, definition) { + assert!( + crate::scene::block_lookup::table(&app.tabs[i].scene.document, definition, h) + .unwrap() + .is_some() + ); + } + app.undo_steps(1); + assert_eq!(app.tabs[i].scene.document, before); + let _ = app.dispatch_command("BEDIT_DISCARD"); + let _ = app.dispatch_command("LINE"); + for cmd in ["BPARAMETER BASEPOINT", "BPARAMETER LOOKUP", "BLOOKUPTABLE"] { + let _ = app.dispatch_command(cmd); + assert_eq!(app.tabs[i].active_cmd.as_ref().unwrap().name(), "LINE"); + } +} + #[test] fn orientation_parameter_batch_numeric_rotation_local_history_save_and_parent_undo() { let (mut app, reference) = fixture(); diff --git a/src/app/block_lookup.rs b/src/app/block_lookup.rs new file mode 100644 index 000000000..581ebca10 --- /dev/null +++ b/src/app/block_lookup.rs @@ -0,0 +1,219 @@ +use super::{Message, ModalKind, OpenCADStudio}; +use crate::scene::{block_basepoint, block_lookup as lookup}; +use crate::ui::window::block_lookup::{Action, Draft, State}; +use iced::Task; + +impl OpenCADStudio { + pub(in crate::app) fn handle_place_block_single_parameter( + &mut self, + result: crate::command::CmdResult, + ) -> Task { + use crate::modules::block_editor::single_parameter::Kind; + let crate::command::CmdResult::PlaceBlockSingleParameter { + document_id, + definition, + kind, + location, + } = result + else { + unreachable!() + }; + let i = self.active_tab; + if self.tabs[i].id != document_id + || self.tabs[i] + .active_block_edit_session() + .map(|s| s.br_handle) + != Some(definition) + { + self.command_line.push_error( + crate::t!("The parameter's Block Editor session is no longer active.").as_ref(), + ); + return Task::none(); + } + if let Some(error) = self.parameter_placement_error(kind.name()) { + self.command_line.push_error(crate::t!(error).as_ref()); + return Task::none(); + } + if !location.is_finite() { + self.command_line + .push_error(crate::t!("Specify a finite parameter location.").as_ref()); + return Task::none(); + } + let location = codec::types::Vector3::new(location.x, location.y, location.z); + if kind == Kind::Basepoint + && block_basepoint::parameter(&self.tabs[i].scene.document, definition).is_some_and( + |(_, p)| { + p.point == location + && p.base_point == location + && p.parameter.definition_point == location + }, + ) + { + self.tabs[i].active_cmd = None; + self.tabs[i].scene.clear_preview_wire(); + return Task::none(); + } + self.push_undo_snapshot(i, &format!("{} parameter", kind.name())); + match kind { + Kind::Basepoint => { + block_basepoint::create(&mut self.tabs[i].scene.document, definition, location) + } + Kind::Lookup => lookup::create(&mut self.tabs[i].scene.document, definition, location), + } + .expect("validated parameter placement"); + self.tabs[i].scene.bump_geometry(); + self.tabs[i].dirty = true; + self.tabs[i].active_cmd = None; + self.tabs[i].scene.clear_preview_wire(); + self.finish_pending_history(i); + self.refresh_properties(); + self.command_line.push_output( + crate::t!(if kind == Kind::Lookup { + "Lookup parameter placed. Use BLOOKUPTABLE to edit its property table." + } else { + "Basepoint parameter placed." + }) + .as_ref(), + ); + Task::none() + } + pub(in crate::app) fn lookup_table_error(&self) -> Option<&'static str> { + let tab = &self.tabs[self.active_tab]; + let Some(session) = tab.active_block_edit_session() else { + return Some("Open a block in the Block Editor before editing a Lookup table."); + }; + if lookup::parameters(&tab.scene.document, session.br_handle).is_empty() { + Some("Place a Lookup parameter before editing its property table.") + } else { + lookup::placement_error(&tab.scene.document, session.br_handle) + } + } + pub(in crate::app) fn open_lookup_table(&mut self, name: &str) -> Task { + if let Some(error) = self.lookup_table_error() { + self.command_line.push_error(crate::t!(error).as_ref()); + return Task::none(); + } + let tab = &self.tabs[self.active_tab]; + let definition = tab.active_block_edit_session().unwrap().br_handle; + let mut drafts = vec![]; + for (handle, parameter) in lookup::parameters(&tab.scene.document, definition) { + let original = match lookup::table(&tab.scene.document, definition, handle) { + Ok(value) => value, + Err(error) => { + self.command_line.push_error(crate::t!(error).as_ref()); + return Task::none(); + } + }; + let edited = original + .as_ref() + .map(|(_, t)| t.clone()) + .unwrap_or_else(|| lookup::empty_table(parameter)); + if edited.row_count < 0 + || edited.row_count > 1000 + || edited.columns.is_empty() + || edited.columns.len() > 64 + || edited.column_count != edited.columns.len() as i32 + || edited.expressions.len() != edited.columns.len() * edited.row_count as usize + { + self.command_line.push_error( + crate::t!("This Lookup table has inconsistent or unsupported dimensions.") + .as_ref(), + ); + return Task::none(); + } + drafts.push(Draft { + parameter: handle, + original_parameter: parameter.clone(), + original, + edited, + }); + } + let selected = if name.is_empty() { + 0 + } else { + let Some(index) = drafts + .iter() + .position(|d| d.original_parameter.lookup_name.eq_ignore_ascii_case(name)) + else { + self.command_line + .push_error(crate::t!("No Lookup parameter has that name.").as_ref()); + return Task::none(); + }; + index + }; + self.block_lookup = Some(State { + document: tab.id, + definition, + epoch: tab.scene.geometry_epoch, + drafts, + selected, + properties: lookup::properties(&tab.scene.document, definition), + selected_property: None, + error: None, + notice: None, + }); + self.active_modal = Some(ModalKind::BlockLookup); + Task::none() + } + pub(super) fn handle_block_lookup(&mut self, action: Action) -> Task { + if !matches!(action, Action::Apply) { + if let Some(state) = self.block_lookup.as_mut() { + state.edit(action); + } + return Task::none(); + } + let Some(state) = self.block_lookup.as_ref() else { + return Task::none(); + }; + let i = self.active_tab; + let tab = &self.tabs[i]; + let stale = tab.id != state.document + || tab.active_block_edit_session().map(|s| s.br_handle) != Some(state.definition) + || tab.scene.geometry_epoch != state.epoch + || state.drafts.iter().any(|d| { + lookup::parameters(&tab.scene.document, state.definition) + .into_iter() + .find(|(h, _)| *h == d.parameter) + .map(|(_, p)| p) + != Some(&d.original_parameter) + || lookup::table(&tab.scene.document, state.definition, d.parameter) + .ok() + .as_ref() + != Some(&d.original) + }); + if stale { + self.block_lookup.as_mut().unwrap().error = Some( + crate::t!("The block changed. Cancel and reopen the Lookup table.").into_owned(), + ); + return Task::none(); + } + let mut working = tab.scene.document.clone(); + let mut changed = false; + for draft in &state.drafts { + if draft.changed() { + if let Err(error) = lookup::apply_table( + &mut working, + state.definition, + draft.parameter, + draft.edited.clone(), + ) { + self.block_lookup.as_mut().unwrap().error = + Some(crate::t!(error.as_str()).into_owned()); + return Task::none(); + } + changed = true; + } + } + if changed { + self.push_undo_snapshot(i, "Lookup tables"); + self.tabs[i].scene.document = working; + self.tabs[i].scene.bump_geometry(); + self.tabs[i].dirty = true; + self.finish_pending_history(i); + self.refresh_properties(); + } + self.block_lookup = None; + self.active_modal = None; + Task::none() + } +} diff --git a/src/app/block_test.rs b/src/app/block_test.rs index 729c4d829..b32cf6b67 100644 --- a/src/app/block_test.rs +++ b/src/app/block_test.rs @@ -67,8 +67,7 @@ impl OpenCADStudio { } document.header.show_model_space = true; crate::scene::block_visibility::finish_editing(&mut document, definition); - let record = document.block_records.get(&name).unwrap(); - let base = record.base_point; + let base = crate::scene::render_graph::block_base_point(&document, &name); let mut insert = Insert::new(&name, codec::types::Vector3::ZERO); insert.common.layer = self.tabs[i].active_layer.clone(); insert.common.handle = document.allocate_handle(); diff --git a/src/app/command_driver/mod.rs b/src/app/command_driver/mod.rs index a920cbee3..fb882bc3e 100644 --- a/src/app/command_driver/mod.rs +++ b/src/app/command_driver/mod.rs @@ -575,6 +575,9 @@ impl OpenCADStudio { return task; } } + result @ CmdResult::PlaceBlockSingleParameter { .. } => { + return self.handle_place_block_single_parameter(result); + } result @ CmdResult::PlaceBlockOrientationParameter { .. } => { return self.handle_place_block_orientation_parameter(result); } diff --git a/src/app/commands/bvisibility.rs b/src/app/commands/bvisibility.rs index c200d9a5e..0fefd6f83 100644 --- a/src/app/commands/bvisibility.rs +++ b/src/app/commands/bvisibility.rs @@ -137,6 +137,8 @@ impl OpenCADStudio { return Some("Open a block in the Block Editor before placing a parameter."); }; match kind.to_ascii_uppercase().as_str() { + "BASEPOINT"|"BASE"|"B" => crate::scene::block_basepoint::placement_error(&tab.scene.document,session.br_handle), + "LOOKUP"|"K" => crate::scene::block_lookup::placement_error(&tab.scene.document,session.br_handle), "POINT" | "P" | "" => crate::scene::block_point_parameter::placement_error( &tab.scene.document, session.br_handle, @@ -151,7 +153,7 @@ impl OpenCADStudio { "VISIBILITY" | "V" => { visibility::placement_error(&tab.scene.document, session.br_handle) } - _ => Some("Supported parameter types: Point, Linear, Polar, XY, Rotation, Alignment, Flip, Visibility."), + _ => Some("Supported parameter types: Point, Linear, Polar, XY, Rotation, Alignment, Flip, Visibility, Lookup, Basepoint."), } } @@ -389,11 +391,27 @@ impl OpenCADStudio { return Some(Task::none()); } match cmd { + cmd if cmd == "BLOOKUPTABLE" || cmd.starts_with("BLOOKUPTABLE ") => { + Some(self.open_lookup_table(cmd.strip_prefix("BLOOKUPTABLE").unwrap().trim())) + } cmd if cmd == "BPARAMETER" || cmd.starts_with("BPARAMETER ") => { let Some(block) = self.visibility_block(i, "BPARAMETER") else { return Some(Task::none()); }; let parameter_type = cmd.strip_prefix("BPARAMETER").unwrap().trim(); + if let Some(kind) = + crate::modules::block_editor::single_parameter::Kind::parse(parameter_type) + { + let command = + crate::modules::block_editor::single_parameter::SingleParameterCommand::new( + self.tabs[i].id, + block, + kind, + ); + self.command_line.push_info(&command.prompt()); + self.tabs[i].active_cmd = Some(Box::new(command)); + return Some(Task::none()); + } if let Some(kind) = crate::scene::block_orientation_parameter::Kind::parse(parameter_type) { @@ -428,7 +446,7 @@ impl OpenCADStudio { { self.command_line.push_error( crate::t!( - "Supported parameter types: Point, Linear, Polar, XY, Rotation, Alignment, Flip, Visibility." + "Supported parameter types: Point, Linear, Polar, XY, Rotation, Alignment, Flip, Visibility, Lookup, Basepoint." ) .as_ref(), ); diff --git a/src/app/commands/mod.rs b/src/app/commands/mod.rs index a1af0cdcd..33194169e 100644 --- a/src/app/commands/mod.rs +++ b/src/app/commands/mod.rs @@ -182,6 +182,12 @@ impl OpenCADStudio { return Task::none(); } } + if cmd == "BLOOKUPTABLE" || cmd.starts_with("BLOOKUPTABLE ") { + if let Some(error) = self.lookup_table_error() { + self.command_line.push_error(crate::t!(error).as_ref()); + return Task::none(); + } + } if self.tabs[i].block_test.is_some() { let name = cmd.split([' ', ':']).next().unwrap_or(cmd); if matches!( diff --git a/src/app/dock/tests.rs b/src/app/dock/tests.rs index b8858fa2c..cb02124dc 100644 --- a/src/app/dock/tests.rs +++ b/src/app/dock/tests.rs @@ -5,6 +5,17 @@ use crate::app::{Message, OpenCADStudio}; fn fresh() -> OpenCADStudio { let mut app = OpenCADStudio::new_for_test(); app.automation_op(r#"{"op":"new"}"#); + // These tests drive dock controls, not the hardware warning dialog. + // Software snapshots intentionally have no scene GPU adapter. + app.gpu_warning_silenced = crate::scene::pipeline::gpu_status() + .identity() + .or_else(|| crate::scene::pipeline::GpuStatus::NoRenderer.identity()) + .unwrap(); + app.pending_startup_modals + .retain(|modal| *modal != crate::app::ModalKind::GpuWarning); + if app.active_modal == Some(crate::app::ModalKind::GpuWarning) { + app.active_modal = None; + } app } @@ -1095,6 +1106,65 @@ fn authoring_app() -> OpenCADStudio { app } +#[test] +fn lookup_basepoint_palette_and_table_dialog_real_controls() { + with_stack(|| { + use crate::ui::window::{authoring_palettes::Tab, block_lookup::Action as A}; + let mut app = authoring_app(); + let _ = app.dispatch_command("BAUTHORPALETTE"); + for (id, point_text) in [ + ("authoring-tool-basepoint", "0.2,0.1"), + ("authoring-tool-lookup", "0.5,0.2"), + ] { + let point = authoring_control(&app, id); + authoring_click(&mut app, point); + assert_eq!( + app.tabs[app.active_tab].active_cmd.as_ref().unwrap().name(), + "BPARAMETER" + ); + let _ = app.update(Message::CommandInput(point_text.into())); + let _ = app.update(Message::CommandSubmit); + } + let _ = app.run_command_line("BPARAMETER LINEAR 0,0 1,0 0,0.3"); + authoring_capture(&app, "lookup-basepoint-placed"); + let point = authoring_control(&app, Tab::Actions.widget_id()); + authoring_click(&mut app, point); + let point = authoring_control(&app, "authoring-tool-lookup-table"); + authoring_click(&mut app, point); + assert_eq!(app.active_modal, Some(crate::app::ModalKind::BlockLookup)); + let _ = app.update(Message::BlockLookup(A::Property(0))); + let point = find_in_view(&app, "Add Property"); + authoring_click(&mut app, point); + let point = find_in_view(&app, "Add Row"); + authoring_click(&mut app, point); + assert_eq!( + app.block_lookup.as_ref().unwrap().drafts[0] + .edited + .row_count, + 1 + ); + let _ = app.update(Message::BlockLookup(A::Cell(0, 1, "Small".into()))); + let point = find_in_view(&app, "Audit"); + authoring_click(&mut app, point); + assert!(app.block_lookup.as_ref().unwrap().notice.is_some()); + authoring_capture(&app, "lookup-property-table"); + let point = find_in_view(&app, "OK"); + authoring_click(&mut app, point); + assert!(app.block_lookup.is_none()); + let i = app.active_tab; + let definition = app.tabs[i].active_block_edit_session().unwrap().br_handle; + let parameter = + crate::scene::block_lookup::parameters(&app.tabs[i].scene.document, definition)[0].0; + assert!(crate::scene::block_lookup::table( + &app.tabs[i].scene.document, + definition, + parameter + ) + .unwrap() + .is_some()); + }); +} + #[test] fn authoring_palettes_ribbon_toggle_and_dock_close_leave_document_and_history_unchanged() { use crate::ui::dock::{DockMsg, PanelId}; @@ -1166,11 +1236,12 @@ fn authoring_palettes_toggle_keeps_the_running_drawing_command() { } #[test] -fn authoring_palettes_catalog_in_real_view_has_no_parameter_placement_action() { +fn authoring_palettes_unwired_move_action_does_not_start_a_command() { with_stack(|| { let mut app = authoring_app(); let _ = app.dispatch_command("BAUTHORPALETTE"); - let point = find_in_view(&app, "Basepoint"); + app.authoring_palette_tab = crate::ui::window::authoring_palettes::Tab::Actions; + let point = find_in_view(&app, "Move"); let before = app.tabs[app.active_tab].scene.document.clone(); drive_view( &mut app, @@ -1262,7 +1333,11 @@ fn authoring_palettes_real_tabs_switch_catalogs_and_unavailable_rows_stay_inert( .active_cmd .as_ref() .map(|command| command.name()), - Some("LINE") + Some(if tab == Tab::Parameters { + "BPARAMETER" + } else { + "LINE" + }) ); } assert_eq!(app.tabs[i].scene.document, before); diff --git a/src/app/dock/view.rs b/src/app/dock/view.rs index 2bb0e5e5f..170d69b88 100644 --- a/src/app/dock/view.rs +++ b/src/app/dock/view.rs @@ -410,6 +410,7 @@ impl OpenCADStudio { self.visibility_placement_error(), self.point_parameter_placement_error(), self.parameter_placement_error("FLIP"), + self.lookup_table_error(), ) } crate::ui::dock::PanelId::BlockPalette => { diff --git a/src/app/mod.rs b/src/app/mod.rs index c58598e43..a7707fe2b 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -13,6 +13,7 @@ mod block_editor; mod block_editor_tests; mod block_save_as; mod block_visibility; +mod block_lookup; mod block_properties; #[cfg(test)] mod block_properties_tests; @@ -593,6 +594,7 @@ pub(super) struct OpenCADStudio { block_edit_picker: Option, block_save_as: Option, block_visibility: Option, + block_lookup: Option, block_visibility_mode: bool, /// PDF dialogs' working copies; `None` while closed. pdf_attach: Option, @@ -2295,6 +2297,7 @@ pub enum ModalKind { BlockEditPicker, BlockSaveAs, BlockVisibility, + BlockLookup, SaveDialog, Recovery, /// Fonts referenced by the drawing are missing on this machine. @@ -2947,6 +2950,7 @@ pub enum Message { BlockSaveAsName(String), BlockSaveAsApply, BlockVisibility(crate::ui::window::block_visibility::Action), + BlockLookup(crate::ui::window::block_lookup::Action), BlockEditPickerName(String), BlockEditPickerSelect(String), BlockEditPickerApply, @@ -4730,6 +4734,7 @@ impl OpenCADStudio { block_edit_picker: None, block_save_as: None, block_visibility: None, + block_lookup: None, block_visibility_mode: false, pdf_attach: None, point_cloud_attach: None, diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 164e8ad17..e503bf00e 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -237,6 +237,7 @@ impl OpenCADStudio { Some(BlockEditPicker) => self.block_edit_picker = None, Some(BlockSaveAs) => self.block_save_as = None, Some(BlockVisibility) => self.block_visibility = None, + Some(BlockLookup) => self.block_lookup = None, Some(PdfAttach) => self.pdf_attach = None, Some(UnderlayLayers) => self.underlay_layers = None, Some(PdfImportSettings) => self.pdf_import_settings = None, @@ -9728,6 +9729,9 @@ impl OpenCADStudio { } Message::CloseModal => { + if self.active_modal == Some(super::ModalKind::BlockLookup) { + self.block_lookup = None; + } if self.active_modal == Some(super::ModalKind::BlockVisibility) { self.block_visibility = None; } @@ -10752,6 +10756,7 @@ impl OpenCADStudio { } Message::BlockSaveAsApply => self.apply_block_save_as_dialog(), Message::BlockVisibility(action) => self.handle_block_visibility(action), + Message::BlockLookup(action) => self.handle_block_lookup(action), Message::BlockEditPickerName(name) => { if let Some(state) = self.block_edit_picker.as_mut() { state.name = name.clone(); diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index 37f166fe2..01e7952cb 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -1119,6 +1119,31 @@ bg={bg_ms:.1}ms n={view_count}" ) }), ) + .chain( + crate::scene::block_basepoint::parameter( + &tab.scene.document, + session.br_handle, + ) + .map(|(_, p)| { + let mut g = crate::scene::block_distance_parameter::Graphic::default(); + g.base_grips.push(p.parameter.definition_point); + (g, p.parameter.definition_point) + }), + ) + .chain( + crate::scene::block_lookup::parameters( + &tab.scene.document, + session.br_handle, + ) + .into_iter() + .map(|(_, p)| { + let mut g = crate::scene::block_distance_parameter::Graphic::default(); + g.lookup_grips.push(p.parameter.definition_point); + g.labels + .push((p.parameter.definition_point, p.lookup_name.clone())); + (g, p.parameter.definition_point) + }), + ) .filter_map(|(graphic, marker_tail)| { let project = |p: codec::types::Vector3| { let p = tab @@ -1155,6 +1180,16 @@ bg={bg_ms:.1}ms n={view_count}" .collect::>>()?; Some(crate::ui::overlay::AuthoringDimension { lines, + base_grips: graphic + .base_grips + .into_iter() + .map(project) + .collect::>>()?, + lookup_grips: graphic + .lookup_grips + .into_iter() + .map(project) + .collect::>>()?, round_grips: graphic .round_grips .into_iter() diff --git a/src/app/view/modal.rs b/src/app/view/modal.rs index 6bb78ae38..9bcd7d479 100644 --- a/src/app/view/modal.rs +++ b/src/app/view/modal.rs @@ -88,6 +88,7 @@ impl OpenCADStudio { Some(K::BlockEditChanges) => crate::t!("Block Editor").into_owned(), Some(K::BlockEditPicker) => crate::t!("Edit Block Definition").into_owned(), Some(K::BlockVisibility) => crate::t!("Visibility States").into_owned(), + Some(K::BlockLookup) => crate::t!("Property Lookup Table").into_owned(), Some(K::BlockSaveAs) => crate::t!("Save Block As").into_owned(), Some(K::PointStyle) => crate::tr!("modal", "point-style"), Some(K::AttributeEditor) => crate::tr!("modal", "attribute-editor"), @@ -522,6 +523,12 @@ impl OpenCADStudio { crate::ui::window::block_edit_picker::view(state, flow) }) } + super::super::ModalKind::BlockLookup => { + let state = self.block_lookup.as_ref()?; + sized_flow(ex, 940, 600, |flow| { + crate::ui::window::block_lookup::view(state, flow) + }) + } super::super::ModalKind::BlockVisibility => { let state = self.block_visibility.as_ref()?; sized_flow(ex, 580, 470, |flow| { diff --git a/src/command.rs b/src/command.rs index 602870f9b..71fdbf73a 100644 --- a/src/command.rs +++ b/src/command.rs @@ -1514,6 +1514,13 @@ impl DimensionPreview { /// Returned by every `CadCommand` method to tell main.rs what to do. #[allow(dead_code)] pub enum CmdResult { + /// Place or relocate a Basepoint, or place a Lookup parameter. + PlaceBlockSingleParameter { + document_id: u64, + definition: Handle, + kind: crate::modules::block_editor::single_parameter::Kind, + location: DVec3, + }, /// Place a native Rotation, Alignment or Flip parameter. PlaceBlockOrientationParameter { document_id: u64, diff --git a/src/modules/block_editor/mod.rs b/src/modules/block_editor/mod.rs index 007de5fe2..9e2b5df08 100644 --- a/src/modules/block_editor/mod.rs +++ b/src/modules/block_editor/mod.rs @@ -7,6 +7,7 @@ use crate::modules::{CadModule, IconKind, ModuleEvent, RibbonGroup, RibbonItem, pub mod visibility_parameter; pub mod point_parameter; +pub mod single_parameter; pub mod distance_parameter; pub mod orientation_parameter; diff --git a/src/modules/block_editor/single_parameter.rs b/src/modules/block_editor/single_parameter.rs new file mode 100644 index 000000000..b84f900cc --- /dev/null +++ b/src/modules/block_editor/single_parameter.rs @@ -0,0 +1,71 @@ +use crate::command::{CadCommand, CmdResult}; +use codec::Handle; +use glam::DVec3; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Kind { + Basepoint, + Lookup, +} +impl Kind { + pub(crate) fn parse(value: &str) -> Option { + match value.to_ascii_uppercase().as_str() { + "BASE" | "BASEPOINT" | "B" => Some(Self::Basepoint), + "LOOKUP" | "K" => Some(Self::Lookup), + _ => None, + } + } + pub(crate) fn name(self) -> &'static str { + if self == Self::Basepoint { + "Basepoint" + } else { + "Lookup" + } + } +} +inventory::submit!(crate::command::CommandRegistration { + names: &["BLOOKUPTABLE"] +}); + +pub struct SingleParameterCommand { + document: u64, + definition: Handle, + kind: Kind, +} +impl SingleParameterCommand { + pub(crate) fn new(document: u64, definition: Handle, kind: Kind) -> Self { + Self { + document, + definition, + kind, + } + } +} +impl CadCommand for SingleParameterCommand { + fn name(&self) -> &'static str { + "BPARAMETER" + } + fn prompt(&self) -> String { + crate::tf!( + "BPARAMETER {} Specify parameter location:", + crate::t!(self.kind.name()) + ) + .into_owned() + } + fn on_point(&mut self, location: DVec3) -> CmdResult { + if !location.is_finite() { + return CmdResult::ReportError( + crate::t!("Specify a finite parameter location.").into_owned(), + ); + } + CmdResult::PlaceBlockSingleParameter { + document_id: self.document, + definition: self.definition, + kind: self.kind, + location, + } + } + fn on_enter(&mut self) -> CmdResult { + CmdResult::Cancel + } +} diff --git a/src/modules/block_editor/visibility_parameter.rs b/src/modules/block_editor/visibility_parameter.rs index ef9cffa51..b6c8ece68 100644 --- a/src/modules/block_editor/visibility_parameter.rs +++ b/src/modules/block_editor/visibility_parameter.rs @@ -28,7 +28,7 @@ impl CadCommand for VisibilityParameterCommand { fn prompt(&self) -> String { if self.choosing_type { crate::t!( - "BPARAMETER Enter parameter type [Point/Linear/Polar/XY/Rotation/Alignment/Flip/Visibility] :" + "BPARAMETER Enter parameter type [Point/Linear/Polar/XY/Rotation/Alignment/Flip/Visibility/Lookup/Basepoint] :" ) .into_owned() } else { @@ -47,6 +47,8 @@ impl CadCommand for VisibilityParameterCommand { CmdOption::new(crate::t!("Alignment").as_ref(), "A"), CmdOption::new(crate::t!("Flip").as_ref(), "F"), CmdOption::new(crate::t!("Visibility").as_ref(), "V"), + CmdOption::new(crate::t!("Lookup").as_ref(), "K"), + CmdOption::new(crate::t!("Basepoint").as_ref(), "B"), ] } else { vec![] @@ -74,13 +76,15 @@ impl CadCommand for VisibilityParameterCommand { "R" | "ROTATION" => CmdResult::Dispatch("BPARAMETER ROTATION".into()), "A" | "ALIGNMENT" => CmdResult::Dispatch("BPARAMETER ALIGNMENT".into()), "F" | "FLIP" => CmdResult::Dispatch("BPARAMETER FLIP".into()), + "K"|"LOOKUP" => CmdResult::Dispatch("BPARAMETER LOOKUP".into()), + "B"|"BASE"|"BASEPOINT" => CmdResult::Dispatch("BPARAMETER BASEPOINT".into()), "P" | "POINT" => CmdResult::Dispatch("BPARAMETER POINT".into()), "" | "V" | "VISIBILITY" => { self.choosing_type = false; CmdResult::NeedPoint } _ => CmdResult::ReportError( - crate::t!("Supported parameter types: Point, Linear, Polar, XY, Rotation, Alignment, Flip, Visibility.") + crate::t!("Supported parameter types: Point, Linear, Polar, XY, Rotation, Alignment, Flip, Visibility, Lookup, Basepoint.") .into_owned(), ), }, diff --git a/src/scene/block_authoring.rs b/src/scene/block_authoring.rs index f67140671..aa7657c81 100644 --- a/src/scene/block_authoring.rs +++ b/src/scene/block_authoring.rs @@ -219,6 +219,31 @@ pub(super) fn append( append_components(document, handle, slots, &expressions, &[2, 3]); } +/// Basepoint parameters and action objects can be standalone expressions. +pub(super) fn append_single( + document: &mut CadDocument, + handle: Handle, + slots: Slots, + expression: Handle, +) { + let ObjectType::DynamicBlock(object) = document.objects.get_mut(&handle).unwrap() else { + unreachable!() + }; + let DynamicBlockData::EvaluationGraph(graph) = &mut object.data else { + unreachable!() + }; + graph.nodes.push(codec::objects::BlockEvaluationNode { + id: slots.node, + next_id: slots.eval, + expression, + edge_flags: 32, + node_data: [-1; 4], + active_cycles: None, + }); + graph.first_node_id = slots.eval; + graph.first_node_id_copy = slots.eval; +} + /// Append a parameter and grip, followed by its location/state expressions. /// `components` gives the expression offsets in native dependency order. pub(super) fn append_components( diff --git a/src/scene/block_basepoint.rs b/src/scene/block_basepoint.rs new file mode 100644 index 000000000..81711a0dd --- /dev/null +++ b/src/scene/block_basepoint.rs @@ -0,0 +1,154 @@ +//! The native authoring anchor, separate from the BLOCK definition origin. +use super::block_authoring::{self as authoring, *}; +use codec::objects::{ + BlockBasePointParameter, BlockElement, BlockEvalValue, BlockOnePointParameter, BlockParameter, + DynamicBlockData, ObjectType, +}; +use codec::{ + types::{Transform, Vector3}, + CadDocument, Handle, +}; + +pub(crate) fn parameter( + document: &CadDocument, + definition: Handle, +) -> Option<(Handle, &BlockBasePointParameter)> { + let ObjectType::Dictionary(dictionary) = document + .objects + .get(&document.extension_dictionary_handle(definition)?)? + else { + return None; + }; + let graph = dictionary.get("ACAD_ENHANCEDBLOCK")?; + let ObjectType::DynamicBlock(object) = document.objects.get(&graph)? else { + return None; + }; + let DynamicBlockData::EvaluationGraph(data) = &object.data else { + return None; + }; + data.nodes + .iter() + .find_map(|node| match document.objects.get(&node.expression) { + Some(ObjectType::DynamicBlock(object)) if object.owner == graph => match &object.data { + DynamicBlockData::BasePointParameter(p) => Some((object.handle, p)), + _ => None, + }, + _ => None, + }) +} +pub(crate) fn placement_error(document: &CadDocument, definition: Handle) -> Option<&'static str> { + authoring::graph(document, definition).err() +} +pub(crate) fn create( + document: &mut CadDocument, + definition: Handle, + location: Vector3, +) -> Result { + if ![location.x, location.y, location.z] + .iter() + .all(|v| v.is_finite()) + { + return Err("Specify a finite parameter location."); + } + authoring::graph(document, definition)?; + let name = document + .block_records + .iter() + .find(|b| b.handle == definition) + .unwrap() + .name + .clone(); + let old_base = super::render_graph::block_base_point(document, &name); + if let Some((handle, _)) = parameter(document, definition) { + let ObjectType::DynamicBlock(object) = document.objects.get_mut(&handle).unwrap() else { + unreachable!() + }; + let DynamicBlockData::BasePointParameter(p) = &mut object.data else { + unreachable!() + }; + p.parameter.definition_point = location; + p.point = location; + p.base_point = location; + move_attributes(document, &name, old_base, location); + return Ok(handle); + } + let (graph, slots) = authoring::prepare(document, definition)?; + register_classes( + document, + &[("BLOCKBASEPOINTPARAMETER", "AcDbBlockBasepointParameter", 55)], + ); + let handle = document.allocate_handle(); + let native = BlockBasePointParameter { + parameter: BlockOnePointParameter { + parameter: BlockParameter { + element: BlockElement { + eval: expression(slots.eval, NO_VALUE, BlockEvalValue::None), + name: "Base Point".into(), + major: MAJOR, + minor: MINOR, + eed_1071: 0, + }, + show_properties: true, + chain_actions: false, + }, + definition_point: location, + ..Default::default() + }, + point: location, + base_point: location, + }; + document.objects.insert( + handle, + ObjectType::DynamicBlock(dynamic( + handle, + graph, + "BLOCKBASEPOINTPARAMETER", + "AcDbBlockBasepointParameter", + DynamicBlockData::BasePointParameter(native), + )), + ); + authoring::append_single(document, graph, slots, handle); + move_attributes(document, &name, old_base, location); + Ok(handle) +} + +fn move_attributes(document: &mut CadDocument, name: &str, old: Vector3, new: Vector3) { + let handles = document + .entities() + .filter_map(|entity| match entity { + codec::EntityType::Insert(i) + if i.block_name.eq_ignore_ascii_case(name) && !i.attributes.is_empty() => + { + Some(i.common.handle) + } + _ => None, + }) + .collect::>(); + for handle in handles { + if let Some(codec::EntityType::Insert(insert)) = document.get_entity_mut(handle) { + let shift = insert.get_transform().apply_rotation(old - new); + let transform = Transform::from_translation(shift); + for attribute in &mut insert.attributes { + codec::Entity::apply_transform(attribute, &transform); + } + } + } +} +pub(crate) fn reframe( + document: &mut CadDocument, + definition: Handle, + transform: &Transform, +) -> usize { + let Some((handle, _)) = parameter(document, definition) else { + return 0; + }; + if let Some(ObjectType::DynamicBlock(object)) = document.objects.get_mut(&handle) { + if let DynamicBlockData::BasePointParameter(p) = &mut object.data { + p.parameter.definition_point = transform.apply(p.parameter.definition_point); + p.point = transform.apply(p.point); + p.base_point = transform.apply(p.base_point); + return 1; + } + } + 0 +} diff --git a/src/scene/block_distance_parameter.rs b/src/scene/block_distance_parameter.rs index b59e62486..13d698fd5 100644 --- a/src/scene/block_distance_parameter.rs +++ b/src/scene/block_distance_parameter.rs @@ -335,6 +335,8 @@ pub(crate) fn create( pub(crate) struct Graphic { pub lines: Vec<[Vector3; 2]>, pub round_grips: Vec, + pub base_grips: Vec, + pub lookup_grips: Vec, pub dimensions: Vec<[Vector3; 2]>, pub labels: Vec<(Vector3, String)>, pub grips: Vec<(Vector3, bool)>, // Linear uses a direction arrow; others a square. diff --git a/src/scene/block_lookup.rs b/src/scene/block_lookup.rs new file mode 100644 index 000000000..0b968920d --- /dev/null +++ b/src/scene/block_lookup.rs @@ -0,0 +1,405 @@ +//! Native Lookup parameter and property-table authoring. +use super::block_authoring::{self as authoring, *}; +use codec::objects::{ + BlockAction, BlockElement, BlockEvalValue, BlockGrip, BlockGripExpression, BlockLookupAction, + BlockLookupColumn, BlockLookupParameter, BlockOnePointParameter, BlockParameter, + DynamicBlockData, ObjectType, +}; +use codec::{ + types::{Transform, Vector3}, + CadDocument, Handle, +}; +mod graph; +#[cfg(test)] +mod tests; + +fn graph_handle(document: &CadDocument, definition: Handle) -> Option { + let ObjectType::Dictionary(d) = document + .objects + .get(&document.extension_dictionary_handle(definition)?)? + else { + return None; + }; + d.get("ACAD_ENHANCEDBLOCK") +} +pub(crate) fn parameters( + document: &CadDocument, + definition: Handle, +) -> Vec<(Handle, &BlockLookupParameter)> { + let Some(graph) = graph_handle(document, definition) else { + return vec![]; + }; + let Some(ObjectType::DynamicBlock(o)) = document.objects.get(&graph) else { + return vec![]; + }; + let DynamicBlockData::EvaluationGraph(g) = &o.data else { + return vec![]; + }; + g.nodes + .iter() + .filter_map(|n| match document.objects.get(&n.expression) { + Some(ObjectType::DynamicBlock(o)) if o.owner == graph => match &o.data { + DynamicBlockData::LookupParameter(p) => Some((o.handle, p)), + _ => None, + }, + _ => None, + }) + .collect() +} +pub(crate) fn placement_error(document: &CadDocument, definition: Handle) -> Option<&'static str> { + authoring::graph(document, definition).err() +} +pub(crate) fn create( + document: &mut CadDocument, + definition: Handle, + location: Vector3, +) -> Result { + if ![location.x, location.y, location.z] + .iter() + .all(|v| v.is_finite()) + { + return Err("Specify a finite parameter location."); + } + let names = parameters(document, definition) + .iter() + .map(|(_, p)| p.lookup_name.clone()) + .collect::>(); + let name = (1..) + .map(|n| format!("Lookup{n}")) + .find(|s| names.iter().all(|n| !n.eq_ignore_ascii_case(s))) + .unwrap(); + let (graph, slots) = authoring::prepare(document, definition)?; + register_classes( + document, + &[ + ("BLOCKLOOKUPPARAMETER", "AcDbBlockLookUpParameter", 55), + ("BLOCKLOOKUPGRIP", "AcDbBlockLookUpGrip", 55), + ("BLOCKGRIPLOCATIONCOMPONENT", "AcDbBlockGripExpr", 20), + ], + ); + let handles = [(); 4].map(|_| document.allocate_handle()); + let element = |offset, name: &str| BlockElement { + eval: expression(slots.eval + offset, NO_VALUE, BlockEvalValue::None), + name: name.into(), + major: MAJOR, + minor: MINOR, + eed_1071: 0, + }; + let parameter = BlockLookupParameter { + parameter: BlockOnePointParameter { + parameter: BlockParameter { + element: element(0, "Lookup"), + show_properties: true, + chain_actions: false, + }, + definition_point: location, + property_count: slots.eval + 1, + ..Default::default() + }, + lookup_name: name, + ..Default::default() + }; + document.objects.insert( + handles[0], + ObjectType::DynamicBlock(dynamic( + handles[0], + graph, + "BLOCKLOOKUPPARAMETER", + "AcDbBlockLookUpParameter", + DynamicBlockData::LookupParameter(parameter), + )), + ); + let grip = BlockGrip { + element: element(1, "Grip"), + flags_91: slots.eval + 2, + flags_92: slots.eval + 3, + location, + insert_cycling: false, + insert_cycling_weight: -1, + }; + document.objects.insert( + handles[1], + ObjectType::DynamicBlock(dynamic( + handles[1], + graph, + "BLOCKLOOKUPGRIP", + "AcDbBlockLookUpGrip", + DynamicBlockData::LookupGrip(grip), + )), + ); + for (offset, axis) in [(2, "UpdatedX"), (3, "UpdatedY")] { + let h = handles[offset as usize]; + document.objects.insert( + h, + ObjectType::DynamicBlock(dynamic( + h, + graph, + "BLOCKGRIPLOCATIONCOMPONENT", + "AcDbBlockGripExpr", + DynamicBlockData::GripLocationComponent(BlockGripExpression { + eval: expression(slots.eval + offset, 40, BlockEvalValue::Real(0.0)), + grip_type: slots.eval, + expression: axis.into(), + }), + )), + ); + } + authoring::append(document, graph, slots, handles); + Ok(handles[0]) +} + +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct Property { + pub label: String, + pub column: BlockLookupColumn, + pub value: String, +} +/// These distance bindings are verified against the supplied native tables. +pub(crate) fn properties(document: &CadDocument, definition: Handle) -> Vec { + super::block_distance_parameter::parameters(document, definition) + .iter() + .filter(|p| { + matches!( + p.kind, + super::block_distance_parameter::Kind::Linear + | super::block_distance_parameter::Kind::Polar + ) + }) + .map(|p| Property { + label: p.labels[0].clone(), + column: BlockLookupColumn { + node_id: p.native.parameter.element.eval.node_id, + value_type: 40, + property_type: 2, + connection_name: "UpdatedDistance".into(), + unmatched_name: "Custom".into(), + ..Default::default() + }, + value: (p.native.definition_end_point - p.native.definition_base_point) + .length() + .to_string(), + }) + .collect() +} +pub(crate) fn table( + document: &CadDocument, + definition: Handle, + parameter: Handle, +) -> Result, &'static str> { + let p = parameters(document, definition) + .into_iter() + .find(|(h, _)| *h == parameter) + .ok_or("The Lookup parameter is no longer available.")? + .1; + if p.index == 0 { + return Ok(None); + } + let graph = graph_handle(document, definition).ok_or("The authoring graph is unavailable.")?; + document + .objects + .values() + .find_map(|o| match o { + ObjectType::DynamicBlock(o) if o.owner == graph => match &o.data { + DynamicBlockData::LookupAction(a) if a.action.element.eval.node_id == p.index => { + Some((o.handle, a.clone())) + } + _ => None, + }, + _ => None, + }) + .map(Some) + .ok_or("The Lookup action cannot be edited.") +} +pub(crate) fn empty_table(parameter: &BlockLookupParameter) -> BlockLookupAction { + BlockLookupAction { + column_count: 1, + columns: vec![BlockLookupColumn { + node_id: parameter.parameter.parameter.element.eval.node_id, + value_type: 1, + property_type: 0, + lookup_property: true, + writable: true, + connection_name: "lookupString".into(), + unmatched_name: "Custom".into(), + }], + flag_280: true, + ..Default::default() + } +} +pub(crate) fn validate( + table: &BlockLookupAction, + original: Option<&BlockLookupAction>, +) -> Result<(), String> { + let rows = usize::try_from(table.row_count).map_err(|_| "Invalid table size.")?; + let cols = table.columns.len(); + if rows == 0 + || rows > 1000 + || cols < 2 + || cols > 64 + || table.column_count != cols as i32 + || table.expressions.len() != rows * cols + { + return Err("Add at least one input property and one complete row.".into()); + } + if !table.columns.iter().any(|c| !c.lookup_property) + || !table.columns.iter().any(|c| c.lookup_property) + { + return Err("A table needs input and lookup properties.".into()); + } + let mut inputs = std::collections::BTreeSet::new(); + let mut outputs = vec![std::collections::BTreeSet::new(); cols]; + for row in 0..rows { + let mut key = vec![]; + for (col, property) in table.columns.iter().enumerate() { + let cell = &table.expressions[row * cols + col]; + if cell.trim().is_empty() || cell.chars().any(char::is_control) { + return Err("Complete each cell without control characters.".into()); + } + if property.lookup_property { + if property.writable && !outputs[col].insert(cell.trim().to_lowercase()) { + return Err("Reverse lookup requires unique lookup values.".into()); + } + } else { + let numeric = cell.trim().parse::().ok().filter(|v| v.is_finite()); + if property.value_type == 40 + && numeric.is_none() + && !original.is_some_and(|old| { + old.columns.iter().enumerate().any(|(index, column)| { + column == property + && old + .expressions + .iter() + .skip(index) + .step_by(old.columns.len()) + .any(|value| value == cell) + }) + }) + { + return Err("Enter finite decimal input values. Existing expressions can be retained unchanged.".into()); + } + key.push( + numeric + .map(|v| if v == 0.0 { "0".into() } else { v.to_string() }) + .unwrap_or_else(|| cell.trim().to_lowercase()), + ); + } + } + if !inputs.insert(key) { + return Err("Each input-property row must be unique.".into()); + } + } + Ok(()) +} +pub(crate) fn apply_table( + document: &mut CadDocument, + definition: Handle, + parameter: Handle, + edited: BlockLookupAction, +) -> Result { + let mut working = document.clone(); + let handle = apply_table_inner(&mut working, definition, parameter, edited)?; + *document = working; + Ok(handle) +} +fn apply_table_inner( + document: &mut CadDocument, + definition: Handle, + parameter: Handle, + mut edited: BlockLookupAction, +) -> Result { + let previous = table(document, definition, parameter).map_err(str::to_string)?; + validate(&edited, previous.as_ref().map(|(_, t)| t))?; + let (graph, slots) = authoring::prepare(document, definition).map_err(str::to_string)?; + let handle = if let Some((handle, original)) = &previous { + edited.action = original.action.clone(); + *handle + } else { + register_classes( + document, + &[("BLOCKLOOKUPACTION", "AcDbBlockLookupAction", 55)], + ); + let h = document.allocate_handle(); + let p = parameters(document, definition) + .into_iter() + .find(|(h, _)| *h == parameter) + .unwrap() + .1; + edited.action = BlockAction { + element: BlockElement { + eval: expression(slots.eval, NO_VALUE, BlockEvalValue::None), + name: p.lookup_name.clone(), + major: MAJOR, + minor: MINOR, + eed_1071: 0, + }, + display_location: p.parameter.definition_point, + ..Default::default() + }; + authoring::append_single(document, graph, slots, h); + document.objects.insert( + h, + ObjectType::DynamicBlock(dynamic( + h, + graph, + "BLOCKLOOKUPACTION", + "AcDbBlockLookupAction", + DynamicBlockData::LookupAction(edited.clone()), + )), + ); + h + }; + let node_id = edited.action.element.eval.node_id; + graph::bind(document, graph, node_id, &edited.columns).map_err(str::to_string)?; + if let Some(ObjectType::DynamicBlock(o)) = document.objects.get_mut(&handle) { + o.data = DynamicBlockData::LookupAction(edited); + } + if let Some(ObjectType::DynamicBlock(o)) = document.objects.get_mut(¶meter) { + if let DynamicBlockData::LookupParameter(p) = &mut o.data { + p.index = node_id; + } + } + Ok(handle) +} +pub(crate) fn reframe( + document: &mut CadDocument, + definition: Handle, + transform: &Transform, +) -> usize { + let Some(graph) = graph_handle(document, definition) else { + return 0; + }; + let owned = parameters(document, definition) + .iter() + .map(|(h, p)| (*h, p.parameter.property_count, p.index)) + .collect::>(); + let mut changed = 0; + for (handle, grip_id, action_id) in owned { + if let Some(ObjectType::DynamicBlock(o)) = document.objects.get_mut(&handle) { + if let DynamicBlockData::LookupParameter(p) = &mut o.data { + p.parameter.definition_point = transform.apply(p.parameter.definition_point); + changed += 1; + } + } + for o in document.objects.values_mut() { + if let ObjectType::DynamicBlock(o) = o { + if o.owner != graph { + continue; + } + match &mut o.data { + DynamicBlockData::LookupGrip(g) if g.element.eval.node_id == grip_id => { + g.location = transform.apply(g.location); + changed += 1; + } + DynamicBlockData::LookupAction(a) + if action_id > 0 && a.action.element.eval.node_id == action_id => + { + a.action.display_location = transform.apply(a.action.display_location); + changed += 1; + } + _ => {} + } + } + } + } + changed +} diff --git a/src/scene/block_lookup/graph.rs b/src/scene/block_lookup/graph.rs new file mode 100644 index 000000000..7c66c8586 --- /dev/null +++ b/src/scene/block_lookup/graph.rs @@ -0,0 +1,220 @@ +//! Maintain native adjacency lists while adding/replacing lookup dependencies. +use super::*; +use codec::objects::{BlockEvaluationEdge, BlockEvaluationGraph}; + +pub(super) fn valid_lists(g: &BlockEvaluationGraph, node: i32) -> bool { + let Some(n) = g.nodes.iter().find(|n| n.id == node) else { + return false; + }; + for (head, tail, previous_slot, next_slot, outgoing) in + [(0, 1, 0, 1, false), (2, 3, 2, 3, true)] + { + let mut current = n.node_data[head]; + let mut previous = -1; + let mut seen = std::collections::BTreeSet::new(); + while current >= 0 { + if !seen.insert(current) { + return false; + } + let Some(edge) = g.edges.iter().find(|e| e.id == current) else { + return false; + }; + if (if outgoing { + edge.source_node + } else { + edge.destination_node + }) != node + || edge.outgoing_edges[previous_slot] != previous + { + return false; + } + previous = current; + current = edge.outgoing_edges[next_slot]; + } + if previous != n.node_data[tail] + || seen.len() + != g.edges + .iter() + .filter(|e| { + (if outgoing { + e.source_node + } else { + e.destination_node + }) == node + }) + .count() + { + return false; + } + } + true +} + +fn remove(g: &mut BlockEvaluationGraph, id: i32) { + let edge = g.edges.iter().find(|e| e.id == id).unwrap().clone(); + let [pi, ni, po, no, _] = edge.outgoing_edges; + for (neighbor, field, value) in [(pi, 1, ni), (ni, 0, pi), (po, 3, no), (no, 2, po)] { + if let Some(e) = g.edges.iter_mut().find(|e| e.id == neighbor) { + e.outgoing_edges[field] = value; + } + } + for n in &mut g.nodes { + if n.id == edge.destination_node { + if n.node_data[0] == id { + n.node_data[0] = ni; + } + if n.node_data[1] == id { + n.node_data[1] = pi; + } + } + if n.id == edge.source_node { + if n.node_data[2] == id { + n.node_data[2] = no; + } + if n.node_data[3] == id { + n.node_data[3] = po; + } + } + } + g.edges.retain(|e| e.id != id); +} +fn add(g: &mut BlockEvaluationGraph, id: i32, source: i32, destination: i32, inverse: i32) { + let incoming = g + .nodes + .iter() + .find(|n| n.id == destination) + .unwrap() + .node_data[1]; + let outgoing = g.nodes.iter().find(|n| n.id == source).unwrap().node_data[3]; + if let Some(e) = g.edges.iter_mut().find(|e| e.id == incoming) { + e.outgoing_edges[1] = id; + } + if let Some(e) = g.edges.iter_mut().find(|e| e.id == outgoing) { + e.outgoing_edges[3] = id; + } + for n in &mut g.nodes { + if n.id == destination { + if n.node_data[0] < 0 { + n.node_data[0] = id; + } + n.node_data[1] = id; + } + if n.id == source { + if n.node_data[2] < 0 { + n.node_data[2] = id; + } + n.node_data[3] = id; + } + } + g.edges.push(BlockEvaluationEdge { + id, + next_id: 4, + incoming_edge: 1, + source_node: source, + destination_node: destination, + outgoing_edges: [incoming, -1, outgoing, -1, inverse], + }); +} +pub(super) fn bind( + document: &mut CadDocument, + graph: Handle, + action: i32, + columns: &[BlockLookupColumn], +) -> Result<(), &'static str> { + let Some(ObjectType::DynamicBlock(object)) = document.objects.get(&graph) else { + return Err("The authoring graph is unavailable."); + }; + let DynamicBlockData::EvaluationGraph(original) = &object.data else { + return Err("The authoring graph is unavailable."); + }; + let mut g = original.clone(); + if std::iter::once(action) + .chain(columns.iter().map(|c| c.node_id)) + .any(|id| g.nodes.iter().filter(|n| n.next_id == id).count() != 1) + { + return Err("A Lookup binding has an invalid or ambiguous expression ID."); + } + let action = g + .nodes + .iter() + .find(|n| n.next_id == action) + .ok_or("The Lookup action node is unavailable.")? + .id; + let mut bindings = vec![]; + for column in columns { + let node = g + .nodes + .iter() + .find(|n| n.next_id == column.node_id) + .ok_or("A table property no longer belongs to this block.")? + .id; + if node == action { + return Err("A Lookup table cannot use its action as a property."); + } + if !bindings.contains(&node) { + bindings.push(node); + } + } + let old = g + .edges + .iter() + .filter(|e| e.source_node == action || e.destination_node == action) + .collect::>(); + if std::iter::once(action) + .chain(bindings.iter().copied()) + .chain(old.iter().flat_map(|e| [e.source_node, e.destination_node])) + .any(|n| !valid_lists(&g, n)) + { + return Err("The Lookup graph has invalid adjacency links."); + } + if old.iter().any(|e| e.next_id != 4) { + return Err("This Lookup action has unsupported graph dependencies."); + } + if old.iter().any(|e| { + !old.iter().any(|inverse| { + inverse.id == e.outgoing_edges[4] + && inverse.outgoing_edges[4] == e.id + && inverse.source_node == e.destination_node + && inverse.destination_node == e.source_node + }) + }) { + return Err("The Lookup graph has invalid inverse dependencies."); + } + let expected = bindings + .iter() + .flat_map(|n| [(action, *n), (*n, action)]) + .collect::>(); + if old.len() == expected.len() + && old + .iter() + .map(|e| (e.source_node, e.destination_node)) + .collect::>() + == expected + { + return Ok(()); + } + let mut id = g + .edges + .iter() + .map(|e| e.id) + .max() + .unwrap_or(-1) + .checked_add(1) + .ok_or("The graph has no free edge IDs.")?; + id.checked_add(bindings.len() as i32 * 2 - 1) + .ok_or("The graph has no free edge IDs.")?; + let old = old.iter().map(|e| e.id).collect::>(); + for edge in old { + remove(&mut g, edge); + } + for node in bindings { + add(&mut g, id, node, action, id + 1); + add(&mut g, id + 1, action, node, id); + id = id.checked_add(2).unwrap_or(i32::MAX); + } + let ObjectType::DynamicBlock(object) = document.objects.get_mut(&graph).unwrap() else { + unreachable!() + }; + object.data = DynamicBlockData::EvaluationGraph(g); + Ok(()) +} diff --git a/src/scene/block_lookup/tests.rs b/src/scene/block_lookup/tests.rs new file mode 100644 index 000000000..f2585c7c5 --- /dev/null +++ b/src/scene/block_lookup/tests.rs @@ -0,0 +1,365 @@ +use super::*; +use crate::scene::{block_basepoint, block_distance_parameter as distance, render_graph, Scene}; +use codec::{ + entities::{Insert, Line}, + EntityType, +}; + +fn fixture() -> (CadDocument, Handle) { + let mut scene = Scene::new(); + scene.define_block_raw( + "LOOKUP", + Vector3::ZERO, + vec![EntityType::Line(Line::from_points( + Vector3::ZERO, + Vector3::UNIT_X, + ))], + ); + let definition = scene.document.block_records.get("LOOKUP").unwrap().handle; + (scene.document, definition) +} +fn add_distance(document: &mut CadDocument, definition: Handle) { + distance::create( + document, + definition, + distance::Kind::Linear, + Vector3::ZERO, + Vector3::new(2.0, 0.0, 0.0), + Vector3::UNIT_Y, + ) + .unwrap(); +} +fn draft(document: &CadDocument, definition: Handle, parameter: Handle) -> BlockLookupAction { + let p = parameters(document, definition) + .into_iter() + .find(|(h, _)| *h == parameter) + .unwrap() + .1; + let mut table = empty_table(p); + table + .columns + .insert(0, properties(document, definition)[0].column.clone()); + table.column_count = 2; + table.row_count = 2; + table.expressions = vec!["2".into(), "Small".into(), "4".into(), "Large".into()]; + table +} +fn graph_data(document: &CadDocument, definition: Handle) -> codec::objects::BlockEvaluationGraph { + let ObjectType::DynamicBlock(o) = + &document.objects[&graph_handle(document, definition).unwrap()] + else { + panic!() + }; + let DynamicBlockData::EvaluationGraph(g) = &o.data else { + panic!() + }; + g.clone() +} +fn assert_links(document: &CadDocument, definition: Handle) { + let g = graph_data(document, definition); + for n in &g.nodes { + assert!(graph::valid_lists(&g, n.id), "invalid node {}", n.id); + } + for edge in g.edges.iter().filter(|e| e.next_id == 4) { + let inverse = g + .edges + .iter() + .find(|e| e.id == edge.outgoing_edges[4]) + .unwrap(); + assert_eq!( + ( + inverse.source_node, + inverse.destination_node, + inverse.outgoing_edges[4] + ), + (edge.destination_node, edge.source_node, edge.id) + ); + } +} +fn roundtrip(document: &CadDocument, definition: Handle) { + let graph = graph_handle(document, definition).unwrap(); + for extension in ["dwg", "dxf"] { + let bytes = crate::io::save_to_bytes(document, extension, document.version).unwrap(); + let read = crate::io::load_bytes(&format!("lookup.{extension}"), bytes).unwrap(); + for (h, o) in &document.objects { + if let ObjectType::DynamicBlock(o) = o { + if o.owner == graph || o.handle == graph { + let ObjectType::DynamicBlock(actual) = &read.objects[h] else { + panic!() + }; + assert_eq!(actual.data, o.data, "{extension}: {h}"); + assert_eq!(actual.owner, o.owner); + } + } + } + assert_links(&read, definition); + assert_eq!( + block_basepoint::parameter(&read, definition), + block_basepoint::parameter(document, definition) + ); + } +} +#[test] +fn lookup_basepoint_native_roundtrip_rebinding_and_preservation() { + let (mut document, definition) = fixture(); + add_distance(&mut document, definition); + let original = document.clone(); + let base = + block_basepoint::create(&mut document, definition, Vector3::new(3.0, 4.0, 0.0)).unwrap(); + let parameter = create(&mut document, definition, Vector3::UNIT_Y).unwrap(); + let edited = draft(&document, definition, parameter); + let action = apply_table(&mut document, definition, parameter, edited).unwrap(); + assert_eq!(document.entities().count(), original.entities().count()); + assert_eq!( + document.block_records.get("LOOKUP"), + original.block_records.get("LOOKUP") + ); + let gh = graph_handle(&document, definition).unwrap(); + for (h, o) in &original.objects { + if *h != gh { + assert_eq!(document.objects.get(h), Some(o), "preserved {h}"); + } + } + let first = graph_data(&document, definition); + assert_eq!(first.nodes.len(), 10); // distance four, base one, lookup four, action one + assert_eq!(first.edges.iter().filter(|e| e.next_id == 4).count(), 4); + assert_links(&document, definition); + let mut edited = table(&document, definition, parameter).unwrap().unwrap().1; + edited.expressions[1] = "Compact".into(); + assert_eq!( + apply_table(&mut document, definition, parameter, edited.clone()).unwrap(), + action + ); + assert_eq!( + graph_data(&document, definition), + first, + "row edits preserve graph IDs and links" + ); + add_distance(&mut document, definition); + edited + .columns + .insert(1, properties(&document, definition)[1].column.clone()); + edited.column_count = 3; + edited.expressions = vec![ + "2".into(), + "2".into(), + "Compact".into(), + "4".into(), + "2".into(), + "Large".into(), + ]; + apply_table(&mut document, definition, parameter, edited.clone()).unwrap(); + assert_links(&document, definition); + assert_eq!( + graph_data(&document, definition) + .edges + .iter() + .filter(|e| e.next_id == 4) + .count(), + 6 + ); + edited.columns.remove(0); + edited.column_count = 2; + edited.expressions = vec!["2".into(), "Compact".into(), "4".into(), "Large".into()]; + apply_table(&mut document, definition, parameter, edited).unwrap(); + assert_links(&document, definition); + assert_eq!( + graph_data(&document, definition) + .edges + .iter() + .filter(|e| e.next_id == 4) + .count(), + 4 + ); + assert_eq!( + block_basepoint::create(&mut document, definition, Vector3::UNIT_X).unwrap(), + base + ); + roundtrip(&document, definition); +} +#[test] +fn lookup_audit_and_failed_binding_are_atomic() { + let (mut document, definition) = fixture(); + add_distance(&mut document, definition); + let parameter = create(&mut document, definition, Vector3::ZERO).unwrap(); + let good = draft(&document, definition, parameter); + for (cell, value) in [(0, "NaN"), (0, "inf"), (0, ""), (2, "2.0"), (3, "small")] { + let mut bad = good.clone(); + bad.expressions[cell] = value.into(); + let before = document.clone(); + assert!(apply_table(&mut document, definition, parameter, bad).is_err()); + assert_eq!(document, before); + } + let mut bad = good.clone(); + bad.columns[0].node_id = i32::MAX; + let before = document.clone(); + assert!(apply_table(&mut document, definition, parameter, bad).is_err()); + assert_eq!(document, before); + let mut forward_only = good; + forward_only.columns[1].writable = false; + forward_only.expressions[3] = "Small".into(); + assert!(validate(&forward_only, None).is_ok()); +} + +#[test] +fn lookup_malformed_dependencies_are_rejected_without_mutation() { + let (mut document, definition) = fixture(); + add_distance(&mut document, definition); + let parameter = create(&mut document, definition, Vector3::ZERO).unwrap(); + let edited = draft(&document, definition, parameter); + apply_table(&mut document, definition, parameter, edited).unwrap(); + let mut edited = table(&document, definition, parameter).unwrap().unwrap().1; + edited.expressions[1] = "Changed".into(); + let handle = graph_handle(&document, definition).unwrap(); + let ObjectType::DynamicBlock(object) = document.objects.get_mut(&handle).unwrap() else { + panic!() + }; + let DynamicBlockData::EvaluationGraph(g) = &mut object.data else { + panic!() + }; + g.edges + .iter_mut() + .find(|e| e.next_id == 4) + .unwrap() + .outgoing_edges[4] = -1; + let before = document.clone(); + assert!(apply_table(&mut document, definition, parameter, edited).is_err()); + assert_eq!(document, before); +} + +#[test] +fn lookup_existing_expressions_survive_row_reordering_and_output_edits() { + let (mut document, definition) = fixture(); + add_distance(&mut document, definition); + let parameter = create(&mut document, definition, Vector3::ZERO).unwrap(); + let mut original = draft(&document, definition, parameter); + original.expressions[0] = "[1,3]".into(); + original.expressions[2] = "[4,6]".into(); + let mut edited = original.clone(); + edited.expressions.rotate_left(2); + edited.expressions[1] = "Renamed".into(); + assert!(validate(&edited, Some(&original)).is_ok()); + edited.expressions[0] = "[7,9]".into(); + assert!(validate(&edited, Some(&original)).is_err()); +} +#[test] +fn lookup_basepoint_anchor_transform_and_reframe() { + let (mut document, definition) = fixture(); + let original = document.clone(); + let point = Vector3::new(3.0, 4.0, 2.0); + let base = block_basepoint::create(&mut document, definition, point).unwrap(); + let objects = document.objects.len(); + assert_eq!( + block_basepoint::create(&mut document, definition, point).unwrap(), + base + ); + assert_eq!(document.objects.len(), objects); + let mut insert = Insert::new("LOOKUP", Vector3::new(11.0, 13.0, 17.0)); + insert.rotation = 1.2; + insert.set_x_scale(2.0); + insert.set_y_scale(3.0); + insert.set_z_scale(4.0); + assert!( + (render_graph::insert_transform(&document, &insert).apply(point) - insert.insert_point) + .length() + < 1e-9 + ); + assert_eq!(document.entities().count(), original.entities().count()); + for original in original.entities() { + let EntityType::Line(before) = original else { + panic!() + }; + let EntityType::Line(after) = document.get_entity(before.common.handle).unwrap() else { + panic!() + }; + assert_eq!( + (after.start, after.end, after.normal, after.thickness), + (before.start, before.end, before.normal, before.thickness), + "native definition tags may be added, geometry stays intact" + ); + } + add_distance(&mut document, definition); + let parameter = create(&mut document, definition, Vector3::UNIT_Y).unwrap(); + let edited = draft(&document, definition, parameter); + apply_table(&mut document, definition, parameter, edited).unwrap(); + let old = table(&document, definition, parameter).unwrap().unwrap().1; + let transform = Transform::from_translation(Vector3::new(10.0, 20.0, 0.0)); + assert_eq!( + block_basepoint::reframe(&mut document, definition, &transform), + 1 + ); + assert_eq!(reframe(&mut document, definition, &transform), 3); + let new = table(&document, definition, parameter).unwrap().unwrap().1; + assert_eq!(old.columns, new.columns); + assert_eq!(old.expressions, new.expressions); + assert_eq!( + new.action.display_location, + transform.apply(old.action.display_location) + ); + assert_eq!( + block_basepoint::parameter(&document, definition) + .unwrap() + .1 + .point, + transform.apply(point) + ); + let before = document.clone(); + assert!( + block_basepoint::create(&mut document, definition, Vector3::new(f64::NAN, 0.0, 0.0)) + .is_err() + ); + assert!(create( + &mut document, + definition, + Vector3::new(0.0, f64::INFINITY, 0.0) + ) + .is_err()); + assert_eq!(document, before); +} + +#[test] +fn lookup_basepoint_existing_attributes_follow_anchor_and_editor_frame() { + use codec::entities::{AttributeDefinition, AttributeEntity}; + let (document, definition) = fixture(); + let mut scene = Scene::new(); + scene.document = document; + let local = Vector3::new(7.0, 9.0, 0.0); + let mut attdef = AttributeDefinition::new("TAG".into(), "Prompt".into(), "Value".into()); + attdef.insertion_point = local; + attdef.common.owner_handle = definition; + scene + .document + .add_entity(EntityType::AttributeDefinition(attdef)) + .unwrap(); + let mut insert = Insert::new("LOOKUP", Vector3::new(10.0, 20.0, 0.0)).with_scale(2.0, 3.0, 1.0); + insert.rotation = 0.5; + let mut attribute = AttributeEntity::simple("TAG", "Value"); + attribute.insertion_point = + render_graph::insert_transform(&scene.document, &insert).apply(local); + insert.attributes.push(attribute); + let handle = scene + .document + .add_entity(EntityType::Insert(insert)) + .unwrap(); + block_basepoint::create(&mut scene.document, definition, Vector3::new(3.0, 4.0, 0.0)).unwrap(); + let Some(EntityType::Insert(i)) = scene.document.get_entity(handle) else { + panic!() + }; + assert!( + (i.attributes[0].insertion_point + - render_graph::insert_transform(&scene.document, i).apply(local)) + .length() + < 1e-9 + ); + let transform = Transform::from_translation(Vector3::new(-1.0, -2.0, 0.0)); + scene.reframe_block_definition(definition, &transform); + let Some(EntityType::Insert(i)) = scene.document.get_entity(handle) else { + panic!() + }; + assert!( + (i.attributes[0].insertion_point + - render_graph::insert_transform(&scene.document, i).apply(transform.apply(local))) + .length() + < 1e-9 + ); +} diff --git a/src/scene/camera_ops.rs b/src/scene/camera_ops.rs index 179e4261b..5b7a6182d 100644 --- a/src/scene/camera_ops.rs +++ b/src/scene/camera_ops.rs @@ -1703,6 +1703,18 @@ impl Scene { fold!(Some((point, point))); } } + let locations = super::block_basepoint::parameter(&self.document, block) + .map(|(_, p)| p.parameter.definition_point) + .into_iter() + .chain( + super::block_lookup::parameters(&self.document, block) + .into_iter() + .map(|(_, p)| p.parameter.definition_point), + ); + for point in locations { + let point = glam::DVec3::new(point.x, point.y, point.z); + fold!(Some((point, point))); + } if let Some(parameter) = super::block_visibility::parameter(&self.document, block) { let point = parameter.def_point; let point = glam::DVec3::new(point.x, point.y, point.z); diff --git a/src/scene/mod.rs b/src/scene/mod.rs index b79bf2ae8..4dbd72a32 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -11,6 +11,8 @@ pub(crate) mod block_metadata; pub mod block_service; pub mod block_visibility; pub(crate) mod block_authoring; +pub(crate) mod block_basepoint; +pub(crate) mod block_lookup; pub(crate) mod block_point_parameter; pub(crate) mod block_distance_parameter; pub(crate) mod block_orientation_parameter; diff --git a/src/scene/modify.rs b/src/scene/modify.rs index e2ff5f3fc..7a95f3fea 100644 --- a/src/scene/modify.rs +++ b/src/scene/modify.rs @@ -507,9 +507,14 @@ impl Scene { } } + let old_base = crate::scene::render_graph::block_base_point(&self.document, &block_name); + let basepoint_changes = + super::block_basepoint::reframe(&mut self.document, block_record, local_from_old); // ATTRIBs live inline on each INSERT in that reference's owner space. // Conjugate the block-local reframe through the INSERT transform: - // owner' = M · local_from_old · M⁻¹ · owner. M is the block-local → + // owner' = M_new · local_from_old · M_old⁻¹ · owner. A native + // Basepoint follows the frame too, so the two maps can differ. + // M is the block-local → // world map the render path uses (`insert_transform`), because that is // the map the attribute positions on the INSERT were placed through; // the codec's base-ignoring transform would conjugate them through a @@ -523,9 +528,11 @@ impl Scene { && !reference.attributes.is_empty() && !owned.contains(&reference.common.handle) => { + let old_insertion = + crate::scene::render_graph::insert_transform_with_base(reference, old_base); let insertion = crate::scene::render_graph::insert_transform(&self.document, reference); - let inverse = inverse_affine(&insertion)?; + let inverse = inverse_affine(&old_insertion)?; Some(( reference.common.handle, codec::types::Transform::from_matrix( @@ -571,6 +578,9 @@ impl Scene { block_record, local_from_old, ); + let parameter_changes = parameter_changes + + basepoint_changes + + super::block_lookup::reframe(&mut self.document, block_record, local_from_old); if !changed.is_empty() || parameter_changes > 0 { self.rebuild_derived_caches(); } diff --git a/src/scene/render_graph.rs b/src/scene/render_graph.rs index ec4884098..367c1ae16 100644 --- a/src/scene/render_graph.rs +++ b/src/scene/render_graph.rs @@ -621,6 +621,10 @@ pub fn block_base_point(document: &CadDocument, block_name: &str) -> Vector3 { .iter() .find(|record| record.name.eq_ignore_ascii_case(block_name)) .map(|record| { + if let Some((_, parameter)) = super::block_basepoint::parameter(document, record.handle) + { + return parameter.point; + } document .get_entity(record.block_entity_handle) .and_then(|entity| match entity { diff --git a/src/ui/overlay.rs b/src/ui/overlay.rs index 3a5103c17..826708911 100644 --- a/src/ui/overlay.rs +++ b/src/ui/overlay.rs @@ -1023,6 +1023,8 @@ pub(crate) fn selection_overlay<'a>( pub(crate) struct AuthoringDimension { pub lines: Vec<[Point; 2]>, pub round_grips: Vec, + pub base_grips: Vec, + pub lookup_grips: Vec, pub dimensions: Vec<[Point; 2]>, pub labels: Vec<(Point, String)>, pub grips: Vec<(Point, Option)>, @@ -2414,6 +2416,35 @@ impl canvas::Program for SelectionCanvas { ..Default::default() }); } + for point in &dimension.base_grips { + let stroke = canvas::Stroke::default().with_color(color); + frame.stroke(&canvas::Path::circle(*point, 6.0), stroke); + frame.stroke( + &canvas::Path::line( + Point::new(point.x - 9.0, point.y), + Point::new(point.x + 9.0, point.y), + ), + stroke, + ); + frame.stroke( + &canvas::Path::line( + Point::new(point.x, point.y - 9.0), + Point::new(point.x, point.y + 9.0), + ), + stroke, + ); + } + for point in &dimension.lookup_grips { + frame.fill( + &canvas::Path::new(|p| { + p.move_to(Point::new(point.x - 5.0, point.y - 4.0)); + p.line_to(Point::new(point.x + 5.0, point.y - 4.0)); + p.line_to(Point::new(point.x, point.y + 5.0)); + p.close(); + }), + color, + ); + } for point in &dimension.round_grips { frame.fill(&canvas::Path::circle(*point, 3.5), color); } diff --git a/src/ui/window/authoring_palettes.rs b/src/ui/window/authoring_palettes.rs index a8eb1e8ee..2d37e9d54 100644 --- a/src/ui/window/authoring_palettes.rs +++ b/src/ui/window/authoring_palettes.rs @@ -193,6 +193,7 @@ fn catalog( visibility_error: Option<&'static str>, point_error: Option<&'static str>, flip_error: Option<&'static str>, + lookup_error: Option<&'static str>, ) -> Element<'static, Message> { let (entries, unavailable) = match tab { Tab::Parameters => ( @@ -212,7 +213,9 @@ fn catalog( let distance = tab == Tab::Parameters && matches!(*label, "Linear" | "Polar" | "XY"); let orientation = tab == Tab::Parameters && matches!(*label,"Rotation"|"Alignment"|"Flip"); let orientation_error = if *label=="Flip" {flip_error} else {point_error}; - let enabled = (orientation && orientation_error.is_none()) || (visibility && visibility_error.is_none()) || ((point || distance) && point_error.is_none()); + let single=tab==Tab::Parameters&&matches!(*label,"Lookup"|"Basepoint"); + let lookup_action=tab==Tab::Actions&&*label=="Lookup"; + let enabled = (single&&point_error.is_none())||(lookup_action&&lookup_error.is_none())||(orientation && orientation_error.is_none()) || (visibility && visibility_error.is_none()) || ((point || distance) && point_error.is_none()); let base = if enabled { icons::semantic(icon, 28.0) } else { @@ -221,10 +224,11 @@ fn catalog( let icon = if tab != Tab::Parameters && *label != "Block Properties Table" { stack![ base, - container(icons::semantic_disabled( - include_bytes!("../../../assets/icons/ui/bolt.svg"), - 14.0 - )) + container(if enabled { + icons::semantic(include_bytes!("../../../assets/icons/ui/bolt.svg"), 14.0) + } else { + icons::semantic_disabled(include_bytes!("../../../assets/icons/ui/bolt.svg"), 14.0) + }) .width(28) .height(28) .align_right(Fill) @@ -235,8 +239,8 @@ fn catalog( base }; let message = enabled.then(|| Message::RibbonToolClick { - tool_id: format!("BPARAMETER_{}", label.to_ascii_uppercase()), - event: crate::modules::ModuleEvent::Command(format!("BPARAMETER {}", label.to_ascii_uppercase())), + tool_id: if lookup_action {"BLOOKUPTABLE".into()}else{format!("BPARAMETER_{}", label.to_ascii_uppercase())}, + event: crate::modules::ModuleEvent::Command(if lookup_action {"BLOOKUPTABLE".into()}else{format!("BPARAMETER {}", label.to_ascii_uppercase())}), }); let description = if visibility { crate::t!(visibility_error @@ -253,6 +257,10 @@ fn catalog( "Alignment" => "Place the alignment base point and tangent direction.", _ => "Place a reflection line and its flip-state label. Associate a Flip action to mirror geometry.", })).into_owned() + } else if single { + crate::t!(point_error.unwrap_or(if *label=="Basepoint" {"Place or relocate the dynamic block insertion anchor."}else{"Place a Lookup parameter, then use the Lookup action to edit its property table."})).into_owned() + } else if lookup_action { + crate::t!(lookup_error.unwrap_or("Create or edit property Lookup tables for this block.")).into_owned() } else { unavailable.to_string() }; @@ -265,6 +273,10 @@ fn catalog( container(entry).id(match *label { "Linear" => "authoring-tool-linear", "Polar" => "authoring-tool-polar", _ => "authoring-tool-xy" }).into() } else if orientation { container(entry).id(match *label {"Rotation"=>"authoring-tool-rotation","Alignment"=>"authoring-tool-alignment",_=>"authoring-tool-flip"}).into() + } else if single { + container(entry).id(if *label=="Lookup" {"authoring-tool-lookup"}else{"authoring-tool-basepoint"}).into() + } else if lookup_action { + container(entry).id("authoring-tool-lookup-table").into() } else { entry } @@ -378,6 +390,7 @@ pub fn view( visibility_error: Option<&'static str>, point_error: Option<&'static str>, flip_error: Option<&'static str>, + lookup_error: Option<&'static str>, ) -> Element<'static, Message> { let tabs = column(Tab::ALL.into_iter().map(|category| { let label = category.label(); @@ -431,7 +444,8 @@ pub fn view( selected_tab, visibility_error, point_error, - flip_error + flip_error, + lookup_error )) .width(Fill) .height(Fill), diff --git a/src/ui/window/block_lookup.rs b/src/ui/window/block_lookup.rs new file mode 100644 index 000000000..f0a164d1f --- /dev/null +++ b/src/ui/window/block_lookup.rs @@ -0,0 +1,319 @@ +use crate::app::Message; +use crate::scene::block_lookup::{self, Property}; +use codec::{ + objects::{BlockLookupAction, BlockLookupParameter}, + Handle, +}; +use iced::{ + widget::{ + button, checkbox, column, container, pick_list, row, scrollable, text, text_input, Space, + }, + Element, Fill, +}; + +#[derive(Debug, Clone)] +pub enum Action { + Select(usize), + Property(usize), + AddProperty, + RemoveProperty(usize), + Cell(usize, usize, String), + AddRow, + DeleteRow(usize), + MoveRow(usize, bool), + Reverse(usize, bool), + Audit, + Apply, +} + +pub(crate) struct Draft { + pub parameter: Handle, + pub original_parameter: BlockLookupParameter, + pub original: Option<(Handle, BlockLookupAction)>, + pub edited: BlockLookupAction, +} +impl Draft { + pub(crate) fn changed(&self) -> bool { + match &self.original { + Some((_, original)) => original != &self.edited, + None => self.edited != block_lookup::empty_table(&self.original_parameter), + } + } +} +pub(crate) struct State { + pub document: u64, + pub definition: Handle, + pub epoch: u64, + pub drafts: Vec, + pub selected: usize, + pub properties: Vec, + pub selected_property: Option, + pub error: Option, + pub notice: Option, +} + +impl State { + pub(crate) fn edit(&mut self, action: Action) { + self.error = None; + self.notice = None; + match action { + Action::Select(index) if index < self.drafts.len() => { + self.selected = index; + self.selected_property = None; + return; + } + Action::Property(index) if index < self.properties.len() => { + self.selected_property = Some(index); + return; + } + _ => {} + } + let Some(draft) = self.drafts.get_mut(self.selected) else { + return; + }; + let table = &mut draft.edited; + let cols = table.columns.len(); + let rows = table.row_count.max(0) as usize; + match action { + Action::AddProperty => { + let Some(property) = self.selected_property.and_then(|i| self.properties.get(i)) + else { + return; + }; + if cols >= 64 + || table.columns.iter().any(|c| { + c.node_id == property.column.node_id + && c.connection_name == property.column.connection_name + }) + { + return; + } + let index = table + .columns + .iter() + .position(|c| c.lookup_property) + .unwrap_or(cols); + for row in (0..rows).rev() { + table + .expressions + .insert(row * cols + index, property.value.clone()); + } + table.columns.insert(index, property.column.clone()); + table.column_count += 1; + self.selected_property = None; + } + Action::RemoveProperty(index) + if table.columns.get(index).is_some_and(|c| !c.lookup_property) => + { + for row in (0..rows).rev() { + table.expressions.remove(row * cols + index); + } + table.columns.remove(index); + table.column_count -= 1; + } + Action::Cell(row, col, value) if row < rows && col < cols => { + table.expressions[row * cols + col] = value; + } + Action::AddRow if rows < 1000 => { + for column in &table.columns { + let value = if column.lookup_property { + format!("Value{}", rows + 1) + } else { + self.properties + .iter() + .find(|p| { + p.column.node_id == column.node_id + && p.column.connection_name == column.connection_name + }) + .map(|p| p.value.clone()) + .unwrap_or_default() + }; + table.expressions.push(value); + } + table.row_count += 1; + } + Action::DeleteRow(row) if row < rows => { + table.expressions.drain(row * cols..(row + 1) * cols); + table.row_count -= 1; + } + Action::MoveRow(row, up) if row < rows => { + let next = if up { + row.checked_sub(1) + } else { + (row + 1 < rows).then_some(row + 1) + }; + if let Some(next) = next { + for col in 0..cols { + table.expressions.swap(row * cols + col, next * cols + col); + } + } + } + Action::Reverse(index, value) + if table.columns.get(index).is_some_and(|c| c.lookup_property) => + { + table.columns[index].writable = value; + } + Action::Audit => { + match block_lookup::validate(table, draft.original.as_ref().map(|(_, t)| t)) { + Ok(()) => self.notice = Some(crate::t!("Table audit passed.").into_owned()), + Err(error) => self.error = Some(crate::t!(error.as_str()).into_owned()), + } + } + _ => {} + } + } +} + +pub(crate) fn view(state: &State, sizing: crate::ui::modal::ModalSizing) -> Element<'_, Message> { + let send = |a| Message::BlockLookup(a); + let draft = &state.drafts[state.selected]; + let table = &draft.edited; + let cols = table.columns.len(); + let rows = table.row_count.max(0) as usize; + let mut headings = row![text(" ").width(28)].spacing(6); + for (index, column) in table.columns.iter().enumerate() { + let name = if column.lookup_property { + state + .drafts + .iter() + .find(|d| { + d.original_parameter + .parameter + .parameter + .element + .eval + .node_id + == column.node_id + }) + .map(|d| d.original_parameter.lookup_name.clone()) + .unwrap_or_else(|| crate::t!("Lookup value").into_owned()) + } else { + state + .properties + .iter() + .find(|p| { + p.column.node_id == column.node_id + && p.column.connection_name == column.connection_name + }) + .map(|p| p.label.clone()) + .unwrap_or_else(|| crate::t!("Input property").into_owned()) + }; + let mut header = column![text(name).size(13)]; + if column.lookup_property { + header = header.push( + row![ + checkbox(column.writable).on_toggle(move |v| send(Action::Reverse(index, v))), + text(crate::t!("Reverse lookup").into_owned()).size(11) + ] + .spacing(4), + ); + } else { + header = header.push( + button(text(crate::t!("Remove").into_owned()).size(11)) + .on_press(send(Action::RemoveProperty(index))), + ); + } + headings = headings.push(container(header.spacing(4)).width(160)); + } + let mut grid = column![headings].spacing(7); + for row_index in 0..rows { + let mut cells = row![text((row_index + 1).to_string()).width(28)].spacing(6); + for col_index in 0..cols { + cells = cells.push( + text_input("", &table.expressions[row_index * cols + col_index]) + .on_input(move |v| send(Action::Cell(row_index, col_index, v))) + .width(160) + .style(crate::ui::style::form::field_style), + ); + } + cells = + cells + .push(button(text("↑")).on_press_maybe( + (row_index > 0).then_some(send(Action::MoveRow(row_index, true))), + )) + .push(button(text("↓")).on_press_maybe( + (row_index + 1 < rows).then_some(send(Action::MoveRow(row_index, false))), + )) + .push( + button(text(crate::t!("Delete").into_owned())) + .on_press(send(Action::DeleteRow(row_index))), + ); + grid = grid.push(cells); + } + let available = state + .properties + .iter() + .enumerate() + .filter(|(_, p)| { + !table.columns.iter().any(|c| { + c.node_id == p.column.node_id && c.connection_name == p.column.connection_name + }) + }) + .map(|(i, _)| i) + .collect::>(); + container( + column![ + row![ + text(crate::t!("Lookup parameter").into_owned()), + pick_list( + Some(state.selected), + (0..state.drafts.len()).collect::>(), + |i: &usize| state.drafts[*i].original_parameter.lookup_name.clone() + ) + .on_select(move |i| send(Action::Select(i))) + .width(Fill) + ] + .spacing(10), + row![ + pick_list(state.selected_property, available, |i: &usize| state + .properties[*i] + .label + .clone()) + .on_select(move |i| send(Action::Property(i))) + .placeholder(crate::t!("Choose input property").into_owned()) + .width(Fill), + button(text(crate::t!("Add Property").into_owned())) + .on_press_maybe(state.selected_property.map(|_| send(Action::AddProperty))) + ] + .spacing(8), + text( + crate::t!( + "Add Linear or Polar distance inputs. Existing table columns are retained." + ) + .into_owned() + ) + .size(12), + scrollable(grid) + .direction(scrollable::Direction::Both { + vertical: Default::default(), + horizontal: Default::default() + }) + .height(Fill) + .width(Fill), + row![ + button(text(crate::t!("Add Row").into_owned())) + .on_press_maybe((rows < 1000).then_some(send(Action::AddRow))), + button(text(crate::t!("Audit").into_owned())).on_press(send(Action::Audit)), + Space::new().width(Fill) + ] + .spacing(8), + text(state.error.clone().unwrap_or_default()) + .size(12) + .style(text::danger), + text(state.notice.clone().unwrap_or_default()).size(12), + row![ + Space::new().width(Fill), + button(text(crate::t!("OK").into_owned())) + .on_press(send(Action::Apply)) + .style(button::primary), + button(text(crate::t!("Cancel").into_owned())).on_press(Message::CloseModal) + ] + .spacing(8), + ] + .spacing(12), + ) + .padding(16) + .width(sizing.width) + .height(sizing.height) + .into() +} diff --git a/src/ui/window/mod.rs b/src/ui/window/mod.rs index 503c7bddd..03ba407e4 100644 --- a/src/ui/window/mod.rs +++ b/src/ui/window/mod.rs @@ -11,6 +11,7 @@ pub mod block_edit_picker; pub mod block_palette; pub mod block_save_as; pub mod block_visibility; +pub mod block_lookup; pub mod browser; pub mod count_palette; pub mod drafting_settings; From f69aefc3dfb04bab8e6c32315ebd129578fdf210 Mon Sep 17 00:00:00 2001 From: Karim Jerbi Date: Fri, 16 Oct 2026 16:41:22 +0100 Subject: [PATCH 2/4] feat(block-editor): author Move and Scale actions with Test Block evaluation Add BACTION/BACTIONTOOL workflows for compatible parameter endpoints, selected geometry and native action markers. Persist Move/Scale records, target handles and evaluation graph bindings; reuse shared adjacency editing with Lookup and the existing palette, tooltips and selection UI. Evaluate endpoint changes in the disposable Test Block document from immutable starting geometry, with geometry/grip undo, source isolation and transformed-reference support. Preserve visibility representations and their native Basepoint anchor. Refuse unsupported options, value constraints and shared test references. This milestone supports default Move and uniform Scale on basic drawing entities. Other actions, custom options, live preview and ordinary drawing-reference evaluation remain pending. Validation: self-contained synthetic DWG/DXF roundtrips, graph adjacency, command cancellation/history, Test Block isolation/undo, transformed references, visibility/Basepoint and real-view palette/viewport input checks; broader block and dock suites. Keep action object selection active across mouse picks/removals and advance only on Enter. Cover empty selection, multiple targets, Shift removal, marker placement and Move/Scale Test Block evaluation using viewport mouse events. Verified 10 focused, 320 block and 39 dock tests (6 existing opt-in block checks ignored). --- src/app/block_actions.rs | 265 ++++++++ src/app/block_editor_tests.rs | 386 ++++++++++++ src/app/block_test.rs | 11 +- src/app/command_driver/mod.rs | 4 + src/app/commands/bvisibility.rs | 11 + src/app/commands/mod.rs | 16 + src/app/dock/tests.rs | 138 +++++ src/app/dock/view.rs | 2 + src/app/mod.rs | 1 + src/app/properties.rs | 1 + src/app/update/viewport.rs | 14 +- src/app/view/mod.rs | 14 + src/command.rs | 14 + src/modules/block_editor/action.rs | 203 +++++++ src/modules/block_editor/mod.rs | 1 + src/scene/block_actions.rs | 568 ++++++++++++++++++ src/scene/block_actions/tests.rs | 214 +++++++ src/scene/block_authoring.rs | 1 + .../graph.rs | 32 +- src/scene/block_lookup.rs | 10 +- src/scene/block_visibility.rs | 13 +- src/scene/camera_ops.rs | 5 + src/scene/mod.rs | 2 + src/scene/modify.rs | 7 +- src/ui/window/authoring_palettes.rs | 20 +- 25 files changed, 1914 insertions(+), 39 deletions(-) create mode 100644 src/app/block_actions.rs create mode 100644 src/modules/block_editor/action.rs create mode 100644 src/scene/block_actions.rs create mode 100644 src/scene/block_actions/tests.rs rename src/scene/{block_lookup => block_authoring}/graph.rs (86%) diff --git a/src/app/block_actions.rs b/src/app/block_actions.rs new file mode 100644 index 000000000..b838796a4 --- /dev/null +++ b/src/app/block_actions.rs @@ -0,0 +1,265 @@ +use super::{Message, OpenCADStudio}; +use crate::command::CadCommand; +use crate::scene::block_actions::{self as actions, Kind}; +use codec::{EntityType, Handle}; +use iced::Task; + +// These grips start an ordinary point-input command, keeping snapping, typed +// coordinates and Escape on the command driver's existing path. +const TEST_GRIP_START: usize = usize::MAX / 2; +pub(super) fn is_test_grip(id: usize) -> bool { + (TEST_GRIP_START..usize::MAX).contains(&id) +} +impl OpenCADStudio { + pub(in crate::app) fn action_error(&self, kind: Option) -> Option<&'static str> { + let tab = &self.tabs[self.active_tab]; + let Some(s) = tab.active_block_edit_session() else { + return Some("Open a block in the Block Editor before adding actions."); + }; + if let Some(e) = + crate::scene::block_point_parameter::placement_error(&tab.scene.document, s.br_handle) + { + return Some(e); + } + if actions::parameters(&tab.scene.document, s.br_handle, kind.unwrap_or(Kind::Move)) + .is_empty() + { + return Some("Place a compatible parameter before adding this action."); + } + None + } + pub(in crate::app) fn start_block_action(&mut self, args: &str) -> Task { + let kind = if args.is_empty() { + None + } else { + Kind::parse(args) + }; + if !args.is_empty() && kind.is_none() { + self.command_line.push_error( + crate::t!("Supported actions: Move, Scale. Other action types are not wired yet.") + .as_ref(), + ); + return Task::none(); + } + if let Some(e) = self.action_error(kind) { + self.command_line.push_error(crate::t!(e).as_ref()); + return Task::none(); + } + let tab = &self.tabs[self.active_tab]; + let definition = tab.active_block_edit_session().unwrap().br_handle; + let command = crate::modules::block_editor::action::ActionCommand::new( + tab.id, + definition, + kind, + actions::parameters(&tab.scene.document, definition, kind.unwrap_or(Kind::Move)), + ); + self.command_line.push_info(&command.prompt()); + self.command_line.set_step_options(command.options()); + self.tabs[self.active_tab].active_cmd = Some(Box::new(command)); + Task::none() + } + pub(in crate::app) fn handle_block_action_result( + &mut self, + result: crate::command::CmdResult, + ) -> Task { + let i = self.active_tab; + match result { + crate::command::CmdResult::PlaceBlockAction { + document, + definition, + kind, + parameter, + targets, + location, + } => { + if self.tabs[i].id != document + || self.tabs[i] + .active_block_edit_session() + .map(|s| s.br_handle) + != Some(definition) + { + self.command_line.push_error( + crate::t!("The action's Block Editor session is no longer active.") + .as_ref(), + ); + return Task::none(); + } + let mut staged = self.tabs[i].scene.document.clone(); + match actions::create(&mut staged, definition, kind, parameter, &targets, location) + { + Ok(_) => { + self.push_undo_snapshot(i, &format!("{} action", kind.name())); + self.tabs[i].scene.document = staged; + self.tabs[i].scene.bump_geometry(); + self.tabs[i].dirty = true; + self.tabs[i].active_cmd = None; + self.tabs[i].scene.clear_preview_wire(); + self.finish_pending_history(i); + self.refresh_properties(); + self.command_line.push_output(crate::tf!("{} action attached. Open Test Block and click the parameter grip to test it.",crate::t!(kind.name())).as_ref()); + } + Err(e) => self.command_line.push_error(crate::t!(e).as_ref()), + } + } + crate::command::CmdResult::EvaluateBlockParameter { + document, + definition, + parameter, + position, + } => { + if self.tabs[i].id != document { + return Task::none(); + } + let Some(test) = self.tabs[i].block_test.as_ref() else { + return Task::none(); + }; + if test.definition != definition { + return Task::none(); + } + let Some(EntityType::Insert(insert)) = + self.tabs[i].scene.document.get_entity(test.insert) + else { + return Task::none(); + }; + let doc = &self.tabs[i].scene.document; + if doc.entities().any(|e|matches!(e,EntityType::Insert(other) if other.common.handle!=test.insert && (other.block_name==insert.block_name||doc.dynamic_definition_for_insert(other.common.handle)==Some(definition)))) { + self.command_line.push_error(crate::t!("Test actions with one reference in Test Block. Remove the extra references or reopen Test Block.").as_ref());return Task::none(); + } + let Some(inverse) = + crate::scene::inverse_affine(&crate::scene::render_graph::insert_transform( + &self.tabs[i].scene.document, + insert, + )) + else { + self.command_line.push_error( + crate::t!("The test reference has a singular transform.").as_ref(), + ); + return Task::none(); + }; + let local = actions::v(inverse.apply(actions::native(position))); + let representation = insert.block_name.clone(); + let mut staged = self.tabs[i].scene.document.clone(); + match actions::evaluate(&mut staged, &test.baseline, definition, parameter, local) { + Ok(changed) => { + if changed { + if let Err(e) = actions::sync_representation( + &mut staged, + definition, + &representation, + ) { + self.command_line.push_error(crate::t!(e).as_ref()); + return Task::none(); + } + self.finish_pending_history(i); + let before = &self.tabs[i].scene.document; + let entities = before + .entities() + .filter_map(|e| { + let h = e.common().handle; + (staged.get_entity(h) != Some(e)) + .then(|| (h, before.get_entity_arc(h).unwrap())) + }) + .collect(); + let objects = before + .objects + .iter() + .filter(|(h, o)| staged.objects.get(h) != Some(*o)) + .map(|(h, o)| (*h, o.clone())) + .collect(); + let dirty_before = self.tabs[i].dirty; + self.tabs[i].scene.document = staged; + self.tabs[i].scene.bump_geometry(); + self.tabs[i].dirty = true; + self.push_entity_group_history( + i, + "Test block parameter", + entities, + objects, + dirty_before, + ); + } + self.tabs[i].active_cmd = None; + self.tabs[i].scene.clear_preview_wire(); + self.refresh_properties(); + } + Err(e) => self.command_line.push_error(crate::t!(e).as_ref()), + } + } + _ => unreachable!(), + } + Task::none() + } + pub(super) fn refresh_test_action_grips(&mut self, wo: [f64; 3]) { + let i = self.active_tab; + let tab = &self.tabs[i]; + let Some(test) = tab.block_test.as_ref() else { + return; + }; + if tab.selected_handle != Some(test.insert) { + return; + } + let Some(EntityType::Insert(insert)) = tab.scene.document.get_entity(test.insert) else { + return; + }; + let transform = crate::scene::render_graph::insert_transform(&tab.scene.document, insert); + let mut grips = vec![]; + let all = actions::actions(&test.baseline, test.definition); + for (index, p) in actions::parameters(&test.baseline, test.definition, Kind::Move) + .into_iter() + .enumerate() + { + if !all.iter().any(|(_, _, _, id)| *id == p.id) { + continue; + } + let world = actions::v( + transform.apply(actions::native(actions::current(&tab.scene.document, &p))), + ) - glam::DVec3::from_array(wo); + grips.push(crate::scene::model::object::GripDef { + id: TEST_GRIP_START + index, + world, + is_midpoint: false, + shape: crate::scene::model::object::GripShape::Triangle, + dir: None, + axis: None, + }); + } + let handle = test.insert; + let tab = &mut self.tabs[i]; + for grip in grips { + tab.selected_grips.push(grip); + tab.selected_grip_handles.push(handle); + } + } + pub(super) fn start_test_action_grip(&mut self, handle: Handle, id: usize) -> bool { + if !is_test_grip(id) { + return false; + } + let i = self.active_tab; + let tab = &self.tabs[i]; + let Some(test) = tab.block_test.as_ref() else { + return false; + }; + if handle != test.insert { + return false; + } + if tab.scene.is_layer_locked(handle) { + return true; + } + let Some(p) = actions::parameters(&test.baseline, test.definition, Kind::Move) + .get(id - TEST_GRIP_START) + .cloned() + else { + return false; + }; + let command = crate::modules::block_editor::action::TestParameterCommand { + document: tab.id, + definition: test.definition, + parameter: p.handle, + }; + self.command_line.push_info(&command.prompt()); + self.tabs[i].active_cmd = Some(Box::new(command)); + self.grip_hover = None; + self.grip_popup = None; + true + } +} diff --git a/src/app/block_editor_tests.rs b/src/app/block_editor_tests.rs index 4a6255703..54c733751 100644 --- a/src/app/block_editor_tests.rs +++ b/src/app/block_editor_tests.rs @@ -59,6 +59,392 @@ fn feed(app: &mut OpenCADStudio, text: &str) { let _ = app.update(Message::CommandSubmit); } +#[test] +fn block_actions_mouse_selection_waits_for_enter_for_move_and_scale() { + use crate::scene::block_actions::{self as actions, Kind}; + for (kind, command) in [(Kind::Move, "BACTION MOVE"), (Kind::Scale, "BACTION SCALE")] { + let (mut app, reference) = fixture(); + begin(&mut app, reference); + let source = app.active_tab; + let _ = app.run_command_line("CIRCLE 0.5,0.5 0.2"); + let _ = app.run_command_line("BPARAMETER LINEAR 0,0 2,0 1,-0.2"); + let definition = app.tabs[source] + .active_block_edit_session() + .unwrap() + .br_handle; + app.tabs[source].scene.selection.borrow_mut().view.vp_size = (1600., 900.); + app.tabs[source] + .scene + .camera + .borrow_mut() + .fit_to_bounds_f64( + glam::DVec3::new(-0.5, -0.5, 0.), + glam::DVec3::new(2.5, 1.5, 0.), + 1600. / 900., + ); + let _ = app.dispatch_command(command); + let click = |app: &mut OpenCADStudio, point: [f64; 3], modifiers: &str| { + let scene = &app.tabs[app.active_tab].scene; + let (vw, vh) = scene.selection.borrow().view.vp_size; + let tile = scene.active_model_tile_bounds(vw, vh); + let screen = scene + .camera + .borrow() + .project( + glam::DVec3::from_array(point), + iced::Rectangle { + x: 0., + y: 0., + width: tile.width, + height: tile.height, + }, + ) + .unwrap(); + let cursor = iced::Point::new(screen.x + tile.x, screen.y + tile.y); + let previous = (app.ctrl_down, app.shift_down); + app.ctrl_down = modifiers.contains("ctrl"); + app.shift_down = modifiers.contains("shift"); + let _ = app.update(Message::ViewportLeftPressAt(cursor)); + let _ = app.update(Message::ViewportLeftRelease); + (app.ctrl_down, app.shift_down) = previous; + }; + click(&mut app, [2., 0., 0.], ""); + assert!(app.tabs[source] + .active_cmd + .as_ref() + .unwrap() + .is_selection_gathering()); + feed(&mut app, ""); // Empty selection does not cancel or advance. + assert!(app.tabs[source] + .active_cmd + .as_ref() + .unwrap() + .is_selection_gathering()); + for (point, modifiers, count) in [ + ([0.5, 0., 0.], "", 1), + ([0.5, 0.7, 0.], "", 2), + ([0.5, 0., 0.], "shift", 1), + ([0.5, 0., 0.], "", 2), + ] { + click(&mut app, point, modifiers); + assert!( + app.tabs[source] + .active_cmd + .as_ref() + .unwrap() + .is_selection_gathering(), + "every mouse pick/removal stays in object selection" + ); + assert_eq!(app.tabs[source].scene.selected.len(), count); + assert!(actions::actions(&app.tabs[source].scene.document, definition).is_empty()); + } + feed(&mut app, ""); + assert!(!app.tabs[source] + .active_cmd + .as_ref() + .unwrap() + .is_selection_gathering()); + click(&mut app, [1.5, 1., 0.], ""); + assert!(app.tabs[source].active_cmd.is_none()); + let authored = actions::actions(&app.tabs[source].scene.document, definition); + assert_eq!(authored.len(), 1); + assert_eq!(authored[0].2, kind); + assert_eq!(authored[0].1.dependencies.len(), 2); + let original = app.tabs[source].scene.document.clone(); + let _ = app.dispatch_command("BTESTBLOCK"); + let i = app.active_tab; + app.tabs[i].scene.selection.borrow_mut().view.vp_size = (1600., 900.); + app.tabs[i].scene.camera.borrow_mut().fit_to_bounds_f64( + glam::DVec3::new(-0.5, -0.5, 0.), + glam::DVec3::new(4.5, 1.5, 0.), + 1600. / 900., + ); + click(&mut app, [0.5, 0., 0.], ""); + click(&mut app, [2., 0., 0.], ""); + assert_eq!( + app.tabs[i].active_cmd.as_ref().map(|c| c.name()), + Some("BTESTBLOCK") + ); + click(&mut app, [4., 0., 0.], ""); + assert!(app.tabs[i].active_cmd.is_none()); + let circle = app.tabs[i] + .scene + .document + .entities_in_block("EDIT_ME") + .find_map(|e| { + if let EntityType::Circle(c) = e { + Some(c) + } else { + None + } + }) + .unwrap(); + let (center, radius) = if kind == Kind::Move { + (Vector3::new(2.5, 0.5, 0.), 0.2) + } else { + (Vector3::new(1., 1., 0.), 0.4) + }; + // Mouse projection uses f32 screen coordinates; allow sub-pixel roundoff. + assert!( + (circle.center - center).length() < 1e-5, + "{kind:?}: actual {:?}, expected {center:?}", + circle.center + ); + assert!((circle.radius - radius).abs() < 1e-5); + assert_eq!(app.tabs[source].scene.document, original); + } +} + +#[test] +fn block_actions_command_selection_cancel_undo_and_test_block_isolation() { + use crate::scene::block_actions::{self as actions, Kind}; + let (mut app, reference) = fixture(); + begin(&mut app, reference); + let source = app.active_tab; + let _ = app.run_command_line("BPARAMETER LINEAR 0,0 10,0 5,2"); + let definition = app.tabs[source] + .active_block_edit_session() + .unwrap() + .br_handle; + let p = + actions::parameters(&app.tabs[source].scene.document, definition, Kind::Move)[0].clone(); + let targets = members(&app); + let before = app.tabs[source].scene.document.clone(); + let depth = app.tabs[source].history.undo_stack.len(); + let _ = app.dispatch_command("BACTION MOVE"); + feed(&mut app, &p.name); + assert!(app.tabs[source] + .active_cmd + .as_ref() + .unwrap() + .is_selection_gathering()); + let _ = app.feed_command(crate::command::StepInput::SelectionComplete( + targets.clone(), + )); + let _ = app.update(Message::CommandEscape); + assert_eq!(app.tabs[source].scene.document, before); + assert_eq!(app.tabs[source].history.undo_stack.len(), depth); + for kind in ["Move", "Scale"] { + let _ = app.dispatch_command(&format!("BACTION {kind}")); + feed(&mut app, &p.name); + let _ = app.feed_command(crate::command::StepInput::SelectionComplete( + targets.clone(), + )); + assert!(app.tabs[source] + .active_cmd + .as_ref() + .unwrap() + .is_selection_gathering()); + let _ = app.feed_command(crate::command::StepInput::Enter); + feed(&mut app, "3,4"); + assert!(app.tabs[source].active_cmd.is_none()); + } + assert_eq!( + actions::actions(&app.tabs[source].scene.document, definition).len(), + 2 + ); + assert_eq!(app.tabs[source].history.undo_stack.len(), depth + 2); + app.undo_steps(1); + assert_eq!( + actions::actions(&app.tabs[source].scene.document, definition).len(), + 1 + ); + app.redo_steps(1); + assert_eq!( + actions::actions(&app.tabs[source].scene.document, definition).len(), + 2 + ); + let original = app.tabs[source].scene.document.clone(); + let _ = app.dispatch_command("BTESTBLOCK"); + let test = app.active_tab; + let insert = app.tabs[test].block_test.as_ref().unwrap().insert; + app.tabs[test].selected_handle = Some(insert); + app.refresh_test_action_grips([0.; 3]); + let grip = app.tabs[test].selected_grips.last().unwrap().id; + assert!(app.start_test_action_grip(insert, grip)); + feed(&mut app, "20,5"); + let EntityType::Line(line) = app.tabs[test] + .scene + .document + .get_entity(targets[0]) + .unwrap() + else { + panic!() + }; + assert_eq!(line.start, Vector3::new(20., 0., 0.)); + assert_eq!(line.end, Vector3::new(22., 0., 0.)); + assert_eq!(app.tabs[source].scene.document, original); + app.undo_steps(1); + let EntityType::Line(line) = app.tabs[test] + .scene + .document + .get_entity(targets[0]) + .unwrap() + else { + panic!() + }; + assert_eq!(line.start, Vector3::ZERO); + app.redo_steps(1); + let EntityType::Line(line) = app.tabs[test] + .scene + .document + .get_entity(targets[0]) + .unwrap() + else { + panic!() + }; + assert_eq!(line.start, Vector3::new(20., 0., 0.)); + app.tabs[test] + .scene + .document + .add_entity(EntityType::Insert(Insert::new( + "EDIT_ME", + Vector3::new(50., 0., 0.), + ))) + .unwrap(); + let shared_before = app.tabs[test].scene.document.clone(); + let id = app.tabs[test].id; + let _ = app.handle_block_action_result(crate::command::CmdResult::EvaluateBlockParameter { + document: id, + definition, + parameter: p.handle, + position: glam::DVec3::new(30., 0., 0.), + }); + assert_eq!( + app.tabs[test].scene.document, shared_before, + "shared references are refused rather than silently modified together" + ); + let _ = app.dispatch_command("BCLOSETESTBLOCK"); + assert_eq!(app.active_tab, source); + assert_eq!(app.tabs[source].scene.document, original); +} + +#[test] +fn block_actions_rejected_outside_editor_preserve_running_command_and_stale_results_are_inert() { + use crate::scene::block_actions::Kind; + let (mut app, reference) = fixture(); + let i = app.active_tab; + let _ = app.dispatch_command("LINE"); + let before = app.tabs[i].scene.document.clone(); + let _ = app.dispatch_command("BACTION MOVE"); + assert_eq!(app.tabs[i].active_cmd.as_ref().unwrap().name(), "LINE"); + assert_eq!(app.tabs[i].scene.document, before); + let _ = app.update(Message::CommandEscape); + begin(&mut app, reference); + let _ = app.run_command_line("BPARAMETER POINT 2,3 4,5"); + let def = app.tabs[i].active_block_edit_session().unwrap().br_handle; + let p = crate::scene::block_actions::parameters(&app.tabs[i].scene.document, def, Kind::Move) + [0] + .handle; + let before = app.tabs[i].scene.document.clone(); + let _ = app.handle_block_action_result(crate::command::CmdResult::PlaceBlockAction { + document: app.tabs[i].id + 1, + definition: def, + kind: Kind::Move, + parameter: p, + targets: members(&app), + location: glam::DVec3::ZERO, + }); + assert_eq!(app.tabs[i].scene.document, before); + let _ = app.dispatch_command("LINE"); + let _ = app.dispatch_command("BACTION SCALE"); + assert_eq!(app.tabs[i].active_cmd.as_ref().unwrap().name(), "LINE"); +} + +#[test] +fn block_actions_test_reference_transform_visibility_and_noop_history() { + use crate::scene::block_actions::{self as actions, Kind}; + let (mut app, reference) = fixture(); + begin(&mut app, reference); + let source = app.active_tab; + let _ = app.run_command_line("BPARAMETER POINT 2,3 4,5"); + let _ = app.run_command_line("BPARAMETER VISIBILITY 8,8"); + let _ = app.run_command_line("BPARAMETER BASEPOINT 1,2"); + let definition = app.tabs[source] + .active_block_edit_session() + .unwrap() + .br_handle; + let p = + actions::parameters(&app.tabs[source].scene.document, definition, Kind::Move)[0].clone(); + let targets = members(&app); + let _ = app.dispatch_command("BACTION MOVE"); + feed(&mut app, &p.name); + let _ = app.feed_command(crate::command::StepInput::SelectionComplete(targets)); + let _ = app.feed_command(crate::command::StepInput::Enter); + feed(&mut app, "4,6"); + let _ = app.dispatch_command("BTESTBLOCK"); + let i = app.active_tab; + let insert = app.tabs[i].block_test.as_ref().unwrap().insert; + let depth = app.tabs[i].history.undo_stack.len(); + let id = app.tabs[i].id; + let EntityType::Insert(ins) = app.tabs[i].scene.document.get_entity(insert).unwrap() else { + panic!() + }; + let current_world = actions::v( + crate::scene::render_graph::insert_transform(&app.tabs[i].scene.document, ins) + .apply(actions::native(p.end)), + ); + let _ = app.handle_block_action_result(crate::command::CmdResult::EvaluateBlockParameter { + document: id, + definition, + parameter: p.handle, + position: current_world, + }); + assert_eq!(app.tabs[i].history.undo_stack.len(), depth); + // Existing visibility UI creates a per-reference anonymous definition. + app.apply_visibility_state(insert, 0); + app.finish_pending_history(i); + let EntityType::Insert(ins) = app.tabs[i].scene.document.get_entity(insert).unwrap() else { + panic!() + }; + let after_world = actions::v( + crate::scene::render_graph::insert_transform(&app.tabs[i].scene.document, ins) + .apply(actions::native(p.end)), + ); + assert_eq!( + after_world, current_world, + "visibility representation retains the native Basepoint anchor" + ); + let EntityType::Insert(ins) = app.tabs[i].scene.document.get_entity_mut(insert).unwrap() else { + panic!() + }; + ins.insert_point = Vector3::new(100., 200., 0.); + ins.rotation = std::f64::consts::FRAC_PI_2; + ins.set_x_scale(2.); + ins.set_y_scale(3.); + let EntityType::Insert(ins) = app.tabs[i].scene.document.get_entity(insert).unwrap() else { + panic!() + }; + let name = ins.block_name.clone(); + assert_ne!(name, "EDIT_ME"); + let world = actions::v( + crate::scene::render_graph::insert_transform(&app.tabs[i].scene.document, ins) + .apply(Vector3::new(12., 13., 0.)), + ); + let before = app.tabs[i].scene.document.clone(); + let _ = app.handle_block_action_result(crate::command::CmdResult::EvaluateBlockParameter { + document: id, + definition, + parameter: p.handle, + position: world, + }); + let anon = app.tabs[i] + .scene + .document + .entities_in_block(&name) + .find_map(|e| { + if let EntityType::Line(l) = e { + Some(l) + } else { + None + } + }) + .unwrap(); + assert!((anon.start.x - 10.).abs() < 1e-9 && (anon.start.y - 10.).abs() < 1e-9); + app.undo_steps(1); + assert_eq!(app.tabs[i].scene.document, before); +} + fn authored_points(app: &OpenCADStudio) -> Vec { let tab = &app.tabs[app.active_tab]; let definition = tab diff --git a/src/app/block_test.rs b/src/app/block_test.rs index b32cf6b67..2ac5f2456 100644 --- a/src/app/block_test.rs +++ b/src/app/block_test.rs @@ -5,6 +5,9 @@ use iced::Task; pub(super) struct BlockTestSession { pub source_id: u64, + pub definition: codec::Handle, + pub insert: codec::Handle, + pub baseline: std::sync::Arc, } impl OpenCADStudio { @@ -97,13 +100,19 @@ impl OpenCADStudio { if !insert.attributes.is_empty() { insert.seqend_handle = Some(document.allocate_handle()); } + let insert_handle = insert.common.handle; if let Err(error) = document.add_entity(EntityType::Insert(insert)) { self.command_line.push_error(&error.to_string()); return Task::none(); } let mut tab = DocumentTab::new_drawing(0); tab.tab_title = crate::tf!("Test Block Window - {}", name).into_owned(); - tab.block_test = Some(BlockTestSession { source_id }); + tab.block_test = Some(BlockTestSession { + source_id, + definition, + insert: insert_handle, + baseline: std::sync::Arc::new(document.clone()), + }); tab.active_layer = self.tabs[i].active_layer.clone(); tab.scene.document = document; tab.scene.named_parameters = self.tabs[i].scene.named_parameters.clone(); diff --git a/src/app/command_driver/mod.rs b/src/app/command_driver/mod.rs index fb882bc3e..aee127260 100644 --- a/src/app/command_driver/mod.rs +++ b/src/app/command_driver/mod.rs @@ -575,6 +575,10 @@ impl OpenCADStudio { return task; } } + result @ (CmdResult::PlaceBlockAction { .. } + | CmdResult::EvaluateBlockParameter { .. }) => { + return self.handle_block_action_result(result); + } result @ CmdResult::PlaceBlockSingleParameter { .. } => { return self.handle_place_block_single_parameter(result); } diff --git a/src/app/commands/bvisibility.rs b/src/app/commands/bvisibility.rs index 0fefd6f83..a33024d00 100644 --- a/src/app/commands/bvisibility.rs +++ b/src/app/commands/bvisibility.rs @@ -391,6 +391,17 @@ impl OpenCADStudio { return Some(Task::none()); } match cmd { + cmd if cmd == "BACTION" + || cmd.starts_with("BACTION ") + || cmd == "BACTIONTOOL" + || cmd.starts_with("BACTIONTOOL ") => + { + Some( + self.start_block_action( + cmd.split_once(' ').map_or("", |(_, args)| args.trim()), + ), + ) + } cmd if cmd == "BLOOKUPTABLE" || cmd.starts_with("BLOOKUPTABLE ") => { Some(self.open_lookup_table(cmd.strip_prefix("BLOOKUPTABLE").unwrap().trim())) } diff --git a/src/app/commands/mod.rs b/src/app/commands/mod.rs index 33194169e..8db2361f9 100644 --- a/src/app/commands/mod.rs +++ b/src/app/commands/mod.rs @@ -174,6 +174,22 @@ impl OpenCADStudio { }; let resolved = self.resolve_alias(cmd); let cmd = resolved.as_deref().unwrap_or(cmd); + if matches!(cmd.split(' ').next(), Some("BACTION" | "BACTIONTOOL")) { + let args = cmd.split_once(' ').map_or("", |(_, a)| a.trim()); + if !args.is_empty() && crate::scene::block_actions::Kind::parse(args).is_none() { + self.command_line.push_error( + crate::t!( + "Supported actions: Move, Scale. Other action types are not wired yet." + ) + .as_ref(), + ); + return Task::none(); + } + if let Some(error) = self.action_error(crate::scene::block_actions::Kind::parse(args)) { + self.command_line.push_error(crate::t!(error).as_ref()); + return Task::none(); + } + } if cmd == "BPARAMETER" || cmd.starts_with("BPARAMETER ") { if let Some(error) = self.parameter_placement_error(cmd.strip_prefix("BPARAMETER").unwrap().trim()) diff --git a/src/app/dock/tests.rs b/src/app/dock/tests.rs index cb02124dc..982427aa0 100644 --- a/src/app/dock/tests.rs +++ b/src/app/dock/tests.rs @@ -1106,6 +1106,144 @@ fn authoring_app() -> OpenCADStudio { app } +#[test] +fn block_actions_palette_controls_require_compatible_parameters_and_start_commands() { + with_stack(|| { + use crate::ui::window::authoring_palettes::Tab; + let mut app = authoring_app(); + let _ = app.dispatch_command("BAUTHORPALETTE"); + let point = authoring_control(&app, Tab::Actions.widget_id()); + authoring_click(&mut app, point); + for id in ["authoring-tool-move-action", "authoring-tool-scale-action"] { + let point = authoring_control(&app, id); + authoring_click(&mut app, point); + assert!(app.tabs[app.active_tab].active_cmd.is_none()); + } + let _ = app.run_command_line("BPARAMETER POINT 0.5,0.5 0.7,0.7"); + let point = authoring_control(&app, "authoring-tool-scale-action"); + authoring_click(&mut app, point); + assert!(app.tabs[app.active_tab].active_cmd.is_none()); + let point = authoring_control(&app, "authoring-tool-move-action"); + authoring_click(&mut app, point); + assert_eq!( + app.tabs[app.active_tab].active_cmd.as_ref().unwrap().name(), + "BACTION" + ); + let _ = app.update(Message::CommandEscape); + let _ = app.run_command_line("BPARAMETER LINEAR 0,0 1,0 0.5,0.3"); + let point = authoring_control(&app, "authoring-tool-scale-action"); + authoring_click(&mut app, point); + assert_eq!( + app.tabs[app.active_tab].active_cmd.as_ref().unwrap().name(), + "BACTION" + ); + let _ = app.update(Message::CommandEscape); + authoring_capture(&app, "move-scale-actions-enabled"); + let i = app.active_tab; + let targets = app.tabs[i] + .scene + .document + .block_records + .get("AUTHOR") + .unwrap() + .entity_handles + .clone(); + let point = authoring_control(&app, "authoring-tool-scale-action"); + authoring_click(&mut app, point); + let _ = app.feed_command(crate::command::StepInput::Point(glam::DVec3::X)); + assert!( + app.tabs[i] + .active_cmd + .as_ref() + .unwrap() + .is_selection_gathering(), + "picking the parameter endpoint starts object selection" + ); + let _ = app.feed_command(crate::command::StepInput::SelectionComplete(targets)); + let _ = app.feed_command(crate::command::StepInput::Enter); + let _ = app.update(Message::CommandInput("0.8,0.5".into())); + let _ = app.update(Message::CommandSubmit); + assert!(app.tabs[i].active_cmd.is_none()); + let _ = app.run_command_line("ZOOM E"); + authoring_capture(&app, "scale-action-placed"); + let _ = app.dispatch_command("BTESTBLOCK"); + let i = app.active_tab; + app.tabs[i].scene.selection.borrow_mut().view.vp_size = (1600., 900.); + app.tabs[i].scene.camera.borrow_mut().fit_to_bounds_f64( + glam::DVec3::new(-0.2, -0.4, 0.), + glam::DVec3::new(1.2, 0.6, 0.), + 1600. / 900., + ); + let insert = app.tabs[i].block_test.as_ref().unwrap().insert; + app.tabs[i].scene.select_entity(insert, true); + app.refresh_properties(); + assert!(app.tabs[i] + .selected_grips + .iter() + .any(|g| crate::app::block_actions::is_test_grip(g.id))); + authoring_capture(&app, "scale-action-test-grip"); + // Drawing a software snapshot updates the canvas size. Use the same + // current tile as the viewport input path, including when captures run. + let (vw, vh) = app.tabs[i].scene.selection.borrow().view.vp_size; + let tile = app.tabs[i].scene.active_model_tile_bounds(vw, vh); + let bounds = iced::Rectangle { + x: 0., + y: 0., + width: tile.width, + height: tile.height, + }; + let grip = app.tabs[i] + .selected_grips + .iter() + .find(|g| crate::app::block_actions::is_test_grip(g.id)) + .unwrap(); + let screen = app.tabs[i] + .scene + .camera + .borrow() + .project(grip.world, bounds) + .unwrap(); + app.cursor_pos = iced::Point::new(screen.x + tile.x, screen.y + tile.y); + app.tabs[i].scene.selection.borrow_mut().input.last_move_pos = Some(app.cursor_pos); + assert!( + app.tabs[i].active_cmd.is_none(), + "a running command consumes the test grip" + ); + let hit = crate::scene::pick::grip::find_hit_grip( + iced::Point::new(screen.x, screen.y), + &app.tabs[i].selected_grips, + &app.tabs[i].scene.camera.borrow(), + bounds, + ); + assert!( + hit.is_some_and(|(_, id, _, _)| crate::app::block_actions::is_test_grip(id)), + "test grip hit failed: {hit:?}; bounds={bounds:?}, cursor={:?}", + app.cursor_pos + ); + let _ = app.update(Message::ViewportLeftPressAt(app.cursor_pos)); + assert_eq!( + app.tabs[i].active_cmd.as_ref().map(|c|c.name()), + Some("BTESTBLOCK"), + "viewport click routes the parameter grip to the test command: modal={:?}, active grip={:?}, selected={:?}, navigation={:?}, ucs={}",app.active_modal,app.tabs[i].active_grip,app.tabs[i].selected_handle,(app.tabs[i].orbit_mode,app.tabs[i].pan_mode,app.tabs[i].zoom_dynamic_mode),app.ucs_icon_selected + ); + let _ = app.update(Message::CommandInput("2,0".into())); + let _ = app.update(Message::CommandSubmit); + let line = app.tabs[i] + .scene + .document + .entities_in_block("AUTHOR") + .find_map(|e| { + if let codec::EntityType::Line(l) = e { + Some(l) + } else { + None + } + }) + .unwrap(); + assert_eq!(line.end, codec::types::Vector3::new(2., 0., 0.)); + }); +} + #[test] fn lookup_basepoint_palette_and_table_dialog_real_controls() { with_stack(|| { diff --git a/src/app/dock/view.rs b/src/app/dock/view.rs index 170d69b88..7ac3e0f88 100644 --- a/src/app/dock/view.rs +++ b/src/app/dock/view.rs @@ -411,6 +411,8 @@ impl OpenCADStudio { self.point_parameter_placement_error(), self.parameter_placement_error("FLIP"), self.lookup_table_error(), + self.action_error(Some(crate::scene::block_actions::Kind::Move)), + self.action_error(Some(crate::scene::block_actions::Kind::Scale)), ) } crate::ui::dock::PanelId::BlockPalette => { diff --git a/src/app/mod.rs b/src/app/mod.rs index a7707fe2b..2c1bd36cc 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -14,6 +14,7 @@ mod block_editor_tests; mod block_save_as; mod block_visibility; mod block_lookup; +mod block_actions; mod block_properties; #[cfg(test)] mod block_properties_tests; diff --git a/src/app/properties.rs b/src/app/properties.rs index 1b88ad023..9882203f3 100644 --- a/src/app/properties.rs +++ b/src/app/properties.rs @@ -2886,6 +2886,7 @@ handles={handles_ms:.1} panel={:.1} ribbon={ribbon_ms:.1} tail={:.1} selected={} // Append the dynamic-block visibility (lookup) grip, if the lone // selection is a visibility-parametric block reference. self.refresh_visibility_grip(wo); + self.refresh_test_action_grips(wo); } pub(super) fn property_target_handles(&self, i: usize) -> Vec { diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index 1b3b5008f..b3453639b 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -829,7 +829,10 @@ impl OpenCADStudio { .copied() .zip(self.tabs[i].selected_grips.iter()) .filter(|(owner, grip)| self.tabs[i].hot_grips.contains(&(*owner, grip.id))) - .filter(|(_, grip)| grip.id != crate::app::visibility::VIS_GRIP_ID) + .filter(|(_, grip)| { + grip.id != crate::app::visibility::VIS_GRIP_ID + && !crate::app::block_actions::is_test_grip(grip.id) + }) .map(|(owner, grip)| GripTarget { handle: owner, grip_id: grip.id, @@ -851,7 +854,10 @@ impl OpenCADStudio { .iter() .copied() .zip(self.tabs[i].selected_grips.iter()) - .filter(|(_, grip)| grip.id != crate::app::visibility::VIS_GRIP_ID) + .filter(|(_, grip)| { + grip.id != crate::app::visibility::VIS_GRIP_ID + && !crate::app::block_actions::is_test_grip(grip.id) + }) .filter(|(_, grip)| (grip.world - world).length_squared() <= epsilon_sq) .map(|(owner, grip)| GripTarget { handle: owner, @@ -993,6 +999,7 @@ impl OpenCADStudio { // hover grip-menu. if self.grip_popup.is_none() && grip_id != crate::app::visibility::VIS_GRIP_ID + && !crate::app::block_actions::is_test_grip(grip_id) && self .grip_hover .as_ref() @@ -3993,6 +4000,9 @@ impl OpenCADStudio { self.grip_popup = None; return Task::none(); } + if self.start_test_action_grip(handle, grip_id) { + return Task::none(); + } if self.shift_down { let key = (handle, grip_id); if !self.tabs[i].hot_grips.remove(&key) { diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index 01e7952cb..580ab4ab0 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -1144,6 +1144,20 @@ bg={bg_ms:.1}ms n={view_count}" (g, p.parameter.definition_point) }), ) + .chain( + crate::scene::block_actions::actions( + &tab.scene.document, + session.br_handle, + ) + .into_iter() + .map(|(_, action, _, _)| { + let mut g = crate::scene::block_distance_parameter::Graphic::default(); + g.grips.push((action.display_location, false)); + g.labels + .push((action.display_location, action.element.name.clone())); + (g, action.display_location) + }), + ) .filter_map(|(graphic, marker_tail)| { let project = |p: codec::types::Vector3| { let p = tab diff --git a/src/command.rs b/src/command.rs index 71fdbf73a..f34a2cc87 100644 --- a/src/command.rs +++ b/src/command.rs @@ -1514,6 +1514,20 @@ impl DimensionPreview { /// Returned by every `CadCommand` method to tell main.rs what to do. #[allow(dead_code)] pub enum CmdResult { + PlaceBlockAction { + document: u64, + definition: Handle, + kind: crate::scene::block_actions::Kind, + parameter: Handle, + targets: Vec, + location: DVec3, + }, + EvaluateBlockParameter { + document: u64, + definition: Handle, + parameter: Handle, + position: DVec3, + }, /// Place or relocate a Basepoint, or place a Lookup parameter. PlaceBlockSingleParameter { document_id: u64, diff --git a/src/modules/block_editor/action.rs b/src/modules/block_editor/action.rs new file mode 100644 index 000000000..03aced724 --- /dev/null +++ b/src/modules/block_editor/action.rs @@ -0,0 +1,203 @@ +//! Reuse the command host's point picking and object-set selection. +use crate::command::{CadCommand, CmdOption, CmdResult, PointPickContext}; +use crate::scene::block_actions::{Kind, Parameter}; +use codec::Handle; +use glam::DVec3; + +inventory::submit!(crate::command::CommandRegistration { + names: &["BACTION", "BACTIONTOOL"] +}); + +pub(crate) struct ActionCommand { + document: u64, + definition: Handle, + kind: Option, + candidates: Vec, + parameter: Option, + targets: Vec, + selecting: bool, + context: Option, +} +impl ActionCommand { + pub(crate) fn new( + document: u64, + definition: Handle, + kind: Option, + candidates: Vec, + ) -> Self { + Self { + document, + definition, + kind, + candidates, + parameter: None, + targets: vec![], + selecting: false, + context: None, + } + } + fn choose(&mut self, p: Parameter) -> CmdResult { + if self.kind == Some(Kind::Scale) && (p.point || p.xy) { + return CmdResult::ReportError( + crate::t!("Scale requires a Linear or Polar parameter.").into_owned(), + ); + } + self.parameter = Some(p.handle); + self.selecting = true; + CmdResult::DeselectAndContinue + } +} +impl CadCommand for ActionCommand { + fn name(&self) -> &'static str { + "BACTION" + } + fn wants_text_input(&self) -> bool { + self.parameter.is_none() + } + fn prompt(&self) -> String { + if self.kind.is_none() { + return crate::t!("BACTION Enter action type [Move/Scale]:").into_owned(); + } + if self.parameter.is_none() { + return crate::tf!( + "BACTION {} Select a parameter endpoint or enter its name:", + crate::t!(self.kind.unwrap().name()) + ) + .into_owned(); + } + crate::t!(if self.selecting { + "BACTION Select objects for the action, then press Enter:" + } else { + "BACTION Specify action marker location:" + }) + .into_owned() + } + fn options(&self) -> Vec { + if self.kind.is_none() { + vec![ + CmdOption::new("Move", "Move"), + CmdOption::new("Scale", "Scale"), + ] + } else { + vec![] + } + } + fn on_text_input(&mut self, s: &str) -> Option { + if self.kind.is_none() { + return Some(if let Some(k) = Kind::parse(s) { + self.kind = Some(k); + CmdResult::NeedPoint + } else { + CmdResult::ReportError(crate::t!("Choose Move or Scale.").into_owned()) + }); + } + if self.parameter.is_none() { + let matches = self + .candidates + .iter() + .filter(|p| p.name.eq_ignore_ascii_case(s)) + .cloned() + .collect::>(); + return Some(if matches.len() == 1 { + self.choose(matches[0].clone()) + } else { + CmdResult::ReportError( + crate::t!("Enter a unique compatible parameter name or pick its endpoint.") + .into_owned(), + ) + }); + } + None + } + fn wants_point_pick_context(&self) -> bool { + self.kind.is_some() && self.parameter.is_none() + } + fn set_point_pick_context(&mut self, c: Option) { + self.context = c; + } + fn on_point(&mut self, point: DVec3) -> CmdResult { + if self.parameter.is_none() { + let Some(c) = self.context else { + return CmdResult::ReportError(crate::t!("Enter the parameter name.").into_owned()); + }; + let screen = + |p| crate::scene::pick::hit_test::world_to_screen(p, c.view, c.eye, c.bounds); + let click = screen(point); + let candidate = self + .candidates + .iter() + .filter(|p| self.kind != Some(Kind::Scale) || (!p.point && !p.xy)) + .map(|p| (screen(p.end).distance(click), p)) + .filter(|(d, _)| d.is_finite() && *d <= c.aperture_px.max(10.)) + .min_by(|a, b| a.0.total_cmp(&b.0)) + .map(|(_, p)| p.clone()); + return candidate.map(|p| self.choose(p)).unwrap_or_else(|| { + CmdResult::ReportError( + crate::t!("Pick a compatible parameter endpoint.").into_owned(), + ) + }); + } + if self.selecting { + return CmdResult::NeedPoint; + } + if !point.is_finite() { + return CmdResult::ReportError( + crate::t!("Specify a finite action location.").into_owned(), + ); + } + CmdResult::PlaceBlockAction { + document: self.document, + definition: self.definition, + kind: self.kind.unwrap(), + parameter: self.parameter.unwrap(), + targets: self.targets.clone(), + location: point, + } + } + fn is_selection_gathering(&self) -> bool { + self.selecting + } + fn on_selection_complete(&mut self, handles: Vec) -> CmdResult { + // The host calls this after every pick/window/removal, not just Enter. + // Retain the current set while the user gathers additional objects. + self.targets = handles; + CmdResult::NeedPoint + } + fn on_enter(&mut self) -> CmdResult { + if self.selecting { + if self.targets.is_empty() { + return CmdResult::ReportError( + crate::t!("Select at least one object for the action.").into_owned(), + ); + } + self.selecting = false; + return CmdResult::NeedPoint; + } + CmdResult::Cancel + } +} + +pub(crate) struct TestParameterCommand { + pub document: u64, + pub definition: Handle, + pub parameter: Handle, +} +impl CadCommand for TestParameterCommand { + fn name(&self) -> &'static str { + "BTESTBLOCK" + } + fn prompt(&self) -> String { + crate::t!("Test Block Specify the new parameter endpoint (Escape cancels):").into_owned() + } + fn on_point(&mut self, position: DVec3) -> CmdResult { + CmdResult::EvaluateBlockParameter { + document: self.document, + definition: self.definition, + parameter: self.parameter, + position, + } + } + fn on_enter(&mut self) -> CmdResult { + CmdResult::Cancel + } +} diff --git a/src/modules/block_editor/mod.rs b/src/modules/block_editor/mod.rs index 9e2b5df08..06fa45da9 100644 --- a/src/modules/block_editor/mod.rs +++ b/src/modules/block_editor/mod.rs @@ -6,6 +6,7 @@ use crate::modules::parametric::ParametricModule; use crate::modules::{CadModule, IconKind, ModuleEvent, RibbonGroup, RibbonItem, ToolDef}; pub mod visibility_parameter; +pub mod action; pub mod point_parameter; pub mod single_parameter; pub mod distance_parameter; diff --git a/src/scene/block_actions.rs b/src/scene/block_actions.rs new file mode 100644 index 000000000..17de6ee9f --- /dev/null +++ b/src/scene/block_actions.rs @@ -0,0 +1,568 @@ +//! Native Move/Scale authoring and deterministic evaluation in Test Block. +use super::block_authoring::{self as authoring, *}; +use codec::objects::{ + BlockAction, BlockActionOffsets, BlockActionWithBasePoint, BlockBasePointAction, + BlockConnection, BlockElement, BlockEvalValue, BlockMoveAction, DynamicBlockData, ObjectType, +}; +use codec::types::{Transform, Vector3}; +use codec::{CadDocument, EntityType, Handle}; +use glam::DVec3; +#[cfg(test)] +mod tests; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Kind { + Move, + Scale, +} +impl Kind { + pub(crate) fn parse(s: &str) -> Option { + match s.to_ascii_uppercase().as_str() { + "MOVE" | "M" => Some(Self::Move), + "SCALE" | "S" => Some(Self::Scale), + _ => None, + } + } + pub(crate) fn name(self) -> &'static str { + match self { + Self::Move => "Move", + Self::Scale => "Scale", + } + } +} + +#[derive(Clone, Debug)] +pub(crate) struct Parameter { + pub handle: Handle, + pub id: i32, + pub grip: Handle, + pub name: String, + pub base: DVec3, + pub end: DVec3, + pub linear: bool, + pub point: bool, + pub xy: bool, +} +pub(crate) fn v(p: Vector3) -> DVec3 { + DVec3::new(p.x, p.y, p.z) +} +pub(crate) fn native(p: DVec3) -> Vector3 { + Vector3::new(p.x, p.y, p.z) +} + +pub(crate) fn parameters(doc: &CadDocument, definition: Handle, kind: Kind) -> Vec { + let Ok(Some(graph)) = authoring::graph(doc, definition) else { + return vec![]; + }; + let mut result = vec![]; + for (handle, p) in super::block_point_parameter::parameters(doc, definition) { + if kind == Kind::Move { + result.push(Parameter { + handle, + id: p.parameter.parameter.element.eval.node_id, + grip: Handle::NULL, + name: p.position_name.clone(), + base: v(p.parameter.definition_point), + end: v(p.parameter.definition_point), + linear: false, + point: true, + xy: false, + }); + } + } + for p in super::block_distance_parameter::parameters(doc, definition) { + let xy = p.kind == super::block_distance_parameter::Kind::XY; + if kind == Kind::Scale && xy { + continue; + } + result.push(Parameter { + handle: p.handle, + id: p.native.parameter.element.eval.node_id, + grip: Handle::NULL, + name: p.labels.first().cloned().unwrap_or_default(), + base: v(p.native.definition_base_point), + end: v(p.native.definition_end_point), + linear: p.kind == super::block_distance_parameter::Kind::Linear, + point: false, + xy, + }); + } + // Resolve the actual linked grip, rather than assuming expression IDs are contiguous. + result.retain_mut(|p| { + let grip_id = if p.point { + super::block_point_parameter::parameters(doc, definition) + .into_iter() + .find(|(h, _)| *h == p.handle) + .unwrap() + .1 + .parameter + .property_count + } else { + super::block_distance_parameter::parameters(doc, definition) + .into_iter() + .find(|q| q.handle == p.handle) + .unwrap() + .native + .properties[2] + .connections + .first() + .map(|c| c.code) + .unwrap_or(-1) + }; + let grips = doc + .objects + .iter() + .filter_map(|(h, o)| { + let ObjectType::DynamicBlock(o) = o else { + return None; + }; + if o.owner != graph { + return None; + } + let grip = match &o.data { + DynamicBlockData::XYGrip(g) | DynamicBlockData::PolarGrip(g) => g, + DynamicBlockData::LinearGrip(g) => &g.grip, + _ => return None, + }; + (grip.element.eval.node_id == grip_id).then_some(*h) + }) + .collect::>(); + if grips.len() != 1 + || !p.base.is_finite() + || !p.end.is_finite() + || (!p.point && (p.end.distance(p.base) < 1e-9 || !p.end.distance(p.base).is_finite())) + { + return false; + } + p.grip = grips[0]; + true + }); + result.sort_by_key(|p| p.id); + result +} + +pub(crate) fn supported(e: &EntityType) -> bool { + matches!( + e, + EntityType::Line(_) + | EntityType::Circle(_) + | EntityType::Arc(_) + | EntityType::LwPolyline(_) + | EntityType::Ellipse(_) + | EntityType::Point(_) + | EntityType::Text(_) + | EntityType::MText(_) + ) +} +pub(crate) fn create( + doc: &mut CadDocument, + definition: Handle, + kind: Kind, + parameter: Handle, + targets: &[Handle], + location: DVec3, +) -> Result { + if !location.is_finite() { + return Err("Specify a finite action location."); + } + let p = parameters(doc, definition, kind) + .into_iter() + .find(|p| p.handle == parameter) + .ok_or("Select a compatible parameter belonging to this block.")?; + if targets.is_empty() { + return Err("Select at least one object for the action."); + } + let members = &doc + .block_records + .iter() + .find(|b| b.handle == definition) + .ok_or("The block definition is unavailable.")? + .entity_handles; + if targets.iter().any(|h| { + !members.contains(h) + || doc + .get_entity(*h) + .is_none_or(|e| !supported(e) || e.common().owner_handle != definition) + }) { + return Err("This milestone supports lines, polylines, arcs, circles, ellipses, points and text belonging to this block."); + } + let mut staged = doc.clone(); + let (graph, slots) = authoring::prepare(&mut staged, definition)?; + let (dxf, cpp) = match kind { + Kind::Move => ("BLOCKMOVEACTION", "AcDbBlockMoveAction"), + Kind::Scale => ("BLOCKSCALEACTION", "AcDbBlockScaleAction"), + }; + register_classes(&mut staged, &[(dxf, cpp, 55)]); + let handle = staged.allocate_handle(); + let mut dependencies = targets.to_vec(); + dependencies.sort(); + dependencies.dedup(); + let action = BlockAction { + element: BlockElement { + eval: expression(slots.eval, NO_VALUE, BlockEvalValue::None), + name: format!("{}{}", kind.name(), slots.eval), + major: MAJOR, + minor: MINOR, + eed_1071: 0, + }, + display_location: native(location), + dependencies, + action_ids: vec![], + }; + let connection = |name: &str| BlockConnection { + code: p.id, + name: name.into(), + }; + let data = match kind { + Kind::Move => DynamicBlockData::MoveAction(BlockMoveAction { + action, + connections: if p.point { + [connection("XDelta"), connection("YDelta")] + } else { + [connection("EndXDelta"), connection("EndYDelta")] + }, + offsets: BlockActionOffsets { + distance_multiplier: 1., + angle_offset: 0., + flags: 0, + }, + }), + Kind::Scale => DynamicBlockData::ScaleAction(BlockBasePointAction { + action: BlockActionWithBasePoint { + action, + offset: Vector3::ZERO, + connections: vec![connection("UpdatedBaseX"), connection("UpdatedBaseY")], + dependent: true, + base_point: Vector3::ZERO, + }, + connections: vec![ + connection("Scale"), + connection("XScale"), + connection("YScale"), + ], + dwg_tail_byte: 0, + }), + }; + staged.objects.insert( + handle, + ObjectType::DynamicBlock(dynamic(handle, graph, dxf, cpp, data)), + ); + authoring::append_single(&mut staged, graph, slots, handle); + authoring::graph::bind_ids(&mut staged, graph, slots.eval, &[p.id])?; + *doc = staged; + Ok(handle) +} + +pub(crate) fn actions( + doc: &CadDocument, + definition: Handle, +) -> Vec<(Handle, &BlockAction, Kind, i32)> { + let Ok(Some(graph)) = authoring::graph(doc, definition) else { + return vec![]; + }; + let ObjectType::DynamicBlock(o) = &doc.objects[&graph] else { + return vec![]; + }; + let DynamicBlockData::EvaluationGraph(g) = &o.data else { + return vec![]; + }; + g.nodes + .iter() + .filter_map(|n| { + let ObjectType::DynamicBlock(o) = doc.objects.get(&n.expression)? else { + return None; + }; + if o.owner != graph { + return None; + } + match &o.data { + DynamicBlockData::MoveAction(a) => { + Some((o.handle, &a.action, Kind::Move, a.connections[0].code)) + } + DynamicBlockData::ScaleAction(a) => a + .connections + .first() + .map(|c| (o.handle, &a.action.action, Kind::Scale, c.code)), + _ => None, + } + }) + .collect() +} +pub(crate) fn reframe(doc: &mut CadDocument, definition: Handle, t: &Transform) -> usize { + let handles = actions(doc, definition) + .iter() + .map(|(h, _, _, _)| *h) + .collect::>(); + for h in &handles { + let ObjectType::DynamicBlock(o) = doc.objects.get_mut(h).unwrap() else { + unreachable!() + }; + match &mut o.data { + DynamicBlockData::MoveAction(a) => { + a.action.display_location = t.apply(a.action.display_location) + } + DynamicBlockData::ScaleAction(a) => { + a.action.action.display_location = t.apply(a.action.action.display_location); + a.action.base_point = t.apply(a.action.base_point); + a.action.offset = t.apply_rotation(a.action.offset); + } + _ => {} + } + } + handles.len() +} + +pub(crate) fn current(doc: &CadDocument, p: &Parameter) -> DVec3 { + let Some(ObjectType::DynamicBlock(o)) = doc.objects.get(&p.grip) else { + return p.end; + }; + match &o.data { + DynamicBlockData::XYGrip(g) | DynamicBlockData::PolarGrip(g) => v(g.location), + DynamicBlockData::LinearGrip(g) => v(g.grip.location), + _ => p.end, + } +} + +/// Visibility can give the test INSERT an anonymous representation. Keep its +/// parallel members in sync, retaining their own handles and visibility flags. +pub(crate) fn sync_representation( + doc: &mut CadDocument, + definition: Handle, + name: &str, +) -> Result<(), &'static str> { + let original = doc + .block_records + .iter() + .find(|b| b.handle == definition) + .ok_or("The test definition is unavailable.")?; + if original.name == name { + return Ok(()); + } + let source = original.entity_handles.clone(); + let target = doc + .block_records + .get(name) + .ok_or("The test representation is unavailable.")? + .entity_handles + .clone(); + if source.len() != target.len() { + return Err("The test representation no longer matches its definition."); + } + let affected = actions(doc, definition) + .iter() + .flat_map(|(_, a, _, _)| a.dependencies.iter().copied()) + .collect::>(); + for (from, to) in source.into_iter().zip(target) { + if !affected.contains(&from) { + continue; + } + let mut entity = doc + .get_entity(from) + .cloned() + .ok_or("An action target is unavailable.")?; + let current = doc + .get_entity_mut(to) + .ok_or("An action representation target is unavailable.")?; + *entity.common_mut() = current.common().clone(); + *current = entity; + } + Ok(()) +} + +/// Re-evaluate from the scratch document's immutable starting geometry. This +/// avoids incremental drift and makes repeated changes/order independent. +pub(crate) fn evaluate( + doc: &mut CadDocument, + baseline: &CadDocument, + definition: Handle, + parameter: Handle, + position: DVec3, +) -> Result { + let graph = authoring::graph(baseline, definition)? + .ok_or("The test authoring graph is unavailable.")?; + if baseline.objects.values().any(|o|matches!(o,ObjectType::DynamicBlock(o) if o.owner==graph && matches!(&o.data, + DynamicBlockData::StretchAction(_)|DynamicBlockData::PolarStretchAction(_)|DynamicBlockData::RotateAction(_)|DynamicBlockData::FlipAction(_)|DynamicBlockData::ArrayAction(_)))) { + return Err("This block contains action types not supported in Test Block yet."); + } + let p = parameters(baseline, definition, Kind::Move) + .into_iter() + .find(|p| p.handle == parameter) + .ok_or("The test parameter is unavailable.")?; + if !position.is_finite() { + return Err("Specify a finite parameter position."); + } + let mut position = position; + position.z = p.end.z; + if p.linear { + let axis = (p.end - p.base).normalize(); + let length = (position - p.base).dot(axis); + if length < 1e-9 { + return Err("Specify a positive parameter length."); + } + position = p.base + axis * length; + } + if current(doc, &p).distance(position) < 1e-10 { + return Ok(false); + } + let mut staged = doc.clone(); + let ObjectType::DynamicBlock(o) = staged + .objects + .get_mut(&p.grip) + .ok_or("The test grip is unavailable.")? + else { + return Err("The test grip is unavailable."); + }; + match &mut o.data { + DynamicBlockData::XYGrip(g) | DynamicBlockData::PolarGrip(g) => { + g.location = native(position) + } + DynamicBlockData::LinearGrip(g) => g.grip.location = native(position), + _ => return Err("The test grip is unavailable."), + } + let all = actions(baseline, definition); + let mut affected = std::collections::BTreeSet::new(); + for (_, a, _, _) in &all { + affected.extend(a.dependencies.iter().copied()); + } + for h in affected { + let mut entity = baseline + .get_entity(h) + .cloned() + .ok_or("An action target is unavailable.")?; + if !supported(&entity) { + return Err("This action contains an unsupported object type."); + } + // State flags may have changed through the existing visibility grip. + if let Some(e) = staged.get_entity(h) { + entity.common_mut().invisible = e.common().invisible; + } + for (action_handle, a, kind, id) in &all { + if !a.dependencies.contains(&h) { + continue; + } + let q = parameters(baseline, definition, *kind) + .into_iter() + .find(|p| p.id == *id) + .ok_or("An action has an unsupported parameter binding.")?; + if !test_parameter_supported(baseline, q.handle) { + return Err("This parameter uses value constraints or chaining not supported in Test Block yet."); + } + let end = current(&staged, &q); + let transform = match kind { + Kind::Move => crate::command::EntityTransform::Translate(end - q.end), + Kind::Scale => { + let factor = end.distance(q.base) / q.end.distance(q.base); + if !factor.is_finite() || factor < 1e-9 { + return Err("Specify a positive scale distance."); + } + crate::command::EntityTransform::Scale { + center: q.base, + factor, + } + } + }; + // Imported actions with custom multipliers/offsets/chaining must + // not silently behave like the simple actions authored here. + if !a.action_ids.is_empty() { + return Err("Chained actions are not supported in Test Block yet."); + } + let ObjectType::DynamicBlock(o) = &baseline.objects[action_handle] else { + unreachable!() + }; + match &o.data { + DynamicBlockData::MoveAction(m) + if m.offsets.distance_multiplier != 1. + || m.offsets.angle_offset != 0. + || m.offsets.flags != 0 + || m.connections + != if q.point { + [ + BlockConnection { + code: q.id, + name: "XDelta".into(), + }, + BlockConnection { + code: q.id, + name: "YDelta".into(), + }, + ] + } else { + [ + BlockConnection { + code: q.id, + name: "EndXDelta".into(), + }, + BlockConnection { + code: q.id, + name: "EndYDelta".into(), + }, + ] + } => + { + return Err("This Move action uses options not supported in Test Block yet.") + } + DynamicBlockData::ScaleAction(s) + if !s.action.dependent + || s.action.offset != Vector3::ZERO + || s.connections + != ["Scale", "XScale", "YScale"].map(|name| BlockConnection { + code: q.id, + name: name.into(), + }) + || s.action.connections + != ["UpdatedBaseX", "UpdatedBaseY"].map(|name| BlockConnection { + code: q.id, + name: name.into(), + }) => + { + return Err("This Scale action uses options not supported in Test Block yet.") + } + _ => {} + } + super::view::dispatch::apply_transform(&mut entity, &transform); + let bounds = entity.as_entity().bounding_box(); + if !v(bounds.min).is_finite() || !v(bounds.max).is_finite() { + return Err("The parameter value produces non-finite geometry."); + } + } + *staged + .get_entity_mut(h) + .ok_or("An action target was removed.")? = entity; + } + *doc = staged; + Ok(true) +} + +fn test_parameter_supported(doc: &CadDocument, handle: Handle) -> bool { + let Some(ObjectType::DynamicBlock(o)) = doc.objects.get(&handle) else { + return false; + }; + let distance = |s: &codec::objects::BlockParameterValueSet| { + s.flags == 1 + && s.minimum == 0. + && s.maximum == 0. + && s.increment == 0. + && s.values.is_empty() + }; + match &o.data { + DynamicBlockData::PointParameter(p) => !p.parameter.parameter.chain_actions, + DynamicBlockData::LinearParameter(p) => { + !p.parameter.parameter.chain_actions && distance(&p.value_set) + } + DynamicBlockData::XYParameter(p) => { + !p.parameter.parameter.chain_actions + && distance(&p.x_value_set) + && distance(&p.y_value_set) + } + DynamicBlockData::PolarParameter(p) => { + !p.parameter.parameter.chain_actions + && distance(&p.distance_value_set) + && p.angle_value_set.flags == 3 + && p.angle_value_set.minimum == 0. + && p.angle_value_set.maximum == std::f64::consts::TAU + && p.angle_value_set.increment == 0. + && p.angle_value_set.values.is_empty() + } + _ => false, + } +} diff --git a/src/scene/block_actions/tests.rs b/src/scene/block_actions/tests.rs new file mode 100644 index 000000000..18b14d891 --- /dev/null +++ b/src/scene/block_actions/tests.rs @@ -0,0 +1,214 @@ +use super::*; +use crate::scene::{block_distance_parameter as distance, block_point_parameter as point, Scene}; +use codec::{DwgReader, DwgWriter, DxfReader, DxfWriter}; +use std::io::Cursor; + +fn fixture() -> (CadDocument, Handle, Vec) { + let mut scene = Scene::new(); + scene.define_block_raw( + "ACTIONS", + Vector3::ZERO, + vec![ + EntityType::Line(codec::entities::Line::from_points( + Vector3::UNIT_X, + Vector3::new(2., 0., 0.), + )), + EntityType::Circle(codec::entities::Circle::from_center_radius( + Vector3::new(4., 3., 0.), + 2., + )), + ], + ); + let block = scene.document.block_records.get("ACTIONS").unwrap(); + let (definition, handles) = (block.handle, block.entity_handles.clone()); + (scene.document, definition, handles) +} +fn linear(doc: &mut CadDocument, def: Handle) -> Handle { + distance::create( + doc, + def, + distance::Kind::Linear, + Vector3::ZERO, + Vector3::new(10., 0., 0.), + Vector3::new(5., 2., 0.), + ) + .unwrap() +} +fn start(doc: &CadDocument, h: Handle) -> Vector3 { + let EntityType::Line(l) = doc.get_entity(h).unwrap() else { + panic!() + }; + l.start +} +#[test] +fn block_actions_native_bindings_roundtrip_and_append_preserves_neighbors() { + let (mut doc, def, handles) = fixture(); + let p = linear(&mut doc, def); + for kind in [Kind::Move, Kind::Scale] { + create( + &mut doc, + def, + kind, + p, + &[handles[0]], + DVec3::new(3., 4., 0.), + ) + .unwrap(); + } + let graph = authoring::graph(&doc, def).unwrap().unwrap(); + let ObjectType::DynamicBlock(o) = &doc.objects[&graph] else { + panic!() + }; + let DynamicBlockData::EvaluationGraph(g) = &o.data else { + panic!() + }; + assert!(g + .nodes + .iter() + .all(|n| authoring::graph::valid_lists(g, n.id))); + let dwg = DwgReader::from_stream(Cursor::new(DwgWriter::write_to_vec(&doc).unwrap())) + .read() + .unwrap(); + let dxf = DxfReader::from_reader(Cursor::new(DxfWriter::new(&doc).write_to_vec().unwrap())) + .unwrap() + .read() + .unwrap(); + for saved in [dwg, dxf] { + assert_eq!(actions(&saved, def).len(), 2); + for (h, _, _, _) in actions(&doc, def) { + assert_eq!(saved.objects[&h], doc.objects[&h]); + } + let mut test = saved.clone(); + assert!(evaluate(&mut test, &saved, def, p, DVec3::new(20., 5., 0.)).unwrap()); + assert_eq!(start(&test, handles[0]), Vector3::new(22., 0., 0.)); + assert_eq!(test.get_entity(handles[1]), saved.get_entity(handles[1])); + } +} +#[test] +fn block_actions_point_move_is_absolute_reversible_and_leaves_unselected_objects() { + let (mut doc, def, handles) = fixture(); + let p = point::create( + &mut doc, + def, + Vector3::new(2., 3., 0.), + Vector3::new(5., 6., 0.), + ) + .unwrap(); + create(&mut doc, def, Kind::Move, p, &[handles[0]], DVec3::ZERO).unwrap(); + let baseline = doc.clone(); + for _ in 0..8 { + assert!(evaluate(&mut doc, &baseline, def, p, DVec3::new(12., -7., 0.)).unwrap()); + assert_eq!(start(&doc, handles[0]), Vector3::new(11., -10., 0.)); + assert!(!evaluate(&mut doc, &baseline, def, p, DVec3::new(12., -7., 0.)).unwrap()); + assert!(evaluate(&mut doc, &baseline, def, p, DVec3::new(2., 3., 0.)).unwrap()); + assert_eq!(start(&doc, handles[0]), start(&baseline, handles[0])); + } + assert_eq!(doc.get_entity(handles[1]), baseline.get_entity(handles[1])); +} +#[test] +fn block_actions_scale_uses_distance_ratio_and_parameter_base() { + let (mut doc, def, handles) = fixture(); + let p = distance::create( + &mut doc, + def, + distance::Kind::Polar, + Vector3::new(4., 3., 0.), + Vector3::new(14., 3., 0.), + Vector3::new(9., 5., 0.), + ) + .unwrap(); + create(&mut doc, def, Kind::Scale, p, &[handles[1]], DVec3::ZERO).unwrap(); + let baseline = doc.clone(); + evaluate(&mut doc, &baseline, def, p, DVec3::new(4., 23., 0.)).unwrap(); + let EntityType::Circle(c) = doc.get_entity(handles[1]).unwrap() else { + panic!() + }; + assert_eq!(c.center, Vector3::new(4., 3., 0.)); + assert_eq!(c.radius, 4.); + let before = doc.clone(); + assert!(evaluate(&mut doc, &baseline, def, p, DVec3::new(4., 3., 0.)).is_err()); + assert_eq!(doc, before); +} +#[test] +fn block_actions_reject_invalid_selection_binding_and_nonfinite_without_mutation() { + let (mut doc, def, handles) = fixture(); + let p = linear(&mut doc, def); + let before = doc.clone(); + for (parameter, targets, location) in [ + (Handle::NULL, vec![handles[0]], DVec3::ZERO), + (p, vec![], DVec3::ZERO), + (p, vec![Handle::new(0xff_ff_ff)], DVec3::ZERO), + (p, vec![handles[0]], DVec3::splat(f64::NAN)), + ] { + assert!(create(&mut doc, def, Kind::Move, parameter, &targets, location).is_err()); + assert_eq!(doc, before); + } + let action = create(&mut doc, def, Kind::Move, p, &[handles[0]], DVec3::ZERO).unwrap(); + let baseline = doc.clone(); + assert!(evaluate(&mut doc, &baseline, def, p, DVec3::splat(f64::NAN)).is_err()); + assert_eq!(doc, baseline); + let mut restricted = doc.clone(); + let ObjectType::DynamicBlock(object) = restricted.objects.get_mut(&p).unwrap() else { + panic!() + }; + let DynamicBlockData::LinearParameter(parameter) = &mut object.data else { + panic!() + }; + parameter.value_set.maximum = 15.; + parameter.value_set.flags |= 2; + let restricted_baseline = restricted.clone(); + assert!(evaluate( + &mut restricted, + &restricted_baseline, + def, + p, + DVec3::new(20., 0., 0.) + ) + .is_err()); + assert_eq!( + restricted, restricted_baseline, + "unsupported value constraints are not silently ignored" + ); + let ObjectType::DynamicBlock(o) = doc.objects.get_mut(&action).unwrap() else { + panic!() + }; + let DynamicBlockData::MoveAction(m) = &mut o.data else { + panic!() + }; + m.offsets.distance_multiplier = 0.5; + let baseline = doc.clone(); + assert!(evaluate(&mut doc, &baseline, def, p, DVec3::new(15., 0., 0.)).is_err()); + assert_eq!(doc, baseline); +} +#[test] +fn block_actions_reframe_moves_markers_and_retains_targets_and_connections() { + let (mut doc, def, handles) = fixture(); + let p = linear(&mut doc, def); + let h = create( + &mut doc, + def, + Kind::Scale, + p, + &[handles[0]], + DVec3::new(3., 4., 0.), + ) + .unwrap(); + let before = doc.objects[&h].clone(); + let t = Transform::from_translation(Vector3::new(-2., -3., 0.)); + assert_eq!(reframe(&mut doc, def, &t), 1); + let ObjectType::DynamicBlock(o) = &doc.objects[&h] else { + panic!() + }; + let DynamicBlockData::ScaleAction(s) = &o.data else { + panic!() + }; + assert_eq!(s.action.action.display_location, Vector3::new(1., 1., 0.)); + let ObjectType::DynamicBlock(o) = before else { + panic!() + }; + let DynamicBlockData::ScaleAction(b) = o.data else { + panic!() + }; + assert_eq!(s.action.action.dependencies, b.action.action.dependencies); + assert_eq!(s.connections, b.connections); +} diff --git a/src/scene/block_authoring.rs b/src/scene/block_authoring.rs index aa7657c81..6c0d9255a 100644 --- a/src/scene/block_authoring.rs +++ b/src/scene/block_authoring.rs @@ -1,4 +1,5 @@ //! Shared native graph construction for Block Editor authoring objects. +pub(crate) mod graph; use codec::objects::{ BlockEvalExpression, BlockEvalValue, DynamicBlockData, DynamicBlockObject, ObjectType, }; diff --git a/src/scene/block_lookup/graph.rs b/src/scene/block_authoring/graph.rs similarity index 86% rename from src/scene/block_lookup/graph.rs rename to src/scene/block_authoring/graph.rs index 7c66c8586..1b8356c56 100644 --- a/src/scene/block_lookup/graph.rs +++ b/src/scene/block_authoring/graph.rs @@ -1,8 +1,8 @@ -//! Maintain native adjacency lists while adding/replacing lookup dependencies. -use super::*; -use codec::objects::{BlockEvaluationEdge, BlockEvaluationGraph}; +//! Maintain native adjacency lists while adding/replacing action dependencies. +use codec::objects::{BlockEvaluationEdge, BlockEvaluationGraph, DynamicBlockData, ObjectType}; +use codec::{CadDocument, Handle}; -pub(super) fn valid_lists(g: &BlockEvaluationGraph, node: i32) -> bool { +pub(crate) fn valid_lists(g: &BlockEvaluationGraph, node: i32) -> bool { let Some(n) = g.nodes.iter().find(|n| n.id == node) else { return false; }; @@ -115,11 +115,11 @@ fn add(g: &mut BlockEvaluationGraph, id: i32, source: i32, destination: i32, inv outgoing_edges: [incoming, -1, outgoing, -1, inverse], }); } -pub(super) fn bind( +pub(crate) fn bind_ids( document: &mut CadDocument, graph: Handle, action: i32, - columns: &[BlockLookupColumn], + parameters: &[i32], ) -> Result<(), &'static str> { let Some(ObjectType::DynamicBlock(object)) = document.objects.get(&graph) else { return Err("The authoring graph is unavailable."); @@ -129,27 +129,27 @@ pub(super) fn bind( }; let mut g = original.clone(); if std::iter::once(action) - .chain(columns.iter().map(|c| c.node_id)) + .chain(parameters.iter().copied()) .any(|id| g.nodes.iter().filter(|n| n.next_id == id).count() != 1) { - return Err("A Lookup binding has an invalid or ambiguous expression ID."); + return Err("An action binding has an invalid or ambiguous expression ID."); } let action = g .nodes .iter() .find(|n| n.next_id == action) - .ok_or("The Lookup action node is unavailable.")? + .ok_or("The action node is unavailable.")? .id; let mut bindings = vec![]; - for column in columns { + for parameter in parameters { let node = g .nodes .iter() - .find(|n| n.next_id == column.node_id) - .ok_or("A table property no longer belongs to this block.")? + .find(|n| n.next_id == *parameter) + .ok_or("A bound parameter no longer belongs to this block.")? .id; if node == action { - return Err("A Lookup table cannot use its action as a property."); + return Err("An action cannot bind to itself."); } if !bindings.contains(&node) { bindings.push(node); @@ -165,10 +165,10 @@ pub(super) fn bind( .chain(old.iter().flat_map(|e| [e.source_node, e.destination_node])) .any(|n| !valid_lists(&g, n)) { - return Err("The Lookup graph has invalid adjacency links."); + return Err("The authoring graph has invalid adjacency links."); } if old.iter().any(|e| e.next_id != 4) { - return Err("This Lookup action has unsupported graph dependencies."); + return Err("This action has unsupported graph dependencies."); } if old.iter().any(|e| { !old.iter().any(|inverse| { @@ -178,7 +178,7 @@ pub(super) fn bind( && inverse.destination_node == e.source_node }) }) { - return Err("The Lookup graph has invalid inverse dependencies."); + return Err("The authoring graph has invalid inverse dependencies."); } let expected = bindings .iter() diff --git a/src/scene/block_lookup.rs b/src/scene/block_lookup.rs index 0b968920d..fa8c8fa1f 100644 --- a/src/scene/block_lookup.rs +++ b/src/scene/block_lookup.rs @@ -9,7 +9,7 @@ use codec::{ types::{Transform, Vector3}, CadDocument, Handle, }; -mod graph; +use super::block_authoring::graph; #[cfg(test)] mod tests; @@ -349,7 +349,13 @@ fn apply_table_inner( h }; let node_id = edited.action.element.eval.node_id; - graph::bind(document, graph, node_id, &edited.columns).map_err(str::to_string)?; + graph::bind_ids( + document, + graph, + node_id, + &edited.columns.iter().map(|c| c.node_id).collect::>(), + ) + .map_err(str::to_string)?; if let Some(ObjectType::DynamicBlock(o)) = document.objects.get_mut(&handle) { o.data = DynamicBlockData::LookupAction(edited); } diff --git a/src/scene/block_visibility.rs b/src/scene/block_visibility.rs index 6c88a883c..282562974 100644 --- a/src/scene/block_visibility.rs +++ b/src/scene/block_visibility.rs @@ -499,18 +499,7 @@ impl Scene { .map_or(1, |n| n + 1); format!("*U{next}") }; - let base = self - .document - .block_records - .iter() - .find(|record| record.handle == source) - .and_then( - |record| match self.document.get_entity(record.block_entity_handle) { - Some(EntityType::Block(block)) => Some(block.base_point), - _ => None, - }, - ) - .unwrap_or(Vector3::ZERO); + let base = super::render_graph::block_base_point(&self.document, &reference.block_name); let originals = members(&self.document, source); let copies: Vec = originals .iter() diff --git a/src/scene/camera_ops.rs b/src/scene/camera_ops.rs index 5b7a6182d..c286840b3 100644 --- a/src/scene/camera_ops.rs +++ b/src/scene/camera_ops.rs @@ -1710,6 +1710,11 @@ impl Scene { super::block_lookup::parameters(&self.document, block) .into_iter() .map(|(_, p)| p.parameter.definition_point), + ) + .chain( + super::block_actions::actions(&self.document, block) + .into_iter() + .map(|(_, a, _, _)| a.display_location), ); for point in locations { let point = glam::DVec3::new(point.x, point.y, point.z); diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 4dbd72a32..0b2958a82 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -13,6 +13,7 @@ pub mod block_visibility; pub(crate) mod block_authoring; pub(crate) mod block_basepoint; pub(crate) mod block_lookup; +pub(crate) mod block_actions; pub(crate) mod block_point_parameter; pub(crate) mod block_distance_parameter; pub(crate) mod block_orientation_parameter; @@ -64,6 +65,7 @@ mod layout; mod limits; mod modify; pub(crate) use modify::remap_copied_reactors; +pub(crate) use modify::inverse_affine; mod mspace; pub mod named_parameters; mod page_setup; diff --git a/src/scene/modify.rs b/src/scene/modify.rs index 7a95f3fea..f7d078dd7 100644 --- a/src/scene/modify.rs +++ b/src/scene/modify.rs @@ -9,7 +9,9 @@ struct TextOrient { x_scale: f64, } -fn inverse_affine(transform: &codec::types::Transform) -> Option { +pub(crate) fn inverse_affine( + transform: &codec::types::Transform, +) -> Option { use codec::types::{Matrix3, Matrix4, Transform, Vector3}; let matrix = &transform.matrix.m; let linear = Matrix3::from_rows( @@ -580,7 +582,8 @@ impl Scene { ); let parameter_changes = parameter_changes + basepoint_changes - + super::block_lookup::reframe(&mut self.document, block_record, local_from_old); + + super::block_lookup::reframe(&mut self.document, block_record, local_from_old) + + super::block_actions::reframe(&mut self.document, block_record, local_from_old); if !changed.is_empty() || parameter_changes > 0 { self.rebuild_derived_caches(); } diff --git a/src/ui/window/authoring_palettes.rs b/src/ui/window/authoring_palettes.rs index 2d37e9d54..348c80a76 100644 --- a/src/ui/window/authoring_palettes.rs +++ b/src/ui/window/authoring_palettes.rs @@ -194,6 +194,8 @@ fn catalog( point_error: Option<&'static str>, flip_error: Option<&'static str>, lookup_error: Option<&'static str>, + move_error: Option<&'static str>, + scale_error: Option<&'static str>, ) -> Element<'static, Message> { let (entries, unavailable) = match tab { Tab::Parameters => ( @@ -215,7 +217,9 @@ fn catalog( let orientation_error = if *label=="Flip" {flip_error} else {point_error}; let single=tab==Tab::Parameters&&matches!(*label,"Lookup"|"Basepoint"); let lookup_action=tab==Tab::Actions&&*label=="Lookup"; - let enabled = (single&&point_error.is_none())||(lookup_action&&lookup_error.is_none())||(orientation && orientation_error.is_none()) || (visibility && visibility_error.is_none()) || ((point || distance) && point_error.is_none()); + let geometry_action=tab==Tab::Actions&&matches!(*label,"Move"|"Scale"); + let action_error=if *label=="Scale" {scale_error}else{move_error}; + let enabled = (geometry_action&&action_error.is_none())||(single&&point_error.is_none())||(lookup_action&&lookup_error.is_none())||(orientation && orientation_error.is_none()) || (visibility && visibility_error.is_none()) || ((point || distance) && point_error.is_none()); let base = if enabled { icons::semantic(icon, 28.0) } else { @@ -239,8 +243,8 @@ fn catalog( base }; let message = enabled.then(|| Message::RibbonToolClick { - tool_id: if lookup_action {"BLOOKUPTABLE".into()}else{format!("BPARAMETER_{}", label.to_ascii_uppercase())}, - event: crate::modules::ModuleEvent::Command(if lookup_action {"BLOOKUPTABLE".into()}else{format!("BPARAMETER {}", label.to_ascii_uppercase())}), + tool_id: if geometry_action {format!("BACTION_{}",label.to_ascii_uppercase())}else if lookup_action {"BLOOKUPTABLE".into()}else{format!("BPARAMETER_{}", label.to_ascii_uppercase())}, + event: crate::modules::ModuleEvent::Command(if geometry_action {format!("BACTION {}",label.to_ascii_uppercase())}else if lookup_action {"BLOOKUPTABLE".into()}else{format!("BPARAMETER {}", label.to_ascii_uppercase())}), }); let description = if visibility { crate::t!(visibility_error @@ -259,6 +263,8 @@ fn catalog( })).into_owned() } else if single { crate::t!(point_error.unwrap_or(if *label=="Basepoint" {"Place or relocate the dynamic block insertion anchor."}else{"Place a Lookup parameter, then use the Lookup action to edit its property table."})).into_owned() + } else if geometry_action { + crate::t!(action_error.unwrap_or("Select a parameter endpoint, select the objects it controls, then place the action marker. Test it using the parameter grip in Test Block.")).into_owned() } else if lookup_action { crate::t!(lookup_error.unwrap_or("Create or edit property Lookup tables for this block.")).into_owned() } else { @@ -275,6 +281,8 @@ fn catalog( container(entry).id(match *label {"Rotation"=>"authoring-tool-rotation","Alignment"=>"authoring-tool-alignment",_=>"authoring-tool-flip"}).into() } else if single { container(entry).id(if *label=="Lookup" {"authoring-tool-lookup"}else{"authoring-tool-basepoint"}).into() + } else if geometry_action { + container(entry).id(if *label=="Move" {"authoring-tool-move-action"}else{"authoring-tool-scale-action"}).into() } else if lookup_action { container(entry).id("authoring-tool-lookup-table").into() } else { @@ -391,6 +399,8 @@ pub fn view( point_error: Option<&'static str>, flip_error: Option<&'static str>, lookup_error: Option<&'static str>, + move_error: Option<&'static str>, + scale_error: Option<&'static str>, ) -> Element<'static, Message> { let tabs = column(Tab::ALL.into_iter().map(|category| { let label = category.label(); @@ -445,7 +455,9 @@ pub fn view( visibility_error, point_error, flip_error, - lookup_error + lookup_error, + move_error, + scale_error )) .width(Fill) .height(Fill), From 677ff0e08c2b229e2b226be6d7fc41aa3d9d7147 Mon Sep 17 00:00:00 2001 From: Karim Jerbi Date: Fri, 16 Oct 2026 17:44:09 +0100 Subject: [PATCH 3/4] feat(block-editor): select and edit native parameter and action grips Parameters and Move/Scale action markers were drawn as overlays with no selectable authoring objects. Pick their points, labels or graphics in BEDIT to display editable definition, endpoint, reference and label grips using the existing selection overlay and point-input commands. Show the selected object's coordinates in the Properties panel. Write edits to the native parameter/grip/action records while preserving graph identities and target bindings. Maintain Rotation radii and oriented grip directions, synchronize visibility lookup data, and retain Basepoint attribute relocation with recorded entity history. Escape cancels edits or clears selection; editor undo/redo and save/discard preserve the native changes. Validate with self-contained viewport-input and native DWG/DXF persistence regressions, all ten parameter types, action bindings, cancellation and Basepoint attribute undo. Checks: 3 focused, 323 block (6 existing opt-in ignored), 39 dock and 23 viewport tests passed; counts overlap. Ordinary drawing-reference grips and per-instance saved action evaluation follow in the next milestone. --- src/app/block_authoring_edit.rs | 230 +++++++++++++ src/app/block_editor_tests.rs | 173 ++++++++++ src/app/command_driver/mod.rs | 3 + src/app/document.rs | 2 + src/app/mod.rs | 1 + src/app/properties.rs | 40 +++ src/app/update/command.rs | 1 + src/app/update/viewport.rs | 10 + src/command.rs | 7 + src/scene/block_authoring.rs | 1 + src/scene/block_authoring/edit.rs | 523 ++++++++++++++++++++++++++++++ 11 files changed, 991 insertions(+) create mode 100644 src/app/block_authoring_edit.rs create mode 100644 src/scene/block_authoring/edit.rs diff --git a/src/app/block_authoring_edit.rs b/src/app/block_authoring_edit.rs new file mode 100644 index 000000000..efc4c542c --- /dev/null +++ b/src/app/block_authoring_edit.rs @@ -0,0 +1,230 @@ +use super::{Message, OpenCADStudio}; +use crate::command::{CadCommand, CmdResult}; +use crate::scene::block_authoring::edit::{self, Part}; +use codec::Handle; +use glam::DVec3; +use iced::{Point, Rectangle, Task}; + +const EDIT_GRIP_START: usize = usize::MAX / 4; +pub(super) fn is_editor_grip(id: usize) -> bool { + (EDIT_GRIP_START..EDIT_GRIP_START + 16).contains(&id) +} +struct EditCommand { + document: u64, + definition: Handle, + handle: Handle, + part: Part, +} +impl CadCommand for EditCommand { + fn name(&self) -> &'static str { + "BEDITGRIP" + } + fn prompt(&self) -> String { + crate::t!("Block Editor Specify the new authoring grip location (Escape cancels):") + .into_owned() + } + fn on_point(&mut self, position: DVec3) -> CmdResult { + CmdResult::EditBlockAuthoring { + document: self.document, + definition: self.definition, + handle: self.handle, + part: self.part, + position, + } + } + fn on_enter(&mut self) -> CmdResult { + CmdResult::Cancel + } +} +impl OpenCADStudio { + pub(super) fn pick_authoring_object(&mut self, p: Point, bounds: Rectangle) -> bool { + let i = self.active_tab; + let tab = &self.tabs[i]; + if tab.active_cmd.is_some() || tab.active_grip.is_some() { + return false; + } + let Some(session) = tab.active_block_edit_session() else { + return false; + }; + let items = edit::items(&tab.scene.document, session.br_handle); + let camera = tab.scene.camera.borrow(); + let project = |v: codec::types::Vector3| { + camera + .project(DVec3::new(v.x, v.y, v.z), bounds) + .map(|v| Point::new(v.x, v.y)) + }; + let segment = |a: Point, b: Point| { + let ab = glam::Vec2::new(b.x - a.x, b.y - a.y); + let ap = glam::Vec2::new(p.x - a.x, p.y - a.y); + let t = (ap.dot(ab) / ab.length_squared().max(1e-12)).clamp(0., 1.); + (ap - ab * t).length() + }; + let hit = items + .iter() + .filter_map(|item| { + let mut d = f32::INFINITY; + for (_, v) in &item.grips { + if let Some(v) = project(*v) { + d = d.min(segment(v, v)); + } + } + for [a, b] in &item.graphic.lines { + if let (Some(a), Some(b)) = (project(*a), project(*b)) { + d = d.min(segment(a, b)); + } + } + for (v, label) in &item.graphic.labels { + if let Some(v) = project(*v) { + // Labels are drawn starting beside their anchor, in screen pixels. + let width = (label.chars().count() as f32 * 7.).max(20.); + if p.x >= v.x - 8. && p.x <= v.x + width + 12. && (p.y - v.y).abs() < 16. { + d = d.min(0.); + } + } + } + (d <= 10.).then_some((d, item.handle, item.name.clone())) + }) + .min_by(|a, b| a.0.total_cmp(&b.0)); + drop(camera); + if let Some((_, handle, name)) = hit { + self.tabs[i].scene.deselect_all(); + self.tabs[i].selected_authoring = (!self.shift_down).then_some(handle); + self.tabs[i] + .scene + .selection + .borrow_mut() + .clear_left_selection_gesture(); + self.refresh_properties(); + self.command_line.push_info(&format!( + "{} — {}", + name, + crate::t!("Select a grip to edit its location.") + )); + true + } else { + if self.tabs[i].selected_authoring.take().is_some() { + self.refresh_properties(); + } + false + } + } + pub(super) fn refresh_authoring_grips(&mut self, wo: [f64; 3]) { + let i = self.active_tab; + let Some(handle) = self.tabs[i].selected_authoring else { + return; + }; + let item = self.tabs[i].active_block_edit_session().and_then(|s| { + edit::items(&self.tabs[i].scene.document, s.br_handle) + .into_iter() + .find(|p| p.handle == handle) + }); + let Some(item) = item.filter(|_| self.tabs[i].scene.selected.is_empty()) else { + self.tabs[i].selected_authoring = None; + return; + }; + for (index, (_, point)) in item.grips.into_iter().enumerate() { + self.tabs[i] + .selected_grips + .push(crate::scene::model::object::GripDef { + id: EDIT_GRIP_START + index, + world: DVec3::new(point.x - wo[0], point.y - wo[1], point.z - wo[2]), + is_midpoint: false, + shape: crate::scene::model::object::GripShape::Square, + dir: None, + axis: None, + }); + self.tabs[i].selected_grip_handles.push(handle); + } + } + pub(super) fn start_authoring_grip(&mut self, handle: Handle, id: usize) -> bool { + if !is_editor_grip(id) { + return false; + } + let i = self.active_tab; + let Some(session) = self.tabs[i].active_block_edit_session() else { + return false; + }; + let Some(part) = edit::items(&self.tabs[i].scene.document, session.br_handle) + .into_iter() + .find(|p| p.handle == handle) + .and_then(|p| p.grips.get(id - EDIT_GRIP_START).map(|(part, _)| *part)) + else { + return false; + }; + let command = EditCommand { + document: self.tabs[i].id, + definition: session.br_handle, + handle, + part, + }; + self.command_line.push_info(&command.prompt()); + self.tabs[i].active_cmd = Some(Box::new(command)); + self.grip_hover = None; + self.grip_popup = None; + true + } + pub(super) fn handle_authoring_edit(&mut self, result: CmdResult) -> Task { + let CmdResult::EditBlockAuthoring { + document, + definition, + handle, + part, + position, + } = result + else { + unreachable!() + }; + let i = self.active_tab; + if self.tabs[i].id != document + || self.tabs[i] + .active_block_edit_session() + .map(|s| s.br_handle) + != Some(definition) + { + self.command_line + .push_error(crate::t!("The Block Editor session is no longer active.").as_ref()); + return Task::none(); + } + let mut staged = self.tabs[i].scene.document.clone(); + match edit::move_part( + &mut staged, + definition, + handle, + part, + codec::types::Vector3::new(position.x, position.y, position.z), + ) { + Ok(changed) => { + if changed { + self.push_undo_snapshot(i, "Block authoring grip"); + // Keep the live entity-change recorder attached. Basepoint edits + // can also relocate INSERT attributes outside the edited block. + let doc = &mut self.tabs[i].scene.document; + let changed_entities = staged + .entities() + .filter(|e| doc.get_entity(e.common().handle) != Some(*e)) + .cloned() + .collect::>(); + for entity in changed_entities { + let handle = entity.common().handle; + *doc.get_entity_mut(handle) + .expect("authoring edits retain entity identities") = entity; + } + for (h, o) in staged.objects.iter() { + if doc.objects.get(h) != Some(o) { + doc.objects.insert(*h, o.clone()); + } + } + doc.block_visibility_params = staged.block_visibility_params.clone(); + self.tabs[i].scene.bump_geometry(); + self.tabs[i].dirty = true; + self.finish_pending_history(i); + } + self.tabs[i].active_cmd = None; + self.tabs[i].scene.clear_preview_wire(); + self.refresh_properties(); + } + Err(e) => self.command_line.push_error(crate::t!(e).as_ref()), + } + Task::none() + } +} diff --git a/src/app/block_editor_tests.rs b/src/app/block_editor_tests.rs index 54c733751..6bc41e9f9 100644 --- a/src/app/block_editor_tests.rs +++ b/src/app/block_editor_tests.rs @@ -59,6 +59,179 @@ fn feed(app: &mut OpenCADStudio, text: &str) { let _ = app.update(Message::CommandSubmit); } +#[test] +fn authoring_edit_basepoint_attribute_relocation_undo_and_cancel() { + let (mut app, reference) = fixture(); + begin(&mut app, reference); + let i = app.active_tab; + let _ = app.run_command_line("BPARAMETER BASEPOINT 2,3"); + let def = app.tabs[i].active_block_edit_session().unwrap().br_handle; + let handle = crate::scene::block_basepoint::parameter(&app.tabs[i].scene.document, def) + .unwrap() + .0; + let mut attribute = codec::entities::AttributeEntity::new("TAG".into(), "value".into()); + attribute.insertion_point = Vector3::new(7., 8., 0.); + let EntityType::Insert(insert) = app.tabs[i] + .scene + .document + .get_entity_mut(reference) + .unwrap() + else { + panic!() + }; + insert.attributes.push(attribute); + let before = app.tabs[i].scene.document.clone(); + let result = crate::command::CmdResult::EditBlockAuthoring { + document: app.tabs[i].id, + definition: def, + handle, + part: crate::scene::block_authoring::edit::Part::Point, + position: glam::DVec3::new(4., 6., 0.), + }; + let _ = app.handle_authoring_edit(result); + assert_ne!( + app.tabs[i].scene.document.get_entity(reference), + before.get_entity(reference) + ); + app.undo_steps(1); + assert_eq!(app.tabs[i].scene.document, before); + app.redo_steps(1); + assert_ne!( + app.tabs[i].scene.document.get_entity(reference), + before.get_entity(reference) + ); + app.tabs[i].selected_authoring = Some(handle); + app.refresh_properties(); + let id = app.tabs[i].selected_grips[0].id; + assert!(app.start_authoring_grip(handle, id)); + let after = app.tabs[i].scene.document.clone(); + let depth = app.tabs[i].history.undo_stack.len(); + let _ = app.update(Message::CommandEscape); + assert_eq!(app.tabs[i].scene.document, after); + assert_eq!(app.tabs[i].history.undo_stack.len(), depth); + let _ = app.update(Message::CommandEscape); + assert_eq!(app.tabs[i].selected_authoring, None); +} + +#[test] +fn authoring_edit_mouse_selection_endpoint_label_marker_history_and_reopen() { + use crate::scene::block_authoring::edit::{self, Part}; + use crate::scene::block_actions::{self as actions, Kind}; + let (mut app, reference) = fixture(); + begin(&mut app, reference); + let i = app.active_tab; + let _ = app.run_command_line("BPARAMETER LINEAR 2,2 6,2 4,3"); + let def = app.tabs[i].active_block_edit_session().unwrap().br_handle; + let p = actions::parameters(&app.tabs[i].scene.document, def, Kind::Move)[0].clone(); + let targets = members(&app); + let a = actions::create( + &mut app.tabs[i].scene.document, + def, + Kind::Move, + p.handle, + &targets, + glam::DVec3::new(5., 5., 0.), + ) + .unwrap(); + app.tabs[i].scene.selection.borrow_mut().view.vp_size = (1600., 900.); + app.tabs[i].scene.camera.borrow_mut().fit_to_bounds_f64( + glam::DVec3::new(-1., -1., 0.), + glam::DVec3::new(10., 7., 0.), + 1600. / 900., + ); + let click = |app: &mut OpenCADStudio, point: Vector3| { + let scene = &app.tabs[app.active_tab].scene; + let (w, h) = scene.selection.borrow().view.vp_size; + let b = scene.active_model_tile_bounds(w, h); + let s = scene + .camera + .borrow() + .project( + glam::DVec3::new(point.x, point.y, point.z), + iced::Rectangle { + x: 0., + y: 0., + width: b.width, + height: b.height, + }, + ) + .unwrap(); + let _ = app.update(Message::ViewportLeftPressAt(iced::Point::new( + s.x + b.x, + s.y + b.y, + ))); + let _ = app.update(Message::ViewportLeftRelease); + }; + click(&mut app, Vector3::new(4., 3., 0.)); + assert_eq!(app.tabs[i].selected_authoring, Some(p.handle)); + assert_eq!(app.tabs[i].selected_grips.len(), 3); + let before = app.tabs[i].scene.document.clone(); + let depth = app.tabs[i].history.undo_stack.len(); + click(&mut app, Vector3::new(6., 2., 0.)); + assert_eq!( + app.tabs[i].active_cmd.as_ref().map(|c| c.name()), + Some("BEDITGRIP") + ); + feed(&mut app, "8,2"); + assert!(app.tabs[i].active_cmd.is_none()); + assert_eq!( + actions::parameters(&app.tabs[i].scene.document, def, Kind::Move)[0].end, + glam::DVec3::new(8., 2., 0.) + ); + assert_eq!(app.tabs[i].history.undo_stack.len(), depth + 1); + app.undo_steps(1); + assert_eq!(app.tabs[i].scene.document, before); + app.redo_steps(1); + let label = edit::items(&app.tabs[i].scene.document, def) + .into_iter() + .find(|i| i.handle == p.handle) + .unwrap() + .grips + .into_iter() + .find(|(p, _)| *p == Part::Label(0)) + .unwrap() + .1; + click(&mut app, label); + feed(&mut app, "5,4"); + let graphic = edit::items(&app.tabs[i].scene.document, def) + .into_iter() + .find(|i| i.handle == p.handle) + .unwrap(); + assert_eq!(graphic.graphic.labels[0].0, Vector3::new(5., 4., 0.)); + let _ = app.update(Message::CommandEscape); + click(&mut app, Vector3::new(5., 5., 0.)); + assert_eq!(app.tabs[i].selected_authoring, Some(a)); + click(&mut app, Vector3::new(5., 5., 0.)); + feed(&mut app, "7,5"); + let action = actions::actions(&app.tabs[i].scene.document, def); + assert_eq!(action[0].1.display_location, Vector3::new(7., 5., 0.)); + assert_eq!(action[0].1.dependencies, targets); + let _ = app.dispatch_command("BSAVE"); + let _ = app.dispatch_command("BEDIT_DISCARD"); + assert!(app.tabs[i] + .selected_grips + .iter() + .all(|g| !super::block_authoring_edit::is_editor_grip(g.id))); + // Persisted native coordinates, rather than session-only display positions. + use std::io::Cursor; + let bytes = codec::DwgWriter::write_to_vec(&app.tabs[i].scene.document).unwrap(); + let loaded = codec::DwgReader::from_stream(Cursor::new(bytes)) + .read() + .unwrap(); + assert_eq!( + actions::parameters(&loaded, def, Kind::Move)[0].end, + glam::DVec3::new(8., 2., 0.) + ); + assert_eq!( + actions::actions(&loaded, def)[0].1.display_location, + Vector3::new(7., 5., 0.) + ); + app.tabs[i].scene.document = loaded; + begin(&mut app, reference); + click(&mut app, Vector3::new(8., 2., 0.)); + assert_eq!(app.tabs[i].selected_authoring, Some(p.handle)); +} + #[test] fn block_actions_mouse_selection_waits_for_enter_for_move_and_scale() { use crate::scene::block_actions::{self as actions, Kind}; diff --git a/src/app/command_driver/mod.rs b/src/app/command_driver/mod.rs index aee127260..ae929a040 100644 --- a/src/app/command_driver/mod.rs +++ b/src/app/command_driver/mod.rs @@ -575,6 +575,9 @@ impl OpenCADStudio { return task; } } + result @ CmdResult::EditBlockAuthoring { .. } => { + return self.handle_authoring_edit(result); + } result @ (CmdResult::PlaceBlockAction { .. } | CmdResult::EvaluateBlockParameter { .. }) => { return self.handle_block_action_result(result); diff --git a/src/app/document.rs b/src/app/document.rs index 77871252f..dfe2434c2 100644 --- a/src/app/document.rs +++ b/src/app/document.rs @@ -182,6 +182,7 @@ pub(super) struct DocumentTab { /// the active grip edit instead of committing it. pub(super) grip_base_pending: bool, pub(super) selected_handle: Option, + pub(super) selected_authoring: Option, /// Dynamic-block visibility grip for the current single selection. pub(super) visibility_grip: Option, pub(super) wireframe: bool, @@ -657,6 +658,7 @@ impl DocumentTab { grip_copy: false, grip_base_pending: false, selected_handle: None, + selected_authoring: None, visibility_grip: None, wireframe: false, render_mode: codec::entities::ViewportRenderMode::Wireframe2D, diff --git a/src/app/mod.rs b/src/app/mod.rs index 2c1bd36cc..717e0ecb2 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -15,6 +15,7 @@ mod block_save_as; mod block_visibility; mod block_lookup; mod block_actions; +mod block_authoring_edit; mod block_properties; #[cfg(test)] mod block_properties_tests; diff --git a/src/app/properties.rs b/src/app/properties.rs index 9882203f3..a243f06ce 100644 --- a/src/app/properties.rs +++ b/src/app/properties.rs @@ -2475,6 +2475,45 @@ filter={:.1} local={:.1} aggregate={:.1} entities={}", } } }; + if selected.is_empty() { + if let Some(item) = self.tabs[i].selected_authoring.and_then(|handle| { + let session = self.tabs[i].active_block_edit_session()?; + crate::scene::block_authoring::edit::items( + &self.tabs[i].scene.document, + session.br_handle, + ) + .into_iter() + .find(|item| item.handle == handle) + }) { + use crate::scene::model::object::{PropSection, PropValue, Property}; + panel.title = item.name; + panel.sections = vec![PropSection { + title: crate::t!("Geometry").into_owned(), + props: item + .grips + .into_iter() + .map(|(part, point)| Property { + label: crate::t!(match part { + crate::scene::block_authoring::edit::Part::Point => "Position", + crate::scene::block_authoring::edit::Part::Base => "Base point", + crate::scene::block_authoring::edit::Part::End => "End point", + crate::scene::block_authoring::edit::Part::Reference => + "Reference point", + crate::scene::block_authoring::edit::Part::Label(0) => + "Label position", + _ => "Second label position", + }) + .into_owned(), + field: "authoring_grip_position", + value: PropValue::ReadOnly(format!( + "{:.4}, {:.4}, {:.4}", + point.x, point.y, point.z + )), + }) + .collect(), + }]; + } + } // Precompute the focused-id → field-key map for O(1) lookups on // `PropSyncActive`; derived from `sections`, so rebuild it here. panel.field_key_by_id = crate::ui::properties::build_field_key_map(&panel.sections); @@ -2887,6 +2926,7 @@ handles={handles_ms:.1} panel={:.1} ribbon={ribbon_ms:.1} tail={:.1} selected={} // selection is a visibility-parametric block reference. self.refresh_visibility_grip(wo); self.refresh_test_action_grips(wo); + self.refresh_authoring_grips(wo); } pub(super) fn property_target_handles(&self, i: usize) -> Vec { diff --git a/src/app/update/command.rs b/src/app/update/command.rs index 83e6fbc47..a85ae7f7c 100644 --- a/src/app/update/command.rs +++ b/src/app/update/command.rs @@ -1410,6 +1410,7 @@ impl OpenCADStudio { // ESC while in MSPACE → exit back to paper space. return Task::done(Message::ExitViewport); } else { + self.tabs[i].selected_authoring = None; self.tabs[i].scene.deselect_all(); self.refresh_properties(); let mut sel = self.tabs[i].scene.selection.borrow_mut(); diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index b3453639b..6a5ea31c6 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -832,6 +832,7 @@ impl OpenCADStudio { .filter(|(_, grip)| { grip.id != crate::app::visibility::VIS_GRIP_ID && !crate::app::block_actions::is_test_grip(grip.id) + && !crate::app::block_authoring_edit::is_editor_grip(grip.id) }) .map(|(owner, grip)| GripTarget { handle: owner, @@ -857,6 +858,7 @@ impl OpenCADStudio { .filter(|(_, grip)| { grip.id != crate::app::visibility::VIS_GRIP_ID && !crate::app::block_actions::is_test_grip(grip.id) + && !crate::app::block_authoring_edit::is_editor_grip(grip.id) }) .filter(|(_, grip)| (grip.world - world).length_squared() <= epsilon_sq) .map(|(owner, grip)| GripTarget { @@ -1000,6 +1002,7 @@ impl OpenCADStudio { if self.grip_popup.is_none() && grip_id != crate::app::visibility::VIS_GRIP_ID && !crate::app::block_actions::is_test_grip(grip_id) + && !crate::app::block_authoring_edit::is_editor_grip(grip_id) && self .grip_hover .as_ref() @@ -4003,6 +4006,9 @@ impl OpenCADStudio { if self.start_test_action_grip(handle, grip_id) { return Task::none(); } + if self.start_authoring_grip(handle, grip_id) { + return Task::none(); + } if self.shift_down { let key = (handle, grip_id); if !self.tabs[i].hot_grips.remove(&key) { @@ -4121,6 +4127,10 @@ impl OpenCADStudio { } } + if edit_cam.is_none() && self.pick_authoring_object(p, bounds) { + return Task::none(); + } + // Ctrl+click on a composite solid picks the solid of it under the // cursor — the one the Properties palette and grips then edit. if self.ctrl_down diff --git a/src/command.rs b/src/command.rs index f34a2cc87..f381dc489 100644 --- a/src/command.rs +++ b/src/command.rs @@ -1514,6 +1514,13 @@ impl DimensionPreview { /// Returned by every `CadCommand` method to tell main.rs what to do. #[allow(dead_code)] pub enum CmdResult { + EditBlockAuthoring { + document: u64, + definition: Handle, + handle: Handle, + part: crate::scene::block_authoring::edit::Part, + position: DVec3, + }, PlaceBlockAction { document: u64, definition: Handle, diff --git a/src/scene/block_authoring.rs b/src/scene/block_authoring.rs index 6c0d9255a..6713d2ca0 100644 --- a/src/scene/block_authoring.rs +++ b/src/scene/block_authoring.rs @@ -1,5 +1,6 @@ //! Shared native graph construction for Block Editor authoring objects. pub(crate) mod graph; +pub(crate) mod edit; use codec::objects::{ BlockEvalExpression, BlockEvalValue, DynamicBlockData, DynamicBlockObject, ObjectType, }; diff --git a/src/scene/block_authoring/edit.rs b/src/scene/block_authoring/edit.rs new file mode 100644 index 000000000..47dfbb4ae --- /dev/null +++ b/src/scene/block_authoring/edit.rs @@ -0,0 +1,523 @@ +//! Editor picking and native definition-point edits. No proxy drawing entities. +use super::*; +use crate::scene::block_distance_parameter::{self as distance, Graphic}; +use codec::objects::{BlockGrip, BlockTwoPointParameter}; +use codec::types::Vector3; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Part { + Point, + Base, + End, + Label(usize), + Reference, +} +pub struct Item { + pub handle: Handle, + pub name: String, + pub graphic: Graphic, + pub grips: Vec<(Part, Vector3)>, +} +pub fn items(doc: &CadDocument, definition: Handle) -> Vec { + let mut result = vec![]; + for (handle, p) in crate::scene::block_point_parameter::parameters(doc, definition) { + let mut graphic = Graphic::default(); + graphic + .lines + .push([p.parameter.definition_point, p.definition_label_point]); + graphic + .labels + .push((p.definition_label_point, p.position_name.clone())); + result.push(Item { + handle, + name: p.position_name.clone(), + graphic, + grips: vec![ + (Part::Point, p.parameter.definition_point), + (Part::Label(0), p.definition_label_point), + ], + }); + } + for p in distance::parameters(doc, definition) { + let graphic = distance::graphic(&p); + let mut grips = vec![ + (Part::Base, p.native.definition_base_point), + (Part::End, p.native.definition_end_point), + ]; + // Polar's second label is derived from its angle arc, not an editable offset. + grips.extend( + graphic + .labels + .iter() + .take(if p.kind == distance::Kind::XY { 2 } else { 1 }) + .enumerate() + .map(|(i, (p, _))| (Part::Label(i), *p)), + ); + result.push(Item { + handle: p.handle, + name: p.labels.join(" / "), + graphic, + grips, + }); + } + for p in crate::scene::block_orientation_parameter::parameters(doc, definition) { + use crate::scene::block_orientation_parameter::Kind; + let graphic = crate::scene::block_orientation_parameter::graphic(&p); + let mut grips = vec![ + (Part::Base, p.native.definition_base_point), + (Part::End, p.native.definition_end_point), + ]; + if p.kind == Kind::Rotation { + grips.push((Part::Reference, p.reference)); + } + if p.kind != Kind::Alignment { + grips.extend( + graphic + .labels + .iter() + .take(1) + .map(|(p, _)| (Part::Label(0), *p)), + ); + } + result.push(Item { + handle: p.handle, + name: p.label.clone(), + graphic, + grips, + }); + } + for (handle, p) in crate::scene::block_lookup::parameters(doc, definition) { + result.push(single( + handle, + p.lookup_name.clone(), + p.parameter.definition_point, + )); + } + if let Some((handle, p)) = crate::scene::block_basepoint::parameter(doc, definition) { + result.push(single( + handle, + "Basepoint".into(), + p.parameter.definition_point, + )); + } + if let Some(p) = crate::scene::block_visibility::parameter(doc, definition) { + result.push(single(p.handle, p.name.clone(), p.def_point)); + } + for (handle, p, _, _) in crate::scene::block_actions::actions(doc, definition) { + result.push(single(handle, p.element.name.clone(), p.display_location)); + } + result +} +fn single(handle: Handle, name: String, point: Vector3) -> Item { + let mut graphic = Graphic::default(); + graphic.labels.push((point, name.clone())); + Item { + handle, + name, + graphic, + grips: vec![(Part::Point, point)], + } +} +fn two(data: &mut DynamicBlockData) -> Option<&mut BlockTwoPointParameter> { + Some(match data { + DynamicBlockData::LinearParameter(p) => &mut p.parameter, + DynamicBlockData::PolarParameter(p) => &mut p.parameter, + DynamicBlockData::XYParameter(p) => &mut p.parameter, + DynamicBlockData::RotationParameter(p) => &mut p.parameter, + DynamicBlockData::AlignmentParameter(p) => &mut p.parameter, + DynamicBlockData::FlipParameter(p) => &mut p.parameter, + _ => return None, + }) +} +fn grip(data: &mut DynamicBlockData) -> Option<&mut BlockGrip> { + Some(match data { + DynamicBlockData::LinearGrip(p) | DynamicBlockData::AlignmentGrip(p) => &mut p.grip, + DynamicBlockData::XYGrip(p) + | DynamicBlockData::PolarGrip(p) + | DynamicBlockData::RotationGrip(p) + | DynamicBlockData::LookupGrip(p) + | DynamicBlockData::VisibilityGrip(p) => p, + DynamicBlockData::FlipGrip(p) => &mut p.grip, + _ => return None, + }) +} +pub fn move_part( + doc: &mut CadDocument, + definition: Handle, + handle: Handle, + part: Part, + at: Vector3, +) -> Result { + if ![at.x, at.y, at.z].iter().all(|v| v.is_finite()) { + return Err("Specify a finite authoring location."); + } + let item = items(doc, definition) + .into_iter() + .find(|i| i.handle == handle) + .ok_or("The authoring object is no longer available.")?; + let old = item + .grips + .iter() + .find(|(p, _)| *p == part) + .map(|(_, p)| *p) + .ok_or("This authoring grip is unavailable.")?; + if (at.z - old.z).abs() > 1e-8 { + return Err("Keep the authoring grip in its block XY plane."); + } + if (at - old).length() < 1e-10 { + return Ok(false); + } + let graph = graph(doc, definition)?.ok_or("The authoring graph is unavailable.")?; + let mut staged = doc.clone(); + if crate::scene::block_basepoint::parameter(doc, definition).is_some_and(|(h, _)| h == handle) { + crate::scene::block_basepoint::create(&mut staged, definition, at)?; + *doc = staged; + return Ok(true); + } + let mut moved_grips = vec![]; + match staged + .objects + .get_mut(&handle) + .ok_or("The authoring object is unavailable.")? + { + ObjectType::BlockVisibilityParameter(p) => { + p.def_point = at; + moved_grips.extend( + p.property_info + .iter() + .flat_map(|p| p.connections.iter().map(|c| (c.code, at))), + ); + } + ObjectType::DynamicBlock(object) => { + if let Some(p) = two(&mut object.data) { + match part { + Part::Base => { + p.definition_base_point = at; + p.base_point = p.base_point.map(|_| at); + p.updated_base_point = p.updated_base_point.map(|_| at); + moved_grips.push((p.property_states[0], at)); + } + Part::End => { + p.definition_end_point = at; + p.end_point = p.end_point.map(|_| at); + p.updated_end_point = p.updated_end_point.map(|_| at); + moved_grips.push((p.property_states[1], at)); + } + _ => {} + } + let length = (p.definition_end_point - p.definition_base_point).length(); + if !length.is_finite() || length < 1e-10 { + return Err("Parameter endpoints must remain distinct and finite."); + } + } + match &mut object.data { + DynamicBlockData::PointParameter(p) => match part { + Part::Point => { + p.parameter.definition_point = at; + moved_grips.push((p.parameter.property_count, at)) + } + Part::Label(_) => p.definition_label_point = at, + _ => {} + }, + DynamicBlockData::LookupParameter(p) => { + p.parameter.definition_point = at; + moved_grips.push((p.parameter.property_count, at)) + } + DynamicBlockData::BasePointParameter(p) => { + p.parameter.definition_point = at; + p.point = at; + p.base_point = at; + } + DynamicBlockData::LinearParameter(p) => { + if let Part::Label(_) = part { + p.distance = label_offset(&p.parameter, at) + } + } + DynamicBlockData::PolarParameter(p) => { + if let Part::Label(_) = part { + p.offset = label_offset(&p.parameter, at) + } + } + DynamicBlockData::XYParameter(p) => { + if let Part::Label(index) = part { + if index == 0 { + p.x_value = at.y - p.parameter.definition_base_point.y + } else { + p.y_value = at.x - p.parameter.definition_base_point.x + } + } + if (p.parameter.definition_end_point.x - p.parameter.definition_base_point.x) + .abs() + < 1e-10 + || (p.parameter.definition_end_point.y + - p.parameter.definition_base_point.y) + .abs() + < 1e-10 + { + return Err("XY parameter endpoints need both X and Y distances."); + } + } + DynamicBlockData::RotationParameter(p) => match part { + Part::Reference => { + if (at - p.parameter.definition_base_point).length() < 1e-10 { + return Err("The rotation reference must differ from its base."); + } + p.definition_base_angle_point = at + } + Part::Label(_) => { + let b = p.parameter.definition_base_point; + p.angle = + (at - b).length() - (p.parameter.definition_end_point - b).length() + } + _ => {} + }, + DynamicBlockData::FlipParameter(p) => { + if let Part::Label(_) = part { + p.definition_label_point = at + } + } + DynamicBlockData::MoveAction(p) => p.action.display_location = at, + DynamicBlockData::ScaleAction(p) => p.action.action.display_location = at, + DynamicBlockData::AlignmentParameter(_) => {} + _ => return Err("This authoring object cannot be edited yet."), + } + if let DynamicBlockData::RotationParameter(p) = &mut object.data { + let base = p.parameter.definition_base_point; + if part == Part::Reference { + let radius = (p.definition_base_angle_point - base).length(); + let delta = p.parameter.definition_end_point - base; + let end = base + delta * (radius / delta.length()); + p.parameter.definition_end_point = end; + p.parameter.end_point = p.parameter.end_point.map(|_| end); + p.parameter.updated_end_point = p.parameter.updated_end_point.map(|_| end); + moved_grips.push((p.parameter.property_states[1], end)); + } else if matches!(part, Part::Base | Part::End) { + let old_base = item.grips.iter().find(|(p, _)| *p == Part::Base).unwrap().1; + let direction = p.definition_base_angle_point - old_base; + let radius = (p.parameter.definition_end_point - base).length(); + p.definition_base_angle_point = + base + direction * (radius / direction.length()); + } + if let Some(e) = crate::scene::block_orientation_parameter::points_error( + crate::scene::block_orientation_parameter::Kind::Rotation, + base, + p.parameter.definition_end_point, + p.definition_base_angle_point, + ) { + return Err(e); + } + } + } + _ => return Err("This is not an authoring object."), + } + for object in staged.objects.values_mut() { + let ObjectType::DynamicBlock(object) = object else { + continue; + }; + if object.owner != graph { + continue; + } + let Some(g) = grip(&mut object.data) else { + continue; + }; + if let Some((_, location)) = moved_grips + .iter() + .find(|(id, _)| *id > 0 && *id == g.element.eval.node_id) + { + g.location = *location; + } + } + // Keep the Linear grip's native direction consistent after endpoint edits. + if matches!(part, Part::Base | Part::End) { + if let Some(p) = distance::parameters(&staged, definition) + .into_iter() + .find(|p| p.handle == handle) + { + let delta = p.native.definition_end_point - p.native.definition_base_point; + let orientation = delta * (1.0 / delta.length()); + let ids = p.native.property_states; + for o in staged.objects.values_mut() { + if let ObjectType::DynamicBlock(o) = o { + if o.owner == graph { + if let DynamicBlockData::LinearGrip(g) = &mut o.data { + if ids.contains(&g.grip.element.eval.node_id) { + g.orientation = orientation; + } + } + } + } + } + } + if let Some(p) = crate::scene::block_orientation_parameter::parameters(&staged, definition) + .into_iter() + .find(|p| p.handle == handle) + { + let orientation = p.native.definition_end_point - p.native.definition_base_point; + let ids = p.native.property_states; + for o in staged.objects.values_mut() { + if let ObjectType::DynamicBlock(o) = o { + if o.owner == graph { + match &mut o.data { + DynamicBlockData::AlignmentGrip(g) + if ids.contains(&g.grip.element.eval.node_id) => + { + g.orientation = orientation + } + DynamicBlockData::FlipGrip(g) + if ids.contains(&g.grip.element.eval.node_id) => + { + g.orientation = orientation + } + _ => {} + } + } + } + } + } + } + if let Some(ObjectType::BlockVisibilityParameter(p)) = staged.objects.get(&handle) { + staged.block_visibility_params.insert(handle, p.clone()); + } + if let Some(item) = items(&staged, definition) + .into_iter() + .find(|i| i.handle == handle) + { + let finite = |p: Vector3| [p.x, p.y, p.z].iter().all(|v| v.is_finite()); + if !item.grips.iter().all(|(_, p)| finite(*p)) + || !item.graphic.lines.iter().flatten().all(|p| finite(*p)) + { + return Err("The authoring location produces non-finite geometry."); + } + } + *doc = staged; + Ok(true) +} +fn label_offset(p: &BlockTwoPointParameter, at: Vector3) -> f64 { + let d = p.definition_end_point - p.definition_base_point; + let n = Vector3::new(-d.y, d.x, 0.) * (1.0 / d.x.hypot(d.y)); + let delta = at - (p.definition_base_point + p.definition_end_point) * 0.5; + delta.x * n.x + delta.y * n.y +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::scene::{ + block_orientation_parameter as orientation, block_point_parameter as point, Scene, + }; + #[test] + fn authoring_edit_all_parameter_kinds_keep_native_grips_and_graph_identity() { + let mut scene = Scene::new(); + scene.define_block_raw("EDIT", Vector3::ZERO, vec![]); + let doc = &mut scene.document; + let def = doc.block_records.get("EDIT").unwrap().handle; + let b = Vector3::new(2., 3., 0.); + let e = Vector3::new(6., 7., 0.); + let l = Vector3::new(1., 8., 0.); + point::create(doc, def, b, l).unwrap(); + for kind in [ + distance::Kind::Linear, + distance::Kind::Polar, + distance::Kind::XY, + ] { + distance::create(doc, def, kind, b, e, l).unwrap(); + } + for kind in [ + orientation::Kind::Rotation, + orientation::Kind::Alignment, + orientation::Kind::Flip, + ] { + orientation::create( + doc, + def, + kind, + b, + e, + Vector3::new(b.x + (e - b).length(), b.y, 0.), + ) + .unwrap(); + } + crate::scene::block_lookup::create(doc, def, b).unwrap(); + crate::scene::block_basepoint::create(doc, def, b).unwrap(); + let visibility = crate::scene::block_visibility::create(doc, def, b).unwrap(); + let graph_handle = graph(doc, def).unwrap().unwrap(); + let graph_before = doc.objects[&graph_handle].clone(); + let inventory = items(doc, def); + assert_eq!(inventory.len(), 10); + for item in inventory { + for (part, _) in item.grips { + let old = items(doc, def) + .into_iter() + .find(|i| i.handle == item.handle) + .unwrap() + .grips + .into_iter() + .find(|(p, _)| *p == part) + .unwrap() + .1; + let next = old + Vector3::new(0.3, 0.5, 0.); + assert!( + move_part(doc, def, item.handle, part, next).unwrap(), + "{} {part:?}", + item.name + ); + let after = items(doc, def) + .into_iter() + .find(|i| i.handle == item.handle) + .unwrap(); + let actual = after.grips.iter().find(|(p, _)| *p == part).unwrap().1; + // Dimension labels project onto their normal/radius; definition points are exact. + if !matches!(part, Part::Label(_)) { + assert!((actual - next).length() < 1e-10); + } + let snapshot = doc.clone(); + assert!(!move_part(doc, def, item.handle, part, actual).unwrap()); + assert!( + move_part(doc, def, item.handle, part, Vector3::new(f64::NAN, 0., 0.)).is_err() + ); + assert_eq!(*doc, snapshot); + } + } + assert_eq!( + doc.objects[&graph_handle], graph_before, + "editing positions must preserve graph lists and node identities" + ); + assert_eq!( + crate::scene::block_visibility::parameter(doc, def) + .unwrap() + .def_point, + items(doc, def) + .into_iter() + .find(|i| i.handle == visibility) + .unwrap() + .grips[0] + .1 + ); + for p in distance::parameters(doc, def) { + let ids = p.native.property_states; + let expected = p.native.definition_end_point; + for o in doc.objects.values() { + if let ObjectType::DynamicBlock(o) = o { + let mut data = o.data.clone(); + if let Some(g) = grip(&mut data) { + if g.element.eval.node_id == ids[1] { + assert_eq!(g.location, expected); + } + } + } + } + } + let bytes = codec::DxfWriter::new(doc).write_to_vec().unwrap(); + let saved = codec::DxfReader::from_reader(std::io::Cursor::new(bytes)) + .unwrap() + .read() + .unwrap(); + let a = items(doc, def); + let b = items(&saved, def); + assert_eq!(a.len(), b.len()); + for (a, b) in a.iter().zip(b) { + assert_eq!(a.handle, b.handle); + assert_eq!(a.grips, b.grips); + } + } +} From e5c307c8ae3fd2c4b03cd4d8a1cb5fd3d577e337 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Fri, 16 Oct 2026 21:54:09 +0300 Subject: [PATCH 4/4] Cargo: codec 0dc8585, kernel d530a52, graph 7dd8cf6 Large DWG sections (over 100 MB) read back instead of opening empty, and DXF files with very large DGN stroke patterns no longer hang the reader. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 8 ++++---- Cargo.toml | 8 ++++---- crates/ocs_plugin_api/Cargo.toml | 4 ++-- crates/ocs_save/Cargo.toml | 2 +- crates/ocs_web_worker/Cargo.toml | 2 +- docs/plugin-template-v2/Cargo.toml | 2 +- plugins/opencad-python/Cargo.lock | 2 +- 7 files changed, 14 insertions(+), 14 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 16b5c0f54..34be4ed3c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4382,7 +4382,7 @@ dependencies = [ [[package]] name = "opencadcodec" version = "0.6.0" -source = "git+https://github.com/HakanSeven12/opencadcodec.git?rev=c418d92#c418d92615508c8b0393eeac6b84e756c0b21f1e" +source = "git+https://github.com/HakanSeven12/opencadcodec.git?rev=0dc8585#0dc85856e47e100bc2064fd633f736076b778719" dependencies = [ "anyhow", "bitflags 2.13.1", @@ -4406,7 +4406,7 @@ dependencies = [ [[package]] name = "opencadgraph" version = "0.1.0" -source = "git+https://github.com/HakanSeven12/opencadgraph.git?rev=dcb9eda#dcb9eda84b33cd87907c3e1bb04fc75a3883a7d1" +source = "git+https://github.com/HakanSeven12/opencadgraph.git?rev=7dd8cf6#7dd8cf6d1fba6a680919accb40f38b57e6270dae" dependencies = [ "opencadkernel", "serde_json", @@ -4415,7 +4415,7 @@ dependencies = [ [[package]] name = "opencadkernel" version = "0.1.0" -source = "git+https://github.com/HakanSeven12/opencadkernel.git?rev=0f6f225#0f6f225d021a28c6218be7424606aa174a835ba8" +source = "git+https://github.com/HakanSeven12/opencadkernel.git?rev=d530a52#d530a5241d0a9351db21afb4690fbea377b14329" dependencies = [ "cavalier_contours", "opencadcodec", @@ -4426,7 +4426,7 @@ dependencies = [ [[package]] name = "opencadkernel-constraints" version = "0.1.0" -source = "git+https://github.com/HakanSeven12/opencadkernel.git?rev=0f6f225#0f6f225d021a28c6218be7424606aa174a835ba8" +source = "git+https://github.com/HakanSeven12/opencadkernel.git?rev=d530a52#d530a5241d0a9351db21afb4690fbea377b14329" dependencies = [ "nalgebra", ] diff --git a/Cargo.toml b/Cargo.toml index 2c68bfb03..040440736 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,10 +37,10 @@ rfd = "0.17" clap = { version = "4", features = ["derive", "string"] } env_logger = "0.11" log = "0.4" -codec = { package = "opencadcodec", git = "https://github.com/HakanSeven12/opencadcodec.git", rev = "c418d92", features = ["serde"] } -kernel = { package = "opencadkernel", git = "https://github.com/HakanSeven12/opencadkernel.git", rev = "0f6f225", features = ["acis", "offset"] } -kernel-constraints = { package = "opencadkernel-constraints", git = "https://github.com/HakanSeven12/opencadkernel.git", rev = "0f6f225" } -graph = { package = "opencadgraph", git = "https://github.com/HakanSeven12/opencadgraph.git", rev = "dcb9eda" } +codec = { package = "opencadcodec", git = "https://github.com/HakanSeven12/opencadcodec.git", rev = "0dc8585", features = ["serde"] } +kernel = { package = "opencadkernel", git = "https://github.com/HakanSeven12/opencadkernel.git", rev = "d530a52", features = ["acis", "offset"] } +kernel-constraints = { package = "opencadkernel-constraints", git = "https://github.com/HakanSeven12/opencadkernel.git", rev = "d530a52" } +graph = { package = "opencadgraph", git = "https://github.com/HakanSeven12/opencadgraph.git", rev = "7dd8cf6" } dwg-thumbnailer = { path = "crates/dwg-thumbnailer" } # Shared SAVE serialization dispatch + web-worker wire format: one # implementation for the inline (fallback/native) path and the worker path. diff --git a/crates/ocs_plugin_api/Cargo.toml b/crates/ocs_plugin_api/Cargo.toml index 0c7ff45da..657c20513 100644 --- a/crates/ocs_plugin_api/Cargo.toml +++ b/crates/ocs_plugin_api/Cargo.toml @@ -15,7 +15,7 @@ serde_json = { version = "1", optional = true } # Pulled in only by the `host` feature, which adds the `opencadcodec`-typed # `HostApi` runtime surface. The default crate stays dependency-free so engine # crates and external tooling can depend on the manifest/ribbon contract cheaply. -codec = { package = "opencadcodec", git = "https://github.com/HakanSeven12/opencadcodec.git", rev = "c418d92", optional = true, features = ["serde"] } +codec = { package = "opencadcodec", git = "https://github.com/HakanSeven12/opencadcodec.git", rev = "0dc8585", optional = true, features = ["serde"] } # Runtime IPC and serialization (host feature only). interprocess = { version = "2", optional = true } @@ -39,7 +39,7 @@ serde_json = "1" serde = { version = "1", features = ["derive"] } cargo-lock = "11" # opencadcodec is scanned at build time to generate the embedded type registry. -codec = { package = "opencadcodec", git = "https://github.com/HakanSeven12/opencadcodec.git", rev = "c418d92", features = ["serde"] } +codec = { package = "opencadcodec", git = "https://github.com/HakanSeven12/opencadcodec.git", rev = "0dc8585", features = ["serde"] } [dev-dependencies] serde_json = "1" diff --git a/crates/ocs_save/Cargo.toml b/crates/ocs_save/Cargo.toml index cb45d7b26..376bc34ad 100644 --- a/crates/ocs_save/Cargo.toml +++ b/crates/ocs_save/Cargo.toml @@ -10,6 +10,6 @@ publish = false # exactly one implementation. No UI/app dependencies — only the codec + serde. [dependencies] -codec = { package = "opencadcodec", git = "https://github.com/HakanSeven12/opencadcodec.git", rev = "c418d92", features = ["serde"] } +codec = { package = "opencadcodec", git = "https://github.com/HakanSeven12/opencadcodec.git", rev = "0dc8585", features = ["serde"] } bincode = "1.3" serde = { version = "1", features = ["derive"] } diff --git a/crates/ocs_web_worker/Cargo.toml b/crates/ocs_web_worker/Cargo.toml index 98fbf7abb..935568afe 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] -codec = { package = "opencadcodec", git = "https://github.com/HakanSeven12/opencadcodec.git", rev = "c418d92", features = ["serde"] } +codec = { package = "opencadcodec", git = "https://github.com/HakanSeven12/opencadcodec.git", rev = "0dc8585", features = ["serde"] } bincode = "1.3" serde = { version = "1", features = ["derive"] } console_error_panic_hook = "0.1" diff --git a/docs/plugin-template-v2/Cargo.toml b/docs/plugin-template-v2/Cargo.toml index 6fa4fe33b..88e98749e 100644 --- a/docs/plugin-template-v2/Cargo.toml +++ b/docs/plugin-template-v2/Cargo.toml @@ -16,4 +16,4 @@ ocs_plugin_api = { path = "../../crates/ocs_plugin_api", features = ["host"] } # entry): the plugin's entities and the host's re-exports are the same types # only when both resolve the same revision. The registration `rustc` / # `acadrust_source` gates enforce this at load time too. -acadrust = { package = "opencadcodec", git = "https://github.com/HakanSeven12/opencadcodec.git", rev = "c418d92" } +acadrust = { package = "opencadcodec", git = "https://github.com/HakanSeven12/opencadcodec.git", rev = "0dc8585" } diff --git a/plugins/opencad-python/Cargo.lock b/plugins/opencad-python/Cargo.lock index 4c1a7b07c..4d01d4323 100644 --- a/plugins/opencad-python/Cargo.lock +++ b/plugins/opencad-python/Cargo.lock @@ -1273,7 +1273,7 @@ dependencies = [ [[package]] name = "opencadcodec" version = "0.6.0" -source = "git+https://github.com/HakanSeven12/opencadcodec.git?rev=c418d92#c418d92615508c8b0393eeac6b84e756c0b21f1e" +source = "git+https://github.com/HakanSeven12/opencadcodec.git?rev=0dc8585#0dc85856e47e100bc2064fd633f736076b778719" dependencies = [ "anyhow", "bitflags",