From d19c7976855e7572f9546d27b8523ef5024639b7 Mon Sep 17 00:00:00 2001 From: Pictus08 Date: Wed, 14 Oct 2026 20:46:31 -0300 Subject: [PATCH] aliases: import AutoCAD PGP files and support UCS-follow workflows --- docs/pgp-import.md | 40 +++++++++ docs/ucs-follow.md | 33 ++++++++ locales/ar-SA/opencadstudio.ftl | 10 +++ locales/bg-BG/opencadstudio.ftl | 10 +++ locales/cs-CZ/opencadstudio.ftl | 10 +++ locales/de-DE/opencadstudio.ftl | 10 +++ locales/el-GR/opencadstudio.ftl | 10 +++ locales/en-US/opencadstudio.ftl | 12 ++- locales/es-ES/opencadstudio.ftl | 12 ++- locales/fi-FI/opencadstudio.ftl | 10 +++ locales/fr-FR/opencadstudio.ftl | 10 +++ locales/hi-IN/opencadstudio.ftl | 10 +++ locales/hu-HU/opencadstudio.ftl | 10 +++ locales/it-IT/opencadstudio.ftl | 10 +++ locales/ja-JP/opencadstudio.ftl | 10 +++ locales/ko-KR/opencadstudio.ftl | 10 +++ locales/lo-LA/opencadstudio.ftl | 10 +++ locales/nl-NL/opencadstudio.ftl | 10 +++ locales/pl-PL/opencadstudio.ftl | 10 +++ locales/pt-BR/opencadstudio.ftl | 10 +++ locales/ru-RU/opencadstudio.ftl | 10 +++ locales/tr-TR/opencadstudio.ftl | 10 +++ locales/zh-CN/opencadstudio.ftl | 10 +++ locales/zh-TW/opencadstudio.ftl | 10 +++ src/app/alias.rs | 70 ++++++++++++---- src/app/alias/pgp.rs | 133 +++++++++++++++++++++++++++++ src/app/commands/layerprops.rs | 2 +- src/app/commands/mod.rs | 10 +++ src/app/commands/styleprops.rs | 1 + src/app/commands/ucsfollow.rs | 144 ++++++++++++++++++++++++++++++++ src/app/mod.rs | 6 ++ src/app/update/mod.rs | 24 ++++++ src/app/update/viewport.rs | 7 +- src/app/view/modal.rs | 1 + src/command.rs | 8 ++ src/locale_catalog.rs | 8 ++ src/modules/view/ucs_cmd.rs | 77 ++++++++++++++++- src/scene/camera_ops.rs | 45 ++++++++++ src/ui/window/alias_editor.rs | 15 ++-- 39 files changed, 818 insertions(+), 30 deletions(-) create mode 100644 docs/pgp-import.md create mode 100644 docs/ucs-follow.md create mode 100644 src/app/alias/pgp.rs create mode 100644 src/app/commands/ucsfollow.rs diff --git a/docs/pgp-import.md b/docs/pgp-import.md new file mode 100644 index 000000000..196934b9c --- /dev/null +++ b/docs/pgp-import.md @@ -0,0 +1,40 @@ +# Importing AutoCAD command aliases (.pgp) + +1. Run `ALIASEDIT` in OpenCADStudio. +2. Choose **Import PGP…** and select an AutoCAD or OpenCAD `.pgp` file. +3. Review the working rows. Supported imported aliases override matching + existing aliases; other existing aliases are retained. +4. Choose **Apply** to activate and save the rows, or close and discard them. + +There is no automatic discovery or loading of AutoCAD profiles. Importing only +changes the editor's working rows; it never changes the active table until +Apply. The source PGP is not modified. + +## Format and compatibility + +- UTF-8 (with or without BOM) and legacy Windows-1252 are accepted. +- `;` comments, whitespace padding and case-insensitive names are supported. +- The last definition of an alias wins, including user overrides at the end. +- Common Spanish command targets are translated, for example `COPIA` to + `COPY`, `EMPALME` to `FILLET` and `SCP` to `UCS`. Alias keys are not translated. +- Only registered command targets are imported. Unsupported commands and + unmapped translations are counted in the editor's summary and do not replace + existing aliases. Full diagnostics are logged, rather than flooding the + command line with errors. This does not implement missing AutoCAD/Civil 3D + commands or add support for every AutoCAD language. +- External-program definitions are ignored and never executed. + +## Synthetic example + +This minimal example is authored for testing, not copied from an Autodesk PGP: + +```text +; Last definition wins; imported aliases remain drafts until Apply. +E, *BORRA +E, *EMPALME +CC, *COPIA +SQ, *SCP +``` + +After importing, `E` resolves to `FILLET`, `CC` to `COPY`, and `SQ` to `UCS`. +No proprietary PGP or user profile is included with this feature. diff --git a/docs/ucs-follow.md b/docs/ucs-follow.md new file mode 100644 index 000000000..7b19d47df --- /dev/null +++ b/docs/ucs-follow.md @@ -0,0 +1,33 @@ +# UCS input and UCSFOLLOW + +## Direct point input + +After running `UCS`, a click or typed coordinate can immediately supply the +new origin, followed by a point on the positive X axis and a point in the +positive XY plane. Explicit `3P` input continues to work. `Origin` / `O` still +changes only the origin with one point. + +The interactive option prompt also accepts Spanish keywords, including +`Universal`, `Vista`, `Cara`, `Objeto`, `Origen`, `Guardar`, `Eliminar`, +and `3p` / `3 puntos`, as well as international English option keywords. + +## UCSFOLLOW + +- `UCSFOLLOW 0`: keep the camera orientation when the UCS changes. +- `UCSFOLLOW 1`: align the view immediately with the current UCS XY plane and + follow later UCS changes. Re-entering `1` realigns after a manual orbit. +- Bare `UCSFOLLOW` prompts for the value; Enter keeps the current setting. +- `SETVAR UCSFOLLOW 0` and `SETVAR UCSFOLLOW 1` work too. + +The value belongs to the model pane / floating viewport and uses the drawing's +existing VPORT/VIEWPORT fields, not a global application preference. Paper +space itself does not follow the UCS: enter a model viewport first. BEDIT's +special block-reframing path is intentionally left unchanged. + +The immediate alignment on entering `1` is deliberate: users can define their +UCS first, then enable following without another UCS change. + +A user-defined alias such as `UF,*UCSFOLLOW` can invoke this command. It is not +installed automatically. A PGP imported before this command was implemented +must be imported again (or edited manually) to add its previously skipped +alias; activating it still requires Apply. diff --git a/locales/ar-SA/opencadstudio.ftl b/locales/ar-SA/opencadstudio.ftl index a6848f12a..83175dbf1 100644 --- a/locales/ar-SA/opencadstudio.ftl +++ b/locales/ar-SA/opencadstudio.ftl @@ -6599,3 +6599,13 @@ measurement = .area-preview = { $current } Preview total area = { $total } +pgp-import = + .button = Import PGP… + .filter = Command aliases (*.pgp) + .empty = PGP: no valid aliases found. The working rows were not changed. + .summary = PGP: __ocs_fmt_0__ alias(es) loaded for review; __ocs_fmt_1__ unsupported command(s) skipped. No changes activated. Apply to save. + +ucs-follow = + .prompt = UCSFOLLOW: enter 0 (keep view) or 1 (follow UCS) <__ocs_fmt_0__>: + .paper-space = UCSFOLLOW: activate a model viewport; paper space cannot follow the UCS. + .invalid = UCSFOLLOW: only 0 or 1 is allowed. diff --git a/locales/bg-BG/opencadstudio.ftl b/locales/bg-BG/opencadstudio.ftl index 0649b09aa..cc20285c3 100644 --- a/locales/bg-BG/opencadstudio.ftl +++ b/locales/bg-BG/opencadstudio.ftl @@ -6587,3 +6587,13 @@ measurement = .area-preview = { $current } Preview total area = { $total } +pgp-import = + .button = Import PGP… + .filter = Command aliases (*.pgp) + .empty = PGP: no valid aliases found. The working rows were not changed. + .summary = PGP: __ocs_fmt_0__ alias(es) loaded for review; __ocs_fmt_1__ unsupported command(s) skipped. No changes activated. Apply to save. + +ucs-follow = + .prompt = UCSFOLLOW: enter 0 (keep view) or 1 (follow UCS) <__ocs_fmt_0__>: + .paper-space = UCSFOLLOW: activate a model viewport; paper space cannot follow the UCS. + .invalid = UCSFOLLOW: only 0 or 1 is allowed. diff --git a/locales/cs-CZ/opencadstudio.ftl b/locales/cs-CZ/opencadstudio.ftl index 5e5ebc79c..66afe10f6 100644 --- a/locales/cs-CZ/opencadstudio.ftl +++ b/locales/cs-CZ/opencadstudio.ftl @@ -6584,3 +6584,13 @@ measurement = .area-preview = { $current } Preview total area = { $total } +pgp-import = + .button = Import PGP… + .filter = Command aliases (*.pgp) + .empty = PGP: no valid aliases found. The working rows were not changed. + .summary = PGP: __ocs_fmt_0__ alias(es) loaded for review; __ocs_fmt_1__ unsupported command(s) skipped. No changes activated. Apply to save. + +ucs-follow = + .prompt = UCSFOLLOW: enter 0 (keep view) or 1 (follow UCS) <__ocs_fmt_0__>: + .paper-space = UCSFOLLOW: activate a model viewport; paper space cannot follow the UCS. + .invalid = UCSFOLLOW: only 0 or 1 is allowed. diff --git a/locales/de-DE/opencadstudio.ftl b/locales/de-DE/opencadstudio.ftl index 0031cdfcd..61eef3101 100644 --- a/locales/de-DE/opencadstudio.ftl +++ b/locales/de-DE/opencadstudio.ftl @@ -6585,3 +6585,13 @@ measurement = .area-preview = { $current } Preview total area = { $total } +pgp-import = + .button = Import PGP… + .filter = Command aliases (*.pgp) + .empty = PGP: no valid aliases found. The working rows were not changed. + .summary = PGP: __ocs_fmt_0__ alias(es) loaded for review; __ocs_fmt_1__ unsupported command(s) skipped. No changes activated. Apply to save. + +ucs-follow = + .prompt = UCSFOLLOW: enter 0 (keep view) or 1 (follow UCS) <__ocs_fmt_0__>: + .paper-space = UCSFOLLOW: activate a model viewport; paper space cannot follow the UCS. + .invalid = UCSFOLLOW: only 0 or 1 is allowed. diff --git a/locales/el-GR/opencadstudio.ftl b/locales/el-GR/opencadstudio.ftl index dc8b4286c..07d706269 100644 --- a/locales/el-GR/opencadstudio.ftl +++ b/locales/el-GR/opencadstudio.ftl @@ -6587,3 +6587,13 @@ measurement = .area-preview = { $current } Preview total area = { $total } +pgp-import = + .button = Import PGP… + .filter = Command aliases (*.pgp) + .empty = PGP: no valid aliases found. The working rows were not changed. + .summary = PGP: __ocs_fmt_0__ alias(es) loaded for review; __ocs_fmt_1__ unsupported command(s) skipped. No changes activated. Apply to save. + +ucs-follow = + .prompt = UCSFOLLOW: enter 0 (keep view) or 1 (follow UCS) <__ocs_fmt_0__>: + .paper-space = UCSFOLLOW: activate a model viewport; paper space cannot follow the UCS. + .invalid = UCSFOLLOW: only 0 or 1 is allowed. diff --git a/locales/en-US/opencadstudio.ftl b/locales/en-US/opencadstudio.ftl index c8736d86f..e241beee5 100644 --- a/locales/en-US/opencadstudio.ftl +++ b/locales/en-US/opencadstudio.ftl @@ -4964,7 +4964,7 @@ view = .cmd-not-yet-implemented = __ocs_fmt_0__: not yet implemented. .viewport-s-in-layout = __ocs_fmt_0__ viewport(s) in layout "__ocs_fmt_1__": - .ucs-option-world-view-3point-z-x-y-origin-save-delete-or-name = UCS option [Face/OBject/World/View/3Point/Z/X/Y/Origin/Save/Delete] or name: + .ucs-option-world-view-3point-z-x-y-origin-save-delete-or-name = UCS specify new origin or [Face/OBject/World/View/3Point/Z/X/Y/Origin/Save/Delete] or name: .ucs-specify-new-origin = UCS specify new origin: .ucs-specify-point-on-positive-x-axis = UCS specify point on positive X axis: .ucs-specify-point-in-positive-xy-plane = UCS specify point in positive XY plane: @@ -6586,3 +6586,13 @@ measurement = .area-preview = { $current } Preview total area = { $total } +pgp-import = + .button = Import PGP… + .filter = Command aliases (*.pgp) + .empty = PGP: no valid aliases found. The working rows were not changed. + .summary = PGP: __ocs_fmt_0__ alias(es) loaded for review; __ocs_fmt_1__ unsupported command(s) skipped. No changes activated. Apply to save. + +ucs-follow = + .prompt = UCSFOLLOW: enter 0 (keep view) or 1 (follow UCS) <__ocs_fmt_0__>: + .paper-space = UCSFOLLOW: activate a model viewport; paper space cannot follow the UCS. + .invalid = UCSFOLLOW: only 0 or 1 is allowed. diff --git a/locales/es-ES/opencadstudio.ftl b/locales/es-ES/opencadstudio.ftl index 965dc1d78..9a4f1c86b 100644 --- a/locales/es-ES/opencadstudio.ftl +++ b/locales/es-ES/opencadstudio.ftl @@ -4965,7 +4965,7 @@ view = .cmd-not-yet-implemented = __ocs_fmt_0__: aún no implementado. .viewport-s-in-layout = __ocs_fmt_0__ ventanas gráficas en la presentación «__ocs_fmt_1__»: - .ucs-option-world-view-3point-z-x-y-origin-save-delete-or-name = UCS { common.specify }: { action.options } [{ common.world }/{ ribbon-tab.view }/{ draw.value-3-point }/Z/X/Y/{ common.origin }/{ common.save }/{ common.delete }] ({ common.name-title-case }): + .ucs-option-world-view-3point-z-x-y-origin-save-delete-or-name = UCS Precisa el nuevo origen o [Cara/Objeto/Universal/Vista/3p/Z/X/Y/Origen/Guardar/Eliminar] o nombre: .ucs-specify-new-origin = UCS { common.specify }: { common.new-origin }: .ucs-specify-point-on-positive-x-axis = UCS { common.specify }: X · { common.positive-direction } · { common.point }: .ucs-specify-point-in-positive-xy-plane = UCS { common.specify }: XY · { common.positive-direction } · { common.point }: @@ -6587,3 +6587,13 @@ measurement = .area-preview = { $current } Preview total area = { $total } +pgp-import = + .button = Importar PGP… + .filter = Alias de comandos (*.pgp) + .empty = PGP: no se encontraron alias válidos. No se modificó el borrador. + .summary = PGP: __ocs_fmt_0__ atajos cargados para revisar; __ocs_fmt_1__ comandos no compatibles omitidos. No se activó ningún cambio. Pulsa Aplicar para guardar. + +ucs-follow = + .prompt = UCSFOLLOW: introduce 0 (conservar vista) o 1 (seguir SCP) <__ocs_fmt_0__>: + .paper-space = UCSFOLLOW: activa una ventana de modelo; no se aplica al espacio papel. + .invalid = UCSFOLLOW: solo se permite 0 o 1. diff --git a/locales/fi-FI/opencadstudio.ftl b/locales/fi-FI/opencadstudio.ftl index 1d58ef2dd..0ec188498 100644 --- a/locales/fi-FI/opencadstudio.ftl +++ b/locales/fi-FI/opencadstudio.ftl @@ -6584,3 +6584,13 @@ measurement = .area-preview = { $current } Preview total area = { $total } +pgp-import = + .button = Import PGP… + .filter = Command aliases (*.pgp) + .empty = PGP: no valid aliases found. The working rows were not changed. + .summary = PGP: __ocs_fmt_0__ alias(es) loaded for review; __ocs_fmt_1__ unsupported command(s) skipped. No changes activated. Apply to save. + +ucs-follow = + .prompt = UCSFOLLOW: enter 0 (keep view) or 1 (follow UCS) <__ocs_fmt_0__>: + .paper-space = UCSFOLLOW: activate a model viewport; paper space cannot follow the UCS. + .invalid = UCSFOLLOW: only 0 or 1 is allowed. diff --git a/locales/fr-FR/opencadstudio.ftl b/locales/fr-FR/opencadstudio.ftl index 646c4da9f..ae9d437ba 100644 --- a/locales/fr-FR/opencadstudio.ftl +++ b/locales/fr-FR/opencadstudio.ftl @@ -6587,3 +6587,13 @@ measurement = .area-preview = { $current } Preview total area = { $total } +pgp-import = + .button = Import PGP… + .filter = Command aliases (*.pgp) + .empty = PGP: no valid aliases found. The working rows were not changed. + .summary = PGP: __ocs_fmt_0__ alias(es) loaded for review; __ocs_fmt_1__ unsupported command(s) skipped. No changes activated. Apply to save. + +ucs-follow = + .prompt = UCSFOLLOW: enter 0 (keep view) or 1 (follow UCS) <__ocs_fmt_0__>: + .paper-space = UCSFOLLOW: activate a model viewport; paper space cannot follow the UCS. + .invalid = UCSFOLLOW: only 0 or 1 is allowed. diff --git a/locales/hi-IN/opencadstudio.ftl b/locales/hi-IN/opencadstudio.ftl index 574d0a2a8..db5a95696 100644 --- a/locales/hi-IN/opencadstudio.ftl +++ b/locales/hi-IN/opencadstudio.ftl @@ -6575,3 +6575,13 @@ measurement = .area-preview = { $current } Preview total area = { $total } +pgp-import = + .button = Import PGP… + .filter = Command aliases (*.pgp) + .empty = PGP: no valid aliases found. The working rows were not changed. + .summary = PGP: __ocs_fmt_0__ alias(es) loaded for review; __ocs_fmt_1__ unsupported command(s) skipped. No changes activated. Apply to save. + +ucs-follow = + .prompt = UCSFOLLOW: enter 0 (keep view) or 1 (follow UCS) <__ocs_fmt_0__>: + .paper-space = UCSFOLLOW: activate a model viewport; paper space cannot follow the UCS. + .invalid = UCSFOLLOW: only 0 or 1 is allowed. diff --git a/locales/hu-HU/opencadstudio.ftl b/locales/hu-HU/opencadstudio.ftl index b3515fd16..a31cd56a4 100644 --- a/locales/hu-HU/opencadstudio.ftl +++ b/locales/hu-HU/opencadstudio.ftl @@ -6571,3 +6571,13 @@ measurement = .area-preview = { $current } Előzetes összterület = { $total } +pgp-import = + .button = Import PGP… + .filter = Command aliases (*.pgp) + .empty = PGP: no valid aliases found. The working rows were not changed. + .summary = PGP: __ocs_fmt_0__ alias(es) loaded for review; __ocs_fmt_1__ unsupported command(s) skipped. No changes activated. Apply to save. + +ucs-follow = + .prompt = UCSFOLLOW: enter 0 (keep view) or 1 (follow UCS) <__ocs_fmt_0__>: + .paper-space = UCSFOLLOW: activate a model viewport; paper space cannot follow the UCS. + .invalid = UCSFOLLOW: only 0 or 1 is allowed. diff --git a/locales/it-IT/opencadstudio.ftl b/locales/it-IT/opencadstudio.ftl index b0c8d9e9b..1d1563b99 100644 --- a/locales/it-IT/opencadstudio.ftl +++ b/locales/it-IT/opencadstudio.ftl @@ -6586,3 +6586,13 @@ measurement = .area-preview = { $current } Preview total area = { $total } +pgp-import = + .button = Import PGP… + .filter = Command aliases (*.pgp) + .empty = PGP: no valid aliases found. The working rows were not changed. + .summary = PGP: __ocs_fmt_0__ alias(es) loaded for review; __ocs_fmt_1__ unsupported command(s) skipped. No changes activated. Apply to save. + +ucs-follow = + .prompt = UCSFOLLOW: enter 0 (keep view) or 1 (follow UCS) <__ocs_fmt_0__>: + .paper-space = UCSFOLLOW: activate a model viewport; paper space cannot follow the UCS. + .invalid = UCSFOLLOW: only 0 or 1 is allowed. diff --git a/locales/ja-JP/opencadstudio.ftl b/locales/ja-JP/opencadstudio.ftl index c4fd50f57..26de504d7 100644 --- a/locales/ja-JP/opencadstudio.ftl +++ b/locales/ja-JP/opencadstudio.ftl @@ -6575,3 +6575,13 @@ measurement = .area-preview = { $current } Preview total area = { $total } +pgp-import = + .button = Import PGP… + .filter = Command aliases (*.pgp) + .empty = PGP: no valid aliases found. The working rows were not changed. + .summary = PGP: __ocs_fmt_0__ alias(es) loaded for review; __ocs_fmt_1__ unsupported command(s) skipped. No changes activated. Apply to save. + +ucs-follow = + .prompt = UCSFOLLOW: enter 0 (keep view) or 1 (follow UCS) <__ocs_fmt_0__>: + .paper-space = UCSFOLLOW: activate a model viewport; paper space cannot follow the UCS. + .invalid = UCSFOLLOW: only 0 or 1 is allowed. diff --git a/locales/ko-KR/opencadstudio.ftl b/locales/ko-KR/opencadstudio.ftl index 60066bc54..c43410f04 100644 --- a/locales/ko-KR/opencadstudio.ftl +++ b/locales/ko-KR/opencadstudio.ftl @@ -6586,3 +6586,13 @@ measurement = .area-preview = { $current } Preview total area = { $total } +pgp-import = + .button = Import PGP… + .filter = Command aliases (*.pgp) + .empty = PGP: no valid aliases found. The working rows were not changed. + .summary = PGP: __ocs_fmt_0__ alias(es) loaded for review; __ocs_fmt_1__ unsupported command(s) skipped. No changes activated. Apply to save. + +ucs-follow = + .prompt = UCSFOLLOW: enter 0 (keep view) or 1 (follow UCS) <__ocs_fmt_0__>: + .paper-space = UCSFOLLOW: activate a model viewport; paper space cannot follow the UCS. + .invalid = UCSFOLLOW: only 0 or 1 is allowed. diff --git a/locales/lo-LA/opencadstudio.ftl b/locales/lo-LA/opencadstudio.ftl index bff99fcdc..8f51fc302 100644 --- a/locales/lo-LA/opencadstudio.ftl +++ b/locales/lo-LA/opencadstudio.ftl @@ -6587,3 +6587,13 @@ measurement = .area-preview = { $current } Preview total area = { $total } +pgp-import = + .button = Import PGP… + .filter = Command aliases (*.pgp) + .empty = PGP: no valid aliases found. The working rows were not changed. + .summary = PGP: __ocs_fmt_0__ alias(es) loaded for review; __ocs_fmt_1__ unsupported command(s) skipped. No changes activated. Apply to save. + +ucs-follow = + .prompt = UCSFOLLOW: enter 0 (keep view) or 1 (follow UCS) <__ocs_fmt_0__>: + .paper-space = UCSFOLLOW: activate a model viewport; paper space cannot follow the UCS. + .invalid = UCSFOLLOW: only 0 or 1 is allowed. diff --git a/locales/nl-NL/opencadstudio.ftl b/locales/nl-NL/opencadstudio.ftl index ea6da2744..761118ba4 100644 --- a/locales/nl-NL/opencadstudio.ftl +++ b/locales/nl-NL/opencadstudio.ftl @@ -6585,3 +6585,13 @@ measurement = .area-preview = { $current } Preview total area = { $total } +pgp-import = + .button = Import PGP… + .filter = Command aliases (*.pgp) + .empty = PGP: no valid aliases found. The working rows were not changed. + .summary = PGP: __ocs_fmt_0__ alias(es) loaded for review; __ocs_fmt_1__ unsupported command(s) skipped. No changes activated. Apply to save. + +ucs-follow = + .prompt = UCSFOLLOW: enter 0 (keep view) or 1 (follow UCS) <__ocs_fmt_0__>: + .paper-space = UCSFOLLOW: activate a model viewport; paper space cannot follow the UCS. + .invalid = UCSFOLLOW: only 0 or 1 is allowed. diff --git a/locales/pl-PL/opencadstudio.ftl b/locales/pl-PL/opencadstudio.ftl index 93bbda0ac..16d72a254 100644 --- a/locales/pl-PL/opencadstudio.ftl +++ b/locales/pl-PL/opencadstudio.ftl @@ -6586,3 +6586,13 @@ measurement = .area-preview = { $current } Preview total area = { $total } +pgp-import = + .button = Import PGP… + .filter = Command aliases (*.pgp) + .empty = PGP: no valid aliases found. The working rows were not changed. + .summary = PGP: __ocs_fmt_0__ alias(es) loaded for review; __ocs_fmt_1__ unsupported command(s) skipped. No changes activated. Apply to save. + +ucs-follow = + .prompt = UCSFOLLOW: enter 0 (keep view) or 1 (follow UCS) <__ocs_fmt_0__>: + .paper-space = UCSFOLLOW: activate a model viewport; paper space cannot follow the UCS. + .invalid = UCSFOLLOW: only 0 or 1 is allowed. diff --git a/locales/pt-BR/opencadstudio.ftl b/locales/pt-BR/opencadstudio.ftl index 097102537..3ffec2a7b 100644 --- a/locales/pt-BR/opencadstudio.ftl +++ b/locales/pt-BR/opencadstudio.ftl @@ -6587,3 +6587,13 @@ measurement = .area-preview = { $current } Preview total area = { $total } +pgp-import = + .button = Import PGP… + .filter = Command aliases (*.pgp) + .empty = PGP: no valid aliases found. The working rows were not changed. + .summary = PGP: __ocs_fmt_0__ alias(es) loaded for review; __ocs_fmt_1__ unsupported command(s) skipped. No changes activated. Apply to save. + +ucs-follow = + .prompt = UCSFOLLOW: enter 0 (keep view) or 1 (follow UCS) <__ocs_fmt_0__>: + .paper-space = UCSFOLLOW: activate a model viewport; paper space cannot follow the UCS. + .invalid = UCSFOLLOW: only 0 or 1 is allowed. diff --git a/locales/ru-RU/opencadstudio.ftl b/locales/ru-RU/opencadstudio.ftl index e994a5dcd..3840440f1 100644 --- a/locales/ru-RU/opencadstudio.ftl +++ b/locales/ru-RU/opencadstudio.ftl @@ -6588,3 +6588,13 @@ measurement = .area-preview = { $current } Preview total area = { $total } +pgp-import = + .button = Import PGP… + .filter = Command aliases (*.pgp) + .empty = PGP: no valid aliases found. The working rows were not changed. + .summary = PGP: __ocs_fmt_0__ alias(es) loaded for review; __ocs_fmt_1__ unsupported command(s) skipped. No changes activated. Apply to save. + +ucs-follow = + .prompt = UCSFOLLOW: enter 0 (keep view) or 1 (follow UCS) <__ocs_fmt_0__>: + .paper-space = UCSFOLLOW: activate a model viewport; paper space cannot follow the UCS. + .invalid = UCSFOLLOW: only 0 or 1 is allowed. diff --git a/locales/tr-TR/opencadstudio.ftl b/locales/tr-TR/opencadstudio.ftl index 9fd2212c5..2733014ca 100644 --- a/locales/tr-TR/opencadstudio.ftl +++ b/locales/tr-TR/opencadstudio.ftl @@ -6508,3 +6508,13 @@ measurement = .area-preview = { $current } Preview total area = { $total } +pgp-import = + .button = Import PGP… + .filter = Command aliases (*.pgp) + .empty = PGP: no valid aliases found. The working rows were not changed. + .summary = PGP: __ocs_fmt_0__ alias(es) loaded for review; __ocs_fmt_1__ unsupported command(s) skipped. No changes activated. Apply to save. + +ucs-follow = + .prompt = UCSFOLLOW: enter 0 (keep view) or 1 (follow UCS) <__ocs_fmt_0__>: + .paper-space = UCSFOLLOW: activate a model viewport; paper space cannot follow the UCS. + .invalid = UCSFOLLOW: only 0 or 1 is allowed. diff --git a/locales/zh-CN/opencadstudio.ftl b/locales/zh-CN/opencadstudio.ftl index f57c72aae..81edca569 100644 --- a/locales/zh-CN/opencadstudio.ftl +++ b/locales/zh-CN/opencadstudio.ftl @@ -6570,3 +6570,13 @@ measurement = .area-preview = { $current } Preview total area = { $total } +pgp-import = + .button = Import PGP… + .filter = Command aliases (*.pgp) + .empty = PGP: no valid aliases found. The working rows were not changed. + .summary = PGP: __ocs_fmt_0__ alias(es) loaded for review; __ocs_fmt_1__ unsupported command(s) skipped. No changes activated. Apply to save. + +ucs-follow = + .prompt = UCSFOLLOW: enter 0 (keep view) or 1 (follow UCS) <__ocs_fmt_0__>: + .paper-space = UCSFOLLOW: activate a model viewport; paper space cannot follow the UCS. + .invalid = UCSFOLLOW: only 0 or 1 is allowed. diff --git a/locales/zh-TW/opencadstudio.ftl b/locales/zh-TW/opencadstudio.ftl index 971c7df49..b7941d3ed 100644 --- a/locales/zh-TW/opencadstudio.ftl +++ b/locales/zh-TW/opencadstudio.ftl @@ -6582,3 +6582,13 @@ measurement = .area-preview = { $current } Preview total area = { $total } +pgp-import = + .button = Import PGP… + .filter = Command aliases (*.pgp) + .empty = PGP: no valid aliases found. The working rows were not changed. + .summary = PGP: __ocs_fmt_0__ alias(es) loaded for review; __ocs_fmt_1__ unsupported command(s) skipped. No changes activated. Apply to save. + +ucs-follow = + .prompt = UCSFOLLOW: enter 0 (keep view) or 1 (follow UCS) <__ocs_fmt_0__>: + .paper-space = UCSFOLLOW: activate a model viewport; paper space cannot follow the UCS. + .invalid = UCSFOLLOW: only 0 or 1 is allowed. diff --git a/src/app/alias.rs b/src/app/alias.rs index 19551287f..f23af0d6a 100644 --- a/src/app/alias.rs +++ b/src/app/alias.rs @@ -20,6 +20,7 @@ use super::OpenCADStudio; use rustc_hash::FxHashMap; +mod pgp; #[cfg(not(target_arch = "wasm32"))] use std::path::PathBuf; @@ -144,23 +145,7 @@ fn migrate_aliases(stored: &mut FxHashMap, from_version: u32) -> /// lines and `;` comments; tolerates a leading `*` on the command and arbitrary /// whitespace padding. fn parse_pgp(body: &str) -> FxHashMap { - let mut map = FxHashMap::default(); - for line in body.lines() { - let line = line.trim(); - if line.is_empty() || line.starts_with(';') { - continue; - } - let Some((alias, cmd)) = line.split_once(',') else { - continue; - }; - let alias = alias.trim().to_uppercase(); - let cmd = cmd.trim().trim_start_matches('*').trim().to_uppercase(); - if alias.is_empty() || cmd.is_empty() { - continue; - } - map.insert(alias, cmd); - } - map + pgp::parse(body).into_iter().collect() } /// Serialize an `alias → command` map to `.pgp` text with a header and aligned @@ -300,6 +285,41 @@ pub(super) fn save_map(map: &FxHashMap) -> std::io::Result<()> { } impl OpenCADStudio { + /// Merge into the working buffer only. Apply persists; closing can discard. + pub(super) fn import_alias_pgp(&mut self, bytes: &[u8]) { + let parsed = pgp::parse(&pgp::decode(bytes)); + let known = crate::command::all_registered_command_names(); + let mut rows: FxHashMap = self.alias_editor_rows.iter() + .filter(|(a, c)| !a.is_empty() && !c.is_empty()).cloned().collect(); + let mut imported = 0; + let mut unsupported = std::collections::BTreeSet::new(); + for (alias, target) in parsed { + let command = pgp::canonical(&target); + if known.iter().any(|name| name.eq_ignore_ascii_case(&command)) { + rows.insert(alias, command); + imported += 1; + } else { + unsupported.insert(target); + } + } + if imported > 0 { + self.alias_editor_rows = rows.into_iter().collect(); + self.alias_editor_rows.sort_by(|a, b| a.0.cmp(&b.0)); + self.alias_pending_add = false; + self.alias_reset_confirm = false; + self.alias_close_confirm = false; + } + self.alias_import_report = if imported == 0 && unsupported.is_empty() { + crate::t!("PGP: no valid aliases found. The working rows were not changed.").into_owned() + } else { + crate::tf!("PGP: {} alias(es) loaded for review; {} unsupported command(s) skipped. No changes activated. Apply to save.", imported, unsupported.len()).into_owned() + }; + self.command_line.push_info(&self.alias_import_report); + // Unsupported AutoCAD/Civil 3D commands are an expected compatibility + // limitation, not import errors. Keep full diagnostics out of the UI. + log::info!("PGP import: {imported} aliases; unsupported targets: {unsupported:?}"); + } + /// Rewrite the leading command token through the alias table, leaving any /// arguments after the first whitespace untouched. Returns `None` when the /// verb is not an alias, so the caller passes the original string through @@ -418,6 +438,22 @@ impl OpenCADStudio { mod tests { use super::*; + #[test] + fn import_merges_supported_aliases_without_activating_them() { + let mut app = OpenCADStudio::new_for_test(); + let original = app.command_aliases.clone(); + app.alias_editor_rows = vec![("KEEP".into(), "LINE".into()), ("E".into(), "ERASE".into())]; + app.import_alias_pgp(b"E,*BORRA\nE,*EMPALME\nSQ,*SCP\nCIVIL,*AECCUNSUPPORTED\nNOTEPAD,START NOTEPAD,1,,\n"); + assert!(app.alias_editor_rows.contains(&("E".into(), "FILLET".into()))); + assert!(app.alias_editor_rows.contains(&("SQ".into(), "UCS".into()))); + assert!(app.alias_editor_rows.contains(&("KEEP".into(), "LINE".into()))); + assert!(!app.alias_editor_rows.iter().any(|(a, _)| a == "CIVIL" || a == "NOTEPAD")); + assert_eq!(app.command_aliases, original, "import must not activate or persist aliases"); + let staged = app.alias_editor_rows.clone(); + app.import_alias_pgp(b"; no aliases\n"); + assert_eq!(app.alias_editor_rows, staged); + } + fn map(pairs: &[(&str, &str)]) -> FxHashMap { pairs .iter() diff --git a/src/app/alias/pgp.rs b/src/app/alias/pgp.rs new file mode 100644 index 000000000..a33c5e5ed --- /dev/null +++ b/src/app/alias/pgp.rs @@ -0,0 +1,133 @@ +//! AutoCAD PGP interoperability. Never interpret external-command definitions. +use std::collections::BTreeMap; + +/// UTF-8 (including BOM), or the Windows-1252 encoding used by legacy acad.pgp. +pub(super) fn decode(bytes: &[u8]) -> String { + if let Ok(text) = std::str::from_utf8(bytes) { + return text.trim_start_matches('\u{feff}').to_owned(); + } + const HIGH: [char; 32] = [ + '€', '\u{81}', '‚', 'ƒ', '„', '…', '†', '‡', 'ˆ', '‰', 'Š', '‹', 'Œ', '\u{8d}', 'Ž', '\u{8f}', + '\u{90}', '‘', '’', '“', '”', '•', '–', '—', '˜', '™', 'š', '›', 'œ', '\u{9d}', 'ž', 'Ÿ', + ]; + bytes.iter().map(|&b| match b { + 0x80..=0x9f => HIGH[(b - 0x80) as usize], + _ => char::from(b), + }).collect() +} + +/// Last definition wins, as in AutoCAD's user-defined aliases section. +/// Accept OpenCAD's optional star too, but reject all multi-field shell rows. +pub(super) fn parse(body: &str) -> BTreeMap { + let mut aliases = BTreeMap::new(); + for line in body.trim_start_matches('\u{feff}').lines() { + let line = line.split(';').next().unwrap_or("").trim(); + let Some((alias, target)) = line.split_once(',') else { continue }; + let alias = alias.trim().to_uppercase(); + let target = target.trim().strip_prefix('*').unwrap_or(target.trim()).trim().to_uppercase(); + if alias.is_empty() || target.is_empty() || target.contains(',') + || alias.chars().any(char::is_whitespace) || target.chars().any(char::is_whitespace) { + continue; + } + aliases.insert(alias, target); + } + aliases +} + +/// Translate command names, never alias keys (e.g. the user's E means FILLET). +pub(super) fn canonical(target: &str) -> String { + let target = target.trim_start_matches(['_', '.']); + let (prefix, name) = target.strip_prefix('-').map_or(("", target), |s| ("-", s)); + let name = match name { + "LINEA" => "LINE", "POL" | "POLILINEA" => "PLINE", "3DPOL" => "3DPOLY", + "CIRCULO" => "CIRCLE", "ARCO" => "ARC", "RECTANG" => "RECTANG", + "ELIPSE" => "ELLIPSE", "POLIGONO" => "POLYGON", "ARANDELA" => "DONUT", + "PUNTO" => "POINT", "COPIA" => "COPY", "DESPLAZA" => "MOVE", + "DESFASE" => "OFFSET", "GIRA" => "ROTATE", "SIMETRIA" => "MIRROR", + "ESCALA" => "SCALE", "ESTIRA" => "STRETCH", "RECORTA" => "TRIM", + "ALARGA" => "EXTEND", "EMPALME" => "FILLET", "CHAFLAN" => "CHAMFER", + "BORRA" => "ERASE", "DESCOMP" => "EXPLODE", "JUNTAR" | "UNIR" => "JOIN", + "PARTE" => "BREAK", "PARTIRENPUNTO" => "BREAKATPOINT", "MATRIZ" => "ARRAY", + "ALINEAR" => "ALIGN", "LONGITUD" => "LENGTHEN", "EDITPOL" => "PEDIT", + "SOMBREA" => "HATCH", "EDITSOMB" => "HATCHEDIT", "CONTORNO" => "BOUNDARY", + "CAPA" => "LAYER", "CAMBCAPAMULT" => "LAYMCH", "IGUALARPROP" => "MATCHPROP", + "PROPIEDADES" => "PROPERTIES", "CAMBIA" => "CHANGE", "TIPOLIN" => "LINETYPE", + "GROSORLIN" => "LWEIGHT", "ESCALATL" => "LTSCALE", + "TEXTO" => "TEXT", "TEXTOM" => "MTEXT", "EDITARTEXTO" => "TEXTEDIT", + "EDITTXTM" => "MTEDIT", "ESTILO" => "STYLE", "BUSCAR" => "FIND", + "BLOQUE" => "BLOCK", "BLOQUEDISC" => "WBLOCK", "MODBLQ" => "BEDIT", + "ATRDEF" => "ATTDEF", "ATREDIT" => "ATTEDIT", "ATREXT" => "ATTEXT", + "ACOALINEADA" => "DIMALIGNED", "ACOANGULO" => "DIMANGULAR", + "ACOLINEAL" => "DIMLINEAR", "ACORADIO" => "DIMRADIUS", + "ACODIAMETRO" => "DIMDIAMETER", "ACOCOORDENADA" => "DIMORDINATE", + "ACOLINEABASE" => "DIMBASELINE", "ACOCONTINUA" => "DIMCONTINUE", + "ACOESTIL" => "DIMSTYLE", "ACOEDIC" => "DIMEDIT", "ACOTEDIC" => "DIMTEDIT", + "DIRECTRIZ" => "LEADER", "DIRECTRIZR" => "QLEADER", + "SCP" => "UCS", "ADMINSCP" => "UCSMAN", "VISTA" => "VIEW", + "VISTADIN" => "DVIEW", "3DORBITA" => "3DORBIT", "3DCARA" => "3DFACE", + "ENCUADRE" => "PAN", "REDIBUJA" => "REDRAW", "REDIBT" => "REDRAWALL", + "REGENERA" => "REGEN", "REGENT" => "REGENALL", "OCULTA" => "HIDE", + "ABRE" => "OPEN", "NUEVO" => "NEW", "GUARDAR" => "QSAVE", + "GUARDARCOMO" => "SAVEAS", "TRAZAR" => "PLOT", "IMPORTAR" => "IMPORT", + "EXPORTAR" => "EXPORT", "EXPORTARPDF" => "EXPORTPDF", "OPCIONES" => "OPTIONS", + "UNIDADES" => "UNITS", "PARAMSDIB" => "DSETTINGS", "DIST" => "DIST", + "LIST" => "LIST", "EXTRUSION" => "EXTRUDE", "REVOLUCION" => "REVOLVE", + "INTERSEC" => "INTERSECT", "DIFERENCIA" => "SUBTRACT", "REGION" => "REGION", + "ORDENAOBJETOS" => "DRAWORDER", "IMAGEN" => "IMAGE", + "ENLAZARIMG" => "IMAGEATTACH", "DELIMITARIMG" => "IMAGECLIP", + "AJUSTARIMG" => "IMAGEADJUST", "REFX" => "XREF", + "ESPACIOM" => "MSPACE", "ESPACIOP" => "PSPACE", "VMULT" => "MVIEW", + "REFENT" => "OSNAP", "PEGAESP" => "PASTESPEC", "CERPROP" => "PROPERTIESCLOSE", + "PREVISUALIZAR" => "PREVIEW", "LIMPIA" => "PURGE", "RENOMBRA" => "RENAME", + "MODIVAR" => "SETVAR", "MODOSOMBRA" => "SHADEMODE", "SECCION" => "SECTION", + "CORTE" => "SLICE", "FORZCURSOR" => "SNAP", "ORTOGRAFIA" => "SPELL", + "EDITSPLINE" => "SPLINEDIT", "TOLERANCIA" => "TOLERANCE", "TOROIDE" => "TORUS", + "PTOVISTA" => "VPOINT", "CUÑA" => "WEDGE", "LINEAX" => "XLINE", + "ENLAZARX" => "XATTACH", "UNIRX" => "XBIND", "DELIMITARX" => "XCLIP", + "TABLA" => "TABLE", "ESTILOTABLA" => "TABLESTYLE", "MARCAREV" => "REVCLOUD", + "CERRARBLOQUE" => "BCLOSE", "EDITARBLOQUE" => "BEDIT", "GUARDARBLOQUE" => "BSAVE", + "ACOARCO" => "DIMARC", "ACORECODO" => "DIMJOGGED", "DEGRADADO" => "GRADIENT", + "RECUPDIBUJO" => "RECOVER", "CALCURAPIDA" => "QUICKCALC", "ESTADOVISBLOQUE" => "BVSTATE", + "SIMETRIA3D" => "MIRROR3D", "DESPLAZA3D" => "3DMOVE", "GIRA3D" => "3DROTATE", + "ESCALA3D" => "3DSCALE", "ALINEAR3D" => "3DALIGN", "CAMARA" => "CAMERA", + "CILINDRO" => "CYLINDER", "REFEXTERNAS" => "EXTERNALREFERENCES", "GEOPLANA" => "FLATSHOT", + "POLISOLIDO" => "POLYSOLID", "PIRAMIDE" => "PYRAMID", "PLANOSECCION" => "SECTIONPLANE", + "ESTILVISUAL" => "VISUALSTYLES", "ESTVISACTUAL" => "VSCURRENT", "EDITATRINSITU" => "ATTIPEDIT", + "EXTRACDAT" => "DATAEXTRACTION", "ESTADOCAPAS" => "LAYERSTATE", "LUZPUNTUAL" => "POINTLIGHT", + "DIRECTRIZM" => "MLEADER", "ESTILDIRECTRIZM" => "MLEADERSTYLE", "EDITARDIRECTRIZM" => "MLEADEREDIT", + "ALINDIRECTRIZM" => "MLEADERALIGN", "COLECDIRECTRIZM" => "MLEADERCOLLECT", + "MEDIRGEOM" => "MEASUREGEOM", "PARAMETROS" => "PARAMETERS", "TIPOPUNTO" => "PTYPE", + "ALINEARTEXTO" => "TEXTALIGN", "ENLAZARNUBEPUNTOS" => "POINTCLOUDATTACH", + "RECORTANUBEPUNTOS" => "POINTCLOUDCLIP", "QUITA" => "REMOVE", + "ACOCENTRO" => "DIMCENTER", "ACOREMPLAZAR" => "DIMOVERRIDE", "GRADUA" => "MEASURE", + "LINEAM" => "MLINE", "GRUPO" => "GROUP", "FILTRO" => "FILTER", + "UNION" => "UNION", "INSERTOBJ" => "INSERTOBJ", + _ => name, + }; + format!("{prefix}{name}") +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn user_overrides_and_external_commands() { + let rows = parse("\u{feff}; header\r\nB,*BORRA\r\nNOTEPAD, START NOTEPAD,1,*File:,\nB,*BUSCAR ; custom\nE,*EMPALME\nBAD,*LINE,extra\nL,LINE\n"); + assert_eq!(rows.len(), 3); + assert_eq!(canonical(&rows["B"]), "FIND"); + assert_eq!(canonical(&rows["E"]), "FILLET"); + assert_eq!(rows["L"], "LINE"); + } + #[test] + fn localized_targets_and_prefixes() { + for (input, expected) in [("_LINE", "LINE"), ("._-CAPA", "-LAYER"), + ("COPIA", "COPY"), ("SCP", "UCS"), ("AECCUNKNOWN", "AECCUNKNOWN")] { + assert_eq!(canonical(input), expected); + } + } + #[test] + fn legacy_encoding_and_utf8_bom() { + assert_eq!(decode(b"; par\xe1metros\nL,*LINEA"), "; parámetros\nL,*LINEA"); + assert_eq!(decode("\u{feff}L,*LINEA".as_bytes()), "L,*LINEA"); + } +} diff --git a/src/app/commands/layerprops.rs b/src/app/commands/layerprops.rs index a120507c1..e708b97e4 100644 --- a/src/app/commands/layerprops.rs +++ b/src/app/commands/layerprops.rs @@ -1770,7 +1770,7 @@ impl OpenCADStudio { } } - fn plan_snap(&mut self, i: usize, r_ucs: glam::Mat4) { + pub(in crate::app) fn plan_snap(&mut self, i: usize, r_ucs: glam::Mat4) { let eye_dir = r_ucs.transform_vector3( crate::scene::CubeRegion::Face(crate::scene::pipeline::viewcube::FACE_TOP) .snap_direction(), diff --git a/src/app/commands/mod.rs b/src/app/commands/mod.rs index 807e33528..79cdb1091 100644 --- a/src/app/commands/mod.rs +++ b/src/app/commands/mod.rs @@ -28,6 +28,7 @@ mod plotvars; mod point_cloud; pub(crate) mod sheet_set; mod styleprops; +mod ucsfollow; mod view; mod view_presets; mod xclip; @@ -323,6 +324,9 @@ impl OpenCADStudio { /// `finish_dispatch`), or `None` to defer to the next — equivalent to one /// sequential `match` over all arms. fn dispatch_families(&mut self, cmd: &str, i: usize) -> Option> { + if let Some(t) = self.dispatch_ucsfollow(cmd, i) { + return Some(t); + } if let Some(t) = self.dispatch_fileops(cmd, i) { return Some(t); } @@ -504,6 +508,12 @@ inventory::submit!(crate::command::CommandRegistration { // Drafting-aid / display toggles + customization entry points wired in the // dispatch families (no interactive command module of their own). "ALIASEDIT", + // Implemented command-line block forms must also validate in ALIASEDIT. + "-BLOCK", + "-BMAKE", + "-WBLOCK", + "-WB", + "-ATTEDIT", "CLEANSCREEN", "CUI", "DSETTINGS", diff --git a/src/app/commands/styleprops.rs b/src/app/commands/styleprops.rs index 77683445d..cc1363096 100644 --- a/src/app/commands/styleprops.rs +++ b/src/app/commands/styleprops.rs @@ -1303,6 +1303,7 @@ impl OpenCADStudio { if name.is_empty() || name == "?" { self.command_line .push_info(&super::plotvars::setvar_listing()); + self.command_line.push_info("SETVAR (view): UCSFOLLOW"); self.command_line .push_info(&super::blockvars::setvar_listing()); self.command_line.push_info( diff --git a/src/app/commands/ucsfollow.rs b/src/app/commands/ucsfollow.rs new file mode 100644 index 000000000..650c9acda --- /dev/null +++ b/src/app/commands/ucsfollow.rs @@ -0,0 +1,144 @@ +//! UCSFOLLOW is a drawing/viewport variable, not an application preference. +use crate::app::{Message, OpenCADStudio}; +use iced::Task; + +inventory::submit!(crate::command::CommandRegistration { names: &["UCSFOLLOW"] }); + +impl OpenCADStudio { + pub(super) fn dispatch_ucsfollow(&mut self, cmd: &str, i: usize) -> Option> { + let rest = cmd.strip_prefix("SETVAR ").unwrap_or(cmd).trim(); + let mut parts = rest.splitn(2, char::is_whitespace); + if parts.next()? != "UCSFOLLOW" { + return None; + } + let value = parts.next().map(str::trim).filter(|v| !v.is_empty()); + let current = self.tabs[i].scene.ucs_follow(); + match value { + None => { + self.command_line.push_output(crate::tf!( + "UCSFOLLOW: enter 0 (keep view) or 1 (follow UCS) <{}>:", + u8::from(current) + ).as_ref()); + self.pending_setvar = Some("UCSFOLLOW".into()); + } + Some("0" | "1") => { + let next = value == Some("1"); + if next && self.tabs[i].scene.current_layout != "Model" + && self.tabs[i].scene.active_viewport.is_none() { + self.command_line.push_error(crate::t!("UCSFOLLOW: activate a model viewport; paper space cannot follow the UCS.").as_ref()); + } else { + if current != next { + self.push_undo_snapshot(i, "UCSFOLLOW"); + self.tabs[i].scene.set_ucs_follow(next); + self.tabs[i].dirty = true; + } + // Enabling UCSFOLLOW must align an already-defined UCS + // immediately, not only follow subsequent UCS edits. Also + // realign when the value was already 1 (e.g. after orbit). + if next && self.tabs[i].active_block_edit.is_none() { + if current == next { + self.push_undo_snapshot(i, "UCSFOLLOW"); + } + self.tabs[i].sync_ucs_to_scene(); + let rotation = self.tabs[i].scene.viewcube_ucs_mat(); + self.plan_snap(i, rotation); + self.tabs[i].dirty = true; + } + self.command_line.push_output(&format!("UCSFOLLOW = {}", u8::from(next))); + } + } + Some(_) => { + self.command_line.push_error(crate::t!("UCSFOLLOW: only 0 or 1 is allowed.").as_ref()); + } + } + Some(Task::none()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn app() -> OpenCADStudio { + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + app + } + + #[test] + fn variable_is_registered_prompted_and_validated() { + let mut app = app(); + let i = app.active_tab; + assert!(!app.tabs[i].scene.ucs_follow()); + let _ = app.run_command_line("UCSFOLLOW"); + assert_eq!(app.pending_setvar.as_deref(), Some("UCSFOLLOW")); + app.pending_setvar = None; + let _ = app.run_command_line("SETVAR UCSFOLLOW 1"); + assert!(app.tabs[i].scene.ucs_follow()); + let _ = app.run_command_line("UCSFOLLOW 2"); + assert!(app.tabs[i].scene.ucs_follow()); + let _ = app.run_command_line("UCSFOLLOW 0"); + assert!(!app.tabs[i].scene.ucs_follow()); + assert!(crate::command::all_registered_command_names().contains(&"UCSFOLLOW")); + } + + #[test] + fn ucs_changes_align_view_only_when_enabled_and_aliases_work() { + let mut app = app(); + let i = app.active_tab; + // Test-only table: never writes or imports into the user's profile. + app.command_aliases.insert("UF".into(), "UCSFOLLOW".into()); + app.command_aliases.insert("SQ".into(), "UCS".into()); + let before = app.tabs[i].scene.camera.borrow().clone(); + let _ = app.run_command_line("SQ Z 30"); + let unchanged = app.tabs[i].scene.camera.borrow().clone(); + assert_eq!(before.rotation, unchanged.rotation); + let _ = app.run_command_line("UF 1"); + assert!(app.tabs[i].scene.ucs_follow()); + let _ = app.run_command_line("SQ Z 30"); + let followed = app.tabs[i].scene.camera.borrow().clone(); + assert_ne!(unchanged.rotation, followed.rotation); + let _ = app.run_command_line("UF 0"); + let _ = app.run_command_line("SQ Z 30"); + assert_eq!(followed.rotation, app.tabs[i].scene.camera.borrow().rotation); + } + + #[test] + fn floating_viewport_setting_does_not_leak_into_model_or_paper() { + let mut app = app(); + let i = app.active_tab; + let first = app.tabs[i].scene.add_entity(codec::EntityType::Viewport( + codec::entities::Viewport::new())); + let second = app.tabs[i].scene.add_entity(codec::EntityType::Viewport( + codec::entities::Viewport::new())); + app.tabs[i].scene.active_viewport = Some(first); + let _ = app.run_command_line("UCSFOLLOW 1"); + assert!(app.tabs[i].scene.ucs_follow()); + app.tabs[i].scene.active_viewport = Some(second); + assert!(!app.tabs[i].scene.ucs_follow()); + app.tabs[i].scene.active_viewport = None; + assert!(!app.tabs[i].scene.ucs_follow()); + app.tabs[i].scene.current_layout = "Layout1".into(); + let _ = app.run_command_line("UCSFOLLOW 1"); + assert!(!app.tabs[i].scene.ucs_follow()); + app.tabs[i].scene.active_viewport = Some(first); + assert!(app.tabs[i].scene.ucs_follow()); + } + + #[test] + fn enabling_after_defining_ucs_aligns_immediately() { + let mut app = app(); + let i = app.active_tab; + let before = app.tabs[i].scene.camera.borrow().rotation; + let _ = app.run_command_line("UCS Z 30"); + assert_eq!(before, app.tabs[i].scene.camera.borrow().rotation); + let _ = app.run_command_line("UCSFOLLOW 1"); + let aligned = app.tabs[i].scene.camera.borrow().rotation; + assert_ne!(before, aligned, "enabling must align the existing UCS immediately"); + // The same orientation must be obtained through the explicit PLAN path. + let _ = app.run_command_line("PLAN CURRENT"); + assert_eq!(aligned, app.tabs[i].scene.camera.borrow().rotation); + let _ = app.run_command_line("UCSFOLLOW 0"); + assert_eq!(aligned, app.tabs[i].scene.camera.borrow().rotation); + } +} diff --git a/src/app/mod.rs b/src/app/mod.rs index cad1551e4..42933e589 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1457,6 +1457,8 @@ pub(super) struct OpenCADStudio { /// True while the "unsaved changes will be discarded" confirmation /// overlays the editor: the user tried to close with un-applied rows. alias_close_confirm: bool, + /// Summary of the most recent PGP import; never activates the working rows. + alias_import_report: String, // ── Named Parameters ────────────────────────────────────────────────── /// Working buffer for the PARAMETERS modal. Unlike `alias_editor_rows`, @@ -3745,6 +3747,9 @@ pub enum Message { // ── Command Alias Editor (ALIASEDIT) ──────────────────────────────── /// Open the command-alias editor modal, seeding rows from the alias table. AliasEditorOpen, + /// Pick an AutoCAD/OpenCAD PGP file, then merge supported aliases for review. + AliasEditorImport, + AliasEditorImported(Option, String>>), /// Live edit of the alias or command in row `idx`. AliasEditorInput { idx: usize, @@ -4964,6 +4969,7 @@ impl OpenCADStudio { alias_pending_add: false, alias_reset_confirm: false, alias_close_confirm: false, + alias_import_report: String::new(), named_parameter_editor_rows: Vec::new(), // Layout Manager layout_manager_selected: "Model".to_string(), diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 66516c997..061b3d8c2 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -8557,6 +8557,7 @@ impl OpenCADStudio { .collect(); rows.sort_by(|a, b| a.0.cmp(&b.0)); self.alias_editor_rows = rows; + self.alias_import_report.clear(); self.alias_pending_add = false; self.alias_reset_confirm = false; self.alias_close_confirm = false; @@ -8578,6 +8579,29 @@ impl OpenCADStudio { // check button does, so no half-visible "ghost" row appears. Task::none() } + Message::AliasEditorImport => Task::perform( + async { + let handle = rfd::AsyncFileDialog::new() + .add_filter(crate::t!("Command aliases (*.pgp)").as_ref(), &["pgp"]) + .pick_file().await?; + #[cfg(not(target_arch = "wasm32"))] + { Some(std::fs::read(handle.path()).map_err(|e| e.to_string())) } + #[cfg(target_arch = "wasm32")] + { Some(Ok(handle.read().await)) } + }, + Message::AliasEditorImported, + ), + Message::AliasEditorImported(result) => { + // A picker can finish after the modal was dismissed. + if self.active_modal == Some(super::ModalKind::Aliases) { + match result { + Some(Ok(bytes)) => self.import_alias_pgp(&bytes), + Some(Err(error)) => self.command_line.push_error(&format!("PGP: {error}")), + None => {} + } + } + Task::none() + } Message::AliasEditorAdd => { if self.alias_pending_add { // One draft at a time: keep the existing top draft. diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index 301b8fa2a..1996ba6e8 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -3506,12 +3506,17 @@ impl OpenCADStudio { } _ => false, }; - if !same_basis(self.tabs[i].active_ucs.as_ref(), persisted.as_ref()) { + let changed = !same_basis(self.tabs[i].active_ucs.as_ref(), persisted.as_ref()); + if changed { self.push_undo_snapshot(i, label); self.tabs[i].persist_active_ucs(); self.tabs[i].dirty = true; } self.tabs[i].sync_ucs_to_scene(); + if changed && self.tabs[i].scene.ucs_follow() { + let rotation = self.tabs[i].scene.viewcube_ucs_mat(); + self.plan_snap(i, rotation); + } } // ── Per-pane Model viewport (pane_grid) ─────────────────────────────── diff --git a/src/app/view/modal.rs b/src/app/view/modal.rs index e8968b928..098e7d878 100644 --- a/src/app/view/modal.rs +++ b/src/app/view/modal.rs @@ -257,6 +257,7 @@ impl OpenCADStudio { sized_flow(ex, 480, 520, |flow| { crate::ui::window::alias_editor::view_window( &self.alias_editor_rows, + &self.alias_import_report, self.alias_pending_add, self.alias_reset_confirm, &duplicate_set, diff --git a/src/command.rs b/src/command.rs index 830f98340..6d553f297 100644 --- a/src/command.rs +++ b/src/command.rs @@ -3237,6 +3237,14 @@ pub fn all_registered_command_names() -> Vec<&'static str> { mod constraint_registry_tests { use super::*; + #[test] + fn default_block_alias_targets_are_registered() { + let names = all_registered_command_names(); + for target in ["-BLOCK", "-WBLOCK", "-ATTEDIT"] { + assert!(names.contains(&target), "{target} must validate in ALIASEDIT"); + } + } + /// Ensures every constraint command remains discoverable by autocomplete /// and command listings. #[test] diff --git a/src/locale_catalog.rs b/src/locale_catalog.rs index 45b4b973a..46a790cc3 100644 --- a/src/locale_catalog.rs +++ b/src/locale_catalog.rs @@ -1137,6 +1137,10 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static "Columns" => Some(("properties", "columns")), "Command" => Some(("command", "command")), "Command Aliases" => Some(("command", "command-aliases")), + "Import PGP…" => Some(("pgp-import", "button")), + "Command aliases (*.pgp)" => Some(("pgp-import", "filter")), + "PGP: no valid aliases found. The working rows were not changed." => Some(("pgp-import", "empty")), + "PGP: {} alias(es) loaded for review; {} unsupported command(s) skipped. No changes activated. Apply to save." => Some(("pgp-import", "summary")), "Command cancelled because the active drawing space changed." => Some(("common", "command-cancelled-because-the-active-drawing-s")), "Command history navigation" => Some(("common", "command-history-navigation")), "Commit date" => Some(("ui", "commit-date")), @@ -4903,7 +4907,11 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static "2Point" => Some(("model", "value-2point")), "Cube" => Some(("model", "cube")), "UCS option [Face/OBject/World/View/3Point/Z/X/Y/Origin/Save/Delete] or name:" => Some(("view", "ucs-option-world-view-3point-z-x-y-origin-save-delete-or-name")), + "UCS specify new origin or [Face/OBject/World/View/3Point/Z/X/Y/Origin/Save/Delete] or name:" => Some(("view", "ucs-option-world-view-3point-z-x-y-origin-save-delete-or-name")), "UCS specify new origin:" => Some(("view", "ucs-specify-new-origin")), + "UCSFOLLOW: enter 0 (keep view) or 1 (follow UCS) <{}>:" => Some(("ucs-follow", "prompt")), + "UCSFOLLOW: activate a model viewport; paper space cannot follow the UCS." => Some(("ucs-follow", "paper-space")), + "UCSFOLLOW: only 0 or 1 is allowed." => Some(("ucs-follow", "invalid")), "UCS specify point on positive X axis:" => Some(("view", "ucs-specify-point-on-positive-x-axis")), "UCS specify point in positive XY plane:" => Some(("view", "ucs-specify-point-in-positive-xy-plane")), "Extrusion" => Some(("common", "extrusion")), diff --git a/src/modules/view/ucs_cmd.rs b/src/modules/view/ucs_cmd.rs index 6b3123a99..15ff42587 100644 --- a/src/modules/view/ucs_cmd.rs +++ b/src/modules/view/ucs_cmd.rs @@ -55,7 +55,7 @@ impl CadCommand for UcsCommand { fn prompt(&self) -> String { match self.option.as_deref() { None => { - t!("UCS option [Face/OBject/World/View/3Point/Z/X/Y/Origin/Save/Delete] or name:") + t!("UCS specify new origin or [Face/OBject/World/View/3Point/Z/X/Y/Origin/Save/Delete] or name:") .into_owned() } Some("Z") => t!("UCS rotation angle about Z (degrees):").into_owned(), @@ -81,7 +81,10 @@ impl CadCommand for UcsCommand { fn on_text_input(&mut self, text: &str) -> Option { let t = text.trim(); - if matches!(self.option.as_deref(), Some("3POINT") | Some("3P")) + // Coordinate input is handled by the point-input pipeline, just like a + // click. With no option selected, it starts the default three-point UCS. + if (self.option.is_none() + || matches!(self.option.as_deref(), Some("3POINT") | Some("3P"))) && crate::app::helpers::parse_coord(t).is_some() { return None; @@ -94,6 +97,20 @@ impl CadCommand for UcsCommand { return Some(CmdResult::Dispatch("UCS LIST".into())); } let up = t.to_uppercase(); + // Accept the Spanish labels shown by the localized prompt as + // well as international option keywords. Only normalize the + // option token, never the value/name supplied in the next step. + let up = match up.trim_start_matches('_') { + "UNIVERSAL" | "U" => "WORLD", + "VISTA" => "VIEW", + "CARA" => "FACE", + "OBJETO" => "OBJECT", + "ORIGEN" => "ORIGIN", + "GUARDAR" | "G" => "SAVE", + "ELIMINAR" | "E" => "DELETE", + "3" | "3PUNTOS" | "3 PUNTOS" => "3POINT", + value => value, + }.to_owned(); if Self::is_zero_arg(&up) { Some(CmdResult::Dispatch(format!("UCS {up}"))) } else if Self::takes_value(&up) || Self::takes_point(&up) { @@ -111,6 +128,11 @@ impl CadCommand for UcsCommand { } fn on_point(&mut self, pt: DVec3) -> CmdResult { + // AutoCAD accepts the new origin immediately after UCS/SCP. Do not + // silently ignore that first click while waiting for an option keyword. + if self.option.is_none() { + self.option = Some("3POINT".into()); + } if matches!(self.option.as_deref(), Some("3POINT") | Some("3P")) { self.points.push(pt); if self.points.len() < 3 { @@ -131,8 +153,7 @@ impl CadCommand for UcsCommand { points[2].z, )); } - // A clicked point only makes sense for the Origin option; otherwise a - // stray click is ignored (keep waiting for the typed keyword / value). + // Other point-taking options retain their explicit behavior. if matches!(self.option.as_deref(), Some(o) if Self::takes_point(o)) { self.option.take(); return CmdResult::Dispatch(format!("UCS ORIGINW {},{},{}", pt.x, pt.y, pt.z)); @@ -149,3 +170,51 @@ impl CadCommand for UcsCommand { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bare_ucs_accepts_origin_then_x_then_xy_without_option() { + let mut cmd = UcsCommand::new(); + assert!(matches!(cmd.on_point(DVec3::new(10.0, 20.0, 0.0)), CmdResult::NeedPoint)); + assert_eq!(cmd.points.len(), 1); + assert!(matches!(cmd.on_point(DVec3::new(11.0, 20.0, 0.0)), CmdResult::NeedPoint)); + match cmd.on_point(DVec3::new(10.0, 21.0, 0.0)) { + CmdResult::Dispatch(command) => assert_eq!( + command, "UCS 3POINTW 10,20,0|11,20,0|10,21,0" + ), + _ => panic!("three clicks must dispatch the new UCS"), + } + } + + #[test] + fn initial_coordinate_is_delegated_to_point_pipeline() { + let mut cmd = UcsCommand::new(); + assert!(cmd.on_text_input("10,20,0").is_none()); + assert!(matches!(cmd.on_point(DVec3::new(10.0, 20.0, 0.0)), CmdResult::NeedPoint)); + assert_eq!(cmd.points.len(), 1); + } + + #[test] + fn explicit_origin_still_changes_only_origin() { + let mut cmd = UcsCommand::new(); + assert!(matches!(cmd.on_text_input("O"), Some(CmdResult::NeedPoint))); + match cmd.on_point(DVec3::new(10.0, 20.0, 0.0)) { + CmdResult::Dispatch(command) => assert_eq!(command, "UCS ORIGINW 10,20,0"), + _ => panic!("explicit Origin must dispatch after one point"), + } + } + + #[test] + fn spanish_and_international_options_work() { + for option in ["Universal", "U", "_World"] { + let mut cmd = UcsCommand::new(); + assert!(matches!(cmd.on_text_input(option), Some(CmdResult::Dispatch(s)) if s == "UCS WORLD")); + } + let mut cmd = UcsCommand::new(); + assert!(matches!(cmd.on_text_input("Guardar"), Some(CmdResult::NeedPoint))); + assert!(matches!(cmd.on_text_input("MiPlano"), Some(CmdResult::Dispatch(s)) if s == "UCS SAVE MiPlano")); + } +} diff --git a/src/scene/camera_ops.rs b/src/scene/camera_ops.rs index 5e5b87628..bb9a11589 100644 --- a/src/scene/camera_ops.rs +++ b/src/scene/camera_ops.rs @@ -1073,6 +1073,51 @@ impl Scene { true } + /// Index of the current model pane's record, independent of record ordering. + fn ucs_follow_vport_index(&self) -> Option { + let rect = self.model_tiles.borrow().get(self.active_model_tile.get()).map(|t| t.rect); + let bounds = rect.map(Self::tile_rect_to_vport); + self.document.vports.iter().enumerate().find_map(|(index, entry)| { + (is_active_vport_name(&entry.name) + && bounds.map_or(true, |(ll, ur)| Self::vport_rect_matches(entry, ll, ur))) + .then_some(index) + }) + } + + /// Read the persisted per-viewport UCSFOLLOW bit. Paper space itself never follows. + pub(crate) fn ucs_follow(&self) -> bool { + if let Some(handle) = self.active_viewport { + return matches!(self.document.get_entity(handle), + Some(codec::EntityType::Viewport(vp)) if vp.status.ucs_follow); + } + if self.current_layout != "Model" { + return false; + } + self.ucs_follow_vport_index() + .and_then(|index| self.document.vports.iter().nth(index)) + .is_some_and(|vp| vp.ucsfollow) + } + + pub(crate) fn set_ucs_follow(&mut self, enabled: bool) { + if let Some(handle) = self.active_viewport { + if let Some(codec::EntityType::Viewport(vp)) = self.document.get_entity_mut(handle) { + vp.status.ucs_follow = enabled; + } + } else if self.current_layout == "Model" { + // Materialize current panes first; saving them preserves the bit by + // matching their rectangles, even when the active pane is reordered. + self.save_model_tiles_to_vports(); + if self.ucs_follow_vport_index().is_none() { + self.document.vports.add_or_replace(codec::tables::VPort::new("*Active")); + } + if let Some(index) = self.ucs_follow_vport_index() { + if let Some(vp) = self.document.vports.iter_mut().nth(index) { + vp.ucsfollow = enabled; + } + } + } + } + /// Persist model tiles as duplicate `*Active` VPort entries. fn save_model_tiles_to_vports(&mut self) { // Stash the live camera into the active tile so the about-to-write diff --git a/src/ui/window/alias_editor.rs b/src/ui/window/alias_editor.rs index 372ffe824..546ad11c7 100644 --- a/src/ui/window/alias_editor.rs +++ b/src/ui/window/alias_editor.rs @@ -57,6 +57,7 @@ fn danger_input_style( /// Build the alias editor content. `rows` is the live working buffer. pub fn view_window<'a>( rows: &'a [(String, String)], + import_report: &'a str, pending_add: bool, reset_confirm: bool, duplicate_aliases: &rustc_hash::FxHashSet, @@ -220,7 +221,10 @@ pub fn view_window<'a>( .style(button::secondary)] .spacing(8) .align_y(iced::Center), - row![add], + row![add, button(text(t!("Import PGP…")).size(12)) + .on_press_maybe((!pending_add).then_some(Message::AliasEditorImport)) + .padding([4, 10]) + .style(button::secondary)].spacing(8), ) }; // Apply — primary action; commits the rows to ocad.pgp and stays open. @@ -265,21 +269,22 @@ pub fn view_window<'a>( column![ title, hint, + text(import_report).size(11).style(muted_style), Space::new().height(6), head, scrollable(container(list).padding(gutter)).height(sizing.height), Space::new().height(6), - conflict_banner, + scrollable(conflict_banner).height(Length::Fit.max(72)), Space::new().height(4), row![ - stats, add_area, reset_area, - Space::new().width(sizing.width), - apply_area ] .spacing(8) .align_y(iced::Center), + row![stats, Space::new().width(Length::Fill), apply_area] + .spacing(8) + .align_y(iced::Center), ] .spacing(6) .width(sizing.width)