diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index 2d013adcd..2ea1d6471 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -6772,6 +6772,48 @@ mod selection_preview_tests { } } + #[test] + fn xline_direction_grip_drag_changes_direction() { + use acadrust::{types::Vector3, EntityType}; + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + let i = app.active_tab; + app.tabs[i].scene.selection.borrow_mut().vp_size = (800.0, 600.0); + // Base away from the model origin so the UCS icon cannot swallow + // the press (it sits at the origin in a fresh drawing). + let handle = app.tabs[i].scene.add_entity(EntityType::XLine( + acadrust::entities::XLine::new( + Vector3::new(100.0, 50.0, 0.0), + Vector3::new(0.0, 1.0, 0.0), + ), + )); + app.tabs[i].scene.selected.insert(handle); + app.refresh_selected_grips(); + assert_eq!(app.tabs[i].selected_grips.len(), 2); + // Press on grip 1 (direction handle) and drag it to point along +X. + let bounds = iced::Rectangle::with_size(iced::Size::new(800.0, 600.0)); + let g1world = app.tabs[i].selected_grips[1].world; + let g1cursor = app.tabs[i].scene.camera.borrow().project(g1world, bounds).unwrap(); + app.tabs[i].scene.selection.borrow_mut().last_move_pos = + Some(iced::Point::new(g1cursor.x, g1cursor.y)); + let _ = app.on_viewport_left_press(); + assert_eq!( + app.tabs[i].active_grip.as_ref().map(|g| g.grip_id), + Some(1), + "press on the direction grip must engage grip 1" + ); + let dir_target = glam::DVec3::new(200.0, 50.0, 0.0); + let dir_cursor = app.tabs[i].scene.camera.borrow().project(dir_target, bounds).unwrap(); + let _ = app.on_viewport_move(iced::Point::new(dir_cursor.x, dir_cursor.y)); + let EntityType::XLine(moved) = app.tabs[i].scene.document.get_entity(handle).unwrap() + else { panic!("expected xline") }; + assert!( + (moved.direction.x - 1.0).abs() < 1e-6 && moved.direction.y.abs() < 1e-6, + "direction grip drag must point the xline along +X, got {:?}", + moved.direction + ); + } + #[test] fn constraint_glyph_tooltip_appears_after_hover_dwell() { use crate::scene::parametric_constraints::{ConstraintKind, ParametricRef, ParametricScope}; diff --git a/src/entities/ray.rs b/src/entities/ray.rs index 04aeb5bb1..598210259 100644 --- a/src/entities/ray.rs +++ b/src/entities/ray.rs @@ -3,7 +3,7 @@ use crate::t; use crate::command::EntityTransform; use crate::entities::common::{ - center_grip, edit_prop as edit, format_length, ro_prop as ro, square_grip, + edit_prop as edit, format_length, ro_prop as ro, square_grip, }; use crate::entities::curve::{point_along, unit_direction}; use crate::entities::traits::{Grippable, PropertyEditable, Transformable, RenderConvertible}; @@ -48,11 +48,14 @@ impl Grippable for Ray { let bp = &self.base_point; let dir = &self.direction; // Grip 0: base point (movable) - // Grip 1: a point along the direction (changes direction) + // Grip 1: a point along the direction (changes direction). A stretch + // grip, not a move handle: `center_grip` would flag it `is_midpoint` + // and the drag would arrive as `Translate`, which the direction arm + // ignores — the grip would silently do nothing. let guide_dist = 10.0_f64; vec![ square_grip(0, glam::DVec3::new(bp.x, bp.y, bp.z)), - center_grip( + square_grip( 1, glam::DVec3::new( bp.x + dir.x * guide_dist, @@ -191,7 +194,8 @@ impl Grippable for XLine { let guide_dist = 10.0_f64; vec![ square_grip(0, glam::DVec3::new(bp.x, bp.y, bp.z)), - center_grip( + // Direction handle: a stretch grip, not a move handle (see Ray). + square_grip( 1, glam::DVec3::new( bp.x + dir.x * guide_dist, diff --git a/src/scene/boundary.rs b/src/scene/boundary.rs index fea24019d..b6316f08a 100644 --- a/src/scene/boundary.rs +++ b/src/scene/boundary.rs @@ -88,6 +88,7 @@ fn entity_boundary_source_on_plane( entity: &EntityType, plane: WorkingPlane, tolerance: f64, + clip_bounds: Option<([f64; 2], [f64; 2])>, ) -> Option { let curves = entity_curves_on_plane(entity, plane, tolerance); @@ -95,27 +96,92 @@ fn entity_boundary_source_on_plane( return None; } - let segments: Vec = curves - .iter() - .flat_map(|curve| { - curve - .tessellate_angle(cadkernel::tessellation::DEFAULT_ANGLE) - .windows(2) - .filter_map(|pair| { - let start = pair[0]; - let end = pair[1]; - - (start.iter().chain(&end).all(|value| value.is_finite()) - && start != end) - .then_some(Line { start, end }) - }) - .collect::>() - }) - .collect(); + let segments = live_segments_for_curves(&curves, clip_bounds, tolerance); (!segments.is_empty()).then_some(BoundarySource { segments, curves }) } +/// Finite extents of boundary segments, expanded by `tolerance` so clipped +/// endpoints weld with the segments that produced the box. `None` when +/// there is nothing finite to clip against. +fn segments_bounds(segments: &[Line], tolerance: f64) -> Option<([f64; 2], [f64; 2])> { + let mut bounds: Option<([f64; 2], [f64; 2])> = None; + for segment in segments { + for point in [segment.start, segment.end] { + bounds = Some(match bounds { + None => ([point[0], point[1]], [point[0], point[1]]), + Some((min, max)) => ( + [min[0].min(point[0]), min[1].min(point[1])], + [max[0].max(point[0]), max[1].max(point[1])], + ), + }); + } + } + bounds.map(|(min, max)| { + ( + [min[0] - tolerance, min[1] - tolerance], + [max[0] + tolerance, max[1] + tolerance], + ) + }) +} + +/// Tessellate live curves to finite segments. Bounded curves tessellate as +/// always; rays and infinite lines are clipped to `clip_bounds` instead — +/// tessellating them raw yields a unit stub that can never close a face. +/// With no bounds there is nothing to clip against, so unbounded curves +/// contribute nothing. +fn live_segments_for_curves( + curves: &[Curve], + clip_bounds: Option<([f64; 2], [f64; 2])>, + tolerance: f64, +) -> Vec { + curves + .iter() + .flat_map(|curve| { + match curve { + Curve::Ray(ray) => clip_bounds + .and_then(|(min, max)| { + clip_unbounded_to_bounds( + ray.origin, + ray.direction, + true, + min, + max, + tolerance, + ) + }) + .into_iter() + .collect::>(), + Curve::XLine(line) => clip_bounds + .and_then(|(min, max)| { + clip_unbounded_to_bounds( + line.base, + line.direction, + false, + min, + max, + tolerance, + ) + }) + .into_iter() + .collect::>(), + _ => curve + .tessellate_angle(cadkernel::tessellation::DEFAULT_ANGLE) + .windows(2) + .filter_map(|pair| { + let start = pair[0]; + let end = pair[1]; + + (start.iter().chain(&end).all(|value| value.is_finite()) + && start != end) + .then_some(Line { start, end }) + }) + .collect::>(), + } + }) + .collect() +} + fn hatch_path_seed(path: &acadrust::entities::BoundaryPath) -> Option<[f64; 2]> { let mut ring = Vec::new(); for edge in &path.edges { @@ -794,6 +860,14 @@ impl Scene { } association_changed |= path.boundary_handles.len() != old_count; modified |= association_changed; + // Finite extents for clipping unbounded live sources (rays / + // construction lines) below. `all_sources` already went + // through the same clipping in `boundary_sources_on_plane`. + let all_segments: Vec = all_sources + .values() + .flat_map(|source| source.segments.iter().copied()) + .collect(); + let clip_bounds = segments_bounds(&all_segments, WELD_TOLERANCE); let sources: rustc_hash::FxHashMap<_, _> = path .boundary_handles .iter() @@ -809,6 +883,7 @@ impl Scene { entity, plane, WELD_TOLERANCE, + clip_bounds, ) }) .or_else(|| all_sources.get(source).cloned()); @@ -895,32 +970,14 @@ impl Scene { .segments .extend(segments); } - // Finite extents of the wire geometry. Rays and infinite - // construction lines are clipped to this box below: tessellating - // them raw yields a single unit stub at the base point, which can - // never close a face (and would overwrite the wire segments with - // it). Expanded by the weld tolerance so clipped endpoints meet - // the segments that produced the box. - let mut bounds: Option<([f64; 2], [f64; 2])> = None; - for source in sources.values() { - for segment in &source.segments { - for point in [segment.start, segment.end] { - bounds = Some(match bounds { - None => ([point[0], point[1]], [point[0], point[1]]), - Some((min, max)) => ( - [min[0].min(point[0]), min[1].min(point[1])], - [max[0].max(point[0]), max[1].max(point[1])], - ), - }); - } - } - } - let bounds = bounds.map(|(min, max)| { - ( - [min[0] - tolerance, min[1] - tolerance], - [max[0] + tolerance, max[1] + tolerance], - ) - }); + // Finite extents of the wire geometry, so rays and infinite + // construction lines clip to something real instead of tessellating + // to a unit stub (see `live_segments_for_curves`). + let all_wire_segments: Vec = sources + .values() + .flat_map(|source| source.segments.iter().copied()) + .collect(); + let bounds = segments_bounds(&all_wire_segments, tolerance); for (&handle, source) in &mut sources { let curves = self .document @@ -931,51 +988,7 @@ impl Scene { // Associative hatch regeneration must use the entity's live geometry, // not a potentially one-frame-old display wire. Grip edits and STRETCH // mutate the document before the resident wire cache catches up. - let live_segments: Vec = curves - .iter() - .flat_map(|curve| { - match curve { - Curve::Ray(ray) => bounds - .and_then(|(min, max)| { - clip_unbounded_to_bounds( - ray.origin, - ray.direction, - true, - min, - max, - tolerance, - ) - }) - .into_iter() - .collect::>(), - Curve::XLine(line) => bounds - .and_then(|(min, max)| { - clip_unbounded_to_bounds( - line.base, - line.direction, - false, - min, - max, - tolerance, - ) - }) - .into_iter() - .collect::>(), - _ => curve - .tessellate_angle(cadkernel::tessellation::DEFAULT_ANGLE) - .windows(2) - .filter_map(|pair| { - let start = pair[0]; - let end = pair[1]; - - (start.iter().chain(&end).all(|value| value.is_finite()) - && start != end) - .then_some(Line { start, end }) - }) - .collect::>(), - } - }) - .collect(); + let live_segments = live_segments_for_curves(&curves, bounds, tolerance); if !live_segments.is_empty() { source.segments = live_segments; @@ -1196,6 +1209,27 @@ mod boundary_xline_tests { ); } + #[test] + fn xline_source_clips_to_given_bounds() { + let xline = EntityType::XLine(acadrust::entities::XLine::new( + acadrust::types::Vector3::new(5.0, 0.0, 0.0), + acadrust::types::Vector3::new(0.0, 1.0, 0.0), + )); + let source = entity_boundary_source_on_plane( + &xline, + crate::command::WorkingPlane::default(), + 1.0e-6, + Some(([0.0, 0.0], [10.0, 10.0])), + ) + .expect("xline crossing the box must produce a source"); + assert_eq!(source.segments.len(), 1); + let segment = &source.segments[0]; + assert!((segment.start[0] - 5.0).abs() < 1e-6, "got {:?}", segment.start); + assert!((segment.start[1] - 0.0).abs() < 1e-6, "got {:?}", segment.start); + assert!((segment.end[0] - 5.0).abs() < 1e-6, "got {:?}", segment.end); + assert!((segment.end[1] - 10.0).abs() < 1e-6, "got {:?}", segment.end); + } + #[test] fn xline_missing_the_geometry_contributes_no_face() { let mut scene = Scene::new();