diff --git a/src/app/cmd_result.rs b/src/app/cmd_result.rs index 62e097507..331fec805 100644 --- a/src/app/cmd_result.rs +++ b/src/app/cmd_result.rs @@ -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() { diff --git a/src/app/commands.rs b/src/app/commands.rs index 96e164169..fd3459753 100644 --- a/src/app/commands.rs +++ b/src/app/commands.rs @@ -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 | \ diff --git a/src/command/mod.rs b/src/command/mod.rs index 5a93c254c..635e3c802 100644 --- a/src/command/mod.rs +++ b/src/command/mod.rs @@ -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 ───────────────────────────────────────────────────────────────── diff --git a/src/modules/home/modify/break_cmd.rs b/src/modules/home/modify/break_cmd.rs new file mode 100644 index 000000000..53e6b314b --- /dev/null +++ b/src/modules/home/modify/break_cmd.rs @@ -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> { + 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 { + 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 { + 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 { + 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 { + // 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, + p1: Option, +} + +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 { + None + } +} diff --git a/src/modules/home/modify/mod.rs b/src/modules/home/modify/mod.rs index da0ea9431..582b5fc31 100644 --- a/src/modules/home/modify/mod.rs +++ b/src/modules/home/modify/mod.rs @@ -1,4 +1,5 @@ pub mod array; +pub mod break_cmd; pub mod copy; pub mod delete; pub mod explode;