diff --git a/locales/ar-SA/opencadstudio.ftl b/locales/ar-SA/opencadstudio.ftl index 3b372ef81..a7f8be194 100644 --- a/locales/ar-SA/opencadstudio.ftl +++ b/locales/ar-SA/opencadstudio.ftl @@ -1251,6 +1251,7 @@ common = .dgn-files = DGN Files .select-dwf-file = Select DWF File .select-dgn-file = Select DGN File + .select-acis-file = اختيار ملف ACIS .pdfattach = PDFATTACH: __ocs_fmt_0__ .select-external-reference-file = { draw.select } · { properties.external-reference } · { common.files } .cad-files = { common.files } (CAD) @@ -6660,10 +6661,6 @@ txt2mtxt = 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 @@ -6688,10 +6685,46 @@ dgn-import = .import-file = Import File .dgn-import = DGN Import .import-dgn-file = Import DGN File + .import-wmf = استيراد WMF .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. +import-3ds = + .title = 3D Studio Import + .summary = { $objects } objects, { $materials } materials + .change = Change... + .mesh = Mesh + .light = Light + .camera = Camera + .general = General + .global = Global Settings + .all = All + .name = Name + .type = Type + .materials = { $n } materials + .objects = Objects + .search = Search by name... + .select-all = Select all + .clear = Clear + .layers = Place on layers + .by-object = By object + .by-material = By material + .by-color = By object color + .single = Single layer + .note-object = Each object goes on a layer named after it. + .note-material = Each object goes on a layer named after its material. + .note-color = Each object goes on a layer named after its color. + .note-single = Every object goes on one layer. + .multi = Objects with several materials + .ask = Ask each time + .split = Split by material + .first = Assign the first material + .none = Don't assign a material + .reason = Check at least one object + .cancel = Cancel + .import = Import + .help = Imports the checked objects of a 3D Studio file: meshes as polyface meshes with their materials, cameras as named views. import-panel = .dgn-import = استيراد DGN .recognize-shx = التعرف على نص SHX diff --git a/locales/bg-BG/opencadstudio.ftl b/locales/bg-BG/opencadstudio.ftl index 6b65f73d7..85ef54df9 100644 --- a/locales/bg-BG/opencadstudio.ftl +++ b/locales/bg-BG/opencadstudio.ftl @@ -1238,6 +1238,7 @@ common = .dgn-files = DGN Files .select-dwf-file = Select DWF File .select-dgn-file = Select DGN File + .select-acis-file = Избор на ACIS файл .pdfattach = PDFATTACH: __ocs_fmt_0__ .select-external-reference-file = { draw.select } · { properties.external-reference } · { common.files } .cad-files = { common.files } (CAD) @@ -6648,10 +6649,6 @@ txt2mtxt = 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 @@ -6676,10 +6673,46 @@ dgn-import = .import-file = Import File .dgn-import = DGN Import .import-dgn-file = Import DGN File + .import-wmf = Импортиране на WMF .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. +import-3ds = + .title = 3D Studio Import + .summary = { $objects } objects, { $materials } materials + .change = Change... + .mesh = Mesh + .light = Light + .camera = Camera + .general = General + .global = Global Settings + .all = All + .name = Name + .type = Type + .materials = { $n } materials + .objects = Objects + .search = Search by name... + .select-all = Select all + .clear = Clear + .layers = Place on layers + .by-object = By object + .by-material = By material + .by-color = By object color + .single = Single layer + .note-object = Each object goes on a layer named after it. + .note-material = Each object goes on a layer named after its material. + .note-color = Each object goes on a layer named after its color. + .note-single = Every object goes on one layer. + .multi = Objects with several materials + .ask = Ask each time + .split = Split by material + .first = Assign the first material + .none = Don't assign a material + .reason = Check at least one object + .cancel = Cancel + .import = Import + .help = Imports the checked objects of a 3D Studio file: meshes as polyface meshes with their materials, cameras as named views. import-panel = .dgn-import = Импортиране на DGN .recognize-shx = Разпознаване на SHX текст diff --git a/locales/cs-CZ/opencadstudio.ftl b/locales/cs-CZ/opencadstudio.ftl index 5163c05aa..2dde5f903 100644 --- a/locales/cs-CZ/opencadstudio.ftl +++ b/locales/cs-CZ/opencadstudio.ftl @@ -1238,6 +1238,7 @@ common = .dgn-files = DGN Files .select-dwf-file = Select DWF File .select-dgn-file = Select DGN File + .select-acis-file = Vybrat soubor ACIS .pdfattach = PDFATTACH: __ocs_fmt_0__ .select-external-reference-file = { draw.select } · { properties.external-reference } · { common.files } .cad-files = { common.files } (CAD) @@ -6645,10 +6646,6 @@ txt2mtxt = 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 @@ -6673,10 +6670,46 @@ dgn-import = .import-file = Import File .dgn-import = DGN Import .import-dgn-file = Import DGN File + .import-wmf = Importovat WMF .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. +import-3ds = + .title = 3D Studio Import + .summary = { $objects } objects, { $materials } materials + .change = Change... + .mesh = Mesh + .light = Light + .camera = Camera + .general = General + .global = Global Settings + .all = All + .name = Name + .type = Type + .materials = { $n } materials + .objects = Objects + .search = Search by name... + .select-all = Select all + .clear = Clear + .layers = Place on layers + .by-object = By object + .by-material = By material + .by-color = By object color + .single = Single layer + .note-object = Each object goes on a layer named after it. + .note-material = Each object goes on a layer named after its material. + .note-color = Each object goes on a layer named after its color. + .note-single = Every object goes on one layer. + .multi = Objects with several materials + .ask = Ask each time + .split = Split by material + .first = Assign the first material + .none = Don't assign a material + .reason = Check at least one object + .cancel = Cancel + .import = Import + .help = Imports the checked objects of a 3D Studio file: meshes as polyface meshes with their materials, cameras as named views. import-panel = .dgn-import = Import DGN .recognize-shx = Rozpoznat text SHX diff --git a/locales/de-DE/opencadstudio.ftl b/locales/de-DE/opencadstudio.ftl index 5e8dcfcb6..6a8467655 100644 --- a/locales/de-DE/opencadstudio.ftl +++ b/locales/de-DE/opencadstudio.ftl @@ -1239,6 +1239,7 @@ common = .dgn-files = DGN Files .select-dwf-file = Select DWF File .select-dgn-file = Select DGN File + .select-acis-file = ACIS-Datei auswählen .pdfattach = PDFATTACH: __ocs_fmt_0__ .select-external-reference-file = { draw.select } · { properties.external-reference } · { common.files } .cad-files = { common.files } (CAD) @@ -6646,10 +6647,6 @@ txt2mtxt = 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 @@ -6674,10 +6671,46 @@ dgn-import = .import-file = Import File .dgn-import = DGN Import .import-dgn-file = Import DGN File + .import-wmf = WMF importieren .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. +import-3ds = + .title = 3D Studio Import + .summary = { $objects } objects, { $materials } materials + .change = Change... + .mesh = Mesh + .light = Light + .camera = Camera + .general = General + .global = Global Settings + .all = All + .name = Name + .type = Type + .materials = { $n } materials + .objects = Objects + .search = Search by name... + .select-all = Select all + .clear = Clear + .layers = Place on layers + .by-object = By object + .by-material = By material + .by-color = By object color + .single = Single layer + .note-object = Each object goes on a layer named after it. + .note-material = Each object goes on a layer named after its material. + .note-color = Each object goes on a layer named after its color. + .note-single = Every object goes on one layer. + .multi = Objects with several materials + .ask = Ask each time + .split = Split by material + .first = Assign the first material + .none = Don't assign a material + .reason = Check at least one object + .cancel = Cancel + .import = Import + .help = Imports the checked objects of a 3D Studio file: meshes as polyface meshes with their materials, cameras as named views. import-panel = .dgn-import = DGN-Import .recognize-shx = SHX-Text erkennen diff --git a/locales/el-GR/opencadstudio.ftl b/locales/el-GR/opencadstudio.ftl index cf2149381..efa7121e4 100644 --- a/locales/el-GR/opencadstudio.ftl +++ b/locales/el-GR/opencadstudio.ftl @@ -1240,6 +1240,7 @@ common = .dgn-files = DGN Files .select-dwf-file = Select DWF File .select-dgn-file = Select DGN File + .select-acis-file = Επιλογή αρχείου ACIS .pdfattach = PDFATTACH: __ocs_fmt_0__ .select-external-reference-file = { draw.select } · { properties.external-reference } · { common.files } .cad-files = { common.files } (CAD) @@ -6648,10 +6649,6 @@ txt2mtxt = 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 @@ -6676,10 +6673,46 @@ dgn-import = .import-file = Import File .dgn-import = DGN Import .import-dgn-file = Import DGN File + .import-wmf = Εισαγωγή WMF .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. +import-3ds = + .title = 3D Studio Import + .summary = { $objects } objects, { $materials } materials + .change = Change... + .mesh = Mesh + .light = Light + .camera = Camera + .general = General + .global = Global Settings + .all = All + .name = Name + .type = Type + .materials = { $n } materials + .objects = Objects + .search = Search by name... + .select-all = Select all + .clear = Clear + .layers = Place on layers + .by-object = By object + .by-material = By material + .by-color = By object color + .single = Single layer + .note-object = Each object goes on a layer named after it. + .note-material = Each object goes on a layer named after its material. + .note-color = Each object goes on a layer named after its color. + .note-single = Every object goes on one layer. + .multi = Objects with several materials + .ask = Ask each time + .split = Split by material + .first = Assign the first material + .none = Don't assign a material + .reason = Check at least one object + .cancel = Cancel + .import = Import + .help = Imports the checked objects of a 3D Studio file: meshes as polyface meshes with their materials, cameras as named views. import-panel = .dgn-import = Εισαγωγή DGN .recognize-shx = Αναγνώριση κειμένου SHX diff --git a/locales/en-US/opencadstudio.ftl b/locales/en-US/opencadstudio.ftl index f143ac188..46c6714df 100644 --- a/locales/en-US/opencadstudio.ftl +++ b/locales/en-US/opencadstudio.ftl @@ -1237,6 +1237,7 @@ common = .dgn-files = DGN Files .select-dwf-file = Select DWF File .select-dgn-file = Select DGN File + .select-acis-file = Select ACIS File .pdfattach = PDFATTACH: __ocs_fmt_0__ .select-external-reference-file = Select External Reference File .cad-files = CAD Files @@ -6647,10 +6648,6 @@ txt2mtxt = 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 @@ -6675,10 +6672,46 @@ dgn-import = .import-file = Import File .dgn-import = DGN Import .import-dgn-file = Import DGN File + .import-wmf = Import WMF .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. +import-3ds = + .title = 3D Studio Import + .summary = { $objects } objects, { $materials } materials + .change = Change... + .mesh = Mesh + .light = Light + .camera = Camera + .general = General + .global = Global Settings + .all = All + .name = Name + .type = Type + .materials = { $n } materials + .objects = Objects + .search = Search by name... + .select-all = Select all + .clear = Clear + .layers = Place on layers + .by-object = By object + .by-material = By material + .by-color = By object color + .single = Single layer + .note-object = Each object goes on a layer named after it. + .note-material = Each object goes on a layer named after its material. + .note-color = Each object goes on a layer named after its color. + .note-single = Every object goes on one layer. + .multi = Objects with several materials + .ask = Ask each time + .split = Split by material + .first = Assign the first material + .none = Don't assign a material + .reason = Check at least one object + .cancel = Cancel + .import = Import + .help = Imports the checked objects of a 3D Studio file: meshes as polyface meshes with their materials, cameras as named views. import-panel = .dgn-import = DGN Import .recognize-shx = Recognize SHX Text diff --git a/locales/es-ES/opencadstudio.ftl b/locales/es-ES/opencadstudio.ftl index 97db96af5..70cac4e39 100644 --- a/locales/es-ES/opencadstudio.ftl +++ b/locales/es-ES/opencadstudio.ftl @@ -1239,6 +1239,7 @@ common = .dgn-files = DGN Files .select-dwf-file = Select DWF File .select-dgn-file = Select DGN File + .select-acis-file = Seleccionar archivo ACIS .pdfattach = PDFATTACH: __ocs_fmt_0__ .select-external-reference-file = { draw.select } · { properties.external-reference } · { common.files } .cad-files = { common.files } (CAD) @@ -6648,10 +6649,6 @@ txt2mtxt = 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 @@ -6676,10 +6673,46 @@ dgn-import = .import-file = Import File .dgn-import = DGN Import .import-dgn-file = Import DGN File + .import-wmf = Importar WMF .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. +import-3ds = + .title = 3D Studio Import + .summary = { $objects } objects, { $materials } materials + .change = Change... + .mesh = Mesh + .light = Light + .camera = Camera + .general = General + .global = Global Settings + .all = All + .name = Name + .type = Type + .materials = { $n } materials + .objects = Objects + .search = Search by name... + .select-all = Select all + .clear = Clear + .layers = Place on layers + .by-object = By object + .by-material = By material + .by-color = By object color + .single = Single layer + .note-object = Each object goes on a layer named after it. + .note-material = Each object goes on a layer named after its material. + .note-color = Each object goes on a layer named after its color. + .note-single = Every object goes on one layer. + .multi = Objects with several materials + .ask = Ask each time + .split = Split by material + .first = Assign the first material + .none = Don't assign a material + .reason = Check at least one object + .cancel = Cancel + .import = Import + .help = Imports the checked objects of a 3D Studio file: meshes as polyface meshes with their materials, cameras as named views. import-panel = .dgn-import = Importar DGN .recognize-shx = Reconocer texto SHX diff --git a/locales/fi-FI/opencadstudio.ftl b/locales/fi-FI/opencadstudio.ftl index 006577b59..f9f53355e 100644 --- a/locales/fi-FI/opencadstudio.ftl +++ b/locales/fi-FI/opencadstudio.ftl @@ -1238,6 +1238,7 @@ common = .dgn-files = DGN Files .select-dwf-file = Select DWF File .select-dgn-file = Select DGN File + .select-acis-file = Valitse ACIS-tiedosto .pdfattach = PDFATTACH: __ocs_fmt_0__ .select-external-reference-file = { draw.select } · { properties.external-reference } · { common.files } .cad-files = { common.files } (CAD) @@ -6645,10 +6646,6 @@ txt2mtxt = 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 @@ -6673,10 +6670,46 @@ dgn-import = .import-file = Import File .dgn-import = DGN Import .import-dgn-file = Import DGN File + .import-wmf = Tuo WMF .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. +import-3ds = + .title = 3D Studio Import + .summary = { $objects } objects, { $materials } materials + .change = Change... + .mesh = Mesh + .light = Light + .camera = Camera + .general = General + .global = Global Settings + .all = All + .name = Name + .type = Type + .materials = { $n } materials + .objects = Objects + .search = Search by name... + .select-all = Select all + .clear = Clear + .layers = Place on layers + .by-object = By object + .by-material = By material + .by-color = By object color + .single = Single layer + .note-object = Each object goes on a layer named after it. + .note-material = Each object goes on a layer named after its material. + .note-color = Each object goes on a layer named after its color. + .note-single = Every object goes on one layer. + .multi = Objects with several materials + .ask = Ask each time + .split = Split by material + .first = Assign the first material + .none = Don't assign a material + .reason = Check at least one object + .cancel = Cancel + .import = Import + .help = Imports the checked objects of a 3D Studio file: meshes as polyface meshes with their materials, cameras as named views. import-panel = .dgn-import = DGN-tuonti .recognize-shx = Tunnista SHX-teksti diff --git a/locales/fr-FR/opencadstudio.ftl b/locales/fr-FR/opencadstudio.ftl index be23a81c9..7f08c8a26 100644 --- a/locales/fr-FR/opencadstudio.ftl +++ b/locales/fr-FR/opencadstudio.ftl @@ -1239,6 +1239,7 @@ common = .dgn-files = DGN Files .select-dwf-file = Select DWF File .select-dgn-file = Select DGN File + .select-acis-file = Sélectionner un fichier ACIS .pdfattach = PDFATTACH: __ocs_fmt_0__ .select-external-reference-file = { draw.select } · { properties.external-reference } · { common.files } .cad-files = { common.files } (CAD) @@ -6648,10 +6649,6 @@ txt2mtxt = 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 @@ -6676,10 +6673,46 @@ dgn-import = .import-file = Import File .dgn-import = DGN Import .import-dgn-file = Import DGN File + .import-wmf = Importer un WMF .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. +import-3ds = + .title = 3D Studio Import + .summary = { $objects } objects, { $materials } materials + .change = Change... + .mesh = Mesh + .light = Light + .camera = Camera + .general = General + .global = Global Settings + .all = All + .name = Name + .type = Type + .materials = { $n } materials + .objects = Objects + .search = Search by name... + .select-all = Select all + .clear = Clear + .layers = Place on layers + .by-object = By object + .by-material = By material + .by-color = By object color + .single = Single layer + .note-object = Each object goes on a layer named after it. + .note-material = Each object goes on a layer named after its material. + .note-color = Each object goes on a layer named after its color. + .note-single = Every object goes on one layer. + .multi = Objects with several materials + .ask = Ask each time + .split = Split by material + .first = Assign the first material + .none = Don't assign a material + .reason = Check at least one object + .cancel = Cancel + .import = Import + .help = Imports the checked objects of a 3D Studio file: meshes as polyface meshes with their materials, cameras as named views. import-panel = .dgn-import = Import DGN .recognize-shx = Reconnaître le texte SHX diff --git a/locales/hi-IN/opencadstudio.ftl b/locales/hi-IN/opencadstudio.ftl index 590588bf9..c1f0a4f88 100644 --- a/locales/hi-IN/opencadstudio.ftl +++ b/locales/hi-IN/opencadstudio.ftl @@ -1227,6 +1227,7 @@ common = .dgn-files = DGN Files .select-dwf-file = Select DWF File .select-dgn-file = Select DGN File + .select-acis-file = ACIS फ़ाइल चुनें .pdfattach = PDFATTACH: __ocs_fmt_0__ .select-external-reference-file = { draw.select } · { properties.external-reference } · { common.files } .cad-files = { common.files } (CAD) @@ -6636,10 +6637,6 @@ txt2mtxt = 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 @@ -6664,10 +6661,46 @@ dgn-import = .import-file = Import File .dgn-import = DGN Import .import-dgn-file = Import DGN File + .import-wmf = WMF आयात करें .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. +import-3ds = + .title = 3D Studio Import + .summary = { $objects } objects, { $materials } materials + .change = Change... + .mesh = Mesh + .light = Light + .camera = Camera + .general = General + .global = Global Settings + .all = All + .name = Name + .type = Type + .materials = { $n } materials + .objects = Objects + .search = Search by name... + .select-all = Select all + .clear = Clear + .layers = Place on layers + .by-object = By object + .by-material = By material + .by-color = By object color + .single = Single layer + .note-object = Each object goes on a layer named after it. + .note-material = Each object goes on a layer named after its material. + .note-color = Each object goes on a layer named after its color. + .note-single = Every object goes on one layer. + .multi = Objects with several materials + .ask = Ask each time + .split = Split by material + .first = Assign the first material + .none = Don't assign a material + .reason = Check at least one object + .cancel = Cancel + .import = Import + .help = Imports the checked objects of a 3D Studio file: meshes as polyface meshes with their materials, cameras as named views. import-panel = .dgn-import = DGN आयात .recognize-shx = SHX टेक्स्ट पहचानें diff --git a/locales/hu-HU/opencadstudio.ftl b/locales/hu-HU/opencadstudio.ftl index d1d29a56f..073d13417 100644 --- a/locales/hu-HU/opencadstudio.ftl +++ b/locales/hu-HU/opencadstudio.ftl @@ -1243,6 +1243,7 @@ common = .dgn-files = DGN-fájlok .select-dwf-file = DWF-fájl kiválasztása .select-dgn-file = DGN-fájl kiválasztása + .select-acis-file = ACIS-fájl kiválasztása .pdfattach = PDFATTACH: __ocs_fmt_0__ .select-external-reference-file = { draw.select } · { properties.external-reference } · { common.files } .cad-files = { common.files } (CAD) @@ -6632,10 +6633,6 @@ txt2mtxt = 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 @@ -6660,10 +6657,46 @@ dgn-import = .import-file = Import File .dgn-import = DGN Import .import-dgn-file = Import DGN File + .import-wmf = WMF importálása .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. +import-3ds = + .title = 3D Studio Import + .summary = { $objects } objects, { $materials } materials + .change = Change... + .mesh = Mesh + .light = Light + .camera = Camera + .general = General + .global = Global Settings + .all = All + .name = Name + .type = Type + .materials = { $n } materials + .objects = Objects + .search = Search by name... + .select-all = Select all + .clear = Clear + .layers = Place on layers + .by-object = By object + .by-material = By material + .by-color = By object color + .single = Single layer + .note-object = Each object goes on a layer named after it. + .note-material = Each object goes on a layer named after its material. + .note-color = Each object goes on a layer named after its color. + .note-single = Every object goes on one layer. + .multi = Objects with several materials + .ask = Ask each time + .split = Split by material + .first = Assign the first material + .none = Don't assign a material + .reason = Check at least one object + .cancel = Cancel + .import = Import + .help = Imports the checked objects of a 3D Studio file: meshes as polyface meshes with their materials, cameras as named views. import-panel = .dgn-import = DGN-importálás .recognize-shx = SHX-szöveg felismerése diff --git a/locales/it-IT/opencadstudio.ftl b/locales/it-IT/opencadstudio.ftl index 456b17597..3de98f192 100644 --- a/locales/it-IT/opencadstudio.ftl +++ b/locales/it-IT/opencadstudio.ftl @@ -1238,6 +1238,7 @@ common = .dgn-files = DGN Files .select-dwf-file = Select DWF File .select-dgn-file = Select DGN File + .select-acis-file = Seleziona file ACIS .pdfattach = PDFATTACH: __ocs_fmt_0__ .select-external-reference-file = { draw.select } · { properties.external-reference } · { common.files } .cad-files = { common.files } (CAD) @@ -6647,10 +6648,6 @@ txt2mtxt = 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 @@ -6675,10 +6672,46 @@ dgn-import = .import-file = Import File .dgn-import = DGN Import .import-dgn-file = Import DGN File + .import-wmf = Importa WMF .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. +import-3ds = + .title = 3D Studio Import + .summary = { $objects } objects, { $materials } materials + .change = Change... + .mesh = Mesh + .light = Light + .camera = Camera + .general = General + .global = Global Settings + .all = All + .name = Name + .type = Type + .materials = { $n } materials + .objects = Objects + .search = Search by name... + .select-all = Select all + .clear = Clear + .layers = Place on layers + .by-object = By object + .by-material = By material + .by-color = By object color + .single = Single layer + .note-object = Each object goes on a layer named after it. + .note-material = Each object goes on a layer named after its material. + .note-color = Each object goes on a layer named after its color. + .note-single = Every object goes on one layer. + .multi = Objects with several materials + .ask = Ask each time + .split = Split by material + .first = Assign the first material + .none = Don't assign a material + .reason = Check at least one object + .cancel = Cancel + .import = Import + .help = Imports the checked objects of a 3D Studio file: meshes as polyface meshes with their materials, cameras as named views. import-panel = .dgn-import = Importa DGN .recognize-shx = Riconosci testo SHX diff --git a/locales/ja-JP/opencadstudio.ftl b/locales/ja-JP/opencadstudio.ftl index 8608192c3..34dfb1382 100644 --- a/locales/ja-JP/opencadstudio.ftl +++ b/locales/ja-JP/opencadstudio.ftl @@ -1229,6 +1229,7 @@ common = .dgn-files = DGN Files .select-dwf-file = Select DWF File .select-dgn-file = Select DGN File + .select-acis-file = ACIS ファイルを選択 .pdfattach = PDFATTACH: __ocs_fmt_0__ .select-external-reference-file = { draw.select } · { properties.external-reference } · { common.files } .cad-files = { common.files } (CAD) @@ -6636,10 +6637,6 @@ txt2mtxt = 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 @@ -6664,10 +6661,46 @@ dgn-import = .import-file = Import File .dgn-import = DGN Import .import-dgn-file = Import DGN File + .import-wmf = WMF を読み込み .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. +import-3ds = + .title = 3D Studio Import + .summary = { $objects } objects, { $materials } materials + .change = Change... + .mesh = Mesh + .light = Light + .camera = Camera + .general = General + .global = Global Settings + .all = All + .name = Name + .type = Type + .materials = { $n } materials + .objects = Objects + .search = Search by name... + .select-all = Select all + .clear = Clear + .layers = Place on layers + .by-object = By object + .by-material = By material + .by-color = By object color + .single = Single layer + .note-object = Each object goes on a layer named after it. + .note-material = Each object goes on a layer named after its material. + .note-color = Each object goes on a layer named after its color. + .note-single = Every object goes on one layer. + .multi = Objects with several materials + .ask = Ask each time + .split = Split by material + .first = Assign the first material + .none = Don't assign a material + .reason = Check at least one object + .cancel = Cancel + .import = Import + .help = Imports the checked objects of a 3D Studio file: meshes as polyface meshes with their materials, cameras as named views. import-panel = .dgn-import = DGN 読み込み .recognize-shx = SHX 文字を認識 diff --git a/locales/ko-KR/opencadstudio.ftl b/locales/ko-KR/opencadstudio.ftl index 94fcee811..6eb60ebdd 100644 --- a/locales/ko-KR/opencadstudio.ftl +++ b/locales/ko-KR/opencadstudio.ftl @@ -1238,6 +1238,7 @@ common = .dgn-files = DGN Files .select-dwf-file = Select DWF File .select-dgn-file = Select DGN File + .select-acis-file = ACIS 파일 선택 .pdfattach = PDFATTACH: __ocs_fmt_0__ .select-external-reference-file = { draw.select } · { properties.external-reference } · { common.files } .cad-files = { common.files } (CAD) @@ -6647,10 +6648,6 @@ txt2mtxt = 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 @@ -6675,10 +6672,46 @@ dgn-import = .import-file = Import File .dgn-import = DGN Import .import-dgn-file = Import DGN File + .import-wmf = WMF 가져오기 .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. +import-3ds = + .title = 3D Studio Import + .summary = { $objects } objects, { $materials } materials + .change = Change... + .mesh = Mesh + .light = Light + .camera = Camera + .general = General + .global = Global Settings + .all = All + .name = Name + .type = Type + .materials = { $n } materials + .objects = Objects + .search = Search by name... + .select-all = Select all + .clear = Clear + .layers = Place on layers + .by-object = By object + .by-material = By material + .by-color = By object color + .single = Single layer + .note-object = Each object goes on a layer named after it. + .note-material = Each object goes on a layer named after its material. + .note-color = Each object goes on a layer named after its color. + .note-single = Every object goes on one layer. + .multi = Objects with several materials + .ask = Ask each time + .split = Split by material + .first = Assign the first material + .none = Don't assign a material + .reason = Check at least one object + .cancel = Cancel + .import = Import + .help = Imports the checked objects of a 3D Studio file: meshes as polyface meshes with their materials, cameras as named views. import-panel = .dgn-import = DGN 가져오기 .recognize-shx = SHX 문자 인식 diff --git a/locales/lo-LA/opencadstudio.ftl b/locales/lo-LA/opencadstudio.ftl index 9716321f8..5b92c6b0f 100644 --- a/locales/lo-LA/opencadstudio.ftl +++ b/locales/lo-LA/opencadstudio.ftl @@ -1244,6 +1244,7 @@ common = .dgn-files = ໄຟລ໌ DGN .select-dwf-file = ເລືອກໄຟລ໌ DWF .select-dgn-file = ເລືອກໄຟລ໌ DGN + .select-acis-file = ເລືອກໄຟລ໌ ACIS .pdfattach = PDFATTACH: __ocs_fmt_0__ .select-external-reference-file = ເລືອກໄຟລ໌ອ້າງອີງພາຍນອກ .cad-files = ໄຟລ໌ CAD @@ -6648,10 +6649,6 @@ txt2mtxt = 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 @@ -6676,10 +6673,46 @@ dgn-import = .import-file = Import File .dgn-import = DGN Import .import-dgn-file = Import DGN File + .import-wmf = ນຳເຂົ້າ WMF .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. +import-3ds = + .title = 3D Studio Import + .summary = { $objects } objects, { $materials } materials + .change = Change... + .mesh = Mesh + .light = Light + .camera = Camera + .general = General + .global = Global Settings + .all = All + .name = Name + .type = Type + .materials = { $n } materials + .objects = Objects + .search = Search by name... + .select-all = Select all + .clear = Clear + .layers = Place on layers + .by-object = By object + .by-material = By material + .by-color = By object color + .single = Single layer + .note-object = Each object goes on a layer named after it. + .note-material = Each object goes on a layer named after its material. + .note-color = Each object goes on a layer named after its color. + .note-single = Every object goes on one layer. + .multi = Objects with several materials + .ask = Ask each time + .split = Split by material + .first = Assign the first material + .none = Don't assign a material + .reason = Check at least one object + .cancel = Cancel + .import = Import + .help = Imports the checked objects of a 3D Studio file: meshes as polyface meshes with their materials, cameras as named views. import-panel = .dgn-import = ນໍາເຂົ້າ DGN .recognize-shx = ຮັບຮູ້ຂໍ້ຄວາມ SHX diff --git a/locales/nl-NL/opencadstudio.ftl b/locales/nl-NL/opencadstudio.ftl index 859555dbe..0314ca291 100644 --- a/locales/nl-NL/opencadstudio.ftl +++ b/locales/nl-NL/opencadstudio.ftl @@ -1237,6 +1237,7 @@ common = .dgn-files = DGN Files .select-dwf-file = Select DWF File .select-dgn-file = Select DGN File + .select-acis-file = ACIS-bestand selecteren .pdfattach = PDFATTACH: __ocs_fmt_0__ .select-external-reference-file = { draw.select } · { properties.external-reference } · { common.files } .cad-files = { common.files } (CAD) @@ -6646,10 +6647,6 @@ txt2mtxt = 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 @@ -6674,10 +6671,46 @@ dgn-import = .import-file = Import File .dgn-import = DGN Import .import-dgn-file = Import DGN File + .import-wmf = WMF importeren .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. +import-3ds = + .title = 3D Studio Import + .summary = { $objects } objects, { $materials } materials + .change = Change... + .mesh = Mesh + .light = Light + .camera = Camera + .general = General + .global = Global Settings + .all = All + .name = Name + .type = Type + .materials = { $n } materials + .objects = Objects + .search = Search by name... + .select-all = Select all + .clear = Clear + .layers = Place on layers + .by-object = By object + .by-material = By material + .by-color = By object color + .single = Single layer + .note-object = Each object goes on a layer named after it. + .note-material = Each object goes on a layer named after its material. + .note-color = Each object goes on a layer named after its color. + .note-single = Every object goes on one layer. + .multi = Objects with several materials + .ask = Ask each time + .split = Split by material + .first = Assign the first material + .none = Don't assign a material + .reason = Check at least one object + .cancel = Cancel + .import = Import + .help = Imports the checked objects of a 3D Studio file: meshes as polyface meshes with their materials, cameras as named views. import-panel = .dgn-import = DGN importeren .recognize-shx = SHX-tekst herkennen diff --git a/locales/pl-PL/opencadstudio.ftl b/locales/pl-PL/opencadstudio.ftl index 422621df8..c3aa4f488 100644 --- a/locales/pl-PL/opencadstudio.ftl +++ b/locales/pl-PL/opencadstudio.ftl @@ -1238,6 +1238,7 @@ common = .dgn-files = DGN Files .select-dwf-file = Select DWF File .select-dgn-file = Select DGN File + .select-acis-file = Wybierz plik ACIS .pdfattach = PDFATTACH: __ocs_fmt_0__ .select-external-reference-file = { draw.select } · { properties.external-reference } · { common.files } .cad-files = { common.files } (CAD) @@ -6647,10 +6648,6 @@ txt2mtxt = 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 @@ -6675,10 +6672,46 @@ dgn-import = .import-file = Import File .dgn-import = DGN Import .import-dgn-file = Import DGN File + .import-wmf = Importuj WMF .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. +import-3ds = + .title = 3D Studio Import + .summary = { $objects } objects, { $materials } materials + .change = Change... + .mesh = Mesh + .light = Light + .camera = Camera + .general = General + .global = Global Settings + .all = All + .name = Name + .type = Type + .materials = { $n } materials + .objects = Objects + .search = Search by name... + .select-all = Select all + .clear = Clear + .layers = Place on layers + .by-object = By object + .by-material = By material + .by-color = By object color + .single = Single layer + .note-object = Each object goes on a layer named after it. + .note-material = Each object goes on a layer named after its material. + .note-color = Each object goes on a layer named after its color. + .note-single = Every object goes on one layer. + .multi = Objects with several materials + .ask = Ask each time + .split = Split by material + .first = Assign the first material + .none = Don't assign a material + .reason = Check at least one object + .cancel = Cancel + .import = Import + .help = Imports the checked objects of a 3D Studio file: meshes as polyface meshes with their materials, cameras as named views. import-panel = .dgn-import = Import DGN .recognize-shx = Rozpoznaj tekst SHX diff --git a/locales/pt-BR/opencadstudio.ftl b/locales/pt-BR/opencadstudio.ftl index f5e9a74ec..f8cb2f55b 100644 --- a/locales/pt-BR/opencadstudio.ftl +++ b/locales/pt-BR/opencadstudio.ftl @@ -1239,6 +1239,7 @@ common = .dgn-files = DGN Files .select-dwf-file = Select DWF File .select-dgn-file = Select DGN File + .select-acis-file = Selecionar arquivo ACIS .pdfattach = PDFATTACH: __ocs_fmt_0__ .select-external-reference-file = { draw.select } · { properties.external-reference } · { common.files } .cad-files = { common.files } (CAD) @@ -6648,10 +6649,6 @@ txt2mtxt = 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 @@ -6676,10 +6673,46 @@ dgn-import = .import-file = Import File .dgn-import = DGN Import .import-dgn-file = Import DGN File + .import-wmf = Importar WMF .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. +import-3ds = + .title = 3D Studio Import + .summary = { $objects } objects, { $materials } materials + .change = Change... + .mesh = Mesh + .light = Light + .camera = Camera + .general = General + .global = Global Settings + .all = All + .name = Name + .type = Type + .materials = { $n } materials + .objects = Objects + .search = Search by name... + .select-all = Select all + .clear = Clear + .layers = Place on layers + .by-object = By object + .by-material = By material + .by-color = By object color + .single = Single layer + .note-object = Each object goes on a layer named after it. + .note-material = Each object goes on a layer named after its material. + .note-color = Each object goes on a layer named after its color. + .note-single = Every object goes on one layer. + .multi = Objects with several materials + .ask = Ask each time + .split = Split by material + .first = Assign the first material + .none = Don't assign a material + .reason = Check at least one object + .cancel = Cancel + .import = Import + .help = Imports the checked objects of a 3D Studio file: meshes as polyface meshes with their materials, cameras as named views. import-panel = .dgn-import = Importar DGN .recognize-shx = Reconhecer texto SHX diff --git a/locales/ru-RU/opencadstudio.ftl b/locales/ru-RU/opencadstudio.ftl index fa726caac..1d32dd666 100644 --- a/locales/ru-RU/opencadstudio.ftl +++ b/locales/ru-RU/opencadstudio.ftl @@ -1242,6 +1242,7 @@ common = .dgn-files = DGN Files .select-dwf-file = Select DWF File .select-dgn-file = Select DGN File + .select-acis-file = Выбор файла ACIS .pdfattach = PDFATTACH: __ocs_fmt_0__ .select-external-reference-file = { draw.select } · { properties.external-reference } · { common.files } .cad-files = { common.files } (CAD) @@ -6649,10 +6650,6 @@ txt2mtxt = 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 @@ -6677,10 +6674,46 @@ dgn-import = .import-file = Import File .dgn-import = DGN Import .import-dgn-file = Import DGN File + .import-wmf = Импорт WMF .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. +import-3ds = + .title = 3D Studio Import + .summary = { $objects } objects, { $materials } materials + .change = Change... + .mesh = Mesh + .light = Light + .camera = Camera + .general = General + .global = Global Settings + .all = All + .name = Name + .type = Type + .materials = { $n } materials + .objects = Objects + .search = Search by name... + .select-all = Select all + .clear = Clear + .layers = Place on layers + .by-object = By object + .by-material = By material + .by-color = By object color + .single = Single layer + .note-object = Each object goes on a layer named after it. + .note-material = Each object goes on a layer named after its material. + .note-color = Each object goes on a layer named after its color. + .note-single = Every object goes on one layer. + .multi = Objects with several materials + .ask = Ask each time + .split = Split by material + .first = Assign the first material + .none = Don't assign a material + .reason = Check at least one object + .cancel = Cancel + .import = Import + .help = Imports the checked objects of a 3D Studio file: meshes as polyface meshes with their materials, cameras as named views. import-panel = .dgn-import = Импорт DGN .recognize-shx = Распознать текст SHX diff --git a/locales/tr-TR/opencadstudio.ftl b/locales/tr-TR/opencadstudio.ftl index ab1cc8358..1f54074a9 100644 --- a/locales/tr-TR/opencadstudio.ftl +++ b/locales/tr-TR/opencadstudio.ftl @@ -1225,6 +1225,7 @@ common = .dgn-files = DGN Dosyaları .select-dwf-file = DWF Dosyası Seç .select-dgn-file = DGN Dosyası Seç + .select-acis-file = ACIS Dosyası Seç .pdfattach = PDFATTACH: __ocs_fmt_0__ .select-external-reference-file = { draw.select } · { properties.external-reference } · { common.files } .cad-files = { common.files } (CAD) @@ -6569,10 +6570,6 @@ txt2mtxt = 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 @@ -6597,10 +6594,46 @@ dgn-import = .import-file = Dosya İçe Aktar .dgn-import = DGN İçe Aktar .import-dgn-file = DGN Dosyası İçe Aktar + .import-wmf = WMF İç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ı. +import-3ds = + .title = 3D Studio İçe Aktar + .summary = { $objects } nesne, { $materials } malzeme + .change = Değiştir… + .mesh = Ağ + .light = Işık + .camera = Kamera + .general = Genel + .global = Genel Ayarlar + .all = Tümü + .name = Ad + .type = Tür + .materials = { $n } malzeme + .objects = Nesneler + .search = Ada göre ara... + .select-all = Tümünü seç + .clear = Temizle + .layers = Katmana yerleştir + .by-object = Nesneye göre + .by-material = Malzemeye göre + .by-color = Nesne rengine göre + .single = Tek katman + .note-object = Her nesne kendi adını taşıyan katmana yerleşir. + .note-material = Her nesne malzemesinin adını taşıyan katmana yerleşir. + .note-color = Her nesne renginin adını taşıyan katmana yerleşir. + .note-single = Tüm nesneler tek bir katmana yerleşir. + .multi = Birden çok malzemeli nesne + .ask = Her seferinde sor + .split = Malzemeye göre böl + .first = İlk malzemeyi ata + .none = Malzeme atama + .reason = En az bir nesne işaretleyin + .cancel = İptal + .import = İçe Aktar + .help = İşaretli nesneleri içe aktarır: ağlar malzemeleriyle çokyüzlü ağ, kameralar adlandırılmış görünüm olur. import-panel = .dgn-import = DGN İçe Aktar .recognize-shx = SHX Metnini Tanı diff --git a/locales/zh-CN/opencadstudio.ftl b/locales/zh-CN/opencadstudio.ftl index 8994acd2c..9257185b5 100644 --- a/locales/zh-CN/opencadstudio.ftl +++ b/locales/zh-CN/opencadstudio.ftl @@ -1226,6 +1226,7 @@ common = .dgn-files = DGN Files .select-dwf-file = Select DWF File .select-dgn-file = Select DGN File + .select-acis-file = 选择 ACIS 文件 .pdfattach = PDFATTACH: __ocs_fmt_0__ .select-external-reference-file = { draw.select } · { properties.external-reference } · { common.files } .cad-files = { common.files } (CAD) @@ -6631,10 +6632,6 @@ txt2mtxt = 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 @@ -6659,10 +6656,46 @@ dgn-import = .import-file = Import File .dgn-import = DGN Import .import-dgn-file = Import DGN File + .import-wmf = 导入 WMF .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. +import-3ds = + .title = 3D Studio Import + .summary = { $objects } objects, { $materials } materials + .change = Change... + .mesh = Mesh + .light = Light + .camera = Camera + .general = General + .global = Global Settings + .all = All + .name = Name + .type = Type + .materials = { $n } materials + .objects = Objects + .search = Search by name... + .select-all = Select all + .clear = Clear + .layers = Place on layers + .by-object = By object + .by-material = By material + .by-color = By object color + .single = Single layer + .note-object = Each object goes on a layer named after it. + .note-material = Each object goes on a layer named after its material. + .note-color = Each object goes on a layer named after its color. + .note-single = Every object goes on one layer. + .multi = Objects with several materials + .ask = Ask each time + .split = Split by material + .first = Assign the first material + .none = Don't assign a material + .reason = Check at least one object + .cancel = Cancel + .import = Import + .help = Imports the checked objects of a 3D Studio file: meshes as polyface meshes with their materials, cameras as named views. import-panel = .dgn-import = DGN 输入 .recognize-shx = 识别 SHX 文字 diff --git a/locales/zh-TW/opencadstudio.ftl b/locales/zh-TW/opencadstudio.ftl index 228b0b1eb..fc980762b 100644 --- a/locales/zh-TW/opencadstudio.ftl +++ b/locales/zh-TW/opencadstudio.ftl @@ -1238,6 +1238,7 @@ common = .dgn-files = DGN Files .select-dwf-file = Select DWF File .select-dgn-file = Select DGN File + .select-acis-file = 選擇 ACIS 檔案 .pdfattach = PDFATTACH: __ocs_fmt_0__ .select-external-reference-file = { draw.select } · { properties.external-reference } · { common.files } .cad-files = { common.files } (CAD) @@ -6643,10 +6644,6 @@ txt2mtxt = 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 @@ -6671,10 +6668,46 @@ dgn-import = .import-file = Import File .dgn-import = DGN Import .import-dgn-file = Import DGN File + .import-wmf = 匯入 WMF .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. +import-3ds = + .title = 3D Studio Import + .summary = { $objects } objects, { $materials } materials + .change = Change... + .mesh = Mesh + .light = Light + .camera = Camera + .general = General + .global = Global Settings + .all = All + .name = Name + .type = Type + .materials = { $n } materials + .objects = Objects + .search = Search by name... + .select-all = Select all + .clear = Clear + .layers = Place on layers + .by-object = By object + .by-material = By material + .by-color = By object color + .single = Single layer + .note-object = Each object goes on a layer named after it. + .note-material = Each object goes on a layer named after its material. + .note-color = Each object goes on a layer named after its color. + .note-single = Every object goes on one layer. + .multi = Objects with several materials + .ask = Ask each time + .split = Split by material + .first = Assign the first material + .none = Don't assign a material + .reason = Check at least one object + .cancel = Cancel + .import = Import + .help = Imports the checked objects of a 3D Studio file: meshes as polyface meshes with their materials, cameras as named views. import-panel = .dgn-import = DGN 匯入 .recognize-shx = 辨識 SHX 文字 diff --git a/src/app/command_driver/mod.rs b/src/app/command_driver/mod.rs index ae929a040..ad4428912 100644 --- a/src/app/command_driver/mod.rs +++ b/src/app/command_driver/mod.rs @@ -664,6 +664,11 @@ impl OpenCADStudio { self.tabs[i].snap_result = None; return self.run_dgn_import(i, request); } + CmdResult::TdsImport(request) => { + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.run_tds_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 index 23efa5b81..607b6899e 100644 --- a/src/app/commands/dgn_import.rs +++ b/src/app/commands/dgn_import.rs @@ -8,6 +8,7 @@ use std::collections::HashMap; use std::sync::Arc; use super::*; +use crate::io::model_import::ModelFormat; use crate::modules::insert::dgn_import::{self, DgnImportCommand, DgnImportRequest}; use crate::ui::window::import_dialogs::{ unsupported_reason, DgnImportState, ImportDialogMsg, ImportFileState, Support, FORMATS, @@ -18,7 +19,7 @@ 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 { +pub(super) 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(); @@ -62,6 +63,9 @@ fn is_dgn(path: &str) -> bool { enum Route { Pdf, Dgn, + Model(ModelFormat), + Wmf, + Tds, } /// The importer for `path` chosen as `format` (an index of [`FORMATS`]; `None` @@ -75,6 +79,22 @@ fn route(path: &str, format: Option) -> Result { } let ext = extension(path); let by_extension = f.is_none_or(|f| f.support == Support::ByExtension); + // The chosen format, or the one whose patterns name the extension. + let named = if by_extension { + FORMATS + .iter() + .find(|f| f.extensions.iter().any(|e| e.eq_ignore_ascii_case(&ext))) + } else { + f + }; + match named.map(|f| f.name) { + Some("ACIS") => return Ok(Route::Model(ModelFormat::Sat)), + Some("STEP") => return Ok(Route::Model(ModelFormat::Step)), + Some("IGES") => return Ok(Route::Model(ModelFormat::Iges)), + Some("Metafile") => return Ok(Route::Wmf), + Some("3D Studio") => return Ok(Route::Tds), + _ => {} + } let pdf = if by_extension { ext == "pdf" } else { f.is_some_and(|f| f.name == "PDF Files") }; if pdf { return Ok(Route::Pdf); @@ -111,8 +131,8 @@ impl OpenCADStudio { Some(Task::none()) } ("IMPORT", true) => { - use crate::command::{CadCommand, ValuePromptCommand}; - let command = ValuePromptCommand::new("_IMPORTFILE", "Enter import file name:"); + use crate::command::{CadCommand, FreeTextValuePromptCommand}; + let command = FreeTextValuePromptCommand::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()) @@ -217,6 +237,24 @@ impl OpenCADStudio { } self.dispatch_command(&format!("-DGNIMPORT {path}")) } + Ok(Route::Model(format)) => { + self.remember_import(path); + let i = self.active_tab; + self.import_model(i, path, format) + } + Ok(Route::Wmf) => { + self.remember_import(path); + let i = self.active_tab; + self.import_wmf(i, path, false) + } + // The options window opens whatever FILEDIA is. + Ok(Route::Tds) => { + self.remember_import(path); + if let Err(error) = self.open_tds_import_dialog(path) { + self.command_line.push_error(&error); + } + Task::none() + } Err(error) => { self.command_line.push_error(&error); Task::none() @@ -232,6 +270,7 @@ impl OpenCADStudio { "dgn" => crate::t!( "Imports a DGN model as drawing objects: levels as layers, cells as blocks." ), + "3ds" => std::borrow::Cow::Owned(crate::tr!("import-3ds", "help")), _ => crate::t!( "Imports a file of the chosen format with the format's importer." ), @@ -324,8 +363,81 @@ impl OpenCADStudio { self.command_line.push_error(&error); } } + Ok(Route::Model(kind)) => { + self.remember_import(&path); + self.close_active_modal(); + return self.import_model(i, &path, kind); + } + Ok(Route::Wmf) => { + self.remember_import(&path); + self.close_active_modal(); + return self.import_wmf(i, &path, false); + } + Ok(Route::Tds) => { + self.remember_import(&path); + self.close_active_modal(); + if let Err(error) = self.open_tds_import_dialog(&path) { + self.command_line.push_error(&error); + } + } } } + // ── 3D Studio Import ───────────────────────────────────────── + ImportDialogMsg::TdsChange => { + let title = crate::t!("Import File").into_owned(); + let filters = vec![("3D Studio (*.3ds)".to_string(), vec!["3ds".to_string()])]; + return Task::perform(pick(title, filters), |path| { + Message::ImportDialog(ImportDialogMsg::TdsPicked(path)) + }); + } + ImportDialogMsg::TdsPicked(Some(path)) => { + if let Err(error) = self.open_tds_import_dialog(&path) { + self.command_line.push_error(&error); + } + } + ImportDialogMsg::TdsPicked(None) => {} + ImportDialogMsg::TdsCheck(k, on) => { + if let Some(c) = self.import_dialogs.tds.as_mut().and_then(|s| s.checked.get_mut(k)) { + *c = on; + } + } + ImportDialogMsg::TdsAll | ImportDialogMsg::TdsClear => { + let on = matches!(message, ImportDialogMsg::TdsAll); + if let Some(s) = self.import_dialogs.tds.as_mut() { + s.checked.iter_mut().for_each(|c| *c = on); + } + } + ImportDialogMsg::TdsFilter(k) => { + if let Some(s) = self.import_dialogs.tds.as_mut() { + s.filter = k.min(4); + } + } + ImportDialogMsg::TdsSearch(text) => { + if let Some(s) = self.import_dialogs.tds.as_mut() { + s.search = text; + } + } + ImportDialogMsg::TdsLayers(k) => { + if let Some(s) = self.import_dialogs.tds.as_mut() { + s.layers = k.min(3); + } + } + ImportDialogMsg::TdsMulti(k) => { + if let Some(s) = self.import_dialogs.tds.as_mut() { + s.multi = k.min(3); + } + } + ImportDialogMsg::TdsOk => { + let Some(state) = self.import_dialogs.tds.take() else { + return Task::none(); + }; + if !state.checked.iter().any(|c| *c) { + self.import_dialogs.tds = Some(state); + return Task::none(); + } + self.close_active_modal(); + return self.start_tds_import(i, state); + } // ── DGN Import ─────────────────────────────────────────────── ImportDialogMsg::DgnChange => return self.pick_dgn_file(), ImportDialogMsg::DgnPicked(Some(path)) => { diff --git a/src/app/commands/mod.rs b/src/app/commands/mod.rs index 8db2361f9..9755dc41a 100644 --- a/src/app/commands/mod.rs +++ b/src/app/commands/mod.rs @@ -20,6 +20,7 @@ mod fileops; mod inquiry; mod layerprops; mod layers; +mod model_import; mod pc_colormap; mod pc_extract; mod pdf_dialogs; @@ -415,6 +416,9 @@ impl OpenCADStudio { if let Some(t) = self.dispatch_import(cmd, i) { return Some(t); } + if let Some(t) = self.dispatch_model_import(cmd, i) { + return Some(t); + } if let Some(t) = self.dispatch_bvisibility(cmd, i) { return Some(t); } @@ -1016,6 +1020,8 @@ inventory::submit!(crate::command::CommandRegistration { "QNEW", "OBJIMPORT", "IMPORT", + "ACISIN", + "WMFIN", "DGNIMPORT", "-DGNIMPORT", "OPEN", diff --git a/src/app/commands/model_import.rs b/src/app/commands/model_import.rs new file mode 100644 index 000000000..66d2bc032 --- /dev/null +++ b/src/app/commands/model_import.rs @@ -0,0 +1,433 @@ +//! ACISIN and WMFIN, and the files IMPORT routes here: ACIS text (SAT), +//! STEP and IGES models and Windows metafiles, read into the current drawing +//! as one undo step. + +use super::*; +use std::collections::HashMap; + +use crate::io::model_import::{self, ModelFormat}; + +impl OpenCADStudio { + /// ACISIN and WMFIN; `None` defers to the other families. + pub(super) fn dispatch_model_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(), ""), + }; + match (verb.as_str(), rest.is_empty()) { + ("ACISIN", true) if crate::sys::file_dia() => { + let filters = vec![("ACIS (*.sat)".to_string(), vec!["sat".to_string()])]; + let title = crate::t!("Select ACIS File").into_owned(); + Some(Task::perform(super::dgn_import::pick(title, filters), |path| match path { + Some(path) => Message::Command(format!("_ACISINFILE {path}")), + None => Message::Noop, + })) + } + ("ACISIN", true) => { + use crate::command::FreeTextValuePromptCommand; + let command = FreeTextValuePromptCommand::new("_ACISINFILE", "Enter SAT file name:"); + self.command_line.push_info(&command.prompt()); + self.tabs[i].active_cmd = Some(Box::new(command)); + Some(Task::none()) + } + ("ACISIN" | "_ACISINFILE", false) => { + if crate::scene::model::pdf_raster::source_bytes(rest).is_none() { + let name = std::path::Path::new(rest) + .file_name() + .map_or_else(|| rest.to_string(), |n| n.to_string_lossy().into_owned()); + let here = std::env::current_dir() + .map(|d| d.display().to_string()) + .unwrap_or_default(); + self.command_line + .push_error(&format!("\"{name}\": file not found in the current directory, {here}")); + return Some(Task::none()); + } + Some(self.import_model(i, rest, ModelFormat::Sat)) + } + ("_ACISINFILE", true) => Some(Task::none()), + ("WMFIN", true) if crate::sys::file_dia() => { + let filters = vec![("Metafile (*.wmf)".to_string(), vec!["wmf".to_string()])]; + let title = crate::t!("Import WMF").into_owned(); + Some(Task::perform(super::dgn_import::pick(title, filters), |path| match path { + Some(path) => Message::Command(format!("_WMFINFILE {path}")), + None => Message::Noop, + })) + } + ("WMFIN", true) => { + use crate::command::FreeTextValuePromptCommand; + let command = FreeTextValuePromptCommand::new( + "_WMFINFILE", + "Enter name of WMF file to import (no extension):", + ); + self.command_line.push_info(&command.prompt()); + self.tabs[i].active_cmd = Some(Box::new(command)); + Some(Task::none()) + } + ("WMFIN" | "_WMFINFILE", false) => { + let path = if std::path::Path::new(rest).extension().is_some() { + rest.to_string() + } else { + format!("{rest}.wmf") + }; + Some(self.import_wmf(i, &path, true)) + } + ("_WMFINFILE", true) => Some(Task::none()), + _ => None, + } + } + + /// Reads a model file into drawing `i`. + pub(in crate::app) fn import_model(&mut self, i: usize, path: &str, format: ModelFormat) -> Task { + let Some(bytes) = crate::scene::model::pdf_raster::source_bytes(path) else { + self.command_line + .push_error(&format!("File {path} does not exist.")); + return Task::none(); + }; + let translated = format != ModelFormat::Sat; + let drawing_mm = crate::app::properties::insunits_to_mm( + self.tabs[i].scene.document.header.insertion_units, + ); + let Some(imported) = model_import::read(format, &bytes, drawing_mm) else { + self.command_line + .push_error(&format!("Import failed: {path} has no model the importer reads.")); + return Task::none(); + }; + self.push_undo_snapshot(i, "IMPORT"); + let scene = &mut self.tabs[i].scene; + // Each block goes under a name the drawing does not use yet (a + // product named like a block already there gets a new name, not the + // old definition), and the inserts are pointed at the names the + // blocks got. An insert of a block that could not be made is left + // out. + fn retarget(entities: Vec, defined: &HashMap) -> Vec { + entities + .into_iter() + .filter_map(|entity| match entity { + codec::EntityType::Insert(mut insert) => { + insert.block_name = defined.get(&insert.block_name)?.clone(); + Some(codec::EntityType::Insert(insert)) + } + other => Some(other), + }) + .collect() + } + let mut taken: Vec = scene.document.block_records.iter().map(|b| b.name.clone()).collect(); + let mut defined: HashMap = HashMap::new(); + for (name, entities) in imported.blocks { + let unique = crate::modules::insert::xattach::unique_block_name(&name, &taken); + let entities = retarget(entities, &defined); + match scene.define_block_from_owned_entities(entities, &unique, glam::DVec3::ZERO) { + Ok(_) => { + taken.push(unique.clone()); + defined.insert(name, unique); + } + Err(error) => self.command_line.push_error(&format!("{name}: {error}")), + } + } + for entity in retarget(imported.entities, &defined) { + scene.add_entity(entity); + } + self.tabs[i].dirty = true; + if translated { + self.command_line.push_output("Import Job Complete"); + } + self.refresh_properties(); + Task::none() + } + + /// A metafile as a new block `WMF`, fitted to half the current view: + /// placed at the view's top-left corner (IMPORT), or by the insertion + /// prompts (WMFIN). + pub(in crate::app) fn import_wmf(&mut self, i: usize, path: &str, prompt: bool) -> Task { + let Some(bytes) = crate::scene::model::pdf_raster::source_bytes(path) else { + self.command_line + .push_error(&format!("File {path} does not exist.")); + return Task::none(); + }; + let (centre, size, pixel) = self.tabs[i].scene.view_window(); + let Some(mut picture) = + crate::io::wmf_import::read(&bytes, [0.5 * size[0], 0.5 * size[1]]) + else { + self.command_line + .push_error(&format!("{path} is not a valid Windows metafile.")); + return Task::none(); + }; + self.push_undo_snapshot(i, if prompt { "WMFIN" } else { "IMPORT" }); + let scene = &mut self.tabs[i].scene; + // A text style per face, `WMF-` with `n` counting the + // face's bold and italic variants; the style's font is the + // installed file of that face, none when it is not installed. A + // style the drawing already has is used as it is. + let mut styles: Vec = Vec::new(); + for (k, face) in picture.fonts.iter().enumerate() { + let n = picture.fonts[..k].iter().filter(|f| f.name == face.name).count(); + // The face name comes from the file. + let name = crate::scene::sanitized_table_name(&format!("WMF-{}{n}", face.name), "WMF"); + if !scene.document.text_styles.contains(&name) { + let mut style = codec::tables::TextStyle::new(name.as_str()); + style.handle = scene.document.allocate_handle(); + style.font_file = + crate::scene::text::sysfont::face_file(&face.name, face.bold, face.italic).unwrap_or_default(); + let _ = scene.document.text_styles.add(style); + } + styles.push(name); + } + for (at, font) in &picture.text_fonts { + if let Some(codec::EntityType::Text(text)) = picture.entities.get_mut(*at) { + text.style = styles[*font].clone(); + } + } + let mut n = 0; + let name = loop { + let name = format!("WMF{n}"); + if scene.document.block_records.get(&name).is_none() { + break name; + } + n += 1; + }; + if let Err(error) = + scene.define_block_from_owned_entities(picture.entities, &name, glam::DVec3::ZERO) + { + self.command_line.push_error(&error); + return Task::none(); + } + self.tabs[i].dirty = true; + if prompt { + use crate::command::CadCommand; + use crate::modules::insert::insert_block::InsertBlockCommand; + let preview = self.tabs[i].scene.block_preview_wires(&name); + let command = InsertBlockCommand::classic_for_block(name, preview, String::new()); + self.command_line.push_info(&command.prompt()); + self.tabs[i].active_cmd = Some(Box::new(command)); + } else { + // The view's top-left corner, a pixel or two in. + let corner = codec::types::Vector3::new( + centre[0] - 0.5 * size[0] + pixel, + centre[1] + 0.5 * size[1] - 2.0 * pixel, + 0.0, + ); + let insert = codec::entities::Insert::new(&name, corner); + self.tabs[i].scene.add_entity(codec::EntityType::Insert(insert)); + self.refresh_properties(); + } + Task::none() + } +} + +impl OpenCADStudio { + /// The 3D Studio Import window for a file; an error when it is no 3D + /// Studio file. + pub(in crate::app) fn open_tds_import_dialog(&mut self, path: &str) -> Result<(), String> { + let scene = crate::scene::model::pdf_raster::source_bytes(path) + .and_then(|bytes| crate::io::tds_import::read(&bytes)) + .ok_or_else(|| format!("{path} is not a valid 3D Studio file."))?; + let mut objects: Vec<(String, &'static str, usize)> = scene + .objects + .iter() + .map(|(name, object)| { + let materials = match object { + crate::io::tds_import::TdsObject::Mesh(mesh) => scene.mesh_materials(mesh).len(), + _ => 0, + }; + (name.clone(), object.kind(), materials) + }) + .collect(); + objects.sort_by_key(|o| o.0.to_lowercase()); + objects.insert(0, ("Global Settings".to_string(), "Global", 0)); + // The layer and material choices made last stay; nothing starts + // checked. + let previous = self.import_dialogs.tds.take(); + self.import_dialogs.tds = Some(crate::ui::window::import_dialogs::TdsImportState { + path: path.to_string(), + checked: vec![false; objects.len()], + objects, + materials: scene.materials.len(), + filter: 0, + search: String::new(), + layers: previous.as_ref().map_or(0, |s| s.layers), + multi: previous.as_ref().map_or(0, |s| s.multi), + }); + self.active_modal = Some(crate::app::ModalKind::TdsImport); + Ok(()) + } + + /// The window's choices as an import: asked mesh by mesh first when set + /// to ask and some checked mesh has several materials. + pub(in crate::app) fn start_tds_import( + &mut self, + i: usize, + state: crate::ui::window::import_dialogs::TdsImportState, + ) -> Task { + use crate::io::tds_import::{LayerMode, MultiMaterial, TdsObject}; + use crate::modules::insert::tds_import::{TdsImportRequest, TdsMaterialCommand}; + let Some(scene) = crate::scene::model::pdf_raster::source_bytes(&state.path) + .and_then(|bytes| crate::io::tds_import::read(&bytes)) + else { + return Task::none(); + }; + let selected: Vec = state + .objects + .iter() + .zip(&state.checked) + .filter(|(_, on)| **on) + .map(|((name, _, _), _)| name.clone()) + .collect(); + let several: Vec<(String, Vec)> = scene + .objects + .iter() + .filter(|(name, _)| selected.contains(name)) + .filter_map(|(name, object)| match object { + TdsObject::Mesh(mesh) => { + let materials = scene.mesh_materials(mesh); + (materials.len() > 1).then(|| (name.clone(), materials)) + } + _ => None, + }) + .collect(); + let choice = match state.multi { + 1 => Some(MultiMaterial::Split), + 2 => Some(MultiMaterial::First), + 3 => Some(MultiMaterial::Material(None)), + _ => None, + }; + let mut request = TdsImportRequest { + path: state.path.clone(), + selected, + layers: match state.layers { + 1 => LayerMode::ByMaterial, + 2 => LayerMode::ByObjectColor, + 3 => LayerMode::Single, + _ => LayerMode::ByObject, + }, + multi: Default::default(), + compact: matches!(state.multi, 0 | 1), + }; + match choice { + Some(choice) => { + for (name, _) in several { + request.multi.insert(name, choice.clone()); + } + } + None if !several.is_empty() => { + use crate::command::CadCommand; + let command = TdsMaterialCommand::new(request, several); + self.command_line.push_info(&command.prompt()); + self.tabs[i].active_cmd = Some(Box::new(command)); + return Task::none(); + } + None => {} + } + self.run_tds_import(i, request); + Task::none() + } + + /// The import itself, one undo step: the meshes on their layers with + /// their materials, the cameras as named views. + pub(in crate::app) fn run_tds_import( + &mut self, + i: usize, + request: crate::modules::insert::tds_import::TdsImportRequest, + ) { + use codec::objects::ObjectType; + let Some(scene) = crate::scene::model::pdf_raster::source_bytes(&request.path) + .and_then(|bytes| crate::io::tds_import::read(&bytes)) + else { + self.command_line + .push_error(&format!("{} is not a valid 3D Studio file.", request.path)); + return; + }; + let import = crate::io::tds_import::convert( + &scene, + &crate::io::tds_import::TdsOptions { + selected: request.selected.clone(), + layers: request.layers, + multi: request.multi.clone(), + compact: request.compact, + single_layer: self.tabs[i].scene.document.header.current_layer_name.clone(), + }, + ); + // Named views take the current view's proportions. + let (_, size, _) = self.tabs[i].scene.view_window(); + let aspect = if size[0] > 0.0 && size[1] > 0.0 { size[0] / size[1] } else { 1.0 }; + self.push_undo_snapshot(i, "IMPORT"); + let document = &mut self.tabs[i].scene.document; + // Materials live in the material dictionary; a drawing without one + // imports the meshes without materials. + let dictionary = document.header.acad_material_dict_handle; + let has_dictionary = matches!(document.objects.get(&dictionary), Some(ObjectType::Dictionary(_))); + // Materials by name: the drawing's own when it has one, else one + // made from the file's colours. + let mut materials: std::collections::HashMap = Default::default(); + for name in &import.materials { + let existing = document.objects.iter().find_map(|(h, o)| match o { + ObjectType::Material(m) if m.name.eq_ignore_ascii_case(name) => Some(*h), + _ => None, + }); + let handle = match existing { + Some(h) => h, + None if !has_dictionary => continue, + None => { + let source = scene.materials.iter().find(|m| m.name == *name); + let mut material = codec::objects::Material::new(); + material.handle = document.allocate_handle(); + material.owner = dictionary; + material.name = name.clone(); + let color = |rgb: Option<[u8; 3]>| match rgb { + Some([r, g, b]) => codec::objects::MaterialColor { + flag: 1, + factor: 1.0, + rgb: Some(crate::scene::convert::acis_import::packed_true_colour(r, g, b)), + }, + None => codec::objects::MaterialColor::default(), + }; + material.ambient_color = color(source.and_then(|m| m.ambient)); + material.diffuse_color = color(source.and_then(|m| m.diffuse)); + material.specular_color = color(source.and_then(|m| m.specular)); + let handle = material.handle; + if let Some(ObjectType::Dictionary(dictionary)) = document.objects.get_mut(&dictionary) { + dictionary.add_entry(name.clone(), handle); + } + document.objects.insert(handle, ObjectType::Material(material)); + handle + } + }; + materials.insert(name.clone(), handle); + } + for part in &import.parts { + if !document.layers.contains(&part.layer) { + let mut layer = codec::tables::Layer::new(part.layer.as_str()); + layer.handle = document.allocate_handle(); + let _ = document.layers.add(layer); + } + } + for view in &import.views { + let mut named = codec::tables::View::new(view.name.as_str()); + named.target = codec::types::Vector3::new(view.target[0], view.target[1], view.target[2]); + named.direction = + codec::types::Vector3::new(view.direction[0], view.direction[1], view.direction[2]); + named.height = view.height; + named.width = view.height * aspect; + named.lens_length = view.lens; + named.perspective = true; + named.handle = match document.views.get(&view.name) { + Some(old) => old.handle, + None => document.allocate_handle(), + }; + document.views.add_or_replace(named); + } + for part in import.parts { + let mut mesh = part.mesh; + mesh.common.layer = part.layer.clone(); + mesh.common.material_handle = part.material.as_ref().and_then(|m| materials.get(m).copied()); + if mesh.common.material_handle.is_some() { + mesh.common.material_flags = 0b11; + } + self.tabs[i].scene.add_entity(codec::EntityType::PolyfaceMesh(mesh)); + } + for line in &import.messages { + self.command_line.push_output(line); + } + self.tabs[i].dirty = true; + self.refresh_properties(); + } +} diff --git a/src/app/control/actions.rs b/src/app/control/actions.rs index f2fe1f497..c7aab03bd 100644 --- a/src/app/control/actions.rs +++ b/src/app/control/actions.rs @@ -112,6 +112,7 @@ pub(super) const NAMES: &[&str] = &[ "pdf_import_pick", "pdf_import_dialog", "dgn_import_dialog", + "tds_import_dialog", "refedit_dialog", "pdf_shx_settings", "pdf_layer_toggle", @@ -2234,6 +2235,45 @@ impl OpenCADStudio { _ => return Err(bad()), }) } + // The 3D Studio Import window, one field per call: "check=" + // and "uncheck=" (the object's name, or "Global Settings"), + // "all", "clear", "filter=all|mesh|light|camera|general", + // "search=", "layers=object|material|color|single", + // "multi=ask|split|first|none", "path=" (as Change… picks + // it), "ok" or "cancel". + "tds_import_dialog" => { + use crate::ui::window::import_dialogs::ImportDialogMsg as I; + if self.active_modal != Some(crate::app::ModalKind::TdsImport) { + return Err(failure("no_dialog", "The 3D Studio 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); + if key == "cancel" { + return Ok(Task::done(Message::CloseModal)); + } + let pick = |options: &[&str]| options.iter().position(|o| *o == value).ok_or_else(bad); + Message::ImportDialog(match key { + "ok" => I::TdsOk, + "all" => I::TdsAll, + "clear" => I::TdsClear, + "check" | "uncheck" => { + let k = self + .import_dialogs + .tds + .as_ref() + .and_then(|s| s.objects.iter().position(|(name, _, _)| name.eq_ignore_ascii_case(value))) + .ok_or_else(bad)?; + I::TdsCheck(k, key == "check") + } + "filter" => I::TdsFilter(pick(&["all", "mesh", "light", "camera", "general"])?), + "search" => I::TdsSearch(value.to_string()), + "layers" => I::TdsLayers(pick(&["object", "material", "color", "single"])?), + "multi" => I::TdsMulti(pick(&["ask", "split", "first", "none"])?), + "path" => I::TdsPicked(Some(value.to_string())), + _ => 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", diff --git a/src/app/mod.rs b/src/app/mod.rs index 717e0ecb2..352b23c70 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -2340,6 +2340,8 @@ pub enum ModalKind { ImportFile, /// DGNIMPORT's DGN Import window. DgnImport, + /// IMPORT's 3D Studio Import window. + TdsImport, /// 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. diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index a844c25d6..a5b3b618a 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -255,6 +255,7 @@ impl OpenCADStudio { Some(PdfImportFile) => self.pdf_import_file = None, Some(ImportFile) => self.import_dialogs.file = None, Some(DgnImport) => self.import_dialogs.dgn = None, + Some(TdsImport) => self.import_dialogs.tds = None, Some(XrefAttach) => self.xref_attach = None, Some(WriteBlock) => self.wblock = None, Some(Hyperlink) => { diff --git a/src/app/view/modal.rs b/src/app/view/modal.rs index 9bcd7d479..6ecb0c10e 100644 --- a/src/app/view/modal.rs +++ b/src/app/view/modal.rs @@ -51,6 +51,7 @@ impl OpenCADStudio { 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::TdsImport) => crate::tr!("import-3ds", "title"), 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(), @@ -620,6 +621,12 @@ impl OpenCADStudio { crate::ui::window::import_dialogs::view_import_file(state, recent, flow) }) } + super::super::ModalKind::TdsImport => { + let state = self.import_dialogs.tds.as_ref()?; + sized_flow(ex, 860, 560, |flow| { + crate::ui::window::import_dialogs::view_tds_import(state, flow) + }) + } super::super::ModalKind::DgnImport => { let state = self.import_dialogs.dgn.as_ref()?; sized_flow(ex, 900, 680, |flow| { diff --git a/src/command.rs b/src/command.rs index 3407f2005..a1aa22bf0 100644 --- a/src/command.rs +++ b/src/command.rs @@ -2333,6 +2333,8 @@ pub enum CmdResult { ReturnPoint(DVec3), /// Import a DGN model (DGNIMPORT); ends the command. DgnImport(crate::modules::insert::dgn_import::DgnImportRequest), + /// Import a 3D Studio file with every question answered; ends the command. + TdsImport(crate::modules::insert::tds_import::TdsImportRequest), } /// What kind of value the active command is currently asking for. Drives diff --git a/src/io/brep_build.rs b/src/io/brep_build.rs new file mode 100644 index 000000000..c8dd1b254 --- /dev/null +++ b/src/io/brep_build.rs @@ -0,0 +1,205 @@ +//! Assembles a kernel B-rep from the topology an exchange file spells out: +//! vertices, edges along their curves, faces bounded by loops of those +//! edges, shells of faces. The STEP and IGES readers share it. +//! +//! Spline curves and surfaces that are exactly lines, circles, planes, +//! spheres, cylinders, cones or tori are kept as those shapes: many systems write +//! every one of them as a spline. + +use std::f64::consts::TAU; + +use kernel::brep::{ + Body, Coedge, Curve3, Edge, EdgeKey, Face, Loop, Lump, Provenance, Shell, Surface, Vertex, + VertexKey, +}; +use kernel::space::Vec3; + +/// A face as the file gives it: its surface, whether its outward normal +/// agrees with the surface's, and its loops — the outer one first — as +/// edges with the sense each is run in. +pub struct FaceSpec { + pub surface: Surface, + pub forward: bool, + pub loops: Vec>, +} + +pub struct Builder { + pub body: Body, +} + +/// Whether a curve repeats, and over how long a parameter run. +fn period(curve: &Curve3) -> Option { + match curve { + Curve3::Circle(_) | Curve3::Ellipse(_) => Some(TAU), + Curve3::Nurbs(nurbs) if nurbs.periodicity() => { + let (from, to) = nurbs.domain(); + Some(to - from) + } + _ => None, + } +} + +/// The parameter of a point on (or next to) a curve. +fn parameter(curve: &Curve3, point: [f64; 3]) -> Option { + Some(match curve { + Curve3::Line(line) => { + let direction = Vec3::from(line.direction); + (Vec3::from(point) - Vec3::from(line.origin)).dot(direction) + / direction.length_squared().max(f64::MIN_POSITIVE) + } + Curve3::Circle(circle) => { + let [x, y] = circle.plane.project(point)?; + y.atan2(x).rem_euclid(TAU) + } + Curve3::Ellipse(ellipse) => { + let [x, y] = ellipse.plane.project(point)?; + (y / ellipse.minor_radius) + .atan2(x / ellipse.major_radius) + .rem_euclid(TAU) + } + Curve3::Nurbs(nurbs) => nurbs.parameter_at(point), + Curve3::PlanarSpline { .. } => return None, + }) +} + +/// The same curve run the other way. +fn reversed(curve: Curve3) -> Option { + Some(match curve { + Curve3::Line(mut line) => { + line.direction = (-Vec3::from(line.direction)).to_array(); + Curve3::Line(line) + } + Curve3::Nurbs(nurbs) => Curve3::Nurbs(nurbs.reversed()?), + _ => return None, + }) +} + +impl Builder { + pub fn new() -> Self { + Self { body: Body::new() } + } + + pub fn vertex(&mut self, point: [f64; 3]) -> VertexKey { + self.body.vertices.insert(Vertex { + point, + provenance: Provenance::Synthesized, + }) + } + + fn point(&self, vertex: VertexKey) -> Option<[f64; 3]> { + self.body.vertices.get(vertex).map(|v| v.point) + } + + /// An edge along `curve` from `start` to `end`, which the curve runs + /// through in that order. A curve that turns out to run the other way + /// (or an open one handed its ends swapped) is reversed to match. + pub fn edge(&mut self, curve: Curve3, start: VertexKey, end: VertexKey) -> Option { + let (a, b) = (self.point(start)?, self.point(end)?); + let mut curve = match curve { + Curve3::Nurbs(nurbs) => kernel::brep::analytic_curve(&nurbs).unwrap_or(Curve3::Nurbs(nurbs)), + other => other, + }; + let mut t0 = parameter(&curve, a)?; + let mut t1 = parameter(&curve, b)?; + match period(&curve) { + Some(period) => { + if let Curve3::Nurbs(nurbs) = &curve { + let (from, _) = nurbs.domain(); + if start == end { + t0 = from; + } + } + if start == end { + t1 = t0 + period; + } else { + while t1 <= t0 + 1e-12 { + t1 += period; + } + } + } + None => { + if start == end { + // A clamped curve closing on itself: its whole domain. + if let Curve3::Nurbs(nurbs) = &curve { + (t0, t1) = nurbs.domain(); + } + } else if t1 < t0 { + curve = reversed(curve)?; + t0 = parameter(&curve, a)?; + t1 = parameter(&curve, b)?; + } + } + } + if !(t1 > t0) { + return None; + } + let curve = self.body.curves.insert(curve); + Some(self.body.edges.insert(Edge { + curve, + start_parameter: t0, + end_parameter: t1, + start, + end, + coedges: Vec::new(), + provenance: Provenance::Synthesized, + })) + } + + /// A lump of shells — the outer one first, then voids — each a list of + /// faces. `None` when a face has no surface or an edge has gone. + pub fn lump(&mut self, shells: Vec>) -> Option<()> { + let lump = self.body.lumps.insert(Lump { + shells: Vec::new(), + provenance: Provenance::Synthesized, + }); + for faces in shells { + let shell = self.body.shells.insert(Shell { + faces: Vec::new(), + owner: lump, + provenance: Provenance::Synthesized, + }); + for spec in faces { + let (surface, forward) = match spec.surface { + Surface::Nurbs(nurbs) => match kernel::brep::analytic_surface(&nurbs) { + Some((analytic, agrees)) => (analytic, spec.forward == agrees), + None => (Surface::Nurbs(nurbs), spec.forward), + }, + other => (other, spec.forward), + }; + let surface = self.body.surfaces.insert(surface); + let face = self.body.faces.insert(Face { + surface, + forward, + loops: Vec::new(), + owner: shell, + provenance: Provenance::Synthesized, + }); + for ring in spec.loops { + let key = self.body.loops.insert(Loop { + coedges: Vec::new(), + owner: face, + provenance: Provenance::Synthesized, + }); + let mut coedges = Vec::with_capacity(ring.len()); + for (edge, forward) in ring { + let coedge = self.body.coedges.insert(Coedge { + edge, + forward, + pcurve: None, + owner: key, + provenance: Provenance::Synthesized, + }); + self.body.edges.get_mut(edge)?.coedges.push(coedge); + coedges.push(coedge); + } + self.body.loops.get_mut(key)?.coedges = coedges; + self.body.faces.get_mut(face)?.loops.push(key); + } + self.body.shells.get_mut(shell)?.faces.push(face); + } + self.body.lumps.get_mut(lump)?.shells.push(shell); + } + self.body.roots.push(lump); + Some(()) + } +} diff --git a/src/io/iges_import.rs b/src/io/iges_import.rs new file mode 100644 index 000000000..ed7b33203 --- /dev/null +++ b/src/io/iges_import.rs @@ -0,0 +1,688 @@ +//! IGES reading: the manifold solids (type 186) of a file, their shells, +//! faces, loops and edges (514/510/508/504/502), and its trimmed and bounded +//! surfaces (144/142 and 143/141), over spline surfaces and curves +//! (128/126), lines and arcs (110/100) and composite curves (102), as +//! kernel bodies. + +use std::collections::HashMap; + +use kernel::brep::{Circle3, Curve3, EdgeKey, Line3, Surface, VertexKey}; +use kernel::space::{NurbsCurve3, NurbsSurface3, Plane}; + +use super::brep_build::{Builder, FaceSpec}; +use super::step_import::compose; + +/// One parameter: a number, or a Hollerith string. +#[derive(Debug, Clone)] +enum P { + Num(f64), + Str(String), +} + +impl P { + fn num(&self) -> f64 { + match self { + P::Num(v) => *v, + P::Str(_) => 0.0, + } + } +} + +/// Splits a section's text into parameters at `delimiter`, up to the first +/// `terminator`, reading `nH…` strings whole. +fn parameters(text: &str, delimiter: char, terminator: char) -> Vec

