From 6490a625f24b6573f941ff52926539c5da78f331 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Tue, 13 Oct 2026 14:38:58 +0300 Subject: [PATCH] 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 --- src/app/control/actions.rs | 15 ++ src/app/update/command.rs | 13 + src/entities/hatch.rs | 472 ++++++++++++++++++++++++++----------- src/mcp.rs | 2 +- 4 files changed, 368 insertions(+), 134 deletions(-) diff --git a/src/app/control/actions.rs b/src/app/control/actions.rs index 76195015f..6fa262f35 100644 --- a/src/app/control/actions.rs +++ b/src/app/control/actions.rs @@ -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::() + .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")); diff --git a/src/app/update/command.rs b/src/app/update/command.rs index 106e3cf10..7e2c909dd 100644 --- a/src/app/update/command.rs +++ b/src/app/update/command.rs @@ -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 { 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, } } diff --git a/src/entities/hatch.rs b/src/entities/hatch.rs index ef7ea5cc7..cdb3a23eb 100644 --- a/src/entities/hatch.rs +++ b/src/entities/hatch.rs @@ -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 { 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, starting: Option }, + 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 = None; + let mut first_start: Option = 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, ¤t.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 { + 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); + } + _ => {} + } + } + _ => {} + } +} diff --git a/src/mcp.rs b/src/mcp.rs index f97678433..449cee556 100644 --- a/src/mcp.rs +++ b/src/mcp.rs @@ -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;