diff --git a/src/app/block_editor_tests.rs b/src/app/block_editor_tests.rs index 72297c88b..54af16bc5 100644 --- a/src/app/block_editor_tests.rs +++ b/src/app/block_editor_tests.rs @@ -74,6 +74,150 @@ fn authored_points(app: &OpenCADStudio) -> Vec usize { + let tab = &app.tabs[app.active_tab]; + let definition = tab + .scene + .document + .block_records + .get("EDIT_ME") + .unwrap() + .handle; + crate::scene::block_distance_parameter::parameters(&tab.scene.document, definition).len() +} + +#[test] +fn distance_parameter_batch_three_picks_local_undo_redo_and_saved_parent_undo() { + let (mut app, reference) = fixture(); + begin(&mut app, reference); + let i = app.active_tab; + for (index, kind) in ["LINEAR", "POLAR", "XY"].iter().enumerate() { + let before = app.tabs[i].scene.document.clone(); + let _ = app.dispatch_command(&format!("BPARAMETER {kind}")); + feed(&mut app, "2,3"); + feed(&mut app, "6,7"); + assert_eq!( + app.tabs[i].scene.document, before, + "base and endpoint are preview only" + ); + feed(&mut app, "1,8"); + assert_eq!(distance_parameter_count(&app), index + 1); + assert_eq!(app.tabs[i].history.undo_stack.len(), index + 1); + assert!(app.tabs[i].scene.preview_wires.is_empty()); + app.undo_steps(1); + assert_eq!(distance_parameter_count(&app), index); + app.redo_steps(1); + assert_eq!(distance_parameter_count(&app), index + 1); + } + let _ = app.run_command_line("BPARAMETER POINT 0,0 1,1"); + let _ = app.run_command_line("BPARAMETER VISIBILITY 0,0"); + let editor = app.tabs[i].scene.document.clone(); + let _ = app.dispatch_command("BTESTBLOCK"); + assert!(app.tabs[app.active_tab] + .active_block_edit_session() + .is_none()); + assert_eq!( + app.tabs[app.active_tab] + .scene + .document + .entities_in_block("EDIT_ME") + .count(), + 1, + "parameters and their editor graphics add no drawing geometry" + ); + assert_eq!(distance_parameter_count(&app), 3); + let _ = app.dispatch_command("BCLOSETESTBLOCK"); + assert_eq!(app.active_tab, i); + assert_eq!(app.tabs[i].scene.document, editor); + let _ = app.run_command_line("UCS ORIGIN 1,2,0"); + let definition = app.tabs[i].active_block_edit_session().unwrap().br_handle; + for p in + crate::scene::block_distance_parameter::parameters(&app.tabs[i].scene.document, definition) + { + assert_eq!(p.native.definition_base_point, Vector3::new(1.0, 1.0, 0.0)); + assert_eq!(p.native.definition_end_point, Vector3::new(5.0, 5.0, 0.0)); + } + let _ = app.dispatch_command("BSAVE"); + let _ = app.dispatch_command("BEDIT_DISCARD"); + assert_eq!(distance_parameter_count(&app), 3); + app.undo_steps(1); + assert_eq!(distance_parameter_count(&app), 0); + app.redo_steps(1); + assert_eq!(distance_parameter_count(&app), 3); +} + +#[test] +fn distance_parameter_cancel_chooser_invalid_endpoint_and_discard_are_safe() { + let (mut app, reference) = fixture(); + begin(&mut app, reference); + let i = app.active_tab; + let before = app.tabs[i].scene.document.clone(); + for kind in ["Linear", "Polar", "XY"] { + for picks in 0..3 { + let _ = app.dispatch_command("BPARAMETER"); + feed(&mut app, kind); + if picks > 0 { + feed(&mut app, "2,3"); + } + if picks > 1 { + feed(&mut app, "6,7"); + } + let _ = app.update(Message::CommandEscape); + assert_eq!(app.tabs[i].scene.document, before); + assert!(app.tabs[i].history.undo_stack.is_empty()); + } + let _ = app.dispatch_command(&format!("BPARAMETER {kind}")); + feed(&mut app, "2,3"); + feed(&mut app, "2,3"); + assert_eq!(app.tabs[i].scene.document, before); + assert!( + app.tabs[i].active_cmd.is_some(), + "invalid endpoint can be retried" + ); + feed(&mut app, "6,7"); + feed(&mut app, "1,8"); + assert_eq!(distance_parameter_count(&app), 1); + app.undo_steps(1); + } + let _ = app.run_command_line("BPARAMETER LINEAR 0,0 1,1 2,2"); + let _ = app.dispatch_command("BEDIT_DISCARD"); + assert_eq!(distance_parameter_count(&app), 0); + let _ = app.dispatch_command("LINE"); + for kind in ["LINEAR", "POLAR", "XY"] { + let _ = app.dispatch_command(&format!("BPARAMETER {kind}")); + assert_eq!(app.tabs[i].active_cmd.as_ref().unwrap().name(), "LINE"); + } +} + +#[test] +fn distance_parameter_stale_context_and_invalid_result_do_not_start_history() { + use crate::scene::block_distance_parameter::Kind; + 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 id = app.tabs[i].id; + let before = app.tabs[i].scene.document.clone(); + for (document_id, end) in [ + (id + 1, glam::DVec3::ONE), + (id, glam::DVec3::ZERO), + (id, glam::DVec3::splat(f64::NAN)), + ] { + let _ = app.handle_place_block_distance_parameter( + crate::command::CmdResult::PlaceBlockDistanceParameter { + document_id, + definition, + kind: Kind::Linear, + base: glam::DVec3::ZERO, + end, + label: glam::DVec3::ZERO, + }, + ); + assert_eq!(app.tabs[i].scene.document, before); + assert!(app.tabs[i].history.undo_stack.is_empty()); + } +} + #[test] fn point_parameter_placement_two_picks_one_undo_and_repeated_names() { let (mut app, reference) = fixture(); diff --git a/src/app/command_driver/mod.rs b/src/app/command_driver/mod.rs index 0f3dfe0cd..aeb460473 100644 --- a/src/app/command_driver/mod.rs +++ b/src/app/command_driver/mod.rs @@ -575,6 +575,9 @@ impl OpenCADStudio { return task; } } + result @ CmdResult::PlaceBlockDistanceParameter { .. } => { + return self.handle_place_block_distance_parameter(result); + } result @ CmdResult::PlaceBlockPointParameter { .. } => { return self.handle_place_block_point_parameter(result); } diff --git a/src/app/commands/bvisibility.rs b/src/app/commands/bvisibility.rs index 775422ebd..6a0b0569d 100644 --- a/src/app/commands/bvisibility.rs +++ b/src/app/commands/bvisibility.rs @@ -7,6 +7,66 @@ use codec::{CadDocument, EntityType, Handle}; use iced::Task; impl OpenCADStudio { + pub(in crate::app) fn handle_place_block_distance_parameter( + &mut self, + result: crate::command::CmdResult, + ) -> Task { + let crate::command::CmdResult::PlaceBlockDistanceParameter { + document_id, + definition, + kind, + base, + end, + label, + } = 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(); + } + let vector = |p: glam::DVec3| codec::types::Vector3::new(p.x, p.y, p.z); + if let Some(error) = self.parameter_placement_error(kind.name()).or_else(|| { + crate::scene::block_distance_parameter::points_error( + kind, + vector(base), + vector(end), + vector(label), + ) + }) { + self.command_line.push_error(crate::t!(error).as_ref()); + return Task::none(); + } + self.push_undo_snapshot(i, &format!("{} parameter", kind.name())); + crate::scene::block_distance_parameter::create( + &mut self.tabs[i].scene.document, + definition, + kind, + vector(base), + vector(end), + vector(label), + ) + .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::tf!("{} parameter placed.", crate::t!(kind.name())).as_ref()); + Task::none() + } + pub(in crate::app) fn point_parameter_placement_error(&self) -> Option<&'static str> { self.parameter_placement_error("POINT") } @@ -21,10 +81,16 @@ impl OpenCADStudio { &tab.scene.document, session.br_handle, ), + "LINEAR" | "L" | "POLAR" | "PO" | "XY" => { + crate::scene::block_distance_parameter::placement_error( + &tab.scene.document, + session.br_handle, + ) + } "VISIBILITY" | "V" => { visibility::placement_error(&tab.scene.document, session.br_handle) } - _ => Some("Only Point and Visibility parameter placement is currently supported."), + _ => Some("Supported parameter types: Point, Linear, Polar, XY, Visibility."), } } @@ -267,6 +333,14 @@ impl OpenCADStudio { return Some(Task::none()); }; let parameter_type = cmd.strip_prefix("BPARAMETER").unwrap().trim(); + if let Some(kind) = + crate::scene::block_distance_parameter::Kind::parse(parameter_type) + { + let command = crate::modules::block_editor::distance_parameter::DistanceParameterCommand::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 parameter_type.eq_ignore_ascii_case("POINT") || parameter_type.eq_ignore_ascii_case("P") { @@ -285,7 +359,7 @@ impl OpenCADStudio { { self.command_line.push_error( crate::t!( - "Only Point and Visibility parameter placement is currently supported." + "Supported parameter types: Point, Linear, Polar, XY, Visibility." ) .as_ref(), ); diff --git a/src/app/dock/tests.rs b/src/app/dock/tests.rs index 37d3b11f3..f07c51fb0 100644 --- a/src/app/dock/tests.rs +++ b/src/app/dock/tests.rs @@ -827,6 +827,42 @@ fn point_parameter_placement_starts_from_the_actual_palette_row() { }); } +#[test] +fn distance_parameter_batch_starts_from_actual_palette_rows() { + with_stack(|| { + for (id, name) in [ + ("authoring-tool-linear", "linear"), + ("authoring-tool-polar", "polar"), + ("authoring-tool-xy", "xy"), + ] { + let mut app = authoring_app(); + let _ = app.dispatch_command("BAUTHORPALETTE"); + let point = authoring_control(&app, id); + authoring_click(&mut app, point); + let i = app.active_tab; + let before = app.tabs[i].scene.document.clone(); + for text in ["0.25,-0.15", "0.65,0.15"] { + let _ = app.update(Message::CommandInput(text.into())); + let _ = app.update(Message::CommandSubmit); + assert_eq!(app.tabs[i].scene.document, before); + } + let _ = app.update(Message::CommandInput("0.3,0.2".into())); + let _ = app.update(Message::CommandSubmit); + let definition = app.tabs[i].active_block_edit_session().unwrap().br_handle; + let found = crate::scene::block_distance_parameter::parameters( + &app.tabs[i].scene.document, + definition, + ); + assert_eq!(found.len(), 1); + assert!(found[0].kind.name().eq_ignore_ascii_case(name)); + assert_eq!(app.tabs[i].history.undo_stack.len(), 1); + assert!(app.tabs[i].scene.preview_wires.is_empty()); + let _ = app.run_command_line("ZOOM E"); + authoring_capture(&app, &format!("{name}-parameter-placed")); + } + }); +} + fn authoring_app() -> OpenCADStudio { use codec::{ entities::{Insert, Line}, diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index b2b03600e..c262c4089 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -1075,6 +1075,64 @@ bg={bg_ms:.1}ms n={view_count}" .collect() }) .unwrap_or_default(); + let authoring_dimensions = tab + .active_block_edit_session() + .map(|session| { + crate::scene::block_distance_parameter::parameters( + &tab.scene.document, + session.br_handle, + ) + .iter() + .filter_map(|parameter| { + let project = |p: codec::types::Vector3| { + let p = tab + .scene + .camera + .borrow() + .project(glam::DVec3::new(p.x, p.y, p.z), vp_bounds)?; + (p.x.is_finite() && p.y.is_finite()) + .then_some(iced::Point::new(vp_bounds.x + p.x, vp_bounds.y + p.y)) + }; + let graphic = crate::scene::block_distance_parameter::graphic(parameter); + let lines = graphic + .lines + .into_iter() + .map(|[a, b]| Some([project(a)?, project(b)?])) + .collect::>>()?; + let labels = graphic + .labels + .into_iter() + .map(|(point, label)| Some((project(point)?, label))) + .collect::>>()?; + let grips = graphic + .grips + .into_iter() + .map(|(point, oriented)| { + Some(( + project(point)?, + if oriented { + Some(project(parameter.native.definition_base_point)?) + } else { + None + }, + )) + }) + .collect::>>()?; + Some(crate::ui::overlay::AuthoringDimension { + lines, + dimensions: graphic + .dimensions + .into_iter() + .map(|[a, b]| Some([project(a)?, project(b)?])) + .collect::>>()?, + labels, + grips, + clip: vp_bounds, + }) + }) + .collect() + }) + .unwrap_or_default(); crate::ui::overlay::selection_overlay( std::sync::Arc::clone(&tab.scene.selection), snap_info, @@ -1141,6 +1199,7 @@ bg={bg_ms:.1}ms n={view_count}" constraint_cursor_badge, authoring_visibility, authoring_points, + authoring_dimensions, ) }; diff --git a/src/command.rs b/src/command.rs index f75018b5f..7e4d09caf 100644 --- a/src/command.rs +++ b/src/command.rs @@ -1514,6 +1514,15 @@ impl DimensionPreview { /// Returned by every `CadCommand` method to tell main.rs what to do. #[allow(dead_code)] pub enum CmdResult { + /// Place a native two-point parameter after its base, end and label picks. + PlaceBlockDistanceParameter { + document_id: u64, + definition: Handle, + kind: crate::scene::block_distance_parameter::Kind, + base: DVec3, + end: DVec3, + label: DVec3, + }, /// Place a native Point parameter after both authoring picks complete. PlaceBlockPointParameter { document_id: u64, diff --git a/src/modules/block_editor/distance_parameter.rs b/src/modules/block_editor/distance_parameter.rs new file mode 100644 index 000000000..1ab353db7 --- /dev/null +++ b/src/modules/block_editor/distance_parameter.rs @@ -0,0 +1,129 @@ +//! Three-pick authoring shared by Linear, Polar and XY parameters. +use crate::command::{CadCommand, CmdResult}; +use crate::scene::{ + block_distance_parameter::{self, Kind}, + WireModel, +}; +use codec::{types::Vector3, Handle}; +use glam::DVec3; + +pub struct DistanceParameterCommand { + document_id: u64, + definition: Handle, + kind: Kind, + base: Option, + end: Option, +} + +impl DistanceParameterCommand { + pub(crate) fn new(document_id: u64, definition: Handle, kind: Kind) -> Self { + Self { + document_id, + definition, + kind, + base: None, + end: None, + } + } +} + +impl CadCommand for DistanceParameterCommand { + fn name(&self) -> &'static str { + "BPARAMETER" + } + fn prompt(&self) -> String { + let step = if self.base.is_none() { + "Specify parameter base point:" + } else if self.end.is_none() { + "Specify parameter endpoint:" + } else { + "Specify parameter label location:" + }; + crate::tf!( + "BPARAMETER {} {}", + crate::t!(self.kind.name()), + crate::t!(step) + ) + .into_owned() + } + fn on_point(&mut self, point: DVec3) -> CmdResult { + if !point.is_finite() { + return CmdResult::ReportError( + crate::t!("Specify a finite parameter location.").into_owned(), + ); + } + let Some(base) = self.base else { + self.base = Some(point); + return CmdResult::NeedPoint; + }; + let vector = |p: DVec3| Vector3::new(p.x, p.y, p.z); + if let Some(error) = block_distance_parameter::points_error( + self.kind, + vector(base), + vector(self.end.unwrap_or(point)), + vector(point), + ) { + return CmdResult::ReportError(crate::t!(error).into_owned()); + } + let Some(end) = self.end else { + self.end = Some(point); + return CmdResult::NeedPoint; + }; + CmdResult::PlaceBlockDistanceParameter { + document_id: self.document_id, + definition: self.definition, + kind: self.kind, + base, + end, + label: point, + } + } + fn on_preview_wires(&mut self, point: DVec3) -> Vec { + let Some(base) = self.base.filter(|_| point.is_finite()) else { + return vec![]; + }; + let end = self.end.unwrap_or(point); + let mut lines = vec![[base, end]]; + if self.kind == Kind::XY { + lines = vec![ + [base, DVec3::new(end.x, base.y, base.z)], + [base, DVec3::new(base.x, end.y, base.z)], + ]; + } + if self.end.is_some() { + if self.kind == Kind::XY { + let x = DVec3::new(base.x, point.y, base.z); + let y = DVec3::new(point.x, base.y, base.z); + lines.extend([ + [base, x], + [x, DVec3::new(end.x, point.y, base.z)], + [base, y], + [y, DVec3::new(point.x, end.y, base.z)], + ]); + } else { + let d = end - base; + let n = DVec3::new(-d.y, d.x, 0.0).normalize_or_zero(); + let offset = n * (point - base).dot(n); + lines = vec![ + [base, base + offset], + [base + offset, end + offset], + [end + offset, end], + ]; + } + } + lines + .into_iter() + .map(|line| { + WireModel::solid_f64( + "preview_distance_parameter".into(), + line.into_iter().map(|point| point.to_array()).collect(), + WireModel::CYAN, + false, + ) + }) + .collect() + } + 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 4dba9ac87..460fffd20 100644 --- a/src/modules/block_editor/mod.rs +++ b/src/modules/block_editor/mod.rs @@ -7,6 +7,7 @@ use crate::modules::{CadModule, IconKind, ModuleEvent, RibbonGroup, RibbonItem, pub mod visibility_parameter; pub mod point_parameter; +pub mod distance_parameter; pub struct BlockEditorModule; diff --git a/src/modules/block_editor/visibility_parameter.rs b/src/modules/block_editor/visibility_parameter.rs index ab6a44bca..071d6ca5f 100644 --- a/src/modules/block_editor/visibility_parameter.rs +++ b/src/modules/block_editor/visibility_parameter.rs @@ -27,8 +27,10 @@ impl CadCommand for VisibilityParameterCommand { fn prompt(&self) -> String { if self.choosing_type { - crate::t!("BPARAMETER Enter parameter type [Point/Visibility] :") - .into_owned() + crate::t!( + "BPARAMETER Enter parameter type [Point/Linear/Polar/XY/Visibility] :" + ) + .into_owned() } else { crate::t!("BPARAMETER Specify visibility parameter location:").into_owned() } @@ -38,6 +40,9 @@ impl CadCommand for VisibilityParameterCommand { if self.choosing_type { vec![ CmdOption::new(crate::t!("Point").as_ref(), "P"), + CmdOption::new(crate::t!("Linear").as_ref(), "L"), + CmdOption::new(crate::t!("Polar").as_ref(), "PO"), + CmdOption::new(crate::t!("XY").as_ref(), "XY"), CmdOption::new(crate::t!("Visibility").as_ref(), "V"), ] } else { @@ -60,16 +65,17 @@ impl CadCommand for VisibilityParameterCommand { .to_ascii_uppercase() .as_str() { + "L" | "LINEAR" => CmdResult::Dispatch("BPARAMETER LINEAR".into()), + "PO" | "POLAR" => CmdResult::Dispatch("BPARAMETER POLAR".into()), + "XY" => CmdResult::Dispatch("BPARAMETER XY".into()), "P" | "POINT" => CmdResult::Dispatch("BPARAMETER POINT".into()), "" | "V" | "VISIBILITY" => { self.choosing_type = false; CmdResult::NeedPoint } _ => CmdResult::ReportError( - crate::t!( - "Only Point and Visibility parameter placement is currently supported." - ) - .into_owned(), + crate::t!("Supported parameter types: Point, Linear, Polar, XY, Visibility.") + .into_owned(), ), }, ) diff --git a/src/scene/block_authoring.rs b/src/scene/block_authoring.rs index 16f499f9e..175e44a49 100644 --- a/src/scene/block_authoring.rs +++ b/src/scene/block_authoring.rs @@ -183,7 +183,8 @@ pub(super) fn prepare( Ok((handle, slots(None)?)) } -/// A one-point parameter, its XY/visibility grip and two position expressions. +/// A parameter, its single grip and two position expressions. Supports one-point +/// parameters and two-point parameters configured with one end grip. /// Graph node IDs, expression IDs and edge IDs are separate namespaces. pub(super) fn append( document: &mut CadDocument, diff --git a/src/scene/block_distance_parameter.rs b/src/scene/block_distance_parameter.rs new file mode 100644 index 000000000..6b52e00f1 --- /dev/null +++ b/src/scene/block_distance_parameter.rs @@ -0,0 +1,794 @@ +//! Native Linear, Polar and XY parameter authoring with one end grip. +use super::block_authoring::{self as authoring, *}; +use codec::objects::{ + BlockConnection, BlockElement, BlockEvalValue, BlockGrip, BlockGripExpression, + BlockLinearParameter, BlockOrientedGrip, BlockParameter, BlockParameterProperty, + BlockParameterValueSet, BlockPolarParameter, BlockTwoPointParameter, BlockXYParameter, + DynamicBlockData, ObjectType, +}; +use codec::types::{Transform, Vector3}; +use codec::{CadDocument, Handle}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Kind { + Linear, + Polar, + XY, +} + +impl Kind { + pub(crate) fn parse(text: &str) -> Option { + match text.to_ascii_uppercase().as_str() { + "LINEAR" | "L" => Some(Self::Linear), + "POLAR" | "PO" => Some(Self::Polar), + "XY" => Some(Self::XY), + _ => None, + } + } + pub(crate) fn name(self) -> &'static str { + match self { + Self::Linear => "Linear", + Self::Polar => "Polar", + Self::XY => "XY", + } + } + fn class(self) -> (&'static str, &'static str) { + match self { + Self::Linear => ("BLOCKLINEARPARAMETER", "AcDbBlockLinearParameter"), + Self::Polar => ("BLOCKPOLARPARAMETER", "AcDbBlockPolarParameter"), + Self::XY => ("BLOCKXYPARAMETER", "AcDbBlockXYParameter"), + } + } +} + +pub(crate) struct Parameter<'a> { + pub handle: Handle, + pub kind: Kind, + pub native: &'a BlockTwoPointParameter, + pub labels: Vec, + pub offsets: [f64; 2], +} + +fn graph_handle(document: &CadDocument, definition: Handle) -> Option { + match document + .objects + .get(&document.extension_dictionary_handle(definition)?)? + { + ObjectType::Dictionary(dictionary) => dictionary.get("ACAD_ENHANCEDBLOCK"), + _ => None, + } +} + +pub(crate) fn parameters(document: &CadDocument, definition: Handle) -> Vec> { + let Some(graph) = graph_handle(document, definition) else { + return Vec::new(); + }; + let Some(ObjectType::DynamicBlock(object)) = document.objects.get(&graph) else { + return Vec::new(); + }; + let DynamicBlockData::EvaluationGraph(data) = &object.data else { + return Vec::new(); + }; + data.nodes + .iter() + .filter_map(|node| { + let ObjectType::DynamicBlock(object) = document.objects.get(&node.expression)? else { + return None; + }; + if object.owner != graph { + return None; + } + let (kind, native, labels, offsets) = match &object.data { + DynamicBlockData::LinearParameter(p) => ( + Kind::Linear, + &p.parameter, + vec![p.distance_name.clone()], + [p.distance, 0.0], + ), + DynamicBlockData::PolarParameter(p) => ( + Kind::Polar, + &p.parameter, + vec![p.distance_name.clone(), p.angle_name.clone()], + [p.offset, 0.0], + ), + DynamicBlockData::XYParameter(p) => ( + Kind::XY, + &p.parameter, + vec![p.x_label.clone(), p.y_label.clone()], + [p.x_value, p.y_value], + ), + _ => return None, + }; + Some(Parameter { + handle: object.handle, + kind, + native, + labels, + offsets, + }) + }) + .collect() +} + +pub(crate) fn placement_error(document: &CadDocument, definition: Handle) -> Option<&'static str> { + authoring::graph(document, definition).err() +} + +pub(crate) fn points_error( + kind: Kind, + base: Vector3, + end: Vector3, + label: Vector3, +) -> Option<&'static str> { + if ![base, end, label] + .iter() + .all(|p| [p.x, p.y, p.z].iter().all(|v| v.is_finite())) + { + return Some("Specify finite parameter and label locations."); + } + let delta = end - base; + if !delta.x.hypot(delta.y).is_finite() { + return Some("The parameter dimensions are too large."); + } + if delta.x.hypot(delta.y) <= 1e-10 + || (end.z - base.z).abs() > 1e-8 + || (label.z - base.z).abs() > 1e-8 + { + return Some("Specify distinct parameter points in the same block XY plane."); + } + if kind == Kind::XY && (delta.x.abs() <= 1e-10 || delta.y.abs() <= 1e-10) { + return Some("Specify an XY endpoint with both X and Y distances."); + } + let offsets = offsets(kind, base, end, label); + if !offsets.iter().all(|value| value.is_finite()) { + return Some("The parameter dimensions are too large."); + } + None +} + +fn offsets(kind: Kind, base: Vector3, end: Vector3, label: Vector3) -> [f64; 2] { + if kind == Kind::XY { + [label.y - base.y, label.x - base.x] + } else { + [(label - base).dot(&dimension_normal(base, end)), 0.0] + } +} + +fn dimension_normal(base: Vector3, end: Vector3) -> Vector3 { + let d = end - base; + let length = d.x.hypot(d.y); + // A definition can be reframed onto an upright UCS after placement. + if length <= 1e-10 { + Vector3::UNIT_X + } else { + Vector3::new(-d.y / length, d.x / length, 0.0) + } +} + +pub(crate) fn create( + document: &mut CadDocument, + definition: Handle, + kind: Kind, + base: Vector3, + end: Vector3, + label: Vector3, +) -> Result { + if let Some(error) = points_error(kind, base, end, label) { + return Err(error); + } + let names = parameters(document, definition) + .into_iter() + .flat_map(|p| p.labels) + .collect::>(); + let unique = |prefix: &str| { + (1..) + .map(|n| format!("{prefix}{n}")) + .find(|name| names.iter().all(|old| !old.eq_ignore_ascii_case(name))) + .unwrap() + }; + let distance_name = unique("Distance"); + let angle_name = unique("Angle"); + let x_label = unique("DistanceX"); + let y_label = unique("DistanceY"); + let values = offsets(kind, base, end, label); + let (graph, slots) = authoring::prepare(document, definition)?; + let (class, cpp) = kind.class(); + let (grip_class, grip_cpp) = match kind { + Kind::Linear => ("BLOCKLINEARGRIP", "AcDbBlockLinearGrip"), + Kind::Polar => ("BLOCKPOLARGRIP", "AcDbBlockPolarGrip"), + Kind::XY => ("BLOCKXYGRIP", "AcDbBlockXYGrip"), + }; + register_classes( + document, + &[ + (class, cpp, 55), + (grip_class, grip_cpp, 55), + ("BLOCKGRIPLOCATIONCOMPONENT", "AcDbBlockGripExpr", 20), + ], + ); + let [parameter, grip, x, y] = [(); 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 native = BlockTwoPointParameter { + parameter: BlockParameter { + element: element(0, kind.name()), + show_properties: true, + chain_actions: false, + }, + definition_base_point: base, + definition_end_point: end, + properties: std::array::from_fn(|i| BlockParameterProperty { + connections: if i < 2 { + vec![] + } else { + vec![BlockConnection { + code: slots.eval + 1, + name: if i == 2 { + "DisplacementX" + } else { + "DisplacementY" + } + .into(), + }] + }, + }), + property_states: [0, slots.eval + 1, 0, 0], + parameter_base_location: 0, + ..Default::default() + }; + let value_set = || BlockParameterValueSet { + flags: 1, + ..Default::default() + }; + let data = match kind { + Kind::Linear => DynamicBlockData::LinearParameter(BlockLinearParameter { + parameter: native, + distance_name, + distance: values[0], + value_set: value_set(), + ..Default::default() + }), + Kind::Polar => DynamicBlockData::PolarParameter(BlockPolarParameter { + parameter: native, + distance_name, + angle_name, + offset: values[0], + distance_value_set: value_set(), + angle_value_set: BlockParameterValueSet { + flags: 3, + maximum: std::f64::consts::TAU, + ..Default::default() + }, + ..Default::default() + }), + Kind::XY => DynamicBlockData::XYParameter(BlockXYParameter { + parameter: native, + x_label, + y_label, + x_value: values[0], + y_value: values[1], + x_value_set: value_set(), + y_value_set: value_set(), + ..Default::default() + }), + }; + document.objects.insert( + parameter, + ObjectType::DynamicBlock(dynamic(parameter, graph, class, cpp, data)), + ); + let native_grip = BlockGrip { + element: element(1, "End Grip"), + flags_91: slots.eval + 2, + flags_92: slots.eval + 3, + location: end, + insert_cycling: true, + insert_cycling_weight: -1, + }; + let data = match kind { + Kind::Linear => { + let d = end - base; + let length = d.x.hypot(d.y); + DynamicBlockData::LinearGrip(BlockOrientedGrip { + grip: native_grip, + orientation: Vector3::new(d.x / length, d.y / length, 0.0), + }) + } + Kind::Polar => DynamicBlockData::PolarGrip(native_grip), + Kind::XY => DynamicBlockData::XYGrip(native_grip), + }; + document.objects.insert( + grip, + ObjectType::DynamicBlock(dynamic(grip, graph, grip_class, grip_cpp, data)), + ); + for (handle, offset, axis) in [(x, 2, "UpdatedEndX"), (y, 3, "UpdatedEndY")] { + document.objects.insert( + handle, + ObjectType::DynamicBlock(dynamic( + handle, + 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, [parameter, grip, x, y]); + Ok(parameter) +} + +/// World-space editor graphics, derived entirely from the persisted native fields. +#[derive(Default)] +pub(crate) struct Graphic { + pub lines: Vec<[Vector3; 2]>, + pub dimensions: Vec<[Vector3; 2]>, + pub labels: Vec<(Vector3, String)>, + pub grips: Vec<(Vector3, bool)>, // Linear uses a direction arrow; others a square. +} + +pub(crate) fn graphic(parameter: &Parameter<'_>) -> Graphic { + let base = parameter.native.definition_base_point; + let end = parameter.native.definition_end_point; + let mut result = Graphic::default(); + let mut dimension = |a: Vector3, b: Vector3, offset: Vector3, label: String| { + let start = a + offset; + let finish = b + offset; + result + .lines + .extend([[a, start], [b, finish], [start, finish]]); + result.dimensions.push([start, finish]); + result.labels.push(((start + finish) * 0.5, label)); + }; + if parameter.kind == Kind::XY { + dimension( + base, + Vector3::new(end.x, base.y, base.z), + Vector3::new(0.0, parameter.offsets[0], 0.0), + parameter.labels[0].clone(), + ); + dimension( + base, + Vector3::new(base.x, end.y, base.z), + Vector3::new(parameter.offsets[1], 0.0, 0.0), + parameter.labels[1].clone(), + ); + } else { + let d = end - base; + let length = d.x.hypot(d.y).hypot(d.z); + if length <= 1e-10 || !length.is_finite() { + return result; + } + dimension( + base, + end, + dimension_normal(base, end) * parameter.offsets[0], + parameter.labels[0].clone(), + ); + if parameter.kind == Kind::Polar { + let angle = d.y.atan2(d.x); + let radius = length * 0.25; + let at = |angle: f64| base + Vector3::new(angle.cos(), angle.sin(), 0.0) * radius; + result.lines.push([base, at(0.0)]); + result.lines.push([base, end]); + for step in 0..24 { + result.lines.push([ + at(angle * step as f64 / 24.0), + at(angle * (step + 1) as f64 / 24.0), + ]); + } + result + .labels + .push((at(angle * 0.5), parameter.labels[1].clone())); + } + } + result.grips.push((end, parameter.kind == Kind::Linear)); + result +} + +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(|p| { + ( + p.handle, + p.native.property_states, + p.kind, + graphic(p) + .labels + .iter() + .map(|(point, _)| *point) + .collect::>(), + ) + }) + .collect::>(); + let mut changed = 0; + for (handle, grip_ids, kind, labels) in owned { + if let Some(ObjectType::DynamicBlock(object)) = document.objects.get_mut(&handle) { + let (native, x, y) = match &mut object.data { + DynamicBlockData::LinearParameter(p) => (&mut p.parameter, &mut p.distance, None), + DynamicBlockData::PolarParameter(p) => (&mut p.parameter, &mut p.offset, None), + DynamicBlockData::XYParameter(p) => { + (&mut p.parameter, &mut p.x_value, Some(&mut p.y_value)) + } + _ => continue, + }; + native.definition_base_point = transform.apply(native.definition_base_point); + native.definition_end_point = transform.apply(native.definition_end_point); + for point in [ + &mut native.updated_base_point, + &mut native.base_point, + &mut native.updated_end_point, + &mut native.end_point, + ] + .into_iter() + .flatten() + { + *point = transform.apply(*point); + } + if let Some(label) = labels.first() { + *x = offsets( + kind, + native.definition_base_point, + native.definition_end_point, + transform.apply(*label), + )[0]; + } + if let (Some(y), Some(label)) = (y, labels.get(1)) { + *y = transform.apply(*label).x - native.definition_base_point.x; + } + changed += 1; + } + for object in document.objects.values_mut() { + if let ObjectType::DynamicBlock(object) = object { + if object.owner != graph { + continue; + } + let grip = match &mut object.data { + DynamicBlockData::LinearGrip(p) + if grip_ids.contains(&p.grip.element.eval.node_id) => + { + p.orientation = transform.apply_rotation(p.orientation); + &mut p.grip + } + DynamicBlockData::PolarGrip(p) | DynamicBlockData::XYGrip(p) + if grip_ids.contains(&p.element.eval.node_id) + && p.element.eval.node_id > 0 => + { + p + } + _ => continue, + }; + grip.location = transform.apply(grip.location); + changed += 1; + } + } + } + changed +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::scene::{block_point_parameter, block_visibility, Scene}; + use codec::{entities::Line, EntityType}; + + fn fixture() -> (Scene, Handle) { + let mut scene = Scene::new(); + scene.define_block_raw( + "PARAMETERS", + Vector3::ZERO, + vec![EntityType::Line(Line::from_points( + Vector3::ZERO, + Vector3::UNIT_X, + ))], + ); + let definition = scene + .document + .block_records + .get("PARAMETERS") + .unwrap() + .handle; + (scene, definition) + } + + fn append_batch(document: &mut CadDocument, definition: Handle) -> Vec { + [Kind::Linear, Kind::Polar, Kind::XY] + .map(|kind| { + create( + document, + definition, + kind, + Vector3::new(2.0, 3.0, 0.0), + Vector3::new(6.0, 7.0, 0.0), + Vector3::new(1.0, 8.0, 0.0), + ) + .unwrap() + }) + .to_vec() + } + + fn assert_native(document: &CadDocument, handles: &[Handle]) { + for handle in handles { + let ObjectType::DynamicBlock(object) = &document.objects[handle] else { + panic!() + }; + let native = match &object.data { + DynamicBlockData::LinearParameter(p) => { + assert!(!p.distance_name.is_empty()); + assert!((p.distance - 6.0 / 2.0_f64.sqrt()).abs() < 1e-12); + &p.parameter + } + DynamicBlockData::PolarParameter(p) => { + assert!(!p.distance_name.is_empty()); + assert!(!p.angle_name.is_empty()); + assert!((p.offset - 6.0 / 2.0_f64.sqrt()).abs() < 1e-12); + &p.parameter + } + DynamicBlockData::XYParameter(p) => { + assert_ne!(p.x_label, p.y_label); + assert_eq!([p.x_value, p.y_value], [5.0, -1.0]); + &p.parameter + } + _ => panic!("native parameter"), + }; + assert_eq!(native.definition_base_point, Vector3::new(2.0, 3.0, 0.0)); + assert_eq!(native.definition_end_point, Vector3::new(6.0, 7.0, 0.0)); + let end = native.property_states[1]; + assert!(end > 0); + assert_eq!(native.property_states, [0, end, 0, 0]); + assert!(native.properties[..2] + .iter() + .all(|p| p.connections.is_empty())); + for (index, axis) in [(2, "DisplacementX"), (3, "DisplacementY")] { + assert_eq!( + native.properties[index].connections, + vec![BlockConnection { + code: end, + name: axis.into() + }] + ); + } + let grip = document + .objects + .values() + .find_map(|o| match o { + ObjectType::DynamicBlock(o) if o.owner == object.owner => match &o.data { + DynamicBlockData::LinearGrip(g) => Some(&g.grip), + DynamicBlockData::PolarGrip(g) | DynamicBlockData::XYGrip(g) => Some(g), + _ => None, + } + .filter(|g| g.element.eval.node_id == end), + _ => None, + }) + .expect("native end grip"); + assert_eq!(grip.location, native.definition_end_point); + for (id, axis) in [ + (grip.flags_91, "UpdatedEndX"), + (grip.flags_92, "UpdatedEndY"), + ] { + assert!(document.objects.values().any(|o| matches!(o, ObjectType::DynamicBlock(o) if o.owner == object.owner && matches!(&o.data, DynamicBlockData::GripLocationComponent(c) if c.eval.node_id == id && c.grip_type == native.parameter.element.eval.node_id && c.expression == axis)))); + } + } + } + + #[test] + fn distance_parameter_batch_native_graph_preserves_point_visibility_and_roundtrips() { + let (mut scene, definition) = fixture(); + block_point_parameter::create( + &mut scene.document, + definition, + Vector3::ZERO, + Vector3::UNIT_X, + ) + .unwrap(); + block_visibility::create(&mut scene.document, definition, Vector3::UNIT_Y).unwrap(); + let graph = graph_handle(&scene.document, definition).unwrap(); + let before = scene.document.clone(); + let handles = append_batch(&mut scene.document, definition); + for (handle, object) in &before.objects { + if *handle != graph { + assert_eq!(scene.document.objects.get(handle), Some(object)); + } + } + assert_native(&scene.document, &handles); + for extension in ["dwg", "dxf"] { + let bytes = + crate::io::save_to_bytes(&scene.document, extension, scene.document.version) + .unwrap(); + let document = + crate::io::load_bytes(&format!("parameters.{extension}"), bytes).unwrap(); + assert_native(&document, &handles); + assert!(authoring::graph(&document, definition).is_ok()); + assert_eq!(parameters(&document, definition).len(), 3); + assert_eq!( + block_point_parameter::parameters(&document, definition).len(), + 1 + ); + assert!(block_visibility::parameter(&document, definition).is_some()); + } + append_batch(&mut scene.document, definition); + let labels = parameters(&scene.document, definition) + .into_iter() + .flat_map(|p| p.labels) + .collect::>(); + let unique = labels.iter().collect::>(); + assert_eq!( + labels.len(), + unique.len(), + "labels are unique across parameter types" + ); + } + + #[test] + fn distance_parameter_rejects_degenerate_nonfinite_and_full_graph_without_mutation() { + let (mut scene, definition) = fixture(); + for kind in [Kind::Linear, Kind::Polar, Kind::XY] { + for end in [ + Vector3::ZERO, + Vector3::new(f64::NAN, 1.0, 0.0), + Vector3::new(1.0, 1.0, 1.0), + ] { + let before = scene.document.clone(); + assert!(create( + &mut scene.document, + definition, + kind, + Vector3::ZERO, + end, + Vector3::ZERO + ) + .is_err()); + assert_eq!(scene.document, before); + } + } + assert!(create( + &mut scene.document, + definition, + Kind::XY, + Vector3::ZERO, + Vector3::UNIT_X, + Vector3::ZERO + ) + .is_err()); + append_batch(&mut scene.document, definition); + let graph = graph_handle(&scene.document, definition).unwrap(); + if let ObjectType::DynamicBlock(o) = scene.document.objects.get_mut(&graph).unwrap() { + if let DynamicBlockData::EvaluationGraph(g) = &mut o.data { + g.first_node_id = i32::MAX; + } + } + let before = scene.document.clone(); + assert!(create( + &mut scene.document, + definition, + Kind::Linear, + Vector3::ZERO, + Vector3::UNIT_X, + Vector3::ZERO + ) + .is_err()); + assert_eq!(scene.document, before); + } + + #[test] + fn distance_parameter_upright_reframe_keeps_finite_label_and_grip() { + let (mut scene, definition) = fixture(); + create( + &mut scene.document, + definition, + Kind::Linear, + Vector3::ZERO, + Vector3::UNIT_Y, + Vector3::new(1.0, 0.5, 0.0), + ) + .unwrap(); + let before = graphic(¶meters(&scene.document, definition)[0]); + let transform = Transform::from_rotation(Vector3::UNIT_X, std::f64::consts::FRAC_PI_2); + reframe(&mut scene.document, definition, &transform); + let parameter = ¶meters(&scene.document, definition)[0]; + assert!(parameter.offsets[0].is_finite()); + let after = graphic(parameter); + let expected = transform.apply(before.labels[0].0); + assert!((after.labels[0].0 - expected).length() < 1e-10); + assert!((after.grips[0].0 - transform.apply(before.grips[0].0)).length() < 1e-10); + } + + #[test] + fn distance_parameter_reframe_and_editor_only_extents() { + let (mut scene, definition) = fixture(); + append_batch(&mut scene.document, definition); + let before = parameters(&scene.document, definition) + .iter() + .map(graphic) + .collect::>(); + let transform = Transform::from_translation(Vector3::new(10.0, 20.0, 0.0)); + assert_eq!(reframe(&mut scene.document, definition, &transform), 6); + for (parameter, original) in parameters(&scene.document, definition).iter().zip(before) { + let new = graphic(parameter); + for ((point, _), (old, _)) in new.labels.iter().zip(original.labels) { + assert_eq!(*point, transform.apply(old)); + } + assert_eq!(new.grips[0].0, Vector3::new(16.0, 27.0, 0.0)); + } + scene.block_edit_block = Some(definition); + let (_, max) = scene.visible_block_bounds(definition).unwrap(); + assert!(max.x >= 16.0 && max.y >= 28.0); + scene.block_edit_block = None; + let (_, max) = scene.visible_block_bounds(definition).unwrap(); + assert!(max.x <= 1.0 && max.y < 1.0); + } + + #[test] + #[ignore = "requires OCS_DYNAMIC_BLOCK_FIXTURES with local DWG fixtures"] + fn distance_parameter_fixture_batch_roundtrips() { + use sha2::{Digest, Sha256}; + let directory = std::env::var_os("OCS_DYNAMIC_BLOCK_FIXTURES").expect("fixture directory"); + let mut paths = std::fs::read_dir(directory) + .unwrap() + .map(|p| p.unwrap().path()) + .filter(|p| p.extension().is_some_and(|e| e.eq_ignore_ascii_case("dwg"))) + .collect::>(); + paths.sort(); + assert_eq!(paths.len(), 17); + for path in paths { + let bytes = std::fs::read(&path).unwrap(); + let checksum = Sha256::digest(&bytes); + let filename = path.file_name().unwrap().to_string_lossy(); + let mut document = crate::io::load_bytes(&filename, bytes).unwrap(); + let definition = document + .block_records + .iter() + .map(|b| b.handle) + .find(|h| authoring::graph(&document, *h).is_ok_and(|g| g.is_some())) + .expect("editable existing graph"); + let graph = graph_handle(&document, definition).unwrap(); + let before = document.clone(); + let handles = append_batch(&mut document, definition); + for (h, o) in &before.objects { + if *h != graph { + assert_eq!( + document.objects.get(h), + Some(o), + "{filename}: preserved {h}" + ); + } + } + if let (ObjectType::DynamicBlock(old), ObjectType::DynamicBlock(new)) = + (&before.objects[&graph], &document.objects[&graph]) + { + if let ( + DynamicBlockData::EvaluationGraph(old), + DynamicBlockData::EvaluationGraph(new), + ) = (&old.data, &new.data) + { + assert_eq!(&new.nodes[..old.nodes.len()], &old.nodes); + assert_eq!(&new.edges[..old.edges.len()], &old.edges); + } + } + for extension in ["dwg", "dxf"] { + let bytes = + crate::io::save_to_bytes(&document, extension, document.version).unwrap(); + let read = crate::io::load_bytes(&format!("batch.{extension}"), bytes).unwrap(); + assert_native(&read, &handles); + assert!(authoring::graph(&read, definition).is_ok()); + } + assert_eq!(Sha256::digest(std::fs::read(&path).unwrap()), checksum); + eprintln!("{filename}: Linear/Polar/XY appended to existing graph; DWG/DXF passed; original objects/checksum preserved"); + } + } +} diff --git a/src/scene/camera_ops.rs b/src/scene/camera_ops.rs index 0cf06db9b..2f1a96d1b 100644 --- a/src/scene/camera_ops.rs +++ b/src/scene/camera_ops.rs @@ -1677,6 +1677,16 @@ impl Scene { fold!(Some((point, point))); } } + for parameter in super::block_distance_parameter::parameters(&self.document, block) { + let graphic = super::block_distance_parameter::graphic(¶meter); + for point in graphic.lines.into_iter().flatten().chain([ + parameter.native.definition_base_point, + parameter.native.definition_end_point, + ]) { + 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 a34e7713c..bf90d1ed4 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -12,6 +12,7 @@ pub mod block_service; pub mod block_visibility; pub(crate) mod block_authoring; pub(crate) mod block_point_parameter; +pub(crate) mod block_distance_parameter; pub mod cache; pub mod compare; pub mod convert; diff --git a/src/scene/modify.rs b/src/scene/modify.rs index 2e30b3dcc..acc715910 100644 --- a/src/scene/modify.rs +++ b/src/scene/modify.rs @@ -559,6 +559,12 @@ impl Scene { block_record, local_from_old, ); + let parameter_changes = parameter_changes + + super::block_distance_parameter::reframe( + &mut self.document, + block_record, + local_from_old, + ); if !changed.is_empty() || parameter_changes > 0 { self.rebuild_derived_caches(); } diff --git a/src/ui/overlay.rs b/src/ui/overlay.rs index 87966498b..925e7fb2d 100644 --- a/src/ui/overlay.rs +++ b/src/ui/overlay.rs @@ -946,7 +946,7 @@ impl canvas::Program for GridCanvas { } } -pub fn selection_overlay<'a>( +pub(crate) fn selection_overlay<'a>( selection: Arc>, snap: Option<(Point, SnapType)>, snap_ext_base: Option, @@ -979,6 +979,7 @@ pub fn selection_overlay<'a>( constraint_cursor_badge: Option, authoring_visibility: Option<(Point, String, iced::Rectangle)>, authoring_points: Vec<(Point, Point, String, iced::Rectangle)>, + authoring_dimensions: Vec, ) -> Element<'a, Message> { canvas(SelectionCanvas { selection, @@ -1009,12 +1010,21 @@ pub fn selection_overlay<'a>( constraint_cursor_badge, authoring_visibility, authoring_points, + authoring_dimensions, }) .width(Length::Fill) .height(Length::Fill) .into() } +pub(crate) struct AuthoringDimension { + pub lines: Vec<[Point; 2]>, + pub dimensions: Vec<[Point; 2]>, + pub labels: Vec<(Point, String)>, + pub grips: Vec<(Point, Option)>, + pub clip: iced::Rectangle, +} + struct SelectionCanvas { selection: Arc>, snap: Option<(Point, SnapType)>, @@ -1087,6 +1097,7 @@ struct SelectionCanvas { constraint_cursor_badge: Option, authoring_visibility: Option<(Point, String, iced::Rectangle)>, authoring_points: Vec<(Point, Point, String, iced::Rectangle)>, + authoring_dimensions: Vec, } /// The move gizmo: an arrow per axis (X red, Y green, Z blue) from the @@ -2351,6 +2362,82 @@ impl canvas::Program for SelectionCanvas { }); }); } + for dimension in &self.authoring_dimensions { + frame.with_clip(dimension.clip, |frame| { + let color = theme.palette().primary.base.color; + for [a, b] in &dimension.lines { + frame.stroke( + &canvas::Path::line(*a, *b), + canvas::Stroke::default().with_color(color), + ); + } + for [a, b] in &dimension.dimensions { + let dx = b.x - a.x; + let dy = b.y - a.y; + let length = dx.hypot(dy).max(1.0); + let (x, y) = (dx / length, dy / length); + for (point, sign) in [(a, 1.0), (b, -1.0)] { + let tip = Point::new(point.x + sign * 7.0 * x, point.y + sign * 7.0 * y); + let arrow = canvas::Path::new(|path| { + path.move_to(*point); + path.line_to(Point::new(tip.x - 2.5 * y, tip.y + 2.5 * x)); + path.line_to(Point::new(tip.x + 2.5 * y, tip.y - 2.5 * x)); + path.close(); + }); + frame.fill(&arrow, color); + } + } + for (point, label) in &dimension.labels { + // Keep diagonal dimension lines from crossing the label. + let background = Color::from_rgba( + self.crosshair_bg[0], + self.crosshair_bg[1], + self.crosshair_bg[2], + self.crosshair_bg[3], + ); + frame.fill( + &canvas::Path::rectangle( + Point::new(point.x + 2.0, point.y - 19.0), + iced::Size::new(label.chars().count() as f32 * 8.0 + 6.0, 19.0), + ), + background, + ); + frame.fill_text(canvas::Text { + content: label.clone(), + position: Point::new(point.x + 5.0, point.y - 17.0), + color, + size: iced::Pixels(13.0), + ..Default::default() + }); + } + for (point, base) in &dimension.grips { + let marker = if let Some(base) = base { + let dx = point.x - base.x; + let dy = point.y - base.y; + let length = dx.hypot(dy).max(1.0); + let (x, y) = (dx / length, dy / length); + canvas::Path::new(|path| { + path.move_to(Point::new(point.x + 5.0 * x, point.y + 5.0 * y)); + path.line_to(Point::new( + point.x - 4.0 * x - 4.0 * y, + point.y - 4.0 * y + 4.0 * x, + )); + path.line_to(Point::new( + point.x - 4.0 * x + 4.0 * y, + point.y - 4.0 * y - 4.0 * x, + )); + path.close(); + }) + } else { + canvas::Path::rectangle( + Point::new(point.x - 3.0, point.y - 3.0), + iced::Size::new(6.0, 6.0), + ) + }; + frame.fill(&marker, color); + } + }); + } // Draw + hover + tooltip all read the memoised entries' precomputed // `top_left`/`size` (one shared `hovered` lookup below) — no offset // recompute, no label clones on this path. diff --git a/src/ui/window/authoring_palettes.rs b/src/ui/window/authoring_palettes.rs index 00d28ff2a..f70d19f5c 100644 --- a/src/ui/window/authoring_palettes.rs +++ b/src/ui/window/authoring_palettes.rs @@ -208,8 +208,8 @@ fn catalog( column(entries.iter().map(|(label, icon)| { let visibility = tab == Tab::Parameters && *label == "Visibility"; let point = tab == Tab::Parameters && *label == "Point"; - let enabled = - (visibility && visibility_error.is_none()) || (point && point_error.is_none()); + let distance = tab == Tab::Parameters && matches!(*label, "Linear" | "Polar" | "XY"); + let enabled = (visibility && visibility_error.is_none()) || ((point || distance) && point_error.is_none()); let base = if enabled { icons::semantic(icon, 28.0) } else { @@ -232,20 +232,8 @@ fn catalog( base }; let message = enabled.then(|| Message::RibbonToolClick { - tool_id: if point { - "BPARAMETER_POINT" - } else { - "BPARAMETER_VISIBILITY" - } - .into(), - event: crate::modules::ModuleEvent::Command( - if point { - "BPARAMETER POINT" - } else { - "BPARAMETER VISIBILITY" - } - .into(), - ), + tool_id: format!("BPARAMETER_{}", label.to_ascii_uppercase()), + event: crate::modules::ModuleEvent::Command(format!("BPARAMETER {}", label.to_ascii_uppercase())), }); let description = if visibility { crate::t!(visibility_error @@ -254,6 +242,8 @@ fn catalog( } else if point { crate::t!(point_error.unwrap_or("Place a point parameter and its position label.")) .into_owned() + } else if distance { + crate::t!(point_error.unwrap_or("Place the base point, endpoint, and dimension label. Creates one end grip; associate an action to change geometry.")).into_owned() } else { unavailable.to_string() }; @@ -262,6 +252,8 @@ fn catalog( container(entry).id("authoring-tool-visibility").into() } else if point { container(entry).id("authoring-tool-point").into() + } else if distance { + container(entry).id(match *label { "Linear" => "authoring-tool-linear", "Polar" => "authoring-tool-polar", _ => "authoring-tool-xy" }).into() } else { entry }