{ + let chars: Vec = text.chars().collect(); + let mut out = Vec::new(); + let mut i = 0; + loop { + while chars.get(i).is_some_and(|c| *c == ' ') { + i += 1; + } + let start = i; + while chars.get(i).is_some_and(|c| c.is_ascii_digit()) { + i += 1; + } + if i > start && matches!(chars.get(i), Some('H' | 'h')) { + let count: usize = chars[start..i].iter().collect::().parse().unwrap_or(0); + let body: String = chars.iter().skip(i + 1).take(count).collect(); + i = i.saturating_add(1).saturating_add(count); + out.push(P::Str(body)); + } else { + i = start; + while chars + .get(i) + .is_some_and(|c| *c != delimiter && *c != terminator) + { + i += 1; + } + let raw: String = chars[start..i].iter().collect::().replace(['D', 'd'], "E"); + out.push(P::Num(raw.trim().parse().unwrap_or(0.0))); + } + while chars.get(i).is_some_and(|c| *c == ' ') { + i += 1; + } + match chars.get(i) { + Some(c) if *c == delimiter => i += 1, + _ => return out, + } + } +} + +/// A directory entry and its parameters. +struct Entity { + kind: i32, + /// Pointer to a transformation matrix entity, or zero. + transform: usize, + /// Colour number, or a negated pointer to a colour definition. + color: i64, + params: Vec

