diff --git a/Cargo.lock b/Cargo.lock index 8f2648119..1b9698e1d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4206,7 +4206,7 @@ dependencies = [ [[package]] name = "opencadcodec" version = "0.5.5" -source = "git+https://github.com/HakanSeven12/opencadcodec.git?rev=fe69506#fe69506cb99dea6f4c4a73b690a27fdf04403ea0" +source = "git+https://github.com/ramox81/cadcodec.git?rev=196a5ae#196a5aeabc1f22a526259d6788efcf0df9ad24e9" dependencies = [ "ahash 0.8.12", "anyhow", diff --git a/Cargo.toml b/Cargo.toml index c8e80051b..5c361677b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -90,6 +90,9 @@ objc2-app-kit = { version = "0.2", features = ["NSApplication", "NSResponder", " block2 = "0.5" objc2-foundation = { version = "0.2", features = ["NSArray", "NSURL", "NSString", "NSObject", "NSNotification", "NSThread", "NSEnumerator"] } +[patch."https://github.com/HakanSeven12/opencadcodec.git"] +opencadcodec = { git = "https://github.com/ramox81/cadcodec.git", rev = "196a5ae" } + [patch.crates-io] iced_core = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aad9e00b9327cb7b8546ed84db39" } iced_widget = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aad9e00b9327cb7b8546ed84db39" } diff --git a/locales/ar-SA/opencadstudio.ftl b/locales/ar-SA/opencadstudio.ftl index 5aeb8221c..a39edaf12 100644 --- a/locales/ar-SA/opencadstudio.ftl +++ b/locales/ar-SA/opencadstudio.ftl @@ -5939,3 +5939,28 @@ style-manager = .center-of-text-overline = مركز النص مع خط علوي .block-extents = نطاق الكتلة .base-point = نقطة الأساس +count-feature = + .palette = Count + .menu = Count + .selection = Count Selection + .review = Review Count Details + .insert-field = Insert Count Field + .expand-by = EXPAND BY + .mirror-state = Mirror State + .area = Count in a specified area + .create-table = Create Table + .back = Back to List + .match-scale = Match Scale + .match-mirror = Match Mirror State + .error-report = Count Error Report: __ocs_fmt_0__ + .toolbar-count = Count: __ocs_fmt_0__ + .overlap = Overlapping object + .show-instances = Show Count Instances + .geometries = Geometries: __ocs_fmt_0__ + .invalid-title = Count - Invalid Area + .invalid-message = The changes to count boundary have resulted to an invalid count area. What do you want to do? + .invalid-undo = Undo the changes to the count boundary + .invalid-undo-detail = The last change is undone; the boundary and the count area come back. + .invalid-continue = Continue and count the entire model space + .invalid-continue-detail = The count area is dropped; counting goes on in all of model space. + .invalid-always = Always perform my current choice diff --git a/locales/bg-BG/opencadstudio.ftl b/locales/bg-BG/opencadstudio.ftl index 07809df3b..294ebfdf2 100644 --- a/locales/bg-BG/opencadstudio.ftl +++ b/locales/bg-BG/opencadstudio.ftl @@ -5927,3 +5927,28 @@ style-manager = .center-of-text-overline = Център на надчертан текст .block-extents = Обхват на блок .base-point = Базова точка +count-feature = + .palette = Count + .menu = Count + .selection = Count Selection + .review = Review Count Details + .insert-field = Insert Count Field + .expand-by = EXPAND BY + .mirror-state = Mirror State + .area = Count in a specified area + .create-table = Create Table + .back = Back to List + .match-scale = Match Scale + .match-mirror = Match Mirror State + .error-report = Count Error Report: __ocs_fmt_0__ + .toolbar-count = Count: __ocs_fmt_0__ + .overlap = Overlapping object + .show-instances = Show Count Instances + .geometries = Geometries: __ocs_fmt_0__ + .invalid-title = Count - Invalid Area + .invalid-message = The changes to count boundary have resulted to an invalid count area. What do you want to do? + .invalid-undo = Undo the changes to the count boundary + .invalid-undo-detail = The last change is undone; the boundary and the count area come back. + .invalid-continue = Continue and count the entire model space + .invalid-continue-detail = The count area is dropped; counting goes on in all of model space. + .invalid-always = Always perform my current choice diff --git a/locales/cs-CZ/opencadstudio.ftl b/locales/cs-CZ/opencadstudio.ftl index 45ca319b6..b26dfe485 100644 --- a/locales/cs-CZ/opencadstudio.ftl +++ b/locales/cs-CZ/opencadstudio.ftl @@ -5924,3 +5924,28 @@ style-manager = .center-of-text-overline = Střed textu s přeškrtnutím .block-extents = Blokovat rozsahy .base-point = Základní bod +count-feature = + .palette = Count + .menu = Count + .selection = Count Selection + .review = Review Count Details + .insert-field = Insert Count Field + .expand-by = EXPAND BY + .mirror-state = Mirror State + .area = Count in a specified area + .create-table = Create Table + .back = Back to List + .match-scale = Match Scale + .match-mirror = Match Mirror State + .error-report = Count Error Report: __ocs_fmt_0__ + .toolbar-count = Count: __ocs_fmt_0__ + .overlap = Overlapping object + .show-instances = Show Count Instances + .geometries = Geometries: __ocs_fmt_0__ + .invalid-title = Count - Invalid Area + .invalid-message = The changes to count boundary have resulted to an invalid count area. What do you want to do? + .invalid-undo = Undo the changes to the count boundary + .invalid-undo-detail = The last change is undone; the boundary and the count area come back. + .invalid-continue = Continue and count the entire model space + .invalid-continue-detail = The count area is dropped; counting goes on in all of model space. + .invalid-always = Always perform my current choice diff --git a/locales/de-DE/opencadstudio.ftl b/locales/de-DE/opencadstudio.ftl index 0b211d9e9..14e1ceaa3 100644 --- a/locales/de-DE/opencadstudio.ftl +++ b/locales/de-DE/opencadstudio.ftl @@ -5925,3 +5925,28 @@ style-manager = .center-of-text-overline = Textzentrum mit Oberstrich .block-extents = Blockgrenzen .base-point = Basispunkt +count-feature = + .palette = Count + .menu = Count + .selection = Count Selection + .review = Review Count Details + .insert-field = Insert Count Field + .expand-by = EXPAND BY + .mirror-state = Mirror State + .area = Count in a specified area + .create-table = Create Table + .back = Back to List + .match-scale = Match Scale + .match-mirror = Match Mirror State + .error-report = Count Error Report: __ocs_fmt_0__ + .toolbar-count = Count: __ocs_fmt_0__ + .overlap = Overlapping object + .show-instances = Show Count Instances + .geometries = Geometries: __ocs_fmt_0__ + .invalid-title = Count - Invalid Area + .invalid-message = The changes to count boundary have resulted to an invalid count area. What do you want to do? + .invalid-undo = Undo the changes to the count boundary + .invalid-undo-detail = The last change is undone; the boundary and the count area come back. + .invalid-continue = Continue and count the entire model space + .invalid-continue-detail = The count area is dropped; counting goes on in all of model space. + .invalid-always = Always perform my current choice diff --git a/locales/el-GR/opencadstudio.ftl b/locales/el-GR/opencadstudio.ftl index 5a9422483..047ec281e 100644 --- a/locales/el-GR/opencadstudio.ftl +++ b/locales/el-GR/opencadstudio.ftl @@ -5927,3 +5927,28 @@ style-manager = .center-of-text-overline = Κέντρο του κειμένου με επιγράμμιση .block-extents = Όρια μπλοκ .base-point = Σημείο βάσης +count-feature = + .palette = Count + .menu = Count + .selection = Count Selection + .review = Review Count Details + .insert-field = Insert Count Field + .expand-by = EXPAND BY + .mirror-state = Mirror State + .area = Count in a specified area + .create-table = Create Table + .back = Back to List + .match-scale = Match Scale + .match-mirror = Match Mirror State + .error-report = Count Error Report: __ocs_fmt_0__ + .toolbar-count = Count: __ocs_fmt_0__ + .overlap = Overlapping object + .show-instances = Show Count Instances + .geometries = Geometries: __ocs_fmt_0__ + .invalid-title = Count - Invalid Area + .invalid-message = The changes to count boundary have resulted to an invalid count area. What do you want to do? + .invalid-undo = Undo the changes to the count boundary + .invalid-undo-detail = The last change is undone; the boundary and the count area come back. + .invalid-continue = Continue and count the entire model space + .invalid-continue-detail = The count area is dropped; counting goes on in all of model space. + .invalid-always = Always perform my current choice diff --git a/locales/en-US/opencadstudio.ftl b/locales/en-US/opencadstudio.ftl index 2949f49ae..96e53825f 100644 --- a/locales/en-US/opencadstudio.ftl +++ b/locales/en-US/opencadstudio.ftl @@ -5928,3 +5928,28 @@ style-manager = .center-of-text-overline = Center of text with overline .block-extents = Block extents .base-point = Base point +count-feature = + .palette = Count + .menu = Count + .selection = Count Selection + .review = Review Count Details + .insert-field = Insert Count Field + .expand-by = EXPAND BY + .mirror-state = Mirror State + .area = Count in a specified area + .create-table = Create Table + .back = Back to List + .match-scale = Match Scale + .match-mirror = Match Mirror State + .error-report = Count Error Report: __ocs_fmt_0__ + .toolbar-count = Count: __ocs_fmt_0__ + .overlap = Overlapping object + .show-instances = Show Count Instances + .geometries = Geometries: __ocs_fmt_0__ + .invalid-title = Count - Invalid Area + .invalid-message = The changes to count boundary have resulted to an invalid count area. What do you want to do? + .invalid-undo = Undo the changes to the count boundary + .invalid-undo-detail = The last change is undone; the boundary and the count area come back. + .invalid-continue = Continue and count the entire model space + .invalid-continue-detail = The count area is dropped; counting goes on in all of model space. + .invalid-always = Always perform my current choice diff --git a/locales/es-ES/opencadstudio.ftl b/locales/es-ES/opencadstudio.ftl index 6235fc49f..79a021096 100644 --- a/locales/es-ES/opencadstudio.ftl +++ b/locales/es-ES/opencadstudio.ftl @@ -5927,3 +5927,28 @@ style-manager = .center-of-text-overline = Centro del texto con suprarrayado .block-extents = Extensión del bloque .base-point = Punto base +count-feature = + .palette = Count + .menu = Count + .selection = Count Selection + .review = Review Count Details + .insert-field = Insert Count Field + .expand-by = EXPAND BY + .mirror-state = Mirror State + .area = Count in a specified area + .create-table = Create Table + .back = Back to List + .match-scale = Match Scale + .match-mirror = Match Mirror State + .error-report = Count Error Report: __ocs_fmt_0__ + .toolbar-count = Count: __ocs_fmt_0__ + .overlap = Overlapping object + .show-instances = Show Count Instances + .geometries = Geometries: __ocs_fmt_0__ + .invalid-title = Count - Invalid Area + .invalid-message = The changes to count boundary have resulted to an invalid count area. What do you want to do? + .invalid-undo = Undo the changes to the count boundary + .invalid-undo-detail = The last change is undone; the boundary and the count area come back. + .invalid-continue = Continue and count the entire model space + .invalid-continue-detail = The count area is dropped; counting goes on in all of model space. + .invalid-always = Always perform my current choice diff --git a/locales/fi-FI/opencadstudio.ftl b/locales/fi-FI/opencadstudio.ftl index 377ab482f..72cbd4dcd 100644 --- a/locales/fi-FI/opencadstudio.ftl +++ b/locales/fi-FI/opencadstudio.ftl @@ -5924,3 +5924,28 @@ style-manager = .center-of-text-overline = Tekstin keskikohta yliviivauksella .block-extents = Estä laajuudet .base-point = Peruspiste +count-feature = + .palette = Count + .menu = Count + .selection = Count Selection + .review = Review Count Details + .insert-field = Insert Count Field + .expand-by = EXPAND BY + .mirror-state = Mirror State + .area = Count in a specified area + .create-table = Create Table + .back = Back to List + .match-scale = Match Scale + .match-mirror = Match Mirror State + .error-report = Count Error Report: __ocs_fmt_0__ + .toolbar-count = Count: __ocs_fmt_0__ + .overlap = Overlapping object + .show-instances = Show Count Instances + .geometries = Geometries: __ocs_fmt_0__ + .invalid-title = Count - Invalid Area + .invalid-message = The changes to count boundary have resulted to an invalid count area. What do you want to do? + .invalid-undo = Undo the changes to the count boundary + .invalid-undo-detail = The last change is undone; the boundary and the count area come back. + .invalid-continue = Continue and count the entire model space + .invalid-continue-detail = The count area is dropped; counting goes on in all of model space. + .invalid-always = Always perform my current choice diff --git a/locales/fr-FR/opencadstudio.ftl b/locales/fr-FR/opencadstudio.ftl index 39881c6cd..702e42640 100644 --- a/locales/fr-FR/opencadstudio.ftl +++ b/locales/fr-FR/opencadstudio.ftl @@ -5926,3 +5926,28 @@ style-manager = .center-of-text-overline = Centre du texte avec surlignement .block-extents = Limites du bloc .base-point = Point de base +count-feature = + .palette = Count + .menu = Count + .selection = Count Selection + .review = Review Count Details + .insert-field = Insert Count Field + .expand-by = EXPAND BY + .mirror-state = Mirror State + .area = Count in a specified area + .create-table = Create Table + .back = Back to List + .match-scale = Match Scale + .match-mirror = Match Mirror State + .error-report = Count Error Report: __ocs_fmt_0__ + .toolbar-count = Count: __ocs_fmt_0__ + .overlap = Overlapping object + .show-instances = Show Count Instances + .geometries = Geometries: __ocs_fmt_0__ + .invalid-title = Count - Invalid Area + .invalid-message = The changes to count boundary have resulted to an invalid count area. What do you want to do? + .invalid-undo = Undo the changes to the count boundary + .invalid-undo-detail = The last change is undone; the boundary and the count area come back. + .invalid-continue = Continue and count the entire model space + .invalid-continue-detail = The count area is dropped; counting goes on in all of model space. + .invalid-always = Always perform my current choice diff --git a/locales/hi-IN/opencadstudio.ftl b/locales/hi-IN/opencadstudio.ftl index 25de21e6b..0ab2cf7fa 100644 --- a/locales/hi-IN/opencadstudio.ftl +++ b/locales/hi-IN/opencadstudio.ftl @@ -5915,3 +5915,28 @@ style-manager = .center-of-text-overline = ऊपरी रेखा सहित टेक्स्ट का केंद्र .block-extents = ब्लॉक परिमिति .base-point = आधार बिंदु +count-feature = + .palette = Count + .menu = Count + .selection = Count Selection + .review = Review Count Details + .insert-field = Insert Count Field + .expand-by = EXPAND BY + .mirror-state = Mirror State + .area = Count in a specified area + .create-table = Create Table + .back = Back to List + .match-scale = Match Scale + .match-mirror = Match Mirror State + .error-report = Count Error Report: __ocs_fmt_0__ + .toolbar-count = Count: __ocs_fmt_0__ + .overlap = Overlapping object + .show-instances = Show Count Instances + .geometries = Geometries: __ocs_fmt_0__ + .invalid-title = Count - Invalid Area + .invalid-message = The changes to count boundary have resulted to an invalid count area. What do you want to do? + .invalid-undo = Undo the changes to the count boundary + .invalid-undo-detail = The last change is undone; the boundary and the count area come back. + .invalid-continue = Continue and count the entire model space + .invalid-continue-detail = The count area is dropped; counting goes on in all of model space. + .invalid-always = Always perform my current choice diff --git a/locales/hu-HU/opencadstudio.ftl b/locales/hu-HU/opencadstudio.ftl index 3ecb0b524..a92b034f2 100644 --- a/locales/hu-HU/opencadstudio.ftl +++ b/locales/hu-HU/opencadstudio.ftl @@ -5926,3 +5926,28 @@ style-manager = .center-of-text-overline = Szöveg közepe áthúzással .block-extents = Kiterjedések blokkolása .base-point = Alappont +count-feature = + .palette = Count + .menu = Count + .selection = Count Selection + .review = Review Count Details + .insert-field = Insert Count Field + .expand-by = EXPAND BY + .mirror-state = Mirror State + .area = Count in a specified area + .create-table = Create Table + .back = Back to List + .match-scale = Match Scale + .match-mirror = Match Mirror State + .error-report = Count Error Report: __ocs_fmt_0__ + .toolbar-count = Count: __ocs_fmt_0__ + .overlap = Overlapping object + .show-instances = Show Count Instances + .geometries = Geometries: __ocs_fmt_0__ + .invalid-title = Count - Invalid Area + .invalid-message = The changes to count boundary have resulted to an invalid count area. What do you want to do? + .invalid-undo = Undo the changes to the count boundary + .invalid-undo-detail = The last change is undone; the boundary and the count area come back. + .invalid-continue = Continue and count the entire model space + .invalid-continue-detail = The count area is dropped; counting goes on in all of model space. + .invalid-always = Always perform my current choice diff --git a/locales/it-IT/opencadstudio.ftl b/locales/it-IT/opencadstudio.ftl index f2fce8f34..d4f900ae2 100644 --- a/locales/it-IT/opencadstudio.ftl +++ b/locales/it-IT/opencadstudio.ftl @@ -5926,3 +5926,28 @@ style-manager = .center-of-text-overline = Centro del testo con sopralineato .block-extents = Blocca estensioni .base-point = Punto base +count-feature = + .palette = Count + .menu = Count + .selection = Count Selection + .review = Review Count Details + .insert-field = Insert Count Field + .expand-by = EXPAND BY + .mirror-state = Mirror State + .area = Count in a specified area + .create-table = Create Table + .back = Back to List + .match-scale = Match Scale + .match-mirror = Match Mirror State + .error-report = Count Error Report: __ocs_fmt_0__ + .toolbar-count = Count: __ocs_fmt_0__ + .overlap = Overlapping object + .show-instances = Show Count Instances + .geometries = Geometries: __ocs_fmt_0__ + .invalid-title = Count - Invalid Area + .invalid-message = The changes to count boundary have resulted to an invalid count area. What do you want to do? + .invalid-undo = Undo the changes to the count boundary + .invalid-undo-detail = The last change is undone; the boundary and the count area come back. + .invalid-continue = Continue and count the entire model space + .invalid-continue-detail = The count area is dropped; counting goes on in all of model space. + .invalid-always = Always perform my current choice diff --git a/locales/ja-JP/opencadstudio.ftl b/locales/ja-JP/opencadstudio.ftl index de4ee732e..8b1b1dbde 100644 --- a/locales/ja-JP/opencadstudio.ftl +++ b/locales/ja-JP/opencadstudio.ftl @@ -5915,3 +5915,28 @@ style-manager = .center-of-text-overline = 上線付きテキストの中心 .block-extents = ブロック範囲 .base-point = 基点 +count-feature = + .palette = Count + .menu = Count + .selection = Count Selection + .review = Review Count Details + .insert-field = Insert Count Field + .expand-by = EXPAND BY + .mirror-state = Mirror State + .area = Count in a specified area + .create-table = Create Table + .back = Back to List + .match-scale = Match Scale + .match-mirror = Match Mirror State + .error-report = Count Error Report: __ocs_fmt_0__ + .toolbar-count = Count: __ocs_fmt_0__ + .overlap = Overlapping object + .show-instances = Show Count Instances + .geometries = Geometries: __ocs_fmt_0__ + .invalid-title = Count - Invalid Area + .invalid-message = The changes to count boundary have resulted to an invalid count area. What do you want to do? + .invalid-undo = Undo the changes to the count boundary + .invalid-undo-detail = The last change is undone; the boundary and the count area come back. + .invalid-continue = Continue and count the entire model space + .invalid-continue-detail = The count area is dropped; counting goes on in all of model space. + .invalid-always = Always perform my current choice diff --git a/locales/ko-KR/opencadstudio.ftl b/locales/ko-KR/opencadstudio.ftl index d2d20aa96..6c3081482 100644 --- a/locales/ko-KR/opencadstudio.ftl +++ b/locales/ko-KR/opencadstudio.ftl @@ -5926,3 +5926,28 @@ style-manager = .center-of-text-overline = 윗줄이 있는 텍스트 중앙 .block-extents = 블록 범위 .base-point = 기준점 +count-feature = + .palette = Count + .menu = Count + .selection = Count Selection + .review = Review Count Details + .insert-field = Insert Count Field + .expand-by = EXPAND BY + .mirror-state = Mirror State + .area = Count in a specified area + .create-table = Create Table + .back = Back to List + .match-scale = Match Scale + .match-mirror = Match Mirror State + .error-report = Count Error Report: __ocs_fmt_0__ + .toolbar-count = Count: __ocs_fmt_0__ + .overlap = Overlapping object + .show-instances = Show Count Instances + .geometries = Geometries: __ocs_fmt_0__ + .invalid-title = Count - Invalid Area + .invalid-message = The changes to count boundary have resulted to an invalid count area. What do you want to do? + .invalid-undo = Undo the changes to the count boundary + .invalid-undo-detail = The last change is undone; the boundary and the count area come back. + .invalid-continue = Continue and count the entire model space + .invalid-continue-detail = The count area is dropped; counting goes on in all of model space. + .invalid-always = Always perform my current choice diff --git a/locales/nl-NL/opencadstudio.ftl b/locales/nl-NL/opencadstudio.ftl index 5b64e8180..c3fb6b01e 100644 --- a/locales/nl-NL/opencadstudio.ftl +++ b/locales/nl-NL/opencadstudio.ftl @@ -5925,3 +5925,28 @@ style-manager = .center-of-text-overline = Tekstmiddelpunt met bovenstreep .block-extents = Symboolgrenzen .base-point = Basispunt +count-feature = + .palette = Count + .menu = Count + .selection = Count Selection + .review = Review Count Details + .insert-field = Insert Count Field + .expand-by = EXPAND BY + .mirror-state = Mirror State + .area = Count in a specified area + .create-table = Create Table + .back = Back to List + .match-scale = Match Scale + .match-mirror = Match Mirror State + .error-report = Count Error Report: __ocs_fmt_0__ + .toolbar-count = Count: __ocs_fmt_0__ + .overlap = Overlapping object + .show-instances = Show Count Instances + .geometries = Geometries: __ocs_fmt_0__ + .invalid-title = Count - Invalid Area + .invalid-message = The changes to count boundary have resulted to an invalid count area. What do you want to do? + .invalid-undo = Undo the changes to the count boundary + .invalid-undo-detail = The last change is undone; the boundary and the count area come back. + .invalid-continue = Continue and count the entire model space + .invalid-continue-detail = The count area is dropped; counting goes on in all of model space. + .invalid-always = Always perform my current choice diff --git a/locales/pl-PL/opencadstudio.ftl b/locales/pl-PL/opencadstudio.ftl index 84e340728..53ad2866a 100644 --- a/locales/pl-PL/opencadstudio.ftl +++ b/locales/pl-PL/opencadstudio.ftl @@ -5926,3 +5926,28 @@ style-manager = .center-of-text-overline = Środek tekstu z podkreśleniem .block-extents = Zasięg bloku .base-point = Punkt bazowy +count-feature = + .palette = Count + .menu = Count + .selection = Count Selection + .review = Review Count Details + .insert-field = Insert Count Field + .expand-by = EXPAND BY + .mirror-state = Mirror State + .area = Count in a specified area + .create-table = Create Table + .back = Back to List + .match-scale = Match Scale + .match-mirror = Match Mirror State + .error-report = Count Error Report: __ocs_fmt_0__ + .toolbar-count = Count: __ocs_fmt_0__ + .overlap = Overlapping object + .show-instances = Show Count Instances + .geometries = Geometries: __ocs_fmt_0__ + .invalid-title = Count - Invalid Area + .invalid-message = The changes to count boundary have resulted to an invalid count area. What do you want to do? + .invalid-undo = Undo the changes to the count boundary + .invalid-undo-detail = The last change is undone; the boundary and the count area come back. + .invalid-continue = Continue and count the entire model space + .invalid-continue-detail = The count area is dropped; counting goes on in all of model space. + .invalid-always = Always perform my current choice diff --git a/locales/pt-BR/opencadstudio.ftl b/locales/pt-BR/opencadstudio.ftl index fcaf2f718..6bfa83486 100644 --- a/locales/pt-BR/opencadstudio.ftl +++ b/locales/pt-BR/opencadstudio.ftl @@ -5927,3 +5927,28 @@ style-manager = .center-of-text-overline = Centro do texto com sobrelinha .block-extents = Extensões do bloco .base-point = Ponto base +count-feature = + .palette = Count + .menu = Count + .selection = Count Selection + .review = Review Count Details + .insert-field = Insert Count Field + .expand-by = EXPAND BY + .mirror-state = Mirror State + .area = Count in a specified area + .create-table = Create Table + .back = Back to List + .match-scale = Match Scale + .match-mirror = Match Mirror State + .error-report = Count Error Report: __ocs_fmt_0__ + .toolbar-count = Count: __ocs_fmt_0__ + .overlap = Overlapping object + .show-instances = Show Count Instances + .geometries = Geometries: __ocs_fmt_0__ + .invalid-title = Count - Invalid Area + .invalid-message = The changes to count boundary have resulted to an invalid count area. What do you want to do? + .invalid-undo = Undo the changes to the count boundary + .invalid-undo-detail = The last change is undone; the boundary and the count area come back. + .invalid-continue = Continue and count the entire model space + .invalid-continue-detail = The count area is dropped; counting goes on in all of model space. + .invalid-always = Always perform my current choice diff --git a/locales/ru-RU/opencadstudio.ftl b/locales/ru-RU/opencadstudio.ftl index a7db84262..6bffa6354 100644 --- a/locales/ru-RU/opencadstudio.ftl +++ b/locales/ru-RU/opencadstudio.ftl @@ -5928,3 +5928,28 @@ style-manager = .center-of-text-overline = Центр текста с надчёркиванием .block-extents = Габариты блока .base-point = Базовая точка +count-feature = + .palette = Count + .menu = Count + .selection = Count Selection + .review = Review Count Details + .insert-field = Insert Count Field + .expand-by = EXPAND BY + .mirror-state = Mirror State + .area = Count in a specified area + .create-table = Create Table + .back = Back to List + .match-scale = Match Scale + .match-mirror = Match Mirror State + .error-report = Count Error Report: __ocs_fmt_0__ + .toolbar-count = Count: __ocs_fmt_0__ + .overlap = Overlapping object + .show-instances = Show Count Instances + .geometries = Geometries: __ocs_fmt_0__ + .invalid-title = Count - Invalid Area + .invalid-message = The changes to count boundary have resulted to an invalid count area. What do you want to do? + .invalid-undo = Undo the changes to the count boundary + .invalid-undo-detail = The last change is undone; the boundary and the count area come back. + .invalid-continue = Continue and count the entire model space + .invalid-continue-detail = The count area is dropped; counting goes on in all of model space. + .invalid-always = Always perform my current choice diff --git a/locales/tr-TR/opencadstudio.ftl b/locales/tr-TR/opencadstudio.ftl index 0d4587ec8..945dc938e 100644 --- a/locales/tr-TR/opencadstudio.ftl +++ b/locales/tr-TR/opencadstudio.ftl @@ -5850,3 +5850,28 @@ style-manager = .center-of-text-overline = Üst çizgili metin merkezi .block-extents = Blok sınırları .base-point = Taban noktası +count-feature = + .palette = Sayım + .menu = Say + .selection = Seçimi say + .review = Sayım ayrıntılarını incele + .insert-field = Sayım alanı ekle + .expand-by = GENİŞLET + .mirror-state = Ayna durumu + .area = Belirli bir alanda say + .create-table = Tablo Oluştur + .back = Listeye dön + .match-scale = Ölçeği Eşleştir + .match-mirror = Ayna Durumunu Eşleştir + .error-report = Sayım hata raporu: __ocs_fmt_0__ + .toolbar-count = Sayım: __ocs_fmt_0__ + .overlap = Üst üste binen nesne + .show-instances = Örnekleri göster + .geometries = Geometriler: __ocs_fmt_0__ + .invalid-title = Sayım - Geçersiz Alan + .invalid-message = Sayım sınırındaki değişiklikler geçersiz bir sayım alanıyla sonuçlandı. Ne yapmak istersiniz? + .invalid-undo = Sayım sınırındaki değişiklikleri geri al + .invalid-undo-detail = Son işlem geri alınır, sınır ve sayım alanı eski haline döner. + .invalid-continue = Devam et ve tüm model alanını say + .invalid-continue-detail = Sayım alanı kalkar; sayım tüm model alanında sürer. + .invalid-always = Geçerli seçimimi her zaman uygula diff --git a/locales/zh-CN/opencadstudio.ftl b/locales/zh-CN/opencadstudio.ftl index 086345d66..a9fdfa9bc 100644 --- a/locales/zh-CN/opencadstudio.ftl +++ b/locales/zh-CN/opencadstudio.ftl @@ -5910,3 +5910,28 @@ style-manager = .center-of-text-overline = 带上划线的文字中心 .block-extents = 块范围 .base-point = 基点 +count-feature = + .palette = Count + .menu = Count + .selection = Count Selection + .review = Review Count Details + .insert-field = Insert Count Field + .expand-by = EXPAND BY + .mirror-state = Mirror State + .area = Count in a specified area + .create-table = Create Table + .back = Back to List + .match-scale = Match Scale + .match-mirror = Match Mirror State + .error-report = Count Error Report: __ocs_fmt_0__ + .toolbar-count = Count: __ocs_fmt_0__ + .overlap = Overlapping object + .show-instances = Show Count Instances + .geometries = Geometries: __ocs_fmt_0__ + .invalid-title = Count - Invalid Area + .invalid-message = The changes to count boundary have resulted to an invalid count area. What do you want to do? + .invalid-undo = Undo the changes to the count boundary + .invalid-undo-detail = The last change is undone; the boundary and the count area come back. + .invalid-continue = Continue and count the entire model space + .invalid-continue-detail = The count area is dropped; counting goes on in all of model space. + .invalid-always = Always perform my current choice diff --git a/locales/zh-TW/opencadstudio.ftl b/locales/zh-TW/opencadstudio.ftl index 5a043837f..ec8dffc8b 100644 --- a/locales/zh-TW/opencadstudio.ftl +++ b/locales/zh-TW/opencadstudio.ftl @@ -5922,3 +5922,28 @@ style-manager = .center-of-text-overline = 帶上划線的文字中心 .block-extents = 圖塊範圍 .base-point = 基點 +count-feature = + .palette = Count + .menu = Count + .selection = Count Selection + .review = Review Count Details + .insert-field = Insert Count Field + .expand-by = EXPAND BY + .mirror-state = Mirror State + .area = Count in a specified area + .create-table = Create Table + .back = Back to List + .match-scale = Match Scale + .match-mirror = Match Mirror State + .error-report = Count Error Report: __ocs_fmt_0__ + .toolbar-count = Count: __ocs_fmt_0__ + .overlap = Overlapping object + .show-instances = Show Count Instances + .geometries = Geometries: __ocs_fmt_0__ + .invalid-title = Count - Invalid Area + .invalid-message = The changes to count boundary have resulted to an invalid count area. What do you want to do? + .invalid-undo = Undo the changes to the count boundary + .invalid-undo-detail = The last change is undone; the boundary and the count area come back. + .invalid-continue = Continue and count the entire model space + .invalid-continue-detail = The count area is dropped; counting goes on in all of model space. + .invalid-always = Always perform my current choice diff --git a/src/app/commands/count.rs b/src/app/commands/count.rs new file mode 100644 index 000000000..d10041a43 --- /dev/null +++ b/src/app/commands/count.rs @@ -0,0 +1,825 @@ +//! COUNT and its family: count mode (COUNT, COUNTAREA, COUNTCLOSE), the +//! Count palette (COUNTLIST, COUNTLISTCLOSE), count fields (COUNTFIELD, +//! UPDATEFIELD), count tables (COUNTTABLE) and the COUNT* variables. + +use crate::app::{Message, OpenCADStudio}; +use crate::command::CadCommand; +use crate::modules::annotate::count_cmd::{CountCommand, CountTableCommand, UpdateFieldCommand}; +use crate::modules::annotate::field_cmd::FieldPlaceCommand; +use crate::ui::window::count_palette::{aci_rgba, CountMode, CountMsg, CountTarget, RowAction, AREA_LAYER}; +use codec::count::{area_json, block_json, single_json, CountKey}; +use codec::{EntityType, Handle}; +use iced::Task; + +/// The variables this family answers for. +pub(super) const COUNT_SYSVARS: &[&str] = + &["COUNTNUMBER", "COUNTSERVICE", "COUNTPALETTESTATE", "COUNTCOLOR", "COUNTERRORCOLOR"]; + +fn hex(h: Handle) -> String { + format!("{:X}", h.value()) +} + +fn parse_handle(s: &str) -> Option { + u64::from_str_radix(s.trim(), 16).ok().map(Handle::new) +} + +fn parse_xy(s: &str) -> Option<[f64; 2]> { + let (x, y) = s.split_once(',')?; + Some([x.trim().parse().ok()?, y.trim().parse().ok()?]) +} + +/// A field referring to `boundary` keeps the count area's polyline. +fn boundary_in_use(doc: &codec::CadDocument, boundary: Handle) -> bool { + let needle = format!("\"boundaryObjectHandle\":\"{}\"", hex(boundary)); + doc.fields.values().any(|f| f.code.contains(&needle)) +} + +impl OpenCADStudio { + pub(super) fn dispatch_count(&mut self, cmd: &str, i: usize) -> Option> { + let cmd = cmd.trim(); + let (verb, rest) = cmd.split_once(char::is_whitespace).unwrap_or((cmd, "")); + let verb = verb.to_ascii_uppercase(); + let rest = rest.trim(); + if verb == "SETVAR" { + let (name, value) = rest.split_once(char::is_whitespace).unwrap_or((rest, "")); + let name = name.to_ascii_uppercase(); + if !COUNT_SYSVARS.contains(&name.as_str()) { + return None; + } + self.count_sysvar(i, &name, Some(value.trim()).filter(|v| !v.is_empty())); + return Some(Task::none()); + } + if COUNT_SYSVARS.contains(&verb.as_str()) { + self.count_sysvar(i, &verb, Some(rest).filter(|v| !v.is_empty())); + return Some(Task::none()); + } + match verb.as_str() { + "COUNT" => { + let preselected = self.tabs[i].scene.selected_handles_in_order(); + let current = matches!(rest.trim_start_matches('_').to_ascii_uppercase().as_str(), "C"); + if current && !preselected.is_empty() { + // Count Selection: the selection is the target. + self.command_line.push_output(&format!("{} found", preselected.len())); + let handles: Vec = preselected.iter().map(|h| hex(*h)).collect(); + self.tabs[i].scene.deselect_all(); + return Some(self.dispatch_command(&format!("_COUNTRUN C T {}", handles.join(",")))); + } + // Plain COUNT asks for its targets itself. + self.tabs[i].scene.deselect_all(); + let command = if current { CountCommand::with_area("C") } else { CountCommand::new(false) }; + Some(self.start_count_command(i, Box::new(command), "COUNT")) + } + "COUNTAREA" => Some(self.start_count_command(i, Box::new(CountCommand::new(true)), "COUNTAREA")), + "COUNTCLOSE" => { + self.close_count(i); + Some(Task::none()) + } + "COUNTLIST" => { + self.set_count_palette(true); + Some(Task::none()) + } + "COUNTLISTCLOSE" => { + self.set_count_palette(false); + Some(Task::none()) + } + "_COUNTLISTTOGGLE" => { + let open = !self.count_palette.show; + self.set_count_palette(open); + Some(Task::none()) + } + "COUNTFIELD" => { + match self.count_field_code(i) { + Some(code) => return Some(self.start_count_field(i, code)), + None if self.tabs[i].count.is_some() => {} + None => self.command_line.push_error("** COUNTFIELD command only available during Count. **"), + } + Some(Task::none()) + } + "COUNTTABLE" => { + let doc = &self.tabs[i].scene.document; + let mut blocks = Vec::new(); + let mut others = [0usize; 3]; + for br in doc.block_records.iter() { + let upper = br.name.to_ascii_uppercase(); + if upper == "*MODEL_SPACE" || upper.starts_with("*PAPER_SPACE") { + continue; + } + if br.flags.is_xref || br.flags.is_xref_overlay { + others[0] += 1; + } else if br.name.contains('|') { + others[1] += 1; + } else if br.name.starts_with('*') { + others[2] += 1; + } else { + blocks.push(br.name.clone()); + } + } + blocks.sort_by_key(|b| b.to_ascii_uppercase()); + Some(self.start_count_command(i, Box::new(CountTableCommand::new(blocks, others)), "COUNTTABLE")) + } + "UPDATEFIELD" => { + let preselected = self.tabs[i].scene.selected_handles_in_order(); + if !preselected.is_empty() { + self.update_fields(i, &preselected); + return Some(Task::none()); + } + Some(self.start_count_command(i, Box::new(UpdateFieldCommand::new()), "UPDATEFIELD")) + } + "_UPDATEFIELDRUN" => { + let handles: Vec = rest.split(',').filter_map(parse_handle).collect(); + self.tabs[i].scene.deselect_all(); + self.update_fields(i, &handles); + Some(Task::none()) + } + "_COUNTRUN" => { + self.count_run(i, rest); + Some(Task::none()) + } + "_COUNTAREASET" => { + let tokens: Vec<&str> = rest.split_whitespace().collect(); + if let Some((area, boundary)) = self.resolve_count_area(i, &tokens) { + let mut mode = self.tabs[i].count.take().unwrap_or_default(); + if mode.boundary != boundary { + self.drop_count_boundary(i, mode.boundary); + } + mode.area = area; + mode.boundary = boundary; + mode.cursor = None; + self.tabs[i].count = Some(mode); + self.apply_count_display(i); + self.command_line.push_output("count area is active."); + } + Some(Task::none()) + } + "_COUNTTABLEPLACE" => { + let (point, names) = rest.split_once(' ').unwrap_or((rest, "")); + let p: Vec = point.split(',').filter_map(|v| v.parse().ok()).collect(); + if p.len() == 3 { + let names: Vec = names.split('|').filter(|n| !n.is_empty()).map(str::to_string).collect(); + self.place_count_table(i, [p[0], p[1], p[2]], names); + } + Some(Task::none()) + } + _ => None, + } + } + + fn start_count_command(&mut self, i: usize, command: Box, _name: &str) -> Task { + self.reset_command_start_state(i); + self.command_line.push_info(&command.prompt()); + self.tabs[i].active_cmd = Some(command); + self.push_ucs_to_cmd(i); + self.sync_dyn_fields(); + self.focus_cmd_input() + } + + /// COUNTNUMBER / COUNTPALETTESTATE are read-only; COUNTSERVICE, + /// COUNTCOLOR and COUNTERRORCOLOR take a value (asked for when none is + /// given). + fn count_sysvar(&mut self, i: usize, name: &str, value: Option<&str>) { + let read_only = match name { + "COUNTNUMBER" => Some( + self.tabs[i] + .count + .as_ref() + .filter(|m| m.target.is_some()) + .map(|m| m.count(&self.tabs[i].scene.document)) + .unwrap_or(0), + ), + "COUNTPALETTESTATE" => Some(self.count_palette.show as usize), + _ => None, + }; + if let Some(v) = read_only { + self.command_line.push_output(&format!("{name} = {v} (read only)")); + return; + } + let (current, min, max) = match name { + "COUNTSERVICE" => (self.count_palette.service as i32, 0, 1), + "COUNTCOLOR" => (self.count_palette.color as i32, 1, 255), + _ => (self.count_palette.error_color as i32, 1, 255), + }; + let ask = |app: &mut Self| { + app.command_line.push_output(&format!("Enter new value for {name} <{current}>:")); + app.pending_setvar = Some(name.to_string()); + }; + let Some(value) = value else { + ask(self); + return; + }; + match value.parse::() { + Ok(n) if (min..=max).contains(&n) => { + match name { + "COUNTSERVICE" => self.count_palette.service = n == 1, + "COUNTCOLOR" => self.count_palette.color = n as i16, + _ => self.count_palette.error_color = n as i16, + } + self.persist_settings_if_changed(); + self.apply_count_display(i); + } + _ => { + self.command_line.push_error(&if max == 1 { + "Requires 0 or 1 only.".to_string() + } else { + format!("Requires an integer between {min} and {max}.") + }); + ask(self); + } + } + } + + /// Open or close the Count palette (docked on the right, expanded). + pub(in crate::app) fn set_count_palette(&mut self, open: bool) { + let id = crate::ui::dock::PanelId::Count; + self.count_palette.show = open; + self.ribbon.set_count_palette(open); + if open { + if self.dock.location(id).is_none() { + self.dock.dock(id, crate::app::config::DockSide::Right, usize::MAX); + } + self.dock_expanded = Some(id); + } else if self.dock_expanded == Some(id) { + self.dock_expanded = None; + } + } + + /// Recolour the drawing for count mode: counted references, duplicates, + /// the rest faded (nothing changes without a target). + pub(in crate::app) fn apply_count_display(&mut self, i: usize) { + let computed = self.tabs[i].count.as_ref().map(|m| m.compute(&self.tabs[i].scene.document)); + if let (Some(mode), Some(c)) = (self.tabs[i].count.as_mut(), computed) { + mode.cache = Some(c); + } + let display = self.tabs[i].count.as_ref().filter(|m| m.target.is_some()).map(|m| { + let r = m.result(&self.tabs[i].scene.document); + crate::scene::CountDisplay { + counted: r.counted.into_iter().collect(), + errors: r.errors.into_iter().chain(r.overlapped).collect(), + color: aci_rgba(self.count_palette.color), + error_color: aci_rgba(self.count_palette.error_color), + } + }); + self.tabs[i].scene.set_count_display(display); + let epoch = self.tabs[i].scene.geometry_epoch; + if let Some(mode) = self.tabs[i].count.as_mut() { + mode.epoch = epoch; + } + } + + /// After the drawing changes, count mode colours its references again. + pub(in crate::app) fn refresh_count_if_stale(&mut self) { + let i = self.active_tab; + let Some(mode) = self.tabs[i].count.as_ref().filter(|m| m.epoch != self.tabs[i].scene.geometry_epoch) else { + return; + }; + if let Some((h, _)) = mode.boundary { + match self.tabs[i].scene.document.get_entity(h) { + // The boundary is gone (erased, undone): ask, or do what was + // chosen for good. + None => { + if self.active_modal == Some(crate::app::ModalKind::CountInvalidArea) { + return; + } + match self.count_palette.invalid_choice { + 1 => self.count_invalid_undo(i), + 2 => self.count_invalid_continue(i), + _ => { + self.count_palette.invalid_always = false; + self.active_modal = Some(crate::app::ModalKind::CountInvalidArea); + } + } + return; + } + // An edited boundary moves the area with it. + Some(EntityType::LwPolyline(pl)) => { + let ring: Vec<[f64; 2]> = pl.vertices.iter().map(|v| [v.location.x, v.location.y]).collect(); + if let Some(mode) = self.tabs[i].count.as_mut() { + mode.area = Some(ring); + } + } + Some(_) => {} + } + } + self.apply_count_display(i); + } + + /// Invalid Area › Undo (also ✕ and Cancel): the change that took the + /// boundary away is undone. + fn count_invalid_undo(&mut self, i: usize) { + self.undo_active_tab(); + self.apply_count_display(i); + } + + /// Invalid Area › Continue: the area goes, the count covers all of model space. + fn count_invalid_continue(&mut self, i: usize) { + if let Some(mode) = self.tabs[i].count.as_mut() { + mode.area = None; + mode.boundary = None; + } + self.apply_count_display(i); + } + + fn close_invalid_area(&mut self, choice: u8) { + if self.active_modal == Some(crate::app::ModalKind::CountInvalidArea) { + self.close_active_modal(); + } + if self.count_palette.invalid_always { + self.count_palette.invalid_choice = choice; + self.persist_settings_if_changed(); + } + } + + /// COUNTCLOSE: leave count mode; the area polyline count mode drew goes + /// unless a field counts in it. + pub(in crate::app) fn close_count(&mut self, i: usize) { + if let Some(mode) = self.tabs[i].count.take() { + self.drop_count_boundary(i, mode.boundary); + } + self.tabs[i].scene.set_count_display(None); + } + + fn drop_count_boundary(&mut self, i: usize, boundary: Option<(Handle, bool)>) { + if let Some((h, true)) = boundary { + let doc = &self.tabs[i].scene.document; + if doc.get_entity(h).is_some() && !boundary_in_use(doc, h) { + self.push_undo_snapshot(i, "COUNT"); + // The area layer is locked; count mode removes its own polyline. + self.tabs[i].scene.rollback_new_entities(&[h]); + self.tabs[i].dirty = true; + } + } + } + + /// The count area of `C`, `E`, `K`, `R a b`, `P a b c …` or `O h`, and + /// its boundary polyline (rectangles and polygons are drawn on the + /// `0-CountArea` layer). `None` when the area cannot be had. + fn resolve_count_area( + &mut self, + i: usize, + tokens: &[&str], + ) -> Option<(Option>, Option<(Handle, bool)>)> { + let kind = tokens.first().copied().unwrap_or("E"); + let points = || tokens[1..].iter().map_while(|t| parse_xy(t)).collect::>(); + match kind { + "E" => Some((None, None)), + "K" => { + let mode = self.tabs[i].count.as_ref(); + Some((mode.and_then(|m| m.area.clone()), mode.and_then(|m| m.boundary))) + } + // Current area counts all of model space (the view and an area + // in use take no part). + "C" => Some((None, None)), + "R" => { + let p = points(); + let (a, b) = (p.first()?, p.get(1)?); + let ring = vec![[a[0], a[1]], [b[0], a[1]], [b[0], b[1]], [a[0], b[1]]]; + let h = self.draw_count_area(i, &ring)?; + Some((Some(ring), Some((h, true)))) + } + "P" => { + let ring = points(); + (ring.len() >= 3).then_some(())?; + let h = self.draw_count_area(i, &ring)?; + Some((Some(ring), Some((h, true)))) + } + "O" => { + let h = parse_handle(tokens.get(1)?)?; + let ring = codec::count::boundary_polygon(&self.tabs[i].scene.document, h)?; + Some((Some(ring), Some((h, false)))) + } + _ => None, + } + } + + /// A closed polyline on `0-CountArea` (locked, colour 152, not plotted, + /// Continuous), whatever the current layer. + fn draw_count_area(&mut self, i: usize, ring: &[[f64; 2]]) -> Option { + self.push_undo_snapshot(i, "COUNT"); + let doc = &mut self.tabs[i].scene.document; + if !doc.layers.contains(AREA_LAYER) { + let mut layer = codec::tables::Layer::new(AREA_LAYER); + layer.handle = doc.allocate_handle(); + layer.color = codec::types::Color::from_index(152); + layer.is_plottable = false; + layer.flags.locked = true; + layer.line_type = "Continuous".into(); + let _ = doc.layers.add(layer); + } + let mut pl = codec::entities::LwPolyline::new(); + pl.vertices = ring + .iter() + .map(|p| codec::entities::LwVertex::new(codec::types::Vector2::new(p[0], p[1]))) + .collect(); + pl.is_closed = true; + pl.common.layer = AREA_LAYER.into(); + let h = self.commit_entity_handle_preserve_layer(EntityType::LwPolyline(pl)); + self.tabs[i].dirty = true; + h + } + + /// `_COUNTRUN L` opens the palette; `_COUNTRUN T ` + /// counts the targets and enters count mode. + fn count_run(&mut self, i: usize, rest: &str) { + let tokens: Vec<&str> = rest.split_whitespace().collect(); + let split = tokens.iter().position(|t| *t == "L" || *t == "T").unwrap_or(tokens.len()); + let (area_tokens, tail) = tokens.split_at(split); + let targets: Vec = match tail { + ["T", handles, ..] => handles + .split(',') + .filter_map(parse_handle) + .filter(|h| self.tabs[i].scene.document.get_entity(*h).is_some()) + .collect(), + _ => Vec::new(), + }; + // List all blocks: the palette, counting in the area when one was drawn. + if targets.is_empty() { + self.set_count_palette(true); + if matches!(area_tokens.first(), Some(&"C") | Some(&"E") | None) { + return; + } + } + let Some((area, boundary)) = self.resolve_count_area(i, area_tokens) else { + return; + }; + let doc = &self.tabs[i].scene.document; + let blocks: Vec<&str> = targets + .iter() + .filter_map(|h| match doc.get_entity(*h) { + Some(EntityType::Insert(ins)) => Some(ins.block_name.as_str()), + _ => None, + }) + .collect(); + let target = match blocks.first() { + Some(first) if blocks.len() == targets.len() && blocks.iter().all(|b| b.eq_ignore_ascii_case(first)) => { + let name = doc.block_records.get(first).map(|b| b.name.clone()).unwrap_or_else(|| first.to_string()); + Some(CountTarget::Block { name, reference: targets.first().copied(), matching: [false; 3], picked: true }) + } + None if targets.is_empty() => None, + _ => Some(CountTarget::Group(targets.clone())), + }; + // The picked targets do not stay selected. + self.tabs[i].scene.deselect_all(); + self.refresh_properties(); + if let Some(old) = self.tabs[i].count.take().filter(|o| o.boundary != boundary) { + self.drop_count_boundary(i, old.boundary); + } + let mode = CountMode { area, boundary, target, ..CountMode::default() }; + if let Some(name) = mode.target_name() { + let n = mode.count(&self.tabs[i].scene.document); + self.command_line.push_output(&format!("{name} ...... {n}")); + } + self.tabs[i].count = Some(mode); + self.apply_count_display(i); + } + + /// The field COUNTFIELD places for the current count: the picked + /// reference (`single`), or the block with the match options (in the + /// area's polyline when there is one). + fn count_field_code(&self, i: usize) -> Option { + let mode = self.tabs[i].count.as_ref()?; + match mode.target.as_ref()? { + CountTarget::Group(handles) => Some(format!("\\AcCount {}", single_json(handles))), + CountTarget::Block { reference: Some(r), matching: [false, false, false], picked: true, .. } + if mode.boundary.is_none() => + { + Some(format!("\\AcCount {}", single_json(&[*r]))) + } + CountTarget::Block { name, .. } => { + let instances = mode.instances(&self.tabs[i].scene.document); + Some(self.block_field_code(i, name, &mode.key(&instances))) + } + } + } + + /// `\AcCount` of a block and key, `\AcCount2` inside count mode's area polyline. + fn block_field_code(&self, i: usize, name: &str, key: &CountKey) -> String { + match self.tabs[i].count.as_ref().and_then(|m| m.boundary) { + Some((boundary, _)) => format!("\\AcCount2 {}", area_json(name, key, boundary)), + None => format!("\\AcCount {}", block_json(name, key)), + } + } + + /// Place a count field as multi-line text (`Specify start point or [Height/Justify]:`). + pub(in crate::app) fn start_count_field(&mut self, i: usize, code: String) -> Task { + let doc = &self.tabs[i].scene.document; + let value = crate::entities::field::evaluate(doc, &code, &[], None).unwrap_or_else(|| "####".into()); + let defaults = crate::scene::creation_style::current_text_defaults(doc); + self.command_line.push_output(&format!( + "MTEXT Current text style: \"{}\" Text height: {:.4}", + defaults.style_name, defaults.height + )); + let command = FieldPlaceCommand::new(value, (code, Vec::new()), defaults.style_name, defaults.height).named("MTEXT"); + self.start_count_command(i, Box::new(command), "COUNTFIELD") + } + + /// UPDATEFIELD: every field the objects host is evaluated again and its + /// text written back. + fn update_fields(&mut self, i: usize, handles: &[Handle]) { + let doc = &self.tabs[i].scene.document; + let mut updates: Vec<(Handle, String)> = Vec::new(); + let mut found = 0; + for h in handles { + let Some(entity) = doc.get_entity(*h) else { continue }; + if matches!(entity, EntityType::Table(_)) { + // Table cells evaluate their fields when drawn. + if let EntityType::Table(t) = entity { + found += t.field_handles.len(); + } + continue; + } + if !crate::entities::field::hosts_field(doc, entity) { + continue; + } + found += 1; + if let Some(text) = crate::entities::field::resolve(doc, *h) { + updates.push((*h, text)); + } + } + self.command_line.push_output(&format!("{found} field(s) found.")); + if found == 0 { + return; + } + self.push_undo_snapshot(i, "UPDATEFIELD"); + let mut changed = Vec::new(); + for (h, text) in updates { + if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(h) { + match entity { + EntityType::MText(m) => m.value = text, + EntityType::Text(t) => t.value = text, + EntityType::AttributeDefinition(a) => a.default_value = text, + _ => continue, + } + changed.push((h, crate::scene::ChangeKind::Modified)); + } + } + let table_handles: Vec<(Handle, crate::scene::ChangeKind)> = handles + .iter() + .filter(|h| matches!(self.tabs[i].scene.document.get_entity(**h), Some(EntityType::Table(_)))) + .map(|h| (*h, crate::scene::ChangeKind::Modified)) + .collect(); + changed.extend(table_handles); + self.tabs[i].scene.bump_entities(&changed); + self.tabs[i].dirty = true; + self.command_line.push_output(&format!("{found} field(s) updated.")); + } + + /// A count table at `point`: `Item` | `Count`, one row per block (all + /// counted blocks when `names` is empty), sorted by name; the counts are + /// fields. + fn place_count_table(&mut self, i: usize, point: [f64; 3], names: Vec) { + let doc = &self.tabs[i].scene.document; + let area = self.tabs[i].count.as_ref().and_then(|m| m.area.clone()); + let instances = codec::count::block_instances(doc, area.as_deref()); + let mut names: Vec = if names.is_empty() { + codec::count::block_counts(&instances).into_iter().map(|c| c.name).collect() + } else { + names + .iter() + .map(|n| doc.block_records.get(n).map(|b| b.name.clone()).unwrap_or_else(|| n.clone())) + .collect() + }; + names.sort_by_key(|n| n.to_ascii_uppercase()); + names.dedup_by_key(|n| n.to_ascii_uppercase()); + let mut table = codec::entities::TableBuilder::new(names.len() + 1, 2) + .at(codec::types::Vector3::new(point[0], point[1], point[2])) + .row_height(0.36) + .column_width(3.6) + .build(); + table.set_column_width(1, 1.08); + // As the reference writes it: no title row, data rows centred + // (table and cell overrides), header cells without overrides. + table.value_flags = 22; + table.legacy_style_override = Some(codec::entities::table::LegacyTableStyleOverride { + flags: 0x10001, + title_suppressed: Some(true), + row_alignments: vec![5], + ..Default::default() + }); + table.set_cell_text(0, 0, "Item"); + table.set_cell_text(0, 1, "Count"); + for (r, name) in names.iter().enumerate() { + table.set_cell_text(r + 1, 0, name); + if let Some(cell) = table.cell_mut(r + 1, 1) { + // The count comes from the field; the cell keeps no text. + let mut content = codec::entities::table::CellContent::text(""); + content.value.flags = 1; + cell.contents = vec![content]; + cell.cell_type = codec::entities::table::CellType::Text; + } + } + for (r, row) in table.rows.iter_mut().enumerate() { + for cell in &mut row.cells { + if let Some(content) = cell.contents.first_mut().filter(|c| c.value.flags != 1) { + content.value.flags = 6; + } + if r == 0 { + cell.flag = 0x40000; + } else { + cell.flag = 0x40001; + let mut style = codec::entities::CellStyle::new(); + style.override_flags = 0x01; + style.alignment = 5; + cell.style = Some(style); + } + } + } + let codes: Vec = + names.iter().map(|n| self.block_field_code(i, n, &CountKey::default())).collect(); + self.push_undo_snapshot(i, "COUNTTABLE"); + let Some(handle) = self.commit_entity_handle(EntityType::Table(Box::new(table))) else { + return; + }; + // The table's graphics block holds one MTEXT per cell; each count + // cell's MTEXT owns its field, which the cell refers to. + let doc = &mut self.tabs[i].scene.document; + let fields = codes + .iter() + .enumerate() + .map(|(r, code)| { + let value = crate::entities::field::evaluate(doc, code, &[], None).unwrap_or_else(|| "####".into()); + (r + 1, 1, r"%<\_FldIdx 0>%".to_string(), vec![codec::fields::NewField::new(code.clone(), value)]) + }) + .collect(); + doc.build_table_block(handle, fields); + self.tabs[i].scene.bump_entities(&[(handle, crate::scene::ChangeKind::Modified)]); + self.tabs[i].dirty = true; + } + + /// Zoom to a counted object (← / →, an error of the report). + fn zoom_to_insert(&mut self, i: usize, h: Handle) { + let doc = &self.tabs[i].scene.document; + let Some(entity) = doc.get_entity(h) else { return }; + let outline = codec::count::entity_outline(doc, entity); + if outline.is_empty() { + return; + } + let (mut lo, mut hi) = (glam::Vec3::splat(f32::MAX), glam::Vec3::splat(f32::MIN)); + for c in outline.iter().flat_map(codec::count::Piece::points) { + let p = glam::Vec3::new(c.x as f32, c.y as f32, c.z as f32); + lo = lo.min(p); + hi = hi.max(p); + } + let pad = (hi - lo) * 1.5; + self.tabs[i].scene.remember_current_view(); + self.tabs[i].scene.zoom_to_window(lo - pad, hi + pad); + } + + fn open_count_target(&mut self, i: usize, name: String, key: CountKey) { + let mut mode = self.tabs[i].count.take().unwrap_or_default(); + let instances = mode.instances(&self.tabs[i].scene.document); + let reference = instances + .iter() + .find(|inst| inst.duplicate_of.is_none() && inst.name.eq_ignore_ascii_case(&name) && key.matches(inst)) + .map(|inst| inst.handle); + mode.target = Some(CountTarget::Block { + name, + reference, + matching: [key.layer.is_some(), key.scale.is_some(), key.mirror_state.is_some()], + picked: false, + }); + mode.cursor = None; + self.tabs[i].count = Some(mode); + self.apply_count_display(i); + } + + /// The Field dialog's Show Count Instances: count mode on what the + /// expression counts. + pub(in crate::app) fn show_count_instances(&mut self, i: usize, json: &str) { + match codec::count::parse_query(json) { + Some(codec::count::CountQuery::Block { name, key, boundary }) => { + if let Some(b) = boundary { + if let Some(ring) = codec::count::boundary_polygon(&self.tabs[i].scene.document, b) { + let mut mode = self.tabs[i].count.take().unwrap_or_default(); + if mode.boundary.map(|(h, _)| h) != Some(b) { + self.drop_count_boundary(i, mode.boundary); + mode.boundary = Some((b, false)); + } + mode.area = Some(ring); + self.tabs[i].count = Some(mode); + } + } + self.open_count_target(i, name, key); + } + Some(codec::count::CountQuery::Single(handles)) if !handles.is_empty() => { + let hexes: Vec = handles.iter().map(|h| hex(*h)).collect(); + let area = if self.tabs[i].count.is_some() { "K" } else { "E" }; + self.count_run(i, &format!("{area} T {}", hexes.join(","))); + } + _ => {} + } + } + + pub(in crate::app) fn on_count(&mut self, m: CountMsg) -> Task { + let i = self.active_tab; + if self.tabs[i].is_start { + return Task::none(); + } + match m { + CountMsg::Search(s) => self.count_palette.search = s, + CountMsg::Sort(by_count) => { + if self.count_palette.by_count == by_count { + self.count_palette.descending = !self.count_palette.descending; + } else { + self.count_palette.by_count = by_count; + self.count_palette.descending = false; + } + } + CountMsg::Open(name, key) | CountMsg::Menu(name, key, RowAction::Review) => { + if self.count_palette.table.is_none() { + self.open_count_target(i, name, key); + } + } + CountMsg::Menu(name, key, RowAction::Field) => { + let code = self.block_field_code(i, &name, &key); + return self.start_count_field(i, code); + } + CountMsg::Menu(name, _, RowAction::Expand(k)) => { + let upper = name.to_ascii_uppercase(); + let list = &mut self.count_palette.expanded; + match list.iter_mut().find(|(n, _)| *n == upper) { + Some((_, e)) => e[k] = !e[k], + None => { + let mut e = [false; 3]; + e[k] = true; + list.push((upper, e)); + } + } + } + CountMsg::CreateTable => self.count_palette.table = Some(Vec::new()), + CountMsg::TableCheck(name, on) => { + if let Some(checked) = self.count_palette.table.as_mut() { + let upper = name.to_ascii_uppercase(); + checked.retain(|n| *n != upper); + if on { + checked.push(upper); + } + } + } + CountMsg::TableAll(on) => { + let names: Vec = if on { + let area = self.tabs[i].count.as_ref().and_then(|m| m.area.clone()); + let instances = codec::count::block_instances(&self.tabs[i].scene.document, area.as_deref()); + crate::ui::window::count_palette::rows(&instances, &self.count_palette) + .into_iter() + .map(|r| r.name.to_ascii_uppercase()) + .collect() + } else { + Vec::new() + }; + self.count_palette.table = Some(names); + } + CountMsg::TableCancel => self.count_palette.table = None, + CountMsg::TableInsert => { + let names = self.count_palette.table.take().unwrap_or_default(); + if names.is_empty() { + return Task::none(); + } + return self.start_count_command(i, Box::new(CountTableCommand::placing(names)), "COUNTTABLE"); + } + CountMsg::Back | CountMsg::Close => self.close_count(i), + CountMsg::Match(k, on) => { + if let Some(CountMode { target: Some(CountTarget::Block { matching, .. }), .. }) = self.tabs[i].count.as_mut() { + matching[k] = on; + } + self.apply_count_display(i); + } + CountMsg::ToggleDetails => self.count_palette.details_closed = !self.count_palette.details_closed, + CountMsg::ToggleErrors => self.count_palette.errors_closed = !self.count_palette.errors_closed, + CountMsg::ShowError(h) => self.zoom_to_insert(i, h), + CountMsg::Prev | CountMsg::Next => { + let Some(mode) = self.tabs[i].count.as_ref() else { return Task::none() }; + let counted = mode.result(&self.tabs[i].scene.document).counted; + if counted.is_empty() { + return Task::none(); + } + let n = counted.len(); + let next = match (mode.cursor, matches!(m, CountMsg::Next)) { + (None, true) => 0, + (None, false) => n - 1, + (Some(c), true) => (c + 1) % n, + (Some(c), false) => (c + n - 1) % n, + }; + if let Some(mode) = self.tabs[i].count.as_mut() { + mode.cursor = Some(next); + } + self.zoom_to_insert(i, counted[next]); + } + CountMsg::Area => return self.dispatch_command("COUNTAREA"), + CountMsg::Select => { + self.tabs[i].scene.deselect_all(); + let command = CountCommand::with_area(if self.tabs[i].count.is_some() { "K" } else { "C" }); + return self.start_count_command(i, Box::new(command), "COUNT"); + } + CountMsg::Field => return self.dispatch_command("COUNTFIELD"), + CountMsg::InvalidAlways(v) => self.count_palette.invalid_always = v, + CountMsg::InvalidUndo => { + self.close_invalid_area(1); + self.count_invalid_undo(i); + } + CountMsg::InvalidContinue => { + self.close_invalid_area(2); + self.count_invalid_continue(i); + } + } + Task::none() + } +} diff --git a/src/app/commands/field.rs b/src/app/commands/field.rs index 422e51bcb..3f0ee8a93 100644 --- a/src/app/commands/field.rs +++ b/src/app/commands/field.rs @@ -222,6 +222,15 @@ impl OpenCADStudio { ); } FieldDialogMsg::PlotScale(k) => state.plot_scale = k, + FieldDialogMsg::CountExpression(v) => state.count_expression = v, + FieldDialogMsg::ShowCountInstances => { + // The dialog closes and count mode shows what the expression counts. + let json = state.count_expression.clone(); + self.field_dialog = None; + self.close_active_modal(); + self.show_count_instances(i, &json); + return Task::none(); + } FieldDialogMsg::PlaceholderProperty(k) => { state.placeholder_property = k; state.text_case = 0; diff --git a/src/app/commands/inquiry.rs b/src/app/commands/inquiry.rs index e13a454d4..dda3f01b1 100644 --- a/src/app/commands/inquiry.rs +++ b/src/app/commands/inquiry.rs @@ -1216,45 +1216,6 @@ impl OpenCADStudio { } } - // ── COUNT — entity statistics ───────────────────────────────────── - "COUNT" => { - use crate::command::KeywordCommand; - let c = KeywordCommand::new( - "COUNT", - "COUNT tally [All (by type) / by Layer]:", - vec![("All", "TYPE", None), ("By layer", "LAYER", None)], - ); - self.command_line.push_info(&c.prompt()); - self.tabs[self.active_tab].active_cmd = Some(Box::new(c)); - } - cmd if cmd.starts_with("COUNT ") => { - let filter = cmd.split_once(' ').map(|(_, r)| r.trim().to_uppercase()); - let mut counts: std::collections::BTreeMap = Default::default(); - for e in self.tabs[i].scene.document.entities() { - let layer = &e.common().layer; - let type_name = crate::entities::names::dxf_name(e); - let key = match &filter { - Some(f) if f == "LAYER" => layer.clone(), - Some(f) if f == "TYPE" => type_name.to_string(), - Some(f) => { - // Filter by layer name - if layer.to_uppercase() != *f { - continue; - } - type_name.to_string() - } - None => type_name.to_string(), - }; - *counts.entry(key).or_default() += 1; - } - let total: usize = counts.values().sum(); - for (k, n) in &counts { - self.command_line.push_output(crate::tf!(" {k}: {n}").as_ref()); - } - self.command_line - .push_output(crate::tf!("COUNT: {total} entity(ies) total.").as_ref()); - } - "DATAEXTRACTION" | "EATTEXT" | "ATTEXT" => { self.open_data_extraction(); } diff --git a/src/app/commands/mod.rs b/src/app/commands/mod.rs index b17c9eee6..5bca869e3 100644 --- a/src/app/commands/mod.rs +++ b/src/app/commands/mod.rs @@ -6,6 +6,7 @@ use std::path::PathBuf; mod attdef; mod field; +mod count; mod blocks; mod xref_attach; mod dim; @@ -324,6 +325,9 @@ impl OpenCADStudio { if let Some(t) = self.dispatch_field(cmd, i) { return Some(t); } + if let Some(t) = self.dispatch_count(cmd, i) { + return Some(t); + } if let Some(t) = self.dispatch_draw(cmd, i) { return Some(t); } diff --git a/src/app/control/actions.rs b/src/app/control/actions.rs index 951c0ef1d..8d63e0176 100644 --- a/src/app/control/actions.rs +++ b/src/app/control/actions.rs @@ -73,6 +73,7 @@ pub(super) const NAMES: &[&str] = &[ "pdf_layer_toggle", "pc_manager_toggle", "pc_manager", + "count_palette", "blocks_palette", "pdf_page_select", "pc_colormap", @@ -927,6 +928,78 @@ impl OpenCADStudio { } // Point Cloud Manager tree: "search=", "collapse", "expand", // "toggle_node=" or "select=". + // Count palette and count mode: "search=", "sort=name|count", + // "open=NAME", "child=NAME:" (an expanded row), "menu=:NAME", "create_table", "check=NAME:1", + // "all=1", "table_cancel", "table_insert", "back", "match=<0|1|2>:1", + // "details", "errors", "error=", "area", "prev", "next", "select", + // "field", "close", "invalid_undo", "invalid_continue", "invalid_always=1". + "count_palette" => { + use crate::ui::window::count_palette::{CountMsg as M, RowAction}; + let value = string(req, "value")?; + let (key, arg) = value.split_once('=').unwrap_or((value, "")); + let flag = |s: &str| s == "1" || s.eq_ignore_ascii_case("true"); + let bad = || failure("invalid_value", "Unknown count palette edit"); + Message::Count(match key { + "search" => M::Search(arg.into()), + "sort" => M::Sort(arg == "count"), + "open" => M::Open(arg.into(), codec::count::CountKey::default()), + "child" => { + let (name, n) = arg.rsplit_once(':').ok_or_else(bad)?; + let n: usize = n.parse().map_err(|_| bad())?; + let [l, s, m] = self.count_palette.expansion(name).ok_or_else(bad)?; + let i = self.active_tab; + let area = self.tabs[i].count.as_ref().and_then(|c| c.area.clone()); + let instances = codec::count::block_instances(&self.tabs[i].scene.document, area.as_deref()); + let (k, _) = codec::count::expanded_counts(&instances, name, l, s, m).into_iter().nth(n).ok_or_else(bad)?; + M::Open(name.into(), k) + } + "menu" => { + let (action, name) = arg.split_once(':').ok_or_else(bad)?; + let action = match action { + "review" => RowAction::Review, + "field" => RowAction::Field, + "layer" => RowAction::Expand(0), + "scale" => RowAction::Expand(1), + "mirror" => RowAction::Expand(2), + _ => return Err(bad()), + }; + M::Menu(name.into(), codec::count::CountKey::default(), action) + } + "create_table" => M::CreateTable, + "check" => { + let (name, on) = arg.rsplit_once(':').unwrap_or((arg, "1")); + M::TableCheck(name.into(), flag(on)) + } + "all" => M::TableAll(flag(arg)), + "table_cancel" => M::TableCancel, + "table_insert" => M::TableInsert, + "back" => M::Back, + "match" => { + let (k, on) = arg.split_once(':').unwrap_or((arg, "1")); + M::Match(k.parse::().ok().filter(|k| *k < 3).ok_or_else(bad)?, flag(on)) + } + "details" => M::ToggleDetails, + "errors" => M::ToggleErrors, + "error" => { + let i = self.active_tab; + let n: usize = arg.parse().map_err(|_| bad())?; + let mode = self.tabs[i].count.as_ref().ok_or_else(bad)?; + M::ShowError(*mode.result(&self.tabs[i].scene.document).errors.get(n).ok_or_else(bad)?) + } + "area" => M::Area, + "prev" => M::Prev, + "next" => M::Next, + "select" => M::Select, + "field" => M::Field, + "close" => M::Close, + // The Invalid Area dialog. + "invalid_undo" => M::InvalidUndo, + "invalid_continue" => M::InvalidContinue, + "invalid_always" => M::InvalidAlways(arg == "1"), + _ => return Err(bad()), + }) + } "pc_manager" => { use crate::ui::window::pc_manager::PcManagerMsg as M; let value = string(req, "value")?; @@ -1133,6 +1206,8 @@ impl OpenCADStudio { "link_text" => F::HyperlinkText(v.into()), "link_url" => F::HyperlinkUrl(v.into()), "plot_scale" => F::PlotScale(index(codec::fields::PLOT_SCALE_FORMATS.len())?), + "count" => F::CountExpression(v.into()), + "show_instances" => F::ShowCountInstances, "placeholder" => F::PlaceholderProperty(index(codec::fields::BLOCK_PLACEHOLDER_PROPERTIES.len())?), "table_function" => F::TableFunction( ["Average", "Sum", "Count", "Cell"] diff --git a/src/app/document.rs b/src/app/document.rs index 9e147db7e..605f13d8e 100644 --- a/src/app/document.rs +++ b/src/app/document.rs @@ -221,6 +221,8 @@ pub(super) struct DocumentTab { pub(super) paper_bg_color: Option<[f32; 4]>, /// Active REFEDIT session, if any. pub(super) refedit_session: Option, + /// COUNT mode, while it is on. + pub(crate) count: Option, /// Open BEDIT block tabs. Definitions are edited live; each tab owns its /// entry snapshot and camera so nested blocks can remain open independently. pub(super) block_edits: Vec, @@ -651,6 +653,7 @@ impl DocumentTab { bg_color: None, paper_bg_color: None, refedit_session: None, + count: None, block_edits: Vec::new(), active_block_edit: None, active_mleader_style: "Standard".to_string(), diff --git a/src/app/mod.rs b/src/app/mod.rs index 807f5a46e..0257df35a 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -818,6 +818,7 @@ pub(super) struct OpenCADStudio { pub(crate) show_browser: bool, /// Point Cloud Manager palette (POINTCLOUDMANAGER). pub(crate) pc_manager: crate::ui::window::pc_manager::PcManager, + pub(crate) count_palette: crate::ui::window::count_palette::CountPalette, /// Which viewport background the colour wheel is editing, or `None` when /// it is closed. One slot, because only one wheel can be open at a time. pub(crate) bg_picker: Option, @@ -1942,6 +1943,7 @@ pub enum ModalKind { GeometricTolerance, AttDef, AttDefEdit, + CountInvalidArea, Field, DraftingSettings, AutoConstrainSettings, @@ -3899,6 +3901,7 @@ pub enum Message { FieldDialog(crate::ui::window::field_dialog::FieldDialogMsg), /// A click or search in the Point Cloud Manager. PcManager(crate::ui::window::pc_manager::PcManagerMsg), + Count(crate::ui::window::count_palette::CountMsg), PdfImportPickResult(Result<(std::path::PathBuf, std::sync::Arc>), String>), /// Result of the PDFATTACH file picker. PdfAttachPickResult(Result<(std::path::PathBuf, std::sync::Arc>), String>), @@ -4225,6 +4228,7 @@ impl OpenCADStudio { show_external_references: false, show_browser: false, pc_manager: Default::default(), + count_palette: Default::default(), bg_picker: None, block_palette: Default::default(), xref_manager: Default::default(), diff --git a/src/app/settings.rs b/src/app/settings.rs index 52907b2bf..89e1728ae 100644 --- a/src/app/settings.rs +++ b/src/app/settings.rs @@ -613,6 +613,18 @@ pub struct UserSettings { /// BLOCKNAVIGATE (default "."). #[serde(default = "default_block_navigate")] pub block_navigate: String, + /// COUNTCOLOR (default 3). + #[serde(default = "default_count_color")] + pub count_color: i16, + /// COUNTERRORCOLOR (default 1). + #[serde(default = "default_count_error_color")] + pub count_error_color: i16, + /// COUNTSERVICE (default on). + #[serde(default = "default_true")] + pub count_service: bool, + /// Count - Invalid Area: 0 ask, 1 undo, 2 count all of model space. + #[serde(default)] + pub count_invalid_area: u8, } /// A block the Blocks palette lists from outside the current drawing (a @@ -676,6 +688,14 @@ fn default_one_u8() -> u8 { 1 } +fn default_count_color() -> i16 { + 3 +} + +fn default_count_error_color() -> i16 { + 1 +} + fn default_block_navigate() -> String { ".".to_string() } @@ -853,6 +873,10 @@ impl Default for UserSettings { block_mru_list: 50, block_redefine_mode: 1, block_navigate: ".".to_string(), + count_color: 3, + count_error_color: 1, + count_service: true, + count_invalid_area: 0, } } } diff --git a/src/app/update/dialog.rs b/src/app/update/dialog.rs index 197091807..65a04a0d1 100644 --- a/src/app/update/dialog.rs +++ b/src/app/update/dialog.rs @@ -590,6 +590,10 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven PanelId::PointCloudManager => { self.pc_manager.show = false; } + PanelId::Count => { + self.count_palette.show = false; + self.ribbon.set_count_palette(false); + } PanelId::Properties => { self.show_properties = false; self.ribbon.set_properties(false); @@ -695,6 +699,7 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven PanelId::Browser => self.show_browser, PanelId::NodeGraph => self.show_node_graph, PanelId::PointCloudManager => self.pc_manager.show, + PanelId::Count => self.count_palette.show, } } diff --git a/src/app/update/file.rs b/src/app/update/file.rs index fe0e4d946..7938f71d1 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -744,6 +744,10 @@ impl OpenCADStudio { block_mru_list: self.block_mru_list, block_redefine_mode: self.block_redefine_mode, block_navigate: self.block_navigate.clone(), + count_color: self.count_palette.color, + count_error_color: self.count_palette.error_color, + count_service: self.count_palette.service, + count_invalid_area: self.count_palette.invalid_choice, } } @@ -875,6 +879,10 @@ impl OpenCADStudio { 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.count_palette.color = s.count_color; + self.count_palette.error_color = s.count_error_color; + self.count_palette.service = s.count_service; + self.count_palette.invalid_choice = s.count_invalid_area.min(2); self.block_palette.recent = s.block_recent.clone(); self.block_palette.favorites = s.block_favorites.clone(); self.block_palette.libraries = s.block_libraries.clone(); diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 9d97758cb..5fa43abe4 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -493,6 +493,8 @@ impl OpenCADStudio { // The Reference Manager watches the active drawing and re-scans when // the palette is open on another tab's entries. self.refresh_xref_manager_if_stale(); + // Count mode recolours its references after the drawing changes. + self.refresh_count_if_stale(); // Let V4 plugins observe selection changes that happened while handling // this message (picking, window select, QSELECT, SELECTALL, grip edits, // and plugin request draining). @@ -1819,6 +1821,7 @@ impl OpenCADStudio { Message::AttdefDialog(message) => self.on_attdef_dialog(message), Message::FieldDialog(message) => self.on_field_dialog(message), Message::PcManager(message) => self.update_pc_manager(message), + Message::Count(message) => self.on_count(message), Message::RibbonSelectTab(idx) => { self.ribbon.select(idx); Task::none() @@ -8548,6 +8551,10 @@ impl OpenCADStudio { } Message::CloseModal => { + // The Invalid Area dialog's ✕ undoes the boundary change, as its Cancel does. + if self.active_modal == Some(super::ModalKind::CountInvalidArea) { + return self.update(Message::Count(crate::ui::window::count_palette::CountMsg::InvalidUndo)); + } if self.active_modal == Some(super::ModalKind::Field) { self.close_field_dialog(); return Task::none(); diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index 71a8a72bc..7a0af5777 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -1669,6 +1669,13 @@ bg={bg_ms:.1}ms n={view_count}" } } + // Count mode: a blue frame round the drawing and the count toolbar. + if let (Some(mode), false) = (tab.count.as_ref(), tab.is_start) { + viewport_stack = viewport_stack.push(crate::ui::window::count_palette::border()); + viewport_stack = viewport_stack + .push(crate::ui::window::count_palette::toolbar(mode, &tab.scene.document)); + } + // Reserve the overlaid command line when placing cursor-anchored panels. mark("chrome"); let command_line_inset = if self.command_line.history_open { @@ -1888,6 +1895,7 @@ bg={bg_ms:.1}ms n={view_count}" crate::ui::dock::PanelId::Browser => self.show_browser, crate::ui::dock::PanelId::NodeGraph => self.show_node_graph, crate::ui::dock::PanelId::PointCloudManager => self.pc_manager.show, + crate::ui::dock::PanelId::Count => self.count_palette.show, } }; let edge_stack = |side: crate::app::config::DockSide| -> Option> { @@ -3022,6 +3030,14 @@ impl OpenCADStudio { auto_collapse, ), crate::ui::dock::PanelId::NodeGraph => tab.graph.panel(width, auto_collapse), + crate::ui::dock::PanelId::Count => crate::ui::window::count_palette::view( + &self.count_palette, + tab.count.as_ref(), + &tab.scene.document, + tab.scene.geometry_epoch, + width, + auto_collapse, + ), crate::ui::dock::PanelId::PointCloudManager => crate::ui::window::pc_manager::view( &self.pc_manager, &tab.scene.document, diff --git a/src/app/view/modal.rs b/src/app/view/modal.rs index d5f81eae6..5f693e757 100644 --- a/src/app/view/modal.rs +++ b/src/app/view/modal.rs @@ -40,6 +40,7 @@ impl OpenCADStudio { Some(K::GeometricTolerance) => crate::t!("Geometric Tolerance").into_owned(), Some(K::AttDef) => crate::t!("Attribute Definition").into_owned(), Some(K::AttDefEdit) => crate::t!("Edit Attribute Definition").into_owned(), + Some(K::CountInvalidArea) => crate::t!("Count - Invalid Area").into_owned(), Some(K::Field) => crate::t!("Field").into_owned(), Some(K::DraftingSettings) => crate::t!("Drafting Settings").into_owned(), Some(K::AutoConstrainSettings) => crate::t!("Constraint Settings").into_owned(), @@ -584,6 +585,9 @@ impl OpenCADStudio { let state = self.field_dialog.as_ref()?; sized_flow(ex, 760, 660, |flow| crate::ui::window::field_dialog::view(state, flow)) } + super::super::ModalKind::CountInvalidArea => { + sized_flow(ex, 520, 260, |flow| crate::ui::window::count_palette::invalid_area_view(&self.count_palette, flow)) + } super::super::ModalKind::AttDefEdit => { let state = self.attdef_edit.as_ref()?; sized_flow(ex, 460, 220, |flow| crate::ui::window::attdef_dialog::view_edit(state, flow)) diff --git a/src/locale_catalog.rs b/src/locale_catalog.rs index 3fe005fee..40758aea5 100644 --- a/src/locale_catalog.rs +++ b/src/locale_catalog.rs @@ -5530,6 +5530,30 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static "US Survey Feet" => Some(("common", "us-survey-feet")), "to layout" => Some(("common", "to-layout")), "{count} objects selected" => Some(("common", "count-objects-selected")), + "Count palette" => Some(("count-feature", "palette")), + "Count menu" => Some(("count-feature", "menu")), + "Count Selection" => Some(("count-feature", "selection")), + "Review Count Details" => Some(("count-feature", "review")), + "Insert Count Field" => Some(("count-feature", "insert-field")), + "EXPAND BY" => Some(("count-feature", "expand-by")), + "Mirror State" => Some(("count-feature", "mirror-state")), + "Count in a specified area" => Some(("count-feature", "area")), + "Create Table" => Some(("count-feature", "create-table")), + "Back to List" => Some(("count-feature", "back")), + "Match Scale" => Some(("count-feature", "match-scale")), + "Match Mirror State" => Some(("count-feature", "match-mirror")), + "Count Error Report: {}" => Some(("count-feature", "error-report")), + "Count: {}" => Some(("count-feature", "toolbar-count")), + "Overlapping object" => Some(("count-feature", "overlap")), + "Show Count Instances" => Some(("count-feature", "show-instances")), + "Geometries: {}" => Some(("count-feature", "geometries")), + "Count - Invalid Area" => Some(("count-feature", "invalid-title")), + "The changes to count boundary have resulted to an invalid count area. What do you want to do?" => Some(("count-feature", "invalid-message")), + "Undo the changes to the count boundary" => Some(("count-feature", "invalid-undo")), + "The last change is undone; the boundary and the count area come back." => Some(("count-feature", "invalid-undo-detail")), + "Continue and count the entire model space" => Some(("count-feature", "invalid-continue")), + "The count area is dropped; counting goes on in all of model space." => Some(("count-feature", "invalid-continue-detail")), + "Always perform my current choice" => Some(("count-feature", "invalid-always")), _ => None, } } diff --git a/src/modules/annotate/count_cmd.rs b/src/modules/annotate/count_cmd.rs new file mode 100644 index 000000000..f2be96126 --- /dev/null +++ b/src/modules/annotate/count_cmd.rs @@ -0,0 +1,447 @@ +// COUNT / COUNTAREA, COUNTTABLE and UPDATEFIELD. +// +// Specify first corner point of the count area or +// [Current area/Entire model space/Object/Polygonal] : +// Specify opposite corner: (Polygonal: Specify start point: / +// Specify next point; Object: +// Select object as count area boundary:) +// Select target objects or [List all blocks] : (COUNT) +// +// The command gathers its answers and hands them to the application as +// `_COUNTRUN L|T ` (COUNTAREA: `_COUNTAREASET `), the +// area being `C` (current view), `E` (entire model space), `R x,y x,y`, +// `P x,y x,y …` or `O `. + +use codec::types::Handle; +use codec::EntityType; +use glam::DVec3; + +use crate::command::{CadCommand, CmdOption, CmdResult, InputKind}; +use crate::scene::model::wire_model::WireModel; + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +enum Step { + Area, + Corner, + PolyStart, + PolyNext, + Object, + Targets, +} + +pub struct CountCommand { + /// COUNTAREA: only the area is asked. + area_only: bool, + step: Step, + area: String, + points: Vec, + targets: Vec, + picked: Option, +} + +fn xy(p: DVec3) -> String { + format!("{:?},{:?}", p.x, p.y) +} + +impl CountCommand { + pub fn new(area_only: bool) -> Self { + Self { + area_only, + step: Step::Area, + area: String::new(), + points: Vec::new(), + targets: Vec::new(), + picked: None, + } + } + + /// COUNT whose area is already answered (`COUNT _C`). + pub fn with_area(area: &str) -> Self { + Self { area: area.to_string(), step: Step::Targets, ..Self::new(false) } + } + + fn area_done(&mut self, area: String) -> CmdResult { + self.area = area; + if self.area_only { + return CmdResult::Dispatch(format!("_COUNTAREASET {}", self.area)); + } + self.step = Step::Targets; + CmdResult::NeedPoint + } + + fn area_keyword(&mut self, text: &str) -> CmdResult { + match text.trim().trim_start_matches('_').to_ascii_uppercase().as_str() { + "" | "C" | "CURRENT" | "CURRENT AREA" => self.area_done("C".into()), + "E" | "ENTIRE" | "ENTIRE MODEL SPACE" => self.area_done("E".into()), + "O" | "OBJECT" => { + self.step = Step::Object; + CmdResult::NeedPoint + } + "P" | "POLYGONAL" => { + self.points.clear(); + self.step = Step::PolyStart; + CmdResult::NeedPoint + } + _ => CmdResult::ReportError("Invalid option keyword.".into()), + } + } + + fn finish_targets(&self) -> CmdResult { + if self.targets.is_empty() { + return CmdResult::Dispatch(format!("_COUNTRUN {} L", self.area)); + } + let handles: Vec = self.targets.iter().map(|h| format!("{:X}", h.value())).collect(); + CmdResult::Dispatch(format!("_COUNTRUN {} T {}", self.area, handles.join(","))) + } +} + +impl CadCommand for CountCommand { + fn name(&self) -> &'static str { + if self.area_only { + "COUNTAREA" + } else { + "COUNT" + } + } + + fn prompt(&self) -> String { + match self.step { + Step::Area => "Specify first corner point of the count area or [Current area/Entire model space/Object/Polygonal] :".into(), + Step::Corner => "Specify opposite corner:".into(), + Step::PolyStart => "Specify start point:".into(), + // The reference shows this one without a colon. + Step::PolyNext => "Specify next point".into(), + Step::Object => "Select object as count area boundary:".into(), + Step::Targets => "Select target objects or [List all blocks] :".into(), + } + } + + fn options(&self) -> Vec { + match self.step { + Step::Area => vec![ + CmdOption::new("Current area", "C"), + CmdOption::new("Entire model space", "E"), + CmdOption::new("Object", "O"), + CmdOption::new("Polygonal", "P"), + ], + Step::Targets => vec![CmdOption::new("List all blocks", "L")], + _ => Vec::new(), + } + } + + fn input_kind(&self) -> InputKind { + match self.step { + Step::Targets => InputKind::SingleToken, + _ => InputKind::Point, + } + } + + fn point_step_accepts_keywords(&self) -> bool { + self.step == Step::Area + } + + fn is_selection_gathering(&self) -> bool { + self.step == Step::Targets + } + + fn on_selection_complete(&mut self, handles: Vec) -> CmdResult { + // The selection arrives whole after every pick: `1 found`, then + // `1 found, 2 total`. + let found = handles.iter().filter(|h| !self.targets.contains(h)).count(); + let first = self.targets.is_empty(); + self.targets = handles; + CmdResult::ReportMeasurement(if first { + format!("{found} found") + } else { + format!("{found} found, {} total", self.targets.len()) + }) + } + + fn needs_entity_pick(&self) -> bool { + self.step == Step::Object + } + + fn inject_before_entity_pick(&self) -> bool { + true + } + + fn inject_picked_entity(&mut self, entity: EntityType) { + self.picked = Some(entity); + } + + fn on_entity_pick(&mut self, handle: Handle, _pt: DVec3) -> CmdResult { + match self.picked.take() { + Some(EntityType::LwPolyline(pl)) if codec::count::valid_boundary(&pl) => { + self.area_done(format!("O {:X}", handle.value())) + } + _ => CmdResult::CancelWithMessage( + "Invalid count area boundary object. Select a closed polyline consisting of line segments and does not intersect itself." + .into(), + ), + } + } + + fn on_point(&mut self, pt: DVec3) -> CmdResult { + match self.step { + Step::Area => { + self.points = vec![pt]; + self.step = Step::Corner; + CmdResult::NeedPoint + } + Step::Corner => { + let area = format!("R {} {}", xy(self.points[0]), xy(pt)); + self.area_done(area) + } + Step::PolyStart | Step::PolyNext => { + self.points.push(pt); + self.step = Step::PolyNext; + CmdResult::NeedPoint + } + _ => CmdResult::NeedPoint, + } + } + + fn on_text_input(&mut self, text: &str) -> Option { + match self.step { + Step::Area => Some(self.area_keyword(text)), + Step::Targets => match text.trim().trim_start_matches('_').to_ascii_uppercase().as_str() { + "L" | "LIST" | "LIST ALL BLOCKS" => { + self.targets.clear(); + Some(self.finish_targets()) + } + _ => None, + }, + _ => None, + } + } + + fn on_enter(&mut self) -> CmdResult { + match self.step { + Step::Area => self.area_keyword(""), + Step::PolyNext if self.points.len() >= 3 => { + let ring: Vec = self.points.iter().map(|p| xy(*p)).collect(); + let area = format!("P {}", ring.join(" ")); + self.area_done(area) + } + Step::Targets => self.finish_targets(), + _ => CmdResult::NeedPoint, + } + } + + fn on_mouse_move(&mut self, pt: DVec3) -> Option { + let ring: Vec = match self.step { + Step::Corner => { + let a = self.points[0]; + vec![a, DVec3::new(pt.x, a.y, a.z), pt, DVec3::new(a.x, pt.y, a.z)] + } + Step::PolyNext => { + let mut ring = self.points.clone(); + ring.push(pt); + ring + } + _ => return None, + }; + let mut points: Vec<[f64; 3]> = ring.iter().map(|p| p.to_array()).collect(); + points.push(points[0]); + Some(WireModel::solid_f64("count_area".into(), points, WireModel::CYAN, false)) + } +} + +/// COUNTTABLE: the blocks to list, then where the table goes. +pub struct CountTableCommand { + step: TableStep, + /// Defined user blocks (sorted), and the listing's other counts: + /// external references, dependent and unnamed blocks. + blocks: Vec, + others: [usize; 3], + names: Vec, + /// Listing lines still to show after `Press ENTER to continue:`. + pending: Vec, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum TableStep { + Names, + List, + Point, + Page, +} + +impl CountTableCommand { + pub fn new(blocks: Vec, others: [usize; 3]) -> Self { + Self { step: TableStep::Names, blocks, others, names: Vec::new(), pending: Vec::new() } + } + + /// The palette's Create Table: the blocks are chosen, only the point is asked. + pub fn placing(names: Vec) -> Self { + Self { step: TableStep::Point, blocks: Vec::new(), others: [0; 3], names, pending: Vec::new() } + } + + /// `?`: the defined blocks matching the pattern, paged by 22 lines + /// (`Press ENTER to continue:` in between), then the summary. + fn listing(&mut self, pattern: &str) -> CmdResult { + let pattern = if pattern.trim().is_empty() { "*" } else { pattern.trim() }; + let mut names: Vec<&String> = self + .blocks + .iter() + .filter(|name| pattern.split(',').any(|p| crate::io::xref_model::wildcard_match(name, p.trim()))) + .collect(); + names.sort_by_key(|name| list_order(name)); + self.pending = std::iter::once(String::new()) + .chain(std::iter::once("Defined blocks.".to_string())) + .chain(names.iter().map(|name| format!(" {:<31}", format!("\"{name}\"")))) + .collect(); + self.next_page() + } + + fn next_page(&mut self) -> CmdResult { + let page: Vec = self.pending.drain(..self.pending.len().min(22)).collect(); + if !self.pending.is_empty() { + self.step = TableStep::Page; + return CmdResult::ReportMeasurement(page.join(" +")); + } + CmdResult::Measurement(format!( + "{} + +User External Dependent Unnamed +Blocks References Blocks Blocks +{:>5}{:>10}{:>12}{:>12} +", + page.join(" +"), + self.blocks.len(), + self.others[0], + self.others[1], + self.others[2] + )) + } +} + +/// Block listing order: `_` first, then `-`, space and other punctuation, +/// digits, and letters without case. +fn list_order(name: &str) -> Vec<(u32, u32)> { + name.chars() + .map(|c| match c { + '_' => (0, 0), + '-' => (1, 0), + ' ' => (2, 0), + '0'..='9' => (4, c as u32), + c if c.is_alphabetic() => (5, c.to_lowercase().next().unwrap_or(c) as u32), + c => (3, c as u32), + }) + .collect() +} + +impl CadCommand for CountTableCommand { + fn name(&self) -> &'static str { + "COUNTTABLE" + } + + fn prompt(&self) -> String { + match self.step { + TableStep::Names => "Enter block name(s) to include or [?] :".into(), + TableStep::List => "Enter block(s) to list <*>:".into(), + TableStep::Point => "Specify insertion point:".into(), + TableStep::Page => "Press ENTER to continue:".into(), + } + } + + fn input_kind(&self) -> InputKind { + match self.step { + TableStep::Point => InputKind::Point, + _ => InputKind::FreeText, + } + } + + fn on_text_input(&mut self, text: &str) -> Option { + let text = text.trim(); + Some(match self.step { + TableStep::Names if text == "?" => { + self.step = TableStep::List; + CmdResult::NeedPoint + } + TableStep::Names if text.is_empty() => { + self.names.clear(); + self.step = TableStep::Point; + CmdResult::NeedPoint + } + TableStep::Names => { + let mut names = Vec::new(); + for part in text.split(',').map(str::trim).filter(|p| !p.is_empty()) { + match self.blocks.iter().find(|b| b.eq_ignore_ascii_case(part)) { + Some(b) => names.push(b.clone()), + None => return Some(CmdResult::CancelWithMessage(format!("Block \"{part}\" cannot be found."))), + } + } + self.names = names; + self.step = TableStep::Point; + CmdResult::NeedPoint + } + TableStep::List => self.listing(text), + TableStep::Page => self.next_page(), + TableStep::Point => return None, + }) + } + + fn on_point(&mut self, pt: DVec3) -> CmdResult { + if self.step != TableStep::Point { + return CmdResult::NeedPoint; + } + CmdResult::Dispatch(format!("_COUNTTABLEPLACE {:?},{:?},{:?} {}", pt.x, pt.y, pt.z, self.names.join("|"))) + } + + fn on_enter(&mut self) -> CmdResult { + match self.step { + TableStep::Point => CmdResult::ReportError("Invalid point.".into()), + _ => self.on_text_input("").unwrap_or(CmdResult::NeedPoint), + } + } +} + +/// UPDATEFIELD: `Select objects:`, then the fields of the selection are +/// evaluated again. +pub struct UpdateFieldCommand { + selected: Vec, +} + +impl UpdateFieldCommand { + pub fn new() -> Self { + Self { selected: Vec::new() } + } +} + +impl CadCommand for UpdateFieldCommand { + fn name(&self) -> &'static str { + "UPDATEFIELD" + } + + fn prompt(&self) -> String { + "Select objects:".into() + } + + fn is_selection_gathering(&self) -> bool { + true + } + + fn on_selection_complete(&mut self, handles: Vec) -> CmdResult { + self.selected = handles; + CmdResult::NeedPoint + } + + fn on_point(&mut self, _pt: DVec3) -> CmdResult { + CmdResult::NeedPoint + } + + fn on_enter(&mut self) -> CmdResult { + if self.selected.is_empty() { + return CmdResult::Cancel; + } + let handles: Vec = self.selected.iter().map(|h| format!("{:X}", h.value())).collect(); + CmdResult::Dispatch(format!("_UPDATEFIELDRUN {}", handles.join(","))) + } +} + +inventory::submit!(crate::command::CommandRegistration { + names: &["COUNT", "COUNTAREA", "COUNTCLOSE", "COUNTFIELD", "COUNTLIST", "COUNTLISTCLOSE", "COUNTTABLE", "UPDATEFIELD"] +}); diff --git a/src/modules/annotate/field_cmd.rs b/src/modules/annotate/field_cmd.rs index 73cf7d02d..953b362d1 100644 --- a/src/modules/annotate/field_cmd.rs +++ b/src/modules/annotate/field_cmd.rs @@ -29,6 +29,7 @@ pub struct FieldPlaceCommand { height: f64, attachment: AttachmentPoint, plane: WorkingPlane, + name: &'static str, } impl FieldPlaceCommand { @@ -41,8 +42,16 @@ impl FieldPlaceCommand { height, attachment: AttachmentPoint::TopLeft, plane: WorkingPlane::default(), + name: "FIELD", } } + + /// The command name shown with the prompts (COUNTFIELD places its field + /// as MTEXT). + pub fn named(mut self, name: &'static str) -> Self { + self.name = name; + self + } } fn short(v: f64) -> String { @@ -52,7 +61,7 @@ fn short(v: f64) -> String { impl CadCommand for FieldPlaceCommand { fn name(&self) -> &'static str { - "FIELD" + self.name } fn set_working_plane(&mut self, plane: WorkingPlane) { diff --git a/src/modules/annotate/mod.rs b/src/modules/annotate/mod.rs index 5a882d148..8e9f8ba39 100644 --- a/src/modules/annotate/mod.rs +++ b/src/modules/annotate/mod.rs @@ -8,6 +8,7 @@ pub mod data_extract; pub mod data_link; pub mod ddedit; pub mod field_cmd; +pub mod count_cmd; pub mod diameter_dim; pub mod dim_baseline; pub mod dim_continue; diff --git a/src/modules/view/count_palette.rs b/src/modules/view/count_palette.rs new file mode 100644 index 000000000..fd1d4e652 --- /dev/null +++ b/src/modules/view/count_palette.rs @@ -0,0 +1,11 @@ +use crate::modules::{IconKind, ModuleEvent, ToolDef}; +pub const ICON: IconKind = IconKind::Svg(include_bytes!("../../../assets/icons/data_extract.svg")); +/// View › Palettes › Count: opens or closes the Count palette. +pub fn tool() -> ToolDef { + ToolDef { + id: "COUNTLIST", + label: "Count palette", + icon: ICON, + event: ModuleEvent::Command("_COUNTLISTTOGGLE".to_string()), + } +} diff --git a/src/modules/view/mod.rs b/src/modules/view/mod.rs index d29f1826d..e11bbd383 100644 --- a/src/modules/view/mod.rs +++ b/src/modules/view/mod.rs @@ -12,6 +12,7 @@ pub mod plot_window; pub mod quick_print; mod properties_palette; mod sheetset; +mod count_palette; mod tile_horiz; mod tile_vert; pub mod visual_style; @@ -120,6 +121,7 @@ impl CadModule for ViewModule { tools: vec![ RibbonItem::LargeTool(tool_palettes::tool()), RibbonItem::LargeTool(properties_palette::tool()), + RibbonItem::LargeTool(count_palette::tool()), RibbonItem::LargeTool(sheetset::tool()), ], }, diff --git a/src/scene/entity.rs b/src/scene/entity.rs index 9c22246bb..fcb98ef22 100644 --- a/src/scene/entity.rs +++ b/src/scene/entity.rs @@ -1451,6 +1451,7 @@ impl Scene { } } } + m.color = self.count_color(handle, m.color, self.current_bg()); if tint_selected && self.selected.contains(&handle) { m.color = [0.15, 0.55, 1.00, m.color[3]]; } @@ -1752,6 +1753,7 @@ impl Scene { ) }); } + model.color = self.count_color(context.root_handle, model.color, hatch_bg); if tint_selected && self.selected.contains(&context.root_handle) { model.color = [0.15, 0.55, 1.00, model.color[3]]; } diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 67335f4bb..2532218c6 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -1973,6 +1973,15 @@ pub enum ViewportRefreshScope { /// Entity membership in document order, keyed by geometry epoch. type BlockMembers = (u64, HashMap>); +/// What count mode colours: see [`Scene::count_display`]. +#[derive(Debug, Clone, PartialEq, Default)] +pub struct CountDisplay { + pub counted: HashSet, + pub errors: HashSet, + pub color: [f32; 4], + pub error_color: [f32; 4], +} + pub struct Scene { pub camera: Rc>, /// View saved immediately before the latest navigation operation. ZOOM @@ -2061,6 +2070,10 @@ pub struct Scene { /// edited geometry stands out while the surrounding drawing stays visible /// for context. `None` = not editing. (#136) pub refedit_keep: Option>, + /// COUNT mode colours: counted references in COUNTCOLOR, overlapping + /// duplicates in COUNTERRORCOLOR, everything else faded. `None` outside + /// count mode. + pub count_display: Option, /// Entity drawn with the selection-highlight colour without being part /// of the real selection — used to preview a row in the cycling list box. pub hover_highlight: Option, @@ -2658,6 +2671,7 @@ impl Scene { preview_hidden: HashSet::default(), command_preview_hidden: HashSet::default(), refedit_keep: None, + count_display: None, hover_highlight: None, constraint_hover_highlights: HashSet::default(), constraint_hover_refs: Vec::new(), @@ -3497,6 +3511,15 @@ impl Scene { self.associative_hatch_source_cache.borrow_mut().take(); } let mut changes = changes.to_vec(); + // A table's drawing (its *T block) follows the table's cells, sizes + // and styles; it is left alone while it already matches. + for (handle, kind) in &changes { + if !matches!(kind, ChangeKind::Removed) + && matches!(self.document.get_entity(*handle), Some(EntityType::Table(_))) + { + self.document.refresh_table_block(*handle); + } + } // A restyle (layer on/off, colour, text or dimension style) moves no // geometry, so nothing measured from it is re-resolved: an // association that disagrees with its dimension would otherwise @@ -4036,6 +4059,9 @@ impl Scene { ); } } + if self.meshes.contains_key(&h) { + material.diffuse = self.count_color(h, material.diffuse, bg); + } (h, material) }) }) @@ -4134,10 +4160,50 @@ impl Scene { self.bump_geometry_no_blocks(); } + /// Enter / leave the COUNT colouring; a no-op when nothing changed. + pub fn set_count_display(&mut self, display: Option) { + if self.count_display == display { + return; + } + self.count_display = display; + self.hatch_cache.borrow_mut().clear(); + self.recolor_meshes(); + self.bump_geometry_no_blocks(); + } + + /// The colour count mode gives an object drawn as part of `handle` (a + /// top-level entity): the count colours, else faded; unchanged outside + /// count mode. + pub(crate) fn count_color(&self, handle: Handle, color: [f32; 4], bg: [f32; 4]) -> [f32; 4] { + let Some(count) = &self.count_display else { + return color; + }; + let [r, g, b, _] = if count.errors.contains(&handle) { + count.error_color + } else if count.counted.contains(&handle) { + count.color + } else { + return crate::scene::cache::block_cache::fade_toward_bg(color, bg); + }; + [r, g, b, color[3]] + } + /// Fade the colours of wires that belong to entities outside the REFEDIT /// keep set (no-op when not editing). The geometry is untouched, so - /// hit-testing still works on faded entities. + /// hit-testing still works on faded entities. In count mode the counted + /// references and the duplicates take the count colours instead. fn apply_refedit_fade(&self, wires: &mut [WireModel], bg: [f32; 4]) { + if let Some(count) = &self.count_display { + for w in wires.iter_mut() { + w.color = match Self::handle_from_wire_name(&w.name) { + Some(h) if count.errors.contains(&h) => count.error_color, + Some(h) if count.counted.contains(&h) => count.color, + _ => crate::scene::cache::block_cache::fade_toward_bg(w.color, bg), + }; + // Shared instance geometry keeps its own colour; draw these wires on their own. + w.render_instance = None; + } + } let Some(keep) = &self.refedit_keep else { return; }; @@ -8777,6 +8843,14 @@ impl Scene { self.material_base_dir.as_deref(), ); } + if self.count_display.is_some() { + let own = transformed.instance_color.or(set.display_color()).unwrap_or([1.0; 4]); + let color = self.count_color(context.root_handle, own, self.current_bg()); + transformed.instance_color = Some(color); + if let Some(material) = transformed.material.as_mut() { + material.diffuse = color; + } + } transformed.instance_handle = Some(context.root_handle); out.push(transformed); }, diff --git a/src/ui/dock.rs b/src/ui/dock.rs index 1e62a58cf..9cab6440c 100644 --- a/src/ui/dock.rs +++ b/src/ui/dock.rs @@ -48,6 +48,8 @@ pub enum PanelId { NodeGraph, /// Regions and scans of the drawing's point clouds. PointCloudManager, + /// Block counts (COUNTLIST). + Count, } impl PanelId { @@ -60,6 +62,7 @@ impl PanelId { PanelId::Browser => "Browser", PanelId::NodeGraph => "Node Graph", PanelId::PointCloudManager => "Point Cloud Manager", + PanelId::Count => "Count", } } @@ -72,6 +75,7 @@ impl PanelId { PanelId::Browser => 230.0, PanelId::NodeGraph => 220.0, PanelId::PointCloudManager => 280.0, + PanelId::Count => 280.0, } } @@ -162,6 +166,7 @@ impl DockState { PanelId::Browser, PanelId::NodeGraph, PanelId::PointCloudManager, + PanelId::Count, ] { self.panels.entry(id).or_insert_with(|| DockPanel::for_id(id)); } diff --git a/src/ui/popup/context_menu.rs b/src/ui/popup/context_menu.rs index 4c68f01ae..407aa66c2 100644 --- a/src/ui/popup/context_menu.rs +++ b/src/ui/popup/context_menu.rs @@ -722,6 +722,12 @@ fn idle_rows( t!("Quick Select...").into_owned(), MenuAction::QuickSelect, ))); + // Count the selected objects, or start a count. + rows.push(MenuRow::Item(if has_selection { + cmd(t!("Count Selection").into_owned(), "COUNT _C") + } else { + cmd(t!("Count menu").into_owned(), "COUNT") + })); rows.push(MenuRow::Separator); // ── Panels ───────────────────────────────────────────────────────── diff --git a/src/ui/ribbon/mod.rs b/src/ui/ribbon/mod.rs index 0cc1e04ee..c98ae83a5 100644 --- a/src/ui/ribbon/mod.rs +++ b/src/ui/ribbon/mod.rs @@ -58,6 +58,8 @@ pub struct Ribbon { pub show_ucs_icon: bool, /// Properties panel (PROPERTIES) visibility — drives the Properties button highlight. pub show_properties: bool, + /// The Count palette is open (View › Palettes › Count). + pub show_count_palette: bool, /// File tabs (FILETAB) visibility — drives the File Tabs button highlight. pub show_file_tabs: bool, /// Layout tabs (LAYOUTTAB) visibility — drives the Layout Tabs button highlight. @@ -174,6 +176,7 @@ impl Ribbon { show_viewcube: true, show_ucs_icon: true, show_properties: true, + show_count_palette: false, show_file_tabs: true, show_layout_tabs: true, open_dropdown: None, @@ -335,6 +338,9 @@ impl Ribbon { pub fn set_properties(&mut self, on: bool) { self.show_properties = on; } + pub fn set_count_palette(&mut self, on: bool) { + self.show_count_palette = on; + } pub fn set_file_tabs(&mut self, on: bool) { self.show_file_tabs = on; } @@ -352,6 +358,7 @@ impl Ribbon { show_viewcube: self.show_viewcube, show_ucs_icon: self.show_ucs_icon, show_properties: self.show_properties, + show_count_palette: self.show_count_palette, show_block_palette, show_file_tabs: self.show_file_tabs, show_layout_tabs: self.show_layout_tabs, diff --git a/src/ui/ribbon/widgets.rs b/src/ui/ribbon/widgets.rs index 74e7d9393..89bb1de2f 100644 --- a/src/ui/ribbon/widgets.rs +++ b/src/ui/ribbon/widgets.rs @@ -37,6 +37,7 @@ pub(super) struct ToggleState { pub show_viewcube: bool, pub show_ucs_icon: bool, pub show_properties: bool, + pub show_count_palette: bool, pub show_block_palette: bool, pub show_file_tabs: bool, pub show_layout_tabs: bool, @@ -379,6 +380,7 @@ pub(super) fn is_active_tool( "NAVVCUBE" => state.show_viewcube, "UCSICON" => state.show_ucs_icon, "PROPERTIES" => state.show_properties, + "COUNTLIST" => state.show_count_palette, "BLOCKPALETTE" => state.show_block_palette, "FILETAB" => state.show_file_tabs, "LAYOUTTAB" => state.show_layout_tabs, diff --git a/src/ui/window/count_palette.rs b/src/ui/window/count_palette.rs new file mode 100644 index 000000000..1f31eeb31 --- /dev/null +++ b/src/ui/window/count_palette.rs @@ -0,0 +1,678 @@ +//! The Count palette (COUNTLIST) and count mode (COUNT): the drawing's +//! blocks with how many references of each are counted, and — once a block +//! is chosen — its counted references coloured in the drawing with a +//! floating toolbar above it. Counting itself is `codec::count`. + +use crate::app::Message; +use crate::ui::dock::PanelId; +use crate::ui::style::common::muted_style; +use crate::ui::style::form::{button_style, field_style}; +use codec::count::{BlockInstance, CountKey}; +use codec::{CadDocument, Handle}; +use iced::widget::{button, checkbox, column, container, mouse_area, row, scrollable, text, text_input, tooltip, Space}; +use iced::{Background, Border, Color, Element, Fill, Length, Theme}; + +const BLOCK_ICON: &[u8] = include_bytes!("../../../assets/icons/blocks/block.svg"); +const AREA_ICON: &[u8] = include_bytes!("../../../assets/icons/shapes/rect.svg"); +const SELECT_ICON: &[u8] = include_bytes!("../../../assets/icons/blocks/select_objects.svg"); +const TABLE_ICON: &[u8] = include_bytes!("../../../assets/icons/table.svg"); +const WARNING_ICON: &[u8] = include_bytes!("../../../assets/icons/ui/warning_triangle.svg"); +const CHECK_ICON: &[u8] = include_bytes!("../../../assets/icons/ui/check.svg"); + +/// The name of the layer count areas are drawn on. +pub const AREA_LAYER: &str = "0-CountArea"; + +/// What a count counts. +#[derive(Debug, Clone, PartialEq)] +pub enum CountTarget { + /// The references of a block. `reference` is the instance the match + /// options compare with; `picked` when it came from a COUNT selection. + Block { + name: String, + reference: Option, + matching: [bool; 3], + picked: bool, + }, + /// Picked objects (several, or one that is not a reference): the copies + /// of the group are counted. + Group(Vec), +} + +/// Count mode of a drawing. +#[derive(Debug, Clone, Default)] +pub struct CountMode { + /// The count area (a closed WCS polygon); `None` = all of model space. + pub area: Option>, + /// The closed polyline marking the area, and whether count mode drew it + /// (then COUNTCLOSE erases it unless a field refers to it). + pub boundary: Option<(Handle, bool)>, + pub target: Option, + /// The counted reference ← / → last showed. + pub cursor: Option, + /// The scene geometry epoch the colouring was computed at. + pub epoch: u64, + /// The result and count at `epoch` (recomputed when the drawing changes). + pub cache: Option<(CountResult, usize)>, +} + +/// A count mode's result: the counted references and the overlapping +/// duplicates (errors). +#[derive(Debug, Clone, Default, PartialEq)] +pub struct CountResult { + pub counted: Vec, + pub errors: Vec, + /// The counted references a duplicate overlaps (drawn in the error colour too). + pub overlapped: Vec, +} + +impl CountMode { + pub fn instances(&self, doc: &CadDocument) -> Vec { + codec::count::block_instances(doc, self.area.as_deref()) + } + + /// The key the match options build from the reference instance. + pub fn key(&self, instances: &[BlockInstance]) -> CountKey { + match &self.target { + Some(CountTarget::Block { reference: Some(r), matching, .. }) => instances + .iter() + .find(|i| i.handle == *r) + .map(|i| CountKey::of(i, matching[0], matching[1], matching[2])) + .unwrap_or_default(), + _ => CountKey::default(), + } + } + + pub fn result(&self, doc: &CadDocument) -> CountResult { + match &self.cache { + Some((result, _)) => result.clone(), + None => self.compute(doc).0, + } + } + + /// What the count shows: the counted references, or the copies of a group. + pub fn count(&self, doc: &CadDocument) -> usize { + match &self.cache { + Some((_, n)) => *n, + None => self.compute(doc).1, + } + } + + /// The result and the count, worked out from the drawing. + pub fn compute(&self, doc: &CadDocument) -> (CountResult, usize) { + if let Some(CountTarget::Group(handles)) = &self.target { + let groups = codec::count::group_matches(doc, handles, self.area.as_deref()); + let result = CountResult { counted: groups.concat(), errors: Vec::new(), overlapped: Vec::new() }; + return (result, groups.len()); + } + let result = self.block_result(doc); + let n = result.counted.len(); + (result, n) + } + + fn block_result(&self, doc: &CadDocument) -> CountResult { + match &self.target { + None | Some(CountTarget::Group(_)) => CountResult::default(), + Some(CountTarget::Block { name, .. }) => { + let instances = self.instances(doc); + let key = self.key(&instances); + let mine = |i: &&BlockInstance| i.name.eq_ignore_ascii_case(name) && key.matches(i); + CountResult { + counted: instances.iter().filter(mine).filter(|i| i.duplicate_of.is_none()).map(|i| i.handle).collect(), + errors: instances.iter().filter(mine).filter(|i| i.duplicate_of.is_some()).map(|i| i.handle).collect(), + overlapped: instances.iter().filter(mine).filter_map(|i| i.duplicate_of).collect(), + } + } + } + } + + /// The name the toolbar and the command line show for the target. + pub fn target_name(&self) -> Option { + match &self.target { + Some(CountTarget::Block { name, .. }) => Some(name.clone()), + Some(CountTarget::Group(_)) => Some("group".into()), + None => None, + } + } +} + +/// Palette state that is not in the drawing, and the profile variables +/// COUNTCOLOR, COUNTERRORCOLOR and COUNTSERVICE. +#[derive(Debug)] +pub struct CountPalette { + pub show: bool, + pub search: String, + /// Sorted by the Count column (else by Name), and descending. + pub by_count: bool, + pub descending: bool, + /// Expanded rows: block name (upper case) → expand by layer, scale, mirror state. + pub expanded: Vec<(String, [bool; 3])>, + /// Create Table selection mode: the checked block names (upper case). + pub table: Option>, + pub details_closed: bool, + pub errors_closed: bool, + pub color: i16, + pub error_color: i16, + pub service: bool, + /// The Invalid Area dialog's "always" box while it is open. + pub invalid_always: bool, + /// What a lost count boundary does without asking: 0 ask, 1 undo, 2 go + /// on with all of model space (kept with the user settings). + pub invalid_choice: u8, + /// The list's references for (document, geometry epoch, area). + pub list_cache: std::cell::RefCell>, std::rc::Rc>)>>, +} + +impl Default for CountPalette { + fn default() -> Self { + Self { + show: false, + search: String::new(), + by_count: false, + descending: false, + expanded: Vec::new(), + table: None, + details_closed: false, + errors_closed: false, + color: 3, + error_color: 1, + service: true, + invalid_always: false, + invalid_choice: 0, + list_cache: Default::default(), + } + } +} + +impl CountPalette { + pub fn expansion(&self, name: &str) -> Option<[bool; 3]> { + let key = name.to_ascii_uppercase(); + self.expanded.iter().find(|(n, _)| *n == key).map(|(_, e)| *e) + } +} + +/// What a row's context menu does. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RowAction { + Review, + Field, + /// Expand by layer (0), scale (1) or mirror state (2). + Expand(usize), +} + +#[derive(Debug, Clone)] +pub enum CountMsg { + Search(String), + /// Sort by the Count column (`true`) or the Name column. + Sort(bool), + /// A row clicked: count mode on its block (and key, for an expanded row). + Open(String, CountKey), + Menu(String, CountKey, RowAction), + CreateTable, + TableCheck(String, bool), + TableAll(bool), + TableCancel, + TableInsert, + Back, + Match(usize, bool), + ToggleDetails, + ToggleErrors, + /// Show an overlapping duplicate. + ShowError(Handle), + Area, + Prev, + Next, + Select, + Field, + Close, + /// The Invalid Area dialog: undo the boundary change (also its ✕ and + /// Cancel), or go on counting all of model space; `InvalidAlways` is its + /// "always perform my current choice" box. + InvalidUndo, + InvalidContinue, + InvalidAlways(bool), +} + +fn msg(m: CountMsg) -> Message { + Message::Count(m) +} + +/// One block's row: counted references and duplicates. +pub struct Row { + pub name: String, + pub count: usize, + pub errors: usize, +} + +/// The rows of the list: blocks whose name contains the search text +/// (case-insensitively), sorted by the chosen column. +pub fn rows(instances: &[BlockInstance], palette: &CountPalette) -> Vec { + let query = palette.search.trim().to_lowercase(); + let mut rows: Vec = codec::count::block_counts(instances) + .into_iter() + .filter(|c| query.is_empty() || c.name.to_lowercase().contains(&query)) + .map(|c| Row { count: c.counted.len(), errors: c.duplicates.len(), name: c.name }) + .collect(); + if palette.by_count { + rows.sort_by(|a, b| a.count.cmp(&b.count).then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))); + } + if palette.descending { + rows.reverse(); + } + rows +} + +fn small_icon<'a>(bytes: &'static [u8], size: f32) -> Element<'a, Message> { + crate::ui::icons::semantic(bytes, size) +} + +fn row_menu(name: String, key: CountKey, expandable: bool) -> Element<'static, Message> { + let entry = |label: String, action: RowAction| -> Element<'static, Message> { + button(text(label).size(12)) + .width(Fill) + .padding([5, 12]) + .on_press(msg(CountMsg::Menu(name.clone(), key.clone(), action))) + .style(|theme: &Theme, status| { + let mut s = button::text(theme, status); + if matches!(status, button::Status::Hovered) { + s.background = Some(Background::Color(theme.palette().primary.base.color)); + s.text_color = theme.palette().primary.base.text; + } + s + }) + .into() + }; + let mut items = column![ + entry(crate::t!("Review Count Details").into_owned(), RowAction::Review), + entry(crate::t!("Insert Count Field").into_owned(), RowAction::Field), + ] + .spacing(1); + if expandable { + items = items + .push(container(Space::new()).height(1).width(Fill).style(|theme: &Theme| container::Style { + background: Some(Background::Color(theme.palette().background.neutral.color)), + ..Default::default() + })) + .push(container(text(crate::t!("EXPAND BY")).size(10).style(muted_style)).padding([3, 12])) + .push(entry(crate::t!("Layer").into_owned(), RowAction::Expand(0))) + .push(entry(crate::t!("Scale").into_owned(), RowAction::Expand(1))) + .push(entry(crate::t!("Mirror State").into_owned(), RowAction::Expand(2))); + } + container(items) + .padding(4) + .width(Length::Fixed(200.0)) + .style(|theme: &Theme| container::Style { + background: Some(Background::Color(theme.palette().background.weak.color)), + border: Border { color: theme.palette().background.neutral.color, width: 1.0, radius: 4.0.into() }, + ..Default::default() + }) + .into() +} + +fn list_row<'a>( + palette: &CountPalette, + name: &str, + key: CountKey, + label: String, + count: String, + errors: bool, + depth: u16, + expanded: Option, +) -> Element<'a, Message> { + let lead: Element<'a, Message> = if let Some(checked) = palette.table.as_ref().filter(|_| depth == 0) { + let on = checked.contains(&name.to_ascii_uppercase()); + let n = name.to_string(); + checkbox(on).size(14).on_toggle(move |v| msg(CountMsg::TableCheck(n.clone(), v))).into() + } else if expanded == Some(true) { + container(text("−").size(13)).width(Length::Fixed(14.0)).into() + } else { + Space::new().width(Length::Fixed(14.0)).into() + }; + let mut cells = row![Space::new().width(Length::Fixed(f32::from(depth) * 18.0)), lead]; + if depth == 0 { + cells = cells.push(small_icon(BLOCK_ICON, 14.0)); + } + cells = cells.push(text(label).size(12).width(Fill)); + if errors { + cells = cells.push(crate::ui::icons::themed_warning(WARNING_ICON, 12.0)); + } + cells = cells.push(text(count).size(12)); + let body = container(cells.spacing(6).align_y(iced::Center)).width(Fill).padding([4, 6]); + let area = mouse_area(body) + .on_press(msg(CountMsg::Open(name.to_string(), key.clone()))) + .interaction(iced::mouse::Interaction::Pointer); + let (n, expandable) = (name.to_string(), depth == 0); + iced_aw::ContextMenu::new(area, move || row_menu(n.clone(), key.clone(), expandable)).into() +} + +fn header_button<'a>(label: String, active: bool, descending: bool, m: CountMsg) -> Element<'a, Message> { + let arrow = if !active { + "" + } else if descending { + " ▼" + } else { + " ▲" + }; + button(text(format!("{label}{arrow}")).size(11)) + .on_press(msg(m)) + .style(button::text) + .padding([2, 4]) + .into() +} + +/// The list: search, area button, Name / Count columns, rows, Create Table. +fn list_view<'a>(palette: &'a CountPalette, instances: &[BlockInstance]) -> Element<'a, Message> { + let search = text_input(&crate::t!("Search"), &palette.search) + .on_input(|v| msg(CountMsg::Search(v))) + .padding([4, 8]) + .size(12) + .style(field_style); + let top = row![ + search.width(Fill), + crate::ui::dock::tool_button( + small_icon(AREA_ICON, crate::ui::dock::TOOL_H), + crate::t!("Count in a specified area").into_owned(), + msg(CountMsg::Area), + ), + ] + .spacing(4) + .align_y(iced::Center); + let header = row![ + header_button(crate::t!("Name").into_owned(), !palette.by_count, palette.descending, CountMsg::Sort(false)), + Space::new().width(Fill), + header_button(crate::t!("Count").into_owned(), palette.by_count, palette.descending, CountMsg::Sort(true)), + ] + .align_y(iced::Center); + let mut list = column![].spacing(1); + for r in rows(instances, palette) { + let expansion = palette.expansion(&r.name).filter(|e| e.iter().any(|v| *v)); + let count = if expansion.is_some() { "•••".to_string() } else { r.count.to_string() }; + list = list.push(list_row( + palette, + &r.name, + CountKey::default(), + r.name.clone(), + count, + r.errors > 0, + 0, + Some(expansion.is_some()), + )); + if let Some([l, s, m]) = expansion { + for (key, n) in codec::count::expanded_counts(instances, &r.name, l, s, m) { + let label = key.label(); + list = list.push(list_row(palette, &r.name, key, label, n.to_string(), false, 1, None)); + } + } + } + let footer: Element<'a, Message> = match &palette.table { + None => row![ + Space::new().width(Fill), + button(text(crate::t!("Create Table")).size(12)) + .on_press(msg(CountMsg::CreateTable)) + .style(button_style(false)) + .padding([5, 12]), + ] + .into(), + Some(checked) => { + let all = rows(instances, palette); + let every = !all.is_empty() && all.iter().all(|r| checked.contains(&r.name.to_ascii_uppercase())); + let mut insert = button(text(crate::t!("Insert")).size(12)).style(button_style(true)).padding([5, 12]); + if !checked.is_empty() { + insert = insert.on_press(msg(CountMsg::TableInsert)); + } + row![ + checkbox(every).size(14).on_toggle(|v| msg(CountMsg::TableAll(v))), + Space::new().width(Fill), + button(text(crate::t!("Cancel")).size(12)) + .on_press(msg(CountMsg::TableCancel)) + .style(button_style(false)) + .padding([5, 12]), + insert, + ] + .spacing(6) + .align_y(iced::Center) + .into() + } + }; + column![ + top, + container(column![header, scrollable(list).height(Fill)].spacing(2)) + .padding(4) + .height(Fill) + .style(card), + footer, + ] + .spacing(6) + .into() +} + +fn card(theme: &Theme) -> container::Style { + container::Style { + background: Some(Background::Color(theme.palette().background.weak.color)), + border: Border { radius: 4.0.into(), ..Default::default() }, + ..Default::default() + } +} + +/// Count mode in the palette: back to the list, the counted block with the +/// match options, and the error report. +fn mode_view<'a>(palette: &'a CountPalette, mode: &'a CountMode, doc: &CadDocument) -> Element<'a, Message> { + let back = button(row![crate::ui::icons::themed_arrow_left(10.0), text(crate::t!("Back to List")).size(12)].spacing(4).align_y(iced::Center)) + .on_press(msg(CountMsg::Back)) + .style(button::text) + .padding([2, 2]); + let result = mode.result(doc); + let mut body = column![back].spacing(8); + if let Some(name) = mode.target_name() { + let mut details = column![button( + row![ + crate::ui::icons::themed_success(CHECK_ICON, 12.0), + text(match mode.target { + Some(CountTarget::Group(_)) => crate::tf!("Geometries: {}", mode.count(doc)).into_owned(), + _ => format!("{name}: {}", mode.count(doc)), + }) + .size(12) + .width(Fill), + ] + .spacing(6) + .align_y(iced::Center), + ) + .on_press(msg(CountMsg::ToggleDetails)) + .style(button::text) + .padding(0)] + .spacing(6); + if let (false, Some(CountTarget::Block { matching, .. })) = (palette.details_closed, &mode.target) { + for (k, label) in ["Match Layer", "Match Scale", "Match Mirror State"].iter().enumerate() { + details = details.push( + checkbox(matching[k]) + .label(crate::t!(*label).into_owned()) + .text_size(12) + .size(14) + .on_toggle(move |v| msg(CountMsg::Match(k, v))), + ); + } + } + body = body.push(container(details).padding(8).width(Fill).style(card)); + } + if !result.errors.is_empty() { + let mut report = column![button( + row![ + crate::ui::icons::themed_warning(WARNING_ICON, 12.0), + text(crate::tf!("Count Error Report: {}", result.errors.len())).size(12).width(Fill), + ] + .spacing(6) + .align_y(iced::Center), + ) + .on_press(msg(CountMsg::ToggleErrors)) + .style(button::text) + .padding(0)] + .spacing(4); + if !palette.errors_closed { + for h in &result.errors { + report = report.push( + button(text(crate::t!("Overlapping object")).size(12)) + .on_press(msg(CountMsg::ShowError(*h))) + .style(button::text) + .padding([2, 18]), + ); + } + } + body = body.push(container(report).padding(8).width(Fill).style(card)); + } + body.into() +} + +pub fn view<'a>( + palette: &'a CountPalette, + mode: Option<&'a CountMode>, + doc: &'a CadDocument, + epoch: u64, + width: f32, + auto_collapse: bool, +) -> Element<'a, Message> { + let title_bar = crate::ui::dock::title_bar(PanelId::Count, crate::t!("Count palette").into_owned(), auto_collapse); + let body = match mode { + Some(mode) if mode.target.is_some() => mode_view(palette, mode, doc), + _ => { + let area = mode.and_then(|m| m.area.clone()); + let key = (doc as *const CadDocument as usize, epoch); + let mut cache = palette.list_cache.borrow_mut(); + let instances = match cache.as_ref() { + Some((d, e, a, list)) if (*d, *e) == key && *a == area => list.clone(), + _ => { + let list = std::rc::Rc::new(codec::count::block_instances(doc, area.as_deref())); + *cache = Some((key.0, key.1, area, list.clone())); + list + } + }; + list_view(palette, &instances) + } + }; + crate::ui::dock::frame(column![title_bar, body].spacing(6), width) +} + +fn bar_button<'a>(icon: Element<'a, Message>, tip: String, m: Option) -> Element<'a, Message> { + let mut b = button(icon).style(button::text).padding([3, 5]); + if let Some(m) = m { + b = b.on_press(msg(m)); + } + tooltip(b, text(tip).size(10), tooltip::Position::Bottom).gap(4).into() +} + +/// The floating toolbar over the drawing in count mode: the count, the +/// status, previous / next reference, area, select, field and close. +pub fn toolbar<'a>(mode: &CountMode, doc: &CadDocument) -> Element<'a, Message> { + let has_target = mode.target.is_some(); + let result = mode.result(doc); + let count = if has_target { mode.count(doc).to_string() } else { "-".to_string() }; + let status: Element<'a, Message> = if !result.errors.is_empty() { + crate::ui::icons::themed_warning(WARNING_ICON, 14.0) + } else if has_target { + container(text("i").size(11).style(|theme: &Theme| text::Style { color: Some(theme.palette().primary.base.color) })) + .padding([0, 4]) + .style(|theme: &Theme| container::Style { + border: Border { color: theme.palette().primary.base.color, width: 1.0, radius: 8.0.into() }, + ..Default::default() + }) + .into() + } else { + crate::ui::icons::themed_disabled(WARNING_ICON, 14.0) + }; + let on = |m: CountMsg| has_target.then_some(m); + let icon = |bytes: &'static [u8]| -> Element<'a, Message> { + if has_target { + crate::ui::icons::themed_success(bytes, 16.0) + } else { + crate::ui::icons::themed_disabled(bytes, 16.0) + } + }; + let bar = row![ + text(crate::tf!("Count: {}", count)).size(13), + status, + bar_button(crate::ui::icons::themed_arrow_left(12.0), crate::t!("Previous").into_owned(), on(CountMsg::Prev)), + bar_button(crate::ui::icons::themed_arrow_right(12.0), crate::t!("Next").into_owned(), on(CountMsg::Next)), + bar_button(crate::ui::icons::themed_success(AREA_ICON, 16.0), crate::t!("Count in a specified area").into_owned(), Some(CountMsg::Area)), + bar_button(crate::ui::icons::themed_success(SELECT_ICON, 16.0), crate::t!("Select objects").into_owned(), Some(CountMsg::Select)), + bar_button(icon(TABLE_ICON), crate::t!("Insert Count Field").into_owned(), on(CountMsg::Field)), + bar_button(crate::ui::icons::themed_secondary(crate::ui::icons::CLOSE, 12.0), crate::t!("Close").into_owned(), Some(CountMsg::Close)), + ] + .spacing(6) + .align_y(iced::Center); + let bar = container(bar).padding([4, 10]).style(|theme: &Theme| container::Style { + background: Some(Background::Color(theme.palette().background.weak.color)), + border: Border { color: theme.palette().background.neutral.color, width: 1.0, radius: 4.0.into() }, + ..Default::default() + }); + container(iced::widget::opaque(bar)).width(Fill).align_x(iced::Center).padding([10, 0]).into() +} + +/// The blue frame around the drawing area in count mode. +pub fn border<'a>() -> Element<'a, Message> { + container(Space::new().width(Fill).height(Fill)) + .width(Fill) + .height(Fill) + .style(|_: &Theme| container::Style { + border: Border { color: Color::from_rgb(0.18, 0.52, 0.95), width: 2.0, radius: 0.0.into() }, + ..Default::default() + }) + .into() +} + +/// A colour index as the display colour (`COUNTCOLOR` / `COUNTERRORCOLOR`). +pub fn aci_rgba(index: i16) -> [f32; 4] { + crate::scene::convert::tess_util::aci_to_rgba(&codec::types::Color::from_index(index)) +} + + +/// The Invalid Area dialog, shown when the count area's boundary is gone. +pub fn invalid_area_view<'a>(palette: &CountPalette, sizing: crate::ui::modal::ModalSizing) -> Element<'a, Message> { + let action = |title: String, detail: String, m: CountMsg| -> Element<'a, Message> { + button( + row![ + text("→").size(16).style(|theme: &Theme| text::Style { color: Some(theme.palette().primary.base.color) }), + column![text(title).size(13), text(detail).size(11).style(muted_style)].spacing(2), + ] + .spacing(10) + .align_y(iced::Center), + ) + .on_press(msg(m)) + .width(Fill) + .padding([10, 12]) + .style(button_style(false)) + .into() + }; + column![ + row![ + crate::ui::icons::themed_warning(WARNING_ICON, 20.0), + text(crate::t!("The changes to count boundary have resulted to an invalid count area. What do you want to do?")) + .size(14) + .width(Fill), + ] + .spacing(12) + .align_y(iced::Center), + action( + crate::t!("Undo the changes to the count boundary").into_owned(), + crate::t!("The last change is undone; the boundary and the count area come back.").into_owned(), + CountMsg::InvalidUndo, + ), + action( + crate::t!("Continue and count the entire model space").into_owned(), + crate::t!("The count area is dropped; counting goes on in all of model space.").into_owned(), + CountMsg::InvalidContinue, + ), + row![ + checkbox(palette.invalid_always) + .label(crate::t!("Always perform my current choice").into_owned()) + .text_size(12) + .size(14) + .on_toggle(|v| msg(CountMsg::InvalidAlways(v))), + Space::new().width(Fill), + crate::ui::style::form::dialog_button(crate::t!("Cancel"), msg(CountMsg::InvalidUndo), false), + ] + .spacing(8) + .align_y(iced::Center), + ] + .spacing(10) + .padding([12, 14]) + .width(sizing.width) + .into() +} diff --git a/src/ui/window/field_dialog.rs b/src/ui/window/field_dialog.rs index 6943ba071..17dce8304 100644 --- a/src/ui/window/field_dialog.rs +++ b/src/ui/window/field_dialog.rs @@ -32,7 +32,7 @@ pub const CATEGORIES: &[(&str, &[&str])] = &[ &["Author", "Comments", "Filename", "Filesize", "HyperlinkBase", "Keywords", "LastSavedBy", "Subject", "Title"], ), ("Linked", &["Hyperlink"]), - ("Objects", &["BlockPlaceholder", "Formula", "NamedObject", "Object"]), + ("Objects", &["BlockPlaceholder", "Count", "CountInArea", "Formula", "NamedObject", "Object"]), ("Other", &["DieselExpression", "SystemVariable"]), ( "Plot", @@ -77,6 +77,8 @@ pub enum FieldKind { Formula, Hyperlink, PlotScale, + /// Count / CountInArea: a JSON expression. + Count, BlockPlaceholder, } @@ -96,6 +98,7 @@ pub fn kind_of(name: &str) -> FieldKind { "Formula" => FieldKind::Formula, "Hyperlink" => FieldKind::Hyperlink, "PlotScale" => FieldKind::PlotScale, + "Count" | "CountInArea" => FieldKind::Count, "BlockPlaceholder" => FieldKind::BlockPlaceholder, _ => FieldKind::Text, } @@ -130,6 +133,8 @@ pub struct FieldDialogState { pub hyperlink_url: String, /// Index into codec PLOT_SCALE_FORMATS. pub plot_scale: usize, + /// The JSON expression of a Count / CountInArea field. + pub count_expression: String, /// The block being edited in the block editor; block placeholders are /// only offered there. pub placeholder_block: Option, @@ -165,6 +170,7 @@ impl FieldDialogState { hyperlink_text: String::new(), hyperlink_url: String::new(), plot_scale: 0, + count_expression: String::new(), placeholder_block: None, placeholder_property: 7, preview: String::new(), @@ -221,6 +227,13 @@ impl FieldDialogState { .map_or_else(|| "\\AcVar PlotScale".to_string(), |f| f.1.to_string()), vec![], ), + FieldKind::Count => { + let evaluator = if self.name == "CountInArea" { "AcCount2" } else { "AcCount" }; + match self.count_expression.trim() { + "" => (format!("\\{evaluator}"), vec![]), + json => (format!("\\{evaluator} {json}"), vec![]), + } + } FieldKind::BlockPlaceholder => { let (_, property, format) = codec::fields::BLOCK_PLACEHOLDER_PROPERTIES[self.placeholder_property]; // Text properties take the chosen case; the others keep their own format. @@ -330,6 +343,8 @@ pub enum FieldDialogMsg { HyperlinkUrl(String), BrowseHyperlink, PlotScale(usize), + CountExpression(String), + ShowCountInstances, Help, Ok, } @@ -632,6 +647,7 @@ fn format_panel<'a>(state: &'a FieldDialogState) -> Element<'a, Message> { ] .spacing(8) .into(), + FieldKind::Count => column![].into(), FieldKind::Object => column![ row![ text(t!("Object type")).size(12).width(Length::Fixed(96.0)), @@ -727,6 +743,33 @@ pub fn view<'a>( ] .spacing(10), FieldKind::Hyperlink => column![card(t!("Hyperlink").into_owned(), format_panel(state)), preview].spacing(10), + FieldKind::Count => column![ + card( + t!("Expression").into_owned(), + column![ + text_input("", &state.count_expression) + .size(12) + .padding([8, 8]) + .font(iced::Font::MONOSPACE) + .style(field_style) + .on_input(|v| msg(FieldDialogMsg::CountExpression(v))), + row![ + button(text(t!("Evaluate")).size(12)) + .on_press(msg(FieldDialogMsg::Evaluate)) + .style(button_style(false)) + .padding([5, 14]), + button(text(t!("Show Count Instances")).size(12)) + .on_press(msg(FieldDialogMsg::ShowCountInstances)) + .style(button_style(false)) + .padding([5, 14]), + ] + .spacing(8), + ] + .spacing(8), + ), + preview, + ] + .spacing(10), FieldKind::BlockPlaceholder => column![card(t!("Block placeholder").into_owned(), format_panel(state)), preview].spacing(10), _ => column![card(t!("Format").into_owned(), format_panel(state)), preview].spacing(10), }; diff --git a/src/ui/window/mod.rs b/src/ui/window/mod.rs index 3b3282d0f..3f3fafc09 100644 --- a/src/ui/window/mod.rs +++ b/src/ui/window/mod.rs @@ -27,6 +27,7 @@ pub mod alias_editor; pub mod find_replace; pub mod named_parameters; pub mod pc_manager; +pub mod count_palette; pub mod pdf_dialogs; pub mod xref_attach; pub mod xref_help;