feat(grip): multi-functional hover popup menu

Phase 2 of the grip overhaul: when the cursor dwells on a selected
entity's grip the overlay opens a popup menu with entity-specific
options (Stretch / Add Vertex / Remove Vertex / Convert to
Arc-Line / …). Picking an item dispatches `apply_grip_menu`; the
default `Stretch` entry is a no-op that lets the user click the
grip to start the normal drag.

- `GripMenuItem` + `GripMenuAction` (full vocabulary covering
  stretch, add/remove vertex, convert to arc/line, reverse arrows,
  text-position variants, hatch parameters, tangent direction).
- `Grippable` trait gains `grip_menu` + `apply_grip_menu` with
  trait-default `Stretch`-only impls so every existing entity gets
  the popup for free.
- `EntityTypeOps` dispatches both methods across the same entity
  list as the existing grip / property machinery.
- `OpenCADStudio` carries `grip_hover` (dwell tracker) +
  `grip_popup` (open menu). `update_grip_hover` runs from
  `ViewportMove`: hit-tests grips, refreshes the dwell timer, and
  opens the popup after 600 ms. Cursor drift past 80 px or a grip
  click dismisses it.
- `view.rs` paints the popup as an absolute-positioned column of
  buttons at the grip's screen anchor; each button publishes
  `Message::GripMenuPick(idx)`.
- `LwPolyline` overrides `Grippable` manually (skips the standard
  macro) so its vertex grip exposes Add/Remove Vertex and its
  segment-midpoint grip exposes Add Vertex + Convert to Arc/Line.
  `apply_grip_menu` performs the edits: vertex insertion at the
  chord midpoint inheriting the previous bulge, vertex removal,
  and bulge toggle for the arc/line conversion.
This commit is contained in:
Hakan Seven 2026-05-29 09:30:54 +03:00
commit 6c1462932d
6 changed files with 443 additions and 2 deletions

View file

