refactor: split four oversized files into submodule trees
Break up the four giant files (~29k lines total) into focused submodule
directories, one concern per file. Pure mechanical moves — behavior is
unchanged.
- app/update.rs (9650) -> app/update/{mod,viewport,command,style,file,
dynamic,dialog,util}. The 384-arm update_inner match stays in mod.rs
with thin delegating arms; the 66 fattest arm bodies move to topic
methods. Arm set preserved exactly (384 -> 384).
- scene/mod.rs (9017) -> scene/{mod,entity,tess,hittest,layout,paper,
mspace,project,selection,modify,group_layer,preview}. The impl Scene
body is split across files via inherent-impl-per-file; struct, ctor,
fields and load-time helpers stay in the root. fn count 123 -> 123.
- app/commands.rs (6082) -> app/commands/* grouped by command family.
The 233-arm dispatch match becomes source-ordered per-family
dispatch_* handlers, preserving first-match precedence (233 -> 233).
- app/view.rs (4686) -> app/view/{mod,overlay,modal,viewcube,controls}.
Largest file drops from 9650 to 3343. Builds clean; app launches and
loads plugins with no panic.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
2baabdf4a0
commit
bddc050b3e
39 changed files with 26678 additions and 26117 deletions
6082
src/app/commands.rs
6082
src/app/commands.rs
File diff suppressed because it is too large
Load diff
285
src/app/commands/blocks.rs
Normal file
285
src/app/commands/blocks.rs
Normal file
|
|
@ -0,0 +1,285 @@
|
|||
use super::*;
|
||||
|
||||
impl OpenCADStudio {
|
||||
pub(super) fn dispatch_blocks(&mut self, cmd: &str, i: usize) -> Option<Task<Message>> {
|
||||
match cmd {
|
||||
"COPYCLIP" | "CC" => {
|
||||
let handles: Vec<_> = self.tabs[i]
|
||||
.scene
|
||||
.selected_entities()
|
||||
.into_iter()
|
||||
.map(|(h, _)| h)
|
||||
.collect();
|
||||
if handles.is_empty() {
|
||||
use crate::modules::draw::select::SelectObjectsCommand;
|
||||
let cmd = SelectObjectsCommand::new("COPYCLIP");
|
||||
self.command_line.push_info(&cmd.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(cmd));
|
||||
} else {
|
||||
let entities: Vec<_> = handles
|
||||
.iter()
|
||||
.filter_map(|&h| self.tabs[i].scene.document.get_entity(h).cloned())
|
||||
.collect();
|
||||
self.clipboard_centroid = super::super::helpers::entities_centroid(
|
||||
&self.tabs[i].scene.wire_models_for(&handles),
|
||||
);
|
||||
self.clipboard = entities;
|
||||
self.clipboard_deps = super::super::ClipboardDeps::capture(
|
||||
&self.tabs[i].scene.document,
|
||||
&self.clipboard,
|
||||
);
|
||||
self.command_line.push_info(&format!(
|
||||
"{} object(s) copied to clipboard.",
|
||||
self.clipboard.len()
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
"CUTCLIP" | "CX" => {
|
||||
let handles: Vec<_> = self.tabs[i]
|
||||
.scene
|
||||
.selected_entities()
|
||||
.into_iter()
|
||||
.map(|(h, _)| h)
|
||||
.collect();
|
||||
if handles.is_empty() {
|
||||
use crate::modules::draw::select::SelectObjectsCommand;
|
||||
let cmd = SelectObjectsCommand::new("CUTCLIP");
|
||||
self.command_line.push_info(&cmd.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(cmd));
|
||||
} else {
|
||||
let entities: Vec<_> = handles
|
||||
.iter()
|
||||
.filter_map(|&h| self.tabs[i].scene.document.get_entity(h).cloned())
|
||||
.collect();
|
||||
self.clipboard_centroid = super::super::helpers::entities_centroid(
|
||||
&self.tabs[i].scene.wire_models_for(&handles),
|
||||
);
|
||||
let count = entities.len();
|
||||
self.clipboard = entities;
|
||||
self.clipboard_deps = super::super::ClipboardDeps::capture(
|
||||
&self.tabs[i].scene.document,
|
||||
&self.clipboard,
|
||||
);
|
||||
self.push_undo_snapshot(i, "CUTCLIP");
|
||||
self.tabs[i].scene.erase_entities(&handles);
|
||||
self.tabs[i].scene.deselect_all();
|
||||
self.tabs[i].dirty = true;
|
||||
self.refresh_properties();
|
||||
self.command_line
|
||||
.push_info(&format!("{} object(s) cut to clipboard.", count));
|
||||
}
|
||||
}
|
||||
|
||||
"PASTECLIP" | "PC" => {
|
||||
if self.clipboard.is_empty() {
|
||||
self.command_line.push_error("Clipboard is empty.");
|
||||
} else {
|
||||
let wires = self.tabs[i].scene.wires_for_entities(&self.clipboard);
|
||||
let centroid = self.clipboard_centroid;
|
||||
use crate::modules::draw::clipboard::paste::PasteCommand;
|
||||
// The ghost anchor is a display-only offset; the precise
|
||||
// paste delta is computed in f64 at commit time.
|
||||
let cmd = PasteCommand::new(wires, centroid.as_vec3());
|
||||
self.command_line.push_info(&cmd.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(cmd));
|
||||
}
|
||||
}
|
||||
|
||||
// PASTEORIG — paste at the entities' original coordinates (no pick).
|
||||
"PASTEORIG" => {
|
||||
if self.clipboard.is_empty() {
|
||||
self.command_line
|
||||
.push_error("PASTEORIG: clipboard is empty.");
|
||||
} else {
|
||||
let count = self.clipboard.len();
|
||||
self.push_undo_snapshot(i, "PASTEORIG");
|
||||
// No transform: entities keep their original coordinates.
|
||||
let _ = self.finalize_paste(i, None);
|
||||
self.tabs[i].dirty = true;
|
||||
self.refresh_layer_panel();
|
||||
self.refresh_properties();
|
||||
self.command_line.push_output(&format!(
|
||||
"PASTEORIG: {} object(s) pasted at original coordinates.",
|
||||
count
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// PASTEBLOCK — wrap the clipboard contents in a new block definition
|
||||
// and place one insert of it at the clipboard's original location.
|
||||
"PASTEBLOCK" => {
|
||||
if self.clipboard.is_empty() {
|
||||
self.command_line
|
||||
.push_error("PASTEBLOCK: clipboard is empty.");
|
||||
} else {
|
||||
self.push_undo_snapshot(i, "PASTEBLOCK");
|
||||
self.merge_clipboard_deps(i);
|
||||
// Recreate any block definition the clipboard's INSERTs
|
||||
// reference, so nested blocks inside the new wrapper block
|
||||
// don't render empty. (#135 / #158)
|
||||
self.merge_clipboard_blocks(i);
|
||||
// Recreate each entity's xdictionary graph (XCLIP filters)
|
||||
// and stamp the new root onto the wrapped entity, so the
|
||||
// block's nested insert keeps its clip. (#xclip-paste)
|
||||
let ext_roots = self.recreate_clipboard_ext_roots(i);
|
||||
let name = self.unique_block_name("Block");
|
||||
let base = self.clipboard_centroid;
|
||||
let mut entities = self.clipboard.clone();
|
||||
for (idx, root) in ext_roots {
|
||||
if let Some(e) = entities.get_mut(idx) {
|
||||
e.common_mut().xdictionary_handle = Some(root);
|
||||
}
|
||||
}
|
||||
match self
|
||||
.tabs[i]
|
||||
.scene
|
||||
.define_block_from_owned_entities(entities, &name, base)
|
||||
{
|
||||
Ok(()) => {
|
||||
// Block defined; now place it interactively so the
|
||||
// user picks the drop point (insertion uses the
|
||||
// clipboard centroid as the block's base). The
|
||||
// clipboard wires rubber-band under the cursor.
|
||||
self.tabs[i].scene.populate_meshes_from_document();
|
||||
self.tabs[i].dirty = true;
|
||||
let wires = self.tabs[i].scene.wires_for_entities(&self.clipboard);
|
||||
use crate::modules::insert::insert_block::InsertBlockCommand;
|
||||
let cmd = InsertBlockCommand::new_for_block(name, wires, base.as_vec3());
|
||||
self.command_line.push_info(&cmd.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(cmd));
|
||||
}
|
||||
Err(e) => self.command_line.push_error(&format!("PASTEBLOCK: {e}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
"BLOCK" => {
|
||||
let handles: Vec<_> = self.tabs[i]
|
||||
.scene
|
||||
.selected_entities()
|
||||
.into_iter()
|
||||
.map(|(h, _)| h)
|
||||
.collect();
|
||||
if handles.is_empty() {
|
||||
use crate::modules::draw::select::SelectObjectsCommand;
|
||||
let cmd = SelectObjectsCommand::new("BLOCK");
|
||||
self.command_line.push_info(&cmd.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(cmd));
|
||||
} else {
|
||||
use crate::modules::insert::create_block::CreateBlockCommand;
|
||||
let cmd = CreateBlockCommand::new(handles);
|
||||
self.command_line.push_info(&cmd.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(cmd));
|
||||
}
|
||||
}
|
||||
|
||||
"INSERT" => {
|
||||
let blocks = self.tabs[i].scene.custom_block_names();
|
||||
if blocks.is_empty() {
|
||||
self.command_line
|
||||
.push_error("No user-defined blocks found in this drawing.");
|
||||
} else {
|
||||
use crate::modules::insert::insert_block::InsertBlockCommand;
|
||||
let cmd = InsertBlockCommand::new(blocks);
|
||||
self.command_line.push_info(&cmd.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(cmd));
|
||||
}
|
||||
}
|
||||
|
||||
"XATTACH" | "XA" => {
|
||||
// Launch the file picker; XAttachPickResult will start the command.
|
||||
return Some(Task::done(Message::XAttachPick));
|
||||
}
|
||||
|
||||
cmd if cmd == "WBLOCK" || cmd == "WB" || cmd.starts_with("WBLOCK ") => {
|
||||
let arg = cmd.splitn(2, ' ').nth(1).unwrap_or("").trim();
|
||||
if arg.is_empty() {
|
||||
// No argument: use selected entities (*) if any, else ask.
|
||||
let sel: Vec<_> = self.tabs[i].scene.selected.iter().copied().collect();
|
||||
if sel.is_empty() {
|
||||
self.command_line.push_error(
|
||||
"WBLOCK Select entities first, or: WBLOCK <block name> or WBLOCK *",
|
||||
);
|
||||
} else {
|
||||
return Some(Task::done(Message::WblockSave("*".to_string())));
|
||||
}
|
||||
} else {
|
||||
return Some(Task::done(Message::WblockSave(arg.to_string())));
|
||||
}
|
||||
}
|
||||
|
||||
"XREF" | "XR" => {
|
||||
// List all xref blocks in the current drawing.
|
||||
let xrefs: Vec<String> = self.tabs[i]
|
||||
.scene
|
||||
.document
|
||||
.block_records
|
||||
.iter()
|
||||
.filter(|br| br.flags.is_xref || br.flags.is_xref_overlay)
|
||||
.map(|br| {
|
||||
format!(
|
||||
" {} — {}",
|
||||
br.name,
|
||||
if br.xref_path.is_empty() {
|
||||
"(no path)".to_string()
|
||||
} else {
|
||||
br.xref_path.clone()
|
||||
}
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
if xrefs.is_empty() {
|
||||
self.command_line
|
||||
.push_output("XREF No external references in this drawing.");
|
||||
} else {
|
||||
self.command_line.push_output("XREF External references:");
|
||||
for line in xrefs {
|
||||
self.command_line.push_output(&line);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
"XRELOAD" => {
|
||||
// Reload all xrefs for the current drawing.
|
||||
if let Some(path) = &self.tabs[i].current_path.clone() {
|
||||
if let Some(base_dir) = path.parent() {
|
||||
let (infos, _dropped) = crate::io::xref::resolve_xrefs(
|
||||
&mut self.tabs[i].scene.document,
|
||||
base_dir,
|
||||
);
|
||||
for info in &infos {
|
||||
match info.status {
|
||||
crate::io::xref::XrefStatus::Loaded => {
|
||||
self.command_line
|
||||
.push_output(&format!("XREF Reloaded \"{}\"", info.name));
|
||||
}
|
||||
crate::io::xref::XrefStatus::NotFound => {
|
||||
self.command_line.push_error(&format!(
|
||||
"XREF Not found: \"{}\" ({})",
|
||||
info.name, info.path
|
||||
));
|
||||
}
|
||||
crate::io::xref::XrefStatus::Unloaded => {
|
||||
self.command_line.push_info(&format!(
|
||||
"XREF Unloaded (skipped): \"{}\"",
|
||||
info.name
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
self.tabs[i].scene.populate_hatches_from_document();
|
||||
self.tabs[i].scene.populate_images_from_document();
|
||||
self.tabs[i].scene.populate_meshes_from_document();
|
||||
}
|
||||
} else {
|
||||
self.command_line
|
||||
.push_error("XREF Save the drawing first to resolve relative XREF paths.");
|
||||
}
|
||||
}
|
||||
|
||||
_ => return None,
|
||||
}
|
||||
Some(self.finish_dispatch(cmd))
|
||||
}
|
||||
}
|
||||
565
src/app/commands/dim.rs
Normal file
565
src/app/commands/dim.rs
Normal file
|
|
@ -0,0 +1,565 @@
|
|||
use super::*;
|
||||
|
||||
impl OpenCADStudio {
|
||||
pub(super) fn dispatch_dim(&mut self, cmd: &str, i: usize) -> Option<Task<Message>> {
|
||||
match cmd {
|
||||
"DIMALIGNED" | "DAL" => {
|
||||
use crate::modules::annotate::aligned_dim::AlignedDimensionCommand;
|
||||
let cmd = AlignedDimensionCommand::new();
|
||||
self.command_line.push_info(&cmd.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(cmd));
|
||||
}
|
||||
|
||||
"DIMDIAMETER" | "DDI" => {
|
||||
use crate::modules::annotate::diameter_dim::DiameterDimensionCommand;
|
||||
let cmd = DiameterDimensionCommand::new();
|
||||
self.command_line.push_info(&cmd.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(cmd));
|
||||
}
|
||||
|
||||
"DIMLINEAR" => {
|
||||
use crate::modules::annotate::linear_dim::LinearDimensionCommand;
|
||||
let new_cmd = LinearDimensionCommand::new();
|
||||
self.command_line.push_info(&new_cmd.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(new_cmd));
|
||||
}
|
||||
|
||||
"DIMRADIUS" => {
|
||||
use crate::modules::annotate::radius_dim::RadiusDimensionCommand;
|
||||
let new_cmd = RadiusDimensionCommand::new();
|
||||
self.command_line.push_info(&new_cmd.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(new_cmd));
|
||||
}
|
||||
|
||||
"DIMANGULAR" => {
|
||||
use crate::modules::annotate::angular_dim::AngularDimensionCommand;
|
||||
let new_cmd = AngularDimensionCommand::new();
|
||||
self.command_line.push_info(&new_cmd.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(new_cmd));
|
||||
}
|
||||
|
||||
"DIMORDINATE" | "DOR" => {
|
||||
use crate::modules::annotate::ordinate_dim::OrdinateDimCommand;
|
||||
let new_cmd = OrdinateDimCommand::new();
|
||||
self.command_line.push_info(&new_cmd.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(new_cmd));
|
||||
}
|
||||
|
||||
"LEADER" | "LE" => {
|
||||
use crate::modules::annotate::leader_cmd::LeaderCommand;
|
||||
let new_cmd = LeaderCommand::new();
|
||||
self.command_line.push_info(&new_cmd.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(new_cmd));
|
||||
}
|
||||
|
||||
"MLEADER" | "MLD" => {
|
||||
use crate::modules::annotate::mleader_cmd::MLeaderCommand;
|
||||
let new_cmd = MLeaderCommand::new();
|
||||
self.command_line.push_info(&new_cmd.prompt());
|
||||
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)
|
||||
{
|
||||
let doc = &self.tabs[i].scene.document;
|
||||
let dimdli = doc
|
||||
.dim_styles
|
||||
.iter()
|
||||
.find(|s| {
|
||||
s.name
|
||||
.eq_ignore_ascii_case(&doc.header.current_dimstyle_name)
|
||||
})
|
||||
.map(|s| s.dimdli as f32)
|
||||
.unwrap_or(1.5);
|
||||
DimBaselineCommand::from_base(p1, p2, dp, rot, dimdli)
|
||||
} else {
|
||||
DimBaselineCommand::new()
|
||||
};
|
||||
self.command_line.push_info(&cmd.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(cmd));
|
||||
}
|
||||
|
||||
"QDIM" => {
|
||||
use crate::modules::annotate::qdim::QdimCommand;
|
||||
let cmd = QdimCommand::new();
|
||||
self.command_line.push_info(&cmd.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(cmd));
|
||||
}
|
||||
|
||||
"DIMEDIT" | "DED" => {
|
||||
use crate::modules::annotate::dimedit::DimEditCommand;
|
||||
let cmd = DimEditCommand::new();
|
||||
self.command_line.push_info(&cmd.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(cmd));
|
||||
}
|
||||
|
||||
"DIMTEDIT" | "DIMTED" => {
|
||||
use crate::modules::annotate::dimtedit::DimTeditCommand;
|
||||
let cmd = DimTeditCommand::new();
|
||||
self.command_line.push_info(&cmd.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(cmd));
|
||||
}
|
||||
|
||||
"DIMBREAK" | "DBR" => {
|
||||
use crate::modules::annotate::dimbreak::DimBreakCommand;
|
||||
let cmd = DimBreakCommand::new();
|
||||
self.command_line.push_info(&cmd.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(cmd));
|
||||
}
|
||||
|
||||
"DIMSPACE" | "DSPACE" => {
|
||||
use crate::modules::annotate::dimspace::DimSpaceCommand;
|
||||
let cmd = DimSpaceCommand::new();
|
||||
self.command_line.push_info(&cmd.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(cmd));
|
||||
}
|
||||
|
||||
"DIMJOGLINE" | "DJL" => {
|
||||
use crate::modules::annotate::dimjogline::DimJogLineCommand;
|
||||
let cmd = DimJogLineCommand::new();
|
||||
self.command_line.push_info(&cmd.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(cmd));
|
||||
}
|
||||
|
||||
"MLEADERADD" | "MLA" => {
|
||||
use crate::modules::annotate::mleader_edit::MLeaderAddCommand;
|
||||
let cmd = MLeaderAddCommand::new();
|
||||
self.command_line.push_info(&cmd.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(cmd));
|
||||
}
|
||||
|
||||
"MLEADERREMOVE" | "MLR" => {
|
||||
use crate::modules::annotate::mleader_edit::MLeaderRemoveCommand;
|
||||
let cmd = MLeaderRemoveCommand::new();
|
||||
self.command_line.push_info(&cmd.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(cmd));
|
||||
}
|
||||
|
||||
"MLEADERALIGN" | "MLAL" => {
|
||||
use crate::modules::annotate::mleader_edit::MLeaderAlignCommand;
|
||||
let cmd = MLeaderAlignCommand::new();
|
||||
self.command_line.push_info(&cmd.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(cmd));
|
||||
}
|
||||
|
||||
"MLEADERCOLLECT" | "MLC" => {
|
||||
use crate::modules::annotate::mleader_edit::MLeaderCollectCommand;
|
||||
let cmd = MLeaderCollectCommand::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");
|
||||
}
|
||||
|
||||
"ZOOM IN" | "ZI" => {
|
||||
self.tabs[i].scene.zoom_camera(1.0 / 1.5);
|
||||
self.command_line.push_output("Zoom In");
|
||||
}
|
||||
|
||||
"ZOOM OUT" | "ZO" => {
|
||||
self.tabs[i].scene.zoom_camera(1.5);
|
||||
self.command_line.push_output("Zoom Out");
|
||||
}
|
||||
|
||||
// ZOOM ALL — fit all entities (same as EXTENTS for now)
|
||||
"ZOOM ALL" | "ZOOM A" | "ZA" => {
|
||||
self.tabs[i].scene.fit_all();
|
||||
self.command_line.push_output("Zoom All");
|
||||
}
|
||||
|
||||
// ZOOM SCALE — set zoom factor (e.g. "ZOOM SCALE 2" or "ZS 0.5")
|
||||
cmd if cmd.starts_with("ZOOM SCALE ") || cmd.starts_with("ZS ") => {
|
||||
let rest = cmd
|
||||
.split_once(' ')
|
||||
.and_then(|(_, r)| r.split_once(' ').map(|(_, v)| v).or(Some(r)))
|
||||
.unwrap_or("1");
|
||||
if let Ok(factor) = rest.trim().parse::<f32>() {
|
||||
if factor > 0.0 {
|
||||
self.tabs[i].scene.zoom_camera(1.0 / factor);
|
||||
self.command_line
|
||||
.push_output(&format!("Zoom Scale ×{factor:.3}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
"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();
|
||||
self.command_line.push_info(&new_cmd.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(new_cmd));
|
||||
}
|
||||
|
||||
"STRETCH" | "SS" => {
|
||||
let handles: Vec<_> = self.tabs[i]
|
||||
.scene
|
||||
.selected_entities()
|
||||
.into_iter()
|
||||
.map(|(h, _)| h)
|
||||
.collect();
|
||||
if handles.is_empty() {
|
||||
use crate::modules::draw::select::SelectObjectsCommand;
|
||||
let cmd = SelectObjectsCommand::new("STRETCH");
|
||||
self.command_line.push_info(&cmd.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(cmd));
|
||||
} else {
|
||||
use crate::modules::draw::modify::stretch::StretchCommand;
|
||||
let wires = self.tabs[i].scene.wire_models_for(&handles);
|
||||
let new_cmd = StretchCommand::new(handles, wires);
|
||||
self.command_line.push_info(&new_cmd.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(new_cmd));
|
||||
}
|
||||
}
|
||||
|
||||
"FILLET" | "F" => {
|
||||
use crate::modules::draw::modify::fillet::FilletCommand;
|
||||
let entities: Vec<_> = self.tabs[i]
|
||||
.scene
|
||||
.entity_wires()
|
||||
.iter()
|
||||
.filter_map(|w| {
|
||||
let h = Scene::handle_from_wire_name(&w.name)?;
|
||||
self.tabs[i]
|
||||
.scene
|
||||
.document
|
||||
.get_entity(h)
|
||||
.cloned()
|
||||
.map(|e| (h, e))
|
||||
})
|
||||
.collect();
|
||||
let all_entities: Vec<_> = entities.into_iter().map(|(_, e)| e).collect();
|
||||
let new_cmd = FilletCommand::new(
|
||||
crate::modules::draw::defaults::get_fillet_radius(),
|
||||
all_entities,
|
||||
);
|
||||
self.command_line.push_info(&new_cmd.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(new_cmd));
|
||||
}
|
||||
|
||||
"ARRAY" | "AR" | "ARRAYRECT" => {
|
||||
let handles: Vec<_> = self.tabs[i]
|
||||
.scene
|
||||
.selected_entities()
|
||||
.into_iter()
|
||||
.map(|(h, _)| h)
|
||||
.collect();
|
||||
if handles.is_empty() {
|
||||
use crate::modules::draw::select::SelectObjectsCommand;
|
||||
let cmd = SelectObjectsCommand::new("ARRAYRECT");
|
||||
self.command_line.push_info(&cmd.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(cmd));
|
||||
} else {
|
||||
use crate::modules::draw::modify::array::ArrayRectCommand;
|
||||
let wires = self.tabs[i].scene.wire_models_for(&handles);
|
||||
let new_cmd = ArrayRectCommand::new(handles, wires);
|
||||
self.command_line.push_info(&new_cmd.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(new_cmd));
|
||||
}
|
||||
}
|
||||
|
||||
"ARRAYPOLAR" => {
|
||||
let handles: Vec<_> = self.tabs[i]
|
||||
.scene
|
||||
.selected_entities()
|
||||
.into_iter()
|
||||
.map(|(h, _)| h)
|
||||
.collect();
|
||||
if handles.is_empty() {
|
||||
use crate::modules::draw::select::SelectObjectsCommand;
|
||||
let cmd = SelectObjectsCommand::new("ARRAYPOLAR");
|
||||
self.command_line.push_info(&cmd.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(cmd));
|
||||
} else {
|
||||
use crate::modules::draw::modify::array::ArrayPolarCommand;
|
||||
let wires = self.tabs[i].scene.wire_models_for(&handles);
|
||||
let new_cmd = ArrayPolarCommand::new(handles, wires);
|
||||
self.command_line.push_info(&new_cmd.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(new_cmd));
|
||||
}
|
||||
}
|
||||
|
||||
"ARRAYPATH" => {
|
||||
let handles: Vec<_> = self.tabs[i]
|
||||
.scene
|
||||
.selected_entities()
|
||||
.into_iter()
|
||||
.map(|(h, _)| h)
|
||||
.collect();
|
||||
if handles.is_empty() {
|
||||
use crate::modules::draw::select::SelectObjectsCommand;
|
||||
let cmd = SelectObjectsCommand::new("ARRAYPATH");
|
||||
self.command_line.push_info(&cmd.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(cmd));
|
||||
} else {
|
||||
use crate::modules::draw::modify::array::ArrayPathCommand;
|
||||
let wires = self.tabs[i].scene.wire_models_for(&handles);
|
||||
let all_entities: Vec<_> = self.tabs[i]
|
||||
.scene
|
||||
.entity_wires()
|
||||
.iter()
|
||||
.filter_map(|w| {
|
||||
let h = Scene::handle_from_wire_name(&w.name)?;
|
||||
self.tabs[i].scene.document.get_entity(h).cloned()
|
||||
})
|
||||
.collect();
|
||||
let new_cmd = ArrayPathCommand::new(handles, wires, all_entities);
|
||||
self.command_line.push_info(&new_cmd.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(new_cmd));
|
||||
}
|
||||
}
|
||||
|
||||
"ARRAY3D" | "3DARRAY" => {
|
||||
let handles: Vec<_> = self.tabs[i]
|
||||
.scene
|
||||
.selected_entities()
|
||||
.into_iter()
|
||||
.map(|(h, _)| h)
|
||||
.collect();
|
||||
if handles.is_empty() {
|
||||
use crate::modules::draw::select::SelectObjectsCommand;
|
||||
let cmd = SelectObjectsCommand::new("ARRAY3D");
|
||||
self.command_line.push_info(&cmd.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(cmd));
|
||||
} else {
|
||||
use crate::modules::draw::modify::array::Array3DCommand;
|
||||
let new_cmd = Array3DCommand::new(handles);
|
||||
self.command_line.push_info(&new_cmd.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(new_cmd));
|
||||
}
|
||||
}
|
||||
|
||||
"CHAMFER" | "CHA" => {
|
||||
use crate::modules::draw::modify::fillet::ChamferCommand;
|
||||
let entities: Vec<_> = self.tabs[i]
|
||||
.scene
|
||||
.entity_wires()
|
||||
.iter()
|
||||
.filter_map(|w| {
|
||||
let h = Scene::handle_from_wire_name(&w.name)?;
|
||||
self.tabs[i]
|
||||
.scene
|
||||
.document
|
||||
.get_entity(h)
|
||||
.cloned()
|
||||
.map(|e| (h, e))
|
||||
})
|
||||
.collect();
|
||||
let all_entities: Vec<_> = entities.into_iter().map(|(_, e)| e).collect();
|
||||
let new_cmd = ChamferCommand::new(
|
||||
crate::modules::draw::defaults::get_chamfer_dist1(),
|
||||
all_entities,
|
||||
);
|
||||
self.command_line.push_info(&new_cmd.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(new_cmd));
|
||||
}
|
||||
|
||||
"EXPLODE" | "X" => {
|
||||
use crate::modules::draw::modify::explode::explode_entity;
|
||||
let entities: Vec<_> = self.tabs[i].scene.selected_entities().into_iter().collect();
|
||||
if entities.is_empty() {
|
||||
use crate::modules::draw::select::SelectObjectsCommand;
|
||||
let cmd = SelectObjectsCommand::new("EXPLODE");
|
||||
self.command_line.push_info(&cmd.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(cmd));
|
||||
} else {
|
||||
let replacements: Vec<(acadrust::Handle, Vec<acadrust::EntityType>)> = entities
|
||||
.iter()
|
||||
.filter_map(|(h, e)| {
|
||||
let pieces = explode_entity(e, &self.tabs[i].scene.document);
|
||||
if pieces.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some((*h, pieces))
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let exploded = replacements.len();
|
||||
if exploded > 0 {
|
||||
self.push_undo_snapshot(i, "EXPLODE");
|
||||
}
|
||||
for (handle, pieces) in replacements {
|
||||
self.tabs[i].scene.erase_entities(&[handle]);
|
||||
for piece in pieces {
|
||||
self.tabs[i].scene.add_entity(piece);
|
||||
}
|
||||
}
|
||||
if exploded > 0 {
|
||||
self.tabs[i].dirty = true;
|
||||
self.refresh_properties();
|
||||
self.command_line
|
||||
.push_output(&format!("{exploded} object(s) exploded."));
|
||||
} else {
|
||||
self.command_line
|
||||
.push_info("EXPLODE: no explodable objects selected.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
"OFFSET" | "O" => {
|
||||
use crate::modules::draw::modify::offset::OffsetCommand;
|
||||
let all_entities: Vec<_> = self.tabs[i]
|
||||
.scene
|
||||
.entity_wires()
|
||||
.iter()
|
||||
.filter_map(|w| {
|
||||
let h = Scene::handle_from_wire_name(&w.name)?;
|
||||
self.tabs[i].scene.document.get_entity(h).cloned()
|
||||
})
|
||||
.collect();
|
||||
let new_cmd = OffsetCommand::new(all_entities);
|
||||
self.command_line.push_info(&new_cmd.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(new_cmd));
|
||||
}
|
||||
|
||||
"TRIM" | "TR" => {
|
||||
use crate::modules::draw::modify::trim::TrimCommand;
|
||||
let entities: Vec<_> = self.tabs[i]
|
||||
.scene
|
||||
.entity_wires()
|
||||
.iter()
|
||||
.filter_map(|w| {
|
||||
let h = Scene::handle_from_wire_name(&w.name)?;
|
||||
self.tabs[i]
|
||||
.scene
|
||||
.document
|
||||
.get_entity(h)
|
||||
.cloned()
|
||||
.map(|e| (h, e))
|
||||
})
|
||||
.collect();
|
||||
let all_entities: Vec<_> = entities.into_iter().map(|(_, e)| e).collect();
|
||||
let new_cmd = TrimCommand::new(all_entities);
|
||||
self.command_line.push_info(&new_cmd.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(new_cmd));
|
||||
}
|
||||
|
||||
"EXTEND" | "EX" => {
|
||||
use crate::modules::draw::modify::trim::ExtendCommand;
|
||||
let entities: Vec<_> = self.tabs[i]
|
||||
.scene
|
||||
.entity_wires()
|
||||
.iter()
|
||||
.filter_map(|w| {
|
||||
let h = Scene::handle_from_wire_name(&w.name)?;
|
||||
self.tabs[i]
|
||||
.scene
|
||||
.document
|
||||
.get_entity(h)
|
||||
.cloned()
|
||||
.map(|e| (h, e))
|
||||
})
|
||||
.collect();
|
||||
let all_entities: Vec<_> = entities.into_iter().map(|(_, e)| e).collect();
|
||||
let new_cmd = ExtendCommand::new(all_entities);
|
||||
self.command_line.push_info(&new_cmd.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(new_cmd));
|
||||
}
|
||||
|
||||
_ => return None,
|
||||
}
|
||||
Some(self.finish_dispatch(cmd))
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
}
|
||||
491
src/app/commands/display.rs
Normal file
491
src/app/commands/display.rs
Normal file
|
|
@ -0,0 +1,491 @@
|
|||
use super::*;
|
||||
|
||||
impl OpenCADStudio {
|
||||
pub(super) fn dispatch_display(&mut self, cmd: &str, i: usize) -> Option<Task<Message>> {
|
||||
match cmd {
|
||||
// ── 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.");
|
||||
}
|
||||
|
||||
// Interactive pan: left-drag pans the view until Esc. The only pan
|
||||
// path when there is no middle mouse button (trackpad / web).
|
||||
"PAN" | "P" => {
|
||||
self.tabs[i].pan_mode = true;
|
||||
self.command_line
|
||||
.push_output("PAN: drag with the left mouse button. Press Esc to exit.");
|
||||
}
|
||||
|
||||
// ── 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";
|
||||
// Update model-space icon flag.
|
||||
self.show_ucs_icon = visible;
|
||||
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) + model space."
|
||||
));
|
||||
}
|
||||
"" => {
|
||||
// Bare UCSICON toggles visibility.
|
||||
self.push_undo_snapshot(i, "UCSICON");
|
||||
let visible = !self.show_ucs_icon;
|
||||
self.show_ucs_icon = visible;
|
||||
for entity in self.tabs[i].scene.document.entities_mut() {
|
||||
if let acadrust::EntityType::Viewport(vp) = entity {
|
||||
vp.status.ucs_icon_visible = visible;
|
||||
}
|
||||
}
|
||||
self.tabs[i].dirty = true;
|
||||
let state = if visible { "ON" } else { "OFF" };
|
||||
self.command_line.push_output(&format!("UCSICON {state}"));
|
||||
}
|
||||
_ => {
|
||||
self.command_line
|
||||
.push_info("Usage: UCSICON ON | OFF | NOORIGIN | ORIGIN");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── NAVVCUBE — toggle ViewCube visibility ────────────────────────────
|
||||
"NAVVCUBE" => {
|
||||
return Some(Task::done(Message::ToggleViewCube));
|
||||
}
|
||||
|
||||
// ── PROPERTIES — toggle Properties panel visibility ──────────────────
|
||||
"PROPERTIES" | "PR" | "PROPS" => {
|
||||
return Some(Task::done(Message::ToggleProperties));
|
||||
}
|
||||
|
||||
// ── FILETAB — toggle file/document tabs ──────────────────────────────
|
||||
"FILETAB" => {
|
||||
return Some(Task::done(Message::ToggleFileTabs));
|
||||
}
|
||||
|
||||
// ── LAYOUTTAB — toggle layout/paper-space tabs ───────────────────────
|
||||
"LAYOUTTAB" => {
|
||||
return Some(Task::done(Message::ToggleLayoutTabs));
|
||||
}
|
||||
|
||||
// ── TOOLPALETTES — not yet implemented ───────────────────────────────
|
||||
"TOOLPALETTES" | "TP" => {
|
||||
self.command_line
|
||||
.push_info("TOOLPALETTES: Tool Palettes not yet implemented.");
|
||||
}
|
||||
|
||||
// ── SHEETSET — not yet implemented ───────────────────────────────────
|
||||
"SHEETSET" | "SSM" => {
|
||||
self.command_line
|
||||
.push_info("SHEETSET: Sheet Set Manager not yet implemented.");
|
||||
}
|
||||
|
||||
// ── 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("OpenCADStudio");
|
||||
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]");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// BOX / SPHERE / CYLINDER / CONE / WEDGE / TORUS are handled by the
|
||||
// Model-tab primitive command above (with truck boolean caching).
|
||||
|
||||
// ── EXTRUDE ────────────────────────────────────────────────────
|
||||
"EXTRUDE" | "EXT" => {
|
||||
use crate::modules::insert::solid3d_cmds::ExtrudeCommand;
|
||||
// If a single entity is already selected, skip the pick step.
|
||||
let selected: Vec<_> = self.tabs[i].scene.selected_entities().into_iter().collect();
|
||||
let color = self.tabs[i].scene.layer_color(&self.tabs[i].active_layer);
|
||||
if selected.len() == 1 {
|
||||
let handle = selected[0].0;
|
||||
let mut cmd = ExtrudeCommand::new(color);
|
||||
cmd.on_entity_pick(handle, glam::DVec3::ZERO);
|
||||
self.command_line.push_info(&cmd.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(cmd));
|
||||
} else {
|
||||
let cmd = ExtrudeCommand::new(color);
|
||||
self.command_line.push_info(&cmd.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(cmd));
|
||||
}
|
||||
}
|
||||
|
||||
// ── REVOLVE ────────────────────────────────────────────────────
|
||||
"REVOLVE" | "REV" => {
|
||||
use crate::modules::insert::solid3d_cmds::RevolveCommand;
|
||||
let color = self.tabs[i].scene.layer_color(&self.tabs[i].active_layer);
|
||||
let cmd = RevolveCommand::new(color);
|
||||
self.command_line.push_info(&cmd.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(cmd));
|
||||
}
|
||||
|
||||
// ── SWEEP ──────────────────────────────────────────────────────
|
||||
"SWEEP" => {
|
||||
use crate::modules::insert::solid3d_cmds::SweepCommand;
|
||||
let color = self.tabs[i].scene.layer_color(&self.tabs[i].active_layer);
|
||||
let cmd = SweepCommand::new(color);
|
||||
self.command_line.push_info(&cmd.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(cmd));
|
||||
}
|
||||
|
||||
// ── LOFT ───────────────────────────────────────────────────────
|
||||
"LOFT" => {
|
||||
use crate::modules::insert::solid3d_cmds::LoftCommand;
|
||||
let color = self.tabs[i].scene.layer_color(&self.tabs[i].active_layer);
|
||||
let cmd = LoftCommand::new(color);
|
||||
self.command_line.push_info(&cmd.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(cmd));
|
||||
}
|
||||
|
||||
// ── OBJ import ───────────────────────────────────────────────
|
||||
"IMPORTOBJ" | "OBJIMPORT" => {
|
||||
return Some(Task::done(Message::ObjImport));
|
||||
}
|
||||
|
||||
// ── STL export ────────────────────────────────────────────────
|
||||
"STLOUT" | "EXPORTSTL" => {
|
||||
return Some(Task::done(Message::StlExport));
|
||||
}
|
||||
|
||||
// STEPOUT — export 3D meshes to STEP AP203 format
|
||||
"STEPOUT" | "EXPORTSTEP" | "STPOUT" => {
|
||||
return Some(Task::done(Message::StepExport));
|
||||
}
|
||||
|
||||
// ── Plot Style Editor GUI ─────────────────────────────────────
|
||||
"PLOTSTYLEPANEL" | "PLOTSTYLEEDITOR" | "STYLESMANAGER" => {
|
||||
return Some(Task::done(Message::PlotStylePanelOpen));
|
||||
}
|
||||
|
||||
// ── Plot / Page Setup ──────────────────────────────────────────
|
||||
"PLOT" | "EXPORT" => {
|
||||
return Some(Task::done(Message::PlotExport));
|
||||
}
|
||||
// PRINT — send current layout to the system default printer.
|
||||
"PRINT" => {
|
||||
return Some(Task::done(Message::PrintToPrinter));
|
||||
}
|
||||
// PLOTSTYLE — load or clear CTB/STB plot style table
|
||||
cmd if cmd == "PLOTSTYLE" || cmd.starts_with("PLOTSTYLE ") => {
|
||||
let sub = cmd
|
||||
.split_once(' ')
|
||||
.map(|(_, r)| r.trim().to_uppercase())
|
||||
.unwrap_or_default();
|
||||
match sub.as_str() {
|
||||
"CLEAR" | "NONE" => {
|
||||
return Some(Task::done(Message::PlotStyleClear));
|
||||
}
|
||||
"" | "LOAD" => {
|
||||
let active = self
|
||||
.active_plot_style
|
||||
.as_ref()
|
||||
.map(|t| format!("Active: {}", t.name))
|
||||
.unwrap_or_else(|| "No plot style loaded.".into());
|
||||
self.command_line.push_info(&active);
|
||||
return Some(Task::done(Message::PlotStyleLoad));
|
||||
}
|
||||
"?" | "STATUS" => {
|
||||
let msg = self
|
||||
.active_plot_style
|
||||
.as_ref()
|
||||
.map(|t| {
|
||||
format!(
|
||||
"Plot style: {} ({} color overrides)",
|
||||
t.name,
|
||||
t.aci_entries.iter().filter(|e| e.color.is_some()).count()
|
||||
)
|
||||
})
|
||||
.unwrap_or_else(|| "No plot style table loaded.".into());
|
||||
self.command_line.push_output(&msg);
|
||||
}
|
||||
_ => {
|
||||
self.command_line
|
||||
.push_error("Usage: PLOTSTYLE [LOAD | CLEAR | STATUS]");
|
||||
}
|
||||
}
|
||||
}
|
||||
// UNDERLAY — edit properties of selected PDF/DWF/DGN underlay entities.
|
||||
// Usage:
|
||||
// UNDERLAY FADE <0-80>
|
||||
// UNDERLAY CONTRAST <0-100>
|
||||
// UNDERLAY ON | OFF
|
||||
// UNDERLAY CLIP ON | OFF
|
||||
// UNDERLAY MONO ON | OFF
|
||||
cmd if cmd == "UNDERLAY" || cmd.starts_with("UNDERLAY ") => {
|
||||
let sub = cmd
|
||||
.split_once(' ')
|
||||
.map(|(_, r)| r.trim().to_uppercase())
|
||||
.unwrap_or_default();
|
||||
let handles: Vec<acadrust::Handle> = self.tabs[i]
|
||||
.scene
|
||||
.selected_entities()
|
||||
.iter()
|
||||
.map(|(h, _)| *h)
|
||||
.collect();
|
||||
if handles.is_empty() {
|
||||
self.command_line
|
||||
.push_error("UNDERLAY: select underlay entities first.");
|
||||
} else {
|
||||
let parts: Vec<&str> = sub.splitn(2, char::is_whitespace).collect();
|
||||
let action = parts.first().copied().unwrap_or("");
|
||||
let arg = parts.get(1).copied().unwrap_or("").trim();
|
||||
let mut changed = 0usize;
|
||||
self.push_undo_snapshot(i, "UNDERLAY");
|
||||
for h in &handles {
|
||||
if let Some(acadrust::EntityType::Underlay(ul)) = self.tabs[i]
|
||||
.scene
|
||||
.document
|
||||
.entities_mut()
|
||||
.find(|e| e.common().handle == *h)
|
||||
{
|
||||
match action {
|
||||
"FADE" => {
|
||||
if let Ok(v) = arg.parse::<u8>() {
|
||||
ul.set_fade(v);
|
||||
changed += 1;
|
||||
}
|
||||
}
|
||||
"CONTRAST" => {
|
||||
if let Ok(v) = arg.parse::<u8>() {
|
||||
ul.set_contrast(v);
|
||||
changed += 1;
|
||||
}
|
||||
}
|
||||
"ON" => {
|
||||
ul.set_on(true);
|
||||
changed += 1;
|
||||
}
|
||||
"OFF" => {
|
||||
ul.set_on(false);
|
||||
changed += 1;
|
||||
}
|
||||
"CLIP" => match arg {
|
||||
"ON" => {
|
||||
ul.flags |=
|
||||
acadrust::entities::UnderlayDisplayFlags::CLIPPING;
|
||||
changed += 1;
|
||||
}
|
||||
"OFF" => {
|
||||
ul.clear_clip();
|
||||
changed += 1;
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
"MONO" => match arg {
|
||||
"ON" => {
|
||||
ul.set_monochrome(true);
|
||||
changed += 1;
|
||||
}
|
||||
"OFF" => {
|
||||
ul.set_monochrome(false);
|
||||
changed += 1;
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
_ => {
|
||||
// No sub-command: print status.
|
||||
self.command_line.push_output(&format!(
|
||||
"Underlay {:x}: fade={}, contrast={}, on={}, clip={}, mono={}",
|
||||
h.value(),
|
||||
ul.fade,
|
||||
ul.contrast,
|
||||
ul.is_on(),
|
||||
ul.is_clipping(),
|
||||
ul.is_monochrome(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if changed > 0 {
|
||||
self.tabs[i].dirty = true;
|
||||
self.command_line
|
||||
.push_info(&format!("Updated {changed} underlay(s)."));
|
||||
} else if !action.is_empty() {
|
||||
self.command_line.push_error(
|
||||
"Usage: UNDERLAY [FADE <n>|CONTRAST <n>|ON|OFF|CLIP ON|OFF|MONO ON|OFF]"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
"PAGESETUP" => {
|
||||
if self.tabs[i].scene.current_layout == "Model" {
|
||||
self.command_line
|
||||
.push_error("PAGESETUP: switch to a paper space layout first.");
|
||||
} else {
|
||||
return Some(Task::done(Message::PageSetupOpen));
|
||||
}
|
||||
}
|
||||
_ => return None,
|
||||
}
|
||||
Some(self.finish_dispatch(cmd))
|
||||
}
|
||||
}
|
||||
673
src/app/commands/draw.rs
Normal file
673
src/app/commands/draw.rs
Normal file
|
|
@ -0,0 +1,673 @@
|
|||
use super::*;
|
||||
|
||||
impl OpenCADStudio {
|
||||
pub(super) fn dispatch_draw(&mut self, cmd: &str, i: usize) -> Option<Task<Message>> {
|
||||
match cmd {
|
||||
// ── Draw commands ──────────────────────────────────────────────
|
||||
"LINE" | "L" => {
|
||||
use crate::modules::draw::draw::line::LineCommand;
|
||||
let new_cmd = LineCommand::new();
|
||||
self.command_line.push_info(&new_cmd.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(new_cmd));
|
||||
}
|
||||
|
||||
"MLINE" | "ML" => {
|
||||
use crate::modules::draw::draw::mline::MlineCommand;
|
||||
let style = self.tabs[i].scene.document.header.multiline_style.clone();
|
||||
let cmd_obj = MlineCommand::with_style(style);
|
||||
self.command_line.push_info(&cmd_obj.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(cmd_obj));
|
||||
}
|
||||
|
||||
cmd if cmd == "WIPEOUT" || cmd == "WO" || cmd.starts_with("WIPEOUT ") => {
|
||||
use crate::modules::draw::draw::wipeout::WipeoutCommand;
|
||||
let args = cmd
|
||||
.split_once(' ')
|
||||
.map(|(_, r)| r.trim().to_uppercase())
|
||||
.unwrap_or_default();
|
||||
let wo_cmd = if args == "P" || args == "POLYGONAL" {
|
||||
WipeoutCommand::new_polygonal()
|
||||
} else {
|
||||
WipeoutCommand::new_rectangular()
|
||||
};
|
||||
self.command_line.push_info(&wo_cmd.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(wo_cmd));
|
||||
}
|
||||
|
||||
cmd if cmd == "IMAGE" || cmd == "IMAGEATTACH" || cmd == "IM" => {
|
||||
return Some(Task::done(Message::ImagePick));
|
||||
}
|
||||
|
||||
"REVCLOUD" => {
|
||||
use crate::modules::draw::draw::revcloud::RevCloudCommand;
|
||||
let cmd = RevCloudCommand::new();
|
||||
self.command_line.push_info(&cmd.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(cmd));
|
||||
}
|
||||
|
||||
"ATTDEF" => {
|
||||
use crate::modules::draw::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::draw::draw::donut::DonutCommand;
|
||||
let cmd = DonutCommand::new();
|
||||
self.command_line.push_info(&cmd.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(cmd));
|
||||
}
|
||||
|
||||
"CIRCLE" | "C" => {
|
||||
use crate::modules::draw::draw::circle::CircleCommand;
|
||||
let new_cmd = CircleCommand::new();
|
||||
self.command_line.push_info(&new_cmd.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(new_cmd));
|
||||
}
|
||||
"CIRCLE_CD" => {
|
||||
use crate::modules::draw::draw::circle::CircleCDCommand;
|
||||
let new_cmd = CircleCDCommand::new();
|
||||
self.command_line.push_info(&new_cmd.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(new_cmd));
|
||||
}
|
||||
"CIRCLE_2P" => {
|
||||
use crate::modules::draw::draw::circle::Circle2PCommand;
|
||||
let new_cmd = Circle2PCommand::new();
|
||||
self.command_line.push_info(&new_cmd.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(new_cmd));
|
||||
}
|
||||
"CIRCLE_3P" => {
|
||||
use crate::modules::draw::draw::circle::Circle3PCommand;
|
||||
let new_cmd = Circle3PCommand::new();
|
||||
self.command_line.push_info(&new_cmd.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(new_cmd));
|
||||
}
|
||||
"CIRCLE_TTR" => {
|
||||
use crate::modules::draw::draw::circle::CircleTTRCommand;
|
||||
let new_cmd = CircleTTRCommand::new();
|
||||
self.command_line.push_info(&new_cmd.prompt());
|
||||
self.pre_cmd_tangent = Some(self.snapper.is_on(crate::snap::SnapType::Tangent));
|
||||
self.snapper.enabled.insert(crate::snap::SnapType::Tangent);
|
||||
self.tabs[i].active_cmd = Some(Box::new(new_cmd));
|
||||
}
|
||||
"CIRCLE_TTT" => {
|
||||
use crate::modules::draw::draw::circle::CircleTTTCommand;
|
||||
let new_cmd = CircleTTTCommand::new();
|
||||
self.command_line.push_info(&new_cmd.prompt());
|
||||
self.pre_cmd_tangent = Some(self.snapper.is_on(crate::snap::SnapType::Tangent));
|
||||
self.snapper.enabled.insert(crate::snap::SnapType::Tangent);
|
||||
self.tabs[i].active_cmd = Some(Box::new(new_cmd));
|
||||
}
|
||||
|
||||
"ARC" | "A" => {
|
||||
use crate::modules::draw::draw::arc::ArcCommand;
|
||||
let new_cmd = ArcCommand::new();
|
||||
self.command_line.push_info(&new_cmd.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(new_cmd));
|
||||
}
|
||||
"ARC_3P" => {
|
||||
use crate::modules::draw::draw::arc::Arc3PCommand;
|
||||
let new_cmd = Arc3PCommand::new();
|
||||
self.command_line.push_info(&new_cmd.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(new_cmd));
|
||||
}
|
||||
"ARC_SCE" => {
|
||||
use crate::modules::draw::draw::arc::ArcSCECommand;
|
||||
let new_cmd = ArcSCECommand::new();
|
||||
self.command_line.push_info(&new_cmd.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(new_cmd));
|
||||
}
|
||||
"ARC_SCA" => {
|
||||
use crate::modules::draw::draw::arc::ArcSCACommand;
|
||||
let new_cmd = ArcSCACommand::new();
|
||||
self.command_line.push_info(&new_cmd.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(new_cmd));
|
||||
}
|
||||
"ARC_SCL" => {
|
||||
use crate::modules::draw::draw::arc::ArcSCLCommand;
|
||||
let new_cmd = ArcSCLCommand::new();
|
||||
self.command_line.push_info(&new_cmd.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(new_cmd));
|
||||
}
|
||||
"ARC_SEA" => {
|
||||
use crate::modules::draw::draw::arc::ArcSEACommand;
|
||||
let new_cmd = ArcSEACommand::new();
|
||||
self.command_line.push_info(&new_cmd.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(new_cmd));
|
||||
}
|
||||
"ARC_SER" => {
|
||||
use crate::modules::draw::draw::arc::ArcSERCommand;
|
||||
let new_cmd = ArcSERCommand::new();
|
||||
self.command_line.push_info(&new_cmd.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(new_cmd));
|
||||
}
|
||||
"ARC_SED" => {
|
||||
use crate::modules::draw::draw::arc::ArcSEDCommand;
|
||||
let new_cmd = ArcSEDCommand::new();
|
||||
self.command_line.push_info(&new_cmd.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(new_cmd));
|
||||
}
|
||||
"ARC_CSA" => {
|
||||
use crate::modules::draw::draw::arc::ArcCSACommand;
|
||||
let new_cmd = ArcCSACommand::new();
|
||||
self.command_line.push_info(&new_cmd.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(new_cmd));
|
||||
}
|
||||
"ARC_CSL" => {
|
||||
use crate::modules::draw::draw::arc::ArcCSLCommand;
|
||||
let new_cmd = ArcCSLCommand::new();
|
||||
self.command_line.push_info(&new_cmd.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(new_cmd));
|
||||
}
|
||||
|
||||
"RECT" | "RECTANG" | "REC" => {
|
||||
use crate::modules::draw::draw::shapes::RectCommand;
|
||||
let new_cmd = RectCommand::new();
|
||||
self.command_line.push_info(&new_cmd.prompt());
|
||||
if self.ortho_mode {
|
||||
self.rect_suppressed_ortho = true;
|
||||
self.ortho_mode = false;
|
||||
}
|
||||
self.tabs[i].active_cmd = Some(Box::new(new_cmd));
|
||||
}
|
||||
"RECT_ROT" => {
|
||||
use crate::modules::draw::draw::shapes::RectRotCommand;
|
||||
let new_cmd = RectRotCommand::new();
|
||||
self.command_line.push_info(&new_cmd.prompt());
|
||||
if self.ortho_mode {
|
||||
self.rect_suppressed_ortho = true;
|
||||
self.ortho_mode = false;
|
||||
}
|
||||
self.tabs[i].active_cmd = Some(Box::new(new_cmd));
|
||||
}
|
||||
"RECT_CEN" => {
|
||||
use crate::modules::draw::draw::shapes::RectCenCommand;
|
||||
let new_cmd = RectCenCommand::new();
|
||||
self.command_line.push_info(&new_cmd.prompt());
|
||||
if self.ortho_mode {
|
||||
self.rect_suppressed_ortho = true;
|
||||
self.ortho_mode = false;
|
||||
}
|
||||
self.tabs[i].active_cmd = Some(Box::new(new_cmd));
|
||||
}
|
||||
"POLY" | "POLYGON" | "POL" => {
|
||||
use crate::modules::draw::draw::shapes::PolyCommand;
|
||||
let new_cmd = PolyCommand::new();
|
||||
self.command_line.push_info(&new_cmd.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(new_cmd));
|
||||
}
|
||||
"POLY_C" => {
|
||||
use crate::modules::draw::draw::shapes::PolyCCommand;
|
||||
let new_cmd = PolyCCommand::new();
|
||||
self.command_line.push_info(&new_cmd.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(new_cmd));
|
||||
}
|
||||
"POLY_E" => {
|
||||
use crate::modules::draw::draw::shapes::PolyECommand;
|
||||
let new_cmd = PolyECommand::new();
|
||||
self.command_line.push_info(&new_cmd.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(new_cmd));
|
||||
}
|
||||
|
||||
"PLINE" | "PL" => {
|
||||
use crate::modules::draw::draw::polyline::PlineCommand;
|
||||
let new_cmd = PlineCommand::new();
|
||||
self.command_line.push_info(&new_cmd.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(new_cmd));
|
||||
}
|
||||
|
||||
// ── Modify commands ────────────────────────────────────────────
|
||||
"MOVE" | "M" => {
|
||||
let handles: Vec<_> = self.tabs[i]
|
||||
.scene
|
||||
.selected_entities()
|
||||
.into_iter()
|
||||
.map(|(h, _)| h)
|
||||
.collect();
|
||||
if handles.is_empty() {
|
||||
use crate::modules::draw::select::SelectObjectsCommand;
|
||||
let cmd = SelectObjectsCommand::new("MOVE");
|
||||
self.command_line.push_info(&cmd.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(cmd));
|
||||
} else {
|
||||
use crate::modules::draw::modify::translate::MoveCommand;
|
||||
let wires = self.tabs[i].scene.wire_models_for(&handles);
|
||||
let new_cmd = MoveCommand::new(handles, wires);
|
||||
self.command_line.push_info(&new_cmd.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(new_cmd));
|
||||
}
|
||||
}
|
||||
|
||||
"COPY" | "CO" => {
|
||||
let handles: Vec<_> = self.tabs[i]
|
||||
.scene
|
||||
.selected_entities()
|
||||
.into_iter()
|
||||
.map(|(h, _)| h)
|
||||
.collect();
|
||||
if handles.is_empty() {
|
||||
use crate::modules::draw::select::SelectObjectsCommand;
|
||||
let cmd = SelectObjectsCommand::new("COPY");
|
||||
self.command_line.push_info(&cmd.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(cmd));
|
||||
} else {
|
||||
use crate::modules::draw::modify::copy::CopyCommand;
|
||||
let wires = self.tabs[i].scene.wire_models_for(&handles);
|
||||
let new_cmd = CopyCommand::new(handles, wires);
|
||||
self.command_line.push_info(&new_cmd.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(new_cmd));
|
||||
}
|
||||
}
|
||||
|
||||
"ROTATE" | "RO" => {
|
||||
let handles: Vec<_> = self.tabs[i]
|
||||
.scene
|
||||
.selected_entities()
|
||||
.into_iter()
|
||||
.map(|(h, _)| h)
|
||||
.collect();
|
||||
if handles.is_empty() {
|
||||
use crate::modules::draw::select::SelectObjectsCommand;
|
||||
let cmd = SelectObjectsCommand::new("ROTATE");
|
||||
self.command_line.push_info(&cmd.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(cmd));
|
||||
} else {
|
||||
use crate::modules::draw::modify::rotate::RotateCommand;
|
||||
let wires = self.tabs[i].scene.wire_models_for(&handles);
|
||||
let new_cmd = RotateCommand::new(handles, wires);
|
||||
self.command_line.push_info(&new_cmd.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(new_cmd));
|
||||
}
|
||||
}
|
||||
|
||||
"TORIENT" => {
|
||||
let handles: Vec<_> = self.tabs[i]
|
||||
.scene
|
||||
.selected_entities()
|
||||
.into_iter()
|
||||
.map(|(h, _)| h)
|
||||
.collect();
|
||||
if handles.is_empty() {
|
||||
use crate::modules::draw::select::SelectObjectsCommand;
|
||||
let cmd = SelectObjectsCommand::new("TORIENT");
|
||||
self.command_line.push_info(&cmd.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(cmd));
|
||||
} else {
|
||||
use crate::modules::draw::modify::torient::TorientCommand;
|
||||
let entities: Vec<_> = handles
|
||||
.iter()
|
||||
.filter_map(|&h| self.tabs[i].scene.document.get_entity(h).cloned().map(|e| (h, e)))
|
||||
.collect();
|
||||
let cam_rot = self.tabs[i].scene.camera.borrow().rotation;
|
||||
let right = cam_rot * glam::Vec3::X;
|
||||
let view_twist = right.y.atan2(right.x) as f64;
|
||||
let new_cmd = TorientCommand::new(entities, view_twist);
|
||||
self.command_line.push_info(&new_cmd.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(new_cmd));
|
||||
}
|
||||
}
|
||||
|
||||
"POINT" | "PO" => {
|
||||
use crate::modules::draw::draw::point::PointCommand;
|
||||
let new_cmd = PointCommand::new();
|
||||
self.command_line.push_info(&new_cmd.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(new_cmd));
|
||||
}
|
||||
|
||||
"RAY" => {
|
||||
use crate::modules::draw::draw::ray::RayCommand;
|
||||
let new_cmd = RayCommand::new();
|
||||
self.command_line.push_info(&new_cmd.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(new_cmd));
|
||||
}
|
||||
|
||||
"XLINE" | "XL" | "CONSTRUCTIONLINE" => {
|
||||
use crate::modules::draw::draw::ray::XLineCommand;
|
||||
let new_cmd = XLineCommand::new();
|
||||
self.command_line.push_info(&new_cmd.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(new_cmd));
|
||||
}
|
||||
|
||||
"HATCH" | "H" => {
|
||||
use crate::modules::draw::draw::hatch::HatchCommand;
|
||||
let outlines = self.tabs[i].scene.closed_outlines();
|
||||
let new_cmd = HatchCommand::new(outlines);
|
||||
self.command_line.push_info(&new_cmd.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(new_cmd));
|
||||
}
|
||||
|
||||
"HATCHEDIT" | "HE" => {
|
||||
use crate::modules::draw::draw::hatchedit::HatcheditCommand;
|
||||
// If a single hatch is already selected, skip the pick step.
|
||||
let sel = self.tabs[i].scene.selected_entities();
|
||||
if sel.len() == 1 {
|
||||
let (h, _) = sel[0];
|
||||
if let Some(model) = self.tabs[i].scene.hatches.get(&h).cloned() {
|
||||
let cmd = HatcheditCommand::with_handle(
|
||||
h,
|
||||
model.name.clone(),
|
||||
model.scale,
|
||||
model.angle_offset,
|
||||
);
|
||||
self.command_line.push_info(&cmd.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(cmd));
|
||||
} else {
|
||||
self.command_line
|
||||
.push_error("HATCHEDIT: selected entity is not a hatch.");
|
||||
}
|
||||
} else {
|
||||
let cmd = HatcheditCommand::new();
|
||||
self.command_line.push_info(&cmd.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(cmd));
|
||||
}
|
||||
}
|
||||
|
||||
"GRADIENT" => {
|
||||
use crate::modules::draw::draw::hatch::GradientCommand;
|
||||
let outlines = self.tabs[i].scene.closed_outlines();
|
||||
let new_cmd = GradientCommand::new(outlines);
|
||||
self.command_line.push_info(&new_cmd.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(new_cmd));
|
||||
}
|
||||
|
||||
"BOUNDARY" => {
|
||||
use crate::modules::draw::draw::hatch::BoundaryCommand;
|
||||
let outlines = self.tabs[i].scene.closed_outlines();
|
||||
let new_cmd = BoundaryCommand::new(outlines);
|
||||
self.command_line.push_info(&new_cmd.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(new_cmd));
|
||||
}
|
||||
|
||||
"ELLIPSE" | "EL" => {
|
||||
use crate::modules::draw::draw::ellipse::EllipseCommand;
|
||||
let new_cmd = EllipseCommand::new();
|
||||
self.command_line.push_info(&new_cmd.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(new_cmd));
|
||||
}
|
||||
|
||||
"ELLIPSE_AXIS" => {
|
||||
use crate::modules::draw::draw::ellipse::EllipseAxisCommand;
|
||||
let new_cmd = EllipseAxisCommand::new();
|
||||
self.command_line.push_info(&new_cmd.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(new_cmd));
|
||||
}
|
||||
|
||||
"ELLIPSE_ARC" => {
|
||||
use crate::modules::draw::draw::ellipse::EllipseArcCommand;
|
||||
let new_cmd = EllipseArcCommand::new();
|
||||
self.command_line.push_info(&new_cmd.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(new_cmd));
|
||||
}
|
||||
|
||||
"SPLINE" | "SPL" => {
|
||||
use crate::modules::draw::draw::spline::SplineCommand;
|
||||
let new_cmd = SplineCommand::new();
|
||||
self.command_line.push_info(&new_cmd.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(new_cmd));
|
||||
}
|
||||
|
||||
"SCALE" | "SC" => {
|
||||
let handles: Vec<_> = self.tabs[i]
|
||||
.scene
|
||||
.selected_entities()
|
||||
.into_iter()
|
||||
.map(|(h, _)| h)
|
||||
.collect();
|
||||
if handles.is_empty() {
|
||||
use crate::modules::draw::select::SelectObjectsCommand;
|
||||
let cmd = SelectObjectsCommand::new("SCALE");
|
||||
self.command_line.push_info(&cmd.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(cmd));
|
||||
} else {
|
||||
use crate::modules::draw::modify::scale::ScaleCommand;
|
||||
let wires = self.tabs[i].scene.wire_models_for(&handles);
|
||||
let new_cmd = ScaleCommand::new(handles, wires);
|
||||
self.command_line.push_info(&new_cmd.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(new_cmd));
|
||||
}
|
||||
}
|
||||
|
||||
"MIRROR" | "MI" => {
|
||||
let handles: Vec<_> = self.tabs[i]
|
||||
.scene
|
||||
.selected_entities()
|
||||
.into_iter()
|
||||
.map(|(h, _)| h)
|
||||
.collect();
|
||||
if handles.is_empty() {
|
||||
use crate::modules::draw::select::SelectObjectsCommand;
|
||||
let cmd = SelectObjectsCommand::new("MIRROR");
|
||||
self.command_line.push_info(&cmd.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(cmd));
|
||||
} else {
|
||||
use crate::modules::draw::modify::mirror::MirrorCommand;
|
||||
let wires = self.tabs[i].scene.wire_models_for(&handles);
|
||||
let new_cmd = MirrorCommand::new(handles, wires);
|
||||
self.command_line.push_info(&new_cmd.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(new_cmd));
|
||||
}
|
||||
}
|
||||
|
||||
"ERASE" | "E" => {
|
||||
let handles: Vec<_> = self.tabs[i]
|
||||
.scene
|
||||
.selected_entities()
|
||||
.into_iter()
|
||||
.map(|(h, _)| h)
|
||||
.collect();
|
||||
if handles.is_empty() {
|
||||
use crate::modules::draw::select::SelectObjectsCommand;
|
||||
let cmd = SelectObjectsCommand::new("ERASE");
|
||||
self.command_line.push_info(&cmd.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(cmd));
|
||||
} else {
|
||||
let n = handles.len();
|
||||
self.push_undo_snapshot(i, "ERASE");
|
||||
self.tabs[i].scene.erase_entities(&handles);
|
||||
self.tabs[i].dirty = true;
|
||||
self.refresh_properties();
|
||||
self.command_line
|
||||
.push_output(&format!("{n} object(s) erased."));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Model commands (3D primitives) ─────────────────────────────
|
||||
"BOX" | "WEDGE" | "CYLINDER" | "CONE" | "SPHERE" | "TORUS" => {
|
||||
use crate::modules::model::primitive_cmd::PrimitiveCommand;
|
||||
let new_cmd = PrimitiveCommand::new(cmd);
|
||||
self.command_line.push_info(&new_cmd.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(new_cmd));
|
||||
}
|
||||
|
||||
// ── Design commands (solid booleans) ───────────────────────────
|
||||
"UNION" | "SUBTRACT" | "INTERSECT" => {
|
||||
use crate::modules::model::boolean_cmd::BoolOp;
|
||||
if let Some(op) = BoolOp::from_id(cmd) {
|
||||
return Some(self.solid_boolean(op));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Annotate commands ──────────────────────────────────────────
|
||||
"TEXT" | "T" | "DT" => {
|
||||
use crate::modules::annotate::text::TextCommand;
|
||||
let new_cmd = TextCommand::new();
|
||||
self.command_line.push_info(&new_cmd.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(new_cmd));
|
||||
}
|
||||
|
||||
"DDEDIT" | "ED" => {
|
||||
use crate::modules::annotate::ddedit::DdeditCommand;
|
||||
// A single text entity already selected opens its in-place
|
||||
// editor directly; otherwise prompt for a pick.
|
||||
let sel = self.tabs[i].scene.selected_entities();
|
||||
let editable = (sel.len() == 1).then(|| sel[0].0).filter(|h| {
|
||||
self.tabs[i].scene.document.get_entity(*h).is_some_and(|e| {
|
||||
super::super::text_inline::read_text_field(e).is_some()
|
||||
|| matches!(e, acadrust::EntityType::Leader(_))
|
||||
})
|
||||
});
|
||||
if let Some(h) = editable {
|
||||
return Some(self.begin_text_edit(h));
|
||||
}
|
||||
if sel.len() == 1 {
|
||||
self.command_line
|
||||
.push_error("DDEDIT: selected entity is not text.");
|
||||
} else {
|
||||
let cmd = DdeditCommand::new();
|
||||
self.command_line.push_info(&cmd.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(cmd));
|
||||
}
|
||||
}
|
||||
|
||||
"MTEXT" | "MT" => {
|
||||
use crate::modules::annotate::mtext::MTextCommand;
|
||||
let new_cmd = MTextCommand::new();
|
||||
self.command_line.push_info(&new_cmd.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(new_cmd));
|
||||
}
|
||||
|
||||
"TEXTEDIT" | "TEDIT" => {
|
||||
use crate::modules::annotate::textedit::TexteditCommand;
|
||||
let mode_str = if self.texteditmode { "Single" } else { "Multiple" };
|
||||
self.command_line.push_output(&format!("Current settings: Edit mode = {}", mode_str));
|
||||
let new_cmd = TexteditCommand::new(self.texteditmode);
|
||||
self.command_line.push_info(&new_cmd.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(new_cmd));
|
||||
}
|
||||
|
||||
"TEXTEDITMODE" => {
|
||||
use crate::modules::annotate::textedit::TexteditmodeCommand;
|
||||
let cmd = TexteditmodeCommand::new(self.texteditmode);
|
||||
self.command_line.push_info(&cmd.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(cmd));
|
||||
}
|
||||
|
||||
_ => return None,
|
||||
}
|
||||
Some(self.finish_dispatch(cmd))
|
||||
}
|
||||
}
|
||||
139
src/app/commands/fileops.rs
Normal file
139
src/app/commands/fileops.rs
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
use super::*;
|
||||
|
||||
impl OpenCADStudio {
|
||||
pub(super) fn dispatch_fileops(&mut self, cmd: &str, i: usize) -> Option<Task<Message>> {
|
||||
match cmd {
|
||||
"NEW" => return Some(Task::done(Message::TabNew)),
|
||||
"OPEN" => return Some(Task::done(Message::OpenFile)),
|
||||
"SAVE" | "QSAVE" => return Some(Task::done(Message::SaveFile)),
|
||||
"SAVEAS" => return Some(Task::done(Message::SaveAs)),
|
||||
"UNDO" | "U" => return Some(Task::done(Message::Undo)),
|
||||
"REDO" => return Some(Task::done(Message::Redo)),
|
||||
"CLEAR" | "CLR" => return Some(Task::done(Message::ClearScene)),
|
||||
"WIREFRAME" | "VW" => return Some(Task::done(Message::SetWireframe(true))),
|
||||
"SOLID" | "VS" => return Some(Task::done(Message::SetWireframe(false))),
|
||||
"EXIT" | "QUIT" => {
|
||||
// Funnel through the OS close path so the unsaved-changes
|
||||
// dialog runs before `iced::exit()`. Falls back to a hard
|
||||
// exit if there's no main window registered yet.
|
||||
if let Some(id) = self.main_window {
|
||||
return Some(Task::done(Message::WindowCloseRequested(id)));
|
||||
}
|
||||
return Some(iced::exit());
|
||||
}
|
||||
|
||||
// ── Frame-budget HUD (Phase 5.3) ───────────────────────────────
|
||||
// Toggle the per-rebuild wire-tessellation readout overlay.
|
||||
"PERF" => {
|
||||
self.perf_hud = !self.perf_hud;
|
||||
self.command_line.push_info(if self.perf_hud {
|
||||
"PERF HUD on — shows last wire re-tessellation cost"
|
||||
} else {
|
||||
"PERF HUD off"
|
||||
});
|
||||
return Some(Task::none());
|
||||
}
|
||||
|
||||
// ── Background color ───────────────────────────────────────────
|
||||
// Usage: BACKGROUND <r> <g> <b> (0–255 each)
|
||||
// BACKGROUND WHITE|BLACK|GRAY|DARKGRAY|LTGRAY (preset)
|
||||
// BACKGROUND RESET (restore default)
|
||||
// The chosen colour is also stored as the persisted default
|
||||
// (`default_bg_color` / `default_paper_bg_color`) so it survives
|
||||
// restarts and applies to new drawings (#188).
|
||||
cmd if cmd == "BACKGROUND" || cmd.starts_with("BACKGROUND ") => {
|
||||
let args = cmd.split_whitespace().skip(1).collect::<Vec<_>>();
|
||||
let is_paper = self.tabs[i].scene.current_layout != "Model";
|
||||
if args
|
||||
.first()
|
||||
.map(|s| s.eq_ignore_ascii_case("RESET"))
|
||||
.unwrap_or(false)
|
||||
{
|
||||
if is_paper {
|
||||
self.tabs[i].paper_bg_color = None;
|
||||
self.tabs[i].scene.paper_bg_color = [1.0, 1.0, 1.0, 1.0];
|
||||
self.default_paper_bg_color = None;
|
||||
} else {
|
||||
self.tabs[i].bg_color = None;
|
||||
self.tabs[i].scene.bg_color = [0.11, 0.11, 0.11, 1.0];
|
||||
self.default_bg_color = None;
|
||||
}
|
||||
// Wire colour adaptation (`adapt_to_bg`) reads the bg
|
||||
// at tessellation time, so the cached wires need to
|
||||
// refresh — otherwise a light→dark bg flip leaves
|
||||
// black lines invisible against the new bg. Meshes
|
||||
// bake colour into per-vertex GPU buffers at upload
|
||||
// time; `recolor_meshes` rewrites the CPU side so
|
||||
// the next epoch-driven re-upload picks up the new
|
||||
// colour.
|
||||
self.tabs[i].scene.recolor_meshes();
|
||||
self.tabs[i].scene.bump_geometry();
|
||||
self.command_line
|
||||
.push_output("Background reset to default.");
|
||||
} else if let Some(rgba) = parse_background_color(&args) {
|
||||
if is_paper {
|
||||
self.tabs[i].paper_bg_color = Some(rgba);
|
||||
self.tabs[i].scene.paper_bg_color = rgba;
|
||||
self.default_paper_bg_color = Some(rgba);
|
||||
} else {
|
||||
self.tabs[i].bg_color = Some(rgba);
|
||||
self.tabs[i].scene.bg_color = rgba;
|
||||
self.default_bg_color = Some(rgba);
|
||||
}
|
||||
self.tabs[i].scene.recolor_meshes();
|
||||
self.tabs[i].scene.bump_geometry();
|
||||
let [r, g, b, _] = rgba;
|
||||
self.command_line.push_output(&format!(
|
||||
"Background: rgb({}, {}, {})",
|
||||
(r * 255.0).round() as u8,
|
||||
(g * 255.0).round() as u8,
|
||||
(b * 255.0).round() as u8
|
||||
));
|
||||
// Persisted centrally after this message via
|
||||
// `persist_settings_if_changed()`.
|
||||
} else {
|
||||
self.command_line.push_info(
|
||||
"Usage: BACKGROUND <r> <g> <b> (0–255) | WHITE|BLACK|GRAY|DARKGRAY|LTGRAY | RESET",
|
||||
);
|
||||
}
|
||||
}
|
||||
"ORTHO" => return Some(Task::done(Message::SetProjection(true))),
|
||||
"PERSP" => return Some(Task::done(Message::SetProjection(false))),
|
||||
"LAYERS" | "LA" => return Some(Task::done(Message::ToggleLayers)),
|
||||
|
||||
_ => return None,
|
||||
}
|
||||
Some(self.finish_dispatch(cmd))
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse the argument list of the `BACKGROUND` command into an `[r,g,b,a]`
|
||||
/// colour (channels 0.0–1.0, `a` always 1.0). Accepts:
|
||||
/// * three whitespace-separated 0–255 values: `255 255 255`
|
||||
/// * a named preset: WHITE / BLACK / GRAY|GREY / DARKGRAY|DARKGREY / LTGRAY
|
||||
/// Returns `None` if the arguments don't match either form.
|
||||
fn parse_background_color(args: &[&str]) -> Option<[f32; 4]> {
|
||||
let to_rgba = |[r, g, b]: [u8; 3]| {
|
||||
[r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0, 1.0]
|
||||
};
|
||||
// Single token: a named preset.
|
||||
if args.len() == 1 {
|
||||
let preset = match args[0].to_ascii_uppercase().as_str() {
|
||||
"WHITE" => [255, 255, 255],
|
||||
"BLACK" => [0, 0, 0],
|
||||
"GRAY" | "GREY" => [128, 128, 128],
|
||||
"DARKGRAY" | "DARKGREY" | "DKGRAY" => [64, 64, 64],
|
||||
"LTGRAY" | "LIGHTGRAY" | "LIGHTGREY" => [192, 192, 192],
|
||||
_ => return None,
|
||||
};
|
||||
return Some(to_rgba(preset));
|
||||
}
|
||||
// Three separate tokens: `r g b`.
|
||||
if args.len() >= 3 {
|
||||
let r = args[0].parse::<u8>().ok()?;
|
||||
let g = args[1].parse::<u8>().ok()?;
|
||||
let b = args[2].parse::<u8>().ok()?;
|
||||
return Some(to_rgba([r, g, b]));
|
||||
}
|
||||
None
|
||||
}
|
||||
878
src/app/commands/inquiry.rs
Normal file
878
src/app/commands/inquiry.rs
Normal file
|
|
@ -0,0 +1,878 @@
|
|||
use super::*;
|
||||
|
||||
impl OpenCADStudio {
|
||||
pub(super) fn dispatch_inquiry(&mut self, cmd: &str, i: usize) -> Option<Task<Message>> {
|
||||
match cmd {
|
||||
"3DORBIT" | "3O" => {
|
||||
self.command_line
|
||||
.push_info("3D Orbit: drag with right mouse button.");
|
||||
}
|
||||
|
||||
// ── Selection utilities ───────────────────────────────────────
|
||||
"SELECTALL" | "SA" => {
|
||||
use crate::scene::Scene;
|
||||
let handles: Vec<acadrust::Handle> = self.tabs[i]
|
||||
.scene
|
||||
.entity_wires()
|
||||
.iter()
|
||||
.filter_map(|w| Scene::handle_from_wire_name(&w.name))
|
||||
.collect();
|
||||
let count = handles.len();
|
||||
for h in handles {
|
||||
self.tabs[i].scene.select_entity(h, false);
|
||||
}
|
||||
self.command_line
|
||||
.push_output(&format!("SELECTALL: {} object(s) selected.", count));
|
||||
self.refresh_properties();
|
||||
}
|
||||
|
||||
"DESELECT" | "DE" | "DESELALL" => {
|
||||
self.tabs[i].scene.deselect_all();
|
||||
self.command_line.push_output("Deselected.");
|
||||
self.refresh_properties();
|
||||
}
|
||||
|
||||
"SELECTSIMILAR" | "SELSIM" => {
|
||||
let added = self.tabs[i].scene.select_similar();
|
||||
self.command_line
|
||||
.push_output(&format!("Select Similar: {} added.", added));
|
||||
self.refresh_properties();
|
||||
}
|
||||
|
||||
"QSELECT" | "QS" => {
|
||||
return Some(Task::done(Message::QSelectOpen));
|
||||
}
|
||||
|
||||
// ── LIST — entity info ────────────────────────────────────────
|
||||
"LIST" | "LI" => {
|
||||
let selected: Vec<_> = self.tabs[i].scene.selected_entities();
|
||||
if selected.is_empty() {
|
||||
self.command_line
|
||||
.push_error("LIST: no entities selected. Select entities first.");
|
||||
} else {
|
||||
for (handle, _) in &selected {
|
||||
if let Some(entity) = self.tabs[i].scene.document.get_entity(*handle) {
|
||||
let type_name = crate::entities::names::dxf_name(entity);
|
||||
let common = entity.common();
|
||||
let color_str = common
|
||||
.color
|
||||
.index()
|
||||
.map(|c| c.to_string())
|
||||
.unwrap_or_else(|| "ByLayer".to_string());
|
||||
let linetype =
|
||||
if common.linetype.is_empty() || common.linetype == "ByLayer" {
|
||||
"ByLayer".to_string()
|
||||
} else {
|
||||
common.linetype.clone()
|
||||
};
|
||||
// Entity-specific details
|
||||
let details = entity_list_details(entity);
|
||||
self.command_line.push_output(&format!(
|
||||
"{type_name} Handle:{:X} Layer:{} Color:{} LT:{}{}",
|
||||
handle.value(),
|
||||
common.layer,
|
||||
color_str,
|
||||
linetype,
|
||||
if details.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!("\n {details}")
|
||||
}
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Break / Join ─────────────────────────────────────────────────
|
||||
"JOIN" | "J" => {
|
||||
use crate::modules::draw::modify::join::JoinCommand;
|
||||
let cmd = JoinCommand::new();
|
||||
self.command_line.push_info(&cmd.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(cmd));
|
||||
}
|
||||
|
||||
"BREAK" | "BR" => {
|
||||
use crate::modules::draw::modify::break_cmd::BreakInteractiveCommand;
|
||||
let cmd = BreakInteractiveCommand::new();
|
||||
self.command_line.push_info(&cmd.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(cmd));
|
||||
}
|
||||
|
||||
"BREAKATPOINT" | "BAP" => {
|
||||
use crate::modules::draw::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::draw::modify::pedit::PeditCommand;
|
||||
let cmd_obj = PeditCommand::new();
|
||||
self.command_line.push_info(&cmd_obj.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(cmd_obj));
|
||||
}
|
||||
|
||||
"SPLINEDIT" | "SPE" => {
|
||||
use crate::modules::draw::modify::splinedit::SplineditCommand;
|
||||
let cmd_obj = SplineditCommand::new();
|
||||
self.command_line.push_info(&cmd_obj.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(cmd_obj));
|
||||
}
|
||||
|
||||
"ATTEDIT" | "ATE" | "-ATTEDIT" => {
|
||||
use crate::modules::draw::modify::attedit::AtteditCommand;
|
||||
let cmd_obj = AtteditCommand::new();
|
||||
self.command_line.push_info(&cmd_obj.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(cmd_obj));
|
||||
}
|
||||
|
||||
// ── REFEDIT — in-place block editing ─────────────────────────────
|
||||
"REFEDIT" => {
|
||||
use crate::modules::draw::modify::refedit::RefEditPickCommand;
|
||||
// If a session is already active, tell the user.
|
||||
if self.tabs[i].refedit_session.is_some() {
|
||||
self.command_line
|
||||
.push_error("REFEDIT: a session is already active. Use REFCLOSE first.");
|
||||
} else {
|
||||
// Check if a single INSERT is already selected.
|
||||
let selected: Vec<_> =
|
||||
self.tabs[i].scene.selected_entities().into_iter().collect();
|
||||
if selected.len() == 1 {
|
||||
if let Some(acadrust::EntityType::Insert(_)) =
|
||||
selected.first().map(|(_, e)| e)
|
||||
{
|
||||
let handle = selected[0].0;
|
||||
// Skip pick phase — jump straight to begin.
|
||||
let _ =
|
||||
self.dispatch_command(&format!("REFEDIT_BEGIN:{}", handle.value()));
|
||||
return Some(Task::none());
|
||||
}
|
||||
}
|
||||
let cmd_obj = RefEditPickCommand::new();
|
||||
self.command_line.push_info(&cmd_obj.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(cmd_obj));
|
||||
}
|
||||
}
|
||||
|
||||
cmd if cmd.starts_with("REFEDIT_BEGIN:") => {
|
||||
use crate::modules::draw::modify::refedit::{
|
||||
apply_insert_transform, RefEditSession,
|
||||
};
|
||||
use acadrust::Handle;
|
||||
|
||||
let handle_u64: u64 = cmd["REFEDIT_BEGIN:".len()..].parse().unwrap_or(0);
|
||||
let insert_handle = Handle::new(handle_u64);
|
||||
|
||||
// Get INSERT entity.
|
||||
let insert = match self.tabs[i].scene.document.get_entity(insert_handle) {
|
||||
Some(acadrust::EntityType::Insert(ins)) => ins.clone(),
|
||||
_ => {
|
||||
self.command_line
|
||||
.push_error("REFEDIT: selected object is not an INSERT.");
|
||||
return Some(Task::none());
|
||||
}
|
||||
};
|
||||
|
||||
// Build the INSERT's full placement transform (OCS + rotation +
|
||||
// scale, including non-uniform / mirrored) and its inverse, so
|
||||
// edits round-trip back to block-local coordinates on SAVE.
|
||||
let sx = insert.x_scale();
|
||||
let sy = insert.y_scale();
|
||||
let sz = insert.z_scale();
|
||||
let forward = insert.get_transform();
|
||||
let inverse = {
|
||||
use acadrust::types::{Matrix3, Matrix4, Transform};
|
||||
let ocs_t =
|
||||
Matrix4::from_matrix3(Matrix3::arbitrary_axis(insert.normal).transpose());
|
||||
let t_inv = Matrix4::translation(
|
||||
-insert.insert_point.x,
|
||||
-insert.insert_point.y,
|
||||
-insert.insert_point.z,
|
||||
);
|
||||
let r_inv = Matrix4::rotation_z(-insert.rotation);
|
||||
let s_inv = Matrix4::scaling(1.0 / sx, 1.0 / sy, 1.0 / sz);
|
||||
// inverse(OCS·T·R·S) = S⁻¹·R⁻¹·T⁻¹·OCSᵀ
|
||||
Transform::from_matrix(s_inv * r_inv * t_inv * ocs_t)
|
||||
};
|
||||
|
||||
// Find the block record.
|
||||
let br_handle = match self.tabs[i]
|
||||
.scene
|
||||
.document
|
||||
.block_records
|
||||
.get(&insert.block_name)
|
||||
{
|
||||
Some(br) => br.handle,
|
||||
None => {
|
||||
self.command_line.push_error(&format!(
|
||||
"REFEDIT: block \"{}\" not found.",
|
||||
insert.block_name
|
||||
));
|
||||
return Some(Task::none());
|
||||
}
|
||||
};
|
||||
|
||||
// Collect block-local entities (skip structural Block/BlockEnd/AttDef).
|
||||
let block_entities: Vec<_> = {
|
||||
let br = self.tabs[i]
|
||||
.scene
|
||||
.document
|
||||
.block_records
|
||||
.get(&insert.block_name)
|
||||
.unwrap();
|
||||
br.entity_handles
|
||||
.iter()
|
||||
.filter_map(|h| self.tabs[i].scene.document.get_entity(*h).cloned())
|
||||
.filter(|e| {
|
||||
!matches!(
|
||||
e,
|
||||
acadrust::EntityType::Block(_)
|
||||
| acadrust::EntityType::BlockEnd(_)
|
||||
| acadrust::EntityType::AttributeDefinition(_)
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
|
||||
if block_entities.is_empty() {
|
||||
self.command_line.push_error("REFEDIT: block is empty.");
|
||||
return Some(Task::none());
|
||||
}
|
||||
|
||||
let session = RefEditSession {
|
||||
block_name: insert.block_name.clone(),
|
||||
br_handle,
|
||||
temp_handles: vec![],
|
||||
forward,
|
||||
inverse,
|
||||
};
|
||||
|
||||
self.push_undo_snapshot(i, "REFEDIT");
|
||||
self.tabs[i].refedit_session = Some(session.clone());
|
||||
|
||||
// Add block entities to model space with INSERT transform applied.
|
||||
let mut temp_handles = Vec::new();
|
||||
for mut entity in block_entities {
|
||||
apply_insert_transform(&mut entity, &session);
|
||||
entity.common_mut().handle = acadrust::Handle::NULL;
|
||||
entity.common_mut().owner_handle = acadrust::Handle::NULL;
|
||||
let h = self.tabs[i].scene.add_entity(entity);
|
||||
temp_handles.push(h);
|
||||
}
|
||||
self.tabs[i].refedit_session.as_mut().unwrap().temp_handles = temp_handles.clone();
|
||||
|
||||
// Fade everything except the entities being edited, so the
|
||||
// surrounding drawing stays visible for context but the block's
|
||||
// geometry stands out. (#136)
|
||||
self.tabs[i]
|
||||
.scene
|
||||
.set_refedit_keep(Some(temp_handles.iter().copied().collect()));
|
||||
|
||||
// Select the temp entities so user can see what they're editing.
|
||||
self.tabs[i].scene.deselect_all();
|
||||
for h in &temp_handles {
|
||||
self.tabs[i].scene.select_entity(*h, false);
|
||||
}
|
||||
self.tabs[i].dirty = true;
|
||||
|
||||
// No active command — the user edits the block's geometry freely
|
||||
// (move, grips, draw, erase…) and runs REFCLOSE when done. (#136)
|
||||
self.tabs[i].active_cmd = None;
|
||||
self.command_line.push_info(&format!(
|
||||
"REFEDIT: Editing block \"{}\". Run REFCLOSE to save, REFCLOSE_DISCARD to cancel.",
|
||||
insert.block_name
|
||||
));
|
||||
}
|
||||
|
||||
"REFCLOSE" => {
|
||||
if self.tabs[i].refedit_session.is_some() {
|
||||
use crate::modules::draw::modify::refedit::RefCloseCommand;
|
||||
let cmd_obj = RefCloseCommand::new();
|
||||
self.command_line.push_info(&cmd_obj.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(cmd_obj));
|
||||
} else {
|
||||
self.command_line
|
||||
.push_error("REFCLOSE: no REFEDIT session active.");
|
||||
}
|
||||
}
|
||||
|
||||
"REFCLOSE_SAVE" => {
|
||||
use crate::modules::draw::modify::explode::normalize_entity_for_block;
|
||||
use crate::modules::draw::modify::refedit::apply_insert_inverse_transform;
|
||||
|
||||
let session = match self.tabs[i].refedit_session.take() {
|
||||
Some(s) => s,
|
||||
None => {
|
||||
self.command_line
|
||||
.push_error("REFCLOSE: no REFEDIT session active.");
|
||||
return Some(Task::none());
|
||||
}
|
||||
};
|
||||
|
||||
self.push_undo_snapshot(i, "REFCLOSE");
|
||||
|
||||
// Collect the edited temp entities.
|
||||
let new_entities: Vec<acadrust::EntityType> = session
|
||||
.temp_handles
|
||||
.iter()
|
||||
.filter_map(|h| self.tabs[i].scene.document.get_entity(*h).cloned())
|
||||
.collect();
|
||||
|
||||
// Remove temp entities from model space.
|
||||
self.tabs[i].scene.erase_entities(&session.temp_handles);
|
||||
|
||||
// Apply inverse INSERT transform → block-local coordinates.
|
||||
let new_entities: Vec<_> = new_entities
|
||||
.into_iter()
|
||||
.map(|mut entity| {
|
||||
apply_insert_inverse_transform(&mut entity, &session);
|
||||
let mut entity = normalize_entity_for_block(entity);
|
||||
entity.common_mut().handle = acadrust::Handle::NULL;
|
||||
entity.common_mut().owner_handle = session.br_handle;
|
||||
entity
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Remove old block entities from the document.
|
||||
let old_handles: Vec<_> = match self.tabs[i]
|
||||
.scene
|
||||
.document
|
||||
.block_records
|
||||
.get(&session.block_name)
|
||||
{
|
||||
Some(br) => br.entity_handles.clone(),
|
||||
None => vec![],
|
||||
};
|
||||
for h in &old_handles {
|
||||
self.tabs[i].scene.document.remove_entity(*h);
|
||||
}
|
||||
// Flush the entity_handles list from the block record.
|
||||
if let Some(br) = self.tabs[i]
|
||||
.scene
|
||||
.document
|
||||
.block_records
|
||||
.get_mut(&session.block_name)
|
||||
{
|
||||
br.entity_handles.clear();
|
||||
}
|
||||
|
||||
// Add the new block entities.
|
||||
for entity in new_entities {
|
||||
let _ = self.tabs[i].scene.document.add_entity(entity);
|
||||
}
|
||||
|
||||
self.tabs[i].dirty = true;
|
||||
self.command_line.push_output(&format!(
|
||||
"REFCLOSE: Block \"{}\" saved. All references updated.",
|
||||
session.block_name
|
||||
));
|
||||
// End the edit fade before rebuilding, so the restored geometry
|
||||
// recolours bright. (#136)
|
||||
self.tabs[i].scene.set_refedit_keep(None);
|
||||
// Rebuild hatch/image/mesh caches since block content changed.
|
||||
self.tabs[i].scene.rebuild_derived_caches();
|
||||
}
|
||||
|
||||
"REFCLOSE_DISCARD" => {
|
||||
let session = match self.tabs[i].refedit_session.take() {
|
||||
Some(s) => s,
|
||||
None => {
|
||||
self.command_line
|
||||
.push_error("REFCLOSE: no REFEDIT session active.");
|
||||
return Some(Task::none());
|
||||
}
|
||||
};
|
||||
// Remove temp entities without modifying the block.
|
||||
self.tabs[i].scene.erase_entities(&session.temp_handles);
|
||||
self.tabs[i].scene.deselect_all();
|
||||
// End the edit fade — restore the drawing to full brightness.
|
||||
self.tabs[i].scene.set_refedit_keep(None);
|
||||
self.command_line
|
||||
.push_output("REFCLOSE: Changes discarded.");
|
||||
}
|
||||
|
||||
"ALIGN" | "AL" => {
|
||||
use crate::modules::draw::modify::align::AlignCommand;
|
||||
let cmd = AlignCommand::new();
|
||||
self.command_line.push_info(&cmd.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(cmd));
|
||||
}
|
||||
|
||||
"LENGTHEN" | "LEN" => {
|
||||
use crate::modules::draw::modify::lengthen::LengthenCommand;
|
||||
let cmd = LengthenCommand::new();
|
||||
self.command_line.push_info(&cmd.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(cmd));
|
||||
}
|
||||
|
||||
"DIVIDE" | "DIV" => {
|
||||
use crate::modules::draw::inquiry::divide::DivideCommand;
|
||||
let cmd = DivideCommand::new();
|
||||
self.command_line.push_info(&cmd.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(cmd));
|
||||
}
|
||||
|
||||
"MEASURE" | "ME" => {
|
||||
use crate::modules::draw::inquiry::divide::MeasureCommand;
|
||||
let cmd = MeasureCommand::new();
|
||||
self.command_line.push_info(&cmd.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(cmd));
|
||||
}
|
||||
|
||||
// ── Inquiry ──────────────────────────────────────────────────────
|
||||
"DIST" | "DI" => {
|
||||
use crate::modules::draw::inquiry::dist::DistCommand;
|
||||
let cmd = DistCommand::new();
|
||||
self.command_line.push_info(&cmd.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(cmd));
|
||||
}
|
||||
|
||||
"ID" => {
|
||||
use crate::modules::draw::inquiry::id::IdCommand;
|
||||
let cmd = IdCommand::new();
|
||||
self.command_line.push_info(&cmd.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(cmd));
|
||||
}
|
||||
|
||||
"AREA" => {
|
||||
use crate::modules::draw::inquiry::area::AreaCommand;
|
||||
let cmd = AreaCommand::new();
|
||||
self.command_line.push_info(&cmd.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(cmd));
|
||||
}
|
||||
|
||||
// ── MASSPROP — area, perimeter, centroid of selected entities ────
|
||||
"MASSPROP" => {
|
||||
let selected = self.tabs[i].scene.selected_entities();
|
||||
if selected.is_empty() {
|
||||
self.command_line
|
||||
.push_error("MASSPROP: no entities selected. Select entities first.");
|
||||
} else {
|
||||
for (handle, _) in &selected {
|
||||
if let Some(entity) = self.tabs[i].scene.document.get_entity(*handle) {
|
||||
use crate::entities::traits::EntityTypeOps;
|
||||
if let Some(props) = entity.mass_props() {
|
||||
self.command_line.push_output(&format!(
|
||||
"{} Area={:.4} Perimeter={:.4} Centroid=({:.4},{:.4})",
|
||||
crate::entities::names::dxf_name(entity),
|
||||
props.area,
|
||||
props.perimeter,
|
||||
props.cx,
|
||||
props.cy,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── FLATTEN — move selected (or all) entities to Z=0 ─────────────
|
||||
"FLATTEN" => {
|
||||
let handles: Vec<acadrust::Handle> = {
|
||||
let sel = self.tabs[i].scene.selected_entities();
|
||||
if sel.is_empty() {
|
||||
// Flatten all entities
|
||||
self.tabs[i]
|
||||
.scene
|
||||
.document
|
||||
.entities()
|
||||
.map(|e| e.common().handle)
|
||||
.collect()
|
||||
} else {
|
||||
sel.into_iter().map(|(h, _)| h).collect()
|
||||
}
|
||||
};
|
||||
if handles.is_empty() {
|
||||
self.command_line.push_error("FLATTEN: no entities.");
|
||||
} else {
|
||||
self.push_undo_snapshot(i, "FLATTEN");
|
||||
for h in &handles {
|
||||
if let Some(e) = self.tabs[i].scene.document.get_entity_mut(*h) {
|
||||
flatten_entity_z(e);
|
||||
}
|
||||
}
|
||||
self.tabs[i].dirty = true;
|
||||
self.command_line.push_output(&format!(
|
||||
"FLATTEN: {} entity(ies) moved to Z=0.",
|
||||
handles.len()
|
||||
));
|
||||
self.refresh_properties();
|
||||
}
|
||||
}
|
||||
|
||||
// ── QSELECT — quick-select entities by property ───────────────────
|
||||
// QSELECT TYPE <type> — select all entities of given type
|
||||
// QSELECT LAYER <name> — select all entities on layer
|
||||
// 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 ") => {
|
||||
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();
|
||||
let val = parts.get(1).map(|s| s.trim()).unwrap_or("").to_uppercase();
|
||||
|
||||
let matched: Vec<acadrust::Handle> = self.tabs[i]
|
||||
.scene
|
||||
.document
|
||||
.entities()
|
||||
.filter(|e| {
|
||||
let c = e.common();
|
||||
match prop.as_str() {
|
||||
"TYPE" => crate::entities::names::dxf_name(e).to_uppercase() == val,
|
||||
"LAYER" => c.layer.to_uppercase() == val,
|
||||
"COLOR" => c
|
||||
.color
|
||||
.index()
|
||||
.map(|n| n.to_string() == val)
|
||||
.unwrap_or(val == "BYLAYER"),
|
||||
"LINETYPE" => c.linetype.to_uppercase() == val,
|
||||
_ => false,
|
||||
}
|
||||
})
|
||||
.map(|e| e.common().handle)
|
||||
.collect();
|
||||
|
||||
if prop.is_empty() {
|
||||
self.command_line
|
||||
.push_info("Usage: QSELECT TYPE|LAYER|COLOR|LINETYPE <value>");
|
||||
} else if matched.is_empty() {
|
||||
self.command_line
|
||||
.push_output("QSELECT: no matching entities.");
|
||||
} else {
|
||||
self.tabs[i].scene.deselect_all();
|
||||
for h in &matched {
|
||||
self.tabs[i].scene.select_entity(*h, false);
|
||||
}
|
||||
self.command_line
|
||||
.push_output(&format!("QSELECT: {} entity(ies) selected.", matched.len()));
|
||||
self.refresh_properties();
|
||||
}
|
||||
}
|
||||
|
||||
// ── COUNT — entity statistics ─────────────────────────────────────
|
||||
cmd if cmd == "COUNT" || cmd.starts_with("COUNT ") => {
|
||||
let filter = cmd.split_once(' ').map(|(_, r)| r.trim().to_uppercase());
|
||||
let mut counts: std::collections::BTreeMap<String, usize> = Default::default();
|
||||
for e in self.tabs[i].scene.document.entities() {
|
||||
let layer = &e.common().layer;
|
||||
let type_name = crate::entities::names::dxf_name(e);
|
||||
let key = match &filter {
|
||||
Some(f) if f == "LAYER" => layer.clone(),
|
||||
Some(f) if f == "TYPE" => type_name.to_string(),
|
||||
Some(f) => {
|
||||
// Filter by layer name
|
||||
if layer.to_uppercase() != *f {
|
||||
continue;
|
||||
}
|
||||
type_name.to_string()
|
||||
}
|
||||
None => type_name.to_string(),
|
||||
};
|
||||
*counts.entry(key).or_default() += 1;
|
||||
}
|
||||
let total: usize = counts.values().sum();
|
||||
for (k, n) in &counts {
|
||||
self.command_line.push_output(&format!(" {k}: {n}"));
|
||||
}
|
||||
self.command_line
|
||||
.push_output(&format!("COUNT: {total} entity(ies) total."));
|
||||
}
|
||||
|
||||
"DATAEXTRACTION" | "EATTEXT" | "ATTEXT" => {
|
||||
let csv = build_data_extraction_csv(&self.tabs[i].scene.document);
|
||||
return Some(Task::done(Message::DataExtractionSave(csv)));
|
||||
}
|
||||
|
||||
// ── Find / Replace ────────────────────────────────────────────────
|
||||
// FIND <search> — list all Text/MText/Dimension containing <search>
|
||||
// FIND <search> REPLACE <rep> — replace first occurrence (case-insensitive)
|
||||
// FINDALL <search> REPLACE <rep> — replace all occurrences
|
||||
cmd if cmd == "FIND"
|
||||
|| cmd.starts_with("FIND ")
|
||||
|| cmd == "FINDALL"
|
||||
|| cmd.starts_with("FINDALL ") =>
|
||||
{
|
||||
let all_mode = cmd.starts_with("FINDALL");
|
||||
let rest = cmd.split_once(' ').map(|(_, r)| r.trim()).unwrap_or("");
|
||||
|
||||
// Split at " REPLACE " keyword (case-insensitive)
|
||||
let (search, replacement) = if let Some(pos) = rest.to_uppercase().find(" REPLACE ")
|
||||
{
|
||||
(&rest[..pos], Some(rest[pos + 9..].trim()))
|
||||
} else {
|
||||
(rest, None)
|
||||
};
|
||||
|
||||
if search.is_empty() {
|
||||
self.command_line.push_error("FIND: specify search text.");
|
||||
} else {
|
||||
let search_lc = search.to_lowercase();
|
||||
let mut count = 0usize;
|
||||
let handles: Vec<acadrust::Handle> = self.tabs[i]
|
||||
.scene
|
||||
.document
|
||||
.entities()
|
||||
.filter_map(|e| {
|
||||
use crate::entities::traits::EntityTypeOps; let txt = e.text_content()?;
|
||||
if txt.to_lowercase().contains(&search_lc) {
|
||||
Some(e.common().handle)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
if let Some(rep) = replacement {
|
||||
// Replace mode
|
||||
let targets: Vec<_> = if all_mode {
|
||||
handles.clone()
|
||||
} else {
|
||||
handles.iter().copied().take(1).collect()
|
||||
};
|
||||
if targets.is_empty() {
|
||||
self.command_line
|
||||
.push_output(&format!("FIND: \"{}\" not found.", search));
|
||||
} else {
|
||||
self.push_undo_snapshot(i, "FIND/REPLACE");
|
||||
for h in &targets {
|
||||
if let Some(e) = self.tabs[i].scene.document.get_entity_mut(*h) {
|
||||
crate::entities::traits::EntityTypeOps::replace_text(e, search, rep);
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
self.tabs[i].dirty = true;
|
||||
self.command_line.push_output(&format!(
|
||||
"FIND/REPLACE: replaced {} occurrence(s) of \"{}\" → \"{}\".",
|
||||
count, search, rep
|
||||
));
|
||||
self.refresh_properties();
|
||||
}
|
||||
} else {
|
||||
// List mode
|
||||
if handles.is_empty() {
|
||||
self.command_line
|
||||
.push_output(&format!("FIND: \"{}\" not found.", search));
|
||||
} else {
|
||||
for h in &handles {
|
||||
if let Some(e) = self.tabs[i].scene.document.get_entity(*h) {
|
||||
use crate::entities::traits::EntityTypeOps; let txt = e.text_content().unwrap_or_default();
|
||||
self.command_line.push_output(&format!(
|
||||
" Handle {:X}: \"{}\"",
|
||||
h.value(),
|
||||
txt
|
||||
));
|
||||
}
|
||||
}
|
||||
self.command_line.push_output(&format!(
|
||||
"FIND: {} match(es) for \"{}\".",
|
||||
handles.len(),
|
||||
search
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_ => return None,
|
||||
}
|
||||
Some(self.finish_dispatch(cmd))
|
||||
}
|
||||
}
|
||||
|
||||
fn entity_list_details(entity: &acadrust::EntityType) -> String {
|
||||
use std::f64::consts::PI;
|
||||
match entity {
|
||||
acadrust::EntityType::Line(l) => format!(
|
||||
"from ({:.4},{:.4},{:.4}) to ({:.4},{:.4},{:.4}) len={:.4}",
|
||||
l.start.x,
|
||||
l.start.y,
|
||||
l.start.z,
|
||||
l.end.x,
|
||||
l.end.y,
|
||||
l.end.z,
|
||||
((l.end.x - l.start.x).powi(2)
|
||||
+ (l.end.y - l.start.y).powi(2)
|
||||
+ (l.end.z - l.start.z).powi(2))
|
||||
.sqrt()
|
||||
),
|
||||
acadrust::EntityType::Circle(c) => format!(
|
||||
"center ({:.4},{:.4},{:.4}) r={:.4} area={:.4}",
|
||||
c.center.x,
|
||||
c.center.y,
|
||||
c.center.z,
|
||||
c.radius,
|
||||
PI * c.radius * c.radius
|
||||
),
|
||||
acadrust::EntityType::Arc(a) => format!(
|
||||
"center ({:.4},{:.4},{:.4}) r={:.4} start={:.2}° end={:.2}°",
|
||||
a.center.x,
|
||||
a.center.y,
|
||||
a.center.z,
|
||||
a.radius,
|
||||
a.start_angle.to_degrees(),
|
||||
a.end_angle.to_degrees()
|
||||
),
|
||||
acadrust::EntityType::LwPolyline(p) => format!(
|
||||
"{} vertices closed={} elevation={:.4}",
|
||||
p.vertices.len(),
|
||||
p.is_closed,
|
||||
p.elevation
|
||||
),
|
||||
acadrust::EntityType::Text(t) => format!(
|
||||
"\"{}\" h={:.4} at ({:.4},{:.4})",
|
||||
t.value, t.height, t.insertion_point.x, t.insertion_point.y
|
||||
),
|
||||
acadrust::EntityType::MText(t) => format!(
|
||||
"\"{}\" h={:.4} at ({:.4},{:.4})",
|
||||
t.value.chars().take(40).collect::<String>(),
|
||||
t.height,
|
||||
t.insertion_point.x,
|
||||
t.insertion_point.y
|
||||
),
|
||||
acadrust::EntityType::Insert(ins) => format!(
|
||||
"block=\"{}\" at ({:.4},{:.4},{:.4}) scale=({:.4},{:.4},{:.4}) rot={:.2}°",
|
||||
ins.block_name,
|
||||
ins.insert_point.x,
|
||||
ins.insert_point.y,
|
||||
ins.insert_point.z,
|
||||
ins.x_scale(),
|
||||
ins.y_scale(),
|
||||
ins.z_scale(),
|
||||
ins.rotation.to_degrees()
|
||||
),
|
||||
acadrust::EntityType::Spline(s) => format!(
|
||||
"{} ctrl pts degree={} closed={}",
|
||||
s.control_points.len(),
|
||||
s.degree,
|
||||
s.flags.closed
|
||||
),
|
||||
acadrust::EntityType::Ellipse(e) => format!(
|
||||
"center ({:.4},{:.4}) major_len={:.4} ratio={:.4}",
|
||||
e.center.x,
|
||||
e.center.y,
|
||||
e.major_axis_length(),
|
||||
e.minor_axis_ratio
|
||||
),
|
||||
_ => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn flatten_entity_z(entity: &mut acadrust::EntityType) {
|
||||
match entity {
|
||||
acadrust::EntityType::Line(l) => {
|
||||
l.start.z = 0.0;
|
||||
l.end.z = 0.0;
|
||||
}
|
||||
acadrust::EntityType::Circle(c) => {
|
||||
c.center.z = 0.0;
|
||||
}
|
||||
acadrust::EntityType::Arc(a) => {
|
||||
a.center.z = 0.0;
|
||||
}
|
||||
acadrust::EntityType::LwPolyline(p) => {
|
||||
p.elevation = 0.0;
|
||||
}
|
||||
acadrust::EntityType::Text(t) => {
|
||||
t.insertion_point.z = 0.0;
|
||||
}
|
||||
acadrust::EntityType::MText(t) => {
|
||||
t.insertion_point.z = 0.0;
|
||||
}
|
||||
acadrust::EntityType::Insert(ins) => {
|
||||
ins.insert_point.z = 0.0;
|
||||
}
|
||||
acadrust::EntityType::Point(p) => {
|
||||
p.location.z = 0.0;
|
||||
}
|
||||
acadrust::EntityType::Spline(s) => {
|
||||
for cp in &mut s.control_points {
|
||||
cp.z = 0.0;
|
||||
}
|
||||
for fp in &mut s.fit_points {
|
||||
fp.z = 0.0;
|
||||
}
|
||||
}
|
||||
acadrust::EntityType::Ellipse(e) => {
|
||||
e.center.z = 0.0;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// ── DATAEXTRACTION ─────────────────────────────────────────────────────────
|
||||
|
||||
/// Build a CSV string with one row per entity in model space.
|
||||
/// Columns: Type, Handle, Layer, Color, Linetype, ExtraInfo
|
||||
fn build_data_extraction_csv(doc: &acadrust::CadDocument) -> String {
|
||||
use acadrust::EntityType;
|
||||
|
||||
let mut out = String::from("Type,Handle,Layer,Color,Linetype,ExtraInfo\n");
|
||||
|
||||
let ms_handle = doc.header.model_space_block_handle;
|
||||
for e in doc.entities() {
|
||||
// Skip Block/EndBlock sentinels and paper-space entities.
|
||||
if matches!(e, EntityType::Block(_) | EntityType::BlockEnd(_)) {
|
||||
continue;
|
||||
}
|
||||
if !ms_handle.is_null() && e.common().owner_handle != ms_handle {
|
||||
continue;
|
||||
}
|
||||
let type_name = crate::entities::names::dxf_name(e);
|
||||
let handle = format!("{:X}", e.common().handle.value());
|
||||
let layer = csv_escape(&e.common().layer);
|
||||
let color = format!("{}", e.common().color);
|
||||
let lt = csv_escape(&e.common().linetype);
|
||||
let extra = csv_escape(&entity_extra_info(e));
|
||||
out.push_str(&format!(
|
||||
"{type_name},{handle},{layer},{color},{lt},{extra}\n"
|
||||
));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
|
||||
/// Return a short geometry summary for CSV ExtraInfo column.
|
||||
fn entity_extra_info(entity: &acadrust::EntityType) -> String {
|
||||
use acadrust::EntityType;
|
||||
match entity {
|
||||
EntityType::Line(e) => format!(
|
||||
"({:.3},{:.3})-({:.3},{:.3})",
|
||||
e.start.x, e.start.y, e.end.x, e.end.y
|
||||
),
|
||||
EntityType::Circle(e) => {
|
||||
format!("C({:.3},{:.3}) R={:.3}", e.center.x, e.center.y, e.radius)
|
||||
}
|
||||
EntityType::Arc(e) => format!(
|
||||
"C({:.3},{:.3}) R={:.3} {:.1}°-{:.1}°",
|
||||
e.center.x,
|
||||
e.center.y,
|
||||
e.radius,
|
||||
e.start_angle.to_degrees(),
|
||||
e.end_angle.to_degrees()
|
||||
),
|
||||
EntityType::Text(e) => e.value.clone(),
|
||||
EntityType::MText(e) => e.value.chars().take(60).collect(),
|
||||
EntityType::Insert(e) => format!(
|
||||
"BLK={} @({:.3},{:.3})",
|
||||
e.block_name, e.insert_point.x, e.insert_point.y
|
||||
),
|
||||
EntityType::LwPolyline(e) => format!("{} vertices", e.vertices.len()),
|
||||
EntityType::Polyline(e) => format!("{} vertices", e.vertices.len()),
|
||||
EntityType::Polyline2D(e) => format!("{} vertices", e.vertices.len()),
|
||||
EntityType::Polyline3D(e) => format!("{} vertices", e.vertices.len()),
|
||||
EntityType::Hatch(e) => format!("PAT={}", e.pattern.name),
|
||||
EntityType::Dimension(e) => format!("{:.3}", e.base().actual_measurement),
|
||||
EntityType::Spline(e) => format!("{} ctrl pts", e.control_points.len()),
|
||||
_ => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Escape a string for a CSV field (wrap in quotes if it contains comma/quote/newline).
|
||||
fn csv_escape(s: &str) -> String {
|
||||
if s.contains(',') || s.contains('"') || s.contains('\n') {
|
||||
format!("\"{}\"", s.replace('"', "\"\""))
|
||||
} else {
|
||||
s.to_string()
|
||||
}
|
||||
}
|
||||
1109
src/app/commands/layerprops.rs
Normal file
1109
src/app/commands/layerprops.rs
Normal file
File diff suppressed because it is too large
Load diff
380
src/app/commands/layers.rs
Normal file
380
src/app/commands/layers.rs
Normal file
|
|
@ -0,0 +1,380 @@
|
|||
use super::*;
|
||||
|
||||
impl OpenCADStudio {
|
||||
pub(super) fn dispatch_layers(&mut self, cmd: &str, i: usize) -> Option<Task<Message>> {
|
||||
match cmd {
|
||||
// ── Layer object commands ──────────────────────────────────────
|
||||
"LAYOFF" => {
|
||||
let handles: Vec<_> = self.tabs[i]
|
||||
.scene
|
||||
.selected_entities()
|
||||
.into_iter()
|
||||
.map(|(h, _)| h)
|
||||
.collect();
|
||||
if handles.is_empty() {
|
||||
use crate::modules::draw::select::SelectObjectsCommand;
|
||||
let cmd = SelectObjectsCommand::new("LAYOFF");
|
||||
self.command_line.push_info(&cmd.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(cmd));
|
||||
} else {
|
||||
let layers: rustc_hash::FxHashSet<String> = self.tabs[i]
|
||||
.scene
|
||||
.selected_entities()
|
||||
.into_iter()
|
||||
.map(|(_, e)| e.common().layer.clone())
|
||||
.collect();
|
||||
self.push_undo_snapshot(i, "LAYOFF");
|
||||
for name in &layers {
|
||||
if name == "0" {
|
||||
continue;
|
||||
}
|
||||
if let Some(dl) = self.tabs[i].scene.document.layers.get_mut(name) {
|
||||
dl.turn_off();
|
||||
}
|
||||
}
|
||||
self.tabs[i].scene.bump_geometry();
|
||||
self.tabs[i].dirty = true;
|
||||
self.refresh_layer_panel();
|
||||
self.command_line.push_info("Layer(s) turned off.");
|
||||
}
|
||||
}
|
||||
|
||||
"LAYFRZ" => {
|
||||
let handles: Vec<_> = self.tabs[i]
|
||||
.scene
|
||||
.selected_entities()
|
||||
.into_iter()
|
||||
.map(|(h, _)| h)
|
||||
.collect();
|
||||
if handles.is_empty() {
|
||||
use crate::modules::draw::select::SelectObjectsCommand;
|
||||
let cmd = SelectObjectsCommand::new("LAYFRZ");
|
||||
self.command_line.push_info(&cmd.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(cmd));
|
||||
} else {
|
||||
let layers: rustc_hash::FxHashSet<String> = self.tabs[i]
|
||||
.scene
|
||||
.selected_entities()
|
||||
.into_iter()
|
||||
.map(|(_, e)| e.common().layer.clone())
|
||||
.collect();
|
||||
self.push_undo_snapshot(i, "LAYFRZ");
|
||||
for name in &layers {
|
||||
if name == "0" {
|
||||
continue;
|
||||
}
|
||||
if let Some(dl) = self.tabs[i].scene.document.layers.get_mut(name) {
|
||||
dl.freeze();
|
||||
}
|
||||
}
|
||||
self.tabs[i].scene.bump_geometry();
|
||||
self.tabs[i].dirty = true;
|
||||
self.refresh_layer_panel();
|
||||
self.command_line.push_info("Layer(s) frozen.");
|
||||
}
|
||||
}
|
||||
|
||||
"LAYLCK" => {
|
||||
let handles: Vec<_> = self.tabs[i]
|
||||
.scene
|
||||
.selected_entities()
|
||||
.into_iter()
|
||||
.map(|(h, _)| h)
|
||||
.collect();
|
||||
if handles.is_empty() {
|
||||
use crate::modules::draw::select::SelectObjectsCommand;
|
||||
let cmd = SelectObjectsCommand::new("LAYLCK");
|
||||
self.command_line.push_info(&cmd.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(cmd));
|
||||
} else {
|
||||
let layers: rustc_hash::FxHashSet<String> = self.tabs[i]
|
||||
.scene
|
||||
.selected_entities()
|
||||
.into_iter()
|
||||
.map(|(_, e)| e.common().layer.clone())
|
||||
.collect();
|
||||
self.push_undo_snapshot(i, "LAYLCK");
|
||||
for name in &layers {
|
||||
if let Some(dl) = self.tabs[i].scene.document.layers.get_mut(name) {
|
||||
dl.lock();
|
||||
}
|
||||
}
|
||||
self.tabs[i].scene.bump_geometry();
|
||||
self.tabs[i].dirty = true;
|
||||
self.refresh_layer_panel();
|
||||
self.command_line.push_info("Layer(s) locked.");
|
||||
}
|
||||
}
|
||||
|
||||
"LAYMCUR" => {
|
||||
let entities = self.tabs[i].scene.selected_entities();
|
||||
if entities.is_empty() {
|
||||
use crate::modules::draw::select::SelectObjectsCommand;
|
||||
let cmd = SelectObjectsCommand::new("LAYMCUR");
|
||||
self.command_line.push_info(&cmd.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(cmd));
|
||||
} else {
|
||||
let layer = entities[0].1.common().layer.clone();
|
||||
// Keep the document header (CLAYER) in sync, not just the
|
||||
// per-tab default, so a later no-selection ribbon refresh
|
||||
// (e.g. after Esc) doesn't snap back to the stale header
|
||||
// layer. See #93.
|
||||
let handle = self.tabs[i]
|
||||
.scene
|
||||
.document
|
||||
.layers
|
||||
.get(&layer)
|
||||
.map(|l| l.handle)
|
||||
.unwrap_or(acadrust::types::Handle::NULL);
|
||||
self.tabs[i].scene.document.header.current_layer_name = layer.clone();
|
||||
self.tabs[i].scene.document.header.current_layer_handle = handle;
|
||||
self.tabs[i].active_layer = layer.clone();
|
||||
self.ribbon.active_layer = layer.clone();
|
||||
self.tabs[i].layers.current_layer = layer.clone();
|
||||
self.tabs[i].dirty = true;
|
||||
self.command_line
|
||||
.push_info(&format!("Current layer set to \"{layer}\"."));
|
||||
self.refresh_layer_panel();
|
||||
}
|
||||
}
|
||||
|
||||
"LAYON" => {
|
||||
self.push_undo_snapshot(i, "LAYON");
|
||||
for name in self.tabs[i]
|
||||
.scene
|
||||
.document
|
||||
.layers
|
||||
.iter()
|
||||
.map(|l| l.name.clone())
|
||||
.collect::<Vec<_>>()
|
||||
{
|
||||
if let Some(dl) = self.tabs[i].scene.document.layers.get_mut(&name) {
|
||||
dl.turn_on();
|
||||
}
|
||||
}
|
||||
self.tabs[i].scene.bump_geometry();
|
||||
self.tabs[i].dirty = true;
|
||||
self.refresh_layer_panel();
|
||||
self.command_line.push_info("All layers turned on.");
|
||||
}
|
||||
|
||||
"LAYTHW" => {
|
||||
self.push_undo_snapshot(i, "LAYTHW");
|
||||
for name in self.tabs[i]
|
||||
.scene
|
||||
.document
|
||||
.layers
|
||||
.iter()
|
||||
.map(|l| l.name.clone())
|
||||
.collect::<Vec<_>>()
|
||||
{
|
||||
if let Some(dl) = self.tabs[i].scene.document.layers.get_mut(&name) {
|
||||
dl.thaw();
|
||||
}
|
||||
}
|
||||
self.tabs[i].scene.bump_geometry();
|
||||
self.tabs[i].dirty = true;
|
||||
self.refresh_layer_panel();
|
||||
self.command_line.push_info("All layers thawed.");
|
||||
}
|
||||
|
||||
"LAYULK" => {
|
||||
let handles: Vec<_> = self.tabs[i]
|
||||
.scene
|
||||
.selected_entities()
|
||||
.into_iter()
|
||||
.map(|(h, _)| h)
|
||||
.collect();
|
||||
if handles.is_empty() {
|
||||
use crate::modules::draw::select::SelectObjectsCommand;
|
||||
let cmd = SelectObjectsCommand::new("LAYULK");
|
||||
self.command_line.push_info(&cmd.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(cmd));
|
||||
} else {
|
||||
let layers: rustc_hash::FxHashSet<String> = self.tabs[i]
|
||||
.scene
|
||||
.selected_entities()
|
||||
.into_iter()
|
||||
.map(|(_, e)| e.common().layer.clone())
|
||||
.collect();
|
||||
self.push_undo_snapshot(i, "LAYULK");
|
||||
for name in &layers {
|
||||
if let Some(dl) = self.tabs[i].scene.document.layers.get_mut(name) {
|
||||
dl.unlock();
|
||||
}
|
||||
}
|
||||
self.tabs[i].scene.bump_geometry();
|
||||
self.tabs[i].dirty = true;
|
||||
self.refresh_layer_panel();
|
||||
self.command_line.push_info("Layer(s) unlocked.");
|
||||
}
|
||||
}
|
||||
|
||||
// LAYISO — turn off all layers except those used by selected entities
|
||||
"LAYISO" => {
|
||||
let sel_layers: rustc_hash::FxHashSet<String> = self.tabs[i]
|
||||
.scene
|
||||
.selected_entities()
|
||||
.into_iter()
|
||||
.map(|(_, e)| e.common().layer.clone())
|
||||
.collect();
|
||||
if sel_layers.is_empty() {
|
||||
self.command_line
|
||||
.push_error("LAYISO: select entities on the layers to isolate first.");
|
||||
} else {
|
||||
self.push_undo_snapshot(i, "LAYISO");
|
||||
let names: Vec<String> = self.tabs[i]
|
||||
.scene
|
||||
.document
|
||||
.layers
|
||||
.iter()
|
||||
.map(|l| l.name.clone())
|
||||
.collect();
|
||||
for name in names {
|
||||
if !sel_layers.contains(&name) {
|
||||
if let Some(dl) = self.tabs[i].scene.document.layers.get_mut(&name) {
|
||||
dl.turn_off();
|
||||
}
|
||||
}
|
||||
}
|
||||
self.tabs[i].scene.bump_geometry();
|
||||
self.tabs[i].dirty = true;
|
||||
self.refresh_layer_panel();
|
||||
self.command_line
|
||||
.push_info(&format!("LAYISO: isolated {} layer(s).", sel_layers.len()));
|
||||
}
|
||||
}
|
||||
|
||||
// ISOLATEOBJECTS — hide every object except the current selection
|
||||
"ISOLATEOBJECTS" => {
|
||||
if self.tabs[i].scene.selected.is_empty() {
|
||||
self.command_line
|
||||
.push_error("ISOLATEOBJECTS: select the objects to isolate first.");
|
||||
} else {
|
||||
let n = self.tabs[i].scene.selected.len();
|
||||
self.tabs[i].scene.isolate_selected();
|
||||
self.command_line.push_info(&format!(
|
||||
"Isolated {n} object(s). UNISOLATEOBJECTS to restore."
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// HIDEOBJECTS — hide the current selection
|
||||
"HIDEOBJECTS" => {
|
||||
if self.tabs[i].scene.selected.is_empty() {
|
||||
self.command_line
|
||||
.push_error("HIDEOBJECTS: select the objects to hide first.");
|
||||
} else {
|
||||
let n = self.tabs[i].scene.selected.len();
|
||||
self.tabs[i].scene.hide_selected();
|
||||
self.command_line
|
||||
.push_info(&format!("Hid {n} object(s). UNISOLATEOBJECTS to restore."));
|
||||
}
|
||||
}
|
||||
|
||||
// UNISOLATEOBJECTS — bring back everything hidden by Isolate / Hide
|
||||
"UNISOLATEOBJECTS" => {
|
||||
if self.tabs[i].scene.is_isolation_active() {
|
||||
self.tabs[i].scene.end_isolation();
|
||||
self.command_line
|
||||
.push_info("Isolation ended — all objects shown.");
|
||||
} else {
|
||||
self.command_line.push_info("No hidden objects.");
|
||||
}
|
||||
}
|
||||
|
||||
// LAYUNISO — restore all layers that were turned off by LAYISO (turn all on)
|
||||
"LAYUNISO" => {
|
||||
self.push_undo_snapshot(i, "LAYUNISO");
|
||||
let names: Vec<String> = self.tabs[i]
|
||||
.scene
|
||||
.document
|
||||
.layers
|
||||
.iter()
|
||||
.map(|l| l.name.clone())
|
||||
.collect();
|
||||
for name in names {
|
||||
if let Some(dl) = self.tabs[i].scene.document.layers.get_mut(&name) {
|
||||
dl.turn_on();
|
||||
}
|
||||
}
|
||||
self.tabs[i].scene.bump_geometry();
|
||||
self.tabs[i].dirty = true;
|
||||
self.refresh_layer_panel();
|
||||
self.command_line
|
||||
.push_info("LAYUNISO: all layers restored.");
|
||||
}
|
||||
|
||||
"LAYMATCH" | "LAYMCH" => {
|
||||
use crate::modules::draw::layers::match_layer::LayMatchCommand;
|
||||
let dest: Vec<_> = self.tabs[i]
|
||||
.scene
|
||||
.selected_entities()
|
||||
.into_iter()
|
||||
.map(|(h, _)| h)
|
||||
.collect();
|
||||
let cmd = LayMatchCommand::new(dest);
|
||||
self.command_line.push_info(&cmd.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(cmd));
|
||||
}
|
||||
|
||||
"MATCHPROP" | "MA" => {
|
||||
use crate::modules::draw::properties::match_prop::MatchPropCommand;
|
||||
self.tabs[i].scene.deselect_all();
|
||||
let cmd = MatchPropCommand::new();
|
||||
self.command_line.push_info(&cmd.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(cmd));
|
||||
}
|
||||
|
||||
"GROUP" | "G" => {
|
||||
let handles: Vec<_> = self.tabs[i]
|
||||
.scene
|
||||
.selected_entities()
|
||||
.into_iter()
|
||||
.map(|(h, _)| h)
|
||||
.collect();
|
||||
if handles.is_empty() {
|
||||
use crate::modules::draw::select::SelectObjectsCommand;
|
||||
let cmd = SelectObjectsCommand::new("GROUP");
|
||||
self.command_line.push_info(&cmd.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(cmd));
|
||||
} else {
|
||||
let auto_name = super::super::helpers::next_group_auto_name(&self.tabs[i].scene);
|
||||
use crate::modules::draw::groups::group::GroupCommand;
|
||||
let cmd = GroupCommand::new(handles, auto_name);
|
||||
self.command_line.push_info(&cmd.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(cmd));
|
||||
}
|
||||
}
|
||||
|
||||
"UNGROUP" | "UG" => {
|
||||
let handles: Vec<_> = self.tabs[i]
|
||||
.scene
|
||||
.selected_entities()
|
||||
.into_iter()
|
||||
.map(|(h, _)| h)
|
||||
.collect();
|
||||
if handles.is_empty() {
|
||||
use crate::modules::draw::groups::ungroup::UngroupCommand;
|
||||
let cmd = UngroupCommand::new();
|
||||
self.command_line.push_info(&cmd.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(cmd));
|
||||
} else {
|
||||
self.push_undo_snapshot(i, "UNGROUP");
|
||||
let count = self.tabs[i].scene.delete_groups_containing(&handles);
|
||||
self.tabs[i].dirty = true;
|
||||
if count > 0 {
|
||||
self.command_line
|
||||
.push_info(&format!("{} group(s) dissolved.", count));
|
||||
} else {
|
||||
self.command_line
|
||||
.push_info("No groups found for selected objects.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_ => return None,
|
||||
}
|
||||
Some(self.finish_dispatch(cmd))
|
||||
}
|
||||
}
|
||||
178
src/app/commands/mod.rs
Normal file
178
src/app/commands/mod.rs
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
use super::{Message, OpenCADStudio};
|
||||
use crate::command::CadCommand;
|
||||
use crate::scene::Scene;
|
||||
use iced::Task;
|
||||
use std::path::PathBuf;
|
||||
|
||||
mod blocks;
|
||||
mod dim;
|
||||
mod display;
|
||||
mod draw;
|
||||
mod fileops;
|
||||
mod inquiry;
|
||||
mod layerprops;
|
||||
mod layers;
|
||||
mod styleprops;
|
||||
mod view;
|
||||
|
||||
// `DrawOrderRefCommand` lives in the `view` family file but is referenced by
|
||||
// path (`commands::DrawOrderRefCommand`) from `update.rs`, so re-export it at
|
||||
// the module root to keep that path valid.
|
||||
pub(crate) use view::DrawOrderRefCommand;
|
||||
|
||||
impl OpenCADStudio {
|
||||
/// First `"{prefix}{n}"` (n ≥ 1) not already used by a block record in the
|
||||
/// active drawing. Used to auto-name a paste-as-block definition.
|
||||
fn unique_block_name(&self, prefix: &str) -> String {
|
||||
let i = self.active_tab;
|
||||
let mut n = 1;
|
||||
loop {
|
||||
let name = format!("{prefix}{n}");
|
||||
if self.tabs[i].scene.document.block_records.get(&name).is_none() {
|
||||
return name;
|
||||
}
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn dispatch_command(&mut self, cmd: &str) -> Task<Message> {
|
||||
let i = self.active_tab;
|
||||
// Starting a command closes any open ribbon dropdown (e.g. a style
|
||||
// combo left open) so it does not stay stuck behind the new tool.
|
||||
self.ribbon.close_dropdown();
|
||||
// Cancel any running command before starting a new one.
|
||||
if self.tabs[i].active_cmd.is_some() {
|
||||
self.tabs[i].scene.clear_preview_wire();
|
||||
self.tabs[i].active_cmd = None;
|
||||
}
|
||||
// Starting any command leaves interactive PAN mode (the PAN arm below
|
||||
// re-enables it).
|
||||
self.tabs[i].pan_mode = false;
|
||||
// Reset the last committed point so the first click of the new command
|
||||
// is not constrained by ortho/polar relative to a previous command's endpoint.
|
||||
self.last_point = None;
|
||||
// A fresh command starts at the polar/cartesian default — clear
|
||||
// any `,`-driven reshape from a previous command (#35).
|
||||
self.dyn_user_reshaped = false;
|
||||
|
||||
if let Some(path_str) = cmd.strip_prefix("OPEN_RECENT:") {
|
||||
let path = PathBuf::from(path_str);
|
||||
let size = std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0);
|
||||
return Task::done(Message::OpenPathPicked(Some((path, size))));
|
||||
}
|
||||
|
||||
// The Start (welcome) tab has no drawing to act on, so a drawing
|
||||
// command would silently do nothing. Allow only the commands that
|
||||
// make sense there (create / open a document, or quit) and tell the
|
||||
// user otherwise instead of running a no-op. See #96.
|
||||
if self.tabs[i].is_start
|
||||
&& !matches!(
|
||||
cmd,
|
||||
"NEW" | "OPEN" | "EXIT" | "QUIT" | "REPORT" | "CHANGELOG" | "ABOUT"
|
||||
| "PLUGINS" | "PLUGINMANAGER" | "DONATE" | "WEBVERSION"
|
||||
)
|
||||
{
|
||||
self.command_line
|
||||
.push_info("No drawing open. Use NEW or OPEN to start a drawing.");
|
||||
return Task::none();
|
||||
}
|
||||
|
||||
if crate::plugin::try_dispatch(self, i, cmd) {
|
||||
// try_dispatch returns true for both finished commands and interactive
|
||||
// commands that it just installed. If no command is now active, the
|
||||
// tool was a one-shot and we must turn the ribbon highlight off here —
|
||||
// normally apply_cmd_result does that, but plugin dispatch can return
|
||||
// without producing a CmdResult.
|
||||
if self.tabs[i].active_cmd.is_none() {
|
||||
self.ribbon.deactivate_tool();
|
||||
}
|
||||
return Task::none();
|
||||
}
|
||||
|
||||
// Command families are dispatched in source order; the first family
|
||||
// whose `match` arm matches `cmd` handles it. Each handler returns
|
||||
// `Some(task)` when it handled the command (either an early-returning
|
||||
// arm or a state-mutating arm that falls through to `finish_dispatch`),
|
||||
// or `None` to fall through to the next family — exactly as a single
|
||||
// sequential `match` over all arms would behave.
|
||||
if let Some(t) = self.dispatch_fileops(cmd, i) {
|
||||
return t;
|
||||
}
|
||||
if let Some(t) = self.dispatch_layers(cmd, i) {
|
||||
return t;
|
||||
}
|
||||
if let Some(t) = self.dispatch_blocks(cmd, i) {
|
||||
return t;
|
||||
}
|
||||
if let Some(t) = self.dispatch_draw(cmd, i) {
|
||||
return t;
|
||||
}
|
||||
if let Some(t) = self.dispatch_dim(cmd, i) {
|
||||
return t;
|
||||
}
|
||||
if let Some(t) = self.dispatch_inquiry(cmd, i) {
|
||||
return t;
|
||||
}
|
||||
if let Some(t) = self.dispatch_view(cmd, i) {
|
||||
return t;
|
||||
}
|
||||
if let Some(t) = self.dispatch_layerprops(cmd, i) {
|
||||
return t;
|
||||
}
|
||||
if let Some(t) = self.dispatch_styleprops(cmd, i) {
|
||||
return t;
|
||||
}
|
||||
if let Some(t) = self.dispatch_display(cmd, i) {
|
||||
return t;
|
||||
}
|
||||
|
||||
// No family matched.
|
||||
self.command_line
|
||||
.push_error(&format!("Unknown command: {cmd}"));
|
||||
self.finish_dispatch(cmd)
|
||||
}
|
||||
|
||||
/// Shared tail run after a `dispatch_*` family handler whose matched arm
|
||||
/// did not early-return. Focuses the command line whenever a command just
|
||||
/// became active.
|
||||
fn finish_dispatch(&mut self, cmd: &str) -> Task<Message> {
|
||||
let i = self.active_tab;
|
||||
if self.tabs[i].active_cmd.is_some() {
|
||||
self.tabs[i].last_cmd = Some(cmd.to_string());
|
||||
self.focus_cmd_input()
|
||||
} else {
|
||||
Task::none()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ── Autocomplete registry — one-shot commands ──────────────────────────────
|
||||
// These commands dispatch a single action (file ops, view, layer/style
|
||||
// managers, undo/redo, …) rather than installing an interactive `CadCommand`,
|
||||
// so they have no module of their own to register from. They are surfaced for
|
||||
// command-line autocomplete here. Internal dispatch tokens that the user never
|
||||
// types (REFEDIT_BEGIN, REFCLOSE_SAVE, REFCLOSE_DISCARD) are intentionally
|
||||
// excluded.
|
||||
inventory::submit!(crate::command::CommandRegistration {
|
||||
names: &[
|
||||
"3DORBIT", "3O", "ABOUT", "ATTDISP", "ATTEXT", "BACKGROUND", "CDIMSTY", "CELTSCALE",
|
||||
"CHANGELOG", "CHPROP", "CLAYER", "CLEAR", "CLR", "COLORSCHEME", "COUNT", "DATAEXTRACTION",
|
||||
"DE", "DESELALL", "DESELECT", "DIMSTYLE", "DONATE", "DRAWORDER", "DWGPROP", "DWGPROPS",
|
||||
"EATTEXT", "EXIT", "EXPORT", "EXPORTSTEP", "EXPORTSTL", "FILETAB", "FIND", "FLATTEN",
|
||||
"HELP", "HIDEOBJECTS", "IM", "IMAGE", "IMAGEATTACH", "IMPORTOBJ", "ISOLATEOBJECTS", "LA",
|
||||
"LAYER", "LAYERS", "LAYISO", "LAYON", "LAYOUTMANAGER", "LAYOUTPANEL", "LAYOUTTAB", "LAYTHW",
|
||||
"LAYUNISO", "LI", "LINETYPE", "LIST", "LTSCALE", "LWDISPLAY", "MASSPROP", "MLEADERSTYLE",
|
||||
"MLSTYLE", "MS", "MSPACE", "NAVVCUBE", "NEW", "OBJIMPORT", "OPEN", "ORTHO",
|
||||
"P", "PAN", "PAGESETUP", "PERF", "PERSP", "PLOT", "PLOTSTYLE", "PLOTSTYLEEDITOR",
|
||||
"PLOTSTYLEPANEL", "PR",
|
||||
"PRINT", "PROPERTIES", "PROPS", "PSPACE", "PURGE", "QS", "QSAVE", "QSELECT",
|
||||
"QUIT", "REDO", "REDRAW", "REDRWALL", "REGEN", "REGENALL", "RENAME", "REPORT",
|
||||
"SA", "SAVE", "SAVEAS", "SCALETEXT", "SELECTALL", "SELECTSIMILAR", "SELSIM", "SHEETSET",
|
||||
"SHORTCUTS", "SOLID", "SSM", "STEPOUT", "STLOUT", "STPOUT", "STYLE", "STYLESMANAGER",
|
||||
"TABLESTYLE", "TOOLPALETTES", "TP", "TS", "U", "UCS", "UCSICON", "UNDERLAY",
|
||||
"UNDO", "UNISOLATEOBJECTS", "USERI", "USERR", "VIEW", "VPORTS", "VS", "VW",
|
||||
"WB", "WBLOCK", "WEBVERSION", "WIREFRAME", "XA", "XATTACH", "XDATA", "XR",
|
||||
"XREF", "XRELOAD", "ZOOM", "ZS",
|
||||
]
|
||||
});
|
||||
638
src/app/commands/styleprops.rs
Normal file
638
src/app/commands/styleprops.rs
Normal file
|
|
@ -0,0 +1,638 @@
|
|||
use super::*;
|
||||
|
||||
impl OpenCADStudio {
|
||||
pub(super) fn dispatch_styleprops(&mut self, cmd: &str, i: usize) -> Option<Task<Message>> {
|
||||
match cmd {
|
||||
// ── LINETYPE management ───────────────────────────────────────
|
||||
cmd if cmd == "LINETYPE"
|
||||
|| cmd == "LT"
|
||||
|| cmd.starts_with("LINETYPE ")
|
||||
|| cmd.starts_with("LT ") =>
|
||||
{
|
||||
let raw_rest = cmd.split_once(' ').map(|(_, r)| r.trim()).unwrap_or("");
|
||||
let parts: Vec<&str> = raw_rest.split_whitespace().collect();
|
||||
let sub = parts.get(0).map(|s| s.to_uppercase()).unwrap_or_default();
|
||||
match sub.as_str() {
|
||||
"" | "LIST" | "?" => {
|
||||
let ltypes: Vec<String> = self.tabs[i]
|
||||
.scene
|
||||
.document
|
||||
.line_types
|
||||
.iter()
|
||||
.map(|lt| format!("{} ({})", lt.name, lt.description))
|
||||
.collect();
|
||||
if ltypes.is_empty() {
|
||||
self.command_line.push_output("No linetypes defined.");
|
||||
} else {
|
||||
self.command_line
|
||||
.push_output(&format!("Linetypes: {}", ltypes.join(", ")));
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
self.command_line.push_info("Usage: LINETYPE LIST");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── PURGE unused definitions ──────────────────────────────────
|
||||
cmd if cmd == "PURGE" || cmd.starts_with("PURGE ") => {
|
||||
let sub = cmd
|
||||
.split_whitespace()
|
||||
.nth(1)
|
||||
.unwrap_or("ALL")
|
||||
.to_uppercase();
|
||||
let all = sub == "ALL" || sub.is_empty();
|
||||
|
||||
// Collect names in use (immutable borrows — done in their own scope)
|
||||
let used_layers: rustc_hash::FxHashSet<String> = self.tabs[i]
|
||||
.scene
|
||||
.document
|
||||
.entities()
|
||||
.filter_map(|e| {
|
||||
let name = &e.common().layer;
|
||||
if name.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(name.clone())
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let used_text_styles: rustc_hash::FxHashSet<String> = self.tabs[i]
|
||||
.scene
|
||||
.document
|
||||
.entities()
|
||||
.filter_map(|e| match e {
|
||||
acadrust::EntityType::Text(t) => Some(t.style.clone()),
|
||||
acadrust::EntityType::MText(t) => Some(t.style.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect();
|
||||
let used_linetypes: rustc_hash::FxHashSet<String> = self.tabs[i]
|
||||
.scene
|
||||
.document
|
||||
.entities()
|
||||
.filter_map(|e| {
|
||||
let lt = &e.common().linetype;
|
||||
if lt.is_empty() || lt == "ByLayer" || lt == "ByBlock" {
|
||||
None
|
||||
} else {
|
||||
Some(lt.clone())
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Build removal lists (still immutable)
|
||||
let layer_remove: Vec<String> = if all || sub == "LAYERS" {
|
||||
self.tabs[i]
|
||||
.scene
|
||||
.document
|
||||
.layers
|
||||
.iter()
|
||||
.filter(|l| l.name != "0" && !used_layers.contains(&l.name))
|
||||
.map(|l| l.name.clone())
|
||||
.collect()
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
let style_remove: Vec<String> = if all || sub == "TEXTSTYLES" || sub == "STYLES" {
|
||||
self.tabs[i]
|
||||
.scene
|
||||
.document
|
||||
.text_styles
|
||||
.iter()
|
||||
.filter(|s| s.name != "Standard" && !used_text_styles.contains(&s.name))
|
||||
.map(|s| s.name.clone())
|
||||
.collect()
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
let lt_remove: Vec<String> = if all || sub == "LINETYPES" || sub == "LT" {
|
||||
let standard = ["Continuous", "ByLayer", "ByBlock"];
|
||||
self.tabs[i]
|
||||
.scene
|
||||
.document
|
||||
.line_types
|
||||
.iter()
|
||||
.filter(|lt| {
|
||||
!standard.iter().any(|s| s.eq_ignore_ascii_case(<.name))
|
||||
&& !used_linetypes.contains(<.name)
|
||||
})
|
||||
.map(|lt| lt.name.clone())
|
||||
.collect()
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
|
||||
// Apply removals (mutable)
|
||||
let purged = layer_remove.len() + style_remove.len() + lt_remove.len();
|
||||
for name in &layer_remove {
|
||||
self.tabs[i].scene.document.layers.remove(name);
|
||||
}
|
||||
for name in &style_remove {
|
||||
self.tabs[i].scene.document.text_styles.remove(name);
|
||||
}
|
||||
for name in <_remove {
|
||||
self.tabs[i].scene.document.line_types.remove(name);
|
||||
}
|
||||
|
||||
if purged > 0 {
|
||||
self.push_undo_snapshot(i, "PURGE");
|
||||
self.tabs[i].dirty = true;
|
||||
self.command_line
|
||||
.push_output(&format!("PURGE: {} definition(s) removed.", purged));
|
||||
} else {
|
||||
self.command_line.push_output("PURGE: nothing to purge.");
|
||||
}
|
||||
}
|
||||
|
||||
// ── CHPROP — change entity properties from command line ───────
|
||||
cmd if cmd == "CHPROP" || cmd.starts_with("CHPROP ") => {
|
||||
// Usage: CHPROP <property> <value>
|
||||
// Applies to currently selected entities.
|
||||
// Properties: LAYER, COLOR, LINETYPE, LTSCALE
|
||||
let parts: Vec<&str> = cmd.split_whitespace().collect();
|
||||
let prop = parts.get(1).map(|s| s.to_uppercase()).unwrap_or_default();
|
||||
let value = parts.get(2).map(|s| s.trim()).unwrap_or("").to_string();
|
||||
|
||||
if prop.is_empty() {
|
||||
self.command_line.push_info(
|
||||
"Usage: CHPROP <prop> <val> (props: LAYER COLOR LINETYPE LTSCALE)",
|
||||
);
|
||||
} else {
|
||||
let handles: Vec<_> = self.tabs[i]
|
||||
.scene
|
||||
.selected_entities()
|
||||
.into_iter()
|
||||
.map(|(h, _)| h)
|
||||
.collect();
|
||||
if handles.is_empty() {
|
||||
self.command_line
|
||||
.push_error("CHPROP: no entities selected.");
|
||||
} else {
|
||||
// Validate value early to give clear errors
|
||||
let color_val: Option<acadrust::types::Color> = if prop == "COLOR" {
|
||||
value
|
||||
.parse::<i16>()
|
||||
.ok()
|
||||
.map(acadrust::types::Color::from_index)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
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 {
|
||||
let mut changed = 0usize;
|
||||
for handle in &handles {
|
||||
if let Some(entity) =
|
||||
self.tabs[i].scene.document.get_entity_mut(*handle)
|
||||
{
|
||||
let common = entity.common_mut();
|
||||
match prop.as_str() {
|
||||
"LAYER" => {
|
||||
common.layer = value.clone();
|
||||
changed += 1;
|
||||
}
|
||||
"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 TRANSPARENCY", prop
|
||||
));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if changed > 0 {
|
||||
self.push_undo_snapshot(i, "CHPROP");
|
||||
self.tabs[i].dirty = true;
|
||||
self.command_line.push_output(&format!(
|
||||
"CHPROP: {} entity/entities updated.",
|
||||
changed
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── RENAME table entries ──────────────────────────────────────
|
||||
cmd if cmd == "RENAME" || cmd.starts_with("RENAME ") => {
|
||||
// Usage: RENAME <type> <old_name> <new_name>
|
||||
// Types: LAYER BLOCK STYLE DIMSTYLE LINETYPE UCS VIEW
|
||||
let parts: Vec<&str> = cmd.split_whitespace().collect();
|
||||
let type_str = parts.get(1).map(|s| s.to_uppercase()).unwrap_or_default();
|
||||
let old_name = parts.get(2).map(|s| s.trim()).unwrap_or("").to_string();
|
||||
let new_name = parts.get(3).map(|s| s.trim()).unwrap_or("").to_string();
|
||||
|
||||
if type_str.is_empty() || old_name.is_empty() || new_name.is_empty() {
|
||||
self.command_line.push_info(
|
||||
"Usage: RENAME <type> <old> <new> (types: LAYER BLOCK STYLE DIMSTYLE LINETYPE UCS VIEW)"
|
||||
);
|
||||
} else {
|
||||
let doc = &mut self.tabs[i].scene.document;
|
||||
let ok = match type_str.as_str() {
|
||||
"LAYER" => {
|
||||
if let Some(l) = doc.layers.get_mut(&old_name) {
|
||||
l.name = new_name.clone();
|
||||
// Update entity references
|
||||
for e in doc.entities_mut() {
|
||||
if e.common().layer == old_name {
|
||||
e.common_mut().layer = new_name.clone();
|
||||
}
|
||||
}
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
"STYLE" | "TEXTSTYLE" => {
|
||||
if let Some(s) = doc.text_styles.get_mut(&old_name) {
|
||||
s.name = new_name.clone();
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
"DIMSTYLE" => {
|
||||
if let Some(s) = doc.dim_styles.get_mut(&old_name) {
|
||||
s.name = new_name.clone();
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
"LINETYPE" | "LT" => {
|
||||
if let Some(lt) = doc.line_types.get_mut(&old_name) {
|
||||
lt.name = new_name.clone();
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
"UCS" => {
|
||||
if let Some(u) = doc.ucss.get_mut(&old_name) {
|
||||
u.name = new_name.clone();
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
"VIEW" => {
|
||||
if let Some(v) = doc.views.get_mut(&old_name) {
|
||||
v.name = new_name.clone();
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
self.command_line.push_error(&format!("RENAME: unknown type '{}'. Use LAYER BLOCK STYLE DIMSTYLE LINETYPE UCS VIEW", type_str));
|
||||
false
|
||||
}
|
||||
};
|
||||
if ok {
|
||||
self.push_undo_snapshot(i, "RENAME");
|
||||
self.tabs[i].dirty = true;
|
||||
self.command_line
|
||||
.push_output(&format!("RENAME: '{}' → '{}'.", old_name, new_name));
|
||||
} else if type_str != "BLOCK" {
|
||||
self.command_line.push_error(&format!(
|
||||
"RENAME: '{}' not found in {}.",
|
||||
old_name, type_str
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
"LTSCALE" => {
|
||||
use crate::command::ValuePromptCommand;
|
||||
let c = ValuePromptCommand::new("LTSCALE", "LTSCALE new global line-type scale:");
|
||||
self.command_line.push_info(&c.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(c));
|
||||
}
|
||||
cmd if 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]");
|
||||
}
|
||||
}
|
||||
"PDMODE" => {
|
||||
use crate::command::ValuePromptCommand;
|
||||
let c = ValuePromptCommand::new(
|
||||
"PDMODE",
|
||||
"PDMODE new value [0=dot 1=none 2=+ 3=x 4=tick; +32 circle +64 square]:",
|
||||
);
|
||||
self.command_line.push_info(&c.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(c));
|
||||
}
|
||||
cmd if cmd.starts_with("PDMODE ") => {
|
||||
let val_str = cmd.trim_start_matches("PDMODE").trim();
|
||||
if val_str.is_empty() {
|
||||
let v = self.tabs[i].scene.document.header.point_display_mode;
|
||||
self.command_line.push_output(&format!("PDMODE = {v}"));
|
||||
} else if let Ok(v) = val_str.parse::<i16>() {
|
||||
self.push_undo_snapshot(i, "PDMODE");
|
||||
self.tabs[i].scene.document.header.point_display_mode = v;
|
||||
// Point glyphs are built at tessellation time — rebuild them.
|
||||
self.tabs[i].scene.bump_geometry();
|
||||
self.tabs[i].dirty = true;
|
||||
self.command_line.push_output(&format!("PDMODE set to {v}"));
|
||||
} else {
|
||||
self.command_line.push_error(
|
||||
"Usage: PDMODE [value] (0=dot 1=none 2=+ 3=x 4=tick; +32 circle, +64 square)",
|
||||
);
|
||||
}
|
||||
}
|
||||
cmd if cmd.starts_with("TEXTEDITMODE ") => {
|
||||
let val_str = cmd.trim_start_matches("TEXTEDITMODE").trim().to_lowercase();
|
||||
if val_str.is_empty() {
|
||||
let v = if self.texteditmode { 1 } else { 0 };
|
||||
self.command_line.push_output(&format!("TEXTEDITMODE = {v}"));
|
||||
} else if let Some(v) =
|
||||
crate::modules::annotate::textedit::parse_texteditmode(&val_str)
|
||||
{
|
||||
self.texteditmode = v;
|
||||
let n = if v { 1 } else { 0 };
|
||||
self.command_line
|
||||
.push_output(&format!("TEXTEDITMODE set to {n}"));
|
||||
} else {
|
||||
self.command_line
|
||||
.push_error("Requires 0 OR 1 OR MULTIPLE OR SINGLE");
|
||||
}
|
||||
}
|
||||
"PDSIZE" => {
|
||||
use crate::command::ValuePromptCommand;
|
||||
let c = ValuePromptCommand::new(
|
||||
"PDSIZE",
|
||||
"PDSIZE new point size (0 = 5% of viewport, <0 = absolute):",
|
||||
);
|
||||
self.command_line.push_info(&c.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(c));
|
||||
}
|
||||
cmd if cmd.starts_with("PDSIZE ") => {
|
||||
let val_str = cmd.trim_start_matches("PDSIZE").trim();
|
||||
if val_str.is_empty() {
|
||||
let v = self.tabs[i].scene.document.header.point_display_size;
|
||||
self.command_line.push_output(&format!("PDSIZE = {v:.4}"));
|
||||
} else if let Ok(v) = val_str.parse::<f64>() {
|
||||
self.push_undo_snapshot(i, "PDSIZE");
|
||||
self.tabs[i].scene.document.header.point_display_size = v;
|
||||
self.tabs[i].scene.bump_geometry();
|
||||
self.tabs[i].dirty = true;
|
||||
self.command_line.push_output(&format!("PDSIZE set to {v:.4}"));
|
||||
} else {
|
||||
self.command_line.push_error(
|
||||
"Usage: PDSIZE [value] (>0 absolute size, <0 percent of viewport, 0 default)",
|
||||
);
|
||||
}
|
||||
}
|
||||
cmd if cmd == "DDPTYPE" => {
|
||||
// The dialog shows the magnitude; the sign (relative/absolute)
|
||||
// is driven by the radio buttons. A positive PDSIZE is absolute;
|
||||
// zero or negative is relative.
|
||||
let pdsize = self.tabs[i].scene.document.header.point_display_size;
|
||||
self.point_size_relative = pdsize <= 0.0;
|
||||
self.point_size_buf = format!("{}", pdsize.abs());
|
||||
self.active_modal = Some(super::super::ModalKind::PointStyle);
|
||||
}
|
||||
cmd if cmd == "LWDISPLAY" || cmd.starts_with("LWDISPLAY ") => {
|
||||
let val_str = cmd.trim_start_matches("LWDISPLAY").trim();
|
||||
let parsed: Result<Option<bool>, ()> =
|
||||
match val_str.to_ascii_uppercase().as_str() {
|
||||
"" => Ok(None),
|
||||
"ON" | "1" | "TRUE" => Ok(Some(true)),
|
||||
"OFF" | "0" | "FALSE" => Ok(Some(false)),
|
||||
_ => Err(()),
|
||||
};
|
||||
match parsed {
|
||||
Err(_) => self
|
||||
.command_line
|
||||
.push_error("Usage: LWDISPLAY [ON|OFF]"),
|
||||
Ok(Some(v)) => {
|
||||
self.push_undo_snapshot(i, "LWDISPLAY");
|
||||
self.tabs[i].scene.document.header.lineweight_display = v;
|
||||
// No retessellate — the wire shader honours the flag via uniforms.
|
||||
self.tabs[i].dirty = true;
|
||||
self.command_line.push_output(&format!(
|
||||
"LWDISPLAY {}",
|
||||
if v { "ON" } else { "OFF" }
|
||||
));
|
||||
}
|
||||
Ok(None) => {
|
||||
let v = self.tabs[i].scene.document.header.lineweight_display;
|
||||
self.command_line.push_output(&format!(
|
||||
"LWDISPLAY = {}",
|
||||
if v { "ON" } else { "OFF" }
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
"CELTSCALE" => {
|
||||
use crate::command::ValuePromptCommand;
|
||||
let c = ValuePromptCommand::new(
|
||||
"CELTSCALE",
|
||||
"CELTSCALE new current-object line-type scale:",
|
||||
);
|
||||
self.command_line.push_info(&c.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(c));
|
||||
}
|
||||
cmd if 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>");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_ => return None,
|
||||
}
|
||||
Some(self.finish_dispatch(cmd))
|
||||
}
|
||||
}
|
||||
898
src/app/commands/view.rs
Normal file
898
src/app/commands/view.rs
Normal file
|
|
@ -0,0 +1,898 @@
|
|||
use super::*;
|
||||
|
||||
impl OpenCADStudio {
|
||||
pub(super) fn dispatch_view(&mut self, cmd: &str, i: usize) -> Option<Task<Message>> {
|
||||
match cmd {
|
||||
"HELP" | "?" => {
|
||||
self.command_line.push_output(
|
||||
"Draw: LINE CIRCLE ARC PLINE RECTANG(RECT) POLYGON(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 DIMCONTINUE DIMBASELINE | \
|
||||
Annotation: TOLERANCE | \
|
||||
Inquiry: DIST ID AREA LIST FIND FINDALL COUNT QSELECT | Draw on entity: DIVIDE MEASURE | \
|
||||
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 | \
|
||||
File: NEW OPEN SAVE SAVEAS PRINT PURGE UNDO REDO"
|
||||
);
|
||||
}
|
||||
|
||||
"DONATE" => {
|
||||
crate::sys::open_url("https://patreon.com/HakanSeven12");
|
||||
self.command_line.push_info("Opening Patreon page...");
|
||||
}
|
||||
|
||||
"WEBVERSION" => {
|
||||
crate::sys::open_url("https://hakanseven12.github.io/OpenCADStudio/");
|
||||
self.command_line.push_info("Opening OCS Web...");
|
||||
}
|
||||
|
||||
// ── DWGPROPS — print round-trip-only HeaderVariables ─────────
|
||||
// No UI dialog for these yet; the command surfaces them so
|
||||
// users can confirm the values that the parser populated and
|
||||
// the writer will round-trip on save.
|
||||
"DWGPROPS" | "DWGPROP" => {
|
||||
let i = self.active_tab;
|
||||
let h = &self.tabs[i].scene.document.header;
|
||||
let path_label = self.tabs[i]
|
||||
.current_path
|
||||
.as_ref()
|
||||
.map(|p| p.display().to_string())
|
||||
.unwrap_or_else(|| "(unsaved)".to_string());
|
||||
self.command_line
|
||||
.push_output(&format!("Drawing: {}", path_label));
|
||||
self.command_line.push_output(&format!(
|
||||
" Created (Julian): {:.6}",
|
||||
h.create_date_julian
|
||||
));
|
||||
self.command_line.push_output(&format!(
|
||||
" Updated (Julian): {:.6}",
|
||||
h.update_date_julian
|
||||
));
|
||||
self.command_line.push_output(&format!(
|
||||
" Total edit time: {:.4}",
|
||||
h.total_editing_time
|
||||
));
|
||||
self.command_line.push_output(&format!(
|
||||
" User elapsed: {:.4}",
|
||||
h.user_elapsed_time
|
||||
));
|
||||
self.command_line.push_output(&format!(
|
||||
" Last saved by: {}",
|
||||
if h.last_saved_by.is_empty() {
|
||||
"(unknown)"
|
||||
} else {
|
||||
&h.last_saved_by
|
||||
}
|
||||
));
|
||||
self.command_line.push_output(&format!(
|
||||
" Fingerprint GUID: {}",
|
||||
if h.fingerprint_guid.is_empty() {
|
||||
"(none)"
|
||||
} else {
|
||||
&h.fingerprint_guid
|
||||
}
|
||||
));
|
||||
self.command_line.push_output(&format!(
|
||||
" Version GUID: {}",
|
||||
if h.version_guid.is_empty() {
|
||||
"(none)"
|
||||
} else {
|
||||
&h.version_guid
|
||||
}
|
||||
));
|
||||
self.command_line
|
||||
.push_output(&format!(" Code page: {}", h.code_page));
|
||||
self.command_line.push_output(&format!(
|
||||
" Menu name: {}",
|
||||
if h.menu_name.is_empty() {
|
||||
"(none)"
|
||||
} else {
|
||||
&h.menu_name
|
||||
}
|
||||
));
|
||||
self.command_line.push_output(&format!(
|
||||
" Hyperlink base: {}",
|
||||
if h.hyperlink_base.is_empty() {
|
||||
"(none)"
|
||||
} else {
|
||||
&h.hyperlink_base
|
||||
}
|
||||
));
|
||||
self.command_line.push_output(&format!(
|
||||
" Project name: {}",
|
||||
if h.project_name.is_empty() {
|
||||
"(none)"
|
||||
} else {
|
||||
&h.project_name
|
||||
}
|
||||
));
|
||||
self.command_line.push_output(&format!(
|
||||
" Stylesheet: {}",
|
||||
if h.stylesheet.is_empty() {
|
||||
"(none)"
|
||||
} else {
|
||||
&h.stylesheet
|
||||
}
|
||||
));
|
||||
self.command_line.push_output(&format!(
|
||||
" Required versions: {:#018x}",
|
||||
h.required_versions
|
||||
));
|
||||
self.command_line.push_output(&format!(
|
||||
" Measurement: {} ({})",
|
||||
h.measurement,
|
||||
if h.measurement == 1 { "Metric" } else { "Imperial" }
|
||||
));
|
||||
self.command_line.push_output(&format!(
|
||||
" Proxy graphics: {}",
|
||||
h.proxy_graphics
|
||||
));
|
||||
self.command_line
|
||||
.push_output(&format!(" Tree depth: {}", h.tree_depth));
|
||||
self.command_line.push_output(&format!(
|
||||
" User vars (int): {} {} {} {} {}",
|
||||
h.user_int1, h.user_int2, h.user_int3, h.user_int4, h.user_int5
|
||||
));
|
||||
self.command_line.push_output(&format!(
|
||||
" User vars (real): {:.6} {:.6} {:.6} {:.6} {:.6}",
|
||||
h.user_real1, h.user_real2, h.user_real3, h.user_real4, h.user_real5
|
||||
));
|
||||
self.command_line.push_output(&format!(
|
||||
" User timer: {}",
|
||||
if h.user_timer { "On" } else { "Off" }
|
||||
));
|
||||
}
|
||||
|
||||
// Edit a USERI1..USERI5 / USERR1..USERR5 slot. Lets the user
|
||||
// store drawing-scoped scalars (and save them through round-trip)
|
||||
// even though we don't have a LISP / DIESEL runtime yet.
|
||||
// USERI 1 42 → header.user_int1 = 42
|
||||
// USERR 3 1.5e-3 → header.user_real3 = 0.0015
|
||||
cmd if cmd.starts_with("USERI") || cmd.starts_with("USERR") => {
|
||||
let is_real = cmd.starts_with("USERR");
|
||||
let rest = if is_real {
|
||||
cmd.trim_start_matches("USERR").trim()
|
||||
} else {
|
||||
cmd.trim_start_matches("USERI").trim()
|
||||
};
|
||||
let parts: Vec<&str> = rest.splitn(2, ' ').collect();
|
||||
let slot: Option<usize> = parts.first().and_then(|s| s.parse().ok());
|
||||
let value = parts.get(1).copied().unwrap_or("").trim();
|
||||
let i = self.active_tab;
|
||||
let h = &mut self.tabs[i].scene.document.header;
|
||||
match (slot, value, is_real) {
|
||||
(Some(n @ 1..=5), v, true) => {
|
||||
if let Ok(val) = v.parse::<f64>() {
|
||||
match n {
|
||||
1 => h.user_real1 = val,
|
||||
2 => h.user_real2 = val,
|
||||
3 => h.user_real3 = val,
|
||||
4 => h.user_real4 = val,
|
||||
_ => h.user_real5 = val,
|
||||
}
|
||||
self.tabs[i].dirty = true;
|
||||
self.command_line
|
||||
.push_output(&format!("USERR{n} = {val}"));
|
||||
} else {
|
||||
self.command_line
|
||||
.push_info("Usage: USERR <1-5> <real>");
|
||||
}
|
||||
}
|
||||
(Some(n @ 1..=5), v, false) => {
|
||||
if let Ok(val) = v.parse::<i16>() {
|
||||
match n {
|
||||
1 => h.user_int1 = val,
|
||||
2 => h.user_int2 = val,
|
||||
3 => h.user_int3 = val,
|
||||
4 => h.user_int4 = val,
|
||||
_ => h.user_int5 = val,
|
||||
}
|
||||
self.tabs[i].dirty = true;
|
||||
self.command_line
|
||||
.push_output(&format!("USERI{n} = {val}"));
|
||||
} else {
|
||||
self.command_line
|
||||
.push_info("Usage: USERI <1-5> <integer>");
|
||||
}
|
||||
}
|
||||
_ => self
|
||||
.command_line
|
||||
.push_info("Usage: USERI <1-5> <int> | USERR <1-5> <real>"),
|
||||
}
|
||||
}
|
||||
|
||||
"REPORT" => {
|
||||
// Pre-fill the GitHub issue body with version + platform so
|
||||
// reports arrive with the basics already filled in.
|
||||
let body = format!(
|
||||
"<!-- Describe the issue and the steps to reproduce it. -->\n\n\n\
|
||||
---\n- Open CAD Studio: v{}\n- Platform: {}\n",
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
crate::sys::platform_info(),
|
||||
);
|
||||
let url = format!(
|
||||
"https://github.com/HakanSeven12/OpenCADStudio/issues/new?body={}",
|
||||
crate::sys::percent_encode(&body)
|
||||
);
|
||||
crate::sys::open_url(&url);
|
||||
self.command_line.push_info("Opening feedback page...");
|
||||
}
|
||||
|
||||
"ABOUT" => {
|
||||
return Some(Task::done(Message::AboutOpen));
|
||||
}
|
||||
|
||||
"PLUGINS" | "PLUGINMANAGER" => {
|
||||
return Some(Task::done(Message::PluginManagerOpen));
|
||||
}
|
||||
|
||||
"CHANGELOG" => {
|
||||
crate::sys::open_url("https://github.com/HakanSeven12/OpenCADStudio/releases");
|
||||
self.command_line.push_info("Opening release notes...");
|
||||
}
|
||||
|
||||
// ── Keyboard Shortcuts panel ──────────────────────────────────
|
||||
cmd if cmd == "SHORTCUTS" || cmd.starts_with("SHORTCUTS ") => {
|
||||
let raw_rest = cmd.trim_start_matches("SHORTCUTS").trim();
|
||||
let parts: Vec<&str> = raw_rest.splitn(3, ' ').collect();
|
||||
let sub = parts.first().map(|s| s.to_uppercase()).unwrap_or_default();
|
||||
match sub.as_str() {
|
||||
"" | "LIST" | "?" => {
|
||||
return Some(Task::done(Message::ShortcutsPanelOpen));
|
||||
}
|
||||
"SET" | "S" => {
|
||||
// SHORTCUTS SET <key> <command>
|
||||
// e.g. SHORTCUTS SET CTRL+D DIST
|
||||
let key = parts.get(1).map(|s| s.to_uppercase()).unwrap_or_default();
|
||||
let cmd_str = parts.get(2).map(|s| s.to_uppercase()).unwrap_or_default();
|
||||
if key.is_empty() || cmd_str.is_empty() {
|
||||
self.command_line.push_error("Usage: SHORTCUTS SET <key> <command> e.g. SHORTCUTS SET CTRL+D DIST");
|
||||
} else {
|
||||
self.shortcut_overrides.insert(key.clone(), cmd_str.clone());
|
||||
self.command_line
|
||||
.push_output(&format!("Shortcut set: {key} → {cmd_str}"));
|
||||
}
|
||||
}
|
||||
"CLEAR" | "DELETE" | "REMOVE" => {
|
||||
let key = parts.get(1).map(|s| s.to_uppercase()).unwrap_or_default();
|
||||
if key.is_empty() {
|
||||
self.command_line.push_error("Usage: SHORTCUTS CLEAR <key>");
|
||||
} else if self.shortcut_overrides.remove(&key).is_some() {
|
||||
self.command_line
|
||||
.push_output(&format!("Shortcut '{key}' removed."));
|
||||
} else {
|
||||
self.command_line
|
||||
.push_error(&format!("Shortcut '{key}' not found."));
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
self.command_line
|
||||
.push_info("Usage: SHORTCUTS LIST | SET <key> <cmd> | CLEAR <key>");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Color Scheme / Theme selector ─────────────────────────────
|
||||
cmd if cmd == "COLORSCHEME" || cmd.starts_with("COLORSCHEME ") => {
|
||||
use iced::Theme;
|
||||
let sub = cmd
|
||||
.split_once(' ')
|
||||
.map(|(_, r)| r.trim())
|
||||
.unwrap_or("")
|
||||
.to_uppercase();
|
||||
// Map name to Theme variant.
|
||||
let theme: Option<Theme> = match sub.as_str() {
|
||||
"DARK" => Some(Theme::Dark),
|
||||
"LIGHT" => Some(Theme::Light),
|
||||
"DRACULA" => Some(Theme::Dracula),
|
||||
"NORD" => Some(Theme::Nord),
|
||||
"SOLARIZED_LIGHT" | "SOLARIZEDLIGHT" => Some(Theme::SolarizedLight),
|
||||
"SOLARIZED_DARK" | "SOLARIZEDDARK" => Some(Theme::SolarizedDark),
|
||||
"GRUVBOX_LIGHT" | "GRUVBOXLIGHT" => Some(Theme::GruvboxLight),
|
||||
"GRUVBOX_DARK" | "GRUVBOXDARK" => Some(Theme::GruvboxDark),
|
||||
"TOKYONIGHT" | "TOKYO_NIGHT" => Some(Theme::TokyoNight),
|
||||
"TOKYONIGHTSTORM" | "TOKYO_NIGHT_STORM" => Some(Theme::TokyoNightStorm),
|
||||
"TOKYONIGHTLIGHT" | "TOKYO_NIGHT_LIGHT" => Some(Theme::TokyoNightLight),
|
||||
"KANAGAWAWAVE" | "KANAGAWA_WAVE" => Some(Theme::KanagawaWave),
|
||||
"KANAGAWADRAGON" | "KANAGAWA_DRAGON" => Some(Theme::KanagawaDragon),
|
||||
"KANAGAWALOTUS" | "KANAGAWA_LOTUS" => Some(Theme::KanagawaLotus),
|
||||
"MOONFLY" => Some(Theme::Moonfly),
|
||||
"NIGHTFLY" => Some(Theme::Nightfly),
|
||||
"OXOCARBON" => Some(Theme::Oxocarbon),
|
||||
"FERRA" => Some(Theme::Ferra),
|
||||
"" | "LIST" | "?" => {
|
||||
self.command_line.push_output(
|
||||
"Available themes: DARK LIGHT DRACULA NORD SOLARIZED_LIGHT SOLARIZED_DARK \
|
||||
GRUVBOX_LIGHT GRUVBOX_DARK TOKYONIGHT TOKYONIGHTSTORM TOKYONIGHTLIGHT \
|
||||
KANAGAWAWAVE KANAGAWADRAGON KANAGAWALOTUS MOONFLY NIGHTFLY OXOCARBON FERRA"
|
||||
);
|
||||
return Some(Task::none());
|
||||
}
|
||||
_ => {
|
||||
self.command_line.push_error(&format!(
|
||||
"COLORSCHEME: unknown theme '{}'. Type COLORSCHEME LIST for options.",
|
||||
sub
|
||||
));
|
||||
return Some(Task::none());
|
||||
}
|
||||
};
|
||||
if let Some(t) = theme {
|
||||
let name = format!("{:?}", t);
|
||||
self.command_line
|
||||
.push_output(&format!("Color scheme set to '{name}'."));
|
||||
return Some(Task::done(Message::SetTheme(t)));
|
||||
}
|
||||
return Some(Task::none());
|
||||
}
|
||||
|
||||
// ── Layout Manager GUI ─────────────────────────────────────────
|
||||
"LAYOUTMANAGER" | "LAYOUTPANEL" => {
|
||||
return Some(Task::done(Message::LayoutManagerOpen));
|
||||
}
|
||||
|
||||
// ── Layout / viewport ──────────────────────────────────────────
|
||||
"MVIEW" | "MV" => {
|
||||
if self.tabs[i].scene.current_layout == "Model" {
|
||||
self.command_line
|
||||
.push_error("MVIEW: switch to a paper space layout first.");
|
||||
} else {
|
||||
use crate::modules::layout::mview::MviewCommand;
|
||||
let new_cmd = MviewCommand::new();
|
||||
self.command_line.push_info(&new_cmd.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(new_cmd));
|
||||
}
|
||||
}
|
||||
|
||||
// ── MSPACE / PSPACE ───────────────────────────────────────────
|
||||
"MS" | "MSPACE" => {
|
||||
return Some(Task::done(Message::MspaceCommand));
|
||||
}
|
||||
"PSPACE" => {
|
||||
return Some(Task::done(Message::PspaceCommand));
|
||||
}
|
||||
|
||||
// ── VPORTS — list or create preset viewport configurations ────
|
||||
cmd if cmd == "VPORTS" || cmd.starts_with("VPORTS ") => {
|
||||
let sub = cmd.split_whitespace().nth(1).unwrap_or("").to_uppercase();
|
||||
let scene = &self.tabs[i].scene;
|
||||
if scene.current_layout == "Model" {
|
||||
// Bare VPORTS → ask for the configuration interactively;
|
||||
// the next command-line entry supplies it.
|
||||
if sub.is_empty() {
|
||||
self.awaiting_vports = true;
|
||||
self.command_line
|
||||
.push_info("VPORTS Configuration [SIngle/2H/2V/4]:");
|
||||
return Some(self.focus_cmd_input());
|
||||
}
|
||||
// Model space: split the tiled viewport layout.
|
||||
use iced::Rectangle as R;
|
||||
let full = R { x: 0.0, y: 0.0, width: 1.0, height: 1.0 };
|
||||
let rects: Option<Vec<R>> = match sub.as_str() {
|
||||
"SINGLE" | "SI" | "1" => Some(vec![full]),
|
||||
"2H" | "2" => Some(vec![
|
||||
R { x: 0.0, y: 0.0, width: 1.0, height: 0.5 },
|
||||
R { x: 0.0, y: 0.5, width: 1.0, height: 0.5 },
|
||||
]),
|
||||
"2V" => Some(vec![
|
||||
R { x: 0.0, y: 0.0, width: 0.5, height: 1.0 },
|
||||
R { x: 0.5, y: 0.0, width: 0.5, height: 1.0 },
|
||||
]),
|
||||
"4" => Some(vec![
|
||||
R { x: 0.0, y: 0.0, width: 0.5, height: 0.5 },
|
||||
R { x: 0.5, y: 0.0, width: 0.5, height: 0.5 },
|
||||
R { x: 0.0, y: 0.5, width: 0.5, height: 0.5 },
|
||||
R { x: 0.5, y: 0.5, width: 0.5, height: 0.5 },
|
||||
]),
|
||||
_ => None,
|
||||
};
|
||||
match rects {
|
||||
Some(rects) => {
|
||||
let n = rects.len();
|
||||
self.tabs[i].scene.set_model_tile_layout(rects);
|
||||
self.tabs[i].scene.camera_generation += 1;
|
||||
self.command_line
|
||||
.push_output(&format!("VPORTS: {n} viewport(s)."));
|
||||
}
|
||||
None => {
|
||||
self.command_line
|
||||
.push_error("VPORTS: use SINGLE | 2H | 2V | 4.");
|
||||
}
|
||||
}
|
||||
} else if sub.is_empty() {
|
||||
// ── List existing viewports ──────────────────────────
|
||||
let layout_block = scene.current_layout_block_handle_pub();
|
||||
let viewports: Vec<_> = scene
|
||||
.document
|
||||
.entities()
|
||||
.filter_map(|e| {
|
||||
if let acadrust::EntityType::Viewport(vp) = e {
|
||||
if vp.id > 1 && vp.common.owner_handle == layout_block {
|
||||
Some((
|
||||
vp.id,
|
||||
vp.center.clone(),
|
||||
vp.width,
|
||||
vp.height,
|
||||
crate::scene::vp_effective_scale(
|
||||
vp.custom_scale,
|
||||
vp.view_height,
|
||||
vp.height,
|
||||
),
|
||||
vp.status.is_on,
|
||||
vp.status.locked,
|
||||
))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
if viewports.is_empty() {
|
||||
self.command_line.push_info("No viewports. Use MVIEW to create one, or VPORTS 2H / 2V / 4 / SINGLE.");
|
||||
} else {
|
||||
self.command_line.push_output(&format!(
|
||||
"{} viewport(s) in layout \"{}\":",
|
||||
viewports.len(),
|
||||
scene.current_layout
|
||||
));
|
||||
for (id, center, w, h, scale, is_on, locked) in &viewports {
|
||||
let state = match (is_on, locked) {
|
||||
(true, true) => "On, Locked",
|
||||
(true, false) => "On",
|
||||
(false, _) => "Off",
|
||||
};
|
||||
self.command_line.push_output(&format!(
|
||||
" VP #{id}: {w:.1}×{h:.1} @ ({:.1},{:.1}) scale={scale:.4} [{state}]",
|
||||
center.x, center.y
|
||||
));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// ── Preset viewport layout ───────────────────────────
|
||||
// Determine paper dimensions from PlotSettings (fallback A4 landscape).
|
||||
let layout_name = scene.current_layout.clone();
|
||||
let (paper_w, paper_h) = {
|
||||
use acadrust::objects::ObjectType;
|
||||
let mut pw = 297.0_f64;
|
||||
let mut ph = 210.0_f64;
|
||||
for (_, obj) in &scene.document.objects {
|
||||
if let ObjectType::PlotSettings(ps) = obj {
|
||||
if ps.page_name == layout_name && ps.paper_width > 0.0 {
|
||||
pw = ps.paper_width;
|
||||
ph = ps.paper_height;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
(pw, ph)
|
||||
};
|
||||
let margin = 5.0_f64; // mm margin around the usable area
|
||||
let uw = paper_w - 2.0 * margin; // usable width
|
||||
let uh = paper_h - 2.0 * margin; // usable height
|
||||
// Collect rectangle specs: (cx, cz, w, h) in mm
|
||||
let rects: Vec<(f64, f64, f64, f64)> = match sub.as_str() {
|
||||
"2H" => {
|
||||
// Two viewports side by side (horizontal split)
|
||||
let vw = (uw - 2.0) / 2.0;
|
||||
vec![
|
||||
(margin + vw / 2.0, margin + uh / 2.0, vw, uh),
|
||||
(margin + vw + 2.0 + vw / 2.0, margin + uh / 2.0, vw, uh),
|
||||
]
|
||||
}
|
||||
"2V" => {
|
||||
// Two viewports stacked (vertical split)
|
||||
let vh = (uh - 2.0) / 2.0;
|
||||
vec![
|
||||
(margin + uw / 2.0, margin + vh + 2.0 + vh / 2.0, uw, vh),
|
||||
(margin + uw / 2.0, margin + vh / 2.0, uw, vh),
|
||||
]
|
||||
}
|
||||
"4" => {
|
||||
// Four equal viewports (2×2 grid)
|
||||
let vw = (uw - 2.0) / 2.0;
|
||||
let vh = (uh - 2.0) / 2.0;
|
||||
vec![
|
||||
(margin + vw / 2.0, margin + vh + 2.0 + vh / 2.0, vw, vh),
|
||||
(
|
||||
margin + vw + 2.0 + vw / 2.0,
|
||||
margin + vh + 2.0 + vh / 2.0,
|
||||
vw,
|
||||
vh,
|
||||
),
|
||||
(margin + vw / 2.0, margin + vh / 2.0, vw, vh),
|
||||
(margin + vw + 2.0 + vw / 2.0, margin + vh / 2.0, vw, vh),
|
||||
]
|
||||
}
|
||||
"SINGLE" | "1" => {
|
||||
// Single full-page viewport
|
||||
vec![(margin + uw / 2.0, margin + uh / 2.0, uw, uh)]
|
||||
}
|
||||
_ => {
|
||||
self.command_line.push_error(
|
||||
"VPORTS: unknown option. Use VPORTS 2H | 2V | 4 | SINGLE",
|
||||
);
|
||||
vec![]
|
||||
}
|
||||
};
|
||||
if !rects.is_empty() {
|
||||
// Remove existing user viewports in this layout first.
|
||||
let layout_block = self.tabs[i].scene.current_layout_block_handle_pub();
|
||||
let to_erase: Vec<acadrust::Handle> = self.tabs[i]
|
||||
.scene
|
||||
.document
|
||||
.entities()
|
||||
.filter_map(|e| {
|
||||
if let acadrust::EntityType::Viewport(vp) = e {
|
||||
if vp.id > 1 && vp.common.owner_handle == layout_block {
|
||||
Some(vp.common.handle)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
self.push_undo_snapshot(i, "VPORTS");
|
||||
self.tabs[i].scene.erase_entities(&to_erase);
|
||||
// Create new viewports.
|
||||
for (cx, cz, w, h) in &rects {
|
||||
let mut vp = acadrust::entities::Viewport::new();
|
||||
vp.center = acadrust::types::Vector3::new(*cx, 0.0, *cz);
|
||||
vp.width = *w;
|
||||
vp.height = *h;
|
||||
vp.id = 2; // commit_entity will assign unique IDs
|
||||
match self.tabs[i].scene.document.add_entity_to_layout(
|
||||
acadrust::EntityType::Viewport(vp),
|
||||
&layout_name,
|
||||
) {
|
||||
Ok(handle) => {
|
||||
self.tabs[i].scene.auto_fit_viewport(handle);
|
||||
}
|
||||
Err(e) => {
|
||||
self.command_line.push_error(&format!("VPORTS: {e}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
// Re-assign unique IDs (1 + existing max per viewport).
|
||||
let layout_block2 = self.tabs[i].scene.current_layout_block_handle_pub();
|
||||
let mut id_counter = 2_i16;
|
||||
let handles: Vec<acadrust::Handle> = self.tabs[i]
|
||||
.scene
|
||||
.document
|
||||
.entities()
|
||||
.filter_map(|e| {
|
||||
if let acadrust::EntityType::Viewport(vp) = e {
|
||||
if vp.id >= 2 && vp.common.owner_handle == layout_block2 {
|
||||
Some(vp.common.handle)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
for h in handles {
|
||||
if let Some(acadrust::EntityType::Viewport(vp)) =
|
||||
self.tabs[i].scene.document.get_entity_mut(h)
|
||||
{
|
||||
vp.id = id_counter;
|
||||
id_counter += 1;
|
||||
}
|
||||
}
|
||||
self.tabs[i].dirty = true;
|
||||
self.command_line.push_output(&format!(
|
||||
"VPORTS: created {} viewport(s) [{}].",
|
||||
rects.len(),
|
||||
sub
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── VPLAYER — per-viewport layer freeze/thaw ──────────────────
|
||||
"VPLAYER" => {
|
||||
let scene = &self.tabs[i].scene;
|
||||
if scene.current_layout == "Model" {
|
||||
self.command_line
|
||||
.push_error("VPLAYER: switch to a paper space layout first.");
|
||||
} else if scene.active_viewport.is_none() {
|
||||
self.command_line
|
||||
.push_error("VPLAYER: enter a viewport first (double-click or MS).");
|
||||
} else {
|
||||
use crate::modules::layout::vplayer::VplayerCommand;
|
||||
let vp_handle = scene.active_viewport.unwrap();
|
||||
// Collect current frozen layer names for display.
|
||||
let frozen_names: Vec<String> = {
|
||||
if let Some(acadrust::EntityType::Viewport(vp)) =
|
||||
scene.document.get_entity(vp_handle)
|
||||
{
|
||||
vp.frozen_layers
|
||||
.iter()
|
||||
.filter_map(|h| {
|
||||
scene
|
||||
.document
|
||||
.layers
|
||||
.iter()
|
||||
.find(|l| l.handle == *h)
|
||||
.map(|l| l.name.clone())
|
||||
})
|
||||
.collect()
|
||||
} else {
|
||||
vec![]
|
||||
}
|
||||
};
|
||||
if frozen_names.is_empty() {
|
||||
self.command_line
|
||||
.push_info("VPLAYER: no frozen layers in active viewport.");
|
||||
} else {
|
||||
self.command_line.push_info(&format!(
|
||||
"VPLAYER: frozen layers: {}",
|
||||
frozen_names.join(", ")
|
||||
));
|
||||
}
|
||||
let new_cmd = VplayerCommand::new(vp_handle);
|
||||
self.command_line.push_info(&new_cmd.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(new_cmd));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Draw Order ────────────────────────────────────────────────
|
||||
cmd if cmd.starts_with("DRAWORDER") => {
|
||||
use acadrust::objects::{ObjectType, SortEntitiesTable};
|
||||
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()
|
||||
.iter()
|
||||
.map(|(h, _)| *h)
|
||||
.collect();
|
||||
if selected.is_empty() {
|
||||
self.command_line
|
||||
.push_error("DRAWORDER: select entities first.");
|
||||
} 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();
|
||||
|
||||
// For FRONT/BACK, anchor the new sort handle to the
|
||||
// block's current effective draw-order range so the moved
|
||||
// entities land strictly above/below every sibling —
|
||||
// including ones not yet in the table, which sort by
|
||||
// their own handle. (min_eff, max_eff) over siblings.
|
||||
let fb_baseline: Option<(u64, u64)> = if to_front_opt.is_some() {
|
||||
let selected_set: rustc_hash::FxHashSet<u64> =
|
||||
selected.iter().map(|h| h.value()).collect();
|
||||
let doc_ref = &self.tabs[i].scene.document;
|
||||
let overrides: rustc_hash::FxHashMap<u64, u64> = doc_ref
|
||||
.objects
|
||||
.values()
|
||||
.find_map(|obj| {
|
||||
if let ObjectType::SortEntitiesTable(t) = obj {
|
||||
if t.block_owner_handle == block_handle {
|
||||
return Some(
|
||||
t.entries()
|
||||
.map(|e| {
|
||||
(
|
||||
e.entity_handle.value(),
|
||||
e.sort_handle.value(),
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
}
|
||||
}
|
||||
None
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let mut max_eff = 0u64;
|
||||
let mut min_eff = u64::MAX;
|
||||
for e in doc_ref.entities() {
|
||||
let c = e.common();
|
||||
let hv = c.handle.value();
|
||||
if selected_set.contains(&hv) {
|
||||
continue;
|
||||
}
|
||||
if c.owner_handle != block_handle && !c.owner_handle.is_null() {
|
||||
continue;
|
||||
}
|
||||
let eff = overrides.get(&hv).copied().unwrap_or(hv);
|
||||
max_eff = max_eff.max(eff);
|
||||
min_eff = min_eff.min(eff);
|
||||
}
|
||||
if min_eff == u64::MAX {
|
||||
min_eff = 1;
|
||||
}
|
||||
Some((min_eff, max_eff))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
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
|
||||
});
|
||||
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 {
|
||||
// move_above/move_below read the target's sort
|
||||
// handle from the table and no-op when it is
|
||||
// absent. A reference object that was never
|
||||
// reordered isn't in the table yet, so seed it
|
||||
// with its own handle as the implicit sort key.
|
||||
if !table.contains(target) {
|
||||
table.add_entry(target, 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 {
|
||||
let (min_eff, max_eff) = fb_baseline.unwrap_or((1, 0));
|
||||
for (k, h) in selected.iter().enumerate() {
|
||||
let sort = if to_front {
|
||||
max_eff.saturating_add(1 + k as u64)
|
||||
} else {
|
||||
min_eff.saturating_sub(1 + k as u64).max(1)
|
||||
};
|
||||
table.add_entry(*h, acadrust::Handle::new(sort));
|
||||
}
|
||||
let dir = if to_front { "front" } else { "back" };
|
||||
self.command_line.push_info(&format!(
|
||||
"DRAWORDER: moved {} entities to {}.",
|
||||
selected.len(),
|
||||
dir
|
||||
));
|
||||
}
|
||||
}
|
||||
// Sort order lives in SortEntitiesTable, which the
|
||||
// render-side `sort_cache` rebuilds per geometry epoch.
|
||||
// Bump it so the new draw order shows immediately
|
||||
// instead of waiting for an unrelated geometry change.
|
||||
self.tabs[i].scene.bump_geometry();
|
||||
self.tabs[i].dirty = true;
|
||||
} else {
|
||||
self.command_line.push_info(
|
||||
"Usage: DRAWORDER F|FRONT | B|BACK | A|ABOVE <handle> | U|UNDER <handle>"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_ => return None,
|
||||
}
|
||||
Some(self.finish_dispatch(cmd))
|
||||
}
|
||||
}
|
||||
|
||||
// ── Draw Order: interactive reference-object pick ──────────────────────────
|
||||
|
||||
/// Moves a captured selection above or below a reference object the user
|
||||
/// picks in the viewport. On pick it relaunches `DRAWORDER A|U <handle>`
|
||||
/// with the captured handles reinstalled as the selection, so the existing
|
||||
/// command path performs the actual reorder.
|
||||
pub(crate) struct DrawOrderRefCommand {
|
||||
to_move: Vec<acadrust::Handle>,
|
||||
above: bool,
|
||||
}
|
||||
|
||||
impl DrawOrderRefCommand {
|
||||
pub(crate) fn new(to_move: Vec<acadrust::Handle>, above: bool) -> Self {
|
||||
Self { to_move, above }
|
||||
}
|
||||
}
|
||||
|
||||
impl CadCommand for DrawOrderRefCommand {
|
||||
fn name(&self) -> &'static str {
|
||||
"DRAWORDER"
|
||||
}
|
||||
|
||||
fn prompt(&self) -> String {
|
||||
if self.above {
|
||||
"DRAWORDER Select reference object (move selection above):".into()
|
||||
} else {
|
||||
"DRAWORDER Select reference object (move selection under):".into()
|
||||
}
|
||||
}
|
||||
|
||||
fn needs_entity_pick(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn on_entity_pick(
|
||||
&mut self,
|
||||
handle: acadrust::Handle,
|
||||
_pt: glam::DVec3,
|
||||
) -> crate::command::CmdResult {
|
||||
if handle.is_null() {
|
||||
return crate::command::CmdResult::NeedPoint;
|
||||
}
|
||||
let opt = if self.above { "A" } else { "U" };
|
||||
let cmd = format!("DRAWORDER {} {:x}", opt, handle.value());
|
||||
crate::command::CmdResult::Relaunch(cmd, std::mem::take(&mut self.to_move))
|
||||
}
|
||||
|
||||
fn on_point(&mut self, _pt: glam::DVec3) -> crate::command::CmdResult {
|
||||
crate::command::CmdResult::NeedPoint
|
||||
}
|
||||
|
||||
fn on_enter(&mut self) -> crate::command::CmdResult {
|
||||
crate::command::CmdResult::Cancel
|
||||
}
|
||||
}
|
||||
9650
src/app/update.rs
9650
src/app/update.rs
File diff suppressed because it is too large
Load diff
1157
src/app/update/command.rs
Normal file
1157
src/app/update/command.rs
Normal file
File diff suppressed because it is too large
Load diff
299
src/app/update/dialog.rs
Normal file
299
src/app/update/dialog.rs
Normal file
|
|
@ -0,0 +1,299 @@
|
|||
//! `dialog` arms and helpers, split out of the original `update.rs` (#mechanical decomposition).
|
||||
|
||||
#![allow(unused_imports)]
|
||||
use super::util::*;
|
||||
use super::{format_size, tile_min_norm, TILE_EDGE_HIT_PX, VIEWCUBE_HIT_SIZE};
|
||||
use crate::app::helpers::{
|
||||
ortho_constrain, parse_coord, polar_constrain_near, ucs_rotate_vec, ucs_to_wcs, ucs_z_axis,
|
||||
CoordKind,
|
||||
};
|
||||
use crate::app::{Message, OpenCADStudio, POLY_START_DELAY_MS};
|
||||
use crate::modules::ModuleEvent;
|
||||
use crate::scene::pick::grip::{find_hit_grip, find_hit_grip_paper, find_hit_grip_rte, GripEdit};
|
||||
use crate::scene::model::object::GripApply;
|
||||
use crate::scene::{
|
||||
self, hover_id, CubeRegion, Scene, TileEdgeOrient, VIEWCUBE_DRAW_PX, VIEWCUBE_PAD, VIEWCUBE_PX,
|
||||
};
|
||||
use crate::ui::PropertiesPanel;
|
||||
use acadrust::types::Color as AcadColor;
|
||||
use acadrust::{EntityType as AcadEntityType, Handle};
|
||||
use iced::time::Instant;
|
||||
use iced::{mouse, Point, Task};
|
||||
|
||||
|
||||
impl OpenCADStudio {
|
||||
pub(in crate::app) fn open_save_dialog_window(&mut self, tab_idx: usize) -> Task<Message> {
|
||||
// Pre-fill filename and folder from current path or defaults.
|
||||
if let Some(p) = &self.tabs[tab_idx].current_path.clone() {
|
||||
if let Some(name) = p.file_name() {
|
||||
self.save_dialog_filename = name.to_string_lossy().into_owned();
|
||||
}
|
||||
if let Some(dir) = p.parent() {
|
||||
self.save_dialog_folder = dir.to_path_buf();
|
||||
}
|
||||
} else {
|
||||
let (ext, _) = crate::io::parse_save_format(&self.save_dialog_format);
|
||||
self.save_dialog_filename = format!("{}.{ext}", self.tabs[tab_idx].tab_display_name());
|
||||
}
|
||||
self.save_dialog_entries = crate::io::read_dir_entries(&self.save_dialog_folder.clone());
|
||||
self.active_modal = Some(crate::app::ModalKind::SaveDialog);
|
||||
Task::none()
|
||||
}
|
||||
|
||||
|
||||
pub(in crate::app) fn close_save_dialog_window(&mut self) -> Task<Message> {
|
||||
if self.active_modal == Some(crate::app::ModalKind::SaveDialog) {
|
||||
self.active_modal = None;
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
|
||||
|
||||
pub(in crate::app) fn open_unsaved_dialog_window(&mut self) -> Task<Message> {
|
||||
self.active_modal = Some(crate::app::ModalKind::Unsaved);
|
||||
// The unsaved-changes prompt renders inside the main window, so bring
|
||||
// that window to the foreground — a close signal can arrive while the
|
||||
// app is backgrounded, leaving the prompt unseen behind other windows.
|
||||
// `gain_focus` alone is ignored by most Linux WMs (focus-stealing
|
||||
// prevention), so pair it with an urgency hint so the window is at
|
||||
// least flagged for attention when the compositor blocks the raise.
|
||||
match self.main_window {
|
||||
Some(id) => Task::batch([
|
||||
iced::window::gain_focus(id),
|
||||
iced::window::request_user_attention(
|
||||
id,
|
||||
Some(iced::window::UserAttention::Critical),
|
||||
),
|
||||
]),
|
||||
None => Task::none(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
pub(in crate::app) fn close_unsaved_dialog_window(&mut self) -> Task<Message> {
|
||||
if self.active_modal == Some(crate::app::ModalKind::Unsaved) {
|
||||
self.active_modal = None;
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
|
||||
|
||||
pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEvent) -> Task<Message> {
|
||||
self.ribbon.activate_tool(&tool_id);
|
||||
match event {
|
||||
ModuleEvent::Command(cmd) => return self.dispatch_command(&cmd),
|
||||
ModuleEvent::OpenFileDialog => {
|
||||
self.command_line
|
||||
.push_info("Open DWG/DXF: not yet implemented.");
|
||||
}
|
||||
ModuleEvent::ClearModels => {
|
||||
let i = self.active_tab;
|
||||
self.tabs[i].scene.clear();
|
||||
self.tabs[i].properties = PropertiesPanel::empty();
|
||||
self.command_line.push_output("Scene cleared.");
|
||||
}
|
||||
ModuleEvent::SetWireframe(w) => {
|
||||
let i = self.active_tab;
|
||||
self.tabs[i].wireframe = w;
|
||||
self.ribbon.set_wireframe(w);
|
||||
self.tabs[i].visual_style = if w {
|
||||
"Wireframe".into()
|
||||
} else {
|
||||
"Shaded".into()
|
||||
};
|
||||
self.command_line.push_output(if w {
|
||||
"Visual style: Wireframe"
|
||||
} else {
|
||||
"Visual style: Shaded"
|
||||
});
|
||||
}
|
||||
ModuleEvent::ToggleLayers => {
|
||||
return Task::done(Message::ToggleLayers);
|
||||
}
|
||||
ModuleEvent::PluginFileDialog {
|
||||
command,
|
||||
title,
|
||||
filter_name,
|
||||
extensions,
|
||||
} => {
|
||||
return Task::perform(
|
||||
async move {
|
||||
let exts: Vec<&str> =
|
||||
extensions.iter().map(|s| s.as_str()).collect();
|
||||
let path = rfd::AsyncFileDialog::new()
|
||||
.set_title(title)
|
||||
.add_filter(filter_name, &exts)
|
||||
.add_filter("All Files", &["*"])
|
||||
.pick_file()
|
||||
.await
|
||||
.map(|h| crate::sys::handle_path(&h));
|
||||
(command, path)
|
||||
},
|
||||
|(command, path)| Message::PluginFileDialogResult { command, path },
|
||||
);
|
||||
}
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
|
||||
pub(super) fn on_unsaved_dialog_discard(&mut self) -> Task<Message> {
|
||||
match self.pending_close.take() {
|
||||
Some(crate::app::PendingClose::Tab(idx)) => {
|
||||
let close_win = self.close_unsaved_dialog_window();
|
||||
if self.tabs.len() == 1 {
|
||||
self.tab_counter += 1;
|
||||
self.tabs[0] =
|
||||
crate::app::document::DocumentTab::new_drawing(self.tab_counter);
|
||||
self.active_tab = 0;
|
||||
self.apply_bg_default(0);
|
||||
} else {
|
||||
self.tabs.remove(idx);
|
||||
if self.active_tab >= self.tabs.len() {
|
||||
self.active_tab = self.tabs.len() - 1;
|
||||
}
|
||||
}
|
||||
// The active tab is now a fresh blank or a
|
||||
// different existing tab; sync ribbon chips so
|
||||
// they don't keep showing the discarded tab's
|
||||
// last selection. #21.
|
||||
self.sync_ribbon_layers();
|
||||
self.sync_ribbon_from_selection();
|
||||
return close_win;
|
||||
}
|
||||
Some(crate::app::PendingClose::Quit) => {
|
||||
if let Some(idx) = self.tabs.iter().position(|t| t.dirty) {
|
||||
self.tabs[idx].dirty = false;
|
||||
}
|
||||
if self.tabs.iter().any(|t| t.dirty) {
|
||||
// More dirty tabs remain — keep window open.
|
||||
self.pending_close = Some(crate::app::PendingClose::Quit);
|
||||
} else {
|
||||
let close_win = self.close_unsaved_dialog_window();
|
||||
return Task::batch(vec![close_win, iced::exit()]);
|
||||
}
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
|
||||
pub(super) fn on_unsaved_dialog_save(&mut self) -> Task<Message> {
|
||||
match self.pending_close.take() {
|
||||
Some(crate::app::PendingClose::Tab(idx)) => {
|
||||
if let Some(path) = if cfg!(target_arch = "wasm32") { None } else { self.tabs[idx].current_path.clone() } {
|
||||
match crate::io::save(&self.tabs[idx].scene.document, &path) {
|
||||
Ok(()) => {
|
||||
self.command_line
|
||||
.push_output(&format!("Saved: {}", path.display()));
|
||||
self.tabs[idx].dirty = false;
|
||||
let close_win = self.close_unsaved_dialog_window();
|
||||
let close_tab = self.update(Message::TabClose(idx));
|
||||
return Task::batch(vec![close_win, close_tab]);
|
||||
}
|
||||
Err(e) => {
|
||||
// Keep dialog open for retry.
|
||||
self.command_line.push_error(&format!("Save failed: {e}"));
|
||||
self.pending_close = Some(crate::app::PendingClose::Tab(idx));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No path — close unsaved dialog, open custom Save As dialog.
|
||||
self.pending_close = Some(crate::app::PendingClose::Tab(idx));
|
||||
self.save_dialog_for_unsaved = true;
|
||||
let close_win = self.close_unsaved_dialog_window();
|
||||
let open_save = self.open_save_dialog_window(idx);
|
||||
return Task::batch([close_win, open_save]);
|
||||
}
|
||||
}
|
||||
Some(crate::app::PendingClose::Quit) => {
|
||||
if let Some(idx) = self.tabs.iter().position(|t| t.dirty) {
|
||||
if let Some(path) = if cfg!(target_arch = "wasm32") { None } else { self.tabs[idx].current_path.clone() } {
|
||||
match crate::io::save(&self.tabs[idx].scene.document, &path) {
|
||||
Ok(()) => {
|
||||
self.command_line
|
||||
.push_output(&format!("Saved: {}", path.display()));
|
||||
self.tabs[idx].dirty = false;
|
||||
}
|
||||
Err(e) => {
|
||||
self.command_line.push_error(&format!("Save failed: {e}"));
|
||||
self.pending_close = Some(crate::app::PendingClose::Quit);
|
||||
return Task::none();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No path — close unsaved dialog, open custom Save As dialog.
|
||||
self.active_tab = idx;
|
||||
self.pending_close = Some(crate::app::PendingClose::Quit);
|
||||
self.save_dialog_for_unsaved = true;
|
||||
let close_win = self.close_unsaved_dialog_window();
|
||||
let open_save = self.open_save_dialog_window(idx);
|
||||
return Task::batch([close_win, open_save]);
|
||||
}
|
||||
}
|
||||
if self.tabs.iter().any(|t| t.dirty) {
|
||||
// More dirty tabs — keep window open.
|
||||
self.pending_close = Some(crate::app::PendingClose::Quit);
|
||||
} else {
|
||||
let close_win = self.close_unsaved_dialog_window();
|
||||
return Task::batch(vec![close_win, iced::exit()]);
|
||||
}
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
|
||||
pub(super) fn on_unsaved_picked_save_path_some(&mut self, path: std::path::PathBuf) -> Task<Message> {
|
||||
let (_, version) = crate::io::parse_save_format(&self.save_dialog_format);
|
||||
match self.pending_close.take() {
|
||||
Some(crate::app::PendingClose::Tab(idx)) => {
|
||||
match crate::io::save_as_version(
|
||||
&self.tabs[idx].scene.document,
|
||||
&path,
|
||||
version,
|
||||
) {
|
||||
Ok(()) => {
|
||||
self.command_line
|
||||
.push_output(&format!("Saved: {}", path.display()));
|
||||
self.tabs[idx].current_path = Some(path);
|
||||
self.tabs[idx].dirty = false;
|
||||
return self.update(Message::TabClose(idx));
|
||||
}
|
||||
Err(e) => {
|
||||
self.command_line.push_error(&format!("Save failed: {e}"));
|
||||
self.pending_close = Some(crate::app::PendingClose::Tab(idx));
|
||||
return self.open_unsaved_dialog_window();
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(crate::app::PendingClose::Quit) => {
|
||||
let i = self.active_tab;
|
||||
match crate::io::save_as_version(
|
||||
&self.tabs[i].scene.document,
|
||||
&path,
|
||||
version,
|
||||
) {
|
||||
Ok(()) => {
|
||||
self.command_line
|
||||
.push_output(&format!("Saved: {}", path.display()));
|
||||
self.tabs[i].current_path = Some(path);
|
||||
self.tabs[i].dirty = false;
|
||||
if self.tabs.iter().any(|t| t.dirty) {
|
||||
self.pending_close = Some(crate::app::PendingClose::Quit);
|
||||
return self.open_unsaved_dialog_window();
|
||||
} else {
|
||||
return iced::exit();
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
self.command_line.push_error(&format!("Save failed: {e}"));
|
||||
self.pending_close = Some(crate::app::PendingClose::Quit);
|
||||
return self.open_unsaved_dialog_window();
|
||||
}
|
||||
}
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
}
|
||||
557
src/app/update/dynamic.rs
Normal file
557
src/app/update/dynamic.rs
Normal file
|
|
@ -0,0 +1,557 @@
|
|||
//! `dynamic` arms and helpers, split out of the original `update.rs` (#mechanical decomposition).
|
||||
|
||||
#![allow(unused_imports)]
|
||||
use super::util::*;
|
||||
use super::{format_size, tile_min_norm, TILE_EDGE_HIT_PX, VIEWCUBE_HIT_SIZE};
|
||||
use crate::app::helpers::{
|
||||
ortho_constrain, parse_coord, polar_constrain_near, ucs_rotate_vec, ucs_to_wcs, ucs_z_axis,
|
||||
CoordKind,
|
||||
};
|
||||
use crate::app::{Message, OpenCADStudio, POLY_START_DELAY_MS};
|
||||
use crate::modules::ModuleEvent;
|
||||
use crate::scene::pick::grip::{find_hit_grip, find_hit_grip_paper, find_hit_grip_rte, GripEdit};
|
||||
use crate::scene::model::object::GripApply;
|
||||
use crate::scene::{
|
||||
self, hover_id, CubeRegion, Scene, TileEdgeOrient, VIEWCUBE_DRAW_PX, VIEWCUBE_PAD, VIEWCUBE_PX,
|
||||
};
|
||||
use crate::ui::PropertiesPanel;
|
||||
use acadrust::types::Color as AcadColor;
|
||||
use acadrust::{EntityType as AcadEntityType, Handle};
|
||||
use iced::time::Instant;
|
||||
use iced::{mouse, Point, Task};
|
||||
|
||||
|
||||
impl OpenCADStudio {
|
||||
/// Rebuild the active tab's dynamic-input field set to match what the
|
||||
/// command is currently asking for. Called on cursor move and after
|
||||
/// command-state changes. The field set only changes shape when the
|
||||
/// command's `dyn_field()` or the presence of a base point changes;
|
||||
/// existing typed buffers survive an unchanged shape.
|
||||
pub(in crate::app) fn sync_dyn_fields(&mut self) {
|
||||
use crate::app::document::{DynComponent, DynFieldEntry};
|
||||
let i = self.active_tab;
|
||||
if !self.dyn_input || self.tabs[i].active_cmd.is_none() {
|
||||
self.tabs[i].dyn_fields.clear();
|
||||
self.tabs[i].dyn_active = 0;
|
||||
return;
|
||||
}
|
||||
// A command may describe its step explicitly via `dyn_spec()` — that
|
||||
// takes full control of the boxes, guide and anchor. Otherwise fall
|
||||
// back to the legacy `dyn_field()` shaping below.
|
||||
if let Some(spec) = self.tabs[i].active_cmd.as_ref().and_then(|c| c.dyn_spec()) {
|
||||
self.apply_dyn_spec(i, spec);
|
||||
return;
|
||||
}
|
||||
let field = self.tabs[i]
|
||||
.active_cmd
|
||||
.as_ref()
|
||||
.map(|c| c.dyn_field())
|
||||
.unwrap_or(crate::command::DynField::Point);
|
||||
// A text-input step reads a single scalar. The overlay shows one box
|
||||
// the user types into (or, when the command supplies a live value,
|
||||
// sets by moving the cursor); on commit the host routes it to
|
||||
// `on_text_input` instead of `on_point`. A distance prompt keeps the
|
||||
// `Distance` box (so a perpendicular-distance live value reads
|
||||
// naturally); everything else uses the typed-only `Scalar` box.
|
||||
let wants_text = self.tabs[i]
|
||||
.active_cmd
|
||||
.as_ref()
|
||||
.map(|c| c.wants_text_input())
|
||||
.unwrap_or(false);
|
||||
// A point step that also accepts keyword letters (PLINE A/L/C…) keeps
|
||||
// its polar boxes: only letters reach the command line, digits stay
|
||||
// coordinates. So such a step is NOT treated as a text-only prompt.
|
||||
let point_keywords = self.tabs[i]
|
||||
.active_cmd
|
||||
.as_ref()
|
||||
.map(|c| c.point_step_accepts_keywords())
|
||||
.unwrap_or(false);
|
||||
let wants_text = wants_text && !point_keywords;
|
||||
// A step that hit-tests for an object (entity / structure pick) has no
|
||||
// coordinate to enter — clicks select, they don't place a point. Show
|
||||
// no coordinate box so the cursor stays clean and typed option keywords
|
||||
// (e.g. FILLET's "R") reach the command line instead of an X/Y field.
|
||||
let picks_object = self.tabs[i]
|
||||
.active_cmd
|
||||
.as_ref()
|
||||
.map(|c| c.needs_entity_pick() || c.needs_structure_point_pick())
|
||||
.unwrap_or(false);
|
||||
let has_base = self.last_point.is_some();
|
||||
// While aligned to an OTRACK ray, the point step reads a single
|
||||
// distance along the ray (issue #69) — show one Distance box.
|
||||
let otrack_dist = self.otrack_active.is_some()
|
||||
&& !wants_text
|
||||
&& matches!(field, crate::command::DynField::Point);
|
||||
let default: Vec<DynComponent> = match field {
|
||||
_ if otrack_dist => vec![DynComponent::Distance],
|
||||
crate::command::DynField::Distance => vec![DynComponent::Distance],
|
||||
crate::command::DynField::Angle => vec![DynComponent::Angle],
|
||||
crate::command::DynField::Scalar => vec![DynComponent::Scalar],
|
||||
// A text prompt with the default `Point` field reads free text /
|
||||
// a name / a keyword (which may itself contain digits) from the
|
||||
// command line — show no scalar box, so digits reach the command
|
||||
// line instead of being captured into a dyn buffer.
|
||||
crate::command::DynField::Point if wants_text || picks_object => vec![],
|
||||
crate::command::DynField::Point if has_base => {
|
||||
vec![DynComponent::Distance, DynComponent::Angle]
|
||||
}
|
||||
crate::command::DynField::Point => vec![DynComponent::X, DynComponent::Y],
|
||||
};
|
||||
// Multiple shapes can satisfy the same command request — e.g. a
|
||||
// `Point` is happy with either `[Distance, Angle]` (polar) or
|
||||
// `[X, Y]` / `[X, Y, Z]` (cartesian). If the user already
|
||||
// reshaped via `,` (see #35) the existing set is still a valid
|
||||
// Point configuration and must not be reverted on every mouse
|
||||
// move.
|
||||
let current: Vec<DynComponent> = self.tabs[i]
|
||||
.dyn_fields
|
||||
.iter()
|
||||
.map(|f| f.component)
|
||||
.collect();
|
||||
// Only treat a cartesian / polar variant as "good enough to keep"
|
||||
// when the user explicitly reshaped via `,`. Otherwise we follow
|
||||
// the command's default so e.g. clicking the first point of LINE
|
||||
// flips a stale `[X, Y]` (from before there was a base) over to
|
||||
// the polar `[Distance, Angle]` the prompt actually wants.
|
||||
let current_is_acceptable = if self.dyn_user_reshaped && !wants_text && !picks_object {
|
||||
match field {
|
||||
crate::command::DynField::Distance => {
|
||||
matches!(current.as_slice(), [DynComponent::Distance])
|
||||
}
|
||||
crate::command::DynField::Angle => {
|
||||
matches!(current.as_slice(), [DynComponent::Angle])
|
||||
}
|
||||
crate::command::DynField::Scalar => {
|
||||
matches!(current.as_slice(), [DynComponent::Scalar])
|
||||
}
|
||||
crate::command::DynField::Point => matches!(
|
||||
current.as_slice(),
|
||||
[DynComponent::Distance, DynComponent::Angle]
|
||||
| [DynComponent::X, DynComponent::Y]
|
||||
| [DynComponent::X, DynComponent::Y, DynComponent::Z]
|
||||
),
|
||||
}
|
||||
} else {
|
||||
current == default
|
||||
};
|
||||
if !current_is_acceptable {
|
||||
self.tabs[i].dyn_fields = default.into_iter().map(DynFieldEntry::new).collect();
|
||||
self.tabs[i].dyn_active = 0;
|
||||
}
|
||||
// Derive the guide + anchor for the legacy field set so the overlay
|
||||
// draws the right construction without each command opting in.
|
||||
let comps: Vec<DynComponent> =
|
||||
self.tabs[i].dyn_fields.iter().map(|f| f.component).collect();
|
||||
self.tabs[i].dyn_guide = match comps.as_slice() {
|
||||
[DynComponent::Distance, DynComponent::Angle] | [DynComponent::Angle] => {
|
||||
crate::command::DynGuide::Polar
|
||||
}
|
||||
[DynComponent::Distance] => crate::command::DynGuide::Radius,
|
||||
_ => crate::command::DynGuide::None,
|
||||
};
|
||||
self.tabs[i].dyn_anchor = self.last_point;
|
||||
self.tabs[i].dyn_ref = None;
|
||||
}
|
||||
|
||||
/// Apply an explicit per-step [`DynSpec`](crate::command::DynSpec): rebuild
|
||||
/// the boxes from its roles (preserving typed buffers when the role set is
|
||||
/// unchanged), and set the guide + anchor.
|
||||
|
||||
pub(in crate::app) fn apply_dyn_spec(&mut self, i: usize, spec: crate::command::DynSpec) {
|
||||
use crate::app::document::DynFieldEntry;
|
||||
let new_roles: Vec<crate::command::DynRole> =
|
||||
spec.fields.iter().map(|f| f.role).collect();
|
||||
let cur_roles: Vec<crate::command::DynRole> =
|
||||
self.tabs[i].dyn_fields.iter().map(|f| f.role).collect();
|
||||
if cur_roles != new_roles {
|
||||
self.tabs[i].dyn_fields =
|
||||
spec.fields.iter().map(|f| DynFieldEntry::from_role(f.role)).collect();
|
||||
self.tabs[i].dyn_active = 0;
|
||||
}
|
||||
self.tabs[i].dyn_guide = spec.guide;
|
||||
self.tabs[i].dyn_anchor = match spec.anchor {
|
||||
crate::command::DynAnchor::LastPoint => self.last_point,
|
||||
crate::command::DynAnchor::Point(p) => Some(p.as_vec3()),
|
||||
};
|
||||
self.tabs[i].dyn_ref = spec.ref_point.map(|v| v.as_vec3());
|
||||
}
|
||||
|
||||
/// Track cursor dwell over a selected entity's grip. Sets
|
||||
/// `grip_hover` while the cursor sits within `GRIP_THRESHOLD_PX` of
|
||||
/// a grip and opens `grip_popup` once the dwell exceeds the
|
||||
/// threshold. Cursor drift clears both.
|
||||
/// After the active Model tile changes, mirror its stored visual style
|
||||
/// into the tab so the picker shows it and the tile renders with it
|
||||
/// (the active tile draws with the tab's live render mode).
|
||||
|
||||
/// Resolve the world point implied by the current dynamic-input field
|
||||
/// values. Locked fields use their typed buffer; the rest fall back to
|
||||
/// the live cursor-derived value. Returns `None` when the field set
|
||||
/// isn't one we know how to turn into a point.
|
||||
/// Hand the active command the current UCS (as a UCS→wire affine) so
|
||||
/// axis-aligned constructions build square to the user's coordinate system.
|
||||
/// No-op for commands that don't override `set_ucs`.
|
||||
pub(in crate::app) fn push_ucs_to_cmd(&mut self, i: usize) {
|
||||
let ucs = self.tabs[i].ucs_wire_affine();
|
||||
if let Some(c) = self.tabs[i].active_cmd.as_mut() {
|
||||
c.set_ucs(ucs);
|
||||
}
|
||||
}
|
||||
|
||||
pub(in crate::app) fn dyn_resolve_point(&self) -> Option<glam::Vec3> {
|
||||
use crate::app::document::DynComponent;
|
||||
let i = self.active_tab;
|
||||
let fields = &self.tabs[i].dyn_fields;
|
||||
if fields.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let w = self.tabs[i].last_cursor_world;
|
||||
let base = self.tabs[i]
|
||||
.dyn_anchor
|
||||
.or(self.last_point)
|
||||
.unwrap_or(glam::Vec3::ZERO);
|
||||
// Buffer value parsed as f32 (de-scaled by the role so a typed diameter
|
||||
// becomes a radius), or the supplied geometric live value. Width/Height
|
||||
// are shown unsigned, so a typed value takes the sign of the cursor's
|
||||
// delta on that axis (`live` is the signed delta in the cartesian arms).
|
||||
let val = |idx: usize, live: f32| -> f32 {
|
||||
match fields[idx]
|
||||
.buffer
|
||||
.as_ref()
|
||||
.map(|s| s.trim().replace(',', "."))
|
||||
.and_then(|s| crate::app::expr_eval::eval_number(&s).map(|v| v as f32))
|
||||
.map(|v| v / fields[idx].role.value_scale())
|
||||
{
|
||||
Some(v) => {
|
||||
if matches!(
|
||||
fields[idx].role,
|
||||
crate::command::DynRole::Width | crate::command::DynRole::Height
|
||||
) {
|
||||
v.abs().copysign(live)
|
||||
} else {
|
||||
v
|
||||
}
|
||||
}
|
||||
None => live,
|
||||
}
|
||||
};
|
||||
// Work in the active UCS frame: the cursor delta from the base is
|
||||
// rotated into UCS, so typed cartesian/polar values are interpreted in
|
||||
// the user's coordinate system and mapped back to world on return.
|
||||
// (The delta is offset-invariant, so only the rotation matters.)
|
||||
let xf = self.tabs[i].ucs_xform();
|
||||
let d_ucs = xf.vec_to_ucs(w - base);
|
||||
let dx = d_ucs.x;
|
||||
let dy = d_ucs.y;
|
||||
let dz = d_ucs.z;
|
||||
let live_d = (dx * dx + dy * dy).sqrt();
|
||||
let live_a = dy.atan2(dx); // radians, in the UCS plane
|
||||
// A typed angle is shown unsigned (0..180); give it the sign of the
|
||||
// cursor's current side so an entry made below the X axis sweeps
|
||||
// downward to match the arc instead of mirroring up. Untyped → live.
|
||||
let angle_rad = |idx: usize| -> f32 {
|
||||
match fields[idx]
|
||||
.buffer
|
||||
.as_ref()
|
||||
.map(|s| s.trim().replace(',', "."))
|
||||
.and_then(|s| crate::app::expr_eval::eval_number(&s).map(|v| v as f32))
|
||||
{
|
||||
Some(mag) => mag.abs().to_radians().copysign(dy),
|
||||
None => live_a,
|
||||
}
|
||||
};
|
||||
let comps: Vec<DynComponent> = fields.iter().map(|f| f.component).collect();
|
||||
// Perpendicular offset: a single distance measured square to the
|
||||
// reference line (anchor → dyn_ref). The committed point lies on the
|
||||
// perpendicular through the anchor at that offset; the command projects
|
||||
// it. Untyped tracks the cursor's signed offset; typed takes the
|
||||
// cursor's side.
|
||||
if let (Some(ref_pt), [DynComponent::Distance]) =
|
||||
(self.tabs[i].dyn_ref, comps.as_slice())
|
||||
{
|
||||
let axis = (ref_pt - base).normalize_or_zero();
|
||||
let perp = glam::Vec3::new(-axis.y, axis.x, 0.0);
|
||||
let signed = (w - base).dot(perp);
|
||||
let typed = fields[0]
|
||||
.buffer
|
||||
.as_ref()
|
||||
.map(|s| s.trim().replace(',', "."))
|
||||
.and_then(|s| crate::app::expr_eval::eval_number(&s).map(|v| v as f32));
|
||||
let h = match typed {
|
||||
Some(v) => v.abs().copysign(signed),
|
||||
None => signed,
|
||||
};
|
||||
return Some(base + perp * h);
|
||||
}
|
||||
// DYN-on defaults to RELATIVE coordinates when a base point is set
|
||||
// (see #26 / #35). The live cartesian fallback is the cursor
|
||||
// position relative to base; typed values are relative deltas.
|
||||
let has_base = self.last_point.is_some();
|
||||
// Relative result: base + the typed UCS-frame offset mapped to world.
|
||||
let rel = |off_ucs: glam::Vec3| base + xf.vec_to_wcs(off_ucs);
|
||||
match comps.as_slice() {
|
||||
[DynComponent::X, DynComponent::Y] if has_base => {
|
||||
Some(rel(glam::Vec3::new(val(0, dx), val(1, dy), 0.0)))
|
||||
}
|
||||
[DynComponent::X, DynComponent::Y] => {
|
||||
Some(glam::Vec3::new(val(0, w.x), val(1, w.y), base.z))
|
||||
}
|
||||
[DynComponent::X, DynComponent::Y, DynComponent::Z] if has_base => {
|
||||
Some(rel(glam::Vec3::new(val(0, dx), val(1, dy), val(2, dz))))
|
||||
}
|
||||
[DynComponent::X, DynComponent::Y, DynComponent::Z] => {
|
||||
Some(glam::Vec3::new(val(0, w.x), val(1, w.y), val(2, base.z)))
|
||||
}
|
||||
[DynComponent::Distance, DynComponent::Angle] => {
|
||||
let d = val(0, live_d);
|
||||
let a = angle_rad(1);
|
||||
Some(rel(glam::Vec3::new(d * a.cos(), d * a.sin(), 0.0)))
|
||||
}
|
||||
[DynComponent::Distance] => {
|
||||
// Keep the cursor's direction (in UCS), override the magnitude.
|
||||
let dir = glam::Vec3::new(dx, dy, 0.0).normalize_or(glam::Vec3::X);
|
||||
Some(rel(dir * val(0, live_d)))
|
||||
}
|
||||
[DynComponent::Angle] => {
|
||||
// Standalone angle (e.g. ROTATE): the typed value is an
|
||||
// absolute CCW angle in the UCS plane, not a cursor-signed
|
||||
// magnitude — keep it literal. Only the polar Distance+Angle
|
||||
// pair uses the cursor-signed `angle_rad`.
|
||||
let a = val(0, live_a.to_degrees()).to_radians();
|
||||
Some(rel(glam::Vec3::new(live_d * a.cos(), live_d * a.sin(), 0.0)))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle `,` while a dynamic-input field set is showing. Locks the
|
||||
/// current field's buffer if it has one, then either advances within
|
||||
/// the existing field set or reshapes it: a polar `[Distance, Angle]`
|
||||
/// configuration becomes cartesian `[X(buf), Y]`, and a cartesian
|
||||
/// `[X, Y]` configuration extends to `[X, Y, Z]`. Default fallthrough
|
||||
/// is "advance to next field", matching `Tab`. See #35.
|
||||
|
||||
pub(in crate::app) fn dyn_comma_advance(&mut self) {
|
||||
use crate::app::document::{DynComponent, DynFieldEntry};
|
||||
let i = self.active_tab;
|
||||
if self.tabs[i].dyn_fields.is_empty() {
|
||||
return;
|
||||
}
|
||||
// The user picked a shape — `sync_dyn_fields` preserves it until
|
||||
// the next commit / command-start clears the flag.
|
||||
self.dyn_user_reshaped = true;
|
||||
let active = self.tabs[i]
|
||||
.dyn_active
|
||||
.min(self.tabs[i].dyn_fields.len() - 1);
|
||||
let comps: Vec<DynComponent> = self.tabs[i]
|
||||
.dyn_fields
|
||||
.iter()
|
||||
.map(|f| f.component)
|
||||
.collect();
|
||||
let cur_buf = self.tabs[i].dyn_fields[active].buffer.clone();
|
||||
match (comps.as_slice(), active) {
|
||||
// First polar field — `,` switches to cartesian, locking the
|
||||
// typed value as X.
|
||||
([DynComponent::Distance, DynComponent::Angle], 0) | ([DynComponent::Distance], 0) => {
|
||||
let mut x_field = DynFieldEntry::new(DynComponent::X);
|
||||
x_field.buffer = cur_buf;
|
||||
self.tabs[i].dyn_fields = vec![x_field, DynFieldEntry::new(DynComponent::Y)];
|
||||
self.tabs[i].dyn_active = 1;
|
||||
}
|
||||
// Already cartesian X (first field) — just advance to Y.
|
||||
([DynComponent::X, DynComponent::Y], 0)
|
||||
| ([DynComponent::X, DynComponent::Y, DynComponent::Z], 0) => {
|
||||
self.tabs[i].dyn_active = 1;
|
||||
}
|
||||
// Cartesian Y — extend to 3-D by appending Z.
|
||||
([DynComponent::X, DynComponent::Y], 1) => {
|
||||
self.tabs[i]
|
||||
.dyn_fields
|
||||
.push(DynFieldEntry::new(DynComponent::Z));
|
||||
self.tabs[i].dyn_active = 2;
|
||||
}
|
||||
// Cartesian Y in the 3-D set — advance to Z.
|
||||
([DynComponent::X, DynComponent::Y, DynComponent::Z], 1) => {
|
||||
self.tabs[i].dyn_active = 2;
|
||||
}
|
||||
// Z, Angle, or any singleton: nothing further to advance to.
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// If dynamic input has at least one locked (typed) field, resolve the
|
||||
/// implied point, feed it to the active command as a point pick, reset
|
||||
/// the field buffers, and return the resulting task. Returns `None`
|
||||
/// when there is nothing typed, so the caller falls back to its normal
|
||||
/// Enter handling.
|
||||
/// Give a bare typed angle the sign of the cursor's side relative to the
|
||||
/// step's reference direction, so a commit-as-text angle rotates/sweeps the
|
||||
/// way the cursor is dragging. Only applies to steps with an `Angle` field;
|
||||
/// an explicit `+`/`-` is left untouched. Returns the (possibly re-signed)
|
||||
/// text to feed `on_text_input`.
|
||||
|
||||
pub(in crate::app) fn dyn_sign_angle_text(&self, i: usize, text: String) -> String {
|
||||
let has_angle = self.tabs[i]
|
||||
.dyn_fields
|
||||
.iter()
|
||||
.any(|f| f.role == crate::command::DynRole::Angle);
|
||||
let t = text.trim();
|
||||
if !has_angle || t.is_empty() || t.starts_with('-') || t.starts_with('+') {
|
||||
return text;
|
||||
}
|
||||
if t.parse::<f32>().is_err() {
|
||||
return text;
|
||||
}
|
||||
let anchor = self.tabs[i]
|
||||
.dyn_anchor
|
||||
.or(self.last_point)
|
||||
.unwrap_or(glam::Vec3::ZERO);
|
||||
let cur = self.tabs[i].last_cursor_world;
|
||||
let a_cur = (cur.y - anchor.y).atan2(cur.x - anchor.x);
|
||||
let a_ref = self.tabs[i]
|
||||
.dyn_ref
|
||||
.map(|r| (r.y - anchor.y).atan2(r.x - anchor.x))
|
||||
.unwrap_or(0.0);
|
||||
let mut d = a_cur - a_ref;
|
||||
while d > std::f32::consts::PI {
|
||||
d -= std::f32::consts::TAU;
|
||||
}
|
||||
while d <= -std::f32::consts::PI {
|
||||
d += std::f32::consts::TAU;
|
||||
}
|
||||
if d < 0.0 {
|
||||
format!("-{t}")
|
||||
} else {
|
||||
text
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
pub(in crate::app) fn try_dyn_commit(&mut self) -> Option<Task<Message>> {
|
||||
let i = self.active_tab;
|
||||
if !self.dyn_input
|
||||
|| self.tabs[i].active_cmd.is_none()
|
||||
|| self.tabs[i].dyn_fields.is_empty()
|
||||
|| !self.tabs[i].dyn_fields.iter().any(|f| f.locked())
|
||||
{
|
||||
return None;
|
||||
}
|
||||
// OTRACK: while aligned to a tracking ray, a typed value is a distance
|
||||
// along the ray from the tracking point (issue #69).
|
||||
if let Some((base, dir)) = self.otrack_active {
|
||||
let wants_text = self.tabs[i]
|
||||
.active_cmd
|
||||
.as_ref()
|
||||
.map(|c| c.wants_text_input())
|
||||
.unwrap_or(false);
|
||||
if !wants_text {
|
||||
if let Some(text) = self.tabs[i]
|
||||
.dyn_fields
|
||||
.iter()
|
||||
.find_map(|f| f.buffer.clone())
|
||||
{
|
||||
if let Some(dist) = crate::app::expr_eval::eval_number(text.trim()) {
|
||||
let pt = base + dir * dist as f32;
|
||||
self.last_point = Some(pt);
|
||||
for f in self.tabs[i].dyn_fields.iter_mut() {
|
||||
f.buffer = None;
|
||||
}
|
||||
self.tabs[i].dyn_active = 0;
|
||||
self.dyn_user_reshaped = false;
|
||||
self.sync_dyn_fields();
|
||||
self.reset_tracking_after_point();
|
||||
self.push_ucs_to_cmd(i);
|
||||
let result = self.tabs[i].active_cmd.as_mut().map(|c| c.on_point(pt.as_dvec3()));
|
||||
let task = result.map(|r| self.apply_cmd_result(r))?;
|
||||
self.refresh_active_cmd_preview(i);
|
||||
return Some(task);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// A text-input step reads its single box as a string and commits via
|
||||
// `on_text_input` (a count, radius, distance) rather than resolving a
|
||||
// point. Only the typed buffer matters here — a mouse-driven live
|
||||
// value commits through the viewport click, not Enter.
|
||||
// A point-with-keywords step (PLINE) commits a typed distance/angle as
|
||||
// a point, not as text, so it is excluded here.
|
||||
let wants_text = self.tabs[i]
|
||||
.active_cmd
|
||||
.as_ref()
|
||||
.map(|c| {
|
||||
(c.wants_text_input() && !c.point_step_accepts_keywords())
|
||||
|| c.dyn_commit_as_text()
|
||||
})
|
||||
.unwrap_or(false);
|
||||
if wants_text {
|
||||
let text = self.tabs[i]
|
||||
.dyn_fields
|
||||
.iter()
|
||||
.find_map(|f| f.buffer.clone())
|
||||
.unwrap_or_default();
|
||||
let text = crate::app::expr_eval::eval_to_string(text.trim());
|
||||
// Shared rule: a bare angle typed into a commit-as-text step takes
|
||||
// the sign of the cursor's side relative to the reference, so the
|
||||
// committed direction matches the drag (the box shows magnitude
|
||||
// only). Commands receive an already-signed string.
|
||||
let text = self.dyn_sign_angle_text(i, text);
|
||||
let result = self.tabs[i]
|
||||
.active_cmd
|
||||
.as_mut()
|
||||
.and_then(|c| c.on_text_input(&text));
|
||||
for f in self.tabs[i].dyn_fields.iter_mut() {
|
||||
f.buffer = None;
|
||||
}
|
||||
self.tabs[i].dyn_active = 0;
|
||||
self.sync_dyn_fields();
|
||||
let prompt = self.tabs[i].active_cmd.as_ref().map(|c| c.prompt());
|
||||
if let Some(p) = prompt {
|
||||
self.command_line.push_info(&p);
|
||||
}
|
||||
self.refresh_active_cmd_preview(i);
|
||||
return Some(match result {
|
||||
Some(r) => self.apply_cmd_result(r),
|
||||
None => self.focus_cmd_input(),
|
||||
});
|
||||
}
|
||||
let pt = self.dyn_resolve_point()?;
|
||||
self.last_point = Some(pt);
|
||||
self.dyn_user_reshaped = false;
|
||||
self.sync_dyn_fields();
|
||||
self.reset_tracking_after_point();
|
||||
self.push_ucs_to_cmd(i);
|
||||
let result = self.tabs[i].active_cmd.as_mut().map(|c| c.on_point(pt.as_dvec3()));
|
||||
for f in self.tabs[i].dyn_fields.iter_mut() {
|
||||
f.buffer = None;
|
||||
}
|
||||
self.tabs[i].dyn_active = 0;
|
||||
let task = result.map(|r| self.apply_cmd_result(r))?;
|
||||
// Match the command-line path: refresh the rubber-band preview
|
||||
// so the next segment immediately starts from the new
|
||||
// last_point even though no mouse-move fires after a typed
|
||||
// coordinate. See #32.
|
||||
self.refresh_active_cmd_preview(i);
|
||||
Some(task)
|
||||
}
|
||||
|
||||
/// Mutable access to the currently selected table style.
|
||||
|
||||
/// Re-run the active command's preview hook against the current
|
||||
/// cursor world position. Keyboard-driven point commits (typed
|
||||
/// coordinates in the command line or dynamic input) don't fire a
|
||||
/// mouse-move event, so without this the rubber-band preview keeps
|
||||
/// dangling from the previous `last_point` until the user actually
|
||||
/// moves the mouse. See #32.
|
||||
pub(in crate::app) fn refresh_active_cmd_preview(&mut self, i: usize) {
|
||||
if self.tabs[i].active_cmd.is_none() {
|
||||
return;
|
||||
}
|
||||
let cur = self.tabs[i].last_cursor_world;
|
||||
let previews = self.tabs[i]
|
||||
.active_cmd
|
||||
.as_mut()
|
||||
.map(|c| c.on_preview_wires(cur.as_dvec3()))
|
||||
.unwrap_or_default();
|
||||
self.tabs[i].scene.set_preview_wires(previews);
|
||||
}
|
||||
}
|
||||
980
src/app/update/file.rs
Normal file
980
src/app/update/file.rs
Normal file
|
|
@ -0,0 +1,980 @@
|
|||
//! `file` arms and helpers, split out of the original `update.rs` (#mechanical decomposition).
|
||||
|
||||
#![allow(unused_imports)]
|
||||
use super::util::*;
|
||||
use super::{format_size, tile_min_norm, TILE_EDGE_HIT_PX, VIEWCUBE_HIT_SIZE};
|
||||
use crate::app::helpers::{
|
||||
ortho_constrain, parse_coord, polar_constrain_near, ucs_rotate_vec, ucs_to_wcs, ucs_z_axis,
|
||||
CoordKind,
|
||||
};
|
||||
use crate::app::{Message, OpenCADStudio, POLY_START_DELAY_MS};
|
||||
use crate::modules::ModuleEvent;
|
||||
use crate::scene::pick::grip::{find_hit_grip, find_hit_grip_paper, find_hit_grip_rte, GripEdit};
|
||||
use crate::scene::model::object::GripApply;
|
||||
use crate::scene::{
|
||||
self, hover_id, CubeRegion, Scene, TileEdgeOrient, VIEWCUBE_DRAW_PX, VIEWCUBE_PAD, VIEWCUBE_PX,
|
||||
};
|
||||
use crate::ui::PropertiesPanel;
|
||||
use acadrust::types::Color as AcadColor;
|
||||
use acadrust::{EntityType as AcadEntityType, Handle};
|
||||
use iced::time::Instant;
|
||||
use iced::{mouse, Point, Task};
|
||||
|
||||
|
||||
impl OpenCADStudio {
|
||||
/// Snapshot the persisted UI preferences from live state.
|
||||
pub(in crate::app) fn current_settings(&self) -> crate::app::settings::UserSettings {
|
||||
crate::app::settings::UserSettings {
|
||||
dyn_input: self.dyn_input,
|
||||
ortho: self.ortho_mode,
|
||||
polar: self.polar_mode,
|
||||
polar_increment_deg: self.polar_increment_deg,
|
||||
snap_enabled: self.snapper.snap_enabled,
|
||||
otrack: self.snapper.otrack_enabled,
|
||||
snap_modes: crate::app::settings::UserSettings::modes_from(self.snapper.enabled.iter()),
|
||||
default_assoc_prompted: self.default_assoc_prompted,
|
||||
disabled_plugins: {
|
||||
let mut v: Vec<String> = self.disabled_plugins.iter().cloned().collect();
|
||||
v.sort();
|
||||
v
|
||||
},
|
||||
plugin_repos: self.plugin_repos.clone(),
|
||||
texteditmode: self.texteditmode,
|
||||
bg_color: self.default_bg_color.map(f4_to_u3),
|
||||
paper_bg_color: self.default_paper_bg_color.map(f4_to_u3),
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply restored preferences to live state.
|
||||
pub(in crate::app) fn apply_settings(&mut self, s: &crate::app::settings::UserSettings) {
|
||||
self.dyn_input = s.dyn_input;
|
||||
self.ortho_mode = s.ortho;
|
||||
self.polar_mode = s.polar;
|
||||
self.polar_increment_deg = s.polar_increment_deg;
|
||||
self.snapper.snap_enabled = s.snap_enabled;
|
||||
self.snapper.otrack_enabled = s.otrack;
|
||||
self.snapper.enabled = s.snap_modes.iter().copied().collect();
|
||||
self.default_assoc_prompted = s.default_assoc_prompted;
|
||||
self.disabled_plugins = s.disabled_plugins.iter().cloned().collect();
|
||||
self.plugin_repos = s.plugin_repos.clone();
|
||||
self.texteditmode = s.texteditmode;
|
||||
self.default_bg_color = s.bg_color.map(u3_to_f4);
|
||||
self.default_paper_bg_color = s.paper_bg_color.map(u3_to_f4);
|
||||
// Push the restored background onto every drawing tab that exists now
|
||||
// (the start tab and any initial drawing). Tabs created later pick it
|
||||
// up via `apply_bg_default` at their construction site.
|
||||
for idx in 0..self.tabs.len() {
|
||||
self.apply_bg_default(idx);
|
||||
}
|
||||
self.rebuild_ribbon_modules();
|
||||
}
|
||||
|
||||
/// Apply the persisted default background(s) to tab `idx`. No-op for the
|
||||
/// start tab or when no default is set. Refreshes the tab's cached wires
|
||||
/// and meshes so background-adaptive colours pick up the change.
|
||||
pub(in crate::app) fn apply_bg_default(&mut self, idx: usize) {
|
||||
let bg = self.default_bg_color;
|
||||
let paper_bg = self.default_paper_bg_color;
|
||||
if bg.is_none() && paper_bg.is_none() {
|
||||
return;
|
||||
}
|
||||
let tab = &mut self.tabs[idx];
|
||||
if tab.is_start {
|
||||
return;
|
||||
}
|
||||
if let Some(c) = bg {
|
||||
tab.bg_color = Some(c);
|
||||
tab.scene.bg_color = c;
|
||||
}
|
||||
if let Some(c) = paper_bg {
|
||||
tab.paper_bg_color = Some(c);
|
||||
tab.scene.paper_bg_color = c;
|
||||
}
|
||||
tab.scene.recolor_meshes();
|
||||
tab.scene.bump_geometry();
|
||||
}
|
||||
|
||||
/// Check if a suspended command exists on the active tab and resume it
|
||||
/// with the outcome of the text editor.
|
||||
pub(in crate::app) fn post_editor_closed(&mut self, committed: bool) -> Task<Message> {
|
||||
let i = self.active_tab;
|
||||
if let Some(mut cmd) = self.tabs[i].suspended_cmd.take() {
|
||||
let res = cmd.on_editor_closed(committed);
|
||||
self.tabs[i].active_cmd = Some(cmd);
|
||||
self.apply_cmd_result(res)
|
||||
} else {
|
||||
Task::none()
|
||||
}
|
||||
}
|
||||
|
||||
/// Rebuild the ribbon's tab list from the registry, dropping the tabs of any
|
||||
/// disabled plugins. Call after `disabled_plugins` changes.
|
||||
pub(in crate::app) fn rebuild_ribbon_modules(&mut self) {
|
||||
let modules =
|
||||
crate::plugin::ribbon_modules_enabled(&self.disabled_plugins);
|
||||
self.ribbon.set_modules(modules);
|
||||
}
|
||||
|
||||
/// Snapshot of disabled plugin ids — lets the registry skip them while it
|
||||
/// holds a `&mut` borrow of the app via `HostSession`.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) fn disabled_plugin_ids(&self) -> rustc_hash::FxHashSet<String> {
|
||||
self.disabled_plugins.clone()
|
||||
}
|
||||
|
||||
/// Background task: fetch the curated plugin registry.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(in crate::app) fn fetch_registry_task(&self) -> Task<Message> {
|
||||
Task::perform(
|
||||
async { crate::plugin::marketplace::fetch_registry() },
|
||||
Message::PluginRegistryFetched,
|
||||
)
|
||||
}
|
||||
|
||||
/// Background task: fetch `owner/repo`'s installable release tags.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(in crate::app) fn fetch_releases_task(&self, repo: String) -> Task<Message> {
|
||||
let label = repo.clone();
|
||||
Task::perform(
|
||||
async move {
|
||||
crate::plugin::marketplace::fetch_releases(&repo).map(|rs| {
|
||||
rs.into_iter()
|
||||
.filter(|r| r.installable())
|
||||
.map(|r| r.tag)
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
},
|
||||
move |res| Message::PluginReleasesFetched(label, res),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub(in crate::app) fn fetch_releases_task(&self, _repo: String) -> Task<Message> {
|
||||
Task::none()
|
||||
}
|
||||
|
||||
/// Background task: download and install the `tag` release of `owner/repo`.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(in crate::app) fn install_task(&self, repo: String, tag: String) -> Task<Message> {
|
||||
Task::perform(
|
||||
async move {
|
||||
let releases = crate::plugin::marketplace::fetch_releases(&repo)?;
|
||||
let rel = releases
|
||||
.into_iter()
|
||||
.find(|r| r.tag == tag)
|
||||
.ok_or_else(|| format!("release {tag} not found"))?;
|
||||
crate::plugin::marketplace::install(&rel)
|
||||
},
|
||||
Message::PluginInstalled,
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub(in crate::app) fn install_task(&self, _repo: String, _tag: String) -> Task<Message> {
|
||||
Task::none()
|
||||
}
|
||||
|
||||
/// Write preferences to disk only when they differ from the last write,
|
||||
/// so a toggle persists immediately without thrashing the file.
|
||||
pub(in crate::app) fn persist_settings_if_changed(&mut self) {
|
||||
let cur = self.current_settings();
|
||||
if self.last_saved_settings.as_ref() != Some(&cur) {
|
||||
cur.save();
|
||||
self.last_saved_settings = Some(cur);
|
||||
}
|
||||
}
|
||||
|
||||
/// Record that the one-time default-association prompt has been answered and
|
||||
/// flush it to disk, so the dialog never reappears on later launches.
|
||||
pub(in crate::app) fn mark_assoc_prompted(&mut self) {
|
||||
self.default_assoc_prompted = true;
|
||||
self.persist_settings_if_changed();
|
||||
}
|
||||
|
||||
pub(super) fn on_open_file(&mut self) -> Task<Message> {
|
||||
// Native: pick a path, then load on a worker thread. Web: the
|
||||
// browser hands back bytes, so pick + parse in one step and feed
|
||||
// the shared `FileOpened` handler directly.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
Task::perform(crate::io::pick_open_path(), Message::OpenPathPicked)
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
// `FileOpened` only installs the result when an open is in
|
||||
// progress, so mark one. The browser picker + parse happen
|
||||
// inside `pick_and_load_web`; the real name is unknown until
|
||||
// then, so show a generic label meanwhile.
|
||||
self.opening = Some(crate::app::OpenProgress {
|
||||
name: "Opening…".into(),
|
||||
size_bytes: 0,
|
||||
phase: std::sync::Arc::new(std::sync::atomic::AtomicU8::new(
|
||||
crate::app::OPEN_PHASE_READING,
|
||||
)),
|
||||
started: Instant::now(),
|
||||
});
|
||||
Task::perform(crate::io::pick_and_load_web(), Message::FileOpened)
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn on_file_opened(&mut self, name: String, path: std::path::PathBuf, doc: acadrust::CadDocument, caches: crate::scene::DerivedCaches) -> Task<Message> {
|
||||
// If the user clicked Cancel while the parser was running, the
|
||||
// overlay state was cleared and we silently drop the result.
|
||||
if self.opening.is_none() {
|
||||
return Task::none();
|
||||
}
|
||||
let open_started = self.opening.take().map(|p| p.started);
|
||||
let timings = caches.timings;
|
||||
let entity_count = doc.entities().count();
|
||||
self.command_line
|
||||
.push_output(&format!("Opened \"{name}\" — {entity_count} entities"));
|
||||
if caches.corrupt_dropped > 0 {
|
||||
self.command_line.push_error(&format!(
|
||||
"Warning: {} corrupt entities dropped (parser junk — bad normals / counts)",
|
||||
caches.corrupt_dropped
|
||||
));
|
||||
}
|
||||
self.app_menu.push_recent(path.clone());
|
||||
|
||||
let current_is_empty = {
|
||||
let t = &self.tabs[self.active_tab];
|
||||
!t.is_start
|
||||
&& t.current_path.is_none()
|
||||
&& !t.dirty
|
||||
&& self.tabs[self.active_tab].scene.document.entities().count() == 0
|
||||
};
|
||||
let i = if current_is_empty {
|
||||
self.active_tab
|
||||
} else {
|
||||
self.tab_counter += 1;
|
||||
let new_tab = crate::app::document::DocumentTab::new_drawing(self.tab_counter);
|
||||
self.tabs.push(new_tab);
|
||||
let idx = self.tabs.len() - 1;
|
||||
self.active_tab = idx;
|
||||
self.apply_bg_default(idx);
|
||||
idx
|
||||
};
|
||||
|
||||
self.tabs[i].current_path = Some(path.clone());
|
||||
self.tabs[i].scene.document = doc;
|
||||
// Follow the file's saved current UCS from the moment it opens.
|
||||
self.tabs[i].adopt_active_ucs_from_header();
|
||||
// Route shared CJK ideographs to the language matching this
|
||||
// drawing's code page (web per-language font split). Drop the
|
||||
// glyph cache if it changed so Han re-resolves to the new
|
||||
// language's font; geometry is (re)built below regardless. (#141)
|
||||
if crate::scene::text::web_font::set_cjk_lang_from_codepage(
|
||||
&self.tabs[i].scene.document.header.code_page,
|
||||
) {
|
||||
crate::scene::text::ttf_glyph::clear_fallback_cache();
|
||||
}
|
||||
// Current model-space annotation scale comes from the drawing's
|
||||
// CANNOSCALEVALUE (paper/drawing factor); the multiplier we use
|
||||
// for text/dim sizing is its inverse (1:50 -> 0.02 -> 50.0).
|
||||
let cannoscale_value = self.tabs[i].scene.document.header.annotation_scale_value;
|
||||
self.tabs[i].scene.annotation_scale = if cannoscale_value > 1e-9 {
|
||||
(1.0 / cannoscale_value) as f32
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
|
||||
// Auto-resolve XREFs relative to the opened file's directory.
|
||||
let mut xref_ms = 0u32;
|
||||
if let Some(base_dir) = path.parent() {
|
||||
// xref content arrives un-purged: parser-garbage entities
|
||||
// inside the referenced file can trigger infinite loops in
|
||||
// tessellation. `resolve_xrefs` runs the corrupt-entity
|
||||
// guard inline as it merges each xref, so no second
|
||||
// full-document walk is needed here.
|
||||
let t_xref = Instant::now();
|
||||
let (xrefs, extra_dropped) =
|
||||
crate::io::xref::resolve_xrefs(&mut self.tabs[i].scene.document, base_dir);
|
||||
xref_ms = t_xref.elapsed().as_millis() as u32;
|
||||
if extra_dropped > 0 {
|
||||
self.command_line.push_error(&format!(
|
||||
"Warning: {extra_dropped} corrupt xref entities dropped"
|
||||
));
|
||||
}
|
||||
for info in &xrefs {
|
||||
match info.status {
|
||||
crate::io::xref::XrefStatus::Loaded => {
|
||||
self.command_line
|
||||
.push_output(&format!("XREF Loaded \"{}\"", info.name));
|
||||
}
|
||||
crate::io::xref::XrefStatus::NotFound => {
|
||||
self.command_line.push_error(&format!(
|
||||
"XREF Not found: \"{}\" ({})",
|
||||
info.name, info.path
|
||||
));
|
||||
}
|
||||
crate::io::xref::XrefStatus::Unloaded => {
|
||||
self.command_line.push_info(&format!(
|
||||
"XREF Unloaded (skipped): \"{}\"",
|
||||
info.name
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Open-time breakdown so regressions are visible immediately.
|
||||
// `total` is wall time from the Open click to here (post-xref,
|
||||
// pre-first-frame); the phase figures are the background-thread
|
||||
// parse/purge/cache spans plus the UI-thread xref resolve.
|
||||
let total_ms = open_started
|
||||
.map(|s| s.elapsed().as_millis() as u32)
|
||||
.unwrap_or(0);
|
||||
self.command_line.push_info(&format!(
|
||||
" parse {}ms · purge {}ms · caches {}ms · xref {}ms · total {}ms",
|
||||
timings.parse_ms, timings.purge_ms, timings.caches_ms, xref_ms, total_ms
|
||||
));
|
||||
|
||||
// Caches were built on the background thread inside open_path().
|
||||
self.tabs[i].scene.local_extent_max = caches.local_extent_max;
|
||||
self.tabs[i].scene.local_center = caches.local_center;
|
||||
self.tabs[i].scene.hatches = caches.hatches;
|
||||
self.tabs[i].scene.images = caches.images;
|
||||
self.tabs[i].scene.meshes = caches.meshes;
|
||||
self.tabs[i].scene.block_meshes = caches.block_meshes;
|
||||
// Invalidate the wire cache so the new document is tessellated.
|
||||
self.tabs[i].scene.bump_geometry();
|
||||
self.tabs[i].scene.selected = rustc_hash::FxHashSet::default();
|
||||
self.tabs[i].scene.preview_wires = vec![];
|
||||
self.tabs[i].scene.current_layout = "Model".to_string();
|
||||
crate::linetypes::populate_document(&mut self.tabs[i].scene.document);
|
||||
self.tabs[i].properties = PropertiesPanel::empty();
|
||||
// Seed the current table / multileader style from the file's
|
||||
// header so the ✓ marks the right one (text/dim/mline come from
|
||||
// the document header directly). DXF provides these via
|
||||
// $CTABLESTYLE / $CMLEADERSTYLE; DWG leaves them at "Standard".
|
||||
self.ribbon.active_table_style = self.tabs[i]
|
||||
.scene
|
||||
.document
|
||||
.header
|
||||
.current_table_style_name
|
||||
.clone();
|
||||
self.tabs[i].active_mleader_style = self.tabs[i]
|
||||
.scene
|
||||
.document
|
||||
.header
|
||||
.current_mleader_style_name
|
||||
.clone();
|
||||
let doc_layers = self.tabs[i].scene.document.layers.clone();
|
||||
let vp_info = self.tabs[i].scene.viewport_list();
|
||||
self.tabs[i]
|
||||
.layers
|
||||
.sync_with_viewports(&doc_layers, vp_info);
|
||||
self.sync_ribbon_layers();
|
||||
// Load the Annotate-ribbon style dropdowns (text / dimension /
|
||||
// multileader / table) from the opened document instead of
|
||||
// leaving them on the hard-coded "Standard" default.
|
||||
self.sync_ribbon_styles();
|
||||
// Reset the Home-ribbon Color / Linetype / Lineweight chips
|
||||
// to the newly opened document's CECOLOR / CELTYPE / CELWEIGHT
|
||||
// defaults (or to ByLayer when the file leaves them empty).
|
||||
// Without this they stick to whatever the prior tab had
|
||||
// selected — see #21.
|
||||
self.sync_ribbon_from_selection();
|
||||
self.tabs[i].scene.restore_saved_camera();
|
||||
// Grid/snap are per-drawing view settings — adopt the opened
|
||||
// file's active viewport state rather than a global preference.
|
||||
self.adopt_view_display(i);
|
||||
self.sync_render_mode_to_active_tile(i);
|
||||
self.tabs[i].last_synced_camera_gen = self.tabs[i].scene.camera_generation;
|
||||
self.tabs[i].dirty = false;
|
||||
self.tabs[i].history = crate::app::document::HistoryState::default();
|
||||
self.refresh_selected_grips();
|
||||
Task::none()
|
||||
}
|
||||
|
||||
pub(super) fn on_wblock_save_result_some(&mut self, block_name: String, path: std::path::PathBuf) -> Task<Message> {
|
||||
let i = self.active_tab;
|
||||
let result = if block_name == "*" {
|
||||
let handles: Vec<_> = self.tabs[i].scene.selected.iter().copied().collect();
|
||||
crate::modules::insert::wblock::extract_entities_to_doc(
|
||||
&self.tabs[i].scene.document,
|
||||
&handles,
|
||||
)
|
||||
} else {
|
||||
crate::modules::insert::wblock::extract_block_to_doc(
|
||||
&self.tabs[i].scene.document,
|
||||
&block_name,
|
||||
)
|
||||
};
|
||||
match result {
|
||||
Ok(doc) => match crate::io::save(&doc, &path) {
|
||||
Ok(()) => {
|
||||
let fname = path
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| path.to_string_lossy().into_owned());
|
||||
self.command_line.push_output(&format!(
|
||||
"WBLOCK Saved \"{block_name}\" → \"{fname}\""
|
||||
));
|
||||
}
|
||||
Err(e) => self
|
||||
.command_line
|
||||
.push_error(&format!("WBLOCK save failed: {e}")),
|
||||
},
|
||||
Err(e) => self.command_line.push_error(&format!("WBLOCK: {e}")),
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
|
||||
pub(super) fn on_stl_export_path_some(&mut self, path: std::path::PathBuf) -> Task<Message> {
|
||||
// Re-build STL bytes (we can't easily pass them through the message).
|
||||
let i = self.active_tab;
|
||||
// STL gets the highest-resolution LOD (slot 0) so the
|
||||
// exported geometry isn't downgraded by the view-dependent
|
||||
// mesh LOD ladder used for rendering.
|
||||
let meshes: Vec<crate::scene::model::mesh_model::MeshModel> = self.tabs[i]
|
||||
.scene
|
||||
.meshes
|
||||
.values()
|
||||
.filter_map(|s| s.lods.first().cloned())
|
||||
.collect();
|
||||
let mesh_refs: Vec<&crate::scene::model::mesh_model::MeshModel> = meshes.iter().collect();
|
||||
match crate::io::stl::build_stl(&mesh_refs) {
|
||||
Some(bytes) => match std::fs::write(&path, bytes) {
|
||||
Ok(()) => self
|
||||
.command_line
|
||||
.push_output(&format!("STLOUT: exported to \"{}\"", path.display())),
|
||||
Err(e) => self
|
||||
.command_line
|
||||
.push_error(&format!("STLOUT: write error: {e}")),
|
||||
},
|
||||
None => self
|
||||
.command_line
|
||||
.push_error("STLOUT: no mesh data to export."),
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
|
||||
pub(super) fn on_step_export_path_some(&mut self, path: std::path::PathBuf) -> Task<Message> {
|
||||
let i = self.active_tab;
|
||||
// Export uses LOD 0 (full resolution); see StlExportPath above.
|
||||
let meshes: Vec<crate::scene::model::mesh_model::MeshModel> = self.tabs[i]
|
||||
.scene
|
||||
.meshes
|
||||
.values()
|
||||
.filter_map(|s| s.lods.first().cloned())
|
||||
.collect();
|
||||
let mesh_refs: Vec<&crate::scene::model::mesh_model::MeshModel> = meshes.iter().collect();
|
||||
match crate::io::step::build_step(&mesh_refs) {
|
||||
Some(text) => match std::fs::write(&path, text.as_bytes()) {
|
||||
Ok(()) => self
|
||||
.command_line
|
||||
.push_output(&format!("STEPOUT: exported to \"{}\"", path.display())),
|
||||
Err(e) => self
|
||||
.command_line
|
||||
.push_error(&format!("STEPOUT: write error: {e}")),
|
||||
},
|
||||
None => self
|
||||
.command_line
|
||||
.push_error("STEPOUT: no mesh data to export."),
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
|
||||
pub(super) fn on_obj_import_path_some(&mut self, path: std::path::PathBuf) -> Task<Message> {
|
||||
let src = match std::fs::read_to_string(&path) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
self.command_line
|
||||
.push_error(&format!("IMPORTOBJ: read error: {e}"));
|
||||
return Task::none();
|
||||
}
|
||||
};
|
||||
let color = [0.7f32, 0.7, 0.85, 1.0];
|
||||
match crate::io::obj::parse_obj(&src, color) {
|
||||
None => {
|
||||
self.command_line
|
||||
.push_error("IMPORTOBJ: no usable geometry in file.");
|
||||
}
|
||||
Some(mut mesh) => {
|
||||
let i = self.active_tab;
|
||||
let file_stem = path
|
||||
.file_stem()
|
||||
.map(|s| s.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| "obj_mesh".into());
|
||||
mesh.name = file_stem.clone();
|
||||
self.push_undo_snapshot(i, "IMPORTOBJ");
|
||||
use crate::modules::insert::solid3d_cmds::empty_solid3d;
|
||||
let entity = empty_solid3d();
|
||||
let handle = self.tabs[i].scene.add_entity(entity);
|
||||
if !handle.is_null() {
|
||||
self.tabs[i]
|
||||
.scene
|
||||
.meshes
|
||||
.insert(handle, crate::scene::MeshLodSet::from_single(mesh));
|
||||
self.tabs[i].dirty = true;
|
||||
self.command_line.push_output(&format!(
|
||||
"IMPORTOBJ: imported \"{}\" as mesh.",
|
||||
file_stem
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
|
||||
pub(super) fn on_save_file(&mut self) -> Task<Message> {
|
||||
if self.read_only {
|
||||
self.command_line
|
||||
.push_error("Read-only session (--read-only): saving is disabled.");
|
||||
return Task::none();
|
||||
}
|
||||
let i = self.active_tab;
|
||||
// Stamp the live grid/snap toggles onto the VPort so the file
|
||||
// reflects them even if they came from settings with no
|
||||
// in-session toggle (#121).
|
||||
self.sync_vport_display(i);
|
||||
// Native: save straight to the known path. Web has no path
|
||||
// (downloads instead), so always go through the Save dialog.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if let Some(path) = self.tabs[i].current_path.clone() {
|
||||
self.tabs[i].scene.document.header.user_real1 =
|
||||
self.tabs[i].scene.annotation_scale as f64;
|
||||
match crate::io::save(&self.tabs[i].scene.document, &path) {
|
||||
Ok(()) => {
|
||||
self.command_line
|
||||
.push_output(&format!("Saved: {}", path.display()));
|
||||
self.tabs[i].dirty = false;
|
||||
}
|
||||
Err(e) => self.command_line.push_error(&format!("Save failed: {e}")),
|
||||
}
|
||||
return Task::none();
|
||||
}
|
||||
self.save_dialog_for_unsaved = false;
|
||||
self.open_save_dialog_window(i)
|
||||
}
|
||||
|
||||
pub(super) fn on_save_dialog_confirm(&mut self) -> Task<Message> {
|
||||
let (ext, version) = crate::io::parse_save_format(&self.save_dialog_format);
|
||||
// The user need not type an extension: append the selected
|
||||
// format's one when the entered name carries none.
|
||||
let name = self.save_dialog_filename.trim();
|
||||
let filename = if name.is_empty() {
|
||||
format!("drawing.{ext}")
|
||||
} else if std::path::Path::new(name).extension().is_none() {
|
||||
format!("{name}.{ext}")
|
||||
} else {
|
||||
name.to_string()
|
||||
};
|
||||
self.save_dialog_filename = filename.clone();
|
||||
self.save_dialog_filename = filename.clone();
|
||||
let close = self.close_save_dialog_window();
|
||||
let i = self.active_tab;
|
||||
sync_annotation_scale_header(&mut self.tabs[i].scene);
|
||||
|
||||
// Native: write to the chosen path. Web: download the bytes
|
||||
// under the chosen name (no filesystem).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let path = self.save_dialog_folder.join(&filename);
|
||||
match crate::io::save_as_version(&self.tabs[i].scene.document, &path, version) {
|
||||
Ok(()) => {
|
||||
self.command_line
|
||||
.push_output(&format!("Saved: {}", path.display()));
|
||||
self.tabs[i].current_path = Some(path.clone());
|
||||
self.tabs[i].dirty = false;
|
||||
if self.save_dialog_for_unsaved {
|
||||
let next = self.update(Message::UnsavedPickedSavePath(Some(path)));
|
||||
return Task::batch([close, next]);
|
||||
}
|
||||
}
|
||||
Err(e) => self.command_line.push_error(&format!("Save failed: {e}")),
|
||||
}
|
||||
close
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
match crate::io::save_to_bytes(&self.tabs[i].scene.document, ext, version) {
|
||||
Ok(bytes) => {
|
||||
crate::sys::download_bytes(&filename, &bytes);
|
||||
self.tabs[i].dirty = false;
|
||||
self.command_line.push_output(&format!("Saved: {filename}"));
|
||||
}
|
||||
Err(e) => self.command_line.push_error(&format!("Save failed: {e}")),
|
||||
}
|
||||
// Continue a pending tab close.
|
||||
if self.save_dialog_for_unsaved {
|
||||
if let Some(crate::app::PendingClose::Tab(idx)) = self.pending_close.take() {
|
||||
let cont = self.update(Message::TabClose(idx));
|
||||
return Task::batch([close, cont]);
|
||||
}
|
||||
}
|
||||
close
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn on_page_setup_commit(&mut self) -> Task<Message> {
|
||||
let i = self.active_tab;
|
||||
let layout_name = self.tabs[i].scene.current_layout.clone();
|
||||
if layout_name != "Model" {
|
||||
let w: f64 = self.page_setup_w.parse::<f64>().unwrap_or(297.0).max(1.0);
|
||||
let h: f64 = self.page_setup_h.parse::<f64>().unwrap_or(210.0).max(1.0);
|
||||
let plot_area = self.page_setup_plot_area.clone();
|
||||
let center = self.page_setup_center;
|
||||
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 AND its embedded
|
||||
// PlotSettings fields. `paper_limits()` (sheet rendering) and
|
||||
// the DWG writer both read these from the Layout, so a page
|
||||
// setup that only touched a side PlotSettings object would not
|
||||
// reflect on screen or survive a save. The dialog's w/h are
|
||||
// the final sheet dimensions, so store them verbatim with no
|
||||
// further rotation swap (#156).
|
||||
for obj in self.tabs[i].scene.document.objects.values_mut() {
|
||||
if let acadrust::objects::ObjectType::Layout(l) = obj {
|
||||
if l.name == layout_name {
|
||||
l.min_limits = (0.0, 0.0);
|
||||
l.max_limits = (w, h);
|
||||
l.min_extents = (0.0, 0.0, 0.0);
|
||||
l.max_extents = (w, h, 0.0);
|
||||
l.paper_width = w;
|
||||
l.paper_height = h;
|
||||
l.plot_rotation = 0;
|
||||
l.plot_paper_units = 1; // millimetres
|
||||
l.plot_origin_x = offset_x;
|
||||
l.plot_origin_y = offset_y;
|
||||
// Custom dimensions no longer match a named size.
|
||||
l.paper_size = String::new();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Find or create the PlotSettings object for this layout.
|
||||
use acadrust::objects::{
|
||||
ObjectType, PlotPaperUnits, PlotRotation, PlotSettings, PlotType,
|
||||
};
|
||||
let plot_handle =
|
||||
self.tabs[i]
|
||||
.scene
|
||||
.document
|
||||
.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 = if let Some(h) = plot_handle {
|
||||
self.tabs[i].scene.document.objects.get_mut(&h)
|
||||
} else {
|
||||
// Create a new PlotSettings object and insert it.
|
||||
let mut ps = PlotSettings::new(layout_name.clone());
|
||||
ps.handle = self.tabs[i].scene.document.allocate_handle();
|
||||
let h = ps.handle;
|
||||
self.tabs[i]
|
||||
.scene
|
||||
.document
|
||||
.objects
|
||||
.insert(h, ObjectType::PlotSettings(ps));
|
||||
self.tabs[i].scene.document.objects.get_mut(&h)
|
||||
};
|
||||
|
||||
if let Some(ObjectType::PlotSettings(ps)) = ps_entry {
|
||||
ps.paper_width = w;
|
||||
ps.paper_height = h;
|
||||
ps.paper_units = PlotPaperUnits::Millimeters;
|
||||
ps.plot_type = if plot_area == "Extents" {
|
||||
PlotType::Extents
|
||||
} else {
|
||||
PlotType::Layout
|
||||
};
|
||||
ps.flags.plot_centered = center;
|
||||
ps.origin_x = offset_x;
|
||||
ps.origin_y = offset_y;
|
||||
ps.rotation = match rotation {
|
||||
90 => PlotRotation::Degrees90,
|
||||
180 => PlotRotation::Degrees180,
|
||||
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;
|
||||
// The paper sheet fill is cached; bump geometry so the new
|
||||
// sheet size re-tessellates and shows immediately.
|
||||
self.tabs[i].scene.bump_geometry();
|
||||
self.command_line.push_info(&format!(
|
||||
"Page setup: {w:.1}×{h:.1} mm area={plot_area} \
|
||||
center={center} rot={rotation}°"
|
||||
));
|
||||
}
|
||||
self.active_modal = None;
|
||||
Task::none()
|
||||
}
|
||||
|
||||
pub(super) fn on_plot_export_path_some(&mut self, path: std::path::PathBuf) -> Task<Message> {
|
||||
let i = self.active_tab;
|
||||
let scene = &self.tabs[i].scene;
|
||||
let wires = scene.entity_wires();
|
||||
let hatches = scene.paper_canvas_hatches();
|
||||
let wipeouts = scene.paper_canvas_wipeouts();
|
||||
|
||||
// Read PlotSettings for current layout (if available).
|
||||
use acadrust::objects::PlotType;
|
||||
let ps_snap = scene.effective_plot_settings();
|
||||
|
||||
// Determine paper size and drawing offset.
|
||||
let (paper_w, paper_h, mut draw_ox, mut draw_oy, rotation_deg) =
|
||||
if let Some(((x0, y0), (x1, y1))) = scene.paper_limits() {
|
||||
let (pw, ph) = (x1 - x0, y1 - y0);
|
||||
|
||||
// If PlotSettings says Extents, use model space extents instead.
|
||||
let use_extents = ps_snap
|
||||
.as_ref()
|
||||
.map(|ps| matches!(ps.plot_type, PlotType::Extents))
|
||||
.unwrap_or(false);
|
||||
|
||||
let (ox, oy) = if use_extents {
|
||||
if let Some((mn, _mx)) = scene.model_space_extents() {
|
||||
(-mn.x as f64, -mn.y as f64)
|
||||
} else {
|
||||
(-x0, -y0)
|
||||
}
|
||||
} else {
|
||||
(-x0, -y0)
|
||||
};
|
||||
|
||||
let rot = ps_snap
|
||||
.as_ref()
|
||||
.map(|ps| ps.rotation.to_degrees() as i32)
|
||||
.unwrap_or(0);
|
||||
|
||||
(pw, ph, ox, oy, rot)
|
||||
} else {
|
||||
// Model space: fit with 5% margin.
|
||||
let margin = 1.05_f64;
|
||||
if let Some((mn, mx)) = scene.model_space_extents() {
|
||||
let w = ((mx.x - mn.x) as f64 * margin).max(1.0);
|
||||
let h = ((mx.y - mn.y) as f64 * margin).max(1.0);
|
||||
let pad_x = (w - (mx.x - mn.x) as f64) * 0.5;
|
||||
let pad_y = (h - (mx.y - mn.y) as f64) * 0.5;
|
||||
(w, h, -(mn.x as f64) + pad_x, -(mn.y as f64) + pad_y, 0)
|
||||
} else {
|
||||
(297.0, 210.0, 0.0, 0.0, 0)
|
||||
}
|
||||
};
|
||||
|
||||
// Apply PlotSettings offset and centering.
|
||||
if let Some(ref ps) = ps_snap {
|
||||
if ps.flags.plot_centered {
|
||||
// Centering: compute wire extents and re-centre.
|
||||
let all_x: Vec<f32> = wires
|
||||
.iter()
|
||||
.flat_map(|w| w.points.iter().map(|p| p[0]))
|
||||
.filter(|v| !v.is_nan())
|
||||
.collect();
|
||||
let all_y: Vec<f32> = wires
|
||||
.iter()
|
||||
.flat_map(|w| w.points.iter().map(|p| p[1]))
|
||||
.filter(|v| !v.is_nan())
|
||||
.collect();
|
||||
if let (Some(&min_x), Some(&max_x), Some(&min_y), Some(&max_y)) = (
|
||||
all_x.iter().copied().reduce(f32::min).as_ref(),
|
||||
all_x.iter().copied().reduce(f32::max).as_ref(),
|
||||
all_y.iter().copied().reduce(f32::min).as_ref(),
|
||||
all_y.iter().copied().reduce(f32::max).as_ref(),
|
||||
) {
|
||||
let cx = (min_x + max_x) as f64 / 2.0;
|
||||
let cy = (min_y + max_y) as f64 / 2.0;
|
||||
draw_ox += paper_w / 2.0 - cx;
|
||||
draw_oy += paper_h / 2.0 - cy;
|
||||
}
|
||||
} else {
|
||||
draw_ox += ps.origin_x;
|
||||
draw_oy += ps.origin_y;
|
||||
}
|
||||
}
|
||||
|
||||
// For rotation: swap paper dimensions and note angle for export.
|
||||
let (eff_w, eff_h) = match rotation_deg {
|
||||
90 | 270 => (paper_h, paper_w),
|
||||
_ => (paper_w, paper_h),
|
||||
};
|
||||
|
||||
match crate::io::pdf_export::export_pdf(
|
||||
&wires,
|
||||
hatches.as_slice(),
|
||||
wipeouts.as_slice(),
|
||||
eff_w,
|
||||
eff_h,
|
||||
draw_ox as f32,
|
||||
draw_oy as f32,
|
||||
rotation_deg,
|
||||
&path,
|
||||
self.active_plot_style.as_ref(),
|
||||
) {
|
||||
Ok(()) => self.command_line.push_info(&format!(
|
||||
"Exported: {}",
|
||||
path.file_name().unwrap_or_default().to_string_lossy()
|
||||
)),
|
||||
Err(e) => self.command_line.push_error(&format!("Export failed: {e}")),
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
|
||||
pub(super) fn on_print_to_printer(&mut self) -> Task<Message> {
|
||||
let i = self.active_tab;
|
||||
let scene = &self.tabs[i].scene;
|
||||
let wires = scene.entity_wires();
|
||||
let hatches: Vec<_> = scene.paper_canvas_hatches().as_ref().clone();
|
||||
let wipeouts: Vec<_> = scene.paper_canvas_wipeouts().as_ref().clone();
|
||||
use acadrust::objects::PlotType;
|
||||
let ps_snap = scene.effective_plot_settings();
|
||||
let (paper_w, paper_h, draw_ox, draw_oy, rotation_deg) =
|
||||
if let Some(((x0, y0), (x1, y1))) = scene.paper_limits() {
|
||||
let (pw, ph) = (x1 - x0, y1 - y0);
|
||||
let use_extents = ps_snap
|
||||
.as_ref()
|
||||
.map(|ps| matches!(ps.plot_type, PlotType::Extents))
|
||||
.unwrap_or(false);
|
||||
let (ox, oy) = if use_extents {
|
||||
if let Some((mn, _mx)) = scene.model_space_extents() {
|
||||
(-mn.x as f64, -mn.y as f64)
|
||||
} else {
|
||||
(-x0, -y0)
|
||||
}
|
||||
} else {
|
||||
(-x0, -y0)
|
||||
};
|
||||
let rot = ps_snap
|
||||
.as_ref()
|
||||
.map(|ps| ps.rotation.to_degrees() as i32)
|
||||
.unwrap_or(0);
|
||||
(pw, ph, ox, oy, rot)
|
||||
} else {
|
||||
if let Some((mn, mx)) = scene.model_space_extents() {
|
||||
let margin = 1.05_f64;
|
||||
let w = ((mx.x - mn.x) as f64 * margin).max(1.0);
|
||||
let h = ((mx.y - mn.y) as f64 * margin).max(1.0);
|
||||
let pad_x = (w - (mx.x - mn.x) as f64) * 0.5;
|
||||
let pad_y = (h - (mx.y - mn.y) as f64) * 0.5;
|
||||
(w, h, -(mn.x as f64) + pad_x, -(mn.y as f64) + pad_y, 0)
|
||||
} else {
|
||||
(297.0, 210.0, 0.0, 0.0, 0)
|
||||
}
|
||||
};
|
||||
let (eff_w, eff_h) = match rotation_deg {
|
||||
90 | 270 => (paper_h, paper_w),
|
||||
_ => (paper_w, paper_h),
|
||||
};
|
||||
let plot_style = self.active_plot_style.clone();
|
||||
self.command_line.push_info("Sending to system printer…");
|
||||
Task::perform(
|
||||
async move {
|
||||
crate::io::print_to_printer::print_wires(
|
||||
wires,
|
||||
hatches,
|
||||
wipeouts,
|
||||
eff_w,
|
||||
eff_h,
|
||||
draw_ox as f32,
|
||||
draw_oy as f32,
|
||||
rotation_deg,
|
||||
plot_style,
|
||||
)
|
||||
.await
|
||||
},
|
||||
Message::PrintResult,
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn on_plot_style_panel_apply(&mut self) -> Task<Message> {
|
||||
let aci = self.plotstyle_panel_aci as usize;
|
||||
if let Some(table) = self.active_plot_style.as_mut() {
|
||||
if let Some(entry) = table.aci_entries.get_mut(aci) {
|
||||
// Parse color.
|
||||
let color_str = self.ps_color_buf.trim();
|
||||
if color_str.is_empty() {
|
||||
entry.color = None;
|
||||
} else if color_str.starts_with('#') && color_str.len() == 7 {
|
||||
let r = u8::from_str_radix(&color_str[1..3], 16).unwrap_or(0);
|
||||
let g = u8::from_str_radix(&color_str[3..5], 16).unwrap_or(0);
|
||||
let b = u8::from_str_radix(&color_str[5..7], 16).unwrap_or(0);
|
||||
entry.color = Some([r, g, b]);
|
||||
}
|
||||
if let Ok(lw) = self.ps_lineweight_buf.trim().parse::<u8>() {
|
||||
entry.lineweight = lw;
|
||||
}
|
||||
if let Ok(sc) = self.ps_screening_buf.trim().parse::<u8>() {
|
||||
entry.screening = sc.min(100);
|
||||
}
|
||||
self.command_line
|
||||
.push_output(&format!("Plot style ACI {aci} updated."));
|
||||
}
|
||||
} else {
|
||||
// No table loaded: create an identity table and apply.
|
||||
let mut table = crate::io::plot_style::PlotStyleTable::identity("Custom.ctb");
|
||||
if let Some(entry) = table.aci_entries.get_mut(aci) {
|
||||
let color_str = self.ps_color_buf.trim();
|
||||
if color_str.starts_with('#') && color_str.len() == 7 {
|
||||
let r = u8::from_str_radix(&color_str[1..3], 16).unwrap_or(0);
|
||||
let g = u8::from_str_radix(&color_str[3..5], 16).unwrap_or(0);
|
||||
let b = u8::from_str_radix(&color_str[5..7], 16).unwrap_or(0);
|
||||
entry.color = Some([r, g, b]);
|
||||
}
|
||||
if let Ok(lw) = self.ps_lineweight_buf.trim().parse::<u8>() {
|
||||
entry.lineweight = lw;
|
||||
}
|
||||
if let Ok(sc) = self.ps_screening_buf.trim().parse::<u8>() {
|
||||
entry.screening = sc.min(100);
|
||||
}
|
||||
}
|
||||
self.active_plot_style = Some(table);
|
||||
self.command_line
|
||||
.push_output(&format!("Created new CTB table, ACI {aci} updated."));
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
|
||||
pub(super) fn on_plot_style_panel_save(&mut self) -> Task<Message> {
|
||||
if self.active_plot_style.is_none() {
|
||||
self.command_line
|
||||
.push_error("No plot style table loaded. Load or create one first.");
|
||||
return Task::none();
|
||||
}
|
||||
let default_name = self
|
||||
.active_plot_style
|
||||
.as_ref()
|
||||
.map(|t| t.name.clone())
|
||||
.unwrap_or("export.ctb".into());
|
||||
Task::perform(
|
||||
async move {
|
||||
rfd::AsyncFileDialog::new()
|
||||
.set_title("Save Plot Style Table")
|
||||
.set_file_name(&default_name)
|
||||
.add_filter("Plot Style Files", &["ctb", "stb", "CTB", "STB"])
|
||||
.add_filter("All Files", &["*"])
|
||||
.save_file()
|
||||
.await
|
||||
.map(|h| crate::sys::handle_path(&h))
|
||||
},
|
||||
Message::PlotStylePanelSavePath,
|
||||
)
|
||||
}
|
||||
}
|
||||
3198
src/app/update/mod.rs
Normal file
3198
src/app/update/mod.rs
Normal file
File diff suppressed because it is too large
Load diff
1029
src/app/update/style.rs
Normal file
1029
src/app/update/style.rs
Normal file
File diff suppressed because it is too large
Load diff
71
src/app/update/util.rs
Normal file
71
src/app/update/util.rs
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
//! Small pure helpers split out of `update.rs`.
|
||||
|
||||
use crate::scene::Scene;
|
||||
|
||||
/// Parse a scale string like "1:50" or "2:1" into (numerator, denominator).
|
||||
/// Returns (1.0, 1.0) for "Fit" or unknown formats.
|
||||
/// Sync the model-space annotation scale into the standard CANNOSCALE /
|
||||
/// CANNOSCALEVALUE header variables before a save, so the scale round-trips
|
||||
/// through the file (and is read correctly by other CAD applications).
|
||||
pub(super) fn sync_annotation_scale_header(scene: &mut Scene) {
|
||||
let anno = scene.annotation_scale;
|
||||
let value = if anno.abs() > 1e-9 {
|
||||
1.0 / anno as f64
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
// Prefer the name of a matching scale already in the drawing's list;
|
||||
// fall back to a formatted ratio when none matches.
|
||||
let name = scene
|
||||
.scale_list()
|
||||
.into_iter()
|
||||
.find(|(_, a, _)| (a - anno).abs() < 0.001 * anno.max(0.001))
|
||||
.map(|(n, _, _)| n)
|
||||
.unwrap_or_else(|| format_annotation_scale_name(anno));
|
||||
let hdr = &mut scene.document.header;
|
||||
hdr.current_annotation_scale = name;
|
||||
hdr.annotation_scale_value = value;
|
||||
}
|
||||
|
||||
/// Format an annotation-scale multiplier as a ratio name: 50.0 -> "1:50",
|
||||
/// 0.5 -> "2:1", 1.0 -> "1:1".
|
||||
fn format_annotation_scale_name(anno: f32) -> String {
|
||||
if anno >= 1.0 {
|
||||
format!("1:{}", anno.round() as i64)
|
||||
} else if anno > 0.0 {
|
||||
format!("{}:1", (1.0 / anno).round() as i64)
|
||||
} else {
|
||||
"1:1".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) 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)
|
||||
}
|
||||
|
||||
/// Convert an internal `[r,g,b,a]` colour (0.0–1.0) to a persisted 0–255 RGB
|
||||
/// triplet, dropping alpha (backgrounds are always opaque).
|
||||
pub(super) fn f4_to_u3([r, g, b, _]: [f32; 4]) -> [u8; 3] {
|
||||
[
|
||||
(r * 255.0).round().clamp(0.0, 255.0) as u8,
|
||||
(g * 255.0).round().clamp(0.0, 255.0) as u8,
|
||||
(b * 255.0).round().clamp(0.0, 255.0) as u8,
|
||||
]
|
||||
}
|
||||
|
||||
/// Inverse of [`f4_to_u3`]: a persisted 0–255 RGB triplet back to an opaque
|
||||
/// `[r,g,b,a]` colour.
|
||||
pub(super) fn u3_to_f4([r, g, b]: [u8; 3]) -> [f32; 4] {
|
||||
[r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0, 1.0]
|
||||
}
|
||||
|
||||
2660
src/app/update/viewport.rs
Normal file
2660
src/app/update/viewport.rs
Normal file
File diff suppressed because it is too large
Load diff
4686
src/app/view.rs
4686
src/app/view.rs
File diff suppressed because it is too large
Load diff
191
src/app/view/controls.rs
Normal file
191
src/app/view/controls.rs
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
use super::*;
|
||||
use super::super::document::{DynComponent, DynFieldEntry};
|
||||
use super::super::Message;
|
||||
use iced::widget::{
|
||||
button, container, row,
|
||||
};
|
||||
use iced::{Background, Border, Color, Element, Theme};
|
||||
|
||||
pub(super) fn viewport_controls<'a>(
|
||||
render_mode: acadrust::entities::ViewportRenderMode,
|
||||
show_grid: bool,
|
||||
snap_on: bool,
|
||||
include_split: bool,
|
||||
tile_count: usize,
|
||||
) -> Element<'a, Message> {
|
||||
use acadrust::entities::ViewportRenderMode as M;
|
||||
let render_modes: Vec<RenderModeChoice> = vec![
|
||||
RenderModeChoice(M::Wireframe2D),
|
||||
RenderModeChoice(M::Wireframe3D),
|
||||
RenderModeChoice(M::HiddenLine),
|
||||
RenderModeChoice(M::FlatShaded),
|
||||
RenderModeChoice(M::GouraudShaded),
|
||||
RenderModeChoice(M::FlatShadedWithEdges),
|
||||
RenderModeChoice(M::GouraudShadedWithEdges),
|
||||
];
|
||||
let light = Color { r: 0.85, g: 0.85, b: 0.85, a: 1.0 };
|
||||
let accent = Color { r: 0.45, g: 0.70, b: 1.0, a: 1.0 };
|
||||
|
||||
// Borderless icon button; an `active` toggle gets an accent tint + fill.
|
||||
let icon_btn = move |bytes: &'static [u8], active: bool, msg: Message| {
|
||||
let tint = if active { accent } else { light };
|
||||
button(crate::ui::icons::tinted(bytes, 15.0, tint))
|
||||
.on_press(msg)
|
||||
.padding([4, 6])
|
||||
.style(move |_: &Theme, status| iced::widget::button::Style {
|
||||
background: Some(Background::Color(match (active, status) {
|
||||
(_, iced::widget::button::Status::Hovered) => Color {
|
||||
r: 0.25,
|
||||
g: 0.25,
|
||||
b: 0.25,
|
||||
a: 0.9,
|
||||
},
|
||||
(true, _) => Color {
|
||||
r: 0.16,
|
||||
g: 0.22,
|
||||
b: 0.32,
|
||||
a: 0.9,
|
||||
},
|
||||
(false, _) => Color::TRANSPARENT,
|
||||
})),
|
||||
border: Border {
|
||||
radius: 3.0.into(),
|
||||
..Default::default()
|
||||
},
|
||||
text_color: tint,
|
||||
..Default::default()
|
||||
})
|
||||
};
|
||||
|
||||
// Render-mode picker, restyled borderless so the outer chip frames it.
|
||||
let picker = iced::widget::pick_list(
|
||||
render_modes,
|
||||
Some(RenderModeChoice(render_mode)),
|
||||
|c| Message::SetRenderMode(c.0),
|
||||
)
|
||||
.text_size(11)
|
||||
.padding([4, 6])
|
||||
.style(move |_: &Theme, _| iced::widget::pick_list::Style {
|
||||
background: Background::Color(Color::TRANSPARENT),
|
||||
border: Border {
|
||||
radius: 3.0.into(),
|
||||
..Default::default()
|
||||
},
|
||||
text_color: light,
|
||||
placeholder_color: light,
|
||||
handle_color: light,
|
||||
});
|
||||
|
||||
// Thin vertical divider between control groups.
|
||||
let sep = || {
|
||||
container(iced::widget::Space::new().width(1.0).height(16.0)).style(|_: &Theme| {
|
||||
iced::widget::container::Style {
|
||||
background: Some(Background::Color(Color {
|
||||
r: 0.45,
|
||||
g: 0.45,
|
||||
b: 0.45,
|
||||
a: 0.7,
|
||||
})),
|
||||
..Default::default()
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
let mut bar = row![]
|
||||
.spacing(3)
|
||||
.align_y(iced::alignment::Vertical::Center);
|
||||
bar = bar
|
||||
.push(icon_btn(crate::ui::icons::GRID, show_grid, Message::ToggleGrid))
|
||||
.push(sep())
|
||||
.push(icon_btn(crate::ui::icons::SNAP, snap_on, Message::ToggleGridSnap))
|
||||
.push(sep())
|
||||
.push(picker);
|
||||
if include_split {
|
||||
bar = bar
|
||||
.push(sep())
|
||||
.push(icon_btn(crate::ui::icons::SPLIT_V, false, Message::SplitModelViewport(false)))
|
||||
.push(sep())
|
||||
.push(icon_btn(crate::ui::icons::SPLIT_H, false, Message::SplitModelViewport(true)));
|
||||
// Close button: only meaningful with more than one model tile.
|
||||
if tile_count > 1 {
|
||||
bar = bar
|
||||
.push(sep())
|
||||
.push(icon_btn(crate::ui::icons::CLOSE, false, Message::CloseModelViewport));
|
||||
}
|
||||
}
|
||||
|
||||
container(bar)
|
||||
.padding(2)
|
||||
.style(|_: &Theme| iced::widget::container::Style {
|
||||
background: Some(Background::Color(Color {
|
||||
r: 0.10,
|
||||
g: 0.10,
|
||||
b: 0.10,
|
||||
a: 0.75,
|
||||
})),
|
||||
border: Border {
|
||||
color: Color {
|
||||
r: 0.35,
|
||||
g: 0.35,
|
||||
b: 0.35,
|
||||
a: 1.0,
|
||||
},
|
||||
width: 1.0,
|
||||
radius: 4.0.into(),
|
||||
},
|
||||
..Default::default()
|
||||
})
|
||||
.into()
|
||||
}
|
||||
|
||||
// ── Dynamic-input field formatting ─────────────────────────────────────────
|
||||
|
||||
/// Short prefix shown before a dynamic-input box's value.
|
||||
/// The string shown inside a dynamic-input box: the typed buffer when the
|
||||
/// field is locked, otherwise the live value derived from the cursor
|
||||
/// world position (and the base point for polar quantities).
|
||||
pub(super) fn dyn_component_value(
|
||||
f: &DynFieldEntry,
|
||||
w: glam::Vec3,
|
||||
base: Option<glam::Vec3>,
|
||||
xf: &super::super::helpers::UcsXform,
|
||||
) -> String {
|
||||
if let Some(b) = &f.buffer {
|
||||
return b.clone();
|
||||
}
|
||||
let b = base.unwrap_or(glam::Vec3::ZERO);
|
||||
// Relative deltas and the polar angle read in the active UCS plane. The
|
||||
// delta is offset-invariant, so only the axis rotation matters (identity
|
||||
// xf reproduces the world-frame deltas).
|
||||
let d = xf.vec_to_ucs(w - b);
|
||||
let dx = d.x as f64;
|
||||
let dy = d.y as f64;
|
||||
// When a base point exists (DYN-on after the first pick) the cartesian
|
||||
// fields show relative deltas — matching the typed-value convention
|
||||
// in `dyn_resolve_point` so the live preview and the committed
|
||||
// coordinate use the same frame. See #35.
|
||||
let has_base = base.is_some();
|
||||
// Width / Height read as unsigned magnitudes (the sign is taken from the
|
||||
// cursor side on commit), matching the rectangle's two-edge entry.
|
||||
let wh = matches!(f.role, crate::command::DynRole::Width | crate::command::DynRole::Height);
|
||||
match f.component {
|
||||
DynComponent::X if has_base => format!("{:.4}", if wh { dx.abs() } else { dx }),
|
||||
DynComponent::Y if has_base => format!("{:.4}", if wh { dy.abs() } else { dy }),
|
||||
DynComponent::Z if has_base => "0.0000".to_string(),
|
||||
DynComponent::X => format!("{:.4}", w.x),
|
||||
DynComponent::Y => format!("{:.4}", w.y),
|
||||
DynComponent::Z => format!("{:.4}", b.z),
|
||||
// Scaled by the role so a diameter box reads twice the radius.
|
||||
DynComponent::Distance => {
|
||||
format!("{:.4}", (dx * dx + dy * dy).sqrt() * f.role.value_scale() as f64)
|
||||
}
|
||||
// Shared rule: unsigned magnitude of the short angle, so CW (below the
|
||||
// reference axis) reads positive (e.g. 30°, not -30°/330°). The
|
||||
// committed value stays signed (see dyn_resolve_point).
|
||||
DynComponent::Angle => {
|
||||
format!("{:.1}", crate::command::dyn_display_angle_deg(dy.atan2(dx) as f32))
|
||||
}
|
||||
// Typed-only scalar — no geometric value to track when empty.
|
||||
DynComponent::Scalar => String::new(),
|
||||
}
|
||||
}
|
||||
2091
src/app/view/mod.rs
Normal file
2091
src/app/view/mod.rs
Normal file
File diff suppressed because it is too large
Load diff
985
src/app/view/modal.rs
Normal file
985
src/app/view/modal.rs
Normal file
|
|
@ -0,0 +1,985 @@
|
|||
use super::super::{Message, OpenCADStudio};
|
||||
use iced::widget::{
|
||||
button, column, container, mouse_area, pick_list, row, text, text_input,
|
||||
Space,
|
||||
};
|
||||
use iced::{Background, Border, Color, Element, Fill, Theme};
|
||||
|
||||
impl OpenCADStudio {
|
||||
/// Build the currently-open modal dialog's content (Plan B), or `None`.
|
||||
/// Each former pop-up window is constructed here and given a bounded size
|
||||
/// (About shrinks to its content). Rendered as an overlay by `view_main`.
|
||||
pub(super) fn modal_content(&self) -> Option<Element<'_, Message>> {
|
||||
fn sized<'a>(e: Element<'a, Message>, w: u16, h: u16) -> Element<'a, Message> {
|
||||
iced::widget::container(e)
|
||||
.width(iced::Length::Fixed(w as f32))
|
||||
.height(iced::Length::Fixed(h as f32))
|
||||
.into()
|
||||
}
|
||||
Some(match self.active_modal? {
|
||||
super::super::ModalKind::About => crate::ui::about::view_window(),
|
||||
super::super::ModalKind::Shortcuts => {
|
||||
sized(crate::ui::shortcuts::view_window(&self.shortcut_overrides), 720, 520)
|
||||
}
|
||||
super::super::ModalKind::PluginManager => sized(
|
||||
crate::ui::plugin_manager::view_window(
|
||||
&self.disabled_plugins,
|
||||
&self.external_plugins,
|
||||
&self.loaded_plugin_ids,
|
||||
crate::ui::plugin_manager::MarketView {
|
||||
registry: &self.plugin_registry,
|
||||
input: &self.plugin_repo_input,
|
||||
repos: &self.plugin_repos,
|
||||
release_tags: &self.repo_release_tags,
|
||||
selected_tag: &self.repo_selected_tag,
|
||||
status: &self.marketplace_status,
|
||||
},
|
||||
),
|
||||
520,
|
||||
460,
|
||||
),
|
||||
super::super::ModalKind::UpdateNotice => {
|
||||
let latest = self.update_notice_version.as_deref().unwrap_or("?");
|
||||
let body = self.update_notice_body.as_deref().unwrap_or("");
|
||||
sized(crate::ui::update_notice::view_window(latest, body), 560, 460)
|
||||
}
|
||||
super::super::ModalKind::Layers => {
|
||||
let tab = &self.tabs[self.active_tab];
|
||||
sized(tab.layers.view_window(), 900, 360)
|
||||
}
|
||||
super::super::ModalKind::PageSetup => sized(
|
||||
crate::ui::page_setup::view_window(
|
||||
&self.page_setup_w,
|
||||
&self.page_setup_h,
|
||||
&self.page_setup_plot_area,
|
||||
self.page_setup_center,
|
||||
&self.page_setup_offset_x,
|
||||
&self.page_setup_offset_y,
|
||||
&self.page_setup_rotation,
|
||||
&self.page_setup_scale,
|
||||
),
|
||||
520,
|
||||
460,
|
||||
),
|
||||
super::super::ModalKind::LayoutManager => {
|
||||
let i = self.active_tab;
|
||||
let layouts = self.tabs[i].scene.layout_names();
|
||||
let current = self.tabs[i].scene.current_layout.clone();
|
||||
sized(
|
||||
crate::ui::layout_manager::view_window(
|
||||
layouts,
|
||||
&self.layout_manager_selected,
|
||||
&self.layout_manager_rename_buf,
|
||||
current,
|
||||
),
|
||||
640,
|
||||
320,
|
||||
)
|
||||
}
|
||||
super::super::ModalKind::Plotstyle => sized(
|
||||
crate::ui::plotstyle::view_window(
|
||||
self.active_plot_style.as_ref(),
|
||||
self.plotstyle_panel_aci,
|
||||
&self.ps_color_buf,
|
||||
&self.ps_lineweight_buf,
|
||||
&self.ps_screening_buf,
|
||||
),
|
||||
780,
|
||||
540,
|
||||
),
|
||||
super::super::ModalKind::TextStyle => {
|
||||
let tab = &self.tabs[self.active_tab];
|
||||
let styles: Vec<String> = tab
|
||||
.scene
|
||||
.document
|
||||
.text_styles
|
||||
.iter()
|
||||
.map(|s| s.name.clone())
|
||||
.collect();
|
||||
let (backward, upside_down, annotative) = tab
|
||||
.scene
|
||||
.document
|
||||
.text_styles
|
||||
.get(&self.textstyle_selected)
|
||||
.map(|s| (s.flags.backward, s.flags.upside_down, s.annotative))
|
||||
.unwrap_or((false, false, false));
|
||||
sized(
|
||||
crate::ui::textstyle::view_window(crate::ui::textstyle::TextStyleView {
|
||||
styles,
|
||||
selected: &self.textstyle_selected,
|
||||
current: &tab.scene.document.header.current_text_style_name,
|
||||
font_buf: &self.textstyle_font,
|
||||
width_buf: &self.textstyle_width,
|
||||
oblique_buf: &self.textstyle_oblique,
|
||||
height_buf: &self.textstyle_height,
|
||||
bigfont_buf: &self.textstyle_bigfont,
|
||||
ttf_buf: &self.textstyle_ttf,
|
||||
backward,
|
||||
upside_down,
|
||||
annotative,
|
||||
rename_active: self.style_rename.as_deref(),
|
||||
rename_buf: &self.style_rename_buf,
|
||||
}),
|
||||
// Wider than the old 620 window: the TTF system-font panel
|
||||
// (Plan B / web fonts) added a column.
|
||||
860,
|
||||
480,
|
||||
)
|
||||
}
|
||||
super::super::ModalKind::MlStyle => {
|
||||
use acadrust::objects::ObjectType;
|
||||
let tab = &self.tabs[self.active_tab];
|
||||
let styles: Vec<String> = tab
|
||||
.scene
|
||||
.document
|
||||
.objects
|
||||
.values()
|
||||
.filter_map(|o| match o {
|
||||
ObjectType::MLineStyle(s) => Some(s.name.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
let selected_style = tab.scene.document.objects.values().find_map(|o| match o {
|
||||
ObjectType::MLineStyle(s) if s.name == self.mlstyle_selected => Some(s),
|
||||
_ => None,
|
||||
});
|
||||
sized(
|
||||
crate::ui::mlstyle::view_window(
|
||||
styles,
|
||||
&self.mlstyle_selected,
|
||||
selected_style,
|
||||
tab.scene.document.header.multiline_style.clone(),
|
||||
self.style_rename.as_deref(),
|
||||
&self.style_rename_buf,
|
||||
),
|
||||
620,
|
||||
420,
|
||||
)
|
||||
}
|
||||
super::super::ModalKind::TableStyle => {
|
||||
use acadrust::objects::ObjectType;
|
||||
let tab = &self.tabs[self.active_tab];
|
||||
let styles: Vec<String> = tab
|
||||
.scene
|
||||
.document
|
||||
.objects
|
||||
.values()
|
||||
.filter_map(|o| match o {
|
||||
ObjectType::TableStyle(s) => Some(s.name.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
let selected_style = tab.scene.document.objects.values().find_map(|o| match o {
|
||||
ObjectType::TableStyle(s) if s.name == self.tablestyle_selected => Some(s),
|
||||
_ => None,
|
||||
});
|
||||
sized(
|
||||
crate::ui::tablestyle::view_window(
|
||||
styles,
|
||||
&self.tablestyle_selected,
|
||||
&self.ribbon.active_table_style,
|
||||
selected_style,
|
||||
&self.ts_hmargin,
|
||||
&self.ts_vmargin,
|
||||
&self.ts_description,
|
||||
&self.ts_cell_textstyle,
|
||||
&self.ts_cell_height,
|
||||
&self.ts_cell_textcolor,
|
||||
&self.ts_cell_fillcolor,
|
||||
&self.ts_cell_datatype,
|
||||
&self.ts_cell_unittype,
|
||||
&self.ts_cell_format,
|
||||
&self.ts_border_lw,
|
||||
&self.ts_border_color,
|
||||
&self.ts_border_spacing,
|
||||
self.style_rename.as_deref(),
|
||||
&self.style_rename_buf,
|
||||
self.ts_color_open,
|
||||
),
|
||||
620,
|
||||
420,
|
||||
)
|
||||
}
|
||||
super::super::ModalKind::MLeaderStyle => {
|
||||
use acadrust::objects::ObjectType;
|
||||
let tab = &self.tabs[self.active_tab];
|
||||
let styles: Vec<String> = tab
|
||||
.scene
|
||||
.document
|
||||
.objects
|
||||
.values()
|
||||
.filter_map(|o| match o {
|
||||
ObjectType::MultiLeaderStyle(s) => Some(s.name.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
let selected_style = tab.scene.document.objects.values().find_map(|o| match o {
|
||||
ObjectType::MultiLeaderStyle(s) if s.name == self.mleaderstyle_selected => {
|
||||
Some(s)
|
||||
}
|
||||
_ => None,
|
||||
});
|
||||
let doc = &tab.scene.document;
|
||||
let mut block_opts: Vec<String> = vec!["None".to_string()];
|
||||
block_opts.extend(doc.block_records.iter().map(|b| b.name.clone()));
|
||||
let mut lt_opts: Vec<String> = vec!["None".to_string()];
|
||||
lt_opts.extend(doc.line_types.iter().map(|lt| lt.name.clone()));
|
||||
let mut textstyle_opts: Vec<String> = vec!["None".to_string()];
|
||||
textstyle_opts.extend(doc.text_styles.iter().map(|t| t.name.clone()));
|
||||
let opt_block = |h: Option<acadrust::types::Handle>| -> String {
|
||||
match h {
|
||||
Some(h) => doc
|
||||
.block_records
|
||||
.iter()
|
||||
.find(|b| b.handle == h)
|
||||
.map(|b| b.name.clone())
|
||||
.unwrap_or_else(|| "None".to_string()),
|
||||
None => "None".to_string(),
|
||||
}
|
||||
};
|
||||
let opt_lt = |h: Option<acadrust::types::Handle>| -> String {
|
||||
match h {
|
||||
Some(h) => doc
|
||||
.line_types
|
||||
.iter()
|
||||
.find(|lt| lt.handle == h)
|
||||
.map(|lt| lt.name.clone())
|
||||
.unwrap_or_else(|| "None".to_string()),
|
||||
None => "None".to_string(),
|
||||
}
|
||||
};
|
||||
let opt_ts = |h: Option<acadrust::types::Handle>| -> String {
|
||||
match h {
|
||||
Some(h) => doc
|
||||
.text_styles
|
||||
.iter()
|
||||
.find(|t| t.handle == h)
|
||||
.map(|t| t.name.clone())
|
||||
.unwrap_or_else(|| "None".to_string()),
|
||||
None => "None".to_string(),
|
||||
}
|
||||
};
|
||||
let (line_type_name, arrowhead_name, text_style_name, block_content_name) =
|
||||
match selected_style {
|
||||
Some(s) => (
|
||||
opt_lt(s.line_type_handle),
|
||||
opt_block(s.arrowhead_handle),
|
||||
opt_ts(s.text_style_handle),
|
||||
opt_block(s.block_content_handle),
|
||||
),
|
||||
None => Default::default(),
|
||||
};
|
||||
sized(
|
||||
crate::ui::mleaderstyle::view_window(crate::ui::mleaderstyle::MLeaderStyleView {
|
||||
styles,
|
||||
selected: &self.mleaderstyle_selected,
|
||||
style: selected_style,
|
||||
current: tab.active_mleader_style.clone(),
|
||||
landing_distance: &self.mls_landing_distance,
|
||||
landing_gap: &self.mls_landing_gap,
|
||||
arrowhead_size: &self.mls_arrowhead_size,
|
||||
text_height: &self.mls_text_height,
|
||||
scale_factor: &self.mls_scale_factor,
|
||||
break_gap: &self.mls_break_gap,
|
||||
first_seg_angle: &self.mls_first_seg_angle,
|
||||
second_seg_angle: &self.mls_second_seg_angle,
|
||||
max_points: &self.mls_max_points,
|
||||
default_text: &self.mls_default_text,
|
||||
line_color: &self.mls_line_color,
|
||||
text_color: &self.mls_text_color,
|
||||
description: &self.mls_description,
|
||||
line_weight: &self.mls_line_weight,
|
||||
align_space: &self.mls_align_space,
|
||||
block_color: &self.mls_block_color,
|
||||
block_rotation: &self.mls_block_rotation,
|
||||
block_scale_x: &self.mls_block_scale_x,
|
||||
block_scale_y: &self.mls_block_scale_y,
|
||||
block_scale_z: &self.mls_block_scale_z,
|
||||
block_opts,
|
||||
lt_opts,
|
||||
textstyle_opts,
|
||||
line_type_name,
|
||||
arrowhead_name,
|
||||
text_style_name,
|
||||
block_content_name,
|
||||
rename_active: self.style_rename.as_deref(),
|
||||
rename_buf: &self.style_rename_buf,
|
||||
color_open: self.mls_color_open,
|
||||
}),
|
||||
560,
|
||||
560,
|
||||
)
|
||||
}
|
||||
super::super::ModalKind::DimStyle => {
|
||||
|
||||
let tab = &self.tabs[self.active_tab];
|
||||
let styles: Vec<String> = tab
|
||||
.scene
|
||||
.document
|
||||
.dim_styles
|
||||
.iter()
|
||||
.map(|s| s.name.clone())
|
||||
.collect();
|
||||
let doc = &tab.scene.document;
|
||||
// Dropdown options (names must match the records exactly so the
|
||||
// selection can be resolved back to a handle on the update side).
|
||||
let mut block_opts: Vec<String> = vec!["Default".to_string()];
|
||||
block_opts.extend(doc.block_records.iter().map(|b| b.name.clone()));
|
||||
let mut lt_opts: Vec<String> = vec!["ByBlock".to_string()];
|
||||
lt_opts.extend(doc.line_types.iter().map(|lt| lt.name.clone()));
|
||||
let blk_name = |h: acadrust::types::Handle| -> String {
|
||||
if h.is_null() {
|
||||
"Default".to_string()
|
||||
} else {
|
||||
doc.block_records
|
||||
.iter()
|
||||
.find(|b| b.handle == h)
|
||||
.map(|b| b.name.clone())
|
||||
.unwrap_or_else(|| "Default".to_string())
|
||||
}
|
||||
};
|
||||
let lt_name = |h: acadrust::types::Handle| -> String {
|
||||
if h.is_null() {
|
||||
"ByBlock".to_string()
|
||||
} else {
|
||||
doc.line_types
|
||||
.iter()
|
||||
.find(|lt| lt.handle == h)
|
||||
.map(|lt| lt.name.clone())
|
||||
.unwrap_or_else(|| "ByBlock".to_string())
|
||||
}
|
||||
};
|
||||
let ds_sel = doc.dim_styles.get(&self.dimstyle_selected);
|
||||
let (
|
||||
dimblk_name,
|
||||
dimblk1_name,
|
||||
dimblk2_name,
|
||||
dimldrblk_name,
|
||||
dimltex_name,
|
||||
dimltex1_name,
|
||||
dimltex2_name,
|
||||
) = match ds_sel {
|
||||
Some(d) => (
|
||||
blk_name(d.dimblk),
|
||||
blk_name(d.dimblk1),
|
||||
blk_name(d.dimblk2),
|
||||
blk_name(d.dimldrblk),
|
||||
lt_name(d.dimltex_handle),
|
||||
lt_name(d.dimltex1_handle),
|
||||
lt_name(d.dimltex2_handle),
|
||||
),
|
||||
None => Default::default(),
|
||||
};
|
||||
sized(crate::ui::dimstyle::view_window(
|
||||
styles,
|
||||
&self.dimstyle_selected,
|
||||
&self.tabs[self.active_tab]
|
||||
.scene
|
||||
.document
|
||||
.header
|
||||
.current_dimstyle_name,
|
||||
self.dimstyle_tab,
|
||||
crate::ui::dimstyle::DimStyleValues {
|
||||
dimdle: &self.ds_dimdle,
|
||||
dimdli: &self.ds_dimdli,
|
||||
dimgap: &self.ds_dimgap,
|
||||
dimexe: &self.ds_dimexe,
|
||||
dimexo: &self.ds_dimexo,
|
||||
dimsd1: self.ds_dimsd1,
|
||||
dimsd2: self.ds_dimsd2,
|
||||
dimse1: self.ds_dimse1,
|
||||
dimse2: self.ds_dimse2,
|
||||
dimasz: &self.ds_dimasz,
|
||||
dimcen: &self.ds_dimcen,
|
||||
dimtsz: &self.ds_dimtsz,
|
||||
dimtxt: &self.ds_dimtxt,
|
||||
dimtxsty: &self.ds_dimtxsty,
|
||||
dimtad: &self.ds_dimtad,
|
||||
dimtih: self.ds_dimtih,
|
||||
dimtoh: self.ds_dimtoh,
|
||||
dimscale: &self.ds_dimscale,
|
||||
dimlfac: &self.ds_dimlfac,
|
||||
dimlunit: &self.ds_dimlunit,
|
||||
dimdec: &self.ds_dimdec,
|
||||
dimpost: &self.ds_dimpost,
|
||||
dimtol: self.ds_dimtol,
|
||||
dimlim: self.ds_dimlim,
|
||||
dimtp: &self.ds_dimtp,
|
||||
dimtm: &self.ds_dimtm,
|
||||
dimtdec: &self.ds_dimtdec,
|
||||
dimtfac: &self.ds_dimtfac,
|
||||
annotative: self.ds_annotative,
|
||||
dimclrd: &self.ds_dimclrd,
|
||||
dimlwd: &self.ds_dimlwd,
|
||||
dimclre: &self.ds_dimclre,
|
||||
dimlwe: &self.ds_dimlwe,
|
||||
dimfxl: &self.ds_dimfxl,
|
||||
dimfxlon: self.ds_dimfxlon,
|
||||
dimsah: self.ds_dimsah,
|
||||
dimarcsym: &self.ds_dimarcsym,
|
||||
dimjogang: &self.ds_dimjogang,
|
||||
dimclrt: &self.ds_dimclrt,
|
||||
dimjust: &self.ds_dimjust,
|
||||
dimtvp: &self.ds_dimtvp,
|
||||
dimtfill: &self.ds_dimtfill,
|
||||
dimtfillclr: &self.ds_dimtfillclr,
|
||||
dimtxtdirection: self.ds_dimtxtdirection,
|
||||
dimatfit: &self.ds_dimatfit,
|
||||
dimtix: self.ds_dimtix,
|
||||
dimsoxd: self.ds_dimsoxd,
|
||||
dimtmove: &self.ds_dimtmove,
|
||||
dimupt: self.ds_dimupt,
|
||||
dimtofl: self.ds_dimtofl,
|
||||
dimfit: &self.ds_dimfit,
|
||||
dimdsep: &self.ds_dimdsep,
|
||||
dimrnd: &self.ds_dimrnd,
|
||||
dimzin: &self.ds_dimzin,
|
||||
dimfrac: &self.ds_dimfrac,
|
||||
dimaunit: &self.ds_dimaunit,
|
||||
dimadec: &self.ds_dimadec,
|
||||
dimunit: &self.ds_dimunit,
|
||||
dimazin: &self.ds_dimazin,
|
||||
dimalt: self.ds_dimalt,
|
||||
dimaltf: &self.ds_dimaltf,
|
||||
dimaltd: &self.ds_dimaltd,
|
||||
dimaltu: &self.ds_dimaltu,
|
||||
dimalttd: &self.ds_dimalttd,
|
||||
dimaltrnd: &self.ds_dimaltrnd,
|
||||
dimapost: &self.ds_dimapost,
|
||||
dimaltz: &self.ds_dimaltz,
|
||||
dimalttz: &self.ds_dimalttz,
|
||||
dimtolj: &self.ds_dimtolj,
|
||||
dimtzin: &self.ds_dimtzin,
|
||||
dimblk_name,
|
||||
dimblk1_name,
|
||||
dimblk2_name,
|
||||
dimldrblk_name,
|
||||
dimltex_name,
|
||||
dimltex1_name,
|
||||
dimltex2_name,
|
||||
block_opts,
|
||||
lt_opts,
|
||||
color_open: self.ds_color_open.clone(),
|
||||
},
|
||||
self.style_rename.as_deref(),
|
||||
&self.style_rename_buf,
|
||||
), 720, 560)
|
||||
}
|
||||
super::super::ModalKind::AssocPrompt => sized(default_assoc_dialog_window(), 440, 210),
|
||||
super::super::ModalKind::Unsaved => {
|
||||
let tab_name = match &self.pending_close {
|
||||
Some(super::super::PendingClose::Tab(idx)) => self
|
||||
.tabs
|
||||
.get(*idx)
|
||||
.map(|t| t.tab_display_name())
|
||||
.unwrap_or_default(),
|
||||
Some(super::super::PendingClose::Quit) => self
|
||||
.tabs
|
||||
.iter()
|
||||
.find(|t| t.dirty)
|
||||
.map(|t| t.tab_display_name())
|
||||
.unwrap_or_default(),
|
||||
None => String::new(),
|
||||
};
|
||||
sized(unsaved_changes_dialog_window(&tab_name), 420, 160)
|
||||
}
|
||||
super::super::ModalKind::PointStyle => sized(
|
||||
crate::ui::point_style::view_window(
|
||||
self.tabs[self.active_tab].scene.document.header.point_display_mode,
|
||||
self.point_size_relative,
|
||||
&self.point_size_buf,
|
||||
),
|
||||
360,
|
||||
470,
|
||||
),
|
||||
super::super::ModalKind::SaveDialog => sized(
|
||||
save_as_dialog_window(
|
||||
&self.save_dialog_filename,
|
||||
&self.save_dialog_folder,
|
||||
&self.save_dialog_entries,
|
||||
&self.save_dialog_format,
|
||||
),
|
||||
560,
|
||||
480,
|
||||
),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const SAVE_FORMAT_OPTIONS: &[&str] = &[
|
||||
"DWG 2018", "DWG 2013", "DWG 2010", "DWG 2007", "DWG 2004", "DWG 2000", "DWG R14", "DXF 2018",
|
||||
"DXF 2013", "DXF 2010", "DXF 2007", "DXF 2004", "DXF 2000", "DXF R14",
|
||||
];
|
||||
|
||||
fn save_as_dialog_window<'a>(
|
||||
filename: &'a str,
|
||||
folder: &'a std::path::Path,
|
||||
entries: &'a [(String, bool, std::path::PathBuf)],
|
||||
format: &'a str,
|
||||
) -> Element<'a, Message> {
|
||||
const BG: Color = Color {
|
||||
r: 0.15,
|
||||
g: 0.15,
|
||||
b: 0.17,
|
||||
a: 1.0,
|
||||
};
|
||||
const LIST_BG: Color = Color {
|
||||
r: 0.11,
|
||||
g: 0.11,
|
||||
b: 0.13,
|
||||
a: 1.0,
|
||||
};
|
||||
const BORDER: Color = Color {
|
||||
r: 0.32,
|
||||
g: 0.32,
|
||||
b: 0.36,
|
||||
a: 1.0,
|
||||
};
|
||||
const TEXT: Color = Color {
|
||||
r: 0.90,
|
||||
g: 0.90,
|
||||
b: 0.90,
|
||||
a: 1.0,
|
||||
};
|
||||
const DIM: Color = Color {
|
||||
r: 0.58,
|
||||
g: 0.58,
|
||||
b: 0.62,
|
||||
a: 1.0,
|
||||
};
|
||||
const INPUT_BG: Color = Color {
|
||||
r: 0.10,
|
||||
g: 0.10,
|
||||
b: 0.12,
|
||||
a: 1.0,
|
||||
};
|
||||
const BTN_OK: Color = Color {
|
||||
r: 0.20,
|
||||
g: 0.46,
|
||||
b: 0.80,
|
||||
a: 1.0,
|
||||
};
|
||||
const BTN_HOV: Color = Color {
|
||||
r: 0.26,
|
||||
g: 0.55,
|
||||
b: 0.92,
|
||||
a: 1.0,
|
||||
};
|
||||
const BTN_GREY: Color = Color {
|
||||
r: 0.26,
|
||||
g: 0.26,
|
||||
b: 0.29,
|
||||
a: 1.0,
|
||||
};
|
||||
const BTN_GHOV: Color = Color {
|
||||
r: 0.34,
|
||||
g: 0.34,
|
||||
b: 0.38,
|
||||
a: 1.0,
|
||||
};
|
||||
const DIR_COL: Color = Color {
|
||||
r: 0.75,
|
||||
g: 0.85,
|
||||
b: 1.00,
|
||||
a: 1.0,
|
||||
};
|
||||
const FILE_COL: Color = TEXT;
|
||||
const ROW_HOV: Color = Color {
|
||||
r: 0.22,
|
||||
g: 0.24,
|
||||
b: 0.28,
|
||||
a: 1.0,
|
||||
};
|
||||
|
||||
let input_sty =
|
||||
|_: &Theme, _: iced::widget::text_input::Status| iced::widget::text_input::Style {
|
||||
background: Background::Color(INPUT_BG),
|
||||
border: Border {
|
||||
color: BORDER,
|
||||
width: 1.0,
|
||||
radius: 4.0.into(),
|
||||
},
|
||||
icon: TEXT,
|
||||
placeholder: DIM,
|
||||
value: TEXT,
|
||||
selection: Color {
|
||||
r: 0.20,
|
||||
g: 0.46,
|
||||
b: 0.80,
|
||||
a: 0.45,
|
||||
},
|
||||
};
|
||||
|
||||
let btn = |lbl: &'static str, msg: Message, base: Color, hov: Color| {
|
||||
button(text(lbl).size(12).color(TEXT))
|
||||
.on_press(msg)
|
||||
.style(move |_: &Theme, st| button::Style {
|
||||
background: Some(Background::Color(
|
||||
if matches!(st, button::Status::Hovered | button::Status::Pressed) {
|
||||
hov
|
||||
} else {
|
||||
base
|
||||
},
|
||||
)),
|
||||
text_color: TEXT,
|
||||
border: Border {
|
||||
color: BORDER,
|
||||
width: 1.0,
|
||||
radius: 4.0.into(),
|
||||
},
|
||||
..Default::default()
|
||||
})
|
||||
.padding([4, 12])
|
||||
};
|
||||
|
||||
// ── Path bar ─────────────────────────────────────────────────────────
|
||||
let path_str = if crate::io::is_drives_root(folder) {
|
||||
crate::io::drives_root_label().to_string()
|
||||
} else {
|
||||
folder.to_string_lossy().into_owned()
|
||||
};
|
||||
let up_path = crate::io::parent_folder(folder);
|
||||
let path_bar = row![
|
||||
{
|
||||
let up_msg = up_path.map(Message::SaveDialogNavigate);
|
||||
let b = button(crate::ui::icons::tinted(crate::ui::icons::UP, 14.0, TEXT))
|
||||
.style(|_: &Theme, st| button::Style {
|
||||
background: Some(Background::Color(
|
||||
if matches!(st, button::Status::Hovered | button::Status::Pressed) {
|
||||
BTN_GHOV
|
||||
} else {
|
||||
BTN_GREY
|
||||
},
|
||||
)),
|
||||
text_color: TEXT,
|
||||
border: Border {
|
||||
color: BORDER,
|
||||
width: 1.0,
|
||||
radius: 4.0.into(),
|
||||
},
|
||||
..Default::default()
|
||||
})
|
||||
.padding([3, 10]);
|
||||
if let Some(msg) = up_msg {
|
||||
b.on_press(msg)
|
||||
} else {
|
||||
b
|
||||
}
|
||||
},
|
||||
Space::new().width(8),
|
||||
container(text(path_str.clone()).size(12).color(DIM))
|
||||
.style(|_: &Theme| container::Style {
|
||||
background: Some(Background::Color(INPUT_BG)),
|
||||
border: Border {
|
||||
color: BORDER,
|
||||
width: 1.0,
|
||||
radius: 4.0.into()
|
||||
},
|
||||
..Default::default()
|
||||
})
|
||||
.padding([4, 8])
|
||||
.width(Fill),
|
||||
]
|
||||
.align_y(iced::Alignment::Center);
|
||||
|
||||
// ── File list ─────────────────────────────────────────────────────────
|
||||
let file_list: Element<'_, Message> = {
|
||||
let rows: Vec<Element<'_, Message>> = entries
|
||||
.iter()
|
||||
.map(|(name, is_dir, path)| {
|
||||
let icon_bytes = if *is_dir {
|
||||
crate::ui::icons::FOLDER
|
||||
} else {
|
||||
crate::ui::icons::DOC
|
||||
};
|
||||
let color = if *is_dir { DIR_COL } else { FILE_COL };
|
||||
let p = path.clone();
|
||||
let d = *is_dir;
|
||||
mouse_area(
|
||||
container(
|
||||
row![
|
||||
crate::ui::icons::tinted(icon_bytes, 13.0, color),
|
||||
Space::new().width(6),
|
||||
text(crate::ui::text_util::elide(name.as_str(), 48))
|
||||
.size(13)
|
||||
.color(color),
|
||||
]
|
||||
.align_y(iced::Alignment::Center),
|
||||
)
|
||||
.style(|_: &Theme| container::Style {
|
||||
..Default::default()
|
||||
})
|
||||
.padding([3, 8])
|
||||
.width(Fill),
|
||||
)
|
||||
.on_press(Message::SaveDialogEntryClicked(p, d))
|
||||
.into()
|
||||
})
|
||||
.collect();
|
||||
|
||||
container(iced::widget::scrollable(
|
||||
column(rows).spacing(1).width(Fill),
|
||||
))
|
||||
.style(|_: &Theme| container::Style {
|
||||
background: Some(Background::Color(LIST_BG)),
|
||||
border: Border {
|
||||
color: BORDER,
|
||||
width: 1.0,
|
||||
radius: 4.0.into(),
|
||||
},
|
||||
..Default::default()
|
||||
})
|
||||
.width(Fill)
|
||||
.height(Fill)
|
||||
.into()
|
||||
};
|
||||
let _ = ROW_HOV; // used conceptually, suppress warning
|
||||
|
||||
let sel_fmt = SAVE_FORMAT_OPTIONS.iter().copied().find(|&s| s == format);
|
||||
let label = |s: &'static str| text(s).size(11).color(DIM);
|
||||
|
||||
// ── Bottom controls ───────────────────────────────────────────────────
|
||||
let bottom = column![
|
||||
row![
|
||||
label("File name:").width(90),
|
||||
text_input("drawing.dwg", filename)
|
||||
.on_input(Message::SaveDialogFilenameChanged)
|
||||
.style(input_sty)
|
||||
.size(13)
|
||||
.padding([5, 8])
|
||||
.width(Fill),
|
||||
]
|
||||
.align_y(iced::Alignment::Center)
|
||||
.spacing(6),
|
||||
Space::new().height(6),
|
||||
row![
|
||||
label("Format:").width(90),
|
||||
pick_list(SAVE_FORMAT_OPTIONS, sel_fmt, |s: &str| {
|
||||
Message::SaveDialogFormatChanged(s.to_string())
|
||||
})
|
||||
.width(Fill),
|
||||
]
|
||||
.align_y(iced::Alignment::Center)
|
||||
.spacing(6),
|
||||
Space::new().height(12),
|
||||
row![
|
||||
Space::new().width(Fill),
|
||||
btn("Save", Message::SaveDialogConfirm, BTN_OK, BTN_HOV),
|
||||
Space::new().width(8),
|
||||
btn("Cancel", Message::SaveDialogCancel, BTN_GREY, BTN_GHOV),
|
||||
],
|
||||
]
|
||||
.spacing(0);
|
||||
|
||||
container(
|
||||
column![
|
||||
path_bar,
|
||||
Space::new().height(8),
|
||||
file_list,
|
||||
Space::new().height(10),
|
||||
bottom,
|
||||
]
|
||||
.spacing(0),
|
||||
)
|
||||
.style(|_: &Theme| container::Style {
|
||||
background: Some(Background::Color(BG)),
|
||||
..Default::default()
|
||||
})
|
||||
.padding([14, 16])
|
||||
.width(Fill)
|
||||
.height(Fill)
|
||||
.into()
|
||||
}
|
||||
|
||||
fn unsaved_changes_dialog_window(name: &str) -> Element<'static, Message> {
|
||||
const BG: Color = Color {
|
||||
r: 0.18,
|
||||
g: 0.18,
|
||||
b: 0.20,
|
||||
a: 1.0,
|
||||
};
|
||||
const BORDER_COL: Color = Color {
|
||||
r: 0.38,
|
||||
g: 0.38,
|
||||
b: 0.42,
|
||||
a: 1.0,
|
||||
};
|
||||
const TEXT_COL: Color = Color {
|
||||
r: 0.90,
|
||||
g: 0.90,
|
||||
b: 0.90,
|
||||
a: 1.0,
|
||||
};
|
||||
const BTN_SAVE: Color = Color {
|
||||
r: 0.20,
|
||||
g: 0.46,
|
||||
b: 0.80,
|
||||
a: 1.0,
|
||||
};
|
||||
const BTN_HOVER: Color = Color {
|
||||
r: 0.26,
|
||||
g: 0.55,
|
||||
b: 0.92,
|
||||
a: 1.0,
|
||||
};
|
||||
const BTN_DISC: Color = Color {
|
||||
r: 0.28,
|
||||
g: 0.28,
|
||||
b: 0.30,
|
||||
a: 1.0,
|
||||
};
|
||||
const BTN_DHOV: Color = Color {
|
||||
r: 0.36,
|
||||
g: 0.36,
|
||||
b: 0.40,
|
||||
a: 1.0,
|
||||
};
|
||||
|
||||
let body_text = format!("Do you want to save changes to \"{}\"?", name);
|
||||
|
||||
let btn = |label: &'static str, msg: Message, base: Color, hov: Color| {
|
||||
button(text(label).size(13).color(TEXT_COL))
|
||||
.on_press(msg)
|
||||
.style(move |_: &Theme, status| button::Style {
|
||||
background: Some(Background::Color(match status {
|
||||
button::Status::Hovered | button::Status::Pressed => hov,
|
||||
_ => base,
|
||||
})),
|
||||
text_color: TEXT_COL,
|
||||
border: Border {
|
||||
color: BORDER_COL,
|
||||
width: 1.0,
|
||||
radius: 4.0.into(),
|
||||
},
|
||||
shadow: iced::Shadow::default(),
|
||||
snap: false,
|
||||
})
|
||||
.padding([6, 18])
|
||||
};
|
||||
|
||||
container(
|
||||
column![
|
||||
text(body_text).size(13).color(TEXT_COL),
|
||||
iced::widget::Space::new().height(20),
|
||||
row![
|
||||
btn("Save", Message::UnsavedDialogSave, BTN_SAVE, BTN_HOVER),
|
||||
iced::widget::Space::new().width(8),
|
||||
btn("Discard", Message::UnsavedDialogDiscard, BTN_DISC, BTN_DHOV),
|
||||
iced::widget::Space::new().width(8),
|
||||
btn("Cancel", Message::UnsavedDialogCancel, BTN_DISC, BTN_DHOV),
|
||||
],
|
||||
]
|
||||
.spacing(0),
|
||||
)
|
||||
.style(move |_: &Theme| container::Style {
|
||||
background: Some(Background::Color(BG)),
|
||||
..Default::default()
|
||||
})
|
||||
.center(Fill)
|
||||
.padding([24, 28])
|
||||
.into()
|
||||
}
|
||||
|
||||
/// First-launch prompt offering to register Open CAD Studio as the default
|
||||
/// handler for .dwg / .dxf. "Yes" runs the platform association call; "Not now"
|
||||
/// just dismisses. Either answer flips the persisted `default_assoc_prompted`
|
||||
/// flag so the dialog never reappears.
|
||||
fn default_assoc_dialog_window() -> Element<'static, Message> {
|
||||
const BG: Color = Color {
|
||||
r: 0.18,
|
||||
g: 0.18,
|
||||
b: 0.20,
|
||||
a: 1.0,
|
||||
};
|
||||
const BORDER_COL: Color = Color {
|
||||
r: 0.38,
|
||||
g: 0.38,
|
||||
b: 0.42,
|
||||
a: 1.0,
|
||||
};
|
||||
const TEXT_COL: Color = Color {
|
||||
r: 0.90,
|
||||
g: 0.90,
|
||||
b: 0.90,
|
||||
a: 1.0,
|
||||
};
|
||||
const DIM_COL: Color = Color {
|
||||
r: 0.62,
|
||||
g: 0.62,
|
||||
b: 0.66,
|
||||
a: 1.0,
|
||||
};
|
||||
const BTN_YES: Color = Color {
|
||||
r: 0.20,
|
||||
g: 0.46,
|
||||
b: 0.80,
|
||||
a: 1.0,
|
||||
};
|
||||
const BTN_YHOV: Color = Color {
|
||||
r: 0.26,
|
||||
g: 0.55,
|
||||
b: 0.92,
|
||||
a: 1.0,
|
||||
};
|
||||
const BTN_NO: Color = Color {
|
||||
r: 0.28,
|
||||
g: 0.28,
|
||||
b: 0.30,
|
||||
a: 1.0,
|
||||
};
|
||||
const BTN_NHOV: Color = Color {
|
||||
r: 0.36,
|
||||
g: 0.36,
|
||||
b: 0.40,
|
||||
a: 1.0,
|
||||
};
|
||||
|
||||
let btn = |label: &'static str, msg: Message, base: Color, hov: Color| {
|
||||
button(text(label).size(13).color(TEXT_COL))
|
||||
.on_press(msg)
|
||||
.style(move |_: &Theme, status| button::Style {
|
||||
background: Some(Background::Color(match status {
|
||||
button::Status::Hovered | button::Status::Pressed => hov,
|
||||
_ => base,
|
||||
})),
|
||||
text_color: TEXT_COL,
|
||||
border: Border {
|
||||
color: BORDER_COL,
|
||||
width: 1.0,
|
||||
radius: 4.0.into(),
|
||||
},
|
||||
shadow: iced::Shadow::default(),
|
||||
snap: false,
|
||||
})
|
||||
.padding([6, 18])
|
||||
};
|
||||
|
||||
container(
|
||||
column![
|
||||
text("Make Open CAD Studio your default CAD app?")
|
||||
.size(15)
|
||||
.color(TEXT_COL),
|
||||
iced::widget::Space::new().height(10),
|
||||
text("Open .dwg and .dxf drawings in Open CAD Studio by default. You can change this later in your system settings.")
|
||||
.size(12)
|
||||
.color(DIM_COL),
|
||||
iced::widget::Space::new().height(22),
|
||||
row![
|
||||
iced::widget::Space::new().width(Fill),
|
||||
btn("Not now", Message::AssocPromptNo, BTN_NO, BTN_NHOV),
|
||||
iced::widget::Space::new().width(8),
|
||||
btn("Yes, set as default", Message::AssocPromptYes, BTN_YES, BTN_YHOV),
|
||||
]
|
||||
.align_y(iced::Center),
|
||||
]
|
||||
.spacing(0),
|
||||
)
|
||||
.style(move |_: &Theme| container::Style {
|
||||
background: Some(Background::Color(BG)),
|
||||
..Default::default()
|
||||
})
|
||||
.center(Fill)
|
||||
.padding([24, 28])
|
||||
.into()
|
||||
}
|
||||
|
||||
1271
src/app/view/overlay.rs
Normal file
1271
src/app/view/overlay.rs
Normal file
File diff suppressed because it is too large
Load diff
195
src/app/view/viewcube.rs
Normal file
195
src/app/view/viewcube.rs
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
use super::super::Message;
|
||||
use crate::scene::{VIEWCUBE_PX, VIEWCUBE_REGION_PX};
|
||||
use iced::widget::{
|
||||
button, column, container, mouse_area, pick_list, row, stack,
|
||||
Space,
|
||||
};
|
||||
use iced::{Background, Border, Color, Element, Fill, Theme};
|
||||
|
||||
// ── Render-mode picker ──────────────────────────────────────────────────────
|
||||
|
||||
/// Top-left viewport control bar: a single dark chip holding (optionally) the
|
||||
/// horizontal/vertical split buttons, the render-mode picker, and the grid /
|
||||
/// grid-snap toggles. `include_split` is off for paper-space viewports, which
|
||||
/// have no model-tile splitting. Grid / snap reflect the active viewport's
|
||||
/// state and emit `ToggleGrid` / `ToggleGridSnap`.
|
||||
// ── ViewCube navigation controls (home / roll / nudge / UCS) ───────────────
|
||||
|
||||
/// Place `el` at pixel offset (x, y) inside a Fill layer (top-left origin).
|
||||
fn vc_place<'a>(x: f32, y: f32, el: Element<'a, Message>) -> Element<'a, Message> {
|
||||
column![
|
||||
Space::new().height(iced::Length::Fixed(y.max(0.0))),
|
||||
row![Space::new().width(iced::Length::Fixed(x.max(0.0))), el],
|
||||
]
|
||||
.width(Fill)
|
||||
.height(Fill)
|
||||
.into()
|
||||
}
|
||||
|
||||
/// Borderless square icon button used by the ViewCube nav controls.
|
||||
fn vc_btn<'a>(content: Element<'a, Message>, size: f32, msg: Message) -> Element<'a, Message> {
|
||||
button(
|
||||
container(content)
|
||||
.width(iced::Length::Fixed(size))
|
||||
.height(iced::Length::Fixed(size))
|
||||
.center_x(iced::Length::Fixed(size))
|
||||
.center_y(iced::Length::Fixed(size)),
|
||||
)
|
||||
.padding(0)
|
||||
.on_press(msg)
|
||||
.style(|_: &Theme, status| iced::widget::button::Style {
|
||||
background: Some(Background::Color(match status {
|
||||
iced::widget::button::Status::Hovered | iced::widget::button::Status::Pressed => Color {
|
||||
r: 0.45,
|
||||
g: 0.62,
|
||||
b: 0.95,
|
||||
a: 0.30,
|
||||
},
|
||||
_ => Color::TRANSPARENT,
|
||||
})),
|
||||
border: Border {
|
||||
radius: 3.0.into(),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
})
|
||||
.into()
|
||||
}
|
||||
|
||||
/// Overlay of home / roll / nudge controls sized to the whole nav region, so
|
||||
/// the caller can position it exactly like the cube hit area.
|
||||
pub(super) fn viewcube_nav_controls<'a>() -> Element<'a, Message> {
|
||||
use crate::scene::NudgeDir;
|
||||
use crate::ui::icons;
|
||||
let tint = Color {
|
||||
r: 0.86,
|
||||
g: 0.89,
|
||||
b: 0.96,
|
||||
a: 1.0,
|
||||
};
|
||||
let r = VIEWCUBE_REGION_PX;
|
||||
let c = r * 0.5;
|
||||
let cube_half = VIEWCUBE_PX as f32 * 0.36; // VIEWCUBE_PX * VIEWCUBE_SCALE
|
||||
let nr = cube_half + 8.0; // nudge triangle distance from centre
|
||||
const BTN: f32 = 18.0;
|
||||
const TRI: f32 = 13.0;
|
||||
let ctr = |cx: f32, cy: f32, s: f32| (cx - s * 0.5, cy - s * 0.5);
|
||||
|
||||
// Home top-left, roll arrows top-right.
|
||||
let (rax, ray) = (r - 2.0 * BTN - 4.0, 2.0);
|
||||
let (rbx, rby) = (r - BTN - 2.0, 2.0);
|
||||
// Nudge triangles pointing inward at the four cube faces.
|
||||
let (tux, tuy) = ctr(c, c - nr, TRI);
|
||||
let (tdx, tdy) = ctr(c, c + nr, TRI);
|
||||
let (tlx, tly) = ctr(c - nr, c, TRI);
|
||||
let (trx, try_) = ctr(c + nr, c, TRI);
|
||||
|
||||
// Bottom layer: the cube/cardinal hit area covering the whole region. The
|
||||
// control buttons sit ABOVE it in the same stack, so a click on a button is
|
||||
// caught by the button while a click on the cube (or empty space) falls
|
||||
// through to this mouse_area → ViewportClick. Moves keep cursor_pos current.
|
||||
let cube_hit = mouse_area(
|
||||
Space::new()
|
||||
.width(iced::Length::Fixed(r))
|
||||
.height(iced::Length::Fixed(r)),
|
||||
)
|
||||
.on_move(Message::CursorMoved)
|
||||
.on_press(Message::ViewportClick);
|
||||
|
||||
let controls = stack![
|
||||
cube_hit,
|
||||
vc_place(
|
||||
3.0,
|
||||
3.0,
|
||||
vc_btn(icons::home(15.0, tint), BTN, Message::ViewCubeHome)
|
||||
),
|
||||
vc_place(
|
||||
rax,
|
||||
ray,
|
||||
vc_btn(icons::undo(14.0, tint), BTN, Message::ViewCubeRoll(false))
|
||||
),
|
||||
vc_place(
|
||||
rbx,
|
||||
rby,
|
||||
vc_btn(icons::redo(14.0, tint), BTN, Message::ViewCubeRoll(true))
|
||||
),
|
||||
vc_place(
|
||||
tux,
|
||||
tuy,
|
||||
vc_btn(
|
||||
icons::arrow_down(11.0, tint),
|
||||
TRI,
|
||||
Message::ViewCubeNudge(NudgeDir::Up)
|
||||
)
|
||||
),
|
||||
vc_place(
|
||||
tdx,
|
||||
tdy,
|
||||
vc_btn(
|
||||
icons::arrow_up(11.0, tint),
|
||||
TRI,
|
||||
Message::ViewCubeNudge(NudgeDir::Down)
|
||||
)
|
||||
),
|
||||
vc_place(
|
||||
tlx,
|
||||
tly,
|
||||
vc_btn(
|
||||
icons::arrow_right(11.0, tint),
|
||||
TRI,
|
||||
Message::ViewCubeNudge(NudgeDir::Left)
|
||||
)
|
||||
),
|
||||
vc_place(
|
||||
trx,
|
||||
try_,
|
||||
vc_btn(
|
||||
icons::arrow_left(11.0, tint),
|
||||
TRI,
|
||||
Message::ViewCubeNudge(NudgeDir::Right)
|
||||
)
|
||||
),
|
||||
];
|
||||
|
||||
container(controls)
|
||||
.width(iced::Length::Fixed(r))
|
||||
.height(iced::Length::Fixed(r))
|
||||
.into()
|
||||
}
|
||||
|
||||
/// The WCS / named-UCS selector shown under the cube.
|
||||
pub(super) fn viewcube_ucs_picker<'a>(current: String, names: Vec<String>) -> Element<'a, Message> {
|
||||
let light = Color {
|
||||
r: 0.85,
|
||||
g: 0.87,
|
||||
b: 0.93,
|
||||
a: 1.0,
|
||||
};
|
||||
let mut options = vec!["WCS".to_string()];
|
||||
options.extend(names);
|
||||
let selected = if current.is_empty() {
|
||||
"WCS".to_string()
|
||||
} else {
|
||||
current
|
||||
};
|
||||
pick_list(options, Some(selected), Message::SetViewcubeUcs)
|
||||
.text_size(11)
|
||||
.padding([2, 6])
|
||||
.style(move |_: &Theme, _| iced::widget::pick_list::Style {
|
||||
background: Background::Color(Color {
|
||||
r: 0.16,
|
||||
g: 0.17,
|
||||
b: 0.20,
|
||||
a: 0.92,
|
||||
}),
|
||||
border: Border {
|
||||
radius: 3.0.into(),
|
||||
..Default::default()
|
||||
},
|
||||
text_color: light,
|
||||
placeholder_color: light,
|
||||
handle_color: light,
|
||||
})
|
||||
.into()
|
||||
}
|
||||
|
||||
1192
src/scene/entity.rs
Normal file
1192
src/scene/entity.rs
Normal file
File diff suppressed because it is too large
Load diff
98
src/scene/group_layer.rs
Normal file
98
src/scene/group_layer.rs
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
// Auto-split from scene/mod.rs. Pure text-move; behaviour unchanged.
|
||||
use super::*;
|
||||
|
||||
impl Scene {
|
||||
// ── Group helpers ──────────────────────────────────────────────────────
|
||||
|
||||
pub fn groups(&self) -> impl Iterator<Item = &acadrust::objects::Group> {
|
||||
self.document.objects.values().filter_map(|obj| match obj {
|
||||
ObjectType::Group(g) => Some(g),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns the names of all groups that contain `handle`.
|
||||
pub fn group_names_for_entity(&self, handle: Handle) -> Vec<String> {
|
||||
self.groups()
|
||||
.filter(|g| g.contains(handle))
|
||||
.map(|g| g.name.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Creates a named group from the given handles and registers it in the group dictionary.
|
||||
pub fn create_group(&mut self, name: String, handles: Vec<Handle>) -> Handle {
|
||||
let group_dict_handle = self.document.header.acad_group_dict_handle;
|
||||
let mut group = acadrust::objects::Group::new(&name);
|
||||
group.handle = self.document.allocate_handle();
|
||||
group.owner = group_dict_handle;
|
||||
group.add_entities(handles);
|
||||
let gh = group.handle;
|
||||
self.document.objects.insert(gh, ObjectType::Group(group));
|
||||
if let Some(ObjectType::Dictionary(dict)) =
|
||||
self.document.objects.get_mut(&group_dict_handle)
|
||||
{
|
||||
dict.add_entry(&name, gh);
|
||||
}
|
||||
gh
|
||||
}
|
||||
|
||||
/// Dissolves all groups that contain any of the given handles.
|
||||
/// Returns the number of groups removed.
|
||||
pub fn delete_groups_containing(&mut self, handles: &[Handle]) -> usize {
|
||||
let group_dict_handle = self.document.header.acad_group_dict_handle;
|
||||
let to_delete: Vec<Handle> = self
|
||||
.document
|
||||
.objects
|
||||
.values()
|
||||
.filter_map(|obj| match obj {
|
||||
ObjectType::Group(g) if handles.iter().any(|h| g.contains(*h)) => Some(g.handle),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
let count = to_delete.len();
|
||||
for gh in &to_delete {
|
||||
if let Some(ObjectType::Dictionary(dict)) =
|
||||
self.document.objects.get_mut(&group_dict_handle)
|
||||
{
|
||||
dict.entries.retain(|(_, h)| h != gh);
|
||||
}
|
||||
self.document.objects.remove(gh);
|
||||
}
|
||||
count
|
||||
}
|
||||
|
||||
/// If `handle` belongs to any selectable groups, also select all other members of those groups.
|
||||
pub fn expand_selection_for_groups(&mut self, handles: &[Handle]) {
|
||||
let to_add: Vec<Handle> = self
|
||||
.document
|
||||
.objects
|
||||
.values()
|
||||
.filter_map(|obj| match obj {
|
||||
ObjectType::Group(g) if g.selectable && handles.iter().any(|h| g.contains(*h)) => {
|
||||
Some(g.entities.clone())
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.flatten()
|
||||
.collect();
|
||||
for h in to_add {
|
||||
self.selected.insert(h);
|
||||
}
|
||||
self.bump_selection();
|
||||
}
|
||||
|
||||
// ── Layer helpers ──────────────────────────────────────────────────────
|
||||
|
||||
pub fn toggle_layer_visibility(&mut self, name: &str) {
|
||||
if let Some(layer) = self.document.layers.get_mut(name) {
|
||||
layer.flags.off = !layer.flags.off;
|
||||
}
|
||||
self.bump_geometry();
|
||||
}
|
||||
|
||||
pub fn toggle_layer_lock(&mut self, name: &str) {
|
||||
if let Some(layer) = self.document.layers.get_mut(name) {
|
||||
layer.flags.locked = !layer.flags.locked;
|
||||
}
|
||||
}
|
||||
}
|
||||
801
src/scene/hittest.rs
Normal file
801
src/scene/hittest.rs
Normal file
|
|
@ -0,0 +1,801 @@
|
|||
// Auto-split from scene/mod.rs. Pure text-move; behaviour unchanged.
|
||||
use super::*;
|
||||
|
||||
impl Scene {
|
||||
// ── Hit-test convenience: wire name → Handle ──────────────────────────
|
||||
|
||||
pub fn handle_from_wire_name(name: &str) -> Option<Handle> {
|
||||
name.parse::<u64>().ok().map(Handle::new)
|
||||
}
|
||||
|
||||
/// Restore camera to a named view from the document view table.
|
||||
pub fn restore_named_view(&mut self, view: &acadrust::tables::View) {
|
||||
use glam::Vec3;
|
||||
let cam = &mut *self.camera.borrow_mut();
|
||||
// view.target is the look-at point; view.direction is eye→target direction.
|
||||
cam.target = glam::DVec3::new(view.target.x, view.target.y, view.target.z);
|
||||
// direction in acadrust = from-target-to-eye (same as AutoCAD convention).
|
||||
let eye_dir = Vec3::new(
|
||||
view.direction.x as f32,
|
||||
view.direction.y as f32,
|
||||
view.direction.z as f32,
|
||||
);
|
||||
let eye_dir = if eye_dir.length_squared() > 1e-10 {
|
||||
eye_dir.normalize()
|
||||
} else {
|
||||
Vec3::Z
|
||||
};
|
||||
// Build rotation: canonical eye is +Z, rotate to eye_dir.
|
||||
cam.rotation = glam::Quat::from_rotation_arc(Vec3::Z, eye_dir);
|
||||
// Sync yaw/pitch from new rotation (for ViewCube).
|
||||
let pitch = eye_dir.z.clamp(-1.0, 1.0).asin();
|
||||
let yaw = eye_dir.x.atan2(eye_dir.y);
|
||||
cam.yaw = yaw;
|
||||
cam.pitch = pitch;
|
||||
// Derive distance from view height and fov.
|
||||
let h = view.height as f32;
|
||||
cam.distance = if h > 0.0 {
|
||||
h / (2.0 * (cam.fov_y * 0.5).tan())
|
||||
} else {
|
||||
cam.distance
|
||||
};
|
||||
self.camera_generation += 1;
|
||||
}
|
||||
|
||||
/// Save the current camera state into a new named view entry.
|
||||
/// Returns the view; caller must push it into document.views.
|
||||
pub fn current_as_named_view(&self, name: &str) -> acadrust::tables::View {
|
||||
use acadrust::types::Vector3;
|
||||
let cam = self.camera.borrow();
|
||||
let eye_dir = cam.rotation * glam::Vec3::Z;
|
||||
let height = cam.ortho_size() * 2.0;
|
||||
let width = height; // caller can adjust; rough square
|
||||
acadrust::tables::View {
|
||||
handle: acadrust::types::Handle::NULL,
|
||||
name: name.to_string(),
|
||||
center: Vector3 {
|
||||
x: cam.target.x as f64,
|
||||
y: cam.target.y as f64,
|
||||
z: 0.0,
|
||||
},
|
||||
target: Vector3 {
|
||||
x: cam.target.x as f64,
|
||||
y: cam.target.y as f64,
|
||||
z: cam.target.z as f64,
|
||||
},
|
||||
direction: Vector3 {
|
||||
x: eye_dir.x as f64,
|
||||
y: eye_dir.y as f64,
|
||||
z: eye_dir.z as f64,
|
||||
},
|
||||
height: height as f64,
|
||||
width: width as f64,
|
||||
lens_length: 50.0,
|
||||
front_clip: 0.0,
|
||||
back_clip: 0.0,
|
||||
twist_angle: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Zoom the model-space camera in/out by a percentage.
|
||||
/// factor > 1 = zoom out, factor < 1 = zoom in.
|
||||
pub fn zoom_camera(&mut self, factor: f32) {
|
||||
let mut cam = self.camera.borrow_mut();
|
||||
cam.distance = (cam.distance * factor).max(0.001);
|
||||
drop(cam);
|
||||
self.camera_generation += 1;
|
||||
}
|
||||
|
||||
/// Fit the camera to a world-space bounding box (corners p1, p2).
|
||||
pub fn zoom_to_window(&mut self, p1: glam::Vec3, p2: glam::Vec3) {
|
||||
let min = p1.min(p2);
|
||||
let max = p1.max(p2);
|
||||
if min == max {
|
||||
return;
|
||||
}
|
||||
self.camera.borrow_mut().fit_to_bounds(min, max);
|
||||
self.camera_generation += 1;
|
||||
}
|
||||
|
||||
/// Apply camera state from an acadrust View table entry, through the shared
|
||||
/// `camera_from_view` decoder so the twist round-trips like every other
|
||||
/// saved view. `model_space`: if true, subtracts world_offset from target
|
||||
/// (wire-space); paper-space entries carry no offset.
|
||||
fn apply_camera_from_view_entry(
|
||||
&mut self,
|
||||
view: &acadrust::tables::View,
|
||||
model_space: bool,
|
||||
) -> bool {
|
||||
let _ = model_space;
|
||||
let Some(cam) = self.camera_from_view(
|
||||
view.direction,
|
||||
view.target,
|
||||
acadrust::types::Vector2 {
|
||||
x: view.center.x,
|
||||
y: view.center.y,
|
||||
},
|
||||
view.height,
|
||||
view.twist_angle,
|
||||
) else {
|
||||
return false;
|
||||
};
|
||||
*self.camera.borrow_mut() = cam;
|
||||
self.camera_generation += 1;
|
||||
true
|
||||
}
|
||||
|
||||
/// Set the model-space camera from the VPORT table's *Active entry.
|
||||
/// Returns true if the entry was found and the camera was set.
|
||||
fn apply_active_vport_camera(&mut self) -> bool {
|
||||
// Restore the single tile's visual style + grid/snap from the *Active
|
||||
// entry, independent of where the camera itself comes from below.
|
||||
if let Some(vp) = self.document.vports.iter().find(|v| v.name == "*Active") {
|
||||
let mode = vp.render_mode;
|
||||
let (grid_on, snap_on) = (vp.grid_on, vp.snap_on);
|
||||
let mut tiles = self.model_tiles.borrow_mut();
|
||||
let active = self.active_model_tile.get().min(tiles.len().saturating_sub(1));
|
||||
if let Some(t) = tiles.get_mut(active) {
|
||||
t.render_mode = mode;
|
||||
t.grid_on = grid_on;
|
||||
t.snap_on = snap_on;
|
||||
}
|
||||
}
|
||||
// Prefer our named View entry — survives DWG save without being overridden.
|
||||
let saved_view = self
|
||||
.document
|
||||
.views
|
||||
.iter()
|
||||
.find(|v| v.name == "OpenCADStudio_Camera_Model")
|
||||
.cloned();
|
||||
if let Some(view) = saved_view {
|
||||
return self.apply_camera_from_view_entry(&view, true);
|
||||
}
|
||||
let vp = match self.document.vports.iter().find(|v| v.name == "*Active") {
|
||||
Some(v) => v.clone(),
|
||||
None => return false,
|
||||
};
|
||||
let Some(new_cam) = self.camera_from_vport(&vp) else {
|
||||
return false;
|
||||
};
|
||||
*self.camera.borrow_mut() = new_cam;
|
||||
self.camera_generation += 1;
|
||||
true
|
||||
}
|
||||
|
||||
/// Decode a saved view into a `Camera`. This is the single shared decoder
|
||||
/// for both a model-space VPORT table entry (tiled) and a paper-space
|
||||
/// VIEWPORT entity (floating): tiled vs floating only changes *where* the
|
||||
/// fields come from and the floating auto-fit fallback — the projection
|
||||
/// math (view direction → yaw/pitch, twist → roll, view_center fold,
|
||||
/// view_height → distance) is identical, so it lives here once. Callers pass
|
||||
/// their already-effective `view_target` / `view_center` / `view_height`.
|
||||
///
|
||||
/// Returns `None` for a zero `view_height` (an uninitialised entry).
|
||||
pub(super) fn camera_from_view(
|
||||
&self,
|
||||
view_direction: acadrust::types::Vector3,
|
||||
view_target: acadrust::types::Vector3,
|
||||
view_center: acadrust::types::Vector2,
|
||||
view_height: f64,
|
||||
twist: f64,
|
||||
// Subtracted from `view_target` to reach wire-space. Model views pass
|
||||
// `[0.0_f64; 3]`; paper-space views (whose entities carry no
|
||||
// offset) pass `[0; 3]`.
|
||||
) -> Option<Camera> {
|
||||
if view_height.abs() < 1e-9 {
|
||||
return None;
|
||||
}
|
||||
let vd = glam::Vec3::new(
|
||||
view_direction.x as f32,
|
||||
view_direction.y as f32,
|
||||
view_direction.z as f32,
|
||||
)
|
||||
.normalize_or(glam::Vec3::Z);
|
||||
let pitch = vd.z.clamp(-1.0, 1.0).asin();
|
||||
// view_dir = (sin(yaw)*cos(pitch), -cos(yaw)*cos(pitch), sin(pitch))
|
||||
// → yaw = atan2(x, -y), but when looking straight up/down cos(pitch)≈0
|
||||
// both x and y are near zero and atan2(0, -0.0) = π due to IEEE 754.
|
||||
let yaw = if vd.x.abs() < 1e-6 && vd.y.abs() < 1e-6 {
|
||||
0.0_f32 // plan/nadir view: yaw is undefined, default to 0
|
||||
} else {
|
||||
vd.x.atan2(-vd.y)
|
||||
};
|
||||
// The saved view can carry a twist (rotation about the view axis), set
|
||||
// when the view was aligned to a rotated UCS. The twist is the angle
|
||||
// that rotates world-X onto screen-right, so the world direction that
|
||||
// ends up horizontal is its negative; feed that in as the camera roll
|
||||
// so the drawing opens square, the way it was saved, instead of in raw
|
||||
// world orientation (which looks tilted).
|
||||
let rotation = view::camera::yaw_pitch_to_quat(yaw, pitch, -twist as f32);
|
||||
let view_right = rotation * glam::Vec3::X;
|
||||
let view_up = rotation * glam::Vec3::Y;
|
||||
// view_target is WCS; wire-space subtracts world_offset. view_center is
|
||||
// a DCS (screen-plane) offset, so fold it through the view basis.
|
||||
// Keep the target in f64: casting it to f32 first quantizes the camera
|
||||
// to ~0.5 m at UTM scale, so panning/zooming inside a floating viewport
|
||||
// (which nudges view_target by sub-metre f64 steps) made the content
|
||||
// jump on the f32 grid. The axis directions stay f32 (orientation only);
|
||||
// only the position must stay precise — matching the model camera.
|
||||
let base = glam::DVec3::new(
|
||||
view_target.x,
|
||||
view_target.y,
|
||||
view_target.z,
|
||||
);
|
||||
let target = base
|
||||
+ view_right.as_dvec3() * view_center.x
|
||||
+ view_up.as_dvec3() * view_center.y;
|
||||
let fov_y = 45.0_f32.to_radians();
|
||||
let distance = ((view_height as f32 / 2.0) / (fov_y * 0.5).tan()).max(0.001);
|
||||
Some(Camera {
|
||||
target,
|
||||
rotation,
|
||||
distance,
|
||||
fov_y,
|
||||
projection: view::camera::Projection::Orthographic,
|
||||
yaw,
|
||||
pitch,
|
||||
})
|
||||
}
|
||||
|
||||
/// Decode a VPort table entry (model-space tiled view) into a `Camera`.
|
||||
fn camera_from_vport(&self, vp: &acadrust::tables::VPort) -> Option<Camera> {
|
||||
self.camera_from_view(
|
||||
vp.view_direction,
|
||||
vp.view_target,
|
||||
vp.view_center,
|
||||
vp.view_height,
|
||||
vp.view_twist,
|
||||
)
|
||||
}
|
||||
|
||||
/// Reverse of `camera_from_vport`: write `cam`'s view target / direction
|
||||
/// / height onto a fresh VPort entry with the given `name` and screen
|
||||
/// rectangle (0..1 normalized, DXF bottom-left origin convention).
|
||||
fn vport_from_camera(
|
||||
&self,
|
||||
name: &str,
|
||||
cam: &Camera,
|
||||
lower_left: acadrust::types::Vector2,
|
||||
upper_right: acadrust::types::Vector2,
|
||||
) -> acadrust::tables::VPort {
|
||||
let view_dir = cam.rotation * glam::Vec3::Z;
|
||||
let view_height = cam.ortho_size() * 2.0;
|
||||
let target_wcs = acadrust::types::Vector3 {
|
||||
x: (cam.target.x as f64) + [0.0_f64; 3][0],
|
||||
y: (cam.target.y as f64) + [0.0_f64; 3][1],
|
||||
z: (cam.target.z as f64) + [0.0_f64; 3][2],
|
||||
};
|
||||
let mut entry = acadrust::tables::VPort::new(name);
|
||||
entry.lower_left = lower_left;
|
||||
entry.upper_right = upper_right;
|
||||
entry.view_target = target_wcs;
|
||||
entry.view_direction = acadrust::types::Vector3 {
|
||||
x: view_dir.x as f64,
|
||||
y: view_dir.y as f64,
|
||||
z: view_dir.z as f64,
|
||||
};
|
||||
entry.view_height = view_height as f64;
|
||||
entry.view_center = acadrust::types::Vector2::ZERO;
|
||||
// Stored twist = -roll, matching the decoder (roll = -twist).
|
||||
entry.view_twist = -cam.roll() as f64;
|
||||
entry
|
||||
}
|
||||
|
||||
/// Convert a `ModelTile`'s normalized iced rectangle (top-left origin) to
|
||||
/// the (lower_left, upper_right) pair the VPort table uses (bottom-left
|
||||
/// origin).
|
||||
fn tile_rect_to_vport(rect: iced::Rectangle) -> (acadrust::types::Vector2, acadrust::types::Vector2) {
|
||||
let lower_left = acadrust::types::Vector2 {
|
||||
x: rect.x as f64,
|
||||
y: (1.0 - rect.y - rect.height) as f64,
|
||||
};
|
||||
let upper_right = acadrust::types::Vector2 {
|
||||
x: (rect.x + rect.width) as f64,
|
||||
y: (1.0 - rect.y) as f64,
|
||||
};
|
||||
(lower_left, upper_right)
|
||||
}
|
||||
|
||||
/// Inverse of `tile_rect_to_vport`.
|
||||
fn vport_to_tile_rect(lower_left: acadrust::types::Vector2, upper_right: acadrust::types::Vector2) -> iced::Rectangle {
|
||||
iced::Rectangle {
|
||||
x: lower_left.x as f32,
|
||||
y: (1.0 - upper_right.y) as f32,
|
||||
width: (upper_right.x - lower_left.x) as f32,
|
||||
height: (upper_right.y - lower_left.y) as f32,
|
||||
}
|
||||
}
|
||||
|
||||
/// Restore `model_tiles` from VPort entries that a previous save left in
|
||||
/// the document. Native AutoCAD tiled model-space layouts are represented
|
||||
/// by duplicate `*Active` VPort entries.
|
||||
/// Returns true on success — the caller skips `apply_active_vport_camera`
|
||||
/// in that case because the active tile's camera has already been loaded
|
||||
/// into `self.camera`.
|
||||
fn restore_model_tiles_from_vports(&mut self) -> bool {
|
||||
let active_vports: Vec<acadrust::tables::VPort> = self
|
||||
.document
|
||||
.vports
|
||||
.iter()
|
||||
.filter(|v| v.name == "*Active")
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
if active_vports.len() <= 1 {
|
||||
return false;
|
||||
}
|
||||
|
||||
let tiles: Vec<ModelTile> = active_vports
|
||||
.iter()
|
||||
.filter_map(|vp| {
|
||||
self.camera_from_vport(vp).map(|cam| ModelTile {
|
||||
rect: Self::vport_to_tile_rect(vp.lower_left, vp.upper_right),
|
||||
camera: cam,
|
||||
render_mode: vp.render_mode,
|
||||
grid_on: vp.grid_on,
|
||||
snap_on: vp.snap_on,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
if tiles.len() <= 1 {
|
||||
return false;
|
||||
}
|
||||
|
||||
let active_cam = tiles[0].camera.clone();
|
||||
*self.model_tiles.borrow_mut() = tiles;
|
||||
self.active_model_tile.set(0);
|
||||
*self.camera.borrow_mut() = active_cam;
|
||||
self.camera_generation += 1;
|
||||
true
|
||||
}
|
||||
|
||||
/// Persist `model_tiles` to the VPort table. Native AutoCAD tiled model
|
||||
/// viewports are written as duplicate `*Active` entries.
|
||||
fn save_model_tiles_to_vports(&mut self) {
|
||||
// Stash the live camera into the active tile so the about-to-write
|
||||
// snapshot reflects the user's most recent orbit / pan / zoom.
|
||||
{
|
||||
let live_cam = self.camera.borrow().clone();
|
||||
let mut tiles = self.model_tiles.borrow_mut();
|
||||
let active = self.active_model_tile.get().min(tiles.len().saturating_sub(1));
|
||||
if let Some(t) = tiles.get_mut(active) {
|
||||
t.camera = live_cam;
|
||||
}
|
||||
}
|
||||
|
||||
let table_handle = self.document.vports.handle();
|
||||
let preserved_vps: Vec<acadrust::tables::VPort> = self
|
||||
.document
|
||||
.vports
|
||||
.iter()
|
||||
.filter(|v| v.name != "*Active")
|
||||
.cloned()
|
||||
.collect();
|
||||
let mut new_vports = acadrust::tables::Table::with_handle(table_handle);
|
||||
for vp in preserved_vps {
|
||||
new_vports.add_or_replace(vp);
|
||||
}
|
||||
self.document.vports = new_vports;
|
||||
|
||||
let tiles = self.model_tiles.borrow().clone();
|
||||
if tiles.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let active = self.active_model_tile.get().min(tiles.len().saturating_sub(1));
|
||||
let mut ordered_tiles = Vec::with_capacity(tiles.len());
|
||||
ordered_tiles.push(tiles[active].clone());
|
||||
for (i, tile) in tiles.iter().enumerate() {
|
||||
if i != active {
|
||||
ordered_tiles.push(tile.clone());
|
||||
}
|
||||
}
|
||||
|
||||
for tile in ordered_tiles {
|
||||
let (ll, ur) = Self::tile_rect_to_vport(tile.rect);
|
||||
let mut entry = self.vport_from_camera("*Active", &tile.camera, ll, ur);
|
||||
entry.render_mode = tile.render_mode;
|
||||
// Each viewport persists its own grid display + grid-snap (#121).
|
||||
entry.grid_on = tile.grid_on;
|
||||
entry.snap_on = tile.snap_on;
|
||||
entry.handle = self.document.allocate_handle();
|
||||
self.document.vports.add_allow_duplicate(entry);
|
||||
}
|
||||
}
|
||||
|
||||
/// Mirror the live grid/snap toggles onto the active view's own store so the
|
||||
/// state stays independent per viewport: a model tile in model space, the
|
||||
/// layout's sheet viewport in paper space. (#121)
|
||||
pub fn set_active_tile_grid_snap(&mut self, grid_on: bool, snap_on: bool) {
|
||||
if self.current_layout != "Model" {
|
||||
// Paper space: target the active floating viewport if the user is
|
||||
// working inside one, otherwise the layout's sheet viewport. Each
|
||||
// viewport keeps its own grid/snap (round-tripped via status flags).
|
||||
let h = self
|
||||
.active_viewport
|
||||
.filter(|h| h.is_valid())
|
||||
.unwrap_or_else(|| self.current_layout_sheet_viewport_handle());
|
||||
if h.is_valid() {
|
||||
if let Some(EntityType::Viewport(vp)) = self.document.get_entity_mut(h) {
|
||||
vp.status.grid_on = grid_on;
|
||||
vp.status.snap_on = snap_on;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
let mut tiles = self.model_tiles.borrow_mut();
|
||||
let active = self.active_model_tile.get().min(tiles.len().saturating_sub(1));
|
||||
if let Some(t) = tiles.get_mut(active) {
|
||||
t.grid_on = grid_on;
|
||||
t.snap_on = snap_on;
|
||||
}
|
||||
}
|
||||
|
||||
/// The active view's grid display + grid-snap, adopted into the live toggles
|
||||
/// on load and whenever the active viewport / tab / layout changes. Reads the
|
||||
/// model tile in model space, the sheet viewport in paper space. (#121)
|
||||
pub fn active_tile_grid_snap(&self) -> Option<(bool, bool)> {
|
||||
if self.current_layout != "Model" {
|
||||
let h = self
|
||||
.active_viewport
|
||||
.filter(|h| h.is_valid())
|
||||
.unwrap_or_else(|| self.current_layout_sheet_viewport_handle());
|
||||
if h.is_valid() {
|
||||
if let Some(EntityType::Viewport(vp)) = self.document.get_entity(h) {
|
||||
return Some((vp.status.grid_on, vp.status.snap_on));
|
||||
}
|
||||
}
|
||||
return Some((false, false));
|
||||
}
|
||||
let tiles = self.model_tiles.borrow();
|
||||
let active = self.active_model_tile.get().min(tiles.len().saturating_sub(1));
|
||||
tiles.get(active).map(|t| (t.grid_on, t.snap_on))
|
||||
}
|
||||
|
||||
/// Set the paper-space camera from the sheet viewport's stored view.
|
||||
/// Returns true if a valid sheet viewport was found and the camera was set.
|
||||
///
|
||||
/// The sheet viewport entity is the authoritative paper-space view (it
|
||||
/// round-trips through both the DXF and DWG writers). An older
|
||||
/// `OpenCADStudio_Camera_<layout>` named View is honoured only as a
|
||||
/// backward-compatible fallback for files saved under the previous scheme.
|
||||
fn apply_sheet_viewport_camera(&mut self) -> bool {
|
||||
let layout_block = self.current_layout_block_handle();
|
||||
let sheet_vp = if layout_block.is_null() {
|
||||
None
|
||||
} else {
|
||||
self.document
|
||||
.entities()
|
||||
.filter_map(|e| {
|
||||
if let EntityType::Viewport(vp) = e {
|
||||
Some(vp)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.find(|vp| {
|
||||
vp.common.owner_handle == layout_block
|
||||
&& !self.is_content_viewport_in_layout(vp, layout_block)
|
||||
})
|
||||
.cloned()
|
||||
};
|
||||
|
||||
let vp = match sheet_vp {
|
||||
Some(v) if v.view_height.abs() >= 1e-9 => v,
|
||||
_ => {
|
||||
// Back-compat: files OCS saved with the named-View side-channel.
|
||||
let view_name = format!("OpenCADStudio_Camera_{}", self.current_layout);
|
||||
let fallback =
|
||||
self.document.views.iter().find(|v| v.name == view_name).cloned();
|
||||
if let Some(view) = fallback {
|
||||
return self.apply_camera_from_view_entry(&view, false);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// Paper-space entities carry no world_offset → decode with a zero
|
||||
// offset, through the same shared decoder (twist included).
|
||||
let Some(cam) = self.camera_from_view(
|
||||
vp.view_direction,
|
||||
vp.view_target,
|
||||
acadrust::types::Vector2 {
|
||||
x: vp.view_center.x,
|
||||
y: vp.view_center.y,
|
||||
},
|
||||
vp.view_height,
|
||||
vp.twist_angle,
|
||||
) else {
|
||||
return false;
|
||||
};
|
||||
*self.camera.borrow_mut() = cam;
|
||||
self.camera_generation += 1;
|
||||
true
|
||||
}
|
||||
|
||||
/// Write the current camera back into the document (VPort or sheet viewport)
|
||||
/// so it is saved with the file. Returns true if the document was modified.
|
||||
pub fn sync_camera_to_document(&mut self) -> bool {
|
||||
let cam = self.camera.borrow().clone();
|
||||
let view_dir = cam.rotation * glam::Vec3::Z;
|
||||
let view_height = cam.ortho_size() * 2.0;
|
||||
// Stored twist is the negative of the camera roll (the decoder applies
|
||||
// roll = -twist), so the saved view round-trips square.
|
||||
let twist = -cam.roll() as f64;
|
||||
let vd3 = acadrust::types::Vector3 {
|
||||
x: view_dir.x as f64,
|
||||
y: view_dir.y as f64,
|
||||
z: view_dir.z as f64,
|
||||
};
|
||||
|
||||
if self.current_layout == "Model" {
|
||||
let target_wcs = acadrust::types::Vector3 {
|
||||
x: (cam.target.x as f64) + [0.0_f64; 3][0],
|
||||
y: (cam.target.y as f64) + [0.0_f64; 3][1],
|
||||
z: (cam.target.z as f64) + [0.0_f64; 3][2],
|
||||
};
|
||||
|
||||
// Write back to the *Active VPort entry (may be overridden by DWG writer).
|
||||
if let Some(vp) = self
|
||||
.document
|
||||
.vports
|
||||
.iter_mut()
|
||||
.find(|v| v.name == "*Active")
|
||||
{
|
||||
vp.view_target = target_wcs;
|
||||
vp.view_center = acadrust::types::Vector2::ZERO;
|
||||
vp.view_direction = vd3;
|
||||
vp.view_height = view_height as f64;
|
||||
vp.view_twist = twist;
|
||||
}
|
||||
|
||||
// Persist the tiled layout as duplicate `*Active` VPort entries.
|
||||
self.save_model_tiles_to_vports();
|
||||
|
||||
// Also write to View table — survives DWG save without override.
|
||||
self.write_camera_view_entry(
|
||||
"OpenCADStudio_Camera_Model",
|
||||
target_wcs,
|
||||
vd3,
|
||||
view_height,
|
||||
twist,
|
||||
);
|
||||
true
|
||||
} else {
|
||||
let target_wcs = acadrust::types::Vector3 {
|
||||
x: cam.target.x as f64,
|
||||
y: cam.target.y as f64,
|
||||
z: cam.target.z as f64,
|
||||
};
|
||||
|
||||
// The sheet viewport entity is the authoritative paper-space view;
|
||||
// it round-trips natively, so no named-View side-channel is needed.
|
||||
let layout_block = self.current_layout_block_handle();
|
||||
if !layout_block.is_null() {
|
||||
let sheet_handle = self
|
||||
.document
|
||||
.entities()
|
||||
.filter_map(|e| {
|
||||
if let EntityType::Viewport(vp) = e {
|
||||
Some(vp)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.find(|vp| {
|
||||
vp.common.owner_handle == layout_block && !self.is_content_viewport_in_layout(vp, layout_block)
|
||||
})
|
||||
.map(|vp| vp.common.handle);
|
||||
|
||||
if let Some(handle) = sheet_handle {
|
||||
if let Some(EntityType::Viewport(vp)) = self.document.get_entity_mut(handle) {
|
||||
// AutoCAD stores the paper-space view position in
|
||||
// `view_center` (DCS) with `view_target` at the origin —
|
||||
// writing it the other way round shifts the layout and
|
||||
// crashes nothing but renders the sheet off-place. Paper
|
||||
// space is always a plan view, so DCS == WCS XY here.
|
||||
vp.view_center =
|
||||
acadrust::types::Vector3::new(target_wcs.x, target_wcs.y, 0.0);
|
||||
vp.view_target = acadrust::types::Vector3::ZERO;
|
||||
vp.view_direction = vd3;
|
||||
vp.view_height = view_height as f64;
|
||||
vp.twist_angle = twist;
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/// Upsert a named View entry with the given camera fields.
|
||||
fn write_camera_view_entry(
|
||||
&mut self,
|
||||
name: &str,
|
||||
target: acadrust::types::Vector3,
|
||||
direction: acadrust::types::Vector3,
|
||||
height: f32,
|
||||
twist: f64,
|
||||
) {
|
||||
let existing_handle = self
|
||||
.document
|
||||
.views
|
||||
.iter()
|
||||
.find(|v| v.name == name)
|
||||
.map(|v| v.handle);
|
||||
let mut entry = acadrust::tables::View::new(name);
|
||||
entry.handle = existing_handle.unwrap_or_else(|| self.document.allocate_handle());
|
||||
entry.target = target;
|
||||
entry.direction = direction;
|
||||
entry.height = height as f64;
|
||||
entry.width = height as f64;
|
||||
entry.center = acadrust::types::Vector3::ZERO;
|
||||
entry.twist_angle = twist;
|
||||
self.document.views.add_or_replace(entry);
|
||||
}
|
||||
|
||||
/// Restore the camera from the file's saved view (called once on open).
|
||||
/// Falls back to fit_all() if no saved view is available.
|
||||
pub fn restore_saved_camera(&mut self) {
|
||||
let restored = if self.current_layout == "Model" {
|
||||
// Tiled-layout restore takes precedence — it sets the camera too.
|
||||
// Single-tile files fall through to the *Active branch.
|
||||
self.restore_model_tiles_from_vports() || self.apply_active_vport_camera()
|
||||
} else {
|
||||
// Every paper layout has a full-screen sheet viewport that holds
|
||||
// its view; create one if a loaded file lacks it.
|
||||
let layout = self.current_layout.clone();
|
||||
self.ensure_sheet_viewport(&layout);
|
||||
self.apply_sheet_viewport_camera()
|
||||
};
|
||||
if !restored {
|
||||
self.fit_all();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn fit_all(&mut self) {
|
||||
// Use the FULL, un-culled wire set — not `entity_wires()`, which is
|
||||
// frustum-culled to the current view. Culled input would fit only the
|
||||
// entities already on screen, so each call would zoom out a little and
|
||||
// reveal more, converging on the true extent only after several uses
|
||||
// (issue #51). `wpp = None` also tessellates at a fixed tolerance so
|
||||
// the bounds don't drift with zoom-adaptive curve sampling.
|
||||
let layout_block = self.current_layout_block_handle();
|
||||
let mut wires = self.wires_for_block_culled(layout_block, None, None, None, None);
|
||||
if self.current_layout != "Model" {
|
||||
wires.extend(self.viewport_content_wires(layout_block, None, None));
|
||||
}
|
||||
// 3D solids render as meshes, not wires, so collect their (offset-rel)
|
||||
// XY AABBs separately — a drawing of only solids has no wires to fit.
|
||||
let mesh_aabbs: Vec<[f32; 4]> = self
|
||||
.meshes
|
||||
.iter()
|
||||
.filter(|(h, _)| {
|
||||
self.document
|
||||
.get_entity(**h)
|
||||
.map(|e| e.common().owner_handle == layout_block)
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.map(|(_, set)| set.world_aabb)
|
||||
.filter(|a| a[0].is_finite() && a[2].is_finite())
|
||||
.collect();
|
||||
if wires.is_empty() && mesh_aabbs.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Per-wire centroid pass — used both for the absolute-magnitude reject
|
||||
// (`local_extent_max`) and for the IQR-based outlier reject below.
|
||||
// A wire whose centroid sits far outside the drawing's consensus
|
||||
// cluster is an orphan (block-defn entity that leaked into MSPACE,
|
||||
// bogus hatch boundary, Ray/XLine far point) and must not poison the
|
||||
// bounding box.
|
||||
struct WireCent {
|
||||
idx: usize,
|
||||
cx: f32,
|
||||
cy: f32,
|
||||
}
|
||||
let lim = self.local_extent_max;
|
||||
let mut cents: Vec<WireCent> = Vec::with_capacity(wires.len());
|
||||
for (idx, wire) in wires.iter().enumerate() {
|
||||
let mut sx = 0.0_f64;
|
||||
let mut sy = 0.0_f64;
|
||||
let mut n = 0_usize;
|
||||
for &[x, y, _] in &wire.points {
|
||||
if !x.is_finite() || !y.is_finite() {
|
||||
continue;
|
||||
}
|
||||
sx += x as f64;
|
||||
sy += y as f64;
|
||||
n += 1;
|
||||
}
|
||||
if n > 0 {
|
||||
cents.push(WireCent {
|
||||
idx,
|
||||
cx: (sx / n as f64) as f32,
|
||||
cy: (sy / n as f64) as f32,
|
||||
});
|
||||
}
|
||||
}
|
||||
if cents.is_empty() && mesh_aabbs.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Robust drawing centre (median centroid). `lim` is a span RELATIVE to
|
||||
// this centre, so every reject below is distance-from-centre — geometry
|
||||
// now reaches fit_all as absolute coordinates (no world_offset), which
|
||||
// at UTM scale are ~5.7e6; an absolute `|x| > lim` test would reject the
|
||||
// entire drawing and make ZOOM Extents a no-op.
|
||||
let (mcx, mcy) = {
|
||||
let mut xs: Vec<f32> = cents.iter().map(|c| c.cx).collect();
|
||||
let mut ys: Vec<f32> = cents.iter().map(|c| c.cy).collect();
|
||||
if xs.is_empty() {
|
||||
(0.0_f32, 0.0_f32)
|
||||
} else {
|
||||
xs.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
|
||||
ys.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
|
||||
(xs[xs.len() / 2], ys[ys.len() / 2])
|
||||
}
|
||||
};
|
||||
|
||||
// IQR-based reject only kicks in with enough samples for the quartiles
|
||||
// to be meaningful. Below that, the centre-relative `lim` filter is the
|
||||
// only gate (legacy behavior).
|
||||
let (rx_lo, rx_hi, ry_lo, ry_hi) = if cents.len() >= 8 {
|
||||
let mut xs: Vec<f32> = cents.iter().map(|c| c.cx).collect();
|
||||
let mut ys: Vec<f32> = cents.iter().map(|c| c.cy).collect();
|
||||
xs.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
|
||||
ys.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
|
||||
let q = |v: &[f32], frac: f32| v[((v.len() as f32 - 1.0) * frac) as usize];
|
||||
let q1x = q(&xs, 0.25);
|
||||
let q3x = q(&xs, 0.75);
|
||||
let q1y = q(&ys, 0.25);
|
||||
let q3y = q(&ys, 0.75);
|
||||
// k=10× the inter-quartile span is permissive enough to keep
|
||||
// legitimate sparse outlying geometry (annotation labels, scattered
|
||||
// dim leaders) but tight enough to drop a single wire stranded at
|
||||
// -world_offset. `max(1.0)` guards against a degenerate IQR=0
|
||||
// (e.g. all wires at the same centroid).
|
||||
const K: f32 = 10.0;
|
||||
let dx = (q3x - q1x).max(1.0) * K;
|
||||
let dy = (q3y - q1y).max(1.0) * K;
|
||||
(q1x - dx, q3x + dx, q1y - dy, q3y + dy)
|
||||
} else {
|
||||
(mcx - lim, mcx + lim, mcy - lim, mcy + lim)
|
||||
};
|
||||
|
||||
let mut min = glam::Vec3::splat(f32::MAX);
|
||||
let mut max = glam::Vec3::splat(f32::MIN);
|
||||
for c in ¢s {
|
||||
if c.cx < rx_lo || c.cx > rx_hi || c.cy < ry_lo || c.cy > ry_hi {
|
||||
continue;
|
||||
}
|
||||
let wire = &wires[c.idx];
|
||||
for &[x, y, z] in &wire.points {
|
||||
if !x.is_finite() || !y.is_finite() || !z.is_finite() {
|
||||
continue;
|
||||
}
|
||||
if (x - mcx).abs() > lim || (y - mcy).abs() > lim {
|
||||
continue;
|
||||
}
|
||||
min = min.min(glam::Vec3::new(x, y, z));
|
||||
max = max.max(glam::Vec3::new(x, y, z));
|
||||
}
|
||||
}
|
||||
// Fold in 3D-solid mesh AABBs (not subject to the wire IQR reject).
|
||||
for [ax, ay, bx, by] in &mesh_aabbs {
|
||||
min = min.min(glam::Vec3::new(*ax, *ay, 0.0));
|
||||
max = max.max(glam::Vec3::new(*bx, *by, 0.0));
|
||||
}
|
||||
// If no usable points found, leave the camera unchanged.
|
||||
if min.x > max.x {
|
||||
return;
|
||||
}
|
||||
if min == max {
|
||||
max += glam::Vec3::splat(1.0);
|
||||
}
|
||||
self.camera.borrow_mut().fit_to_bounds(min, max);
|
||||
self.camera_generation += 1;
|
||||
}
|
||||
|
||||
pub fn update(&mut self, _dt: Duration) {}
|
||||
}
|
||||
622
src/scene/layout.rs
Normal file
622
src/scene/layout.rs
Normal file
|
|
@ -0,0 +1,622 @@
|
|||
// Auto-split from scene/mod.rs. Pure text-move; behaviour unchanged.
|
||||
use super::*;
|
||||
|
||||
impl Scene {
|
||||
// ── Layout management ─────────────────────────────────────────────────
|
||||
|
||||
/// Rename a paper-space layout. Updates the Layout object name in the document.
|
||||
pub fn rename_layout(&mut self, old_name: &str, new_name: &str) {
|
||||
for obj in self.document.objects.values_mut() {
|
||||
if let ObjectType::Layout(l) = obj {
|
||||
if l.name == old_name {
|
||||
l.name = new_name.to_string();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete a paper-space layout and all entities owned by it.
|
||||
/// Returns `false` if the layout was not found or is "Model".
|
||||
pub fn delete_layout(&mut self, name: &str) -> bool {
|
||||
if name == "Model" {
|
||||
return false;
|
||||
}
|
||||
|
||||
let layout_info = self.document.objects.values().find_map(|obj| {
|
||||
if let ObjectType::Layout(l) = obj {
|
||||
if l.name == name {
|
||||
return Some((l.handle, l.block_record));
|
||||
}
|
||||
}
|
||||
None
|
||||
});
|
||||
|
||||
let (layout_handle, block_handle) = match layout_info {
|
||||
Some(info) => info,
|
||||
None => return false,
|
||||
};
|
||||
|
||||
// Remove all entities that belong to this layout's block record.
|
||||
let to_remove: Vec<Handle> = self
|
||||
.document
|
||||
.entities()
|
||||
.filter(|e| e.common().owner_handle == block_handle)
|
||||
.map(|e| e.common().handle)
|
||||
.collect();
|
||||
for h in &to_remove {
|
||||
self.hatches.remove(h);
|
||||
self.meshes.remove(h);
|
||||
self.solid_models.remove(h);
|
||||
self.document.remove_entity(*h);
|
||||
}
|
||||
|
||||
// Remove the Layout object itself.
|
||||
self.document.objects.remove(&layout_handle);
|
||||
|
||||
// Drop the layout's entry from the ACAD_LAYOUT dictionary so it does not
|
||||
// dangle (and so AutoCAD doesn't try to recover a now-missing layout).
|
||||
let dict_handle = self.document.header.acad_layout_dict_handle;
|
||||
if let Some(ObjectType::Dictionary(d)) = self.document.objects.get_mut(&dict_handle) {
|
||||
d.entries.retain(|(k, _)| k != name);
|
||||
}
|
||||
|
||||
// Remove the now-empty paper-space block record.
|
||||
let block_name = self
|
||||
.document
|
||||
.block_records
|
||||
.iter()
|
||||
.find(|b| b.handle == block_handle)
|
||||
.map(|b| b.name.clone());
|
||||
if let Some(bn) = block_name {
|
||||
self.document.block_records.remove(&bn);
|
||||
}
|
||||
|
||||
// Drop any standalone PlotSettings page setup tied to this layout.
|
||||
let ps_handles: Vec<Handle> = self
|
||||
.document
|
||||
.objects
|
||||
.iter()
|
||||
.filter_map(|(h, o)| match o {
|
||||
ObjectType::PlotSettings(ps) if ps.page_name == name => Some(*h),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
for h in ps_handles {
|
||||
self.document.objects.remove(&h);
|
||||
}
|
||||
|
||||
// If the deleted layout was active, fall back to Model space.
|
||||
if self.current_layout == name {
|
||||
self.current_layout = "Model".to_string();
|
||||
}
|
||||
|
||||
self.bump_geometry();
|
||||
true
|
||||
}
|
||||
|
||||
/// Swap the `tab_order` of two paper layouts so they appear in swapped order.
|
||||
pub fn swap_layout_order(&mut self, name_a: &str, name_b: &str) {
|
||||
let mut order_a: Option<i16> = None;
|
||||
let mut order_b: Option<i16> = None;
|
||||
for obj in self.document.objects.values() {
|
||||
if let ObjectType::Layout(l) = obj {
|
||||
if l.name == name_a {
|
||||
order_a = Some(l.tab_order);
|
||||
}
|
||||
if l.name == name_b {
|
||||
order_b = Some(l.tab_order);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let (Some(oa), Some(ob)) = (order_a, order_b) {
|
||||
for obj in self.document.objects.values_mut() {
|
||||
if let ObjectType::Layout(l) = obj {
|
||||
if l.name == name_a {
|
||||
l.tab_order = ob;
|
||||
} else if l.name == name_b {
|
||||
l.tab_order = oa;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Discover the inner divider edges between Model tiles. Each entry
|
||||
/// is one draggable horizontal or vertical edge, with the span along
|
||||
/// the perpendicular axis that the edge actually covers (the union
|
||||
/// of touching tiles' extents). Coordinates are in normalized 0..1
|
||||
/// canvas space. Returns an empty list outside Model or for a
|
||||
/// single-tile layout.
|
||||
pub fn model_tile_edges(&self) -> Vec<TileEdge> {
|
||||
if self.current_layout != "Model" {
|
||||
return vec![];
|
||||
}
|
||||
let tiles = self.model_tiles.borrow();
|
||||
if tiles.len() < 2 {
|
||||
return vec![];
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
// Collect candidate inner x's: any tile edge that's strictly
|
||||
// inside (0, 1). Dedup by epsilon.
|
||||
let mut xs: Vec<f32> = Vec::new();
|
||||
let mut ys: Vec<f32> = Vec::new();
|
||||
for t in tiles.iter() {
|
||||
for x in [t.rect.x, t.rect.x + t.rect.width] {
|
||||
if x > TILE_EPS && x < 1.0 - TILE_EPS {
|
||||
xs.push(x);
|
||||
}
|
||||
}
|
||||
for y in [t.rect.y, t.rect.y + t.rect.height] {
|
||||
if y > TILE_EPS && y < 1.0 - TILE_EPS {
|
||||
ys.push(y);
|
||||
}
|
||||
}
|
||||
}
|
||||
xs.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
|
||||
xs.dedup_by(|a, b| (*a - *b).abs() < TILE_EPS);
|
||||
ys.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
|
||||
ys.dedup_by(|a, b| (*a - *b).abs() < TILE_EPS);
|
||||
for x in xs {
|
||||
let mut y0 = f32::INFINITY;
|
||||
let mut y1 = f32::NEG_INFINITY;
|
||||
let mut has_left = false;
|
||||
let mut has_right = false;
|
||||
for t in tiles.iter() {
|
||||
if ((t.rect.x + t.rect.width) - x).abs() < TILE_EPS {
|
||||
has_left = true;
|
||||
y0 = y0.min(t.rect.y);
|
||||
y1 = y1.max(t.rect.y + t.rect.height);
|
||||
}
|
||||
if (t.rect.x - x).abs() < TILE_EPS {
|
||||
has_right = true;
|
||||
y0 = y0.min(t.rect.y);
|
||||
y1 = y1.max(t.rect.y + t.rect.height);
|
||||
}
|
||||
}
|
||||
if has_left && has_right && y1 > y0 {
|
||||
out.push(TileEdge {
|
||||
orient: TileEdgeOrient::Vertical,
|
||||
coord: x,
|
||||
span: (y0, y1),
|
||||
});
|
||||
}
|
||||
}
|
||||
for y in ys {
|
||||
let mut x0 = f32::INFINITY;
|
||||
let mut x1 = f32::NEG_INFINITY;
|
||||
let mut has_top = false;
|
||||
let mut has_bot = false;
|
||||
for t in tiles.iter() {
|
||||
if ((t.rect.y + t.rect.height) - y).abs() < TILE_EPS {
|
||||
has_top = true;
|
||||
x0 = x0.min(t.rect.x);
|
||||
x1 = x1.max(t.rect.x + t.rect.width);
|
||||
}
|
||||
if (t.rect.y - y).abs() < TILE_EPS {
|
||||
has_bot = true;
|
||||
x0 = x0.min(t.rect.x);
|
||||
x1 = x1.max(t.rect.x + t.rect.width);
|
||||
}
|
||||
}
|
||||
if has_top && has_bot && x1 > x0 {
|
||||
out.push(TileEdge {
|
||||
orient: TileEdgeOrient::Horizontal,
|
||||
coord: y,
|
||||
span: (x0, x1),
|
||||
});
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Hit-test the inner Model-tile dividers against a pixel cursor.
|
||||
/// `bounds` is the canvas pixel rectangle (origin = canvas top-left).
|
||||
/// Returns the closest edge within `tolerance_px` pixels of the cursor
|
||||
/// along its perpendicular axis, also requiring the cursor to lie
|
||||
/// within the edge's actual span.
|
||||
pub fn hit_model_tile_edge(
|
||||
&self,
|
||||
cursor_px: iced::Point,
|
||||
bounds: iced::Rectangle,
|
||||
tolerance_px: f32,
|
||||
) -> Option<TileEdge> {
|
||||
if bounds.width <= 0.0 || bounds.height <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
let cx = cursor_px.x - bounds.x;
|
||||
let cy = cursor_px.y - bounds.y;
|
||||
let nx = cx / bounds.width;
|
||||
let ny = cy / bounds.height;
|
||||
let tol_nx = tolerance_px / bounds.width;
|
||||
let tol_ny = tolerance_px / bounds.height;
|
||||
let mut best: Option<(f32, TileEdge)> = None;
|
||||
for e in self.model_tile_edges() {
|
||||
let (dist, in_span) = match e.orient {
|
||||
TileEdgeOrient::Vertical => (
|
||||
(e.coord - nx).abs() / tol_nx.max(1e-9),
|
||||
ny >= e.span.0 && ny <= e.span.1,
|
||||
),
|
||||
TileEdgeOrient::Horizontal => (
|
||||
(e.coord - ny).abs() / tol_ny.max(1e-9),
|
||||
nx >= e.span.0 && nx <= e.span.1,
|
||||
),
|
||||
};
|
||||
if in_span && dist <= 1.0 {
|
||||
if best.as_ref().map_or(true, |(d, _)| dist < *d) {
|
||||
best = Some((dist, e));
|
||||
}
|
||||
}
|
||||
}
|
||||
best.map(|(_, e)| e)
|
||||
}
|
||||
|
||||
/// Move the inner divider edge from `old_coord` to `new_coord`, both
|
||||
/// in normalized 0..1 space. Adjusts every tile that touches the
|
||||
/// edge on either side. `min_size` clamps the new coordinate so no
|
||||
/// tile on either side can shrink below the minimum — dragging a
|
||||
/// divider to the screen edge stops at that minimum instead of
|
||||
/// closing the pane (use the close button for that).
|
||||
pub fn move_model_tile_edge(
|
||||
&self,
|
||||
orient: TileEdgeOrient,
|
||||
old_coord: f32,
|
||||
new_coord: f32,
|
||||
min_size: f32,
|
||||
) {
|
||||
let mut tiles = self.model_tiles.borrow_mut();
|
||||
// Clamp the new coordinate so no tile becomes ≤ 0 wide / tall.
|
||||
// (Sub-`min_size` results are still allowed — the collapse pass
|
||||
// handles those.)
|
||||
let new_coord = match orient {
|
||||
TileEdgeOrient::Vertical => {
|
||||
let mut lo = 0.0_f32;
|
||||
let mut hi = 1.0_f32;
|
||||
for t in tiles.iter() {
|
||||
if ((t.rect.x + t.rect.width) - old_coord).abs() < TILE_EPS {
|
||||
lo = lo.max(t.rect.x + min_size);
|
||||
}
|
||||
if (t.rect.x - old_coord).abs() < TILE_EPS {
|
||||
hi = hi.min(t.rect.x + t.rect.width - min_size);
|
||||
}
|
||||
}
|
||||
new_coord.clamp(lo, hi.max(lo))
|
||||
}
|
||||
TileEdgeOrient::Horizontal => {
|
||||
let mut lo = 0.0_f32;
|
||||
let mut hi = 1.0_f32;
|
||||
for t in tiles.iter() {
|
||||
if ((t.rect.y + t.rect.height) - old_coord).abs() < TILE_EPS {
|
||||
lo = lo.max(t.rect.y + min_size);
|
||||
}
|
||||
if (t.rect.y - old_coord).abs() < TILE_EPS {
|
||||
hi = hi.min(t.rect.y + t.rect.height - min_size);
|
||||
}
|
||||
}
|
||||
new_coord.clamp(lo, hi.max(lo))
|
||||
}
|
||||
};
|
||||
for t in tiles.iter_mut() {
|
||||
match orient {
|
||||
TileEdgeOrient::Vertical => {
|
||||
if ((t.rect.x + t.rect.width) - old_coord).abs() < TILE_EPS {
|
||||
t.rect.width = (new_coord - t.rect.x).max(0.0);
|
||||
} else if (t.rect.x - old_coord).abs() < TILE_EPS {
|
||||
let old_right = t.rect.x + t.rect.width;
|
||||
t.rect.x = new_coord;
|
||||
t.rect.width = (old_right - new_coord).max(0.0);
|
||||
}
|
||||
}
|
||||
TileEdgeOrient::Horizontal => {
|
||||
if ((t.rect.y + t.rect.height) - old_coord).abs() < TILE_EPS {
|
||||
t.rect.height = (new_coord - t.rect.y).max(0.0);
|
||||
} else if (t.rect.y - old_coord).abs() < TILE_EPS {
|
||||
let old_bottom = t.rect.y + t.rect.height;
|
||||
t.rect.y = new_coord;
|
||||
t.rect.height = (old_bottom - new_coord).max(0.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Close the active Model tile, absorbing its area into the
|
||||
/// neighbour that shares the longest contact edge and rebinding the
|
||||
/// live camera to that neighbour. No-op with fewer than two tiles.
|
||||
pub fn close_active_model_tile(&self) {
|
||||
let mut tiles = self.model_tiles.borrow_mut();
|
||||
if tiles.len() < 2 {
|
||||
return;
|
||||
}
|
||||
let idx = self.active_model_tile.get().min(tiles.len() - 1);
|
||||
self.absorb_model_tile(&mut tiles, idx);
|
||||
}
|
||||
|
||||
/// Drop tile `idx`, growing the neighbour with the longest shared
|
||||
/// contact edge to cover the vacated area. Fixes up
|
||||
/// `active_model_tile` so the live camera stays bound to a real tile
|
||||
/// (preferring the neighbour that absorbed it). Falls back to
|
||||
/// stretching the first remaining tile to fill the canvas if the
|
||||
/// tile has no axis-aligned neighbour.
|
||||
fn absorb_model_tile(&self, tiles: &mut Vec<ModelTile>, idx: usize) {
|
||||
let removed = tiles[idx].rect;
|
||||
// Find the neighbour with the longest shared contact edge.
|
||||
let mut best: Option<(usize, f32, ContactSide)> = None;
|
||||
for (j, t) in tiles.iter().enumerate() {
|
||||
if j == idx {
|
||||
continue;
|
||||
}
|
||||
let probes = [
|
||||
(
|
||||
ContactSide::Left,
|
||||
((t.rect.x + t.rect.width) - removed.x).abs() < TILE_EPS,
|
||||
overlap_len(
|
||||
(t.rect.y, t.rect.y + t.rect.height),
|
||||
(removed.y, removed.y + removed.height),
|
||||
),
|
||||
),
|
||||
(
|
||||
ContactSide::Right,
|
||||
(t.rect.x - (removed.x + removed.width)).abs() < TILE_EPS,
|
||||
overlap_len(
|
||||
(t.rect.y, t.rect.y + t.rect.height),
|
||||
(removed.y, removed.y + removed.height),
|
||||
),
|
||||
),
|
||||
(
|
||||
ContactSide::Top,
|
||||
((t.rect.y + t.rect.height) - removed.y).abs() < TILE_EPS,
|
||||
overlap_len(
|
||||
(t.rect.x, t.rect.x + t.rect.width),
|
||||
(removed.x, removed.x + removed.width),
|
||||
),
|
||||
),
|
||||
(
|
||||
ContactSide::Bottom,
|
||||
(t.rect.y - (removed.y + removed.height)).abs() < TILE_EPS,
|
||||
overlap_len(
|
||||
(t.rect.x, t.rect.x + t.rect.width),
|
||||
(removed.x, removed.x + removed.width),
|
||||
),
|
||||
),
|
||||
];
|
||||
for (side, touches, c) in probes {
|
||||
if touches && c > 0.0 {
|
||||
if best.map_or(true, |(_, len, _)| c > len) {
|
||||
best = Some((j, c, side));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some((nbr_idx, _, side)) = best {
|
||||
match side {
|
||||
ContactSide::Left => {
|
||||
tiles[nbr_idx].rect.width =
|
||||
(removed.x + removed.width) - tiles[nbr_idx].rect.x;
|
||||
}
|
||||
ContactSide::Right => {
|
||||
let old_right =
|
||||
tiles[nbr_idx].rect.x + tiles[nbr_idx].rect.width;
|
||||
tiles[nbr_idx].rect.x = removed.x;
|
||||
tiles[nbr_idx].rect.width = old_right - removed.x;
|
||||
}
|
||||
ContactSide::Top => {
|
||||
tiles[nbr_idx].rect.height =
|
||||
(removed.y + removed.height) - tiles[nbr_idx].rect.y;
|
||||
}
|
||||
ContactSide::Bottom => {
|
||||
let old_bottom =
|
||||
tiles[nbr_idx].rect.y + tiles[nbr_idx].rect.height;
|
||||
tiles[nbr_idx].rect.y = removed.y;
|
||||
tiles[nbr_idx].rect.height = old_bottom - removed.y;
|
||||
}
|
||||
}
|
||||
let active = self.active_model_tile.get();
|
||||
let new_active = if active == idx {
|
||||
if nbr_idx > idx { nbr_idx - 1 } else { nbr_idx }
|
||||
} else if active > idx {
|
||||
active - 1
|
||||
} else {
|
||||
active
|
||||
};
|
||||
tiles.remove(idx);
|
||||
self.active_model_tile
|
||||
.set(new_active.min(tiles.len().saturating_sub(1)));
|
||||
} else {
|
||||
// Isolated tile (shouldn't happen with axis-aligned
|
||||
// splits) — drop it and stretch the first remaining
|
||||
// tile to fill the canvas so we don't leave a hole.
|
||||
tiles.remove(idx);
|
||||
let active = self.active_model_tile.get();
|
||||
self.active_model_tile
|
||||
.set(active.saturating_sub(if active > idx { 1 } else { 0 }).min(tiles.len().saturating_sub(1)));
|
||||
if let Some(first) = tiles.first_mut() {
|
||||
first.rect = iced::Rectangle {
|
||||
x: 0.0,
|
||||
y: 0.0,
|
||||
width: 1.0,
|
||||
height: 1.0,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Split the active Model tile in two. `horizontal` → a horizontal
|
||||
/// divider (top / bottom halves); otherwise a vertical divider (left /
|
||||
/// right). Both halves inherit the active tile's current camera; the
|
||||
/// active tile stays the first half. No-op outside the Model layout.
|
||||
pub fn split_active_model_tile(&self, horizontal: bool) {
|
||||
if self.current_layout != "Model" {
|
||||
return;
|
||||
}
|
||||
let cam_now = self.camera.borrow().clone();
|
||||
let mut tiles = self.model_tiles.borrow_mut();
|
||||
let active = self.active_model_tile.get().min(tiles.len().saturating_sub(1));
|
||||
let r = tiles[active].rect;
|
||||
let (a, b) = if horizontal {
|
||||
(
|
||||
iced::Rectangle { height: r.height / 2.0, ..r },
|
||||
iced::Rectangle {
|
||||
y: r.y + r.height / 2.0,
|
||||
height: r.height / 2.0,
|
||||
..r
|
||||
},
|
||||
)
|
||||
} else {
|
||||
(
|
||||
iced::Rectangle { width: r.width / 2.0, ..r },
|
||||
iced::Rectangle {
|
||||
x: r.x + r.width / 2.0,
|
||||
width: r.width / 2.0,
|
||||
..r
|
||||
},
|
||||
)
|
||||
};
|
||||
let mode = tiles[active].render_mode;
|
||||
let (grid_on, snap_on) = (tiles[active].grid_on, tiles[active].snap_on);
|
||||
tiles[active] = ModelTile {
|
||||
rect: a,
|
||||
camera: cam_now.clone(),
|
||||
render_mode: mode,
|
||||
grid_on,
|
||||
snap_on,
|
||||
};
|
||||
tiles.insert(
|
||||
active + 1,
|
||||
ModelTile {
|
||||
rect: b,
|
||||
camera: cam_now,
|
||||
render_mode: mode,
|
||||
grid_on,
|
||||
snap_on,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Make the Model tile containing normalized point `(nx, ny)` active,
|
||||
/// swapping cameras so the live `Scene::camera` follows the new tile.
|
||||
/// Returns `true` when the active tile changed. No-op outside Model.
|
||||
pub fn set_active_model_tile_at(&self, nx: f32, ny: f32) -> bool {
|
||||
if self.current_layout != "Model" {
|
||||
return false;
|
||||
}
|
||||
let new = {
|
||||
let tiles = self.model_tiles.borrow();
|
||||
tiles.iter().position(|t| {
|
||||
nx >= t.rect.x
|
||||
&& nx < t.rect.x + t.rect.width
|
||||
&& ny >= t.rect.y
|
||||
&& ny < t.rect.y + t.rect.height
|
||||
})
|
||||
};
|
||||
let Some(new) = new else { return false };
|
||||
let old = self.active_model_tile.get();
|
||||
if new == old {
|
||||
return false;
|
||||
}
|
||||
// Stash the live camera into the outgoing tile, load the incoming.
|
||||
let incoming = {
|
||||
let mut tiles = self.model_tiles.borrow_mut();
|
||||
if let Some(t) = tiles.get_mut(old) {
|
||||
t.camera = self.camera.borrow().clone();
|
||||
}
|
||||
tiles.get(new).map(|t| t.camera.clone())
|
||||
};
|
||||
if let Some(cam) = incoming {
|
||||
*self.camera.borrow_mut() = cam;
|
||||
}
|
||||
self.active_model_tile.set(new);
|
||||
// Caller bumps camera_generation (it needs &mut Scene).
|
||||
true
|
||||
}
|
||||
|
||||
/// Replace the Model tiled layout with the given normalized rectangles
|
||||
/// (each in 0..1). Every tile inherits the current camera; the first
|
||||
/// tile becomes active. Used by VPORTS presets and `reset_model_tiles`.
|
||||
pub fn set_model_tile_layout(&self, rects: Vec<iced::Rectangle>) {
|
||||
let cam_now = self.camera.borrow().clone();
|
||||
// Every new pane inherits the active tile's current visual style.
|
||||
let (mode, grid_on, snap_on) = {
|
||||
let tiles = self.model_tiles.borrow();
|
||||
let active = self.active_model_tile.get().min(tiles.len().saturating_sub(1));
|
||||
tiles
|
||||
.get(active)
|
||||
.map(|t| (t.render_mode, t.grid_on, t.snap_on))
|
||||
.unwrap_or((
|
||||
acadrust::entities::ViewportRenderMode::Wireframe2D,
|
||||
false,
|
||||
false,
|
||||
))
|
||||
};
|
||||
let tiles: Vec<ModelTile> = rects
|
||||
.into_iter()
|
||||
.map(|rect| ModelTile {
|
||||
rect,
|
||||
camera: cam_now.clone(),
|
||||
render_mode: mode,
|
||||
grid_on,
|
||||
snap_on,
|
||||
})
|
||||
.collect();
|
||||
*self.model_tiles.borrow_mut() = if tiles.is_empty() {
|
||||
vec![ModelTile {
|
||||
rect: iced::Rectangle {
|
||||
x: 0.0,
|
||||
y: 0.0,
|
||||
width: 1.0,
|
||||
height: 1.0,
|
||||
},
|
||||
camera: cam_now,
|
||||
render_mode: mode,
|
||||
grid_on,
|
||||
snap_on,
|
||||
}]
|
||||
} else {
|
||||
tiles
|
||||
};
|
||||
self.active_model_tile.set(0);
|
||||
}
|
||||
|
||||
/// Screen-pixel rectangle of the active Model tile within a canvas of
|
||||
/// `(vw, vh)`. Full canvas outside the Model layout or for a single
|
||||
/// tile. Used to map cursor coordinates into the active tile so pick /
|
||||
/// pan / ViewCube work per-pane in a tiled layout.
|
||||
/// Canvas bounds + camera for every Model tile whose grid display is on.
|
||||
/// Each pane renders its own grid independently of which tile is active or
|
||||
/// hovered, so the grid never flickers as the cursor crosses panes. The
|
||||
/// active tile uses the live camera (mid-orbit/pan); others use their
|
||||
/// stored camera. (#121)
|
||||
/// Screen rect + camera for every grid-on sub-view in the current layout —
|
||||
/// model tiles in model space, the sheet plus each floating viewport
|
||||
/// (clipped to its rectangle) in paper space. Derived from the same
|
||||
/// `active_viewports` enumeration the renderer uses, so the grid overlay can
|
||||
/// never drift from the views actually on screen (issue #121). The grid
|
||||
|
||||
pub fn active_model_tile_bounds(&self, vw: f32, vh: f32) -> iced::Rectangle {
|
||||
if self.current_layout != "Model" {
|
||||
return iced::Rectangle { x: 0.0, y: 0.0, width: vw, height: vh };
|
||||
}
|
||||
let tiles = self.model_tiles.borrow();
|
||||
let active = self.active_model_tile.get().min(tiles.len().saturating_sub(1));
|
||||
match tiles.get(active) {
|
||||
Some(t) => iced::Rectangle {
|
||||
x: t.rect.x * vw,
|
||||
y: t.rect.y * vh,
|
||||
width: (t.rect.width * vw).max(1.0),
|
||||
height: (t.rect.height * vh).max(1.0),
|
||||
},
|
||||
None => iced::Rectangle { x: 0.0, y: 0.0, width: vw, height: vh },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug)]
|
||||
enum ContactSide {
|
||||
Left,
|
||||
Right,
|
||||
Top,
|
||||
Bottom,
|
||||
}
|
||||
|
||||
fn overlap_len(a: (f32, f32), b: (f32, f32)) -> f32 {
|
||||
(a.1.min(b.1) - a.0.max(b.0)).max(0.0)
|
||||
}
|
||||
5716
src/scene/mod.rs
5716
src/scene/mod.rs
File diff suppressed because it is too large
Load diff
319
src/scene/modify.rs
Normal file
319
src/scene/modify.rs
Normal file
|
|
@ -0,0 +1,319 @@
|
|||
// Auto-split from scene/mod.rs. Pure text-move; behaviour unchanged.
|
||||
use super::*;
|
||||
|
||||
impl Scene {
|
||||
// ── Modify (transform / copy) ─────────────────────────────────────────
|
||||
|
||||
pub fn transform_entities(&mut self, handles: &[Handle], t: &EntityTransform) {
|
||||
// MIRRTEXT (header.mirror_text): when false AutoCAD positions text /
|
||||
// mtext / shape by the mirror but keeps the original rotation +
|
||||
// oblique so the text stays right-reading. Capture before the
|
||||
// transform and re-apply afterwards.
|
||||
let preserve_text_orientation =
|
||||
matches!(t, EntityTransform::Mirror { .. }) && !self.document.header.mirror_text;
|
||||
let mut text_orient_backup: Vec<(Handle, f64, f64, f64)> = Vec::new();
|
||||
if preserve_text_orientation {
|
||||
for &h in handles {
|
||||
if let Some(entity) = self.document.get_entity(h) {
|
||||
match entity {
|
||||
EntityType::Text(t) => {
|
||||
text_orient_backup.push((h, t.rotation, t.oblique_angle, 0.0))
|
||||
}
|
||||
EntityType::MText(m) => {
|
||||
text_orient_backup.push((h, m.rotation, 0.0, 0.0))
|
||||
}
|
||||
EntityType::Shape(s) => text_orient_backup.push((
|
||||
h,
|
||||
s.rotation,
|
||||
s.oblique_angle,
|
||||
s.relative_x_scale,
|
||||
)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// A dimension's final geometry is baked into a per-instance `*D`
|
||||
// block, and the render draws those sub-entities directly (not the
|
||||
// definition points). Transform them with the dimension, or it would
|
||||
// stay drawn in place while only its def points move.
|
||||
let dim_block_subs: Vec<Handle> = handles
|
||||
.iter()
|
||||
.filter_map(|&h| match self.document.get_entity(h) {
|
||||
Some(EntityType::Dimension(d)) => {
|
||||
let bn = d.base().block_name.clone();
|
||||
if bn.trim().is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(bn)
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.filter_map(|bn| {
|
||||
self.document
|
||||
.block_records
|
||||
.iter()
|
||||
.find(|br| br.name.eq_ignore_ascii_case(&bn))
|
||||
.map(|br| br.entity_handles.clone())
|
||||
})
|
||||
.flatten()
|
||||
.collect();
|
||||
for &h in handles {
|
||||
if let Some(entity) = self.document.get_entity_mut(h) {
|
||||
view::dispatch::apply_transform(entity, t);
|
||||
}
|
||||
if self.hatches.contains_key(&h) {
|
||||
let existing_color = self.hatches[&h].color;
|
||||
let new_model = match self.document.get_entity(h) {
|
||||
Some(EntityType::Hatch(dxf)) => {
|
||||
Self::hatch_model_from_dxf(dxf, existing_color)
|
||||
}
|
||||
// A DXF SOLID renders as a solid-fill hatch; rebuild it from
|
||||
// the moved corners so the fill follows the transform.
|
||||
Some(EntityType::Solid(s)) => {
|
||||
Some(Self::solid_hatch_model(s, existing_color))
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
if let Some(model) = new_model {
|
||||
self.hatches.insert(h, model);
|
||||
}
|
||||
}
|
||||
}
|
||||
if preserve_text_orientation {
|
||||
for (h, rot, oblique, x_scale) in text_orient_backup {
|
||||
if let Some(entity) = self.document.get_entity_mut(h) {
|
||||
match entity {
|
||||
EntityType::Text(t) => {
|
||||
t.rotation = rot;
|
||||
t.oblique_angle = oblique;
|
||||
}
|
||||
EntityType::MText(m) => {
|
||||
m.rotation = rot;
|
||||
}
|
||||
EntityType::Shape(s) => {
|
||||
s.rotation = rot;
|
||||
s.oblique_angle = oblique;
|
||||
s.relative_x_scale = x_scale;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Move the baked dimension-block sub-entities too (collected above).
|
||||
for h in &dim_block_subs {
|
||||
if let Some(entity) = self.document.get_entity_mut(*h) {
|
||||
view::dispatch::apply_transform(entity, t);
|
||||
}
|
||||
}
|
||||
// Only the transformed entities changed (a top-level move/rotate/scale/
|
||||
// mirror never edits a block definition) — re-tessellate just those and
|
||||
// keep the block cache + every other entity's memoized wires.
|
||||
for &h in handles {
|
||||
self.mark_entity_dirty(h);
|
||||
}
|
||||
self.bump_geometry_no_blocks();
|
||||
}
|
||||
|
||||
/// Give a freshly-cloned entity brand-new handles for every *inline*
|
||||
/// sub-entity that stores one (INSERT attributes, 3D-polyline vertices).
|
||||
/// `document.add_entity` only assigns the top-level handle, so without this
|
||||
/// a copy keeps its source's sub-handles — duplicate handles that corrupt
|
||||
/// the saved DWG (file won't reopen in other CAD apps). Vertices that don't
|
||||
/// store a handle (LwPolyline / heavy 2D polyline) get one from the writer,
|
||||
/// so they need no fix-up here. (#129)
|
||||
pub(super) fn reset_clone_subhandles(doc: &mut acadrust::CadDocument, entity: &mut EntityType) {
|
||||
match entity {
|
||||
EntityType::Insert(ins) => {
|
||||
for att in ins.attributes.iter_mut() {
|
||||
att.common.handle = doc.allocate_handle();
|
||||
}
|
||||
}
|
||||
EntityType::Polyline3D(p) => {
|
||||
for v in p.vertices.iter_mut() {
|
||||
v.handle = doc.allocate_handle();
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Add a freshly-cloned entity, allocating a new handle for it *and* every
|
||||
/// inline sub-entity so the copy never shares a handle with its source.
|
||||
/// Use this (not `add_entity`) whenever inserting a duplicate. (#129)
|
||||
pub fn add_entity_clone(&mut self, mut entity: EntityType) -> Handle {
|
||||
Self::reset_clone_subhandles(&mut self.document, &mut entity);
|
||||
entity.common_mut().handle = Handle::NULL;
|
||||
self.add_entity(entity)
|
||||
}
|
||||
|
||||
/// Duplicate the anonymous block `src_name`, transforming every sub-entity
|
||||
/// by `t`, and return the new block's name. A dimension's drawn geometry
|
||||
/// lives in such a baked `*D` block, so a copied dimension needs its own
|
||||
/// transformed block — otherwise it still references the source block and
|
||||
/// renders on top of the original instead of at the copy. Returns None when
|
||||
/// the source block is missing or empty. (#161)
|
||||
fn clone_transformed_block(&mut self, src_name: &str, t: &EntityTransform) -> Option<String> {
|
||||
let sub_handles = self
|
||||
.document
|
||||
.block_records
|
||||
.iter()
|
||||
.find(|br| br.name.eq_ignore_ascii_case(src_name))
|
||||
.map(|br| br.entity_handles.clone())?;
|
||||
if sub_handles.is_empty() {
|
||||
return None;
|
||||
}
|
||||
// Smallest free `*D<n>` anonymous name.
|
||||
let mut n = 0u64;
|
||||
let new_name = loop {
|
||||
let cand = format!("*D{n}");
|
||||
if self.document.block_records.get(&cand).is_none() {
|
||||
break cand;
|
||||
}
|
||||
n += 1;
|
||||
};
|
||||
let next = self.document.next_handle();
|
||||
let br_handle = Handle::new(next);
|
||||
let block_handle = Handle::new(next + 1);
|
||||
let end_handle = Handle::new(next + 2);
|
||||
let mut br = acadrust::tables::BlockRecord::new(&new_name);
|
||||
br.handle = br_handle;
|
||||
br.block_entity_handle = block_handle;
|
||||
br.block_end_handle = end_handle;
|
||||
self.document.block_records.add(br).ok()?;
|
||||
let mut block = Block::new(&new_name, acadrust::types::Vector3::ZERO);
|
||||
block.common.handle = block_handle;
|
||||
block.common.owner_handle = br_handle;
|
||||
self.document.add_entity(EntityType::Block(block)).ok()?;
|
||||
let mut block_end = BlockEnd::new();
|
||||
block_end.common.handle = end_handle;
|
||||
block_end.common.owner_handle = br_handle;
|
||||
self.document.add_entity(EntityType::BlockEnd(block_end)).ok()?;
|
||||
for sh in sub_handles {
|
||||
if let Some(mut sub) = self.document.get_entity(sh).cloned() {
|
||||
view::dispatch::apply_transform(&mut sub, t);
|
||||
Self::reset_clone_subhandles(&mut self.document, &mut sub);
|
||||
sub.common_mut().handle = Handle::NULL;
|
||||
sub.common_mut().owner_handle = br_handle;
|
||||
let _ = self.document.add_entity(sub);
|
||||
}
|
||||
}
|
||||
Some(new_name)
|
||||
}
|
||||
|
||||
pub fn copy_entities(&mut self, handles: &[Handle], t: &EntityTransform) -> Vec<Handle> {
|
||||
let clones: Vec<EntityType> = handles
|
||||
.iter()
|
||||
.filter_map(|&h| self.document.get_entity(h).cloned())
|
||||
.collect();
|
||||
let mut new_handles = Vec::with_capacity(clones.len());
|
||||
for mut entity in clones {
|
||||
view::dispatch::apply_transform(&mut entity, t);
|
||||
// A dimension draws from its baked `*D` block; give the copy its own
|
||||
// transformed block so it lands at the copy position rather than
|
||||
// rendering on top of the source. (#161)
|
||||
if let EntityType::Dimension(d) = &entity {
|
||||
let bn = d.base().block_name.clone();
|
||||
if !bn.trim().is_empty() {
|
||||
if let Some(new_bn) = self.clone_transformed_block(&bn, t) {
|
||||
if let EntityType::Dimension(d) = &mut entity {
|
||||
d.base_mut().block_name = new_bn;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Self::reset_clone_subhandles(&mut self.document, &mut entity);
|
||||
entity.common_mut().handle = Handle::NULL;
|
||||
let h = self.document.add_entity(entity).unwrap_or(Handle::NULL);
|
||||
if !h.is_null() {
|
||||
let new_model = match self.document.get_entity(h) {
|
||||
Some(EntityType::Hatch(dxf)) => {
|
||||
let color = convert::tess_util::aci_to_rgba(&dxf.common.color);
|
||||
Self::hatch_model_from_dxf(dxf, color)
|
||||
}
|
||||
Some(EntityType::Solid(s)) => {
|
||||
let color = convert::tess_util::aci_to_rgba(&s.common.color);
|
||||
Some(Self::solid_hatch_model(s, color))
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
if let Some(model) = new_model {
|
||||
self.hatches.insert(h, model);
|
||||
}
|
||||
}
|
||||
new_handles.push(h);
|
||||
}
|
||||
// The copies are new handles (natural memo misses, tessellated fresh)
|
||||
// and reference only already-cached blocks — no block defn changes.
|
||||
self.bump_geometry_no_blocks();
|
||||
new_handles
|
||||
}
|
||||
|
||||
// ── Grip editing ──────────────────────────────────────────────────────
|
||||
|
||||
pub fn apply_grip(&mut self, handle: Handle, grip_id: usize, apply: GripApply) {
|
||||
// For Solid3D / Region / Body, record the old point_of_reference so we
|
||||
// can translate the pre-tessellated MeshModel by the same delta after
|
||||
// the grip is applied (the ACIS data itself is not modified).
|
||||
let old_por: Option<[f64; 3]> = self
|
||||
.document
|
||||
.get_entity(handle)
|
||||
.and_then(crate::entities::solid3d::point_of_reference)
|
||||
.map(|p| [p.x, p.y, p.z]);
|
||||
|
||||
if let Some(entity) = self.document.get_entity_mut(handle) {
|
||||
view::dispatch::apply_grip(entity, grip_id, apply);
|
||||
}
|
||||
|
||||
// Translate MeshModel vertices by the same delta the grip applied.
|
||||
if let Some(old) = old_por {
|
||||
let new_por: Option<[f64; 3]> = self
|
||||
.document
|
||||
.get_entity(handle)
|
||||
.and_then(crate::entities::solid3d::point_of_reference)
|
||||
.map(|p| [p.x, p.y, p.z]);
|
||||
if let Some(new) = new_por {
|
||||
let dx = (new[0] - old[0]) as f32;
|
||||
let dy = (new[1] - old[1]) as f32;
|
||||
let dz = (new[2] - old[2]) as f32;
|
||||
if let Some(set) = self.meshes.get_mut(&handle) {
|
||||
for lod in &mut set.lods {
|
||||
for v in &mut lod.verts {
|
||||
v[0] += dx;
|
||||
v[1] += dy;
|
||||
v[2] += dz;
|
||||
}
|
||||
}
|
||||
set.world_aabb[0] += dx;
|
||||
set.world_aabb[1] += dy;
|
||||
set.world_aabb[2] += dx;
|
||||
set.world_aabb[3] += dy;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Rebuild GPU hatch/solid model when a boundary vertex or corner moves.
|
||||
match self.document.get_entity(handle) {
|
||||
Some(EntityType::Hatch(dxf)) => {
|
||||
let color = convert::tess_util::aci_to_rgba(&dxf.common.color);
|
||||
if let Some(model) = Self::hatch_model_from_dxf(dxf, color) {
|
||||
self.hatches.insert(handle, model);
|
||||
} else {
|
||||
self.hatches.remove(&handle);
|
||||
}
|
||||
}
|
||||
Some(EntityType::Solid(solid)) => {
|
||||
let color = convert::tess_util::aci_to_rgba(&solid.common.color);
|
||||
self.hatches
|
||||
.insert(handle, Self::solid_hatch_model(solid, color));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
// NOTE: no `bump_geometry()` here. The grip-drag caller hides the
|
||||
// edited entity and previews it as an overlay during the drag (so a
|
||||
// move doesn't re-tessellate the whole model), then bumps once on
|
||||
// commit. Any other caller must bump geometry itself.
|
||||
}
|
||||
}
|
||||
481
src/scene/mspace.rs
Normal file
481
src/scene/mspace.rs
Normal file
|
|
@ -0,0 +1,481 @@
|
|||
// Auto-split from scene/mod.rs. Pure text-move; behaviour unchanged.
|
||||
use super::*;
|
||||
|
||||
impl Scene {
|
||||
// ── MSPACE helpers ───────────────────────────────────────────────────
|
||||
|
||||
/// Convert a **paper-space** world coordinate to **model-space** using the
|
||||
/// geometry of the currently active viewport. Returns the input unchanged
|
||||
/// when there is no active viewport.
|
||||
/// Convert a paper-space point to model space (precise at UTM scale).
|
||||
pub fn paper_to_model(&self, paper_pt: glam::DVec3) -> glam::DVec3 {
|
||||
let vp_handle = match self.active_viewport {
|
||||
Some(h) => h,
|
||||
None => return paper_pt,
|
||||
};
|
||||
let vp = match self.document.get_entity(vp_handle) {
|
||||
Some(acadrust::EntityType::Viewport(vp)) => vp,
|
||||
_ => return paper_pt,
|
||||
};
|
||||
// Uses the viewport's own `view_target` — kept valid by
|
||||
// `normalize_active_viewport_view` on entry, which folds a stale UTM
|
||||
// saved view onto the auto-fit centre so the display, pan/zoom and this
|
||||
// inverse all agree. Cheap (no per-call camera rebuild).
|
||||
let scale = vp_effective_scale(vp.custom_scale, vp.view_height, vp.height);
|
||||
if scale.abs() < 1e-9 {
|
||||
return paper_pt;
|
||||
}
|
||||
let tx = vp.view_target.x;
|
||||
let ty = vp.view_target.y;
|
||||
let pcx = vp.center.x;
|
||||
let pcy = vp.center.y;
|
||||
glam::DVec3::new(
|
||||
(paper_pt.x - pcx) / scale + tx,
|
||||
(paper_pt.y - pcy) / scale + ty,
|
||||
paper_pt.z,
|
||||
)
|
||||
}
|
||||
|
||||
/// Inverse of [`paper_to_model`]: map a model-space point to the paper
|
||||
/// sheet through the active viewport. Returns the input unchanged when
|
||||
/// there is no active viewport. Kept as the inverse companion to
|
||||
/// `paper_to_model`; in-viewport overlays now project via the viewport
|
||||
/// camera ([`viewport_edit_frame`]) rather than mapping onto the sheet.
|
||||
#[allow(dead_code)]
|
||||
pub fn model_to_paper(&self, model_pt: glam::DVec3) -> glam::DVec3 {
|
||||
let vp_handle = match self.active_viewport {
|
||||
Some(h) => h,
|
||||
None => return model_pt,
|
||||
};
|
||||
let vp = match self.document.get_entity(vp_handle) {
|
||||
Some(acadrust::EntityType::Viewport(vp)) => vp,
|
||||
_ => return model_pt,
|
||||
};
|
||||
let scale = vp_effective_scale(vp.custom_scale, vp.view_height, vp.height);
|
||||
glam::DVec3::new(
|
||||
(model_pt.x - vp.view_target.x) * scale + vp.center.x,
|
||||
(model_pt.y - vp.view_target.y) * scale + vp.center.y,
|
||||
model_pt.z,
|
||||
)
|
||||
}
|
||||
|
||||
/// In-viewport (MSPACE) editing frame: the active floating viewport's own
|
||||
/// camera — *exactly* the one the GPU renders its content with
|
||||
/// ([`camera_for_viewport`]) — together with the viewport's full screen
|
||||
/// rectangle in canvas pixels ([`viewport_screen_rect`]).
|
||||
///
|
||||
/// This is the unified editing adapter (the "süzgeç"). Inside a viewport,
|
||||
/// editing IS model-space: treat the returned camera as *the* camera, the
|
||||
/// returned rect as *the* pane, and the cursor relative to that rect — then
|
||||
/// the existing model-space snap / hit-test / grip / preview / plane-pick
|
||||
/// code runs unchanged and lands on the same pixels the GPU draws. Results
|
||||
/// come back as model coordinates directly (no paper round-trip).
|
||||
///
|
||||
/// Because the camera is the real GPU camera, this tracks the viewport's
|
||||
/// pan / zoom / twist / oblique view correctly — unlike a linear
|
||||
/// paper-projection, whose auto-fit / saved-view / crop divergence left the
|
||||
/// snap stale after pan/zoom. Returns `None` when not editing inside a
|
||||
/// floating viewport, or the camera / rect cannot be derived.
|
||||
pub fn viewport_edit_frame(
|
||||
&self,
|
||||
canvas_px: (f32, f32),
|
||||
) -> Option<(view::camera::Camera, iced::Rectangle)> {
|
||||
let vp_handle = self.active_viewport?;
|
||||
let cam = self.camera_for_viewport(vp_handle)?;
|
||||
let full = self.viewport_screen_rect(vp_handle, canvas_px)?;
|
||||
Some((cam, full))
|
||||
}
|
||||
|
||||
/// Fold the active viewport's saved view onto the effective camera (the
|
||||
/// auto-fit centre for stale UTM views) and persist it into `view_target` /
|
||||
/// `view_height`. Called on entering MSPACE so pan/zoom, paper↔model and the
|
||||
/// rendered content all share one valid view — otherwise a stale `(0,0,0)`
|
||||
/// target left the camera auto-fitting to the model centre while the cursor
|
||||
/// math used the origin, and pan toggled the two (jitter).
|
||||
pub fn normalize_active_viewport_view(&mut self) {
|
||||
let Some(vp_handle) = self.active_viewport else {
|
||||
return;
|
||||
};
|
||||
let Some(cam) = self.camera_for_viewport(vp_handle) else {
|
||||
return;
|
||||
};
|
||||
let eff_h = cam.ortho_size() as f64 * 2.0;
|
||||
if let Some(acadrust::EntityType::Viewport(vp)) = self.document.get_entity_mut(vp_handle) {
|
||||
vp.view_target.x = cam.target.x;
|
||||
vp.view_target.y = cam.target.y;
|
||||
vp.view_center.x = 0.0;
|
||||
vp.view_center.y = 0.0;
|
||||
if eff_h > 1e-9 {
|
||||
vp.view_height = eff_h;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Pan the active viewport's model-space view by `(screen_dx, screen_dy)` pixels.
|
||||
/// The delta is converted to model-space units using the camera and viewport scale.
|
||||
/// No-op when there is no active viewport.
|
||||
pub fn pan_active_viewport(&mut self, screen_dx: f32, screen_dy: f32, bounds: iced::Rectangle) {
|
||||
let vp_handle = match self.active_viewport {
|
||||
Some(h) => h,
|
||||
None => return,
|
||||
};
|
||||
// Use the viewport's own camera for the pan axes (matches 3-D view orientation).
|
||||
let vp_cam = match self.camera_for_viewport(vp_handle) {
|
||||
Some(c) => c,
|
||||
None => return,
|
||||
};
|
||||
|
||||
// Read viewport dims (immutable borrow ends here).
|
||||
let (view_height, vp_height, locked) = match self.document.get_entity(vp_handle) {
|
||||
Some(acadrust::EntityType::Viewport(vp)) => {
|
||||
(vp.view_height as f32, vp.height as f32, vp.status.locked)
|
||||
}
|
||||
_ => return,
|
||||
};
|
||||
if locked {
|
||||
return;
|
||||
}
|
||||
|
||||
// Correct pan speed: how many model units correspond to one screen pixel.
|
||||
//
|
||||
// The paper camera's ortho_size() gives the visible paper-space half-height
|
||||
// (in paper mm). One screen pixel = 2*half_h / canvas_height paper mm.
|
||||
// Inside the viewport, one paper mm = view_height / vp_height model units.
|
||||
// Together: model_per_pixel = (2*half_h / canvas_height) * (view_height / vp_height)
|
||||
let paper_half_h = self.camera.borrow().ortho_size();
|
||||
let speed = if bounds.height > 0.0 && paper_half_h > 1e-6 && vp_height > 1e-6 {
|
||||
(2.0 * paper_half_h / bounds.height) * (view_height / vp_height)
|
||||
} else {
|
||||
vp_cam.distance * 0.001
|
||||
};
|
||||
|
||||
let cam_right = vp_cam.rotation * glam::Vec3::X;
|
||||
let cam_up = vp_cam.rotation * glam::Vec3::Y;
|
||||
let model_delta = -(cam_right * screen_dx * speed) + (cam_up * screen_dy * speed);
|
||||
|
||||
if let Some(acadrust::EntityType::Viewport(vp)) = self.document.get_entity_mut(vp_handle) {
|
||||
vp.view_target.x += model_delta.x as f64;
|
||||
vp.view_target.y += model_delta.y as f64;
|
||||
vp.view_target.z += model_delta.z as f64;
|
||||
}
|
||||
}
|
||||
|
||||
/// Zoom the active viewport's model-space view by `steps` notches.
|
||||
/// Positive = zoom in (increase detail), negative = zoom out.
|
||||
/// `cursor_paper`: optional paper-space XY of the cursor; when supplied the
|
||||
/// model point under the cursor is kept stationary (AutoCAD-style zoom).
|
||||
/// No-op when there is no active viewport.
|
||||
pub fn zoom_active_viewport(&mut self, steps: f32, cursor_paper: Option<glam::Vec2>) {
|
||||
let vp_handle = match self.active_viewport {
|
||||
Some(h) => h,
|
||||
None => return,
|
||||
};
|
||||
if let Some(acadrust::EntityType::Viewport(vp)) = self.document.get_entity_mut(vp_handle) {
|
||||
if vp.status.locked {
|
||||
return;
|
||||
}
|
||||
// Zoom in = shrink view_height → higher scale → objects appear larger.
|
||||
let factor = (1.0_f64 - 0.15 * steps as f64).clamp(0.1, 10.0);
|
||||
|
||||
if let Some(cp) = cursor_paper {
|
||||
// Compute the model-space point under the cursor before zoom.
|
||||
let scale_before =
|
||||
vp_effective_scale(vp.custom_scale, vp.view_height, vp.height) as f32;
|
||||
let cx = vp.center.x as f32;
|
||||
let cy = vp.center.y as f32;
|
||||
let tx = vp.view_target.x as f32;
|
||||
let ty = vp.view_target.y as f32;
|
||||
let mx = (cp.x - cx) / scale_before + tx;
|
||||
let my = (cp.y - cy) / scale_before + ty;
|
||||
|
||||
// Apply zoom.
|
||||
vp.view_height = (vp.view_height * factor).max(1e-6);
|
||||
if vp.view_height.abs() > 1e-9 {
|
||||
vp.custom_scale = vp.height / vp.view_height;
|
||||
}
|
||||
let scale_after = vp.custom_scale as f32;
|
||||
|
||||
// Adjust view_target so the model point under cursor stays there.
|
||||
let mx_after = (cp.x - cx) / scale_after + vp.view_target.x as f32;
|
||||
let my_after = (cp.y - cy) / scale_after + vp.view_target.y as f32;
|
||||
vp.view_target.x += (mx - mx_after) as f64;
|
||||
vp.view_target.y += (my - my_after) as f64;
|
||||
} else {
|
||||
vp.view_height = (vp.view_height * factor).max(1e-6);
|
||||
if vp.view_height.abs() > 1e-9 {
|
||||
vp.custom_scale = vp.height / vp.view_height;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Orbit the active viewport's view direction by the given screen-pixel delta.
|
||||
/// No-op when there is no active viewport or it is locked.
|
||||
pub fn orbit_active_viewport(&mut self, delta_x: f32, delta_y: f32) {
|
||||
let vp_handle = match self.active_viewport {
|
||||
Some(h) => h,
|
||||
None => return,
|
||||
};
|
||||
let mut cam = match self.camera_for_viewport(vp_handle) {
|
||||
Some(c) => c,
|
||||
None => return,
|
||||
};
|
||||
cam.orbit(delta_x, delta_y);
|
||||
// yaw_pitch_to_quat(y,p)*Z = (cos(p)*sin(y), -cos(p)*cos(y), sin(p))
|
||||
// `camera_for_viewport` reconstructs the rotation so that
|
||||
// `rotation * Z == view_direction` exactly (its `yaw = atan2(x, -y)`
|
||||
// cancels the sign). Store `eye` directly so the orbit round-trips —
|
||||
// negating Y here made each drag step read back a Y-mirrored camera,
|
||||
// flipping the model between a rotation and its opposite every frame.
|
||||
let eye = cam.rotation * glam::Vec3::Z;
|
||||
if let Some(acadrust::EntityType::Viewport(vp)) = self.document.get_entity_mut(vp_handle) {
|
||||
if vp.status.locked {
|
||||
return;
|
||||
}
|
||||
vp.view_direction.x = eye.x as f64;
|
||||
vp.view_direction.y = eye.y as f64;
|
||||
vp.view_direction.z = eye.z as f64;
|
||||
}
|
||||
}
|
||||
|
||||
/// Snap the active viewport's view direction to `eye_dir` (unit
|
||||
/// vector from target toward camera). Twist angle is left at its
|
||||
/// current value so the up-sense is preserved across successive
|
||||
/// snaps. No-op when there is no active viewport or it is locked.
|
||||
pub fn snap_active_viewport_to_direction(&mut self, eye_dir: glam::Vec3, ucs: glam::Mat4) {
|
||||
let vp_handle = match self.active_viewport {
|
||||
Some(h) => h,
|
||||
None => return,
|
||||
};
|
||||
// Build the full UCS-aligned orientation exactly as the model snap does
|
||||
// (snap_to_direction picks the in-plane roll from the UCS axes), seeded
|
||||
// from the viewport's current camera so the "best up" stays stable, then
|
||||
// decode it back to the stored (view_direction, twist_angle). Writing
|
||||
// only view_direction loses the roll and the rebuilt camera snaps to
|
||||
// WCS-up instead of the UCS the clicked cube was drawn in.
|
||||
let mut tmp = self.camera_for_viewport(vp_handle).unwrap_or_default();
|
||||
tmp.snap_to_direction(eye_dir, ucs);
|
||||
let dir = (tmp.rotation * glam::Vec3::Z).normalize_or(glam::Vec3::Z);
|
||||
let desired_up = (tmp.rotation * glam::Vec3::Y).normalize_or(glam::Vec3::Y);
|
||||
|
||||
// camera_from_view rebuilds the rotation with its OWN yaw convention
|
||||
// (atan2(x, -y)) and applies roll = -twist, which is *not* the camera's
|
||||
// internal yaw/roll convention — so `-tmp.roll()` does not round-trip.
|
||||
// Instead reproduce the decoder's zero-twist basis here, then measure
|
||||
// the signed roll about the view axis that carries its up onto the
|
||||
// desired UCS up. Store twist = -roll (the decoder negates it back).
|
||||
let pitch = dir.z.clamp(-1.0, 1.0).asin();
|
||||
let yaw = if dir.x.abs() < 1e-6 && dir.y.abs() < 1e-6 {
|
||||
0.0
|
||||
} else {
|
||||
dir.x.atan2(-dir.y)
|
||||
};
|
||||
let up0 = (view::camera::yaw_pitch_to_quat(yaw, pitch, 0.0) * glam::Vec3::Y)
|
||||
.normalize_or(glam::Vec3::Y);
|
||||
let roll = up0.cross(desired_up).dot(dir).atan2(up0.dot(desired_up));
|
||||
let twist = -roll as f64;
|
||||
if let Some(acadrust::EntityType::Viewport(vp)) = self.document.get_entity_mut(vp_handle) {
|
||||
if vp.status.locked {
|
||||
return;
|
||||
}
|
||||
vp.view_direction.x = dir.x as f64;
|
||||
vp.view_direction.y = dir.y as f64;
|
||||
vp.view_direction.z = dir.z as f64;
|
||||
vp.twist_angle = twist;
|
||||
}
|
||||
}
|
||||
|
||||
/// Mutate the active viewport's camera through a closure, then re-encode the
|
||||
/// result to the stored `(view_direction, twist_angle)` — the same decode
|
||||
/// the ViewCube snap uses. Lets the home / roll / nudge controls drive a
|
||||
/// floating viewport just like the model camera. Returns `false` if there is
|
||||
/// no active (unlocked) viewport.
|
||||
pub fn mutate_active_viewport_camera(
|
||||
&mut self,
|
||||
f: impl FnOnce(&mut view::camera::Camera),
|
||||
) -> bool {
|
||||
let Some(vp_handle) = self.active_viewport else {
|
||||
return false;
|
||||
};
|
||||
let mut tmp = self.camera_for_viewport(vp_handle).unwrap_or_default();
|
||||
f(&mut tmp);
|
||||
let dir = (tmp.rotation * glam::Vec3::Z).normalize_or(glam::Vec3::Z);
|
||||
let desired_up = (tmp.rotation * glam::Vec3::Y).normalize_or(glam::Vec3::Y);
|
||||
let pitch = dir.z.clamp(-1.0, 1.0).asin();
|
||||
let yaw = if dir.x.abs() < 1e-6 && dir.y.abs() < 1e-6 {
|
||||
0.0
|
||||
} else {
|
||||
dir.x.atan2(-dir.y)
|
||||
};
|
||||
let up0 = (view::camera::yaw_pitch_to_quat(yaw, pitch, 0.0) * glam::Vec3::Y)
|
||||
.normalize_or(glam::Vec3::Y);
|
||||
let roll = up0.cross(desired_up).dot(dir).atan2(up0.dot(desired_up));
|
||||
let twist = -roll as f64;
|
||||
if let Some(acadrust::EntityType::Viewport(vp)) = self.document.get_entity_mut(vp_handle) {
|
||||
if vp.status.locked {
|
||||
return false;
|
||||
}
|
||||
vp.view_direction.x = dir.x as f64;
|
||||
vp.view_direction.y = dir.y as f64;
|
||||
vp.view_direction.z = dir.z as f64;
|
||||
vp.twist_angle = twist;
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Render mode of the active paper-space viewport, or `None` when no
|
||||
/// viewport is active (PSPACE / model layout).
|
||||
pub fn active_viewport_render_mode(
|
||||
&self,
|
||||
) -> Option<acadrust::entities::ViewportRenderMode> {
|
||||
let h = self.active_viewport?;
|
||||
match self.document.get_entity(h) {
|
||||
Some(acadrust::EntityType::Viewport(vp)) => Some(vp.render_mode),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the active paper-space viewport's render mode. Returns `true`
|
||||
/// when a viewport was active and updated; `false` (no-op) otherwise,
|
||||
/// so the caller can fall back to the model-layout render mode.
|
||||
pub fn set_active_viewport_render_mode(
|
||||
&mut self,
|
||||
mode: acadrust::entities::ViewportRenderMode,
|
||||
) -> bool {
|
||||
let Some(h) = self.active_viewport else {
|
||||
return false;
|
||||
};
|
||||
if let Some(acadrust::EntityType::Viewport(vp)) = self.document.get_entity_mut(h) {
|
||||
vp.render_mode = mode;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Visual style of the active Model tile (for the render-mode picker).
|
||||
pub fn active_model_tile_render_mode(
|
||||
&self,
|
||||
) -> acadrust::entities::ViewportRenderMode {
|
||||
let tiles = self.model_tiles.borrow();
|
||||
let active = self.active_model_tile.get().min(tiles.len().saturating_sub(1));
|
||||
tiles
|
||||
.get(active)
|
||||
.map(|t| t.render_mode)
|
||||
.unwrap_or(acadrust::entities::ViewportRenderMode::Wireframe2D)
|
||||
}
|
||||
|
||||
/// Set only the active Model tile's render mode. Other tiles keep theirs.
|
||||
pub fn set_active_model_tile_render_mode(
|
||||
&self,
|
||||
mode: acadrust::entities::ViewportRenderMode,
|
||||
) {
|
||||
let mut tiles = self.model_tiles.borrow_mut();
|
||||
let active = self.active_model_tile.get().min(tiles.len().saturating_sub(1));
|
||||
if let Some(t) = tiles.get_mut(active) {
|
||||
t.render_mode = mode;
|
||||
}
|
||||
}
|
||||
|
||||
/// Current gaze direction (canonical +Z eye dir, world space) of whichever
|
||||
/// camera owns the ViewCube — the active floating viewport in MSPACE, else
|
||||
/// the main camera. Used by the ViewCube "already there → flip to opposite"
|
||||
/// check, which must test the camera the cube actually reflects (the paper
|
||||
/// camera always looks straight down, so testing it flipped every snap).
|
||||
pub fn active_gaze_dir(&self) -> glam::Vec3 {
|
||||
if let Some(h) = self.active_viewport {
|
||||
if let Some(cam) = self.camera_for_viewport(h) {
|
||||
return cam.rotation * glam::Vec3::Z;
|
||||
}
|
||||
}
|
||||
self.camera.borrow().rotation * glam::Vec3::Z
|
||||
}
|
||||
|
||||
/// View-rotation matrix for the active viewport (MSPACE), or the
|
||||
/// paper-space camera's matrix when not in MSPACE.
|
||||
/// Used by ViewCube hit-testing so clicks map to the correct camera.
|
||||
pub fn active_view_rotation_mat(&self) -> glam::Mat4 {
|
||||
// Must match exactly what the drawn cube uses (see ViewportData's
|
||||
// `cam_rotation`): the active context's camera composed with the
|
||||
// ViewCube UCS. Inside a floating viewport that's the viewport's own
|
||||
// camera; the UCS factor applies in both cases.
|
||||
if let Some(h) = self.active_viewport {
|
||||
if let Some(cam) = self.camera_for_viewport(h) {
|
||||
return cam.view_rotation_mat() * self.viewcube_ucs_mat();
|
||||
}
|
||||
}
|
||||
self.camera.borrow().view_rotation_mat() * self.viewcube_ucs_mat()
|
||||
}
|
||||
|
||||
/// The UCS→world rotation the ViewCube should compose with the camera —
|
||||
/// the active UCS in model space, identity everywhere else. Render,
|
||||
/// hit-test, and click-snap all go through this so they stay in lock-step.
|
||||
pub fn viewcube_ucs_mat(&self) -> glam::Mat4 {
|
||||
// UCS applies in model space and inside a floating viewport (MSPACE);
|
||||
// plain paper space stays WCS.
|
||||
if self.current_layout == "Model" || self.active_viewport.is_some() {
|
||||
self.viewcube_ucs
|
||||
} else {
|
||||
glam::Mat4::IDENTITY
|
||||
}
|
||||
}
|
||||
|
||||
/// Return the handle of the user viewport whose *visible* on-screen
|
||||
/// rectangle (clamped to the canvas) contains the given screen-pixel point.
|
||||
/// Viewport activation goes through this so a click only enters a viewport
|
||||
/// when it lands on the part the user can actually see — clicking the empty
|
||||
/// area beside a viewport that runs off-screen no longer matches its full
|
||||
/// (partly off-canvas) paper rect and switches to it by mistake.
|
||||
pub fn viewport_at_screen_point(
|
||||
&self,
|
||||
px: f32,
|
||||
py: f32,
|
||||
canvas: (f32, f32),
|
||||
) -> Option<Handle> {
|
||||
let layout_block = self.current_layout_block_handle();
|
||||
self.document
|
||||
.entities()
|
||||
.filter_map(|e| {
|
||||
let EntityType::Viewport(vp) = e else {
|
||||
return None;
|
||||
};
|
||||
if !self.is_content_viewport_in_layout(vp, layout_block) || !vp.status.is_on {
|
||||
return None;
|
||||
}
|
||||
let rect = self.viewport_screen_rect(vp.common.handle, canvas)?;
|
||||
let x0 = rect.x.max(0.0);
|
||||
let y0 = rect.y.max(0.0);
|
||||
let x1 = (rect.x + rect.width).min(canvas.0);
|
||||
let y1 = (rect.y + rect.height).min(canvas.1);
|
||||
if x1 <= x0 || y1 <= y0 {
|
||||
return None; // fully off-canvas → nothing to click
|
||||
}
|
||||
if px >= x0 && px <= x1 && py >= y0 && py <= y1 {
|
||||
Some((vp.common.handle, (x1 - x0) * (y1 - y0)))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.min_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))
|
||||
.map(|(h, _)| h)
|
||||
}
|
||||
|
||||
/// Return the handle of the first active user viewport in the current layout,
|
||||
/// or `None` if there are none. Used by the MS command.
|
||||
pub fn first_user_viewport(&self) -> Option<Handle> {
|
||||
let layout_block = self.current_layout_block_handle();
|
||||
self.document.entities().find_map(|e| {
|
||||
let EntityType::Viewport(vp) = e else {
|
||||
return None;
|
||||
};
|
||||
if self.is_content_viewport_in_layout(vp, layout_block)
|
||||
&& vp.status.is_on
|
||||
{
|
||||
Some(vp.common.handle)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
481
src/scene/paper.rs
Normal file
481
src/scene/paper.rs
Normal file
|
|
@ -0,0 +1,481 @@
|
|||
// Auto-split from scene/mod.rs. Pure text-move; behaviour unchanged.
|
||||
use super::*;
|
||||
|
||||
impl Scene {
|
||||
pub fn grid_views(&self, vw: f32, vh: f32) -> Vec<(iced::Rectangle, Camera, Handle)> {
|
||||
self.active_viewports(vw, vh, acadrust::entities::ViewportRenderMode::Wireframe2D)
|
||||
.into_iter()
|
||||
.filter(|inst| inst.grid_on)
|
||||
.map(|inst| (inst.screen_rect, inst.camera, inst.handle))
|
||||
.collect()
|
||||
}
|
||||
/// The viewports to render this frame, one entry per scissor pass.
|
||||
///
|
||||
/// - **Model layout**: a single full-canvas instance driven by the
|
||||
/// scene camera (tiled splits will append more later). `model_mode`
|
||||
/// supplies its render mode (held on the tab, not the scene).
|
||||
/// - **Paper layout**: one instance per content viewport entity
|
||||
/// (`id > 1`, owned by the current layout block, switched on),
|
||||
/// using each viewport's own camera and render mode.
|
||||
pub fn active_viewports(
|
||||
&self,
|
||||
canvas_w: f32,
|
||||
canvas_h: f32,
|
||||
model_mode: acadrust::entities::ViewportRenderMode,
|
||||
) -> Vec<ViewportInstance> {
|
||||
if self.current_layout == "Model" {
|
||||
let tiles = self.model_tiles.borrow();
|
||||
let active = self.active_model_tile.get().min(tiles.len().saturating_sub(1));
|
||||
return tiles
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, tile)| {
|
||||
// The active tile renders the live camera (orbit/pan act
|
||||
// on it); inactive tiles use their stored snapshot.
|
||||
let camera = if i == active {
|
||||
self.camera.borrow().clone()
|
||||
} else {
|
||||
tile.camera.clone()
|
||||
};
|
||||
ViewportInstance {
|
||||
handle: Handle::NULL,
|
||||
tile_idx: Some(i),
|
||||
screen_rect: iced::Rectangle {
|
||||
x: tile.rect.x * canvas_w,
|
||||
y: tile.rect.y * canvas_h,
|
||||
width: tile.rect.width * canvas_w,
|
||||
height: tile.rect.height * canvas_h,
|
||||
},
|
||||
camera,
|
||||
// The active tile shows the live mode the picker
|
||||
// drives; every other tile keeps its own stored
|
||||
// style so editing one never disturbs the rest.
|
||||
render_mode: if i == active { model_mode } else { tile.render_mode },
|
||||
active: i == active,
|
||||
grid_on: tile.grid_on,
|
||||
paper_sheet: false,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
let layout_block = self.current_layout_block_handle();
|
||||
let mut out: Vec<ViewportInstance> = Vec::new();
|
||||
// The full-canvas sheet viewport renders the paper-space entities
|
||||
// themselves — the layout's own view, drawn first so the floating
|
||||
// content viewports overlay it. Its camera keeps the paper pan/zoom
|
||||
// (target + ortho size) but is LOCKED to the top/plan orientation:
|
||||
// paper is 2-D, so the sheet never orbits.
|
||||
let mut sheet_cam = self.camera.borrow().clone();
|
||||
sheet_cam.yaw = 0.0;
|
||||
sheet_cam.pitch = std::f32::consts::FRAC_PI_2;
|
||||
sheet_cam.rotation = view::camera::yaw_pitch_to_quat(0.0, std::f32::consts::FRAC_PI_2, 0.0);
|
||||
sheet_cam.projection = view::camera::Projection::Orthographic;
|
||||
let sheet_grid_on = match self
|
||||
.document
|
||||
.get_entity(self.current_layout_sheet_viewport_handle())
|
||||
{
|
||||
Some(EntityType::Viewport(vp)) => vp.status.grid_on,
|
||||
_ => false,
|
||||
};
|
||||
out.push(ViewportInstance {
|
||||
handle: Handle::NULL,
|
||||
tile_idx: None,
|
||||
screen_rect: iced::Rectangle {
|
||||
x: 0.0,
|
||||
y: 0.0,
|
||||
width: canvas_w,
|
||||
height: canvas_h,
|
||||
},
|
||||
camera: sheet_cam,
|
||||
render_mode: acadrust::entities::ViewportRenderMode::Wireframe2D,
|
||||
active: false,
|
||||
grid_on: sheet_grid_on,
|
||||
paper_sheet: true,
|
||||
});
|
||||
for e in self.document.entities() {
|
||||
let EntityType::Viewport(vp) = e else {
|
||||
continue;
|
||||
};
|
||||
if !self.is_content_viewport_in_layout(vp, layout_block)
|
||||
|| !vp.status.is_on
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let h = vp.common.handle;
|
||||
let (Some(screen_rect), Some(camera)) = (
|
||||
self.viewport_screen_rect(h, (canvas_w, canvas_h)),
|
||||
self.camera_for_viewport(h),
|
||||
) else {
|
||||
continue;
|
||||
};
|
||||
out.push(ViewportInstance {
|
||||
handle: h,
|
||||
tile_idx: None,
|
||||
screen_rect,
|
||||
camera,
|
||||
render_mode: vp.render_mode,
|
||||
active: self.active_viewport == Some(h),
|
||||
grid_on: vp.status.grid_on,
|
||||
paper_sheet: false,
|
||||
});
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Convert a paper-space Viewport entity's position/size into a pixel
|
||||
/// `Rectangle` relative to the top-left of the canvas.
|
||||
///
|
||||
/// Uses the same top-down ortho transform as the GPU sheet viewport so the
|
||||
/// overlay lands exactly over the drawn viewport border regardless of zoom
|
||||
/// or pan level.
|
||||
pub fn viewport_screen_rect(
|
||||
&self,
|
||||
vp_handle: Handle,
|
||||
canvas_px: (f32, f32),
|
||||
) -> Option<iced::Rectangle> {
|
||||
let vp = match self.document.get_entity(vp_handle) {
|
||||
Some(EntityType::Viewport(vp)) => vp,
|
||||
_ => return None,
|
||||
};
|
||||
|
||||
let (canvas_w, canvas_h) = canvas_px;
|
||||
if canvas_w < 1.0 || canvas_h < 1.0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let cam = self.camera.borrow();
|
||||
let aspect = canvas_w / canvas_h;
|
||||
let half_h = cam.ortho_size();
|
||||
let half_w = half_h * aspect;
|
||||
let tx = cam.target.x as f32;
|
||||
let ty = cam.target.y as f32;
|
||||
drop(cam);
|
||||
|
||||
// Top-down ortho mapping matching the GPU sheet viewport's camera.
|
||||
let to_px = |wx: f32, wy: f32| -> (f32, f32) {
|
||||
let x = (wx - tx + half_w) / (2.0 * half_w) * canvas_w;
|
||||
let y = (ty + half_h - wy) / (2.0 * half_h) * canvas_h;
|
||||
(x, y)
|
||||
};
|
||||
|
||||
let cx = vp.center.x as f32;
|
||||
let cy = vp.center.y as f32;
|
||||
let hw = (vp.width / 2.0) as f32;
|
||||
let hh = (vp.height / 2.0) as f32;
|
||||
|
||||
let (x0, y0) = to_px(cx - hw, cy + hh); // top-left in screen
|
||||
let (x1, y1) = to_px(cx + hw, cy - hh); // bottom-right in screen
|
||||
|
||||
let w = (x1 - x0).max(1.0);
|
||||
let h = (y1 - y0).max(1.0);
|
||||
|
||||
Some(iced::Rectangle {
|
||||
x: x0,
|
||||
y: y0,
|
||||
width: w,
|
||||
height: h,
|
||||
})
|
||||
}
|
||||
// ── Paper-space helpers ───────────────────────────────────────────────
|
||||
|
||||
/// Paper-layout hatch fills, restricted to the active layout block (used by
|
||||
/// paper-space hatch hit-testing / export). The GPU-rendered
|
||||
/// content viewports already draw model-block hatches inside their
|
||||
/// own scissor; including those here would also draw them on the
|
||||
/// paper sheet through the paper camera (huge / off-position), so
|
||||
/// restrict the canvas list to entities owned by the active paper
|
||||
/// layout block. Iterates the source `self.hatches` map (keyed by
|
||||
/// entity handle) rather than the already-flattened arc — the
|
||||
/// flattened arc carries pattern names, not handles, so filtering
|
||||
/// there is unreliable.
|
||||
pub fn paper_canvas_hatches(&self) -> Arc<Vec<HatchModel>> {
|
||||
let layout_block = self.current_layout_block_handle();
|
||||
let layer_hidden = |layer: &str| {
|
||||
self.document
|
||||
.layers
|
||||
.get(layer)
|
||||
.map(|l| l.flags.off || l.flags.frozen)
|
||||
.unwrap_or(false)
|
||||
};
|
||||
let mut models: Vec<HatchModel> = Vec::new();
|
||||
for (&handle, model) in self.hatches.iter() {
|
||||
let Some(entity) = self.document.get_entity(handle) else {
|
||||
continue;
|
||||
};
|
||||
let c = entity.common();
|
||||
if c.invisible || layer_hidden(&c.layer) {
|
||||
continue;
|
||||
}
|
||||
if !self.belongs_to_visible_block(handle, c.owner_handle, layout_block) {
|
||||
continue;
|
||||
}
|
||||
let mut m = model.clone();
|
||||
m.color = self.render_style(entity).0;
|
||||
if let EntityType::Hatch(dxf) = entity {
|
||||
if let model::hatch_model::HatchPattern::Pattern(_) = &m.pattern {
|
||||
m.angle_offset = dxf.pattern_angle as f32;
|
||||
m.scale = dxf.pattern_scale as f32;
|
||||
}
|
||||
}
|
||||
if self.selected.contains(&handle) {
|
||||
m.color = [0.15, 0.55, 1.00, m.color[3]];
|
||||
}
|
||||
models.push(m);
|
||||
}
|
||||
Arc::new(models)
|
||||
}
|
||||
|
||||
/// Paper-layout wipeout fills (paper hit-testing / export). Same rationale as
|
||||
/// `paper_canvas_hatches` — only include wipeouts owned by the
|
||||
/// active paper layout block, so model wipeouts (drawn through their
|
||||
/// content viewport's GPU pipeline) don't get a second mis-projected
|
||||
/// copy on the paper sheet.
|
||||
pub fn paper_canvas_wipeouts(&self) -> Arc<Vec<HatchModel>> {
|
||||
let layout_block = self.current_layout_block_handle();
|
||||
let bg_color = self.paper_bg_color;
|
||||
let mut models = Vec::new();
|
||||
for entity in self.document.entities() {
|
||||
let EntityType::Wipeout(wo) = entity else {
|
||||
continue;
|
||||
};
|
||||
if wo.common.invisible {
|
||||
continue;
|
||||
}
|
||||
if self
|
||||
.document
|
||||
.layers
|
||||
.get(&wo.common.layer)
|
||||
.map(|l| l.flags.off || l.flags.frozen)
|
||||
.unwrap_or(false)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if !self.belongs_to_visible_block(wo.common.handle, wo.common.owner_handle, layout_block)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
// Paper-block wipeouts live in paper coords — no `world_offset`.
|
||||
let (fill_origin, boundary) = Self::wipeout_boundary_2d(wo);
|
||||
if boundary.len() < 3 {
|
||||
continue;
|
||||
}
|
||||
let mut fill_color = bg_color;
|
||||
if self.selected.contains(&wo.common.handle) {
|
||||
fill_color = [0.15, 0.55, 1.00, 0.35];
|
||||
}
|
||||
models.push(HatchModel {
|
||||
boundary: Arc::new(boundary),
|
||||
pattern: model::hatch_model::HatchPattern::Solid,
|
||||
name: "WIPEOUT_FILL".into(),
|
||||
color: fill_color,
|
||||
angle_offset: 0.0,
|
||||
scale: 1.0,
|
||||
world_origin: fill_origin,
|
||||
vp_scissor: None,
|
||||
draw_depth: 0.0,
|
||||
});
|
||||
}
|
||||
Arc::new(models)
|
||||
}
|
||||
|
||||
/// Build a Camera oriented and scaled to match a paper-space Viewport entity.
|
||||
/// Used by `active_viewports` to render model-space content through each
|
||||
/// content viewport's own view direction and scale.
|
||||
pub(super) fn camera_for_viewport(&self, vp_handle: Handle) -> Option<view::camera::Camera> {
|
||||
let vp = match self.document.get_entity(vp_handle) {
|
||||
Some(EntityType::Viewport(vp)) => vp,
|
||||
_ => return None,
|
||||
};
|
||||
|
||||
// Floating-viewport–specific step: decide saved-view vs auto-fit, then
|
||||
// hand the effective view to the shared `camera_from_view` decoder so
|
||||
// twist / view_center / distance behave identically to a model VPORT.
|
||||
//
|
||||
// UTM / coordinate-shifted drawings often arrive with
|
||||
// `view_target = (0, 0, 0)` and a stale `view_center` from before the
|
||||
// file was geo-referenced; the saved view points at empty WCS while the
|
||||
// actual model sits ~`world_offset` away. Decode the saved view first
|
||||
// and keep it only if its target actually frames the model cluster.
|
||||
//
|
||||
// The overlap test runs on the *decoded* target (wire-space, so the
|
||||
// cluster is `±cluster_half` about the origin), NOT a raw
|
||||
// `view_target + view_center` sum: under a view twist `view_center` is a
|
||||
// DCS offset, so the raw sum lands far from the real WCS centre and
|
||||
// would wrongly trip the auto-fit — replacing the saved view_height with
|
||||
// the whole-cluster fit and rendering the content at the wrong zoom.
|
||||
let saved_h = vp.view_height.abs();
|
||||
let aspect_d = (vp.width / vp.height.max(1.0)).max(1e-9);
|
||||
let cluster_half = self.local_extent_max.max(1.0) as f64;
|
||||
// Absolute drawing centre. Geometry now reaches the scene at absolute
|
||||
// (UTM) coordinates — the old code centred the overlap test and the
|
||||
// auto-fit on the origin, which was right only while world_offset
|
||||
// re-centred the model there. Without it a UTM drawing sits ~5.7e6 away,
|
||||
// so a stale `(0,0,0)` saved view failed the overlap test AND the
|
||||
// auto-fit aimed at empty origin → blank viewports.
|
||||
// Frame the overlap test / auto-fit on the robust cluster centre (median
|
||||
// of entity centroids), NOT the raw extents centre: a drawing with a
|
||||
// far second cluster (e.g. a small-coordinate legend beside a UTM survey)
|
||||
// has an extents centre in the empty gap, which would reject a valid
|
||||
// saved view and then auto-fit onto blank space. Fall back to the extents
|
||||
// centre only when no cluster centre was computed.
|
||||
let (cx, cy) = if self.local_center != [0.0, 0.0] {
|
||||
(self.local_center[0], self.local_center[1])
|
||||
} else {
|
||||
self.model_space_extents()
|
||||
.map(|(mn, mx)| {
|
||||
(((mn.x + mx.x) * 0.5) as f64, ((mn.y + mx.y) * 0.5) as f64)
|
||||
})
|
||||
.unwrap_or((0.0, 0.0))
|
||||
};
|
||||
|
||||
if let Some(cam) = self.camera_from_view(
|
||||
vp.view_direction,
|
||||
vp.view_target,
|
||||
acadrust::types::Vector2 {
|
||||
x: vp.view_center.x,
|
||||
y: vp.view_center.y,
|
||||
},
|
||||
saved_h,
|
||||
vp.twist_angle,
|
||||
) {
|
||||
let half_h = saved_h * 0.5;
|
||||
let half_w = half_h * aspect_d;
|
||||
let (tx, ty) = (cam.target.x as f64, cam.target.y as f64);
|
||||
let overlaps = tx + half_w >= cx - cluster_half
|
||||
&& tx - half_w <= cx + cluster_half
|
||||
&& ty + half_h >= cy - cluster_half
|
||||
&& ty - half_h <= cy + cluster_half;
|
||||
if overlaps {
|
||||
return Some(cam);
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-fit: aim at the content cluster centre, drop the stale view_center.
|
||||
let fit_h = cluster_half * 2.0 * 1.05;
|
||||
let tgt = acadrust::types::Vector3 {
|
||||
x: cx,
|
||||
y: cy,
|
||||
z: vp.view_target.z,
|
||||
};
|
||||
self.camera_from_view(
|
||||
vp.view_direction,
|
||||
tgt,
|
||||
acadrust::types::Vector2::ZERO,
|
||||
fit_h,
|
||||
vp.twist_angle,
|
||||
)
|
||||
}
|
||||
|
||||
/// Collect model-space WireModels visible through `vp_handle`, respecting
|
||||
/// global layer visibility, the viewport's per-viewport layer freeze list,
|
||||
/// and the per-viewport frustum + LOD cull derived from
|
||||
/// `screen_height_px` (the on-paper pixel height of this viewport).
|
||||
fn model_wires_for_viewport(
|
||||
&self,
|
||||
vp_handle: Handle,
|
||||
screen_height_px: f32,
|
||||
) -> Vec<WireModel> {
|
||||
use rustc_hash::FxHashSet as HSet;
|
||||
|
||||
let (frozen, vp_anno_scale, vp_aspect) = match self.document.get_entity(vp_handle) {
|
||||
Some(EntityType::Viewport(vp)) => {
|
||||
let f: HSet<Handle> = vp.frozen_layers.iter().cloned().collect();
|
||||
let vp_scale =
|
||||
vp_effective_scale(vp.custom_scale, vp.view_height, vp.height);
|
||||
let anno = if vp_scale > 1e-9 {
|
||||
(1.0 / vp_scale) as f32
|
||||
} else {
|
||||
1.0_f32
|
||||
};
|
||||
let aspect = if vp.height > 1e-9 {
|
||||
(vp.width / vp.height) as f32
|
||||
} else {
|
||||
1.0_f32
|
||||
};
|
||||
(f, anno, aspect)
|
||||
}
|
||||
_ => (HSet::default(), 1.0_f32, 1.0_f32),
|
||||
};
|
||||
|
||||
// Drive the per-viewport view_aabb / wpp from the *effective* camera
|
||||
// `camera_for_viewport` produces — it folds in the auto-fit
|
||||
// fallback for UTM-style files whose saved `view_target` sits at
|
||||
// empty WCS. Without that, the GPU pass would frustum-cull every
|
||||
// entity (saved-view rect doesn't overlap the offset-subtracted
|
||||
// model cluster) and the viewport would render blank.
|
||||
let Some(cam) = self.camera_for_viewport(vp_handle) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let vp_ortho_h = cam.ortho_size();
|
||||
// Rotation-aware cull box: a twisted/rotated viewport sees a rotated
|
||||
// rectangle in world XY, so derive the box from the camera basis.
|
||||
// `None` (tilted view) disables the cull — render everything.
|
||||
let view_aabb = view_cull_aabb(&cam, vp_aspect, 1.25);
|
||||
// World units per on-screen pixel for LOD substitution + curve
|
||||
// tolerance. Tracks the paper-zoom-driven pixel height the
|
||||
// viewport currently occupies.
|
||||
let wpp = if screen_height_px > 1.0 {
|
||||
Some((2.0 * vp_ortho_h) / screen_height_px)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
self.wires_for_block_culled(
|
||||
self.model_space_block_handle(),
|
||||
view_aabb,
|
||||
wpp,
|
||||
Some(&frozen),
|
||||
Some(vp_anno_scale),
|
||||
)
|
||||
}
|
||||
|
||||
/// Cached per-paper-viewport tessellation. Each viewport's wpp tracks
|
||||
/// the on-paper pixel height (paper-zoom dependent), so the cache key
|
||||
/// includes a quantized form of that height in addition to the
|
||||
/// geometry epoch — every paper zoom step that actually changes the
|
||||
/// LOD bucket invalidates this viewport's entry.
|
||||
pub(crate) fn model_wires_for_viewport_arc(
|
||||
&self,
|
||||
vp_handle: Handle,
|
||||
screen_height_px: f32,
|
||||
) -> Arc<Vec<WireModel>> {
|
||||
// Drop sub-pixel noise so trivial paper-zoom jitter does not
|
||||
// re-tessellate a 100k-entity drawing every frame; round to an
|
||||
// integer pixel.
|
||||
let height_key = screen_height_px.max(1.0).round() as u32;
|
||||
// Hash the viewport's own view (pan + zoom + orbit) into the key.
|
||||
// Editing inside the viewport (MSPACE) changes its frustum but does NOT
|
||||
// bump geometry_epoch, so without this the stale frustum-culled subset
|
||||
// is returned and newly-revealed lines stay invisible until the layout
|
||||
// re-tessellates. Quantize to ~1 px / fine steps to ignore jitter.
|
||||
let view_key = {
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
if let Some(EntityType::Viewport(vp)) = self.document.get_entity(vp_handle) {
|
||||
let vh = vp.view_height.abs().max(1e-6);
|
||||
let q = vh / (screen_height_px.max(1.0) as f64); // model units / px
|
||||
(((vp.view_target.x + vp.view_center.x) / q).round() as i64).hash(&mut h);
|
||||
(((vp.view_target.y + vp.view_center.y) / q).round() as i64).hash(&mut h);
|
||||
((vh * 1000.0).round() as i64).hash(&mut h);
|
||||
((vp.view_direction.x * 1000.0).round() as i64).hash(&mut h);
|
||||
((vp.view_direction.y * 1000.0).round() as i64).hash(&mut h);
|
||||
((vp.view_direction.z * 1000.0).round() as i64).hash(&mut h);
|
||||
}
|
||||
h.finish()
|
||||
};
|
||||
let key = (self.geometry_epoch, height_key, view_key);
|
||||
{
|
||||
let cache = self.viewport_wire_cache.borrow();
|
||||
if let Some((cached_key, ref arc)) = cache.get(&vp_handle) {
|
||||
if *cached_key == key {
|
||||
return Arc::clone(arc);
|
||||
}
|
||||
}
|
||||
}
|
||||
let arc = Arc::new(self.model_wires_for_viewport(vp_handle, screen_height_px));
|
||||
self.viewport_wire_cache
|
||||
.borrow_mut()
|
||||
.insert(vp_handle, (key, Arc::clone(&arc)));
|
||||
arc
|
||||
}
|
||||
}
|
||||
87
src/scene/preview.rs
Normal file
87
src/scene/preview.rs
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
// Auto-split from scene/mod.rs. Pure text-move; behaviour unchanged.
|
||||
use super::*;
|
||||
|
||||
impl Scene {
|
||||
// ── Preview wire ──────────────────────────────────────────────────────
|
||||
|
||||
pub fn set_preview_wires(&mut self, wires: Vec<WireModel>) {
|
||||
// Preview wires are an overlay appended to the cached base wire set in
|
||||
// `build_primitive`; they are NOT part of the tessellation cache. So a
|
||||
// preview update must NOT bump `geometry_epoch` — that would re-
|
||||
// tessellate the whole model on every rubber-band frame. The overlay
|
||||
// forces a GPU wire re-upload on its own (the `has_overlay` content-id
|
||||
// path), and iced redraws after the message that set the preview.
|
||||
self.preview_wires = wires;
|
||||
}
|
||||
|
||||
pub fn clear_preview_wire(&mut self) {
|
||||
// No geometry bump — see `set_preview_wires`. Dropping the overlay
|
||||
// flips the wire content id back to the base tessellation id, which
|
||||
// re-uploads the base wires (without the preview) on the next frame.
|
||||
self.preview_wires = vec![];
|
||||
self.interim_wire = None;
|
||||
}
|
||||
|
||||
pub fn wire_models_for(&self, handles: &[acadrust::Handle]) -> Vec<WireModel> {
|
||||
handles
|
||||
.iter()
|
||||
.flat_map(|h| {
|
||||
match self.document.entities().find(|e| e.common().handle == *h) {
|
||||
// Hatches carry no outline in the normal wire set, but an
|
||||
// edit preview (move / copy / array / grip-drag) needs to
|
||||
// show the shape following the cursor. Build a live boundary
|
||||
// from the current HatchModel — `apply_grip` keeps it in
|
||||
// step, so the preview tracks a dragged grip in real time.
|
||||
Some(EntityType::Hatch(_)) => {
|
||||
self.hatch_outline_wire(*h).into_iter().collect()
|
||||
}
|
||||
Some(e) => self.tessellate_one(e),
|
||||
None => Vec::new(),
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Boundary outline wire for a hatch, reconstructed from its cached
|
||||
/// `HatchModel` (offsets from `world_origin`). Used only for edit previews —
|
||||
/// the normal render shows the fill, not this outline.
|
||||
fn hatch_outline_wire(&self, handle: Handle) -> Option<WireModel> {
|
||||
let m = self.hatches.get(&handle)?;
|
||||
let (wx, wy) = (m.world_origin[0], m.world_origin[1]);
|
||||
let pts: Vec<[f64; 3]> = m
|
||||
.boundary
|
||||
.iter()
|
||||
.map(|&[x, y]| {
|
||||
if x.is_finite() && y.is_finite() {
|
||||
[wx + x as f64, wy + y as f64, 0.0]
|
||||
} else {
|
||||
[f64::NAN; 3]
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
if pts.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
Some(WireModel::solid_f64(
|
||||
handle.value().to_string(),
|
||||
pts,
|
||||
m.color,
|
||||
false,
|
||||
))
|
||||
}
|
||||
|
||||
/// Build wire models for an arbitrary slice of entities (e.g. clipboard contents).
|
||||
/// Entities need not be in the document — they are tessellated directly.
|
||||
pub fn wires_for_entities(&self, entities: &[acadrust::EntityType]) -> Vec<WireModel> {
|
||||
entities
|
||||
.iter()
|
||||
.flat_map(|e| self.tessellate_one(e))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn set_interim_wire(&mut self, w: WireModel) {
|
||||
// Overlay wire — same reasoning as `set_preview_wires`: no geometry
|
||||
// bump, so the model isn't re-tessellated on every interim update.
|
||||
self.interim_wire = Some(w);
|
||||
}
|
||||
}
|
||||
440
src/scene/project.rs
Normal file
440
src/scene/project.rs
Normal file
|
|
@ -0,0 +1,440 @@
|
|||
// Auto-split from scene/mod.rs. Pure text-move; behaviour unchanged.
|
||||
use super::*;
|
||||
|
||||
impl Scene {
|
||||
/// Collect model-space wires projected into paper space for all (or one specific)
|
||||
/// user viewports. `only_vp = Some(h)` restricts output to that viewport.
|
||||
pub(super) fn viewport_content_wires(
|
||||
&self,
|
||||
paper_block: Handle,
|
||||
only_vp: Option<Handle>,
|
||||
exclude_vp: Option<Handle>,
|
||||
) -> Vec<WireModel> {
|
||||
use acadrust::entities::Viewport;
|
||||
|
||||
let viewports: Vec<&Viewport> = self
|
||||
.document
|
||||
.entities()
|
||||
.filter_map(|e| {
|
||||
if let EntityType::Viewport(vp) = e {
|
||||
Some(vp)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.filter(|vp| {
|
||||
self.is_content_viewport_in_layout(vp, paper_block)
|
||||
&& vp.status.is_on
|
||||
&& only_vp.map_or(true, |h| vp.common.handle == h)
|
||||
&& exclude_vp.map_or(true, |h| vp.common.handle != h)
|
||||
})
|
||||
.collect();
|
||||
|
||||
if viewports.is_empty() {
|
||||
return vec![];
|
||||
}
|
||||
|
||||
let mut result = Vec::new();
|
||||
|
||||
for vp in viewports {
|
||||
let vp_handle = vp.common.handle;
|
||||
|
||||
// ── Fast path: return cached projected wires ──────────────────
|
||||
{
|
||||
let cache = self.paper_projected_cache.borrow();
|
||||
if let Some((cached_epoch, ref wires)) = cache.get(&vp_handle) {
|
||||
if *cached_epoch == self.geometry_epoch {
|
||||
result.extend_from_slice(wires);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Cache miss: compute projection ────────────────────────────
|
||||
|
||||
// Use camera_for_viewport so the axes match the GPU renderer exactly.
|
||||
let cam_frame = match self.camera_for_viewport(vp_handle) {
|
||||
Some(c) => c,
|
||||
None => continue,
|
||||
};
|
||||
let view_right = cam_frame.rotation * glam::Vec3::X;
|
||||
let view_up = cam_frame.rotation * glam::Vec3::Y;
|
||||
|
||||
// Scale (paper units per model unit) comes straight from the camera
|
||||
// the GPU uses: the model height shown is `2 * ortho_size`, mapped
|
||||
// onto `vp.height` of paper. `camera_for_viewport` already made the
|
||||
// saved-view-vs-auto-fit decision (with the twist-correct overlap
|
||||
// test), so deriving scale from it keeps the CPU projection (used
|
||||
// for hit-test / snap / fit) locked to the GPU render — no second,
|
||||
// independently-computed scale that could disagree under a twist.
|
||||
let view_height_eff = (cam_frame.ortho_size() * 2.0) as f64;
|
||||
let scale = if view_height_eff > 1e-9 {
|
||||
(vp.height / view_height_eff) as f32
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
|
||||
let pcx = vp.center.x as f32;
|
||||
let pcy = vp.center.y as f32;
|
||||
let pcz = vp.center.z as f32;
|
||||
let hw = (vp.width / 2.0) as f32;
|
||||
let hh = (vp.height / 2.0) as f32;
|
||||
|
||||
// ── Use cached tessellation (model_wires_for_viewport_arc) ────
|
||||
// This eliminates the per-frame tessellate_one() loop that was here
|
||||
// previously; tessellation is now O(1) on navigation frames.
|
||||
// Pass 0.0 for screen height — the CPU-projection / hit-test
|
||||
// path wants the full-fidelity (no-LOD-stub) wire list,
|
||||
// regardless of paper zoom.
|
||||
let model_wires = self.model_wires_for_viewport_arc(vp_handle, 0.0);
|
||||
|
||||
// ── Project and clip wires into viewport ──────────────────────
|
||||
let vp_x0 = pcx - hw;
|
||||
let vp_x1 = pcx + hw;
|
||||
let vp_y0 = pcy - hh;
|
||||
let vp_y1 = pcy + hh;
|
||||
|
||||
// camera_dist: how far the camera is from the target plane.
|
||||
let use_perspective = vp.status.perspective && vp.lens_length > 1.0;
|
||||
let camera_dist = if use_perspective {
|
||||
(vp.view_height as f32 * vp.lens_length as f32 / 24.0).max(0.001)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
let mut projected: Vec<WireModel> = Vec::new();
|
||||
|
||||
// Precompute precision-stable WCS-space projection inputs in
|
||||
// f64. The previous f32 inner loop suffered catastrophic
|
||||
// cancellation on UTM-scale drawings: `(wire_offset_rel -
|
||||
// target_offset_rel).dot(view_right) - view_center` is a
|
||||
// small paper offset computed by subtracting two values at
|
||||
// ~5e6 magnitude — f32 ULP there is ~0.5 m, so paper output
|
||||
// jittered by cm even when the actual model was clean.
|
||||
//
|
||||
// Do everything WCS-relative in f64; cast to f32 only at the
|
||||
// final paper position.
|
||||
// Display centre = the camera's target, in WCS. `camera_for_viewport`
|
||||
// already folded view_center through the (twisted) view basis and
|
||||
// applied the empty-WCS auto-fit, so taking its target keeps the CPU
|
||||
// projection identical to the GPU renderer under any twist.
|
||||
let display_center_x = cam_frame.target.x as f64 + [0.0_f64; 3][0];
|
||||
let display_center_y = cam_frame.target.y as f64 + [0.0_f64; 3][1];
|
||||
let display_center_z = cam_frame.target.z as f64 + [0.0_f64; 3][2];
|
||||
let view_right_d = (
|
||||
view_right.x as f64,
|
||||
view_right.y as f64,
|
||||
view_right.z as f64,
|
||||
);
|
||||
let view_up_d = (view_up.x as f64, view_up.y as f64, view_up.z as f64);
|
||||
let view_fwd = cam_frame.rotation * glam::Vec3::Z;
|
||||
let view_fwd_d = (view_fwd.x as f64, view_fwd.y as f64, view_fwd.z as f64);
|
||||
let camera_dist_d = camera_dist as f64;
|
||||
let scale_d = scale as f64;
|
||||
let pcx_d = pcx as f64;
|
||||
let pcy_d = pcy as f64;
|
||||
// Project one ABSOLUTE-WCS model point (f64) onto the paper sheet.
|
||||
// Shared by the polyline points, snap points and key vertices so the
|
||||
// hit-test / snap geometry lands in the same paper frame the wire is
|
||||
// drawn in — otherwise snaps and the click-AABB stay in model (UTM)
|
||||
// space and the cursor never reaches them.
|
||||
let proj_abs = |ax: f64, ay: f64, az: f64| -> [f32; 3] {
|
||||
let mp_x = ax - display_center_x;
|
||||
let mp_y = ay - display_center_y;
|
||||
let mp_z = az - display_center_z;
|
||||
let u = mp_x * view_right_d.0 + mp_y * view_right_d.1 + mp_z * view_right_d.2;
|
||||
let v = mp_x * view_up_d.0 + mp_y * view_up_d.1 + mp_z * view_up_d.2;
|
||||
if use_perspective {
|
||||
let d_vd = mp_x * view_fwd_d.0 + mp_y * view_fwd_d.1 + mp_z * view_fwd_d.2;
|
||||
let fwd = camera_dist_d - d_vd;
|
||||
if fwd <= 0.001 {
|
||||
return [f32::NAN; 3];
|
||||
}
|
||||
let factor = camera_dist_d / fwd;
|
||||
[
|
||||
(pcx_d + u * factor * scale_d) as f32,
|
||||
(pcy_d + v * factor * scale_d) as f32,
|
||||
pcz,
|
||||
]
|
||||
} else {
|
||||
[(pcx_d + u * scale_d) as f32, (pcy_d + v * scale_d) as f32, pcz]
|
||||
}
|
||||
};
|
||||
let in_vp = |x: f32, y: f32| x >= vp_x0 && x <= vp_x1 && y >= vp_y0 && y <= vp_y1;
|
||||
|
||||
for wire in model_wires.iter() {
|
||||
let projected_pts: Vec<[f32; 3]> = wire
|
||||
.points
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(pi, &[mx, my, mz])| {
|
||||
if mx.is_nan() || my.is_nan() || mz.is_nan() {
|
||||
return [f32::NAN; 3];
|
||||
}
|
||||
// Reconstruct absolute WCS from the double-single high
|
||||
// (`points`) + low (`points_low`) pair — the high f32
|
||||
// alone is ~0.5 m off at UTM scale.
|
||||
let lo = wire.points_low.get(pi).copied().unwrap_or([0.0; 3]);
|
||||
proj_abs(mx as f64 + lo[0] as f64, my as f64 + lo[1] as f64, mz as f64 + lo[2] as f64)
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Fast AABB pre-reject.
|
||||
let any_near = projected_pts.iter().any(|&[x, y, _]| {
|
||||
x.is_finite()
|
||||
&& y.is_finite()
|
||||
&& x >= vp_x0 - 1.0
|
||||
&& x <= vp_x1 + 1.0
|
||||
&& y >= vp_y0 - 1.0
|
||||
&& y <= vp_y1 + 1.0
|
||||
});
|
||||
let (min_x, max_x, min_y, max_y) =
|
||||
projected_pts.iter().filter(|p| p[0].is_finite()).fold(
|
||||
(
|
||||
f32::INFINITY,
|
||||
f32::NEG_INFINITY,
|
||||
f32::INFINITY,
|
||||
f32::NEG_INFINITY,
|
||||
),
|
||||
|(mnx, mxx, mny, mxy), &[x, y, _]| {
|
||||
(mnx.min(x), mxx.max(x), mny.min(y), mxy.max(y))
|
||||
},
|
||||
);
|
||||
let aabb_hits =
|
||||
max_x >= vp_x0 && min_x <= vp_x1 && max_y >= vp_y0 && min_y <= vp_y1;
|
||||
if !any_near && !aabb_hits {
|
||||
continue;
|
||||
}
|
||||
|
||||
let clipped =
|
||||
clip_polyline_to_rect(&projected_pts, vp_x0, vp_y0, vp_x1, vp_y1, pcz);
|
||||
if clipped.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Paper-space AABB of the clipped polyline — the cloned model
|
||||
// (UTM) AABB would make click_hit's screen-projected pre-reject
|
||||
// discard the wire (box selection has no pre-reject, which is why
|
||||
// it kept working while picking didn't).
|
||||
let mut pmnx = f32::INFINITY;
|
||||
let mut pmny = f32::INFINITY;
|
||||
let mut pmxx = f32::NEG_INFINITY;
|
||||
let mut pmxy = f32::NEG_INFINITY;
|
||||
for &[x, y, _] in clipped.iter().filter(|p| p[0].is_finite()) {
|
||||
pmnx = pmnx.min(x);
|
||||
pmny = pmny.min(y);
|
||||
pmxx = pmxx.max(x);
|
||||
pmxy = pmxy.max(y);
|
||||
}
|
||||
|
||||
// Project snap points + key vertices into the same paper frame,
|
||||
// keeping only those inside the viewport rect, so endpoint /
|
||||
// midpoint / centre snaps land on the visible sheet geometry
|
||||
// instead of the model's UTM coordinates.
|
||||
let snap_pts: Vec<(glam::DVec3, model::wire_model::SnapHint)> = wire
|
||||
.snap_pts
|
||||
.iter()
|
||||
.filter_map(|(w, h)| {
|
||||
let p = proj_abs(w.x, w.y, w.z);
|
||||
(p[0].is_finite() && in_vp(p[0], p[1]))
|
||||
.then(|| (glam::DVec3::new(p[0] as f64, p[1] as f64, p[2] as f64), *h))
|
||||
})
|
||||
.collect();
|
||||
let key_vertices: Vec<[f64; 3]> = wire
|
||||
.key_vertices
|
||||
.iter()
|
||||
.filter_map(|&[kx, ky, kz]| {
|
||||
let p = proj_abs(kx, ky, kz);
|
||||
(p[0].is_finite() && in_vp(p[0], p[1]))
|
||||
.then(|| [p[0] as f64, p[1] as f64, p[2] as f64])
|
||||
})
|
||||
.collect();
|
||||
|
||||
let adapted = view::render::adapt_to_bg(wire.color, self.paper_bg_color);
|
||||
let [r, g, b, a] = adapted;
|
||||
let mut out = wire.clone();
|
||||
out.points = clipped;
|
||||
// Paper coordinates are small sheet units — no relative-to-eye
|
||||
// residual is needed, and keeping the model wire's points_low
|
||||
// here would add a model-scale offset to the paper points.
|
||||
out.points_low = Vec::new();
|
||||
out.snap_pts = snap_pts;
|
||||
out.key_vertices = key_vertices;
|
||||
// Tangent geometry is in model space and can't be trivially
|
||||
// re-expressed in paper coords — drop it (no tangent snap on
|
||||
// projected viewport content) rather than snap to UTM.
|
||||
out.tangent_geoms = Vec::new();
|
||||
out.aabb = if pmnx.is_finite() {
|
||||
[pmnx, pmny, pmxx, pmxy]
|
||||
} else {
|
||||
WireModel::UNBOUNDED_AABB
|
||||
};
|
||||
out.color = [r * 0.80, g * 0.80, b * 0.80, a * 0.85];
|
||||
out.line_weight_px = wire.line_weight_px;
|
||||
// Wire's pattern was sized for model-space coords during
|
||||
// tessellation; we just projected points into paper coords
|
||||
// (× scale), so rescale the dash pattern by the same factor
|
||||
// to keep dimensional consistency in the GPU shader.
|
||||
out.pattern_length = wire.pattern_length * scale;
|
||||
out.pattern = wire.pattern.map(|v| v * scale);
|
||||
out.vp_scissor = Some([vp_x0, vp_y0, vp_x1, vp_y1]);
|
||||
projected.push(out);
|
||||
}
|
||||
|
||||
// Store in cache, then extend result.
|
||||
self.paper_projected_cache
|
||||
.borrow_mut()
|
||||
.insert(vp_handle, (self.geometry_epoch, projected.clone()));
|
||||
result.extend(projected);
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
// ── Paper boundary wire ────────────────────────────────────────────────────
|
||||
|
||||
// ── Cohen-Sutherland line clipping ───────────────────────────────────────
|
||||
|
||||
/// Clip a single segment (x0,y0)→(x1,y1) against the axis-aligned rectangle
|
||||
/// [xmin,xmax]×[ymin,ymax]. Returns the clipped endpoints or `None` if the
|
||||
/// segment is entirely outside.
|
||||
|
||||
fn cs_clip(
|
||||
mut x0: f32,
|
||||
mut y0: f32,
|
||||
mut x1: f32,
|
||||
mut y1: f32,
|
||||
xmin: f32,
|
||||
ymin: f32,
|
||||
xmax: f32,
|
||||
ymax: f32,
|
||||
) -> Option<(f32, f32, f32, f32)> {
|
||||
const LEFT: u8 = 1;
|
||||
const RIGHT: u8 = 2;
|
||||
const BOTTOM: u8 = 4;
|
||||
const TOP: u8 = 8;
|
||||
|
||||
let code = |x: f32, y: f32| -> u8 {
|
||||
let mut c = 0u8;
|
||||
if x < xmin {
|
||||
c |= LEFT;
|
||||
} else if x > xmax {
|
||||
c |= RIGHT;
|
||||
}
|
||||
if y < ymin {
|
||||
c |= BOTTOM;
|
||||
} else if y > ymax {
|
||||
c |= TOP;
|
||||
}
|
||||
c
|
||||
};
|
||||
|
||||
let mut c0 = code(x0, y0);
|
||||
let mut c1 = code(x1, y1);
|
||||
|
||||
loop {
|
||||
if c0 | c1 == 0 {
|
||||
return Some((x0, y0, x1, y1));
|
||||
}
|
||||
if c0 & c1 != 0 {
|
||||
return None;
|
||||
}
|
||||
let cout = if c0 != 0 { c0 } else { c1 };
|
||||
let (x, y);
|
||||
if cout & TOP != 0 {
|
||||
x = x0 + (x1 - x0) * (ymax - y0) / (y1 - y0);
|
||||
y = ymax;
|
||||
} else if cout & BOTTOM != 0 {
|
||||
x = x0 + (x1 - x0) * (ymin - y0) / (y1 - y0);
|
||||
y = ymin;
|
||||
} else if cout & RIGHT != 0 {
|
||||
y = y0 + (y1 - y0) * (xmax - x0) / (x1 - x0);
|
||||
x = xmax;
|
||||
} else {
|
||||
y = y0 + (y1 - y0) * (xmin - x0) / (x1 - x0);
|
||||
x = xmin;
|
||||
}
|
||||
if cout == c0 {
|
||||
x0 = x;
|
||||
y0 = y;
|
||||
c0 = code(x0, y0);
|
||||
} else {
|
||||
x1 = x;
|
||||
y1 = y;
|
||||
c1 = code(x1, y1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Clip a projected polyline (NaN-separated segments) to the viewport rectangle.
|
||||
/// Returns a new points vec with proper NaN separators at clip boundaries.
|
||||
fn clip_polyline_to_rect(
|
||||
pts: &[[f32; 3]],
|
||||
xmin: f32,
|
||||
ymin: f32,
|
||||
xmax: f32,
|
||||
ymax: f32,
|
||||
z: f32,
|
||||
) -> Vec<[f32; 3]> {
|
||||
const NAN3: [f32; 3] = [f32::NAN, f32::NAN, f32::NAN];
|
||||
let mut result: Vec<[f32; 3]> = Vec::new();
|
||||
let mut i = 0;
|
||||
|
||||
while i < pts.len() {
|
||||
// Skip NaN separators.
|
||||
if pts[i][0].is_nan() || pts[i][1].is_nan() {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
// Gather contiguous run of finite points.
|
||||
let start = i;
|
||||
while i < pts.len() && pts[i][0].is_finite() && pts[i][1].is_finite() {
|
||||
i += 1;
|
||||
}
|
||||
let seg = &pts[start..i];
|
||||
if seg.len() < 2 {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Clip each edge and track pen state to insert NaN on lift.
|
||||
let mut pen_down = false;
|
||||
for j in 0..seg.len() - 1 {
|
||||
let [x0, y0, _] = seg[j];
|
||||
let [x1, y1, _] = seg[j + 1];
|
||||
match cs_clip(x0, y0, x1, y1, xmin, ymin, xmax, ymax) {
|
||||
None => {
|
||||
pen_down = false;
|
||||
}
|
||||
Some((cx0, cy0, cx1, cy1)) => {
|
||||
if !pen_down {
|
||||
if !result.is_empty() {
|
||||
result.push(NAN3);
|
||||
}
|
||||
result.push([cx0, cy0, z]);
|
||||
pen_down = true;
|
||||
} else if let Some(&[lx, ly, _]) = result.last() {
|
||||
if (lx - cx0).abs() > 1e-4 || (ly - cy0).abs() > 1e-4 {
|
||||
result.push(NAN3);
|
||||
result.push([cx0, cy0, z]);
|
||||
}
|
||||
}
|
||||
result.push([cx1, cy1, z]);
|
||||
// If the exit point was clipped, lift pen.
|
||||
if (cx1 - x1).abs() > 1e-4 || (cy1 - y1).abs() > 1e-4 {
|
||||
pen_down = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Remove trailing NaN.
|
||||
while result
|
||||
.last()
|
||||
.map(|p: &[f32; 3]| p[0].is_nan())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
result.pop();
|
||||
}
|
||||
result
|
||||
}
|
||||
355
src/scene/selection.rs
Normal file
355
src/scene/selection.rs
Normal file
|
|
@ -0,0 +1,355 @@
|
|||
// Auto-split from scene/mod.rs. Pure text-move; behaviour unchanged.
|
||||
use super::*;
|
||||
|
||||
impl Scene {
|
||||
// ── Selection ─────────────────────────────────────────────────────────
|
||||
|
||||
pub fn select_entity(&mut self, handle: Handle, exclusive: bool) {
|
||||
if exclusive {
|
||||
self.selected.clear();
|
||||
}
|
||||
self.selected.insert(handle);
|
||||
self.bump_selection();
|
||||
}
|
||||
|
||||
pub fn deselect_all(&mut self) {
|
||||
self.selected.clear();
|
||||
self.bump_selection();
|
||||
}
|
||||
|
||||
/// Remove a single entity from the selection (Shift+click subtractive pick).
|
||||
pub fn deselect_entity(&mut self, handle: Handle) {
|
||||
if self.selected.remove(&handle) {
|
||||
self.bump_selection();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn selected_entities(&self) -> Vec<(Handle, &EntityType)> {
|
||||
self.selected
|
||||
.iter()
|
||||
.filter_map(|&h| self.document.get_entity(h).map(|e| (h, e)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Iterates every entity owned by the current layout's block-record.
|
||||
/// Returns an empty vec when the block-record is missing or holds no
|
||||
/// entity handles (legacy DXF without group-code 330 — we err on the
|
||||
/// side of "no candidates" instead of scanning the whole document, so
|
||||
/// model-block entities don't leak into a paper-layout selection).
|
||||
fn current_layout_entity_handles(&self) -> Vec<Handle> {
|
||||
let block = self.current_layout_block_handle();
|
||||
self.document
|
||||
.block_records
|
||||
.iter()
|
||||
.find(|br| br.handle == block)
|
||||
.map(|br| br.entity_handles.clone())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Extends the current selection with every entity in the active
|
||||
/// layout that matches one of the selected entities by `(variant,
|
||||
/// layer)`. The seed selection stays selected. No-op when nothing is
|
||||
/// selected. Returns the number of newly-added entities.
|
||||
pub fn select_similar(&mut self) -> usize {
|
||||
use crate::entities::traits::entity_type_name;
|
||||
if self.selected.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
let pairs: rustc_hash::FxHashSet<(&'static str, String)> = self
|
||||
.selected
|
||||
.iter()
|
||||
.filter_map(|h| self.document.get_entity(*h))
|
||||
.map(|e| (entity_type_name(e), e.as_entity().layer().to_string()))
|
||||
.collect();
|
||||
let handles = self.current_layout_entity_handles();
|
||||
let mut added = 0;
|
||||
for h in handles {
|
||||
if self.selected.contains(&h) {
|
||||
continue;
|
||||
}
|
||||
if let Some(e) = self.document.get_entity(h) {
|
||||
let key = (entity_type_name(e), e.as_entity().layer().to_string());
|
||||
if pairs.contains(&key) {
|
||||
self.selected.insert(h);
|
||||
added += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
if added > 0 {
|
||||
self.bump_selection();
|
||||
}
|
||||
added
|
||||
}
|
||||
|
||||
/// Replace the selection with its complement: every selectable object
|
||||
/// in the active layout that isn't currently selected. The candidate
|
||||
/// set is the visible wire set, so objects on off/frozen layers (which
|
||||
/// can't be picked anyway) are excluded. Returns the new count.
|
||||
pub fn invert_selection(&mut self) -> usize {
|
||||
let prev: rustc_hash::FxHashSet<Handle> = self.selected.iter().copied().collect();
|
||||
let all: Vec<Handle> = self
|
||||
.entity_wires()
|
||||
.iter()
|
||||
.filter_map(|w| Self::handle_from_wire_name(&w.name))
|
||||
.collect();
|
||||
self.selected.clear();
|
||||
for h in all {
|
||||
if !prev.contains(&h) {
|
||||
self.selected.insert(h);
|
||||
}
|
||||
}
|
||||
self.bump_selection();
|
||||
self.selected.len()
|
||||
}
|
||||
|
||||
/// Replaces (or extends, when `append` is true) the current
|
||||
/// selection with every entity in the active layout that matches
|
||||
/// the filter. Returns the number of newly-matching entities.
|
||||
///
|
||||
/// `type_name` of `None` means "any type". `property_field` of
|
||||
/// `None` skips the property test (only the type filter applies).
|
||||
/// The operator's `Any` variant also skips the property test.
|
||||
/// Numeric operators (`Gt` / `Lt`) parse both sides as `f64` and
|
||||
/// reject anything non-numeric.
|
||||
pub fn qselect(
|
||||
&mut self,
|
||||
type_name: Option<&str>,
|
||||
property_field: Option<&str>,
|
||||
op: crate::app::QSelectOp,
|
||||
value: &str,
|
||||
append: bool,
|
||||
) -> usize {
|
||||
use crate::app::QSelectOp;
|
||||
use crate::entities::traits::entity_type_name;
|
||||
if !append {
|
||||
self.selected.clear();
|
||||
}
|
||||
let handles = self.current_layout_entity_handles();
|
||||
let mut matched = 0;
|
||||
for h in handles {
|
||||
let Some(e) = self.document.get_entity(h) else {
|
||||
continue;
|
||||
};
|
||||
if let Some(t) = type_name {
|
||||
if entity_type_name(e) != t {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let prop_ok = match (property_field, op) {
|
||||
(None, _) | (_, QSelectOp::Any) => true,
|
||||
(Some(field), op) => {
|
||||
let Some(actual) = self.entity_property_value(e, field) else {
|
||||
continue;
|
||||
};
|
||||
match op {
|
||||
QSelectOp::Eq => actual.eq_ignore_ascii_case(value),
|
||||
QSelectOp::Neq => !actual.eq_ignore_ascii_case(value),
|
||||
QSelectOp::Gt | QSelectOp::Lt => {
|
||||
let (Ok(a), Ok(b)) =
|
||||
(actual.parse::<f64>(), value.parse::<f64>())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if matches!(op, QSelectOp::Gt) {
|
||||
a > b
|
||||
} else {
|
||||
a < b
|
||||
}
|
||||
}
|
||||
QSelectOp::Any => true,
|
||||
}
|
||||
}
|
||||
};
|
||||
if prop_ok {
|
||||
self.selected.insert(h);
|
||||
matched += 1;
|
||||
}
|
||||
}
|
||||
self.bump_selection();
|
||||
matched
|
||||
}
|
||||
|
||||
/// Returns the sorted set of entity-type names present in the active
|
||||
/// layout. Used to populate the Quick Select "Object type" dropdown
|
||||
/// with only the types that actually exist in the drawing.
|
||||
pub fn entity_type_names_in_layout(&self) -> Vec<&'static str> {
|
||||
use crate::entities::traits::entity_type_name;
|
||||
let mut names: std::collections::BTreeSet<&'static str> =
|
||||
std::collections::BTreeSet::new();
|
||||
for h in self.current_layout_entity_handles() {
|
||||
if let Some(e) = self.document.get_entity(h) {
|
||||
names.insert(entity_type_name(e));
|
||||
}
|
||||
}
|
||||
names.into_iter().collect()
|
||||
}
|
||||
|
||||
/// True when `handle`'s entity type is allowed by the selection filter.
|
||||
/// The filter stores excluded type names; empty = everything allowed.
|
||||
pub fn passes_selection_filter(&self, handle: Handle) -> bool {
|
||||
if self.selection_filter.is_empty() {
|
||||
return true;
|
||||
}
|
||||
match self.document.get_entity(handle) {
|
||||
Some(e) => !self
|
||||
.selection_filter
|
||||
.contains(crate::entities::traits::entity_type_name(e)),
|
||||
None => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// True when the selection filter is excluding at least one type.
|
||||
pub fn selection_filter_active(&self) -> bool {
|
||||
!self.selection_filter.is_empty()
|
||||
}
|
||||
|
||||
/// Returns the list of `(field, label)` pairs the Quick Select
|
||||
/// "Properties" dropdown should show given the current type filter:
|
||||
///
|
||||
/// * Common properties (Layer, Color, Linetype, Lineweight) are
|
||||
/// always included.
|
||||
/// * When `type_name` names a specific entity type present in the
|
||||
/// active layout, the first entity of that type contributes its
|
||||
/// `geometry_properties()` rows (Start X, Length, Radius, …) so
|
||||
/// type-specific filtering works.
|
||||
pub fn qselect_properties(
|
||||
&self,
|
||||
type_name: Option<&str>,
|
||||
) -> Vec<(String, String)> {
|
||||
use crate::entities::traits::{entity_type_name, EntityTypeOps};
|
||||
let mut out: Vec<(String, String)> = vec![
|
||||
("layer".to_string(), "Layer".to_string()),
|
||||
("color".to_string(), "Color".to_string()),
|
||||
("linetype".to_string(), "Linetype".to_string()),
|
||||
("lineweight".to_string(), "Lineweight".to_string()),
|
||||
];
|
||||
if let Some(t) = type_name {
|
||||
let text_style_names: Vec<String> = self
|
||||
.document
|
||||
.text_styles
|
||||
.iter()
|
||||
.map(|s| s.name.clone())
|
||||
.collect();
|
||||
let sample = self
|
||||
.current_layout_entity_handles()
|
||||
.into_iter()
|
||||
.filter_map(|h| self.document.get_entity(h))
|
||||
.find(|e| entity_type_name(e) == t);
|
||||
if let Some(sample) = sample {
|
||||
if let Some(section) = sample.geometry_properties(&text_style_names) {
|
||||
for prop in section.props {
|
||||
// Skip rows that don't sensibly compare via
|
||||
// `entity_property_value` (read-only labels are
|
||||
// fine — users can still match against them).
|
||||
out.push((prop.field.to_string(), prop.label.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Reads a property value from an entity for QSELECT comparison.
|
||||
/// Returns the canonical string used as the left-hand side of the
|
||||
/// operator test. Common properties have hand-rolled formatting so
|
||||
/// `"ByLayer"` / `"7"` / `"0.30mm"` are stable; everything else
|
||||
/// goes through `geometry_properties()` and pulls the matching
|
||||
/// row's value out.
|
||||
pub fn entity_property_value(
|
||||
&self,
|
||||
entity: &acadrust::EntityType,
|
||||
field: &str,
|
||||
) -> Option<String> {
|
||||
use crate::entities::traits::EntityTypeOps;
|
||||
use crate::scene::model::object::PropValue;
|
||||
match field {
|
||||
"layer" => Some(entity.common().layer.clone()),
|
||||
"color" => Some(Self::format_color(entity.common().color)),
|
||||
"linetype" => Some(entity.common().linetype.clone()),
|
||||
"lineweight" => Some(Self::format_lineweight(entity.common().line_weight)),
|
||||
_ => {
|
||||
let text_style_names: Vec<String> = self
|
||||
.document
|
||||
.text_styles
|
||||
.iter()
|
||||
.map(|s| s.name.clone())
|
||||
.collect();
|
||||
let section = entity.geometry_properties(&text_style_names)?;
|
||||
let prop = section.props.into_iter().find(|p| p.field == field)?;
|
||||
Some(match prop.value {
|
||||
PropValue::ReadOnly(s) | PropValue::EditText(s) => s,
|
||||
PropValue::LayerChoice(s) => s,
|
||||
PropValue::Choice { selected, .. } => selected,
|
||||
PropValue::ColorChoice(c) => Self::format_color(c),
|
||||
PropValue::LwChoice(lw) => Self::format_lineweight(lw),
|
||||
PropValue::LinetypeChoice(s) => s,
|
||||
PropValue::HatchPatternChoice(s) => s,
|
||||
PropValue::BoolToggle { value, .. } => value.to_string(),
|
||||
PropValue::ColorVaries | PropValue::LwVaries => return None,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn format_color(c: acadrust::types::Color) -> String {
|
||||
use acadrust::types::Color;
|
||||
match c {
|
||||
Color::ByLayer => "ByLayer".to_string(),
|
||||
Color::ByBlock => "ByBlock".to_string(),
|
||||
Color::Index(i) => i.to_string(),
|
||||
Color::Rgb { r, g, b } => format!("{},{},{}", r, g, b),
|
||||
}
|
||||
}
|
||||
|
||||
fn format_lineweight(lw: acadrust::types::LineWeight) -> String {
|
||||
use acadrust::types::LineWeight;
|
||||
match lw {
|
||||
LineWeight::ByLayer => "ByLayer".to_string(),
|
||||
LineWeight::ByBlock => "ByBlock".to_string(),
|
||||
LineWeight::Default => "Default".to_string(),
|
||||
LineWeight::Value(v) => format!("{:.2}mm", v as f64 / 100.0),
|
||||
}
|
||||
}
|
||||
|
||||
// ── Erase ─────────────────────────────────────────────────────────────
|
||||
|
||||
pub fn erase_entities(&mut self, handles: &[Handle]) {
|
||||
for &h in handles {
|
||||
self.document.remove_entity(h);
|
||||
self.selected.remove(&h);
|
||||
self.hatches.remove(&h);
|
||||
self.meshes.remove(&h);
|
||||
self.solid_models.remove(&h);
|
||||
self.mark_entity_dirty(h);
|
||||
}
|
||||
// Remove erased handles from all groups; delete groups that become empty.
|
||||
let group_dict_handle = self.document.header.acad_group_dict_handle;
|
||||
let to_remove: Vec<Handle> = self
|
||||
.document
|
||||
.objects
|
||||
.values_mut()
|
||||
.filter_map(|obj| match obj {
|
||||
ObjectType::Group(g) => {
|
||||
g.entities.retain(|h| !handles.contains(h));
|
||||
if g.entities.is_empty() {
|
||||
Some(g.handle)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
for gh in &to_remove {
|
||||
if let Some(ObjectType::Dictionary(dict)) =
|
||||
self.document.objects.get_mut(&group_dict_handle)
|
||||
{
|
||||
dict.entries.retain(|(_, h)| h != gh);
|
||||
}
|
||||
self.document.objects.remove(gh);
|
||||
}
|
||||
// Deleting top-level entities/inserts leaves block definitions intact;
|
||||
// the erased handles were already dropped from the memo above.
|
||||
self.bump_geometry_no_blocks();
|
||||
}
|
||||
}
|
||||
859
src/scene/tess.rs
Normal file
859
src/scene/tess.rs
Normal file
|
|
@ -0,0 +1,859 @@
|
|||
// Auto-split from scene/mod.rs. Pure text-move; behaviour unchanged.
|
||||
use super::*;
|
||||
|
||||
// ── Parallel tessellation free function ──────────────────────────────────────
|
||||
//
|
||||
// Takes only the `Send + Sync` data needed for tessellation so that
|
||||
// `wires_for_block` can dispatch work across rayon's thread pool without
|
||||
// requiring `Scene` (which contains `Rc<RefCell<...>>` and is `!Send`) to
|
||||
// cross thread boundaries.
|
||||
|
||||
|
||||
/// Tessellate a synthesised dimension-text entity through `tessellate_entity`
|
||||
/// so it picks up the standard text LOD ladder (baseline / greek / full),
|
||||
/// then re-color the returned wires with the dimension's resolved text colour
|
||||
/// (so DIMCLRT / DIMSTYLE colours win over the synthetic Text's defaults).
|
||||
pub(crate) fn tessellate_entity_dim_text(
|
||||
document: &acadrust::CadDocument,
|
||||
selected: &HashSet<Handle>,
|
||||
active_viewport: Option<Handle>,
|
||||
bg_color: [f32; 4],
|
||||
anno_scale: f32,
|
||||
e: &EntityType,
|
||||
view_aabb: Option<[f32; 4]>,
|
||||
world_per_pixel: Option<f32>,
|
||||
text_color: [f32; 4],
|
||||
) -> Vec<WireModel> {
|
||||
let mut wires = tessellate_entity(
|
||||
document, selected, active_viewport, bg_color,
|
||||
anno_scale, e, None, view_aabb, world_per_pixel,
|
||||
);
|
||||
for w in &mut wires {
|
||||
// Synth dim text carries no real entity colour — paint everything
|
||||
// (including greek-LOD fill tris which read `wire.color`) with the
|
||||
// dim's text colour. Selection highlight already baked in by
|
||||
// tessellate_entity, so leave that alone.
|
||||
if !w.selected {
|
||||
w.color = text_color;
|
||||
}
|
||||
}
|
||||
wires
|
||||
}
|
||||
|
||||
pub(crate) fn tessellate_entity(
|
||||
document: &acadrust::CadDocument,
|
||||
selected: &HashSet<Handle>,
|
||||
active_viewport: Option<Handle>,
|
||||
bg_color: [f32; 4],
|
||||
anno_scale: f32,
|
||||
e: &EntityType,
|
||||
block_cache: Option<&cache::block_cache::BlockCache>,
|
||||
// World-space XY view AABB (post `world_offset` subtraction). When
|
||||
// `Some`, entities whose AABB doesn't intersect this rect are skipped.
|
||||
view_aabb: Option<[f32; 4]>,
|
||||
// World units per screen pixel for LOD culling. `None` = no LOD.
|
||||
world_per_pixel: Option<f32>,
|
||||
) -> Vec<WireModel> {
|
||||
let h = e.common().handle;
|
||||
let sel = selected.contains(&h);
|
||||
|
||||
// Frustum + LOD cull for non-Insert, non-Viewport entities. Insert is
|
||||
// handled separately (its WCS bbox depends on the block defn AABB ×
|
||||
// Insert transform — done inside expand_insert). Viewports always emit
|
||||
// so the viewport frame stays visible regardless of zoom.
|
||||
let needs_cull = view_aabb.is_some() || world_per_pixel.is_some();
|
||||
if needs_cull {
|
||||
match e {
|
||||
EntityType::Viewport(_) | EntityType::Insert(_) => {}
|
||||
_ => {
|
||||
let ab = entity_aabb(e);
|
||||
if ab != WireModel::UNBOUNDED_AABB {
|
||||
if let Some(view) = view_aabb {
|
||||
if cache::block_cache::aabb_disjoint_xy(ab, view) {
|
||||
return vec![];
|
||||
}
|
||||
}
|
||||
if let Some(wpp) = world_per_pixel {
|
||||
let w_px = (ab[2] - ab[0]).abs();
|
||||
let h_px = (ab[3] - ab[1]).abs();
|
||||
// Keep in sync with `cache::block_cache::MIN_PIXEL_SIZE`.
|
||||
// Text/MText have their own LOD ladder below
|
||||
// (baseline-line / greek / full) and must reach it
|
||||
// even when projected size is sub-5 px.
|
||||
let is_text = matches!(e, EntityType::Text(_) | EntityType::MText(_));
|
||||
// Face3D is exempt from the sub-pixel stub: it is trivially
|
||||
// cheap to tessellate (4 corners → 2 tris), so there is no
|
||||
// cost to draw it full at any zoom, and the cube-stub
|
||||
// otherwise pops/coarsens flat faces across the threshold.
|
||||
let is_face3d = matches!(e, EntityType::Face3D(_));
|
||||
let is_3d_entity = matches!(
|
||||
e,
|
||||
EntityType::Solid3D(_)
|
||||
| EntityType::Mesh(_)
|
||||
| EntityType::PolyfaceMesh(_)
|
||||
| EntityType::PolygonMesh(_)
|
||||
| EntityType::Body(_)
|
||||
| EntityType::Region(_)
|
||||
| EntityType::Surface(_)
|
||||
);
|
||||
if !is_text && !is_face3d && w_px.max(h_px) / wpp < 5.0 {
|
||||
// Sub-pixel entity: emit a stub instead of
|
||||
// nothing so it stays visible / selectable /
|
||||
// hit-test'able at any zoom. 2-D entities
|
||||
// get the cheap diagonal segment; 3-D
|
||||
// entities get an AABB cube so their
|
||||
// footprint doesn't drift when the camera
|
||||
// crosses the LOD threshold. See #19.
|
||||
let (entity_color, _, _, _, aci_idx) =
|
||||
view::render::render_style_for(document, e);
|
||||
let entity_color = view::render::adapt_to_bg(entity_color, bg_color);
|
||||
if is_3d_entity {
|
||||
// `ab` is already in the local frame
|
||||
// (entity_aabb subtracted world_offset
|
||||
// XY). The bbox z fields are still in
|
||||
// WCS, so subtract `world_offset[2]` to
|
||||
// match — otherwise the stub sits at a
|
||||
// different z than the full tessellation
|
||||
// and the geometry visibly shifts when
|
||||
// the camera crosses the LOD threshold.
|
||||
let bbox = e.as_entity().bounding_box();
|
||||
let oz = 0.0_f64;
|
||||
let z_min = (bbox.min.z - oz) as f32;
|
||||
let z_max = (bbox.max.z - oz) as f32;
|
||||
return vec![lod_stub_wire_3d(
|
||||
h.value().to_string(),
|
||||
entity_color,
|
||||
sel,
|
||||
aci_idx,
|
||||
ab,
|
||||
z_min,
|
||||
z_max,
|
||||
)];
|
||||
}
|
||||
return vec![lod_stub_wire(
|
||||
h.value().to_string(),
|
||||
entity_color,
|
||||
sel,
|
||||
aci_idx,
|
||||
ab,
|
||||
0.0,
|
||||
0.0,
|
||||
)];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let EntityType::Viewport(vp) = e {
|
||||
// The sheet viewport (overall/id=1) is never shown — it represents the
|
||||
// paper boundary, not a user-defined content window.
|
||||
if !Scene::is_content_viewport(vp) {
|
||||
return vec![];
|
||||
}
|
||||
let is_active = active_viewport == Some(h);
|
||||
let is_locked = vp.status.locked;
|
||||
let color = if sel {
|
||||
[1.0, 1.0, 1.0, 1.0]
|
||||
} else if is_active {
|
||||
[1.0, 0.90, 0.20, 1.0]
|
||||
} else if is_locked {
|
||||
[0.90, 0.55, 0.10, 1.0]
|
||||
} else {
|
||||
[0.0, 0.75, 0.75, 1.0]
|
||||
};
|
||||
let (pattern_length, pattern) = if is_active {
|
||||
(1.5_f32, [0.8, -0.4, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0_f32])
|
||||
} else {
|
||||
(0.0_f32, [0.0f32; 8])
|
||||
};
|
||||
let mut wires = convert::tessellate::tessellate(
|
||||
document,
|
||||
h,
|
||||
e,
|
||||
sel,
|
||||
color,
|
||||
pattern_length,
|
||||
pattern,
|
||||
1.5,
|
||||
1.0,
|
||||
world_per_pixel,
|
||||
);
|
||||
let ab = entity_aabb(e);
|
||||
for w in &mut wires {
|
||||
w.aabb = ab;
|
||||
}
|
||||
return wires;
|
||||
}
|
||||
|
||||
let (entity_color, pattern_length, pattern, line_weight_px, aci) =
|
||||
view::render::render_style_for(document, e);
|
||||
let entity_color = view::render::adapt_to_bg(entity_color, bg_color);
|
||||
let lt_scale = document.header.linetype_scale as f32 * e.common().linetype_scale as f32;
|
||||
let lt_name = view::render::linetype_name_for(document, e);
|
||||
// PSLTSCALE: scale linetype dashes by viewport anno_scale so they appear uniform in paper space.
|
||||
let pslt_factor = if document.header.paper_space_linetype_scaling {
|
||||
anno_scale
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
let pattern_length = pattern_length * pslt_factor;
|
||||
let pattern = pattern.map(|v| v * pslt_factor);
|
||||
|
||||
// ── Dimension baked-block fast path ─────────────────────────────────────
|
||||
//
|
||||
// AutoCAD bakes each dimension's final geometry (extension lines, dim
|
||||
// line, arrows, text MText) into a per-instance block — usually
|
||||
// `*D<n>`, but custom names like `DIMBLOCK###-4NP` also occur. When the
|
||||
// block exists we render its contents through `tessellate_entity` so
|
||||
// sub-Text/MText get the standard baseline/greek/full LOD ladder, and
|
||||
// DIMTXT × DIMSCALE isn't re-applied on already-baked geometry.
|
||||
if let EntityType::Dimension(dim) = e {
|
||||
let block_name = &dim.base().block_name;
|
||||
if !block_name.trim().is_empty() {
|
||||
if let Some(br) = document
|
||||
.block_records
|
||||
.iter()
|
||||
.find(|br| br.name.eq_ignore_ascii_case(block_name))
|
||||
{
|
||||
if !br.entity_handles.is_empty() {
|
||||
let mut wires: Vec<WireModel> =
|
||||
Vec::with_capacity(br.entity_handles.len());
|
||||
for &eh in &br.entity_handles {
|
||||
let Some(sub) = document.get_entity(eh) else { continue };
|
||||
// Sub-entities inside *D### / DIMBLOCK## blocks
|
||||
// typically use ByBlock color/linetype/lineweight —
|
||||
// they should inherit from the Dimension entity.
|
||||
let sub_color_is_byblock =
|
||||
sub.common().color == acadrust::types::Color::ByBlock;
|
||||
let sub_wires = tessellate_entity(
|
||||
document, selected, active_viewport, bg_color,
|
||||
// Block contents are baked at the final WCS size —
|
||||
// don't let downstream paths re-apply anno_scale.
|
||||
1.0, sub, block_cache, view_aabb, world_per_pixel,
|
||||
);
|
||||
for mut w in sub_wires {
|
||||
w.name = h.value().to_string();
|
||||
// Override ByBlock colour with the dim's resolved
|
||||
// colour so text matches `DIMCLRT`-style behaviour
|
||||
// (or layer colour) instead of the raw ByBlock
|
||||
// fallback that render_style_for produces.
|
||||
if sub_color_is_byblock {
|
||||
w.color = if sel { WireModel::SELECTED } else { entity_color };
|
||||
w.aci = aci;
|
||||
}
|
||||
wires.push(w);
|
||||
}
|
||||
}
|
||||
if !wires.is_empty() {
|
||||
let aabb = entity_aabb(e);
|
||||
for w in &mut wires {
|
||||
w.aabb = aabb;
|
||||
}
|
||||
return wires;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Fall through to the synthesis path below when no block is attached.
|
||||
}
|
||||
|
||||
if let EntityType::Dimension(dim) = e {
|
||||
let aabb = entity_aabb(e);
|
||||
use crate::entities::dimension::DimensionTess;
|
||||
let mut wires = dim.tessellate(
|
||||
document,
|
||||
h,
|
||||
sel,
|
||||
entity_color,
|
||||
line_weight_px,
|
||||
anno_scale,
|
||||
selected,
|
||||
active_viewport,
|
||||
bg_color,
|
||||
view_aabb,
|
||||
world_per_pixel,
|
||||
);
|
||||
for w in &mut wires {
|
||||
w.aci = aci;
|
||||
w.aabb = aabb;
|
||||
}
|
||||
return wires;
|
||||
}
|
||||
|
||||
if let EntityType::MultiLeader(ml) = e {
|
||||
let aabb = entity_aabb(e);
|
||||
use crate::entities::multileader::MultiLeaderTess;
|
||||
let mut wires = ml.tessellate(
|
||||
document,
|
||||
h,
|
||||
sel,
|
||||
entity_color,
|
||||
line_weight_px,
|
||||
anno_scale,
|
||||
world_per_pixel,
|
||||
);
|
||||
for w in &mut wires {
|
||||
w.aci = aci;
|
||||
w.aabb = aabb;
|
||||
}
|
||||
return wires;
|
||||
}
|
||||
|
||||
// ── Table baked-block fast path ─────────────────────────────────────────
|
||||
//
|
||||
// AutoCAD bakes a Table's final rendered geometry (cell text, gridlines,
|
||||
// fill) into a per-instance block (usually `*T###`) referenced through
|
||||
// `table.block_record_handle`. The block's text uses the *displayed*
|
||||
// height; synthesising cells from `self.rows + TableStyle` instead would
|
||||
// re-apply the table's scale factor on top of already-baked geometry.
|
||||
// When the block exists we render it directly. Same pattern as
|
||||
// Dimension's `block_name`.
|
||||
if let EntityType::Table(tab) = e {
|
||||
if let Some(br_h) = tab.block_record_handle {
|
||||
if let Some(br) = document
|
||||
.block_records
|
||||
.iter()
|
||||
.find(|br| br.handle == br_h)
|
||||
{
|
||||
if !br.entity_handles.is_empty() {
|
||||
let mut wires: Vec<WireModel> =
|
||||
Vec::with_capacity(br.entity_handles.len());
|
||||
for &eh in &br.entity_handles {
|
||||
let Some(sub) = document.get_entity(eh) else { continue };
|
||||
let sub_color_is_byblock =
|
||||
sub.common().color == acadrust::types::Color::ByBlock;
|
||||
let sub_wires = tessellate_entity(
|
||||
document, selected, active_viewport, bg_color,
|
||||
anno_scale, sub, block_cache, view_aabb, world_per_pixel,
|
||||
);
|
||||
for mut w in sub_wires {
|
||||
w.name = h.value().to_string();
|
||||
if sub_color_is_byblock {
|
||||
w.color = if sel { WireModel::SELECTED } else { entity_color };
|
||||
w.aci = aci;
|
||||
}
|
||||
wires.push(w);
|
||||
}
|
||||
}
|
||||
if !wires.is_empty() {
|
||||
let aabb = entity_aabb(e);
|
||||
for w in &mut wires {
|
||||
w.aabb = aabb;
|
||||
}
|
||||
return wires;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// No baked block (e.g. a table created in-app) — synthesise coloured
|
||||
// geometry from the rows + TableStyle so fills/colours/borders/margins
|
||||
// are honoured instead of the monochrome fallback.
|
||||
let mut wires = crate::entities::table::tessellate_table(
|
||||
tab, document, sel, entity_color, line_weight_px,
|
||||
);
|
||||
if !wires.is_empty() {
|
||||
let aabb = entity_aabb(e);
|
||||
for w in &mut wires {
|
||||
w.aci = aci;
|
||||
w.aabb = aabb;
|
||||
}
|
||||
return wires;
|
||||
}
|
||||
}
|
||||
|
||||
if let EntityType::Insert(ins) = e {
|
||||
// Resolve the INSERT's own style so ByBlock sub-entities can inherit it.
|
||||
let (ins_color, ins_pat_len, ins_pat, ins_lw_px, _) = view::render::render_style_for(document, e);
|
||||
let ins_color = view::render::adapt_to_bg(ins_color, bg_color);
|
||||
let ip = glam::Vec3::new(
|
||||
(ins.insert_point.x) as f32,
|
||||
(ins.insert_point.y) as f32,
|
||||
(ins.insert_point.z) as f32,
|
||||
);
|
||||
let marker = WireModel {
|
||||
name: h.value().to_string(),
|
||||
points: vec![],
|
||||
points_low: Vec::new(),
|
||||
color: entity_color,
|
||||
selected: sel,
|
||||
aci: 0,
|
||||
pattern_length: 0.0,
|
||||
pattern: [0.0; 8],
|
||||
line_weight_px: 1.0,
|
||||
snap_pts: vec![(ip.as_dvec3(), model::wire_model::SnapHint::Insertion)],
|
||||
tangent_geoms: vec![],
|
||||
key_vertices: vec![],
|
||||
aabb: WireModel::UNBOUNDED_AABB,
|
||||
plinegen: true,
|
||||
vp_scissor: None,
|
||||
fill_tris: vec![],
|
||||
fill_tris_low: Vec::new(),
|
||||
};
|
||||
|
||||
if let Some(cache) = block_cache {
|
||||
// Xrefs render with the same hue but faded toward `bg_color` so
|
||||
// the user can recognise external-reference geometry at a glance.
|
||||
let is_xref = document
|
||||
.block_records
|
||||
.get(&ins.block_name)
|
||||
.map(|br| br.flags.is_xref || br.flags.is_xref_overlay)
|
||||
.unwrap_or(false);
|
||||
if let Some(mut wires) = cache::block_cache::expand_insert(
|
||||
cache,
|
||||
ins,
|
||||
h,
|
||||
ins_color,
|
||||
ins_pat_len,
|
||||
ins_pat,
|
||||
ins_lw_px,
|
||||
sel,
|
||||
pslt_factor,
|
||||
view_aabb,
|
||||
world_per_pixel,
|
||||
is_xref,
|
||||
bg_color,
|
||||
) {
|
||||
// XCLIP: if this INSERT carries an enabled spatial filter,
|
||||
// clip the expanded block geometry to the boundary polygon so
|
||||
// only the portion inside the clip is drawn.
|
||||
if let Some(sf) = pick::xclip::insert_spatial_filter(document, ins) {
|
||||
let poly = pick::xclip::world_clip_polygon_f64(sf, ins);
|
||||
pick::xclip::clip_wires(&mut wires, &poly);
|
||||
}
|
||||
|
||||
// Per-INSERT attribute values. The block defn carries the
|
||||
// AttributeDefinitions (templates) which expand_insert skips;
|
||||
// the AttributeEntity instances live on the Insert itself in
|
||||
// WCS and need their own tessellation so the user sees the
|
||||
// values they actually filled in. See #20.
|
||||
crate::entities::insert::append_insert_attribute_wires(
|
||||
&mut wires,
|
||||
document,
|
||||
ins,
|
||||
h,
|
||||
sel,
|
||||
ins_color,
|
||||
ins_pat_len,
|
||||
ins_pat,
|
||||
ins_lw_px,
|
||||
bg_color,
|
||||
is_xref,
|
||||
pslt_factor,
|
||||
anno_scale,
|
||||
);
|
||||
wires.push(marker);
|
||||
return wires;
|
||||
}
|
||||
}
|
||||
|
||||
// Cache miss / unavailable: fall back to the original explode path.
|
||||
// The block_cache primary path covers all typical Inserts; this
|
||||
// branch only fires for pathological cache failures.
|
||||
let br = document.block_records.get(&ins.block_name);
|
||||
let is_xref = br
|
||||
.map(|br| br.flags.is_xref || br.flags.is_xref_overlay)
|
||||
.unwrap_or(false);
|
||||
let mut wires: Vec<WireModel> = ins
|
||||
.explode_from_document(document)
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(crate::modules::draw::modify::explode::normalize_insert_entity)
|
||||
.flat_map(|sub| {
|
||||
let (sub_color, sub_pattern_length, sub_pattern, sub_line_weight_px, sub_aci) =
|
||||
view::render::render_style_for_block_sub(
|
||||
document,
|
||||
&sub,
|
||||
ins_color,
|
||||
ins_pat_len,
|
||||
ins_pat,
|
||||
ins_lw_px,
|
||||
);
|
||||
let sub_color = view::render::adapt_to_bg(sub_color, bg_color);
|
||||
let sub_color = if is_xref && !sel {
|
||||
cache::block_cache::fade_toward_bg(sub_color, bg_color)
|
||||
} else {
|
||||
sub_color
|
||||
};
|
||||
let sub_aabb = entity_aabb(&sub);
|
||||
let sub_pattern_length = sub_pattern_length * pslt_factor;
|
||||
let sub_pattern = sub_pattern.map(|v| v * pslt_factor);
|
||||
let mut wires = convert::tessellate::tessellate(
|
||||
document,
|
||||
h,
|
||||
&sub,
|
||||
sel,
|
||||
sub_color,
|
||||
sub_pattern_length,
|
||||
sub_pattern,
|
||||
sub_line_weight_px,
|
||||
anno_scale,
|
||||
world_per_pixel,
|
||||
);
|
||||
for w in &mut wires {
|
||||
w.name = h.value().to_string();
|
||||
w.aci = sub_aci;
|
||||
w.aabb = sub_aabb;
|
||||
}
|
||||
wires
|
||||
})
|
||||
.collect();
|
||||
crate::entities::insert::append_insert_attribute_wires(
|
||||
&mut wires,
|
||||
document,
|
||||
ins,
|
||||
h,
|
||||
sel,
|
||||
ins_color,
|
||||
ins_pat_len,
|
||||
ins_pat,
|
||||
ins_lw_px,
|
||||
bg_color,
|
||||
is_xref,
|
||||
pslt_factor,
|
||||
anno_scale,
|
||||
);
|
||||
wires.push(marker);
|
||||
return wires;
|
||||
}
|
||||
|
||||
let aabb = entity_aabb(e);
|
||||
|
||||
// Text-specific LOD ladder, keyed off the entity's glyph height in
|
||||
// pixels (anno-scaled):
|
||||
// < 1 px → baseline line in the text's color (text-here hint)
|
||||
// 1–5 px → greeked OBB rect in the text's color
|
||||
// ≥ 5 px → full per-glyph stroke tessellation
|
||||
//
|
||||
// Applies to every entity that is "primarily a piece of text" — Text,
|
||||
// MText, ATTDEF, ATTRIB, Tolerance — so far-out drawings don't pay the
|
||||
// full glyph-tessellation cost. Composite entities (Dimension, Table,
|
||||
// MultiLeader) carry non-text geometry and have their own LOD paths.
|
||||
if let Some(wpp) = world_per_pixel {
|
||||
let text_height: Option<f64> = match e {
|
||||
EntityType::Text(t) => Some(t.height * anno_scale as f64),
|
||||
EntityType::MText(m) => Some(m.height * anno_scale as f64),
|
||||
EntityType::AttributeDefinition(a) => Some(a.height * anno_scale as f64),
|
||||
EntityType::AttributeEntity(a) => Some(a.height * anno_scale as f64),
|
||||
EntityType::Tolerance(t) => {
|
||||
// Tolerance text_height defaults to 0.18 from creation; treat
|
||||
// 0 as missing and fall back to the AutoCAD default so the
|
||||
// pixel check still kicks in for legitimately tiny dimensions.
|
||||
let raw = if t.text_height > 0.0 { t.text_height } else { 2.5 };
|
||||
Some(raw * anno_scale as f64)
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
if let Some(h_world) = text_height {
|
||||
let h_px = (h_world as f32) / wpp;
|
||||
// Wrap-expanded line count for MText (Text = 1).
|
||||
let n_lines = match e {
|
||||
EntityType::MText(m) => {
|
||||
crate::entities::text_support::mtext_line_count(m, document, anno_scale)
|
||||
}
|
||||
_ => 1,
|
||||
};
|
||||
if h_px < 1.0 {
|
||||
let pts = crate::entities::text_support::text_baseline_points(e, anno_scale, n_lines);
|
||||
if pts.len() < 2 {
|
||||
return vec![];
|
||||
}
|
||||
// Skip the baseline too if the line itself projects under
|
||||
// 2 px (e.g. a 1-char text seen edge-on). All wrap lines
|
||||
// share the same baseline length, so the first segment is
|
||||
// a representative sample.
|
||||
let dx = pts[1][0] - pts[0][0];
|
||||
let dy = pts[1][1] - pts[0][1];
|
||||
let len_px = (dx * dx + dy * dy).sqrt() / wpp;
|
||||
if len_px < 2.0 {
|
||||
// Text projects to under 2 px — fall back to the
|
||||
// generic LOD stub so the entity stays visible /
|
||||
// selectable. #19. Text is 2-D in the XY plane so
|
||||
// z_min = z_max = 0 keeps the historical behaviour.
|
||||
return vec![lod_stub_wire(
|
||||
h.value().to_string(),
|
||||
entity_color,
|
||||
sel,
|
||||
aci,
|
||||
aabb,
|
||||
0.0,
|
||||
0.0,
|
||||
)];
|
||||
}
|
||||
return vec![WireModel {
|
||||
name: h.value().to_string(),
|
||||
points: pts,
|
||||
points_low: Vec::new(),
|
||||
color: entity_color,
|
||||
selected: sel,
|
||||
aci,
|
||||
pattern_length: 0.0,
|
||||
pattern: [0.0; 8],
|
||||
line_weight_px: 1.0,
|
||||
snap_pts: vec![],
|
||||
tangent_geoms: vec![],
|
||||
key_vertices: vec![],
|
||||
aabb,
|
||||
plinegen: true,
|
||||
vp_scissor: None,
|
||||
fill_tris: vec![],
|
||||
fill_tris_low: Vec::new(),
|
||||
}];
|
||||
}
|
||||
if h_px < 5.0 && aabb != WireModel::UNBOUNDED_AABB {
|
||||
let fill_tris = crate::entities::text_support::text_greek_obb_tris(e, anno_scale, n_lines);
|
||||
if fill_tris.is_empty() {
|
||||
// Text greek fallback: also 2-D, keep stub at z=0.
|
||||
return vec![lod_stub_wire(
|
||||
h.value().to_string(),
|
||||
entity_color,
|
||||
sel,
|
||||
aci,
|
||||
aabb,
|
||||
0.0,
|
||||
0.0,
|
||||
)];
|
||||
}
|
||||
// Greek text renders via the face3d fill batch, which colours
|
||||
// each tri with `wire.color`. Bake the selected colour in so
|
||||
// a selected text stays highlighted across the LOD boundary.
|
||||
// hit_test's AABB fallback handles window / crossing. #19.
|
||||
let fill_color = if sel { WireModel::SELECTED } else { entity_color };
|
||||
return vec![WireModel {
|
||||
name: h.value().to_string(),
|
||||
points: vec![],
|
||||
points_low: Vec::new(),
|
||||
color: fill_color,
|
||||
selected: sel,
|
||||
aci,
|
||||
pattern_length: 0.0,
|
||||
pattern: [0.0; 8],
|
||||
line_weight_px: 1.0,
|
||||
snap_pts: vec![],
|
||||
tangent_geoms: vec![],
|
||||
key_vertices: vec![],
|
||||
aabb,
|
||||
plinegen: true,
|
||||
vp_scissor: None,
|
||||
fill_tris,
|
||||
fill_tris_low: Vec::new(),
|
||||
}];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut bases = convert::tessellate::tessellate(
|
||||
document,
|
||||
h,
|
||||
e,
|
||||
sel,
|
||||
entity_color,
|
||||
pattern_length,
|
||||
pattern,
|
||||
line_weight_px,
|
||||
anno_scale,
|
||||
world_per_pixel,
|
||||
);
|
||||
for b in &mut bases {
|
||||
b.aci = aci;
|
||||
b.aabb = aabb;
|
||||
}
|
||||
|
||||
// Complex linetypes (with embedded shapes / text) expand the *base*
|
||||
// polyline along its tangent. Text-type entities never have a complex
|
||||
// linetype assigned, so we only consult the first wire here — multi-wire
|
||||
// returns come exclusively from MTEXT colour splits which can't trigger
|
||||
// this path.
|
||||
if let Some(clt) = crate::linetypes::complex_lt(lt_name) {
|
||||
if let Some(base) = bases.first() {
|
||||
let mut wires = text::complex_lt::apply_along(
|
||||
&base.name,
|
||||
&base.points,
|
||||
clt,
|
||||
(lt_scale * pslt_factor).max(1e-4),
|
||||
entity_color,
|
||||
sel,
|
||||
base.line_weight_px,
|
||||
);
|
||||
if !wires.is_empty() {
|
||||
for w in &mut wires {
|
||||
w.aabb = aabb;
|
||||
}
|
||||
return wires;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bases
|
||||
}
|
||||
|
||||
/// Build the 4 OBB corners (CCW: bl, br, tr, tl) of a Text / MText entity
|
||||
/// in its **native frame** — for top-level entities this is world coords,
|
||||
/// for block-defn subs it's block-local. No offset/transform applied.
|
||||
/// Width is approximated from glyph height × character count (TEXT) or
|
||||
/// from `rectangle_width` (MTEXT). Returns `None` for non-text entities.
|
||||
///
|
||||
/// `mtext_lines_override` lets the caller plug in a wrap-aware line count
|
||||
/// (from `text_support::mtext_line_count`). Without it, MText's OBB
|
||||
/// height collapses to a single line when the file omits `rectangle_height`,
|
||||
/// which makes downstream per-line LOD math degenerate.
|
||||
|
||||
/// Build a "low-LOD stub" wire for an entity that would otherwise be culled
|
||||
/// to nothing — the entity's AABB diagonal as a 2-point segment, plus the
|
||||
/// AABB itself so window / crossing selection picks the entity up. The
|
||||
/// stored `selected` flag tracks across zoom levels so highlight visuals
|
||||
/// don't disappear when the LOD level changes. See #19.
|
||||
fn lod_stub_wire(
|
||||
name: String,
|
||||
color: [f32; 4],
|
||||
selected: bool,
|
||||
aci: u8,
|
||||
aabb: [f32; 4],
|
||||
z_min: f32,
|
||||
z_max: f32,
|
||||
) -> WireModel {
|
||||
let [ax, ay, bx, by] = aabb;
|
||||
let cx = (ax + bx) * 0.5;
|
||||
let cy = (ay + by) * 0.5;
|
||||
let cz = (z_min + z_max) * 0.5;
|
||||
// Mirror what tessellate.rs does for the non-stub paths: bake the
|
||||
// selection-highlight colour into the wire so a re-tessellate triggered
|
||||
// by a zoom-induced LOD change keeps the entity highlighted. Without
|
||||
// this swap the wire's `selected` flag is true but its colour stays at
|
||||
// the entity's own hue, so the user sees the highlight vanish at the
|
||||
// LOD boundary. #19.
|
||||
let stored_color = if selected { WireModel::SELECTED } else { color };
|
||||
WireModel {
|
||||
name,
|
||||
// Diagonal of the entity's 3D AABB so depth tests against
|
||||
// shaded / hidden-line geometry are correct — the stub doesn't
|
||||
// flatten to z=0 and pop in front of objects that sit at a
|
||||
// different elevation. 2D entities (text fallbacks) pass
|
||||
// z_min = z_max = 0 to keep the historical behaviour.
|
||||
points: vec![[ax, ay, z_min], [bx, by, z_max]],
|
||||
points_low: Vec::new(),
|
||||
color: stored_color,
|
||||
selected,
|
||||
aci,
|
||||
pattern_length: 0.0,
|
||||
pattern: [0.0; 8],
|
||||
line_weight_px: 1.0,
|
||||
snap_pts: vec![],
|
||||
tangent_geoms: vec![],
|
||||
key_vertices: vec![[cx as f64, cy as f64, cz as f64]],
|
||||
aabb,
|
||||
plinegen: true,
|
||||
vp_scissor: None,
|
||||
fill_tris: vec![],
|
||||
fill_tris_low: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Sub-pixel LOD stub for 3D entities. Emits the entity's 3D AABB as a
|
||||
/// 12-edge cube so the geometry occupies the same screen footprint and
|
||||
/// depth range as the full tessellation, just with a tiny constant cost
|
||||
/// (12 line segments). Without this, the diagonal stub used by
|
||||
/// `lod_stub_wire` cuts off at two opposite bbox corners and drifts
|
||||
/// visibly when the camera crosses the LOD threshold.
|
||||
fn lod_stub_wire_3d(
|
||||
name: String,
|
||||
color: [f32; 4],
|
||||
selected: bool,
|
||||
aci: u8,
|
||||
aabb: [f32; 4],
|
||||
z_min: f32,
|
||||
z_max: f32,
|
||||
) -> WireModel {
|
||||
let [x0, y0, x1, y1] = aabb;
|
||||
let (z0, z1) = if z_min <= z_max { (z_min, z_max) } else { (z_max, z_min) };
|
||||
let p = [
|
||||
[x0, y0, z0], [x1, y0, z0], [x1, y1, z0], [x0, y1, z0],
|
||||
[x0, y0, z1], [x1, y0, z1], [x1, y1, z1], [x0, y1, z1],
|
||||
];
|
||||
// 12 edges = 4 bottom-face + 4 top-face + 4 vertical connectors.
|
||||
const EDGES: [(usize, usize); 12] = [
|
||||
(0, 1), (1, 2), (2, 3), (3, 0),
|
||||
(4, 5), (5, 6), (6, 7), (7, 4),
|
||||
(0, 4), (1, 5), (2, 6), (3, 7),
|
||||
];
|
||||
let mut points: Vec<[f32; 3]> = Vec::with_capacity(EDGES.len() * 3);
|
||||
for (a, b) in EDGES {
|
||||
if !points.is_empty() {
|
||||
points.push([f32::NAN; 3]);
|
||||
}
|
||||
points.push(p[a]);
|
||||
points.push(p[b]);
|
||||
}
|
||||
let stored_color = if selected { WireModel::SELECTED } else { color };
|
||||
WireModel {
|
||||
name,
|
||||
points,
|
||||
points_low: Vec::new(),
|
||||
color: stored_color,
|
||||
selected,
|
||||
aci,
|
||||
pattern_length: 0.0,
|
||||
pattern: [0.0; 8],
|
||||
line_weight_px: 1.0,
|
||||
snap_pts: vec![],
|
||||
tangent_geoms: vec![],
|
||||
// No `key_vertices` — Face3DGpu requires 4 corners to emit a
|
||||
// fill quad, and we don't want this stub painted as a solid
|
||||
// face. The wire pass still draws its 12 edges.
|
||||
key_vertices: vec![],
|
||||
aabb,
|
||||
plinegen: true,
|
||||
vp_scissor: None,
|
||||
fill_tris: vec![],
|
||||
fill_tris_low: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Tessellate each visible AttributeEntity attached to an Insert and append
|
||||
/// the resulting wires. AttributeEntity positions are already in WCS — the
|
||||
/// INSERT only stamps the geometry once, attribute text sits at the world
|
||||
/// position recorded on each ATTRIB. See #20.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) fn entity_aabb(e: &acadrust::EntityType) -> [f32; 4] {
|
||||
let bbox = e.as_entity().bounding_box();
|
||||
let min_x = (bbox.min.x) as f32;
|
||||
let min_y = (bbox.min.y) as f32;
|
||||
let max_x = (bbox.max.x) as f32;
|
||||
let max_y = (bbox.max.y) as f32;
|
||||
// A degenerate box (min == max == 0) means bounding_box() returned Default —
|
||||
// use UNBOUNDED so the wire is never wrongly pre-rejected.
|
||||
if min_x == max_x && min_y == max_y {
|
||||
return WireModel::UNBOUNDED_AABB;
|
||||
}
|
||||
[min_x, min_y, max_x, max_y]
|
||||
}
|
||||
|
||||
/// AABB of `e` in WCS f64 (no world_offset subtraction). `None` for
|
||||
/// entities whose `bounding_box()` returned the degenerate default
|
||||
/// (which `entity_aabb` collapses to `UNBOUNDED_AABB`). Quadtree
|
||||
/// indexing uses this so changing `world_offset` doesn't invalidate
|
||||
/// the index.
|
||||
pub(crate) fn entity_world_aabb_f64(e: &acadrust::EntityType) -> Option<[f64; 4]> {
|
||||
let bbox = e.as_entity().bounding_box();
|
||||
let (xmin, ymin, xmax, ymax) = (bbox.min.x, bbox.min.y, bbox.max.x, bbox.max.y);
|
||||
if xmin == xmax && ymin == ymax {
|
||||
return None;
|
||||
}
|
||||
if !xmin.is_finite() || !ymin.is_finite() || !xmax.is_finite() || !ymax.is_finite() {
|
||||
return None;
|
||||
}
|
||||
Some([xmin, ymin, xmax, ymax])
|
||||
}
|
||||
|
||||
/// True if `e` is a type the quadtree should skip. `Insert` and
|
||||
/// `Viewport` are sized only after extra transformation; tessellation
|
||||
/// already handles them via dedicated code paths. `Block`/`BlockEnd`
|
||||
/// are block-defn sentinels with no geometry.
|
||||
pub(crate) fn is_unindexable_entity(e: &acadrust::EntityType) -> bool {
|
||||
use acadrust::EntityType as E;
|
||||
matches!(
|
||||
e,
|
||||
E::Insert(_) | E::Viewport(_) | E::Block(_) | E::BlockEnd(_)
|
||||
)
|
||||
}
|
||||
Loading…
Reference in a new issue