diff --git a/Cargo.lock b/Cargo.lock index 047c97779..7a2002eb1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4382,7 +4382,7 @@ dependencies = [ [[package]] name = "opencadcodec" version = "0.6.0" -source = "git+https://github.com/ramox81/cadcodec.git?rev=58fac2c#58fac2c067cbd1346c08f371aaf92b0807a07f66" +source = "git+https://github.com/ramox81/cadcodec.git?rev=65511ca#65511ca113f74b07dc335b9102851812a8d0415b" 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=7dd8cf6#7dd8cf6d1fba6a680919accb40f38b57e6270dae" dependencies = [ "opencadkernel", "serde_json", @@ -4415,7 +4415,7 @@ dependencies = [ [[package]] name = "opencadkernel" version = "0.1.0" -source = "git+https://github.com/ramox81/cadkernel.git?rev=9490fb8#9490fb81fa81088d7890ce87af01353fd834101f" +source = "git+https://github.com/ramox81/cadkernel.git?rev=8d24b7f#8d24b7f82f20d2d6b48b61e3e11438bd2d049487" dependencies = [ "cavalier_contours", "opencadcodec", @@ -4426,7 +4426,7 @@ dependencies = [ [[package]] name = "opencadkernel-constraints" version = "0.1.0" -source = "git+https://github.com/ramox81/cadkernel.git?rev=9490fb8#9490fb81fa81088d7890ce87af01353fd834101f" +source = "git+https://github.com/ramox81/cadkernel.git?rev=8d24b7f#8d24b7f82f20d2d6b48b61e3e11438bd2d049487" dependencies = [ "nalgebra", ] diff --git a/Cargo.toml b/Cargo.toml index 5223981c3..4d632a86b 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 = "0dc8585", features = ["serde"] } +kernel = { package = "opencadkernel", git = "https://github.com/HakanSeven12/opencadkernel.git", rev = "d530a52", features = ["acis", "offset"] } +kernel-constraints = { package = "opencadkernel-constraints", git = "https://github.com/HakanSeven12/opencadkernel.git", rev = "d530a52" } +graph = { package = "opencadgraph", git = "https://github.com/HakanSeven12/opencadgraph.git", rev = "7dd8cf6" } dwg-thumbnailer = { path = "crates/dwg-thumbnailer" } # Shared SAVE serialization dispatch + web-worker wire format: one # implementation for the inline (fallback/native) path and the worker path. @@ -224,8 +224,8 @@ name = "integration" path = "tests/integration.rs" [patch."https://github.com/HakanSeven12/opencadcodec.git"] -opencadcodec = { git = "https://github.com/ramox81/cadcodec.git", rev = "58fac2c" } +opencadcodec = { git = "https://github.com/ramox81/cadcodec.git", rev = "65511ca" } [patch."https://github.com/HakanSeven12/opencadkernel.git"] -opencadkernel = { git = "https://github.com/ramox81/cadkernel.git", rev = "9490fb8" } -opencadkernel-constraints = { git = "https://github.com/ramox81/cadkernel.git", rev = "9490fb8" } +opencadkernel = { git = "https://github.com/ramox81/cadkernel.git", rev = "8d24b7f" } +opencadkernel-constraints = { git = "https://github.com/ramox81/cadkernel.git", rev = "8d24b7f" } diff --git a/crates/ocs_plugin_api/Cargo.toml b/crates/ocs_plugin_api/Cargo.toml index dc495b6eb..657c20513 100644 --- a/crates/ocs_plugin_api/Cargo.toml +++ b/crates/ocs_plugin_api/Cargo.toml @@ -15,7 +15,7 @@ serde_json = { version = "1", optional = true } # Pulled in only by the `host` feature, which adds the `opencadcodec`-typed # `HostApi` runtime surface. The default crate stays dependency-free so engine # crates and external tooling can depend on the manifest/ribbon contract cheaply. -codec = { package = "opencadcodec", git = "https://github.com/HakanSeven12/opencadcodec.git", rev = "325fa98", optional = true, features = ["serde"] } +codec = { package = "opencadcodec", git = "https://github.com/HakanSeven12/opencadcodec.git", rev = "0dc8585", optional = true, features = ["serde"] } # Runtime IPC and serialization (host feature only). interprocess = { version = "2", optional = true } @@ -39,7 +39,7 @@ serde_json = "1" serde = { version = "1", features = ["derive"] } cargo-lock = "11" # opencadcodec is scanned at build time to generate the embedded type registry. -codec = { package = "opencadcodec", git = "https://github.com/HakanSeven12/opencadcodec.git", rev = "325fa98", features = ["serde"] } +codec = { package = "opencadcodec", git = "https://github.com/HakanSeven12/opencadcodec.git", rev = "0dc8585", features = ["serde"] } [dev-dependencies] serde_json = "1" diff --git a/crates/ocs_save/Cargo.toml b/crates/ocs_save/Cargo.toml index a64f550ef..376bc34ad 100644 --- a/crates/ocs_save/Cargo.toml +++ b/crates/ocs_save/Cargo.toml @@ -10,6 +10,6 @@ publish = false # exactly one implementation. No UI/app dependencies — only the codec + serde. [dependencies] -codec = { package = "opencadcodec", git = "https://github.com/HakanSeven12/opencadcodec.git", rev = "325fa98", features = ["serde"] } +codec = { package = "opencadcodec", git = "https://github.com/HakanSeven12/opencadcodec.git", rev = "0dc8585", features = ["serde"] } bincode = "1.3" serde = { version = "1", features = ["derive"] } diff --git a/crates/ocs_web_worker/Cargo.toml b/crates/ocs_web_worker/Cargo.toml index bc91d4438..935568afe 100644 --- a/crates/ocs_web_worker/Cargo.toml +++ b/crates/ocs_web_worker/Cargo.toml @@ -8,7 +8,7 @@ publish = false crate-type = ["cdylib"] [dependencies] -codec = { package = "opencadcodec", git = "https://github.com/HakanSeven12/opencadcodec.git", rev = "325fa98", features = ["serde"] } +codec = { package = "opencadcodec", git = "https://github.com/HakanSeven12/opencadcodec.git", rev = "0dc8585", features = ["serde"] } bincode = "1.3" serde = { version = "1", features = ["derive"] } console_error_panic_hook = "0.1" diff --git a/docs/plugin-template-v2/Cargo.toml b/docs/plugin-template-v2/Cargo.toml index 63a19d6b5..88e98749e 100644 --- a/docs/plugin-template-v2/Cargo.toml +++ b/docs/plugin-template-v2/Cargo.toml @@ -16,4 +16,4 @@ ocs_plugin_api = { path = "../../crates/ocs_plugin_api", features = ["host"] } # entry): the plugin's entities and the host's re-exports are the same types # only when both resolve the same revision. The registration `rustc` / # `acadrust_source` gates enforce this at load time too. -acadrust = { package = "opencadcodec", git = "https://github.com/HakanSeven12/opencadcodec.git", rev = "325fa98" } +acadrust = { package = "opencadcodec", git = "https://github.com/HakanSeven12/opencadcodec.git", rev = "0dc8585" } diff --git a/plugins/opencad-python/Cargo.lock b/plugins/opencad-python/Cargo.lock index 11cfbc9aa..4d01d4323 100644 --- a/plugins/opencad-python/Cargo.lock +++ b/plugins/opencad-python/Cargo.lock @@ -1273,7 +1273,7 @@ dependencies = [ [[package]] name = "opencadcodec" version = "0.6.0" -source = "git+https://github.com/HakanSeven12/opencadcodec.git?rev=325fa98#325fa98d2b881f48e106a830d5cf161624f63131" +source = "git+https://github.com/HakanSeven12/opencadcodec.git?rev=0dc8585#0dc85856e47e100bc2064fd633f736076b778719" dependencies = [ "anyhow", "bitflags", diff --git a/src/app/block_actions.rs b/src/app/block_actions.rs new file mode 100644 index 000000000..b838796a4 --- /dev/null +++ b/src/app/block_actions.rs @@ -0,0 +1,265 @@ +use super::{Message, OpenCADStudio}; +use crate::command::CadCommand; +use crate::scene::block_actions::{self as actions, Kind}; +use codec::{EntityType, Handle}; +use iced::Task; + +// These grips start an ordinary point-input command, keeping snapping, typed +// coordinates and Escape on the command driver's existing path. +const TEST_GRIP_START: usize = usize::MAX / 2; +pub(super) fn is_test_grip(id: usize) -> bool { + (TEST_GRIP_START..usize::MAX).contains(&id) +} +impl OpenCADStudio { + pub(in crate::app) fn action_error(&self, kind: Option) -> Option<&'static str> { + let tab = &self.tabs[self.active_tab]; + let Some(s) = tab.active_block_edit_session() else { + return Some("Open a block in the Block Editor before adding actions."); + }; + if let Some(e) = + crate::scene::block_point_parameter::placement_error(&tab.scene.document, s.br_handle) + { + return Some(e); + } + if actions::parameters(&tab.scene.document, s.br_handle, kind.unwrap_or(Kind::Move)) + .is_empty() + { + return Some("Place a compatible parameter before adding this action."); + } + None + } + pub(in crate::app) fn start_block_action(&mut self, args: &str) -> Task { + let kind = if args.is_empty() { + None + } else { + Kind::parse(args) + }; + if !args.is_empty() && kind.is_none() { + self.command_line.push_error( + crate::t!("Supported actions: Move, Scale. Other action types are not wired yet.") + .as_ref(), + ); + return Task::none(); + } + if let Some(e) = self.action_error(kind) { + self.command_line.push_error(crate::t!(e).as_ref()); + return Task::none(); + } + let tab = &self.tabs[self.active_tab]; + let definition = tab.active_block_edit_session().unwrap().br_handle; + let command = crate::modules::block_editor::action::ActionCommand::new( + tab.id, + definition, + kind, + actions::parameters(&tab.scene.document, definition, kind.unwrap_or(Kind::Move)), + ); + self.command_line.push_info(&command.prompt()); + self.command_line.set_step_options(command.options()); + self.tabs[self.active_tab].active_cmd = Some(Box::new(command)); + Task::none() + } + pub(in crate::app) fn handle_block_action_result( + &mut self, + result: crate::command::CmdResult, + ) -> Task { + let i = self.active_tab; + match result { + crate::command::CmdResult::PlaceBlockAction { + document, + definition, + kind, + parameter, + targets, + location, + } => { + if self.tabs[i].id != document + || self.tabs[i] + .active_block_edit_session() + .map(|s| s.br_handle) + != Some(definition) + { + self.command_line.push_error( + crate::t!("The action's Block Editor session is no longer active.") + .as_ref(), + ); + return Task::none(); + } + let mut staged = self.tabs[i].scene.document.clone(); + match actions::create(&mut staged, definition, kind, parameter, &targets, location) + { + Ok(_) => { + self.push_undo_snapshot(i, &format!("{} action", kind.name())); + self.tabs[i].scene.document = staged; + self.tabs[i].scene.bump_geometry(); + self.tabs[i].dirty = true; + self.tabs[i].active_cmd = None; + self.tabs[i].scene.clear_preview_wire(); + self.finish_pending_history(i); + self.refresh_properties(); + self.command_line.push_output(crate::tf!("{} action attached. Open Test Block and click the parameter grip to test it.",crate::t!(kind.name())).as_ref()); + } + Err(e) => self.command_line.push_error(crate::t!(e).as_ref()), + } + } + crate::command::CmdResult::EvaluateBlockParameter { + document, + definition, + parameter, + position, + } => { + if self.tabs[i].id != document { + return Task::none(); + } + let Some(test) = self.tabs[i].block_test.as_ref() else { + return Task::none(); + }; + if test.definition != definition { + return Task::none(); + } + let Some(EntityType::Insert(insert)) = + self.tabs[i].scene.document.get_entity(test.insert) + else { + return Task::none(); + }; + let doc = &self.tabs[i].scene.document; + if doc.entities().any(|e|matches!(e,EntityType::Insert(other) if other.common.handle!=test.insert && (other.block_name==insert.block_name||doc.dynamic_definition_for_insert(other.common.handle)==Some(definition)))) { + self.command_line.push_error(crate::t!("Test actions with one reference in Test Block. Remove the extra references or reopen Test Block.").as_ref());return Task::none(); + } + let Some(inverse) = + crate::scene::inverse_affine(&crate::scene::render_graph::insert_transform( + &self.tabs[i].scene.document, + insert, + )) + else { + self.command_line.push_error( + crate::t!("The test reference has a singular transform.").as_ref(), + ); + return Task::none(); + }; + let local = actions::v(inverse.apply(actions::native(position))); + let representation = insert.block_name.clone(); + let mut staged = self.tabs[i].scene.document.clone(); + match actions::evaluate(&mut staged, &test.baseline, definition, parameter, local) { + Ok(changed) => { + if changed { + if let Err(e) = actions::sync_representation( + &mut staged, + definition, + &representation, + ) { + self.command_line.push_error(crate::t!(e).as_ref()); + return Task::none(); + } + self.finish_pending_history(i); + let before = &self.tabs[i].scene.document; + let entities = before + .entities() + .filter_map(|e| { + let h = e.common().handle; + (staged.get_entity(h) != Some(e)) + .then(|| (h, before.get_entity_arc(h).unwrap())) + }) + .collect(); + let objects = before + .objects + .iter() + .filter(|(h, o)| staged.objects.get(h) != Some(*o)) + .map(|(h, o)| (*h, o.clone())) + .collect(); + let dirty_before = self.tabs[i].dirty; + self.tabs[i].scene.document = staged; + self.tabs[i].scene.bump_geometry(); + self.tabs[i].dirty = true; + self.push_entity_group_history( + i, + "Test block parameter", + entities, + objects, + dirty_before, + ); + } + self.tabs[i].active_cmd = None; + self.tabs[i].scene.clear_preview_wire(); + self.refresh_properties(); + } + Err(e) => self.command_line.push_error(crate::t!(e).as_ref()), + } + } + _ => unreachable!(), + } + Task::none() + } + pub(super) fn refresh_test_action_grips(&mut self, wo: [f64; 3]) { + let i = self.active_tab; + let tab = &self.tabs[i]; + let Some(test) = tab.block_test.as_ref() else { + return; + }; + if tab.selected_handle != Some(test.insert) { + return; + } + let Some(EntityType::Insert(insert)) = tab.scene.document.get_entity(test.insert) else { + return; + }; + let transform = crate::scene::render_graph::insert_transform(&tab.scene.document, insert); + let mut grips = vec![]; + let all = actions::actions(&test.baseline, test.definition); + for (index, p) in actions::parameters(&test.baseline, test.definition, Kind::Move) + .into_iter() + .enumerate() + { + if !all.iter().any(|(_, _, _, id)| *id == p.id) { + continue; + } + let world = actions::v( + transform.apply(actions::native(actions::current(&tab.scene.document, &p))), + ) - glam::DVec3::from_array(wo); + grips.push(crate::scene::model::object::GripDef { + id: TEST_GRIP_START + index, + world, + is_midpoint: false, + shape: crate::scene::model::object::GripShape::Triangle, + dir: None, + axis: None, + }); + } + let handle = test.insert; + let tab = &mut self.tabs[i]; + for grip in grips { + tab.selected_grips.push(grip); + tab.selected_grip_handles.push(handle); + } + } + pub(super) fn start_test_action_grip(&mut self, handle: Handle, id: usize) -> bool { + if !is_test_grip(id) { + return false; + } + let i = self.active_tab; + let tab = &self.tabs[i]; + let Some(test) = tab.block_test.as_ref() else { + return false; + }; + if handle != test.insert { + return false; + } + if tab.scene.is_layer_locked(handle) { + return true; + } + let Some(p) = actions::parameters(&test.baseline, test.definition, Kind::Move) + .get(id - TEST_GRIP_START) + .cloned() + else { + return false; + }; + let command = crate::modules::block_editor::action::TestParameterCommand { + document: tab.id, + definition: test.definition, + parameter: p.handle, + }; + self.command_line.push_info(&command.prompt()); + self.tabs[i].active_cmd = Some(Box::new(command)); + self.grip_hover = None; + self.grip_popup = None; + true + } +} diff --git a/src/app/block_authoring_edit.rs b/src/app/block_authoring_edit.rs new file mode 100644 index 000000000..efc4c542c --- /dev/null +++ b/src/app/block_authoring_edit.rs @@ -0,0 +1,230 @@ +use super::{Message, OpenCADStudio}; +use crate::command::{CadCommand, CmdResult}; +use crate::scene::block_authoring::edit::{self, Part}; +use codec::Handle; +use glam::DVec3; +use iced::{Point, Rectangle, Task}; + +const EDIT_GRIP_START: usize = usize::MAX / 4; +pub(super) fn is_editor_grip(id: usize) -> bool { + (EDIT_GRIP_START..EDIT_GRIP_START + 16).contains(&id) +} +struct EditCommand { + document: u64, + definition: Handle, + handle: Handle, + part: Part, +} +impl CadCommand for EditCommand { + fn name(&self) -> &'static str { + "BEDITGRIP" + } + fn prompt(&self) -> String { + crate::t!("Block Editor Specify the new authoring grip location (Escape cancels):") + .into_owned() + } + fn on_point(&mut self, position: DVec3) -> CmdResult { + CmdResult::EditBlockAuthoring { + document: self.document, + definition: self.definition, + handle: self.handle, + part: self.part, + position, + } + } + fn on_enter(&mut self) -> CmdResult { + CmdResult::Cancel + } +} +impl OpenCADStudio { + pub(super) fn pick_authoring_object(&mut self, p: Point, bounds: Rectangle) -> bool { + let i = self.active_tab; + let tab = &self.tabs[i]; + if tab.active_cmd.is_some() || tab.active_grip.is_some() { + return false; + } + let Some(session) = tab.active_block_edit_session() else { + return false; + }; + let items = edit::items(&tab.scene.document, session.br_handle); + let camera = tab.scene.camera.borrow(); + let project = |v: codec::types::Vector3| { + camera + .project(DVec3::new(v.x, v.y, v.z), bounds) + .map(|v| Point::new(v.x, v.y)) + }; + let segment = |a: Point, b: Point| { + let ab = glam::Vec2::new(b.x - a.x, b.y - a.y); + let ap = glam::Vec2::new(p.x - a.x, p.y - a.y); + let t = (ap.dot(ab) / ab.length_squared().max(1e-12)).clamp(0., 1.); + (ap - ab * t).length() + }; + let hit = items + .iter() + .filter_map(|item| { + let mut d = f32::INFINITY; + for (_, v) in &item.grips { + if let Some(v) = project(*v) { + d = d.min(segment(v, v)); + } + } + for [a, b] in &item.graphic.lines { + if let (Some(a), Some(b)) = (project(*a), project(*b)) { + d = d.min(segment(a, b)); + } + } + for (v, label) in &item.graphic.labels { + if let Some(v) = project(*v) { + // Labels are drawn starting beside their anchor, in screen pixels. + let width = (label.chars().count() as f32 * 7.).max(20.); + if p.x >= v.x - 8. && p.x <= v.x + width + 12. && (p.y - v.y).abs() < 16. { + d = d.min(0.); + } + } + } + (d <= 10.).then_some((d, item.handle, item.name.clone())) + }) + .min_by(|a, b| a.0.total_cmp(&b.0)); + drop(camera); + if let Some((_, handle, name)) = hit { + self.tabs[i].scene.deselect_all(); + self.tabs[i].selected_authoring = (!self.shift_down).then_some(handle); + self.tabs[i] + .scene + .selection + .borrow_mut() + .clear_left_selection_gesture(); + self.refresh_properties(); + self.command_line.push_info(&format!( + "{} — {}", + name, + crate::t!("Select a grip to edit its location.") + )); + true + } else { + if self.tabs[i].selected_authoring.take().is_some() { + self.refresh_properties(); + } + false + } + } + pub(super) fn refresh_authoring_grips(&mut self, wo: [f64; 3]) { + let i = self.active_tab; + let Some(handle) = self.tabs[i].selected_authoring else { + return; + }; + let item = self.tabs[i].active_block_edit_session().and_then(|s| { + edit::items(&self.tabs[i].scene.document, s.br_handle) + .into_iter() + .find(|p| p.handle == handle) + }); + let Some(item) = item.filter(|_| self.tabs[i].scene.selected.is_empty()) else { + self.tabs[i].selected_authoring = None; + return; + }; + for (index, (_, point)) in item.grips.into_iter().enumerate() { + self.tabs[i] + .selected_grips + .push(crate::scene::model::object::GripDef { + id: EDIT_GRIP_START + index, + world: DVec3::new(point.x - wo[0], point.y - wo[1], point.z - wo[2]), + is_midpoint: false, + shape: crate::scene::model::object::GripShape::Square, + dir: None, + axis: None, + }); + self.tabs[i].selected_grip_handles.push(handle); + } + } + pub(super) fn start_authoring_grip(&mut self, handle: Handle, id: usize) -> bool { + if !is_editor_grip(id) { + return false; + } + let i = self.active_tab; + let Some(session) = self.tabs[i].active_block_edit_session() else { + return false; + }; + let Some(part) = edit::items(&self.tabs[i].scene.document, session.br_handle) + .into_iter() + .find(|p| p.handle == handle) + .and_then(|p| p.grips.get(id - EDIT_GRIP_START).map(|(part, _)| *part)) + else { + return false; + }; + let command = EditCommand { + document: self.tabs[i].id, + definition: session.br_handle, + handle, + part, + }; + self.command_line.push_info(&command.prompt()); + self.tabs[i].active_cmd = Some(Box::new(command)); + self.grip_hover = None; + self.grip_popup = None; + true + } + pub(super) fn handle_authoring_edit(&mut self, result: CmdResult) -> Task { + let CmdResult::EditBlockAuthoring { + document, + definition, + handle, + part, + position, + } = result + else { + unreachable!() + }; + let i = self.active_tab; + if self.tabs[i].id != document + || self.tabs[i] + .active_block_edit_session() + .map(|s| s.br_handle) + != Some(definition) + { + self.command_line + .push_error(crate::t!("The Block Editor session is no longer active.").as_ref()); + return Task::none(); + } + let mut staged = self.tabs[i].scene.document.clone(); + match edit::move_part( + &mut staged, + definition, + handle, + part, + codec::types::Vector3::new(position.x, position.y, position.z), + ) { + Ok(changed) => { + if changed { + self.push_undo_snapshot(i, "Block authoring grip"); + // Keep the live entity-change recorder attached. Basepoint edits + // can also relocate INSERT attributes outside the edited block. + let doc = &mut self.tabs[i].scene.document; + let changed_entities = staged + .entities() + .filter(|e| doc.get_entity(e.common().handle) != Some(*e)) + .cloned() + .collect::>(); + for entity in changed_entities { + let handle = entity.common().handle; + *doc.get_entity_mut(handle) + .expect("authoring edits retain entity identities") = entity; + } + for (h, o) in staged.objects.iter() { + if doc.objects.get(h) != Some(o) { + doc.objects.insert(*h, o.clone()); + } + } + doc.block_visibility_params = staged.block_visibility_params.clone(); + self.tabs[i].scene.bump_geometry(); + self.tabs[i].dirty = true; + self.finish_pending_history(i); + } + self.tabs[i].active_cmd = None; + self.tabs[i].scene.clear_preview_wire(); + self.refresh_properties(); + } + Err(e) => self.command_line.push_error(crate::t!(e).as_ref()), + } + Task::none() + } +} diff --git a/src/app/block_editor_tests.rs b/src/app/block_editor_tests.rs index 839e9613e..6bc41e9f9 100644 --- a/src/app/block_editor_tests.rs +++ b/src/app/block_editor_tests.rs @@ -59,6 +59,565 @@ fn feed(app: &mut OpenCADStudio, text: &str) { let _ = app.update(Message::CommandSubmit); } +#[test] +fn authoring_edit_basepoint_attribute_relocation_undo_and_cancel() { + let (mut app, reference) = fixture(); + begin(&mut app, reference); + let i = app.active_tab; + let _ = app.run_command_line("BPARAMETER BASEPOINT 2,3"); + let def = app.tabs[i].active_block_edit_session().unwrap().br_handle; + let handle = crate::scene::block_basepoint::parameter(&app.tabs[i].scene.document, def) + .unwrap() + .0; + let mut attribute = codec::entities::AttributeEntity::new("TAG".into(), "value".into()); + attribute.insertion_point = Vector3::new(7., 8., 0.); + let EntityType::Insert(insert) = app.tabs[i] + .scene + .document + .get_entity_mut(reference) + .unwrap() + else { + panic!() + }; + insert.attributes.push(attribute); + let before = app.tabs[i].scene.document.clone(); + let result = crate::command::CmdResult::EditBlockAuthoring { + document: app.tabs[i].id, + definition: def, + handle, + part: crate::scene::block_authoring::edit::Part::Point, + position: glam::DVec3::new(4., 6., 0.), + }; + let _ = app.handle_authoring_edit(result); + assert_ne!( + app.tabs[i].scene.document.get_entity(reference), + before.get_entity(reference) + ); + app.undo_steps(1); + assert_eq!(app.tabs[i].scene.document, before); + app.redo_steps(1); + assert_ne!( + app.tabs[i].scene.document.get_entity(reference), + before.get_entity(reference) + ); + app.tabs[i].selected_authoring = Some(handle); + app.refresh_properties(); + let id = app.tabs[i].selected_grips[0].id; + assert!(app.start_authoring_grip(handle, id)); + let after = app.tabs[i].scene.document.clone(); + let depth = app.tabs[i].history.undo_stack.len(); + let _ = app.update(Message::CommandEscape); + assert_eq!(app.tabs[i].scene.document, after); + assert_eq!(app.tabs[i].history.undo_stack.len(), depth); + let _ = app.update(Message::CommandEscape); + assert_eq!(app.tabs[i].selected_authoring, None); +} + +#[test] +fn authoring_edit_mouse_selection_endpoint_label_marker_history_and_reopen() { + use crate::scene::block_authoring::edit::{self, Part}; + use crate::scene::block_actions::{self as actions, Kind}; + let (mut app, reference) = fixture(); + begin(&mut app, reference); + let i = app.active_tab; + let _ = app.run_command_line("BPARAMETER LINEAR 2,2 6,2 4,3"); + let def = app.tabs[i].active_block_edit_session().unwrap().br_handle; + let p = actions::parameters(&app.tabs[i].scene.document, def, Kind::Move)[0].clone(); + let targets = members(&app); + let a = actions::create( + &mut app.tabs[i].scene.document, + def, + Kind::Move, + p.handle, + &targets, + glam::DVec3::new(5., 5., 0.), + ) + .unwrap(); + app.tabs[i].scene.selection.borrow_mut().view.vp_size = (1600., 900.); + app.tabs[i].scene.camera.borrow_mut().fit_to_bounds_f64( + glam::DVec3::new(-1., -1., 0.), + glam::DVec3::new(10., 7., 0.), + 1600. / 900., + ); + let click = |app: &mut OpenCADStudio, point: Vector3| { + let scene = &app.tabs[app.active_tab].scene; + let (w, h) = scene.selection.borrow().view.vp_size; + let b = scene.active_model_tile_bounds(w, h); + let s = scene + .camera + .borrow() + .project( + glam::DVec3::new(point.x, point.y, point.z), + iced::Rectangle { + x: 0., + y: 0., + width: b.width, + height: b.height, + }, + ) + .unwrap(); + let _ = app.update(Message::ViewportLeftPressAt(iced::Point::new( + s.x + b.x, + s.y + b.y, + ))); + let _ = app.update(Message::ViewportLeftRelease); + }; + click(&mut app, Vector3::new(4., 3., 0.)); + assert_eq!(app.tabs[i].selected_authoring, Some(p.handle)); + assert_eq!(app.tabs[i].selected_grips.len(), 3); + let before = app.tabs[i].scene.document.clone(); + let depth = app.tabs[i].history.undo_stack.len(); + click(&mut app, Vector3::new(6., 2., 0.)); + assert_eq!( + app.tabs[i].active_cmd.as_ref().map(|c| c.name()), + Some("BEDITGRIP") + ); + feed(&mut app, "8,2"); + assert!(app.tabs[i].active_cmd.is_none()); + assert_eq!( + actions::parameters(&app.tabs[i].scene.document, def, Kind::Move)[0].end, + glam::DVec3::new(8., 2., 0.) + ); + assert_eq!(app.tabs[i].history.undo_stack.len(), depth + 1); + app.undo_steps(1); + assert_eq!(app.tabs[i].scene.document, before); + app.redo_steps(1); + let label = edit::items(&app.tabs[i].scene.document, def) + .into_iter() + .find(|i| i.handle == p.handle) + .unwrap() + .grips + .into_iter() + .find(|(p, _)| *p == Part::Label(0)) + .unwrap() + .1; + click(&mut app, label); + feed(&mut app, "5,4"); + let graphic = edit::items(&app.tabs[i].scene.document, def) + .into_iter() + .find(|i| i.handle == p.handle) + .unwrap(); + assert_eq!(graphic.graphic.labels[0].0, Vector3::new(5., 4., 0.)); + let _ = app.update(Message::CommandEscape); + click(&mut app, Vector3::new(5., 5., 0.)); + assert_eq!(app.tabs[i].selected_authoring, Some(a)); + click(&mut app, Vector3::new(5., 5., 0.)); + feed(&mut app, "7,5"); + let action = actions::actions(&app.tabs[i].scene.document, def); + assert_eq!(action[0].1.display_location, Vector3::new(7., 5., 0.)); + assert_eq!(action[0].1.dependencies, targets); + let _ = app.dispatch_command("BSAVE"); + let _ = app.dispatch_command("BEDIT_DISCARD"); + assert!(app.tabs[i] + .selected_grips + .iter() + .all(|g| !super::block_authoring_edit::is_editor_grip(g.id))); + // Persisted native coordinates, rather than session-only display positions. + use std::io::Cursor; + let bytes = codec::DwgWriter::write_to_vec(&app.tabs[i].scene.document).unwrap(); + let loaded = codec::DwgReader::from_stream(Cursor::new(bytes)) + .read() + .unwrap(); + assert_eq!( + actions::parameters(&loaded, def, Kind::Move)[0].end, + glam::DVec3::new(8., 2., 0.) + ); + assert_eq!( + actions::actions(&loaded, def)[0].1.display_location, + Vector3::new(7., 5., 0.) + ); + app.tabs[i].scene.document = loaded; + begin(&mut app, reference); + click(&mut app, Vector3::new(8., 2., 0.)); + assert_eq!(app.tabs[i].selected_authoring, Some(p.handle)); +} + +#[test] +fn block_actions_mouse_selection_waits_for_enter_for_move_and_scale() { + use crate::scene::block_actions::{self as actions, Kind}; + for (kind, command) in [(Kind::Move, "BACTION MOVE"), (Kind::Scale, "BACTION SCALE")] { + let (mut app, reference) = fixture(); + begin(&mut app, reference); + let source = app.active_tab; + let _ = app.run_command_line("CIRCLE 0.5,0.5 0.2"); + let _ = app.run_command_line("BPARAMETER LINEAR 0,0 2,0 1,-0.2"); + let definition = app.tabs[source] + .active_block_edit_session() + .unwrap() + .br_handle; + app.tabs[source].scene.selection.borrow_mut().view.vp_size = (1600., 900.); + app.tabs[source] + .scene + .camera + .borrow_mut() + .fit_to_bounds_f64( + glam::DVec3::new(-0.5, -0.5, 0.), + glam::DVec3::new(2.5, 1.5, 0.), + 1600. / 900., + ); + let _ = app.dispatch_command(command); + let click = |app: &mut OpenCADStudio, point: [f64; 3], modifiers: &str| { + let scene = &app.tabs[app.active_tab].scene; + let (vw, vh) = scene.selection.borrow().view.vp_size; + let tile = scene.active_model_tile_bounds(vw, vh); + let screen = scene + .camera + .borrow() + .project( + glam::DVec3::from_array(point), + iced::Rectangle { + x: 0., + y: 0., + width: tile.width, + height: tile.height, + }, + ) + .unwrap(); + let cursor = iced::Point::new(screen.x + tile.x, screen.y + tile.y); + let previous = (app.ctrl_down, app.shift_down); + app.ctrl_down = modifiers.contains("ctrl"); + app.shift_down = modifiers.contains("shift"); + let _ = app.update(Message::ViewportLeftPressAt(cursor)); + let _ = app.update(Message::ViewportLeftRelease); + (app.ctrl_down, app.shift_down) = previous; + }; + click(&mut app, [2., 0., 0.], ""); + assert!(app.tabs[source] + .active_cmd + .as_ref() + .unwrap() + .is_selection_gathering()); + feed(&mut app, ""); // Empty selection does not cancel or advance. + assert!(app.tabs[source] + .active_cmd + .as_ref() + .unwrap() + .is_selection_gathering()); + for (point, modifiers, count) in [ + ([0.5, 0., 0.], "", 1), + ([0.5, 0.7, 0.], "", 2), + ([0.5, 0., 0.], "shift", 1), + ([0.5, 0., 0.], "", 2), + ] { + click(&mut app, point, modifiers); + assert!( + app.tabs[source] + .active_cmd + .as_ref() + .unwrap() + .is_selection_gathering(), + "every mouse pick/removal stays in object selection" + ); + assert_eq!(app.tabs[source].scene.selected.len(), count); + assert!(actions::actions(&app.tabs[source].scene.document, definition).is_empty()); + } + feed(&mut app, ""); + assert!(!app.tabs[source] + .active_cmd + .as_ref() + .unwrap() + .is_selection_gathering()); + click(&mut app, [1.5, 1., 0.], ""); + assert!(app.tabs[source].active_cmd.is_none()); + let authored = actions::actions(&app.tabs[source].scene.document, definition); + assert_eq!(authored.len(), 1); + assert_eq!(authored[0].2, kind); + assert_eq!(authored[0].1.dependencies.len(), 2); + let original = app.tabs[source].scene.document.clone(); + let _ = app.dispatch_command("BTESTBLOCK"); + let i = app.active_tab; + app.tabs[i].scene.selection.borrow_mut().view.vp_size = (1600., 900.); + app.tabs[i].scene.camera.borrow_mut().fit_to_bounds_f64( + glam::DVec3::new(-0.5, -0.5, 0.), + glam::DVec3::new(4.5, 1.5, 0.), + 1600. / 900., + ); + click(&mut app, [0.5, 0., 0.], ""); + click(&mut app, [2., 0., 0.], ""); + assert_eq!( + app.tabs[i].active_cmd.as_ref().map(|c| c.name()), + Some("BTESTBLOCK") + ); + click(&mut app, [4., 0., 0.], ""); + assert!(app.tabs[i].active_cmd.is_none()); + let circle = app.tabs[i] + .scene + .document + .entities_in_block("EDIT_ME") + .find_map(|e| { + if let EntityType::Circle(c) = e { + Some(c) + } else { + None + } + }) + .unwrap(); + let (center, radius) = if kind == Kind::Move { + (Vector3::new(2.5, 0.5, 0.), 0.2) + } else { + (Vector3::new(1., 1., 0.), 0.4) + }; + // Mouse projection uses f32 screen coordinates; allow sub-pixel roundoff. + assert!( + (circle.center - center).length() < 1e-5, + "{kind:?}: actual {:?}, expected {center:?}", + circle.center + ); + assert!((circle.radius - radius).abs() < 1e-5); + assert_eq!(app.tabs[source].scene.document, original); + } +} + +#[test] +fn block_actions_command_selection_cancel_undo_and_test_block_isolation() { + use crate::scene::block_actions::{self as actions, Kind}; + let (mut app, reference) = fixture(); + begin(&mut app, reference); + let source = app.active_tab; + let _ = app.run_command_line("BPARAMETER LINEAR 0,0 10,0 5,2"); + let definition = app.tabs[source] + .active_block_edit_session() + .unwrap() + .br_handle; + let p = + actions::parameters(&app.tabs[source].scene.document, definition, Kind::Move)[0].clone(); + let targets = members(&app); + let before = app.tabs[source].scene.document.clone(); + let depth = app.tabs[source].history.undo_stack.len(); + let _ = app.dispatch_command("BACTION MOVE"); + feed(&mut app, &p.name); + assert!(app.tabs[source] + .active_cmd + .as_ref() + .unwrap() + .is_selection_gathering()); + let _ = app.feed_command(crate::command::StepInput::SelectionComplete( + targets.clone(), + )); + let _ = app.update(Message::CommandEscape); + assert_eq!(app.tabs[source].scene.document, before); + assert_eq!(app.tabs[source].history.undo_stack.len(), depth); + for kind in ["Move", "Scale"] { + let _ = app.dispatch_command(&format!("BACTION {kind}")); + feed(&mut app, &p.name); + let _ = app.feed_command(crate::command::StepInput::SelectionComplete( + targets.clone(), + )); + assert!(app.tabs[source] + .active_cmd + .as_ref() + .unwrap() + .is_selection_gathering()); + let _ = app.feed_command(crate::command::StepInput::Enter); + feed(&mut app, "3,4"); + assert!(app.tabs[source].active_cmd.is_none()); + } + assert_eq!( + actions::actions(&app.tabs[source].scene.document, definition).len(), + 2 + ); + assert_eq!(app.tabs[source].history.undo_stack.len(), depth + 2); + app.undo_steps(1); + assert_eq!( + actions::actions(&app.tabs[source].scene.document, definition).len(), + 1 + ); + app.redo_steps(1); + assert_eq!( + actions::actions(&app.tabs[source].scene.document, definition).len(), + 2 + ); + let original = app.tabs[source].scene.document.clone(); + let _ = app.dispatch_command("BTESTBLOCK"); + let test = app.active_tab; + let insert = app.tabs[test].block_test.as_ref().unwrap().insert; + app.tabs[test].selected_handle = Some(insert); + app.refresh_test_action_grips([0.; 3]); + let grip = app.tabs[test].selected_grips.last().unwrap().id; + assert!(app.start_test_action_grip(insert, grip)); + feed(&mut app, "20,5"); + let EntityType::Line(line) = app.tabs[test] + .scene + .document + .get_entity(targets[0]) + .unwrap() + else { + panic!() + }; + assert_eq!(line.start, Vector3::new(20., 0., 0.)); + assert_eq!(line.end, Vector3::new(22., 0., 0.)); + assert_eq!(app.tabs[source].scene.document, original); + app.undo_steps(1); + let EntityType::Line(line) = app.tabs[test] + .scene + .document + .get_entity(targets[0]) + .unwrap() + else { + panic!() + }; + assert_eq!(line.start, Vector3::ZERO); + app.redo_steps(1); + let EntityType::Line(line) = app.tabs[test] + .scene + .document + .get_entity(targets[0]) + .unwrap() + else { + panic!() + }; + assert_eq!(line.start, Vector3::new(20., 0., 0.)); + app.tabs[test] + .scene + .document + .add_entity(EntityType::Insert(Insert::new( + "EDIT_ME", + Vector3::new(50., 0., 0.), + ))) + .unwrap(); + let shared_before = app.tabs[test].scene.document.clone(); + let id = app.tabs[test].id; + let _ = app.handle_block_action_result(crate::command::CmdResult::EvaluateBlockParameter { + document: id, + definition, + parameter: p.handle, + position: glam::DVec3::new(30., 0., 0.), + }); + assert_eq!( + app.tabs[test].scene.document, shared_before, + "shared references are refused rather than silently modified together" + ); + let _ = app.dispatch_command("BCLOSETESTBLOCK"); + assert_eq!(app.active_tab, source); + assert_eq!(app.tabs[source].scene.document, original); +} + +#[test] +fn block_actions_rejected_outside_editor_preserve_running_command_and_stale_results_are_inert() { + use crate::scene::block_actions::Kind; + let (mut app, reference) = fixture(); + let i = app.active_tab; + let _ = app.dispatch_command("LINE"); + let before = app.tabs[i].scene.document.clone(); + let _ = app.dispatch_command("BACTION MOVE"); + assert_eq!(app.tabs[i].active_cmd.as_ref().unwrap().name(), "LINE"); + assert_eq!(app.tabs[i].scene.document, before); + let _ = app.update(Message::CommandEscape); + begin(&mut app, reference); + let _ = app.run_command_line("BPARAMETER POINT 2,3 4,5"); + let def = app.tabs[i].active_block_edit_session().unwrap().br_handle; + let p = crate::scene::block_actions::parameters(&app.tabs[i].scene.document, def, Kind::Move) + [0] + .handle; + let before = app.tabs[i].scene.document.clone(); + let _ = app.handle_block_action_result(crate::command::CmdResult::PlaceBlockAction { + document: app.tabs[i].id + 1, + definition: def, + kind: Kind::Move, + parameter: p, + targets: members(&app), + location: glam::DVec3::ZERO, + }); + assert_eq!(app.tabs[i].scene.document, before); + let _ = app.dispatch_command("LINE"); + let _ = app.dispatch_command("BACTION SCALE"); + assert_eq!(app.tabs[i].active_cmd.as_ref().unwrap().name(), "LINE"); +} + +#[test] +fn block_actions_test_reference_transform_visibility_and_noop_history() { + use crate::scene::block_actions::{self as actions, Kind}; + let (mut app, reference) = fixture(); + begin(&mut app, reference); + let source = app.active_tab; + let _ = app.run_command_line("BPARAMETER POINT 2,3 4,5"); + let _ = app.run_command_line("BPARAMETER VISIBILITY 8,8"); + let _ = app.run_command_line("BPARAMETER BASEPOINT 1,2"); + let definition = app.tabs[source] + .active_block_edit_session() + .unwrap() + .br_handle; + let p = + actions::parameters(&app.tabs[source].scene.document, definition, Kind::Move)[0].clone(); + let targets = members(&app); + let _ = app.dispatch_command("BACTION MOVE"); + feed(&mut app, &p.name); + let _ = app.feed_command(crate::command::StepInput::SelectionComplete(targets)); + let _ = app.feed_command(crate::command::StepInput::Enter); + feed(&mut app, "4,6"); + let _ = app.dispatch_command("BTESTBLOCK"); + let i = app.active_tab; + let insert = app.tabs[i].block_test.as_ref().unwrap().insert; + let depth = app.tabs[i].history.undo_stack.len(); + let id = app.tabs[i].id; + let EntityType::Insert(ins) = app.tabs[i].scene.document.get_entity(insert).unwrap() else { + panic!() + }; + let current_world = actions::v( + crate::scene::render_graph::insert_transform(&app.tabs[i].scene.document, ins) + .apply(actions::native(p.end)), + ); + let _ = app.handle_block_action_result(crate::command::CmdResult::EvaluateBlockParameter { + document: id, + definition, + parameter: p.handle, + position: current_world, + }); + assert_eq!(app.tabs[i].history.undo_stack.len(), depth); + // Existing visibility UI creates a per-reference anonymous definition. + app.apply_visibility_state(insert, 0); + app.finish_pending_history(i); + let EntityType::Insert(ins) = app.tabs[i].scene.document.get_entity(insert).unwrap() else { + panic!() + }; + let after_world = actions::v( + crate::scene::render_graph::insert_transform(&app.tabs[i].scene.document, ins) + .apply(actions::native(p.end)), + ); + assert_eq!( + after_world, current_world, + "visibility representation retains the native Basepoint anchor" + ); + let EntityType::Insert(ins) = app.tabs[i].scene.document.get_entity_mut(insert).unwrap() else { + panic!() + }; + ins.insert_point = Vector3::new(100., 200., 0.); + ins.rotation = std::f64::consts::FRAC_PI_2; + ins.set_x_scale(2.); + ins.set_y_scale(3.); + let EntityType::Insert(ins) = app.tabs[i].scene.document.get_entity(insert).unwrap() else { + panic!() + }; + let name = ins.block_name.clone(); + assert_ne!(name, "EDIT_ME"); + let world = actions::v( + crate::scene::render_graph::insert_transform(&app.tabs[i].scene.document, ins) + .apply(Vector3::new(12., 13., 0.)), + ); + let before = app.tabs[i].scene.document.clone(); + let _ = app.handle_block_action_result(crate::command::CmdResult::EvaluateBlockParameter { + document: id, + definition, + parameter: p.handle, + position: world, + }); + let anon = app.tabs[i] + .scene + .document + .entities_in_block(&name) + .find_map(|e| { + if let EntityType::Line(l) = e { + Some(l) + } else { + None + } + }) + .unwrap(); + assert!((anon.start.x - 10.).abs() < 1e-9 && (anon.start.y - 10.).abs() < 1e-9); + app.undo_steps(1); + assert_eq!(app.tabs[i].scene.document, before); +} + fn authored_points(app: &OpenCADStudio) -> Vec { let tab = &app.tabs[app.active_tab]; let definition = tab @@ -98,6 +657,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..2ac5f2456 100644 --- a/src/app/block_test.rs +++ b/src/app/block_test.rs @@ -5,6 +5,9 @@ use iced::Task; pub(super) struct BlockTestSession { pub source_id: u64, + pub definition: codec::Handle, + pub insert: codec::Handle, + pub baseline: std::sync::Arc, } impl OpenCADStudio { @@ -67,8 +70,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(); @@ -98,13 +100,19 @@ impl OpenCADStudio { if !insert.attributes.is_empty() { insert.seqend_handle = Some(document.allocate_handle()); } + let insert_handle = insert.common.handle; if let Err(error) = document.add_entity(EntityType::Insert(insert)) { self.command_line.push_error(&error.to_string()); return Task::none(); } let mut tab = DocumentTab::new_drawing(0); tab.tab_title = crate::tf!("Test Block Window - {}", name).into_owned(); - tab.block_test = Some(BlockTestSession { source_id }); + tab.block_test = Some(BlockTestSession { + source_id, + definition, + insert: insert_handle, + baseline: std::sync::Arc::new(document.clone()), + }); tab.active_layer = self.tabs[i].active_layer.clone(); tab.scene.document = document; tab.scene.named_parameters = self.tabs[i].scene.named_parameters.clone(); diff --git a/src/app/command_driver/mod.rs b/src/app/command_driver/mod.rs index 7d5133b36..ad4428912 100644 --- a/src/app/command_driver/mod.rs +++ b/src/app/command_driver/mod.rs @@ -575,6 +575,16 @@ impl OpenCADStudio { return task; } } + result @ CmdResult::EditBlockAuthoring { .. } => { + return self.handle_authoring_edit(result); + } + result @ (CmdResult::PlaceBlockAction { .. } + | CmdResult::EvaluateBlockParameter { .. }) => { + return self.handle_block_action_result(result); + } + 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..a33024d00 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,38 @@ impl OpenCADStudio { return Some(Task::none()); } match cmd { + cmd if cmd == "BACTION" + || cmd.starts_with("BACTION ") + || cmd == "BACTIONTOOL" + || cmd.starts_with("BACTIONTOOL ") => + { + Some( + self.start_block_action( + cmd.split_once(' ').map_or("", |(_, args)| args.trim()), + ), + ) + } + cmd if cmd == "BLOOKUPTABLE" || cmd.starts_with("BLOOKUPTABLE ") => { + Some(self.open_lookup_table(cmd.strip_prefix("BLOOKUPTABLE").unwrap().trim())) + } 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 +457,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 ac2bf7cc6..9755dc41a 100644 --- a/src/app/commands/mod.rs +++ b/src/app/commands/mod.rs @@ -175,6 +175,22 @@ impl OpenCADStudio { }; let resolved = self.resolve_alias(cmd); let cmd = resolved.as_deref().unwrap_or(cmd); + if matches!(cmd.split(' ').next(), Some("BACTION" | "BACTIONTOOL")) { + let args = cmd.split_once(' ').map_or("", |(_, a)| a.trim()); + if !args.is_empty() && crate::scene::block_actions::Kind::parse(args).is_none() { + self.command_line.push_error( + crate::t!( + "Supported actions: Move, Scale. Other action types are not wired yet." + ) + .as_ref(), + ); + return Task::none(); + } + if let Some(error) = self.action_error(crate::scene::block_actions::Kind::parse(args)) { + self.command_line.push_error(crate::t!(error).as_ref()); + return Task::none(); + } + } if cmd == "BPARAMETER" || cmd.starts_with("BPARAMETER ") { if let Some(error) = self.parameter_placement_error(cmd.strip_prefix("BPARAMETER").unwrap().trim()) @@ -183,6 +199,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..982427aa0 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,203 @@ fn authoring_app() -> OpenCADStudio { app } +#[test] +fn block_actions_palette_controls_require_compatible_parameters_and_start_commands() { + with_stack(|| { + use crate::ui::window::authoring_palettes::Tab; + let mut app = authoring_app(); + let _ = app.dispatch_command("BAUTHORPALETTE"); + let point = authoring_control(&app, Tab::Actions.widget_id()); + authoring_click(&mut app, point); + for id in ["authoring-tool-move-action", "authoring-tool-scale-action"] { + let point = authoring_control(&app, id); + authoring_click(&mut app, point); + assert!(app.tabs[app.active_tab].active_cmd.is_none()); + } + let _ = app.run_command_line("BPARAMETER POINT 0.5,0.5 0.7,0.7"); + let point = authoring_control(&app, "authoring-tool-scale-action"); + authoring_click(&mut app, point); + assert!(app.tabs[app.active_tab].active_cmd.is_none()); + let point = authoring_control(&app, "authoring-tool-move-action"); + authoring_click(&mut app, point); + assert_eq!( + app.tabs[app.active_tab].active_cmd.as_ref().unwrap().name(), + "BACTION" + ); + let _ = app.update(Message::CommandEscape); + let _ = app.run_command_line("BPARAMETER LINEAR 0,0 1,0 0.5,0.3"); + let point = authoring_control(&app, "authoring-tool-scale-action"); + authoring_click(&mut app, point); + assert_eq!( + app.tabs[app.active_tab].active_cmd.as_ref().unwrap().name(), + "BACTION" + ); + let _ = app.update(Message::CommandEscape); + authoring_capture(&app, "move-scale-actions-enabled"); + let i = app.active_tab; + let targets = app.tabs[i] + .scene + .document + .block_records + .get("AUTHOR") + .unwrap() + .entity_handles + .clone(); + let point = authoring_control(&app, "authoring-tool-scale-action"); + authoring_click(&mut app, point); + let _ = app.feed_command(crate::command::StepInput::Point(glam::DVec3::X)); + assert!( + app.tabs[i] + .active_cmd + .as_ref() + .unwrap() + .is_selection_gathering(), + "picking the parameter endpoint starts object selection" + ); + let _ = app.feed_command(crate::command::StepInput::SelectionComplete(targets)); + let _ = app.feed_command(crate::command::StepInput::Enter); + let _ = app.update(Message::CommandInput("0.8,0.5".into())); + let _ = app.update(Message::CommandSubmit); + assert!(app.tabs[i].active_cmd.is_none()); + let _ = app.run_command_line("ZOOM E"); + authoring_capture(&app, "scale-action-placed"); + let _ = app.dispatch_command("BTESTBLOCK"); + let i = app.active_tab; + app.tabs[i].scene.selection.borrow_mut().view.vp_size = (1600., 900.); + app.tabs[i].scene.camera.borrow_mut().fit_to_bounds_f64( + glam::DVec3::new(-0.2, -0.4, 0.), + glam::DVec3::new(1.2, 0.6, 0.), + 1600. / 900., + ); + let insert = app.tabs[i].block_test.as_ref().unwrap().insert; + app.tabs[i].scene.select_entity(insert, true); + app.refresh_properties(); + assert!(app.tabs[i] + .selected_grips + .iter() + .any(|g| crate::app::block_actions::is_test_grip(g.id))); + authoring_capture(&app, "scale-action-test-grip"); + // Drawing a software snapshot updates the canvas size. Use the same + // current tile as the viewport input path, including when captures run. + let (vw, vh) = app.tabs[i].scene.selection.borrow().view.vp_size; + let tile = app.tabs[i].scene.active_model_tile_bounds(vw, vh); + let bounds = iced::Rectangle { + x: 0., + y: 0., + width: tile.width, + height: tile.height, + }; + let grip = app.tabs[i] + .selected_grips + .iter() + .find(|g| crate::app::block_actions::is_test_grip(g.id)) + .unwrap(); + let screen = app.tabs[i] + .scene + .camera + .borrow() + .project(grip.world, bounds) + .unwrap(); + app.cursor_pos = iced::Point::new(screen.x + tile.x, screen.y + tile.y); + app.tabs[i].scene.selection.borrow_mut().input.last_move_pos = Some(app.cursor_pos); + assert!( + app.tabs[i].active_cmd.is_none(), + "a running command consumes the test grip" + ); + let hit = crate::scene::pick::grip::find_hit_grip( + iced::Point::new(screen.x, screen.y), + &app.tabs[i].selected_grips, + &app.tabs[i].scene.camera.borrow(), + bounds, + ); + assert!( + hit.is_some_and(|(_, id, _, _)| crate::app::block_actions::is_test_grip(id)), + "test grip hit failed: {hit:?}; bounds={bounds:?}, cursor={:?}", + app.cursor_pos + ); + let _ = app.update(Message::ViewportLeftPressAt(app.cursor_pos)); + assert_eq!( + app.tabs[i].active_cmd.as_ref().map(|c|c.name()), + Some("BTESTBLOCK"), + "viewport click routes the parameter grip to the test command: modal={:?}, active grip={:?}, selected={:?}, navigation={:?}, ucs={}",app.active_modal,app.tabs[i].active_grip,app.tabs[i].selected_handle,(app.tabs[i].orbit_mode,app.tabs[i].pan_mode,app.tabs[i].zoom_dynamic_mode),app.ucs_icon_selected + ); + let _ = app.update(Message::CommandInput("2,0".into())); + let _ = app.update(Message::CommandSubmit); + let line = app.tabs[i] + .scene + .document + .entities_in_block("AUTHOR") + .find_map(|e| { + if let codec::EntityType::Line(l) = e { + Some(l) + } else { + None + } + }) + .unwrap(); + assert_eq!(line.end, codec::types::Vector3::new(2., 0., 0.)); + }); +} + +#[test] +fn lookup_basepoint_palette_and_table_dialog_real_controls() { + with_stack(|| { + 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 +1374,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 +1471,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..7ac3e0f88 100644 --- a/src/app/dock/view.rs +++ b/src/app/dock/view.rs @@ -410,6 +410,9 @@ impl OpenCADStudio { self.visibility_placement_error(), self.point_parameter_placement_error(), self.parameter_placement_error("FLIP"), + self.lookup_table_error(), + self.action_error(Some(crate::scene::block_actions::Kind::Move)), + self.action_error(Some(crate::scene::block_actions::Kind::Scale)), ) } crate::ui::dock::PanelId::BlockPalette => { diff --git a/src/app/document.rs b/src/app/document.rs index 77871252f..dfe2434c2 100644 --- a/src/app/document.rs +++ b/src/app/document.rs @@ -182,6 +182,7 @@ pub(super) struct DocumentTab { /// the active grip edit instead of committing it. pub(super) grip_base_pending: bool, pub(super) selected_handle: Option, + pub(super) selected_authoring: Option, /// Dynamic-block visibility grip for the current single selection. pub(super) visibility_grip: Option, pub(super) wireframe: bool, @@ -657,6 +658,7 @@ impl DocumentTab { grip_copy: false, grip_base_pending: false, selected_handle: None, + selected_authoring: None, visibility_grip: None, wireframe: false, render_mode: codec::entities::ViewportRenderMode::Wireframe2D, diff --git a/src/app/mod.rs b/src/app/mod.rs index e23efa6d4..352b23c70 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -13,6 +13,9 @@ mod block_editor; mod block_editor_tests; mod block_save_as; mod block_visibility; +mod block_lookup; +mod block_actions; +mod block_authoring_edit; mod block_properties; #[cfg(test)] mod block_properties_tests; @@ -593,6 +596,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 +2299,7 @@ pub enum ModalKind { BlockEditPicker, BlockSaveAs, BlockVisibility, + BlockLookup, SaveDialog, Recovery, /// Fonts referenced by the drawing are missing on this machine. @@ -2949,6 +2954,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, @@ -4732,6 +4738,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/properties.rs b/src/app/properties.rs index 1b88ad023..a243f06ce 100644 --- a/src/app/properties.rs +++ b/src/app/properties.rs @@ -2475,6 +2475,45 @@ filter={:.1} local={:.1} aggregate={:.1} entities={}", } } }; + if selected.is_empty() { + if let Some(item) = self.tabs[i].selected_authoring.and_then(|handle| { + let session = self.tabs[i].active_block_edit_session()?; + crate::scene::block_authoring::edit::items( + &self.tabs[i].scene.document, + session.br_handle, + ) + .into_iter() + .find(|item| item.handle == handle) + }) { + use crate::scene::model::object::{PropSection, PropValue, Property}; + panel.title = item.name; + panel.sections = vec![PropSection { + title: crate::t!("Geometry").into_owned(), + props: item + .grips + .into_iter() + .map(|(part, point)| Property { + label: crate::t!(match part { + crate::scene::block_authoring::edit::Part::Point => "Position", + crate::scene::block_authoring::edit::Part::Base => "Base point", + crate::scene::block_authoring::edit::Part::End => "End point", + crate::scene::block_authoring::edit::Part::Reference => + "Reference point", + crate::scene::block_authoring::edit::Part::Label(0) => + "Label position", + _ => "Second label position", + }) + .into_owned(), + field: "authoring_grip_position", + value: PropValue::ReadOnly(format!( + "{:.4}, {:.4}, {:.4}", + point.x, point.y, point.z + )), + }) + .collect(), + }]; + } + } // Precompute the focused-id → field-key map for O(1) lookups on // `PropSyncActive`; derived from `sections`, so rebuild it here. panel.field_key_by_id = crate::ui::properties::build_field_key_map(&panel.sections); @@ -2886,6 +2925,8 @@ handles={handles_ms:.1} panel={:.1} ribbon={ribbon_ms:.1} tail={:.1} selected={} // Append the dynamic-block visibility (lookup) grip, if the lone // selection is a visibility-parametric block reference. self.refresh_visibility_grip(wo); + self.refresh_test_action_grips(wo); + self.refresh_authoring_grips(wo); } pub(super) fn property_target_handles(&self, i: usize) -> Vec { diff --git a/src/app/update/command.rs b/src/app/update/command.rs index 83e6fbc47..a85ae7f7c 100644 --- a/src/app/update/command.rs +++ b/src/app/update/command.rs @@ -1410,6 +1410,7 @@ impl OpenCADStudio { // ESC while in MSPACE → exit back to paper space. return Task::done(Message::ExitViewport); } else { + self.tabs[i].selected_authoring = None; self.tabs[i].scene.deselect_all(); self.refresh_properties(); let mut sel = self.tabs[i].scene.selection.borrow_mut(); diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index d77fb772a..1766571d1 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, @@ -9729,6 +9730,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; } @@ -10753,6 +10757,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/update/viewport.rs b/src/app/update/viewport.rs index 1b3b5008f..6a5ea31c6 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -829,7 +829,11 @@ impl OpenCADStudio { .copied() .zip(self.tabs[i].selected_grips.iter()) .filter(|(owner, grip)| self.tabs[i].hot_grips.contains(&(*owner, grip.id))) - .filter(|(_, grip)| grip.id != crate::app::visibility::VIS_GRIP_ID) + .filter(|(_, grip)| { + grip.id != crate::app::visibility::VIS_GRIP_ID + && !crate::app::block_actions::is_test_grip(grip.id) + && !crate::app::block_authoring_edit::is_editor_grip(grip.id) + }) .map(|(owner, grip)| GripTarget { handle: owner, grip_id: grip.id, @@ -851,7 +855,11 @@ impl OpenCADStudio { .iter() .copied() .zip(self.tabs[i].selected_grips.iter()) - .filter(|(_, grip)| grip.id != crate::app::visibility::VIS_GRIP_ID) + .filter(|(_, grip)| { + grip.id != crate::app::visibility::VIS_GRIP_ID + && !crate::app::block_actions::is_test_grip(grip.id) + && !crate::app::block_authoring_edit::is_editor_grip(grip.id) + }) .filter(|(_, grip)| (grip.world - world).length_squared() <= epsilon_sq) .map(|(owner, grip)| GripTarget { handle: owner, @@ -993,6 +1001,8 @@ impl OpenCADStudio { // hover grip-menu. if self.grip_popup.is_none() && grip_id != crate::app::visibility::VIS_GRIP_ID + && !crate::app::block_actions::is_test_grip(grip_id) + && !crate::app::block_authoring_edit::is_editor_grip(grip_id) && self .grip_hover .as_ref() @@ -3993,6 +4003,12 @@ impl OpenCADStudio { self.grip_popup = None; return Task::none(); } + if self.start_test_action_grip(handle, grip_id) { + return Task::none(); + } + if self.start_authoring_grip(handle, grip_id) { + return Task::none(); + } if self.shift_down { let key = (handle, grip_id); if !self.tabs[i].hot_grips.remove(&key) { @@ -4111,6 +4127,10 @@ impl OpenCADStudio { } } + if edit_cam.is_none() && self.pick_authoring_object(p, bounds) { + return Task::none(); + } + // Ctrl+click on a composite solid picks the solid of it under the // cursor — the one the Properties palette and grips then edit. if self.ctrl_down diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index 37f166fe2..580ab4ab0 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -1119,6 +1119,45 @@ 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) + }), + ) + .chain( + crate::scene::block_actions::actions( + &tab.scene.document, + session.br_handle, + ) + .into_iter() + .map(|(_, action, _, _)| { + let mut g = crate::scene::block_distance_parameter::Graphic::default(); + g.grips.push((action.display_location, false)); + g.labels + .push((action.display_location, action.element.name.clone())); + (g, action.display_location) + }), + ) .filter_map(|(graphic, marker_tail)| { let project = |p: codec::types::Vector3| { let p = tab @@ -1155,6 +1194,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 6d1d2f362..6ecb0c10e 100644 --- a/src/app/view/modal.rs +++ b/src/app/view/modal.rs @@ -89,6 +89,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"), @@ -523,6 +524,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 78778f14f..410de6a4d 100644 --- a/src/command.rs +++ b/src/command.rs @@ -1514,6 +1514,34 @@ impl DimensionPreview { /// Returned by every `CadCommand` method to tell main.rs what to do. #[allow(dead_code)] pub enum CmdResult { + EditBlockAuthoring { + document: u64, + definition: Handle, + handle: Handle, + part: crate::scene::block_authoring::edit::Part, + position: DVec3, + }, + PlaceBlockAction { + document: u64, + definition: Handle, + kind: crate::scene::block_actions::Kind, + parameter: Handle, + targets: Vec, + location: DVec3, + }, + EvaluateBlockParameter { + document: u64, + definition: Handle, + parameter: Handle, + position: DVec3, + }, + /// Place or relocate a Basepoint, or place a Lookup parameter. + PlaceBlockSingleParameter { + document_id: u64, + 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/action.rs b/src/modules/block_editor/action.rs new file mode 100644 index 000000000..03aced724 --- /dev/null +++ b/src/modules/block_editor/action.rs @@ -0,0 +1,203 @@ +//! Reuse the command host's point picking and object-set selection. +use crate::command::{CadCommand, CmdOption, CmdResult, PointPickContext}; +use crate::scene::block_actions::{Kind, Parameter}; +use codec::Handle; +use glam::DVec3; + +inventory::submit!(crate::command::CommandRegistration { + names: &["BACTION", "BACTIONTOOL"] +}); + +pub(crate) struct ActionCommand { + document: u64, + definition: Handle, + kind: Option, + candidates: Vec, + parameter: Option, + targets: Vec, + selecting: bool, + context: Option, +} +impl ActionCommand { + pub(crate) fn new( + document: u64, + definition: Handle, + kind: Option, + candidates: Vec, + ) -> Self { + Self { + document, + definition, + kind, + candidates, + parameter: None, + targets: vec![], + selecting: false, + context: None, + } + } + fn choose(&mut self, p: Parameter) -> CmdResult { + if self.kind == Some(Kind::Scale) && (p.point || p.xy) { + return CmdResult::ReportError( + crate::t!("Scale requires a Linear or Polar parameter.").into_owned(), + ); + } + self.parameter = Some(p.handle); + self.selecting = true; + CmdResult::DeselectAndContinue + } +} +impl CadCommand for ActionCommand { + fn name(&self) -> &'static str { + "BACTION" + } + fn wants_text_input(&self) -> bool { + self.parameter.is_none() + } + fn prompt(&self) -> String { + if self.kind.is_none() { + return crate::t!("BACTION Enter action type [Move/Scale]:").into_owned(); + } + if self.parameter.is_none() { + return crate::tf!( + "BACTION {} Select a parameter endpoint or enter its name:", + crate::t!(self.kind.unwrap().name()) + ) + .into_owned(); + } + crate::t!(if self.selecting { + "BACTION Select objects for the action, then press Enter:" + } else { + "BACTION Specify action marker location:" + }) + .into_owned() + } + fn options(&self) -> Vec { + if self.kind.is_none() { + vec![ + CmdOption::new("Move", "Move"), + CmdOption::new("Scale", "Scale"), + ] + } else { + vec![] + } + } + fn on_text_input(&mut self, s: &str) -> Option { + if self.kind.is_none() { + return Some(if let Some(k) = Kind::parse(s) { + self.kind = Some(k); + CmdResult::NeedPoint + } else { + CmdResult::ReportError(crate::t!("Choose Move or Scale.").into_owned()) + }); + } + if self.parameter.is_none() { + let matches = self + .candidates + .iter() + .filter(|p| p.name.eq_ignore_ascii_case(s)) + .cloned() + .collect::>(); + return Some(if matches.len() == 1 { + self.choose(matches[0].clone()) + } else { + CmdResult::ReportError( + crate::t!("Enter a unique compatible parameter name or pick its endpoint.") + .into_owned(), + ) + }); + } + None + } + fn wants_point_pick_context(&self) -> bool { + self.kind.is_some() && self.parameter.is_none() + } + fn set_point_pick_context(&mut self, c: Option) { + self.context = c; + } + fn on_point(&mut self, point: DVec3) -> CmdResult { + if self.parameter.is_none() { + let Some(c) = self.context else { + return CmdResult::ReportError(crate::t!("Enter the parameter name.").into_owned()); + }; + let screen = + |p| crate::scene::pick::hit_test::world_to_screen(p, c.view, c.eye, c.bounds); + let click = screen(point); + let candidate = self + .candidates + .iter() + .filter(|p| self.kind != Some(Kind::Scale) || (!p.point && !p.xy)) + .map(|p| (screen(p.end).distance(click), p)) + .filter(|(d, _)| d.is_finite() && *d <= c.aperture_px.max(10.)) + .min_by(|a, b| a.0.total_cmp(&b.0)) + .map(|(_, p)| p.clone()); + return candidate.map(|p| self.choose(p)).unwrap_or_else(|| { + CmdResult::ReportError( + crate::t!("Pick a compatible parameter endpoint.").into_owned(), + ) + }); + } + if self.selecting { + return CmdResult::NeedPoint; + } + if !point.is_finite() { + return CmdResult::ReportError( + crate::t!("Specify a finite action location.").into_owned(), + ); + } + CmdResult::PlaceBlockAction { + document: self.document, + definition: self.definition, + kind: self.kind.unwrap(), + parameter: self.parameter.unwrap(), + targets: self.targets.clone(), + location: point, + } + } + fn is_selection_gathering(&self) -> bool { + self.selecting + } + fn on_selection_complete(&mut self, handles: Vec) -> CmdResult { + // The host calls this after every pick/window/removal, not just Enter. + // Retain the current set while the user gathers additional objects. + self.targets = handles; + CmdResult::NeedPoint + } + fn on_enter(&mut self) -> CmdResult { + if self.selecting { + if self.targets.is_empty() { + return CmdResult::ReportError( + crate::t!("Select at least one object for the action.").into_owned(), + ); + } + self.selecting = false; + return CmdResult::NeedPoint; + } + CmdResult::Cancel + } +} + +pub(crate) struct TestParameterCommand { + pub document: u64, + pub definition: Handle, + pub parameter: Handle, +} +impl CadCommand for TestParameterCommand { + fn name(&self) -> &'static str { + "BTESTBLOCK" + } + fn prompt(&self) -> String { + crate::t!("Test Block Specify the new parameter endpoint (Escape cancels):").into_owned() + } + fn on_point(&mut self, position: DVec3) -> CmdResult { + CmdResult::EvaluateBlockParameter { + document: self.document, + definition: self.definition, + parameter: self.parameter, + position, + } + } + fn on_enter(&mut self) -> CmdResult { + CmdResult::Cancel + } +} diff --git a/src/modules/block_editor/mod.rs b/src/modules/block_editor/mod.rs index 007de5fe2..06fa45da9 100644 --- a/src/modules/block_editor/mod.rs +++ b/src/modules/block_editor/mod.rs @@ -6,7 +6,9 @@ use crate::modules::parametric::ParametricModule; use crate::modules::{CadModule, IconKind, ModuleEvent, RibbonGroup, RibbonItem, ToolDef}; pub mod visibility_parameter; +pub mod action; pub mod point_parameter; +pub mod single_parameter; pub mod distance_parameter; 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_actions.rs b/src/scene/block_actions.rs new file mode 100644 index 000000000..17de6ee9f --- /dev/null +++ b/src/scene/block_actions.rs @@ -0,0 +1,568 @@ +//! Native Move/Scale authoring and deterministic evaluation in Test Block. +use super::block_authoring::{self as authoring, *}; +use codec::objects::{ + BlockAction, BlockActionOffsets, BlockActionWithBasePoint, BlockBasePointAction, + BlockConnection, BlockElement, BlockEvalValue, BlockMoveAction, DynamicBlockData, ObjectType, +}; +use codec::types::{Transform, Vector3}; +use codec::{CadDocument, EntityType, Handle}; +use glam::DVec3; +#[cfg(test)] +mod tests; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Kind { + Move, + Scale, +} +impl Kind { + pub(crate) fn parse(s: &str) -> Option { + match s.to_ascii_uppercase().as_str() { + "MOVE" | "M" => Some(Self::Move), + "SCALE" | "S" => Some(Self::Scale), + _ => None, + } + } + pub(crate) fn name(self) -> &'static str { + match self { + Self::Move => "Move", + Self::Scale => "Scale", + } + } +} + +#[derive(Clone, Debug)] +pub(crate) struct Parameter { + pub handle: Handle, + pub id: i32, + pub grip: Handle, + pub name: String, + pub base: DVec3, + pub end: DVec3, + pub linear: bool, + pub point: bool, + pub xy: bool, +} +pub(crate) fn v(p: Vector3) -> DVec3 { + DVec3::new(p.x, p.y, p.z) +} +pub(crate) fn native(p: DVec3) -> Vector3 { + Vector3::new(p.x, p.y, p.z) +} + +pub(crate) fn parameters(doc: &CadDocument, definition: Handle, kind: Kind) -> Vec { + let Ok(Some(graph)) = authoring::graph(doc, definition) else { + return vec![]; + }; + let mut result = vec![]; + for (handle, p) in super::block_point_parameter::parameters(doc, definition) { + if kind == Kind::Move { + result.push(Parameter { + handle, + id: p.parameter.parameter.element.eval.node_id, + grip: Handle::NULL, + name: p.position_name.clone(), + base: v(p.parameter.definition_point), + end: v(p.parameter.definition_point), + linear: false, + point: true, + xy: false, + }); + } + } + for p in super::block_distance_parameter::parameters(doc, definition) { + let xy = p.kind == super::block_distance_parameter::Kind::XY; + if kind == Kind::Scale && xy { + continue; + } + result.push(Parameter { + handle: p.handle, + id: p.native.parameter.element.eval.node_id, + grip: Handle::NULL, + name: p.labels.first().cloned().unwrap_or_default(), + base: v(p.native.definition_base_point), + end: v(p.native.definition_end_point), + linear: p.kind == super::block_distance_parameter::Kind::Linear, + point: false, + xy, + }); + } + // Resolve the actual linked grip, rather than assuming expression IDs are contiguous. + result.retain_mut(|p| { + let grip_id = if p.point { + super::block_point_parameter::parameters(doc, definition) + .into_iter() + .find(|(h, _)| *h == p.handle) + .unwrap() + .1 + .parameter + .property_count + } else { + super::block_distance_parameter::parameters(doc, definition) + .into_iter() + .find(|q| q.handle == p.handle) + .unwrap() + .native + .properties[2] + .connections + .first() + .map(|c| c.code) + .unwrap_or(-1) + }; + let grips = doc + .objects + .iter() + .filter_map(|(h, o)| { + let ObjectType::DynamicBlock(o) = o else { + return None; + }; + if o.owner != graph { + return None; + } + let grip = match &o.data { + DynamicBlockData::XYGrip(g) | DynamicBlockData::PolarGrip(g) => g, + DynamicBlockData::LinearGrip(g) => &g.grip, + _ => return None, + }; + (grip.element.eval.node_id == grip_id).then_some(*h) + }) + .collect::>(); + if grips.len() != 1 + || !p.base.is_finite() + || !p.end.is_finite() + || (!p.point && (p.end.distance(p.base) < 1e-9 || !p.end.distance(p.base).is_finite())) + { + return false; + } + p.grip = grips[0]; + true + }); + result.sort_by_key(|p| p.id); + result +} + +pub(crate) fn supported(e: &EntityType) -> bool { + matches!( + e, + EntityType::Line(_) + | EntityType::Circle(_) + | EntityType::Arc(_) + | EntityType::LwPolyline(_) + | EntityType::Ellipse(_) + | EntityType::Point(_) + | EntityType::Text(_) + | EntityType::MText(_) + ) +} +pub(crate) fn create( + doc: &mut CadDocument, + definition: Handle, + kind: Kind, + parameter: Handle, + targets: &[Handle], + location: DVec3, +) -> Result { + if !location.is_finite() { + return Err("Specify a finite action location."); + } + let p = parameters(doc, definition, kind) + .into_iter() + .find(|p| p.handle == parameter) + .ok_or("Select a compatible parameter belonging to this block.")?; + if targets.is_empty() { + return Err("Select at least one object for the action."); + } + let members = &doc + .block_records + .iter() + .find(|b| b.handle == definition) + .ok_or("The block definition is unavailable.")? + .entity_handles; + if targets.iter().any(|h| { + !members.contains(h) + || doc + .get_entity(*h) + .is_none_or(|e| !supported(e) || e.common().owner_handle != definition) + }) { + return Err("This milestone supports lines, polylines, arcs, circles, ellipses, points and text belonging to this block."); + } + let mut staged = doc.clone(); + let (graph, slots) = authoring::prepare(&mut staged, definition)?; + let (dxf, cpp) = match kind { + Kind::Move => ("BLOCKMOVEACTION", "AcDbBlockMoveAction"), + Kind::Scale => ("BLOCKSCALEACTION", "AcDbBlockScaleAction"), + }; + register_classes(&mut staged, &[(dxf, cpp, 55)]); + let handle = staged.allocate_handle(); + let mut dependencies = targets.to_vec(); + dependencies.sort(); + dependencies.dedup(); + let action = BlockAction { + element: BlockElement { + eval: expression(slots.eval, NO_VALUE, BlockEvalValue::None), + name: format!("{}{}", kind.name(), slots.eval), + major: MAJOR, + minor: MINOR, + eed_1071: 0, + }, + display_location: native(location), + dependencies, + action_ids: vec![], + }; + let connection = |name: &str| BlockConnection { + code: p.id, + name: name.into(), + }; + let data = match kind { + Kind::Move => DynamicBlockData::MoveAction(BlockMoveAction { + action, + connections: if p.point { + [connection("XDelta"), connection("YDelta")] + } else { + [connection("EndXDelta"), connection("EndYDelta")] + }, + offsets: BlockActionOffsets { + distance_multiplier: 1., + angle_offset: 0., + flags: 0, + }, + }), + Kind::Scale => DynamicBlockData::ScaleAction(BlockBasePointAction { + action: BlockActionWithBasePoint { + action, + offset: Vector3::ZERO, + connections: vec![connection("UpdatedBaseX"), connection("UpdatedBaseY")], + dependent: true, + base_point: Vector3::ZERO, + }, + connections: vec![ + connection("Scale"), + connection("XScale"), + connection("YScale"), + ], + dwg_tail_byte: 0, + }), + }; + staged.objects.insert( + handle, + ObjectType::DynamicBlock(dynamic(handle, graph, dxf, cpp, data)), + ); + authoring::append_single(&mut staged, graph, slots, handle); + authoring::graph::bind_ids(&mut staged, graph, slots.eval, &[p.id])?; + *doc = staged; + Ok(handle) +} + +pub(crate) fn actions( + doc: &CadDocument, + definition: Handle, +) -> Vec<(Handle, &BlockAction, Kind, i32)> { + let Ok(Some(graph)) = authoring::graph(doc, definition) else { + return vec![]; + }; + let ObjectType::DynamicBlock(o) = &doc.objects[&graph] else { + return vec![]; + }; + let DynamicBlockData::EvaluationGraph(g) = &o.data else { + return vec![]; + }; + g.nodes + .iter() + .filter_map(|n| { + let ObjectType::DynamicBlock(o) = doc.objects.get(&n.expression)? else { + return None; + }; + if o.owner != graph { + return None; + } + match &o.data { + DynamicBlockData::MoveAction(a) => { + Some((o.handle, &a.action, Kind::Move, a.connections[0].code)) + } + DynamicBlockData::ScaleAction(a) => a + .connections + .first() + .map(|c| (o.handle, &a.action.action, Kind::Scale, c.code)), + _ => None, + } + }) + .collect() +} +pub(crate) fn reframe(doc: &mut CadDocument, definition: Handle, t: &Transform) -> usize { + let handles = actions(doc, definition) + .iter() + .map(|(h, _, _, _)| *h) + .collect::>(); + for h in &handles { + let ObjectType::DynamicBlock(o) = doc.objects.get_mut(h).unwrap() else { + unreachable!() + }; + match &mut o.data { + DynamicBlockData::MoveAction(a) => { + a.action.display_location = t.apply(a.action.display_location) + } + DynamicBlockData::ScaleAction(a) => { + a.action.action.display_location = t.apply(a.action.action.display_location); + a.action.base_point = t.apply(a.action.base_point); + a.action.offset = t.apply_rotation(a.action.offset); + } + _ => {} + } + } + handles.len() +} + +pub(crate) fn current(doc: &CadDocument, p: &Parameter) -> DVec3 { + let Some(ObjectType::DynamicBlock(o)) = doc.objects.get(&p.grip) else { + return p.end; + }; + match &o.data { + DynamicBlockData::XYGrip(g) | DynamicBlockData::PolarGrip(g) => v(g.location), + DynamicBlockData::LinearGrip(g) => v(g.grip.location), + _ => p.end, + } +} + +/// Visibility can give the test INSERT an anonymous representation. Keep its +/// parallel members in sync, retaining their own handles and visibility flags. +pub(crate) fn sync_representation( + doc: &mut CadDocument, + definition: Handle, + name: &str, +) -> Result<(), &'static str> { + let original = doc + .block_records + .iter() + .find(|b| b.handle == definition) + .ok_or("The test definition is unavailable.")?; + if original.name == name { + return Ok(()); + } + let source = original.entity_handles.clone(); + let target = doc + .block_records + .get(name) + .ok_or("The test representation is unavailable.")? + .entity_handles + .clone(); + if source.len() != target.len() { + return Err("The test representation no longer matches its definition."); + } + let affected = actions(doc, definition) + .iter() + .flat_map(|(_, a, _, _)| a.dependencies.iter().copied()) + .collect::>(); + for (from, to) in source.into_iter().zip(target) { + if !affected.contains(&from) { + continue; + } + let mut entity = doc + .get_entity(from) + .cloned() + .ok_or("An action target is unavailable.")?; + let current = doc + .get_entity_mut(to) + .ok_or("An action representation target is unavailable.")?; + *entity.common_mut() = current.common().clone(); + *current = entity; + } + Ok(()) +} + +/// Re-evaluate from the scratch document's immutable starting geometry. This +/// avoids incremental drift and makes repeated changes/order independent. +pub(crate) fn evaluate( + doc: &mut CadDocument, + baseline: &CadDocument, + definition: Handle, + parameter: Handle, + position: DVec3, +) -> Result { + let graph = authoring::graph(baseline, definition)? + .ok_or("The test authoring graph is unavailable.")?; + if baseline.objects.values().any(|o|matches!(o,ObjectType::DynamicBlock(o) if o.owner==graph && matches!(&o.data, + DynamicBlockData::StretchAction(_)|DynamicBlockData::PolarStretchAction(_)|DynamicBlockData::RotateAction(_)|DynamicBlockData::FlipAction(_)|DynamicBlockData::ArrayAction(_)))) { + return Err("This block contains action types not supported in Test Block yet."); + } + let p = parameters(baseline, definition, Kind::Move) + .into_iter() + .find(|p| p.handle == parameter) + .ok_or("The test parameter is unavailable.")?; + if !position.is_finite() { + return Err("Specify a finite parameter position."); + } + let mut position = position; + position.z = p.end.z; + if p.linear { + let axis = (p.end - p.base).normalize(); + let length = (position - p.base).dot(axis); + if length < 1e-9 { + return Err("Specify a positive parameter length."); + } + position = p.base + axis * length; + } + if current(doc, &p).distance(position) < 1e-10 { + return Ok(false); + } + let mut staged = doc.clone(); + let ObjectType::DynamicBlock(o) = staged + .objects + .get_mut(&p.grip) + .ok_or("The test grip is unavailable.")? + else { + return Err("The test grip is unavailable."); + }; + match &mut o.data { + DynamicBlockData::XYGrip(g) | DynamicBlockData::PolarGrip(g) => { + g.location = native(position) + } + DynamicBlockData::LinearGrip(g) => g.grip.location = native(position), + _ => return Err("The test grip is unavailable."), + } + let all = actions(baseline, definition); + let mut affected = std::collections::BTreeSet::new(); + for (_, a, _, _) in &all { + affected.extend(a.dependencies.iter().copied()); + } + for h in affected { + let mut entity = baseline + .get_entity(h) + .cloned() + .ok_or("An action target is unavailable.")?; + if !supported(&entity) { + return Err("This action contains an unsupported object type."); + } + // State flags may have changed through the existing visibility grip. + if let Some(e) = staged.get_entity(h) { + entity.common_mut().invisible = e.common().invisible; + } + for (action_handle, a, kind, id) in &all { + if !a.dependencies.contains(&h) { + continue; + } + let q = parameters(baseline, definition, *kind) + .into_iter() + .find(|p| p.id == *id) + .ok_or("An action has an unsupported parameter binding.")?; + if !test_parameter_supported(baseline, q.handle) { + return Err("This parameter uses value constraints or chaining not supported in Test Block yet."); + } + let end = current(&staged, &q); + let transform = match kind { + Kind::Move => crate::command::EntityTransform::Translate(end - q.end), + Kind::Scale => { + let factor = end.distance(q.base) / q.end.distance(q.base); + if !factor.is_finite() || factor < 1e-9 { + return Err("Specify a positive scale distance."); + } + crate::command::EntityTransform::Scale { + center: q.base, + factor, + } + } + }; + // Imported actions with custom multipliers/offsets/chaining must + // not silently behave like the simple actions authored here. + if !a.action_ids.is_empty() { + return Err("Chained actions are not supported in Test Block yet."); + } + let ObjectType::DynamicBlock(o) = &baseline.objects[action_handle] else { + unreachable!() + }; + match &o.data { + DynamicBlockData::MoveAction(m) + if m.offsets.distance_multiplier != 1. + || m.offsets.angle_offset != 0. + || m.offsets.flags != 0 + || m.connections + != if q.point { + [ + BlockConnection { + code: q.id, + name: "XDelta".into(), + }, + BlockConnection { + code: q.id, + name: "YDelta".into(), + }, + ] + } else { + [ + BlockConnection { + code: q.id, + name: "EndXDelta".into(), + }, + BlockConnection { + code: q.id, + name: "EndYDelta".into(), + }, + ] + } => + { + return Err("This Move action uses options not supported in Test Block yet.") + } + DynamicBlockData::ScaleAction(s) + if !s.action.dependent + || s.action.offset != Vector3::ZERO + || s.connections + != ["Scale", "XScale", "YScale"].map(|name| BlockConnection { + code: q.id, + name: name.into(), + }) + || s.action.connections + != ["UpdatedBaseX", "UpdatedBaseY"].map(|name| BlockConnection { + code: q.id, + name: name.into(), + }) => + { + return Err("This Scale action uses options not supported in Test Block yet.") + } + _ => {} + } + super::view::dispatch::apply_transform(&mut entity, &transform); + let bounds = entity.as_entity().bounding_box(); + if !v(bounds.min).is_finite() || !v(bounds.max).is_finite() { + return Err("The parameter value produces non-finite geometry."); + } + } + *staged + .get_entity_mut(h) + .ok_or("An action target was removed.")? = entity; + } + *doc = staged; + Ok(true) +} + +fn test_parameter_supported(doc: &CadDocument, handle: Handle) -> bool { + let Some(ObjectType::DynamicBlock(o)) = doc.objects.get(&handle) else { + return false; + }; + let distance = |s: &codec::objects::BlockParameterValueSet| { + s.flags == 1 + && s.minimum == 0. + && s.maximum == 0. + && s.increment == 0. + && s.values.is_empty() + }; + match &o.data { + DynamicBlockData::PointParameter(p) => !p.parameter.parameter.chain_actions, + DynamicBlockData::LinearParameter(p) => { + !p.parameter.parameter.chain_actions && distance(&p.value_set) + } + DynamicBlockData::XYParameter(p) => { + !p.parameter.parameter.chain_actions + && distance(&p.x_value_set) + && distance(&p.y_value_set) + } + DynamicBlockData::PolarParameter(p) => { + !p.parameter.parameter.chain_actions + && distance(&p.distance_value_set) + && p.angle_value_set.flags == 3 + && p.angle_value_set.minimum == 0. + && p.angle_value_set.maximum == std::f64::consts::TAU + && p.angle_value_set.increment == 0. + && p.angle_value_set.values.is_empty() + } + _ => false, + } +} diff --git a/src/scene/block_actions/tests.rs b/src/scene/block_actions/tests.rs new file mode 100644 index 000000000..18b14d891 --- /dev/null +++ b/src/scene/block_actions/tests.rs @@ -0,0 +1,214 @@ +use super::*; +use crate::scene::{block_distance_parameter as distance, block_point_parameter as point, Scene}; +use codec::{DwgReader, DwgWriter, DxfReader, DxfWriter}; +use std::io::Cursor; + +fn fixture() -> (CadDocument, Handle, Vec) { + let mut scene = Scene::new(); + scene.define_block_raw( + "ACTIONS", + Vector3::ZERO, + vec![ + EntityType::Line(codec::entities::Line::from_points( + Vector3::UNIT_X, + Vector3::new(2., 0., 0.), + )), + EntityType::Circle(codec::entities::Circle::from_center_radius( + Vector3::new(4., 3., 0.), + 2., + )), + ], + ); + let block = scene.document.block_records.get("ACTIONS").unwrap(); + let (definition, handles) = (block.handle, block.entity_handles.clone()); + (scene.document, definition, handles) +} +fn linear(doc: &mut CadDocument, def: Handle) -> Handle { + distance::create( + doc, + def, + distance::Kind::Linear, + Vector3::ZERO, + Vector3::new(10., 0., 0.), + Vector3::new(5., 2., 0.), + ) + .unwrap() +} +fn start(doc: &CadDocument, h: Handle) -> Vector3 { + let EntityType::Line(l) = doc.get_entity(h).unwrap() else { + panic!() + }; + l.start +} +#[test] +fn block_actions_native_bindings_roundtrip_and_append_preserves_neighbors() { + let (mut doc, def, handles) = fixture(); + let p = linear(&mut doc, def); + for kind in [Kind::Move, Kind::Scale] { + create( + &mut doc, + def, + kind, + p, + &[handles[0]], + DVec3::new(3., 4., 0.), + ) + .unwrap(); + } + let graph = authoring::graph(&doc, def).unwrap().unwrap(); + let ObjectType::DynamicBlock(o) = &doc.objects[&graph] else { + panic!() + }; + let DynamicBlockData::EvaluationGraph(g) = &o.data else { + panic!() + }; + assert!(g + .nodes + .iter() + .all(|n| authoring::graph::valid_lists(g, n.id))); + let dwg = DwgReader::from_stream(Cursor::new(DwgWriter::write_to_vec(&doc).unwrap())) + .read() + .unwrap(); + let dxf = DxfReader::from_reader(Cursor::new(DxfWriter::new(&doc).write_to_vec().unwrap())) + .unwrap() + .read() + .unwrap(); + for saved in [dwg, dxf] { + assert_eq!(actions(&saved, def).len(), 2); + for (h, _, _, _) in actions(&doc, def) { + assert_eq!(saved.objects[&h], doc.objects[&h]); + } + let mut test = saved.clone(); + assert!(evaluate(&mut test, &saved, def, p, DVec3::new(20., 5., 0.)).unwrap()); + assert_eq!(start(&test, handles[0]), Vector3::new(22., 0., 0.)); + assert_eq!(test.get_entity(handles[1]), saved.get_entity(handles[1])); + } +} +#[test] +fn block_actions_point_move_is_absolute_reversible_and_leaves_unselected_objects() { + let (mut doc, def, handles) = fixture(); + let p = point::create( + &mut doc, + def, + Vector3::new(2., 3., 0.), + Vector3::new(5., 6., 0.), + ) + .unwrap(); + create(&mut doc, def, Kind::Move, p, &[handles[0]], DVec3::ZERO).unwrap(); + let baseline = doc.clone(); + for _ in 0..8 { + assert!(evaluate(&mut doc, &baseline, def, p, DVec3::new(12., -7., 0.)).unwrap()); + assert_eq!(start(&doc, handles[0]), Vector3::new(11., -10., 0.)); + assert!(!evaluate(&mut doc, &baseline, def, p, DVec3::new(12., -7., 0.)).unwrap()); + assert!(evaluate(&mut doc, &baseline, def, p, DVec3::new(2., 3., 0.)).unwrap()); + assert_eq!(start(&doc, handles[0]), start(&baseline, handles[0])); + } + assert_eq!(doc.get_entity(handles[1]), baseline.get_entity(handles[1])); +} +#[test] +fn block_actions_scale_uses_distance_ratio_and_parameter_base() { + let (mut doc, def, handles) = fixture(); + let p = distance::create( + &mut doc, + def, + distance::Kind::Polar, + Vector3::new(4., 3., 0.), + Vector3::new(14., 3., 0.), + Vector3::new(9., 5., 0.), + ) + .unwrap(); + create(&mut doc, def, Kind::Scale, p, &[handles[1]], DVec3::ZERO).unwrap(); + let baseline = doc.clone(); + evaluate(&mut doc, &baseline, def, p, DVec3::new(4., 23., 0.)).unwrap(); + let EntityType::Circle(c) = doc.get_entity(handles[1]).unwrap() else { + panic!() + }; + assert_eq!(c.center, Vector3::new(4., 3., 0.)); + assert_eq!(c.radius, 4.); + let before = doc.clone(); + assert!(evaluate(&mut doc, &baseline, def, p, DVec3::new(4., 3., 0.)).is_err()); + assert_eq!(doc, before); +} +#[test] +fn block_actions_reject_invalid_selection_binding_and_nonfinite_without_mutation() { + let (mut doc, def, handles) = fixture(); + let p = linear(&mut doc, def); + let before = doc.clone(); + for (parameter, targets, location) in [ + (Handle::NULL, vec![handles[0]], DVec3::ZERO), + (p, vec![], DVec3::ZERO), + (p, vec![Handle::new(0xff_ff_ff)], DVec3::ZERO), + (p, vec![handles[0]], DVec3::splat(f64::NAN)), + ] { + assert!(create(&mut doc, def, Kind::Move, parameter, &targets, location).is_err()); + assert_eq!(doc, before); + } + let action = create(&mut doc, def, Kind::Move, p, &[handles[0]], DVec3::ZERO).unwrap(); + let baseline = doc.clone(); + assert!(evaluate(&mut doc, &baseline, def, p, DVec3::splat(f64::NAN)).is_err()); + assert_eq!(doc, baseline); + let mut restricted = doc.clone(); + let ObjectType::DynamicBlock(object) = restricted.objects.get_mut(&p).unwrap() else { + panic!() + }; + let DynamicBlockData::LinearParameter(parameter) = &mut object.data else { + panic!() + }; + parameter.value_set.maximum = 15.; + parameter.value_set.flags |= 2; + let restricted_baseline = restricted.clone(); + assert!(evaluate( + &mut restricted, + &restricted_baseline, + def, + p, + DVec3::new(20., 0., 0.) + ) + .is_err()); + assert_eq!( + restricted, restricted_baseline, + "unsupported value constraints are not silently ignored" + ); + let ObjectType::DynamicBlock(o) = doc.objects.get_mut(&action).unwrap() else { + panic!() + }; + let DynamicBlockData::MoveAction(m) = &mut o.data else { + panic!() + }; + m.offsets.distance_multiplier = 0.5; + let baseline = doc.clone(); + assert!(evaluate(&mut doc, &baseline, def, p, DVec3::new(15., 0., 0.)).is_err()); + assert_eq!(doc, baseline); +} +#[test] +fn block_actions_reframe_moves_markers_and_retains_targets_and_connections() { + let (mut doc, def, handles) = fixture(); + let p = linear(&mut doc, def); + let h = create( + &mut doc, + def, + Kind::Scale, + p, + &[handles[0]], + DVec3::new(3., 4., 0.), + ) + .unwrap(); + let before = doc.objects[&h].clone(); + let t = Transform::from_translation(Vector3::new(-2., -3., 0.)); + assert_eq!(reframe(&mut doc, def, &t), 1); + let ObjectType::DynamicBlock(o) = &doc.objects[&h] else { + panic!() + }; + let DynamicBlockData::ScaleAction(s) = &o.data else { + panic!() + }; + assert_eq!(s.action.action.display_location, Vector3::new(1., 1., 0.)); + let ObjectType::DynamicBlock(o) = before else { + panic!() + }; + let DynamicBlockData::ScaleAction(b) = o.data else { + panic!() + }; + assert_eq!(s.action.action.dependencies, b.action.action.dependencies); + assert_eq!(s.connections, b.connections); +} diff --git a/src/scene/block_authoring.rs b/src/scene/block_authoring.rs index f67140671..6713d2ca0 100644 --- a/src/scene/block_authoring.rs +++ b/src/scene/block_authoring.rs @@ -1,4 +1,6 @@ //! Shared native graph construction for Block Editor authoring objects. +pub(crate) mod graph; +pub(crate) mod edit; use codec::objects::{ BlockEvalExpression, BlockEvalValue, DynamicBlockData, DynamicBlockObject, ObjectType, }; @@ -219,6 +221,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_authoring/edit.rs b/src/scene/block_authoring/edit.rs new file mode 100644 index 000000000..47dfbb4ae --- /dev/null +++ b/src/scene/block_authoring/edit.rs @@ -0,0 +1,523 @@ +//! Editor picking and native definition-point edits. No proxy drawing entities. +use super::*; +use crate::scene::block_distance_parameter::{self as distance, Graphic}; +use codec::objects::{BlockGrip, BlockTwoPointParameter}; +use codec::types::Vector3; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Part { + Point, + Base, + End, + Label(usize), + Reference, +} +pub struct Item { + pub handle: Handle, + pub name: String, + pub graphic: Graphic, + pub grips: Vec<(Part, Vector3)>, +} +pub fn items(doc: &CadDocument, definition: Handle) -> Vec { + let mut result = vec![]; + for (handle, p) in crate::scene::block_point_parameter::parameters(doc, definition) { + let mut graphic = Graphic::default(); + graphic + .lines + .push([p.parameter.definition_point, p.definition_label_point]); + graphic + .labels + .push((p.definition_label_point, p.position_name.clone())); + result.push(Item { + handle, + name: p.position_name.clone(), + graphic, + grips: vec![ + (Part::Point, p.parameter.definition_point), + (Part::Label(0), p.definition_label_point), + ], + }); + } + for p in distance::parameters(doc, definition) { + let graphic = distance::graphic(&p); + let mut grips = vec![ + (Part::Base, p.native.definition_base_point), + (Part::End, p.native.definition_end_point), + ]; + // Polar's second label is derived from its angle arc, not an editable offset. + grips.extend( + graphic + .labels + .iter() + .take(if p.kind == distance::Kind::XY { 2 } else { 1 }) + .enumerate() + .map(|(i, (p, _))| (Part::Label(i), *p)), + ); + result.push(Item { + handle: p.handle, + name: p.labels.join(" / "), + graphic, + grips, + }); + } + for p in crate::scene::block_orientation_parameter::parameters(doc, definition) { + use crate::scene::block_orientation_parameter::Kind; + let graphic = crate::scene::block_orientation_parameter::graphic(&p); + let mut grips = vec![ + (Part::Base, p.native.definition_base_point), + (Part::End, p.native.definition_end_point), + ]; + if p.kind == Kind::Rotation { + grips.push((Part::Reference, p.reference)); + } + if p.kind != Kind::Alignment { + grips.extend( + graphic + .labels + .iter() + .take(1) + .map(|(p, _)| (Part::Label(0), *p)), + ); + } + result.push(Item { + handle: p.handle, + name: p.label.clone(), + graphic, + grips, + }); + } + for (handle, p) in crate::scene::block_lookup::parameters(doc, definition) { + result.push(single( + handle, + p.lookup_name.clone(), + p.parameter.definition_point, + )); + } + if let Some((handle, p)) = crate::scene::block_basepoint::parameter(doc, definition) { + result.push(single( + handle, + "Basepoint".into(), + p.parameter.definition_point, + )); + } + if let Some(p) = crate::scene::block_visibility::parameter(doc, definition) { + result.push(single(p.handle, p.name.clone(), p.def_point)); + } + for (handle, p, _, _) in crate::scene::block_actions::actions(doc, definition) { + result.push(single(handle, p.element.name.clone(), p.display_location)); + } + result +} +fn single(handle: Handle, name: String, point: Vector3) -> Item { + let mut graphic = Graphic::default(); + graphic.labels.push((point, name.clone())); + Item { + handle, + name, + graphic, + grips: vec![(Part::Point, point)], + } +} +fn two(data: &mut DynamicBlockData) -> Option<&mut BlockTwoPointParameter> { + Some(match data { + DynamicBlockData::LinearParameter(p) => &mut p.parameter, + DynamicBlockData::PolarParameter(p) => &mut p.parameter, + DynamicBlockData::XYParameter(p) => &mut p.parameter, + DynamicBlockData::RotationParameter(p) => &mut p.parameter, + DynamicBlockData::AlignmentParameter(p) => &mut p.parameter, + DynamicBlockData::FlipParameter(p) => &mut p.parameter, + _ => return None, + }) +} +fn grip(data: &mut DynamicBlockData) -> Option<&mut BlockGrip> { + Some(match data { + DynamicBlockData::LinearGrip(p) | DynamicBlockData::AlignmentGrip(p) => &mut p.grip, + DynamicBlockData::XYGrip(p) + | DynamicBlockData::PolarGrip(p) + | DynamicBlockData::RotationGrip(p) + | DynamicBlockData::LookupGrip(p) + | DynamicBlockData::VisibilityGrip(p) => p, + DynamicBlockData::FlipGrip(p) => &mut p.grip, + _ => return None, + }) +} +pub fn move_part( + doc: &mut CadDocument, + definition: Handle, + handle: Handle, + part: Part, + at: Vector3, +) -> Result { + if ![at.x, at.y, at.z].iter().all(|v| v.is_finite()) { + return Err("Specify a finite authoring location."); + } + let item = items(doc, definition) + .into_iter() + .find(|i| i.handle == handle) + .ok_or("The authoring object is no longer available.")?; + let old = item + .grips + .iter() + .find(|(p, _)| *p == part) + .map(|(_, p)| *p) + .ok_or("This authoring grip is unavailable.")?; + if (at.z - old.z).abs() > 1e-8 { + return Err("Keep the authoring grip in its block XY plane."); + } + if (at - old).length() < 1e-10 { + return Ok(false); + } + let graph = graph(doc, definition)?.ok_or("The authoring graph is unavailable.")?; + let mut staged = doc.clone(); + if crate::scene::block_basepoint::parameter(doc, definition).is_some_and(|(h, _)| h == handle) { + crate::scene::block_basepoint::create(&mut staged, definition, at)?; + *doc = staged; + return Ok(true); + } + let mut moved_grips = vec![]; + match staged + .objects + .get_mut(&handle) + .ok_or("The authoring object is unavailable.")? + { + ObjectType::BlockVisibilityParameter(p) => { + p.def_point = at; + moved_grips.extend( + p.property_info + .iter() + .flat_map(|p| p.connections.iter().map(|c| (c.code, at))), + ); + } + ObjectType::DynamicBlock(object) => { + if let Some(p) = two(&mut object.data) { + match part { + Part::Base => { + p.definition_base_point = at; + p.base_point = p.base_point.map(|_| at); + p.updated_base_point = p.updated_base_point.map(|_| at); + moved_grips.push((p.property_states[0], at)); + } + Part::End => { + p.definition_end_point = at; + p.end_point = p.end_point.map(|_| at); + p.updated_end_point = p.updated_end_point.map(|_| at); + moved_grips.push((p.property_states[1], at)); + } + _ => {} + } + let length = (p.definition_end_point - p.definition_base_point).length(); + if !length.is_finite() || length < 1e-10 { + return Err("Parameter endpoints must remain distinct and finite."); + } + } + match &mut object.data { + DynamicBlockData::PointParameter(p) => match part { + Part::Point => { + p.parameter.definition_point = at; + moved_grips.push((p.parameter.property_count, at)) + } + Part::Label(_) => p.definition_label_point = at, + _ => {} + }, + DynamicBlockData::LookupParameter(p) => { + p.parameter.definition_point = at; + moved_grips.push((p.parameter.property_count, at)) + } + DynamicBlockData::BasePointParameter(p) => { + p.parameter.definition_point = at; + p.point = at; + p.base_point = at; + } + DynamicBlockData::LinearParameter(p) => { + if let Part::Label(_) = part { + p.distance = label_offset(&p.parameter, at) + } + } + DynamicBlockData::PolarParameter(p) => { + if let Part::Label(_) = part { + p.offset = label_offset(&p.parameter, at) + } + } + DynamicBlockData::XYParameter(p) => { + if let Part::Label(index) = part { + if index == 0 { + p.x_value = at.y - p.parameter.definition_base_point.y + } else { + p.y_value = at.x - p.parameter.definition_base_point.x + } + } + if (p.parameter.definition_end_point.x - p.parameter.definition_base_point.x) + .abs() + < 1e-10 + || (p.parameter.definition_end_point.y + - p.parameter.definition_base_point.y) + .abs() + < 1e-10 + { + return Err("XY parameter endpoints need both X and Y distances."); + } + } + DynamicBlockData::RotationParameter(p) => match part { + Part::Reference => { + if (at - p.parameter.definition_base_point).length() < 1e-10 { + return Err("The rotation reference must differ from its base."); + } + p.definition_base_angle_point = at + } + Part::Label(_) => { + let b = p.parameter.definition_base_point; + p.angle = + (at - b).length() - (p.parameter.definition_end_point - b).length() + } + _ => {} + }, + DynamicBlockData::FlipParameter(p) => { + if let Part::Label(_) = part { + p.definition_label_point = at + } + } + DynamicBlockData::MoveAction(p) => p.action.display_location = at, + DynamicBlockData::ScaleAction(p) => p.action.action.display_location = at, + DynamicBlockData::AlignmentParameter(_) => {} + _ => return Err("This authoring object cannot be edited yet."), + } + if let DynamicBlockData::RotationParameter(p) = &mut object.data { + let base = p.parameter.definition_base_point; + if part == Part::Reference { + let radius = (p.definition_base_angle_point - base).length(); + let delta = p.parameter.definition_end_point - base; + let end = base + delta * (radius / delta.length()); + p.parameter.definition_end_point = end; + p.parameter.end_point = p.parameter.end_point.map(|_| end); + p.parameter.updated_end_point = p.parameter.updated_end_point.map(|_| end); + moved_grips.push((p.parameter.property_states[1], end)); + } else if matches!(part, Part::Base | Part::End) { + let old_base = item.grips.iter().find(|(p, _)| *p == Part::Base).unwrap().1; + let direction = p.definition_base_angle_point - old_base; + let radius = (p.parameter.definition_end_point - base).length(); + p.definition_base_angle_point = + base + direction * (radius / direction.length()); + } + if let Some(e) = crate::scene::block_orientation_parameter::points_error( + crate::scene::block_orientation_parameter::Kind::Rotation, + base, + p.parameter.definition_end_point, + p.definition_base_angle_point, + ) { + return Err(e); + } + } + } + _ => return Err("This is not an authoring object."), + } + for object in staged.objects.values_mut() { + let ObjectType::DynamicBlock(object) = object else { + continue; + }; + if object.owner != graph { + continue; + } + let Some(g) = grip(&mut object.data) else { + continue; + }; + if let Some((_, location)) = moved_grips + .iter() + .find(|(id, _)| *id > 0 && *id == g.element.eval.node_id) + { + g.location = *location; + } + } + // Keep the Linear grip's native direction consistent after endpoint edits. + if matches!(part, Part::Base | Part::End) { + if let Some(p) = distance::parameters(&staged, definition) + .into_iter() + .find(|p| p.handle == handle) + { + let delta = p.native.definition_end_point - p.native.definition_base_point; + let orientation = delta * (1.0 / delta.length()); + let ids = p.native.property_states; + for o in staged.objects.values_mut() { + if let ObjectType::DynamicBlock(o) = o { + if o.owner == graph { + if let DynamicBlockData::LinearGrip(g) = &mut o.data { + if ids.contains(&g.grip.element.eval.node_id) { + g.orientation = orientation; + } + } + } + } + } + } + if let Some(p) = crate::scene::block_orientation_parameter::parameters(&staged, definition) + .into_iter() + .find(|p| p.handle == handle) + { + let orientation = p.native.definition_end_point - p.native.definition_base_point; + let ids = p.native.property_states; + for o in staged.objects.values_mut() { + if let ObjectType::DynamicBlock(o) = o { + if o.owner == graph { + match &mut o.data { + DynamicBlockData::AlignmentGrip(g) + if ids.contains(&g.grip.element.eval.node_id) => + { + g.orientation = orientation + } + DynamicBlockData::FlipGrip(g) + if ids.contains(&g.grip.element.eval.node_id) => + { + g.orientation = orientation + } + _ => {} + } + } + } + } + } + } + if let Some(ObjectType::BlockVisibilityParameter(p)) = staged.objects.get(&handle) { + staged.block_visibility_params.insert(handle, p.clone()); + } + if let Some(item) = items(&staged, definition) + .into_iter() + .find(|i| i.handle == handle) + { + let finite = |p: Vector3| [p.x, p.y, p.z].iter().all(|v| v.is_finite()); + if !item.grips.iter().all(|(_, p)| finite(*p)) + || !item.graphic.lines.iter().flatten().all(|p| finite(*p)) + { + return Err("The authoring location produces non-finite geometry."); + } + } + *doc = staged; + Ok(true) +} +fn label_offset(p: &BlockTwoPointParameter, at: Vector3) -> f64 { + let d = p.definition_end_point - p.definition_base_point; + let n = Vector3::new(-d.y, d.x, 0.) * (1.0 / d.x.hypot(d.y)); + let delta = at - (p.definition_base_point + p.definition_end_point) * 0.5; + delta.x * n.x + delta.y * n.y +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::scene::{ + block_orientation_parameter as orientation, block_point_parameter as point, Scene, + }; + #[test] + fn authoring_edit_all_parameter_kinds_keep_native_grips_and_graph_identity() { + let mut scene = Scene::new(); + scene.define_block_raw("EDIT", Vector3::ZERO, vec![]); + let doc = &mut scene.document; + let def = doc.block_records.get("EDIT").unwrap().handle; + let b = Vector3::new(2., 3., 0.); + let e = Vector3::new(6., 7., 0.); + let l = Vector3::new(1., 8., 0.); + point::create(doc, def, b, l).unwrap(); + for kind in [ + distance::Kind::Linear, + distance::Kind::Polar, + distance::Kind::XY, + ] { + distance::create(doc, def, kind, b, e, l).unwrap(); + } + for kind in [ + orientation::Kind::Rotation, + orientation::Kind::Alignment, + orientation::Kind::Flip, + ] { + orientation::create( + doc, + def, + kind, + b, + e, + Vector3::new(b.x + (e - b).length(), b.y, 0.), + ) + .unwrap(); + } + crate::scene::block_lookup::create(doc, def, b).unwrap(); + crate::scene::block_basepoint::create(doc, def, b).unwrap(); + let visibility = crate::scene::block_visibility::create(doc, def, b).unwrap(); + let graph_handle = graph(doc, def).unwrap().unwrap(); + let graph_before = doc.objects[&graph_handle].clone(); + let inventory = items(doc, def); + assert_eq!(inventory.len(), 10); + for item in inventory { + for (part, _) in item.grips { + let old = items(doc, def) + .into_iter() + .find(|i| i.handle == item.handle) + .unwrap() + .grips + .into_iter() + .find(|(p, _)| *p == part) + .unwrap() + .1; + let next = old + Vector3::new(0.3, 0.5, 0.); + assert!( + move_part(doc, def, item.handle, part, next).unwrap(), + "{} {part:?}", + item.name + ); + let after = items(doc, def) + .into_iter() + .find(|i| i.handle == item.handle) + .unwrap(); + let actual = after.grips.iter().find(|(p, _)| *p == part).unwrap().1; + // Dimension labels project onto their normal/radius; definition points are exact. + if !matches!(part, Part::Label(_)) { + assert!((actual - next).length() < 1e-10); + } + let snapshot = doc.clone(); + assert!(!move_part(doc, def, item.handle, part, actual).unwrap()); + assert!( + move_part(doc, def, item.handle, part, Vector3::new(f64::NAN, 0., 0.)).is_err() + ); + assert_eq!(*doc, snapshot); + } + } + assert_eq!( + doc.objects[&graph_handle], graph_before, + "editing positions must preserve graph lists and node identities" + ); + assert_eq!( + crate::scene::block_visibility::parameter(doc, def) + .unwrap() + .def_point, + items(doc, def) + .into_iter() + .find(|i| i.handle == visibility) + .unwrap() + .grips[0] + .1 + ); + for p in distance::parameters(doc, def) { + let ids = p.native.property_states; + let expected = p.native.definition_end_point; + for o in doc.objects.values() { + if let ObjectType::DynamicBlock(o) = o { + let mut data = o.data.clone(); + if let Some(g) = grip(&mut data) { + if g.element.eval.node_id == ids[1] { + assert_eq!(g.location, expected); + } + } + } + } + } + let bytes = codec::DxfWriter::new(doc).write_to_vec().unwrap(); + let saved = codec::DxfReader::from_reader(std::io::Cursor::new(bytes)) + .unwrap() + .read() + .unwrap(); + let a = items(doc, def); + let b = items(&saved, def); + assert_eq!(a.len(), b.len()); + for (a, b) in a.iter().zip(b) { + assert_eq!(a.handle, b.handle); + assert_eq!(a.grips, b.grips); + } + } +} diff --git a/src/scene/block_authoring/graph.rs b/src/scene/block_authoring/graph.rs new file mode 100644 index 000000000..1b8356c56 --- /dev/null +++ b/src/scene/block_authoring/graph.rs @@ -0,0 +1,220 @@ +//! Maintain native adjacency lists while adding/replacing action dependencies. +use codec::objects::{BlockEvaluationEdge, BlockEvaluationGraph, DynamicBlockData, ObjectType}; +use codec::{CadDocument, Handle}; + +pub(crate) 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(crate) fn bind_ids( + document: &mut CadDocument, + graph: Handle, + action: i32, + parameters: &[i32], +) -> 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(parameters.iter().copied()) + .any(|id| g.nodes.iter().filter(|n| n.next_id == id).count() != 1) + { + return Err("An action binding has an invalid or ambiguous expression ID."); + } + let action = g + .nodes + .iter() + .find(|n| n.next_id == action) + .ok_or("The action node is unavailable.")? + .id; + let mut bindings = vec![]; + for parameter in parameters { + let node = g + .nodes + .iter() + .find(|n| n.next_id == *parameter) + .ok_or("A bound parameter no longer belongs to this block.")? + .id; + if node == action { + return Err("An action cannot bind to itself."); + } + 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 authoring graph has invalid adjacency links."); + } + if old.iter().any(|e| e.next_id != 4) { + return Err("This 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 authoring 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_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..fa8c8fa1f --- /dev/null +++ b/src/scene/block_lookup.rs @@ -0,0 +1,411 @@ +//! 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, +}; +use super::block_authoring::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_ids( + document, + graph, + node_id, + &edited.columns.iter().map(|c| c.node_id).collect::>(), + ) + .map_err(str::to_string)?; + if let Some(ObjectType::DynamicBlock(o)) = document.objects.get_mut(&handle) { + o.data = DynamicBlockData::LookupAction(edited); + } + 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/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/block_visibility.rs b/src/scene/block_visibility.rs index 6c88a883c..282562974 100644 --- a/src/scene/block_visibility.rs +++ b/src/scene/block_visibility.rs @@ -499,18 +499,7 @@ impl Scene { .map_or(1, |n| n + 1); format!("*U{next}") }; - let base = self - .document - .block_records - .iter() - .find(|record| record.handle == source) - .and_then( - |record| match self.document.get_entity(record.block_entity_handle) { - Some(EntityType::Block(block)) => Some(block.base_point), - _ => None, - }, - ) - .unwrap_or(Vector3::ZERO); + let base = super::render_graph::block_base_point(&self.document, &reference.block_name); let originals = members(&self.document, source); let copies: Vec = originals .iter() diff --git a/src/scene/camera_ops.rs b/src/scene/camera_ops.rs index 4b2cee7ab..3edb0a8f5 100644 --- a/src/scene/camera_ops.rs +++ b/src/scene/camera_ops.rs @@ -1714,6 +1714,23 @@ 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), + ) + .chain( + super::block_actions::actions(&self.document, block) + .into_iter() + .map(|(_, a, _, _)| a.display_location), + ); + for point in locations { + let point = glam::DVec3::new(point.x, point.y, point.z); + 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..0b2958a82 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -11,6 +11,9 @@ 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_actions; pub(crate) mod block_point_parameter; pub(crate) mod block_distance_parameter; pub(crate) mod block_orientation_parameter; @@ -62,6 +65,7 @@ mod layout; mod limits; mod modify; pub(crate) use modify::remap_copied_reactors; +pub(crate) use modify::inverse_affine; mod mspace; pub mod named_parameters; mod page_setup; diff --git a/src/scene/modify.rs b/src/scene/modify.rs index e2ff5f3fc..f7d078dd7 100644 --- a/src/scene/modify.rs +++ b/src/scene/modify.rs @@ -9,7 +9,9 @@ struct TextOrient { x_scale: f64, } -fn inverse_affine(transform: &codec::types::Transform) -> Option { +pub(crate) fn inverse_affine( + transform: &codec::types::Transform, +) -> Option { use codec::types::{Matrix3, Matrix4, Transform, Vector3}; let matrix = &transform.matrix.m; let linear = Matrix3::from_rows( @@ -507,9 +509,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 +530,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 +580,10 @@ 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) + + super::block_actions::reframe(&mut self.document, block_record, local_from_old); if !changed.is_empty() || parameter_changes > 0 { self.rebuild_derived_caches(); } diff --git a/src/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..348c80a76 100644 --- a/src/ui/window/authoring_palettes.rs +++ b/src/ui/window/authoring_palettes.rs @@ -193,6 +193,9 @@ fn catalog( visibility_error: Option<&'static str>, point_error: Option<&'static str>, flip_error: Option<&'static str>, + lookup_error: Option<&'static str>, + move_error: Option<&'static str>, + scale_error: Option<&'static str>, ) -> Element<'static, Message> { let (entries, unavailable) = match tab { Tab::Parameters => ( @@ -212,7 +215,11 @@ 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 geometry_action=tab==Tab::Actions&&matches!(*label,"Move"|"Scale"); + let action_error=if *label=="Scale" {scale_error}else{move_error}; + let enabled = (geometry_action&&action_error.is_none())||(single&&point_error.is_none())||(lookup_action&&lookup_error.is_none())||(orientation && orientation_error.is_none()) || (visibility && visibility_error.is_none()) || ((point || distance) && point_error.is_none()); let base = if enabled { icons::semantic(icon, 28.0) } else { @@ -221,10 +228,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 +243,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 geometry_action {format!("BACTION_{}",label.to_ascii_uppercase())}else if lookup_action {"BLOOKUPTABLE".into()}else{format!("BPARAMETER_{}", label.to_ascii_uppercase())}, + event: crate::modules::ModuleEvent::Command(if geometry_action {format!("BACTION {}",label.to_ascii_uppercase())}else if lookup_action {"BLOOKUPTABLE".into()}else{format!("BPARAMETER {}", label.to_ascii_uppercase())}), }); let description = if visibility { crate::t!(visibility_error @@ -253,6 +261,12 @@ 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 geometry_action { + crate::t!(action_error.unwrap_or("Select a parameter endpoint, select the objects it controls, then place the action marker. Test it using the parameter grip in Test Block.")).into_owned() + } else if lookup_action { + crate::t!(lookup_error.unwrap_or("Create or edit property Lookup tables for this block.")).into_owned() } else { unavailable.to_string() }; @@ -265,6 +279,12 @@ 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 geometry_action { + container(entry).id(if *label=="Move" {"authoring-tool-move-action"}else{"authoring-tool-scale-action"}).into() + } else if lookup_action { + container(entry).id("authoring-tool-lookup-table").into() } else { entry } @@ -378,6 +398,9 @@ pub fn view( visibility_error: Option<&'static str>, point_error: Option<&'static str>, flip_error: Option<&'static str>, + lookup_error: Option<&'static str>, + move_error: Option<&'static str>, + scale_error: Option<&'static str>, ) -> Element<'static, Message> { let tabs = column(Tab::ALL.into_iter().map(|category| { let label = category.label(); @@ -431,7 +454,10 @@ pub fn view( selected_tab, visibility_error, point_error, - flip_error + flip_error, + lookup_error, + move_error, + scale_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;