diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index abc196da5..699ef7dbf 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -3165,9 +3165,11 @@ impl OpenCADStudio { .copied() .map(|handle| (handle, crate::scene::ChangeKind::Modified)) .collect(); - self.tabs[i] - .scene - .bump_entities_with_parametric_driven(&changes, &[first_ref]); + self.tabs[i].scene.bump_entities_with_parametric_policy( + &changes, + &[first_ref], + self.constraint_solve_mode, + ); self.tabs[i].dirty = true; self.tabs[i].snap_result = None; if !multiple { @@ -8601,6 +8603,21 @@ mod parametric_constraint_undo_tests { entities.contains(&a) && entities.contains(&b), "the constraint should reference both lines" ); + let Some(acadrust::EntityType::Line(first)) = + app.tabs[app.active_tab].scene.document.get_entity(a) + else { + panic!("expected first line"); + }; + let Some(acadrust::EntityType::Line(second)) = + app.tabs[app.active_tab].scene.document.get_entity(b) + else { + panic!("expected second line"); + }; + assert!( + (first.end - acadrust::types::Vector3::new(5.0, 0.0, 0.0)).length() < 1.0e-7 + ); + assert!((second.start - first.end).length() < 1.0e-7); + assert!((second.length() - 5.0).abs() < 1.0e-7); } /// A pick that doesn't land near any real point (no object snap match) diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index 6730f4509..943119b3d 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -813,7 +813,14 @@ bg={bg_ms:.1}ms n={view_count}" .active_cmd .as_ref() .is_some_and(|cmd| !cmd.needs_entity_pick() && !cmd.is_selection_gathering()); - let constraint_glyphs: Vec<(iced::Point, [f32; 2], String, bool, bool)> = if is_paper { + let constraint_glyphs: Vec<( + iced::Point, + [f32; 2], + String, + bool, + bool, + Vec, + )> = if is_paper { Vec::new() } else { let scope = tab.current_parametric_scope(); @@ -825,9 +832,16 @@ bg={bg_ms:.1}ms n={view_count}" self.constraint_bar_display, ) .into_iter() - .map(|(id, point, direction, label, is_conflicting)| { + .map(|(id, point, direction, label, is_conflicting, hover_points)| { let selected = tab.scene.selected_constraint == Some(id); - (point, direction, label, is_conflicting, selected) + ( + point, + direction, + label, + is_conflicting, + selected, + hover_points, + ) }) .collect() }; diff --git a/src/command.rs b/src/command.rs index b5adb586d..d88fa1727 100644 --- a/src/command.rs +++ b/src/command.rs @@ -1479,9 +1479,9 @@ pub enum CmdResult { }, /// Opens the Auto Constrain settings dialog from the selection prompt. OpenAutoConstrainSettings, - /// Adds a Coincident constraint between the sub-entity points nearest - /// the two picked positions. The host resolves the raw points because - /// `CadCommand` has no document access. + /// Adds an ordered Coincident relation. Point/point selections create a + /// Coincident constraint; point/curve selections create the corresponding + /// point-on-curve relation while retaining Coincident command semantics. AddCoincidentConstraint { first: CoincidentPick, second: CoincidentPick, diff --git a/src/modules/parametric/coincident.rs b/src/modules/parametric/coincident.rs index cb4f3b7a6..0a13b7329 100644 --- a/src/modules/parametric/coincident.rs +++ b/src/modules/parametric/coincident.rs @@ -299,3 +299,61 @@ impl CadCommand for CoincidentConstraintCommand { inventory::submit!(crate::command::CommandRegistration { names: &["CCONSTRAINT", "GCCOINCIDENT"] }); + +#[cfg(test)] +mod tests { + use super::*; + use acadrust::types::Vector3; + + #[test] + fn multiple_keeps_the_first_curve_for_repeated_points() { + let mut command = CoincidentConstraintCommand::new(); + assert!(matches!( + command.on_text_input("Object"), + Some(CmdResult::NeedPoint) + )); + command.inject_picked_entity(EntityType::Line( + acadrust::entities::Line::from_points(Vector3::ZERO, Vector3::UNIT_X), + )); + let handle = Handle::new(7); + assert!(matches!( + command.on_entity_pick(handle, DVec3::ZERO), + CmdResult::NeedPoint + )); + assert!(matches!( + command.on_text_input("Multiple"), + Some(CmdResult::NeedPoint) + )); + + assert!(matches!( + command.on_point(DVec3::X), + CmdResult::AddCoincidentConstraint { + first: CoincidentPick { + handle: Some(found), + whole_curve: true, + .. + }, + multiple: true, + .. + } if found == handle + )); + } + + #[test] + fn automatic_mode_returns_the_completed_selection() { + let mut command = CoincidentConstraintCommand::new(); + assert!(matches!( + command.on_text_input("Autoconstrain"), + Some(CmdResult::NeedPoint) + )); + let handles = vec![Handle::new(3), Handle::new(5)]; + assert!(matches!( + command.on_selection_complete(handles.clone()), + CmdResult::NeedPoint + )); + assert!(matches!( + command.on_enter(), + CmdResult::AddAutoCoincidentConstraints { handles: found } if found == handles + )); + } +} diff --git a/src/modules/parametric/geom_constraint.rs b/src/modules/parametric/geom_constraint.rs index 6bf27ddde..9f3189f8c 100644 --- a/src/modules/parametric/geom_constraint.rs +++ b/src/modules/parametric/geom_constraint.rs @@ -85,3 +85,21 @@ impl CadCommand for GeomConstraintCommand { inventory::submit!(crate::command::CommandRegistration { names: &["GEOMCONSTRAINT"] }); + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn chooser_dispatches_an_existing_constraint_command() { + let mut command = GeomConstraintCommand::new(); + assert!(matches!( + command.on_text_input("Parallel"), + Some(CmdResult::Dispatch(found)) if found == "PCONSTRAINT" + )); + assert!(matches!( + command.on_enter(), + CmdResult::Dispatch(found) if found == "GCCOINCIDENT" + )); + } +} diff --git a/src/scene/parametric_constraints.rs b/src/scene/parametric_constraints.rs index 57d83c8fc..9c7fe40cb 100644 --- a/src/scene/parametric_constraints.rs +++ b/src/scene/parametric_constraints.rs @@ -418,38 +418,10 @@ pub(crate) fn resolve_point(entity: &acadrust::EntityType, marker: i32) -> Optio } .segment_midpoint_index(); if let Some(segment) = segment { - let points = super::dimension_assoc::source_points(entity); - let closed = match entity { - acadrust::EntityType::LwPolyline(polyline) => polyline.is_closed, - acadrust::EntityType::Polyline2D(polyline) => polyline.is_closed(), - _ => return None, - }; - let a = *points.get(segment)?; - let b = if segment + 1 < points.len() { - points[segment + 1] - } else if closed { - *points.first()? - } else { - return None; - }; - let bulge = match entity { - acadrust::EntityType::LwPolyline(polyline) => { - polyline.vertices.get(segment)?.bulge - } - acadrust::EntityType::Polyline2D(polyline) => { - polyline.vertices.get(segment)?.bulge - } - _ => 0.0, - }; - if let Some(arc) = cadkernel::geom2d::BulgeArc::from_bulge( - [a.x, a.y], - [b.x, b.y], - bulge, - ) { - let midpoint = arc.sample(0.5); - return Some(Vector3::new(midpoint[0], midpoint[1], (a.z + b.z) * 0.5)); - } - return Some((a + b) * 0.5); + let planar = crate::entities::curve::entity_curve(entity)?; + let curve = planar.curve.segments().into_iter().nth(segment)?; + let point = planar.plane.point_at(curve.point_at(0.5)); + return Some(Vector3::new(point[0], point[1], point[2])); } if marker < 0 { return None; @@ -495,55 +467,21 @@ pub(crate) fn parametric_point_candidates( } } - let polyline_midpoints = |vertices: &[Vector3], bulges: &[f64], closed: bool| { - let mut result = Vec::new(); - for (index, bulge) in bulges.iter().copied().enumerate() { - let Some(a) = vertices.get(index).copied() else { - continue; - }; - let b = vertices - .get(index + 1) - .copied() - .or_else(|| closed.then(|| vertices.first().copied()).flatten()); - if let Some(b) = b { - let midpoint = cadkernel::geom2d::BulgeArc::from_bulge( - [a.x, a.y], - [b.x, b.y], - bulge, - ) - .map(|arc| { - let point = arc.sample(0.5); - Vector3::new(point[0], point[1], (a.z + b.z) * 0.5) - }) - .unwrap_or((a + b) * 0.5); - result.push(( - POLYLINE_SEGMENT_MIDPOINT_MARKER_BASE - index as i32, - midpoint, - )); - } + if matches!( + entity, + acadrust::EntityType::LwPolyline(_) | acadrust::EntityType::Polyline2D(_) + ) { + if let Some(planar) = crate::entities::curve::entity_curve(entity) { + points.extend(planar.curve.segments().into_iter().enumerate().map( + |(index, curve)| { + let point = planar.plane.point_at(curve.point_at(0.5)); + ( + POLYLINE_SEGMENT_MIDPOINT_MARKER_BASE - index as i32, + Vector3::new(point[0], point[1], point[2]), + ) + }, + )); } - result - }; - match entity { - acadrust::EntityType::LwPolyline(polyline) => { - let vertices = super::dimension_assoc::source_points(entity); - let bulges: Vec<_> = polyline - .vertices - .iter() - .map(|vertex| vertex.bulge) - .collect(); - points.extend(polyline_midpoints(&vertices, &bulges, polyline.is_closed)); - } - acadrust::EntityType::Polyline2D(polyline) => { - let vertices = super::dimension_assoc::source_points(entity); - let bulges: Vec<_> = polyline - .vertices - .iter() - .map(|vertex| vertex.bulge) - .collect(); - points.extend(polyline_midpoints(&vertices, &bulges, polyline.is_closed())); - } - _ => {} } points } @@ -609,17 +547,7 @@ pub(crate) fn nearest_parametric_point_on_entity( .map(|(_, reference)| reference) } -fn point_segment_distance_squared(point: Vector3, start: Vector3, end: Vector3) -> f64 { - let edge = end - start; - let denominator = edge.length_squared(); - if denominator <= f64::EPSILON { - return (point - start).length_squared(); - } - let unit = ((point - start).dot(&edge) / denominator).clamp(0.0, 1.0); - (point - (start + edge * unit)).length_squared() -} - -/// Resolve the whole curve or the picked straight polyline segment used by a +/// Resolve the whole curve or the picked polyline segment used by a /// point-to-curve Coincident relation. pub(crate) fn parametric_curve_ref_for_pick( document: &acadrust::CadDocument, @@ -637,33 +565,10 @@ pub(crate) fn parametric_curve_ref_for_pick( | acadrust::EntityType::Arc(_) | acadrust::EntityType::Ellipse(_) | acadrust::EntityType::Spline(_) => Some(ParametricRef::whole(handle)), - acadrust::EntityType::LwPolyline(polyline) => { - let vertices = super::dimension_assoc::source_points(entity); - (0..polyline.vertices.len()) - .filter_map(|index| { - let start = vertices.get(index).copied()?; - let end = vertices - .get(index + 1) - .copied() - .or_else(|| polyline.is_closed.then(|| vertices.first().copied()).flatten())?; - Some((point_segment_distance_squared(world_point, start, end), index)) - }) - .min_by(|(a, _), (b, _)| a.total_cmp(b)) - .map(|(_, index)| ParametricRef::segment(handle, index)) - } - acadrust::EntityType::Polyline2D(polyline) => { - let vertices = super::dimension_assoc::source_points(entity); - (0..polyline.vertices.len()) - .filter_map(|index| { - let start = vertices.get(index).copied()?; - let end = vertices - .get(index + 1) - .copied() - .or_else(|| polyline.is_closed().then(|| vertices.first().copied()).flatten())?; - Some((point_segment_distance_squared(world_point, start, end), index)) - }) - .min_by(|(a, _), (b, _)| a.total_cmp(b)) - .map(|(_, index)| ParametricRef::segment(handle, index)) + acadrust::EntityType::LwPolyline(_) | acadrust::EntityType::Polyline2D(_) => { + let segments = crate::entities::curve::entity_curve_xy(entity)?.segments(); + cadkernel::geom2d::nearest_of(segments.iter(), [world_point.x, world_point.y]) + .map(|(index, _)| ParametricRef::segment(handle, index)) } _ => None, } @@ -790,6 +695,121 @@ pub(crate) fn glyph_placement( } } +/// 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. +fn constraint_segment_endpoints( + document: &acadrust::CadDocument, + reference: ParametricRef, +) -> Option<[Vector3; 2]> { + let segment = reference.segment_index()?; + let entity = document.get_entity(reference.entity)?; + let points = super::dimension_assoc::source_points(entity); + let closed = match entity { + acadrust::EntityType::LwPolyline(polyline) => polyline.is_closed, + acadrust::EntityType::Polyline2D(polyline) => polyline.is_closed(), + _ => return None, + }; + let first = *points.get(segment)?; + let second = if segment + 1 < points.len() { + points[segment + 1] + } else if closed { + *points.first()? + } else { + return None; + }; + Some([first, second]) +} + +fn constraint_reference_curve_xy( + document: &acadrust::CadDocument, + reference: ParametricRef, +) -> Option { + let curve = crate::entities::curve::entity_curve_xy(document.get_entity(reference.entity)?)?; + reference + .segment_index() + .map(|index| curve.segments().into_iter().nth(index)) + .unwrap_or(Some(curve)) +} + +pub(crate) fn constraint_hover_points( + document: &acadrust::CadDocument, + constraint: &ParametricConstraint, +) -> Vec { + let mut points = Vec::new(); + let push_unique = |points: &mut Vec, point: Vector3| { + if point.x.is_finite() + && point.y.is_finite() + && point.z.is_finite() + && !points + .iter() + .any(|existing| (*existing - point).length_squared() <= 1.0e-12) + { + points.push(point); + } + }; + + for reference in &constraint.refs { + let Some(marker) = reference.marker else { + continue; + }; + let Some(entity) = document.get_entity(reference.entity) else { + continue; + }; + if let Some(point) = resolve_point(entity, marker) { + push_unique(&mut points, point); + } + } + + if matches!( + constraint.kind, + ConstraintKind::Horizontal | ConstraintKind::Vertical + ) { + for reference in &constraint.refs { + let Some(entity) = document.get_entity(reference.entity) else { + continue; + }; + if let Some(endpoints) = constraint_segment_endpoints(document, *reference) { + for point in endpoints { + push_unique(&mut points, point); + } + } else if matches!(entity, acadrust::EntityType::Line(_)) { + for point in super::dimension_assoc::source_points(entity) { + push_unique(&mut points, point); + } + } + } + } + + if matches!( + constraint.kind, + ConstraintKind::Perpendicular | ConstraintKind::Tangent + ) { + if let [first, second, ..] = constraint.refs.as_slice() { + if let (Some(first_curve), Some(second_curve)) = ( + constraint_reference_curve_xy(document, *first), + constraint_reference_curve_xy(document, *second), + ) { + let elevation = glyph_placement(document, constraint) + .map(|(anchor, _)| anchor.z) + .unwrap_or(0.0); + for crossing in cadkernel::geom2d::intersect( + &first_curve, + &second_curve, + cadkernel::geom2d::Tolerance::default(), + ) { + push_unique( + &mut points, + Vector3::new(crossing.point[0], crossing.point[1], elevation), + ); + } + } + } + } + + points +} + impl super::Scene { /// Expand a set of entities to the full enabled constraint component in /// one scope. Whole-object translations use this to keep every connected @@ -1253,8 +1273,9 @@ impl super::Scene { } /// Screen-projected parametric-constraint glyph placements for `scope`: - /// `(id, anchor, outward_screen_direction, label, is_conflicting)` for - /// every enabled, visible constraint whose glyph projects on-screen. + /// `(id, anchor, outward_screen_direction, label, is_conflicting, + /// hover_points)` for every enabled, visible constraint whose glyph + /// projects on-screen. /// `vp_size` is the full canvas size (as `SelectionState::vp_size` /// reports it), matching what `viewport_edit_frame`/ /// `active_model_tile_bounds` expect. Mirrors the projection @@ -1269,7 +1290,14 @@ impl super::Scene { vp_size: (f32, f32), show_values: bool, display_mode: i16, - ) -> Vec<(ConstraintId, iced::Point, [f32; 2], String, bool)> { + ) -> Vec<( + ConstraintId, + iced::Point, + [f32; 2], + String, + bool, + Vec, + )> { let Some(set) = self.parametric_constraint_set(scope) else { return Vec::new(); }; @@ -1323,7 +1351,30 @@ impl super::Scene { } else { c.kind.glyph_symbol().to_string() }; - point.x.is_finite().then_some((c.id, point, direction.to_array(), label, is_conflicting)) + let hover_points = constraint_hover_points(&self.document, c) + .into_iter() + .filter_map(|hover_point| { + let projected = crate::scene::pick::grip::project_rte( + glam::DVec3::new(hover_point.x, hover_point.y, hover_point.z), + view_rot, + eye, + bounds, + )?; + let point = iced::Point::new( + bounds.x + projected.x, + bounds.y + projected.y, + ); + (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, + )) }) .collect() } @@ -1349,7 +1400,7 @@ impl super::Scene { ); let glyphs: Vec<(iced::Point, [f32; 2], String, bool)> = placements .iter() - .map(|(_, point, direction, label, is_conflicting)| { + .map(|(_, point, direction, label, is_conflicting, _)| { (*point, *direction, label.clone(), *is_conflicting) }) .collect(); @@ -1835,4 +1886,77 @@ mod tests { assert!(!scene.should_display_parametric_constraint(scope, id, true, 2)); } + + #[test] + fn hover_geometry_keeps_a_bulged_polyline_segment_curved() { + let mut document = acadrust::CadDocument::new(); + let mut polyline = acadrust::entities::LwPolyline::new(); + polyline.common.handle = h(1); + polyline.vertices = vec![ + acadrust::entities::LwVertex::with_bulge( + acadrust::types::Vector2::new(0.0, 0.0), + 1.0, + ), + acadrust::entities::LwVertex::from_coords(10.0, 0.0), + ]; + document + .add_entity(acadrust::EntityType::LwPolyline(polyline)) + .unwrap(); + + assert!(matches!( + constraint_reference_curve_xy(&document, ParametricRef::segment(h(1), 0)), + Some(cadkernel::geom2d::Curve::Arc(_)) + )); + } + + #[test] + fn curve_pick_uses_the_bulged_segment_instead_of_its_chord() { + let mut scene = super::super::Scene::new(); + 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::from_coords(10.0, 0.0), + acadrust::entities::LwVertex::from_coords(0.0, -4.0), + ]; + let handle = scene.add_entity(acadrust::EntityType::LwPolyline(polyline)); + + assert_eq!( + parametric_curve_ref_for_pick( + &scene.document, + ParametricScope::ModelSpace, + handle, + Vector3::new(5.0, -5.0, 0.0), + ), + Some(ParametricRef::segment(handle, 0)) + ); + } + + #[test] + fn move_expansion_follows_only_coincident_relations() { + let mut scene = super::super::Scene::new(); + let set = scene.parametric_constraint_set_mut(ParametricScope::ModelSpace); + set.add( + ConstraintKind::Coincident, + vec![ParametricRef::point(h(1), 0), ParametricRef::point(h(2), 0)], + None, + ); + set.add( + ConstraintKind::PointOnCurve, + vec![ParametricRef::point(h(2), 0), ParametricRef::whole(h(3))], + None, + ); + set.add( + ConstraintKind::Parallel, + vec![ParametricRef::whole(h(3)), ParametricRef::whole(h(4))], + None, + ); + + assert_eq!( + scene.parametric_connected_handles(ParametricScope::ModelSpace, &[h(1)]), + vec![h(1), h(2), h(3)] + ); + } } diff --git a/src/scene/parametric_solve.rs b/src/scene/parametric_solve.rs index 5424028f2..281558740 100644 --- a/src/scene/parametric_solve.rs +++ b/src/scene/parametric_solve.rs @@ -1823,14 +1823,10 @@ fn solve_scope( } EntityGeom::Polyline { points, - straight, closed, .. } => { - for index in 0..straight.len() { - if !straight[index] { - continue; - } + for index in 0..points.len() { let Some(a) = points.get(index).copied() else { continue; }; @@ -2748,6 +2744,26 @@ mod tests { .is_err()); } + #[test] + fn bulged_polyline_segment_registers_as_a_kernel_arc() { + let mut scene = Scene::new(); + 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::from_coords(10.0, 0.0), + ]; + let handle = scene.add_entity(EntityType::LwPolyline(polyline)); + let mut system = cadkernel_constraints::system::System::new(); + let geometry = super::register_entity(&scene.document, &mut system, handle).unwrap(); + let segment = geometry.arc_segment(0).expect("expected an arc segment"); + + assert!((system.store().get(segment.arc.circle.rad) - 5.0).abs() < 1.0e-9); + assert!((segment.sweep - std::f64::consts::PI).abs() < 1.0e-9); + } + #[test] fn ray_and_construction_line_constraints_solve_through_the_kernel() { let mut scene = Scene::new(); diff --git a/src/scene/view/dispatch.rs b/src/scene/view/dispatch.rs index d58128961..86a8e5ea4 100644 --- a/src/scene/view/dispatch.rs +++ b/src/scene/view/dispatch.rs @@ -271,9 +271,8 @@ pub fn set_entity_xdata( } /// The extrusion thickness (DXF 39) of the entities that carry one, or `None` -/// for entity types that have none. Thickness is a per-entity field but is -/// surfaced in the General group (as in a standard properties palette), so -/// this bridges the two. +/// for entity types that have none. The Properties panel uses this to append +/// the shared field to each entity's Geometry group. pub fn entity_thickness(entity: &EntityType) -> Option { Some(match entity { EntityType::Arc(e) => e.thickness, @@ -401,3 +400,26 @@ pub fn grip_menu_point_value( pub fn apply_transform(entity: &mut EntityType, t: &EntityTransform) { EntityTypeOps::apply_transform(entity, t); } + +#[cfg(test)] +mod tests { + use super::*; + use acadrust::types::Vector3; + + #[test] + fn line_thickness_follows_the_entity_geometry_rows() { + let mut line = acadrust::entities::Line::from_points(Vector3::ZERO, Vector3::UNIT_X); + line.thickness = 2.5; + let sections = properties_sectioned(Handle::NULL, &EntityType::Line(line), &[]); + + assert!(!sections[0] + .props + .iter() + .any(|property| property.field == "thickness")); + let geometry = sections + .iter() + .find(|section| section.props.iter().any(|property| property.field == "start_x")) + .unwrap(); + assert_eq!(geometry.props.last().unwrap().field, "thickness"); + } +} diff --git a/src/ui/overlay.rs b/src/ui/overlay.rs index 4e9b2fab7..001dc39d5 100644 --- a/src/ui/overlay.rs +++ b/src/ui/overlay.rs @@ -27,6 +27,7 @@ const CONSTRAINT_GLYPH_PAD_X: f32 = 7.0; const CONSTRAINT_GLYPH_PAD_Y: f32 = 4.0; const CONSTRAINT_GLYPH_GAP: f32 = 6.0; const CONSTRAINT_GLYPH_ROW_GAP: f32 = 4.0; +const CONSTRAINT_HOVER_MARKER_RADIUS: f32 = 7.0; fn constraint_glyph_size(label: &str) -> Size { let w = label.chars().count() as f32 * CONSTRAINT_GLYPH_SIZE * 0.62 @@ -35,6 +36,22 @@ fn constraint_glyph_size(label: &str) -> Size { Size::new(w, h) } +fn draw_tangent_constraint_glyph( + frame: &mut canvas::Frame, + center: Point, + color: Color, +) { + let radius = 4.5; + let circle_center = Point::new(center.x - 0.3, center.y + 2.75); + let diagonal = radius * std::f32::consts::FRAC_1_SQRT_2; + let contact = Point::new(circle_center.x - diagonal, circle_center.y - diagonal); + let tangent_end = Point::new(contact.x + 8.0, contact.y - 8.0); + let stroke = canvas::Stroke::default().with_color(color).with_width(1.35); + + frame.stroke(&canvas::Path::circle(circle_center, radius), stroke.clone()); + frame.stroke(&canvas::Path::line(contact, tangent_end), stroke); +} + fn constraint_glyph_box( anchor: Point, outward: [f32; 2], @@ -749,7 +766,7 @@ pub fn selection_overlay<'a>( crosshair_bg: [f32; 4], crosshair: CrosshairOptions, selection_visual: SelectionVisualOptions, - constraint_glyphs: Vec<(Point, [f32; 2], String, bool, bool)>, + constraint_glyphs: Vec<(Point, [f32; 2], String, bool, bool, Vec)>, ) -> Element<'a, Message> { canvas(SelectionCanvas { selection, @@ -833,9 +850,9 @@ struct SelectionCanvas { crosshair: CrosshairOptions, selection_visual: SelectionVisualOptions, /// Constraint glyph anchor, outward screen direction, label, conflict - /// state, and whether the pill itself is the current click-to-select - /// target (`Scene::selected_constraint`) — for the current scope. - constraint_glyphs: Vec<(Point, [f32; 2], String, bool, bool)>, + /// 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)>, } fn draw_grip_marker( @@ -1021,7 +1038,7 @@ impl canvas::Program for SelectionCanvas { let glyphs: Vec<_> = self .constraint_glyphs .iter() - .map(|(anchor, outward, label, conflict, _)| { + .map(|(anchor, outward, label, conflict, _, _)| { (*anchor, *outward, label.clone(), *conflict) }) .collect(); @@ -1747,15 +1764,18 @@ impl canvas::Program for SelectionCanvas { if !self.constraint_glyphs.is_empty() { // `constraint_glyph_offsets` only needs the anchor/outward/label/ // conflict quadruple it was written against; project away the - // trailing `is_selected` flag rather than widen its signature. + // trailing display fields rather than widen its signature. let glyphs_for_offsets: Vec<(Point, [f32; 2], String, bool)> = self .constraint_glyphs .iter() - .map(|(anchor, outward, label, is_conflicting, _)| { + .map(|(anchor, outward, label, is_conflicting, _, _)| { (*anchor, *outward, label.clone(), *is_conflicting) }) .collect(); let offsets = constraint_glyph_offsets(&glyphs_for_offsets); + let hovered = cursor + .position_in(bounds) + .and_then(|point| constraint_glyph_hit_test(&glyphs_for_offsets, point)); let normal_bg = theme.palette().primary.base.color; let normal_fg = theme.palette().primary.base.text; // A redundant or conflicting constraint gets the danger palette @@ -1765,7 +1785,7 @@ impl canvas::Program for SelectionCanvas { let conflict_bg = theme.palette().danger.base.color; 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 + for ((anchor, outward, label, is_conflicting, is_selected, _), tangent_offset) in self.constraint_glyphs.iter().zip(offsets) { if !anchor.x.is_finite() || !anchor.y.is_finite() { @@ -1786,19 +1806,52 @@ impl canvas::Program for SelectionCanvas { canvas::Stroke::default().with_color(selected_ring).with_width(2.0), ); } - frame.fill_text(canvas::Text { - content: label.clone(), - position: Point::new( - top_left.x + size.width * 0.5, - top_left.y + size.height * 0.5, - ), - color: fg, - size: iced::Pixels(CONSTRAINT_GLYPH_SIZE), - align_x: iced::alignment::Horizontal::Center.into(), - align_y: iced::alignment::Vertical::Center, - shaping: iced::advanced::text::Shaping::Advanced, - ..Default::default() - }); + let glyph_center = Point::new( + top_left.x + size.width * 0.5, + top_left.y + size.height * 0.5, + ); + if label == "T" { + draw_tangent_constraint_glyph(&mut frame, glyph_center, fg); + } else { + frame.fill_text(canvas::Text { + content: label.clone(), + position: glyph_center, + color: fg, + size: iced::Pixels(CONSTRAINT_GLYPH_SIZE), + align_x: iced::alignment::Horizontal::Center.into(), + align_y: iced::alignment::Vertical::Center, + shaping: iced::advanced::text::Shaping::Advanced, + ..Default::default() + }); + } + } + if let Some(index) = hovered { + let red = Color::from_rgb(1.0, 0.0, 0.0); + let stroke = canvas::Stroke::default().with_color(red).with_width(1.5); + for point in &self.constraint_glyphs[index].5 { + let first = canvas::Path::line( + Point::new( + point.x - CONSTRAINT_HOVER_MARKER_RADIUS, + point.y - CONSTRAINT_HOVER_MARKER_RADIUS, + ), + Point::new( + point.x + CONSTRAINT_HOVER_MARKER_RADIUS, + point.y + CONSTRAINT_HOVER_MARKER_RADIUS, + ), + ); + let second = canvas::Path::line( + Point::new( + point.x - CONSTRAINT_HOVER_MARKER_RADIUS, + point.y + CONSTRAINT_HOVER_MARKER_RADIUS, + ), + Point::new( + point.x + CONSTRAINT_HOVER_MARKER_RADIUS, + point.y - CONSTRAINT_HOVER_MARKER_RADIUS, + ), + ); + frame.stroke(&first, stroke.clone()); + frame.stroke(&second, stroke.clone()); + } } } // Small cross at each acquired tracking point.