feat(commands): expand standard CAD command coverage

Integrate a large batch of standard CAD commands and fix several
name/behaviour mismatches, with no product references in code.

3D solids (reusing the truck B-rep pipeline):
- SLICE, INTERFERE, PRESSPULL, POLYSOLID, PYRAMID, SECTION
- 3DROTATE, 3DMIRROR, 3DALIGN (cached-solid transforms)

Annotation / text:
- ARCTEXT, SPLINEFIT (Catmull-Rom to Bezier), DIMJOGGED, OBJECTSCALE
  (XData markers honoured by the existing geometry-gen / tessellator)

Blocks / misc:
- NCOPY (block-content extraction via explode_entity)
- OPTIONS, SYNCPVIEWPORTS, THICKEN, plus earlier toggles, aliases,
  system variables, and layer/style/view commands

POINTCLOUD/RECAP and UNDERLAYLAYERS/UOSNAP are intentionally left out:
they require a proprietary binary / PDF-layer parser, a GPU point
renderer, and real test files.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
Hakan SevenandClaude Opus 4.8 committed 2026-06-29 08:23:58 +03:00
1 parent 76ef544cda
commit c19c18c2f2
24 files changed
+4405 -216

No files matched your search

+39
View File
@@ -0,0 +1,39 @@
// COPYBASE — copy the current selection to the clipboard using a base point the
// user picks (rather than the selection's centroid, as COPYCLIP does). The
// command only collects the base point; the host performs the copy when it
// receives the dispatched `COPYBASE_AT <x> <y> <z>` token.
use glam::DVec3;
use crate::command::{CadCommand, CmdResult};
pub struct CopyBaseCommand;
impl CopyBaseCommand {
pub fn new() -> Self {
Self
}
}
impl CadCommand for CopyBaseCommand {
fn name(&self) -> &'static str {
"COPYBASE"
}
fn prompt(&self) -> String {
"COPYBASE Specify base point:".to_string()
}
fn on_point(&mut self, pt: DVec3) -> CmdResult {
CmdResult::Dispatch(format!("COPYBASE_AT {} {} {}", pt.x, pt.y, pt.z))
}
fn on_enter(&mut self) -> CmdResult {
CmdResult::Cancel
}
}
// ── Autocomplete registry ─────────────────────────────────
inventory::submit!(crate::command::CommandRegistration {
names: &["COPYBASE"]
}); // CopyBaseCommand
+1
View File
@@ -1,3 +1,4 @@
pub mod copy_base;
pub mod copy_clip;
pub mod cut;
pub mod paste;
+283
View File
@@ -0,0 +1,283 @@
// Centre line tool — interactive command.
//
// Command: CENTERLINE — pick two straight Line entities and draw a single
// centre line between them, committed as one Line entity.
//
// * If the two picked lines are (near) parallel, the result is the midline:
// a line running between the two, centred on the average of their
// midpoints, oriented along the averaged direction, with a length equal to
// the longer of the two source lines.
// * If the two picked lines cross, the result is the bisector of the acute
// angle at their intersection point, drawn symmetrically either side of
// that point.
//
// The command picks the first line on the first click, stores it, prompts for
// the second line, then computes the centre line and 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: "CENTERLINE",
label: "Center Line",
icon: IconKind::Svg(include_bytes!("../../../../assets/icons/line.svg")),
event: ModuleEvent::Command("CENTERLINE".to_string()),
}
}
// ── Geometry helpers ──────────────────────────────────────────────────────
/// A line reduced to the quantities the centre-line maths needs.
#[derive(Clone, Copy)]
struct LineGeom {
start: DVec3,
end: DVec3,
}
impl LineGeom {
fn from_line(line: &Line) -> Self {
Self {
start: DVec3::new(line.start.x, line.start.y, line.start.z),
end: DVec3::new(line.end.x, line.end.y, line.end.z),
}
}
fn midpoint(&self) -> DVec3 {
(self.start + self.end) * 0.5
}
fn length(&self) -> f64 {
(self.end - self.start).length()
}
/// Normalized direction in 3D, or `None` if the line is degenerate.
fn dir(&self) -> Option<DVec3> {
let d = self.end - self.start;
let len = d.length();
if len <= 1e-9 {
None
} else {
Some(d / len)
}
}
}
/// 2D cross product of the XY components of two vectors.
fn cross_xy(a: DVec3, b: DVec3) -> f64 {
a.x * b.y - a.y * b.x
}
fn to_v3(p: DVec3) -> Vector3 {
Vector3::new(p.x, p.y, p.z)
}
/// Compute the centre line between two source lines. `None` when the result
/// would be degenerate (zero-length sources, or coincident parallel lines that
/// give no usable direction).
fn compute_center_line(a: LineGeom, b: LineGeom) -> Option<Line> {
let da = a.dir()?;
let db = b.dir()?;
// Average Z of the four endpoints keeps the result on a sensible plane even
// when the picked lines sit at slightly different elevations.
let avg_z = (a.start.z + a.end.z + b.start.z + b.end.z) * 0.25;
let parallel = cross_xy(da, db).abs() <= 1e-9;
if parallel {
// Midline: average direction (flip db so the two add constructively),
// centred on the average of the two midpoints, length = longer source.
let db_aligned = if da.dot(db) < 0.0 { -db } else { db };
let avg = da + db_aligned;
let dir = if avg.length() <= 1e-9 {
da
} else {
avg / avg.length()
};
let dir = DVec3::new(dir.x, dir.y, 0.0);
let dir = if dir.length() <= 1e-9 {
return None;
} else {
dir / dir.length()
};
let mid = (a.midpoint() + b.midpoint()) * 0.5;
let half = a.length().max(b.length()) * 0.5;
if half <= 1e-9 {
return None;
}
let center = DVec3::new(mid.x, mid.y, avg_z);
let p0 = center - dir * half;
let p1 = center + dir * half;
Some(Line::from_points(to_v3(p0), to_v3(p1)))
} else {
// Intersecting: bisector of the acute angle at the intersection point.
// Solve the 2D line-line intersection in XY.
// a.start + t*da = b.start + s*db
let r = da; // direction of line a
let s = db; // direction of line b
let denom = cross_xy(r, s);
if denom.abs() <= 1e-12 {
return None;
}
let qp = b.start - a.start;
let t = cross_xy(qp, s) / denom;
let ix = a.start.x + r.x * t;
let iy = a.start.y + r.y * t;
let p = DVec3::new(ix, iy, avg_z);
// Orient both directions away from the intersection point, choosing the
// half of each line whose midpoint lies on the side we keep, so the two
// oriented directions bisect the acute angle between the lines.
let a_away = orient_away(p, a);
let b_away = orient_away(p, b);
// For an acute-angle bisector, flip b_away if the two oriented dirs open
// obtuse (their dot is negative).
let b_oriented = if a_away.dot(b_away) < 0.0 {
-b_away
} else {
b_away
};
let bis = a_away + b_oriented;
let bis = DVec3::new(bis.x, bis.y, 0.0);
if bis.length() <= 1e-9 {
return None;
}
let dir = bis / bis.length();
let half = ((a.length() + b.length()) * 0.5) * 0.5;
if half <= 1e-9 {
return None;
}
let p0 = p - dir * half;
let p1 = p + dir * half;
Some(Line::from_points(to_v3(p0), to_v3(p1)))
}
}
/// Unit direction from `p` toward the farther endpoint of `line` (the side that
/// extends away from the intersection point).
fn orient_away(p: DVec3, line: LineGeom) -> DVec3 {
let to_start = line.start - p;
let to_end = line.end - p;
let pick = if to_end.length() >= to_start.length() {
to_end
} else {
to_start
};
let len = pick.length();
if len <= 1e-9 {
// Fall back to the raw line direction if the pick degenerates.
line.dir().unwrap_or(DVec3::X)
} else {
pick / len
}
}
// ── Command implementation ────────────────────────────────────────────────
pub struct CenterLineCommand {
/// First picked Line's geometry, captured once the first pick lands.
first: Option<LineGeom>,
/// The entity injected by the host before `on_entity_pick` runs; `None`
/// until the host injects it.
picked: Option<EntityType>,
}
impl CenterLineCommand {
pub fn new() -> Self {
Self {
first: None,
picked: None,
}
}
/// Extract a `LineGeom` from a picked entity; `None` for anything else.
fn as_line(entity: &EntityType) -> Option<LineGeom> {
match entity {
EntityType::Line(l) => Some(LineGeom::from_line(l)),
_ => None,
}
}
}
impl CadCommand for CenterLineCommand {
fn name(&self) -> &'static str {
"CENTERLINE"
}
fn prompt(&self) -> String {
if self.first.is_none() {
"CENTERLINE Select first line:".to_string()
} else {
"CENTERLINE Select second line:".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;
}
// Resolve the just-picked entity; ignore the pick if it isn't a Line.
let picked = self.picked.take();
let geom = match picked.as_ref().and_then(Self::as_line) {
Some(g) => g,
None => return CmdResult::NeedPoint,
};
// Reject zero-length picks rather than corrupting the result.
if geom.length() <= 1e-9 {
return CmdResult::NeedPoint;
}
match self.first {
None => {
// First line captured; keep prompting for the second.
self.first = Some(geom);
CmdResult::NeedPoint
}
Some(first) => match compute_center_line(first, geom) {
Some(line) => CmdResult::CommitAndExit(EntityType::Line(line)),
// Degenerate combination (coincident / no usable result):
// keep prompting for a usable second line.
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: &["CENTERLINE"]
}); // CenterLineCommand
+3 -2
View File
@@ -1,12 +1,13 @@
pub mod arc;
pub mod attdef;
pub mod centerline;
pub mod circle;
pub mod dimcenter;
pub mod donut;
pub mod ellipse;
pub mod helix;
pub mod hatch;
pub mod hatchedit;
pub mod helix;
pub mod line;
pub mod mline;
pub mod point;
@@ -18,6 +19,6 @@ pub mod revcloud;
pub mod shapes;
pub mod sketch;
pub mod solid2d;
pub mod trace;
pub mod spline;
pub mod trace;
pub mod wipeout;
+3 -1
View File
@@ -108,4 +108,6 @@ impl CadCommand for Solid2dCommand {
}
// ── Autocomplete registry ─────────────────────────────────
inventory::submit!(crate::command::CommandRegistration { names: &["SO", "SOLID2D"] }); // Solid2dCommand
inventory::submit!(crate::command::CommandRegistration {
names: &["SO", "SOLID", "SOLID2D"]
}); // Solid2dCommand
+229
View File
@@ -0,0 +1,229 @@
//! MINSERT command — place a block reference as a rectangular array.
//!
//! Modeled on the plain INSERT command but simpler: there is no attribute
//! filling. The user picks a block name and an insertion point, then types the
//! array parameters (rows, columns, row spacing, column spacing). The committed
//! entity is a single [`Insert`] with its array fields set, which the renderer
//! replicates over `row_count × column_count` using the row/column spacing.
use acadrust::entities::Insert;
use acadrust::types::Vector3;
use acadrust::EntityType;
use glam::DVec3;
use crate::command::{CadCommand, CmdResult};
use crate::modules::{IconKind, ModuleEvent, ToolDef};
#[allow(dead_code)]
pub fn tool() -> ToolDef {
ToolDef {
id: "MINSERT",
label: "Array Insert",
icon: IconKind::Svg(include_bytes!("../../../assets/icons/blocks/insert.svg")),
event: ModuleEvent::Command("MINSERT".to_string()),
}
}
/// Which step the command is currently collecting.
enum Step {
/// Pick / type the block name from the available list.
Name,
/// Specify the insertion point for `name`.
Point { name: String },
/// Type the numeric array parameters, one per `ParamIdx`.
Params {
name: String,
point: Vector3,
idx: ParamIdx,
},
}
/// The numeric array parameter currently being typed.
#[derive(Clone, Copy)]
enum ParamIdx {
Rows,
Columns,
RowSpacing,
ColumnSpacing,
}
impl ParamIdx {
fn next(self) -> Option<ParamIdx> {
match self {
ParamIdx::Rows => Some(ParamIdx::Columns),
ParamIdx::Columns => Some(ParamIdx::RowSpacing),
ParamIdx::RowSpacing => Some(ParamIdx::ColumnSpacing),
ParamIdx::ColumnSpacing => None,
}
}
}
pub struct MinsertCommand {
available: Vec<String>,
step: Step,
/// Collected array parameters (defaults applied as the user Enters through).
rows: u16,
columns: u16,
row_spacing: f64,
column_spacing: f64,
}
impl MinsertCommand {
pub fn new(available: Vec<String>) -> Self {
Self {
available,
step: Step::Name,
rows: 1,
columns: 1,
row_spacing: 0.0,
column_spacing: 0.0,
}
}
/// Build the array Insert from the collected parameters and finish.
fn build(&self, name: &str, point: Vector3) -> CmdResult {
let mut ins = Insert::new(name.to_string(), point);
ins.row_count = self.rows.max(1);
ins.column_count = self.columns.max(1);
ins.row_spacing = self.row_spacing;
ins.column_spacing = self.column_spacing;
CmdResult::CommitAndExit(EntityType::Insert(ins))
}
/// Advance from the parameter currently at `idx` to the next, or build the
/// entity when the last parameter has been accepted.
fn advance_param(&mut self, name: String, point: Vector3, idx: ParamIdx) -> CmdResult {
match idx.next() {
Some(next) => {
self.step = Step::Params {
name,
point,
idx: next,
};
CmdResult::NeedPoint
}
None => self.build(&name, point),
}
}
}
impl CadCommand for MinsertCommand {
fn name(&self) -> &'static str {
"MINSERT"
}
fn prompt(&self) -> String {
match &self.step {
Step::Name => {
let hint = if self.available.is_empty() {
String::new()
} else {
format!(" [{}]", self.available.join(", "))
};
format!("MINSERT Enter block name:{hint}")
}
Step::Point { name } => {
format!("MINSERT Specify insertion point for \"{name}\":")
}
Step::Params { idx, .. } => match idx {
ParamIdx::Rows => format!("MINSERT Enter number of rows <{}>:", self.rows),
ParamIdx::Columns => {
format!("MINSERT Enter number of columns <{}>:", self.columns)
}
ParamIdx::RowSpacing => {
format!("MINSERT Enter row spacing <{}>:", self.row_spacing)
}
ParamIdx::ColumnSpacing => {
format!("MINSERT Enter column spacing <{}>:", self.column_spacing)
}
},
}
}
fn on_point(&mut self, pt: DVec3) -> CmdResult {
match &self.step {
Step::Name => CmdResult::NeedPoint,
Step::Point { name } => {
let name = name.clone();
let point = Vector3::new(pt.x, pt.y, pt.z);
self.step = Step::Params {
name,
point,
idx: ParamIdx::Rows,
};
CmdResult::NeedPoint
}
// Numeric-parameter steps ignore stray clicks and keep prompting.
Step::Params { .. } => CmdResult::NeedPoint,
}
}
fn on_enter(&mut self) -> CmdResult {
match &self.step {
// Bare Enter at a parameter step accepts the current default and
// advances (or commits after the last one).
Step::Params { name, point, idx } => {
let (name, point, idx) = (name.clone(), *point, *idx);
self.advance_param(name, point, idx)
}
// Enter before a block / point is supplied cancels the command.
Step::Name | Step::Point { .. } => CmdResult::Cancel,
}
}
fn wants_text_input(&self) -> bool {
matches!(self.step, Step::Name | Step::Params { .. })
}
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
match &self.step {
Step::Name => {
let typed = text.trim();
let matched = self
.available
.iter()
.find(|c| c.eq_ignore_ascii_case(typed))?;
self.step = Step::Point {
name: matched.clone(),
};
Some(CmdResult::NeedPoint)
}
Step::Point { .. } => None,
Step::Params { name, point, idx } => {
let (name, point, idx) = (name.clone(), *point, *idx);
let typed = text.trim();
// Empty input keeps the default; otherwise parse and store.
if !typed.is_empty() {
match idx {
ParamIdx::Rows => {
if let Ok(v) = typed.parse::<u16>() {
self.rows = v.max(1);
}
}
ParamIdx::Columns => {
if let Ok(v) = typed.parse::<u16>() {
self.columns = v.max(1);
}
}
ParamIdx::RowSpacing => {
if let Ok(v) = typed.parse::<f64>() {
self.row_spacing = v;
}
}
ParamIdx::ColumnSpacing => {
if let Ok(v) = typed.parse::<f64>() {
self.column_spacing = v;
}
}
}
}
Some(self.advance_param(name, point, idx))
}
}
}
}
// ── Autocomplete registry ─────────────────────────────────
inventory::submit!(crate::command::CommandRegistration {
names: &["MINSERT"]
});
+1
View File
@@ -11,6 +11,7 @@ mod design_center;
mod edit_block;
pub(crate) mod insert_block;
mod landxml;
pub mod minsert;
mod mview_block;
mod open_obj;
mod pc_attach;
+5 -1
View File
@@ -1,3 +1,7 @@
// Orthographic-projection toggle (Projection ribbon group).
// id "ORTHO" keys the active-state highlight (is_active_tool) off the ribbon's
// projection state; the event uses the PARALLEL verb so the typed ORTHO command
// stays free for the orthogonal cursor-constraint drafting aid.
use crate::modules::{IconKind, ModuleEvent, ToolDef};
pub const ICON: IconKind = IconKind::Svg(include_bytes!("../../../assets/icons/ortho.svg"));
pub fn tool() -> ToolDef {
@@ -5,6 +9,6 @@ pub fn tool() -> ToolDef {
id: "ORTHO",
label: "Ortho",
icon: ICON,
event: ModuleEvent::Command("ORTHO".into()),
event: ModuleEvent::Command("PARALLEL".into()),
}
}