forked from 1b-agent/opencadstudio-upstream
Merge remote-tracking branch 'origin/main' into codex/field-command
# Conflicts: # src/ui/window/attdef_dialog.rs
This commit is contained in:
commit
762cf1e7e4
68 files changed
+4665
-1048
No files matched your search
@@ -771,6 +771,7 @@ impl OpenCADStudio {
|
||||
self.command_line
|
||||
.push_output(crate::tf!("Block \"{name}\" created.").as_ref());
|
||||
self.refresh_properties();
|
||||
self.note_recent_block(&name);
|
||||
self.refresh_block_palette();
|
||||
}
|
||||
Err(err) => {
|
||||
@@ -1008,7 +1009,16 @@ impl OpenCADStudio {
|
||||
self.commit_entity(entity);
|
||||
self.tabs[i].dirty = true;
|
||||
self.tabs[i].scene.clear_preview_wire();
|
||||
self.tabs[i].active_cmd = None;
|
||||
// A repeating insertion keeps its command for the next block.
|
||||
let keep = self.tabs[i].active_cmd.as_ref().is_some_and(|c| c.attreq_continue());
|
||||
if keep {
|
||||
let prompt = self.tabs[i].active_cmd.as_ref().map(|c| c.prompt());
|
||||
if let Some(p) = prompt {
|
||||
self.command_line.push_info(&p);
|
||||
}
|
||||
} else {
|
||||
self.tabs[i].active_cmd = None;
|
||||
}
|
||||
self.tabs[i].snap_result = None;
|
||||
if let Some(n) = insert_name {
|
||||
self.record_block_insert(&n);
|
||||
@@ -1024,7 +1034,16 @@ impl OpenCADStudio {
|
||||
.as_mut()
|
||||
.and_then(|cmd| cmd.attreq_set_attdefs(attdefs));
|
||||
if let Some(entity) = completed {
|
||||
return Some(self.apply_cmd_result(CmdResult::CommitAndExit(entity)));
|
||||
if let codec::EntityType::Insert(ins) = &entity {
|
||||
let name = ins.block_name.clone();
|
||||
self.record_block_insert(&name);
|
||||
}
|
||||
let keep = self.tabs[i].active_cmd.as_ref().is_some_and(|c| c.attreq_continue());
|
||||
return Some(self.apply_cmd_result(if keep {
|
||||
CmdResult::CommitEntity(entity)
|
||||
} else {
|
||||
CmdResult::CommitAndExit(entity)
|
||||
}));
|
||||
}
|
||||
let prompt = self.tabs[i].active_cmd.as_ref().map(|c| c.prompt());
|
||||
if let Some(p) = prompt {
|
||||
|
||||
@@ -767,6 +767,32 @@ impl OpenCADStudio {
|
||||
return task;
|
||||
}
|
||||
}
|
||||
CmdResult::CommitExplodedInsert { insert, keep_going } => {
|
||||
let i = self.active_tab;
|
||||
if let codec::EntityType::Insert(ins) = &insert {
|
||||
let name = ins.block_name.clone();
|
||||
self.record_block_insert(&name);
|
||||
}
|
||||
let pieces = crate::modules::draw::modify::explode::explode_entity(
|
||||
&insert,
|
||||
&self.tabs[i].scene.document,
|
||||
);
|
||||
if keep_going {
|
||||
if let Some(task) = self.handle_commit_entities(
|
||||
pieces,
|
||||
preserve_commit_style,
|
||||
preserve_commit_layer,
|
||||
) {
|
||||
return task;
|
||||
}
|
||||
} else {
|
||||
self.handle_commit_entities_and_exit(
|
||||
pieces,
|
||||
preserve_commit_style,
|
||||
preserve_commit_layer,
|
||||
);
|
||||
}
|
||||
}
|
||||
CmdResult::AttreqNeeded { block_name } => {
|
||||
if let Some(task) = self.handle_attreq_needed(block_name) {
|
||||
return task;
|
||||
|
||||
@@ -1139,4 +1139,61 @@ impl OpenCADStudio {
|
||||
return task;
|
||||
}
|
||||
}
|
||||
|
||||
/// Feed one line from a command script (.scr) into the command pipeline.
|
||||
///
|
||||
/// If an interactive command is active, the line (or its tokens) is routed to
|
||||
/// the active command's prompts (e.g. coordinates or options). If no command
|
||||
/// is active, the line starts a new command (supporting both bare verbs and
|
||||
/// inline single-line commands).
|
||||
pub(in crate::app) fn feed_script_line(&mut self, line: &str) -> Task<Message> {
|
||||
let i = self.active_tab;
|
||||
let trimmed = line.trim();
|
||||
|
||||
// 1. Text editor currently open (e.g. TEXT command content step).
|
||||
if self.text_inline.is_some() {
|
||||
if let Some(editor) = self.text_inline.as_mut() {
|
||||
editor.value = line.to_string();
|
||||
}
|
||||
let committed = self.text_inline_commit();
|
||||
let task = self.post_editor_closed(committed);
|
||||
return if committed {
|
||||
Task::batch([task, Task::done(Message::CommandEscape)])
|
||||
} else {
|
||||
task
|
||||
};
|
||||
}
|
||||
|
||||
// 2. Blank line submits Enter to the active command (or repeats last).
|
||||
if trimmed.is_empty() {
|
||||
return self.feed_command(StepInput::Enter);
|
||||
}
|
||||
|
||||
// 3. A command is currently active: route to its prompt handlers.
|
||||
if self.tabs[i].active_cmd.is_some() {
|
||||
if self.tabs[i]
|
||||
.active_cmd
|
||||
.as_ref()
|
||||
.map(|c| c.input_kind().is_free_text())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return self.feed_command(StepInput::Text(trimmed.to_string()));
|
||||
}
|
||||
|
||||
let tokens: Vec<&str> = trimmed.split_whitespace().collect();
|
||||
let mut tasks = Vec::new();
|
||||
for tok in tokens {
|
||||
if self.tabs[i].active_cmd.is_none() {
|
||||
// Preceding token finished the active command; remaining tokens start a new one.
|
||||
tasks.push(self.run_command_line(tok));
|
||||
} else {
|
||||
tasks.push(self.feed_active_cmd(tok));
|
||||
}
|
||||
}
|
||||
return Task::batch(tasks);
|
||||
}
|
||||
|
||||
// 4. No command is active: run as top-level command.
|
||||
self.run_command_line(trimmed)
|
||||
}
|
||||
}
|
||||
+66
-26
@@ -489,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()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -667,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
|
||||
@@ -1733,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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -1217,6 +1217,27 @@ impl OpenCADStudio {
|
||||
self.command_line.push_info(&cmd.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(cmd));
|
||||
} else {
|
||||
// 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 {
|
||||
|
||||
+136
-22
@@ -342,33 +342,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());
|
||||
@@ -421,3 +443,95 @@ 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);
|
||||
}
|
||||
}
|
||||
+29
-1
@@ -22,6 +22,7 @@ mod pc_extract;
|
||||
mod pdf_underlay;
|
||||
mod pdf_dialogs;
|
||||
mod xclip;
|
||||
mod blockvars;
|
||||
mod plotvars;
|
||||
mod styleprops;
|
||||
mod view;
|
||||
@@ -198,7 +199,7 @@ 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();
|
||||
@@ -230,6 +231,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
|
||||
@@ -304,6 +314,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);
|
||||
}
|
||||
@@ -481,6 +494,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.
|
||||
@@ -1042,3 +1063,10 @@ mod marquee_cancel_tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// `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)
|
||||
}
|
||||
@@ -1107,6 +1107,7 @@ 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(),
|
||||
);
|
||||
@@ -1688,11 +1689,11 @@ impl OpenCADStudio {
|
||||
},
|
||||
"ZOOMFACTOR" => match &value {
|
||||
Some(v) => match v.parse::<i32>() {
|
||||
Ok(factor) if (3..=100).contains(&factor) => {
|
||||
Ok(factor) if (3..=500).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))
|
||||
|
||||
@@ -73,6 +73,7 @@ pub(super) const NAMES: &[&str] = &[
|
||||
"pdf_layer_toggle",
|
||||
"pc_manager_toggle",
|
||||
"pc_manager",
|
||||
"blocks_palette",
|
||||
"pdf_page_select",
|
||||
"pc_colormap",
|
||||
"pc_section",
|
||||
@@ -825,6 +826,93 @@ impl OpenCADStudio {
|
||||
_ => return Err(failure("invalid_value", "Unknown point cloud manager edit")),
|
||||
})
|
||||
}
|
||||
// Blocks palette: "tab=recent", "search=D*", "view=2",
|
||||
// "option=x:2", "options", "library=<dir>", "insert=<item>",
|
||||
// "drop=<item>@x,y", "menu=<action>:<item>". An item is
|
||||
// "current:NAME", "recent:N", "fav:N", "lib:N" or "file:<path>".
|
||||
"blocks_palette" => {
|
||||
use crate::ui::window::block_palette::{BlockPaletteMsg as M, Item, MenuAction, OptionMsg, Tab, ViewMode};
|
||||
let value = string(req, "value")?;
|
||||
let (key, arg) = value.split_once('=').unwrap_or((value, ""));
|
||||
let item = |app: &Self, spec: &str| -> Result<Item, Value> {
|
||||
let (kind, rest) = spec.split_once(':').unwrap_or((spec, ""));
|
||||
let index = || rest.parse::<usize>().map_err(|_| failure("invalid_value", "Bad item index"));
|
||||
let missing = || failure("invalid_value", "No such palette item");
|
||||
Ok(match kind {
|
||||
"current" => Item::Current(rest.to_string()),
|
||||
"recent" => Item::Ref(app.block_palette.recent.get(index()?).cloned().ok_or_else(missing)?),
|
||||
"fav" => Item::Ref(app.block_palette.favorites.get(index()?).cloned().ok_or_else(missing)?),
|
||||
"lib" => {
|
||||
let e = app.block_palette.library_entries.get(index()?).ok_or_else(missing)?;
|
||||
if e.folder { Item::Folder(e.path.clone()) } else { Item::File(e.path.clone()) }
|
||||
}
|
||||
"file" => Item::File(std::path::PathBuf::from(rest)),
|
||||
_ => return Err(missing()),
|
||||
})
|
||||
};
|
||||
match key {
|
||||
"tab" => Message::BlockPalette(M::Tab(match arg {
|
||||
"recent" => Tab::Recent,
|
||||
"favorites" => Tab::Favorites,
|
||||
"libraries" => Tab::Libraries,
|
||||
_ => Tab::Current,
|
||||
})),
|
||||
"search" => Message::BlockPalette(M::Search(arg.into())),
|
||||
"view" => Message::BlockPalette(M::View(ViewMode::from_u8(arg.parse().unwrap_or(1)))),
|
||||
"options" => Message::BlockPalette(M::ToggleOptions),
|
||||
"library" => Message::BlockPalette(M::LibraryPicked(Ok(arg.into()))),
|
||||
"up" => Message::BlockPalette(M::LibraryUp),
|
||||
"option" => {
|
||||
let (name, v) = arg.split_once(':').unwrap_or((arg, "1"));
|
||||
let on = v == "1";
|
||||
Message::BlockPalette(M::Option(match name {
|
||||
"insertion_point" => OptionMsg::InsertionPoint(on),
|
||||
"scale" => OptionMsg::Scale(on),
|
||||
"uniform" => OptionMsg::Uniform(on),
|
||||
"rotation" => OptionMsg::Rotation(on),
|
||||
"auto" => OptionMsg::AutoPlacement(on),
|
||||
"repeat" => OptionMsg::Repeat(on),
|
||||
"explode" => OptionMsg::Explode(on),
|
||||
"x" => OptionMsg::X(v.into()),
|
||||
"y" => OptionMsg::Y(v.into()),
|
||||
"z" => OptionMsg::Z(v.into()),
|
||||
"angle" => OptionMsg::Angle(v.into()),
|
||||
_ => return Err(failure("invalid_value", "Unknown palette option")),
|
||||
}))
|
||||
}
|
||||
"insert" => {
|
||||
let it = item(self, arg)?;
|
||||
self.block_palette.pressed = Some(it.clone());
|
||||
Message::BlockPalette(M::Release(it))
|
||||
}
|
||||
"drop" => {
|
||||
let (spec, at) = arg.rsplit_once('@').ok_or_else(|| failure("invalid_value", "drop needs @x,y"))?;
|
||||
let xy: Vec<f64> = at.split(',').filter_map(|v| v.trim().parse().ok()).collect();
|
||||
if xy.len() < 2 {
|
||||
return Err(failure("invalid_value", "drop needs @x,y"));
|
||||
}
|
||||
let it = item(self, spec)?;
|
||||
let i = self.active_tab;
|
||||
self.tabs[i].last_cursor_world = glam::DVec3::new(xy[0], xy[1], xy.get(2).copied().unwrap_or(0.0));
|
||||
self.block_palette.pressed = Some(it);
|
||||
Message::ViewportLeftRelease
|
||||
}
|
||||
"menu" => {
|
||||
let (action, spec) = arg.split_once(':').ok_or_else(|| failure("invalid_value", "menu needs action:item"))?;
|
||||
let action = match action {
|
||||
"insert" => MenuAction::Insert,
|
||||
"redefine" => MenuAction::Redefine,
|
||||
"favorite" => MenuAction::Favorite,
|
||||
"unfavorite" => MenuAction::Unfavorite,
|
||||
"edit" => MenuAction::Edit,
|
||||
"remove" => MenuAction::Remove,
|
||||
_ => return Err(failure("invalid_value", "Unknown menu action")),
|
||||
};
|
||||
Message::BlockPalette(M::Menu(item(self, spec)?, action))
|
||||
}
|
||||
_ => return Err(failure("invalid_value", "Unknown blocks palette edit")),
|
||||
}
|
||||
}
|
||||
// Attach dialog: choose pages by index ("0,2").
|
||||
"pdf_page_select" => {
|
||||
let pages: Vec<usize> = string(req, "value")?
|
||||
|
||||
@@ -676,7 +676,7 @@ impl OpenCADStudio {
|
||||
/// Silent no-op for an unknown name (callers validate first);
|
||||
/// `*`-prefixed layout records are rejected by the callers. Returns the
|
||||
/// number of entities erased.
|
||||
fn erase_block_definition(scene: &mut crate::scene::Scene, name: &str) -> usize {
|
||||
pub(crate) fn erase_block_definition(scene: &mut crate::scene::Scene, name: &str) -> usize {
|
||||
let Some(record) = scene.document.block_records.get(name) else {
|
||||
return 0;
|
||||
};
|
||||
|
||||
@@ -1484,6 +1484,7 @@ pub(crate) mod vision;
|
||||
mod actions;
|
||||
pub(crate) use actions::property_json;
|
||||
mod entities;
|
||||
pub(crate) use entities::erase_block_definition;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) mod http_bridge;
|
||||
mod interactive;
|
||||
|
||||
+25
-6
@@ -559,7 +559,7 @@ pub(super) struct OpenCADStudio {
|
||||
polar_increment_deg: f32,
|
||||
/// Reverse the mouse-wheel zoom direction when true (ZOOMWHEEL = 1).
|
||||
zoom_wheel_reversed: bool,
|
||||
/// Mouse-wheel zoom sensitivity, clamped to 3..=100 (ZOOMFACTOR).
|
||||
/// Mouse-wheel zoom sensitivity, clamped to 3..=500 (ZOOMFACTOR).
|
||||
zoom_factor: i32,
|
||||
/// Crosshair size setting (CURSORSIZE, 1..=100).
|
||||
cursor_size: i32,
|
||||
@@ -676,6 +676,14 @@ pub(super) struct OpenCADStudio {
|
||||
block_mru: Vec<String>,
|
||||
/// Insertion frequency per block name (uppercase key → count), capped.
|
||||
block_freq: std::collections::HashMap<String, u32>,
|
||||
/// BLOCKMRULIST: how many recent blocks the palette keeps (0–100).
|
||||
pub(crate) block_mru_list: u8,
|
||||
/// BLOCKREDEFINEMODE (0–2).
|
||||
pub(crate) block_redefine_mode: u8,
|
||||
/// BLOCKNAVIGATE: the Libraries tab's start folder ("." for none).
|
||||
pub(crate) block_navigate: String,
|
||||
/// INSNAME: the default block name for -INSERT (session only).
|
||||
pub(crate) insname: String,
|
||||
/// Last time block-usage was flushed to disk (debounce per 2.4).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
block_usage_last_persist: Option<std::time::Instant>,
|
||||
@@ -1304,6 +1312,7 @@ pub(super) struct OpenCADStudio {
|
||||
/// drawings in a file manager produces exactly that (one process per file,
|
||||
/// all arriving at once), which makes this queue load-bearing, not polish.
|
||||
pub(super) pending_opens: std::collections::VecDeque<PathBuf>,
|
||||
pub(super) pending_startup_script_lines: Vec<String>,
|
||||
/// One global interaction-index build at a time. Large drawings can each
|
||||
/// hold millions of entries, so file-open bursts must not multiply peak
|
||||
/// CPU and memory by the number of tabs.
|
||||
@@ -2316,7 +2325,7 @@ pub enum Message {
|
||||
CommandLineFadeChanged(i32),
|
||||
/// Toggle reversing the mouse-wheel zoom direction (ZOOMWHEEL).
|
||||
ZoomWheelReversedChanged(bool),
|
||||
/// Change how far one wheel notch zooms (ZOOMFACTOR, 3..=100).
|
||||
/// Change how far one wheel notch zooms (ZOOMFACTOR, 3..=500).
|
||||
ZoomFactorChanged(i32),
|
||||
/// Options → User Preferences: right-click behaviour (SHORTCUTMENU).
|
||||
RightClickModeChanged(settings::RightClickMode),
|
||||
@@ -4155,6 +4164,10 @@ impl OpenCADStudio {
|
||||
commandline_fade_ms: 3000,
|
||||
block_mru: Vec::new(),
|
||||
block_freq: std::collections::HashMap::new(),
|
||||
block_mru_list: 50,
|
||||
block_redefine_mode: 1,
|
||||
block_navigate: ".".to_string(),
|
||||
insname: String::new(),
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
block_usage_last_persist: None,
|
||||
awaiting_vports: false,
|
||||
@@ -4323,6 +4336,7 @@ impl OpenCADStudio {
|
||||
missing_fonts_downloading: false,
|
||||
suppressed_missing_fonts: rustc_hash::FxHashSet::default(),
|
||||
pending_opens: std::collections::VecDeque::new(),
|
||||
pending_startup_script_lines: Vec::new(),
|
||||
active_interaction_index: None,
|
||||
queued_interaction_indices: std::collections::VecDeque::new(),
|
||||
pending_close: None,
|
||||
@@ -4669,21 +4683,26 @@ impl OpenCADStudio {
|
||||
s.command_line.push_warning(¬ice);
|
||||
crate::scene::pipeline::report_gpu_line(&format!("[gpu] {notice}"));
|
||||
}
|
||||
let cli_open: Task<Message> = if !cfg.files.is_empty() {
|
||||
let has_files = !cfg.files.is_empty();
|
||||
let cli_open: Task<Message> = if has_files {
|
||||
Task::batch(
|
||||
cfg.files
|
||||
.into_iter()
|
||||
.map(|p| Task::done(Message::OpenExternal(p))),
|
||||
)
|
||||
} else if cfg.new {
|
||||
} else if cfg.new || !cfg.script_lines.is_empty() {
|
||||
Task::done(Message::TabNew)
|
||||
} else {
|
||||
Task::none()
|
||||
};
|
||||
// Startup command script: each line dispatched as if typed at the
|
||||
// command line, in order, after any file open is requested.
|
||||
// Startup command script: if files were passed to open, defer the
|
||||
// script lines until the document finishes opening. Otherwise, dispatch
|
||||
// them on the newly created drawing tab.
|
||||
let script: Task<Message> = if cfg.script_lines.is_empty() {
|
||||
Task::none()
|
||||
} else if has_files {
|
||||
s.pending_startup_script_lines = cfg.script_lines;
|
||||
Task::none()
|
||||
} else {
|
||||
Task::batch(
|
||||
cfg.script_lines
|
||||
|
||||
@@ -222,4 +222,15 @@ mod tests {
|
||||
assert_eq!(h.display_silhouette, silhouette);
|
||||
assert!(!app.tabs[i].dirty, "the drawing is as unmodified as it was");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zoom_factor_supports_values_above_100() {
|
||||
let mut app = app();
|
||||
let _ = app.update(Message::ZoomFactorChanged(250));
|
||||
assert_eq!(app.zoom_factor, 250);
|
||||
let _ = app.update(Message::ZoomFactorChanged(500));
|
||||
assert_eq!(app.zoom_factor, 500);
|
||||
let _ = app.update(Message::ZoomFactorChanged(600));
|
||||
assert_eq!(app.zoom_factor, 500, "clamped to new upper limit of 500");
|
||||
}
|
||||
}
|
||||
@@ -548,6 +548,96 @@ pub struct UserSettings {
|
||||
/// Insertion frequency per block name (uppercase key → count).
|
||||
#[serde(default)]
|
||||
pub block_freq: std::collections::HashMap<String, u32>,
|
||||
/// Blocks palette: the recent list (newest first, BLOCKMRULIST long).
|
||||
#[serde(default)]
|
||||
pub block_recent: Vec<PaletteBlockRef>,
|
||||
/// Blocks palette: favorites, kept locally.
|
||||
#[serde(default)]
|
||||
pub block_favorites: Vec<PaletteBlockRef>,
|
||||
/// Blocks palette: library folders or drawings, last used first.
|
||||
#[serde(default)]
|
||||
pub block_libraries: Vec<String>,
|
||||
/// Blocks palette view: 0 extra large, 1 large, 2 medium, 3 small icons,
|
||||
/// 4 details, 5 list.
|
||||
#[serde(default = "default_block_view")]
|
||||
pub block_palette_view: u8,
|
||||
/// Blocks palette insertion options.
|
||||
#[serde(default)]
|
||||
pub block_insert: BlockInsertOptions,
|
||||
/// BLOCKMRULIST (0–100, default 50).
|
||||
#[serde(default = "default_block_mru_list")]
|
||||
pub block_mru_list: u8,
|
||||
/// BLOCKREDEFINEMODE (0–2, default 1).
|
||||
#[serde(default = "default_one_u8")]
|
||||
pub block_redefine_mode: u8,
|
||||
/// BLOCKNAVIGATE (default ".").
|
||||
#[serde(default = "default_block_navigate")]
|
||||
pub block_navigate: String,
|
||||
}
|
||||
|
||||
/// A block the Blocks palette lists from outside the current drawing (a
|
||||
/// recent or favorite entry): its name, the drawing it came from (empty for
|
||||
/// an unsaved drawing), when it was last used (Unix seconds), and whether it
|
||||
/// stands for a whole drawing inserted as a block.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct PaletteBlockRef {
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub source: String,
|
||||
#[serde(default)]
|
||||
pub time: u64,
|
||||
#[serde(default)]
|
||||
pub drawing: bool,
|
||||
}
|
||||
|
||||
/// The Blocks palette's insertion options.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct BlockInsertOptions {
|
||||
pub insertion_point: bool,
|
||||
pub scale: bool,
|
||||
pub uniform: bool,
|
||||
pub x: f64,
|
||||
pub y: f64,
|
||||
pub z: f64,
|
||||
pub rotation: bool,
|
||||
pub angle: f64,
|
||||
pub auto_placement: bool,
|
||||
pub repeat: bool,
|
||||
pub explode: bool,
|
||||
}
|
||||
|
||||
impl Default for BlockInsertOptions {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
insertion_point: true,
|
||||
scale: false,
|
||||
uniform: false,
|
||||
x: 1.0,
|
||||
y: 1.0,
|
||||
z: 1.0,
|
||||
rotation: false,
|
||||
angle: 0.0,
|
||||
auto_placement: true,
|
||||
repeat: false,
|
||||
explode: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn default_block_view() -> u8 {
|
||||
1
|
||||
}
|
||||
|
||||
fn default_block_mru_list() -> u8 {
|
||||
50
|
||||
}
|
||||
|
||||
fn default_one_u8() -> u8 {
|
||||
1
|
||||
}
|
||||
|
||||
fn default_block_navigate() -> String {
|
||||
".".to_string()
|
||||
}
|
||||
|
||||
fn default_clipromptlines() -> i32 {
|
||||
@@ -713,6 +803,14 @@ impl Default for UserSettings {
|
||||
grid_beyond_limits: true,
|
||||
block_mru: Vec::new(),
|
||||
block_freq: std::collections::HashMap::new(),
|
||||
block_recent: Vec::new(),
|
||||
block_favorites: Vec::new(),
|
||||
block_libraries: Vec::new(),
|
||||
block_palette_view: 1,
|
||||
block_insert: BlockInsertOptions::default(),
|
||||
block_mru_list: 50,
|
||||
block_redefine_mode: 1,
|
||||
block_navigate: ".".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,603 @@
|
||||
//! The Blocks palette on the app side: tabs, the recent / favorite lists,
|
||||
//! library folders, the insertion options, click and drag insertion, and the
|
||||
//! block context menu.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::app::settings::PaletteBlockRef;
|
||||
use crate::app::{Message, OpenCADStudio};
|
||||
use crate::modules::insert::insert_block::{BlockCatalog, InsertBlockCommand};
|
||||
use crate::ui::window::block_palette::{
|
||||
BlockEntry, BlockPaletteMsg, Item, LibraryEntry, MenuAction, OptionMsg, Tab,
|
||||
};
|
||||
use iced::Task;
|
||||
|
||||
/// Library folders remembered in the Libraries list.
|
||||
const MAX_LIBRARIES: usize = 10;
|
||||
|
||||
fn now_secs() -> u64 {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
fn file_stem(path: &Path) -> String {
|
||||
path.file_stem()
|
||||
.map(|s| s.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| "Block".to_string())
|
||||
}
|
||||
|
||||
fn is_drawing(path: &Path) -> bool {
|
||||
path.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.is_some_and(|e| e.eq_ignore_ascii_case("dwg") || e.eq_ignore_ascii_case("dxf"))
|
||||
}
|
||||
|
||||
impl OpenCADStudio {
|
||||
pub(super) fn on_block_palette(&mut self, m: BlockPaletteMsg) -> Task<Message> {
|
||||
match m {
|
||||
BlockPaletteMsg::Search(s) => self.block_palette.search = s,
|
||||
BlockPaletteMsg::Tab(tab) => self.show_block_palette_tab(tab),
|
||||
BlockPaletteMsg::View(v) => {
|
||||
self.block_palette.view = v;
|
||||
self.persist_settings_if_changed();
|
||||
}
|
||||
BlockPaletteMsg::Refresh => self.refresh_block_palette(),
|
||||
BlockPaletteMsg::PickFile => {
|
||||
return Task::perform(
|
||||
async {
|
||||
let handle = rfd::AsyncFileDialog::new()
|
||||
.set_title(crate::t!("Select Drawing to Insert as Block").as_ref())
|
||||
.add_filter(crate::t!("DWG/DXF Files").as_ref(), &["dwg", "dxf", "DWG", "DXF"])
|
||||
.pick_file()
|
||||
.await;
|
||||
match handle {
|
||||
Some(h) => Ok(crate::sys::handle_path(&h)),
|
||||
None => Err("Cancelled".to_string()),
|
||||
}
|
||||
},
|
||||
|r| Message::BlockPalette(BlockPaletteMsg::FilePicked(r)),
|
||||
);
|
||||
}
|
||||
BlockPaletteMsg::FilePicked(Ok(path)) => return self.palette_insert(Item::File(path), None),
|
||||
BlockPaletteMsg::FilePicked(Err(e)) | BlockPaletteMsg::LibraryPicked(Err(e)) => {
|
||||
if e != "Cancelled" {
|
||||
self.command_line.push_error(&e);
|
||||
}
|
||||
}
|
||||
BlockPaletteMsg::Option(o) => self.on_block_option(o),
|
||||
BlockPaletteMsg::ToggleOptions => {
|
||||
self.block_palette.options_collapsed ^= true;
|
||||
}
|
||||
BlockPaletteMsg::LibraryBrowse => {
|
||||
return Task::perform(
|
||||
async {
|
||||
match rfd::AsyncFileDialog::new()
|
||||
.set_title(crate::t!("Select a Block Library Folder").as_ref())
|
||||
.pick_folder()
|
||||
.await
|
||||
{
|
||||
Some(h) => Ok(crate::sys::handle_path(&h)),
|
||||
None => Err("Cancelled".to_string()),
|
||||
}
|
||||
},
|
||||
|r| Message::BlockPalette(BlockPaletteMsg::LibraryPicked(r)),
|
||||
);
|
||||
}
|
||||
BlockPaletteMsg::LibraryPicked(Ok(path)) => self.open_block_library(path),
|
||||
BlockPaletteMsg::LibrarySelect(path) => self.open_block_library(PathBuf::from(path)),
|
||||
BlockPaletteMsg::LibraryUp => {
|
||||
if let Some(parent) = self.block_palette.library_dir.as_ref().and_then(|d| d.parent()) {
|
||||
let parent = parent.to_path_buf();
|
||||
self.list_block_library(parent);
|
||||
}
|
||||
}
|
||||
BlockPaletteMsg::Press(item) => self.block_palette.pressed = Some(item),
|
||||
BlockPaletteMsg::Release(item) => {
|
||||
if self.block_palette.pressed.take().as_ref() == Some(&item) {
|
||||
return match item {
|
||||
Item::Folder(dir) => {
|
||||
self.list_block_library(dir);
|
||||
Task::none()
|
||||
}
|
||||
item => self.palette_insert(item, None),
|
||||
};
|
||||
}
|
||||
}
|
||||
BlockPaletteMsg::Menu(item, action) => return self.on_block_menu(item, action),
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
|
||||
/// BLOCKSPALETTE: open (expanded) on `tab`, or on the tab it was left on.
|
||||
pub(crate) fn open_blocks_palette(&mut self, tab: Option<Tab>) {
|
||||
self.show_block_palette = true;
|
||||
self.dock_expanded = Some(crate::ui::dock::PanelId::BlockPalette);
|
||||
self.show_block_palette_tab(tab.unwrap_or(self.block_palette.tab));
|
||||
}
|
||||
|
||||
fn show_block_palette_tab(&mut self, tab: Tab) {
|
||||
self.block_palette.tab = tab;
|
||||
if tab == Tab::Libraries && self.block_palette.library_dir.is_none() {
|
||||
let start = Some(self.block_navigate.trim())
|
||||
.filter(|p| !p.is_empty() && *p != "." && Path::new(p).is_dir())
|
||||
.map(PathBuf::from)
|
||||
.or_else(|| self.block_palette.libraries.first().map(PathBuf::from));
|
||||
if let Some(dir) = start {
|
||||
self.list_block_library(dir);
|
||||
}
|
||||
}
|
||||
self.refresh_block_palette();
|
||||
}
|
||||
|
||||
fn open_block_library(&mut self, root: PathBuf) {
|
||||
let key = root.display().to_string();
|
||||
let libraries = &mut self.block_palette.libraries;
|
||||
libraries.retain(|l| l != &key);
|
||||
libraries.insert(0, key);
|
||||
libraries.truncate(MAX_LIBRARIES);
|
||||
self.list_block_library(root);
|
||||
self.persist_settings_if_changed();
|
||||
}
|
||||
|
||||
/// Show `dir`: its sub-folders, then its drawings, each by name.
|
||||
fn list_block_library(&mut self, dir: PathBuf) {
|
||||
let mut entries: Vec<LibraryEntry> = std::fs::read_dir(&dir)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten()
|
||||
.filter_map(|e| {
|
||||
let path = e.path();
|
||||
// Hidden folders (".name") hold tool data, not blocks.
|
||||
if e.file_name().to_string_lossy().starts_with('.') {
|
||||
return None;
|
||||
}
|
||||
if path.is_dir() {
|
||||
Some(LibraryEntry { path, folder: true, thumb: None })
|
||||
} else if is_drawing(&path) {
|
||||
let thumb = crate::io::thumbnail::read_handle(&path);
|
||||
Some(LibraryEntry { path, folder: false, thumb })
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
entries.sort_by_key(|e| {
|
||||
(!e.folder, e.path.file_name().map(|n| n.to_string_lossy().to_lowercase()))
|
||||
});
|
||||
self.block_palette.library_dir = Some(dir);
|
||||
self.block_palette.library_entries = entries;
|
||||
}
|
||||
|
||||
fn on_block_option(&mut self, o: OptionMsg) {
|
||||
let p = &mut self.block_palette;
|
||||
let number = |t: &str| t.trim().parse::<f64>().ok().filter(|v| v.is_finite());
|
||||
match o {
|
||||
OptionMsg::InsertionPoint(v) => p.options.insertion_point = v,
|
||||
OptionMsg::Scale(v) => p.options.scale = v,
|
||||
OptionMsg::Uniform(v) => p.options.uniform = v,
|
||||
OptionMsg::Rotation(v) => p.options.rotation = v,
|
||||
OptionMsg::AutoPlacement(v) => p.options.auto_placement = v,
|
||||
OptionMsg::Repeat(v) => p.options.repeat = v,
|
||||
OptionMsg::Explode(v) => p.options.explode = v,
|
||||
OptionMsg::X(t) => {
|
||||
if let Some(v) = number(&t).filter(|v| *v != 0.0) {
|
||||
p.options.x = v;
|
||||
}
|
||||
p.option_text[0] = t;
|
||||
}
|
||||
OptionMsg::Y(t) => {
|
||||
if let Some(v) = number(&t).filter(|v| *v != 0.0) {
|
||||
p.options.y = v;
|
||||
}
|
||||
p.option_text[1] = t;
|
||||
}
|
||||
OptionMsg::Z(t) => {
|
||||
if let Some(v) = number(&t).filter(|v| *v != 0.0) {
|
||||
p.options.z = v;
|
||||
}
|
||||
p.option_text[2] = t;
|
||||
}
|
||||
OptionMsg::Angle(t) => {
|
||||
if let Some(v) = number(&t) {
|
||||
p.options.angle = v;
|
||||
}
|
||||
p.option_text[3] = t;
|
||||
}
|
||||
}
|
||||
self.persist_settings_if_changed();
|
||||
}
|
||||
|
||||
/// Start placing `name` as a palette click would.
|
||||
#[cfg(test)]
|
||||
pub(super) fn start_block_placement(&mut self, name: &str) {
|
||||
let _ = self.start_palette_insert(name, None);
|
||||
}
|
||||
|
||||
fn start_palette_insert(&mut self, name: &str, drop_at: Option<glam::DVec3>) -> Task<Message> {
|
||||
use crate::command::CadCommand;
|
||||
let i = self.active_tab;
|
||||
let wires = self
|
||||
.block_palette
|
||||
.blocks
|
||||
.iter()
|
||||
.find(|b| b.name.eq_ignore_ascii_case(name))
|
||||
.map(|b| b.wires.clone())
|
||||
.unwrap_or_else(|| self.tabs[i].scene.block_preview_wires(name));
|
||||
let mut options = self.block_palette.options.clone();
|
||||
if drop_at.is_some() {
|
||||
// A dropped block goes in where it lands with the fixed values.
|
||||
options.insertion_point = false;
|
||||
options.scale = false;
|
||||
options.rotation = false;
|
||||
options.repeat = false;
|
||||
}
|
||||
let mut cmd = InsertBlockCommand::palette(name.to_string(), wires, &options);
|
||||
if let Some(point) = drop_at {
|
||||
cmd.set_point(point);
|
||||
}
|
||||
self.reset_command_start_state(i);
|
||||
let plane = if self.tabs[i].editing_model_space() {
|
||||
self.tabs[i].ucs_xform().working_plane()
|
||||
} else {
|
||||
crate::command::WorkingPlane::default()
|
||||
};
|
||||
cmd.set_working_plane(plane);
|
||||
let first = cmd.initial();
|
||||
let prompt = cmd.prompt();
|
||||
self.tabs[i].active_cmd = Some(Box::new(cmd));
|
||||
self.block_palette.placing = Some(name.to_string());
|
||||
match first {
|
||||
Some(result) => self.apply_cmd_result(result),
|
||||
None => {
|
||||
self.command_line.push_info(&prompt);
|
||||
Task::none()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Insert `item` (a click, or a drop at `drop_at`), bringing its block
|
||||
/// into the drawing first when it lives elsewhere.
|
||||
pub(crate) fn palette_insert(&mut self, item: Item, drop_at: Option<glam::DVec3>) -> Task<Message> {
|
||||
match self.resolve_palette_block(&item) {
|
||||
Ok(name) => {
|
||||
self.refresh_block_palette();
|
||||
self.start_palette_insert(&name, drop_at)
|
||||
}
|
||||
Err(e) => {
|
||||
if !e.is_empty() {
|
||||
self.command_line.push_error(&e);
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn block_exists(&self, name: &str) -> bool {
|
||||
self.tabs[self.active_tab].scene.document.block_records.get(name).is_some()
|
||||
}
|
||||
|
||||
fn current_drawing_path(&self) -> String {
|
||||
self.tabs[self.active_tab]
|
||||
.current_path
|
||||
.as_ref()
|
||||
.map(|p| p.display().to_string())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn resolve_palette_block(&mut self, item: &Item) -> Result<String, String> {
|
||||
match item {
|
||||
Item::Current(name) => Ok(name.clone()),
|
||||
Item::Ref(r) if r.drawing => self.block_from_drawing(PathBuf::from(&r.source)),
|
||||
Item::Ref(r) => {
|
||||
let elsewhere = !r.source.is_empty() && r.source != self.current_drawing_path();
|
||||
if self.block_exists(&r.name) && !(elsewhere && self.block_redefine_mode == 2) {
|
||||
return Ok(r.name.clone());
|
||||
}
|
||||
if !elsewhere {
|
||||
return Err(crate::tf!("Block \"{}\" not found.", r.name).into_owned());
|
||||
}
|
||||
let doc = crate::io::load_file(Path::new(&r.source)).map_err(|e| e.to_string())?;
|
||||
let redefine = self.block_exists(&r.name);
|
||||
self.import_block_definition(&doc, &r.name, redefine)?;
|
||||
Ok(r.name.clone())
|
||||
}
|
||||
Item::File(path) => self.block_from_drawing(path.clone()),
|
||||
Item::Folder(_) => Err(String::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The block standing for drawing `path`: the drawing's own block when
|
||||
/// the drawing already has one of that name (redefined from the file
|
||||
/// under BLOCKREDEFINEMODE 2), else the file inserted as a new block.
|
||||
fn block_from_drawing(&mut self, path: PathBuf) -> Result<String, String> {
|
||||
let stem = file_stem(&path);
|
||||
let name = if self.block_exists(&stem) && self.block_redefine_mode != 2 {
|
||||
stem
|
||||
} else {
|
||||
self.import_drawing_block(path.clone(), true)?
|
||||
};
|
||||
self.block_palette
|
||||
.file_sources
|
||||
.insert(name.to_ascii_uppercase(), path.display().to_string());
|
||||
Ok(name)
|
||||
}
|
||||
|
||||
/// Bring block `name` (and what it needs) from `source` into the active
|
||||
/// drawing; with `redefine` an existing definition takes the source's.
|
||||
fn import_block_definition(
|
||||
&mut self,
|
||||
source: &codec::CadDocument,
|
||||
name: &str,
|
||||
redefine: bool,
|
||||
) -> Result<(), String> {
|
||||
if source.block_records.get(name).is_none() {
|
||||
return Err(crate::tf!("Block \"{}\" not found.", name).into_owned());
|
||||
}
|
||||
let probe = codec::EntityType::Insert(codec::entities::Insert::new(
|
||||
name.to_string(),
|
||||
codec::types::Vector3::ZERO,
|
||||
));
|
||||
let deps = crate::app::ClipboardDeps::capture(source, std::slice::from_ref(&probe));
|
||||
let i = self.active_tab;
|
||||
self.push_undo_snapshot(i, "INSERT");
|
||||
self.merge_dependencies(i, &deps);
|
||||
for def in deps.blocks {
|
||||
let scene = &mut self.tabs[i].scene;
|
||||
if redefine && def.name.eq_ignore_ascii_case(name) {
|
||||
scene.redefine_block_raw(&def.name, def.base_point, def.entities);
|
||||
} else if scene.document.block_records.get(&def.name).is_none() {
|
||||
scene.define_block_raw(&def.name, def.base_point, def.entities);
|
||||
}
|
||||
}
|
||||
self.tabs[i].scene.populate_meshes_from_document();
|
||||
self.tabs[i].dirty = true;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn palette_ref(&self, item: &Item) -> Option<PaletteBlockRef> {
|
||||
let time = now_secs();
|
||||
match item {
|
||||
Item::Current(name) => Some(PaletteBlockRef {
|
||||
name: name.clone(),
|
||||
source: self.current_drawing_path(),
|
||||
time,
|
||||
drawing: false,
|
||||
}),
|
||||
Item::Ref(r) => Some(r.clone()),
|
||||
Item::File(path) => Some(PaletteBlockRef {
|
||||
name: file_stem(path),
|
||||
source: path.display().to_string(),
|
||||
time,
|
||||
drawing: true,
|
||||
}),
|
||||
Item::Folder(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn on_block_menu(&mut self, item: Item, action: MenuAction) -> Task<Message> {
|
||||
let same = |a: &PaletteBlockRef, b: &PaletteBlockRef| {
|
||||
a.name.eq_ignore_ascii_case(&b.name) && a.source == b.source
|
||||
};
|
||||
match action {
|
||||
MenuAction::Insert => return self.palette_insert(item, None),
|
||||
MenuAction::Redefine => {
|
||||
let result = match &item {
|
||||
Item::Ref(r) if r.drawing => self.import_drawing_block(PathBuf::from(&r.source), true),
|
||||
Item::File(path) => self.import_drawing_block(path.clone(), true),
|
||||
Item::Ref(r) if !r.source.is_empty() => crate::io::load_file(Path::new(&r.source))
|
||||
.map_err(|e| e.to_string())
|
||||
.and_then(|doc| self.import_block_definition(&doc, &r.name, true))
|
||||
.map(|_| r.name.clone()),
|
||||
_ => return Task::none(),
|
||||
};
|
||||
match result {
|
||||
Ok(name) => {
|
||||
self.command_line
|
||||
.push_output(&crate::tf!("Block \"{name}\" redefined."));
|
||||
self.refresh_block_palette();
|
||||
}
|
||||
Err(e) => self.command_line.push_error(&e),
|
||||
}
|
||||
}
|
||||
MenuAction::Favorite => {
|
||||
if let Some(r) = self.palette_ref(&item) {
|
||||
let favorites = &mut self.block_palette.favorites;
|
||||
favorites.retain(|f| !same(f, &r));
|
||||
favorites.insert(0, r);
|
||||
self.persist_settings_if_changed();
|
||||
self.refresh_block_palette();
|
||||
}
|
||||
}
|
||||
MenuAction::Unfavorite => {
|
||||
if let Some(r) = self.palette_ref(&item) {
|
||||
self.block_palette.favorites.retain(|f| !same(f, &r));
|
||||
self.persist_settings_if_changed();
|
||||
}
|
||||
}
|
||||
MenuAction::Edit => {
|
||||
let name = item.name();
|
||||
let i = self.active_tab;
|
||||
let reference = self.tabs[i].scene.document.entities().find_map(|e| match e {
|
||||
codec::EntityType::Insert(ins) if ins.block_name.eq_ignore_ascii_case(&name) => {
|
||||
Some(ins.common.handle)
|
||||
}
|
||||
_ => None,
|
||||
});
|
||||
match reference {
|
||||
Some(handle) => return self.dispatch_command(&format!("BEDIT_BEGIN:{}", handle.value())),
|
||||
None => self.command_line.push_error(&crate::tf!(
|
||||
"Block \"{name}\" has no reference in the drawing to edit."
|
||||
)),
|
||||
}
|
||||
}
|
||||
MenuAction::Remove => match self.block_palette.tab {
|
||||
Tab::Current => {
|
||||
let name = item.name();
|
||||
let i = self.active_tab;
|
||||
let referenced = self.tabs[i].scene.document.entities().any(|e| {
|
||||
matches!(e, codec::EntityType::Insert(ins) if ins.block_name.eq_ignore_ascii_case(&name))
|
||||
});
|
||||
if referenced {
|
||||
self.command_line.push_error(&crate::tf!(
|
||||
"Block \"{name}\" is referenced in the drawing and cannot be removed."
|
||||
));
|
||||
} else {
|
||||
self.push_undo_snapshot(i, "PURGE");
|
||||
crate::app::control::erase_block_definition(&mut self.tabs[i].scene, &name);
|
||||
self.tabs[i].dirty = true;
|
||||
self.command_line.push_output(&crate::tf!("Block \"{name}\" removed."));
|
||||
self.refresh_block_palette();
|
||||
}
|
||||
}
|
||||
Tab::Recent | Tab::Favorites => {
|
||||
if let Some(r) = self.palette_ref(&item) {
|
||||
let list = if self.block_palette.tab == Tab::Recent {
|
||||
&mut self.block_palette.recent
|
||||
} else {
|
||||
&mut self.block_palette.favorites
|
||||
};
|
||||
list.retain(|f| !same(f, &r));
|
||||
self.persist_settings_if_changed();
|
||||
}
|
||||
}
|
||||
Tab::Libraries => {}
|
||||
},
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
|
||||
/// A press in the drawing ends any palette press; a release there with
|
||||
/// a block still pressed drops it at the cursor.
|
||||
pub(super) fn take_block_drop(&mut self) -> Option<Item> {
|
||||
self.block_palette.pressed.take()
|
||||
}
|
||||
|
||||
pub(super) fn drop_block(&mut self, item: Item) -> Task<Message> {
|
||||
let at = self.tabs[self.active_tab].last_cursor_world;
|
||||
self.palette_insert(item, Some(at))
|
||||
}
|
||||
|
||||
/// Put `name` at the top of the Recent list (BLOCKMRULIST long).
|
||||
pub(crate) fn note_recent_block(&mut self, name: &str) {
|
||||
let (source, drawing) = match self.block_palette.file_sources.get(&name.to_ascii_uppercase()) {
|
||||
Some(path) => (path.clone(), true),
|
||||
None => (self.current_drawing_path(), false),
|
||||
};
|
||||
let entry = PaletteBlockRef {
|
||||
name: if drawing { file_stem(Path::new(&source)) } else { name.to_string() },
|
||||
source,
|
||||
time: now_secs(),
|
||||
drawing,
|
||||
};
|
||||
let recent = &mut self.block_palette.recent;
|
||||
recent.retain(|r| !(r.name.eq_ignore_ascii_case(&entry.name) && r.source == entry.source));
|
||||
recent.insert(0, entry);
|
||||
self.trim_recent_blocks();
|
||||
}
|
||||
|
||||
pub(crate) fn trim_recent_blocks(&mut self) {
|
||||
self.block_palette.recent.truncate(self.block_mru_list as usize);
|
||||
}
|
||||
|
||||
/// Rebuild the Current Drawing list, and the previews kept for blocks
|
||||
/// of other drawings and for whole drawings.
|
||||
pub(crate) fn refresh_block_palette(&mut self) {
|
||||
let i = self.active_tab;
|
||||
let names = self.tabs[i].scene.custom_block_names();
|
||||
self.block_palette.cached_names = names.clone();
|
||||
self.block_palette.source_tab_id = Some(self.tabs[i].id);
|
||||
self.block_palette.source_block_epoch = self.tabs[i].scene.block_epoch;
|
||||
let doc = &self.tabs[i].scene.document;
|
||||
let annotative = |name: &str| {
|
||||
doc.block_records.get(name).is_some_and(|br| {
|
||||
matches!(
|
||||
doc.get_entity(br.block_entity_handle),
|
||||
Some(codec::EntityType::Block(b)) if b.common.extended_data.records().iter().any(|r| r.application_name == "AcadAnnotative")
|
||||
)
|
||||
})
|
||||
};
|
||||
let entries: Vec<BlockEntry> = names
|
||||
.into_iter()
|
||||
.map(|name| BlockEntry {
|
||||
wires: self.tabs[i].scene.block_preview_wires(&name),
|
||||
annotative: annotative(&name),
|
||||
dynamic: false,
|
||||
name,
|
||||
})
|
||||
.collect();
|
||||
let here = self.current_drawing_path();
|
||||
for entry in &entries {
|
||||
let key = format!("{here}|{}", entry.name.to_ascii_uppercase());
|
||||
self.block_palette.ref_wires.insert(key, entry.wires.clone());
|
||||
}
|
||||
self.block_palette.blocks = entries;
|
||||
let drawings: Vec<String> = self
|
||||
.block_palette
|
||||
.recent
|
||||
.iter()
|
||||
.chain(&self.block_palette.favorites)
|
||||
.filter(|r| r.drawing && !self.block_palette.file_thumbs.contains_key(&r.source))
|
||||
.map(|r| r.source.clone())
|
||||
.collect();
|
||||
for source in drawings {
|
||||
let thumb = crate::io::thumbnail::read_handle(Path::new(&source));
|
||||
self.block_palette.file_thumbs.insert(source, thumb);
|
||||
}
|
||||
}
|
||||
|
||||
/// Cheap per-update check: rebuild when the active drawing's definitions
|
||||
/// changed, even when their names happen to stay the same.
|
||||
pub(crate) fn refresh_block_palette_if_stale(&mut self) {
|
||||
if !self.show_block_palette {
|
||||
return;
|
||||
}
|
||||
let i = self.active_tab;
|
||||
if self.block_palette.source_tab_id != Some(self.tabs[i].id)
|
||||
|| self.block_palette.source_block_epoch != self.tabs[i].scene.block_epoch
|
||||
{
|
||||
self.refresh_block_palette();
|
||||
}
|
||||
}
|
||||
|
||||
/// What `-INSERT`'s name step knows about the drawing's blocks.
|
||||
pub(crate) fn block_catalog(&self) -> BlockCatalog {
|
||||
let doc = &self.tabs[self.active_tab].scene.document;
|
||||
let host = doc.header.insertion_units;
|
||||
let mut user = Vec::new();
|
||||
let mut unnamed = 0;
|
||||
let mut units = rustc_hash::FxHashMap::default();
|
||||
for br in doc.block_records.iter() {
|
||||
if br.flags.is_xref || br.name.eq_ignore_ascii_case("*Model_Space") || br.name.to_ascii_uppercase().starts_with("*PAPER_SPACE") {
|
||||
continue;
|
||||
}
|
||||
if br.name.starts_with('*') {
|
||||
unnamed += 1;
|
||||
continue;
|
||||
}
|
||||
let factor = crate::app::properties::insert_unit_scale(host, br.units).unwrap_or(1.0);
|
||||
units.insert(
|
||||
br.name.to_ascii_uppercase(),
|
||||
format!(
|
||||
"Units: {} Conversion:{:>10}",
|
||||
crate::app::properties::insunits_name(br.units),
|
||||
crate::app::properties::format_unit_factor(factor)
|
||||
),
|
||||
);
|
||||
user.push(br.name.clone());
|
||||
}
|
||||
user.sort_by_key(|n| n.to_ascii_uppercase());
|
||||
let search_path = std::env::current_dir()
|
||||
.map(|d| d.display().to_string())
|
||||
.unwrap_or_default();
|
||||
BlockCatalog { user, unnamed, units, search_path }
|
||||
}
|
||||
}
|
||||
+41
-119
@@ -2,7 +2,6 @@
|
||||
|
||||
#![allow(unused_imports)]
|
||||
use super::util::*;
|
||||
use crate::ui::window::block_palette::BlockPaletteMsg;
|
||||
use super::{format_size, VIEWCUBE_HIT_SIZE};
|
||||
use crate::app::helpers::{
|
||||
parse_coord, polar_constrain_near, ucs_rotate_vec, ucs_to_wcs, ucs_z_axis,
|
||||
@@ -373,21 +372,41 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven
|
||||
/// one new block in the active drawing. Returns the new block's name, or an
|
||||
/// error message. Nested block definitions are imported first so nested
|
||||
/// INSERTs render (AutoCAD's "inserting a drawing imports its block defs").
|
||||
fn import_file_as_block(&mut self, path: std::path::PathBuf) -> Result<String, String> {
|
||||
pub(in crate::app) fn import_file_as_block(&mut self, path: std::path::PathBuf) -> Result<String, String> {
|
||||
self.import_drawing_block(path, false)
|
||||
}
|
||||
|
||||
/// [`Self::import_file_as_block`]; with `redefine` a block already named
|
||||
/// after the file takes the file's contents instead of a new name.
|
||||
pub(super) fn import_drawing_block(
|
||||
&mut self,
|
||||
path: std::path::PathBuf,
|
||||
redefine: bool,
|
||||
) -> Result<String, String> {
|
||||
let doc = crate::io::load_file(&path).map_err(|e| e.to_string())?;
|
||||
let stem = path
|
||||
.file_stem()
|
||||
.map(|s| s.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| "Block".to_string());
|
||||
self.import_document_as_block(doc, stem)
|
||||
self.import_document_block(doc, stem, redefine)
|
||||
}
|
||||
|
||||
/// Define one block in the active drawing from a loaded `CadDocument`'s
|
||||
/// model-space entities (base = the file's model-space insertion base).
|
||||
#[cfg(test)]
|
||||
fn import_document_as_block(
|
||||
&mut self,
|
||||
doc: codec::CadDocument,
|
||||
stem: String,
|
||||
) -> Result<String, String> {
|
||||
self.import_document_block(doc, stem, false)
|
||||
}
|
||||
|
||||
fn import_document_block(
|
||||
&mut self,
|
||||
doc: codec::CadDocument,
|
||||
stem: String,
|
||||
redefine: bool,
|
||||
) -> Result<String, String> {
|
||||
let i = self.active_tab;
|
||||
// Model-space block record handle (Layout object first, name fallback).
|
||||
@@ -441,7 +460,13 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven
|
||||
doc.header.model_space_insertion_base.y,
|
||||
doc.header.model_space_insertion_base.z,
|
||||
);
|
||||
let name = self.block_name_from_file(&stem);
|
||||
let redefine = redefine
|
||||
&& self.tabs[i].scene.document.block_records.get(stem.trim()).is_some();
|
||||
let name = if redefine {
|
||||
stem.trim().to_string()
|
||||
} else {
|
||||
self.block_name_from_file(&stem)
|
||||
};
|
||||
// Capture every table record needed by the top-level entities and their
|
||||
// nested block definitions. Importing only the definitions leaves
|
||||
// source-only layers, linetypes, and text/dimension styles dangling.
|
||||
@@ -513,74 +538,22 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven
|
||||
.scene
|
||||
.define_block_raw(&def.name, def.base_point, def.entities);
|
||||
}
|
||||
self.tabs[i]
|
||||
.scene
|
||||
.define_block_from_owned_entities(entities, &name, base)?;
|
||||
if redefine {
|
||||
self.tabs[i].scene.redefine_block_raw(
|
||||
&name,
|
||||
codec::types::Vector3::new(base.x, base.y, base.z),
|
||||
entities,
|
||||
);
|
||||
} else {
|
||||
self.tabs[i]
|
||||
.scene
|
||||
.define_block_from_owned_entities(entities, &name, base)?;
|
||||
}
|
||||
self.tabs[i].scene.populate_meshes_from_document();
|
||||
self.tabs[i].dirty = true;
|
||||
Ok(name)
|
||||
}
|
||||
|
||||
pub(super) fn on_block_palette(&mut self, m: crate::ui::window::block_palette::BlockPaletteMsg) -> iced::Task<Message> {
|
||||
use crate::ui::window::block_palette::{BlockEntry, BlockPaletteMsg};
|
||||
match m {
|
||||
BlockPaletteMsg::Search(s) => {
|
||||
self.block_palette.search = s;
|
||||
iced::Task::none()
|
||||
}
|
||||
BlockPaletteMsg::CyclePreviewSize => {
|
||||
self.block_palette.preview_size =
|
||||
crate::ui::window::block_palette::cycle_preview_size(
|
||||
self.block_palette.preview_size,
|
||||
);
|
||||
iced::Task::none()
|
||||
}
|
||||
BlockPaletteMsg::Refresh => {
|
||||
self.refresh_block_palette();
|
||||
iced::Task::none()
|
||||
}
|
||||
BlockPaletteMsg::PickFile => iced::Task::perform(
|
||||
async {
|
||||
let handle = rfd::AsyncFileDialog::new()
|
||||
.set_title(crate::t!("Select Drawing to Insert as Block").as_ref())
|
||||
.add_filter(crate::t!("DWG/DXF Files").as_ref(), &["dwg", "dxf", "DWG", "DXF"])
|
||||
.pick_file()
|
||||
.await;
|
||||
match handle {
|
||||
Some(h) => Ok(crate::sys::handle_path(&h)),
|
||||
None => Err("Cancelled".to_string()),
|
||||
}
|
||||
},
|
||||
|r| Message::BlockPalette(BlockPaletteMsg::FilePicked(r)),
|
||||
),
|
||||
BlockPaletteMsg::FilePicked(Ok(path)) => {
|
||||
match self.import_file_as_block(path) {
|
||||
Ok(name) => {
|
||||
self.command_line
|
||||
.push_output(&crate::tf!("Inserting \"{name}\" from file."));
|
||||
self.refresh_block_palette();
|
||||
self.start_block_placement(&name);
|
||||
}
|
||||
Err(e) if e != "Cancelled" => {
|
||||
self.command_line.push_error(&crate::tf!("INSERT FILE: {e}"));
|
||||
}
|
||||
Err(_) => {}
|
||||
}
|
||||
iced::Task::none()
|
||||
}
|
||||
BlockPaletteMsg::FilePicked(Err(e)) => {
|
||||
if e != "Cancelled" {
|
||||
self.command_line.push_error(&e);
|
||||
}
|
||||
iced::Task::none()
|
||||
}
|
||||
BlockPaletteMsg::Insert(name) => {
|
||||
self.start_block_placement(&name);
|
||||
iced::Task::none()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Dock chrome interaction (grab / pin / resize / hover / move) applied to
|
||||
/// whichever panel the message names.
|
||||
pub(super) fn on_dock(&mut self, m: crate::ui::dock::DockMsg) -> iced::Task<Message> {
|
||||
@@ -754,57 +727,6 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven
|
||||
.count()
|
||||
}
|
||||
|
||||
/// Start placing `name` through the INSERT command, skipping the name prompt.
|
||||
fn start_block_placement(&mut self, name: &str) {
|
||||
let i = self.active_tab;
|
||||
let wires = self
|
||||
.block_palette
|
||||
.blocks
|
||||
.iter()
|
||||
.find(|b| b.name == name)
|
||||
.map(|b| b.wires.clone())
|
||||
.unwrap_or_else(|| self.tabs[i].scene.block_preview_wires(name));
|
||||
use crate::modules::insert::insert_block::InsertBlockCommand;
|
||||
let cmd = InsertBlockCommand::new_for_block(name.to_string(), wires, glam::Vec3::ZERO);
|
||||
use crate::command::CadCommand;
|
||||
self.reset_command_start_state(i);
|
||||
self.command_line.push_info(&cmd.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(cmd));
|
||||
self.block_palette.placing = Some(name.to_string());
|
||||
}
|
||||
|
||||
/// Rebuild the panel's block list + cached wires from the active drawing.
|
||||
pub(crate) fn refresh_block_palette(&mut self) {
|
||||
let i = self.active_tab;
|
||||
let names = self.tabs[i].scene.custom_block_names();
|
||||
self.block_palette.cached_names = names.clone();
|
||||
self.block_palette.source_tab_id = Some(self.tabs[i].id);
|
||||
self.block_palette.source_block_epoch = self.tabs[i].scene.block_epoch;
|
||||
self.block_palette.blocks = names
|
||||
.into_iter()
|
||||
.map(|name| {
|
||||
let wires = self.tabs[i].scene.block_preview_wires(&name);
|
||||
crate::ui::window::block_palette::BlockEntry { name, wires }
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
|
||||
/// Cheap per-update check: rebuild when the active drawing's definitions
|
||||
/// changed, even when their names happen to stay the same.
|
||||
pub(crate) fn refresh_block_palette_if_stale(&mut self) {
|
||||
if !self.show_block_palette {
|
||||
return;
|
||||
}
|
||||
let i = self.active_tab;
|
||||
// `block_epoch` advances for every block-definition change, so it
|
||||
// avoids re-scanning every block name on unrelated application updates.
|
||||
if self.block_palette.source_tab_id != Some(self.tabs[i].id)
|
||||
|| self.block_palette.source_block_epoch != self.tabs[i].scene.block_epoch
|
||||
{
|
||||
self.refresh_block_palette();
|
||||
}
|
||||
}
|
||||
|
||||
/// Rebuild the Reference Manager's entry list from the active drawing.
|
||||
/// Uses the tab's session sets (unloaded + stat baseline) so CLI and
|
||||
/// palette agree; writes fresh load-time mtimes back into the tab's stat
|
||||
@@ -1626,7 +1548,7 @@ mod tests {
|
||||
assert!(app.block_palette.blocks.iter().any(|b| b.name == "Fixture"));
|
||||
app.start_block_placement(&name);
|
||||
let cmd = app.tabs[app.active_tab].active_cmd.as_ref().expect("INSERT running");
|
||||
assert_eq!(cmd.name(), "INSERT");
|
||||
assert_eq!(cmd.name(), "-INSERT");
|
||||
assert_eq!(app.block_palette.placing.as_deref(), Some("Fixture"));
|
||||
}
|
||||
|
||||
|
||||
+27
-2
@@ -735,6 +735,14 @@ impl OpenCADStudio {
|
||||
grid_beyond_limits: self.grid_beyond_limits,
|
||||
block_mru: self.block_mru.clone(),
|
||||
block_freq: self.block_freq.clone(),
|
||||
block_recent: self.block_palette.recent.clone(),
|
||||
block_favorites: self.block_palette.favorites.clone(),
|
||||
block_libraries: self.block_palette.libraries.clone(),
|
||||
block_palette_view: self.block_palette.view as u8,
|
||||
block_insert: self.block_palette.options.clone(),
|
||||
block_mru_list: self.block_mru_list,
|
||||
block_redefine_mode: self.block_redefine_mode,
|
||||
block_navigate: self.block_navigate.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -744,7 +752,7 @@ impl OpenCADStudio {
|
||||
self.polar_mode = s.polar;
|
||||
self.polar_increment_deg = s.polar_increment_deg;
|
||||
self.zoom_wheel_reversed = s.zoom_wheel_reversed;
|
||||
self.zoom_factor = s.zoom_factor.clamp(3, 100);
|
||||
self.zoom_factor = s.zoom_factor.clamp(3, 500);
|
||||
self.cursor_size = s.cursor_size.clamp(1, 100);
|
||||
self.pick_box = s.pick_box.clamp(0, 50);
|
||||
self.options_tab = s.options_tab;
|
||||
@@ -856,6 +864,15 @@ impl OpenCADStudio {
|
||||
.take(200)
|
||||
.map(|(k, v)| (k.clone(), *v))
|
||||
.collect();
|
||||
self.block_mru_list = s.block_mru_list.min(100);
|
||||
self.block_redefine_mode = s.block_redefine_mode.min(2);
|
||||
self.block_navigate = s.block_navigate.clone();
|
||||
self.block_palette.recent = s.block_recent.clone();
|
||||
self.block_palette.favorites = s.block_favorites.clone();
|
||||
self.block_palette.libraries = s.block_libraries.clone();
|
||||
self.block_palette.view = crate::ui::window::block_palette::ViewMode::from_u8(s.block_palette_view);
|
||||
self.block_palette.set_options(s.block_insert.clone());
|
||||
self.trim_recent_blocks();
|
||||
// Push restored display defaults onto every drawing tab that exists now.
|
||||
// Tabs created later pick them up at their construction site.
|
||||
for idx in 0..self.tabs.len() {
|
||||
@@ -865,6 +882,8 @@ impl OpenCADStudio {
|
||||
}
|
||||
|
||||
pub(crate) fn record_block_insert(&mut self, name: &str) {
|
||||
self.insname = name.to_string();
|
||||
self.note_recent_block(name);
|
||||
let key = name.to_ascii_uppercase();
|
||||
*self.block_freq.entry(key).or_insert(0) += 1;
|
||||
self.block_mru.retain(|n| !n.eq_ignore_ascii_case(name));
|
||||
@@ -2012,7 +2031,13 @@ impl OpenCADStudio {
|
||||
}
|
||||
self.drain_pending_open()
|
||||
};
|
||||
Task::batch([thumbs_task, pending_open_task, interaction_task])
|
||||
let startup_script_task = if !self.pending_startup_script_lines.is_empty() {
|
||||
let lines = std::mem::take(&mut self.pending_startup_script_lines);
|
||||
Task::batch(lines.into_iter().map(|l| Task::done(Message::ScriptLine(l))))
|
||||
} else {
|
||||
Task::none()
|
||||
};
|
||||
Task::batch([thumbs_task, pending_open_task, interaction_task, startup_script_task])
|
||||
}
|
||||
|
||||
pub(super) fn on_wblock_save_result_some(
|
||||
|
||||
@@ -92,6 +92,7 @@ fn reorder_insertion_index(from: usize, to: usize, after: bool, len: usize) -> O
|
||||
|
||||
mod command;
|
||||
mod context_menu;
|
||||
mod blocks_palette;
|
||||
mod dialog;
|
||||
mod dynamic;
|
||||
mod file;
|
||||
@@ -988,6 +989,9 @@ impl OpenCADStudio {
|
||||
.extension()
|
||||
.map(|e| e.to_string_lossy().to_lowercase())
|
||||
.unwrap_or_default();
|
||||
if ext == "scr" {
|
||||
return self.dispatch_command(&format!("SCRIPT \"{}\"", path.display()));
|
||||
}
|
||||
if !matches!(ext.as_str(), "dwg" | "dxf" | "bak" | "sv$") {
|
||||
self.command_line.push_error(
|
||||
crate::tf!("Unsupported file type: {}", path.display()).as_ref(),
|
||||
@@ -2526,13 +2530,7 @@ impl OpenCADStudio {
|
||||
self.dispatch_command(&cmd)
|
||||
}
|
||||
|
||||
Message::ScriptLine(line) => {
|
||||
if line.trim().is_empty() {
|
||||
self.feed_command(crate::command::StepInput::Enter)
|
||||
} else {
|
||||
self.run_command_line(&line)
|
||||
}
|
||||
}
|
||||
Message::ScriptLine(line) => self.feed_script_line(&line),
|
||||
|
||||
Message::ToggleLayers => {
|
||||
if self.active_modal == Some(super::ModalKind::Layers) {
|
||||
@@ -5776,6 +5774,9 @@ impl OpenCADStudio {
|
||||
if self.tabs[self.active_tab].is_start {
|
||||
self.ribbon.close_dropdown();
|
||||
} else {
|
||||
if id == crate::modules::insert::insert_block::GALLERY_ID {
|
||||
self.refresh_block_palette();
|
||||
}
|
||||
self.ribbon.toggle_dropdown(&id);
|
||||
}
|
||||
Task::none()
|
||||
@@ -8247,7 +8248,7 @@ impl OpenCADStudio {
|
||||
}
|
||||
|
||||
Message::ZoomFactorChanged(factor) => {
|
||||
self.zoom_factor = factor.clamp(3, 100);
|
||||
self.zoom_factor = factor.clamp(3, 500);
|
||||
self.persist_settings_if_changed();
|
||||
Task::none()
|
||||
}
|
||||
|
||||
+110
-1
@@ -48,6 +48,11 @@ enum ScrollIntent {
|
||||
Pan { dx: f32, dy: f32 },
|
||||
}
|
||||
|
||||
/// How far a ZOOM Dynamic drag must travel vertically before the magnifier
|
||||
/// cursor flips between "+" and "-". Without the dead zone a hand holding
|
||||
/// still at the top of a drag strobes the glyph on every jittered pixel.
|
||||
const ZOOM_DIR_FLIP_PX: f32 = 2.0;
|
||||
|
||||
/// A wheel notch zooms; a trackpad's two fingers pan.
|
||||
///
|
||||
/// A wheel reports notches, a precise-scrolling device reports pixels, and that
|
||||
@@ -1238,7 +1243,14 @@ impl OpenCADStudio {
|
||||
self.tabs[i]
|
||||
.scene
|
||||
.record_nav_perf(crate::scene::NavPerfOp::Zoom, move_started);
|
||||
self.tabs[i].scene.selection.borrow_mut().middle_last_pos = Some(p);
|
||||
let mut sel = self.tabs[i].scene.selection.borrow_mut();
|
||||
// Latch the direction for the magnifier cursor. A dead
|
||||
// zone keeps the +/- glyph from strobing when a slow drag
|
||||
// wobbles a pixel either side of where it started.
|
||||
if dy.abs() >= ZOOM_DIR_FLIP_PX {
|
||||
sel.zoom_dir_out = dy > 0.0;
|
||||
}
|
||||
sel.middle_last_pos = Some(p);
|
||||
return Task::none();
|
||||
}
|
||||
// Shift+MMB drag orbits the model view instead of panning
|
||||
@@ -3358,6 +3370,7 @@ impl OpenCADStudio {
|
||||
|
||||
pub(super) fn on_viewport_left_press(&mut self) -> Task<Message> {
|
||||
let i = self.active_tab;
|
||||
self.block_palette.pressed = None;
|
||||
if let Some(command) = self.tabs[i].active_cmd.as_mut() {
|
||||
command.set_ctrl(self.ctrl_down);
|
||||
command.set_shift(self.shift_down);
|
||||
@@ -3473,6 +3486,9 @@ impl OpenCADStudio {
|
||||
sel.box_current = Some(p);
|
||||
sel.box_crossing = false;
|
||||
sel.box_crossing_locked = true;
|
||||
// A fresh drag starts neutral: the magnifier shows "+" until
|
||||
// the pointer has actually moved far enough to mean zoom out.
|
||||
sel.zoom_dir_out = false;
|
||||
}
|
||||
return Task::none();
|
||||
}
|
||||
@@ -3726,6 +3742,10 @@ impl OpenCADStudio {
|
||||
|
||||
pub(super) fn on_viewport_left_release(&mut self) -> Task<Message> {
|
||||
let i = self.active_tab;
|
||||
// A block dragged from the Blocks palette lands here.
|
||||
if let Some(item) = self.take_block_drop() {
|
||||
return self.drop_block(item);
|
||||
}
|
||||
if let Some(command) = self.tabs[i].active_cmd.as_mut() {
|
||||
command.set_ctrl(self.ctrl_down);
|
||||
command.set_shift(self.shift_down);
|
||||
@@ -3742,6 +3762,7 @@ impl OpenCADStudio {
|
||||
sel.box_anchor_world = None;
|
||||
sel.box_current = None;
|
||||
sel.box_crossing_locked = false;
|
||||
sel.zoom_dir_out = false;
|
||||
drop(sel);
|
||||
self.arm_hover_after_navigation(i);
|
||||
return Task::none();
|
||||
@@ -6729,6 +6750,94 @@ mod scroll_intent_tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod zoom_dynamic_cursor_tests {
|
||||
use super::*;
|
||||
use crate::app::OpenCADStudio;
|
||||
use crate::ui::overlay::NavCursor;
|
||||
|
||||
fn zoom_armed_app() -> OpenCADStudio {
|
||||
let mut app = OpenCADStudio::new_for_test();
|
||||
app.automation_op(r#"{"op":"new"}"#);
|
||||
app.tabs[0].scene.selection.borrow_mut().vp_size = (800.0, 600.0);
|
||||
let _ = app.dispatch_command("ZOOM DYNAMIC");
|
||||
app
|
||||
}
|
||||
|
||||
fn drag_to(app: &mut OpenCADStudio, y: f32) {
|
||||
let _ = app.update(Message::ViewportMove(Point::new(400.0, y)));
|
||||
}
|
||||
|
||||
fn zoom_dir_out(app: &OpenCADStudio) -> bool {
|
||||
app.tabs[0].scene.selection.borrow().zoom_dir_out
|
||||
}
|
||||
|
||||
/// The magnifier has to carry the sign of the zoom actually under way:
|
||||
/// dragging up zooms in, dragging back down zooms out.
|
||||
#[test]
|
||||
fn the_magnifier_follows_the_drag_direction() {
|
||||
let mut app = zoom_armed_app();
|
||||
assert!(app.tabs[0].zoom_dynamic_mode);
|
||||
drag_to(&mut app, 300.0);
|
||||
let _ = app.update(Message::ViewportLeftPress);
|
||||
// A fresh drag starts neutral — "+" until told otherwise.
|
||||
assert!(!zoom_dir_out(&app));
|
||||
|
||||
drag_to(&mut app, 200.0);
|
||||
assert!(!zoom_dir_out(&app), "dragging up zooms in");
|
||||
|
||||
drag_to(&mut app, 400.0);
|
||||
assert!(zoom_dir_out(&app), "dragging down zooms out");
|
||||
|
||||
drag_to(&mut app, 250.0);
|
||||
assert!(!zoom_dir_out(&app), "dragging up again zooms in");
|
||||
|
||||
// Releasing disarms the direction, so the next hover shows "+".
|
||||
let _ = app.update(Message::ViewportLeftRelease);
|
||||
assert!(!zoom_dir_out(&app));
|
||||
}
|
||||
|
||||
/// Sub-pixel wobble while a hand holds still must not strobe the glyph.
|
||||
#[test]
|
||||
fn jitter_below_the_dead_zone_does_not_flip_the_glyph() {
|
||||
let mut app = zoom_armed_app();
|
||||
drag_to(&mut app, 300.0);
|
||||
let _ = app.update(Message::ViewportLeftPress);
|
||||
drag_to(&mut app, 400.0);
|
||||
assert!(zoom_dir_out(&app));
|
||||
|
||||
// Back up by less than ZOOM_DIR_FLIP_PX: still zooming out.
|
||||
drag_to(&mut app, 399.0);
|
||||
assert!(zoom_dir_out(&app), "1 px of wobble must not flip the sign");
|
||||
}
|
||||
|
||||
/// Each navigation tool owns its own cursor. They used to share the pan
|
||||
/// hand, which read as "drag the sheet" during a zoom and an orbit.
|
||||
#[test]
|
||||
fn each_navigation_tool_reports_its_own_mode() {
|
||||
let mut app = OpenCADStudio::new_for_test();
|
||||
app.automation_op(r#"{"op":"new"}"#);
|
||||
for (command, want) in [
|
||||
("PAN", NavCursor::Pan),
|
||||
("ORBIT", NavCursor::Orbit),
|
||||
("ZOOM DYNAMIC", NavCursor::Zoom),
|
||||
] {
|
||||
let _ = app.dispatch_command(command);
|
||||
assert_eq!(
|
||||
crate::app::view::nav_cursor(&app.tabs[0]),
|
||||
want,
|
||||
"{command} must have its own cursor"
|
||||
);
|
||||
let _ = app.update(Message::CommandEscape);
|
||||
assert_eq!(
|
||||
crate::app::view::nav_cursor(&app.tabs[0]),
|
||||
NavCursor::None,
|
||||
"Esc must disarm {command}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod snap_elevation_tests {
|
||||
use super::snap_keeps_elevation;
|
||||
|
||||
+31
-3
@@ -45,6 +45,23 @@ fn crosshair_background(tab: &DocumentTab, is_paper: bool) -> [f32; 4] {
|
||||
tab.scene.paper_bg_color
|
||||
}
|
||||
|
||||
/// Which navigation tool the viewport cursor should advertise. The three flags
|
||||
/// are mutually exclusive — every command entry point clears all of them before
|
||||
/// arming one (`crate::app::commands`) — so the order here only settles a state
|
||||
/// that cannot occur.
|
||||
pub(in crate::app) fn nav_cursor(tab: &DocumentTab) -> crate::ui::overlay::NavCursor {
|
||||
use crate::ui::overlay::NavCursor;
|
||||
if tab.pan_mode {
|
||||
NavCursor::Pan
|
||||
} else if tab.orbit_mode {
|
||||
NavCursor::Orbit
|
||||
} else if tab.zoom_dynamic_mode {
|
||||
NavCursor::Zoom
|
||||
} else {
|
||||
NavCursor::None
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear gap (px) kept between the render-mode bar (top-left) and the ViewCube
|
||||
/// (top-right) before the cube is judged to collide and hides.
|
||||
const VIEWCUBE_GAP: f32 = 12.0;
|
||||
@@ -894,7 +911,7 @@ bg={bg_ms:.1}ms n={view_count}"
|
||||
dividers,
|
||||
pane_move_rect,
|
||||
pane_drop_rect,
|
||||
tab.pan_mode || tab.orbit_mode || tab.zoom_dynamic_mode,
|
||||
nav_cursor(tab),
|
||||
self.ribbon.open_dropdown.is_some(),
|
||||
hover_locked,
|
||||
crosshair_background(tab, is_paper),
|
||||
@@ -2316,7 +2333,17 @@ bg={bg_ms:.1}ms n={view_count}"
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let dropdown_layer: Element<'_, Message> = self
|
||||
let gallery_open = open_dropdown == Some(crate::modules::insert::insert_block::GALLERY_ID)
|
||||
&& !self.tabs[self.active_tab].is_start;
|
||||
let dropdown_layer: Element<'_, Message> = if gallery_open {
|
||||
self.ribbon.place_dropdown(
|
||||
crate::modules::insert::insert_block::GALLERY_ID,
|
||||
crate::ui::window::block_palette::gallery(&self.block_palette),
|
||||
crate::ui::window::block_palette::GALLERY_W,
|
||||
self.win_size.0,
|
||||
)
|
||||
} else {
|
||||
self
|
||||
.ribbon
|
||||
.dropdown_overlay(
|
||||
&undo_labels,
|
||||
@@ -2325,7 +2352,8 @@ bg={bg_ms:.1}ms n={view_count}"
|
||||
self.tabs[self.active_tab].is_start,
|
||||
&self.recent_colors,
|
||||
)
|
||||
.unwrap_or_else(|| iced::widget::Space::new().width(0).height(0).into());
|
||||
.unwrap_or_else(|| iced::widget::Space::new().width(0).height(0).into())
|
||||
};
|
||||
|
||||
let snap_override_layer: Element<'_, Message> = if let Some(pos) = self.snap_override_popup
|
||||
{
|
||||
|
||||
Reference in new issue
Block a user