diff --git a/src/app/mod.rs b/src/app/mod.rs index 77cd9f90a..48ef49c54 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -642,6 +642,9 @@ pub(super) struct OpenCADStudio { /// `HOVER_DWELL_MS`. Skipping the pick mid-stroke avoids the per-frame /// O(N) wire+hatch+mesh sweep that froze the cursor on large drawings. hover_dwell: Option, + /// Constraint kind shown after the ordinary rollover dwell while the + /// cursor remains over one of its viewport indicators. + constraint_glyph_tooltip: Option, /// Snapshots of edited entities taken at the start of a grip drag. The drag /// mutates the document live, so Escape restores this group atomically. grip_originals: Vec<(acadrust::Handle, acadrust::EntityType)>, @@ -3711,6 +3714,7 @@ impl OpenCADStudio { grip_add_provisional: None, grip_preview_handles: Vec::new(), hover_dwell: None, + constraint_glyph_tooltip: None, grip_originals: Vec::new(), grip_history_originals: Vec::new(), grip_dirty_before: None, diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index 3971ec478..f404c0a83 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -933,9 +933,10 @@ impl OpenCADStudio { return Task::none(); } let i = self.active_tab; + self.constraint_glyph_tooltip = None; let constraint_hover = self .constraint_glyph_under(i, p) - .map(|(_, handles)| handles) + .map(|(_, references)| references) .unwrap_or_default(); self.tabs[i] .scene @@ -1752,7 +1753,7 @@ impl OpenCADStudio { .scene .document .get_entity(target.handle)?; - crate::scene::parametric_constraints::driven_ref_for_grip( + crate::scene::parametric_constraints::grip_solve_anchor_ref( entity, target.handle, target.grip_id, @@ -1766,7 +1767,7 @@ impl OpenCADStudio { .solve_parametric_constraints_preview( &edited_handles, &driven_refs, - self.constraint_solve_mode && !driven_refs.is_empty(), + false, &self.grip_originals, ); for (handle, _) in &solved_by_constraints { @@ -3012,7 +3013,7 @@ impl OpenCADStudio { cursor: iced::Point, ) -> Option<( crate::scene::parametric_constraints::ConstraintKind, - Vec, + Vec, )> { if self.tabs[i].scene.current_layout != "Model" { return None; @@ -3028,10 +3029,7 @@ impl OpenCADStudio { )?; let set = self.tabs[i].scene.parametric_constraint_set(scope)?; let constraint = set.get(id)?; - let mut handles: Vec<_> = constraint.refs.iter().map(|r| r.entity).collect(); - handles.sort_unstable_by_key(|handle| handle.value()); - handles.dedup(); - Some((constraint.kind, handles)) + Some((constraint.kind, constraint.refs.clone())) } pub(super) fn on_viewport_left_press(&mut self) -> Task { @@ -3090,7 +3088,10 @@ impl OpenCADStudio { let (vw, vh) = vp_size; if self.tabs[i].active_cmd.is_none() { - if let Some((kind, handles)) = self.constraint_glyph_under(i, p) { + if let Some((kind, references)) = self.constraint_glyph_under(i, p) { + let mut handles: Vec<_> = references.iter().map(|reference| reference.entity).collect(); + handles.sort_unstable_by_key(|handle| handle.value()); + handles.dedup(); self.tabs[i].scene.deselect_all(); self.tabs[i].scene.select_entities(&handles); self.refresh_selected_grips(); @@ -5253,6 +5254,7 @@ properties={:.1}ms picked={}", } self.tabs[i].scene.set_hover_highlight(None); self.hover_dwell = None; + self.constraint_glyph_tooltip = None; self.grip_hover = None; self.ucs_icon_hover = false; } @@ -5574,6 +5576,10 @@ properties={:.1}ms picked={}", self.clear_navigation_hover(i); return Task::none(); } + let cursor = self.tabs[i].scene.selection.borrow().last_move_pos; + self.constraint_glyph_tooltip = cursor.and_then(|point| { + self.constraint_glyph_under(i, point).map(|(kind, _)| kind) + }); let bounds = iced::Rectangle { x: 0.0, y: 0.0, @@ -6205,9 +6211,13 @@ mod selection_preview_tests { .normalize() }; assert!(direction(current[0]).dot(direction(current[1])).abs() < 1e-8); - assert_ne!( + assert_eq!( current[1], before[1], - "constrained neighbor must follow each grip frame" + "geometry outside the grip edit must stay fixed" + ); + assert_eq!( + current[0][0], before[0][0], + "the endpoint opposite the dragged grip must stay fixed" ); } let _ = app.on_viewport_left_release(); @@ -6219,6 +6229,106 @@ mod selection_preview_tests { assert_eq!(geometry(&app), after); } + #[test] + fn constrained_polyline_vertex_grip_reshapes_instead_of_translating() { + use crate::scene::parametric_constraints::{ConstraintKind, ParametricRef, ParametricScope}; + use acadrust::{entities::LwPolyline, types::Vector2, EntityType}; + + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + let i = app.active_tab; + app.snapper.snap_enabled = false; + app.snapper.grid_snap_on = false; + app.snapper.otrack_enabled = false; + app.ortho_mode = false; + app.polar_mode = false; + app.tabs[i].scene.selection.borrow_mut().vp_size = (800.0, 600.0); + let mut rectangle = LwPolyline::from_points(vec![ + Vector2::new(0.0, 0.0), + Vector2::new(4.0, 0.0), + Vector2::new(4.0, 2.0), + Vector2::new(0.0, 2.0), + ]); + rectangle.is_closed = true; + let handle = app.tabs[i].scene.add_entity(EntityType::LwPolyline(rectangle)); + let set = app.tabs[i].scene.parametric_constraint_set_mut(ParametricScope::ModelSpace); + set.add(ConstraintKind::Parallel, vec![ParametricRef::segment(handle, 0), ParametricRef::segment(handle, 2)], None); + set.add(ConstraintKind::Parallel, vec![ParametricRef::segment(handle, 1), ParametricRef::segment(handle, 3)], None); + set.add(ConstraintKind::Perpendicular, vec![ParametricRef::segment(handle, 3), ParametricRef::segment(handle, 2)], None); + set.add(ConstraintKind::Horizontal, vec![ParametricRef::segment(handle, 2)], None); + let vertices = |app: &OpenCADStudio| { + let EntityType::LwPolyline(polyline) = + app.tabs[i].scene.document.get_entity(handle).unwrap() + else { + panic!("expected polyline") + }; + polyline.vertices.iter().map(|vertex| vertex.location).collect::>() + }; + let before = vertices(&app); + app.tabs[i].active_grip = Some(GripEdit::single(handle, 0, false, glam::DVec3::ZERO)); + + let _ = app.on_viewport_move(Point::new(460.0, 220.0)); + + let after = vertices(&app); + let dragged = after[0] - before[0]; + assert!(dragged.length_squared() > 1.0e-12); + assert!(after.iter().zip(&before).skip(1).any(|(after, before)| + (*after - *before - dragged).length_squared() > 1.0e-12)); + let segments = [ + after[1] - after[0], + after[2] - after[1], + after[3] - after[2], + after[0] - after[3], + ]; + let cross = |a: Vector2, b: Vector2| a.x * b.y - a.y * b.x; + let dot = |a: Vector2, b: Vector2| a.x * b.x + a.y * b.y; + assert!(cross(segments[0], segments[2]).abs() < 1.0e-6); + assert!(cross(segments[1], segments[3]).abs() < 1.0e-6); + assert!(dot(segments[3], segments[2]).abs() < 1.0e-6); + assert!(segments[2].y.abs() < 1.0e-6); + } + + #[test] + fn constraint_glyph_tooltip_appears_after_hover_dwell() { + use crate::scene::parametric_constraints::{ConstraintKind, ParametricRef, ParametricScope}; + use acadrust::{entities::Line, 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); + let handle = app.tabs[i].scene.add_entity(EntityType::Line(Line::from_points( + Vector3::new(-1.0, 0.0, 0.0), + Vector3::new(1.0, 0.0, 0.0), + ))); + app.tabs[i].scene.parametric_constraint_set_mut(ParametricScope::ModelSpace).add( + ConstraintKind::Horizontal, + vec![ParametricRef::whole(handle)], + None, + ); + let anchor = app.tabs[i].scene.constraint_glyph_placements_screen( + ParametricScope::ModelSpace, + (800.0, 600.0), + true, + 3, + )[0].1; + let point = (-30..=30).find_map(|y| { + (-30..=30).find_map(|x| { + let point = Point::new(anchor.x + x as f32, anchor.y + y as f32); + app.constraint_glyph_under(i, point).is_some().then_some(point) + }) + }).expect("projected constraint glyph should be hit-testable"); + + let _ = app.on_viewport_move(point); + assert_eq!(app.constraint_glyph_tooltip, None); + app.hover_dwell.as_mut().unwrap().last_move_at = + Instant::now() + - std::time::Duration::from_millis(crate::app::HOVER_DWELL_MS as u64 + 1); + let _ = app.on_hover_dwell_tick(); + + assert_eq!(app.constraint_glyph_tooltip, Some(ConstraintKind::Horizontal)); + } + #[cfg(target_os = "linux")] #[test] fn hyperlink_click_respects_existing_pointer_owners() { diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index 0e3c7fdae..6ba89cd45 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -888,6 +888,8 @@ bg={bg_ms:.1}ms n={view_count}" grip_hover: self.model_space.grip_hover, }, constraint_glyphs, + self.constraint_glyph_tooltip + .map(|kind| crate::t!(kind.label()).into_owned()), ) }; diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 384933efd..4137628bf 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -1685,6 +1685,10 @@ pub struct Scene { pub hover_highlight: Option, /// All entities related to the constraint indicator currently under the cursor. constraint_hover_highlights: HashSet, + /// Sub-entity references related to that indicator. Polyline segments are + /// drawn separately so hovering one constraint does not light the whole chain. + constraint_hover_refs: Vec, + constraint_hover_wires: Vec, /// Color used to draw the selection highlight overlay wires (SELECTIONEFFECTCOLOR). pub selection_color: [f32; 4], /// Whether selection visual effect (glow/highlight) is enabled (SELECTIONEFFECT). @@ -2215,6 +2219,8 @@ impl Scene { refedit_keep: None, hover_highlight: None, constraint_hover_highlights: HashSet::default(), + constraint_hover_refs: Vec::new(), + constraint_hover_wires: Vec::new(), selection_color: crate::scene::model::wire_model::WireModel::SELECTED, selection_effect: true, transparency_display: true, @@ -5126,12 +5132,48 @@ impl Scene { handles } - pub fn set_constraint_hover_highlights(&mut self, handles: &[Handle]) { - let desired: HashSet<_> = handles.iter().copied().collect(); - if desired != self.constraint_hover_highlights { - self.constraint_hover_highlights = desired; - self.bump_selection(); + pub fn set_constraint_hover_highlights( + &mut self, + references: &[crate::scene::parametric_constraints::ParametricRef], + ) { + let mut references = references.to_vec(); + references.dedup(); + if references == self.constraint_hover_refs { + return; } + + let mut handles = HashSet::default(); + let mut wires = Vec::new(); + for reference in &references { + let Some(segment) = reference.segment_index() else { + handles.insert(reference.entity); + continue; + }; + let Some(entity) = self.document.get_entity(reference.entity) else { + continue; + }; + let Some(mut curve) = crate::entities::curve::entity_curve(entity) else { + handles.insert(reference.entity); + continue; + }; + let Some(segment_curve) = curve.curve.segments().into_iter().nth(segment) else { + continue; + }; + curve.curve = segment_curve; + let mut wire = WireModel::solid_f64( + format!("constraint-segment:{}:{segment}", reference.entity.value()), + curve.tessellate(8.0), + WireModel::HOVER, + false, + ); + wire.set_fixed_screen_width(2.0); + wires.push(wire); + } + + self.constraint_hover_refs = references; + self.constraint_hover_highlights = handles; + self.constraint_hover_wires = wires; + self.bump_selection(); } /// Keep the current selection visible and temporarily filter every other diff --git a/src/scene/parametric_constraints.rs b/src/scene/parametric_constraints.rs index 082da8eb3..982f2d94f 100644 --- a/src/scene/parametric_constraints.rs +++ b/src/scene/parametric_constraints.rs @@ -83,17 +83,17 @@ impl ParametricRef { } } -/// Point reference driven by a native entity grip. Segment midpoint and -/// scalar radius grips deliberately return None because they do not identify -/// one stored point parameter. -pub(crate) fn driven_ref_for_grip( +/// Point reference temporarily pinned while solving a native grip edit. +/// A line endpoint anchors its opposite endpoint so the grabbed endpoint is +/// projected onto the constraint solution instead of moving the whole line. +pub(crate) fn grip_solve_anchor_ref( entity: &acadrust::EntityType, handle: Handle, grip_id: usize, ) -> Option { match entity { acadrust::EntityType::Line(_) if grip_id <= 1 => { - Some(ParametricRef::point(handle, grip_id as i32)) + Some(ParametricRef::point(handle, 1 - grip_id as i32)) } acadrust::EntityType::LwPolyline(polyline) if grip_id < polyline.vertices.len() => { Some(ParametricRef::point(handle, grip_id as i32)) @@ -575,6 +575,37 @@ pub(crate) fn parametric_curve_ref_for_pick( } impl ConstraintKind { + pub const fn label(self) -> &'static str { + match self { + Self::Coincident => "Coincident", + Self::Horizontal => "Horizontal", + Self::Vertical => "Vertical", + Self::Parallel => "Parallel", + Self::Perpendicular => "Perpendicular", + Self::Equal => "Equal", + Self::Distance => "Distance", + Self::Angle => "Angle", + Self::Angle3Point => "3-point angle", + Self::Radius => "Radius", + Self::Tangent => "Tangent", + Self::Smooth => "Smooth", + Self::Concentric => "Concentric", + Self::CenterPoint => "Center point", + Self::Colinear => "Collinear", + Self::Midpoint => "Midpoint", + Self::Fixed => "Fixed", + Self::PointOnCurve => "Point on curve", + Self::EqualDistance => "Equal distance", + Self::Symmetric => "Symmetric", + Self::Diameter => "Diameter", + Self::DistanceX => "Horizontal distance", + Self::DistanceY => "Vertical distance", + Self::DistanceDirected => "Directed distance", + Self::Normal => "Normal", + Self::RigidSet => "Rigid set", + } + } + /// The short symbol a constraint glyph shows — matches the existing /// ribbon icons (`crate::modules::parametric::{tools,value}`) for /// the kinds that have a one-click button, so the same glyph means the @@ -640,7 +671,13 @@ pub(crate) fn glyph_placement( document: &acadrust::CadDocument, constraint: &ParametricConstraint, ) -> Option<(Vector3, Vector3)> { - let r = constraint.refs.first()?; + glyph_placements(document, constraint).into_iter().next() +} + +fn glyph_placement_for_reference( + document: &acadrust::CadDocument, + r: ParametricRef, +) -> Option<(Vector3, Vector3)> { let entity = document.get_entity(r.entity)?; let line_midpoint = |line: &acadrust::entities::Line| { Vector3::new( @@ -661,7 +698,7 @@ pub(crate) fn glyph_placement( entity, POLYLINE_SEGMENT_MIDPOINT_MARKER_BASE - segment as i32, )?; - let [start, end] = constraint_segment_endpoints(document, *r)?; + let [start, end] = constraint_segment_endpoints(document, r)?; return Some((anchor, segment_normal(start, end))); } match (entity, r.marker) { @@ -704,6 +741,52 @@ pub(crate) fn glyph_placement( } } +fn glyph_placements( + document: &acadrust::CadDocument, + constraint: &ParametricConstraint, +) -> Vec<(Vector3, Vector3)> { + if constraint.kind == ConstraintKind::Parallel { + return constraint + .refs + .iter() + .filter_map(|reference| glyph_placement_for_reference(document, *reference)) + .collect(); + } + + let Some(first) = constraint.refs.first().copied() else { + return Vec::new(); + }; + let fallback = glyph_placement_for_reference(document, first); + if matches!( + constraint.kind, + ConstraintKind::Perpendicular | ConstraintKind::Tangent + ) { + if let [first, second, ..] = constraint.refs.as_slice() { + if let (Some(first_curve), Some(second_curve), Some((anchor, outward))) = ( + constraint_reference_curve_xy(document, *first), + constraint_reference_curve_xy(document, *second), + fallback, + ) { + if let Some(crossing) = cadkernel::geom2d::intersect( + &first_curve, + &second_curve, + cadkernel::geom2d::Tolerance::default(), + ) + .into_iter() + .next() + { + return vec![( + Vector3::new(crossing.point[0], crossing.point[1], anchor.z), + outward, + )]; + } + } + } + } + + fallback.into_iter().collect() +} + /// World-space locations that explain what a hovered constraint acts on. /// Point constraints expose their referenced point directly; curve relations /// expose the contact or intersection that makes the relation visible. @@ -1334,33 +1417,14 @@ impl super::Scene { .any(|reference| self.selected.contains(&reference.entity)); self.should_display_parametric_constraint(scope, c.id, selected, display_mode) }) - .filter_map(|c| { - let (anchor, outward) = glyph_placement(&self.document, c)?; - let screen = crate::scene::pick::grip::project_rte( - glam::DVec3::new(anchor.x, anchor.y, anchor.z), - view_rot, - eye, - bounds, - )?; - let outward_screen = crate::scene::pick::grip::project_rte( - glam::DVec3::new( - anchor.x + outward.x, - anchor.y + outward.y, - anchor.z + outward.z, - ), - view_rot, - eye, - bounds, - )?; - let direction = (outward_screen - screen).normalize_or(glam::Vec2::NEG_Y); - let point = iced::Point::new(bounds.x + screen.x, bounds.y + screen.y); + .flat_map(|c| { let is_conflicting = set.conflicts.iter().any(|(id, _)| *id == c.id); let label = if show_values { glyph_label(c) } else { c.kind.glyph_symbol().to_string() }; - let hover_points = constraint_hover_points(&self.document, c) + let hover_points: Vec = constraint_hover_points(&self.document, c) .into_iter() .filter_map(|hover_point| { let projected = crate::scene::pick::grip::project_rte( @@ -1376,14 +1440,38 @@ impl super::Scene { (point.x.is_finite() && point.y.is_finite()).then_some(point) }) .collect(); - point.x.is_finite().then_some(( - c.id, - point, - direction.to_array(), - label, - is_conflicting, - hover_points, - )) + glyph_placements(&self.document, c) + .into_iter() + .filter_map(|(anchor, outward)| { + let screen = crate::scene::pick::grip::project_rte( + glam::DVec3::new(anchor.x, anchor.y, anchor.z), + view_rot, + eye, + bounds, + )?; + let outward_screen = crate::scene::pick::grip::project_rte( + glam::DVec3::new( + anchor.x + outward.x, + anchor.y + outward.y, + anchor.z + outward.z, + ), + view_rot, + eye, + bounds, + )?; + let direction = + (outward_screen - screen).normalize_or(glam::Vec2::NEG_Y); + let point = iced::Point::new(bounds.x + screen.x, bounds.y + screen.y); + point.x.is_finite().then(|| ( + c.id, + point, + direction.to_array(), + label.clone(), + is_conflicting, + hover_points.clone(), + )) + }) + .collect::>() }) .collect() } @@ -1580,6 +1668,88 @@ mod tests { glyph_placement(&document, &constraint(ParametricRef::segment(h(3), 0))).unwrap(); assert_eq!(anchor, Vector3::new(5.0, 0.0, 0.0)); assert_eq!(direction, Vector3::new(0.0, 10.0, 0.0)); + + let mut tangent_line = acadrust::entities::Line::from_points( + Vector3::new(-10.0, 5.0, 0.0), + Vector3::new(10.0, 5.0, 0.0), + ); + tangent_line.common.handle = h(4); + document.add_entity(acadrust::EntityType::Line(tangent_line)).unwrap(); + let relation = |id, kind, refs| ParametricConstraint { + id, + kind, + refs, + driving_param: None, + enabled: true, + native_origin: None, + rigid_points: Vec::new(), + distance_direction_type: 0, + distance_direction: None, + angle_sector: angle_sector::PARALLEL_COUNTERCLOCKWISE, + }; + let tangent = relation( + 1, + ConstraintKind::Tangent, + vec![ParametricRef::whole(h(4)), ParametricRef::whole(h(2))], + ); + assert_eq!( + glyph_placement(&document, &tangent).unwrap().0, + Vector3::new(0.0, 5.0, 0.0) + ); + + let mut rectangle = acadrust::entities::LwPolyline::from_points(vec![ + acadrust::types::Vector2::new(0.0, 0.0), + acadrust::types::Vector2::new(4.0, 0.0), + acadrust::types::Vector2::new(4.0, 2.0), + acadrust::types::Vector2::new(0.0, 2.0), + ]); + rectangle.common.handle = h(5); + rectangle.is_closed = true; + document.add_entity(acadrust::EntityType::LwPolyline(rectangle)).unwrap(); + let parallel = glyph_placements( + &document, + &relation( + 2, + ConstraintKind::Parallel, + vec![ParametricRef::segment(h(5), 0), ParametricRef::segment(h(5), 2)], + ), + ); + assert_eq!(parallel.len(), 2); + assert_eq!(parallel[0].0, Vector3::new(2.0, 0.0, 0.0)); + assert_eq!(parallel[1].0, Vector3::new(2.0, 2.0, 0.0)); + let perpendicular = relation( + 3, + ConstraintKind::Perpendicular, + vec![ParametricRef::segment(h(5), 3), ParametricRef::segment(h(5), 2)], + ); + assert_eq!( + glyph_placement(&document, &perpendicular).unwrap().0, + Vector3::new(0.0, 2.0, 0.0) + ); + } + + #[test] + fn polyline_constraint_hover_builds_only_the_referenced_segment() { + let mut scene = super::super::Scene::new(); + let handle = scene.add_entity(acadrust::EntityType::LwPolyline( + acadrust::entities::LwPolyline::from_points(vec![ + acadrust::types::Vector2::new(0.0, 0.0), + acadrust::types::Vector2::new(4.0, 0.0), + acadrust::types::Vector2::new(4.0, 2.0), + ]), + )); + + scene.set_constraint_hover_highlights(&[ParametricRef::segment(handle, 1)]); + + assert!(scene.constraint_hover_highlights.is_empty()); + assert_eq!(scene.constraint_hover_wires.len(), 1); + let wire = &scene.constraint_hover_wires[0]; + let points: Vec<_> = wire.points.iter().zip(&wire.points_low).map(|(high, low)| [ + high[0] as f64 + low[0] as f64, + high[1] as f64 + low[1] as f64, + high[2] as f64 + low[2] as f64, + ]).collect(); + assert_eq!(points, vec![[4.0, 0.0, 0.0], [4.0, 2.0, 0.0]]); } #[test] @@ -1718,18 +1888,18 @@ mod tests { )); assert_eq!( - driven_ref_for_grip(&arc, handle, 0), + grip_solve_anchor_ref(&arc, handle, 0), Some(ParametricRef::center(handle)) ); assert_eq!( - driven_ref_for_grip(&arc, handle, 1), + grip_solve_anchor_ref(&arc, handle, 1), Some(ParametricRef::point(handle, 0)) ); assert_eq!( - driven_ref_for_grip(&arc, handle, 2), + grip_solve_anchor_ref(&arc, handle, 2), Some(ParametricRef::point(handle, 1)) ); - assert_eq!(driven_ref_for_grip(&arc, handle, 3), None); + assert_eq!(grip_solve_anchor_ref(&arc, handle, 3), None); } #[test] diff --git a/src/scene/parametric_solve.rs b/src/scene/parametric_solve.rs index 535faf3b1..8297860df 100644 --- a/src/scene/parametric_solve.rs +++ b/src/scene/parametric_solve.rs @@ -1942,10 +1942,10 @@ fn solve_scope( sys.add_constraint(Rc::new(Difference::new(el.center.y, el.focus1.y, dy))); } - // The first ordered Coincident selection is the anchor for this solve. - // Pin either its addressed point or every defining parameter of its whole - // curve temporarily; the relation remains persistent, while these pins do - // not enter the drawing's constraint graph. + // Temporarily make each requested anchor a fixed kernel input. Marking the + // geometry parameters as driven keeps them exact and out of the solver's + // free-variable list; an approximate equality equation would still allow + // visible drift after repeated grip frames. for reference in driven_refs { if !set .constraints @@ -1996,9 +1996,7 @@ fn solve_scope( } }; for parameter in params_to_pin { - let value = sys.store().get(parameter); - let target = sys.add_param(value, true); - sys.add_constraint(Rc::new(Equal::new(parameter, target, 1.0))); + sys.store_mut().set_driven(parameter, true); } } @@ -2638,11 +2636,24 @@ impl Scene { if !is_touched { continue; } + let mut anchors = driven_refs.to_vec(); + for reference in set + .constraints + .iter() + .filter(|constraint| constraint.enabled) + .flat_map(|constraint| &constraint.refs) + { + if !touched.contains(&reference.entity) + && !anchors.iter().any(|anchor| anchor.entity == reference.entity) + { + anchors.push(ParametricRef::whole(reference.entity)); + } + } let Some((solved, _dof, _conflicts)) = solve_scope( &self.document, &self.named_parameters, set, - driven_refs, + &anchors, retain_size, &retained_before, ) @@ -2914,11 +2925,11 @@ mod tests { ); } - /// `solve_parametric_constraints_preview` reports the constrained - /// neighbor's solved position without writing anything into the - /// document itself — the grip-drag caller owns applying it. + /// A grip preview treats geometry outside the edit as a fixed reference, + /// so a parallel constraint projects the dragged endpoint without moving + /// the other line or changing their separation. #[test] - fn preview_solve_reports_the_neighbors_new_state_without_mutating_the_document() { + fn preview_solve_keeps_untouched_parallel_geometry_fixed() { let mut scene = Scene::new(); let a = scene.add_entity(EntityType::Line(acadrust::entities::Line::from_points( Vector3::new(0.0, 0.0, 0.0), @@ -2943,40 +2954,39 @@ mod tests { } let b_before = scene.document.get_entity(b).cloned(); - let solved = scene.solve_parametric_constraints_preview(&[a], &[], false, &[]); + let solved = scene.solve_parametric_constraints_preview( + &[a], + &[ParametricRef::point(a, 0)], + false, + &[], + ); assert_eq!( scene.document.get_entity(b), b_before.as_ref(), "preview must not mutate the document" ); - // The whole scope solves together (every registered entity's params - // are free, not just `b`'s — see `register_entity`'s doc comment), - // so `a` itself may also have shifted slightly to reach the nearest - // mutually-parallel configuration; read whichever position `a` ends - // up at from `solved` too, rather than assuming it stayed exactly - // where the simulated drag put it. - let line_dir = |entity: &EntityType| { - let EntityType::Line(l) = entity else { - panic!("expected a Line") - }; - (l.end.x - l.start.x, l.end.y - l.start.y) - }; - let dir_a = solved + let moved_a = solved .iter() .find(|(h, _)| *h == a) - .map(|(_, e)| line_dir(e)) - .unwrap_or((10.0, 6.0)); - let (_, moved_entity) = solved - .iter() - .find(|(h, _)| *h == b) - .expect("b should be reported as moved"); - let dir_b = line_dir(moved_entity); - let cross = dir_a.0 * dir_b.1 - dir_a.1 * dir_b.0; + .map(|(_, entity)| entity) + .expect("the dragged line should be projected"); + assert!(solved.iter().all(|(handle, _)| *handle != b)); + let EntityType::Line(moved_a_line) = moved_a else { + panic!("expected a Line") + }; + assert_eq!(moved_a_line.start, Vector3::new(0.0, 0.0, 0.0)); + let EntityType::Line(fixed_b) = b_before.unwrap() else { + panic!("expected a Line") + }; + let dir_a = moved_a_line.end - moved_a_line.start; + let dir_b = fixed_b.end - fixed_b.start; + let cross = dir_a.x * dir_b.y - dir_a.y * dir_b.x; assert!( cross.abs() < 1e-6, "the previewed positions should be mutually parallel: dir_a={dir_a:?} dir_b={dir_b:?}" ); + assert_eq!(fixed_b.start.y - moved_a_line.start.y, 5.0); } #[test] diff --git a/src/scene/selection.rs b/src/scene/selection.rs index 4ebab412c..7469aed92 100644 --- a/src/scene/selection.rs +++ b/src/scene/selection.rs @@ -1008,6 +1008,16 @@ impl Scene { hover_changed = true; } hover_changed |= self.constraint_hover_highlights.remove(&h); + if self + .constraint_hover_refs + .iter() + .any(|reference| reference.entity == h) + { + self.constraint_hover_refs.clear(); + self.constraint_hover_wires.clear(); + self.constraint_hover_highlights.clear(); + hover_changed = true; + } self.hatches.remove(&h); self.images.remove(&h); self.meshes.remove(&h); diff --git a/src/scene/view/render.rs b/src/scene/view/render.rs index 82bfd2eb7..5cbeb605e 100644 --- a/src/scene/view/render.rs +++ b/src/scene/view/render.rs @@ -4254,15 +4254,20 @@ impl Scene { Arc::new(Vec::new()) }; let preview_wires = if !show_live_overlay - || (self.interim_wire.is_none() && self.preview_wires.is_empty()) + || (self.interim_wire.is_none() + && self.preview_wires.is_empty() + && self.constraint_hover_wires.is_empty()) { Arc::new(Vec::new()) } else { - let mut v: Vec = Vec::with_capacity(self.preview_wires.len() + 1); + let mut v: Vec = Vec::with_capacity( + self.preview_wires.len() + self.constraint_hover_wires.len() + 1, + ); if let Some(iw) = &self.interim_wire { v.push(iw.clone()); } v.extend(self.preview_wires.iter().cloned()); + v.extend(self.constraint_hover_wires.iter().cloned()); Arc::new(v) }; let preview_hatches = if show_live_overlay { diff --git a/src/ui/overlay.rs b/src/ui/overlay.rs index 001dc39d5..3501c2a6e 100644 --- a/src/ui/overlay.rs +++ b/src/ui/overlay.rs @@ -767,6 +767,7 @@ pub fn selection_overlay<'a>( crosshair: CrosshairOptions, selection_visual: SelectionVisualOptions, constraint_glyphs: Vec<(Point, [f32; 2], String, bool, bool, Vec)>, + constraint_glyph_tooltip: Option, ) -> Element<'a, Message> { canvas(SelectionCanvas { selection, @@ -791,6 +792,7 @@ pub fn selection_overlay<'a>( crosshair, selection_visual, constraint_glyphs, + constraint_glyph_tooltip, }) .width(Length::Fill) .height(Length::Fill) @@ -853,6 +855,8 @@ struct SelectionCanvas { /// state, whether the pill itself is the current click-to-select target, /// and the points to mark while the pill is hovered. constraint_glyphs: Vec<(Point, [f32; 2], String, bool, bool, Vec)>, + /// Localized kind name made visible after the app-level hover dwell. + constraint_glyph_tooltip: Option, } fn draw_grip_marker( @@ -1786,7 +1790,7 @@ impl canvas::Program for SelectionCanvas { let conflict_fg = theme.palette().danger.base.text; let selected_ring = theme.palette().primary.strong.color; for ((anchor, outward, label, is_conflicting, is_selected, _), tangent_offset) in - self.constraint_glyphs.iter().zip(offsets) + self.constraint_glyphs.iter().zip(offsets.iter().copied()) { if !anchor.x.is_finite() || !anchor.y.is_finite() { continue; @@ -1852,6 +1856,41 @@ impl canvas::Program for SelectionCanvas { frame.stroke(&first, stroke.clone()); frame.stroke(&second, stroke.clone()); } + if let Some(label) = &self.constraint_glyph_tooltip { + let (glyph_top_left, glyph_size) = constraint_glyph_box( + self.constraint_glyphs[index].0, + self.constraint_glyphs[index].1, + &self.constraint_glyphs[index].2, + offsets[index], + ); + let width = (label.chars().count() as f32 * 7.0 + 14.0).max(54.0); + let height = 24.0; + let left = glyph_top_left.x.clamp(2.0, (bounds.width - width - 2.0).max(2.0)); + let top = (glyph_top_left.y + glyph_size.height + 4.0) + .clamp(2.0, (bounds.height - height - 2.0).max(2.0)); + let tooltip = canvas::Path::rounded_rectangle( + Point::new(left, top), + Size::new(width, height), + 4.0.into(), + ); + frame.fill(&tooltip, theme.palette().background.strong.color); + frame.stroke( + &tooltip, + canvas::Stroke::default() + .with_color(theme.palette().background.strong.text) + .with_width(1.0), + ); + frame.fill_text(canvas::Text { + content: label.clone(), + position: Point::new(left + width * 0.5, top + height * 0.5), + color: theme.palette().background.strong.text, + size: iced::Pixels(12.0), + align_x: iced::alignment::Horizontal::Center.into(), + align_y: iced::alignment::Vertical::Center, + shaping: iced::advanced::text::Shaping::Advanced, + ..Default::default() + }); + } } } // Small cross at each acquired tracking point.