feat(commands): integrate standard CAD commands and fix behavior gaps

Broad pass over the command set: integrate missing commands, wire
previously-unreachable ribbon/menu buttons, and correct name/behaviour
mismatches. Every UI command token now resolves (real handler where the
infrastructure exists, an honest "not yet implemented" status otherwise).

New commands:
- Draw: 3DPOLY, 2D solid (SO/SOLID2D), HELIX, TRACE, SKETCH, DIMCENTER
  (DCE/CENTERMARK)
- Modify/inquiry: REVERSE, MEASUREGEOM (MEA), DBLIST
- Data/manage: BASE, OVERKILL (exact-duplicate removal), AUDIT (read-only
  integrity report), SETVAR (system-variable get/set), SCRIPT (.scr runner),
  FINDNONPURGEABLE

Behaviour fixes:
- LINE and SPLINE gain Close/Undo; AREA computes area/perimeter in f64
  (correct at large coordinates)
- ATTEDIT/ATE unified so the alias and the ribbon button open the
  interactive editor; -ATTEDIT is the command-line form
- Linear/aligned/radius/diameter dimensions gain a text override (Text) and
  text angle (Angle) option
- INSERT gains Scale/Rotate options; COPY gains an Array option
- LINETYPE SET sets the current entity linetype; SETBYLAYER resets overrides
- UNDO accepts a step count

Integrations: standard dimension/block aliases (DLI/DRA/DAN, BMAKE,
EXPORTPDF, DDIM), drafting-aid and screen toggles (GRID/SNAP/POLAR/OSNAP/
DSETTINGS/CLEANSCREEN/QUICKPROPERTIES), viewport tiling
(CASCADE/HORIZONTAL/VERTICAL/VPJOIN), and customization entry points
(CUI/ALIASEDIT).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
Hakan SevenandClaude Opus 4.8 committed 2026-06-28 19:27:35 +03:00
1 parent e04b580a70
commit 0ca3954b5d
32 files changed
+2763 -67

No files matched your search

