diff --git a/src/app/block_actions.rs b/src/app/block_actions.rs index 10610cb9e..86d0dc2bb 100644 --- a/src/app/block_actions.rs +++ b/src/app/block_actions.rs @@ -21,9 +21,11 @@ impl OpenCADStudio { { return Some(e); } - if actions::parameters(&tab.scene.document, s.br_handle, kind.unwrap_or(Kind::Move)) - .is_empty() - { + let candidates = kind.map_or_else( + || actions::all_parameters(&tab.scene.document, s.br_handle), + |kind| actions::parameters(&tab.scene.document, s.br_handle, kind), + ); + if candidates.is_empty() { return Some("Place a compatible parameter before adding this action."); } None @@ -36,7 +38,7 @@ impl OpenCADStudio { }; if !args.is_empty() && kind.is_none() { self.command_line.push_error( - crate::t!("Supported actions: Move, Scale. Other action types are not wired yet.") + crate::t!("Supported actions: Move, Scale, Rotate, Flip. Other action types are not wired yet.") .as_ref(), ); return Task::none(); @@ -51,7 +53,10 @@ impl OpenCADStudio { tab.id, definition, kind, - actions::parameters(&tab.scene.document, definition, kind.unwrap_or(Kind::Move)), + kind.map_or_else( + || actions::all_parameters(&tab.scene.document, definition), + |kind| actions::parameters(&tab.scene.document, definition, kind), + ), ); self.command_line.push_info(&command.prompt()); self.command_line.set_step_options(command.options()); @@ -96,7 +101,7 @@ impl OpenCADStudio { self.tabs[i].scene.clear_preview_wire(); self.finish_pending_history(i); self.refresh_properties(); - self.command_line.push_output(crate::tf!("{} action attached. Open Test Block and click the parameter grip to test it.",crate::t!(kind.name())).as_ref()); + self.command_line.push_output(crate::tf!("{} action attached. Select the reference and use its parameter grip, or open Test Block.",crate::t!(kind.name())).as_ref()); } Err(e) => self.command_line.push_error(crate::t!(e).as_ref()), } @@ -106,14 +111,13 @@ impl OpenCADStudio { document, definition, parameter, - position, + value, } => { if self.tabs[i].id != document { return Task::none(); } if let Some(insert) = insert { - return self - .evaluate_reference_parameter(insert, definition, parameter, position); + return self.evaluate_reference_parameter(insert, definition, parameter, value); } let Some(test) = self.tabs[i].block_test.as_ref() else { return Task::none(); @@ -141,10 +145,21 @@ impl OpenCADStudio { ); return Task::none(); }; - let local = actions::v(inverse.apply(actions::native(position))); + let local = match value { + actions::Value::Point(at) => { + actions::Value::Point(actions::v(inverse.apply(actions::native(at)))) + } + state => state, + }; let representation = insert.block_name.clone(); let mut staged = self.tabs[i].scene.document.clone(); - match actions::evaluate(&mut staged, &test.baseline, definition, parameter, local) { + match actions::evaluate_value( + &mut staged, + &test.baseline, + definition, + parameter, + local, + ) { Ok(changed) => { if changed { if let Err(e) = actions::sync_representation( @@ -210,21 +225,28 @@ impl OpenCADStudio { let transform = crate::scene::render_graph::insert_transform(&tab.scene.document, insert); let mut grips = vec![]; let all = actions::actions(&test.baseline, test.definition); - for (index, p) in actions::parameters(&test.baseline, test.definition, Kind::Move) + for (index, p) in actions::all_parameters(&test.baseline, test.definition) .into_iter() .enumerate() { if !all.iter().any(|(_, _, _, id)| *id == p.id) { continue; } - let world = actions::v( - transform.apply(actions::native(actions::current(&tab.scene.document, &p))), - ) - glam::DVec3::from_array(wo); + let Ok(value) = actions::value(&tab.scene.document, &p) else { + continue; + }; + let world = + actions::v(transform.apply(actions::native(actions::grip_position(&p, value)))) + - glam::DVec3::from_array(wo); grips.push(crate::scene::model::object::GripDef { id: TEST_GRIP_START + index, world, is_midpoint: false, - shape: crate::scene::model::object::GripShape::Triangle, + shape: if p.flip { + crate::scene::model::object::GripShape::Square + } else { + crate::scene::model::object::GripShape::Triangle + }, dir: None, axis: None, }); @@ -251,12 +273,18 @@ impl OpenCADStudio { if tab.scene.is_layer_locked(handle) { return true; } - let Some(p) = actions::parameters(&test.baseline, test.definition, Kind::Move) + let Some(p) = actions::all_parameters(&test.baseline, test.definition) .get(id - TEST_GRIP_START) .cloned() else { return false; }; + if p.flip { + let state = actions::value(&tab.scene.document, &p); + let document = tab.id; + let definition = test.definition; + return self.toggle_action_grip(document, definition, p.handle, None, state); + } let command = crate::modules::block_editor::action::TestParameterCommand { insert: None, document: tab.id, @@ -291,23 +319,28 @@ impl OpenCADStudio { crate::scene::render_graph::insert_transform(&tab.scene.document, reference); let all = actions::actions(&tab.scene.document, def); let mut grips = vec![]; - for (index, p) in actions::parameters(&tab.scene.document, def, Kind::Move) + for (index, p) in actions::all_parameters(&tab.scene.document, def) .into_iter() .enumerate() { if !all.iter().any(|(_, _, _, id)| *id == p.id) { continue; } - let Ok(local) = actions::instance::current(&tab.scene.document, handle, &p) else { + let Ok(value) = actions::instance::value(&tab.scene.document, handle, &p) else { continue; }; let world = - actions::v(transform.apply(actions::native(local))) - glam::DVec3::from_array(wo); + actions::v(transform.apply(actions::native(actions::grip_position(&p, value)))) + - glam::DVec3::from_array(wo); grips.push(crate::scene::model::object::GripDef { id: TEST_GRIP_START + index, world, is_midpoint: false, - shape: crate::scene::model::object::GripShape::Triangle, + shape: if p.flip { + crate::scene::model::object::GripShape::Square + } else { + crate::scene::model::object::GripShape::Triangle + }, dir: None, axis: None, }); @@ -329,12 +362,17 @@ impl OpenCADStudio { if tab.scene.is_layer_locked(handle) { return true; } - let Some(p) = actions::parameters(&tab.scene.document, def, Kind::Move) + let Some(p) = actions::all_parameters(&tab.scene.document, def) .get(id - TEST_GRIP_START) .cloned() else { return false; }; + if p.flip { + let state = actions::instance::value(&tab.scene.document, handle, &p); + let document = tab.id; + return self.toggle_action_grip(document, def, p.handle, Some(handle), state); + } let command = crate::modules::block_editor::action::TestParameterCommand { insert: Some(handle), document: tab.id, @@ -352,7 +390,7 @@ impl OpenCADStudio { handle: Handle, definition: Handle, parameter: Handle, - position: glam::DVec3, + value: actions::Value, ) -> Task { let i = self.active_tab; let tab = &self.tabs[i]; @@ -373,8 +411,13 @@ impl OpenCADStudio { ) else { return Task::none(); }; - let local = actions::v(inverse.apply(actions::native(position))); - match actions::instance::evaluate(&tab.scene.document, handle, parameter, local) { + let local = match value { + actions::Value::Point(at) => { + actions::Value::Point(actions::v(inverse.apply(actions::native(at)))) + } + state => state, + }; + match actions::instance::evaluate_value(&tab.scene.document, handle, parameter, local) { Ok(staged) => { if let Some(mut staged) = staged { self.push_undo_snapshot(i, "Block reference parameter"); @@ -407,3 +450,33 @@ impl OpenCADStudio { Task::none() } } + +impl OpenCADStudio { + fn toggle_action_grip( + &mut self, + document: u64, + definition: Handle, + parameter: Handle, + insert: Option, + state: Result, + ) -> bool { + match state { + Ok(actions::Value::Flip(state)) => { + let _ = self.handle_block_action_result( + crate::command::CmdResult::EvaluateBlockParameter { + document, + definition, + parameter, + insert, + value: actions::Value::Flip(!state), + }, + ); + } + Err(e) => self.command_line.push_error(crate::t!(e).as_ref()), + _ => return false, + } + self.grip_hover = None; + self.grip_popup = None; + true + } +} diff --git a/src/app/block_editor_tests.rs b/src/app/block_editor_tests.rs index 1e9dfe147..ece0b61ee 100644 --- a/src/app/block_editor_tests.rs +++ b/src/app/block_editor_tests.rs @@ -59,6 +59,139 @@ fn feed(app: &mut OpenCADStudio, text: &str) { let _ = app.update(Message::CommandSubmit); } +#[test] +fn block_actions_rotate_flip_mouse_grips_authoring_history_and_reopen() { + use crate::scene::block_actions::{self as actions, Kind}; + let (mut app, reference) = fixture(); + begin(&mut app, reference); + let source = app.active_tab; + let _ = app.run_command_line("BPARAMETER ROTATION 0,0 10 0"); + let _ = app.run_command_line("BPARAMETER FLIP 0,0 10,0 -2,2"); + let def = app.tabs[source] + .active_block_edit_session() + .unwrap() + .br_handle; + let rotate = + actions::parameters(&app.tabs[source].scene.document, def, Kind::Rotate)[0].clone(); + let flip = actions::parameters(&app.tabs[source].scene.document, def, Kind::Flip)[0].clone(); + let targets = members(&app); + for (kind, p) in [("Rotate", &rotate), ("Flip", &flip)] { + let depth = app.tabs[source].history.undo_stack.len(); + let _ = app.dispatch_command(&format!("BACTION {kind}")); + feed(&mut app, &p.name); + let _ = app.feed_command(crate::command::StepInput::SelectionComplete( + targets.clone(), + )); + let _ = app.feed_command(crate::command::StepInput::Enter); + feed(&mut app, "3,4"); + assert!(app.tabs[source].active_cmd.is_none()); + assert_eq!(app.tabs[source].history.undo_stack.len(), depth + 1); + } + let baseline = app.tabs[source].scene.document.clone(); + let click = |app: &mut OpenCADStudio, world: glam::DVec3| { + let scene = &app.tabs[app.active_tab].scene; + let (w, h) = scene.selection.borrow().view.vp_size; + let tile = scene.active_model_tile_bounds(w, h); + let at = scene + .camera + .borrow() + .project( + world, + iced::Rectangle { + x: 0., + y: 0., + width: tile.width, + height: tile.height, + }, + ) + .unwrap(); + let _ = app.update(Message::ViewportLeftPressAt(iced::Point::new( + at.x + tile.x, + at.y + tile.y, + ))); + let _ = app.update(Message::ViewportLeftRelease); + }; + let check = |app: &OpenCADStudio, insert: Handle, expected: Vector3| { + let doc = &app.tabs[app.active_tab].scene.document; + let EntityType::Insert(ins) = doc.get_entity(insert).unwrap() else { + panic!() + }; + let h = doc + .block_records + .get(&ins.block_name) + .unwrap() + .entity_handles[0]; + let EntityType::Line(line) = doc.get_entity(h).unwrap() else { + panic!() + }; + assert!( + line.end.distance(&expected) < 1e-5, + "{:?} != {expected:?}", + line.end + ); + }; + // Exercise the exact viewport grip press/release path in both contexts. + for test in [true, false] { + let insert = if test { + let _ = app.dispatch_command("BTESTBLOCK"); + app.tabs[app.active_tab].block_test.as_ref().unwrap().insert + } else { + app.active_tab = source; + let _ = app.dispatch_command("BSAVE"); + let _ = app.dispatch_command("BEDIT_DISCARD"); + reference + }; + let i = app.active_tab; + app.tabs[i].scene.selection.borrow_mut().view.vp_size = (1600., 900.); + app.tabs[i].scene.camera.borrow_mut().fit_to_bounds_f64( + glam::DVec3::new(-4., -3., 0.), + glam::DVec3::new(13., 13., 0.), + 1600. / 900., + ); + app.tabs[i].scene.selected.insert(insert); + app.refresh_properties(); + click(&mut app, rotate.end); + assert!(app.tabs[i].active_cmd.is_some()); + let depth = app.tabs[i].history.undo_stack.len(); + click(&mut app, glam::DVec3::new(0., 10., 0.)); + assert_eq!(app.tabs[i].history.undo_stack.len(), depth + 1); + check(&app, insert, Vector3::UNIT_Y); + app.tabs[i].scene.selected.insert(insert); + app.refresh_properties(); + let depth = app.tabs[i].history.undo_stack.len(); + click(&mut app, flip.reference); + assert!( + app.tabs[i].active_cmd.is_none(), + "Flip toggles immediately without point input" + ); + check(&app, insert, -Vector3::UNIT_Y); + assert_eq!(app.tabs[i].history.undo_stack.len(), depth + 1); + app.undo_steps(1); + check(&app, insert, Vector3::UNIT_Y); + app.redo_steps(1); + check(&app, insert, -Vector3::UNIT_Y); + if test { + assert_eq!(app.tabs[source].scene.document, baseline); + let _ = app.dispatch_command("BCLOSETESTBLOCK"); + } else { + let bytes = codec::DwgWriter::write_to_vec(&app.tabs[i].scene.document).unwrap(); + app.tabs[i].scene.document = codec::DwgReader::from_stream(std::io::Cursor::new(bytes)) + .read() + .unwrap(); + app.tabs[i].scene.selected.insert(insert); + app.refresh_properties(); + click(&mut app, flip.reference); + check(&app, insert, Vector3::UNIT_Y); + let EntityType::Line(original) = + app.tabs[i].scene.document.get_entity(targets[0]).unwrap() + else { + panic!() + }; + assert_eq!(original.end, Vector3::UNIT_X); + } + } +} + #[test] fn block_actions_drawing_grips_history_cancel_transform_and_reopen() { use crate::scene::block_actions::{self as actions, Kind}; @@ -601,7 +734,7 @@ fn block_actions_command_selection_cancel_undo_and_test_block_isolation() { document: id, definition, parameter: p.handle, - position: glam::DVec3::new(30., 0., 0.), + value: crate::scene::block_actions::Value::Point(glam::DVec3::new(30., 0., 0.)), }); assert_eq!( app.tabs[test].scene.document, shared_before, @@ -682,7 +815,7 @@ fn block_actions_test_reference_transform_visibility_and_noop_history() { document: id, definition, parameter: p.handle, - position: current_world, + value: crate::scene::block_actions::Value::Point(current_world), }); assert_eq!(app.tabs[i].history.undo_stack.len(), depth); // Existing visibility UI creates a per-reference anonymous definition. @@ -721,7 +854,7 @@ fn block_actions_test_reference_transform_visibility_and_noop_history() { document: id, definition, parameter: p.handle, - position: world, + value: crate::scene::block_actions::Value::Point(world), }); let anon = app.tabs[i] .scene diff --git a/src/app/dock/tests.rs b/src/app/dock/tests.rs index 982427aa0..61cd36469 100644 --- a/src/app/dock/tests.rs +++ b/src/app/dock/tests.rs @@ -1244,6 +1244,44 @@ fn block_actions_palette_controls_require_compatible_parameters_and_start_comman }); } +#[test] +fn block_actions_rotate_flip_palette_requires_matching_parameters() { + with_stack(|| { + use crate::ui::window::authoring_palettes::Tab; + let mut app = authoring_app(); + let _ = app.dispatch_command("BAUTHORPALETTE"); + let point = authoring_control(&app, Tab::Actions.widget_id()); + authoring_click(&mut app, point); + for id in ["authoring-tool-rotate-action", "authoring-tool-flip-action"] { + let point = authoring_control(&app, id); + authoring_click(&mut app, point); + assert!(app.tabs[app.active_tab].active_cmd.is_none()); + } + let _ = app.run_command_line("BPARAMETER ROTATION 0,0 2 0"); + let point = authoring_control(&app, "authoring-tool-flip-action"); + authoring_click(&mut app, point); + assert!(app.tabs[app.active_tab].active_cmd.is_none()); + let point = authoring_control(&app, "authoring-tool-rotate-action"); + authoring_click(&mut app, point); + assert!(app.tabs[app.active_tab] + .active_cmd + .as_ref() + .unwrap() + .prompt() + .contains("Rotate")); + let _ = app.update(Message::CommandEscape); + let _ = app.run_command_line("BPARAMETER FLIP 0,0 2,0 -1,1"); + let point = authoring_control(&app, "authoring-tool-flip-action"); + authoring_click(&mut app, point); + assert!(app.tabs[app.active_tab] + .active_cmd + .as_ref() + .unwrap() + .prompt() + .contains("Flip")); + }); +} + #[test] fn lookup_basepoint_palette_and_table_dialog_real_controls() { with_stack(|| { diff --git a/src/app/dock/view.rs b/src/app/dock/view.rs index 7ac3e0f88..e35f0c4af 100644 --- a/src/app/dock/view.rs +++ b/src/app/dock/view.rs @@ -413,6 +413,8 @@ impl OpenCADStudio { self.lookup_table_error(), self.action_error(Some(crate::scene::block_actions::Kind::Move)), self.action_error(Some(crate::scene::block_actions::Kind::Scale)), + self.action_error(Some(crate::scene::block_actions::Kind::Rotate)), + self.action_error(Some(crate::scene::block_actions::Kind::Flip)), ) } crate::ui::dock::PanelId::BlockPalette => { diff --git a/src/command.rs b/src/command.rs index 706852f09..d25c2f31a 100644 --- a/src/command.rs +++ b/src/command.rs @@ -1534,7 +1534,7 @@ pub enum CmdResult { document: u64, definition: Handle, parameter: Handle, - position: DVec3, + value: crate::scene::block_actions::Value, }, /// Place or relocate a Basepoint, or place a Lookup parameter. PlaceBlockSingleParameter { diff --git a/src/modules/block_editor/action.rs b/src/modules/block_editor/action.rs index 805709bac..519c2cd78 100644 --- a/src/modules/block_editor/action.rs +++ b/src/modules/block_editor/action.rs @@ -1,6 +1,6 @@ //! Reuse the command host's point picking and object-set selection. use crate::command::{CadCommand, CmdOption, CmdResult, PointPickContext}; -use crate::scene::block_actions::{Kind, Parameter}; +use crate::scene::block_actions::{self as actions, Kind, Parameter}; use codec::Handle; use glam::DVec3; @@ -37,9 +37,9 @@ impl ActionCommand { } } fn choose(&mut self, p: Parameter) -> CmdResult { - if self.kind == Some(Kind::Scale) && (p.point || p.xy) { + if self.kind.is_some_and(|kind| !actions::compatible(&p, kind)) { return CmdResult::ReportError( - crate::t!("Scale requires a Linear or Polar parameter.").into_owned(), + crate::t!("Select a compatible parameter for this action.").into_owned(), ); } self.parameter = Some(p.handle); @@ -51,12 +51,18 @@ impl CadCommand for ActionCommand { fn name(&self) -> &'static str { "BACTION" } - fn wants_text_input(&self) -> bool { - self.parameter.is_none() + fn input_kind(&self) -> crate::command::InputKind { + if self.parameter.is_some() { + crate::command::InputKind::Point + } else if self.kind.is_some() { + crate::command::InputKind::FreeText + } else { + crate::command::InputKind::SingleToken + } } fn prompt(&self) -> String { if self.kind.is_none() { - return crate::t!("BACTION Enter action type [Move/Scale]:").into_owned(); + return crate::t!("BACTION Enter action type [Move/Scale/Rotate/Flip]:").into_owned(); } if self.parameter.is_none() { return crate::tf!( @@ -77,6 +83,8 @@ impl CadCommand for ActionCommand { vec![ CmdOption::new("Move", "Move"), CmdOption::new("Scale", "Scale"), + CmdOption::new("Rotate", "Rotate"), + CmdOption::new("Flip", "Flip"), ] } else { vec![] @@ -88,14 +96,18 @@ impl CadCommand for ActionCommand { self.kind = Some(k); CmdResult::NeedPoint } else { - CmdResult::ReportError(crate::t!("Choose Move or Scale.").into_owned()) + CmdResult::ReportError( + crate::t!("Choose Move, Scale, Rotate or Flip.").into_owned(), + ) }); } if self.parameter.is_none() { let matches = self .candidates .iter() - .filter(|p| p.name.eq_ignore_ascii_case(s)) + .filter(|p| { + p.name.eq_ignore_ascii_case(s) && actions::compatible(p, self.kind.unwrap()) + }) .cloned() .collect::>(); return Some(if matches.len() == 1 { @@ -126,7 +138,7 @@ impl CadCommand for ActionCommand { let candidate = self .candidates .iter() - .filter(|p| self.kind != Some(Kind::Scale) || (!p.point && !p.xy)) + .filter(|p| actions::compatible(p, self.kind.unwrap())) .map(|p| (screen(p.end).distance(click), p)) .filter(|(d, _)| d.is_finite() && *d <= c.aperture_px.max(10.)) .min_by(|a, b| a.0.total_cmp(&b.0)) @@ -200,7 +212,7 @@ impl CadCommand for TestParameterCommand { document: self.document, definition: self.definition, parameter: self.parameter, - position, + value: actions::Value::Point(position), } } fn on_enter(&mut self) -> CmdResult { diff --git a/src/scene/block_actions.rs b/src/scene/block_actions.rs index 156f6b9e7..76160baf1 100644 --- a/src/scene/block_actions.rs +++ b/src/scene/block_actions.rs @@ -1,8 +1,9 @@ -//! Native Move/Scale authoring and deterministic dynamic-block evaluation. +//! Native Move/Scale/Rotate/Flip authoring and deterministic block evaluation. use super::block_authoring::{self as authoring, *}; use codec::objects::{ BlockAction, BlockActionOffsets, BlockActionWithBasePoint, BlockBasePointAction, - BlockConnection, BlockElement, BlockEvalValue, BlockMoveAction, DynamicBlockData, ObjectType, + BlockConnection, BlockElement, BlockEvalValue, BlockMoveAction, BlockFlipAction, + DynamicBlockData, ObjectType, }; use codec::types::{Transform, Vector3}; use codec::{CadDocument, EntityType, Handle}; @@ -15,12 +16,16 @@ mod tests; pub enum Kind { Move, Scale, + Rotate, + Flip, } impl Kind { pub(crate) fn parse(s: &str) -> Option { match s.to_ascii_uppercase().as_str() { "MOVE" | "M" => Some(Self::Move), "SCALE" | "S" => Some(Self::Scale), + "ROTATE" | "R" => Some(Self::Rotate), + "FLIP" | "F" => Some(Self::Flip), _ => None, } } @@ -28,6 +33,8 @@ impl Kind { match self { Self::Move => "Move", Self::Scale => "Scale", + Self::Rotate => "Rotate", + Self::Flip => "Flip", } } } @@ -43,6 +50,9 @@ pub(crate) struct Parameter { pub linear: bool, pub point: bool, pub xy: bool, + pub rotation: bool, + pub flip: bool, + pub reference: DVec3, } pub(crate) fn v(p: Vector3) -> DVec3 { DVec3::new(p.x, p.y, p.z) @@ -51,6 +61,117 @@ pub(crate) fn native(p: DVec3) -> Vector3 { Vector3::new(p.x, p.y, p.z) } +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Value { + Point(DVec3), + Flip(bool), +} + +pub(crate) fn all_parameters(doc: &CadDocument, definition: Handle) -> Vec { + let mut result = parameters(doc, definition, Kind::Move); + result.extend(parameters(doc, definition, Kind::Rotate)); + result.extend(parameters(doc, definition, Kind::Flip)); + result.sort_by_key(|p| p.id); + result +} + +pub(crate) fn compatible(p: &Parameter, kind: Kind) -> bool { + match kind { + Kind::Move => !p.rotation && !p.flip, + Kind::Scale => !p.rotation && !p.flip && !p.point && !p.xy, + Kind::Rotate => p.rotation, + Kind::Flip => p.flip, + } +} + +fn flip_component(doc: &CadDocument, p: &Parameter) -> Result { + let Some(ObjectType::DynamicBlock(o)) = doc.objects.get(&p.grip) else { + return Err("Missing Flip grip."); + }; + let DynamicBlockData::FlipGrip(g) = &o.data else { + return Err("Missing Flip grip."); + }; + let candidates = doc + .objects + .iter() + .filter_map(|(h, x)| match x { + ObjectType::DynamicBlock(x) if x.owner == o.owner => match &x.data { + DynamicBlockData::GripLocationComponent(c) + if c.eval.node_id == g.combined_state && c.expression == "UpdatedFlip" => + { + Some(*h) + } + _ => None, + }, + _ => None, + }) + .collect::>(); + if candidates.len() != 1 { + return Err("The Flip state binding is unavailable."); + } + Ok(candidates[0]) +} + +pub(crate) fn value(doc: &CadDocument, p: &Parameter) -> Result { + if !p.flip { + return Ok(Value::Point(current(doc, p))); + } + let h = flip_component(doc, p)?; + let ObjectType::DynamicBlock(o) = &doc.objects[&h] else { + unreachable!() + }; + let DynamicBlockData::GripLocationComponent(c) = &o.data else { + unreachable!() + }; + match c.eval.value { + BlockEvalValue::Short(0) => Ok(Value::Flip(false)), + BlockEvalValue::Short(1) => Ok(Value::Flip(true)), + _ => Err("The Flip state is not zero or one."), + } +} + +pub(crate) fn set_value( + doc: &mut CadDocument, + p: &Parameter, + value: Value, +) -> Result<(), &'static str> { + if let Value::Flip(state) = value { + if !p.flip { + return Err("Select a Flip parameter."); + } + let h = flip_component(doc, p)?; + let Some(ObjectType::DynamicBlock(o)) = doc.objects.get_mut(&h) else { + unreachable!() + }; + let DynamicBlockData::GripLocationComponent(c) = &mut o.data else { + unreachable!() + }; + c.eval.value = BlockEvalValue::Short(i16::from(state)); + return Ok(()); + } + let Value::Point(position) = value else { + unreachable!() + }; + let Some(ObjectType::DynamicBlock(o)) = doc.objects.get_mut(&p.grip) else { + return Err("Missing parameter grip."); + }; + match &mut o.data { + DynamicBlockData::XYGrip(g) + | DynamicBlockData::PolarGrip(g) + | DynamicBlockData::RotationGrip(g) => g.location = native(position), + DynamicBlockData::LinearGrip(g) => g.grip.location = native(position), + _ => return Err("Unsupported parameter grip."), + } + Ok(()) +} + +pub(crate) fn grip_position(p: &Parameter, value: Value) -> DVec3 { + match value { + Value::Point(at) => at, + Value::Flip(_) => p.reference, + } +} + pub(crate) fn parameters(doc: &CadDocument, definition: Handle, kind: Kind) -> Vec { let Ok(Some(graph)) = authoring::graph(doc, definition) else { return vec![]; @@ -68,10 +189,16 @@ pub(crate) fn parameters(doc: &CadDocument, definition: Handle, kind: Kind) -> V linear: false, point: true, xy: false, + rotation: false, + flip: false, + reference: DVec3::ZERO, }); } } for p in super::block_distance_parameter::parameters(doc, definition) { + if matches!(kind, Kind::Rotate | Kind::Flip) { + continue; + } let xy = p.kind == super::block_distance_parameter::Kind::XY; if kind == Kind::Scale && xy { continue; @@ -86,11 +213,46 @@ pub(crate) fn parameters(doc: &CadDocument, definition: Handle, kind: Kind) -> V linear: p.kind == super::block_distance_parameter::Kind::Linear, point: false, xy, + rotation: false, + flip: false, + reference: DVec3::ZERO, + }); + } + for p in super::block_orientation_parameter::parameters(doc, definition) { + use super::block_orientation_parameter::Kind as O; + if !matches!( + (kind, p.kind), + (Kind::Rotate, O::Rotation) | (Kind::Flip, O::Flip) + ) { + continue; + } + result.push(Parameter { + handle: p.handle, + id: p.native.parameter.element.eval.node_id, + grip: Handle::NULL, + name: p.label, + base: v(p.native.definition_base_point), + end: v(p.native.definition_end_point), + linear: false, + point: false, + xy: false, + rotation: kind == Kind::Rotate, + flip: kind == Kind::Flip, + reference: v(p.reference), }); } // Resolve the actual linked grip, rather than assuming expression IDs are contiguous. result.retain_mut(|p| { - let grip_id = if p.point { + let grip_id = if p.rotation || p.flip { + let Some(ObjectType::DynamicBlock(o)) = doc.objects.get(&p.handle) else { + return false; + }; + match &o.data { + DynamicBlockData::RotationParameter(p) => p.parameter.property_states[1], + DynamicBlockData::FlipParameter(p) => p.flags_96, + _ => return false, + } + } else if p.point { super::block_point_parameter::parameters(doc, definition) .into_iter() .find(|(h, _)| *h == p.handle) @@ -121,7 +283,10 @@ pub(crate) fn parameters(doc: &CadDocument, definition: Handle, kind: Kind) -> V return None; } let grip = match &o.data { - DynamicBlockData::XYGrip(g) | DynamicBlockData::PolarGrip(g) => g, + DynamicBlockData::XYGrip(g) + | DynamicBlockData::PolarGrip(g) + | DynamicBlockData::RotationGrip(g) => g, + DynamicBlockData::FlipGrip(g) => &g.grip, DynamicBlockData::LinearGrip(g) => &g.grip, _ => return None, }; @@ -136,6 +301,19 @@ pub(crate) fn parameters(doc: &CadDocument, definition: Handle, kind: Kind) -> V return false; } p.grip = grips[0]; + if p.flip { + if let Some(ObjectType::DynamicBlock(o)) = doc.objects.get(&p.grip) { + if let DynamicBlockData::FlipGrip(g) = &o.data { + let at = v(g.grip.location); + if at.distance(p.base) > 1e-8 { + p.reference = at; + } + } + } + if p.reference.distance(p.base) < 1e-8 { + p.reference = p.end; + } + } true }); result.sort_by_key(|p| p.id); @@ -192,6 +370,8 @@ pub(crate) fn create( let (dxf, cpp) = match kind { Kind::Move => ("BLOCKMOVEACTION", "AcDbBlockMoveAction"), Kind::Scale => ("BLOCKSCALEACTION", "AcDbBlockScaleAction"), + Kind::Rotate => ("BLOCKROTATEACTION", "AcDbBlockRotationAction"), + Kind::Flip => ("BLOCKFLIPACTION", "AcDbBlockFlipAction"), }; register_classes(&mut staged, &[(dxf, cpp, 55)]); let handle = staged.allocate_handle(); @@ -228,6 +408,26 @@ pub(crate) fn create( flags: 0, }, }), + Kind::Flip => DynamicBlockData::FlipAction(BlockFlipAction { + action, + connections: [ + connection("Flip"), + connection("UpdatedFlip"), + connection("UpdatedBase"), + connection("UpdatedEnd"), + ], + }), + Kind::Rotate => DynamicBlockData::RotateAction(BlockBasePointAction { + action: BlockActionWithBasePoint { + action, + offset: Vector3::ZERO, + connections: vec![connection("UpdatedBaseX"), connection("UpdatedBaseY")], + dependent: true, + base_point: Vector3::ZERO, + }, + connections: vec![connection("AngleDelta")], + dwg_tail_byte: 0, + }), Kind::Scale => DynamicBlockData::ScaleAction(BlockBasePointAction { action: BlockActionWithBasePoint { action, @@ -280,6 +480,13 @@ pub(crate) fn actions( DynamicBlockData::MoveAction(a) => { Some((o.handle, &a.action, Kind::Move, a.connections[0].code)) } + DynamicBlockData::FlipAction(a) => { + Some((o.handle, &a.action, Kind::Flip, a.connections[0].code)) + } + DynamicBlockData::RotateAction(a) => a + .connections + .first() + .map(|c| (o.handle, &a.action.action, Kind::Rotate, c.code)), DynamicBlockData::ScaleAction(a) => a .connections .first() @@ -302,7 +509,10 @@ pub(crate) fn reframe(doc: &mut CadDocument, definition: Handle, t: &Transform) DynamicBlockData::MoveAction(a) => { a.action.display_location = t.apply(a.action.display_location) } - DynamicBlockData::ScaleAction(a) => { + DynamicBlockData::FlipAction(a) => { + a.action.display_location = t.apply(a.action.display_location) + } + DynamicBlockData::ScaleAction(a) | DynamicBlockData::RotateAction(a) => { a.action.action.display_location = t.apply(a.action.action.display_location); a.action.base_point = t.apply(a.action.base_point); a.action.offset = t.apply_rotation(a.action.offset); @@ -318,7 +528,9 @@ pub(crate) fn current(doc: &CadDocument, p: &Parameter) -> DVec3 { return p.end; }; match &o.data { - DynamicBlockData::XYGrip(g) | DynamicBlockData::PolarGrip(g) => v(g.location), + DynamicBlockData::XYGrip(g) + | DynamicBlockData::PolarGrip(g) + | DynamicBlockData::RotationGrip(g) => v(g.location), DynamicBlockData::LinearGrip(g) => v(g.grip.location), _ => p.end, } @@ -372,54 +584,71 @@ pub(crate) fn sync_representation( /// Re-evaluate from the scratch document's immutable starting geometry. This /// avoids incremental drift and makes repeated changes/order independent. +#[cfg(test)] pub(crate) fn evaluate( doc: &mut CadDocument, baseline: &CadDocument, definition: Handle, parameter: Handle, position: DVec3, +) -> Result { + evaluate_value(doc, baseline, definition, parameter, Value::Point(position)) +} + +pub(crate) fn evaluate_value( + doc: &mut CadDocument, + baseline: &CadDocument, + definition: Handle, + parameter: Handle, + input: Value, ) -> Result { let graph = authoring::graph(baseline, definition)? .ok_or("The block authoring graph is unavailable.")?; if baseline.objects.values().any(|o|matches!(o,ObjectType::DynamicBlock(o) if o.owner==graph && matches!(&o.data, - DynamicBlockData::StretchAction(_)|DynamicBlockData::PolarStretchAction(_)|DynamicBlockData::RotateAction(_)|DynamicBlockData::FlipAction(_)|DynamicBlockData::ArrayAction(_)))) { + DynamicBlockData::StretchAction(_)|DynamicBlockData::PolarStretchAction(_)|DynamicBlockData::ArrayAction(_)))) { return Err("This block contains action types not supported yet."); } - let p = parameters(baseline, definition, Kind::Move) + let p = all_parameters(baseline, definition) .into_iter() .find(|p| p.handle == parameter) .ok_or("The block parameter is unavailable.")?; - if !position.is_finite() { - return Err("Specify a finite parameter position."); - } - let mut position = position; - position.z = p.end.z; - if p.linear { - let axis = (p.end - p.base).normalize(); - let length = (position - p.base).dot(axis); - if length < 1e-9 { - return Err("Specify a positive parameter length."); + let input = match input { + Value::Flip(_) if !p.flip => return Err("Select a Flip parameter."), + Value::Point(_) if p.flip => return Err("Use the Flip grip to toggle its state."), + Value::Point(mut position) => { + if !position.is_finite() { + return Err("Specify a finite parameter position."); + } + position.z = p.end.z; + if p.linear { + let axis = (p.end - p.base).normalize(); + let length = (position - p.base).dot(axis); + if length < 1e-9 { + return Err("Specify a positive parameter length."); + } + position = p.base + axis * length; + } + if p.rotation { + let delta = position - p.base; + if delta.length() < 1e-9 { + return Err("Specify a rotation direction away from the parameter base."); + } + position = p.base + delta.normalize() * p.end.distance(p.base); + } + Value::Point(position) } - position = p.base + axis * length; - } - if current(doc, &p).distance(position) < 1e-10 { + value => value, + }; + let previous = value(doc, &p)?; + if match (previous, input) { + (Value::Flip(a), Value::Flip(b)) => a == b, + (Value::Point(a), Value::Point(b)) => a.distance(b) < 1e-10, + _ => false, + } { return Ok(false); } let mut staged = doc.clone(); - let ObjectType::DynamicBlock(o) = staged - .objects - .get_mut(&p.grip) - .ok_or("The parameter grip is unavailable.")? - else { - return Err("The parameter grip is unavailable."); - }; - match &mut o.data { - DynamicBlockData::XYGrip(g) | DynamicBlockData::PolarGrip(g) => { - g.location = native(position) - } - DynamicBlockData::LinearGrip(g) => g.grip.location = native(position), - _ => return Err("The parameter grip is unavailable."), - } + set_value(&mut staged, &p, input)?; let all = actions(baseline, definition); let mut affected = std::collections::BTreeSet::new(); for (_, a, _, _) in &all { @@ -445,12 +674,32 @@ pub(crate) fn evaluate( .into_iter() .find(|p| p.id == *id) .ok_or("An action has an unsupported parameter binding.")?; - if !test_parameter_supported(baseline, q.handle) { + if !supported_parameter(baseline, q.handle) { return Err("This parameter uses value constraints or chaining not supported yet."); } let end = current(&staged, &q); let transform = match kind { Kind::Move => crate::command::EntityTransform::Translate(end - q.end), + Kind::Rotate => { + let a = end - q.base; + let b = q.end - q.base; + crate::command::EntityTransform::Rotate { + center: q.base, + axis: DVec3::Z, + angle_rad: a.y.atan2(a.x) - b.y.atan2(b.x), + } + } + Kind::Flip => { + if value(&staged, &q)? == Value::Flip(false) { + crate::command::EntityTransform::Translate(DVec3::ZERO) + } else { + crate::command::EntityTransform::Mirror { + p1: q.base, + p2: q.end, + working_normal: DVec3::Z, + } + } + } Kind::Scale => { let factor = end.distance(q.base) / q.end.distance(q.base); if !factor.is_finite() || factor < 1e-9 { @@ -502,6 +751,33 @@ pub(crate) fn evaluate( { return Err("This Move action uses options not supported yet.") } + DynamicBlockData::FlipAction(f) + if f.connections + != ["Flip", "UpdatedFlip", "UpdatedBase", "UpdatedEnd"].map(|name| { + BlockConnection { + code: q.id, + name: name.into(), + } + }) => + { + return Err("This Flip action uses options not supported yet.") + } + DynamicBlockData::RotateAction(r) + if !r.action.dependent + || r.action.offset != Vector3::ZERO + || r.connections + != vec![BlockConnection { + code: q.id, + name: "AngleDelta".into(), + }] + || r.action.connections + != ["UpdatedBaseX", "UpdatedBaseY"].map(|name| BlockConnection { + code: q.id, + name: name.into(), + }) => + { + return Err("This Rotate action uses options not supported yet.") + } DynamicBlockData::ScaleAction(s) if !s.action.dependent || s.action.offset != Vector3::ZERO @@ -534,7 +810,7 @@ pub(crate) fn evaluate( Ok(true) } -fn test_parameter_supported(doc: &CadDocument, handle: Handle) -> bool { +fn supported_parameter(doc: &CadDocument, handle: Handle) -> bool { let Some(ObjectType::DynamicBlock(o)) = doc.objects.get(&handle) else { return false; }; @@ -546,6 +822,15 @@ fn test_parameter_supported(doc: &CadDocument, handle: Handle) -> bool { && s.values.is_empty() }; match &o.data { + DynamicBlockData::FlipParameter(p) => !p.parameter.parameter.chain_actions, + DynamicBlockData::RotationParameter(p) => { + !p.parameter.parameter.chain_actions + && p.value_set.flags == 3 + && p.value_set.minimum == 0. + && p.value_set.maximum == std::f64::consts::TAU + && p.value_set.increment == 0. + && p.value_set.values.is_empty() + } DynamicBlockData::PointParameter(p) => !p.parameter.parameter.chain_actions, DynamicBlockData::LinearParameter(p) => { !p.parameter.parameter.chain_actions && distance(&p.value_set) diff --git a/src/scene/block_actions/instance.rs b/src/scene/block_actions/instance.rs index 57d7518e0..60fa7f9bb 100644 --- a/src/scene/block_actions/instance.rs +++ b/src/scene/block_actions/instance.rs @@ -50,6 +50,13 @@ pub(crate) fn current( .filter_map(|e| e.value.as_point3d()) .map(|(x, y, z)| DVec3::new(x, y, z)) .collect::>(); + if p.rotation + && points + .get(3) + .is_none_or(|r| !r.is_finite() || r.distance(p.reference) > 1e-8) + { + return Err("The rotation reference point is unavailable or has been relocated."); + } if p.xy { let (Some(a), Some(b)) = (points.first(), points.get(1)) else { return Err("The XY parameter cache has no finite endpoint."); @@ -76,6 +83,48 @@ pub(crate) fn current( .ok_or("The reference parameter cache has no finite endpoint.") } +pub(crate) fn value( + doc: &CadDocument, + insert: Handle, + p: &Parameter, +) -> Result { + if !p.flip { + return current(doc, insert, p).map(Value::Point); + } + let Some(h) = cache(doc, insert, p.id)? else { + return Ok(Value::Flip(false)); + }; + let Some(ObjectType::XRecord(r)) = doc.objects.get(&h) else { + return Err("Invalid Flip parameter record."); + }; + if !r.entries_complete { + return Err("The Flip parameter cache is incomplete."); + } + let points = r + .entries + .iter() + .filter_map(|e| e.value.as_point3d()) + .map(|(x, y, z)| DVec3::new(x, y, z)) + .collect::>(); + if points.len() < 2 + || !points[0].is_finite() + || !points[1].is_finite() + || points[0].distance(p.base) > 1e-8 + || points[1].distance(p.end) > 1e-8 + { + return Err("This reference uses an unsupported Flip reflection line."); + } + let state = r.entries.iter().skip(4).rev().find_map(|e| match e.value { + XRecordValue::Int16(s) => Some(s), + _ => None, + }); + match state { + Some(0) => Ok(Value::Flip(false)), + Some(1) => Ok(Value::Flip(true)), + _ => Err("The Flip state is not zero or one."), + } +} + fn ensure_dictionary( doc: &mut CadDocument, owner: Handle, @@ -103,7 +152,7 @@ fn save( doc: &mut CadDocument, insert: Handle, p: &Parameter, - position: DVec3, + value: Value, ) -> Result<(), &'static str> { let mut owner = doc.ensure_extension_dictionary(insert); for key in [ @@ -137,7 +186,9 @@ fn save( DVec3::new(p.end.x, p.base.y, p.end.z), -DVec3::Z, ] - } else if !p.linear { + } else if p.rotation { + vec![p.base, p.end, -DVec3::Z, p.reference] + } else if !p.linear && !p.flip { vec![p.base, p.end, -DVec3::Z, p.end] } else { vec![p.base, p.end, -DVec3::Z] @@ -146,6 +197,9 @@ fn save( r.entries .push(XRecordEntry::new(10, XRecordValue::Point3D(v.x, v.y, v.z))); } + if p.flip { + r.entries.push(XRecordEntry::int16(70, 0)); + } doc.objects.insert(h, ObjectType::XRecord(r)); if let Some(ObjectType::Dictionary(d)) = doc.objects.get_mut(&owner) { d.add_entry(&key, h); @@ -155,6 +209,22 @@ fn save( let Some(ObjectType::XRecord(r)) = doc.objects.get_mut(&h) else { return Err("Invalid parameter record."); }; + if let Value::Flip(state) = value { + let entry = r + .entries + .iter_mut() + .skip(4) + .rev() + .find(|e| matches!(e.value, XRecordValue::Int16(_))) + .ok_or("The Flip cache has no state.")?; + entry.value = XRecordValue::Int16(i16::from(state)); + r.raw_data.clear(); + r.raw_dwg_data = None; + return Ok(()); + } + let Value::Point(position) = value else { + unreachable!() + }; if p.xy { let mut points = r .entries @@ -199,37 +269,38 @@ pub(crate) fn definition(doc: &CadDocument, insert: Handle) -> Option { } /// Stage and validate completely before exposing any changes to the drawing. +#[cfg(test)] pub(crate) fn evaluate( doc: &CadDocument, insert: Handle, parameter: Handle, local: DVec3, +) -> Result, &'static str> { + evaluate_value(doc, insert, parameter, Value::Point(local)) +} + +pub(crate) fn evaluate_value( + doc: &CadDocument, + insert: Handle, + parameter: Handle, + input: Value, ) -> Result, &'static str> { let def = definition(doc, insert).ok_or("Select a uniformly scaled dynamic block reference.")?; - let parameters = parameters(doc, def, Kind::Move); + let parameters = all_parameters(doc, def); let selected = parameters .iter() .find(|p| p.handle == parameter) .ok_or("The reference parameter is unavailable.")?; let mut evaluated = doc.clone(); for p in ¶meters { - let value = current(doc, insert, p)?; - let Some(ObjectType::DynamicBlock(o)) = evaluated.objects.get_mut(&p.grip) else { - return Err("Missing parameter grip."); - }; - match &mut o.data { - DynamicBlockData::XYGrip(g) | DynamicBlockData::PolarGrip(g) => { - g.location = native(value) - } - DynamicBlockData::LinearGrip(g) => g.grip.location = native(value), - _ => return Err("Unsupported parameter grip."), - } + let value = value(doc, insert, p)?; + super::set_value(&mut evaluated, p, value)?; } - if !super::evaluate(&mut evaluated, doc, def, parameter, local)? { + if !super::evaluate_value(&mut evaluated, doc, def, parameter, input)? { return Ok(None); } - let value = super::current(&evaluated, selected); + let value = super::value(&evaluated, selected)?; let mut scene = crate::scene::Scene::new(); scene.document = doc.clone(); scene.dynamic_representation(insert); diff --git a/src/scene/block_actions/tests.rs b/src/scene/block_actions/tests.rs index 2ffbf4057..7eaf14d55 100644 --- a/src/scene/block_actions/tests.rs +++ b/src/scene/block_actions/tests.rs @@ -23,6 +23,207 @@ fn fixture() -> (CadDocument, Handle, Vec) { let (definition, handles) = (block.handle, block.entity_handles.clone()); (scene.document, definition, handles) } + +fn orientation( + doc: &mut CadDocument, + def: Handle, + kind: super::super::block_orientation_parameter::Kind, +) -> Handle { + super::super::block_orientation_parameter::create( + doc, + def, + kind, + Vector3::ZERO, + Vector3::new(10., 0., 0.), + if kind == super::super::block_orientation_parameter::Kind::Rotation { + Vector3::new(10., 0., 0.) + } else { + Vector3::new(-2., 2., 0.) + }, + ) + .unwrap() +} + +#[test] +fn block_actions_rotate_flip_native_bindings_and_marker_edits_roundtrip() { + use super::super::block_orientation_parameter::Kind as O; + let (mut doc, def, targets) = fixture(); + let r = orientation(&mut doc, def, O::Rotation); + let f = orientation(&mut doc, def, O::Flip); + assert!(parameters(&doc, def, Kind::Move).is_empty()); + assert!(parameters(&doc, def, Kind::Scale).is_empty()); + assert_eq!(all_parameters(&doc, def).len(), 2); + let rotate = create( + &mut doc, + def, + Kind::Rotate, + r, + &targets, + DVec3::new(3., 4., 0.), + ) + .unwrap(); + let flip = create( + &mut doc, + def, + Kind::Flip, + f, + &targets, + DVec3::new(5., 6., 0.), + ) + .unwrap(); + assert!(create(&mut doc, def, Kind::Rotate, f, &targets, DVec3::ZERO).is_err()); + for handle in [rotate, flip] { + super::super::block_authoring::edit::move_part( + &mut doc, + def, + handle, + super::super::block_authoring::edit::Part::Point, + Vector3::new(7., 8., 0.), + ) + .unwrap(); + } + for saved in [ + DwgReader::from_stream(Cursor::new(DwgWriter::write_to_vec(&doc).unwrap())) + .read() + .unwrap(), + DxfReader::from_reader(Cursor::new(DxfWriter::new(&doc).write_to_vec().unwrap())) + .unwrap() + .read() + .unwrap(), + ] { + assert_eq!(actions(&saved, def).len(), 2); + for handle in [rotate, flip] { + assert_eq!(saved.objects[&handle], doc.objects[&handle]); + } + for (_, action, _, _) in actions(&saved, def) { + assert_eq!(action.display_location, Vector3::new(7., 8., 0.)); + assert_eq!(action.dependencies, targets); + } + } +} + +#[test] +fn block_actions_rotate_flip_saved_values_compose_copy_and_roundtrip() { + use super::super::block_orientation_parameter::Kind as O; + let (mut doc, def, targets) = fixture(); + let r = orientation(&mut doc, def, O::Rotation); + let f = orientation(&mut doc, def, O::Flip); + create(&mut doc, def, Kind::Rotate, r, &[targets[0]], DVec3::ZERO).unwrap(); + create(&mut doc, def, Kind::Flip, f, &[targets[0]], DVec3::ZERO).unwrap(); + let a = doc + .add_entity(EntityType::Insert(codec::entities::Insert::new( + "ACTIONS", + Vector3::ZERO, + ))) + .unwrap(); + let b = doc + .add_entity(EntityType::Insert(codec::entities::Insert::new( + "ACTIONS", + Vector3::new(30., 0., 0.), + ))) + .unwrap(); + let baseline = doc.clone(); + doc = instance::evaluate(&doc, a, r, DVec3::new(0., 50., 0.)) + .unwrap() + .unwrap(); + assert!(instance_line(&doc, a).distance(&Vector3::UNIT_Y) < 1e-8); + doc = instance::evaluate_value(&doc, a, f, Value::Flip(true)) + .unwrap() + .unwrap(); + assert!(instance_line(&doc, a).distance(&-Vector3::UNIT_Y) < 1e-8); + assert!(instance::evaluate_value(&doc, a, f, Value::Flip(true)) + .unwrap() + .is_none()); + let props = doc.dynamic_block_properties(def, Some(a)).unwrap(); + assert!(props + .iter() + .any(|p| p.parameter_handle == f && p.value == BlockEvalValue::Short(1))); + assert!(props.iter().any(|p|p.parameter_handle==r&&matches!(p.value,BlockEvalValue::Real(v) if (v-std::f64::consts::FRAC_PI_2).abs()<1e-8))); + let variants = [ + doc.clone(), + DwgReader::from_stream(Cursor::new(DwgWriter::write_to_vec(&doc).unwrap())) + .read() + .unwrap(), + DxfReader::from_reader(Cursor::new(DxfWriter::new(&doc).write_to_vec().unwrap())) + .unwrap() + .read() + .unwrap(), + ]; + for saved in variants { + let mut next = instance::evaluate(&saved, a, r, DVec3::new(0., -50., 0.)) + .unwrap() + .unwrap(); + assert!(instance_line(&next, a).distance(&Vector3::UNIT_Y) < 1e-8); + for _ in 0..6 { + next = instance::evaluate_value(&next, a, f, Value::Flip(false)) + .unwrap() + .unwrap(); + assert!(instance_line(&next, a).distance(&-Vector3::UNIT_Y) < 1e-8); + next = instance::evaluate_value(&next, a, f, Value::Flip(true)) + .unwrap() + .unwrap(); + assert!(instance_line(&next, a).distance(&Vector3::UNIT_Y) < 1e-8); + } + assert_eq!(instance_line(&next, b), Vector3::UNIT_X); + assert_eq!(start(&next, targets[0]), start(&baseline, targets[0])); + let mut scene = Scene::new(); + scene.document = next; + let c = scene.copy_entities( + &[a], + &crate::command::EntityTransform::Translate(DVec3::new(40., 0., 0.)), + )[0]; + let next = instance::evaluate_value(&scene.document, c, f, Value::Flip(false)) + .unwrap() + .unwrap(); + assert!(instance_line(&next, c).distance(&-Vector3::UNIT_Y) < 1e-8); + assert!(instance_line(&next, a).distance(&Vector3::UNIT_Y) < 1e-8); + let resaved = DwgReader::from_stream(Cursor::new(DwgWriter::write_to_vec(&next).unwrap())) + .read() + .unwrap(); + assert!(instance_line(&resaved, c).distance(&-Vector3::UNIT_Y) < 1e-8); + let fp = parameters(&resaved, def, Kind::Flip).remove(0); + assert_eq!( + instance::value(&resaved, c, &fp).unwrap(), + Value::Flip(false) + ); + assert_eq!( + instance::value(&resaved, a, &fp).unwrap(), + Value::Flip(true) + ); + } +} + +#[test] +fn block_actions_rotate_flip_test_baseline_reversible_and_invalid_atomic() { + use super::super::block_orientation_parameter::Kind as O; + let (mut doc, def, targets) = fixture(); + let r = orientation(&mut doc, def, O::Rotation); + let f = orientation(&mut doc, def, O::Flip); + let action = create(&mut doc, def, Kind::Rotate, r, &targets, DVec3::ZERO).unwrap(); + create(&mut doc, def, Kind::Flip, f, &targets, DVec3::ZERO).unwrap(); + let baseline = doc.clone(); + assert!(evaluate(&mut doc, &baseline, def, r, DVec3::ZERO).is_err()); + assert_eq!(doc, baseline); + assert!(evaluate_value(&mut doc, &baseline, def, r, Value::Flip(true)).is_err()); + assert!(evaluate_value(&mut doc, &baseline, def, f, Value::Point(DVec3::ONE)).is_err()); + assert_eq!(doc, baseline); + evaluate(&mut doc, &baseline, def, r, DVec3::new(0., 10., 0.)).unwrap(); + evaluate_value(&mut doc, &baseline, def, f, Value::Flip(true)).unwrap(); + assert!(start(&doc, targets[0]).distance(&-Vector3::UNIT_Y) < 1e-8); + evaluate_value(&mut doc, &baseline, def, f, Value::Flip(false)).unwrap(); + evaluate(&mut doc, &baseline, def, r, DVec3::new(10., 0., 0.)).unwrap(); + assert!(start(&doc, targets[0]).distance(&Vector3::UNIT_X) < 1e-8); + let ObjectType::DynamicBlock(o) = doc.objects.get_mut(&action).unwrap() else { + panic!() + }; + let DynamicBlockData::RotateAction(a) = &mut o.data else { + panic!() + }; + a.action.dependent = false; + let unsupported = doc.clone(); + assert!(evaluate(&mut doc, &unsupported, def, r, DVec3::new(0., 10., 0.)).is_err()); + assert_eq!(doc, unsupported); +} fn linear(doc: &mut CadDocument, def: Handle) -> Handle { distance::create( doc, diff --git a/src/scene/block_authoring/edit.rs b/src/scene/block_authoring/edit.rs index 47dfbb4ae..8a8764a06 100644 --- a/src/scene/block_authoring/edit.rs +++ b/src/scene/block_authoring/edit.rs @@ -277,7 +277,10 @@ pub fn move_part( } } DynamicBlockData::MoveAction(p) => p.action.display_location = at, - DynamicBlockData::ScaleAction(p) => p.action.action.display_location = at, + DynamicBlockData::FlipAction(p) => p.action.display_location = at, + DynamicBlockData::ScaleAction(p) | DynamicBlockData::RotateAction(p) => { + p.action.action.display_location = at + } DynamicBlockData::AlignmentParameter(_) => {} _ => return Err("This authoring object cannot be edited yet."), } diff --git a/src/ui/window/authoring_palettes.rs b/src/ui/window/authoring_palettes.rs index 348c80a76..fced5729f 100644 --- a/src/ui/window/authoring_palettes.rs +++ b/src/ui/window/authoring_palettes.rs @@ -196,6 +196,8 @@ fn catalog( lookup_error: Option<&'static str>, move_error: Option<&'static str>, scale_error: Option<&'static str>, + rotate_error: Option<&'static str>, + flip_action_error: Option<&'static str>, ) -> Element<'static, Message> { let (entries, unavailable) = match tab { Tab::Parameters => ( @@ -217,8 +219,8 @@ fn catalog( let orientation_error = if *label=="Flip" {flip_error} else {point_error}; let single=tab==Tab::Parameters&&matches!(*label,"Lookup"|"Basepoint"); let lookup_action=tab==Tab::Actions&&*label=="Lookup"; - let geometry_action=tab==Tab::Actions&&matches!(*label,"Move"|"Scale"); - let action_error=if *label=="Scale" {scale_error}else{move_error}; + let geometry_action=tab==Tab::Actions&&matches!(*label,"Move"|"Scale"|"Rotate"|"Flip"); + let action_error=match *label {"Scale"=>scale_error,"Rotate"=>rotate_error,"Flip"=>flip_action_error,_=>move_error}; let enabled = (geometry_action&&action_error.is_none())||(single&&point_error.is_none())||(lookup_action&&lookup_error.is_none())||(orientation && orientation_error.is_none()) || (visibility && visibility_error.is_none()) || ((point || distance) && point_error.is_none()); let base = if enabled { icons::semantic(icon, 28.0) @@ -264,7 +266,7 @@ fn catalog( } else if single { crate::t!(point_error.unwrap_or(if *label=="Basepoint" {"Place or relocate the dynamic block insertion anchor."}else{"Place a Lookup parameter, then use the Lookup action to edit its property table."})).into_owned() } else if geometry_action { - crate::t!(action_error.unwrap_or("Select a parameter endpoint, select the objects it controls, then place the action marker. Test it using the parameter grip in Test Block.")).into_owned() + crate::t!(action_error.unwrap_or("Select a parameter endpoint, select the objects it controls, then place the action marker. Use the reference parameter grip in the drawing or Test Block.")).into_owned() } else if lookup_action { crate::t!(lookup_error.unwrap_or("Create or edit property Lookup tables for this block.")).into_owned() } else { @@ -282,7 +284,7 @@ fn catalog( } else if single { container(entry).id(if *label=="Lookup" {"authoring-tool-lookup"}else{"authoring-tool-basepoint"}).into() } else if geometry_action { - container(entry).id(if *label=="Move" {"authoring-tool-move-action"}else{"authoring-tool-scale-action"}).into() + container(entry).id(match *label {"Move"=>"authoring-tool-move-action","Scale"=>"authoring-tool-scale-action","Rotate"=>"authoring-tool-rotate-action",_=>"authoring-tool-flip-action"}).into() } else if lookup_action { container(entry).id("authoring-tool-lookup-table").into() } else { @@ -401,6 +403,8 @@ pub fn view( lookup_error: Option<&'static str>, move_error: Option<&'static str>, scale_error: Option<&'static str>, + rotate_error: Option<&'static str>, + flip_action_error: Option<&'static str>, ) -> Element<'static, Message> { let tabs = column(Tab::ALL.into_iter().map(|category| { let label = category.label(); @@ -457,7 +461,9 @@ pub fn view( flip_error, lookup_error, move_error, - scale_error + scale_error, + rotate_error, + flip_action_error )) .width(Fill) .height(Fill),