forked from 1b-agent/opencadstudio-upstream
The drawing plane is world XY, but break, join, rectangular array, the stretch/window move, the spline preview wire and several doc comments still used the stale "Y-up XZ" mapping (world Z = DXF Y). That made those commands operate against a constant-elevation axis: arc/circle/ line/spline break and join picked the wrong points, rectangular array stepped rows along the Z (elevation) axis, window-stretch tested the wrong window bound, and the spline wire drew in the wrong plane. Map every drawing-plane coordinate through .y (elevation on .z), matching the entity builders, the inquiry commands and the central transform. Stale Y-up comments updated to avoid future confusion. Co-Authored-By: Claude Opus 4.8 <[email protected]>
244 lines
7.5 KiB
Rust
244 lines
7.5 KiB
Rust
// JOIN command — merge collinear Lines or co-circular Arcs into one entity.
|
|
//
|
|
// Supports:
|
|
// Line + Line → merged Line (if collinear and end-to-end)
|
|
// Arc + Arc → merged Arc (if same center/radius and contiguous)
|
|
// Arc + Arc → Circle (if the merged arc spans 360°)
|
|
//
|
|
// Workflow: select objects then press Enter to join.
|
|
|
|
use acadrust::types::Vector3;
|
|
use acadrust::{EntityType, Handle};
|
|
use glam::Vec3;
|
|
|
|
use crate::command::{CadCommand, CmdResult};
|
|
|
|
// ── Command ────────────────────────────────────────────────────────────────
|
|
|
|
pub struct JoinCommand {
|
|
handles: Vec<Handle>,
|
|
gathering: bool,
|
|
}
|
|
|
|
impl JoinCommand {
|
|
pub fn new() -> Self {
|
|
Self {
|
|
handles: vec![],
|
|
gathering: true,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl CadCommand for JoinCommand {
|
|
fn name(&self) -> &'static str {
|
|
"JOIN"
|
|
}
|
|
|
|
fn prompt(&self) -> String {
|
|
format!(
|
|
"JOIN Select objects to join ({} selected, Enter to apply):",
|
|
self.handles.len()
|
|
)
|
|
}
|
|
|
|
fn is_selection_gathering(&self) -> bool {
|
|
self.gathering
|
|
}
|
|
|
|
fn on_selection_complete(&mut self, handles: Vec<Handle>) -> CmdResult {
|
|
self.handles = handles;
|
|
CmdResult::NeedPoint
|
|
}
|
|
|
|
fn on_point(&mut self, _pt: Vec3) -> CmdResult {
|
|
CmdResult::NeedPoint
|
|
}
|
|
|
|
fn on_enter(&mut self) -> CmdResult {
|
|
if self.handles.len() < 2 {
|
|
return CmdResult::Cancel;
|
|
}
|
|
self.gathering = false;
|
|
CmdResult::JoinEntities(self.handles.clone())
|
|
}
|
|
}
|
|
|
|
// ── Geometry ───────────────────────────────────────────────────────────────
|
|
|
|
/// Try to join all `entities` in the slice into a minimal set.
|
|
/// Returns `(kept, removed)` handle lists and the merged entity vec.
|
|
pub fn join_entities(entities: &[(Handle, &EntityType)]) -> Option<(Vec<Handle>, Vec<EntityType>)> {
|
|
if entities.len() < 2 {
|
|
return None;
|
|
}
|
|
|
|
// Split into lines and arcs
|
|
let lines: Vec<_> = entities
|
|
.iter()
|
|
.filter(|(_, e)| matches!(e, EntityType::Line(_)))
|
|
.collect();
|
|
let arcs: Vec<_> = entities
|
|
.iter()
|
|
.filter(|(_, e)| matches!(e, EntityType::Arc(_)))
|
|
.collect();
|
|
|
|
if !lines.is_empty() && arcs.is_empty() {
|
|
return try_join_lines(&lines);
|
|
}
|
|
if !arcs.is_empty() && lines.is_empty() {
|
|
return try_join_arcs(&arcs);
|
|
}
|
|
None
|
|
}
|
|
|
|
fn try_join_lines(lines: &[&(Handle, &EntityType)]) -> Option<(Vec<Handle>, Vec<EntityType>)> {
|
|
// Collect endpoints in the world XY plane
|
|
let segs: Vec<_> = lines
|
|
.iter()
|
|
.map(|(h, e)| {
|
|
if let EntityType::Line(l) = e {
|
|
let s = Vec3::new(l.start.x as f32, l.start.y as f32, 0.0);
|
|
let e2 = Vec3::new(l.end.x as f32, l.end.y as f32, 0.0);
|
|
(*h, l, s, e2)
|
|
} else {
|
|
unreachable!()
|
|
}
|
|
})
|
|
.collect();
|
|
|
|
// Check collinearity: all lines must be parallel and on the same infinite line.
|
|
let (_, first_line, s0, e0) = segs[0];
|
|
let dir0 = (e0 - s0).normalize_or_zero();
|
|
if dir0.length_squared() < 1e-12 {
|
|
return None;
|
|
}
|
|
|
|
for (_, _, si, ei) in &segs[1..] {
|
|
// Check parallel
|
|
let diri = (*ei - *si).normalize_or_zero();
|
|
let cross = (dir0.x * diri.y - dir0.y * diri.x).abs();
|
|
if cross > 1e-4 {
|
|
return None;
|
|
}
|
|
// Check co-linear (point on same line)
|
|
let off = *si - s0;
|
|
let perp = (off - dir0 * off.dot(dir0)).length();
|
|
if perp > 1e-3 {
|
|
return None;
|
|
}
|
|
}
|
|
|
|
// Project all endpoints onto the direction axis
|
|
let params: Vec<f32> = segs
|
|
.iter()
|
|
.flat_map(|(_, _, si, ei)| [si.dot(dir0), ei.dot(dir0)])
|
|
.collect();
|
|
let t_min = params.iter().cloned().fold(f32::INFINITY, f32::min);
|
|
let t_max = params.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
|
|
|
|
// Check connectivity: no gaps larger than tolerance
|
|
let mut sorted: Vec<[f32; 2]> = segs
|
|
.iter()
|
|
.map(|(_, _, si, ei)| {
|
|
let ta = si.dot(dir0);
|
|
let tb = ei.dot(dir0);
|
|
[ta.min(tb), ta.max(tb)]
|
|
})
|
|
.collect();
|
|
sorted.sort_by(|a, b| a[0].partial_cmp(&b[0]).unwrap());
|
|
for w in sorted.windows(2) {
|
|
if w[1][0] > w[0][1] + 1e-3 {
|
|
return None;
|
|
} // gap
|
|
}
|
|
|
|
let new_start = s0 + dir0 * t_min;
|
|
let new_end = s0 + dir0 * t_max;
|
|
|
|
let mut merged = first_line.clone();
|
|
merged.common.handle = Handle::NULL;
|
|
merged.start = vec3_xz_to_v3(new_start, first_line.start.z);
|
|
merged.end = vec3_xz_to_v3(new_end, first_line.start.z);
|
|
|
|
let handles: Vec<Handle> = lines.iter().map(|(h, _)| *h).collect();
|
|
Some((handles, vec![EntityType::Line(merged)]))
|
|
}
|
|
|
|
fn try_join_arcs(arcs: &[&(Handle, &EntityType)]) -> Option<(Vec<Handle>, Vec<EntityType>)> {
|
|
let segs: Vec<_> = arcs
|
|
.iter()
|
|
.map(|(h, e)| {
|
|
if let EntityType::Arc(a) = e {
|
|
(*h, a)
|
|
} else {
|
|
unreachable!()
|
|
}
|
|
})
|
|
.collect();
|
|
|
|
// All arcs must share the same center and radius
|
|
let (_, first) = segs[0];
|
|
let cx = first.center.x;
|
|
let cy = first.center.y;
|
|
let r = first.radius;
|
|
|
|
for (_, a) in &segs[1..] {
|
|
if (a.center.x - cx).abs() > 1e-3 || (a.center.y - cy).abs() > 1e-3 {
|
|
return None;
|
|
}
|
|
if (a.radius - r).abs() > 1e-3 {
|
|
return None;
|
|
}
|
|
}
|
|
|
|
// Collect (start_angle, end_angle) in radians.
|
|
let mut intervals: Vec<[f32; 2]> = segs
|
|
.iter()
|
|
.map(|(_, a)| [a.start_angle as f32, a.end_angle as f32])
|
|
.collect();
|
|
|
|
// Sort by start angle
|
|
intervals.sort_by(|a, b| a[0].partial_cmp(&b[0]).unwrap());
|
|
|
|
// Try to merge into one contiguous arc
|
|
let merged_start = intervals[0][0];
|
|
let mut merged_end = intervals[0][1];
|
|
for &[s, e] in &intervals[1..] {
|
|
let span = (e - merged_end).rem_euclid(std::f32::consts::TAU);
|
|
let gap = (s - merged_end).rem_euclid(std::f32::consts::TAU);
|
|
let _ = span;
|
|
if gap > 1e-3 {
|
|
return None;
|
|
} // discontinuous
|
|
merged_end = e;
|
|
}
|
|
|
|
let span = merged_end - merged_start;
|
|
let handles: Vec<Handle> = arcs.iter().map(|(h, _)| *h).collect();
|
|
|
|
if (span - std::f32::consts::TAU).abs() < 0.01 {
|
|
// Full circle
|
|
let mut circle = acadrust::entities::Circle::new();
|
|
circle.common = first.common.clone();
|
|
circle.common.handle = Handle::NULL;
|
|
circle.center = first.center.clone();
|
|
circle.radius = r;
|
|
circle.normal = first.normal.clone();
|
|
return Some((handles, vec![EntityType::Circle(circle)]));
|
|
}
|
|
|
|
let mut merged_arc = first.clone();
|
|
merged_arc.common.handle = Handle::NULL;
|
|
merged_arc.start_angle = merged_start as f64;
|
|
merged_arc.end_angle = merged_end as f64;
|
|
Some((handles, vec![EntityType::Arc(merged_arc)]))
|
|
}
|
|
|
|
fn vec3_xz_to_v3(v: Vec3, z: f64) -> Vector3 {
|
|
Vector3::new(v.x as f64, v.y as f64, z)
|
|
}
|
|
|
|
|
|
// ── Autocomplete registry ─────────────────────────────────
|
|
inventory::submit!(crate::command::CommandRegistration { names: &["J", "JOIN"] }); // JoinCommand
|