diff --git a/src/Mod/BIM/importers/importDAE.py b/src/Mod/BIM/importers/importDAE.py index 6ab0e76da3..8d787d2ed5 100644 --- a/src/Mod/BIM/importers/importDAE.py +++ b/src/Mod/BIM/importers/importDAE.py @@ -34,6 +34,7 @@ __url__ = "https://www.freecad.org" import os from typing import Optional +from xml.sax.saxutils import escape as sax_escape import numpy as np @@ -53,29 +54,35 @@ else: return text # \endcond -DEBUG = True + +def xml_escape(text: str, entities: dict[str, str] = None) -> str: + """Escape text for XML. + + This is a wrapper around xml.sax.saxutils.escape that replaces also + `"` with `"` by default. + """ + if entities is None: + entities = {'"': """} + return sax_escape(text, entities=entities) -def check_collada_import() -> bool: +def import_collada() -> bool: """Return True if the `collada` module is available. Also imports the module. """ - global collada try: import collada except ImportError: FreeCAD.Console.PrintError(translate("BIM", "pycollada not found, collada support is disabled.") + "\n") return False - else: - return True + return True def triangulate(shape): """Triangulate the given shape.""" - mesher = params.get_param_arch("ColladaMesher") tessellation = params.get_param_arch("ColladaTessellation") grading = params.get_param_arch("ColladaGrading") @@ -102,8 +109,7 @@ def triangulate(shape): def open(filename): """Called when FreeCAD wants to open a file.""" - - if not check_collada_import(): + if not import_collada(): return docname = os.path.splitext(os.path.basename(filename))[0] doc = FreeCAD.newDocument(docname) @@ -115,8 +121,7 @@ def open(filename): def insert(filename, docname): """Called when FreeCAD wants to import a file.""" - - if not check_collada_import(): + if not import_collada(): return try: doc = FreeCAD.getDocument(docname) @@ -129,50 +134,84 @@ def insert(filename, docname): def read(filename): """Read a DAE file.""" - + doc = FreeCAD.activeDocument() + if not doc: + return col = collada.Collada(filename, ignore=[collada.common.DaeUnsupportedError]) # Read the unitmeter info from DAE file and compute unit to convert to mm. - unitmeter = col.assetInfo.unitmeter or 1 - unit = unitmeter / 0.001 - # for geom in col.geometries: - # for geom in col.scene.objects("geometry"): - for node in col.scene.nodes: - for child in node.children: - if not isinstance(child, collada.scene.GeometryNode): + unit_meter = col.assetInfo.unitmeter or 1.0 + unit = unit_meter / 0.001 + bound_geom: collada.geometry.BoundGeometry + # Implementation note: there's also `col.geometries` but when using them, + # the materials are string and Gaƫl didn't find a way to get the material + # node from this string. + for bound_geom in col.scene.objects('geometry'): + prim: collada.primitive.BoundPrimitive + for prim in bound_geom.primitives(): + if not isinstance(prim, collada.triangleset.BoundTriangleSet): + # e.g. a BoundLineSet, which is not supported yet. continue - geom: collada.scenes.GeometryNode = child.geometry - mat_symbols: list[str] = [m.symbol for m in child.materials] - for prim in geom.primitives: - meshdata = [] - for tri in prim: - # tri.vertices is a numpy array. - meshdata.append((tri.vertices * unit).tolist()) - mesh = Mesh.Mesh(meshdata) - try: - name = geom.name - except AttributeError: - name = geom.id - obj = FreeCAD.ActiveDocument.addObject("Mesh::Feature", name) + # Get the materials and associated vertices. + meshes: dict[collada.scene.MaterialNode, list] = {} + tri: collada.triangleset.Triangle + for tri in prim: + material_node: collada.material.Material = tri.material + if material_node not in meshes: + # Not yet in the dict, create a new entry. + meshes[material_node] = [] + if len(tri.vertices) != 3: + msg = ( + f"Warning: triangle with {len(tri.vertices)} vertices found" + f" in {bound_geom.original.name}, expected 3. Skipping this triangle." + ) + FreeCAD.Console.PrintWarning(msg + "\n") + continue + # tri.vertices is a numpy array. + meshes[material_node].append((tri.vertices * unit).tolist()) + # Create a mesh for each material node. + for material_node, vertices in meshes.items(): + mesh = Mesh.Mesh(vertices) + name = bound_geom.original.name + if not name: + name = bound_geom.original.id + obj = doc.addObject("Mesh::Feature", name) obj.Label = name obj.Mesh = mesh + if not material_node: + continue if FreeCAD.GuiUp: - try: - mat_index = mat_symbols.index(prim.material) - material = child.materials[mat_index].target - color = material.effect.diffuse - obj.ViewObject.ShapeColor = color - except ValueError: - # Material not found. - pass - except TypeError: - # color is not a tuple but a texture. - pass + fc_mat = FreeCAD.Material() + # We do not import transparency because it is often set + # wrongly (transparency mistaken for opacity). + # TODO: Ask whether to import transparency. + field_map = { + "ambient": "AmbientColor", + "diffuse": "DiffuseColor", + "emission": "EmissiveColor", + "specular": "SpecularColor", + "shininess": "Shininess", + # "transparency": "Transparency", + } + for col_field, fc_field in field_map.items(): + try: + # Implementation note: using floats, so values must + # be within [0, 1]. OK. + setattr(fc_mat, fc_field, getattr(material_node.effect, col_field)) + except ValueError: + # The collada value is not compatible with FreeCAD. + pass + except TypeError: + # color is not a tuple but a texture. + pass + obj.ViewObject.ShapeAppearance = (fc_mat,) # Print the errors that occurred during reading. if col.errors: FreeCAD.Console.PrintWarning(translate("BIM", "File was read but some errors occurred:") + "\n") for e in col.errors: FreeCAD.Console.PrintWarning(str(e) + "\n") + if FreeCAD.GuiUp: + FreeCAD.Gui.SendMsgToActiveView("ViewFit") def export( @@ -191,8 +230,7 @@ def export( mode if you want to be able to export colors. """ - - if not check_collada_import(): + if not import_collada(): return if colors is None: colors = {} @@ -207,12 +245,13 @@ def export( author = FreeCAD.ActiveDocument.CreatedBy except UnicodeEncodeError: author = FreeCAD.ActiveDocument.CreatedBy.encode("utf8") - author = author.replace("<", "") - author = author.replace(">", "") + author = xml_escape(author) col_contributor.author = author ver = FreeCAD.Version() appli = f"FreeCAD v{ver[0]}.{ver[1]} build {ver[2]}" col_contributor.authoring_tool = appli + # Bug in collada from 0.4 to 0.9, contributors are not written to file. + # Set it anyway for future versions. col_mesh.assetInfo.contributors.append(col_contributor) col_mesh.assetInfo.unitname = "meter" col_mesh.assetInfo.unitmeter = 1.0 @@ -269,7 +308,7 @@ def export( geom = collada.geometry.Geometry( collada=col_mesh, id=f"geometry{obj_ind}", - name=obj.Name, + name=xml_escape(obj.Label), sourcebyid=[vert_src, normal_src], ) input_list = collada.source.InputList() @@ -327,7 +366,7 @@ def export( ) col_mesh.effects.append(effect) col_mesh.materials.append(mat) - mat_ref = "ref_" + obj.Name + mat_ref = f"ref_{obj.Name}" mat_node = collada.scene.MaterialNode( symbol=mat_ref, target=mat,