From 10c5a9d382ca16de331958675f301be845ee0c0a Mon Sep 17 00:00:00 2001 From: Karim Jerbi Date: Fri, 16 Oct 2026 15:19:13 +0100 Subject: [PATCH] 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;