aliases: import AutoCAD PGP files and support UCS-follow workflows

This commit is contained in:
Pictus08 committed 2026-10-14 20:46:35 -03:00
1 parent 2414721c14
commit d19c797685
39 files changed
+818 -30

No files matched your search

+40
View File
@@ -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.
+33
View File
@@ -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.
+10
View File
@@ -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.
+10
View File
@@ -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.
+10
View File
@@ -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.
+10
View File
@@ -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.
+10
View File
@@ -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.
+11 -1
View File
@@ -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.
+11 -1
View File
@@ -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.
+10
View File
@@ -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.
+10
View File
@@ -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.
+10
View File
@@ -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.
+10
View File
@@ -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.
+10
View File
@@ -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.
+10
View File
@@ -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.
+10
View File
@@ -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.
+10
View File
@@ -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.
+10
View File
@@ -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.
+10
View File
@@ -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.
+10
View File
@@ -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.
+10
View File
@@ -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.
+10
View File
@@ -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.
+10
View File
@@ -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.
+10
View File
@@ -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.
+53 -17
View File
@@ -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<String, String>, from_version: u32) ->
/// lines and `;` comments; tolerates a leading `*` on the command and arbitrary
/// whitespace padding.
fn parse_pgp(body: &str) -> FxHashMap<String, String> {
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<String, String>) -> 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<String, String> = 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<String, String> {
pairs
.iter()
+133
View File
@@ -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<String, String> {
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");
}
}
+1 -1
View File
@@ -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(),
+10
View File
@@ -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<Task<Message>> {
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",
+1
View File
@@ -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(
+144
View File
@@ -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<Task<Message>> {
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);
}
}
+6
View File
@@ -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<Result<Vec<u8>, 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(),
+24
View File
@@ -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.
+6 -1
View File
@@ -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) ───────────────────────────────
+1
View File
@@ -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,
+8
View File
@@ -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]
+8
View File
@@ -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")),
+73 -4
View File
@@ -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<CmdResult> {
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"));
}
}
+45
View File
@@ -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<usize> {
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
+10 -5
View File
@@ -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<String>,
@@ -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)