first commit

This commit is contained in:
Hakan Seven committed 2026-03-22 14:56:27 +03:00
1 parent d52c6440ff
commit d2fdac7f1e
274 files changed
+89330

No files matched your search

+699
View File
@@ -0,0 +1,699 @@
// Array dropdown — ribbon definition + interactive commands.
//
// ARRAYRECT (AR):
// Rectangular array: row/column counts and spacing collected via text input.
// 1. Row count → 2. Column count → 3. Row spacing → 4. Column spacing
// → Returns BatchCopy with a grid of Translate transforms.
//
// ARRAYPATH:
// Path array: copies placed at equal intervals along a curve/line.
// (pending geometry engine support — stub)
//
// ARRAYPOLAR:
// Polar array: copies rotated around a center point by a total angle.
// 1. Center point → 2. Item count (text) → 3. Total angle in degrees (text)
use acadrust::Handle;
use glam::Vec3;
use crate::command::{CadCommand, CmdResult, EntityTransform};
use crate::modules::home::defaults;
use crate::modules::IconKind;
use crate::scene::wire_model::WireModel;
// ── Dropdown constants ─────────────────────────────────────────────────────
pub const DROPDOWN_ID: &str = "array_type";
pub const ICON: IconKind = IconKind::Svg(include_bytes!("../../../../assets/icons/array_rect.svg"));
pub const DROPDOWN_ITEMS: &[(&str, &str, IconKind)] = &[
(
"ARRAYRECT",
"Rectangular Array",
IconKind::Svg(include_bytes!("../../../../assets/icons/array_rect.svg")),
),
(
"ARRAYPATH",
"Path Array",
IconKind::Svg(include_bytes!("../../../../assets/icons/array_path.svg")),
),
(
"ARRAYPOLAR",
"Polar Array",
IconKind::Svg(include_bytes!("../../../../assets/icons/array_polar.svg")),
),
];
// ── Rectangular Array ──────────────────────────────────────────────────────
#[derive(Debug, Clone, Copy)]
enum RectStep {
Rows,
Cols { rows: u32 },
RowSp { rows: u32, cols: u32 },
ColSp { rows: u32, cols: u32, row_sp: f32 },
}
pub struct ArrayRectCommand {
handles: Vec<Handle>,
wire_models: Vec<WireModel>,
step: RectStep,
default_rows: u32,
default_cols: u32,
default_row_sp: f32,
default_col_sp: f32,
}
impl ArrayRectCommand {
pub fn new(handles: Vec<Handle>, wire_models: Vec<WireModel>) -> Self {
Self {
handles,
wire_models,
step: RectStep::Rows,
default_rows: defaults::get_array_rows() as u32,
default_cols: defaults::get_array_cols() as u32,
default_row_sp: defaults::get_array_row_sp(),
default_col_sp: defaults::get_array_col_sp(),
}
}
fn build_transforms(rows: u32, cols: u32, row_sp: f32, col_sp: f32) -> Vec<EntityTransform> {
let mut t = Vec::new();
for r in 0..rows {
for c in 0..cols {
if r == 0 && c == 0 {
continue;
}
t.push(EntityTransform::Translate(Vec3::new(
col_sp * c as f32,
row_sp * r as f32,
0.0,
)));
}
}
t
}
}
impl CadCommand for ArrayRectCommand {
fn name(&self) -> &'static str {
"ARRAYRECT"
}
fn prompt(&self) -> String {
match self.step {
RectStep::Rows => format!("ARRAYRECT Enter row count <{}>:", self.default_rows),
RectStep::Cols { rows } => format!(
"ARRAYRECT Enter column count <{}> [{rows} rows]:",
self.default_cols
),
RectStep::RowSp { rows, cols } => format!(
"ARRAYRECT Row spacing <{:.0}> [{rows}×{cols}]:",
self.default_row_sp
),
RectStep::ColSp { rows, cols, row_sp } => format!(
"ARRAYRECT Column spacing <{:.0}> [{rows}×{cols}, row={row_sp:.0}]:",
self.default_col_sp
),
}
}
fn wants_text_input(&self) -> bool {
true
}
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
let t = text.trim().replace(',', ".");
let t = t.as_str();
match self.step {
RectStep::Rows => {
let rows = if t.is_empty() {
self.default_rows
} else {
let v = t.parse::<u32>().unwrap_or(self.default_rows).max(1);
defaults::set_array_rows(v as f32);
self.default_rows = v;
v
};
self.step = RectStep::Cols { rows };
None
}
RectStep::Cols { rows } => {
let cols = if t.is_empty() {
self.default_cols
} else {
let v = t.parse::<u32>().unwrap_or(self.default_cols).max(1);
defaults::set_array_cols(v as f32);
self.default_cols = v;
v
};
self.step = RectStep::RowSp { rows, cols };
None
}
RectStep::RowSp { rows, cols } => {
let row_sp = if t.is_empty() {
self.default_row_sp
} else {
let v = t.parse::<f32>().unwrap_or(self.default_row_sp);
defaults::set_array_row_sp(v);
self.default_row_sp = v;
v
};
self.step = RectStep::ColSp { rows, cols, row_sp };
None
}
RectStep::ColSp { rows, cols, row_sp } => {
let col_sp = if t.is_empty() {
self.default_col_sp
} else {
let v = t.parse::<f32>().unwrap_or(self.default_col_sp);
defaults::set_array_col_sp(v);
v
};
Some(CmdResult::BatchCopy(
self.handles.clone(),
Self::build_transforms(rows, cols, row_sp, col_sp),
))
}
}
}
fn on_preview_wires(&mut self, _pt: Vec3) -> Vec<WireModel> {
let (rows, cols, row_sp, col_sp) = match self.step {
RectStep::Rows => (
self.default_rows,
self.default_cols,
self.default_row_sp,
self.default_col_sp,
),
RectStep::Cols { rows } => (
rows,
self.default_cols,
self.default_row_sp,
self.default_col_sp,
),
RectStep::RowSp { rows, cols } => {
(rows, cols, self.default_row_sp, self.default_col_sp)
}
RectStep::ColSp { rows, cols, row_sp } => (rows, cols, row_sp, self.default_col_sp),
};
Self::build_transforms(rows, cols, row_sp, col_sp)
.iter()
.flat_map(|t| {
if let EntityTransform::Translate(delta) = t {
self.wire_models
.iter()
.map(|w| w.translated(*delta))
.collect::<Vec<_>>()
} else {
vec![]
}
})
.collect()
}
fn on_point(&mut self, _pt: Vec3) -> CmdResult {
CmdResult::NeedPoint
}
fn on_enter(&mut self) -> CmdResult {
// Enter with empty input = use default for current step
self.on_text_input("").map_or(CmdResult::NeedPoint, |r| r)
}
fn on_escape(&mut self) -> CmdResult {
CmdResult::Cancel
}
}
// ── Polar Array ────────────────────────────────────────────────────────────
enum PolarStep {
Center,
Count { center: Vec3 },
Angle { center: Vec3, count: u32 },
}
pub struct ArrayPolarCommand {
handles: Vec<Handle>,
wire_models: Vec<WireModel>,
step: PolarStep,
default_count: u32,
default_angle: f32,
}
impl ArrayPolarCommand {
pub fn new(handles: Vec<Handle>, wire_models: Vec<WireModel>) -> Self {
Self {
handles,
wire_models,
step: PolarStep::Center,
default_count: defaults::get_array_p_count() as u32,
default_angle: defaults::get_array_p_angle(),
}
}
}
impl CadCommand for ArrayPolarCommand {
fn name(&self) -> &'static str {
"ARRAYPOLAR"
}
fn prompt(&self) -> String {
match &self.step {
PolarStep::Center => format!(
"ARRAYPOLAR Specify center point [{} objects]:",
self.handles.len()
),
PolarStep::Count { .. } => {
format!("ARRAYPOLAR Enter item count <{}>:", self.default_count)
}
PolarStep::Angle { count, .. } => format!(
"ARRAYPOLAR Enter total angle in degrees <{:.0}> [{count} items]:",
self.default_angle
),
}
}
fn wants_text_input(&self) -> bool {
matches!(self.step, PolarStep::Count { .. } | PolarStep::Angle { .. })
}
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
let t = text.trim().replace(',', ".");
let t = t.as_str();
match &self.step {
PolarStep::Count { center } => {
let center = *center;
let count = if t.is_empty() {
self.default_count
} else {
let v = t.parse::<u32>().unwrap_or(self.default_count).max(2);
defaults::set_array_p_count(v as f32);
self.default_count = v;
v
};
self.step = PolarStep::Angle { center, count };
None
}
PolarStep::Angle { center, count } => {
let center = *center;
let count = *count;
let total_deg = if t.is_empty() {
self.default_angle
} else {
let v = t.parse::<f32>().unwrap_or(self.default_angle);
defaults::set_array_p_angle(v);
v
};
let step_rad = total_deg.to_radians() / count as f32;
let transforms = (1..count)
.map(|n| EntityTransform::Rotate {
center,
angle_rad: step_rad * n as f32,
})
.collect();
Some(CmdResult::BatchCopy(self.handles.clone(), transforms))
}
_ => None,
}
}
fn on_preview_wires(&mut self, pt: Vec3) -> Vec<WireModel> {
let (center, count, total_deg) = match &self.step {
PolarStep::Center => (pt, self.default_count, self.default_angle),
PolarStep::Count { center } => (*center, self.default_count, self.default_angle),
PolarStep::Angle { center, count } => (*center, *count, self.default_angle),
};
let step_rad = total_deg.to_radians() / count as f32;
let mut out: Vec<WireModel> = (1..count)
.flat_map(|n| {
let angle_rad = step_rad * n as f32;
self.wire_models
.iter()
.map(move |w| w.rotated(center, angle_rad))
})
.collect();
// Rubber-band from center to cursor while picking the center point.
if matches!(self.step, PolarStep::Center) {
out.push(WireModel::solid(
"rubber_band".into(),
vec![[center.x, center.y, center.z], [pt.x, pt.y, pt.z]],
WireModel::CYAN,
false,
));
}
out
}
fn on_point(&mut self, pt: Vec3) -> CmdResult {
if let PolarStep::Center = self.step {
self.step = PolarStep::Count { center: pt };
}
CmdResult::NeedPoint
}
fn on_enter(&mut self) -> CmdResult {
self.on_text_input("").map_or(CmdResult::NeedPoint, |r| r)
}
fn on_escape(&mut self) -> CmdResult {
CmdResult::Cancel
}
}
// ── Path Array ─────────────────────────────────────────────────────────────
//
// ARRAYPATH:
// Copies selected objects at equal arc-length intervals along a path entity.
// 1. Select path entity (Line, Arc, Circle, LwPolyline)
// 2. Enter item count (total, including the original at the path start)
// → Returns BatchCopy with Translate transforms derived from path samples.
use acadrust::EntityType;
use std::f32::consts::TAU as FTAU;
// ── Path geometry helpers ──────────────────────────────────────────────────
/// Tessellate an LwPolyline.
fn lw_dense_pts(p: &acadrust::entities::LwPolyline) -> Vec<Vec3> {
let z = p.elevation as f32;
let verts = &p.vertices;
if verts.is_empty() {
return vec![];
}
let n = verts.len();
let segs = if p.is_closed { n } else { n.saturating_sub(1) };
let mut out: Vec<Vec3> = vec![];
for i in 0..segs {
let v0 = &verts[i];
let v1 = &verts[(i + 1) % n];
let x0 = v0.location.x as f32;
let y0 = v0.location.y as f32;
let x1 = v1.location.x as f32;
let y1 = v1.location.y as f32;
if out.is_empty() {
out.push(Vec3::new(x0, y0, z));
}
let bulge = v0.bulge as f32;
if bulge.abs() < 1e-9 {
out.push(Vec3::new(x1, y1, z));
} else {
let dx = x1 - x0;
let dy = y1 - y0;
let d = (dx * dx + dy * dy).sqrt();
if d < 1e-9 {
out.push(Vec3::new(x1, y1, z));
continue;
}
let angle = 4.0 * bulge.atan();
let r = (d * 0.5) / (angle * 0.5).sin().abs();
let mx = (x0 + x1) * 0.5;
let my = (y0 + y1) * 0.5;
let inv = 1.0 / d;
let (px, py) = (-dy * inv, dx * inv);
let sign = if bulge > 0.0 { 1.0f32 } else { -1.0 };
let h_val = r - (r * r - d * d * 0.25).max(0.0).sqrt();
let (cx, cy) = (mx - sign * px * (r - h_val), my - sign * py * (r - h_val));
let a0 = (y0 - cy).atan2(x0 - cx);
let a1 = (y1 - cy).atan2(x1 - cx);
// Signed span: CCW for bulge > 0, CW (negative) for bulge < 0.
let span = if bulge > 0.0 {
let s = a1 - a0;
if s <= 0.0 {
s + FTAU
} else {
s
}
} else {
let s = a1 - a0;
if s >= 0.0 {
s - FTAU
} else {
s
}
};
let steps = ((r * span.abs() / 2.0).ceil() as usize).clamp(4, 64);
for j in 1..=steps {
let ang = a0 + span * (j as f32 / steps as f32);
out.push(Vec3::new(cx + r * ang.cos(), cy + r * ang.sin(), z));
}
}
}
out
}
/// Walk `pts` (ordered) and return `count` points at equal arc-length spacing.
fn subsample_equidistant(pts: &[Vec3], count: usize) -> Vec<Vec3> {
if count == 0 {
return vec![];
}
if pts.len() < 2 {
return vec![pts.first().copied().unwrap_or(Vec3::ZERO); count];
}
let mut cum = vec![0.0f32; pts.len()];
for i in 1..pts.len() {
cum[i] = cum[i - 1] + pts[i].distance(pts[i - 1]);
}
let total = *cum.last().unwrap();
if total < 1e-9 {
return vec![pts[0]; count];
}
let mut out = Vec::with_capacity(count);
let mut seg = 0usize;
for i in 0..count {
let target = if count > 1 {
total * i as f32 / (count - 1) as f32
} else {
0.0
};
while seg + 2 < pts.len() && cum[seg + 1] < target - 1e-9 {
seg += 1;
}
let seg_len = cum[seg + 1] - cum[seg];
let t = if seg_len > 1e-9 {
(target - cum[seg]) / seg_len
} else {
0.0
};
out.push(pts[seg].lerp(pts[seg + 1], t.clamp(0.0, 1.0)));
}
out
}
// ── State machine ──────────────────────────────────────────────────────────
enum PathStep {
SelectPath,
Count { path_entity: EntityType },
}
pub struct ArrayPathCommand {
handles: Vec<Handle>,
wire_models: Vec<WireModel>,
all_entities: Vec<EntityType>,
step: PathStep,
default_count: u32,
}
impl ArrayPathCommand {
pub fn new(
handles: Vec<Handle>,
wire_models: Vec<WireModel>,
all_entities: Vec<EntityType>,
) -> Self {
Self {
handles,
wire_models,
all_entities,
step: PathStep::SelectPath,
default_count: defaults::get_array_path_count() as u32,
}
}
/// Sample `count` evenly-spaced points along `entity`.
fn sample_path(entity: &EntityType, count: usize) -> Vec<Vec3> {
if count == 0 {
return vec![];
}
let fn_norm = |x: f32| -> f32 { ((x % FTAU) + FTAU) % FTAU };
match entity {
EntityType::Line(l) => {
let p0 = Vec3::new(l.start.x as f32, l.start.y as f32, 0.0);
let p1 = Vec3::new(l.end.x as f32, l.end.y as f32, 0.0);
let d = (count - 1).max(1) as f32;
(0..count).map(|i| p0.lerp(p1, i as f32 / d)).collect()
}
EntityType::Arc(a) => {
let (cx, cy, r) = (a.center.x as f32, a.center.y as f32, a.radius as f32);
let a0 = a.start_angle.to_radians() as f32;
let a1 = a.end_angle.to_radians() as f32;
let span = {
let s = fn_norm(a1) - fn_norm(a0);
if s <= 0.0 {
s + FTAU
} else {
s
}
};
let d = (count - 1).max(1) as f32;
(0..count)
.map(|i| {
let ang = fn_norm(a0) + span * (i as f32 / d);
Vec3::new(cx + r * ang.cos(), cy + r * ang.sin(), 0.0)
})
.collect()
}
EntityType::Circle(c) => {
let (cx, cy, r) = (c.center.x as f32, c.center.y as f32, c.radius as f32);
(0..count)
.map(|i| {
let ang = i as f32 / count as f32 * FTAU;
Vec3::new(cx + r * ang.cos(), cy + r * ang.sin(), 0.0)
})
.collect()
}
EntityType::LwPolyline(p) => subsample_equidistant(&lw_dense_pts(p), count),
_ => vec![Vec3::ZERO; count],
}
}
/// Build Translate transforms: delta from pts[0] to each subsequent point.
fn build_transforms(pts: &[Vec3]) -> Vec<EntityTransform> {
if pts.len() < 2 {
return vec![];
}
let origin = pts[0];
pts[1..]
.iter()
.map(|&p| EntityTransform::Translate(p - origin))
.collect()
}
}
impl CadCommand for ArrayPathCommand {
fn name(&self) -> &'static str {
"ARRAYPATH"
}
fn prompt(&self) -> String {
match &self.step {
PathStep::SelectPath => format!(
"ARRAYPATH Select path entity [{} objects]:",
self.handles.len()
),
PathStep::Count { .. } => {
format!("ARRAYPATH Enter item count <{}>:", self.default_count)
}
}
}
fn needs_entity_pick(&self) -> bool {
matches!(self.step, PathStep::SelectPath)
}
fn wants_text_input(&self) -> bool {
matches!(self.step, PathStep::Count { .. })
}
fn on_entity_pick(&mut self, handle: Handle, _pt: Vec3) -> CmdResult {
if handle.is_null() || self.handles.contains(&handle) {
return CmdResult::NeedPoint;
}
if let Some(entity) = self
.all_entities
.iter()
.find(|e| e.common().handle == handle)
.cloned()
{
self.step = PathStep::Count {
path_entity: entity,
};
}
CmdResult::NeedPoint
}
fn on_hover_entity(&mut self, handle: Handle, _pt: Vec3) -> Vec<WireModel> {
if handle.is_null() || self.handles.contains(&handle) {
return vec![];
}
if !matches!(self.step, PathStep::SelectPath) {
return vec![];
}
if let Some(entity) = self
.all_entities
.iter()
.find(|e| e.common().handle == handle)
{
let pts = Self::sample_path(entity, 64);
if pts.len() >= 2 {
return vec![WireModel::solid(
"arraypath_hover".into(),
pts.iter().map(|p| [p.x, p.y, p.z]).collect(),
WireModel::CYAN,
false,
)];
}
}
vec![]
}
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
let PathStep::Count { path_entity } = &self.step else {
return None;
};
let t = text.trim().replace(',', ".");
let count = if t.is_empty() {
self.default_count
} else {
let v = t.parse::<u32>().unwrap_or(self.default_count).max(2);
defaults::set_array_path_count(v as f32);
self.default_count = v;
v
};
let pts = Self::sample_path(path_entity, count as usize);
let transforms = Self::build_transforms(&pts);
if transforms.is_empty() {
return Some(CmdResult::Cancel);
}
Some(CmdResult::BatchCopy(self.handles.clone(), transforms))
}
fn on_preview_wires(&mut self, _pt: Vec3) -> Vec<WireModel> {
let PathStep::Count { path_entity } = &self.step else {
return vec![];
};
let pts = Self::sample_path(path_entity, self.default_count as usize);
let transforms = Self::build_transforms(&pts);
transforms
.iter()
.flat_map(|t| {
if let EntityTransform::Translate(delta) = t {
self.wire_models
.iter()
.map(|w| w.translated(*delta))
.collect::<Vec<_>>()
} else {
vec![]
}
})
.collect()
}
fn on_point(&mut self, _pt: Vec3) -> CmdResult {
CmdResult::NeedPoint
}
fn on_enter(&mut self) -> CmdResult {
self.on_text_input("").map_or(CmdResult::NeedPoint, |r| r)
}
fn on_escape(&mut self) -> CmdResult {
CmdResult::Cancel
}
}
+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::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()),
}
}
+226
View File
@@ -0,0 +1,226 @@
// 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)
//
// Unsupported entity types are skipped silently.
use std::f64::consts::TAU;
use acadrust::entities::EntityCommon;
use acadrust::entities::{Arc as ArcEnt, Circle as CircleEnt, Line as LineEnt, LwPolyline};
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 ────────────────────────────────────────────────────────
/// 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::Insert(ins) => ins
.explode_from_document(document)
.into_iter()
.map(normalize_insert_entity)
.collect(),
_ => vec![],
}
}
pub fn normalize_insert_entity(mut entity: EntityType) -> EntityType {
match &mut entity {
EntityType::Arc(arc) => {
arc.start_angle = arc.start_angle.to_degrees();
arc.end_angle = arc.end_angle.to_degrees();
}
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(mut entity: EntityType) -> EntityType {
if let EntityType::Arc(arc) = &mut entity {
arc.start_angle = arc.start_angle.to_radians();
arc.end_angle = arc.end_angle.to_radians();
}
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 chord_x = p1[0] - p0[0];
let chord_y = p1[1] - p0[1];
let chord_len = (chord_x * chord_x + chord_y * chord_y).sqrt();
if chord_len < 1e-12 {
return None;
}
// Included angle = 4 * atan(bulge)
let b = bulge;
let b2 = b * b;
// Radius: r = chord * (1 + b²) / (4 * |b|)
let r = chord_len * (1.0 + b2) / (4.0 * b.abs());
// Perpendicular distance from midpoint to center:
// d = r * cos(theta/2), where cos(theta/2) = (1 - b²) / (1 + b²)
let d = r * (1.0 - b2) / (1.0 + b2);
// Midpoint
let mx = (p0[0] + p1[0]) * 0.5;
let my = (p0[1] + p1[1]) * 0.5;
// Left-perpendicular direction (rotate chord 90° CCW)
let perp_x = -chord_y / chord_len;
let perp_y = chord_x / chord_len;
// Positive bulge → center is to the left of chord direction (CCW arc)
let sign = b.signum();
let cx = mx + sign * d * perp_x;
let cy = my + sign * d * perp_y;
// Angles from center to p0 and p1
let a0_rad = (p0[1] - cy).atan2(p0[0] - cx);
let a1_rad = (p1[1] - cy).atan2(p1[0] - cx);
// acadrust arcs are always CCW: positive bulge → CCW from p0 to p1
// Negative bulge → CW from p0 to p1 = CCW from p1 to p0
let (start_deg, end_deg) = if b > 0.0 {
(norm_deg(a0_rad.to_degrees()), norm_deg(a1_rad.to_degrees()))
} else {
(norm_deg(a1_rad.to_degrees()), norm_deg(a0_rad.to_degrees()))
};
let mut common = common_src.clone();
common.handle = Handle::NULL;
let arc = ArcEnt {
common,
center: Vector3::new(cx, cy, elevation),
radius: r,
start_angle: start_deg,
end_angle: end_deg,
..ArcEnt::new()
};
Some(EntityType::Arc(arc))
}
fn norm_deg(a: f64) -> f64 {
let t = TAU.to_degrees();
((a % t) + t) % t
}
// ── 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.
+830
View File
@@ -0,0 +1,830 @@
// Fillet / Chamfer — ribbon definitions + full command implementations.
//
// FILLET (F):
// Pick two lines. Command finds their intersection, computes a tangent arc
// of radius R, trims both lines to the tangent points, and inserts the arc.
// R=0 just extends/trims both lines to the exact intersection (sharp corner).
//
// CHAMFER (CHA):
// Pick two lines. Command finds their intersection, backs off dist1 along
// line 1 and dist2 along line 2, trims, and inserts a straight chamfer line.
use acadrust::entities::{Arc as ArcEnt, Line as LineEnt};
use acadrust::types::Vector3;
use acadrust::{EntityType, Handle};
use glam::Vec3;
use crate::command::{CadCommand, CmdResult};
use crate::modules::home::defaults;
use crate::modules::IconKind;
use crate::scene::wire_model::WireModel;
// ── Dropdown constants ─────────────────────────────────────────────────────
pub const DROPDOWN_ID: &str = "fillet_chamfer";
pub const ICON: IconKind = IconKind::Svg(include_bytes!("../../../../assets/icons/fillet.svg"));
pub const DROPDOWN_ITEMS: &[(&str, &str, IconKind)] = &[
(
"FILLET",
"Fillet",
IconKind::Svg(include_bytes!("../../../../assets/icons/fillet.svg")),
),
(
"CHAMFER",
"Chamfer",
IconKind::Svg(include_bytes!("../../../../assets/icons/chamfer.svg")),
),
];
// ══════════════════════════════════════════════════════════════════════════
// Geometry
// ══════════════════════════════════════════════════════════════════════════
/// Intersect two infinite lines. Returns (t on L1, u on L2).
fn ll(
ax: f64,
ay: f64,
dx: f64,
dy: f64,
cx: f64,
cy: f64,
ex: f64,
ey: f64,
) -> Option<(f64, f64)> {
let det = dx * ey - dy * ex;
if det.abs() < 1e-10 {
return None;
}
let t = ((cx - ax) * ey - (cy - ay) * ex) / det;
let u = ((cx - ax) * dy - (cy - ay) * dx) / det;
Some((t, u))
}
/// Extract coords and unit direction for a Line entity.
fn line_geom(l: &LineEnt) -> ([f64; 2], [f64; 2], [f64; 2], f64) {
let p1 = [l.start.x, l.start.y];
let p2 = [l.end.x, l.end.y];
let dx = p2[0] - p1[0];
let dy = p2[1] - p1[1];
let len = (dx * dx + dy * dy).sqrt().max(1e-12);
(p1, p2, [dx / len, dy / len], len)
}
/// Project click onto line, returning t ∈ ℝ.
fn project_click(click: [f64; 2], p1: [f64; 2], unit: [f64; 2]) -> f64 {
(click[0] - p1[0]) * unit[0] + (click[1] - p1[1]) * unit[1]
}
// ── Fillet ─────────────────────────────────────────────────────────────────
/// Compute fillet: trim l1/l2 and insert a tangent arc of `radius`.
/// Returns (trimmed_l1, trimmed_l2, fillet_arc).
fn compute_fillet(
l1: &LineEnt,
click1: [f64; 2],
l2: &LineEnt,
click2: [f64; 2],
radius: f64,
) -> Option<(EntityType, EntityType, Option<EntityType>)> {
let (p1, _p2, u1, _len1) = line_geom(l1);
let (p3, _p4, u2, _len2) = line_geom(l2);
// Intersection of infinite lines
let (t_p, u_p) = ll(p1[0], p1[1], u1[0], u1[1], p3[0], p3[1], u2[0], u2[1])?;
// Intersection point
let px = p1[0] + t_p * u1[0];
let py = p1[1] + t_p * u1[1];
// Direction from P toward each click (the "keep" side)
let s1 = project_click(click1, [px, py], u1); // positive = along u1
let s2 = project_click(click2, [px, py], u2);
let dir1 = if s1 >= 0.0 {
[u1[0], u1[1]]
} else {
[-u1[0], -u1[1]]
};
let dir2 = if s2 >= 0.0 {
[u2[0], u2[1]]
} else {
[-u2[0], -u2[1]]
};
// Angle between the two keep-directions
let cos_a = (dir1[0] * dir2[0] + dir1[1] * dir2[1]).clamp(-1.0, 1.0);
let angle = cos_a.acos();
// Lines are parallel / anti-parallel
if angle < 1e-6 || (angle - std::f64::consts::PI).abs() < 1e-6 {
return None;
}
let half = angle / 2.0;
let z = l1.start.z;
if radius < 1e-9 {
// r = 0: just extend/trim both lines to the intersection
let (new_l1, new_l2) = trim_to_point(l1, t_p, p1, u1, l2, u_p, p3, u2)?;
return Some((EntityType::Line(new_l1), EntityType::Line(new_l2), None));
}
// Distance from P to tangent points
let d = radius / half.tan();
// Tangent points
let t1 = [px + d * dir1[0], py + d * dir1[1]];
let t2 = [px + d * dir2[0], py + d * dir2[1]];
// Arc center: along bisector of dir1+dir2, distance = r / sin(half)
let bx = dir1[0] + dir2[0];
let by = dir1[1] + dir2[1];
let blen = (bx * bx + by * by).sqrt();
if blen < 1e-10 {
return None;
}
let arc_dist = radius / half.sin();
let arc_cx = px + arc_dist * bx / blen;
let arc_cy = py + arc_dist * by / blen;
// Arc angles in degrees
let a_start_deg = (t1[1] - arc_cy).atan2(t1[0] - arc_cx).to_degrees();
let a_end_deg = (t2[1] - arc_cy).atan2(t2[0] - arc_cx).to_degrees();
// Pick CCW direction that fills the concave corner
let cross = dir1[0] * dir2[1] - dir1[1] * dir2[0];
let (start_deg, end_deg) = if cross <= 0.0 {
(a_start_deg, a_end_deg)
} else {
(a_end_deg, a_start_deg)
};
// Trim l1 to T1 and l2 to T2
let new_l1 = trim_to_xy(l1, t_p, t1, dir1, p1, u1)?;
let new_l2 = trim_to_xy(l2, u_p, t2, dir2, p3, u2)?;
// Build arc entity
let mut arc = ArcEnt::new();
arc.common = l1.common.clone();
arc.common.handle = Handle::NULL;
arc.center = Vector3::new(arc_cx, arc_cy, z);
arc.radius = radius;
arc.start_angle = start_deg;
arc.end_angle = end_deg;
Some((
EntityType::Line(new_l1),
EntityType::Line(new_l2),
Some(EntityType::Arc(arc)),
))
}
/// Trim a line's parameter to an intersection t on the same side as dir (keep side).
fn trim_to_xy(
orig: &LineEnt,
t_isect: f64,
tangent: [f64; 2],
dir: [f64; 2],
p1: [f64; 2],
unit: [f64; 2],
) -> Option<LineEnt> {
let z = orig.start.z;
let mut l = orig.clone();
l.common.handle = Handle::NULL;
// t_tangent: parameter of the tangent point along the line from start
let t_tan = (tangent[0] - p1[0]) * unit[0] + (tangent[1] - p1[1]) * unit[1];
// dir is positive along unit → we keep the portion BEYOND t_tan in that direction
// dir positive: keep from t_tan to +∞ (i.e. set start to tangent point)
// dir negative: keep from -∞ to t_tan (i.e. set end to tangent point)
let len = {
let dx = orig.end.x - orig.start.x;
let dy = orig.end.y - orig.start.y;
(dx * dx + dy * dy).sqrt().max(1e-12)
};
let dot = dir[0] * unit[0] + dir[1] * unit[1]; // +1 or -1
if dot > 0.0 {
// keep from tangent to end → move start to tangent point
l.start = Vector3::new(tangent[0], tangent[1], z);
} else {
// keep from start to tangent → move end to tangent point
l.end = Vector3::new(tangent[0], tangent[1], z);
}
let _ = (t_isect, len, t_tan); // used implicitly via `dot`
Some(l)
}
/// Trim both lines exactly to their intersection point (r=0 case).
fn trim_to_point(
l1: &LineEnt,
t_p: f64,
p1: [f64; 2],
u1: [f64; 2],
l2: &LineEnt,
u_p: f64,
_p3: [f64; 2],
_u2: [f64; 2],
) -> Option<(LineEnt, LineEnt)> {
let px = p1[0] + t_p * u1[0];
let py = p1[1] + t_p * u1[1];
let z1 = l1.start.z;
let z2 = l2.start.z;
// For l1: if t_p is past the midpoint, keep start…P; else keep P…end
// We use the same "which end is P closer to" logic
let mut ll1 = l1.clone();
ll1.common.handle = Handle::NULL;
let mut ll2 = l2.clone();
ll2.common.handle = Handle::NULL;
if t_p >= 0.0 {
ll1.end = Vector3::new(px, py, z1);
} else {
ll1.start = Vector3::new(px, py, z1);
}
if u_p >= 0.0 {
ll2.end = Vector3::new(px, py, z2);
} else {
ll2.start = Vector3::new(px, py, z2);
}
Some((ll1, ll2))
}
// ── Point-generation helpers ──────────────────────────────────────────────
fn line_pts(l: &LineEnt) -> Vec<[f32; 3]> {
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],
]
}
fn arc_pts(cx: f64, cy: f64, r: f64, a0_deg: f64, a1_deg: f64, y: f64) -> Vec<[f32; 3]> {
use std::f64::consts::TAU;
let fn_norm = |a: f64| -> f64 { ((a % TAU) + TAU) % TAU };
let a0 = a0_deg.to_radians();
let a1 = a1_deg.to_radians();
let span = {
let s = fn_norm(a1) - fn_norm(a0);
if s <= 0.0 {
s + TAU
} else {
s
}
};
let steps = (span.abs() * 20.0).ceil().max(4.0) as usize;
(0..=steps)
.map(|i| {
let ang = fn_norm(a0) + span * (i as f64 / steps as f64);
[
(cx + r * ang.cos()) as f32,
y as f32,
(cy + r * ang.sin()) as f32,
]
})
.collect()
}
fn entity_pts(e: &EntityType) -> Vec<[f32; 3]> {
match e {
EntityType::Line(l) => line_pts(l),
EntityType::Arc(a) => arc_pts(
a.center.x,
a.center.y,
a.radius,
a.start_angle,
a.end_angle,
a.center.y,
),
_ => vec![],
}
}
// ── Chamfer ────────────────────────────────────────────────────────────────
/// Compute chamfer: trim l1 by dist1 from intersection, l2 by dist2, add chamfer line.
fn compute_chamfer(
l1: &LineEnt,
click1: [f64; 2],
dist1: f64,
l2: &LineEnt,
click2: [f64; 2],
dist2: f64,
) -> Option<(EntityType, EntityType, EntityType)> {
let (p1, _, u1, _) = line_geom(l1);
let (p3, _, u2, _) = line_geom(l2);
let (t_p, u_p) = ll(p1[0], p1[1], u1[0], u1[1], p3[0], p3[1], u2[0], u2[1])?;
let px = p1[0] + t_p * u1[0];
let py = p1[1] + t_p * u1[1];
let z = l1.start.z;
let s1 = project_click(click1, [px, py], u1);
let s2 = project_click(click2, [px, py], u2);
let dir1 = if s1 >= 0.0 {
[u1[0], u1[1]]
} else {
[-u1[0], -u1[1]]
};
let dir2 = if s2 >= 0.0 {
[u2[0], u2[1]]
} else {
[-u2[0], -u2[1]]
};
// Chamfer points: back off dist from P along keep-direction
let c1 = [px + dist1 * dir1[0], py + dist1 * dir1[1]];
let c2 = [px + dist2 * dir2[0], py + dist2 * dir2[1]];
// Trim l1 to c1 and l2 to c2
let new_l1 = trim_to_xy(l1, t_p, c1, dir1, p1, u1)?;
let new_l2 = trim_to_xy(l2, u_p, c2, dir2, p3, u2)?;
// Chamfer line
let mut cline = l1.clone();
cline.common.handle = Handle::NULL;
cline.start = Vector3::new(c1[0], c1[1], z);
cline.end = Vector3::new(c2[0], c2[1], z);
Some((
EntityType::Line(new_l1),
EntityType::Line(new_l2),
EntityType::Line(cline),
))
}
// ══════════════════════════════════════════════════════════════════════════
// FilletCommand
// ══════════════════════════════════════════════════════════════════════════
enum FilletStep {
First,
Second {
h1: Handle,
l1: LineEnt,
click1: [f64; 2],
},
}
pub struct FilletCommand {
radius: f64,
step: FilletStep,
all_entities: Vec<EntityType>,
}
impl FilletCommand {
pub fn new(radius: f32, all_entities: Vec<EntityType>) -> Self {
Self {
radius: radius as f64,
step: FilletStep::First,
all_entities,
}
}
}
impl CadCommand for FilletCommand {
fn name(&self) -> &'static str {
"FILLET"
}
fn prompt(&self) -> String {
match &self.step {
FilletStep::First => format!(
"FILLET Select first line [R={:.4} | type R <val> to change]:",
self.radius
),
FilletStep::Second { .. } => {
format!("FILLET Select second line [R={:.4}]:", self.radius)
}
}
}
fn wants_text_input(&self) -> bool {
matches!(self.step, FilletStep::First)
}
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
let t = text.trim();
let body = if t.to_uppercase().starts_with('R') {
t[1..].trim()
} else {
t
};
if let Ok(v) = body.replace(',', ".").parse::<f64>() {
if v >= 0.0 {
self.radius = v;
defaults::set_fillet_radius(v as f32);
}
}
None
}
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;
}
let click = [pt.x as f64, pt.y as f64];
match &self.step {
FilletStep::First => {
// Must be a line
let l1 = self
.all_entities
.iter()
.find(|e| e.common().handle == handle)
.and_then(|e| {
if let EntityType::Line(l) = e {
Some(l.clone())
} else {
None
}
});
if let Some(l) = l1 {
self.step = FilletStep::Second {
h1: handle,
l1: l,
click1: click,
};
CmdResult::NeedPoint
} else {
CmdResult::NeedPoint // not a line — ignore
}
}
FilletStep::Second { h1, l1, click1 } => {
let h1 = *h1;
let l1 = l1.clone();
let click1 = *click1;
if handle == h1 {
return CmdResult::NeedPoint;
}
let l2 = self
.all_entities
.iter()
.find(|e| e.common().handle == handle)
.and_then(|e| {
if let EntityType::Line(l) = e {
Some(l.clone())
} else {
None
}
});
if let Some(l2) = l2 {
match compute_fillet(&l1, click1, &l2, click, self.radius) {
Some((new_l1, new_l2, maybe_arc)) => {
let mut additions = vec![];
if let Some(arc) = maybe_arc {
additions.push(arc);
}
CmdResult::ReplaceMany(
vec![(h1, vec![new_l1]), (handle, vec![new_l2])],
additions,
)
}
None => {
// Could not compute fillet (parallel lines, etc.)
CmdResult::NeedPoint
}
}
} else {
CmdResult::NeedPoint
}
}
}
}
fn on_hover_entity(&mut self, handle: Handle, pt: Vec3) -> Vec<WireModel> {
if handle.is_null() {
return vec![];
}
let click = [pt.x as f64, pt.y as f64];
match &self.step {
FilletStep::First => {
// Highlight hovered line in cyan
let pts = self
.all_entities
.iter()
.find(|e| e.common().handle == handle)
.and_then(|e| {
if let EntityType::Line(l) = e {
Some(line_pts(l))
} else {
None
}
});
if let Some(pts) = pts {
vec![WireModel::solid(
"fillet_hover".into(),
pts,
WireModel::CYAN,
false,
)]
} else {
vec![]
}
}
FilletStep::Second { l1, click1, .. } => {
let l1 = l1.clone();
let click1 = *click1;
if handle.is_null() {
return vec![];
}
let l2 = self
.all_entities
.iter()
.find(|e| e.common().handle == handle)
.and_then(|e| {
if let EntityType::Line(l) = e {
Some(l.clone())
} else {
None
}
});
if let Some(l2) = l2 {
if let Some((new_l1, new_l2, maybe_arc)) =
compute_fillet(&l1, click1, &l2, click, self.radius)
{
let mut out = vec![
WireModel::solid(
"fillet_l1".into(),
entity_pts(&new_l1),
WireModel::CYAN,
false,
),
WireModel::solid(
"fillet_l2".into(),
entity_pts(&new_l2),
WireModel::CYAN,
false,
),
];
if let Some(arc) = maybe_arc {
out.push(WireModel::solid(
"fillet_arc".into(),
entity_pts(&arc),
WireModel::CYAN,
false,
));
}
return out;
}
}
vec![]
}
}
}
fn on_point(&mut self, _pt: Vec3) -> CmdResult {
CmdResult::NeedPoint
}
fn on_enter(&mut self) -> CmdResult {
CmdResult::Cancel
}
fn on_escape(&mut self) -> CmdResult {
CmdResult::Cancel
}
}
// ══════════════════════════════════════════════════════════════════════════
// ChamferCommand
// ══════════════════════════════════════════════════════════════════════════
enum ChamferStep {
First,
Second {
h1: Handle,
l1: LineEnt,
click1: [f64; 2],
},
}
pub struct ChamferCommand {
dist1: f64,
dist2: f64,
step: ChamferStep,
all_entities: Vec<EntityType>,
}
impl ChamferCommand {
pub fn new(dist: f32, all_entities: Vec<EntityType>) -> Self {
Self {
dist1: dist as f64,
dist2: defaults::get_chamfer_dist2() as f64,
step: ChamferStep::First,
all_entities,
}
}
}
impl CadCommand for ChamferCommand {
fn name(&self) -> &'static str {
"CHAMFER"
}
fn prompt(&self) -> String {
match &self.step {
ChamferStep::First => format!(
"CHAMFER Select first line [D1={:.4} D2={:.4} | type D <d1> <d2>]:",
self.dist1, self.dist2
),
ChamferStep::Second { .. } => format!(
"CHAMFER Select second line [D1={:.4} D2={:.4}]:",
self.dist1, self.dist2
),
}
}
fn wants_text_input(&self) -> bool {
matches!(self.step, ChamferStep::First)
}
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
let t = text.trim();
let body = if t.to_uppercase().starts_with('D') {
t[1..].trim()
} else {
t
};
let parts: Vec<f64> = body
.split_whitespace()
.filter_map(|s| s.replace(',', ".").parse::<f64>().ok())
.collect();
if let Some(&v) = parts.first() {
self.dist1 = v.max(0.0);
defaults::set_chamfer_dist1(self.dist1 as f32);
}
if let Some(&v) = parts.get(1) {
self.dist2 = v.max(0.0);
defaults::set_chamfer_dist2(self.dist2 as f32);
} else if parts.len() == 1 {
self.dist2 = self.dist1;
defaults::set_chamfer_dist2(self.dist2 as f32);
}
None
}
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;
}
let click = [pt.x as f64, pt.y as f64];
match &self.step {
ChamferStep::First => {
let l1 = self
.all_entities
.iter()
.find(|e| e.common().handle == handle)
.and_then(|e| {
if let EntityType::Line(l) = e {
Some(l.clone())
} else {
None
}
});
if let Some(l) = l1 {
self.step = ChamferStep::Second {
h1: handle,
l1: l,
click1: click,
};
CmdResult::NeedPoint
} else {
CmdResult::NeedPoint
}
}
ChamferStep::Second { h1, l1, click1 } => {
let h1 = *h1;
let l1 = l1.clone();
let click1 = *click1;
if handle == h1 {
return CmdResult::NeedPoint;
}
let l2 = self
.all_entities
.iter()
.find(|e| e.common().handle == handle)
.and_then(|e| {
if let EntityType::Line(l) = e {
Some(l.clone())
} else {
None
}
});
if let Some(l2) = l2 {
match compute_chamfer(&l1, click1, self.dist1, &l2, click, self.dist2) {
Some((new_l1, new_l2, chamfer_line)) => CmdResult::ReplaceMany(
vec![(h1, vec![new_l1]), (handle, vec![new_l2])],
vec![chamfer_line],
),
None => CmdResult::NeedPoint,
}
} else {
CmdResult::NeedPoint
}
}
}
}
fn on_hover_entity(&mut self, handle: Handle, pt: Vec3) -> Vec<WireModel> {
if handle.is_null() {
return vec![];
}
let click = [pt.x as f64, pt.y as f64];
match &self.step {
ChamferStep::First => {
let pts = self
.all_entities
.iter()
.find(|e| e.common().handle == handle)
.and_then(|e| {
if let EntityType::Line(l) = e {
Some(line_pts(l))
} else {
None
}
});
if let Some(pts) = pts {
vec![WireModel::solid(
"chamfer_hover".into(),
pts,
WireModel::CYAN,
false,
)]
} else {
vec![]
}
}
ChamferStep::Second { l1, click1, .. } => {
let l1 = l1.clone();
let click1 = *click1;
let l2 = self
.all_entities
.iter()
.find(|e| e.common().handle == handle)
.and_then(|e| {
if let EntityType::Line(l) = e {
Some(l.clone())
} else {
None
}
});
if let Some(l2) = l2 {
if let Some((new_l1, new_l2, cline)) =
compute_chamfer(&l1, click1, self.dist1, &l2, click, self.dist2)
{
return vec![
WireModel::solid(
"chamfer_l1".into(),
entity_pts(&new_l1),
WireModel::CYAN,
false,
),
WireModel::solid(
"chamfer_l2".into(),
entity_pts(&new_l2),
WireModel::CYAN,
false,
),
WireModel::solid(
"chamfer_line".into(),
entity_pts(&cline),
WireModel::CYAN,
false,
),
];
}
}
vec![]
}
}
}
fn on_point(&mut self, _pt: Vec3) -> CmdResult {
CmdResult::NeedPoint
}
fn on_enter(&mut self) -> CmdResult {
CmdResult::Cancel
}
fn on_escape(&mut self) -> CmdResult {
CmdResult::Cancel
}
}
+12
View File
@@ -0,0 +1,12 @@
// Group tool — ribbon definition.
use crate::modules::{IconKind, ModuleEvent, ToolDef};
pub fn tool() -> ToolDef {
ToolDef {
id: "GROUP",
label: "Group",
icon: IconKind::Svg(include_bytes!("../../../../assets/icons/group.svg")),
event: ModuleEvent::Command("GROUP".to_string()),
}
}
+106
View File
@@ -0,0 +1,106 @@
// 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 → mirrors
use acadrust::Handle;
use glam::Vec3;
use crate::command::{CadCommand, CmdResult, EntityTransform};
use crate::modules::{IconKind, ModuleEvent, ToolDef};
use crate::scene::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),
}
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
),
}
}
fn on_point(&mut self, pt: Vec3) -> CmdResult {
match &self.step {
Step::P1 => {
self.step = Step::P2(pt);
CmdResult::NeedPoint
}
Step::P2(p1) => {
let p1 = *p1;
CmdResult::TransformSelected(
self.handles.clone(),
EntityTransform::Mirror { p1, p2: pt },
)
}
}
}
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::P2(p1) = &self.step else {
return vec![];
};
let p1 = *p1;
// Mirrored ghosts of all selected objects.
let mut out: Vec<WireModel> = self
.wire_models
.iter()
.map(|w| w.mirrored(p1, pt))
.collect();
// Mirror-axis line (rubber-band).
out.push(WireModel::solid(
"rubber_band".into(),
vec![[p1.x, p1.y, p1.z], [pt.x, pt.y, pt.z]],
WireModel::CYAN,
false,
));
out
}
}
+15
View File
@@ -0,0 +1,15 @@
pub mod array;
pub mod copy;
pub mod delete;
pub mod explode;
mod extend;
pub mod fillet;
pub mod group;
pub mod mirror;
pub mod offset;
pub mod rotate;
pub mod scale;
pub mod stretch;
pub mod translate;
pub mod trim;
pub mod ungroup;
+573
View File
@@ -0,0 +1,573 @@
// 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 acadrust::entities::LwVertex;
use acadrust::entities::{Arc as ArcEnt, Circle as CircleEnt, Line as LineEnt, LwPolyline};
use acadrust::{EntityType, Handle};
use glam::Vec3;
use crate::command::{CadCommand, CmdResult};
use crate::modules::home::defaults;
use crate::modules::{IconKind, ModuleEvent, ToolDef};
use crate::scene::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))
}
// ── 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),
_ => None,
}
}
// ── 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.to_radians());
let a1 = norm_rad(a.end_angle.to_radians());
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),
_ => 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 {
Distance,
SelectObject {
dist: f64,
},
PickSide {
dist: f64,
#[allow(dead_code)]
handle: Handle,
entity: EntityType,
},
}
pub struct OffsetCommand {
default_dist: f64,
step: Step,
all_entities: Vec<EntityType>,
}
impl OffsetCommand {
pub fn new(last_dist: f32, all_entities: Vec<EntityType>) -> Self {
Self {
default_dist: last_dist as f64,
step: Step::Distance,
all_entities,
}
}
}
impl CadCommand for OffsetCommand {
fn name(&self) -> &'static str {
"OFFSET"
}
fn prompt(&self) -> String {
match &self.step {
Step::Distance => format!(
"OFFSET Specify offset distance <{:.4}>:",
self.default_dist
),
Step::SelectObject { dist } => {
format!("OFFSET Select object to offset [dist={dist:.4}]:")
}
Step::PickSide { dist, .. } => {
format!("OFFSET Specify side to offset [dist={dist:.4}]:")
}
}
}
fn wants_text_input(&self) -> bool {
matches!(self.step, Step::Distance)
}
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
if let Step::Distance = self.step {
let dist = if text.trim().is_empty() {
self.default_dist
} else {
text.trim()
.replace(',', ".")
.parse::<f64>()
.unwrap_or(self.default_dist)
.abs()
.max(1e-9)
};
defaults::set_offset_dist(dist as f32);
self.default_dist = dist;
self.step = Step::SelectObject { dist };
}
None
}
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() {
return CmdResult::NeedPoint;
}
let dist = match &self.step {
Step::SelectObject { dist } => *dist,
_ => 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 {
dist,
handle,
entity: e,
};
CmdResult::NeedPoint
}
_ => CmdResult::NeedPoint,
}
}
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 (dist, entity) = match &self.step {
Step::PickSide { dist, entity, .. } => (*dist, entity.clone()),
_ => return CmdResult::NeedPoint,
};
match compute_offset(&entity, dist, pt) {
Some(new_entity) => CmdResult::CommitAndExit(new_entity),
None => CmdResult::NeedPoint,
}
}
fn on_preview_wires(&mut self, pt: Vec3) -> Vec<WireModel> {
let (dist, entity) = match &self.step {
Step::PickSide { dist, entity, .. } => (*dist, entity.clone()),
_ => return vec![],
};
if let Some(result) = compute_offset(&entity, dist, 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
}
}
+190
View File
@@ -0,0 +1,190 @@
// 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, EntityTransform};
use crate::modules::home::defaults;
use crate::modules::{IconKind, ModuleEvent, ToolDef};
use crate::scene::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 {
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;
// Object ghosts rotated to new angle.
let mut out: Vec<WireModel> = self
.wire_models
.iter()
.map(|w| w.rotated(center, angle_rad))
.collect();
// Arc rubber-band showing the rotation sweep.
let r = center.distance(pt).max(0.3);
let mut end = dest_angle;
if end < ref_angle {
end += std::f32::consts::TAU;
}
let span = end - ref_angle;
let segs = ((span.abs() / std::f32::consts::TAU) * 48.0)
.ceil()
.max(4.0) as u32;
let mut arc_pts: Vec<[f32; 3]> = (0..=segs)
.map(|i| {
let a = ref_angle + span * (i as f32 / segs as f32);
[center.x + r * a.cos(), center.y + r * a.sin(), center.z]
})
.collect();
arc_pts.push([center.x, center.y, center.z]);
arc_pts.push([
center.x + r * ref_angle.cos(),
center.y + r * ref_angle.sin(),
center.z,
]);
out.push(WireModel::solid(
"rubber_band".into(),
arc_pts,
WireModel::CYAN,
false,
));
out
}
}
+167
View File
@@ -0,0 +1,167 @@
// Scale tool — ribbon definition + interactive command.
//
// Command: SCALE (SC)
// Requires at least one entity selected.
// Step 1: pick base (scale center)
// Step 2: pick reference point (defines reference distance)
// Step 3: pick new point (new distance = scale factor * objects)
use acadrust::Handle;
use glam::Vec3;
use crate::command::{CadCommand, CmdResult, EntityTransform};
use crate::modules::home::defaults;
use crate::modules::{IconKind, ModuleEvent, ToolDef};
use crate::scene::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,
Ref { base: Vec3 },
New { 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(),
}
}
}
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::Ref { .. } => {
"SCALE Specify reference point (or type scale factor directly):".into()
}
Step::New { ref_dist, .. } => format!(
"SCALE Specify new point or type scale factor <{:.4}> [ref={:.3}]:",
self.default_factor, ref_dist
),
}
}
fn on_point(&mut self, pt: Vec3) -> CmdResult {
match &self.step {
Step::Base => {
self.step = Step::Ref { base: pt };
CmdResult::NeedPoint
}
Step::Ref { base } => {
let base = *base;
let ref_dist = base.distance(pt).max(1e-6);
self.step = Step::New { base, ref_dist };
CmdResult::NeedPoint
}
Step::New { base, ref_dist } => {
let base = *base;
let new_dist = base.distance(pt).max(1e-6);
let factor = new_dist / *ref_dist;
defaults::set_scale_factor(factor);
CmdResult::TransformSelected(
self.handles.clone(),
EntityTransform::Scale {
center: base,
factor,
},
)
}
}
}
fn on_enter(&mut self) -> CmdResult {
// At New-point step: Enter uses the stored default factor applied from base.
if let Step::New { base, .. } = &self.step {
let base = *base;
let factor = self.default_factor;
return CmdResult::TransformSelected(
self.handles.clone(),
EntityTransform::Scale {
center: base,
factor,
},
);
}
CmdResult::Cancel
}
fn on_escape(&mut self) -> CmdResult {
CmdResult::Cancel
}
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
if let Step::New { base, .. } | Step::Ref { base } = &self.step {
let factor: f32 = text.trim().replace(',', ".").parse().ok()?;
if factor > 0.0 {
let base = *base;
defaults::set_scale_factor(factor);
return Some(CmdResult::TransformSelected(
self.handles.clone(),
EntityTransform::Scale {
center: base,
factor,
},
));
}
}
None
}
fn on_preview_wires(&mut self, pt: Vec3) -> Vec<WireModel> {
let (base, factor) = match &self.step {
Step::Ref { base } => {
// Reference pick: just show rubber-band line, no scale yet.
return vec![WireModel::solid(
"rubber_band".into(),
vec![[base.x, base.y, base.z], [pt.x, pt.y, pt.z]],
WireModel::CYAN,
false,
)];
}
Step::New { base, ref_dist } => {
let f = base.distance(pt).max(1e-6) / ref_dist;
(*base, f)
}
_ => 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
}
}
+109
View File
@@ -0,0 +1,109 @@
// Stretch tool — ribbon definition + interactive command.
//
// Command: STRETCH (SS)
// STRETCH: Moves endpoints/vertices that lie within a crossing
// window while leaving the rest of the object fixed.
// Simplified implementation: works like MOVE on the full selected set
// because we don't yet have a crossing-window selector in the viewport.
// Step 1: pick base point
// Step 2: pick new point → translates all selected entities by (new - base)
use acadrust::Handle;
use glam::Vec3;
use crate::command::{CadCommand, CmdResult, EntityTransform};
use crate::modules::{IconKind, ModuleEvent, ToolDef};
use crate::scene::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 {
Base,
Target(Vec3),
}
pub struct StretchCommand {
handles: Vec<Handle>,
step: Step,
}
impl StretchCommand {
pub fn new(handles: Vec<Handle>) -> Self {
Self {
handles,
step: Step::Base,
}
}
}
impl CadCommand for StretchCommand {
fn name(&self) -> &'static str {
"STRETCH"
}
fn prompt(&self) -> String {
match &self.step {
Step::Base => format!(
"STRETCH Specify base point [{} objects]:",
self.handles.len()
),
Step::Target(base) => format!(
"STRETCH Specify new point [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_mouse_move(&mut self, pt: Vec3) -> Option<WireModel> {
if let Step::Target(base) = &self.step {
Some(WireModel {
name: "rubber_band".into(),
points: vec![[base.x, base.y, base.z], [pt.x, pt.y, pt.z]],
color: WireModel::CYAN,
selected: false,
pattern_length: 0.0,
pattern: [0.0; 8],
line_weight_px: 1.0,
snap_pts: vec![],
tangent_geoms: vec![],
key_vertices: vec![],
})
} else {
None
}
}
}
+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::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
}
}
+883
View File
@@ -0,0 +1,883 @@
// Trim / Extend — ribbon definitions + full command implementations.
//
// TRIM (TR): Click the segment you want to remove. The command finds all
// intersections of that entity with every other entity and trims
// out the clicked interval. Stays active — click more segments,
// press Enter to finish.
//
// EXTEND (EX): Click near one end of an entity. The command extends that
// endpoint to the nearest intersecting boundary. Stays active.
use std::f64::consts::TAU;
use acadrust::entities::{Arc as ArcEnt, Line as LineEnt};
use acadrust::types::Vector3;
use acadrust::{EntityType, Handle};
use glam::Vec3;
use crate::command::{CadCommand, CmdResult};
use crate::modules::IconKind;
use crate::scene::wire_model::WireModel;
// ── Dropdown constants ─────────────────────────────────────────────────────
pub const DROPDOWN_ID: &str = "trim_extend";
pub const ICON: IconKind = IconKind::Svg(include_bytes!("../../../../assets/icons/trim.svg"));
pub const DROPDOWN_ITEMS: &[(&str, &str, IconKind)] = &[
(
"TRIM",
"Trim",
IconKind::Svg(include_bytes!("../../../../assets/icons/trim.svg")),
),
(
"EXTEND",
"Extend",
IconKind::Svg(include_bytes!("../../../../assets/icons/extend.svg")),
),
];
// ══════════════════════════════════════════════════════════════════════════
// Geometry helpers
// ══════════════════════════════════════════════════════════════════════════
/// Normalize angle to [0, 2π).
fn norm(a: f64) -> f64 {
((a % TAU) + TAU) % TAU
}
/// Is angle `a` within the arc from `s` to `e` (CCW, radians)?
fn in_arc(a: f64, s: f64, e: f64) -> bool {
let (a, s, e) = (norm(a), norm(s), norm(e));
if (e - s).abs() < 1e-9 || (e - s - TAU).abs() < 1e-9 {
return true;
}
if s <= e {
a >= s - 1e-9 && a <= e + 1e-9
} else {
a >= s - 1e-9 || a <= e + 1e-9
}
}
/// Parametric t ∈ [0,1] on arc (a0→a1 CCW) for angle `a`.
fn arc_t(a: f64, a0: f64, a1: f64) -> f64 {
let span = {
let s = norm(a1) - norm(a0);
if s <= 0.0 {
s + TAU
} else {
s
}
};
let da = {
let d = norm(a) - norm(a0);
if d < 0.0 {
d + TAU
} else {
d
}
};
(da / span).clamp(0.0, 1.0)
}
/// Intersect infinite lines (p+t·d) and (q+u·e). Returns (t, u).
fn ll(
px: f64,
py: f64,
dx: f64,
dy: f64,
qx: f64,
qy: f64,
ex: f64,
ey: f64,
) -> Option<(f64, f64)> {
let det = dx * ey - dy * ex;
if det.abs() < 1e-10 {
return None;
}
let t = ((qx - px) * ey - (qy - py) * ex) / det;
let u = ((qx - px) * dy - (qy - py) * dx) / det;
Some((t, u))
}
/// Intersect infinite line (p+t·d) with circle (cx,cy,r). Returns t values.
fn lc(px: f64, py: f64, dx: f64, dy: f64, cx: f64, cy: f64, r: f64) -> Vec<f64> {
let fx = px - cx;
let fy = py - cy;
let a = dx * dx + dy * dy;
let b = 2.0 * (fx * dx + fy * dy);
let c = fx * fx + fy * fy - r * r;
let disc = b * b - 4.0 * a * c;
if disc < 0.0 {
return vec![];
}
let sq = disc.sqrt();
if disc < 1e-14 {
vec![(-b) / (2.0 * a)]
} else {
vec![(-b - sq) / (2.0 * a), (-b + sq) / (2.0 * a)]
}
}
/// Circle-circle intersection: angles on circle 1 where they meet.
fn cc_angles(cx1: f64, cy1: f64, r1: f64, cx2: f64, cy2: f64, r2: f64) -> Vec<f64> {
let d = ((cx2 - cx1).powi(2) + (cy2 - cy1).powi(2)).sqrt();
if d < 1e-9 || d > r1 + r2 + 1e-9 || d < (r1 - r2).abs() - 1e-9 {
return vec![];
}
let a = (r1 * r1 - r2 * r2 + d * d) / (2.0 * d);
let h2 = r1 * r1 - a * a;
if h2 < 0.0 {
return vec![];
}
let h = h2.sqrt();
let mx = cx1 + a * (cx2 - cx1) / d;
let my = cy1 + a * (cy2 - cy1) / d;
let px = h * (cy2 - cy1) / d;
let py = -h * (cx2 - cx1) / d;
let a1 = ((my + py) - cy1).atan2((mx + px) - cx1);
let a2 = ((my - py) - cy1).atan2((mx - px) - cx1);
if h < 1e-9 {
vec![a1]
} else {
vec![a1, a2]
}
}
// ── Boundary geometry ─────────────────────────────────────────────────────
enum Geo {
Line {
handle: Handle,
p1: [f64; 2],
p2: [f64; 2],
},
Arc {
handle: Handle,
cx: f64,
cy: f64,
r: f64,
a0: f64,
a1: f64,
},
Circle {
handle: Handle,
cx: f64,
cy: f64,
r: f64,
},
}
fn build_geos(entities: &[EntityType]) -> Vec<Geo> {
entities
.iter()
.filter_map(|e| {
let h = e.common().handle;
match e {
EntityType::Line(l) => Some(Geo::Line {
handle: h,
p1: [l.start.x, l.start.y],
p2: [l.end.x, l.end.y],
}),
EntityType::Arc(a) => Some(Geo::Arc {
handle: h,
cx: a.center.x,
cy: a.center.y,
r: a.radius,
a0: a.start_angle.to_radians(),
a1: a.end_angle.to_radians(),
}),
EntityType::Circle(c) => Some(Geo::Circle {
handle: h,
cx: c.center.x,
cy: c.center.y,
r: c.radius,
}),
_ => None,
}
})
.collect()
}
// ── Intersection helpers ──────────────────────────────────────────────────
/// Sorted, deduped t-params ∈ [0,1] where LINE segment (ax,ay)→(bx,by) intersects boundaries.
fn line_seg_ts(ax: f64, ay: f64, bx: f64, by: f64, target: Handle, geos: &[Geo]) -> Vec<f64> {
let (dx, dy) = (bx - ax, by - ay);
let mut ts = vec![];
for geo in geos {
match geo {
Geo::Line { handle, p1, p2 } => {
if *handle == target {
continue;
}
let (ex, ey) = (p2[0] - p1[0], p2[1] - p1[1]);
if let Some((t, u)) = ll(ax, ay, dx, dy, p1[0], p1[1], ex, ey) {
if (-1e-9..=1.0 + 1e-9).contains(&u) && (-1e-9..=1.0 + 1e-9).contains(&t) {
ts.push(t.clamp(0.0, 1.0));
}
}
}
Geo::Arc {
handle,
cx,
cy,
r,
a0,
a1,
} => {
if *handle == target {
continue;
}
for t in lc(ax, ay, dx, dy, *cx, *cy, *r) {
if !(-1e-9..=1.0 + 1e-9).contains(&t) {
continue;
}
let ix = ax + t * dx;
let iy = ay + t * dy;
if in_arc((iy - cy).atan2(ix - cx), *a0, *a1) {
ts.push(t.clamp(0.0, 1.0));
}
}
}
Geo::Circle { handle, cx, cy, r } => {
if *handle == target {
continue;
}
for t in lc(ax, ay, dx, dy, *cx, *cy, *r) {
if (-1e-9..=1.0 + 1e-9).contains(&t) {
ts.push(t.clamp(0.0, 1.0));
}
}
}
}
}
ts.sort_by(|a, b| a.partial_cmp(b).unwrap());
ts.dedup_by(|a, b| (*a - *b).abs() < 1e-6);
ts
}
/// Sorted, deduped t-params ∈ [0,1] where ARC (cx,cy,r,a0→a1) intersects boundaries.
fn arc_seg_ts(
cx: f64,
cy: f64,
r: f64,
a0: f64,
a1: f64,
target: Handle,
geos: &[Geo],
) -> Vec<f64> {
let mut ts = vec![];
for geo in geos {
let angles: Vec<f64> = match geo {
Geo::Line { handle, p1, p2 } => {
if *handle == target {
continue;
}
let (dx, dy) = (p2[0] - p1[0], p2[1] - p1[1]);
lc(p1[0], p1[1], dx, dy, cx, cy, r)
.into_iter()
.filter(|&u| (-1e-9..=1.0 + 1e-9).contains(&u))
.map(|u| (p1[1] + u * dy - cy).atan2(p1[0] + u * dx - cx))
.collect()
}
Geo::Arc {
handle,
cx: cx2,
cy: cy2,
r: r2,
a0: a02,
a1: a12,
} => {
if *handle == target {
continue;
}
cc_angles(cx, cy, r, *cx2, *cy2, *r2)
.into_iter()
.filter(|&a| in_arc(a, *a02, *a12))
.collect()
}
Geo::Circle {
handle,
cx: cx2,
cy: cy2,
r: r2,
} => {
if *handle == target {
continue;
}
cc_angles(cx, cy, r, *cx2, *cy2, *r2)
}
};
for a in angles {
if in_arc(a, a0, a1) {
ts.push(arc_t(a, a0, a1));
}
}
}
ts.sort_by(|a, b| a.partial_cmp(b).unwrap());
ts.dedup_by(|a, b| (*a - *b).abs() < 1e-6);
ts
}
// ── Trim helpers ──────────────────────────────────────────────────────────
/// Remove the t-interval containing `t_click` from sorted ts. Returns surviving pieces.
fn trim_intervals(ts: &[f64], t_click: f64) -> Vec<(f64, f64)> {
let mut bounds = vec![0.0f64];
bounds.extend_from_slice(ts);
bounds.push(1.0);
bounds.dedup_by(|a, b| (*a - *b).abs() < 1e-6);
let remove = bounds
.windows(2)
.position(|w| t_click >= w[0] - 1e-6 && t_click <= w[1] + 1e-6);
bounds
.windows(2)
.enumerate()
.filter(|(idx, _)| Some(*idx) != remove)
.filter(|(_, w)| (w[1] - w[0]) > 1e-6)
.map(|(_, w)| (w[0], w[1]))
.collect()
}
fn lerp2(p1: [f64; 2], p2: [f64; 2], t: f64) -> [f64; 2] {
[p1[0] + t * (p2[0] - p1[0]), p1[1] + t * (p2[1] - p1[1])]
}
/// Trim a Line entity. Returns the surviving line segments.
fn trim_line(orig: &LineEnt, ts: &[f64], t_click: f64) -> Vec<EntityType> {
let p1 = [orig.start.x, orig.start.y];
let p2 = [orig.end.x, orig.end.y];
let z = orig.start.z;
trim_intervals(ts, t_click)
.into_iter()
.filter_map(|(ta, tb)| {
let a = lerp2(p1, p2, ta);
let b = lerp2(p1, p2, tb);
if (b[0] - a[0]).hypot(b[1] - a[1]) < 1e-6 {
return None;
}
let mut l = orig.clone();
l.common.handle = Handle::NULL;
l.start = Vector3::new(a[0], a[1], z);
l.end = Vector3::new(b[0], b[1], z);
Some(EntityType::Line(l))
})
.collect()
}
/// Trim an Arc entity. Returns the surviving arc segments.
fn trim_arc(orig: &ArcEnt, ts: &[f64], t_click: f64) -> Vec<EntityType> {
let a0 = orig.start_angle.to_radians();
let a1 = orig.end_angle.to_radians();
let span = {
let s = norm(a1) - norm(a0);
if s <= 0.0 {
s + TAU
} else {
s
}
};
let angle_at = |t: f64| norm(a0) + span * t;
trim_intervals(ts, t_click)
.into_iter()
.filter_map(|(ta, tb)| {
if (tb - ta).abs() < 1e-6 {
return None;
}
let mut a = orig.clone();
a.common.handle = Handle::NULL;
a.start_angle = angle_at(ta).to_degrees();
a.end_angle = angle_at(tb).to_degrees();
Some(EntityType::Arc(a))
})
.collect()
}
// ── Extend helper ─────────────────────────────────────────────────────────
/// Extend a Line to the nearest boundary on the extended side.
/// t_click < 0.5 → extend start (look for t < 0); t_click ≥ 0.5 → extend end (t > 1).
fn extend_line(orig: &LineEnt, t_click: f64, geos: &[Geo]) -> Option<EntityType> {
let ax = orig.start.x;
let ay = orig.start.y;
let bx = orig.end.x;
let by = orig.end.y;
let (dx, dy) = (bx - ax, by - ay);
let target = orig.common.handle;
let extend_end = t_click >= 0.5;
let mut best_t = if extend_end {
f64::INFINITY
} else {
f64::NEG_INFINITY
};
for geo in geos {
match geo {
Geo::Line { handle, p1, p2 } => {
if *handle == target {
continue;
}
let (ex, ey) = (p2[0] - p1[0], p2[1] - p1[1]);
if let Some((t, u)) = ll(ax, ay, dx, dy, p1[0], p1[1], ex, ey) {
if !(-1e-9..=1.0 + 1e-9).contains(&u) {
continue;
}
if extend_end && t > 1.0 + 1e-6 && t < best_t {
best_t = t;
}
if !extend_end && t < -1e-6 && t > best_t {
best_t = t;
}
}
}
Geo::Arc {
handle,
cx,
cy,
r,
a0,
a1,
} => {
if *handle == target {
continue;
}
for t in lc(ax, ay, dx, dy, *cx, *cy, *r) {
let ix = ax + t * dx;
let iy = ay + t * dy;
if !in_arc((iy - cy).atan2(ix - cx), *a0, *a1) {
continue;
}
if extend_end && t > 1.0 + 1e-6 && t < best_t {
best_t = t;
}
if !extend_end && t < -1e-6 && t > best_t {
best_t = t;
}
}
}
Geo::Circle { handle, cx, cy, r } => {
if *handle == target {
continue;
}
for t in lc(ax, ay, dx, dy, *cx, *cy, *r) {
if extend_end && t > 1.0 + 1e-6 && t < best_t {
best_t = t;
}
if !extend_end && t < -1e-6 && t > best_t {
best_t = t;
}
}
}
}
}
if !best_t.is_finite() {
return None;
}
let mut line = orig.clone();
line.common.handle = Handle::NULL;
let new_x = ax + best_t * dx;
let new_y = ay + best_t * dy;
if extend_end {
line.end = Vector3::new(new_x, new_y, orig.end.z);
} else {
line.start = Vector3::new(new_x, new_y, orig.start.z);
}
Some(EntityType::Line(line))
}
// ── Point-generation helpers ──────────────────────────────────────────────
const DIM_RED: [f32; 4] = [1.0, 0.3, 0.3, 0.6];
fn line_pts(l: &LineEnt) -> Vec<[f32; 3]> {
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],
]
}
fn arc_pts(cx: f64, cy: f64, r: f64, a0: f64, a1: f64, y: f64) -> Vec<[f32; 3]> {
let span = {
let s = norm(a1) - norm(a0);
if s <= 0.0 {
s + TAU
} else {
s
}
};
let steps = (span.abs() * 20.0).ceil().max(4.0) as usize;
(0..=steps)
.map(|i| {
let ang = norm(a0) + span * (i as f64 / steps as f64);
[
(cx + r * ang.cos()) as f32,
y as f32,
(cy + r * ang.sin()) as f32,
]
})
.collect()
}
fn entity_pts(e: &EntityType) -> Vec<[f32; 3]> {
match e {
EntityType::Line(l) => line_pts(l),
EntityType::Arc(a) => arc_pts(
a.center.x,
a.center.y,
a.radius,
a.start_angle.to_radians(),
a.end_angle.to_radians(),
a.center.y,
),
_ => vec![],
}
}
// ══════════════════════════════════════════════════════════════════════════
// TrimCommand
// ══════════════════════════════════════════════════════════════════════════
pub struct TrimCommand {
all_entities: Vec<EntityType>,
geos: Vec<Geo>,
}
impl TrimCommand {
pub fn new(all_entities: Vec<EntityType>) -> Self {
let geos = build_geos(&all_entities);
Self { all_entities, geos }
}
}
impl CadCommand for TrimCommand {
fn name(&self) -> &'static str {
"TRIM"
}
fn prompt(&self) -> String {
"TRIM Click segment to remove [Enter=done]:".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;
}
let entity = self
.all_entities
.iter()
.find(|e| e.common().handle == handle);
let result: Option<Vec<EntityType>> = match entity {
Some(EntityType::Line(l)) => {
let ax = l.start.x;
let ay = l.start.y;
let bx = l.end.x;
let by = l.end.y;
let ts = line_seg_ts(ax, ay, bx, by, handle, &self.geos);
if ts.is_empty() {
return CmdResult::NeedPoint;
}
let dx = bx - ax;
let dy = by - ay;
let len2 = dx * dx + dy * dy;
let t_click = if len2 > 1e-12 {
((pt.x as f64 - ax) * dx + (pt.y as f64 - ay) * dy) / len2
} else {
0.5
};
Some(trim_line(l, &ts, t_click))
}
Some(EntityType::Arc(a)) => {
let cx = a.center.x;
let cy = a.center.y;
let a0 = a.start_angle.to_radians();
let a1 = a.end_angle.to_radians();
let ts = arc_seg_ts(cx, cy, a.radius, a0, a1, handle, &self.geos);
if ts.is_empty() {
return CmdResult::NeedPoint;
}
let click_angle = (pt.y as f64 - cy).atan2(pt.x as f64 - cx);
let t_click = arc_t(click_angle, a0, a1);
Some(trim_arc(a, &ts, t_click))
}
_ => None,
};
if let Some(new_entities) = result {
// Snapshot is updated in on_entity_replaced once we know the real handles.
// Pre-stage: remove old entry now so geos exclude it immediately.
if let Some(pos) = self
.all_entities
.iter()
.position(|e| e.common().handle == handle)
{
self.all_entities.remove(pos);
// Add pieces with NULL handles as geometry-only placeholders.
self.all_entities.extend(new_entities.clone());
self.geos = build_geos(&self.all_entities);
}
CmdResult::ReplaceEntity(handle, new_entities)
} else {
self.command_line_hint();
CmdResult::NeedPoint
}
}
fn on_entity_replaced(&mut self, _old: Handle, new_handles: &[acadrust::Handle]) {
// The last new_handles.len() entries in all_entities are the trimmed pieces
// that were appended with NULL handles. Assign their real document handles.
let start = self.all_entities.len().saturating_sub(new_handles.len());
for (e, &h) in self.all_entities[start..]
.iter_mut()
.zip(new_handles.iter())
{
match e {
EntityType::Line(l) => l.common.handle = h,
EntityType::Arc(a) => a.common.handle = h,
_ => {}
}
}
self.geos = build_geos(&self.all_entities);
}
fn on_hover_entity(&mut self, handle: Handle, pt: Vec3) -> Vec<WireModel> {
if handle.is_null() {
return vec![];
}
let entity = self
.all_entities
.iter()
.find(|e| e.common().handle == handle);
match entity {
Some(EntityType::Line(l)) => {
let ax = l.start.x;
let ay = l.start.y;
let bx = l.end.x;
let by = l.end.y;
let ts = line_seg_ts(ax, ay, bx, by, handle, &self.geos);
if ts.is_empty() {
return vec![];
}
let dx = bx - ax;
let dy = by - ay;
let len2 = dx * dx + dy * dy;
let t_click = if len2 > 1e-12 {
((pt.x as f64 - ax) * dx + (pt.y as f64 - ay) * dy) / len2
} else {
0.5
};
let survivors = trim_line(l, &ts, t_click);
let p1 = [l.start.x as f32, l.start.y as f32, l.start.y as f32];
let p2 = [l.end.x as f32, l.end.y as f32, l.end.y as f32];
let removed = WireModel::solid("trim_rm".into(), vec![p1, p2], DIM_RED, false);
let mut out = vec![removed];
for (i, e) in survivors.iter().enumerate() {
let pts = entity_pts(e);
out.push(WireModel::solid(
format!("trim_keep_{i}"),
pts,
WireModel::CYAN,
false,
));
}
out
}
Some(EntityType::Arc(a)) => {
let cx = a.center.x;
let cy = a.center.y;
let a0 = a.start_angle.to_radians();
let a1 = a.end_angle.to_radians();
let ts = arc_seg_ts(cx, cy, a.radius, a0, a1, handle, &self.geos);
if ts.is_empty() {
return vec![];
}
let click_angle = (pt.y as f64 - cy).atan2(pt.x as f64 - cx);
let t_click = arc_t(click_angle, a0, a1);
let survivors = trim_arc(a, &ts, t_click);
let orig_pts = arc_pts(cx, cy, a.radius, a0, a1, a.center.y);
let removed = WireModel::solid("trim_rm".into(), orig_pts, DIM_RED, false);
let mut out = vec![removed];
for (i, e) in survivors.iter().enumerate() {
let pts = entity_pts(e);
out.push(WireModel::solid(
format!("trim_keep_{i}"),
pts,
WireModel::CYAN,
false,
));
}
out
}
_ => vec![],
}
}
fn on_point(&mut self, _pt: Vec3) -> CmdResult {
CmdResult::NeedPoint
}
fn on_enter(&mut self) -> CmdResult {
CmdResult::Cancel
}
fn on_escape(&mut self) -> CmdResult {
CmdResult::Cancel
}
}
impl TrimCommand {
fn command_line_hint(&self) {}
}
// ══════════════════════════════════════════════════════════════════════════
// ExtendCommand
// ══════════════════════════════════════════════════════════════════════════
pub struct ExtendCommand {
all_entities: Vec<EntityType>,
geos: Vec<Geo>,
/// (old_handle, new_entity_with_updated_geometry) — set in on_entity_pick,
/// consumed in on_entity_replaced to patch the snapshot with both new handle + geometry.
pending_replace: Option<(Handle, EntityType)>,
}
impl ExtendCommand {
pub fn new(all_entities: Vec<EntityType>) -> Self {
let geos = build_geos(&all_entities);
Self {
all_entities,
geos,
pending_replace: None,
}
}
}
impl CadCommand for ExtendCommand {
fn name(&self) -> &'static str {
"EXTEND"
}
fn prompt(&self) -> String {
"EXTEND Click near end of object to extend [Enter=done]:".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;
}
let entity = self
.all_entities
.iter()
.find(|e| e.common().handle == handle);
let result: Option<EntityType> = match entity {
Some(EntityType::Line(l)) => {
let ax = l.start.x;
let ay = l.start.y;
let bx = l.end.x;
let by = l.end.y;
let dx = bx - ax;
let dy = by - ay;
let len2 = dx * dx + dy * dy;
let t_click = if len2 > 1e-12 {
((pt.x as f64 - ax) * dx + (pt.y as f64 - ay) * dy) / len2
} else {
0.5
};
extend_line(l, t_click, &self.geos)
}
_ => None,
};
if let Some(new_entity) = result {
// Save the extended entity so on_entity_replaced can patch the snapshot
// with both the new geometry and the real document handle.
self.pending_replace = Some((handle, new_entity.clone()));
CmdResult::ReplaceEntity(handle, vec![new_entity])
} else {
CmdResult::NeedPoint
}
}
fn on_entity_replaced(&mut self, old: Handle, new_handles: &[acadrust::Handle]) {
if let (Some(&new_handle), Some((pending_old, mut new_entity))) =
(new_handles.first(), self.pending_replace.take())
{
if pending_old == old {
// Update the snapshot entry: replace geometry + assign real handle.
if let EntityType::Line(l) = &mut new_entity {
l.common.handle = new_handle;
}
if let Some(pos) = self
.all_entities
.iter()
.position(|e| e.common().handle == old)
{
self.all_entities[pos] = new_entity;
}
self.geos = build_geos(&self.all_entities);
}
}
}
fn on_hover_entity(&mut self, handle: Handle, pt: Vec3) -> Vec<WireModel> {
if handle.is_null() {
return vec![];
}
let entity = self
.all_entities
.iter()
.find(|e| e.common().handle == handle);
if let Some(EntityType::Line(l)) = entity {
let ax = l.start.x;
let ay = l.start.y;
let bx = l.end.x;
let by = l.end.y;
let dx = bx - ax;
let dy = by - ay;
let len2 = dx * dx + dy * dy;
let t_click = if len2 > 1e-12 {
((pt.x as f64 - ax) * dx + (pt.y as f64 - ay) * dy) / len2
} else {
0.5
};
if let Some(extended) = extend_line(l, t_click, &self.geos) {
let pts = entity_pts(&extended);
return vec![WireModel::solid(
"extend_prev".into(),
pts,
WireModel::CYAN,
false,
)];
}
}
vec![]
}
fn on_point(&mut self, _pt: Vec3) -> CmdResult {
CmdResult::NeedPoint
}
fn on_enter(&mut self) -> CmdResult {
CmdResult::Cancel
}
fn on_escape(&mut self) -> CmdResult {
CmdResult::Cancel
}
}
+12
View File
@@ -0,0 +1,12 @@
// Ungroup tool — ribbon definition.
use crate::modules::{IconKind, ModuleEvent, ToolDef};
pub fn tool() -> ToolDef {
ToolDef {
id: "UNGROUP",
label: "Ungroup",
icon: IconKind::Svg(include_bytes!("../../../../assets/icons/ungroup.svg")),
event: ModuleEvent::Command("UNGROUP".to_string()),
}
}