refactor(modules): rename home module to draw, restore first-tab order

The Home tab was already titled "Draw"; rename the module to match:
folder modules/home → modules/draw, HomeModule → DrawModule, id "home"
→ "draw". The inner draw/ command folder is kept (modules::draw::draw).

Update build.rs PRIORITY ("home" → "draw") so the tab returns to the
front of the ribbon; registry.rs regenerates accordingly.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
Hakan SevenandClaude Opus 4.8 committed 2026-06-16 11:49:14 +03:00
1 parent c6152ef705
commit 3666c7fa69
85 files changed
+141 -141

No files matched your search

+195
View File
@@ -0,0 +1,195 @@
// ALIGN command — align selected objects using 1 or 2 point pairs.
//
// Workflow:
// 1. Select objects (Enter to finish selection)
// 2. First source point → first destination point
// 3. Second source point → second destination point (Enter to skip = translate only)
// 4. Enter = apply (scale = optional: Y/N prompt after 2nd pair)
//
// With 1 pair: pure translation (src1 → dst1)
// With 2 pairs: translate + rotate (+ optional uniform scale to fit)
use acadrust::Handle;
use glam::Vec3;
use crate::command::{CadCommand, CmdResult, EntityTransform};
pub struct AlignCommand {
state: AlignState,
handles: Vec<Handle>,
src1: Option<Vec3>,
dst1: Option<Vec3>,
src2: Option<Vec3>,
dst2: Option<Vec3>,
}
#[derive(PartialEq)]
enum AlignState {
Gathering,
Src1,
Dst1,
Src2,
Dst2,
AskScale,
}
impl AlignCommand {
pub fn new() -> Self {
Self {
state: AlignState::Gathering,
handles: vec![],
src1: None,
dst1: None,
src2: None,
dst2: None,
}
}
}
impl CadCommand for AlignCommand {
fn name(&self) -> &'static str {
"ALIGN"
}
fn prompt(&self) -> String {
match self.state {
AlignState::Gathering => format!(
"ALIGN Select objects ({} selected, Enter when done):",
self.handles.len()
),
AlignState::Src1 => "ALIGN Specify 1st source point:".into(),
AlignState::Dst1 => "ALIGN Specify 1st destination point:".into(),
AlignState::Src2 => "ALIGN Specify 2nd source point (Enter = translate only):".into(),
AlignState::Dst2 => "ALIGN Specify 2nd destination point:".into(),
AlignState::AskScale => "ALIGN Scale objects based on alignment points? [Y/N]:".into(),
}
}
fn is_selection_gathering(&self) -> bool {
self.state == AlignState::Gathering
}
fn on_selection_complete(&mut self, handles: Vec<Handle>) -> CmdResult {
self.handles = handles;
CmdResult::NeedPoint
}
fn on_point(&mut self, pt: Vec3) -> CmdResult {
match self.state {
AlignState::Gathering => CmdResult::NeedPoint,
AlignState::Src1 => {
self.src1 = Some(pt);
self.state = AlignState::Dst1;
CmdResult::NeedPoint
}
AlignState::Dst1 => {
self.dst1 = Some(pt);
self.state = AlignState::Src2;
CmdResult::NeedPoint
}
AlignState::Src2 => {
self.src2 = Some(pt);
self.state = AlignState::Dst2;
CmdResult::NeedPoint
}
AlignState::Dst2 => {
self.dst2 = Some(pt);
self.state = AlignState::AskScale;
CmdResult::NeedPoint
}
AlignState::AskScale => CmdResult::NeedPoint,
}
}
fn on_enter(&mut self) -> CmdResult {
match self.state {
AlignState::Gathering => {
if self.handles.is_empty() {
return CmdResult::Cancel;
}
self.state = AlignState::Src1;
CmdResult::NeedPoint
}
AlignState::Src2 => {
// Only 1 pair — pure translation
match (self.src1, self.dst1) {
(Some(s), Some(d)) => {
let delta = d - s;
CmdResult::TransformSelected(
self.handles.clone(),
EntityTransform::Translate(delta),
)
}
_ => CmdResult::Cancel,
}
}
AlignState::AskScale => {
// No scale (default N)
self.compute_align(false)
}
_ => CmdResult::Cancel,
}
}
fn wants_text_input(&self) -> bool {
self.state == AlignState::AskScale
}
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
if self.state != AlignState::AskScale {
return None;
}
let scale = text.trim().to_uppercase().starts_with('Y');
Some(self.compute_align(scale))
}
}
impl AlignCommand {
fn compute_align(&self, with_scale: bool) -> CmdResult {
let (s1, d1, s2, d2) = match (self.src1, self.dst1, self.src2, self.dst2) {
(Some(a), Some(b), Some(c), Some(d)) => (a, b, c, d),
_ => return CmdResult::Cancel,
};
// Build transform: move s1→d1, rotate so s2-s1 aligns with d2-d1 (in the world XY plane)
let src_vec = s2 - s1;
let dst_vec = d2 - d1;
let src_len = src_vec.length();
let dst_len = dst_vec.length();
if src_len < 1e-6 || dst_len < 1e-6 {
// Degenerate: just translate
let delta = d1 - s1;
return CmdResult::TransformSelected(
self.handles.clone(),
EntityTransform::Translate(delta),
);
}
// Angle from src_vec to dst_vec in the world XY plane
let src_angle = src_vec.y.atan2(src_vec.x);
let dst_angle = dst_vec.y.atan2(dst_vec.x);
let angle = dst_angle - src_angle;
let scale_factor = if with_scale { dst_len / src_len } else { 1.0 };
// Apply: translate to origin (s1), scale, rotate, translate to d1
// We use the EntityTransform enum — it doesn't support composed transforms directly.
// Return a special align result that carries the full matrix.
let _ = (angle, scale_factor, with_scale);
// Compose via AlignTransform CmdResult
CmdResult::AlignSelected {
handles: self.handles.clone(),
src1: s1,
dst1: d1,
angle_rad: angle,
scale: scale_factor,
}
}
}
// ── Autocomplete registry ─────────────────────────────────
inventory::submit!(crate::command::CommandRegistration { names: &["AL", "ALIGN"] }); // AlignCommand
File diff suppressed because it is too large. Load diff
+178
View File
@@ -0,0 +1,178 @@
// ATTEDIT command — edit attribute values of a selected INSERT entity.
//
// Workflow:
// Step 1: pick an INSERT entity that has attributes.
// Step 2+: for each attribute tag, show "TAG = <value>" and accept new
// text via text input. Enter with empty string keeps the old value.
// After all attributes are processed, commit via ReplaceMany.
use acadrust::EntityType;
use glam::Vec3;
use crate::command::{CadCommand, CmdResult};
use crate::scene::model::wire_model::WireModel;
pub struct AtteditCommand {
step: Step,
}
enum Step {
/// Waiting for the user to pick an INSERT entity.
SelectInsert,
/// Editing the attribute at index `idx` of the collected insert data.
EditAttr {
handle: acadrust::Handle,
/// (tag, current_value) pairs collected from the insert.
attrs: Vec<(String, String)>,
/// Index of the attribute currently being edited.
idx: usize,
},
}
impl AtteditCommand {
pub fn new() -> Self {
Self {
step: Step::SelectInsert,
}
}
}
impl CadCommand for AtteditCommand {
fn name(&self) -> &'static str {
"ATTEDIT"
}
fn prompt(&self) -> String {
match &self.step {
Step::SelectInsert => "ATTEDIT Select block with attributes:".to_string(),
Step::EditAttr { attrs, idx, .. } => {
let (tag, val) = &attrs[*idx];
format!(
"ATTEDIT {} = <{}> (Enter to keep, type new value):",
tag, val
)
}
}
}
fn needs_entity_pick(&self) -> bool {
matches!(self.step, Step::SelectInsert)
}
fn on_entity_pick(&mut self, handle: acadrust::Handle, _pt: Vec3) -> CmdResult {
if handle.is_null() {
return CmdResult::NeedPoint;
}
// We can't inspect the document here — store the handle and let the
// command host inject the attribute list via `init_with_attrs`.
// Instead, signal the host to call prepare_attedit().
self.step = Step::EditAttr {
handle,
attrs: vec![], // will be filled by init_with_attrs() in cmd_result.rs
idx: 0,
};
CmdResult::NeedPoint
}
fn wants_text_input(&self) -> bool {
if let Step::EditAttr { ref attrs, idx, .. } = self.step {
!attrs.is_empty() && idx < attrs.len()
} else {
false
}
}
fn wants_text_with_spaces(&self) -> bool {
// Editing an attribute value — same free-form semantics as the
// text-content prompts in TEXT / MTEXT / DDEDIT.
self.wants_text_input()
}
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
let Step::EditAttr { handle, attrs, idx } = &mut self.step else {
return None;
};
let handle = *handle;
// Update the current attribute value if the user typed something.
if !text.trim().is_empty() {
attrs[*idx].1 = text.trim().to_string();
}
*idx += 1;
if *idx >= attrs.len() {
// All attributes done — build new Insert entity.
// We signal via a special CmdResult; the host will call apply_attedit().
let pairs = attrs.clone();
return Some(CmdResult::ReplaceEntity(
handle,
vec![make_attedit_sentinel(handle, &pairs)],
));
}
// More attributes to edit.
None
}
fn on_point(&mut self, _pt: Vec3) -> CmdResult {
CmdResult::NeedPoint
}
fn on_enter(&mut self) -> CmdResult {
CmdResult::Cancel
}
fn on_preview_wires(&mut self, _pt: Vec3) -> Vec<WireModel> {
vec![]
}
fn attedit_pending_handle(&self) -> Option<acadrust::Handle> {
if let Step::EditAttr { handle, attrs, .. } = &self.step {
if attrs.is_empty() {
return Some(*handle);
}
}
None
}
fn attedit_set_attrs(&mut self, new_attrs: Vec<(String, String)>) {
if let Step::EditAttr { attrs, idx, .. } = &mut self.step {
*attrs = new_attrs;
*idx = 0;
}
}
}
/// Make a sentinel entity carrying the edited attribute values.
/// Encodes all (tag=value) pairs in the layer field as "tag1\x01val1\x02tag2\x01val2...".
fn make_attedit_sentinel(_handle: acadrust::Handle, pairs: &[(String, String)]) -> EntityType {
let encoded: String = pairs
.iter()
.map(|(t, v)| format!("{}\x01{}", t, v))
.collect::<Vec<_>>()
.join("\x02");
let mut xl = acadrust::entities::XLine::new(
acadrust::types::Vector3::zero(),
acadrust::types::Vector3::new(1.0, 0.0, 0.0),
);
xl.common.layer = format!("__ATTEDIT__{}", encoded);
EntityType::XLine(xl)
}
/// Apply edited attribute values to an INSERT entity in the document.
/// Called from `cmd_result.rs` when the sentinel is detected.
pub fn apply_attedit(doc: &mut acadrust::CadDocument, handle: acadrust::Handle, encoded: &str) {
let Some(EntityType::Insert(ins)) = doc.get_entity_mut(handle) else {
return;
};
for pair in encoded.split('\x02') {
let mut parts = pair.splitn(2, '\x01');
let Some(tag) = parts.next() else {
continue;
};
let Some(val) = parts.next() else {
continue;
};
if let Some(attrib) = ins.attributes.iter_mut().find(|a| a.tag == tag) {
attrib.set_value(val);
}
}
}
+474
View File
@@ -0,0 +1,474 @@
// 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 crate::modules::draw::modify::spline_ops::{
bspline_to_spline, spline_nearest_t, spline_to_bspline,
};
use acadrust::entities::{
Arc as ArcEnt, Ellipse as EllipseEnt, Line as LineEnt, LwPolyline, Spline as SplineEnt,
};
use acadrust::types::Vector3;
use acadrust::{EntityType, Handle};
use glam::Vec3;
use truck_modeling::base::{BoundedCurve, Cut};
use crate::command::{CadCommand, CmdResult};
use crate::modules::{IconKind, ModuleEvent, ToolDef};
use crate::scene::model::wire_model::WireModel;
// ── Ribbon definition ──────────────────────────────────────────────────────
#[allow(dead_code)]
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)),
EntityType::Ellipse(e) => Some(break_ellipse(e, p1, p2)),
EntityType::Spline(s) => Some(break_spline(s, 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.y as f32,
line.start.z as f32,
);
let e = Vec3::new(line.end.x as f32, line.end.y as f32, line.end.z 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.y as f32;
let r = arc.radius as f32;
// Project p1 and p2 onto the arc (world XY 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.y 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
}
}
fn break_ellipse(ell: &EllipseEnt, p1: Vec3, p2: Vec3) -> Vec<EntityType> {
// Compute the eccentric-anomaly parameter of a world point relative to ellipse.
// World = DXF (XY drawing plane).
let cx = ell.center.x;
let cy = ell.center.y;
let a = (ell.major_axis.x.powi(2) + ell.major_axis.y.powi(2)).sqrt();
if a < 1e-9 {
return vec![EntityType::Ellipse(ell.clone())];
}
let _b = a * ell.minor_axis_ratio;
let nx = ell.major_axis.x / a;
let ny = ell.major_axis.y / a;
// Project a point onto the ellipse parameter (eccentric anomaly)
let param_of = |pt: Vec3| -> f64 {
let rx = pt.x as f64 - cx;
let ry = pt.y as f64 - cy;
let xl = rx * nx + ry * ny;
let yl = -rx * ny + ry * nx;
yl.atan2(xl) // atan2(yl/b*b, xl/a*a) simplifies to atan2(yl,xl) for ordering
};
let t0 = ell.start_parameter;
let t1 = ell.end_parameter;
let pa1 = param_of(p1);
let pa2 = param_of(p2);
// Clamp both params to arc range (same logic as clamp_to_arc for arcs)
let span_deg = {
let s = t1 - t0;
if s <= 0.0 {
s + std::f64::consts::TAU
} else {
s
}
};
let clamp = |a: f64| -> f64 {
let rel =
((a - t0) % std::f64::consts::TAU + std::f64::consts::TAU) % std::f64::consts::TAU;
if rel <= span_deg {
a
} else if rel < span_deg + (std::f64::consts::TAU - span_deg) / 2.0 {
t1
} else {
t0
}
};
let a1_on = clamp(pa1);
let a2_on = clamp(pa2);
if (a1_on - a2_on).abs() < 1e-4 {
return vec![EntityType::Ellipse(ell.clone())];
}
// Remove CCW from a1_on to a2_on → result goes from a2_on to a1_on
let mut result = ell.clone();
result.common.handle = Handle::NULL;
result.start_parameter = a2_on;
result.end_parameter = a1_on;
vec![EntityType::Ellipse(result)]
}
// ── Small utilities ────────────────────────────────────────────────────────
/// Returns the angle (radians, 0-2π) of `pt` viewed from (cx, cy) in the world XY plane.
fn angle_on_arc(cx: f32, cy: f32, pt: Vec3) -> f32 {
let dx = pt.x - cx;
let dy = pt.y - cy;
dy.atan2(dx).rem_euclid(std::f32::consts::TAU)
}
/// 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).rem_euclid(std::f32::consts::TAU);
let rel = (a - start).rem_euclid(std::f32::consts::TAU);
if rel <= span {
a
} else if rel < span + (std::f32::consts::TAU - 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.y;
((dx * dx + dy * dy) * 1e6) as i64
})
.map(|(i, _)| i)
.unwrap_or(0)
}
fn world_to_dxf(v: Vec3) -> Vec3 {
// World = DXF (identity).
Vec3::new(v.x, v.y, v.z)
}
fn vec3_to_v3(v: Vec3) -> Vector3 {
Vector3::new(v.x as f64, v.y as f64, v.z as f64)
}
fn break_spline(spl: &SplineEnt, p1: Vec3, p2: Vec3) -> Vec<EntityType> {
// Find the two nearest parameters to p1 and p2 (DXF XY: world x, z).
let t1 = match spline_nearest_t(spl, p1.x as f64, p1.y as f64) {
Some(t) => t,
None => return vec![EntityType::Spline(spl.clone())],
};
let t2 = match spline_nearest_t(spl, p2.x as f64, p2.y as f64) {
Some(t) => t,
None => return vec![EntityType::Spline(spl.clone())],
};
let bs = match spline_to_bspline(spl) {
Some(b) => b,
None => return vec![EntityType::Spline(spl.clone())],
};
let (t0, t_end) = bs.range_tuple();
let (ta, tb) = if t1 <= t2 { (t1, t2) } else { (t2, t1) };
// Single-point break (ta ≈ tb): split into two segments at that point.
if (tb - ta).abs() < 1e-9 {
let mut piece = bs.clone();
let right = piece.cut(ta);
return vec![
EntityType::Spline(bspline_to_spline(&piece, spl)),
EntityType::Spline(bspline_to_spline(&right, spl)),
];
}
// Two-point break: keep [t0..ta] and [tb..t_end], discard middle.
let mut result = vec![];
// Left piece [t0, ta]
if ta - t0 > 1e-9 {
let mut left = bs.clone();
let _right = left.cut(ta); // left = [t0, ta]
result.push(EntityType::Spline(bspline_to_spline(&left, spl)));
}
// Right piece [tb, t_end]
if t_end - tb > 1e-9 {
let mut full = bs.clone();
let right = full.cut(tb); // right = [tb, t_end]
result.push(EntityType::Spline(bspline_to_spline(&right, spl)));
}
result
}
// ── 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
}
}
// ── BREAKATPOINT (BAP) — split at a single point, no gap ──────────────────
pub struct BreakAtPointCommand {
target: Option<Handle>,
}
impl BreakAtPointCommand {
pub fn new() -> Self {
Self { target: None }
}
}
impl CadCommand for BreakAtPointCommand {
fn name(&self) -> &'static str {
"BREAKATPOINT"
}
fn prompt(&self) -> String {
if self.target.is_none() {
"BREAKATPOINT Select object:".into()
} else {
"BREAKATPOINT Specify 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);
CmdResult::NeedPoint
}
fn on_point(&mut self, pt: Vec3) -> CmdResult {
match self.target {
Some(handle) => CmdResult::BreakEntity {
handle,
p1: pt,
p2: pt,
},
None => CmdResult::Cancel,
}
}
fn on_enter(&mut self) -> CmdResult {
CmdResult::Cancel
}
}
// ── Autocomplete registry ─────────────────────────────────
inventory::submit!(crate::command::CommandRegistration { names: &["BAP", "BREAKATPOINT"] }); // BreakAtPointCommand
inventory::submit!(crate::command::CommandRegistration { names: &["BR", "BREAK"] }); // BreakInteractiveCommand
+108
View File
@@ -0,0 +1,108 @@
// Copy tool — ribbon definition + interactive command.
//
// Command: COPY (CO)
// Requires at least one entity selected before starting.
// Step 1: pick base point
// Step 2+: each click makes another copy at (click - base); Enter to finish.
use acadrust::Handle;
use glam::Vec3;
use crate::command::{CadCommand, CmdResult, EntityTransform};
use crate::modules::{IconKind, ModuleEvent, ToolDef};
use crate::scene::model::wire_model::WireModel;
// ── Ribbon definition ──────────────────────────────────────────────────────
pub fn tool() -> ToolDef {
ToolDef {
id: "COPY",
label: "Copy",
icon: IconKind::Svg(include_bytes!("../../../../assets/icons/copy.svg")),
event: ModuleEvent::Command("COPY".to_string()),
}
}
// ── Command implementation ─────────────────────────────────────────────────
enum Step {
Base,
Placing(Vec3),
}
pub struct CopyCommand {
handles: Vec<Handle>,
wire_models: Vec<WireModel>,
step: Step,
count: usize,
}
impl CopyCommand {
pub fn new(handles: Vec<Handle>, wire_models: Vec<WireModel>) -> Self {
Self {
handles,
wire_models,
step: Step::Base,
count: 0,
}
}
}
impl CadCommand for CopyCommand {
fn name(&self) -> &'static str {
"COPY"
}
fn prompt(&self) -> String {
match &self.step {
Step::Base => format!(
"COPY Specify base point [{} objects]:",
self.handles.len()
),
Step::Placing(base) => format!(
"COPY Specify destination [{} copies so far | Enter=done | base {:.3},{:.3}]:",
self.count, base.x, base.y
),
}
}
fn on_point(&mut self, pt: Vec3) -> CmdResult {
match &self.step {
Step::Base => {
self.step = Step::Placing(pt);
CmdResult::NeedPoint
}
Step::Placing(base) => {
let delta = pt - *base;
self.count += 1;
CmdResult::CopySelected(self.handles.clone(), EntityTransform::Translate(delta))
}
}
}
fn on_enter(&mut self) -> CmdResult {
CmdResult::Cancel
}
fn on_escape(&mut self) -> CmdResult {
CmdResult::Cancel
}
fn on_preview_wires(&mut self, pt: Vec3) -> Vec<WireModel> {
let Step::Placing(base) = &self.step else {
return vec![];
};
let delta = pt - *base;
let mut out: Vec<WireModel> = self
.wire_models
.iter()
.map(|w| w.translated(delta))
.collect();
out.push(WireModel::solid(
"rubber_band".into(),
vec![[base.x, base.y, base.z], [pt.x, pt.y, pt.z]],
WireModel::CYAN,
false,
));
out
}
}
+15
View File
@@ -0,0 +1,15 @@
// Delete tool — ribbon definition.
//
// Fires the ERASE command (same as pressing Delete key or typing ERASE/E).
// Shown as "Delete" in the ribbon so the label matches familiar terminology.
use crate::modules::{IconKind, ModuleEvent, ToolDef};
pub fn tool() -> ToolDef {
ToolDef {
id: "ERASE",
label: "Delete",
icon: IconKind::Svg(include_bytes!("../../../../assets/icons/erase.svg")),
event: ModuleEvent::Command("ERASE".to_string()),
}
}
+459
View File
@@ -0,0 +1,459 @@
// Explode tool — ribbon definition + command implementation.
//
// Command: EXPLODE (X)
// EXPLODE: Breaks compound objects into their constituent simple entities.
//
// Supported:
// LwPolyline → Lines (straight segments) + Arcs (bulge segments)
// Polyline2D → Lines + Arcs
// Polyline3D → Lines
// Polyline → Lines
// Insert → constituent entities (via acadrust explode_from_document)
// MLine → Lines (spine + offset lines per miter direction)
// Dimension → Lines (extension + dimension + arrows) + Text
//
// Unsupported entity types are skipped silently.
use std::f64::consts::TAU;
use acadrust::entities::EntityCommon;
use acadrust::entities::{
Arc as ArcEnt, Circle as CircleEnt, Dimension, Line as LineEnt, LwPolyline, MLine,
};
use acadrust::entities::{Polyline, Polyline2D};
use acadrust::types::Vector3;
use acadrust::{CadDocument, EntityType, Handle};
use crate::command::{CadCommand, CmdResult};
use crate::modules::{IconKind, ModuleEvent, ToolDef};
use glam::Vec3;
// ── Ribbon definition ──────────────────────────────────────────────────────
pub fn tool() -> ToolDef {
ToolDef {
id: "EXPLODE",
label: "Explode",
icon: IconKind::Svg(include_bytes!("../../../../assets/icons/explode.svg")),
event: ModuleEvent::Command("EXPLODE".to_string()),
}
}
// ── Geometry helpers ────────────────────────────────────────────────────────
/// Explode just the polyline family (LwPolyline / Polyline / Polyline2D /
/// Polyline3D) into Line + Arc segments. No document needed — used where a
/// polyline must be treated as its constituent edges (e.g. TRIM boundaries).
/// Returns empty for any other entity type.
pub fn explode_polyline_segments(entity: &EntityType) -> Vec<EntityType> {
match entity {
EntityType::LwPolyline(p) => explode_lwpolyline(p),
EntityType::Polyline2D(p) => explode_polyline2d(p),
EntityType::Polyline(p) => explode_polyline(p),
EntityType::Polyline3D(p) => explode_polyline3d(p),
_ => vec![],
}
}
/// Decompose an entity into its constituent simple entities.
/// Returns an empty vec if the entity cannot be exploded.
pub fn explode_entity(entity: &EntityType, document: &CadDocument) -> Vec<EntityType> {
match entity {
EntityType::LwPolyline(p) => explode_lwpolyline(p),
EntityType::Polyline2D(p) => explode_polyline2d(p),
EntityType::Polyline(p) => explode_polyline(p),
EntityType::Polyline3D(p) => explode_polyline3d(p),
EntityType::Insert(ins) => ins
.explode_from_document(document)
.into_iter()
.map(normalize_insert_entity)
.collect(),
EntityType::MLine(ml) => explode_mline(ml),
EntityType::Dimension(dim) => explode_dimension(dim),
_ => vec![],
}
}
fn explode_polyline(p: &Polyline) -> Vec<EntityType> {
let n = p.vertices.len();
if n < 2 {
return vec![];
}
let closed = p.flags.is_closed();
let n_segs = if closed { n } else { n - 1 };
let mut result = Vec::new();
for i in 0..n_segs {
let v0 = &p.vertices[i];
let v1 = &p.vertices[(i + 1) % n];
let mut common = p.common.clone();
common.handle = Handle::NULL;
result.push(EntityType::Line(LineEnt {
common,
start: v0.location.clone(),
end: v1.location.clone(),
..LineEnt::new()
}));
}
result
}
fn explode_polyline3d(p: &acadrust::entities::Polyline3D) -> Vec<EntityType> {
let n = p.vertices.len();
if n < 2 {
return vec![];
}
let closed = p.is_closed();
let n_segs = if closed { n } else { n - 1 };
let mut result = Vec::new();
for i in 0..n_segs {
let v0 = &p.vertices[i];
let v1 = &p.vertices[(i + 1) % n];
let mut common = p.common.clone();
common.handle = Handle::NULL;
result.push(EntityType::Line(LineEnt {
common,
start: v0.position.clone(),
end: v1.position.clone(),
..LineEnt::new()
}));
}
result
}
fn explode_polyline2d(p: &Polyline2D) -> Vec<EntityType> {
let n = p.vertices.len();
if n < 2 {
return vec![];
}
let closed = p.is_closed();
let n_segs = if closed { n } else { n - 1 };
let elevation = p.elevation;
let mut result = Vec::new();
for i in 0..n_segs {
let v0 = &p.vertices[i];
let v1 = &p.vertices[(i + 1) % n];
let p0 = [v0.location.x, v0.location.y];
let p1 = [v1.location.x, v1.location.y];
if v0.bulge.abs() < 1e-10 {
let mut common = p.common.clone();
common.handle = Handle::NULL;
result.push(EntityType::Line(LineEnt {
common,
start: Vector3::new(p0[0], p0[1], elevation),
end: Vector3::new(p1[0], p1[1], elevation),
..LineEnt::new()
}));
} else if let Some(arc) = bulge_to_arc(p0, p1, v0.bulge, elevation, &p.common) {
result.push(arc);
}
}
result
}
pub fn normalize_insert_entity(mut entity: EntityType) -> EntityType {
match &mut entity {
EntityType::Ellipse(ell) => {
let major_len = ell.major_axis_length();
let full_span = {
let mut span = ell.end_parameter - ell.start_parameter;
if span < 0.0 {
span += std::f64::consts::TAU;
}
(span - std::f64::consts::TAU).abs() < 1e-6
};
if (ell.minor_axis_ratio - 1.0).abs() < 1e-6 && full_span {
let mut circle = CircleEnt::new();
circle.common = ell.common.clone();
circle.center = ell.center;
circle.radius = major_len;
circle.normal = ell.normal;
entity = EntityType::Circle(circle);
}
}
_ => {}
}
entity.common_mut().handle = Handle::NULL;
entity.common_mut().owner_handle = Handle::NULL;
entity
}
pub fn normalize_entity_for_block(entity: EntityType) -> EntityType {
entity
}
fn explode_lwpolyline(p: &LwPolyline) -> Vec<EntityType> {
let n = p.vertices.len();
if n < 2 {
return vec![];
}
let elevation = p.elevation;
let n_segs = if p.is_closed { n } else { n - 1 };
let mut result = Vec::new();
for i in 0..n_segs {
let v0 = &p.vertices[i];
let v1 = &p.vertices[(i + 1) % n];
let p0 = [v0.location.x, v0.location.y];
let p1 = [v1.location.x, v1.location.y];
if v0.bulge.abs() < 1e-10 {
// Straight segment → Line
let mut common = p.common.clone();
common.handle = Handle::NULL;
let line = LineEnt {
common,
start: Vector3::new(p0[0], p0[1], elevation),
end: Vector3::new(p1[0], p1[1], elevation),
..LineEnt::new()
};
result.push(EntityType::Line(line));
} else {
// Arc segment from bulge
if let Some(arc) = bulge_to_arc(p0, p1, v0.bulge, elevation, &p.common) {
result.push(arc);
}
}
}
result
}
/// Convert a polyline bulge segment to an Arc entity.
/// Arc angles are measured from the +X axis.
fn bulge_to_arc(
p0: [f64; 2],
p1: [f64; 2],
bulge: f64,
elevation: f64,
common_src: &EntityCommon,
) -> Option<EntityType> {
let ba = crate::entities::common::BulgeArc::from_bulge(p0, p1, bulge)?;
// acadrust Arc is always CCW from start_angle to end_angle. Negative
// bulge means the polyline goes p0→p1 the CW way around the centre,
// which is the same circular arc traversed p1→p0 the CCW way — so
// swap endpoints when bulge < 0.
let (start_angle, end_angle) = if bulge > 0.0 {
(norm_angle(ba.start_angle), norm_angle(ba.end_angle))
} else {
(norm_angle(ba.end_angle), norm_angle(ba.start_angle))
};
let mut common = common_src.clone();
common.handle = Handle::NULL;
let arc = ArcEnt {
common,
center: Vector3::new(ba.center[0], ba.center[1], elevation),
radius: ba.radius,
start_angle,
end_angle,
..ArcEnt::new()
};
Some(EntityType::Arc(arc))
}
fn norm_angle(a: f64) -> f64 {
((a % TAU) + TAU) % TAU
}
fn explode_mline(ml: &MLine) -> Vec<EntityType> {
let n = ml.vertices.len();
if n < 2 {
return vec![];
}
let closed = ml.flags.contains(acadrust::entities::MLineFlags::CLOSED);
let scale = ml.scale_factor;
let n_segs = if closed { n } else { n - 1 };
let mut result = Vec::new();
// Helper: build a Line from two Vector3 positions.
let make_line = |common: &acadrust::entities::EntityCommon,
s: &acadrust::types::Vector3,
e: &acadrust::types::Vector3|
-> EntityType {
let mut c = common.clone();
c.handle = Handle::NULL;
EntityType::Line(LineEnt {
common: c,
start: s.clone(),
end: e.clone(),
..LineEnt::new()
})
};
// For each segment, emit the center-spine line and the two ±scale/2 offset lines.
for i in 0..n_segs {
let v0 = &ml.vertices[i];
let v1 = &ml.vertices[(i + 1) % n];
// Spine line
result.push(make_line(&ml.common, &v0.position, &v1.position));
if scale.abs() > 1e-9 {
let half = scale * 0.5;
for &sign in &[-1.0_f64, 1.0_f64] {
let off = half * sign;
// Use miter direction at each vertex to offset the endpoints.
let s = Vector3::new(
v0.position.x + v0.miter.x * off,
v0.position.y + v0.miter.y * off,
v0.position.z + v0.miter.z * off,
);
let e = Vector3::new(
v1.position.x + v1.miter.x * off,
v1.position.y + v1.miter.y * off,
v1.position.z + v1.miter.z * off,
);
result.push(make_line(&ml.common, &s, &e));
}
}
}
result
}
// ── Dimension explode ──────────────────────────────────────────────────────
/// Convert a Dimension entity into Lines (geometry) + Text (label).
fn explode_dimension(dim: &Dimension) -> Vec<EntityType> {
use acadrust::entities::Text;
let base = dim.base();
let common = base.common.clone();
let mut result: Vec<EntityType> = Vec::new();
// Helper: make a line segment
let make_seg = |a: &Vector3, b: &Vector3, common: &EntityCommon| -> EntityType {
let mut c = common.clone();
c.handle = Handle::NULL;
EntityType::Line(LineEnt {
common: c,
start: a.clone(),
end: b.clone(),
..LineEnt::new()
})
};
let v3 = |x: f64, y: f64, z: f64| Vector3::new(x, y, z);
match dim {
Dimension::Aligned(d) => {
let fx = d.first_point.x;
let fy = d.first_point.y;
let sx = d.second_point.x;
let sy = d.second_point.y;
let dx_s = sx - fx;
let dy_s = sy - fy;
let len = (dx_s * dx_s + dy_s * dy_s).sqrt().max(1e-12);
let axis_angle = dy_s.atan2(dx_s);
let perp_x = -(axis_angle.sin());
let perp_y = axis_angle.cos();
let offset =
(d.definition_point.x - fx) * perp_x + (d.definition_point.y - fy) * perp_y;
let d1 = v3(fx + perp_x * offset, fy + perp_y * offset, d.first_point.z);
let d2 = v3(sx + perp_x * offset, sy + perp_y * offset, d.second_point.z);
result.push(make_seg(&d.first_point, &d1, &common));
result.push(make_seg(&d.second_point, &d2, &common));
result.push(make_seg(&d1, &d2, &common));
let _ = len;
}
Dimension::Linear(d) => {
let angle = d.rotation.to_radians();
let perp_x = -(angle.sin());
let perp_y = angle.cos();
let fx = d.first_point.x;
let fy = d.first_point.y;
let sx = d.second_point.x;
let sy = d.second_point.y;
let offset =
(d.definition_point.x - fx) * perp_x + (d.definition_point.y - fy) * perp_y;
let d1 = v3(fx + perp_x * offset, fy + perp_y * offset, d.first_point.z);
let d2 = v3(sx + perp_x * offset, sy + perp_y * offset, d.second_point.z);
result.push(make_seg(&d.first_point, &d1, &common));
result.push(make_seg(&d.second_point, &d2, &common));
result.push(make_seg(&d1, &d2, &common));
}
Dimension::Radius(d) => {
result.push(make_seg(&d.angle_vertex, &d.definition_point, &common));
}
Dimension::Diameter(d) => {
result.push(make_seg(&d.angle_vertex, &d.definition_point, &common));
}
Dimension::Angular2Ln(d) => {
result.push(make_seg(&d.first_point, &d.angle_vertex, &common));
result.push(make_seg(&d.second_point, &d.angle_vertex, &common));
}
Dimension::Angular3Pt(d) => {
result.push(make_seg(&d.first_point, &d.angle_vertex, &common));
result.push(make_seg(&d.second_point, &d.angle_vertex, &common));
}
Dimension::Ordinate(d) => {
result.push(make_seg(&d.feature_location, &d.definition_point, &common));
result.push(make_seg(&d.definition_point, &d.leader_endpoint, &common));
}
}
// Text entity for the dimension label
let text_val = if let Some(u) = &base.user_text {
if !u.trim().is_empty() {
u.clone()
} else {
format!("{:.4}", dim.measurement())
}
} else if !base.text.trim().is_empty() {
base.text.clone()
} else {
match dim {
Dimension::Radius(_) => format!("R{:.4}", dim.measurement()),
Dimension::Diameter(_) => format!("Ø{:.4}", dim.measurement()),
Dimension::Angular2Ln(_) | Dimension::Angular3Pt(_) => {
format!("{:.2}°", dim.measurement())
}
_ => format!("{:.4}", dim.measurement()),
}
};
let mut text = Text::with_value(text_val, base.text_middle_point.clone())
.with_height(base.line_spacing_factor.abs().max(0.1))
.with_rotation(base.text_rotation);
text.common = common.clone();
text.common.handle = Handle::NULL;
result.push(EntityType::Text(text));
result
}
// ── Command stub (kept for future interactive selection mode) ───────────────
pub struct ExplodeCommand;
impl ExplodeCommand {
#[allow(dead_code)]
pub fn new() -> Self {
Self
}
}
impl CadCommand for ExplodeCommand {
fn name(&self) -> &'static str {
"EXPLODE"
}
fn prompt(&self) -> String {
"EXPLODE Select objects to explode:".into()
}
fn on_point(&mut self, _pt: Vec3) -> CmdResult {
CmdResult::Cancel
}
fn on_enter(&mut self) -> CmdResult {
CmdResult::Cancel
}
fn on_escape(&mut self) -> CmdResult {
CmdResult::Cancel
}
}
+1
View File
@@ -0,0 +1 @@
// Extend — dispatch handled by trim.rs dropdown. This file exists for module resolution.
File diff suppressed because it is too large. Load diff
+243
View File
@@ -0,0 +1,243 @@
// 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
+514
View File
@@ -0,0 +1,514 @@
// LENGTHEN command — extend or trim a Line or Arc by a specified delta or total.
//
// Options (entered as text after the entity pick):
// DE <value> — extend by delta (positive extends, negative trims)
// TO <value> — set total length (Line) or arc length (Arc)
// P <pct> — change by percentage (100 = no change, 150 = +50%)
//
// The entity is modified at whichever end is closest to the pick point.
use crate::modules::draw::modify::spline_ops::{bspline_to_spline, spline_to_bspline};
use acadrust::entities::{
Arc as ArcEnt, Ellipse as EllipseEnt, Line as LineEnt, LwPolyline, Spline as SplineEnt,
};
use acadrust::types::Vector3;
use acadrust::{EntityType, Handle};
use glam::Vec3;
use truck_modeling::base::{BoundedCurve, Cut};
use crate::command::{CadCommand, CmdResult};
pub struct LengthenCommand {
state: LenState,
}
enum LenState {
PickEntity,
PickOption { handle: Handle, pick_pt: Vec3 },
}
impl LengthenCommand {
pub fn new() -> Self {
Self {
state: LenState::PickEntity,
}
}
}
impl CadCommand for LengthenCommand {
fn name(&self) -> &'static str {
"LENGTHEN"
}
fn prompt(&self) -> String {
match &self.state {
LenState::PickEntity => "LENGTHEN Select object:".into(),
LenState::PickOption { .. } => {
"LENGTHEN Enter option [DE <delta> / TO <total> / P <pct>]:".into()
}
}
}
fn needs_entity_pick(&self) -> bool {
matches!(self.state, LenState::PickEntity)
}
fn on_entity_pick(&mut self, handle: Handle, pt: Vec3) -> CmdResult {
if handle.is_null() {
return CmdResult::NeedPoint;
}
self.state = LenState::PickOption {
handle,
pick_pt: pt,
};
CmdResult::NeedPoint
}
fn wants_text_input(&self) -> bool {
matches!(self.state, LenState::PickOption { .. })
}
fn dyn_field(&self) -> crate::command::DynField {
if matches!(self.state, LenState::PickOption { .. }) {
crate::command::DynField::Scalar
} else {
crate::command::DynField::Point
}
}
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
let (handle, pick_pt) = match &self.state {
LenState::PickOption { handle, pick_pt } => (*handle, *pick_pt),
_ => return None,
};
let text = text.trim().to_uppercase();
if let Some(rest) = text.strip_prefix("DE ").or_else(|| text.strip_prefix("DE")) {
let delta: f64 = rest.trim().replace(',', ".").parse().ok()?;
Some(CmdResult::LengthenEntity {
handle,
pick_pt,
mode: LenMode::Delta(delta),
})
} else if let Some(rest) = text.strip_prefix("TO ").or_else(|| text.strip_prefix("TO")) {
let total: f64 = rest
.trim()
.replace(',', ".")
.parse()
.ok()
.filter(|&v: &f64| v > 0.0)?;
Some(CmdResult::LengthenEntity {
handle,
pick_pt,
mode: LenMode::Total(total),
})
} else if let Some(rest) = text.strip_prefix("P ").or_else(|| text.strip_prefix("P")) {
let pct: f64 = rest
.trim()
.replace(',', ".")
.parse()
.ok()
.filter(|&v: &f64| v > 0.0)?;
Some(CmdResult::LengthenEntity {
handle,
pick_pt,
mode: LenMode::Percent(pct),
})
} else {
// Try plain number as delta
let delta: f64 = text.replace(',', ".").parse().ok()?;
Some(CmdResult::LengthenEntity {
handle,
pick_pt,
mode: LenMode::Delta(delta),
})
}
}
fn on_point(&mut self, _pt: Vec3) -> CmdResult {
CmdResult::NeedPoint
}
fn on_enter(&mut self) -> CmdResult {
CmdResult::Cancel
}
}
// ── Mode enum (also used in CmdResult) ────────────────────────────────────
#[derive(Clone)]
pub enum LenMode {
Delta(f64),
Total(f64),
Percent(f64),
}
// ── Geometry ───────────────────────────────────────────────────────────────
/// Apply LENGTHEN to a Line, Arc, Ellipse, or Spline.
/// `pick_pt` determines which end to extend/trim (closest end is modified).
pub fn lengthen_entity(entity: &EntityType, pick_pt: Vec3, mode: &LenMode) -> Option<EntityType> {
match entity {
EntityType::Line(l) => lengthen_line(l, pick_pt, mode),
EntityType::Arc(a) => lengthen_arc(a, pick_pt, mode),
EntityType::Ellipse(e) => lengthen_ellipse(e, pick_pt, mode),
EntityType::Spline(s) => lengthen_spline(s, pick_pt, mode),
EntityType::LwPolyline(p) => lengthen_lwpoly(p, pick_pt, mode),
_ => None,
}
}
fn lengthen_line(line: &LineEnt, pick_pt: Vec3, mode: &LenMode) -> Option<EntityType> {
let s = Vec3::new(line.start.x as f32, line.start.y as f32, 0.0);
let e = Vec3::new(line.end.x as f32, line.end.y as f32, 0.0);
let p = Vec3::new(pick_pt.x, pick_pt.y, 0.0);
let current_len = (e - s).length() as f64;
if current_len < 1e-10 {
return None;
}
let new_len = apply_mode(current_len, mode)?;
if new_len < 1e-10 {
return None;
}
let dir = (e - s) / current_len as f32;
// Which end is closer to pick?
let dist_to_start = (p - s).length();
let dist_to_end = (p - e).length();
let mut result = line.clone();
result.common.handle = Handle::NULL;
if dist_to_end <= dist_to_start {
// Extend/trim the end
let new_end = s + dir * new_len as f32;
result.end = xz_to_v3(new_end, line.end.z);
} else {
// Extend/trim the start (move start backward along dir)
let new_start = e - dir * new_len as f32;
result.start = xz_to_v3(new_start, line.start.z);
}
Some(EntityType::Line(result))
}
fn lengthen_arc(arc: &ArcEnt, pick_pt: Vec3, mode: &LenMode) -> Option<EntityType> {
let cx = arc.center.x as f32;
let cy = arc.center.y as f32;
// Current arc span
let span = arc_span_rad(arc.start_angle, arc.end_angle);
let current_arc_len = arc.radius * span;
let new_arc_len = apply_mode(current_arc_len, mode)?;
if new_arc_len < 1e-10 {
return None;
}
let new_span = new_arc_len / arc.radius;
// Which end (start or end angle) is closer to pick?
let start_rad = arc.start_angle;
let end_rad = arc.end_angle;
let start_pt = Vec3::new(
cx + arc.radius as f32 * start_rad.cos() as f32,
pick_pt.y,
cy + arc.radius as f32 * start_rad.sin() as f32,
);
let end_pt = Vec3::new(
cx + arc.radius as f32 * end_rad.cos() as f32,
pick_pt.y,
cy + arc.radius as f32 * end_rad.sin() as f32,
);
let dist_start = (pick_pt - start_pt).length();
let dist_end = (pick_pt - end_pt).length();
let delta_span = new_span - span;
let mut result = arc.clone();
result.common.handle = Handle::NULL;
if dist_end <= dist_start {
// Extend end angle
result.end_angle = arc.start_angle + new_span;
} else {
// Extend start angle (move start backwards)
result.start_angle = arc.end_angle - new_span;
}
let _ = delta_span;
Some(EntityType::Arc(result))
}
fn lengthen_ellipse(ell: &EllipseEnt, pick_pt: Vec3, mode: &LenMode) -> Option<EntityType> {
let a = (ell.major_axis.x.powi(2) + ell.major_axis.y.powi(2)).sqrt();
if a < 1e-9 {
return None;
}
let b = a * ell.minor_axis_ratio;
let nx = ell.major_axis.x / a;
let ny = ell.major_axis.y / a;
let t0 = ell.start_parameter;
let mut t1 = ell.end_parameter;
if t1 <= t0 {
t1 += std::f64::consts::TAU;
}
let span = t1 - t0;
// Approximate arc length via 128-point Gaussian quadrature estimate.
let arc_len_approx = |span: f64| -> f64 {
let n = 128usize;
let mut len = 0.0;
for i in 0..n {
let ti = t0 + span * (i as f64 / n as f64);
let tip = t0 + span * ((i + 1) as f64 / n as f64);
let xi = a * ti.cos() * nx - b * ti.sin() * ny + ell.center.x;
let yi = a * ti.cos() * ny + b * ti.sin() * nx + ell.center.y;
let xip = a * tip.cos() * nx - b * tip.sin() * ny + ell.center.x;
let yip = a * tip.cos() * ny + b * tip.sin() * nx + ell.center.y;
len += (xip - xi).hypot(yip - yi);
}
len
};
let current_len = arc_len_approx(span);
if current_len < 1e-10 {
return None;
}
let new_len = apply_mode(current_len, mode)?;
if new_len < 1e-10 {
return None;
}
// Find the new span via bisection so that arc_len_approx(new_span) ≈ new_len.
let max_span = std::f64::consts::TAU;
let mut lo = 0.0f64;
let mut hi = max_span;
for _ in 0..40 {
let mid = (lo + hi) * 0.5;
if arc_len_approx(mid) < new_len {
lo = mid;
} else {
hi = mid;
}
}
let new_span = (lo + hi) * 0.5;
// Determine which end is closer to pick_pt (use DXF XY plane).
let p_x = pick_pt.x as f64;
let p_y = pick_pt.y as f64;
let pt_start_x = ell.center.x + a * t0.cos() * nx - b * t0.sin() * ny;
let pt_start_y = ell.center.y + a * t0.cos() * ny + b * t0.sin() * nx;
let pt_end_x = ell.center.x + a * t1.cos() * nx - b * t1.sin() * ny;
let pt_end_y = ell.center.y + a * t1.cos() * ny + b * t1.sin() * nx;
let dist_start = (p_x - pt_start_x).hypot(p_y - pt_start_y);
let dist_end = (p_x - pt_end_x).hypot(p_y - pt_end_y);
let mut result = ell.clone();
result.common.handle = Handle::NULL;
if dist_end <= dist_start {
result.end_parameter = t0 + new_span;
} else {
result.start_parameter = t1 - new_span;
}
Some(EntityType::Ellipse(result))
}
fn apply_mode(current: f64, mode: &LenMode) -> Option<f64> {
match mode {
LenMode::Delta(d) => Some(current + d),
LenMode::Total(t) => Some(*t),
LenMode::Percent(p) => Some(current * p / 100.0),
}
}
fn arc_span_rad(start: f64, end: f64) -> f64 {
let span = (end - start).rem_euclid(std::f64::consts::TAU);
if span < 1e-6 {
std::f64::consts::TAU
} else {
span
}
}
fn xz_to_v3(v: Vec3, z: f64) -> Vector3 {
// v is (world_x, world_z, 0) → DXF (x, world_z, z)
Vector3::new(v.x as f64, v.y as f64, z)
}
fn lengthen_lwpoly(poly: &LwPolyline, pick_pt: Vec3, mode: &LenMode) -> Option<EntityType> {
let n = poly.vertices.len();
if n < 2 {
return None;
}
// Determine which end is closer to the pick point (DXF XY: pick_pt.x, pick_pt.z).
let px = pick_pt.x as f64;
let py = pick_pt.y as f64;
let first = &poly.vertices[0];
let last = &poly.vertices[n - 1];
let d_first = (first.location.x - px).hypot(first.location.y - py);
let d_last = (last.location.x - px).hypot(last.location.y - py);
let at_end = d_last <= d_first;
// Terminal segment direction and current length.
let (sx, sy, ex, ey) = if at_end {
(
poly.vertices[n - 2].location.x,
poly.vertices[n - 2].location.y,
last.location.x,
last.location.y,
)
} else {
(
poly.vertices[1].location.x,
poly.vertices[1].location.y,
first.location.x,
first.location.y,
)
};
let dx = ex - sx;
let dy = ey - sy;
let current_len = (dx * dx + dy * dy).sqrt();
if current_len < 1e-10 {
return None;
}
let new_len = apply_mode(current_len, mode)?;
if new_len < 1e-10 {
return None;
}
let ux = dx / current_len;
let uy = dy / current_len;
let new_x = sx + ux * new_len;
let new_y = sy + uy * new_len;
let mut new_poly = poly.clone();
new_poly.common.handle = Handle::NULL;
if at_end {
let v = new_poly.vertices.last_mut()?;
v.location.x = new_x;
v.location.y = new_y;
} else {
let v = new_poly.vertices.first_mut()?;
v.location.x = new_x;
v.location.y = new_y;
}
Some(EntityType::LwPolyline(new_poly))
}
fn lengthen_spline(spl: &SplineEnt, pick_pt: Vec3, mode: &LenMode) -> Option<EntityType> {
let bs = spline_to_bspline(spl)?;
let (t0, t1) = bs.range_tuple();
if (t1 - t0).abs() < 1e-12 {
return None;
}
// Approximate arc length via 64-point numerical integration.
use truck_modeling::base::ParametricCurve;
let arc_len = {
let n = 64usize;
let mut len = 0.0f64;
for i in 0..n {
let ta = t0 + (t1 - t0) * (i as f64 / n as f64);
let tb = t0 + (t1 - t0) * ((i + 1) as f64 / n as f64);
let pa = bs.subs(ta);
let pb = bs.subs(tb);
len += (pb.x - pa.x).hypot(pb.y - pa.y);
}
len
};
if arc_len < 1e-10 {
return None;
}
let new_len = apply_mode(arc_len, mode)?;
if new_len < 1e-10 {
return None;
}
// Determine which end (start or end) is closer to pick_pt.
let p_start = bs.subs(t0);
let p_end = bs.subs(t1);
let dist_start = (p_start.x - pick_pt.x as f64).hypot(p_start.y - pick_pt.y as f64);
let dist_end = (p_end.x - pick_pt.x as f64).hypot(p_end.y - pick_pt.y as f64);
let extend_end = dist_end <= dist_start;
// Find the parameter `t_new` such that the arc length from the fixed end to t_new = new_len.
// Use bisection on cumulative arc length.
let fixed_t = if extend_end { t0 } else { t1 };
let delta_ratio = new_len / arc_len;
// Find t_new via bisection: cumulative_len(fixed_t..t_new) = new_len.
let cum_len = |t_end_param: f64| -> f64 {
let (lo, hi) = if extend_end {
(t0, t_end_param)
} else {
(t_end_param, t1)
};
if hi <= lo {
return 0.0;
}
let n = 32usize;
let mut len = 0.0f64;
for i in 0..n {
let ta = lo + (hi - lo) * (i as f64 / n as f64);
let tb = lo + (hi - lo) * ((i + 1) as f64 / n as f64);
let pa = bs.subs(ta);
let pb = bs.subs(tb);
len += (pb.x - pa.x).hypot(pb.y - pa.y);
}
len
};
let (mut lo_t, mut hi_t) = if extend_end {
(t0, t0 + delta_ratio * (t1 - t0) * 2.0)
} else {
(t1 - delta_ratio * (t1 - t0) * 2.0, t1)
};
// Clamp to valid range with some buffer for extension.
let buf = (t1 - t0) * 0.5;
lo_t = lo_t.max(t0 - buf);
hi_t = hi_t.min(t1 + buf);
for _ in 0..40 {
let mid = (lo_t + hi_t) * 0.5;
if cum_len(mid) < new_len {
if extend_end {
hi_t = mid;
} else {
lo_t = mid;
}
} else {
if extend_end {
lo_t = mid;
} else {
hi_t = mid;
}
}
}
let t_new = (lo_t + hi_t) * 0.5;
let _ = fixed_t;
// Split the spline at t_new.
let mut piece = bs.clone();
if extend_end {
// Keep [t0, t_new]: cut at t_new
let _right = piece.cut(t_new.clamp(t0 + 1e-10, t1 * 2.0));
Some(EntityType::Spline(bspline_to_spline(&piece, spl)))
} else {
// Keep [t_new, t1]: cut at t_new, return the right portion
let right = piece.cut(t_new.clamp(t0 * 0.5, t1 - 1e-10));
Some(EntityType::Spline(bspline_to_spline(&right, spl)))
}
}
// ── Autocomplete registry ─────────────────────────────────
inventory::submit!(crate::command::CommandRegistration { names: &["LEN", "LENGTHEN"] }); // LengthenCommand
+152
View File
@@ -0,0 +1,152 @@
// Mirror tool — ribbon definition + interactive command.
//
// Command: MIRROR (MI)
// Requires at least one entity selected.
// Step 1: pick first mirror-line point
// Step 2: pick second mirror-line point
// Step 3: "Erase source objects? [Yes/No] <No>"
// No → keep the original, add a mirrored copy
// Yes → flip the original in place (no copy kept)
use acadrust::Handle;
use glam::Vec3;
use crate::command::{CadCommand, CmdResult, EntityTransform};
use crate::modules::{IconKind, ModuleEvent, ToolDef};
use crate::scene::model::wire_model::WireModel;
pub fn tool() -> ToolDef {
ToolDef {
id: "MIRROR",
label: "Mirror",
icon: IconKind::Svg(include_bytes!("../../../../assets/icons/mirror.svg")),
event: ModuleEvent::Command("MIRROR".to_string()),
}
}
enum Step {
P1,
P2(Vec3),
/// Both mirror-line points fixed; waiting on the erase-source answer.
AskErase { p1: Vec3, p2: Vec3 },
}
pub struct MirrorCommand {
handles: Vec<Handle>,
wire_models: Vec<WireModel>,
step: Step,
}
impl MirrorCommand {
pub fn new(handles: Vec<Handle>, wire_models: Vec<WireModel>) -> Self {
Self {
handles,
wire_models,
step: Step::P1,
}
}
}
impl CadCommand for MirrorCommand {
fn name(&self) -> &'static str {
"MIRROR"
}
fn prompt(&self) -> String {
match &self.step {
Step::P1 => format!(
"MIRROR Specify first mirror-line point [{} objects]:",
self.handles.len()
),
Step::P2(p1) => format!(
"MIRROR Specify second point [p1={:.2},{:.2}]:",
p1.x, p1.y
),
Step::AskErase { .. } => "MIRROR Erase source objects? [Yes/No] <No>:".to_string(),
}
}
fn on_point(&mut self, pt: Vec3) -> CmdResult {
match &self.step {
Step::P1 => {
self.step = Step::P2(pt);
CmdResult::NeedPoint
}
Step::P2(p1) => {
self.step = Step::AskErase { p1: *p1, p2: pt };
CmdResult::NeedPoint
}
// Second point is fixed; further clicks ignored until the
// erase-source question is answered via the command line.
Step::AskErase { .. } => CmdResult::NeedPoint,
}
}
fn on_enter(&mut self) -> CmdResult {
// Enter at the erase prompt accepts the default (No → keep source).
match &self.step {
Step::AskErase { p1, p2 } => self.finish(*p1, *p2, false),
_ => CmdResult::Cancel,
}
}
fn on_escape(&mut self) -> CmdResult {
CmdResult::Cancel
}
fn wants_text_input(&self) -> bool {
matches!(self.step, Step::AskErase { .. })
}
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
let Step::AskErase { p1, p2 } = &self.step else {
return None;
};
let (p1, p2) = (*p1, *p2);
let t = text.trim().to_ascii_lowercase();
let erase = match t.as_str() {
"y" | "yes" => true,
// Empty input (bare Enter) or an explicit No keeps the source.
"" | "n" | "no" => false,
// Unrecognised input: re-ask without committing.
_ => return Some(CmdResult::NeedPoint),
};
Some(self.finish(p1, p2, erase))
}
fn on_preview_wires(&mut self, pt: Vec3) -> Vec<WireModel> {
// While picking the second point the ghost tracks the cursor; once it
// is fixed (erase prompt) the ghost freezes at the chosen axis.
let (p1, p2) = match &self.step {
Step::P2(p1) => (*p1, pt),
Step::AskErase { p1, p2 } => (*p1, *p2),
_ => return vec![],
};
// Mirrored ghosts of all selected objects.
let mut out: Vec<WireModel> = self
.wire_models
.iter()
.map(|w| w.mirrored(p1, p2))
.collect();
// Mirror-axis line (rubber-band).
out.push(WireModel::solid(
"rubber_band".into(),
vec![[p1.x, p1.y, p1.z], [p2.x, p2.y, p2.z]],
WireModel::CYAN,
false,
));
out
}
}
impl MirrorCommand {
/// Commit the mirror. `erase` true flips the originals in place; false
/// keeps them and adds a mirrored copy. Either way the command ends.
fn finish(&self, p1: Vec3, p2: Vec3, erase: bool) -> CmdResult {
let xform = EntityTransform::Mirror { p1, p2 };
if erase {
CmdResult::TransformSelected(self.handles.clone(), xform)
} else {
CmdResult::BatchCopy(self.handles.clone(), vec![xform])
}
}
}
+22
View File
@@ -0,0 +1,22 @@
pub mod align;
pub mod array;
pub mod attedit;
pub mod break_cmd;
pub mod copy;
pub mod delete;
pub mod explode;
mod extend;
pub mod fillet;
pub mod join;
pub mod lengthen;
pub mod mirror;
pub mod offset;
pub mod pedit;
pub mod refedit;
pub mod rotate;
pub mod scale;
pub mod spline_ops;
pub mod splinedit;
pub mod stretch;
pub mod translate;
pub mod trim;
+772
View File
@@ -0,0 +1,772 @@
// Offset tool — ribbon definition + interactive command.
//
// Command: OFFSET (O)
// OFFSET: Creates a parallel copy of an object (line, arc, circle,
// or lwpolyline) at a specified distance on the chosen side.
//
// Steps:
// 1. Text input: "Specify offset distance <last>:" → enter float or Enter for default
// 2. Pick object to offset (Line, Arc, Circle, LwPolyline)
// 3. Pick a point on the side to offset toward
use std::f64::consts::TAU;
use crate::modules::draw::modify::spline_ops::{spline_pts_wire, spline_sample_xy};
use acadrust::entities::LwVertex;
use acadrust::entities::{
Arc as ArcEnt, Circle as CircleEnt, Ellipse as EllipseEnt, Line as LineEnt, LwPolyline,
Spline as SplineEnt,
};
use acadrust::{EntityType, Handle};
use glam::Vec3;
use crate::command::{CadCommand, CmdResult};
use crate::modules::draw::defaults;
use crate::modules::{IconKind, ModuleEvent, ToolDef};
use crate::scene::model::wire_model::WireModel;
// ── Ribbon definition ──────────────────────────────────────────────────────
pub fn tool() -> ToolDef {
ToolDef {
id: "OFFSET",
label: "Offset",
icon: IconKind::Svg(include_bytes!("../../../../assets/icons/offset.svg")),
event: ModuleEvent::Command("OFFSET".to_string()),
}
}
// ── Geometry helpers ────────────────────────────────────────────────────────
/// Infinite-line intersection in 2D. Returns the point or None if parallel.
fn isect_lines(p0: [f64; 2], p1: [f64; 2], q0: [f64; 2], q1: [f64; 2]) -> Option<[f64; 2]> {
let dx = p1[0] - p0[0];
let dy = p1[1] - p0[1];
let ex = q1[0] - q0[0];
let ey = q1[1] - q0[1];
let det = dx * ey - dy * ex;
if det.abs() < 1e-10 {
return None;
}
let t = ((q0[0] - p0[0]) * ey - (q0[1] - p0[1]) * ex) / det;
Some([p0[0] + t * dx, p0[1] + t * dy])
}
fn norm_rad(a: f64) -> f64 {
((a % TAU) + TAU) % TAU
}
// ── Line offset ────────────────────────────────────────────────────────────
fn offset_line(l: &LineEnt, dist: f64, side_pt: Vec3) -> Option<EntityType> {
let dx = l.end.x - l.start.x;
let dy = l.end.y - l.start.y;
let len = (dx * dx + dy * dy).sqrt();
if len < 1e-12 {
return None;
}
let nx = -dy / len; // left-perpendicular
let ny = dx / len;
let vx = side_pt.x as f64 - l.start.x;
let vy = side_pt.y as f64 - l.start.y;
let cross = dx * vy - dy * vx;
let sign = if cross >= 0.0 { 1.0 } else { -1.0 };
let ox = sign * nx * dist;
let oy = sign * ny * dist;
let mut new_l = l.clone();
new_l.common.handle = Handle::NULL;
new_l.start.x += ox;
new_l.start.y += oy;
new_l.end.x += ox;
new_l.end.y += oy;
Some(EntityType::Line(new_l))
}
// ── Circle offset ──────────────────────────────────────────────────────────
fn offset_circle(c: &CircleEnt, dist: f64, side_pt: Vec3) -> Option<EntityType> {
let px = side_pt.x as f64;
let py = side_pt.y as f64;
let dc = ((px - c.center.x).powi(2) + (py - c.center.y).powi(2)).sqrt();
let new_r = if dc < c.radius {
c.radius - dist
} else {
c.radius + dist
};
if new_r <= 1e-9 {
return None;
}
let mut new_c = c.clone();
new_c.common.handle = Handle::NULL;
new_c.radius = new_r;
Some(EntityType::Circle(new_c))
}
// ── Arc offset ─────────────────────────────────────────────────────────────
fn offset_arc(a: &ArcEnt, dist: f64, side_pt: Vec3) -> Option<EntityType> {
let px = side_pt.x as f64;
let py = side_pt.y as f64;
let dc = ((px - a.center.x).powi(2) + (py - a.center.y).powi(2)).sqrt();
let new_r = if dc < a.radius {
a.radius - dist
} else {
a.radius + dist
};
if new_r <= 1e-9 {
return None;
}
let mut new_a = a.clone();
new_a.common.handle = Handle::NULL;
new_a.radius = new_r;
Some(EntityType::Arc(new_a))
}
// ── LwPolyline offset ──────────────────────────────────────────────────────
//
// Algorithm:
// 1. Offset every segment by `dist` in the direction perpendicular to it
// (sign is determined once from the first non-degenerate segment + side_pt).
// 2. Reconnect adjacent offset segments:
// - Open: first / last vertex use the raw offset endpoints;
// interior vertices are the intersection of adjacent offset segments.
// - Closed: every vertex is the intersection of the previous and next
// offset segments.
// 3. Bulge values are preserved from the original vertices (arc segments
// keep the same angle; the radius changes implicitly via the new chord
// length — a minor approximation acceptable for modest offsets).
fn offset_lwpolyline(p: &LwPolyline, dist: f64, side_pt: Vec3) -> Option<EntityType> {
let n = p.vertices.len();
if n < 2 {
return None;
}
let n_segs = if p.is_closed { n } else { n - 1 };
// Determine offset sign from the first non-degenerate segment.
let sign: f64 = (0..n_segs).find_map(|i| {
let v0 = &p.vertices[i];
let v1 = &p.vertices[(i + 1) % n];
let dx = v1.location.x - v0.location.x;
let dy = v1.location.y - v0.location.y;
let len = (dx * dx + dy * dy).sqrt();
if len < 1e-12 {
return None;
}
let vx = side_pt.x as f64 - v0.location.x;
let vy = side_pt.y as f64 - v0.location.y;
let cross = dx * vy - dy * vx;
Some(if cross >= 0.0 { 1.0 } else { -1.0 })
})?;
// Offset each segment. A segment may be degenerate (zero length) → None.
struct OffSeg {
p0: [f64; 2],
p1: [f64; 2],
}
let segs: Vec<Option<OffSeg>> = (0..n_segs)
.map(|i| {
let v0 = &p.vertices[i];
let v1 = &p.vertices[(i + 1) % n];
let dx = v1.location.x - v0.location.x;
let dy = v1.location.y - v0.location.y;
let len = (dx * dx + dy * dy).sqrt();
if len < 1e-12 {
return None;
}
let ox = sign * (-dy / len) * dist;
let oy = sign * (dx / len) * dist;
Some(OffSeg {
p0: [v0.location.x + ox, v0.location.y + oy],
p1: [v1.location.x + ox, v1.location.y + oy],
})
})
.collect();
let m = segs.len();
// Helper: corner vertex from the intersection of two consecutive offset segments.
let corner = |prev: &OffSeg, curr: &OffSeg| -> [f64; 2] {
isect_lines(prev.p0, prev.p1, curr.p0, curr.p1).unwrap_or([
(prev.p1[0] + curr.p0[0]) * 0.5,
(prev.p1[1] + curr.p0[1]) * 0.5,
])
};
let mut new_verts: Vec<LwVertex> = Vec::new();
if p.is_closed {
for i in 0..m {
let prev_idx = (i + m - 1) % m;
let prev = match &segs[prev_idx] {
Some(s) => s,
None => continue,
};
let curr = match &segs[i] {
Some(s) => s,
None => continue,
};
let pt = corner(prev, curr);
let mut v = LwVertex::from_coords(pt[0], pt[1]);
v.bulge = p.vertices[i].bulge;
new_verts.push(v);
}
} else {
// First vertex
if let Some(s) = &segs[0] {
let mut v = LwVertex::from_coords(s.p0[0], s.p0[1]);
v.bulge = p.vertices[0].bulge;
new_verts.push(v);
}
// Interior vertices
for i in 1..m {
let prev = match &segs[i - 1] {
Some(s) => s,
None => continue,
};
let curr = match &segs[i] {
Some(s) => s,
None => continue,
};
let pt = corner(prev, curr);
let mut v = LwVertex::from_coords(pt[0], pt[1]);
v.bulge = p.vertices[i].bulge;
new_verts.push(v);
}
// Last vertex
if let Some(s) = &segs[m - 1] {
new_verts.push(LwVertex::from_coords(s.p1[0], s.p1[1]));
}
}
if new_verts.len() < 2 {
return None;
}
let mut new_p = p.clone();
new_p.common.handle = Handle::NULL;
new_p.vertices = new_verts;
Some(EntityType::LwPolyline(new_p))
}
// ── Ellipse offset ─────────────────────────────────────────────────────────
//
// A true offset of an ellipse is a Lamé curve, not an ellipse. As an
// acceptable CAD approximation we scale both semi-axes uniformly and keep
// the same orientation, center and parameter range. The sign of the offset
// is determined by whether side_pt is inside or outside the ellipse.
fn offset_ellipse(e: &EllipseEnt, dist: f64, side_pt: Vec3) -> Option<EntityType> {
let a = (e.major_axis.x.powi(2) + e.major_axis.y.powi(2)).sqrt();
if a < 1e-9 {
return None;
}
let b = a * e.minor_axis_ratio;
let nx = e.major_axis.x / a;
let ny = e.major_axis.y / a;
// Project side_pt onto ellipse local frame and test inside/outside.
let rx = side_pt.x as f64 - e.center.x;
let ry = side_pt.y as f64 - e.center.y;
let xl = rx * nx + ry * ny;
let yl = -rx * ny + ry * nx;
let inside = (xl / a).powi(2) + (yl / b).powi(2) < 1.0;
let sign = if inside { -1.0 } else { 1.0 };
let new_a = a + sign * dist;
let new_b = b + sign * dist;
if new_a <= 1e-9 || new_b <= 1e-9 {
return None;
}
let mut new_e = e.clone();
new_e.common.handle = Handle::NULL;
// Scale the major_axis vector proportionally.
let scale = new_a / a;
new_e.major_axis.x *= scale;
new_e.major_axis.y *= scale;
new_e.major_axis.z *= scale;
new_e.minor_axis_ratio = new_b / new_a;
Some(EntityType::Ellipse(new_e))
}
// ── Spline offset ──────────────────────────────────────────────────────────
//
// Strategy: sample the spline into N points, offset each sample point by
// `dist` along the local perpendicular (based on the finite-difference
// tangent), then fit a new spline through the offset points.
fn offset_spline(spl: &SplineEnt, dist: f64, side_pt: Vec3) -> Option<EntityType> {
let (ts_knot, pts) = spline_sample_xy(spl, 64);
let n = pts.len();
if n < 2 {
return None;
}
// Determine offset sign from the first non-degenerate tangent.
let sign: f64 = (0..n - 1).find_map(|i| {
let dx = pts[i + 1][0] - pts[i][0];
let dy = pts[i + 1][1] - pts[i][1];
let len = (dx * dx + dy * dy).sqrt();
if len < 1e-12 {
return None;
}
let vx = side_pt.x as f64 - pts[i][0];
let vy = side_pt.y as f64 - pts[i][1];
let cross = dx * vy - dy * vx;
Some(if cross >= 0.0 { 1.0 } else { -1.0 })
})?;
// Offset each sample point along the local normal.
let offset_pts: Vec<acadrust::types::Vector3> = pts
.iter()
.enumerate()
.map(|(i, p)| {
// Tangent via central / forward / backward difference.
let (dx, dy) = if i == 0 {
let d = [pts[1][0] - pts[0][0], pts[1][1] - pts[0][1]];
(d[0], d[1])
} else if i == n - 1 {
let d = [pts[n - 1][0] - pts[n - 2][0], pts[n - 1][1] - pts[n - 2][1]];
(d[0], d[1])
} else {
(
(pts[i + 1][0] - pts[i - 1][0]) * 0.5,
(pts[i + 1][1] - pts[i - 1][1]) * 0.5,
)
};
let len = (dx * dx + dy * dy).sqrt().max(1e-12);
let nx = -dy / len; // left perpendicular
let ny = dx / len;
let z = spl.control_points.first().map(|v| v.z).unwrap_or(0.0);
acadrust::types::Vector3::new(p[0] + sign * nx * dist, p[1] + sign * ny * dist, z)
})
.collect();
let _ = ts_knot;
// Build a new spline from the offset control points (treat sample pts as fit pts → ctrl pts).
let degree = spl.degree.max(1) as usize;
let new_ctrl: Vec<acadrust::types::Vector3> = offset_pts;
let n_ctrl = new_ctrl.len();
let kv = truck_modeling::KnotVec::uniform_knot(degree, n_ctrl - 1);
let mut new_spl = spl.clone();
new_spl.common.handle = Handle::NULL;
new_spl.control_points = new_ctrl;
new_spl.knots = kv.iter().copied().collect();
new_spl.fit_points.clear();
new_spl.weights.clear();
Some(EntityType::Spline(new_spl))
}
// ── Dispatch ───────────────────────────────────────────────────────────────
fn compute_offset(entity: &EntityType, dist: f64, side_pt: Vec3) -> Option<EntityType> {
match entity {
EntityType::Line(l) => offset_line(l, dist, side_pt),
EntityType::Circle(c) => offset_circle(c, dist, side_pt),
EntityType::Arc(a) => offset_arc(a, dist, side_pt),
EntityType::LwPolyline(p) => offset_lwpolyline(p, dist, side_pt),
EntityType::Ellipse(e) => offset_ellipse(e, dist, side_pt),
EntityType::Spline(s) => offset_spline(s, dist, side_pt),
_ => None,
}
}
// ── Through-mode distance ─────────────────────────────────────────────────
//
// Nearest distance from the cursor to the entity outline, used by "through"
// mode so the offset copy passes through the cursor. Measured against the
// tessellated wire (point-to-segment), which approximates the perpendicular
// distance for every supported entity type.
fn perp_distance(entity: &EntityType, pt: Vec3) -> f64 {
let pts = entity_wire_pts(entity);
if pts.len() < 2 {
return 0.0;
}
let px = pt.x as f64;
let py = pt.y as f64;
let mut best = f64::INFINITY;
for w in pts.windows(2) {
let ax = w[0][0] as f64;
let ay = w[0][1] as f64;
let bx = w[1][0] as f64;
let by = w[1][1] as f64;
let dx = bx - ax;
let dy = by - ay;
let len2 = dx * dx + dy * dy;
let t = if len2 < 1e-12 {
0.0
} else {
(((px - ax) * dx + (py - ay) * dy) / len2).clamp(0.0, 1.0)
};
let cx = ax + t * dx;
let cy = ay + t * dy;
let d = ((px - cx).powi(2) + (py - cy).powi(2)).sqrt();
if d < best {
best = d;
}
}
best
}
// ── Wire preview points ─────────────────────────────────────────────────────
fn entity_wire_pts(e: &EntityType) -> Vec<[f32; 3]> {
match e {
EntityType::Line(l) => vec![
[l.start.x as f32, l.start.y as f32, l.start.z as f32],
[l.end.x as f32, l.end.y as f32, l.end.z as f32],
],
EntityType::Circle(c) => {
let steps = 64usize;
(0..=steps)
.map(|i| {
let a = TAU * i as f64 / steps as f64;
[
(c.center.x + c.radius * a.cos()) as f32,
(c.center.y + c.radius * a.sin()) as f32,
c.center.z as f32,
]
})
.collect()
}
EntityType::Arc(a) => {
let a0 = norm_rad(a.start_angle);
let a1 = norm_rad(a.end_angle);
let span = {
let s = a1 - a0;
if s <= 0.0 {
s + TAU
} else {
s
}
};
let steps = ((span.abs() * 20.0).ceil() as usize).max(4);
(0..=steps)
.map(|i| {
let ang = a0 + span * (i as f64 / steps as f64);
[
(a.center.x + a.radius * ang.cos()) as f32,
(a.center.y + a.radius * ang.sin()) as f32,
a.center.z as f32,
]
})
.collect()
}
EntityType::LwPolyline(p) => lwpolyline_pts(p),
EntityType::Ellipse(e) => {
let a = (e.major_axis.x.powi(2) + e.major_axis.y.powi(2)).sqrt();
if a < 1e-9 {
return vec![];
}
let b = a * e.minor_axis_ratio;
let nx = e.major_axis.x / a;
let ny = e.major_axis.y / a;
let t0 = e.start_parameter;
let mut t1 = e.end_parameter;
if t1 <= t0 {
t1 += TAU;
}
let span = t1 - t0;
let steps = ((span.abs() * 20.0).ceil() as usize).max(4);
(0..=steps)
.map(|i| {
let t = t0 + span * (i as f64 / steps as f64);
let lx = a * t.cos();
let ly = b * t.sin();
[
(e.center.x + lx * nx - ly * ny) as f32,
(e.center.y + lx * ny + ly * nx) as f32,
e.center.z as f32,
]
})
.collect()
}
EntityType::Spline(s) => spline_pts_wire(s),
_ => vec![],
}
}
/// Tessellate a LwPolyline into wire points (straight segments + arc bulges).
fn lwpolyline_pts(p: &LwPolyline) -> Vec<[f32; 3]> {
let n = p.vertices.len();
if n < 2 {
return vec![];
}
let z = p.elevation as f32;
let n_segs = if p.is_closed { n } else { n - 1 };
let mut pts: Vec<[f32; 3]> = Vec::new();
for i in 0..n_segs {
let v0 = &p.vertices[i];
let v1 = &p.vertices[(i + 1) % n];
let x0 = v0.location.x;
let y0 = v0.location.y;
let x1 = v1.location.x;
let y1 = v1.location.y;
if pts.is_empty() {
pts.push([x0 as f32, y0 as f32, z]);
}
if v0.bulge.abs() < 1e-10 {
pts.push([x1 as f32, y1 as f32, z]);
} else {
// Arc from bulge
let b = v0.bulge;
let chord_x = x1 - x0;
let chord_y = y1 - y0;
let chord_len = (chord_x * chord_x + chord_y * chord_y).sqrt();
if chord_len < 1e-12 {
pts.push([x1 as f32, y1 as f32, z]);
continue;
}
let b2 = b * b;
let r = chord_len * (1.0 + b2) / (4.0 * b.abs());
let d = r * (1.0 - b2) / (1.0 + b2);
let mx = (x0 + x1) * 0.5;
let my = (y0 + y1) * 0.5;
let perp_x = -chord_y / chord_len;
let perp_y = chord_x / chord_len;
let sign = b.signum();
let cx = mx + sign * d * perp_x;
let cy = my + sign * d * perp_y;
let a0 = norm_rad((y0 - cy).atan2(x0 - cx));
let a1 = norm_rad((y1 - cy).atan2(x1 - cx));
let span = if b > 0.0 {
let s = a1 - a0;
if s <= 0.0 {
s + TAU
} else {
s
}
} else {
let s = a0 - a1;
if s <= 0.0 {
s + TAU
} else {
s
}
};
let steps = ((span.abs() * 20.0).ceil() as usize).max(4);
for j in 1..=steps {
let t = j as f64 / steps as f64;
let ang = if b > 0.0 {
a0 + span * t
} else {
a0 - span * t
};
pts.push([(cx + r * ang.cos()) as f32, (cy + r * ang.sin()) as f32, z]);
}
}
}
if p.is_closed {
if let Some(&first) = pts.first() {
pts.push(first);
}
}
pts
}
// ── Command implementation ─────────────────────────────────────────────────
enum Step {
SelectObject,
// `locked == None` is "through" mode: the offset magnitude follows the
// cursor (perpendicular distance to the picked object). Typing a value
// locks the magnitude; the cursor then only chooses the side.
PickSide {
#[allow(dead_code)]
handle: Handle,
entity: EntityType,
locked: Option<f64>,
},
}
pub struct OffsetCommand {
step: Step,
all_entities: Vec<EntityType>,
}
impl OffsetCommand {
pub fn new(all_entities: Vec<EntityType>) -> Self {
Self {
step: Step::SelectObject,
all_entities,
}
}
}
impl CadCommand for OffsetCommand {
fn name(&self) -> &'static str {
"OFFSET"
}
fn prompt(&self) -> String {
match &self.step {
Step::SelectObject => "OFFSET Select object to offset:".into(),
Step::PickSide {
locked: Some(d), ..
} => format!("OFFSET Click side [distance {d:.4}, type to change]:"),
Step::PickSide { locked: None, .. } => format!(
"OFFSET Click through point, or type a distance (last {:.4}):",
defaults::get_offset_dist()
),
}
}
fn needs_entity_pick(&self) -> bool {
matches!(self.step, Step::SelectObject)
}
fn on_entity_pick(&mut self, handle: Handle, _pt: Vec3) -> CmdResult {
if handle.is_null() || !matches!(self.step, Step::SelectObject) {
return CmdResult::NeedPoint;
}
let entity = self
.all_entities
.iter()
.find(|e| e.common().handle == handle)
.cloned();
match entity {
Some(e @ EntityType::Line(_))
| Some(e @ EntityType::Circle(_))
| Some(e @ EntityType::Arc(_))
| Some(e @ EntityType::LwPolyline(_)) => {
self.step = Step::PickSide {
handle,
entity: e,
locked: None,
};
CmdResult::NeedPoint
}
_ => CmdResult::NeedPoint,
}
}
// The side step accepts an optional typed magnitude — through mode by
// default, a fixed distance once the user types one.
fn wants_text_input(&self) -> bool {
matches!(self.step, Step::PickSide { .. })
}
fn dyn_field(&self) -> crate::command::DynField {
match self.step {
Step::PickSide { .. } => crate::command::DynField::Scalar,
_ => crate::command::DynField::Point,
}
}
fn dyn_live_value(&self, cursor: Vec3) -> Option<f64> {
match &self.step {
Step::PickSide { entity, locked, .. } => {
Some(locked.unwrap_or_else(|| perp_distance(entity, cursor)))
}
_ => None,
}
}
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
if let Step::PickSide { locked, .. } = &mut self.step {
let t = text.trim().replace(',', ".");
if !t.is_empty() {
if let Ok(d) = t.parse::<f64>() {
let d = d.abs().max(1e-9);
defaults::set_offset_dist(d as f32);
*locked = Some(d);
}
}
// Stay on the side step — the click chooses which side.
return Some(CmdResult::NeedPoint);
}
None
}
fn on_hover_entity(&mut self, handle: Handle, _pt: Vec3) -> Vec<WireModel> {
if handle.is_null() {
return vec![];
}
if let Some(entity) = self
.all_entities
.iter()
.find(|e| e.common().handle == handle)
{
let pts = entity_wire_pts(entity);
if !pts.is_empty() {
return vec![WireModel::solid(
"offset_hover".into(),
pts,
WireModel::CYAN,
false,
)];
}
}
vec![]
}
fn on_point(&mut self, pt: Vec3) -> CmdResult {
let (locked, entity) = match &self.step {
Step::PickSide { locked, entity, .. } => (*locked, entity.clone()),
_ => return CmdResult::NeedPoint,
};
let mag = locked.unwrap_or_else(|| perp_distance(&entity, pt));
if mag < 1e-9 {
return CmdResult::NeedPoint;
}
match compute_offset(&entity, mag, pt) {
Some(new_entity) => CmdResult::CommitAndExit(new_entity),
None => CmdResult::NeedPoint,
}
}
fn on_preview_wires(&mut self, pt: Vec3) -> Vec<WireModel> {
let (locked, entity) = match &self.step {
Step::PickSide { locked, entity, .. } => (*locked, entity.clone()),
_ => return vec![],
};
let mag = locked.unwrap_or_else(|| perp_distance(&entity, pt));
if mag < 1e-9 {
return vec![];
}
if let Some(result) = compute_offset(&entity, mag, pt) {
let pts = entity_wire_pts(&result);
if !pts.is_empty() {
return vec![WireModel::solid(
"offset_preview".into(),
pts,
WireModel::CYAN,
false,
)];
}
}
vec![]
}
fn on_enter(&mut self) -> CmdResult {
CmdResult::Cancel
}
fn on_escape(&mut self) -> CmdResult {
CmdResult::Cancel
}
}
// ── Autocomplete registry ─────────────────────────────────
inventory::submit!(crate::command::CommandRegistration { names: &["O", "OFFSET"] }); // OffsetCommand
+144
View File
@@ -0,0 +1,144 @@
// PEDIT command — edit a polyline entity.
//
// Supports LwPolyline and (partially) Polyline2D.
// Subcommands (text input after entity pick):
// C / CLOSE — toggle closed flag
// O / OPEN — clear closed flag
// W <width> — set uniform width (LwPolyline)
// E — enter vertex editing mode (not implemented, use grips)
// J — join (same as JOIN command, triggered as alias)
// X / EXIT — exit
use acadrust::{EntityType, Handle};
use glam::Vec3;
use crate::command::{CadCommand, CmdResult};
pub struct PeditCommand {
target: Option<Handle>,
}
impl PeditCommand {
pub fn new() -> Self {
Self { target: None }
}
}
impl CadCommand for PeditCommand {
fn name(&self) -> &'static str {
"PEDIT"
}
fn prompt(&self) -> String {
if self.target.is_none() {
"PEDIT Select polyline:".into()
} else {
"PEDIT Enter option [C=Close O=Open W=Width X=Exit]:".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);
CmdResult::NeedPoint
}
fn wants_text_input(&self) -> bool {
self.target.is_some()
}
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
let handle = self.target?;
let up = text.trim().to_uppercase();
match up.as_str() {
"X" | "EXIT" => return Some(CmdResult::Cancel),
"C" | "CLOSE" => {
return Some(CmdResult::PeditOp {
handle,
op: PeditOp::SetClosed(true),
})
}
"O" | "OPEN" => {
return Some(CmdResult::PeditOp {
handle,
op: PeditOp::SetClosed(false),
})
}
_ => {}
}
if let Some(rest) = up.strip_prefix("W ").or_else(|| up.strip_prefix("W")) {
let w: f64 = rest
.trim()
.replace(',', ".")
.parse()
.ok()
.filter(|&v: &f64| v >= 0.0)?;
return Some(CmdResult::PeditOp {
handle,
op: PeditOp::SetWidth(w),
});
}
None
}
fn on_point(&mut self, _pt: Vec3) -> CmdResult {
CmdResult::NeedPoint
}
fn on_enter(&mut self) -> CmdResult {
CmdResult::Cancel
}
}
// ── Op enum (used in CmdResult) ────────────────────────────────────────────
#[derive(Clone)]
pub enum PeditOp {
SetClosed(bool),
SetWidth(f64),
}
// ── Apply logic ────────────────────────────────────────────────────────────
pub fn apply_pedit(entity: &mut EntityType, op: &PeditOp) -> bool {
match op {
PeditOp::SetClosed(closed) => match entity {
EntityType::LwPolyline(p) => {
p.is_closed = *closed;
true
}
EntityType::Polyline2D(p) => {
if *closed {
p.close();
} else {
p.flags.set_closed(false);
}
true
}
_ => false,
},
PeditOp::SetWidth(w) => match entity {
EntityType::LwPolyline(p) => {
p.constant_width = *w;
for v in &mut p.vertices {
v.start_width = *w;
v.end_width = *w;
}
true
}
_ => false,
},
}
}
// ── Autocomplete registry ─────────────────────────────────
inventory::submit!(crate::command::CommandRegistration { names: &["PE", "PEDIT"] }); // PeditCommand
+213
View File
@@ -0,0 +1,213 @@
// REFEDIT — in-place block reference editing.
//
// Workflow:
// 1. REFEDIT: user picks an INSERT entity.
// 2. The block's entities are copied into model space with the INSERT
// transform applied (translate + rotate + uniform scale).
// 3. The user edits them with normal commands (MOVE, COPY, DELETE, …).
// 4. REFCLOSE SAVE: temp entities are inverse-transformed back into the
// block definition; all INSERT references auto-update.
// REFCLOSE DISCARD: temp entities are removed, block unchanged.
//
// Limitation: non-uniform scale inserts (x_scale ≠ y_scale) are rejected
// with an error message — full matrix inversion for those cases would
// require per-entity matrix transforms not yet in EntityTransform.
use acadrust::{EntityType, Handle};
use glam::Vec3;
use crate::command::{CadCommand, CmdResult};
// ── Session state (held in DocumentTab) ───────────────────────────────────
/// Active REFEDIT session. Lives in `DocumentTab::refedit_session`.
#[derive(Debug, Clone)]
pub struct RefEditSession {
/// Name of the block being edited.
pub block_name: String,
/// Handle of the block record (owns the block entities).
pub br_handle: Handle,
/// Handles of the temporary model-space entities added for editing.
pub temp_handles: Vec<Handle>,
// ── INSERT transform (needed for inverse on SAVE) ──────────────────
pub insert_x: f64,
pub insert_y: f64,
pub insert_z: f64,
/// Rotation in degrees (stored as degrees in acadrust).
pub rotation_deg: f64,
/// Uniform scale factor (same for X/Y/Z after validation).
pub scale: f64,
}
// ── REFEDIT pick command ───────────────────────────────────────────────────
/// Step 1: wait for the user to pick a single INSERT entity.
pub struct RefEditPickCommand;
impl RefEditPickCommand {
pub fn new() -> Self {
Self
}
}
impl CadCommand for RefEditPickCommand {
fn name(&self) -> &'static str {
"REFEDIT"
}
fn prompt(&self) -> String {
"REFEDIT Select block reference to edit:".into()
}
fn needs_entity_pick(&self) -> bool {
true
}
fn on_entity_pick(&mut self, handle: Handle, _pt: Vec3) -> CmdResult {
if handle.is_null() {
return CmdResult::NeedPoint;
}
// Signal the host to enter the editing session for this handle.
// We reuse Relaunch("REFEDIT_BEGIN:<handle>") as a convention.
CmdResult::Relaunch(format!("REFEDIT_BEGIN:{}", handle.value()), vec![handle])
}
fn on_point(&mut self, _pt: Vec3) -> CmdResult {
CmdResult::NeedPoint
}
fn on_enter(&mut self) -> CmdResult {
CmdResult::Cancel
}
}
// ── REFCLOSE command ───────────────────────────────────────────────────────
/// Step 4: prompt for SAVE or DISCARD.
pub struct RefCloseCommand;
impl RefCloseCommand {
pub fn new() -> Self {
Self
}
}
impl CadCommand for RefCloseCommand {
fn name(&self) -> &'static str {
"REFCLOSE"
}
fn prompt(&self) -> String {
"REFCLOSE [Save/Discard] <Save>:".into()
}
fn wants_text_input(&self) -> bool {
true
}
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
let t = text.trim().to_uppercase();
let save = t.is_empty() || t == "S" || t == "SAVE";
let discard = t == "D" || t == "DISCARD";
if save {
Some(CmdResult::Relaunch("REFCLOSE_SAVE".into(), vec![]))
} else if discard {
Some(CmdResult::Relaunch("REFCLOSE_DISCARD".into(), vec![]))
} else {
Some(CmdResult::NeedPoint) // re-prompt
}
}
fn on_enter(&mut self) -> CmdResult {
// Default: SAVE
CmdResult::Relaunch("REFCLOSE_SAVE".into(), vec![])
}
fn on_point(&mut self, _pt: Vec3) -> CmdResult {
CmdResult::NeedPoint
}
}
// ── Geometry helpers ───────────────────────────────────────────────────────
/// Apply the INSERT's forward transform to a block-local entity so it
/// appears at its correct world-space position.
/// Order: scale → rotate (around origin) → translate.
pub fn apply_insert_transform(entity: &mut EntityType, session: &RefEditSession) {
use crate::command::EntityTransform;
use crate::scene::view::dispatch;
let origin = Vec3::ZERO;
// 1. Uniform scale (if not 1.0)
if (session.scale - 1.0).abs() > 1e-10 {
dispatch::apply_transform(
entity,
&EntityTransform::Scale {
center: origin,
factor: session.scale as f32,
},
);
}
// 2. Rotate around origin
if session.rotation_deg.abs() > 1e-10 {
dispatch::apply_transform(
entity,
&EntityTransform::Rotate {
center: origin,
angle_rad: session.rotation_deg.to_radians() as f32,
},
);
}
// 3. Translate to insert position
dispatch::apply_transform(
entity,
&EntityTransform::Translate(Vec3::new(
session.insert_x as f32,
session.insert_y as f32,
session.insert_z as f32,
)),
);
}
/// Apply the INSERT's inverse transform to a world-space entity to bring it
/// back to block-local coordinates.
/// Order: un-translate → un-rotate → un-scale.
pub fn apply_insert_inverse_transform(entity: &mut EntityType, session: &RefEditSession) {
use crate::command::EntityTransform;
use crate::scene::view::dispatch;
let origin = Vec3::ZERO;
// 1. Un-translate
dispatch::apply_transform(
entity,
&EntityTransform::Translate(Vec3::new(
-session.insert_x as f32,
-session.insert_y as f32,
-session.insert_z as f32,
)),
);
// 2. Un-rotate
if session.rotation_deg.abs() > 1e-10 {
dispatch::apply_transform(
entity,
&EntityTransform::Rotate {
center: origin,
angle_rad: -session.rotation_deg.to_radians() as f32,
},
);
}
// 3. Un-scale
if (session.scale - 1.0).abs() > 1e-10 && session.scale.abs() > 1e-12 {
dispatch::apply_transform(
entity,
&EntityTransform::Scale {
center: origin,
factor: (1.0 / session.scale) as f32,
},
);
}
}
// ── Autocomplete registry ─────────────────────────────────
inventory::submit!(crate::command::CommandRegistration { names: &["REFCLOSE"] }); // RefCloseCommand
inventory::submit!(crate::command::CommandRegistration { names: &["REFEDIT"] }); // RefEditPickCommand
+208
View File
@@ -0,0 +1,208 @@
// Rotate tool — ribbon definition + interactive command.
//
// Command: ROTATE (RO)
// Requires at least one entity selected before starting.
// Step 1: pick rotation center
// Step 2: pick reference point (defines the 0° direction)
// Step 3: pick destination point → rotates by (dest_angle - ref_angle)
use acadrust::Handle;
use glam::Vec3;
use crate::command::{CadCommand, CmdResult, DynField, EntityTransform};
use crate::modules::draw::defaults;
use crate::modules::{IconKind, ModuleEvent, ToolDef};
use crate::scene::model::wire_model::WireModel;
// ── Ribbon definition ──────────────────────────────────────────────────────
pub fn tool() -> ToolDef {
ToolDef {
id: "ROTATE",
label: "Rotate",
icon: IconKind::Svg(include_bytes!("../../../../assets/icons/rotate.svg")),
event: ModuleEvent::Command("ROTATE".to_string()),
}
}
// ── Command implementation ─────────────────────────────────────────────────
enum Step {
Center,
RefPoint { center: Vec3 },
Angle { center: Vec3, ref_angle: f32 },
}
pub struct RotateCommand {
handles: Vec<Handle>,
wire_models: Vec<WireModel>,
step: Step,
default_angle: f32, // degrees
}
impl RotateCommand {
pub fn new(handles: Vec<Handle>, wire_models: Vec<WireModel>) -> Self {
Self {
handles,
wire_models,
step: Step::Center,
default_angle: defaults::get_rotate_angle(),
}
}
}
impl CadCommand for RotateCommand {
fn name(&self) -> &'static str {
"ROTATE"
}
fn prompt(&self) -> String {
match &self.step {
Step::Center => format!(
"ROTATE Specify rotation center [{} objects]:",
self.handles.len()
),
Step::RefPoint { .. } => {
"ROTATE Specify reference point (or skip: type angle directly):".into()
}
Step::Angle { ref_angle, .. } => format!(
"ROTATE Specify destination or type angle in degrees <{:.4}> [ref={:.1}°]:",
self.default_angle,
ref_angle.to_degrees()
),
}
}
fn on_point(&mut self, pt: Vec3) -> CmdResult {
match &self.step {
Step::Center => {
self.step = Step::RefPoint { center: pt };
CmdResult::NeedPoint
}
Step::RefPoint { center } => {
let center = *center;
let ref_angle = (pt.y - center.y).atan2(pt.x - center.x);
self.step = Step::Angle { center, ref_angle };
CmdResult::NeedPoint
}
Step::Angle { center, ref_angle } => {
let center = *center;
let ref_angle = *ref_angle;
let dest_angle = (pt.y - center.y).atan2(pt.x - center.x);
let delta = dest_angle - ref_angle;
defaults::set_rotate_angle(delta.to_degrees());
self.default_angle = delta.to_degrees();
CmdResult::TransformSelected(
self.handles.clone(),
EntityTransform::Rotate {
center,
angle_rad: delta,
},
)
}
}
}
fn on_enter(&mut self) -> CmdResult {
// At Angle step: Enter uses the stored default angle.
if let Step::Angle { center, .. } = &self.step {
let center = *center;
let angle_rad = self.default_angle.to_radians();
return CmdResult::TransformSelected(
self.handles.clone(),
EntityTransform::Rotate { center, angle_rad },
);
}
CmdResult::Cancel
}
fn on_escape(&mut self) -> CmdResult {
CmdResult::Cancel
}
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
if let Step::Angle { center, .. } = &self.step {
// The value already carries the correct sign (the dynamic-input
// layer applies the cursor's side for a bare magnitude).
let deg: f32 = text.trim().replace(',', ".").parse().ok()?;
let center = *center;
defaults::set_rotate_angle(deg);
return Some(CmdResult::TransformSelected(
self.handles.clone(),
EntityTransform::Rotate {
center,
angle_rad: deg.to_radians(),
},
));
}
None
}
fn on_preview_wires(&mut self, pt: Vec3) -> Vec<WireModel> {
let (center, ref_angle) = match &self.step {
Step::Angle { center, ref_angle } => (*center, *ref_angle),
Step::RefPoint { center } => {
// Show a reference line from center to cursor only.
return vec![WireModel::solid(
"rubber_band".into(),
vec![[center.x, center.y, center.z], [pt.x, pt.y, pt.z]],
WireModel::CYAN,
false,
)];
}
_ => return vec![],
};
let dest_angle = (pt.y - center.y).atan2(pt.x - center.x);
let angle_rad = dest_angle - ref_angle;
// Track the live SIGNED rotation (relative to the reference) so that
// committing with Enter rotates the way the cursor is dragging — the
// dynamic-input box shows the unsigned magnitude, but the committed
// value must keep its direction (clockwise = negative).
self.default_angle = angle_rad.to_degrees();
// Object ghosts rotated to the new angle. The rotation sweep arc is
// drawn by the dynamic-input overlay (polar guide), not here.
self.wire_models
.iter()
.map(|w| w.rotated(center, angle_rad))
.collect()
}
fn dyn_field(&self) -> DynField {
match self.step {
Step::Angle { .. } => DynField::Angle,
_ => DynField::Point,
}
}
fn dyn_spec(&self) -> Option<crate::command::DynSpec> {
use crate::command::{DynAnchor, DynFieldSpec, DynGuide, DynRole, DynSpec};
// Rotation angle is measured about the CENTRE, swept from the reference
// direction. The polar guide arc is anchored at the centre and starts
// at the reference (via ref_point), with the value box centred on it.
if let Step::Angle { center, ref_angle } = self.step {
let ref_dir = Vec3::new(center.x + ref_angle.cos(), center.y + ref_angle.sin(), center.z);
Some(DynSpec {
anchor: DynAnchor::Point(center),
fields: vec![DynFieldSpec::new(DynRole::Angle)],
guide: DynGuide::Polar,
ref_point: Some(ref_dir),
})
} else {
None
}
}
fn dyn_commit_as_text(&self) -> bool {
matches!(self.step, Step::Angle { .. })
}
fn dyn_live_value(&self, cursor: Vec3) -> Option<f64> {
// The rotation amount = cursor direction from the centre minus the
// reference angle, so the box reads the actual rotation.
if let Step::Angle { center, ref_angle } = &self.step {
let dest = (cursor.y - center.y).atan2(cursor.x - center.x);
Some(crate::command::dyn_display_angle_deg(dest - ref_angle) as f64)
} else {
None
}
}
}
+199
View File
@@ -0,0 +1,199 @@
// Scale tool — ribbon definition + interactive command.
//
// Command: SCALE (SC)
// Requires at least one entity selected.
// Step 1: pick base (scale center)
// Step 2: specify scale factor — drag for a live preview (factor = cursor
// distance from base) or type a factor. A live ghost of the
// selection tracks the cursor from the first move onward.
//
// Reference scaling: type `R` at step 2 to define the factor as
// new-length / reference-length:
// Step 2a: specify reference length (pick a point or type a length)
// Step 2b: specify new length (pick a point or type a length)
use acadrust::Handle;
use glam::Vec3;
use crate::command::{CadCommand, CmdResult, EntityTransform};
use crate::modules::draw::defaults;
use crate::modules::{IconKind, ModuleEvent, ToolDef};
use crate::scene::model::wire_model::WireModel;
#[allow(dead_code)]
pub fn tool() -> ToolDef {
ToolDef {
id: "SCALE",
label: "Scale",
icon: IconKind::Svg(include_bytes!("../../../../assets/icons/scale.svg")),
event: ModuleEvent::Command("SCALE".to_string()),
}
}
enum Step {
Base,
/// Default flow: factor is the cursor distance from `base`.
Factor { base: Vec3 },
/// Reference flow: defining the reference length from `base`.
RefLen { base: Vec3 },
/// Reference flow: factor is `cursor_dist / ref_dist` from `base`.
RefNew { base: Vec3, ref_dist: f32 },
}
pub struct ScaleCommand {
handles: Vec<Handle>,
wire_models: Vec<WireModel>,
step: Step,
default_factor: f32,
}
impl ScaleCommand {
pub fn new(handles: Vec<Handle>, wire_models: Vec<WireModel>) -> Self {
Self {
handles,
wire_models,
step: Step::Base,
default_factor: defaults::get_scale_factor(),
}
}
/// Commit a uniform scale about `base` and end the command.
fn commit(&self, base: Vec3, factor: f32) -> CmdResult {
defaults::set_scale_factor(factor);
CmdResult::TransformSelected(
self.handles.clone(),
EntityTransform::Scale {
center: base,
factor,
},
)
}
}
impl CadCommand for ScaleCommand {
fn name(&self) -> &'static str {
"SCALE"
}
fn prompt(&self) -> String {
match &self.step {
Step::Base => format!(
"SCALE Specify base point [{} objects]:",
self.handles.len()
),
Step::Factor { .. } => format!(
"SCALE Specify scale factor or [Reference] <{:.4}>:",
self.default_factor
),
Step::RefLen { .. } => {
"SCALE Specify reference length (pick a point or type a length):".into()
}
Step::RefNew { ref_dist, .. } => format!(
"SCALE Specify new length (pick a point or type a length) [ref={:.3}]:",
ref_dist
),
}
}
fn on_point(&mut self, pt: Vec3) -> CmdResult {
match &self.step {
Step::Base => {
self.step = Step::Factor { base: pt };
CmdResult::NeedPoint
}
Step::Factor { base } => {
let base = *base;
let factor = base.distance(pt).max(1e-6);
self.commit(base, factor)
}
Step::RefLen { base } => {
let base = *base;
let ref_dist = base.distance(pt).max(1e-6);
self.step = Step::RefNew { base, ref_dist };
CmdResult::NeedPoint
}
Step::RefNew { base, ref_dist } => {
let base = *base;
let new_dist = base.distance(pt).max(1e-6);
self.commit(base, new_dist / *ref_dist)
}
}
}
fn on_enter(&mut self) -> CmdResult {
// Enter at the factor step accepts the stored default factor.
if let Step::Factor { base } = &self.step {
let base = *base;
return self.commit(base, self.default_factor);
}
CmdResult::Cancel
}
fn on_escape(&mut self) -> CmdResult {
CmdResult::Cancel
}
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
let t = text.trim();
match &self.step {
Step::Factor { base } => {
let base = *base;
// `R` / `Reference` switches to reference scaling.
let low = t.to_ascii_lowercase();
if low == "r" || low == "reference" {
self.step = Step::RefLen { base };
return Some(CmdResult::NeedPoint);
}
let factor: f32 = t.replace(',', ".").parse().ok()?;
(factor > 0.0).then(|| self.commit(base, factor))
}
Step::RefLen { base } => {
let base = *base;
let ref_dist: f32 = t.replace(',', ".").parse().ok()?;
if ref_dist > 0.0 {
self.step = Step::RefNew { base, ref_dist };
return Some(CmdResult::NeedPoint);
}
None
}
Step::RefNew { base, ref_dist } => {
let (base, ref_dist) = (*base, *ref_dist);
let new_len: f32 = t.replace(',', ".").parse().ok()?;
(new_len > 0.0).then(|| self.commit(base, new_len / ref_dist))
}
Step::Base => None,
}
}
fn on_preview_wires(&mut self, pt: Vec3) -> Vec<WireModel> {
let (base, factor) = match &self.step {
// Default flow: scale live by cursor distance from the base.
Step::Factor { base } => (*base, base.distance(pt).max(1e-6)),
// Reference flow, new-length step: factor = cursor_dist / ref_dist.
Step::RefNew { base, ref_dist } => {
(*base, base.distance(pt).max(1e-6) / ref_dist)
}
// Reference-length step: rubber-band only, no factor defined yet.
Step::RefLen { base } => {
return vec![WireModel::solid(
"rubber_band".into(),
vec![[base.x, base.y, base.z], [pt.x, pt.y, pt.z]],
WireModel::CYAN,
false,
)];
}
Step::Base => return vec![],
};
let mut out: Vec<WireModel> = self
.wire_models
.iter()
.map(|w| w.scaled(base, factor))
.collect();
out.push(WireModel::solid(
"rubber_band".into(),
vec![[base.x, base.y, base.z], [pt.x, pt.y, pt.z]],
WireModel::CYAN,
false,
));
out
}
}
+149
View File
@@ -0,0 +1,149 @@
// Shared B-spline utilities for modify commands (TRIM, BREAK, OFFSET, LENGTHEN).
//
// Provides:
// - spline_to_bspline : acadrust Spline → truck BSplineCurve<Point3>
// - bspline_to_spline : truck BSplineCurve<Point3> → acadrust Spline
// - spline_sample_xy : sample the DXF-XY projection at N+1 points (with t params)
// - spline_nearest_t : find the spline parameter t closest to a DXF-XY click
// - spline_pts_wire : world-space wire points for WireModel preview
use acadrust::entities::Spline;
use acadrust::types::Vector3;
use acadrust::Handle;
use truck_modeling::base::{BoundedCurve, ParametricCurve};
use truck_modeling::{BSplineCurve, KnotVec, Point3};
// ── Conversion ─────────────────────────────────────────────────────────────
/// Convert an acadrust `Spline` to a truck `BSplineCurve<Point3>`.
/// Returns `None` if the spline has too few control points or inconsistent data.
pub fn spline_to_bspline(spl: &Spline) -> Option<BSplineCurve<Point3>> {
let ctrl_pts: Vec<Point3> = spl
.control_points
.iter()
.map(|p| Point3::new(p.x, p.y, p.z))
.collect();
if ctrl_pts.len() < 2 {
return None;
}
let degree = spl.degree as usize;
let expected_knots = ctrl_pts.len() + degree + 1;
let knot_vec = if spl.knots.len() == expected_knots {
KnotVec::from(spl.knots.clone())
} else {
KnotVec::uniform_knot(degree, ctrl_pts.len() - 1)
};
Some(BSplineCurve::new(knot_vec, ctrl_pts))
}
/// Rebuild an acadrust `Spline` from a trimmed `BSplineCurve<Point3>`.
/// The `template` spline provides the entity common data, degree, and Z-elevation.
pub fn bspline_to_spline(bs: &BSplineCurve<Point3>, template: &Spline) -> Spline {
let z = template.control_points.first().map(|v| v.z).unwrap_or(0.0);
let mut spl = template.clone();
spl.common.handle = Handle::NULL;
spl.degree = bs.degree() as i32;
spl.knots = bs.knot_vec().iter().copied().collect();
spl.control_points = bs
.control_points()
.iter()
.map(|p| Vector3::new(p.x, p.y, z))
.collect();
spl.weights.clear(); // not rational after split
spl.fit_points.clear(); // fit points are no longer valid
spl
}
// ── Sampling ───────────────────────────────────────────────────────────────
/// Sample the spline's DXF-XY projection at `n+1` evenly-spaced parameter
/// values. Returns `(t_params, xy_points)` both of length `n+1`.
pub fn spline_sample_xy(spl: &Spline, n: usize) -> (Vec<f64>, Vec<[f64; 2]>) {
let bs = match spline_to_bspline(spl) {
Some(b) => b,
None => return (vec![], vec![]),
};
let (t0, t1) = bs.range_tuple();
let ts: Vec<f64> = (0..=n)
.map(|i| t0 + (t1 - t0) * (i as f64 / n as f64))
.collect();
let pts: Vec<[f64; 2]> = ts
.iter()
.map(|&t| {
let p = bs.subs(t);
[p.x, p.y]
})
.collect();
(ts, pts)
}
/// Find the spline parameter `t` (in the knot-vector range [t0, t1]) that is
/// closest to the DXF-XY point `(x, y)`.
/// Uses 64-sample coarse search followed by bisection.
pub fn spline_nearest_t(spl: &Spline, x: f64, y: f64) -> Option<f64> {
let bs = spline_to_bspline(spl)?;
let (t0, t1) = bs.range_tuple();
let n = 64usize;
let step = (t1 - t0) / n as f64;
// Coarse search
let mut best_t = t0;
let mut best_d = f64::MAX;
for i in 0..=n {
let t = t0 + i as f64 * step;
let p = bs.subs(t);
let d = (p.x - x).powi(2) + (p.y - y).powi(2);
if d < best_d {
best_d = d;
best_t = t;
}
}
// Bisection refinement in [best_t - step, best_t + step]
let lo = (best_t - step).max(t0);
let hi = (best_t + step).min(t1);
let mut a = lo;
let mut b = hi;
for _ in 0..32 {
let m1 = a + (b - a) / 3.0;
let m2 = b - (b - a) / 3.0;
let p1 = bs.subs(m1);
let p2 = bs.subs(m2);
let d1 = (p1.x - x).powi(2) + (p1.y - y).powi(2);
let d2 = (p2.x - x).powi(2) + (p2.y - y).powi(2);
if d1 < d2 {
b = m2;
} else {
a = m1;
}
}
Some((a + b) * 0.5)
}
/// Returns the normalised parameter in [0, 1] given an actual B-spline
/// parameter `t_actual` and the range `[t0, t1]`.
pub fn t_to_rel(t_actual: f64, t0: f64, t1: f64) -> f64 {
if (t1 - t0).abs() < 1e-12 {
return 0.0;
}
((t_actual - t0) / (t1 - t0)).clamp(0.0, 1.0)
}
// ── Wire preview ───────────────────────────────────────────────────────────
/// World-space wire points for a Spline (samples 64 segments).
/// Drawing plane is world XY: DXF (x, y) → world (x, y), z = elevation.
pub fn spline_pts_wire(spl: &Spline) -> Vec<[f32; 3]> {
let (_, pts) = spline_sample_xy(spl, 64);
if pts.is_empty() {
return vec![];
}
let elev = spl
.control_points
.first()
.map(|v| v.z as f32)
.unwrap_or(0.0);
pts.iter()
.map(|p| [p[0] as f32, p[1] as f32, elev])
.collect()
}
+206
View File
@@ -0,0 +1,206 @@
// SPLINEDIT command — interactive spline editing.
//
// Phase 1: select a spline (entity pick)
// Phase 2: choose a sub-command:
// CLOSE — set the spline as closed (wrap last control point to first)
// OPEN — remove the closure
// REVERSE— reverse the control point order
// EXIT — done (Enter / Escape)
//
// Control-point dragging is already supported via the grip editing system.
use acadrust::EntityType;
use glam::Vec3;
use crate::command::{CadCommand, CmdResult};
use crate::modules::{IconKind, ModuleEvent, ToolDef};
use crate::scene::model::wire_model::WireModel;
#[allow(dead_code)]
pub fn tool() -> ToolDef {
ToolDef {
id: "SPLINEDIT",
label: "Spline Edit",
icon: IconKind::Svg(include_bytes!("../../../../assets/icons/spline.svg")),
event: ModuleEvent::Command("SPLINEDIT".to_string()),
}
}
enum Step {
/// Waiting for the user to pick a spline entity.
SelectSpline,
/// Spline selected; waiting for sub-command text input.
SubCommand { handle: acadrust::Handle },
}
pub struct SplineditCommand {
step: Step,
}
impl SplineditCommand {
pub fn new() -> Self {
Self {
step: Step::SelectSpline,
}
}
}
impl CadCommand for SplineditCommand {
fn name(&self) -> &'static str {
"SPLINEDIT"
}
fn prompt(&self) -> String {
match &self.step {
Step::SelectSpline => "SPLINEDIT Select spline:".to_string(),
Step::SubCommand { .. } => "SPLINEDIT [CLOSE/OPEN/REVERSE/EXIT]:".to_string(),
}
}
fn needs_entity_pick(&self) -> bool {
matches!(self.step, Step::SelectSpline)
}
fn on_entity_pick(&mut self, handle: acadrust::Handle, _pt: Vec3) -> CmdResult {
if handle.is_null() {
return CmdResult::NeedPoint;
}
self.step = Step::SubCommand { handle };
CmdResult::NeedPoint
}
fn wants_text_input(&self) -> bool {
matches!(self.step, Step::SubCommand { .. })
}
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
let Step::SubCommand { handle } = &self.step else {
return None;
};
let handle = *handle;
match text.trim().to_uppercase().as_str() {
"CLOSE" | "C" => Some(CmdResult::ReplaceEntity(
handle,
vec![SplineOp::Close.into_marker(handle)],
)),
"OPEN" | "O" => Some(CmdResult::ReplaceEntity(
handle,
vec![SplineOp::Open.into_marker(handle)],
)),
"REVERSE" | "R" | "REV" => Some(CmdResult::ReplaceEntity(
handle,
vec![SplineOp::Reverse.into_marker(handle)],
)),
"EXIT" | "X" | "" => Some(CmdResult::Cancel),
_ => None,
}
}
fn on_point(&mut self, _pt: Vec3) -> CmdResult {
CmdResult::NeedPoint
}
fn on_enter(&mut self) -> CmdResult {
CmdResult::Cancel
}
fn on_preview_wires(&mut self, _pt: Vec3) -> Vec<WireModel> {
vec![]
}
}
// ── Spline operation helpers ───────────────────────────────────────────────
//
// Because `CmdResult::ReplaceEntity` takes `Vec<EntityType>` and we need the
// host to apply it to the actual document, we use a small "deferred op" trick:
// the host applies the spline transform in `apply_cmd_result` by detecting
// that we replaced an entity with zero new entities (delete) or one new one.
//
// Here we actually build the modified entity without accessing the document —
// we only have the handle. The real transformation (close/open/reverse) is
// applied in the Scene via `apply_spline_op`.
enum SplineOp {
Close,
Open,
Reverse,
}
impl SplineOp {
/// Return a placeholder that encodes the op in a comment field.
/// The host `cmd_result.rs` detects SPLINEDIT replaces and calls
/// `Scene::apply_spline_op`.
fn into_marker(self, _handle: acadrust::Handle) -> EntityType {
// We can't build the modified spline here without the document.
// Return a sentinel — the actual transformation is handled by the
// apply_spline_op path in Scene. We use an XLine as a sentinel
// that the host will never actually commit (it detects the SPLINEDIT
// replace path and calls apply_spline_op instead).
//
// In practice: use ReplaceMany with empty new entities + a SplineOp
// side-channel via the xattach_path mechanism isn't clean.
//
// Simpler: encode op in the sentinel entity's layer field.
let mut sentinel = acadrust::entities::XLine::new(
acadrust::types::Vector3::zero(),
acadrust::types::Vector3::new(1.0, 0.0, 0.0),
);
sentinel.common.layer = match self {
SplineOp::Close => "__SPLINEDIT_CLOSE__".to_string(),
SplineOp::Open => "__SPLINEDIT_OPEN__".to_string(),
SplineOp::Reverse => "__SPLINEDIT_REVERSE__".to_string(),
};
EntityType::XLine(sentinel)
}
}
/// Apply a spline operation (CLOSE/OPEN/REVERSE) to a spline entity.
/// Called from `cmd_result.rs` when the ReplaceEntity sentinel is detected.
pub fn apply_spline_op(doc: &mut acadrust::CadDocument, handle: acadrust::Handle, op: &str) {
let Some(EntityType::Spline(spline)) = doc.get_entity_mut(handle) else {
return;
};
match op {
"__SPLINEDIT_CLOSE__" => {
if spline.control_points.len() >= 2 {
let first = spline.control_points[0];
spline.control_points.push(first);
// Regenerate clamped knots.
spline.knots = acadrust::entities::Spline::generate_clamped_knots(
spline.degree as usize,
spline.control_points.len(),
);
spline.fit_points.clear();
}
}
"__SPLINEDIT_OPEN__" => {
if spline.control_points.len() >= 2 {
let n = spline.control_points.len();
let first = spline.control_points[0];
let last = spline.control_points[n - 1];
if (first.x - last.x).abs() < 1e-9
&& (first.y - last.y).abs() < 1e-9
&& (first.z - last.z).abs() < 1e-9
{
spline.control_points.pop();
spline.knots = acadrust::entities::Spline::generate_clamped_knots(
spline.degree as usize,
spline.control_points.len(),
);
spline.fit_points.clear();
}
}
}
"__SPLINEDIT_REVERSE__" => {
spline.control_points.reverse();
spline.fit_points.reverse();
spline.knots = acadrust::entities::Spline::generate_clamped_knots(
spline.degree as usize,
spline.control_points.len(),
);
}
_ => {}
}
}
// ── Autocomplete registry ─────────────────────────────────
inventory::submit!(crate::command::CommandRegistration { names: &["SPE", "SPLINEDIT"] }); // SplineditCommand
+176
View File
@@ -0,0 +1,176 @@
// Stretch tool — ribbon definition + interactive command.
//
// Command: STRETCH (SS)
// Workflow:
// 1. Pick first corner of the crossing window (right-to-left = crossing).
// 2. Pick second corner.
// 3. Pick base point.
// 4. Pick new point → stretches only vertices inside the crossing window.
//
// Entity behaviour:
// Line : move start if inside, move end if inside, move both if both inside.
// LwPolyline : move each vertex independently.
// Polyline/P2D: move each vertex independently.
// Arc / Circle: move the whole entity if its center is inside the window.
// Insert : move the whole entity if its insertion point is inside.
// All others : move the whole entity if any point is inside.
use acadrust::Handle;
use glam::Vec3;
use crate::command::{CadCommand, CmdResult};
use crate::modules::{IconKind, ModuleEvent, ToolDef};
use crate::scene::model::wire_model::WireModel;
// ── Ribbon definition ──────────────────────────────────────────────────────
pub fn tool() -> ToolDef {
ToolDef {
id: "STRETCH",
label: "Stretch",
icon: IconKind::Svg(include_bytes!("../../../../assets/icons/stretch.svg")),
event: ModuleEvent::Command("STRETCH".to_string()),
}
}
// ── Command implementation ─────────────────────────────────────────────────
enum Step {
/// Waiting for the first crossing-window corner.
WindowCorner1,
/// Waiting for the second corner; `c1` is the first corner.
WindowCorner2(Vec3),
/// Crossing window defined; waiting for base point.
Base { win_min: Vec3, win_max: Vec3 },
/// Waiting for target point.
Target {
win_min: Vec3,
win_max: Vec3,
base: Vec3,
},
}
pub struct StretchCommand {
handles: Vec<Handle>,
wire_models: Vec<WireModel>,
step: Step,
}
impl StretchCommand {
pub fn new(handles: Vec<Handle>, wire_models: Vec<WireModel>) -> Self {
Self {
handles,
wire_models,
step: Step::WindowCorner1,
}
}
}
impl CadCommand for StretchCommand {
fn name(&self) -> &'static str {
"STRETCH"
}
fn prompt(&self) -> String {
match &self.step {
Step::WindowCorner1 => format!(
"STRETCH Specify first corner of crossing window [{} objects]:",
self.handles.len()
),
Step::WindowCorner2(_) => "STRETCH Specify opposite corner:".into(),
Step::Base { .. } => "STRETCH Specify base point:".into(),
Step::Target { base, .. } => format!(
"STRETCH Specify new point [base {:.3},{:.3}]:",
base.x, base.z
),
}
}
fn on_point(&mut self, pt: Vec3) -> CmdResult {
match &self.step {
Step::WindowCorner1 => {
self.step = Step::WindowCorner2(pt);
CmdResult::NeedPoint
}
Step::WindowCorner2(c1) => {
let win_min = c1.min(pt);
let win_max = c1.max(pt);
self.step = Step::Base { win_min, win_max };
CmdResult::NeedPoint
}
Step::Base { win_min, win_max } => {
let (wmin, wmax) = (*win_min, *win_max);
self.step = Step::Target {
win_min: wmin,
win_max: wmax,
base: pt,
};
CmdResult::NeedPoint
}
Step::Target {
win_min,
win_max,
base,
} => {
let delta = pt - *base;
CmdResult::StretchEntities {
handles: self.handles.clone(),
win_min: *win_min,
win_max: *win_max,
delta,
}
}
}
}
fn on_enter(&mut self) -> CmdResult {
CmdResult::Cancel
}
fn on_escape(&mut self) -> CmdResult {
CmdResult::Cancel
}
fn on_preview_wires(&mut self, pt: Vec3) -> Vec<WireModel> {
match &self.step {
Step::WindowCorner2(c1) => {
// Show crossing-window rectangle preview (dashed green)
let c1 = *c1;
let pts = vec![
[c1.x, c1.y, c1.z],
[pt.x, c1.y, c1.z],
[pt.x, pt.y, pt.z],
[c1.x, pt.y, pt.z],
[c1.x, c1.y, c1.z],
];
vec![WireModel::solid(
"stretch_window".into(),
pts,
[0.3, 1.0, 0.3, 0.7],
false,
)]
}
Step::Target {
win_min,
win_max,
base,
} => {
let delta = pt - *base;
// Live ghost: vertices inside the crossing window follow the
// cursor, the rest stay anchored.
let mut out: Vec<WireModel> = self
.wire_models
.iter()
.map(|w| w.stretched(*win_min, *win_max, delta))
.collect();
out.push(WireModel::solid(
"rubber_band".into(),
vec![[base.x, base.y, base.z], [pt.x, pt.y, pt.z]],
WireModel::CYAN,
false,
));
out
}
_ => vec![],
}
}
}
+109
View File
@@ -0,0 +1,109 @@
// Move tool — ribbon definition + interactive command.
//
// Command: MOVE (M)
// Requires at least one entity selected before starting.
// Step 1: pick base point
// Step 2: pick destination → translates all selected entities by (dest - base)
use acadrust::Handle;
use glam::Vec3;
use crate::command::{CadCommand, CmdResult, EntityTransform};
use crate::modules::{IconKind, ModuleEvent, ToolDef};
use crate::scene::model::wire_model::WireModel;
// ── Ribbon definition ──────────────────────────────────────────────────────
pub fn tool() -> ToolDef {
ToolDef {
id: "MOVE",
label: "Move",
icon: IconKind::Svg(include_bytes!("../../../../assets/icons/move.svg")),
event: ModuleEvent::Command("MOVE".to_string()),
}
}
// ── Command implementation ─────────────────────────────────────────────────
enum Step {
Base,
Target(Vec3),
}
pub struct MoveCommand {
handles: Vec<Handle>,
wire_models: Vec<WireModel>,
step: Step,
}
impl MoveCommand {
pub fn new(handles: Vec<Handle>, wire_models: Vec<WireModel>) -> Self {
Self {
handles,
wire_models,
step: Step::Base,
}
}
}
impl CadCommand for MoveCommand {
fn name(&self) -> &'static str {
"MOVE"
}
fn prompt(&self) -> String {
match &self.step {
Step::Base => format!(
"MOVE Specify base point [{} objects]:",
self.handles.len()
),
Step::Target(base) => format!(
"MOVE Specify destination [base {:.3},{:.3}]:",
base.x, base.y
),
}
}
fn on_point(&mut self, pt: Vec3) -> CmdResult {
match &self.step {
Step::Base => {
self.step = Step::Target(pt);
CmdResult::NeedPoint
}
Step::Target(base) => {
let delta = pt - *base;
CmdResult::TransformSelected(
self.handles.clone(),
EntityTransform::Translate(delta),
)
}
}
}
fn on_enter(&mut self) -> CmdResult {
CmdResult::Cancel
}
fn on_escape(&mut self) -> CmdResult {
CmdResult::Cancel
}
fn on_preview_wires(&mut self, pt: Vec3) -> Vec<WireModel> {
let Step::Target(base) = &self.step else {
return vec![];
};
let delta = pt - *base;
// Translated ghost of each selected object + rubber-band line.
let mut out: Vec<WireModel> = self
.wire_models
.iter()
.map(|w| w.translated(delta))
.collect();
out.push(WireModel::solid(
"rubber_band".into(),
vec![[base.x, base.y, base.z], [pt.x, pt.y, pt.z]],
WireModel::CYAN,
false,
));
out
}
}
File diff suppressed because it is too large. Load diff