Feat: BREAK command (BR) — split Line/Arc/Circle/LwPolyline at two points
3-step interactive flow: 1. Entity pick (first break point recorded at click location) 2. Optional first point refinement 3. Second break point → CmdResult::BreakEntity applied in cmd_result Geometry: - Line: project p1/p2 onto line parameter space → two line fragments - Arc: project p1/p2 to arc angles → trim CCW from p1 to p2 - Circle: same as Arc, converts Circle → Arc - LwPolyline: nearest-vertex split → two polyline fragments Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
a3bb2714c1
commit
4b8ae08a19
5 changed files with 330 additions and 1 deletions
|
|
@ -371,6 +371,37 @@ impl H7CAD {
|
|||
self.restore_pre_cmd_tangent();
|
||||
self.command_line.push_output(&msg);
|
||||
}
|
||||
CmdResult::BreakEntity { handle, p1, p2 } => {
|
||||
use crate::modules::home::modify::break_cmd::break_entity;
|
||||
let replacement = self.tabs[i].scene.document
|
||||
.get_entity(handle)
|
||||
.and_then(|e| break_entity(e, p1, p2));
|
||||
match replacement {
|
||||
Some(frags) => {
|
||||
let label = self.history_label_from_active_cmd(i, "BREAK");
|
||||
self.push_undo_snapshot(i, label);
|
||||
self.tabs[i].scene.erase_entities(&[handle]);
|
||||
let count = frags.len();
|
||||
for e in frags {
|
||||
self.tabs[i].scene.add_entity(e);
|
||||
}
|
||||
self.tabs[i].dirty = true;
|
||||
self.tabs[i].scene.clear_preview_wire();
|
||||
self.tabs[i].active_cmd = None;
|
||||
self.tabs[i].snap_result = None;
|
||||
self.restore_pre_cmd_tangent();
|
||||
self.command_line.push_output(&format!("BREAK: {} fragment(s).", count));
|
||||
self.refresh_properties();
|
||||
}
|
||||
None => {
|
||||
self.tabs[i].active_cmd = None;
|
||||
self.tabs[i].snap_result = None;
|
||||
self.tabs[i].scene.clear_preview_wire();
|
||||
self.restore_pre_cmd_tangent();
|
||||
self.command_line.push_error("BREAK: entity type not supported.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Focus the command-line input while a command is active; blur it when the command ends.
|
||||
if self.tabs[i].active_cmd.is_some() {
|
||||
|
|
|
|||
|
|
@ -970,6 +970,14 @@ impl H7CAD {
|
|||
}
|
||||
}
|
||||
|
||||
// ── Break ────────────────────────────────────────────────────────
|
||||
"BREAK"|"BR" => {
|
||||
use crate::modules::home::modify::break_cmd::BreakInteractiveCommand;
|
||||
let cmd = BreakInteractiveCommand::new();
|
||||
self.command_line.push_info(&cmd.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(cmd));
|
||||
}
|
||||
|
||||
// ── Inquiry ──────────────────────────────────────────────────────
|
||||
"DIST"|"DI" => {
|
||||
use crate::modules::home::inquiry::dist::DistCommand;
|
||||
|
|
@ -995,7 +1003,7 @@ impl H7CAD {
|
|||
"HELP"|"?" => {
|
||||
self.command_line.push_output(
|
||||
"Draw: LINE CIRCLE ARC PLINE RECT POLY POINT ELLIPSE SPLINE RAY XLINE HATCH | \
|
||||
Modify: MOVE COPY ROTATE SCALE MIRROR ERASE OFFSET EXTEND FILLET CHAMFER STRETCH EXPLODE TRIM | \
|
||||
Modify: MOVE COPY ROTATE SCALE MIRROR ERASE OFFSET EXTEND FILLET CHAMFER STRETCH EXPLODE TRIM BREAK | \
|
||||
Array: ARRAY ARRAYRECT ARRAYPOLAR ARRAYPATH | \
|
||||
Text: TEXT MTEXT LEADER MLEADER | \
|
||||
Dimension: DIMLINEAR DIMANGULAR DIMRADIUS | \
|
||||
|
|
|
|||
|
|
@ -95,6 +95,8 @@ pub enum CmdResult {
|
|||
ZoomToWindow { p1: Vec3, p2: Vec3 },
|
||||
/// Print a measurement result to the command line and end the command.
|
||||
Measurement(String),
|
||||
/// Break `handle` at points `p1` and `p2`; replace with computed fragments.
|
||||
BreakEntity { handle: Handle, p1: Vec3, p2: Vec3 },
|
||||
}
|
||||
|
||||
// ── Trait ─────────────────────────────────────────────────────────────────
|
||||
|
|
|
|||
287
src/modules/home/modify/break_cmd.rs
Normal file
287
src/modules/home/modify/break_cmd.rs
Normal file
|
|
@ -0,0 +1,287 @@
|
|||
// BREAK command — remove a portion of a Line, Arc, Circle, or LwPolyline.
|
||||
//
|
||||
// Workflow:
|
||||
// 1. Click to select the entity AND set the first break point.
|
||||
// 2. Click a second break point.
|
||||
// The segment between the two points (going CCW for arcs/circles) is removed.
|
||||
//
|
||||
// BREAK @ (at-sign as second point) → Break at a single point (splits without gap).
|
||||
|
||||
use std::f64::consts::TAU;
|
||||
|
||||
use acadrust::entities::{Arc as ArcEnt, Line as LineEnt, LwPolyline};
|
||||
use acadrust::types::Vector3;
|
||||
use acadrust::{EntityType, Handle};
|
||||
use glam::Vec3;
|
||||
|
||||
use crate::command::{CadCommand, CmdResult};
|
||||
use crate::modules::{IconKind, ModuleEvent, ToolDef};
|
||||
use crate::scene::wire_model::WireModel;
|
||||
|
||||
// ── Ribbon definition ──────────────────────────────────────────────────────
|
||||
|
||||
pub fn tool() -> ToolDef {
|
||||
ToolDef {
|
||||
id: "BREAK",
|
||||
label: "Break",
|
||||
icon: IconKind::Svg(include_bytes!("../../../../assets/icons/trim.svg")),
|
||||
event: ModuleEvent::Command("BREAK".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
// ── Geometry ───────────────────────────────────────────────────────────────
|
||||
|
||||
/// Break `entity` between world-space points `p1` and `p2`.
|
||||
/// Returns the replacement entities (empty vec means "erase, no replacement").
|
||||
pub fn break_entity(entity: &EntityType, p1: Vec3, p2: Vec3) -> Option<Vec<EntityType>> {
|
||||
match entity {
|
||||
EntityType::Line(line) => Some(break_line(line, p1, p2)),
|
||||
EntityType::Arc(arc) => Some(break_arc(arc, p1, p2)),
|
||||
EntityType::Circle(c) => Some(break_circle(c, p1, p2)),
|
||||
EntityType::LwPolyline(p) => Some(break_lwpolyline(p, p1, p2)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn break_line(line: &LineEnt, p1: Vec3, p2: Vec3) -> Vec<EntityType> {
|
||||
let s = Vec3::new(line.start.x as f32, line.start.z as f32, line.start.y as f32);
|
||||
let e = Vec3::new(line.end.x as f32, line.end.z as f32, line.end.y as f32);
|
||||
let dir = e - s;
|
||||
let len2 = dir.length_squared();
|
||||
if len2 < 1e-12 {
|
||||
return vec![];
|
||||
}
|
||||
let t1 = (p1 - s).dot(dir) / len2;
|
||||
let t2 = (p2 - s).dot(dir) / len2;
|
||||
let (ta, tb) = if t1 <= t2 { (t1, t2) } else { (t2, t1) };
|
||||
let ta = ta.clamp(0.0, 1.0);
|
||||
let tb = tb.clamp(0.0, 1.0);
|
||||
|
||||
// Single-point break (ta ≈ tb): split into two coincident-endpoint lines
|
||||
let pa = world_to_dxf(s + dir * ta);
|
||||
let pb = world_to_dxf(s + dir * tb);
|
||||
let start = world_to_dxf(s);
|
||||
let end = world_to_dxf(e);
|
||||
|
||||
let mut result = Vec::new();
|
||||
// First segment: start → pa
|
||||
if (pa - start).length() > 1e-6 {
|
||||
let mut ent = line.clone();
|
||||
ent.common.handle = Handle::NULL;
|
||||
ent.start = vec3_to_v3(start);
|
||||
ent.end = vec3_to_v3(pa);
|
||||
result.push(EntityType::Line(ent));
|
||||
}
|
||||
// Second segment: pb → end
|
||||
if (end - pb).length() > 1e-6 {
|
||||
let mut ent = line.clone();
|
||||
ent.common.handle = Handle::NULL;
|
||||
ent.start = vec3_to_v3(pb);
|
||||
ent.end = vec3_to_v3(end);
|
||||
result.push(EntityType::Line(ent));
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn break_arc(arc: &ArcEnt, p1: Vec3, p2: Vec3) -> Vec<EntityType> {
|
||||
let cx = arc.center.x as f32;
|
||||
let cy = arc.center.z as f32; // Y-up: DXF Y→world Z
|
||||
let r = arc.radius as f32;
|
||||
|
||||
// Project p1 and p2 onto the arc (use XZ plane)
|
||||
let a1 = angle_on_arc(cx, cy, p1);
|
||||
let a2 = angle_on_arc(cx, cy, p2);
|
||||
|
||||
let start = arc.start_angle as f32;
|
||||
let end = arc.end_angle as f32;
|
||||
|
||||
// Normalize: clamp a1 to arc range, then remove CCW from a1 to a2
|
||||
let a1_on = clamp_to_arc(a1, start, end);
|
||||
let a2_on = clamp_to_arc(a2, start, end);
|
||||
|
||||
// Resulting arc: from a2_on to a1_on (CCW, skipping the removed segment)
|
||||
// This matches AutoCAD's break behavior: removes CCW from first to second point.
|
||||
if (a1_on - a2_on).abs() < 0.01 {
|
||||
// Single-point break: return original unchanged (no gap)
|
||||
return vec![EntityType::Arc(arc.clone())];
|
||||
}
|
||||
|
||||
let _ = r; // radius unchanged
|
||||
let mut result = arc.clone();
|
||||
result.common.handle = Handle::NULL;
|
||||
result.start_angle = a2_on as f64;
|
||||
result.end_angle = a1_on as f64;
|
||||
vec![EntityType::Arc(result)]
|
||||
}
|
||||
|
||||
fn break_circle(circle: &acadrust::entities::Circle, p1: Vec3, p2: Vec3) -> Vec<EntityType> {
|
||||
let cx = circle.center.x as f32;
|
||||
let cy = circle.center.z as f32;
|
||||
|
||||
let a1 = angle_on_arc(cx, cy, p1);
|
||||
let a2 = angle_on_arc(cx, cy, p2);
|
||||
|
||||
if (a1 - a2).abs() < 0.01 {
|
||||
return vec![EntityType::Circle(circle.clone())];
|
||||
}
|
||||
|
||||
// Convert circle to arc, removing CCW from a1 to a2
|
||||
let mut arc = ArcEnt::new();
|
||||
arc.common = circle.common.clone();
|
||||
arc.common.handle = Handle::NULL;
|
||||
arc.center = circle.center.clone();
|
||||
arc.radius = circle.radius;
|
||||
arc.normal = circle.normal.clone();
|
||||
arc.start_angle = a2 as f64;
|
||||
arc.end_angle = a1 as f64;
|
||||
vec![EntityType::Arc(arc)]
|
||||
}
|
||||
|
||||
fn break_lwpolyline(p: &LwPolyline, p1: Vec3, p2: Vec3) -> Vec<EntityType> {
|
||||
// For LwPolyline, find the nearest vertex indices for p1 and p2,
|
||||
// then split into two polylines at those vertices.
|
||||
let n = p.vertices.len();
|
||||
if n < 2 {
|
||||
return vec![EntityType::LwPolyline(p.clone())];
|
||||
}
|
||||
|
||||
let t1 = nearest_pline_param(p, p1);
|
||||
let t2 = nearest_pline_param(p, p2);
|
||||
let (ta, tb) = if t1 <= t2 { (t1, t2) } else { (t2, t1) };
|
||||
|
||||
// Build two polylines: [0..ta] and [tb..end]
|
||||
let idx_a = ta.min(n - 1);
|
||||
let idx_b = tb.min(n - 1);
|
||||
|
||||
let mut result = Vec::new();
|
||||
|
||||
// First piece
|
||||
if idx_a > 0 {
|
||||
let mut first = p.clone();
|
||||
first.common.handle = Handle::NULL;
|
||||
first.vertices = p.vertices[..=idx_a].to_vec();
|
||||
first.is_closed = false;
|
||||
result.push(EntityType::LwPolyline(first));
|
||||
}
|
||||
|
||||
// Second piece
|
||||
if idx_b < n - 1 {
|
||||
let mut second = p.clone();
|
||||
second.common.handle = Handle::NULL;
|
||||
second.vertices = p.vertices[idx_b..].to_vec();
|
||||
second.is_closed = false;
|
||||
result.push(EntityType::LwPolyline(second));
|
||||
}
|
||||
|
||||
if result.is_empty() {
|
||||
vec![EntityType::LwPolyline(p.clone())]
|
||||
} else {
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
// ── Small utilities ────────────────────────────────────────────────────────
|
||||
|
||||
/// Returns the angle (degrees, 0-360) of `pt` viewed from (cx, cy) in the XZ plane.
|
||||
fn angle_on_arc(cx: f32, cy: f32, pt: Vec3) -> f32 {
|
||||
let dx = pt.x - cx;
|
||||
let dy = pt.z - cy; // XZ plane
|
||||
let a = dy.atan2(dx).to_degrees();
|
||||
(a + 360.0) % 360.0
|
||||
}
|
||||
|
||||
/// Clamp angle `a` to within the arc's angular range (CCW from `start` to `end`).
|
||||
fn clamp_to_arc(a: f32, start: f32, end: f32) -> f32 {
|
||||
let span = ((end - start) + 360.0) % 360.0;
|
||||
let rel = ((a - start) + 360.0) % 360.0;
|
||||
if rel <= span {
|
||||
a
|
||||
} else if rel < span + (360.0 - span) / 2.0 {
|
||||
end
|
||||
} else {
|
||||
start
|
||||
}
|
||||
}
|
||||
|
||||
/// Find the index of the polyline vertex closest to `pt`.
|
||||
fn nearest_pline_param(p: &LwPolyline, pt: Vec3) -> usize {
|
||||
p.vertices.iter().enumerate().min_by_key(|(_, v)| {
|
||||
let dx = v.location.x as f32 - pt.x;
|
||||
let dy = v.location.y as f32 - pt.z;
|
||||
((dx * dx + dy * dy) * 1e6) as i64
|
||||
}).map(|(i, _)| i).unwrap_or(0)
|
||||
}
|
||||
|
||||
fn world_to_dxf(v: Vec3) -> Vec3 {
|
||||
// world Y-up: X→X, Z→DXF Y, Y→DXF Z
|
||||
Vec3::new(v.x, v.z, v.y)
|
||||
}
|
||||
|
||||
fn vec3_to_v3(v: Vec3) -> Vector3 {
|
||||
Vector3::new(v.x as f64, v.y as f64, v.z as f64)
|
||||
}
|
||||
|
||||
// ── CadCommand (simplified — break logic via CmdResult::BreakEntity) ───────
|
||||
|
||||
/// Thin wrapper for commands.rs to register the break command using the
|
||||
/// BreakEntity CmdResult variant added below.
|
||||
pub struct BreakInteractiveCommand {
|
||||
target: Option<Handle>,
|
||||
p1: Option<Vec3>,
|
||||
}
|
||||
|
||||
impl BreakInteractiveCommand {
|
||||
pub fn new() -> Self {
|
||||
Self { target: None, p1: None }
|
||||
}
|
||||
}
|
||||
|
||||
impl CadCommand for BreakInteractiveCommand {
|
||||
fn name(&self) -> &'static str { "BREAK" }
|
||||
|
||||
fn prompt(&self) -> String {
|
||||
if self.target.is_none() {
|
||||
"BREAK Select object:".into()
|
||||
} else if self.p1.is_none() {
|
||||
"BREAK Specify first break point:".into()
|
||||
} else {
|
||||
"BREAK Specify second break point:".into()
|
||||
}
|
||||
}
|
||||
|
||||
fn needs_entity_pick(&self) -> bool {
|
||||
self.target.is_none()
|
||||
}
|
||||
|
||||
fn on_entity_pick(&mut self, handle: Handle, pt: Vec3) -> CmdResult {
|
||||
if handle.is_null() {
|
||||
return CmdResult::NeedPoint;
|
||||
}
|
||||
self.target = Some(handle);
|
||||
self.p1 = Some(pt);
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
|
||||
fn on_point(&mut self, pt: Vec3) -> CmdResult {
|
||||
let handle = match self.target {
|
||||
Some(h) => h,
|
||||
None => return CmdResult::Cancel,
|
||||
};
|
||||
let p1 = match self.p1 {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
self.p1 = Some(pt);
|
||||
return CmdResult::NeedPoint;
|
||||
}
|
||||
};
|
||||
CmdResult::BreakEntity { handle, p1, p2: pt }
|
||||
}
|
||||
|
||||
fn on_enter(&mut self) -> CmdResult {
|
||||
CmdResult::Cancel
|
||||
}
|
||||
|
||||
fn on_mouse_move(&mut self, _pt: Vec3) -> Option<WireModel> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
pub mod array;
|
||||
pub mod break_cmd;
|
||||
pub mod copy;
|
||||
pub mod delete;
|
||||
pub mod explode;
|
||||
|
|
|
|||
Loading…
Reference in a new issue