, +} + +struct Doc { + /// Keyed by the entry's first directory line number. + entities: HashMap, + /// File units to drawing units. + scale: f64, + /// Points closer than this are one point, in drawing units. + resolution: f64, +} + +/// A 3×4 matrix applied as `R·p + T`. +type Matrix = super::step_import::Placement; + +/// A transformation matrix entity's (124) matrix. +fn matrix_of(entity: &Entity) -> Matrix { + let v = |k: usize| entity.params.get(k).map_or(0.0, P::num); + [[v(1), v(2), v(3), v(4)], [v(5), v(6), v(7), v(8)], [v(9), v(10), v(11), v(12)]] +} + +impl Doc { + fn get(&self, pointer: f64) -> Option<&Entity> { + self.entities.get(&(pointer.abs() as usize)) + } + + /// An entry's colour: a numbered one, or a definition's RGB percentages + /// — as the nearest standard index colour when it is one exactly. + fn color(&self, number: i64) -> codec::types::Color { + use codec::types::Color; + let rgb = match number { + // Black draws as colour 7, so it shows on a dark background too. + 1 => return Color::Index(7), + 2 => [255, 0, 0], + 3 => [0, 255, 0], + 4 => [0, 0, 255], + 5 => [255, 255, 0], + 6 => [255, 0, 255], + 7 => [0, 255, 255], + 8 => [255, 255, 255], + n if n < 0 => match self.entities.get(&(-n as usize)).filter(|e| e.kind == 314) { + Some(e) => [1, 2, 3].map(|k| { + (e.params.get(k).map_or(0.0, P::num) * 2.55).round().clamp(0.0, 255.0) as u8 + }), + None => return Color::ByLayer, + }, + _ => return Color::ByLayer, + }; + match rgb { + [255, 0, 0] => Color::Index(1), + [255, 255, 0] => Color::Index(2), + [0, 255, 0] => Color::Index(3), + [0, 255, 255] => Color::Index(4), + [0, 0, 255] => Color::Index(5), + [255, 0, 255] => Color::Index(6), + [255, 255, 255] => Color::Index(7), + [r, g, b] => Color::Rgb { r, g, b }, + } + } + + /// A transformation matrix, with the matrices it chains through its own + /// transformation pointer applied after it. The chain is cut after a + /// few links, so matrices that point at each other cannot loop. + fn matrix(&self, pointer: usize) -> Option { + let mut entity = self.entities.get(&pointer).filter(|e| e.kind == 124)?; + let mut m = matrix_of(entity); + for _ in 0..16 { + let Some(outer) = (entity.transform != 0) + .then(|| self.entities.get(&entity.transform)) + .flatten() + .filter(|e| e.kind == 124) + else { + break; + }; + m = compose(&matrix_of(outer), &m); + entity = outer; + } + Some(m) + } + + /// `entity`'s placement applied to a point it defines. + fn place(&self, entity: &Entity) -> impl Fn([f64; 3]) -> [f64; 3] { + let m = (entity.transform != 0).then(|| self.matrix(entity.transform)).flatten(); + let scale = self.scale; + move |p| { + let p = match m { + Some(m) => [0, 1, 2].map(|r| m[r][0] * p[0] + m[r][1] * p[1] + m[r][2] * p[2] + m[r][3]), + None => p, + }; + p.map(|c| c * scale) + } + } + + fn curve(&self, pointer: f64) -> Option { + let entity = self.get(pointer)?; + let p = &entity.params; + let v = |k: usize| p.get(k).map_or(0.0, P::num); + let place = self.place(entity); + Some(match entity.kind { + 110 => { + let a = place([v(1), v(2), v(3)]); + let b = place([v(4), v(5), v(6)]); + Curve3::Line(Line3 { + origin: a, + direction: [b[0] - a[0], b[1] - a[1], b[2] - a[2]], + }) + } + 100 => { + let centre = place([v(2), v(3), v(1)]); + let x = place([v(2) + 1.0, v(3), v(1)]); + let y = place([v(2), v(3) + 1.0, v(1)]); + let start = place([v(4), v(5), v(1)]); + let radius = (0..3).map(|k| (start[k] - centre[k]).powi(2)).sum::().sqrt(); + Curve3::Circle(Circle3 { + plane: Plane::orthonormal( + centre, + [x[0] - centre[0], x[1] - centre[1], x[2] - centre[2]], + { + let (a, b) = ( + [x[0] - centre[0], x[1] - centre[1], x[2] - centre[2]], + [y[0] - centre[0], y[1] - centre[1], y[2] - centre[2]], + ); + [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]] + }, + )?, + radius, + }) + } + 126 => { + // The sizes come from the file: refuse ones its parameters + // cannot hold. + let k = v(1) as usize; + let degree = v(2) as usize; + let w0 = k.checked_add(degree)?.checked_add(9)?; + let c0 = w0.checked_add(k)?.checked_add(1)?; + if c0.checked_add(k.checked_add(1)?.checked_mul(3)?)? > p.len() { + return None; + } + let knots = (7..w0).map(v).collect(); + let weights = (0..=k).map(|n| v(w0 + n)).collect(); + let points = (0..=k) + .map(|n| place([v(c0 + 3 * n), v(c0 + 3 * n + 1), v(c0 + 3 * n + 2)])) + .collect(); + Curve3::Nurbs(NurbsCurve3::new(degree, points, knots, Some(weights))?) + } + _ => return None, + }) + } + + /// A boundary curve as its pieces — each a curve and the points it runs + /// from and to — composite curves taken apart. + /// Composite curves nest only a few deep, so a deeper (or circular) one + /// is refused. + fn pieces(&self, pointer: f64) -> Option> { + self.pieces_at(pointer, 0) + } + + fn pieces_at(&self, pointer: f64, depth: usize) -> Option> { + let entity = self.get(pointer)?; + if entity.kind == 102 { + if depth >= 8 { + return None; + } + let count = entity.params.get(1).map_or(0.0, P::num) as usize; + let mut out = Vec::new(); + for k in 0..count { + out.extend(self.pieces_at(entity.params.get(2 + k)?.num(), depth + 1)?); + } + return Some(out); + } + let curve = self.curve(pointer)?; + let v = |k: usize| entity.params.get(k).map_or(0.0, P::num); + let place = self.place(entity); + let (from, to) = match (&curve, entity.kind) { + (_, 110) => (place([v(1), v(2), v(3)]), place([v(4), v(5), v(6)])), + (_, 100) => (place([v(4), v(5), v(1)]), place([v(6), v(7), v(1)])), + (Curve3::Nurbs(nurbs), _) => { + let (t0, t1) = nurbs.domain(); + (nurbs.point_at_knot(t0), nurbs.point_at_knot(t1)) + } + _ => return None, + }; + Some(vec![(curve, from, to)]) + } + + /// The model-space pieces of a curve on a surface (142) or a boundary + /// (141), each of the latter's curves in the sense it is run. + fn boundary(&self, pointer: f64) -> Option> { + let entity = self.get(pointer)?; + let v = |k: usize| entity.params.get(k).map_or(0.0, P::num); + match entity.kind { + 142 => self.pieces(v(4)), + 141 => { + let mut out = Vec::new(); + let mut at: usize = 5; + for _ in 0..(v(4) as usize).min(entity.params.len()) { + let mut pieces = self.pieces(v(at))?; + if v(at + 1) == 2.0 { + pieces.reverse(); + for piece in &mut pieces { + std::mem::swap(&mut piece.1, &mut piece.2); + } + } + out.extend(pieces); + at = at.saturating_add(3).saturating_add(v(at + 2) as usize); + } + Some(out) + } + _ => self.pieces(pointer), + } + } + + fn surface(&self, pointer: f64) -> Option { + let entity = self.get(pointer)?; + if entity.kind != 128 { + return None; + } + let v = |k: usize| entity.params.get(k).map_or(0.0, P::num); + let place = self.place(entity); + let (k1, k2, m1, m2) = (v(1) as usize, v(2) as usize, v(3) as usize, v(4) as usize); + // The sizes come from the file: refuse ones its parameters cannot + // hold. + let s0 = 10; + let t0 = k1.checked_add(m1)?.checked_add(s0 + 2)?; + let w0 = t0.checked_add(k2)?.checked_add(m2)?.checked_add(2)?; + let count = k1.checked_add(1)?.checked_mul(k2.checked_add(1)?)?; + let c0 = w0.checked_add(count)?; + if c0.checked_add(count.checked_mul(3)?)? > entity.params.len() { + return None; + } + let u_knots = (s0..t0).map(v).collect(); + let v_knots = (t0..w0).map(v).collect(); + // Points run with the first index fastest. + let at = |i: usize, j: usize| j * (k1 + 1) + i; + let weights = (0..=k1) + .map(|i| (0..=k2).map(|j| v(w0 + at(i, j))).collect()) + .collect(); + let points = (0..=k1) + .map(|i| { + (0..=k2) + .map(|j| { + let n = c0 + 3 * at(i, j); + place([v(n), v(n + 1), v(n + 2)]) + }) + .collect() + }) + .collect(); + NurbsSurface3::new(m1, m2, points, u_knots, v_knots, Some(weights)).map(Surface::Nurbs) + } +} + +struct Assembly<'a> { + doc: &'a Doc, + build: Builder, + vertices: HashMap<(usize, usize), VertexKey>, + edges: HashMap<(usize, usize), EdgeKey>, + /// Vertices made where boundary curves meet, by position. + corners: Vec<([f64; 3], VertexKey)>, +} + +fn distance(a: [f64; 3], b: [f64; 3]) -> f64 { + (0..3).map(|k| (a[k] - b[k]).powi(2)).sum::().sqrt() +} + +impl Assembly<'_> { + fn vertex(&mut self, list: f64, index: f64) -> Option { + let key = (list.abs() as usize, index as usize); + if let Some(&v) = self.vertices.get(&key) { + return Some(v); + } + let entity = self.doc.get(list).filter(|e| e.kind == 502)?; + let n = key.1.checked_sub(1)?.checked_mul(3)?.checked_add(2)?.min(entity.params.len()); + let p = |k: usize| entity.params.get(k).map(P::num); + let point = self.doc.place(entity)([p(n)?, p(n + 1)?, p(n + 2)?]); + let v = self.build.vertex(point); + self.vertices.insert(key, v); + Some(v) + } + + fn edge(&mut self, list: f64, index: f64) -> Option { + let key = (list.abs() as usize, index as usize); + if let Some(&e) = self.edges.get(&key) { + return Some(e); + } + let entity = self.doc.get(list).filter(|e| e.kind == 504)?; + let n = key.1.checked_sub(1)?.checked_mul(5)?.checked_add(2)?.min(entity.params.len()); + let p = |k: usize| entity.params.get(k).map(P::num); + let curve = self.doc.curve(p(n)?)?; + let start = self.vertex(p(n + 1)?, p(n + 2)?)?; + let end = self.vertex(p(n + 3)?, p(n + 4)?)?; + let e = self.build.edge(curve, start, end)?; + self.edges.insert(key, e); + Some(e) + } + + /// The vertex at a point, shared with one already there. + fn corner(&mut self, point: [f64; 3]) -> VertexKey { + let tolerance = self.doc.resolution; + if let Some((_, v)) = self.corners.iter().find(|(p, _)| distance(*p, point) <= tolerance) { + return *v; + } + let v = self.build.vertex(point); + self.corners.push((point, v)); + v + } + + /// A loop along curve pieces, in their order. + fn curve_ring(&mut self, pieces: Vec<(Curve3, [f64; 3], [f64; 3])>) -> Option> { + let mut ring = Vec::new(); + for (curve, from, to) in pieces { + if distance(from, to) <= self.doc.resolution && matches!(curve, Curve3::Line(_)) { + continue; + } + let (a, b) = (self.corner(from), self.corner(to)); + ring.push((self.build.edge(curve, a, b)?, true)); + } + (!ring.is_empty()).then_some(ring) + } + + /// A loop round the edge of a spline surface's parameter domain; sides + /// that close up to a point (a pole) are left out. + fn domain_ring(&mut self, surface: &NurbsSurface3) -> Option> { + let ((u0, u1), (v0, v1)) = surface.domain(); + let corners = [(u0, v0), (u1, v0), (u1, v1), (u0, v1)]; + let sides = [(1, v0), (0, u1), (1, v1), (0, u0)]; + let mut pieces = Vec::new(); + for (k, (axis, at)) in sides.into_iter().enumerate() { + let (a, b) = (corners[k], corners[(k + 1) % 4]); + let (from, to) = (surface.point_at_knot(a.0, a.1), surface.point_at_knot(b.0, b.1)); + let middle = surface.point_at_knot(0.5 * (a.0 + b.0), 0.5 * (a.1 + b.1)); + if distance(from, middle) <= self.doc.resolution && distance(to, middle) <= self.doc.resolution { + continue; + } + pieces.push((Curve3::Nurbs(surface.isocurve(axis, at)?), from, to)); + } + self.curve_ring(pieces) + } + + /// Turns a loop to run counter-clockwise round the surface's normal (an + /// outer one) or clockwise (a hole), judged by the area it encloses in + /// the surface's parameters. + fn orient(&self, surface: &Surface, ring: &mut [(EdgeKey, bool)], outer: bool) { + let mut uv = Vec::new(); + for (edge, forward) in ring.iter() { + let Some(edge) = self.build.body.edges.get(*edge) else { return }; + let Some(curve) = self.build.body.curves.get(edge.curve) else { return }; + let (t0, t1) = (edge.start_parameter, edge.end_parameter); + for k in 0..8 { + let s = k as f64 / 8.0; + let s = if *forward { s } else { 1.0 - s }; + if let Some(p) = surface.parameters_at(curve.point_at(t0 + (t1 - t0) * s)) { + uv.push(p); + } + } + } + let area: f64 = (0..uv.len()) + .map(|k| { + let (a, b) = (uv[k], uv[(k + 1) % uv.len()]); + a.0 * b.1 - b.0 * a.1 + }) + .sum(); + if (area > 0.0) != outer { + ring.reverse(); + for (_, forward) in ring.iter_mut() { + *forward = !*forward; + } + } + } + + /// A trimmed (144) or bounded (143) surface as a one-face sheet. + fn sheet(&mut self, entity: &Entity) -> Option<()> { + let v = |k: usize| entity.params.get(k).map_or(0.0, P::num); + // A count can be no more than the parameters there are. + let count = (v(3) as usize).min(entity.params.len()); + let (surface_at, outer, holes): (f64, Option, Vec) = match entity.kind { + 144 => (v(1), (v(2) != 0.0).then(|| v(4)), (0..count).map(|k| v(5 + k)).collect()), + 143 => (v(2), (count > 0).then(|| v(4)), (1..count).map(|k| v(4 + k)).collect()), + _ => return None, + }; + let Surface::Nurbs(nurbs) = self.doc.surface(surface_at)? else { return None }; + let surface = Surface::Nurbs(nurbs.clone()); + let mut first = match outer { + Some(boundary) => { + let pieces = self.doc.boundary(boundary)?; + self.curve_ring(pieces)? + } + None => self.domain_ring(&nurbs)?, + }; + self.orient(&surface, &mut first, true); + let mut loops = vec![first]; + for hole in holes { + let pieces = self.doc.boundary(hole)?; + let mut ring = self.curve_ring(pieces)?; + self.orient(&surface, &mut ring, false); + loops.push(ring); + } + self.build.lump(vec![vec![FaceSpec { + surface, + forward: true, + loops, + }]]) + } + + fn ring(&mut self, pointer: f64) -> Option> { + let entity = self.doc.get(pointer).filter(|e| e.kind == 508)?; + let p = |k: usize| entity.params.get(k).map(P::num); + let mut ring = Vec::new(); + let mut at = 2; + for _ in 0..p(1)? as usize { + let (kind, list, index, agrees, pcurves) = (p(at)?, p(at + 1)?, p(at + 2)?, p(at + 3)?, p(at + 4)?); + if kind == 0.0 { + ring.push((self.edge(list, index)?, agrees != 0.0)); + } + at = at.saturating_add(5).saturating_add((pcurves as usize).saturating_mul(2)); + } + Some(ring) + } + + fn face(&mut self, pointer: f64, agrees: bool) -> Option { + let entity = self.doc.get(pointer).filter(|e| e.kind == 510)?; + let p = |k: usize| entity.params.get(k).map(P::num); + let surface = self.doc.surface(p(1)?)?; + let loops = (0..p(2)? as usize) + .map(|n| self.ring(p(4 + n)?)) + .collect::>>()?; + Some(FaceSpec { + surface, + forward: agrees, + loops, + }) + } + + fn shell(&mut self, pointer: f64, agrees: bool) -> Option> { + let entity = self.doc.get(pointer).filter(|e| e.kind == 514)?; + let p = |k: usize| entity.params.get(k).map(P::num); + (0..p(1)? as usize) + .map(|n| { + let face_agrees = p(3 + 2 * n)? != 0.0; + let mut face = self.face(p(2 + 2 * n)?, face_agrees == agrees)?; + if !agrees { + for ring in &mut face.loops { + ring.reverse(); + for (_, f) in ring.iter_mut() { + *f = !*f; + } + } + } + Some(face) + }) + .collect() + } +} + +/// What an IGES file holds. +pub struct IgesModel { + /// Each solid, scaled to the drawing, with its colour. + pub bodies: Vec<(kernel::brep::Body, codec::types::Color)>, + /// Each trimmed or bounded surface, a one-face sheet, with its colour. + pub sheets: Vec<(kernel::brep::Body, codec::types::Color)>, +} + +/// `drawing_mm`: millimetres per drawing unit, `None` for a unitless drawing. +pub fn read(bytes: &[u8], drawing_mm: Option) -> Option { + let text = String::from_utf8_lossy(bytes); + let mut sections: HashMap> = HashMap::new(); + for line in text.lines() { + let line = line.trim_end_matches('\r'); + if line.len() < 73 { + continue; + } + let Some(section) = line.get(72..73).and_then(|s| s.chars().next()) else { continue }; + sections.entry(section).or_default().push(line); + } + let directory = sections.get(&'D')?; + let global: String = sections + .get(&'G') + .map(|lines| lines.iter().filter_map(|l| l.get(..72)).collect()) + .unwrap_or_default(); + // The delimiters are themselves the first two parameters, when given. + let mut delimiter = ','; + let mut terminator = ';'; + if global.starts_with("1H") { + delimiter = global.chars().nth(2).unwrap_or(','); + let rest = global.get(4..).unwrap_or_default(); + if rest.starts_with("1H") { + terminator = rest.chars().nth(2).unwrap_or(';'); + } + } + let globals = parameters(&global, delimiter, terminator); + let unit_mm = match globals.get(13).map(P::num).unwrap_or(1.0) as i32 { + 1 => 25.4, + 4 => 304.8, + 5 => 1_609_344.0, + 6 => 1000.0, + 7 => 1_000_000.0, + 8 => 0.0254, + 9 => 0.001, + 10 => 10.0, + 11 => 0.000_025_4, + 3 => match globals.get(14) { + Some(P::Str(name)) if name.eq_ignore_ascii_case("IN") || name.eq_ignore_ascii_case("INCH") => 25.4, + Some(P::Str(name)) if name.eq_ignore_ascii_case("FT") => 304.8, + Some(P::Str(name)) if name.eq_ignore_ascii_case("M") => 1000.0, + Some(P::Str(name)) if name.eq_ignore_ascii_case("CM") => 10.0, + _ => 1.0, + }, + _ => 1.0, + } * globals.get(12).map(P::num).filter(|s| *s > 0.0).map_or(1.0, |s| 1.0 / s); + + let parameter_lines: HashMap = sections + .get(&'P') + .map(|lines| { + lines + .iter() + .filter_map(|l| Some((l.get(73..)?.trim().parse().ok()?, *l))) + .collect() + }) + .unwrap_or_default(); + let field = |line: &str, n: usize| -> i64 { + line.get(n * 8..n * 8 + 8) + .and_then(|f| f.trim().parse().ok()) + .unwrap_or(0) + }; + let mut entities = HashMap::new(); + for pair in directory.chunks(2) { + let [first, second] = pair else { continue }; + let Some(number) = first.get(73..).and_then(|n| n.trim().parse::().ok()) else { + continue; + }; + // An entry spans at most every parameter line there is. + let start = field(first, 1).max(0) as usize; + let count = (field(second, 3).max(1) as usize).min(parameter_lines.len()); + let data: String = (start..start.saturating_add(count)) + .filter_map(|n| parameter_lines.get(&n)) + .filter_map(|l| l.get(..64)) + .collect(); + entities.insert( + number, + Entity { + kind: field(first, 0) as i32, + transform: field(first, 6).max(0) as usize, + color: field(second, 2), + params: parameters(&data, delimiter, terminator), + }, + ); + } + let scale = drawing_mm.filter(|mm| *mm > 0.0).map_or(1.0, |mm| unit_mm / mm); + let resolution = globals.get(18).map(P::num).filter(|r| *r > 0.0).unwrap_or(1e-6) * scale; + let doc = Doc { + entities, + scale, + resolution, + }; + + let mut solids: Vec = doc + .entities + .iter() + .filter(|(_, e)| e.kind == 186) + .map(|(n, _)| *n) + .collect(); + solids.sort_unstable(); + let mut bodies = Vec::new(); + for number in solids { + let entity = &doc.entities[&number]; + let v = |k: usize| entity.params.get(k).map(P::num); + let Some((shell, agrees)) = v(1).zip(v(2)) else { continue }; + let mut assembly = Assembly { + doc: &doc, + build: Builder::new(), + vertices: HashMap::new(), + edges: HashMap::new(), + corners: Vec::new(), + }; + let mut shells = Vec::new(); + let outer = assembly.shell(shell, agrees != 0.0); + let voids = (0..v(3).unwrap_or(0.0) as usize) + .map(|n| assembly.shell(v(4 + 2 * n)?, v(5 + 2 * n)? != 0.0)) + .collect::>>(); + if let (Some(outer), Some(voids)) = (outer, voids) { + shells.push(outer); + shells.extend(voids); + if assembly.build.lump(shells).is_some() { + bodies.push((assembly.build.body, doc.color(entity.color))); + } + } + } + // The solids are listed last first. + bodies.reverse(); + let mut numbers: Vec = doc + .entities + .iter() + .filter(|(_, e)| matches!(e.kind, 143 | 144)) + .map(|(n, _)| *n) + .collect(); + numbers.sort_unstable(); + let mut sheets = Vec::new(); + for number in numbers { + let entity = &doc.entities[&number]; + let mut assembly = Assembly { + doc: &doc, + build: Builder::new(), + vertices: HashMap::new(), + edges: HashMap::new(), + corners: Vec::new(), + }; + if assembly.sheet(entity).is_some() { + sheets.push((assembly.build.body, doc.color(entity.color))); + } + } + Some(IgesModel { bodies, sheets }) +} diff --git a/src/io/mod.rs b/src/io/mod.rs index fa8a619f7..660ece8fb 100644 --- a/src/io/mod.rs +++ b/src/io/mod.rs @@ -8,12 +8,15 @@ pub mod edit_lock; // Same split as web_session/web_autosave: the save-routing decision (and // its tests) is pure and compiles everywhere; the showSaveFilePicker half // inside is wasm-only. +pub mod brep_build; pub mod fbx; pub mod file_association; pub mod font_repo; pub(crate) mod fs_access; +pub mod iges_import; pub mod line_read; pub mod linetypes; +pub mod model_import; pub mod obj; pub mod ole_embed; pub mod paper_catalog; @@ -27,11 +30,14 @@ pub mod recovery; #[cfg(not(target_arch = "wasm32"))] pub mod single_instance; pub mod step; +pub mod step_import; pub mod stl; pub mod svg_export; +pub mod tds_import; pub mod thumbnail; pub mod update_check; pub mod visual_styles; +pub mod wmf_import; #[cfg(target_arch = "wasm32")] pub(crate) mod web_recent; // Registered on every target so the pure helpers (and their tests) compile on diff --git a/src/io/model_import.rs b/src/io/model_import.rs new file mode 100644 index 000000000..4b72358e6 --- /dev/null +++ b/src/io/model_import.rs @@ -0,0 +1,222 @@ +//! Model files IMPORT reads: ACIS text (SAT), STEP and IGES, as the drawing +//! objects they become. + +use codec::entities::{Entity as _, Insert}; +use codec::types::{Color, Matrix4, Transform, Vector3}; +use codec::EntityType; + +use crate::scene::convert::acis_import::{painted_body_entity, sat_entities}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ModelFormat { + Sat, + Step, + Iges, +} + +/// The objects a file brings in. +pub struct Imported { + /// Block definitions, each before any block that inserts it. + pub blocks: Vec<(String, Vec)>, + /// The objects for the current space. + pub entities: Vec, +} + +/// File units over drawing units; one where the drawing is unitless. +fn ratio(file_mm: f64, drawing_mm: Option) -> f64 { + drawing_mm.filter(|mm| *mm > 0.0).map_or(1.0, |mm| file_mm / mm) +} + +/// A block name from a product's, with the characters names cannot hold +/// replaced. +fn block_name(name: &str) -> String { + crate::scene::sanitized_table_name(name, "Part") +} + +/// A STEP presentation colour as the drawing colour it imports as: the +/// standard index colour it is close to — red, yellow, green, cyan, blue, +/// magenta, white, or the dark grey 250 (which black also becomes) — and +/// otherwise the true colour. +// ponytail: the closeness bands are fitted to sample files (saturation +// at least 0.75, value at least 0.825 and hue within 7.5 degrees for the six +// hues; greys from 0.16 to 0.39 for 250 and from 0.935 for white); refit +// them if a colour on a band edge comes out differently. +pub fn step_colour(rgb: crate::io::step_import::Rgb) -> Color { + let [r, g, b] = rgb.map(|c| c.clamp(0.0, 1.0)); + let true_colour = Color::Rgb { + r: (r * 255.0).round() as u8, + g: (g * 255.0).round() as u8, + b: (b * 255.0).round() as u8, + }; + let max = r.max(g).max(b); + let min = r.min(g).min(b); + if max == 0.0 { + return Color::Index(250); + } + let saturation = (max - min) / max; + if saturation <= 0.1 { + return if max >= 0.935 { + Color::Index(7) + } else if (0.16..0.395).contains(&max) { + Color::Index(250) + } else { + true_colour + }; + } + if saturation < 0.75 || max < 0.825 { + return true_colour; + } + let delta = max - min; + let hue = if max == r { + 60.0 * ((g - b) / delta).rem_euclid(6.0) + } else if max == g { + 60.0 * ((b - r) / delta + 2.0) + } else { + 60.0 * ((r - g) / delta + 4.0) + }; + let sector = (hue / 60.0).round(); + if (hue - 60.0 * sector).abs() > 7.5 { + return true_colour; + } + // Red, yellow, green, cyan, blue, magenta. + Color::Index([1, 2, 3, 4, 5, 6][sector as usize % 6]) +} + +/// An insert of `block` placed by `placement` (columns: axes, origin). +fn placed_insert(block: &str, placement: &[[f64; 4]; 3]) -> EntityType { + let mut insert = Insert::new(block, Vector3::ZERO); + let mut m = Matrix4::identity(); + for r in 0..3 { + m.m[r] = placement[r]; + } + insert.apply_transform(&Transform::from_matrix(m)); + EntityType::Insert(insert) +} + +/// STEP: a block per product — its solids, and inserts of its components — +/// with each top product inserted at the origin, scaled from the file's +/// units to the drawing's. The solids take their colour from the block. +fn step_blocks(model: crate::io::step_import::StepModel, scale: f64) -> Imported { + // Unique names, in product order. + let mut names: Vec = Vec::new(); + for product in &model.products { + let base = block_name(&product.name); + let mut name = base.clone(); + let mut n = 1; + while names.iter().any(|taken| taken.eq_ignore_ascii_case(&name)) { + name = format!("{base}_{n}"); + n += 1; + } + names.push(name); + } + // Children before parents. A link that closes a loop in the file's + // assembly (a product that would contain itself) is dropped, so no + // block ends up inserting itself. + let mut order = Vec::new(); + let mut dropped: std::collections::HashSet<(usize, usize)> = Default::default(); + let mut state = vec![0u8; model.products.len()]; + for &root in &model.roots { + if state[root] != 0 { + continue; + } + state[root] = 1; + // Each product being walked and the next of its children to visit. + let mut stack = vec![(root, 0usize)]; + while let Some((k, next)) = stack.last_mut() { + let k = *k; + match model.products[k].children.get(*next) { + Some(&(child, _)) => { + let link = (k, *next); + *next += 1; + match state[child] { + 0 => { + state[child] = 1; + stack.push((child, 0)); + } + 1 => { + dropped.insert(link); + } + _ => {} + } + } + None => { + state[k] = 2; + order.push(k); + stack.pop(); + } + } + } + } + let blocks = order + .iter() + .map(|&k| { + let product = &model.products[k]; + let mut entities: Vec = product + .bodies + .iter() + .filter_map(|body| { + let faces: Vec> = body.faces.iter().map(|c| c.map(step_colour)).collect(); + painted_body_entity(&body.body, body.colour.map(step_colour), &faces, false) + }) + .collect(); + entities.extend( + product + .children + .iter() + .enumerate() + .filter(|(n, _)| !dropped.contains(&(k, *n))) + .map(|(_, (child, placement))| { + let mut insert = placed_insert(&names[*child], placement); + insert.common_mut().color = Color::ByBlock; + insert + }), + ); + (names[k].clone(), entities) + }) + .collect(); + let identity = [[scale, 0.0, 0.0, 0.0], [0.0, scale, 0.0, 0.0], [0.0, 0.0, scale, 0.0]]; + Imported { + blocks, + entities: model + .roots + .iter() + .map(|&k| placed_insert(&names[k], &identity)) + .collect(), + } +} + +/// `None` when the file is not of `format` or holds nothing it can read. +pub fn read(format: ModelFormat, bytes: &[u8], drawing_mm: Option) -> Option { + let imported = match format { + ModelFormat::Sat => Imported { + blocks: Vec::new(), + entities: sat_entities(&String::from_utf8_lossy(bytes))?, + }, + ModelFormat::Step => { + let model = crate::io::step_import::read(bytes)?; + let scale = ratio(model.unit_mm, drawing_mm); + step_blocks(model, scale) + } + ModelFormat::Iges => { + // Solids, then the surfaces as surfaces however flat; faces and + // edges carry the object's colour. + let model = crate::io::iges_import::read(bytes, drawing_mm)?; + let solids = model.bodies.iter().map(|body| (body, false)); + let sheets = model.sheets.iter().map(|body| (body, true)); + Imported { + blocks: Vec::new(), + entities: solids + .chain(sheets) + .filter_map(|((body, color), sheet)| painted_body_entity(body, Some(*color), &[], sheet)) + .collect(), + } + } + }; + let solids = imported + .blocks + .iter() + .flat_map(|(_, entities)| entities) + .chain(&imported.entities) + .any(|entity| !matches!(entity, EntityType::Insert(_))); + solids.then_some(imported) +} diff --git a/src/io/step_import.rs b/src/io/step_import.rs new file mode 100644 index 000000000..24ee86435 --- /dev/null +++ b/src/io/step_import.rs @@ -0,0 +1,969 @@ +//! STEP (ISO 10303-21) reading: the B-rep solids and shell models of an +//! AP203/AP214/AP242 file, as kernel bodies, with the product's name and the +//! file's length unit. + +use std::collections::HashMap; + +use kernel::brep::{ + Circle3, Cone, Curve3, Cylinder, EdgeKey, Ellipse3, Line3, Sphere, Surface, Torus, VertexKey, +}; +use kernel::space::{NurbsCurve3, NurbsSurface3, Plane, Vec3}; + +use super::brep_build::{Builder, FaceSpec}; + +/// One parameter of an entity instance. +#[derive(Debug, Clone)] +enum P { + Num(f64), + Str(String), + Enum(String), + Ref(u64), + List(Vec

), + Null, + /// A typed value, such as `LENGTH_MEASURE(1.)`. + Typed(Vec

), +} + +impl P { + fn num(&self) -> Option { + match self { + P::Num(v) => Some(*v), + P::Typed(inner) => inner.first()?.num(), + _ => None, + } + } + fn reference(&self) -> Option { + match self { + P::Ref(id) => Some(*id), + _ => None, + } + } + fn list(&self) -> &[P] { + match self { + P::List(items) => items, + _ => &[], + } + } + fn truth(&self) -> bool { + matches!(self, P::Enum(e) if e == "T") + } + fn text(&self) -> Option<&str> { + match self { + P::Str(s) => Some(s), + _ => None, + } + } +} + +/// An instance: one `(type, parameters)` part, or several for a complex one. +type Instance = Vec<(String, Vec

)>; + +struct Cursor<'a> { + s: &'a [u8], + i: usize, + /// How many parameter lists deep the cursor is. + depth: usize, +} + +impl Cursor<'_> { + fn skip(&mut self) { + loop { + while self.s.get(self.i).is_some_and(|c| c.is_ascii_whitespace()) { + self.i += 1; + } + if self.s.get(self.i..).is_some_and(|rest| rest.starts_with(b"/*")) { + match self.s[self.i + 2..].windows(2).position(|w| w == b"*/") { + Some(end) => self.i += end + 4, + None => self.i = self.s.len(), + } + } else { + break; + } + } + } + fn peek(&mut self) -> Option { + self.skip(); + self.s.get(self.i).copied() + } + fn eat(&mut self, c: u8) -> Option<()> { + (self.peek()? == c).then(|| self.i += 1) + } + fn word(&mut self) -> String { + self.skip(); + let start = self.i; + while self + .s + .get(self.i) + .is_some_and(|c| c.is_ascii_alphanumeric() || *c == b'_' || *c == b'-') + { + self.i += 1; + } + String::from_utf8_lossy(&self.s[start..self.i]).to_ascii_uppercase() + } + fn param(&mut self) -> Option

