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;