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;