{ + Some(match self.peek()? { + b'#' => { + self.i += 1; + P::Ref(self.word().parse().ok()?) + } + b'\'' => { + self.i += 1; + let mut text = Vec::new(); + loop { + let c = *self.s.get(self.i)?; + self.i += 1; + if c == b'\'' { + if self.s.get(self.i) == Some(&b'\'') { + self.i += 1; + } else { + break; + } + } + text.push(c); + } + P::Str(String::from_utf8_lossy(&text).into_owned()) + } + b'.' => { + self.i += 1; + let value = self.word(); + self.eat(b'.')?; + P::Enum(value) + } + b'$' | b'*' => { + self.i += 1; + P::Null + } + b'(' => P::List(self.params()?), + c if c.is_ascii_digit() || c == b'-' || c == b'+' || c == b'.' => { + let start = self.i; + while self + .s + .get(self.i) + .is_some_and(|c| c.is_ascii_digit() || b"+-.eE".contains(c)) + { + self.i += 1; + } + let raw = String::from_utf8_lossy(&self.s[start..self.i]).replace(".E", ".0E"); + P::Num(raw.replace(".e", ".0e").parse().ok()?) + } + _ => { + if self.word().is_empty() { + return None; + } + P::Typed(self.params()?) + } + }) + } + /// A parameter list; lists nested past any real file's depth are + /// refused rather than read on until the stack runs out. + fn params(&mut self) -> Option> { + if self.depth >= 64 { + return None; + } + self.depth += 1; + let items = self.list(); + self.depth -= 1; + items + } + fn list(&mut self) -> Option> { + self.eat(b'(')?; + let mut items = Vec::new(); + if self.eat(b')').is_some() { + return Some(items); + } + loop { + items.push(self.param()?); + match self.peek()? { + b',' => self.i += 1, + b')' => { + self.i += 1; + return Some(items); + } + _ => return None, + } + } + } + /// `#id = TYPE(...)` or `#id = (A(...) B(...))`, up to its `;`. + fn instance(&mut self) -> Option<(u64, Instance)> { + self.eat(b'#')?; + let id = self.word().parse().ok()?; + self.eat(b'=')?; + let mut parts = Vec::new(); + if self.eat(b'(').is_some() { + while self.eat(b')').is_none() { + let name = self.word(); + if name.is_empty() { + return None; + } + parts.push((name, self.params()?)); + } + } else { + let name = self.word(); + parts.push((name, self.params()?)); + } + self.eat(b';')?; + Some((id, parts)) + } +} + +struct Doc { + instances: HashMap, + /// Plane-angle unit in radians. + angle: f64, + /// The colour each styled item (a solid, a shell or a face) is shown + /// in, as 0–1 RGB. + styles: HashMap, +} + +/// An RGB colour as 0–1 values. +pub type Rgb = [f64; 3]; + +impl Doc { + /// A presentation colour: an RGB one or a predefined one. + fn colour(&self, p: &P) -> Option { + let (kind, params) = self.simple(p.reference()?)?; + match kind { + "COLOUR_RGB" => Some([params.get(1)?.num()?, params.get(2)?.num()?, params.get(3)?.num()?]), + "DRAUGHTING_PRE_DEFINED_COLOUR" => Some(match params.first()?.text()? { + "red" => [1.0, 0.0, 0.0], + "green" => [0.0, 1.0, 0.0], + "blue" => [0.0, 0.0, 1.0], + "yellow" => [1.0, 1.0, 0.0], + "magenta" => [1.0, 0.0, 1.0], + "cyan" => [0.0, 1.0, 1.0], + "black" => [0.0, 0.0, 0.0], + "white" => [1.0, 1.0, 1.0], + _ => return None, + }), + _ => None, + } + } + + /// The colour a styled item's presentation styles give it: its surface + /// fill (or rendering) colour, or failing that its curve colour. + fn style_colour(&self, styles: &P) -> Option { + let mut stack: Vec<&P> = vec![styles]; + let mut seen = Vec::new(); + let mut curve = None; + while let Some(p) = stack.pop() { + match p { + P::List(items) | P::Typed(items) => stack.extend(items.iter().rev()), + P::Ref(id) if !seen.contains(id) => { + seen.push(*id); + let Some((kind, params)) = self.simple(*id) else { continue }; + match kind { + "FILL_AREA_STYLE_COLOUR" => { + if let Some(c) = params.get(1).and_then(|c| self.colour(c)) { + return Some(c); + } + } + "SURFACE_STYLE_RENDERING" | "SURFACE_STYLE_RENDERING_WITH_PROPERTIES" => { + if let Some(c) = params.get(1).and_then(|c| self.colour(c)) { + return Some(c); + } + } + "CURVE_STYLE" => curve = curve.or_else(|| params.get(3).and_then(|c| self.colour(c))), + _ => stack.extend(params.iter().rev()), + } + } + _ => {} + } + } + curve + } + + fn parts(&self, id: u64) -> Option<&Instance> { + self.instances.get(&id) + } + fn kind(&self, id: u64) -> &str { + self.parts(id) + .and_then(|parts| parts.first()) + .map_or("", |(name, _)| name.as_str()) + } + /// The parameters of the part named `name`. + fn part(&self, id: u64, name: &str) -> Option<&[P]> { + self.parts(id)? + .iter() + .find(|(n, _)| n == name) + .map(|(_, p)| p.as_slice()) + } + /// A simple instance's type and parameters. + fn simple(&self, id: u64) -> Option<(&str, &[P])> { + let parts = self.parts(id)?; + let (name, params) = parts.first()?; + Some((name.as_str(), params.as_slice())) + } + + fn point(&self, p: &P) -> Option<[f64; 3]> { + let (_, params) = self.simple(p.reference()?)?; + let c = params.get(1)?.list(); + Some([c.first()?.num()?, c.get(1)?.num()?, c.get(2).and_then(P::num).unwrap_or(0.0)]) + } + fn direction(&self, p: &P) -> Option<[f64; 3]> { + self.point(p) + } + fn placement(&self, p: &P) -> Option { + let (_, params) = self.simple(p.reference()?)?; + let origin = self.point(params.get(1)?)?; + let z = params.get(2).and_then(|d| self.direction(d)).unwrap_or([0.0, 0.0, 1.0]); + let x = params + .get(3) + .and_then(|d| self.direction(d)) + .filter(|x| Vec3::from(*x).cross(Vec3::from(z)).length() > 1e-9) + // Any axis off the normal; the plane squares it up. + .unwrap_or(if z[0].abs() < 0.9 { [1.0, 0.0, 0.0] } else { [0.0, 1.0, 0.0] }); + Plane::orthonormal(origin, x, z) + } + + /// Knots expanded by their multiplicities. No knot repeats more than + /// `degree + 1` times, so a bogus multiplicity cannot blow up the list. + fn knots(mults: &P, knots: &P, degree: usize) -> Option> { + let mut out = Vec::new(); + for (m, k) in mults.list().iter().zip(knots.list()) { + for _ in 0..(m.num()? as usize).min(degree.saturating_add(1)) { + out.push(k.num()?); + } + } + Some(out) + } + + fn curve(&self, p: &P) -> Option { + let mut id = p.reference()?; + // Curves that wrap a basis curve are followed to it; the hop limit + // stops curves that wrap each other in a loop. + let mut hops = 0; + while matches!( + self.kind(id), + "SURFACE_CURVE" | "SEAM_CURVE" | "INTERSECTION_CURVE" | "TRIMMED_CURVE" + ) { + hops += 1; + if hops > 32 { + return None; + } + id = self.simple(id)?.1.get(1)?.reference()?; + } + let kind = self.kind(id); + if kind == "B_SPLINE_CURVE_WITH_KNOTS" || self.part(id, "B_SPLINE_CURVE").is_some() { + return self.spline_curve(id).map(Curve3::Nurbs); + } + let (_, params) = self.simple(id)?; + Some(match kind { + "LINE" => { + let origin = self.point(params.get(1)?)?; + let (_, vector) = self.simple(params.get(2)?.reference()?)?; + let direction = Vec3::from(self.direction(vector.get(1)?)?).normalize()? + * vector.get(2)?.num()?; + Curve3::Line(Line3 { + origin, + direction: direction.to_array(), + }) + } + "CIRCLE" => Curve3::Circle(Circle3 { + plane: self.placement(params.get(1)?)?, + radius: params.get(2)?.num()?, + }), + "ELLIPSE" => Curve3::Ellipse(Ellipse3 { + plane: self.placement(params.get(1)?)?, + major_radius: params.get(2)?.num()?, + minor_radius: params.get(3)?.num()?, + }), + "POLYLINE" => { + let points: Option> = + params.get(1)?.list().iter().map(|q| self.point(q)).collect(); + Curve3::Nurbs(NurbsCurve3::new(1, points?, Vec::new(), None)?) + } + _ => return None, + }) + } + + fn spline_curve(&self, id: u64) -> Option { + // The simple form carries everything after its name; the complex + // form splits it over parts that have no name. + let (degree, points, mults, knots, weights) = + if let Some(p) = self.part(id, "B_SPLINE_CURVE_WITH_KNOTS").filter(|p| p.len() >= 9) { + (&p[1], &p[2], &p[6], &p[7], None) + } else { + let base = self.part(id, "B_SPLINE_CURVE")?; + let with = self.part(id, "B_SPLINE_CURVE_WITH_KNOTS")?; + let weights = self.part(id, "RATIONAL_B_SPLINE_CURVE").and_then(|w| w.first()); + (base.first()?, base.get(1)?, with.first()?, with.get(1)?, weights) + }; + let points: Option> = points.list().iter().map(|q| self.point(q)).collect(); + let weights: Option> = weights.map(|w| w.list().iter().filter_map(P::num).collect()); + let degree = degree.num()? as usize; + let points = points?; + let knots = Self::knots(mults, knots, degree)?; + match &weights { + Some(w) => NurbsCurve3::new_strict(degree, points.clone(), knots.clone(), w.clone()) + .or_else(|| NurbsCurve3::new(degree, points, knots, weights.clone())), + None => NurbsCurve3::new(degree, points, knots, None), + } + } + + fn surface(&self, p: &P) -> Option { + let id = p.reference()?; + let kind = self.kind(id); + if kind == "B_SPLINE_SURFACE_WITH_KNOTS" || self.part(id, "B_SPLINE_SURFACE").is_some() { + return self.spline_surface(id).map(Surface::Nurbs); + } + let (_, params) = self.simple(id)?; + let frame = self.placement(params.get(1)?)?; + let value = |k: usize| params.get(k).and_then(P::num); + Some(match kind { + "PLANE" => Surface::Plane(frame), + "CYLINDRICAL_SURFACE" => Surface::Cylinder(Cylinder { + base: frame, + radius: value(2)?, + }), + // A STEP cone widens along its axis by its semi-angle. + "CONICAL_SURFACE" => Surface::Cone(Cone { + base: frame, + radius: value(2)?, + half_angle: -value(3)? * self.angle, + }), + "SPHERICAL_SURFACE" => Surface::Sphere(Sphere { + frame, + radius: value(2)?, + }), + "TOROIDAL_SURFACE" => Surface::Torus(Torus { + frame, + major_radius: value(2)?, + minor_radius: value(3)?, + }), + _ => return None, + }) + } + + fn spline_surface(&self, id: u64) -> Option { + let (ud, vd, points, um, vm, uk, vk, weights) = if let Some(p) = self + .part(id, "B_SPLINE_SURFACE_WITH_KNOTS") + .filter(|p| p.len() >= 13) + { + (&p[1], &p[2], &p[3], &p[8], &p[9], &p[10], &p[11], None) + } else { + let base = self.part(id, "B_SPLINE_SURFACE")?; + let with = self.part(id, "B_SPLINE_SURFACE_WITH_KNOTS")?; + let weights = self.part(id, "RATIONAL_B_SPLINE_SURFACE").and_then(|w| w.first()); + ( + base.first()?, + base.get(1)?, + base.get(2)?, + with.first()?, + with.get(1)?, + with.get(2)?, + with.get(3)?, + weights, + ) + }; + let rows: Option>> = points + .list() + .iter() + .map(|row| row.list().iter().map(|q| self.point(q)).collect()) + .collect(); + let weights: Option>> = weights.map(|w| { + w.list() + .iter() + .map(|row| row.list().iter().filter_map(P::num).collect()) + .collect() + }); + let (ud, vd) = (ud.num()? as usize, vd.num()? as usize); + NurbsSurface3::new(ud, vd, rows?, Self::knots(um, uk, ud)?, Self::knots(vm, vk, vd)?, weights) + } +} + +/// Topology of one body, keyed by the file's instance ids. +struct Assembly<'a> { + doc: &'a Doc, + build: Builder, + vertices: HashMap, + /// Each edge, and whether it runs from the file's edge end to its start. + edges: HashMap, + /// The file's face of each face built, in build order. + faces: Vec, +} + +impl Assembly<'_> { + fn vertex(&mut self, p: &P) -> Option { + let id = p.reference()?; + if let Some(&v) = self.vertices.get(&id) { + return Some(v); + } + let (_, params) = self.doc.simple(id)?; + let v = self.build.vertex(self.doc.point(params.get(1)?)?); + self.vertices.insert(id, v); + Some(v) + } + + fn edge(&mut self, p: &P) -> Option<(EdgeKey, bool)> { + let id = p.reference()?; + if let Some(&e) = self.edges.get(&id) { + return Some(e); + } + let (_, params) = self.doc.simple(id)?; + let (a, b) = (self.vertex(params.get(1)?)?, self.vertex(params.get(2)?)?); + let curve = self.doc.curve(params.get(3)?)?; + let same_sense = params.get(4).is_none_or(P::truth); + let key = if same_sense { + self.build.edge(curve, a, b)? + } else { + self.build.edge(curve, b, a)? + }; + self.edges.insert(id, (key, !same_sense)); + Some((key, !same_sense)) + } + + /// A bound's coedges, in the order the face runs them. + fn bound(&mut self, p: &P) -> Option> { + let (_, params) = self.doc.simple(p.reference()?)?; + let ring_id = params.get(1)?.reference()?; + let agrees = params.get(2).is_none_or(P::truth); + let mut ring = Vec::new(); + if self.doc.kind(ring_id) == "EDGE_LOOP" { + let (_, loop_params) = self.doc.simple(ring_id)?; + for oriented in loop_params.get(1)?.list() { + let (_, o) = self.doc.simple(oriented.reference()?)?; + let (edge, flipped) = self.edge(o.get(3)?)?; + let along = o.get(4).is_none_or(P::truth); + ring.push((edge, along != flipped)); + } + } + if !agrees { + ring.reverse(); + for (_, forward) in &mut ring { + *forward = !*forward; + } + } + Some(ring) + } + + fn face(&mut self, p: &P, flip: bool) -> Option { + let id = p.reference()?; + self.faces.push(id); + let (_, params) = self.doc.simple(id)?; + let surface = self.doc.surface(params.get(2)?)?; + let mut outer = Vec::new(); + let mut inner = Vec::new(); + for bound in params.get(1)?.list() { + let ring = self.bound(bound)?; + if self.doc.kind(bound.reference()?) == "FACE_OUTER_BOUND" { + outer.push(ring); + } else { + inner.push(ring); + } + } + outer.extend(inner); + let mut loops = outer; + let mut forward = params.get(3).is_none_or(P::truth); + if flip { + forward = !forward; + for ring in &mut loops { + ring.reverse(); + for (_, f) in ring.iter_mut() { + *f = !*f; + } + } + } + Some(FaceSpec { + surface, + forward, + loops, + }) + } + + /// A shell's faces; an oriented shell set against its faces flips them. + fn shell(&mut self, p: &P) -> Option> { + let mut id = p.reference()?; + let mut flip = false; + if self.doc.kind(id) == "ORIENTED_CLOSED_SHELL" || self.doc.kind(id) == "ORIENTED_OPEN_SHELL" { + let (_, params) = self.doc.simple(id)?; + flip = !params.get(3).is_none_or(P::truth); + id = params.get(2)?.reference()?; + } + let (_, params) = self.doc.simple(id)?; + params + .get(1)? + .list() + .iter() + .map(|face| self.face(face, flip)) + .collect() + } +} + +/// A 3×4 placement: the columns are the x, y and z axes and the origin. +pub type Placement = [[f64; 4]; 3]; + +const IDENTITY: Placement = [[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0]]; + +/// A body with the colours the file shows it in. +pub struct StepBody { + pub body: kernel::brep::Body, + /// The colour of the solid (or shell model) as a whole. + pub colour: Option, + /// Each face's own colour, in the body's face order. + pub faces: Vec>, +} + +/// One product: its own bodies and where each of its components sits. +pub struct StepProduct { + pub name: String, + pub bodies: Vec, + /// Components: the product (an index into [`StepModel::products`]) and + /// its placement in this one. + pub children: Vec<(usize, Placement)>, +} + +/// What a STEP file holds. +pub struct StepModel { + /// Millimetres per file length unit. + pub unit_mm: f64, + pub products: Vec, + /// The products no other one is a component of. + pub roots: Vec, +} + +/// A named unit's size: millimetres for a length, radians for an angle. +/// A conversion-based unit is a multiple of another unit; the hop limit +/// stops units defined in terms of each other. +fn unit_size(doc: &Doc, mut id: u64) -> Option { + let mut factor = 1.0; + for _ in 0..16 { + let Some(si) = doc.part(id, "SI_UNIT") else { + let conversion = doc.part(id, "CONVERSION_BASED_UNIT")?; + let (_, measure) = doc.simple(conversion.get(1)?.reference()?)?; + factor *= measure.first()?.num()?; + id = measure.get(1)?.reference()?; + continue; + }; + let prefix = match si.first() { + Some(P::Enum(prefix)) => match prefix.as_str() { + "MILLI" => 1e-3, + "CENTI" => 1e-2, + "DECI" => 1e-1, + "KILO" => 1e3, + "MICRO" => 1e-6, + _ => 1.0, + }, + _ => 1.0, + }; + return Some( + factor + * match si.get(1) { + Some(P::Enum(name)) if name == "METRE" => prefix * 1000.0, + _ => prefix, + }, + ); + } + None +} + +/// The bodies of one B-rep item: a solid, a solid with voids, or each shell +/// of a shell model. +fn item_bodies(doc: &Doc, id: u64) -> Vec { + let Some((kind, params)) = doc.simple(id) else { + return Vec::new(); + }; + let lumps: Vec> = match kind { + "MANIFOLD_SOLID_BREP" => vec![params.get(1).into_iter().collect()], + "BREP_WITH_VOIDS" => vec![params + .get(1) + .into_iter() + .chain(params.get(2).map(P::list).unwrap_or_default()) + .collect()], + "SHELL_BASED_SURFACE_MODEL" => params + .get(1) + .map(P::list) + .unwrap_or_default() + .iter() + .map(|shell| vec![shell]) + .collect(), + _ => return Vec::new(), + }; + let mut bodies = Vec::new(); + for lump in lumps { + let mut assembly = Assembly { + doc, + build: Builder::new(), + vertices: HashMap::new(), + edges: HashMap::new(), + faces: Vec::new(), + }; + let faces: Option> = lump.iter().map(|shell| assembly.shell(shell)).collect(); + if let Some(faces) = faces { + if assembly.build.lump(faces).is_some() { + // The item's own style, or its (first) shell's. + let colour = doc.styles.get(&id).copied().or_else(|| { + lump.iter() + .filter_map(|shell| shell.reference()) + .find_map(|shell| doc.styles.get(&shell).copied()) + }); + bodies.push(StepBody { + body: assembly.build.body, + colour, + faces: assembly.faces.iter().map(|face| doc.styles.get(face).copied()).collect(), + }); + } + } + } + bodies +} + +impl Doc { + /// An axis placement as a placement matrix. + fn frame(&self, id: u64) -> Option { + let plane = self.placement(&P::Ref(id))?; + let z = plane.normal()?; + let [x, y, o] = [plane.x_axis, plane.y_axis, plane.origin]; + Some([0, 1, 2].map(|r| [x[r], y[r], z[r], o[r]])) + } +} + +/// `a·b`. +pub(super) fn compose(a: &Placement, b: &Placement) -> Placement { + let mut out = [[0.0; 4]; 3]; + for r in 0..3 { + for c in 0..4 { + out[r][c] = (0..3).map(|k| a[r][k] * b[k][c]).sum::() + if c == 3 { a[r][3] } else { 0.0 }; + } + } + out +} + +/// The inverse of a rigid placement. +fn invert(a: &Placement) -> Placement { + let mut out = [[0.0; 4]; 3]; + for r in 0..3 { + for c in 0..3 { + out[r][c] = a[c][r]; + } + out[r][3] = -(0..3).map(|k| a[k][r] * a[k][3]).sum::(); + } + out +} + +pub fn read(bytes: &[u8]) -> Option { + let text = String::from_utf8_lossy(bytes); + if !text.trim_start().starts_with("ISO-10303-21") { + return None; + } + let data = text.find("DATA;")? + 5; + let mut cursor = Cursor { + s: &text.as_bytes()[data..], + i: 0, + depth: 0, + }; + let mut instances = HashMap::new(); + while cursor.peek() == Some(b'#') { + match cursor.instance() { + Some((id, parts)) => { + instances.insert(id, parts); + } + None => { + // Skip a malformed instance up to the next one. + while cursor.s.get(cursor.i).is_some_and(|c| *c != b';') { + cursor.i += 1; + } + // Past the `;`, or at the end of a truncated file. + cursor.i = (cursor.i + 1).min(cursor.s.len()); + } + } + } + let mut doc = Doc { + instances, + angle: 1.0, + styles: HashMap::new(), + }; + + let mut unit_mm = 1.0; + let mut angle = 1.0; + let mut ids: Vec = doc.instances.keys().copied().collect(); + ids.sort_unstable(); + for &id in &ids { + let Some(units) = doc.part(id, "GLOBAL_UNIT_ASSIGNED_CONTEXT") else { + continue; + }; + for unit in units.first().map(P::list).unwrap_or_default() { + let Some(unit) = unit.reference() else { continue }; + if doc.part(unit, "LENGTH_UNIT").is_some() { + unit_mm = unit_size(&doc, unit).unwrap_or(1.0); + } else if doc.part(unit, "PLANE_ANGLE_UNIT").is_some() { + angle = unit_size(&doc, unit).unwrap_or(1.0); + } + } + break; + } + doc.angle = angle; + // Styled items: plain ones first, so an overriding style wins. + let mut styled = Vec::new(); + for kind in ["STYLED_ITEM", "OVER_RIDING_STYLED_ITEM"] { + for &id in &ids { + if doc.kind(id) != kind { + continue; + } + let Some((_, params)) = doc.simple(id) else { continue }; + let (Some(styles), Some(item)) = (params.get(1), params.get(2).and_then(P::reference)) else { + continue; + }; + if let Some(colour) = doc.style_colour(styles) { + styled.push((item, colour)); + } + } + } + doc.styles.extend(styled); + let doc = doc; + + // What each instance id is, by kind. + let of_kind = |kind: &str| -> Vec { + ids.iter() + .copied() + .filter(|&id| doc.kind(id) == kind) + .collect() + }; + let param = |id: u64, k: usize| doc.simple(id).and_then(|(_, p)| p.get(k)).and_then(P::reference); + + // Product definitions and the name of the product each defines. + let definitions: Vec = ids + .iter() + .copied() + .filter(|&id| { + matches!(doc.kind(id), "PRODUCT_DEFINITION" | "PRODUCT_DEFINITION_WITH_ASSOCIATED_DOCUMENTS") + }) + .collect(); + let product_name = |definition: u64| -> String { + let product = param(definition, 2).and_then(|formation| param(formation, 2)); + product + .and_then(|p| doc.simple(p)) + .and_then(|(_, params)| { + params + .get(1) + .and_then(P::text) + .filter(|s| !s.trim().is_empty()) + .or_else(|| params.first().and_then(P::text)) + }) + .unwrap_or_default() + .to_string() + }; + // The shape of a definition (or an occurrence), and the representations + // attached to a shape. + let shape_of: HashMap = of_kind("PRODUCT_DEFINITION_SHAPE") + .into_iter() + .filter_map(|pds| Some((param(pds, 2)?, pds))) + .collect(); + let mut reps_of_shape: HashMap> = HashMap::new(); + for sdr in of_kind("SHAPE_DEFINITION_REPRESENTATION") { + if let (Some(pds), Some(rep)) = (param(sdr, 0), param(sdr, 1)) { + reps_of_shape.entry(pds).or_default().push(rep); + } + } + // Representations tied without a transformation share their items. + let mut linked: HashMap> = HashMap::new(); + for id in &ids { + let Some(("SHAPE_REPRESENTATION_RELATIONSHIP", params)) = doc.simple(*id) else { + continue; + }; + if let (Some(a), Some(b)) = (params.get(2).and_then(P::reference), params.get(3).and_then(P::reference)) { + linked.entry(a).or_default().push(b); + linked.entry(b).or_default().push(a); + } + } + let items_of = |rep: u64| -> Vec { + let mut seen = vec![rep]; + let mut at = 0; + while at < seen.len() { + for next in linked.get(&seen[at]).into_iter().flatten() { + if !seen.contains(next) { + seen.push(*next); + } + } + at += 1; + } + seen.iter() + .filter_map(|r| doc.simple(*r)) + .flat_map(|(_, params)| params.get(1).map(P::list).unwrap_or_default()) + .filter_map(P::reference) + .collect() + }; + + let mut index: HashMap = HashMap::new(); + let mut products: Vec = Vec::new(); + let mut used_items: Vec = Vec::new(); + for &definition in &definitions { + let mut bodies = Vec::new(); + for rep in shape_of.get(&definition).and_then(|pds| reps_of_shape.get(pds)).into_iter().flatten() { + for item in items_of(*rep) { + if !used_items.contains(&item) { + let found = item_bodies(&doc, item); + if !found.is_empty() { + used_items.push(item); + } + bodies.extend(found); + } + } + } + index.insert(definition, products.len()); + products.push(StepProduct { + name: product_name(definition), + bodies, + children: Vec::new(), + }); + } + + // Components, placed by the transformation of their occurrence. + let mut component = vec![false; products.len()]; + for occurrence in of_kind("NEXT_ASSEMBLY_USAGE_OCCURRENCE") { + let (Some(&parent), Some(&child)) = ( + param(occurrence, 3).and_then(|d| index.get(&d)), + param(occurrence, 4).and_then(|d| index.get(&d)), + ) else { + continue; + }; + let placement = shape_of + .get(&occurrence) + .and_then(|pds| { + ids.iter().copied().find(|&id| { + doc.kind(id) == "CONTEXT_DEPENDENT_SHAPE_REPRESENTATION" && param(id, 1) == Some(*pds) + }) + }) + .and_then(|cdsr| param(cdsr, 0)) + .and_then(|relation| doc.part(relation, "REPRESENTATION_RELATIONSHIP_WITH_TRANSFORMATION")) + .and_then(|p| p.first()?.reference()) + .and_then(|transformation| { + let (_, t) = doc.simple(transformation)?; + let from = doc.frame(t.get(2)?.reference()?)?; + let to = doc.frame(t.get(3)?.reference()?)?; + Some(compose(&to, &invert(&from))) + }) + .unwrap_or(IDENTITY); + products[parent].children.push((child, placement)); + component[child] = true; + } + + // A file whose solids no product claims: one product holding them all, + // named after the file's first product. + let loose: Vec = ids + .iter() + .copied() + .filter(|id| !used_items.contains(id)) + .collect(); + let mut loose_bodies = Vec::new(); + for id in loose { + loose_bodies.extend(item_bodies(&doc, id)); + } + if !loose_bodies.is_empty() { + let name = definitions.first().map(|d| product_name(*d)).unwrap_or_default(); + component.push(false); + products.push(StepProduct { + name, + bodies: loose_bodies, + children: Vec::new(), + }); + } + + let roots = (0..products.len()) + .filter(|&k| !component[k] && (!products[k].bodies.is_empty() || !products[k].children.is_empty())) + .collect(); + Some(StepModel { + unit_mm, + products, + roots, + }) +} diff --git a/src/io/tds_import.rs b/src/io/tds_import.rs new file mode 100644 index 000000000..56490406c --- /dev/null +++ b/src/io/tds_import.rs @@ -0,0 +1,424 @@ +//! 3D Studio (.3ds) reading: the materials, meshes, lights and cameras of a +//! file's editor section, and their conversion to drawing objects — each +//! mesh a polyface mesh on a layer chosen by object, material, object colour +//! or a single layer, with its material; each camera a named view. + +use std::collections::HashMap; + +use codec::entities::{PolyfaceFace, PolyfaceMesh, PolyfaceVertex}; +use codec::types::Vector3; + +use crate::scene::sanitized_table_name; + +pub struct TdsMaterial { + pub name: String, + pub ambient: Option<[u8; 3]>, + pub diffuse: Option<[u8; 3]>, + pub specular: Option<[u8; 3]>, +} + +pub struct TdsMesh { + pub vertices: Vec<[f64; 3]>, + pub faces: Vec<[usize; 3]>, + /// Each material the mesh uses and the faces it covers, in file order. + pub groups: Vec<(String, Vec)>, + /// The object colour, a palette index, when the file gives one. + pub color: Option, +} + +pub struct TdsCamera { + pub position: [f64; 3], + pub target: [f64; 3], + pub lens: f64, +} + +pub enum TdsObject { + Mesh(TdsMesh), + Light, + Camera(TdsCamera), +} + +impl TdsObject { + /// The kind as the import window lists it. + pub fn kind(&self) -> &'static str { + match self { + TdsObject::Mesh(_) => "Mesh", + TdsObject::Light => "Light", + TdsObject::Camera(_) => "Camera", + } + } +} + +pub struct TdsScene { + pub materials: Vec, + pub objects: Vec<(String, TdsObject)>, +} + +impl TdsScene { + /// The materials a mesh uses, in the order the file defines them. + pub fn mesh_materials(&self, mesh: &TdsMesh) -> Vec { + let mut used: Vec = Vec::new(); + for m in &self.materials { + if mesh.groups.iter().any(|(name, faces)| !faces.is_empty() && *name == m.name) { + used.push(m.name.clone()); + } + } + for (name, faces) in &mesh.groups { + if !faces.is_empty() && !used.contains(name) { + used.push(name.clone()); + } + } + used + } +} + +struct Reader<'a> { + b: &'a [u8], +} + +impl Reader<'_> { + fn u16(&self, at: usize) -> Option { + Some(u16::from_le_bytes([*self.b.get(at)?, *self.b.get(at + 1)?])) + } + fn u32(&self, at: usize) -> Option { + Some(u32::from_le_bytes(self.b.get(at..at + 4)?.try_into().ok()?)) + } + fn f32(&self, at: usize) -> Option { + Some(f64::from(f32::from_le_bytes(self.b.get(at..at + 4)?.try_into().ok()?))) + } + fn point(&self, at: usize) -> Option<[f64; 3]> { + Some([self.f32(at)?, self.f32(at + 4)?, self.f32(at + 8)?]) + } + /// A zero-terminated string and the offset after it. + fn cstr(&self, at: usize, end: usize) -> (String, usize) { + let stop = self.b[at.min(end)..end] + .iter() + .position(|c| *c == 0) + .map_or(end, |k| at + k); + ( + String::from_utf8_lossy(&self.b[at.min(stop)..stop]).into_owned(), + (stop + 1).min(end), + ) + } + /// The chunks between `from` and `to`: id, body start, body end. + fn chunks(&self, from: usize, to: usize) -> Vec<(u16, usize, usize)> { + let mut out = Vec::new(); + let mut at = from; + while at + 6 <= to { + let (Some(id), Some(len)) = (self.u16(at), self.u32(at + 2)) else { break }; + let len = len as usize; + if len < 6 || at + len > to { + break; + } + out.push((id, at + 6, at + len)); + at += len; + } + out + } + /// A colour chunk's 8-bit RGB: 24-bit or float, plain before linear. + fn color(&self, from: usize, to: usize) -> Option<[u8; 3]> { + let chunks = self.chunks(from, to); + for want in [0x0011, 0x0010, 0x0012, 0x0013] { + for &(id, start, _) in &chunks { + if id != want { + continue; + } + return Some(match id { + 0x0011 | 0x0012 => [*self.b.get(start)?, *self.b.get(start + 1)?, *self.b.get(start + 2)?], + _ => self.point(start)?.map(|c| (c.clamp(0.0, 1.0) * 255.0).round() as u8), + }); + } + } + None + } +} + +/// `None` when the bytes are no 3D Studio file. +pub fn read(bytes: &[u8]) -> Option { + let r = Reader { b: bytes }; + if r.u16(0)? != 0x4D4D { + return None; + } + let end = (r.u32(2)? as usize).min(bytes.len()); + let mut scene = TdsScene { + materials: Vec::new(), + objects: Vec::new(), + }; + for (id, start, stop) in r.chunks(6, end) { + if id != 0x3D3D { + continue; + } + for (id, start, stop) in r.chunks(start, stop) { + match id { + 0xAFFF => { + let mut material = TdsMaterial { + name: String::new(), + ambient: None, + diffuse: None, + specular: None, + }; + for (id, s, e) in r.chunks(start, stop) { + match id { + 0xA000 => material.name = r.cstr(s, e).0, + 0xA010 => material.ambient = r.color(s, e), + 0xA020 => material.diffuse = r.color(s, e), + 0xA030 => material.specular = r.color(s, e), + _ => {} + } + } + scene.materials.push(material); + } + 0x4000 => { + let (name, body) = r.cstr(start, stop); + for (id, s, e) in r.chunks(body, stop) { + let object = match id { + 0x4100 => TdsObject::Mesh(read_mesh(&r, s, e)), + 0x4600 => TdsObject::Light, + 0x4700 => TdsObject::Camera(TdsCamera { + position: r.point(s)?, + target: r.point(s + 12)?, + lens: r.f32(s + 28).filter(|l| *l > 0.0).unwrap_or(50.0), + }), + _ => continue, + }; + scene.objects.push((name.clone(), object)); + break; + } + } + _ => {} + } + } + } + Some(scene) +} + +fn read_mesh(r: &Reader<'_>, from: usize, to: usize) -> TdsMesh { + let mut mesh = TdsMesh { + vertices: Vec::new(), + faces: Vec::new(), + groups: Vec::new(), + color: None, + }; + for (id, s, e) in r.chunks(from, to) { + match id { + 0x4110 => { + let n = r.u16(s).unwrap_or(0) as usize; + mesh.vertices = (0..n).filter_map(|k| r.point(s + 2 + 12 * k)).collect(); + } + 0x4120 => { + let n = r.u16(s).unwrap_or(0) as usize; + mesh.faces = (0..n) + .filter_map(|k| { + let at = s + 2 + 8 * k; + Some([r.u16(at)? as usize, r.u16(at + 2)? as usize, r.u16(at + 4)? as usize]) + }) + .collect(); + for (id, gs, ge) in r.chunks(s + 2 + 8 * n, e) { + if id == 0x4130 { + let (name, at) = r.cstr(gs, ge); + let count = r.u16(at).unwrap_or(0) as usize; + let faces = (0..count).filter_map(|k| r.u16(at + 2 + 2 * k).map(usize::from)).collect(); + mesh.groups.push((name, faces)); + } + } + } + 0x4165 => mesh.color = r.b.get(s).copied(), + _ => {} + } + } + mesh +} + +/// Where imported objects go. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum LayerMode { + #[default] + ByObject, + ByMaterial, + ByObjectColor, + Single, +} + +/// What a mesh with several materials becomes. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum MultiMaterial { + /// One mesh per material. + Split, + /// One mesh with the first material the file defines. + First, + /// One mesh with this material, or none. + Material(Option), +} + +pub struct TdsOptions { + /// The objects to import, by name ("Global Settings" included). + pub selected: Vec, + pub layers: LayerMode, + /// The choice for each mesh with several materials, by name. + pub multi: HashMap, + /// Whether meshes keep only the vertices their faces use, in the order + /// they first use them — as they do when the import splits by + /// material or asks — rather than the file's vertex list. + pub compact: bool, + /// The layer every mesh goes on with [`LayerMode::Single`]. + pub single_layer: String, +} + +/// One imported mesh. +pub struct TdsPart { + pub layer: String, + pub material: Option, + pub mesh: PolyfaceMesh, +} + +pub struct TdsCameraView { + pub name: String, + pub target: [f64; 3], + pub direction: [f64; 3], + /// The view height at the target, from the lens. + pub height: f64, + pub lens: f64, +} + +pub struct TdsImport { + pub parts: Vec, + /// The materials the parts use, in the order they were first used. + pub materials: Vec, + pub views: Vec, + pub messages: Vec, +} + +/// Polyface meshes of `faces` of `mesh`: its vertices in file order, or +/// (`compact`) only those the faces use, in the order they first use them. +/// A polyface mesh numbers its vertices in 16 bits, so a mesh with more +/// vertices than that is always compact, and the faces that need further +/// vertices go on further meshes. +fn polyfaces(mesh: &TdsMesh, faces: &[usize], compact: bool) -> Vec { + const MAX_VERTICES: usize = i16::MAX as usize; + let compact = compact || mesh.vertices.len() > MAX_VERTICES; + let vertex = |v: usize| { + let p = mesh.vertices[v]; + PolyfaceVertex::new(Vector3::new(p[0], p[1], p[2])) + }; + let mut out = Vec::new(); + let mut current = PolyfaceMesh::new(); + let mut index: HashMap = HashMap::new(); + if !compact { + for k in 0..mesh.vertices.len() { + index.insert(k, current.add_vertex(vertex(k))); + } + } + for &f in faces { + let Some(face) = mesh.faces.get(f) else { continue }; + if face.iter().any(|v| *v >= mesh.vertices.len()) { + continue; + } + let new = face.iter().filter(|v| !index.contains_key(v)).count(); + if current.vertices.len() + new > MAX_VERTICES { + out.push(std::mem::replace(&mut current, PolyfaceMesh::new())); + index.clear(); + } + let ids = face.map(|v| *index.entry(v).or_insert_with(|| current.add_vertex(vertex(v)))); + current.add_face(PolyfaceFace::triangle(ids[0], ids[1], ids[2])); + } + out.push(current); + out +} + +/// The view height a camera's lens sees at the target distance. +// ponytail: one measured camera (50 mm at 86.46 units gives 30.457); the +// height is taken as proportional to distance over lens. +fn view_height(distance: f64, lens: f64) -> f64 { + distance * 17.6141 / lens +} + +pub fn convert(scene: &TdsScene, options: &TdsOptions) -> TdsImport { + let mut out = TdsImport { + parts: Vec::new(), + materials: Vec::new(), + views: Vec::new(), + messages: Vec::new(), + }; + let chosen = |name: &str| options.selected.iter().any(|s| s == name); + // The objects are taken in the order the window lists them, by name. + let mut objects: Vec<&(String, TdsObject)> = scene.objects.iter().collect(); + objects.sort_by_key(|(name, _)| name.to_lowercase()); + for (name, object) in objects { + if !chosen(name) { + continue; + } + match object { + TdsObject::Mesh(mesh) => { + out.messages.push(format!("Processing object {name}")); + let materials = scene.mesh_materials(mesh); + let all: Vec = (0..mesh.faces.len()).collect(); + let pieces: Vec<(Option, Vec)> = if materials.len() > 1 { + match options.multi.get(name).cloned().unwrap_or(MultiMaterial::Split) { + MultiMaterial::Split => materials + .iter() + .map(|m| { + let faces = mesh + .groups + .iter() + .filter(|(g, _)| g == m) + .flat_map(|(_, f)| f.iter().copied()) + .collect(); + (Some(m.clone()), faces) + }) + .collect(), + MultiMaterial::First => vec![(materials.first().cloned(), all)], + MultiMaterial::Material(m) => vec![(m, all)], + } + } else { + vec![(materials.first().cloned(), all)] + }; + let compact = options.compact || pieces.len() > 1; + for (material, faces) in pieces { + if let Some(m) = &material { + if !out.materials.contains(m) { + out.messages.push(format!("Converting material {m}")); + out.materials.push(m.clone()); + } + } + // Object and material names come from the file, so they + // are made fit to name a layer. + let layer = match options.layers { + LayerMode::ByObject => sanitized_table_name(&name.to_uppercase(), "0"), + LayerMode::ByMaterial => { + sanitized_table_name(&material.as_deref().unwrap_or("0").to_uppercase(), "0") + } + LayerMode::ByObjectColor => match mesh.color { + Some(c) => format!("COLOR{c:02}"), + None => "COLORNONE".to_string(), + }, + LayerMode::Single => options.single_layer.clone(), + }; + for polyface in polyfaces(mesh, &faces, compact) { + out.parts.push(TdsPart { + layer: layer.clone(), + material: material.clone(), + mesh: polyface, + }); + } + } + } + TdsObject::Camera(camera) => { + let name = sanitized_table_name(&name.to_uppercase(), "CAMERA"); + out.messages.push(format!("Creating camera {name}")); + let direction = [0, 1, 2].map(|k| camera.position[k] - camera.target[k]); + let distance = direction.iter().map(|d| d * d).sum::().sqrt(); + out.views.push(TdsCameraView { + name, + target: camera.target, + direction, + height: view_height(distance, camera.lens), + lens: camera.lens, + }); + } + TdsObject::Light => {} + } + } + out.messages.push("3D Studio file import completed".to_string()); + out +} + diff --git a/src/io/wmf_import.rs b/src/io/wmf_import.rs new file mode 100644 index 000000000..7d6686a3a --- /dev/null +++ b/src/io/wmf_import.rs @@ -0,0 +1,551 @@ +//! Windows metafile (WMF) reading: the records a metafile plays, as the +//! drawing objects of a block — outlines as polylines in their pen's colour +//! and width, filled shapes as closed polylines in their brush's colour, +//! ellipses as 99-segment polylines, arcs and pies on whole units, text as +//! single-line text placed and sized by its font's own ascent. Chords are +//! not read. +//! +//! The picture is scaled to fit half the view it is imported into, with its +//! top-left corner at the block's origin and y running down the screen as +//! a metafile's does. + +use codec::entities::{Polyline2D, Text, Vertex2D}; +use codec::types::{Color, Vector3}; +use codec::EntityType; + +/// A font face as the text uses it: its name, and whether bold and italic. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Face { + pub name: String, + pub bold: bool, + pub italic: bool, +} + +/// A picture: its objects, in block coordinates, and the font of each +/// text (an index into `fonts`). +pub struct Picture { + pub entities: Vec, + /// Each face the text uses, in the order first used; each becomes a + /// text style. + pub fonts: Vec, + /// For each text in `entities` (by position), its font index. + pub text_fonts: Vec<(usize, usize)>, +} + +#[derive(Clone, Copy)] +struct Pen { + null: bool, + width: f64, + rgb: [u8; 3], +} + +#[derive(Clone, Copy)] +struct Brush { + null: bool, + rgb: [u8; 3], +} + +#[derive(Clone)] +struct Font { + /// The logical height: negative for the character height, positive for + /// the cell height. + height: f64, + escapement: f64, + weight: i32, + italic: bool, + face: String, +} + +/// A font's ascent, in metafile units, as the system lays the font out at +/// its logical height: the baseline sits this far below a text's reference +/// point. Without the system's font engine it is nine tenths of the height. +#[cfg(target_os = "windows")] +fn ascent(font: &Font) -> f64 { + use windows_sys::Win32::Graphics::Gdi::{ + CreateCompatibleDC, CreateFontIndirectW, DeleteDC, DeleteObject, GetTextMetricsW, + SelectObject, LOGFONTW, TEXTMETRICW, + }; + // SAFETY: a private memory device context and font, released before + // returning; the structures are plain data the calls fill. + unsafe { + let mut logfont: LOGFONTW = std::mem::zeroed(); + logfont.lfHeight = font.height as i32; + logfont.lfWeight = font.weight; + logfont.lfItalic = u8::from(font.italic); + for (slot, unit) in logfont + .lfFaceName + .iter_mut() + .zip(font.face.encode_utf16().take(31)) + { + *slot = unit; + } + let dc = CreateCompatibleDC(std::ptr::null_mut()); + if dc.is_null() { + return 0.9 * font.height.abs(); + } + let handle = CreateFontIndirectW(&logfont); + let previous = SelectObject(dc, handle); + let mut metrics: TEXTMETRICW = std::mem::zeroed(); + let ok = GetTextMetricsW(dc, &mut metrics) != 0; + SelectObject(dc, previous); + DeleteObject(handle); + DeleteDC(dc); + if ok { + f64::from(metrics.tmAscent) + } else { + 0.9 * font.height.abs() + } + } +} + +#[cfg(not(target_os = "windows"))] +fn ascent(font: &Font) -> f64 { + 0.9 * font.height.abs() +} + +#[derive(Clone)] +enum Object { + Pen(Pen), + Brush(Brush), + Font(Font), + Other, +} + +/// Segments of a whole ellipse. +const ELLIPSE_SEGMENTS: usize = 99; + +/// A colour reference as the nearest index colour; black and white both +/// draw as colour 7. +fn color(rgb: [u8; 3]) -> Color { + if rgb == [0, 0, 0] || rgb == [255, 255, 255] { + return Color::Index(7); + } + Color::Index(codec::types::nearest_aci(rgb[0], rgb[1], rgb[2])) +} + +fn colorref(lo: u16, hi: u16) -> [u8; 3] { + [(lo & 0xff) as u8, (lo >> 8) as u8, (hi & 0xff) as u8] +} + +struct Player { + origin: [f64; 2], + scale: [f64; 2], + entities: Vec, + fonts: Vec, + text_fonts: Vec<(usize, usize)>, +} + +impl Player { + fn at(&self, x: f64, y: f64) -> Vector3 { + Vector3::new((x - self.origin[0]) * self.scale[0], (y - self.origin[1]) * self.scale[1], 0.0) + } + + fn polyline(&mut self, points: &[[f64; 2]], closed: bool, width: f64, rgb: [u8; 3]) { + if points.len() < 2 { + return; + } + let width = width * self.scale[0].abs(); + let mut polyline = Polyline2D::new(); + polyline.start_width = width; + polyline.end_width = width; + for p in points { + let mut vertex = Vertex2D::new(self.at(p[0], p[1])); + vertex.start_width = width; + vertex.end_width = width; + polyline.add_vertex(vertex); + } + if closed { + polyline.close(); + } + polyline.common.color = color(rgb); + self.entities.push(EntityType::Polyline2D(polyline)); + } + + /// An outline in the pen, or a filled area as its brush's outline. + fn shape(&mut self, points: &[[f64; 2]], pen: &Pen, brush: &Brush) { + let width = if pen.null { 0.0 } else { pen.width }; + if !brush.null { + self.polyline(points, true, width, brush.rgb); + } else if !pen.null { + self.polyline(points, true, width, pen.rgb); + } + } +} + +/// Points round an ellipse inside `(left, top)–(right, bottom)`, from +/// `start` to `end` (radians, counter-clockwise in drawing coordinates). +fn ellipse_points(bounds: [f64; 4], start: f64, sweep: f64) -> Vec<[f64; 2]> { + let [left, top, right, bottom] = bounds; + let (cx, cy) = (0.5 * (left + right), 0.5 * (top + bottom)); + let (rx, ry) = (0.5 * (right - left).abs(), 0.5 * (bottom - top).abs()); + let step = std::f64::consts::TAU / ELLIPSE_SEGMENTS as f64; + let count = ((sweep.abs() / step).round() as usize).max(1); + (0..=count) + .map(|k| { + let a = start + sweep * k as f64 / count as f64; + // Metafile y runs down: counter-clockwise on the drawing is + // up the page. + [cx + rx * a.cos(), cy - ry * a.sin()] + }) + .collect() +} + +/// Turns an arc is cut into: its points lie on whole multiples of this +/// share of a turn. +const ARC_STEPS: i64 = 99; + +/// The angle of a radial point round an ellipse, in degrees in `(0, 360]` +/// counter-clockwise in drawing orientation — or `None` for a point inside +/// the ellipse, which marks no angle. +fn radial_angle(bounds: [f64; 4], x: f64, y: f64) -> Option { + let [left, top, right, bottom] = bounds; + let (cx, cy) = (0.5 * (left + right), 0.5 * (top + bottom)); + let (rx, ry) = (0.5 * (right - left).abs(), 0.5 * (bottom - top).abs()); + let (u, v) = ((x - cx) / rx, -(y - cy) / ry); + if u.hypot(v) < 1.0 { + return None; + } + let degrees = v.atan2(u).to_degrees().rem_euclid(360.0); + Some(if degrees == 0.0 { 360.0 } else { degrees }) +} + +/// An arc's points: round the ellipse's whole-unit centre and radii at +/// whole multiples of 1/99 turn, rounded +/// down to whole units, from the start angle up to the end angle — past +/// 360 degrees back from zero when the end comes first. A start inside the +/// ellipse starts at zero; an end inside it runs on to a whole turn; with +/// both inside there is no arc. +fn arc_points(bounds: [f64; 4], start: [f64; 2], end: [f64; 2]) -> Vec<[f64; 2]> { + let [left, top, right, bottom] = bounds.map(|v| v as i64); + let (cx, cy) = ((left + right).div_euclid(2), (top + bottom).div_euclid(2)); + let (rx, ry) = ((right - left).abs() / 2, (bottom - top).abs() / 2); + let (from, to) = (radial_angle(bounds, start[0], start[1]), radial_angle(bounds, end[0], end[1])); + let (start_angle, end_angle) = match (from, to) { + (None, None) => return Vec::new(), + (None, to) => (0.0, to), + (Some(from), to) => (from, to.filter(|to| *to != from)), + }; + // The angles are stepped out from zero one step at a time in degrees, + // rounding and all, as the points come out. + let mut degrees = vec![0.0f64; ARC_STEPS as usize + 1]; + for k in 1..degrees.len() { + degrees[k] = degrees[k - 1] + 360.0 / ARC_STEPS as f64; + } + let point = |k: i64| { + let a = degrees[k as usize] * (std::f64::consts::PI / 180.0); + [ + (cx as f64 + rx as f64 * a.cos()).floor(), + (cy as f64 + ry as f64 * -a.sin()).floor(), + ] + }; + // `k` steps lie below an angle when k·360 < angle·99, kept exact. + let under = |k: &i64, angle: f64| ((k * 360) as f64) < angle * ARC_STEPS as f64; + let first = (start_angle * ARC_STEPS as f64 / 360.0).ceil() as i64; + let mut steps: Vec = match end_angle { + // Up to the end. + Some(to) if to > start_angle => (first..).take_while(|k| under(k, to)).collect(), + // Round to a whole turn, then from zero up to the end. + Some(to) => (first..=ARC_STEPS).chain((0..).take_while(|k| under(k, to))).collect(), + // Round to a whole turn. + None => (first..=ARC_STEPS).collect(), + }; + // The whole-unit points next to the start and end rays are judged by + // which side of the ray they fall: a point on the start ray is left + // out, one on the end ray kept. + let (fx, fy) = (0.5 * (bounds[0] + bounds[2]), 0.5 * (bounds[1] + bounds[3])); + let side = |ray: [f64; 2], k: i64| { + let p = point(k); + let (ux, uy) = (ray[0] - fx, -(ray[1] - fy)); + let (vx, vy) = (p[0] - fx, -(p[1] - fy)); + ux * vy - uy * vx + }; + if from.is_some() { + if let Some(&k) = steps.first() { + if k < ARC_STEPS && side(start, k) <= 0.0 { + steps.remove(0); + } else if k > 0 && k < ARC_STEPS && side(start, k - 1) > 0.0 { + steps.insert(0, k - 1); + } + } + } + if end_angle.is_some() { + if let Some(&k) = steps.last() { + if k > 0 && side(end, k) > 0.0 { + steps.pop(); + } else if k + 1 < ARC_STEPS && side(end, k + 1) <= 0.0 { + steps.push(k + 1); + } + } + } + steps.into_iter().map(point).collect() +} + +/// Reads a metafile, scaled so the picture fits within `fit` (width and +/// height, drawing units). `None` when it is not a metafile. +pub fn read(bytes: &[u8], fit: [f64; 2]) -> Option { + let word = |at: usize| -> Option { + Some(u16::from_le_bytes([*bytes.get(at)?, *bytes.get(at + 1)?])) + }; + let mut at = 0; + let mut bounds = None; + if word(0)? == 0xCDD7 && word(2)? == 0x9AC6 { + let s = |k: usize| word(6 + 2 * k).map(|w| w as i16 as f64); + bounds = Some([s(0)?, s(1)?, s(2)?, s(3)?]); + at = 22; + } + // The metafile header: type 1 or 2, nine words long. + if !matches!(word(at)?, 1 | 2) || word(at + 2)? != 9 { + return None; + } + at += 18; + let mut records = Vec::new(); + while at + 6 <= bytes.len() { + let size = (u32::from(word(at)?) | (u32::from(word(at + 2)?) << 16)) as usize; + let function = word(at + 4)?; + if function == 0 || size < 3 { + break; + } + // A record claims no more words than the file has left. + let words = (size - 3).min((bytes.len() - (at + 6)) / 2); + let params: Vec = (0..words).filter_map(|k| word(at + 6 + 2 * k)).collect(); + records.push((function, params)); + at = at.saturating_add(size.saturating_mul(2)); + } + + // The window first: where the picture is and how big. + let mut origin = [0.0, 0.0]; + let mut extent = None; + for (function, p) in &records { + let s = |k: usize| p.get(k).map(|w| *w as i16 as f64); + match function { + 0x020B => origin = [s(1)?, s(0)?], + 0x020C => extent = Some([s(1)?, s(0)?]), + _ => {} + } + } + let extent = extent + .or_else(|| bounds.map(|[l, t, r, b]| [r - l, b - t])) + .filter(|[w, h]| *w != 0.0 && *h != 0.0)?; + if let (None, Some([l, t, _, _])) = (records.iter().find(|(f, _)| *f == 0x020B), bounds) { + origin = [l, t]; + } + let scale = (fit[0] / extent[0].abs()).min(fit[1] / extent[1].abs()); + let mut player = Player { + origin, + scale: [scale * extent[0].signum(), -scale * extent[1].signum()], + entities: Vec::new(), + fonts: Vec::new(), + text_fonts: Vec::new(), + }; + + // A fresh device context: a one-unit black pen, a white brush. + let mut pen = Pen { + null: false, + width: 1.0, + rgb: [0, 0, 0], + }; + let mut brush = Brush { + null: false, + rgb: [255, 255, 255], + }; + let mut font = Font { + height: 12.0, + escapement: 0.0, + weight: 400, + italic: false, + face: String::new(), + }; + let mut text_rgb = [0, 0, 0]; + let mut table: Vec> = Vec::new(); + let mut current = [0.0, 0.0]; + for (function, p) in &records { + let s = |k: usize| p.get(k).map(|w| *w as i16 as f64).unwrap_or(0.0); + let points = |from: usize, count: usize| -> Vec<[f64; 2]> { + (0..count).map(|k| [s(from + 2 * k), s(from + 2 * k + 1)]).collect() + }; + let create = |table: &mut Vec>, object: Object| { + match table.iter().position(Option::is_none) { + Some(slot) => table[slot] = Some(object), + None => table.push(Some(object)), + } + }; + match function { + // CREATEPENINDIRECT + 0x02FA => create( + &mut table, + Object::Pen(Pen { + null: p.first().is_some_and(|style| style & 0x0f == 5), + width: s(1), + rgb: colorref(p.get(3).copied().unwrap_or(0), p.get(4).copied().unwrap_or(0)), + }), + ), + // CREATEBRUSHINDIRECT + 0x02FC => create( + &mut table, + Object::Brush(Brush { + null: p.first() == Some(&1), + rgb: colorref(p.get(1).copied().unwrap_or(0), p.get(2).copied().unwrap_or(0)), + }), + ), + // CREATEFONTINDIRECT: height, width, escapement, ... face at + // byte 18. + 0x02FB => { + let mut face = Vec::new(); + for w in p.iter().skip(9) { + face.extend(w.to_le_bytes()); + } + let end = face.iter().position(|b| *b == 0).unwrap_or(face.len()); + create( + &mut table, + Object::Font(Font { + height: s(0), + escapement: s(2) / 10.0, + weight: s(4) as i32, + italic: p.get(5).is_some_and(|w| w & 0xff != 0), + face: String::from_utf8_lossy(&face[..end]).into_owned(), + }), + ) + } + // Palettes and regions take a slot too. + 0x00F7 | 0x06FF | 0x01F9 | 0x0142 => create(&mut table, Object::Other), + // SELECTOBJECT + 0x012D => match table.get(p.first().copied().unwrap_or(0) as usize) { + Some(Some(Object::Pen(x))) => pen = *x, + Some(Some(Object::Brush(x))) => brush = *x, + Some(Some(Object::Font(x))) => font = x.clone(), + _ => {} + }, + // DELETEOBJECT + 0x01F0 => { + if let Some(slot) = table.get_mut(p.first().copied().unwrap_or(0) as usize) { + *slot = None; + } + } + // SETTEXTCOLOR + 0x0209 => text_rgb = colorref(p.first().copied().unwrap_or(0), p.get(1).copied().unwrap_or(0)), + // MOVETO, LINETO (y, x) + 0x0214 => current = [s(1), s(0)], + 0x0213 => { + let to = [s(1), s(0)]; + if !pen.null { + player.polyline(&[current, to], false, pen.width, pen.rgb); + } + current = to; + } + // POLYLINE + 0x0325 => { + if !pen.null { + let line = points(1, s(0) as usize); + player.polyline(&line, false, pen.width, pen.rgb); + } + } + // POLYGON + 0x0324 => { + let ring = points(1, s(0) as usize); + player.shape(&ring, &pen, &brush); + } + // POLYPOLYGON: counts, then every ring's points + 0x0538 => { + let rings = s(0) as usize; + let mut from = 1 + rings; + for k in 0..rings { + let count = s(1 + k) as usize; + let ring = points(from, count); + player.shape(&ring, &pen, &brush); + from += 2 * count; + } + } + // RECTANGLE, ROUNDRECT (bottom, right, top, left; corner + // ellipse first for the latter) + 0x041B | 0x061C => { + let o = if *function == 0x061C { 2 } else { 0 }; + let (b, r, t, l) = (s(o), s(o + 1), s(o + 2), s(o + 3)); + player.shape(&[[l, t], [r, t], [r, b], [l, b]], &pen, &brush); + } + // ELLIPSE: its outline in the pen + 0x0418 => { + if !pen.null { + let bounds = [s(3), s(2), s(1), s(0)]; + let ring = ellipse_points(bounds, 0.0, std::f64::consts::TAU); + player.polyline(&ring, false, pen.width, pen.rgb); + } + } + // ARC, PIE: (yEnd, xEnd, yStart, xStart, bottom, right, top, + // left), drawn counter-clockwise; a pie's outline starts at the + // centre. (CHORD records are not read.) + 0x0817 | 0x081A => { + let bounds = [s(7), s(6), s(5), s(4)]; + let arc = arc_points(bounds, [s(3), s(2)], [s(1), s(0)]); + if arc.is_empty() { + continue; + } + if *function == 0x0817 { + if !pen.null { + player.polyline(&arc, false, pen.width, pen.rgb); + } + } else { + let centre = [ + ((bounds[0] + bounds[2]) as i64).div_euclid(2) as f64, + ((bounds[1] + bounds[3]) as i64).div_euclid(2) as f64, + ]; + let ring: Vec<[f64; 2]> = std::iter::once(centre).chain(arc).collect(); + player.shape(&ring, &pen, &brush); + } + } + // TEXTOUT (count, string, y, x) and EXTTEXTOUT (y, x, count, + // options, [rectangle], string) + 0x0521 | 0x0A32 => { + let (count, text_at, (x, y)) = if *function == 0x0521 { + let count = s(0) as usize; + let words = count.div_ceil(2); + (count, 1, (s(2 + words), s(1 + words))) + } else { + let count = s(2) as usize; + let clipped = p.get(3).is_some_and(|o| o & 0x0006 != 0); + (count, if clipped { 8 } else { 4 }, (s(1), s(0))) + }; + let mut raw = Vec::new(); + for w in p.iter().skip(text_at) { + raw.extend(w.to_le_bytes()); + } + raw.truncate(count); + let value: String = raw.iter().map(|b| char::from(*b)).collect(); + if value.trim().is_empty() { + continue; + } + // The reference point is the cell's top; the baseline sits + // the font's ascent below it, and the text is four fifths + // of that ascent tall. + let rotation = font.escapement.to_radians(); + let drop = ascent(&font); + let base = player.at(x + drop * rotation.sin(), y + drop * rotation.cos()); + let face = Face { + name: font.face.clone(), + bold: font.weight >= 600, + italic: font.italic, + }; + let index = match player.fonts.iter().position(|f| *f == face) { + Some(k) => k, + None => { + player.fonts.push(face); + player.fonts.len() - 1 + } + }; + let mut text = Text::new(); + text.value = value; + text.insertion_point = base; + text.height = 0.8 * drop * scale; + text.rotation = rotation; + text.common.color = color(text_rgb); + player.text_fonts.push((player.entities.len(), index)); + player.entities.push(EntityType::Text(text)); + } + _ => {} + } + } + Some(Picture { + entities: player.entities, + fonts: player.fonts, + text_fonts: player.text_fonts, + }) +} diff --git a/src/locale_catalog.rs b/src/locale_catalog.rs index 16904c85e..fa7f4b9e0 100644 --- a/src/locale_catalog.rs +++ b/src/locale_catalog.rs @@ -3477,10 +3477,6 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static "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")), @@ -3505,6 +3501,7 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static "Import File" => Some(("dgn-import", "import-file")), "DGN Import" => Some(("dgn-import", "dgn-import")), "Import DGN File" => Some(("dgn-import", "import-dgn-file")), + "Import WMF" => Some(("dgn-import", "import-wmf")), "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")), @@ -4429,6 +4426,7 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static "DGN Files" => Some(("common", "dgn-files")), "Select DWF File" => Some(("common", "select-dwf-file")), "Select DGN File" => Some(("common", "select-dgn-file")), + "Select ACIS File" => Some(("common", "select-acis-file")), "PDFATTACH: {e}" => Some(("common", "pdfattach")), "Select External Reference File" => Some(("common", "select-external-reference-file")), "CAD Files" => Some(("common", "cad-files")), diff --git a/src/mcp.rs b/src/mcp.rs index 31e64499c..5f4b64017 100644 --- a/src/mcp.rs +++ b/src/mcp.rs @@ -32,7 +32,7 @@ const MODERN_PROTOCOL_VERSION: &str = "2026-07-28"; /// /// `block_delete` gained its optional `force` parameter. #[cfg(test)] -const TOOL_SCHEMA_DIGEST: &str = "baab80e34177ea4c20ae859fbf4f66aa82e612fe69062ab6389779a3c5bf7d93"; +const TOOL_SCHEMA_DIGEST: &str = "d15005e14fc00154501db5d261cb9febae1bcc61f6d9b850e91b7fd1879c3b8e"; const MAX_REQUEST: usize = 1_048_576; const MAX_RESPONSE: u64 = 16 * 1024 * 1024; const CACHE_TTL_MS: u64 = 3_600_000; diff --git a/src/modules/insert/mod.rs b/src/modules/insert/mod.rs index 11385add7..8946a6a18 100644 --- a/src/modules/insert/mod.rs +++ b/src/modules/insert/mod.rs @@ -9,6 +9,7 @@ mod content_browser; pub(crate) mod create_block; mod design_center; pub(crate) mod dgn_import; +pub(crate) mod tds_import; pub(crate) mod edit_block; pub(crate) mod image_transparency; pub(crate) mod insert_block; diff --git a/src/modules/insert/tds_import.rs b/src/modules/insert/tds_import.rs new file mode 100644 index 000000000..a98e12880 --- /dev/null +++ b/src/modules/insert/tds_import.rs @@ -0,0 +1,132 @@ +// 3D Studio import: when the import is set to ask what a mesh with several +// materials becomes, this asks for each such mesh in turn. +// +// has multiple materials assigned. [Split/First/Material] : +// Select a material or [None] : + +use std::collections::HashMap; + +use crate::command::{CadCommand, CmdOption, CmdResult, InputKind}; +use crate::io::tds_import::{LayerMode, MultiMaterial}; + +/// What one 3D Studio import reads and how. +#[derive(Clone, Debug, PartialEq)] +pub struct TdsImportRequest { + pub path: String, + /// The objects to import, by name ("Global Settings" included). + pub selected: Vec, + pub layers: LayerMode, + /// The choice for each mesh with several materials, by name. + pub multi: HashMap, + /// Meshes keep only the vertices their faces use (the import splits or + /// asks), else the file's vertex list. + pub compact: bool, +} + +pub struct TdsMaterialCommand { + request: TdsImportRequest, + /// The meshes still to ask about, with their materials. + queue: Vec<(String, Vec)>, + choosing: bool, +} + +impl TdsMaterialCommand { + pub fn new(request: TdsImportRequest, queue: Vec<(String, Vec)>) -> Self { + Self { + request, + queue, + choosing: false, + } + } + + fn answer(&mut self, choice: MultiMaterial) -> CmdResult { + if self.queue.is_empty() { + return CmdResult::Cancel; + } + let (name, _) = self.queue.remove(0); + self.request.multi.insert(name, choice); + self.choosing = false; + if self.queue.is_empty() { + CmdResult::TdsImport(self.request.clone()) + } else { + CmdResult::NeedPoint + } + } +} + +impl CadCommand for TdsMaterialCommand { + fn name(&self) -> &'static str { + "IMPORT" + } + + fn prompt(&self) -> String { + match (self.choosing, self.queue.first()) { + (true, _) => "Select a material or [None] :".to_string(), + (false, Some((name, _))) => { + format!("{name} has multiple materials assigned. [Split/First/Material] :") + } + (false, None) => String::new(), + } + } + + fn options(&self) -> Vec { + if self.choosing { + let mut options = vec![CmdOption::new("None", "N")]; + if let Some((_, materials)) = self.queue.first() { + options.extend(materials.iter().map(|m| CmdOption::new(m, m))); + } + options + } else { + vec![ + CmdOption::new("Split", "S"), + CmdOption::new("First", "F"), + CmdOption::new("Material", "M"), + ] + } + } + + fn input_kind(&self) -> InputKind { + if self.choosing { + InputKind::FreeText + } else { + InputKind::SingleToken + } + } + + fn on_point(&mut self, _pt: glam::DVec3) -> CmdResult { + CmdResult::NeedPoint + } + + fn on_text_input(&mut self, text: &str) -> Option { + let text = text.trim(); + if self.choosing { + if text.is_empty() || text.eq_ignore_ascii_case("None") || text.eq_ignore_ascii_case("N") { + return Some(self.answer(MultiMaterial::Material(None))); + } + let found = self + .queue + .first() + .and_then(|(_, materials)| materials.iter().find(|m| m.eq_ignore_ascii_case(text))) + .cloned(); + return Some(match found { + Some(material) => self.answer(MultiMaterial::Material(Some(material))), + None => CmdResult::ReportError("Invalid option keyword.".to_string()), + }); + } + let t = text.to_ascii_uppercase(); + Some(match t.as_str() { + "" => self.answer(MultiMaterial::Split), + _ if "SPLIT".starts_with(&t) => self.answer(MultiMaterial::Split), + _ if "FIRST".starts_with(&t) => self.answer(MultiMaterial::First), + _ if "MATERIAL".starts_with(&t) => { + self.choosing = true; + CmdResult::NeedPoint + } + _ => CmdResult::ReportError("Invalid option keyword.".to_string()), + }) + } + + fn on_enter(&mut self) -> CmdResult { + self.on_text_input("").unwrap_or(CmdResult::NeedPoint) + } +} diff --git a/src/scene/camera_ops.rs b/src/scene/camera_ops.rs index c286840b3..3edb0a8f5 100644 --- a/src/scene/camera_ops.rs +++ b/src/scene/camera_ops.rs @@ -615,6 +615,17 @@ impl Scene { true } + /// The plan view's window: its centre and its width and height in world + /// units, and the world size of one screen pixel (zero before the first + /// frame). + pub fn view_window(&self) -> ([f64; 2], [f64; 2], f64) { + let aspect = f64::from(self.active_camera_aspect()); + let camera = self.camera.borrow(); + let height = 2.0 * f64::from(camera.ortho_size()); + let pixel = self.world_per_pixel().map_or(0.0, f64::from); + ([camera.target.x, camera.target.y], [height * aspect, height], pixel) + } + /// Aspect ratio of the active camera's actual render rectangle. Model /// panes are separate shader widgets, so the full-canvas render aspect is /// wrong whenever a tiled viewport is active. @@ -1784,6 +1795,21 @@ impl Scene { fold!(Some((lo.min(hi), lo.max(hi)))); } } + // Solids inside blocks draw as one mesh instance per insert, placed + // where the insert puts them: that is the content the skipped insert + // marker stands for. + for set in self.instanced_block_meshes( + block, + None, + scale, + self.annotation_all_visible(), + None, + None, + ) { + if let Some([x0, y0, z0, x1, y1, z1]) = set.instance_aabb { + fold!(Some((glam::DVec3::new(x0, y0, z0), glam::DVec3::new(x1, y1, z1)))); + } + } // Raster images, OLE frames and underlays draw as GPU quads; with // their frames hidden they carry no wire outline at all. diff --git a/src/scene/convert/acis_import.rs b/src/scene/convert/acis_import.rs new file mode 100644 index 000000000..e88746a2f --- /dev/null +++ b/src/scene/convert/acis_import.rs @@ -0,0 +1,168 @@ +//! ACIS text (SAT) files and kernel bodies as drawing objects. +//! +//! Each body of a file becomes one object: a closed solid a 3DSOLID, a sheet +//! whose faces all lie on planes a REGION, any other sheet a SURFACE. + +use codec::entities::acis::{SatDocument, SatFace, Sidedness}; +use codec::entities::{AcisData, Region, Solid3D, Surface, SurfaceKind}; +use codec::EntityType; + +/// What a body restores as. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Class { + Solid, + Region, + Surface, +} + +fn class(document: &SatDocument) -> Option { + let faces: Vec> = document + .records + .iter() + .filter_map(SatFace::from_record) + .collect(); + if faces.is_empty() { + return None; + } + if faces + .iter() + .all(|face| face.sidedness() != Sidedness::Double) + { + return Some(Class::Solid); + } + let planar = faces.iter().all(|face| { + face.surface() + .index() + .and_then(|at| document.record(at)) + .is_some_and(|surface| surface.entity_type == "plane-surface") + }); + Some(if planar { Class::Region } else { Class::Surface }) +} + +/// One body's document as its drawing object. +fn entity(document: &SatDocument) -> Option { + entity_as(document, class(document)?) +} + +fn entity_as(document: &SatDocument, class: Class) -> Option { + Some(match class { + Class::Solid => { + let mut solid = Solid3D::new(); + solid.set_sat_document(document); + EntityType::Solid3D(solid) + } + Class::Region => { + let mut region = Region::new(); + region.set_sat_document(document); + EntityType::Region(region) + } + Class::Surface => { + let mut surface = Surface::new(SurfaceKind::Generic); + surface.acis_data = AcisData::from_sat(&document.to_sat_string()); + EntityType::Surface(Box::new(surface)) + } + }) +} + +/// The objects of an ACIS text file, one per body with faces; `None` when +/// the text is no ACIS data. +pub fn sat_entities(text: &str) -> Option> { + let document = SatDocument::parse(text).ok()?; + Some(document.split_bodies().iter().filter_map(entity).collect()) +} + +/// The value of an ACIS colour attribute: the index (0 by block), or the +/// index nearest a true colour. +fn attribute_index(color: codec::types::Color) -> i64 { + match color { + codec::types::Color::Index(index) => i64::from(index), + codec::types::Color::Rgb { r, g, b } => i64::from(codec::types::nearest_aci(r, g, b)), + _ => 0, + } +} + +/// A true colour packed as a 32-bit colour value: the true-colour flag +/// byte, then red, green and blue. +pub(crate) fn packed_true_colour(r: u8, g: u8, b: u8) -> i32 { + ((0xC2u32 << 24) | (u32::from(r) << 16) | (u32::from(g) << 8) | u32::from(b)) as i32 +} + +/// Hangs colour attributes on a record: a true colour first, then its +/// index, as imported models carry them. +fn paint_record(document: &mut codec::entities::acis::SatDocument, owner: usize, color: codec::types::Color) { + use codec::entities::acis::{SatPointer, SatRecord, SatToken}; + let mut chain: Vec<(&str, i64)> = Vec::new(); + if let codec::types::Color::Rgb { r, g, b } = color { + chain.push(("truecolor-adesk-attrib", i64::from(packed_true_colour(r, g, b)))); + } + chain.push(("color-adesk-attrib", attribute_index(color))); + let Some(existing) = document.record(owner).map(|record| record.attribute) else { + return; + }; + let first = document.record_count() as i32; + let count = chain.len() as i32; + for (k, (kind, value)) in chain.into_iter().enumerate() { + let at = first + k as i32; + let next = if k as i32 + 1 < count { SatPointer::new(at + 1) } else { existing }; + let previous = if k == 0 { SatPointer::NULL } else { SatPointer::new(at - 1) }; + let mut record = SatRecord::new(-1, kind); + record.tokens = vec![ + SatToken::Pointer(next), + SatToken::Pointer(previous), + SatToken::Pointer(SatPointer::new(owner as i32)), + SatToken::Integer(value), + ]; + document.add_record(record); + } + if let Some(record) = document.record_mut(owner) { + record.attribute = SatPointer::new(first); + } +} + +/// A kernel body as its drawing object in `colour` (by block when `None`), +/// with the faces given a colour of their own carrying it, and the edges +/// bounding those faces carrying it too. A `sheet` is a SURFACE however +/// flat it is. +pub fn painted_body_entity( + body: &kernel::brep::Body, + colour: Option, + faces: &[Option], + sheet: bool, +) -> Option { + let mut document = super::acis_export::solid_to_sat(body)?; + let base = colour.unwrap_or(codec::types::Color::ByBlock); + let own: std::collections::HashMap = body + .faces + .keys() + .zip(faces) + .filter_map(|(key, color)| Some((key, (*color)?))) + .collect(); + // The writer puts faces and edges in the body's own order. + let face_records: Vec = (0..document.record_count()) + .filter(|&at| document.record(at).is_some_and(|r| r.entity_type == "face")) + .collect(); + let edge_records: Vec = (0..document.record_count()) + .filter(|&at| document.record(at).is_some_and(|r| r.entity_type == "edge")) + .collect(); + for (key, at) in body.faces.keys().zip(face_records) { + if let Some(color) = own.get(&key) { + paint_record(&mut document, at, *color); + } + } + for ((_, edge), at) in body.edges.iter().zip(edge_records) { + let bounding = edge.coedges.iter().find_map(|coedge| { + let ring = body.coedges.get(*coedge)?.owner; + own.get(&body.loops.get(ring)?.owner).copied() + }); + if let Some(color) = bounding { + paint_record(&mut document, at, color); + } + } + let mut entity = if sheet { + entity_as(&document, Class::Surface)? + } else { + entity(&document)? + }; + entity.common_mut().color = base; + Some(entity) +} diff --git a/src/scene/convert/acis_kernel.rs b/src/scene/convert/acis_kernel.rs index c37992c28..0ec93b26a 100644 --- a/src/scene/convert/acis_kernel.rs +++ b/src/scene/convert/acis_kernel.rs @@ -70,13 +70,24 @@ pub fn tessellate_sat( (owner >= 0 && handle > 0).then(|| (owner, codec::Handle::new(handle as u64))) }) .collect(); + // A true colour attribute outranks the index one beside it, which only + // holds its nearest index. let face_colors: std::collections::HashMap = document .records .iter() .filter(|record| record.entity_type == "color-adesk-attrib") + .chain( + document + .records + .iter() + .filter(|record| record.entity_type == "truecolor-adesk-attrib"), + ) .filter_map(|record| { let owner = record.token_pointer(2)?.0; - let value = record.token(3)?.as_integer()?; + let mut value = record.token(3)?.as_integer()?; + if record.entity_type == "truecolor-adesk-attrib" { + value = (value & 0x00FF_FFFF) | 0x0100_0000; + } let source = if (1..=255).contains(&value) { codec::Color::from_index(value as i16) } else if value > 257 { diff --git a/src/scene/convert/mod.rs b/src/scene/convert/mod.rs index 842532517..4ca327429 100644 --- a/src/scene/convert/mod.rs +++ b/src/scene/convert/mod.rs @@ -1,5 +1,6 @@ pub mod acad_to_render; pub mod acis_export; +pub mod acis_import; pub mod acis_kernel; pub mod dgn_linestyle; pub mod proxy_graphics; diff --git a/src/scene/entity.rs b/src/scene/entity.rs index ac89c65c6..5b9af9763 100644 --- a/src/scene/entity.rs +++ b/src/scene/entity.rs @@ -1349,6 +1349,9 @@ impl Scene { .map_err(|e| e.to_string())?; entity_handles.push(handle); } + // Solids, surfaces and meshes of a definition draw through their + // instanced block meshes, which only a tessellation pass creates. + self.refresh_meshes_for_handles(&entity_handles); // Block defns don't render on their own, but the geometry cache must // pick up the new definition so the interactive insert can preview it. self.bump_geometry(); @@ -1391,12 +1394,14 @@ impl Scene { block_end.common.owner_handle = br_handle; let _ = self.document.add_entity(EntityType::BlockEnd(block_end)); + let mut added = Vec::new(); for mut entity in entities { Self::reset_clone_subhandles(&mut self.document, &mut entity); entity.common_mut().handle = Handle::NULL; entity.common_mut().owner_handle = br_handle; - let _ = self.document.add_entity(entity); + added.extend(self.document.add_entity(entity).ok()); } + self.refresh_meshes_for_handles(&added); self.bump_geometry(); } @@ -1446,18 +1451,21 @@ impl Scene { }) .map(|e| e.common().handle) .collect(); - for h in owned { - self.document.remove_entity(h); + for h in &owned { + self.document.remove_entity(*h); } if let Some(EntityType::Block(block)) = self.document.get_entity_mut(block_handle) { block.base_point = base_point; } + // The old contents' meshes go with them; the new ones are built. + let mut changed = owned; for mut entity in entities { Self::reset_clone_subhandles(&mut self.document, &mut entity); entity.common_mut().handle = Handle::NULL; entity.common_mut().owner_handle = br_handle; - let _ = self.document.add_entity(entity); + changed.extend(self.document.add_entity(entity).ok()); } + self.refresh_meshes_for_handles(&changed); self.bump_geometry(); Ok(()) } diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 0b2958a82..3297b5433 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -1208,6 +1208,20 @@ pub(crate) fn valid_block_name(name: &str) -> bool { }) } +/// `raw` made fit to name a table entry (a block, layer, style or view): +/// characters [`valid_block_name`] refuses become `_`, the name is cut to +/// 255 characters, and `fallback` stands in when nothing is left. For names +/// that come from outside files. +pub(crate) fn sanitized_table_name(raw: &str, fallback: &str) -> String { + let name: String = raw + .trim() + .chars() + .map(|c| if valid_block_name(c.encode_utf8(&mut [0; 4])) { c } else { '_' }) + .take(255) + .collect(); + block_service::validate_block_name(&name).unwrap_or_else(|_| fallback.to_string()) +} + /// Pre-built entity caches returned by [`build_derived_caches`]. /// Produced in the file-load background task so the UI thread only assigns. #[derive(Debug, Clone)] diff --git a/src/scene/text/sysfont.rs b/src/scene/text/sysfont.rs index a74de6122..ae0b3ed96 100644 --- a/src/scene/text/sysfont.rs +++ b/src/scene/text/sysfont.rs @@ -292,6 +292,22 @@ fn face_id_weighted(family: &str, weight: fontdb::Weight) -> Option fonts().db.lock().unwrap().query(&query) } +/// The file name (lower case) of the installed face of exactly `family` — +/// no stand-in family — in the bold and italic asked for, or `None`. +pub fn face_file(family: &str, bold: bool, italic: bool) -> Option { + let db = fonts().db.lock().unwrap(); + let id = db.query(&fontdb::Query { + families: &[fontdb::Family::Name(family)], + weight: if bold { fontdb::Weight::BOLD } else { fontdb::Weight::NORMAL }, + style: if italic { fontdb::Style::Italic } else { fontdb::Style::Normal }, + ..Default::default() + })?; + match &db.face(id)?.source { + fontdb::Source::File(path) => Some(path.file_name()?.to_string_lossy().to_ascii_lowercase()), + _ => None, + } +} + /// Borrow the raw face bytes for `family` and run `f` over them. The byte slice /// is only valid inside the closure, so callers extract everything they need /// (e.g. flattened glyph outlines) before returning. `index` is the face index diff --git a/src/ui/window/import_dialogs.rs b/src/ui/window/import_dialogs.rs index 48b010e9e..f98136c9c 100644 --- a/src/ui/window/import_dialogs.rs +++ b/src/ui/window/import_dialogs.rs @@ -1,6 +1,8 @@ //! 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). +//! format's importer), DGN Import (DGNIMPORT: model, target drawing, +//! conversion units and the mapping setup's effect on the file) and 3D Studio +//! Import (the objects of a .3ds file to bring in, their layers and what a +//! mesh with several materials becomes). use iced::widget::{button, column, container, image, row, scrollable, text, text_input, Space}; use iced::{Background, Border, Element, Fill, Length, Theme}; @@ -34,6 +36,17 @@ pub enum ImportDialogMsg { DgnExplode(bool), DgnMappingTab(usize), DgnOk, + // 3D Studio Import + TdsChange, + TdsPicked(Option), + TdsCheck(usize, bool), + TdsAll, + TdsClear, + TdsFilter(usize), + TdsSearch(String), + TdsLayers(usize), + TdsMulti(usize), + TdsOk, Help(&'static str), } @@ -50,10 +63,6 @@ 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, } @@ -87,9 +96,9 @@ 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("Metafile", "*.wmf", &["wmf"], 0, Support::Yes), + format("3D Studio", "*.3ds", &["3ds"], 1, Support::Yes), + format("ACIS", "*.sat", &["sat"], 1, Support::Yes), format( "CATIA V4", "*.model; *.session; *.exp; *.dlv3", @@ -104,7 +113,7 @@ pub const FORMATS: [ImportFormat; 21] = [ 1, Support::No, ), - format("IGES", "*.igs; *.iges", &["igs", "iges"], 1, Support::No), + format("IGES", "*.igs; *.iges", &["igs", "iges"], 1, Support::Yes), format("Inventor", "*.ipt; *.iam", &["ipt", "iam"], 1, Support::No), format("JT", "*.jt", &["jt"], 1, Support::No), format("NX", "*.prt", &["prt"], 1, Support::No), @@ -121,7 +130,7 @@ pub const FORMATS: [ImportFormat; 21] = [ 1, Support::No, ), - format("STEP", "*.ste; *.stp; *.step", &["ste", "stp", "step"], 1, Support::ExportOnly), + format("STEP", "*.ste; *.stp; *.step", &["ste", "stp", "step"], 1, Support::Yes), format("All Files", "*.*", &["*"], 2, Support::ByExtension), ]; @@ -150,11 +159,29 @@ pub struct DgnImportState { pub mapping: [Vec<(String, String)>; 4], } -/// Both windows' working copies and the files imported this session. +/// The objects of a 3D Studio file and the import's choices. +pub struct TdsImportState { + pub path: String, + /// "Global Settings" first, then the file's objects by name: each name, + /// its kind (Mesh, Light, Camera or Global) and the materials it uses. + pub objects: Vec<(String, &'static str, usize)>, + pub materials: usize, + pub checked: Vec, + /// 0 all, 1 meshes, 2 lights, 3 cameras, 4 global. + pub filter: usize, + pub search: String, + /// 0 by object, 1 by material, 2 by object colour, 3 single layer. + pub layers: usize, + /// 0 always ask, 1 split by material, 2 first material, 3 no material. + pub multi: usize, +} + +/// The windows' working copies and the files imported this session. #[derive(Default)] pub struct ImportDialogs { pub file: Option, pub dgn: Option, + pub tds: Option, pub recent: Vec, } @@ -178,13 +205,10 @@ 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( @@ -210,10 +234,6 @@ fn badge<'a>(support: Support) -> Element<'a, Message> { 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.")), } } @@ -425,6 +445,212 @@ pub fn view_import_file<'a>( .into() } +/// One choice out of a few, as buttons stacked one above the other. +fn stacked<'a>(options: Vec<(String, bool, Message)>) -> Element<'a, Message> { + let buttons = options.into_iter().map(|(label, on, message)| { + button(text(label).size(11).width(Fill)) + .on_press(message) + .style(button_style(on)) + .padding([5, 10]) + .width(Fill) + .into() + }); + container(iced::widget::Column::with_children(buttons.collect::>()).spacing(2)) + .padding(2) + .width(Fill) + .style(well_style) + .into() +} + +/// The kind of a 3D Studio object in the window's words. +fn tds_kind(kind: &str) -> String { + match kind { + "Mesh" => crate::tr!("import-3ds", "mesh"), + "Light" => crate::tr!("import-3ds", "light"), + "Camera" => crate::tr!("import-3ds", "camera"), + _ => crate::tr!("import-3ds", "general"), + } +} + +pub fn view_tds_import<'a>( + state: &'a TdsImportState, + 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(crate::tr!("import-3ds", "summary", objects = (state.objects.len().saturating_sub(1) as i64), materials = (state.materials as i64))) + .size(11) + .style(muted_style) + .width(Fill), + button(text(crate::tr!("import-3ds", "change")).size(11)) + .on_press(msg(ImportDialogMsg::TdsChange)) + .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 kinds = ["", "Mesh", "Light", "Camera", "Global"]; + let mut filters = row![].spacing(4).align_y(iced::Center); + for (k, label) in [crate::tr!("import-3ds", "all"), crate::tr!("import-3ds", "mesh"), crate::tr!("import-3ds", "light"), crate::tr!("import-3ds", "camera"), crate::tr!("import-3ds", "general")] + .into_iter() + .enumerate() + { + filters = filters.push(chip(label, state.filter == k, msg(ImportDialogMsg::TdsFilter(k)))); + } + let search = state.search.trim().to_lowercase(); + let mut rows = column![row![ + Space::new().width(Length::Fixed(22.0)), + text(crate::tr!("import-3ds", "name")).size(11).style(muted_style).width(Fill), + text(crate::tr!("import-3ds", "type")).size(11).style(muted_style).width(Length::Fixed(150.0)), + ]] + .spacing(2); + for (k, (name, kind, materials)) in state.objects.iter().enumerate() { + let global = *kind == "Global"; + if state.filter != 0 && kinds[state.filter] != *kind { + continue; + } + if !search.is_empty() && !name.to_lowercase().contains(&search) { + continue; + } + let shown_name = if global { crate::tr!("import-3ds", "global") } else { name.clone() }; + let shown_kind = if global { + String::new() + } else if *kind == "Mesh" { + format!("{} · {}", tds_kind(kind), crate::tr!("import-3ds", "materials", n = (*materials as i64))) + } else { + tds_kind(kind) + }; + let on = state.checked.get(k).copied().unwrap_or(false); + rows = rows.push( + row![ + iced::widget::checkbox(on) + .on_toggle(move |v| msg(ImportDialogMsg::TdsCheck(k, v))) + .size(14), + text(shown_name).size(12).width(Fill), + text(shown_kind).size(11).style(muted_style).width(Length::Fixed(150.0)), + ] + .spacing(8) + .align_y(iced::Center), + ); + } + let objects = card( + crate::tr!("import-3ds", "objects"), + column![ + row![ + filters, + Space::new().width(Fill), + text_input(crate::tr!("import-3ds", "search").as_ref(), &state.search) + .on_input(|s| msg(ImportDialogMsg::TdsSearch(s))) + .size(11) + .padding([4, 8]) + .width(Length::Fixed(170.0)) + .style(field_style), + ] + .spacing(8) + .align_y(iced::Center), + container(scrollable(rows).height(Length::Fixed(300.0))) + .padding(6) + .width(Fill) + .style(well_style), + row![ + button(text(crate::tr!("import-3ds", "select-all")).size(11)) + .on_press(msg(ImportDialogMsg::TdsAll)) + .style(button_style(false)) + .padding([4, 10]), + button(text(crate::tr!("import-3ds", "clear")).size(11)) + .on_press(msg(ImportDialogMsg::TdsClear)) + .style(button_style(false)) + .padding([4, 10]), + ] + .spacing(6), + ] + .spacing(8), + ); + let layer_notes = [ + crate::tr!("import-3ds", "note-object"), + crate::tr!("import-3ds", "note-material"), + crate::tr!("import-3ds", "note-color"), + crate::tr!("import-3ds", "note-single"), + ]; + let layers = card( + crate::tr!("import-3ds", "layers"), + column![ + stacked( + [crate::tr!("import-3ds", "by-object"), crate::tr!("import-3ds", "by-material"), crate::tr!("import-3ds", "by-color"), crate::tr!("import-3ds", "single")] + .into_iter() + .enumerate() + .map(|(k, label)| (label, state.layers == k, msg(ImportDialogMsg::TdsLayers(k)))) + .collect() + ), + text(layer_notes[state.layers.min(3)].clone()).size(11).style(muted_style), + ] + .spacing(8), + ); + let multi = card( + crate::tr!("import-3ds", "multi"), + stacked( + [ + crate::tr!("import-3ds", "ask"), + crate::tr!("import-3ds", "split"), + crate::tr!("import-3ds", "first"), + crate::tr!("import-3ds", "none"), + ] + .into_iter() + .enumerate() + .map(|(k, label)| (label, state.multi == k, msg(ImportDialogMsg::TdsMulti(k)))) + .collect(), + ), + ); + let any = state.checked.iter().any(|c| *c); + let mut actions = row![ + button(text("?").size(12)) + .on_press(msg(ImportDialogMsg::Help("3ds"))) + .style(button_style(false)) + .padding([5, 11]), + Space::new().width(Fill), + ] + .spacing(6) + .align_y(iced::Center); + if !any { + actions = actions.push(text(crate::tr!("import-3ds", "reason")).size(11).style(muted_style)); + } + actions = actions + .push(dialog_button(crate::tr!("import-3ds", "cancel"), Message::CloseModal, false)) + .push(dialog_button_styled_opt( + crate::tr!("import-3ds", "import"), + any.then(|| msg(ImportDialogMsg::TdsOk)), + button::primary, + )); + column![ + top, + row![ + container(objects).width(Length::FillPortion(3)), + column![layers, multi].spacing(10).width(Length::FillPortion(2)), + ] + .spacing(10), + actions, + ] + .spacing(10) + .padding([10, 12]) + .width(sizing.width) + .into() +} + pub fn view_dgn_import<'a>( state: &'a DgnImportState, sizing: crate::ui::modal::ModalSizing,