+92 -3
View File
@@ -28,11 +28,25 @@ enum Step {
pub struct AlignedDimensionCommand {
step: Step,
/// Optional text that replaces the measured value (None = measurement).
text_override: Option<String>,
/// True while the next typed line is captured as the text override.
awaiting_text: bool,
/// Explicit text rotation in radians (None = follow the UCS/style).
text_angle: Option<f64>,
/// True while the next typed value is captured as the text angle.
awaiting_angle: bool,
}
impl AlignedDimensionCommand {
pub fn new() -> Self {
Self { step: Step::First }
Self {
step: Step::First,
text_override: None,
awaiting_text: false,
text_angle: None,
awaiting_angle: false,
}
}
}
@@ -42,10 +56,20 @@ impl CadCommand for AlignedDimensionCommand {
}
fn prompt(&self) -> String {
if self.awaiting_text {
return "DIMALIGNED Enter dimension text (blank = measured value):".into();
}
if self.awaiting_angle {
return "DIMALIGNED Specify text angle (degrees):".into();
}
match self.step {
Step::First => "DIMALIGNED Specify first extension line origin:".into(),
Step::Second(_) => "DIMALIGNED Specify second extension line origin:".into(),
Step::DimLine { .. } => "DIMALIGNED Specify dimension line location:".into(),
Step::Second(_) => {
"DIMALIGNED Specify second extension line origin [Text/Angle]:".into()
}
Step::DimLine { .. } => {
"DIMALIGNED Specify dimension line location [Text/Angle]:".into()
}
}
}
@@ -76,15 +100,80 @@ impl CadCommand for AlignedDimensionCommand {
dim.base.text_middle_point = v3((d1 + d2) * 0.5);
dim.base.insertion_point = dim.base.text_middle_point;
dim.base.actual_measurement = dim.measurement();
dim.base.user_text = self.text_override.clone();
// An explicit text angle overrides the default rotation.
if let Some(a) = self.text_angle {
dim.base.text_rotation = a;
}
CmdResult::CommitAndExit(EntityType::Dimension(Dimension::Aligned(dim)))
}
}
}
fn on_enter(&mut self) -> CmdResult {
// A bare Enter while entering override text/angle accepts the default.
if self.awaiting_text {
self.awaiting_text = false;
return CmdResult::NeedPoint;
}
if self.awaiting_angle {
self.awaiting_angle = false;
return CmdResult::NeedPoint;
}
CmdResult::Cancel
}
fn wants_text_input(&self) -> bool {
true
}
fn point_step_accepts_keywords(&self) -> bool {
// While entering the override text or angle it is a value, not a point
// step.
!self.awaiting_text && !self.awaiting_angle
}
fn wants_text_with_spaces(&self) -> bool {
// The override text may contain spaces.
self.awaiting_text
}
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
if self.awaiting_text {
let t = text.trim();
// Blank (or the "<>" placeholder) keeps the measured value.
self.text_override = if t.is_empty() || t == "<>" {
None
} else {
Some(t.to_string())
};
self.awaiting_text = false;
return Some(CmdResult::NeedPoint);
}
if self.awaiting_angle {
let t = text.trim();
// Blank clears any explicit angle (follow the default again).
self.text_angle = if t.is_empty() {
None
} else {
t.parse::<f64>().ok().map(f64::to_radians)
};
self.awaiting_angle = false;
return Some(CmdResult::NeedPoint);
}
match text.trim().to_uppercase().as_str() {
"T" | "TEXT" | "M" | "MTEXT" => {
self.awaiting_text = true;
Some(CmdResult::NeedPoint)
}
"A" | "ANGLE" => {
self.awaiting_angle = true;
Some(CmdResult::NeedPoint)
}
_ => None,
}
}
fn on_mouse_move(&mut self, pt: DVec3) -> Option<WireModel> { let pt = pt.as_vec3();
let (p1, p2) = match self.step {
Step::First => return None,
+1 -1
View File
@@ -178,4 +178,4 @@ fn angular_preview(vertex: Vec3, first: Vec3, second: Vec3, arc_pt: Vec3) -> Vec
// ── Autocomplete registry ─────────────────────────────────
inventory::submit!(crate::command::CommandRegistration { names: &["DIMANGULAR"] }); // AngularDimensionCommand
inventory::submit!(crate::command::CommandRegistration { names: &["DIMANGULAR", "DAN"] }); // AngularDimensionCommand
+85 -1
View File
@@ -28,12 +28,24 @@ enum Step {
pub struct DiameterDimensionCommand {
step: Step,
/// Optional text that replaces the measured value (None = measurement).
text_override: Option<String>,
/// True while the next typed line is captured as the text override.
awaiting_text: bool,
/// Explicit text rotation in radians (None = follow the UCS/style).
text_angle: Option<f64>,
/// True while the next typed value is captured as the text angle.
awaiting_angle: bool,
}
impl DiameterDimensionCommand {
pub fn new() -> Self {
Self {
step: Step::CenterPoint,
text_override: None,
awaiting_text: false,
text_angle: None,
awaiting_angle: false,
}
}
}
@@ -44,10 +56,18 @@ impl CadCommand for DiameterDimensionCommand {
}
fn prompt(&self) -> String {
if self.awaiting_text {
return "DIMDIAMETER Enter dimension text (blank = measured value):".into();
}
if self.awaiting_angle {
return "DIMDIAMETER Specify text angle (degrees):".into();
}
match self.step {
Step::CenterPoint => "DIMDIAMETER Specify center point:".into(),
Step::ArcPoint(_) => "DIMDIAMETER Specify point on circle:".into(),
Step::TextPoint { .. } => "DIMDIAMETER Specify dimension line location:".into(),
Step::TextPoint { .. } => {
"DIMDIAMETER Specify dimension line location [Text/Angle]:".into()
}
}
}
@@ -68,15 +88,79 @@ impl CadCommand for DiameterDimensionCommand {
dim.base.insertion_point = v3(pt);
dim.leader_length = arc_pt.distance(pt) as f64;
dim.base.actual_measurement = dim.measurement();
dim.base.user_text = self.text_override.clone();
// An explicit text angle overrides the default rotation.
if let Some(a) = self.text_angle {
dim.base.text_rotation = a;
}
CmdResult::CommitAndExit(EntityType::Dimension(Dimension::Diameter(dim)))
}
}
}
fn on_enter(&mut self) -> CmdResult {
// A bare Enter while entering override text accepts the measured value.
if self.awaiting_text {
self.awaiting_text = false;
return CmdResult::NeedPoint;
}
if self.awaiting_angle {
self.awaiting_angle = false;
return CmdResult::NeedPoint;
}
CmdResult::Cancel
}
fn wants_text_input(&self) -> bool {
true
}
fn point_step_accepts_keywords(&self) -> bool {
// While entering the override text or angle it is a value, not a point step.
!self.awaiting_text && !self.awaiting_angle
}
fn wants_text_with_spaces(&self) -> bool {
// The override text may contain spaces.
self.awaiting_text
}
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
if self.awaiting_text {
let t = text.trim();
// Blank (or the "<>" placeholder) keeps the measured value.
self.text_override = if t.is_empty() || t == "<>" {
None
} else {
Some(t.to_string())
};
self.awaiting_text = false;
return Some(CmdResult::NeedPoint);
}
if self.awaiting_angle {
let t = text.trim();
// Blank clears any explicit angle (follow the default again).
self.text_angle = if t.is_empty() {
None
} else {
t.parse::<f64>().ok().map(f64::to_radians)
};
self.awaiting_angle = false;
return Some(CmdResult::NeedPoint);
}
match text.trim().to_uppercase().as_str() {
"T" | "TEXT" | "M" | "MTEXT" => {
self.awaiting_text = true;
Some(CmdResult::NeedPoint)
}
"A" | "ANGLE" => {
self.awaiting_angle = true;
Some(CmdResult::NeedPoint)
}
_ => None,
}
}
fn on_mouse_move(&mut self, pt: DVec3) -> Option<WireModel> { let pt = pt.as_vec3();
match self.step {
Step::CenterPoint => None,
+90 -3
View File
@@ -41,6 +41,14 @@ enum Step {
pub struct LinearDimensionCommand {
step: Step,
ucs: Mat4,
/// Optional text that replaces the measured value (None = measurement).
text_override: Option<String>,
/// True while the next typed line is captured as the text override.
awaiting_text: bool,
/// Explicit text rotation in radians (None = follow the UCS/style).
text_angle: Option<f64>,
/// True while the next typed value is captured as the text angle.
awaiting_angle: bool,
}
impl LinearDimensionCommand {
@@ -48,6 +56,10 @@ impl LinearDimensionCommand {
Self {
step: Step::FirstPoint,
ucs: Mat4::IDENTITY,
text_override: None,
awaiting_text: false,
text_angle: None,
awaiting_angle: false,
}
}
}
@@ -62,10 +74,20 @@ impl CadCommand for LinearDimensionCommand {
}
fn prompt(&self) -> String {
if self.awaiting_text {
return "DIMLINEAR Enter dimension text (blank = measured value):".into();
}
if self.awaiting_angle {
return "DIMLINEAR Specify text angle (degrees):".into();
}
match self.step {
Step::FirstPoint => "DIMLINEAR Specify first extension line origin:".into(),
Step::SecondPoint(_) => "DIMLINEAR Specify second extension line origin:".into(),
Step::DimensionLine { .. } => "DIMLINEAR Specify dimension line location:".into(),
Step::SecondPoint(_) => {
"DIMLINEAR Specify second extension line origin [Text/Angle]:".into()
}
Step::DimensionLine { .. } => {
"DIMLINEAR Specify dimension line location [Text/Angle]:".into()
}
}
}
@@ -100,12 +122,26 @@ impl CadCommand for LinearDimensionCommand {
dim.base.text_middle_point = v3(linear_text_pos(first, second, pt, axis));
dim.base.insertion_point = dim.base.text_middle_point;
dim.base.actual_measurement = dim.measurement();
dim.base.user_text = self.text_override.clone();
// An explicit text angle overrides the UCS-derived rotation.
if let Some(a) = self.text_angle {
dim.base.text_rotation = a;
}
CmdResult::CommitAndExit(EntityType::Dimension(Dimension::Linear(dim)))
}
}
}
fn on_enter(&mut self) -> CmdResult {
// A bare Enter while entering override text/angle accepts the default.
if self.awaiting_text {
self.awaiting_text = false;
return CmdResult::NeedPoint;
}
if self.awaiting_angle {
self.awaiting_angle = false;
return CmdResult::NeedPoint;
}
CmdResult::Cancel
}
@@ -113,6 +149,57 @@ impl CadCommand for LinearDimensionCommand {
CmdResult::Cancel
}
fn wants_text_input(&self) -> bool {
true
}
fn point_step_accepts_keywords(&self) -> bool {
// While typing the override text or angle, route input as a value, not
// a point pick / keyword.
!self.awaiting_text && !self.awaiting_angle
}
fn wants_text_with_spaces(&self) -> bool {
// The override text may contain spaces.
self.awaiting_text
}
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
if self.awaiting_text {
let t = text.trim();
// Blank (or the "<>" placeholder) keeps the measured value.
self.text_override = if t.is_empty() || t == "<>" {
None
} else {
Some(t.to_string())
};
self.awaiting_text = false;
return Some(CmdResult::NeedPoint);
}
if self.awaiting_angle {
let t = text.trim();
// Blank clears any explicit angle (follow the UCS/style again).
self.text_angle = if t.is_empty() {
None
} else {
t.parse::<f64>().ok().map(f64::to_radians)
};
self.awaiting_angle = false;
return Some(CmdResult::NeedPoint);
}
match text.trim().to_uppercase().as_str() {
"T" | "TEXT" | "M" | "MTEXT" => {
self.awaiting_text = true;
Some(CmdResult::NeedPoint)
}
"A" | "ANGLE" => {
self.awaiting_angle = true;
Some(CmdResult::NeedPoint)
}
_ => None,
}
}
fn on_mouse_move(&mut self, pt: DVec3) -> Option<WireModel> { let pt = pt.as_vec3();
match self.step {
Step::FirstPoint => None,
@@ -178,4 +265,4 @@ fn linear_text_pos(first: Vec3, second: Vec3, def: Vec3, axis: Vec3) -> Vec3 {
// ── Autocomplete registry ─────────────────────────────────
inventory::submit!(crate::command::CommandRegistration { names: &["DIMLINEAR"] }); // LinearDimensionCommand
inventory::submit!(crate::command::CommandRegistration { names: &["DIMLINEAR", "DLI"] }); // LinearDimensionCommand
+86 -2
View File
@@ -26,12 +26,24 @@ enum Step {
pub struct RadiusDimensionCommand {
step: Step,
/// Optional text that replaces the measured value (None = measurement).
text_override: Option<String>,
/// True while the next typed line is captured as the text override.
awaiting_text: bool,
/// Explicit text rotation in radians (None = follow the UCS/style).
text_angle: Option<f64>,
/// True while the next typed value is captured as the text angle.
awaiting_angle: bool,
}
impl RadiusDimensionCommand {
pub fn new() -> Self {
Self {
step: Step::CenterPoint,
text_override: None,
awaiting_text: false,
text_angle: None,
awaiting_angle: false,
}
}
}
@@ -42,10 +54,18 @@ impl CadCommand for RadiusDimensionCommand {
}
fn prompt(&self) -> String {
if self.awaiting_text {
return "DIMRADIUS Enter dimension text (blank = measured value):".into();
}
if self.awaiting_angle {
return "DIMRADIUS Specify text angle (degrees):".into();
}
match self.step {
Step::CenterPoint => "DIMRADIUS Specify center point:".into(),
Step::RadiusPoint(_) => "DIMRADIUS Specify radius point:".into(),
Step::TextPoint { .. } => "DIMRADIUS Specify dimension line location:".into(),
Step::TextPoint { .. } => {
"DIMRADIUS Specify dimension line location [Text/Angle]:".into()
}
}
}
@@ -66,12 +86,26 @@ impl CadCommand for RadiusDimensionCommand {
dim.base.insertion_point = v3(pt);
dim.leader_length = point.distance(pt) as f64;
dim.base.actual_measurement = dim.measurement();
dim.base.user_text = self.text_override.clone();
// An explicit text angle overrides the default rotation.
if let Some(a) = self.text_angle {
dim.base.text_rotation = a;
}
CmdResult::CommitAndExit(EntityType::Dimension(Dimension::Radius(dim)))
}
}
}
fn on_enter(&mut self) -> CmdResult {
// A bare Enter while entering override text accepts the measured value.
if self.awaiting_text {
self.awaiting_text = false;
return CmdResult::NeedPoint;
}
if self.awaiting_angle {
self.awaiting_angle = false;
return CmdResult::NeedPoint;
}
CmdResult::Cancel
}
@@ -79,6 +113,56 @@ impl CadCommand for RadiusDimensionCommand {
CmdResult::Cancel
}
fn wants_text_input(&self) -> bool {
true
}
fn point_step_accepts_keywords(&self) -> bool {
// While entering the override text or angle it is a value, not a point step.
!self.awaiting_text && !self.awaiting_angle
}
fn wants_text_with_spaces(&self) -> bool {
// The override text may contain spaces.
self.awaiting_text
}
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
if self.awaiting_text {
let t = text.trim();
// Blank (or the "<>" placeholder) keeps the measured value.
self.text_override = if t.is_empty() || t == "<>" {
None
} else {
Some(t.to_string())
};
self.awaiting_text = false;
return Some(CmdResult::NeedPoint);
}
if self.awaiting_angle {
let t = text.trim();
// Blank clears any explicit angle (follow the UCS/style again).
self.text_angle = if t.is_empty() {
None
} else {
t.parse::<f64>().ok().map(f64::to_radians)
};
self.awaiting_angle = false;
return Some(CmdResult::NeedPoint);
}
match text.trim().to_uppercase().as_str() {
"T" | "TEXT" | "M" | "MTEXT" => {
self.awaiting_text = true;
Some(CmdResult::NeedPoint)
}
"A" | "ANGLE" => {
self.awaiting_angle = true;
Some(CmdResult::NeedPoint)
}
_ => None,
}
}
fn on_mouse_move(&mut self, pt: DVec3) -> Option<WireModel> { let pt = pt.as_vec3();
match self.step {
Step::CenterPoint => None,
@@ -122,4 +206,4 @@ fn preview_wire(points: Vec<Vec3>) -> WireModel {
// ── Autocomplete registry ─────────────────────────────────
inventory::submit!(crate::command::CommandRegistration { names: &["DIMRADIUS"] }); // RadiusDimensionCommand
inventory::submit!(crate::command::CommandRegistration { names: &["DIMRADIUS", "DRA"] }); // RadiusDimensionCommand
+120
View File
@@ -0,0 +1,120 @@
// Center mark tool — interactive command.
//
// Command: DIMCENTER (aliases DCE, CENTERMARK) — pick a Circle or Arc and draw
// a small cross of two Line entities through its center. Each arm of the cross
// extends a distance of `radius * 0.2` from the center in the four cardinal
// directions, so the result is a horizontal line from (cx - m, cy) to
// (cx + m, cy) and a vertical line from (cx, cy - m) to (cx, cy + m), with
// `m = radius * 0.2`. Both lines are committed at once and the command ends.
use acadrust::types::Vector3;
use acadrust::{EntityType, Handle, Line};
use glam::DVec3;
use crate::command::{CadCommand, CmdResult};
use crate::modules::{IconKind, ModuleEvent, ToolDef};
// ── Ribbon definition ─────────────────────────────────────────────────────
#[allow(dead_code)] // ribbon definition ready for wiring; command works via the command line
pub fn tool() -> ToolDef {
ToolDef {
id: "DIMCENTER",
label: "Center Mark",
icon: IconKind::Svg(include_bytes!("../../../../assets/icons/line.svg")),
event: ModuleEvent::Command("DIMCENTER".to_string()),
}
}
// ── Command implementation ────────────────────────────────────────────────
pub struct DimCenterCommand {
/// The picked entity, injected by the host before `on_entity_pick` runs.
/// `None` until the host injects it.
picked: Option<EntityType>,
}
impl DimCenterCommand {
pub fn new() -> Self {
Self { picked: None }
}
/// Extract (center, radius) from a Circle or Arc; `None` for anything else.
fn center_radius(entity: &EntityType) -> Option<(Vector3, f64)> {
match entity {
EntityType::Circle(c) => Some((c.center, c.radius)),
EntityType::Arc(a) => Some((a.center, a.radius)),
_ => None,
}
}
/// Build the two cross lines from a center and radius.
fn build_cross(center: Vector3, radius: f64) -> Vec<EntityType> {
let m = radius * 0.2;
let cx = center.x;
let cy = center.y;
let cz = center.z;
let horizontal = Line::from_points(
Vector3::new(cx - m, cy, cz),
Vector3::new(cx + m, cy, cz),
);
let vertical = Line::from_points(
Vector3::new(cx, cy - m, cz),
Vector3::new(cx, cy + m, cz),
);
vec![EntityType::Line(horizontal), EntityType::Line(vertical)]
}
}
impl CadCommand for DimCenterCommand {
fn name(&self) -> &'static str {
"DIMCENTER"
}
fn prompt(&self) -> String {
"DIMCENTER Select arc or circle:".to_string()
}
fn needs_entity_pick(&self) -> bool {
true
}
fn inject_before_entity_pick(&self) -> bool {
true
}
fn inject_picked_entity(&mut self, entity: EntityType) {
self.picked = Some(entity);
}
fn on_entity_pick(&mut self, handle: Handle, _pt: DVec3) -> CmdResult {
if handle.is_null() {
return CmdResult::NeedPoint;
}
match self.picked.as_ref().and_then(Self::center_radius) {
Some((center, radius)) => {
let lines = Self::build_cross(center, radius);
CmdResult::ReplaceMany(vec![], lines)
}
// Picked something that is not a circle or arc — keep prompting.
None => CmdResult::NeedPoint,
}
}
fn on_point(&mut self, _pt: DVec3) -> CmdResult {
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: &["DIMCENTER", "DCE", "CENTERMARK"]
}); // DimCenterCommand
+300
View File
@@ -0,0 +1,300 @@
// Parametric helix tool — interactive command.
//
// Command: HELIX — build a parametric 3D helix and commit it as a single 3D
// polyline whose straight segments approximate the curve. The user picks a
// base centre point, then types the base radius, the top radius (Enter keeps
// it equal to the base radius for a cylindrical helix), the height, and the
// number of turns (Enter accepts a default of three turns).
//
// The curve is sampled at a fixed angular resolution: with `SEG_PER_TURN`
// samples per turn the helix has `turns * SEG_PER_TURN` segments. For each
// sample the radius interpolates linearly from base to top, the elevation
// interpolates linearly from zero to the height, and the planar position is
// `(radius*cos(angle), radius*sin(angle))` offset from the centre.
use acadrust::entities::Polyline3D;
use acadrust::types::Vector3;
use acadrust::EntityType;
use crate::command::{CadCommand, CmdResult, DynField};
use crate::modules::{IconKind, ModuleEvent, ToolDef};
use crate::scene::model::wire_model::WireModel;
use glam::DVec3;
/// Angular samples per full turn of the helix.
const SEG_PER_TURN: usize = 36;
/// Default number of turns when the user accepts the prompt with Enter.
const DEFAULT_TURNS: f64 = 3.0;
// ── Ribbon definition ─────────────────────────────────────────────────────
#[allow(dead_code)] // ribbon definition ready for wiring; command works via the command line
pub fn tool() -> ToolDef {
ToolDef {
id: "HELIX",
label: "Helix",
icon: IconKind::Svg(include_bytes!("../../../../assets/icons/line.svg")),
event: ModuleEvent::Command("HELIX".to_string()),
}
}
// ── Command implementation ────────────────────────────────────────────────
/// Which parameter the command is currently collecting.
#[derive(Clone, Copy, PartialEq, Eq)]
enum Step {
Center,
BaseRadius,
TopRadius,
Height,
Turns,
}
pub struct HelixCommand {
step: Step,
center: DVec3,
base_radius: f64,
top_radius: f64,
height: f64,
turns: f64,
}
impl HelixCommand {
pub fn new() -> Self {
Self {
step: Step::Center,
center: DVec3::ZERO,
base_radius: 0.0,
top_radius: 0.0,
height: 0.0,
turns: DEFAULT_TURNS,
}
}
/// Sample the helix into a list of world points.
fn sample_points(&self, base_radius: f64, top_radius: f64, height: f64, turns: f64) -> Vec<DVec3> {
let turns = if turns <= 0.0 { DEFAULT_TURNS } else { turns };
let segments = ((turns * SEG_PER_TURN as f64).round() as usize).max(1);
let mut pts = Vec::with_capacity(segments + 1);
for t in 0..=segments {
let frac = t as f64 / segments as f64;
let angle = turns * std::f64::consts::TAU * frac;
let radius = base_radius + (top_radius - base_radius) * frac;
let z = height * frac;
pts.push(DVec3::new(
self.center.x + radius * angle.cos(),
self.center.y + radius * angle.sin(),
self.center.z + z,
));
}
pts
}
/// Build the committed helix entity from the collected parameters.
fn build(&self) -> Option<EntityType> {
let pts = self.sample_points(self.base_radius, self.top_radius, self.height, self.turns);
if pts.len() < 2 {
return None;
}
let verts: Vec<Vector3> = pts.iter().map(|p| Vector3::new(p.x, p.y, p.z)).collect();
let mut pl = Polyline3D::from_points(verts);
pl.flags.closed = false;
Some(EntityType::Polyline3D(pl))
}
/// Parse a positive distance from typed text; returns `None` on failure.
fn parse_value(text: &str) -> Option<f64> {
text.trim().parse::<f64>().ok()
}
}
impl CadCommand for HelixCommand {
fn name(&self) -> &'static str {
"HELIX"
}
fn prompt(&self) -> String {
match self.step {
Step::Center => "HELIX Specify centre point of base:".to_string(),
Step::BaseRadius => "HELIX Specify base radius:".to_string(),
Step::TopRadius => "HELIX Specify top radius <same as base>:".to_string(),
Step::Height => "HELIX Specify helix height:".to_string(),
Step::Turns => "HELIX Enter number of turns <3>:".to_string(),
}
}
fn on_point(&mut self, pt: DVec3) -> CmdResult {
match self.step {
Step::Center => {
self.center = pt;
self.step = Step::BaseRadius;
CmdResult::NeedPoint
}
Step::BaseRadius => {
// A point click resolves the base radius as the planar distance
// from the centre to the picked point.
let r = ((pt.x - self.center.x).powi(2) + (pt.y - self.center.y).powi(2)).sqrt();
if r > 0.0 {
self.base_radius = r;
self.step = Step::TopRadius;
}
CmdResult::NeedPoint
}
Step::TopRadius => {
let r = ((pt.x - self.center.x).powi(2) + (pt.y - self.center.y).powi(2)).sqrt();
self.top_radius = if r > 0.0 { r } else { self.base_radius };
self.step = Step::Height;
CmdResult::NeedPoint
}
Step::Height => {
// Use the elevation of the picked point relative to the centre.
let h = pt.z - self.center.z;
if h.abs() > 0.0 {
self.height = h;
self.step = Step::Turns;
}
CmdResult::NeedPoint
}
Step::Turns => CmdResult::NeedPoint,
}
}
fn on_enter(&mut self) -> CmdResult {
match self.step {
Step::TopRadius => {
// Enter keeps the top radius equal to the base radius.
self.top_radius = self.base_radius;
self.step = Step::Height;
CmdResult::NeedPoint
}
Step::Turns => match self.build() {
Some(e) => CmdResult::CommitAndExit(e),
None => CmdResult::Cancel,
},
_ => CmdResult::NeedPoint,
}
}
fn on_escape(&mut self) -> CmdResult {
CmdResult::Cancel
}
fn wants_text_input(&self) -> bool {
// Every step past the centre pick reads a typed numeric value.
self.step != Step::Center
}
fn dyn_field(&self) -> DynField {
match self.step {
Step::Center => DynField::Point,
Step::BaseRadius | Step::TopRadius => DynField::Distance,
Step::Height => DynField::Distance,
Step::Turns => DynField::Scalar,
}
}
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
let t = text.trim();
match self.step {
Step::Center => None,
Step::BaseRadius => {
if let Some(v) = Self::parse_value(t) {
if v > 0.0 {
self.base_radius = v;
self.step = Step::TopRadius;
}
}
Some(CmdResult::NeedPoint)
}
Step::TopRadius => {
if t.is_empty() {
// Empty entry keeps the top radius equal to the base radius.
self.top_radius = self.base_radius;
self.step = Step::Height;
} else if let Some(v) = Self::parse_value(t) {
self.top_radius = if v > 0.0 { v } else { self.base_radius };
self.step = Step::Height;
}
Some(CmdResult::NeedPoint)
}
Step::Height => {
if let Some(v) = Self::parse_value(t) {
if v != 0.0 {
self.height = v;
self.step = Step::Turns;
}
}
Some(CmdResult::NeedPoint)
}
Step::Turns => {
self.turns = if t.is_empty() {
DEFAULT_TURNS
} else {
match Self::parse_value(t) {
Some(v) if v > 0.0 => v,
_ => return Some(CmdResult::NeedPoint),
}
};
match self.build() {
Some(e) => Some(CmdResult::CommitAndExit(e)),
None => Some(CmdResult::Cancel),
}
}
}
}
fn on_mouse_move(&mut self, pt: DVec3) -> Option<WireModel> {
// Preview the helix once enough parameters are known. Before a base
// radius is fixed, track the cursor distance to size a live ring.
match self.step {
Step::Center => None,
Step::BaseRadius => {
let r = ((pt.x - self.center.x).powi(2) + (pt.y - self.center.y).powi(2)).sqrt();
if r <= 0.0 {
return None;
}
let pts = self.sample_points(r, r, 0.0, 1.0);
let raw: Vec<[f64; 3]> = pts.iter().map(|p| [p.x, p.y, p.z]).collect();
Some(WireModel::solid_f64(
"helix_preview".to_string(),
raw,
WireModel::CYAN,
false,
))
}
_ => {
// Use the parameters fixed so far; for the height step let the
// cursor elevation drive the live height.
let height = if self.step == Step::Height {
pt.z - self.center.z
} else {
self.height
};
let top = if self.step == Step::TopRadius {
let r = ((pt.x - self.center.x).powi(2) + (pt.y - self.center.y).powi(2)).sqrt();
if r > 0.0 {
r
} else {
self.base_radius
}
} else {
self.top_radius
};
let pts = self.sample_points(self.base_radius, top, height, self.turns);
if pts.len() < 2 {
return None;
}
let raw: Vec<[f64; 3]> = pts.iter().map(|p| [p.x, p.y, p.z]).collect();
Some(WireModel::solid_f64(
"helix_preview".to_string(),
raw,
WireModel::CYAN,
false,
))
}
}
}
}
// ── Autocomplete registry ─────────────────────────────────
inventory::submit!(crate::command::CommandRegistration { names: &["HELIX"] }); // HelixCommand
+67 -16
View File
@@ -28,13 +28,23 @@ pub fn tool() -> ToolDef {
// ── Command implementation ────────────────────────────────────────────────
pub struct LineCommand {
/// The last committed point (start of the next segment).
last: Option<DVec3>,
/// Every point picked so far. `points[0]` is the start (needed by Close);
/// `points.last()` is the start of the next segment. Each pick after the
/// first commits one Line entity, so the count of committed segments is
/// `points.len() - 1`.
points: Vec<DVec3>,
}
impl LineCommand {
pub fn new() -> Self {
Self { last: None }
Self { points: Vec::new() }
}
fn line_between(a: DVec3, b: DVec3) -> EntityType {
EntityType::Line(Line::from_points(
Vector3::new(a.x, a.y, a.z),
Vector3::new(b.x, b.y, b.z),
))
}
}
@@ -44,23 +54,20 @@ impl CadCommand for LineCommand {
}
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()
match self.points.len() {
0 => "LINE Specify first point:".to_string(),
1 => "LINE Specify next point [Undo]:".to_string(),
_ => "LINE Specify next point [Close/Undo]:".to_string(),
}
}
fn on_point(&mut self, pt: DVec3) -> CmdResult {
if let Some(last) = self.last {
let line = Line::from_points(
Vector3::new(last.x, last.y, last.z),
Vector3::new(pt.x, pt.y, pt.z),
);
self.last = Some(pt);
CmdResult::CommitEntity(EntityType::Line(line))
if let Some(&last) = self.points.last() {
let line = Self::line_between(last, pt);
self.points.push(pt);
CmdResult::CommitEntity(line)
} else {
self.last = Some(pt);
self.points.push(pt);
CmdResult::NeedPoint
}
}
@@ -73,8 +80,52 @@ impl CadCommand for LineCommand {
CmdResult::Cancel
}
fn wants_text_input(&self) -> bool {
// Accept Close / Undo once at least the first point is placed.
!self.points.is_empty()
}
fn point_step_accepts_keywords(&self) -> bool {
// The next-point pick also takes C / U, so the polar dynamic-input
// distance/angle boxes stay visible while the keywords are available.
!self.points.is_empty()
}
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
match text.trim().to_uppercase().as_str() {
"C" | "CLOSE" => {
// Need at least two points to draw a closing segment back to
// the start; then finish the command.
if self.points.len() >= 2 {
let close = Self::line_between(
*self.points.last().unwrap(),
self.points[0],
);
Some(CmdResult::CommitAndExit(close))
} else {
Some(CmdResult::NeedPoint)
}
}
"U" | "UNDO" => {
if self.points.len() >= 2 {
// Drop the last vertex and revert its committed segment.
self.points.pop();
Some(CmdResult::UndoDocument)
} else if self.points.len() == 1 {
// Only the start is placed (nothing committed yet) — clear
// it so the next pick restarts the line.
self.points.clear();
Some(CmdResult::NeedPoint)
} else {
Some(CmdResult::NeedPoint)
}
}
_ => None,
}
}
fn on_mouse_move(&mut self, pt: DVec3) -> Option<WireModel> {
let last = self.last?;
let last = *self.points.last()?;
Some(WireModel::solid_f64(
"rubber_band".to_string(),
vec![[last.x, last.y, last.z], [pt.x, pt.y, pt.z]],
+6
View File
@@ -1,17 +1,23 @@
pub mod arc;
pub mod attdef;
pub mod circle;
pub mod dimcenter;
pub mod donut;
pub mod ellipse;
pub mod helix;
pub mod hatch;
pub mod hatchedit;
pub mod line;
pub mod mline;
pub mod point;
pub mod poly3d;
pub mod polyline;
pub mod raster_image;
pub mod ray;
pub mod revcloud;
pub mod shapes;
pub mod sketch;
pub mod solid2d;
pub mod trace;
pub mod spline;
pub mod wipeout;
+130
View File
@@ -0,0 +1,130 @@
// 3D polyline tool — interactive command.
//
// Command: 3DPOLY — pick a sequence of points whose Z ordinates may differ and
// commit them as a single 3D polyline of straight segments. Unlike PLINE (a
// planar lightweight polyline with bulge arcs), every vertex keeps its own Z,
// so the result is a true non-planar path. Close (C) adds a closing segment
// back to the first point; Undo (U) drops the last picked vertex. Enter / Esc
// finishes the open polyline.
use acadrust::entities::Polyline3D;
use acadrust::types::Vector3;
use acadrust::EntityType;
use crate::command::{CadCommand, CmdResult};
use crate::modules::{IconKind, ModuleEvent, ToolDef};
use crate::scene::model::wire_model::WireModel;
use glam::DVec3;
// ── Ribbon definition ─────────────────────────────────────────────────────
#[allow(dead_code)] // ribbon definition ready for wiring; command works via the command line
pub fn tool() -> ToolDef {
ToolDef {
id: "3DPOLY",
label: "3D Polyline",
icon: IconKind::Svg(include_bytes!("../../../../assets/icons/line.svg")),
event: ModuleEvent::Command("3DPOLY".to_string()),
}
}
// ── Command implementation ────────────────────────────────────────────────
pub struct Poly3dCommand {
/// Every point picked so far, in world space. `points[0]` is the start
/// (needed by Close); each retains its own Z.
points: Vec<DVec3>,
}
impl Poly3dCommand {
pub fn new() -> Self {
Self { points: Vec::new() }
}
/// Build the 3D polyline entity from the collected points, or `None` when
/// there are too few points to form a segment.
fn build(&self, closed: bool) -> Option<EntityType> {
if self.points.len() < 2 {
return None;
}
let pts: Vec<Vector3> = self
.points
.iter()
.map(|p| Vector3::new(p.x, p.y, p.z))
.collect();
let mut pl = Polyline3D::from_points(pts);
pl.flags.closed = closed;
Some(EntityType::Polyline3D(pl))
}
}
impl CadCommand for Poly3dCommand {
fn name(&self) -> &'static str {
"3DPOLY"
}
fn prompt(&self) -> String {
match self.points.len() {
0 => "3DPOLY Specify start point of polyline:".to_string(),
1 => "3DPOLY Specify next point [Undo]:".to_string(),
_ => "3DPOLY Specify next point [Close/Undo]:".to_string(),
}
}
fn on_point(&mut self, pt: DVec3) -> CmdResult {
self.points.push(pt);
CmdResult::NeedPoint
}
fn on_enter(&mut self) -> CmdResult {
match self.build(false) {
Some(e) => CmdResult::CommitAndExit(e),
None => CmdResult::Cancel,
}
}
fn on_escape(&mut self) -> CmdResult {
CmdResult::Cancel
}
fn wants_text_input(&self) -> bool {
!self.points.is_empty()
}
fn point_step_accepts_keywords(&self) -> bool {
!self.points.is_empty()
}
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
match text.trim().to_uppercase().as_str() {
"C" | "CLOSE" => match self.build(true) {
Some(e) => Some(CmdResult::CommitAndExit(e)),
None => Some(CmdResult::NeedPoint),
},
"U" | "UNDO" => {
self.points.pop();
Some(CmdResult::NeedPoint)
}
_ => None,
}
}
fn on_mouse_move(&mut self, pt: DVec3) -> Option<WireModel> {
if self.points.is_empty() {
return None;
}
// Preview the whole chain committed so far plus the pending segment to
// the cursor, so the path is visible while it is built.
let mut pts: Vec<[f64; 3]> = self.points.iter().map(|p| [p.x, p.y, p.z]).collect();
pts.push([pt.x, pt.y, pt.z]);
Some(WireModel::solid_f64(
"rubber_band".to_string(),
pts,
WireModel::CYAN,
false,
))
}
}
// ── Autocomplete registry ─────────────────────────────────
inventory::submit!(crate::command::CommandRegistration { names: &["3DPOLY"] }); // Poly3dCommand
+158
View File
@@ -0,0 +1,158 @@
// Freehand sketch tool — interactive command.
//
// Command: SKETCH — freehand sketching. A click toggles the pen down/up. While
// the pen is DOWN, moving the cursor records its position into the current
// stroke whenever it has travelled more than a small fixed threshold, and a
// cyan preview wire of the current stroke is shown. A click while the pen is
// down lifts it, ending the current stroke; the next click starts a fresh one.
// Enter commits every recorded stroke (each stroke of two or more points
// becomes one lightweight polyline of straight segments); Esc cancels.
//
// Geometry is built like the wide-line / ring tools: each stroke is one
// LwPolyline whose vertices carry the sampled XY positions and whose elevation
// is the first sample's Z.
use acadrust::entities::{LwPolyline, LwVertex};
use acadrust::types::{Vector2, Vector3};
use acadrust::EntityType;
use crate::command::{CadCommand, CmdResult};
use crate::modules::{IconKind, ModuleEvent, ToolDef};
use crate::scene::model::wire_model::WireModel;
use glam::DVec3;
// Minimum cursor travel (drawing units) before a new sample is recorded.
const SAMPLE_EPSILON: f64 = 0.5;
// ── Ribbon definition ─────────────────────────────────────────────────────
#[allow(dead_code)] // ribbon definition ready for wiring; command works via the command line
pub fn tool() -> ToolDef {
ToolDef {
id: "SKETCH",
label: "Sketch",
icon: IconKind::Svg(include_bytes!("../../../../assets/icons/line.svg")),
event: ModuleEvent::Command("SKETCH".to_string()),
}
}
// ── Command implementation ────────────────────────────────────────────────
pub struct SketchCommand {
/// All completed strokes plus, while the pen is down, the one being drawn
/// as the last element. Each stroke is its own list of sampled points.
strokes: Vec<Vec<DVec3>>,
/// True while the pen is down and samples accumulate into the current stroke.
pen_down: bool,
}
impl SketchCommand {
pub fn new() -> Self {
Self {
strokes: Vec::new(),
pen_down: false,
}
}
/// Build one lightweight polyline from a stroke's sampled points, or `None`
/// when the stroke has too few points to form a segment.
fn build_stroke(points: &[DVec3]) -> Option<EntityType> {
if points.len() < 2 {
return None;
}
let elevation = points[0].z;
let mut pl = LwPolyline::new();
pl.is_closed = false;
pl.elevation = elevation;
pl.vertices = points
.iter()
.map(|p| LwVertex::new(Vector2::new(p.x, p.y)))
.collect();
pl.normal = Vector3::UNIT_Z;
Some(EntityType::LwPolyline(pl))
}
/// All strokes that have enough points, as committable entities.
fn build_all(&self) -> Vec<EntityType> {
self.strokes
.iter()
.filter_map(|s| Self::build_stroke(s))
.collect()
}
}
impl CadCommand for SketchCommand {
fn name(&self) -> &'static str {
"SKETCH"
}
fn prompt(&self) -> String {
if self.pen_down {
"SKETCH Pen down — move to sketch, click to lift, Enter to record:".to_string()
} else {
"SKETCH Pen up — click to lower the pen, Enter to record:".to_string()
}
}
fn on_point(&mut self, pt: DVec3) -> CmdResult {
if self.pen_down {
// Lift the pen: the current stroke is finished. A new click later
// begins a fresh stroke.
self.pen_down = false;
} else {
// Lower the pen: start a new stroke seeded with this point.
self.strokes.push(vec![pt]);
self.pen_down = true;
}
CmdResult::NeedPoint
}
fn on_enter(&mut self) -> CmdResult {
let entities = self.build_all();
match entities.len() {
0 => CmdResult::Cancel,
1 => CmdResult::CommitAndExit(entities.into_iter().next().unwrap()),
_ => CmdResult::ReplaceMany(vec![], entities),
}
}
fn on_escape(&mut self) -> CmdResult {
CmdResult::Cancel
}
fn on_mouse_move(&mut self, pt: DVec3) -> Option<WireModel> {
if !self.pen_down {
return None;
}
// The current stroke is the last one pushed while the pen is down.
let stroke = self.strokes.last_mut()?;
// Record the sample only once it has moved past the threshold from the
// last recorded point, so the polyline isn't flooded with near-duplicate
// vertices.
let record = match stroke.last() {
Some(last) => last.distance(pt) > SAMPLE_EPSILON,
None => true,
};
if record {
stroke.push(pt);
}
// Preview the current stroke, including the (possibly unrecorded) cursor
// position so the line tracks the pointer smoothly.
let mut pts: Vec<[f64; 3]> = stroke.iter().map(|p| [p.x, p.y, p.z]).collect();
if !record {
pts.push([pt.x, pt.y, pt.z]);
}
if pts.len() < 2 {
return None;
}
Some(WireModel::solid_f64(
"rubber_band".to_string(),
pts,
WireModel::CYAN,
false,
))
}
}
// ── Autocomplete registry ─────────────────────────────────
inventory::submit!(crate::command::CommandRegistration { names: &["SKETCH"] }); // SketchCommand
+111
View File
@@ -0,0 +1,111 @@
// 2D solid tool — interactive command.
//
// Command: SOLID (reachable as SO / SOLID2D — the bare SOLID verb currently
// toggles the shaded display) — pick three or four corner points and commit a
// filled triangle or quadrilateral. Points are taken in the order picked: the
// third point is the corner diagonally opposite the second, so a four-point
// solid is entered in a Z pattern (entering the corners in ring order yields
// the classic bow-tie fill). Enter after the third point commits a triangle.
use acadrust::entities::Solid;
use acadrust::types::Vector3;
use acadrust::EntityType;
use crate::command::{CadCommand, CmdResult};
use crate::modules::{IconKind, ModuleEvent, ToolDef};
use crate::scene::model::wire_model::WireModel;
use glam::DVec3;
// ── Ribbon definition ─────────────────────────────────────────────────────
#[allow(dead_code)] // ribbon definition ready for wiring; command works via the command line
pub fn tool() -> ToolDef {
ToolDef {
id: "SOLID2D",
label: "2D Solid",
icon: IconKind::Svg(include_bytes!("../../../../assets/icons/line.svg")),
event: ModuleEvent::Command("SOLID2D".to_string()),
}
}
// ── Command implementation ────────────────────────────────────────────────
pub struct Solid2dCommand {
/// Corner points picked so far (3 → triangle, 4 → quadrilateral).
points: Vec<DVec3>,
}
impl Solid2dCommand {
pub fn new() -> Self {
Self { points: Vec::new() }
}
fn v3(p: DVec3) -> Vector3 {
Vector3::new(p.x, p.y, p.z)
}
}
impl CadCommand for Solid2dCommand {
fn name(&self) -> &'static str {
"SOLID"
}
fn prompt(&self) -> String {
match self.points.len() {
0 => "SOLID Specify first point:".to_string(),
1 => "SOLID Specify second point:".to_string(),
2 => "SOLID Specify third point:".to_string(),
_ => "SOLID Specify fourth point [Enter = triangle]:".to_string(),
}
}
fn on_point(&mut self, pt: DVec3) -> CmdResult {
self.points.push(pt);
if self.points.len() == 4 {
let p = &self.points;
let solid = Solid::new(
Self::v3(p[0]),
Self::v3(p[1]),
Self::v3(p[2]),
Self::v3(p[3]),
);
CmdResult::CommitAndExit(EntityType::Solid(solid))
} else {
CmdResult::NeedPoint
}
}
fn on_enter(&mut self) -> CmdResult {
if self.points.len() == 3 {
let p = &self.points;
let solid = Solid::triangle(Self::v3(p[0]), Self::v3(p[1]), Self::v3(p[2]));
CmdResult::CommitAndExit(EntityType::Solid(solid))
} else {
CmdResult::Cancel
}
}
fn on_escape(&mut self) -> CmdResult {
CmdResult::Cancel
}
fn on_mouse_move(&mut self, pt: DVec3) -> Option<WireModel> {
if self.points.is_empty() {
return None;
}
// Outline the corners picked so far plus the cursor, closed back to the
// first point, as a rubber-band hint.
let mut pts: Vec<[f64; 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::solid_f64(
"rubber_band".to_string(),
pts,
WireModel::CYAN,
false,
))
}
}
// ── Autocomplete registry ─────────────────────────────────
inventory::submit!(crate::command::CommandRegistration { names: &["SO", "SOLID2D"] }); // Solid2dCommand
+31 -5
View File
@@ -30,7 +30,7 @@ impl SplineCommand {
Self { pts: Vec::new() }
}
fn build(&self) -> Option<EntityType> {
fn build(&self, closed: bool) -> Option<EntityType> {
if self.pts.len() < 2 {
return None;
}
@@ -43,12 +43,15 @@ impl SplineCommand {
// 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 {
let mut spline = Spline {
degree,
control_points,
knots,
..Default::default()
};
// Closed splines render with a segment bridging the last point back to
// the first (tessellation honours `flags.closed`).
spline.flags.closed = closed;
Some(EntityType::Spline(spline))
}
}
@@ -78,7 +81,7 @@ impl CadCommand for SplineCommand {
"SPLINE Specify first control point:".into()
} else {
format!(
"SPLINE Specify next point [{} pts | Enter=done]:",
"SPLINE Specify next point [{} pts | Close/Undo | Enter=done]:",
self.pts.len()
)
}
@@ -90,19 +93,42 @@ impl CadCommand for SplineCommand {
}
fn on_enter(&mut self) -> CmdResult {
match self.build() {
match self.build(false) {
Some(e) => CmdResult::CommitEntity(e),
None => CmdResult::Cancel,
}
}
fn on_escape(&mut self) -> CmdResult {
match self.build() {
match self.build(false) {
Some(e) => CmdResult::CommitEntity(e),
None => CmdResult::Cancel,
}
}
fn wants_text_input(&self) -> bool {
// Accept Close / Undo once a control point exists.
!self.pts.is_empty()
}
fn point_step_accepts_keywords(&self) -> bool {
!self.pts.is_empty()
}
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
match text.trim().to_uppercase().as_str() {
"C" | "CLOSE" => match self.build(true) {
Some(e) => Some(CmdResult::CommitAndExit(e)),
None => Some(CmdResult::NeedPoint),
},
"U" | "UNDO" => {
self.pts.pop();
Some(CmdResult::NeedPoint)
}
_ => None,
}
}
fn on_mouse_move(&mut self, pt: DVec3) -> Option<WireModel> { let pt = pt.as_vec3();
if self.pts.is_empty() {
return None;
+180
View File
@@ -0,0 +1,180 @@
// Wide-line tool — interactive command.
//
// Command: TRACE — first type a band width (a number; Enter alone keeps the
// default 1.0), then pick a sequence of points. Each picked point extends a
// connected, solid-filled band of the fixed width along the path. Undo (U)
// drops the last picked vertex; Enter / Esc finishes the band.
//
// The result is committed as one lightweight polyline whose constant width is
// set, so the renderer fills it as a continuous solid band. Picking a single
// entity here would not apply — TRACE builds new geometry from picked points,
// so the planar point-pick path is used (every vertex shares the polyline
// elevation, taken from the first point's Z).
use acadrust::entities::{LwPolyline, LwVertex};
use acadrust::types::{Vector2, Vector3};
use acadrust::EntityType;
use crate::command::{CadCommand, CmdResult};
use crate::modules::{IconKind, ModuleEvent, ToolDef};
use crate::scene::model::wire_model::WireModel;
use glam::DVec3;
// ── Ribbon definition ─────────────────────────────────────────────────────
#[allow(dead_code)] // ribbon definition ready for wiring; command works via the command line
pub fn tool() -> ToolDef {
ToolDef {
id: "TRACE",
label: "Trace",
icon: IconKind::Svg(include_bytes!("../../../../assets/icons/line.svg")),
event: ModuleEvent::Command("TRACE".to_string()),
}
}
// ── Command implementation ────────────────────────────────────────────────
pub struct TraceCommand {
/// Band width. Collected first (typed), defaults to 1.0 until a width step
/// is completed.
width: f64,
/// True until the width has been entered (typed value or bare Enter for the
/// default). While false the command is in its width-prompt step.
width_set: bool,
/// Every point picked so far, in world space. `points[0]` fixes the band's
/// elevation (its Z).
points: Vec<DVec3>,
}
impl TraceCommand {
pub fn new() -> Self {
Self {
width: 1.0,
width_set: false,
points: Vec::new(),
}
}
/// Build the wide lightweight polyline from the collected points, or `None`
/// when there are too few points to form a segment.
fn build(&self) -> Option<EntityType> {
if self.points.len() < 2 {
return None;
}
let elevation = self.points[0].z;
let mut pl = LwPolyline::new();
pl.is_closed = false;
pl.constant_width = self.width;
pl.elevation = elevation;
pl.vertices = self
.points
.iter()
.map(|p| {
let mut v = LwVertex::new(Vector2::new(p.x, p.y));
v.start_width = self.width;
v.end_width = self.width;
v
})
.collect();
pl.normal = Vector3::UNIT_Z;
Some(EntityType::LwPolyline(pl))
}
}
impl CadCommand for TraceCommand {
fn name(&self) -> &'static str {
"TRACE"
}
fn prompt(&self) -> String {
if !self.width_set {
return format!("TRACE Specify trace width <{:.4}>:", self.width);
}
match self.points.len() {
0 => "TRACE Specify start point:".to_string(),
1 => "TRACE Specify next point [Undo]:".to_string(),
_ => "TRACE Specify next point [Undo]:".to_string(),
}
}
fn on_point(&mut self, pt: DVec3) -> CmdResult {
// A point click before the width is fixed accepts the current default.
if !self.width_set {
self.width_set = true;
}
self.points.push(pt);
CmdResult::NeedPoint
}
fn on_enter(&mut self) -> CmdResult {
if !self.width_set {
// Bare Enter at the width step keeps the default and advances.
self.width_set = true;
return CmdResult::NeedPoint;
}
match self.build() {
Some(e) => CmdResult::CommitAndExit(e),
None => CmdResult::Cancel,
}
}
fn on_escape(&mut self) -> CmdResult {
CmdResult::Cancel
}
fn wants_text_input(&self) -> bool {
// Width step reads a typed number; the point steps accept the Undo
// keyword.
!self.width_set || !self.points.is_empty()
}
fn point_step_accepts_keywords(&self) -> bool {
self.width_set && !self.points.is_empty()
}
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
let t = text.trim();
if !self.width_set {
if t.is_empty() {
// Treated as bare Enter: keep the default width.
self.width_set = true;
return Some(CmdResult::NeedPoint);
}
if let Ok(w) = t.parse::<f64>() {
if w > 0.0 {
self.width = w;
self.width_set = true;
return Some(CmdResult::NeedPoint);
}
}
// Unparsable / non-positive: re-prompt without changing state.
return Some(CmdResult::NeedPoint);
}
match t.to_uppercase().as_str() {
"U" | "UNDO" => {
self.points.pop();
Some(CmdResult::NeedPoint)
}
_ => None,
}
}
fn on_mouse_move(&mut self, pt: DVec3) -> Option<WireModel> {
if !self.width_set || self.points.is_empty() {
return None;
}
// Preview the path centre-line committed so far plus the pending segment
// to the cursor.
let mut pts: Vec<[f64; 3]> = self.points.iter().map(|p| [p.x, p.y, p.z]).collect();
pts.push([pt.x, pt.y, pt.z]);
Some(WireModel::solid_f64(
"rubber_band".to_string(),
pts,
WireModel::CYAN,
false,
))
}
}
// ── Autocomplete registry ─────────────────────────────────
inventory::submit!(crate::command::CommandRegistration { names: &["TRACE"] }); // TraceCommand
+18 -13
View File
@@ -1,13 +1,13 @@
// AREA command — compute area and perimeter of a polygon picked point by point.
// Press Enter to close and calculate.
use glam::{DVec3, Vec3};
use glam::DVec3;
use crate::command::{CadCommand, CmdResult};
use crate::scene::model::wire_model::WireModel;
pub struct AreaCommand {
points: Vec<Vec3>,
points: Vec<DVec3>,
}
impl AreaCommand {
@@ -32,7 +32,7 @@ impl CadCommand for AreaCommand {
}
}
fn on_point(&mut self, pt: DVec3) -> CmdResult { let pt = pt.as_vec3();
fn on_point(&mut self, pt: DVec3) -> CmdResult {
self.points.push(pt);
CmdResult::NeedPoint
}
@@ -41,28 +41,33 @@ impl CadCommand for AreaCommand {
if self.points.len() < 3 {
return CmdResult::Cancel;
}
// Shoelace formula in the world XY plane.
// Shoelace formula in the world XY plane, evaluated in f64 relative to
// the first vertex. Subtracting that origin keeps the cross-product
// terms small even at large (survey/UTM) coordinates, avoiding the
// catastrophic cancellation a raw f32/absolute evaluation suffers.
let n = self.points.len();
let mut area_sum = 0.0f32;
let mut perimeter = 0.0f32;
let origin = self.points[0];
let mut area_sum = 0.0f64;
let mut perimeter = 0.0f64;
for idx in 0..n {
let a = self.points[idx];
let b = self.points[(idx + 1) % n];
let a = self.points[idx] - origin;
let b = self.points[(idx + 1) % n] - origin;
area_sum += a.x * b.y - b.x * a.y;
perimeter += (b - a).length();
perimeter += (self.points[(idx + 1) % n] - self.points[idx]).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: DVec3) -> Option<WireModel> { let pt = pt.as_vec3();
fn on_mouse_move(&mut self, pt: DVec3) -> 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]);
let f = |p: DVec3| [p.x as f32, p.y as f32, p.z as f32];
let mut pts: Vec<[f32; 3]> = self.points.iter().map(|p| f(*p)).collect();
pts.push(f(pt));
pts.push(f(self.points[0]));
Some(WireModel {
name: "area_preview".into(),
points: pts,
+303
View File
@@ -0,0 +1,303 @@
// MEASUREGEOM command (alias MEA) — multi-mode geometry measurement.
//
// First step prompts for a mode keyword:
// Distance / Radius / Angle / ARea
// then collects the geometry the mode needs and prints a one-line readout
// via `CmdResult::Measurement`, ending the command.
//
// DISTANCE — two points → distance, delta X/Y/Z, angle in the XY plane.
// AREA — points until Enter → area (f64 shoelace relative to the first
// vertex, mirroring inquiry/area.rs for precision) + perimeter.
// ANGLE — three points (vertex + two ray endpoints) → angle in degrees.
// RADIUS — pick a Circle or Arc → radius + diameter.
//
// All arithmetic is kept in f64 (picked points stay full precision; downcasting
// to f32 loses several hundredths of a unit at survey-scale coordinates).
use acadrust::{EntityType, Handle};
use glam::DVec3;
use crate::command::{CadCommand, CmdResult};
use crate::modules::{IconKind, ModuleEvent, ToolDef};
use crate::scene::model::wire_model::WireModel;
// ── Ribbon definition ─────────────────────────────────────────────────────
#[allow(dead_code)] // ribbon definition ready for wiring; command works via the command line
pub fn tool() -> ToolDef {
ToolDef {
id: "MEASUREGEOM",
label: "Measure Geometry",
icon: IconKind::Svg(include_bytes!("../../../../assets/icons/line.svg")),
event: ModuleEvent::Command("MEASUREGEOM".to_string()),
}
}
// ── Mode ───────────────────────────────────────────────────────────────────
#[derive(Clone, Copy, PartialEq, Eq)]
enum Mode {
/// Awaiting the mode keyword.
Choose,
Distance,
Area,
Angle,
Radius,
}
// ── Command implementation ──────────────────────────────────────────────────
pub struct MeasureGeomCommand {
mode: Mode,
/// Picked points for the active point-pick mode.
points: Vec<DVec3>,
/// The picked entity for RADIUS, injected before `on_entity_pick`.
picked: Option<EntityType>,
}
impl MeasureGeomCommand {
pub fn new() -> Self {
Self {
mode: Mode::Choose,
points: vec![],
picked: None,
}
}
/// Build a cyan preview wire connecting the picked points and the cursor.
fn preview_wire(name: &str, pts: Vec<[f32; 3]>) -> WireModel {
WireModel {
name: name.to_string(),
points: pts,
points_low: Vec::new(),
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![],
fill_tris_low: Vec::new(),
}
}
/// Extract (radius) from a Circle or Arc; `None` for anything else.
fn radius_of(entity: &EntityType) -> Option<f64> {
match entity {
EntityType::Circle(c) => Some(c.radius),
EntityType::Arc(a) => Some(a.radius),
_ => None,
}
}
/// DISTANCE readout for the two collected points.
fn distance_msg(p1: DVec3, p2: DVec3) -> String {
let delta = p2 - p1;
let dist = delta.length();
let dx = delta.x;
let dy = delta.y; // drawing plane is world XY
let dz = delta.z; // elevation
let angle_xy = dy.atan2(dx).to_degrees();
format!(
"Distance = {dist:.4}, Angle in XY Plane = {angle_xy:.4}°\n Delta X = {dx:.4}, Delta Y = {dy:.4}, Delta Z = {dz:.4}",
)
}
/// AREA readout: shoelace area (f64, relative to first vertex) + perimeter.
fn area_msg(points: &[DVec3]) -> String {
let n = points.len();
let origin = points[0];
let mut area_sum = 0.0f64;
let mut perimeter = 0.0f64;
for idx in 0..n {
let a = points[idx] - origin;
let b = points[(idx + 1) % n] - origin;
area_sum += a.x * b.y - b.x * a.y;
perimeter += (points[(idx + 1) % n] - points[idx]).length();
}
let area = (area_sum * 0.5).abs();
format!("Area = {area:.4}, Perimeter = {perimeter:.4}")
}
/// ANGLE readout: angle at `vertex` between the rays to `a` and `b`.
fn angle_msg(vertex: DVec3, a: DVec3, b: DVec3) -> String {
let va = a - vertex;
let vb = b - vertex;
let la = va.length();
let lb = vb.length();
if la == 0.0 || lb == 0.0 {
return "Angle = 0.0000° (degenerate rays)".to_string();
}
let cos = (va.dot(vb) / (la * lb)).clamp(-1.0, 1.0);
let angle = cos.acos().to_degrees();
format!("Angle = {angle:.4}°")
}
}
impl CadCommand for MeasureGeomCommand {
fn name(&self) -> &'static str {
"MEASUREGEOM"
}
fn prompt(&self) -> String {
match self.mode {
Mode::Choose => {
"MEASUREGEOM Enter an option [Distance/Radius/Angle/ARea]:".into()
}
Mode::Distance => {
if self.points.is_empty() {
"MEASUREGEOM Specify first point:".into()
} else {
"MEASUREGEOM Specify second point:".into()
}
}
Mode::Area => {
if self.points.is_empty() {
"MEASUREGEOM Specify first corner point (Enter to cancel):".into()
} else {
format!(
"MEASUREGEOM Specify next point ({} picked, Enter to calculate):",
self.points.len()
)
}
}
Mode::Angle => match self.points.len() {
0 => "MEASUREGEOM Specify vertex point:".into(),
1 => "MEASUREGEOM Specify first ray point:".into(),
_ => "MEASUREGEOM Specify second ray point:".into(),
},
Mode::Radius => "MEASUREGEOM Select arc or circle:".into(),
}
}
fn wants_text_input(&self) -> bool {
// Only the opening mode-keyword step reads a typed token.
self.mode == Mode::Choose
}
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
if self.mode != Mode::Choose {
return None;
}
let t = text.trim().to_uppercase();
self.mode = match t.as_str() {
"D" | "DISTANCE" => Mode::Distance,
"R" | "RADIUS" => Mode::Radius,
"A" | "ANGLE" => Mode::Angle,
"AR" | "AREA" => Mode::Area,
_ => return Some(CmdResult::NeedPoint), // re-prompt on unknown keyword
};
Some(CmdResult::NeedPoint)
}
fn needs_entity_pick(&self) -> bool {
self.mode == Mode::Radius
}
fn inject_before_entity_pick(&self) -> bool {
true
}
fn inject_picked_entity(&mut self, entity: EntityType) {
self.picked = Some(entity);
}
fn on_entity_pick(&mut self, handle: Handle, _pt: DVec3) -> CmdResult {
if handle.is_null() {
return CmdResult::NeedPoint;
}
match self.picked.as_ref().and_then(Self::radius_of) {
Some(radius) => {
let diameter = radius * 2.0;
CmdResult::Measurement(format!(
"Radius = {radius:.4}, Diameter = {diameter:.4}"
))
}
// Picked something that is not a circle or arc — keep prompting.
None => CmdResult::NeedPoint,
}
}
fn on_point(&mut self, pt: DVec3) -> CmdResult {
match self.mode {
Mode::Distance => {
self.points.push(pt);
if self.points.len() == 2 {
CmdResult::Measurement(Self::distance_msg(self.points[0], self.points[1]))
} else {
CmdResult::NeedPoint
}
}
Mode::Angle => {
self.points.push(pt);
if self.points.len() == 3 {
CmdResult::Measurement(Self::angle_msg(
self.points[0],
self.points[1],
self.points[2],
))
} else {
CmdResult::NeedPoint
}
}
Mode::Area => {
self.points.push(pt);
CmdResult::NeedPoint
}
// Choose / Radius do not take point picks.
_ => CmdResult::NeedPoint,
}
}
fn on_enter(&mut self) -> CmdResult {
match self.mode {
Mode::Area => {
if self.points.len() < 3 {
CmdResult::Cancel
} else {
CmdResult::Measurement(Self::area_msg(&self.points))
}
}
_ => CmdResult::Cancel,
}
}
fn on_escape(&mut self) -> CmdResult {
CmdResult::Cancel
}
fn on_mouse_move(&mut self, pt: DVec3) -> Option<WireModel> {
let f = |p: DVec3| [p.x as f32, p.y as f32, p.z as f32];
match self.mode {
Mode::Distance | Mode::Angle => {
if self.points.is_empty() {
return None;
}
let mut pts: Vec<[f32; 3]> = self.points.iter().map(|p| f(*p)).collect();
pts.push(f(pt));
Some(Self::preview_wire("measuregeom_preview", pts))
}
Mode::Area => {
if self.points.is_empty() {
return None;
}
let mut pts: Vec<[f32; 3]> = self.points.iter().map(|p| f(*p)).collect();
pts.push(f(pt));
pts.push(f(self.points[0]));
Some(Self::preview_wire("measuregeom_preview", pts))
}
_ => None,
}
}
}
// ── Autocomplete registry ─────────────────────────────────
inventory::submit!(crate::command::CommandRegistration {
names: &["MEASUREGEOM", "MEA"]
}); // MeasureGeomCommand
+1
View File
@@ -2,3 +2,4 @@ pub mod area;
pub mod dist;
pub mod divide;
pub mod id;
pub mod measuregeom;
+70 -6
View File
@@ -35,6 +35,10 @@ pub struct CopyCommand {
wire_models: Vec<WireModel>,
step: Step,
count: usize,
/// Number of items for an Array copy (None = place copies one at a time).
array_count: Option<usize>,
/// True while the next typed value is captured as the array item count.
awaiting_count: bool,
}
impl CopyCommand {
@@ -44,6 +48,8 @@ impl CopyCommand {
wire_models,
step: Step::Base,
count: 0,
array_count: None,
awaiting_count: false,
}
}
}
@@ -54,15 +60,27 @@ impl CadCommand for CopyCommand {
}
fn prompt(&self) -> String {
if self.awaiting_count {
return "COPY Enter number of items to array:".to_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
),
Step::Placing(base) => {
if let Some(n) = self.array_count {
format!(
"COPY Specify second point for {n}-item array [base {:.3},{:.3}]:",
base.x, base.y
)
} else {
format!(
"COPY Specify destination [{} copies so far | Array | Enter=done | base {:.3},{:.3}]:",
self.count, base.x, base.y
)
}
}
}
}
@@ -74,19 +92,65 @@ impl CadCommand for CopyCommand {
}
Step::Placing(base) => {
let delta = pt - *base;
self.count += 1;
CmdResult::CopySelected(self.handles.clone(), EntityTransform::Translate(delta))
if let Some(n) = self.array_count {
// Array: place n-1 copies at delta, 2·delta, … so the result
// is n evenly spaced items including the original. Ends here.
let transforms: Vec<EntityTransform> = (1..n)
.map(|k| EntityTransform::Translate(delta * k as f64))
.collect();
CmdResult::BatchCopy(self.handles.clone(), transforms)
} else {
self.count += 1;
CmdResult::CopySelected(
self.handles.clone(),
EntityTransform::Translate(delta),
)
}
}
}
}
fn on_enter(&mut self) -> CmdResult {
// Cancel an in-progress Array count entry; otherwise finish.
if self.awaiting_count {
self.awaiting_count = false;
return CmdResult::NeedPoint;
}
CmdResult::Cancel
}
fn on_escape(&mut self) -> CmdResult {
CmdResult::Cancel
}
fn wants_text_input(&self) -> bool {
matches!(self.step, Step::Placing(_))
}
fn point_step_accepts_keywords(&self) -> bool {
// Accept the Array keyword while placing, but route the typed item
// count straight through on_text_input.
matches!(self.step, Step::Placing(_)) && !self.awaiting_count
}
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
if self.awaiting_count {
if let Ok(n) = text.trim().parse::<usize>() {
if n >= 2 {
self.array_count = Some(n);
}
}
self.awaiting_count = false;
return Some(CmdResult::NeedPoint);
}
match text.trim().to_uppercase().as_str() {
"A" | "ARRAY" => {
self.awaiting_count = true;
Some(CmdResult::NeedPoint)
}
_ => None,
}
}
fn on_preview_wires(&mut self, pt: DVec3) -> Vec<WireModel> {
let Step::Placing(base) = &self.step else {
return vec![];
+1
View File
@@ -12,6 +12,7 @@ pub mod mirror;
pub mod offset;
pub mod pedit;
pub mod refedit;
pub mod reverse;
pub mod rotate;
pub mod scale;
pub mod spline_ops;
+194
View File
@@ -0,0 +1,194 @@
// REVERSE command — reverse the direction (vertex / point order) of a single
// picked curve.
//
// The user picks one open or closed curve; the command builds a reversed copy
// and swaps it in via `CmdResult::ReplaceEntity`. Supported curve types:
//
// * Line — swap start / end.
// * LwPolyline — reverse the vertex list. The bulge stored at a vertex
// describes the arc on the segment *starting* at that vertex,
// so a plain `vertices.reverse()` would attach each bulge to
// the wrong segment (and with the wrong sense). We therefore
// recompute every bulge from the original segment bulges (see
// `reverse_lwpolyline`).
// * Polyline3D — reverse the vertex list (no bulges to reconcile).
// * Spline — reverse control points and fit points, then regenerate the
// clamped knot vector (mirrors the REVERSE branch of
// `splinedit::apply_spline_op`).
//
// Any other entity type is left untouched: the command returns
// `CmdResult::NeedPoint` and keeps prompting so nothing is corrupted.
use acadrust::entities::Spline;
use acadrust::{EntityType, Handle};
use glam::DVec3;
use crate::command::{CadCommand, CmdResult};
use crate::modules::{IconKind, ModuleEvent, ToolDef};
// ── Ribbon definition ───────────────────────────────────────────────────────
#[allow(dead_code)] // ribbon definition ready for wiring; command works via the command line
pub fn tool() -> ToolDef {
ToolDef {
id: "REVERSE",
label: "Reverse",
icon: IconKind::Svg(include_bytes!("../../../../assets/icons/line.svg")),
event: ModuleEvent::Command("REVERSE".to_string()),
}
}
// ── Command implementation ──────────────────────────────────────────────────
pub struct ReverseCommand {
/// The picked entity, injected by the host before `on_entity_pick` runs.
/// `None` until the host injects it.
picked: Option<EntityType>,
}
impl ReverseCommand {
pub fn new() -> Self {
Self { picked: None }
}
/// Build a reversed copy of `entity`, or `None` for an unsupported type.
fn reversed(entity: &EntityType) -> Option<EntityType> {
match entity {
EntityType::Line(line) => {
let mut out = line.clone();
std::mem::swap(&mut out.start, &mut out.end);
Some(EntityType::Line(out))
}
EntityType::LwPolyline(pl) => Some(EntityType::LwPolyline(reverse_lwpolyline(pl))),
EntityType::Polyline3D(pl) => {
let mut out = pl.clone();
out.vertices.reverse();
Some(EntityType::Polyline3D(out))
}
EntityType::Spline(sp) => Some(EntityType::Spline(reverse_spline(sp))),
_ => None,
}
}
}
/// Reverse an LwPolyline, reconciling per-segment bulges.
///
/// Bulge semantics: `vertices[k].bulge` is the bulge of the segment that
/// *starts* at vertex `k`. For an open polyline with `n` vertices there are
/// `n - 1` segments (the trailing vertex's bulge is unused); for a closed
/// polyline there are `n` segments (the trailing vertex's bulge is the closing
/// segment back to vertex 0).
///
/// After reversal the new vertex `i` is the original vertex `n-1-i`. The new
/// segment from new-vertex `i` to new-vertex `i+1` is the original segment
/// between original vertices `n-1-i` and `n-2-i`, traversed backwards — i.e.
/// the original segment that *starts* at vertex `n-2-i`. Reversing the
/// traversal direction flips an arc's sense, so the new bulge is the negation
/// of that original segment's bulge:
///
/// new_vertices[i].bulge = -old_vertices[n-2-i].bulge (open / interior)
///
/// For a closed polyline the closing segment wraps, so the general modular form
/// is used: new seg `i` (from new vertex `i`) corresponds to original segment
/// starting at original vertex `(n-1-i-1).rem_euclid(n)`. The widths follow the
/// vertex they are attached to and are carried along with the reversed order;
/// they are not segment-relative, so they need no shift.
fn reverse_lwpolyline(pl: &acadrust::LwPolyline) -> acadrust::LwPolyline {
let mut out = pl.clone();
let n = pl.vertices.len();
if n < 2 {
return out;
}
// Reverse vertex positions and per-vertex widths by cloning in reverse.
for i in 0..n {
let src = &pl.vertices[n - 1 - i];
out.vertices[i].location = src.location;
out.vertices[i].start_width = src.start_width;
out.vertices[i].end_width = src.end_width;
}
// Recompute bulges from the original segment bulges.
if pl.is_closed {
// n segments; closing segment included.
for i in 0..n {
let src_seg_start = (n - 1 - i + n - 1) % n; // = (2n - 2 - i) % n
out.vertices[i].bulge = -pl.vertices[src_seg_start].bulge;
}
} else {
// n - 1 segments. New segment i (for i in 0..n-1) maps to original
// segment starting at vertex n-2-i. The trailing vertex's bulge is
// unused for an open polyline; clear it for cleanliness.
for i in 0..n - 1 {
out.vertices[i].bulge = -pl.vertices[n - 2 - i].bulge;
}
out.vertices[n - 1].bulge = 0.0;
}
out
}
/// Reverse a Spline: flip control points and fit points, regenerate the
/// clamped knot vector. Mirrors `splinedit::apply_spline_op`'s REVERSE branch.
fn reverse_spline(sp: &Spline) -> Spline {
let mut out = sp.clone();
out.control_points.reverse();
out.fit_points.reverse();
// Weights track control points one-to-one for rational splines.
if out.weights.len() == out.control_points.len() {
out.weights.reverse();
}
out.knots =
Spline::generate_clamped_knots(out.degree as usize, out.control_points.len());
out
}
impl CadCommand for ReverseCommand {
fn name(&self) -> &'static str {
"REVERSE"
}
fn prompt(&self) -> String {
"REVERSE Select line, polyline or spline to reverse:".to_string()
}
fn needs_entity_pick(&self) -> bool {
true
}
fn inject_before_entity_pick(&self) -> bool {
true
}
fn inject_picked_entity(&mut self, entity: EntityType) {
self.picked = Some(entity);
}
fn on_entity_pick(&mut self, handle: Handle, _pt: DVec3) -> CmdResult {
if handle.is_null() {
return CmdResult::NeedPoint;
}
match self.picked.as_ref().and_then(Self::reversed) {
Some(reversed) => CmdResult::ReplaceEntity(handle, vec![reversed]),
// Unsupported entity type — keep prompting, do not corrupt it.
None => CmdResult::NeedPoint,
}
}
fn on_point(&mut self, _pt: DVec3) -> CmdResult {
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: &["REVERSE"]
}); // ReverseCommand
+35
View File
@@ -1,4 +1,7 @@
use crate::command::{CadCommand, CmdResult};
use crate::modules::{IconKind, ModuleEvent, ToolDef};
use glam::DVec3;
pub const ICON: IconKind = IconKind::Svg(include_bytes!("../../../assets/icons/base_point.svg"));
pub fn tool() -> ToolDef {
ToolDef {
@@ -8,3 +11,35 @@ pub fn tool() -> ToolDef {
event: ModuleEvent::Command("BASE".to_string()),
}
}
// ── Command implementation ────────────────────────────────────────────────
/// Picks one point and delegates to the inline `BASE <x> <y> <z>` handler,
/// which writes the active space's insertion base point onto the document.
pub struct BaseCommand;
impl BaseCommand {
pub fn new() -> Self {
Self
}
}
impl CadCommand for BaseCommand {
fn name(&self) -> &'static str {
"BASE"
}
fn prompt(&self) -> String {
"BASE Specify base point:".to_string()
}
fn on_point(&mut self, pt: DVec3) -> CmdResult {
CmdResult::Dispatch(format!("BASE {} {} {}", pt.x, pt.y, pt.z))
}
fn on_enter(&mut self) -> CmdResult {
CmdResult::Cancel
}
}
inventory::submit!(crate::command::CommandRegistration { names: &["BASE"] }); // BaseCommand
+80 -4
View File
@@ -32,9 +32,24 @@ enum Step {
},
}
/// Which numeric value the insertion-point step is currently waiting for after
/// a Scale / Rotate keyword.
#[derive(Clone, Copy)]
enum AwaitKind {
Scale,
Rotation,
}
pub struct InsertBlockCommand {
available: Vec<String>,
step: Step,
/// Uniform X/Y scale applied to the placed block (default 1).
x_scale: f64,
y_scale: f64,
/// Rotation applied to the placed block, in radians (default 0).
rotation_rad: f64,
/// Set while a Scale/Rotate value is being typed at the insertion step.
awaiting: Option<AwaitKind>,
/// Pending Insert entity stored while attr-filling is in progress.
pending_insert: Option<Insert>,
/// Optional drag preview: the block's wire geometry plus the base point it
@@ -48,6 +63,10 @@ impl InsertBlockCommand {
Self {
available,
step: Step::Name,
x_scale: 1.0,
y_scale: 1.0,
rotation_rad: 0.0,
awaiting: None,
pending_insert: None,
preview: None,
}
@@ -61,6 +80,10 @@ impl InsertBlockCommand {
Self {
available: vec![name.clone()],
step: Step::Point { name },
x_scale: 1.0,
y_scale: 1.0,
rotation_rad: 0.0,
awaiting: None,
pending_insert: None,
preview: Some((preview_wires, base)),
}
@@ -82,7 +105,14 @@ impl CadCommand for InsertBlockCommand {
};
format!("INSERT Enter block name:{hint}")
}
Step::Point { name } => format!("INSERT Specify insertion point for \"{}\":", name),
Step::Point { name } => match self.awaiting {
Some(AwaitKind::Scale) => "INSERT Specify scale factor <1>:".to_string(),
Some(AwaitKind::Rotation) => "INSERT Specify rotation angle <0>:".to_string(),
None => format!(
"INSERT Specify insertion point for \"{}\" [Scale/Rotate]:",
name
),
},
Step::FillAttr { attdefs, idx, .. } => {
if let Some((tag, prompt, default)) = attdefs.get(*idx) {
let default_hint = if default.is_empty() {
@@ -107,10 +137,13 @@ impl CadCommand for InsertBlockCommand {
match &self.step {
Step::Name => CmdResult::NeedPoint,
Step::Point { name } => {
let ins = Insert::new(
let mut ins = Insert::new(
name.clone(),
Vector3::new(pt.x as f64, pt.y as f64, pt.z as f64),
);
ins.set_x_scale(self.x_scale);
ins.set_y_scale(self.y_scale);
ins.rotation = self.rotation_rad;
let block_name = name.clone();
self.pending_insert = Some(ins);
// Signal the host to check for attdefs.
@@ -122,6 +155,12 @@ impl CadCommand for InsertBlockCommand {
fn on_enter(&mut self) -> CmdResult {
match &self.step {
// A bare Enter while a scale/rotation value is awaited keeps the
// current default instead of cancelling the command.
Step::Point { .. } if self.awaiting.is_some() => {
self.awaiting = None;
CmdResult::NeedPoint
}
Step::Name | Step::Point { .. } => CmdResult::Cancel,
Step::FillAttr { .. } => {
// Treat Enter as accepting the default.
@@ -131,7 +170,17 @@ impl CadCommand for InsertBlockCommand {
}
fn wants_text_input(&self) -> bool {
matches!(self.step, Step::Name | Step::FillAttr { .. })
matches!(
self.step,
Step::Name | Step::FillAttr { .. } | Step::Point { .. }
)
}
fn point_step_accepts_keywords(&self) -> bool {
// The insertion-point step also takes Scale / Rotate keywords while
// still accepting a point pick. Once a value is being typed (awaiting),
// route the whole number through `on_text_input` instead.
matches!(self.step, Step::Point { .. }) && self.awaiting.is_none()
}
fn wants_text_with_spaces(&self) -> bool {
@@ -152,7 +201,34 @@ impl CadCommand for InsertBlockCommand {
Some(CmdResult::NeedPoint)
}
Step::FillAttr { .. } => Some(self.accept_attr_value(text)),
Step::Point { .. } => None,
Step::Point { .. } => {
// Typing the value awaited after a Scale / Rotate keyword.
if let Some(kind) = self.awaiting {
if let Ok(v) = text.trim().parse::<f64>() {
match kind {
AwaitKind::Scale if v != 0.0 => {
self.x_scale = v;
self.y_scale = v;
}
AwaitKind::Scale => {}
AwaitKind::Rotation => self.rotation_rad = v.to_radians(),
}
}
self.awaiting = None;
return Some(CmdResult::NeedPoint);
}
match text.trim().to_uppercase().as_str() {
"S" | "SCALE" => {
self.awaiting = Some(AwaitKind::Scale);
Some(CmdResult::NeedPoint)
}
"R" | "ROTATE" => {
self.awaiting = Some(AwaitKind::Rotation);
Some(CmdResult::NeedPoint)
}
_ => None,
}
}
}
}
+1 -1
View File
@@ -4,7 +4,7 @@ mod attdef;
mod attedit;
mod attman;
mod attsync;
mod base_point;
pub mod base_point;
mod content_browser;
pub(crate) mod create_block;
mod design_center;