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 <noreply@anthropic.com>
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

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