diff --git a/Cargo.lock b/Cargo.lock index 3b1aa45d3..bfb39bf61 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -921,7 +921,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cadkernel" version = "0.1.0" -source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=24fd86a#24fd86aafba83ee1d9a80dc6fd4f6d510d49eb30" +source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=d2a288b#d2a288b5f397ad7941719119f216a048d6cdcc6f" dependencies = [ "acadrust", "cavalier_contours", @@ -932,7 +932,7 @@ dependencies = [ [[package]] name = "cadkernel-constraints" version = "0.1.0" -source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=24fd86a#24fd86aafba83ee1d9a80dc6fd4f6d510d49eb30" +source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=d2a288b#d2a288b5f397ad7941719119f216a048d6cdcc6f" dependencies = [ "nalgebra", ] diff --git a/Cargo.toml b/Cargo.toml index dbd55db8d..062ce7acb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,8 +34,8 @@ clap = { version = "4", features = ["derive", "string"] } env_logger = "0.11" log = "0.4" acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "8a28c21", features = ["serde"] } -cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "24fd86a", features = ["acis", "offset"] } -cadkernel-constraints = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "24fd86a" } +cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "d2a288b", features = ["acis", "offset"] } +cadkernel-constraints = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "d2a288b" } dwg-thumbnailer = { path = "crates/dwg-thumbnailer" } flate2 = "1" image = { version = "0.25", default-features = false, features = ["png", "jpeg", "bmp", "tiff"] } diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index 8b60699cd..6de0b3768 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -8725,6 +8725,125 @@ mod parametric_constraint_undo_tests { ); } + #[test] + fn horizontal_initial_solve_uses_the_captured_axis_in_kernel() { + let mut app = OpenCADStudio::new_for_test(); + let _ = app.automation_op(r#"{"op":"new"}"#); + let handle = add_line(&mut app, 0.0, 0.0, 5.0, 2.0); + let (original_start, original_end, original_length) = match app.tabs[app.active_tab] + .scene + .document + .get_entity(handle) + { + Some(acadrust::EntityType::Line(line)) => (line.start, line.end, line.length()), + other => panic!("expected a Line, got {other:?}"), + }; + let direction = acadrust::types::Vector3::new(3.0, 4.0, 0.0).normalize(); + + let _ = app.apply_cmd_result(CmdResult::AddHorizontalConstraint { + selection: crate::command::HorizontalConstraintSelection::Reference( + ParametricRef::whole(handle), + ), + direction, + label: "Horizontal constraint", + }); + + let line = match app.tabs[app.active_tab].scene.document.get_entity(handle) { + Some(acadrust::EntityType::Line(line)) => line, + other => panic!("expected a Line, got {other:?}"), + }; + let solved = (line.end - line.start).normalize(); + assert!(solved.cross(&direction).length() < 1.0e-7); + assert!((line.length() - original_length).abs() < 1.0e-7); + let constraint = &app.tabs[app.active_tab] + .scene + .parametric_constraint_set(ParametricScope::ModelSpace) + .unwrap() + .constraints[0]; + assert_eq!(constraint.axis_direction, Some(direction)); + + app.undo_steps(1); + let line = match app.tabs[app.active_tab].scene.document.get_entity(handle) { + Some(acadrust::EntityType::Line(line)) => line, + other => panic!("expected a Line after undo, got {other:?}"), + }; + assert_eq!((line.start, line.end), (original_start, original_end)); + assert_eq!( + app.tabs[app.active_tab] + .scene + .parametric_constraint_set(ParametricScope::ModelSpace) + .map(|set| set.constraints.len()) + .unwrap_or(0), + 0 + ); + + app.redo_steps(1); + let line = match app.tabs[app.active_tab].scene.document.get_entity(handle) { + Some(acadrust::EntityType::Line(line)) => line, + other => panic!("expected a Line after redo, got {other:?}"), + }; + assert!((line.end - line.start).normalize().cross(&direction).length() < 1.0e-7); + } + + #[test] + fn horizontal_two_point_solve_keeps_the_first_point_fixed() { + let mut app = OpenCADStudio::new_for_test(); + let _ = app.automation_op(r#"{"op":"new"}"#); + let first = add_line(&mut app, 0.0, 0.0, 0.0, 5.0); + let second = add_line(&mut app, 8.0, 3.0, 8.0, 7.0); + let pick = |handle, point| crate::command::CoincidentPick { + handle: Some(handle), + point, + whole_curve: false, + }; + + let _ = app.apply_cmd_result(CmdResult::AddHorizontalConstraint { + selection: crate::command::HorizontalConstraintSelection::Points( + pick(first, glam::DVec3::ZERO), + pick(second, glam::DVec3::new(8.0, 3.0, 0.0)), + ), + direction: acadrust::types::Vector3::UNIT_X, + label: "Horizontal constraint", + }); + + let line = |handle| match app.tabs[app.active_tab].scene.document.get_entity(handle) { + Some(acadrust::EntityType::Line(line)) => line, + other => panic!("expected a Line, got {other:?}"), + }; + assert_eq!(line(first).start, acadrust::types::Vector3::ZERO); + assert!(line(second).start.y.abs() < 1.0e-8); + } + + #[test] + fn horizontal_minor_axis_rotates_an_ellipse_around_its_center() { + let mut app = OpenCADStudio::new_for_test(); + let _ = app.automation_op(r#"{"op":"new"}"#); + let mut ellipse = acadrust::entities::Ellipse::default(); + ellipse.center = acadrust::types::Vector3::new(3.0, 7.0, 0.0); + ellipse.major_axis = acadrust::types::Vector3::new(3.0, 4.0, 0.0); + ellipse.minor_axis_ratio = 0.4; + let handle = app.tabs[app.active_tab] + .scene + .add_entity(acadrust::EntityType::Ellipse(ellipse)); + + let _ = app.apply_cmd_result(CmdResult::AddHorizontalConstraint { + selection: crate::command::HorizontalConstraintSelection::Reference( + ParametricRef::ellipse_minor_axis(handle), + ), + direction: acadrust::types::Vector3::UNIT_X, + label: "Horizontal constraint", + }); + + let ellipse = match app.tabs[app.active_tab].scene.document.get_entity(handle) { + Some(acadrust::EntityType::Ellipse(ellipse)) => ellipse, + other => panic!("expected an Ellipse, got {other:?}"), + }; + assert!((ellipse.center - acadrust::types::Vector3::new(3.0, 7.0, 0.0)).length() < 1.0e-9); + assert!(ellipse.major_axis.x.abs() < 1.0e-7); + assert!((ellipse.major_axis.length() - 5.0).abs() < 1.0e-7); + assert!((ellipse.minor_axis_ratio - 0.4).abs() < 1.0e-9); + } + /// Drawing commands must not create parametric constraints implicitly. #[test] fn drawing_a_line_onto_an_existing_endpoint_does_not_add_a_constraint() { diff --git a/src/app/commands/draw.rs b/src/app/commands/draw.rs index 381ce86c6..ac2bc6ff7 100644 --- a/src/app/commands/draw.rs +++ b/src/app/commands/draw.rs @@ -1339,7 +1339,7 @@ impl OpenCADStudio { } } - "HCONSTRAINT" | "GCHORIZONTAL" => { + "GCHORIZONTAL" => { use crate::command::{CmdResult, HorizontalConstraintSelection}; use crate::modules::parametric::HorizontalConstraintCommand; diff --git a/src/app/control/mod.rs b/src/app/control/mod.rs index 1f624ad96..3d44a7fb2 100644 --- a/src/app/control/mod.rs +++ b/src/app/control/mod.rs @@ -227,7 +227,7 @@ fn command_category(name: &str) -> &'static str { "GCPERPENDICULAR", "QCONSTRAINT", "PCONSTRAINT", - "HCONSTRAINT", + "GCHORIZONTAL", "VCONSTRAINT", "ECONSTRAINT", "TCONSTRAINT", diff --git a/src/command.rs b/src/command.rs index 2b0bebfa7..356048dd9 100644 --- a/src/command.rs +++ b/src/command.rs @@ -2795,7 +2795,7 @@ mod constraint_registry_tests { "CPCONSTRAINT", "MPCONSTRAINT", "OCCONSTRAINT", - "HCONSTRAINT", + "GCHORIZONTAL", "VCONSTRAINT", "PCONSTRAINT", "QCONSTRAINT", diff --git a/src/modules/parametric/horizontal.rs b/src/modules/parametric/horizontal.rs index c7fcaab1a..2b1b9f4ba 100644 --- a/src/modules/parametric/horizontal.rs +++ b/src/modules/parametric/horizontal.rs @@ -15,9 +15,7 @@ enum Step { SecondPoint(CoincidentPick), } -/// Reference-compatible front end for the Horizontal geometric constraint. -/// It deliberately uses entity picks rather than the generic selection set so -/// a polyline segment or one ellipse axis can be addressed unambiguously. +/// Interactive front end for the Horizontal geometric constraint. pub struct HorizontalConstraintCommand { step: Step, picked_entity: Option, @@ -55,8 +53,7 @@ impl HorizontalConstraintCommand { fn invalid_point() -> CmdResult { CmdResult::ReportError( - "Horizontal: select an endpoint, center, midpoint, or polyline vertex." - .to_string(), + "Horizontal: select an endpoint, center, midpoint, or polyline vertex.".to_string(), ) } @@ -75,32 +72,44 @@ impl HorizontalConstraintCommand { } fn distance_to_axis(point: DVec3, endpoints: [acadrust::types::Vector3; 2]) -> f64 { - let start = DVec3::new(endpoints[0].x, endpoints[0].y, endpoints[0].z); - let end = DVec3::new(endpoints[1].x, endpoints[1].y, endpoints[1].z); - let direction = end - start; - if direction.length_squared() <= 1.0e-24 { - return f64::INFINITY; - } - (point - start).cross(direction.normalize()).length() + cadkernel::space::Vec3::from(point.to_array()) + .distance_to_line( + cadkernel::space::Vec3::new(endpoints[0].x, endpoints[0].y, endpoints[0].z), + cadkernel::space::Vec3::new(endpoints[1].x, endpoints[1].y, endpoints[1].z), + ) + .unwrap_or(f64::INFINITY) } - fn ellipse_reference(entity: &EntityType, handle: Handle, point: DVec3) -> Option { + fn ellipse_reference( + entity: &EntityType, + handle: Handle, + point: DVec3, + ) -> Option { let major = ParametricRef::ellipse_major_axis(handle); let minor = ParametricRef::ellipse_minor_axis(handle); let major_distance = Self::distance_to_axis(point, directional_axis_endpoints(entity, major)?); let minor_distance = Self::distance_to_axis(point, directional_axis_endpoints(entity, minor)?); - Some(if minor_distance < major_distance { minor } else { major }) + Some(if minor_distance < major_distance { + minor + } else { + major + }) } - fn picked_reference(entity: &EntityType, handle: Handle, point: DVec3) -> Option { + fn picked_reference( + entity: &EntityType, + handle: Handle, + point: DVec3, + ) -> Option { match entity { EntityType::Line(_) | EntityType::Ray(_) | EntityType::XLine(_) => { Some(ParametricRef::whole(handle)) } EntityType::LwPolyline(_) | EntityType::Polyline2D(_) => { - let (source, _, _) = crate::scene::centerline::picked_source(entity, handle, point)?; + let (source, _, _) = + crate::scene::centerline::picked_source(entity, handle, point)?; let index = usize::try_from(source.segment_index).ok()?; Self::segment_is_straight(entity, index) .then_some(ParametricRef::segment(handle, index)) @@ -279,6 +288,56 @@ impl CadCommand for HorizontalConstraintCommand { } } +#[cfg(test)] +mod tests { + use super::*; + use acadrust::entities::Line; + use acadrust::types::Vector3; + + #[test] + fn object_pick_captures_the_working_x_direction() { + let handle = Handle::new(7); + let mut command = HorizontalConstraintCommand::new(); + command.set_working_plane(WorkingPlane::new(DVec3::ZERO, DVec3::Y, DVec3::NEG_X)); + command.inject_picked_entity(EntityType::Line(Line::from_points( + Vector3::ZERO, + Vector3::new(2.0, 3.0, 0.0), + ))); + + let CmdResult::AddHorizontalConstraint { + selection: HorizontalConstraintSelection::Reference(reference), + direction, + .. + } = command.on_entity_pick(handle, DVec3::ZERO) + else { + panic!("object pick must create the constraint"); + }; + assert_eq!(reference, ParametricRef::whole(handle)); + assert!((direction - Vector3::UNIT_Y).length() < 1.0e-12); + } + + #[test] + fn enter_starts_the_two_point_flow() { + let mut command = HorizontalConstraintCommand::new(); + assert!(matches!(command.on_enter(), CmdResult::NeedPoint)); + assert!(matches!( + command.on_point(DVec3::new(1.0, 2.0, 0.0)), + CmdResult::NeedPoint + )); + let CmdResult::AddHorizontalConstraint { + selection: HorizontalConstraintSelection::Points(first, second), + direction, + .. + } = command.on_point(DVec3::new(4.0, 5.0, 0.0)) + else { + panic!("second point must create the constraint"); + }; + assert_eq!(first.point, DVec3::new(1.0, 2.0, 0.0)); + assert_eq!(second.point, DVec3::new(4.0, 5.0, 0.0)); + assert!((direction - Vector3::UNIT_X).length() < 1.0e-12); + } +} + inventory::submit!(crate::command::CommandRegistration { names: &["GCHORIZONTAL"] }); diff --git a/src/modules/parametric/mod.rs b/src/modules/parametric/mod.rs index 6d6705d1b..e8dc57edc 100644 --- a/src/modules/parametric/mod.rs +++ b/src/modules/parametric/mod.rs @@ -176,7 +176,7 @@ mod tests { [ "AUTOCONSTRAIN", "CCONSTRAINT", "PCONSTRAINT", "TCONSTRAINT", "LCONSTRAINT", "QCONSTRAINT", "GCSMOOTH", "GCCONCENTRIC", - "HCONSTRAINT", "SYCONSTRAINT", "FXCONSTRAINT", "VCONSTRAINT", + "GCHORIZONTAL", "SYCONSTRAINT", "FXCONSTRAINT", "VCONSTRAINT", "ECONSTRAINT", "GCVISIBILITY", "GCSHOWALL", "GCHIDEALL", ] ); diff --git a/src/modules/parametric/perpendicular.rs b/src/modules/parametric/perpendicular.rs index 0bdd8a247..19f4d99ba 100644 --- a/src/modules/parametric/perpendicular.rs +++ b/src/modules/parametric/perpendicular.rs @@ -33,24 +33,37 @@ impl PerpendicularConstraintCommand { } fn distance_to_axis(point: DVec3, endpoints: [acadrust::types::Vector3; 2]) -> f64 { - let start = DVec3::new(endpoints[0].x, endpoints[0].y, endpoints[0].z); - let end = DVec3::new(endpoints[1].x, endpoints[1].y, endpoints[1].z); - let direction = end - start; - if direction.length_squared() <= 1.0e-24 { - return f64::INFINITY; - } - (point - start).cross(direction.normalize()).length() + cadkernel::space::Vec3::from(point.to_array()) + .distance_to_line( + cadkernel::space::Vec3::new(endpoints[0].x, endpoints[0].y, endpoints[0].z), + cadkernel::space::Vec3::new(endpoints[1].x, endpoints[1].y, endpoints[1].z), + ) + .unwrap_or(f64::INFINITY) } - fn ellipse_reference(entity: &EntityType, handle: Handle, point: DVec3) -> Option { + fn ellipse_reference( + entity: &EntityType, + handle: Handle, + point: DVec3, + ) -> Option { let major = ParametricRef::ellipse_major_axis(handle); let minor = ParametricRef::ellipse_minor_axis(handle); - let major_distance = Self::distance_to_axis(point, directional_axis_endpoints(entity, major)?); - let minor_distance = Self::distance_to_axis(point, directional_axis_endpoints(entity, minor)?); - Some(if minor_distance < major_distance { minor } else { major }) + let major_distance = + Self::distance_to_axis(point, directional_axis_endpoints(entity, major)?); + let minor_distance = + Self::distance_to_axis(point, directional_axis_endpoints(entity, minor)?); + Some(if minor_distance < major_distance { + minor + } else { + major + }) } - fn picked_reference(entity: &EntityType, handle: Handle, point: DVec3) -> Option { + fn picked_reference( + entity: &EntityType, + handle: Handle, + point: DVec3, + ) -> Option { let (reference, fixed_reference, start_reference) = match entity { EntityType::Line(_) => ( ParametricRef::whole(handle), @@ -58,7 +71,8 @@ impl PerpendicularConstraintCommand { ParametricRef::point(handle, 0), ), EntityType::LwPolyline(_) | EntityType::Polyline2D(_) => { - let (source, _, _) = crate::scene::centerline::picked_source(entity, handle, point)?; + let (source, _, _) = + crate::scene::centerline::picked_source(entity, handle, point)?; let index = usize::try_from(source.segment_index).ok()?; ( ParametricRef::segment(handle, index), @@ -240,10 +254,7 @@ mod tests { let handle = Handle::new(11); let mut polyline = acadrust::entities::LwPolyline::new(); polyline.vertices = vec![ - acadrust::entities::LwVertex::with_bulge( - acadrust::types::Vector2::new(0.0, 0.0), - 1.0, - ), + acadrust::entities::LwVertex::with_bulge(acadrust::types::Vector2::new(0.0, 0.0), 1.0), acadrust::entities::LwVertex::from_coords(4.0, 0.0), acadrust::entities::LwVertex::from_coords(8.0, 0.0), ]; diff --git a/src/modules/parametric/tools.rs b/src/modules/parametric/tools.rs index da0dc9bf0..4d2e8fd94 100644 --- a/src/modules/parametric/tools.rs +++ b/src/modules/parametric/tools.rs @@ -6,7 +6,7 @@ pub mod horizontal { use super::*; pub fn tool() -> ToolDef { ToolDef { - id: "HCONSTRAINT", + id: "GCHORIZONTAL", label: "Horizontal", icon: IconKind::Svg(include_bytes!( "../../../assets/icons/constrain/horizontal.svg" @@ -160,7 +160,6 @@ pub mod symmetric { // ── Autocomplete registry ───────────────────────────────── inventory::submit!(crate::command::CommandRegistration { names: &[ - "HCONSTRAINT", "GCHORIZONTAL", "VCONSTRAINT", "PCONSTRAINT", diff --git a/src/scene/dwg_native_constraints.rs b/src/scene/dwg_native_constraints.rs index dcb1a04e5..e36ca0c6d 100644 --- a/src/scene/dwg_native_constraints.rs +++ b/src/scene/dwg_native_constraints.rs @@ -1650,6 +1650,21 @@ fn group_requires_preservation(document: &CadDocument, group: &Assoc2dConstraint }) } +fn work_plane_vector(work_plane: &[Vector3; 3], local: Vector3) -> Option { + let [origin, axis_x, axis_y] = *work_plane; + let plane = cadkernel::space::Plane::from_axes( + [origin.x, origin.y, origin.z], + [axis_x.x, axis_x.y, axis_x.z], + [axis_y.x, axis_y.y, axis_y.z], + ); + let mut world = cadkernel::space::Vec3::from(plane.vector_at([local.x, local.y])); + if local.z != 0.0 { + world = world + cadkernel::space::Vec3::from(plane.normal()?) * local.z; + } + let world = world.normalize()?; + Some(Vector3::new(world.x, world.y, world.z)) +} + fn polyline_segment_reference( document: &CadDocument, entity: Handle, @@ -2258,17 +2273,10 @@ pub(super) fn native_constraint_set( }) }) }); - if let Some(local) = local_direction { - let [_, axis_x, axis_y] = group.work_plane; - let normal = Vector3::new( - axis_x.y * axis_y.z - axis_x.z * axis_y.y, - axis_x.z * axis_y.x - axis_x.x * axis_y.z, - axis_x.x * axis_y.y - axis_x.y * axis_y.x, - ); - let world = axis_x * local.x + axis_y * local.y + normal * local.z; - if world.length_squared() > 1.0e-24 { - constraint.axis_direction = Some(world.normalize()); - } + if let Some(world) = + local_direction.and_then(|local| work_plane_vector(&group.work_plane, local)) + { + constraint.axis_direction = Some(world); } } if let AssocConstraintNodeData::Distance { @@ -2901,14 +2909,15 @@ mod tests { fn horizontal_and_vertical_constraints_round_trip_through_dwg_and_dxf() { for ext in ["dwg", "dxf"] { let mut scene = Scene::new(); - let a = line_entity(&mut scene, (0.0, 0.0), (10.0, 0.0)); + let a = line_entity(&mut scene, (0.0, 0.0), (6.0, 8.0)); let b = line_entity(&mut scene, (10.0, 0.0), (10.0, 5.0)); + let horizontal_direction = Vector3::new(0.6, 0.8, 0.0); scene .parametric_constraint_set_mut(ParametricScope::ModelSpace) - .add( + .add_axis_constraint( ConstraintKind::Horizontal, vec![ParametricRef::whole(a)], - None, + horizontal_direction, ); scene .parametric_constraint_set_mut(ParametricScope::ModelSpace) @@ -2974,6 +2983,20 @@ mod tests { assert!(datums.iter().all(|datum| group.nodes.iter().any(|node| { node.node_id == *datum && node.class_name == "AcConstrainedDatumLine" }))); + + let mut restored = Scene::new(); + restored.document = reloaded; + restored.load_parametric_constraints_from_document(); + let horizontal = restored + .parametric_constraint_set(ParametricScope::ModelSpace) + .unwrap() + .constraints + .iter() + .find(|constraint| constraint.kind == ConstraintKind::Horizontal) + .unwrap(); + assert!( + (horizontal.axis_direction.unwrap() - horizontal_direction).length() < 1.0e-12 + ); } } diff --git a/src/scene/parametric_constraints.rs b/src/scene/parametric_constraints.rs index e9f8d8fa2..98d983f5c 100644 --- a/src/scene/parametric_constraints.rs +++ b/src/scene/parametric_constraints.rs @@ -1773,6 +1773,7 @@ impl super::Scene { ConstraintKind, Vec, Option, + Option, )> = Vec::new(); for (scope_index, set) in self.parametric_constraints.iter().enumerate() { for c in &set.constraints { @@ -1787,16 +1788,27 @@ impl super::Scene { marker: r.marker, }) .collect(); - to_add.push((scope_index, c.kind, new_refs, c.driving_param.clone())); + to_add.push(( + scope_index, + c.kind, + new_refs, + c.driving_param.clone(), + c.axis_direction, + )); } } if to_add.is_empty() { return; } let mut touched: Vec = Vec::new(); - for (scope_index, kind, refs, driving_param) in to_add { + for (scope_index, kind, refs, driving_param, axis_direction) in to_add { touched.extend(refs.iter().map(|r| r.entity)); self.parametric_constraints[scope_index].add(kind, refs, driving_param); + self.parametric_constraints[scope_index] + .constraints + .last_mut() + .expect("the copied constraint was just added") + .axis_direction = axis_direction; } touched.sort(); touched.dedup(); @@ -2435,4 +2447,37 @@ mod tests { vec![h(1), h(2), h(3)] ); } + + #[test] + fn duplicating_an_axis_constraint_keeps_its_direction() { + let mut scene = super::super::Scene::new(); + let line = |y| { + acadrust::EntityType::Line(acadrust::entities::Line::from_points( + Vector3::new(0.0, y, 0.0), + Vector3::new(4.0, y + 1.0, 0.0), + )) + }; + let source = scene.add_entity(line(0.0)); + let copied = scene.add_entity(line(10.0)); + let direction = Vector3::new(3.0, 4.0, 0.0); + scene + .parametric_constraint_set_mut(ParametricScope::ModelSpace) + .add_axis_constraint( + ConstraintKind::Horizontal, + vec![ParametricRef::whole(source)], + direction, + ); + let mut handle_map = rustc_hash::FxHashMap::default(); + handle_map.insert(source, copied); + + scene.duplicate_parametric_constraints_for(&handle_map); + + let constraints = &scene + .parametric_constraint_set(ParametricScope::ModelSpace) + .unwrap() + .constraints; + assert_eq!(constraints.len(), 2); + assert_eq!(constraints[1].refs, vec![ParametricRef::whole(copied)]); + assert_eq!(constraints[1].axis_direction, Some(direction.normalize())); + } }