forked from 1b-agent/opencadstudio-upstream
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:
1 parent
c6152ef705
commit
3666c7fa69
85 files changed
+141
-141
No files matched your search
@@ -0,0 +1,11 @@
|
||||
use crate::modules::{IconKind, ModuleEvent, ToolDef};
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn tool() -> ToolDef {
|
||||
ToolDef {
|
||||
id: "ABOUT",
|
||||
label: "About",
|
||||
icon: IconKind::Svg(include_bytes!("../../../assets/icons/about.svg")),
|
||||
event: ModuleEvent::Command("ABOUT".to_string()),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
use crate::modules::{IconKind, ModuleEvent, ToolDef};
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn tool() -> ToolDef {
|
||||
ToolDef {
|
||||
id: "CHANGELOG",
|
||||
label: "Changelog",
|
||||
icon: IconKind::Svg(include_bytes!("../../../assets/icons/changelog.svg")),
|
||||
event: ModuleEvent::Command("CHANGELOG".to_string()),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
use crate::modules::{IconKind, ModuleEvent, ToolDef};
|
||||
|
||||
pub fn tool() -> ToolDef {
|
||||
ToolDef {
|
||||
id: "COPYCLIP",
|
||||
label: "Copy",
|
||||
icon: IconKind::Svg(include_bytes!("../../../../assets/icons/copy_clip.svg")),
|
||||
event: ModuleEvent::Command("COPYCLIP".to_string()),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
use crate::modules::{IconKind, ModuleEvent, ToolDef};
|
||||
|
||||
pub fn tool() -> ToolDef {
|
||||
ToolDef {
|
||||
id: "CUTCLIP",
|
||||
label: "Cut",
|
||||
icon: IconKind::Svg(include_bytes!("../../../../assets/icons/cut.svg")),
|
||||
event: ModuleEvent::Command("CUTCLIP".to_string()),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
pub mod copy_clip;
|
||||
pub mod cut;
|
||||
pub mod paste;
|
||||
@@ -0,0 +1,64 @@
|
||||
// Paste tool — ribbon definition + CadCommand implementation.
|
||||
|
||||
use crate::modules::{IconKind, ModuleEvent, ToolDef};
|
||||
|
||||
pub fn tool() -> ToolDef {
|
||||
ToolDef {
|
||||
id: "PASTECLIP",
|
||||
label: "Paste",
|
||||
icon: IconKind::Svg(include_bytes!("../../../../assets/icons/paste.svg")),
|
||||
event: ModuleEvent::Command("PASTECLIP".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
// ── CadCommand implementation ─────────────────────────────────────────────
|
||||
|
||||
use acadrust::Handle;
|
||||
use glam::Vec3;
|
||||
|
||||
use crate::command::{CadCommand, CmdResult};
|
||||
use crate::scene::model::wire_model::WireModel;
|
||||
|
||||
pub struct PasteCommand {
|
||||
/// Wire models of the clipboard entities (used for preview).
|
||||
wires: Vec<WireModel>,
|
||||
/// Centroid of the clipboard entities (offset origin for translation).
|
||||
centroid: Vec3,
|
||||
}
|
||||
|
||||
impl PasteCommand {
|
||||
pub fn new(wires: Vec<WireModel>, centroid: Vec3) -> Self {
|
||||
Self { wires, centroid }
|
||||
}
|
||||
}
|
||||
|
||||
impl CadCommand for PasteCommand {
|
||||
fn name(&self) -> &'static str {
|
||||
"PASTECLIP"
|
||||
}
|
||||
|
||||
fn prompt(&self) -> String {
|
||||
"PASTECLIP Pick insertion point:".into()
|
||||
}
|
||||
|
||||
fn on_point(&mut self, pt: Vec3) -> CmdResult {
|
||||
CmdResult::PasteClipboard { base_pt: pt }
|
||||
}
|
||||
|
||||
fn on_enter(&mut self) -> CmdResult {
|
||||
CmdResult::Cancel
|
||||
}
|
||||
|
||||
fn on_hover_entity(&mut self, _handle: Handle, _pt: Vec3) -> Vec<WireModel> {
|
||||
vec![]
|
||||
}
|
||||
|
||||
fn on_preview_wires(&mut self, pt: Vec3) -> Vec<WireModel> {
|
||||
let delta = pt - self.centroid;
|
||||
self.wires.iter().map(|w| w.translated(delta)).collect()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ── Autocomplete registry ─────────────────────────────────
|
||||
inventory::submit!(crate::command::CommandRegistration { names: &["PASTECLIP", "PC"] }); // PasteCommand
|
||||
@@ -0,0 +1,49 @@
|
||||
// Persistent command defaults — last-used values survive across command runs
|
||||
// within the same session. Uses thread_local! so no synchronisation needed.
|
||||
|
||||
use std::cell::Cell;
|
||||
|
||||
thread_local! {
|
||||
static CIRCLE_RADIUS: Cell<f32> = Cell::new(1.0);
|
||||
static CIRCLE_DIAM: Cell<f32> = Cell::new(2.0);
|
||||
static ROTATE_ANGLE: Cell<f32> = Cell::new(0.0); // degrees
|
||||
static SCALE_FACTOR: Cell<f32> = Cell::new(1.0);
|
||||
static OFFSET_DIST: Cell<f32> = Cell::new(1.0);
|
||||
static FILLET_RADIUS: Cell<f32> = Cell::new(1.0);
|
||||
static CHAMFER_DIST1: Cell<f32> = Cell::new(10.0);
|
||||
static CHAMFER_DIST2: Cell<f32> = Cell::new(10.0);
|
||||
static ARRAY_ROWS: Cell<f32> = Cell::new(2.0);
|
||||
static ARRAY_COLS: Cell<f32> = Cell::new(2.0);
|
||||
static ARRAY_ROW_SP: Cell<f32> = Cell::new(100.0);
|
||||
static ARRAY_COL_SP: Cell<f32> = Cell::new(100.0);
|
||||
static ARRAY_P_COUNT: Cell<f32> = Cell::new(6.0);
|
||||
static ARRAY_P_ANGLE: Cell<f32> = Cell::new(360.0); // degrees
|
||||
static ARRAY_PATH_COUNT: Cell<f32> = Cell::new(6.0);
|
||||
}
|
||||
|
||||
macro_rules! accessors {
|
||||
($get:ident, $set:ident, $var:ident) => {
|
||||
pub fn $get() -> f32 {
|
||||
$var.with(|c| c.get())
|
||||
}
|
||||
pub fn $set(v: f32) {
|
||||
$var.with(|c| c.set(v));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
accessors!(get_circle_radius, set_circle_radius, CIRCLE_RADIUS);
|
||||
accessors!(get_circle_diam, set_circle_diam, CIRCLE_DIAM);
|
||||
accessors!(get_rotate_angle, set_rotate_angle, ROTATE_ANGLE);
|
||||
accessors!(get_scale_factor, set_scale_factor, SCALE_FACTOR);
|
||||
accessors!(get_offset_dist, set_offset_dist, OFFSET_DIST);
|
||||
accessors!(get_fillet_radius, set_fillet_radius, FILLET_RADIUS);
|
||||
accessors!(get_chamfer_dist1, set_chamfer_dist1, CHAMFER_DIST1);
|
||||
accessors!(get_chamfer_dist2, set_chamfer_dist2, CHAMFER_DIST2);
|
||||
accessors!(get_array_rows, set_array_rows, ARRAY_ROWS);
|
||||
accessors!(get_array_cols, set_array_cols, ARRAY_COLS);
|
||||
accessors!(get_array_row_sp, set_array_row_sp, ARRAY_ROW_SP);
|
||||
accessors!(get_array_col_sp, set_array_col_sp, ARRAY_COL_SP);
|
||||
accessors!(get_array_p_count, set_array_p_count, ARRAY_P_COUNT);
|
||||
accessors!(get_array_p_angle, set_array_p_angle, ARRAY_P_ANGLE);
|
||||
accessors!(get_array_path_count, set_array_path_count, ARRAY_PATH_COUNT);
|
||||
@@ -0,0 +1,13 @@
|
||||
use crate::modules::{IconKind, ModuleEvent, ToolDef};
|
||||
|
||||
// Kept for the command-line route ("DONATE" cmd) and possible future ribbon
|
||||
// re-introduction; currently the Start tab invokes the same command directly.
|
||||
#[allow(dead_code)]
|
||||
pub fn tool() -> ToolDef {
|
||||
ToolDef {
|
||||
id: "DONATE",
|
||||
label: "Donate",
|
||||
icon: IconKind::Svg(include_bytes!("../../../assets/icons/donate.svg")),
|
||||
event: ModuleEvent::Command("DONATE".to_string()),
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,190 @@
|
||||
// ATTDEF command — define a block attribute (AttributeDefinition entity).
|
||||
//
|
||||
// Workflow (command-line only):
|
||||
// 1. Text: Enter attribute tag (required, no spaces)
|
||||
// 2. Text: Enter attribute prompt (optional — press Enter to use tag)
|
||||
// 3. Text: Enter default value (optional — press Enter for blank)
|
||||
// 4. Point: Click insertion point
|
||||
|
||||
use acadrust::entities::AttributeDefinition;
|
||||
use acadrust::types::Vector3;
|
||||
use acadrust::EntityType;
|
||||
use glam::Vec3;
|
||||
|
||||
use crate::command::{CadCommand, CmdResult};
|
||||
use crate::scene::model::wire_model::WireModel;
|
||||
|
||||
enum Step {
|
||||
Tag,
|
||||
Prompt {
|
||||
tag: String,
|
||||
},
|
||||
Default {
|
||||
tag: String,
|
||||
prompt: String,
|
||||
},
|
||||
Insertion {
|
||||
tag: String,
|
||||
prompt: String,
|
||||
default: String,
|
||||
},
|
||||
}
|
||||
|
||||
pub struct AttdefCommand {
|
||||
step: Step,
|
||||
/// Text height in world units.
|
||||
height: f64,
|
||||
}
|
||||
|
||||
impl AttdefCommand {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
step: Step::Tag,
|
||||
height: 0.2,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CadCommand for AttdefCommand {
|
||||
fn name(&self) -> &'static str {
|
||||
"ATTDEF"
|
||||
}
|
||||
|
||||
fn prompt(&self) -> String {
|
||||
match &self.step {
|
||||
Step::Tag => "ATTDEF Enter attribute tag (no spaces):".into(),
|
||||
Step::Prompt { tag } => format!("ATTDEF Enter prompt for '{tag}' (Enter=use tag):"),
|
||||
Step::Default { tag, .. } => {
|
||||
format!("ATTDEF Enter default value for '{tag}' (Enter=blank):")
|
||||
}
|
||||
Step::Insertion { tag, .. } => format!("ATTDEF Specify insertion point for '{tag}':"),
|
||||
}
|
||||
}
|
||||
|
||||
fn wants_text_input(&self) -> bool {
|
||||
!matches!(self.step, Step::Insertion { .. })
|
||||
}
|
||||
|
||||
fn wants_text_with_spaces(&self) -> bool {
|
||||
// Tag must be a single token (so disallow spaces there); the
|
||||
// Prompt and Default value steps are free-form text.
|
||||
matches!(self.step, Step::Prompt { .. } | Step::Default { .. })
|
||||
}
|
||||
|
||||
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
|
||||
match &self.step {
|
||||
Step::Tag => {
|
||||
let tag = text.trim().replace(' ', "_");
|
||||
if tag.is_empty() {
|
||||
return Some(CmdResult::NeedPoint);
|
||||
}
|
||||
self.step = Step::Prompt { tag };
|
||||
Some(CmdResult::NeedPoint)
|
||||
}
|
||||
Step::Prompt { tag } => {
|
||||
let tag = tag.clone();
|
||||
let prompt = if text.trim().is_empty() {
|
||||
tag.clone()
|
||||
} else {
|
||||
text.trim().to_string()
|
||||
};
|
||||
self.step = Step::Default { tag, prompt };
|
||||
Some(CmdResult::NeedPoint)
|
||||
}
|
||||
Step::Default { tag, prompt } => {
|
||||
let tag = tag.clone();
|
||||
let prompt = prompt.clone();
|
||||
let default = text.trim().to_string();
|
||||
self.step = Step::Insertion {
|
||||
tag,
|
||||
prompt,
|
||||
default,
|
||||
};
|
||||
Some(CmdResult::NeedPoint)
|
||||
}
|
||||
Step::Insertion { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn on_point(&mut self, pt: Vec3) -> CmdResult {
|
||||
if let Step::Insertion {
|
||||
tag,
|
||||
prompt,
|
||||
default,
|
||||
} = &self.step
|
||||
{
|
||||
let mut attdef = AttributeDefinition {
|
||||
tag: tag.clone(),
|
||||
prompt: prompt.clone(),
|
||||
default_value: default.clone(),
|
||||
insertion_point: Vector3::new(pt.x as f64, pt.y as f64, pt.z as f64),
|
||||
height: self.height,
|
||||
..Default::default()
|
||||
};
|
||||
attdef.common.layer = "0".into();
|
||||
CmdResult::CommitAndExit(EntityType::AttributeDefinition(attdef))
|
||||
} else {
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
}
|
||||
|
||||
fn on_enter(&mut self) -> CmdResult {
|
||||
match &self.step {
|
||||
Step::Tag => CmdResult::Cancel,
|
||||
// Treat Enter as empty text input for prompt/default steps.
|
||||
Step::Prompt { tag } => {
|
||||
let tag = tag.clone();
|
||||
self.step = Step::Default {
|
||||
tag: tag.clone(),
|
||||
prompt: tag,
|
||||
};
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
Step::Default { tag, prompt } => {
|
||||
let (tag, prompt) = (tag.clone(), prompt.clone());
|
||||
self.step = Step::Insertion {
|
||||
tag,
|
||||
prompt,
|
||||
default: String::new(),
|
||||
};
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
Step::Insertion { .. } => CmdResult::Cancel,
|
||||
}
|
||||
}
|
||||
|
||||
fn on_mouse_move(&mut self, pt: Vec3) -> Option<WireModel> {
|
||||
if !matches!(self.step, Step::Insertion { .. }) {
|
||||
return None;
|
||||
}
|
||||
// Show a small cross at the insertion point.
|
||||
let d = 0.15_f32;
|
||||
Some(WireModel {
|
||||
name: "attdef_preview".into(),
|
||||
points: vec![
|
||||
[pt.x - d, pt.y, pt.z],
|
||||
[pt.x + d, pt.y, pt.z],
|
||||
[f32::NAN, 0.0, 0.0],
|
||||
[pt.x, pt.y, pt.z - d],
|
||||
[pt.x, pt.y, pt.z + d],
|
||||
],
|
||||
color: WireModel::CYAN,
|
||||
selected: false,
|
||||
pattern_length: 0.0,
|
||||
pattern: [0.0; 8],
|
||||
line_weight_px: 1.0,
|
||||
snap_pts: vec![],
|
||||
tangent_geoms: vec![],
|
||||
aci: 0,
|
||||
key_vertices: vec![],
|
||||
aabb: WireModel::UNBOUNDED_AABB,
|
||||
plinegen: true,
|
||||
vp_scissor: None,
|
||||
fill_tris: vec![],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ── Autocomplete registry ─────────────────────────────────
|
||||
inventory::submit!(crate::command::CommandRegistration { names: &["ATTDEF"] }); // AttdefCommand
|
||||
@@ -0,0 +1,966 @@
|
||||
// Circle tool — ribbon dropdown + all OpenCADStudio circle creation methods.
|
||||
//
|
||||
// Methods:
|
||||
// CIRCLE — Center, Radius (default)
|
||||
// CIRCLE_CD — Center, Diameter
|
||||
// CIRCLE_2P — 2-Point (two endpoints of diameter)
|
||||
// CIRCLE_3P — 3-Point (circumscribed circle through 3 points)
|
||||
// CIRCLE_TTR — Tan, Tan, Radius (two tangent objects + radius)
|
||||
// CIRCLE_TTT — Tan, Tan, Tan (inscribed circle tangent to three objects)
|
||||
|
||||
use acadrust::types::Vector3;
|
||||
use acadrust::{Circle, EntityType};
|
||||
|
||||
use crate::command::{CadCommand, CmdResult, DynField, TangentObject};
|
||||
use crate::modules::draw::defaults;
|
||||
use crate::modules::IconKind;
|
||||
use crate::scene::model::wire_model::WireModel;
|
||||
use glam::Vec3;
|
||||
|
||||
const TAU: f32 = std::f32::consts::TAU;
|
||||
|
||||
// ── Per-method SVG icons ───────────────────────────────────────────────────
|
||||
|
||||
const ICON_CR: IconKind = IconKind::Svg(include_bytes!(
|
||||
"../../../../assets/icons/circle/circle_cr.svg"
|
||||
));
|
||||
const ICON_CD: IconKind = IconKind::Svg(include_bytes!(
|
||||
"../../../../assets/icons/circle/circle_cd.svg"
|
||||
));
|
||||
const ICON_2P: IconKind = IconKind::Svg(include_bytes!(
|
||||
"../../../../assets/icons/circle/circle_2p.svg"
|
||||
));
|
||||
const ICON_3P: IconKind = IconKind::Svg(include_bytes!(
|
||||
"../../../../assets/icons/circle/circle_3p.svg"
|
||||
));
|
||||
const ICON_TTR: IconKind = IconKind::Svg(include_bytes!(
|
||||
"../../../../assets/icons/circle/circle_ttr.svg"
|
||||
));
|
||||
const ICON_TTT: IconKind = IconKind::Svg(include_bytes!(
|
||||
"../../../../assets/icons/circle/circle_ttt.svg"
|
||||
));
|
||||
|
||||
// ── Dropdown metadata (used by ribbon.rs) ─────────────────────────────────
|
||||
|
||||
pub const DROPDOWN_ID: &str = "CIRCLE";
|
||||
|
||||
pub const DROPDOWN_ITEMS: &[(&str, &str, IconKind)] = &[
|
||||
("CIRCLE", "Center, Radius", ICON_CR),
|
||||
("CIRCLE_CD", "Center, Diameter", ICON_CD),
|
||||
("CIRCLE_2P", "2-Point", ICON_2P),
|
||||
("CIRCLE_3P", "3-Point", ICON_3P),
|
||||
("CIRCLE_TTR", "Tan, Tan, Radius", ICON_TTR),
|
||||
("CIRCLE_TTT", "Tan, Tan, Tan", ICON_TTT),
|
||||
];
|
||||
|
||||
/// Default icon — shown until first use (falls back to Center, Radius).
|
||||
pub const ICON: IconKind = ICON_CR;
|
||||
|
||||
// ── Shared geometry ────────────────────────────────────────────────────────
|
||||
|
||||
fn circle_wire(center: Vec3, radius: f32) -> WireModel {
|
||||
let segs = 64u32;
|
||||
let mut pts: Vec<[f32; 3]> = (0..=segs)
|
||||
.map(|i| {
|
||||
let a = (i as f32) * TAU / segs as f32;
|
||||
[
|
||||
center.x + radius * a.cos(),
|
||||
center.y + radius * a.sin(),
|
||||
center.z,
|
||||
]
|
||||
})
|
||||
.collect();
|
||||
if let Some(first) = pts.first().cloned() {
|
||||
pts.push(first);
|
||||
}
|
||||
WireModel::solid("rubber_band".into(), pts, WireModel::CYAN, false)
|
||||
}
|
||||
|
||||
fn make_circle(center: Vec3, radius: f64) -> EntityType {
|
||||
EntityType::Circle(Circle {
|
||||
center: Vector3::new(center.x as f64, center.y as f64, center.z as f64),
|
||||
radius,
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
/// Circumscribed circle through three points.
|
||||
/// Returns `None` if the points are collinear.
|
||||
fn circumcircle(a: Vec3, b: Vec3, c: Vec3) -> Option<(Vec3, f32)> {
|
||||
let ax = a.x;
|
||||
let ay = a.y;
|
||||
let bx = b.x;
|
||||
let by = b.y;
|
||||
let cx = c.x;
|
||||
let cy = c.y;
|
||||
let d = 2.0 * (ax * (by - cy) + bx * (cy - ay) + cx * (ay - by));
|
||||
if d.abs() < 1e-9 {
|
||||
return None;
|
||||
}
|
||||
let ux = ((ax * ax + ay * ay) * (by - cy)
|
||||
+ (bx * bx + by * by) * (cy - ay)
|
||||
+ (cx * cx + cy * cy) * (ay - by))
|
||||
/ d;
|
||||
let uy = ((ax * ax + ay * ay) * (cx - bx)
|
||||
+ (bx * bx + by * by) * (ax - cx)
|
||||
+ (cx * cx + cy * cy) * (bx - ax))
|
||||
/ d;
|
||||
let center = Vec3::new(ux, uy, a.z);
|
||||
Some((center, center.distance(a)))
|
||||
}
|
||||
|
||||
// ── Command: Center, Radius ────────────────────────────────────────────────
|
||||
|
||||
pub struct CircleCommand {
|
||||
step: StepCR,
|
||||
default_r: f32,
|
||||
}
|
||||
enum StepCR {
|
||||
Center,
|
||||
Radius(Vec3),
|
||||
}
|
||||
|
||||
impl CircleCommand {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
step: StepCR::Center,
|
||||
default_r: defaults::get_circle_radius(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CadCommand for CircleCommand {
|
||||
fn name(&self) -> &'static str {
|
||||
"CIRCLE"
|
||||
}
|
||||
fn prompt(&self) -> String {
|
||||
match &self.step {
|
||||
StepCR::Center => "CIRCLE Specify center point:".into(),
|
||||
StepCR::Radius(c) => format!(
|
||||
"CIRCLE Specify radius or type value <{:.4}> [center ({:.3},{:.3})]:",
|
||||
self.default_r, c.x, c.y
|
||||
),
|
||||
}
|
||||
}
|
||||
fn on_point(&mut self, pt: Vec3) -> CmdResult {
|
||||
match &self.step {
|
||||
StepCR::Center => {
|
||||
self.step = StepCR::Radius(pt);
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
StepCR::Radius(c) => {
|
||||
let r = c.distance(pt);
|
||||
defaults::set_circle_radius(r);
|
||||
CmdResult::CommitAndExit(make_circle(*c, r as f64))
|
||||
}
|
||||
}
|
||||
}
|
||||
fn on_enter(&mut self) -> CmdResult {
|
||||
if let StepCR::Radius(c) = &self.step {
|
||||
let c = *c;
|
||||
let r = self.default_r;
|
||||
return CmdResult::CommitAndExit(make_circle(c, r as f64));
|
||||
}
|
||||
CmdResult::Cancel
|
||||
}
|
||||
fn on_escape(&mut self) -> CmdResult {
|
||||
CmdResult::Cancel
|
||||
}
|
||||
fn on_mouse_move(&mut self, pt: Vec3) -> Option<WireModel> {
|
||||
if let StepCR::Radius(c) = &self.step {
|
||||
Some(circle_wire(*c, c.distance(pt)))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
|
||||
if let StepCR::Radius(c) = &self.step {
|
||||
let r: f32 = text.trim().replace(',', ".").parse().ok()?;
|
||||
if r > 0.0 {
|
||||
defaults::set_circle_radius(r);
|
||||
return Some(CmdResult::CommitAndExit(make_circle(*c, r as f64)));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
fn dyn_field(&self) -> DynField {
|
||||
match self.step {
|
||||
StepCR::Center => DynField::Point,
|
||||
StepCR::Radius(_) => DynField::Distance,
|
||||
}
|
||||
}
|
||||
|
||||
fn dyn_spec(&self) -> Option<crate::command::DynSpec> {
|
||||
use crate::command::{DynAnchor, DynFieldSpec, DynGuide, DynRole, DynSpec};
|
||||
match self.step {
|
||||
// Center is a normal first-point pick.
|
||||
StepCR::Center => None,
|
||||
// Radius: a single R value with one dotted line from the centre to
|
||||
// the cursor (no angle, no axis legs).
|
||||
StepCR::Radius(c) => Some(DynSpec {
|
||||
anchor: DynAnchor::Point(c),
|
||||
fields: vec![DynFieldSpec::new(DynRole::Radius)],
|
||||
guide: DynGuide::Radius,
|
||||
ref_point: None,
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Command: Center, Diameter ──────────────────────────────────────────────
|
||||
|
||||
pub struct CircleCDCommand {
|
||||
step: StepCR,
|
||||
default_d: f32,
|
||||
}
|
||||
|
||||
impl CircleCDCommand {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
step: StepCR::Center,
|
||||
default_d: defaults::get_circle_diam(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CadCommand for CircleCDCommand {
|
||||
fn name(&self) -> &'static str {
|
||||
"CIRCLE_CD"
|
||||
}
|
||||
fn prompt(&self) -> String {
|
||||
match &self.step {
|
||||
StepCR::Center => "CIRCLE CD Specify center point:".into(),
|
||||
StepCR::Radius(c) => format!(
|
||||
"CIRCLE CD Specify diameter or type value <{:.4}> [center ({:.3},{:.3})]:",
|
||||
self.default_d, c.x, c.y
|
||||
),
|
||||
}
|
||||
}
|
||||
fn on_point(&mut self, pt: Vec3) -> CmdResult {
|
||||
match &self.step {
|
||||
StepCR::Center => {
|
||||
self.step = StepCR::Radius(pt);
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
StepCR::Radius(c) => {
|
||||
let d = c.distance(pt) * 2.0;
|
||||
defaults::set_circle_diam(d);
|
||||
CmdResult::CommitAndExit(make_circle(*c, (d / 2.0) as f64))
|
||||
}
|
||||
}
|
||||
}
|
||||
fn on_enter(&mut self) -> CmdResult {
|
||||
if let StepCR::Radius(c) = &self.step {
|
||||
let c = *c;
|
||||
let d = self.default_d;
|
||||
return CmdResult::CommitAndExit(make_circle(c, (d / 2.0) as f64));
|
||||
}
|
||||
CmdResult::Cancel
|
||||
}
|
||||
fn on_escape(&mut self) -> CmdResult {
|
||||
CmdResult::Cancel
|
||||
}
|
||||
fn on_mouse_move(&mut self, pt: Vec3) -> Option<WireModel> {
|
||||
// Preview radius = distance to cursor; on commit that distance becomes the radius (diameter = 2x).
|
||||
if let StepCR::Radius(c) = &self.step {
|
||||
Some(circle_wire(*c, c.distance(pt)))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
|
||||
if let StepCR::Radius(c) = &self.step {
|
||||
let d: f32 = text.trim().replace(',', ".").parse().ok()?;
|
||||
if d > 0.0 {
|
||||
defaults::set_circle_diam(d);
|
||||
return Some(CmdResult::CommitAndExit(make_circle(*c, (d / 2.0) as f64)));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn dyn_spec(&self) -> Option<crate::command::DynSpec> {
|
||||
use crate::command::{DynAnchor, DynFieldSpec, DynGuide, DynRole, DynSpec};
|
||||
match self.step {
|
||||
StepCR::Center => None,
|
||||
// Diameter: the box shows/accepts twice the cursor radius; resolved
|
||||
// back to a radius point by the host (role scaling).
|
||||
StepCR::Radius(c) => Some(DynSpec {
|
||||
anchor: DynAnchor::Point(c),
|
||||
fields: vec![DynFieldSpec::new(DynRole::Diameter)],
|
||||
guide: DynGuide::Radius,
|
||||
ref_point: None,
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Command: 2-Point ──────────────────────────────────────────────────────
|
||||
|
||||
pub struct Circle2PCommand {
|
||||
p1: Option<Vec3>,
|
||||
}
|
||||
|
||||
impl Circle2PCommand {
|
||||
pub fn new() -> Self {
|
||||
Self { p1: None }
|
||||
}
|
||||
}
|
||||
|
||||
impl CadCommand for Circle2PCommand {
|
||||
fn name(&self) -> &'static str {
|
||||
"CIRCLE_2P"
|
||||
}
|
||||
fn prompt(&self) -> String {
|
||||
if self.p1.is_none() {
|
||||
"CIRCLE 2P Specify first end of diameter:".into()
|
||||
} else {
|
||||
"CIRCLE 2P Specify second end of diameter:".into()
|
||||
}
|
||||
}
|
||||
fn on_point(&mut self, pt: Vec3) -> CmdResult {
|
||||
match self.p1 {
|
||||
None => {
|
||||
self.p1 = Some(pt);
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
Some(p1) => {
|
||||
let center = (p1 + pt) * 0.5;
|
||||
let radius = p1.distance(pt) as f64 / 2.0;
|
||||
CmdResult::CommitAndExit(make_circle(center, radius))
|
||||
}
|
||||
}
|
||||
}
|
||||
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> {
|
||||
let p1 = self.p1?;
|
||||
let center = (p1 + pt) * 0.5;
|
||||
let radius = p1.distance(pt) / 2.0;
|
||||
Some(circle_wire(center, radius))
|
||||
}
|
||||
}
|
||||
|
||||
// ── Command: 3-Point ──────────────────────────────────────────────────────
|
||||
|
||||
pub struct Circle3PCommand {
|
||||
pts: Vec<Vec3>,
|
||||
}
|
||||
|
||||
impl Circle3PCommand {
|
||||
pub fn new() -> Self {
|
||||
Self { pts: Vec::new() }
|
||||
}
|
||||
}
|
||||
|
||||
impl CadCommand for Circle3PCommand {
|
||||
fn name(&self) -> &'static str {
|
||||
"CIRCLE_3P"
|
||||
}
|
||||
fn prompt(&self) -> String {
|
||||
match self.pts.len() {
|
||||
0 => "CIRCLE 3P Specify first point:".into(),
|
||||
1 => "CIRCLE 3P Specify second point:".into(),
|
||||
_ => "CIRCLE 3P Specify third point:".into(),
|
||||
}
|
||||
}
|
||||
fn on_point(&mut self, pt: Vec3) -> CmdResult {
|
||||
self.pts.push(pt);
|
||||
if self.pts.len() < 3 {
|
||||
return CmdResult::NeedPoint;
|
||||
}
|
||||
let (a, b, c) = (self.pts[0], self.pts[1], self.pts[2]);
|
||||
match circumcircle(a, b, c) {
|
||||
Some((center, radius)) => CmdResult::CommitAndExit(make_circle(center, radius as f64)),
|
||||
None => {
|
||||
self.pts.pop();
|
||||
CmdResult::NeedPoint
|
||||
} // collinear — ask again
|
||||
}
|
||||
}
|
||||
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> {
|
||||
match self.pts.len() {
|
||||
0 => None,
|
||||
1 => {
|
||||
// Show circle preview with p1→cursor as diameter (same as 2P).
|
||||
let p1 = self.pts[0];
|
||||
let center = (p1 + pt) * 0.5;
|
||||
let radius = p1.distance(pt) / 2.0;
|
||||
Some(circle_wire(center, radius))
|
||||
}
|
||||
_ => {
|
||||
// Show circumcircle preview if non-collinear, else polyline.
|
||||
let (a, b) = (self.pts[0], self.pts[1]);
|
||||
if let Some((center, radius)) = circumcircle(a, b, pt) {
|
||||
Some(circle_wire(center, radius))
|
||||
} else {
|
||||
Some(WireModel::solid(
|
||||
"rubber_band".into(),
|
||||
vec![[a.x, a.y, a.z], [b.x, b.y, b.z], [pt.x, pt.y, pt.z]],
|
||||
WireModel::CYAN,
|
||||
false,
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 2-D geometry for TTR/TTT ────────────────────────────────────
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct Line2D {
|
||||
a: f32,
|
||||
b: f32,
|
||||
c: f32,
|
||||
} // ax + by + c = 0, a²+b²=1
|
||||
|
||||
impl Line2D {
|
||||
fn from_obj(p1: Vec3, p2: Vec3) -> Self {
|
||||
let dx = p2.x - p1.x;
|
||||
let dy = p2.y - p1.y;
|
||||
let len = (dx * dx + dy * dy).sqrt();
|
||||
if len < 1e-9 {
|
||||
return Self {
|
||||
a: 1.0,
|
||||
b: 0.0,
|
||||
c: -p1.x,
|
||||
};
|
||||
}
|
||||
let a = -dy / len;
|
||||
let b = dx / len;
|
||||
Self {
|
||||
a,
|
||||
b,
|
||||
c: -(a * p1.x + b * p1.y),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn line_line_isect(l1: Line2D, l2: Line2D) -> Option<Vec3> {
|
||||
let det = l1.a * l2.b - l2.a * l1.b;
|
||||
if det.abs() < 1e-9 {
|
||||
return None;
|
||||
}
|
||||
let x = (-l1.c * l2.b + l2.c * l1.b) / det;
|
||||
let y = (-l1.a * l2.c + l2.a * l1.c) / det;
|
||||
Some(Vec3::new(x, y, 0.0))
|
||||
}
|
||||
|
||||
fn solve_quadratic(a: f32, b: f32, c: f32) -> Vec<f32> {
|
||||
if a.abs() < 1e-9 {
|
||||
if b.abs() < 1e-9 {
|
||||
return vec![];
|
||||
}
|
||||
return vec![-c / b];
|
||||
}
|
||||
let disc = b * b - 4.0 * a * c;
|
||||
if disc < 0.0 {
|
||||
return vec![];
|
||||
}
|
||||
let sq = disc.sqrt();
|
||||
vec![(-b - sq) / (2.0 * a), (-b + sq) / (2.0 * a)]
|
||||
}
|
||||
|
||||
fn best_of(candidates: &[Vec3], hint: Vec3) -> Option<Vec3> {
|
||||
candidates.iter().copied().min_by(|a, b| {
|
||||
a.distance(hint)
|
||||
.partial_cmp(&b.distance(hint))
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
})
|
||||
}
|
||||
|
||||
fn best_circle_of(candidates: &[(Vec3, f32)], hint: Vec3) -> Option<(Vec3, f32)> {
|
||||
candidates
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|&(_, r)| r > 1e-4)
|
||||
.min_by(|(a, _), (b, _)| {
|
||||
a.distance(hint)
|
||||
.partial_cmp(&b.distance(hint))
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
})
|
||||
}
|
||||
|
||||
/// All candidate circle centers tangent to two objects with radius r.
|
||||
fn ttr_candidates(obj1: TangentObject, obj2: TangentObject, r: f32) -> Vec<Vec3> {
|
||||
match (obj1, obj2) {
|
||||
(TangentObject::Line { p1: a, p2: b }, TangentObject::Line { p1: c, p2: d }) => {
|
||||
let l1 = Line2D::from_obj(a, b);
|
||||
let l2 = Line2D::from_obj(c, d);
|
||||
let mut out = Vec::new();
|
||||
for s1 in [-r, r] {
|
||||
for s2 in [-r, r] {
|
||||
let ol1 = Line2D { c: l1.c + s1, ..l1 };
|
||||
let ol2 = Line2D { c: l2.c + s2, ..l2 };
|
||||
if let Some(pt) = line_line_isect(ol1, ol2) {
|
||||
out.push(pt);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
(
|
||||
TangentObject::Line { p1, p2 },
|
||||
TangentObject::Circle {
|
||||
center: cp,
|
||||
radius: cr,
|
||||
},
|
||||
)
|
||||
| (
|
||||
TangentObject::Circle {
|
||||
center: cp,
|
||||
radius: cr,
|
||||
},
|
||||
TangentObject::Line { p1, p2 },
|
||||
) => {
|
||||
let l = Line2D::from_obj(p1, p2);
|
||||
ttr_lc_candidates(l, cp, cr, r)
|
||||
}
|
||||
(
|
||||
TangentObject::Circle {
|
||||
center: c1,
|
||||
radius: r1,
|
||||
},
|
||||
TangentObject::Circle {
|
||||
center: c2,
|
||||
radius: r2,
|
||||
},
|
||||
) => ttr_cc_candidates(c1, r1, c2, r2, r),
|
||||
}
|
||||
}
|
||||
|
||||
fn ttr_lc_candidates(l: Line2D, cp: Vec3, cr: f32, r: f32) -> Vec<Vec3> {
|
||||
let mut out = Vec::new();
|
||||
for s1 in [-1.0f32, 1.0] {
|
||||
for s2 in [-1.0f32, 1.0] {
|
||||
let rho = cr + s2 * r;
|
||||
if rho < 0.0 {
|
||||
continue;
|
||||
}
|
||||
let c_off = l.c + s1 * r;
|
||||
// The candidate center (cx, cy) satisfies:
|
||||
// l.a*cx + l.b*cy + c_off = 0
|
||||
// (cx - cp.x)^2 + (cy - cp.y)^2 = rho^2
|
||||
// Parameterise cx = -l.b*t - l.a*c_off, cy = l.a*t - l.b*c_off
|
||||
// where t is the free parameter along the offset line.
|
||||
// Substituting into the circle equation:
|
||||
// (-l.b*t - l.a*c_off - cp.x)^2 + (l.a*t - l.b*c_off - cp.y)^2 = rho^2
|
||||
// Let alpha = l.a*c_off + cp.x, beta = l.b*c_off + cp.y (using sign convention)
|
||||
// Actually: u = -l.a*c_off - cp.x, v = -l.b*c_off - cp.y
|
||||
let u = -(l.a * c_off) - cp.x;
|
||||
let v = -(l.b * c_off) - cp.y;
|
||||
// (-l.b*t + u)^2 + (l.a*t + v)^2 = rho^2
|
||||
// (l.b^2 + l.a^2)*t^2 + 2*(-l.b*u + l.a*v)*t + (u^2 + v^2 - rho^2) = 0
|
||||
// Since a^2 + b^2 = 1:
|
||||
let qa = 1.0f32;
|
||||
let qb = 2.0 * (-l.b * u + l.a * v);
|
||||
let qc = u * u + v * v - rho * rho;
|
||||
for t in solve_quadratic(qa, qb, qc) {
|
||||
let cx = -l.b * t - l.a * c_off;
|
||||
let cy = l.a * t - l.b * c_off;
|
||||
out.push(Vec3::new(cx, cy, 0.0));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn ttr_cc_candidates(c1: Vec3, r1: f32, c2: Vec3, r2: f32, r: f32) -> Vec<Vec3> {
|
||||
let mut out = Vec::new();
|
||||
for s1 in [-1.0f32, 1.0] {
|
||||
for s2 in [-1.0f32, 1.0] {
|
||||
let rho1 = r1 + s1 * r;
|
||||
let rho2 = r2 + s2 * r;
|
||||
if rho1 < 0.0 || rho2 < 0.0 {
|
||||
continue;
|
||||
}
|
||||
let ax = c2.x - c1.x;
|
||||
let ay = c2.y - c1.y;
|
||||
let k = 0.5
|
||||
* (rho1 * rho1 - rho2 * rho2 + c2.x * c2.x + c2.y * c2.y
|
||||
- c1.x * c1.x
|
||||
- c1.y * c1.y);
|
||||
let a2 = ax * ax + ay * ay;
|
||||
if a2 < 1e-12 {
|
||||
continue;
|
||||
}
|
||||
if ay.abs() >= ax.abs() {
|
||||
let iy = 1.0 / ay;
|
||||
let qa = 1.0 + ax * ax * iy * iy;
|
||||
let p = k * iy - c1.y;
|
||||
let qb = -2.0 * (c1.x + ax * iy * p);
|
||||
let qc = c1.x * c1.x + p * p - rho1 * rho1;
|
||||
let disc = qb * qb - 4.0 * qa * qc;
|
||||
if disc < 0.0 {
|
||||
continue;
|
||||
}
|
||||
for sign in [-1.0f32, 1.0] {
|
||||
let cx = (-qb + sign * disc.sqrt()) / (2.0 * qa);
|
||||
let cy = (k - ax * cx) * iy;
|
||||
out.push(Vec3::new(cx, cy, 0.0));
|
||||
}
|
||||
} else {
|
||||
let ix = 1.0 / ax;
|
||||
let qa = ay * ay * ix * ix + 1.0;
|
||||
let p = k * ix - c1.x;
|
||||
let qb = -2.0 * (ay * ix * p + c1.y);
|
||||
let qc = p * p + c1.y * c1.y - rho1 * rho1;
|
||||
let disc = qb * qb - 4.0 * qa * qc;
|
||||
if disc < 0.0 {
|
||||
continue;
|
||||
}
|
||||
for sign in [-1.0f32, 1.0] {
|
||||
let cy = (-qb + sign * disc.sqrt()) / (2.0 * qa);
|
||||
let cx = (k - ay * cy) * ix;
|
||||
out.push(Vec3::new(cx, cy, 0.0));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Unified TTT solver: circle tangent to three objects. Returns all (center, radius) candidates.
|
||||
fn ttt_candidates(
|
||||
obj1: TangentObject,
|
||||
obj2: TangentObject,
|
||||
obj3: TangentObject,
|
||||
) -> Vec<(Vec3, f32)> {
|
||||
let objs = [obj1, obj2, obj3];
|
||||
let mut results = Vec::new();
|
||||
let sign_combos: [[f32; 3]; 8] = [
|
||||
[-1., -1., -1.],
|
||||
[-1., -1., 1.],
|
||||
[-1., 1., -1.],
|
||||
[-1., 1., 1.],
|
||||
[1., -1., -1.],
|
||||
[1., -1., 1.],
|
||||
[1., 1., -1.],
|
||||
[1., 1., 1.],
|
||||
];
|
||||
for eps in &sign_combos {
|
||||
for (center, r) in ttt_solve_sign(&objs, eps) {
|
||||
if r > 1e-4 {
|
||||
results.push((center, r));
|
||||
}
|
||||
}
|
||||
}
|
||||
results
|
||||
}
|
||||
|
||||
// LinEq: lx*cx + ly*cy + lr*r = k
|
||||
struct LinEq {
|
||||
lx: f32,
|
||||
ly: f32,
|
||||
lr: f32,
|
||||
k: f32,
|
||||
}
|
||||
|
||||
fn ttt_solve_sign(objs: &[TangentObject; 3], eps: &[f32; 3]) -> Vec<(Vec3, f32)> {
|
||||
let mut lin_eqs: Vec<LinEq> = Vec::new();
|
||||
let mut circle_idx: Vec<usize> = Vec::new();
|
||||
|
||||
for (i, &obj) in objs.iter().enumerate() {
|
||||
match obj {
|
||||
TangentObject::Line { p1, p2 } => {
|
||||
let l = Line2D::from_obj(p1, p2);
|
||||
lin_eqs.push(LinEq {
|
||||
lx: l.a,
|
||||
ly: l.b,
|
||||
lr: -eps[i],
|
||||
k: -l.c,
|
||||
});
|
||||
}
|
||||
TangentObject::Circle { .. } => {
|
||||
circle_idx.push(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Circle-pair differences → additional linear equations
|
||||
for j in 1..circle_idx.len() {
|
||||
let i0 = circle_idx[0];
|
||||
let i1 = circle_idx[j];
|
||||
if let (
|
||||
TangentObject::Circle {
|
||||
center: p0,
|
||||
radius: r0,
|
||||
},
|
||||
TangentObject::Circle {
|
||||
center: p1,
|
||||
radius: r1,
|
||||
},
|
||||
) = (objs[i0], objs[i1])
|
||||
{
|
||||
let lx = 2.0 * (p1.x - p0.x);
|
||||
let ly = 2.0 * (p1.y - p0.y);
|
||||
let lr = -2.0 * (r0 * eps[i0] - r1 * eps[i1]);
|
||||
let k = (r0 * r0 - r1 * r1) + p1.x * p1.x + p1.y * p1.y - p0.x * p0.x - p0.y * p0.y;
|
||||
lin_eqs.push(LinEq { lx, ly, lr, k });
|
||||
}
|
||||
}
|
||||
|
||||
if lin_eqs.len() < 2 {
|
||||
return vec![];
|
||||
}
|
||||
|
||||
let e0 = &lin_eqs[0];
|
||||
let e1 = &lin_eqs[1];
|
||||
let det = e0.lx * e1.ly - e1.lx * e0.ly;
|
||||
if det.abs() < 1e-9 {
|
||||
return vec![];
|
||||
}
|
||||
|
||||
// cx = a_cx + b_cx*r, cy = a_cy + b_cy*r
|
||||
let a_cx = (e0.k * e1.ly - e1.k * e0.ly) / det;
|
||||
let b_cx = -(e0.lr * e1.ly - e1.lr * e0.ly) / det;
|
||||
let a_cy = (e0.lx * e1.k - e1.lx * e0.k) / det;
|
||||
let b_cy = -(e0.lx * e1.lr - e1.lx * e0.lr) / det;
|
||||
|
||||
let r_vals: Vec<f32> = if lin_eqs.len() >= 3 {
|
||||
let e2 = &lin_eqs[2];
|
||||
let r_coeff = b_cx * e2.lx + b_cy * e2.ly + e2.lr;
|
||||
let r_const = e2.k - a_cx * e2.lx - a_cy * e2.ly;
|
||||
if r_coeff.abs() < 1e-9 {
|
||||
vec![]
|
||||
} else {
|
||||
vec![r_const / r_coeff]
|
||||
}
|
||||
} else if !circle_idx.is_empty() {
|
||||
let ci = circle_idx[0];
|
||||
if let TangentObject::Circle {
|
||||
center: cp,
|
||||
radius: cr,
|
||||
} = objs[ci]
|
||||
{
|
||||
let p = a_cx - cp.x;
|
||||
let q = a_cy - cp.y;
|
||||
let e = eps[ci];
|
||||
let a_q = b_cx * b_cx + b_cy * b_cy - e * e;
|
||||
let b_q = 2.0 * (p * b_cx + q * b_cy - cr * e);
|
||||
let c_q = p * p + q * q - cr * cr;
|
||||
solve_quadratic(a_q, b_q, c_q)
|
||||
} else {
|
||||
vec![]
|
||||
}
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
|
||||
r_vals
|
||||
.into_iter()
|
||||
.filter_map(|r| {
|
||||
if r <= 1e-4 {
|
||||
return None;
|
||||
}
|
||||
let cx = a_cx + b_cx * r;
|
||||
let cy = a_cy + b_cy * r;
|
||||
Some((Vec3::new(cx, cy, 0.0), r))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
// ── Command: Tan, Tan, Radius ──────────────────────────────────────────────
|
||||
|
||||
pub struct CircleTTRCommand {
|
||||
step: StepTTR,
|
||||
}
|
||||
|
||||
enum StepTTR {
|
||||
First,
|
||||
Second {
|
||||
obj1: TangentObject,
|
||||
hit1: Vec3,
|
||||
},
|
||||
Radius {
|
||||
obj1: TangentObject,
|
||||
obj2: TangentObject,
|
||||
hit1: Vec3,
|
||||
hit2: Vec3,
|
||||
},
|
||||
}
|
||||
|
||||
impl CircleTTRCommand {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
step: StepTTR::First,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CadCommand for CircleTTRCommand {
|
||||
fn name(&self) -> &'static str {
|
||||
"CIRCLE_TTR"
|
||||
}
|
||||
|
||||
fn needs_tangent_pick(&self) -> bool {
|
||||
matches!(self.step, StepTTR::First | StepTTR::Second { .. })
|
||||
}
|
||||
|
||||
fn wants_text_input(&self) -> bool {
|
||||
matches!(self.step, StepTTR::Radius { .. })
|
||||
}
|
||||
|
||||
fn dyn_field(&self) -> crate::command::DynField {
|
||||
if matches!(self.step, StepTTR::Radius { .. }) {
|
||||
crate::command::DynField::Scalar
|
||||
} else {
|
||||
crate::command::DynField::Point
|
||||
}
|
||||
}
|
||||
|
||||
fn prompt(&self) -> String {
|
||||
match &self.step {
|
||||
StepTTR::First => "CIRCLE TTR Select first tangent object:".into(),
|
||||
StepTTR::Second { .. } => "CIRCLE TTR Select second tangent object:".into(),
|
||||
StepTTR::Radius { .. } => "CIRCLE TTR Specify radius:".into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn on_point(&mut self, _pt: Vec3) -> CmdResult {
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
|
||||
fn on_tangent_point(&mut self, obj: TangentObject, hit: Vec3) -> CmdResult {
|
||||
match &self.step {
|
||||
StepTTR::First => {
|
||||
self.step = StepTTR::Second {
|
||||
obj1: obj,
|
||||
hit1: hit,
|
||||
};
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
StepTTR::Second { obj1, hit1 } => {
|
||||
let (o1, h1) = (*obj1, *hit1);
|
||||
self.step = StepTTR::Radius {
|
||||
obj1: o1,
|
||||
obj2: obj,
|
||||
hit1: h1,
|
||||
hit2: hit,
|
||||
};
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
StepTTR::Radius { .. } => CmdResult::NeedPoint,
|
||||
}
|
||||
}
|
||||
|
||||
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
|
||||
let r: f32 = text.trim().parse().ok()?;
|
||||
if r <= 0.0 {
|
||||
return Some(CmdResult::Cancel);
|
||||
}
|
||||
if let StepTTR::Radius {
|
||||
obj1,
|
||||
obj2,
|
||||
hit1,
|
||||
hit2,
|
||||
} = &self.step
|
||||
{
|
||||
let hint = (*hit1 + *hit2) * 0.5;
|
||||
let candidates = ttr_candidates(*obj1, *obj2, r);
|
||||
if let Some(center) = best_of(&candidates, hint) {
|
||||
Some(CmdResult::CommitAndExit(make_circle(center, r as f64)))
|
||||
} else {
|
||||
Some(CmdResult::Cancel)
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
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> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
// ── Command: Tan, Tan, Tan ────────────────────────────────────────────────
|
||||
|
||||
pub struct CircleTTTCommand {
|
||||
objs: Vec<TangentObject>,
|
||||
hits: Vec<Vec3>,
|
||||
}
|
||||
|
||||
impl CircleTTTCommand {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
objs: Vec::new(),
|
||||
hits: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CadCommand for CircleTTTCommand {
|
||||
fn name(&self) -> &'static str {
|
||||
"CIRCLE_TTT"
|
||||
}
|
||||
|
||||
fn needs_tangent_pick(&self) -> bool {
|
||||
self.objs.len() < 3
|
||||
}
|
||||
|
||||
fn prompt(&self) -> String {
|
||||
match self.objs.len() {
|
||||
0 => "CIRCLE TTT Select first tangent object:".into(),
|
||||
1 => "CIRCLE TTT Select second tangent object:".into(),
|
||||
_ => "CIRCLE TTT Select third tangent object:".into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn on_point(&mut self, _pt: Vec3) -> CmdResult {
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
|
||||
fn on_tangent_point(&mut self, obj: TangentObject, hit: Vec3) -> CmdResult {
|
||||
self.objs.push(obj);
|
||||
self.hits.push(hit);
|
||||
if self.objs.len() < 3 {
|
||||
return CmdResult::NeedPoint;
|
||||
}
|
||||
let hint = self.hits.iter().fold(Vec3::ZERO, |a, &b| a + b) / 3.0;
|
||||
let candidates = ttt_candidates(self.objs[0], self.objs[1], self.objs[2]);
|
||||
match best_circle_of(&candidates, hint) {
|
||||
Some((center, r)) => CmdResult::CommitAndExit(make_circle(center, r as f64)),
|
||||
None => {
|
||||
self.objs.pop();
|
||||
self.hits.pop();
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ── Autocomplete registry ─────────────────────────────────
|
||||
inventory::submit!(crate::command::CommandRegistration { names: &["CIRCLE_2P"] }); // Circle2PCommand
|
||||
inventory::submit!(crate::command::CommandRegistration { names: &["CIRCLE_3P"] }); // Circle3PCommand
|
||||
inventory::submit!(crate::command::CommandRegistration { names: &["CIRCLE_CD"] }); // CircleCDCommand
|
||||
inventory::submit!(crate::command::CommandRegistration { names: &["C", "CIRCLE"] }); // CircleCommand
|
||||
inventory::submit!(crate::command::CommandRegistration { names: &["CIRCLE_TTR"] }); // CircleTTRCommand
|
||||
inventory::submit!(crate::command::CommandRegistration { names: &["CIRCLE_TTT"] }); // CircleTTTCommand
|
||||
@@ -0,0 +1,146 @@
|
||||
// DONUT command — create a filled circular ring (thick LwPolyline).
|
||||
//
|
||||
// A donut is an LwPolyline with:
|
||||
// - 2 vertices at (cx ± r_avg, 0), both with bulge = 1.0 (two 180° CCW arcs)
|
||||
// - start_width = end_width = (outer - inner) / 2
|
||||
// - is_closed = true
|
||||
//
|
||||
// Workflow:
|
||||
// 1. Type inner diameter (or 0 for a filled circle)
|
||||
// 2. Type outer diameter
|
||||
// 3. Click center point(s); Enter to finish
|
||||
|
||||
use acadrust::entities::{LwPolyline, LwVertex};
|
||||
use acadrust::EntityType;
|
||||
use glam::Vec3;
|
||||
|
||||
use crate::command::{CadCommand, CmdResult};
|
||||
|
||||
pub struct DonutCommand {
|
||||
state: DonutState,
|
||||
inner_r: f64,
|
||||
outer_r: f64,
|
||||
}
|
||||
|
||||
enum DonutState {
|
||||
AskInner,
|
||||
AskOuter,
|
||||
PlaceCenter,
|
||||
}
|
||||
|
||||
impl DonutCommand {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
state: DonutState::AskInner,
|
||||
inner_r: 0.0,
|
||||
outer_r: 1.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CadCommand for DonutCommand {
|
||||
fn name(&self) -> &'static str {
|
||||
"DONUT"
|
||||
}
|
||||
|
||||
fn prompt(&self) -> String {
|
||||
match &self.state {
|
||||
DonutState::AskInner => "DONUT Specify inside diameter <0>:".into(),
|
||||
DonutState::AskOuter => "DONUT Specify outside diameter:".into(),
|
||||
DonutState::PlaceCenter => "DONUT Specify center of donut (Enter to exit):".into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn wants_text_input(&self) -> bool {
|
||||
matches!(self.state, DonutState::AskInner | DonutState::AskOuter)
|
||||
}
|
||||
|
||||
fn dyn_field(&self) -> crate::command::DynField {
|
||||
if matches!(self.state, DonutState::AskInner | DonutState::AskOuter) {
|
||||
crate::command::DynField::Scalar
|
||||
} else {
|
||||
crate::command::DynField::Point
|
||||
}
|
||||
}
|
||||
|
||||
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
|
||||
let val: f64 = text
|
||||
.trim()
|
||||
.replace(',', ".")
|
||||
.parse()
|
||||
.ok()
|
||||
.filter(|&v: &f64| v >= 0.0)?;
|
||||
match &self.state {
|
||||
DonutState::AskInner => {
|
||||
self.inner_r = val / 2.0;
|
||||
self.state = DonutState::AskOuter;
|
||||
Some(CmdResult::NeedPoint)
|
||||
}
|
||||
DonutState::AskOuter => {
|
||||
if val <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
self.outer_r = val / 2.0;
|
||||
if self.inner_r > self.outer_r {
|
||||
std::mem::swap(&mut self.inner_r, &mut self.outer_r);
|
||||
}
|
||||
self.state = DonutState::PlaceCenter;
|
||||
Some(CmdResult::NeedPoint)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn on_point(&mut self, pt: Vec3) -> CmdResult {
|
||||
match &self.state {
|
||||
DonutState::PlaceCenter => {
|
||||
let entity = make_donut(pt.x as f64, pt.y as f64, self.inner_r, self.outer_r);
|
||||
// Keep command active so user can place more donuts.
|
||||
CmdResult::CommitEntity(entity)
|
||||
}
|
||||
_ => CmdResult::NeedPoint,
|
||||
}
|
||||
}
|
||||
|
||||
fn on_enter(&mut self) -> CmdResult {
|
||||
match &self.state {
|
||||
DonutState::AskInner => {
|
||||
// Accept default 0 for inner diameter
|
||||
self.inner_r = 0.0;
|
||||
self.state = DonutState::AskOuter;
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
DonutState::PlaceCenter => CmdResult::Cancel,
|
||||
_ => CmdResult::Cancel,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn make_donut(cx: f64, cy: f64, inner_r: f64, outer_r: f64) -> EntityType {
|
||||
use acadrust::types::Vector2;
|
||||
let r_avg = (inner_r + outer_r) / 2.0;
|
||||
let width = outer_r - inner_r;
|
||||
|
||||
let mut p = LwPolyline::new();
|
||||
p.is_closed = true;
|
||||
p.constant_width = width;
|
||||
|
||||
// Vertex at (cx - r, cy) with bulge 1.0 (180° CCW arc to next vertex)
|
||||
let mut v0 = LwVertex::new(Vector2::new(cx - r_avg, cy));
|
||||
v0.bulge = 1.0;
|
||||
v0.start_width = width;
|
||||
v0.end_width = width;
|
||||
|
||||
// Vertex at (cx + r, cy) with bulge 1.0 (second 180° arc back to v0)
|
||||
let mut v1 = LwVertex::new(Vector2::new(cx + r_avg, cy));
|
||||
v1.bulge = 1.0;
|
||||
v1.start_width = width;
|
||||
v1.end_width = width;
|
||||
|
||||
p.vertices = vec![v0, v1];
|
||||
EntityType::LwPolyline(p)
|
||||
}
|
||||
|
||||
|
||||
// ── Autocomplete registry ─────────────────────────────────
|
||||
inventory::submit!(crate::command::CommandRegistration { names: &["DO", "DONUT"] }); // DonutCommand
|
||||
@@ -0,0 +1,636 @@
|
||||
// Ellipse tool — ribbon dropdown + all OpenCADStudio ellipse creation methods.
|
||||
//
|
||||
// Commands:
|
||||
// ELLIPSE — Center, Axes (center → major endpoint → minor distance)
|
||||
// ELLIPSE_AXIS — Axis, End (axis endpoint 1 → endpoint 2 → minor distance)
|
||||
// ELLIPSE_ARC — Ellipse Arc (shape as above, then start/end parametric angles)
|
||||
|
||||
use acadrust::types::Vector3;
|
||||
use acadrust::{Ellipse, EntityType};
|
||||
|
||||
use crate::command::{CadCommand, CmdResult};
|
||||
use crate::modules::IconKind;
|
||||
use crate::scene::model::wire_model::WireModel;
|
||||
use glam::Vec3;
|
||||
|
||||
fn parse_f32(text: &str) -> Option<f32> {
|
||||
text.trim().replace(',', ".").parse().ok()
|
||||
}
|
||||
|
||||
const TAU: f32 = std::f32::consts::TAU;
|
||||
|
||||
/// Minimum swept angle before the previewed arc may flip CW/CCW — filters the
|
||||
/// per-frame cursor jitter that otherwise reverses the sweep on tiny moves.
|
||||
const DIR_TOL: f32 = 0.1745; // ~10°
|
||||
|
||||
// ── Icons ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const ICON_CTR: IconKind = IconKind::Svg(include_bytes!(
|
||||
"../../../../assets/icons/ellipse/ellipse_ctr.svg"
|
||||
));
|
||||
const ICON_AXIS: IconKind = IconKind::Svg(include_bytes!(
|
||||
"../../../../assets/icons/ellipse/ellipse_axis.svg"
|
||||
));
|
||||
const ICON_ARC: IconKind = IconKind::Svg(include_bytes!(
|
||||
"../../../../assets/icons/ellipse/ellipse_arc.svg"
|
||||
));
|
||||
|
||||
// ── Dropdown metadata ─────────────────────────────────────────────────────
|
||||
|
||||
pub const DROPDOWN_ID: &str = "ELLIPSE";
|
||||
|
||||
pub const DROPDOWN_ITEMS: &[(&str, &str, IconKind)] = &[
|
||||
("ELLIPSE", "Center, Axes", ICON_CTR),
|
||||
("ELLIPSE_AXIS", "Axis, End", ICON_AXIS),
|
||||
("ELLIPSE_ARC", "Ellipse Arc", ICON_ARC),
|
||||
];
|
||||
|
||||
pub const ICON: IconKind = ICON_CTR;
|
||||
|
||||
// ── Shared helpers ────────────────────────────────────────────────────────
|
||||
|
||||
/// Preview wire for a full or partial ellipse.
|
||||
fn ellipse_wire(
|
||||
center: Vec3,
|
||||
major: Vec3, // vector from center to major-axis endpoint
|
||||
ratio: f32, // minor/major
|
||||
t_start: f32,
|
||||
t_end: f32,
|
||||
) -> WireModel {
|
||||
let r_major = major.length();
|
||||
if r_major < 1e-9 {
|
||||
return WireModel::solid("rubber_band".into(), vec![], WireModel::CYAN, false);
|
||||
}
|
||||
let major_dir = major / r_major;
|
||||
let v = Vec3::Z.cross(major_dir).normalize();
|
||||
let segs = 64u32;
|
||||
// Unwrap t_end so the arc goes counter-clockwise.
|
||||
let t_e = if t_end <= t_start { t_end + TAU } else { t_end };
|
||||
let pts: Vec<[f32; 3]> = (0..=segs)
|
||||
.map(|i| {
|
||||
let t = t_start + (t_e - t_start) * (i as f32 / segs as f32);
|
||||
let p = center + t.cos() * r_major * major_dir + t.sin() * r_major * ratio * v;
|
||||
[p.x, p.y, p.z]
|
||||
})
|
||||
.collect();
|
||||
WireModel::solid("rubber_band".into(), pts, WireModel::CYAN, false)
|
||||
}
|
||||
|
||||
/// Convert a world point to the parametric angle on the ellipse.
|
||||
fn param_angle(center: Vec3, major_dir: Vec3, v: Vec3, pt: Vec3, ratio: f32) -> f32 {
|
||||
let d = pt - center;
|
||||
let u_proj = d.dot(major_dir);
|
||||
let v_proj = d.dot(v);
|
||||
// Inverse-map: on ellipse x=cos(t)*r_major, y=sin(t)*r_major*ratio
|
||||
// → t = atan2(v_proj / (r_major*ratio), u_proj / r_major) but we normalise
|
||||
v_proj.atan2(u_proj * ratio).rem_euclid(TAU)
|
||||
}
|
||||
|
||||
/// Build the final Ellipse entity.
|
||||
fn make_ellipse(center: Vec3, major: Vec3, ratio: f32, t_start: f32, t_end: f32) -> Ellipse {
|
||||
Ellipse {
|
||||
center: Vector3::new(center.x as f64, center.y as f64, center.z as f64),
|
||||
major_axis: Vector3::new(major.x as f64, major.y as f64, major.z as f64),
|
||||
minor_axis_ratio: ratio as f64,
|
||||
start_parameter: t_start as f64,
|
||||
end_parameter: t_end as f64,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
// ── 1. Center mode ────────────────────────────────────────────────────────
|
||||
// Step 1: center Step 2: major-axis endpoint Step 3: minor-axis point
|
||||
|
||||
enum CtrStep {
|
||||
Center,
|
||||
MajorAxis { center: Vec3 },
|
||||
MinorRatio { center: Vec3, major: Vec3 },
|
||||
}
|
||||
|
||||
pub struct EllipseCommand {
|
||||
step: CtrStep,
|
||||
}
|
||||
|
||||
impl EllipseCommand {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
step: CtrStep::Center,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CadCommand for EllipseCommand {
|
||||
fn name(&self) -> &'static str {
|
||||
"ELLIPSE"
|
||||
}
|
||||
|
||||
fn prompt(&self) -> String {
|
||||
match &self.step {
|
||||
CtrStep::Center => "ELLIPSE Specify center:".into(),
|
||||
CtrStep::MajorAxis { .. } => "ELLIPSE Specify major axis endpoint:".into(),
|
||||
CtrStep::MinorRatio { major, .. } => format!(
|
||||
"ELLIPSE Specify minor axis point or type half-length [major r={:.3}]:",
|
||||
major.length()
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn on_point(&mut self, pt: Vec3) -> CmdResult {
|
||||
match &self.step {
|
||||
CtrStep::Center => {
|
||||
self.step = CtrStep::MajorAxis { center: pt };
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
CtrStep::MajorAxis { center } => {
|
||||
let center = *center;
|
||||
self.step = CtrStep::MinorRatio {
|
||||
center,
|
||||
major: pt - center,
|
||||
};
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
CtrStep::MinorRatio { center, major } => {
|
||||
let (center, major) = (*center, *major);
|
||||
let ratio = minor_ratio(center, major, pt);
|
||||
CmdResult::CommitAndExit(EntityType::Ellipse(make_ellipse(
|
||||
center, major, ratio, 0.0, TAU,
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn on_enter(&mut self) -> CmdResult {
|
||||
CmdResult::Cancel
|
||||
}
|
||||
|
||||
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
|
||||
if let CtrStep::MinorRatio { center, major } = &self.step {
|
||||
let r_minor = parse_f32(text)?;
|
||||
if r_minor > 0.0 {
|
||||
let ratio = (r_minor / major.length()).clamp(1e-6, 1.0);
|
||||
return Some(CmdResult::CommitAndExit(EntityType::Ellipse(make_ellipse(
|
||||
*center, *major, ratio, 0.0, TAU,
|
||||
))));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn on_mouse_move(&mut self, pt: Vec3) -> Option<WireModel> {
|
||||
match &self.step {
|
||||
CtrStep::MajorAxis { center } => Some(line_wire(*center, pt)),
|
||||
CtrStep::MinorRatio { center, major } => {
|
||||
let ratio = minor_ratio(*center, *major, pt).max(0.001);
|
||||
Some(ellipse_wire(*center, *major, ratio, 0.0, TAU))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn dyn_spec(&self) -> Option<crate::command::DynSpec> {
|
||||
use crate::command::{DynAnchor, DynFieldSpec, DynGuide, DynRole, DynSpec};
|
||||
match &self.step {
|
||||
// Center + major axis endpoint: ordinary point picks (legacy polar
|
||||
// anchored at the previous point).
|
||||
CtrStep::Center | CtrStep::MajorAxis { .. } => None,
|
||||
// Minor axis: half-length measured square to the major axis. Show
|
||||
// the perpendicular drop from the cursor onto the major axis.
|
||||
CtrStep::MinorRatio { center, major } => Some(DynSpec {
|
||||
anchor: DynAnchor::Point(*center),
|
||||
fields: vec![DynFieldSpec::new(DynRole::Distance)],
|
||||
guide: DynGuide::Perp,
|
||||
ref_point: Some(*center + *major),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn dyn_live_value(&self, cursor: Vec3) -> Option<f64> {
|
||||
if let CtrStep::MinorRatio { center, major } = &self.step {
|
||||
Some((minor_ratio(*center, *major, cursor) * major.length()) as f64)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 2. Axis, End mode ─────────────────────────────────────────────────────
|
||||
// Step 1: axis endpoint 1 Step 2: axis endpoint 2 Step 3: minor-axis point
|
||||
|
||||
enum AxisStep {
|
||||
Pt1,
|
||||
Pt2 { p1: Vec3 },
|
||||
MinorRatio { center: Vec3, major: Vec3 },
|
||||
}
|
||||
|
||||
pub struct EllipseAxisCommand {
|
||||
step: AxisStep,
|
||||
}
|
||||
|
||||
impl EllipseAxisCommand {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
step: AxisStep::Pt1,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CadCommand for EllipseAxisCommand {
|
||||
fn name(&self) -> &'static str {
|
||||
"ELLIPSE_AXIS"
|
||||
}
|
||||
|
||||
fn prompt(&self) -> String {
|
||||
match &self.step {
|
||||
AxisStep::Pt1 => "ELLIPSE (Axis) Specify first endpoint of major axis:".into(),
|
||||
AxisStep::Pt2 { .. } => "ELLIPSE (Axis) Specify second endpoint of major axis:".into(),
|
||||
AxisStep::MinorRatio { major, .. } => format!(
|
||||
"ELLIPSE (Axis) Specify minor axis point or type half-length [major r={:.3}]:",
|
||||
major.length()
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn on_point(&mut self, pt: Vec3) -> CmdResult {
|
||||
match &self.step {
|
||||
AxisStep::Pt1 => {
|
||||
self.step = AxisStep::Pt2 { p1: pt };
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
AxisStep::Pt2 { p1 } => {
|
||||
let center = (*p1 + pt) * 0.5;
|
||||
let major = pt - center; // half-vector
|
||||
self.step = AxisStep::MinorRatio { center, major };
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
AxisStep::MinorRatio { center, major } => {
|
||||
let (center, major) = (*center, *major);
|
||||
let ratio = minor_ratio(center, major, pt);
|
||||
CmdResult::CommitAndExit(EntityType::Ellipse(make_ellipse(
|
||||
center, major, ratio, 0.0, TAU,
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn on_enter(&mut self) -> CmdResult {
|
||||
CmdResult::Cancel
|
||||
}
|
||||
|
||||
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
|
||||
if let AxisStep::MinorRatio { center, major } = &self.step {
|
||||
let r_minor = parse_f32(text)?;
|
||||
if r_minor > 0.0 {
|
||||
let ratio = (r_minor / major.length()).clamp(1e-6, 1.0);
|
||||
return Some(CmdResult::CommitAndExit(EntityType::Ellipse(make_ellipse(
|
||||
*center, *major, ratio, 0.0, TAU,
|
||||
))));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn on_mouse_move(&mut self, pt: Vec3) -> Option<WireModel> {
|
||||
match &self.step {
|
||||
AxisStep::Pt1 => None,
|
||||
AxisStep::Pt2 { p1 } => Some(line_wire(*p1, pt)),
|
||||
AxisStep::MinorRatio { center, major } => {
|
||||
let ratio = minor_ratio(*center, *major, pt).max(0.001);
|
||||
Some(ellipse_wire(*center, *major, ratio, 0.0, TAU))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn dyn_spec(&self) -> Option<crate::command::DynSpec> {
|
||||
use crate::command::{DynAnchor, DynFieldSpec, DynGuide, DynRole, DynSpec};
|
||||
match &self.step {
|
||||
// Endpoints define the full major axis — legacy polar (anchored at
|
||||
// the previous point) is right.
|
||||
AxisStep::Pt1 | AxisStep::Pt2 { .. } => None,
|
||||
// Minor half-length, square to the major axis.
|
||||
AxisStep::MinorRatio { center, major } => Some(DynSpec {
|
||||
anchor: DynAnchor::Point(*center),
|
||||
fields: vec![DynFieldSpec::new(DynRole::Distance)],
|
||||
guide: DynGuide::Perp,
|
||||
ref_point: Some(*center + *major),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn dyn_live_value(&self, cursor: Vec3) -> Option<f64> {
|
||||
if let AxisStep::MinorRatio { center, major } = &self.step {
|
||||
Some((minor_ratio(*center, *major, cursor) * major.length()) as f64)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 3. Ellipse Arc mode ───────────────────────────────────────────────────
|
||||
// Same shape steps as Center mode, then: start parameter, end parameter.
|
||||
|
||||
enum ArcStep {
|
||||
Center,
|
||||
MajorAxis {
|
||||
center: Vec3,
|
||||
},
|
||||
MinorRatio {
|
||||
center: Vec3,
|
||||
major: Vec3,
|
||||
},
|
||||
StartAngle {
|
||||
center: Vec3,
|
||||
major: Vec3,
|
||||
ratio: f32,
|
||||
},
|
||||
EndAngle {
|
||||
center: Vec3,
|
||||
major: Vec3,
|
||||
ratio: f32,
|
||||
t_start: f32,
|
||||
},
|
||||
}
|
||||
|
||||
pub struct EllipseArcCommand {
|
||||
step: ArcStep,
|
||||
prev_pt: Option<Vec3>,
|
||||
cw: bool,
|
||||
}
|
||||
|
||||
impl EllipseArcCommand {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
step: ArcStep::Center,
|
||||
prev_pt: None,
|
||||
cw: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CadCommand for EllipseArcCommand {
|
||||
fn name(&self) -> &'static str {
|
||||
"ELLIPSE_ARC"
|
||||
}
|
||||
|
||||
fn prompt(&self) -> String {
|
||||
match &self.step {
|
||||
ArcStep::Center => "ELLIPSE ARC Specify center:".into(),
|
||||
ArcStep::MajorAxis { .. } => "ELLIPSE ARC Specify major axis endpoint:".into(),
|
||||
ArcStep::MinorRatio { major, .. } => format!(
|
||||
"ELLIPSE ARC Specify minor axis point or type half-length [major r={:.3}]:",
|
||||
major.length()
|
||||
),
|
||||
ArcStep::StartAngle { .. } => {
|
||||
"ELLIPSE ARC Specify start angle point or type degrees:".into()
|
||||
}
|
||||
ArcStep::EndAngle { t_start, .. } => format!(
|
||||
"ELLIPSE ARC Specify end angle point or type degrees [start={:.1}°]:",
|
||||
t_start.to_degrees()
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn on_point(&mut self, pt: Vec3) -> CmdResult {
|
||||
match &self.step {
|
||||
ArcStep::Center => {
|
||||
self.step = ArcStep::MajorAxis { center: pt };
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
ArcStep::MajorAxis { center } => {
|
||||
let center = *center;
|
||||
self.step = ArcStep::MinorRatio {
|
||||
center,
|
||||
major: pt - center,
|
||||
};
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
ArcStep::MinorRatio { center, major } => {
|
||||
let (center, major) = (*center, *major);
|
||||
let ratio = minor_ratio(center, major, pt);
|
||||
self.step = ArcStep::StartAngle {
|
||||
center,
|
||||
major,
|
||||
ratio,
|
||||
};
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
ArcStep::StartAngle {
|
||||
center,
|
||||
major,
|
||||
ratio,
|
||||
} => {
|
||||
let (center, major, ratio) = (*center, *major, *ratio);
|
||||
let t_start = angle_from_point(center, major, ratio, pt);
|
||||
self.prev_pt = None; // reset direction tracking for end-angle step
|
||||
self.step = ArcStep::EndAngle {
|
||||
center,
|
||||
major,
|
||||
ratio,
|
||||
t_start,
|
||||
};
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
ArcStep::EndAngle {
|
||||
center,
|
||||
major,
|
||||
ratio,
|
||||
t_start,
|
||||
} => {
|
||||
let (center, major, ratio, t_start) = (*center, *major, *ratio, *t_start);
|
||||
let t_end = angle_from_point(center, major, ratio, pt);
|
||||
let entity = if self.cw {
|
||||
make_ellipse(center, major, ratio, t_end, t_start)
|
||||
} else {
|
||||
make_ellipse(center, major, ratio, t_start, t_end)
|
||||
};
|
||||
CmdResult::CommitAndExit(EntityType::Ellipse(entity))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn on_enter(&mut self) -> CmdResult {
|
||||
CmdResult::Cancel
|
||||
}
|
||||
|
||||
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
|
||||
let val = parse_f32(text)?;
|
||||
match &self.step {
|
||||
ArcStep::MinorRatio { center, major } => {
|
||||
if val > 0.0 {
|
||||
let ratio = (val / major.length()).clamp(1e-6, 1.0);
|
||||
let (c, m) = (*center, *major);
|
||||
self.step = ArcStep::StartAngle {
|
||||
center: c,
|
||||
major: m,
|
||||
ratio,
|
||||
};
|
||||
return Some(CmdResult::NeedPoint);
|
||||
}
|
||||
}
|
||||
ArcStep::StartAngle {
|
||||
center,
|
||||
major,
|
||||
ratio,
|
||||
} => {
|
||||
let t_start = val.to_radians();
|
||||
let (c, m, r) = (*center, *major, *ratio);
|
||||
self.prev_pt = None;
|
||||
self.step = ArcStep::EndAngle {
|
||||
center: c,
|
||||
major: m,
|
||||
ratio: r,
|
||||
t_start,
|
||||
};
|
||||
return Some(CmdResult::NeedPoint);
|
||||
}
|
||||
ArcStep::EndAngle {
|
||||
center,
|
||||
major,
|
||||
ratio,
|
||||
t_start,
|
||||
} => {
|
||||
// Typed degrees: positive = CCW, negative = CW.
|
||||
let t_end = val.to_radians();
|
||||
return Some(CmdResult::CommitAndExit(EntityType::Ellipse(make_ellipse(
|
||||
*center, *major, *ratio, *t_start, t_end,
|
||||
))));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn on_mouse_move(&mut self, pt: Vec3) -> Option<WireModel> {
|
||||
match &self.step {
|
||||
ArcStep::MajorAxis { center } => Some(line_wire(*center, pt)),
|
||||
ArcStep::MinorRatio { center, major } => {
|
||||
let ratio = minor_ratio(*center, *major, pt).max(0.001);
|
||||
Some(ellipse_wire(*center, *major, ratio, 0.0, TAU))
|
||||
}
|
||||
ArcStep::StartAngle { center, .. } => {
|
||||
// Only the start angle is being chosen here — show a line from
|
||||
// the centre to the cursor to indicate that angle, not a
|
||||
// (misleading) full arc preview.
|
||||
Some(line_wire(*center, pt))
|
||||
}
|
||||
ArcStep::EndAngle {
|
||||
center,
|
||||
major,
|
||||
ratio,
|
||||
t_start,
|
||||
} => {
|
||||
// Detect sweep direction from the change in PARAMETRIC angle
|
||||
// (the visual sweep along the ellipse), not the geometric angle
|
||||
// about the centre — on a flat ellipse a large visible move can
|
||||
// be a tiny centre angle, which made the direction stick. A
|
||||
// tolerance ignores jitter; the reference advances only on a
|
||||
// clear move so slow sweeps accumulate.
|
||||
if let Some(prev) = self.prev_pt {
|
||||
let t_prev = angle_from_point(*center, *major, *ratio, prev);
|
||||
let t_cur = angle_from_point(*center, *major, *ratio, pt);
|
||||
let mut d = t_cur - t_prev;
|
||||
while d > std::f32::consts::PI {
|
||||
d -= TAU;
|
||||
}
|
||||
while d <= -std::f32::consts::PI {
|
||||
d += TAU;
|
||||
}
|
||||
if d.abs() > DIR_TOL {
|
||||
self.cw = d < 0.0;
|
||||
self.prev_pt = Some(pt);
|
||||
}
|
||||
} else {
|
||||
self.prev_pt = Some(pt);
|
||||
}
|
||||
let t_end = angle_from_point(*center, *major, *ratio, pt);
|
||||
Some(if self.cw {
|
||||
ellipse_wire(*center, *major, *ratio, t_end, *t_start)
|
||||
} else {
|
||||
ellipse_wire(*center, *major, *ratio, *t_start, t_end)
|
||||
})
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn dyn_spec(&self) -> Option<crate::command::DynSpec> {
|
||||
use crate::command::{DynAnchor, DynFieldSpec, DynGuide, DynRole, DynSpec};
|
||||
match &self.step {
|
||||
ArcStep::Center | ArcStep::MajorAxis { .. } => None,
|
||||
// Minor half-length, square to the major axis.
|
||||
ArcStep::MinorRatio { center, major } => Some(DynSpec {
|
||||
anchor: DynAnchor::Point(*center),
|
||||
fields: vec![DynFieldSpec::new(DynRole::Distance)],
|
||||
guide: DynGuide::Perp,
|
||||
ref_point: Some(*center + *major),
|
||||
}),
|
||||
// Start / end sweep angles measured at the centre (the last point
|
||||
// is the previous pick, so anchor the angle arc at the centre).
|
||||
ArcStep::StartAngle { center, .. } | ArcStep::EndAngle { center, .. } => {
|
||||
Some(DynSpec {
|
||||
anchor: DynAnchor::Point(*center),
|
||||
fields: vec![DynFieldSpec::new(DynRole::Angle)],
|
||||
guide: DynGuide::Polar,
|
||||
ref_point: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn dyn_live_value(&self, cursor: Vec3) -> Option<f64> {
|
||||
if let ArcStep::MinorRatio { center, major } = &self.step {
|
||||
Some((minor_ratio(*center, *major, cursor) * major.length()) as f64)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Internal helpers ──────────────────────────────────────────────────────
|
||||
|
||||
fn minor_ratio(center: Vec3, major: Vec3, pt: Vec3) -> f32 {
|
||||
let r_major = major.length();
|
||||
if r_major < 1e-9 {
|
||||
return 0.5;
|
||||
}
|
||||
let major_dir = major / r_major;
|
||||
let to_pt = pt - center;
|
||||
let perp = to_pt - major_dir * to_pt.dot(major_dir);
|
||||
let r_minor = perp.length().max(1e-6);
|
||||
(r_minor / r_major).clamp(1e-6, 1.0)
|
||||
}
|
||||
|
||||
fn angle_from_point(center: Vec3, major: Vec3, ratio: f32, pt: Vec3) -> f32 {
|
||||
let r_major = major.length();
|
||||
if r_major < 1e-9 {
|
||||
return 0.0;
|
||||
}
|
||||
let major_dir = major / r_major;
|
||||
let v = Vec3::Z.cross(major_dir).normalize();
|
||||
param_angle(center, major_dir, v, pt, ratio)
|
||||
}
|
||||
|
||||
fn line_wire(from: Vec3, to: Vec3) -> WireModel {
|
||||
WireModel {
|
||||
name: "rubber_band".into(),
|
||||
points: vec![[from.x, from.y, from.z], [to.x, to.y, to.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![],
|
||||
aci: 0,
|
||||
key_vertices: vec![],
|
||||
aabb: WireModel::UNBOUNDED_AABB,
|
||||
plinegen: true,
|
||||
vp_scissor: None,
|
||||
fill_tris: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ── Autocomplete registry ─────────────────────────────────
|
||||
inventory::submit!(crate::command::CommandRegistration { names: &["ELLIPSE_ARC"] }); // EllipseArcCommand
|
||||
inventory::submit!(crate::command::CommandRegistration { names: &["ELLIPSE_AXIS"] }); // EllipseAxisCommand
|
||||
inventory::submit!(crate::command::CommandRegistration { names: &["EL", "ELLIPSE"] }); // EllipseCommand
|
||||
@@ -0,0 +1,434 @@
|
||||
// Hatch/Gradient/Boundary commands — OpenCADStudio Home > Draw > Hatch dropdown.
|
||||
//
|
||||
// Commands:
|
||||
// HATCH — ANSI31: 45° hatch lines (pick inside or type S for manual)
|
||||
// GRADIENT — Linear gradient fill (pick inside or type S for manual)
|
||||
// BOUNDARY — Traces the enclosing boundary as a closed LwPolyline
|
||||
//
|
||||
// Primary workflow (matches OpenCADStudio):
|
||||
// Click a point INSIDE a closed region → boundary auto-detected.
|
||||
// Type "S" to switch to manual vertex-picking mode (HATCH/GRADIENT only).
|
||||
|
||||
use crate::command::{CadCommand, CmdResult};
|
||||
use crate::modules::IconKind;
|
||||
use crate::scene::model::hatch_model::{HatchModel, HatchPattern, PatFamily};
|
||||
use crate::scene::model::wire_model::WireModel;
|
||||
use glam::Vec3;
|
||||
|
||||
// ── Icons ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const ICON_HATCH: IconKind = IconKind::Svg(include_bytes!(
|
||||
"../../../../assets/icons/hatch/hatch_lines.svg"
|
||||
));
|
||||
const ICON_GRADIENT: IconKind = IconKind::Svg(include_bytes!(
|
||||
"../../../../assets/icons/hatch/hatch_gradient.svg"
|
||||
));
|
||||
const ICON_BOUNDARY: IconKind = IconKind::Svg(include_bytes!(
|
||||
"../../../../assets/icons/hatch/hatch_boundary.svg"
|
||||
));
|
||||
|
||||
// ── Dropdown metadata ──────────────────────────────────────────────────────
|
||||
|
||||
pub const DROPDOWN_ID: &str = "HATCH";
|
||||
pub const ICON: IconKind = ICON_HATCH;
|
||||
|
||||
pub const DROPDOWN_ITEMS: &[(&str, &str, IconKind)] = &[
|
||||
("HATCH", "Hatch", ICON_HATCH),
|
||||
("GRADIENT", "Gradient", ICON_GRADIENT),
|
||||
("BOUNDARY", "Boundary", ICON_BOUNDARY),
|
||||
];
|
||||
|
||||
// ── Shared mode ────────────────────────────────────────────────────────────
|
||||
|
||||
enum Mode {
|
||||
/// Primary: click inside a closed shape → boundary auto-detected.
|
||||
PickInside,
|
||||
/// Fallback: user manually picks polygon vertices (type "S" to enter).
|
||||
Manual,
|
||||
}
|
||||
|
||||
// ── CPU point-in-polygon (ray casting) ────────────────────────────────────
|
||||
|
||||
fn point_in_polygon(p: [f32; 2], poly: &[[f32; 2]]) -> bool {
|
||||
let n = poly.len();
|
||||
if n < 3 {
|
||||
return false;
|
||||
}
|
||||
let mut inside = false;
|
||||
let mut j = n - 1;
|
||||
for i in 0..n {
|
||||
let vi = poly[i];
|
||||
let vj = poly[j];
|
||||
if (vi[1] > p[1]) != (vj[1] > p[1]) {
|
||||
let x_int = (vj[0] - vi[0]) * (p[1] - vi[1]) / (vj[1] - vi[1]) + vi[0];
|
||||
if p[0] < x_int {
|
||||
inside = !inside;
|
||||
}
|
||||
}
|
||||
j = i;
|
||||
}
|
||||
inside
|
||||
}
|
||||
|
||||
// ── HATCH command ──────────────────────────────────────────────────────────
|
||||
|
||||
pub struct HatchCommand {
|
||||
outlines: Vec<Vec<[f32; 2]>>,
|
||||
mode: Mode,
|
||||
manual_pts: Vec<Vec3>,
|
||||
missed: bool,
|
||||
}
|
||||
|
||||
impl HatchCommand {
|
||||
pub fn new(outlines: Vec<Vec<[f32; 2]>>) -> Self {
|
||||
Self {
|
||||
outlines,
|
||||
mode: Mode::PickInside,
|
||||
manual_pts: vec![],
|
||||
missed: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn make_hatch(&self, boundary: Vec<[f32; 2]>) -> HatchModel {
|
||||
// Default: ANSI31 from catalog; fallback to a single 45° family.
|
||||
let pat_name = "ANSI31";
|
||||
let families = crate::scene::model::hatch_patterns::find(pat_name)
|
||||
.and_then(|e| {
|
||||
if let HatchPattern::Pattern(f) = &e.gpu {
|
||||
Some(f.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.unwrap_or_else(|| {
|
||||
// 45° lines, perpendicular spacing ≈ 5 world units.
|
||||
let dy = 5.0_f32 / (45.0_f32.to_radians().cos());
|
||||
vec![PatFamily {
|
||||
angle_deg: 45.0,
|
||||
x0: 0.0,
|
||||
y0: 0.0,
|
||||
dx: 0.0,
|
||||
dy,
|
||||
dashes: vec![],
|
||||
}]
|
||||
});
|
||||
HatchModel {
|
||||
boundary: std::sync::Arc::new(boundary),
|
||||
pattern: HatchPattern::Pattern(families),
|
||||
name: pat_name.into(),
|
||||
color: [0.75, 0.75, 0.75, 0.85],
|
||||
angle_offset: 0.0,
|
||||
scale: 1.0,
|
||||
world_origin: [0.0; 2],
|
||||
vp_scissor: None,
|
||||
draw_depth: 0.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CadCommand for HatchCommand {
|
||||
fn name(&self) -> &'static str {
|
||||
"HATCH"
|
||||
}
|
||||
|
||||
fn prompt(&self) -> String {
|
||||
match &self.mode {
|
||||
Mode::PickInside => {
|
||||
let miss = if self.missed {
|
||||
" ⚠ No closed boundary found."
|
||||
} else {
|
||||
""
|
||||
};
|
||||
format!("HATCH Pick internal point [S=Draw manually]:{miss}")
|
||||
}
|
||||
Mode::Manual => {
|
||||
if self.manual_pts.is_empty() {
|
||||
"HATCH Boundary point 1:".into()
|
||||
} else {
|
||||
format!(
|
||||
"HATCH Point {} [Enter=accept, ≥3 points]:",
|
||||
self.manual_pts.len() + 1
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn on_point(&mut self, pt: Vec3) -> CmdResult {
|
||||
match &self.mode {
|
||||
Mode::PickInside => {
|
||||
let xy = [pt.x, pt.y];
|
||||
for outline in &self.outlines {
|
||||
if point_in_polygon(xy, outline) {
|
||||
self.missed = false;
|
||||
return CmdResult::CommitHatch(self.make_hatch(outline.clone()));
|
||||
}
|
||||
}
|
||||
self.missed = true;
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
Mode::Manual => {
|
||||
self.manual_pts.push(pt);
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn on_enter(&mut self) -> CmdResult {
|
||||
match &self.mode {
|
||||
Mode::PickInside => CmdResult::Cancel,
|
||||
Mode::Manual => {
|
||||
if self.manual_pts.len() < 3 {
|
||||
return CmdResult::Cancel;
|
||||
}
|
||||
let boundary = self.manual_pts.iter().map(|p| [p.x, p.y]).collect();
|
||||
CmdResult::CommitHatch(self.make_hatch(boundary))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn on_escape(&mut self) -> CmdResult {
|
||||
CmdResult::Cancel
|
||||
}
|
||||
|
||||
fn wants_text_input(&self) -> bool {
|
||||
matches!(self.mode, Mode::PickInside)
|
||||
}
|
||||
|
||||
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
|
||||
if text.trim().eq_ignore_ascii_case("s") {
|
||||
self.mode = Mode::Manual;
|
||||
self.missed = false;
|
||||
return Some(CmdResult::NeedPoint);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn on_mouse_move(&mut self, pt: Vec3) -> Option<WireModel> {
|
||||
if let Mode::Manual = &self.mode {
|
||||
if self.manual_pts.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut pts: Vec<[f32; 3]> = self.manual_pts.iter().map(|p| [p.x, p.y, p.z]).collect();
|
||||
pts.push([pt.x, pt.y, pt.z]);
|
||||
pts.push([
|
||||
self.manual_pts[0].x,
|
||||
self.manual_pts[0].y,
|
||||
self.manual_pts[0].z,
|
||||
]);
|
||||
return Some(WireModel::solid(
|
||||
"rubber_band".into(),
|
||||
pts,
|
||||
WireModel::CYAN,
|
||||
false,
|
||||
));
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
// ── GRADIENT command ───────────────────────────────────────────────────────
|
||||
|
||||
pub struct GradientCommand {
|
||||
outlines: Vec<Vec<[f32; 2]>>,
|
||||
mode: Mode,
|
||||
manual_pts: Vec<Vec3>,
|
||||
missed: bool,
|
||||
}
|
||||
|
||||
impl GradientCommand {
|
||||
pub fn new(outlines: Vec<Vec<[f32; 2]>>) -> Self {
|
||||
Self {
|
||||
outlines,
|
||||
mode: Mode::PickInside,
|
||||
manual_pts: vec![],
|
||||
missed: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn make_hatch(&self, boundary: Vec<[f32; 2]>) -> HatchModel {
|
||||
HatchModel {
|
||||
boundary: std::sync::Arc::new(boundary),
|
||||
pattern: HatchPattern::Gradient {
|
||||
angle_deg: 0.0,
|
||||
color2: [0.18, 0.18, 0.18, 0.0],
|
||||
},
|
||||
name: "LINEAR".into(),
|
||||
color: [0.30, 0.60, 0.95, 0.80],
|
||||
angle_offset: 0.0,
|
||||
scale: 1.0,
|
||||
world_origin: [0.0; 2],
|
||||
vp_scissor: None,
|
||||
draw_depth: 0.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CadCommand for GradientCommand {
|
||||
fn name(&self) -> &'static str {
|
||||
"GRADIENT"
|
||||
}
|
||||
|
||||
fn prompt(&self) -> String {
|
||||
match &self.mode {
|
||||
Mode::PickInside => {
|
||||
let miss = if self.missed {
|
||||
" ⚠ No closed boundary found."
|
||||
} else {
|
||||
""
|
||||
};
|
||||
format!("GRADIENT Pick internal point [S=Draw manually]:{miss}")
|
||||
}
|
||||
Mode::Manual => {
|
||||
if self.manual_pts.is_empty() {
|
||||
"GRADIENT Boundary point 1:".into()
|
||||
} else {
|
||||
format!(
|
||||
"GRADIENT Point {} [Enter=accept, ≥3 points]:",
|
||||
self.manual_pts.len() + 1
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn on_point(&mut self, pt: Vec3) -> CmdResult {
|
||||
match &self.mode {
|
||||
Mode::PickInside => {
|
||||
let xy = [pt.x, pt.y];
|
||||
for outline in &self.outlines {
|
||||
if point_in_polygon(xy, outline) {
|
||||
self.missed = false;
|
||||
return CmdResult::CommitHatch(self.make_hatch(outline.clone()));
|
||||
}
|
||||
}
|
||||
self.missed = true;
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
Mode::Manual => {
|
||||
self.manual_pts.push(pt);
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn on_enter(&mut self) -> CmdResult {
|
||||
match &self.mode {
|
||||
Mode::PickInside => CmdResult::Cancel,
|
||||
Mode::Manual => {
|
||||
if self.manual_pts.len() < 3 {
|
||||
return CmdResult::Cancel;
|
||||
}
|
||||
let boundary = self.manual_pts.iter().map(|p| [p.x, p.y]).collect();
|
||||
CmdResult::CommitHatch(self.make_hatch(boundary))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn on_escape(&mut self) -> CmdResult {
|
||||
CmdResult::Cancel
|
||||
}
|
||||
|
||||
fn wants_text_input(&self) -> bool {
|
||||
matches!(self.mode, Mode::PickInside)
|
||||
}
|
||||
|
||||
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
|
||||
if text.trim().eq_ignore_ascii_case("s") {
|
||||
self.mode = Mode::Manual;
|
||||
self.missed = false;
|
||||
return Some(CmdResult::NeedPoint);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn on_mouse_move(&mut self, pt: Vec3) -> Option<WireModel> {
|
||||
if let Mode::Manual = &self.mode {
|
||||
if self.manual_pts.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut pts: Vec<[f32; 3]> = self.manual_pts.iter().map(|p| [p.x, p.y, p.z]).collect();
|
||||
pts.push([pt.x, pt.y, pt.z]);
|
||||
pts.push([
|
||||
self.manual_pts[0].x,
|
||||
self.manual_pts[0].y,
|
||||
self.manual_pts[0].z,
|
||||
]);
|
||||
return Some(WireModel::solid(
|
||||
"rubber_band".into(),
|
||||
pts,
|
||||
WireModel::CYAN,
|
||||
false,
|
||||
));
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
// ── BOUNDARY command ───────────────────────────────────────────────────────
|
||||
|
||||
pub struct BoundaryCommand {
|
||||
outlines: Vec<Vec<[f32; 2]>>,
|
||||
missed: bool,
|
||||
}
|
||||
|
||||
impl BoundaryCommand {
|
||||
pub fn new(outlines: Vec<Vec<[f32; 2]>>) -> Self {
|
||||
Self {
|
||||
outlines,
|
||||
missed: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CadCommand for BoundaryCommand {
|
||||
fn name(&self) -> &'static str {
|
||||
"BOUNDARY"
|
||||
}
|
||||
|
||||
fn prompt(&self) -> String {
|
||||
let miss = if self.missed {
|
||||
" ⚠ No closed boundary found."
|
||||
} else {
|
||||
""
|
||||
};
|
||||
format!("BOUNDARY Pick internal point:{miss}")
|
||||
}
|
||||
|
||||
fn on_point(&mut self, pt: Vec3) -> CmdResult {
|
||||
let xy = [pt.x, pt.y];
|
||||
for outline in &self.outlines {
|
||||
if point_in_polygon(xy, outline) {
|
||||
self.missed = false;
|
||||
// Store as a Hatch entity (solid fill) so it is selectable.
|
||||
let model = HatchModel {
|
||||
boundary: std::sync::Arc::new(outline.clone()),
|
||||
pattern: HatchPattern::Solid,
|
||||
name: "SOLID".into(),
|
||||
color: [0.45, 0.45, 0.45, 0.60],
|
||||
angle_offset: 0.0,
|
||||
scale: 1.0,
|
||||
world_origin: [0.0; 2],
|
||||
vp_scissor: None,
|
||||
draw_depth: 0.0,
|
||||
};
|
||||
return CmdResult::CommitHatch(model);
|
||||
}
|
||||
}
|
||||
self.missed = true;
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
|
||||
fn on_enter(&mut self) -> CmdResult {
|
||||
CmdResult::Cancel
|
||||
}
|
||||
fn on_escape(&mut self) -> CmdResult {
|
||||
CmdResult::Cancel
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ── Autocomplete registry ─────────────────────────────────
|
||||
inventory::submit!(crate::command::CommandRegistration { names: &["BOUNDARY"] }); // BoundaryCommand
|
||||
inventory::submit!(crate::command::CommandRegistration { names: &["GRADIENT"] }); // GradientCommand
|
||||
inventory::submit!(crate::command::CommandRegistration { names: &["H", "HATCH"] }); // HatchCommand
|
||||
@@ -0,0 +1,167 @@
|
||||
// HATCHEDIT — edit an existing hatch entity's pattern, scale, or angle.
|
||||
//
|
||||
// Workflow:
|
||||
// 1. Pick or pre-select a Hatch entity.
|
||||
// 2. Enter options:
|
||||
// P <name> — change pattern (ANSI31, SOLID, etc.)
|
||||
// S <value> — change scale
|
||||
// A <degrees> — change angle
|
||||
// Press Enter to apply changes.
|
||||
|
||||
use acadrust::Handle;
|
||||
use glam::Vec3;
|
||||
|
||||
use crate::command::{CadCommand, CmdResult};
|
||||
|
||||
enum HatcheditStep {
|
||||
PickHatch,
|
||||
EditOptions {
|
||||
handle: Handle,
|
||||
name: String,
|
||||
scale: f32,
|
||||
angle: f32,
|
||||
},
|
||||
}
|
||||
|
||||
pub struct HatcheditCommand {
|
||||
step: HatcheditStep,
|
||||
}
|
||||
|
||||
impl HatcheditCommand {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
step: HatcheditStep::PickHatch,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_handle(handle: Handle, name: String, scale: f32, angle: f32) -> Self {
|
||||
Self {
|
||||
step: HatcheditStep::EditOptions {
|
||||
handle,
|
||||
name,
|
||||
scale,
|
||||
angle,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CadCommand for HatcheditCommand {
|
||||
fn name(&self) -> &'static str {
|
||||
"HATCHEDIT"
|
||||
}
|
||||
|
||||
fn prompt(&self) -> String {
|
||||
match &self.step {
|
||||
HatcheditStep::PickHatch => "HATCHEDIT Select hatch:".into(),
|
||||
HatcheditStep::EditOptions {
|
||||
name, scale, angle, ..
|
||||
} => format!(
|
||||
"HATCHEDIT Pattern:{name} Scale:{scale:.4} Angle:{angle:.1} \
|
||||
[P <pat> / S <scale> / A <angle> | Enter to apply]:"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn needs_entity_pick(&self) -> bool {
|
||||
matches!(self.step, HatcheditStep::PickHatch)
|
||||
}
|
||||
|
||||
fn on_entity_pick(&mut self, handle: Handle, _pt: Vec3) -> CmdResult {
|
||||
if handle.is_null() {
|
||||
return CmdResult::NeedPoint;
|
||||
}
|
||||
// Actual hatch model retrieval happens in commands.rs dispatch.
|
||||
// Store handle; name/scale/angle filled in by dispatch.
|
||||
self.step = HatcheditStep::EditOptions {
|
||||
handle,
|
||||
name: String::new(),
|
||||
scale: 1.0,
|
||||
angle: 0.0,
|
||||
};
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
|
||||
fn wants_text_input(&self) -> bool {
|
||||
matches!(self.step, HatcheditStep::EditOptions { .. })
|
||||
}
|
||||
|
||||
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
|
||||
let (handle, name, scale, angle) = match &mut self.step {
|
||||
HatcheditStep::EditOptions {
|
||||
handle,
|
||||
name,
|
||||
scale,
|
||||
angle,
|
||||
} => (*handle, name, scale, angle),
|
||||
_ => return None,
|
||||
};
|
||||
|
||||
let text = text.trim().to_uppercase();
|
||||
|
||||
if text.is_empty() {
|
||||
// Apply and exit
|
||||
return Some(CmdResult::HatcheditApply {
|
||||
handle,
|
||||
name: name.clone(),
|
||||
scale: *scale,
|
||||
angle: *angle,
|
||||
});
|
||||
}
|
||||
|
||||
// Parse option: P/S/A followed by value
|
||||
if let Some(rest) = text.strip_prefix('P') {
|
||||
let n = rest.trim().to_string();
|
||||
if !n.is_empty() {
|
||||
*name = n;
|
||||
}
|
||||
return Some(CmdResult::NeedPoint);
|
||||
}
|
||||
if let Some(rest) = text.strip_prefix('S') {
|
||||
if let Ok(v) = rest.trim().replace(',', ".").parse::<f32>() {
|
||||
if v > 0.0 {
|
||||
*scale = v;
|
||||
}
|
||||
}
|
||||
return Some(CmdResult::NeedPoint);
|
||||
}
|
||||
if let Some(rest) = text.strip_prefix('A') {
|
||||
if let Ok(v) = rest.trim().replace(',', ".").parse::<f32>() {
|
||||
*angle = v;
|
||||
}
|
||||
return Some(CmdResult::NeedPoint);
|
||||
}
|
||||
|
||||
// Unrecognized — stay and re-prompt
|
||||
Some(CmdResult::NeedPoint)
|
||||
}
|
||||
|
||||
fn on_point(&mut self, _pt: Vec3) -> CmdResult {
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
fn on_enter(&mut self) -> CmdResult {
|
||||
// Enter without text → apply current settings
|
||||
let (handle, name, scale, angle) = match &self.step {
|
||||
HatcheditStep::EditOptions {
|
||||
handle,
|
||||
name,
|
||||
scale,
|
||||
angle,
|
||||
} => (*handle, name.clone(), *scale, *angle),
|
||||
_ => return CmdResult::Cancel,
|
||||
};
|
||||
CmdResult::HatcheditApply {
|
||||
handle,
|
||||
name,
|
||||
scale,
|
||||
angle,
|
||||
}
|
||||
}
|
||||
fn on_escape(&mut self) -> CmdResult {
|
||||
CmdResult::Cancel
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ── Autocomplete registry ─────────────────────────────────
|
||||
inventory::submit!(crate::command::CommandRegistration { names: &["HATCHEDIT", "HE"] }); // HatcheditCommand
|
||||
@@ -0,0 +1,100 @@
|
||||
// Line tool — ribbon definition + interactive command.
|
||||
//
|
||||
// Command: LINE — OpenCADStudio behaviour:
|
||||
// 1. First click → stores start point, prompts for next point
|
||||
// 2. Each further click → immediately commits an acadrust::Line entity
|
||||
// (start→end) to the document; end becomes the new start point
|
||||
// 3. Enter / Escape → ends the command
|
||||
|
||||
use acadrust::types::Vector3;
|
||||
use acadrust::{EntityType, Line};
|
||||
|
||||
use crate::command::{CadCommand, CmdResult};
|
||||
use crate::modules::{IconKind, ModuleEvent, ToolDef};
|
||||
use crate::scene::model::wire_model::WireModel;
|
||||
use glam::Vec3;
|
||||
|
||||
// ── Ribbon definition ─────────────────────────────────────────────────────
|
||||
|
||||
pub fn tool() -> ToolDef {
|
||||
ToolDef {
|
||||
id: "LINE",
|
||||
label: "Line",
|
||||
icon: IconKind::Svg(include_bytes!("../../../../assets/icons/line.svg")),
|
||||
event: ModuleEvent::Command("LINE".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
// ── Command implementation ────────────────────────────────────────────────
|
||||
|
||||
pub struct LineCommand {
|
||||
/// The last committed point (start of the next segment).
|
||||
last: Option<Vec3>,
|
||||
}
|
||||
|
||||
impl LineCommand {
|
||||
pub fn new() -> Self {
|
||||
Self { last: None }
|
||||
}
|
||||
}
|
||||
|
||||
impl CadCommand for LineCommand {
|
||||
fn name(&self) -> &'static str {
|
||||
"LINE"
|
||||
}
|
||||
|
||||
fn prompt(&self) -> String {
|
||||
if self.last.is_none() {
|
||||
"LINE Specify first point:".to_string()
|
||||
} else {
|
||||
"LINE Specify next point [Enter/Esc = done]:".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn on_point(&mut self, pt: Vec3) -> CmdResult {
|
||||
if let Some(last) = self.last {
|
||||
let line = Line::from_points(
|
||||
Vector3::new(last.x as f64, last.y as f64, last.z as f64),
|
||||
Vector3::new(pt.x as f64, pt.y as f64, pt.z as f64),
|
||||
);
|
||||
self.last = Some(pt);
|
||||
CmdResult::CommitEntity(EntityType::Line(line))
|
||||
} else {
|
||||
self.last = Some(pt);
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
}
|
||||
|
||||
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> {
|
||||
let last = self.last?;
|
||||
Some(WireModel {
|
||||
name: "rubber_band".to_string(),
|
||||
points: vec![[last.x, last.y, last.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![],
|
||||
aci: 0,
|
||||
key_vertices: vec![],
|
||||
aabb: WireModel::UNBOUNDED_AABB,
|
||||
plinegen: true,
|
||||
vp_scissor: None,
|
||||
fill_tris: vec![],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ── Autocomplete registry ─────────────────────────────────
|
||||
inventory::submit!(crate::command::CommandRegistration { names: &["L", "LINE"] }); // LineCommand
|
||||
@@ -0,0 +1,170 @@
|
||||
// MLINE command — create a multiline (parallel lines).
|
||||
//
|
||||
// Workflow: pick vertices, Enter to finish.
|
||||
// Text input (when >= 1 point picked):
|
||||
// C / CLOSE → close and commit
|
||||
// S <value> → set scale factor then continue picking
|
||||
|
||||
use acadrust::entities::MLine;
|
||||
use acadrust::types::Vector3;
|
||||
use acadrust::EntityType;
|
||||
use glam::Vec3;
|
||||
|
||||
use crate::command::{CadCommand, CmdResult};
|
||||
use crate::scene::model::wire_model::WireModel;
|
||||
|
||||
pub struct MlineCommand {
|
||||
points: Vec<Vec3>,
|
||||
scale: f64,
|
||||
waiting_scale: bool,
|
||||
style_name: String,
|
||||
}
|
||||
|
||||
impl MlineCommand {
|
||||
#[allow(dead_code)]
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
points: vec![],
|
||||
scale: 1.0,
|
||||
waiting_scale: false,
|
||||
style_name: "Standard".into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_style(style_name: impl Into<String>) -> Self {
|
||||
Self {
|
||||
points: vec![],
|
||||
scale: 1.0,
|
||||
waiting_scale: false,
|
||||
style_name: style_name.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CadCommand for MlineCommand {
|
||||
fn name(&self) -> &'static str {
|
||||
"MLINE"
|
||||
}
|
||||
|
||||
fn prompt(&self) -> String {
|
||||
if self.waiting_scale {
|
||||
"MLINE Enter scale factor:".into()
|
||||
} else if self.points.is_empty() {
|
||||
format!("MLINE Specify start point (scale={:.2}):", self.scale)
|
||||
} else {
|
||||
format!(
|
||||
"MLINE Specify next point ({} pts, Enter to finish, C to close, S to set scale):",
|
||||
self.points.len()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn wants_text_input(&self) -> bool {
|
||||
self.waiting_scale || !self.points.is_empty()
|
||||
}
|
||||
|
||||
fn point_step_accepts_keywords(&self) -> bool {
|
||||
// The vertex steps accept J / S keywords but are point picks, so keep
|
||||
// polar dynamic input. The scale prompt (`waiting_scale`) is genuine
|
||||
// text entry and is excluded.
|
||||
!self.waiting_scale && !self.points.is_empty()
|
||||
}
|
||||
|
||||
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
|
||||
// Waiting for scale value
|
||||
if self.waiting_scale {
|
||||
let v: f64 = text
|
||||
.trim()
|
||||
.replace(',', ".")
|
||||
.parse()
|
||||
.ok()
|
||||
.filter(|&v: &f64| v > 0.0)?;
|
||||
self.scale = v;
|
||||
self.waiting_scale = false;
|
||||
return Some(CmdResult::NeedPoint);
|
||||
}
|
||||
|
||||
let up = text.trim().to_uppercase();
|
||||
|
||||
// Close command
|
||||
if (up == "C" || up == "CLOSE") && self.points.len() >= 3 {
|
||||
let entity = build_mline(&self.points, self.scale, true, &self.style_name);
|
||||
return Some(CmdResult::CommitAndExit(entity));
|
||||
}
|
||||
|
||||
// Scale: "S" alone → prompt for value
|
||||
if up == "S" {
|
||||
self.waiting_scale = true;
|
||||
return Some(CmdResult::NeedPoint);
|
||||
}
|
||||
|
||||
// Scale: "S <value>" inline
|
||||
if let Some(rest) = up.strip_prefix("S ") {
|
||||
if let Ok(v) = rest.trim().replace(',', ".").parse::<f64>() {
|
||||
if v > 0.0 {
|
||||
self.scale = v;
|
||||
}
|
||||
return Some(CmdResult::NeedPoint);
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn on_point(&mut self, pt: Vec3) -> CmdResult {
|
||||
self.points.push(pt);
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
|
||||
fn on_enter(&mut self) -> CmdResult {
|
||||
if self.points.len() < 2 {
|
||||
return CmdResult::Cancel;
|
||||
}
|
||||
let entity = build_mline(&self.points, self.scale, false, &self.style_name);
|
||||
CmdResult::CommitAndExit(entity)
|
||||
}
|
||||
|
||||
fn on_mouse_move(&mut self, pt: Vec3) -> Option<WireModel> {
|
||||
if self.points.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut pts: Vec<[f32; 3]> = self.points.iter().map(|p| [p.x, p.y, p.z]).collect();
|
||||
pts.push([pt.x, pt.y, pt.z]);
|
||||
Some(WireModel {
|
||||
name: "mline_preview".into(),
|
||||
points: pts,
|
||||
color: WireModel::CYAN,
|
||||
selected: false,
|
||||
pattern_length: 0.0,
|
||||
pattern: [0.0; 8],
|
||||
line_weight_px: 1.0,
|
||||
snap_pts: vec![],
|
||||
tangent_geoms: vec![],
|
||||
aci: 0,
|
||||
key_vertices: vec![],
|
||||
aabb: WireModel::UNBOUNDED_AABB,
|
||||
plinegen: true,
|
||||
vp_scissor: None,
|
||||
fill_tris: vec![],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn build_mline(pts: &[Vec3], scale: f64, closed: bool, style_name: &str) -> EntityType {
|
||||
let verts: Vec<Vector3> = pts
|
||||
.iter()
|
||||
.map(|p| Vector3::new(p.x as f64, p.y as f64, p.z as f64))
|
||||
.collect();
|
||||
let mut mline = if closed {
|
||||
MLine::closed_from_points(&verts)
|
||||
} else {
|
||||
MLine::from_points(&verts)
|
||||
};
|
||||
mline.scale_factor = scale;
|
||||
mline.style_name = style_name.to_string();
|
||||
EntityType::MLine(mline)
|
||||
}
|
||||
|
||||
|
||||
// ── Autocomplete registry ─────────────────────────────────
|
||||
inventory::submit!(crate::command::CommandRegistration { names: &["ML", "MLINE"] }); // MlineCommand
|
||||
@@ -0,0 +1,17 @@
|
||||
pub mod arc;
|
||||
pub mod attdef;
|
||||
pub mod circle;
|
||||
pub mod donut;
|
||||
pub mod ellipse;
|
||||
pub mod hatch;
|
||||
pub mod hatchedit;
|
||||
pub mod line;
|
||||
pub mod mline;
|
||||
pub mod point;
|
||||
pub mod polyline;
|
||||
pub mod raster_image;
|
||||
pub mod ray;
|
||||
pub mod revcloud;
|
||||
pub mod shapes;
|
||||
pub mod spline;
|
||||
pub mod wipeout;
|
||||
@@ -0,0 +1,61 @@
|
||||
// Point tool — ribbon definition + interactive command.
|
||||
//
|
||||
// Command: POINT (PO)
|
||||
// Single click → commits EntityType::Point. Stays active for more points.
|
||||
|
||||
use acadrust::types::Vector3;
|
||||
use acadrust::{EntityType, Point as CadPoint};
|
||||
|
||||
use crate::command::{CadCommand, CmdResult};
|
||||
use crate::modules::{IconKind, ModuleEvent, ToolDef};
|
||||
use crate::scene::model::wire_model::WireModel;
|
||||
use glam::Vec3;
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn tool() -> ToolDef {
|
||||
ToolDef {
|
||||
id: "POINT",
|
||||
label: "Point",
|
||||
icon: IconKind::Svg(include_bytes!("../../../../assets/icons/point.svg")),
|
||||
event: ModuleEvent::Command("POINT".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
pub struct PointCommand;
|
||||
|
||||
impl PointCommand {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
}
|
||||
|
||||
impl CadCommand for PointCommand {
|
||||
fn name(&self) -> &'static str {
|
||||
"POINT"
|
||||
}
|
||||
fn prompt(&self) -> String {
|
||||
"POINT Specify point [Enter=done]:".into()
|
||||
}
|
||||
|
||||
fn on_point(&mut self, pt: Vec3) -> CmdResult {
|
||||
let p = CadPoint {
|
||||
location: Vector3::new(pt.x as f64, pt.y as f64, pt.z as f64),
|
||||
..Default::default()
|
||||
};
|
||||
CmdResult::CommitEntity(EntityType::Point(p))
|
||||
}
|
||||
|
||||
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> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ── Autocomplete registry ─────────────────────────────────
|
||||
inventory::submit!(crate::command::CommandRegistration { names: &["PO", "POINT"] }); // PointCommand
|
||||
@@ -0,0 +1,333 @@
|
||||
// Polyline tool — ribbon definition + interactive command.
|
||||
//
|
||||
// Command: PLINE (PL)
|
||||
// Each click adds a vertex.
|
||||
// Type A = switch to Arc segment mode.
|
||||
// Type L = switch back to Line segment mode.
|
||||
// Enter / C = close and commit. Escape = commit as-is (if ≥2 vertices).
|
||||
//
|
||||
// Arc mode: arcs are tangent-continuous with the preceding segment.
|
||||
// Bulge is stored per vertex (segment i→i+1); positive = CCW, negative = CW.
|
||||
|
||||
use acadrust::entities::LwVertex;
|
||||
use acadrust::types::Vector2;
|
||||
use acadrust::{EntityType, LwPolyline};
|
||||
use glam::{Vec2, 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: "PLINE",
|
||||
label: "Polyline",
|
||||
icon: IconKind::Svg(include_bytes!("../../../../assets/icons/polyline.svg")),
|
||||
event: ModuleEvent::Command("PLINE".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
// ── Segment mode ───────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
enum SegMode {
|
||||
Line,
|
||||
Arc,
|
||||
}
|
||||
|
||||
// ── Command implementation ─────────────────────────────────────────────────
|
||||
|
||||
pub struct PlineCommand {
|
||||
vertices: Vec<Vec3>,
|
||||
/// Bulge for segment i → i+1 (one entry per vertex; last entry unused on commit).
|
||||
bulges: Vec<f64>,
|
||||
mode: SegMode,
|
||||
/// Unit direction of the last committed segment (for arc tangent continuity).
|
||||
last_tangent: Option<Vec2>,
|
||||
}
|
||||
|
||||
impl PlineCommand {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
vertices: Vec::new(),
|
||||
bulges: Vec::new(),
|
||||
mode: SegMode::Line,
|
||||
last_tangent: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn build_entity(&self, closed: bool) -> Option<EntityType> {
|
||||
if self.vertices.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
let lw_verts: Vec<LwVertex> = self
|
||||
.vertices
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, v)| {
|
||||
let mut lv = LwVertex::new(Vector2::new(v.x as f64, v.y as f64));
|
||||
lv.bulge = self.bulges.get(i).copied().unwrap_or(0.0);
|
||||
lv
|
||||
})
|
||||
.collect();
|
||||
let pline = LwPolyline {
|
||||
vertices: lw_verts,
|
||||
is_closed: closed,
|
||||
..Default::default()
|
||||
};
|
||||
Some(EntityType::LwPolyline(pline))
|
||||
}
|
||||
}
|
||||
|
||||
// ── Arc geometry helpers ───────────────────────────────────────────────────
|
||||
|
||||
/// Compute the bulge for the arc from `a` to `b` that is tangent to `tangent` at `a`.
|
||||
/// Returns 0.0 if the points are coincident or the tangent is parallel to the chord.
|
||||
fn compute_bulge(a: Vec2, tangent: Vec2, b: Vec2) -> f64 {
|
||||
let d = b - a;
|
||||
let len_sq = d.length_squared();
|
||||
if len_sq < 1e-10 {
|
||||
return 0.0;
|
||||
}
|
||||
// Perpendicular to tangent (CCW) — this is the direction to the arc center.
|
||||
let perp = Vec2::new(-tangent.y, tangent.x);
|
||||
let dot = d.dot(perp);
|
||||
if dot.abs() < 1e-10 {
|
||||
// Tangent is perpendicular to chord → straight line (bulge = 0).
|
||||
return 0.0;
|
||||
}
|
||||
// t = distance from a to center along perp.
|
||||
let t = len_sq / (2.0 * dot);
|
||||
let center = a + perp * t;
|
||||
|
||||
// Arc angle from start to end (signed).
|
||||
let start_angle = (a - center).y.atan2((a - center).x);
|
||||
let end_angle = (b - center).y.atan2((b - center).x);
|
||||
let mut arc_angle = end_angle - start_angle;
|
||||
|
||||
if t > 0.0 {
|
||||
// CCW arc: ensure arc_angle is in (0, 2π].
|
||||
if arc_angle <= 0.0 {
|
||||
arc_angle += std::f32::consts::TAU;
|
||||
}
|
||||
} else {
|
||||
// CW arc: ensure arc_angle is in [-2π, 0).
|
||||
if arc_angle >= 0.0 {
|
||||
arc_angle -= std::f32::consts::TAU;
|
||||
}
|
||||
}
|
||||
(arc_angle as f64 / 4.0).tan()
|
||||
}
|
||||
|
||||
/// Update `tangent` after an arc segment described by `bulge` from `a` to `b`.
|
||||
fn update_tangent_after_arc(tangent: &mut Option<Vec2>, bulge: f64) {
|
||||
let Some(t) = *tangent else {
|
||||
return;
|
||||
};
|
||||
// The arc sweeps theta = 4*atan(bulge) radians, so the exit tangent is
|
||||
// the entry tangent rotated by that angle.
|
||||
let theta = 4.0 * (bulge as f32).atan();
|
||||
let (sin_t, cos_t) = theta.sin_cos();
|
||||
*tangent =
|
||||
Some(Vec2::new(t.x * cos_t - t.y * sin_t, t.x * sin_t + t.y * cos_t).normalize_or_zero());
|
||||
}
|
||||
|
||||
/// Sample a circular arc defined by bulge into `n` line-segment points.
|
||||
/// Returns the sampled [x, y, z] points (uses `z` from `a`).
|
||||
fn arc_sample_points(a: Vec3, bulge: f64, b: Vec3, n: usize) -> Vec<[f32; 3]> {
|
||||
let ax = a.x as f64;
|
||||
let ay = a.y as f64;
|
||||
let bx = b.x as f64;
|
||||
let by = b.y as f64;
|
||||
|
||||
let dx = bx - ax;
|
||||
let dy = by - ay;
|
||||
let chord_len = (dx * dx + dy * dy).sqrt();
|
||||
if chord_len < 1e-10 || bulge.abs() < 1e-10 {
|
||||
return vec![[a.x, a.y, a.z], [b.x, b.y, b.z]];
|
||||
}
|
||||
|
||||
// Center of the arc.
|
||||
// Formula: center = midpoint + offset * perp_unit
|
||||
// where offset = chord_len * (1 - bulge²) / (4 * bulge).
|
||||
let b2 = bulge * bulge;
|
||||
let offset = chord_len * (1.0 - b2) / (4.0 * bulge);
|
||||
let perp_x = -dy / chord_len;
|
||||
let perp_y = dx / chord_len;
|
||||
let mx = (ax + bx) / 2.0;
|
||||
let my = (ay + by) / 2.0;
|
||||
let cx = mx + offset * perp_x;
|
||||
let cy = my + offset * perp_y;
|
||||
|
||||
let r = ((ax - cx) * (ax - cx) + (ay - cy) * (ay - cy)).sqrt();
|
||||
let start_angle = (ay - cy).atan2(ax - cx);
|
||||
// Total arc angle (signed).
|
||||
let theta = 4.0 * bulge.atan();
|
||||
|
||||
let mut pts = Vec::with_capacity(n + 1);
|
||||
for i in 0..=n {
|
||||
let t = i as f64 / n as f64;
|
||||
let angle = start_angle + t * theta;
|
||||
pts.push([
|
||||
(cx + r * angle.cos()) as f32,
|
||||
(cy + r * angle.sin()) as f32,
|
||||
a.z,
|
||||
]);
|
||||
}
|
||||
pts
|
||||
}
|
||||
|
||||
// ── CadCommand impl ────────────────────────────────────────────────────────
|
||||
|
||||
impl CadCommand for PlineCommand {
|
||||
fn name(&self) -> &'static str {
|
||||
"PLINE"
|
||||
}
|
||||
|
||||
fn prompt(&self) -> String {
|
||||
let mode_tag = match self.mode {
|
||||
SegMode::Line => "Line",
|
||||
SegMode::Arc => "Arc",
|
||||
};
|
||||
if self.vertices.is_empty() {
|
||||
"PLINE Specify start point:".into()
|
||||
} else {
|
||||
format!(
|
||||
"PLINE [{mode_tag}] Next pt [{}pts | A=arc L=line C=close Enter=done]:",
|
||||
self.vertices.len()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn on_point(&mut self, pt: Vec3) -> CmdResult {
|
||||
if !self.vertices.is_empty() {
|
||||
let last = *self.vertices.last().unwrap();
|
||||
let last_idx = self.vertices.len() - 1;
|
||||
|
||||
let bulge = match self.mode {
|
||||
SegMode::Line => {
|
||||
let d = Vec2::new(pt.x - last.x, pt.y - last.y);
|
||||
if d.length_squared() > 1e-10 {
|
||||
self.last_tangent = Some(d.normalize());
|
||||
}
|
||||
0.0
|
||||
}
|
||||
SegMode::Arc => {
|
||||
let a = Vec2::new(last.x, last.y);
|
||||
let b = Vec2::new(pt.x, pt.y);
|
||||
let tangent = self.last_tangent.unwrap_or_else(|| {
|
||||
// No previous tangent: default to pointing right (arbitrary).
|
||||
Vec2::new(1.0, 0.0)
|
||||
});
|
||||
let bulge = compute_bulge(a, tangent, b);
|
||||
update_tangent_after_arc(&mut self.last_tangent, bulge);
|
||||
bulge
|
||||
}
|
||||
};
|
||||
self.bulges[last_idx] = bulge;
|
||||
}
|
||||
|
||||
self.vertices.push(pt);
|
||||
self.bulges.push(0.0);
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
|
||||
fn on_enter(&mut self) -> CmdResult {
|
||||
match self.build_entity(false) {
|
||||
Some(e) => CmdResult::CommitAndExit(e),
|
||||
None => CmdResult::Cancel,
|
||||
}
|
||||
}
|
||||
|
||||
fn on_escape(&mut self) -> CmdResult {
|
||||
match self.build_entity(false) {
|
||||
Some(e) => CmdResult::CommitAndExit(e),
|
||||
None => CmdResult::Cancel,
|
||||
}
|
||||
}
|
||||
|
||||
fn wants_text_input(&self) -> bool {
|
||||
// Accept A / L / C once we have at least the first point.
|
||||
!self.vertices.is_empty()
|
||||
}
|
||||
|
||||
fn point_step_accepts_keywords(&self) -> bool {
|
||||
// Each segment is a point pick that also accepts A / L / C / U, so the
|
||||
// polar dynamic-input distance/angle stays visible.
|
||||
!self.vertices.is_empty()
|
||||
}
|
||||
|
||||
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
|
||||
match text.trim().to_uppercase().as_str() {
|
||||
"A" | "ARC" => {
|
||||
self.mode = SegMode::Arc;
|
||||
Some(CmdResult::NeedPoint)
|
||||
}
|
||||
"L" | "LINE" => {
|
||||
self.mode = SegMode::Line;
|
||||
Some(CmdResult::NeedPoint)
|
||||
}
|
||||
"C" | "CLOSE" => match self.build_entity(true) {
|
||||
Some(e) => Some(CmdResult::CommitAndExit(e)),
|
||||
None => Some(CmdResult::Cancel),
|
||||
},
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn on_mouse_move(&mut self, pt: Vec3) -> Option<WireModel> {
|
||||
if self.vertices.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let last = *self.vertices.last().unwrap();
|
||||
|
||||
// Build committed segments first.
|
||||
let mut pts: Vec<[f32; 3]> = Vec::new();
|
||||
|
||||
// Re-emit all committed vertices + arc samples between them.
|
||||
for i in 0..self.vertices.len() {
|
||||
let v = self.vertices[i];
|
||||
if i == 0 {
|
||||
pts.push([v.x, v.y, v.z]);
|
||||
} else {
|
||||
let prev = self.vertices[i - 1];
|
||||
let b = self.bulges.get(i - 1).copied().unwrap_or(0.0);
|
||||
if b.abs() > 1e-6 {
|
||||
// Arc segment: sample it.
|
||||
let arc_pts = arc_sample_points(prev, b, v, 16);
|
||||
pts.extend_from_slice(&arc_pts[1..]); // skip first (already added)
|
||||
} else {
|
||||
pts.push([v.x, v.y, v.z]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Rubber-band to cursor.
|
||||
match self.mode {
|
||||
SegMode::Line => {
|
||||
pts.push([pt.x, pt.y, pt.z]);
|
||||
}
|
||||
SegMode::Arc => {
|
||||
let a = Vec2::new(last.x, last.y);
|
||||
let b = Vec2::new(pt.x, pt.y);
|
||||
let tangent = self.last_tangent.unwrap_or(Vec2::new(1.0, 0.0));
|
||||
let bulge = compute_bulge(a, tangent, b);
|
||||
let arc_pts = arc_sample_points(last, bulge, pt, 16);
|
||||
pts.extend_from_slice(&arc_pts[1..]);
|
||||
}
|
||||
}
|
||||
|
||||
Some(WireModel::solid(
|
||||
"rubber_band".into(),
|
||||
pts,
|
||||
WireModel::CYAN,
|
||||
false,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ── Autocomplete registry ─────────────────────────────────
|
||||
inventory::submit!(crate::command::CommandRegistration { names: &["PL", "PLINE"] }); // PlineCommand
|
||||
@@ -0,0 +1,135 @@
|
||||
// IMAGE / IMAGEATTACH command — place a raster image in the drawing.
|
||||
//
|
||||
// Workflow:
|
||||
// 1. File dialog opens (async, handled in update.rs).
|
||||
// 2. User picks insertion point (first click).
|
||||
// 3. User drags to pick width; height is computed from the image's aspect ratio.
|
||||
// 4. Entity is committed.
|
||||
|
||||
use acadrust::entities::RasterImage;
|
||||
use acadrust::types::Vector3;
|
||||
use acadrust::EntityType;
|
||||
use glam::Vec3;
|
||||
|
||||
use crate::command::{CadCommand, CmdResult};
|
||||
use crate::scene::model::wire_model::WireModel;
|
||||
|
||||
pub struct ImageCommand {
|
||||
file_path: String,
|
||||
pixel_width: u32,
|
||||
pixel_height: u32,
|
||||
origin: Option<Vec3>,
|
||||
}
|
||||
|
||||
impl ImageCommand {
|
||||
pub fn new(file_path: String, pixel_width: u32, pixel_height: u32) -> Self {
|
||||
Self {
|
||||
file_path,
|
||||
pixel_width,
|
||||
pixel_height,
|
||||
origin: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn aspect(&self) -> f64 {
|
||||
if self.pixel_height == 0 {
|
||||
1.0
|
||||
} else {
|
||||
self.pixel_width as f64 / self.pixel_height as f64
|
||||
}
|
||||
}
|
||||
|
||||
fn make_entity(&self, origin: Vec3, width_pt: Vec3) -> EntityType {
|
||||
let world_width = ((width_pt.x - origin.x) as f64).abs().max(0.001);
|
||||
let world_height = world_width / self.aspect();
|
||||
|
||||
let ins = Vector3::new(origin.x as f64, origin.y as f64, origin.z as f64);
|
||||
|
||||
let mut img = RasterImage::with_size(
|
||||
&self.file_path,
|
||||
ins,
|
||||
self.pixel_width as f64,
|
||||
self.pixel_height as f64,
|
||||
world_width,
|
||||
world_height,
|
||||
);
|
||||
img.flags = acadrust::entities::ImageDisplayFlags::SHOW_IMAGE
|
||||
| acadrust::entities::ImageDisplayFlags::USE_CLIPPING_BOUNDARY;
|
||||
EntityType::RasterImage(img)
|
||||
}
|
||||
}
|
||||
|
||||
impl CadCommand for ImageCommand {
|
||||
fn name(&self) -> &'static str {
|
||||
"IMAGE"
|
||||
}
|
||||
|
||||
fn prompt(&self) -> String {
|
||||
if self.origin.is_none() {
|
||||
format!(
|
||||
"IMAGE Specify insertion point ({}): ",
|
||||
short_name(&self.file_path)
|
||||
)
|
||||
} else {
|
||||
"IMAGE Specify width (drag right):".into()
|
||||
}
|
||||
}
|
||||
|
||||
fn on_point(&mut self, pt: Vec3) -> CmdResult {
|
||||
if let Some(origin) = self.origin {
|
||||
let entity = self.make_entity(origin, pt);
|
||||
CmdResult::CommitAndExit(entity)
|
||||
} else {
|
||||
self.origin = Some(pt);
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
}
|
||||
|
||||
fn on_enter(&mut self) -> CmdResult {
|
||||
// If origin is set, place with a default width of 1 unit * pixel count / 100
|
||||
if let Some(origin) = self.origin {
|
||||
let default_w = (self.pixel_width as f64 / 100.0).max(1.0);
|
||||
let width_pt = Vec3::new(origin.x + default_w as f32, origin.y, origin.z);
|
||||
let entity = self.make_entity(origin, width_pt);
|
||||
CmdResult::CommitAndExit(entity)
|
||||
} else {
|
||||
CmdResult::Cancel
|
||||
}
|
||||
}
|
||||
|
||||
fn on_mouse_move(&mut self, pt: Vec3) -> Option<WireModel> {
|
||||
let origin = self.origin?;
|
||||
let world_width = (pt.x - origin.x).abs().max(0.001);
|
||||
let world_height = world_width / self.aspect() as f32;
|
||||
|
||||
let p0 = [origin.x, origin.y, origin.z];
|
||||
let p1 = [origin.x + world_width, origin.y, origin.z];
|
||||
let p2 = [origin.x + world_width, origin.y + world_height, origin.z];
|
||||
let p3 = [origin.x, origin.y + world_height, origin.z];
|
||||
|
||||
Some(WireModel {
|
||||
name: "image_preview".into(),
|
||||
points: vec![p0, p1, p2, p3, p0],
|
||||
color: WireModel::CYAN,
|
||||
selected: false,
|
||||
pattern_length: 0.0,
|
||||
pattern: [0.0; 8],
|
||||
line_weight_px: 1.0,
|
||||
snap_pts: vec![],
|
||||
tangent_geoms: vec![],
|
||||
aci: 0,
|
||||
key_vertices: vec![],
|
||||
aabb: WireModel::UNBOUNDED_AABB,
|
||||
plinegen: true,
|
||||
vp_scissor: None,
|
||||
fill_tris: vec![],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn short_name(path: &str) -> &str {
|
||||
std::path::Path::new(path)
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or(path)
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
// Ray / XLine draw commands.
|
||||
//
|
||||
// RAY — semi-infinite line: click base point, then click direction point.
|
||||
// Produces a Ray entity; repeats until Enter/Esc.
|
||||
// XLINE — infinite construction line: same two-click pattern, yields XLine.
|
||||
|
||||
use acadrust::entities::{Ray as RayEnt, XLine as XLineEnt};
|
||||
use acadrust::types::Vector3;
|
||||
use acadrust::EntityType;
|
||||
|
||||
use crate::command::{CadCommand, CmdResult};
|
||||
use crate::scene::model::wire_model::WireModel;
|
||||
use glam::Vec3;
|
||||
|
||||
const DISPLAY_EXTENT: f32 = 1_000_000.0;
|
||||
|
||||
// ── RAY ───────────────────────────────────────────────────────────────────
|
||||
|
||||
pub struct RayCommand {
|
||||
base: Option<Vec3>,
|
||||
}
|
||||
|
||||
impl RayCommand {
|
||||
pub fn new() -> Self {
|
||||
Self { base: None }
|
||||
}
|
||||
}
|
||||
|
||||
impl CadCommand for RayCommand {
|
||||
fn name(&self) -> &'static str {
|
||||
"RAY"
|
||||
}
|
||||
|
||||
fn prompt(&self) -> String {
|
||||
if self.base.is_none() {
|
||||
"RAY Specify start point:".into()
|
||||
} else {
|
||||
"RAY Specify through point [Enter/Esc = done]:".into()
|
||||
}
|
||||
}
|
||||
|
||||
fn on_point(&mut self, pt: Vec3) -> CmdResult {
|
||||
if let Some(base) = self.base {
|
||||
let dir = pt - base;
|
||||
let len = dir.length();
|
||||
if len < 1e-6 {
|
||||
return CmdResult::NeedPoint;
|
||||
}
|
||||
let dir_n = dir / len;
|
||||
let ray = RayEnt::new(
|
||||
Vector3::new(base.x as f64, base.y as f64, base.z as f64),
|
||||
Vector3::new(dir_n.x as f64, dir_n.y as f64, dir_n.z as f64),
|
||||
);
|
||||
// Stay active: new base = same base (can keep clicking through points)
|
||||
// Actually AutoCAD prompts for new start after each ray — reset base.
|
||||
self.base = None;
|
||||
CmdResult::CommitEntity(EntityType::Ray(ray))
|
||||
} else {
|
||||
self.base = Some(pt);
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
}
|
||||
|
||||
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> {
|
||||
let base = self.base?;
|
||||
let dir = (pt - base).normalize_or_zero();
|
||||
let far = base + dir * DISPLAY_EXTENT;
|
||||
Some(WireModel {
|
||||
name: "ray_preview".into(),
|
||||
points: vec![[base.x, base.y, base.z], [far.x, far.y, far.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![],
|
||||
aci: 0,
|
||||
key_vertices: vec![],
|
||||
aabb: WireModel::UNBOUNDED_AABB,
|
||||
plinegen: true,
|
||||
vp_scissor: None,
|
||||
fill_tris: vec![],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ── XLINE ─────────────────────────────────────────────────────────────────
|
||||
|
||||
pub struct XLineCommand {
|
||||
base: Option<Vec3>,
|
||||
}
|
||||
|
||||
impl XLineCommand {
|
||||
pub fn new() -> Self {
|
||||
Self { base: None }
|
||||
}
|
||||
}
|
||||
|
||||
impl CadCommand for XLineCommand {
|
||||
fn name(&self) -> &'static str {
|
||||
"XLINE"
|
||||
}
|
||||
|
||||
fn prompt(&self) -> String {
|
||||
if self.base.is_none() {
|
||||
"XLINE Specify a point:".into()
|
||||
} else {
|
||||
"XLINE Specify through point [Enter/Esc = done]:".into()
|
||||
}
|
||||
}
|
||||
|
||||
fn on_point(&mut self, pt: Vec3) -> CmdResult {
|
||||
if let Some(base) = self.base {
|
||||
let dir = pt - base;
|
||||
let len = dir.length();
|
||||
if len < 1e-6 {
|
||||
return CmdResult::NeedPoint;
|
||||
}
|
||||
let dir_n = dir / len;
|
||||
let xline = XLineEnt::new(
|
||||
Vector3::new(base.x as f64, base.y as f64, base.z as f64),
|
||||
Vector3::new(dir_n.x as f64, dir_n.y as f64, dir_n.z as f64),
|
||||
);
|
||||
self.base = None;
|
||||
CmdResult::CommitEntity(EntityType::XLine(xline))
|
||||
} else {
|
||||
self.base = Some(pt);
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
}
|
||||
|
||||
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> {
|
||||
let base = self.base?;
|
||||
let dir = (pt - base).normalize_or_zero();
|
||||
let far_pos = base + dir * DISPLAY_EXTENT;
|
||||
let far_neg = base - dir * DISPLAY_EXTENT;
|
||||
Some(WireModel {
|
||||
name: "xline_preview".into(),
|
||||
points: vec![
|
||||
[far_neg.x, far_neg.y, far_neg.z],
|
||||
[far_pos.x, far_pos.y, far_pos.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![],
|
||||
aci: 0,
|
||||
key_vertices: vec![],
|
||||
aabb: WireModel::UNBOUNDED_AABB,
|
||||
plinegen: true,
|
||||
vp_scissor: None,
|
||||
fill_tris: vec![],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ── Autocomplete registry ─────────────────────────────────
|
||||
inventory::submit!(crate::command::CommandRegistration { names: &["RAY"] }); // RayCommand
|
||||
inventory::submit!(crate::command::CommandRegistration { names: &["CONSTRUCTIONLINE", "XL", "XLINE"] }); // XLineCommand
|
||||
@@ -0,0 +1,139 @@
|
||||
// REVCLOUD command — draw a revision cloud (arc-bumped closed polyline).
|
||||
//
|
||||
// Workflow: pick polygon corners (like PLINE), press Enter to close.
|
||||
// Each segment of the polygon is subdivided into arc bumps (bulge = 0.5).
|
||||
// Minimum arc length = `arc_length` parameter.
|
||||
|
||||
use acadrust::entities::LwPolyline;
|
||||
use acadrust::types::Vector2;
|
||||
use acadrust::{entities::LwVertex, EntityType};
|
||||
use glam::Vec3;
|
||||
|
||||
use crate::command::{CadCommand, CmdResult};
|
||||
use crate::modules::{IconKind, ModuleEvent, ToolDef};
|
||||
use crate::scene::model::wire_model::WireModel;
|
||||
|
||||
pub const ICON: IconKind = IconKind::Svg(include_bytes!("../../../../assets/icons/revcloud.svg"));
|
||||
|
||||
pub fn tool() -> ToolDef {
|
||||
ToolDef {
|
||||
id: "REVCLOUD",
|
||||
label: "Rev Cloud",
|
||||
icon: ICON,
|
||||
event: ModuleEvent::Command("REVCLOUD".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
const DEFAULT_ARC_LEN: f64 = 1.0; // default arc bump length
|
||||
|
||||
pub struct RevCloudCommand {
|
||||
points: Vec<Vec3>,
|
||||
arc_length: f64,
|
||||
}
|
||||
|
||||
impl RevCloudCommand {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
points: vec![],
|
||||
arc_length: DEFAULT_ARC_LEN,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CadCommand for RevCloudCommand {
|
||||
fn name(&self) -> &'static str {
|
||||
"REVCLOUD"
|
||||
}
|
||||
|
||||
fn prompt(&self) -> String {
|
||||
if self.points.is_empty() {
|
||||
format!(
|
||||
"REVCLOUD Specify start point (arc length = {:.2}):",
|
||||
self.arc_length
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"REVCLOUD Specify next point ({} pts, Enter to close):",
|
||||
self.points.len()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn on_point(&mut self, pt: Vec3) -> CmdResult {
|
||||
self.points.push(pt);
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
|
||||
fn on_enter(&mut self) -> CmdResult {
|
||||
if self.points.len() < 3 {
|
||||
return CmdResult::Cancel;
|
||||
}
|
||||
let entity = make_revcloud(&self.points, self.arc_length);
|
||||
CmdResult::CommitAndExit(entity)
|
||||
}
|
||||
|
||||
fn on_mouse_move(&mut self, pt: Vec3) -> Option<WireModel> {
|
||||
if self.points.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut preview_pts: Vec<[f32; 3]> = self.points.iter().map(|p| [p.x, p.y, p.z]).collect();
|
||||
preview_pts.push([pt.x, pt.y, pt.z]);
|
||||
preview_pts.push([self.points[0].x, self.points[0].y, self.points[0].z]);
|
||||
Some(WireModel {
|
||||
name: "revcloud_preview".into(),
|
||||
points: preview_pts,
|
||||
color: WireModel::CYAN,
|
||||
selected: false,
|
||||
pattern_length: 0.0,
|
||||
pattern: [0.0; 8],
|
||||
line_weight_px: 1.0,
|
||||
snap_pts: vec![],
|
||||
tangent_geoms: vec![],
|
||||
aci: 0,
|
||||
key_vertices: vec![],
|
||||
aabb: WireModel::UNBOUNDED_AABB,
|
||||
plinegen: true,
|
||||
vp_scissor: None,
|
||||
fill_tris: vec![],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn make_revcloud(pts: &[Vec3], arc_len: f64) -> EntityType {
|
||||
let n = pts.len();
|
||||
let mut vertices: Vec<LwVertex> = Vec::new();
|
||||
|
||||
// For each edge, subdivide into arc bumps (bulge ≈ 0.5)
|
||||
let bump_bulge = 0.5f64; // tan(included_angle/4) ≈ 0.5 → ~53° arc
|
||||
|
||||
for i in 0..n {
|
||||
let p0 = pts[i];
|
||||
let p1 = pts[(i + 1) % n];
|
||||
let seg_len = ((p1.x - p0.x).powi(2) + (p1.y - p0.y).powi(2)).sqrt() as f64;
|
||||
if seg_len < 1e-6 {
|
||||
continue;
|
||||
}
|
||||
|
||||
// How many full arcs fit?
|
||||
let num_arcs = ((seg_len / arc_len).round() as usize).max(1);
|
||||
let step_x = (p1.x - p0.x) as f64 / num_arcs as f64;
|
||||
let step_y = (p1.y - p0.y) as f64 / num_arcs as f64;
|
||||
|
||||
for j in 0..num_arcs {
|
||||
let x = p0.x as f64 + step_x * j as f64;
|
||||
let y = p0.y as f64 + step_y * j as f64; // DXF Y
|
||||
let mut v = LwVertex::new(Vector2::new(x, y));
|
||||
v.bulge = bump_bulge;
|
||||
vertices.push(v);
|
||||
}
|
||||
}
|
||||
|
||||
let mut p = LwPolyline::new();
|
||||
p.is_closed = true;
|
||||
p.vertices = vertices;
|
||||
EntityType::LwPolyline(p)
|
||||
}
|
||||
|
||||
|
||||
// ── Autocomplete registry ─────────────────────────────────
|
||||
inventory::submit!(crate::command::CommandRegistration { names: &["REVCLOUD"] }); // RevCloudCommand
|
||||
@@ -0,0 +1,748 @@
|
||||
// Shapes dropdown — Rectangle and Polygon creation methods.
|
||||
//
|
||||
// Rectangle:
|
||||
// RECT — Two Corners (axis-aligned)
|
||||
// RECT_ROT — Rotated (corner + adjacent corner + height)
|
||||
// RECT_CEN — Center Point + corner
|
||||
//
|
||||
// Polygon (regular N-gon, sides typed in command line):
|
||||
// POLY — Inscribed in circle (vertices ON the circle)
|
||||
// POLY_C — Circumscribed about circle (edges tangent to circle)
|
||||
// POLY_E — Edge (pick two endpoints of one edge)
|
||||
|
||||
use acadrust::entities::LwVertex;
|
||||
use acadrust::types::Vector2;
|
||||
use acadrust::{EntityType, LwPolyline};
|
||||
|
||||
use crate::command::{CadCommand, CmdResult};
|
||||
use crate::modules::IconKind;
|
||||
use crate::scene::model::wire_model::WireModel;
|
||||
use glam::Vec3;
|
||||
|
||||
const TAU: f32 = std::f32::consts::TAU;
|
||||
const PI: f32 = std::f32::consts::PI;
|
||||
|
||||
// ── Icons ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const ICON_RECT: IconKind =
|
||||
IconKind::Svg(include_bytes!("../../../../assets/icons/shapes/rect.svg"));
|
||||
const ICON_RECT_ROT: IconKind = IconKind::Svg(include_bytes!(
|
||||
"../../../../assets/icons/shapes/rect_rot.svg"
|
||||
));
|
||||
const ICON_RECT_CEN: IconKind = IconKind::Svg(include_bytes!(
|
||||
"../../../../assets/icons/shapes/rect_cen.svg"
|
||||
));
|
||||
const ICON_POLY_I: IconKind =
|
||||
IconKind::Svg(include_bytes!("../../../../assets/icons/shapes/poly_i.svg"));
|
||||
const ICON_POLY_C: IconKind =
|
||||
IconKind::Svg(include_bytes!("../../../../assets/icons/shapes/poly_c.svg"));
|
||||
const ICON_POLY_E: IconKind =
|
||||
IconKind::Svg(include_bytes!("../../../../assets/icons/shapes/poly_e.svg"));
|
||||
|
||||
// ── Dropdown metadata ──────────────────────────────────────────────────────
|
||||
|
||||
pub const DROPDOWN_ID: &str = "SHAPES";
|
||||
|
||||
pub const DROPDOWN_ITEMS: &[(&str, &str, IconKind)] = &[
|
||||
("RECT", "Rectangle - Two Corners", ICON_RECT),
|
||||
("RECT_ROT", "Rectangle - Rotated", ICON_RECT_ROT),
|
||||
("RECT_CEN", "Rectangle - Center", ICON_RECT_CEN),
|
||||
("POLY", "Polygon - Inscribed", ICON_POLY_I),
|
||||
("POLY_C", "Polygon - Circumscribed", ICON_POLY_C),
|
||||
("POLY_E", "Polygon - Edge", ICON_POLY_E),
|
||||
];
|
||||
|
||||
pub const ICON: IconKind = ICON_RECT;
|
||||
|
||||
// ── Shared geometry helpers ────────────────────────────────────────────────
|
||||
|
||||
fn make_pline(xy_pairs: &[[f64; 2]]) -> EntityType {
|
||||
EntityType::LwPolyline(LwPolyline {
|
||||
vertices: xy_pairs
|
||||
.iter()
|
||||
.map(|&[x, y]| LwVertex::new(Vector2::new(x, y)))
|
||||
.collect(),
|
||||
is_closed: true,
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
fn wire_loop(pts: Vec<[f32; 3]>) -> WireModel {
|
||||
let mut p = pts;
|
||||
if let Some(&first) = p.first() {
|
||||
p.push(first);
|
||||
}
|
||||
WireModel::solid("rubber_band".into(), p, WireModel::CYAN, false)
|
||||
}
|
||||
|
||||
fn wire_seg(a: Vec3, b: Vec3) -> WireModel {
|
||||
WireModel::solid(
|
||||
"rubber_band".into(),
|
||||
vec![[a.x, a.y, a.z], [b.x, b.y, b.z]],
|
||||
WireModel::CYAN,
|
||||
false,
|
||||
)
|
||||
}
|
||||
|
||||
// ── Polygon geometry ───────────────────────────────────────────────────────
|
||||
|
||||
fn poly_verts_xy(center: Vec3, vertex_r: f32, sides: u32, start_angle: f32) -> Vec<[f64; 2]> {
|
||||
(0..sides)
|
||||
.map(|i| {
|
||||
let a = start_angle + (i as f32) * TAU / sides as f32;
|
||||
[
|
||||
(center.x + vertex_r * a.cos()) as f64,
|
||||
(center.y + vertex_r * a.sin()) as f64,
|
||||
]
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn poly_wire(center: Vec3, vertex_r: f32, sides: u32, start_angle: f32) -> WireModel {
|
||||
let pts: Vec<[f32; 3]> = (0..sides)
|
||||
.map(|i| {
|
||||
let a = start_angle + (i as f32) * TAU / sides as f32;
|
||||
[
|
||||
center.x + vertex_r * a.cos(),
|
||||
center.y + vertex_r * a.sin(),
|
||||
center.z,
|
||||
]
|
||||
})
|
||||
.collect();
|
||||
wire_loop(pts)
|
||||
}
|
||||
|
||||
fn angle_xy(from: Vec3, to: Vec3) -> f32 {
|
||||
(to.y - from.y).atan2(to.x - from.x)
|
||||
}
|
||||
|
||||
// ── Command: Rectangle — Two Corners (RECT) ──────────────────────────────
|
||||
|
||||
pub struct RectCommand {
|
||||
a: Option<Vec3>,
|
||||
}
|
||||
|
||||
impl RectCommand {
|
||||
pub fn new() -> Self {
|
||||
Self { a: None }
|
||||
}
|
||||
}
|
||||
|
||||
impl CadCommand for RectCommand {
|
||||
fn name(&self) -> &'static str {
|
||||
"RECT"
|
||||
}
|
||||
fn prompt(&self) -> String {
|
||||
if self.a.is_none() {
|
||||
"RECT Specify first corner:".into()
|
||||
} else {
|
||||
"RECT Specify opposite corner:".into()
|
||||
}
|
||||
}
|
||||
fn on_point(&mut self, pt: Vec3) -> CmdResult {
|
||||
match self.a {
|
||||
None => {
|
||||
self.a = Some(pt);
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
Some(a) => CmdResult::CommitAndExit(make_pline(&[
|
||||
[a.x as f64, a.y as f64],
|
||||
[pt.x as f64, a.y as f64],
|
||||
[pt.x as f64, pt.y as f64],
|
||||
[a.x as f64, pt.y as f64],
|
||||
])),
|
||||
}
|
||||
}
|
||||
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> {
|
||||
let a = self.a?;
|
||||
Some(wire_loop(vec![
|
||||
[a.x, a.y, a.z],
|
||||
[pt.x, a.y, a.z],
|
||||
[pt.x, pt.y, a.z],
|
||||
[a.x, pt.y, a.z],
|
||||
]))
|
||||
}
|
||||
fn dyn_spec(&self) -> Option<crate::command::DynSpec> {
|
||||
use crate::command::{DynAnchor, DynFieldSpec, DynGuide, DynRole, DynSpec};
|
||||
// Opposite corner: enter width and height (signed deltas from the first
|
||||
// corner), with the rectangle drawn as the guide. First corner is a
|
||||
// normal point pick.
|
||||
self.a.map(|a| DynSpec {
|
||||
anchor: DynAnchor::Point(a),
|
||||
fields: vec![
|
||||
DynFieldSpec::new(DynRole::Width),
|
||||
DynFieldSpec::new(DynRole::Height),
|
||||
],
|
||||
guide: DynGuide::RectSides,
|
||||
ref_point: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ── Command: Rectangle — Rotated (RECT_ROT) ──────────────────────────────
|
||||
// Step 0: pick corner A
|
||||
// Step 1: pick adjacent corner B (defines one edge direction + length)
|
||||
// Step 2: pick height point (perpendicular offset from the A-B edge)
|
||||
|
||||
pub struct RectRotCommand {
|
||||
step: u8,
|
||||
a: Vec3,
|
||||
b: Vec3,
|
||||
}
|
||||
|
||||
impl RectRotCommand {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
step: 0,
|
||||
a: Vec3::ZERO,
|
||||
b: Vec3::ZERO,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CadCommand for RectRotCommand {
|
||||
fn name(&self) -> &'static str {
|
||||
"RECT_ROT"
|
||||
}
|
||||
fn prompt(&self) -> String {
|
||||
match self.step {
|
||||
0 => "RECT ROT Specify first corner:".into(),
|
||||
1 => "RECT ROT Specify adjacent corner (defines edge direction):".into(),
|
||||
_ => "RECT ROT Specify height (perpendicular pick):".into(),
|
||||
}
|
||||
}
|
||||
fn on_point(&mut self, pt: Vec3) -> CmdResult {
|
||||
match self.step {
|
||||
0 => {
|
||||
self.a = pt;
|
||||
self.step = 1;
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
1 => {
|
||||
self.b = pt;
|
||||
self.step = 2;
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
_ => {
|
||||
let (a, b) = (self.a, self.b);
|
||||
let dir = (b - a).normalize_or_zero();
|
||||
let perp = Vec3::new(-dir.y, dir.x, 0.0);
|
||||
let h = (pt - b).dot(perp); // signed height
|
||||
let c = b + perp * h;
|
||||
let d = a + perp * h;
|
||||
CmdResult::CommitAndExit(make_pline(&[
|
||||
[a.x as f64, a.y as f64],
|
||||
[b.x as f64, b.y as f64],
|
||||
[c.x as f64, c.y as f64],
|
||||
[d.x as f64, d.y as f64],
|
||||
]))
|
||||
}
|
||||
}
|
||||
}
|
||||
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> {
|
||||
match self.step {
|
||||
1 => Some(wire_seg(self.a, pt)),
|
||||
2 => {
|
||||
let (a, b) = (self.a, self.b);
|
||||
let dir = (b - a).normalize_or_zero();
|
||||
let perp = Vec3::new(-dir.y, dir.x, 0.0);
|
||||
let h = (pt - b).dot(perp);
|
||||
let c = b + perp * h;
|
||||
let d = a + perp * h;
|
||||
Some(wire_loop(vec![
|
||||
[a.x, a.y, a.z],
|
||||
[b.x, b.y, b.z],
|
||||
[c.x, c.y, c.z],
|
||||
[d.x, d.y, d.z],
|
||||
]))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn dyn_spec(&self) -> Option<crate::command::DynSpec> {
|
||||
use crate::command::{DynAnchor, DynFieldSpec, DynGuide, DynRole, DynSpec};
|
||||
// Step 0: corner A (point). Step 1: adjacent corner — the base edge,
|
||||
// needs direction + length (legacy polar). Step 2: height — measured
|
||||
// square to the fixed base edge A→B, so show the perpendicular drop
|
||||
// and take the perpendicular distance (no angle).
|
||||
(self.step == 2).then(|| DynSpec {
|
||||
anchor: DynAnchor::Point(self.b),
|
||||
fields: vec![DynFieldSpec::new(DynRole::Distance)],
|
||||
guide: DynGuide::PerpDim,
|
||||
ref_point: Some(self.a),
|
||||
})
|
||||
}
|
||||
|
||||
fn dyn_live_value(&self, cursor: Vec3) -> Option<f64> {
|
||||
// Live height = perpendicular distance from the cursor to the base edge.
|
||||
(self.step == 2).then(|| {
|
||||
let dir = (self.b - self.a).normalize_or_zero();
|
||||
let perp = Vec3::new(-dir.y, dir.x, 0.0);
|
||||
(cursor - self.b).dot(perp).abs() as f64
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ── Command: Rectangle — Center (RECT_CEN) ───────────────────────────────
|
||||
// Step 0: pick center
|
||||
// Step 1: pick any corner (half-width = |cx|, half-height = |cy|)
|
||||
|
||||
pub struct RectCenCommand {
|
||||
center: Option<Vec3>,
|
||||
}
|
||||
|
||||
impl RectCenCommand {
|
||||
pub fn new() -> Self {
|
||||
Self { center: None }
|
||||
}
|
||||
}
|
||||
|
||||
impl CadCommand for RectCenCommand {
|
||||
fn name(&self) -> &'static str {
|
||||
"RECT_CEN"
|
||||
}
|
||||
fn prompt(&self) -> String {
|
||||
if self.center.is_none() {
|
||||
"RECT CEN Specify center point:".into()
|
||||
} else {
|
||||
"RECT CEN Specify corner point:".into()
|
||||
}
|
||||
}
|
||||
fn on_point(&mut self, pt: Vec3) -> CmdResult {
|
||||
match self.center {
|
||||
None => {
|
||||
self.center = Some(pt);
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
Some(c) => {
|
||||
let hw = (pt.x - c.x).abs() as f64;
|
||||
let hh = (pt.y - c.y).abs() as f64;
|
||||
let cx = c.x as f64;
|
||||
let cy = c.y as f64;
|
||||
CmdResult::CommitAndExit(make_pline(&[
|
||||
[cx - hw, cy - hh],
|
||||
[cx + hw, cy - hh],
|
||||
[cx + hw, cy + hh],
|
||||
[cx - hw, cy + hh],
|
||||
]))
|
||||
}
|
||||
}
|
||||
}
|
||||
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> {
|
||||
let c = self.center?;
|
||||
let hw = (pt.x - c.x).abs();
|
||||
let hh = (pt.y - c.y).abs();
|
||||
Some(wire_loop(vec![
|
||||
[c.x - hw, c.y - hh, c.z],
|
||||
[c.x + hw, c.y - hh, c.z],
|
||||
[c.x + hw, c.y + hh, c.z],
|
||||
[c.x - hw, c.y + hh, c.z],
|
||||
]))
|
||||
}
|
||||
fn dyn_spec(&self) -> Option<crate::command::DynSpec> {
|
||||
use crate::command::{DynAnchor, DynFieldSpec, DynGuide, DynRole, DynSpec};
|
||||
// Corner from the centre gives the half-width / half-height; show them
|
||||
// on dotted axis legs out of the centre.
|
||||
self.center.map(|c| DynSpec {
|
||||
anchor: DynAnchor::Point(c),
|
||||
fields: vec![
|
||||
DynFieldSpec::new(DynRole::Width),
|
||||
DynFieldSpec::new(DynRole::Height),
|
||||
],
|
||||
guide: DynGuide::AxisDelta,
|
||||
ref_point: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ── Command: Polygon — Inscribed (POLY) ──────────────────────────────────
|
||||
// Type number of sides (default 6) → pick center → pick vertex
|
||||
// Vertices lie ON the circle of the picked radius.
|
||||
|
||||
pub struct PolyCommand {
|
||||
sides: u32,
|
||||
step: u8,
|
||||
center: Vec3,
|
||||
}
|
||||
|
||||
impl PolyCommand {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
sides: 6,
|
||||
step: 0,
|
||||
center: Vec3::ZERO,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CadCommand for PolyCommand {
|
||||
fn name(&self) -> &'static str {
|
||||
"POLY"
|
||||
}
|
||||
|
||||
fn wants_text_input(&self) -> bool {
|
||||
self.step == 0
|
||||
}
|
||||
|
||||
fn dyn_field(&self) -> crate::command::DynField {
|
||||
if self.step == 0 {
|
||||
crate::command::DynField::Scalar
|
||||
} else {
|
||||
crate::command::DynField::Point
|
||||
}
|
||||
}
|
||||
|
||||
fn dyn_spec(&self) -> Option<crate::command::DynSpec> {
|
||||
use crate::command::{DynAnchor, DynFieldSpec, DynGuide, DynRole, DynSpec};
|
||||
// Vertex on the circle: radius from the centre + rotation angle.
|
||||
(self.step == 2).then(|| DynSpec {
|
||||
anchor: DynAnchor::Point(self.center),
|
||||
fields: vec![
|
||||
DynFieldSpec::new(DynRole::Radius),
|
||||
DynFieldSpec::new(DynRole::Angle),
|
||||
],
|
||||
guide: DynGuide::Polar,
|
||||
ref_point: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
|
||||
if let Ok(n) = text.trim().parse::<u32>() {
|
||||
if (3..=1024).contains(&n) {
|
||||
self.sides = n;
|
||||
}
|
||||
}
|
||||
self.step = 1;
|
||||
Some(CmdResult::NeedPoint)
|
||||
}
|
||||
|
||||
fn prompt(&self) -> String {
|
||||
match self.step {
|
||||
0 => format!("POLYGON Enter number of sides <{}>:", self.sides),
|
||||
1 => format!("POLYGON Specify center [{} sides]:", self.sides),
|
||||
_ => format!(
|
||||
"POLYGON Specify vertex on circle [{} sides inscribed]:",
|
||||
self.sides
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn on_point(&mut self, pt: Vec3) -> CmdResult {
|
||||
match self.step {
|
||||
0 => {
|
||||
// User clicked without typing sides: use default, treat click as center.
|
||||
self.center = pt;
|
||||
self.step = 2;
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
1 => {
|
||||
self.center = pt;
|
||||
self.step = 2;
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
_ => {
|
||||
let r = self.center.distance(pt);
|
||||
let sa = angle_xy(self.center, pt);
|
||||
CmdResult::CommitAndExit(make_pline(&poly_verts_xy(self.center, r, self.sides, sa)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn on_enter(&mut self) -> CmdResult {
|
||||
if self.step == 0 {
|
||||
self.step = 1;
|
||||
return CmdResult::NeedPoint;
|
||||
}
|
||||
CmdResult::Cancel
|
||||
}
|
||||
fn on_escape(&mut self) -> CmdResult {
|
||||
CmdResult::Cancel
|
||||
}
|
||||
|
||||
fn on_mouse_move(&mut self, pt: Vec3) -> Option<WireModel> {
|
||||
if self.step < 2 {
|
||||
return None;
|
||||
}
|
||||
let r = self.center.distance(pt);
|
||||
let sa = angle_xy(self.center, pt);
|
||||
Some(poly_wire(self.center, r, self.sides, sa))
|
||||
}
|
||||
}
|
||||
|
||||
// ── Command: Polygon — Circumscribed (POLY_C) ────────────────────────────
|
||||
// Type sides → pick center → pick edge-midpoint (on the inscribed circle).
|
||||
// vertex_radius = inradius / cos(π/N).
|
||||
|
||||
pub struct PolyCCommand {
|
||||
sides: u32,
|
||||
step: u8,
|
||||
center: Vec3,
|
||||
}
|
||||
|
||||
impl PolyCCommand {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
sides: 6,
|
||||
step: 0,
|
||||
center: Vec3::ZERO,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CadCommand for PolyCCommand {
|
||||
fn name(&self) -> &'static str {
|
||||
"POLY_C"
|
||||
}
|
||||
|
||||
fn wants_text_input(&self) -> bool {
|
||||
self.step == 0
|
||||
}
|
||||
|
||||
fn dyn_field(&self) -> crate::command::DynField {
|
||||
if self.step == 0 {
|
||||
crate::command::DynField::Scalar
|
||||
} else {
|
||||
crate::command::DynField::Point
|
||||
}
|
||||
}
|
||||
|
||||
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
|
||||
if let Ok(n) = text.trim().parse::<u32>() {
|
||||
if (3..=1024).contains(&n) {
|
||||
self.sides = n;
|
||||
}
|
||||
}
|
||||
self.step = 1;
|
||||
Some(CmdResult::NeedPoint)
|
||||
}
|
||||
|
||||
fn dyn_spec(&self) -> Option<crate::command::DynSpec> {
|
||||
use crate::command::{DynAnchor, DynFieldSpec, DynGuide, DynRole, DynSpec};
|
||||
// Edge-midpoint distance (apothem) from the centre + rotation.
|
||||
(self.step == 2).then(|| DynSpec {
|
||||
anchor: DynAnchor::Point(self.center),
|
||||
fields: vec![
|
||||
DynFieldSpec::new(DynRole::Radius),
|
||||
DynFieldSpec::new(DynRole::Angle),
|
||||
],
|
||||
guide: DynGuide::Polar,
|
||||
ref_point: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn prompt(&self) -> String {
|
||||
match self.step {
|
||||
0 => format!("POLYGON C Enter number of sides <{}>:", self.sides),
|
||||
1 => format!("POLYGON C Specify center [{} sides]:", self.sides),
|
||||
_ => format!(
|
||||
"POLYGON C Specify edge-midpoint radius [{} sides circumscribed]:",
|
||||
self.sides
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn on_point(&mut self, pt: Vec3) -> CmdResult {
|
||||
match self.step {
|
||||
0 => {
|
||||
self.center = pt;
|
||||
self.step = 2;
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
1 => {
|
||||
self.center = pt;
|
||||
self.step = 2;
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
_ => {
|
||||
let inradius = self.center.distance(pt);
|
||||
let vr = inradius / (PI / self.sides as f32).cos();
|
||||
// The picked pt is at the midpoint of an edge; the vertex is
|
||||
// offset by half a sector (π/N) from that direction.
|
||||
let edge_angle = angle_xy(self.center, pt);
|
||||
let sa = edge_angle + PI / self.sides as f32;
|
||||
CmdResult::CommitAndExit(make_pline(&poly_verts_xy(
|
||||
self.center,
|
||||
vr,
|
||||
self.sides,
|
||||
sa,
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn on_enter(&mut self) -> CmdResult {
|
||||
if self.step == 0 {
|
||||
self.step = 1;
|
||||
return CmdResult::NeedPoint;
|
||||
}
|
||||
CmdResult::Cancel
|
||||
}
|
||||
fn on_escape(&mut self) -> CmdResult {
|
||||
CmdResult::Cancel
|
||||
}
|
||||
|
||||
fn on_mouse_move(&mut self, pt: Vec3) -> Option<WireModel> {
|
||||
if self.step < 2 {
|
||||
return None;
|
||||
}
|
||||
let inradius = self.center.distance(pt);
|
||||
let vr = inradius / (PI / self.sides as f32).cos();
|
||||
let sa = angle_xy(self.center, pt) + PI / self.sides as f32;
|
||||
Some(poly_wire(self.center, vr, self.sides, sa))
|
||||
}
|
||||
}
|
||||
|
||||
// ── Command: Polygon — Edge (POLY_E) ─────────────────────────────────────
|
||||
// Type sides → pick edge start A → pick edge end B.
|
||||
// Center is computed from the edge and the polygon geometry.
|
||||
|
||||
pub struct PolyECommand {
|
||||
sides: u32,
|
||||
step: u8,
|
||||
a: Vec3,
|
||||
}
|
||||
|
||||
impl PolyECommand {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
sides: 6,
|
||||
step: 0,
|
||||
a: Vec3::ZERO,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CadCommand for PolyECommand {
|
||||
fn name(&self) -> &'static str {
|
||||
"POLY_E"
|
||||
}
|
||||
|
||||
fn wants_text_input(&self) -> bool {
|
||||
self.step == 0
|
||||
}
|
||||
|
||||
fn dyn_field(&self) -> crate::command::DynField {
|
||||
if self.step == 0 {
|
||||
crate::command::DynField::Scalar
|
||||
} else {
|
||||
crate::command::DynField::Point
|
||||
}
|
||||
}
|
||||
|
||||
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
|
||||
if let Ok(n) = text.trim().parse::<u32>() {
|
||||
if (3..=1024).contains(&n) {
|
||||
self.sides = n;
|
||||
}
|
||||
}
|
||||
self.step = 1;
|
||||
Some(CmdResult::NeedPoint)
|
||||
}
|
||||
|
||||
fn prompt(&self) -> String {
|
||||
match self.step {
|
||||
0 => format!("POLYGON E Enter number of sides <{}>:", self.sides),
|
||||
1 => format!(
|
||||
"POLYGON E Specify first endpoint of edge [{} sides]:",
|
||||
self.sides
|
||||
),
|
||||
_ => format!(
|
||||
"POLYGON E Specify second endpoint of edge [{} sides]:",
|
||||
self.sides
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn on_point(&mut self, pt: Vec3) -> CmdResult {
|
||||
match self.step {
|
||||
0 => {
|
||||
self.a = pt;
|
||||
self.step = 2;
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
1 => {
|
||||
self.a = pt;
|
||||
self.step = 2;
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
_ => {
|
||||
if let Some((center, vr, sa)) = edge_poly_params(self.a, pt, self.sides) {
|
||||
CmdResult::CommitAndExit(make_pline(&poly_verts_xy(center, vr, self.sides, sa)))
|
||||
} else {
|
||||
CmdResult::Cancel
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn on_enter(&mut self) -> CmdResult {
|
||||
if self.step == 0 {
|
||||
self.step = 1;
|
||||
return CmdResult::NeedPoint;
|
||||
}
|
||||
CmdResult::Cancel
|
||||
}
|
||||
fn on_escape(&mut self) -> CmdResult {
|
||||
CmdResult::Cancel
|
||||
}
|
||||
|
||||
fn on_mouse_move(&mut self, pt: Vec3) -> Option<WireModel> {
|
||||
if self.step < 2 {
|
||||
return None;
|
||||
}
|
||||
if let Some((center, vr, sa)) = edge_poly_params(self.a, pt, self.sides) {
|
||||
Some(poly_wire(center, vr, self.sides, sa))
|
||||
} else {
|
||||
Some(wire_seg(self.a, pt))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute polygon center, vertex-radius and start-angle from two edge endpoints.
|
||||
/// The polygon is placed on the left side of A→B (CCW convention).
|
||||
fn edge_poly_params(a: Vec3, b: Vec3, sides: u32) -> Option<(Vec3, f32, f32)> {
|
||||
let edge_len = a.distance(b);
|
||||
if edge_len < 1e-6 {
|
||||
return None;
|
||||
}
|
||||
// vertex_radius = edge_len / (2 * sin(π/N))
|
||||
let vr = edge_len / (2.0 * (PI / sides as f32).sin());
|
||||
// inradius = vr * cos(π/N) = edge_len / (2 * tan(π/N))
|
||||
let inradius = vr * (PI / sides as f32).cos();
|
||||
// Center: on the left perpendicular bisector of A→B
|
||||
let dir = (b - a) / edge_len;
|
||||
let perp = Vec3::new(-dir.y, dir.x, 0.0); // CCW left
|
||||
let mid = (a + b) * 0.5;
|
||||
let center = mid + perp * inradius;
|
||||
// First vertex = A
|
||||
let sa = angle_xy(center, a);
|
||||
Some((center, vr, sa))
|
||||
}
|
||||
|
||||
|
||||
// ── Autocomplete registry ─────────────────────────────────
|
||||
inventory::submit!(crate::command::CommandRegistration { names: &["POLY_C"] }); // PolyCCommand
|
||||
inventory::submit!(crate::command::CommandRegistration { names: &["POL", "POLY", "POLYGON"] }); // PolyCommand
|
||||
inventory::submit!(crate::command::CommandRegistration { names: &["POLY_E"] }); // PolyECommand
|
||||
inventory::submit!(crate::command::CommandRegistration { names: &["RECT_CEN"] }); // RectCenCommand
|
||||
inventory::submit!(crate::command::CommandRegistration { names: &["REC", "RECT", "RECTANG"] }); // RectCommand
|
||||
inventory::submit!(crate::command::CommandRegistration { names: &["RECT_ROT"] }); // RectRotCommand
|
||||
@@ -0,0 +1,124 @@
|
||||
// Spline tool — ribbon definition + interactive command.
|
||||
//
|
||||
// Command: SPLINE (SPL)
|
||||
// Click to add control points. Enter (≥2 pts) → commits EntityType::Spline.
|
||||
|
||||
use acadrust::types::Vector3;
|
||||
use acadrust::{EntityType, Spline};
|
||||
|
||||
use crate::command::{CadCommand, CmdResult};
|
||||
use crate::modules::{IconKind, ModuleEvent, ToolDef};
|
||||
use crate::scene::model::wire_model::WireModel;
|
||||
use glam::Vec3;
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn tool() -> ToolDef {
|
||||
ToolDef {
|
||||
id: "SPLINE",
|
||||
label: "Spline",
|
||||
icon: IconKind::Svg(include_bytes!("../../../../assets/icons/spline.svg")),
|
||||
event: ModuleEvent::Command("SPLINE".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
pub struct SplineCommand {
|
||||
pts: Vec<Vec3>,
|
||||
}
|
||||
|
||||
impl SplineCommand {
|
||||
pub fn new() -> Self {
|
||||
Self { pts: Vec::new() }
|
||||
}
|
||||
|
||||
fn build(&self) -> Option<EntityType> {
|
||||
if self.pts.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
let control_points = self
|
||||
.pts
|
||||
.iter()
|
||||
.map(|p| Vector3::new(p.x as f64, p.y as f64, p.z as f64))
|
||||
.collect();
|
||||
let n = self.pts.len();
|
||||
// Uniform open knot vector for degree-3 clamped B-spline
|
||||
let degree = 3_i32.min((n - 1) as i32);
|
||||
let knots = uniform_knots(n, degree as usize);
|
||||
let spline = Spline {
|
||||
degree,
|
||||
control_points,
|
||||
knots,
|
||||
..Default::default()
|
||||
};
|
||||
Some(EntityType::Spline(spline))
|
||||
}
|
||||
}
|
||||
|
||||
fn uniform_knots(n: usize, d: usize) -> Vec<f64> {
|
||||
let m = n + d + 1;
|
||||
(0..m)
|
||||
.map(|i| {
|
||||
if i <= d {
|
||||
0.0
|
||||
} else if i >= m - d - 1 {
|
||||
1.0
|
||||
} else {
|
||||
(i - d) as f64 / (n - d) as f64
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
impl CadCommand for SplineCommand {
|
||||
fn name(&self) -> &'static str {
|
||||
"SPLINE"
|
||||
}
|
||||
|
||||
fn prompt(&self) -> String {
|
||||
if self.pts.is_empty() {
|
||||
"SPLINE Specify first control point:".into()
|
||||
} else {
|
||||
format!(
|
||||
"SPLINE Specify next point [{} pts | Enter=done]:",
|
||||
self.pts.len()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn on_point(&mut self, pt: Vec3) -> CmdResult {
|
||||
self.pts.push(pt);
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
|
||||
fn on_enter(&mut self) -> CmdResult {
|
||||
match self.build() {
|
||||
Some(e) => CmdResult::CommitEntity(e),
|
||||
None => CmdResult::Cancel,
|
||||
}
|
||||
}
|
||||
|
||||
fn on_escape(&mut self) -> CmdResult {
|
||||
match self.build() {
|
||||
Some(e) => CmdResult::CommitEntity(e),
|
||||
None => CmdResult::Cancel,
|
||||
}
|
||||
}
|
||||
|
||||
fn on_mouse_move(&mut self, pt: Vec3) -> Option<WireModel> {
|
||||
if self.pts.is_empty() {
|
||||
return None;
|
||||
}
|
||||
// Show all committed points + cursor as a connected polyline.
|
||||
let mut pts: Vec<[f32; 3]> = self.pts.iter().map(|p| [p.x, p.y, p.z]).collect();
|
||||
pts.push([pt.x, pt.y, pt.z]);
|
||||
Some(WireModel::solid(
|
||||
"rubber_band".into(),
|
||||
pts,
|
||||
WireModel::CYAN,
|
||||
false,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ── Autocomplete registry ─────────────────────────────────
|
||||
inventory::submit!(crate::command::CommandRegistration { names: &["SPL", "SPLINE"] }); // SplineCommand
|
||||
@@ -0,0 +1,185 @@
|
||||
// WIPEOUT command — create a rectangular or polygonal masking area.
|
||||
//
|
||||
// Modes:
|
||||
// WIPEOUT (default): two-corner rectangular wipeout
|
||||
// WIPEOUT P: polygonal wipeout (pick corners, Enter to close)
|
||||
|
||||
use acadrust::entities::Wipeout;
|
||||
use acadrust::types::Vector3;
|
||||
use acadrust::EntityType;
|
||||
use glam::Vec3;
|
||||
|
||||
use crate::command::{CadCommand, CmdResult};
|
||||
use crate::modules::{IconKind, ModuleEvent, ToolDef};
|
||||
use crate::scene::model::wire_model::WireModel;
|
||||
|
||||
pub const ICON: IconKind = IconKind::Svg(include_bytes!("../../../../assets/icons/wipeout.svg"));
|
||||
|
||||
pub fn tool() -> ToolDef {
|
||||
ToolDef {
|
||||
id: "WIPEOUT",
|
||||
label: "Wipeout",
|
||||
icon: ICON,
|
||||
event: ModuleEvent::Command("WIPEOUT".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
pub struct WipeoutCommand {
|
||||
mode: WipeoutMode,
|
||||
first: Option<Vec3>,
|
||||
poly_pts: Vec<Vec3>,
|
||||
}
|
||||
|
||||
enum WipeoutMode {
|
||||
Rectangular,
|
||||
Polygonal,
|
||||
}
|
||||
|
||||
impl WipeoutCommand {
|
||||
pub fn new_rectangular() -> Self {
|
||||
Self {
|
||||
mode: WipeoutMode::Rectangular,
|
||||
first: None,
|
||||
poly_pts: vec![],
|
||||
}
|
||||
}
|
||||
pub fn new_polygonal() -> Self {
|
||||
Self {
|
||||
mode: WipeoutMode::Polygonal,
|
||||
first: None,
|
||||
poly_pts: vec![],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CadCommand for WipeoutCommand {
|
||||
fn name(&self) -> &'static str {
|
||||
"WIPEOUT"
|
||||
}
|
||||
|
||||
fn prompt(&self) -> String {
|
||||
match &self.mode {
|
||||
WipeoutMode::Rectangular => {
|
||||
if self.first.is_none() {
|
||||
"WIPEOUT Specify first corner:".into()
|
||||
} else {
|
||||
"WIPEOUT Specify opposite corner:".into()
|
||||
}
|
||||
}
|
||||
WipeoutMode::Polygonal => {
|
||||
if self.poly_pts.is_empty() {
|
||||
"WIPEOUT (Polygon) Specify first point:".into()
|
||||
} else {
|
||||
format!(
|
||||
"WIPEOUT (Polygon) Specify next point ({} pts, Enter to close):",
|
||||
self.poly_pts.len()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn on_point(&mut self, pt: Vec3) -> CmdResult {
|
||||
match &self.mode {
|
||||
WipeoutMode::Rectangular => {
|
||||
if let Some(p1) = self.first {
|
||||
let entity = make_rect_wipeout(p1, pt);
|
||||
CmdResult::CommitAndExit(entity)
|
||||
} else {
|
||||
self.first = Some(pt);
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
}
|
||||
WipeoutMode::Polygonal => {
|
||||
self.poly_pts.push(pt);
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn on_enter(&mut self) -> CmdResult {
|
||||
match &self.mode {
|
||||
WipeoutMode::Polygonal if self.poly_pts.len() >= 3 => {
|
||||
let entity = make_poly_wipeout(&self.poly_pts);
|
||||
CmdResult::CommitAndExit(entity)
|
||||
}
|
||||
_ => CmdResult::Cancel,
|
||||
}
|
||||
}
|
||||
|
||||
fn on_mouse_move(&mut self, pt: Vec3) -> Option<WireModel> {
|
||||
match &self.mode {
|
||||
WipeoutMode::Rectangular => {
|
||||
let p1 = self.first?;
|
||||
let min = p1.min(pt);
|
||||
let max = p1.max(pt);
|
||||
Some(WireModel {
|
||||
name: "wipeout_preview".into(),
|
||||
points: vec![
|
||||
[min.x, min.y, min.z],
|
||||
[max.x, min.y, min.z],
|
||||
[max.x, max.y, max.z],
|
||||
[min.x, max.y, max.z],
|
||||
[min.x, min.y, min.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![],
|
||||
aci: 0,
|
||||
key_vertices: vec![],
|
||||
aabb: WireModel::UNBOUNDED_AABB,
|
||||
plinegen: true,
|
||||
vp_scissor: None,
|
||||
fill_tris: vec![],
|
||||
})
|
||||
}
|
||||
WipeoutMode::Polygonal => {
|
||||
if self.poly_pts.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut pts: Vec<[f32; 3]> =
|
||||
self.poly_pts.iter().map(|p| [p.x, p.y, p.z]).collect();
|
||||
pts.push([pt.x, pt.y, pt.z]);
|
||||
pts.push([self.poly_pts[0].x, self.poly_pts[0].y, self.poly_pts[0].z]);
|
||||
Some(WireModel {
|
||||
name: "wipeout_poly_preview".into(),
|
||||
points: pts,
|
||||
color: WireModel::CYAN,
|
||||
selected: false,
|
||||
pattern_length: 0.0,
|
||||
pattern: [0.0; 8],
|
||||
line_weight_px: 1.0,
|
||||
snap_pts: vec![],
|
||||
tangent_geoms: vec![],
|
||||
aci: 0,
|
||||
key_vertices: vec![],
|
||||
aabb: WireModel::UNBOUNDED_AABB,
|
||||
plinegen: true,
|
||||
vp_scissor: None,
|
||||
fill_tris: vec![],
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn make_rect_wipeout(p1: Vec3, p2: Vec3) -> EntityType {
|
||||
// Drawing plane is world XY (z = elevation).
|
||||
let c1 = Vector3::new(p1.x.min(p2.x) as f64, p1.y.min(p2.y) as f64, p1.z as f64);
|
||||
let c2 = Vector3::new(p1.x.max(p2.x) as f64, p1.y.max(p2.y) as f64, p1.z as f64);
|
||||
EntityType::Wipeout(Wipeout::from_corners(c1, c2))
|
||||
}
|
||||
|
||||
fn make_poly_wipeout(pts: &[Vec3]) -> EntityType {
|
||||
use acadrust::types::Vector2;
|
||||
let z = pts[0].z as f64;
|
||||
let verts: Vec<Vector2> = pts
|
||||
.iter()
|
||||
.map(|p| Vector2::new(p.x as f64, p.y as f64))
|
||||
.collect();
|
||||
EntityType::Wipeout(Wipeout::polygonal(&verts, z))
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
// Group tool — ribbon definition + CadCommand implementation.
|
||||
|
||||
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()),
|
||||
}
|
||||
}
|
||||
|
||||
// ── CadCommand implementation ─────────────────────────────────────────────
|
||||
|
||||
use acadrust::Handle;
|
||||
use glam::Vec3;
|
||||
|
||||
use crate::command::{CadCommand, CmdResult};
|
||||
use crate::scene::model::wire_model::WireModel;
|
||||
|
||||
pub struct GroupCommand {
|
||||
handles: Vec<Handle>,
|
||||
/// Auto-generated fallback name shown in the prompt.
|
||||
auto_name: String,
|
||||
}
|
||||
|
||||
impl GroupCommand {
|
||||
pub fn new(handles: Vec<Handle>, auto_name: String) -> Self {
|
||||
Self { handles, auto_name }
|
||||
}
|
||||
}
|
||||
|
||||
impl CadCommand for GroupCommand {
|
||||
fn name(&self) -> &'static str {
|
||||
"GROUP"
|
||||
}
|
||||
|
||||
fn prompt(&self) -> String {
|
||||
format!("GROUP Enter group name [{}]:", self.auto_name)
|
||||
}
|
||||
|
||||
fn wants_text_input(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
|
||||
let name = if text.trim().is_empty() {
|
||||
std::mem::take(&mut self.auto_name)
|
||||
} else {
|
||||
text.trim().to_string()
|
||||
};
|
||||
Some(CmdResult::CreateGroup {
|
||||
handles: std::mem::take(&mut self.handles),
|
||||
name,
|
||||
})
|
||||
}
|
||||
|
||||
fn on_enter(&mut self) -> CmdResult {
|
||||
// Enter with no typed name uses the auto-generated name.
|
||||
CmdResult::CreateGroup {
|
||||
handles: std::mem::take(&mut self.handles),
|
||||
name: std::mem::take(&mut self.auto_name),
|
||||
}
|
||||
}
|
||||
|
||||
fn on_point(&mut self, _pt: Vec3) -> CmdResult {
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
|
||||
fn on_hover_entity(&mut self, _handle: Handle, _pt: Vec3) -> Vec<WireModel> {
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
pub mod group;
|
||||
pub mod ungroup;
|
||||
@@ -0,0 +1,65 @@
|
||||
// Ungroup tool — ribbon definition + CadCommand implementation.
|
||||
|
||||
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()),
|
||||
}
|
||||
}
|
||||
|
||||
// ── CadCommand implementation ─────────────────────────────────────────────
|
||||
|
||||
use acadrust::Handle;
|
||||
use glam::Vec3;
|
||||
|
||||
use crate::command::{CadCommand, CmdResult};
|
||||
use crate::scene::model::wire_model::WireModel;
|
||||
|
||||
pub struct UngroupCommand;
|
||||
|
||||
impl UngroupCommand {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
}
|
||||
|
||||
impl CadCommand for UngroupCommand {
|
||||
fn name(&self) -> &'static str {
|
||||
"UNGROUP"
|
||||
}
|
||||
|
||||
fn prompt(&self) -> String {
|
||||
"UNGROUP Select grouped objects:".into()
|
||||
}
|
||||
|
||||
fn is_selection_gathering(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn on_selection_complete(&mut self, handles: Vec<Handle>) -> CmdResult {
|
||||
if handles.is_empty() {
|
||||
return CmdResult::NeedPoint;
|
||||
}
|
||||
CmdResult::DeleteGroups { handles }
|
||||
}
|
||||
|
||||
fn on_point(&mut self, _pt: Vec3) -> CmdResult {
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
|
||||
fn on_enter(&mut self) -> CmdResult {
|
||||
CmdResult::Cancel
|
||||
}
|
||||
|
||||
fn on_hover_entity(&mut self, _handle: Handle, _pt: Vec3) -> Vec<WireModel> {
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ── Autocomplete registry ─────────────────────────────────
|
||||
inventory::submit!(crate::command::CommandRegistration { names: &["UG", "UNGROUP"] }); // UngroupCommand
|
||||
@@ -0,0 +1,88 @@
|
||||
// AREA command — compute area and perimeter of a polygon picked point by point.
|
||||
// Press Enter to close and calculate.
|
||||
|
||||
use glam::Vec3;
|
||||
|
||||
use crate::command::{CadCommand, CmdResult};
|
||||
use crate::scene::model::wire_model::WireModel;
|
||||
|
||||
pub struct AreaCommand {
|
||||
points: Vec<Vec3>,
|
||||
}
|
||||
|
||||
impl AreaCommand {
|
||||
pub fn new() -> Self {
|
||||
Self { points: vec![] }
|
||||
}
|
||||
}
|
||||
|
||||
impl CadCommand for AreaCommand {
|
||||
fn name(&self) -> &'static str {
|
||||
"AREA"
|
||||
}
|
||||
|
||||
fn prompt(&self) -> String {
|
||||
if self.points.is_empty() {
|
||||
"AREA Specify first corner point (Enter to cancel):".into()
|
||||
} else {
|
||||
format!(
|
||||
"AREA Specify next point ({} picked, Enter to calculate):",
|
||||
self.points.len()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn on_point(&mut self, pt: Vec3) -> CmdResult {
|
||||
self.points.push(pt);
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
|
||||
fn on_enter(&mut self) -> CmdResult {
|
||||
if self.points.len() < 3 {
|
||||
return CmdResult::Cancel;
|
||||
}
|
||||
// Shoelace formula in the world XY plane.
|
||||
let n = self.points.len();
|
||||
let mut area_sum = 0.0f32;
|
||||
let mut perimeter = 0.0f32;
|
||||
for idx in 0..n {
|
||||
let a = self.points[idx];
|
||||
let b = self.points[(idx + 1) % n];
|
||||
area_sum += a.x * b.y - b.x * a.y;
|
||||
perimeter += (b - a).length();
|
||||
}
|
||||
let area = (area_sum * 0.5).abs();
|
||||
let msg = format!("Area = {area:.4}, Perimeter = {perimeter:.4}");
|
||||
CmdResult::Measurement(msg)
|
||||
}
|
||||
|
||||
fn on_mouse_move(&mut self, pt: Vec3) -> Option<WireModel> {
|
||||
if self.points.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut pts: Vec<[f32; 3]> = self.points.iter().map(|p| [p.x, p.y, p.z]).collect();
|
||||
pts.push([pt.x, pt.y, pt.z]);
|
||||
pts.push([self.points[0].x, self.points[0].y, self.points[0].z]);
|
||||
Some(WireModel {
|
||||
name: "area_preview".into(),
|
||||
points: pts,
|
||||
color: WireModel::CYAN,
|
||||
selected: false,
|
||||
pattern_length: 0.0,
|
||||
pattern: [0.0; 8],
|
||||
line_weight_px: 1.0,
|
||||
snap_pts: vec![],
|
||||
tangent_geoms: vec![],
|
||||
aci: 0,
|
||||
key_vertices: vec![],
|
||||
aabb: WireModel::UNBOUNDED_AABB,
|
||||
plinegen: true,
|
||||
vp_scissor: None,
|
||||
fill_tris: vec![],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ── Autocomplete registry ─────────────────────────────────
|
||||
inventory::submit!(crate::command::CommandRegistration { names: &["AREA"] }); // AreaCommand
|
||||
@@ -0,0 +1,83 @@
|
||||
// DIST command — measure distance and angle between two picked points.
|
||||
|
||||
use glam::Vec3;
|
||||
|
||||
use crate::command::{CadCommand, CmdResult};
|
||||
use crate::scene::model::wire_model::WireModel;
|
||||
|
||||
pub struct DistCommand {
|
||||
first: Option<Vec3>,
|
||||
}
|
||||
|
||||
impl DistCommand {
|
||||
pub fn new() -> Self {
|
||||
Self { first: None }
|
||||
}
|
||||
}
|
||||
|
||||
impl CadCommand for DistCommand {
|
||||
fn name(&self) -> &'static str {
|
||||
"DIST"
|
||||
}
|
||||
|
||||
fn prompt(&self) -> String {
|
||||
if self.first.is_none() {
|
||||
"DIST Specify first point:".into()
|
||||
} else {
|
||||
"DIST Specify second point:".into()
|
||||
}
|
||||
}
|
||||
|
||||
fn on_point(&mut self, pt: Vec3) -> CmdResult {
|
||||
if let Some(p1) = self.first {
|
||||
let delta = pt - p1;
|
||||
let dist = delta.length();
|
||||
let dx = delta.x;
|
||||
let dy = delta.y; // drawing plane is world XY
|
||||
let dz = delta.z; // elevation
|
||||
|
||||
// Angle in XY plane — degrees from +X
|
||||
let angle_xy = dy.atan2(dx).to_degrees();
|
||||
// Angle from XY plane toward Z (elevation angle)
|
||||
let dist_xy = dx.hypot(dy);
|
||||
let angle_z = dz.atan2(dist_xy).to_degrees();
|
||||
|
||||
let msg = format!(
|
||||
"Distance = {dist:.4}, Angle in XY Plane = {angle_xy:.4}°, Angle from XY Plane = {angle_z:.4}°\n Delta X = {dx:.4}, Delta Y = {dy:.4}, Delta Z = {dz:.4}",
|
||||
);
|
||||
CmdResult::Measurement(msg)
|
||||
} else {
|
||||
self.first = Some(pt);
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
}
|
||||
|
||||
fn on_enter(&mut self) -> CmdResult {
|
||||
CmdResult::Cancel
|
||||
}
|
||||
|
||||
fn on_mouse_move(&mut self, pt: Vec3) -> Option<WireModel> {
|
||||
let p1 = self.first?;
|
||||
Some(WireModel {
|
||||
name: "dist_preview".into(),
|
||||
points: vec![[p1.x, p1.y, p1.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![],
|
||||
aci: 0,
|
||||
key_vertices: vec![],
|
||||
aabb: WireModel::UNBOUNDED_AABB,
|
||||
plinegen: true,
|
||||
vp_scissor: None,
|
||||
fill_tris: vec![],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ── Autocomplete registry ─────────────────────────────────
|
||||
inventory::submit!(crate::command::CommandRegistration { names: &["DI", "DIST"] }); // DistCommand
|
||||
@@ -0,0 +1,318 @@
|
||||
// DIVIDE command — place Point entities at N equal intervals along an entity.
|
||||
// MEASURE command — place Point entities at fixed-distance intervals along an entity.
|
||||
|
||||
use std::f64::consts::PI;
|
||||
|
||||
use acadrust::entities::Point as PointEnt;
|
||||
use acadrust::types::Vector3;
|
||||
use acadrust::{EntityType, Handle};
|
||||
use glam::Vec3;
|
||||
|
||||
use crate::command::{CadCommand, CmdResult};
|
||||
|
||||
// ── DIVIDE ─────────────────────────────────────────────────────────────────
|
||||
|
||||
pub struct DivideCommand {
|
||||
target: Option<Handle>,
|
||||
waiting_for_n: bool,
|
||||
}
|
||||
|
||||
impl DivideCommand {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
target: None,
|
||||
waiting_for_n: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CadCommand for DivideCommand {
|
||||
fn name(&self) -> &'static str {
|
||||
"DIVIDE"
|
||||
}
|
||||
|
||||
fn prompt(&self) -> String {
|
||||
if self.target.is_none() {
|
||||
"DIVIDE Select object to divide:".into()
|
||||
} else {
|
||||
"DIVIDE Enter number of segments:".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.waiting_for_n = true;
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
|
||||
fn wants_text_input(&self) -> bool {
|
||||
self.waiting_for_n
|
||||
}
|
||||
|
||||
fn dyn_field(&self) -> crate::command::DynField {
|
||||
if self.waiting_for_n {
|
||||
crate::command::DynField::Scalar
|
||||
} else {
|
||||
crate::command::DynField::Point
|
||||
}
|
||||
}
|
||||
|
||||
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
|
||||
let n: usize = text.trim().parse().ok().filter(|&n| n >= 2)?;
|
||||
let handle = self.target?;
|
||||
self.waiting_for_n = false;
|
||||
Some(CmdResult::DivideEntity { handle, n })
|
||||
}
|
||||
|
||||
fn on_point(&mut self, _pt: Vec3) -> CmdResult {
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
fn on_enter(&mut self) -> CmdResult {
|
||||
CmdResult::Cancel
|
||||
}
|
||||
}
|
||||
|
||||
// ── MEASURE ────────────────────────────────────────────────────────────────
|
||||
|
||||
pub struct MeasureCommand {
|
||||
target: Option<Handle>,
|
||||
waiting_for_dist: bool,
|
||||
}
|
||||
|
||||
impl MeasureCommand {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
target: None,
|
||||
waiting_for_dist: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CadCommand for MeasureCommand {
|
||||
fn name(&self) -> &'static str {
|
||||
"MEASURE"
|
||||
}
|
||||
|
||||
fn prompt(&self) -> String {
|
||||
if self.target.is_none() {
|
||||
"MEASURE Select object to measure:".into()
|
||||
} else {
|
||||
"MEASURE Specify segment length:".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.waiting_for_dist = true;
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
|
||||
fn wants_text_input(&self) -> bool {
|
||||
self.waiting_for_dist
|
||||
}
|
||||
|
||||
fn dyn_field(&self) -> crate::command::DynField {
|
||||
if self.waiting_for_dist {
|
||||
crate::command::DynField::Scalar
|
||||
} else {
|
||||
crate::command::DynField::Point
|
||||
}
|
||||
}
|
||||
|
||||
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
|
||||
let dist: f64 = text
|
||||
.trim()
|
||||
.replace(',', ".")
|
||||
.parse()
|
||||
.ok()
|
||||
.filter(|&d: &f64| d > 0.0)?;
|
||||
let handle = self.target?;
|
||||
self.waiting_for_dist = false;
|
||||
Some(CmdResult::MeasureEntity {
|
||||
handle,
|
||||
segment_length: dist,
|
||||
})
|
||||
}
|
||||
|
||||
fn on_point(&mut self, _pt: Vec3) -> CmdResult {
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
fn on_enter(&mut self) -> CmdResult {
|
||||
CmdResult::Cancel
|
||||
}
|
||||
}
|
||||
|
||||
// ── Geometry ───────────────────────────────────────────────────────────────
|
||||
|
||||
/// Compute N-1 equally spaced points along the entity (DIVIDE).
|
||||
pub fn divide_entity(entity: &EntityType, n: usize) -> Vec<EntityType> {
|
||||
if n < 2 {
|
||||
return vec![];
|
||||
}
|
||||
let total = entity_length(entity);
|
||||
if total < 1e-10 {
|
||||
return vec![];
|
||||
}
|
||||
let step = total / n as f64;
|
||||
(1..n)
|
||||
.filter_map(|k| {
|
||||
let t = step * k as f64;
|
||||
point_at_distance(entity, t).map(make_point)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Compute points at fixed `segment_length` intervals along the entity (MEASURE).
|
||||
pub fn measure_entity(entity: &EntityType, segment_length: f64) -> Vec<EntityType> {
|
||||
if segment_length <= 0.0 {
|
||||
return vec![];
|
||||
}
|
||||
let total = entity_length(entity);
|
||||
if total < 1e-10 {
|
||||
return vec![];
|
||||
}
|
||||
let mut pts = Vec::new();
|
||||
let mut t = segment_length;
|
||||
while t < total - 1e-6 {
|
||||
if let Some(p) = point_at_distance(entity, t) {
|
||||
pts.push(make_point(p));
|
||||
}
|
||||
t += segment_length;
|
||||
}
|
||||
pts
|
||||
}
|
||||
|
||||
fn make_point(pos: Vector3) -> EntityType {
|
||||
let mut p = PointEnt::new();
|
||||
p.location = pos;
|
||||
EntityType::Point(p)
|
||||
}
|
||||
|
||||
fn entity_length(entity: &EntityType) -> f64 {
|
||||
match entity {
|
||||
EntityType::Line(l) => {
|
||||
let dx = l.end.x - l.start.x;
|
||||
let dy = l.end.y - l.start.y;
|
||||
let dz = l.end.z - l.start.z;
|
||||
(dx * dx + dy * dy + dz * dz).sqrt()
|
||||
}
|
||||
EntityType::Arc(a) => {
|
||||
let span = arc_span_rad(a.start_angle, a.end_angle);
|
||||
a.radius * span
|
||||
}
|
||||
EntityType::Circle(c) => 2.0 * PI * c.radius,
|
||||
EntityType::LwPolyline(p) => {
|
||||
let n = p.vertices.len();
|
||||
if n < 2 {
|
||||
return 0.0;
|
||||
}
|
||||
let segs = if p.is_closed { n } else { n - 1 };
|
||||
(0..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;
|
||||
(dx * dx + dy * dy).sqrt()
|
||||
})
|
||||
.sum()
|
||||
}
|
||||
_ => 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
fn point_at_distance(entity: &EntityType, d: f64) -> Option<Vector3> {
|
||||
match entity {
|
||||
EntityType::Line(l) => {
|
||||
let total = entity_length(entity);
|
||||
if total < 1e-10 {
|
||||
return None;
|
||||
}
|
||||
let t = (d / total).clamp(0.0, 1.0);
|
||||
Some(Vector3::new(
|
||||
l.start.x + t * (l.end.x - l.start.x),
|
||||
l.start.y + t * (l.end.y - l.start.y),
|
||||
l.start.z + t * (l.end.z - l.start.z),
|
||||
))
|
||||
}
|
||||
EntityType::Arc(a) => {
|
||||
let span = arc_span_rad(a.start_angle, a.end_angle);
|
||||
let t = d / a.radius; // arc_length = r * theta
|
||||
if t > span {
|
||||
return None;
|
||||
}
|
||||
let angle = a.start_angle + t;
|
||||
Some(Vector3::new(
|
||||
a.center.x + a.radius * angle.cos(),
|
||||
a.center.y + a.radius * angle.sin(),
|
||||
a.center.z,
|
||||
))
|
||||
}
|
||||
EntityType::Circle(c) => {
|
||||
let circumference = 2.0 * PI * c.radius;
|
||||
if circumference < 1e-10 {
|
||||
return None;
|
||||
}
|
||||
let angle = 2.0 * PI * (d / circumference);
|
||||
Some(Vector3::new(
|
||||
c.center.x + c.radius * angle.cos(),
|
||||
c.center.y + c.radius * angle.sin(),
|
||||
c.center.z,
|
||||
))
|
||||
}
|
||||
EntityType::LwPolyline(p) => {
|
||||
let n = p.vertices.len();
|
||||
if n < 2 {
|
||||
return None;
|
||||
}
|
||||
let segs = if p.is_closed { n } else { n - 1 };
|
||||
let mut acc = 0.0f64;
|
||||
for i in 0..segs {
|
||||
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 seg_len = (dx * dx + dy * dy).sqrt();
|
||||
if acc + seg_len >= d - 1e-10 {
|
||||
let t = (d - acc) / seg_len.max(1e-10);
|
||||
return Some(Vector3::new(
|
||||
v0.location.x + t * dx,
|
||||
v0.location.y + t * dy,
|
||||
p.elevation,
|
||||
));
|
||||
}
|
||||
acc += seg_len;
|
||||
}
|
||||
None
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn arc_span_rad(start: f64, end: f64) -> f64 {
|
||||
let span = (end - start).rem_euclid(2.0 * PI);
|
||||
if span < 1e-6 {
|
||||
2.0 * PI
|
||||
} else {
|
||||
span
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ── Autocomplete registry ─────────────────────────────────
|
||||
inventory::submit!(crate::command::CommandRegistration { names: &["DIV", "DIVIDE"] }); // DivideCommand
|
||||
inventory::submit!(crate::command::CommandRegistration { names: &["ME", "MEASURE"] }); // MeasureCommand
|
||||
@@ -0,0 +1,40 @@
|
||||
// ID command — report coordinates of a picked point.
|
||||
|
||||
use glam::Vec3;
|
||||
|
||||
use crate::command::{CadCommand, CmdResult};
|
||||
|
||||
pub struct IdCommand;
|
||||
|
||||
impl IdCommand {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
}
|
||||
|
||||
impl CadCommand for IdCommand {
|
||||
fn name(&self) -> &'static str {
|
||||
"ID"
|
||||
}
|
||||
|
||||
fn prompt(&self) -> String {
|
||||
"ID Specify point:".into()
|
||||
}
|
||||
|
||||
fn on_point(&mut self, pt: Vec3) -> CmdResult {
|
||||
// Drawing plane is world XY (z = elevation).
|
||||
let x = pt.x;
|
||||
let y = pt.y;
|
||||
let z = pt.z;
|
||||
let msg = format!("X = {x:.4}, Y = {y:.4}, Z = {z:.4}");
|
||||
CmdResult::Measurement(msg)
|
||||
}
|
||||
|
||||
fn on_enter(&mut self) -> CmdResult {
|
||||
CmdResult::Cancel
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ── Autocomplete registry ─────────────────────────────────
|
||||
inventory::submit!(crate::command::CommandRegistration { names: &["ID"] }); // IdCommand
|
||||
@@ -0,0 +1,4 @@
|
||||
pub mod area;
|
||||
pub mod dist;
|
||||
pub mod divide;
|
||||
pub mod id;
|
||||
@@ -0,0 +1,13 @@
|
||||
// Color tool — ribbon definition.
|
||||
|
||||
use crate::modules::{IconKind, ModuleEvent, ToolDef};
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn tool() -> ToolDef {
|
||||
ToolDef {
|
||||
id: "COLOR",
|
||||
label: "Color",
|
||||
icon: IconKind::Glyph("🎨"),
|
||||
event: ModuleEvent::Command("COLOR".to_string()),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
use crate::modules::{IconKind, ModuleEvent, ToolDef};
|
||||
|
||||
pub fn tool() -> ToolDef {
|
||||
ToolDef {
|
||||
id: "LAYFRZ",
|
||||
label: "Layer Freeze",
|
||||
icon: IconKind::Svg(include_bytes!("../../../../assets/icons/layers/layfrz.svg")),
|
||||
event: ModuleEvent::Command("LAYFRZ".to_string()),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
use crate::modules::{IconKind, ModuleEvent, ToolDef};
|
||||
|
||||
pub fn tool() -> ToolDef {
|
||||
ToolDef {
|
||||
id: "LAYLCK",
|
||||
label: "Layer Lock",
|
||||
icon: IconKind::Svg(include_bytes!("../../../../assets/icons/layers/laylck.svg")),
|
||||
event: ModuleEvent::Command("LAYLCK".to_string()),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
use crate::modules::{IconKind, ModuleEvent, ToolDef};
|
||||
|
||||
pub fn tool() -> ToolDef {
|
||||
ToolDef {
|
||||
id: "LAYOFF",
|
||||
label: "Layer Off",
|
||||
icon: IconKind::Svg(include_bytes!("../../../../assets/icons/layers/layoff.svg")),
|
||||
event: ModuleEvent::Command("LAYOFF".to_string()),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
use crate::modules::{IconKind, ModuleEvent, ToolDef};
|
||||
|
||||
pub fn tool() -> ToolDef {
|
||||
ToolDef {
|
||||
id: "LAYON",
|
||||
label: "Turn All Layers On",
|
||||
icon: IconKind::Svg(include_bytes!("../../../../assets/icons/layers/layon.svg")),
|
||||
event: ModuleEvent::Command("LAYON".to_string()),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
use crate::modules::{IconKind, ModuleEvent, ToolDef};
|
||||
|
||||
pub fn tool() -> ToolDef {
|
||||
ToolDef {
|
||||
id: "LAYTHW",
|
||||
label: "Thaw All Layers",
|
||||
icon: IconKind::Svg(include_bytes!("../../../../assets/icons/layers/laythw.svg")),
|
||||
event: ModuleEvent::Command("LAYTHW".to_string()),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
use crate::modules::{IconKind, ModuleEvent, ToolDef};
|
||||
|
||||
pub fn tool() -> ToolDef {
|
||||
ToolDef {
|
||||
id: "LAYULK",
|
||||
label: "Layer Unlock",
|
||||
icon: IconKind::Svg(include_bytes!("../../../../assets/icons/layers/layulk.svg")),
|
||||
event: ModuleEvent::Command("LAYULK".to_string()),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
use crate::modules::{IconKind, ModuleEvent, ToolDef};
|
||||
|
||||
pub fn tool() -> ToolDef {
|
||||
ToolDef {
|
||||
id: "LAYMCUR",
|
||||
label: "Make Current",
|
||||
icon: IconKind::Svg(include_bytes!(
|
||||
"../../../../assets/icons/layers/laymcur.svg"
|
||||
)),
|
||||
event: ModuleEvent::Command("LAYMCUR".to_string()),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
// Match Layer tool — ribbon definition + CadCommand implementation.
|
||||
|
||||
use crate::modules::{IconKind, ModuleEvent, ToolDef};
|
||||
|
||||
pub fn tool() -> ToolDef {
|
||||
ToolDef {
|
||||
id: "LAYMATCH",
|
||||
label: "Match Layer",
|
||||
icon: IconKind::Svg(include_bytes!(
|
||||
"../../../../assets/icons/layers/laymatch.svg"
|
||||
)),
|
||||
event: ModuleEvent::Command("LAYMATCH".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
// ── CadCommand implementation ─────────────────────────────────────────────
|
||||
|
||||
use acadrust::Handle;
|
||||
use glam::Vec3;
|
||||
|
||||
use crate::command::{CadCommand, CmdResult};
|
||||
use crate::scene::model::wire_model::WireModel;
|
||||
|
||||
pub struct LayMatchCommand {
|
||||
dest_handles: Vec<Handle>,
|
||||
}
|
||||
|
||||
impl LayMatchCommand {
|
||||
pub fn new(dest: Vec<Handle>) -> Self {
|
||||
Self { dest_handles: dest }
|
||||
}
|
||||
}
|
||||
|
||||
impl CadCommand for LayMatchCommand {
|
||||
fn name(&self) -> &'static str {
|
||||
"LAYMATCH"
|
||||
}
|
||||
|
||||
fn prompt(&self) -> String {
|
||||
if self.dest_handles.is_empty() {
|
||||
"LAYMATCH Select objects to change layer:".into()
|
||||
} else {
|
||||
"LAYMATCH Select source object to match layer from:".into()
|
||||
}
|
||||
}
|
||||
|
||||
fn is_selection_gathering(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn on_selection_complete(&mut self, handles: Vec<Handle>) -> CmdResult {
|
||||
if handles.is_empty() {
|
||||
return CmdResult::NeedPoint;
|
||||
}
|
||||
if self.dest_handles.is_empty() {
|
||||
// Phase 1 complete — store destinations, move to phase 2.
|
||||
self.dest_handles = handles;
|
||||
CmdResult::NeedPoint
|
||||
} else {
|
||||
// Phase 2 complete — first handle is the source object.
|
||||
let src = handles[0];
|
||||
CmdResult::MatchEntityLayer {
|
||||
dest: std::mem::take(&mut self.dest_handles),
|
||||
src,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
fn on_hover_entity(&mut self, _handle: Handle, _pt: Vec3) -> Vec<WireModel> {
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ── Autocomplete registry ─────────────────────────────────
|
||||
inventory::submit!(crate::command::CommandRegistration { names: &["LAYMATCH", "LAYMCH"] }); // LayMatchCommand
|
||||
@@ -0,0 +1,10 @@
|
||||
pub(super) mod color;
|
||||
pub(super) mod layfrz;
|
||||
pub(super) mod laylck;
|
||||
pub(super) mod layoff;
|
||||
pub(super) mod layon;
|
||||
pub(super) mod laythw;
|
||||
pub(super) mod layulk;
|
||||
pub(super) mod make_current;
|
||||
pub mod match_layer;
|
||||
pub(super) mod panel;
|
||||
@@ -0,0 +1,12 @@
|
||||
// Layers panel toggle — ribbon definition.
|
||||
|
||||
use crate::modules::{IconKind, ModuleEvent, ToolDef};
|
||||
|
||||
pub fn tool() -> ToolDef {
|
||||
ToolDef {
|
||||
id: "LAYERS",
|
||||
label: "Layers",
|
||||
icon: IconKind::Svg(include_bytes!("../../../../assets/icons/layers/panel.svg")),
|
||||
event: ModuleEvent::ToggleLayers,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
// Draw module — Draw, Modify, Annotation and Layer tools.
|
||||
|
||||
mod about;
|
||||
mod changelog;
|
||||
pub mod clipboard;
|
||||
pub mod defaults;
|
||||
mod donate;
|
||||
pub mod draw;
|
||||
pub mod groups;
|
||||
pub mod inquiry;
|
||||
pub mod layers;
|
||||
pub mod modify;
|
||||
pub mod properties;
|
||||
mod report;
|
||||
pub mod select;
|
||||
|
||||
use crate::modules::{CadModule, RibbonGroup, RibbonItem};
|
||||
|
||||
pub struct DrawModule;
|
||||
|
||||
impl CadModule for DrawModule {
|
||||
fn id(&self) -> &'static str {
|
||||
"draw"
|
||||
}
|
||||
fn title(&self) -> &'static str {
|
||||
"Draw"
|
||||
}
|
||||
|
||||
fn ribbon_groups(&self) -> Vec<RibbonGroup> {
|
||||
use crate::modules::annotate::{
|
||||
angular_dim, leader_cmd, linear_dim, mleader_cmd, mtext, radius_dim, text,
|
||||
};
|
||||
use crate::modules::insert::{create_block, insert_block};
|
||||
use clipboard::{copy_clip, cut, paste};
|
||||
use draw::{arc, circle, ellipse, hatch, line, polyline, shapes};
|
||||
use groups::{group, ungroup};
|
||||
use layers::{
|
||||
layfrz, laylck, layoff, layon, laythw, layulk, make_current, match_layer, panel,
|
||||
};
|
||||
use modify::{
|
||||
array, copy, delete, explode, fillet, mirror, offset, rotate, scale, stretch,
|
||||
translate, trim,
|
||||
};
|
||||
use properties::match_prop;
|
||||
|
||||
vec![
|
||||
RibbonGroup {
|
||||
title: "Draw",
|
||||
tools: vec![
|
||||
RibbonItem::LargeTool(line::tool()),
|
||||
RibbonItem::LargeTool(polyline::tool()),
|
||||
RibbonItem::LargeDropdown {
|
||||
id: circle::DROPDOWN_ID,
|
||||
label: "Circle",
|
||||
icon: circle::ICON,
|
||||
items: circle::DROPDOWN_ITEMS.to_vec(),
|
||||
default: "CIRCLE",
|
||||
},
|
||||
RibbonItem::LargeDropdown {
|
||||
id: arc::DROPDOWN_ID,
|
||||
label: "Arc",
|
||||
icon: arc::ICON,
|
||||
items: arc::DROPDOWN_ITEMS.to_vec(),
|
||||
default: "ARC",
|
||||
},
|
||||
RibbonItem::Dropdown {
|
||||
id: shapes::DROPDOWN_ID,
|
||||
icon: shapes::ICON,
|
||||
items: shapes::DROPDOWN_ITEMS.to_vec(),
|
||||
default: "RECT",
|
||||
},
|
||||
RibbonItem::Dropdown {
|
||||
id: ellipse::DROPDOWN_ID,
|
||||
icon: ellipse::ICON,
|
||||
items: ellipse::DROPDOWN_ITEMS.to_vec(),
|
||||
default: "ELLIPSE",
|
||||
},
|
||||
RibbonItem::Dropdown {
|
||||
id: hatch::DROPDOWN_ID,
|
||||
icon: hatch::ICON,
|
||||
items: hatch::DROPDOWN_ITEMS.to_vec(),
|
||||
default: "HATCH",
|
||||
},
|
||||
],
|
||||
},
|
||||
RibbonGroup {
|
||||
title: "Modify",
|
||||
tools: vec![
|
||||
translate::tool().into(),
|
||||
copy::tool().into(),
|
||||
stretch::tool().into(),
|
||||
rotate::tool().into(),
|
||||
mirror::tool().into(),
|
||||
scale::tool().into(),
|
||||
RibbonItem::Dropdown {
|
||||
id: trim::DROPDOWN_ID,
|
||||
icon: trim::ICON,
|
||||
items: trim::DROPDOWN_ITEMS.to_vec(),
|
||||
default: "TRIM",
|
||||
},
|
||||
RibbonItem::Dropdown {
|
||||
id: fillet::DROPDOWN_ID,
|
||||
icon: fillet::ICON,
|
||||
items: fillet::DROPDOWN_ITEMS.to_vec(),
|
||||
default: "FILLET",
|
||||
},
|
||||
RibbonItem::Dropdown {
|
||||
id: array::DROPDOWN_ID,
|
||||
icon: array::ICON,
|
||||
items: array::DROPDOWN_ITEMS.to_vec(),
|
||||
default: "ARRAYRECT",
|
||||
},
|
||||
delete::tool().into(),
|
||||
explode::tool().into(),
|
||||
offset::tool().into(),
|
||||
],
|
||||
},
|
||||
RibbonGroup {
|
||||
title: "Annotation",
|
||||
tools: vec![
|
||||
RibbonItem::LargeDropdown {
|
||||
id: "ANNOTATION_TEXT",
|
||||
label: "Text",
|
||||
icon: text::ICON,
|
||||
items: vec![
|
||||
(text::tool().id, text::tool().label, text::tool().icon),
|
||||
(mtext::tool().id, mtext::tool().label, mtext::tool().icon),
|
||||
],
|
||||
default: "TEXT",
|
||||
},
|
||||
RibbonItem::LargeDropdown {
|
||||
id: "ANNOTATION_DIMENSIONS",
|
||||
label: "Dimensions",
|
||||
icon: linear_dim::ICON,
|
||||
items: vec![
|
||||
(
|
||||
linear_dim::tool().id,
|
||||
linear_dim::tool().label,
|
||||
linear_dim::tool().icon,
|
||||
),
|
||||
(
|
||||
radius_dim::tool().id,
|
||||
radius_dim::tool().label,
|
||||
radius_dim::tool().icon,
|
||||
),
|
||||
(
|
||||
angular_dim::tool().id,
|
||||
angular_dim::tool().label,
|
||||
angular_dim::tool().icon,
|
||||
),
|
||||
],
|
||||
default: "DIMLINEAR",
|
||||
},
|
||||
RibbonItem::LargeDropdown {
|
||||
id: "ANNOTATION_LEADER",
|
||||
label: "Leader",
|
||||
icon: leader_cmd::ICON,
|
||||
items: vec![
|
||||
(
|
||||
mleader_cmd::tool().id,
|
||||
mleader_cmd::tool().label,
|
||||
mleader_cmd::tool().icon,
|
||||
),
|
||||
(
|
||||
leader_cmd::tool().id,
|
||||
leader_cmd::tool().label,
|
||||
leader_cmd::tool().icon,
|
||||
),
|
||||
],
|
||||
default: "MLEADER",
|
||||
},
|
||||
],
|
||||
},
|
||||
RibbonGroup {
|
||||
title: "Layers",
|
||||
tools: vec![
|
||||
RibbonItem::LargeTool(panel::tool()),
|
||||
RibbonItem::LayerComboGroup {
|
||||
row2: vec![
|
||||
layoff::tool(),
|
||||
layfrz::tool(),
|
||||
laylck::tool(),
|
||||
make_current::tool(),
|
||||
],
|
||||
row3: vec![
|
||||
layon::tool(),
|
||||
laythw::tool(),
|
||||
layulk::tool(),
|
||||
match_layer::tool(),
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
RibbonGroup {
|
||||
title: "Block",
|
||||
tools: vec![
|
||||
RibbonItem::LargeTool(create_block::tool()),
|
||||
RibbonItem::LargeTool(insert_block::tool()),
|
||||
],
|
||||
},
|
||||
RibbonGroup {
|
||||
title: "Properties",
|
||||
tools: vec![RibbonItem::PropertiesGroup {
|
||||
match_prop: match_prop::tool(),
|
||||
}],
|
||||
},
|
||||
RibbonGroup {
|
||||
title: "Groups",
|
||||
tools: vec![
|
||||
RibbonItem::LargeTool(group::tool()),
|
||||
RibbonItem::LargeTool(ungroup::tool()),
|
||||
],
|
||||
},
|
||||
RibbonGroup {
|
||||
title: "Clipboard",
|
||||
tools: vec![
|
||||
RibbonItem::LargeTool(paste::tool()),
|
||||
copy_clip::tool().into(),
|
||||
cut::tool().into(),
|
||||
],
|
||||
},
|
||||
// Support group lives on the Start tab now (see view.rs:
|
||||
// start_page_view). Removed from the Draw ribbon to declutter.
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()),
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
@@ -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
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
@@ -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![],
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -0,0 +1,12 @@
|
||||
// ByLayer tool — ribbon definition.
|
||||
|
||||
use crate::modules::{IconKind, ModuleEvent, ToolDef};
|
||||
|
||||
pub fn tool() -> ToolDef {
|
||||
ToolDef {
|
||||
id: "BYLAYER",
|
||||
label: "ByLayer",
|
||||
icon: IconKind::Glyph("⊟"),
|
||||
event: ModuleEvent::Command("BYLAYER".to_string()),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
// Match Properties tool — ribbon definition + CadCommand implementation.
|
||||
|
||||
use crate::modules::{IconKind, ModuleEvent, ToolDef};
|
||||
|
||||
pub fn tool() -> ToolDef {
|
||||
ToolDef {
|
||||
id: "MATCHPROP",
|
||||
label: "Match",
|
||||
icon: IconKind::Svg(include_bytes!("../../../../assets/icons/match_prop.svg")),
|
||||
event: ModuleEvent::Command("MATCHPROP".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
// ── CadCommand implementation ─────────────────────────────────────────────
|
||||
|
||||
use acadrust::Handle;
|
||||
use glam::Vec3;
|
||||
|
||||
use crate::command::{CadCommand, CmdResult};
|
||||
use crate::scene::model::wire_model::WireModel;
|
||||
|
||||
pub struct MatchPropCommand {
|
||||
/// Source handle; NULL until phase 1 is complete.
|
||||
src: Handle,
|
||||
}
|
||||
|
||||
impl MatchPropCommand {
|
||||
pub fn new() -> Self {
|
||||
Self { src: Handle::NULL }
|
||||
}
|
||||
|
||||
fn phase1_done(&self) -> bool {
|
||||
!self.src.is_null()
|
||||
}
|
||||
}
|
||||
|
||||
impl CadCommand for MatchPropCommand {
|
||||
fn name(&self) -> &'static str {
|
||||
"MATCHPROP"
|
||||
}
|
||||
|
||||
fn prompt(&self) -> String {
|
||||
if !self.phase1_done() {
|
||||
"MATCHPROP Select source object:".into()
|
||||
} else {
|
||||
"MATCHPROP Select destination objects (Enter to apply):".into()
|
||||
}
|
||||
}
|
||||
|
||||
// ── Phase 1: single entity pick ───────────────────────────────────────
|
||||
|
||||
fn needs_entity_pick(&self) -> bool {
|
||||
!self.phase1_done()
|
||||
}
|
||||
|
||||
fn on_entity_pick(&mut self, handle: Handle, _pt: Vec3) -> CmdResult {
|
||||
if handle.is_null() {
|
||||
return CmdResult::NeedPoint; // nothing hit, keep waiting
|
||||
}
|
||||
self.src = handle;
|
||||
CmdResult::NeedPoint // move to phase 2 (selection gathering)
|
||||
}
|
||||
|
||||
// ── Phase 2: destination selection gathering ──────────────────────────
|
||||
|
||||
fn is_selection_gathering(&self) -> bool {
|
||||
self.phase1_done()
|
||||
}
|
||||
|
||||
fn on_selection_complete(&mut self, handles: Vec<Handle>) -> CmdResult {
|
||||
if handles.is_empty() {
|
||||
return CmdResult::NeedPoint;
|
||||
}
|
||||
CmdResult::MatchProperties {
|
||||
dest: handles,
|
||||
src: self.src,
|
||||
}
|
||||
}
|
||||
|
||||
fn on_enter(&mut self) -> CmdResult {
|
||||
CmdResult::Cancel
|
||||
}
|
||||
|
||||
fn on_point(&mut self, _pt: Vec3) -> CmdResult {
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
|
||||
fn on_hover_entity(&mut self, _handle: Handle, _pt: Vec3) -> Vec<WireModel> {
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ── Autocomplete registry ─────────────────────────────────
|
||||
inventory::submit!(crate::command::CommandRegistration { names: &["MA", "MATCHPROP"] }); // MatchPropCommand
|
||||
@@ -0,0 +1 @@
|
||||
pub mod match_prop;
|
||||
@@ -0,0 +1,11 @@
|
||||
use crate::modules::{IconKind, ModuleEvent, ToolDef};
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn tool() -> ToolDef {
|
||||
ToolDef {
|
||||
id: "REPORT",
|
||||
label: "Report",
|
||||
icon: IconKind::Svg(include_bytes!("../../../assets/icons/report.svg")),
|
||||
event: ModuleEvent::Command("REPORT".to_string()),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
// SelectObjectsCommand — generic "select objects then run command" gather phase.
|
||||
//
|
||||
// Used when a modify command is invoked with nothing pre-selected.
|
||||
// The user may single-click, box-select, or polygon-select any number of objects.
|
||||
// After the FIRST completed selection action (regardless of method) the command fires
|
||||
// immediately — no Enter required.
|
||||
|
||||
use acadrust::Handle;
|
||||
use glam::Vec3;
|
||||
|
||||
use crate::command::{CadCommand, CmdResult};
|
||||
use crate::scene::model::wire_model::WireModel;
|
||||
|
||||
pub struct SelectObjectsCommand {
|
||||
pending_cmd: String,
|
||||
}
|
||||
|
||||
impl SelectObjectsCommand {
|
||||
pub fn new(pending_cmd: &str) -> Self {
|
||||
Self {
|
||||
pending_cmd: pending_cmd.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CadCommand for SelectObjectsCommand {
|
||||
fn name(&self) -> &'static str {
|
||||
"SELECT"
|
||||
}
|
||||
|
||||
fn prompt(&self) -> String {
|
||||
format!("{} Select objects:", self.pending_cmd)
|
||||
}
|
||||
|
||||
// Opt into the selection-gathering path; host routes clicks through
|
||||
// the normal selection system and calls on_selection_complete after each action.
|
||||
fn is_selection_gathering(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn on_selection_complete(&mut self, handles: Vec<Handle>) -> CmdResult {
|
||||
if handles.is_empty() {
|
||||
return CmdResult::NeedPoint;
|
||||
}
|
||||
CmdResult::Relaunch(std::mem::take(&mut self.pending_cmd), handles)
|
||||
}
|
||||
|
||||
// These are never called while is_selection_gathering is true, but the
|
||||
// trait requires implementations.
|
||||
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
|
||||
}
|
||||
fn on_hover_entity(&mut self, _handle: Handle, _pt: Vec3) -> Vec<WireModel> {
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ── Autocomplete registry ─────────────────────────────────
|
||||
inventory::submit!(crate::command::CommandRegistration { names: &["AR", "ARRAY", "ARRAYPATH", "ARRAYPOLAR", "ARRAYRECT", "BLOCK", "CC", "CO", "COPY", "COPYCLIP", "CUTCLIP", "CX", "E", "ERASE", "EXPLODE", "G", "GROUP", "LAYFRZ", "LAYLCK", "LAYMCUR", "LAYOFF", "LAYULK", "M", "MI", "MIRROR", "MOVE", "RO", "ROTATE", "SC", "SCALE", "SS", "STRETCH", "X"] }); // SelectObjectsCommand
|
||||
Reference in new issue
Block a user