diff --git a/src/app/block_editor_tests.rs b/src/app/block_editor_tests.rs index 54af16bc5..839e9613e 100644 --- a/src/app/block_editor_tests.rs +++ b/src/app/block_editor_tests.rs @@ -86,6 +86,155 @@ fn distance_parameter_count(app: &OpenCADStudio) -> usize { crate::scene::block_distance_parameter::parameters(&tab.scene.document, definition).len() } +fn orientation_parameter_count(app: &OpenCADStudio) -> usize { + let tab = &app.tabs[app.active_tab]; + let definition = tab + .scene + .document + .block_records + .get("EDIT_ME") + .unwrap() + .handle; + crate::scene::block_orientation_parameter::parameters(&tab.scene.document, definition).len() +} + +#[test] +fn orientation_parameter_batch_numeric_rotation_local_history_save_and_parent_undo() { + let (mut app, reference) = fixture(); + begin(&mut app, reference); + let i = app.active_tab; + for (index, (kind, inputs)) in [ + ("ROTATION", vec!["0,0", "2", "90"]), + ("ALIGNMENT", vec!["0,0", "0,2"]), + ("FLIP", vec!["0,0", "0,2", "3,3"]), + ] + .into_iter() + .enumerate() + { + let before = app.tabs[i].scene.document.clone(); + let _ = app.dispatch_command(&format!("BPARAMETER {kind}")); + for text in &inputs[..inputs.len() - 1] { + feed(&mut app, text); + assert_eq!(app.tabs[i].scene.document, before, "incomplete placement"); + } + feed(&mut app, inputs.last().unwrap()); + assert_eq!(orientation_parameter_count(&app), index + 1, "{kind}"); + 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!(orientation_parameter_count(&app), index); + app.redo_steps(1); + assert_eq!(orientation_parameter_count(&app), index + 1); + } + let definition = app.tabs[i].active_block_edit_session().unwrap().br_handle; + let p = &crate::scene::block_orientation_parameter::parameters( + &app.tabs[i].scene.document, + definition, + )[0]; + assert!((p.native.definition_end_point - Vector3::new(0.0, 2.0, 0.0)).length() < 1e-10); + assert_eq!(p.reference, Vector3::new(2.0, 0.0, 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 + ); + let _ = app.dispatch_command("BCLOSETESTBLOCK"); + assert_eq!(app.tabs[i].scene.document, editor); + let _ = app.run_command_line("UCS ORIGIN 1,2,0"); + for p in crate::scene::block_orientation_parameter::parameters( + &app.tabs[i].scene.document, + definition, + ) { + assert_eq!( + p.native.definition_base_point, + Vector3::new(-1.0, -2.0, 0.0) + ); + } + let _ = app.dispatch_command("BSAVE"); + let _ = app.dispatch_command("BEDIT_DISCARD"); + assert_eq!(orientation_parameter_count(&app), 3); + app.undo_steps(1); + assert_eq!(orientation_parameter_count(&app), 0); + app.redo_steps(1); + assert_eq!(orientation_parameter_count(&app), 3); +} + +#[test] +fn orientation_parameter_chooser_cancels_invalid_picks_and_rotation_default_angle() { + let (mut app, reference) = fixture(); + begin(&mut app, reference); + let i = app.active_tab; + let before = app.tabs[i].scene.document.clone(); + for (kind, maximum) in [("R", 2), ("A", 1), ("F", 2)] { + for picks in 0..=maximum { + let _ = app.dispatch_command("BPARAMETER"); + feed(&mut app, kind); + if picks > 0 { + feed(&mut app, "0,0"); + feed(&mut app, "0,0"); + assert_eq!(app.tabs[i].scene.document, before); + } + if picks > 1 { + feed(&mut app, "0,2"); + } + 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("BPARAMETER ROTATION"); + feed(&mut app, "0,0"); + feed(&mut app, "-2"); + assert_eq!(app.tabs[i].scene.document, before); + feed(&mut app, "2"); + feed(&mut app, ""); + assert_eq!(orientation_parameter_count(&app), 1); + let _ = app.dispatch_command("BEDIT_DISCARD"); + assert_eq!(orientation_parameter_count(&app), 0); + let _ = app.dispatch_command("LINE"); + for kind in ["ROTATION", "ALIGNMENT", "FLIP"] { + let _ = app.dispatch_command(&format!("BPARAMETER {kind}")); + assert_eq!(app.tabs[i].active_cmd.as_ref().unwrap().name(), "LINE"); + } +} + +#[test] +fn orientation_parameter_stale_context_and_invalid_result_do_not_start_history() { + use crate::scene::block_orientation_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::Y), + (id, glam::DVec3::ZERO), + (id, glam::DVec3::splat(f64::NAN)), + ] { + let _ = app.handle_place_block_orientation_parameter( + crate::command::CmdResult::PlaceBlockOrientationParameter { + document_id, + definition, + kind: Kind::Flip, + base: glam::DVec3::ZERO, + end, + reference: glam::DVec3::X, + }, + ); + assert_eq!(app.tabs[i].scene.document, before); + assert!(app.tabs[i].history.undo_stack.is_empty()); + } +} + #[test] fn distance_parameter_batch_three_picks_local_undo_redo_and_saved_parent_undo() { let (mut app, reference) = fixture(); diff --git a/src/app/command_driver/mod.rs b/src/app/command_driver/mod.rs index aeb460473..c5be52a97 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::PlaceBlockOrientationParameter { .. } => { + return self.handle_place_block_orientation_parameter(result); + } result @ CmdResult::PlaceBlockDistanceParameter { .. } => { return self.handle_place_block_distance_parameter(result); } diff --git a/src/app/commands/bvisibility.rs b/src/app/commands/bvisibility.rs index 6a0b0569d..c200d9a5e 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_orientation_parameter( + &mut self, + result: crate::command::CmdResult, + ) -> Task { + let crate::command::CmdResult::PlaceBlockOrientationParameter { + document_id, + definition, + kind, + base, + end, + reference, + } = 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_orientation_parameter::points_error( + kind, + vector(base), + vector(end), + vector(reference), + ) + }) { + 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_orientation_parameter::create( + &mut self.tabs[i].scene.document, + definition, + kind, + vector(base), + vector(end), + vector(reference), + ) + .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 handle_place_block_distance_parameter( &mut self, result: crate::command::CmdResult, @@ -87,10 +147,11 @@ impl OpenCADStudio { session.br_handle, ) } + "ROTATION" | "R" | "ALIGNMENT" | "A" | "FLIP" | "F" => crate::scene::block_orientation_parameter::placement_error(&tab.scene.document, session.br_handle, crate::scene::block_orientation_parameter::Kind::parse(kind).unwrap()), "VISIBILITY" | "V" => { visibility::placement_error(&tab.scene.document, session.br_handle) } - _ => Some("Supported parameter types: Point, Linear, Polar, XY, Visibility."), + _ => Some("Supported parameter types: Point, Linear, Polar, XY, Rotation, Alignment, Flip, Visibility."), } } @@ -333,6 +394,14 @@ impl OpenCADStudio { return Some(Task::none()); }; let parameter_type = cmd.strip_prefix("BPARAMETER").unwrap().trim(); + if let Some(kind) = + crate::scene::block_orientation_parameter::Kind::parse(parameter_type) + { + let command = crate::modules::block_editor::orientation_parameter::OrientationParameterCommand::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_distance_parameter::Kind::parse(parameter_type) { @@ -359,7 +428,7 @@ impl OpenCADStudio { { self.command_line.push_error( crate::t!( - "Supported parameter types: Point, Linear, Polar, XY, Visibility." + "Supported parameter types: Point, Linear, Polar, XY, Rotation, Alignment, Flip, Visibility." ) .as_ref(), ); diff --git a/src/app/dock/tests.rs b/src/app/dock/tests.rs index f07c51fb0..eeda37b0e 100644 --- a/src/app/dock/tests.rs +++ b/src/app/dock/tests.rs @@ -863,6 +863,54 @@ fn distance_parameter_batch_starts_from_actual_palette_rows() { }); } +#[test] +fn orientation_parameter_batch_starts_from_actual_palette_rows() { + with_stack(|| { + for (id, name, inputs) in [ + ( + "authoring-tool-rotation", + "rotation", + vec!["0.35,-0.12", "0.65,-0.12", "0.35,0.2"], + ), + ( + "authoring-tool-alignment", + "alignment", + vec!["0.25,-0.1", "0.6,0.15"], + ), + ( + "authoring-tool-flip", + "flip", + vec!["0.35,-0.15", "0.35,0.15", "0.6,0.15"], + ), + ] { + 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 &inputs[..inputs.len() - 1] { + 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(inputs.last().unwrap().to_string())); + let _ = app.update(Message::CommandSubmit); + let definition = app.tabs[i].active_block_edit_session().unwrap().br_handle; + let found = crate::scene::block_orientation_parameter::parameters( + &app.tabs[i].scene.document, + definition, + ); + assert_eq!(found.len(), 1); + assert!(found[0].kind.name().eq_ignore_ascii_case(name)); + assert!(app.tabs[i].scene.preview_wires.is_empty()); + assert_eq!(app.tabs[i].history.undo_stack.len(), 1); + 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/dock/view.rs b/src/app/dock/view.rs index 49f271e1c..a1735fd93 100644 --- a/src/app/dock/view.rs +++ b/src/app/dock/view.rs @@ -408,6 +408,7 @@ impl OpenCADStudio { chrome, self.visibility_placement_error(), self.point_parameter_placement_error(), + self.parameter_placement_error("FLIP"), ) } crate::ui::dock::PanelId::BlockPalette => { diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index c262c4089..ef483273f 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -1083,7 +1083,26 @@ bg={bg_ms:.1}ms n={view_count}" session.br_handle, ) .iter() - .filter_map(|parameter| { + .map(|p| { + ( + crate::scene::block_distance_parameter::graphic(p), + p.native.definition_base_point, + ) + }) + .chain( + crate::scene::block_orientation_parameter::parameters( + &tab.scene.document, + session.br_handle, + ) + .iter() + .map(|p| { + ( + crate::scene::block_orientation_parameter::graphic(p), + crate::scene::block_orientation_parameter::marker_tail(p), + ) + }), + ) + .filter_map(|(graphic, marker_tail)| { let project = |p: codec::types::Vector3| { let p = tab .scene @@ -1093,7 +1112,6 @@ bg={bg_ms:.1}ms n={view_count}" (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() @@ -1111,7 +1129,7 @@ bg={bg_ms:.1}ms n={view_count}" Some(( project(point)?, if oriented { - Some(project(parameter.native.definition_base_point)?) + Some(project(marker_tail)?) } else { None }, @@ -1120,6 +1138,11 @@ bg={bg_ms:.1}ms n={view_count}" .collect::>>()?; Some(crate::ui::overlay::AuthoringDimension { lines, + round_grips: graphic + .round_grips + .into_iter() + .map(project) + .collect::>>()?, dimensions: graphic .dimensions .into_iter() diff --git a/src/command.rs b/src/command.rs index 7e4d09caf..602870f9b 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 Rotation, Alignment or Flip parameter. + PlaceBlockOrientationParameter { + document_id: u64, + definition: Handle, + kind: crate::scene::block_orientation_parameter::Kind, + base: DVec3, + end: DVec3, + reference: DVec3, + }, /// Place a native two-point parameter after its base, end and label picks. PlaceBlockDistanceParameter { document_id: u64, diff --git a/src/modules/block_editor/mod.rs b/src/modules/block_editor/mod.rs index 460fffd20..007de5fe2 100644 --- a/src/modules/block_editor/mod.rs +++ b/src/modules/block_editor/mod.rs @@ -8,6 +8,7 @@ use crate::modules::{CadModule, IconKind, ModuleEvent, RibbonGroup, RibbonItem, pub mod visibility_parameter; pub mod point_parameter; pub mod distance_parameter; +pub mod orientation_parameter; pub struct BlockEditorModule; diff --git a/src/modules/block_editor/orientation_parameter.rs b/src/modules/block_editor/orientation_parameter.rs new file mode 100644 index 000000000..2ffbb8fbf --- /dev/null +++ b/src/modules/block_editor/orientation_parameter.rs @@ -0,0 +1,193 @@ +//! Interactive native Rotation, Alignment and Flip placement. +use crate::command::{CadCommand, CmdResult}; +use crate::scene::{ + block_orientation_parameter::{self, Kind}, + WireModel, +}; +use codec::{types::Vector3, Handle}; +use glam::DVec3; + +pub struct OrientationParameterCommand { + document_id: u64, + definition: Handle, + kind: Kind, + base: Option, + second: Option, +} + +impl OrientationParameterCommand { + pub(crate) fn new(document_id: u64, definition: Handle, kind: Kind) -> Self { + Self { + document_id, + definition, + kind, + base: None, + second: None, + } + } + fn finish(&self, end: DVec3, reference: DVec3) -> CmdResult { + let base = self.base.unwrap(); + let vector = |p: DVec3| Vector3::new(p.x, p.y, p.z); + if let Some(error) = block_orientation_parameter::points_error( + self.kind, + vector(base), + vector(end), + vector(reference), + ) { + return CmdResult::ReportError(crate::t!(error).into_owned()); + } + CmdResult::PlaceBlockOrientationParameter { + document_id: self.document_id, + definition: self.definition, + kind: self.kind, + base, + end, + reference, + } + } + fn rotation_at(&self, angle: f64) -> CmdResult { + let base = self.base.unwrap(); + let reference = self.second.unwrap(); + let radius = (reference - base).length(); + self.finish( + base + DVec3::new(angle.cos(), angle.sin(), 0.0) * radius, + reference, + ) + } +} + +impl CadCommand for OrientationParameterCommand { + fn name(&self) -> &'static str { + "BPARAMETER" + } + fn prompt(&self) -> String { + let step = if self.base.is_none() { + "Specify parameter base point:" + } else { + match (self.kind, self.second.is_some()) { + (Kind::Rotation, false) => "Specify parameter radius or pick a radius point:", + (Kind::Rotation, true) => "Specify default rotation angle or pick direction <0>:", + (Kind::Alignment, _) => "Specify alignment direction (tangent):", + (Kind::Flip, false) => "Specify reflection line endpoint:", + (Kind::Flip, true) => "Specify flip 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; + }; + if (point.z - base.z).abs() > 1e-8 { + return CmdResult::ReportError( + crate::t!("Specify points in the same block XY plane.").into_owned(), + ); + } + if self.kind == Kind::Flip && self.second.is_some() { + return self.finish(self.second.unwrap(), point); + } + let d = point - base; + let radius = d.x.hypot(d.y); + if radius <= 1e-10 || !radius.is_finite() || !d.length().is_finite() { + return CmdResult::ReportError( + crate::t!("Specify a finite nonzero parameter direction or radius.").into_owned(), + ); + } + match self.kind { + Kind::Alignment => self.finish(point, point), + Kind::Flip => { + self.second = Some(point); + CmdResult::NeedPoint + } + Kind::Rotation => { + if self.second.is_some() { + self.rotation_at(d.y.atan2(d.x)) + } else { + self.second = Some(base + DVec3::X * radius); + CmdResult::NeedPoint + } + } + } + } + fn on_text_input(&mut self, text: &str) -> Option { + if self.kind != Kind::Rotation || self.base.is_none() { + return None; + } + let value = text.trim().parse::().ok()?; + if !value.is_finite() { + return Some(CmdResult::ReportError( + crate::t!("Specify a finite radius or angle.").into_owned(), + )); + } + Some(if self.second.is_some() { + self.rotation_at(value.rem_euclid(360.0).to_radians()) + } else if value > 1e-10 { + self.on_point(self.base.unwrap() + DVec3::X * value) + } else { + CmdResult::ReportError(crate::t!("Specify a positive parameter radius.").into_owned()) + }) + } + fn on_preview_wires(&mut self, point: DVec3) -> Vec { + let Some(base) = self.base.filter(|_| point.is_finite()) else { + return vec![]; + }; + let mut lines = vec![]; + if self.kind == Kind::Rotation { + let radius = self + .second + .map(|p| (p - base).length()) + .unwrap_or((point - base).length()); + if !radius.is_finite() { + return vec![]; + } + let angle = (point.y - base.y).atan2(point.x - base.x); + let end = base + DVec3::new(angle.cos(), angle.sin(), 0.0) * radius; + lines.push(vec![base.to_array(), end.to_array()]); + lines.push( + (0..=64) + .map(|i| { + let a = std::f64::consts::TAU * i as f64 / 64.0; + (base + DVec3::new(a.cos(), a.sin(), 0.0) * radius).to_array() + }) + .collect(), + ); + } else { + lines.push(vec![ + base.to_array(), + self.second.unwrap_or(point).to_array(), + ]); + if let Some(end) = self.second { + lines.push(vec![end.to_array(), point.to_array()]); + } + } + lines + .into_iter() + .map(|points| { + WireModel::solid_f64( + "preview_orientation_parameter".into(), + points, + WireModel::CYAN, + false, + ) + }) + .collect() + } + fn on_enter(&mut self) -> CmdResult { + if self.kind == Kind::Rotation && self.second.is_some() { + self.rotation_at(0.0) + } else { + CmdResult::Cancel + } + } +} diff --git a/src/modules/block_editor/visibility_parameter.rs b/src/modules/block_editor/visibility_parameter.rs index 071d6ca5f..ef9cffa51 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/Visibility] :" + "BPARAMETER Enter parameter type [Point/Linear/Polar/XY/Rotation/Alignment/Flip/Visibility] :" ) .into_owned() } else { @@ -43,6 +43,9 @@ impl CadCommand for VisibilityParameterCommand { 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!("Rotation").as_ref(), "R"), + CmdOption::new(crate::t!("Alignment").as_ref(), "A"), + CmdOption::new(crate::t!("Flip").as_ref(), "F"), CmdOption::new(crate::t!("Visibility").as_ref(), "V"), ] } else { @@ -68,13 +71,16 @@ impl CadCommand for VisibilityParameterCommand { "L" | "LINEAR" => CmdResult::Dispatch("BPARAMETER LINEAR".into()), "PO" | "POLAR" => CmdResult::Dispatch("BPARAMETER POLAR".into()), "XY" => CmdResult::Dispatch("BPARAMETER XY".into()), + "R" | "ROTATION" => CmdResult::Dispatch("BPARAMETER ROTATION".into()), + "A" | "ALIGNMENT" => CmdResult::Dispatch("BPARAMETER ALIGNMENT".into()), + "F" | "FLIP" => CmdResult::Dispatch("BPARAMETER FLIP".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, Visibility.") + crate::t!("Supported parameter types: Point, Linear, Polar, XY, Rotation, Alignment, Flip, Visibility.") .into_owned(), ), }, diff --git a/src/scene/block_authoring.rs b/src/scene/block_authoring.rs index 175e44a49..bb1ce7a1d 100644 --- a/src/scene/block_authoring.rs +++ b/src/scene/block_authoring.rs @@ -183,6 +183,30 @@ pub(super) fn prepare( Ok((handle, slots(None)?)) } +/// Preflight the larger subgraph needed by parameters with state expressions. +pub(super) fn capacity( + document: &CadDocument, + definition: Handle, + nodes: i32, + edges: i32, +) -> Result<(), &'static str> { + let graph = graph(document, definition)?.and_then(|handle| match &document.objects[&handle] { + ObjectType::DynamicBlock(object) => match &object.data { + DynamicBlockData::EvaluationGraph(graph) => Some(graph), + _ => None, + }, + _ => None, + }); + let start = slots(graph)?; + if start.node.checked_add(nodes - 1).is_none() + || start.eval.checked_add(nodes - 1).is_none() + || start.edge.checked_add(edges - 1).is_none() + { + return Err("The block authoring graph has no free node IDs."); + } + Ok(()) +} + /// 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. @@ -191,6 +215,18 @@ pub(super) fn append( handle: Handle, slots: Slots, expressions: [Handle; 4], +) { + append_components(document, handle, slots, &expressions, &[2, 3]); +} + +/// 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( + document: &mut CadDocument, + handle: Handle, + slots: Slots, + expressions: &[Handle], + components: &[usize], ) { use codec::objects::{BlockEvaluationEdge, BlockEvaluationNode}; let ObjectType::DynamicBlock(object) = document.objects.get_mut(&handle).unwrap() else { @@ -201,13 +237,14 @@ pub(super) fn append( }; let n = slots.node; let e = slots.edge; - let links = [ - [e, e, e + 1, e + 2], - [-1, -1, e, e], - [e + 1, e + 1, -1, -1], - [e + 2, e + 2, -1, -1], - ]; - for (offset, expression) in expressions.into_iter().enumerate() { + let mut links = vec![[-1; 4]; expressions.len()]; + links[0] = [e, e, e + 1, e + components.len() as i32]; + links[1] = [-1, -1, e, e]; + for (index, component) in components.iter().enumerate() { + let edge = e + 1 + index as i32; + links[*component] = [edge, edge, -1, -1]; + } + for (offset, expression) in expressions.iter().copied().enumerate() { graph.nodes.push(BlockEvaluationNode { id: n + offset as i32, edge_flags: 32, @@ -217,11 +254,24 @@ pub(super) fn append( active_cycles: None, }); } - for (offset, source_node, destination_node, outgoing_edges) in [ - (0, n + 1, n, [-1; 5]), - (1, n, n + 2, [-1, -1, -1, e + 2, -1]), - (2, n, n + 3, [-1, -1, e + 1, -1, -1]), - ] { + let dependencies = std::iter::once((0, n + 1, n, [-1; 5])).chain( + components.iter().enumerate().map(|(index, component)| { + let offset = index as i32 + 1; + let previous = if index == 0 { -1 } else { e + offset - 1 }; + let next = if index + 1 == components.len() { + -1 + } else { + e + offset + 1 + }; + ( + offset, + n, + n + *component as i32, + [-1, -1, previous, next, -1], + ) + }), + ); + for (offset, source_node, destination_node, outgoing_edges) in dependencies { graph.edges.push(BlockEvaluationEdge { id: e + offset, next_id: 0, @@ -231,7 +281,7 @@ pub(super) fn append( outgoing_edges, }); } - graph.first_node_id = slots.eval + 3; + graph.first_node_id = slots.eval + (expressions.len() as i32 - 1); graph.first_node_id_copy = graph.first_node_id; } diff --git a/src/scene/block_distance_parameter.rs b/src/scene/block_distance_parameter.rs index 6b52e00f1..b59e62486 100644 --- a/src/scene/block_distance_parameter.rs +++ b/src/scene/block_distance_parameter.rs @@ -176,10 +176,15 @@ pub(crate) fn create( if let Some(error) = points_error(kind, base, end, label) { return Err(error); } - let names = parameters(document, definition) + let mut names = parameters(document, definition) .into_iter() .flat_map(|p| p.labels) .collect::>(); + names.extend( + super::block_orientation_parameter::parameters(document, definition) + .into_iter() + .map(|p| p.label), + ); let unique = |prefix: &str| { (1..) .map(|n| format!("{prefix}{n}")) @@ -329,6 +334,7 @@ pub(crate) fn create( #[derive(Default)] pub(crate) struct Graphic { pub lines: Vec<[Vector3; 2]>, + pub round_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_orientation_parameter.rs b/src/scene/block_orientation_parameter.rs new file mode 100644 index 000000000..f2dcb5da1 --- /dev/null +++ b/src/scene/block_orientation_parameter.rs @@ -0,0 +1,528 @@ +//! Native Rotation, Alignment and Flip parameter placement. +use super::block_authoring::{self as authoring, *}; +use super::block_distance_parameter::Graphic; +use codec::objects::{ + BlockAlignmentParameter, BlockConnection, BlockElement, BlockEvalValue, BlockFlipGrip, + BlockFlipParameter, BlockGrip, BlockGripExpression, BlockOrientedGrip, BlockParameter, + BlockParameterProperty, BlockParameterValueSet, BlockRotationParameter, BlockTwoPointParameter, + DynamicBlockData, ObjectType, +}; +use codec::types::{Transform, Vector3}; +use codec::{CadDocument, Handle}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Kind { + Rotation, + Alignment, + Flip, +} + +impl Kind { + pub(crate) fn parse(text: &str) -> Option { + match text.to_ascii_uppercase().as_str() { + "ROTATION" | "R" => Some(Self::Rotation), + "ALIGNMENT" | "A" => Some(Self::Alignment), + "FLIP" | "F" => Some(Self::Flip), + _ => None, + } + } + pub(crate) fn name(self) -> &'static str { + match self { + Self::Rotation => "Rotation", + Self::Alignment => "Alignment", + Self::Flip => "Flip", + } + } + fn classes(self) -> (&'static str, &'static str, &'static str, &'static str) { + match self { + Self::Rotation => ( + "BLOCKROTATIONPARAMETER", + "AcDbBlockRotationParameter", + "BLOCKROTATIONGRIP", + "AcDbBlockRotationGrip", + ), + Self::Alignment => ( + "BLOCKALIGNMENTPARAMETER", + "AcDbBlockAlignmentParameter", + "BLOCKALIGNMENTGRIP", + "AcDbBlockAlignmentGrip", + ), + Self::Flip => ( + "BLOCKFLIPPARAMETER", + "AcDbBlockFlipParameter", + "BLOCKFLIPGRIP", + "AcDbBlockFlipGrip", + ), + } + } +} + +pub(crate) struct Parameter<'a> { + pub handle: Handle, + pub kind: Kind, + pub native: &'a BlockTwoPointParameter, + pub reference: Vector3, + pub label: String, + pub offset: f64, +} + +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![]; + }; + let Some(ObjectType::DynamicBlock(object)) = document.objects.get(&graph) else { + return vec![]; + }; + let DynamicBlockData::EvaluationGraph(data) = &object.data else { + return vec![]; + }; + 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, reference, label, offset) = match &object.data { + DynamicBlockData::RotationParameter(p) => ( + Kind::Rotation, + &p.parameter, + p.definition_base_angle_point, + p.angle_name.clone(), + p.angle, + ), + DynamicBlockData::AlignmentParameter(p) => ( + Kind::Alignment, + &p.parameter, + p.parameter.definition_end_point, + p.parameter.parameter.element.name.clone(), + 0.0, + ), + DynamicBlockData::FlipParameter(p) => ( + Kind::Flip, + &p.parameter, + p.definition_label_point, + p.flip_label.clone(), + 0.0, + ), + _ => return None, + }; + Some(Parameter { + handle: object.handle, + kind, + native, + reference, + label, + offset, + }) + }) + .collect() +} + +pub(crate) fn placement_error( + document: &CadDocument, + definition: Handle, + kind: Kind, +) -> Option<&'static str> { + let nodes = if kind == Kind::Flip { 5 } else { 4 }; + authoring::capacity(document, definition, nodes, nodes - 1).err() +} + +pub(crate) fn points_error( + kind: Kind, + base: Vector3, + end: Vector3, + reference: Vector3, +) -> Option<&'static str> { + if ![base, end, reference] + .iter() + .all(|p| [p.x, p.y, p.z].iter().all(|v| v.is_finite())) + { + return Some("Specify finite parameter locations."); + } + let d = end - base; + let length = d.x.hypot(d.y); + if !length.is_finite() { + return Some("The parameter dimensions are too large."); + } + if length <= 1e-10 || (end.z - base.z).abs() > 1e-8 || (reference.z - base.z).abs() > 1e-8 { + return Some("Specify distinct parameter points in the same block XY plane."); + } + if kind == Kind::Rotation { + let r = reference - base; + if !r.length().is_finite() || !d.length().is_finite() { + return Some("The parameter dimensions are too large."); + } + if !r.x.hypot(r.y).is_finite() || (r.x.hypot(r.y) - length).abs() > length.max(1.0) * 1e-8 { + return Some("The rotation grip and base-angle point must share the specified radius."); + } + } + None +} + +pub(crate) fn create( + document: &mut CadDocument, + definition: Handle, + kind: Kind, + base: Vector3, + end: Vector3, + reference: Vector3, +) -> Result { + if let Some(error) = points_error(kind, base, end, reference) + .or_else(|| placement_error(document, definition, kind)) + { + return Err(error); + } + let mut names = super::block_distance_parameter::parameters(document, definition) + .into_iter() + .flat_map(|p| p.labels) + .collect::>(); + names.extend( + parameters(document, definition) + .into_iter() + .map(|p| p.label), + ); + let prefix = match kind { + Kind::Rotation => "Angle", + Kind::Alignment => "Alignment", + Kind::Flip => "Flip state", + }; + let label = (1..) + .map(|n| format!("{prefix}{n}")) + .find(|name| names.iter().all(|old| !old.eq_ignore_ascii_case(name))) + .unwrap(); + let (graph, slots) = authoring::prepare(document, definition)?; + let (class, cpp, grip_class, grip_cpp) = kind.classes(); + register_classes( + document, + &[ + (class, cpp, 55), + (grip_class, grip_cpp, 55), + ("BLOCKGRIPLOCATIONCOMPONENT", "AcDbBlockGripExpr", 20), + ], + ); + let count = if kind == Kind::Flip { 5 } else { 4 }; + let handles = (0..count) + .map(|_| document.allocate_handle()) + .collect::>(); + 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, + if kind == Kind::Alignment { + &label + } else { + 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 kind == Kind::Rotation && i >= 2 { + vec![BlockConnection { + code: slots.eval + 1, + name: if i == 2 { + "DisplacementX" + } else { + "DisplacementY" + } + .into(), + }] + } else { + vec![] + }, + }), + property_states: if kind == Kind::Rotation { + [0, slots.eval + 1, 0, 0] + } else { + [slots.eval + 1, 0, 0, 0] + }, + ..Default::default() + }; + let data = match kind { + Kind::Rotation => DynamicBlockData::RotationParameter(BlockRotationParameter { + parameter: native, + definition_base_angle_point: reference, + angle_name: label, + angle: (end - base).length() * 0.15, + value_set: BlockParameterValueSet { + flags: 3, + maximum: std::f64::consts::TAU, + ..Default::default() + }, + ..Default::default() + }), + Kind::Alignment => DynamicBlockData::AlignmentParameter(BlockAlignmentParameter { + parameter: native, + align_perpendicular: false, + }), + Kind::Flip => DynamicBlockData::FlipParameter(BlockFlipParameter { + parameter: native, + flip_label: label, + base_state_label: "Not flipped".into(), + flipped_state_label: "Flipped".into(), + definition_label_point: reference, + flags_96: slots.eval + 1, + tooltip: "UpdatedFlip".into(), + ..Default::default() + }), + }; + document.objects.insert( + handles[0], + ObjectType::DynamicBlock(dynamic(handles[0], graph, class, cpp, data)), + ); + let x_offset = if kind == Kind::Flip { 3 } else { 2 }; + let grip = BlockGrip { + element: element(1, "Grip"), + flags_91: slots.eval + x_offset, + flags_92: slots.eval + x_offset + 1, + location: if kind == Kind::Rotation { end } else { base }, + insert_cycling: kind == Kind::Alignment, + insert_cycling_weight: -1, + }; + let data = match kind { + Kind::Rotation => DynamicBlockData::RotationGrip(grip), + Kind::Alignment => DynamicBlockData::AlignmentGrip(BlockOrientedGrip { + grip, + orientation: end - base, + }), + Kind::Flip => DynamicBlockData::FlipGrip(BlockFlipGrip { + grip, + combined_state: slots.eval + 2, + orientation: end - base, + }), + }; + document.objects.insert( + handles[1], + ObjectType::DynamicBlock(dynamic(handles[1], graph, grip_class, grip_cpp, data)), + ); + for (offset, axis, code, value) in [ + ( + x_offset, + if kind == Kind::Rotation { + "UpdatedEndX" + } else { + "UpdatedBaseX" + }, + 40, + BlockEvalValue::Real(0.0), + ), + ( + x_offset + 1, + if kind == Kind::Rotation { + "UpdatedEndY" + } else { + "UpdatedBaseY" + }, + 40, + BlockEvalValue::Real(0.0), + ), + ] + .into_iter() + .chain((kind == Kind::Flip).then_some((2, "UpdatedFlip", 70, BlockEvalValue::Short(0)))) + { + let handle = handles[offset as usize]; + document.objects.insert( + handle, + ObjectType::DynamicBlock(dynamic( + handle, + graph, + "BLOCKGRIPLOCATIONCOMPONENT", + "AcDbBlockGripExpr", + DynamicBlockData::GripLocationComponent(BlockGripExpression { + eval: expression(slots.eval + offset, code, value), + grip_type: slots.eval, + expression: axis.into(), + }), + )), + ); + } + let components: &[usize] = if kind == Kind::Flip { + &[3, 4, 2] + } else { + &[2, 3] + }; + authoring::append_components(document, graph, slots, &handles, components); + Ok(handles[0]) +} + +pub(crate) fn graphic(parameter: &Parameter<'_>) -> Graphic { + let base = parameter.native.definition_base_point; + let end = parameter.native.definition_end_point; + let d = end - base; + let radius = d.x.hypot(d.y).hypot(d.z); + let mut result = Graphic::default(); + if !radius.is_finite() || radius <= 1e-10 { + return result; + } + match parameter.kind { + Kind::Rotation => { + let reference = parameter.reference - base; + let u = reference.normalize(); + let mut v = if u.z.abs() < 1e-10 && d.z.abs() < 1e-10 { + Vector3::new(-u.y, u.x, 0.0) + } else { + d - u * d.dot(&u) + }; + if v.length() <= 1e-10 { + v = Vector3::new(-u.y, u.x, 0.0); + } + if v.length() <= 1e-10 { + v = Vector3::UNIT_Y; + } + let v = v.normalize(); + let mut angle = d.dot(&v).atan2(d.dot(&u)).rem_euclid(std::f64::consts::TAU); + if angle.abs() <= 1e-10 { + angle = std::f64::consts::TAU; + } + let at = |angle: f64, r: f64| base + (u * angle.cos() + v * angle.sin()) * r; + result + .lines + .extend([[base, parameter.reference], [base, end]]); + for step in 0..48 { + result.lines.push([ + at(angle * step as f64 / 48.0, radius), + at(angle * (step + 1) as f64 / 48.0, radius), + ]); + } + result.labels.push(( + at(angle * 0.5, radius + parameter.offset), + parameter.label.clone(), + )); + result.round_grips.push(end); + } + Kind::Alignment => { + result.lines.push([base, end]); + result.labels.push((end, parameter.label.clone())); + result.grips.push((base, true)); + } + Kind::Flip => { + for step in (0..24).step_by(2) { + result.lines.push([ + base + d * (step as f64 / 24.0), + base + d * ((step + 1) as f64 / 24.0), + ]); + } + result.lines.push([end, parameter.reference]); + result + .labels + .push((parameter.reference, parameter.label.clone())); + result.grips.push((base, true)); + } + } + result +} + +/// Tail position used to orient a base grip in the shared screen overlay. +pub(crate) fn marker_tail(parameter: &Parameter<'_>) -> Vector3 { + let base = parameter.native.definition_base_point; + let d = parameter.native.definition_end_point - base; + if parameter.kind == Kind::Flip { + base - Vector3::new(-d.y, d.x, 0.0) + } else { + base - d + } +} + +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)) + .collect::>(); + let mut changed = 0; + for (handle, ids) in owned { + if let Some(ObjectType::DynamicBlock(object)) = document.objects.get_mut(&handle) { + let native = match &mut object.data { + DynamicBlockData::RotationParameter(p) => { + p.definition_base_angle_point = transform.apply(p.definition_base_angle_point); + &mut p.parameter + } + DynamicBlockData::AlignmentParameter(p) => &mut p.parameter, + DynamicBlockData::FlipParameter(p) => { + p.definition_label_point = transform.apply(p.definition_label_point); + &mut p.parameter + } + _ => 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); + } + changed += 1; + } + for object in document.objects.values_mut() { + let ObjectType::DynamicBlock(object) = object else { + continue; + }; + if object.owner != graph { + continue; + } + let grip = match &mut object.data { + DynamicBlockData::RotationGrip(g) + if g.element.eval.node_id > 0 && ids.contains(&g.element.eval.node_id) => + { + g + } + DynamicBlockData::AlignmentGrip(g) + if g.grip.element.eval.node_id > 0 + && ids.contains(&g.grip.element.eval.node_id) => + { + g.orientation = transform.apply_rotation(g.orientation); + &mut g.grip + } + DynamicBlockData::FlipGrip(g) + if g.grip.element.eval.node_id > 0 + && ids.contains(&g.grip.element.eval.node_id) => + { + g.orientation = transform.apply_rotation(g.orientation); + &mut g.grip + } + _ => continue, + }; + grip.location = transform.apply(grip.location); + changed += 1; + } + } + changed +} + +#[cfg(test)] +mod tests; diff --git a/src/scene/block_orientation_parameter/tests.rs b/src/scene/block_orientation_parameter/tests.rs new file mode 100644 index 000000000..04e6a6045 --- /dev/null +++ b/src/scene/block_orientation_parameter/tests.rs @@ -0,0 +1,328 @@ +use super::*; +use crate::scene::{block_distance_parameter, 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 handle = scene + .document + .block_records + .get("PARAMETERS") + .unwrap() + .handle; + (scene, handle) +} +fn append_batch(document: &mut CadDocument, definition: Handle) { + for kind in [Kind::Rotation, Kind::Alignment, Kind::Flip] { + create( + document, + definition, + kind, + Vector3::ZERO, + Vector3::new(0.0, 2.0, 0.0), + if kind == Kind::Rotation { + Vector3::new(2.0, 0.0, 0.0) + } else { + Vector3::new(3.0, 3.0, 0.0) + }, + ) + .unwrap(); + } +} +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 +} +fn assert_roundtrips(document: &CadDocument, definition: Handle, before: &CadDocument) { + let graph = graph_handle(document, definition).unwrap(); + if let Some(old_graph) = graph_handle(before, definition) { + for (h, o) in &before.objects { + if *h != old_graph { + assert_eq!(document.objects.get(h), Some(o), "preserved {h}"); + } + } + let old = graph_data(before, definition); + let new = graph_data(document, definition); + assert_eq!(&new.nodes[..old.nodes.len()], &old.nodes); + assert_eq!(&new.edges[..old.edges.len()], &old.edges); + } + let objects = document + .objects + .iter() + .filter_map(|(h, o)| match o { + ObjectType::DynamicBlock(o) if o.owner == graph && !before.objects.contains_key(h) => { + Some((*h, o.data.clone())) + } + _ => None, + }) + .collect::>(); + assert_eq!(objects.len(), 13); + let g = graph_data(document, definition); + for node in g + .nodes + .iter() + .filter(|n| objects.iter().any(|(h, _)| *h == n.expression)) + { + let incoming = g + .edges + .iter() + .filter(|e| e.destination_node == node.id) + .map(|e| e.id) + .collect::>(); + let outgoing = g + .edges + .iter() + .filter(|e| e.source_node == node.id) + .map(|e| e.id) + .collect::>(); + assert_eq!( + node.node_data, + [ + incoming.first().copied().unwrap_or(-1), + incoming.last().copied().unwrap_or(-1), + outgoing.first().copied().unwrap_or(-1), + outgoing.last().copied().unwrap_or(-1) + ] + ); + } + for extension in ["dwg", "dxf"] { + let bytes = crate::io::save_to_bytes(document, extension, document.version).unwrap(); + let read = crate::io::load_bytes(&format!("orientation.{extension}"), bytes).unwrap(); + for (h, data) in &objects { + let ObjectType::DynamicBlock(object) = &read.objects[h] else { + panic!("{extension}: native {h}") + }; + assert_eq!(&object.data, data, "{extension}: {h}"); + assert_eq!(object.owner, graph); + } + assert!(authoring::graph(&read, definition).is_ok()); + } +} +#[test] +fn orientation_parameter_native_batch_coexists_and_roundtrips_complete_objects() { + 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::ZERO).unwrap(); + block_distance_parameter::create( + &mut scene.document, + definition, + block_distance_parameter::Kind::Polar, + Vector3::ZERO, + Vector3::UNIT_Y, + Vector3::UNIT_X, + ) + .unwrap(); + let before = scene.document.clone(); + append_batch(&mut scene.document, definition); + assert_roundtrips(&scene.document, definition, &before); + let parameters = parameters(&scene.document, definition); + assert_eq!(parameters[0].label, "Angle2", "share labels with Polar"); + for p in ¶meters { + let id = p.native.parameter.element.eval.node_id; + assert_eq!( + p.native.property_states, + if p.kind == Kind::Rotation { + [0, id + 1, 0, 0] + } else { + [id + 1, 0, 0, 0] + } + ); + } + let flip = parameters.iter().find(|p| p.kind == Kind::Flip).unwrap(); + let id = flip.native.parameter.element.eval.node_id; + assert!(scene.document.objects.values().any(|o|matches!(o,ObjectType::DynamicBlock(o) if matches!(&o.data,DynamicBlockData::FlipGrip(g) if g.combined_state==id+2&&g.grip.flags_91==id+3&&g.grip.flags_92==id+4)))); + assert!(scene.document.objects.values().any(|o|matches!(o,ObjectType::DynamicBlock(o) if matches!(&o.data,DynamicBlockData::GripLocationComponent(c) if c.expression=="UpdatedFlip"&&c.grip_type==id&&c.eval.value_code==70&&c.eval.value==BlockEvalValue::Short(0))))); +} +#[test] +fn orientation_parameter_rotation_and_polar_share_unique_labels_in_both_orders() { + for rotation_first in [false, true] { + let (mut scene, definition) = fixture(); + for rotation in [rotation_first, !rotation_first] { + if rotation { + create( + &mut scene.document, + definition, + Kind::Rotation, + Vector3::ZERO, + Vector3::UNIT_Y, + Vector3::UNIT_X, + ) + .unwrap(); + } else { + block_distance_parameter::create( + &mut scene.document, + definition, + block_distance_parameter::Kind::Polar, + Vector3::ZERO, + Vector3::UNIT_Y, + Vector3::UNIT_X, + ) + .unwrap(); + } + } + let rotation = parameters(&scene.document, definition)[0].label.clone(); + let polar = + block_distance_parameter::parameters(&scene.document, definition)[0].labels[1].clone(); + assert_ne!(rotation, polar); + } +} + +#[test] +fn orientation_parameter_flip_capacity_and_invalid_points_leave_document_intact() { + let (mut scene, definition) = fixture(); + for kind in [Kind::Rotation, Kind::Alignment, Kind::Flip] { + 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::UNIT_X + ) + .is_err()); + assert_eq!(scene.document, before); + } + } + 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 - 4; + } + } + let before = scene.document.clone(); + assert!(placement_error(&scene.document, definition, Kind::Flip).is_some()); + assert!(create( + &mut scene.document, + definition, + Kind::Flip, + Vector3::ZERO, + Vector3::UNIT_X, + Vector3::ZERO + ) + .is_err()); + assert_eq!(scene.document, before); + assert!(placement_error(&scene.document, definition, Kind::Rotation).is_none()); + create( + &mut scene.document, + definition, + Kind::Rotation, + Vector3::ZERO, + Vector3::UNIT_Y, + Vector3::UNIT_X, + ) + .unwrap(); + assert_eq!( + graph_data(&scene.document, definition).first_node_id, + i32::MAX + ); +} +#[test] +fn orientation_parameter_reframe_and_editor_extents_preserve_native_points() { + let (mut scene, definition) = fixture(); + append_batch(&mut scene.document, definition); + let original = parameters(&scene.document, definition) + .iter() + .map(|p| { + ( + p.native.definition_base_point, + p.native.definition_end_point, + p.reference, + ) + }) + .collect::>(); + let transform = Transform::from_translation(Vector3::new(10.0, 20.0, 0.0)); + assert_eq!(reframe(&mut scene.document, definition, &transform), 6); + for (p, (base, end, reference)) in parameters(&scene.document, definition).iter().zip(original) + { + assert_eq!(p.native.definition_base_point, transform.apply(base)); + assert_eq!(p.native.definition_end_point, transform.apply(end)); + assert_eq!(p.reference, transform.apply(reference)); + assert!(graphic(p) + .lines + .into_iter() + .flatten() + .all(|p| p.x.is_finite() && p.y.is_finite() && p.z.is_finite())); + } + scene.block_edit_block = Some(definition); + let (_, max) = scene.visible_block_bounds(definition).unwrap(); + assert!(max.x >= 13.0 && max.y >= 23.0); + scene.block_edit_block = None; + let (_, max) = scene.visible_block_bounds(definition).unwrap(); + assert!(max.x <= 1.0 && max.y < 1.0); +} +#[test] +fn orientation_parameter_rotation_270_graphic_retains_counterclockwise_arc() { + let (mut scene, definition) = fixture(); + create( + &mut scene.document, + definition, + Kind::Rotation, + Vector3::ZERO, + -Vector3::UNIT_Y, + Vector3::UNIT_X, + ) + .unwrap(); + let g = graphic(¶meters(&scene.document, definition)[0]); + assert!(g.lines.iter().flatten().any(|p| p.x < -0.99)); +} +#[test] +#[ignore = "requires OCS_DYNAMIC_BLOCK_FIXTURES with local DWG fixtures"] +fn orientation_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 digest = 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()) + && placement_error(&document, *h, Kind::Flip).is_none() + }) + .unwrap(); + let before = document.clone(); + append_batch(&mut document, definition); + assert_roundtrips(&document, definition, &before); + assert_eq!(Sha256::digest(std::fs::read(&path).unwrap()), digest); + eprintln!("{filename}: Rotation/Alignment/Flip native DWG/DXF objects and graph append passed; original graph/objects/checksum preserved"); + } +} diff --git a/src/scene/camera_ops.rs b/src/scene/camera_ops.rs index 2f1a96d1b..204ea758e 100644 --- a/src/scene/camera_ops.rs +++ b/src/scene/camera_ops.rs @@ -1687,6 +1687,22 @@ impl Scene { fold!(Some((point, point))); } } + for parameter in super::block_orientation_parameter::parameters(&self.document, block) { + let graphic = super::block_orientation_parameter::graphic(¶meter); + for point in graphic + .lines + .into_iter() + .flatten() + .chain(graphic.labels.into_iter().map(|(p, _)| p)) + .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 bf90d1ed4..5df8c9f19 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -13,6 +13,7 @@ pub mod block_visibility; pub(crate) mod block_authoring; pub(crate) mod block_point_parameter; pub(crate) mod block_distance_parameter; +pub(crate) mod block_orientation_parameter; pub mod cache; pub mod compare; pub mod convert; diff --git a/src/scene/modify.rs b/src/scene/modify.rs index acc715910..7fd0d5ec0 100644 --- a/src/scene/modify.rs +++ b/src/scene/modify.rs @@ -565,6 +565,12 @@ impl Scene { block_record, local_from_old, ); + let parameter_changes = parameter_changes + + super::block_orientation_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 925e7fb2d..41a1c1cbf 100644 --- a/src/ui/overlay.rs +++ b/src/ui/overlay.rs @@ -1019,6 +1019,7 @@ pub(crate) fn selection_overlay<'a>( pub(crate) struct AuthoringDimension { pub lines: Vec<[Point; 2]>, + pub round_grips: Vec, pub dimensions: Vec<[Point; 2]>, pub labels: Vec<(Point, String)>, pub grips: Vec<(Point, Option)>, @@ -2410,6 +2411,9 @@ impl canvas::Program for SelectionCanvas { ..Default::default() }); } + for point in &dimension.round_grips { + frame.fill(&canvas::Path::circle(*point, 3.5), color); + } for (point, base) in &dimension.grips { let marker = if let Some(base) = base { let dx = point.x - base.x; diff --git a/src/ui/window/authoring_palettes.rs b/src/ui/window/authoring_palettes.rs index f70d19f5c..a8eb1e8ee 100644 --- a/src/ui/window/authoring_palettes.rs +++ b/src/ui/window/authoring_palettes.rs @@ -192,6 +192,7 @@ fn catalog( tab: Tab, visibility_error: Option<&'static str>, point_error: Option<&'static str>, + flip_error: Option<&'static str>, ) -> Element<'static, Message> { let (entries, unavailable) = match tab { Tab::Parameters => ( @@ -209,7 +210,9 @@ fn catalog( let visibility = tab == Tab::Parameters && *label == "Visibility"; let point = tab == Tab::Parameters && *label == "Point"; let distance = tab == Tab::Parameters && matches!(*label, "Linear" | "Polar" | "XY"); - let enabled = (visibility && visibility_error.is_none()) || ((point || distance) && point_error.is_none()); + 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 base = if enabled { icons::semantic(icon, 28.0) } else { @@ -244,6 +247,12 @@ fn catalog( .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 if orientation { + crate::t!(orientation_error.unwrap_or(match *label { + "Rotation" => "Place the center, specify a radius, then the default rotation angle.", + "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 { unavailable.to_string() }; @@ -254,6 +263,8 @@ fn catalog( 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 if orientation { + container(entry).id(match *label {"Rotation"=>"authoring-tool-rotation","Alignment"=>"authoring-tool-alignment",_=>"authoring-tool-flip"}).into() } else { entry } @@ -366,6 +377,7 @@ pub fn view( chrome: Chrome, visibility_error: Option<&'static str>, point_error: Option<&'static str>, + flip_error: Option<&'static str>, ) -> Element<'static, Message> { let tabs = column(Tab::ALL.into_iter().map(|category| { let label = category.label(); @@ -415,9 +427,14 @@ pub fn view( chrome, ), row![ - scrollable(catalog(selected_tab, visibility_error, point_error)) - .width(Fill) - .height(Fill), + scrollable(catalog( + selected_tab, + visibility_error, + point_error, + flip_error + )) + .width(Fill) + .height(Fill), tabs ] .spacing(4)