feat(hatch): add and remove boundary vertices from the grip menu

A non-associative hatch's boundary vertex grips now offer Add Vertex and
Remove Vertex next to Stretch, on polyline boundaries and on boundaries
made of line edges alike. Add Vertex inserts the vertex after the picked
one, halfway along the next segment (on the arc for a bulged polyline
segment, splitting its bulge), and lets it be placed like any grip.
Remove Vertex drops it while keeping at least a triangle.

Line-edge boundaries used to show two grips at every corner (one line's
end, the next one's start), so dragging one opened the loop; each corner
is now one grip that moves both ends.

The automation grip_menu_pick action chooses an entry of the open grip
menu, so these edits can be scripted and checked.

Fixes #1058

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Hakan SevenandClaude Opus 5.5 committed 2026-10-13 14:38:58 +03:00
1 parent 4518569b61
commit 6490a625f2
4 files changed
+368 -134

No files matched your search

+15
View File
@@ -124,6 +124,7 @@ pub(super) const NAMES: &[&str] = &[
"toggle_grid",
"toggle_snap",
"toggle_ortho",
"grip_menu_pick",
"mtext_insert",
"mtext_commit",
"mtext_cancel",
@@ -1996,6 +1997,20 @@ impl OpenCADStudio {
"toggle_grid" => Message::ToggleGrid,
"toggle_snap" => Message::ToggleSnapEnabled,
"toggle_ortho" => Message::ToggleOrtho,
// Choose an entry of the open multi-functional grip menu by its
// position (0 = first), as a click on it does.
"grip_menu_pick" => {
let entries = self.grip_popup.as_ref().map_or(0, |popup| popup.items.len());
if entries == 0 {
return Err(failure("menu_closed", "No grip menu is open"));
}
let index = string(req, "value")?
.parse::<usize>()
.ok()
.filter(|index| *index < entries)
.ok_or_else(|| failure("invalid_value", "value is a menu entry index"))?;
Message::GripMenuPick(index)
}
"mtext_insert" => {
if self.mtext_editor.is_none() {
return Err(failure("editor_closed", "MText editor is closed"));
+13
View File
@@ -55,6 +55,16 @@ fn polyline_add_vertex_target(entity: &AcadEntityType, grip_id: usize) -> Option
AcadEntityType::Polyline3D(polyline) if grip_id < polyline.vertices.len() => {
Some((grip_id + 1, polyline.vertices.len()))
}
// A hatch boundary vertex: the new vertex is the next grip.
AcadEntityType::Hatch(hatch) => {
use crate::entities::traits::Grippable;
use crate::scene::model::object::GripMenuAction;
hatch
.grip_menu(grip_id)
.iter()
.any(|item| matches!(item.action, GripMenuAction::AddVertex))
.then(|| (grip_id + 1, hatch.grips().len()))
}
_ => None,
}
}
@@ -65,6 +75,9 @@ fn polyline_vertex_count(entity: &AcadEntityType) -> Option<usize> {
AcadEntityType::Polyline(polyline) => Some(polyline.vertices.len()),
AcadEntityType::Polyline2D(polyline) => Some(polyline.vertices.len()),
AcadEntityType::Polyline3D(polyline) => Some(polyline.vertices.len()),
AcadEntityType::Hatch(hatch) => {
Some(crate::entities::traits::Grippable::grips(hatch).len())
}
_ => None,
}
}
+339 -133
View File
@@ -758,7 +758,8 @@ impl Transformable for Hatch {
/// Assign sequential grip IDs across all boundary paths and edges.
/// Exposed control points per edge type:
/// Polyline → each vertex (x, y)
/// Line → start, end
/// Line → each corner once: where one line ends and the next
/// starts is a single grip moving both ends
/// CircularArc → center
/// EllipticArc → center
/// Spline → fit points if present, else control points (x, y)
@@ -784,51 +785,9 @@ impl Grippable for Hatch {
if self.is_associative {
return out;
}
for path in &self.paths {
for edge in &path.edges {
match edge {
BoundaryEdge::Polyline(p) => {
for v in &p.vertices {
out.push(center_grip(id, glam::DVec3::new(v.x, v.y, elev)));
id += 1;
}
}
BoundaryEdge::Line(l) => {
out.push(center_grip(
id,
glam::DVec3::new(l.start.x, l.start.y, elev),
));
id += 1;
out.push(center_grip(id, glam::DVec3::new(l.end.x, l.end.y, elev)));
id += 1;
}
BoundaryEdge::CircularArc(a) => {
out.push(center_grip(
id,
glam::DVec3::new(a.center.x, a.center.y, elev),
));
id += 1;
}
BoundaryEdge::EllipticArc(e) => {
out.push(center_grip(
id,
glam::DVec3::new(e.center.x, e.center.y, elev),
));
id += 1;
}
BoundaryEdge::Spline(s) => {
let pts: Vec<[f64; 2]> = if !s.fit_points.is_empty() {
s.fit_points.iter().map(|p| [p.x, p.y]).collect()
} else {
s.control_points.iter().map(|p| [p.x, p.y]).collect()
};
for [x, y] in pts {
out.push(center_grip(id, glam::DVec3::new(x, y, elev)));
id += 1;
}
}
}
}
for (_, [x, y]) in boundary_points(self) {
out.push(center_grip(id, glam::DVec3::new(x, y, elev)));
id += 1;
}
out
}
@@ -866,97 +825,40 @@ impl Grippable for Hatch {
id += 1;
}
'outer: for path in &mut self.paths {
for edge in &mut path.edges {
match edge {
BoundaryEdge::Polyline(p) => {
for v in &mut p.vertices {
if id == grip_id {
let (nx, ny) =
resolve(&apply, Vec3::new(v.x as f32, v.y as f32, elev));
v.x = nx;
v.y = ny;
break 'outer;
}
id += 1;
}
}
BoundaryEdge::Line(l) => {
if id == grip_id {
let (nx, ny) = resolve(
&apply,
Vec3::new(l.start.x as f32, l.start.y as f32, elev),
);
l.start.x = nx;
l.start.y = ny;
break 'outer;
}
id += 1;
if id == grip_id {
let (nx, ny) =
resolve(&apply, Vec3::new(l.end.x as f32, l.end.y as f32, elev));
l.end.x = nx;
l.end.y = ny;
break 'outer;
}
id += 1;
}
BoundaryEdge::CircularArc(a) => {
if id == grip_id {
let (nx, ny) = resolve(
&apply,
Vec3::new(a.center.x as f32, a.center.y as f32, elev),
);
a.center.x = nx;
a.center.y = ny;
break 'outer;
}
id += 1;
}
BoundaryEdge::EllipticArc(e) => {
if id == grip_id {
let (nx, ny) = resolve(
&apply,
Vec3::new(e.center.x as f32, e.center.y as f32, elev),
);
e.center.x = nx;
e.center.y = ny;
break 'outer;
}
id += 1;
}
BoundaryEdge::Spline(s) => {
if !s.fit_points.is_empty() {
for fp in &mut s.fit_points {
if id == grip_id {
let (nx, ny) =
resolve(&apply, Vec3::new(fp.x as f32, fp.y as f32, elev));
fp.x = nx;
fp.y = ny;
break 'outer;
}
id += 1;
}
} else {
for cp in &mut s.control_points {
if id == grip_id {
let (nx, ny) =
resolve(&apply, Vec3::new(cp.x as f32, cp.y as f32, elev));
cp.x = nx;
cp.y = ny;
break 'outer;
}
id += 1;
}
}
}
}
}
}
let Some(&(point, [x, y])) = boundary_points(self).get(grip_id.wrapping_sub(id)) else {
return;
};
let (nx, ny) = resolve(&apply, Vec3::new(x as f32, y as f32, elev));
set_boundary_point(self, point, nx, ny);
}
fn grip_menu(&self, grip_id: usize) -> Vec<crate::scene::model::object::GripMenuItem> {
use crate::scene::model::object::{GripMenuAction, GripMenuItem};
if let Some(point) = vertex_point_at(self, grip_id) {
let removable = match point {
BoundaryPoint::LineVertex { ending, starting, .. } => {
ending.is_some() && starting.is_some()
}
_ => true,
};
let mut items = vec![
GripMenuItem {
label: "Stretch",
action: GripMenuAction::Stretch,
},
GripMenuItem {
label: "Add Vertex",
action: GripMenuAction::AddVertex,
},
];
if removable {
items.push(GripMenuItem {
label: "Remove Vertex",
action: GripMenuAction::RemoveVertex,
});
}
return items;
}
if self.pattern.lines.is_empty() || grip_id != 0 {
return vec![GripMenuItem {
label: "Stretch",
@@ -989,6 +891,10 @@ impl Grippable for Hatch {
action: crate::scene::model::object::GripMenuAction,
) {
use crate::scene::model::object::GripMenuAction as A;
if let Some(point) = vertex_point_at(self, grip_id) {
edit_boundary_vertex(self, point, action);
return;
}
if grip_id == 0 && matches!(action, A::OriginPoint) {
if let (Some((gx, gy)), Some((_ox, _oy))) = (
boundary_centroid(self),
@@ -1099,3 +1005,303 @@ impl FallbackTess for Hatch {
(pts, snap_pts, vec![], key_verts)
}
}
/// Where one boundary grip's point lives.
#[derive(Clone, Copy, Debug, PartialEq)]
enum BoundaryPoint {
PolylineVertex { path: usize, edge: usize, vertex: usize },
/// A corner of a chain of line edges: the end of `ending` and the start
/// of `starting`, kept together so moving it never opens the boundary.
LineVertex { path: usize, ending: Option<usize>, starting: Option<usize> },
ArcCenter { path: usize, edge: usize },
EllipseCenter { path: usize, edge: usize },
SplinePoint { path: usize, edge: usize, index: usize, fit: bool },
}
fn same_point(a: &codec::types::Vector2, b: &codec::types::Vector2) -> bool {
let scale = a.x.abs().max(a.y.abs()).max(1.0);
(a.x - b.x).abs() <= 1e-9 * scale && (a.y - b.y).abs() <= 1e-9 * scale
}
/// The non-associative boundary's grip points, in grip order (after the
/// pattern-origin grip), each joined corner of a line chain once.
fn boundary_points(hatch: &Hatch) -> Vec<(BoundaryPoint, [f64; 2])> {
let mut out = Vec::new();
for (path_index, path) in hatch.paths.iter().enumerate() {
let mut pending_end: Option<usize> = None;
let mut first_start: Option<usize> = None;
let line = |edge: usize| match &path.edges[edge] {
BoundaryEdge::Line(line) => Some(line),
_ => None,
};
for (edge_index, edge) in path.edges.iter().enumerate() {
if let BoundaryEdge::Line(current) = edge {
match pending_end {
Some(previous) if same_point(&line(previous).unwrap().end, &current.start) => {
out.push((
BoundaryPoint::LineVertex {
path: path_index,
ending: Some(previous),
starting: Some(edge_index),
},
[current.start.x, current.start.y],
));
}
pending => {
if let Some(previous) = pending {
let end = &line(previous).unwrap().end;
out.push((
BoundaryPoint::LineVertex {
path: path_index,
ending: Some(previous),
starting: None,
},
[end.x, end.y],
));
}
if edge_index == 0 {
first_start = Some(out.len());
}
out.push((
BoundaryPoint::LineVertex {
path: path_index,
ending: None,
starting: Some(edge_index),
},
[current.start.x, current.start.y],
));
}
}
pending_end = Some(edge_index);
continue;
}
if let Some(previous) = pending_end.take() {
let end = &line(previous).unwrap().end;
out.push((
BoundaryPoint::LineVertex {
path: path_index,
ending: Some(previous),
starting: None,
},
[end.x, end.y],
));
}
match edge {
BoundaryEdge::Polyline(polyline) => {
for (vertex, v) in polyline.vertices.iter().enumerate() {
out.push((
BoundaryPoint::PolylineVertex {
path: path_index,
edge: edge_index,
vertex,
},
[v.x, v.y],
));
}
}
BoundaryEdge::CircularArc(arc) => out.push((
BoundaryPoint::ArcCenter {
path: path_index,
edge: edge_index,
},
[arc.center.x, arc.center.y],
)),
BoundaryEdge::EllipticArc(ellipse) => out.push((
BoundaryPoint::EllipseCenter {
path: path_index,
edge: edge_index,
},
[ellipse.center.x, ellipse.center.y],
)),
BoundaryEdge::Spline(spline) => {
let fit = !spline.fit_points.is_empty();
let points: Vec<[f64; 2]> = if fit {
spline.fit_points.iter().map(|p| [p.x, p.y]).collect()
} else {
spline.control_points.iter().map(|p| [p.x, p.y]).collect()
};
for (index, point) in points.into_iter().enumerate() {
out.push((
BoundaryPoint::SplinePoint {
path: path_index,
edge: edge_index,
index,
fit,
},
point,
));
}
}
BoundaryEdge::Line(_) => {}
}
}
// A closed chain: the last line ends where the first one starts.
if let Some(last) = pending_end {
let end = line(last).unwrap().end.clone();
match first_start.map(|slot| (slot, &mut out[slot])) {
Some((_, (BoundaryPoint::LineVertex { ending, starting: Some(0), .. }, _)))
if ending.is_none() && same_point(&line(0).unwrap().start, &end) =>
{
*ending = Some(last);
}
_ => out.push((
BoundaryPoint::LineVertex {
path: path_index,
ending: Some(last),
starting: None,
},
[end.x, end.y],
)),
}
}
}
out
}
/// Move one boundary grip point, every edge end it stands for included.
fn set_boundary_point(hatch: &mut Hatch, point: BoundaryPoint, x: f64, y: f64) {
match point {
BoundaryPoint::PolylineVertex { path, edge, vertex } => {
if let BoundaryEdge::Polyline(polyline) = &mut hatch.paths[path].edges[edge] {
polyline.vertices[vertex].x = x;
polyline.vertices[vertex].y = y;
}
}
BoundaryPoint::LineVertex { path, ending, starting } => {
if let Some(edge) = ending {
if let BoundaryEdge::Line(line) = &mut hatch.paths[path].edges[edge] {
line.end = codec::types::Vector2::new(x, y);
}
}
if let Some(edge) = starting {
if let BoundaryEdge::Line(line) = &mut hatch.paths[path].edges[edge] {
line.start = codec::types::Vector2::new(x, y);
}
}
}
BoundaryPoint::ArcCenter { path, edge } => {
if let BoundaryEdge::CircularArc(arc) = &mut hatch.paths[path].edges[edge] {
arc.center.x = x;
arc.center.y = y;
}
}
BoundaryPoint::EllipseCenter { path, edge } => {
if let BoundaryEdge::EllipticArc(ellipse) = &mut hatch.paths[path].edges[edge] {
ellipse.center.x = x;
ellipse.center.y = y;
}
}
BoundaryPoint::SplinePoint { path, edge, index, fit } => {
if let BoundaryEdge::Spline(spline) = &mut hatch.paths[path].edges[edge] {
if fit {
if let Some(point) = spline.fit_points.get_mut(index) {
point.x = x;
point.y = y;
}
} else if let Some(point) = spline.control_points.get_mut(index) {
point.x = x;
point.y = y;
}
}
}
}
}
/// The boundary vertex a grip stands for, when it is one that vertices can
/// be added after or removed at.
fn vertex_point_at(hatch: &Hatch, grip_id: usize) -> Option<BoundaryPoint> {
if hatch.is_associative {
return None;
}
// Grip 0 is the pattern origin when there is a pattern.
let first = usize::from(!hatch.pattern.lines.is_empty());
let (point, _) = *boundary_points(hatch).get(grip_id.checked_sub(first)?)?;
matches!(
point,
BoundaryPoint::PolylineVertex { .. } | BoundaryPoint::LineVertex { .. }
)
.then_some(point)
}
/// Add a vertex after `point` (halfway along the following segment, on
/// the arc for a bulged one) or remove `point`, keeping the loop closed.
fn edit_boundary_vertex(
hatch: &mut Hatch,
point: BoundaryPoint,
action: crate::scene::model::object::GripMenuAction,
) {
use crate::scene::model::object::GripMenuAction as A;
match point {
BoundaryPoint::PolylineVertex { path, edge, vertex } => {
let BoundaryEdge::Polyline(polyline) = &mut hatch.paths[path].edges[edge] else {
return;
};
let n = polyline.vertices.len();
match action {
A::AddVertex => {
if vertex + 1 == n && !polyline.is_closed {
let last = polyline.vertices[vertex].clone();
polyline.vertices.push(codec::types::Vector3::new(last.x, last.y, 0.0));
return;
}
let a = polyline.vertices[vertex].clone();
let b = polyline.vertices[(vertex + 1) % n].clone();
let (cx, cy) = (b.x - a.x, b.y - a.y);
// The arc's midpoint sits off the chord by the sagitta,
// to the right of a positive (counter-clockwise) bulge.
let sagitta = a.z * 0.5;
let half = (a.z.atan() * 0.5).tan();
let mid = codec::types::Vector3::new(
(a.x + b.x) * 0.5 + cy * sagitta,
(a.y + b.y) * 0.5 - cx * sagitta,
half,
);
polyline.vertices[vertex].z = half;
polyline.vertices.insert(vertex + 1, mid);
}
A::RemoveVertex if n > if polyline.is_closed { 3 } else { 2 } => {
polyline.vertices.remove(vertex);
}
_ => {}
}
}
BoundaryPoint::LineVertex { path, ending, starting } => {
let edges = &mut hatch.paths[path].edges;
match (action, ending, starting) {
(A::AddVertex, _, Some(edge)) => {
let BoundaryEdge::Line(line) = &mut edges[edge] else {
return;
};
let mid = codec::types::Vector2::new(
(line.start.x + line.end.x) * 0.5,
(line.start.y + line.end.y) * 0.5,
);
let tail = codec::entities::LineEdge {
start: mid.clone(),
end: line.end.clone(),
};
line.end = mid;
edges.insert(edge + 1, BoundaryEdge::Line(tail));
}
(A::RemoveVertex, Some(previous), Some(edge)) if previous != edge => {
let lines = edges
.iter()
.filter(|edge| matches!(edge, BoundaryEdge::Line(_)))
.count();
if lines <= 3 {
return;
}
let BoundaryEdge::Line(removed) = edges[edge].clone() else {
return;
};
if let BoundaryEdge::Line(line) = &mut edges[previous] {
line.end = removed.end;
}
edges.remove(edge);
}
_ => {}
}
}
_ => {}
}
}
+1 -1
View File
@@ -32,7 +32,7 @@ const MODERN_PROTOCOL_VERSION: &str = "2026-07-28";
///
/// `block_delete` gained its optional `force` parameter.
#[cfg(test)]
const TOOL_SCHEMA_DIGEST: &str = "4f193abfb8341acd6dc1a86ef7fe85148bc0bb76d1df700bcd289f79291a1d0f";
const TOOL_SCHEMA_DIGEST: &str = "1a8658ef2aa2fbc387607ea4eb08b3a6ada735d9b6c9ca2002aa0d3bb4d1dff9";
const MAX_REQUEST: usize = 1_048_576;
const MAX_RESPONSE: u64 = 16 * 1024 * 1024;
const CACHE_TTL_MS: u64 = 3_600_000;