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