@ -440,7 +440,121 @@ impl TruckConvertible for LwPolyline {
}
}
crate::impl_entity_basics!(LwPolyline);
impl crate::entities::traits::Grippable for LwPolyline {
fn grips(&self) -> Vec<crate::scene::object::GripDef> {
grips(self)
}
fn apply_grip(
&mut self,
grip_id: usize,
apply: crate::scene::object::GripApply,
) {
apply_grip(self, grip_id, apply);
}
fn grip_menu(
&self,
grip_id: usize,
) -> Vec<crate::scene::object::GripMenuItem> {
use crate::scene::object::{GripMenuAction, GripMenuItem};
let n = self.vertices.len();
if grip_id < n {
// Vertex grip.
return vec![
GripMenuItem { label: "Stretch", action: GripMenuAction::Stretch },
GripMenuItem { label: "Add Vertex", action: GripMenuAction::AddVertex },
GripMenuItem { label: "Remove Vertex", action: GripMenuAction::RemoveVertex },
];
}
// Segment midpoint grip.
let seg = grip_id - n;
let is_arc = self
.vertices
.get(seg)
.map_or(false, |v| v.bulge.abs() >= 1e-9);
let convert = if is_arc {
GripMenuItem { label: "Convert to Line", action: GripMenuAction::ConvertToLine }
} else {
GripMenuItem { label: "Convert to Arc", action: GripMenuAction::ConvertToArc }
};
vec![
GripMenuItem { label: "Stretch", action: GripMenuAction::Stretch },
GripMenuItem { label: "Add Vertex", action: GripMenuAction::AddVertex },
convert,
]
}
fn apply_grip_menu(
&mut self,
grip_id: usize,
action: crate::scene::object::GripMenuAction,
) {
use crate::scene::object::GripMenuAction as A;
let n = self.vertices.len();
match action {
A::Stretch => {}
A::AddVertex => {
// Insert a new vertex at the chord midpoint between the
// hovered vertex (or segment) and its neighbour. The new
// vertex inherits the previous vertex's bulge so an
// existing arc is split into two arcs of the same chord.
let (i0, i1) = if grip_id < n {
let i0 = grip_id;
let i1 = (grip_id + 1) % n;
(i0, i1)
} else {
let seg = grip_id - n;
(seg, (seg + 1) % n)
};
if i1 == 0 && !self.is_closed {
return;
}
let v0 = &self.vertices[i0];
let v1 = &self.vertices[i1];
let mx = (v0.location.x + v1.location.x) * 0.5;
let my = (v0.location.y + v1.location.y) * 0.5;
let inherited = v0.clone();
let mut new_v = inherited.clone();
new_v.location.x = mx;
new_v.location.y = my;
let insert_at = (i0 + 1).min(self.vertices.len());
self.vertices.insert(insert_at, new_v);
}
A::RemoveVertex if grip_id < n && self.vertices.len() > 2 => {
self.vertices.remove(grip_id);
}
A::ConvertToArc if grip_id >= n => {
if let Some(v) = self.vertices.get_mut(grip_id - n) {
if v.bulge.abs() < 1e-9 {
v.bulge = 0.5;
}
}
}
A::ConvertToLine if grip_id >= n => {
if let Some(v) = self.vertices.get_mut(grip_id - n) {
v.bulge = 0.0;
}
}
_ => {}
}
}
}
impl crate::entities::traits::PropertyEditable for LwPolyline {
fn geometry_properties(
&self,
_text_style_names: &[String],
) -> crate::scene::object::PropSection {
properties(self)
}
fn apply_geom_prop(&mut self, field: &str, value: &str) {
apply_geom_prop(self, field, value);
}
}
impl crate::entities::traits::Transformable for LwPolyline {
fn apply_transform(&mut self, t: &crate::command::EntityTransform) {
apply_transform(self, t);
}
}
pub(crate) fn wide_fills(pl: &acadrust::entities::LwPolyline) -> Vec<Vec<[f32; 2]>> {
let hw_const = (pl.constant_width / 2.0) as f32;

View file

@ -2,7 +2,9 @@ use acadrust::{CadDocument, EntityType};
use crate::command::EntityTransform;
use crate::scene::acad_to_truck::TruckEntity;
use crate::scene::object::{GripApply, GripDef, PropSection};
use crate::scene::object::{
GripApply, GripDef, GripMenuAction, GripMenuItem, PropSection,
};
use crate::scene::tess_util::FallbackGeometry;
pub trait TruckConvertible {
@ -20,6 +22,19 @@ pub trait FallbackTess {
pub trait Grippable {
fn grips(&self) -> Vec<GripDef>;
fn apply_grip(&mut self, grip_id: usize, apply: GripApply);
/// Items shown in the hover popup that opens when the cursor
/// dwells on a grip. Default is a single `Stretch` entry — i.e.
/// the popup just confirms the default drag behaviour. Override
/// per entity to add Add Vertex / Convert to Arc / Reverse Arrows /
/// etc.
fn grip_menu(&self, _grip_id: usize) -> Vec<GripMenuItem> {
vec![GripMenuItem { label: "Stretch", action: GripMenuAction::Stretch }]
}
/// React to a popup-menu commit. Default no-op — `Stretch` is the
/// "do nothing extra" path, the normal drag still happens on click.
/// Entities override this to mutate themselves when the user picks
/// Add Vertex / Remove Vertex / Convert / Reverse / …
fn apply_grip_menu(&mut self, _grip_id: usize, _action: GripMenuAction) {}
}
pub trait PropertyEditable {
@ -58,9 +73,11 @@ pub trait TextContent {
pub trait EntityTypeOps {
fn to_truck_entity(&self, document: &CadDocument) -> Option<TruckEntity>;
fn grips(&self) -> Vec<GripDef>;
fn grip_menu(&self, grip_id: usize) -> Vec<GripMenuItem>;
fn geometry_properties(&self, text_style_names: &[String]) -> Option<PropSection>;
fn apply_geom_prop(&mut self, field: &str, value: &str);
fn apply_grip(&mut self, grip_id: usize, apply: GripApply);
fn apply_grip_menu(&mut self, grip_id: usize, action: GripMenuAction);
fn apply_transform(&mut self, t: &EntityTransform);
fn mass_props(&self) -> Option<MassProps>;
fn text_content(&self) -> Option<String>;
@ -238,6 +255,38 @@ impl EntityTypeOps for EntityType {
)
}
fn grip_menu(&self, grip_id: usize) -> Vec<GripMenuItem> {
dispatch!(self,
|e| Grippable::grip_menu(e, grip_id),
[
Line, Circle, Arc, Ellipse, LwPolyline, Polyline, Polyline2D,
Polyline3D, Ray, XLine, RasterImage, Wipeout,
AttributeDefinition, AttributeEntity, MLine, Tolerance,
Solid, Solid3D, Region, Body, Face3D, PolygonMesh,
PolyfaceMesh, Mesh, Table, Point, Spline, Text, MText,
Viewport, Insert, Leader, MultiLeader, Dimension, Hatch,
Underlay, Shape, Ole2Frame,
],
_ => vec![],
)
}
fn apply_grip_menu(&mut self, grip_id: usize, action: GripMenuAction) {
dispatch!(self,
|e| Grippable::apply_grip_menu(e, grip_id, action),
[
Line, Circle, Arc, Ellipse, LwPolyline, Polyline, Polyline2D,
Polyline3D, Ray, XLine, RasterImage, Wipeout,
AttributeDefinition, AttributeEntity, MLine, Tolerance,
Solid, Solid3D, Region, Body, Face3D, PolygonMesh,
PolyfaceMesh, Mesh, Table, Point, Spline, Text, MText,
Viewport, Insert, Leader, MultiLeader, Dimension, Hatch,
Underlay, Shape, Ole2Frame,
],
_ => {},
)
}
fn apply_transform(&mut self, t: &EntityTransform) {
dispatch!(self,
|e| Transformable::apply_transform(e, t),