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:
parent
27b5e124c7
commit
0aef1fc12f
27 changed files with 2217 additions and 100 deletions
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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 ─────────────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
138
src/modules/annotate/dim_baseline.rs
Normal file
138
src/modules/annotate/dim_baseline.rs
Normal 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)
|
||||
}
|
||||
135
src/modules/annotate/dim_continue.rs
Normal file
135
src/modules/annotate/dim_continue.rs
Normal 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)
|
||||
}
|
||||
|
|
@ -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};
|
||||
|
||||
|
|
|
|||
139
src/modules/annotate/table_cmd.rs
Normal file
139
src/modules/annotate/table_cmd.rs
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
89
src/modules/annotate/tolerance_cmd.rs
Normal file
89
src/modules/annotate/tolerance_cmd.rs
Normal 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![],
|
||||
})
|
||||
}
|
||||
}
|
||||
137
src/modules/home/draw/attdef.rs
Normal file
137
src/modules/home/draw/attdef.rs
Normal 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,4 +1,5 @@
|
|||
pub mod arc;
|
||||
pub mod attdef;
|
||||
pub mod circle;
|
||||
pub mod donut;
|
||||
pub mod ellipse;
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ use acadrust::{EntityType, Handle};
|
|||
use glam::Vec3;
|
||||
|
||||
use crate::command::{CadCommand, CmdResult};
|
||||
use crate::scene::wire_model::WireModel;
|
||||
|
||||
// ── DIVIDE ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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![],
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
66
src/modules/view/plot_window.rs
Normal file
66
src/modules/view/plot_window.rs
Normal 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![],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
Loading…
Reference in a new issue