Feat: ROADMAP Phase 1-5 batch — trim/extend, plot scale, viewport UX, mline caps, table alignment

- TRIM/EXTEND: Ray and XLine entities supported as cutting boundaries and trim targets;
  trim results in Ray→Line/Ray, XLine→Ray/Ray or Line; hover preview added
- Plot scale override: Fit/1:1/1:2/1:5/1:10/1:20/1:50/1:100/2:1 buttons added to Page Setup
  panel; PageSetupCommit applies scale to PlotSettings
- Viewport UX: locked viewport gets orange border; selected viewport gets white border
- Named view → viewport assignment: Choice dropdown in properties panel copies
  view_target/direction/height to the viewport entity
- MLine caps: perpendicular start/end cap lines added for open (non-closed) MLines
- Wipeout polygon grips: boundary vertices become editable grips when clipping_enabled+Polygonal;
  world→pixel back-projection on drag
- Table cell alignment: CellStyle.alignment (1-9) drives left/center/right + top/middle/bottom
  text positioning; cxf::measure_text() helper added
- New commands/features: DIMCONTINUE (DCO), DIMBASELINE (DBA), TOLERANCE (TOL), TABLE,
  ATTDEF, ATTEDIT, ATTDISP, BREAKATPOINT (BAP), PLOTWINDOW (PW), MLINE (ML);
  DIMSTYLE/DIMCURRENT/CLAYER/LTSCALE/CELTSCALE/SCALETEXT/XDATA/UCSICON/REGEN/DRAWORDER extended
- PLINE arc mode: A/L/CLOSE text input, bulge computation, arc preview
- Warning cleanup: removed unused imports and unreachable patterns

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
This commit is contained in:
Hakan SevenandClaude Sonnet 4.6 committed 2026-04-03 15:31:05 +03:00
1 parent 27b5e124c7
commit 0aef1fc12f
27 files changed
+2217 -100

No files matched your search

+137
View File
@@ -0,0 +1,137 @@
// 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::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 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.z as f64, pt.y 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![],
key_vertices: vec![],
})
}
}
+1
View File
@@ -1,4 +1,5 @@
pub mod arc;
pub mod attdef;
pub mod circle;
pub mod donut;
pub mod ellipse;
+220 -13
View File
@@ -1,16 +1,22 @@
// Polyline tool — ribbon definition + interactive command.
//
// Command: PLINE (PL)
// Each click adds a vertex. The rubber-band shows the segment being drawn.
// 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::wire_model::WireModel;
use glam::Vec3;
// ── Ribbon definition ──────────────────────────────────────────────────────
@@ -23,16 +29,32 @@ pub fn tool() -> ToolDef {
}
}
// ── 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,
}
}
@@ -43,7 +65,12 @@ impl PlineCommand {
let lw_verts: Vec<LwVertex> = self
.vertices
.iter()
.map(|v| LwVertex::new(acadrust::types::Vector2::new(v.x as f64, v.y as f64)))
.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,
@@ -54,24 +81,151 @@ impl PlineCommand {
}
}
// ── 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 Specify next point [{}pts | Enter=done C=close Esc=cancel]:",
"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
}
@@ -89,18 +243,71 @@ impl CadCommand for PlineCommand {
}
}
fn wants_text_input(&self) -> bool {
// Accept A / L / C once we have at least the first point.
!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;
}
// Show all committed vertices + cursor as a continuous preview.
let mut pts: Vec<[f32; 3]> = self.vertices.iter().map(|v| [v.x, v.y, v.z]).collect();
pts.push([pt.x, pt.y, pt.z]);
Some(WireModel::solid(
"rubber_band".into(),
pts,
WireModel::CYAN,
false,
))
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))
}
}