diff --git a/locales/ar-SA/opencadstudio.ftl b/locales/ar-SA/opencadstudio.ftl index 83175dbf1..d6142a81a 100644 --- a/locales/ar-SA/opencadstudio.ftl +++ b/locales/ar-SA/opencadstudio.ftl @@ -6609,3 +6609,39 @@ 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. + +dgn-import = + .supported = Supported + .by-extension = By extension + .acis-solver = ACIS solver available + .export-only = Export only + .acis-reason = ACIS bodies are read only inside drawings; SAT files cannot be imported. + .step-reason = STEP files are only written (STEPOUT), not read. + .no-importer = OpenCAD Studio has no importer for this format. + .recent = Recent: + .group-2d = 2D / Underlay + .group-3d = 3D Model + .search = Search formats... + .only-supported = Only what OCS can open + .models = __ocs_fmt_0__ model(s) + .change = Change file... + .name-conflict = On name conflict + .prefix = Add prefix to dependent definitions + .ignore-duplicates = Ignore duplicate names + .master-unit = Master unit + .sub-unit = Sub unit + .explode = Explode text nodes to text elements + .references = External DGN references + .bind = Bind as block / xref + .attach = Attach as DGN underlay + .references-note = Reference attachments of DGN files are not read; they are left out. + .mapping = Property mapping + .mapping-standard = Standard mapping setup. + .mapping-note = The preview shows the selected setup's effect on this file. + .import-file = Import File + .dgn-import = DGN Import + .import-dgn-file = Import DGN File + .help-dgn = Imports a DGN model as drawing objects: levels as layers, cells as blocks. + .help-import = Imports a file of the chosen format with the format's importer. + .blank = Filename cannot be blank + .invalid-dgn = Invalid or unsupported DGN file. diff --git a/locales/bg-BG/opencadstudio.ftl b/locales/bg-BG/opencadstudio.ftl index cc20285c3..4a2d79079 100644 --- a/locales/bg-BG/opencadstudio.ftl +++ b/locales/bg-BG/opencadstudio.ftl @@ -6597,3 +6597,39 @@ 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. + +dgn-import = + .supported = Supported + .by-extension = By extension + .acis-solver = ACIS solver available + .export-only = Export only + .acis-reason = ACIS bodies are read only inside drawings; SAT files cannot be imported. + .step-reason = STEP files are only written (STEPOUT), not read. + .no-importer = OpenCAD Studio has no importer for this format. + .recent = Recent: + .group-2d = 2D / Underlay + .group-3d = 3D Model + .search = Search formats... + .only-supported = Only what OCS can open + .models = __ocs_fmt_0__ model(s) + .change = Change file... + .name-conflict = On name conflict + .prefix = Add prefix to dependent definitions + .ignore-duplicates = Ignore duplicate names + .master-unit = Master unit + .sub-unit = Sub unit + .explode = Explode text nodes to text elements + .references = External DGN references + .bind = Bind as block / xref + .attach = Attach as DGN underlay + .references-note = Reference attachments of DGN files are not read; they are left out. + .mapping = Property mapping + .mapping-standard = Standard mapping setup. + .mapping-note = The preview shows the selected setup's effect on this file. + .import-file = Import File + .dgn-import = DGN Import + .import-dgn-file = Import DGN File + .help-dgn = Imports a DGN model as drawing objects: levels as layers, cells as blocks. + .help-import = Imports a file of the chosen format with the format's importer. + .blank = Filename cannot be blank + .invalid-dgn = Invalid or unsupported DGN file. diff --git a/locales/cs-CZ/opencadstudio.ftl b/locales/cs-CZ/opencadstudio.ftl index 66afe10f6..a66a220f7 100644 --- a/locales/cs-CZ/opencadstudio.ftl +++ b/locales/cs-CZ/opencadstudio.ftl @@ -6594,3 +6594,39 @@ 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. + +dgn-import = + .supported = Supported + .by-extension = By extension + .acis-solver = ACIS solver available + .export-only = Export only + .acis-reason = ACIS bodies are read only inside drawings; SAT files cannot be imported. + .step-reason = STEP files are only written (STEPOUT), not read. + .no-importer = OpenCAD Studio has no importer for this format. + .recent = Recent: + .group-2d = 2D / Underlay + .group-3d = 3D Model + .search = Search formats... + .only-supported = Only what OCS can open + .models = __ocs_fmt_0__ model(s) + .change = Change file... + .name-conflict = On name conflict + .prefix = Add prefix to dependent definitions + .ignore-duplicates = Ignore duplicate names + .master-unit = Master unit + .sub-unit = Sub unit + .explode = Explode text nodes to text elements + .references = External DGN references + .bind = Bind as block / xref + .attach = Attach as DGN underlay + .references-note = Reference attachments of DGN files are not read; they are left out. + .mapping = Property mapping + .mapping-standard = Standard mapping setup. + .mapping-note = The preview shows the selected setup's effect on this file. + .import-file = Import File + .dgn-import = DGN Import + .import-dgn-file = Import DGN File + .help-dgn = Imports a DGN model as drawing objects: levels as layers, cells as blocks. + .help-import = Imports a file of the chosen format with the format's importer. + .blank = Filename cannot be blank + .invalid-dgn = Invalid or unsupported DGN file. diff --git a/locales/de-DE/opencadstudio.ftl b/locales/de-DE/opencadstudio.ftl index 61eef3101..437f483f4 100644 --- a/locales/de-DE/opencadstudio.ftl +++ b/locales/de-DE/opencadstudio.ftl @@ -6595,3 +6595,39 @@ 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. + +dgn-import = + .supported = Supported + .by-extension = By extension + .acis-solver = ACIS solver available + .export-only = Export only + .acis-reason = ACIS bodies are read only inside drawings; SAT files cannot be imported. + .step-reason = STEP files are only written (STEPOUT), not read. + .no-importer = OpenCAD Studio has no importer for this format. + .recent = Recent: + .group-2d = 2D / Underlay + .group-3d = 3D Model + .search = Search formats... + .only-supported = Only what OCS can open + .models = __ocs_fmt_0__ model(s) + .change = Change file... + .name-conflict = On name conflict + .prefix = Add prefix to dependent definitions + .ignore-duplicates = Ignore duplicate names + .master-unit = Master unit + .sub-unit = Sub unit + .explode = Explode text nodes to text elements + .references = External DGN references + .bind = Bind as block / xref + .attach = Attach as DGN underlay + .references-note = Reference attachments of DGN files are not read; they are left out. + .mapping = Property mapping + .mapping-standard = Standard mapping setup. + .mapping-note = The preview shows the selected setup's effect on this file. + .import-file = Import File + .dgn-import = DGN Import + .import-dgn-file = Import DGN File + .help-dgn = Imports a DGN model as drawing objects: levels as layers, cells as blocks. + .help-import = Imports a file of the chosen format with the format's importer. + .blank = Filename cannot be blank + .invalid-dgn = Invalid or unsupported DGN file. diff --git a/locales/el-GR/opencadstudio.ftl b/locales/el-GR/opencadstudio.ftl index 07d706269..d5df0b812 100644 --- a/locales/el-GR/opencadstudio.ftl +++ b/locales/el-GR/opencadstudio.ftl @@ -6597,3 +6597,39 @@ 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. + +dgn-import = + .supported = Supported + .by-extension = By extension + .acis-solver = ACIS solver available + .export-only = Export only + .acis-reason = ACIS bodies are read only inside drawings; SAT files cannot be imported. + .step-reason = STEP files are only written (STEPOUT), not read. + .no-importer = OpenCAD Studio has no importer for this format. + .recent = Recent: + .group-2d = 2D / Underlay + .group-3d = 3D Model + .search = Search formats... + .only-supported = Only what OCS can open + .models = __ocs_fmt_0__ model(s) + .change = Change file... + .name-conflict = On name conflict + .prefix = Add prefix to dependent definitions + .ignore-duplicates = Ignore duplicate names + .master-unit = Master unit + .sub-unit = Sub unit + .explode = Explode text nodes to text elements + .references = External DGN references + .bind = Bind as block / xref + .attach = Attach as DGN underlay + .references-note = Reference attachments of DGN files are not read; they are left out. + .mapping = Property mapping + .mapping-standard = Standard mapping setup. + .mapping-note = The preview shows the selected setup's effect on this file. + .import-file = Import File + .dgn-import = DGN Import + .import-dgn-file = Import DGN File + .help-dgn = Imports a DGN model as drawing objects: levels as layers, cells as blocks. + .help-import = Imports a file of the chosen format with the format's importer. + .blank = Filename cannot be blank + .invalid-dgn = Invalid or unsupported DGN file. diff --git a/locales/en-US/opencadstudio.ftl b/locales/en-US/opencadstudio.ftl index e241beee5..30ad6c56f 100644 --- a/locales/en-US/opencadstudio.ftl +++ b/locales/en-US/opencadstudio.ftl @@ -6596,3 +6596,39 @@ 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. + +dgn-import = + .supported = Supported + .by-extension = By extension + .acis-solver = ACIS solver available + .export-only = Export only + .acis-reason = ACIS bodies are read only inside drawings; SAT files cannot be imported. + .step-reason = STEP files are only written (STEPOUT), not read. + .no-importer = OpenCAD Studio has no importer for this format. + .recent = Recent: + .group-2d = 2D / Underlay + .group-3d = 3D Model + .search = Search formats... + .only-supported = Only what OCS can open + .models = __ocs_fmt_0__ model(s) + .change = Change file... + .name-conflict = On name conflict + .prefix = Add prefix to dependent definitions + .ignore-duplicates = Ignore duplicate names + .master-unit = Master unit + .sub-unit = Sub unit + .explode = Explode text nodes to text elements + .references = External DGN references + .bind = Bind as block / xref + .attach = Attach as DGN underlay + .references-note = Reference attachments of DGN files are not read; they are left out. + .mapping = Property mapping + .mapping-standard = Standard mapping setup. + .mapping-note = The preview shows the selected setup's effect on this file. + .import-file = Import File + .dgn-import = DGN Import + .import-dgn-file = Import DGN File + .help-dgn = Imports a DGN model as drawing objects: levels as layers, cells as blocks. + .help-import = Imports a file of the chosen format with the format's importer. + .blank = Filename cannot be blank + .invalid-dgn = Invalid or unsupported DGN file. diff --git a/locales/es-ES/opencadstudio.ftl b/locales/es-ES/opencadstudio.ftl index 9a4f1c86b..b6969ad46 100644 --- a/locales/es-ES/opencadstudio.ftl +++ b/locales/es-ES/opencadstudio.ftl @@ -6597,3 +6597,39 @@ 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. + +dgn-import = + .supported = Supported + .by-extension = By extension + .acis-solver = ACIS solver available + .export-only = Export only + .acis-reason = ACIS bodies are read only inside drawings; SAT files cannot be imported. + .step-reason = STEP files are only written (STEPOUT), not read. + .no-importer = OpenCAD Studio has no importer for this format. + .recent = Recent: + .group-2d = 2D / Underlay + .group-3d = 3D Model + .search = Search formats... + .only-supported = Only what OCS can open + .models = __ocs_fmt_0__ model(s) + .change = Change file... + .name-conflict = On name conflict + .prefix = Add prefix to dependent definitions + .ignore-duplicates = Ignore duplicate names + .master-unit = Master unit + .sub-unit = Sub unit + .explode = Explode text nodes to text elements + .references = External DGN references + .bind = Bind as block / xref + .attach = Attach as DGN underlay + .references-note = Reference attachments of DGN files are not read; they are left out. + .mapping = Property mapping + .mapping-standard = Standard mapping setup. + .mapping-note = The preview shows the selected setup's effect on this file. + .import-file = Import File + .dgn-import = DGN Import + .import-dgn-file = Import DGN File + .help-dgn = Imports a DGN model as drawing objects: levels as layers, cells as blocks. + .help-import = Imports a file of the chosen format with the format's importer. + .blank = Filename cannot be blank + .invalid-dgn = Invalid or unsupported DGN file. diff --git a/locales/fi-FI/opencadstudio.ftl b/locales/fi-FI/opencadstudio.ftl index 0ec188498..d9b8a6b94 100644 --- a/locales/fi-FI/opencadstudio.ftl +++ b/locales/fi-FI/opencadstudio.ftl @@ -6594,3 +6594,39 @@ 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. + +dgn-import = + .supported = Supported + .by-extension = By extension + .acis-solver = ACIS solver available + .export-only = Export only + .acis-reason = ACIS bodies are read only inside drawings; SAT files cannot be imported. + .step-reason = STEP files are only written (STEPOUT), not read. + .no-importer = OpenCAD Studio has no importer for this format. + .recent = Recent: + .group-2d = 2D / Underlay + .group-3d = 3D Model + .search = Search formats... + .only-supported = Only what OCS can open + .models = __ocs_fmt_0__ model(s) + .change = Change file... + .name-conflict = On name conflict + .prefix = Add prefix to dependent definitions + .ignore-duplicates = Ignore duplicate names + .master-unit = Master unit + .sub-unit = Sub unit + .explode = Explode text nodes to text elements + .references = External DGN references + .bind = Bind as block / xref + .attach = Attach as DGN underlay + .references-note = Reference attachments of DGN files are not read; they are left out. + .mapping = Property mapping + .mapping-standard = Standard mapping setup. + .mapping-note = The preview shows the selected setup's effect on this file. + .import-file = Import File + .dgn-import = DGN Import + .import-dgn-file = Import DGN File + .help-dgn = Imports a DGN model as drawing objects: levels as layers, cells as blocks. + .help-import = Imports a file of the chosen format with the format's importer. + .blank = Filename cannot be blank + .invalid-dgn = Invalid or unsupported DGN file. diff --git a/locales/fr-FR/opencadstudio.ftl b/locales/fr-FR/opencadstudio.ftl index ae9d437ba..8ac41b726 100644 --- a/locales/fr-FR/opencadstudio.ftl +++ b/locales/fr-FR/opencadstudio.ftl @@ -6597,3 +6597,39 @@ 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. + +dgn-import = + .supported = Supported + .by-extension = By extension + .acis-solver = ACIS solver available + .export-only = Export only + .acis-reason = ACIS bodies are read only inside drawings; SAT files cannot be imported. + .step-reason = STEP files are only written (STEPOUT), not read. + .no-importer = OpenCAD Studio has no importer for this format. + .recent = Recent: + .group-2d = 2D / Underlay + .group-3d = 3D Model + .search = Search formats... + .only-supported = Only what OCS can open + .models = __ocs_fmt_0__ model(s) + .change = Change file... + .name-conflict = On name conflict + .prefix = Add prefix to dependent definitions + .ignore-duplicates = Ignore duplicate names + .master-unit = Master unit + .sub-unit = Sub unit + .explode = Explode text nodes to text elements + .references = External DGN references + .bind = Bind as block / xref + .attach = Attach as DGN underlay + .references-note = Reference attachments of DGN files are not read; they are left out. + .mapping = Property mapping + .mapping-standard = Standard mapping setup. + .mapping-note = The preview shows the selected setup's effect on this file. + .import-file = Import File + .dgn-import = DGN Import + .import-dgn-file = Import DGN File + .help-dgn = Imports a DGN model as drawing objects: levels as layers, cells as blocks. + .help-import = Imports a file of the chosen format with the format's importer. + .blank = Filename cannot be blank + .invalid-dgn = Invalid or unsupported DGN file. diff --git a/locales/hi-IN/opencadstudio.ftl b/locales/hi-IN/opencadstudio.ftl index db5a95696..816dbca37 100644 --- a/locales/hi-IN/opencadstudio.ftl +++ b/locales/hi-IN/opencadstudio.ftl @@ -6585,3 +6585,39 @@ 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. + +dgn-import = + .supported = Supported + .by-extension = By extension + .acis-solver = ACIS solver available + .export-only = Export only + .acis-reason = ACIS bodies are read only inside drawings; SAT files cannot be imported. + .step-reason = STEP files are only written (STEPOUT), not read. + .no-importer = OpenCAD Studio has no importer for this format. + .recent = Recent: + .group-2d = 2D / Underlay + .group-3d = 3D Model + .search = Search formats... + .only-supported = Only what OCS can open + .models = __ocs_fmt_0__ model(s) + .change = Change file... + .name-conflict = On name conflict + .prefix = Add prefix to dependent definitions + .ignore-duplicates = Ignore duplicate names + .master-unit = Master unit + .sub-unit = Sub unit + .explode = Explode text nodes to text elements + .references = External DGN references + .bind = Bind as block / xref + .attach = Attach as DGN underlay + .references-note = Reference attachments of DGN files are not read; they are left out. + .mapping = Property mapping + .mapping-standard = Standard mapping setup. + .mapping-note = The preview shows the selected setup's effect on this file. + .import-file = Import File + .dgn-import = DGN Import + .import-dgn-file = Import DGN File + .help-dgn = Imports a DGN model as drawing objects: levels as layers, cells as blocks. + .help-import = Imports a file of the chosen format with the format's importer. + .blank = Filename cannot be blank + .invalid-dgn = Invalid or unsupported DGN file. diff --git a/locales/hu-HU/opencadstudio.ftl b/locales/hu-HU/opencadstudio.ftl index a31cd56a4..83a802b65 100644 --- a/locales/hu-HU/opencadstudio.ftl +++ b/locales/hu-HU/opencadstudio.ftl @@ -6581,3 +6581,39 @@ 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. + +dgn-import = + .supported = Supported + .by-extension = By extension + .acis-solver = ACIS solver available + .export-only = Export only + .acis-reason = ACIS bodies are read only inside drawings; SAT files cannot be imported. + .step-reason = STEP files are only written (STEPOUT), not read. + .no-importer = OpenCAD Studio has no importer for this format. + .recent = Recent: + .group-2d = 2D / Underlay + .group-3d = 3D Model + .search = Search formats... + .only-supported = Only what OCS can open + .models = __ocs_fmt_0__ model(s) + .change = Change file... + .name-conflict = On name conflict + .prefix = Add prefix to dependent definitions + .ignore-duplicates = Ignore duplicate names + .master-unit = Master unit + .sub-unit = Sub unit + .explode = Explode text nodes to text elements + .references = External DGN references + .bind = Bind as block / xref + .attach = Attach as DGN underlay + .references-note = Reference attachments of DGN files are not read; they are left out. + .mapping = Property mapping + .mapping-standard = Standard mapping setup. + .mapping-note = The preview shows the selected setup's effect on this file. + .import-file = Import File + .dgn-import = DGN Import + .import-dgn-file = Import DGN File + .help-dgn = Imports a DGN model as drawing objects: levels as layers, cells as blocks. + .help-import = Imports a file of the chosen format with the format's importer. + .blank = Filename cannot be blank + .invalid-dgn = Invalid or unsupported DGN file. diff --git a/locales/it-IT/opencadstudio.ftl b/locales/it-IT/opencadstudio.ftl index 1d1563b99..b66974bc4 100644 --- a/locales/it-IT/opencadstudio.ftl +++ b/locales/it-IT/opencadstudio.ftl @@ -6596,3 +6596,39 @@ 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. + +dgn-import = + .supported = Supported + .by-extension = By extension + .acis-solver = ACIS solver available + .export-only = Export only + .acis-reason = ACIS bodies are read only inside drawings; SAT files cannot be imported. + .step-reason = STEP files are only written (STEPOUT), not read. + .no-importer = OpenCAD Studio has no importer for this format. + .recent = Recent: + .group-2d = 2D / Underlay + .group-3d = 3D Model + .search = Search formats... + .only-supported = Only what OCS can open + .models = __ocs_fmt_0__ model(s) + .change = Change file... + .name-conflict = On name conflict + .prefix = Add prefix to dependent definitions + .ignore-duplicates = Ignore duplicate names + .master-unit = Master unit + .sub-unit = Sub unit + .explode = Explode text nodes to text elements + .references = External DGN references + .bind = Bind as block / xref + .attach = Attach as DGN underlay + .references-note = Reference attachments of DGN files are not read; they are left out. + .mapping = Property mapping + .mapping-standard = Standard mapping setup. + .mapping-note = The preview shows the selected setup's effect on this file. + .import-file = Import File + .dgn-import = DGN Import + .import-dgn-file = Import DGN File + .help-dgn = Imports a DGN model as drawing objects: levels as layers, cells as blocks. + .help-import = Imports a file of the chosen format with the format's importer. + .blank = Filename cannot be blank + .invalid-dgn = Invalid or unsupported DGN file. diff --git a/locales/ja-JP/opencadstudio.ftl b/locales/ja-JP/opencadstudio.ftl index 26de504d7..f288bb5ff 100644 --- a/locales/ja-JP/opencadstudio.ftl +++ b/locales/ja-JP/opencadstudio.ftl @@ -6585,3 +6585,39 @@ 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. + +dgn-import = + .supported = Supported + .by-extension = By extension + .acis-solver = ACIS solver available + .export-only = Export only + .acis-reason = ACIS bodies are read only inside drawings; SAT files cannot be imported. + .step-reason = STEP files are only written (STEPOUT), not read. + .no-importer = OpenCAD Studio has no importer for this format. + .recent = Recent: + .group-2d = 2D / Underlay + .group-3d = 3D Model + .search = Search formats... + .only-supported = Only what OCS can open + .models = __ocs_fmt_0__ model(s) + .change = Change file... + .name-conflict = On name conflict + .prefix = Add prefix to dependent definitions + .ignore-duplicates = Ignore duplicate names + .master-unit = Master unit + .sub-unit = Sub unit + .explode = Explode text nodes to text elements + .references = External DGN references + .bind = Bind as block / xref + .attach = Attach as DGN underlay + .references-note = Reference attachments of DGN files are not read; they are left out. + .mapping = Property mapping + .mapping-standard = Standard mapping setup. + .mapping-note = The preview shows the selected setup's effect on this file. + .import-file = Import File + .dgn-import = DGN Import + .import-dgn-file = Import DGN File + .help-dgn = Imports a DGN model as drawing objects: levels as layers, cells as blocks. + .help-import = Imports a file of the chosen format with the format's importer. + .blank = Filename cannot be blank + .invalid-dgn = Invalid or unsupported DGN file. diff --git a/locales/ko-KR/opencadstudio.ftl b/locales/ko-KR/opencadstudio.ftl index c43410f04..8c1d00307 100644 --- a/locales/ko-KR/opencadstudio.ftl +++ b/locales/ko-KR/opencadstudio.ftl @@ -6596,3 +6596,39 @@ 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. + +dgn-import = + .supported = Supported + .by-extension = By extension + .acis-solver = ACIS solver available + .export-only = Export only + .acis-reason = ACIS bodies are read only inside drawings; SAT files cannot be imported. + .step-reason = STEP files are only written (STEPOUT), not read. + .no-importer = OpenCAD Studio has no importer for this format. + .recent = Recent: + .group-2d = 2D / Underlay + .group-3d = 3D Model + .search = Search formats... + .only-supported = Only what OCS can open + .models = __ocs_fmt_0__ model(s) + .change = Change file... + .name-conflict = On name conflict + .prefix = Add prefix to dependent definitions + .ignore-duplicates = Ignore duplicate names + .master-unit = Master unit + .sub-unit = Sub unit + .explode = Explode text nodes to text elements + .references = External DGN references + .bind = Bind as block / xref + .attach = Attach as DGN underlay + .references-note = Reference attachments of DGN files are not read; they are left out. + .mapping = Property mapping + .mapping-standard = Standard mapping setup. + .mapping-note = The preview shows the selected setup's effect on this file. + .import-file = Import File + .dgn-import = DGN Import + .import-dgn-file = Import DGN File + .help-dgn = Imports a DGN model as drawing objects: levels as layers, cells as blocks. + .help-import = Imports a file of the chosen format with the format's importer. + .blank = Filename cannot be blank + .invalid-dgn = Invalid or unsupported DGN file. diff --git a/locales/lo-LA/opencadstudio.ftl b/locales/lo-LA/opencadstudio.ftl index 8f51fc302..2881c3bb9 100644 --- a/locales/lo-LA/opencadstudio.ftl +++ b/locales/lo-LA/opencadstudio.ftl @@ -6597,3 +6597,39 @@ 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. + +dgn-import = + .supported = Supported + .by-extension = By extension + .acis-solver = ACIS solver available + .export-only = Export only + .acis-reason = ACIS bodies are read only inside drawings; SAT files cannot be imported. + .step-reason = STEP files are only written (STEPOUT), not read. + .no-importer = OpenCAD Studio has no importer for this format. + .recent = Recent: + .group-2d = 2D / Underlay + .group-3d = 3D Model + .search = Search formats... + .only-supported = Only what OCS can open + .models = __ocs_fmt_0__ model(s) + .change = Change file... + .name-conflict = On name conflict + .prefix = Add prefix to dependent definitions + .ignore-duplicates = Ignore duplicate names + .master-unit = Master unit + .sub-unit = Sub unit + .explode = Explode text nodes to text elements + .references = External DGN references + .bind = Bind as block / xref + .attach = Attach as DGN underlay + .references-note = Reference attachments of DGN files are not read; they are left out. + .mapping = Property mapping + .mapping-standard = Standard mapping setup. + .mapping-note = The preview shows the selected setup's effect on this file. + .import-file = Import File + .dgn-import = DGN Import + .import-dgn-file = Import DGN File + .help-dgn = Imports a DGN model as drawing objects: levels as layers, cells as blocks. + .help-import = Imports a file of the chosen format with the format's importer. + .blank = Filename cannot be blank + .invalid-dgn = Invalid or unsupported DGN file. diff --git a/locales/nl-NL/opencadstudio.ftl b/locales/nl-NL/opencadstudio.ftl index 761118ba4..e8fedb98c 100644 --- a/locales/nl-NL/opencadstudio.ftl +++ b/locales/nl-NL/opencadstudio.ftl @@ -6595,3 +6595,39 @@ 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. + +dgn-import = + .supported = Supported + .by-extension = By extension + .acis-solver = ACIS solver available + .export-only = Export only + .acis-reason = ACIS bodies are read only inside drawings; SAT files cannot be imported. + .step-reason = STEP files are only written (STEPOUT), not read. + .no-importer = OpenCAD Studio has no importer for this format. + .recent = Recent: + .group-2d = 2D / Underlay + .group-3d = 3D Model + .search = Search formats... + .only-supported = Only what OCS can open + .models = __ocs_fmt_0__ model(s) + .change = Change file... + .name-conflict = On name conflict + .prefix = Add prefix to dependent definitions + .ignore-duplicates = Ignore duplicate names + .master-unit = Master unit + .sub-unit = Sub unit + .explode = Explode text nodes to text elements + .references = External DGN references + .bind = Bind as block / xref + .attach = Attach as DGN underlay + .references-note = Reference attachments of DGN files are not read; they are left out. + .mapping = Property mapping + .mapping-standard = Standard mapping setup. + .mapping-note = The preview shows the selected setup's effect on this file. + .import-file = Import File + .dgn-import = DGN Import + .import-dgn-file = Import DGN File + .help-dgn = Imports a DGN model as drawing objects: levels as layers, cells as blocks. + .help-import = Imports a file of the chosen format with the format's importer. + .blank = Filename cannot be blank + .invalid-dgn = Invalid or unsupported DGN file. diff --git a/locales/pl-PL/opencadstudio.ftl b/locales/pl-PL/opencadstudio.ftl index 16d72a254..d830cebbf 100644 --- a/locales/pl-PL/opencadstudio.ftl +++ b/locales/pl-PL/opencadstudio.ftl @@ -6596,3 +6596,39 @@ 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. + +dgn-import = + .supported = Supported + .by-extension = By extension + .acis-solver = ACIS solver available + .export-only = Export only + .acis-reason = ACIS bodies are read only inside drawings; SAT files cannot be imported. + .step-reason = STEP files are only written (STEPOUT), not read. + .no-importer = OpenCAD Studio has no importer for this format. + .recent = Recent: + .group-2d = 2D / Underlay + .group-3d = 3D Model + .search = Search formats... + .only-supported = Only what OCS can open + .models = __ocs_fmt_0__ model(s) + .change = Change file... + .name-conflict = On name conflict + .prefix = Add prefix to dependent definitions + .ignore-duplicates = Ignore duplicate names + .master-unit = Master unit + .sub-unit = Sub unit + .explode = Explode text nodes to text elements + .references = External DGN references + .bind = Bind as block / xref + .attach = Attach as DGN underlay + .references-note = Reference attachments of DGN files are not read; they are left out. + .mapping = Property mapping + .mapping-standard = Standard mapping setup. + .mapping-note = The preview shows the selected setup's effect on this file. + .import-file = Import File + .dgn-import = DGN Import + .import-dgn-file = Import DGN File + .help-dgn = Imports a DGN model as drawing objects: levels as layers, cells as blocks. + .help-import = Imports a file of the chosen format with the format's importer. + .blank = Filename cannot be blank + .invalid-dgn = Invalid or unsupported DGN file. diff --git a/locales/pt-BR/opencadstudio.ftl b/locales/pt-BR/opencadstudio.ftl index 3ffec2a7b..49c381420 100644 --- a/locales/pt-BR/opencadstudio.ftl +++ b/locales/pt-BR/opencadstudio.ftl @@ -6597,3 +6597,39 @@ 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. + +dgn-import = + .supported = Supported + .by-extension = By extension + .acis-solver = ACIS solver available + .export-only = Export only + .acis-reason = ACIS bodies are read only inside drawings; SAT files cannot be imported. + .step-reason = STEP files are only written (STEPOUT), not read. + .no-importer = OpenCAD Studio has no importer for this format. + .recent = Recent: + .group-2d = 2D / Underlay + .group-3d = 3D Model + .search = Search formats... + .only-supported = Only what OCS can open + .models = __ocs_fmt_0__ model(s) + .change = Change file... + .name-conflict = On name conflict + .prefix = Add prefix to dependent definitions + .ignore-duplicates = Ignore duplicate names + .master-unit = Master unit + .sub-unit = Sub unit + .explode = Explode text nodes to text elements + .references = External DGN references + .bind = Bind as block / xref + .attach = Attach as DGN underlay + .references-note = Reference attachments of DGN files are not read; they are left out. + .mapping = Property mapping + .mapping-standard = Standard mapping setup. + .mapping-note = The preview shows the selected setup's effect on this file. + .import-file = Import File + .dgn-import = DGN Import + .import-dgn-file = Import DGN File + .help-dgn = Imports a DGN model as drawing objects: levels as layers, cells as blocks. + .help-import = Imports a file of the chosen format with the format's importer. + .blank = Filename cannot be blank + .invalid-dgn = Invalid or unsupported DGN file. diff --git a/locales/ru-RU/opencadstudio.ftl b/locales/ru-RU/opencadstudio.ftl index 3840440f1..37c5b3730 100644 --- a/locales/ru-RU/opencadstudio.ftl +++ b/locales/ru-RU/opencadstudio.ftl @@ -6598,3 +6598,39 @@ 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. + +dgn-import = + .supported = Supported + .by-extension = By extension + .acis-solver = ACIS solver available + .export-only = Export only + .acis-reason = ACIS bodies are read only inside drawings; SAT files cannot be imported. + .step-reason = STEP files are only written (STEPOUT), not read. + .no-importer = OpenCAD Studio has no importer for this format. + .recent = Recent: + .group-2d = 2D / Underlay + .group-3d = 3D Model + .search = Search formats... + .only-supported = Only what OCS can open + .models = __ocs_fmt_0__ model(s) + .change = Change file... + .name-conflict = On name conflict + .prefix = Add prefix to dependent definitions + .ignore-duplicates = Ignore duplicate names + .master-unit = Master unit + .sub-unit = Sub unit + .explode = Explode text nodes to text elements + .references = External DGN references + .bind = Bind as block / xref + .attach = Attach as DGN underlay + .references-note = Reference attachments of DGN files are not read; they are left out. + .mapping = Property mapping + .mapping-standard = Standard mapping setup. + .mapping-note = The preview shows the selected setup's effect on this file. + .import-file = Import File + .dgn-import = DGN Import + .import-dgn-file = Import DGN File + .help-dgn = Imports a DGN model as drawing objects: levels as layers, cells as blocks. + .help-import = Imports a file of the chosen format with the format's importer. + .blank = Filename cannot be blank + .invalid-dgn = Invalid or unsupported DGN file. diff --git a/locales/tr-TR/opencadstudio.ftl b/locales/tr-TR/opencadstudio.ftl index 2733014ca..c3260e41d 100644 --- a/locales/tr-TR/opencadstudio.ftl +++ b/locales/tr-TR/opencadstudio.ftl @@ -6518,3 +6518,39 @@ 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. + +dgn-import = + .supported = Destekleniyor + .by-extension = Uzantıya göre + .acis-solver = ACIS çözücü var + .export-only = Yalnız dışa aktarma + .acis-reason = ACIS gövdeleri yalnız çizim içinde okunur; SAT dosyası içe aktarılamaz. + .step-reason = STEP dosyaları yalnız yazılır (STEPOUT), okunmaz. + .no-importer = OpenCAD Studio'da bu biçim için içe aktarıcı yok. + .recent = Son: + .group-2d = 2B / Altlık + .group-3d = 3B Model + .search = Biçim ara... + .only-supported = Yalnız OCS'nin açabildikleri + .models = __ocs_fmt_0__ model + .change = Değiştir... + .name-conflict = Ad çakışmasında + .prefix = Bağımlı tanımlara önek ekle + .ignore-duplicates = Yinelenen adları yok say + .master-unit = Ana birim + .sub-unit = Alt birim + .explode = Metin düğümlerini metin öğelerine patlat + .references = Dış DGN referansları + .bind = Blok / xref yap + .attach = DGN altlığı bağla + .references-note = DGN dosyalarının referans ekleri okunmaz; dışarıda bırakılır. + .mapping = Özellik eşleme + .mapping-standard = Standart eşleme kurulumu. + .mapping-note = Önizleme seçili kurulumun bu dosyaya etkisini gösterir. + .import-file = Dosya İçe Aktar + .dgn-import = DGN İçe Aktar + .import-dgn-file = DGN Dosyası İçe Aktar + .help-dgn = DGN modelini çizim nesneleri olarak içe aktarır: seviyeler katman, hücreler blok olur. + .help-import = Seçilen biçimdeki dosyayı o biçimin içe aktarıcısıyla içe aktarır. + .blank = Dosya adı boş olamaz + .invalid-dgn = Geçersiz veya desteklenmeyen DGN dosyası. diff --git a/locales/zh-CN/opencadstudio.ftl b/locales/zh-CN/opencadstudio.ftl index 81edca569..7cbeef634 100644 --- a/locales/zh-CN/opencadstudio.ftl +++ b/locales/zh-CN/opencadstudio.ftl @@ -6580,3 +6580,39 @@ 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. + +dgn-import = + .supported = Supported + .by-extension = By extension + .acis-solver = ACIS solver available + .export-only = Export only + .acis-reason = ACIS bodies are read only inside drawings; SAT files cannot be imported. + .step-reason = STEP files are only written (STEPOUT), not read. + .no-importer = OpenCAD Studio has no importer for this format. + .recent = Recent: + .group-2d = 2D / Underlay + .group-3d = 3D Model + .search = Search formats... + .only-supported = Only what OCS can open + .models = __ocs_fmt_0__ model(s) + .change = Change file... + .name-conflict = On name conflict + .prefix = Add prefix to dependent definitions + .ignore-duplicates = Ignore duplicate names + .master-unit = Master unit + .sub-unit = Sub unit + .explode = Explode text nodes to text elements + .references = External DGN references + .bind = Bind as block / xref + .attach = Attach as DGN underlay + .references-note = Reference attachments of DGN files are not read; they are left out. + .mapping = Property mapping + .mapping-standard = Standard mapping setup. + .mapping-note = The preview shows the selected setup's effect on this file. + .import-file = Import File + .dgn-import = DGN Import + .import-dgn-file = Import DGN File + .help-dgn = Imports a DGN model as drawing objects: levels as layers, cells as blocks. + .help-import = Imports a file of the chosen format with the format's importer. + .blank = Filename cannot be blank + .invalid-dgn = Invalid or unsupported DGN file. diff --git a/locales/zh-TW/opencadstudio.ftl b/locales/zh-TW/opencadstudio.ftl index b7941d3ed..058f3ae60 100644 --- a/locales/zh-TW/opencadstudio.ftl +++ b/locales/zh-TW/opencadstudio.ftl @@ -6592,3 +6592,39 @@ 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. + +dgn-import = + .supported = Supported + .by-extension = By extension + .acis-solver = ACIS solver available + .export-only = Export only + .acis-reason = ACIS bodies are read only inside drawings; SAT files cannot be imported. + .step-reason = STEP files are only written (STEPOUT), not read. + .no-importer = OpenCAD Studio has no importer for this format. + .recent = Recent: + .group-2d = 2D / Underlay + .group-3d = 3D Model + .search = Search formats... + .only-supported = Only what OCS can open + .models = __ocs_fmt_0__ model(s) + .change = Change file... + .name-conflict = On name conflict + .prefix = Add prefix to dependent definitions + .ignore-duplicates = Ignore duplicate names + .master-unit = Master unit + .sub-unit = Sub unit + .explode = Explode text nodes to text elements + .references = External DGN references + .bind = Bind as block / xref + .attach = Attach as DGN underlay + .references-note = Reference attachments of DGN files are not read; they are left out. + .mapping = Property mapping + .mapping-standard = Standard mapping setup. + .mapping-note = The preview shows the selected setup's effect on this file. + .import-file = Import File + .dgn-import = DGN Import + .import-dgn-file = Import DGN File + .help-dgn = Imports a DGN model as drawing objects: levels as layers, cells as blocks. + .help-import = Imports a file of the chosen format with the format's importer. + .blank = Filename cannot be blank + .invalid-dgn = Invalid or unsupported DGN file. diff --git a/src/app/command_driver/mod.rs b/src/app/command_driver/mod.rs index 5eed4f834..79647a956 100644 --- a/src/app/command_driver/mod.rs +++ b/src/app/command_driver/mod.rs @@ -627,6 +627,12 @@ impl OpenCADStudio { self.apply_xclip(i, inserts, action); } CmdResult::OpenPdfImportSettings => self.open_pdf_import_settings(), + CmdResult::DgnImport(request) => { + self.tabs[i].scene.clear_preview_wire(); + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + return self.run_dgn_import(i, request); + } CmdResult::PdfImportFile(import) => { self.tabs[i].scene.clear_preview_wire(); self.tabs[i].active_cmd = None; diff --git a/src/app/commands/dgn_import.rs b/src/app/commands/dgn_import.rs new file mode 100644 index 000000000..23efa5b81 --- /dev/null +++ b/src/app/commands/dgn_import.rs @@ -0,0 +1,501 @@ +//! IMPORT (the Import File window, or a file name on the command line, +//! routed by format) and DGNIMPORT / -DGNIMPORT (the DGN Import window or +//! the command line), and the import itself: a DGN model's layers, text +//! styles, cells and objects added as one undo step, into a new drawing +//! unless the window chose the current one. + +use std::collections::HashMap; +use std::sync::Arc; + +use super::*; +use crate::modules::insert::dgn_import::{self, DgnImportCommand, DgnImportRequest}; +use crate::ui::window::import_dialogs::{ + unsupported_reason, DgnImportState, ImportDialogMsg, ImportFileState, Support, FORMATS, +}; + +/// How many files the Import File window offers as recent. +const RECENT: usize = 5; + +/// Picks a file and registers its bytes (the web build reads them only +/// here); `None` when the picker is cancelled. +async fn pick(title: String, filters: Vec<(String, Vec)>) -> Option { + let mut dialog = crate::sys::file_dialog().set_title(title.as_str()); + for (name, extensions) in &filters { + let extensions: Vec<&str> = extensions.iter().map(String::as_str).collect(); + dialog = dialog.add_filter(name.as_str(), &extensions); + } + let handle = dialog.pick_file().await?; + let path = crate::sys::handle_path(&handle) + .to_string_lossy() + .into_owned(); + let bytes = handle.read().await; + crate::scene::model::pdf_raster::register_source(&path, Arc::new(bytes)); + Some(path) +} + +fn dgn_filters() -> Vec<(String, Vec)> { + vec![ + ("MicroStation DGN (*.dgn)".to_string(), vec!["dgn".to_string()]), + ("All DGN Files (*.*)".to_string(), vec!["*".to_string()]), + ] +} + +fn extension(path: &str) -> String { + std::path::Path::new(path) + .extension() + .map(|e| e.to_string_lossy().to_ascii_lowercase()) + .unwrap_or_default() +} + +fn exists(path: &str) -> bool { + crate::scene::model::pdf_raster::source_bytes(path).is_some() +} + +/// Whether the file is a DGN model DGNIMPORT reads. +fn is_dgn(path: &str) -> bool { + crate::scene::model::underlay_vector::item_names(codec::entities::UnderlayType::Dgn, path) + .and_then(|models| models.into_iter().next()) + .is_some_and(|model| crate::scene::model::underlay_vector::dgn_import(path, &model).is_some()) +} + +/// The importer a file goes to. +enum Route { + Pdf, + Dgn, +} + +/// The importer for `path` chosen as `format` (an index of [`FORMATS`]; `None` +/// follows the extension), or why there is none. +fn route(path: &str, format: Option) -> Result { + let f = format.map(|k| &FORMATS[k]); + if let Some(f) = f { + if let Some(reason) = unsupported_reason(f.support) { + return Err(format!("{}: {reason}", f.name)); + } + } + let ext = extension(path); + let by_extension = f.is_none_or(|f| f.support == Support::ByExtension); + let pdf = if by_extension { ext == "pdf" } else { f.is_some_and(|f| f.name == "PDF Files") }; + if pdf { + return Ok(Route::Pdf); + } + let dgn_format = f.is_some_and(|f| f.name.contains("DGN")); + if (dgn_format || by_extension) && is_dgn(path) { + return Ok(Route::Dgn); + } + if dgn_format || ext == "dgn" { + return Err(crate::t!("Invalid or unsupported DGN file.").into_owned()); + } + // Any other extension: the format whose patterns name it. + let named = FORMATS + .iter() + .find(|f| f.extensions.iter().any(|e| e.eq_ignore_ascii_case(&ext))); + Err(match named.and_then(|f| unsupported_reason(f.support).map(|r| (f, r))) { + Some((f, reason)) => format!("{}: {reason}", f.name), + None => crate::t!("OpenCAD Studio has no importer for this format.").into_owned(), + }) +} + +impl OpenCADStudio { + /// IMPORT, DGNIMPORT and -DGNIMPORT; `None` defers to the other + /// families. + pub(super) fn dispatch_import(&mut self, cmd: &str, i: usize) -> Option> { + let (verb, rest) = match cmd.trim().split_once(char::is_whitespace) { + Some((verb, rest)) => (verb.to_ascii_uppercase(), rest.trim().trim_matches('"')), + None => (cmd.trim().to_ascii_uppercase(), ""), + }; + let dialog = crate::sys::file_dia(); + match (verb.as_str(), rest.is_empty()) { + ("IMPORT", true) if dialog => { + self.open_import_file_dialog(); + Some(Task::none()) + } + ("IMPORT", true) => { + use crate::command::{CadCommand, ValuePromptCommand}; + let command = ValuePromptCommand::new("_IMPORTFILE", "Enter import file name:"); + self.command_line.push_info(&command.prompt()); + self.tabs[i].active_cmd = Some(Box::new(command)); + Some(Task::none()) + } + // A file named after IMPORT (or typed at its prompt). + ("IMPORT" | "_IMPORTFILE", false) => Some(self.import_routed(rest, None)), + ("_IMPORTFILE", true) => Some(Task::none()), + ("DGNIMPORT", true) if dialog => Some(self.pick_dgn_file()), + ("DGNIMPORT" | "-DGNIMPORT", _) => { + use crate::command::CadCommand; + let command = DgnImportCommand::new(); + self.command_line.push_info(&command.prompt()); + self.tabs[i].active_cmd = Some(Box::new(command)); + if !rest.is_empty() { + return Some( + self.feed_command(crate::command::StepInput::Text(rest.to_string())), + ); + } + Some(Task::none()) + } + _ => None, + } + } + + fn pick_dgn_file(&mut self) -> Task { + let title = crate::t!("Import DGN File").into_owned(); + Task::perform(pick(title, dgn_filters()), |path| { + Message::ImportDialog(ImportDialogMsg::DgnPicked(path)) + }) + } + + pub(in crate::app) fn open_import_file_dialog(&mut self) { + let path = self.import_dialogs.recent.first().cloned().unwrap_or_default(); + self.import_dialogs.file = Some(ImportFileState { + path, + format: 0, + search: String::new(), + only_supported: false, + error: None, + }); + self.active_modal = Some(crate::app::ModalKind::ImportFile); + } + + /// The DGN Import window for a file; an error when it is no DGN model. + pub(in crate::app) fn open_dgn_import_dialog(&mut self, path: &str) -> Result<(), String> { + use crate::scene::model::underlay_vector::{dgn_import, item_names}; + let models = item_names(codec::entities::UnderlayType::Dgn, path).unwrap_or_default(); + let Some(import) = models.first().and_then(|m| dgn_import(path, m)) else { + return Err(crate::t!("Invalid or unsupported DGN file.").into_owned()); + }; + // The options chosen last stay when the file is changed. + let previous = self.import_dialogs.dgn.take(); + let keep = |f: fn(&DgnImportState) -> bool, default: bool| { + previous.as_ref().map_or(default, f) + }; + self.import_dialogs.dgn = Some(DgnImportState { + path: path.to_string(), + version: if import.v7 { "V7" } else { "V8" }, + models: crate::ui::window::pdf_dialogs::item_thumbs( + codec::entities::UnderlayType::Dgn, + path, + ), + model: 0, + current_drawing: keep(|s| s.current_drawing, false), + prefix_duplicates: keep(|s| s.prefix_duplicates, false), + sub_units: keep(|s| s.sub_units, false), + explode_text_nodes: keep(|s| s.explode_text_nodes, false), + units: import.unit_names.clone(), + mapping_tab: previous.as_ref().map_or(0, |s| s.mapping_tab), + mapping: [0, 1, 2, 3].map(|tab| dgn_import::mapping_rows(&import, tab)), + }); + self.active_modal = Some(crate::app::ModalKind::DgnImport); + Ok(()) + } + + fn remember_import(&mut self, path: &str) { + let recent = &mut self.import_dialogs.recent; + recent.retain(|p| p != path); + recent.insert(0, path.to_string()); + recent.truncate(RECENT); + } + + /// IMPORT on the command line: the file to its importer. + fn import_routed(&mut self, path: &str, format: Option) -> Task { + if !exists(path) { + self.command_line + .push_error(&format!("File {path} does not exist.")); + return Task::none(); + } + match route(path, format) { + Ok(Route::Pdf) => { + self.remember_import(path); + self.dispatch_command(&format!("_PDFIMPORTFILE {path}")) + } + Ok(Route::Dgn) => { + self.remember_import(path); + if crate::sys::file_dia() { + if let Err(error) = self.open_dgn_import_dialog(path) { + self.command_line.push_error(&error); + } + return Task::none(); + } + self.dispatch_command(&format!("-DGNIMPORT {path}")) + } + Err(error) => { + self.command_line.push_error(&error); + Task::none() + } + } + } + + pub(in crate::app) fn update_import_dialog(&mut self, message: ImportDialogMsg) -> Task { + let i = self.active_tab; + match message { + ImportDialogMsg::Help(which) => { + let help = match which { + "dgn" => crate::t!( + "Imports a DGN model as drawing objects: levels as layers, cells as blocks." + ), + _ => crate::t!( + "Imports a file of the chosen format with the format's importer." + ), + }; + self.command_line.push_info(help.as_ref()); + } + // ── Import File ────────────────────────────────────────────── + ImportDialogMsg::Path(path) => { + if let Some(s) = self.import_dialogs.file.as_mut() { + s.path = path; + s.error = None; + } + } + ImportDialogMsg::Recent(n) => { + let path = self.import_dialogs.recent.get(n).cloned(); + if let (Some(s), Some(path)) = (self.import_dialogs.file.as_mut(), path) { + s.path = path; + s.error = None; + } + } + ImportDialogMsg::Format(k) => { + if let Some(s) = self.import_dialogs.file.as_mut() { + s.format = k.min(FORMATS.len() - 1); + s.error = None; + } + } + ImportDialogMsg::Search(text) => { + if let Some(s) = self.import_dialogs.file.as_mut() { + s.search = text; + } + } + ImportDialogMsg::OnlySupported(on) => { + if let Some(s) = self.import_dialogs.file.as_mut() { + s.only_supported = on; + } + } + ImportDialogMsg::Browse => { + let Some(s) = self.import_dialogs.file.as_ref() else { + return Task::none(); + }; + let f = &FORMATS[s.format]; + let filters = vec![( + format!("{} ({})", f.name, f.patterns), + f.extensions.iter().map(|e| e.to_string()).collect(), + )]; + let title = crate::t!("Import File").into_owned(); + return Task::perform(pick(title, filters), |path| { + Message::ImportDialog(ImportDialogMsg::Picked(path)) + }); + } + ImportDialogMsg::Picked(Some(path)) => { + if let Some(s) = self.import_dialogs.file.as_mut() { + s.path = path; + s.error = None; + } + } + ImportDialogMsg::Picked(None) => {} + ImportDialogMsg::ImportOk => { + let Some(s) = self.import_dialogs.file.as_ref() else { + return Task::none(); + }; + let path = s.path.trim().trim_matches('"').to_string(); + let format = s.format; + let error = if path.is_empty() { + Some(crate::t!("Filename cannot be blank").into_owned()) + } else if !exists(&path) { + Some(format!("File {path} does not exist.")) + } else { + None + }; + let routed = match error { + Some(error) => Err(error), + None => route(&path, Some(format)), + }; + match routed { + Err(error) => { + if let Some(s) = self.import_dialogs.file.as_mut() { + s.error = Some(error); + } + } + Ok(Route::Pdf) => { + self.remember_import(&path); + self.close_active_modal(); + return self.dispatch_command(&format!("_PDFIMPORTFILE {path}")); + } + Ok(Route::Dgn) => { + self.remember_import(&path); + self.close_active_modal(); + if let Err(error) = self.open_dgn_import_dialog(&path) { + self.command_line.push_error(&error); + } + } + } + } + // ── DGN Import ─────────────────────────────────────────────── + ImportDialogMsg::DgnChange => return self.pick_dgn_file(), + ImportDialogMsg::DgnPicked(Some(path)) => { + if let Err(error) = self.open_dgn_import_dialog(&path) { + self.command_line.push_error(&error); + } + } + ImportDialogMsg::DgnPicked(None) => {} + ImportDialogMsg::DgnModel(k) => { + use crate::scene::model::underlay_vector::dgn_import; + if let Some(s) = self.import_dialogs.dgn.as_mut() { + if let Some(model) = s.models.get(k) { + if let Some(import) = dgn_import(&s.path, &model.label) { + s.mapping = [0, 1, 2, 3].map(|tab| dgn_import::mapping_rows(&import, tab)); + s.units = import.unit_names.clone(); + } + s.model = k; + } + } + } + ImportDialogMsg::DgnCurrent(on) => { + if let Some(s) = self.import_dialogs.dgn.as_mut() { + s.current_drawing = on; + } + } + ImportDialogMsg::DgnPrefix(on) => { + if let Some(s) = self.import_dialogs.dgn.as_mut() { + s.prefix_duplicates = on; + } + } + ImportDialogMsg::DgnSub(on) => { + if let Some(s) = self.import_dialogs.dgn.as_mut() { + s.sub_units = on; + } + } + ImportDialogMsg::DgnExplode(on) => { + if let Some(s) = self.import_dialogs.dgn.as_mut() { + s.explode_text_nodes = on; + } + } + ImportDialogMsg::DgnMappingTab(tab) => { + if let Some(s) = self.import_dialogs.dgn.as_mut() { + s.mapping_tab = tab.min(3); + } + } + ImportDialogMsg::DgnOk => { + let Some(s) = self.import_dialogs.dgn.as_ref() else { + return Task::none(); + }; + let request = DgnImportRequest { + path: s.path.clone(), + model: s + .models + .get(s.model) + .map(|m| m.label.clone()) + .unwrap_or_default(), + sub_units: s.sub_units, + explode_text_nodes: s.explode_text_nodes, + current_drawing: s.current_drawing, + prefix_duplicates: s.prefix_duplicates, + }; + self.close_active_modal(); + return self.run_dgn_import(i, request); + } + } + Task::none() + } + + /// The import: into a new drawing (or the current one), one undo step. + pub(in crate::app) fn run_dgn_import(&mut self, i: usize, request: DgnImportRequest) -> Task { + let Some(import) = + crate::scene::model::underlay_vector::dgn_import(&request.path, &request.model) + else { + self.command_line + .push_error(crate::t!("Invalid or unsupported DGN file.").as_ref()); + return Task::none(); + }; + let converted = + dgn_import::convert(&import, request.sub_units, request.explode_text_nodes); + let mut task = Task::none(); + let i = if request.current_drawing { + i + } else { + task = self.update(Message::TabNew); + self.active_tab + }; + self.push_undo_snapshot(i, "DGNIMPORT"); + let stem = std::path::Path::new(&request.path.replace('\\', "/")) + .file_stem() + .map(|s| s.to_string_lossy().into_owned()) + .unwrap_or_default(); + let scene = &mut self.tabs[i].scene; + // In the current drawing, names it already has keep the existing + // definitions, or are imported under the file's name as prefix. + let prefix = (request.current_drawing && request.prefix_duplicates) + .then(|| format!("{stem}$0$")); + let rename = |taken: bool, name: &str| match &prefix { + Some(p) if taken && name != "0" => format!("{p}{name}"), + _ => name.to_string(), + }; + let mut layers: HashMap = HashMap::new(); + for (name, color) in &converted.layers { + let target = rename(scene.document.layers.contains(name), name); + if !scene.document.layers.contains(&target) { + let mut layer = codec::tables::Layer::new(target.as_str()); + layer.handle = scene.document.allocate_handle(); + layer.color = *color; + let _ = scene.document.layers.add(layer); + } + layers.insert(name.clone(), target); + } + let mut styles: HashMap = HashMap::new(); + for (name, font) in &converted.styles { + let target = rename(scene.document.text_styles.contains(name), name); + if !scene.document.text_styles.contains(&target) { + let mut style = codec::tables::TextStyle::new(target.as_str()); + style.handle = scene.document.allocate_handle(); + style.font_file = font.clone(); + let _ = scene.document.text_styles.add(style); + } + styles.insert(name.clone(), target); + } + let mut blocks: HashMap = HashMap::new(); + for (name, _) in &converted.blocks { + let taken = scene.document.block_records.get(name).is_some(); + blocks.insert(name.clone(), rename(taken, name)); + } + let fix = |mut entity: codec::EntityType| { + let common = entity.common_mut(); + if let Some(layer) = layers.get(&common.layer) { + common.layer = layer.clone(); + } + match &mut entity { + codec::EntityType::Text(t) => { + if let Some(s) = styles.get(&t.style) { + t.style = s.clone(); + } + } + codec::EntityType::MText(t) => { + if let Some(s) = styles.get(&t.style) { + t.style = s.clone(); + } + } + codec::EntityType::Insert(insert) => { + if let Some(b) = blocks.get(&insert.block_name) { + insert.block_name = b.clone(); + } + } + _ => {} + } + entity + }; + for (name, entities) in converted.blocks { + let target = &blocks[&name]; + if scene.document.block_records.get(target).is_some() { + continue; + } + let entities = entities.into_iter().map(&fix).collect(); + let _ = scene.define_block_from_owned_entities(entities, target, glam::DVec3::ZERO); + } + for entity in converted.entities { + scene.add_entity(fix(entity)); + } + if !request.current_drawing { + scene.fit_all(); + } + self.tabs[i].dirty = true; + self.command_line.push_output("Regenerating model."); + self.refresh_layer_panel(); + self.refresh_properties(); + task + } +} diff --git a/src/app/commands/mod.rs b/src/app/commands/mod.rs index 1b98f6377..bf6b5ffd0 100644 --- a/src/app/commands/mod.rs +++ b/src/app/commands/mod.rs @@ -10,6 +10,7 @@ mod blocks; mod blockvars; mod bvisibility; mod count; +mod dgn_import; mod dim; pub(crate) mod display; mod draw; @@ -336,6 +337,9 @@ impl OpenCADStudio { if let Some(t) = self.dispatch_xref_attach(cmd, i) { return Some(t); } + if let Some(t) = self.dispatch_import(cmd, i) { + return Some(t); + } if let Some(t) = self.dispatch_bvisibility(cmd, i) { return Some(t); } @@ -921,6 +925,9 @@ inventory::submit!(crate::command::CommandRegistration { "NEW", "QNEW", "OBJIMPORT", + "IMPORT", + "DGNIMPORT", + "-DGNIMPORT", "OPEN", "ORTHO", "P", diff --git a/src/app/control/actions.rs b/src/app/control/actions.rs index aa397d1f4..e0ca5b58e 100644 --- a/src/app/control/actions.rs +++ b/src/app/control/actions.rs @@ -105,6 +105,8 @@ pub(super) const NAMES: &[&str] = &[ "pdf_dialog_ok", "view_background", "image_adjust", + "import_file_dialog", + "dgn_import_dialog", "refedit_dialog", "pdf_layer_toggle", "pc_manager_toggle", @@ -2046,6 +2048,90 @@ impl OpenCADStudio { _ => return Err(bad()), } } + // The Import File window, one field per call: "path=", + // "format=", "search=", "filter=all|supported", + // "recent=", "ok" or "cancel". + "import_file_dialog" => { + use crate::ui::window::import_dialogs::{ImportDialogMsg as I, FORMATS}; + if self.active_modal != Some(crate::app::ModalKind::ImportFile) { + return Err(failure("no_dialog", "The Import File window is not open")); + } + let spec = string(req, "value")?; + let (key, value) = spec.split_once('=').unwrap_or((spec, "")); + let bad = || failure("invalid_value", spec); + if key == "cancel" { + return Ok(Task::done(Message::CloseModal)); + } + Message::ImportDialog(match key { + "ok" => I::ImportOk, + "path" => I::Path(value.to_string()), + "search" => I::Search(value.to_string()), + "filter" => I::OnlySupported(match value { + "all" => false, + "supported" => true, + _ => return Err(bad()), + }), + "recent" => I::Recent(value.trim().parse().map_err(|_| bad())?), + "format" => I::Format(match value.trim().parse::() { + Ok(k) if k < FORMATS.len() => k, + _ => FORMATS + .iter() + .position(|f| f.name.eq_ignore_ascii_case(value.trim())) + .ok_or_else(bad)?, + }), + _ => return Err(bad()), + }) + } + // The DGN Import window, one field per call: "path=" (as + // Change… picks it), "model=", "target=new|current", + // "conflict=prefix|ignore", "units=master|sub", "explode=on|off", + // "mapping=layer|linetype|lineweight|color", "ok" or "cancel". + "dgn_import_dialog" => { + use crate::ui::window::import_dialogs::ImportDialogMsg as I; + if self.active_modal != Some(crate::app::ModalKind::DgnImport) { + return Err(failure("no_dialog", "The DGN Import window is not open")); + } + let spec = string(req, "value")?; + let (key, value) = spec.split_once('=').unwrap_or((spec, "")); + let bad = || failure("invalid_value", spec); + let on = |v: &str| match v { + "on" | "1" | "true" => Ok(true), + "off" | "0" | "false" => Ok(false), + _ => Err(bad()), + }; + if key == "cancel" { + return Ok(Task::done(Message::CloseModal)); + } + Message::ImportDialog(match key { + "ok" => I::DgnOk, + "path" => I::DgnPicked(Some(value.to_string())), + "model" => I::DgnModel(value.trim().parse().map_err(|_| bad())?), + "target" => I::DgnCurrent(match value { + "new" => false, + "current" => true, + _ => return Err(bad()), + }), + "conflict" => I::DgnPrefix(match value { + "prefix" => true, + "ignore" => false, + _ => return Err(bad()), + }), + "units" => I::DgnSub(match value { + "master" => false, + "sub" => true, + _ => return Err(bad()), + }), + "explode" => I::DgnExplode(on(value)?), + "mapping" => I::DgnMappingTab(match value { + "layer" => 0, + "linetype" => 1, + "lineweight" => 2, + "color" => 3, + _ => return Err(bad()), + }), + _ => return Err(bad()), + }) + } // The Image Adjust dialog, one field per call: "brightness=70", // "contrast=40", "fade=20" (the bar), "type=fade,abc" (typed into // the box), "step=brightness,+1|-1", "reset", "apply" or "cancel". diff --git a/src/app/mod.rs b/src/app/mod.rs index 8e3bebe25..0aedd3677 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -598,6 +598,8 @@ pub(super) struct OpenCADStudio { underlay_layers: Option, pdf_import_settings: Option, pdf_import_file: Option, + /// IMPORT and DGNIMPORT windows. + import_dialogs: crate::ui::window::import_dialogs::ImportDialogs, /// The dialog Options was opened from; it comes back when Options closes. options_parent: Option, /// Working copy of the Attach External Reference dialog; None while closed. @@ -2238,6 +2240,10 @@ pub enum ModalKind { XrefHelp, /// Sheet set wizard, properties and sheet / subset forms. SheetSet, + /// IMPORT's Import File window. + ImportFile, + /// DGNIMPORT's DGN Import window. + DgnImport, /// Web: a crash-recovery autosave snapshot newer than the drawing's last /// save awaits a restore/discard decision. Distinct from the corrupt-file /// `RecoveryPrompt` — nothing is wrong with the drawing itself. @@ -4502,6 +4508,8 @@ pub enum Message { std::path::PathBuf, Result, ), + /// An edit in the Import File or DGN Import window. + ImportDialog(crate::ui::window::import_dialogs::ImportDialogMsg), } #[derive(Debug, Clone)] @@ -4617,6 +4625,7 @@ impl OpenCADStudio { underlay_layers: None, pdf_import_settings: None, pdf_import_file: None, + import_dialogs: Default::default(), options_parent: None, xref_attach: None, wblock: None, diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index bcfb76142..901ba439c 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -238,6 +238,8 @@ impl OpenCADStudio { Some(UnderlayLayers) => self.underlay_layers = None, Some(PdfImportSettings) => self.pdf_import_settings = None, Some(PdfImportFile) => self.pdf_import_file = None, + Some(ImportFile) => self.import_dialogs.file = None, + Some(DgnImport) => self.import_dialogs.dgn = None, Some(XrefAttach) => self.xref_attach = None, Some(WriteBlock) => self.wblock = None, Some(Hyperlink) => { @@ -2406,6 +2408,7 @@ impl OpenCADStudio { } Message::PdfDialog(message) => self.update_pdf_dialog(message), + Message::ImportDialog(message) => self.update_import_dialog(message), Message::AttdefDialog(message) => self.on_attdef_dialog(message), Message::FieldDialog(message) => self.on_field_dialog(message), Message::RefEditDialog(message) => self.update_refedit_dialog(message), diff --git a/src/app/view/modal.rs b/src/app/view/modal.rs index dfb36033d..a98a902c9 100644 --- a/src/app/view/modal.rs +++ b/src/app/view/modal.rs @@ -47,6 +47,8 @@ impl OpenCADStudio { Some(K::UnderlayLayers) => crate::t!("Underlay Layers").into_owned(), Some(K::PdfImportSettings) => crate::t!("PDF Import Settings").into_owned(), Some(K::PdfImportFile) => crate::t!("Import PDF").into_owned(), + Some(K::ImportFile) => crate::t!("Import File").into_owned(), + Some(K::DgnImport) => crate::t!("DGN Import").into_owned(), Some(K::XrefAttach) => crate::t!("Attach External Reference").into_owned(), Some(K::WriteBlock) => crate::t!("Write Block").into_owned(), Some(K::GeometricTolerance) => crate::t!("Geometric Tolerance").into_owned(), @@ -581,6 +583,19 @@ impl OpenCADStudio { crate::ui::window::pdf_dialogs::view_import_file(state, flow) }) } + super::super::ModalKind::ImportFile => { + let state = self.import_dialogs.file.as_ref()?; + let recent = &self.import_dialogs.recent; + sized_flow(ex, 960, 620, |flow| { + crate::ui::window::import_dialogs::view_import_file(state, recent, flow) + }) + } + super::super::ModalKind::DgnImport => { + let state = self.import_dialogs.dgn.as_ref()?; + sized_flow(ex, 900, 680, |flow| { + crate::ui::window::import_dialogs::view_dgn_import(state, flow) + }) + } super::super::ModalKind::XrefAttach => { let state = self.xref_attach.as_ref()?; let height = if state.details { 520 } else { 450 }; diff --git a/src/command.rs b/src/command.rs index e62e98500..1148c9f87 100644 --- a/src/command.rs +++ b/src/command.rs @@ -2269,6 +2269,8 @@ pub enum CmdResult { SetTexteditMode(bool), /// Return a resolved point back to a suspended parent command (e.g. MTP / M2P). ReturnPoint(DVec3), + /// Import a DGN model (DGNIMPORT); ends the command. + DgnImport(crate::modules::insert::dgn_import::DgnImportRequest), } /// What kind of value the active command is currently asking for. Drives diff --git a/src/locale_catalog.rs b/src/locale_catalog.rs index 46a790cc3..08a75a2d5 100644 --- a/src/locale_catalog.rs +++ b/src/locale_catalog.rs @@ -3456,6 +3456,40 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static "Unable to save: file is in use." => Some(("command-line", "unable-to-save-file-is-in-use")), "Unable to verify the server certificate" => Some(("plugins", "unable-to-verify-the-server-certificate")), "Unavailable" => Some(("plugins", "unavailable")), + "Supported" => Some(("dgn-import", "supported")), + "By extension" => Some(("dgn-import", "by-extension")), + "ACIS solver available" => Some(("dgn-import", "acis-solver")), + "Export only" => Some(("dgn-import", "export-only")), + "ACIS bodies are read only inside drawings; SAT files cannot be imported." => Some(("dgn-import", "acis-reason")), + "STEP files are only written (STEPOUT), not read." => Some(("dgn-import", "step-reason")), + "OpenCAD Studio has no importer for this format." => Some(("dgn-import", "no-importer")), + "Recent:" => Some(("dgn-import", "recent")), + "2D / Underlay" => Some(("dgn-import", "group-2d")), + "3D Model" => Some(("dgn-import", "group-3d")), + "Search formats..." => Some(("dgn-import", "search")), + "Only what OCS can open" => Some(("dgn-import", "only-supported")), + "{n} model(s)" => Some(("dgn-import", "models")), + "Change file..." => Some(("dgn-import", "change")), + "On name conflict" => Some(("dgn-import", "name-conflict")), + "Add prefix to dependent definitions" => Some(("dgn-import", "prefix")), + "Ignore duplicate names" => Some(("dgn-import", "ignore-duplicates")), + "Master unit" => Some(("dgn-import", "master-unit")), + "Sub unit" => Some(("dgn-import", "sub-unit")), + "Explode text nodes to text elements" => Some(("dgn-import", "explode")), + "External DGN references" => Some(("dgn-import", "references")), + "Bind as block / xref" => Some(("dgn-import", "bind")), + "Attach as DGN underlay" => Some(("dgn-import", "attach")), + "Reference attachments of DGN files are not read; they are left out." => Some(("dgn-import", "references-note")), + "Property mapping" => Some(("dgn-import", "mapping")), + "Standard mapping setup." => Some(("dgn-import", "mapping-standard")), + "The preview shows the selected setup's effect on this file." => Some(("dgn-import", "mapping-note")), + "Import File" => Some(("dgn-import", "import-file")), + "DGN Import" => Some(("dgn-import", "dgn-import")), + "Import DGN File" => Some(("dgn-import", "import-dgn-file")), + "Imports a DGN model as drawing objects: levels as layers, cells as blocks." => Some(("dgn-import", "help-dgn")), + "Imports a file of the chosen format with the format's importer." => Some(("dgn-import", "help-import")), + "Filename cannot be blank" => Some(("dgn-import", "blank")), + "Invalid or unsupported DGN file." => Some(("dgn-import", "invalid-dgn")), "PDF scale: {scale}:1" => Some(("insert", "pdfui-pdf-scale")), "Applied" => Some(("properties", "applied")), "No PDF underlays found." => Some(("insert", "pdfui-no-pdf-underlays")), diff --git a/src/modules/insert/dgn_import.rs b/src/modules/insert/dgn_import.rs new file mode 100644 index 000000000..41b22f575 --- /dev/null +++ b/src/modules/insert/dgn_import.rs @@ -0,0 +1,675 @@ +// DGNIMPORT / -DGNIMPORT — a DGN model (V7 or V8) as drawing objects. +// +// Enter filename for DGN import: +// Enter name of model or [?] : +// Specify conversion units [Master/Sub] : +// Specify mapping setup or [?] : +// +// The Standard mapping setup: each level becomes the layer of its name (its +// number when unnamed), colours become the colour index of the same RGB +// (else a true colour), weights the lineweights below, V7 line styles +// Continuous. Lines, line strings and shapes, arcs, circles and ellipses, +// complex chains and shapes (polylines with arcs), text, text nodes and +// shared cells (blocks) are converted. + +use codec::entities::{ + Arc, Circle, Ellipse, Insert, Line, LwPolyline, LwVertex, MText, Text, +}; +use codec::types::{Color, LineWeight, Vector2, Vector3}; +use codec::EntityType; + +use crate::command::{CadCommand, CmdOption, CmdResult, InputKind}; +use crate::scene::model::underlay_vector::{DgnElement, DgnGeom, DgnImport}; + +/// What one import reads and where it goes. +#[derive(Clone, Debug, PartialEq)] +pub struct DgnImportRequest { + pub path: String, + pub model: String, + /// Sub units (else master units) become drawing units. + pub sub_units: bool, + /// Text nodes become one TEXT per line (else one MTEXT). + pub explode_text_nodes: bool, + /// Into the current drawing (else a new one). + pub current_drawing: bool, + /// In the current drawing: layer, block and style names that already + /// exist get the file name as prefix (else the existing ones are used). + pub prefix_duplicates: bool, +} + +/// The objects of an import: layers (name, colour), text styles (name, font +/// file), blocks (name, objects) and the model's objects, in file order. +#[derive(Default)] +pub struct Converted { + pub layers: Vec<(String, Color)>, + pub styles: Vec<(String, String)>, + pub blocks: Vec<(String, Vec)>, + pub entities: Vec, +} + +/// A DGN weight (0-31) as a lineweight (1/100 mm), as the Standard mapping +/// converts it. +pub fn lineweight_of(weight: u32) -> i16 { + const TABLE: [i16; 15] = [0, 13, 30, 40, 53, 70, 80, 100, 106, 120, 140, 158, 158, 158, 200]; + TABLE.get(weight as usize).copied().unwrap_or(211) +} + +/// An RGB colour as the colour index of the same RGB, else a true colour. +pub fn color_of(rgb: [u8; 3]) -> Color { + let index = codec::types::aci_table::nearest_aci(rgb[0], rgb[1], rgb[2]); + if codec::types::aci_table::aci_to_rgb(index) == Some((rgb[0], rgb[1], rgb[2])) { + Color::Index(index) + } else { + Color::from_rgb(rgb[0], rgb[1], rgb[2]) + } +} + +/// A unit's short name as words ("m" → Meters). +pub fn unit_display(short: &str) -> String { + match short.trim().to_ascii_lowercase().as_str() { + "m" => "Meters", + "mm" => "Millimeters", + "cm" => "Centimeters", + "km" => "Kilometers", + "in" | "\"" => "Inches", + "ft" | "'" => "Feet", + "yd" => "Yards", + "mi" => "Miles", + other => return other.to_string(), + } + .to_string() +} + +fn layer_name(import: &DgnImport, level: u32) -> String { + import + .levels + .get(&level) + .map(|l| l.0.clone()) + .filter(|n| !n.is_empty()) + .unwrap_or_else(|| level.to_string()) +} + +/// True when the axes `u` and `v` are of one length and at right angles. +fn circular(u: [f64; 2], v: [f64; 2]) -> bool { + let (lu, lv) = (u[0].hypot(u[1]), v[0].hypot(v[1])); + (lu - lv).abs() <= 1e-9 * lu.max(1.0) && (u[0] * v[0] + u[1] * v[1]).abs() <= 1e-9 * lu * lv +} + +/// The arc's points at `n` steps. +fn arc_points(center: [f64; 2], u: [f64; 2], v: [f64; 2], start: f64, sweep: f64) -> Vec<[f64; 2]> { + let n = ((sweep.abs() / (std::f64::consts::PI / 32.0)).ceil() as usize).clamp(2, 4096); + (0..=n) + .map(|k| { + let t = start + sweep * k as f64 / n as f64; + [ + center[0] + t.cos() * u[0] + t.sin() * v[0], + center[1] + t.cos() * u[1] + t.sin() * v[1], + ] + }) + .collect() +} + +/// A circular arc as centre, radius, start angle and signed sweep in the +/// drawing's angles. +fn circle_angles(u: [f64; 2], v: [f64; 2], start: f64, sweep: f64) -> (f64, f64, f64) { + let side = (u[0] * v[1] - u[1] * v[0]).signum(); + let a0 = u[1].atan2(u[0]); + (u[0].hypot(u[1]), a0 + side * start, side * sweep) +} + +fn polyline(points: &[[f64; 2]], bulges: &[f64]) -> EntityType { + let mut pl = LwPolyline::new(); + pl.plinegen = true; + pl.vertices = points + .iter() + .enumerate() + .map(|(k, p)| { + let mut v = LwVertex::new(Vector2::new(p[0], p[1])); + v.bulge = bulges.get(k).copied().unwrap_or(0.0); + v + }) + .collect(); + EntityType::LwPolyline(pl) +} + +/// A complex chain's vertices and bulges: lines and line strings add their +/// points, circular arcs a bulge, other arcs their points. +fn chain(parts: &[DgnGeom]) -> (Vec<[f64; 2]>, Vec) { + let mut points: Vec<[f64; 2]> = Vec::new(); + let mut bulges: Vec = Vec::new(); + let mut add = |p: [f64; 2], bulge_before: Option| { + let same = points + .last() + .is_some_and(|q| (q[0] - p[0]).abs() < 1e-9 && (q[1] - p[1]).abs() < 1e-9); + if let (Some(b), Some(last)) = (bulge_before, bulges.last_mut()) { + *last = b; + } + if !same { + points.push(p); + bulges.push(0.0); + } + }; + for part in parts { + match part { + DgnGeom::Line(a, b) => { + add(*a, None); + add(*b, None); + } + DgnGeom::Points(pts) => pts.iter().for_each(|p| add(*p, None)), + DgnGeom::Arc { + center, + u, + v, + start, + sweep, + } if circular(*u, *v) => { + let at = |t: f64| { + [ + center[0] + t.cos() * u[0] + t.sin() * v[0], + center[1] + t.cos() * u[1] + t.sin() * v[1], + ] + }; + let (_, _, signed) = circle_angles(*u, *v, *start, *sweep); + add(at(*start), None); + add(at(start + sweep), Some((signed / 4.0).tan())); + } + DgnGeom::Arc { + center, + u, + v, + start, + sweep, + } => arc_points(*center, *u, *v, *start, *sweep) + .into_iter() + .for_each(|p| add(p, None)), + _ => {} + } + } + (points, bulges) +} + +fn text_entity(geom: &DgnGeom, style: &str) -> Option { + let DgnGeom::Text { + text, + origin, + height, + width_factor, + rotation, + .. + } = geom + else { + return None; + }; + let mut t = Text::with_value(text.clone(), Vector3::new(origin[0], origin[1], 0.0)) + .with_height(*height) + .with_rotation(*rotation); + t.width_factor = if *width_factor > 0.0 { *width_factor } else { 1.0 }; + t.style = style.to_string(); + Some(EntityType::Text(t)) +} + +/// The text style of a line of text (V7: one per font number, "dgnNNN"), +/// added to `styles` with its font file the first time. +fn text_style(geom: &DgnGeom, styles: &mut Vec<(String, String)>) -> String { + match geom { + DgnGeom::Text { + font: Some(font), .. + } => { + let name = format!("dgn{font:03}"); + if !styles.iter().any(|s| s.0 == name) { + styles.push((name.clone(), format!("{name}.shx"))); + } + name + } + _ => "Standard".to_string(), + } +} + +/// Lines of text as one MTEXT, a paragraph a line, as the reference +/// converts them: a text node attached at its top left (a height above the +/// first baseline), a V8 text at its bottom left a third of the height +/// below its baseline. +fn mtext(lines: &[DgnGeom], style: String, node: bool) -> Option { + let Some(DgnGeom::Text { + origin, + height, + rotation, + .. + }) = lines.first() + else { + return None; + }; + let mut m = MText::new(); + m.value = lines + .iter() + .filter_map(|g| match g { + DgnGeom::Text { text, .. } => Some(text.clone()), + _ => None, + }) + .collect::>() + .join("\\P"); + let up = if node { *height } else { -0.33 * height }; + m.insertion_point = Vector3::new( + origin[0] - rotation.sin() * up, + origin[1] + rotation.cos() * up, + 0.0, + ); + if !node { + m.attachment_point = codec::entities::AttachmentPoint::BottomLeft; + m.line_spacing_factor = 0.8; + } + m.height = *height; + m.rotation = *rotation; + m.rectangle_width = 0.0; + m.style = style; + Some(EntityType::MText(m)) +} + +/// The object(s) one element converts to. +fn convert_geom( + geom: &DgnGeom, + explode_text_nodes: bool, + styles: &mut Vec<(String, String)>, +) -> Vec { + match geom { + DgnGeom::Line(a, b) => vec![EntityType::Line(Line::from_points( + Vector3::new(a[0], a[1], 0.0), + Vector3::new(b[0], b[1], 0.0), + ))], + DgnGeom::Points(pts) if pts.len() >= 2 => vec![polyline(pts, &[])], + DgnGeom::Points(_) => Vec::new(), + DgnGeom::Arc { + center, + u, + v, + start, + sweep, + } => { + let c = Vector3::new(center[0], center[1], 0.0); + let full = sweep.abs() >= std::f64::consts::TAU - 1e-9; + if circular(*u, *v) { + let (radius, a0, signed) = circle_angles(*u, *v, *start, *sweep); + if full { + return vec![EntityType::Circle(Circle::from_center_radius(c, radius))]; + } + let (from, to) = if signed >= 0.0 { + (a0, a0 + signed) + } else { + (a0 + signed, a0) + }; + return vec![EntityType::Arc(Arc::from_center_radius_angles( + c, + radius, + from.rem_euclid(std::f64::consts::TAU), + to.rem_euclid(std::f64::consts::TAU), + ))]; + } + let (lu, lv) = (u[0].hypot(u[1]), v[0].hypot(v[1])); + if (u[0] * v[0] + u[1] * v[1]).abs() > 1e-9 * lu * lv || lu < 1e-12 || lv < 1e-12 { + // ponytail: a skewed plan projection (a 3D ellipse seen + // obliquely) becomes points; find its principal axes if + // such files matter. + return vec![polyline(&arc_points(*center, *u, *v, *start, *sweep), &[])]; + } + // P(t) = c + cos t·u + sin t·v as an ellipse on its major axis + // with the minor axis turned counter-clockwise from it. + let ccw = u[0] * v[1] - u[1] * v[0] > 0.0; + let (major, ratio, map): (_, f64, Box f64>) = if lu >= lv { + if ccw { + (*u, lv / lu, Box::new(|t| t)) + } else { + (*u, lv / lu, Box::new(|t: f64| -t)) + } + } else if ccw { + (*v, lu / lv, Box::new(|t: f64| t - std::f64::consts::FRAC_PI_2)) + } else { + (*v, lu / lv, Box::new(|t: f64| std::f64::consts::FRAC_PI_2 - t)) + }; + let mut e = Ellipse::from_center_axes(c, Vector3::new(major[0], major[1], 0.0), ratio); + if !full { + let (p0, p1) = (map(*start), map(start + sweep)); + let (from, to) = if p1 >= p0 { (p0, p1) } else { (p1, p0) }; + e.start_parameter = from.rem_euclid(std::f64::consts::TAU); + e.end_parameter = e.start_parameter + (to - from); + } + vec![EntityType::Ellipse(e)] + } + DgnGeom::Chain(parts) => { + let (points, bulges) = chain(parts); + if points.len() >= 2 { + vec![polyline(&points, &bulges)] + } else { + Vec::new() + } + } + // V7 text is TEXT, V8 text an MTEXT. + DgnGeom::Text { font: Some(_), .. } => { + let style = text_style(geom, styles); + text_entity(geom, &style).into_iter().collect() + } + DgnGeom::Text { .. } => { + let style = text_style(geom, styles); + mtext(std::slice::from_ref(geom), style, false).into_iter().collect() + } + // Exploded, each line converts as a text element of its own. + DgnGeom::TextNode(lines) if explode_text_nodes => lines + .iter() + .flat_map(|g| convert_geom(g, false, styles)) + .collect(), + DgnGeom::TextNode(lines) => { + let Some(first) = lines.first() else { + return Vec::new(); + }; + let style = text_style(first, styles); + mtext(lines, style, true).into_iter().collect() + } + DgnGeom::Cell { + name, + m, + origin, + z_scale, + } => { + let sx = m[0].hypot(m[2]); + let det = m[0] * m[3] - m[1] * m[2]; + let sy = if sx > 0.0 { det / sx } else { 1.0 }; + let insert = Insert::new(name.clone(), Vector3::new(origin[0], origin[1], 0.0)) + .with_scale(sx, sy, *z_scale) + .with_rotation(m[2].atan2(m[0])); + vec![EntityType::Insert(insert)] + } + } +} + +fn convert_elements( + import: &DgnImport, + elements: &[DgnElement], + explode_text_nodes: bool, + in_cell: bool, + out: &mut Converted, +) -> Vec { + let mut entities = Vec::new(); + for element in elements { + // A cell's objects are on layer 0 (V7: by block), as the reference + // defines the block. + let layer = if in_cell { + "0".to_string() + } else { + layer_name(import, element.level) + }; + if !out.layers.iter().any(|l| l.0 == layer) { + let color = import + .levels + .get(&element.level) + .and_then(|l| l.1) + .map(color_of) + .unwrap_or(Color::Index(7)); + out.layers.push((layer.clone(), color)); + } + for mut entity in convert_geom(&element.geom, explode_text_nodes, &mut out.styles) { + let common = entity.common_mut(); + common.layer = layer.clone(); + common.color = element.color.map(color_of).unwrap_or(Color::ByLayer); + common.line_weight = element + .weight + .map(|w| LineWeight::Value(lineweight_of(w))) + .unwrap_or(LineWeight::ByLayer); + // The Standard mapping draws every line style Continuous. + if element.style.is_some() { + common.linetype = "Continuous".to_string(); + } + if import.v7 && in_cell { + common.color = Color::ByBlock; + common.line_weight = LineWeight::ByBlock; + } + entities.push(entity); + } + } + entities +} + +/// The import's objects, in sub units when `sub_units`. +pub fn convert(import: &DgnImport, sub_units: bool, explode_text_nodes: bool) -> Converted { + let k = if sub_units { import.sub_per_master } else { 1.0 }; + let scale = |elements: &[DgnElement]| { + crate::scene::model::underlay_vector::scaled_elements(elements, k, [0.0, 0.0]) + }; + let mut out = Converted::default(); + for (name, elements) in &import.cells { + let entities = convert_elements(import, &scale(elements), explode_text_nodes, true, &mut out); + if !name.is_empty() && !entities.is_empty() { + out.blocks.push((name.clone(), entities)); + } + } + out.entities = convert_elements(import, &scale(&import.elements), explode_text_nodes, false, &mut out); + out +} + +/// The Standard mapping of what a file uses: level → layer, line style → +/// linetype, weight → lineweight, colour → colour, one row each. +pub fn mapping_rows(import: &DgnImport, tab: usize) -> Vec<(String, String)> { + let mut rows: Vec<(String, String)> = Vec::new(); + let mut add = |dgn: String, dwg: String| { + if !rows.iter().any(|r| r.0 == dgn) { + rows.push((dgn, dwg)); + } + }; + let all = import + .elements + .iter() + .chain(import.cells.iter().flat_map(|c| c.1.iter())); + for e in all { + match tab { + 0 => { + let name = layer_name(import, e.level); + add(name.clone(), name); + } + 1 => match e.style { + Some(s) => add(s.to_string(), "Continuous".to_string()), + None => add("ByLevel".to_string(), "ByLayer".to_string()), + }, + 2 => match e.weight { + Some(w) => add( + w.to_string(), + format!("{:.2} mm", lineweight_of(w) as f64 / 100.0), + ), + None => add("ByLevel".to_string(), "ByLayer".to_string()), + }, + _ => match e.color { + Some(rgb) => add( + format!("RGB {},{},{}", rgb[0], rgb[1], rgb[2]), + match color_of(rgb) { + Color::Index(i) => i.to_string(), + _ => format!("{},{},{}", rgb[0], rgb[1], rgb[2]), + }, + ), + None => add("ByLevel".to_string(), "ByLayer".to_string()), + }, + } + } + rows +} + +// ── Command line ─────────────────────────────────────────────────────────── + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Step { + Path, + Model, + ListModels, + Conversion, + Mapping, + ListMappings, +} + +pub struct DgnImportCommand { + step: Step, + path: String, + models: Vec, + model: String, + sub_units: bool, +} + +impl Default for DgnImportCommand { + fn default() -> Self { + Self::new() + } +} + +impl DgnImportCommand { + pub fn new() -> Self { + Self { + step: Step::Path, + path: String::new(), + models: Vec::new(), + model: String::new(), + sub_units: false, + } + } + + fn accept_path(&mut self, text: &str) -> CmdResult { + let path = text.trim().trim_matches('"').to_string(); + if path.is_empty() { + return CmdResult::NeedPoint; + } + let models = crate::scene::model::underlay_vector::item_names( + codec::entities::UnderlayType::Dgn, + &path, + ) + .filter(|m| !m.is_empty() && crate::scene::model::underlay_vector::dgn_import(&path, &m[0]).is_some()); + let Some(models) = models else { + return CmdResult::ReportError("Invalid or unsupported DGN file.".to_string()); + }; + self.path = path; + self.model = models[0].clone(); + self.models = models; + self.step = Step::Model; + CmdResult::NeedPoint + } + + fn accept_model(&mut self, text: &str) -> CmdResult { + let text = text.trim(); + if text == "?" { + self.step = Step::ListModels; + return CmdResult::NeedPoint; + } + let name = if text.is_empty() { self.models[0].as_str() } else { text }; + match self.models.iter().find(|m| m.eq_ignore_ascii_case(name)) { + Some(found) => { + self.model = found.clone(); + self.step = Step::Conversion; + CmdResult::NeedPoint + } + None => CmdResult::ReportError(format!("There is no {name} in {}.", self.path)), + } + } + + fn list(&mut self, pattern: &str, names: &[&str], back: Step) -> CmdResult { + let pattern = match pattern.trim() { + "" => "*", + p => p, + }; + let lines: Vec<&str> = names + .iter() + .copied() + .filter(|n| crate::io::xref_model::wildcard_match(n, pattern)) + .collect(); + self.step = back; + CmdResult::ReportMeasurement(lines.join("\n")) + } + + fn accept_conversion(&mut self, text: &str) -> CmdResult { + let t = text.trim().to_ascii_uppercase(); + self.sub_units = match t.as_str() { + "" => false, + _ if "MASTER".starts_with(&t) => false, + _ if "SUB".starts_with(&t) => true, + _ => return CmdResult::ReportError("Invalid option keyword.".to_string()), + }; + self.step = Step::Mapping; + CmdResult::NeedPoint + } + + fn accept_mapping(&mut self, text: &str) -> CmdResult { + let text = text.trim(); + if text == "?" { + self.step = Step::ListMappings; + return CmdResult::NeedPoint; + } + if !text.is_empty() && !text.eq_ignore_ascii_case("Standard") { + return CmdResult::ReportError(format!("{text} not found.")); + } + CmdResult::DgnImport(DgnImportRequest { + path: self.path.clone(), + model: self.model.clone(), + sub_units: self.sub_units, + explode_text_nodes: false, + current_drawing: false, + prefix_duplicates: false, + }) + } +} + +impl CadCommand for DgnImportCommand { + fn name(&self) -> &'static str { + "DGNIMPORT" + } + + fn prompt(&self) -> String { + match self.step { + Step::Path => "Enter filename for DGN import:".to_string(), + Step::Model => format!("Enter name of model or [?] <{}>:", self.models[0]), + Step::ListModels => "Enter model(s) to list <*>:".to_string(), + Step::Conversion => "Specify conversion units [Master/Sub] :".to_string(), + Step::Mapping => "Specify mapping setup or [?] :".to_string(), + Step::ListMappings => "Enter mapping setup(s) to list <*>:".to_string(), + } + } + + fn options(&self) -> Vec { + match self.step { + Step::Model | Step::Mapping => vec![CmdOption::new("?", "?")], + Step::Conversion => vec![CmdOption::new("Master", "M"), CmdOption::new("Sub", "S")], + _ => Vec::new(), + } + } + + fn input_kind(&self) -> InputKind { + match self.step { + Step::Path | Step::Model => InputKind::FreeText, + _ => InputKind::SingleToken, + } + } + + fn on_point(&mut self, _pt: glam::DVec3) -> CmdResult { + CmdResult::NeedPoint + } + + fn on_text_input(&mut self, text: &str) -> Option { + Some(match self.step { + Step::Path => self.accept_path(text), + Step::Model => self.accept_model(text), + Step::ListModels => { + let models = self.models.clone(); + let names: Vec<&str> = models.iter().map(String::as_str).collect(); + self.list(text, &names, Step::Model) + } + Step::Conversion => self.accept_conversion(text), + Step::Mapping => self.accept_mapping(text), + Step::ListMappings => { + match self.list(text, &["Standard"], Step::Mapping) { + CmdResult::ReportMeasurement(lines) => { + CmdResult::ReportMeasurement(format!("Import mapping setups:\n{lines}")) + } + other => other, + } + } + }) + } + + fn on_enter(&mut self) -> CmdResult { + match self.step { + Step::Path => CmdResult::Cancel, + _ => self.on_text_input("").unwrap_or(CmdResult::NeedPoint), + } + } +} diff --git a/src/modules/insert/mod.rs b/src/modules/insert/mod.rs index a8677fe3c..340bb2b0f 100644 --- a/src/modules/insert/mod.rs +++ b/src/modules/insert/mod.rs @@ -8,6 +8,7 @@ pub(crate) mod block_cmdline; mod content_browser; pub(crate) mod create_block; mod design_center; +pub(crate) mod dgn_import; pub(crate) mod edit_block; pub(crate) mod image_transparency; pub(crate) mod insert_block; diff --git a/src/scene/model/underlay_vector/dgn7.rs b/src/scene/model/underlay_vector/dgn7.rs index a54b55ed0..e2205e473 100644 --- a/src/scene/model/underlay_vector/dgn7.rs +++ b/src/scene/model/underlay_vector/dgn7.rs @@ -6,8 +6,9 @@ use std::collections::HashMap; use super::model::{ - arc_cubics, bspline_points, dgn_color_table, paths_bounds, Path, PathBuilder, Segment, Sheet, - SubPath, Text, DGN_DEFAULT_COLORS, + arc_cubics, bspline_points, dgn_color_table, paths_bounds, scaled_elements, DgnCollector, + DgnElement, DgnGeom, DgnImport, Path, PathBuilder, Segment, Sheet, SubPath, Text, + DGN_DEFAULT_COLORS, }; /// A 32-bit integer stored as two little-endian words, high word first. @@ -123,6 +124,8 @@ struct Raw { paths: Vec, texts: Vec, instances: Vec, + /// The same run element by element, for DGNIMPORT. + elements: Vec, } #[derive(Clone)] @@ -199,6 +202,7 @@ fn read_run(run: &[&[u8]], colors: &[[u8; 3]]) -> Raw { raw.paths.push(path); } }; + let mut collect = DgnCollector::default(); for e in run { let kind = e[1] & 0x7f; if e[1] & 0x80 != 0 || e.len() < 36 { @@ -206,6 +210,13 @@ fn read_run(run: &[&[u8]], colors: &[[u8; 3]]) -> Raw { } let symbology = uint16(e, 34).unwrap_or(0); let color = colors[(symbology >> 8) as usize]; + let element = |geom: DgnGeom| DgnElement { + level: level(e) as u32, + color: Some(color), + weight: Some(((symbology >> 3) & 31) as u32), + style: Some((symbology & 7) as u32), + geom, + }; // One pixel wide, as V8 models draw: the element weight is a // lineweight, not shown while lineweights are off. let stroke = Some((color, -1.0)); @@ -213,8 +224,14 @@ fn read_run(run: &[&[u8]], colors: &[[u8; 3]]) -> Raw { let int_pt = |i: usize| -> Option<[f64; 2]> { Some([int32(e, i)? as f64, int32(e, i + 4)? as f64]) }; match kind { + // A text node: its count of text components. + 7 => collect.open( + uint16(e, 38).unwrap_or(0) as usize, + element(DgnGeom::TextNode(Vec::new())), + ), 12 | 14 => { let n = uint16(e, 38).unwrap_or(0) as usize; + collect.open(n, element(DgnGeom::Chain(Vec::new()))); if n > 0 { complex = Some(( n, @@ -227,6 +244,7 @@ fn read_run(run: &[&[u8]], colors: &[[u8; 3]]) -> Raw { } 3 => { if let (Some(a), Some(b)) = (int_pt(36), int_pt(44)) { + collect.push(element(DgnGeom::Line(a, b))); push( &mut raw, &mut complex, @@ -245,6 +263,7 @@ fn read_run(run: &[&[u8]], colors: &[[u8; 3]]) -> Raw { let n = uint16(e, 36).unwrap_or(0) as usize; let pts: Vec<[f64; 2]> = (0..n).filter_map(|k| int_pt(38 + k * 8)).collect(); if pts.len() >= 2 { + collect.push(element(DgnGeom::Points(pts.clone()))); push( &mut raw, &mut complex, @@ -290,6 +309,15 @@ fn read_run(run: &[&[u8]], colors: &[[u8; 3]]) -> Raw { let Some((a, b, r, ox, oy, start, sweep)) = arc else { continue; }; + let turn = (r as f64 / 360000.0).to_radians(); + let (c, s) = (turn.cos(), turn.sin()); + collect.push(element(DgnGeom::Arc { + center: [ox, oy], + u: [a * c, a * s], + v: [-b * s, b * c], + start: start.to_radians(), + sweep: sweep.to_radians(), + })); let pieces = arc_cubics( [ox, oy], a, @@ -326,7 +354,7 @@ fn read_run(run: &[&[u8]], colors: &[[u8; 3]]) -> Raw { continue; }; let (height, width) = (height as f64 * 6.0 / 1000.0, width as f64 * 6.0 / 1000.0); - raw.texts.push(Text { + let text = Text { text: String::from_utf8_lossy(chars) .trim_end_matches('\0') .to_string(), @@ -336,7 +364,16 @@ fn read_run(run: &[&[u8]], colors: &[[u8; 3]]) -> Raw { rotation: (rotation as f64 / 360000.0).to_radians(), color, font: "txt".to_string(), - }); + }; + collect.push(element(DgnGeom::Text { + text: text.text.clone(), + origin, + height, + width_factor: text.width_factor, + rotation: text.rotation, + font: Some(e[36] as u32), + })); + raw.texts.push(text); } 27 => { // B-spline curve header: order, pole count. @@ -369,6 +406,10 @@ fn read_run(run: &[&[u8]], colors: &[[u8; 3]]) -> Raw { full.extend(interior); full.extend(vec![1.0; order]); let pts = bspline_points(order, &poles, &full, 24 * count); + collect.push(DgnElement { + color: Some(color), + ..element(DgnGeom::Points(pts.clone())) + }); push( &mut raw, &mut complex, @@ -399,6 +440,12 @@ fn read_run(run: &[&[u8]], colors: &[[u8; 3]]) -> Raw { ) else { continue; }; + collect.push(element(DgnGeom::Cell { + name: name.clone(), + m: [m0, m1, m2, m3], + origin: [ox as f64, oy as f64], + z_scale: 1.0, + })); raw.instances.push(Instance { name, m: [m0, m1, m2, m3], @@ -408,6 +455,8 @@ fn read_run(run: &[&[u8]], colors: &[[u8; 3]]) -> Raw { _ => {} } } + collect.close(); + raw.elements = collect.elements; raw } @@ -632,3 +681,67 @@ pub fn model(bytes: &[u8], hidden: &[String]) -> Option { })?; Some(sheet) } + +/// A unit name of the design file header (two characters, zero padded). +fn unit_name(b: &[u8], at: usize) -> String { + b.get(at..at + 2) + .map(|n| String::from_utf8_lossy(n).trim_matches(char::from(0)).trim().to_string()) + .unwrap_or_default() +} + +/// The model element by element (DGNIMPORT): in master units with the +/// global origin at 0,0, shared cell definitions in their own units scaled +/// to master units, and every level by number with its name. +pub fn import(bytes: &[u8]) -> Option { + if !is_v7(bytes) { + return None; + } + let u = units(bytes)?; + let all = elements(bytes); + let colors = color_table(&all).unwrap_or_else(|| DGN_DEFAULT_COLORS.to_vec()); + let names = level_names(&all); + let per_unit = u.uor_per_sub * u.sub_per_master; + let s = 1.0 / per_unit; + let mut cells = Vec::new(); + let mut top: Vec<&[u8]> = Vec::new(); + let mut i = 0; + while i < all.len() { + let e = all[i]; + if e[1] & 0x7f == 34 && e[0] & 0x80 == 0 { + let name = e + .get(164..180) + .map(|n| { + String::from_utf8_lossy(n) + .trim_end_matches('\0') + .trim() + .to_string() + }) + .unwrap_or_default(); + let mut j = i + 1; + while j < all.len() && all[j][0] & 0x80 != 0 { + j += 1; + } + let def = read_run(&all[i + 1..j], &colors).elements; + cells.push((name, scaled_elements(&def, s, [0.0, 0.0]))); + i = j; + continue; + } + top.push(e); + i += 1; + } + let raw = read_run(&top, &colors); + let levels = (0..64u32) + .map(|l| { + let name = names.get(&(l as u8)).cloned().unwrap_or_else(|| l.to_string()); + (l, (name, None)) + }) + .collect(); + Some(DgnImport { + elements: scaled_elements(&raw.elements, s, [-u.origin[0] * s, -u.origin[1] * s]), + cells, + levels, + sub_per_master: u.sub_per_master, + unit_names: (unit_name(bytes, 1120), unit_name(bytes, 1122)), + v7: true, + }) +} diff --git a/src/scene/model/underlay_vector/dgn8.rs b/src/scene/model/underlay_vector/dgn8.rs index 497d0628f..b442b6a30 100644 --- a/src/scene/model/underlay_vector/dgn8.rs +++ b/src/scene/model/underlay_vector/dgn8.rs @@ -8,8 +8,8 @@ use std::collections::HashMap; use std::io::Read; use super::model::{ - arc_cubics, dgn_color_table, paths_bounds, Path, PathBuilder, Segment, Sheet, SubPath, Text, - DGN_DEFAULT_COLORS, + arc_cubics, dgn_color_table, paths_bounds, scaled_elements, DgnCollector, DgnElement, DgnGeom, + DgnImport, Path, PathBuilder, Segment, Sheet, SubPath, Text, DGN_DEFAULT_COLORS, }; // ── Compound file ─────────────────────────────────────────────────────────── @@ -392,6 +392,8 @@ struct Raw { paths: Vec, texts: Vec, instances: Vec, + /// The same run element by element, for DGNIMPORT. + elements: Vec, } #[derive(Clone)] @@ -551,11 +553,21 @@ fn read_run(run: &[&[u8]], styles: &Styles, xf: &Xf) -> Raw { let q = xf_vector(xf, v); [q[0], q[1]] }; - for e in run { + let mut collect = DgnCollector::default(); + for (index, e) in run.iter().enumerate() { if e.len() < 104 || styles.hidden.contains(&u32_at(e, 12).unwrap_or(0)) { continue; } let color = styles.color(e); + // Colour and weight words: 0xFFFFFFFF takes the level's; line style + // 0x7FFFFFFF (or 0xFFFFFFFF). + let element = |geom: DgnGeom| DgnElement { + level: u32_at(e, 12).unwrap_or(0), + color: (u32_at(e, 52) != Some(u32::MAX)).then_some(color), + weight: u32_at(e, 48).filter(|w| *w != u32::MAX).map(|w| w & 31), + style: u32_at(e, 44).filter(|s| *s != u32::MAX && *s != 0x7FFF_FFFF), + geom, + }; let stroke = Some((color, -1.0)); let d3 = is_3d(e); // A point: x, y (and z in a 3D element). @@ -567,8 +579,20 @@ fn read_run(run: &[&[u8]], styles: &Styles, xf: &Xf) -> Raw { ]) }; match kind(e) { + // A text node: grouped when its count is followed by that many + // text components. + 7 => { + let n = u32_at(e, 104).unwrap_or(0) as usize; + let texts = run.get(index + 1..index + 1 + n).is_some_and(|parts| { + parts.iter().all(|p| p.len() >= 104 && kind(p) == 17 && is_component(p)) + }); + if texts { + collect.open(n, element(DgnGeom::TextNode(Vec::new()))); + } + } 12 | 14 => { let n = u32_at(e, 104).unwrap_or(0) as usize; + collect.open(n, element(DgnGeom::Chain(Vec::new()))); if n > 0 { let fill = (kind(e) == 14 && e.windows(4).any(|w| w == [0x07, 0x10, 0x41, 0x00])) @@ -578,6 +602,7 @@ fn read_run(run: &[&[u8]], styles: &Styles, xf: &Xf) -> Raw { } 3 => { if let (Some(a), Some(b)) = (p3(104), p3(if d3 { 128 } else { 120 })) { + collect.push(element(DgnGeom::Line(plan(a), plan(b)))); push( &mut raw, &mut complex, @@ -600,6 +625,7 @@ fn read_run(run: &[&[u8]], styles: &Styles, xf: &Xf) -> Raw { .map(plan) .collect(); if pts.len() >= 2 { + collect.push(element(DgnGeom::Points(pts.clone()))); let mut pb = PathBuilder::default(); pb.move_to(pts[0]); for p in &pts[1..] { @@ -675,6 +701,16 @@ fn read_run(run: &[&[u8]], styles: &Styles, xf: &Xf) -> Raw { sweep }; let closed = sweep.abs() >= std::f64::consts::TAU - 1e-9; + { + let (pu, pv) = (axis(u), axis(v)); + collect.push(element(DgnGeom::Arc { + center: plan(c), + u: [a * pu[0], a * pu[1]], + v: [b * pv[0], b * pv[1]], + start, + sweep, + })); + } push( &mut raw, &mut complex, @@ -730,11 +766,20 @@ fn read_run(run: &[&[u8]], styles: &Styles, xf: &Xf) -> Raw { continue; } let (height, width) = (height * 6.0 / 1000.0 * sv, width * 6.0 / 1000.0 * su); + let width_factor = if height > 0.0 { width / height } else { 1.0 }; + collect.push(element(DgnGeom::Text { + text: text.clone(), + origin: plan(origin), + height, + width_factor, + rotation: pu[1].atan2(pu[0]), + font: None, + })); raw.texts.push(Text { text, origin: plan(origin), height, - width_factor: if height > 0.0 { width / height } else { 1.0 }, + width_factor, rotation: pu[1].atan2(pu[0]), color, font: "txt".to_string(), @@ -756,6 +801,12 @@ fn read_run(run: &[&[u8]], styles: &Styles, xf: &Xf) -> Raw { [m[3], m[4], m[5], o[1]], [m[6], m[7], m[8], o[2]], ]; + collect.push(element(DgnGeom::Cell { + name: name.clone(), + m: [m[0], m[1], m[3], m[4]], + origin: [o[0], o[1]], + z_scale: m[8], + })); raw.instances.push(Instance { name: name.to_ascii_uppercase(), xf: xf_compose(xf, &local), @@ -775,6 +826,8 @@ fn read_run(run: &[&[u8]], styles: &Styles, xf: &Xf) -> Raw { fill, }); } + collect.close(); + raw.elements = collect.elements; raw } @@ -921,6 +974,17 @@ pub fn model_names(bytes: &[u8]) -> Option> { /// name and colour index. The default level (64) is listed as "0", as the /// reference names it. fn level_table(nm: &[&[u8]]) -> Vec<(u32, String, u32)> { + let mut out = level_table_raw(nm); + for (id, name, _) in &mut out { + if *id == 64 && name.eq_ignore_ascii_case("Default") { + *name = "0".to_string(); + } + } + out +} + +/// The level table with the file's own names. +fn level_table_raw(nm: &[&[u8]]) -> Vec<(u32, String, u32)> { let mut out = Vec::new(); let mut in_levels = false; for e in nm { @@ -930,10 +994,7 @@ fn level_table(nm: &[&[u8]]) -> Vec<(u32, String, u32)> { let (Some(id), Some(color)) = (u32_at(e, 32), u32_at(e, 72)) else { continue; }; - let mut name = marked_string(e, 32).unwrap_or_default(); - if id == 64 && name.eq_ignore_ascii_case("Default") { - name = "0".to_string(); - } + let name = marked_string(e, 32).unwrap_or_default(); out.push((id, name, color)); } _ => {} @@ -1072,6 +1133,93 @@ fn stored_range(graphics: &[&[u8]]) -> Option<[f64; 4]> { (r[0] <= r[2]).then_some(r) } +/// The short name of a unit of `metres` per unit ("m", "mm", "ft" …), empty +/// for one without a common name. +fn unit_name(metres: f64) -> String { + const NAMES: [(f64, &str); 8] = [ + (1.0, "m"), + (0.001, "mm"), + (0.01, "cm"), + (1000.0, "km"), + (0.0254, "in"), + (0.3048, "ft"), + (0.9144, "yd"), + (1609.344, "mi"), + ]; + NAMES + .iter() + .find(|(m, _)| (metres / m - 1.0).abs() < 1e-6) + .map(|(_, n)| n.to_string()) + .unwrap_or_default() +} + +/// The named model element by element (DGNIMPORT): in master units with +/// the global origin at 0,0, shared cell definitions in master units, and +/// every level by id with its own name and colour. +pub fn import(bytes: &[u8], name: &str) -> Option { + let cfb = Cfb::open(bytes)?; + let all = models(&cfb); + let (_, graphics, header) = all + .iter() + .find(|m| m.0.eq_ignore_ascii_case(name)) + .or_else(|| all.first())?; + let non_model = non_model(&cfb); + let nm = elements(&non_model); + let colors = nm + .iter() + .find(|e| kind(e) == 5) + .and_then(|e| dgn_color_table(e, 37)) + .unwrap_or_else(|| DGN_DEFAULT_COLORS.to_vec()); + let table = level_table_raw(&nm); + let styles = Styles { + levels: table.iter().map(|l| (l.0, l.2)).collect(), + hidden: Default::default(), + extended: extended_colors(&cfb), + colors, + }; + let levels = table + .iter() + .map(|(id, name, color)| { + let rgb = styles.colors.get(*color as usize).copied(); + (*id, (name.clone(), rgb)) + }) + .collect(); + let (per_unit, sub_uor) = units(header); + let s = 1.0 / per_unit; + let mut cells = Vec::new(); + let mut i = 0; + while i < nm.len() { + if kind(nm[i]) == 34 && !is_component(nm[i]) { + let cell = marked_string(nm[i], 248).unwrap_or_default(); + let mut j = i + 1; + while j < nm.len() && is_component(nm[j]) { + j += 1; + } + let def = read_run(&nm[i + 1..j], &styles, &IDENTITY).elements; + cells.push((cell, scaled_elements(&def, s, [0.0, 0.0]))); + i = j; + } else { + i += 1; + } + } + let raw = read_run(&elements(graphics), &styles, &IDENTITY); + let origin = global_origin(header); + // Unit definitions: numerator / denominator per metre, master at 4180, + // sub at 4196. + let metres = |at: usize| match (f64_at(header, at), f64_at(header, at + 8)) { + (Some(n), Some(d)) if n > 0.0 && d > 0.0 => unit_name(d / n), + _ => String::new(), + }; + Some(DgnImport { + elements: scaled_elements(&raw.elements, s, [-origin[0] * s, -origin[1] * s]), + cells, + levels, + sub_per_master: per_unit / sub_uor, + unit_names: (metres(4180), metres(4196)), + v7: false, + }) +} + #[cfg(test)] mod cfb_memory_tests { use super::*; diff --git a/src/scene/model/underlay_vector/mod.rs b/src/scene/model/underlay_vector/mod.rs index f0fbec233..7452be7a5 100644 --- a/src/scene/model/underlay_vector/mod.rs +++ b/src/scene/model/underlay_vector/mod.rs @@ -20,10 +20,21 @@ use std::sync::{Arc, Mutex, OnceLock}; use codec::entities::UnderlayType; -pub use model::{Path, Segment, Sheet, SubPath}; +pub use model::{scaled_elements, DgnElement, DgnGeom, DgnImport, Path, Segment, Sheet, SubPath}; use super::pdf_raster::{source_bytes, PdfPage}; +/// A DGN model element by element, for DGNIMPORT; `None` when the file is +/// not a V7 or V8 design file. +pub fn dgn_import(path: &str, item: &str) -> Option { + let bytes = source_bytes(path)?; + if dgn7::is_v7(&bytes) { + dgn7::import(&bytes) + } else { + dgn8::import(&bytes, item) + } +} + /// Sheet names (DWF) or model names (DGN) of a file. pub fn item_names(kind: UnderlayType, path: &str) -> Option> { let bytes = source_bytes(path)?; diff --git a/src/scene/model/underlay_vector/model.rs b/src/scene/model/underlay_vector/model.rs index 0b1910711..7c455094d 100644 --- a/src/scene/model/underlay_vector/model.rs +++ b/src/scene/model/underlay_vector/model.rs @@ -532,6 +532,182 @@ pub fn dgn_color_table(e: &[u8], first: usize) -> Option> { Some(out) } +/// A DGN element as drawing geometry (what DGNIMPORT converts): its level, +/// symbology and shape, in the units of the run that read it. +#[derive(Clone, Debug, PartialEq)] +pub struct DgnElement { + /// Level number (V7) or id (V8). + pub level: u32, + /// Colour; `None` takes the level's. + pub color: Option<[u8; 3]>, + /// Weight 0-31; `None` takes the level's. + pub weight: Option, + /// Line style; `None` takes the level's. + pub style: Option, + pub geom: DgnGeom, +} + +#[derive(Clone, Debug, PartialEq)] +pub enum DgnGeom { + Line([f64; 2], [f64; 2]), + /// A line string, curve or B-spline (sampled), or a shape (its points + /// end where they start). + Points(Vec<[f64; 2]>), + /// An elliptical arc: the point at parameter t is + /// `center + cos t · u + sin t · v`, from `start` over `sweep` (radians). + Arc { + center: [f64; 2], + u: [f64; 2], + v: [f64; 2], + start: f64, + sweep: f64, + }, + /// A complex chain or shape: its components in order. + Chain(Vec), + Text { + text: String, + origin: [f64; 2], + height: f64, + width_factor: f64, + rotation: f64, + /// Font number (V7); `None` when the file's font is not read. + font: Option, + }, + /// A text node: its lines. + TextNode(Vec), + /// A shared cell instance: x' = m[0]·x + m[1]·y + origin.x, + /// y' = m[2]·x + m[3]·y + origin.y. + Cell { + name: String, + m: [f64; 4], + origin: [f64; 2], + /// Scale along z (1 in a 2D file). + z_scale: f64, + }, +} + +impl DgnGeom { + /// The geometry through x' = s·x + t (lengths scaled by `s`). + pub fn scaled(&self, s: f64, t: [f64; 2]) -> DgnGeom { + let p = |q: [f64; 2]| [q[0] * s + t[0], q[1] * s + t[1]]; + let v = |q: [f64; 2]| [q[0] * s, q[1] * s]; + match self { + DgnGeom::Line(a, b) => DgnGeom::Line(p(*a), p(*b)), + DgnGeom::Points(pts) => DgnGeom::Points(pts.iter().map(|q| p(*q)).collect()), + DgnGeom::Arc { + center, + u, + v: w, + start, + sweep, + } => DgnGeom::Arc { + center: p(*center), + u: v(*u), + v: v(*w), + start: *start, + sweep: *sweep, + }, + DgnGeom::Chain(parts) => DgnGeom::Chain(parts.iter().map(|g| g.scaled(s, t)).collect()), + DgnGeom::TextNode(parts) => { + DgnGeom::TextNode(parts.iter().map(|g| g.scaled(s, t)).collect()) + } + DgnGeom::Text { + text, + origin, + height, + width_factor, + rotation, + font, + } => DgnGeom::Text { + text: text.clone(), + origin: p(*origin), + height: height * s, + width_factor: *width_factor, + rotation: *rotation, + font: *font, + }, + DgnGeom::Cell { + name, + m, + origin, + z_scale, + } => DgnGeom::Cell { + name: name.clone(), + m: *m, + origin: p(*origin), + z_scale: *z_scale, + }, + } + } +} + +/// Collects elements in file order, gathering the components of a complex +/// chain, shape or text node into their header's element. +#[derive(Default)] +pub struct DgnCollector { + pub elements: Vec, + /// The open complex element and how many components it still takes. + open: Option<(usize, DgnElement)>, +} + +impl DgnCollector { + /// A complex chain or shape (or a text node) header with `count` + /// components. + pub fn open(&mut self, count: usize, header: DgnElement) { + self.close(); + if count > 0 { + self.open = Some((count, header)); + } + } + + pub fn push(&mut self, element: DgnElement) { + if let Some((left, header)) = self.open.as_mut() { + if let DgnGeom::Chain(parts) | DgnGeom::TextNode(parts) = &mut header.geom { + parts.push(element.geom); + } + *left -= 1; + if *left == 0 { + self.close(); + } + return; + } + self.elements.push(element); + } + + /// Ends a complex element cut short with the components it has. + pub fn close(&mut self) { + if let Some((_, header)) = self.open.take() { + self.elements.push(header); + } + } +} + +/// What DGNIMPORT reads from a model: its elements and the shared cells +/// they place (in master units, the global origin at 0,0), the levels by +/// number or id (name, colour) and the sub units per master unit. +#[derive(Clone, Debug, Default)] +pub struct DgnImport { + pub elements: Vec, + pub cells: Vec<(String, Vec)>, + pub levels: std::collections::HashMap)>, + pub sub_per_master: f64, + /// Master and sub unit names, when the file has them. + pub unit_names: (String, String), + /// A V7 file (its elements carry their own line style and weight). + pub v7: bool, +} + +/// The elements through x' = s·x + t. +pub fn scaled_elements(elements: &[DgnElement], s: f64, t: [f64; 2]) -> Vec { + elements + .iter() + .map(|e| DgnElement { + geom: e.geom.scaled(s, t), + ..e.clone() + }) + .collect() +} + /// A line of text the host draws with its own font. #[derive(Clone, Debug, PartialEq)] pub struct Text { diff --git a/src/ui/window/import_dialogs.rs b/src/ui/window/import_dialogs.rs new file mode 100644 index 000000000..48b010e9e --- /dev/null +++ b/src/ui/window/import_dialogs.rs @@ -0,0 +1,639 @@ +//! Import File (IMPORT: the format first, then the file, routed to the +//! format's importer) and DGN Import (DGNIMPORT: model, target drawing, +//! conversion units and the mapping setup's effect on the file). + +use iced::widget::{button, column, container, image, row, scrollable, text, text_input, Space}; +use iced::{Background, Border, Element, Fill, Length, Theme}; + +use crate::app::Message; +use crate::t; +use crate::ui::style::common::muted_style; +use crate::ui::style::form::{button_style, dialog_button, dialog_button_styled_opt, field_style}; +use crate::ui::window::pdf_dialogs::{card, well_style, PageThumb}; + +/// One edit in the Import File or DGN Import window. +#[derive(Debug, Clone)] +pub enum ImportDialogMsg { + // Import File + Path(String), + Browse, + /// A recent file chosen from its chip. + Recent(usize), + Format(usize), + Search(String), + OnlySupported(bool), + Picked(Option), + ImportOk, + // DGN Import + DgnChange, + DgnPicked(Option), + DgnModel(usize), + DgnCurrent(bool), + DgnPrefix(bool), + DgnSub(bool), + DgnExplode(bool), + DgnMappingTab(usize), + DgnOk, + Help(&'static str), +} + +/// The colour of a format that is read in part or only written. +const AMBER: iced::Color = iced::Color::from_rgb8(0xd9, 0xa4, 0x00); + +fn msg(m: ImportDialogMsg) -> Message { + Message::ImportDialog(m) +} + +/// Whether OCS imports a format, and why not when it does not. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Support { + Yes, + /// Any file: the importer follows the file's extension. + ByExtension, + /// SAT: ACIS bodies are read only inside drawings. + AcisSolverOnly, + /// STEP: written by STEPOUT, not read. + ExportOnly, + No, +} + +pub struct ImportFormat { + pub name: &'static str, + pub patterns: &'static str, + pub extensions: &'static [&'static str], + /// 0 2D / underlay, 1 3D model, 2 other. + pub group: u8, + pub support: Support, +} + +const fn format( + name: &'static str, + patterns: &'static str, + extensions: &'static [&'static str], + group: u8, + support: Support, +) -> ImportFormat { + ImportFormat { + name, + patterns, + extensions, + group, + support, + } +} + +/// The reference's Import File types, in its order of groups. +pub const FORMATS: [ImportFormat; 21] = [ + format("PDF Files", "*.pdf", &["pdf"], 0, Support::Yes), + format("MicroStation DGN", "*.dgn", &["dgn"], 0, Support::Yes), + format("All DGN Files", "*.*", &["*"], 0, Support::Yes), + format("Metafile", "*.wmf", &["wmf"], 0, Support::No), + format("3D Studio", "*.3ds", &["3ds"], 1, Support::No), + format("ACIS", "*.sat", &["sat"], 1, Support::AcisSolverOnly), + format( + "CATIA V4", + "*.model; *.session; *.exp; *.dlv3", + &["model", "session", "exp", "dlv3"], + 1, + Support::No, + ), + format( + "CATIA V5", + "*.CATPart; *.CATProduct", + &["CATPart", "CATProduct"], + 1, + Support::No, + ), + format("IGES", "*.igs; *.iges", &["igs", "iges"], 1, Support::No), + format("Inventor", "*.ipt; *.iam", &["ipt", "iam"], 1, Support::No), + format("JT", "*.jt", &["jt"], 1, Support::No), + format("NX", "*.prt", &["prt"], 1, Support::No), + format("Parasolid Binary", "*.x_b", &["x_b"], 1, Support::No), + format("Parasolid Text", "*.x_t", &["x_t"], 1, Support::No), + format("Pro/ENGINEER", "*.prt*; *.asm*", &["prt", "asm"], 1, Support::No), + format("Pro/ENGINEER Granite", "*.g", &["g"], 1, Support::No), + format("Pro/ENGINEER Neutral", "*.neu*", &["neu"], 1, Support::No), + format("Rhino", "*.3dm", &["3dm"], 1, Support::No), + format( + "SolidWorks", + "*.prt; *.sldprt; *.asm; *.sldasm", + &["prt", "sldprt", "asm", "sldasm"], + 1, + Support::No, + ), + format("STEP", "*.ste; *.stp; *.step", &["ste", "stp", "step"], 1, Support::ExportOnly), + format("All Files", "*.*", &["*"], 2, Support::ByExtension), +]; + +pub struct ImportFileState { + pub path: String, + pub format: usize, + pub search: String, + pub only_supported: bool, + pub error: Option, +} + +pub struct DgnImportState { + pub path: String, + pub version: &'static str, + pub models: Vec, + pub model: usize, + pub current_drawing: bool, + pub prefix_duplicates: bool, + pub sub_units: bool, + pub explode_text_nodes: bool, + /// Master and sub unit names. + pub units: (String, String), + pub mapping_tab: usize, + /// The chosen model's mapping rows per tab (layer, linetype, + /// lineweight, colour). + pub mapping: [Vec<(String, String)>; 4], +} + +/// Both windows' working copies and the files imported this session. +#[derive(Default)] +pub struct ImportDialogs { + pub file: Option, + pub dgn: Option, + pub recent: Vec, +} + +fn file_name(path: &str) -> String { + std::path::Path::new(path) + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_else(|| path.to_string()) +} + +/// A long path as its drive, an ellipsis and the last three parts. +fn short_path(path: &str) -> String { + let parts: Vec<&str> = path.split(['/', '\\']).filter(|p| !p.is_empty()).collect(); + if parts.len() <= 4 { + return path.to_string(); + } + format!("{}\\…\\{}", parts[0], parts[parts.len() - 3..].join("\\")) +} + +fn badge<'a>(support: Support) -> Element<'a, Message> { + let (label, tone) = match support { + Support::Yes => (t!("Supported"), 0), + Support::ByExtension => (t!("By extension"), 2), + Support::AcisSolverOnly => (t!("ACIS solver available"), 1), + Support::ExportOnly => (t!("Export only"), 1), + Support::No => (t!("None"), 2), + }; + let color = move |theme: &Theme| match tone { + 0 => theme.palette().success.base.color, + 1 => AMBER, + _ => theme.palette().background.base.text.scale_alpha(0.55), + }; + container( + text(label) + .size(10) + .style(move |theme: &Theme| text::Style { + color: Some(color(theme)), + }), + ) + .padding([1, 7]) + .style(move |theme: &Theme| container::Style { + border: Border { + width: 1.0, + radius: 9.0.into(), + color: color(theme), + }, + ..Default::default() + }) + .into() +} + +/// The reason a format cannot be imported. +pub fn unsupported_reason(support: Support) -> Option> { + match support { + Support::Yes | Support::ByExtension => None, + Support::AcisSolverOnly => Some(t!( + "ACIS bodies are read only inside drawings; SAT files cannot be imported." + )), + Support::ExportOnly => Some(t!("STEP files are only written (STEPOUT), not read.")), + Support::No => Some(t!("OpenCAD Studio has no importer for this format.")), + } +} + +fn tile_style(selected: bool) -> impl Fn(&Theme, button::Status) -> button::Style { + move |theme: &Theme, status| { + let palette = theme.palette(); + let hovered = matches!(status, button::Status::Hovered | button::Status::Pressed); + button::Style { + background: Some(Background::Color(if selected || hovered { + palette.background.base.color + } else { + palette.background.weak.color + })), + text_color: palette.background.base.text, + border: Border { + width: if selected { 1.5 } else { 0.0 }, + radius: 6.0.into(), + color: palette.primary.base.color, + }, + ..Default::default() + } + } +} + +/// Help, Cancel and the named action (inactive while `ok` is `None`). +fn footer<'a>(help: &'static str, action: std::borrow::Cow<'static, str>, ok: Option) -> Element<'a, Message> { + row![ + button(text("?").size(12)) + .on_press(msg(ImportDialogMsg::Help(help))) + .style(button_style(false)) + .padding([5, 11]), + Space::new().width(Fill), + dialog_button(t!("Cancel"), Message::CloseModal, false), + dialog_button_styled_opt(action, ok, button::primary), + ] + .spacing(6) + .align_y(iced::Center) + .into() +} + +fn chip<'a>(label: String, on: bool, message: Message) -> Element<'a, Message> { + button(text(label).size(11)) + .on_press(message) + .style(button_style(on)) + .padding([4, 10]) + .into() +} + +/// One choice out of a few, as joined buttons; inactive ones are greyed. +fn segmented<'a>(options: Vec<(String, bool, Option)>) -> Element<'a, Message> { + let buttons = options.into_iter().map(|(label, on, message)| { + button(text(label).size(11).width(Fill).align_x(iced::Center)) + .on_press_maybe(message) + .style(button_style(on)) + .padding([5, 6]) + .width(Fill) + .into() + }); + container(iced::widget::Row::with_children(buttons.collect::>()).spacing(2)) + .padding(2) + .width(Fill) + .style(well_style) + .into() +} + +pub fn view_import_file<'a>( + state: &'a ImportFileState, + recent: &'a [String], + sizing: crate::ui::modal::ModalSizing, +) -> Element<'a, Message> { + let mut chips = row![text(t!("Recent:")).size(11).style(muted_style)] + .spacing(6) + .align_y(iced::Center); + for (n, path) in recent.iter().enumerate() { + chips = chips.push(chip( + file_name(path), + state.path == *path, + msg(ImportDialogMsg::Recent(n)), + )); + } + let mut file = column![row![ + text_input("", &state.path) + .on_input(|s| msg(ImportDialogMsg::Path(s))) + .on_submit(msg(ImportDialogMsg::ImportOk)) + .size(12) + .padding([6, 8]) + .width(Fill) + .style(field_style), + button(text(t!("Browse...")).size(11)) + .on_press(msg(ImportDialogMsg::Browse)) + .style(button_style(false)) + .padding([6, 14]), + ] + .spacing(8) + .align_y(iced::Center)] + .spacing(8); + if !recent.is_empty() { + file = file.push(chips); + } + if let Some(error) = &state.error { + file = file.push(text(error.clone()).size(11).style(|theme: &Theme| text::Style { + color: Some(theme.palette().danger.base.color), + })); + } + + let search = state.search.trim().to_lowercase(); + let shown: Vec = (0..FORMATS.len()) + .filter(|&k| { + let f = &FORMATS[k]; + (!state.only_supported || matches!(f.support, Support::Yes | Support::ByExtension)) + && (search.is_empty() + || f.name.to_lowercase().contains(&search) + || f.patterns.to_lowercase().contains(&search)) + }) + .collect(); + let mut groups = column![].spacing(6); + for (group, title) in [ + (0u8, t!("2D / Underlay")), + (1, t!("3D Model")), + (2, t!("Other")), + ] { + let members: Vec = shown + .iter() + .copied() + .filter(|&k| FORMATS[k].group == group) + .collect(); + if members.is_empty() { + continue; + } + groups = groups.push(text(title.to_uppercase()).size(10).style(muted_style)); + for line in members.chunks(3) { + let mut r = row![].spacing(6); + for &k in line { + let f = &FORMATS[k]; + r = r.push( + button( + row![ + column![ + text(f.name).size(12), + text(f.patterns).size(10).style(muted_style), + ] + .spacing(1) + .width(Fill), + badge(f.support), + ] + .spacing(6) + .align_y(iced::Center), + ) + .on_press(msg(ImportDialogMsg::Format(k))) + .style(tile_style(state.format == k)) + .padding([5, 8]) + .width(Length::FillPortion(1)), + ); + } + for _ in line.len()..3 { + r = r.push(Space::new().width(Length::FillPortion(1))); + } + groups = groups.push(r); + } + } + let selected = &FORMATS[state.format]; + let reason = unsupported_reason(selected.support); + let formats = card( + format!("{} · {}", t!("Format"), FORMATS.len()), + column![ + row![ + text_input(&t!("Search formats..."), &state.search) + .on_input(|s| msg(ImportDialogMsg::Search(s))) + .size(11) + .padding([5, 8]) + .width(Length::Fixed(220.0)) + .style(field_style), + Space::new().width(Fill), + chip( + t!("All").into_owned(), + !state.only_supported, + msg(ImportDialogMsg::OnlySupported(false)) + ), + chip( + t!("Only what OCS can open").into_owned(), + state.only_supported, + msg(ImportDialogMsg::OnlySupported(true)) + ), + ] + .spacing(6) + .align_y(iced::Center), + scrollable(groups).height(Length::Fixed(360.0)), + text(match &reason { + Some(r) => format!("{}: {r}", selected.name), + None => String::new(), + }) + .size(11) + .style(|_: &Theme| text::Style { + color: Some(AMBER), + }), + ] + .spacing(8), + ); + let ok = reason.is_none().then(|| msg(ImportDialogMsg::ImportOk)); + column![ + card(t!("File").into_owned(), file), + formats, + footer("import", t!("Import"), ok), + ] + .spacing(10) + .padding([10, 12]) + .width(sizing.width) + .into() +} + +pub fn view_dgn_import<'a>( + state: &'a DgnImportState, + sizing: crate::ui::modal::ModalSizing, +) -> Element<'a, Message> { + let top = container( + row![ + container(text(file_name(&state.path)).size(12)) + .padding([4, 10]) + .style(|theme: &Theme| container::Style { + border: Border { + width: 1.0, + radius: 12.0.into(), + color: theme.palette().primary.base.color, + }, + ..Default::default() + }), + text(format!( + "{} · {} · {}", + short_path(&state.path), + state.version, + crate::tf!("{n} model(s)", n = state.models.len()) + )) + .size(11) + .style(muted_style) + .width(Fill) + .wrapping(iced::advanced::text::Wrapping::None), + button(text(t!("Change file...")).size(11)) + .on_press(msg(ImportDialogMsg::DgnChange)) + .style(button::text) + .padding([4, 6]), + ] + .spacing(10) + .align_y(iced::Center), + ) + .padding([8, 12]) + .width(Fill) + .style(crate::ui::window::pdf_dialogs::card_style); + + let tiles = state.models.iter().enumerate().map(|(k, m)| { + let chosen = k == state.model; + let picture: Element<'a, Message> = match &m.image { + Some(handle) => image(handle.clone()).width(Fill).height(Fill).into(), + None => Space::new().width(Fill).height(Fill).into(), + }; + button( + column![ + container(picture) + .width(Length::Fixed(132.0)) + .height(Length::Fixed(80.0)) + .padding(4) + .style(well_style), + text(m.label.clone()) + .size(11) + .width(Length::Fixed(132.0)) + .align_x(iced::Center), + ] + .spacing(4), + ) + .on_press(msg(ImportDialogMsg::DgnModel(k))) + .style(tile_style(chosen)) + .padding(5) + .into() + }); + let model = card( + t!("Model").into_owned(), + scrollable( + iced::widget::Row::with_children(tiles.collect::>()) + .spacing(6) + .wrap() + .vertical_spacing(6), + ) + .height(Length::Fixed(118.0)), + ); + let current = state.current_drawing; + let target = card( + t!("Target").into_owned(), + column![ + segmented(vec![ + ( + t!("New Drawing").into_owned(), + !current, + Some(msg(ImportDialogMsg::DgnCurrent(false))) + ), + ( + t!("Current Drawing").into_owned(), + current, + Some(msg(ImportDialogMsg::DgnCurrent(true))) + ), + ]), + text(t!("On name conflict")).size(10).style(muted_style), + segmented(vec![ + ( + t!("Add prefix to dependent definitions").into_owned(), + current && state.prefix_duplicates, + current.then(|| msg(ImportDialogMsg::DgnPrefix(true))) + ), + ( + t!("Ignore duplicate names").into_owned(), + current && !state.prefix_duplicates, + current.then(|| msg(ImportDialogMsg::DgnPrefix(false))) + ), + ]), + ] + .spacing(6), + ); + let unit = |name: &str| { + let word = crate::modules::insert::dgn_import::unit_display(name); + if word.is_empty() { + String::new() + } else { + format!(" · {}", t!(word.as_str())) + } + }; + let units = card( + t!("Units").into_owned(), + column![ + segmented(vec![ + ( + format!("{}{}", t!("Master unit"), unit(&state.units.0)), + !state.sub_units, + Some(msg(ImportDialogMsg::DgnSub(false))) + ), + ( + format!("{}{}", t!("Sub unit"), unit(&state.units.1)), + state.sub_units, + Some(msg(ImportDialogMsg::DgnSub(true))) + ), + ]), + row![ + iced::widget::toggler(state.explode_text_nodes) + .on_toggle(|on| msg(ImportDialogMsg::DgnExplode(on))) + .size(16), + text(t!("Explode text nodes to text elements")).size(11), + ] + .spacing(8) + .align_y(iced::Center), + ] + .spacing(8), + ); + // The DGN readers do not read reference attachments, so their handling + // is shown but cannot be chosen. + let references = card( + t!("External DGN references").into_owned(), + column![ + segmented(vec![ + (t!("Bind as block / xref").into_owned(), false, None), + (t!("Ignore").into_owned(), true, None), + (t!("Attach as DGN underlay").into_owned(), false, None), + ]), + text(t!("Reference attachments of DGN files are not read; they are left out.")) + .size(11) + .style(muted_style), + ] + .spacing(8), + ); + let tabs = [t!("Layer"), t!("Linetype"), t!("Lineweight"), t!("Color")]; + let mut table = column![row![ + text("DGN").size(11).style(muted_style).width(Fill), + text("DWG").size(11).style(muted_style).width(Fill), + ]] + .spacing(4); + for (dgn, dwg) in &state.mapping[state.mapping_tab.min(3)] { + table = table.push(row![ + text(dgn.clone()).size(11).width(Fill), + text("\u{2192}").size(11).style(muted_style).width(Length::Fixed(24.0)), + text(dwg.clone()).size(11).width(Fill), + ]); + } + let mapping = card( + t!("Property mapping").into_owned(), + column![ + row![chip( + "Standard".to_string(), + true, + msg(ImportDialogMsg::DgnMappingTab(state.mapping_tab)) + )], + text(t!("Standard mapping setup.")).size(11).style(muted_style), + segmented( + tabs.iter() + .enumerate() + .map(|(k, label)| { + ( + label.to_string(), + state.mapping_tab == k, + Some(msg(ImportDialogMsg::DgnMappingTab(k))), + ) + }) + .collect() + ), + scrollable(table).height(Length::Fixed(250.0)), + text(t!("The preview shows the selected setup's effect on this file.")) + .size(11) + .style(muted_style), + ] + .spacing(8), + ); + column![ + top, + row![ + column![model, target, units, references] + .spacing(10) + .width(Length::FillPortion(3)), + container(mapping).width(Length::FillPortion(2)), + ] + .spacing(10), + footer("dgn", t!("Import"), Some(msg(ImportDialogMsg::DgnOk))), + ] + .spacing(10) + .padding([10, 12]) + .width(sizing.width) + .into() +} diff --git a/src/ui/window/mod.rs b/src/ui/window/mod.rs index b12716252..97246b371 100644 --- a/src/ui/window/mod.rs +++ b/src/ui/window/mod.rs @@ -15,6 +15,7 @@ pub mod drawing_units; pub mod field_dialog; pub mod find_replace; pub mod geometric_tolerance; +pub mod import_dialogs; pub mod gradient_editor; pub mod graphic_attributes; pub mod layer_state_manager;