diff --git a/Cargo.toml b/Cargo.toml index a8045c25d..915fa648d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -98,7 +98,6 @@ opencadcodec = { git = "https://github.com/ramox81/cadcodec.git", rev = "595800e iced_core = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aad9e00b9327cb7b8546ed84db39" } iced_widget = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aad9e00b9327cb7b8546ed84db39" } - [target.'cfg(not(target_arch = "wasm32"))'.dependencies] getrandom = "0.3" ocs_plugin_api = { path = "crates/ocs_plugin_api", features = ["host"] } diff --git a/benches/performance_benchmarks.rs b/benches/performance_benchmarks.rs index ffae1f7a2..2cc078a0d 100644 --- a/benches/performance_benchmarks.rs +++ b/benches/performance_benchmarks.rs @@ -58,7 +58,7 @@ use OpenCADStudio::snap::Snapper; use OpenCADStudio::ui::icons::{self, CHECK}; use OpenCADStudio::ui::overlay::{ grid_segments, selection_overlay, should_reuse, CrosshairOptions, GridCanvasState, - GridKey, GridParams, GridStyle, GripMarker, OstTrackPoint, SelectionVisualOptions, + GridKey, GridParams, GridStyle, GripMarker, NavCursor, OstTrackPoint, SelectionVisualOptions, UcsIconParams, }; use OpenCADStudio::ui::properties::LinetypeItem; @@ -1512,7 +1512,7 @@ fn build_selection_overlay_element( vec![], None, None, - false, + NavCursor::None, false, false, [0.1, 0.1, 0.1, 1.0], diff --git a/locales/ar-SA/opencadstudio.ftl b/locales/ar-SA/opencadstudio.ftl index e6f68ac6a..eebcbccbf 100644 --- a/locales/ar-SA/opencadstudio.ftl +++ b/locales/ar-SA/opencadstudio.ftl @@ -4032,6 +4032,40 @@ insert = .pcs-positive = Requires a positive number. .pcs-positive-int = Requires a positive integer. .pcs-help = Traces the points on the kept side of a live section as lines. + .bp-add-favorite = Add to Favorites + .bp-annotative-dynamic = Annotative / Dynamic + .bp-auto-placement = Auto-Placement + .bp-gallery-libraries = Blocks from Libraries... + .bp-choose-library = Choose a library folder with … + .bp-tab-current = Current Drawing + .bp-heading-current = Current Drawing Blocks + .bp-view-xl = Extra Large Icons + .bp-heading-favorites = Favorite Blocks + .bp-gallery-favorites = Favorite Blocks... + .bp-tab-favorites = Favorites + .bp-filter = Filter... (* ? wildcards) + .bp-icon = Icon + .bp-insertion-point = Insertion Point + .bp-view-large = Large Icons + .bp-tab-libraries = Libraries + .bp-heading-libraries = Library Blocks + .bp-view-medium = Medium Icons + .bp-tab-recent = Recent + .bp-heading-recent = Recent Blocks + .bp-gallery-recent = Recent Blocks... + .bp-redefine = Redefine + .bp-remove-block = Remove Block + .bp-remove-favorite = Remove from Favorites + .bp-remove-list = Remove from List + .bp-repeat-placement = Repeat Placement + .bp-select-library = Select a Block Library Folder + .bp-view-small = Small Icons + .bp-on-screen = specified on screen + .bp-block-not-found = Block "__ocs_fmt_0__" not found. + .bp-block-redefined = Block "__ocs_fmt_0__" redefined. + .bp-block-no-reference = Block "__ocs_fmt_0__" has no reference in the drawing to edit. + .bp-block-referenced = Block "__ocs_fmt_0__" is referenced in the drawing and cannot be removed. + .bp-block-removed = Block "__ocs_fmt_0__" removed. .attdef-on-screen = Specify on-screen .attdef-insertion-point = Insertion Point .attdef-insert-field = Insert field diff --git a/locales/bg-BG/opencadstudio.ftl b/locales/bg-BG/opencadstudio.ftl index e4fbebcc8..69e552f00 100644 --- a/locales/bg-BG/opencadstudio.ftl +++ b/locales/bg-BG/opencadstudio.ftl @@ -4019,6 +4019,40 @@ insert = .pcs-positive = Requires a positive number. .pcs-positive-int = Requires a positive integer. .pcs-help = Traces the points on the kept side of a live section as lines. + .bp-add-favorite = Add to Favorites + .bp-annotative-dynamic = Annotative / Dynamic + .bp-auto-placement = Auto-Placement + .bp-gallery-libraries = Blocks from Libraries... + .bp-choose-library = Choose a library folder with … + .bp-tab-current = Current Drawing + .bp-heading-current = Current Drawing Blocks + .bp-view-xl = Extra Large Icons + .bp-heading-favorites = Favorite Blocks + .bp-gallery-favorites = Favorite Blocks... + .bp-tab-favorites = Favorites + .bp-filter = Filter... (* ? wildcards) + .bp-icon = Icon + .bp-insertion-point = Insertion Point + .bp-view-large = Large Icons + .bp-tab-libraries = Libraries + .bp-heading-libraries = Library Blocks + .bp-view-medium = Medium Icons + .bp-tab-recent = Recent + .bp-heading-recent = Recent Blocks + .bp-gallery-recent = Recent Blocks... + .bp-redefine = Redefine + .bp-remove-block = Remove Block + .bp-remove-favorite = Remove from Favorites + .bp-remove-list = Remove from List + .bp-repeat-placement = Repeat Placement + .bp-select-library = Select a Block Library Folder + .bp-view-small = Small Icons + .bp-on-screen = specified on screen + .bp-block-not-found = Block "__ocs_fmt_0__" not found. + .bp-block-redefined = Block "__ocs_fmt_0__" redefined. + .bp-block-no-reference = Block "__ocs_fmt_0__" has no reference in the drawing to edit. + .bp-block-referenced = Block "__ocs_fmt_0__" is referenced in the drawing and cannot be removed. + .bp-block-removed = Block "__ocs_fmt_0__" removed. .attdef-on-screen = Specify on-screen .attdef-insertion-point = Insertion Point .attdef-insert-field = Insert field diff --git a/locales/cs-CZ/opencadstudio.ftl b/locales/cs-CZ/opencadstudio.ftl index 2899fe9ad..03725d1f2 100644 --- a/locales/cs-CZ/opencadstudio.ftl +++ b/locales/cs-CZ/opencadstudio.ftl @@ -4017,6 +4017,40 @@ insert = .pcs-positive = Requires a positive number. .pcs-positive-int = Requires a positive integer. .pcs-help = Traces the points on the kept side of a live section as lines. + .bp-add-favorite = Add to Favorites + .bp-annotative-dynamic = Annotative / Dynamic + .bp-auto-placement = Auto-Placement + .bp-gallery-libraries = Blocks from Libraries... + .bp-choose-library = Choose a library folder with … + .bp-tab-current = Current Drawing + .bp-heading-current = Current Drawing Blocks + .bp-view-xl = Extra Large Icons + .bp-heading-favorites = Favorite Blocks + .bp-gallery-favorites = Favorite Blocks... + .bp-tab-favorites = Favorites + .bp-filter = Filter... (* ? wildcards) + .bp-icon = Icon + .bp-insertion-point = Insertion Point + .bp-view-large = Large Icons + .bp-tab-libraries = Libraries + .bp-heading-libraries = Library Blocks + .bp-view-medium = Medium Icons + .bp-tab-recent = Recent + .bp-heading-recent = Recent Blocks + .bp-gallery-recent = Recent Blocks... + .bp-redefine = Redefine + .bp-remove-block = Remove Block + .bp-remove-favorite = Remove from Favorites + .bp-remove-list = Remove from List + .bp-repeat-placement = Repeat Placement + .bp-select-library = Select a Block Library Folder + .bp-view-small = Small Icons + .bp-on-screen = specified on screen + .bp-block-not-found = Block "__ocs_fmt_0__" not found. + .bp-block-redefined = Block "__ocs_fmt_0__" redefined. + .bp-block-no-reference = Block "__ocs_fmt_0__" has no reference in the drawing to edit. + .bp-block-referenced = Block "__ocs_fmt_0__" is referenced in the drawing and cannot be removed. + .bp-block-removed = Block "__ocs_fmt_0__" removed. .attdef-on-screen = Specify on-screen .attdef-insertion-point = Insertion Point .attdef-insert-field = Insert field diff --git a/locales/de-DE/opencadstudio.ftl b/locales/de-DE/opencadstudio.ftl index 16cbf3cb4..c111960ae 100644 --- a/locales/de-DE/opencadstudio.ftl +++ b/locales/de-DE/opencadstudio.ftl @@ -4018,6 +4018,40 @@ insert = .pcs-positive = Requires a positive number. .pcs-positive-int = Requires a positive integer. .pcs-help = Traces the points on the kept side of a live section as lines. + .bp-add-favorite = Add to Favorites + .bp-annotative-dynamic = Annotative / Dynamic + .bp-auto-placement = Auto-Placement + .bp-gallery-libraries = Blocks from Libraries... + .bp-choose-library = Choose a library folder with … + .bp-tab-current = Current Drawing + .bp-heading-current = Current Drawing Blocks + .bp-view-xl = Extra Large Icons + .bp-heading-favorites = Favorite Blocks + .bp-gallery-favorites = Favorite Blocks... + .bp-tab-favorites = Favorites + .bp-filter = Filter... (* ? wildcards) + .bp-icon = Icon + .bp-insertion-point = Insertion Point + .bp-view-large = Large Icons + .bp-tab-libraries = Libraries + .bp-heading-libraries = Library Blocks + .bp-view-medium = Medium Icons + .bp-tab-recent = Recent + .bp-heading-recent = Recent Blocks + .bp-gallery-recent = Recent Blocks... + .bp-redefine = Redefine + .bp-remove-block = Remove Block + .bp-remove-favorite = Remove from Favorites + .bp-remove-list = Remove from List + .bp-repeat-placement = Repeat Placement + .bp-select-library = Select a Block Library Folder + .bp-view-small = Small Icons + .bp-on-screen = specified on screen + .bp-block-not-found = Block "__ocs_fmt_0__" not found. + .bp-block-redefined = Block "__ocs_fmt_0__" redefined. + .bp-block-no-reference = Block "__ocs_fmt_0__" has no reference in the drawing to edit. + .bp-block-referenced = Block "__ocs_fmt_0__" is referenced in the drawing and cannot be removed. + .bp-block-removed = Block "__ocs_fmt_0__" removed. .attdef-on-screen = Specify on-screen .attdef-insertion-point = Insertion Point .attdef-insert-field = Insert field diff --git a/locales/el-GR/opencadstudio.ftl b/locales/el-GR/opencadstudio.ftl index ec8a36752..0355311c9 100644 --- a/locales/el-GR/opencadstudio.ftl +++ b/locales/el-GR/opencadstudio.ftl @@ -4020,6 +4020,40 @@ insert = .pcs-positive = Requires a positive number. .pcs-positive-int = Requires a positive integer. .pcs-help = Traces the points on the kept side of a live section as lines. + .bp-add-favorite = Add to Favorites + .bp-annotative-dynamic = Annotative / Dynamic + .bp-auto-placement = Auto-Placement + .bp-gallery-libraries = Blocks from Libraries... + .bp-choose-library = Choose a library folder with … + .bp-tab-current = Current Drawing + .bp-heading-current = Current Drawing Blocks + .bp-view-xl = Extra Large Icons + .bp-heading-favorites = Favorite Blocks + .bp-gallery-favorites = Favorite Blocks... + .bp-tab-favorites = Favorites + .bp-filter = Filter... (* ? wildcards) + .bp-icon = Icon + .bp-insertion-point = Insertion Point + .bp-view-large = Large Icons + .bp-tab-libraries = Libraries + .bp-heading-libraries = Library Blocks + .bp-view-medium = Medium Icons + .bp-tab-recent = Recent + .bp-heading-recent = Recent Blocks + .bp-gallery-recent = Recent Blocks... + .bp-redefine = Redefine + .bp-remove-block = Remove Block + .bp-remove-favorite = Remove from Favorites + .bp-remove-list = Remove from List + .bp-repeat-placement = Repeat Placement + .bp-select-library = Select a Block Library Folder + .bp-view-small = Small Icons + .bp-on-screen = specified on screen + .bp-block-not-found = Block "__ocs_fmt_0__" not found. + .bp-block-redefined = Block "__ocs_fmt_0__" redefined. + .bp-block-no-reference = Block "__ocs_fmt_0__" has no reference in the drawing to edit. + .bp-block-referenced = Block "__ocs_fmt_0__" is referenced in the drawing and cannot be removed. + .bp-block-removed = Block "__ocs_fmt_0__" removed. .attdef-on-screen = Specify on-screen .attdef-insertion-point = Insertion Point .attdef-insert-field = Insert field diff --git a/locales/en-US/opencadstudio.ftl b/locales/en-US/opencadstudio.ftl index f0e040b93..f7d7c3a38 100644 --- a/locales/en-US/opencadstudio.ftl +++ b/locales/en-US/opencadstudio.ftl @@ -4021,6 +4021,40 @@ insert = .pcs-positive = Requires a positive number. .pcs-positive-int = Requires a positive integer. .pcs-help = Traces the points on the kept side of a live section as lines. + .bp-add-favorite = Add to Favorites + .bp-annotative-dynamic = Annotative / Dynamic + .bp-auto-placement = Auto-Placement + .bp-gallery-libraries = Blocks from Libraries... + .bp-choose-library = Choose a library folder with … + .bp-tab-current = Current Drawing + .bp-heading-current = Current Drawing Blocks + .bp-view-xl = Extra Large Icons + .bp-heading-favorites = Favorite Blocks + .bp-gallery-favorites = Favorite Blocks... + .bp-tab-favorites = Favorites + .bp-filter = Filter... (* ? wildcards) + .bp-icon = Icon + .bp-insertion-point = Insertion Point + .bp-view-large = Large Icons + .bp-tab-libraries = Libraries + .bp-heading-libraries = Library Blocks + .bp-view-medium = Medium Icons + .bp-tab-recent = Recent + .bp-heading-recent = Recent Blocks + .bp-gallery-recent = Recent Blocks... + .bp-redefine = Redefine + .bp-remove-block = Remove Block + .bp-remove-favorite = Remove from Favorites + .bp-remove-list = Remove from List + .bp-repeat-placement = Repeat Placement + .bp-select-library = Select a Block Library Folder + .bp-view-small = Small Icons + .bp-on-screen = specified on screen + .bp-block-not-found = Block "__ocs_fmt_0__" not found. + .bp-block-redefined = Block "__ocs_fmt_0__" redefined. + .bp-block-no-reference = Block "__ocs_fmt_0__" has no reference in the drawing to edit. + .bp-block-referenced = Block "__ocs_fmt_0__" is referenced in the drawing and cannot be removed. + .bp-block-removed = Block "__ocs_fmt_0__" removed. .attdef-on-screen = Specify on-screen .attdef-insertion-point = Insertion Point .attdef-insert-field = Insert field diff --git a/locales/es-ES/opencadstudio.ftl b/locales/es-ES/opencadstudio.ftl index d304992f1..c6d1a9cfa 100644 --- a/locales/es-ES/opencadstudio.ftl +++ b/locales/es-ES/opencadstudio.ftl @@ -4020,6 +4020,40 @@ insert = .pcs-positive = Requires a positive number. .pcs-positive-int = Requires a positive integer. .pcs-help = Traces the points on the kept side of a live section as lines. + .bp-add-favorite = Add to Favorites + .bp-annotative-dynamic = Annotative / Dynamic + .bp-auto-placement = Auto-Placement + .bp-gallery-libraries = Blocks from Libraries... + .bp-choose-library = Choose a library folder with … + .bp-tab-current = Current Drawing + .bp-heading-current = Current Drawing Blocks + .bp-view-xl = Extra Large Icons + .bp-heading-favorites = Favorite Blocks + .bp-gallery-favorites = Favorite Blocks... + .bp-tab-favorites = Favorites + .bp-filter = Filter... (* ? wildcards) + .bp-icon = Icon + .bp-insertion-point = Insertion Point + .bp-view-large = Large Icons + .bp-tab-libraries = Libraries + .bp-heading-libraries = Library Blocks + .bp-view-medium = Medium Icons + .bp-tab-recent = Recent + .bp-heading-recent = Recent Blocks + .bp-gallery-recent = Recent Blocks... + .bp-redefine = Redefine + .bp-remove-block = Remove Block + .bp-remove-favorite = Remove from Favorites + .bp-remove-list = Remove from List + .bp-repeat-placement = Repeat Placement + .bp-select-library = Select a Block Library Folder + .bp-view-small = Small Icons + .bp-on-screen = specified on screen + .bp-block-not-found = Block "__ocs_fmt_0__" not found. + .bp-block-redefined = Block "__ocs_fmt_0__" redefined. + .bp-block-no-reference = Block "__ocs_fmt_0__" has no reference in the drawing to edit. + .bp-block-referenced = Block "__ocs_fmt_0__" is referenced in the drawing and cannot be removed. + .bp-block-removed = Block "__ocs_fmt_0__" removed. .attdef-on-screen = Specify on-screen .attdef-insertion-point = Insertion Point .attdef-insert-field = Insert field diff --git a/locales/fi-FI/opencadstudio.ftl b/locales/fi-FI/opencadstudio.ftl index a0b803861..59459a0d0 100644 --- a/locales/fi-FI/opencadstudio.ftl +++ b/locales/fi-FI/opencadstudio.ftl @@ -4019,6 +4019,40 @@ insert = .pcs-positive = Requires a positive number. .pcs-positive-int = Requires a positive integer. .pcs-help = Traces the points on the kept side of a live section as lines. + .bp-add-favorite = Add to Favorites + .bp-annotative-dynamic = Annotative / Dynamic + .bp-auto-placement = Auto-Placement + .bp-gallery-libraries = Blocks from Libraries... + .bp-choose-library = Choose a library folder with … + .bp-tab-current = Current Drawing + .bp-heading-current = Current Drawing Blocks + .bp-view-xl = Extra Large Icons + .bp-heading-favorites = Favorite Blocks + .bp-gallery-favorites = Favorite Blocks... + .bp-tab-favorites = Favorites + .bp-filter = Filter... (* ? wildcards) + .bp-icon = Icon + .bp-insertion-point = Insertion Point + .bp-view-large = Large Icons + .bp-tab-libraries = Libraries + .bp-heading-libraries = Library Blocks + .bp-view-medium = Medium Icons + .bp-tab-recent = Recent + .bp-heading-recent = Recent Blocks + .bp-gallery-recent = Recent Blocks... + .bp-redefine = Redefine + .bp-remove-block = Remove Block + .bp-remove-favorite = Remove from Favorites + .bp-remove-list = Remove from List + .bp-repeat-placement = Repeat Placement + .bp-select-library = Select a Block Library Folder + .bp-view-small = Small Icons + .bp-on-screen = specified on screen + .bp-block-not-found = Block "__ocs_fmt_0__" not found. + .bp-block-redefined = Block "__ocs_fmt_0__" redefined. + .bp-block-no-reference = Block "__ocs_fmt_0__" has no reference in the drawing to edit. + .bp-block-referenced = Block "__ocs_fmt_0__" is referenced in the drawing and cannot be removed. + .bp-block-removed = Block "__ocs_fmt_0__" removed. .attdef-on-screen = Specify on-screen .attdef-insertion-point = Insertion Point .attdef-insert-field = Insert field diff --git a/locales/fr-FR/opencadstudio.ftl b/locales/fr-FR/opencadstudio.ftl index 63e30ebc5..8a6b6f1df 100644 --- a/locales/fr-FR/opencadstudio.ftl +++ b/locales/fr-FR/opencadstudio.ftl @@ -4020,6 +4020,40 @@ insert = .pcs-positive = Requires a positive number. .pcs-positive-int = Requires a positive integer. .pcs-help = Traces the points on the kept side of a live section as lines. + .bp-add-favorite = Add to Favorites + .bp-annotative-dynamic = Annotative / Dynamic + .bp-auto-placement = Auto-Placement + .bp-gallery-libraries = Blocks from Libraries... + .bp-choose-library = Choose a library folder with … + .bp-tab-current = Current Drawing + .bp-heading-current = Current Drawing Blocks + .bp-view-xl = Extra Large Icons + .bp-heading-favorites = Favorite Blocks + .bp-gallery-favorites = Favorite Blocks... + .bp-tab-favorites = Favorites + .bp-filter = Filter... (* ? wildcards) + .bp-icon = Icon + .bp-insertion-point = Insertion Point + .bp-view-large = Large Icons + .bp-tab-libraries = Libraries + .bp-heading-libraries = Library Blocks + .bp-view-medium = Medium Icons + .bp-tab-recent = Recent + .bp-heading-recent = Recent Blocks + .bp-gallery-recent = Recent Blocks... + .bp-redefine = Redefine + .bp-remove-block = Remove Block + .bp-remove-favorite = Remove from Favorites + .bp-remove-list = Remove from List + .bp-repeat-placement = Repeat Placement + .bp-select-library = Select a Block Library Folder + .bp-view-small = Small Icons + .bp-on-screen = specified on screen + .bp-block-not-found = Block "__ocs_fmt_0__" not found. + .bp-block-redefined = Block "__ocs_fmt_0__" redefined. + .bp-block-no-reference = Block "__ocs_fmt_0__" has no reference in the drawing to edit. + .bp-block-referenced = Block "__ocs_fmt_0__" is referenced in the drawing and cannot be removed. + .bp-block-removed = Block "__ocs_fmt_0__" removed. .attdef-on-screen = Specify on-screen .attdef-insertion-point = Insertion Point .attdef-insert-field = Insert field diff --git a/locales/hi-IN/opencadstudio.ftl b/locales/hi-IN/opencadstudio.ftl index e73aeaaca..bda484c8a 100644 --- a/locales/hi-IN/opencadstudio.ftl +++ b/locales/hi-IN/opencadstudio.ftl @@ -4008,6 +4008,40 @@ insert = .pcs-positive = Requires a positive number. .pcs-positive-int = Requires a positive integer. .pcs-help = Traces the points on the kept side of a live section as lines. + .bp-add-favorite = Add to Favorites + .bp-annotative-dynamic = Annotative / Dynamic + .bp-auto-placement = Auto-Placement + .bp-gallery-libraries = Blocks from Libraries... + .bp-choose-library = Choose a library folder with … + .bp-tab-current = Current Drawing + .bp-heading-current = Current Drawing Blocks + .bp-view-xl = Extra Large Icons + .bp-heading-favorites = Favorite Blocks + .bp-gallery-favorites = Favorite Blocks... + .bp-tab-favorites = Favorites + .bp-filter = Filter... (* ? wildcards) + .bp-icon = Icon + .bp-insertion-point = Insertion Point + .bp-view-large = Large Icons + .bp-tab-libraries = Libraries + .bp-heading-libraries = Library Blocks + .bp-view-medium = Medium Icons + .bp-tab-recent = Recent + .bp-heading-recent = Recent Blocks + .bp-gallery-recent = Recent Blocks... + .bp-redefine = Redefine + .bp-remove-block = Remove Block + .bp-remove-favorite = Remove from Favorites + .bp-remove-list = Remove from List + .bp-repeat-placement = Repeat Placement + .bp-select-library = Select a Block Library Folder + .bp-view-small = Small Icons + .bp-on-screen = specified on screen + .bp-block-not-found = Block "__ocs_fmt_0__" not found. + .bp-block-redefined = Block "__ocs_fmt_0__" redefined. + .bp-block-no-reference = Block "__ocs_fmt_0__" has no reference in the drawing to edit. + .bp-block-referenced = Block "__ocs_fmt_0__" is referenced in the drawing and cannot be removed. + .bp-block-removed = Block "__ocs_fmt_0__" removed. .attdef-on-screen = Specify on-screen .attdef-insertion-point = Insertion Point .attdef-insert-field = Insert field diff --git a/locales/hu-HU/opencadstudio.ftl b/locales/hu-HU/opencadstudio.ftl index 661f31f9a..7407c89e0 100644 --- a/locales/hu-HU/opencadstudio.ftl +++ b/locales/hu-HU/opencadstudio.ftl @@ -4019,6 +4019,40 @@ insert = .pcs-positive = Requires a positive number. .pcs-positive-int = Requires a positive integer. .pcs-help = Traces the points on the kept side of a live section as lines. + .bp-add-favorite = Add to Favorites + .bp-annotative-dynamic = Annotative / Dynamic + .bp-auto-placement = Auto-Placement + .bp-gallery-libraries = Blocks from Libraries... + .bp-choose-library = Choose a library folder with … + .bp-tab-current = Current Drawing + .bp-heading-current = Current Drawing Blocks + .bp-view-xl = Extra Large Icons + .bp-heading-favorites = Favorite Blocks + .bp-gallery-favorites = Favorite Blocks... + .bp-tab-favorites = Favorites + .bp-filter = Filter... (* ? wildcards) + .bp-icon = Icon + .bp-insertion-point = Insertion Point + .bp-view-large = Large Icons + .bp-tab-libraries = Libraries + .bp-heading-libraries = Library Blocks + .bp-view-medium = Medium Icons + .bp-tab-recent = Recent + .bp-heading-recent = Recent Blocks + .bp-gallery-recent = Recent Blocks... + .bp-redefine = Redefine + .bp-remove-block = Remove Block + .bp-remove-favorite = Remove from Favorites + .bp-remove-list = Remove from List + .bp-repeat-placement = Repeat Placement + .bp-select-library = Select a Block Library Folder + .bp-view-small = Small Icons + .bp-on-screen = specified on screen + .bp-block-not-found = Block "__ocs_fmt_0__" not found. + .bp-block-redefined = Block "__ocs_fmt_0__" redefined. + .bp-block-no-reference = Block "__ocs_fmt_0__" has no reference in the drawing to edit. + .bp-block-referenced = Block "__ocs_fmt_0__" is referenced in the drawing and cannot be removed. + .bp-block-removed = Block "__ocs_fmt_0__" removed. .attdef-on-screen = Specify on-screen .attdef-insertion-point = Insertion Point .attdef-insert-field = Insert field diff --git a/locales/it-IT/opencadstudio.ftl b/locales/it-IT/opencadstudio.ftl index 076e47305..88420cc86 100644 --- a/locales/it-IT/opencadstudio.ftl +++ b/locales/it-IT/opencadstudio.ftl @@ -4019,6 +4019,40 @@ insert = .pcs-positive = Requires a positive number. .pcs-positive-int = Requires a positive integer. .pcs-help = Traces the points on the kept side of a live section as lines. + .bp-add-favorite = Add to Favorites + .bp-annotative-dynamic = Annotative / Dynamic + .bp-auto-placement = Auto-Placement + .bp-gallery-libraries = Blocks from Libraries... + .bp-choose-library = Choose a library folder with … + .bp-tab-current = Current Drawing + .bp-heading-current = Current Drawing Blocks + .bp-view-xl = Extra Large Icons + .bp-heading-favorites = Favorite Blocks + .bp-gallery-favorites = Favorite Blocks... + .bp-tab-favorites = Favorites + .bp-filter = Filter... (* ? wildcards) + .bp-icon = Icon + .bp-insertion-point = Insertion Point + .bp-view-large = Large Icons + .bp-tab-libraries = Libraries + .bp-heading-libraries = Library Blocks + .bp-view-medium = Medium Icons + .bp-tab-recent = Recent + .bp-heading-recent = Recent Blocks + .bp-gallery-recent = Recent Blocks... + .bp-redefine = Redefine + .bp-remove-block = Remove Block + .bp-remove-favorite = Remove from Favorites + .bp-remove-list = Remove from List + .bp-repeat-placement = Repeat Placement + .bp-select-library = Select a Block Library Folder + .bp-view-small = Small Icons + .bp-on-screen = specified on screen + .bp-block-not-found = Block "__ocs_fmt_0__" not found. + .bp-block-redefined = Block "__ocs_fmt_0__" redefined. + .bp-block-no-reference = Block "__ocs_fmt_0__" has no reference in the drawing to edit. + .bp-block-referenced = Block "__ocs_fmt_0__" is referenced in the drawing and cannot be removed. + .bp-block-removed = Block "__ocs_fmt_0__" removed. .attdef-on-screen = Specify on-screen .attdef-insertion-point = Insertion Point .attdef-insert-field = Insert field diff --git a/locales/ja-JP/opencadstudio.ftl b/locales/ja-JP/opencadstudio.ftl index d6d39f90e..068528c47 100644 --- a/locales/ja-JP/opencadstudio.ftl +++ b/locales/ja-JP/opencadstudio.ftl @@ -4008,6 +4008,40 @@ insert = .pcs-positive = Requires a positive number. .pcs-positive-int = Requires a positive integer. .pcs-help = Traces the points on the kept side of a live section as lines. + .bp-add-favorite = Add to Favorites + .bp-annotative-dynamic = Annotative / Dynamic + .bp-auto-placement = Auto-Placement + .bp-gallery-libraries = Blocks from Libraries... + .bp-choose-library = Choose a library folder with … + .bp-tab-current = Current Drawing + .bp-heading-current = Current Drawing Blocks + .bp-view-xl = Extra Large Icons + .bp-heading-favorites = Favorite Blocks + .bp-gallery-favorites = Favorite Blocks... + .bp-tab-favorites = Favorites + .bp-filter = Filter... (* ? wildcards) + .bp-icon = Icon + .bp-insertion-point = Insertion Point + .bp-view-large = Large Icons + .bp-tab-libraries = Libraries + .bp-heading-libraries = Library Blocks + .bp-view-medium = Medium Icons + .bp-tab-recent = Recent + .bp-heading-recent = Recent Blocks + .bp-gallery-recent = Recent Blocks... + .bp-redefine = Redefine + .bp-remove-block = Remove Block + .bp-remove-favorite = Remove from Favorites + .bp-remove-list = Remove from List + .bp-repeat-placement = Repeat Placement + .bp-select-library = Select a Block Library Folder + .bp-view-small = Small Icons + .bp-on-screen = specified on screen + .bp-block-not-found = Block "__ocs_fmt_0__" not found. + .bp-block-redefined = Block "__ocs_fmt_0__" redefined. + .bp-block-no-reference = Block "__ocs_fmt_0__" has no reference in the drawing to edit. + .bp-block-referenced = Block "__ocs_fmt_0__" is referenced in the drawing and cannot be removed. + .bp-block-removed = Block "__ocs_fmt_0__" removed. .attdef-on-screen = Specify on-screen .attdef-insertion-point = Insertion Point .attdef-insert-field = Insert field diff --git a/locales/ko-KR/opencadstudio.ftl b/locales/ko-KR/opencadstudio.ftl index 2d0268da0..791d86503 100644 --- a/locales/ko-KR/opencadstudio.ftl +++ b/locales/ko-KR/opencadstudio.ftl @@ -4019,6 +4019,40 @@ insert = .pcs-positive = Requires a positive number. .pcs-positive-int = Requires a positive integer. .pcs-help = Traces the points on the kept side of a live section as lines. + .bp-add-favorite = Add to Favorites + .bp-annotative-dynamic = Annotative / Dynamic + .bp-auto-placement = Auto-Placement + .bp-gallery-libraries = Blocks from Libraries... + .bp-choose-library = Choose a library folder with … + .bp-tab-current = Current Drawing + .bp-heading-current = Current Drawing Blocks + .bp-view-xl = Extra Large Icons + .bp-heading-favorites = Favorite Blocks + .bp-gallery-favorites = Favorite Blocks... + .bp-tab-favorites = Favorites + .bp-filter = Filter... (* ? wildcards) + .bp-icon = Icon + .bp-insertion-point = Insertion Point + .bp-view-large = Large Icons + .bp-tab-libraries = Libraries + .bp-heading-libraries = Library Blocks + .bp-view-medium = Medium Icons + .bp-tab-recent = Recent + .bp-heading-recent = Recent Blocks + .bp-gallery-recent = Recent Blocks... + .bp-redefine = Redefine + .bp-remove-block = Remove Block + .bp-remove-favorite = Remove from Favorites + .bp-remove-list = Remove from List + .bp-repeat-placement = Repeat Placement + .bp-select-library = Select a Block Library Folder + .bp-view-small = Small Icons + .bp-on-screen = specified on screen + .bp-block-not-found = Block "__ocs_fmt_0__" not found. + .bp-block-redefined = Block "__ocs_fmt_0__" redefined. + .bp-block-no-reference = Block "__ocs_fmt_0__" has no reference in the drawing to edit. + .bp-block-referenced = Block "__ocs_fmt_0__" is referenced in the drawing and cannot be removed. + .bp-block-removed = Block "__ocs_fmt_0__" removed. .attdef-on-screen = Specify on-screen .attdef-insertion-point = Insertion Point .attdef-insert-field = Insert field diff --git a/locales/nl-NL/opencadstudio.ftl b/locales/nl-NL/opencadstudio.ftl index 3d132c8dd..b8076a424 100644 --- a/locales/nl-NL/opencadstudio.ftl +++ b/locales/nl-NL/opencadstudio.ftl @@ -4018,6 +4018,40 @@ insert = .pcs-positive = Requires a positive number. .pcs-positive-int = Requires a positive integer. .pcs-help = Traces the points on the kept side of a live section as lines. + .bp-add-favorite = Add to Favorites + .bp-annotative-dynamic = Annotative / Dynamic + .bp-auto-placement = Auto-Placement + .bp-gallery-libraries = Blocks from Libraries... + .bp-choose-library = Choose a library folder with … + .bp-tab-current = Current Drawing + .bp-heading-current = Current Drawing Blocks + .bp-view-xl = Extra Large Icons + .bp-heading-favorites = Favorite Blocks + .bp-gallery-favorites = Favorite Blocks... + .bp-tab-favorites = Favorites + .bp-filter = Filter... (* ? wildcards) + .bp-icon = Icon + .bp-insertion-point = Insertion Point + .bp-view-large = Large Icons + .bp-tab-libraries = Libraries + .bp-heading-libraries = Library Blocks + .bp-view-medium = Medium Icons + .bp-tab-recent = Recent + .bp-heading-recent = Recent Blocks + .bp-gallery-recent = Recent Blocks... + .bp-redefine = Redefine + .bp-remove-block = Remove Block + .bp-remove-favorite = Remove from Favorites + .bp-remove-list = Remove from List + .bp-repeat-placement = Repeat Placement + .bp-select-library = Select a Block Library Folder + .bp-view-small = Small Icons + .bp-on-screen = specified on screen + .bp-block-not-found = Block "__ocs_fmt_0__" not found. + .bp-block-redefined = Block "__ocs_fmt_0__" redefined. + .bp-block-no-reference = Block "__ocs_fmt_0__" has no reference in the drawing to edit. + .bp-block-referenced = Block "__ocs_fmt_0__" is referenced in the drawing and cannot be removed. + .bp-block-removed = Block "__ocs_fmt_0__" removed. .attdef-on-screen = Specify on-screen .attdef-insertion-point = Insertion Point .attdef-insert-field = Insert field diff --git a/locales/pl-PL/opencadstudio.ftl b/locales/pl-PL/opencadstudio.ftl index de75a9ba0..49d04e6fe 100644 --- a/locales/pl-PL/opencadstudio.ftl +++ b/locales/pl-PL/opencadstudio.ftl @@ -4019,6 +4019,40 @@ insert = .pcs-positive = Requires a positive number. .pcs-positive-int = Requires a positive integer. .pcs-help = Traces the points on the kept side of a live section as lines. + .bp-add-favorite = Add to Favorites + .bp-annotative-dynamic = Annotative / Dynamic + .bp-auto-placement = Auto-Placement + .bp-gallery-libraries = Blocks from Libraries... + .bp-choose-library = Choose a library folder with … + .bp-tab-current = Current Drawing + .bp-heading-current = Current Drawing Blocks + .bp-view-xl = Extra Large Icons + .bp-heading-favorites = Favorite Blocks + .bp-gallery-favorites = Favorite Blocks... + .bp-tab-favorites = Favorites + .bp-filter = Filter... (* ? wildcards) + .bp-icon = Icon + .bp-insertion-point = Insertion Point + .bp-view-large = Large Icons + .bp-tab-libraries = Libraries + .bp-heading-libraries = Library Blocks + .bp-view-medium = Medium Icons + .bp-tab-recent = Recent + .bp-heading-recent = Recent Blocks + .bp-gallery-recent = Recent Blocks... + .bp-redefine = Redefine + .bp-remove-block = Remove Block + .bp-remove-favorite = Remove from Favorites + .bp-remove-list = Remove from List + .bp-repeat-placement = Repeat Placement + .bp-select-library = Select a Block Library Folder + .bp-view-small = Small Icons + .bp-on-screen = specified on screen + .bp-block-not-found = Block "__ocs_fmt_0__" not found. + .bp-block-redefined = Block "__ocs_fmt_0__" redefined. + .bp-block-no-reference = Block "__ocs_fmt_0__" has no reference in the drawing to edit. + .bp-block-referenced = Block "__ocs_fmt_0__" is referenced in the drawing and cannot be removed. + .bp-block-removed = Block "__ocs_fmt_0__" removed. .attdef-on-screen = Specify on-screen .attdef-insertion-point = Insertion Point .attdef-insert-field = Insert field diff --git a/locales/pt-BR/opencadstudio.ftl b/locales/pt-BR/opencadstudio.ftl index e6747180f..30e2b6a98 100644 --- a/locales/pt-BR/opencadstudio.ftl +++ b/locales/pt-BR/opencadstudio.ftl @@ -4020,6 +4020,40 @@ insert = .pcs-positive = Requires a positive number. .pcs-positive-int = Requires a positive integer. .pcs-help = Traces the points on the kept side of a live section as lines. + .bp-add-favorite = Add to Favorites + .bp-annotative-dynamic = Annotative / Dynamic + .bp-auto-placement = Auto-Placement + .bp-gallery-libraries = Blocks from Libraries... + .bp-choose-library = Choose a library folder with … + .bp-tab-current = Current Drawing + .bp-heading-current = Current Drawing Blocks + .bp-view-xl = Extra Large Icons + .bp-heading-favorites = Favorite Blocks + .bp-gallery-favorites = Favorite Blocks... + .bp-tab-favorites = Favorites + .bp-filter = Filter... (* ? wildcards) + .bp-icon = Icon + .bp-insertion-point = Insertion Point + .bp-view-large = Large Icons + .bp-tab-libraries = Libraries + .bp-heading-libraries = Library Blocks + .bp-view-medium = Medium Icons + .bp-tab-recent = Recent + .bp-heading-recent = Recent Blocks + .bp-gallery-recent = Recent Blocks... + .bp-redefine = Redefine + .bp-remove-block = Remove Block + .bp-remove-favorite = Remove from Favorites + .bp-remove-list = Remove from List + .bp-repeat-placement = Repeat Placement + .bp-select-library = Select a Block Library Folder + .bp-view-small = Small Icons + .bp-on-screen = specified on screen + .bp-block-not-found = Block "__ocs_fmt_0__" not found. + .bp-block-redefined = Block "__ocs_fmt_0__" redefined. + .bp-block-no-reference = Block "__ocs_fmt_0__" has no reference in the drawing to edit. + .bp-block-referenced = Block "__ocs_fmt_0__" is referenced in the drawing and cannot be removed. + .bp-block-removed = Block "__ocs_fmt_0__" removed. .attdef-on-screen = Specify on-screen .attdef-insertion-point = Insertion Point .attdef-insert-field = Insert field diff --git a/locales/ru-RU/opencadstudio.ftl b/locales/ru-RU/opencadstudio.ftl index 6b65c9a04..b0e1badaa 100644 --- a/locales/ru-RU/opencadstudio.ftl +++ b/locales/ru-RU/opencadstudio.ftl @@ -4021,6 +4021,40 @@ insert = .pcs-positive = Requires a positive number. .pcs-positive-int = Requires a positive integer. .pcs-help = Traces the points on the kept side of a live section as lines. + .bp-add-favorite = Add to Favorites + .bp-annotative-dynamic = Annotative / Dynamic + .bp-auto-placement = Auto-Placement + .bp-gallery-libraries = Blocks from Libraries... + .bp-choose-library = Choose a library folder with … + .bp-tab-current = Current Drawing + .bp-heading-current = Current Drawing Blocks + .bp-view-xl = Extra Large Icons + .bp-heading-favorites = Favorite Blocks + .bp-gallery-favorites = Favorite Blocks... + .bp-tab-favorites = Favorites + .bp-filter = Filter... (* ? wildcards) + .bp-icon = Icon + .bp-insertion-point = Insertion Point + .bp-view-large = Large Icons + .bp-tab-libraries = Libraries + .bp-heading-libraries = Library Blocks + .bp-view-medium = Medium Icons + .bp-tab-recent = Recent + .bp-heading-recent = Recent Blocks + .bp-gallery-recent = Recent Blocks... + .bp-redefine = Redefine + .bp-remove-block = Remove Block + .bp-remove-favorite = Remove from Favorites + .bp-remove-list = Remove from List + .bp-repeat-placement = Repeat Placement + .bp-select-library = Select a Block Library Folder + .bp-view-small = Small Icons + .bp-on-screen = specified on screen + .bp-block-not-found = Block "__ocs_fmt_0__" not found. + .bp-block-redefined = Block "__ocs_fmt_0__" redefined. + .bp-block-no-reference = Block "__ocs_fmt_0__" has no reference in the drawing to edit. + .bp-block-referenced = Block "__ocs_fmt_0__" is referenced in the drawing and cannot be removed. + .bp-block-removed = Block "__ocs_fmt_0__" removed. .attdef-on-screen = Specify on-screen .attdef-insertion-point = Insertion Point .attdef-insert-field = Insert field diff --git a/locales/tr-TR/opencadstudio.ftl b/locales/tr-TR/opencadstudio.ftl index aacafb9bd..9e11988b6 100644 --- a/locales/tr-TR/opencadstudio.ftl +++ b/locales/tr-TR/opencadstudio.ftl @@ -3980,6 +3980,40 @@ insert = .pcs-positive = Pozitif bir sayı gerekir. .pcs-positive-int = Pozitif bir tamsayı gerekir. .pcs-help = Canlı kesitin korunan tarafındaki noktaları çizgilerle izler. + .bp-add-favorite = Sık kullanılanlara ekle + .bp-annotative-dynamic = Açıklayıcı / Dinamik + .bp-auto-placement = Otomatik yerleştirme + .bp-gallery-libraries = Kütüphanelerden bloklar... + .bp-choose-library = … ile bir kütüphane klasörü seçin + .bp-tab-current = Geçerli Çizim + .bp-heading-current = Geçerli çizimdeki bloklar + .bp-view-xl = Çok büyük simgeler + .bp-heading-favorites = Sık kullanılan bloklar + .bp-gallery-favorites = Sık kullanılan bloklar... + .bp-tab-favorites = Sık Kullanılan + .bp-filter = Filtre... (* ? joker) + .bp-icon = Simge + .bp-insertion-point = Ekleme noktası + .bp-view-large = Büyük simgeler + .bp-tab-libraries = Kütüphaneler + .bp-heading-libraries = Kütüphane blokları + .bp-view-medium = Orta simgeler + .bp-tab-recent = Son Kullanılan + .bp-heading-recent = Son kullanılan bloklar + .bp-gallery-recent = Son kullanılan bloklar... + .bp-redefine = Yeniden tanımla + .bp-remove-block = Bloğu kaldır + .bp-remove-favorite = Sık kullanılanlardan çıkar + .bp-remove-list = Listeden kaldır + .bp-repeat-placement = Yerleştirmeyi yinele + .bp-select-library = Blok kütüphanesi klasörü seçin + .bp-view-small = Küçük simgeler + .bp-on-screen = ekranda belirtilir + .bp-block-not-found = "__ocs_fmt_0__" bloğu bulunamadı. + .bp-block-redefined = "__ocs_fmt_0__" bloğu yeniden tanımlandı. + .bp-block-no-reference = "__ocs_fmt_0__" bloğunun çizimde düzenlenecek bir başvurusu yok. + .bp-block-referenced = "__ocs_fmt_0__" bloğu çizimde kullanılıyor, kaldırılamaz. + .bp-block-removed = "__ocs_fmt_0__" bloğu kaldırıldı. .attdef-on-screen = Ekranda belirt .attdef-insertion-point = Ekleme Noktası .attdef-insert-field = Alan ekle diff --git a/locales/zh-CN/opencadstudio.ftl b/locales/zh-CN/opencadstudio.ftl index c36d3ea01..ab519c48a 100644 --- a/locales/zh-CN/opencadstudio.ftl +++ b/locales/zh-CN/opencadstudio.ftl @@ -4003,6 +4003,40 @@ insert = .pcs-positive = Requires a positive number. .pcs-positive-int = Requires a positive integer. .pcs-help = Traces the points on the kept side of a live section as lines. + .bp-add-favorite = Add to Favorites + .bp-annotative-dynamic = Annotative / Dynamic + .bp-auto-placement = Auto-Placement + .bp-gallery-libraries = Blocks from Libraries... + .bp-choose-library = Choose a library folder with … + .bp-tab-current = Current Drawing + .bp-heading-current = Current Drawing Blocks + .bp-view-xl = Extra Large Icons + .bp-heading-favorites = Favorite Blocks + .bp-gallery-favorites = Favorite Blocks... + .bp-tab-favorites = Favorites + .bp-filter = Filter... (* ? wildcards) + .bp-icon = Icon + .bp-insertion-point = Insertion Point + .bp-view-large = Large Icons + .bp-tab-libraries = Libraries + .bp-heading-libraries = Library Blocks + .bp-view-medium = Medium Icons + .bp-tab-recent = Recent + .bp-heading-recent = Recent Blocks + .bp-gallery-recent = Recent Blocks... + .bp-redefine = Redefine + .bp-remove-block = Remove Block + .bp-remove-favorite = Remove from Favorites + .bp-remove-list = Remove from List + .bp-repeat-placement = Repeat Placement + .bp-select-library = Select a Block Library Folder + .bp-view-small = Small Icons + .bp-on-screen = specified on screen + .bp-block-not-found = Block "__ocs_fmt_0__" not found. + .bp-block-redefined = Block "__ocs_fmt_0__" redefined. + .bp-block-no-reference = Block "__ocs_fmt_0__" has no reference in the drawing to edit. + .bp-block-referenced = Block "__ocs_fmt_0__" is referenced in the drawing and cannot be removed. + .bp-block-removed = Block "__ocs_fmt_0__" removed. .attdef-on-screen = Specify on-screen .attdef-insertion-point = Insertion Point .attdef-insert-field = Insert field diff --git a/locales/zh-TW/opencadstudio.ftl b/locales/zh-TW/opencadstudio.ftl index 609a3d99a..3782082b2 100644 --- a/locales/zh-TW/opencadstudio.ftl +++ b/locales/zh-TW/opencadstudio.ftl @@ -4015,6 +4015,40 @@ insert = .pcs-positive = Requires a positive number. .pcs-positive-int = Requires a positive integer. .pcs-help = Traces the points on the kept side of a live section as lines. + .bp-add-favorite = Add to Favorites + .bp-annotative-dynamic = Annotative / Dynamic + .bp-auto-placement = Auto-Placement + .bp-gallery-libraries = Blocks from Libraries... + .bp-choose-library = Choose a library folder with … + .bp-tab-current = Current Drawing + .bp-heading-current = Current Drawing Blocks + .bp-view-xl = Extra Large Icons + .bp-heading-favorites = Favorite Blocks + .bp-gallery-favorites = Favorite Blocks... + .bp-tab-favorites = Favorites + .bp-filter = Filter... (* ? wildcards) + .bp-icon = Icon + .bp-insertion-point = Insertion Point + .bp-view-large = Large Icons + .bp-tab-libraries = Libraries + .bp-heading-libraries = Library Blocks + .bp-view-medium = Medium Icons + .bp-tab-recent = Recent + .bp-heading-recent = Recent Blocks + .bp-gallery-recent = Recent Blocks... + .bp-redefine = Redefine + .bp-remove-block = Remove Block + .bp-remove-favorite = Remove from Favorites + .bp-remove-list = Remove from List + .bp-repeat-placement = Repeat Placement + .bp-select-library = Select a Block Library Folder + .bp-view-small = Small Icons + .bp-on-screen = specified on screen + .bp-block-not-found = Block "__ocs_fmt_0__" not found. + .bp-block-redefined = Block "__ocs_fmt_0__" redefined. + .bp-block-no-reference = Block "__ocs_fmt_0__" has no reference in the drawing to edit. + .bp-block-referenced = Block "__ocs_fmt_0__" is referenced in the drawing and cannot be removed. + .bp-block-removed = Block "__ocs_fmt_0__" removed. .attdef-on-screen = Specify on-screen .attdef-insertion-point = Insertion Point .attdef-insert-field = Insert field diff --git a/src/app/command_driver/entity_commit.rs b/src/app/command_driver/entity_commit.rs index 8aa9f3672..b2802535d 100644 --- a/src/app/command_driver/entity_commit.rs +++ b/src/app/command_driver/entity_commit.rs @@ -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 { diff --git a/src/app/command_driver/mod.rs b/src/app/command_driver/mod.rs index 34cbd7f4f..76ba3bfb0 100644 --- a/src/app/command_driver/mod.rs +++ b/src/app/command_driver/mod.rs @@ -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; diff --git a/src/app/command_driver/utilities.rs b/src/app/command_driver/utilities.rs index b3ee8e4c3..f80076737 100644 --- a/src/app/command_driver/utilities.rs +++ b/src/app/command_driver/utilities.rs @@ -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 { + 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) + } } diff --git a/src/app/commands/blocks.rs b/src/app/commands/blocks.rs index 7f671436b..4c10b5e5f 100644 --- a/src/app/commands/blocks.rs +++ b/src/app/commands/blocks.rs @@ -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 ` 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); } diff --git a/src/app/commands/blockvars.rs b/src/app/commands/blockvars.rs new file mode 100644 index 000000000..fae4f4eae --- /dev/null +++ b/src/app/commands/blockvars.rs @@ -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> { + 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::() { + 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()) + } +} diff --git a/src/app/commands/dim.rs b/src/app/commands/dim.rs index 770007d25..2bc34742a 100644 --- a/src/app/commands/dim.rs +++ b/src/app/commands/dim.rs @@ -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 { diff --git a/src/app/commands/fileops.rs b/src/app/commands/fileops.rs index 1a5d74f76..4dd2ed284 100644 --- a/src/app/commands/fileops.rs +++ b/src/app/commands/fileops.rs @@ -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> = 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> = 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 ").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); + } +} diff --git a/src/app/commands/mod.rs b/src/app/commands/mod.rs index 5a0ce5a8a..934650290 100644 --- a/src/app/commands/mod.rs +++ b/src/app/commands/mod.rs @@ -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) +} diff --git a/src/app/commands/styleprops.rs b/src/app/commands/styleprops.rs index 8479f551a..bde923ef3 100644 --- a/src/app/commands/styleprops.rs +++ b/src/app/commands/styleprops.rs @@ -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::() { - 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)) diff --git a/src/app/control/actions.rs b/src/app/control/actions.rs index 136c621f3..eeeabb515 100644 --- a/src/app/control/actions.rs +++ b/src/app/control/actions.rs @@ -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=", "insert=", + // "drop=@x,y", "menu=:". An item is + // "current:NAME", "recent:N", "fav:N", "lib:N" or "file:". + "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 { + let (kind, rest) = spec.split_once(':').unwrap_or((spec, "")); + let index = || rest.parse::().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 = 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 = string(req, "value")? diff --git a/src/app/control/entities.rs b/src/app/control/entities.rs index 7eb5acd64..c4da959ac 100644 --- a/src/app/control/entities.rs +++ b/src/app/control/entities.rs @@ -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; }; diff --git a/src/app/control/mod.rs b/src/app/control/mod.rs index cab09f40a..cef6959fd 100644 --- a/src/app/control/mod.rs +++ b/src/app/control/mod.rs @@ -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; diff --git a/src/app/mod.rs b/src/app/mod.rs index 9c3e0776b..8bedce650 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -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, /// Insertion frequency per block name (uppercase key → count), capped. block_freq: std::collections::HashMap, + /// 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, @@ -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, + pub(super) pending_startup_script_lines: Vec, /// 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 = if !cfg.files.is_empty() { + let has_files = !cfg.files.is_empty(); + let cli_open: Task = 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 = 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 diff --git a/src/app/options_session.rs b/src/app/options_session.rs index b6f8d0378..d0245f64b 100644 --- a/src/app/options_session.rs +++ b/src/app/options_session.rs @@ -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"); + } } diff --git a/src/app/settings.rs b/src/app/settings.rs index c1a60ea3b..530d660a5 100644 --- a/src/app/settings.rs +++ b/src/app/settings.rs @@ -548,6 +548,96 @@ pub struct UserSettings { /// Insertion frequency per block name (uppercase key → count). #[serde(default)] pub block_freq: std::collections::HashMap, + /// Blocks palette: the recent list (newest first, BLOCKMRULIST long). + #[serde(default)] + pub block_recent: Vec, + /// Blocks palette: favorites, kept locally. + #[serde(default)] + pub block_favorites: Vec, + /// Blocks palette: library folders or drawings, last used first. + #[serde(default)] + pub block_libraries: Vec, + /// 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(), } } } diff --git a/src/app/update/blocks_palette.rs b/src/app/update/blocks_palette.rs new file mode 100644 index 000000000..8c88eb728 --- /dev/null +++ b/src/app/update/blocks_palette.rs @@ -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 { + 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) { + 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 = 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::().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) -> Task { + 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) -> Task { + 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 { + 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 { + 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 { + 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 { + 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 { + self.block_palette.pressed.take() + } + + pub(super) fn drop_block(&mut self, item: Item) -> Task { + 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 = 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 = 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 } + } +} diff --git a/src/app/update/dialog.rs b/src/app/update/dialog.rs index 00d1b8b9e..943ab3d4a 100644 --- a/src/app/update/dialog.rs +++ b/src/app/update/dialog.rs @@ -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 { + pub(in crate::app) fn import_file_as_block(&mut self, path: std::path::PathBuf) -> Result { + 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 { 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 { + self.import_document_block(doc, stem, false) + } + + fn import_document_block( + &mut self, + doc: codec::CadDocument, + stem: String, + redefine: bool, ) -> Result { 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 { - 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 { @@ -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")); } diff --git a/src/app/update/file.rs b/src/app/update/file.rs index d1407d8d6..00ad0d722 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -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( diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index cd3f93700..5712ed8fb 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -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() } diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index 7dabd8108..8200e8882 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -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 { 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 { 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; diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index 6399320d8..ee8e0d7fc 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -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 { diff --git a/src/command.rs b/src/command.rs index f8a5abfe2..0ba7f8953 100644 --- a/src/command.rs +++ b/src/command.rs @@ -1888,6 +1888,12 @@ pub enum CmdResult { ReportError(String), /// Reports an error and ends the command (`Lines are parallel.`). CancelWithMessage(String), + /// Explode this block reference and commit its pieces; end the command + /// unless `keep_going` (repeated placement). + CommitExplodedInsert { + insert: EntityType, + keep_going: bool, + }, /// Print a measurement result, clear the current selection, and keep the command active. ReportMeasurementAndDeselect(String), /// Clear the current selection and keep the command active at its updated step. @@ -2932,6 +2938,12 @@ pub trait CadCommand: Send { None } + /// After the host commits an ATTREQ insert: keep the command running + /// (repeated placement) instead of ending it. + fn attreq_continue(&self) -> bool { + false + } + /// Returns the INSERT entity built so far (pending attr fill) if this is an /// ATTREQ-aware INSERT command waiting for attdef injection. /// Called by the host after `AttreqNeeded` to commit the completed Insert. diff --git a/src/entities/attribute.rs b/src/entities/attribute.rs index aa382cc08..43d87f683 100644 --- a/src/entities/attribute.rs +++ b/src/entities/attribute.rs @@ -713,7 +713,7 @@ impl PropertyEditable for AttributeDefinition { return; } match field { - "att_tag" => self.tag = value.trim().to_string(), + "att_tag" => self.tag = value.trim().to_uppercase(), "att_prompt" if !self.flags.constant => self.prompt = value.to_string(), "att_default" => { self.default_value = value.to_string(); diff --git a/src/entities/raster_image.rs b/src/entities/raster_image.rs index 87a51dab4..2de0fc5ff 100644 --- a/src/entities/raster_image.rs +++ b/src/entities/raster_image.rs @@ -142,9 +142,11 @@ impl RenderConvertible for RasterImage { // Diagonals are the BROKEN-reference placeholder; a resolvable image // draws its pixels inside the frame, so the X would scribble over it. - let path_probe = self.file_path.trim(); - let resolvable = path_probe.is_empty() - || crate::scene::model::image_model::resolve_image(path_probe).is_some(); + // One probe for the whole conversion: the outline below and the + // placeholder branch further down must agree on a single answer. + let path = self.file_path.trim(); + let resolvable = + path.is_empty() || crate::scene::model::image_model::resolve_image(path).is_some(); // Outline: the clip boundary, or — when it hides its inside — the // frame with the boundary as a hole. Must match the raster's own clip // triangulation in `ImageModel` so outline and pixels align. @@ -166,16 +168,15 @@ impl RenderConvertible for RasterImage { // A raster OCS can display renders its pixels (built separately) inside // this frame — just draw the frame/clip outline. A reference it cannot - // resolve (an offline/broken URL, a missing or renamed file) gets - // AutoCAD's broken-reference treatment: the frame plus the saved path - // drawn as text, so the user sees WHICH reference is unresolved instead - // of an empty box. `resolve_image` is memoised and shared with the - // raster loader, so a URL that fetches online is treated as resolvable - // (no placeholder — the image shows) while an offline one falls back to - // the path text, and neither is fetched twice. - let path = self.file_path.trim(); - let resolvable = - path.is_empty() || crate::scene::model::image_model::resolve_image(path).is_some(); + // resolve (an offline/broken URL, a missing or renamed file) gets the + // usual broken-reference treatment: the frame plus the saved path drawn + // as text, so the user sees WHICH reference is unresolved instead of an + // empty box. `resolve_image` is memoised and shared with the raster + // loader, so a URL that fetches online is treated as resolvable (no + // placeholder — the image shows) while an offline one falls back to the + // path text, and neither is fetched twice. The scene's text-cache + // classifier runs the same probe, so erasing this placeholder drops its + // glyphs from the text buffer instead of leaving them on screen. if resolvable { return Some(RenderEntity { // Picked on its frame only (shown or hidden), like the other diff --git a/src/locale_catalog.rs b/src/locale_catalog.rs index 74a88413f..e04d085d6 100644 --- a/src/locale_catalog.rs +++ b/src/locale_catalog.rs @@ -163,8 +163,41 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static "Requires a positive number." => Some(("insert", "pcs-positive")), "Requires a positive integer." => Some(("insert", "pcs-positive-int")), "Traces the points on the kept side of a live section as lines." => Some(("insert", "pcs-help")), + "Add to Favorites" => Some(("insert", "bp-add-favorite")), + "Annotative / Dynamic" => Some(("insert", "bp-annotative-dynamic")), + "Auto-Placement" => Some(("insert", "bp-auto-placement")), + "Blocks from Libraries..." => Some(("insert", "bp-gallery-libraries")), + "Choose a library folder with …" => Some(("insert", "bp-choose-library")), + "Current Drawing" => Some(("insert", "bp-tab-current")), + "Current Drawing Blocks" => Some(("insert", "bp-heading-current")), + "Extra Large Icons" => Some(("insert", "bp-view-xl")), + "Favorite Blocks" => Some(("insert", "bp-heading-favorites")), + "Favorite Blocks..." => Some(("insert", "bp-gallery-favorites")), + "Favorites" => Some(("insert", "bp-tab-favorites")), + "Filter... (* ? wildcards)" => Some(("insert", "bp-filter")), + "Icon" => Some(("insert", "bp-icon")), + "Insertion Point" => Some(("insert", "bp-insertion-point")), + "Large Icons" => Some(("insert", "bp-view-large")), + "Libraries" => Some(("insert", "bp-tab-libraries")), + "Library Blocks" => Some(("insert", "bp-heading-libraries")), + "Medium Icons" => Some(("insert", "bp-view-medium")), + "Recent" => Some(("insert", "bp-tab-recent")), + "Recent Blocks" => Some(("insert", "bp-heading-recent")), + "Recent Blocks..." => Some(("insert", "bp-gallery-recent")), + "Redefine" => Some(("insert", "bp-redefine")), + "Remove Block" => Some(("insert", "bp-remove-block")), + "Remove from Favorites" => Some(("insert", "bp-remove-favorite")), + "Remove from List" => Some(("insert", "bp-remove-list")), + "Repeat Placement" => Some(("insert", "bp-repeat-placement")), + "Select a Block Library Folder" => Some(("insert", "bp-select-library")), + "Small Icons" => Some(("insert", "bp-view-small")), + "specified on screen" => Some(("insert", "bp-on-screen")), + "Block \"{}\" not found." => Some(("insert", "bp-block-not-found")), + "Block \"{name}\" redefined." => Some(("insert", "bp-block-redefined")), + "Block \"{name}\" has no reference in the drawing to edit." => Some(("insert", "bp-block-no-reference")), + "Block \"{name}\" is referenced in the drawing and cannot be removed." => Some(("insert", "bp-block-referenced")), + "Block \"{name}\" removed." => Some(("insert", "bp-block-removed")), "Specify on-screen" => Some(("insert", "attdef-on-screen")), - "Insertion Point" => Some(("insert", "attdef-insertion-point")), "Insert field" => Some(("insert", "attdef-insert-field")), "Text Settings" => Some(("insert", "attdef-text-settings")), "Align below previous attribute definition" => Some(("insert", "attdef-align-below")), diff --git a/src/modules/draw/mod.rs b/src/modules/draw/mod.rs index b5297b206..049e8e76d 100644 --- a/src/modules/draw/mod.rs +++ b/src/modules/draw/mod.rs @@ -203,7 +203,7 @@ impl CadModule for DrawModule { // Slide-out: Define / Manage / Synchronize Attributes and // Set Base Point. tools: vec![ - RibbonItem::LargeTool(insert_block::tool()), + insert_block::gallery(), RibbonItem::Tool(create_block::tool()), RibbonItem::Tool(edit_block::tool()), RibbonItem::Tool(attedit::tool()), diff --git a/src/modules/draw/modify/trim.rs b/src/modules/draw/modify/trim.rs index 37db81855..130151dad 100644 --- a/src/modules/draw/modify/trim.rs +++ b/src/modules/draw/modify/trim.rs @@ -1108,6 +1108,162 @@ fn extract_sub_polyline(poly: &LwPolyline, s0: f64, s1: f64) -> Option Option<(Curve, bool)> { + let n = poly.vertices.len(); + if n < 2 { + return None; + } + + let a = &poly.vertices[segment % n]; + let b = &poly.vertices[(segment + 1) % n]; + + let p0 = [a.location.x, a.location.y]; + let p1 = [b.location.x, b.location.y]; + + if (p1[0] - p0[0]).hypot(p1[1] - p0[1]) < 1e-12 { + return None; + } + + if let Some(arc) = BulgeArc::from_bulge(p0, p1, a.bulge) { + if arc.sweep >= 0.0 { + Some(( + Curve::Arc(KernelArc { + centre: arc.center, + radius: arc.radius, + start_angle: arc.start_angle, + end_angle: arc.start_angle + arc.sweep, + }), + false, + )) + } else { + Some(( + Curve::Arc(KernelArc { + centre: arc.center, + radius: arc.radius, + start_angle: arc.end_angle, + end_angle: arc.end_angle - arc.sweep, + }), + true, + )) + } + } else { + Some(( + Curve::Line(KernelLine { + start: p0, + end: p1, + }), + false, + )) + } +} + +/// Global polyline parameters at which non-adjacent segments of the same +/// polyline intersect. +/// +/// Segment neighbours are deliberately excluded: their common vertex is a +/// normal polyline vertex, not a self-intersection. A polyline cuts itself +/// only when it is one of the cutting edges. +fn lwpoly_self_cut_params(poly: &LwPolyline, geos: &[Geo]) -> Vec { + let n = poly.vertices.len(); + if n < 3 || !geos.iter().any(|geo| geo.handle() == poly.common.handle) { + return Vec::new(); + } + + let closed = poly.is_closed; + let segment_count = if closed { n } else { n - 1 }; + let total = segment_count as f64; + + let mut cuts = Vec::new(); + + for i in 0..segment_count { + let Some((curve_a, reverse_a)) = lwpoly_segment_curve(poly, i) else { + continue; + }; + + for j in (i + 1)..segment_count { + // Ordinary neighbouring segments meet at their common vertex. + let adjacent = j == i + 1 + || (closed && i == 0 && j + 1 == segment_count); + + if adjacent { + continue; + } + + let Some((curve_b, reverse_b)) = lwpoly_segment_curve(poly, j) else { + continue; + }; + + let hits = kernel_intersect( + &curve_a, + &curve_b, + KernelTolerance::new(CUT_TOLERANCE), + ); + + for hit in hits { + let mut ua = hit.t_a; + let mut ub = hit.t_b; + + if reverse_a { + ua = 1.0 - ua; + } + if reverse_b { + ub = 1.0 - ub; + } + + if !ua.is_finite() + || !ub.is_finite() + || ua < -1e-7 + || ua > 1.0 + 1e-7 + || ub < -1e-7 + || ub > 1.0 + 1e-7 + { + continue; + } + + ua = ua.clamp(0.0, 1.0); + ub = ub.clamp(0.0, 1.0); + + // If both sides only touch at existing vertices, those + // vertices already act as trim limits below. + let a_endpoint = ua <= 1e-7 || ua >= 1.0 - 1e-7; + let b_endpoint = ub <= 1e-7 || ub >= 1.0 - 1e-7; + + if a_endpoint && b_endpoint { + continue; + } + + let pa = i as f64 + ua; + let pb = j as f64 + ub; + + cuts.push(if closed { + pa.rem_euclid(total) + } else { + pa + }); + + cuts.push(if closed { + pb.rem_euclid(total) + } else { + pb + }); + } + } + } + + cuts.sort_by(f64::total_cmp); + cuts.dedup_by(|a, b| (*a - *b).abs() < 1e-6); + cuts +} + /// Trim a clicked LwPolyline: remove the portion containing the click, bounded /// by the nearest boundary intersections on each side. A closed polyline needs /// ≥2 cuts and becomes an open polyline (the surviving arc); an open one yields @@ -1131,18 +1287,32 @@ fn trim_lwpolyline(poly: &LwPolyline, cx: f64, cy: f64, geos: &[Geo]) -> Option< poly.vertices[i % n].bulge }; - // Boundary cuts as global params (segment index + local u). + // External boundary cuts as global params (segment index + local u). let mut cuts: Vec = Vec::new(); + for i in 0..seg_count { let p0 = vx(i); let p1 = vx(i + 1); let b = seg_bulge(i); + for u in polyline_seg_ts(p0, p1, b, handle, geos) { let param = i as f64 + u.clamp(0.0, 1.0); - cuts.push(if closed { param.rem_euclid(total) } else { param }); + + cuts.push(if closed { + param.rem_euclid(total) + } else { + param + }); } } - cuts.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); + + // A polyline can also cut itself. These are intersections between + // non-adjacent segments belonging to this same LwPolyline. Its vertices + // are not cuts: the trimmed piece runs on past corners to the next + // cutting edge, and a polyline no edge crosses is left alone. + cuts.extend(lwpoly_self_cut_params(poly, geos)); + + cuts.sort_by(f64::total_cmp); cuts.dedup_by(|a, b| (*a - *b).abs() < 1e-6); if cuts.is_empty() { return None; @@ -1589,9 +1759,6 @@ enum TrimMode { struct CrossingWindow { min: [f64; 2], max: [f64; 2], - /// The first corner is the trim-side hint, matching the side from which - /// the crossing window was dragged. - pick: [f64; 2], } fn segment_window_range( @@ -1650,6 +1817,11 @@ fn crossing_trim_lwpolyline( }; let mut removed = Vec::<(f64, f64)>::new(); + + // Compute the polyline's own non-adjacent segment intersections once. + // These global parameters will later be mapped back into each source segment. + let self_cuts = lwpoly_self_cut_params(poly, geos); + for i in 0..seg_count { let a = vertex_xy(i); let b = vertex_xy(i + 1); @@ -1678,42 +1850,54 @@ fn crossing_trim_lwpolyline( let Some((inside_lo, inside_hi)) = window_range else { continue; }; - let cuts = polyline_seg_ts(a, b, bulge, handle, geos); - if cuts.is_empty() { - continue; + // External intersections on this segment. + let mut cuts = polyline_seg_ts(a, b, bulge, handle, geos); + + // Also include self-intersections belonging to this source segment. + // `lwpoly_self_cut_params()` stores them as global polyline parameters: + // segment_index + local_parameter + // + // Convert the ones belonging to segment `i` back to local 0..1. + let seg_start = i as f64; + let seg_end = seg_start + 1.0; + + for global_t in self_cuts.iter().copied() { + if global_t > seg_start + 1e-6 + && global_t < seg_end - 1e-6 + { + cuts.push(global_t - seg_start); + } } - let pick_t = if let Some(ba) = BulgeArc::from_bulge(a, b, bulge) { - let angle = (window.pick[1] - ba.center[1]).atan2(window.pick[0] - ba.center[0]); - let travelled = if ba.sweep >= 0.0 { - (angle - ba.start_angle).rem_euclid(TAU) - } else { - -((ba.start_angle - angle).rem_euclid(TAU)) - }; - (travelled / ba.sweep).clamp(inside_lo, inside_hi) - } else { - let dx = b[0] - a[0]; - let dy = b[1] - a[1]; - let len2 = dx * dx + dy * dy; - let projected = if len2 > 1e-12 { - ((window.pick[0] - a[0]) * dx + (window.pick[1] - a[1]) * dy) / len2 - } else { - (inside_lo + inside_hi) * 0.5 - }; - projected.clamp(inside_lo, inside_hi) - }; + // The source segment endpoints are also valid trim limits. + cuts.push(0.0); + cuts.push(1.0); - let mut bounds = vec![0.0]; - bounds.extend(cuts); - bounds.push(1.0); - bounds.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); - bounds.dedup_by(|a, b| (*a - *b).abs() < 1e-6); - if let Some(span) = bounds - .windows(2) - .find(|span| pick_t >= span[0] - 1e-6 && pick_t <= span[1] + 1e-6) - { - if span[1] - span[0] > 1e-6 { - removed.push((i as f64 + span[0], i as f64 + span[1])); + cuts.sort_by(f64::total_cmp); + cuts.dedup_by(|a, b| (*a - *b).abs() < 1e-6); + + // Remove every trim span that is actually touched by the selection box. + // + // A single source segment may contain several sub-spans when external + // boundaries cross it. Only the sub-spans overlapping the window are removed. + for span in cuts.windows(2) { + let lo = span[0].clamp(0.0, 1.0); + let hi = span[1].clamp(0.0, 1.0); + + if hi - lo <= 1e-6 { + continue; + } + + // This trim span participates when its parameter interval overlaps the + // portion of the source segment lying inside the crossing rectangle. + let overlaps_window = + hi >= inside_lo - 1e-6 && lo <= inside_hi + 1e-6; + + if overlaps_window { + removed.push(( + i as f64 + lo, + i as f64 + hi, + )); } } } @@ -2730,11 +2914,7 @@ impl CadCommand for TrimCommand { if !matches!(self.mode, TrimMode::Pick) || fence.len() < 2 { return None; } - let window = window.map(|(min, max)| CrossingWindow { - min, - max, - pick: fence[0], - }); + let window = window.map(|(min, max)| CrossingWindow { min, max }); let replacements = fence_pass( &self.all_entities, &self.geos, @@ -3114,7 +3294,6 @@ impl CadCommand for TrimCommand { let window = CrossingWindow { min: [p1[0].min(p2[0]), p1[1].min(p2[1])], max: [p1[0].max(p2[0]), p1[1].max(p2[1])], - pick: p1, }; self.fence_run(&rect, Some(window)) } @@ -3146,7 +3325,6 @@ impl CadCommand for TrimCommand { let window = CrossingWindow { min: [p1[0].min(p2[0]), p1[1].min(p2[1])], max: [p1[0].max(p2[0]), p1[1].max(p2[1])], - pick: p1, }; out.extend(fence_result_preview( &self.all_entities, @@ -3580,7 +3758,6 @@ impl CadCommand for ExtendCommand { let window = CrossingWindow { min: [p1[0].min(p2[0]), p1[1].min(p2[1])], max: [p1[0].max(p2[0]), p1[1].max(p2[1])], - pick: p1, }; self.fence_run(&rect, Some(window)) } @@ -3612,7 +3789,6 @@ impl CadCommand for ExtendCommand { let window = CrossingWindow { min: [p1[0].min(p2[0]), p1[1].min(p2[1])], max: [p1[0].max(p2[0]), p1[1].max(p2[1])], - pick: p1, }; out.extend(fence_result_preview( &self.all_entities, diff --git a/src/modules/insert/insert_block.rs b/src/modules/insert/insert_block.rs index 2bf879675..c504cab75 100644 --- a/src/modules/insert/insert_block.rs +++ b/src/modules/insert/insert_block.rs @@ -1,27 +1,98 @@ +//! Block insertion: `-INSERT` on the command line, a click on the Blocks +//! palette, and paste-as-block. +//! +//! `-INSERT` follows the reference's prompts: +//! Enter block name or [?] : +//! Units: Feet Conversion: 1.0000 +//! Specify insertion point or [Basepoint/Scale/X/Y/Z/Rotate/Explode/REpeat]: +//! Enter X scale factor, specify opposite corner, or [Corner/XYZ] <1>: +//! Enter Y scale factor : +//! Specify rotation angle <0>: +//! A palette click asks only what its options leave open, with the shorter +//! option list `[Basepoint/Scale/X/Y/Z/Rotate]`. + use codec::entities::{AttributeDefinition, AttributeEntity, Entity, Insert}; use codec::types::Vector3; use codec::EntityType; use glam::{DVec3, Vec3}; use crate::t; -use crate::command::{CadCommand, CmdResult, InputKind, WorkingPlane}; -use crate::modules::{IconKind, ModuleEvent, ToolDef}; +use crate::app::settings::BlockInsertOptions; +use crate::command::{CadCommand, CmdResult, DynField, InputKind, WorkingPlane}; +use crate::modules::IconKind; use crate::scene::model::wire_model::WireModel; -pub fn tool() -> ToolDef { - ToolDef { - id: "INSERT", +/// The ribbon's Insert button: its face opens the Blocks palette, its arrow +/// the gallery of the drawing's blocks. +pub const GALLERY_ID: &str = "INSERT_GALLERY"; +pub const ICON: IconKind = IconKind::Svg(include_bytes!("../../../assets/icons/blocks/insert.svg")); +pub const GALLERY_ITEMS: &[(&str, &str, IconKind)] = &[("INSERT", "Insert Block", ICON)]; + +pub fn gallery() -> crate::modules::RibbonItem { + crate::modules::RibbonItem::LargeDropdown { + id: GALLERY_ID, label: "Insert Block", - icon: IconKind::Svg(include_bytes!("../../../assets/icons/blocks/insert.svg")), - event: ModuleEvent::Command("INSERT".to_string()), + icon: ICON, + items: GALLERY_ITEMS.to_vec(), + default: "INSERT", } } +/// What the drawing knows about its blocks, for `-INSERT`'s name step. +#[derive(Clone, Default)] +pub struct BlockCatalog { + /// Named (user) blocks, sorted. + pub user: Vec, + /// How many anonymous (`*`) blocks the drawing holds. + pub unnamed: usize, + /// Each user block's `Units: … Conversion: …` line. + pub units: rustc_hash::FxHashMap, + /// The folder `-INSERT` reports as its search path. + pub search_path: String, +} + +#[derive(Clone, Copy, PartialEq)] +enum Mode { + /// `-INSERT`. + Classic, + /// A Blocks palette click. + Palette, + /// Paste-as-block: only the drop point. + Paste, +} + +#[derive(Clone, Copy, PartialEq, Debug)] +enum Preset { + Base, + Scale, + X, + Y, + Z, + Rotate, +} + +#[derive(Clone, Copy, PartialEq, Debug)] +enum Ask { + Explode, + Repeat, + RepeatScale, + RepeatRotate, +} + enum Step { Name, - Point { - name: String, - }, + ListPattern, + Point, + Preset(Preset), + YesNo(Ask), + ScaleX, + Corner, + XyzX, + XyzY, + XyzZ, + ScaleY, + Uniform, + Rotation, FillAttr { attdefs: Vec, idx: usize, @@ -29,80 +100,299 @@ enum Step { }, } -/// Which numeric value the insertion-point step is currently waiting for after -/// a Scale / Rotate keyword. -#[derive(Clone, Copy)] -enum AwaitKind { - Scale, - Rotation, -} - pub struct InsertBlockCommand { + mode: Mode, picker: crate::modules::insert::picker::BlockPicker, + catalog: BlockCatalog, + default_name: String, step: Step, - /// Uniform X/Y scale applied to the placed block (default 1). - x_scale: f64, - y_scale: f64, - /// Rotation applied to the placed block, in radians (default 0). - rotation_rad: f64, - /// Set while a Scale/Rotate value is being typed at the insertion step. - awaiting: Option, - /// Pending Insert entity stored while attr-filling is in progress. + name: String, + /// Block preview geometry, in block coordinates. + preview: Vec, + /// The point of the block that lands on the insertion point (Basepoint). + base: DVec3, + /// Scales / rotation fixed before the point (options, palette fields). + preset_scale: [Option; 3], + preset_rotation: Option, + /// Palette: the scale is asked after the point, uniformly. + ask_uniform: bool, + explode: bool, + repeat: bool, + repeat_scale: bool, + repeat_rotation: bool, + placed: usize, + point: Option, + scale: [f64; 3], + rotation: f64, pending_insert: Option, - /// Optional drag preview: the block's wire geometry plus the base point it - /// is measured from, so `on_preview_wires` can rubber-band it to the - /// cursor. Set by paste-as-block; empty for a plain INSERT. - preview: Option<(Vec, Vec3)>, plane: WorkingPlane, } impl InsertBlockCommand { - /// Construct with explicit usage ranking. `usage` maps uppercase block name → (frequency, MRU position). - /// `cliprompt_lines` directly controls suggestion count (relation per spec). - pub fn new_with_usage( + fn base_state(mode: Mode, picker: crate::modules::insert::picker::BlockPicker) -> Self { + Self { + mode, + picker, + catalog: BlockCatalog::default(), + default_name: String::new(), + step: Step::Name, + name: String::new(), + preview: Vec::new(), + base: DVec3::ZERO, + preset_scale: [None; 3], + preset_rotation: None, + ask_uniform: false, + explode: false, + repeat: false, + repeat_scale: false, + repeat_rotation: false, + placed: 0, + point: None, + scale: [1.0; 3], + rotation: 0.0, + pending_insert: None, + plane: WorkingPlane::default(), + } + } + + /// `-INSERT`: the name step first. `default_name` is INSNAME. + pub fn classic( available: Vec, usage_rank: rustc_hash::FxHashMap, cliprompt_lines: u8, + catalog: BlockCatalog, + default_name: String, ) -> Self { let limit = (cliprompt_lines as usize).clamp(0, crate::modules::insert::picker::MAX_SUGGESTIONS); let picker = crate::modules::insert::picker::BlockPicker::new(available, usage_rank, limit); - Self { - picker, - step: Step::Name, - x_scale: 1.0, - y_scale: 1.0, - rotation_rad: 0.0, - awaiting: None, - pending_insert: None, - preview: None, - plane: WorkingPlane::default(), - } + let mut command = Self::base_state(Mode::Classic, picker); + command.catalog = catalog; + command.default_name = default_name; + command } - /// Start the command already locked to `name`, skipping the name prompt and - /// going straight to "specify insertion point". `preview_wires` (measured - /// from `base`) rubber-band under the cursor. Used by paste-as-block, which - /// has just defined the block and only needs the drop point. + /// `-INSERT` already given a block (a drawing just inserted as a block). + pub fn classic_for_block(name: String, preview: Vec, units_line: String) -> Self { + let mut command = Self::base_state(Mode::Classic, Self::single_picker(&name)); + command.catalog.units.insert(name.to_ascii_uppercase(), units_line); + command.name = name; + command.preview = preview; + command.step = Step::Point; + command + } + + /// A Blocks palette click with the palette's options. + pub fn palette(name: String, preview: Vec, options: &BlockInsertOptions) -> Self { + let mut command = Self::base_state(Mode::Palette, Self::single_picker(&name)); + command.name = name; + command.preview = preview; + if !options.scale { + let x = options.x; + command.preset_scale = if options.uniform { + [Some(x); 3] + } else { + [Some(options.x), Some(options.y), Some(options.z)] + }; + } else { + command.ask_uniform = options.uniform; + } + if !options.rotation { + command.preset_rotation = Some(options.angle.to_radians()); + } + command.explode = options.explode; + command.repeat = options.repeat; + command.repeat_scale = options.scale; + command.repeat_rotation = options.rotation; + command.step = Step::Point; + if !options.insertion_point { + command.point = Some(DVec3::ZERO); + } + command + } + + /// Paste-as-block: the block is placed at the picked point. pub fn new_for_block(name: String, preview_wires: Vec, base: Vec3) -> Self { - // Minimal picker for the locked-name path; not used for Name step. - let picker = crate::modules::insert::picker::BlockPicker::new( - vec![name.clone()], + let mut command = Self::base_state(Mode::Paste, Self::single_picker(&name)); + command.name = name; + command.preview = preview_wires; + command.base = base.as_dvec3(); + command.preset_scale = [Some(1.0); 3]; + command.preset_rotation = Some(0.0); + command.step = Step::Point; + command + } + + fn single_picker(name: &str) -> crate::modules::insert::picker::BlockPicker { + crate::modules::insert::picker::BlockPicker::new( + vec![name.to_string()], rustc_hash::FxHashMap::default(), 0, - ); - Self { - picker, - step: Step::Point { name }, - x_scale: 1.0, - y_scale: 1.0, - rotation_rad: 0.0, - awaiting: None, - pending_insert: None, - preview: Some((preview_wires, base)), - plane: WorkingPlane::default(), + ) + } + + /// A block dropped from the palette: it goes in at `point` (world). + pub fn set_point(&mut self, point: DVec3) { + self.point = Some(point); + } + + /// A palette click with the insertion point turned off and nothing to + /// ask: the block goes in at once. The host applies this result before + /// showing a prompt. + pub fn initial(&mut self) -> Option { + if self.point.is_some() && matches!(self.step, Step::Point) { + return Some(self.after_point()); + } + None + } + + fn options_text(&self) -> &'static str { + match self.mode { + Mode::Classic => "[Basepoint/Scale/X/Y/Z/Rotate/Explode/REpeat]", + _ => "[Basepoint/Scale/X/Y/Z/Rotate]", } } + /// After the insertion point: ask what is still open, else place. + fn after_point(&mut self) -> CmdResult { + if self.preset_scale[0].is_none() { + self.step = if self.ask_uniform { Step::Uniform } else { Step::ScaleX }; + return CmdResult::NeedPoint; + } + self.scale = [ + self.preset_scale[0].unwrap_or(1.0), + self.preset_scale[1].unwrap_or(1.0), + self.preset_scale[2].unwrap_or(1.0), + ]; + self.after_scale() + } + + fn after_scale(&mut self) -> CmdResult { + match self.preset_rotation { + Some(r) => { + self.rotation = r; + self.place() + } + None => { + self.step = Step::Rotation; + CmdResult::NeedPoint + } + } + } + + /// Build the reference at the chosen point and hand it over. + fn place(&mut self) -> CmdResult { + let Some(point) = self.point.take() else { + return CmdResult::NeedPoint; + }; + let local = self.plane.to_local(point); + let offset = self.local_offset(self.base); + let mut ins = Insert::new( + self.name.clone(), + Vector3::new(local.x - offset.x, local.y - offset.y, local.z - offset.z), + ); + ins.set_x_scale(self.scale[0]); + ins.set_y_scale(self.scale[1]); + ins.set_z_scale(self.scale[2]); + ins.rotation = self.rotation; + if !self.plane.is_identity() { + ins.apply_transform(&self.plane.to_world_transform()); + } + self.placed += 1; + // Repeating: later blocks keep the first one's scale / rotation + // unless each block is to be asked. + if self.repeat { + if !self.repeat_scale { + self.preset_scale = self.scale.map(Some); + } + if !self.repeat_rotation { + self.preset_rotation = Some(self.rotation); + } + self.step = Step::Point; + } + if self.explode { + return CmdResult::CommitExplodedInsert { + insert: EntityType::Insert(ins), + keep_going: self.repeat, + }; + } + let block_name = self.name.clone(); + self.pending_insert = Some(ins); + CmdResult::AttreqNeeded { block_name } + } + + /// The scaled, rotated block-space offset of `p`. + fn local_offset(&self, p: DVec3) -> DVec3 { + let s = DVec3::new(p.x * self.scale[0], p.y * self.scale[1], p.z * self.scale[2]); + let (sin, cos) = self.rotation.sin_cos(); + DVec3::new(s.x * cos - s.y * sin, s.x * sin + s.y * cos, s.z) + } + + fn number(text: &str) -> Option { + let t = text.trim().replace(',', "."); + t.parse::().ok().filter(|v| v.is_finite()) + } + + fn yes_no(text: &str, default: bool) -> Option { + match text.trim().to_ascii_uppercase().as_str() { + "" => Some(default), + "Y" | "YES" => Some(true), + "N" | "NO" => Some(false), + _ => None, + } + } + + /// `-INSERT ?`: the reference's listing. + fn listing(&self, pattern: &str) -> String { + let pattern = if pattern.trim().is_empty() { "*" } else { pattern.trim() }; + let mut out = String::from("Defined blocks."); + let mut shown = 0; + for name in &self.catalog.user { + if pattern.split(',').any(|p| crate::io::xref_model::wildcard_match(name, p.trim())) { + out.push_str(&format!("\n \"{name}\"")); + shown += 1; + } + } + let _ = shown; + out.push_str(&format!( + "\n\nUser Unnamed\nBlocks Blocks\n{:>5}{:>9}", + self.catalog.user.len(), + self.catalog.unnamed + )); + out + } + + /// The block name chosen at the name step. + fn choose(&mut self, name: String) -> CmdResult { + let units = self + .catalog + .units + .get(&name.to_ascii_uppercase()) + .cloned() + .unwrap_or_default(); + self.name = name; + self.step = Step::Point; + if units.is_empty() { + CmdResult::NeedPoint + } else { + CmdResult::ReportMeasurement(units) + } + } + + fn not_found(&self, name: &str) -> CmdResult { + let file = if name.to_ascii_lowercase().ends_with(".dwg") { + name.to_string() + } else { + format!("{name}.dwg") + }; + let path = std::path::Path::new(&file); + if path.is_file() { + return CmdResult::Dispatch(format!("_-INSERTFILE {}", path.display())); + } + CmdResult::CancelWithMessage(format!( + "\"{file}\": Can't find file in search path:\n {} (current directory)\n*Invalid*", + self.catalog.search_path + )) + } } impl CadCommand for InsertBlockCommand { @@ -111,55 +401,59 @@ impl CadCommand for InsertBlockCommand { } fn name(&self) -> &'static str { - "INSERT" + match self.mode { + Mode::Paste => "INSERT", + _ => "-INSERT", + } } fn prompt(&self) -> String { match &self.step { Step::Name => { - if self.picker.is_empty() { - return t!("INSERT Enter block name:").into_owned(); - } - let needle = self.picker.needle(); - let filtered = self.picker.filtered(); - if !needle.is_empty() && filtered.is_empty() { - return t!( - "INSERT No matching blocks for \"%{needle}\"", - needle = needle - ) - .into_owned(); - } - if needle.is_empty() { - let total = self.picker.total(); - let shown = filtered.len(); - if total <= shown { - t!("INSERT Enter block name:").into_owned() - } else { - t!( - "INSERT Enter block name: [%{shown} of %{total} — type to search]", - shown = shown, - total = total - ) - .into_owned() - } + if self.default_name.is_empty() { + "Enter block name or [?]:".to_string() } else { - t!( - "INSERT Enter block name: \"%{needle}\" [%{shown} matches]", - needle = needle, - shown = filtered.len() - ) - .into_owned() + format!("Enter block name or [?] <{}>:", self.default_name) } } - Step::Point { name } => match self.awaiting { - Some(AwaitKind::Scale) => t!("INSERT Specify scale factor <1>:").into_owned(), - Some(AwaitKind::Rotation) => t!("INSERT Specify rotation angle <0>:").into_owned(), - None => t!( + Step::ListPattern => "Enter block(s) to list <*>:".to_string(), + Step::Point => match self.mode { + Mode::Paste => t!( "INSERT Specify insertion point for \"%{name}\" [Scale/Rotate]:", - name = name + name = self.name ) .into_owned(), + _ => format!("Specify insertion point or {}:", self.options_text()), }, + Step::Preset(Preset::Base) => "Specify base point:".to_string(), + Step::Preset(Preset::Scale) => match self.mode { + Mode::Paste => t!("INSERT Specify scale factor <1>:").into_owned(), + _ => "Specify scale factor for XYZ axes <1>:".to_string(), + }, + Step::Preset(Preset::X) => "Specify X scale factor <1>:".to_string(), + Step::Preset(Preset::Y) => "Specify Y scale factor <1>:".to_string(), + Step::Preset(Preset::Z) => "Specify Z scale factor <1>:".to_string(), + Step::Preset(Preset::Rotate) => match self.mode { + Mode::Paste => t!("INSERT Specify rotation angle <0>:").into_owned(), + _ => "Specify rotation angle <0>:".to_string(), + }, + Step::YesNo(Ask::Explode) => "Explode block [Yes/No] :".to_string(), + Step::YesNo(Ask::Repeat) => "Repeat insertion of block [Yes/No] :".to_string(), + Step::YesNo(Ask::RepeatScale) => { + "Enter a scale factor for each block [Yes/No] ".to_string() + } + Step::YesNo(Ask::RepeatRotate) => { + "Enter a rotation angle for each block [Yes/No] ".to_string() + } + Step::ScaleX => { + "Enter X scale factor, specify opposite corner, or [Corner/XYZ] <1>:".to_string() + } + Step::Corner => "Specify opposite corner:".to_string(), + Step::XyzX => "Specify X scale factor or [Corner] <1>:".to_string(), + Step::XyzY | Step::ScaleY => "Enter Y scale factor :".to_string(), + Step::XyzZ => "Specify Z scale factor or :".to_string(), + Step::Uniform => "Specify scale factor for XYZ axes <1>:".to_string(), + Step::Rotation => "Specify rotation angle <0>:".to_string(), Step::FillAttr { attdefs, idx, .. } => { if let Some(ad) = attdefs.get(*idx) { let default_hint = if ad.default_value.is_empty() { @@ -186,24 +480,40 @@ impl CadCommand for InsertBlockCommand { } fn on_point(&mut self, pt: DVec3) -> CmdResult { - match &self.step { - Step::Name => CmdResult::NeedPoint, - Step::Point { name } => { - let point = self.plane.to_local(pt); - let mut ins = Insert::new( - name.clone(), - Vector3::new(point.x, point.y, point.z), - ); - ins.set_x_scale(self.x_scale); - ins.set_y_scale(self.y_scale); - ins.rotation = self.rotation_rad; - ins.apply_transform(&self.plane.to_world_transform()); - let block_name = name.clone(); - self.pending_insert = Some(ins); - // Signal the host to check for attdefs. - CmdResult::AttreqNeeded { block_name } + match self.step { + Step::Point => { + self.point = Some(pt); + self.after_point() } - Step::FillAttr { .. } => CmdResult::NeedPoint, + Step::Preset(Preset::Base) => { + self.base = self.plane.to_local(pt); + self.step = Step::Point; + CmdResult::NeedPoint + } + Step::ScaleX | Step::Corner | Step::XyzX => { + let Some(point) = self.point else { + return CmdResult::NeedPoint; + }; + let d = self.plane.to_local(pt) - self.plane.to_local(point); + if d.x.abs() < 1e-12 || d.y.abs() < 1e-12 { + return CmdResult::ReportError("Value must be nonzero.".to_string()); + } + self.scale = [d.x, d.y, d.x]; + self.after_scale() + } + Step::Rotation => { + let Some(point) = self.point else { + return CmdResult::NeedPoint; + }; + match self.plane.angle(point, pt) { + Some(angle) => { + self.rotation = angle; + self.place() + } + None => CmdResult::NeedPoint, + } + } + _ => CmdResult::NeedPoint, } } @@ -223,10 +533,8 @@ impl CadCommand for InsertBlockCommand { if !matches!(self.step, Step::Name) { return false; } - // Performance: only recompute if needle actually changed; picker - // uses upper/lower caches and partial sort so per-keystroke is <0.2ms. let needle = input.trim(); - if needle == self.picker.needle() { + if needle == self.picker.needle() || needle == "?" { return false; } self.picker.set_needle(needle.to_string()); @@ -234,114 +542,273 @@ impl CadCommand for InsertBlockCommand { } fn on_enter(&mut self) -> CmdResult { - match &self.step { - // A bare Enter while a scale/rotation value is awaited keeps the - // current default instead of cancelling the command. - Step::Point { .. } if self.awaiting.is_some() => { - self.awaiting = None; + match self.step { + Step::Name => { + if self.default_name.is_empty() { + CmdResult::Cancel + } else { + let name = self.default_name.clone(); + match self.picker.contains_name(&name) { + Some(canonical) => self.choose(canonical), + None => self.not_found(&name), + } + } + } + Step::ListPattern => CmdResult::Measurement(self.listing("*")), + Step::Point => { + if self.repeat && self.placed > 0 { + CmdResult::Cancel + } else if self.mode == Mode::Paste { + CmdResult::Cancel + } else { + CmdResult::ReportError("Point or option keyword required.".to_string()) + } + } + Step::Preset(_) => { + self.step = Step::Point; CmdResult::NeedPoint } - Step::Name | Step::Point { .. } => CmdResult::Cancel, - Step::FillAttr { .. } => { - // Treat Enter as accepting the default. - self.accept_attr_value("") + Step::YesNo(ask) => self.answer(ask, None), + Step::ScaleX | Step::XyzX => { + self.scale[0] = 1.0; + self.step = if matches!(self.step, Step::XyzX) { Step::XyzY } else { Step::ScaleY }; + CmdResult::NeedPoint } + Step::XyzY => { + self.scale[1] = self.scale[0]; + self.step = Step::XyzZ; + CmdResult::NeedPoint + } + Step::XyzZ => { + self.scale[2] = self.scale[0]; + self.after_scale() + } + Step::ScaleY => { + self.scale[1] = self.scale[0]; + self.scale[2] = self.scale[0]; + self.after_scale() + } + Step::Uniform => { + self.scale = [1.0; 3]; + self.after_scale() + } + Step::Rotation => { + self.rotation = 0.0; + self.place() + } + Step::Corner => CmdResult::NeedPoint, + Step::FillAttr { .. } => self.accept_attr_value(""), } } fn input_kind(&self) -> InputKind { match self.step { Step::FillAttr { .. } => InputKind::FreeText, - Step::Name | Step::Point { .. } => InputKind::SingleToken, + Step::Name | Step::ListPattern | Step::YesNo(_) => InputKind::SingleToken, + Step::Point | Step::Preset(Preset::Base) | Step::Corner => InputKind::Point, + _ => InputKind::SingleToken, + } + } + + fn dyn_field(&self) -> DynField { + match self.step { + Step::Rotation | Step::Preset(Preset::Rotate) => DynField::Angle, + Step::ScaleX + | Step::ScaleY + | Step::XyzX + | Step::XyzY + | Step::XyzZ + | Step::Uniform + | Step::Preset(_) => DynField::Scalar, + _ => DynField::Point, } } fn point_step_accepts_keywords(&self) -> bool { - // The insertion-point step also takes Scale / Rotate keywords while - // still accepting a point pick. Once a value is being typed (awaiting), - // route the whole number through `on_text_input` instead. - matches!(self.step, Step::Point { .. }) && self.awaiting.is_none() + matches!(self.step, Step::Point) } fn on_text_input(&mut self, text: &str) -> Option { - match &self.step { + let t = text.trim(); + let up = t.to_ascii_uppercase(); + Some(match self.step { Step::Name => { - let name = text.trim(); - // Empty input: reset needle to show default ranked list (2.1). - // Consumed so prompt+buttons refresh, not stale filter. - if name.is_empty() { + if t == "?" { + self.step = Step::ListPattern; + return Some(CmdResult::NeedPoint); + } + if t.is_empty() { self.picker.set_needle(String::new()); return Some(CmdResult::NeedPoint); } - // Exact block name (case-insensitive) → accept and go to point step. - // This path is used both for typed exact names and for CmdOption - // button clicks (Message::CommandOptionPick feeds the keyword through - // on_text_input). Returning NeedPoint keeps the prompt updated. - if let Some(canonical) = self.picker.contains_name(name) { - self.step = Step::Point { name: canonical }; - return Some(CmdResult::NeedPoint); + match self.picker.contains_name(t) { + Some(canonical) => self.choose(canonical), + None => self.not_found(t), } - // Not an exact match → treat as incremental search needle. - // Update filter and re-render prompt + option buttons. Must return - // Some(NeedPoint) (consumed) not None, otherwise the driver would - // offer the same text to the command a second time. - self.picker.set_needle(name.to_string()); - Some(CmdResult::NeedPoint) } - Step::FillAttr { .. } => Some(self.accept_attr_value(text)), - Step::Point { .. } => { - // Typing the value awaited after a Scale / Rotate keyword. The - // two read differently: a scale is a plain multiplier with no - // unit to write it in, while a rotation is an angle and takes - // whichever convention the drawing is set to. - if let Some(kind) = self.awaiting { - match kind { - AwaitKind::Scale => { - if let Ok(v) = text.trim().parse::() { - if v != 0.0 { - self.x_scale = v; - self.y_scale = v; - } - } - } - AwaitKind::Rotation => { - if let Some(v) = crate::entities::common::parse_typed_angle(text) { - self.rotation_rad = v; - } + Step::ListPattern => CmdResult::Measurement(self.listing(t)), + Step::Point => match up.as_str() { + "B" | "BASEPOINT" => { + self.step = Step::Preset(Preset::Base); + CmdResult::NeedPoint + } + "S" | "SCALE" => { + self.step = Step::Preset(Preset::Scale); + CmdResult::NeedPoint + } + "X" => { + self.step = Step::Preset(Preset::X); + CmdResult::NeedPoint + } + "Y" => { + self.step = Step::Preset(Preset::Y); + CmdResult::NeedPoint + } + "Z" => { + self.step = Step::Preset(Preset::Z); + CmdResult::NeedPoint + } + "R" | "ROTATE" => { + self.step = Step::Preset(Preset::Rotate); + CmdResult::NeedPoint + } + "E" | "EXPLODE" if self.mode == Mode::Classic => { + self.step = Step::YesNo(Ask::Explode); + CmdResult::NeedPoint + } + "RE" | "REPEAT" if self.mode == Mode::Classic => { + self.step = Step::YesNo(Ask::Repeat); + CmdResult::NeedPoint + } + _ => CmdResult::ReportError("Invalid option keyword.".to_string()), + }, + Step::Preset(kind) => { + if kind == Preset::Rotate { + match crate::entities::common::parse_typed_angle(t) { + Some(angle) => self.preset_rotation = Some(angle), + None => { + return Some(CmdResult::ReportError( + "Requires valid numeric angle or second point.".to_string(), + )) } } - self.awaiting = None; - return Some(CmdResult::NeedPoint); - } - match text.trim().to_uppercase().as_str() { - "S" | "SCALE" => { - self.awaiting = Some(AwaitKind::Scale); - Some(CmdResult::NeedPoint) + } else { + match Self::number(t) { + Some(v) if v != 0.0 => match kind { + Preset::Scale => self.preset_scale = [Some(v); 3], + Preset::X => self.preset_scale[0] = Some(v), + Preset::Y => self.preset_scale[1] = Some(v), + Preset::Z => self.preset_scale[2] = Some(v), + _ => {} + }, + Some(_) => { + return Some(CmdResult::ReportError("Value must be nonzero.".to_string())) + } + None => { + return Some(CmdResult::ReportError( + "Requires numeric value.".to_string(), + )) + } } - "R" | "ROTATE" => { - self.awaiting = Some(AwaitKind::Rotation); - Some(CmdResult::NeedPoint) + // A single axis given: the others stay at 1. + if self.preset_scale.iter().any(Option::is_some) { + for axis in &mut self.preset_scale { + axis.get_or_insert(1.0); + } } - _ => None, } + self.step = Step::Point; + CmdResult::NeedPoint } - } + Step::YesNo(ask) => self.answer(ask, Some(t)), + Step::ScaleX | Step::XyzX => match up.as_str() { + "C" | "CORNER" => { + self.step = Step::Corner; + CmdResult::NeedPoint + } + "XYZ" if matches!(self.step, Step::ScaleX) => { + self.step = Step::XyzX; + CmdResult::NeedPoint + } + _ => match Self::number(t) { + Some(v) if v != 0.0 => { + self.scale[0] = v; + self.step = if matches!(self.step, Step::XyzX) { Step::XyzY } else { Step::ScaleY }; + CmdResult::NeedPoint + } + Some(_) => CmdResult::ReportError("Value must be nonzero.".to_string()), + None => CmdResult::ReportError("Requires numeric value.".to_string()), + }, + }, + Step::ScaleY | Step::XyzY | Step::XyzZ | Step::Uniform => match Self::number(t) { + Some(v) if v != 0.0 => match self.step { + Step::ScaleY => { + self.scale[1] = v; + self.scale[2] = self.scale[0]; + self.after_scale() + } + Step::XyzY => { + self.scale[1] = v; + self.step = Step::XyzZ; + CmdResult::NeedPoint + } + Step::XyzZ => { + self.scale[2] = v; + self.after_scale() + } + _ => { + self.scale = [v; 3]; + self.after_scale() + } + }, + Some(_) => CmdResult::ReportError("Value must be nonzero.".to_string()), + None => CmdResult::ReportError("Requires numeric value.".to_string()), + }, + Step::Rotation => match crate::entities::common::parse_typed_angle(t) { + Some(angle) => { + self.rotation = angle; + self.place() + } + None => CmdResult::ReportError( + "Requires valid numeric angle or second point.".to_string(), + ), + }, + Step::Corner => CmdResult::NeedPoint, + Step::FillAttr { .. } => self.accept_attr_value(text), + }) } - fn on_preview_wires(&mut self, pt: DVec3) -> Vec { let pt = pt.as_vec3(); - match (&self.step, &self.preview) { - (Step::Point { .. }, Some((wires, base))) => { - let delta = pt - *base; - wires.iter().map(|w| w.translated(delta)).collect() - } - _ => vec![], + fn on_preview_wires(&mut self, pt: DVec3) -> Vec { + if self.preview.is_empty() || !matches!(self.step, Step::Point) { + return Vec::new(); } + let scale = [ + self.preset_scale[0].unwrap_or(1.0), + self.preset_scale[1].unwrap_or(1.0), + self.preset_scale[2].unwrap_or(1.0), + ]; + let (sin, cos) = self.preset_rotation.unwrap_or(0.0).sin_cos(); + let base = self.base; + let origin = self.plane.to_local(pt); + let plane = self.plane; + self.preview + .iter() + .map(|w| { + w.mapped(|p| { + let d = p - base; + let s = DVec3::new(d.x * scale[0], d.y * scale[1], d.z * scale[2]); + let r = DVec3::new(s.x * cos - s.y * sin, s.x * sin + s.y * cos, s.z); + plane.to_world(origin + r) + }) + }) + .collect() } - fn attreq_set_attdefs( - &mut self, - attdefs: Vec, - ) -> Option { + fn attreq_continue(&self) -> bool { + self.repeat + } + + fn attreq_set_attdefs(&mut self, attdefs: Vec) -> Option { self.step = Step::FillAttr { attdefs, idx: 0, @@ -351,13 +818,37 @@ impl CadCommand for InsertBlockCommand { } fn attreq_take_insert(&mut self) -> Option { - self.pending_insert - .take() - .map(|ins| EntityType::Insert(ins)) + self.pending_insert.take().map(EntityType::Insert) } } impl InsertBlockCommand { + fn answer(&mut self, ask: Ask, text: Option<&str>) -> CmdResult { + let default = !matches!(ask, Ask::RepeatScale | Ask::RepeatRotate); + let Some(yes) = Self::yes_no(text.unwrap_or(""), default) else { + return CmdResult::ReportError("Invalid option keyword.".to_string()); + }; + self.step = match ask { + Ask::Explode => { + self.explode = yes; + Step::Point + } + Ask::Repeat => { + self.repeat = yes; + if yes { Step::YesNo(Ask::RepeatScale) } else { Step::Point } + } + Ask::RepeatScale => { + self.repeat_scale = yes; + Step::YesNo(Ask::RepeatRotate) + } + Ask::RepeatRotate => { + self.repeat_rotation = yes; + Step::Point + } + }; + CmdResult::NeedPoint + } + fn accept_attr_value(&mut self, text: &str) -> CmdResult { let (attdef_idx, default) = match &self.step { Step::FillAttr { attdefs, idx, .. } => { @@ -368,13 +859,11 @@ impl InsertBlockCommand { } _ => return CmdResult::Cancel, }; - let value = if text.trim().is_empty() { default } else { text.trim().to_string() }; - if let Step::FillAttr { ref mut values, ref mut idx, @@ -384,8 +873,11 @@ impl InsertBlockCommand { values.push((attdef_idx, value)); *idx = attdef_idx + 1; } - match self.advance_automatic_attributes() { + Some(entity) if self.repeat => { + self.step = Step::Point; + CmdResult::CommitEntity(entity) + } Some(entity) => CmdResult::CommitAndExit(entity), None => CmdResult::NeedPoint, } @@ -416,9 +908,7 @@ impl InsertBlockCommand { fn finish_insert(&mut self) -> Option { let (attdefs, values) = match &self.step { - Step::FillAttr { - attdefs, values, .. - } => (attdefs.clone(), values.clone()), + Step::FillAttr { attdefs, values, .. } => (attdefs.clone(), values.clone()), _ => return None, }; let mut insert = self.pending_insert.take()?; @@ -431,10 +921,12 @@ impl InsertBlockCommand { attribute.apply_transform(&transform); insert.attributes.push(attribute); } + if self.repeat { + self.step = Step::Point; + } Some(EntityType::Insert(insert)) } } - // ── Autocomplete registry ───────────────────────────────── -inventory::submit!(crate::command::CommandRegistration { names: &["INSERT"] }); // InsertBlockCommand +inventory::submit!(crate::command::CommandRegistration { names: &["INSERT", "-INSERT"] }); diff --git a/src/modules/insert/mod.rs b/src/modules/insert/mod.rs index 92ee61dcd..359c48d16 100644 --- a/src/modules/insert/mod.rs +++ b/src/modules/insert/mod.rs @@ -104,7 +104,7 @@ impl CadModule for InsertModule { title: "Block", tools: vec![ RibbonItem::LargeTool(mview_block::tool()), - RibbonItem::LargeTool(insert_block::tool()), + insert_block::gallery(), RibbonItem::LabeledDropdown { id: "ATTEDIT_DROPDOWN", label: "Edit Attribute", diff --git a/src/scene/camera_ops.rs b/src/scene/camera_ops.rs index b9eed710d..a1f0ffc2e 100644 --- a/src/scene/camera_ops.rs +++ b/src/scene/camera_ops.rs @@ -81,6 +81,26 @@ fn rendered_wire_bounds( } } +/// Merge `pair` into `bounds`, dropping coordinates that are parser junk +/// rather than geometry: DXF writes ±1e20 as its "no extents" sentinel, and +/// past 1e16 an f32 has no resolution left to place a point with. +fn fold_sane_bounds( + bounds: &mut Option<(glam::DVec3, glam::DVec3)>, + pair: Option<(glam::DVec3, glam::DVec3)>, +) { + const SANE_EXTENT: f64 = 1.0e16; + let Some((lo, hi)) = pair else { + return; + }; + if lo.abs().max_element() >= SANE_EXTENT || hi.abs().max_element() >= SANE_EXTENT { + return; + } + *bounds = Some(match *bounds { + Some((min, max)) => (min.min(lo), max.max(hi)), + None => (lo, hi), + }); +} + fn rendered_wire_center(wires: &[WireModel], fallback_z: f64) -> Option { rendered_wire_bounds(wires, fallback_z).map(|(min, max)| (min + max) * 0.5) } @@ -1321,6 +1341,155 @@ impl Scene { self.camera_generation += 1; } + /// Absolute-WCS bounds of everything visible in `block` — the one extents + /// walk behind ZOOM EXTENTS and the drawing-extents readers. + /// + /// Nothing is rejected here on a guess. An earlier version dropped any wire + /// whose centroid fell outside the inter-quartile consensus of the others, + /// to stop one junk wire from a bad parse poisoning the box. It also + /// dropped legitimately remote geometry — an outlying detail, a survey + /// point, a block placed far from the rest — so ZOOM EXTENTS quietly + /// refused to reach it. `SANE_EXTENT` and the finite test are the only + /// filters now; the ±1e6 display segments that motivated the old heuristic + /// come from XLine / Ray, which are handled explicitly below. + pub(super) fn visible_block_bounds(&self, block: Handle) -> Option<(glam::DVec3, glam::DVec3)> { + if block.is_null() { + return None; + } + let mut bounds: Option<(glam::DVec3, glam::DVec3)> = None; + macro_rules! fold { + ($pair:expr) => { + fold_sane_bounds(&mut bounds, $pair) + }; + } + + // Drawing extents must not depend on where the camera happens to be + // looking: the result is cached per geometry epoch, and a floating + // viewport's own camera is derived FROM the extents + // (`camera_for_viewport`), so reading the camera here recurses. A wire + // with no precise position falls back to the z = 0 plane. + let fallback_z = 0.0; + let scale = crate::scene::annotative::scale_handle_by_name( + &self.document, + &self.document.header.current_annotation_scale, + ); + // The resident set is the full, un-culled, LOD-free tessellation the + // block actually renders from. Culled input would fit only what is + // already on screen, so each ZOOM EXTENTS would zoom out a little and + // reveal more, converging on the true extent after several uses (#51). + let wires = self.resident_wires_for(block, None, scale, None, None); + // Infinite display segments do not contribute to drawing extents. + // Keep their base points as a fallback for otherwise empty drawings. + let mut infinite_base: Vec = Vec::new(); + for wire in wires.iter() { + let is_infinite = Self::handle_from_wire_name(&wire.name) + .and_then(|handle| self.document.get_entity(handle)) + .is_some_and(|entity| matches!(entity, EntityType::XLine(_) | EntityType::Ray(_))); + if is_infinite { + infinite_base.extend( + wire.key_vertices + .iter() + .map(|&[x, y, z]| glam::DVec3::new(x, y, z)) + .filter(|point| point.is_finite()), + ); + continue; + } + fold!(rendered_wire_bounds(std::slice::from_ref(wire), fallback_z)); + } + + // 3D solids render as meshes, not wires: a drawing of only solids has + // no wire to fit. + for (&handle, set) in &self.meshes { + let Some(entity) = self.document.get_entity(handle) else { + continue; + }; + if !self.mesh_entity_visible(handle) + || !self.belongs_to_visible_block(handle, entity.common().owner_handle, block) + { + continue; + } + let [x0, y0, x1, y1] = set.world_aabb; + let [z0, z1] = set.z_aabb; + let lo = glam::DVec3::new(x0 as f64, y0 as f64, z0 as f64); + let hi = glam::DVec3::new(x1 as f64, y1 as f64, z1 as f64); + if lo.is_finite() && hi.is_finite() { + fold!(Some((lo.min(hi), lo.max(hi)))); + } + } + + // Raster images, OLE frames and underlays draw as GPU quads; with + // their frames hidden they carry no wire outline at all. + for (&handle, image) in &self.images { + let Some(entity) = self.document.get_entity(handle) else { + continue; + }; + if !self.mesh_entity_visible(handle) + || !self.belongs_to_visible_block(handle, entity.common().owner_handle, block) + { + continue; + } + for (corner, low) in image.corners.iter().zip(image.corners_low.iter()) { + let point = glam::DVec3::new( + corner[0] as f64 + low[0] as f64, + corner[1] as f64 + low[1] as f64, + corner[2] as f64 + low[2] as f64, + ); + if point.is_finite() { + fold!(Some((point, point))); + } + } + } + + // HATCH tessellates to no wires at all (#131), so a drawing whose + // outermost geometry is a fill — or which is nothing but fills — has + // to pick its bounds up from the fill models themselves. The paper + // sheet already does this; model space did not. + // The renderer already holds the fill models for the block it is + // drawing, keyed by geometry epoch — reuse that rather than exploding + // every INSERT again on each ZOOM EXTENTS. Only a block that is not + // the one on screen (model space while a BEDIT is open) has to build + // its own set. + let hatches = if block == self.content_render_block_handle() { + // Tinted, matching the renderer's own call: the two then share one + // cache entry. Asking for an untinted set would key the same slot + // with a different selection signature and the two would evict + // each other on every frame. Tint is colour; it moves no vertex. + self.hatch_models_arc_for_view(true) + } else { + std::sync::Arc::new(self.synced_hatch_models( + block, + None, + scale, + self.annotation_all_visible(), + None, + false, + )) + }; + for hatch in hatches.iter() { + for &[x, y] in hatch.boundary.iter() { + // NaN-NaN separators between disconnected paths fall out on + // the finite test — they must not be treated as vertices. + let point = glam::DVec3::new( + hatch.world_origin[0] + x as f64, + hatch.world_origin[1] + y as f64, + 0.0, + ); + if point.is_finite() { + fold!(Some((point, point))); + } + } + } + + // Drawing holds only infinite construction geometry: fall back to its + // base points rather than reporting nothing. + if bounds.is_none() { + for point in infinite_base { + fold!(Some((point, point))); + } + } + bounds + } + pub fn fit_all(&mut self) { if self.active_viewport.is_some() { if let Some((mut min, mut max)) = self.model_space_extents() { @@ -1337,225 +1506,19 @@ impl Scene { return; } - // Use the FULL, un-culled wire set — not `entity_wires()`, which is - // frustum-culled to the current view. Culled input would fit only the - // entities already on screen, so each call would zoom out a little and - // reveal more, converging on the true extent only after several uses - // (issue #51). `wpp = None` also tessellates at a fixed tolerance so - // the bounds don't drift with zoom-adaptive curve sampling. - let layout_block = self.current_layout_block_handle(); - let scale = if self.current_layout == "Model" { - crate::scene::annotative::scale_handle_by_name( - &self.document, - &self.document.header.current_annotation_scale, - ) - } else { - self.paper_annotation_scale_handle() + let Some((min, mut max)) = self.visible_block_bounds(self.current_layout_block_handle()) + else { + // Nothing usable to fit: leave the camera where it is. + return; }; - let mut wires = self.wires_for_block_culled( - layout_block, - None, - None, - None, - None, - scale, - self.annotation_all_visible(), - None, - ); - // Infinite display segments do not contribute to drawing extents. - // Preserve their base points as a fallback for otherwise empty drawings. - let mut infinite_base_pts: Vec = Vec::new(); - wires.retain(|w| { - let is_infinite = Self::handle_from_wire_name(&w.name) - .and_then(|h| self.document.get_entity(h)) - .map(|e| matches!(e, EntityType::XLine(_) | EntityType::Ray(_))) - .unwrap_or(false); - if is_infinite { - infinite_base_pts.extend( - w.key_vertices - .iter() - .map(|v| glam::Vec3::new(v[0] as f32, v[1] as f32, v[2] as f32)), - ); - } - !is_infinite - }); - // 3D solids render as meshes, not wires, so collect their complete - // world boxes separately — a drawing of only solids has no wires to fit. - let mesh_aabbs: Vec<([f32; 4], [f32; 2])> = self - .meshes - .iter() - .filter(|(h, _)| { - let handle = **h; - self.mesh_entity_visible(handle) - && self.document.get_entity(handle).is_some_and(|entity| { - self.belongs_to_visible_block( - handle, - entity.common().owner_handle, - layout_block, - ) - }) - }) - .map(|(_, set)| (set.world_aabb, set.z_aabb)) - .filter(|(xy, z)| { - xy[0].is_finite() - && xy[1].is_finite() - && xy[2].is_finite() - && xy[3].is_finite() - && z[0].is_finite() - && z[1].is_finite() - }) - .collect(); - // Raster images and underlays draw as GPU quads; with their frames - // hidden they carry no wire outline, so fold their corners in too. - let image_corners: Vec = self - .images - .iter() - .filter(|(h, _)| { - let handle = **h; - self.mesh_entity_visible(handle) - && self.document.get_entity(handle).is_some_and(|entity| { - self.belongs_to_visible_block(handle, entity.common().owner_handle, layout_block) - }) - }) - .flat_map(|(_, image)| { - image.corners.iter().zip(image.corners_low.iter()).map(|(c, low)| { - glam::Vec3::new(c[0] + low[0], c[1] + low[1], c[2] + low[2]) - }) - }) - .filter(|p| p.is_finite()) - .collect(); - if wires.is_empty() && mesh_aabbs.is_empty() && infinite_base_pts.is_empty() && image_corners.is_empty() { - return; - } - - // Collect wire centroids for robust outlier rejection. - struct WireCent { - idx: usize, - cx: f32, - cy: f32, - } - let mut cents: Vec = Vec::with_capacity(wires.len()); - for (idx, wire) in wires.iter().enumerate() { - let mut sx = 0.0_f64; - let mut sy = 0.0_f64; - let mut n = 0_usize; - for &[x, y, _] in &wire.points { - if !x.is_finite() || !y.is_finite() { - continue; - } - sx += x as f64; - sy += y as f64; - n += 1; - } - // SDF text is carried as textured glyph quads, not ordinary wire - // points. Include those vertices so a text-only wire participates - // in the robust-centre filter used by ZOOM EXTENTS. - for vertex in &wire.text_verts { - let x = vertex.pos[0] as f64 + vertex.pos_low[0] as f64; - let y = vertex.pos[1] as f64 + vertex.pos_low[1] as f64; - if !x.is_finite() || !y.is_finite() { - continue; - } - sx += x; - sy += y; - n += 1; - } - if n > 0 { - cents.push(WireCent { - idx, - cx: (sx / n as f64) as f32, - cy: (sy / n as f64) as f32, - }); - } - } - if cents.is_empty() && mesh_aabbs.is_empty() && infinite_base_pts.is_empty() && image_corners.is_empty() { - return; - } - - // Quartile rejection needs enough samples to be meaningful. With fewer - // wires, every finite visible point belongs to the drawing extents. - let (rx_lo, rx_hi, ry_lo, ry_hi) = if cents.len() >= 8 { - let mut xs: Vec = cents.iter().map(|c| c.cx).collect(); - let mut ys: Vec = cents.iter().map(|c| c.cy).collect(); - xs.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); - ys.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); - let q = |v: &[f32], frac: f32| v[((v.len() as f32 - 1.0) * frac) as usize]; - let q1x = q(&xs, 0.25); - let q3x = q(&xs, 0.75); - let q1y = q(&ys, 0.25); - let q3y = q(&ys, 0.75); - // Keep sparse annotations while rejecting isolated corrupt wires. - const K: f32 = 10.0; - let dx = (q3x - q1x).max(1.0) * K; - let dy = (q3y - q1y).max(1.0) * K; - (q1x - dx, q3x + dx, q1y - dy, q3y + dy) - } else { - ( - f32::NEG_INFINITY, - f32::INFINITY, - f32::NEG_INFINITY, - f32::INFINITY, - ) - }; - - let mut min = glam::Vec3::splat(f32::MAX); - let mut max = glam::Vec3::splat(f32::MIN); - for c in ¢s { - if c.cx < rx_lo || c.cx > rx_hi || c.cy < ry_lo || c.cy > ry_hi { - continue; - } - let wire = &wires[c.idx]; - let hw = (wire.world_width * 0.5) as f32; - for &[x, y, z] in &wire.points { - if !x.is_finite() || !y.is_finite() || !z.is_finite() { - continue; - } - if hw > 0.0 { - min = min.min(glam::Vec3::new(x - hw, y - hw, z - hw)); - max = max.max(glam::Vec3::new(x + hw, y + hw, z + hw)); - } else { - min = min.min(glam::Vec3::new(x, y, z)); - max = max.max(glam::Vec3::new(x, y, z)); - } - } - for vertex in &wire.text_verts { - let point = glam::Vec3::new( - vertex.pos[0] + vertex.pos_low[0], - vertex.pos[1] + vertex.pos_low[1], - vertex.pos[2] + vertex.pos_low[2], - ); - if point.is_finite() { - min = min.min(point); - max = max.max(point); - } - } - } - // Fold in 3D-solid mesh AABBs (not subject to the wire IQR reject). - for ([ax, ay, bx, by], [az, bz]) in &mesh_aabbs { - min = min.min(glam::Vec3::new(*ax, *ay, *az)); - max = max.max(glam::Vec3::new(*bx, *by, *bz)); - } - for p in &image_corners { - min = min.min(*p); - max = max.max(*p); - } - // Drawing holds only infinite construction geometry: fit the view to - // its base points rather than leaving the camera unchanged. - if min.x > max.x { - for p in &infinite_base_pts { - min = min.min(*p); - max = max.max(*p); - } - } - // If no usable points found, leave the camera unchanged. - if min.x > max.x { - return; - } if min == max { - max += glam::Vec3::splat(1.0); + max += glam::DVec3::splat(1.0); } let aspect = self.active_camera_aspect(); - self.camera.borrow_mut().fit_to_bounds(min, max, aspect); + // f64 all the way into the camera: a UTM-scale drawing loses whole + // metres to an f32 round-trip, which shows up as a fit that sits + // slightly off the geometry it was asked to frame. + self.camera.borrow_mut().fit_to_bounds_f64(min, max, aspect); self.projection_bounds_epoch.set(self.geometry_epoch); self.camera_generation += 1; } diff --git a/src/scene/entity.rs b/src/scene/entity.rs index 067b4cbf4..6108ac028 100644 --- a/src/scene/entity.rs +++ b/src/scene/entity.rs @@ -1234,6 +1234,46 @@ impl Scene { self.bump_geometry(); } + /// Replace the contents and base point of block `name`, defining it when + /// absent. References keep pointing at it and show the new contents. + pub fn redefine_block_raw( + &mut self, + name: &str, + base_point: codec::types::Vector3, + entities: Vec, + ) { + let Some((br_handle, block_handle)) = self + .document + .block_records + .get(name) + .map(|br| (br.handle, br.block_entity_handle)) + else { + return self.define_block_raw(name, base_point, entities); + }; + let owned: Vec = self + .document + .entities() + .filter(|e| { + e.common().owner_handle == br_handle + && !matches!(e, EntityType::Block(_) | EntityType::BlockEnd(_)) + }) + .map(|e| e.common().handle) + .collect(); + for h in owned { + self.document.remove_entity(h); + } + if let Some(EntityType::Block(block)) = self.document.get_entity_mut(block_handle) { + block.base_point = base_point; + } + for mut entity in entities { + Self::reset_clone_subhandles(&mut self.document, &mut entity); + entity.common_mut().handle = Handle::NULL; + entity.common_mut().owner_handle = br_handle; + let _ = self.document.add_entity(entity); + } + self.bump_geometry(); + } + pub(super) fn synced_hatch_models( &self, target_block: Handle, diff --git a/src/scene/mod.rs b/src/scene/mod.rs index dff34ff69..a08c183b8 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -3008,6 +3008,10 @@ impl Scene { Some(state.into_iter().collect()) } + /// Whether this entity contributes glyphs to the SDF text buffer, so a + /// change to it must invalidate the cached text vertices. Every entity whose + /// `to_render` can emit a `GlyphRun` belongs here — miss one and its glyphs + /// linger after an erase and fail to appear after an add. fn entity_affects_text_cache(&self, entity: &EntityType) -> bool { if matches!( entity, @@ -3024,6 +3028,21 @@ impl Scene { ) { return true; } + // A raster reference it cannot resolve draws the saved path as a glyph + // run in place of the picture (`RasterImage::to_render`), so its + // broken-reference placeholder lives in the text buffer; a resolvable + // one contributes only its frame. Same memoised probe the converter + // uses, so the two cannot disagree — and keeping it a probe rather than + // a blanket type match keeps a working image's grip-drag from + // rebuilding every glyph in the drawing on each mouse move. + if let EntityType::RasterImage(image) = entity { + let path = image.file_path.trim(); + if !path.is_empty() + && crate::scene::model::image_model::resolve_image(path).is_none() + { + return true; + } + } // Complex-linetype glyphs ride the host entity's wire. let lt = crate::scene::view::render::linetype_name_for(&self.document, entity); crate::io::linetypes::resolve_complex_lt(&self.document, <).is_some() @@ -3117,12 +3136,6 @@ impl Scene { true } - /// True when no text-bearing entity changed since `last_epoch`, so cached SDF - /// glyphs stay valid. Text comes from Text / MText / Dimension / MultiLeader / - /// Leader / Table / Tolerance / attributes (incl. ATTDEF) and from block - /// references (their baked text moves with the instance) — an edit to any of - /// those, or any removal, invalidates it; a plain line / arc / polyline edit - /// does not. /// Whether the per-entity draw-order labels can be replayed since /// `last_epoch`. Add/Remove keep every existing label stable; only a full /// structural delta (DRAWORDER, file/layout/block rebuild, journal overflow) @@ -3131,6 +3144,15 @@ impl Scene { self.replay_since(last_epoch).is_some() } + /// True when no text-bearing entity changed since `last_epoch`, so cached SDF + /// glyphs stay valid. Text comes from Text / MText / Dimension / MultiLeader / + /// Leader / Table / Tolerance / attributes (incl. ATTDEF), from block + /// references (their baked text moves with the instance), and from a raster + /// image whose reference cannot be resolved (it draws the saved path in place + /// of the picture) — an edit or removal of any of those invalidates it; a + /// plain line / arc / polyline edit does not. See + /// [`Scene::entity_affects_text_cache`], which classifies both directions: + /// live entities for an add/edit, the pre-erase category mask for a removal. fn text_unchanged(&self, last_epoch: u64) -> bool { self.category_cache_valid(last_epoch, CACHE_CATEGORY_TEXT, |handle| { self.document @@ -11931,78 +11953,15 @@ vis_index={:.1} visible_probe={:.1}", fn compute_model_space_extents(&self) -> Option<(glam::Vec3, glam::Vec3)> { let model_block = self.model_space_block_handle(); - if model_block.is_null() { - return None; - } - // Reuse the full resident source in every layout. The kernel bounds - // its world-space key vertices; camera fitting never tessellates a - // second copy or reads the current paper sheet's wire cache. - let scale = crate::scene::annotative::scale_handle_by_name( - &self.document, - &self.document.header.current_annotation_scale, - ); - let wires = - self.resident_wires_for(model_block, Some(self.annotation_scale), scale, None, None); - let mut min = [f64::INFINITY; 3]; - let mut max = [f64::NEG_INFINITY; 3]; - let mut has_points = false; - - for wire in wires.iter() { - for &[x, y, z] in &wire.key_vertices { - if (x as f32).is_finite() && (y as f32).is_finite() && (z as f32).is_finite() { - min[0] = min[0].min(x); - min[1] = min[1].min(y); - min[2] = min[2].min(z); - max[0] = max[0].max(x); - max[1] = max[1].max(y); - max[2] = max[2].max(z); - has_points = true; - } - } - } - - if !self.meshes.is_empty() { - for (&handle, set) in &self.meshes { - let Some(entity) = self.document.get_entity(handle) else { - continue; - }; - if !self.mesh_entity_visible(handle) - || !self.belongs_to_visible_block( - handle, - entity.common().owner_handle, - model_block, - ) - { - continue; - } - let [ax, ay, bx, by] = set.world_aabb; - let [az, bz] = set.z_aabb; - let (ax, ay, bx, by, az, bz) = ( - ax as f64, ay as f64, bx as f64, by as f64, az as f64, bz as f64, - ); - if (ax as f32).is_finite() - && (ay as f32).is_finite() - && (az as f32).is_finite() - && (bx as f32).is_finite() - && (by as f32).is_finite() - && (bz as f32).is_finite() - { - min[0] = min[0].min(ax.min(bx)); - min[1] = min[1].min(ay.min(by)); - min[2] = min[2].min(az.min(bz)); - max[0] = max[0].max(ax.max(bx)); - max[1] = max[1].max(ay.max(by)); - max[2] = max[2].max(az.max(bz)); - has_points = true; - } - } - } - - if has_points { - return Some(( - glam::Vec3::new(min[0] as f32, min[1] as f32, min[2] as f32), - glam::Vec3::new(max[0] as f32, max[1] as f32, max[2] as f32), - )); + // Shared with ZOOM EXTENTS so the two never disagree about what the + // drawing covers. The previous walk here read only `key_vertices`, + // which is empty for every tessellated curve (see `WireModel`) — so + // circles, arcs, ellipses and splines contributed nothing, and + // neither did hatches or images. Everything that reads drawing + // extents (plot Extents, sheet-set framing, viewport auto-fit, + // SpaceMouse model bounds) inherited that blind spot. + if let Some((min, max)) = self.visible_block_bounds(model_block) { + return Some((min.as_vec3(), max.as_vec3())); } // Last resort: saved EXTMIN/EXTMAX before the wire cache is built. const SANE_EXTENT: f64 = 1.0e16; @@ -13058,6 +13017,108 @@ mod journal_tests { ); assert!(s.text_unchanged(cached_epoch)); } + + /// A raster image whose file cannot be resolved draws the saved path as a + /// glyph run instead of the picture, so erasing it MUST invalidate the SDF + /// text buffer — otherwise `gather_text_verts` reuses the previous vertex + /// list and the path text keeps drawing over the now-empty frame. + #[test] + fn erasing_broken_raster_reference_invalidates_text_cache() { + let mut s = Scene::new(); + let image = s.add_entity(EntityType::RasterImage(broken_raster("erase"))); + let cached_epoch = s.geometry_epoch; + s.erase_entities(&[image]); + + assert!( + !s.text_unchanged(cached_epoch), + "erasing a broken raster reference must drop its placeholder glyphs" + ); + } + + /// The same classification in the other direction: attaching (and, through + /// the identical Added/Modified path, moving or undoing the erase of) a + /// broken reference must invalidate the text buffer so its path text appears. + #[test] + fn attaching_broken_raster_reference_invalidates_text_cache() { + let mut s = Scene::new(); + let cached_epoch = s.geometry_epoch; + s.add_entity(EntityType::RasterImage(broken_raster("attach"))); + + assert!( + !s.text_unchanged(cached_epoch), + "attaching a broken raster reference must build its placeholder glyphs" + ); + } + + /// The converse perf property: a raster that resolves contributes no glyphs, + /// so editing or erasing it must leave the text buffer warm — a grip-drag of + /// a working image cannot re-walk every glyph in the drawing per mouse move. + /// An empty path counts as resolvable to both the converter and the + /// classifier, so this needs no picture on disk. + #[test] + fn erasing_resolvable_raster_keeps_text_cache_warm() { + use codec::entities::RasterImage; + use codec::types::Vector3; + + let mut s = Scene::new(); + let image = s.add_entity(EntityType::RasterImage(RasterImage::with_size( + "", + Vector3::new(0.0, 0.0, 0.0), + 16.0, + 16.0, + 10.0, + 10.0, + ))); + let cached_epoch = s.geometry_epoch; + s.erase_entities(&[image]); + + assert!(s.text_unchanged(cached_epoch)); + } + + /// End-to-end on the buffer the GPU actually draws: the placeholder's path + /// glyphs must be gone from the gathered SDF vertices after the erase. This + /// is the shape of the reported bug — `gather_text_verts` misses its content- + /// id cache on every geometry edit and then reuses the previous vertex list + /// whenever `text_unchanged` says the text is untouched. + #[test] + fn erasing_broken_raster_reference_drops_its_glyphs_from_the_text_buffer() { + let depth_map = rustc_hash::FxHashMap::default(); + let mut s = Scene::new(); + let image = s.add_entity(EntityType::RasterImage(broken_raster("gather"))); + + let wires = s.entity_wires(); + let before = s.gather_text_verts(&wires, 1, 7, &depth_map); + assert!( + !before.is_empty(), + "a broken raster reference must contribute its path glyphs" + ); + + s.erase_entities(&[image]); + let wires = s.entity_wires(); + let after = s.gather_text_verts(&wires, 2, 7, &depth_map); + assert!( + after.is_empty(), + "the erased placeholder left {} glyph vertices on screen", + after.len() + ); + } + + /// A raster pointing at a path that cannot exist. `resolve_image` memoises + /// per path for the life of the process, so each test uses its own path and + /// cannot inherit another's cached answer. + fn broken_raster(tag: &str) -> codec::entities::RasterImage { + use codec::entities::RasterImage; + use codec::types::Vector3; + + RasterImage::with_size( + &format!("/nonexistent-ocs-test/{tag}/missing-reference.png"), + Vector3::new(0.0, 0.0, 0.0), + 16.0, + 16.0, + 10.0, + 10.0, + ) + } } /// Delta-undo round-trips: a recorded entity-only edit must be exactly @@ -13539,42 +13600,27 @@ mod layout_cache_tests { s.add_entity(EntityType::LwPolyline(pl)); let bounds = s.model_space_extents().expect("Extents must exist"); - // Key vertices come from entities with distinct vertex positions (Line, LwPolyline). - // Line vertices: (0, 0, 10), (50, 100, 20) - // LwPolyline vertices: (300, -200, 0), (400, -100, 0) - // Min X = 0.0, Max X = 400.0 - // Min Y = -200.0, Max Y = 100.0 - // Min Z = 0.0, Max Z = 20.0 - assert!( - (bounds.0.x - 0.0).abs() < 1e-3, - "min.x mismatch: {}", - bounds.0.x - ); - assert!( - (bounds.1.x - 400.0).abs() < 1e-3, - "max.x mismatch: {}", - bounds.1.x - ); - assert!( - (bounds.0.y - (-200.0)).abs() < 1e-3, - "min.y mismatch: {}", - bounds.0.y - ); - assert!( - (bounds.1.y - 100.0).abs() < 1e-3, - "max.y mismatch: {}", - bounds.1.y - ); - assert!( - (bounds.0.z - 0.0).abs() < 1e-3, - "min.z mismatch: {}", - bounds.0.z - ); - assert!( - (bounds.1.z - 20.0).abs() < 1e-3, - "max.z mismatch: {}", - bounds.1.z - ); + // Every entity counts, curves included. The circle and the arc used to + // contribute nothing at all: the walk read only `key_vertices`, which + // is empty for anything tessellated, so the drawing looked 275 units + // narrower and 150 shorter than it is. + // Line: (0, 0, 10) .. (50, 100, 20) + // Circle: (150, 150, 0) .. (250, 250, 0) centre 200,200 r50 + // Arc: (-125, -50, -5) .. (-75, -25, -5) centre -100,-50 r25, 0..PI + // LwPolyline: (300, -200, 0) .. (400, -100, 0) + for (got, want, axis) in [ + (bounds.0.x, -125.0, "min.x"), + (bounds.1.x, 400.0, "max.x"), + (bounds.0.y, -200.0, "min.y"), + (bounds.1.y, 250.0, "max.y"), + (bounds.0.z, -5.0, "min.z"), + (bounds.1.z, 20.0, "max.z"), + ] { + assert!( + (got - want).abs() < 1e-3, + "{axis} mismatch: got {got}, want {want}" + ); + } } #[test] diff --git a/src/scene/pick/selection_state.rs b/src/scene/pick/selection_state.rs index 22faca9a5..b4fc62baa 100644 --- a/src/scene/pick/selection_state.rs +++ b/src/scene/pick/selection_state.rs @@ -59,6 +59,11 @@ pub struct SelectionState { pub middle_down: bool, pub middle_last_pos: Option, pub middle_last_press_time: Option, + /// Which way a ZOOM Dynamic drag is currently zooming: `true` while it is + /// zooming out. Only the magnifier cursor reads it. The sign is latched + /// rather than taken from the live `dy` so that the sub-pixel jitter of a + /// slow drag cannot strobe the `+` / `−` glyph. + pub zoom_dir_out: bool, } /// Transient state of the open right-click context menu. diff --git a/src/scene/view/camera.rs b/src/scene/view/camera.rs index 4f9974491..8384732c1 100644 --- a/src/scene/view/camera.rs +++ b/src/scene/view/camera.rs @@ -492,7 +492,7 @@ impl Camera { } pub fn zoom(&mut self, delta: f32) { - self.distance = (self.distance * (1.0 - delta * 0.1)).max(0.001); + self.distance = (self.distance * 0.9f32.powf(delta)).max(0.001); } /// World-space offset from `target` to the point under `screen` on the diff --git a/src/snap.rs b/src/snap.rs index 53bc6a779..39917f1a6 100644 --- a/src/snap.rs +++ b/src/snap.rs @@ -1755,6 +1755,53 @@ impl Snapper { if self.is_on(SnapType::Intersection) && (local_segments.is_some() || allow_unindexed_pairwise) { + // Unindexed fallback: also test non-adjacent segments belonging to the + // same wire. A polyline is one entity but its own segments may cross. + if local_segments.is_none() { + for wire in in_range_wires.iter() { + let src = wire_source(wire); + let segment_count = wire.points.len().saturating_sub(1); + + for ai in 0..segment_count { + let a0 = wp_f64(wire, ai); + let a1 = wp_f64(wire, ai + 1); + + let a_min_x = a0.x.min(a1.x); + let a_max_x = a0.x.max(a1.x); + let a_min_y = a0.y.min(a1.y); + let a_max_y = a0.y.max(a1.y); + + for bi in (ai + 1)..segment_count { + let b0 = wp_f64(wire, bi); + let b1 = wp_f64(wire, bi + 1); + + // Consecutive polyline segments meet at a normal vertex. + // That is already an Endpoint snap, not a self-intersection. + if segments_share_endpoint(a0, a1, b0, b1) { + continue; + } + + // Cheap AABB rejection before the exact 3-D intersection. + if a_max_x < b0.x.min(b1.x) + || a_min_x > b0.x.max(b1.x) + || a_max_y < b0.y.min(b1.y) + || a_min_y > b0.y.max(b1.y) + { + continue; + } + + if let Some(pt) = seg_intersect_3d(a0, a1, b0, b1) { + try_pt( + pt, + SnapType::Intersection, + src, + None, + ); + } + } + } + } + } if let Some(segments) = &local_segments { let local_wires: Vec<_> = wires.iter().filter(|wire| wire_in_range(wire)).collect(); let mut resolved_pairs = rustc_hash::FxHashSet::default(); @@ -1789,7 +1836,13 @@ impl Snapper { if b.min_x() > a.max_x() { break; } - if a.wire == b.wire || a.max_y() < b.min_y() || a.min_y() > b.max_y() { + let same_wire_neighbours = a.wire == b.wire + && segments_share_endpoint(a.a, a.b, b.a, b.b); + + if same_wire_neighbours + || a.max_y() < b.min_y() + || a.min_y() > b.max_y() + { continue; } if wires.source_wire(a.wire).zip(wires.source_wire(b.wire)) @@ -1801,11 +1854,23 @@ impl Snapper { world_to_screen(pt, view_rot, eye, bounds), cursor_screen, ) <= f32::EPSILON; + let primary = wires + .source_wire(a.wire) + .and_then(wire_source); + + let secondary = if a.wire == b.wire { + None + } else { + wires + .source_wire(b.wire) + .and_then(wire_source) + }; + try_pt( pt, SnapType::Intersection, - wires.source_wire(a.wire).and_then(wire_source), - wires.source_wire(b.wire).and_then(wire_source), + primary, + secondary, ); if exact_cursor { break 'intersection_sweep; @@ -3151,6 +3216,24 @@ pub(crate) fn exact_curve_intersections( Some(points) } +/// Two segments of the same polyline that merely meet at a shared endpoint +/// are neighbours, not a self-intersection. +/// +/// Non-adjacent segments are allowed to intersect even when they belong to +/// the same WireModel. +fn segments_share_endpoint( + a0: DVec3, + a1: DVec3, + b0: DVec3, + b1: DVec3, +) -> bool { + const EPS2: f64 = 1.0e-18; + + a0.distance_squared(b0) <= EPS2 + || a0.distance_squared(b1) <= EPS2 + || a1.distance_squared(b0) <= EPS2 + || a1.distance_squared(b1) <= EPS2 +} /// XY-plane segment-segment intersection. Returns `None` if parallel or outside. /// True 3D intersection of two segments: the point where their plan (XY) /// projections cross **and** both segments are at the same height there. Returns diff --git a/src/ui/overlay.rs b/src/ui/overlay.rs index c6d38fa84..adde3f65d 100644 --- a/src/ui/overlay.rs +++ b/src/ui/overlay.rs @@ -213,6 +213,18 @@ pub(crate) fn pick_box_aperture_px(value: i32) -> f32 { aperture.max(1.0) } +/// Which interactive navigation tool is armed. Each one owns the whole +/// viewport and hides the CAD crosshair, but they do not share a cursor: the +/// hand reads as "drag the sheet", which is wrong for a zoom or an orbit. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +pub enum NavCursor { + #[default] + None, + Pan, + Orbit, + Zoom, +} + #[derive(Clone, Copy)] pub struct CrosshairOptions { pub size_percent: i32, @@ -852,7 +864,7 @@ pub fn selection_overlay<'a>( dividers: Vec, pane_move_rect: Option, pane_drop_rect: Option, - pan_mode: bool, + nav: NavCursor, suppressed: bool, hover_locked: bool, crosshair_bg: [f32; 4], @@ -882,7 +894,7 @@ pub fn selection_overlay<'a>( dividers, pane_move_rect, pane_drop_rect, - pan_mode, + nav, suppressed, hover_locked, crosshair_bg, @@ -936,9 +948,10 @@ struct SelectionCanvas { pane_move_rect: Option, /// The pane under the cursor during a pane move (drop target), highlighted. pane_drop_rect: Option, - /// Interactive PAN mode: the crosshair is hidden and the cursor becomes a - /// hand so the viewport reads as a draggable surface. - pan_mode: bool, + /// Which interactive navigation tool is armed. Any of them hides the + /// crosshair — the viewport is being driven, not drawn on — and each + /// picks its own cursor in `mouse_interaction`. + nav: NavCursor, /// A ribbon dropdown (or similar overlay) is open over the viewport. The /// crosshair is not drawn and the OS cursor is shown normally so the panel /// is usable instead of the cursor vanishing over it. (#227) @@ -1197,13 +1210,30 @@ impl canvas::Program for SelectionCanvas { if self.suppressed { return mouse::Interaction::default(); } - // PAN mode owns the whole viewport: an open hand when hovering, a - // closed hand while dragging. - if self.pan_mode && cursor.is_over(bounds) { - return if self.selection.borrow().middle_down { - mouse::Interaction::Grabbing - } else { - mouse::Interaction::Grab + // An armed navigation tool owns the whole viewport, and the cursor + // says which one: a hand to drag the sheet, four-way arrows to swing + // the model, a magnifier that carries the sign of the zoom actually + // under way. Hovering before the drag starts shows the neutral "+" + // magnifier rather than guessing a direction. + if self.nav != NavCursor::None && cursor.is_over(bounds) { + let dragging = self.selection.borrow().middle_down; + return match self.nav { + NavCursor::Pan => { + if dragging { + mouse::Interaction::Grabbing + } else { + mouse::Interaction::Grab + } + } + NavCursor::Orbit => mouse::Interaction::AllScroll, + NavCursor::Zoom => { + if dragging && self.selection.borrow().zoom_dir_out { + mouse::Interaction::ZoomOut + } else { + mouse::Interaction::ZoomIn + } + } + NavCursor::None => unreachable!("guarded above"), }; } if self.show_viewcube { @@ -1367,8 +1397,14 @@ impl canvas::Program for SelectionCanvas { ); } - if let (Some(a), Some(b)) = (self.selection.borrow().box_anchor, self.selection.borrow().box_current) { - draw_marquee(&mut frame, a, b, self.selection.borrow().box_crossing, theme, &self.selection_visual, self.crosshair_bg); + // ZOOM Dynamic reuses `box_anchor` to remember where the drag began, + // so the marquee would otherwise rubber-band across the screen while + // the view zooms. Suppress the drawing, not the state — the anchor is + // still re-projected as the camera moves. + if self.nav != NavCursor::Zoom { + if let (Some(a), Some(b)) = (self.selection.borrow().box_anchor, self.selection.borrow().box_current) { + draw_marquee(&mut frame, a, b, self.selection.borrow().box_crossing, theme, &self.selection_visual, self.crosshair_bg); + } } // Preview marquee for point-picked windows (STRETCH) — same look, no pick. if let Some((a, b, crossing)) = self.selection.borrow().preview_box { @@ -1826,10 +1862,10 @@ impl canvas::Program for SelectionCanvas { // arrow (see `mouse_interaction`); drawing the CAD crosshair on // top of it would double up the visual feedback. let over_divider = self.divider_under(cursor, bounds); - // PAN mode replaces the crosshair with a hand cursor. + // An armed navigation tool replaces the crosshair with its own cursor. if !over_viewcube && !over_divider - && !self.pan_mode + && self.nav == NavCursor::None && !self.suppressed && self.crosshair.cursor_type == CursorType::Crosshair { diff --git a/src/ui/ribbon/mod.rs b/src/ui/ribbon/mod.rs index e277820b7..20989bd37 100644 --- a/src/ui/ribbon/mod.rs +++ b/src/ui/ribbon/mod.rs @@ -20,7 +20,7 @@ use crate::modules::{CadModule, IconKind, RibbonGroup, RibbonItem}; use crate::plugin::all_ribbon_modules; use crate::ui::properties::{linetype_display_name, lw_options, LinetypeItem}; -mod widgets; +pub(crate) mod widgets; mod draw_panel; pub(crate) use draw_panel::tools as panel_tools; mod modify_panel; @@ -745,6 +745,19 @@ impl Ribbon { // ── Dropdown overlay ────────────────────────────────────────────────── + /// Place `panel` (`w` wide) under dropdown `id`, closing on a click + /// outside it. + pub fn place_dropdown<'a>( + &self, + id: &str, + panel: Element<'a, Message>, + w: f32, + win_w: f32, + ) -> Element<'a, Message> { + let (align_right, h_pad, top) = self.dd_anchor(id, w, win_w); + dropdown_backdrop(position_ribbon_dropdown(panel, align_right, h_pad, top)) + } + pub fn dropdown_overlay( &self, undo_labels: &[String], diff --git a/src/ui/ribbon/widgets.rs b/src/ui/ribbon/widgets.rs index c5ea6acfa..74e7d9393 100644 --- a/src/ui/ribbon/widgets.rs +++ b/src/ui/ribbon/widgets.rs @@ -446,7 +446,7 @@ pub(super) fn combo_btn_style( } } -pub(super) fn popup_row_style(theme: &Theme, status: button::Status) -> button::Style { +pub(crate) fn popup_row_style(theme: &Theme, status: button::Status) -> button::Style { let palette = theme.palette(); let pair = if matches!(status, button::Status::Hovered | button::Status::Pressed) { palette.background.weak @@ -460,7 +460,7 @@ pub(super) fn popup_row_style(theme: &Theme, status: button::Status) -> button:: } } -pub(super) fn popup_panel_style(theme: &Theme) -> container::Style { +pub(crate) fn popup_panel_style(theme: &Theme) -> container::Style { let palette = theme.palette(); container::Style { background: Some(Background::Color(palette.background.base.color)), diff --git a/src/ui/theme_accessibility_tests.rs b/src/ui/theme_accessibility_tests.rs index f8e6caecf..8eb9ce90b 100644 --- a/src/ui/theme_accessibility_tests.rs +++ b/src/ui/theme_accessibility_tests.rs @@ -480,14 +480,16 @@ fn test_block_palette_contrast() { // a hardcoded foreground, these equality checks fail on every theme // whose surface text is not pure white. let (normal_bg, normal_fg) = block_card_colors(theme, false, BtnStatus::Active); + // Cards sit on the dock's base surface, so they take the weak pair + // to read as cards. assert_eq!( - normal_bg, p.background.base.color, - "Theme {:?} block card normal background drifted from base pair", + normal_bg, p.background.weak.color, + "Theme {:?} block card normal background drifted from weak pair", theme ); assert_eq!( - normal_fg, p.background.base.text, - "Theme {:?} block card normal label must use base text (was hardcoded WHITE)", + normal_fg, p.background.weak.text, + "Theme {:?} block card normal label must use weak text (was hardcoded WHITE)", theme ); @@ -505,11 +507,11 @@ fn test_block_palette_contrast() { theme ); - // Default-branch statuses (Active/Disabled/Focused/...) share the base pair. + // Default-branch statuses (Active/Disabled/Focused/...) share the weak pair. let (_, disabled_fg) = block_card_colors(theme, false, BtnStatus::Disabled); assert_eq!( - disabled_fg, p.background.base.text, - "Theme {:?} block card default-state label must use base text", + disabled_fg, p.background.weak.text, + "Theme {:?} block card default-state label must use weak text", theme ); diff --git a/src/ui/window/attdef_dialog.rs b/src/ui/window/attdef_dialog.rs index baf5702aa..bceab8f3e 100644 --- a/src/ui/window/attdef_dialog.rs +++ b/src/ui/window/attdef_dialog.rs @@ -125,8 +125,12 @@ fn input<'a>(value: &str, enabled: bool, on: fn(String) -> AttdefDialogMsg) -> E } /// The small button beside a field that takes the value from the drawing. -fn pick<'a>(glyph: &'static str, tip: String, message: Option) -> Element<'a, Message> { - let b = button(text(glyph).size(13).align_x(iced::Center).width(Fill)) +fn pick<'a>( + face: Element<'a, Message>, + tip: String, + message: Option, +) -> Element<'a, Message> { + let b = button(container(face).center_x(Fill)) .style(button_style(false)) .padding([4, 0]) .width(Length::Fixed(28.0)); @@ -155,7 +159,7 @@ fn error_band<'a>(error: &Option) -> Option> { crate::ui::icons::semantic(ICON_WARN, 14.0), text(error).size(11), Space::new().width(Fill), - button(text("×").size(12)) + button(crate::ui::icons::themed(crate::ui::icons::CLOSE, 10.0)) .on_press(msg(AttdefDialogMsg::DismissError)) .style(button_style(false)) .padding([1, 6]), @@ -174,6 +178,11 @@ fn error_band<'a>(error: &Option) -> Option> { ) } +/// The face of the buttons that take a value from the drawing. +fn screen_pick<'a>() -> Element<'a, Message> { + crate::ui::icons::themed(crate::ui::icons::SNAP, 14.0) +} + static ICON_WARN: &[u8] = include_bytes!("../../../assets/icons/ui/warning_triangle.svg"); fn footer<'a>(action: std::borrow::Cow<'static, str>, ok: AttdefDialogMsg) -> Element<'a, Message> { @@ -243,12 +252,12 @@ pub fn view<'a>(state: &'a AttdefDialogState, sizing: crate::ui::modal::ModalSiz let mut value = row![input(&state.default, true, AttdefDialogMsg::Default)].spacing(6); if multiline { value = value.push(pick( - "…", + text("...").size(13).into(), t!("Multiline editor").into_owned(), Some(AttdefDialogMsg::EditValue), )); } - value.push(pick("ƒ", t!("Insert field").into_owned(), Some(AttdefDialogMsg::InsertField))) + value.push(pick(text("fx").size(12).into(), t!("Insert field").into_owned(), Some(AttdefDialogMsg::InsertField))) } .spacing(6) .into(), @@ -301,7 +310,7 @@ pub fn view<'a>(state: &'a AttdefDialogState, sizing: crate::ui::modal::ModalSiz t!("Text height").into_owned(), row![ input(&state.height, size_on, AttdefDialogMsg::Height), - pick("⌖", t!("Specify on-screen").into_owned(), size_on.then_some(AttdefDialogMsg::PickHeight)), + pick(screen_pick(), t!("Specify on-screen").into_owned(), size_on.then_some(AttdefDialogMsg::PickHeight)), ] .spacing(6) .into(), @@ -311,7 +320,7 @@ pub fn view<'a>(state: &'a AttdefDialogState, sizing: crate::ui::modal::ModalSiz t!("Rotation").into_owned(), row![ input(&state.rotation, rot_on, AttdefDialogMsg::Rotation), - pick("⌖", t!("Specify on-screen").into_owned(), rot_on.then_some(AttdefDialogMsg::PickRotation)), + pick(screen_pick(), t!("Specify on-screen").into_owned(), rot_on.then_some(AttdefDialogMsg::PickRotation)), ] .spacing(6) .into(), @@ -322,7 +331,7 @@ pub fn view<'a>(state: &'a AttdefDialogState, sizing: crate::ui::modal::ModalSiz row![ input(&state.width, text_on && multiline, AttdefDialogMsg::Width), pick( - "⌖", + screen_pick(), t!("Specify on-screen").into_owned(), (text_on && multiline).then_some(AttdefDialogMsg::PickWidth) ), @@ -380,7 +389,7 @@ pub fn view_edit<'a>(state: &'a AttdefEditState, sizing: crate::ui::modal::Modal true, AttdefDialogMsg::EditDefault ), - pick("ƒ", t!("Insert field").into_owned(), Some(AttdefDialogMsg::EditInsertField)), + pick(text("fx").size(12).into(), t!("Insert field").into_owned(), Some(AttdefDialogMsg::EditInsertField)), ] .spacing(6) .align_y(iced::Center), diff --git a/src/ui/window/block_palette.rs b/src/ui/window/block_palette.rs index c5129e45b..17527437a 100644 --- a/src/ui/window/block_palette.rs +++ b/src/ui/window/block_palette.rs @@ -1,92 +1,293 @@ -//! Docked "Insert Block" panel: a searchable grid of block thumbnails that -//! inserts a clicked block, mirrors the existing `InsertBlockCommand` flow. +//! The docked Blocks palette: Current Drawing, Recent, Favorites and +//! Libraries tabs over one block list, a wildcard filter, six views, and the +//! insertion options. A click on a block starts placing it with the options; +//! a block dragged onto the drawing goes in where it is dropped. +use std::path::PathBuf; + +use crate::app::settings::{BlockInsertOptions, PaletteBlockRef}; use crate::app::Message; use crate::modules::IconKind; use crate::scene::model::wire_model::WireModel; use crate::ui::dock::PanelId; +use crate::ui::style::common::muted_style; +use crate::ui::style::form::{button_style, field_style}; +use crate::ui::window::pdf_dialogs::{accent_text, card_style, well_style}; use iced::widget::canvas::{Frame, Path, Program, Stroke}; -use iced::widget::{button, canvas, column, container, row, scrollable, text, text_input}; +use iced::widget::{ + button, canvas, checkbox, column, container, image, mouse_area, pick_list, row, scrollable, + text, text_input, Space, +}; use iced::{Background, Border, Color, Element, Fill, Length, Theme}; const TOOL_H: f32 = 22.0; -/// Character budget for a block card's label so all rows stay one line. -/// Fits the narrowest card (Small = 3 per row) at size 11 without wrapping. -const MAX_LABEL_CHARS: usize = 12; -/// Fixed single-line height of a card's label. Clips any text that still -/// won't fit so every cell in a grid row shares the same height. +/// Fixed single-line height of a card's label so every cell in a row +/// shares the same height. const LABEL_LINE_H: f32 = 16.0; +/// The palette's tabs. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum Tab { + #[default] + Current, + Recent, + Favorites, + Libraries, +} + +impl Tab { + fn label(self) -> String { + match self { + Tab::Current => crate::t!("Current Drawing"), + Tab::Recent => crate::t!("Recent"), + Tab::Favorites => crate::t!("Favorites"), + Tab::Libraries => crate::t!("Libraries"), + } + .into_owned() + } + + fn heading(self) -> String { + match self { + Tab::Current => crate::t!("Current Drawing Blocks"), + Tab::Recent => crate::t!("Recent Blocks"), + Tab::Favorites => crate::t!("Favorite Blocks"), + Tab::Libraries => crate::t!("Library Blocks"), + } + .into_owned() + } +} + +/// How the list is drawn. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum ViewMode { + ExtraLarge, + #[default] + Large, + Medium, + Small, + Details, + List, +} + +impl ViewMode { + pub const ALL: [ViewMode; 6] = [ + ViewMode::ExtraLarge, + ViewMode::Large, + ViewMode::Medium, + ViewMode::Small, + ViewMode::Details, + ViewMode::List, + ]; + + pub fn from_u8(v: u8) -> Self { + Self::ALL.get(v as usize).copied().unwrap_or_default() + } + + pub fn label(self) -> String { + match self { + ViewMode::ExtraLarge => crate::t!("Extra Large Icons"), + ViewMode::Large => crate::t!("Large Icons"), + ViewMode::Medium => crate::t!("Medium Icons"), + ViewMode::Small => crate::t!("Small Icons"), + ViewMode::Details => crate::t!("Details"), + ViewMode::List => crate::t!("List"), + } + .into_owned() + } + + /// Cards per row and thumbnail height for the icon views. + fn grid(self) -> Option<(usize, f32)> { + match self { + ViewMode::ExtraLarge => Some((2, 120.0)), + ViewMode::Large => Some((3, 70.0)), + ViewMode::Medium => Some((4, 46.0)), + _ => None, + } + } +} + +/// One thing the list shows. +#[derive(Debug, Clone, PartialEq)] +pub enum Item { + /// A block of the current drawing. + Current(String), + /// A recent or favorite block, possibly from another drawing. + Ref(PaletteBlockRef), + /// A drawing in a library folder (inserted as a block). + File(PathBuf), + /// A sub-folder of a library folder. + Folder(PathBuf), +} + +impl Item { + pub fn name(&self) -> String { + match self { + Item::Current(name) => name.clone(), + Item::Ref(r) => r.name.clone(), + Item::File(p) | Item::Folder(p) => p + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_default(), + } + } +} + +/// A switch or value of the Options card. +#[derive(Debug, Clone)] +pub enum OptionMsg { + InsertionPoint(bool), + Scale(bool), + Uniform(bool), + X(String), + Y(String), + Z(String), + Rotation(bool), + Angle(String), + AutoPlacement(bool), + Repeat(bool), + Explode(bool), +} + +/// The block context menu's commands. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum MenuAction { + Insert, + Redefine, + Favorite, + Unfavorite, + Edit, + Remove, +} + /// Every message the block palette emits. Wrapped in `Message::BlockPalette`. #[derive(Debug, Clone)] pub enum BlockPaletteMsg { Search(String), - CyclePreviewSize, + Tab(Tab), + View(ViewMode), PickFile, - Insert(String), + FilePicked(Result), Refresh, - FilePicked(Result), + Option(OptionMsg), + ToggleOptions, + LibraryBrowse, + LibraryPicked(Result), + LibrarySelect(String), + LibraryUp, + /// Pressed on an item (a click when released on it, else a drag). + Press(Item), + Release(Item), + Menu(Item, MenuAction), } -/// Thumbnail box sizes for the three preview-size settings. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub enum PreviewSize { - #[default] - Small, - Medium, - Large, -} - -impl PreviewSize { - /// Number of cards per grid row. - fn columns(self) -> usize { - match self { - PreviewSize::Small => 3, - PreviewSize::Medium => 2, - PreviewSize::Large => 1, - } - } - /// Height (px) of the thumbnail box inside each card. - fn box_height(self) -> f32 { - match self { - PreviewSize::Small => 56.0, - PreviewSize::Medium => 84.0, - PreviewSize::Large => 120.0, - } - } -} - -/// Advance to the next preview size (Small → Medium → Large → Small). -pub fn cycle_preview_size(s: PreviewSize) -> PreviewSize { - match s { - PreviewSize::Small => PreviewSize::Medium, - PreviewSize::Medium => PreviewSize::Large, - PreviewSize::Large => PreviewSize::Small, - } -} - -/// One block's cached preview wires. +/// One block of the current drawing with its cached preview. pub struct BlockEntry { pub name: String, pub wires: Vec, + pub annotative: bool, + pub dynamic: bool, +} + +/// A library folder entry. +pub struct LibraryEntry { + pub path: PathBuf, + pub folder: bool, + pub thumb: Option, } /// Panel state held on the app. #[derive(Default)] pub struct BlockPalette { + pub tab: Tab, pub search: String, - pub preview_size: PreviewSize, - /// Cached (name, wires) pairs, rebuilt on refresh. + pub view: ViewMode, + /// Current drawing blocks, rebuilt on refresh. pub blocks: Vec, - /// Block name currently being placed (INSERT active), for the highlight. + /// Block name currently being placed, for the highlight. pub placing: Option, - /// Last known block-name list, for the cheap stale check. pub cached_names: Vec, - /// Document tab and block-definition revision that produced `blocks`. - /// Names alone are not enough: separate drawings can share block names, - /// and a block definition can change without being renamed. pub source_tab_id: Option, pub source_block_epoch: u64, + pub recent: Vec, + pub favorites: Vec, + /// Library roots, last used first. + pub libraries: Vec, + /// The folder shown (a library root or one of its sub-folders). + pub library_dir: Option, + pub library_entries: Vec, + /// Thumbnails of drawings recent / favorite entries point at. + pub file_thumbs: std::collections::HashMap>, + pub options: BlockInsertOptions, + /// The typed X / Y / Z / angle fields. + pub option_text: [String; 4], + pub options_collapsed: bool, + /// The item a press started on (a drag until released on it). + pub pressed: Option, + /// Blocks brought in from drawings this session (upper name → file), + /// so the Recent list remembers the drawing. + pub file_sources: std::collections::HashMap, + /// Previews of blocks seen this session, keyed `source|NAME`, for + /// recent / favorite blocks of other drawings. + pub ref_wires: std::collections::HashMap>, +} + +impl BlockPalette { + pub fn set_options(&mut self, options: BlockInsertOptions) { + self.option_text = [ + short(options.x), + short(options.y), + short(options.z), + short(options.angle), + ]; + self.options = options; + } + + /// The items of the current tab that pass the filter. + pub fn items(&self) -> Vec { + let pattern = self.search.trim(); + let pass = |name: &str| { + pattern.is_empty() || crate::io::xref_model::wildcard_match(name, pattern) + }; + match self.tab { + Tab::Current => self + .blocks + .iter() + .filter(|b| pass(&b.name)) + .map(|b| Item::Current(b.name.clone())) + .collect(), + Tab::Recent => self + .recent + .iter() + .filter(|r| pass(&r.name)) + .map(|r| Item::Ref(r.clone())) + .collect(), + Tab::Favorites => self + .favorites + .iter() + .filter(|r| pass(&r.name)) + .map(|r| Item::Ref(r.clone())) + .collect(), + Tab::Libraries => self + .library_entries + .iter() + .filter(|e| e.folder || pass(&display_name(&e.path))) + .map(|e| if e.folder { Item::Folder(e.path.clone()) } else { Item::File(e.path.clone()) }) + .collect(), + } + } + + fn current_entry(&self, name: &str) -> Option<&BlockEntry> { + self.blocks.iter().find(|b| b.name.eq_ignore_ascii_case(name)) + } +} + +pub fn short(v: f64) -> String { + let t = format!("{v:.4}"); + t.trim_end_matches('0').trim_end_matches('.').to_string() +} + +fn display_name(path: &std::path::Path) -> String { + path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default() } /// Canvas program that draws a block's wires fit-to-box inside its bounds. @@ -106,7 +307,7 @@ impl<'a> Program for BlockPreviewCanvas<'a> { _cursor: iced::mouse::Cursor, ) -> Vec { let mut frame = Frame::new(renderer, bounds.size()); - let pad = 6.0; + let pad = 4.0; let inner_w = (bounds.width - 2.0 * pad).max(1.0); let inner_h = (bounds.height - 2.0 * pad).max(1.0); let (mut minx, mut miny, mut maxx, mut maxy) = @@ -124,7 +325,6 @@ impl<'a> Program for BlockPreviewCanvas<'a> { } } if !any { - // Placeholder box so an empty block still reads as a card. let rect = Path::rectangle(iced::Point::new(pad, pad), iced::Size::new(inner_w, inner_h)); frame.fill(&rect, Color { r: 0.10, g: 0.10, b: 0.10, a: 1.0 }); return vec![frame.into_geometry()]; @@ -134,9 +334,7 @@ impl<'a> Program for BlockPreviewCanvas<'a> { let scale = (inner_w / span_x).min(inner_h / span_y); let ox = (bounds.width - span_x * scale) * 0.5; let oy = (bounds.height - span_y * scale) * 0.5; - // World up → screen down; centre the fitted AABB. let map = |x: f32, y: f32| iced::Point::new(ox + (x - minx) * scale, oy + (maxy - y) * scale); - // Solid fills first so strokes sit on top. for w in self.wires { let col = Color { r: w.color[0], g: w.color[1], b: w.color[2], a: 0.55 }; for tri in w.fill_tris.chunks(3) { @@ -151,12 +349,11 @@ impl<'a> Program for BlockPreviewCanvas<'a> { } } } - // Polylines, split into runs on NaN separators (NaN = polyline break). for w in self.wires { let col = Color { r: w.color[0], g: w.color[1], b: w.color[2], a: 1.0 }; let width = if w.line_weight_px > 1.5 { 2.0 } else { 1.0 }; let mut run: Vec = Vec::new(); - let flush = |frame: &mut Frame, run: &mut Vec, col: Color, width: f32| { + let flush = |frame: &mut Frame, run: &mut Vec| { if run.len() >= 2 { let path = Path::new(|p| { p.move_to(run[0]); @@ -172,112 +369,513 @@ impl<'a> Program for BlockPreviewCanvas<'a> { if p[0].is_finite() && p[1].is_finite() { run.push(map(p[0], p[1])); } else { - flush(&mut frame, &mut run, col, width); + flush(&mut frame, &mut run); } } - flush(&mut frame, &mut run, col, width); + flush(&mut frame, &mut run); } vec![frame.into_geometry()] } } +fn pm(m: BlockPaletteMsg) -> Message { + Message::BlockPalette(m) +} + +/// The thumbnail of an item, `size` pixels tall. +fn thumb<'a>(palette: &'a BlockPalette, item: &Item, size: f32) -> Element<'a, Message> { + let blank = || -> Element<'a, Message> { + container(Space::new()) + .width(Fill) + .height(Length::Fixed(size)) + .style(|_: &Theme| container::Style { + background: Some(Background::Color(Color::from_rgb(0.10, 0.10, 0.10))), + ..Default::default() + }) + .into() + }; + let wires = |name: &str| palette.current_entry(name).map(|e| &e.wires[..]); + let file_thumb = |path: &str| palette.file_thumbs.get(path).cloned().flatten(); + match item { + Item::Current(name) => match wires(name) { + Some(w) => canvas(BlockPreviewCanvas { wires: w }).width(Fill).height(Length::Fixed(size)).into(), + None => blank(), + }, + Item::Ref(r) => { + if !r.drawing { + let seen = palette.ref_wires.get(&format!("{}|{}", r.source, r.name.to_ascii_uppercase())); + if let Some(w) = seen.map(|w| &w[..]).or_else(|| wires(&r.name)) { + return canvas(BlockPreviewCanvas { wires: w }).width(Fill).height(Length::Fixed(size)).into(); + } + } + match file_thumb(&r.source) { + Some(h) if r.drawing => image(h).width(Fill).height(Length::Fixed(size)).into(), + _ => blank(), + } + } + Item::File(path) => { + match palette.library_entries.iter().find(|e| &e.path == path).and_then(|e| e.thumb.clone()) { + Some(h) => image(h).width(Fill).height(Length::Fixed(size)).into(), + None => blank(), + } + } + Item::Folder(_) => container(crate::ui::icons::themed_secondary(crate::ui::icons::FOLDER_OPEN, size * 0.6)) + .center_x(Fill) + .center_y(Length::Fixed(size)) + .into(), + } +} + +/// The context menu for an item. +fn item_menu(tab: Tab, item: Item, in_drawing: bool, external: bool) -> Element<'static, Message> { + let entry = |label: String, action: MenuAction, enabled: bool| -> Element<'static, Message> { + let b = button(text(label).size(12)) + .width(Fill) + .padding([5, 12]) + .style(|theme: &Theme, status| { + let mut s = button::text(theme, status); + if matches!(status, button::Status::Hovered) { + s.background = Some(Background::Color(theme.palette().primary.base.color)); + s.text_color = theme.palette().primary.base.text; + } + s + }); + if enabled { + b.on_press(pm(BlockPaletteMsg::Menu(item.clone(), action))).into() + } else { + b.into() + } + }; + let favorite = if tab == Tab::Favorites { + entry(crate::t!("Remove from Favorites").into_owned(), MenuAction::Unfavorite, true) + } else { + entry(crate::t!("Add to Favorites").into_owned(), MenuAction::Favorite, !matches!(item, Item::Folder(_))) + }; + let remove_label = match tab { + Tab::Recent | Tab::Favorites => crate::t!("Remove from List"), + _ => crate::t!("Remove Block"), + }; + let remove_enabled = match tab { + Tab::Current => true, + Tab::Recent | Tab::Favorites => true, + Tab::Libraries => false, + }; + container( + column![ + entry(crate::t!("Insert").into_owned(), MenuAction::Insert, !matches!(item, Item::Folder(_))), + entry(crate::t!("Redefine").into_owned(), MenuAction::Redefine, external), + favorite, + container(Space::new()).height(1).width(Fill).style(|theme: &Theme| container::Style { + background: Some(Background::Color(theme.palette().background.neutral.color)), + ..Default::default() + }), + entry(crate::t!("Edit Block").into_owned(), MenuAction::Edit, in_drawing), + entry(remove_label.into_owned(), MenuAction::Remove, remove_enabled), + ] + .spacing(1), + ) + .padding(4) + .width(Length::Fixed(200.0)) + .style(|theme: &Theme| container::Style { + background: Some(Background::Color(theme.palette().background.weak.color)), + border: Border { + color: theme.palette().background.neutral.color, + width: 1.0, + radius: 4.0.into(), + }, + ..Default::default() + }) + .into() +} + +/// One item as a card / row, with press, release and the context menu. +fn item_view<'a>(palette: &'a BlockPalette, item: Item) -> Element<'a, Message> { + let name = item.name(); + let placing = matches!(&item, Item::Current(n) if palette.placing.as_deref() == Some(n.as_str())); + let in_drawing = palette.current_entry(&name).is_some() + && !matches!(&item, Item::Ref(r) if r.drawing) + && !matches!(item, Item::File(_) | Item::Folder(_)); + let external = matches!(&item, Item::Ref(r) if !r.source.is_empty()) || matches!(item, Item::File(_)); + let body: Element<'a, Message> = match palette.view.grid() { + Some((_, size)) => column![ + thumb(palette, &item, size), + text(crate::ui::text_util::elide(&name, 14)) + .size(11) + .width(Fill) + .height(Length::Fixed(LABEL_LINE_H)) + .center(), + ] + .spacing(4) + .into(), + None => match palette.view { + ViewMode::Small => row![ + container(thumb(palette, &item, 16.0)).width(Length::Fixed(20.0)), + text(crate::ui::text_util::elide(&name, 16)).size(11), + ] + .spacing(4) + .align_y(iced::Center) + .into(), + ViewMode::Details => { + let (ann, dynamic) = palette + .current_entry(&name) + .map_or((false, false), |e| (e.annotative, e.dynamic)); + let yes_no = |v: bool| if v { crate::t!("Yes") } else { crate::t!("No") }.into_owned(); + row![ + container(thumb(palette, &item, 16.0)).width(Length::Fixed(40.0)), + text(name.clone()).size(11).width(Fill), + text(format!("{} / {}", yes_no(ann), yes_no(dynamic))) + .size(11) + .width(Length::Fixed(140.0)), + ] + .spacing(4) + .align_y(iced::Center) + .into() + } + _ => text(name.clone()).size(11).width(Fill).into(), + }, + }; + let card = container(body) + .padding(if palette.view.grid().is_some() { 6 } else { 3 }) + .width(Fill) + .style(move |theme: &Theme| { + let (bg, fg) = block_card_colors(theme, placing, button::Status::Active); + container::Style { + background: Some(Background::Color(bg)), + text_color: Some(fg), + border: Border { + color: block_card_border(theme, placing), + width: 1.0, + radius: 4.0.into(), + }, + ..Default::default() + } + }); + let area = mouse_area(card) + .on_press(pm(BlockPaletteMsg::Press(item.clone()))) + .on_release(pm(BlockPaletteMsg::Release(item.clone()))) + .interaction(iced::mouse::Interaction::Pointer); + let tab = palette.tab; + iced_aw::ContextMenu::new(area, move || item_menu(tab, item.clone(), in_drawing, external)).into() +} + +fn icon_button<'a>(icon: IconKind, tip: &str, msg: BlockPaletteMsg) -> Element<'a, Message> { + let icon_el: Element<'_, Message> = match icon { + IconKind::Glyph(s) => text(s).size(15).into(), + IconKind::Svg(bytes) => crate::ui::icons::semantic(bytes, TOOL_H), + }; + crate::ui::dock::tool_button(icon_el, crate::t!(tip).into_owned(), pm(msg)) +} + +fn options_card<'a>(palette: &'a BlockPalette) -> Element<'a, Message> { + let o = &palette.options; + let check = |label: String, on: bool, f: fn(bool) -> OptionMsg| -> Element<'a, Message> { + checkbox(on) + .label(label) + .text_size(12) + .size(14) + .on_toggle(move |v| pm(BlockPaletteMsg::Option(f(v)))) + .into() + }; + let num = |value: &'a str, f: fn(String) -> OptionMsg, enabled: bool| -> Element<'a, Message> { + let mut input = text_input("", value).size(11).padding([3, 6]).width(Length::Fixed(48.0)).style(field_style); + if enabled { + input = input.on_input(move |v| pm(BlockPaletteMsg::Option(f(v)))); + } + input.into() + }; + let header = mouse_area( + row![ + text(crate::t!("Options").to_uppercase()).size(10).style(accent_text), + Space::new().width(Fill), + if palette.options_collapsed { + crate::ui::icons::themed_arrow_right(12.0) + } else { + crate::ui::icons::themed_arrow_down(12.0) + }, + ] + .align_y(iced::Center), + ) + .on_press(pm(BlockPaletteMsg::ToggleOptions)) + .interaction(iced::mouse::Interaction::Pointer); + let mut content = column![header].spacing(6); + if !palette.options_collapsed { + let scale_kinds = vec![crate::t!("Scale").into_owned(), crate::t!("Uniform Scale").into_owned()]; + let current_kind = scale_kinds[o.uniform as usize].clone(); + let uniform_label = scale_kinds[1].clone(); + let kind = pick_list(Some(current_kind), scale_kinds, |v: &String| v.clone()) + .on_select(move |v: String| pm(BlockPaletteMsg::Option(OptionMsg::Uniform(v == uniform_label)))) + .text_size(11) + .padding([3, 6]); + let fields_on = !o.scale; + let mut scale_row = row![ + checkbox(o.scale).size(14).on_toggle(|v| pm(BlockPaletteMsg::Option(OptionMsg::Scale(v)))), + kind, + ] + .spacing(6) + .align_y(iced::Center); + // The scale values sit on their own line under the switch. + let mut xyz_row = row![Space::new().width(Length::Fixed(20.0))].spacing(6).align_y(iced::Center); + if o.scale { + scale_row = scale_row.push(text(crate::t!("specified on screen")).size(11).style(muted_style)); + } else if o.uniform { + xyz_row = xyz_row.push(num(&palette.option_text[0], OptionMsg::X, fields_on)); + } else { + xyz_row = xyz_row + .push(text("X").size(11)) + .push(num(&palette.option_text[0], OptionMsg::X, fields_on)) + .push(text("Y").size(11)) + .push(num(&palette.option_text[1], OptionMsg::Y, fields_on)) + .push(text("Z").size(11)) + .push(num(&palette.option_text[2], OptionMsg::Z, fields_on)); + } + let mut rot_row = row![check(crate::t!("Rotation").into_owned(), o.rotation, OptionMsg::Rotation), Space::new().width(Fill)] + .spacing(6) + .align_y(iced::Center); + if o.rotation { + rot_row = rot_row.push(text(crate::t!("specified on screen")).size(11).style(muted_style)); + } else { + rot_row = rot_row + .push(num(&palette.option_text[3], OptionMsg::Angle, true)) + .push(text(crate::t!("Angle")).size(11)); + } + content = content + .push(check(crate::t!("Insertion Point").into_owned(), o.insertion_point, OptionMsg::InsertionPoint)) + .push(scale_row); + if !o.scale { + content = content.push(xyz_row); + } + content = content + .push(rot_row) + .push(check(crate::t!("Auto-Placement").into_owned(), o.auto_placement, OptionMsg::AutoPlacement)) + .push(check(crate::t!("Repeat Placement").into_owned(), o.repeat, OptionMsg::Repeat)) + .push(check(crate::t!("Explode").into_owned(), o.explode, OptionMsg::Explode)); + } + container(content).padding([8, 10]).width(Fill).style(card_style).into() +} + /// Build the docked panel element from the palette state. pub fn view(palette: &BlockPalette, width: f32, auto_collapse: bool) -> Element<'_, Message> { - let query = palette.search.trim().to_lowercase(); - let filtered: Vec<&BlockEntry> = palette - .blocks - .iter() - .filter(|block| query.is_empty() || block.name.to_lowercase().contains(&query)) - .collect(); + let title_bar = crate::ui::dock::title_bar(PanelId::BlockPalette, crate::t!("Blocks").into_owned(), auto_collapse); - let title_bar = crate::ui::dock::title_bar( - PanelId::BlockPalette, - crate::t!("Block Palette").into_owned(), - auto_collapse, - ); + let tabs = { + let buttons = [Tab::Current, Tab::Recent, Tab::Favorites, Tab::Libraries].map(|t| { + button(text(t.label()).size(10).width(Fill).align_x(iced::Center)) + .on_press(pm(BlockPaletteMsg::Tab(t))) + .style(button_style(t == palette.tab)) + .padding([5, 1]) + .width(Fill) + .into() + }); + container(iced::widget::Row::with_children(buttons).spacing(2)) + .padding(2) + .width(Fill) + .style(well_style) + }; - let search_input = text_input(&crate::t!("Search blocks…"), &palette.search) - .on_input(|v| Message::BlockPalette(BlockPaletteMsg::Search(v))) + let search_input = text_input(&crate::t!("Filter... (* ? wildcards)"), &palette.search) + .on_input(|v| pm(BlockPaletteMsg::Search(v))) .padding([4, 8]) - .size(12); - + .size(12) + .style(field_style); + let view_pick = pick_list(Some(palette.view.label()), ViewMode::ALL.map(|v| v.label()).to_vec(), |v: &String| v.clone()) + .on_select(|label: String| { + pm(BlockPaletteMsg::View( + ViewMode::ALL.into_iter().find(|v| v.label() == label).unwrap_or_default(), + )) + }) + .text_size(11) + .width(Length::Fixed(118.0)) + .padding([3, 6]); let header = row![ - search_input, + search_input.width(Fill), icon_button( IconKind::Svg(include_bytes!("../../../assets/icons/blocks/insert.svg")), - "Insert from file", + "Insert a drawing as a block", BlockPaletteMsg::PickFile, ), - icon_button( - IconKind::Svg(include_bytes!("../../../assets/icons/blocks/preview_size.svg")), - "Preview size", - BlockPaletteMsg::CyclePreviewSize, - ), + view_pick, ] .spacing(4) .align_y(iced::Center); - let body: Element<'_, Message> = if filtered.is_empty() { - let msg = if palette.cached_names.is_empty() { - "No blocks in this drawing" - } else { - "No matches" + let mut top = column![tabs, header].spacing(6); + if palette.tab == Tab::Libraries { + let names: Vec = palette.libraries.clone(); + let selected = palette.libraries.first().cloned(); + let mut lib_row = row![ + pick_list(selected, names, |v: &String| v.clone()) + .on_select(|v: String| pm(BlockPaletteMsg::LibrarySelect(v))) + .placeholder(crate::t!("No library").into_owned()) + .text_size(11) + .padding([3, 6]) + .width(Fill), + button(crate::ui::icons::themed(crate::ui::icons::FOLDER_OPEN, 14.0)) + .on_press(pm(BlockPaletteMsg::LibraryBrowse)) + .style(button_style(false)) + .padding([3, 8]), + ] + .spacing(4) + .align_y(iced::Center); + let in_sub = palette.library_dir.as_ref().is_some_and(|d| { + palette.libraries.first().is_some_and(|root| d != &PathBuf::from(root)) + }); + if in_sub { + lib_row = lib_row.push( + button(crate::ui::icons::themed_arrow_up(12.0)) + .on_press(pm(BlockPaletteMsg::LibraryUp)) + .style(button_style(false)) + .padding([3, 8]), + ); + } + top = top.push(lib_row); + if let (Some(root), Some(dir)) = (palette.libraries.first(), palette.library_dir.as_ref()) { + let root = PathBuf::from(root); + let shown = match dir.strip_prefix(root.parent().unwrap_or(&root)) { + Ok(rel) => rel.display().to_string(), + Err(_) => dir.display().to_string(), + }; + top = top.push(text(format!("{} {shown}", crate::t!("Path:"))).size(10).style(accent_text)); + } + } + top = top.push(text(palette.tab.heading().to_uppercase()).size(10).style(accent_text)); + + let items = palette.items(); + let body: Element<'_, Message> = if items.is_empty() { + let msg = match palette.tab { + Tab::Current if palette.cached_names.is_empty() => crate::t!("No blocks in this drawing"), + Tab::Libraries if palette.libraries.is_empty() => crate::t!("Choose a library folder with …"), + _ => crate::t!("No matches"), }; - container( - text(crate::t!(msg)) - .size(12) - .style(|theme: &Theme| iced::widget::text::Style { - color: Some( - theme - .palette() - .background - .base - .text - .scale_alpha(0.72), - ), - }), - ) - .center_x(Fill) - .center_y(Fill) + container(text(msg).size(12).style(muted_style)).center_x(Fill).center_y(Fill).width(Fill).height(Fill).into() + } else { + let mut col = column![].spacing(4); + match palette.view.grid() { + Some((per_row, _)) => { + for chunk in items.chunks(per_row) { + let mut r = row![].spacing(6).width(Fill); + for item in chunk { + r = r.push(item_view(palette, item.clone())); + } + for _ in chunk.len()..per_row { + r = r.push(Space::new().width(Fill)); + } + col = col.push(r); + } + } + None if palette.view == ViewMode::Small => { + for chunk in items.chunks(2) { + let mut r = row![].spacing(6).width(Fill); + for item in chunk { + r = r.push(item_view(palette, item.clone())); + } + if chunk.len() < 2 { + r = r.push(Space::new().width(Fill)); + } + col = col.push(r); + } + } + None => { + if palette.view == ViewMode::Details { + col = col.push( + row![ + text(crate::t!("Icon")).size(11).style(muted_style).width(Length::Fixed(40.0)), + text(crate::t!("Name")).size(11).style(muted_style).width(Fill), + text(crate::t!("Annotative / Dynamic")).size(11).style(muted_style).width(Length::Fixed(140.0)), + ] + .spacing(4), + ); + } + for item in items { + col = col.push(item_view(palette, item)); + } + } + } + scrollable(container(col).padding(iced::Padding { top: 0.0, right: 8.0, bottom: 6.0, left: 0.0 })) .width(Fill) .height(Fill) .into() - } else { - let mut col = column![].spacing(6); - for chunk in filtered.chunks(palette.preview_size.columns()) { - let mut r = row![].spacing(6).width(Fill); - for block in chunk { - r = r.push(block_card(palette, block)); - } - col = col.push(r); - } - scrollable( - container(col).padding(iced::Padding { - top: 0.0, - right: 8.0, - bottom: 6.0, - left: 0.0, - }), - ) - .width(Fill) - .height(Fill) - .into() }; - crate::ui::dock::frame(column![title_bar, header, body].spacing(6), width) + crate::ui::dock::frame(column![title_bar, top, body, options_card(palette)].spacing(6), width) } -/// Theme-aware foreground/background pair for a block card. -/// -/// Centralizes the card's text/background mapping so the contrast suite can -/// exercise the exact colors the UI renders (instead of re-implementing the -/// match). Normal cards use the `base` pair, hovered/pressed cards use the -/// `strong` pair, and the actively-placing card uses the `primary.base` pair. -pub(crate) fn block_card_colors( - theme: &Theme, - is_placing: bool, - status: button::Status, -) -> (Color, Color) { +/// Width of the ribbon's block gallery. +pub const GALLERY_W: f32 = 300.0; + +/// The ribbon Insert gallery: the drawing's blocks (a click inserts one), +/// then the ways into the palette's other tabs. +pub fn gallery(palette: &BlockPalette) -> Element<'_, Message> { + let ribbon_command = |cmd: String| Message::RibbonToolClick { + tool_id: "INSERT".to_string(), + event: crate::modules::ModuleEvent::Command(cmd), + }; + let mut grid = column![].spacing(4); + for chunk in palette.blocks.chunks(4) { + let mut r = row![].spacing(4).width(Fill); + for block in chunk { + let card = column![ + canvas(BlockPreviewCanvas { wires: &block.wires }).width(Fill).height(Length::Fixed(48.0)), + text(crate::ui::text_util::elide(&block.name, 10)) + .size(10) + .width(Fill) + .height(Length::Fixed(LABEL_LINE_H)) + .center(), + ] + .spacing(2); + r = r.push( + button(card) + .on_press(ribbon_command(format!("_BLOCKINSERT {}", block.name))) + .style(crate::ui::ribbon::widgets::popup_row_style) + .padding(3) + .width(Fill), + ); + } + for _ in chunk.len()..4 { + r = r.push(Space::new().width(Fill)); + } + grid = grid.push(r); + } + let blocks: Element<'_, Message> = if palette.blocks.is_empty() { + container(text(crate::t!("No blocks in this drawing")).size(11).style(muted_style)) + .padding(10) + .center_x(Fill) + .into() + } else { + scrollable(container(grid).padding(iced::Padding { top: 0.0, right: 8.0, bottom: 0.0, left: 0.0 })) + .height(Length::Shrink) + .into() + }; + let link = |label: std::borrow::Cow<'static, str>, tab: &str| -> Element<'_, Message> { + button(text(label.into_owned()).size(11)) + .on_press(ribbon_command(format!("_BLOCKSPALETTE {tab}"))) + .style(crate::ui::ribbon::widgets::popup_row_style) + .width(Fill) + .padding([5, 10]) + .into() + }; + container( + column![ + text(crate::t!("Current Drawing Blocks").to_uppercase()).size(10).style(accent_text), + container(blocks).height(Length::Fixed(((palette.blocks.len().div_ceil(4)) as f32 * 79.0).clamp(36.0, 260.0))), + container(Space::new()).height(1).width(Fill).style(|theme: &Theme| container::Style { + background: Some(Background::Color(theme.palette().background.neutral.color)), + ..Default::default() + }), + link(crate::t!("Recent Blocks..."), "RECENT"), + link(crate::t!("Favorite Blocks..."), "FAVORITES"), + link(crate::t!("Blocks from Libraries..."), "LIBRARIES"), + ] + .spacing(6), + ) + .padding(8) + .width(Length::Fixed(GALLERY_W)) + .style(crate::ui::ribbon::widgets::popup_panel_style) + .into() +} + +/// Theme-aware foreground/background pair for a block card (normal, +/// hovered/pressed, or the block being placed). +pub(crate) fn block_card_colors(theme: &Theme, is_placing: bool, status: button::Status) -> (Color, Color) { let palette = theme.palette(); if is_placing { (palette.primary.base.color, palette.primary.base.text) @@ -286,15 +884,12 @@ pub(crate) fn block_card_colors( button::Status::Hovered | button::Status::Pressed => { (palette.background.strong.color, palette.background.strong.text) } - _ => ( - palette.background.base.color, - palette.background.base.text, - ), + _ => (palette.background.weak.color, palette.background.weak.text), } } } -/// Border color for a block card in a given state (exposed for contrast tests). +/// Border color for a block card in a given state. pub(crate) fn block_card_border(theme: &Theme, is_placing: bool) -> Color { let palette = theme.palette(); if is_placing { @@ -304,11 +899,8 @@ pub(crate) fn block_card_border(theme: &Theme, is_placing: bool) -> Color { } } -/// Theme-aware foreground for the palette's header icon buttons. -/// -/// Transparent resting state composites over the dock (`base`) background; -/// hovered/pressed state sits on the `strong` surface, so each state pairs -/// with its own background's text color to preserve contrast. +/// Theme-aware foreground for the palette's header icon buttons (shared with +/// the dock's tool buttons). pub(crate) fn block_icon_button_text_color(theme: &Theme, status: button::Status) -> Color { let palette = theme.palette(); match status { @@ -316,64 +908,3 @@ pub(crate) fn block_icon_button_text_color(theme: &Theme, status: button::Status _ => palette.background.base.text, } } - -fn block_card<'a>(palette: &'a BlockPalette, block: &'a BlockEntry) -> Element<'a, Message> { - let is_placing = palette.placing.as_deref() == Some(block.name.as_str()); - let preview = canvas(BlockPreviewCanvas { wires: &block.wires }) - .width(Fill) - .height(Length::Fixed(palette.preview_size.box_height())); - // Fixed height guarantees the label occupies exactly one line, so every - // cell in a row keeps the same height even for long names (the elide budget - // handles overflow, and this clips anything that still won't fit). - // - // NOTE: no explicit `.color(...)` here — the label inherits the button's - // `text_color` so normal/hover/placing states each get a theme-aware - // foreground with sufficient contrast on both light and dark themes. - // (A hardcoded `Color::WHITE` is unreadable on light themes.) - let label = text(crate::ui::text_util::elide(&block.name, MAX_LABEL_CHARS)) - .size(11) - .width(Fill) - .height(Length::Fixed(LABEL_LINE_H)) - .center(); - let content = column![preview, label].spacing(4).padding(6); - button(content) - .on_press(Message::BlockPalette(BlockPaletteMsg::Insert(block.name.clone()))) - .width(Fill) - .style(move |theme: &Theme, status| { - let (bg, fg) = block_card_colors(theme, is_placing, status); - let border = block_card_border(theme, is_placing); - button::Style { - background: Some(Background::Color(bg)), - border: Border { - color: border, - width: 1.0, - radius: 3.0.into(), - }, - text_color: fg, - ..Default::default() - } - }) - .into() -} - -fn icon_button<'a>(icon: IconKind, tip: &str, msg: BlockPaletteMsg) -> Element<'a, Message> { - let icon_el: Element<'_, Message> = match icon { - // No explicit color — inherits the button's theme-aware `text_color` - // so glyphs stay legible on light themes. - IconKind::Glyph(s) => text(s).size(15).into(), - IconKind::Svg(bytes) => crate::ui::icons::semantic(bytes, TOOL_H), - }; - crate::ui::dock::tool_button(icon_el, crate::t!(tip).into_owned(), Message::BlockPalette(msg)) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn preview_size_cycles() { - assert_eq!(cycle_preview_size(PreviewSize::Small), PreviewSize::Medium); - assert_eq!(cycle_preview_size(PreviewSize::Medium), PreviewSize::Large); - assert_eq!(cycle_preview_size(PreviewSize::Large), PreviewSize::Small); - } -} diff --git a/src/ui/window/options.rs b/src/ui/window/options.rs index 0e6d109f5..81b16fcbe 100644 --- a/src/ui/window/options.rs +++ b/src/ui/window/options.rs @@ -55,7 +55,7 @@ pub struct AppPrefs { pub commandline_fade_ms: i32, /// ZOOMWHEEL: reverse the mouse-wheel zoom direction. pub zoom_wheel_reversed: bool, - /// ZOOMFACTOR, 3..=100. + /// ZOOMFACTOR, 3..=500. pub zoom_factor: i32, /// TEXTEDITMODE: TEXTEDIT keeps prompting for the next object. pub texteditmode: bool, @@ -1181,10 +1181,10 @@ pub fn view_window<'a>( Space::new().height(12), row![ text(crate::t!("Zoom factor")).size(12).width(150), - slider(3..=100, prefs.zoom_factor.clamp(3, 100), Message::ZoomFactorChanged) + slider(3..=500, prefs.zoom_factor.clamp(3, 500), Message::ZoomFactorChanged) .step(1) .width(Fill), - text(prefs.zoom_factor.clamp(3, 100).to_string()).size(11).width(44), + text(prefs.zoom_factor.clamp(3, 500).to_string()).size(11).width(44), ] .spacing(10) .align_y(iced::Center), diff --git a/src/ui/window/pdf_dialogs.rs b/src/ui/window/pdf_dialogs.rs index 685409323..e2cc8e4f9 100644 --- a/src/ui/window/pdf_dialogs.rs +++ b/src/ui/window/pdf_dialogs.rs @@ -251,7 +251,7 @@ pub(crate) fn card_style(theme: &Theme) -> container::Style { } } -fn well_style(theme: &Theme) -> container::Style { +pub(crate) fn well_style(theme: &Theme) -> container::Style { container::Style { background: Some(Background::Color(theme.palette().background.base.color)), border: Border { diff --git a/tests/integration.rs b/tests/integration.rs index d695e4ffb..dd6d13d09 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -72,5 +72,8 @@ mod viewport_wide_polyline_plot_width; #[path = "xclip_plot_test.rs"] mod xclip_plot_test; +#[path = "zoom_extents_coverage.rs"] +mod zoom_extents_coverage; + #[path = "zoom_extents_xline.rs"] mod zoom_extents_xline; diff --git a/tests/zoom_extents_coverage.rs b/tests/zoom_extents_coverage.rs new file mode 100644 index 000000000..455349760 --- /dev/null +++ b/tests/zoom_extents_coverage.rs @@ -0,0 +1,123 @@ +//! ZOOM EXTENTS must frame every visible entity, the way commercial solutions +//! do — no heuristic allowed to decide that part of the drawing does not count. +//! +//! Two things used to stop it. A quartile filter dropped any wire whose +//! centroid sat far from the consensus of the others, so a detail parked away +//! from the main body was silently left outside the fit. And HATCH tessellates +//! to no wires at all, so a drawing whose outermost geometry is a fill had +//! nothing for the model-space walk to read. +#![cfg(not(target_arch = "wasm32"))] + +use codec::entities::hatch::{BoundaryEdge, BoundaryPath, LineEdge}; +use codec::entities::{Hatch, Line}; +use codec::types::{Vector2, Vector3}; +use codec::EntityType; +use OpenCADStudio::scene::Scene; + +fn add_line(scene: &mut Scene, x1: f64, y1: f64, x2: f64, y2: f64) { + let mut line = Line::new(); + line.start = Vector3::new(x1, y1, 0.0); + line.end = Vector3::new(x2, y2, 0.0); + scene.add_entity(EntityType::Line(line)); +} + +/// A solid square fill with corners (x, y) .. (x + size, y + size). +fn square_hatch(x: f64, y: f64, size: f64) -> Hatch { + let mut path = BoundaryPath::new(); + for (sx, sy, ex, ey) in [ + (0.0, 0.0, size, 0.0), + (size, 0.0, size, size), + (size, size, 0.0, size), + (0.0, size, 0.0, 0.0), + ] { + path.edges.push(BoundaryEdge::Line(LineEdge { + start: Vector2::new(x + sx, y + sy), + end: Vector2::new(x + ex, y + ey), + })); + } + let mut hatch = Hatch::new(); + hatch.paths.push(path); + hatch +} + +#[test] +fn fit_all_reaches_an_entity_parked_far_from_the_rest() { + let mut scene = Scene::new(); + // A dense consensus cluster — more than the eight wires the old quartile + // filter needed before it switched itself on. + for i in 0..12 { + let x = i as f64; + add_line(&mut scene, x, 0.0, x, 10.0); + } + // …and one line far outside it. Nothing about it is invalid: it is simply + // somewhere else, which is exactly what the filter could not tell apart + // from parser junk. + add_line(&mut scene, 50_000.0, 50_000.0, 50_010.0, 50_010.0); + + scene.fit_all(); + + // The fit centres on the extents box, so the target alone says whether the + // remote line counted: dropping it leaves the camera on the cluster at + // about (5.5, 5) instead of halfway out to (25005, 25005). + let cam = scene.camera.borrow(); + assert!( + (cam.target.x - 25_005.0).abs() < 50.0 && (cam.target.y - 25_005.0).abs() < 50.0, + "the fit must span cluster and remote line, target = {:?}", + cam.target + ); + assert!( + cam.ortho_size() >= 25_005.0, + "the view must be wide enough to hold both, ortho_size = {}", + cam.ortho_size() + ); +} + +#[test] +fn fit_all_frames_a_hatch_only_drawing() { + let mut scene = Scene::new(); + scene.add_entity(EntityType::Hatch(square_hatch(100.0, 200.0, 40.0))); + + scene.fit_all(); + + // Nothing else is in the drawing, so without the fill the camera never + // moves and stays on the origin. + let cam = scene.camera.borrow(); + assert!( + (cam.target.x - 120.0).abs() < 1.0 && (cam.target.y - 220.0).abs() < 1.0, + "a hatch is geometry: ZOOM EXTENTS must centre on it, target = {:?}", + cam.target + ); + assert!( + cam.ortho_size() >= 20.0, + "the view must hold the whole fill, ortho_size = {}", + cam.ortho_size() + ); +} + +#[test] +fn fit_all_keeps_a_hatch_outside_the_wires_in_view() { + let mut scene = Scene::new(); + for i in 0..12 { + let x = i as f64; + add_line(&mut scene, x, 0.0, x, 10.0); + } + // The fill is the outermost thing in the drawing; the wires alone would + // frame a 12 x 10 strip and leave it off screen. + scene.add_entity(EntityType::Hatch(square_hatch(500.0, 500.0, 50.0))); + + scene.fit_all(); + + // Wires alone centre near (5.5, 5); including the fill pulls the centre + // out to about (275, 275). + let cam = scene.camera.borrow(); + assert!( + cam.target.x > 200.0 && cam.target.y > 200.0, + "the fill must widen the fit past the wires, target = {:?}", + cam.target + ); + assert!( + cam.ortho_size() >= 275.0, + "the view must reach the far corner of the fill, ortho_size = {}", + cam.ortho_size() + ); +}