Merge main into codex/graphic-attributes-cleanup

Conflicts were additions on both sides, kept together: the Count palette
and Sheet Set toggles next to the Graphic Attributes toggle in the ribbon,
main's new locale groups and catalog entries next to graphic-attributes,
and module declarations. The palette code follows main's crate renames
(acadrust -> codec, cadkernel -> kernel).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01VgkJN8DEojuPTumAXLHotu
This commit is contained in:
Claude committed 2026-10-07 20:11:05 +00:00
commit 7a03e1c43c
625 files changed
+104397 -28053

No files matched your search

+390
View File
@@ -0,0 +1,390 @@
//! ATTDEF (dialog), -ATTDEF (command line), AFLAGS, and the Edit Attribute
//! Definition dialog opened by double-click / TEXTEDIT / DDEDIT.
use crate::app::{Message, OpenCADStudio};
use crate::command::CadCommand;
use crate::modules::draw::draw::attdef::{
self as attdef, AttdefCommand, AttdefPickCommand, AttdefPlaceCommand, AttdefSpec, Justify,
};
use crate::ui::window::attdef_dialog::{
tag_error, AttdefDialogMsg, AttdefDialogState, AttdefEditState,
};
use iced::Task;
fn short(v: f64) -> String {
let t = format!("{v:.4}");
t.trim_end_matches('0').trim_end_matches('.').to_string()
}
impl OpenCADStudio {
pub(super) fn dispatch_attdef(&mut self, cmd: &str, i: usize) -> Option<Task<Message>> {
if matches!(cmd, "ATTDEF" | "-ATTDEF") {
// Placement measures the tag with its style's font.
crate::entities::common::set_fixed_text_heights(&self.tabs[i].scene.document);
}
match cmd {
"ATTDEF" => {
self.open_attdef_dialog(i);
Some(Task::none())
}
"-ATTDEF" => {
let defaults =
crate::scene::creation_style::current_text_defaults(&self.tabs[i].scene.document);
let styles: Vec<_> = self.tabs[i].scene.document.text_styles.iter().cloned().collect();
let command = AttdefCommand::new(
"-ATTDEF",
defaults.style_name,
defaults.height,
defaults.width_factor,
defaults.oblique_angle,
styles,
);
self.reset_command_start_state(i);
self.command_line.push_output(&command.intro());
self.command_line.push_info(&command.prompt());
self.tabs[i].active_cmd = Some(Box::new(command));
self.push_ucs_to_cmd(i);
Some(Task::none())
}
"ATTMODE0" => self.dispatch_draw("ATTDISP OFF", i),
"ATTMODE1" => self.dispatch_draw("ATTDISP NORMAL", i),
"ATTMODE2" => self.dispatch_draw("ATTDISP ON", i),
"_ATTDEF_VALUE" => {
let value = attdef::session().editor_value.take();
if let Some(state) = self.attdef_dialog.as_mut() {
if let Some(value) = value {
state.default = value;
state.field = None;
}
self.active_modal = Some(crate::app::ModalKind::AttDef);
}
Some(Task::none())
}
cmd if cmd == "_ATTDEF_PICKED" || cmd.starts_with("_ATTDEF_PICKED ") => {
let mut parts = cmd.split_whitespace().skip(1);
if let (Some(kind), Some(value), Some(state)) =
(parts.next(), parts.next(), self.attdef_dialog.as_mut())
{
match kind {
"H" => state.height = value.to_string(),
"R" => state.rotation = value.to_string(),
_ => state.width = value.to_string(),
}
}
if self.attdef_dialog.is_some() {
self.active_modal = Some(crate::app::ModalKind::AttDef);
}
Some(Task::none())
}
_ => self.dispatch_aflags(cmd),
}
}
/// AFLAGS: the session's attribute modes (0–63, default 16).
fn dispatch_aflags(&mut self, cmd: &str) -> Option<Task<Message>> {
let rest = cmd.strip_prefix("SETVAR ").map(str::trim).unwrap_or(cmd);
let mut parts = rest.splitn(2, char::is_whitespace);
if !parts.next().unwrap_or("").eq_ignore_ascii_case("AFLAGS") {
return None;
}
let current = attdef::session().aflags;
match parts.next().map(str::trim).filter(|v| !v.is_empty()) {
None => {
self.command_line
.push_output(&format!("Enter new value for AFLAGS <{current}>:"));
self.pending_setvar = Some("AFLAGS".to_string());
}
Some(v) => match v.parse::<u8>().ok().filter(|v| *v <= 63) {
Some(v) => attdef::session().aflags = v,
None => {
self.command_line.push_error("Requires an integer between 0 and 63.");
self.command_line
.push_output(&format!("Enter new value for AFLAGS <{current}>:"));
self.pending_setvar = Some("AFLAGS".to_string());
}
},
}
Some(Task::none())
}
fn open_attdef_dialog(&mut self, i: usize) {
let document = &self.tabs[i].scene.document;
let defaults = crate::scene::creation_style::current_text_defaults(document);
let mut styles: Vec<String> = document
.text_styles
.iter()
.filter(|s| !s.name.is_empty())
.map(|s| s.name.clone())
.collect();
styles.sort_by_key(|s| s.to_lowercase());
let session = attdef::session();
self.attdef_dialog = Some(AttdefDialogState {
aflags: session.aflags,
annotative: session.annotative,
tag: String::new(),
prompt: String::new(),
default: String::new(),
justify: Justify::Left,
style: defaults.style_name,
styles,
height: short(defaults.height),
rotation: "0".into(),
width: "0".into(),
on_screen: session.on_screen,
coords: ["0".into(), "0".into(), "0".into()],
align_below: false,
can_align_below: session.last.is_some(),
error: None,
field: None,
});
drop(session);
self.tabs[i].active_cmd = None;
self.active_modal = Some(crate::app::ModalKind::AttDef);
}
/// The Edit Attribute Definition dialog for `handle`.
pub(in crate::app) fn open_attdef_edit(&mut self, handle: codec::Handle) {
let i = self.active_tab;
let Some(codec::EntityType::AttributeDefinition(a)) = self.tabs[i].scene.document.get_entity(handle)
else {
return;
};
self.attdef_edit = Some(AttdefEditState {
handle,
tag: a.tag.clone(),
prompt: a.prompt.clone(),
default: a.default_value.clone(),
constant: a.flags.constant,
error: None,
field: None,
field_changed: false,
});
self.active_modal = Some(crate::app::ModalKind::AttDefEdit);
}
pub(in crate::app) fn on_attdef_dialog(&mut self, m: AttdefDialogMsg) -> Task<Message> {
let i = self.active_tab;
if let Some(edit) = self.attdef_edit.as_mut() {
match m {
AttdefDialogMsg::EditTag(v) => edit.tag = v,
AttdefDialogMsg::EditPrompt(v) => edit.prompt = v,
AttdefDialogMsg::EditDefault(v) => {
edit.default = v;
edit.field = None;
edit.field_changed = true;
}
AttdefDialogMsg::EditInsertField => {
self.active_modal = None;
self.open_field_dialog(crate::ui::window::field_dialog::FieldTarget::AttdefEdit);
}
AttdefDialogMsg::Help => self.command_line.push_info(
crate::t!("Changes the tag, prompt and default value of an attribute definition.")
.as_ref(),
),
AttdefDialogMsg::EditOk => return self.attdef_edit_ok(i),
AttdefDialogMsg::DismissError => edit.error = None,
_ => {}
}
return Task::none();
}
let Some(state) = self.attdef_dialog.as_mut() else {
return Task::none();
};
match m {
AttdefDialogMsg::Mode(bit, on) => {
if on {
state.aflags |= bit;
} else {
state.aflags &= !bit;
}
}
AttdefDialogMsg::Annotative(v) => state.annotative = v,
AttdefDialogMsg::Tag(v) => state.tag = v,
AttdefDialogMsg::Prompt(v) => state.prompt = v,
AttdefDialogMsg::Default(v) => {
state.default = v;
state.field = None;
}
AttdefDialogMsg::InsertField => {
self.active_modal = None;
self.open_field_dialog(crate::ui::window::field_dialog::FieldTarget::AttdefDefault);
return Task::none();
}
AttdefDialogMsg::Justify(c) => state.justify = c.0,
AttdefDialogMsg::Style(v) => state.style = v,
AttdefDialogMsg::Height(v) => state.height = v,
AttdefDialogMsg::Rotation(v) => state.rotation = v,
AttdefDialogMsg::Width(v) => state.width = v,
AttdefDialogMsg::OnScreen(v) => state.on_screen = v,
AttdefDialogMsg::Coord(k, v) => state.coords[k] = v,
AttdefDialogMsg::AlignBelow(v) => state.align_below = v,
AttdefDialogMsg::PickHeight | AttdefDialogMsg::PickRotation | AttdefDialogMsg::PickWidth => {
let (kind, current) = match m {
AttdefDialogMsg::PickHeight => ("H", state.height.clone()),
AttdefDialogMsg::PickRotation => ("R", state.rotation.clone()),
_ => ("W", state.width.clone()),
};
// Keep the dialog's fields; it reopens when the value is in.
self.active_modal = None;
let command = AttdefPickCommand::new(kind, current);
self.command_line.push_info(&command.prompt());
self.tabs[i].active_cmd = Some(Box::new(command));
self.push_ucs_to_cmd(i);
return self.focus_cmd_input();
}
AttdefDialogMsg::Help => self.command_line.push_info(
crate::t!("Defines an attribute: a tag, prompt and default value placed as text in a block definition.")
.as_ref(),
),
AttdefDialogMsg::Ok => return self.attdef_dialog_ok(i),
AttdefDialogMsg::DismissError => state.error = None,
AttdefDialogMsg::EditValue => {
// The dialog waits while the multi-line editor collects the value.
let initial = state.default.clone();
let height = state.height.trim().parse::<f64>().ok().filter(|h| *h > 0.0).unwrap_or(2.5);
self.active_modal = None;
attdef::session().editor_value = None;
self.tabs[i].active_cmd = Some(Box::new(attdef::AttdefValueCommand));
let pos = self.tabs[i].scene.camera.borrow().target;
return self.apply_cmd_result(crate::command::CmdResult::SuspendForMTextInput {
pos,
initial,
height,
});
}
_ => {}
}
Task::none()
}
/// Show a validation message in the dialog; it stays open.
fn attdef_dialog_error(&mut self, error: &str) -> Task<Message> {
if let Some(state) = self.attdef_dialog.as_mut() {
state.error = Some(crate::t!(error).into_owned());
}
Task::none()
}
fn attdef_dialog_ok(&mut self, i: usize) -> Task<Message> {
let Some(state) = self.attdef_dialog.clone() else {
return Task::none();
};
if let Some(error) = tag_error(&state.tag) {
return self.attdef_dialog_error(error);
}
let number = |s: &str| s.trim().parse::<f64>().ok().filter(|v| v.is_finite());
let height = match number(&state.height) {
Some(v) if v > 0.0 => v,
_ => {
return self.attdef_dialog_error("Value must be positive and nonzero.");
}
};
let Some(rotation) = number(&state.rotation) else {
return self.attdef_dialog_error("Requires valid numeric angle or second point.");
};
let width = number(&state.width).unwrap_or(0.0).max(0.0);
let defaults =
crate::scene::creation_style::current_text_defaults(&self.tabs[i].scene.document);
let spec = AttdefSpec {
aflags: state.aflags,
annotative: state.annotative,
tag: state.tag.clone(),
prompt: state.prompt.clone(),
value: state.default.clone(),
justify: state.justify,
style: state.style.clone(),
height,
rotation: rotation.to_radians(),
width_factor: defaults.width_factor,
oblique_angle: defaults.oblique_angle,
boundary_width: width,
line_spacing: 1.0,
field: state.field.clone(),
};
self.attdef_dialog = None;
self.close_active_modal();
{
let mut session = attdef::session();
session.aflags = spec.aflags;
session.annotative = spec.annotative;
session.on_screen = state.on_screen;
}
// The choice is kept with the user settings, as the reference keeps it.
self.persist_settings_if_changed();
self.reset_command_start_state(i);
if state.align_below {
let previous = attdef::session().last.clone();
if let Some(previous) = previous {
let entity = attdef::build_below(&spec, &previous);
self.tabs[i].active_cmd = Some(Box::new(AttdefPlaceCommand::new(spec.clone())));
attdef::remember(&spec, &entity);
return self.apply_cmd_result(crate::command::CmdResult::CommitAndExit(entity));
}
}
let plane = if self.tabs[i].editing_model_space() {
self.tabs[i].ucs_xform().working_plane()
} else {
crate::command::WorkingPlane::default()
};
let mut command = AttdefPlaceCommand::new(spec);
command.set_working_plane(plane);
if state.on_screen {
self.command_line.push_info(&command.prompt());
self.tabs[i].active_cmd = Some(Box::new(command));
return self.focus_cmd_input();
}
let local = glam::DVec3::new(
number(&state.coords[0]).unwrap_or(0.0),
number(&state.coords[1]).unwrap_or(0.0),
number(&state.coords[2]).unwrap_or(0.0),
);
let result = command.on_point(plane.to_world(local));
self.tabs[i].active_cmd = Some(Box::new(command));
self.apply_cmd_result(result)
}
fn attdef_edit_ok(&mut self, i: usize) -> Task<Message> {
let Some(state) = self.attdef_edit.clone() else {
return Task::none();
};
if let Some(error) = tag_error(&state.tag) {
if let Some(edit) = self.attdef_edit.as_mut() {
edit.error = Some(crate::t!(error).into_owned());
}
return Task::none();
}
self.attdef_edit = None;
self.close_active_modal();
let changed = matches!(
self.tabs[i].scene.document.get_entity(state.handle),
Some(codec::EntityType::AttributeDefinition(a))
if a.tag != attdef::normalize_tag(&state.tag) || a.prompt != state.prompt || a.default_value != state.default
) || state.field_changed;
if changed && !self.reject_locked_edit(i, state.handle) {
self.push_undo_snapshot(i, "DDEDIT");
if let Some(codec::EntityType::AttributeDefinition(a)) =
self.tabs[i].scene.document.get_entity_mut(state.handle)
{
a.tag = attdef::normalize_tag(&state.tag);
if !a.flags.constant {
a.prompt = state.prompt.clone();
}
a.default_value = state.default.clone();
}
if state.field_changed {
crate::entities::field::set_text_field(
&mut self.tabs[i].scene.document,
state.handle,
state.field.clone(),
&state.default,
);
}
self.tabs[i]
.scene
.bump_entities(&[(state.handle, crate::scene::ChangeKind::Modified)]);
self.tabs[i].dirty = true;
self.refresh_properties();
}
self.post_editor_closed(changed)
}
}
+224 -84
View File
@@ -34,18 +34,12 @@ impl OpenCADStudio {
pub(in crate::app) fn copy_entities_to_clipboard(
&mut self,
i: usize,
handles: &[acadrust::Handle],
handles: &[codec::Handle],
base: glam::DVec3,
) -> usize {
let (entities, deps) = {
let document = &self.tabs[i].scene.document;
let entities: Vec<_> = handles
.iter()
.filter_map(|&handle| document.get_entity(handle).cloned())
.collect();
let deps = super::super::ClipboardDeps::capture(document, &entities);
(entities, deps)
};
// Clone + dep-capture live in the shared kernel; storage stays here.
let (entities, deps) =
super::super::command_driver::copy_to_clipboard_kernel(&self.tabs[i].scene.document, handles);
let count = entities.len();
self.clipboard_base = base;
self.clipboard = entities;
@@ -119,7 +113,7 @@ impl OpenCADStudio {
.collect();
if nums.len() >= 2 {
let z = nums.get(2).copied().unwrap_or(0.0);
let pt = acadrust::types::Vector3::new(nums[0], nums[1], z);
let pt = codec::types::Vector3::new(nums[0], nums[1], z);
let is_paper = self.tabs[i].scene.current_layout != "Model";
self.push_undo_snapshot(i, "BASE");
if is_paper {
@@ -495,25 +489,50 @@ impl OpenCADStudio {
self.active_modal = Some(crate::app::ModalKind::WriteBlock);
}
// INSERT opens the Blocks palette on the drawing's blocks;
// -INSERT runs on the command line.
"INSERT" => {
self.command_line.push_output("*Insert a block from the Blocks palette");
self.open_blocks_palette(Some(crate::ui::window::block_palette::Tab::Current));
}
"-INSERT" => {
use crate::command::CadCommand;
use crate::modules::insert::insert_block::InsertBlockCommand;
let blocks = self.tabs[i].scene.custom_block_names();
if blocks.is_empty() {
self.command_line.push_error(
crate::t!("No user-defined blocks found in this drawing.").as_ref(),
);
} else {
use crate::modules::insert::insert_block::InsertBlockCommand;
let ranked = self.ranked_block_names(&blocks);
let snapshot = self.block_usage_snapshot();
let cmd = InsertBlockCommand::new_with_usage(
ranked,
snapshot,
self.cliprompt_lines.clamp(0, 50) as u8,
);
self.command_line.push_info(&cmd.prompt());
let opts = cmd.options();
self.command_line.set_step_options(opts.clone());
self.tabs[i].active_cmd = Some(Box::new(cmd));
let ranked = self.ranked_block_names(&blocks);
let snapshot = self.block_usage_snapshot();
let cmd = InsertBlockCommand::classic(
ranked,
snapshot,
self.cliprompt_lines.clamp(0, 50) as u8,
self.block_catalog(),
self.insname.clone(),
);
self.command_line.push_info(&cmd.prompt());
self.command_line.set_step_options(cmd.options());
self.tabs[i].active_cmd = Some(Box::new(cmd));
}
// A drawing named at -INSERT's block prompt: brought in as a
// block, then placed.
cmd if cmd.starts_with("_-INSERTFILE ") => {
use crate::command::CadCommand;
use crate::modules::insert::insert_block::InsertBlockCommand;
let path = std::path::PathBuf::from(cmd["_-INSERTFILE ".len()..].trim().trim_matches('"'));
match self.import_file_as_block(path) {
Ok(name) => {
let units = self
.block_catalog()
.units
.remove(&name.to_ascii_uppercase())
.unwrap_or_default();
let preview = self.tabs[i].scene.block_preview_wires(&name);
let cmd = InsertBlockCommand::classic_for_block(name, preview, units);
self.command_line.push_info(&cmd.prompt());
self.tabs[i].active_cmd = Some(Box::new(cmd));
}
Err(e) => self.command_line.push_error(e.as_str()),
}
}
@@ -582,7 +601,7 @@ impl OpenCADStudio {
.iter()
.filter_map(|h| doc.get_entity(*h))
.filter_map(|e| match e {
acadrust::EntityType::AttributeDefinition(a) => {
codec::EntityType::AttributeDefinition(a) => {
Some((a.tag.clone(), a.default_value.clone()))
}
_ => None,
@@ -595,7 +614,7 @@ impl OpenCADStudio {
.document
.entities()
.filter_map(|entity| match entity {
acadrust::EntityType::Insert(insert)
codec::EntityType::Insert(insert)
if insert.block_name.eq_ignore_ascii_case(&block)
&& !self.tabs[i].scene.is_layer_locked(insert.common.handle) =>
{
@@ -613,7 +632,7 @@ impl OpenCADStudio {
let mut synced = 0usize;
let mut changes = Vec::new();
for handle in inserts {
let Some(acadrust::EntityType::Insert(ins)) =
let Some(codec::EntityType::Insert(ins)) =
self.tabs[i].scene.document.get_entity_mut(handle)
else {
continue;
@@ -627,7 +646,7 @@ impl OpenCADStudio {
.any(|a| a.tag.eq_ignore_ascii_case(tag))
{
ins.attributes
.push(acadrust::entities::AttributeEntity::new(
.push(codec::entities::AttributeEntity::new(
tag.clone(),
default.clone(),
));
@@ -673,15 +692,27 @@ impl OpenCADStudio {
);
}
// BLOCKPALETTE / BLOCKSPALETTE — toggle the docked Insert Block panel.
"BLOCKPALETTE" | "BLOCKSPALETTE" => {
self.show_block_palette ^= true;
if self.show_block_palette {
// Always open expanded so the panel is immediately usable;
// the user can still collapse it via the pin (Auto) button.
self.dock_expanded = Some(crate::ui::dock::PanelId::BlockPalette);
self.refresh_block_palette();
}
// BLOCKSPALETTE opens the Blocks palette; BLOCKSPALETTECLOSE
// closes it. `_BLOCKSPALETTE <tab>` opens it on a tab.
"BLOCKPALETTE" | "BLOCKSPALETTE" => self.open_blocks_palette(None),
"BLOCKSPALETTECLOSE" => {
self.show_block_palette = false;
self.block_palette.placing = None;
}
// A block picked in the ribbon gallery.
cmd if cmd.starts_with("_BLOCKINSERT ") => {
let name = cmd["_BLOCKINSERT ".len()..].trim().to_string();
return Some(self.palette_insert(crate::ui::window::block_palette::Item::Current(name), None));
}
cmd if cmd.starts_with("_BLOCKSPALETTE ") => {
use crate::ui::window::block_palette::Tab;
let tab = match cmd["_BLOCKSPALETTE ".len()..].trim() {
"RECENT" => Tab::Recent,
"FAVORITES" => Tab::Favorites,
"LIBRARIES" => Tab::Libraries,
_ => Tab::Current,
};
self.open_blocks_palette(Some(tab));
}
// ATTMAN / BATTMAN — the Block Attribute Manager. Rather than a
@@ -695,8 +726,114 @@ impl OpenCADStudio {
self.open_attedit_dialog();
}
"PDFATTACH" => {
return Some(Task::done(Message::PdfAttachPick));
"PDFIMPORT" => {
use crate::command::CadCommand;
let command = crate::modules::insert::pdf_import::PdfImportCommand::new();
self.command_line.push_info(&command.prompt());
self.tabs[i].active_cmd = Some(Box::new(command));
}
"_PDFIMPORTFILE" => {
return Some(Task::done(Message::PdfImportPick));
}
// A file named after it skips the picker.
cmd if cmd.starts_with("_PDFIMPORTFILE ") => {
let path = cmd["_PDFIMPORTFILE ".len()..].trim().trim_matches('"').to_string();
match std::fs::read(&path) {
Ok(bytes) => {
crate::scene::model::pdf_raster::register_source(&path, std::sync::Arc::new(bytes));
self.open_pdf_import_file(&path);
}
Err(_) => self.command_line.push_error(&format!("{path} not found.")),
}
}
"PDFCLIP" | "DWFCLIP" | "DGNCLIP" => {
use crate::command::CadCommand;
let kind = match cmd {
"DWFCLIP" => codec::entities::UnderlayType::Dwf,
"DGNCLIP" => codec::entities::UnderlayType::Dgn,
_ => codec::entities::UnderlayType::Pdf,
};
let command = crate::modules::insert::pdf_clip::PdfClipCommand::for_kind(kind);
self.command_line.push_info(&command.prompt());
self.tabs[i].active_cmd = Some(Box::new(command));
}
"POINTCLOUDCROP" | "POINTCLOUDUNCROP" => {
use crate::command::CadCommand;
use crate::modules::insert::pc_crop::PointCloudCropCommand;
let command =
if cmd == "POINTCLOUDUNCROP" { PointCloudCropCommand::uncrop() } else { PointCloudCropCommand::new() };
self.command_line.push_info(&command.prompt());
self.tabs[i].active_cmd = Some(Box::new(command));
}
"IMAGECLIP" => {
use crate::command::CadCommand;
let command = crate::modules::insert::pdf_clip::PdfClipCommand::image();
self.command_line.push_info(&command.prompt());
self.tabs[i].active_cmd = Some(Box::new(command));
}
"VPCLIP" => {
use crate::command::CadCommand;
let command = crate::modules::layout::mview::MviewCommand::vpclip_select();
self.command_line.push_info(&command.prompt());
self.tabs[i].active_cmd = Some(Box::new(command));
}
"CLIP" => {
use crate::command::CadCommand;
let command = crate::modules::insert::xclip::ClipCommand::new();
self.command_line.push_info(&command.prompt());
self.tabs[i].active_cmd = Some(Box::new(command));
}
// XCLIP takes block references chosen beforehand; others are
// left out.
"XCLIP" => {
use crate::command::CadCommand;
use crate::modules::insert::xclip::XclipCommand;
let inserts: Vec<codec::Handle> = self.tabs[i]
.scene
.selected_entities()
.iter()
.filter(|(_, e)| matches!(e, codec::EntityType::Insert(_)))
.map(|(h, _)| *h)
.collect();
let command = if inserts.is_empty() {
XclipCommand::new()
} else {
let clipped = inserts.iter().any(|h| {
crate::scene::pick::xclip::filter_handle(&self.tabs[i].scene.document, *h)
.is_some()
});
XclipCommand::for_inserts(inserts, clipped)
};
self.command_line.push_info(&command.prompt());
self.tabs[i].active_cmd = Some(Box::new(command));
}
// PDFATTACH picks the file; -PDFATTACH asks for it on the command
// line. A path after either names the file at once.
cmd if matches!(
cmd.split_whitespace().next().map(|v| v.to_ascii_uppercase()).as_deref(),
Some("PDFATTACH") | Some("-PDFATTACH")
) =>
{
use crate::command::CadCommand;
let (verb, rest) = cmd
.split_once(char::is_whitespace)
.map(|(verb, rest)| (verb.to_ascii_uppercase(), rest.trim()))
.unwrap_or_else(|| (cmd.to_ascii_uppercase(), ""));
if verb == "PDFATTACH" && rest.is_empty() {
return Some(Task::done(Message::PdfAttachPick));
}
let insunits = self.tabs[i].scene.document.header.insertion_units;
let mut command =
crate::modules::insert::pdf_attach::PdfAttachCommand::new(insunits);
if !rest.is_empty() {
if let Some(crate::command::CmdResult::ReportError(message)) =
command.on_text_input(rest)
{
self.command_line.push_error(&message);
}
}
self.command_line.push_info(&command.prompt());
self.tabs[i].active_cmd = Some(Box::new(command));
}
"XATTACH" => {
// Launch the file picker; XAttachPickResult will start the command.
@@ -942,7 +1079,7 @@ impl OpenCADStudio {
self.tabs[i].xref_unloaded.remove(key);
self.tabs[i].xref_stat_cache.remove(key);
}
let handles: rustc_hash::FxHashSet<acadrust::types::Handle> = self.tabs
let handles: rustc_hash::FxHashSet<codec::types::Handle> = self.tabs
[i]
.scene
.document
@@ -1529,7 +1666,7 @@ impl OpenCADStudio {
.selected_entities()
.iter()
.filter_map(|(_, e)| match e {
acadrust::EntityType::Insert(ins) => Some(ins.block_name.clone()),
codec::EntityType::Insert(ins) => Some(ins.block_name.clone()),
_ => None,
})
.collect();
@@ -1633,7 +1770,10 @@ mod tests {
app.dock_expanded == Some(crate::ui::dock::PanelId::BlockPalette),
"palette must open expanded, not as the collapsed bar"
);
// BLOCKSPALETTE only opens; BLOCKSPALETTECLOSE closes.
let _ = app.run_command_line("BLOCKSPALETTE");
assert!(app.show_block_palette);
let _ = app.run_command_line("BLOCKSPALETTECLOSE");
assert!(!app.show_block_palette);
}
@@ -1642,13 +1782,13 @@ mod tests {
let mut app = fresh_app();
app.automation_op(r#"{"op":"new"}"#);
let i = app.active_tab;
let mut line = acadrust::entities::Line::new();
line.start = acadrust::types::Vector3::ZERO;
line.end = acadrust::types::Vector3::new(10.0, 0.0, 0.0);
let mut line = codec::entities::Line::new();
line.start = codec::types::Vector3::ZERO;
line.end = codec::types::Vector3::new(10.0, 0.0, 0.0);
app.tabs[i]
.scene
.define_block_from_owned_entities(
vec![acadrust::EntityType::Line(line)],
vec![codec::EntityType::Line(line)],
"Widget",
glam::DVec3::ZERO,
)
@@ -1667,13 +1807,13 @@ mod tests {
app.refresh_block_palette();
assert!(app.block_palette.blocks.is_empty());
let i = app.active_tab;
let mut line = acadrust::entities::Line::new();
line.start = acadrust::types::Vector3::ZERO;
line.end = acadrust::types::Vector3::new(10.0, 0.0, 0.0);
let mut line = codec::entities::Line::new();
line.start = codec::types::Vector3::ZERO;
line.end = codec::types::Vector3::new(10.0, 0.0, 0.0);
app.tabs[i]
.scene
.define_block_from_owned_entities(
vec![acadrust::EntityType::Line(line)],
vec![codec::EntityType::Line(line)],
"Widget",
glam::DVec3::ZERO,
)
@@ -1738,7 +1878,7 @@ mod tests {
fn add_dwg_xref(app: &mut OpenCADStudio, name: &str, saved: &str) {
let i = app.active_tab;
let mut br = acadrust::tables::BlockRecord::new(name);
let mut br = codec::tables::BlockRecord::new(name);
br.flags.is_xref = true;
br.xref_path = saved.to_string();
br.handle = app.tabs[i].scene.document.allocate_handle();
@@ -1776,7 +1916,7 @@ mod tests {
#[test]
fn xref_overlay_on_image_errors() {
use acadrust::objects::{ImageDefinition, ObjectType};
use codec::objects::{ImageDefinition, ObjectType};
let mut app = fresh_app();
let i = app.active_tab;
let h = app.tabs[i].scene.document.allocate_handle();
@@ -1788,9 +1928,9 @@ mod tests {
.objects
.insert(h, ObjectType::ImageDefinition(def));
// Reference it so collect_entries lists it.
let mut img = acadrust::entities::RasterImage::new(
let mut img = codec::entities::RasterImage::new(
"img.png",
acadrust::types::Vector3::ZERO,
codec::types::Vector3::ZERO,
8.0,
8.0,
);
@@ -1798,7 +1938,7 @@ mod tests {
app.tabs[i]
.scene
.document
.add_entity(acadrust::EntityType::RasterImage(img))
.add_entity(codec::EntityType::RasterImage(img))
.unwrap();
let out = run_capture(&mut app, "XREF Overlay img.png");
assert!(
@@ -1812,7 +1952,7 @@ mod tests {
// CLI mirror of the palette F7 guard: reloading an image row reports
// the drawing-only error, leaves its unloaded flag untouched, and
// does not follow with a spurious no-match error.
use acadrust::objects::{ImageDefinition, ObjectType};
use codec::objects::{ImageDefinition, ObjectType};
let mut app = fresh_app();
let i = app.active_tab;
let h = app.tabs[i].scene.document.allocate_handle();
@@ -1823,9 +1963,9 @@ mod tests {
.document
.objects
.insert(h, ObjectType::ImageDefinition(def));
let mut img = acadrust::entities::RasterImage::new(
let mut img = codec::entities::RasterImage::new(
"img.png",
acadrust::types::Vector3::ZERO,
codec::types::Vector3::ZERO,
8.0,
8.0,
);
@@ -1833,7 +1973,7 @@ mod tests {
app.tabs[i]
.scene
.document
.add_entity(acadrust::EntityType::RasterImage(img))
.add_entity(codec::EntityType::RasterImage(img))
.unwrap();
app.tabs[i].current_path = Some(std::path::PathBuf::from("C:/Drawings/host.dwg"));
app.tabs[i].xref_unloaded.add(h.value());
@@ -1881,7 +2021,7 @@ mod tests {
#[test]
fn xref_detach_nested_guard_errors() {
use acadrust::tables::BlockRecord;
use codec::tables::BlockRecord;
// Host file on disk containing its own xref "INNER".
let dir = std::env::temp_dir().join(format!(
"ocs_xref_nested_{}_{}",
@@ -1892,7 +2032,7 @@ mod tests {
.as_nanos()
));
std::fs::create_dir_all(&dir).unwrap();
let mut host_doc = acadrust::CadDocument::new();
let mut host_doc = codec::CadDocument::new();
let mut inner = BlockRecord::new("INNER");
inner.flags.is_xref = true;
inner.xref_path = "inner.dwg".to_string();
@@ -1922,15 +2062,15 @@ mod tests {
.as_nanos()
));
std::fs::create_dir_all(&dir).unwrap();
let mut xref_doc = acadrust::CadDocument::new();
let mut xref_doc = codec::CadDocument::new();
xref_doc
.layers
.add(acadrust::tables::Layer::new("WALLS"))
.add(codec::tables::Layer::new("WALLS"))
.unwrap();
let mut line = acadrust::entities::Line::new();
let mut line = codec::entities::Line::new();
line.common.layer = "WALLS".to_string();
xref_doc
.add_entity(acadrust::EntityType::Line(line))
.add_entity(codec::EntityType::Line(line))
.unwrap();
let bytes = crate::io::save_to_bytes(&xref_doc, "dwg", xref_doc.version).unwrap();
let xref_path = dir.join("plan.dwg");
@@ -1960,12 +2100,12 @@ mod tests {
.as_nanos()
));
std::fs::create_dir_all(&dir).unwrap();
let mut xref_doc = acadrust::CadDocument::new();
let mut line = acadrust::entities::Line::new();
let mut xref_doc = codec::CadDocument::new();
let mut line = codec::entities::Line::new();
line.common.plotstyle_flags = 0b11;
line.common.plotstyle_handle = Some(xref_doc.allocate_handle());
xref_doc
.add_entity(acadrust::EntityType::Line(line))
.add_entity(codec::EntityType::Line(line))
.unwrap();
let bytes = crate::io::save_to_bytes(&xref_doc, "dwg", xref_doc.version).unwrap();
let xref_path = dir.join("plan.dwg");
@@ -1985,7 +2125,7 @@ mod tests {
#[test]
fn xref_bind_pdf_errors() {
use acadrust::objects::{ObjectType, UnderlayDefinition};
use codec::objects::{ObjectType, UnderlayDefinition};
let mut app = fresh_app();
let i = app.active_tab;
let h = app.tabs[i].scene.document.allocate_handle();
@@ -2006,7 +2146,7 @@ mod tests {
#[test]
fn xref_bind_nested_guard_errors() {
use acadrust::tables::BlockRecord;
use codec::tables::BlockRecord;
let dir = std::env::temp_dir().join(format!(
"ocs_xref_bind_nested_{}_{}",
std::process::id(),
@@ -2016,7 +2156,7 @@ mod tests {
.as_nanos()
));
std::fs::create_dir_all(&dir).unwrap();
let mut host_doc = acadrust::CadDocument::new();
let mut host_doc = codec::CadDocument::new();
let mut inner = BlockRecord::new("INNER");
inner.flags.is_xref = true;
inner.xref_path = "inner.dwg".to_string();
@@ -2200,15 +2340,15 @@ mod tests {
.as_nanos()
));
std::fs::create_dir_all(&dir).unwrap();
let mut xref_doc = acadrust::CadDocument::new();
let mut xref_doc = codec::CadDocument::new();
xref_doc
.layers
.add(acadrust::tables::Layer::new("WALLS"))
.add(codec::tables::Layer::new("WALLS"))
.unwrap();
let mut line = acadrust::entities::Line::new();
let mut line = codec::entities::Line::new();
line.common.layer = "WALLS".to_string();
xref_doc
.add_entity(acadrust::EntityType::Line(line))
.add_entity(codec::EntityType::Line(line))
.unwrap();
let bytes = crate::io::save_to_bytes(&xref_doc, "dwg", xref_doc.version).unwrap();
let xref_path = dir.join("plan.dwg");
@@ -2258,12 +2398,12 @@ mod tests {
let mut app = fresh_app();
app.automation_op(r#"{"op":"new"}"#);
let i = app.active_tab;
let mut line = acadrust::entities::Line::new();
line.end = acadrust::types::Vector3::new(10.0, 0.0, 0.0);
let mut line = codec::entities::Line::new();
line.end = codec::types::Vector3::new(10.0, 0.0, 0.0);
app.tabs[i]
.scene
.define_block_from_owned_entities(
vec![acadrust::EntityType::Line(line)],
vec![codec::EntityType::Line(line)],
"Widget",
glam::DVec3::ZERO,
)
@@ -2275,12 +2415,12 @@ mod tests {
app.tabs
.push(crate::app::document::DocumentTab::new_drawing(99));
let other = app.tabs.len() - 1;
let mut line = acadrust::entities::Line::new();
line.end = acadrust::types::Vector3::new(50.0, 0.0, 0.0);
let mut line = codec::entities::Line::new();
line.end = codec::types::Vector3::new(50.0, 0.0, 0.0);
app.tabs[other]
.scene
.define_block_from_owned_entities(
vec![acadrust::EntityType::Line(line)],
vec![codec::EntityType::Line(line)],
"Widget",
glam::DVec3::ZERO,
)
+125
View File
@@ -0,0 +1,125 @@
//! Block system variables. `BLOCKMRULIST`, `BLOCKREDEFINEMODE` and
//! `BLOCKNAVIGATE` are application preferences kept with the settings;
//! `EXPLMODE` lives in the drawing; `INSNAME` is the session's default block
//! name; `BLOCKSTATE` is read-only. Each answers to its bare name (prompting
//! for the value) and to `SETVAR NAME value`, with the reference's messages.
use crate::app::{Message, OpenCADStudio};
use iced::Task;
/// The variables this family answers for, for the command registry and the
/// `SETVAR ?` listing.
pub(super) const BLOCK_SYSVARS: &[&str] = &[
"BLOCKMRULIST",
"BLOCKREDEFINEMODE",
"BLOCKNAVIGATE",
"BLOCKSTATE",
"EXPLMODE",
"INSNAME",
];
/// The `SETVAR ?` line for this family.
pub(super) fn setvar_listing() -> String {
format!("SETVAR (block): {}", BLOCK_SYSVARS.join(" "))
}
/// EXPLMODE of the drawing (default 1).
pub(crate) fn explmode(document: &codec::CadDocument) -> i16 {
crate::io::drawing_variable(document, "EXPLMODE")
.and_then(|v| v.trim().parse().ok())
.unwrap_or(1)
}
impl OpenCADStudio {
pub(super) fn dispatch_blockvars(&mut self, cmd: &str, i: usize) -> Option<Task<Message>> {
let rest = cmd.strip_prefix("SETVAR ").map(str::trim).unwrap_or(cmd);
let mut parts = rest.splitn(2, char::is_whitespace);
let name = parts.next().unwrap_or("").to_ascii_uppercase();
if !BLOCK_SYSVARS.contains(&name.as_str()) {
return None;
}
let value = parts.next().map(str::trim).filter(|v| !v.is_empty());
match name.as_str() {
"BLOCKSTATE" => {
let state = self.tabs[i].refedit_session.is_some() as u8;
self.command_line.push_output(&format!("BLOCKSTATE = {state} (read only)"));
}
"INSNAME" | "BLOCKNAVIGATE" => {
let current = if name == "INSNAME" {
self.insname.clone()
} else {
self.block_navigate.clone()
};
match value {
Some(v) => {
let v = if v == "." { String::new() } else { v.trim_matches('"').to_string() };
if name == "INSNAME" {
self.insname = v;
} else {
self.block_navigate = if v.is_empty() { ".".to_string() } else { v };
self.persist_settings_if_changed();
}
}
None => {
self.command_line.push_output(&format!(
"Enter new value for {name}, or . for none <\"{current}\">:"
));
self.pending_setvar = Some(name);
}
}
}
_ => {
let (current, max) = match name.as_str() {
"BLOCKMRULIST" => (self.block_mru_list as i32, 100),
"BLOCKREDEFINEMODE" => (self.block_redefine_mode as i32, 2),
_ => (explmode(&self.tabs[i].scene.document) as i32, 1),
};
let ask = |app: &mut Self, name: String| {
app.command_line
.push_output(&format!("Enter new value for {name} <{current}>:"));
app.pending_setvar = Some(name);
};
match value {
None => ask(self, name),
Some(v) => match v.parse::<i32>() {
Err(_) => {
self.command_line.push_error("Requires an integer value.");
ask(self, name);
}
Ok(n) if !(0..=max).contains(&n) => {
self.command_line.push_error(&if max == 1 {
"Requires 0 or 1 only.".to_string()
} else {
format!("Requires an integer between 0 and {max}.")
});
ask(self, name);
}
Ok(n) => match name.as_str() {
"BLOCKMRULIST" => {
self.block_mru_list = n as u8;
self.trim_recent_blocks();
self.persist_settings_if_changed();
}
"BLOCKREDEFINEMODE" => {
self.block_redefine_mode = n as u8;
self.persist_settings_if_changed();
}
_ => {
if n != current {
self.push_undo_snapshot(i, "EXPLMODE");
crate::io::set_drawing_variable(
&mut self.tabs[i].scene.document,
"EXPLMODE",
&n.to_string(),
);
self.tabs[i].dirty = true;
}
}
},
},
}
}
}
Some(Task::none())
}
}
+862
View File
@@ -0,0 +1,862 @@
//! COUNT and its family: count mode (COUNT, COUNTAREA, COUNTCLOSE), the
//! Count palette (COUNTLIST, COUNTLISTCLOSE), count fields (COUNTFIELD,
//! UPDATEFIELD), count tables (COUNTTABLE) and the COUNT* variables.
use crate::app::{Message, OpenCADStudio};
use crate::command::CadCommand;
use crate::modules::annotate::count_cmd::{CountCommand, CountTableCommand, UpdateFieldCommand};
use crate::modules::annotate::field_cmd::FieldPlaceCommand;
use crate::ui::window::count_palette::{aci_rgba, CountMode, CountMsg, CountTarget, RowAction, AREA_LAYER};
use codec::count::{area_json, block_json, single_json, CountKey};
use codec::{EntityType, Handle};
use iced::Task;
/// The variables this family answers for.
pub(super) const COUNT_SYSVARS: &[&str] =
&["COUNTNUMBER", "COUNTSERVICE", "COUNTPALETTESTATE", "COUNTCOLOR", "COUNTERRORCOLOR"];
fn hex(h: Handle) -> String {
format!("{:X}", h.value())
}
fn parse_handle(s: &str) -> Option<Handle> {
u64::from_str_radix(s.trim(), 16).ok().map(Handle::new)
}
fn parse_xy(s: &str) -> Option<[f64; 2]> {
let (x, y) = s.split_once(',')?;
Some([x.trim().parse().ok()?, y.trim().parse().ok()?])
}
/// A field referring to `boundary` keeps the count area's polyline.
fn boundary_in_use(doc: &codec::CadDocument, boundary: Handle) -> bool {
let needle = format!("\"boundaryObjectHandle\":\"{}\"", hex(boundary));
doc.fields.values().any(|f| f.code.contains(&needle))
}
impl OpenCADStudio {
pub(super) fn dispatch_count(&mut self, cmd: &str, i: usize) -> Option<Task<Message>> {
let cmd = cmd.trim();
let (verb, rest) = cmd.split_once(char::is_whitespace).unwrap_or((cmd, ""));
let verb = verb.to_ascii_uppercase();
let rest = rest.trim();
if verb == "SETVAR" {
let (name, value) = rest.split_once(char::is_whitespace).unwrap_or((rest, ""));
let name = name.to_ascii_uppercase();
if !COUNT_SYSVARS.contains(&name.as_str()) {
return None;
}
self.count_sysvar(i, &name, Some(value.trim()).filter(|v| !v.is_empty()));
return Some(Task::none());
}
if COUNT_SYSVARS.contains(&verb.as_str()) {
self.count_sysvar(i, &verb, Some(rest).filter(|v| !v.is_empty()));
return Some(Task::none());
}
match verb.as_str() {
"COUNT" => {
let preselected = self.tabs[i].scene.selected_handles_in_order();
let current = matches!(rest.trim_start_matches('_').to_ascii_uppercase().as_str(), "C");
if current && !preselected.is_empty() {
// Count Selection: the selection is the target.
let found = preselected.len();
self.command_line.push_output(&crate::tf!("{found} found"));
let handles: Vec<String> = preselected.iter().map(|h| hex(*h)).collect();
self.tabs[i].scene.deselect_all();
return Some(self.dispatch_command(&format!("_COUNTRUN C T {}", handles.join(","))));
}
// Plain COUNT asks for its targets itself.
self.tabs[i].scene.deselect_all();
let command = if current { CountCommand::with_area("C") } else { CountCommand::new(false) };
Some(self.start_count_command(i, Box::new(command)))
}
"COUNTAREA" => Some(self.start_count_command(i, Box::new(CountCommand::new(true)))),
"COUNTCLOSE" => {
self.close_count(i);
Some(Task::none())
}
"COUNTLIST" => {
self.set_count_palette(true);
Some(Task::none())
}
"COUNTLISTCLOSE" => {
self.set_count_palette(false);
Some(Task::none())
}
"_COUNTLISTTOGGLE" => {
let open = !self.count_palette.show;
self.set_count_palette(open);
Some(Task::none())
}
"COUNTFIELD" => {
match self.count_field_code(i) {
Some(code) => return Some(self.start_count_field(i, code)),
None if self.tabs[i].count.is_some() => {}
None => self
.command_line
.push_error(&crate::t!("** COUNTFIELD command only available during Count. **")),
}
Some(Task::none())
}
"COUNTTABLE" => {
let doc = &self.tabs[i].scene.document;
let mut blocks = Vec::new();
let mut others = [0usize; 3];
for br in doc.block_records.iter() {
let upper = br.name.to_ascii_uppercase();
if upper == "*MODEL_SPACE" || upper.starts_with("*PAPER_SPACE") {
continue;
}
if br.flags.is_xref || br.flags.is_xref_overlay {
others[0] += 1;
} else if br.name.contains('|') {
others[1] += 1;
} else if br.name.starts_with('*') {
others[2] += 1;
} else {
blocks.push(br.name.clone());
}
}
blocks.sort_by_key(|b| b.to_ascii_uppercase());
Some(self.start_count_command(i, Box::new(CountTableCommand::new(blocks, others))))
}
"UPDATEFIELD" => {
let preselected = self.tabs[i].scene.selected_handles_in_order();
if !preselected.is_empty() {
self.update_fields(i, &preselected);
return Some(Task::none());
}
Some(self.start_count_command(i, Box::new(UpdateFieldCommand::new())))
}
"_UPDATEFIELDRUN" => {
let handles: Vec<Handle> = rest.split(',').filter_map(parse_handle).collect();
self.tabs[i].scene.deselect_all();
self.update_fields(i, &handles);
Some(Task::none())
}
"_COUNTRUN" => {
self.count_run(i, rest);
Some(Task::none())
}
"_COUNTAREASET" => {
let tokens: Vec<&str> = rest.split_whitespace().collect();
if let Some((area, boundary)) = self.resolve_count_area(i, &tokens) {
let mut mode = self.tabs[i].count.take().unwrap_or_default();
if mode.boundary != boundary {
self.drop_count_boundary(i, mode.boundary);
}
mode.area = area;
mode.boundary = boundary;
mode.cursor = None;
self.tabs[i].count = Some(mode);
self.apply_count_display(i);
self.command_line.push_output(&crate::t!("count area is active."));
}
Some(Task::none())
}
"_COUNTTABLEPLACE" => {
let (point, names) = rest.split_once(' ').unwrap_or((rest, ""));
let p: Vec<f64> = point.split(',').filter_map(|v| v.parse().ok()).collect();
if p.len() == 3 {
let names: Vec<String> = names.split('|').filter(|n| !n.is_empty()).map(str::to_string).collect();
self.place_count_table(i, [p[0], p[1], p[2]], names);
}
Some(Task::none())
}
_ => None,
}
}
fn start_count_command(&mut self, i: usize, command: Box<dyn CadCommand>) -> Task<Message> {
self.reset_command_start_state(i);
self.command_line.push_info(&command.prompt());
self.tabs[i].active_cmd = Some(command);
self.push_ucs_to_cmd(i);
self.sync_dyn_fields();
self.focus_cmd_input()
}
/// COUNTNUMBER / COUNTPALETTESTATE are read-only; COUNTSERVICE,
/// COUNTCOLOR and COUNTERRORCOLOR take a value (asked for when none is
/// given).
fn count_sysvar(&mut self, i: usize, name: &str, value: Option<&str>) {
let read_only = match name {
"COUNTNUMBER" => Some(
self.tabs[i]
.count
.as_ref()
.filter(|m| m.target.is_some())
.map(|m| m.count(&self.tabs[i].scene.document))
.unwrap_or(0),
),
"COUNTPALETTESTATE" => Some(self.count_palette.show as usize),
_ => None,
};
if let Some(v) = read_only {
self.command_line.push_output(&crate::tf!("{name} = {v} (read only)"));
return;
}
let (current, min, max) = match name {
"COUNTSERVICE" => (self.count_palette.service as i32, 0, 1),
"COUNTCOLOR" => (self.count_palette.color as i32, 1, 255),
_ => (self.count_palette.error_color as i32, 1, 255),
};
let ask = |app: &mut Self| {
app.command_line.push_output(&crate::tf!("Enter new value for {name} <{current}>:"));
app.pending_setvar = Some(name.to_string());
};
let Some(value) = value else {
ask(self);
return;
};
match value.parse::<i32>() {
Ok(n) if (min..=max).contains(&n) => {
match name {
"COUNTSERVICE" => self.count_palette.service = n == 1,
"COUNTCOLOR" => self.count_palette.color = n as i16,
_ => self.count_palette.error_color = n as i16,
}
self.persist_settings_if_changed();
self.apply_count_display(i);
}
_ => {
self.command_line.push_error(&if max == 1 {
crate::t!("Requires 0 or 1 only.")
} else {
crate::tf!("Requires an integer between {min} and {max}.")
});
ask(self);
}
}
}
/// Open or close the Count palette (docked on the right, expanded).
pub(in crate::app) fn set_count_palette(&mut self, open: bool) {
let id = crate::ui::dock::PanelId::Count;
self.count_palette.show = open;
self.ribbon.set_count_palette(open);
if open {
if self.dock.location(id).is_none() {
self.dock.dock(id, crate::app::config::DockSide::Right, usize::MAX);
}
self.dock_expanded = Some(id);
} else if self.dock_expanded == Some(id) {
self.dock_expanded = None;
}
}
/// Recolour the drawing for count mode: counted references, duplicates,
/// the rest faded (nothing changes without a target).
pub(in crate::app) fn apply_count_display(&mut self, i: usize) {
let computed = self.tabs[i].count.as_ref().map(|m| m.compute(&self.tabs[i].scene.document));
if let (Some(mode), Some(c)) = (self.tabs[i].count.as_mut(), computed) {
mode.cache = Some(c);
}
let display = self.tabs[i].count.as_ref().filter(|m| m.target.is_some()).map(|m| {
let r = m.result(&self.tabs[i].scene.document);
crate::scene::CountDisplay {
counted: r.counted.iter().copied().collect(),
errors: r.errors.iter().chain(&r.overlapped).copied().collect(),
color: aci_rgba(self.count_palette.color),
error_color: aci_rgba(self.count_palette.error_color),
}
});
self.tabs[i].scene.set_count_display(display);
let epoch = self.tabs[i].scene.geometry_epoch;
if let Some(mode) = self.tabs[i].count.as_mut() {
mode.epoch = epoch;
}
}
/// After the drawing changes, count mode colours its references again.
pub(in crate::app) fn refresh_count_if_stale(&mut self) {
let i = self.active_tab;
let Some(mode) = self.tabs[i].count.as_ref().filter(|m| m.epoch != self.tabs[i].scene.geometry_epoch) else {
return;
};
if let Some((h, _)) = mode.boundary {
match self.tabs[i].scene.document.get_entity(h) {
// The boundary is gone (erased, undone): ask, or do what was
// chosen for good.
None => {
if self.active_modal == Some(crate::app::ModalKind::CountInvalidArea) {
return;
}
match self.count_palette.invalid_choice {
1 => self.count_invalid_undo(i),
2 => self.count_invalid_continue(i),
_ => {
self.count_palette.invalid_always = false;
self.active_modal = Some(crate::app::ModalKind::CountInvalidArea);
}
}
return;
}
// An edited boundary moves the area with it.
Some(EntityType::LwPolyline(pl)) => {
let ring: Vec<[f64; 2]> = pl.vertices.iter().map(|v| [v.location.x, v.location.y]).collect();
if let Some(mode) = self.tabs[i].count.as_mut() {
mode.area = Some(ring);
}
}
Some(_) => {}
}
}
self.apply_count_display(i);
}
/// Invalid Area › Undo (also ✕ and Cancel): the change that took the
/// boundary away is reverted — redone when an undo took it, undone
/// otherwise. When that does not bring the boundary back, the history
/// step is put back and the area is dropped as with Continue.
fn count_invalid_undo(&mut self, i: usize) {
let Some((boundary, _)) = self.tabs[i].count.as_ref().and_then(|m| m.boundary) else {
return;
};
// An open step (the edit that just erased the boundary) goes on the
// undo stack first; after that, something to redo means the last
// history step was an undo.
self.finish_pending_history(i);
let redo = !self.tabs[i].history.redo_stack.is_empty();
if redo || !self.tabs[i].history.undo_stack.is_empty() {
if redo {
self.redo_active_tab();
} else {
self.undo_active_tab();
}
if self.tabs[i].scene.document.get_entity(boundary).is_some() {
self.apply_count_display(i);
return;
}
if redo {
self.undo_active_tab();
} else {
self.redo_active_tab();
}
}
self.count_invalid_continue(i);
}
/// Invalid Area › Continue: the area goes, the count covers all of model space.
fn count_invalid_continue(&mut self, i: usize) {
if let Some(mode) = self.tabs[i].count.as_mut() {
mode.area = None;
mode.boundary = None;
}
self.apply_count_display(i);
}
fn close_invalid_area(&mut self, choice: u8) {
if self.active_modal == Some(crate::app::ModalKind::CountInvalidArea) {
self.close_active_modal();
}
if self.count_palette.invalid_always {
self.count_palette.invalid_choice = choice;
self.persist_settings_if_changed();
}
}
/// COUNTCLOSE: leave count mode; the area polyline count mode drew goes
/// unless a field counts in it.
pub(in crate::app) fn close_count(&mut self, i: usize) {
if let Some(mode) = self.tabs[i].count.take() {
self.drop_count_boundary(i, mode.boundary);
}
self.tabs[i].scene.set_count_display(None);
}
fn drop_count_boundary(&mut self, i: usize, boundary: Option<(Handle, bool)>) {
if let Some((h, true)) = boundary {
let doc = &self.tabs[i].scene.document;
if doc.get_entity(h).is_some() && !boundary_in_use(doc, h) {
// Count mode removes its own polyline (the area layer is
// locked) outside the history: an open step must not take the
// removal in, or undoing it would bring back an orphan area.
self.finish_pending_history(i);
self.tabs[i].scene.rollback_new_entities(&[h]);
self.tabs[i].dirty = true;
}
}
}
/// The count area of `C`, `E`, `K`, `R a b`, `P a b c …` or `O h`, and
/// its boundary polyline (rectangles and polygons are drawn on the
/// `0-CountArea` layer). `None` when the area cannot be had.
fn resolve_count_area(
&mut self,
i: usize,
tokens: &[&str],
) -> Option<(Option<Vec<[f64; 2]>>, Option<(Handle, bool)>)> {
let kind = tokens.first().copied().unwrap_or("E");
let points = || tokens[1..].iter().map_while(|t| parse_xy(t)).collect::<Vec<_>>();
match kind {
"E" => Some((None, None)),
"K" => {
let mode = self.tabs[i].count.as_ref();
Some((mode.and_then(|m| m.area.clone()), mode.and_then(|m| m.boundary)))
}
// Current area counts all of model space (the view and an area
// in use take no part).
"C" => Some((None, None)),
"R" => {
let p = points();
let (a, b) = (p.first()?, p.get(1)?);
let ring = vec![[a[0], a[1]], [b[0], a[1]], [b[0], b[1]], [a[0], b[1]]];
let h = self.draw_count_area(i, &ring)?;
Some((Some(ring), Some((h, true))))
}
"P" => {
let ring = points();
(ring.len() >= 3).then_some(())?;
let h = self.draw_count_area(i, &ring)?;
Some((Some(ring), Some((h, true))))
}
"O" => {
let h = parse_handle(tokens.get(1)?)?;
let ring = codec::count::boundary_polygon(&self.tabs[i].scene.document, h)?;
Some((Some(ring), Some((h, false))))
}
_ => None,
}
}
/// A closed polyline on `0-CountArea` (locked, colour 152, not plotted,
/// Continuous), whatever the current layer.
fn draw_count_area(&mut self, i: usize, ring: &[[f64; 2]]) -> Option<Handle> {
self.push_undo_snapshot(i, "COUNT");
let doc = &mut self.tabs[i].scene.document;
if !doc.layers.contains(AREA_LAYER) {
let mut layer = codec::tables::Layer::new(AREA_LAYER);
layer.handle = doc.allocate_handle();
layer.color = codec::types::Color::from_index(152);
layer.is_plottable = false;
layer.flags.locked = true;
layer.line_type = "Continuous".into();
let _ = doc.layers.add(layer);
}
let mut pl = codec::entities::LwPolyline::new();
pl.vertices = ring
.iter()
.map(|p| codec::entities::LwVertex::new(codec::types::Vector2::new(p[0], p[1])))
.collect();
pl.is_closed = true;
pl.common.layer = AREA_LAYER.into();
let h = self.commit_entity_handle_preserve_layer(EntityType::LwPolyline(pl));
self.tabs[i].dirty = true;
h
}
/// `_COUNTRUN <area> L` opens the palette; `_COUNTRUN <area> T <handles>`
/// counts the targets and enters count mode.
fn count_run(&mut self, i: usize, rest: &str) {
let tokens: Vec<&str> = rest.split_whitespace().collect();
let split = tokens.iter().position(|t| *t == "L" || *t == "T").unwrap_or(tokens.len());
let (area_tokens, tail) = tokens.split_at(split);
let targets: Vec<Handle> = match tail {
["T", handles, ..] => handles
.split(',')
.filter_map(parse_handle)
.filter(|h| self.tabs[i].scene.document.get_entity(*h).is_some())
.collect(),
_ => Vec::new(),
};
// List all blocks: the palette, counting in the area when one was drawn.
if targets.is_empty() {
self.set_count_palette(true);
if matches!(area_tokens.first(), Some(&"C") | Some(&"E") | None) {
return;
}
}
let Some((area, boundary)) = self.resolve_count_area(i, area_tokens) else {
return;
};
let doc = &self.tabs[i].scene.document;
let blocks: Vec<&str> = targets
.iter()
.filter_map(|h| match doc.get_entity(*h) {
Some(EntityType::Insert(ins)) => Some(ins.block_name.as_str()),
_ => None,
})
.collect();
let target = match blocks.first() {
Some(first) if blocks.len() == targets.len() && blocks.iter().all(|b| b.eq_ignore_ascii_case(first)) => {
let name = doc.block_records.get(first).map(|b| b.name.clone()).unwrap_or_else(|| first.to_string());
Some(CountTarget::Block { name, reference: targets.first().copied(), matching: [false; 3], picked: true })
}
None if targets.is_empty() => None,
_ => Some(CountTarget::Group(targets.clone())),
};
// The picked targets do not stay selected.
self.tabs[i].scene.deselect_all();
self.refresh_properties();
if let Some(old) = self.tabs[i].count.take().filter(|o| o.boundary != boundary) {
self.drop_count_boundary(i, old.boundary);
}
let mode = CountMode { area, boundary, target, ..CountMode::default() };
if let Some(name) = mode.target_name() {
let n = mode.count(&self.tabs[i].scene.document);
self.command_line.push_output(&crate::tf!("{name} ...... {n}"));
}
self.tabs[i].count = Some(mode);
self.apply_count_display(i);
}
/// The field COUNTFIELD places for the current count: the picked
/// reference (`single`), or the block with the match options (in the
/// area's polyline when there is one).
fn count_field_code(&self, i: usize) -> Option<String> {
let mode = self.tabs[i].count.as_ref()?;
match mode.target.as_ref()? {
CountTarget::Group(handles) => Some(format!("\\AcCount {}", single_json(handles))),
CountTarget::Block { reference: Some(r), matching: [false, false, false], picked: true, .. }
if mode.boundary.is_none() =>
{
Some(format!("\\AcCount {}", single_json(&[*r])))
}
CountTarget::Block { name, .. } => {
let instances = mode.instances(&self.tabs[i].scene.document);
Some(self.block_field_code(i, name, &mode.key(&instances)))
}
}
}
/// `\AcCount` of a block and key, `\AcCount2` inside count mode's area polyline.
fn block_field_code(&self, i: usize, name: &str, key: &CountKey) -> String {
match self.tabs[i].count.as_ref().and_then(|m| m.boundary) {
Some((boundary, _)) => format!("\\AcCount2 {}", area_json(name, key, boundary)),
None => format!("\\AcCount {}", block_json(name, key)),
}
}
/// Place a count field as multi-line text (`Specify start point or [Height/Justify]:`).
pub(in crate::app) fn start_count_field(&mut self, i: usize, code: String) -> Task<Message> {
let doc = &self.tabs[i].scene.document;
let value = crate::entities::field::evaluate(doc, &code, &[], None).unwrap_or_else(|| "####".into());
let defaults = crate::scene::creation_style::current_text_defaults(doc);
self.command_line.push_output(&crate::tf!(
"MTEXT Current text style: \"{}\" Text height: {:.4}",
defaults.style_name, defaults.height
));
let command = FieldPlaceCommand::new(value, (code, Vec::new()), defaults.style_name, defaults.height).named("MTEXT");
self.start_count_command(i, Box::new(command))
}
/// UPDATEFIELD: every field the objects host is evaluated again and its
/// text written back, whatever FIELDEVAL says. A block reference brings
/// its attributes and the texts of its block definition; a table, the
/// cell texts of its drawing (its `*T` block).
fn update_fields(&mut self, i: usize, handles: &[Handle]) {
// Every run is an undo step, as in the reference, even with nothing
// selected, no field found or no value changed.
self.push_undo_mark(i, "UPDATEFIELD");
if handles.is_empty() {
return;
}
let doc = &self.tabs[i].scene.document;
let mut hosts = handles.to_vec();
for h in handles {
let record = match doc.get_entity(*h) {
Some(EntityType::Insert(insert)) => {
hosts.extend(insert.attributes.iter().map(|a| a.common.handle));
doc.block_records.get(&insert.block_name)
}
Some(EntityType::Table(t)) => t
.block_record_handle
.and_then(|r| doc.block_records.iter().find(|b| b.handle == r))
.or_else(|| doc.block_records.get(&t.block_name).filter(|_| !t.block_name.is_empty())),
_ => None,
};
if let Some(record) = record {
hosts.extend(record.entity_handles.iter().copied());
}
}
let attributes: Vec<&codec::entities::EntityCommon> = handles
.iter()
.filter_map(|h| match doc.get_entity(*h) {
Some(EntityType::Insert(insert)) => Some(insert.attributes.iter().map(|a| &a.common)),
_ => None,
})
.flatten()
.collect();
let hosting = hosts.iter().any(|h| match doc.get_entity(*h) {
Some(entity) => crate::entities::field::hosts_field(doc, entity),
None => attributes.iter().any(|common| {
common.handle == *h && crate::entities::field::common_hosts_field(doc, common)
}),
});
if !hosting {
self.command_line.push_output(&crate::tf!("{found} field(s) found.", found = 0));
self.command_line.push_output(&crate::tf!("{updated} field(s) updated.", updated = 0));
return;
}
// The reference counts fields, not the texts holding them, and
// reports every field it evaluated as updated, changed or not.
let (changed, found) = self.tabs[i].scene.update_fields(32, Some(&hosts));
if changed > 0 {
self.tabs[i].dirty = true;
}
self.command_line.push_output(&crate::tf!("{found} field(s) found."));
self.command_line.push_output(&crate::tf!("{updated} field(s) updated.", updated = found));
}
/// A count table at `point`: `Item` | `Count`, one row per block (all
/// counted blocks when `names` is empty), sorted by name; the counts are
/// fields.
fn place_count_table(&mut self, i: usize, point: [f64; 3], names: Vec<String>) {
let doc = &self.tabs[i].scene.document;
let area = self.tabs[i].count.as_ref().and_then(|m| m.area.clone());
let instances = codec::count::block_instances(doc, area.as_deref());
let mut names: Vec<String> = if names.is_empty() {
codec::count::block_counts(&instances).into_iter().map(|c| c.name).collect()
} else {
names
.iter()
.map(|n| doc.block_records.get(n).map(|b| b.name.clone()).unwrap_or_else(|| n.clone()))
.collect()
};
names.sort_by_key(|n| n.to_ascii_uppercase());
names.dedup_by_key(|n| n.to_ascii_uppercase());
let mut table = codec::entities::TableBuilder::new(names.len() + 1, 2)
.at(codec::types::Vector3::new(point[0], point[1], point[2]))
.row_height(0.36)
.column_width(3.6)
.build();
table.set_column_width(1, 1.08);
// As the reference writes it: no title row, data rows centred
// (table and cell overrides), header cells without overrides.
table.value_flags = 22;
table.legacy_style_override = Some(codec::entities::table::LegacyTableStyleOverride {
flags: 0x10001,
title_suppressed: Some(true),
row_alignments: vec![5],
..Default::default()
});
table.set_cell_text(0, 0, "Item");
table.set_cell_text(0, 1, "Count");
for (r, name) in names.iter().enumerate() {
table.set_cell_text(r + 1, 0, name);
if let Some(cell) = table.cell_mut(r + 1, 1) {
// The count comes from the field; the cell keeps no text.
let mut content = codec::entities::table::CellContent::text("");
content.value.flags = 1;
cell.contents = vec![content];
cell.cell_type = codec::entities::table::CellType::Text;
}
}
for (r, row) in table.rows.iter_mut().enumerate() {
for cell in &mut row.cells {
if let Some(content) = cell.contents.first_mut().filter(|c| c.value.flags != 1) {
content.value.flags = 6;
}
if r == 0 {
cell.flag = 0x40000;
} else {
cell.flag = 0x40001;
let mut style = codec::entities::CellStyle::new();
style.override_flags = 0x01;
style.alignment = 5;
cell.style = Some(style);
}
}
}
let codes: Vec<String> =
names.iter().map(|n| self.block_field_code(i, n, &CountKey::default())).collect();
self.push_undo_snapshot(i, "COUNTTABLE");
let Some(handle) = self.commit_entity_handle(EntityType::Table(Box::new(table))) else {
return;
};
// The table's graphics block holds one MTEXT per cell; each count
// cell's MTEXT owns its field, which the cell refers to.
let doc = &mut self.tabs[i].scene.document;
let fields = codes
.iter()
.enumerate()
.map(|(r, code)| {
let value = crate::entities::field::evaluate(doc, code, &[], None).unwrap_or_else(|| "####".into());
(r + 1, 1, r"%<\_FldIdx 0>%".to_string(), vec![codec::fields::NewField::new(code.clone(), value)])
})
.collect();
doc.build_table_block(handle, fields);
self.tabs[i].scene.bump_entities(&[(handle, crate::scene::ChangeKind::Modified)]);
self.tabs[i].dirty = true;
}
/// Zoom to a counted object (← / →, an error of the report).
fn zoom_to_insert(&mut self, i: usize, h: Handle) {
let doc = &self.tabs[i].scene.document;
let Some(entity) = doc.get_entity(h) else { return };
let outline = codec::count::entity_outline(doc, entity);
if outline.is_empty() {
return;
}
let (mut lo, mut hi) = (glam::DVec3::splat(f64::MAX), glam::DVec3::splat(f64::MIN));
for c in outline.iter().flat_map(codec::count::Piece::points) {
let p = glam::DVec3::new(c.x, c.y, c.z);
lo = lo.min(p);
hi = hi.max(p);
}
let pad = (hi - lo) * 1.5;
self.handle_zoom_to_window(lo - pad, hi + pad);
}
fn open_count_target(&mut self, i: usize, name: String, key: CountKey) {
let mut mode = self.tabs[i].count.take().unwrap_or_default();
let instances = mode.instances(&self.tabs[i].scene.document);
let reference = instances
.iter()
.find(|inst| inst.duplicate_of.is_none() && inst.name.eq_ignore_ascii_case(&name) && key.matches(inst))
.map(|inst| inst.handle);
mode.target = Some(CountTarget::Block {
name,
reference,
matching: [key.layer.is_some(), key.scale.is_some(), key.mirror_state.is_some()],
picked: false,
});
mode.cursor = None;
self.tabs[i].count = Some(mode);
self.apply_count_display(i);
}
/// The Field dialog's Show Count Instances: count mode on what the
/// expression counts.
pub(in crate::app) fn show_count_instances(&mut self, i: usize, json: &str) {
match codec::count::parse_query(json) {
Some(codec::count::CountQuery::Block { name, key, boundary }) => {
if let Some(b) = boundary {
if let Some(ring) = codec::count::boundary_polygon(&self.tabs[i].scene.document, b) {
let mut mode = self.tabs[i].count.take().unwrap_or_default();
if mode.boundary.map(|(h, _)| h) != Some(b) {
self.drop_count_boundary(i, mode.boundary);
mode.boundary = Some((b, false));
}
mode.area = Some(ring);
self.tabs[i].count = Some(mode);
}
}
self.open_count_target(i, name, key);
}
Some(codec::count::CountQuery::Single(handles)) if !handles.is_empty() => {
let hexes: Vec<String> = handles.iter().map(|h| hex(*h)).collect();
let area = if self.tabs[i].count.is_some() { "K" } else { "E" };
self.count_run(i, &format!("{area} T {}", hexes.join(",")));
}
_ => {}
}
}
pub(in crate::app) fn on_count(&mut self, m: CountMsg) -> Task<Message> {
let i = self.active_tab;
if self.tabs[i].is_start {
return Task::none();
}
match m {
CountMsg::Search(s) => self.count_palette.search = s,
CountMsg::Sort(by_count) => {
if self.count_palette.by_count == by_count {
self.count_palette.descending = !self.count_palette.descending;
} else {
self.count_palette.by_count = by_count;
self.count_palette.descending = false;
}
}
CountMsg::Open(name, key) | CountMsg::Menu(name, key, RowAction::Review) => {
if self.count_palette.table.is_none() {
self.open_count_target(i, name, key);
}
}
CountMsg::Menu(name, key, RowAction::Field) => {
let code = self.block_field_code(i, &name, &key);
return self.start_count_field(i, code);
}
CountMsg::Menu(name, _, RowAction::Expand(k)) => {
let upper = name.to_ascii_uppercase();
let list = &mut self.count_palette.expanded;
match list.iter_mut().find(|(n, _)| *n == upper) {
Some((_, e)) => e[k] = !e[k],
None => {
let mut e = [false; 3];
e[k] = true;
list.push((upper, e));
}
}
}
CountMsg::CreateTable => self.count_palette.table = Some(Vec::new()),
CountMsg::TableCheck(name, on) => {
if let Some(checked) = self.count_palette.table.as_mut() {
let upper = name.to_ascii_uppercase();
checked.retain(|n| *n != upper);
if on {
checked.push(upper);
}
}
}
CountMsg::TableAll(on) => {
let names: Vec<String> = if on {
let area = self.tabs[i].count.as_ref().and_then(|m| m.area.clone());
let instances = codec::count::block_instances(&self.tabs[i].scene.document, area.as_deref());
crate::ui::window::count_palette::rows(&instances, &self.count_palette)
.into_iter()
.map(|r| r.name.to_ascii_uppercase())
.collect()
} else {
Vec::new()
};
self.count_palette.table = Some(names);
}
CountMsg::TableCancel => self.count_palette.table = None,
CountMsg::TableInsert => {
let names = self.count_palette.table.take().unwrap_or_default();
if names.is_empty() {
return Task::none();
}
return self.start_count_command(i, Box::new(CountTableCommand::placing(names)));
}
CountMsg::Back | CountMsg::Close => self.close_count(i),
CountMsg::Match(k, on) => {
if let Some(CountMode { target: Some(CountTarget::Block { matching, .. }), .. }) = self.tabs[i].count.as_mut() {
matching[k] = on;
}
self.apply_count_display(i);
}
CountMsg::ToggleDetails => self.count_palette.details_closed = !self.count_palette.details_closed,
CountMsg::ToggleErrors => self.count_palette.errors_closed = !self.count_palette.errors_closed,
CountMsg::ShowError(h) => self.zoom_to_insert(i, h),
CountMsg::Prev | CountMsg::Next => {
let Some(mode) = self.tabs[i].count.as_ref() else { return Task::none() };
let result = mode.result(&self.tabs[i].scene.document);
let n = result.counted.len();
if n == 0 {
return Task::none();
}
let next = match (mode.cursor, matches!(m, CountMsg::Next)) {
(None, true) => 0,
(None, false) => n - 1,
(Some(c), true) => (c + 1) % n,
(Some(c), false) => (c + n - 1) % n,
};
let target = result.counted[next];
if let Some(mode) = self.tabs[i].count.as_mut() {
mode.cursor = Some(next);
}
self.zoom_to_insert(i, target);
}
CountMsg::Area => return self.dispatch_command("COUNTAREA"),
CountMsg::Select => {
self.tabs[i].scene.deselect_all();
let command = CountCommand::with_area(if self.tabs[i].count.is_some() { "K" } else { "C" });
return self.start_count_command(i, Box::new(command));
}
CountMsg::Field => return self.dispatch_command("COUNTFIELD"),
CountMsg::InvalidAlways(v) => self.count_palette.invalid_always = v,
CountMsg::InvalidUndo => {
self.close_invalid_area(1);
self.count_invalid_undo(i);
}
CountMsg::InvalidContinue => {
self.close_invalid_area(2);
self.count_invalid_continue(i);
}
}
Task::none()
}
}
+102 -55
View File
@@ -1,6 +1,6 @@
use super::*;
fn selected_edge_body(app: &OpenCADStudio, tab: usize) -> Option<acadrust::Handle> {
fn selected_edge_body(app: &OpenCADStudio, tab: usize) -> Option<codec::Handle> {
let scene = &app.tabs.get(tab)?.scene;
let selected = scene.selected_handles_in_order();
let [handle] = selected.as_slice() else {
@@ -9,7 +9,7 @@ fn selected_edge_body(app: &OpenCADStudio, tab: usize) -> Option<acadrust::Handl
if matches!(
scene.document.get_entity(*handle),
Some(acadrust::EntityType::Solid3D(_) | acadrust::EntityType::Surface(_))
Some(codec::EntityType::Solid3D(_) | codec::EntityType::Surface(_))
) {
Some(*handle)
} else {
@@ -20,7 +20,7 @@ fn selected_edge_body(app: &OpenCADStudio, tab: usize) -> Option<acadrust::Handl
fn solid_edge_sources(
app: &mut OpenCADStudio,
tab: usize,
) -> Vec<(acadrust::Handle, cadkernel::brep::Body)> {
) -> Vec<(codec::Handle, kernel::brep::Body)> {
let handles = app.tabs[tab]
.scene
.document
@@ -28,7 +28,7 @@ fn solid_edge_sources(
.filter_map(|entity| {
matches!(
entity,
acadrust::EntityType::Solid3D(_) | acadrust::EntityType::Surface(_)
codec::EntityType::Solid3D(_) | codec::EntityType::Surface(_)
)
.then_some(entity.common().handle)
})
@@ -146,7 +146,7 @@ impl OpenCADStudio {
}
cmd if cmd.starts_with("JUSTIFYTEXT ") => {
let opt = cmd.split_whitespace().nth(1).unwrap_or("").to_uppercase();
let handles: Vec<acadrust::Handle> = self.tabs[i]
let handles: Vec<codec::Handle> = self.tabs[i]
.scene
.selected_entities()
.iter()
@@ -164,7 +164,7 @@ impl OpenCADStudio {
);
return Some(Task::none());
}
use acadrust::entities::{
use codec::entities::{
AttachmentPoint as AP, TextHorizontalAlignment as TH,
TextVerticalAlignment as TV,
};
@@ -213,7 +213,7 @@ impl OpenCADStudio {
.find(|e| e.common().handle == *h)
{
match e {
acadrust::EntityType::Text(t) => {
codec::EntityType::Text(t) => {
let mut changed = false;
if let Some(a) = text_align {
t.horizontal_alignment = a;
@@ -228,7 +228,7 @@ impl OpenCADStudio {
n += 1;
}
}
acadrust::EntityType::MText(m) => {
codec::EntityType::MText(m) => {
if let Some(a) = mtext_ap {
m.attachment_point = a;
n += 1;
@@ -269,7 +269,7 @@ impl OpenCADStudio {
}
cmd if cmd.starts_with("TCASE ") => {
let opt = cmd.split_whitespace().nth(1).unwrap_or("").to_uppercase();
let handles: Vec<acadrust::Handle> = self.tabs[i]
let handles: Vec<codec::Handle> = self.tabs[i]
.scene
.selected_entities()
.iter()
@@ -315,11 +315,11 @@ impl OpenCADStudio {
.find(|e| e.common().handle == *h)
{
match e {
acadrust::EntityType::Text(t) => {
codec::EntityType::Text(t) => {
t.value = conv(&t.value);
n += 1;
}
acadrust::EntityType::MText(m) => {
codec::EntityType::MText(m) => {
m.value = conv(&m.value);
n += 1;
}
@@ -353,14 +353,14 @@ impl OpenCADStudio {
self.tabs[i].active_cmd = Some(Box::new(c));
}
cmd if cmd.starts_with("TEXTMASK ") => {
let handles: Vec<acadrust::Handle> = self.tabs[i]
let handles: Vec<codec::Handle> = self.tabs[i]
.scene
.selected_entities()
.iter()
.filter(|(_, e)| {
matches!(
e,
acadrust::EntityType::Text(_) | acadrust::EntityType::MText(_)
codec::EntityType::Text(_) | codec::EntityType::MText(_)
)
})
.map(|(h, _)| *h)
@@ -388,20 +388,20 @@ impl OpenCADStudio {
continue; // no geometry for this object
}
let pad = ((max[1] - min[1]) * 0.15).max(0.0);
let c1 = acadrust::types::Vector3::new(
let c1 = codec::types::Vector3::new(
(min[0] - pad) as f64,
(min[1] - pad) as f64,
min[2] as f64,
);
let c2 = acadrust::types::Vector3::new(
let c2 = codec::types::Vector3::new(
(max[0] + pad) as f64,
(max[1] + pad) as f64,
min[2] as f64,
);
self.tabs[i]
.scene
.add_entity_clone(acadrust::EntityType::Wipeout(
acadrust::entities::Wipeout::from_corners(c1, c2),
.add_entity_clone(codec::EntityType::Wipeout(
codec::entities::Wipeout::from_corners(c1, c2),
));
n += 1;
}
@@ -436,11 +436,11 @@ impl OpenCADStudio {
return Some(Task::none());
}
};
let handles: Vec<acadrust::Handle> = self.tabs[i]
let handles: Vec<codec::Handle> = self.tabs[i]
.scene
.selected_entities()
.iter()
.filter(|(_, e)| matches!(e, acadrust::EntityType::Text(_)))
.filter(|(_, e)| matches!(e, codec::EntityType::Text(_)))
.map(|(h, _)| *h)
.filter(|handle| !self.tabs[i].scene.is_layer_locked(*handle))
.collect();
@@ -464,7 +464,7 @@ impl OpenCADStudio {
if cur_w <= 1e-9 {
continue;
}
if let Some(acadrust::EntityType::Text(t)) = self.tabs[i]
if let Some(codec::EntityType::Text(t)) = self.tabs[i]
.scene
.document
.entities_mut()
@@ -533,12 +533,12 @@ impl OpenCADStudio {
}
};
let mut texts: Vec<(acadrust::Handle, f64, f64)> = self.tabs[i]
let mut texts: Vec<(codec::Handle, f64, f64)> = self.tabs[i]
.scene
.selected_entities()
.iter()
.filter_map(|(h, e)| match e {
acadrust::EntityType::Text(t)
codec::EntityType::Text(t)
if !self.tabs[i].scene.is_layer_locked(*h) =>
{
Some((*h, t.insertion_point.x, t.insertion_point.y))
@@ -569,7 +569,7 @@ impl OpenCADStudio {
let mut num = start;
for (h, _, _) in &texts {
if let Some(acadrust::EntityType::Text(t)) = self.tabs[i]
if let Some(codec::EntityType::Text(t)) = self.tabs[i]
.scene
.document
.entities_mut()
@@ -624,7 +624,7 @@ impl OpenCADStudio {
.objects
.iter()
.find_map(|(handle, object)| match object {
acadrust::objects::ObjectType::MultiLeaderStyle(style)
codec::objects::ObjectType::MultiLeaderStyle(style)
if style.name.eq_ignore_ascii_case(&name) =>
{
let mut style = style.clone();
@@ -911,13 +911,29 @@ impl OpenCADStudio {
.or_else(|| cmd.strip_prefix("ZOOM S "))
.or_else(|| cmd.strip_prefix("ZS "))
.unwrap_or("1");
if let Ok(factor) = rest.trim().parse::<f32>() {
if factor > 0.0 {
self.tabs[i].scene.remember_current_view();
self.tabs[i].scene.zoom_camera(1.0 / factor);
// nXP scales the active viewport against paper space, nX (or
// a bare n) the current view (#1465). n may be an expression
// such as 1/50.
let rest = rest.trim().to_ascii_uppercase();
let (number, xp) = match rest.strip_suffix("XP") {
Some(n) => (n, true),
None => (rest.strip_suffix('X').unwrap_or(&rest), false),
};
let factor = crate::app::expr_eval::eval_number(number.trim())
.filter(|f| f.is_finite() && *f > 0.0);
match factor {
Some(factor) if xp && self.tabs[i].scene.set_viewport_scale_factor(factor) => {
self.tabs[i].dirty = true;
self.command_line
.push_output(crate::tf!("Zoom Scale ×{factor:.3}").as_ref());
}
Some(factor) => {
self.tabs[i].scene.remember_current_view();
self.tabs[i].scene.zoom_camera(1.0 / factor as f32);
self.command_line
.push_output(crate::tf!("Zoom Scale ×{factor:.3}").as_ref());
}
None => {}
}
}
@@ -960,7 +976,29 @@ impl OpenCADStudio {
} else {
self.tabs[i].scene.wire_models_for(&handles)
};
let new_cmd = StretchCommand::new(handles, wires);
// Selected by a crossing window just before: stretch through
// that window, no second one asked for (#1068). Every picked
// object must touch it, or the selection was changed since.
let window = self.tabs[i].last_crossing_window.and_then(|(lo, hi)| {
let touches = |wire: &crate::scene::WireModel| {
let [x0, y0, x1, y1] = wire.aabb.map(f64::from);
x0 <= hi[0] && x1 >= lo[0] && y0 <= hi[1] && y1 >= lo[1]
};
(!handles.is_empty()
&& handles.iter().all(|h| {
self.tabs[i].scene.wire_models_for(&[*h]).iter().any(touches)
}))
.then(|| {
(
glam::DVec3::new(lo[0], lo[1], f64::NEG_INFINITY),
glam::DVec3::new(hi[0], hi[1], f64::INFINITY),
)
})
});
let new_cmd = match window {
Some(window) => StretchCommand::preselected(handles, wires, window),
None => StretchCommand::new(handles, wires),
};
self.command_line.push_info(&new_cmd.prompt());
self.tabs[i].active_cmd = Some(Box::new(new_cmd));
}
@@ -1202,40 +1240,48 @@ impl OpenCADStudio {
}
"EXPLODE" => {
use crate::modules::draw::modify::explode::explode_entity;
let entities: Vec<_> = self.tabs[i]
use crate::modules::draw::modify::explode::{
apply_explode_replacements, plan_explode,
};
let selected: Vec<_> = self.tabs[i]
.scene
.selected_entities()
.into_iter()
.filter(|(handle, _)| !self.tabs[i].scene.is_layer_locked(*handle))
.collect();
if entities.is_empty() {
if selected.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();
// EXPLMODE 0 keeps references that are not uniformly and
// positively scaled whole.
let selected: Vec<_> = if super::blockvars::explmode(&self.tabs[i].scene.document) == 0 {
selected
.into_iter()
.filter(|(_, entity)| match entity {
codec::EntityType::Insert(ins) => {
let (x, y, z) = (ins.x_scale(), ins.y_scale(), ins.z_scale());
let uniform = x > 0.0 && (x - y).abs() <= 1e-10 && (x - z).abs() <= 1e-10;
if !uniform {
self.command_line
.push_output("The object is not uniformly and positively scaled.");
}
uniform
}
_ => true,
})
.collect()
} else {
selected
};
let replacements = plan_explode(&selected, &self.tabs[i].scene.document);
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);
}
}
apply_explode_replacements(&mut self.tabs[i].scene, replacements);
if exploded > 0 {
self.tabs[i].dirty = true;
self.refresh_properties();
@@ -1260,7 +1306,8 @@ impl OpenCADStudio {
})
.collect();
// Pick-first (#422): with offsettable objects already selected,
// skip the pick step and go straight to distance / side.
// skip the distance and pick steps and open on the live side
// preview.
let preselected: Vec<_> = self.tabs[i]
.scene
.selected_entities()
@@ -1293,7 +1340,7 @@ impl OpenCADStudio {
.map(|e| (h, e))
})
.collect();
let initial_edges: Vec<acadrust::Handle> = self.tabs[i]
let initial_edges: Vec<codec::Handle> = self.tabs[i]
.scene
.selected_entities()
.into_iter()
@@ -1361,8 +1408,8 @@ impl OpenCADStudio {
self.tabs[i].active_cmd = Some(Box::new(c));
}
cmd if cmd.starts_with("ARCTEXT ") => {
use acadrust::entities::Text;
use acadrust::types::Vector3;
use codec::entities::Text;
use codec::types::Vector3;
let text = cmd.strip_prefix("ARCTEXT").unwrap_or("").trim().to_string();
if text.is_empty() {
self.command_line.push_info(
@@ -1376,7 +1423,7 @@ impl OpenCADStudio {
.selected_entities()
.iter()
.find_map(|(_, e)| match e {
acadrust::EntityType::Arc(a) => Some(a.clone()),
codec::EntityType::Arc(a) => Some(a.clone()),
_ => None,
});
let Some(arc) = arc else {
@@ -1406,7 +1453,7 @@ impl OpenCADStudio {
let t = Text::with_value(ch.to_string(), pos)
.with_height(height)
.with_rotation(ang + std::f64::consts::FRAC_PI_2);
self.tabs[i].scene.add_entity(acadrust::EntityType::Text(t));
self.tabs[i].scene.add_entity(codec::EntityType::Text(t));
}
self.tabs[i].dirty = true;
self.command_line
+105 -51
View File
@@ -29,7 +29,7 @@ impl OpenCADStudio {
Lock(usize, usize, bool),
Block(usize, usize, String),
Formula(usize, usize, String),
Field(usize, usize, acadrust::Handle),
Field(usize, usize, codec::Handle),
}
let words: Vec<&str> = rest.split_whitespace().collect();
let integer = |index: usize| {
@@ -120,7 +120,7 @@ impl OpenCADStudio {
TableAction::Field(
row,
column,
acadrust::Handle::new(handle),
codec::Handle::new(handle),
)
})
}
@@ -134,7 +134,7 @@ impl OpenCADStudio {
);
return Some(Task::none());
};
let selected_handles: Vec<acadrust::Handle> = self.tabs[i]
let selected_handles: Vec<codec::Handle> = self.tabs[i]
.scene
.selected_entities()
.iter()
@@ -174,15 +174,17 @@ impl OpenCADStudio {
self.push_undo_snapshot(i, "TABLE EDIT");
let mut changed = false;
for handle in &selected_handles {
let Some(acadrust::EntityType::Table(table)) =
let Some(codec::EntityType::Table(table)) =
self.tabs[i].scene.document.get_entity_mut(*handle)
else {
continue;
};
// `changed` tracks this table while the action runs.
let changed_before = std::mem::take(&mut changed);
match &action {
TableAction::Cell(row, column, text) => {
if let Some(cell) = table.cell_mut(*row, *column) {
use acadrust::entities::table::CellStateFlags;
use codec::entities::table::CellStateFlags;
if !cell.state.intersects(
CellStateFlags::CONTENT_LOCKED
| CellStateFlags::CONTENT_READ_ONLY,
@@ -229,7 +231,7 @@ impl OpenCADStudio {
&& *c1 < table.column_count()
&& *c2 < table.column_count() =>
{
table.merge_cells(acadrust::entities::table::CellRange::new(
table.merge_cells(codec::entities::table::CellRange::new(
(*r1).min(*r2),
(*c1).min(*c2),
(*r1).max(*r2),
@@ -253,7 +255,7 @@ impl OpenCADStudio {
}
TableAction::Lock(row, column, locked) => {
if let Some(cell) = table.cell_mut(*row, *column) {
use acadrust::entities::table::CellStateFlags;
use codec::entities::table::CellStateFlags;
cell.state.set(CellStateFlags::CONTENT_LOCKED, *locked);
cell.state.set(CellStateFlags::FORMAT_LOCKED, *locked);
changed = true;
@@ -263,7 +265,7 @@ impl OpenCADStudio {
if let (Some(handle), Some(cell)) =
(block_handle, table.cell_mut(*row, *column))
{
use acadrust::entities::table::{
use codec::entities::table::{
CellContent, CellStateFlags, CellType,
};
if !cell.state.intersects(
@@ -279,7 +281,7 @@ impl OpenCADStudio {
}
TableAction::Formula(row, column, expression) => {
if let Some(cell) = table.cell_mut(*row, *column) {
use acadrust::entities::table::CellStateFlags;
use codec::entities::table::CellStateFlags;
if !cell.state.intersects(
CellStateFlags::CONTENT_LOCKED
| CellStateFlags::CONTENT_READ_ONLY,
@@ -297,7 +299,7 @@ impl OpenCADStudio {
TableAction::Field(row, column, field_handle) => {
let mut attached = false;
if let Some(cell) = table.cell_mut(*row, *column) {
use acadrust::entities::table::{
use codec::entities::table::{
CellContent, CellStateFlags, CellType,
};
if !cell.state.intersects(
@@ -321,6 +323,12 @@ impl OpenCADStudio {
}
_ => {}
}
if changed {
// The stored picture no longer shows the cells: draw
// from them, and redraw the picture.
table.block_record_handle = None;
}
changed |= changed_before;
}
if changed {
self.invalidate_property_targets(i, &selected_handles);
@@ -371,7 +379,7 @@ impl OpenCADStudio {
}
let mut count = 0usize;
for entity in self.tabs[i].scene.document.entities_mut() {
if let acadrust::EntityType::Viewport(vp) = entity {
if let codec::EntityType::Viewport(vp) = entity {
vp.status.ucs_icon_visible = visible;
if sub == "NOORIGIN" || sub == "ORIGIN" {
vp.status.ucs_icon_at_origin = at_origin;
@@ -391,7 +399,7 @@ impl OpenCADStudio {
self.show_ucs_icon = visible;
self.ribbon.set_ucs_icon(visible);
for entity in self.tabs[i].scene.document.entities_mut() {
if let acadrust::EntityType::Viewport(vp) = entity {
if let codec::EntityType::Viewport(vp) = entity {
vp.status.ucs_icon_visible = visible;
}
}
@@ -509,6 +517,8 @@ impl OpenCADStudio {
}
// Rebuild tessellation caches without modifying the document.
"REGEN" | "REGENALL" => {
// A regen is a field evaluation event (FIELDEVAL bit 16).
self.tabs[i].scene.update_fields(16, None);
self.tabs[i].scene.populate_meshes_from_document();
self.command_line.push_output(crate::t!("REGEN: regenerated model.").as_ref());
return Some(Task::none());
@@ -592,12 +602,6 @@ impl OpenCADStudio {
.push_info(crate::t!("TOOLPALETTES: Tool Palettes not yet implemented.").as_ref());
}
// ── SHEETSET — not yet implemented ───────────────────────────────────
"SHEETSET" => {
self.command_line
.push_info(crate::t!("SHEETSET: Sheet Set Manager not yet implemented.").as_ref());
}
// ── 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>
@@ -616,11 +620,11 @@ impl OpenCADStudio {
self.tabs[i].active_cmd = Some(Box::new(c));
}
cmd if cmd.starts_with("XDATA ") => {
use acadrust::xdata::{ExtendedDataRecord, XDataValue};
use codec::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]
let selected_handles: Vec<codec::Handle> = self.tabs[i]
.scene
.selected_entities()
.iter()
@@ -787,7 +791,7 @@ impl OpenCADStudio {
use crate::modules::insert::solid3d_cmds::PresspullCommand;
let color = self.tabs[i].scene.layer_color(&self.tabs[i].active_layer);
let mut command = PresspullCommand::new(color);
command.set_isolines(self.tabs[i].scene.document.header.isolines.max(0) as usize);
command.set_isolines(crate::entities::solid3d::clamp_header_isolines(self.tabs[i].scene.document.header.isolines));
command.set_preselection(self.presspull_preselection());
self.command_line.push_info(&command.prompt());
self.tabs[i].active_cmd = Some(Box::new(command));
@@ -798,7 +802,7 @@ impl OpenCADStudio {
use crate::modules::insert::solid3d_cmds::RevolveCommand;
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);
let isolines = self.tabs[i].scene.document.header.isolines.max(0) as usize;
let isolines = crate::entities::solid3d::clamp_header_isolines(self.tabs[i].scene.document.header.isolines);
let mut cmd = RevolveCommand::new(color, isolines);
if !selected.is_empty() {
cmd.set_preselection(
@@ -816,8 +820,9 @@ impl OpenCADStudio {
"SWEEP" => {
use crate::modules::insert::solid3d_cmds::SweepCommand;
let color = self.tabs[i].scene.layer_color(&self.tabs[i].active_layer);
let isolines = self.tabs[i].scene.document.header.isolines.max(0) as usize;
let isolines = crate::entities::solid3d::clamp_header_isolines(self.tabs[i].scene.document.header.isolines);
let mut cmd = SweepCommand::new(color, isolines);
cmd.set_parameters(self.tabs[i].scene.named_parameters().clone());
let selected = self.tabs[i].scene.selected_handles_in_order()
.into_iter()
.filter_map(|handle| self.tabs[i].scene.document.get_entity(handle)
@@ -834,7 +839,7 @@ impl OpenCADStudio {
"LOFT" => {
use crate::modules::insert::solid3d_cmds::LoftCommand;
let color = self.tabs[i].scene.layer_color(&self.tabs[i].active_layer);
let isolines = self.tabs[i].scene.document.header.isolines.max(0) as usize;
let isolines = crate::entities::solid3d::clamp_header_isolines(self.tabs[i].scene.document.header.isolines);
let selected = self.tabs[i].scene.selected_handles_in_order().into_iter()
.filter_map(|handle| self.tabs[i].scene.document.get_entity(handle)
.cloned().map(|entity| (handle, entity))).collect();
@@ -934,6 +939,55 @@ impl OpenCADStudio {
}
}
}
// TRANSPARENCY — raster images show transparent pixels (ON) or
// draw every pixel in its colour (OFF).
"TRANSPARENCY" => {
let c = crate::modules::insert::image_transparency::TransparencyCommand::new();
self.command_line.push_info(&c.prompt());
self.tabs[i].active_cmd = Some(Box::new(c));
}
"TRANSPARENCY MODE" | "TRANSPARENCY ON" | "TRANSPARENCY OFF" => {
use codec::entities::ImageDisplayFlags;
let images: Vec<(codec::Handle, bool)> = self.tabs[i]
.scene
.selected_entities()
.iter()
.filter(|(handle, _)| !self.tabs[i].scene.is_layer_locked(*handle))
.filter_map(|(handle, _)| match self.tabs[i].scene.document.get_entity(*handle) {
Some(codec::EntityType::RasterImage(image)) => {
Some((*handle, image.flags.contains(ImageDisplayFlags::TRANSPARENCY_ON)))
}
_ => None,
})
.collect();
let Some(&(_, first_on)) = images.first() else {
return Some(Task::none());
};
let handles: Vec<codec::Handle> = images.iter().map(|(handle, _)| *handle).collect();
if cmd == "TRANSPARENCY MODE" {
let c = crate::modules::insert::image_transparency::TransparencyCommand::mode(
handles, first_on,
);
self.command_line.push_info(&c.prompt());
self.tabs[i].active_cmd = Some(Box::new(c));
} else {
let on = cmd == "TRANSPARENCY ON";
self.push_undo_snapshot(i, "TRANSPARENCY");
for handle in &handles {
if let Some(codec::EntityType::RasterImage(image)) =
self.tabs[i].scene.document.get_entity_mut(*handle)
{
image.flags.set(ImageDisplayFlags::TRANSPARENCY_ON, on);
}
}
let changes: Vec<_> = handles
.iter()
.map(|handle| (*handle, crate::scene::ChangeKind::Modified))
.collect();
self.tabs[i].scene.bump_entities(&changes);
self.tabs[i].dirty = true;
}
}
// UNDERLAY — edit properties of selected PDF/DWF/DGN underlay entities.
// Usage:
// UNDERLAY FADE <0-80>
@@ -965,7 +1019,7 @@ impl OpenCADStudio {
.split_once(' ')
.map(|(_, r)| r.trim().to_uppercase())
.unwrap_or_default();
let handles: Vec<acadrust::Handle> = self.tabs[i]
let handles: Vec<codec::Handle> = self.tabs[i]
.scene
.selected_entities()
.iter()
@@ -982,7 +1036,7 @@ impl OpenCADStudio {
let mut changed = 0usize;
self.push_undo_snapshot(i, "UNDERLAY");
for h in &handles {
if let Some(acadrust::EntityType::Underlay(ul)) = self.tabs[i]
if let Some(codec::EntityType::Underlay(ul)) = self.tabs[i]
.scene
.document
.entities_mut()
@@ -1012,7 +1066,7 @@ impl OpenCADStudio {
"CLIP" => match arg {
"ON" => {
ul.flags |=
acadrust::entities::UnderlayDisplayFlags::CLIPPING;
codec::entities::UnderlayDisplayFlags::CLIPPING;
changed += 1;
}
"OFF" => {
@@ -1068,7 +1122,7 @@ impl OpenCADStudio {
// annotative objects. With no preselection, gather one through the
// regular selection engine before applying the action.
action @ ("OBJECTSCALE ADD" | "OBJECTSCALE DELETE") => {
let selected: Vec<acadrust::Handle> = self.tabs[i]
let selected: Vec<codec::Handle> = self.tabs[i]
.scene
.selected_entities()
.iter()
@@ -1186,13 +1240,13 @@ impl OpenCADStudio {
self.tabs[i].active_cmd = Some(Box::new(c));
}
cmd if cmd.starts_with("HYPERLINK ") => {
use acadrust::xdata::XDataValue;
use codec::xdata::XDataValue;
let url = cmd.strip_prefix("HYPERLINK").unwrap_or("").trim().to_string();
if url.is_empty() {
self.command_line.push_info(crate::t!("Usage: HYPERLINK <url> (select objects first)").as_ref());
return Some(Task::none());
}
let handles: Vec<acadrust::Handle> = self.tabs[i]
let handles: Vec<codec::Handle> = self.tabs[i]
.scene
.selected_entities()
.iter()
@@ -1247,7 +1301,7 @@ impl OpenCADStudio {
let parts: Vec<&str> = rest.splitn(2, char::is_whitespace).collect();
let action = parts.first().map(|s| s.to_uppercase()).unwrap_or_default();
let arg = parts.get(1).copied().unwrap_or("").trim();
let handles: Vec<acadrust::Handle> = self.tabs[i]
let handles: Vec<codec::Handle> = self.tabs[i]
.scene
.selected_entities()
.iter()
@@ -1266,7 +1320,7 @@ impl OpenCADStudio {
let mut changed = 0usize;
let mut changed_handles = Vec::new();
for h in &handles {
if let Some(acadrust::EntityType::RasterImage(img)) = self.tabs[i]
if let Some(codec::EntityType::RasterImage(img)) = self.tabs[i]
.scene
.document
.entities_mut()
@@ -1544,7 +1598,7 @@ impl OpenCADStudio {
// Reset every alternate scale representation to the position of
// the representation visible at the current annotation scale.
"ANNORESET" => {
let handles: Vec<acadrust::Handle> = self.tabs[i]
let handles: Vec<codec::Handle> = self.tabs[i]
.scene
.selected_entities()
.iter()
@@ -1632,8 +1686,8 @@ impl OpenCADStudio {
.push_error(crate::t!("DATALINK: the CSV file is empty.").as_ref());
return Some(Task::none());
}
use acadrust::entities::TableBuilder;
use acadrust::types::Vector3;
use codec::entities::TableBuilder;
use codec::types::Vector3;
let mut table = TableBuilder::new(nrows, ncols)
.at(Vector3::new(0.0, 0.0, 0.0))
.row_height(0.5)
@@ -1648,7 +1702,7 @@ impl OpenCADStudio {
let current_style = doc.header.current_table_style_name.clone();
table.table_style_handle = doc.objects.iter().find_map(|(handle, object)| {
match object {
acadrust::objects::ObjectType::TableStyle(style)
codec::objects::ObjectType::TableStyle(style)
if style.name.eq_ignore_ascii_case(&current_style) =>
{
Some(*handle)
@@ -1678,12 +1732,12 @@ impl OpenCADStudio {
.trim_start_matches("DATALINKUPDATE")
.trim()
.eq_ignore_ascii_case("WRITE");
let selected: Vec<acadrust::Handle> = self.tabs[i]
let selected: Vec<codec::Handle> = self.tabs[i]
.scene
.selected_entities()
.into_iter()
.filter_map(|(handle, entity)| {
matches!(entity, acadrust::EntityType::Table(_)).then_some(handle)
matches!(entity, codec::EntityType::Table(_)).then_some(handle)
})
.collect();
let table_handles = if selected.is_empty() {
@@ -1692,7 +1746,7 @@ impl OpenCADStudio {
.document
.entities()
.filter_map(|entity| {
matches!(entity, acadrust::EntityType::Table(_))
matches!(entity, codec::EntityType::Table(_))
.then_some(entity.common().handle)
})
.collect::<Vec<_>>()
@@ -1701,7 +1755,7 @@ impl OpenCADStudio {
};
let mut jobs = Vec::new();
for handle in &table_handles {
let Some(acadrust::EntityType::Table(table)) =
let Some(codec::EntityType::Table(table)) =
self.tabs[i].scene.document.get_entity(*handle)
else {
continue;
@@ -1725,7 +1779,7 @@ impl OpenCADStudio {
let mut written = 0usize;
let mut errors = Vec::new();
for (table_handle, link_handle) in &jobs {
let Some(acadrust::EntityType::Table(table)) =
let Some(codec::EntityType::Table(table)) =
self.tabs[i].scene.document.get_entity(*table_handle)
else {
continue;
@@ -1769,11 +1823,11 @@ impl OpenCADStudio {
return Some(Task::none());
}
self.push_undo_snapshot(i, "DATALINKUPDATE");
use acadrust::entities::table::CellStateFlags;
use codec::entities::table::CellStateFlags;
let mut changed_handles = Vec::new();
for (table_handle, link_handle, rows) in updates {
let columns = rows.iter().map(Vec::len).max().unwrap_or(0);
let Some(acadrust::EntityType::Table(table)) =
let Some(codec::EntityType::Table(table)) =
self.tabs[i].scene.document.get_entity_mut(table_handle)
else {
continue;
@@ -1848,11 +1902,11 @@ impl OpenCADStudio {
}
self.push_undo_snapshot(i, "LANDXMLIMPORT");
for [x, y, z] in &pts {
let mut p = acadrust::entities::Point::new();
p.location = acadrust::types::Vector3::new(*x, *y, *z);
let mut p = codec::entities::Point::new();
p.location = codec::types::Vector3::new(*x, *y, *z);
self.tabs[i]
.scene
.add_entity_clone(acadrust::EntityType::Point(p));
.add_entity_clone(codec::EntityType::Point(p));
}
self.tabs[i].dirty = true;
self.command_line.push_output(crate::tf!(
@@ -1866,8 +1920,8 @@ impl OpenCADStudio {
}
}
"POINTCLOUDATTACH" | "RECAP" | "SYNCPVIEWPORTS" | "UNDERLAYLAYERS"
| "UOSNAP" => {
"UNDERLAYLAYERS" | "ULAYERS" => self.open_underlay_layers_dialog(i),
"RECAP" | "SYNCPVIEWPORTS" => {
self.command_line
.push_info(crate::tf!("{cmd}: not yet implemented.").as_ref());
}
@@ -1954,7 +2008,7 @@ pub(crate) fn parse_csv_table(text: &str) -> Vec<Vec<String>> {
rows
}
pub(crate) fn table_to_csv(table: &acadrust::entities::Table) -> String {
pub(crate) fn table_to_csv(table: &codec::entities::Table) -> String {
fn escape(value: &str) -> String {
if value.contains([',', '"', '\r', '\n']) {
format!("\"{}\"", value.replace('"', "\"\""))
@@ -2045,7 +2099,7 @@ mod tests {
app.automation_op(r#"{"op":"new"}"#);
let i = app.active_tab;
let stale = acadrust::Handle::new(0xDEAD);
let stale = codec::Handle::new(0xDEAD);
app.tabs[i].scene.meshes.insert(stale, stale_mesh());
let epoch_before = app.tabs[i].scene.geometry_epoch;
@@ -2070,7 +2124,7 @@ mod tests {
let i = app.active_tab;
let seed = |app: &mut OpenCADStudio, i: usize| {
let stale = acadrust::Handle::new(0xBEEF);
let stale = codec::Handle::new(0xBEEF);
app.tabs[i].scene.meshes.insert(stale, stale_mesh());
stale
};
+87 -132
View File
@@ -23,7 +23,7 @@ impl OpenCADStudio {
.objects
.iter()
.filter_map(|(handle, object)| match object {
acadrust::objects::ObjectType::MLineStyle(style) => {
codec::objects::ObjectType::MLineStyle(style) => {
Some((*handle, style.clone()))
}
_ => None,
@@ -79,21 +79,6 @@ impl OpenCADStudio {
self.tabs[i].active_cmd = Some(Box::new(command));
}
"ATTDEF" => {
use crate::modules::draw::draw::attdef::AttdefCommand;
let defaults = crate::scene::creation_style::current_text_defaults(
&self.tabs[i].scene.document,
);
let cmd = AttdefCommand::with_text_defaults(
defaults.height,
defaults.style_name,
defaults.width_factor,
defaults.oblique_angle,
);
self.command_line.push_info(&cmd.prompt());
self.tabs[i].active_cmd = Some(Box::new(cmd));
}
// Command-line attribute editing on selected Insert entities. Bare
// ATTEDIT and the ATE alias launch the interactive editor instead
// (see the ATTEDIT arm in the inquiry family); the dash form is the
@@ -110,7 +95,7 @@ impl OpenCADStudio {
.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]
let selected_handles: Vec<codec::Handle> = self.tabs[i]
.scene
.selected_entities()
.iter()
@@ -122,7 +107,7 @@ impl OpenCADStudio {
} else {
let mut found_any = false;
for sh in &selected_handles {
if let Some(acadrust::EntityType::Insert(ins)) = self.tabs[i]
if let Some(codec::EntityType::Insert(ins)) = self.tabs[i]
.scene
.document
.entities()
@@ -166,7 +151,7 @@ impl OpenCADStudio {
if self.tabs[i].scene.is_layer_locked(*sh) {
continue;
}
if let Some(acadrust::EntityType::Insert(ins)) = self.tabs[i]
if let Some(codec::EntityType::Insert(ins)) = self.tabs[i]
.scene
.document
.entities_mut()
@@ -198,68 +183,32 @@ impl OpenCADStudio {
}
}
// 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
// ATTDISP sets ATTMODE: 0 hides every attribute, 1 shows the
// visible ones, 2 shows them all. The definitions are not touched.
"ATTDISP" => {
use crate::command::KeywordCommand;
let c = KeywordCommand::new(
"ATTDISP",
"ATTDISP attribute display [On / Off / Normal]:",
vec![
("On", "ON", None),
("Off", "OFF", None),
("Normal", "NORMAL", None),
],
let command = crate::modules::draw::draw::attdef::AttdispCommand::new(
self.tabs[i].scene.document.header.attribute_visibility,
);
self.command_line.push_info(&c.prompt());
self.tabs[i].active_cmd = Some(Box::new(c));
self.command_line.push_info(&command.prompt());
self.tabs[i].active_cmd = Some(Box::new(command));
}
cmd if cmd.starts_with("ATTDISP ") => {
let sub = cmd.split_whitespace().nth(1).unwrap_or("").to_uppercase();
match sub.as_str() {
"ON" | "OFF" | "NORMAL" => {
let handles: Vec<_> = self.tabs[i]
.scene
.document
.entities()
.filter_map(|entity| {
matches!(entity, acadrust::EntityType::AttributeDefinition(_))
.then_some(entity.common().handle)
})
.filter(|handle| !self.tabs[i].scene.is_layer_locked(*handle))
.collect();
self.push_undo_snapshot(i, "ATTDISP");
let mut count = 0usize;
for handle in handles {
if let Some(acadrust::EntityType::AttributeDefinition(ad)) =
self.tabs[i].scene.document.get_entity_mut(handle)
{
match sub.as_str() {
"ON" => {
ad.flags.invisible = false;
count += 1;
}
"OFF" => {
ad.flags.invisible = true;
count += 1;
}
_ => {}
}
}
}
self.tabs[i].dirty = true;
self.command_line.push_output(
crate::tf!("ATTDISP {sub}: {count} attribute definition(s) updated.")
.as_ref(),
);
}
let mode = match cmd[8..].trim().to_ascii_uppercase().as_str() {
"OFF" => 0,
"ON" => 2,
"NORMAL" | "N" => 1,
_ => {
self.command_line
.push_info(crate::t!("Usage: ATTDISP ON | OFF | NORMAL").as_ref());
self.command_line.push_error("Invalid option keyword.");
return Some(Task::none());
}
};
if self.tabs[i].scene.document.header.attribute_visibility != mode {
self.push_undo_snapshot(i, "ATTDISP");
self.tabs[i].scene.document.header.attribute_visibility = mode;
self.tabs[i].dirty = true;
self.tabs[i].scene.bump_geometry();
}
self.command_line.push_output("Regenerating model.");
}
"DONUT" => {
@@ -391,6 +340,7 @@ impl OpenCADStudio {
});
match seed {
Some((s, tangent)) => {
self.last_point = Some(s);
let new_cmd = ArcContCommand::new(s, tangent);
self.command_line.push_info(&new_cmd.prompt());
self.tabs[i].active_cmd = Some(Box::new(new_cmd));
@@ -488,7 +438,7 @@ impl OpenCADStudio {
.document
.entities()
.filter_map(|entity| match entity {
acadrust::EntityType::Face3D(face) => {
codec::EntityType::Face3D(face) => {
Some((face.common.handle, face.clone()))
}
_ => None,
@@ -549,12 +499,12 @@ impl OpenCADStudio {
.iter()
.copied()
.filter(|handle| {
let Some(acadrust::EntityType::Line(line)) =
let Some(codec::EntityType::Line(line)) =
self.tabs[i].scene.document.get_entity(*handle)
else {
return false;
};
acadrust::entities::CenterMarkAssociation::read(&line.common.extended_data)
codec::entities::CenterMarkAssociation::read(&line.common.extended_data)
.is_some()
})
.collect();
@@ -793,7 +743,7 @@ impl OpenCADStudio {
let normal = working_plane.z.normalize_or(glam::DVec3::Z);
let elevation = working_plane.origin.dot(normal);
let storage = crate::entities::curve::ocs_plane(
acadrust::types::Vector3::new(normal.x, normal.y, normal.z),
codec::types::Vector3::new(normal.x, normal.y, normal.z),
elevation,
);
let plane = crate::command::WorkingPlane::new(
@@ -837,7 +787,7 @@ impl OpenCADStudio {
)
});
let (scale, angle) = match entity {
Some(acadrust::EntityType::Hatch(hatch)) => (
Some(codec::EntityType::Hatch(hatch)) => (
hatch.pattern_scale as f32,
hatch.pattern_angle.to_degrees() as f32,
),
@@ -1131,7 +1081,7 @@ impl OpenCADStudio {
self.command_line.push_output("No constraints found.");
return None;
}
let dimensions: Vec<acadrust::Handle> = ids
let dimensions: Vec<codec::Handle> = ids
.iter()
.filter_map(|id| before.dimensions.get(id).copied())
.collect();
@@ -1456,7 +1406,7 @@ impl OpenCADStudio {
annotational,
decimals,
);
if let Some(acadrust::EntityType::Dimension(mut dimension)) =
if let Some(codec::EntityType::Dimension(mut dimension)) =
self.tabs[i].scene.document.get_entity(handle).cloned()
{
let before = self.tabs[i].scene.document.get_entity_arc(handle);
@@ -1472,7 +1422,7 @@ impl OpenCADStudio {
dimension.base_mut().common.layer =
crate::scene::parametric_constraints::DYNAMIC_DIMENSION_LAYER
.to_string();
dimension.base_mut().common.color = acadrust::types::Color::Rgb {
dimension.base_mut().common.color = codec::types::Color::Rgb {
r: 103,
g: 109,
b: 118,
@@ -1480,7 +1430,7 @@ impl OpenCADStudio {
}
self.tabs[i]
.scene
.update_entity(acadrust::EntityType::Dimension(dimension));
.update_entity(codec::EntityType::Dimension(dimension));
}
let set = self.tabs[i].scene.parametric_constraint_set_mut(scope);
let id = set.add(kind, refs, Some(DrivingValue::Named(name)));
@@ -1554,7 +1504,7 @@ impl OpenCADStudio {
return Some(self.apply_cmd_result(CmdResult::AddHorizontalConstraint {
kind,
selection: HorizontalConstraintSelection::Reference(reference),
direction: acadrust::types::Vector3::new(
direction: codec::types::Vector3::new(
direction.x,
direction.y,
direction.z,
@@ -2105,7 +2055,7 @@ impl OpenCADStudio {
.filter(|handle| {
matches!(
self.tabs[i].scene.document.get_entity(*handle),
Some(acadrust::EntityType::Solid3D(_))
Some(codec::EntityType::Solid3D(_))
)
})
.collect::<Vec<_>>();
@@ -2166,12 +2116,12 @@ impl OpenCADStudio {
matches!(
scene.document.get_entity(*handle),
Some(
acadrust::EntityType::Solid3D(_)
| acadrust::EntityType::Region(_)
| acadrust::EntityType::Surface(_)
| acadrust::EntityType::Mesh(_)
| acadrust::EntityType::PolygonMesh(_)
| acadrust::EntityType::PolyfaceMesh(_)
codec::EntityType::Solid3D(_)
| codec::EntityType::Region(_)
| codec::EntityType::Surface(_)
| codec::EntityType::Mesh(_)
| codec::EntityType::PolygonMesh(_)
| codec::EntityType::PolyfaceMesh(_)
)
)
})
@@ -2180,9 +2130,9 @@ impl OpenCADStudio {
matches!(
scene.document.get_entity(*handle),
Some(
acadrust::EntityType::Mesh(_)
| acadrust::EntityType::PolygonMesh(_)
| acadrust::EntityType::PolyfaceMesh(_)
codec::EntityType::Mesh(_)
| codec::EntityType::PolygonMesh(_)
| codec::EntityType::PolyfaceMesh(_)
)
)
});
@@ -2218,12 +2168,12 @@ impl OpenCADStudio {
.find(|(_, entity)| {
matches!(
entity,
acadrust::EntityType::Line(_)
| acadrust::EntityType::Arc(_)
| acadrust::EntityType::Circle(_)
| acadrust::EntityType::Ellipse(_)
| acadrust::EntityType::LwPolyline(_)
| acadrust::EntityType::Spline(_)
codec::EntityType::Line(_)
| codec::EntityType::Arc(_)
| codec::EntityType::Circle(_)
| codec::EntityType::Ellipse(_)
| codec::EntityType::LwPolyline(_)
| codec::EntityType::Spline(_)
)
})
.map(|(handle, entity)| (handle, entity.clone()));
@@ -2246,18 +2196,18 @@ impl OpenCADStudio {
self.tabs[i].active_cmd = Some(Box::new(command));
return Some(iced::Task::none());
}
use acadrust::entities::Region;
use acadrust::types::Vector3;
use codec::entities::Region;
use codec::types::Vector3;
let mut regions = Vec::new();
let mut sources = Vec::new();
for (handle, e) in self.tabs[i].scene.selected_entities().iter() {
if !matches!(e, acadrust::EntityType::Region(_)) {
if !matches!(e, codec::EntityType::Region(_)) {
let Some((plane, loops, true)) =
crate::scene::model::presspull_model::profile_geometry(e)
else {
continue;
};
let Some(body) = cadkernel::brep::planar_region(plane, &loops) else {
let Some(body) = kernel::brep::planar_region(plane, &loops) else {
continue;
};
let mut region = Region::new();
@@ -2276,7 +2226,7 @@ impl OpenCADStudio {
.filter(|(handle, _)| !sources.contains(handle))
.filter_map(|(_, entity)| crate::entities::curve::entity_curve(entity))
.collect();
let open_plane = cadkernel::space::common_curve_plane(&open_curves, 1.0e-6);
let open_plane = kernel::space::common_curve_plane(&open_curves, 1.0e-6);
let open_plane_rejected = !open_curves.is_empty() && open_plane.is_none();
if let Some(plane) = open_plane {
let working_plane = crate::command::WorkingPlane::new(
@@ -2314,7 +2264,7 @@ impl OpenCADStudio {
else {
continue;
};
let Some(body) = cadkernel::brep::planar_region(plane, &[curves]) else {
let Some(body) = kernel::brep::planar_region(plane, &[curves]) else {
continue;
};
let mut region = Region::new();
@@ -2427,10 +2377,10 @@ impl OpenCADStudio {
.filter_map(|entity| {
matches!(
entity,
acadrust::EntityType::Solid3D(_)
| acadrust::EntityType::Surface(_)
| acadrust::EntityType::Region(_)
| acadrust::EntityType::Body(_)
codec::EntityType::Solid3D(_)
| codec::EntityType::Surface(_)
| codec::EntityType::Region(_)
| codec::EntityType::Body(_)
)
.then_some(entity.common().handle)
})
@@ -2452,10 +2402,10 @@ impl OpenCADStudio {
.document
.entities()
.filter_map(|entity| {
let acadrust::EntityType::Extended(extended) = entity else {
let codec::EntityType::Extended(extended) = entity else {
return None;
};
let acadrust::entities::ExtendedEntityData::SectionObject(data) =
let codec::entities::ExtendedEntityData::SectionObject(data) =
&extended.data
else {
return None;
@@ -2534,21 +2484,26 @@ impl OpenCADStudio {
}
// 3DROTATE [X|Y|Z] <angle> — rotate the selected solid about an axis.
// 3DROTATE — any objects about an axis through a base point.
"3DROTATE" | "ROTATE3D" => {
use crate::command::SelectThenKeywordCommand;
let has_sel = !self.tabs[i].scene.selected_entities().is_empty();
let c = SelectThenKeywordCommand::new(
"3DROTATE",
"3DROTATE rotation axis [X / Y / Z]:",
vec![
("X", "X", Some("3DROTATE angle in degrees:")),
("Y", "Y", Some("3DROTATE angle in degrees:")),
("Z", "Z", Some("3DROTATE angle in degrees:")),
],
has_sel,
);
self.command_line.push_info(&c.prompt());
self.tabs[i].active_cmd = Some(Box::new(c));
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("3DROTATE");
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_3d(handles, wires);
self.command_line.push_info(&new_cmd.prompt());
self.tabs[i].active_cmd = Some(Box::new(new_cmd));
}
}
cmd if cmd.starts_with("3DROTATE ") || cmd.starts_with("ROTATE3D ") => {
let parts: Vec<String> = cmd
@@ -2583,8 +2538,8 @@ impl OpenCADStudio {
matches!(
scene.document.get_entity(*handle),
Some(
acadrust::EntityType::Solid3D(_)
| acadrust::EntityType::Surface(_)
codec::EntityType::Solid3D(_)
| codec::EntityType::Surface(_)
)
)
})
@@ -2641,7 +2596,7 @@ impl OpenCADStudio {
1 => ([0.0, v, 0.0], [0.0, 0.0, 1.0], [0.0, 1.0, 0.0]),
_ => ([0.0, 0.0, v], [1.0, 0.0, 0.0], [0.0, 0.0, 1.0]),
};
let plane = cadkernel::space::Plane::orthonormal(origin, x, normal)
let plane = kernel::space::Plane::orthonormal(origin, x, normal)
.expect("fixed world axes define a plane");
let side = glam::DVec3::from_array(origin)
+ glam::DVec3::from_array(normal) * if keep_low { -1.0 } else { 1.0 };
@@ -2680,7 +2635,7 @@ impl OpenCADStudio {
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(_))
|| matches!(e, codec::EntityType::Leader(_))
})
});
if let Some(h) = editable {
@@ -2759,7 +2714,7 @@ mod region_tests {
.map(|entity| {
(
entity.common().handle,
matches!(entity, acadrust::EntityType::Circle(_)),
matches!(entity, codec::EntityType::Circle(_)),
)
})
.collect();
@@ -2780,7 +2735,7 @@ mod region_tests {
.scene
.document
.entities()
.filter(|entity| matches!(entity, acadrust::EntityType::Region(_)))
.filter(|entity| matches!(entity, codec::EntityType::Region(_)))
.count();
assert_eq!(region_count, 1);
+461
View File
@@ -0,0 +1,461 @@
//! FIELD and the Field dialog behind the ƒ buttons of the attribute
//! definition dialogs.
use crate::app::{Message, OpenCADStudio};
use crate::command::CadCommand;
use crate::modules::annotate::field_cmd::{FieldObjectPickCommand, FieldPlaceCommand, FieldTablePickCommand};
use crate::ui::window::field_dialog::{
fields_of, nav_props, object_properties, FieldDialogMsg, FieldDialogState, FieldTarget, DATE_FORMATS,
NAMED_TYPES,
};
use iced::Task;
impl OpenCADStudio {
pub(super) fn dispatch_field(&mut self, cmd: &str, i: usize) -> Option<Task<Message>> {
match cmd {
"FIELD" => {
self.open_field_dialog(FieldTarget::NewText);
Some(Task::none())
}
// FIELDEVAL — the events that update fields, kept in the drawing.
cmd if cmd == "FIELDEVAL" || cmd.starts_with("FIELDEVAL ") || cmd.starts_with("SETVAR FIELDEVAL") => {
let value = cmd
.trim_start_matches("SETVAR ")
.trim_start_matches("FIELDEVAL")
.trim();
let current = crate::entities::field::fieldeval(&self.tabs[i].scene.document);
let ask = |app: &mut Self| {
app.command_line
.push_output(&format!("Enter new value for FIELDEVAL <{current}>:"));
app.pending_setvar = Some("FIELDEVAL".into());
};
match value.parse::<i32>() {
_ if value.is_empty() => ask(self),
Ok(n) if (0..=31).contains(&n) => {
if n != current {
self.push_undo_snapshot(i, "FIELDEVAL");
crate::io::set_drawing_variable(
&mut self.tabs[i].scene.document,
"FIELDEVAL",
&n.to_string(),
);
self.tabs[i].dirty = true;
}
}
_ => {
let (min, max) = (0, 31);
self.command_line
.push_error(&crate::tf!("Requires an integer between {min} and {max}."));
ask(self);
}
}
Some(Task::none())
}
cmd if cmd == "_FIELD_OBJECT" || cmd.starts_with("_FIELD_OBJECT ") => {
let handle = cmd
.split_whitespace()
.nth(1)
.and_then(|h| u64::from_str_radix(h, 16).ok())
.map(codec::Handle::new);
if let Some(state) = self.field_dialog.as_mut() {
if let Some(entity) = handle.and_then(|h| self.tabs[i].scene.document.get_entity(h)) {
let props = object_properties(entity);
state.object = Some((entity.common().handle, crate::t!(crate::entities::names::ui_name(entity)).into_owned()));
state.object_prop = props.first().copied();
state.object_props = props;
}
self.active_modal = Some(crate::app::ModalKind::Field);
self.refresh_field_preview();
}
Some(Task::none())
}
cmd if cmd == "_FIELD_CELL" || cmd.starts_with("_FIELD_CELL ") => {
let parts: Vec<&str> = cmd.split_whitespace().skip(1).collect();
if let Some(expression) = self.table_cell_expression(i, &parts) {
if let Some(state) = self.field_dialog.as_mut() {
// The reference appends at the end, with no operator.
state.formula.push_str(&expression);
}
self.refresh_field_preview();
// A real result gets the Decimal format, as the reference picks it.
if let Some(state) = self.field_dialog.as_mut() {
if state.formula_format == 0 && state.preview.contains('.') {
state.formula_format = 2;
}
}
}
if self.field_dialog.is_some() {
self.active_modal = Some(crate::app::ModalKind::Field);
self.refresh_field_preview();
}
Some(Task::none())
}
_ => None,
}
}
/// The formula text for a picked table cell or cell range:
/// `Table(%<\_ObjId H>%).B4` or `Table(%<\_ObjId H>%).Evaluate(Sum(A3:B5))`.
fn table_cell_expression(&self, i: usize, parts: &[&str]) -> Option<String> {
let function = *parts.first()?;
let cell = |at: &[&str]| -> Option<(codec::Handle, usize, usize)> {
let handle = codec::Handle::new(u64::from_str_radix(at.first()?, 16).ok()?);
let point = glam::DVec3::new(at.get(1)?.parse().ok()?, at.get(2)?.parse().ok()?, at.get(3)?.parse().ok()?);
let document = &self.tabs[i].scene.document;
let codec::EntityType::Table(table) = document.get_entity(handle)? else {
return None;
};
let hit = crate::modules::annotate::table_cmd::table_cell_at(table, None, point)?;
Some((handle, hit.row, hit.column))
};
let name = |row: usize, column: usize| format!("{}{}", column_letters(column), row + 1);
let (table, row, column) = cell(parts.get(1..5)?)?;
if function == "Cell" {
return Some(format!("Table(%<\\_ObjId {:X}>%).{}", table.value(), name(row, column)));
}
let (second_table, row2, column2) = cell(parts.get(5..9)?)?;
if second_table != table {
return None;
}
Some(format!(
"Table(%<\\_ObjId {:X}>%).Evaluate({function}({}:{}))",
table.value(),
name(row.min(row2), column.min(column2)),
name(row.max(row2), column.max(column2)),
))
}
/// Open the Field dialog for `target`.
pub(in crate::app) fn open_field_dialog(&mut self, target: FieldTarget) {
let mut state = FieldDialogState::new(target);
if !matches!(target, FieldTarget::NewText) {
state.placeholder_block = self.tabs[self.active_tab]
.active_block_edit_session()
.map(|session| session.block_name.clone());
}
state.examples = DATE_FORMATS
.iter()
.map(|f| self.field_value(&format!("\\AcVar Date \\f \"{f}\""), &[]))
.collect();
if let Some(db) = self.sheet_set.db() {
let custom = codec::sheet_set::custom_properties(db.sheet_set());
let names = |flag: i32| custom.iter().filter(|(_, _, f)| f & flag != 0).map(|(n, _, _)| n.clone()).collect();
state.ss_sheet_custom = names(codec::sheet_set::CUSTOM_SHEET_PROP);
state.ss_set_custom = names(codec::sheet_set::CUSTOM_SHEET_SET_PROP);
state.ss_set_name = db.name().to_string();
}
state.ss_sets = self.sheet_set.sets.clone();
state.ss_set = self.sheet_set.current.unwrap_or(0);
self.field_dialog = Some(state);
self.fill_named_objects();
self.refresh_field_preview();
self.active_modal = Some(crate::app::ModalKind::Field);
}
fn field_value(&self, code: &str, objects: &[codec::Handle]) -> String {
crate::entities::field::evaluate(&self.tabs[self.active_tab].scene.document, code, objects, None)
.unwrap_or_else(|| "----".to_string())
}
fn refresh_field_preview(&mut self) {
let Some(state) = self.field_dialog.as_ref() else {
return;
};
let (code, objects) = state.code();
let preview = if state.complete() {
if state.name == "BlockPlaceholder" {
// A placeholder shows its property name until a block reference resolves it.
let (label, _, _) = codec::fields::BLOCK_PLACEHOLDER_PROPERTIES[state.placeholder_property];
text_case(label, state.text_case)
} else {
self.field_value(&code, &objects)
}
} else {
String::new()
};
if let Some(state) = self.field_dialog.as_mut() {
state.preview = preview;
}
}
/// The names of the chosen named-object type, sorted.
fn fill_named_objects(&mut self) {
let Some(state) = self.field_dialog.as_mut() else {
return;
};
let document = &self.tabs[self.active_tab].scene.document;
let mut names: Vec<(String, codec::Handle)> = match NAMED_TYPES[state.named_type] {
"Block" => document
.block_records
.iter()
.filter(|b| !b.name.starts_with('*'))
.map(|b| (b.name.clone(), b.handle))
.collect(),
"Dimstyle" => document.dim_styles.iter().map(|s| (s.name.clone(), s.handle)).collect(),
"Layer" => document.layers.iter().map(|l| (l.name.clone(), l.handle)).collect(),
"Linetype" => document.line_types.iter().map(|l| (l.name.clone(), l.handle)).collect(),
"Textstyle" => document
.text_styles
.iter()
.filter(|s| !s.name.is_empty())
.map(|s| (s.name.clone(), s.handle))
.collect(),
"View" => document.views.iter().map(|v| (v.name.clone(), v.handle)).collect(),
_ => Vec::new(),
};
names.sort_by_key(|(n, _)| n.to_lowercase());
state.named_names = names;
state.named = None;
}
pub(in crate::app) fn on_field_dialog(&mut self, m: FieldDialogMsg) -> Task<Message> {
let i = self.active_tab;
let Some(state) = self.field_dialog.as_mut() else {
return Task::none();
};
match m {
FieldDialogMsg::Category(c) => {
state.category = c;
let names = fields_of(c);
if !names.contains(&state.name) {
state.name = names.first().copied().unwrap_or("Date");
}
}
FieldDialogMsg::Name(n) => {
state.name = n;
// Sheet set fields start in Title case.
state.text_case = if crate::ui::window::field_dialog::SHEET_SET_FIELDS.contains(&n) { 4 } else { 0 };
state.ss_custom.clear();
state.ss_node.clear();
state.ss_prop.clear();
// SheetSet starts on the set and its first property; SheetView on nothing.
if n == "SheetSet" {
if let Some(db) = state.ss_sets.get(state.ss_set) {
state.ss_node = db.sheet_set().id().to_string();
state.ss_prop = nav_props(db, &state.ss_node).first().map(|p| p.0.clone()).unwrap_or_default();
}
}
}
FieldDialogMsg::SsCustom(name) => state.ss_custom = name,
FieldDialogMsg::SsSet(k) => {
state.ss_set = k;
state.ss_node.clear();
state.ss_prop.clear();
}
FieldDialogMsg::SsNode(id) => {
state.ss_prop = state
.ss_sets
.get(state.ss_set)
.and_then(|db| nav_props(db, &id).first().map(|p| p.0.clone()))
.unwrap_or_default();
state.ss_node = id;
}
FieldDialogMsg::SsProp(p) => state.ss_prop = p,
FieldDialogMsg::SsHref(v) => state.ss_href = v,
FieldDialogMsg::SsBrowse => {
return Task::perform(
async {
crate::sys::file_dialog()
.set_title(crate::t!("Open Sheet Set").as_ref())
.add_filter(crate::t!("Sheet Set (*.dst)").as_ref(), &["dst", "DST"])
.pick_file()
.await
.map(|h| crate::sys::handle_path(&h))
},
|p| Message::FieldDialog(FieldDialogMsg::SsPicked(p)),
);
}
FieldDialogMsg::SsPicked(None) => {}
FieldDialogMsg::SsPicked(Some(path)) => match crate::app::commands::sheet_set::read_set(&path.to_string_lossy()) {
Ok(db) => {
let key = codec::sheet_set::path_key(&path.to_string_lossy());
let open = state
.ss_sets
.iter()
.position(|d| d.path.as_deref().is_some_and(|p| codec::sheet_set::path_key(p) == key));
state.ss_set = match open {
Some(k) => k,
None => {
state.ss_sets.push(db);
state.ss_sets.len() - 1
}
};
state.ss_node.clear();
state.ss_prop.clear();
}
Err(e) => self.command_line.push_error(&format!("{}: {e}", path.display())),
},
FieldDialogMsg::SsPlaceholder(k) => {
state.ss_placeholder = k;
state.ss_custom.clear();
}
FieldDialogMsg::SsScaleFormat(k) => state.ss_scale_format = k,
FieldDialogMsg::DateFormat(f) => state.date_format = f,
FieldDialogMsg::DateExample(k) => {
if let Some(f) = DATE_FORMATS.get(k) {
state.date_format = f.to_string();
}
}
FieldDialogMsg::TextCase(k) => state.text_case = k,
FieldDialogMsg::FileParts(b) => state.file_parts = b,
FieldDialogMsg::FileExtension(v) => state.file_extension = v,
FieldDialogMsg::SizeUnit(k) => state.size_unit = k,
FieldDialogMsg::SysVar(v) => state.sysvar = v,
FieldDialogMsg::Diesel(v) => state.diesel = v,
FieldDialogMsg::NamedType(k) => {
state.named_type = k;
self.fill_named_objects();
}
FieldDialogMsg::Named(k) => state.named = Some(k),
FieldDialogMsg::ObjectProp(p) => state.object_prop = Some(p),
FieldDialogMsg::Formula(v) => state.formula = v,
FieldDialogMsg::FormulaFormat(k) => state.formula_format = k,
FieldDialogMsg::FormulaPrecision(k) => state.formula_precision = k,
FieldDialogMsg::Evaluate => {}
FieldDialogMsg::HyperlinkText(v) => state.hyperlink_text = v,
FieldDialogMsg::HyperlinkUrl(v) => state.hyperlink_url = v,
FieldDialogMsg::BrowseHyperlink => {
return Task::perform(
async {
rfd::AsyncFileDialog::new()
.set_title(crate::t!("Select a file to link").as_ref())
.pick_file()
.await
.map(|h| crate::sys::handle_path(&h))
},
|path| match path {
Some(path) => Message::FieldDialog(FieldDialogMsg::HyperlinkUrl(path.to_string_lossy().into_owned())),
None => Message::Noop,
},
);
}
FieldDialogMsg::PlotScale(k) => state.plot_scale = k,
FieldDialogMsg::CountExpression(v) => state.count_expression = v,
FieldDialogMsg::ShowCountInstances => {
// The dialog closes and count mode shows what the expression counts.
let json = state.count_expression.clone();
self.field_dialog = None;
self.close_active_modal();
self.show_count_instances(i, &json);
return Task::none();
}
FieldDialogMsg::PlaceholderProperty(k) => {
state.placeholder_property = k;
state.text_case = 0;
}
FieldDialogMsg::TableFunction(function) => {
// The dialog waits while the cells are picked.
self.active_modal = None;
let command = FieldTablePickCommand::new(function);
self.command_line.push_info(&command.prompt());
self.tabs[i].active_cmd = Some(Box::new(command));
return Task::none();
}
FieldDialogMsg::SelectObject => {
// The dialog waits while one object is picked.
self.active_modal = None;
let command = FieldObjectPickCommand;
self.command_line.push_info(&command.prompt());
self.tabs[i].active_cmd = Some(Box::new(command));
return Task::none();
}
FieldDialogMsg::Help => self.command_line.push_info(
crate::t!("Inserts a field: text that updates from the drawing, the date or another source.").as_ref(),
),
FieldDialogMsg::Ok => return self.field_dialog_ok(i),
}
self.refresh_field_preview();
Task::none()
}
fn field_dialog_ok(&mut self, i: usize) -> Task<Message> {
let Some(state) = self.field_dialog.take() else {
return Task::none();
};
let (code, objects) = state.code();
let value = state.preview.clone();
let field = Some((code, objects));
match state.target {
FieldTarget::NewText => {
self.close_active_modal();
let defaults =
crate::scene::creation_style::current_text_defaults(&self.tabs[i].scene.document);
let command = FieldPlaceCommand::new(
value,
field.expect("set above"),
defaults.style_name,
defaults.height,
);
self.reset_command_start_state(i);
self.command_line.push_info(&command.prompt());
self.tabs[i].active_cmd = Some(Box::new(command));
self.push_ucs_to_cmd(i);
return self.focus_cmd_input();
}
FieldTarget::AttdefDefault => {
if let Some(dialog) = self.attdef_dialog.as_mut() {
dialog.default = value;
dialog.field = field;
}
self.active_modal = Some(crate::app::ModalKind::AttDef);
}
FieldTarget::AttdefEdit => {
if let Some(edit) = self.attdef_edit.as_mut() {
edit.default = value;
edit.field = field;
edit.field_changed = true;
}
self.active_modal = Some(crate::app::ModalKind::AttDefEdit);
}
}
Task::none()
}
/// The dialogs a Field dialog was opened from come back when it closes.
pub(in crate::app) fn close_field_dialog(&mut self) {
let target = self.field_dialog.take().map(|s| s.target);
self.close_active_modal();
self.active_modal = match target {
Some(FieldTarget::AttdefDefault) if self.attdef_dialog.is_some() => Some(crate::app::ModalKind::AttDef),
Some(FieldTarget::AttdefEdit) if self.attdef_edit.is_some() => Some(crate::app::ModalKind::AttDefEdit),
_ => None,
};
}
}
/// Spreadsheet column letters: A … Z, AA, AB …
fn column_letters(mut column: usize) -> String {
let mut letters = Vec::new();
loop {
letters.push(b'A' + (column % 26) as u8);
if column < 26 {
break;
}
column = column / 26 - 1;
}
letters.reverse();
String::from_utf8(letters).unwrap_or_default()
}
/// The Field dialog's text cases: (none), Uppercase, Lowercase, First capital, Title case.
fn text_case(value: &str, case: usize) -> String {
match case {
1 => value.to_uppercase(),
2 => value.to_lowercase(),
3 => {
let lower = value.to_lowercase();
let mut chars = lower.chars();
chars.next().map(|c| c.to_uppercase().chain(chars).collect()).unwrap_or_default()
}
4 => value
.split(' ')
.map(|word| {
let lower = word.to_lowercase();
let mut chars = lower.chars();
chars.next().map(|c| c.to_uppercase().chain(chars).collect::<String>()).unwrap_or_default()
})
.collect::<Vec<_>>()
.join(" "),
_ => value.to_string(),
}
}
+200 -25
View File
@@ -3,7 +3,25 @@ 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)),
// QNEW starts from the default template (Options > Files); NEW
// asks which template to start from (#1635).
"QNEW" => return Some(Task::done(Message::TabNew)),
"NEW" => {
return Some(Task::perform(
async {
crate::sys::file_dialog()
.set_title(crate::t!("Select Template").as_ref())
.add_filter(
crate::t!("Drawing Template (*.dwt)").as_ref(),
&["dwt", "DWT", "dwg", "DWG", "dxf", "DXF"],
)
.pick_file()
.await
.map(|handle| crate::sys::handle_path(&handle))
},
Message::NewFromTemplate,
));
}
"OPEN" => return Some(Task::done(Message::OpenFile)),
"SAVE" | "QSAVE" => return Some(Task::done(Message::SaveFile)),
// SAVEALL — write every open drawing that already has a file path.
@@ -141,7 +159,10 @@ impl OpenCADStudio {
// CLOSE — close the active drawing tab (with the unsaved-changes
// prompt the tab-close handler already runs).
"CLOSE" => {
return Some(Task::done(Message::TabClose(self.active_tab)));
return Some(match self.tabs.get(self.active_tab) {
Some(tab) => Task::done(Message::TabClose(tab.id)),
None => Task::none(),
});
}
// ARCHIVE / ETRANSMIT — package the drawing and its referenced files
@@ -173,7 +194,7 @@ impl OpenCADStudio {
}
}
for e in self.tabs[i].scene.document.entities() {
if let acadrust::EntityType::RasterImage(img) = e {
if let codec::EntityType::RasterImage(img) = e {
if !img.file_path.trim().is_empty() {
deps.push(img.file_path.clone());
}
@@ -339,33 +360,55 @@ impl OpenCADStudio {
// line is fed through the same command path the `--script` startup
// flag uses. Blank rows submit Enter to the active command.
"SCRIPT" | "SCR" => {
use crate::command::ValuePromptCommand;
let c = ValuePromptCommand::new("SCRIPT", "SCRIPT path to the .scr file:");
self.command_line.push_info(&c.prompt());
self.tabs[i].active_cmd = Some(Box::new(c));
#[cfg(not(target_arch = "wasm32"))]
{
return Some(Task::perform(
async move {
crate::sys::file_dialog()
.add_filter("Script files (*.scr)", &["scr"])
.pick_file()
.await
.map(|h| crate::sys::handle_path(&h))
},
|path| match path {
Some(p) => Message::Command(format!("SCRIPT \"{}\"", p.display())),
None => Message::Noop,
},
));
}
#[cfg(target_arch = "wasm32")]
{
use crate::command::ValuePromptCommand;
let c = ValuePromptCommand::new("SCRIPT", "SCRIPT path to the .scr file:");
self.command_line.push_info(&c.prompt());
self.tabs[i].active_cmd = Some(Box::new(c));
}
}
cmd if cmd.starts_with("SCRIPT ") || cmd.starts_with("SCR ") => {
let path = cmd.split_once(' ').map(|(_, r)| r.trim().to_string());
match path {
Some(p) if !p.is_empty() => match std::fs::read_to_string(&p) {
Ok(text) => {
let cmds: Vec<Task<Message>> = text
.lines()
.map(str::trim)
.filter(|l| !l.starts_with('#') && !l.starts_with(';'))
.map(|l| Task::done(Message::ScriptLine(l.to_string())))
.collect();
self.command_line.push_output(crate::tf!(
"SCRIPT: running {} command(s) from {p}.",
cmds.len()
).as_ref());
return Some(Task::batch(cmds));
Some(p) if !p.is_empty() => {
let clean_path = p.trim().trim_matches(|c| c == '"' || c == '\'');
match std::fs::read_to_string(clean_path) {
Ok(text) => {
let cmds: Vec<Task<Message>> = text
.lines()
.map(str::trim)
.filter(|l| !l.starts_with('#') && !l.starts_with(';'))
.map(|l| Task::done(Message::ScriptLine(l.to_string())))
.collect();
self.command_line.push_output(crate::tf!(
"SCRIPT: running {} command(s) from {clean_path}.",
cmds.len()
).as_ref());
return Some(Task::batch(cmds));
}
Err(e) => {
self.command_line
.push_error(crate::tf!("SCRIPT: cannot read {clean_path}: {e}").as_ref());
}
}
Err(e) => {
self.command_line
.push_error(crate::tf!("SCRIPT: cannot read {p}: {e}").as_ref());
}
},
}
_ => {
self.command_line
.push_info(crate::t!("Usage: SCRIPT <path to .scr file>").as_ref());
@@ -418,3 +461,135 @@ fn parse_background_color(args: &[&str]) -> Option<[f32; 4]> {
}
None
}
#[cfg(test)]
mod tests {
use crate::app::{Message, OpenCADStudio};
fn fresh_app() -> OpenCADStudio {
let mut app = OpenCADStudio::new_for_test();
app.automation_op(r#"{"op":"new"}"#);
app
}
#[test]
fn multiline_script_draws_line() {
let mut app = fresh_app();
let i = app.active_tab;
let before_count = app.tabs[i].scene.document.entities().count();
// Feed standard multi-line CAD script sequence
let _ = app.update(Message::ScriptLine("LINE".into()));
assert!(app.tabs[i].active_cmd.is_some(), "LINE command should be active");
let _ = app.update(Message::ScriptLine("0,0".into()));
assert!(app.tabs[i].active_cmd.is_some(), "LINE command should stay active after first point");
let _ = app.update(Message::ScriptLine("10,10".into()));
let _ = app.update(Message::ScriptLine("".into())); // Enter to finish
let after_count = app.tabs[i].scene.document.entities().count();
assert_eq!(after_count, before_count + 1, "Line entity should have been created");
assert!(app.tabs[i].active_cmd.is_none(), "LINE command should be finished");
}
#[test]
fn single_line_script_still_works() {
let mut app = fresh_app();
let i = app.active_tab;
let before_count = app.tabs[i].scene.document.entities().count();
let _ = app.update(Message::ScriptLine("CIRCLE 5,5 2".into()));
let after_count = app.tabs[i].scene.document.entities().count();
assert_eq!(after_count, before_count + 1, "Circle entity should have been created");
assert!(app.tabs[i].active_cmd.is_none(), "CIRCLE command should be finished");
}
#[test]
fn command_name_prefixes_are_stripped() {
let mut app = fresh_app();
let i = app.active_tab;
let before_count = app.tabs[i].scene.document.entities().count();
let _ = app.update(Message::ScriptLine("_CIRCLE 10,10 3".into()));
let after_count = app.tabs[i].scene.document.entities().count();
assert_eq!(after_count, before_count + 1, "_CIRCLE should resolve to CIRCLE and create entity");
}
#[test]
fn test_script_scr_draws_all_entities() {
let mut app = fresh_app();
let i = app.active_tab;
const SCRIPT: &str = r#"
_LINE
0,0
100,0
100,100
0,100
C
_LINE
0,0
100,100
_LINE
0,100
100,0
CIRCLE
50,50
35
CIRCLE 50,50 15
ZOOM EXTENTS
"#;
for line in SCRIPT.lines().map(str::trim).filter(|l| !l.starts_with('#') && !l.starts_with(';')) {
let _ = app.update(Message::ScriptLine(line.to_string()));
}
// 4 square edges + 2 diagonal lines + 2 circles = 8 entities
assert_eq!(app.tabs[i].scene.document.entities().count(), 8);
}
#[test]
fn blank_line_repeats_last_command_when_idle() {
let mut app = fresh_app();
let i = app.active_tab;
// Blank line before any command does nothing and does not panic
let _ = app.update(Message::ScriptLine("".into()));
assert!(app.tabs[i].active_cmd.is_none());
// 1. Draw a line
let _ = app.update(Message::ScriptLine("LINE".into()));
let _ = app.update(Message::ScriptLine("0,0".into()));
let _ = app.update(Message::ScriptLine("10,10".into()));
let _ = app.update(Message::ScriptLine("".into())); // finishes LINE
assert_eq!(app.tabs[i].scene.document.entities().count(), 1);
assert!(app.tabs[i].active_cmd.is_none());
// 2. Blank line when idle repeats LINE
let _ = app.update(Message::ScriptLine("".into()));
assert!(app.tabs[i].active_cmd.is_some(), "Blank line should repeat LINE");
let _ = app.update(Message::ScriptLine("20,20".into()));
let _ = app.update(Message::ScriptLine("30,30".into()));
let _ = app.update(Message::ScriptLine("".into())); // finishes second LINE
assert_eq!(app.tabs[i].scene.document.entities().count(), 2);
assert!(app.tabs[i].active_cmd.is_none());
// 3. Single-line command repeat
let _ = app.update(Message::ScriptLine("CIRCLE 5,5 2".into()));
assert_eq!(app.tabs[i].scene.document.entities().count(), 3);
assert!(app.tabs[i].active_cmd.is_none());
// Blank line repeats CIRCLE
let _ = app.update(Message::ScriptLine("".into()));
assert!(app.tabs[i].active_cmd.is_some(), "Blank line should repeat CIRCLE");
let _ = app.update(Message::ScriptLine("15,15".into()));
let _ = app.update(Message::ScriptLine("3".into()));
assert_eq!(app.tabs[i].scene.document.entities().count(), 4);
assert!(app.tabs[i].active_cmd.is_none());
}
}
+68 -550
View File
@@ -4,7 +4,7 @@ impl OpenCADStudio {
/// Align every selected object or complete group by one edge or center.
fn align_selected_bounds(&mut self, i: usize, command: &str) {
use crate::command::EntityTransform;
use cadkernel::space::BoundsAlignment;
use kernel::space::BoundsAlignment;
use glam::DVec3;
let (alignment, label) = match command {
@@ -58,7 +58,7 @@ impl OpenCADStudio {
}
let extents: Vec<_> = bounds.iter().map(|(_, bounds)| *bounds).collect();
let offsets = cadkernel::space::align_aabbs_2d(&extents, alignment)
let offsets = kernel::space::align_aabbs_2d(&extents, alignment)
.expect("validated at least two finite bounds");
let pending = self.begin_undo(i, label, handles.len(), true);
for ((unit, _), [dx, dy]) in bounds.iter().zip(offsets) {
@@ -207,7 +207,7 @@ impl OpenCADStudio {
*type_counts
.entry(crate::entities::names::dxf_name(e))
.or_default() += 1;
if let acadrust::EntityType::Insert(ins) = e {
if let codec::EntityType::Insert(ins) = e {
*block_counts.entry(ins.block_name.clone()).or_default() += 1;
}
}
@@ -287,7 +287,7 @@ impl OpenCADStudio {
use crate::modules::draw::modify::join::JoinCommand;
// Pickfirst: with objects already selected, join them right
// away instead of asking for a selection again.
let selected: Vec<acadrust::Handle> =
let selected: Vec<codec::Handle> =
self.tabs[i].scene.selected.iter().copied().collect();
if selected.len() >= 2 {
let task =
@@ -325,8 +325,8 @@ impl OpenCADStudio {
.filter_map(|e| {
let h = e.common().handle.value();
match e {
acadrust::EntityType::LwPolyline(_)
| acadrust::EntityType::Polyline2D(_) => Some((
codec::EntityType::LwPolyline(_)
| codec::EntityType::Polyline2D(_) => Some((
h,
PeditTarget {
is_poly: true,
@@ -335,7 +335,7 @@ impl OpenCADStudio {
mesh_closed: None,
},
)),
acadrust::EntityType::Line(_) | acadrust::EntityType::Arc(_) => {
codec::EntityType::Line(_) | codec::EntityType::Arc(_) => {
Some((
h,
PeditTarget {
@@ -346,7 +346,7 @@ impl OpenCADStudio {
},
))
}
acadrust::EntityType::PolygonMesh(mesh) => Some((
codec::EntityType::PolygonMesh(mesh) => Some((
h,
PeditTarget {
is_poly: true,
@@ -364,7 +364,7 @@ impl OpenCADStudio {
.collect();
// Pickfirst: an already-selected polyline (or line/arc, via
// the convert prompt) skips the select step.
let preselected: Vec<acadrust::Handle> =
let preselected: Vec<codec::Handle> =
self.tabs[i].scene.selected.iter().copied().collect();
let header = &self.tabs[i].scene.document.header;
let cmd_obj = PeditCommand::new(
@@ -387,12 +387,12 @@ impl OpenCADStudio {
let targets = document
.entities()
.filter_map(|entity| {
let acadrust::EntityType::MLine(mline) = entity else {
let codec::EntityType::MLine(mline) = entity else {
return None;
};
let style = crate::entities::mline::resolved_mline_style(mline, document)
.cloned()
.unwrap_or_else(acadrust::objects::MLineStyle::standard);
.unwrap_or_else(codec::objects::MLineStyle::standard);
Some((
entity.common().handle.value(),
MlineEditTarget {
@@ -412,7 +412,7 @@ impl OpenCADStudio {
let mut cmd_obj = SplineditCommand::new().with_delete_source(self.delete_objects != 0);
let selected: Vec<_> = self.tabs[i].scene.selected.iter().copied().collect();
if let [handle] = selected.as_slice() {
if let Some(entity @ acadrust::EntityType::Spline(_)) =
if let Some(entity @ codec::EntityType::Spline(_)) =
self.tabs[i].scene.document.get_entity(*handle).cloned()
{
if self.reject_locked_edit(i, *handle) { return Some(Task::none()); }
@@ -444,7 +444,7 @@ impl OpenCADStudio {
let selected: Vec<_> =
self.tabs[i].scene.selected_entities().into_iter().collect();
if selected.len() == 1 {
if let Some(acadrust::EntityType::Insert(_)) =
if let Some(codec::EntityType::Insert(_)) =
selected.first().map(|(_, e)| e)
{
let handle = selected[0].0;
@@ -470,7 +470,7 @@ impl OpenCADStudio {
let selected: Vec<_> =
self.tabs[i].scene.selected_entities().into_iter().collect();
if selected.len() == 1 {
if let Some(acadrust::EntityType::Insert(_)) =
if let Some(codec::EntityType::Insert(_)) =
selected.first().map(|(_, e)| e)
{
let handle = selected[0].0;
@@ -487,7 +487,7 @@ impl OpenCADStudio {
cmd if cmd.starts_with("BEDIT_BEGIN:") => {
use crate::modules::draw::modify::block_edit::BlockEditSession;
use acadrust::Handle;
use codec::Handle;
let handle_u64: u64 = cmd["BEDIT_BEGIN:".len()..].parse().unwrap_or(0);
let insert_handle = Handle::new(handle_u64);
@@ -496,7 +496,7 @@ impl OpenCADStudio {
}
let block_name = match self.tabs[i].scene.document.get_entity(insert_handle) {
Some(acadrust::EntityType::Insert(ins)) => ins.block_name.clone(),
Some(codec::EntityType::Insert(ins)) => ins.block_name.clone(),
_ => {
self.command_line
.push_error(crate::t!("BEDIT: selected object is not a block reference.").as_ref());
@@ -563,7 +563,7 @@ impl OpenCADStudio {
.document
.entities()
.filter_map(|entity| match entity {
acadrust::EntityType::Insert(reference)
codec::EntityType::Insert(reference)
if reference.block_name.eq_ignore_ascii_case(&block_name)
&& !reference.attributes.is_empty() =>
{
@@ -735,7 +735,7 @@ impl OpenCADStudio {
let _ = self.tabs[i].scene.document.add_entity(entity);
}
for (handle, attributes) in session.reference_attributes {
if let Some(acadrust::EntityType::Insert(reference)) =
if let Some(codec::EntityType::Insert(reference)) =
self.tabs[i].scene.document.get_entity_mut(handle)
{
reference.attributes = attributes;
@@ -765,7 +765,7 @@ impl OpenCADStudio {
use crate::modules::draw::modify::refedit::{
apply_insert_transform, RefEditSession,
};
use acadrust::Handle;
use codec::Handle;
let handle_u64: u64 = cmd["REFEDIT_BEGIN:".len()..].parse().unwrap_or(0);
let insert_handle = Handle::new(handle_u64);
@@ -775,7 +775,7 @@ impl OpenCADStudio {
// Get INSERT entity.
let insert = match self.tabs[i].scene.document.get_entity(insert_handle) {
Some(acadrust::EntityType::Insert(ins)) => ins.clone(),
Some(codec::EntityType::Insert(ins)) => ins.clone(),
_ => {
self.command_line
.push_error(crate::t!("REFEDIT: selected object is not an INSERT.").as_ref());
@@ -791,7 +791,7 @@ impl OpenCADStudio {
let sz = insert.z_scale();
let forward = insert.get_transform();
let inverse = {
use acadrust::types::{Matrix3, Matrix4, Transform};
use codec::types::{Matrix3, Matrix4, Transform};
let ocs_t =
Matrix4::from_matrix3(Matrix3::arbitrary_axis(insert.normal).transpose());
let t_inv = Matrix4::translation(
@@ -836,9 +836,9 @@ impl OpenCADStudio {
.filter(|e| {
!matches!(
e,
acadrust::EntityType::Block(_)
| acadrust::EntityType::BlockEnd(_)
| acadrust::EntityType::AttributeDefinition(_)
codec::EntityType::Block(_)
| codec::EntityType::BlockEnd(_)
| codec::EntityType::AttributeDefinition(_)
)
})
.collect()
@@ -871,8 +871,8 @@ impl OpenCADStudio {
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;
entity.common_mut().handle = codec::Handle::NULL;
entity.common_mut().owner_handle = codec::Handle::NULL;
let h = self.tabs[i].scene.add_entity(entity);
temp_handles.push(h);
}
@@ -933,7 +933,7 @@ impl OpenCADStudio {
// offset entities carry handles at or above the watermark) —
// those must fold into the block too, not stay behind in
// model space (#423).
let working: Vec<acadrust::Handle> = {
let working: Vec<codec::Handle> = {
let space = self.tabs[i].scene.current_layout_block_handle_pub();
let drawn = self.tabs[i].scene.document.entities().filter(|e| {
let c = e.common();
@@ -950,7 +950,7 @@ impl OpenCADStudio {
};
// Collect the edited temp entities.
let new_entities: Vec<acadrust::EntityType> = working
let new_entities: Vec<codec::EntityType> = working
.iter()
.filter_map(|h| self.tabs[i].scene.document.get_entity(*h).cloned())
.collect();
@@ -964,7 +964,7 @@ impl OpenCADStudio {
.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().handle = codec::Handle::NULL;
entity.common_mut().owner_handle = session.br_handle;
entity
})
@@ -1021,7 +1021,7 @@ impl OpenCADStudio {
};
// Remove the working set without modifying the block — the
// temp copies plus anything created during the session (#423).
let working: Vec<acadrust::Handle> = {
let working: Vec<codec::Handle> = {
let space = self.tabs[i].scene.current_layout_block_handle_pub();
let drawn = self.tabs[i].scene.document.entities().filter(|e| {
let c = e.common();
@@ -1047,7 +1047,7 @@ impl OpenCADStudio {
"ALIGN" => {
use crate::modules::draw::modify::align::AlignCommand;
let selected: Vec<acadrust::Handle> =
let selected: Vec<codec::Handle> =
self.tabs[i].scene.selected.iter().copied().collect();
let cmd = AlignCommand::with_selection(selected);
@@ -1131,48 +1131,25 @@ impl OpenCADStudio {
// ── FLATTEN — move selected (or all) entities to Z=0 ─────────────
"FLATTEN" => {
let handles: Vec<acadrust::Handle> = {
let scene = &self.tabs[i].scene;
let sel = self.tabs[i].scene.selected_entities();
if sel.is_empty() {
scene
.document
.entities()
.map(|e| e.common().handle)
.filter(|handle| {
scene.entity_belongs_to_active_space(*handle)
&& !scene.is_layer_locked(*handle)
})
.collect()
} else {
sel.into_iter()
.map(|(h, _)| h)
.filter(|handle| {
scene.entity_belongs_to_active_space(*handle)
&& !scene.is_layer_locked(*handle)
})
.collect()
}
};
let scene = &self.tabs[i].scene;
let handles =
crate::modules::draw::modify::flatten::collect_flatten_handles(scene);
if handles.is_empty() {
self.command_line.push_error(crate::t!("FLATTEN: no entities.").as_ref());
} else {
let candidate_count = handles.len();
let updates: Vec<_> = handles
.iter()
.filter_map(|handle| self.tabs[i].scene.document.get_entity(*handle))
.filter_map(flatten_entity_z)
.collect();
let updates = crate::modules::draw::modify::flatten::plan_flatten(
&self.tabs[i].scene,
&handles,
);
let moved = if updates.is_empty() {
0
} else {
self.push_undo_snapshot(i, "FLATTEN");
let mut moved = 0usize;
for entity in updates {
if self.tabs[i].scene.update_entity(entity) {
moved += 1;
}
}
let moved = crate::modules::draw::modify::flatten::apply_flatten_updates(
&mut self.tabs[i].scene,
updates,
);
if moved == 0 {
self.discard_last_undo_entry(i);
}
@@ -1201,7 +1178,7 @@ impl OpenCADStudio {
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]
let matched: Vec<codec::Handle> = self.tabs[i]
.scene
.document
.entities()
@@ -1239,45 +1216,6 @@ impl OpenCADStudio {
}
}
// ── COUNT — entity statistics ─────────────────────────────────────
"COUNT" => {
use crate::command::KeywordCommand;
let c = KeywordCommand::new(
"COUNT",
"COUNT tally [All (by type) / by Layer]:",
vec![("All", "TYPE", None), ("By layer", "LAYER", None)],
);
self.command_line.push_info(&c.prompt());
self.tabs[self.active_tab].active_cmd = Some(Box::new(c));
}
cmd if 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(crate::tf!(" {k}: {n}").as_ref());
}
self.command_line
.push_output(crate::tf!("COUNT: {total} entity(ies) total.").as_ref());
}
"DATAEXTRACTION" | "EATTEXT" | "ATTEXT" => {
self.open_data_extraction();
}
@@ -1316,7 +1254,7 @@ impl OpenCADStudio {
} else {
let search_lc = search.to_lowercase();
let mut count = 0usize;
let handles: Vec<acadrust::Handle> = self.tabs[i]
let handles: Vec<codec::Handle> = self.tabs[i]
.scene
.document
.entities()
@@ -1475,7 +1413,7 @@ impl OpenCADStudio {
// A dimension template also carries its dimension style, adopted
// as the current DIMSTYLE so the cloned dimension matches (#239).
match e {
acadrust::EntityType::Dimension(d) => Some(d.base().style_name.clone()),
codec::EntityType::Dimension(d) => Some(d.base().style_name.clone()),
_ => None,
},
)
@@ -1536,7 +1474,7 @@ impl OpenCADStudio {
.layers
.get(&layer)
.map(|l| l.handle)
.unwrap_or(acadrust::types::Handle::NULL);
.unwrap_or(codec::types::Handle::NULL);
let lt_handle = self.tabs[i]
.scene
.document
@@ -1544,7 +1482,7 @@ impl OpenCADStudio {
.iter()
.find(|x| x.name.eq_ignore_ascii_case(&linetype))
.map(|x| x.handle)
.unwrap_or(acadrust::types::Handle::NULL);
.unwrap_or(codec::types::Handle::NULL);
{
let header = &mut self.tabs[i].scene.document.header;
header.current_layer_name = layer.clone();
@@ -1565,7 +1503,7 @@ impl OpenCADStudio {
.iter()
.find(|s| s.name.eq_ignore_ascii_case(ds))
.map(|s| s.handle)
.unwrap_or(acadrust::types::Handle::NULL);
.unwrap_or(codec::types::Handle::NULL);
let header = &mut self.tabs[i].scene.document.header;
header.current_dimstyle_name = ds.clone();
header.current_dimstyle_handle = ds_handle;
@@ -1622,8 +1560,8 @@ impl OpenCADStudio {
/// Map a template entity to the draw-command verb that creates the same kind of
/// object, or `None` when there is no interactive creator for it. Used by
/// ADDSELECTED (issue #239).
fn add_selected_verb(entity: &acadrust::EntityType) -> Option<&'static str> {
use acadrust::EntityType;
fn add_selected_verb(entity: &codec::EntityType) -> Option<&'static str> {
use codec::EntityType;
Some(match entity {
EntityType::Point(_) => "POINT",
EntityType::Line(_) => "LINE",
@@ -1644,7 +1582,7 @@ fn add_selected_verb(entity: &acadrust::EntityType) -> Option<&'static str> {
// Dimensions launch the matching dimension command by their stored type
// (issue #239). Angular 2-line and 3-point both use DIMANGULAR.
EntityType::Dimension(d) => {
use acadrust::entities::DimensionType;
use codec::entities::DimensionType;
match d.base().dimension_type {
DimensionType::Linear => "DIMLINEAR",
DimensionType::Aligned => "DIMALIGNED",
@@ -1660,10 +1598,10 @@ fn add_selected_verb(entity: &acadrust::EntityType) -> Option<&'static str> {
})
}
fn entity_list_details(entity: &acadrust::EntityType) -> String {
fn entity_list_details(entity: &codec::EntityType) -> String {
use std::f64::consts::PI;
match entity {
acadrust::EntityType::Line(l) => crate::tf!(
codec::EntityType::Line(l) => crate::tf!(
"from ({:.4},{:.4},{:.4}) to ({:.4},{:.4},{:.4}) len={:.4}",
l.start.x,
l.start.y,
@@ -1676,7 +1614,7 @@ fn entity_list_details(entity: &acadrust::EntityType) -> String {
+ (l.end.z - l.start.z).powi(2))
.sqrt()
).into_owned(),
acadrust::EntityType::Circle(c) => crate::tf!(
codec::EntityType::Circle(c) => crate::tf!(
"center ({:.4},{:.4},{:.4}) r={:.4} area={:.4}",
c.center.x,
c.center.y,
@@ -1684,7 +1622,7 @@ fn entity_list_details(entity: &acadrust::EntityType) -> String {
c.radius,
PI * c.radius * c.radius
).into_owned(),
acadrust::EntityType::Arc(a) => crate::tf!(
codec::EntityType::Arc(a) => crate::tf!(
"center ({:.4},{:.4},{:.4}) r={:.4} start={:.2}° end={:.2}°",
a.center.x,
a.center.y,
@@ -1693,24 +1631,24 @@ fn entity_list_details(entity: &acadrust::EntityType) -> String {
a.start_angle.to_degrees(),
a.end_angle.to_degrees()
).into_owned(),
acadrust::EntityType::LwPolyline(p) => crate::tf!(
codec::EntityType::LwPolyline(p) => crate::tf!(
"{} vertices closed={} elevation={:.4}",
p.vertices.len(),
p.is_closed,
p.elevation
).into_owned(),
acadrust::EntityType::Text(t) => crate::tf!(
codec::EntityType::Text(t) => crate::tf!(
"\"{}\" h={:.4} at ({:.4},{:.4})",
t.value, t.height, t.insertion_point.x, t.insertion_point.y
).into_owned(),
acadrust::EntityType::MText(t) => crate::tf!(
codec::EntityType::MText(t) => crate::tf!(
"\"{}\" h={:.4} at ({:.4},{:.4})",
t.value.chars().take(40).collect::<String>(),
t.height,
t.insertion_point.x,
t.insertion_point.y
).into_owned(),
acadrust::EntityType::Insert(ins) => crate::tf!(
codec::EntityType::Insert(ins) => crate::tf!(
"block=\"{}\" at ({:.4},{:.4},{:.4}) scale=({:.4},{:.4},{:.4}) rot={:.2}°",
ins.block_name,
ins.insert_point.x,
@@ -1721,13 +1659,13 @@ fn entity_list_details(entity: &acadrust::EntityType) -> String {
ins.z_scale(),
ins.rotation.to_degrees()
).into_owned(),
acadrust::EntityType::Spline(s) => crate::tf!(
codec::EntityType::Spline(s) => crate::tf!(
"{} ctrl pts degree={} closed={}",
s.control_points.len(),
s.degree,
s.flags.closed
).into_owned(),
acadrust::EntityType::Ellipse(e) => crate::tf!(
codec::EntityType::Ellipse(e) => crate::tf!(
"center ({:.4},{:.4}) major_len={:.4} ratio={:.4}",
e.center.x,
e.center.y,
@@ -1738,283 +1676,6 @@ fn entity_list_details(entity: &acadrust::EntityType) -> String {
}
}
fn flatten_entity_z(entity: &acadrust::EntityType) -> Option<acadrust::EntityType> {
use acadrust::types::Vector3;
let flattened = match entity {
acadrust::EntityType::Circle(_)
| acadrust::EntityType::Arc(_)
| acadrust::EntityType::Ellipse(_)
| acadrust::EntityType::LwPolyline(_)
| acadrust::EntityType::Polyline2D(_) => flatten_curve_entity(entity)?,
acadrust::EntityType::Line(source) => {
let mut line = source.clone();
line.start = flatten_point(line.start)?;
line.end = flatten_point(line.end)?;
line.thickness = 0.0;
line.normal = Vector3::UNIT_Z;
acadrust::EntityType::Line(line)
}
acadrust::EntityType::Polyline(source) => {
let mut polyline = source.clone();
for vertex in &mut polyline.vertices {
vertex.location = flatten_point(vertex.location)?;
}
acadrust::EntityType::Polyline(polyline)
}
acadrust::EntityType::Polyline3D(source) => {
let mut polyline = source.clone();
polyline.elevation = 0.0;
polyline.normal = Vector3::UNIT_Z;
for vertex in &mut polyline.vertices {
vertex.position = flatten_point(vertex.position)?;
}
acadrust::EntityType::Polyline3D(polyline)
}
acadrust::EntityType::Text(source) if positive_z_normal(source.normal) => {
let mut text = source.clone();
text.insertion_point = flatten_point(text.insertion_point)?;
if let Some(alignment) = text.alignment_point {
text.alignment_point = Some(flatten_point(alignment)?);
}
text.thickness = 0.0;
text.normal = Vector3::UNIT_Z;
acadrust::EntityType::Text(text)
}
acadrust::EntityType::MText(source) if positive_z_normal(source.normal) => {
let mut text = source.clone();
text.insertion_point = flatten_point(text.insertion_point)?;
text.normal = Vector3::UNIT_Z;
acadrust::EntityType::MText(text)
}
acadrust::EntityType::Point(source) => {
let mut point = source.clone();
point.location = flatten_point(point.location)?;
point.thickness = 0.0;
point.normal = Vector3::UNIT_Z;
acadrust::EntityType::Point(point)
}
acadrust::EntityType::Spline(source) => {
let mut spline = source.clone();
for point in &mut spline.control_points {
*point = flatten_point(*point)?;
}
for point in &mut spline.fit_points {
*point = flatten_point(*point)?;
}
spline.begin_tangent = flatten_vector(spline.begin_tangent)?;
spline.end_tangent = flatten_vector(spline.end_tangent)?;
spline.normal = Vector3::UNIT_Z;
spline.flags.planar = true;
acadrust::EntityType::Spline(spline)
}
acadrust::EntityType::Solid(source) => {
let corners = crate::entities::solid::wcs_corners(source);
let mut solid = source.clone();
solid.first_corner = flatten_array(corners[0])?;
solid.second_corner = flatten_array(corners[1])?;
solid.third_corner = flatten_array(corners[2])?;
solid.fourth_corner = flatten_array(corners[3])?;
solid.normal = Vector3::UNIT_Z;
solid.thickness = 0.0;
acadrust::EntityType::Solid(solid)
}
acadrust::EntityType::Face3D(source) => {
let mut face = source.clone();
face.first_corner = flatten_point(face.first_corner)?;
face.second_corner = flatten_point(face.second_corner)?;
face.third_corner = flatten_point(face.third_corner)?;
face.fourth_corner = flatten_point(face.fourth_corner)?;
acadrust::EntityType::Face3D(face)
}
_ => return None,
};
(flattened != *entity).then_some(flattened)
}
fn flatten_curve_entity(entity: &acadrust::EntityType) -> Option<acadrust::EntityType> {
use acadrust::types::{Vector2, Vector3};
use cadkernel::geom2d::Curve;
match entity {
acadrust::EntityType::Ellipse(source)
if source.center.z == 0.0
&& source.major_axis.z == 0.0
&& source.normal == Vector3::UNIT_Z =>
{
return None;
}
acadrust::EntityType::LwPolyline(source)
if !z_axis_normal(source.normal)
&& (source.constant_width != 0.0
|| source
.vertices
.iter()
.any(|vertex| vertex.start_width != 0.0 || vertex.end_width != 0.0)) =>
{
return None;
}
acadrust::EntityType::Polyline2D(source)
if !z_axis_normal(source.normal)
&& (source.start_width != 0.0
|| source.end_width != 0.0
|| source
.vertices
.iter()
.any(|vertex| vertex.start_width != 0.0 || vertex.end_width != 0.0)) =>
{
return None;
}
acadrust::EntityType::LwPolyline(source) if source.vertices.len() < 2 => {
let plane = crate::entities::curve::ocs_plane(source.normal, source.elevation);
let mut polyline = source.clone();
for vertex in &mut polyline.vertices {
let point = flatten_array(plane.point_at([
vertex.location.x,
vertex.location.y,
]))?;
vertex.location = Vector2::new(point.x, point.y);
}
polyline.elevation = 0.0;
polyline.thickness = 0.0;
polyline.normal = Vector3::UNIT_Z;
return Some(acadrust::EntityType::LwPolyline(polyline));
}
acadrust::EntityType::Polyline2D(source) if source.vertices.len() < 2 => {
let plane = crate::entities::curve::ocs_plane(source.normal, source.elevation);
let mut polyline = source.clone();
for vertex in &mut polyline.vertices {
let point = flatten_array(plane.point_at([
vertex.location.x,
vertex.location.y,
]))?;
vertex.location = point;
}
polyline.elevation = 0.0;
polyline.thickness = 0.0;
polyline.normal = Vector3::UNIT_Z;
return Some(acadrust::EntityType::Polyline2D(polyline));
}
_ => {}
}
let curve = crate::entities::curve::entity_curve_xy(entity)?;
match (entity, curve) {
(acadrust::EntityType::Circle(source), Curve::Circle(curve)) => {
let mut circle = source.clone();
circle.center = Vector3::new(curve.centre[0], curve.centre[1], 0.0);
circle.radius = curve.radius;
circle.thickness = 0.0;
circle.normal = Vector3::UNIT_Z;
Some(acadrust::EntityType::Circle(circle))
}
(acadrust::EntityType::Arc(source), Curve::Arc(curve)) => {
let mut arc = source.clone();
arc.center = Vector3::new(curve.centre[0], curve.centre[1], 0.0);
arc.radius = curve.radius;
arc.start_angle = curve.start_angle;
arc.end_angle = curve.end_angle;
arc.thickness = 0.0;
arc.normal = Vector3::UNIT_Z;
Some(acadrust::EntityType::Arc(arc))
}
(acadrust::EntityType::Circle(source), Curve::Ellipse(curve)) => {
flattened_ellipse(source.common.clone(), curve)
}
(acadrust::EntityType::Arc(source), Curve::Ellipse(curve)) => {
flattened_ellipse(source.common.clone(), curve)
}
(acadrust::EntityType::Ellipse(source), Curve::Ellipse(curve)) => {
flattened_ellipse(source.common.clone(), curve)
}
(acadrust::EntityType::LwPolyline(source), Curve::Polyline(curve)) => {
if curve.vertices.len() != source.vertices.len() {
return None;
}
let mut polyline = source.clone();
for (target, projected) in polyline.vertices.iter_mut().zip(curve.vertices) {
target.location = Vector2::new(projected.position[0], projected.position[1]);
target.bulge = projected.bulge;
}
polyline.elevation = 0.0;
polyline.thickness = 0.0;
polyline.normal = Vector3::UNIT_Z;
Some(acadrust::EntityType::LwPolyline(polyline))
}
(acadrust::EntityType::Polyline2D(source), Curve::Polyline(curve)) => {
if curve.vertices.len() != source.vertices.len() {
return None;
}
let mut polyline = source.clone();
for (target, projected) in polyline.vertices.iter_mut().zip(curve.vertices) {
target.location =
Vector3::new(projected.position[0], projected.position[1], 0.0);
target.bulge = projected.bulge;
}
polyline.elevation = 0.0;
polyline.thickness = 0.0;
polyline.normal = Vector3::UNIT_Z;
Some(acadrust::EntityType::Polyline2D(polyline))
}
_ => None,
}
}
fn flattened_ellipse(
common: acadrust::entities::EntityCommon,
curve: cadkernel::geom2d::EllipseArc,
) -> Option<acadrust::EntityType> {
use acadrust::types::Vector3;
let geometry = curve.ellipse;
if !geometry.major_radius.is_finite() || geometry.major_radius <= 0.0 {
return None;
}
let mut ellipse = acadrust::entities::Ellipse::new();
ellipse.common = common;
ellipse.center = Vector3::new(geometry.centre[0], geometry.centre[1], 0.0);
ellipse.major_axis = Vector3::new(
geometry.major_axis[0] * geometry.major_radius,
geometry.major_axis[1] * geometry.major_radius,
0.0,
);
ellipse.minor_axis_ratio = geometry.minor_radius / geometry.major_radius;
ellipse.start_parameter = curve.start_parameter;
ellipse.end_parameter = curve.end_parameter;
ellipse.normal = Vector3::UNIT_Z;
Some(acadrust::EntityType::Ellipse(ellipse))
}
fn flatten_point(point: acadrust::types::Vector3) -> Option<acadrust::types::Vector3> {
flatten_array([point.x, point.y, point.z])
}
fn flatten_array(point: [f64; 3]) -> Option<acadrust::types::Vector3> {
use cadkernel::space::Plane;
let point = Plane::XY.point_at(Plane::XY.project(point)?);
Some(acadrust::types::Vector3::new(point[0], point[1], point[2]))
}
fn flatten_vector(vector: acadrust::types::Vector3) -> Option<acadrust::types::Vector3> {
use cadkernel::space::Plane;
let vector = Plane::XY.vector_at(Plane::XY.project_vector([
vector.x, vector.y, vector.z,
])?);
Some(acadrust::types::Vector3::new(
vector[0], vector[1], vector[2],
))
}
fn positive_z_normal(normal: acadrust::types::Vector3) -> bool {
normal.x == 0.0 && normal.y == 0.0 && normal.z > 0.0
}
fn z_axis_normal(normal: acadrust::types::Vector3) -> bool {
normal.x == 0.0 && normal.y == 0.0 && normal.z != 0.0
}
// ── CAL — arithmetic expression evaluator ──────────────────────────────────
// A small recursive-descent parser for `+ - * /`, parentheses, unary signs and
@@ -2044,7 +1705,7 @@ impl OpenCADStudio {
if sel.len() == 1 {
let (h, e) = sel[0];
match e {
acadrust::EntityType::Insert(ins) if !ins.attributes.is_empty() => Some(h),
codec::EntityType::Insert(ins) if !ins.attributes.is_empty() => Some(h),
_ => None,
}
} else {
@@ -2149,158 +1810,14 @@ impl ArithParser {
}
}
#[cfg(test)]
mod flatten_tests {
use super::flatten_entity_z;
use acadrust::types::Vector3;
use acadrust::EntityType;
#[test]
fn flattens_3d_polyline_vertices() {
let mut pl = acadrust::entities::Polyline3D::new();
for z in [5.0, -2.0, 7.5] {
pl.vertices
.push(acadrust::entities::Vertex3DPolyline::new(Vector3::new(1.0, 2.0, z)));
}
pl.elevation = 5.0;
let entity = EntityType::Polyline3D(pl);
let entity = flatten_entity_z(&entity).expect("projection");
let EntityType::Polyline3D(pl) = entity else {
panic!("wrong variant")
};
assert_eq!(pl.elevation, 0.0);
assert!(pl.vertices.iter().all(|v| v.position.z == 0.0));
// X/Y must survive the projection.
assert!(pl.vertices.iter().all(|v| v.position.x == 1.0 && v.position.y == 2.0));
}
#[test]
fn flattens_2d_polyline_vertices_and_elevation() {
let mut pl = acadrust::entities::Polyline2D::new();
pl.vertices
.push(acadrust::entities::Vertex2D::new(Vector3::new(3.0, 4.0, 9.0)));
pl.elevation = 9.0;
let entity = EntityType::Polyline2D(pl);
let entity = flatten_entity_z(&entity).expect("projection");
let EntityType::Polyline2D(pl) = entity else {
panic!("wrong variant")
};
assert_eq!(pl.elevation, 0.0);
assert_eq!(pl.vertices[0].location.z, 0.0);
assert_eq!(pl.vertices[0].location.x, 3.0);
}
#[test]
fn flattens_solid_corners() {
let solid = acadrust::entities::Solid::new(
Vector3::new(0.0, 0.0, 1.0),
Vector3::new(1.0, 0.0, 2.0),
Vector3::new(1.0, 1.0, 3.0),
Vector3::new(0.0, 1.0, 4.0),
);
let entity = EntityType::Solid(solid);
let entity = flatten_entity_z(&entity).expect("projection");
let EntityType::Solid(s) = entity else {
panic!("wrong variant")
};
assert_eq!(
[s.first_corner.z, s.second_corner.z, s.third_corner.z, s.fourth_corner.z],
[0.0; 4]
);
}
#[test]
fn reports_unsupported_entities_as_not_moved() {
let entity = EntityType::Ray(acadrust::entities::Ray::new(
Vector3::new(0.0, 0.0, 5.0),
Vector3::new(1.0, 0.0, 0.0),
));
assert!(flatten_entity_z(&entity).is_none());
}
#[test]
fn still_flattens_a_line() {
let mut line = acadrust::entities::Line::new();
line.start = Vector3::new(0.0, 0.0, 3.0);
line.end = Vector3::new(1.0, 1.0, 4.0);
let entity = EntityType::Line(line);
let entity = flatten_entity_z(&entity).expect("projection");
let EntityType::Line(l) = entity else { panic!("wrong variant") };
assert_eq!((l.start.z, l.end.z), (0.0, 0.0));
}
#[test]
fn already_flat_line_is_not_moved() {
let mut line = acadrust::entities::Line::new();
line.start = Vector3::new(0.0, 0.0, 0.0);
line.end = Vector3::new(1.0, 1.0, 0.0);
let entity = EntityType::Line(line);
assert!(flatten_entity_z(&entity).is_none());
}
#[test]
fn tilted_circle_projects_to_ellipse() {
let mut circle = acadrust::entities::Circle::from_center_radius(
Vector3::new(2.0, 3.0, 4.0),
5.0,
);
circle.normal = Vector3::new(0.0, 0.6, 0.8);
circle.thickness = 2.0;
let entity = EntityType::Circle(circle);
let projected = flatten_entity_z(&entity).expect("projection");
let EntityType::Ellipse(ellipse) = projected else {
panic!("wrong variant")
};
assert_eq!(ellipse.center.z, 0.0);
assert_eq!(ellipse.major_axis.z, 0.0);
assert_eq!(ellipse.normal, Vector3::UNIT_Z);
assert!(ellipse.minor_axis_ratio < 1.0);
}
#[test]
fn tilted_solid_uses_world_projection() {
let mut solid = acadrust::entities::Solid::new(
Vector3::new(0.0, 0.0, 2.0),
Vector3::new(1.0, 0.0, 2.0),
Vector3::new(1.0, 1.0, 2.0),
Vector3::new(0.0, 1.0, 2.0),
);
solid.normal = Vector3::new(0.0, 0.6, 0.8);
solid.thickness = 3.0;
let entity = EntityType::Solid(solid);
let projected = flatten_entity_z(&entity).expect("projection");
let EntityType::Solid(solid) = projected else {
panic!("wrong variant")
};
assert_eq!(solid.normal, Vector3::UNIT_Z);
assert_eq!(solid.thickness, 0.0);
assert_eq!(
[
solid.first_corner.z,
solid.second_corner.z,
solid.third_corner.z,
solid.fourth_corner.z,
],
[0.0; 4]
);
}
}
#[cfg(test)]
mod align_selected_bounds_tests {
use super::*;
use acadrust::entities::{EntityType, Line};
use acadrust::types::Vector3;
use codec::entities::{EntityType, Line};
use codec::types::Vector3;
fn add_line(app: &mut OpenCADStudio, x1: f64, y1: f64, x2: f64, y2: f64) -> acadrust::Handle {
fn add_line(app: &mut OpenCADStudio, x1: f64, y1: f64, x2: f64, y2: f64) -> codec::Handle {
app.tabs[app.active_tab]
.scene
.add_entity(EntityType::Line(Line::from_points(
@@ -2309,14 +1826,14 @@ mod align_selected_bounds_tests {
)))
}
fn line_start_x(app: &OpenCADStudio, handle: acadrust::Handle) -> f64 {
fn line_start_x(app: &OpenCADStudio, handle: codec::Handle) -> f64 {
match app.tabs[app.active_tab].scene.document.get_entity(handle) {
Some(EntityType::Line(l)) => l.start.x.min(l.end.x),
other => panic!("expected a Line, got {other:?}"),
}
}
fn line_max_y(app: &OpenCADStudio, handle: acadrust::Handle) -> f64 {
fn line_max_y(app: &OpenCADStudio, handle: codec::Handle) -> f64 {
match app.tabs[app.active_tab].scene.document.get_entity(handle) {
Some(EntityType::Line(l)) => l.start.y.max(l.end.y),
other => panic!("expected a Line, got {other:?}"),
@@ -2439,3 +1956,4 @@ mod find_replace_command_tests {
}
}
}
+66 -29
View File
@@ -5,7 +5,7 @@ impl OpenCADStudio {
match cmd {
// ── LAYER management ─────────────────────────────────────────
cmd if cmd == "LAYER" || cmd.starts_with("LAYER ") || cmd.starts_with("LA ") => {
use acadrust::tables::Layer;
use codec::tables::Layer;
let raw_rest = if cmd.starts_with("LAYER ") {
cmd.trim_start_matches("LAYER ").trim()
} else if cmd.starts_with("LA ") {
@@ -127,7 +127,7 @@ impl OpenCADStudio {
if let Ok(idx) = color_str.parse::<i16>() {
if let Some(l) = self.tabs[i].scene.document.layers.get_mut(&layer_name)
{
l.color = acadrust::types::Color::from_index(idx);
l.color = codec::types::Color::from_index(idx);
l.color_name = None;
l.book_name = None;
self.push_undo_snapshot(i, "LAYER COLOR");
@@ -186,8 +186,8 @@ impl OpenCADStudio {
// ── UCS management (inline `UCS <option> …`) ─────────────────────
cmd if cmd.starts_with("UCS ") => {
use super::super::helpers::{ucs_rotated_z, ucs_to_wcs, ucs_z_axis};
use acadrust::tables::Ucs;
use acadrust::types::Vector3;
use codec::tables::Ucs;
use codec::types::Vector3;
let parts: Vec<&str> = cmd.splitn(4, ' ').collect();
let sub = parts.get(1).map(|s| s.to_uppercase()).unwrap_or_default();
let mut active_changed = false;
@@ -247,6 +247,7 @@ impl OpenCADStudio {
.document
.ucss
.add_or_replace(ucs.clone());
self.tabs[i].scene.bump_ucs_epoch();
self.tabs[i].active_ucs = Some(ucs);
active_changed = true;
self.tabs[i].dirty = true;
@@ -261,11 +262,12 @@ impl OpenCADStudio {
} else if let Some(removed) =
self.tabs[i].scene.document.ucss.remove(&name)
{
self.tabs[i].scene.bump_ucs_epoch();
let removed_handle = removed.handle;
for entity in self.tabs[i].scene.document.entities_mut() {
if let acadrust::EntityType::Viewport(viewport) = entity {
if let codec::EntityType::Viewport(viewport) = entity {
if viewport.ucs_handle == removed_handle {
viewport.ucs_handle = acadrust::Handle::NULL;
viewport.ucs_handle = codec::Handle::NULL;
}
}
}
@@ -276,9 +278,9 @@ impl OpenCADStudio {
if active_matches {
if let Some(active) = self.tabs[i].active_ucs.as_mut() {
active.name = "*ACTIVE*".to_string();
active.handle = acadrust::Handle::NULL;
active.named_ucs_handle = acadrust::Handle::NULL;
active.base_ucs_handle = acadrust::Handle::NULL;
active.handle = codec::Handle::NULL;
active.named_ucs_handle = codec::Handle::NULL;
active.base_ucs_handle = codec::Handle::NULL;
}
active_changed = true;
}
@@ -325,7 +327,7 @@ impl OpenCADStudio {
.and_then(|token| {
u64::from_str_radix(token.trim().trim_start_matches("0x"), 16).ok()
})
.map(acadrust::Handle::new)
.map(codec::Handle::new)
.zip(
parts
.get(3)
@@ -343,6 +345,7 @@ impl OpenCADStudio {
self.tabs[i].active_cmd = Some(Box::new(picker));
}
Some((handle, pick)) => {
self.tabs[i].scene.restore_solid_models(&[handle]);
let built = self.tabs[i]
.scene
.solid_models
@@ -351,13 +354,7 @@ impl OpenCADStudio {
use crate::scene::model::solid_model;
let face =
solid_model::nearest_planar_face(body, pick.to_array())?;
solid_model::planar_face_normal(body, face)
})
.and_then(|normal| {
super::super::helpers::ucs_from_normal(
pick,
glam::DVec3::from_array(normal),
)
solid_model::planar_face_ucs(body, face, pick.to_array())
});
match built {
Some(ucs) => {
@@ -384,7 +381,7 @@ impl OpenCADStudio {
.and_then(|token| {
u64::from_str_radix(token.trim().trim_start_matches("0x"), 16).ok()
})
.map(acadrust::Handle::new);
.map(codec::Handle::new);
match target {
// Bare `UCS OBJECT` starts the pick; the click
// comes back through this arm with the handle.
@@ -808,6 +805,29 @@ impl OpenCADStudio {
.push_output(crate::tf!("View '{}' saved.", name).as_ref());
}
}
"WINDOW" | "W" => {
// A plan view of the window between two corners.
let args: Vec<&str> = parts.get(2).map(|s| s.split_whitespace().collect()).unwrap_or_default();
let corner = |s: &str| {
let mut c = s.split(',');
Some((c.next()?.trim().parse::<f64>().ok()?, c.next()?.trim().parse::<f64>().ok()?))
};
match (args.first(), args.get(1).and_then(|s| corner(s)), args.get(2).and_then(|s| corner(s))) {
(Some(name), Some(a), Some(b)) if a.0 != b.0 && a.1 != b.1 => {
use codec::types::Vector3;
let mut view = self.tabs[i].scene.current_as_named_view(name);
view.target = Vector3 { x: (a.0 + b.0) / 2.0, y: (a.1 + b.1) / 2.0, z: 0.0 };
view.direction = Vector3 { x: 0.0, y: 0.0, z: 1.0 };
view.width = (b.0 - a.0).abs();
view.height = (b.1 - a.1).abs();
self.tabs[i].scene.document.views.add_or_replace(view);
self.command_line.push_output(crate::tf!("View '{}' saved.", name).as_ref());
}
_ => self
.command_line
.push_error(crate::t!("Usage: VIEW WINDOW <name> <x1,y1> <x2,y2>").as_ref()),
}
}
"DELETE" | "DEL" | "D" => {
let name = parts.get(2).map(|s| s.trim()).unwrap_or("").to_string();
if name.is_empty() {
@@ -857,8 +877,25 @@ impl OpenCADStudio {
crate::scene::CubeRegion::Face(face),
)));
}
"ISO" | "ISOMETRIC" | "SWISO" => {
return Some(Task::done(Message::ViewCubeHome));
// Isometric presets snap to the matching ViewCube corner;
// ISO alone is the south-west one.
"ISO" | "ISOMETRIC" | "SWISO" | "SEISO" | "NEISO" | "NWISO" => {
let (x, y) = match sub.as_str() {
"SEISO" => (1.0, -1.0),
"NEISO" => (1.0, 1.0),
"NWISO" => (-1.0, 1.0),
_ => (-1.0, -1.0),
};
let want = glam::Vec3::new(x, y, 1.0).normalize();
let corner = (18..26)
.map(crate::scene::CubeRegion::Corner)
.max_by(|a, b| {
a.snap_direction()
.dot(want)
.total_cmp(&b.snap_direction().dot(want))
})
.expect("the cube has corners");
return Some(Task::done(Message::ViewCubeSnap(corner)));
}
// VIEW <name> shortcut for restore
_ => {
@@ -880,7 +917,7 @@ impl OpenCADStudio {
// ── DimStyle management ───────────────────────────────────────
// TABLESTYLE — Table Style Manager.
cmd if cmd == "TABLESTYLE" || cmd == "TS" || cmd.starts_with("TABLESTYLE ") => {
use acadrust::objects::{ObjectType, TableStyle};
use codec::objects::{ObjectType, TableStyle};
let raw_rest = cmd.split_once(' ').map(|(_, r)| r.trim()).unwrap_or("");
let parts: Vec<&str> = raw_rest.split_whitespace().collect();
let sub = parts.first().map(|s| s.to_uppercase()).unwrap_or_default();
@@ -930,7 +967,7 @@ impl OpenCADStudio {
self.push_undo_snapshot(i, "TABLESTYLE NEW");
let mut style = TableStyle::standard();
style.name = name.clone();
let nh = acadrust::Handle::new(
let nh = codec::Handle::new(
self.tabs[i].scene.document.next_handle(),
);
style.handle = nh;
@@ -960,7 +997,7 @@ impl OpenCADStudio {
// MLSTYLE SET <name> — set current multiline style
// MLSTYLE DEL <name> — delete a style (not Standard)
cmd if cmd == "MLSTYLE" || cmd.starts_with("MLSTYLE ") => {
use acadrust::objects::{MLineStyle, ObjectType};
use codec::objects::{MLineStyle, ObjectType};
let raw_rest = cmd.split_once(' ').map(|(_, r)| r.trim()).unwrap_or("");
let parts: Vec<&str> = raw_rest.split_whitespace().collect();
let sub = parts.first().map(|s| s.to_uppercase()).unwrap_or_default();
@@ -1009,7 +1046,7 @@ impl OpenCADStudio {
self.push_undo_snapshot(i, "MLSTYLE NEW");
let mut style = MLineStyle::standard();
style.name = name.clone();
let nh = acadrust::Handle::new(
let nh = codec::Handle::new(
self.tabs[i].scene.document.next_handle(),
);
style.handle = nh;
@@ -1089,7 +1126,7 @@ impl OpenCADStudio {
|| cmd.starts_with("DIMSTYLE ")
|| cmd.starts_with("DDIM ") =>
{
use acadrust::tables::DimStyle;
use codec::tables::DimStyle;
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();
@@ -1222,7 +1259,7 @@ impl OpenCADStudio {
// ── MLeader Style management ──────────────────────────────────
cmd if cmd == "MLEADERSTYLE" || cmd.starts_with("MLEADERSTYLE ") => {
use acadrust::objects::{MultiLeaderStyle, ObjectType};
use codec::objects::{MultiLeaderStyle, ObjectType};
let raw_rest = cmd.trim_start_matches("MLEADERSTYLE").trim();
let parts: Vec<&str> = raw_rest.split_whitespace().collect();
let sub = parts.first().map(|s| s.to_uppercase()).unwrap_or_default();
@@ -1442,7 +1479,7 @@ impl OpenCADStudio {
"STYLE NEW",
std::slice::from_ref(&name),
);
let style = acadrust::tables::TextStyle::new(&name);
let style = codec::tables::TextStyle::new(&name);
let _ = self.tabs[i].scene.document.text_styles.add(style);
self.tabs[i].dirty = true;
self.commit_text_style_undo(i, undo);
@@ -1568,7 +1605,7 @@ impl OpenCADStudio {
/// mean, because a few pixels of cursor travel cover a long way in
/// world space.
fn open_sketch(&mut self, i: usize) {
let opened_with: std::collections::HashSet<acadrust::Handle> = self.tabs[i]
let opened_with: std::collections::HashSet<codec::Handle> = self.tabs[i]
.scene
.document
.entities()
@@ -1601,7 +1638,7 @@ impl OpenCADStudio {
.push_error(crate::t!("No sketch is open.").as_ref());
return;
};
let created: Vec<acadrust::Handle> = self.tabs[i]
let created: Vec<codec::Handle> = self.tabs[i]
.scene
.document
.entities()
+10 -10
View File
@@ -37,7 +37,7 @@ impl OpenCADStudio {
dl.turn_off();
}
}
self.tabs[i].scene.invalidate_layer_dependencies(&names);
self.tabs[i].scene.invalidate_layer_visibility(&names);
self.tabs[i].dirty = true;
self.commit_layer_undo(i, undo);
self.refresh_layer_panel();
@@ -74,7 +74,7 @@ impl OpenCADStudio {
dl.freeze();
}
}
self.tabs[i].scene.invalidate_layer_dependencies(&names);
self.tabs[i].scene.invalidate_layer_visibility(&names);
self.tabs[i].dirty = true;
self.commit_layer_undo(i, undo);
self.refresh_layer_panel();
@@ -154,7 +154,7 @@ impl OpenCADStudio {
);
return Some(Task::none());
}
let handles: Vec<acadrust::Handle> = self.tabs[i]
let handles: Vec<codec::Handle> = self.tabs[i]
.scene
.document
.entities()
@@ -417,7 +417,7 @@ impl OpenCADStudio {
.layers
.get(&layer)
.map(|l| l.handle)
.unwrap_or(acadrust::types::Handle::NULL);
.unwrap_or(codec::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();
@@ -444,7 +444,7 @@ impl OpenCADStudio {
dl.turn_on();
}
}
self.tabs[i].scene.invalidate_layer_dependencies(&names);
self.tabs[i].scene.invalidate_layer_visibility(&names);
self.tabs[i].dirty = true;
self.commit_layer_undo(i, undo);
self.refresh_layer_panel();
@@ -465,7 +465,7 @@ impl OpenCADStudio {
dl.thaw();
}
}
self.tabs[i].scene.invalidate_layer_dependencies(&names);
self.tabs[i].scene.invalidate_layer_visibility(&names);
self.tabs[i].dirty = true;
self.commit_layer_undo(i, undo);
self.refresh_layer_panel();
@@ -534,7 +534,7 @@ impl OpenCADStudio {
}
}
}
self.tabs[i].scene.invalidate_layer_dependencies(&names);
self.tabs[i].scene.invalidate_layer_visibility(&names);
self.tabs[i].dirty = true;
self.commit_layer_undo(i, undo);
self.refresh_layer_panel();
@@ -624,7 +624,7 @@ impl OpenCADStudio {
dl.turn_on();
}
}
self.tabs[i].scene.invalidate_layer_dependencies(&names);
self.tabs[i].scene.invalidate_layer_visibility(&names);
self.tabs[i].dirty = true;
self.commit_layer_undo(i, undo);
self.refresh_layer_panel();
@@ -822,11 +822,11 @@ impl OpenCADStudio {
// Model framing follows model units; sheet framing stays in paper units.
let sheets = self.tabs[i].scene.sheet_viewport_handles();
let scaled: std::collections::HashSet<acadrust::Handle> = handles.iter().copied().collect();
let scaled: std::collections::HashSet<codec::Handle> = handles.iter().copied().collect();
let mut reframed = 0usize;
for entity in self.tabs[i].scene.document.entities_mut() {
let handle = entity.common().handle;
let acadrust::entities::EntityType::Viewport(vp) = entity else {
let codec::entities::EntityType::Viewport(vp) = entity else {
continue;
};
let is_sheet = !scaled.contains(&handle) && sheets.contains(&handle);
+291 -92
View File
@@ -4,6 +4,9 @@ use crate::scene::Scene;
use iced::Task;
use std::path::PathBuf;
mod attdef;
mod field;
mod count;
mod blocks;
mod xref_attach;
mod dim;
@@ -13,6 +16,15 @@ mod fileops;
mod inquiry;
mod layerprops;
mod layers;
pub(crate) mod pdf_import;
mod point_cloud;
mod pc_colormap;
mod pc_extract;
mod pdf_underlay;
mod pdf_dialogs;
pub(crate) mod sheet_set;
mod xclip;
mod blockvars;
mod plotvars;
mod styleprops;
mod view;
@@ -43,8 +55,65 @@ impl OpenCADStudio {
}
}
/// Per-command start state every new command begins from — also used by
/// entry points that install a command without going through dispatch
/// (the block palette), so they don't inherit the previous command's last
/// point, snaps or dynamic-input values. (#1525)
pub(in crate::app) fn reset_command_start_state(&mut self, i: usize) {
// A command parked behind a transparent zoom / MTP goes with it.
self.tabs[i].suspended_cmd = None;
self.tabs[i].transparent_resume = false;
// Starting any command leaves interactive navigation modes (their own
// command arms below re-enable the selected one).
self.tabs[i].pan_mode = false;
self.tabs[i].orbit_mode = false;
self.tabs[i].zoom_dynamic_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 new command collects its own points, so the previous command's
// accepted snaps must not leak into it.
self.clear_accepted_snaps();
// Starting a command restarts the right-click cycle, so its first
// right-click acts as Enter rather than opening the context menu.
self.tabs[i]
.scene
.selection
.borrow_mut()
.input.right_click_entered = false;
// A fresh command starts at the polar/cartesian default — clear
// any `,`-driven reshape and locked dynamic-input values from a
// previous command. Otherwise a bare Enter on the first point prompt
// can commit that stale coordinate instead of accepting the command's
// default (LIMITS then compares an unintended lower-left point with
// the displayed default upper-right).
self.dyn_user_reshaped = false;
self.dyn_coord_absolute = false;
self.tabs[i].dyn_fields.clear();
self.tabs[i].dyn_active = 0;
}
pub(super) fn dispatch_command(&mut self, cmd: &str) -> Task<Message> {
self.dispatch_command_inner(cmd, false)
let task = self.dispatch_command_inner(cmd, false);
self.clear_idle_tool_highlight();
task
}
/// Turn the ribbon highlight off when the command just dispatched left
/// nothing running: no interactive command, dialog or navigation mode.
/// One-shot commands (view changes, clipboard, toggles, audits…) would
/// otherwise leave it lit forever; interactive commands and dialog owners
/// keep theirs until the command end / modal close clears it. (#355)
pub(in crate::app) fn clear_idle_tool_highlight(&mut self) {
let i = self.active_tab;
if self.tabs[i].active_cmd.is_none()
&& self.active_modal.is_none()
&& !self.tabs[i].pan_mode
&& !self.tabs[i].orbit_mode
&& !self.tabs[i].zoom_dynamic_mode
{
self.ribbon.deactivate_tool();
}
}
/// Dispatch a verb typed at the interactive command line, falling back to
@@ -56,7 +125,9 @@ impl OpenCADStudio {
/// callers (ribbon, plugins, headless automation) use `dispatch_command`
/// and never get silent substitution.
pub(super) fn dispatch_command_or_suggest(&mut self, cmd: &str) -> Task<Message> {
self.dispatch_command_inner(cmd, true)
let task = self.dispatch_command_inner(cmd, true);
self.clear_idle_tool_highlight();
task
}
fn dispatch_command_inner(&mut self, cmd: &str, allow_suggest: bool) -> Task<Message> {
@@ -113,18 +184,7 @@ impl OpenCADStudio {
{
let mut selection = self.tabs[i].scene.selection.borrow_mut();
// Cancel the active selection gesture before the new command starts.
selection.left_down = false;
selection.left_press_pos = None;
selection.left_press_time = None;
selection.left_dragging = false;
selection.box_anchor = None;
selection.box_anchor_world = None;
selection.box_current = None;
selection.box_crossing = false;
selection.box_crossing_locked = false;
selection.poly_active = false;
selection.poly_points.clear();
selection.poly_crossing = false;
selection.clear_left_selection_gesture();
}
// Cancel any running command before starting a new one.
if self.tabs[i].active_cmd.is_some() {
@@ -134,37 +194,7 @@ impl OpenCADStudio {
// template-property override too (#239).
self.restore_add_selected_defaults();
}
// A command parked behind a transparent zoom / MTP goes with it.
self.tabs[i].suspended_cmd = None;
self.tabs[i].transparent_resume = false;
// Starting any command leaves interactive navigation modes (their own
// command arms below re-enable the selected one).
self.tabs[i].pan_mode = false;
self.tabs[i].orbit_mode = false;
self.tabs[i].zoom_dynamic_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 new command collects its own points, so the previous command's
// accepted snaps must not leak into it.
self.clear_accepted_snaps();
// Starting a command restarts the right-click cycle, so its first
// right-click acts as Enter rather than opening the context menu.
self.tabs[i]
.scene
.selection
.borrow_mut()
.right_click_entered = false;
// A fresh command starts at the polar/cartesian default — clear
// any `,`-driven reshape and locked dynamic-input values from a
// previous command. Otherwise a bare Enter on the first point prompt
// can commit that stale coordinate instead of accepting the command's
// default (LIMITS then compares an unintended lower-left point with
// the displayed default upper-right).
self.dyn_user_reshaped = false;
self.dyn_coord_absolute = false;
self.tabs[i].dyn_fields.clear();
self.tabs[i].dyn_active = 0;
self.reset_command_start_state(i);
if let Some(path_str) = cmd.strip_prefix("OPEN_RECENT:") {
let path = PathBuf::from(path_str);
@@ -181,23 +211,31 @@ impl OpenCADStudio {
// editors (shortcuts, aliases) — none of them read the scene. This is
// the single place that decides; `on_ribbon_tool_click` defers to it
// rather than keeping a second, blunter copy (#388, #389).
if self.tabs[i].is_start && !start_allowed(cmd) {
if self.tabs[i].is_start && !start_allowed(strip_command_prefixes(cmd).unwrap_or(cmd)) {
self.command_line
.push_info(crate::t!("No drawing open. Use NEW or OPEN to start a drawing.").as_ref());
return Task::none();
}
// Light the ribbon button of the command now starting, however it was
// started (typed, alias, shortcut, Repeat), so the user sees where it
// lives. A transparent command returned above and leaves the running
// command's highlight alone.
self.ribbon
.show_command(strip_command_prefixes(cmd).unwrap_or(cmd));
if !self.suppress_plugin_dispatch && 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.
self.tabs[i].last_cmd = Some(cmd.to_string());
self.command_line.record_recent(cmd);
if self.tabs[i].active_cmd.is_none() {
self.ribbon.deactivate_tool();
}
return Task::none();
return self.finish_dispatch(cmd);
}
// Command families are dispatched in source order (see
@@ -212,6 +250,15 @@ impl OpenCADStudio {
return t;
}
// Scripts and typed input may carry the international (`_`) and
// built-in (`.`) name prefixes (`_LINE`, `._LINE`). Ribbon tools
// dispatch internal names that start with `_` themselves
// (`_PCCROPPOLY`), so a name is tried as written first and loses its
// prefixes only when nothing handles it.
if let Some(bare) = strip_command_prefixes(cmd) {
return self.dispatch_command_inner(bare, allow_suggest);
}
// No family matched. From the interactive command line, run the
// closest autocomplete suggestion instead of erroring, so a partial
// command completes on Enter (`BAC` → `BACKGROUND`). The verb's own
@@ -253,6 +300,27 @@ impl OpenCADStudio {
if let Some(t) = self.dispatch_blocks(cmd, i) {
return Some(t);
}
if let Some(t) = self.dispatch_pdf_underlay(cmd, i) {
return Some(t);
}
if let Some(t) = self.dispatch_pc_colormap(cmd, i) {
return Some(t);
}
if let Some(t) = self.dispatch_pc_extract(cmd, i) {
return Some(t);
}
if let Some(t) = self.dispatch_attdef(cmd, i) {
return Some(t);
}
if let Some(t) = self.dispatch_sheet_set(cmd, i) {
return Some(t);
}
if let Some(t) = self.dispatch_field(cmd, i) {
return Some(t);
}
if let Some(t) = self.dispatch_count(cmd, i) {
return Some(t);
}
if let Some(t) = self.dispatch_draw(cmd, i) {
return Some(t);
}
@@ -271,6 +339,9 @@ impl OpenCADStudio {
if let Some(t) = self.dispatch_plotvars(cmd, i) {
return Some(t);
}
if let Some(t) = self.dispatch_blockvars(cmd, i) {
return Some(t);
}
if let Some(t) = self.dispatch_styleprops(cmd, i) {
return Some(t);
}
@@ -449,6 +520,14 @@ inventory::submit!(crate::command::CommandRegistration {
// Block list + block-attribute list (command-line forms).
"BLOCKPALETTE",
"BLOCKSPALETTE",
"BLOCKSPALETTECLOSE",
"-INSERT",
"BLOCKMRULIST",
"BLOCKREDEFINEMODE",
"BLOCKNAVIGATE",
"BLOCKSTATE",
"EXPLMODE",
"INSNAME",
"ATTMAN",
"BATTMAN",
// Drawing-content overview.
@@ -545,6 +624,9 @@ inventory::submit!(crate::command::CommandRegistration {
"FITSPLINE",
// System variables (typeable directly).
"MIRRTEXT",
"PERSPECTIVE",
"GRIDMODE",
"GRIDUNIT",
"ZOOMWHEEL",
"ZOOMFACTOR",
"SHORTCUTMENU",
@@ -611,12 +693,44 @@ inventory::submit!(crate::command::CommandRegistration {
"FRAME",
"IMAGEFRAME",
"PDFFRAME",
"DWFFRAME",
"DGNFRAME",
"PDFOSNAP",
"UOSNAP",
"FIELDDISPLAY",
"FIELDEVAL",
"PDFIMPORTMODE",
"PDFIMPORTFILTER",
"PDFIMPORTLAYERS",
"PDFIMPORTIMAGEPATH",
"XDWGFADECTL",
"POINTCLOUDCLIPFRAME",
"POINTCLOUDDENSITY",
"POINTCLOUDPOINTSIZE",
"POINTCLOUDLOCK",
"POINTCLOUDAUTOUPDATE",
"POINTCLOUDBOUNDARY",
"POINTCLOUDRTDENSITY",
"POINTCLOUDLOD",
"POINTCLOUDPOINTMAX",
"POINTCLOUDVISRETAIN",
"POINTCLOUDSHADING",
"POINTCLOUDCACHESIZE",
"POINTCLOUD2DVSDISPLAY",
"POINTCLOUDLIGHTING",
"POINTCLOUDLIGHTSOURCE",
"POINTCLOUDPOINTMAXLEGACY",
"XCLIPFRAME",
"WIPEOUTFRAME",
"ATTMODE0",
"ATTMODE1",
"ATTMODE2",
"FRAMES0",
"FRAMES1",
"FRAMES3",
"FRAMES2",
"UOSNAP0",
"UOSNAP1",
"HALOGAP",
"TRACEWID",
"SKETCHINC",
@@ -689,6 +803,8 @@ inventory::submit!(crate::command::CommandRegistration {
"EXPORTSTEP",
"EXPORTSTL",
"EXTERNALREFERENCES",
"POINTCLOUDMANAGER",
"POINTCLOUDMANAGERCLOSE",
"EXTRIM",
"FILETAB",
"FIND",
@@ -698,6 +814,7 @@ inventory::submit!(crate::command::CommandRegistration {
"IM",
"IMAGE",
"IMAGEATTACH",
"TRANSPARENCY",
"IMAGEEMBED",
"IMPORTOBJ",
"ISOLATEOBJECTS",
@@ -721,8 +838,10 @@ inventory::submit!(crate::command::CommandRegistration {
"MLSTYLE",
"MS",
"MSPACE",
"CHSPACE",
"NAVVCUBE",
"NEW",
"QNEW",
"OBJIMPORT",
"OPEN",
"ORTHO",
@@ -767,6 +886,15 @@ inventory::submit!(crate::command::CommandRegistration {
"CREATESKETCH",
"FINISHSKETCH",
"SHEETSET",
"SHEETSETHIDE",
"NEWSHEETSET",
"OPENSHEETSET",
"SSMAUTOOPEN",
"SSLOCATE",
"SSMPOLLTIME",
"SSMSHEETSTATUS",
"SSMSTATE",
"SSFOUND",
"SHORTCUTS",
"SSM",
"STEPOUT",
@@ -807,10 +935,10 @@ inventory::submit!(crate::command::CommandRegistration {
#[cfg(test)]
mod marquee_cancel_tests {
use crate::app::{GripPendingValue, OpenCADStudio};
use crate::app::{GripPendingValue, Message, OpenCADStudio};
use crate::scene::model::object::GripMenuAction;
use crate::scene::pick::grip::GripEdit;
use acadrust::Handle;
use codec::Handle;
use iced::time::Instant;
fn fresh() -> OpenCADStudio {
@@ -823,31 +951,31 @@ mod marquee_cancel_tests {
fn arm_marquee(app: &mut OpenCADStudio) {
let i = app.active_tab;
let mut sel = app.tabs[i].scene.selection.borrow_mut();
sel.left_down = true;
sel.left_press_pos = Some(iced::Point::new(10.0, 10.0));
sel.left_press_time = Some(Instant::now());
sel.left_dragging = true;
sel.box_anchor = Some(iced::Point::new(10.0, 10.0));
sel.box_anchor_world = Some(glam::DVec3::new(1.0, 2.0, 0.0));
sel.box_current = Some(iced::Point::new(40.0, 40.0));
sel.box_crossing = true;
sel.box_crossing_locked = true;
sel.input.left_down = true;
sel.input.left_press_pos = Some(iced::Point::new(10.0, 10.0));
sel.input.left_press_time = Some(Instant::now());
sel.input.left_dragging = true;
sel.gesture.box_anchor = Some(iced::Point::new(10.0, 10.0));
sel.gesture.box_anchor_world = Some(glam::DVec3::new(1.0, 2.0, 0.0));
sel.gesture.box_current = Some(iced::Point::new(40.0, 40.0));
sel.gesture.box_crossing = true;
sel.gesture.box_crossing_locked = true;
}
/// Arm a held lasso drag.
fn arm_lasso(app: &mut OpenCADStudio) {
let i = app.active_tab;
let mut sel = app.tabs[i].scene.selection.borrow_mut();
sel.left_down = true;
sel.left_press_pos = Some(iced::Point::new(10.0, 10.0));
sel.left_press_time = Some(Instant::now());
sel.left_dragging = true;
sel.poly_active = true;
sel.poly_points = vec![
sel.input.left_down = true;
sel.input.left_press_pos = Some(iced::Point::new(10.0, 10.0));
sel.input.left_press_time = Some(Instant::now());
sel.input.left_dragging = true;
sel.gesture.poly_active = true;
sel.gesture.poly_points = vec![
iced::Point::new(10.0, 10.0),
iced::Point::new(20.0, 30.0),
];
sel.poly_crossing = true;
sel.gesture.poly_crossing = true;
}
#[test]
@@ -859,15 +987,15 @@ mod marquee_cancel_tests {
let _ = app.dispatch_command("LINE");
let sel = app.tabs[i].scene.selection.borrow();
assert!(!sel.left_down);
assert!(sel.left_press_pos.is_none());
assert!(sel.left_press_time.is_none());
assert!(!sel.left_dragging);
assert!(sel.box_anchor.is_none());
assert!(sel.box_anchor_world.is_none());
assert!(sel.box_current.is_none());
assert!(!sel.box_crossing);
assert!(!sel.box_crossing_locked);
assert!(!sel.input.left_down);
assert!(sel.input.left_press_pos.is_none());
assert!(sel.input.left_press_time.is_none());
assert!(!sel.input.left_dragging);
assert!(sel.gesture.box_anchor.is_none());
assert!(sel.gesture.box_anchor_world.is_none());
assert!(sel.gesture.box_current.is_none());
assert!(!sel.gesture.box_crossing);
assert!(!sel.gesture.box_crossing_locked);
}
#[test]
@@ -879,13 +1007,13 @@ mod marquee_cancel_tests {
let _ = app.dispatch_command("LINE");
let sel = app.tabs[i].scene.selection.borrow();
assert!(!sel.left_down);
assert!(sel.left_press_pos.is_none());
assert!(sel.left_press_time.is_none());
assert!(!sel.left_dragging);
assert!(!sel.poly_active);
assert!(sel.poly_points.is_empty());
assert!(!sel.poly_crossing);
assert!(!sel.input.left_down);
assert!(sel.input.left_press_pos.is_none());
assert!(sel.input.left_press_time.is_none());
assert!(!sel.input.left_dragging);
assert!(!sel.gesture.poly_active);
assert!(sel.gesture.poly_points.is_empty());
assert!(!sel.gesture.poly_crossing);
}
#[test]
@@ -901,8 +1029,8 @@ mod marquee_cancel_tests {
Some("LINE")
);
let sel = app.tabs[i].scene.selection.borrow();
assert!(sel.box_anchor.is_none());
assert!(sel.box_anchor_world.is_none());
assert!(sel.gesture.box_anchor.is_none());
assert!(sel.gesture.box_anchor_world.is_none());
}
#[test]
@@ -914,15 +1042,15 @@ mod marquee_cancel_tests {
let _ = app.dispatch_command("ORTHO");
let sel = app.tabs[i].scene.selection.borrow();
assert!(sel.left_down);
assert!(sel.left_press_pos.is_some());
assert!(sel.left_press_time.is_some());
assert!(sel.left_dragging);
assert!(sel.box_anchor.is_some());
assert!(sel.box_anchor_world.is_some());
assert!(sel.box_current.is_some());
assert!(sel.box_crossing);
assert!(sel.box_crossing_locked);
assert!(sel.input.left_down);
assert!(sel.input.left_press_pos.is_some());
assert!(sel.input.left_press_time.is_some());
assert!(sel.input.left_dragging);
assert!(sel.gesture.box_anchor.is_some());
assert!(sel.gesture.box_anchor_world.is_some());
assert!(sel.gesture.box_current.is_some());
assert!(sel.gesture.box_crossing);
assert!(sel.gesture.box_crossing_locked);
}
#[test]
@@ -953,4 +1081,75 @@ mod marquee_cancel_tests {
Some("LINE")
);
}
#[test]
fn command_finalize_repeats_last_cmd() {
let mut app = fresh();
let i = app.active_tab;
assert_eq!(app.tabs[i].last_cmd, None);
// Run LINE command
let _ = app.dispatch_command("LINE");
assert_eq!(app.tabs[i].last_cmd.as_deref(), Some("LINE"));
// Cancel LINE with Escape
let _ = app.update(Message::CommandEscape);
assert!(app.tabs[i].active_cmd.is_none());
assert_eq!(app.tabs[i].last_cmd.as_deref(), Some("LINE"));
// Press Enter (CommandFinalize) on empty command line repeats LINE
let _ = app.update(Message::CommandFinalize);
assert_eq!(
app.tabs[i].active_cmd.as_deref().map(|cmd| cmd.name()),
Some("LINE")
);
}
}
/// `cmd` without its leading `_` / `.` name prefixes (`_LINE`, `.LINE`,
/// `._LINE`), or `None` when it carries none.
fn strip_command_prefixes(cmd: &str) -> Option<&str> {
let bare = cmd.trim_start_matches(['_', '.']);
(bare.len() != cmd.len() && !bare.is_empty()).then_some(bare)
}
#[cfg(test)]
mod ribbon_highlight_tests {
use crate::app::OpenCADStudio;
fn fresh() -> OpenCADStudio {
let mut app = OpenCADStudio::new_for_test();
app.automation_op(r#"{"op":"new"}"#);
app
}
#[test]
fn a_typed_alias_lights_the_button_of_the_command_it_starts() {
let mut app = fresh();
let _ = app.dispatch_command_or_suggest("O");
assert!(app.tabs[app.active_tab].active_cmd.is_some());
assert_eq!(app.ribbon.active_tool(), Some("OFFSET"));
let _ = app.dispatch_command_or_suggest("L");
assert_eq!(app.ribbon.active_tool(), Some("LINE"));
}
#[test]
fn a_transparent_command_keeps_the_running_command_lit() {
let mut app = fresh();
let _ = app.dispatch_command_or_suggest("LINE");
let _ = app.dispatch_command_or_suggest("'ZOOM E");
assert!(app.tabs[app.active_tab].active_cmd.is_some());
assert_eq!(app.ribbon.active_tool(), Some("LINE"));
}
#[test]
fn a_one_shot_command_leaves_nothing_lit() {
// REGEN cancels the running LINE and finishes at once, so neither
// its own button nor LINE's may stay lit.
let mut app = fresh();
let _ = app.dispatch_command_or_suggest("LINE");
let _ = app.dispatch_command_or_suggest("REGEN");
assert!(app.tabs[app.active_tab].active_cmd.is_none());
assert_eq!(app.ribbon.active_tool(), None);
}
}
+409
View File
@@ -0,0 +1,409 @@
//! POINTCLOUDCOLORMAP: the Point Cloud Color Map dialog for a chosen cloud
//! (or the drawing's default schemes), and what OK / Apply write — the
//! drawing's colour map (its schemes) and the cloud's intensity and
//! elevation colouring — as one undo step.
use super::*;
use codec::entities::{ExtendedEntityData, PointCloudExData};
use codec::objects::{ClassObject, ClassObjectData, Dictionary, ObjectType, PointCloudColorMap, PointCloudColorRamp, PointCloudRampColor};
use codec::types::Handle;
use crate::ui::window::pdf_dialogs::{short, MapRamp, MapSlot, OutOfRange, PdfDialogMsg, PointCloudColorMapState};
const MAP_DICTIONARY: &str = "ACAD_POINTCLOUD_COLORMAP_DICT";
const MAP_ENTRY: &str = "ACAD_POINTCLOUD_COLORMAP";
/// The classification scheme the colour map names by default.
const CLASSIFICATION_SCHEME: &str = "8AF59545-D22F-46B7-923C-912F71ED57DE";
fn cloud_data(document: &codec::CadDocument, handle: Handle) -> Option<&PointCloudExData> {
match document.get_entity(handle)? {
codec::EntityType::Extended(extended) => match &extended.data {
ExtendedEntityData::PointCloudEx(data) => Some(data),
_ => None,
},
_ => None,
}
}
/// The drawing's colour map object, created (with the built-in schemes)
/// under its dictionary when the drawing has none.
fn ensure_color_map(document: &mut codec::CadDocument) -> Option<Handle> {
let existing = document.objects.iter().find_map(|(handle, object)| match object {
ObjectType::ClassObject(object) if matches!(object.data, ClassObjectData::PointCloudColorMap(_)) => Some(*handle),
_ => None,
});
if existing.is_some() {
return existing;
}
let root = document.header.named_objects_dict_handle;
let ObjectType::Dictionary(root_dictionary) = document.objects.get(&root)? else {
return None;
};
let dictionary = match root_dictionary.get(MAP_DICTIONARY) {
Some(handle) if matches!(document.objects.get(&handle), Some(ObjectType::Dictionary(_))) => handle,
_ => {
let handle = document.allocate_handle();
let mut dictionary = Dictionary::new();
dictionary.handle = handle;
dictionary.owner = root;
document.objects.insert(handle, ObjectType::Dictionary(dictionary));
if let Some(ObjectType::Dictionary(root_dictionary)) = document.objects.get_mut(&root) {
root_dictionary.add_entry(MAP_DICTIONARY, handle);
}
handle
}
};
let handle = document.allocate_handle();
let ramps = crate::scene::model::point_cloud::schemes(document)
.into_iter()
.map(|(id, name, colors)| to_codec_ramp(&MapRamp { id, name, colors }))
.collect();
let mut map = ClassObject::new(ClassObjectData::PointCloudColorMap(PointCloudColorMap {
class_version: 4,
default_intensity_scheme: crate::entities::extended::point_cloud_ramp_id("Spectrum").to_string(),
default_elevation_scheme: crate::entities::extended::point_cloud_ramp_id("Earth").to_string(),
default_classification_scheme: CLASSIFICATION_SCHEME.to_string(),
color_ramps: ramps,
classification_color_ramps: Vec::new(),
}));
map.handle = handle;
map.owner = dictionary;
document.objects.insert(handle, ObjectType::ClassObject(map));
if let Some(ObjectType::Dictionary(dictionary)) = document.objects.get_mut(&dictionary) {
dictionary.add_entry(MAP_ENTRY, handle);
}
Some(handle)
}
/// A scheme as the colour map stores it: true colours, all shown.
fn to_codec_ramp(ramp: &MapRamp) -> PointCloudColorRamp {
PointCloudColorRamp {
id: ramp.id.clone(),
class_version: 1,
colors: ramp
.colors
.iter()
.map(|[r, g, b]| PointCloudRampColor {
color: (0xC2u32 << 24 | u32::from(*r) << 16 | u32::from(*g) << 8 | u32::from(*b)) as i32,
visible: true,
})
.collect(),
name: ramp.name.clone(),
}
}
/// A new scheme identifier: a random GUID in the stored form.
fn new_scheme_id() -> String {
let mut bytes = [0u8; 16];
getrandom::fill(&mut bytes).expect("OS random source");
let hex: String = bytes.iter().map(|b| format!("{b:02X}")).collect();
format!("{}-{}-{}-{}-{}", &hex[0..8], &hex[8..12], &hex[12..16], &hex[16..20], &hex[20..32])
}
/// `count` colours taken evenly along a scheme's blend.
fn resample(colors: &[[u8; 3]], count: usize) -> Vec<[u8; 3]> {
let count = count.clamp(2, 25);
(0..count)
.map(|k| crate::ui::window::pdf_dialogs::blend(colors, k as f64 / (count - 1) as f64))
.collect()
}
impl OpenCADStudio {
/// POINTCLOUDCOLORMAP after the prompt: the dialog for `cloud`, or for
/// the drawing's default schemes.
pub(in crate::app) fn open_point_cloud_color_map(&mut self, i: usize, cloud: Option<Handle>) {
let document = &self.tabs[i].scene.document;
let ramps: Vec<MapRamp> = crate::scene::model::point_cloud::schemes(document)
.into_iter()
.map(|(id, name, colors)| MapRamp { id, name, colors })
.collect();
let defaults = crate::scene::model::point_cloud::default_schemes(document);
let data = cloud.and_then(|handle| cloud_data(document, handle));
let height = data
.map(|data| {
let corners = codec::entities::point_cloud_ex_corners(data);
let low = corners.iter().map(|c| c.z).fold(f64::MAX, f64::min);
let high = corners.iter().map(|c| c.z).fold(f64::MIN, f64::max);
(low, high)
})
.unwrap_or((0.0, 0.0));
let slot = |scheme: &str, fallback: &str, gradient: bool, max: String, min: String, out: i16| MapSlot {
scheme: if scheme.is_empty() { fallback.to_string() } else { scheme.to_string() },
gradient,
max,
min,
out_of_range: OutOfRange(out),
};
let (intensity, elevation, extents, elevation_tab) = match data {
Some(data) => (
slot(
&data.intensity_color_scheme,
&defaults.0,
data.intensity_as_gradient,
data.intensity_max.to_string(),
data.intensity_min.to_string(),
data.intensity_out_of_range_behavior,
),
slot(
&data.current_color_scheme,
&defaults.1,
data.elevation_as_gradient,
short(if data.elevation_apply_to_fixed_range { data.elevation_max } else { height.1 }),
short(if data.elevation_apply_to_fixed_range { data.elevation_min } else { height.0 }),
data.elevation_out_of_range_behavior,
),
!data.elevation_apply_to_fixed_range,
data.stylization_type == 4,
),
None => (
slot("", &defaults.0, true, String::new(), String::new(), 1),
slot("", &defaults.1, false, String::new(), String::new(), 1),
true,
false,
),
};
let mut state = PointCloudColorMapState {
cloud: cloud.filter(|_| data.is_some()),
elevation_tab,
ramps,
intensity,
elevation,
extents,
height: (clean(height.0), clean(height.1)),
make_current: true,
interval: String::new(),
naming: None,
};
refresh_interval(&mut state);
self.point_cloud_color_map = Some(state);
self.active_modal = Some(crate::app::ModalKind::PointCloudColorMap);
}
pub(in crate::app) fn update_point_cloud_color_map(&mut self, message: PdfDialogMsg) -> Task<Message> {
let i = self.active_tab;
let Some(state) = self.point_cloud_color_map.as_mut() else {
return Task::none();
};
match message {
PdfDialogMsg::MapTab(elevation) => {
state.elevation_tab = elevation;
state.naming = None;
refresh_interval(state);
}
PdfDialogMsg::MapScheme(name) => {
if let Some(ramp) = state.ramps.iter().find(|r| r.name == name) {
state.slot_mut().scheme = ramp.id.clone();
refresh_interval(state);
}
}
PdfDialogMsg::MapCount(count) => {
if let Some(ramp) = state.ramp_mut() {
ramp.colors = resample(&ramp.colors, count);
}
refresh_interval(state);
}
// The colours between the two ends, blended evenly.
PdfDialogMsg::MapEven => {
if let Some(ramp) = state.ramp_mut() {
let (first, last) = (ramp.colors[0], ramp.colors[ramp.colors.len() - 1]);
ramp.colors = resample(&[first, last], ramp.colors.len());
}
}
PdfDialogMsg::MapReverse => {
if let Some(ramp) = state.ramp_mut() {
ramp.colors.reverse();
}
}
PdfDialogMsg::MapGradient(on) => state.slot_mut().gradient = on,
PdfDialogMsg::MapNew => {
// "Color Scheme 0", or the first number not taken.
let name = (0..)
.map(|n| format!("Color Scheme {n}"))
.find(|name| !state.ramps.iter().any(|r| r.name.eq_ignore_ascii_case(name)))
.unwrap_or_default();
state.naming = Some((true, name));
}
PdfDialogMsg::MapRename => {
let name = state.ramp().map(|r| r.name.clone()).unwrap_or_default();
state.naming = Some((false, name));
}
PdfDialogMsg::MapNameInput(value) => {
if let Some((_, name)) = state.naming.as_mut() {
*name = value;
}
}
PdfDialogMsg::MapNameCancel => state.naming = None,
PdfDialogMsg::MapNameOk => {
let Some((new, name)) = state.naming.take() else {
return Task::none();
};
let name = name.trim().to_string();
let taken = state.ramps.iter().any(|r| r.name.eq_ignore_ascii_case(&name))
&& !(!new && state.ramp().is_some_and(|r| r.name == name));
if name.is_empty() || taken {
self.command_line.push_error(crate::t!("A color scheme with this name already exists.").as_ref());
self.point_cloud_color_map.as_mut().map(|s| s.naming = Some((new, name)));
return Task::none();
}
if new {
let colors = state.ramp().map(|r| r.colors.clone()).unwrap_or_else(|| vec![[255; 3], [0; 3]]);
let id = new_scheme_id();
state.ramps.push(MapRamp { id: id.clone(), name, colors });
state.slot_mut().scheme = id;
} else if let Some(ramp) = state.ramp_mut() {
ramp.name = name;
}
}
// The last scheme stays; a slot naming a deleted one takes the first.
PdfDialogMsg::MapDelete => {
if state.ramps.len() > 1 {
let id = state.ramp().map(|r| r.id.clone()).unwrap_or_default();
state.ramps.retain(|r| r.id != id);
let first = state.ramps[0].id.clone();
for slot in [&mut state.intensity, &mut state.elevation] {
if slot.scheme.eq_ignore_ascii_case(&id) {
slot.scheme = first.clone();
}
}
refresh_interval(state);
}
}
PdfDialogMsg::MapMax(value) => {
state.slot_mut().max = value;
refresh_interval(state);
}
PdfDialogMsg::MapMin(value) => {
state.slot_mut().min = value;
refresh_interval(state);
}
// A band height sets the maximum from the minimum.
PdfDialogMsg::MapInterval(value) => {
let count = state.ramp().map_or(1, |r| r.colors.len()) as f64;
if let (Ok(step), Ok(min)) = (value.trim().parse::<f64>(), state.elevation.min.trim().parse::<f64>()) {
state.elevation.max = short(min + step * count);
}
state.interval = value;
}
PdfDialogMsg::MapExtents(on) => {
state.extents = on;
if on {
state.elevation.max = short(state.height.1);
state.elevation.min = short(state.height.0);
}
refresh_interval(state);
}
PdfDialogMsg::MapOutOfRange(choice) => state.slot_mut().out_of_range = choice,
PdfDialogMsg::MapCurrent(on) => state.make_current = on,
PdfDialogMsg::MapApply => {
self.apply_point_cloud_color_map(i);
}
PdfDialogMsg::MapOk => {
if self.apply_point_cloud_color_map(i) {
self.point_cloud_color_map = None;
self.active_modal = None;
}
}
_ => {}
}
Task::none()
}
/// Writes the dialog's schemes and the cloud's colouring as one undo
/// step; `false` (with a message) when a range value is not a number.
fn apply_point_cloud_color_map(&mut self, i: usize) -> bool {
let Some(state) = self.point_cloud_color_map.as_ref() else {
return false;
};
let intensity = (state.intensity.min.trim().parse::<i32>(), state.intensity.max.trim().parse::<i32>());
let elevation = (state.elevation.min.trim().parse::<f64>(), state.elevation.max.trim().parse::<f64>());
let cloud = state.cloud;
if cloud.is_some() {
let ok_intensity = matches!(intensity, (Ok(lo), Ok(hi)) if (0..=100).contains(&lo) && (0..=100).contains(&hi) && lo < hi);
let ok_elevation = state.extents || matches!(elevation, (Ok(lo), Ok(hi)) if lo < hi);
if !ok_intensity || !ok_elevation {
self.command_line.push_error(crate::t!("Invalid range of colorized points.").as_ref());
return false;
}
}
let ramps: Vec<PointCloudColorRamp> = state.ramps.iter().map(to_codec_ramp).collect();
let (intensity_slot, elevation_slot) = (state.intensity.clone(), state.elevation.clone());
let (extents, make_current, elevation_tab) = (state.extents, state.make_current, state.elevation_tab);
self.push_undo_snapshot(i, "POINTCLOUDCOLORMAP");
let document = &mut self.tabs[i].scene.document;
if let Some(handle) = ensure_color_map(document) {
if let Some(ObjectType::ClassObject(object)) = document.objects.get_mut(&handle) {
if let ClassObjectData::PointCloudColorMap(map) = &mut object.data {
map.color_ramps = ramps;
if cloud.is_none() {
map.default_intensity_scheme = intensity_slot.scheme.clone();
map.default_elevation_scheme = elevation_slot.scheme.clone();
}
}
}
}
if let Some(handle) = cloud {
if let Some(codec::EntityType::Extended(extended)) = document.get_entity_mut(handle) {
if let ExtendedEntityData::PointCloudEx(data) = &mut extended.data {
data.intensity_color_scheme = intensity_slot.scheme;
data.intensity_as_gradient = intensity_slot.gradient;
data.intensity_out_of_range_behavior = intensity_slot.out_of_range.0;
if let (Ok(lo), Ok(hi)) = intensity {
(data.intensity_min, data.intensity_max) = (lo, hi);
}
data.current_color_scheme = elevation_slot.scheme;
data.elevation_as_gradient = elevation_slot.gradient;
data.elevation_out_of_range_behavior = elevation_slot.out_of_range.0;
data.elevation_apply_to_fixed_range = !extents;
if let (false, Ok(lo), Ok(hi)) = (extents, elevation.0, elevation.1) {
(data.elevation_min, data.elevation_max) = (lo, hi);
}
if make_current {
data.stylization_type = if elevation_tab { 4 } else { 5 };
}
}
}
}
// Every cloud colours from the schemes: redraw them all.
let changes: Vec<_> = self.tabs[i]
.scene
.document
.entities()
.filter(|entity| crate::scene::is_point_cloud(entity))
.map(|entity| (entity.common().handle, crate::scene::ChangeKind::Modified))
.collect();
self.tabs[i].scene.bump_entities(&changes);
self.tabs[i].dirty = true;
self.refresh_properties();
true
}
pub(super) fn dispatch_pc_colormap(&mut self, cmd: &str, i: usize) -> Option<Task<Message>> {
use crate::command::CadCommand;
if cmd == "POINTCLOUDCOLORMAP" {
let command = crate::modules::insert::pc_stylize::PointCloudColorMapCommand::new();
self.command_line.push_info(&command.prompt());
self.tabs[i].active_cmd = Some(Box::new(command));
return Some(self.finish_dispatch(cmd));
}
let rest = cmd.strip_prefix("_PCCOLORMAP")?;
let cloud = u64::from_str_radix(rest.trim(), 16).ok().map(Handle::new);
let task = self.finish_dispatch(cmd);
self.open_point_cloud_color_map(i, cloud);
Some(task)
}
}
/// A height with rounding noise dropped (the extents box's -0.0016 reads 0).
fn clean(value: f64) -> f64 {
(value * 100.0).round() / 100.0
}
fn refresh_interval(state: &mut PointCloudColorMapState) {
let count = state.ramp().map_or(1, |r| r.colors.len()).max(1) as f64;
let lo = state.elevation.min.trim().parse::<f64>();
let hi = state.elevation.max.trim().parse::<f64>();
state.interval = match (lo, hi) {
(Ok(lo), Ok(hi)) => short((hi - lo) / count),
_ => String::new(),
};
}
+199
View File
@@ -0,0 +1,199 @@
//! PCEXTRACTEDGE, PCEXTRACTCORNER, PCEXTRACTCENTERLINE and
//! (-)PCEXTRACTSECTION: the commands get the drawing's shown cloud points
//! and its section objects when they start.
use super::*;
use crate::ui::window::pdf_dialogs::PdfDialogMsg;
use codec::entities::ExtendedEntityData;
use glam::DVec3;
use crate::modules::insert::pc_extract::{
CenterlineCommand, Clouds, PlanesCommand, Section, SectionCommand,
};
fn dvec(v: codec::types::Vector3) -> DVec3 {
DVec3::new(v.x, v.y, v.z)
}
impl OpenCADStudio {
/// The points each point cloud shows (the renderer's placed set).
fn extraction_clouds(&self, i: usize) -> Clouds {
let document = &self.tabs[i].scene.document;
let mut clouds = Vec::new();
for entity in document.entities() {
let codec::EntityType::Extended(extended) = entity else {
continue;
};
let ExtendedEntityData::PointCloudEx(data) = &extended.data else {
continue;
};
let common = entity.common();
// The colour the renderer places with, so its cached points are reused.
let color = if matches!(common.color, codec::types::Color::ByLayer) {
document.layers.get(&common.layer).map(|layer| layer.color.clone()).unwrap_or(common.color.clone())
} else {
common.color.clone()
};
let color = color.rgb().map_or([255; 3], |(r, g, b)| [r, g, b]);
if let Some(placed) = crate::scene::model::point_cloud::placed(
document,
data,
color,
None,
&crate::scene::model::point_cloud::hidden(data),
) {
clouds.push((common.handle, placed));
}
}
Clouds(clouds)
}
fn extraction_sections(&self, i: usize) -> Vec<Section> {
self.tabs[i]
.scene
.document
.entities()
.filter_map(|entity| {
let codec::EntityType::Extended(extended) = entity else {
return None;
};
let ExtendedEntityData::SectionObject(data) = &extended.data else {
return None;
};
let (first, last) = (dvec(*data.vertices.first()?), dvec(*data.vertices.last()?));
let tangent = (last - first).normalize_or(DVec3::X);
let vertical = dvec(data.vertical_direction).normalize_or(DVec3::Z);
let viewing = vertical.cross(tangent).normalize_or(-DVec3::Z)
* if data.flags & 4 != 0 { 1.0 } else { -1.0 };
Some(Section { origin: first, viewing, tangent, live: data.flags & 1 != 0 })
})
.collect()
}
pub(super) fn dispatch_pc_extract(&mut self, cmd: &str, i: usize) -> Option<Task<Message>> {
use crate::command::CadCommand;
let command: Box<dyn CadCommand> = match cmd {
"PCEXTRACTEDGE" => Box::new(PlanesCommand::new(false, self.extraction_clouds(i))),
"PCEXTRACTCORNER" => Box::new(PlanesCommand::new(true, self.extraction_clouds(i))),
"PCEXTRACTCENTERLINE" => Box::new(CenterlineCommand::new(self.extraction_clouds(i))),
"PCEXTRACTSECTION" | "-PCEXTRACTSECTION" => Box::new(SectionCommand::new(
cmd == "PCEXTRACTSECTION",
self.extraction_clouds(i),
self.extraction_sections(i),
)),
"_PCSECTIONDLG" => {
self.open_pc_section_dialog(i);
return Some(self.finish_dispatch(cmd));
}
_ => return None,
};
self.command_line.push_info(&command.prompt());
self.tabs[i].active_cmd = Some(command);
Some(self.finish_dispatch(cmd))
}
}
impl OpenCADStudio {
/// PCEXTRACTSECTION's settings dialog, filled from the session settings.
fn open_pc_section_dialog(&mut self, i: usize) {
use crate::modules::insert::pc_extract::{has_job, settings};
use crate::ui::window::pdf_dialogs::{LineColor, PcSectionState};
if !has_job() {
return;
}
let s = settings();
let current = crate::t!("Use Current").into_owned();
let mut layers = vec![current.clone()];
let mut names: Vec<String> = self.tabs[i].scene.document.layers.iter().map(|l| l.name.clone()).collect();
names.sort_by_key(|n| n.to_lowercase());
layers.extend(names);
let short = |v: f64| {
let t = format!("{v:.4}");
t.trim_end_matches('0').trim_end_matches('.').to_string()
};
self.pc_section = Some(PcSectionState {
perimeter: s.perimeter,
max_points: s.max_points.to_string(),
layers,
layer: s.layer.clone().unwrap_or(current),
color: LineColor(s.color),
polylines: s.polylines,
width: short(s.width),
min_length: short(s.min_length),
connect: short(s.connect),
angle: short(s.angle),
preview: s.preview,
});
self.active_modal = Some(crate::app::ModalKind::PcSection);
}
pub(in crate::app) fn update_pc_section(&mut self, message: PdfDialogMsg) -> Task<Message> {
use crate::modules::insert::pc_extract::{set_settings, settings, SectionCommand, SectionDistanceCommand};
use crate::command::CadCommand;
let i = self.active_tab;
let Some(state) = self.pc_section.as_mut() else {
return Task::none();
};
match message {
PdfDialogMsg::SecPerimeter(on) => state.perimeter = on,
PdfDialogMsg::SecMaxPoints(v) => state.max_points = v,
PdfDialogMsg::SecLayer(v) => state.layer = v,
PdfDialogMsg::SecColor(c) => state.color = c,
PdfDialogMsg::SecPolylines(on) => state.polylines = on,
PdfDialogMsg::SecWidth(v) => state.width = v,
PdfDialogMsg::SecMinLength(v) => state.min_length = v,
PdfDialogMsg::SecConnect(v) => state.connect = v,
PdfDialogMsg::SecAngle(v) => state.angle = v,
PdfDialogMsg::SecPreview(on) => state.preview = on,
// Measure on screen, then back to the dialog (the extraction waits).
PdfDialogMsg::SecPick(connect) => {
self.pc_section = None;
self.active_modal = None;
let command = SectionDistanceCommand::new(connect);
self.command_line.push_info(&command.prompt());
self.tabs[i].active_cmd = Some(Box::new(command));
}
PdfDialogMsg::SecCreate => {
let number = |t: &str| crate::entities::common::parse_f64(t.trim());
let (min_length, connect, width) = (number(&state.min_length), number(&state.connect), number(&state.width));
let (Some(min_length), Some(connect)) = (min_length.filter(|v| *v > 0.0), connect.filter(|v| *v > 0.0)) else {
self.command_line.push_error(crate::t!("Requires a positive number.").as_ref());
return Task::none();
};
let Some(angle) = state.angle.trim().parse::<i64>().ok().filter(|v| (0..=10).contains(v)) else {
self.command_line
.push_error("Colinear angle tolerance only accepts integer from 0 to 10.");
return Task::none();
};
let Some(max_points) = state.max_points.trim().parse::<usize>().ok().filter(|v| *v > 0) else {
self.command_line.push_error(crate::t!("Requires a positive integer.").as_ref());
return Task::none();
};
let current = crate::t!("Use Current").into_owned();
let mut s = settings();
s.perimeter = state.perimeter;
s.max_points = max_points;
s.layer = (state.layer != current).then(|| state.layer.clone());
s.color = state.color.0;
s.polylines = state.polylines;
s.width = width.filter(|v| *v >= 0.0).unwrap_or(0.0);
s.min_length = min_length;
s.connect = connect;
s.angle = angle as f64;
s.preview = state.preview;
set_settings(s);
self.pc_section = None;
self.active_modal = None;
let Some(mut command) = SectionCommand::resume() else {
return Task::none();
};
let result = command.run();
self.command_line.push_info(&command.prompt());
self.tabs[i].active_cmd = Some(Box::new(command));
return self.apply_cmd_result(result);
}
_ => {}
}
Task::none()
}
}
+789
View File
@@ -0,0 +1,789 @@
//! The PDF dialogs' state changes: Attach PDF Underlay, Underlay Layers,
//! PDF Import Settings and Import PDF.
use super::*;
use crate::io::xref_model::Pathtype;
use crate::modules::insert::pdf_import::{self, PdfFileImport};
use crate::ui::window::pdf_dialogs::{
click_page, item_thumbs, page_thumbs, LayerTarget, PdfAttachState, PdfDialogMsg, PdfImportFileState,
PointCloudAttachState, RotationChoice, UnderlayLayersState,
};
/// "7.8740 × 3.9369" (inches) for a page.
fn page_size_text(path: &str, page: &str) -> String {
crate::scene::model::pdf_raster::page_size_inches(path, page)
.map(|(w, h)| format!("{w:.4} × {h:.4}"))
.unwrap_or_default()
}
/// The page's (PDF, inches) or the sheet's / model's size.
fn item_size_text(kind: codec::entities::UnderlayType, path: &str, item: &str) -> String {
if kind == codec::entities::UnderlayType::Pdf {
return page_size_text(path, item);
}
crate::scene::model::underlay_vector::sheet(kind, path, item)
.map(|s| format!("{:.4} × {:.4}", s.rect[2] - s.rect[0], s.rect[3] - s.rect[1]))
.unwrap_or_default()
}
fn sub_per_master(kind: codec::entities::UnderlayType, path: &str, item: &str) -> f64 {
crate::scene::model::underlay_vector::sheet(kind, path, item)
.map(|s| s.sub_per_master)
.filter(|v| *v > 0.0)
.unwrap_or(1.0)
}
fn file_stem(path: &str) -> String {
std::path::Path::new(&path.replace('\\', "/"))
.file_stem()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_default()
}
impl OpenCADStudio {
/// The path an attached file is stored with: full, relative to the saved
/// drawing (the full path until it is saved), or the file name alone.
fn pdf_stored_path(&self, path: &str, path_type: Pathtype) -> String {
match path_type {
Pathtype::Full => path.to_string(),
Pathtype::Relative => self.tabs[self.active_tab]
.current_path
.as_deref()
.and_then(|host| {
crate::io::xref_model::to_pathtype_result(path, host, Pathtype::Relative).ok()
})
.unwrap_or_else(|| path.to_string()),
Pathtype::None => std::path::Path::new(&path.replace('\\', "/"))
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| path.to_string()),
}
}
/// PDFATTACH after the file is chosen.
pub(in crate::app) fn open_pdf_attach_dialog(&mut self, path: &str) {
self.open_underlay_attach_dialog(codec::entities::UnderlayType::Pdf, path);
}
/// PDFATTACH / DWFATTACH / DGNATTACH after the file is chosen.
pub(in crate::app) fn open_underlay_attach_dialog(&mut self, kind: codec::entities::UnderlayType, path: &str) {
let i = self.active_tab;
let mut existing: Vec<(String, String)> = Vec::new();
for object in self.tabs[i].scene.document.objects.values() {
if let codec::objects::ObjectType::UnderlayDefinition(def) = object {
if def.underlay_type != kind {
continue;
}
let name = file_stem(&def.file_path);
if !existing.iter().any(|(n, _)| *n == name) {
existing.push((name, def.file_path.clone()));
}
}
}
let mut state = PdfAttachState::new(existing);
state.kind = kind;
self.load_pdf_attach_file(&mut state, path);
self.pdf_attach = Some(state);
self.active_modal = Some(crate::app::ModalKind::PdfAttach);
}
fn load_pdf_attach_file(&self, state: &mut PdfAttachState, path: &str) {
state.path = path.to_string();
state.name = file_stem(path);
state.pages = item_thumbs(state.kind, path);
state.selected = vec![0];
state.anchor = 0;
state.found_in = crate::entities::underlay::display_path(path);
let first = state.page_labels().into_iter().next().unwrap_or_else(|| "1".to_string());
state.page_size = item_size_text(state.kind, path, &first);
state.sub_per_master = sub_per_master(state.kind, path, &first);
state.sub_units = false;
state.saved_path =
crate::entities::underlay::display_path(&self.pdf_stored_path(path, state.path_type.0));
}
/// POINTCLOUDATTACH after the file is chosen: the Attach Point Cloud
/// dialog, placed on screen by default.
pub(in crate::app) fn open_point_cloud_attach_dialog(&mut self, path: &std::path::Path) {
let Some(cloud) = crate::scene::model::point_cloud::load(path) else {
return;
};
let yes_no = |on: bool| if on { crate::t!("Yes") } else { crate::t!("No") }.into_owned();
let data = format!(
"RGB: {} · {}: {} · {}: {}",
yes_no(cloud.has_rgb),
crate::t!("Intensity"),
yes_no(cloud.has_intensity),
crate::t!("Normals"),
yes_no(cloud.has_normals),
);
let data_more = format!(
"{}: {} · {}: {}",
crate::t!("Classification"),
yes_no(false),
crate::t!("Segmentation"),
yes_no(false),
);
let factor = crate::app::properties::insert_unit_scale(self.tabs[self.active_tab].scene.document.header.insertion_units, 6)
.unwrap_or(1.0);
let full = path.to_string_lossy().into_owned();
let memory = crate::ui::window::pdf_dialogs::ATTACH_MEMORY.lock().ok().and_then(|m| m.clone()).unwrap_or_default();
let mut state = PointCloudAttachState {
path: full.clone(),
name: file_stem(&full),
preview: cloud.preview.clone().map(iced::widget::image::Handle::from_bytes),
summary: format!(
"{} · {}",
crate::tf!("{count} scans", count = cloud.scans),
crate::tf!("{count} points", count = cloud.positions.len())
),
data,
data_more,
size: cloud
.bounds
.map(|[lo, hi]| format!("{:.4} × {:.4} × {:.4}", hi[0] - lo[0], hi[1] - lo[1], hi[2] - lo[2]))
.unwrap_or_default(),
unit: format!("{} · {} {}", crate::t!("Meters"), crate::t!("Unit factor"), crate::app::properties::format_unit_factor(factor)),
found_in: crate::entities::underlay::display_path(&full),
saved_path: String::new(),
path_type: memory.path_type,
insert_on_screen: true,
insert: ["0.0000".into(), "0.0000".into(), "0.0000".into()],
scale_on_screen: true,
scale: "1.0000".into(),
rotation_on_screen: true,
rotation: "0".into(),
lock: false,
zoom: true,
};
state.saved_path = crate::entities::underlay::display_path(&self.pdf_stored_path(&full, state.path_type.0));
self.point_cloud_attach = Some(state);
self.active_modal = Some(crate::app::ModalKind::PointCloudAttach);
}
/// The dialog's Attach: what it set goes to the command, which asks for
/// the rest.
fn point_cloud_attach_ok(&mut self, i: usize) -> Task<Message> {
use crate::command::CadCommand;
let Some(state) = self.point_cloud_attach.as_ref() else {
return Task::none();
};
let number = |s: &str| s.trim().parse::<f64>().ok();
let insertion = if state.insert_on_screen {
None
} else {
match (number(&state.insert[0]), number(&state.insert[1]), number(&state.insert[2])) {
(Some(x), Some(y), Some(z)) => Some(glam::DVec3::new(x, y, z)),
_ => {
self.command_line.push_error("Requires numeric value.");
return Task::none();
}
}
};
let scale = if state.scale_on_screen {
None
} else {
match number(&state.scale).filter(|v| *v > 0.0) {
Some(v) => Some(v),
None => {
self.command_line.push_error("Value must be positive and nonzero.");
return Task::none();
}
}
};
let rotation = if state.rotation_on_screen {
None
} else {
match number(&state.rotation) {
Some(v) => Some(v),
None => {
self.command_line.push_error("Requires numeric value.");
return Task::none();
}
}
};
let state = self.point_cloud_attach.take().expect("checked above");
self.close_active_modal();
let options = crate::modules::insert::pc_attach::AttachOptions {
stored: (state.path_type.0 == Pathtype::None).then(|| {
std::path::Path::new(&state.path)
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default()
}),
relative: state.path_type.0 == Pathtype::Relative,
locked: Some(state.lock),
zoom: state.zoom,
scale,
rotation,
};
let mut command = crate::modules::insert::pc_attach::PointCloudAttachCommand::from_dialog(
std::path::PathBuf::from(&state.path),
options,
);
match insertion {
Some(point) => {
let result = command.on_point(point);
self.tabs[i].active_cmd = Some(Box::new(command));
self.apply_cmd_result(result)
}
None => {
self.command_line.push_info(&command.prompt());
self.tabs[i].active_cmd = Some(Box::new(command));
self.focus_cmd_input()
}
}
}
/// ULAYERS: every underlay of the drawing, the selected one first shown.
pub(in crate::app) fn open_underlay_layers_dialog(&mut self, i: usize) {
let document = &self.tabs[i].scene.document;
let selected: Vec<codec::Handle> = self.tabs[i]
.scene
.selected_entities()
.iter()
.map(|(h, _)| *h)
.collect();
let mut targets: Vec<LayerTarget> = Vec::new();
for entity in document.entities() {
let codec::EntityType::Underlay(u) = entity else {
continue;
};
let Some(def) = crate::entities::underlay::definition(u, document) else {
continue;
};
let mut name = crate::entities::underlay::definition_display_name(def);
let same = targets.iter().filter(|t| t.name.starts_with(&name)).count();
if same > 0 {
name = format!("{name} ({})", same + 1);
}
let mut layers: Vec<String> = match u.underlay_type {
codec::entities::UnderlayType::Pdf => crate::scene::model::pdf_layers::layers(&def.file_path)
.into_iter()
.map(|l| l.name)
.collect(),
kind => crate::scene::model::underlay_vector::layer_names(kind, &def.file_path),
};
layers.sort_by_key(|n| n.to_lowercase());
layers.dedup();
targets.push(LayerTarget {
name,
handle: u.common.handle,
layers,
hidden: crate::scene::model::pdf_layers::hidden_layers(u),
});
}
if targets.is_empty() {
self.command_line.push_error("No underlays found.");
return;
}
let current = targets
.iter()
.position(|t| selected.contains(&t.handle))
.unwrap_or(0);
self.underlay_layers = Some(UnderlayLayersState {
targets,
current,
search: String::new(),
});
self.active_modal = Some(crate::app::ModalKind::UnderlayLayers);
}
/// PDFIMPORT's Settings option: the dialog over the running command.
pub(in crate::app) fn open_pdf_import_settings(&mut self) {
self.pdf_import_settings = Some(pdf_import::import_settings());
self.active_modal = Some(crate::app::ModalKind::PdfImportSettings);
}
/// PDFIMPORT's File option after the file is chosen.
pub(in crate::app) fn open_pdf_import_file(&mut self, path: &str) {
self.pdf_import_file = Some(PdfImportFileState {
path: path.to_string(),
pages: page_thumbs(path),
selected: 0,
page_text: "1".into(),
insert_on_screen: false,
scale: "1".into(),
rotation: RotationChoice(0),
page_size: page_size_mm(path, "1"),
settings: pdf_import::import_settings(),
});
self.active_modal = Some(crate::app::ModalKind::PdfImportFile);
}
/// Writes the layers each underlay turns off, as one undo step.
fn apply_underlay_layers(&mut self, i: usize, targets: Vec<LayerTarget>) {
use crate::scene::model::pdf_layers::{hidden_layers, DGN_OVERRIDE_PLACEHOLDER, LAYER_OVERRIDE_APP};
let changed: Vec<LayerTarget> = targets
.into_iter()
.filter(|t| match self.tabs[i].scene.document.get_entity(t.handle) {
Some(codec::EntityType::Underlay(u)) => hidden_layers(u) != t.hidden,
_ => false,
})
.collect();
if changed.is_empty() {
return;
}
self.push_undo_snapshot(i, "ULAYERS");
self.tabs[i].scene.ensure_app_id(LAYER_OVERRIDE_APP);
let app_handle = self.tabs[i]
.scene
.document
.app_ids
.get(LAYER_OVERRIDE_APP)
.map(|a| a.handle.value());
for target in &changed {
if let Some(codec::EntityType::Underlay(u)) =
self.tabs[i].scene.document.get_entity_mut(target.handle)
{
let data = &mut u.common.extended_data;
if let Some(app) = app_handle {
data.raw_dwg_eed.retain(|(a, _)| *a != app);
}
data.remove_record(LAYER_OVERRIDE_APP);
if !target.hidden.is_empty() {
let mut record = codec::xdata::ExtendedDataRecord::new(LAYER_OVERRIDE_APP);
// A DGN underlay's names follow a placeholder string.
if u.underlay_type == codec::entities::UnderlayType::Dgn {
record.add_value(codec::xdata::XDataValue::String(DGN_OVERRIDE_PLACEHOLDER.to_string()));
}
for name in &target.hidden {
record.add_value(codec::xdata::XDataValue::String(name.clone()));
}
data.add_record(record);
}
}
self.tabs[i].scene.reseed_derived_caches(target.handle);
}
let changes: Vec<_> = changed
.iter()
.map(|t| (t.handle, crate::scene::ChangeKind::Modified))
.collect();
self.tabs[i].scene.bump_entities(&changes);
self.tabs[i].dirty = true;
self.refresh_properties();
}
pub(in crate::app) fn update_pdf_dialog(&mut self, message: PdfDialogMsg) -> Task<Message> {
let i = self.active_tab;
match message {
PdfDialogMsg::Help(which) => {
let kind = self.pdf_attach.as_ref().map(|s| s.kind);
let text = match which {
"attach" if kind == Some(codec::entities::UnderlayType::Dwf) => {
crate::t!("Attaches a DWF or DWFx file as an underlay.")
}
"attach" if kind == Some(codec::entities::UnderlayType::Dgn) => {
crate::t!("Attaches a DGN file as an underlay.")
}
"attach" => crate::t!("Attaches a PDF file as an underlay."),
"pointcloud" => crate::t!("Attaches a point cloud scan or project."),
"pcsection" => crate::t!("Traces the points on the kept side of a live section as lines."),
"pointcloudcolormap" => {
crate::t!("Sets the color schemes and ranges a point cloud is colored by intensity or elevation.")
}
"layers" => crate::t!("Turns the layers of a PDF underlay on or off."),
_ => crate::t!("Imports the geometry, fills, raster images and text of a PDF file as drawing objects."),
};
self.command_line.push_info(text.as_ref());
}
PdfDialogMsg::Options => {
self.options_parent = self.active_modal;
self.options_open();
}
// ── Attach ────────────────────────────────────────────────────
PdfDialogMsg::AttachBrowse => {
let kind = self.pdf_attach.as_ref().map(|s| s.kind).unwrap_or(codec::entities::UnderlayType::Pdf);
if let Some(state) = &self.pdf_attach {
state.remember();
}
if kind != codec::entities::UnderlayType::Pdf {
return Task::done(Message::UnderlayAttachPick(kind));
}
return Task::done(Message::PdfAttachPick);
}
PdfDialogMsg::AttachSubUnits(sub) => {
if let Some(s) = self.pdf_attach.as_mut() {
s.sub_units = sub;
// The scale offered follows the units, as on the command line.
s.scale = if sub { format!("{}", 1.0 / s.sub_per_master) } else { "1.0000".to_string() };
}
}
PdfDialogMsg::AttachName(name) => {
let Some(mut state) = self.pdf_attach.take() else {
return Task::none();
};
if let Some((_, path)) = state.existing.iter().find(|(n, _)| *n == name).cloned() {
self.load_pdf_attach_file(&mut state, &path);
}
self.pdf_attach = Some(state);
}
PdfDialogMsg::AttachPage(page) => {
let (ctrl, shift) = (self.ctrl_down, self.shift_down);
if let Some(state) = self.pdf_attach.as_mut() {
if state.kind == codec::entities::UnderlayType::Pdf {
click_page(&mut state.selected, &mut state.anchor, page, ctrl, shift);
} else {
// One sheet or model at a time.
state.selected = vec![page];
state.anchor = page;
}
let first = state.page_labels().into_iter().next().unwrap_or_default();
state.page_size = item_size_text(state.kind, &state.path, &first);
state.sub_per_master = sub_per_master(state.kind, &state.path, &first);
}
}
PdfDialogMsg::AttachPathType(choice) => {
let Some(mut state) = self.pdf_attach.take() else {
return Task::none();
};
state.path_type = choice;
state.saved_path = crate::entities::underlay::display_path(
&self.pdf_stored_path(&state.path, choice.0),
);
self.pdf_attach = Some(state);
}
PdfDialogMsg::AttachInsertOnScreen(on) => {
if let Some(s) = self.pdf_attach.as_mut() {
s.insert_on_screen = on;
}
}
PdfDialogMsg::AttachInsert(axis, value) => {
if let Some(s) = self.pdf_attach.as_mut() {
s.insert[axis.min(2)] = value;
}
}
PdfDialogMsg::AttachScaleOnScreen(on) => {
if let Some(s) = self.pdf_attach.as_mut() {
s.scale_on_screen = on;
}
}
PdfDialogMsg::AttachScale(value) => {
if let Some(s) = self.pdf_attach.as_mut() {
s.scale = value;
}
}
PdfDialogMsg::AttachRotationOnScreen(on) => {
if let Some(s) = self.pdf_attach.as_mut() {
s.rotation_on_screen = on;
}
}
PdfDialogMsg::AttachRotation(value) => {
if let Some(s) = self.pdf_attach.as_mut() {
s.rotation = value;
}
}
PdfDialogMsg::AttachDetails(on) => {
if let Some(s) = self.pdf_attach.as_mut() {
s.details = on;
}
}
PdfDialogMsg::AttachOk => return self.pdf_attach_ok(i),
// ── Attach Point Cloud ────────────────────────────────────────
PdfDialogMsg::CloudBrowse => return Task::done(Message::PointCloudAttachPick),
PdfDialogMsg::CloudOk => return self.point_cloud_attach_ok(i),
message @ (PdfDialogMsg::MapTab(_)
| PdfDialogMsg::MapScheme(_)
| PdfDialogMsg::MapCount(_)
| PdfDialogMsg::MapEven
| PdfDialogMsg::MapReverse
| PdfDialogMsg::MapGradient(_)
| PdfDialogMsg::MapNew
| PdfDialogMsg::MapDelete
| PdfDialogMsg::MapRename
| PdfDialogMsg::MapNameInput(_)
| PdfDialogMsg::MapNameOk
| PdfDialogMsg::MapNameCancel
| PdfDialogMsg::MapMax(_)
| PdfDialogMsg::MapMin(_)
| PdfDialogMsg::MapInterval(_)
| PdfDialogMsg::MapExtents(_)
| PdfDialogMsg::MapOutOfRange(_)
| PdfDialogMsg::MapCurrent(_)
| PdfDialogMsg::MapApply
| PdfDialogMsg::MapOk) => return self.update_point_cloud_color_map(message),
message @ (PdfDialogMsg::SecPerimeter(_)
| PdfDialogMsg::SecMaxPoints(_)
| PdfDialogMsg::SecLayer(_)
| PdfDialogMsg::SecColor(_)
| PdfDialogMsg::SecPolylines(_)
| PdfDialogMsg::SecWidth(_)
| PdfDialogMsg::SecMinLength(_)
| PdfDialogMsg::SecConnect(_)
| PdfDialogMsg::SecAngle(_)
| PdfDialogMsg::SecPick(_)
| PdfDialogMsg::SecPreview(_)
| PdfDialogMsg::SecCreate) => return self.update_pc_section(message),
PdfDialogMsg::CloudPathType(choice) => {
let stored = self.point_cloud_attach.as_ref().map(|s| self.pdf_stored_path(&s.path, choice.0));
if let (Some(s), Some(stored)) = (self.point_cloud_attach.as_mut(), stored) {
s.path_type = choice;
s.saved_path = crate::entities::underlay::display_path(&stored);
}
}
PdfDialogMsg::CloudInsertOnScreen(on) => {
if let Some(s) = self.point_cloud_attach.as_mut() {
s.insert_on_screen = on;
}
}
PdfDialogMsg::CloudInsert(k, value) => {
if let Some(s) = self.point_cloud_attach.as_mut() {
s.insert[k.min(2)] = value;
}
}
PdfDialogMsg::CloudScaleOnScreen(on) => {
if let Some(s) = self.point_cloud_attach.as_mut() {
s.scale_on_screen = on;
}
}
PdfDialogMsg::CloudScale(value) => {
if let Some(s) = self.point_cloud_attach.as_mut() {
s.scale = value;
}
}
PdfDialogMsg::CloudRotationOnScreen(on) => {
if let Some(s) = self.point_cloud_attach.as_mut() {
s.rotation_on_screen = on;
}
}
PdfDialogMsg::CloudRotation(value) => {
if let Some(s) = self.point_cloud_attach.as_mut() {
s.rotation = value;
}
}
PdfDialogMsg::CloudLock(on) => {
if let Some(s) = self.point_cloud_attach.as_mut() {
s.lock = on;
}
}
PdfDialogMsg::CloudZoom(on) => {
if let Some(s) = self.point_cloud_attach.as_mut() {
s.zoom = on;
}
}
// ── Underlay Layers ───────────────────────────────────────────
PdfDialogMsg::LayersUnderlay(name) => {
if let Some(s) = self.underlay_layers.as_mut() {
if let Some(at) = s.targets.iter().position(|t| t.name == name) {
s.current = at;
}
}
}
PdfDialogMsg::LayersSearch(text) => {
if let Some(s) = self.underlay_layers.as_mut() {
s.search = text;
}
}
PdfDialogMsg::LayersToggle(layer) => {
if let Some(s) = self.underlay_layers.as_mut() {
if let Some(t) = s.targets.get_mut(s.current) {
match t.hidden.iter().position(|h| *h == layer) {
Some(at) => {
t.hidden.remove(at);
}
// Only a layer the file has can be turned off.
None if t.layers.contains(&layer) => t.hidden.push(layer),
None => {}
}
}
}
}
PdfDialogMsg::LayersOk => {
if let Some(state) = self.underlay_layers.take() {
self.apply_underlay_layers(i, state.targets);
}
self.close_active_modal();
}
// ── Import settings ───────────────────────────────────────────
PdfDialogMsg::Vector(_)
| PdfDialogMsg::Fills(_)
| PdfDialogMsg::Text(_)
| PdfDialogMsg::Raster(_)
| PdfDialogMsg::Layers(_)
| PdfDialogMsg::AsBlock(_)
| PdfDialogMsg::Join(_)
| PdfDialogMsg::Hatches(_)
| PdfDialogMsg::Lineweights(_)
| PdfDialogMsg::Linetypes(_) => {
let settings = if self.active_modal == Some(crate::app::ModalKind::PdfImportFile) {
self.pdf_import_file.as_mut().map(|s| &mut s.settings)
} else {
self.pdf_import_settings.as_mut()
};
if let Some(s) = settings {
match message {
PdfDialogMsg::Vector(v) => s.vector = v,
PdfDialogMsg::Fills(v) => s.fills = v,
PdfDialogMsg::Text(v) => s.text = v,
PdfDialogMsg::Raster(v) => s.raster = v,
PdfDialogMsg::Layers(v) => s.layers = v,
PdfDialogMsg::AsBlock(v) => s.as_block = v,
PdfDialogMsg::Join(v) => s.join = v,
PdfDialogMsg::Hatches(v) => s.hatches = v,
PdfDialogMsg::Lineweights(v) => s.lineweights = v,
PdfDialogMsg::Linetypes(v) => s.linetypes = v,
_ => {}
}
}
}
PdfDialogMsg::SettingsOk => {
if let Some(s) = self.pdf_import_settings.take() {
pdf_import::set_import_settings(s);
}
self.close_active_modal();
}
// ── Import PDF ────────────────────────────────────────────────
PdfDialogMsg::ImportBrowse => return Task::done(Message::PdfImportPick),
PdfDialogMsg::ImportPage(page) => {
if let Some(s) = self.pdf_import_file.as_mut() {
s.selected = page;
s.page_text = (page + 1).to_string();
s.page_size = page_size_mm(&s.path, &s.page_text);
}
}
PdfDialogMsg::ImportPageText(text) => {
if let Some(s) = self.pdf_import_file.as_mut() {
if let Some(n) = text.trim().parse::<usize>().ok().filter(|n| (1..=s.pages.len()).contains(n)) {
s.selected = n - 1;
s.page_size = page_size_mm(&s.path, &n.to_string());
}
s.page_text = text;
}
}
PdfDialogMsg::ImportInsertOnScreen(on) => {
if let Some(s) = self.pdf_import_file.as_mut() {
s.insert_on_screen = on;
}
}
PdfDialogMsg::ImportScale(value) => {
if let Some(s) = self.pdf_import_file.as_mut() {
s.scale = value;
}
}
PdfDialogMsg::ImportRotation(r) => {
if let Some(s) = self.pdf_import_file.as_mut() {
s.rotation = r;
}
}
PdfDialogMsg::ImportOk => {
let Some(state) = self.pdf_import_file.as_ref() else {
return Task::none();
};
let Some(scale) = state.scale.trim().parse::<f64>().ok().filter(|v| *v > 0.0) else {
self.command_line.push_error("Value must be positive and nonzero.");
return Task::none();
};
let state = self.pdf_import_file.take().expect("checked above");
pdf_import::set_import_settings(state.settings);
self.close_active_modal();
let import = PdfFileImport {
path: state.path.clone(),
page: (state.selected + 1).to_string(),
scale,
rotation: (state.rotation.0 as f64).to_radians(),
insertion: glam::DVec3::ZERO,
};
if state.insert_on_screen {
use crate::command::CadCommand;
let command = pdf_import::PdfImportPointCommand::new(import);
self.command_line.push_info(&command.prompt());
self.tabs[i].active_cmd = Some(Box::new(command));
return self.focus_cmd_input();
}
self.run_pdf_import(i, crate::app::commands::pdf_import::PdfImportSource::File(import));
}
}
Task::none()
}
fn pdf_attach_ok(&mut self, i: usize) -> Task<Message> {
use crate::command::CadCommand;
let Some(state) = self.pdf_attach.as_ref() else {
return Task::none();
};
let number = |s: &str| s.trim().parse::<f64>().ok();
let insertion = if state.insert_on_screen {
None
} else {
match (number(&state.insert[0]), number(&state.insert[1]), number(&state.insert[2])) {
(Some(x), Some(y), Some(z)) => Some(glam::DVec3::new(x, y, z)),
_ => {
self.command_line.push_error("Requires numeric value.");
return Task::none();
}
}
};
let scale = if state.scale_on_screen {
None
} else {
match number(&state.scale).filter(|v| *v > 0.0) {
Some(v) => Some(v),
None => {
self.command_line.push_error("Value must be positive and nonzero.");
return Task::none();
}
}
};
let rotation = if state.rotation_on_screen {
None
} else {
match number(&state.rotation) {
Some(v) => Some(v),
None => {
self.command_line.push_error("Requires numeric value.");
return Task::none();
}
}
};
let state = self.pdf_attach.take().expect("checked above");
state.remember();
let stored = self.pdf_stored_path(&state.path, state.path_type.0);
if stored != state.path {
if let Some(bytes) = crate::scene::model::pdf_raster::source_bytes(&state.path) {
crate::scene::model::pdf_raster::register_source(&stored, bytes);
}
}
self.close_active_modal();
let insunits = self.tabs[i].scene.document.header.insertion_units;
let mut command = crate::modules::insert::pdf_attach::PdfAttachCommand::from_kind_dialog(
state.kind,
&state.path,
&stored,
&state.page_labels(),
scale,
rotation,
state.sub_units,
insunits,
);
match insertion {
Some(point) => {
let result = command.on_point(point);
self.tabs[i].active_cmd = Some(Box::new(command));
if matches!(result, crate::command::CmdResult::NeedPoint) {
self.command_line.push_info(
&self.tabs[i].active_cmd.as_ref().map(|c| c.prompt()).unwrap_or_default(),
);
return self.focus_cmd_input();
}
self.apply_cmd_result(result)
}
None => {
self.command_line.push_info(&command.prompt());
self.tabs[i].active_cmd = Some(Box::new(command));
self.focus_cmd_input()
}
}
}
}
/// "200 × 100 mm" for a page.
fn page_size_mm(path: &str, page: &str) -> String {
crate::scene::model::pdf_raster::page_size_inches(path, page)
.map(|(w, h)| format!("{:.0} × {:.0} mm", w * 25.4, h * 25.4))
.unwrap_or_default()
}
+264
View File
@@ -0,0 +1,264 @@
//! PDFIMPORT: adding the converted PDF content to the drawing as one undo
//! step, with the layers and text styles it uses, and what then happens to
//! the underlay (Keep / Detach / Unload).
use super::*;
use crate::modules::insert::pdf_import::{self, ImportArea, PdfImportRequest, UnderlayMode};
/// What an import reads: a placed underlay, or a file imported at the
/// origin at full size.
pub(crate) enum PdfImportSource {
Underlay(PdfImportRequest),
File(pdf_import::PdfFileImport),
}
impl OpenCADStudio {
pub(crate) fn run_pdf_import(&mut self, i: usize, source: PdfImportSource) {
use codec::objects::ObjectType;
let document = &self.tabs[i].scene.document;
let (underlay, path, page, area, mode, handle) = match &source {
PdfImportSource::Underlay(request) => {
let Some(codec::EntityType::Underlay(underlay)) =
document.get_entity(request.underlay)
else {
return;
};
let Some(def) = crate::entities::underlay::definition(underlay, document) else {
return;
};
if def.unloaded {
self.command_line
.push_error("Cannot bind a PDF underlay that is unloaded.");
return;
}
(
underlay.clone(),
def.file_path.clone(),
crate::entities::underlay::page_of(def).to_string(),
request.area.clone(),
Some(request.mode),
Some(request.underlay),
)
}
PdfImportSource::File(import) => (
import.placement(),
import.path.clone(),
import.page.clone(),
ImportArea::All,
None,
None,
),
};
let shown = crate::entities::underlay::display_path(&path);
match &source {
PdfImportSource::Underlay(_) => self
.command_line
.push_output(&format!("Binding PDF file {shown}, page {page} ...")),
PdfImportSource::File(_) => self
.command_line
.push_output(&format!("Importing page {page} of PDF file: {shown}...")),
}
// The underlay's own layer overrides leave those layers out.
let hidden = crate::scene::model::pdf_layers::hidden_layers(&underlay);
let Some(content) =
crate::scene::model::pdf_vector::page_content_by_layer(&path, &page, &hidden)
else {
self.command_line.push_error(&format!("{shown} not found."));
return;
};
let settings = pdf_import::import_settings();
let current_layer = {
let header = &self.tabs[i].scene.document.header;
if header.current_layer_name.is_empty() {
self.tabs[i].active_layer.clone()
} else {
header.current_layer_name.clone()
}
};
let naming = pdf_import::LayerNaming {
settings,
prefix: import_prefix(),
current: current_layer.clone(),
};
let mut result = pdf_import::convert(&content, &underlay, &area, &naming);
let images = std::mem::take(&mut result.images);
let image_dir = pdf_import::image_dir(&path);
self.push_undo_snapshot(i, "PDFIMPORT");
let scene = &mut self.tabs[i].scene;
if result.dashed && !scene.document.line_types.contains(pdf_import::DASH_LINETYPE) {
let mut lt = codec::tables::LineType::new(pdf_import::DASH_LINETYPE);
lt.handle = scene.document.allocate_handle();
lt.add_element(codec::tables::LineTypeElement::dash(0.8));
lt.add_element(codec::tables::LineTypeElement::space(0.2));
lt.pattern_length = 1.0;
let _ = scene.document.line_types.add(lt);
}
// Raster images: each written as a PNG in the PDFIMPORTIMAGEPATH
// folder, named after the PDF with 8 hex digits, then referenced.
let stem = std::path::Path::new(&path.replace('\\', "/"))
.file_stem()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| "PDF".to_string());
for (n, image) in images.iter().enumerate() {
let Some(dir) = &image_dir else { break };
if std::fs::create_dir_all(dir).is_err() {
break;
}
let file = dir.join(format!("{stem}{:08x}.png", image_file_id(&path, &page, n)));
if image::save_buffer(&file, &image.rgba, image.width, image.height, image::ColorType::Rgba8)
.is_err()
{
continue;
}
// Rebuilt from its components: one separator style, the platform's.
let stored = file
.components()
.collect::<std::path::PathBuf>()
.to_string_lossy()
.into_owned();
let mut raster = codec::entities::RasterImage::with_size(
&stored,
image.insertion,
image.width as f64,
image.height as f64,
1.0,
1.0,
);
raster.u_vector = image.u;
raster.v_vector = image.v;
raster.flags = codec::entities::ImageDisplayFlags::SHOW_IMAGE
| codec::entities::ImageDisplayFlags::SHOW_NOT_ALIGNED
| codec::entities::ImageDisplayFlags::USE_CLIPPING_BOUNDARY
| codec::entities::ImageDisplayFlags::TRANSPARENCY_ON;
raster.common.layer = image.layer.clone();
result.entities.push(codec::EntityType::RasterImage(raster));
}
for (name, color) in &result.layers {
if !scene.document.layers.contains(name) {
let mut layer = codec::tables::Layer::new(name.as_str());
layer.handle = scene.document.allocate_handle();
layer.color = *color;
let _ = scene.document.layers.add(layer);
}
}
for (name, font) in &result.text_styles {
if !scene.document.text_styles.contains(name) {
let mut style = codec::tables::TextStyle::new(name.as_str());
style.handle = scene.document.allocate_handle();
style.font_file = font.clone();
let _ = scene.document.text_styles.add(style);
}
}
if settings.as_block && !result.entities.is_empty() {
// One block named after the file, inserted at the origin on the
// current layer.
let mut name = stem.clone();
let mut n = 1;
while scene.document.block_records.get(&name).is_some() {
name = format!("{stem}{n}");
n += 1;
}
if scene
.define_block_from_owned_entities(result.entities, &name, glam::DVec3::ZERO)
.is_ok()
{
let mut insert = codec::entities::Insert::new(
name,
codec::types::Vector3::new(0.0, 0.0, 0.0),
);
insert.common.layer = current_layer;
scene.add_entity(codec::EntityType::Insert(insert));
}
} else {
for entity in result.entities {
scene.add_entity(entity);
}
}
if let (Some(mode), Some(handle)) = (mode, handle) {
match mode {
UnderlayMode::Keep => {}
UnderlayMode::Unload => {
if let Some(ObjectType::UnderlayDefinition(definition)) =
scene.document.objects.get_mut(&underlay.definition_handle)
{
definition.unloaded = true;
}
scene.reseed_underlays();
}
UnderlayMode::Detach => {
scene.erase_entities(&[handle]);
// Drop the definition once nothing references it.
let definition = underlay.definition_handle;
let referenced = scene.document.entities().any(|entity| {
matches!(entity, codec::EntityType::Underlay(other)
if other.definition_handle == definition)
});
if !referenced {
scene.document.objects.remove(&definition);
for object in scene.document.objects.values_mut() {
if let ObjectType::Dictionary(dictionary) = object {
dictionary.entries.retain(|(_, target)| *target != definition);
}
}
}
}
}
}
self.tabs[i].dirty = true;
self.refresh_layer_panel();
self.refresh_properties();
}
}
impl OpenCADStudio {
/// PDFATTACH's end: every page's definition (created or reused, with the
/// definitions dictionary) and underlay in one undo step.
pub(crate) fn attach_pdf_pages(
&mut self,
i: usize,
label: String,
kind: codec::entities::UnderlayType,
path: &str,
pages: Vec<(String, codec::EntityType)>,
) {
let pending = self.begin_undo(i, label, pages.len(), false);
for (page, mut entity) in pages {
let definition = crate::modules::insert::pdf_attach::ensure_underlay_definition(
&mut self.tabs[i].scene.document,
kind,
path,
&page,
);
if let codec::EntityType::Underlay(underlay) = &mut entity {
underlay.definition_handle = definition;
}
self.commit_entity_handle(entity);
}
self.tabs[i].dirty = true;
if let Some(pd) = pending {
self.commit_undo_delta(i, pd);
}
}
}
/// The layer prefix of an import: "PDF_" for the first import of the
/// session, "PDF2_", "PDF3_" … for each later one, of the same file or not.
// ponytail: counted for the session only; a reopened drawing starts from
// "PDF_" again.
fn import_prefix() -> String {
static IMPORTS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
match IMPORTS.fetch_add(1, std::sync::atomic::Ordering::Relaxed) {
0 => "PDF_".to_string(),
n => format!("PDF{}_", n + 1),
}
}
/// A file name suffix for an imported image, different per import.
fn image_file_id(path: &str, page: &str, n: usize) -> u32 {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
(path, page, n, std::time::SystemTime::now()).hash(&mut h);
h.finish() as u32
}
+425
View File
@@ -0,0 +1,425 @@
//! The right-edge underlay and xref tools: their state comes from the
//! selection, their buttons edit it.
use super::*;
use codec::entities::{Underlay, UnderlayDisplayFlags};
use crate::ui::ribbon::UnderlayContext;
impl OpenCADStudio {
/// The selected underlays when they are all of one kind, or nothing
/// when anything else (or another kind) is selected.
fn selected_pdf_underlays(&self, i: usize) -> Vec<(codec::Handle, Underlay)> {
let mut out: Vec<(codec::Handle, Underlay)> = Vec::new();
for (handle, entity) in self.tabs[i].scene.selected_entities() {
match entity {
codec::EntityType::Underlay(u)
if out.first().is_none_or(|(_, first)| first.underlay_type == u.underlay_type) =>
{
out.push((handle, u.clone()));
}
_ => return Vec::new(),
}
}
out
}
/// The selected xrefs, or nothing when anything else is selected.
pub(in crate::app) fn selected_xrefs(&self, i: usize) -> Vec<codec::Handle> {
let doc = &self.tabs[i].scene.document;
let mut out = Vec::new();
for (handle, entity) in self.tabs[i].scene.selected_entities() {
let xref = match entity {
codec::EntityType::Insert(ins) => doc
.block_records
.get(&ins.block_name)
.is_some_and(|br| br.flags.is_xref || br.flags.is_xref_overlay),
_ => false,
};
if !xref {
return Vec::new();
}
out.push(handle);
}
out
}
/// The selection context for the right-edge tools: the selected PDF
/// underlay's switches while only underlays are selected, or that only
/// xrefs are.
pub(in crate::app) fn sync_underlay_tab(&mut self) {
let i = self.active_tab;
let xref = !self.tabs[i].is_start && !self.selected_xrefs(i).is_empty();
let context = if self.tabs[i].is_start {
None
} else {
self.selected_pdf_underlays(i).first().map(|(_, u)| {
UnderlayContext {
kind: u.underlay_type,
monochrome: u.flags.contains(UnderlayDisplayFlags::MONOCHROME),
shown: u.flags.contains(UnderlayDisplayFlags::ON),
snap: crate::scene::model::pdf_vector::underlay_osnap(u.underlay_type),
}
})
};
self.ribbon.set_underlay_context(context, xref);
let clouds = if self.tabs[i].is_start { Vec::new() } else { self.selected_point_clouds(i) };
self.ribbon.set_point_cloud_context(clouds.first().map(|(_, data)| data.show_cropping));
}
/// The selected point clouds, or nothing when anything else is selected.
fn selected_point_clouds(&self, i: usize) -> Vec<(codec::Handle, codec::entities::PointCloudExData)> {
let mut out = Vec::new();
for (handle, entity) in self.tabs[i].scene.selected_entities() {
match entity {
codec::EntityType::Extended(extended) => match &extended.data {
codec::entities::ExtendedEntityData::PointCloudEx(data) => out.push((handle, data.clone())),
_ => return Vec::new(),
},
_ => return Vec::new(),
}
}
out
}
/// POINTCLOUDSTYLIZE's result: the clouds that carry the data take the
/// stylization (one undo step); the rest are named.
fn stylize_point_clouds(&mut self, i: usize, handles: &[codec::Handle], stylization: i16) {
let mut done = Vec::new();
for &handle in handles {
if self.tabs[i].scene.is_layer_locked(handle) {
continue;
}
let document = &self.tabs[i].scene.document;
let Some(codec::EntityType::Extended(extended)) = document.get_entity(handle) else {
continue;
};
let codec::entities::ExtendedEntityData::PointCloudEx(data) = &extended.data else {
continue;
};
let cloud = crate::scene::model::point_cloud::resolve_source(document, data)
.and_then(|path| crate::scene::model::point_cloud::load(&path));
let supported = match (stylization, &cloud) {
(6, _) => false,
(_, None) => true,
(1, Some(c)) => c.has_rgb,
(5, Some(c)) => c.has_intensity,
(3, Some(c)) => c.has_normals,
_ => true,
};
if supported {
done.push(handle);
} else {
self.command_line.push_info(&format!(
"The selected point cloud({}) does not support this stylization type.",
data.name
));
}
}
if !done.is_empty() {
self.push_undo_snapshot(i, "POINTCLOUDSTYLIZE");
for handle in &done {
if let Some(codec::EntityType::Extended(extended)) = self.tabs[i].scene.document.get_entity_mut(*handle) {
if let codec::entities::ExtendedEntityData::PointCloudEx(data) = &mut extended.data {
crate::entities::extended::set_point_cloud_stylization(data, stylization);
}
}
}
let changes: Vec<_> = done.iter().map(|h| (*h, crate::scene::ChangeKind::Modified)).collect();
self.tabs[i].scene.bump_entities(&changes);
self.tabs[i].dirty = true;
self.refresh_properties();
}
self.command_line.push_output(&format!("{} point cloud(s) stylized", done.len()));
}
/// One undo step that edits every selected point cloud not locked.
fn edit_selected_point_clouds(
&mut self,
i: usize,
label: &str,
edit: impl Fn(&mut codec::entities::PointCloudExData),
) {
let handles: Vec<_> = self
.selected_point_clouds(i)
.into_iter()
.map(|(h, _)| h)
.filter(|h| !self.tabs[i].scene.is_layer_locked(*h))
.collect();
if handles.is_empty() {
return;
}
self.push_undo_snapshot(i, label);
for handle in &handles {
if let Some(codec::EntityType::Extended(extended)) = self.tabs[i].scene.document.get_entity_mut(*handle) {
if let codec::entities::ExtendedEntityData::PointCloudEx(data) = &mut extended.data {
edit(data);
}
}
}
let changes: Vec<_> = handles.iter().map(|h| (*h, crate::scene::ChangeKind::Modified)).collect();
self.tabs[i].scene.bump_entities(&changes);
self.tabs[i].dirty = true;
self.refresh_properties();
}
/// A Point Cloud Manager click: a switch writes the cloud's hidden scans
/// and regions as one undo step; the rest is palette state.
pub(in crate::app) fn update_pc_manager(&mut self, message: crate::ui::window::pc_manager::PcManagerMsg) -> Task<Message> {
use crate::scene::model::point_cloud::UNASSIGNED_OFF;
use crate::ui::window::pc_manager::{self, PcManagerMsg, Row};
let i = self.active_tab;
match message {
PcManagerMsg::Toggle(handle, row) => {
let Some(cloud) =
pc_manager::clouds(&self.tabs[i].scene.document, &[]).into_iter().find(|c| c.handle == handle)
else {
return Task::none();
};
if self.tabs[i].scene.is_layer_locked(handle) {
return Task::none();
}
// A scan named by its file name (automation) is the scan with that name.
let row = match row {
Row::Scan(key) => Row::Scan(
cloud.scans.iter().find(|(name, _)| *name == key).map_or(key, |(_, id)| id.clone()),
),
row => row,
};
let hidden = pc_manager::toggled(&cloud.hidden, &cloud.scans, &row);
self.push_undo_snapshot(i, "POINTCLOUDMANAGER");
if let Some(codec::EntityType::Extended(extended)) = self.tabs[i].scene.document.get_entity_mut(handle) {
if let codec::entities::ExtendedEntityData::PointCloudEx(data) = &mut extended.data {
data.hidden_regions = if hidden.iter().any(|h| h == UNASSIGNED_OFF) { vec![0] } else { Vec::new() };
data.hidden_scans = hidden.into_iter().filter(|h| h != UNASSIGNED_OFF).collect();
}
}
self.tabs[i].scene.bump_entities(&[(handle, crate::scene::ChangeKind::Modified)]);
self.tabs[i].dirty = true;
}
PcManagerMsg::Expand(key) => {
if !self.pc_manager.collapsed.remove(&key) {
self.pc_manager.collapsed.insert(key);
}
}
PcManagerMsg::Select(key) => self.pc_manager.selected = Some(key),
PcManagerMsg::Search(text) => self.pc_manager.search = text,
PcManagerMsg::CollapseAll => {
let clouds = pc_manager::clouds(&self.tabs[i].scene.document, &[]);
self.pc_manager.collapsed.extend(pc_manager::folding_keys(&clouds));
}
PcManagerMsg::ExpandAll => self.pc_manager.collapsed.clear(),
}
Task::none()
}
/// One undo step that edits every selected PDF underlay.
fn edit_selected_underlays(&mut self, i: usize, label: &str, edit: impl Fn(&mut Underlay)) {
let handles: Vec<_> = self
.selected_pdf_underlays(i)
.into_iter()
.map(|(h, _)| h)
.filter(|h| !self.tabs[i].scene.is_layer_locked(*h))
.collect();
if handles.is_empty() {
return;
}
self.push_undo_snapshot(i, label);
for handle in &handles {
if let Some(codec::EntityType::Underlay(u)) =
self.tabs[i].scene.document.get_entity_mut(*handle)
{
edit(u);
}
self.tabs[i].scene.reseed_derived_caches(*handle);
}
let changes: Vec<_> = handles
.iter()
.map(|h| (*h, crate::scene::ChangeKind::Modified))
.collect();
self.tabs[i].scene.bump_entities(&changes);
self.tabs[i].dirty = true;
self.refresh_properties();
}
/// The right-edge tools (host-only command names).
pub(super) fn dispatch_pdf_underlay(&mut self, cmd: &str, i: usize) -> Option<Task<Message>> {
match cmd {
"_PDFULMONO" => {
let on = !self
.selected_pdf_underlays(i)
.first()
.is_some_and(|(_, u)| u.flags.contains(UnderlayDisplayFlags::MONOCHROME));
self.edit_selected_underlays(i, "PDFADJUST", |u| u.set_monochrome(on));
}
"_PDFULSHOW" => {
let on = !self
.selected_pdf_underlays(i)
.first()
.is_some_and(|(_, u)| u.flags.contains(UnderlayDisplayFlags::ON));
self.edit_selected_underlays(i, "PDFUNDERLAY", |u| u.set_on(on));
}
// Enable Snap switches PDFOSNAP, DWFOSNAP or DGNOSNAP.
"_PDFULSNAP" => {
let Some((_, underlay)) = self.selected_pdf_underlays(i).into_iter().next() else {
return Some(Task::none());
};
let kind = underlay.underlay_type;
let on = !crate::scene::model::pdf_vector::underlay_osnap(kind);
crate::scene::model::pdf_vector::set_underlay_osnap(kind, on);
self.tabs[i].scene.reseed_underlays();
self.sync_underlay_tab();
}
// External Reference tab: the selected xrefs.
"_XREFEDIT" | "_XREFOPEN" => {
let Some(&handle) = self.selected_xrefs(i).first() else {
return Some(Task::none());
};
self.tabs[i].scene.deselect_all();
self.tabs[i].scene.select_entity(handle, true);
let command = if cmd == "_XREFEDIT" { "REFEDIT" } else { "XOPEN" };
return Some(self.dispatch_command(command));
}
"_XREFCLIP" => {
use crate::command::CadCommand;
let inserts = self.selected_xrefs(i);
if inserts.is_empty() {
return Some(Task::none());
}
let clipped = inserts.iter().any(|h| {
crate::scene::pick::xclip::filter_handle(&self.tabs[i].scene.document, *h).is_some()
});
let (command, first) =
crate::modules::insert::xclip::XclipCommand::start_new_boundary(inserts, clipped);
if let crate::command::CmdResult::ReportMeasurement(text) = first {
for line in text.lines() {
self.command_line.push_output(line);
}
}
self.command_line.push_info(&command.prompt());
self.tabs[i].active_cmd = Some(Box::new(command));
}
"_XREFUNCLIP" => {
let inserts = self.selected_xrefs(i);
if !inserts.is_empty() {
self.apply_xclip(i, inserts, crate::modules::insert::xclip::XclipAction::Delete);
}
}
// Point cloud tools: a crop of the first selected cloud, or the
// crops of every selected one.
"_PCCROPRECT" | "_PCCROPPOLY" | "_PCCROPCIRC" => {
use crate::command::CadCommand;
let Some(&(handle, _)) = self.selected_point_clouds(i).first() else {
return Some(Task::none());
};
if self.tabs[i].scene.is_layer_locked(handle) {
return Some(Task::none());
}
let Some(codec::EntityType::Extended(cloud)) = self.tabs[i].scene.document.get_entity(handle).cloned() else {
return Some(Task::none());
};
let option = match cmd {
"_PCCROPPOLY" => "P",
"_PCCROPCIRC" => "C",
_ => "",
};
let command = crate::modules::insert::pc_crop::PointCloudCropCommand::for_cloud(handle, *cloud, option);
self.command_line.push_info(&command.prompt());
self.tabs[i].active_cmd = Some(Box::new(command));
}
"POINTCLOUDSTYLIZE" => {
use crate::command::CadCommand;
let command = crate::modules::draw::select::SelectObjectsCommand::with_prompt(
"POINTCLOUDSTYLIZE",
"_PCSTYLIZE",
"Select point cloud objects:",
);
self.command_line.push_info(&command.prompt());
self.tabs[i].active_cmd = Some(Box::new(command));
}
// The chosen objects' point clouds, then the option.
"_PCSTYLIZE" => {
use crate::command::CadCommand;
let handles: Vec<codec::Handle> = self.tabs[i]
.scene
.selected_entities()
.into_iter()
.filter(|(_, entity)| crate::scene::is_point_cloud(entity))
.map(|(h, _)| h)
.collect();
if handles.is_empty() {
return Some(self.finish_dispatch(cmd));
}
let command = crate::modules::insert::pc_stylize::PointCloudStylizeCommand::new(handles);
self.command_line.push_info(&command.prompt());
self.tabs[i].active_cmd = Some(Box::new(command));
}
c if c.starts_with("_PCSTYLIZEAPPLY ") => {
let mut parts = c.split_whitespace().skip(1);
let stylization: i16 = parts.next().and_then(|v| v.parse().ok()).unwrap_or(1);
let handles: Vec<codec::Handle> =
parts.filter_map(|h| u64::from_str_radix(h, 16).ok()).map(codec::Handle::new).collect();
self.stylize_point_clouds(i, &handles, stylization);
}
"_PCCROPSHOW" => {
let on = !self.selected_point_clouds(i).first().is_some_and(|(_, data)| data.show_cropping);
self.edit_selected_point_clouds(i, "POINTCLOUDCROP", |data| data.show_cropping = on);
}
"_PCCROPINVERT" => self.edit_selected_point_clouds(i, "POINTCLOUDCROP", |data| {
for crop in &mut data.croppings {
crop.inverted = !crop.inverted;
}
}),
// The Point Cloud Manager docks on the right like External
// References and opens expanded.
"POINTCLOUDMANAGER" => {
let id = crate::ui::dock::PanelId::PointCloudManager;
self.pc_manager.show = true;
if self.dock.location(id).is_none() {
self.dock.dock(id, crate::app::config::DockSide::Right, usize::MAX);
}
self.dock_expanded = Some(id);
}
"POINTCLOUDMANAGERCLOSE" => {
self.pc_manager.show = false;
if self.dock_expanded == Some(crate::ui::dock::PanelId::PointCloudManager) {
self.dock_expanded = None;
}
}
"_PCUNCROP" =>self.edit_selected_point_clouds(i, "POINTCLOUDUNCROP", |data| data.croppings.clear()),
"_PDFULUNCLIP" => {
self.edit_selected_underlays(i, "PDFCLIP", |u| {
u.clip_boundary_vertices.clear();
u.clip_inverted = false;
u.flags -= UnderlayDisplayFlags::CLIPPING;
});
}
"_PDFULCLIP" => {
use crate::command::CadCommand;
let Some((handle, underlay)) = self.selected_pdf_underlays(i).into_iter().next()
else {
return Some(Task::none());
};
let (command, first) =
crate::modules::insert::pdf_clip::PdfClipCommand::new_boundary(handle, underlay);
if let crate::command::CmdResult::ReportMeasurement(text) = first {
for line in text.lines() {
self.command_line.push_output(line);
}
}
self.command_line.push_info(&command.prompt());
self.tabs[i].active_cmd = Some(Box::new(command));
}
"_PDFULIMPORT" => {
use crate::command::CadCommand;
let Some((handle, _)) = self.selected_pdf_underlays(i).into_iter().next() else {
return Some(Task::none());
};
let command = crate::modules::insert::pdf_import::PdfImportCommand::for_underlay(handle);
self.command_line.push_info(&command.prompt());
self.tabs[i].active_cmd = Some(Box::new(command));
}
_ => return None,
}
Some(self.finish_dispatch(cmd))
}
}
+201
View File
@@ -0,0 +1,201 @@
//! POINTCLOUDATTACH: the point cloud, its definition (one per file, in
//! ACAD_POINTCLOUD_EX_DICT) and the reactor linking them, added as one undo
//! step.
use super::*;
use codec::objects::{
ClassObject, ClassObjectData, Dictionary, ObjectType, PointCloudDefinition,
PointCloudDefinitionReactor,
};
use codec::types::{Handle, Vector3};
use crate::modules::insert::pc_attach::PointCloudPlacement;
const DEFINITIONS: &str = "ACAD_POINTCLOUD_EX_DICT";
fn vector(v: glam::DVec3) -> Vector3 {
Vector3::new(v.x, v.y, v.z)
}
/// The point cloud definitions dictionary under the named-objects root,
/// created when the drawing has none.
fn definitions_dictionary(document: &mut codec::CadDocument) -> Option<Handle> {
let root = document.header.named_objects_dict_handle;
let Some(ObjectType::Dictionary(root_dictionary)) = document.objects.get(&root) else {
return None;
};
if let Some(existing) = root_dictionary.get(DEFINITIONS) {
if matches!(document.objects.get(&existing), Some(ObjectType::Dictionary(_))) {
return Some(existing);
}
}
let handle = document.allocate_handle();
let mut dictionary = Dictionary::new();
dictionary.handle = handle;
dictionary.owner = root;
document.objects.insert(handle, ObjectType::Dictionary(dictionary));
if let Some(ObjectType::Dictionary(root_dictionary)) = document.objects.get_mut(&root) {
root_dictionary.add_entry(DEFINITIONS, handle);
}
Some(handle)
}
/// The definition for a file: the existing one, or a new one registered
/// under the file's name ("Chair", "Chair(2)" …). Its point count and
/// extents come from the file.
fn ensure_definition(
document: &mut codec::CadDocument,
path: &str,
name: &str,
points: &crate::scene::model::point_cloud::CloudPoints,
) -> (Handle, Vector3, Vector3) {
let [lo, hi] = points.bounds.unwrap_or_default();
let (min, max) = (Vector3::new(lo[0], lo[1], lo[2]), Vector3::new(hi[0], hi[1], hi[2]));
let same = |stored: &str| stored.replace('/', "\\").eq_ignore_ascii_case(&path.replace('/', "\\"));
let existing = document.objects.iter().find_map(|(handle, object)| match object {
ObjectType::ClassObject(object) => match &object.data {
ClassObjectData::PointCloudDefinitionEx(def) if same(&def.source_filename) => Some(*handle),
_ => None,
},
_ => None,
});
if let Some(handle) = existing {
return (handle, min, max);
}
let handle = document.allocate_handle();
let mut definition = ClassObject::new(ClassObjectData::PointCloudDefinitionEx(PointCloudDefinition {
class_version: 1,
source_filename: path.to_string(),
is_loaded: true,
point_count: points.positions.len() as i64,
extents_min: min,
extents_max: max,
}));
definition.handle = handle;
if let Some(dictionary) = definitions_dictionary(document) {
definition.owner = dictionary;
definition.reactors.push(dictionary);
if let Some(ObjectType::Dictionary(entries)) = document.objects.get_mut(&dictionary) {
let mut key = name.to_string();
let mut suffix = 1;
while entries.get(&key).is_some() {
suffix += 1;
key = format!("{name}({suffix})");
}
entries.add_entry(key, handle);
}
}
document.objects.insert(handle, ObjectType::ClassObject(definition));
(handle, min, max)
}
impl OpenCADStudio {
pub(crate) fn attach_point_cloud(&mut self, i: usize, label: String, placement: PointCloudPlacement) {
let Some(points) = crate::scene::model::point_cloud::load(&placement.path) else {
self.command_line.push_error("All scans are not found or invalid.");
self.command_line.push_error("Attach point cloud failed");
return;
};
let path = placement.path.to_string_lossy().into_owned();
let options = placement.options.clone();
// The path stored: as chosen; relative to the drawing once it has a
// file (until then the full path, made relative on the first save).
let host = self.tabs[i].current_path.clone();
let stored = match &options.stored {
Some(stored) => stored.clone(),
None if options.relative => host
.as_deref()
.and_then(|host| {
crate::io::xref_model::to_pathtype_result(&path, host, crate::io::xref_model::Pathtype::Relative).ok()
})
.map(|relative| relative.replace('/', "\\"))
.unwrap_or_else(|| path.clone()),
None => path.clone(),
};
if stored != path {
crate::scene::model::point_cloud::register_source(&stored, placement.path.clone());
}
let relative_later = options.relative && options.stored.is_none() && host.is_none();
let name = placement
.path
.file_stem()
.map(|stem| stem.to_string_lossy().into_owned())
.unwrap_or_default();
let pending = self.begin_undo(i, label, 1, false);
let document = &mut self.tabs[i].scene.document;
// POINTCLOUDLOCK 1 attaches clouds locked.
let locked = options.locked.unwrap_or_else(|| {
crate::scene::model::point_cloud::setting("POINTCLOUDLOCK")
.is_some_and(|setting| crate::scene::model::point_cloud::setting_value(document, setting) == 1)
});
let (definition, extents_min, extents_max) = ensure_definition(document, &stored, &name, &points);
let [x, y, z] = placement.axes;
// As the reference creates a cloud: scan colours, the intensity
// range 0–100 as a gradient, crops shown.
let data = codec::entities::PointCloudExData {
class_version: 1,
extents_min,
extents_max,
ucs_origin: vector(placement.insertion),
ucs_x_direction: vector(x),
ucs_y_direction: vector(y),
ucs_z_direction: vector(z),
locked,
definition_handle: definition,
reactor_handle: Handle::NULL,
name,
show_intensity: false,
show_cropping: true,
hidden_scans: Vec::new(),
hidden_regions: Vec::new(),
stylization_type: 1,
intensity_color_scheme: String::new(),
current_color_scheme: String::new(),
classification_color_scheme: String::new(),
elevation_min: 0.0,
elevation_max: 0.0,
intensity_min: 0,
intensity_max: 100,
intensity_out_of_range_behavior: 1,
elevation_out_of_range_behavior: 1,
elevation_apply_to_fixed_range: false,
intensity_as_gradient: true,
elevation_as_gradient: false,
croppings: Vec::new(),
};
let entity = codec::EntityType::Extended(Box::new(codec::entities::ExtendedEntity {
common: codec::entities::EntityCommon::new(),
data: codec::entities::ExtendedEntityData::PointCloudEx(data),
}));
if let Some(cloud) = self.commit_entity_handle(entity) {
// The reactor, owned by the cloud, tells the definition who uses it.
let document = &mut self.tabs[i].scene.document;
let reactor = document.allocate_handle();
let mut object = ClassObject::new(ClassObjectData::PointCloudDefinitionReactorEx(
PointCloudDefinitionReactor { class_version: 1 },
));
object.handle = reactor;
object.owner = cloud;
document.objects.insert(reactor, ObjectType::ClassObject(object));
if let Some(ObjectType::ClassObject(definition)) = document.objects.get_mut(&definition) {
definition.reactors.push(reactor);
}
if let Some(codec::EntityType::Extended(extended)) = document.get_entity_mut(cloud) {
if let codec::entities::ExtendedEntityData::PointCloudEx(data) = &mut extended.data {
data.reactor_handle = reactor;
}
}
self.command_line.push_output("1 point cloud attached");
if relative_later {
self.tabs[i].xref_relative_on_save.insert(format!("{}{:X}", crate::io::xref::POINT_CLOUD_KEY, definition.value()));
}
if options.zoom {
self.tabs[i].scene.zoom_to_entities(&[cloud]);
}
}
self.tabs[i].dirty = true;
if let Some(pd) = pending {
self.commit_undo_delta(i, pd);
}
}
}
File diff suppressed because it is too large. Load diff
+486 -56
View File
@@ -3,6 +3,65 @@ use super::*;
impl OpenCADStudio {
pub(super) fn dispatch_styleprops(&mut self, cmd: &str, i: usize) -> Option<Task<Message>> {
match cmd {
// View / grid variables held by the app rather than the header,
// typed bare or through SETVAR (#1524, #1618).
cmd if matches!(
cmd.strip_prefix("SETVAR ").unwrap_or(cmd).split_whitespace().next(),
Some("PERSPECTIVE" | "GRIDMODE" | "GRIDUNIT")
) =>
{
let mut words = cmd.strip_prefix("SETVAR ").unwrap_or(cmd).splitn(2, ' ');
let name = words.next().unwrap_or_default().to_string();
let value = words.next().map(str::trim).filter(|v| !v.is_empty());
let current = match name.as_str() {
"PERSPECTIVE" => {
let persp = self.tabs[i].scene.camera.borrow().projection
== crate::scene::Projection::Perspective;
(persp as i32).to_string()
}
"GRIDMODE" => (self.show_grid as i32).to_string(),
_ => format!("{},{}", self.grid_spacing_x, self.grid_spacing_y),
};
let Some(value) = value else {
self.command_line
.push_output(&crate::tf!("Enter new value for {name} <{current}>:"));
self.pending_setvar = Some(name);
return Some(Task::none());
};
match (name.as_str(), value) {
("PERSPECTIVE", "0" | "1") => {
return Some(Task::done(Message::SetProjection(value == "0")));
}
("GRIDMODE", "0" | "1") => {
if (value == "1") != self.show_grid {
return Some(Task::done(Message::ToggleGrid));
}
}
("GRIDUNIT", _) => {
let mut parts = value.split(',').map(|p| p.trim().parse::<f32>());
let x = parts.next().and_then(Result::ok).filter(|v| *v > 0.0);
let y = match parts.next() {
Some(p) => p.ok().filter(|v| *v > 0.0),
None => x,
};
let (Some(x), Some(y)) = (x, y) else {
self.command_line.push_error(
crate::t!("Requires two positive values (x,y) or one for both.")
.as_ref(),
);
return Some(Task::none());
};
self.grid_spacing_x = x;
self.grid_spacing_y = y;
self.sync_vport_display(i);
self.persist_settings_if_changed();
}
_ => self
.command_line
.push_error(crate::t!("Requires 0 or 1").as_ref()),
}
return Some(Task::none());
}
"CETRANSPARENCY" => return self.dispatch_styleprops("SETVAR CETRANSPARENCY", i),
cmd if cmd.starts_with("CETRANSPARENCY ") => {
return self.dispatch_styleprops(&format!("SETVAR {cmd}"), i);
@@ -10,6 +69,10 @@ impl OpenCADStudio {
"FRAMES0" => return self.dispatch_styleprops("SETVAR FRAME 0", i),
"FRAMES1" => return self.dispatch_styleprops("SETVAR FRAME 1", i),
"FRAMES2" => return self.dispatch_styleprops("SETVAR FRAME 2", i),
// The mixed state is shown, never chosen.
"FRAMES3" => return Some(Task::none()),
"UOSNAP0" => return self.dispatch_styleprops("SETVAR UOSNAP 0", i),
"UOSNAP1" => return self.dispatch_styleprops("SETVAR UOSNAP 1", i),
// COLOR <ByLayer|ByBlock|1-255|name> — the colour applied to new
// objects (CECOLOR). Bare COLOR reports the current value.
"COLOR" | "COLOUR" | "CECOLOR" | "DDCOLOR" => {
@@ -29,7 +92,7 @@ impl OpenCADStudio {
|| cmd.starts_with("CECOLOR ")
|| cmd.starts_with("DDCOLOR ") =>
{
use acadrust::types::Color;
use codec::types::Color;
let describe = |c: &Color| match c {
Color::ByLayer => "ByLayer".to_string(),
Color::ByBlock => "ByBlock".to_string(),
@@ -80,9 +143,10 @@ impl OpenCADStudio {
use crate::command::KeywordCommand;
let c = KeywordCommand::new(
"LINETYPE",
"LINETYPE [List / Set]:",
"LINETYPE [List / Set / Load]:",
vec![
("List", "LIST", None),
("Load", "LOAD", None),
("Set", "SET", Some("LINETYPE SET linetype name (ByLayer / ByBlock / …):")),
],
);
@@ -94,6 +158,13 @@ impl OpenCADStudio {
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() {
// Load the definitions of a `.lin` file (#1588).
"LOAD" | "L" => {
return Some(Task::perform(
crate::io::pick_linetype_file(),
Message::LinetypeLoaded,
));
}
"" | "LIST" | "?" => {
let ltypes: Vec<String> = self.tabs[i]
.scene
@@ -117,9 +188,9 @@ impl OpenCADStudio {
.push_info(crate::t!("Usage: LINETYPE SET <name | ByLayer | ByBlock>").as_ref());
} else {
let canon = if name.eq_ignore_ascii_case("BYLAYER") {
Some(("ByLayer".to_string(), acadrust::types::Handle::NULL))
Some(("ByLayer".to_string(), codec::types::Handle::NULL))
} else if name.eq_ignore_ascii_case("BYBLOCK") {
Some(("ByBlock".to_string(), acadrust::types::Handle::NULL))
Some(("ByBlock".to_string(), codec::types::Handle::NULL))
} else {
self.tabs[i]
.scene
@@ -220,12 +291,12 @@ impl OpenCADStudio {
.document
.entities()
.filter_map(|e| match e {
acadrust::EntityType::Text(t) => Some(t.style.clone()),
acadrust::EntityType::MText(t) => Some(t.style.clone()),
acadrust::EntityType::AttributeDefinition(a) => {
codec::EntityType::Text(t) => Some(t.style.clone()),
codec::EntityType::MText(t) => Some(t.style.clone()),
codec::EntityType::AttributeDefinition(a) => {
Some(a.text_style.clone())
}
acadrust::EntityType::AttributeEntity(a) => {
codec::EntityType::AttributeEntity(a) => {
Some(a.text_style.clone())
}
_ => None,
@@ -269,13 +340,13 @@ impl OpenCADStudio {
// through the shared graph. Dangling layouts do not count.
let live_blocks: rustc_hash::FxHashSet<String> = {
let doc = &self.tabs[i].scene.document;
let is_real_layout = |br: &acadrust::BlockRecord| -> bool {
let is_real_layout = |br: &codec::BlockRecord| -> bool {
let up = br.name.to_ascii_uppercase();
up.starts_with("*MODEL_SPACE")
|| up.starts_with("*PAPER_SPACE")
|| matches!(
doc.objects.get(&br.layout),
Some(acadrust::objects::ObjectType::Layout(_))
Some(codec::objects::ObjectType::Layout(_))
)
};
let children = |name: &str| -> Vec<String> {
@@ -446,20 +517,20 @@ impl OpenCADStudio {
// Remove draw-order tables whose owning blocks are gone.
let mut n_sortents = 0usize;
if do_blocks {
let live_blocks: rustc_hash::FxHashSet<acadrust::Handle> = self.tabs[i]
let live_blocks: rustc_hash::FxHashSet<codec::Handle> = self.tabs[i]
.scene
.document
.block_records
.iter()
.map(|br| br.handle)
.collect();
let orphans: Vec<acadrust::Handle> = self.tabs[i]
let orphans: Vec<codec::Handle> = self.tabs[i]
.scene
.document
.objects
.iter()
.filter_map(|(h, o)| match o {
acadrust::objects::ObjectType::SortEntitiesTable(s)
codec::objects::ObjectType::SortEntitiesTable(s)
if !live_blocks.contains(&s.block_owner_handle) =>
{
Some(*h)
@@ -558,11 +629,11 @@ impl OpenCADStudio {
.push_error(crate::t!("CHPROP: no entities selected.").as_ref());
} else {
// Validate value early to give clear errors
let color_val: Option<acadrust::types::Color> = if prop == "COLOR" {
let color_val: Option<codec::types::Color> = if prop == "COLOR" {
value
.parse::<i16>()
.ok()
.map(acadrust::types::Color::from_index)
.map(codec::types::Color::from_index)
} else {
None
};
@@ -571,12 +642,12 @@ impl OpenCADStudio {
} else {
None
};
let transparency_val: Option<acadrust::types::Transparency> =
let transparency_val: Option<codec::types::Transparency> =
if prop == "TRANSPARENCY" {
value
.parse::<f64>()
.ok()
.map(acadrust::types::Transparency::from_percent)
.map(codec::types::Transparency::from_percent)
} else {
None
};
@@ -686,7 +757,7 @@ impl OpenCADStudio {
let mut index = 0;
while index < handles.len() {
let children = match self.tabs[i].scene.document.get_entity(handles[index]) {
Some(acadrust::EntityType::Insert(insert)) => self.tabs[i].scene.document
Some(codec::EntityType::Insert(insert)) => self.tabs[i].scene.document
.block_records.get(&insert.block_name)
.map(|block| block.entity_handles.clone()).unwrap_or_default(),
_ => Vec::new(),
@@ -783,60 +854,60 @@ impl OpenCADStudio {
removed.insert(candidates[b].0);progress=true;continue;
}
if overlap {
let contained = |circle: &acadrust::entities::Circle, arc: &acadrust::entities::Arc| {
let contained = |circle: &codec::entities::Circle, arc: &codec::entities::Arc| {
circle.common == arc.common && circle.thickness == arc.thickness
&& cadkernel::space::arc_union::circle_contains_arc(
&& kernel::space::arc_union::circle_contains_arc(
[circle.center.x,circle.center.y,circle.center.z],
[circle.normal.x,circle.normal.y,circle.normal.z],circle.radius,
cadkernel::space::arc_union::CircularArc { center:[arc.center.x,arc.center.y,arc.center.z],
kernel::space::arc_union::CircularArc { center:[arc.center.x,arc.center.y,arc.center.z],
normal:[arc.normal.x,arc.normal.y,arc.normal.z],radius:arc.radius,start:arc.start_angle,end:arc.end_angle })
};
match (&left,&right) {
(acadrust::EntityType::Circle(circle),acadrust::EntityType::Arc(arc)) if contained(circle,arc) => {
(codec::EntityType::Circle(circle),codec::EntityType::Arc(arc)) if contained(circle,arc) => {
removed.insert(candidates[b].0);progress=true;continue;
}
(acadrust::EntityType::Arc(arc),acadrust::EntityType::Circle(circle)) if contained(circle,arc) => {
(codec::EntityType::Arc(arc),codec::EntityType::Circle(circle)) if contained(circle,arc) => {
removed.insert(candidates[a].0);progress=true;break;
}
_ => {},
}
}
if let (acadrust::EntityType::Arc(l),acadrust::EntityType::Arc(r))=(&left,&right) {
use cadkernel::space::arc_union::{CircularArc,ArcUnionKind,circular_arc_union};
if let (codec::EntityType::Arc(l),codec::EntityType::Arc(r))=(&left,&right) {
use kernel::space::arc_union::{CircularArc,ArcUnionKind,circular_arc_union};
if l.common!=r.common || l.thickness!=r.thickness {continue;}
let arc=|v:&acadrust::entities::Arc|CircularArc{center:[v.center.x,v.center.y,v.center.z],normal:[v.normal.x,v.normal.y,v.normal.z],radius:v.radius,start:v.start_angle,end:v.end_angle};
let arc=|v:&codec::entities::Arc|CircularArc{center:[v.center.x,v.center.y,v.center.z],normal:[v.normal.x,v.normal.y,v.normal.z],radius:v.radius,start:v.start_angle,end:v.end_angle};
let Some(union)=circular_arc_union(arc(l),arc(r),tolerance) else {continue;};
let allowed=match union.kind {ArcUnionKind::Duplicate=>true,ArcUnionKind::Overlap=>overlap,ArcUnionKind::EndToEnd=>end_to_end};
if !allowed {continue;}
if let acadrust::EntityType::Arc(source)=&candidates[a].1 {
if let codec::EntityType::Arc(source)=&candidates[a].1 {
let replacement=if union.full_circle {
let mut circle=acadrust::entities::Circle::new();
let mut circle=codec::entities::Circle::new();
circle.common=source.common.clone();circle.center=source.center.clone();circle.normal=source.normal.clone();circle.radius=source.radius;circle.thickness=source.thickness;
acadrust::EntityType::Circle(circle)
codec::EntityType::Circle(circle)
} else {
let mut arc=source.clone();arc.start_angle=union.start;arc.end_angle=union.end;acadrust::EntityType::Arc(arc)
let mut arc=source.clone();arc.start_angle=union.start;arc.end_angle=union.end;codec::EntityType::Arc(arc)
};
candidates[a].1=replacement;
}
changed.insert(candidates[a].0);removed.insert(candidates[b].0);progress=true;
continue;
}
let (acadrust::EntityType::Line(l),acadrust::EntityType::Line(r))=(&left,&right)
let (codec::EntityType::Line(l),codec::EntityType::Line(r))=(&left,&right)
else {continue;};
if l.common!=r.common||l.thickness!=r.thickness||l.normal!=r.normal {continue;}
let point=|p:acadrust::types::Vector3|[p.x,p.y,p.z];
let Some(union)=cadkernel::space::line_union(
let point=|p:codec::types::Vector3|[p.x,p.y,p.z];
let Some(union)=kernel::space::line_union(
[point(l.start),point(l.end)],[point(r.start),point(r.end)],tolerance)
else {continue;};
let allowed=match union.kind {
cadkernel::space::LineUnionKind::Duplicate=>true,
cadkernel::space::LineUnionKind::Overlap=>overlap,
cadkernel::space::LineUnionKind::EndToEnd=>end_to_end,
kernel::space::LineUnionKind::Duplicate=>true,
kernel::space::LineUnionKind::Overlap=>overlap,
kernel::space::LineUnionKind::EndToEnd=>end_to_end,
};
if !allowed {continue;}
if let acadrust::EntityType::Line(line)=&mut candidates[a].1 {
line.start=acadrust::types::Vector3::new(union.start[0],union.start[1],union.start[2]);
line.end=acadrust::types::Vector3::new(union.end[0],union.end[1],union.end[2]);
if let codec::EntityType::Line(line)=&mut candidates[a].1 {
line.start=codec::types::Vector3::new(union.start[0],union.start[1],union.start[2]);
line.end=codec::types::Vector3::new(union.end[0],union.end[1],union.end[2]);
}
changed.insert(candidates[a].0);removed.insert(candidates[b].0);progress=true;
}
@@ -1015,7 +1086,34 @@ impl OpenCADStudio {
| "FRAME"
| "IMAGEFRAME"
| "PDFFRAME"
| "DWFFRAME"
| "DGNFRAME"
| "PDFOSNAP"
| "DWFOSNAP"
| "DGNOSNAP"
| "UOSNAP"
| "FIELDDISPLAY"
| "PDFIMPORTMODE"
| "PDFIMPORTFILTER"
| "PDFIMPORTLAYERS"
| "PDFIMPORTIMAGEPATH"
| "XDWGFADECTL"
| "POINTCLOUDCLIPFRAME"
| "POINTCLOUDDENSITY"
| "POINTCLOUDPOINTSIZE"
| "POINTCLOUDLOCK"
| "POINTCLOUDAUTOUPDATE"
| "POINTCLOUDBOUNDARY"
| "POINTCLOUDRTDENSITY"
| "POINTCLOUDLOD"
| "POINTCLOUDPOINTMAX"
| "POINTCLOUDVISRETAIN"
| "POINTCLOUDSHADING"
| "POINTCLOUDCACHESIZE"
| "POINTCLOUD2DVSDISPLAY"
| "POINTCLOUDLIGHTING"
| "POINTCLOUDLIGHTSOURCE"
| "POINTCLOUDPOINTMAXLEGACY"
| "XCLIPFRAME"
| "WIPEOUTFRAME"
| "HALOGAP"
@@ -1055,6 +1153,7 @@ impl OpenCADStudio {
| "CONSTRAINTNAMEFORMAT"
| "DYNCONSTRAINTDISPLAY"
| "CCONSTRAINTFORM"
| "DYNMODE"
) =>
{
return self.dispatch_styleprops(&format!("SETVAR {cmd}"), i);
@@ -1077,10 +1176,57 @@ impl OpenCADStudio {
let value = it.next().map(|s| s.trim().to_string());
if name.is_empty() || name == "?" {
self.command_line.push_info(&super::plotvars::setvar_listing());
self.command_line.push_info(&super::blockvars::setvar_listing());
self.command_line.push_info(
crate::t!("SETVAR: CETRANSPARENCY LTSCALE CELTSCALE PDMODE PDSIZE TEXTSIZE ORTHOMODE FILLMODE MIRRTEXT FRAME IMAGEFRAME PDFFRAME WIPEOUTFRAME XCLIPFRAME POINTCLOUDCLIPFRAME ZOOMWHEEL ZOOMFACTOR SHORTCUTMENU SHORTCUTMENUDURATION CURSORSIZE PICKBOX CURSORTYPE SNAPANG TEXTFILL CLIPROMPTLINES COMMANDLINEFADETIME ATTREQ ATTDIA DIMASSOC DIMCONTINUEMODE CONSTRAINTSOLVEMODE CONSTRAINTINFER CONSTRAINTBARDISPLAY CONSTRAINTBARMODE CONSTRAINTNAMEFORMAT DYNCONSTRAINTDISPLAY ANGBASE ANGDIR SKETCHINC SKPOLY SKTOLERANCE DONUTID DONUTOD CENTEREXE CENTERLAYER CENTERLTYPE CENTERLTSCALE CENTERLTYPEFILE CENTERCROSSSIZE CENTERCROSSGAP CENTERMARKEXE COLORTHEME SELECTIONAREA SELECTIONAREAOPACITY SELECTIONEFFECT SELECTIONEFFECTCOLOR WINDOWSAREACOLOR CROSSINGAREACOLOR SELECTIONPREVIEW GRIPSIZE GRIPCOLOR GRIPHOT GRIPHOVER GRIPOBJLIMIT | CLAYER CELTYPE TEXTSTYLE (read-only)").as_ref(),
crate::t!("SETVAR: CETRANSPARENCY LTSCALE CELTSCALE PDMODE PDSIZE TEXTSIZE ORTHOMODE FILLMODE MIRRTEXT FRAME IMAGEFRAME PDFFRAME WIPEOUTFRAME XCLIPFRAME POINTCLOUDCLIPFRAME ZOOMWHEEL ZOOMFACTOR SHORTCUTMENU SHORTCUTMENUDURATION CURSORSIZE PICKBOX CURSORTYPE SNAPANG TEXTFILL CLIPROMPTLINES COMMANDLINEFADETIME ATTREQ ATTDIA DIMASSOC DIMCONTINUEMODE CONSTRAINTSOLVEMODE CONSTRAINTINFER CONSTRAINTBARDISPLAY CONSTRAINTBARMODE CONSTRAINTNAMEFORMAT DYNCONSTRAINTDISPLAY DYNMODE ANGBASE ANGDIR SKETCHINC SKPOLY SKTOLERANCE DONUTID DONUTOD CENTEREXE CENTERLAYER CENTERLTYPE CENTERLTSCALE CENTERLTYPEFILE CENTERCROSSSIZE CENTERCROSSGAP CENTERMARKEXE COLORTHEME SELECTIONAREA SELECTIONAREAOPACITY SELECTIONEFFECT SELECTIONEFFECTCOLOR WINDOWSAREACOLOR CROSSINGAREACOLOR SELECTIONPREVIEW GRIPSIZE GRIPCOLOR GRIPHOT GRIPHOVER GRIPOBJLIMIT | CLAYER CELTYPE TEXTSTYLE (read-only)").as_ref(),
);
} else {
// Point cloud settings, kept with the drawing.
if let Some(setting) = crate::scene::model::point_cloud::setting(&name) {
let document = &self.tabs[i].scene.document;
let current = crate::scene::model::point_cloud::setting_value(document, setting);
match value.as_deref().map(|value| setting.check(value)) {
Some(Ok(new)) => {
if new != current {
crate::io::set_drawing_variable(
&mut self.tabs[i].scene.document,
setting.name,
&new.to_string(),
);
self.tabs[i].scene.bump_geometry();
self.tabs[i].dirty = true;
}
}
// Refused: say why, and ask again unless the value ends it.
Some(Err(refusal)) => {
for line in &refusal.lines {
self.command_line.push_error(line);
}
if refusal.ask_again {
self.command_line.push_output(&format!("Enter new value for {name} <{current}>:"));
self.pending_setvar = Some(name.clone());
}
}
None => {
self.command_line.push_output(&format!("Enter new value for {name} <{current}>:"));
self.pending_setvar = Some(name.clone());
}
}
return Some(self.finish_dispatch(cmd));
}
if name == "XDWGFADECTL" {
let current = crate::scene::cache::block_cache::xref_fade_ctl();
if let Some(value) = &value {
match value.parse::<i32>().ok().filter(|v| (-90..=90).contains(v)) {
Some(fade) => self.set_xref_fade(fade),
None => self.command_line.push_error("Requires an integer between -90 and 90."),
}
} else {
self.command_line.push_output(&format!("Enter new value for XDWGFADECTL <{current}>:"));
self.pending_setvar = Some(name.clone());
}
return Some(self.finish_dispatch(cmd));
}
if name == "CETRANSPARENCY" {
let current = self.tabs[i].scene.document.current_entity_transparency();
if let Some(value) = &value {
@@ -1106,6 +1252,120 @@ impl OpenCADStudio {
}
return Some(self.finish_dispatch(cmd));
}
// Snapping to the geometry inside underlays (one switch for
// PDF underlays and underlays in general).
// The PDF Import Settings' options as bits.
if matches!(name.as_str(), "PDFIMPORTMODE" | "PDFIMPORTFILTER" | "PDFIMPORTLAYERS") {
use crate::modules::insert::pdf_import::{import_settings, set_import_settings, ImportLayers};
let mut settings = import_settings();
let (current, max) = match name.as_str() {
"PDFIMPORTMODE" => (settings.mode(), 31),
"PDFIMPORTFILTER" => (settings.filter(), 15),
_ => (settings.layers_value(), 2),
};
if let Some(value) = &value {
match value.parse::<i16>().ok().filter(|v| (0..=max).contains(v)) {
Some(v) => {
match name.as_str() {
"PDFIMPORTMODE" => settings.set_mode(v),
"PDFIMPORTFILTER" => settings.set_filter(v),
_ => {
settings.layers = [ImportLayers::Pdf, ImportLayers::Object, ImportLayers::Current][v as usize]
}
}
set_import_settings(settings);
}
None => self.command_line.push_error(&format!("Requires an integer between 0 and {max}.")),
}
} else {
self.command_line.push_output(&format!("Enter new value for {name} <{current}>:"));
self.pending_setvar = Some(name.clone());
}
return Some(self.finish_dispatch(cmd));
}
// A folder name; "." clears it.
if name == "PDFIMPORTIMAGEPATH" {
use crate::modules::insert::pdf_import::{image_path, set_image_path};
match &value {
Some(value) => {
let value = value.trim().trim_matches('"');
set_image_path(if value == "." { String::new() } else { value.to_string() });
}
None => {
self.command_line.push_output(&format!(
"Enter new value for PDFIMPORTIMAGEPATH, or . for none <\"{}\">:",
image_path()
));
self.pending_setvar = Some(name.clone());
}
}
return Some(self.finish_dispatch(cmd));
}
if name == "FIELDDISPLAY" {
let current = i16::from(crate::entities::field::display());
match value.as_deref().map(|v| v.trim().parse::<i16>().ok().filter(|v| (0..=1).contains(v))) {
Some(Some(mode)) => {
if current != mode {
crate::entities::field::set_display(mode == 1);
// A profile setting: redraw the fields of every open drawing.
for tab in &mut self.tabs {
let changes: Vec<_> = tab
.scene
.document
.entities()
.filter(|e| crate::entities::field::hosts_field(&tab.scene.document, e))
.map(|e| (e.common().handle, crate::scene::ChangeKind::Modified))
.collect();
if !changes.is_empty() {
tab.scene.bump_entities(&changes);
}
}
self.save_config();
}
}
Some(None) => {
self.command_line.push_error(crate::t!("Requires 0 or 1 only.").as_ref());
self.command_line.push_output(&format!("Enter new value for FIELDDISPLAY <{current}>:"));
self.pending_setvar = Some(name.clone());
}
None => {
self.command_line.push_output(&format!("Enter new value for FIELDDISPLAY <{current}>:"));
self.pending_setvar = Some(name.clone());
}
}
return Some(self.finish_dispatch(cmd));
}
if matches!(name.as_str(), "PDFOSNAP" | "DWFOSNAP" | "DGNOSNAP" | "UOSNAP") {
use crate::scene::model::pdf_vector as pv;
use codec::entities::UnderlayType;
// UOSNAP sets every kind and reads 2 while they differ.
let kind = match name.as_str() {
"PDFOSNAP" => Some(UnderlayType::Pdf),
"DWFOSNAP" => Some(UnderlayType::Dwf),
"DGNOSNAP" => Some(UnderlayType::Dgn),
_ => None,
};
let current = kind.map_or_else(pv::uosnap, |kind| i16::from(pv::underlay_osnap(kind)));
if let Some(value) = &value {
match value.parse::<i16>().ok().filter(|value| (0..=1).contains(value)) {
Some(mode) => {
if current != mode {
match kind {
Some(kind) => pv::set_underlay_osnap(kind, mode == 1),
None => pv::set_uosnap(mode == 1),
}
self.tabs[i].scene.reseed_underlays();
self.sync_underlay_tab();
}
}
None => self.command_line.push_error("Requires 0 or 1 only"),
}
} else {
self.command_line.push_output(&format!("Enter new value for {name} <{current}>:"));
self.pending_setvar = Some(name.clone());
}
return Some(self.finish_dispatch(cmd));
}
if matches!(name.as_str(), "SHOWHIST" | "SOLIDHIST") {
let current = if name == "SHOWHIST" {
self.tabs[i].scene.document.header.show_solid_history.clamp(0, 2)
@@ -1194,13 +1454,23 @@ impl OpenCADStudio {
self.tabs[i].scene.bump_entities(&changes);
}
self.tabs[i].dirty = true;
// IMAGEFRAME also lands in the profile.
self.save_config();
}
self.command_line
.push_output(&crate::tf!("{name} = {mode}"));
.push_output(crate::t!("Regenerating model.").as_ref());
}
// Out of range: say so and ask again.
_ => {
self.command_line.push_error(
crate::t!("Requires an integer between 0 and 2.").as_ref(),
);
self.command_line.push_output(
crate::tf!("Enter new value for {name} <{current}>:")
.as_ref(),
);
self.pending_setvar = Some(name.clone());
}
_ => self.command_line.push_error(
crate::tf!("SETVAR: {name} requires 0, 1, or 2.").as_ref(),
),
},
None => {
self.command_line.push_output(crate::tf!(
@@ -1501,6 +1771,17 @@ impl OpenCADStudio {
.ok_or_else(|| "SETVAR: 0 or 1 required.".into()),
None => Ok((format!("MIRRTEXT = {}", h.mirror_text as i32), false)),
},
"DYNMODE" => match &value {
Some(v) => match v.parse::<i16>() {
Ok(mode) if (-3..=3).contains(&mode) => {
self.dyn_mode = mode;
self.dyn_input = mode > 0;
Ok((format!("DYNMODE = {mode}"), true))
}
_ => Err("SETVAR: integer from -3 to 3 required.".into()),
},
None => Ok((format!("DYNMODE = {}", self.dyn_mode), false)),
},
"ZOOMWHEEL" => match &value {
Some(v) => match parse_bool(v) {
Some(reversed) => {
@@ -1520,13 +1801,23 @@ impl OpenCADStudio {
false,
)),
},
// The system variable keeps its own range. The
// Options window reaches further
// (`settings::ZOOM_FACTOR_MAX`) and may have
// stored a value above this, which a bare
// `SETVAR ZOOMFACTOR` still reports; only setting
// one is held to the range of the variable.
"ZOOMFACTOR" => match &value {
Some(v) => match v.parse::<i32>() {
Ok(factor) if (3..=100).contains(&factor) => {
Ok(factor)
if (crate::app::settings::ZOOM_FACTOR_MIN
..=crate::app::settings::ZOOM_FACTOR_MAX)
.contains(&factor) =>
{
self.zoom_factor = factor;
Ok((format!("ZOOMFACTOR = {factor}"), true))
}
_ => Err("SETVAR: integer from 3 to 100 required.".into()),
_ => Err("SETVAR: integer from 3 to 500 required.".into()),
},
None => {
Ok((format!("ZOOMFACTOR = {}", self.zoom_factor), false))
@@ -2031,6 +2322,7 @@ impl OpenCADStudio {
Some(v) => v
.parse::<i16>()
.map(|x| {
let x = x.clamp(0, 8);
h.linear_unit_precision = x;
(format!("LUPREC = {x}"), true)
})
@@ -2053,6 +2345,7 @@ impl OpenCADStudio {
Some(v) => v
.parse::<i16>()
.map(|x| {
let x = x.clamp(0, 8);
h.angular_unit_precision = x;
(format!("AUPREC = {x}"), true)
})
@@ -2105,6 +2398,14 @@ impl OpenCADStudio {
Some(v) => v
.parse::<i16>()
.map(|x| {
// Stored values stay inside the range
// the Options slider can ask for; the
// consumption sites clamp again for
// file-sourced headers.
let x = x.clamp(
0,
crate::entities::solid3d::MAX_HEADER_ISOLINES,
);
h.isolines = x;
(format!("ISOLINES = {x}"), true)
})
@@ -2463,6 +2764,7 @@ impl OpenCADStudio {
// Without this it marked the drawing
// modified instead of persisting.
| "TEXTFILL"
| "DYNMODE"
) {
self.persist_settings_if_changed();
} else {
@@ -2555,13 +2857,13 @@ impl OpenCADStudio {
linetypes.insert(lt.clone());
}
match e {
acadrust::EntityType::Text(t) if !t.style.is_empty() => {
codec::EntityType::Text(t) if !t.style.is_empty() => {
styles.insert(t.style.clone());
}
acadrust::EntityType::MText(t) if !t.style.is_empty() => {
codec::EntityType::MText(t) if !t.style.is_empty() => {
styles.insert(t.style.clone());
}
acadrust::EntityType::Insert(ins) => {
codec::EntityType::Insert(ins) => {
blocks.insert(ins.block_name.clone());
}
_ => {}
@@ -2606,7 +2908,7 @@ impl OpenCADStudio {
if !layer.is_empty() && doc.layers.get(layer).is_none() {
undefined_layers.insert(layer.clone());
}
if let acadrust::EntityType::Insert(ins) = e {
if let codec::EntityType::Insert(ins) = e {
if doc.block_records.get(&ins.block_name).is_none() {
undefined_blocks.insert(ins.block_name.clone());
}
@@ -2695,6 +2997,7 @@ impl OpenCADStudio {
self.tabs[i].scene.bump_geometry_no_blocks();
}
if type_str == "UCS" {
self.tabs[i].scene.bump_ucs_epoch();
if let Some(active) = self.tabs[i].active_ucs.as_mut() {
if active.name.eq_ignore_ascii_case(&old_name) {
active.name = new_name.clone();
@@ -3012,7 +3315,7 @@ impl OpenCADStudio {
);
}
}
cmd if cmd == "DDPTYPE" => {
cmd if cmd == "DDPTYPE" || cmd == "PTYPE" => {
// 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.
@@ -3108,7 +3411,7 @@ impl OpenCADStudio {
cmd if 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]
let selected_handles: Vec<codec::Handle> = self.tabs[i]
.scene
.selected_entities()
.iter()
@@ -3140,12 +3443,12 @@ impl OpenCADStudio {
continue;
}
match entity {
acadrust::EntityType::Text(t) => {
codec::EntityType::Text(t) => {
t.height =
if use_absolute { val } else { t.height * val };
count += 1;
}
acadrust::EntityType::MText(t) => {
codec::EntityType::MText(t) => {
t.height =
if use_absolute { val } else { t.height * val };
count += 1;
@@ -3179,8 +3482,8 @@ impl OpenCADStudio {
}
/// RENAME for the remaining name-keyed symbol tables.
fn rename_symbol(doc: &mut acadrust::CadDocument, ty: &str, old: &str, new: &str) -> bool {
use acadrust::{EntityType, Table, TableEntry};
fn rename_symbol(doc: &mut codec::CadDocument, ty: &str, old: &str, new: &str) -> bool {
use codec::{EntityType, Table, TableEntry};
fn rekey<T: TableEntry>(table: &mut Table<T>, old: &str, new: &str) -> bool {
if !crate::scene::valid_block_name(new) {
@@ -3298,6 +3601,73 @@ mod tests {
app
}
/// `SETVAR ZOOMFACTOR` is the system variable, so it keeps the range
/// the variable has — the Options window is where a wheel faster than
/// that is set, and a value set there is still what the variable
/// reports.
#[test]
fn setvar_zoomfactor_keeps_the_range_of_the_system_variable() {
let mut app = fresh_app();
let _ = app.run_command_line("SETVAR ZOOMFACTOR 500");
assert_eq!(app.zoom_factor, 500);
let _ = app.run_command_line("SETVAR ZOOMFACTOR 501");
assert_eq!(app.zoom_factor, 500, "501 is not a value the variable takes");
let _ = app.run_command_line("SETVAR ZOOMFACTOR 2");
assert_eq!(app.zoom_factor, 500, "and neither is 2");
// What the Options field reached is still reported here.
let _ = app.update(crate::app::Message::ZoomFactorInputChanged("250".into()));
assert_eq!(app.zoom_factor, 250);
let _ = app.run_command_line("SETVAR ZOOMFACTOR 60");
assert_eq!(app.zoom_factor, 60);
}
/// `SETVAR ISOLINES` stored any parseable `i16` verbatim. The header
/// value is the per-face isolate count handed to the tessellation
/// kernel (which loops `0..count`), so `ISOLINES 32767` made every
/// rebuild and per-cursor preview allocate without bound. The stored
/// value must stay inside the range the app's own UI can ask for
/// (the Options slider is 0..=64).
#[test]
fn setvar_isolines_stays_within_the_renderable_range() {
let mut app = fresh_app();
let i = app.active_tab;
let _ = app.run_command_line("SETVAR ISOLINES 32767");
assert_eq!(app.tabs[i].scene.document.header.isolines, 64);
let _ = app.run_command_line("SETVAR ISOLINES 8");
assert_eq!(app.tabs[i].scene.document.header.isolines, 8);
let _ = app.run_command_line("SETVAR ISOLINES -3");
assert_eq!(app.tabs[i].scene.document.header.isolines, 0);
}
/// `SETVAR LUPREC` / `AUPREC` stored any parseable `i16` verbatim. The
/// units dialog only ever offers 0-8 places, and the formatters read the
/// header on every properties/annotation call, so an unclamped value
/// (positive or negative) fed unbounded strings into those hot paths.
#[test]
fn setvar_precision_stays_within_the_range_the_units_dialog_offers() {
let mut app = fresh_app();
let i = app.active_tab;
let _ = app.run_command_line("SETVAR LUPREC 30000");
assert_eq!(app.tabs[i].scene.document.header.linear_unit_precision, 8);
let _ = app.run_command_line("SETVAR AUPREC 30000");
assert_eq!(app.tabs[i].scene.document.header.angular_unit_precision, 8);
let _ = app.run_command_line("SETVAR LUPREC -3");
assert_eq!(app.tabs[i].scene.document.header.linear_unit_precision, 0);
let _ = app.run_command_line("SETVAR LUPREC 5");
assert_eq!(app.tabs[i].scene.document.header.linear_unit_precision, 5);
}
#[test]
fn selection_commands_gather_instead_of_using_the_whole_drawing() {
for command in ["SETBYLAYER", "OVERKILL"] {
@@ -3492,6 +3862,51 @@ mod tests {
let _ = app.run_command_line("SETVAR DELOBJ 4");
assert_eq!(app.delete_objects, 3);
}
#[test]
fn dynmode_direct_and_setvar_toggles_dynamic_input() {
let mut app = fresh_app();
assert!(app.dyn_input);
assert_eq!(app.dyn_mode, 3);
// Turn off via direct command: DYNMODE 0
let _ = app.run_command_line("DYNMODE 0");
assert!(!app.dyn_input);
assert_eq!(app.dyn_mode, 0);
// Turn on via direct command: DYNMODE 3
let _ = app.run_command_line("DYNMODE 3");
assert!(app.dyn_input);
assert_eq!(app.dyn_mode, 3);
// Turn off via SETVAR: SETVAR DYNMODE 0
let _ = app.run_command_line("SETVAR DYNMODE 0");
assert!(!app.dyn_input);
assert_eq!(app.dyn_mode, 0);
// Turn on via SETVAR: SETVAR DYNMODE 1 (pointer input only)
let _ = app.run_command_line("SETVAR DYNMODE 1");
assert!(app.dyn_input);
assert_eq!(app.dyn_mode, 1);
// Out of range rejected
let _ = app.run_command_line("DYNMODE 5");
assert_eq!(app.dyn_mode, 1);
assert!(app.dyn_input);
let _ = app.run_command_line("DYNMODE -5");
assert_eq!(app.dyn_mode, 1);
assert!(app.dyn_input);
// F12 turns it off temporarily (negative) and restores the mode.
let _ = app.update(crate::app::Message::ToggleDynInput);
assert!(!app.dyn_input);
assert_eq!(app.dyn_mode, -1);
let _ = app.update(crate::app::Message::ToggleDynInput);
assert!(app.dyn_input);
assert_eq!(app.dyn_mode, 1);
}
}
#[cfg(test)]
@@ -3525,3 +3940,18 @@ mod scale_validation_tests {
}
}
}
impl OpenCADStudio {
/// XDWGFADECTL: store it, show it on the ribbon and redraw every tab's
/// referenced drawings.
pub(in crate::app) fn set_xref_fade(&mut self, fade: i32) {
crate::scene::cache::block_cache::set_xref_fade_ctl(fade);
self.ribbon.xref_fade = crate::scene::cache::block_cache::xref_fade_ctl();
for tab in &mut self.tabs {
if !tab.is_start {
tab.scene.recolor_meshes();
tab.scene.bump_geometry();
}
}
}
}
+165 -69
View File
@@ -3,10 +3,10 @@ use super::*;
// Sort tables belong to the block's extension dictionary. Reserve real handles
// and reconnect older unattached tables without losing their existing entries.
fn ensure_draw_order_table(
doc: &mut acadrust::CadDocument,
block: acadrust::Handle,
) -> acadrust::Handle {
use acadrust::objects::{Dictionary, ObjectType, SortEntitiesTable};
doc: &mut codec::CadDocument,
block: codec::Handle,
) -> codec::Handle {
use codec::objects::{Dictionary, ObjectType, SortEntitiesTable};
// Older commands inserted objects using the unreserved next-handle value.
// Repair the allocator floor before creating anything beside those objects.
@@ -548,6 +548,102 @@ impl OpenCADStudio {
return Some(Task::done(Message::PspaceCommand));
}
// ── CHSPACE — move objects between model and paper space through
// a viewport, keeping their apparent size and place (#1428).
// Inside a viewport the selection goes to the sheet; on the
// sheet it goes into the model through the viewport under it.
"CHSPACE" => {
let handles: Vec<codec::Handle> = self.tabs[i]
.scene
.selected_entities()
.into_iter()
.map(|(h, _)| h)
.collect();
if self.tabs[i].scene.current_layout == "Model" {
self.command_line
.push_error(crate::t!("CHSPACE: Command not allowed in the Model tab.").as_ref());
return Some(Task::none());
}
if handles.is_empty() {
use crate::modules::draw::select::SelectObjectsCommand;
let cmd = SelectObjectsCommand::new("CHSPACE");
self.command_line.push_info(&cmd.prompt());
self.tabs[i].active_cmd = Some(Box::new(cmd));
return Some(Task::none());
}
let scene = &self.tabs[i].scene;
let to_paper = scene.active_viewport.is_some();
let frame = match scene.active_viewport {
Some(vp) => scene.viewport_frame(vp),
None => {
// The viewport under the middle of the selection.
let (mut lo, mut hi) = (glam::DVec2::MAX, glam::DVec2::MIN);
for wire in scene.wire_models_for(&handles) {
let [x0, y0, x1, y1] = wire.aabb.map(f64::from);
if [x0, y0, x1, y1].iter().all(|v| v.is_finite()) {
lo = lo.min(glam::DVec2::new(x0, y0));
hi = hi.max(glam::DVec2::new(x1, y1));
}
}
let mid = (lo + hi) * 0.5;
mid.is_finite()
.then(|| scene.viewport_frames_at_paper_point(mid.extend(0.0)))
.and_then(|frames| frames.into_iter().next())
}
};
let Some(frame) = frame else {
self.command_line.push_error(
crate::t!("CHSPACE: No plan-view viewport to change space through.").as_ref(),
);
return Some(Task::none());
};
use crate::command::EntityTransform as T;
let z = glam::DVec3::Z;
let steps = if to_paper {
[
T::Translate((-frame.model_target).extend(0.0)),
T::Rotate { center: glam::DVec3::ZERO, axis: z, angle_rad: frame.twist },
T::Scale { center: glam::DVec3::ZERO, factor: frame.scale },
T::Translate(frame.paper_center.extend(0.0)),
]
} else {
[
T::Translate((-frame.paper_center).extend(0.0)),
T::Scale { center: glam::DVec3::ZERO, factor: 1.0 / frame.scale },
T::Rotate { center: glam::DVec3::ZERO, axis: z, angle_rad: -frame.twist },
T::Translate(frame.model_target.extend(0.0)),
]
};
self.push_undo_snapshot(i, "CHSPACE");
let scene = &mut self.tabs[i].scene;
let entities: Vec<codec::EntityType> = handles
.iter()
.filter_map(|h| scene.document.get_entity(*h).cloned())
.collect();
scene.deselect_all();
scene.erase_entities(&handles);
// The clones land in the other space: the sheet with no active
// viewport, the model with one.
let active = scene.active_viewport;
scene.active_viewport = if to_paper { None } else { Some(frame.viewport) };
let moved: Vec<codec::Handle> =
entities.into_iter().map(|e| scene.add_entity_clone(e)).collect();
scene.active_viewport = active;
for step in &steps {
scene.transform_entities(&moved, step);
}
self.tabs[i].dirty = true;
let count = moved.len();
self.command_line.push_output(
if to_paper {
crate::tf!("CHSPACE: {count} object(s) changed to paper space.")
} else {
crate::tf!("CHSPACE: {count} object(s) changed to model space.")
}
.as_ref(),
);
}
// ── Viewport arrangement shortcuts ────────────────────────────
// Tile the model viewports into preset splits. Each delegates to the
// matching VPORTS configuration so the Model/paper handling stays in
@@ -611,7 +707,7 @@ impl OpenCADStudio {
.document
.entities()
.filter_map(|e| {
if let acadrust::EntityType::Viewport(vp) = e {
if let codec::EntityType::Viewport(vp) = e {
if vp.id > 1 && vp.common.owner_handle == layout_block {
Some((
vp.id,
@@ -710,12 +806,12 @@ impl OpenCADStudio {
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]
let to_erase: Vec<codec::Handle> = self.tabs[i]
.scene
.document
.entities()
.filter_map(|e| {
if let acadrust::EntityType::Viewport(vp) = e {
if let codec::EntityType::Viewport(vp) = e {
if vp.id > 1 && vp.common.owner_handle == layout_block {
Some(vp.common.handle)
} else {
@@ -740,13 +836,13 @@ impl OpenCADStudio {
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);
let mut vp = codec::entities::Viewport::new();
vp.center = codec::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),
codec::EntityType::Viewport(vp),
&layout_name,
) {
Ok(handle) => {
@@ -760,12 +856,12 @@ impl OpenCADStudio {
// 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]
let handles: Vec<codec::Handle> = self.tabs[i]
.scene
.document
.entities()
.filter_map(|e| {
if let acadrust::EntityType::Viewport(vp) = e {
if let codec::EntityType::Viewport(vp) = e {
if vp.id >= 2 && vp.common.owner_handle == layout_block2 {
Some(vp.common.handle)
} else {
@@ -777,7 +873,7 @@ impl OpenCADStudio {
})
.collect();
for h in handles {
if let Some(acadrust::EntityType::Viewport(vp)) =
if let Some(codec::EntityType::Viewport(vp)) =
self.tabs[i].scene.document.get_entity_mut(h)
{
vp.id = id_counter;
@@ -808,7 +904,7 @@ impl OpenCADStudio {
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)) =
if let Some(codec::EntityType::Viewport(vp)) =
scene.document.get_entity(vp_handle)
{
vp.frozen_layers
@@ -847,7 +943,7 @@ impl OpenCADStudio {
"TEXTTOFRONT" | "TEXTTOBACK" => {
let to_front = cmd.ends_with("FRONT");
let block_handle = self.tabs[i].scene.current_layout_block_handle_pub();
let handles: rustc_hash::FxHashSet<acadrust::Handle> = self.tabs[i]
let handles: rustc_hash::FxHashSet<codec::Handle> = self.tabs[i]
.scene
.document
.entities()
@@ -856,9 +952,9 @@ impl OpenCADStudio {
(c.owner_handle == block_handle || c.owner_handle.is_null())
&& matches!(
e,
acadrust::EntityType::Text(_)
| acadrust::EntityType::MText(_)
| acadrust::EntityType::Dimension(_)
codec::EntityType::Text(_)
| codec::EntityType::MText(_)
| codec::EntityType::Dimension(_)
)
})
.map(|e| e.common().handle)
@@ -881,7 +977,7 @@ impl OpenCADStudio {
// HATCHTOBACK ÔÇö move every hatch object in the active space to the back of the draw order.
"HATCHTOBACK" => {
use acadrust::objects::ObjectType;
use codec::objects::ObjectType;
let block_handle = self.tabs[i].scene.current_layout_block_handle_pub();
let doc_ref = &self.tabs[i].scene.document;
@@ -912,7 +1008,7 @@ impl OpenCADStudio {
let has_locked_layers = !locked_layers.is_empty();
// 3. Single pass over entities in the active space.
let mut hatches_to_move: Vec<acadrust::Handle> = Vec::new();
let mut hatches_to_move: Vec<codec::Handle> = Vec::new();
let mut min_eff = u64::MAX;
for e in doc_ref.entities() {
@@ -921,7 +1017,7 @@ impl OpenCADStudio {
continue;
}
let hv = c.handle.value();
if matches!(e, acadrust::EntityType::Hatch(_)) {
if matches!(e, codec::EntityType::Hatch(_)) {
if !has_locked_layers || !locked_layers.contains(c.layer.as_str()) {
hatches_to_move.push(c.handle);
}
@@ -964,7 +1060,7 @@ impl OpenCADStudio {
self.tabs[i].scene.document.objects.get_mut(&th)
{
for (h, sort) in &assigns {
table.add_entry(*h, acadrust::Handle::new(*sort));
table.add_entry(*h, codec::Handle::new(*sort));
}
}
@@ -973,7 +1069,7 @@ impl OpenCADStudio {
}
// 6. Invalidate ONLY draw-depth cache without dropping whole-drawing tessellations or spatial indexes.
let changes: Vec<(acadrust::Handle, crate::scene::ChangeKind)> = hatches_to_move
let changes: Vec<(codec::Handle, crate::scene::ChangeKind)> = hatches_to_move
.iter()
.map(|h| (*h, crate::scene::ChangeKind::Modified))
.collect();
@@ -989,7 +1085,7 @@ impl OpenCADStudio {
}
"DRAWORDER_FRONT" | "DRAWORDER_BACK" | "DRAWORDER_ABOVE" | "DRAWORDER_UNDER" => {
let selected: Vec<acadrust::Handle> = self.tabs[i]
let selected: Vec<codec::Handle> = self.tabs[i]
.scene
.selected_entities()
.iter()
@@ -1020,7 +1116,7 @@ impl OpenCADStudio {
}
}
"DRAWORDER" => {
let selected: Vec<acadrust::Handle> = self.tabs[i]
let selected: Vec<codec::Handle> = self.tabs[i]
.scene
.selected_entities()
.iter()
@@ -1031,11 +1127,11 @@ impl OpenCADStudio {
self.tabs[i].active_cmd = Some(Box::new(c));
}
cmd if cmd.starts_with("DRAWORDER ") => {
use acadrust::objects::ObjectType;
use codec::objects::ObjectType;
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]
let selected: Vec<codec::Handle> = self.tabs[i]
.scene
.selected_entities()
.iter()
@@ -1053,7 +1149,7 @@ impl OpenCADStudio {
};
let references: Vec<_> = parts.iter().skip(2).filter_map(|text| {
u64::from_str_radix(text.trim_start_matches("0x").trim_start_matches("0X"), 16)
.ok().map(acadrust::Handle::new)
.ok().map(codec::Handle::new)
}).filter(|handle| !selected.contains(handle)).collect();
let relative_assignments = relative_above.and_then(|above| {
assign_relative_group_keys(
@@ -1077,7 +1173,7 @@ impl OpenCADStudio {
// 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 mut back_assigns: Option<Vec<(acadrust::Handle, u64)>> = None;
let mut back_assigns: Option<Vec<(codec::Handle, u64)>> = None;
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();
@@ -1150,7 +1246,7 @@ impl OpenCADStudio {
{
if let Some(assignments) = &relative_assignments {
for (handle, sort) in assignments {
table.add_entry(*handle, acadrust::Handle::new(*sort));
table.add_entry(*handle, codec::Handle::new(*sort));
}
self.command_line.push_info(crate::t!("DRAWORDER: selection reordered relative to reference objects.").as_ref());
} else if let Some(to_front) = to_front_opt {
@@ -1158,11 +1254,11 @@ impl OpenCADStudio {
let (_, max_eff) = fb_baseline.unwrap_or((1, 0));
for (k, h) in selected.iter().enumerate() {
let sort = max_eff.saturating_add(1 + k as u64);
table.add_entry(*h, acadrust::Handle::new(sort));
table.add_entry(*h, codec::Handle::new(sort));
}
} else if let Some(assigns) = &back_assigns {
for (h, sort) in assigns {
table.add_entry(*h, acadrust::Handle::new(*sort));
table.add_entry(*h, codec::Handle::new(*sort));
}
}
let dir = if to_front { "front" } else { "back" };
@@ -1192,11 +1288,11 @@ impl OpenCADStudio {
// SYNCPVIEWPORTS — copy the first selected viewport's display settings
// (view direction/target, scale, snap/grid, frozen layers) to the rest.
"SYNCPVIEWPORTS" | "VPSYNC" => {
let vps: Vec<acadrust::Handle> = self.tabs[i]
let vps: Vec<codec::Handle> = self.tabs[i]
.scene
.selected_entities()
.iter()
.filter(|(_, e)| matches!(e, acadrust::EntityType::Viewport(_)))
.filter(|(_, e)| matches!(e, codec::EntityType::Viewport(_)))
.map(|(h, _)| *h)
.filter(|handle| !self.tabs[i].scene.is_layer_locked(*handle))
.collect();
@@ -1207,7 +1303,7 @@ impl OpenCADStudio {
return Some(Task::none());
}
let src = match self.tabs[i].scene.document.get_entity(vps[0]) {
Some(acadrust::EntityType::Viewport(vp)) => vp.clone(),
Some(codec::EntityType::Viewport(vp)) => vp.clone(),
_ => {
self.command_line
.push_error(crate::t!("SYNCPVIEWPORTS: master is not a viewport.").as_ref());
@@ -1217,7 +1313,7 @@ impl OpenCADStudio {
self.push_undo_snapshot(i, "SYNCPVIEWPORTS");
let mut n = 0usize;
for h in &vps[1..] {
if let Some(acadrust::EntityType::Viewport(vp)) =
if let Some(codec::EntityType::Viewport(vp)) =
self.tabs[i].scene.document.get_entity_mut(*h)
{
vp.view_direction = src.view_direction;
@@ -1241,7 +1337,7 @@ impl OpenCADStudio {
// HIDE — hidden-line view of the active viewport.
"HIDE" => {
return Some(Task::done(Message::SetRenderMode(
acadrust::entities::ViewportRenderMode::HiddenLine,
codec::entities::ViewportRenderMode::HiddenLine,
)));
}
@@ -1286,13 +1382,13 @@ enum DrawOrderStep {
/// 3. Gather reference objects, excluding the moved selection; Enter applies.
/// Typed hexadecimal handles may also be accumulated before confirming.
pub(crate) struct DrawOrderCommand {
selected: Vec<acadrust::Handle>,
selected: Vec<codec::Handle>,
step: DrawOrderStep,
references: Vec<acadrust::Handle>,
references: Vec<codec::Handle>,
}
impl DrawOrderCommand {
pub(crate) fn new(selected: Vec<acadrust::Handle>) -> Self {
pub(crate) fn new(selected: Vec<codec::Handle>) -> Self {
let step = if selected.is_empty() {
DrawOrderStep::SelectObjects
} else {
@@ -1301,7 +1397,7 @@ impl DrawOrderCommand {
Self { selected, step, references: Vec::new() }
}
pub(crate) fn for_reference_pick(selected: Vec<acadrust::Handle>, above: bool) -> Self {
pub(crate) fn for_reference_pick(selected: Vec<codec::Handle>, above: bool) -> Self {
Self {
selected,
step: DrawOrderStep::PickReference { above },
@@ -1356,7 +1452,7 @@ impl CadCommand for DrawOrderCommand {
matches!(self.step, DrawOrderStep::SelectObjects | DrawOrderStep::PickReference { .. })
}
fn on_selection_complete(&mut self, handles: Vec<acadrust::Handle>) -> crate::command::CmdResult {
fn on_selection_complete(&mut self, handles: Vec<codec::Handle>) -> crate::command::CmdResult {
if matches!(self.step, DrawOrderStep::PickReference { .. }) {
self.references = handles.into_iter().filter(|handle| !self.selected.contains(handle)).collect();
} else {
@@ -1422,7 +1518,7 @@ impl CadCommand for DrawOrderCommand {
for text in t.split_whitespace() {
let hex = text.trim_start_matches("0x").trim_start_matches("0X");
if let Ok(value) = u64::from_str_radix(hex, 16) {
let handle = acadrust::Handle::new(value);
let handle = codec::Handle::new(value);
if !handle.is_null() && !self.selected.contains(&handle) && !self.references.contains(&handle) {
self.references.push(handle);
}
@@ -1433,7 +1529,7 @@ impl CadCommand for DrawOrderCommand {
}
}
fn on_entity_pick(&mut self, handle: acadrust::Handle, _pt: glam::DVec3) -> crate::command::CmdResult {
fn on_entity_pick(&mut self, handle: codec::Handle, _pt: glam::DVec3) -> crate::command::CmdResult {
if matches!(self.step, DrawOrderStep::PickReference { .. }) && !handle.is_null()
&& !self.selected.contains(&handle) && !self.references.contains(&handle) {
self.references.push(handle);
@@ -1448,13 +1544,13 @@ impl CadCommand for DrawOrderCommand {
/// Insert the selected block next to the extremal reference in effective draw
/// order. Renumber the ordered siblings to avoid ties or saturated sort keys.
fn assign_relative_group_keys(
document: &acadrust::CadDocument,
block: acadrust::Handle,
selected: &[acadrust::Handle],
references: &[acadrust::Handle],
document: &codec::CadDocument,
block: codec::Handle,
selected: &[codec::Handle],
references: &[codec::Handle],
above: bool,
) -> Option<Vec<(acadrust::Handle, u64)>> {
use acadrust::objects::ObjectType;
) -> Option<Vec<(codec::Handle, u64)>> {
use codec::objects::ObjectType;
let overrides: std::collections::HashMap<_, _> = document.objects.values().find_map(|object| {
if let ObjectType::SortEntitiesTable(table) = object {
(table.block_owner_handle == block).then(|| table.entries().map(|entry| (entry.entity_handle, entry.sort_handle.value())).collect())
@@ -1489,15 +1585,15 @@ fn assign_relative_group_keys(
/// shifted by `n` so lifted siblings keep their relative order. Locked-layer
/// entities are never moved or lifted.
fn assign_back_group_keys(
doc: &acadrust::CadDocument,
block_handle: acadrust::Handle,
group: &[acadrust::Handle],
doc: &codec::CadDocument,
block_handle: codec::Handle,
group: &[codec::Handle],
floor: u64,
overrides: Option<&rustc_hash::FxHashMap<u64, u64>>,
locked_layers: &rustc_hash::FxHashSet<&str>,
) -> Vec<(acadrust::Handle, u64)> {
) -> Vec<(codec::Handle, u64)> {
let n = group.len() as u64;
let mut out: Vec<(acadrust::Handle, u64)> = Vec::with_capacity(group.len());
let mut out: Vec<(codec::Handle, u64)> = Vec::with_capacity(group.len());
if n == 0 {
return out;
}
@@ -1512,7 +1608,7 @@ fn assign_back_group_keys(
}
let moved: rustc_hash::FxHashSet<u64> = group.iter().map(|h| h.value()).collect();
let has_locked = !locked_layers.is_empty();
let mut lifts: Vec<(acadrust::Handle, u64)> = Vec::new();
let mut lifts: Vec<(codec::Handle, u64)> = Vec::new();
for e in doc.entities() {
let c = e.common();
if c.owner_handle != block_handle && !c.owner_handle.is_null() {
@@ -1538,8 +1634,8 @@ fn assign_back_group_keys(
mod tests {
use super::*;
use crate::app::OpenCADStudio;
use acadrust::objects::ObjectType;
use acadrust::EntityType;
use codec::objects::ObjectType;
use codec::EntityType;
@@ -1651,7 +1747,7 @@ mod tests {
fn hatchtoback_ignores_hatches_in_other_spaces() {
let mut app = fresh_app();
let i = app.active_tab;
let other_block = acadrust::Handle::new(0x9999);
let other_block = codec::Handle::new(0x9999);
let h_foreign = app.tabs[i].scene.add_entity_clone(EntityType::Hatch(Default::default()));
if let Some(entity) = app.tabs[i].scene.document.get_entity_mut(h_foreign) {
entity.common_mut().owner_handle = other_block;
@@ -1758,7 +1854,7 @@ mod tests {
layer.flags.locked = true;
}
let mut locked_hatch = acadrust::entities::Hatch::default();
let mut locked_hatch = codec::entities::Hatch::default();
locked_hatch.common.layer = "LOCKED_LAYER".into();
let h_locked_hatch = app.tabs[i].scene.add_entity_clone(EntityType::Hatch(locked_hatch));
@@ -1844,12 +1940,12 @@ mod tests {
// Pin the line to sort key 2: only ONE slot below the floor exists,
// but three hatches need to fit behind it.
{
use acadrust::objects::{ObjectType, SortEntitiesTable};
use codec::objects::{ObjectType, SortEntitiesTable};
let doc = &mut app.tabs[i].scene.document;
let nh = acadrust::Handle::new(doc.next_handle());
let nh = codec::Handle::new(doc.next_handle());
let mut table = SortEntitiesTable::for_block(block_handle);
table.handle = nh;
table.add_entry(h_line, acadrust::Handle::new(2));
table.add_entry(h_line, codec::Handle::new(2));
doc.objects.insert(nh, ObjectType::SortEntitiesTable(table));
}
@@ -1979,12 +2075,12 @@ mod tests {
let h_line2 = app.tabs[i].scene.add_entity_clone(EntityType::Line(Default::default()));
{
use acadrust::objects::{ObjectType, SortEntitiesTable};
use codec::objects::{ObjectType, SortEntitiesTable};
let doc = &mut app.tabs[i].scene.document;
let nh = acadrust::Handle::new(doc.next_handle());
let nh = codec::Handle::new(doc.next_handle());
let mut table = SortEntitiesTable::for_block(block_handle);
table.handle = nh;
table.add_entry(h_pinned, acadrust::Handle::new(2));
table.add_entry(h_pinned, codec::Handle::new(2));
doc.objects.insert(nh, ObjectType::SortEntitiesTable(table));
}
@@ -2113,16 +2209,16 @@ mod tests {
#[test]
fn draw_order_table_reattaches_orphan_and_advances_object_handles() {
use acadrust::objects::SortEntitiesTable;
use codec::objects::SortEntitiesTable;
let mut app = fresh_app();
let i = app.active_tab;
let block = app.tabs[i].scene.current_layout_block_handle_pub();
let entity = app.tabs[i].scene.add_entity_clone(EntityType::Line(Default::default()));
let orphan_handle = acadrust::Handle::new(0x10_0000);
let orphan_handle = codec::Handle::new(0x10_0000);
let mut orphan = SortEntitiesTable::for_block(block);
orphan.handle = orphan_handle;
orphan.add_entry(entity, acadrust::Handle::new(7));
orphan.add_entry(entity, codec::Handle::new(7));
app.tabs[i]
.scene
.document
+99
View File
@@ -0,0 +1,99 @@
//! XCLIP's edits on block references, as one undo step.
use super::*;
use crate::modules::insert::xclip::XclipAction;
use crate::scene::pick::xclip as clip;
impl OpenCADStudio {
/// A block reference's extents in WCS: its block's objects through the
/// insert transform.
fn insert_extents(&self, i: usize, insert: codec::Handle) -> ([f64; 2], [f64; 2]) {
let document = &self.tabs[i].scene.document;
let Some(codec::EntityType::Insert(ins)) = document.get_entity(insert) else {
return ([0.0; 2], [0.0; 2]);
};
let xform = ins.get_transform();
let (mut lo, mut hi) = ([f64::MAX; 2], [f64::MIN; 2]);
for e in document.entities_in_block(&ins.block_name) {
let (a, b) = crate::scene::convert::tess::entity_bounds_in(document, e);
for x in [a[0], b[0]] {
for y in [a[1], b[1]] {
let w = xform.apply(codec::types::Vector3::new(x, y, a[2]));
lo = [lo[0].min(w.x), lo[1].min(w.y)];
hi = [hi[0].max(w.x), hi[1].max(w.y)];
}
}
}
if lo[0] > hi[0] {
let p = xform.apply(codec::types::Vector3::new(0.0, 0.0, 0.0));
return ([p.x, p.y], [p.x, p.y]);
}
(lo, hi)
}
pub(in crate::app) fn apply_xclip(
&mut self,
i: usize,
inserts: Vec<codec::Handle>,
action: XclipAction,
) {
if let XclipAction::Depth { rejected: true, .. } = action {
self.command_line.push_error(crate::modules::insert::xclip::DEPTH_REJECTED);
}
self.push_undo_snapshot(i, "XCLIP");
let extents: Vec<_> = inserts.iter().map(|h| self.insert_extents(i, *h)).collect();
let current_layer = self.tabs[i].scene.document.header.current_layer_name.clone();
let scene = &mut self.tabs[i].scene;
let mut polylines = Vec::new();
for (n, insert) in inserts.iter().enumerate() {
let doc = &mut scene.document;
match &action {
XclipAction::Enable(on) => {
clip::set_insert_clip_enabled(doc, *insert, *on);
}
XclipAction::Delete => {
clip::remove_insert_clip(doc, *insert);
}
XclipAction::Depth { front, back, .. } => {
clip::set_insert_clip_depth(doc, *insert, *front, *back);
}
XclipAction::New { boundary, inverted } => {
clip::set_insert_clip(doc, *insert, boundary, *inverted, extents[n]);
}
XclipAction::Polyline => {
let Some(spatial) = clip::filter_handle(doc, *insert) else {
continue;
};
let (Some(codec::objects::ObjectType::SpatialFilter(filter)), Some(codec::EntityType::Insert(ins))) =
(doc.objects.get(&spatial), doc.get_entity(*insert))
else {
continue;
};
let outline = clip::clip_outline_world(doc, filter, &ins.get_transform());
let mut pl = codec::entities::LwPolyline::from_points(
outline
.iter()
.map(|p| codec::types::Vector2::new(p[0], p[1]))
.collect(),
);
pl.is_closed = true;
if !current_layer.is_empty() {
pl.common.layer = current_layer.clone();
}
polylines.push(codec::EntityType::LwPolyline(pl));
}
}
scene.reseed_derived_caches(*insert);
}
for pl in polylines {
scene.add_entity(pl);
}
let changes: Vec<_> = inserts
.iter()
.map(|h| (*h, crate::scene::ChangeKind::Modified))
.collect();
scene.bump_entities(&changes);
self.tabs[i].dirty = true;
self.refresh_properties();
}
}
+130 -5
View File
@@ -12,6 +12,26 @@ use crate::modules::insert::xref_cmd::display_path;
use crate::ui::window::xref_attach::{XrefAttachMsg, XrefAttachState};
impl OpenCADStudio {
/// DWFATTACH / DGNATTACH on the command line: with a file, from its
/// sheet / model prompt; without one, from the path prompt.
pub(super) fn start_underlay_attach(
&mut self,
i: usize,
kind: codec::entities::UnderlayType,
file: &str,
) {
use crate::command::CadCommand;
let insunits = self.tabs[i].scene.document.header.insertion_units;
let mut command = crate::modules::insert::pdf_attach::PdfAttachCommand::for_kind(kind, insunits);
if !file.trim().is_empty() {
if let Some(crate::command::CmdResult::ReportError(message)) = command.on_text_input(file) {
self.command_line.push_error(&message);
}
}
self.command_line.push_info(&command.prompt());
self.tabs[i].active_cmd = Some(Box::new(command));
}
/// Commands owned here; `None` defers to the other families.
pub(super) fn dispatch_xref_attach(&mut self, cmd: &str, i: usize) -> Option<Task<Message>> {
let upper = cmd.to_ascii_uppercase();
@@ -21,6 +41,35 @@ impl OpenCADStudio {
};
match (verb.as_str(), rest.is_empty()) {
("ATTACH", true) => Some(Task::done(Message::AttachPick)),
("DWFATTACH", true) => Some(Task::done(Message::UnderlayAttachPick(
codec::entities::UnderlayType::Dwf,
))),
("DGNATTACH", true) => Some(Task::done(Message::UnderlayAttachPick(
codec::entities::UnderlayType::Dgn,
))),
// A file after DWFATTACH / DGNATTACH, or the - forms, go on the
// command line.
("DWFATTACH" | "-DWFATTACH", _) => {
self.start_underlay_attach(i, codec::entities::UnderlayType::Dwf, rest);
Some(Task::none())
}
("DGNATTACH" | "-DGNATTACH", _) => {
self.start_underlay_attach(i, codec::entities::UnderlayType::Dgn, rest);
Some(Task::none())
}
// POINTCLOUDATTACH picks the file; with a file, or as
// -POINTCLOUDATTACH, it asks on the command line.
("POINTCLOUDATTACH", true) => Some(Task::done(Message::PointCloudAttachPick)),
("POINTCLOUDATTACH" | "-POINTCLOUDATTACH", _) => {
use crate::command::CadCommand;
let command = crate::modules::insert::pc_attach::PointCloudAttachCommand::new();
self.command_line.push_info(&command.prompt());
self.tabs[i].active_cmd = Some(Box::new(command));
if !rest.is_empty() {
return Some(self.feed_command(crate::command::StepInput::Text(rest.to_string())));
}
Some(Task::none())
}
("XATTACH", true) => Some(Task::done(Message::XAttachPick)),
// A file named on the command line skips the picker.
// Handled at once, so the placement it starts belongs to this
@@ -190,13 +239,13 @@ impl OpenCADStudio {
#[cfg(not(target_arch = "wasm32"))]
if let Ok(source) = crate::io::load_file(std::path::Path::new(path)) {
let owner = source.header.model_space_block_handle;
let entities: Vec<acadrust::EntityType> = source
let entities: Vec<codec::EntityType> = source
.entities()
.filter(|e| e.common().owner_handle == owner)
.filter(|e| {
!matches!(
e,
acadrust::EntityType::Block(_) | acadrust::EntityType::BlockEnd(_)
codec::EntityType::Block(_) | codec::EntityType::BlockEnd(_)
)
})
.take(MAX_PREVIEW_ENTITIES + 1)
@@ -219,7 +268,7 @@ impl OpenCADStudio {
&mut self,
i: usize,
request: XrefAttachRequest,
mut entity: acadrust::EntityType,
mut entity: codec::EntityType,
) {
let pending = self.begin_undo(i, "XATTACH", 1, false);
let host = self.tabs[i].current_path.clone();
@@ -227,7 +276,7 @@ impl OpenCADStudio {
if host.is_none() && request.path_type == Pathtype::Relative {
self.tabs[i].xref_relative_on_save.insert(name.clone());
}
if let acadrust::EntityType::Insert(insert) = &mut entity {
if let codec::EntityType::Insert(insert) = &mut entity {
insert.block_name = name;
}
// Resolving merged the reference's layers and linetypes — mirror
@@ -299,6 +348,41 @@ impl OpenCADStudio {
/// Messages of the ATTACH picker and the dialog.
pub(in crate::app) fn update_xref_attach(&mut self, message: Message) -> Task<Message> {
match message {
Message::UnderlayAttachPick(kind) => Task::perform(
async move {
let (title, filter, extensions): (&str, &str, &[&str]) = match kind {
codec::entities::UnderlayType::Dgn => {
("Select DGN File", "DGN Files", &["dgn"])
}
_ => ("Select DWF File", "DWF Files", &["dwf", "dwfx"]),
};
let handle = crate::sys::file_dialog()
.set_title(crate::t!(title).as_ref())
.add_filter(crate::t!(filter).as_ref(), extensions)
.pick_file()
.await;
match handle {
Some(h) => Ok(crate::sys::handle_path(&h)),
None => Err("Cancelled".to_string()),
}
},
Message::AttachPickResult,
),
Message::PointCloudAttachPick => Task::perform(
async {
let handle = crate::sys::file_dialog()
.set_title(crate::t!("Select Point Cloud File").as_ref())
.add_filter(crate::t!("Point Cloud Project").as_ref(), &["rcp"])
.add_filter(crate::t!("Point Cloud Scan").as_ref(), &["rcs"])
.pick_file()
.await;
match handle {
Some(h) => Ok(crate::sys::handle_path(&h)),
None => Err("Cancelled".to_string()),
}
},
Message::AttachPickResult,
),
Message::AttachPick => Task::perform(
async {
let handle = crate::sys::file_dialog()
@@ -306,15 +390,19 @@ impl OpenCADStudio {
.add_filter(
crate::t!("All Reference Files").as_ref(),
&[
"dwg", "dxf", "pdf", "png", "jpg", "jpeg", "bmp", "tif", "tiff",
"dwg", "dxf", "pdf", "dwf", "dwfx", "dgn", "png", "jpg", "jpeg",
"bmp", "tif", "tiff", "rcp", "rcs",
],
)
.add_filter(crate::t!("Drawing").as_ref(), &["dwg", "dxf"])
.add_filter(crate::t!("PDF Files").as_ref(), &["pdf"])
.add_filter(crate::t!("DWF Files").as_ref(), &["dwf", "dwfx"])
.add_filter(crate::t!("DGN Files").as_ref(), &["dgn"])
.add_filter(
crate::t!("Images").as_ref(),
&["png", "jpg", "jpeg", "bmp", "tif", "tiff"],
)
.add_filter(crate::t!("Point Clouds").as_ref(), &["rcp", "rcs"])
.pick_file()
.await;
match handle {
@@ -337,6 +425,43 @@ impl OpenCADStudio {
)))),
Err(e) => self.update(Message::PdfAttachPickResult(Err(e.to_string()))),
},
"dwf" | "dwfx" | "dgn" => {
let kind = if ext == "dgn" {
codec::entities::UnderlayType::Dgn
} else {
codec::entities::UnderlayType::Dwf
};
let path = path.to_string_lossy().into_owned();
match std::fs::read(&path) {
Ok(bytes) => {
crate::scene::model::pdf_raster::register_source(&path, std::sync::Arc::new(bytes));
crate::scene::model::underlay_vector::forget(&path);
if crate::scene::model::underlay_vector::item_names(kind, &path).is_some_and(|n| !n.is_empty()) {
self.open_underlay_attach_dialog(kind, &path);
} else {
self.command_line.push_error(&format!(
"{} not found.",
crate::entities::underlay::display_path(&path)
));
}
}
Err(_) => self.command_line.push_error(&format!(
"{} not found.",
crate::entities::underlay::display_path(&path)
)),
}
Task::none()
}
// A point cloud goes on to the Attach Point Cloud dialog.
"rcp" | "rcs" => {
if crate::scene::model::point_cloud::load(&path).is_none() {
self.command_line.push_error("All scans are not found or invalid.");
self.command_line.push_error("Attach point cloud failed");
return Task::none();
}
self.open_point_cloud_attach_dialog(&path);
Task::none()
}
"png" | "jpg" | "jpeg" | "bmp" | "tif" | "tiff" => {
let result = image::image_dimensions(&path)
.map(|(w, h)| (path.clone(), w, h))