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 <noreply@anthropic.com>
This commit is contained in:
Hakan Seven 2026-04-03 15:31:05 +03:00
commit 0aef1fc12f
27 changed files with 2217 additions and 100 deletions

View file

@ -573,6 +573,48 @@ impl H7CAD {
}
}
}
CmdResult::SetPlotWindow { p1, p2 } => {
use acadrust::objects::{ObjectType, PlotSettings};
let layout_name = self.tabs[i].scene.current_layout.clone();
if layout_name == "Model" {
self.command_line.push_error("PLOTWINDOW: switch to a paper space layout first.");
} else {
let block_handle = self.tabs[i].scene.current_layout_block_handle_pub();
let doc = &mut self.tabs[i].scene.document;
let ps_handle = doc.objects.iter().find_map(|(h, obj)| {
if let ObjectType::PlotSettings(ps) = obj {
if ps.page_name == layout_name { Some(*h) } else { None }
} else { None }
});
let ps_entry = match ps_handle {
Some(h) => doc.objects.get_mut(&h),
None => {
let nh = acadrust::Handle::new(doc.next_handle());
let ps = PlotSettings::new(layout_name.clone());
doc.objects.insert(nh, ObjectType::PlotSettings(ps));
doc.objects.get_mut(&nh)
}
};
let _ = block_handle;
if let Some(ObjectType::PlotSettings(ps)) = ps_entry {
// Convert world-space points to DXF coordinates (X, Z plane → DXF X, Y).
let x1 = p1.x.min(p2.x) as f64;
let y1 = p1.z.min(p2.z) as f64;
let x2 = p1.x.max(p2.x) as f64;
let y2 = p1.z.max(p2.z) as f64;
ps.set_plot_window(x1, y1, x2, y2);
self.push_undo_snapshot(i, "PLOTWINDOW");
self.tabs[i].dirty = true;
self.command_line.push_output(&format!(
"PLOTWINDOW: ({x1:.3},{y1:.3}) → ({x2:.3},{y2:.3})"
));
}
}
self.tabs[i].active_cmd = None;
self.tabs[i].snap_result = None;
self.tabs[i].scene.clear_preview_wire();
self.restore_pre_cmd_tangent();
}
}
// Focus the command-line input while a command is active; blur it when the command ends.
if self.tabs[i].active_cmd.is_some() {

View file

@ -416,6 +416,118 @@ impl H7CAD {
self.tabs[i].active_cmd = Some(Box::new(cmd));
}
"ATTDEF" => {
use crate::modules::home::draw::attdef::AttdefCommand;
let cmd = AttdefCommand::new();
self.command_line.push_info(&cmd.prompt());
self.tabs[i].active_cmd = Some(Box::new(cmd));
}
// ATTEDIT — list or edit attribute values on selected Insert entities.
// Usage:
// ATTEDIT — list all attributes on selected Insert(s)
// ATTEDIT <tag> <v> — set the value of attribute <tag> to <v>
cmd if cmd == "ATTEDIT" || cmd.starts_with("ATTEDIT ") => {
let rest = cmd.trim_start_matches("ATTEDIT").trim();
let parts: Vec<&str> = rest.splitn(2, char::is_whitespace).collect();
let selected_handles: Vec<acadrust::Handle> = self.tabs[i].scene
.selected_entities()
.iter()
.map(|(h, _)| *h)
.collect();
if selected_handles.is_empty() {
self.command_line.push_error("ATTEDIT: select an Insert entity first.");
} else {
let mut found_any = false;
for sh in &selected_handles {
if let Some(acadrust::EntityType::Insert(ins)) =
self.tabs[i].scene.document.entities().find(|e| e.common().handle == *sh)
{
found_any = true;
if rest.is_empty() {
// List attributes.
if ins.attributes.is_empty() {
self.command_line.push_output(&format!(
" Insert {:x}: no attributes.", sh.value()
));
} else {
for attr in &ins.attributes {
self.command_line.push_output(&format!(
" [{tag}] = {val}",
tag = attr.tag,
val = attr.get_value()
));
}
}
}
}
}
if !found_any {
self.command_line.push_error("ATTEDIT: no Insert entities in selection.");
}
// If tag + value supplied, mutate attributes.
if parts.len() == 2 && !parts[0].is_empty() {
let tag_up = parts[0].to_uppercase();
let new_val = parts[1];
let mut changed = 0usize;
self.push_undo_snapshot(i, "ATTEDIT");
for sh in &selected_handles {
if let Some(acadrust::EntityType::Insert(ins)) =
self.tabs[i].scene.document.entities_mut().find(|e| e.common().handle == *sh)
{
for attr in &mut ins.attributes {
if attr.tag.to_uppercase() == tag_up {
attr.set_value(new_val);
changed += 1;
}
}
}
}
if changed > 0 {
self.tabs[i].dirty = true;
self.command_line.push_output(&format!(
"ATTEDIT: updated {changed} attribute(s) [{tag_up}] = {new_val}."
));
} else {
self.command_line.push_error(&format!(
"ATTEDIT: tag '{tag_up}' not found in selection."
));
}
}
}
}
// ATTDISP — control attribute display visibility.
// ATTDISP ON — make all AttributeDefinitions visible
// ATTDISP OFF — make all AttributeDefinitions invisible
// ATTDISP NORMAL — restore: show only those without the invisible flag
cmd if cmd == "ATTDISP" || cmd.starts_with("ATTDISP ") => {
let sub = cmd.split_whitespace().nth(1).unwrap_or("").to_uppercase();
match sub.as_str() {
"ON" | "OFF" | "NORMAL" => {
self.push_undo_snapshot(i, "ATTDISP");
let mut count = 0usize;
for entity in self.tabs[i].scene.document.entities_mut() {
if let acadrust::EntityType::AttributeDefinition(ad) = entity {
match sub.as_str() {
"ON" => { ad.flags.invisible = false; count += 1; }
"OFF" => { ad.flags.invisible = true; count += 1; }
"NORMAL" => { /* leave existing flags — they are already the "normal" state */ }
_ => {}
}
}
}
self.tabs[i].dirty = true;
self.command_line.push_output(&format!(
"ATTDISP {sub}: {count} attribute definition(s) updated."
));
}
_ => {
self.command_line.push_info("Usage: ATTDISP ON | OFF | NORMAL");
}
}
}
"DONUT"|"DO" => {
use crate::modules::home::draw::donut::DonutCommand;
let cmd = DonutCommand::new();
@ -810,6 +922,42 @@ impl H7CAD {
self.tabs[i].active_cmd = Some(Box::new(new_cmd));
}
"TOLERANCE"|"TOL" => {
use crate::modules::annotate::tolerance_cmd::ToleranceCommand;
let cmd = ToleranceCommand::new();
self.command_line.push_info(&cmd.prompt());
self.tabs[i].active_cmd = Some(Box::new(cmd));
}
"TABLE" => {
use crate::modules::annotate::table_cmd::TableCommand;
let cmd = TableCommand::new();
self.command_line.push_info(&cmd.prompt());
self.tabs[i].active_cmd = Some(Box::new(cmd));
}
"DIMCONTINUE"|"DCO" => {
use crate::modules::annotate::dim_continue::DimContinueCommand;
let cmd = if let Some((p1, p2, dp, rot)) = find_last_linear_dim(&self.tabs[i].scene) {
DimContinueCommand::from_base(p1, p2, dp, rot)
} else {
DimContinueCommand::new()
};
self.command_line.push_info(&cmd.prompt());
self.tabs[i].active_cmd = Some(Box::new(cmd));
}
"DIMBASELINE"|"DBA" => {
use crate::modules::annotate::dim_baseline::DimBaselineCommand;
let cmd = if let Some((p1, p2, dp, rot)) = find_last_linear_dim(&self.tabs[i].scene) {
DimBaselineCommand::from_base(p1, p2, dp, rot)
} else {
DimBaselineCommand::new()
};
self.command_line.push_info(&cmd.prompt());
self.tabs[i].active_cmd = Some(Box::new(cmd));
}
"ZOOM EXTENTS"|"ZOOMEXTENTS"|"ZE" => {
self.tabs[i].scene.fit_all();
self.command_line.push_output("Zoom Extents");
@ -844,6 +992,13 @@ impl H7CAD {
}
}
"PLOTWINDOW"|"PW" => {
use crate::modules::view::plot_window::PlotWindowCommand;
let cmd = PlotWindowCommand::new();
self.command_line.push_info(&cmd.prompt());
self.tabs[i].active_cmd = Some(Box::new(cmd));
}
"ZOOM WINDOW"|"ZOOM W"|"ZW" => {
use crate::modules::view::zoom_window::ZoomWindowCommand;
let new_cmd = ZoomWindowCommand::new();
@ -1097,6 +1252,13 @@ impl H7CAD {
self.tabs[i].active_cmd = Some(Box::new(cmd));
}
"BREAKATPOINT"|"BAP" => {
use crate::modules::home::modify::break_cmd::BreakAtPointCommand;
let cmd = BreakAtPointCommand::new();
self.command_line.push_info(&cmd.prompt());
self.tabs[i].active_cmd = Some(Box::new(cmd));
}
"PEDIT"|"PE" => {
use crate::modules::home::modify::pedit::PeditCommand;
let cmd_obj = PeditCommand::new();
@ -1190,7 +1352,6 @@ impl H7CAD {
// QSELECT COLOR <n> — select all entities with color index n
// QSELECT LINETYPE <name> — select all entities with linetype
cmd if cmd == "QSELECT" || cmd.starts_with("QSELECT ") => {
use crate::scene::Scene;
let rest = cmd.split_once(' ').map(|(_, r)| r.trim()).unwrap_or("");
let parts: Vec<&str> = rest.splitn(2, ' ').collect();
let prop = parts.first().map(|s| s.to_uppercase()).unwrap_or_default();
@ -1336,14 +1497,16 @@ impl H7CAD {
"HELP"|"?" => {
self.command_line.push_output(
"Draw: LINE CIRCLE ARC PLINE RECT POLY POINT ELLIPSE SPLINE RAY XLINE HATCH DONUT | \
Modify: MOVE COPY ROTATE SCALE MIRROR ERASE OFFSET EXTEND FILLET CHAMFER STRETCH EXPLODE TRIM BREAK JOIN LENGTHEN ALIGN | \
"Draw: LINE CIRCLE ARC PLINE RECT POLY POINT ELLIPSE SPLINE RAY XLINE HATCH DONUT REVCLOUD WIPEOUT MLINE ATTDEF | \
Modify: MOVE COPY ROTATE SCALE MIRROR ERASE OFFSET EXTEND FILLET CHAMFER STRETCH EXPLODE TRIM BREAK JOIN LENGTHEN ALIGN PEDIT | \
Array: ARRAY ARRAYRECT ARRAYPOLAR ARRAYPATH | \
Text: TEXT MTEXT LEADER MLEADER | \
Dimension: DIMLINEAR DIMALIGNED DIMANGULAR DIMRADIUS DIMDIAMETER | \
Dimension: DIMLINEAR DIMALIGNED DIMANGULAR DIMRADIUS DIMDIAMETER DIMCONTINUE DIMBASELINE | \
Annotation: TOLERANCE | \
Inquiry: DIST ID AREA LIST FIND FINDALL COUNT QSELECT | Draw on entity: DIVIDE MEASURE | \
Utilities: FLATTEN LAYISO LAYUNISO PEDIT MLINE MLEADER | \
View: ZOOM EXTENTS VIEW LIST/SAVE/RESTORE/DELETE | \
Attributes: ATTEDIT ATTDISP | \
Utilities: FLATTEN LAYISO LAYUNISO | \
View: ZOOM EXTENTS ZOOM WINDOW VIEW LIST/SAVE/RESTORE/DELETE | \
Layer: LAYER LIST/NEW/ON/OFF/FREEZE/THAW/LOCK/UNLOCK/COLOR/SET | \
Viewport: MVIEW VPLAYER VPORTS MS PS DRAWORDER | \
Tables: STYLE DIMSTYLE LINETYPE UCS RENAME PURGE | \
@ -1448,12 +1611,8 @@ impl H7CAD {
// ── Draw Order ────────────────────────────────────────────────
cmd if cmd.starts_with("DRAWORDER") => {
use acadrust::objects::{ObjectType, SortEntitiesTable};
let option = cmd.split_whitespace().nth(1).unwrap_or("").to_uppercase();
let bring_front = match option.as_str() {
"F" | "FRONT" => Some(true),
"B" | "BACK" => Some(false),
_ => None,
};
let parts: Vec<&str> = cmd.split_whitespace().collect();
let option = parts.get(1).unwrap_or(&"").to_uppercase();
let i = self.active_tab;
let selected: Vec<acadrust::Handle> = self.tabs[i].scene
.selected_entities()
@ -1462,42 +1621,87 @@ impl H7CAD {
.collect();
if selected.is_empty() {
self.command_line.push_error("DRAWORDER: select entities first.");
} else if let Some(to_front) = bring_front {
self.push_undo_snapshot(i, "DRAWORDER");
let block_handle = self.tabs[i].scene.current_layout_block_handle_pub();
let doc = &mut self.tabs[i].scene.document;
let table_handle = doc.objects.iter()
.find_map(|(h, obj)| {
if let ObjectType::SortEntitiesTable(t) = obj {
if t.block_owner_handle == block_handle { Some(*h) } else { None }
} else { None }
} else {
// Parse relative target handle for ABOVE/UNDER.
let relative_target: Option<(bool, acadrust::Handle)> = match option.as_str() {
"A" | "ABOVE" => {
let h_val = parts.get(2).and_then(|s| u64::from_str_radix(s, 16).ok());
h_val.map(|v| (true, acadrust::Handle::new(v)))
}
"U" | "UNDER" | "BELOW" => {
let h_val = parts.get(2).and_then(|s| u64::from_str_radix(s, 16).ok());
h_val.map(|v| (false, acadrust::Handle::new(v)))
}
_ => None,
};
let to_front_opt = match option.as_str() {
"F" | "FRONT" => Some(true),
"B" | "BACK" => Some(false),
_ => None,
};
if relative_target.is_some() || to_front_opt.is_some() {
self.push_undo_snapshot(i, "DRAWORDER");
let block_handle = self.tabs[i].scene.current_layout_block_handle_pub();
let doc = &mut self.tabs[i].scene.document;
let table_handle = doc.objects.iter()
.find_map(|(h, obj)| {
if let ObjectType::SortEntitiesTable(t) = obj {
if t.block_owner_handle == block_handle { Some(*h) } else { None }
} else { None }
});
let get_or_create = |doc: &mut acadrust::CadDocument, block_handle| -> acadrust::Handle {
if let Some(th) = doc.objects.iter()
.find_map(|(h, obj)| {
if let ObjectType::SortEntitiesTable(t) = obj {
if t.block_owner_handle == block_handle { Some(*h) } else { None }
} else { None }
})
{
th
} else {
let nh = acadrust::Handle::new(doc.next_handle());
let mut table = SortEntitiesTable::for_block(block_handle);
table.handle = nh;
doc.objects.insert(nh, ObjectType::SortEntitiesTable(table));
nh
}
};
let th = table_handle.unwrap_or_else(|| {
let nh = acadrust::Handle::new(doc.next_handle());
let mut table = SortEntitiesTable::for_block(block_handle);
table.handle = nh;
doc.objects.insert(nh, ObjectType::SortEntitiesTable(table));
nh
});
if let Some(th) = table_handle {
if let Some(ObjectType::SortEntitiesTable(table)) =
doc.objects.get_mut(&th)
{
for h in &selected {
if to_front { table.bring_to_front(*h); }
else { table.send_to_back(*h); }
let _ = get_or_create; // suppress unused warning
if let Some(ObjectType::SortEntitiesTable(table)) = doc.objects.get_mut(&th) {
if let Some((above, target)) = relative_target {
for h in &selected {
if above { table.move_above(*h, target); }
else { table.move_below(*h, target); }
}
let rel = if above { "above" } else { "below" };
self.command_line.push_info(&format!(
"DRAWORDER: moved {} entities {} {:x}.", selected.len(), rel, target.value()
));
} else if let Some(to_front) = to_front_opt {
for h in &selected {
if to_front { table.bring_to_front(*h); }
else { table.send_to_back(*h); }
}
let dir = if to_front { "front" } else { "back" };
self.command_line.push_info(&format!(
"DRAWORDER: moved {} entities to {}.", selected.len(), dir
));
}
}
self.tabs[i].dirty = true;
} else {
let new_handle = acadrust::Handle::new(doc.next_handle());
let mut table = SortEntitiesTable::for_block(block_handle);
table.handle = new_handle;
for h in &selected {
if to_front { table.bring_to_front(*h); }
else { table.send_to_back(*h); }
}
doc.objects.insert(new_handle, ObjectType::SortEntitiesTable(table));
self.command_line.push_info(
"Usage: DRAWORDER F|FRONT | B|BACK | A|ABOVE <handle> | U|UNDER <handle>"
);
}
self.tabs[i].dirty = true;
let dir = if to_front { "front" } else { "back" };
self.command_line.push_info(&format!(
"DRAWORDER: moved {} entities to {}.", selected.len(), dir
));
} else {
self.command_line.push_info("Usage: DRAWORDER F (front) or DRAWORDER B (back)");
}
}
@ -1798,15 +2002,23 @@ impl H7CAD {
if let Ok(val) = val_str.parse::<f64>() {
if let Some(ds) = self.tabs[i].scene.document.dim_styles.get_mut(&style_name) {
match prop.as_str() {
"dimtxt" => { ds.dimtxt = val; }
"dimasz" => { ds.dimasz = val; }
"dimdli" => { ds.dimdli = val; }
"dimexo" => { ds.dimexo = val; }
"dimexe" => { ds.dimexe = val; }
"dimgap" => { ds.dimgap = val; }
"dimscale"| "dimlfac" => { ds.dimgap = val; } // best effort
"dimtxt" => { ds.dimtxt = val; }
"dimasz" => { ds.dimasz = val; }
"dimdli" => { ds.dimdli = val; }
"dimexo" => { ds.dimexo = val; }
"dimexe" => { ds.dimexe = val; }
"dimgap" => { ds.dimgap = val; }
"dimscale" => { ds.dimscale = val; }
"dimlfac" => { ds.dimlfac = val; }
"dimdle" => { ds.dimdle = val; }
"dimtvp" => { ds.dimtvp = val; }
"dimcen" => { ds.dimcen = val; }
"dimtsz" => { ds.dimtsz = val; }
"dimfxl" => { ds.dimfxl = val; }
_ => {
self.command_line.push_error(&format!("DIMSTYLE: unknown property '{}'. Try: dimtxt dimasz dimdli dimexo dimexe dimgap", prop));
self.command_line.push_error(&format!(
"DIMSTYLE: unknown property '{}'. Try: dimtxt dimasz dimdli dimexo dimexe dimgap dimscale dimlfac dimdle dimcen dimtsz", prop
));
return Task::none();
}
}
@ -1905,9 +2117,26 @@ impl H7CAD {
self.command_line.push_error(&format!("Usage: {prefix} WIDTH <style> <factor>"));
}
}
"OBLIQUE" => {
// STYLE OBLIQUE <name> <angle_degrees>
let style_name = parts.get(1).map(|s| s.trim()).unwrap_or("").to_string();
let angle_str = parts.get(2).map(|s| s.trim()).unwrap_or("");
if let Ok(deg) = angle_str.parse::<f64>() {
if let Some(s) = self.tabs[i].scene.document.text_styles.get_mut(&style_name) {
s.oblique_angle = deg.to_radians();
self.push_undo_snapshot(i, "STYLE OBLIQUE");
self.tabs[i].dirty = true;
self.command_line.push_output(&format!("{prefix}: '{style_name}' oblique angle set to {deg:.1}°."));
} else {
self.command_line.push_error(&format!("{prefix}: style '{style_name}' not found."));
}
} else {
self.command_line.push_error(&format!("Usage: {prefix} OBLIQUE <style> <angle_degrees>"));
}
}
_ => {
self.command_line.push_info(&format!(
"Usage: {prefix} LIST | NEW <name> | FONT <style> <file> | WIDTH <style> <factor>"
"Usage: {prefix} LIST | NEW <name> | FONT <style> <file> | WIDTH <style> <factor> | OBLIQUE <style> <angle>"
));
}
}
@ -2021,9 +2250,13 @@ impl H7CAD {
let ltscale_val: Option<f64> = if prop == "LTSCALE" {
value.parse().ok()
} else { None };
let transparency_val: Option<acadrust::types::Transparency> = if prop == "TRANSPARENCY" {
value.parse::<f64>().ok().map(acadrust::types::Transparency::from_percent)
} else { None };
if (prop == "COLOR" && color_val.is_none())
|| (prop == "LTSCALE" && ltscale_val.is_none())
|| (prop == "TRANSPARENCY" && transparency_val.is_none())
{
self.command_line.push_error(&format!("CHPROP: invalid value '{}' for {}.", value, prop));
} else {
@ -2036,9 +2269,10 @@ impl H7CAD {
"LINETYPE" | "LT" => { common.linetype = value.clone(); changed += 1; }
"LTSCALE" => { common.linetype_scale = ltscale_val.unwrap(); changed += 1; }
"COLOR" => { common.color = color_val.unwrap(); changed += 1; }
"TRANSPARENCY" => { common.transparency = transparency_val.unwrap(); changed += 1; }
_ => {
self.command_line.push_error(&format!(
"CHPROP: unknown property '{}'. Use: LAYER COLOR LINETYPE LTSCALE", prop
"CHPROP: unknown property '{}'. Use: LAYER COLOR LINETYPE LTSCALE TRANSPARENCY", prop
));
break;
}
@ -2123,6 +2357,305 @@ impl H7CAD {
}
}
// ── System variable getters/setters ──────────────────────────────────
// CLAYER [name] — get or set current layer
// TEXTSTYLE [name] — already handled above under STYLE SET
// DIMSTYLE [name] — get or set active dim style
// LTSCALE [val] — global linetype scale
cmd if cmd == "CLAYER" || cmd.starts_with("CLAYER ") => {
let name_arg = cmd.trim_start_matches("CLAYER").trim();
if name_arg.is_empty() {
let cur = &self.tabs[i].scene.document.header.current_layer_name;
self.command_line.push_output(&format!("CLAYER = \"{cur}\""));
} else {
if self.tabs[i].scene.document.layers.contains(name_arg) {
self.tabs[i].scene.document.header.current_layer_name = name_arg.to_string();
self.tabs[i].dirty = true;
self.command_line.push_output(&format!("CLAYER set to \"{name_arg}\""));
} else {
self.command_line.push_error(&format!("CLAYER: layer '{}' not found.", name_arg));
}
}
}
cmd if cmd == "CDIMSTY" || cmd == "DIMCURRENT" || cmd.starts_with("CDIMSTY ") || cmd.starts_with("DIMCURRENT ") => {
let name_arg = cmd.split_whitespace().skip(1).collect::<Vec<_>>().join(" ");
if name_arg.is_empty() {
let cur = &self.tabs[i].scene.document.header.current_dimstyle_name;
self.command_line.push_output(&format!("CDIMSTY = \"{cur}\""));
} else {
if self.tabs[i].scene.document.dim_styles.contains(&name_arg) {
self.tabs[i].scene.document.header.current_dimstyle_name = name_arg.clone();
self.tabs[i].dirty = true;
self.command_line.push_output(&format!("Active dim style set to \"{name_arg}\""));
} else {
self.command_line.push_error(&format!("CDIMSTY: dim style '{}' not found.", name_arg));
}
}
}
cmd if cmd == "LTSCALE" || cmd.starts_with("LTSCALE ") => {
let val_str = cmd.trim_start_matches("LTSCALE").trim();
if val_str.is_empty() {
let v = self.tabs[i].scene.document.header.linetype_scale;
self.command_line.push_output(&format!("LTSCALE = {v:.4}"));
} else if let Ok(v) = val_str.parse::<f64>() {
if v > 0.0 {
self.push_undo_snapshot(i, "LTSCALE");
self.tabs[i].scene.document.header.linetype_scale = v;
self.tabs[i].dirty = true;
self.command_line.push_output(&format!("LTSCALE set to {v:.4}"));
} else {
self.command_line.push_error("LTSCALE: value must be positive.");
}
} else {
self.command_line.push_error("Usage: LTSCALE [value]");
}
}
cmd if cmd == "CELTSCALE" || cmd.starts_with("CELTSCALE ") => {
let val_str = cmd.trim_start_matches("CELTSCALE").trim();
if val_str.is_empty() {
let v = self.tabs[i].scene.document.header.current_entity_linetype_scale;
self.command_line.push_output(&format!("CELTSCALE = {v:.4}"));
} else if let Ok(v) = val_str.parse::<f64>() {
if v > 0.0 {
self.tabs[i].scene.document.header.current_entity_linetype_scale = v;
self.tabs[i].dirty = true;
self.command_line.push_output(&format!("CELTSCALE set to {v:.4}"));
} else {
self.command_line.push_error("CELTSCALE: value must be positive.");
}
} else {
self.command_line.push_error("Usage: CELTSCALE [value]");
}
}
// ── SCALETEXT — rescale selected Text/MText entities ─────────────────
// Usage: SCALETEXT <factor> e.g. SCALETEXT 2
// SCALETEXT H <height> set absolute height
cmd if cmd == "SCALETEXT" || cmd.starts_with("SCALETEXT ") => {
let rest = cmd.trim_start_matches("SCALETEXT").trim();
let parts: Vec<&str> = rest.split_whitespace().collect();
let selected_handles: Vec<acadrust::Handle> = self.tabs[i].scene
.selected_entities()
.iter()
.map(|(h, _)| *h)
.collect();
if selected_handles.is_empty() {
self.command_line.push_error("SCALETEXT: select Text/MText entities first.");
} else {
let (use_absolute, value) = match (parts.first().map(|s| s.to_uppercase()).as_deref(), parts.get(1)) {
(Some("H"), Some(v)) => (true, v.parse::<f64>().ok()),
(Some(v), None) => (false, v.parse::<f64>().ok()),
_ => (false, None),
};
if let Some(val) = value {
if val <= 0.0 {
self.command_line.push_error("SCALETEXT: value must be positive.");
} else {
self.push_undo_snapshot(i, "SCALETEXT");
let mut count = 0usize;
for sh in &selected_handles {
for entity in self.tabs[i].scene.document.entities_mut() {
if entity.common().handle != *sh { continue; }
match entity {
acadrust::EntityType::Text(t) => {
t.height = if use_absolute { val } else { t.height * val };
count += 1;
}
acadrust::EntityType::MText(t) => {
t.height = if use_absolute { val } else { t.height * val };
count += 1;
}
_ => {}
}
break;
}
}
if count > 0 {
self.tabs[i].dirty = true;
self.command_line.push_output(&format!(
"SCALETEXT: scaled {count} text entity(ies)."
));
} else {
self.command_line.push_error("SCALETEXT: no Text/MText in selection.");
}
}
} else {
self.command_line.push_info("Usage: SCALETEXT <factor> or SCALETEXT H <height>");
}
}
}
// ── Display refresh (no-op in GPU raster pipeline) ────────────────
"REGEN"|"REGENALL"|"REDRAW"|"REDRWALL" => {
// Display is always up-to-date in the GPU raster pipeline.
self.command_line.push_output("Display regenerated.");
}
// ── TABLE cell editing ─────────────────────────────────────────────
// TABLE CELL <row> <col> <text> — set text for a cell in the selected Table
cmd if cmd.starts_with("TABLE ") => {
let rest = cmd.trim_start_matches("TABLE").trim();
let sub_up = rest.split_whitespace().next().unwrap_or("").to_uppercase();
if sub_up == "CELL" {
let parts: Vec<&str> = rest.splitn(4, char::is_whitespace).collect();
// parts: ["CELL", "<row>", "<col>", "<text>"]
let row_res = parts.get(1).and_then(|s| s.parse::<usize>().ok());
let col_res = parts.get(2).and_then(|s| s.parse::<usize>().ok());
let text = parts.get(3).copied().unwrap_or("");
match (row_res, col_res) {
(Some(row), Some(col)) => {
let selected_handles: Vec<acadrust::Handle> = self.tabs[i].scene
.selected_entities()
.iter()
.map(|(h, _)| *h)
.collect();
let mut found = false;
for sh in &selected_handles {
if let Some(acadrust::EntityType::Table(tbl)) =
self.tabs[i].scene.document.entities_mut().find(|e| e.common().handle == *sh)
{
if tbl.set_cell_text(row, col, text) {
found = true;
}
}
}
if found {
self.push_undo_snapshot(i, "TABLE CELL");
self.tabs[i].dirty = true;
self.command_line.push_output(&format!(
"TABLE CELL: set [{row},{col}] = \"{text}\"."
));
} else {
self.command_line.push_error(
"TABLE CELL: select a Table entity first, or row/col out of range."
);
}
}
_ => {
self.command_line.push_info("Usage: TABLE CELL <row> <col> <text>");
}
}
} else {
self.command_line.push_info("Usage: TABLE (creates new table) or TABLE CELL <row> <col> <text>");
}
}
// ── UCSICON — toggle UCS icon visibility on all viewports ────────────
// UCSICON ON — show UCS icon in all viewports
// UCSICON OFF — hide UCS icon in all viewports
// UCSICON NOORIGIN — show icon but not at origin (show at corner)
// UCSICON ORIGIN — show icon at UCS origin
cmd if cmd == "UCSICON" || cmd.starts_with("UCSICON ") => {
let sub = cmd.split_whitespace().nth(1).unwrap_or("").to_uppercase();
match sub.as_str() {
"ON" | "OFF" | "NOORIGIN" | "ORIGIN" => {
self.push_undo_snapshot(i, "UCSICON");
let visible = sub != "OFF";
let at_origin = sub == "ORIGIN";
let mut count = 0usize;
for entity in self.tabs[i].scene.document.entities_mut() {
if let acadrust::EntityType::Viewport(vp) = entity {
vp.status.ucs_icon_visible = visible;
if sub == "NOORIGIN" || sub == "ORIGIN" {
vp.status.ucs_icon_at_origin = at_origin;
}
count += 1;
}
}
self.tabs[i].dirty = true;
self.command_line.push_output(&format!(
"UCSICON {sub}: updated {count} viewport(s)."
));
}
_ => {
self.command_line.push_info("Usage: UCSICON ON | OFF | NOORIGIN | ORIGIN");
}
}
}
// ── XDATA — read/write extended entity data ──────────────────────────
// XDATA LIST — show all xdata records on selected entities
// XDATA SET <app> <str> — append a string xdata value for <app>
// XDATA CLEAR — remove all xdata from selected entities
// XDATA CLEAR <app> — remove xdata for a specific application
cmd if cmd == "XDATA" || cmd.starts_with("XDATA ") => {
use acadrust::xdata::{ExtendedDataRecord, XDataValue};
let rest = cmd.trim_start_matches("XDATA").trim();
let parts: Vec<&str> = rest.splitn(3, char::is_whitespace).collect();
let sub = parts.first().map(|s| s.to_uppercase()).unwrap_or_default();
let selected_handles: Vec<acadrust::Handle> = self.tabs[i].scene
.selected_entities()
.iter()
.map(|(h, _)| *h)
.collect();
if selected_handles.is_empty() {
self.command_line.push_error("XDATA: select entities first.");
} else {
match sub.as_str() {
"LIST" | "" => {
for sh in &selected_handles {
if let Some(entity) = self.tabs[i].scene.document.get_entity(*sh) {
let xd = &entity.common().extended_data;
if xd.is_empty() {
self.command_line.push_output(&format!(" {:x}: no xdata.", sh.value()));
} else {
for rec in xd.records() {
self.command_line.push_output(&format!(
" {:x} [{}]: {} value(s)", sh.value(), rec.application_name, rec.values.len()
));
for v in &rec.values {
self.command_line.push_output(&format!(" {:?}", v));
}
}
}
}
}
}
"SET" => {
let app = parts.get(1).copied().unwrap_or("H7CAD");
let val = parts.get(2).copied().unwrap_or("");
self.push_undo_snapshot(i, "XDATA SET");
for sh in &selected_handles {
if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(*sh) {
let mut rec = ExtendedDataRecord::new(app);
rec.add_value(XDataValue::String(val.to_string()));
entity.common_mut().extended_data.add_record(rec);
}
}
self.tabs[i].dirty = true;
self.command_line.push_output(&format!(
"XDATA: set [{app}] = \"{val}\" on {} entity/entities.", selected_handles.len()
));
}
"CLEAR" => {
let app_filter = parts.get(1).copied();
self.push_undo_snapshot(i, "XDATA CLEAR");
for sh in &selected_handles {
if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(*sh) {
let xd = &mut entity.common_mut().extended_data;
if let Some(app) = app_filter {
// Rebuild without the matching app.
let kept: Vec<_> = xd.records().iter()
.filter(|r| r.application_name != app)
.cloned()
.collect();
xd.clear();
for r in kept { xd.add_record(r); }
} else {
xd.clear();
}
}
}
self.tabs[i].dirty = true;
self.command_line.push_output("XDATA: cleared.");
}
_ => {
self.command_line.push_info("Usage: XDATA LIST | SET <app> <value> | CLEAR [app]");
}
}
}
}
// ── Plot / Page Setup ──────────────────────────────────────────
"PRINT"|"PLOT"|"EXPORT" => {
return Task::done(Message::PlotExport);
@ -2219,6 +2752,46 @@ fn flatten_entity_z(entity: &mut acadrust::EntityType) {
}
}
/// Find the last placed linear or aligned dimension in the document.
/// Returns `(first_point, second_point, definition_point, rotation_rad)` in world-space.
fn find_last_linear_dim(scene: &crate::scene::Scene) -> Option<(glam::Vec3, glam::Vec3, glam::Vec3, f64)> {
use acadrust::entities::Dimension;
let mut best_handle: u64 = 0;
let mut result: Option<(glam::Vec3, glam::Vec3, glam::Vec3, f64)> = None;
for entity in scene.document.entities() {
if let acadrust::EntityType::Dimension(dim) = entity {
let h = entity.common().handle.value();
if h <= best_handle {
continue;
}
let item = match dim {
Dimension::Linear(d) => {
let p1 = glam::Vec3::new(d.first_point.x as f32, d.first_point.y as f32, d.first_point.z as f32);
let p2 = glam::Vec3::new(d.second_point.x as f32, d.second_point.y as f32, d.second_point.z as f32);
let dp = glam::Vec3::new(d.base.definition_point.x as f32, d.base.definition_point.y as f32, d.base.definition_point.z as f32);
Some((p1, p2, dp, d.rotation))
}
Dimension::Aligned(d) => {
let p1 = glam::Vec3::new(d.first_point.x as f32, d.first_point.y as f32, d.first_point.z as f32);
let p2 = glam::Vec3::new(d.second_point.x as f32, d.second_point.y as f32, d.second_point.z as f32);
let dp = glam::Vec3::new(d.base.definition_point.x as f32, d.base.definition_point.y as f32, d.base.definition_point.z as f32);
let dx = (d.second_point.x - d.first_point.x) as f32;
let dy = (d.second_point.y - d.first_point.y) as f32;
let rot = dy.atan2(dx) as f64;
Some((p1, p2, dp, rot))
}
_ => None,
};
if let Some(data) = item {
best_handle = h;
result = Some(data);
}
}
}
result
}
fn entity_type_name(entity: &acadrust::EntityType) -> &'static str {
match entity {
acadrust::EntityType::Line(_) => "LINE",

View file

@ -81,6 +81,8 @@ pub(super) struct H7CAD {
page_setup_offset_y: String,
/// Plot rotation in degrees: "0" | "90" | "180" | "270".
page_setup_rotation: String,
/// Plot scale: "Fit" | "1:1" | "1:2" | "1:4" | "1:5" | "1:10" | "1:20" | "1:50" | "1:100" | "2:1".
page_setup_scale: String,
}
#[derive(Debug, Clone)]
@ -281,6 +283,7 @@ pub enum Message {
PageSetupOffsetYEdit(String),
/// User changed plot rotation.
PageSetupRotation(String),
PageSetupScale(String),
/// Apply the changes entered in Page Setup.
PageSetupCommit,
// ── Plot / Export ─────────────────────────────────────────────────────
@ -327,6 +330,7 @@ impl H7CAD {
page_setup_offset_x: "0.0".to_string(),
page_setup_offset_y: "0.0".to_string(),
page_setup_rotation: "0".to_string(),
page_setup_scale: "Fit".to_string(),
};
app.sync_ribbon_layers();
app

View file

@ -82,6 +82,13 @@ impl H7CAD {
.map(|u| u.name.clone())
.unwrap_or_default();
// Collect available named view names.
let view_names: Vec<String> = self.tabs[i].scene.document.views
.iter()
.map(|v| v.name.clone())
.filter(|n| !n.is_empty())
.collect();
if let Some(geom) = sections.last_mut() {
geom.props.push(crate::scene::object::Property {
label: "Frozen Layers".to_string(),
@ -100,6 +107,16 @@ impl H7CAD {
},
});
}
if !view_names.is_empty() {
geom.props.push(crate::scene::object::Property {
label: "Named View".to_string(),
field: "vp_named_view",
value: crate::scene::object::PropValue::Choice {
selected: String::new(),
options: view_names,
},
});
}
}
}

View file

@ -1502,6 +1502,26 @@ impl H7CAD {
}
}
}
} else if field == "vp_named_view" {
// Assign a named view to viewport(s): copy camera parameters.
let view_data = self.tabs[i].scene.document.views
.iter()
.find(|v| v.name == value)
.cloned();
if let Some(view) = view_data {
for handle in &handles {
if let Some(acadrust::EntityType::Viewport(vp)) =
self.tabs[i].scene.document.get_entity_mut(*handle)
{
vp.view_target = view.target.clone();
vp.view_direction = view.direction.clone();
if view.height > 0.0 {
vp.view_height = view.height;
}
}
}
self.tabs[i].scene.camera_generation += 1;
}
} else {
for handle in handles {
if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(handle) {
@ -1841,6 +1861,10 @@ impl H7CAD {
self.page_setup_rotation = s;
Task::none()
}
Message::PageSetupScale(s) => {
self.page_setup_scale = s;
Task::none()
}
Message::PageSetupCommit => {
let i = self.active_tab;
let layout_name = self.tabs[i].scene.current_layout.clone();
@ -1852,6 +1876,7 @@ impl H7CAD {
let offset_x = self.page_setup_offset_x.parse::<f64>().unwrap_or(0.0);
let offset_y = self.page_setup_offset_y.parse::<f64>().unwrap_or(0.0);
let rotation: i16 = self.page_setup_rotation.parse().unwrap_or(0);
let scale_str = self.page_setup_scale.clone();
// Update the Layout object's limits.
for obj in self.tabs[i].scene.document.objects.values_mut() {
@ -1904,6 +1929,16 @@ impl H7CAD {
270 => PlotRotation::Degrees270,
_ => PlotRotation::None,
};
// Apply plot scale.
use acadrust::objects::ScaledType;
let (num, den) = parse_plot_scale(&scale_str);
if scale_str == "Fit" {
ps.set_scale_to_fit();
} else {
ps.scale_type = ScaledType::CustomScale;
ps.scale_numerator = num;
ps.scale_denominator = den;
}
}
self.tabs[i].dirty = true;
@ -2035,3 +2070,15 @@ impl H7CAD {
}
}
}
/// Parse a scale string like "1:50" or "2:1" into (numerator, denominator).
/// Returns (1.0, 1.0) for "Fit" or unknown formats.
fn parse_plot_scale(s: &str) -> (f64, f64) {
if s == "Fit" { return (1.0, 1.0); }
if let Some((a, b)) = s.split_once(':') {
let num: f64 = a.trim().parse().unwrap_or(1.0);
let den: f64 = b.trim().parse().unwrap_or(1.0);
if den > 0.0 { return (num, den); }
}
(1.0, 1.0)
}

View file

@ -209,6 +209,7 @@ impl H7CAD {
&self.page_setup_offset_x,
&self.page_setup_offset_y,
&self.page_setup_rotation,
&self.page_setup_scale,
)
} else {
iced::widget::Space::new().width(0).height(0).into()
@ -489,6 +490,7 @@ fn page_setup_overlay<'a>(
offset_x: &'a str,
offset_y: &'a str,
rotation: &'a str,
scale: &'a str,
) -> Element<'a, Message> {
const PANEL_BG: Color = Color { r: 0.15, g: 0.15, b: 0.15, a: 1.0 };
const BORDER_COL: Color = Color { r: 0.35, g: 0.35, b: 0.35, a: 1.0 };
@ -667,6 +669,40 @@ fn page_setup_overlay<'a>(
text("Rotation").size(11).color(DIM_COLOR),
rot_row,
divider(),
// Plot Scale
text("Plot Scale").size(11).color(DIM_COLOR),
row![
button(text("Fit").size(10).color(TEXT_COLOR))
.on_press(Message::PageSetupScale("Fit".into()))
.style(pill_style(scale == "Fit")).padding([3, 8]),
button(text("1:1").size(10).color(TEXT_COLOR))
.on_press(Message::PageSetupScale("1:1".into()))
.style(pill_style(scale == "1:1")).padding([3, 8]),
button(text("1:2").size(10).color(TEXT_COLOR))
.on_press(Message::PageSetupScale("1:2".into()))
.style(pill_style(scale == "1:2")).padding([3, 8]),
button(text("1:5").size(10).color(TEXT_COLOR))
.on_press(Message::PageSetupScale("1:5".into()))
.style(pill_style(scale == "1:5")).padding([3, 8]),
button(text("1:10").size(10).color(TEXT_COLOR))
.on_press(Message::PageSetupScale("1:10".into()))
.style(pill_style(scale == "1:10")).padding([3, 8]),
].spacing(4),
row![
button(text("1:20").size(10).color(TEXT_COLOR))
.on_press(Message::PageSetupScale("1:20".into()))
.style(pill_style(scale == "1:20")).padding([3, 8]),
button(text("1:50").size(10).color(TEXT_COLOR))
.on_press(Message::PageSetupScale("1:50".into()))
.style(pill_style(scale == "1:50")).padding([3, 8]),
button(text("1:100").size(10).color(TEXT_COLOR))
.on_press(Message::PageSetupScale("1:100".into()))
.style(pill_style(scale == "1:100")).padding([3, 8]),
button(text("2:1").size(10).color(TEXT_COLOR))
.on_press(Message::PageSetupScale("2:1".into()))
.style(pill_style(scale == "2:1")).padding([3, 8]),
].spacing(4),
divider(),
// Buttons
row![
button(text("Cancel").size(12).color(TEXT_COLOR))

View file

@ -122,6 +122,8 @@ pub enum CmdResult {
angle_rad: f32,
scale: f32,
},
/// Set the plot window on the active layout's PlotSettings.
SetPlotWindow { p1: Vec3, p2: Vec3 },
}
// ── Trait ─────────────────────────────────────────────────────────────────

View file

@ -38,8 +38,9 @@ impl TruckConvertible for MLine {
// Parallel offset lines — one at +scale/2 and one at -scale/2
// along each vertex's miter direction.
if scale.abs() > 1e-6 {
for sign in [-0.5_f32, 0.5_f32] {
let offset = scale * sign;
let half = scale * 0.5;
for sign in [-1.0_f32, 1.0_f32] {
let offset = half * sign;
pts.push([f32::NAN; 3]);
for v in &self.vertices {
let mx = v.miter.x as f32;
@ -63,6 +64,26 @@ impl TruckConvertible for MLine {
]);
}
}
// Start and end caps: perpendicular line connecting the two offset lines
// at the first and last vertex of an open MLine.
if !closed {
let cap_v = |v: &acadrust::entities::MLineVertex| {
let mx = v.miter.x as f32;
let my = v.miter.y as f32;
let mz = v.miter.z as f32;
let px = v.position.x as f32;
let py = v.position.y as f32;
let pz = v.position.z as f32;
[
[f32::NAN; 3],
[px + mx * (-half), py + my * (-half), pz + mz * (-half)],
[px + mx * half, py + my * half, pz + mz * half ],
]
};
pts.extend_from_slice(&cap_v(&self.vertices[0]));
pts.extend_from_slice(&cap_v(&self.vertices[n - 1]));
}
}
let key_verts: Vec<[f32; 3]> = self

View file

@ -251,23 +251,91 @@ impl TruckConvertible for Wipeout {
impl Grippable for Wipeout {
fn grips(&self) -> Vec<GripDef> {
let corners = image_corners(
&self.insertion_point,
&self.u_vector,
&self.v_vector,
self.size.x,
self.size.y,
);
vec![
square_grip(0, Vec3::from(corners[0])),
diamond_grip(1, Vec3::from(corners[1])),
diamond_grip(2, Vec3::from(corners[2])),
diamond_grip(3, Vec3::from(corners[3])),
]
// If polygonal clipping is active, expose individual polygon vertices as grips.
let is_polygon = self.clipping_enabled
&& self.clip_boundary_vertices.len() >= 3
&& matches!(
self.clip_type,
acadrust::entities::WipeoutClipType::Polygonal
);
if is_polygon {
let ox = self.insertion_point.x as f32;
let oy = self.insertion_point.y as f32;
let oz = self.insertion_point.z as f32;
self.clip_boundary_vertices
.iter()
.enumerate()
.map(|(i, v)| {
let wx = (self.u_vector.x * v.x * self.size.x
+ self.v_vector.x * v.y * self.size.y) as f32;
let wy = (self.u_vector.y * v.x * self.size.x
+ self.v_vector.y * v.y * self.size.y) as f32;
let wz = (self.u_vector.z * v.x * self.size.x
+ self.v_vector.z * v.y * self.size.y) as f32;
if i == 0 {
square_grip(i, Vec3::new(ox + wx, oy + wy, oz + wz))
} else {
diamond_grip(i, Vec3::new(ox + wx, oy + wy, oz + wz))
}
})
.collect()
} else {
let corners = image_corners(
&self.insertion_point,
&self.u_vector,
&self.v_vector,
self.size.x,
self.size.y,
);
vec![
square_grip(0, Vec3::from(corners[0])),
diamond_grip(1, Vec3::from(corners[1])),
diamond_grip(2, Vec3::from(corners[2])),
diamond_grip(3, Vec3::from(corners[3])),
]
}
}
fn apply_grip(&mut self, grip_id: usize, apply: GripApply) {
if grip_id == 0 {
let is_polygon = self.clipping_enabled
&& self.clip_boundary_vertices.len() >= 3
&& matches!(
self.clip_type,
acadrust::entities::WipeoutClipType::Polygonal
);
if is_polygon {
// Move the clicked polygon vertex in world space → back-project to pixel space.
if let Some(v) = self.clip_boundary_vertices.get_mut(grip_id) {
// Compute current world position of this vertex.
let ox = self.insertion_point.x;
let oy = self.insertion_point.y;
let oz = self.insertion_point.z;
let cur_wx = ox + self.u_vector.x * v.x * self.size.x + self.v_vector.x * v.y * self.size.y;
let cur_wy = oy + self.u_vector.y * v.x * self.size.x + self.v_vector.y * v.y * self.size.y;
let cur_wz = oz + self.u_vector.z * v.x * self.size.x + self.v_vector.z * v.y * self.size.y;
let new_w = match apply {
GripApply::Translate(d) => {
[cur_wx + d.x as f64, cur_wy + d.y as f64, cur_wz + d.z as f64]
}
GripApply::Absolute(p) => [p.x as f64, p.y as f64, p.z as f64],
};
// Back-project: solve for pixel coords using u_vector and v_vector.
// In 2D (XY plane): new_w - insertion_point = u_vec * vx * sx + v_vec * vy * sy
let dx = new_w[0] - self.insertion_point.x;
let dy = new_w[1] - self.insertion_point.y;
let ux = self.u_vector.x * self.size.x;
let uy = self.u_vector.y * self.size.x;
let vx = self.v_vector.x * self.size.y;
let vy = self.v_vector.y * self.size.y;
let det = ux * vy - uy * vx;
if det.abs() > 1e-12 {
v.x = (dx * vy - dy * vx) / det;
v.y = (ux * dy - uy * dx) / det;
}
}
} else if grip_id == 0 {
match apply {
GripApply::Translate(d) => {
self.insertion_point.x += d.x as f64;

View file

@ -71,22 +71,49 @@ impl TruckConvertible for Table {
// Cell text — lifted into Lines points via 2D strokes
let text_height = 0.18_f32;
let margin = text_height * 0.5_f32;
for (ri, row) in self.rows.iter().enumerate() {
let row_top = row_offsets[ri];
let row_bot = row_offsets.get(ri + 1).copied().unwrap_or(row_top + row.height as f32);
let row_mid = (row_top + row_bot) * 0.5;
let row_top = row_offsets[ri];
let row_bot = row_offsets.get(ri + 1).copied().unwrap_or(row_top + row.height as f32);
let row_mid = (row_top + row_bot) * 0.5;
for (ci, cell) in row.cells.iter().enumerate() {
let text = cell.text_value();
if text.is_empty() {
continue;
}
let col_left = col_offsets[ci];
let col_mid = col_left + self.columns.get(ci).map(|c| c.width as f32 * 0.5).unwrap_or(0.5);
let cell_center = origin + h * col_mid + v_down * row_mid;
let col_left = col_offsets[ci];
let col_width = self.columns.get(ci).map(|c| c.width as f32).unwrap_or(1.0);
let col_right = col_left + col_width;
// Alignment: CellStyle.alignment i32 encodes 1-9 (AutoCAD convention):
// 1=TopLeft 2=TopCenter 3=TopRight
// 4=MiddleLeft 5=MiddleCenter 6=MiddleRight
// 7=BottomLeft 8=BottomCenter 9=BottomRight
// 0/default = MiddleCenter (5)
let align = cell.style.as_ref().map_or(5, |s| s.alignment);
let horiz = ((align - 1).rem_euclid(3)) + 1; // 1=left, 2=center, 3=right
let vert = ((align - 1) / 3) + 1; // 1=top, 2=middle, 3=bottom
let text_w = cxf::measure_text(text, text_height, 1.0, "txt");
let x_offset = match horiz {
1 => col_left + margin, // left
3 => col_right - margin - text_w, // right
_ => col_left + (col_width - text_w) * 0.5, // center (default)
};
let y_offset = match vert {
1 => row_top + margin, // top
3 => row_bot - margin - text_height, // bottom
_ => row_mid - text_height * 0.5, // middle (default)
};
let text_origin = origin + h * x_offset + v_down * y_offset;
let strokes = cxf::tessellate_text_ex(
[cell_center.x, cell_center.y],
[text_origin.x, text_origin.y],
text_height,
0.0,
1.0,

View file

@ -0,0 +1,138 @@
// DIMBASELINE command — stacked baseline dimensions all measured from the same origin.
//
// Each new point becomes the second extension line origin of a new dimension.
// The first extension line is always the same base origin point.
// Each new dimension line is placed further from the baseline by DIMDLI (increment).
//
// Constructed from commands.rs after finding the last placed linear/aligned dimension.
use acadrust::entities::{Dimension, DimensionLinear};
use acadrust::types::Vector3;
use acadrust::EntityType;
use glam::Vec3;
use crate::command::{CadCommand, CmdResult};
use crate::scene::wire_model::WireModel;
/// Default stacking increment (world units) between successive baseline dimensions.
const DIMDLI: f32 = 1.5;
pub struct DimBaselineCommand {
/// Fixed first-extension-line origin (never changes).
base_p1: Vec3,
/// Direction along the measurement direction (0.0 = horizontal, PI/2 = vertical).
rotation: f64,
/// Unit vector perpendicular to the dimension axis, pointing toward the dim line side.
perp: Vec3,
/// Perpendicular distance of the NEXT dimension line from the extension-line axis.
next_offset: f32,
/// True once we have a base dimension loaded.
ready: bool,
}
impl DimBaselineCommand {
/// No base dim found — cancel immediately.
pub fn new() -> Self {
Self {
base_p1: Vec3::ZERO,
rotation: 0.0,
perp: Vec3::Y,
next_offset: DIMDLI,
ready: false,
}
}
/// Build from the last placed dimension.
///
/// `p1` — first extension line origin (fixed baseline).
/// `p2` — second extension line origin of the base dim (unused for placement, kept for context).
/// `definition_point` — dim-line position of the base dim (defines perpendicular side).
/// `rotation` — 0.0 = horizontal, PI/2 = vertical.
pub fn from_base(p1: Vec3, _p2: Vec3, definition_point: Vec3, rotation: f64) -> Self {
let axis = if rotation.abs() < 0.1 { Vec3::X } else { Vec3::Y };
let perp = Vec3::new(-axis.y, axis.x, 0.0);
let base_offset = (definition_point - p1).dot(perp);
// Next baseline dim goes one DIMDLI further from the baseline.
let next_offset = base_offset + DIMDLI;
Self {
base_p1: p1,
rotation,
perp,
next_offset,
ready: true,
}
}
}
impl CadCommand for DimBaselineCommand {
fn name(&self) -> &'static str { "DIMBASELINE" }
fn prompt(&self) -> String {
if !self.ready {
"DIMBASELINE No base dimension found. Place a dimension first.".into()
} else {
"DIMBASELINE Specify a second extension line origin (Enter to exit):".into()
}
}
fn on_point(&mut self, pt: Vec3) -> CmdResult {
if !self.ready {
return CmdResult::Cancel;
}
let p1 = self.base_p1;
let p2 = pt;
// Build a new linear dimension.
let mut dim = DimensionLinear::new(v3(p1), v3(p2));
dim.rotation = self.rotation;
let dim_line_pt = p1 + self.perp * self.next_offset;
let dim_line_pt2 = p2 + self.perp * self.next_offset;
dim.definition_point = v3(dim_line_pt);
dim.base.definition_point = v3(dim_line_pt);
dim.base.text_middle_point = v3((dim_line_pt + dim_line_pt2) * 0.5);
dim.base.insertion_point = dim.base.text_middle_point;
dim.base.actual_measurement = dim.measurement();
// Stack the next dim line further out.
self.next_offset += DIMDLI;
CmdResult::CommitEntity(EntityType::Dimension(Dimension::Linear(dim)))
}
fn on_enter(&mut self) -> CmdResult {
CmdResult::Cancel
}
fn on_mouse_move(&mut self, pt: Vec3) -> Option<WireModel> {
if !self.ready {
return None;
}
let p1 = self.base_p1;
let dim_line_pt = p1 + self.perp * self.next_offset;
let dim_line_pt2 = pt + self.perp * self.next_offset;
Some(WireModel {
name: "dimbase_preview".into(),
points: vec![
[p1.x, p1.y, p1.z], [dim_line_pt.x, dim_line_pt.y, dim_line_pt.z],
[f32::NAN, 0.0, 0.0],
[pt.x, pt.y, pt.z], [dim_line_pt2.x, dim_line_pt2.y, dim_line_pt2.z],
[f32::NAN, 0.0, 0.0],
[dim_line_pt.x, dim_line_pt.y, dim_line_pt.z],
[dim_line_pt2.x, dim_line_pt2.y, dim_line_pt2.z],
],
color: WireModel::CYAN,
selected: false,
pattern_length: 0.0,
pattern: [0.0; 8],
line_weight_px: 1.0,
snap_pts: vec![],
tangent_geoms: vec![],
key_vertices: vec![],
})
}
}
fn v3(p: Vec3) -> Vector3 {
Vector3::new(p.x as f64, p.y as f64, p.z as f64)
}

View file

@ -0,0 +1,135 @@
// DIMCONTINUE command — chain linear/aligned dimensions end-to-end.
//
// Each new point becomes the second extension line origin of a new dimension,
// whose first extension line origin is the second extension line of the previous dim.
// The dimension line stays at the same perpendicular offset as the base dimension.
//
// Constructed from commands.rs after finding the last placed linear/aligned dimension.
use acadrust::entities::{Dimension, DimensionLinear};
use acadrust::types::Vector3;
use acadrust::EntityType;
use glam::Vec3;
use crate::command::{CadCommand, CmdResult};
use crate::scene::wire_model::WireModel;
pub struct DimContinueCommand {
/// Fixed first-extension-line origin for the current step (moves each iteration).
chain_p1: Vec3,
/// Direction along the dimension axis (0.0 = horizontal, PI/2 = vertical).
rotation: f64,
/// Perpendicular distance from the extension-line axis to the dimension line.
/// Preserved from the base dimension.
dim_offset: f32,
/// Direction of "up" perpendicular to the dim axis (points toward the dim line).
perp: Vec3,
/// True once we have a base dimension loaded.
ready: bool,
}
impl DimContinueCommand {
/// No base dim found — will show an error prompt and cancel immediately.
pub fn new() -> Self {
Self {
chain_p1: Vec3::ZERO,
rotation: 0.0,
dim_offset: 0.0,
perp: Vec3::Y,
ready: false,
}
}
/// Build from the last placed dimension.
///
/// `p1` / `p2` — extension line origins of the base dim.
/// `definition_point` — where the dim line was placed (defines perpendicular offset).
/// `rotation` — 0.0 = horizontal dim, PI/2 = vertical dim.
pub fn from_base(p1: Vec3, p2: Vec3, definition_point: Vec3, rotation: f64) -> Self {
// Axis unit vector along the measurement direction.
let axis = if rotation.abs() < 0.1 { Vec3::X } else { Vec3::Y };
// Perpendicular unit vector toward the dim line.
let perp = Vec3::new(-axis.y, axis.x, 0.0);
let dim_offset = (definition_point - p1).dot(perp);
Self {
chain_p1: p2,
rotation,
dim_offset,
perp,
ready: true,
}
}
}
impl CadCommand for DimContinueCommand {
fn name(&self) -> &'static str { "DIMCONTINUE" }
fn prompt(&self) -> String {
if !self.ready {
"DIMCONTINUE No base dimension found. Place a dimension first.".into()
} else {
"DIMCONTINUE Specify a second extension line origin (Enter to exit):".into()
}
}
fn on_point(&mut self, pt: Vec3) -> CmdResult {
if !self.ready {
return CmdResult::Cancel;
}
let p1 = self.chain_p1;
let p2 = pt;
// Build a new linear dimension.
let mut dim = DimensionLinear::new(v3(p1), v3(p2));
dim.rotation = self.rotation;
// Dim-line position: same perpendicular distance as base.
let dim_line_pt = p1 + self.perp * self.dim_offset;
dim.definition_point = v3(dim_line_pt);
dim.base.definition_point = v3(dim_line_pt);
dim.base.text_middle_point = v3((dim_line_pt + (p2 + self.perp * self.dim_offset)) * 0.5);
dim.base.insertion_point = dim.base.text_middle_point;
dim.base.actual_measurement = dim.measurement();
// Advance chain: next dim's P1 = this dim's P2.
self.chain_p1 = p2;
CmdResult::CommitEntity(EntityType::Dimension(Dimension::Linear(dim)))
}
fn on_enter(&mut self) -> CmdResult {
CmdResult::Cancel
}
fn on_mouse_move(&mut self, pt: Vec3) -> Option<WireModel> {
if !self.ready {
return None;
}
let p1 = self.chain_p1;
let dim_line_pt = p1 + self.perp * self.dim_offset;
let dim_line_pt2 = pt + self.perp * self.dim_offset;
Some(WireModel {
name: "dimcont_preview".into(),
points: vec![
[p1.x, p1.y, p1.z], [dim_line_pt.x, dim_line_pt.y, dim_line_pt.z],
[f32::NAN, 0.0, 0.0],
[pt.x, pt.y, pt.z], [dim_line_pt2.x, dim_line_pt2.y, dim_line_pt2.z],
[f32::NAN, 0.0, 0.0],
[dim_line_pt.x, dim_line_pt.y, dim_line_pt.z],
[dim_line_pt2.x, dim_line_pt2.y, dim_line_pt2.z],
],
color: WireModel::CYAN,
selected: false,
pattern_length: 0.0,
pattern: [0.0; 8],
line_weight_px: 1.0,
snap_pts: vec![],
tangent_geoms: vec![],
key_vertices: vec![],
})
}
}
fn v3(p: Vec3) -> Vector3 {
Vector3::new(p.x as f64, p.y as f64, p.z as f64)
}

View file

@ -3,12 +3,16 @@
pub mod aligned_dim;
pub mod angular_dim;
pub mod diameter_dim;
pub mod dim_baseline;
pub mod dim_continue;
pub mod leader_cmd;
pub mod linear_dim;
pub mod mleader_cmd;
pub mod mtext;
pub mod radius_dim;
pub mod table_cmd;
pub mod text;
pub mod tolerance_cmd;
use crate::modules::{CadModule, RibbonGroup, RibbonItem};

View file

@ -0,0 +1,139 @@
// TABLE command — create an empty table entity.
//
// Workflow:
// 1. Text: Enter number of columns (default 3)
// 2. Text: Enter number of rows (default 4, includes header row)
// 3. Point: Click insertion point
//
// Creates a Table entity with uniform row height (0.5) and column width (2.0).
use acadrust::entities::TableBuilder;
use acadrust::types::Vector3;
use acadrust::EntityType;
use glam::Vec3;
use crate::command::{CadCommand, CmdResult};
use crate::scene::wire_model::WireModel;
const DEFAULT_COLS: usize = 3;
const DEFAULT_ROWS: usize = 4;
const COL_WIDTH: f64 = 2.0;
const ROW_HEIGHT: f64 = 0.5;
enum Step {
Columns,
Rows { cols: usize },
Insertion { cols: usize, rows: usize },
}
pub struct TableCommand {
step: Step,
}
impl TableCommand {
pub fn new() -> Self {
Self { step: Step::Columns }
}
}
impl CadCommand for TableCommand {
fn name(&self) -> &'static str { "TABLE" }
fn prompt(&self) -> String {
match &self.step {
Step::Columns => format!("TABLE Enter number of columns [{DEFAULT_COLS}]:"),
Step::Rows { cols } => format!("TABLE Enter number of rows (incl. header) [{DEFAULT_ROWS}] ({cols} cols):"),
Step::Insertion { cols, rows } => format!("TABLE Specify insertion point [{cols}×{rows}]:"),
}
}
fn wants_text_input(&self) -> bool {
!matches!(self.step, Step::Insertion { .. })
}
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
let t = text.trim();
match &self.step {
Step::Columns => {
let cols = if t.is_empty() {
DEFAULT_COLS
} else {
t.parse::<usize>().ok().filter(|&n| n >= 1)?
};
self.step = Step::Rows { cols };
Some(CmdResult::NeedPoint)
}
Step::Rows { cols } => {
let cols = *cols;
let rows = if t.is_empty() {
DEFAULT_ROWS
} else {
t.parse::<usize>().ok().filter(|&n| n >= 1)?
};
self.step = Step::Insertion { cols, rows };
Some(CmdResult::NeedPoint)
}
Step::Insertion { .. } => None,
}
}
fn on_enter(&mut self) -> CmdResult {
match &self.step {
Step::Columns => {
// Accept default.
self.step = Step::Rows { cols: DEFAULT_COLS };
CmdResult::NeedPoint
}
Step::Rows { cols } => {
let cols = *cols;
self.step = Step::Insertion { cols, rows: DEFAULT_ROWS };
CmdResult::NeedPoint
}
Step::Insertion { .. } => CmdResult::Cancel,
}
}
fn on_point(&mut self, pt: Vec3) -> CmdResult {
if let Step::Insertion { cols, rows } = self.step {
let ins = Vector3::new(pt.x as f64, pt.z as f64, pt.y as f64);
let table = TableBuilder::new(rows, cols)
.at(ins)
.row_height(ROW_HEIGHT)
.column_width(COL_WIDTH)
.build();
CmdResult::CommitAndExit(EntityType::Table(table))
} else {
CmdResult::NeedPoint
}
}
fn on_mouse_move(&mut self, pt: Vec3) -> Option<WireModel> {
if let Step::Insertion { cols, rows } = self.step {
// Preview: outline of the table bounding box.
let w = (cols as f32) * COL_WIDTH as f32;
let h = (rows as f32) * ROW_HEIGHT as f32;
let x = pt.x;
let y = pt.y;
let z = pt.z;
Some(WireModel {
name: "table_preview".into(),
points: vec![
[x, y, z], [x + w, y, z],
[x + w, y, z], [x + w, y, z - h],
[x + w, y, z - h], [x, y, z - h],
[x, y, z - h], [x, y, z],
],
color: WireModel::CYAN,
selected: false,
pattern_length: 0.0,
pattern: [0.0; 8],
line_weight_px: 1.0,
snap_pts: vec![],
tangent_geoms: vec![],
key_vertices: vec![],
})
} else {
None
}
}
}

View file

@ -0,0 +1,89 @@
// TOLERANCE command — place a GD&T (geometric dimensioning & tolerancing) frame.
//
// Workflow:
// 1. Text: Enter tolerance string (e.g. "%%v0.05|A" or plain text)
// 2. Point: Click insertion point
use acadrust::entities::Tolerance;
use acadrust::types::Vector3;
use acadrust::EntityType;
use glam::Vec3;
use crate::command::{CadCommand, CmdResult};
use crate::scene::wire_model::WireModel;
enum Step {
Text,
Insertion { text: String },
}
pub struct ToleranceCommand {
step: Step,
}
impl ToleranceCommand {
pub fn new() -> Self {
Self { step: Step::Text }
}
}
impl CadCommand for ToleranceCommand {
fn name(&self) -> &'static str { "TOLERANCE" }
fn prompt(&self) -> String {
match &self.step {
Step::Text => "TOLERANCE Enter tolerance text:".into(),
Step::Insertion { text } => format!("TOLERANCE Specify insertion point [{text}]:"),
}
}
fn wants_text_input(&self) -> bool {
matches!(self.step, Step::Text)
}
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
let t = text.trim().to_string();
if t.is_empty() {
return Some(CmdResult::Cancel);
}
self.step = Step::Insertion { text: t };
Some(CmdResult::NeedPoint)
}
fn on_point(&mut self, pt: Vec3) -> CmdResult {
if let Step::Insertion { text } = &self.step {
let ins = Vector3::new(pt.x as f64, pt.z as f64, pt.y as f64);
let tol = Tolerance::with_text(ins, text.clone());
CmdResult::CommitAndExit(EntityType::Tolerance(tol))
} else {
CmdResult::NeedPoint
}
}
fn on_enter(&mut self) -> CmdResult {
CmdResult::Cancel
}
fn on_mouse_move(&mut self, pt: Vec3) -> Option<WireModel> {
if !matches!(self.step, Step::Insertion { .. }) {
return None;
}
let d = 0.15_f32;
Some(WireModel {
name: "tolerance_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![],
})
}
}

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![],
})
}
}

View file

@ -1,4 +1,5 @@
pub mod arc;
pub mod attdef;
pub mod circle;
pub mod donut;
pub mod ellipse;

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))
}
}

View file

@ -9,7 +9,6 @@ use acadrust::{EntityType, Handle};
use glam::Vec3;
use crate::command::{CadCommand, CmdResult};
use crate::scene::wire_model::WireModel;
// ── DIVIDE ─────────────────────────────────────────────────────────────────

View file

@ -9,7 +9,7 @@
// With 1 pair: pure translation (src1 → dst1)
// With 2 pairs: translate + rotate (+ optional uniform scale to fit)
use glam::{Mat4, Vec3};
use glam::Vec3;
use acadrust::Handle;
use crate::command::{CadCommand, CmdResult, EntityTransform};

View file

@ -7,8 +7,6 @@
//
// BREAK @ (at-sign as second point) → Break at a single point (splits without gap).
use std::f64::consts::TAU;
use acadrust::entities::{Arc as ArcEnt, Line as LineEnt, LwPolyline};
use acadrust::types::Vector3;
use acadrust::{EntityType, Handle};
@ -20,6 +18,7 @@ use crate::scene::wire_model::WireModel;
// ── Ribbon definition ──────────────────────────────────────────────────────
#[allow(dead_code)]
pub fn tool() -> ToolDef {
ToolDef {
id: "BREAK",
@ -285,3 +284,50 @@ impl CadCommand for BreakInteractiveCommand {
None
}
}
// ── BREAKATPOINT (BAP) — split at a single point, no gap ──────────────────
pub struct BreakAtPointCommand {
target: Option<Handle>,
}
impl BreakAtPointCommand {
pub fn new() -> Self {
Self { target: None }
}
}
impl CadCommand for BreakAtPointCommand {
fn name(&self) -> &'static str { "BREAKATPOINT" }
fn prompt(&self) -> String {
if self.target.is_none() {
"BREAKATPOINT Select object:".into()
} else {
"BREAKATPOINT Specify break point:".into()
}
}
fn needs_entity_pick(&self) -> bool {
self.target.is_none()
}
fn on_entity_pick(&mut self, handle: Handle, _pt: Vec3) -> CmdResult {
if handle.is_null() {
return CmdResult::NeedPoint;
}
self.target = Some(handle);
CmdResult::NeedPoint
}
fn on_point(&mut self, pt: Vec3) -> CmdResult {
match self.target {
Some(handle) => CmdResult::BreakEntity { handle, p1: pt, p2: pt },
None => CmdResult::Cancel,
}
}
fn on_enter(&mut self) -> CmdResult {
CmdResult::Cancel
}
}

View file

@ -7,7 +7,6 @@
//
// Workflow: select objects then press Enter to join.
use acadrust::entities::{Arc as ArcEnt, Line as LineEnt};
use acadrust::types::Vector3;
use acadrust::{EntityType, Handle};
use glam::Vec3;
@ -162,7 +161,7 @@ fn try_join_arcs(arcs: &[&(Handle, &EntityType)]) -> Option<(Vec<Handle>, Vec<En
intervals.sort_by(|a, b| a[0].partial_cmp(&b[0]).unwrap());
// Try to merge into one contiguous arc
let mut merged_start = intervals[0][0];
let merged_start = intervals[0][0];
let mut merged_end = intervals[0][1];
for &[s, e] in &intervals[1..] {
let span = ((e - merged_end) + 360.0) % 360.0;

View file

@ -10,7 +10,7 @@
use std::f64::consts::TAU;
use acadrust::entities::{Arc as ArcEnt, Line as LineEnt};
use acadrust::entities::{Arc as ArcEnt, Line as LineEnt, Ray as RayEnt, XLine as XLineEnt};
use acadrust::types::Vector3;
use acadrust::{EntityType, Handle};
use glam::Vec3;
@ -146,6 +146,11 @@ fn cc_angles(cx1: f64, cy1: f64, r1: f64, cx2: f64, cy2: f64, r2: f64) -> Vec<f6
// ── Boundary geometry ─────────────────────────────────────────────────────
/// Virtual extent used to represent infinite ends of Ray / XLine.
const TRIM_EXTENT: f64 = 1_000_000.0;
/// If a trim interval endpoint is beyond this threshold it is treated as "infinite".
const INF_T: f64 = 0.9999;
enum Geo {
Line {
handle: Handle,
@ -166,6 +171,22 @@ enum Geo {
cy: f64,
r: f64,
},
/// Semi-infinite line from base in +direction.
Ray {
handle: Handle,
bx: f64,
by: f64,
dx: f64,
dy: f64,
},
/// Fully-infinite line through base along direction.
InfLine {
handle: Handle,
bx: f64,
by: f64,
dx: f64,
dy: f64,
},
}
fn build_geos(entities: &[EntityType]) -> Vec<Geo> {
@ -193,6 +214,20 @@ fn build_geos(entities: &[EntityType]) -> Vec<Geo> {
cy: c.center.y,
r: c.radius,
}),
EntityType::Ray(r) => Some(Geo::Ray {
handle: h,
bx: r.base_point.x,
by: r.base_point.y,
dx: r.direction.x,
dy: r.direction.y,
}),
EntityType::XLine(x) => Some(Geo::InfLine {
handle: h,
bx: x.base_point.x,
by: x.base_point.y,
dx: x.direction.x,
dy: x.direction.y,
}),
_ => None,
}
})
@ -250,6 +285,24 @@ fn line_seg_ts(ax: f64, ay: f64, bx: f64, by: f64, target: Handle, geos: &[Geo])
}
}
}
Geo::Ray { handle, bx: rbx, by: rby, dx: rdx, dy: rdy } => {
if *handle == target { continue; }
if let Some((t, u)) = ll(ax, ay, dx, dy, *rbx, *rby, *rdx, *rdy) {
// Ray: u >= 0 (semi-infinite)
if u >= -1e-9 && (-1e-9..=1.0 + 1e-9).contains(&t) {
ts.push(t.clamp(0.0, 1.0));
}
}
}
Geo::InfLine { handle, bx: ibx, by: iby, dx: idx, dy: idy } => {
if *handle == target { continue; }
if let Some((t, _u)) = ll(ax, ay, dx, dy, *ibx, *iby, *idx, *idy) {
// XLine: any u accepted
if (-1e-9..=1.0 + 1e-9).contains(&t) {
ts.push(t.clamp(0.0, 1.0));
}
}
}
}
}
ts.sort_by(|a, b| a.partial_cmp(b).unwrap());
@ -274,11 +327,11 @@ fn arc_seg_ts(
if *handle == target {
continue;
}
let (dx, dy) = (p2[0] - p1[0], p2[1] - p1[1]);
lc(p1[0], p1[1], dx, dy, cx, cy, r)
let (ldx, ldy) = (p2[0] - p1[0], p2[1] - p1[1]);
lc(p1[0], p1[1], ldx, ldy, cx, cy, r)
.into_iter()
.filter(|&u| (-1e-9..=1.0 + 1e-9).contains(&u))
.map(|u| (p1[1] + u * dy - cy).atan2(p1[0] + u * dx - cx))
.map(|u| (p1[1] + u * ldy - cy).atan2(p1[0] + u * ldx - cx))
.collect()
}
Geo::Arc {
@ -308,6 +361,23 @@ fn arc_seg_ts(
}
cc_angles(cx, cy, r, *cx2, *cy2, *r2)
}
Geo::Ray { handle, bx: rbx, by: rby, dx: rdx, dy: rdy } => {
if *handle == target { continue; }
// Intersect arc circle with the Ray direction
lc(*rbx, *rby, *rdx, *rdy, cx, cy, r)
.into_iter()
.filter(|&u| u >= -1e-9) // Ray: u >= 0
.map(|u| (rby + u * rdy - cy).atan2(rbx + u * rdx - cx))
.collect()
}
Geo::InfLine { handle, bx: ibx, by: iby, dx: idx, dy: idy } => {
if *handle == target { continue; }
// XLine: any u accepted
lc(*ibx, *iby, *idx, *idy, cx, cy, r)
.into_iter()
.map(|u| (iby + u * idy - cy).atan2(ibx + u * idx - cx))
.collect()
}
};
for a in angles {
if in_arc(a, a0, a1) {
@ -473,6 +543,22 @@ fn extend_line(orig: &LineEnt, t_click: f64, geos: &[Geo]) -> Option<EntityType>
}
}
}
Geo::Ray { handle, bx: rbx, by: rby, dx: rdx, dy: rdy } => {
if *handle == target { continue; }
if let Some((t, u)) = ll(ax, ay, dx, dy, *rbx, *rby, *rdx, *rdy) {
if u >= -1e-9 { // only forward along the Ray
if extend_end && t > 1.0 + 1e-6 && t < best_t { best_t = t; }
if !extend_end && t < -1e-6 && t > best_t { best_t = t; }
}
}
}
Geo::InfLine { handle, bx: ibx, by: iby, dx: idx, dy: idy } => {
if *handle == target { continue; }
if let Some((t, _u)) = ll(ax, ay, dx, dy, *ibx, *iby, *idx, *idy) {
if extend_end && t > 1.0 + 1e-6 && t < best_t { best_t = t; }
if !extend_end && t < -1e-6 && t > best_t { best_t = t; }
}
}
}
}
@ -491,6 +577,113 @@ fn extend_line(orig: &LineEnt, t_click: f64, geos: &[Geo]) -> Option<EntityType>
Some(EntityType::Line(line))
}
/// Trim a Ray entity.
/// Virtual t ∈ [0,1]: t=0 → base_point, t=1 → base + TRIM_EXTENT * dir.
/// Surviving pieces become Lines (finite) or Rays (still semi-infinite).
fn trim_ray(orig: &RayEnt, ts: &[f64], t_click: f64) -> Vec<EntityType> {
let bx = orig.base_point.x;
let by = orig.base_point.y;
let bz = orig.base_point.z;
let dx = orig.direction.x;
let dy = orig.direction.y;
let dz = orig.direction.z;
let pt = |t: f64| [bx + t * dx * TRIM_EXTENT, by + t * dy * TRIM_EXTENT, bz + t * dz * TRIM_EXTENT];
trim_intervals(ts, t_click)
.into_iter()
.filter_map(|(ta, tb)| {
let pa = pt(ta);
let pb = pt(tb);
if (pb[0] - pa[0]).hypot(pb[1] - pa[1]) < 1e-6 { return None; }
if tb > INF_T {
// Still extends to infinity → remains a Ray with new base
let r = RayEnt::new(
Vector3::new(pa[0], pa[1], pa[2]),
Vector3::new(dx, dy, dz),
);
let mut r = r;
r.common = orig.common.clone();
r.common.handle = Handle::NULL;
Some(EntityType::Ray(r))
} else {
// Finite segment → Line
let mut l = LineEnt { common: orig.common.clone(), ..LineEnt::new() };
l.common.handle = Handle::NULL;
l.start = Vector3::new(pa[0], pa[1], pa[2]);
l.end = Vector3::new(pb[0], pb[1], pb[2]);
Some(EntityType::Line(l))
}
})
.collect()
}
/// Trim an XLine entity.
/// Virtual t ∈ [0,1]: t=0 → base - dir*TRIM_EXTENT, t=0.5 → base, t=1 → base + dir*TRIM_EXTENT.
/// Surviving pieces become Lines (finite), Rays (one infinite end), or the original XLine (both ends).
fn trim_xline(orig: &XLineEnt, ts: &[f64], t_click: f64) -> Vec<EntityType> {
let bx = orig.base_point.x;
let by = orig.base_point.y;
let bz = orig.base_point.z;
let dx = orig.direction.x;
let dy = orig.direction.y;
let dz = orig.direction.z;
// Point at virtual t: scale factor s = 2t - 1 ∈ [-1, +1]
let pt = |t: f64| {
let s = 2.0 * t - 1.0;
[bx + s * dx * TRIM_EXTENT, by + s * dy * TRIM_EXTENT, bz + s * dz * TRIM_EXTENT]
};
trim_intervals(ts, t_click)
.into_iter()
.filter_map(|(ta, tb)| {
let pa = pt(ta);
let pb = pt(tb);
let ext_neg = ta < 1.0 - INF_T; // extends toward -infinity
let ext_pos = tb > INF_T; // extends toward +infinity
match (ext_neg, ext_pos) {
(true, true) => {
// Whole XLine survived (shouldn't happen after a real trim)
let mut x = orig.clone();
x.common.handle = Handle::NULL;
Some(EntityType::XLine(x))
}
(true, false) => {
// Extends toward -infinity: Ray at pb pointing in -dir
let r = RayEnt::new(
Vector3::new(pb[0], pb[1], pb[2]),
Vector3::new(-dx, -dy, -dz),
);
let mut r = r;
r.common = orig.common.clone();
r.common.handle = Handle::NULL;
Some(EntityType::Ray(r))
}
(false, true) => {
// Extends toward +infinity: Ray at pa pointing in +dir
let r = RayEnt::new(
Vector3::new(pa[0], pa[1], pa[2]),
Vector3::new(dx, dy, dz),
);
let mut r = r;
r.common = orig.common.clone();
r.common.handle = Handle::NULL;
Some(EntityType::Ray(r))
}
(false, false) => {
// Finite segment
let mut l = LineEnt { common: orig.common.clone(), ..LineEnt::new() };
l.common.handle = Handle::NULL;
l.start = Vector3::new(pa[0], pa[1], pa[2]);
l.end = Vector3::new(pb[0], pb[1], pb[2]);
Some(EntityType::Line(l))
}
}
})
.collect()
}
// ── Point-generation helpers ──────────────────────────────────────────────
const DIM_RED: [f32; 4] = [1.0, 0.3, 0.3, 0.6];
@ -535,6 +728,16 @@ fn entity_pts(e: &EntityType) -> Vec<[f32; 3]> {
a.end_angle.to_radians(),
a.center.y,
),
// For preview, show a 20-unit section of semi-infinite results
EntityType::Ray(r) => {
let bx = r.base_point.x;
let by = r.base_point.y;
let bz = r.base_point.z;
let far_x = bx + r.direction.x * 20.0;
let far_y = by + r.direction.y * 20.0;
let far_z = bz + r.direction.z * 20.0;
vec![[bx as f32, bz as f32, by as f32], [far_x as f32, far_z as f32, far_y as f32]]
}
_ => vec![],
}
}
@ -611,6 +814,38 @@ impl CadCommand for TrimCommand {
let t_click = arc_t(click_angle, a0, a1);
Some(trim_arc(a, &ts, t_click))
}
Some(EntityType::Ray(r)) => {
// Virtual segment: base → base + dir * TRIM_EXTENT (t ∈ [0,1])
let bx = r.base_point.x;
let by = r.base_point.y;
let ex = bx + r.direction.x * TRIM_EXTENT;
let ey = by + r.direction.y * TRIM_EXTENT;
let ts = line_seg_ts(bx, by, ex, ey, handle, &self.geos);
if ts.is_empty() { return CmdResult::NeedPoint; }
let dx = r.direction.x * TRIM_EXTENT;
let dy = r.direction.y * TRIM_EXTENT;
let len2 = dx * dx + dy * dy;
let t_click = if len2 > 1e-12 {
((pt.x as f64 - bx) * dx + (pt.y as f64 - by) * dy) / len2
} else { 0.5 };
Some(trim_ray(r, &ts, t_click))
}
Some(EntityType::XLine(x)) => {
// Virtual segment: base - dir*TRIM_EXTENT → base + dir*TRIM_EXTENT
let bx = x.base_point.x - x.direction.x * TRIM_EXTENT;
let by = x.base_point.y - x.direction.y * TRIM_EXTENT;
let ex = x.base_point.x + x.direction.x * TRIM_EXTENT;
let ey = x.base_point.y + x.direction.y * TRIM_EXTENT;
let ts = line_seg_ts(bx, by, ex, ey, handle, &self.geos);
if ts.is_empty() { return CmdResult::NeedPoint; }
let dx = ex - bx;
let dy = ey - by;
let len2 = dx * dx + dy * dy;
let t_click = if len2 > 1e-12 {
((pt.x as f64 - bx) * dx + (pt.y as f64 - by) * dy) / len2
} else { 0.5 };
Some(trim_xline(x, &ts, t_click))
}
_ => None,
};
@ -645,6 +880,8 @@ impl CadCommand for TrimCommand {
match e {
EntityType::Line(l) => l.common.handle = h,
EntityType::Arc(a) => a.common.handle = h,
EntityType::Ray(r) => r.common.handle = h,
EntityType::XLine(x) => x.common.handle = h,
_ => {}
}
}
@ -721,6 +958,58 @@ impl CadCommand for TrimCommand {
}
out
}
Some(EntityType::Ray(r)) => {
let bx = r.base_point.x;
let by = r.base_point.y;
let ex = bx + r.direction.x * TRIM_EXTENT;
let ey = by + r.direction.y * TRIM_EXTENT;
let ts = line_seg_ts(bx, by, ex, ey, handle, &self.geos);
if ts.is_empty() { return vec![]; }
let dx = r.direction.x * TRIM_EXTENT;
let dy = r.direction.y * TRIM_EXTENT;
let len2 = dx * dx + dy * dy;
let t_click = if len2 > 1e-12 {
((pt.x as f64 - bx) * dx + (pt.y as f64 - by) * dy) / len2
} else { 0.5 };
let survivors = trim_ray(r, &ts, t_click);
// Show a finite preview section (20 units) for the original ray
let far = [(bx + r.direction.x * 20.0) as f32, (by + r.direction.y * 20.0) as f32, r.base_point.z as f32];
let base = [bx as f32, by as f32, r.base_point.z as f32];
let removed = WireModel::solid("trim_rm".into(), vec![base, far], DIM_RED, false);
let mut out = vec![removed];
for (i, e) in survivors.iter().enumerate() {
let pts = entity_pts(e);
out.push(WireModel::solid(format!("trim_keep_{i}"), pts, WireModel::CYAN, false));
}
out
}
Some(EntityType::XLine(x)) => {
let bx = x.base_point.x;
let by = x.base_point.y;
let ex_start = bx - x.direction.x * TRIM_EXTENT;
let ey_start = by - x.direction.y * TRIM_EXTENT;
let ex_end = bx + x.direction.x * TRIM_EXTENT;
let ey_end = by + x.direction.y * TRIM_EXTENT;
let ts = line_seg_ts(ex_start, ey_start, ex_end, ey_end, handle, &self.geos);
if ts.is_empty() { return vec![]; }
let dx = ex_end - ex_start;
let dy = ey_end - ey_start;
let len2 = dx * dx + dy * dy;
let t_click = if len2 > 1e-12 {
((pt.x as f64 - ex_start) * dx + (pt.y as f64 - ey_start) * dy) / len2
} else { 0.5 };
let survivors = trim_xline(x, &ts, t_click);
// Show a finite 40-unit preview section around base
let neg = [(bx - x.direction.x * 20.0) as f32, (by - x.direction.y * 20.0) as f32, x.base_point.z as f32];
let pos = [(bx + x.direction.x * 20.0) as f32, (by + x.direction.y * 20.0) as f32, x.base_point.z as f32];
let removed = WireModel::solid("trim_rm".into(), vec![neg, pos], DIM_RED, false);
let mut out = vec![removed];
for (i, e) in survivors.iter().enumerate() {
let pts = entity_pts(e);
out.push(WireModel::solid(format!("trim_keep_{i}"), pts, WireModel::CYAN, false));
}
out
}
_ => vec![],
}
}

View file

@ -15,6 +15,7 @@ mod xray;
mod zoom_ext;
mod zoom_in;
mod zoom_out;
pub mod plot_window;
pub mod zoom_window;
use crate::modules::{CadModule, RibbonGroup};

View file

@ -0,0 +1,66 @@
// PLOTWINDOW command — pick two corners to define the plot window area.
//
// Works only in paper space layouts. After picking P1 and P2, writes the
// window to the layout's PlotSettings (PlotType::Window).
use crate::command::{CadCommand, CmdResult};
use crate::scene::wire_model::WireModel;
use glam::Vec3;
pub struct PlotWindowCommand {
p1: Option<Vec3>,
}
impl PlotWindowCommand {
pub fn new() -> Self {
Self { p1: None }
}
}
impl CadCommand for PlotWindowCommand {
fn name(&self) -> &'static str { "PLOTWINDOW" }
fn prompt(&self) -> String {
if self.p1.is_none() {
"PLOTWINDOW Specify first corner of plot window:".into()
} else {
"PLOTWINDOW Specify opposite corner:".into()
}
}
fn on_point(&mut self, pt: Vec3) -> CmdResult {
match self.p1 {
None => {
self.p1 = Some(pt);
CmdResult::NeedPoint
}
Some(p1) => CmdResult::SetPlotWindow { p1, p2: pt },
}
}
fn on_enter(&mut self) -> CmdResult {
CmdResult::Cancel
}
fn on_mouse_move(&mut self, pt: Vec3) -> Option<WireModel> {
let p1 = self.p1?;
// Draw the selection rectangle.
Some(WireModel {
name: "plotwindow_preview".into(),
points: vec![
[p1.x, p1.y, p1.z], [pt.x, p1.y, p1.z],
[pt.x, p1.y, p1.z], [pt.x, pt.y, pt.z],
[pt.x, pt.y, pt.z], [p1.x, pt.y, pt.z],
[p1.x, pt.y, pt.z], [p1.x, p1.y, p1.z],
],
color: WireModel::CYAN,
selected: false,
pattern_length: 0.0,
pattern: [0.0; 8],
line_weight_px: 1.0,
snap_pts: vec![],
tangent_geoms: vec![],
key_vertices: vec![],
})
}
}

View file

@ -259,6 +259,29 @@ pub fn tessellate_text_ex(
out
}
/// Measure the width of a rendered text string in font glyph units × scale.
/// Returns the total advance width in world units (height-independent: divide by height for
/// a normalised ratio, or use directly when `height` is already the desired em size).
pub fn measure_text(text: &str, height: f32, width_factor: f32, font_name: &str) -> f32 {
if text.is_empty() || height <= 0.0 {
return 0.0;
}
let font = get_font(font_name);
let scale = height / 9.0;
let wf = width_factor.clamp(0.01, 100.0);
let mut cursor_x: f32 = 0.0;
for ch in text.chars() {
if ch == ' ' {
cursor_x += font.word_spacing;
} else if let Some(glyph) = font.glyph(ch) {
cursor_x += glyph.advance + font.letter_spacing;
} else {
cursor_x += 6.0 + font.letter_spacing;
}
}
cursor_x * scale * wf
}
// ── Parser ────────────────────────────────────────────────────────────────
fn parse(src: &str) -> CxfFile {

View file

@ -421,12 +421,19 @@ impl Scene {
if let EntityType::Viewport(vp) = e {
let is_active = self.active_viewport == Some(h);
let color = if vp.id == 1 {
let is_locked = vp.status.locked;
let color = if sel && vp.id != 1 {
// Selected viewport — bright white highlight.
[1.0, 1.0, 1.0, 1.0]
} else if vp.id == 1 {
// Overall paper-space viewport — subtle grey.
[0.40, 0.40, 0.40, 1.0]
} else if is_active {
// Active (entered) viewport — bright yellow.
[1.0, 0.90, 0.20, 1.0]
} else if is_locked {
// Locked viewport — orange tint to indicate scale is frozen.
[0.90, 0.55, 0.10, 1.0]
} else {
// Normal user viewport — cyan.
[0.0, 0.75, 0.75, 1.0]