From 6bd55bc32ec36925949c71915a27c0e03fa9ad45 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Wed, 23 Sep 2026 14:44:12 +0300 Subject: [PATCH] Xref attach: Attach dialog, reference prompts, and xref drawings other applications can open Attach External Reference dialog after XATTACH / ATTACH; ATTACH routes drawings, PDFs and images; placement prompts and messages follow the reference (presets, Corner/XYZ, nonzero check, existing definition reuse, missing file, self-reference); name keeps the file's case; overlay and path type at attach; one undo step for definition and INSERT; -XREF option prompt and `?` table; XREF opens the palette; ER alias; xref Properties rows; dependent symbols flagged and remapped and resolved content left out of the saved file; dialog_ok automation action; localized labels. Co-Authored-By: Claude Opus 5.5 --- assets/ocad.pgp | 1 + locales/ar-SA/opencadstudio.ftl | 22 + locales/bg-BG/opencadstudio.ftl | 22 + locales/cs-CZ/opencadstudio.ftl | 22 + locales/de-DE/opencadstudio.ftl | 22 + locales/el-GR/opencadstudio.ftl | 22 + locales/en-US/opencadstudio.ftl | 22 + locales/es-ES/opencadstudio.ftl | 22 + locales/fi-FI/opencadstudio.ftl | 22 + locales/fr-FR/opencadstudio.ftl | 22 + locales/hi-IN/opencadstudio.ftl | 22 + locales/hu-HU/opencadstudio.ftl | 22 + locales/it-IT/opencadstudio.ftl | 22 + locales/ja-JP/opencadstudio.ftl | 22 + locales/ko-KR/opencadstudio.ftl | 22 + locales/nl-NL/opencadstudio.ftl | 22 + locales/pl-PL/opencadstudio.ftl | 22 + locales/pt-BR/opencadstudio.ftl | 22 + locales/ru-RU/opencadstudio.ftl | 22 + locales/tr-TR/opencadstudio.ftl | 28 +- locales/zh-CN/opencadstudio.ftl | 22 + locales/zh-TW/opencadstudio.ftl | 22 + src/app/alias.rs | 5 +- src/app/command_driver.rs | 67 +-- src/app/commands/blocks.rs | 17 +- src/app/commands/mod.rs | 5 + src/app/commands/xref_attach.rs | 421 +++++++++++++++ src/app/control/actions.rs | 9 + src/app/mod.rs | 12 + src/app/properties.rs | 88 +++- src/app/update/command.rs | 40 ++ src/app/update/file.rs | 3 +- src/app/update/mod.rs | 13 +- src/app/view/modal.rs | 8 + src/command.rs | 6 +- src/io/xref.rs | 271 +++++++++- src/locale_catalog.rs | 22 + src/modules/insert/mod.rs | 1 + src/modules/insert/xattach.rs | 775 +++++++++++++++++++--------- src/modules/insert/xref_cmd.rs | 221 ++++++++ src/scene/parametric_constraints.rs | 1 + src/ui/window/block_definition.rs | 4 +- src/ui/window/mod.rs | 1 + src/ui/window/xref_attach.rs | 414 +++++++++++++++ 44 files changed, 2514 insertions(+), 359 deletions(-) create mode 100644 src/app/commands/xref_attach.rs create mode 100644 src/modules/insert/xref_cmd.rs create mode 100644 src/ui/window/xref_attach.rs diff --git a/assets/ocad.pgp b/assets/ocad.pgp index 84951d132..1e5790c6c 100644 --- a/assets/ocad.pgp +++ b/assets/ocad.pgp @@ -43,6 +43,7 @@ SS, *STRETCH S, *STRETCH O, *OFFSET TR, *TRIM +ER, *EXTERNALREFERENCES EX, *EXTEND F, *FILLET CHA, *CHAMFER diff --git a/locales/ar-SA/opencadstudio.ftl b/locales/ar-SA/opencadstudio.ftl index 11271f783..0e5c19162 100644 --- a/locales/ar-SA/opencadstudio.ftl +++ b/locales/ar-SA/opencadstudio.ftl @@ -3469,6 +3469,28 @@ annotate = .tolerance-specify-insertion-point = TOLERANCE حدد نقطة الإدراج: insert = + .xref-attach-dialog-title = Attach External Reference + .xref-attach-browse = Browse... + .xref-attach-reference-type = Reference Type + .xref-attach-specify-on-screen = Specify On-screen + .xref-attach-uniform-scale = Uniform Scale + .xref-attach-insertion-point = Insertion point + .xref-attach-angle = Angle: + .xref-attach-block-unit = Block Unit + .xref-attach-unit = Unit: + .xref-attach-factor = Factor: + .xref-attach-found-in = Found in: + .xref-attach-saved-path = Saved path: + .xref-attach-show-details = Show Details + .xref-attach-hide-details = Hide Details + .xref-attach-select-file = Select Reference File + .xref-attach-all-files = All Reference Files + .xref-attach-help = Inserts references to external files such as other drawings, raster images, and underlays. + .xref-attach-invalid-input = Invalid input. + .xref-insertion-point-x = Insertion point X + .xref-insertion-point-y = Insertion point Y + .xref-insertion-point-z = Insertion point Z + .xref-layer-property-overrides = Layer property overrides .point-clouds = سحب النقاط diff --git a/locales/bg-BG/opencadstudio.ftl b/locales/bg-BG/opencadstudio.ftl index d3f4ed151..f3044ffec 100644 --- a/locales/bg-BG/opencadstudio.ftl +++ b/locales/bg-BG/opencadstudio.ftl @@ -3456,6 +3456,28 @@ annotate = .tolerance-specify-insertion-point = TOLERANCE Посочи точка на вмъкване: insert = + .xref-attach-dialog-title = Attach External Reference + .xref-attach-browse = Browse... + .xref-attach-reference-type = Reference Type + .xref-attach-specify-on-screen = Specify On-screen + .xref-attach-uniform-scale = Uniform Scale + .xref-attach-insertion-point = Insertion point + .xref-attach-angle = Angle: + .xref-attach-block-unit = Block Unit + .xref-attach-unit = Unit: + .xref-attach-factor = Factor: + .xref-attach-found-in = Found in: + .xref-attach-saved-path = Saved path: + .xref-attach-show-details = Show Details + .xref-attach-hide-details = Hide Details + .xref-attach-select-file = Select Reference File + .xref-attach-all-files = All Reference Files + .xref-attach-help = Inserts references to external files such as other drawings, raster images, and underlays. + .xref-attach-invalid-input = Invalid input. + .xref-insertion-point-x = Insertion point X + .xref-insertion-point-y = Insertion point Y + .xref-insertion-point-z = Insertion point Z + .xref-layer-property-overrides = Layer property overrides .point-clouds = Облаци от точки diff --git a/locales/cs-CZ/opencadstudio.ftl b/locales/cs-CZ/opencadstudio.ftl index ad2751b9f..20b93a3b7 100644 --- a/locales/cs-CZ/opencadstudio.ftl +++ b/locales/cs-CZ/opencadstudio.ftl @@ -3454,6 +3454,28 @@ annotate = .tolerance-specify-insertion-point = TOLERANCE Zadejte bod vložení: insert = + .xref-attach-dialog-title = Attach External Reference + .xref-attach-browse = Browse... + .xref-attach-reference-type = Reference Type + .xref-attach-specify-on-screen = Specify On-screen + .xref-attach-uniform-scale = Uniform Scale + .xref-attach-insertion-point = Insertion point + .xref-attach-angle = Angle: + .xref-attach-block-unit = Block Unit + .xref-attach-unit = Unit: + .xref-attach-factor = Factor: + .xref-attach-found-in = Found in: + .xref-attach-saved-path = Saved path: + .xref-attach-show-details = Show Details + .xref-attach-hide-details = Hide Details + .xref-attach-select-file = Select Reference File + .xref-attach-all-files = All Reference Files + .xref-attach-help = Inserts references to external files such as other drawings, raster images, and underlays. + .xref-attach-invalid-input = Invalid input. + .xref-insertion-point-x = Insertion point X + .xref-insertion-point-y = Insertion point Y + .xref-insertion-point-z = Insertion point Z + .xref-layer-property-overrides = Layer property overrides .point-clouds = Bod Mraky diff --git a/locales/de-DE/opencadstudio.ftl b/locales/de-DE/opencadstudio.ftl index e7ec7a59e..336c59731 100644 --- a/locales/de-DE/opencadstudio.ftl +++ b/locales/de-DE/opencadstudio.ftl @@ -3457,6 +3457,28 @@ annotate = .tolerance-specify-insertion-point = TOLERANCE Einführstelle angeben: insert = + .xref-attach-dialog-title = Attach External Reference + .xref-attach-browse = Browse... + .xref-attach-reference-type = Reference Type + .xref-attach-specify-on-screen = Specify On-screen + .xref-attach-uniform-scale = Uniform Scale + .xref-attach-insertion-point = Insertion point + .xref-attach-angle = Angle: + .xref-attach-block-unit = Block Unit + .xref-attach-unit = Unit: + .xref-attach-factor = Factor: + .xref-attach-found-in = Found in: + .xref-attach-saved-path = Saved path: + .xref-attach-show-details = Show Details + .xref-attach-hide-details = Hide Details + .xref-attach-select-file = Select Reference File + .xref-attach-all-files = All Reference Files + .xref-attach-help = Inserts references to external files such as other drawings, raster images, and underlays. + .xref-attach-invalid-input = Invalid input. + .xref-insertion-point-x = Insertion point X + .xref-insertion-point-y = Insertion point Y + .xref-insertion-point-z = Insertion point Z + .xref-layer-property-overrides = Layer property overrides .point-clouds = Punkt- wolken diff --git a/locales/el-GR/opencadstudio.ftl b/locales/el-GR/opencadstudio.ftl index 612e50752..9fd3fb7b2 100644 --- a/locales/el-GR/opencadstudio.ftl +++ b/locales/el-GR/opencadstudio.ftl @@ -3457,6 +3457,28 @@ annotate = .tolerance-specify-insertion-point = TOLERANCE Ορίστε σημείο εισαγωγής: insert = + .xref-attach-dialog-title = Attach External Reference + .xref-attach-browse = Browse... + .xref-attach-reference-type = Reference Type + .xref-attach-specify-on-screen = Specify On-screen + .xref-attach-uniform-scale = Uniform Scale + .xref-attach-insertion-point = Insertion point + .xref-attach-angle = Angle: + .xref-attach-block-unit = Block Unit + .xref-attach-unit = Unit: + .xref-attach-factor = Factor: + .xref-attach-found-in = Found in: + .xref-attach-saved-path = Saved path: + .xref-attach-show-details = Show Details + .xref-attach-hide-details = Hide Details + .xref-attach-select-file = Select Reference File + .xref-attach-all-files = All Reference Files + .xref-attach-help = Inserts references to external files such as other drawings, raster images, and underlays. + .xref-attach-invalid-input = Invalid input. + .xref-insertion-point-x = Insertion point X + .xref-insertion-point-y = Insertion point Y + .xref-insertion-point-z = Insertion point Z + .xref-layer-property-overrides = Layer property overrides .revolve-creation-mode-solid-surface = REVOLVE Τρόπος δημιουργίας [Solid/Surface] .revolve-enter-angle-expression = REVOLVE Δώστε έκφραση γωνίας: .revolve-select-line-ray-or-construction-line-for-axis = REVOLVE Επιλέξτε γραμμή, ημιευθεία ή βοηθητική ευθεία ως άξονα: diff --git a/locales/en-US/opencadstudio.ftl b/locales/en-US/opencadstudio.ftl index e2beacbf0..9075b79ec 100644 --- a/locales/en-US/opencadstudio.ftl +++ b/locales/en-US/opencadstudio.ftl @@ -3458,6 +3458,28 @@ annotate = .tolerance-specify-insertion-point = TOLERANCE Specify insertion point: insert = + .xref-attach-dialog-title = Attach External Reference + .xref-attach-browse = Browse... + .xref-attach-reference-type = Reference Type + .xref-attach-specify-on-screen = Specify On-screen + .xref-attach-uniform-scale = Uniform Scale + .xref-attach-insertion-point = Insertion point + .xref-attach-angle = Angle: + .xref-attach-block-unit = Block Unit + .xref-attach-unit = Unit: + .xref-attach-factor = Factor: + .xref-attach-found-in = Found in: + .xref-attach-saved-path = Saved path: + .xref-attach-show-details = Show Details + .xref-attach-hide-details = Hide Details + .xref-attach-select-file = Select Reference File + .xref-attach-all-files = All Reference Files + .xref-attach-help = Inserts references to external files such as other drawings, raster images, and underlays. + .xref-attach-invalid-input = Invalid input. + .xref-insertion-point-x = Insertion point X + .xref-insertion-point-y = Insertion point Y + .xref-insertion-point-z = Insertion point Z + .xref-layer-property-overrides = Layer property overrides .point-clouds = Point Clouds diff --git a/locales/es-ES/opencadstudio.ftl b/locales/es-ES/opencadstudio.ftl index 0842935a8..6c2c7ff75 100644 --- a/locales/es-ES/opencadstudio.ftl +++ b/locales/es-ES/opencadstudio.ftl @@ -3457,6 +3457,28 @@ annotate = .tolerance-specify-insertion-point = TOLERANCE Especifique el punto de inserción: insert = + .xref-attach-dialog-title = Attach External Reference + .xref-attach-browse = Browse... + .xref-attach-reference-type = Reference Type + .xref-attach-specify-on-screen = Specify On-screen + .xref-attach-uniform-scale = Uniform Scale + .xref-attach-insertion-point = Insertion point + .xref-attach-angle = Angle: + .xref-attach-block-unit = Block Unit + .xref-attach-unit = Unit: + .xref-attach-factor = Factor: + .xref-attach-found-in = Found in: + .xref-attach-saved-path = Saved path: + .xref-attach-show-details = Show Details + .xref-attach-hide-details = Hide Details + .xref-attach-select-file = Select Reference File + .xref-attach-all-files = All Reference Files + .xref-attach-help = Inserts references to external files such as other drawings, raster images, and underlays. + .xref-attach-invalid-input = Invalid input. + .xref-insertion-point-x = Insertion point X + .xref-insertion-point-y = Insertion point Y + .xref-insertion-point-z = Insertion point Z + .xref-layer-property-overrides = Layer property overrides .point-clouds = Nubes de puntos diff --git a/locales/fi-FI/opencadstudio.ftl b/locales/fi-FI/opencadstudio.ftl index 6ccf9ff8e..cfd99daa5 100644 --- a/locales/fi-FI/opencadstudio.ftl +++ b/locales/fi-FI/opencadstudio.ftl @@ -3456,6 +3456,28 @@ annotate = .tolerance-specify-insertion-point = TOLERANCE Määritä lisäyskohta: insert = + .xref-attach-dialog-title = Attach External Reference + .xref-attach-browse = Browse... + .xref-attach-reference-type = Reference Type + .xref-attach-specify-on-screen = Specify On-screen + .xref-attach-uniform-scale = Uniform Scale + .xref-attach-insertion-point = Insertion point + .xref-attach-angle = Angle: + .xref-attach-block-unit = Block Unit + .xref-attach-unit = Unit: + .xref-attach-factor = Factor: + .xref-attach-found-in = Found in: + .xref-attach-saved-path = Saved path: + .xref-attach-show-details = Show Details + .xref-attach-hide-details = Hide Details + .xref-attach-select-file = Select Reference File + .xref-attach-all-files = All Reference Files + .xref-attach-help = Inserts references to external files such as other drawings, raster images, and underlays. + .xref-attach-invalid-input = Invalid input. + .xref-insertion-point-x = Insertion point X + .xref-insertion-point-y = Insertion point Y + .xref-insertion-point-z = Insertion point Z + .xref-layer-property-overrides = Layer property overrides .point-clouds = Point Pilviä diff --git a/locales/fr-FR/opencadstudio.ftl b/locales/fr-FR/opencadstudio.ftl index 73cc69a1c..92aff2825 100644 --- a/locales/fr-FR/opencadstudio.ftl +++ b/locales/fr-FR/opencadstudio.ftl @@ -3457,6 +3457,28 @@ annotate = .tolerance-specify-insertion-point = TOLERANCE Spécifier le point d'insertion: insert = + .xref-attach-dialog-title = Attach External Reference + .xref-attach-browse = Browse... + .xref-attach-reference-type = Reference Type + .xref-attach-specify-on-screen = Specify On-screen + .xref-attach-uniform-scale = Uniform Scale + .xref-attach-insertion-point = Insertion point + .xref-attach-angle = Angle: + .xref-attach-block-unit = Block Unit + .xref-attach-unit = Unit: + .xref-attach-factor = Factor: + .xref-attach-found-in = Found in: + .xref-attach-saved-path = Saved path: + .xref-attach-show-details = Show Details + .xref-attach-hide-details = Hide Details + .xref-attach-select-file = Select Reference File + .xref-attach-all-files = All Reference Files + .xref-attach-help = Inserts references to external files such as other drawings, raster images, and underlays. + .xref-attach-invalid-input = Invalid input. + .xref-insertion-point-x = Insertion point X + .xref-insertion-point-y = Insertion point Y + .xref-insertion-point-z = Insertion point Z + .xref-layer-property-overrides = Layer property overrides .point-clouds = Nuages de points diff --git a/locales/hi-IN/opencadstudio.ftl b/locales/hi-IN/opencadstudio.ftl index 27f433885..340122426 100644 --- a/locales/hi-IN/opencadstudio.ftl +++ b/locales/hi-IN/opencadstudio.ftl @@ -3445,6 +3445,28 @@ annotate = .tolerance-specify-insertion-point = TOLERANCE निर्दिष्ट सम्मिलन बिंदु: insert = + .xref-attach-dialog-title = Attach External Reference + .xref-attach-browse = Browse... + .xref-attach-reference-type = Reference Type + .xref-attach-specify-on-screen = Specify On-screen + .xref-attach-uniform-scale = Uniform Scale + .xref-attach-insertion-point = Insertion point + .xref-attach-angle = Angle: + .xref-attach-block-unit = Block Unit + .xref-attach-unit = Unit: + .xref-attach-factor = Factor: + .xref-attach-found-in = Found in: + .xref-attach-saved-path = Saved path: + .xref-attach-show-details = Show Details + .xref-attach-hide-details = Hide Details + .xref-attach-select-file = Select Reference File + .xref-attach-all-files = All Reference Files + .xref-attach-help = Inserts references to external files such as other drawings, raster images, and underlays. + .xref-attach-invalid-input = Invalid input. + .xref-insertion-point-x = Insertion point X + .xref-insertion-point-y = Insertion point Y + .xref-insertion-point-z = Insertion point Z + .xref-layer-property-overrides = Layer property overrides .point-clouds = बिंदु बादल diff --git a/locales/hu-HU/opencadstudio.ftl b/locales/hu-HU/opencadstudio.ftl index b9a9c2896..6ba3f46ac 100644 --- a/locales/hu-HU/opencadstudio.ftl +++ b/locales/hu-HU/opencadstudio.ftl @@ -3456,6 +3456,28 @@ annotate = .tolerance-specify-insertion-point = TOLERANCE Beszúrási pont megadása: insert = + .xref-attach-dialog-title = Attach External Reference + .xref-attach-browse = Browse... + .xref-attach-reference-type = Reference Type + .xref-attach-specify-on-screen = Specify On-screen + .xref-attach-uniform-scale = Uniform Scale + .xref-attach-insertion-point = Insertion point + .xref-attach-angle = Angle: + .xref-attach-block-unit = Block Unit + .xref-attach-unit = Unit: + .xref-attach-factor = Factor: + .xref-attach-found-in = Found in: + .xref-attach-saved-path = Saved path: + .xref-attach-show-details = Show Details + .xref-attach-hide-details = Hide Details + .xref-attach-select-file = Select Reference File + .xref-attach-all-files = All Reference Files + .xref-attach-help = Inserts references to external files such as other drawings, raster images, and underlays. + .xref-attach-invalid-input = Invalid input. + .xref-insertion-point-x = Insertion point X + .xref-insertion-point-y = Insertion point Y + .xref-insertion-point-z = Insertion point Z + .xref-layer-property-overrides = Layer property overrides .point-clouds = Pont Felhők diff --git a/locales/it-IT/opencadstudio.ftl b/locales/it-IT/opencadstudio.ftl index 394d9423e..11c637f9a 100644 --- a/locales/it-IT/opencadstudio.ftl +++ b/locales/it-IT/opencadstudio.ftl @@ -3456,6 +3456,28 @@ annotate = .tolerance-specify-insertion-point = TOLERANCE Specificare il punto di inserimento: insert = + .xref-attach-dialog-title = Attach External Reference + .xref-attach-browse = Browse... + .xref-attach-reference-type = Reference Type + .xref-attach-specify-on-screen = Specify On-screen + .xref-attach-uniform-scale = Uniform Scale + .xref-attach-insertion-point = Insertion point + .xref-attach-angle = Angle: + .xref-attach-block-unit = Block Unit + .xref-attach-unit = Unit: + .xref-attach-factor = Factor: + .xref-attach-found-in = Found in: + .xref-attach-saved-path = Saved path: + .xref-attach-show-details = Show Details + .xref-attach-hide-details = Hide Details + .xref-attach-select-file = Select Reference File + .xref-attach-all-files = All Reference Files + .xref-attach-help = Inserts references to external files such as other drawings, raster images, and underlays. + .xref-attach-invalid-input = Invalid input. + .xref-insertion-point-x = Insertion point X + .xref-insertion-point-y = Insertion point Y + .xref-insertion-point-z = Insertion point Z + .xref-layer-property-overrides = Layer property overrides .point-clouds = Punto Nuvole diff --git a/locales/ja-JP/opencadstudio.ftl b/locales/ja-JP/opencadstudio.ftl index 3f548f133..e88e947f0 100644 --- a/locales/ja-JP/opencadstudio.ftl +++ b/locales/ja-JP/opencadstudio.ftl @@ -3447,6 +3447,28 @@ annotate = .tolerance-specify-insertion-point = TOLERANCE 挿入点を指定: insert = + .xref-attach-dialog-title = Attach External Reference + .xref-attach-browse = Browse... + .xref-attach-reference-type = Reference Type + .xref-attach-specify-on-screen = Specify On-screen + .xref-attach-uniform-scale = Uniform Scale + .xref-attach-insertion-point = Insertion point + .xref-attach-angle = Angle: + .xref-attach-block-unit = Block Unit + .xref-attach-unit = Unit: + .xref-attach-factor = Factor: + .xref-attach-found-in = Found in: + .xref-attach-saved-path = Saved path: + .xref-attach-show-details = Show Details + .xref-attach-hide-details = Hide Details + .xref-attach-select-file = Select Reference File + .xref-attach-all-files = All Reference Files + .xref-attach-help = Inserts references to external files such as other drawings, raster images, and underlays. + .xref-attach-invalid-input = Invalid input. + .xref-insertion-point-x = Insertion point X + .xref-insertion-point-y = Insertion point Y + .xref-insertion-point-z = Insertion point Z + .xref-layer-property-overrides = Layer property overrides .point-clouds = 点群 .attsync-block-name-to-sync = ATTSYNC 同期するブロック名: .attsync-no-block-named-arg = ATTSYNC: "__ocs_fmt_0__" というブロックはありません。 diff --git a/locales/ko-KR/opencadstudio.ftl b/locales/ko-KR/opencadstudio.ftl index 8417cdab4..e7017e00e 100644 --- a/locales/ko-KR/opencadstudio.ftl +++ b/locales/ko-KR/opencadstudio.ftl @@ -3456,6 +3456,28 @@ annotate = .tolerance-specify-insertion-point = TOLERANCE 삽입점 지정: insert = + .xref-attach-dialog-title = Attach External Reference + .xref-attach-browse = Browse... + .xref-attach-reference-type = Reference Type + .xref-attach-specify-on-screen = Specify On-screen + .xref-attach-uniform-scale = Uniform Scale + .xref-attach-insertion-point = Insertion point + .xref-attach-angle = Angle: + .xref-attach-block-unit = Block Unit + .xref-attach-unit = Unit: + .xref-attach-factor = Factor: + .xref-attach-found-in = Found in: + .xref-attach-saved-path = Saved path: + .xref-attach-show-details = Show Details + .xref-attach-hide-details = Hide Details + .xref-attach-select-file = Select Reference File + .xref-attach-all-files = All Reference Files + .xref-attach-help = Inserts references to external files such as other drawings, raster images, and underlays. + .xref-attach-invalid-input = Invalid input. + .xref-insertion-point-x = Insertion point X + .xref-insertion-point-y = Insertion point Y + .xref-insertion-point-z = Insertion point Z + .xref-layer-property-overrides = Layer property overrides .point-clouds = 포인트 구름 diff --git a/locales/nl-NL/opencadstudio.ftl b/locales/nl-NL/opencadstudio.ftl index c486bd035..24b595f5e 100644 --- a/locales/nl-NL/opencadstudio.ftl +++ b/locales/nl-NL/opencadstudio.ftl @@ -3455,6 +3455,28 @@ annotate = .dimradius-select-arc-circle-or-polyline-arc = DIMRADIUS Selecteer een boog, cirkel of polylijnboog: insert = + .xref-attach-dialog-title = Attach External Reference + .xref-attach-browse = Browse... + .xref-attach-reference-type = Reference Type + .xref-attach-specify-on-screen = Specify On-screen + .xref-attach-uniform-scale = Uniform Scale + .xref-attach-insertion-point = Insertion point + .xref-attach-angle = Angle: + .xref-attach-block-unit = Block Unit + .xref-attach-unit = Unit: + .xref-attach-factor = Factor: + .xref-attach-found-in = Found in: + .xref-attach-saved-path = Saved path: + .xref-attach-show-details = Show Details + .xref-attach-hide-details = Hide Details + .xref-attach-select-file = Select Reference File + .xref-attach-all-files = All Reference Files + .xref-attach-help = Inserts references to external files such as other drawings, raster images, and underlays. + .xref-attach-invalid-input = Invalid input. + .xref-insertion-point-x = Insertion point X + .xref-insertion-point-y = Insertion point Y + .xref-insertion-point-z = Insertion point Z + .xref-layer-property-overrides = Layer property overrides .point-clouds = Punten- wolken diff --git a/locales/pl-PL/opencadstudio.ftl b/locales/pl-PL/opencadstudio.ftl index 503339cb1..ee146bd1a 100644 --- a/locales/pl-PL/opencadstudio.ftl +++ b/locales/pl-PL/opencadstudio.ftl @@ -3456,6 +3456,28 @@ annotate = .tolerance-specify-insertion-point = TOLERANCE Określ punkt wstawienia: insert = + .xref-attach-dialog-title = Attach External Reference + .xref-attach-browse = Browse... + .xref-attach-reference-type = Reference Type + .xref-attach-specify-on-screen = Specify On-screen + .xref-attach-uniform-scale = Uniform Scale + .xref-attach-insertion-point = Insertion point + .xref-attach-angle = Angle: + .xref-attach-block-unit = Block Unit + .xref-attach-unit = Unit: + .xref-attach-factor = Factor: + .xref-attach-found-in = Found in: + .xref-attach-saved-path = Saved path: + .xref-attach-show-details = Show Details + .xref-attach-hide-details = Hide Details + .xref-attach-select-file = Select Reference File + .xref-attach-all-files = All Reference Files + .xref-attach-help = Inserts references to external files such as other drawings, raster images, and underlays. + .xref-attach-invalid-input = Invalid input. + .xref-insertion-point-x = Insertion point X + .xref-insertion-point-y = Insertion point Y + .xref-insertion-point-z = Insertion point Z + .xref-layer-property-overrides = Layer property overrides .point-clouds = Punkt Chmury diff --git a/locales/pt-BR/opencadstudio.ftl b/locales/pt-BR/opencadstudio.ftl index 7b21b39cb..e9e8bcaca 100644 --- a/locales/pt-BR/opencadstudio.ftl +++ b/locales/pt-BR/opencadstudio.ftl @@ -3457,6 +3457,28 @@ annotate = .tolerance-specify-insertion-point = TOLERANCE Especifique o ponto de inserção: insert = + .xref-attach-dialog-title = Attach External Reference + .xref-attach-browse = Browse... + .xref-attach-reference-type = Reference Type + .xref-attach-specify-on-screen = Specify On-screen + .xref-attach-uniform-scale = Uniform Scale + .xref-attach-insertion-point = Insertion point + .xref-attach-angle = Angle: + .xref-attach-block-unit = Block Unit + .xref-attach-unit = Unit: + .xref-attach-factor = Factor: + .xref-attach-found-in = Found in: + .xref-attach-saved-path = Saved path: + .xref-attach-show-details = Show Details + .xref-attach-hide-details = Hide Details + .xref-attach-select-file = Select Reference File + .xref-attach-all-files = All Reference Files + .xref-attach-help = Inserts references to external files such as other drawings, raster images, and underlays. + .xref-attach-invalid-input = Invalid input. + .xref-insertion-point-x = Insertion point X + .xref-insertion-point-y = Insertion point Y + .xref-insertion-point-z = Insertion point Z + .xref-layer-property-overrides = Layer property overrides .point-clouds = Nuvens de pontos diff --git a/locales/ru-RU/opencadstudio.ftl b/locales/ru-RU/opencadstudio.ftl index 9414c0b99..2404b7722 100644 --- a/locales/ru-RU/opencadstudio.ftl +++ b/locales/ru-RU/opencadstudio.ftl @@ -3458,6 +3458,28 @@ annotate = .tolerance-specify-insertion-point = TOLERANCE Укажите точку вставки: insert = + .xref-attach-dialog-title = Attach External Reference + .xref-attach-browse = Browse... + .xref-attach-reference-type = Reference Type + .xref-attach-specify-on-screen = Specify On-screen + .xref-attach-uniform-scale = Uniform Scale + .xref-attach-insertion-point = Insertion point + .xref-attach-angle = Angle: + .xref-attach-block-unit = Block Unit + .xref-attach-unit = Unit: + .xref-attach-factor = Factor: + .xref-attach-found-in = Found in: + .xref-attach-saved-path = Saved path: + .xref-attach-show-details = Show Details + .xref-attach-hide-details = Hide Details + .xref-attach-select-file = Select Reference File + .xref-attach-all-files = All Reference Files + .xref-attach-help = Inserts references to external files such as other drawings, raster images, and underlays. + .xref-attach-invalid-input = Invalid input. + .xref-insertion-point-x = Insertion point X + .xref-insertion-point-y = Insertion point Y + .xref-insertion-point-z = Insertion point Z + .xref-layer-property-overrides = Layer property overrides .point-clouds = Облака точек diff --git a/locales/tr-TR/opencadstudio.ftl b/locales/tr-TR/opencadstudio.ftl index 698cb575a..0d9250213 100644 --- a/locales/tr-TR/opencadstudio.ftl +++ b/locales/tr-TR/opencadstudio.ftl @@ -2004,7 +2004,7 @@ common = .fit-data-does-not-define-a-valid-control-curve = Fit data does not define a valid control curve. .fit-points-do-not-define-a-valid-spline = Fit points do not define a valid spline. .formula = Formula - .full-path = Full path + .full-path = Tam yol .fully-constrained-no-remaining-degrees-of-freedom = Fully constrained — no remaining degrees of freedom .grid-on-f7 = Grid On (F7) .grid-x-spacing = Grid X spacing: @@ -2059,7 +2059,7 @@ common = .named-parameters = Named Parameters .named-range = Named range .nearest-to-face = Nearest to face - .no-path = No path + .no-path = Yol yok .no-preview-available = No preview available. .no-surfaces-selected = No surfaces selected. .objectscale-the-selected-objects-already-have-the-current-scale-or-do-not-support-annotation-scales = OBJECTSCALE: the selected objects already have the current scale, or do not support annotation scales. @@ -2099,7 +2099,7 @@ common = .recent-colors = Recent Colors .reference-changes-are-not-available-on-web-the-reference-list-is-read-only = Reference changes are not available on web — the reference list is read-only. .refine-data = Refine data - .relative-path = Relative path + .relative-path = Göreli yol .remaining-degrees-of-freedom-in-the-current-parametric-constraint-scope = Remaining degrees of freedom in the current parametric constraint scope .requires-an-integer-between-0-and-99 = Requires an integer between 0 and 99. .requires-numeric-distance-or-two-points = Requires numeric distance or two points. @@ -3431,6 +3431,28 @@ annotate = .tolerance-specify-insertion-point = TOLERANCE Ekleme noktasını belirtin: insert = + .xref-attach-dialog-title = Harici Referans Ekle + .xref-attach-browse = Gözat... + .xref-attach-reference-type = Referans Türü + .xref-attach-specify-on-screen = Ekranda belirt + .xref-attach-uniform-scale = Eşit ölçek + .xref-attach-insertion-point = Ekleme noktası + .xref-attach-angle = Açı: + .xref-attach-block-unit = Blok birimi + .xref-attach-unit = Birim: + .xref-attach-factor = Çarpan: + .xref-attach-found-in = Bulunduğu yer: + .xref-attach-saved-path = Kayıtlı yol: + .xref-attach-show-details = Ayrıntıları Göster + .xref-attach-hide-details = Ayrıntıları Gizle + .xref-attach-select-file = Referans Dosyası Seç + .xref-attach-all-files = Tüm Referans Dosyaları + .xref-attach-help = Diğer çizimler, raster görüntüler ve altlıklar gibi harici dosyalara referans ekler. + .xref-attach-invalid-input = Geçersiz giriş. + .xref-insertion-point-x = Ekleme noktası X + .xref-insertion-point-y = Ekleme noktası Y + .xref-insertion-point-z = Ekleme noktası Z + .xref-layer-property-overrides = Katman özelliği geçersiz kılmaları .point-clouds = Nokta Bulutları diff --git a/locales/zh-CN/opencadstudio.ftl b/locales/zh-CN/opencadstudio.ftl index 730334f4b..bbdaf3212 100644 --- a/locales/zh-CN/opencadstudio.ftl +++ b/locales/zh-CN/opencadstudio.ftl @@ -3444,6 +3444,28 @@ annotate = .tolerance-specify-insertion-point = TOLERANCE 指定插入点: insert = + .xref-attach-dialog-title = Attach External Reference + .xref-attach-browse = Browse... + .xref-attach-reference-type = Reference Type + .xref-attach-specify-on-screen = Specify On-screen + .xref-attach-uniform-scale = Uniform Scale + .xref-attach-insertion-point = Insertion point + .xref-attach-angle = Angle: + .xref-attach-block-unit = Block Unit + .xref-attach-unit = Unit: + .xref-attach-factor = Factor: + .xref-attach-found-in = Found in: + .xref-attach-saved-path = Saved path: + .xref-attach-show-details = Show Details + .xref-attach-hide-details = Hide Details + .xref-attach-select-file = Select Reference File + .xref-attach-all-files = All Reference Files + .xref-attach-help = Inserts references to external files such as other drawings, raster images, and underlays. + .xref-attach-invalid-input = Invalid input. + .xref-insertion-point-x = Insertion point X + .xref-insertion-point-y = Insertion point Y + .xref-insertion-point-z = Insertion point Z + .xref-layer-property-overrides = Layer property overrides .point-clouds = 点云 .attsync-block-name-to-sync = ATTSYNC 要同步的块名 : .attsync-no-block-named-arg = ATTSYNC:没有块名“__ocs_fmt_0__”。 diff --git a/locales/zh-TW/opencadstudio.ftl b/locales/zh-TW/opencadstudio.ftl index 3d9e872d6..14a4ed1cc 100644 --- a/locales/zh-TW/opencadstudio.ftl +++ b/locales/zh-TW/opencadstudio.ftl @@ -3456,6 +3456,28 @@ annotate = .tolerance-specify-insertion-point = TOLERANCE 指定插入點: insert = + .xref-attach-dialog-title = Attach External Reference + .xref-attach-browse = Browse... + .xref-attach-reference-type = Reference Type + .xref-attach-specify-on-screen = Specify On-screen + .xref-attach-uniform-scale = Uniform Scale + .xref-attach-insertion-point = Insertion point + .xref-attach-angle = Angle: + .xref-attach-block-unit = Block Unit + .xref-attach-unit = Unit: + .xref-attach-factor = Factor: + .xref-attach-found-in = Found in: + .xref-attach-saved-path = Saved path: + .xref-attach-show-details = Show Details + .xref-attach-hide-details = Hide Details + .xref-attach-select-file = Select Reference File + .xref-attach-all-files = All Reference Files + .xref-attach-help = Inserts references to external files such as other drawings, raster images, and underlays. + .xref-attach-invalid-input = Invalid input. + .xref-insertion-point-x = Insertion point X + .xref-insertion-point-y = Insertion point Y + .xref-insertion-point-z = Insertion point Z + .xref-layer-property-overrides = Layer property overrides .point-clouds = 點雲 .attsync-block-name-to-sync = ATTSYNC 要同步的圖塊名 : .attsync-no-block-named-arg = ATTSYNC:沒有圖塊名“__ocs_fmt_0__”。 diff --git a/src/app/alias.rs b/src/app/alias.rs index dc0a3a169..350c95c67 100644 --- a/src/app/alias.rs +++ b/src/app/alias.rs @@ -74,8 +74,8 @@ const WEB_ALIAS_KEY: &str = "opencadstudio.aliases"; /// defaults add aliases or change a default target, so existing profiles can be /// migrated forward (see `introduced_at` / `migrate_aliases`). Version 1 shipped /// the pre-REDRAW table; version 2 introduced the REDRAW-family aliases; version 3 -/// introduced HB (HATCHTOBACK). -const DEFAULT_ALIASES_VERSION: u32 = 3; +/// introduced HB (HATCHTOBACK); version 4 introduced ER (EXTERNALREFERENCES). +const DEFAULT_ALIASES_VERSION: u32 = 4; #[cfg(target_arch = "wasm32")] const WEB_ALIAS_VERSION_KEY: &str = "opencadstudio.aliases.version"; @@ -93,6 +93,7 @@ fn introduced_at(version: u32) -> &'static [(&'static str, &'static str)] { ("REA", "REGENALL"), ], 3 => &[("HB", "HATCHTOBACK")], + 4 => &[("ER", "EXTERNALREFERENCES")], _ => &[], } } diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index 91491dfde..f9f057a90 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -3127,60 +3127,17 @@ impl OpenCADStudio { self.sync_dyn_fields(); self.refresh_area_preview(i); } - CmdResult::CommitAndExit(mut entity) => { - // For XATTACH: ensure the xref block definition exists before - // committing the INSERT entity that references it. - // Extract path early to avoid borrow conflicts. - let xattach_path: Option = { - let tab = &self.tabs[i]; - if let Some(cmd) = tab.active_cmd.as_ref() { - if cmd.name() == "XATTACH" { - cmd.xattach_path() - } else { - None - } - } else { - None - } - }; - if let Some(path) = xattach_path { - // XREF-Task6: the host drawing path lives in the tab - // (`current_path`), not in `Scene`, so the self-attach - // guard runs here via the pure `is_self_attach` helper. - let host_file = self.tabs[i].current_path.clone(); - let host_base = host_file - .as_deref() - .and_then(|p| p.parent()) - .map(|p| p.to_path_buf()); - if let Some(host) = host_file.as_deref() { - if crate::modules::insert::xattach::is_self_attach( - host, - &path, - host_base.as_deref(), - ) { - self.command_line.push_error(crate::t!("XATTACH: cannot attach the host drawing into itself.").as_ref()); - self.tabs[i].scene.clear_preview_wire(); - self.tabs[i].active_cmd = None; - self.tabs[i].snap_result = None; - self.restore_pre_cmd_tangent(); - return Task::none(); - } - } - let prepared_name = crate::modules::insert::xattach::prepare_xref_block( - &mut self.tabs[i].scene, - &path, - host_base.as_deref(), - ); - // `prepare_xref_block` may suffix a colliding stem. The - // command was created before that collision was known, so - // retarget its pending INSERT to the actual new block. - if let acadrust::EntityType::Insert(insert) = &mut entity { - insert.block_name = prepared_name; - } - // Resolving the xref merged its layer / linetype tables - // into the document — mirror them into the Layers panel - // and ribbon dropdowns now, not on the next reopen (#407). - self.refresh_layer_panel(); + CmdResult::CommitAndExit(entity) => { + // XATTACH: the definition and its INSERT are created together + // in one undo step. + let xattach_request = self.tabs[i] + .active_cmd + .as_ref() + .filter(|cmd| cmd.name() == "XATTACH") + .and_then(|cmd| cmd.xattach_request()); + if let Some(request) = xattach_request { + self.commit_xref_attach(i, request, entity); + return Task::none(); } let insert_block_name = match &entity { acadrust::EntityType::Insert(ins) => Some(ins.block_name.clone()), @@ -9274,7 +9231,7 @@ impl OpenCADStudio { out } - fn restore_pre_cmd_tangent(&mut self) { + pub(super) fn restore_pre_cmd_tangent(&mut self) { if let Some(was_on) = self.pre_cmd_tangent.take() { if !was_on { self.snapper.enabled.remove(&crate::snap::SnapType::Tangent); diff --git a/src/app/commands/blocks.rs b/src/app/commands/blocks.rs index 662360b29..6e4deae53 100644 --- a/src/app/commands/blocks.rs +++ b/src/app/commands/blocks.rs @@ -663,10 +663,6 @@ impl OpenCADStudio { "PDFATTACH" => { return Some(Task::done(Message::PdfAttachPick)); } - "XATTACH" => { - // Launch the file picker; XAttachPickResult will start the command. - return Some(Task::done(Message::XAttachPick)); - } cmd if cmd == "WBLOCK" || cmd == "WB" || cmd.starts_with("WBLOCK ") => { let arg = cmd.splitn(2, ' ').nth(1).unwrap_or("").trim(); if arg.is_empty() { @@ -1366,11 +1362,16 @@ impl OpenCADStudio { if after.is_empty() { return Some(Task::done(Message::XAttachPick)); } - let cmd = crate::modules::insert::xattach::XAttachCommand::with_path( - after.to_string(), + self.start_xref_attach( + i, + crate::modules::insert::xattach::XrefAttachRequest { + path: after.trim_matches('"').to_string(), + overlay: false, + path_type: crate::io::xref_model::Pathtype::Full, + }, + crate::modules::insert::xattach::XrefPlacement::on_screen(), + "-XREF", ); - self.command_line.push_info(&cmd.prompt()); - self.tabs[i].active_cmd = Some(Box::new(cmd)); } else { self.command_line.push_error(crate::tf!( "XREF: unknown option '{}'. Options: ? Reload Unload Detach Path Pathtype Bind Overlay Attach", diff --git a/src/app/commands/mod.rs b/src/app/commands/mod.rs index 26329416d..6fb283b99 100644 --- a/src/app/commands/mod.rs +++ b/src/app/commands/mod.rs @@ -5,6 +5,7 @@ use iced::Task; use std::path::PathBuf; mod blocks; +mod xref_attach; mod dim; pub(crate) mod display; mod draw; @@ -246,6 +247,9 @@ impl OpenCADStudio { if let Some(t) = self.dispatch_layers(cmd, i) { return Some(t); } + if let Some(t) = self.dispatch_xref_attach(cmd, i) { + return Some(t); + } if let Some(t) = self.dispatch_blocks(cmd, i) { return Some(t); } @@ -652,6 +656,7 @@ inventory::submit!(crate::command::CommandRegistration { "3DORBIT", "3O", "ABOUT", + "ATTACH", "ATTDISP", "ATTEXT", "BACKGROUND", diff --git a/src/app/commands/xref_attach.rs b/src/app/commands/xref_attach.rs new file mode 100644 index 000000000..a28dc2fcc --- /dev/null +++ b/src/app/commands/xref_attach.rs @@ -0,0 +1,421 @@ +//! Attaching drawing references: ATTACH / XATTACH (file picker, then the +//! Attach External Reference dialog), the -XREF command line, the `?` list, +//! and the commit that creates the definition and its INSERT as one undo step. + +use super::*; +use crate::io::xref_model::Pathtype; +use crate::modules::insert::xattach::{ + existing_reference, path_to_block_name, prepare_xref_definition, stored_path, + XAttachCommand, XrefAttachRequest, XrefPlacement, +}; +use crate::modules::insert::xref_cmd::display_path; +use crate::ui::window::xref_attach::{XrefAttachMsg, XrefAttachState}; + +impl OpenCADStudio { + /// Commands owned here; `None` defers to the other families. + pub(super) fn dispatch_xref_attach(&mut self, cmd: &str, i: usize) -> Option> { + let upper = cmd.to_ascii_uppercase(); + let (verb, rest) = match cmd.split_once(char::is_whitespace) { + Some((verb, rest)) => (verb.to_ascii_uppercase(), rest.trim()), + None => (upper.clone(), ""), + }; + match (verb.as_str(), rest.is_empty()) { + ("ATTACH", true) => Some(Task::done(Message::AttachPick)), + ("XATTACH", true) => Some(Task::done(Message::XAttachPick)), + // A file named on the command line skips the picker. + ("ATTACH", false) => Some(Task::done(Message::AttachPickResult(Ok( + std::path::PathBuf::from(rest.trim_matches('"')), + )))), + ("XATTACH", false) => { + self.open_xref_attach_dialog(std::path::PathBuf::from(rest.trim_matches('"'))); + Some(Task::none()) + } + // XREF opens the External References palette; -XREF asks on the + // command line. Both keep their argument forms for scripts. + ("XREF", true) | ("XREFSHOW", true) => { + if !self.show_external_references { + return self.dispatch_blocks("EXTERNALREFERENCES", i); + } + self.refresh_xref_manager(); + Some(self.finish_dispatch(cmd)) + } + ("-XREF", true) => { + let saved: Vec<(String, String)> = self.tabs[i] + .scene + .document + .block_records + .iter() + .filter(|br| br.flags.is_xref || br.flags.is_xref_overlay) + .map(|br| (br.name.clone(), br.xref_path.clone())) + .collect(); + let command = crate::modules::insert::xref_cmd::XrefCommand::new(saved); + self.command_line.push_info(&command.prompt()); + self.tabs[i].active_cmd = Some(Box::new(command)); + Some(Task::none()) + } + ("XREFLIST", _) => { + self.list_xrefs(i, if rest.is_empty() { "*" } else { rest }); + Some(self.finish_dispatch(cmd)) + } + ("XREFATTACHFILE", false) | ("XREFOVERLAYFILE", false) => { + let request = XrefAttachRequest { + path: rest.trim_matches('"').to_string(), + overlay: verb == "XREFOVERLAYFILE", + // The reference default (REFPATHTYPE 1): relative once the + // drawing is saved, the full path before that. + path_type: Pathtype::Relative, + }; + self.start_xref_attach(i, request, XrefPlacement::on_screen(), "-XREF"); + Some(Task::none()) + } + _ => None, + } + } + + /// `-XREF ?` — the reference table as the reference application prints it. + fn list_xrefs(&mut self, i: usize, pattern: &str) { + let rows: Vec<(String, &'static str, String)> = self.tabs[i] + .scene + .document + .block_records + .iter() + .filter(|br| br.flags.is_xref || br.flags.is_xref_overlay) + .filter(|br| { + pattern == "*" || crate::io::xref_model::wildcard_match(&br.name, pattern) + }) + .map(|br| { + let kind = if br.flags.is_xref_overlay { "Overlay" } else { "Attach" }; + (br.name.clone(), kind, display_path(&br.xref_path)) + }) + .collect(); + let out = &mut self.command_line; + out.push_output(""); + out.push_output(&format!( + "{:<34}{:<13}{}", + "Xref name", + "Xref Type", + "Path" + )); + out.push_output(&format!("{:<34}{:<13}{}", "----------------------", "---------", "----------")); + out.push_output(""); + for (name, kind, path) in &rows { + out.push_output(&format!("{:<34}{:<13}{}", format!("\"{name}\""), *kind, path)); + } + out.push_output(""); + out.push_output(&format!("Total Xref(s): {}", rows.len())); + } + + /// Checks the file, reports it the way the reference does, then asks + /// for whatever is left on-screen (or commits straight away). + pub(in crate::app) fn start_xref_attach( + &mut self, + i: usize, + request: XrefAttachRequest, + placement: XrefPlacement, + label: &'static str, + ) { + let name = path_to_block_name(&request.path); + let full = crate::modules::insert::xattach::resolve_xref_store_path(&request.path, None); + let host = self.tabs[i].current_path.clone(); + if let Some(host) = host.as_deref() { + if crate::modules::insert::xattach::is_self_attach(host, &full, host.parent()) { + self.command_line.push_error( + "Error: Possible circular reference to current drawing. *Invalid*", + ); + return; + } + } + if let Some(existing) = existing_reference(&self.tabs[i].scene, &name) { + self.command_line + .push_output(&format!("Xref \"{}\" has already been defined.", existing)); + self.command_line + .push_output("Using existing definition."); + } else { + let verb = if request.overlay { + format!("Overlay Xref \"{}\": {}", name, display_path(&full)) + } else { + format!("Attach Xref \"{}\": {}", name, display_path(&full)) + }; + self.command_line.push_output(&verb); + if !std::path::Path::new(&full).is_file() { + let file = std::path::Path::new(&full.replace('\\', "/")) + .file_name() + .map(|f| f.to_string_lossy().into_owned()) + .unwrap_or(full.clone()); + self.command_line + .push_output(&format!("\"{}\" cannot be found.", file)); + self.command_line.push_error("*Invalid*"); + return; + } + self.command_line + .push_output(&format!("\"{}\" loaded.", name)); + } + let command = XAttachCommand::new(request.clone(), placement, label); + if let Some(entity) = command.immediate() { + self.commit_xref_attach(i, request, entity); + return; + } + self.command_line.push_info(&command.prompt()); + self.tabs[i].active_cmd = Some(Box::new(command)); + } + + /// Creates (or reuses) the definition and commits its INSERT — one undo + /// step for both, so undo leaves no definition behind. + pub(in crate::app) fn commit_xref_attach( + &mut self, + i: usize, + request: XrefAttachRequest, + mut entity: acadrust::EntityType, + ) { + let pending = self.begin_undo(i, "XATTACH", 1, false); + let host = self.tabs[i].current_path.clone(); + let name = prepare_xref_definition(&mut self.tabs[i].scene, &request, host.as_deref()); + if let acadrust::EntityType::Insert(insert) = &mut entity { + insert.block_name = name; + } + // Resolving merged the reference's layers and linetypes — mirror + // them into the Layers panel and ribbon dropdowns now (#407). + self.refresh_layer_panel(); + let _ = self.commit_entity_handle(entity); + self.tabs[i].dirty = true; + self.tabs[i].scene.clear_preview_wire(); + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.restore_pre_cmd_tangent(); + if let Some(pending) = pending { + self.commit_undo_delta(i, pending); + } + self.refresh_xref_manager(); + } + + /// Opens the Attach External Reference dialog for `path`. + pub(in crate::app) fn open_xref_attach_dialog(&mut self, path: std::path::PathBuf) { + let i = self.active_tab; + let existing: Vec<(String, String)> = self.tabs[i] + .scene + .document + .block_records + .iter() + .filter(|br| br.flags.is_xref || br.flags.is_xref_overlay) + .map(|br| (br.name.clone(), br.xref_path.clone())) + .collect(); + let mut state = XrefAttachState::new(existing); + self.load_xref_attach_file(&mut state, &path.to_string_lossy()); + self.xref_attach = Some(state); + self.active_modal = Some(crate::app::ModalKind::XrefAttach); + } + + /// Fills the dialog from the chosen file: name, preview, block unit and + /// the paths shown under Details. + fn load_xref_attach_file(&self, state: &mut XrefAttachState, path: &str) { + let i = self.active_tab; + state.path = path.to_string(); + state.name = path_to_block_name(path); + state.found_in = display_path(path); + #[cfg(not(target_arch = "wasm32"))] + { + state.preview = crate::io::thumbnail::read_handle(std::path::Path::new(path)); + let host_units = self.tabs[i].scene.document.header.insertion_units; + let source_units = crate::io::load_file(std::path::Path::new(path)) + .map(|doc| doc.header.insertion_units) + .unwrap_or(0); + state.unit_label = crate::t!(crate::app::properties::insunits_name(source_units)) + .into_owned(); + let factor = + crate::app::properties::insert_unit_scale(host_units, source_units).unwrap_or(1.0); + state.unit_factor = crate::app::properties::format_unit_factor(factor); + } + self.refresh_xref_attach_saved_path(state); + } + + fn refresh_xref_attach_saved_path(&self, state: &mut XrefAttachState) { + let host = self.tabs[self.active_tab].current_path.clone(); + let request = XrefAttachRequest { + path: state.path.clone(), + overlay: state.overlay, + path_type: state.path_type.0, + }; + state.saved_path = display_path(&stored_path(&request, host.as_deref())); + } + + /// Messages of the ATTACH picker and the dialog. + pub(in crate::app) fn update_xref_attach(&mut self, message: Message) -> Task { + match message { + Message::AttachPick => Task::perform( + async { + let handle = crate::sys::file_dialog() + .set_title(crate::t!("Select Reference File").as_ref()) + .add_filter( + crate::t!("All Reference Files").as_ref(), + &[ + "dwg", "dxf", "pdf", "png", "jpg", "jpeg", "bmp", "tif", "tiff", + ], + ) + .add_filter(crate::t!("Drawing").as_ref(), &["dwg", "dxf"]) + .add_filter(crate::t!("PDF Files").as_ref(), &["pdf"]) + .add_filter( + crate::t!("Images").as_ref(), + &["png", "jpg", "jpeg", "bmp", "tif", "tiff"], + ) + .pick_file() + .await; + match handle { + Some(h) => Ok(crate::sys::handle_path(&h)), + None => Err("Cancelled".to_string()), + } + }, + Message::AttachPickResult, + ), + Message::AttachPickResult(Ok(path)) => { + let ext = path + .extension() + .map(|e| e.to_string_lossy().to_ascii_lowercase()) + .unwrap_or_default(); + match ext.as_str() { + "pdf" => match std::fs::read(&path) { + Ok(bytes) => self.update(Message::PdfAttachPickResult(Ok(( + path, + std::sync::Arc::new(bytes), + )))), + Err(e) => self.update(Message::PdfAttachPickResult(Err(e.to_string()))), + }, + "png" | "jpg" | "jpeg" | "bmp" | "tif" | "tiff" => { + let result = image::image_dimensions(&path) + .map(|(w, h)| (path.clone(), w, h)) + .map_err(|e| e.to_string()); + self.update(Message::ImagePickResult(result)) + } + _ => { + self.open_xref_attach_dialog(path); + Task::none() + } + } + } + Message::AttachPickResult(Err(e)) => { + if e != "Cancelled" { + self.command_line + .push_error(format!("ATTACH: {e}").as_ref()); + } + Task::none() + } + Message::XrefAttachBrowseResult(Ok(path)) => { + if let Some(mut state) = self.xref_attach.take() { + self.load_xref_attach_file(&mut state, &path.to_string_lossy()); + self.xref_attach = Some(state); + } + Task::none() + } + Message::XrefAttachBrowseResult(Err(_)) => Task::none(), + Message::XrefAttach(edit) => self.edit_xref_attach(edit), + _ => Task::none(), + } + } + + fn edit_xref_attach(&mut self, edit: XrefAttachMsg) -> Task { + let Some(mut state) = self.xref_attach.take() else { + return Task::none(); + }; + let mut task = Task::none(); + match edit { + XrefAttachMsg::Name(name) => { + if let Some((_, saved)) = state + .existing + .iter() + .find(|(n, _)| n.eq_ignore_ascii_case(&name)) + .cloned() + { + let host = self.tabs[self.active_tab].current_path.clone(); + let base = host.as_deref().and_then(|h| h.parent()); + let full = crate::modules::insert::xattach::resolve_xref_store_path(&saved, base); + self.load_xref_attach_file(&mut state, &full); + } + } + XrefAttachMsg::Browse => { + task = Task::perform( + async { + let handle = crate::sys::file_dialog() + .set_title(crate::t!("Select Reference File").as_ref()) + .add_filter(crate::t!("Drawing").as_ref(), &["dwg", "dxf"]) + .pick_file() + .await; + match handle { + Some(h) => Ok(crate::sys::handle_path(&h)), + None => Err("Cancelled".to_string()), + } + }, + Message::XrefAttachBrowseResult, + ); + } + XrefAttachMsg::Overlay(overlay) => state.overlay = overlay, + XrefAttachMsg::ScaleOnScreen(on) => state.scale_on_screen = on, + XrefAttachMsg::Scale(axis, value) => { + if axis == 0 && state.uniform { + state.scale = [value.clone(), value.clone(), value]; + } else if let Some(slot) = state.scale.get_mut(axis) { + *slot = value; + } + } + XrefAttachMsg::Uniform(on) => { + state.uniform = on; + if on { + let x = state.scale[0].clone(); + state.scale = [x.clone(), x.clone(), x]; + } + } + XrefAttachMsg::InsertOnScreen(on) => state.insert_on_screen = on, + XrefAttachMsg::Insert(axis, value) => { + if let Some(slot) = state.insert.get_mut(axis) { + *slot = value; + } + } + XrefAttachMsg::PathType(choice) => { + state.path_type = choice; + self.refresh_xref_attach_saved_path(&mut state); + } + XrefAttachMsg::RotationOnScreen(on) => state.rotation_on_screen = on, + XrefAttachMsg::Rotation(value) => state.rotation = value, + XrefAttachMsg::Details(on) => state.details = on, + XrefAttachMsg::Help => { + self.command_line.push_info( + crate::t!("Inserts references to external files such as other drawings, raster images, and underlays.") + .as_ref(), + ); + } + XrefAttachMsg::Apply => { + let placement = XrefPlacement { + insert: (!state.insert_on_screen) + .then(|| state.insert_point()) + .flatten(), + scale: (!state.scale_on_screen).then(|| state.scale_values()).flatten(), + rotation: (!state.rotation_on_screen) + .then(|| crate::entities::common::parse_typed_angle(&state.rotation)) + .flatten() + .map(f64::to_degrees), + }; + let invalid = (!state.insert_on_screen && placement.insert.is_none()) + || (!state.scale_on_screen + && placement + .scale + .is_none_or(|s| s.iter().any(|v| *v == 0.0))) + || (!state.rotation_on_screen && placement.rotation.is_none()); + if invalid || state.path.is_empty() { + self.command_line + .push_error(crate::t!("Invalid input.").as_ref()); + self.xref_attach = Some(state); + return Task::none(); + } + let request = XrefAttachRequest { + path: state.path.clone(), + overlay: state.overlay, + path_type: state.path_type.0, + }; + self.active_modal = None; + let i = self.active_tab; + self.start_xref_attach(i, request, placement, "XATTACH"); + return Task::none(); + } + } + self.xref_attach = Some(state); + task + } +} diff --git a/src/app/control/actions.rs b/src/app/control/actions.rs index c5955c512..35adf4ccc 100644 --- a/src/app/control/actions.rs +++ b/src/app/control/actions.rs @@ -47,6 +47,7 @@ fn color_value(color: acadrust::types::Color) -> Value { } pub(super) const NAMES: &[&str] = &[ "close_modal", + "dialog_ok", "close_document", "toggle_properties", "toggle_layers", @@ -255,6 +256,14 @@ impl OpenCADStudio { let name = string(req, "name")?; let msg = match name { "close_modal" => Message::CloseModal, + // The open dialog's OK button. + "dialog_ok" => match self.active_modal { + Some(crate::app::ModalKind::XrefAttach) => Message::XrefAttach( + crate::ui::window::xref_attach::XrefAttachMsg::Apply, + ), + Some(crate::app::ModalKind::BlockDefinition) => Message::BlockDefApply, + _ => return Err(failure("no_dialog", "No dialog with an OK button is open")), + }, "close_document" => Message::TabClose(self.active_tab), "toggle_properties" => Message::ToggleProperties, "toggle_layers" => Message::ToggleLayers, diff --git a/src/app/mod.rs b/src/app/mod.rs index 4b3dcb04e..6b3d22e02 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -493,6 +493,8 @@ pub(super) struct OpenCADStudio { drawing_units: Option, /// Working copy of the Block Definition dialog; `None` while it is closed. block_definition: Option, + /// Working copy of the Attach External Reference dialog; None while closed. + xref_attach: Option, /// Working copy of the structured feature-control-frame editor. geometric_tolerance: Option, /// PICKDRAG (#226): false (default) = press-drag lassoes; true = @@ -1871,6 +1873,7 @@ pub enum ModalKind { LayerTranslator, DrawingUnits, BlockDefinition, + XrefAttach, GeometricTolerance, DraftingSettings, AutoConstrainSettings, @@ -3779,6 +3782,14 @@ pub enum Message { XAttachPick, /// Result of the XATTACH file picker. XAttachPickResult(Result), + /// ATTACH: pick a drawing, image or PDF to reference. + AttachPick, + /// Result of the ATTACH file picker. + AttachPickResult(Result), + /// An edit in the Attach External Reference dialog. + XrefAttach(crate::ui::window::xref_attach::XrefAttachMsg), + /// Result of the dialog's Browse picker. + XrefAttachBrowseResult(Result), // ── WBLOCK ──────────────────────────────────────────────────────────── /// Trigger the WBLOCK save dialog for `block_name` (or `*` = selection). WblockSave(String), @@ -3941,6 +3952,7 @@ impl OpenCADStudio { layer_translator: None, drawing_units: None, block_definition: None, + xref_attach: None, geometric_tolerance: None, pick_drag_rect: false, perf_hud: false, diff --git a/src/app/properties.rs b/src/app/properties.rs index cd327597d..d7e44afc3 100644 --- a/src/app/properties.rs +++ b/src/app/properties.rs @@ -1478,6 +1478,25 @@ impl OpenCADStudio { }, ); } + if let Some(br) = doc + .block_records + .get(&ins.block_name) + .filter(|br| br.flags.is_xref || br.flags.is_xref_overlay) + { + let base_dir = self.tabs[i] + .current_path + .as_deref() + .and_then(|p| p.parent()) + .map(|p| p.to_path_buf()) + .unwrap_or_else(|| std::path::PathBuf::from(".")); + let overrides = crate::io::xref::layer_overrides( + doc, + &br.name, + &br.xref_path, + &base_dir, + ); + xref_rows(&mut sections, ins, &br.xref_path, factor, overrides); + } } // Underlay: name + path from the referenced definition. acadrust::EntityType::Underlay(ul) => { @@ -3702,6 +3721,69 @@ fn merge_prop_value( /// Set the first property row matching `field` (across all sections) to a /// read-only `value`. No-op when the field is absent. Used to fill the /// doc-dependent placeholder rows the entity builders leave empty. +/// An external reference's rows as the reference application lists them: +/// Insertion point X/Y/Z, Scale X/Y/Z without the unit conversion the INSERT +/// carries (no uniform-scale or Annotative rows), Saved Path after the name +/// and Layer property overrides last. +fn xref_rows( + sections: &mut [crate::scene::model::object::PropSection], + ins: &acadrust::entities::Insert, + saved_path: &str, + factor: f64, + overrides: bool, +) { + use crate::entities::common::{edit_prop, ro_prop}; + let factor = if factor.abs() > 0.0 { factor } else { 1.0 }; + for section in sections.iter_mut() { + for prop in section.props.iter_mut() { + let label = match prop.field { + "ins_x" => "Insertion point X", + "ins_y" => "Insertion point Y", + "ins_z" => "Insertion point Z", + _ => continue, + }; + prop.label = t!(label).into_owned(); + } + let scale_at = section.props.iter().position(|p| { + matches!(p.field, "x_scale" | "y_scale" | "z_scale" | "u_scale" | "ins_uniform") + }); + section.props.retain(|p| { + !matches!( + p.field, + "x_scale" | "y_scale" | "z_scale" | "u_scale" | "ins_uniform" | "annotative" + ) + }); + if let Some(at) = scale_at { + let at = at.min(section.props.len()); + section.props.splice( + at..at, + [ + edit_prop(t!("Scale X").as_ref(), "xref_x_scale", ins.x_scale() / factor), + edit_prop(t!("Scale Y").as_ref(), "xref_y_scale", ins.y_scale() / factor), + edit_prop(t!("Scale Z").as_ref(), "xref_z_scale", ins.z_scale() / factor), + ], + ); + } + if let Some(at) = section.props.iter().position(|p| p.field == "block") { + section.props.insert( + at + 1, + ro_prop( + t!("Saved Path").as_ref(), + "xref_saved_path", + crate::modules::insert::xref_cmd::display_path(saved_path), + ), + ); + } + if section.props.iter().any(|p| p.field == "unit_factor") { + section.props.push(ro_prop( + t!("Layer property overrides").as_ref(), + "xref_layer_overrides", + if overrides { t!("Yes") } else { t!("No") }.into_owned(), + )); + } + } +} + fn set_row(sections: &mut [crate::scene::model::object::PropSection], field: &str, value: String) { for section in sections.iter_mut() { if let Some(row) = section.props.iter_mut().find(|p| p.field == field) { @@ -4092,7 +4174,7 @@ fn dim_lineweight_label(dimlwd: i16) -> String { } /// Human-readable INSUNITS name (DXF group 70 unit codes). -fn insunits_name(code: i16) -> &'static str { +pub(in crate::app) fn insunits_name(code: i16) -> &'static str { match code { 1 => "Inches", 2 => "Feet", @@ -4124,7 +4206,7 @@ fn insunits_name(code: i16) -> &'static str { } /// Unit-conversion scale for a new INSERT. -fn insert_unit_scale(host_units: i16, src_units: i16) -> Option { +pub(in crate::app) fn insert_unit_scale(host_units: i16, src_units: i16) -> Option { let host_mm = insunits_to_mm(host_units)?; let src_mm = insunits_to_mm(src_units)?; let ratio = src_mm / host_mm; @@ -4134,7 +4216,7 @@ fn insert_unit_scale(host_units: i16, src_units: i16) -> Option { Some(ratio) } -fn format_unit_factor(factor: f64) -> String { +pub(in crate::app) fn format_unit_factor(factor: f64) -> String { let magnitude = factor.abs(); if magnitude > 0.0 && !(1.0e-4..1.0e7).contains(&magnitude) { format!("{factor:.4e}") diff --git a/src/app/update/command.rs b/src/app/update/command.rs index 09be3f45c..c057fdc1d 100644 --- a/src/app/update/command.rs +++ b/src/app/update/command.rs @@ -3471,6 +3471,46 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { } } } + } else if let Some(axis) = match field { + "xref_x_scale" => Some(0), + "xref_y_scale" => Some(1), + "xref_z_scale" => Some(2), + _ => None, + } { + // An xref's Scale rows show the scale without the + // unit conversion its INSERT carries; write it back + // with that conversion. + let value = val + .trim() + .parse::() + .ok() + .filter(|v| v.is_finite() && *v != 0.0); + if let Some(value) = value { + let host = self.tabs[i].scene.document.header.insertion_units; + for &handle in &handles { + let factor = match self.tabs[i].scene.document.get_entity(handle) { + Some(acadrust::EntityType::Insert(ins)) => self.tabs[i] + .scene + .document + .block_records + .get(&ins.block_name) + .and_then(|br| { + crate::app::properties::insert_unit_scale(host, br.units) + }) + .unwrap_or(1.0), + _ => continue, + }; + if let Some(acadrust::EntityType::Insert(ins)) = + self.tabs[i].scene.document.get_entity_mut(handle) + { + match axis { + 0 => ins.set_x_scale(value * factor), + 1 => ins.set_y_scale(value * factor), + _ => ins.set_z_scale(value * factor), + } + } + } + } } else if field == "block" { // Name row on a block reference: an existing name // re-points the selected inserts; a new one renames diff --git a/src/app/update/file.rs b/src/app/update/file.rs index b23d252c4..d32f5a425 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -2105,7 +2105,8 @@ impl OpenCADStudio { } let mut recent_task = Task::none(); - let saved = match crate::io::save_to_bytes(&self.tabs[i].scene.document, &ext, version) { + let document = self.tabs[i].scene.document_for_save(); + let saved = match crate::io::save_to_bytes(&document, &ext, version) { Ok(bytes) => { crate::sys::download_bytes(&filename, &bytes); let cache_name = std::path::Path::new(&filename) diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index a0201728a..f1d12f893 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -208,6 +208,7 @@ impl OpenCADStudio { } Some(GeometricTolerance) => self.geometric_tolerance = None, Some(BlockDefinition) => self.block_definition = None, + Some(XrefAttach) => self.xref_attach = None, Some(Hyperlink) => { self.hyperlink_editor_handles.clear(); self.hyperlink_editor_url.clear(); @@ -1278,15 +1279,13 @@ impl OpenCADStudio { ), Message::XAttachPickResult(Ok(path)) => { - use crate::command::CadCommand; - use crate::modules::insert::xattach::XAttachCommand; - let path_str = path.to_string_lossy().into_owned(); - let cmd = XAttachCommand::with_path(path_str); - let i = self.active_tab; - self.command_line.push_info(&cmd.prompt()); - self.tabs[i].active_cmd = Some(Box::new(cmd)); + self.open_xref_attach_dialog(path); Task::none() } + message @ (Message::AttachPick + | Message::AttachPickResult(_) + | Message::XrefAttach(_) + | Message::XrefAttachBrowseResult(_)) => self.update_xref_attach(message), Message::XAttachPickResult(Err(e)) => { if e != "Cancelled" { diff --git a/src/app/view/modal.rs b/src/app/view/modal.rs index b7a7c092e..4b77e7cbb 100644 --- a/src/app/view/modal.rs +++ b/src/app/view/modal.rs @@ -24,6 +24,7 @@ impl OpenCADStudio { Some(K::LayerTranslator) => crate::t!("Layer Translator").into_owned(), Some(K::DrawingUnits) => crate::t!("Drawing Units").into_owned(), Some(K::BlockDefinition) => crate::t!("Block Definition").into_owned(), + Some(K::XrefAttach) => crate::t!("Attach External Reference").into_owned(), Some(K::GeometricTolerance) => crate::t!("Geometric Tolerance").into_owned(), Some(K::DraftingSettings) => crate::t!("Drafting Settings").into_owned(), Some(K::AutoConstrainSettings) => crate::t!("Constraint Settings").into_owned(), @@ -506,6 +507,13 @@ impl OpenCADStudio { crate::ui::window::block_definition::view_window(state, flow) }) } + super::super::ModalKind::XrefAttach => { + let state = self.xref_attach.as_ref()?; + let height = if state.details { 520 } else { 450 }; + sized_flow(ex, 740, height, |flow| { + crate::ui::window::xref_attach::view_window(state, flow) + }) + } super::super::ModalKind::GeometricTolerance => { let state = self.geometric_tolerance.as_ref()?; sized_flow(ex, 670, 530, |flow| { diff --git a/src/command.rs b/src/command.rs index 33304dae2..8719ce9d7 100644 --- a/src/command.rs +++ b/src/command.rs @@ -2838,9 +2838,9 @@ pub trait CadCommand: Send { false } - /// If this command is XATTACH, returns the file path to attach. - /// Default: None. - fn xattach_path(&self) -> Option { + /// If this command is XATTACH, what it attaches and how the reference is + /// stored. Default: None. + fn xattach_request(&self) -> Option { None } diff --git a/src/io/xref.rs b/src/io/xref.rs index 948965cb6..4ddf257ee 100644 --- a/src/io/xref.rs +++ b/src/io/xref.rs @@ -305,6 +305,82 @@ fn resolve_xrefs_with_filter( (result, dropped) } +/// Layer properties an override can change, as compared for the +/// "Layer property overrides" row. +#[derive(Clone, PartialEq)] +struct LayerLook { + color: acadrust::types::Color, + line_type: String, + line_weight: acadrust::types::LineWeight, + off: bool, + frozen: bool, + locked: bool, + plottable: bool, +} + +impl LayerLook { + fn of(layer: &acadrust::tables::Layer) -> Self { + Self { + color: layer.color.clone(), + line_type: layer.line_type.to_uppercase(), + line_weight: layer.line_weight, + off: layer.flags.off, + frozen: layer.flags.frozen, + locked: layer.flags.locked, + plottable: layer.is_plottable, + } + } +} + +/// The source file's layers by upper-case name, read once per file version. +fn source_layers(path: &Path) -> Option> { + type Cache = std::sync::Mutex)>>; + static CACHE: std::sync::OnceLock = std::sync::OnceLock::new(); + let modified = std::fs::metadata(path).and_then(|m| m.modified()).ok()?; + let cache = CACHE.get_or_init(Default::default); + if let Some((stamp, layers)) = cache.lock().ok()?.get(path) { + if *stamp == modified { + return Some(layers.clone()); + } + } + let doc = super::load_file(path).ok()?; + let layers: HashMap = doc + .layers + .iter() + .map(|layer| (layer.name.to_uppercase(), LayerLook::of(layer))) + .collect(); + cache + .lock() + .ok()? + .insert(path.to_path_buf(), (modified, layers.clone())); + Some(layers) +} + +/// Whether any of reference `name`'s layers was changed in the host — the +/// Properties "Layer property overrides" row. Compares the host's dependent +/// layers with the source file's own. +pub fn layer_overrides(doc: &CadDocument, name: &str, raw_path: &str, base_dir: &Path) -> bool { + let Some(source) = resolve_path(raw_path, base_dir).and_then(|p| source_layers(&p)) else { + return false; + }; + let prefix = format!("{}|", name.to_uppercase()); + doc.layers.iter().any(|layer| { + let upper = layer.name.to_uppercase(); + let Some(own) = upper.strip_prefix(&prefix) else { + return false; + }; + let Some(original) = source.get(own) else { + return false; + }; + let mut here = LayerLook::of(layer); + // Dependent linetypes carry the reference prefix. + if let Some(stripped) = here.line_type.strip_prefix(&prefix) { + here.line_type = stripped.to_string(); + } + here != *original + }) +} + /// Try to build an absolute path from a raw xref path string. /// Handles absolute paths, relative paths, and Windows-style separators. fn resolve_path(raw: &str, base_dir: &Path) -> Option { @@ -342,18 +418,44 @@ fn resolve_path(raw: &str, base_dir: &Path) -> Option { None } -/// Make sure `doc` has BLOCK + ENDBLK entities for `block_name`. -/// These are required so renderers can find the block content. -fn ensure_block_entities(doc: &mut CadDocument, block_name: &str) { - let has_block = doc - .entities() - .any(|e| matches!(e, EntityType::Block(b) if b.name == block_name)); - if has_block { +/// Give a block record its BLOCK / ENDBLK markers, linked through the +/// record's marker handles. The DWG writer emits the markers the record +/// points at; a record left pointing at a null handle is written with +/// handle 0, which makes the whole drawing unreadable elsewhere. +pub(crate) fn ensure_block_entities(doc: &mut CadDocument, block_name: &str) { + let Some((owner, begin, end, path)) = doc.block_records.get(block_name).map(|br| { + ( + br.handle, + br.block_entity_handle, + br.block_end_handle, + br.xref_path.clone(), + ) + }) else { return; + }; + if begin.is_null() || !matches!(doc.get_entity(begin), Some(EntityType::Block(_))) { + let handle = doc.allocate_handle(); + let mut block = Block::new(block_name, Vector3::zero()); + if !path.is_empty() { + block = block.with_xref_path(&path); + } + block.common.handle = handle; + block.common.owner_handle = owner; + let _ = doc.add_entity(EntityType::Block(block)); + if let Some(br) = doc.block_records.get_mut(block_name) { + br.block_entity_handle = handle; + } + } + if end.is_null() || !matches!(doc.get_entity(end), Some(EntityType::BlockEnd(_))) { + let handle = doc.allocate_handle(); + let mut block_end = BlockEnd::new(); + block_end.common.handle = handle; + block_end.common.owner_handle = owner; + let _ = doc.add_entity(EntityType::BlockEnd(block_end)); + if let Some(br) = doc.block_records.get_mut(block_name) { + br.block_end_handle = handle; + } } - let b = Block::new(block_name, Vector3::zero()); - let _ = doc.add_entity(EntityType::Block(b)); - let _ = doc.add_entity(EntityType::BlockEnd(BlockEnd::new())); } /// Shared symbol-remap core for XREF load-merge and BIND (SPIKE3 REFACTOR @@ -471,6 +573,7 @@ struct XrefSymbolMaps { layers: HashMap, linetypes: HashMap, text_styles: HashMap, + text_style_handles: HashMap, dim_styles: HashMap, blocks: HashMap, br_handles: HashMap, @@ -493,16 +596,55 @@ fn import_xref_symbols( ) -> XrefSymbolMaps { let mut maps = XrefSymbolMaps::default(); + // A layer's plot style and material point at objects of the file it came + // from; carried over verbatim they point at whatever the host keeps under + // those handles. Take the host's defaults (its layer 0) instead. + // ponytail: every imported layer gets the default plot style and material; + // map them by name if a reference ever carries non-default ones. + let (host_plotstyle, host_material) = doc + .layers + .get("0") + .map(|layer| (layer.plotstyle_handle, layer.material)) + .unwrap_or((Handle::NULL, Handle::NULL)); + for layer in xref_doc.layers.iter() { let old = layer.name.clone(); let new = naming.name_for(SymbolKind::Layer, &old); let mut cloned = layer.clone(); cloned.name = new.clone(); + cloned.plotstyle_handle = host_plotstyle; + cloned.material = host_material; cloned.set_handle(doc.allocate_handle()); doc.layers.add_or_replace(cloned); maps.layers.insert(old.to_uppercase(), new); } + for ts in xref_doc.text_styles.iter() { + // A shape-file style has no name of its own; the host's style for the + // same shape file serves the imported linetypes. + if ts.is_shape_file { + let same_file = doc + .text_styles + .iter() + .find(|host| host.is_shape_file && host.font_file.eq_ignore_ascii_case(&ts.font_file)) + .map(|host| host.handle); + if let Some(host_handle) = same_file { + maps.text_style_handles.insert(ts.handle, host_handle); + continue; + } + } + let old = ts.name.clone(); + let new = naming.name_for(SymbolKind::TextStyle, &old); + let mut cloned = ts.clone(); + cloned.name = new.clone(); + let new_handle = doc.allocate_handle(); + cloned.set_handle(new_handle); + cloned.xref_dependent = false; + doc.text_styles.add_or_replace(cloned); + maps.text_style_handles.insert(ts.handle, new_handle); + maps.text_styles.insert(old.to_uppercase(), new); + } + for lt in xref_doc.line_types.iter() { let old = lt.name.clone(); if is_sentinel_linetype(&old) { @@ -511,22 +653,20 @@ fn import_xref_symbols( let new = naming.name_for(SymbolKind::Linetype, &old); let mut cloned = lt.clone(); cloned.name = new.clone(); + // Shape and text segments name their style by handle: point them at + // the imported copy, not at the source file's record. + for element in &mut cloned.elements { + if let Some(complex) = element.complex.as_mut() { + if let Some(new_style) = maps.text_style_handles.get(&complex.style_handle) { + complex.style_handle = *new_style; + } + } + } cloned.set_handle(doc.allocate_handle()); doc.line_types.add_or_replace(cloned); maps.linetypes.insert(old.to_uppercase(), new); } - for ts in xref_doc.text_styles.iter() { - let old = ts.name.clone(); - let new = naming.name_for(SymbolKind::TextStyle, &old); - let mut cloned = ts.clone(); - cloned.name = new.clone(); - cloned.set_handle(doc.allocate_handle()); - cloned.xref_dependent = false; - doc.text_styles.add_or_replace(cloned); - maps.text_styles.insert(old.to_uppercase(), new); - } - for ds in xref_doc.dim_styles.iter() { let old = ds.name.clone(); let new = naming.name_for(SymbolKind::DimStyle, &old); @@ -798,6 +938,21 @@ fn merge_xref_into_block( prefix: prefix.to_string(), }; let maps = import_xref_symbols(doc, &xref_doc, &mut naming); + // Dependent layers and linetypes carry the xref flag and their owning + // reference, the way the file stores them; without it the host writes + // plain records with a "|" in the name, which other readers reject. + for name in maps.layers.values() { + if let Some(layer) = doc.layers.get_mut(name) { + layer.flags.xref_dependent = true; + layer.xref_block_record_handle = br_handle; + } + } + for name in maps.linetypes.values() { + if let Some(linetype) = doc.line_types.get_mut(name) { + linetype.xref_dependent = true; + linetype.xref_block_record_handle = br_handle; + } + } // ── Entities (shared helper) ──────────────────────────────────────── // The unremapped-handle count is ignored on the load path (BIND reports @@ -1709,7 +1864,54 @@ fn same_file(a: &Path, b: &Path) -> bool { /// Shared by Detach (which then also erases the BlockRecord itself) and BIND /// (which drops the previous merge before re-merging under `$N$` names). The /// xref BlockRecord itself, its INSERTs, and non-prefixed symbols stay. +/// Drop from a save copy what resolving each xref merged into the host but +/// the file never stores: the reference's own geometry, its nested block +/// definitions and its text and dimension styles. Dependent layers and +/// linetypes stay (flagged), so layer overrides kept under VISRETAIN persist. +pub fn strip_resolved_xref_content(doc: &mut CadDocument) { + let xrefs: Vec<(String, Handle)> = doc + .block_records + .iter() + .filter(|br| br.flags.is_xref || br.flags.is_xref_overlay) + .map(|br| (br.name.clone(), br.handle)) + .collect(); + for (name, handle) in xrefs { + let (begin, end) = doc + .block_records + .get(&name) + .map(|br| (br.block_entity_handle, br.block_end_handle)) + .unwrap_or((Handle::NULL, Handle::NULL)); + let owned: Vec = doc + .entities() + .filter(|e| e.common().owner_handle == handle) + .map(|e| e.common().handle) + .filter(|h| *h != begin && *h != end) + .collect(); + for h in owned { + doc.remove_entity(h); + } + if let Some(br) = doc.block_records.get_mut(&name) { + br.entity_handles.clear(); + } + remove_dependent_symbols(doc, &name, false); + } +} + +/// Text styles a kept linetype draws its shapes or text with. +fn linetype_styles(doc: &CadDocument) -> HashSet { + doc.line_types + .iter() + .flat_map(|linetype| linetype.elements.iter()) + .filter_map(|element| element.complex.as_ref().map(|complex| complex.style_handle)) + .filter(|handle| !handle.is_null()) + .collect() +} + fn remove_pipe_symbols(doc: &mut CadDocument, name: &str) { + remove_dependent_symbols(doc, name, true); +} + +fn remove_dependent_symbols(doc: &mut CadDocument, name: &str, layers_too: bool) { let prefix = format!("{}|", name.to_uppercase()); let is_dep = |n: &str| n.to_uppercase().starts_with(&prefix); // Dependent blocks first — their handles route the owned-entity cleanup. @@ -1746,7 +1948,7 @@ fn remove_pipe_symbols(doc: &mut CadDocument, name: &str) { let layers: Vec = doc .layers .names() - .filter(|n| is_dep(n)) + .filter(|n| layers_too && is_dep(n)) .map(|s| s.to_string()) .collect(); for n in layers { @@ -1755,21 +1957,38 @@ fn remove_pipe_symbols(doc: &mut CadDocument, name: &str) { let lts: Vec = doc .line_types .names() - .filter(|n| is_dep(n)) + .filter(|n| layers_too && is_dep(n)) .map(|s| s.to_string()) .collect(); for n in lts { doc.line_types.remove(&n); } + // Styles a kept dependent linetype still draws with stay, flagged. + let used = if layers_too { + HashSet::default() + } else { + linetype_styles(doc) + }; let styles: Vec = doc .text_styles - .names() - .filter(|n| is_dep(n)) - .map(|s| s.to_string()) + .iter() + .filter(|style| is_dep(&style.name) && !used.contains(&style.handle)) + .map(|style| style.name.clone()) .collect(); for n in styles { doc.text_styles.remove(&n); } + let owner = doc + .block_records + .get(name) + .map(|br| br.handle) + .unwrap_or(Handle::NULL); + for style in doc.text_styles.iter_mut() { + if is_dep(&style.name) { + style.xref_dependent = true; + style.xref_block_record_handle = owner; + } + } let dims: Vec = doc .dim_styles .names() diff --git a/src/locale_catalog.rs b/src/locale_catalog.rs index db5c7f379..d55c470c3 100644 --- a/src/locale_catalog.rs +++ b/src/locale_catalog.rs @@ -265,6 +265,28 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static "Added annotation scale {arg}." => Some(("view", "added-annotation-scale-arg")), "Added from GitHub" => Some(("plugins", "added-from-github")), "Adjust" => Some(("insert", "adjust")), + "Attach External Reference" => Some(("insert", "xref-attach-dialog-title")), + "Browse..." => Some(("insert", "xref-attach-browse")), + "Reference Type" => Some(("insert", "xref-attach-reference-type")), + "Specify On-screen" => Some(("insert", "xref-attach-specify-on-screen")), + "Uniform Scale" => Some(("insert", "xref-attach-uniform-scale")), + "Insertion point" => Some(("insert", "xref-attach-insertion-point")), + "Angle:" => Some(("insert", "xref-attach-angle")), + "Block Unit" => Some(("insert", "xref-attach-block-unit")), + "Unit:" => Some(("insert", "xref-attach-unit")), + "Factor:" => Some(("insert", "xref-attach-factor")), + "Found in:" => Some(("insert", "xref-attach-found-in")), + "Saved path:" => Some(("insert", "xref-attach-saved-path")), + "Show Details" => Some(("insert", "xref-attach-show-details")), + "Hide Details" => Some(("insert", "xref-attach-hide-details")), + "Select Reference File" => Some(("insert", "xref-attach-select-file")), + "All Reference Files" => Some(("insert", "xref-attach-all-files")), + "Inserts references to external files such as other drawings, raster images, and underlays." => Some(("insert", "xref-attach-help")), + "Invalid input." => Some(("insert", "xref-attach-invalid-input")), + "Insertion point X" => Some(("insert", "xref-insertion-point-x")), + "Insertion point Y" => Some(("insert", "xref-insertion-point-y")), + "Insertion point Z" => Some(("insert", "xref-insertion-point-z")), + "Layer property overrides" => Some(("insert", "xref-layer-property-overrides")), "Adjust Colors for Background" => Some(("properties", "adjust-colors-for-background")), "Advanced" => Some(("properties", "advanced")), "Alias" => Some(("command", "alias-title-case")), diff --git a/src/modules/insert/mod.rs b/src/modules/insert/mod.rs index 0311f3eca..9abd83270 100644 --- a/src/modules/insert/mod.rs +++ b/src/modules/insert/mod.rs @@ -23,6 +23,7 @@ mod underlay_layers; pub(crate) mod wblock; mod xadjust; pub(crate) mod xattach; +pub(crate) mod xref_cmd; mod xclip; use crate::modules::{CadModule, IconKind, RibbonGroup, RibbonItem}; diff --git a/src/modules/insert/xattach.rs b/src/modules/insert/xattach.rs index b7be7c47a..cf42ea40b 100644 --- a/src/modules/insert/xattach.rs +++ b/src/modules/insert/xattach.rs @@ -1,23 +1,25 @@ -// XATTACH command — attach an external DWG/DXF file as an XREF block -// and insert it at a picked point. +// XATTACH placement — attach an external DWG/DXF file as an XREF block and +// insert it. // -// Workflow: -// Step 1 (point pick): user clicks the insertion point. -// Step 2 (text input): scale factor (default 1.0). -// Step 3 (text input): rotation angle (default 0.0). -// Result: BlockRecord + Block entities are created with is_xref=true, -// then an INSERT entity is committed with the chosen scale/rotation. +// The reference options (type, path type, fixed or on-screen scale, +// insertion point and rotation) come from the Attach External Reference +// dialog or from -XREF Attach / Overlay. Values left "on-screen" are asked +// here with the reference prompts: +// Specify insertion point or [Scale/X/Y/Z/Rotate/PScale/PX/PY/PZ/PRotate]: +// Enter X scale factor, specify opposite corner, or [Corner/XYZ] <1>: +// Enter Y scale factor : +// Specify rotation angle <0>: +// The definition is created when the INSERT is committed (see the command +// driver), so one undo removes both. -use acadrust::entities::{Block, BlockEnd, Insert}; +use acadrust::entities::Insert; use acadrust::tables::block_record::{BlockFlags, BlockRecord}; use acadrust::types::Vector3; use acadrust::EntityType; use glam::DVec3; -use crate::t; - -use crate::command::{CadCommand, CmdResult, InputKind, WorkingPlane}; -use crate::io::xref_model::normalize_lexical; +use crate::command::{CadCommand, CmdOption, CmdResult, InputKind, WorkingPlane}; +use crate::io::xref_model::{normalize_lexical, Pathtype}; use crate::modules::{IconKind, ModuleEvent, ToolDef}; use crate::scene::model::wire_model::WireModel; use crate::scene::Scene; @@ -31,45 +33,210 @@ pub fn tool() -> ToolDef { } } -/// Multi-step placement state: insertion point, then scale, then rotation. -/// Mirrors the INSERT command's scale/rotation value handling (single value -/// typed after the point pick, bare Enter keeps the default). -enum XAttachStep { - Point, - Scale { point: DVec3 }, - Rotate { point: DVec3, sx: f64, sy: f64, sz: f64 }, +/// What to attach and how the reference is stored. +#[derive(Debug, Clone, PartialEq)] +pub struct XrefAttachRequest { + /// The drawing file as chosen (absolute). + pub path: String, + pub overlay: bool, + pub path_type: Pathtype, } -pub struct XAttachCommand { - path: String, - block_name: String, - plane: WorkingPlane, - step: XAttachStep, +/// Placement values fixed before the command starts; `None` = on-screen. +#[derive(Debug, Clone, Copy, Default)] +pub struct XrefPlacement { + pub insert: Option, + pub scale: Option<[f64; 3]>, + /// Degrees. + pub rotation: Option, } -impl XAttachCommand { - /// Create an XATTACH command with a path already filled in (from file-picker). - pub fn with_path(path: String) -> Self { - let block_name = path_to_block_name(&path); - Self { - path, - block_name, - plane: WorkingPlane::default(), - step: XAttachStep::Point, +impl XrefPlacement { + /// Everything on-screen, as -XREF asks it. + pub fn on_screen() -> Self { + Self::default() + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Preset { + Scale, + X, + Y, + Z, + Rotate, + PScale, + PX, + PY, + PZ, + PRotate, +} + +impl Preset { + fn from_keyword(text: &str) -> Option { + Some(match text { + "S" | "SCALE" => Preset::Scale, + "X" => Preset::X, + "Y" => Preset::Y, + "Z" => Preset::Z, + "R" | "ROTATE" => Preset::Rotate, + "PS" | "PSCALE" => Preset::PScale, + "PX" => Preset::PX, + "PY" => Preset::PY, + "PZ" => Preset::PZ, + "PR" | "PROTATE" => Preset::PRotate, + _ => return None, + }) + } + + fn prompt(self) -> &'static str { + match self { + Preset::Scale => "Specify scale factor for XYZ axes:", + Preset::X => "Specify X scale factor:", + Preset::Y => "Specify Y scale factor:", + Preset::Z => "Specify Z Scale factor:", + Preset::Rotate => "Specify rotation angle:", + Preset::PScale => "Specify preview scale factor for XYZ axes:", + Preset::PX => "Specify preview X scale factor:", + Preset::PY => "Specify preview Y scale factor:", + Preset::PZ => "Specify preview Z scale factor:", + Preset::PRotate => "Specify preview rotation angle:", } } - fn commit_insert(&self, point: DVec3, sx: f64, sy: f64, sz: f64, rotation: f64) -> EntityType { + fn is_angle(self) -> bool { + matches!(self, Preset::Rotate | Preset::PRotate) + } +} + +#[derive(Debug, Clone, Copy, PartialEq)] +enum Step { + Insert, + Preset(Preset), + XScale, + Corner, + YScale, + XyzX, + XyzY, + XyzZ, + Rotation, +} + +pub struct XAttachCommand { + request: XrefAttachRequest, + block_name: String, + /// Command name shown before each prompt (ATTACH, XATTACH or -XREF). + label: &'static str, + plane: WorkingPlane, + step: Step, + point: Option, + scale: [f64; 3], + /// Scale fixed in the dialog or by the Scale/X/Y/Z options. + scale_fixed: bool, + /// Degrees. + rotation: f64, + rotation_fixed: bool, +} + +const NONZERO: &str = "Value must be nonzero."; + +impl XAttachCommand { + pub fn new(request: XrefAttachRequest, placement: XrefPlacement, label: &'static str) -> Self { + let block_name = path_to_block_name(&request.path); + let mut command = Self { + request, + block_name, + label, + plane: WorkingPlane::default(), + step: Step::Insert, + point: placement.insert, + scale: placement.scale.unwrap_or([1.0; 3]), + scale_fixed: placement.scale.is_some(), + rotation: placement.rotation.unwrap_or(0.0), + rotation_fixed: placement.rotation.is_some(), + }; + if command.point.is_some() { + command.step = command.next_step().unwrap_or(Step::Rotation); + } + command + } + + /// Attach with every value on-screen. + #[cfg(test)] + pub fn with_path(path: String) -> Self { + Self::new( + XrefAttachRequest { + path, + overlay: false, + path_type: Pathtype::Full, + }, + XrefPlacement::on_screen(), + "XATTACH", + ) + } + + /// The finished INSERT when nothing is left to ask (all values fixed in + /// the dialog). + pub fn immediate(&self) -> Option { + (self.point.is_some() && self.next_step().is_none()).then(|| self.commit_insert()) + } + + /// The step after the insertion point, or `None` when it can commit. + fn next_step(&self) -> Option { + if !self.scale_fixed { + Some(Step::XScale) + } else if !self.rotation_fixed { + Some(Step::Rotation) + } else { + None + } + } + + fn advance(&mut self) -> CmdResult { + match self.next_step() { + Some(step) => { + self.step = step; + CmdResult::NeedPoint + } + None => CmdResult::CommitAndExit(self.commit_insert()), + } + } + + /// Scale settled at this step: the rotation (if asked) comes next. + fn scale_done(&mut self) -> CmdResult { + self.scale_fixed = true; + self.advance() + } + + fn commit_insert(&self) -> EntityType { + let point = self.point.unwrap_or(DVec3::ZERO); let mut ins = Insert::new( self.block_name.clone(), Vector3::new(point.x, point.y, point.z), ); - ins.set_x_scale(sx); - ins.set_y_scale(sy); - ins.set_z_scale(sz); - ins.rotation = rotation; + ins.set_x_scale(self.scale[0]); + ins.set_y_scale(self.scale[1]); + ins.set_z_scale(self.scale[2]); + ins.rotation = self.rotation.to_radians(); self.plane.place_entity(EntityType::Insert(ins)) } + + /// Corner pick at the X scale prompt: X and Y from the rectangle, + /// Z follows X. + fn corner(&mut self, pt: DVec3) -> CmdResult { + let base = self.point.unwrap_or(DVec3::ZERO); + let corner = self.plane.to_local(pt); + let (sx, sy) = (corner.x - base.x, corner.y - base.y); + if sx.abs() < 1e-12 || sy.abs() < 1e-12 { + return CmdResult::ReportError(NONZERO.to_string()); + } + self.scale = [sx, sy, sx.abs()]; + self.scale_done() + } +} + +fn parse_number(text: &str) -> Option { + text.trim().replace(',', ".").parse::().ok().filter(|v| v.is_finite()) } impl CadCommand for XAttachCommand { @@ -82,121 +249,228 @@ impl CadCommand for XAttachCommand { } fn prompt(&self) -> String { - match &self.step { - XAttachStep::Point => t!( - "XATTACH Specify insertion point for \"%{name}\":", - name = self.block_name - ) - .into_owned(), - XAttachStep::Scale { .. } => { - t!("XATTACH Specify scale factor <1.0> (X,Y,Z or pick Corner):").into_owned() + let text = match self.step { + Step::Insert => { + "Specify insertion point or [Scale/X/Y/Z/Rotate/PScale/PX/PY/PZ/PRotate]:" } - XAttachStep::Rotate { .. } => { - t!("XATTACH Specify rotation angle <0>:").into_owned() + Step::Preset(preset) => preset.prompt(), + Step::XScale => { + "Enter X scale factor, specify opposite corner, or [Corner/XYZ] <1>:" } + Step::Corner => "Specify opposite corner:", + Step::YScale => "Enter Y scale factor :", + Step::XyzX => "Specify X scale factor or [Corner] <1>:", + Step::XyzY => "Enter Y scale factor :", + Step::XyzZ => "Specify Z scale factor or :", + Step::Rotation => "Specify rotation angle <0>:", + }; + format!("{} {}", self.label, text) + } + + fn options(&self) -> Vec { + match self.step { + Step::Insert => vec![ + CmdOption::new("Scale", "S"), + CmdOption::new("X", "X"), + CmdOption::new("Y", "Y"), + CmdOption::new("Z", "Z"), + CmdOption::new("Rotate", "R"), + CmdOption::new("PScale", "PS"), + CmdOption::new("PX", "PX"), + CmdOption::new("PY", "PY"), + CmdOption::new("PZ", "PZ"), + CmdOption::new("PRotate", "PR"), + ], + Step::XScale => vec![CmdOption::new("Corner", "C"), CmdOption::new("XYZ", "XYZ")], + Step::XyzX => vec![CmdOption::new("Corner", "C")], + _ => Vec::new(), } } fn input_kind(&self) -> InputKind { match self.step { - XAttachStep::Point => InputKind::Point, - XAttachStep::Scale { .. } | XAttachStep::Rotate { .. } => InputKind::SingleToken, + Step::Insert | Step::Corner => InputKind::Point, + _ => InputKind::SingleToken, } } + fn point_step_accepts_keywords(&self) -> bool { + self.step == Step::Insert + } + fn on_point(&mut self, pt: DVec3) -> CmdResult { match self.step { - XAttachStep::Point => { - let point = self.plane.to_local(pt); - self.step = XAttachStep::Scale { point }; - CmdResult::NeedPoint + Step::Insert => { + self.point = Some(self.plane.to_local(pt)); + self.advance() } - XAttachStep::Scale { point } => { - // Corner: derive X/Y scale from the picked opposite corner. - let corner = self.plane.to_local(pt); - let sx = (corner.x - point.x).abs(); - let sy = (corner.y - point.y).abs(); - self.step = XAttachStep::Rotate { - point, - sx: if sx < 1e-9 { 1.0 } else { sx }, - sy: if sy < 1e-9 { 1.0 } else { sy }, - sz: 1.0, - }; - CmdResult::NeedPoint - } - XAttachStep::Rotate { point, sx, sy, sz } => { - // Second point defines the rotation direction from the base. - let base_world = self.plane.to_world(point); - let rotation = self.plane.angle(base_world, pt).unwrap_or(0.0); - CmdResult::CommitAndExit(self.commit_insert(point, sx, sy, sz, rotation)) + Step::XScale | Step::Corner | Step::XyzX => self.corner(pt), + Step::Rotation => { + let base = self.plane.to_world(self.point.unwrap_or(DVec3::ZERO)); + self.rotation = self.plane.angle(base, pt).unwrap_or(0.0).to_degrees(); + CmdResult::CommitAndExit(self.commit_insert()) } + _ => CmdResult::NeedPoint, } } fn on_text_input(&mut self, text: &str) -> Option { + let token = text.trim().to_uppercase(); match self.step { - XAttachStep::Point => None, - XAttachStep::Scale { point } => { - // Single value (uniform) or X/Y/Z triplet; invalid input is - // consumed and re-prompts (mirrors INSERT's scale handling). - if let Some((sx, sy, sz)) = parse_scale_spec(text) { - self.step = XAttachStep::Rotate { point, sx, sy, sz }; - } + Step::Insert => { + let preset = Preset::from_keyword(&token)?; + self.step = Step::Preset(preset); Some(CmdResult::NeedPoint) } - XAttachStep::Rotate { point, sx, sy, sz } => { - if let Some(rotation) = crate::entities::common::parse_typed_angle(text) { - Some(CmdResult::CommitAndExit( - self.commit_insert(point, sx, sy, sz, rotation), - )) + Step::Preset(preset) => { + let value = if preset.is_angle() { + crate::entities::common::parse_typed_angle(text).map(f64::to_degrees) } else { - Some(CmdResult::NeedPoint) + parse_number(text) + }; + let Some(value) = value else { + return Some(CmdResult::NeedPoint); + }; + if !preset.is_angle() && value == 0.0 { + return Some(CmdResult::ReportError(NONZERO.to_string())); } + match preset { + Preset::Scale => { + self.scale = [value; 3]; + self.scale_fixed = true; + } + Preset::X => { + self.scale[0] = value; + self.scale_fixed = true; + } + Preset::Y => { + self.scale[1] = value; + self.scale_fixed = true; + } + Preset::Z => { + self.scale[2] = value; + self.scale_fixed = true; + } + Preset::Rotate => { + self.rotation = value; + self.rotation_fixed = true; + } + // Preview-only values: the reference asks the real ones + // after the insertion point as usual. + Preset::PScale | Preset::PX | Preset::PY | Preset::PZ | Preset::PRotate => {} + } + self.step = Step::Insert; + Some(CmdResult::NeedPoint) } + Step::XScale | Step::XyzX if token == "C" || token == "CORNER" => { + self.step = Step::Corner; + Some(CmdResult::NeedPoint) + } + Step::XScale if token == "XYZ" => { + self.step = Step::XyzX; + Some(CmdResult::NeedPoint) + } + Step::XScale | Step::XyzX => { + let value = parse_number(text)?; + if value == 0.0 { + return Some(CmdResult::ReportError(NONZERO.to_string())); + } + self.scale = [value, value, value.abs()]; + self.step = if self.step == Step::XScale { + Step::YScale + } else { + Step::XyzY + }; + Some(CmdResult::NeedPoint) + } + Step::YScale | Step::XyzY => { + let value = parse_number(text)?; + if value == 0.0 { + return Some(CmdResult::ReportError(NONZERO.to_string())); + } + self.scale[1] = value; + if self.step == Step::XyzY { + self.step = Step::XyzZ; + return Some(CmdResult::NeedPoint); + } + Some(self.scale_done()) + } + Step::XyzZ => { + let value = parse_number(text)?; + if value == 0.0 { + return Some(CmdResult::ReportError(NONZERO.to_string())); + } + self.scale[2] = value; + Some(self.scale_done()) + } + Step::Rotation => { + let rotation = crate::entities::common::parse_typed_angle(text)?; + self.rotation = rotation.to_degrees(); + Some(CmdResult::CommitAndExit(self.commit_insert())) + } + Step::Corner => None, } } fn on_enter(&mut self) -> CmdResult { match self.step { - // Bare Enter at the insertion-point step cancels (unchanged). - XAttachStep::Point => CmdResult::Cancel, - // Bare Enter accepts the default scale / rotation. - XAttachStep::Scale { point } => { - self.step = XAttachStep::Rotate { - point, - sx: 1.0, - sy: 1.0, - sz: 1.0, + Step::Insert => { + CmdResult::ReportError("Point or option keyword required.".to_string()) + } + Step::Preset(_) => { + self.step = Step::Insert; + CmdResult::NeedPoint + } + Step::XScale | Step::XyzX => { + self.scale = [1.0; 3]; + self.step = if self.step == Step::XScale { + Step::YScale + } else { + Step::XyzY }; CmdResult::NeedPoint } - XAttachStep::Rotate { point, sx, sy, sz } => { - CmdResult::CommitAndExit(self.commit_insert(point, sx, sy, sz, 0.0)) + Step::YScale => { + self.scale[1] = self.scale[0]; + self.scale_done() + } + Step::XyzY => { + self.scale[1] = self.scale[0]; + self.step = Step::XyzZ; + CmdResult::NeedPoint + } + Step::XyzZ => { + self.scale[2] = self.scale[0]; + self.scale_done() + } + Step::Corner => CmdResult::NeedPoint, + Step::Rotation => { + self.rotation = 0.0; + CmdResult::CommitAndExit(self.commit_insert()) } } } fn on_preview_wires(&mut self, _pt: DVec3) -> Vec { - // XREF-Task6: PScale drag preview — no framework hook; revisit vec![] } - fn xattach_path(&self) -> Option { - Some(self.path.clone()) + fn xattach_request(&self) -> Option { + Some(self.request.clone()) } } -/// Derive a block name from the file path: take the file stem, uppercase it. +/// Reference name for a file: its name without the extension, case kept. pub fn path_to_block_name(path: &str) -> String { - let p = std::path::Path::new(path); - p.file_stem() - .map(|s| s.to_string_lossy().to_uppercase()) + let normalized = path.replace('\\', "/"); + std::path::Path::new(&normalized) + .file_stem() + .map(|s| s.to_string_lossy().into_owned()) .unwrap_or_else(|| "XREF".to_string()) } -/// Collision-free block name: `stem` unless taken (case-insensitive — block -/// names are uppercase by convention), else `stem_1`, `stem_2`, ... -/// (`$0$` numbering belongs to Bind, not Attach.) +/// Collision-free block name: `stem` unless taken (case-insensitive), else +/// `stem_1`, `stem_2`, ... (`$0$` numbering belongs to Bind, not Attach.) pub fn unique_block_name(stem: &str, taken: &[impl AsRef]) -> String { let upper_taken: Vec = taken.iter().map(|t| t.as_ref().to_uppercase()).collect(); if !upper_taken.iter().any(|t| t == &stem.to_uppercase()) { @@ -215,10 +489,6 @@ pub fn unique_block_name(stem: &str, taken: &[impl AsRef]) -> String { } } -/// Attach-default-Full store path: a relative incoming path is joined onto -/// the host drawing's base dir (yielding an absolute path when the base dir -/// is absolute); absolute paths and missing base dirs pass through verbatim. -/// Separators are normalized to `/` for stable DWG storage. fn is_absolute_xref_path(path: &str) -> bool { let bytes = path.as_bytes(); std::path::Path::new(path).is_absolute() @@ -229,6 +499,9 @@ fn is_absolute_xref_path(path: &str) -> bool { && matches!(bytes[2], b'/' | b'\\')) } +/// Full store path: a relative incoming path is joined onto the host +/// drawing's base dir; absolute paths and missing base dirs pass through. +/// Separators are normalized to `/` for stable DWG storage. pub fn resolve_xref_store_path(path: &str, host_base_dir: Option<&std::path::Path>) -> String { let p = std::path::Path::new(path); if is_absolute_xref_path(path) { @@ -240,14 +513,6 @@ pub fn resolve_xref_store_path(path: &str, host_base_dir: Option<&std::path::Pat } } -// Host-path note (XREF-Task6): `Scene` exposes no host file path — it owns -// the document plus view state only (verified in scene/mod.rs: no file path -// field). The host drawing path lives one layer up, in `Tab::current_path` -// (app/document.rs). The self-attach guard therefore runs at the command -// driver layer, which owns both the tab path and the pending XATTACH path, -// via this pure, unit-tested helper. `prepare_xref_block` below stays -// side-effect-only and keeps its `-> String` return. - /// True when attaching `ref_path` would attach the host drawing into itself. /// Lexical comparison only (no filesystem touch — the target may be missing); /// a relative ref is resolved against `host_base_dir` (falling back to the @@ -271,46 +536,52 @@ pub fn is_self_attach( normalize_lexical(&candidate) == normalize_lexical(&host_file.to_string_lossy()) } -/// Parse a typed scale: a single uniform value, or an X/Y/Z triplet separated -/// by commas, semicolons or whitespace. Zero never scales (rejected, like -/// INSERT); unparseable input returns None and the caller re-prompts. -fn parse_scale_spec(text: &str) -> Option<(f64, f64, f64)> { - let norm = text.trim().replace([',', ';'], " "); - let parts: Vec<&str> = norm.split_whitespace().collect(); - match parts.as_slice() { - [single] => single - .parse::() - .ok() - .filter(|v| *v != 0.0) - .map(|v| (v, v, 1.0)), - [x, y, z] => { - let parsed: Option> = - [x, y, z].iter().map(|s| s.parse::().ok()).collect(); - match parsed { - Some(v) if v.iter().all(|n| *n != 0.0) => Some((v[0], v[1], v[2])), - _ => None, - } - } - _ => None, +/// The drawing's existing reference for `name`, if any (case-insensitive). +pub fn existing_reference(scene: &Scene, name: &str) -> Option { + scene + .document + .block_records + .iter() + .find(|br| { + (br.flags.is_xref || br.flags.is_xref_overlay) && br.name.eq_ignore_ascii_case(name) + }) + .map(|br| br.name.clone()) +} + +/// The path to store for `request`: full, relative to the saved host, or +/// the file name alone. A relative path needs a saved host; until then the +/// full path is kept, as the reference does. +pub fn stored_path(request: &XrefAttachRequest, host_file: Option<&std::path::Path>) -> String { + let full = resolve_xref_store_path(&request.path, None); + match request.path_type { + Pathtype::Full => full, + Pathtype::Relative => host_file + .and_then(|host| { + crate::io::xref_model::to_pathtype_result(&full, host, Pathtype::Relative).ok() + }) + .unwrap_or(full), + Pathtype::None => std::path::Path::new(&full.replace('\\', "/")) + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or(full), } } -/// Create the XREF BlockRecord + Block/EndBlock entities in the scene document -/// for a given file path. Returns the block name. +/// Create (or reuse) the reference definition for `request` and load its +/// content. Returns the block name the INSERT must use. /// -/// This must be called before committing the INSERT so that the block -/// definition exists when the renderer looks it up. -/// -/// Name collisions get a `_1`, `_2`... suffix via [`unique_block_name`]; the -/// stored path follows Attach-default-Full via [`resolve_xref_store_path`]. -/// Self-attach blocking is NOT done here — see the note on [`is_self_attach`]; -/// the driver checks it before calling. -pub fn prepare_xref_block( +/// A reference of the same name already in the drawing is reused ("Using +/// existing definition."). The self-attach guard runs before this, where the +/// host path is known. +pub fn prepare_xref_definition( scene: &mut Scene, - path: &str, - host_base_dir: Option<&std::path::Path>, + request: &XrefAttachRequest, + host_file: Option<&std::path::Path>, ) -> String { - let stem = path_to_block_name(path); + let stem = path_to_block_name(&request.path); + if let Some(existing) = existing_reference(scene, &stem) { + return existing; + } let taken: Vec = scene .document .block_records @@ -318,20 +589,13 @@ pub fn prepare_xref_block( .map(|b| b.name.clone()) .collect(); let block_name = unique_block_name(&stem, &taken); + let store_path = stored_path(request, host_file); - // If a BlockRecord already exists with this name, skip creation. - if scene.document.block_records.get(&block_name).is_some() { - return block_name; - } - - let store_path = resolve_xref_store_path(path, host_base_dir); - - // Create the BlockRecord. let mut br = BlockRecord::new(&block_name); br.handle = scene.document.allocate_handle(); br.flags = BlockFlags { - is_xref: true, - is_xref_overlay: false, + is_xref: !request.overlay, + is_xref_overlay: request.overlay, anonymous: false, has_attributes: false, is_external: false, @@ -339,33 +603,50 @@ pub fn prepare_xref_block( is_xref_unloaded: false, }; br.xref_path = store_path.clone(); + let key = br.handle; let _ = scene.document.block_records.add(br); - // Create BLOCK entity. - let b = Block::new(&block_name, Vector3::zero()).with_xref_path(&store_path); - let _ = scene.document.add_entity(EntityType::Block(b)); - let _ = scene - .document - .add_entity(EntityType::BlockEnd(BlockEnd::new())); + // BLOCK / ENDBLK markers linked through the record. + crate::io::xref::ensure_block_entities(&mut scene.document, &block_name); - // Resolve the XREF content immediately. - // Every reference in this host resolves relative to the host drawing, not - // relative to the newly-attached file. Using `store_path.parent()` here - // made an attach unexpectedly redirect existing relative xrefs. - if let Some(base_dir) = host_base_dir { - let _ = crate::io::xref::resolve_xrefs(&mut scene.document, base_dir); - } else if let Some(base_dir) = std::path::Path::new(&store_path).parent() { - let _ = crate::io::xref::resolve_xrefs(&mut scene.document, base_dir); + // Load this reference only. A stored relative path resolves against the + // host folder; a full path or bare file name against the chosen file's. + let file_dir = std::path::Path::new(&resolve_xref_store_path(&request.path, None)) + .parent() + .map(|p| p.to_path_buf()); + let base_dir = match request.path_type { + Pathtype::Relative => host_file.and_then(|h| h.parent()).map(|p| p.to_path_buf()), + _ => None, + } + .or(file_dir); + if let Some(base_dir) = base_dir { + let keys: rustc_hash::FxHashSet = [key].into_iter().collect(); + let _ = crate::io::xref::resolve_xrefs_for_keys(&mut scene.document, &base_dir, &keys); } block_name } +/// Full-path attach of `path` (kept for callers that only have a path). +#[cfg(test)] +pub fn prepare_xref_block( + scene: &mut Scene, + path: &str, + host_base_dir: Option<&std::path::Path>, +) -> String { + let request = XrefAttachRequest { + path: resolve_xref_store_path(path, host_base_dir), + overlay: false, + path_type: Pathtype::Full, + }; + prepare_xref_definition(scene, &request, None) +} + #[cfg(test)] mod tests { use super::{ - is_self_attach, parse_scale_spec, path_to_block_name, resolve_xref_store_path, - unique_block_name, XAttachCommand, + is_self_attach, path_to_block_name, resolve_xref_store_path, unique_block_name, + XAttachCommand, }; use crate::command::{CadCommand, CmdResult}; use acadrust::EntityType; @@ -385,8 +666,9 @@ mod tests { } #[test] - fn block_name_derives_from_stem() { - assert_eq!(path_to_block_name("C:/refs/plan.dwg"), "PLAN"); + fn block_name_keeps_the_file_name_case() { + assert_eq!(path_to_block_name("C:/refs/plan.dwg"), "plan"); + assert_eq!(path_to_block_name("C:\\refs\\Site Plan.dwg"), "Site Plan"); } #[test] @@ -407,96 +689,91 @@ mod tests { fn self_attach_guard_matches_normalized_paths() { let host = std::path::Path::new("C:/Drawings/host.dwg"); let base = std::path::Path::new("C:/Drawings"); - // Verbatim, drive-letter case, dot segments, and base-relative forms. assert!(is_self_attach(host, "C:/Drawings/host.dwg", Some(base))); assert!(is_self_attach(host, "c:/Drawings/host.dwg", Some(base))); assert!(is_self_attach(host, "C:/Drawings/./host.dwg", Some(base))); assert!(is_self_attach(host, "host.dwg", Some(base))); assert!(is_self_attach(host, "C:/Drawings/host.dwg", None)); - // Anything else attaches freely. assert!(!is_self_attach(host, "C:/Drawings/other.dwg", Some(base))); assert!(!is_self_attach(host, "other.dwg", Some(base))); assert!(!is_self_attach(host, "other.dwg", None)); } #[test] - fn scale_spec_parses_single_and_triplet() { - assert_eq!(parse_scale_spec("2"), Some((2.0, 2.0, 1.0))); - assert_eq!(parse_scale_spec("2,3,4"), Some((2.0, 3.0, 4.0))); - assert_eq!(parse_scale_spec("2 3 4"), Some((2.0, 3.0, 4.0))); - assert_eq!(parse_scale_spec("0"), None); - assert_eq!(parse_scale_spec("abc"), None); - assert_eq!(parse_scale_spec("1,2"), None); + fn enter_at_insertion_point_asks_again() { + let mut cmd = XAttachCommand::with_path("C:/refs/plan.dwg".to_string()); + assert!(matches!(cmd.on_enter(), CmdResult::ReportError(_))); + assert!(cmd.prompt().contains("Specify insertion point")); } - #[test] - fn enter_at_insertion_point_cancels() { - let mut cmd = XAttachCommand::with_path("C:/refs/plan.dwg".to_string()); - assert!(matches!(cmd.on_enter(), CmdResult::Cancel)); - } - - #[test] - fn placement_flow_scale_then_rotate_commits() { - let mut cmd = XAttachCommand::with_path("C:/refs/plan.dwg".to_string()); - assert!(matches!( - cmd.on_point(DVec3::new(10.0, 20.0, 0.0)), - CmdResult::NeedPoint - )); - assert!(cmd.prompt().contains("scale")); - assert!(matches!(cmd.on_text_input("2"), Some(CmdResult::NeedPoint))); - assert!(cmd.prompt().contains("rotation")); - match cmd.on_enter() { - CmdResult::CommitAndExit(EntityType::Insert(ins)) => { - assert_eq!(ins.block_name, "PLAN"); - assert!((ins.x_scale() - 2.0).abs() < 1e-9); - assert!((ins.y_scale() - 2.0).abs() < 1e-9); - assert!(ins.rotation.abs() < 1e-9); - assert!((ins.insert_point.x - 10.0).abs() < 1e-9); - assert!((ins.insert_point.y - 20.0).abs() < 1e-9); - } + fn committed(result: Option) -> acadrust::entities::Insert { + match result { + Some(CmdResult::CommitAndExit(EntityType::Insert(ins))) => ins, _ => panic!("expected CommitAndExit(Insert)"), } } #[test] - fn placement_flow_triplet_and_typed_rotation() { + fn x_scale_then_default_y_and_rotation() { let mut cmd = XAttachCommand::with_path("C:/refs/plan.dwg".to_string()); - assert!(matches!( - cmd.on_point(DVec3::ZERO), - CmdResult::NeedPoint - )); - assert!(matches!( - cmd.on_text_input("2,3,4"), - Some(CmdResult::NeedPoint) - )); - match cmd.on_text_input("90") { - Some(CmdResult::CommitAndExit(EntityType::Insert(ins))) => { - assert!((ins.x_scale() - 2.0).abs() < 1e-9); - assert!((ins.y_scale() - 3.0).abs() < 1e-9); - assert!((ins.z_scale() - 4.0).abs() < 1e-9); - assert!((ins.rotation - std::f64::consts::FRAC_PI_2).abs() < 1e-6); - } - _ => panic!("expected CommitAndExit(Insert)"), - } + assert!(matches!(cmd.on_point(DVec3::new(10.0, 20.0, 0.0)), CmdResult::NeedPoint)); + assert!(cmd.prompt().contains("Enter X scale factor")); + assert!(matches!(cmd.on_text_input("-2"), Some(CmdResult::NeedPoint))); + assert!(cmd.prompt().contains("Enter Y scale factor")); + assert!(matches!(cmd.on_enter(), CmdResult::NeedPoint)); + assert!(cmd.prompt().contains("Specify rotation angle <0>")); + let ins = committed(Some(cmd.on_enter())); + assert_eq!(ins.block_name, "plan"); + assert!((ins.x_scale() + 2.0).abs() < 1e-9); + assert!((ins.y_scale() + 2.0).abs() < 1e-9); + // Z follows the size of X, not its sign. + assert!((ins.z_scale() - 2.0).abs() < 1e-9); + assert!((ins.insert_point.x - 10.0).abs() < 1e-9); } #[test] - fn placement_flow_corner_pick_sets_xy_scale() { + fn zero_scale_is_refused() { let mut cmd = XAttachCommand::with_path("C:/refs/plan.dwg".to_string()); - assert!(matches!( - cmd.on_point(DVec3::ZERO), - CmdResult::NeedPoint - )); - assert!(matches!( - cmd.on_point(DVec3::new(3.0, 4.0, 0.0)), - CmdResult::NeedPoint - )); - match cmd.on_enter() { - CmdResult::CommitAndExit(EntityType::Insert(ins)) => { - assert!((ins.x_scale() - 3.0).abs() < 1e-9); - assert!((ins.y_scale() - 4.0).abs() < 1e-9); - } - _ => panic!("expected CommitAndExit(Insert)"), - } + cmd.on_point(DVec3::ZERO); + assert!(matches!(cmd.on_text_input("0"), Some(CmdResult::ReportError(_)))); + assert!(cmd.prompt().contains("Enter X scale factor")); + } + + #[test] + fn xyz_option_takes_three_factors() { + let mut cmd = XAttachCommand::with_path("C:/refs/plan.dwg".to_string()); + cmd.on_point(DVec3::ZERO); + cmd.on_text_input("XYZ"); + cmd.on_text_input("2"); + cmd.on_text_input("3"); + cmd.on_text_input("4"); + let ins = committed(cmd.on_text_input("90")); + assert!((ins.x_scale() - 2.0).abs() < 1e-9); + assert!((ins.y_scale() - 3.0).abs() < 1e-9); + assert!((ins.z_scale() - 4.0).abs() < 1e-9); + assert!((ins.rotation - std::f64::consts::FRAC_PI_2).abs() < 1e-6); + } + + #[test] + fn corner_pick_sets_x_and_y() { + let mut cmd = XAttachCommand::with_path("C:/refs/plan.dwg".to_string()); + cmd.on_point(DVec3::ZERO); + assert!(matches!(cmd.on_point(DVec3::new(5.0, 7.0, 0.0)), CmdResult::NeedPoint)); + let ins = committed(Some(cmd.on_enter())); + assert!((ins.x_scale() - 5.0).abs() < 1e-9); + assert!((ins.y_scale() - 7.0).abs() < 1e-9); + assert!((ins.z_scale() - 5.0).abs() < 1e-9); + } + + #[test] + fn scale_preset_skips_the_scale_prompts() { + let mut cmd = XAttachCommand::with_path("C:/refs/plan.dwg".to_string()); + cmd.on_text_input("S"); + assert!(cmd.prompt().contains("Specify scale factor for XYZ axes")); + cmd.on_text_input("2"); + cmd.on_point(DVec3::ZERO); + assert!(cmd.prompt().contains("Specify rotation angle <0>")); + let ins = committed(Some(cmd.on_enter())); + assert!((ins.z_scale() - 2.0).abs() < 1e-9); } } diff --git a/src/modules/insert/xref_cmd.rs b/src/modules/insert/xref_cmd.rs new file mode 100644 index 000000000..10f172650 --- /dev/null +++ b/src/modules/insert/xref_cmd.rs @@ -0,0 +1,221 @@ +// -XREF — the command-line form of the reference manager. +// +// Enter an option [?/Bind/Detach/Path/pathType/Unload/Reload/Overlay/Attach/Show] : +// +// Each option collects its answers with the reference prompts and hands them +// to the existing reference operations through `CmdResult::Dispatch`. + +use glam::DVec3; + +use crate::command::{CadCommand, CmdOption, CmdResult, InputKind}; + +#[derive(Debug, Clone, PartialEq, Eq)] +enum Step { + Option, + List, + Names(&'static str), + File { overlay: bool }, + PathNames, + NewPath { name: String }, + PathTypeNames, + PathTypeKind { names: String }, + ShowNames, +} + +pub struct XrefCommand { + step: Step, + /// Saved paths by reference name, for the Path option's "Old path:" line. + saved_paths: Vec<(String, String)>, +} + +impl XrefCommand { + pub fn new(saved_paths: Vec<(String, String)>) -> Self { + Self { + step: Step::Option, + saved_paths, + } + } + + fn choose(&mut self, token: &str) -> CmdResult { + self.step = match token { + "?" => Step::List, + "B" | "BIND" => Step::Names("Bind"), + "D" | "DETACH" => Step::Names("Detach"), + "P" | "PATH" => Step::PathNames, + "T" | "PATHTYPE" => Step::PathTypeNames, + "U" | "UNLOAD" => Step::Names("Unload"), + "R" | "RELOAD" => Step::Names("Reload"), + "O" | "OVERLAY" => Step::File { overlay: true }, + "A" | "ATTACH" => Step::File { overlay: false }, + "S" | "SHOW" => Step::ShowNames, + _ => { + return CmdResult::ReportError("Invalid option keyword.".to_string()); + } + }; + CmdResult::NeedPoint + } +} + +impl CadCommand for XrefCommand { + fn name(&self) -> &'static str { + "-XREF" + } + + fn prompt(&self) -> String { + let text = match &self.step { + Step::Option => "Enter an option [?/Bind/Detach/Path/pathType/Unload/Reload/Overlay/Attach/Show] :", + Step::List => "Enter xref name(s) to list <*>:", + Step::Names("Bind") => "Enter xref name(s) to bind:", + Step::Names("Detach") => "Enter xref name(s) to detach:", + Step::Names("Unload") => "Enter xref name(s) to unload:", + Step::Names(_) => "Enter xref name(s) to reload:", + Step::File { overlay: false } => "Enter name of file to attach:", + Step::File { overlay: true } => "Enter name of file to overlay:", + Step::PathNames => "Edit xref name(s) to edit path:", + Step::NewPath { .. } => "Enter new path:", + Step::PathTypeNames => "Enter xref name(s) to edit path type:", + Step::PathTypeKind { .. } => "Enter new path type [Full/Relative/None]:", + Step::ShowNames => "Enter xref name(s) to show:", + }; + format!("-XREF {text}") + } + + fn options(&self) -> Vec { + match self.step { + Step::Option => vec![ + CmdOption::new("?", "?"), + CmdOption::new("Bind", "B"), + CmdOption::new("Detach", "D"), + CmdOption::new("Path", "P"), + CmdOption::new("pathType", "T"), + CmdOption::new("Unload", "U"), + CmdOption::new("Reload", "R"), + CmdOption::new("Overlay", "O"), + CmdOption::new("Attach", "A"), + CmdOption::new("Show", "S"), + ], + Step::PathTypeKind { .. } => vec![ + CmdOption::new("Full", "F"), + CmdOption::new("Relative", "R"), + CmdOption::new("None", "N"), + ], + _ => Vec::new(), + } + } + + fn input_kind(&self) -> InputKind { + match self.step { + // Paths and names can hold spaces. + Step::File { .. } | Step::NewPath { .. } => InputKind::FreeText, + _ => InputKind::SingleToken, + } + } + + fn on_point(&mut self, _pt: DVec3) -> CmdResult { + CmdResult::NeedPoint + } + + fn on_text_input(&mut self, text: &str) -> Option { + let value = text.trim().trim_matches('"').to_string(); + let token = value.to_uppercase(); + Some(match self.step.clone() { + Step::Option => self.choose(&token), + Step::List => CmdResult::Dispatch(format!("XREFLIST {value}")), + Step::Names(op) => CmdResult::Dispatch(format!("XREF {op} {value}")), + Step::File { overlay } => { + let verb = if overlay { "XREFOVERLAYFILE" } else { "XREFATTACHFILE" }; + CmdResult::Dispatch(format!("{verb} {value}")) + } + Step::PathNames => { + let Some((name, old)) = self + .saved_paths + .iter() + .find(|(name, _)| crate::io::xref_model::wildcard_match(name, &value)) + .cloned() + else { + return Some(CmdResult::CancelWithMessage( + "No matching xref names found.".to_string(), + )); + }; + self.step = Step::NewPath { name: name.clone() }; + CmdResult::ReportMeasurement(format!( + "{}\n{}", + format!("xref name: \"{}\"", name), + format!("Old path: {}", display_path(&old)) + )) + } + Step::NewPath { name } => CmdResult::Dispatch(format!("XREF Path {name} {value}")), + Step::PathTypeNames => { + self.step = Step::PathTypeKind { names: value }; + CmdResult::NeedPoint + } + Step::PathTypeKind { names } => { + let kind = match token.as_str() { + "F" | "FULL" => "Full", + "R" | "RELATIVE" => "Relative", + "N" | "NONE" => "None", + _ => { + return Some(CmdResult::ReportError( + "Invalid option keyword.".to_string(), + )) + } + }; + CmdResult::Dispatch(format!("XREF Pathtype {kind} {names}")) + } + Step::ShowNames => CmdResult::Dispatch("XREFSHOW".to_string()), + }) + } + + fn on_enter(&mut self) -> CmdResult { + match self.step.clone() { + Step::Option => self.choose("A"), + Step::List => CmdResult::Dispatch("XREFLIST *".to_string()), + Step::NewPath { .. } => { + CmdResult::CancelWithMessage("Path unchanged.".to_string()) + } + Step::PathTypeKind { .. } => CmdResult::NeedPoint, + _ => CmdResult::Cancel, + } + } +} + +/// A stored path shown the way the platform writes it. +pub fn display_path(path: &str) -> String { + if cfg!(windows) { + path.replace('/', "\\") + } else { + path.to_string() + } +} + +#[cfg(test)] +mod tests { + use super::XrefCommand; + use crate::command::{CadCommand, CmdResult}; + + #[test] + fn enter_chooses_attach() { + let mut cmd = XrefCommand::new(Vec::new()); + assert!(matches!(cmd.on_enter(), CmdResult::NeedPoint)); + assert!(cmd.prompt().contains("Enter name of file to attach:")); + } + + #[test] + fn list_defaults_to_every_reference() { + let mut cmd = XrefCommand::new(Vec::new()); + cmd.on_text_input("?"); + assert!(cmd.prompt().contains("Enter xref name(s) to list <*>:")); + assert!(matches!(cmd.on_enter(), CmdResult::Dispatch(ref s) if s == "XREFLIST *")); + } + + #[test] + fn overlay_file_is_dispatched() { + let mut cmd = XrefCommand::new(Vec::new()); + cmd.on_text_input("O"); + assert!(cmd.prompt().contains("Enter name of file to overlay:")); + assert!(matches!( + cmd.on_text_input("C:/refs/site plan.dwg"), + Some(CmdResult::Dispatch(ref s)) if s == "XREFOVERLAYFILE C:/refs/site plan.dwg" + )); + } +} diff --git a/src/scene/parametric_constraints.rs b/src/scene/parametric_constraints.rs index 75cea5769..96aa14da8 100644 --- a/src/scene/parametric_constraints.rs +++ b/src/scene/parametric_constraints.rs @@ -2530,6 +2530,7 @@ impl super::Scene { } } } + crate::io::xref::strip_resolved_xref_content(&mut document); document } diff --git a/src/ui/window/block_definition.rs b/src/ui/window/block_definition.rs index 5894eecfb..638bcd538 100644 --- a/src/ui/window/block_definition.rs +++ b/src/ui/window/block_definition.rs @@ -155,7 +155,7 @@ impl BlockDefinitionState { } } -fn group<'a>( +pub(crate) fn group<'a>( title: impl Into, body: impl Into>, height: impl Into, @@ -185,7 +185,7 @@ fn group<'a>( .into() } -fn labeled_checkbox<'a>( +pub(crate) fn labeled_checkbox<'a>( label: impl Into, is_checked: bool, on_toggle: impl Fn(bool) -> Message + 'a, diff --git a/src/ui/window/mod.rs b/src/ui/window/mod.rs index 71af8baf6..1dcd3c445 100644 --- a/src/ui/window/mod.rs +++ b/src/ui/window/mod.rs @@ -24,5 +24,6 @@ pub mod annotation_data; pub mod alias_editor; pub mod find_replace; pub mod named_parameters; +pub mod xref_attach; pub mod xref_help; pub mod xref_manager; diff --git a/src/ui/window/xref_attach.rs b/src/ui/window/xref_attach.rs new file mode 100644 index 000000000..30251a6d7 --- /dev/null +++ b/src/ui/window/xref_attach.rs @@ -0,0 +1,414 @@ +//! Attach External Reference dialog — the options chosen after picking a +//! drawing with ATTACH / XATTACH: reference type, scale, insertion point, +//! path type and rotation, with the file's preview and block unit. + +use std::fmt; + +use iced::widget::{ + button, column, combo_box, container, image, pick_list, radio, row, text, text_input, Space, +}; +use iced::{Element, Fill, Length, Theme}; + +use crate::app::Message; +use crate::io::xref_model::Pathtype; +use crate::t; +use crate::ui::style::common::muted_style; +use crate::ui::style::form::{button_style, field_style}; +use crate::ui::window::block_definition::{group, labeled_checkbox}; + +/// One edit in the dialog. +#[derive(Debug, Clone)] +pub enum XrefAttachMsg { + /// Name box: a reference already in the drawing, picked or typed. + Name(String), + Browse, + Overlay(bool), + ScaleOnScreen(bool), + Scale(usize, String), + Uniform(bool), + InsertOnScreen(bool), + Insert(usize, String), + PathType(PathTypeChoice), + RotationOnScreen(bool), + Rotation(String), + Details(bool), + Apply, + Help, +} + +/// Path type choice as the dialog lists it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct PathTypeChoice(pub Pathtype); + +impl PathTypeChoice { + pub const ALL: [PathTypeChoice; 3] = [ + PathTypeChoice(Pathtype::Full), + PathTypeChoice(Pathtype::Relative), + PathTypeChoice(Pathtype::None), + ]; +} + +impl fmt::Display for PathTypeChoice { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let label = match self.0 { + Pathtype::Full => t!("Full path"), + Pathtype::Relative => t!("Relative path"), + Pathtype::None => t!("No path"), + }; + f.write_str(label.as_ref()) + } +} + +/// The dialog's working copy. +#[derive(Clone)] +pub struct XrefAttachState { + /// Absolute path of the drawing to attach. + pub path: String, + /// Reference name (the file name without extension). + pub name: String, + /// References already in the drawing: name and saved path. + pub existing: Vec<(String, String)>, + pub name_combo: combo_box::State, + pub overlay: bool, + pub scale_on_screen: bool, + pub scale: [String; 3], + pub uniform: bool, + pub insert_on_screen: bool, + pub insert: [String; 3], + pub path_type: PathTypeChoice, + pub rotation_on_screen: bool, + pub rotation: String, + /// Block unit of the chosen drawing and its factor to the host's unit. + pub unit_label: String, + pub unit_factor: String, + pub found_in: String, + pub saved_path: String, + pub details: bool, + pub preview: Option, +} + +impl XrefAttachState { + pub fn new(existing: Vec<(String, String)>) -> Self { + let names = existing.iter().map(|(name, _)| name.clone()).collect(); + Self { + path: String::new(), + name: String::new(), + existing, + name_combo: combo_box::State::new(names), + overlay: false, + scale_on_screen: false, + scale: ["1.0000".into(), "1.0000".into(), "1.0000".into()], + uniform: false, + insert_on_screen: true, + insert: ["0.0000".into(), "0.0000".into(), "0.0000".into()], + path_type: PathTypeChoice(Pathtype::Relative), + rotation_on_screen: false, + rotation: "0".into(), + unit_label: String::new(), + unit_factor: String::new(), + found_in: String::new(), + saved_path: String::new(), + details: false, + preview: None, + } + } + + /// Parsed X/Y/Z scale; a uniform scale takes X for all three. + pub fn scale_values(&self) -> Option<[f64; 3]> { + let parse = |s: &String| s.trim().parse::().ok(); + let x = parse(&self.scale[0])?; + if self.uniform { + return Some([x, x, x]); + } + Some([x, parse(&self.scale[1])?, parse(&self.scale[2])?]) + } + + pub fn insert_point(&self) -> Option { + let parse = |s: &String| s.trim().parse::().ok(); + Some(glam::DVec3::new( + parse(&self.insert[0])?, + parse(&self.insert[1])?, + parse(&self.insert[2])?, + )) + } +} + +fn msg(m: XrefAttachMsg) -> Message { + Message::XrefAttach(m) +} + +fn number_field<'a>( + label: &'a str, + value: &'a str, + enabled: bool, + on_input: impl Fn(String) -> Message + 'a, +) -> Element<'a, Message> { + let mut field = text_input("", value) + .size(11) + .padding([3, 6]) + .width(Fill) + .style(field_style); + if enabled { + field = field.on_input(on_input); + } + row![ + text(label).size(11).style(muted_style).width(Length::Fixed(16.0)), + field + ] + .spacing(6) + .align_y(iced::Center) + .into() +} + +fn read_only_field<'a>(label: String, value: &'a str, label_width: f32) -> Element<'a, Message> { + row![ + text(label) + .size(11) + .style(muted_style) + .width(Length::Fixed(label_width)), + text_input("", value) + .size(11) + .padding([3, 6]) + .width(Fill) + .style(field_style), + ] + .spacing(6) + .align_y(iced::Center) + .into() +} + +pub fn view_window<'a>( + state: &'a XrefAttachState, + sizing: crate::ui::modal::ModalSizing, +) -> Element<'a, Message> { + // ── Name ──────────────────────────────────────────────────────────── + let name_combo = combo_box( + &state.name_combo, + "", + if state.name.is_empty() { + None + } else { + Some(&state.name) + }, + |chosen: String| msg(XrefAttachMsg::Name(chosen)), + ) + .size(11) + .padding([3, 6]) + .width(Fill); + let name_section = column![ + text(t!("Name:")).size(11).style(muted_style), + row![ + name_combo, + button(text(t!("Browse...")).size(11)) + .on_press(msg(XrefAttachMsg::Browse)) + .style(button_style(false)) + .padding([4, 12]) + .width(Length::Fixed(96.0)), + ] + .spacing(8) + .align_y(iced::Center), + ] + .spacing(3); + + // ── Preview + reference type ──────────────────────────────────────── + let preview_body: Element<'a, Message> = match &state.preview { + Some(handle) => image(handle.clone()).width(Fill).height(Fill).into(), + None => Space::new().width(Fill).height(Fill).into(), + }; + let preview = group( + t!("Preview").into_owned(), + container(preview_body) + .width(Fill) + .height(Length::Fixed(132.0)) + .style(|theme: &Theme| container::Style { + background: Some(iced::Background::Color( + theme.palette().background.base.color, + )), + border: iced::Border { + width: 1.0, + radius: 2.0.into(), + color: theme.palette().background.strong.color, + }, + ..Default::default() + }), + Length::Shrink, + ); + let reference_type = group( + t!("Reference Type").into_owned(), + column![ + radio( + t!("Attachment"), + false, + Some(state.overlay), + |v| msg(XrefAttachMsg::Overlay(v)) + ) + .size(14) + .text_size(11), + radio(t!("Overlay"), true, Some(state.overlay), |v| msg( + XrefAttachMsg::Overlay(v) + )) + .size(14) + .text_size(11), + ] + .spacing(6), + Length::Shrink, + ); + let left = column![preview, reference_type] + .spacing(6) + .width(Length::FillPortion(1)); + + // ── Scale + insertion point ───────────────────────────────────────── + let scale_enabled = !state.scale_on_screen; + let scale = group( + t!("Scale").into_owned(), + column![ + labeled_checkbox(t!("Specify On-screen"), state.scale_on_screen, |v| msg( + XrefAttachMsg::ScaleOnScreen(v) + )), + number_field("X:", &state.scale[0], scale_enabled, |v| msg( + XrefAttachMsg::Scale(0, v) + )), + number_field( + "Y:", + &state.scale[1], + scale_enabled && !state.uniform, + |v| msg(XrefAttachMsg::Scale(1, v)) + ), + number_field( + "Z:", + &state.scale[2], + scale_enabled && !state.uniform, + |v| msg(XrefAttachMsg::Scale(2, v)) + ), + labeled_checkbox(t!("Uniform Scale"), state.uniform, |v| msg( + XrefAttachMsg::Uniform(v) + )), + ] + .spacing(5), + Length::Shrink, + ); + let insert_enabled = !state.insert_on_screen; + let insertion = group( + t!("Insertion point").into_owned(), + column![ + labeled_checkbox(t!("Specify On-screen"), state.insert_on_screen, |v| msg( + XrefAttachMsg::InsertOnScreen(v) + )), + number_field("X:", &state.insert[0], insert_enabled, |v| msg( + XrefAttachMsg::Insert(0, v) + )), + number_field("Y:", &state.insert[1], insert_enabled, |v| msg( + XrefAttachMsg::Insert(1, v) + )), + number_field("Z:", &state.insert[2], insert_enabled, |v| msg( + XrefAttachMsg::Insert(2, v) + )), + ] + .spacing(5), + Length::Shrink, + ); + let middle = column![scale, insertion] + .spacing(6) + .width(Length::FillPortion(1)); + + // ── Path type, rotation, block unit ───────────────────────────────── + let path_type = group( + t!("Path type").into_owned(), + pick_list(Some(state.path_type), PathTypeChoice::ALL, |choice| choice.to_string()) + .on_select(|v| msg(XrefAttachMsg::PathType(v))) + .text_size(11) + .padding([3, 6]) + .width(Fill), + Length::Shrink, + ); + let mut angle = text_input("", &state.rotation) + .size(11) + .padding([3, 6]) + .width(Fill) + .style(field_style); + if !state.rotation_on_screen { + angle = angle.on_input(|v| msg(XrefAttachMsg::Rotation(v))); + } + let rotation = group( + t!("Rotation").into_owned(), + column![ + labeled_checkbox(t!("Specify On-screen"), state.rotation_on_screen, |v| msg( + XrefAttachMsg::RotationOnScreen(v) + )), + row![ + text(t!("Angle:")) + .size(11) + .style(muted_style) + .width(Length::Fixed(44.0)), + angle + ] + .spacing(6) + .align_y(iced::Center), + ] + .spacing(5), + Length::Shrink, + ); + let block_unit = group( + t!("Block Unit").into_owned(), + column![ + read_only_field(t!("Unit:").into_owned(), &state.unit_label, 52.0), + read_only_field(t!("Factor:").into_owned(), &state.unit_factor, 52.0), + ] + .spacing(5), + Length::Shrink, + ); + let right = column![path_type, rotation, block_unit] + .spacing(6) + .width(Length::FillPortion(1)); + + let body = row![left, middle, right].spacing(6).width(Fill); + + // ── Details ───────────────────────────────────────────────────────── + let details: Element<'a, Message> = if state.details { + group( + t!("Location").into_owned(), + column![ + read_only_field(t!("Found in:").into_owned(), &state.found_in, 92.0), + read_only_field(t!("Saved path:").into_owned(), &state.saved_path, 92.0), + ] + .spacing(5), + Length::Shrink, + ) + } else { + Space::new().height(0).into() + }; + + // ── Footer ────────────────────────────────────────────────────────── + let details_label = if state.details { + t!("Hide Details") + } else { + t!("Show Details") + }; + let footer = row![ + button(text(details_label).size(11)) + .on_press(msg(XrefAttachMsg::Details(!state.details))) + .style(button_style(false)) + .padding([4, 12]), + Space::new().width(Fill), + button(text(t!("OK")).size(11)) + .on_press(msg(XrefAttachMsg::Apply)) + .style(button_style(true)) + .padding([4, 16]), + button(text(t!("Cancel")).size(11)) + .on_press(Message::CloseModal) + .style(button_style(false)) + .padding([4, 12]), + button(text(t!("Help")).size(11)) + .on_press(msg(XrefAttachMsg::Help)) + .style(button_style(false)) + .padding([4, 12]), + ] + .spacing(6) + .align_y(iced::Center); + + column![name_section, body, details, footer] + .spacing(6) + .padding([8, 10]) + .width(sizing.width) + .into() +}