diff --git a/source/__init__.py b/source/__init__.py index 0c39d6e..bf5eb90 100644 --- a/source/__init__.py +++ b/source/__init__.py @@ -162,6 +162,28 @@ class X3D_PT_export_include(bpy.types.Panel): col.enabled = not blender_version_higher_279 col.prop(operator, "use_h3d") +class X3D_PT_export_external_resource(bpy.types.Panel): + bl_space_type = 'FILE_BROWSER' + bl_region_type = 'TOOL_PROPS' + bl_label = "Image Textures" + bl_parent_id = "FILE_PT_operator" + + @classmethod + def poll(cls, context): + sfile = context.space_data + operator = sfile.active_operator + + return operator.bl_idname == "EXPORT_SCENE_OT_x3d" + + def draw(self, context): + layout = self.layout + layout.use_property_split = True + layout.use_property_decorate = False # No animation. + + sfile = context.space_data + operator = sfile.active_operator + + layout.prop(operator, "path_mode") class X3D_PT_export_transform(bpy.types.Panel): bl_space_type = 'FILE_BROWSER' @@ -198,6 +220,8 @@ class X3D_PT_export_transform(bpy.types.Panel): layout.prop(operator, "axis_up") + + class X3D_PT_export_geometry(bpy.types.Panel): bl_space_type = 'FILE_BROWSER' bl_region_type = 'TOOL_PROPS' @@ -419,6 +443,7 @@ classes = ( X3D_PT_export_include, X3D_PT_export_transform, X3D_PT_export_geometry, + X3D_PT_export_external_resource, ImportX3D, X3D_PT_import_general, X3D_PT_import_transform, diff --git a/source/export_x3d.py b/source/export_x3d.py index 898847d..f73d915 100644 --- a/source/export_x3d.py +++ b/source/export_x3d.py @@ -7,14 +7,18 @@ # - Doesn't handle multiple UV textures on a single mesh (create a mesh for each texture). # - Can't get the texture array associated with material * not the UV ones. +import logging +logger = logging.getLogger("export_x3d") + import math -import os + import bpy import mathutils from bpy_extras.io_utils import create_derived_objects +from .material_node_search import imageTexture_in_material # h3d defines H3D_TOP_LEVEL = 'TOP_LEVEL_TI' @@ -185,6 +189,8 @@ def h3d_is_object_view(scene, obj): return False + + # ----------------------------------------------------------------------------- # Functions for writing output file # ----------------------------------------------------------------------------- @@ -206,6 +212,7 @@ def export(file, name_decorations=True, ): + logger.info("export with path_mode: %r" % path_mode) # ------------------------------------------------------------------------- # Global Setup # ------------------------------------------------------------------------- @@ -259,6 +266,19 @@ def export(file, # store files to copy copy_set = set() + import os, os.path + if path_mode == 'COPY': + # create a per-export temporary folder. image data which does not + # exist on disk yet will be saved here from which it can be copied by + # the bpy_extras.io_utils.path_reference_copy utility + blender_tempfolder = bpy.app.tempdir + import uuid + temporary_image_directory = os.path.join(blender_tempfolder,uuid.uuid4().hex) + os.mkdir(temporary_image_directory) + logger.info("temporary_image_directory: %s" % temporary_image_directory ) + else: + temporary_image_directory = None + # store names of newly created meshes, so we dont overlap mesh_name_set = set() @@ -512,27 +532,6 @@ def export(file, is_coords_written = False - mesh_materials = mesh.materials[:] - if not mesh_materials: - mesh_materials = [None] - - mesh_material_tex = [None] * len(mesh_materials) - mesh_material_mtex = [None] * len(mesh_materials) - mesh_material_images = [None] * len(mesh_materials) - - for i, material in enumerate(mesh_materials): - if 0 and material: - for mtex in material.texture_slots: - if mtex: - tex = mtex.texture - if tex and tex.type == 'IMAGE': - image = tex.image - if image: - mesh_material_tex[i] = tex - mesh_material_mtex[i] = mtex - mesh_material_images[i] = image - break - # fast access! mesh_vertices = mesh.vertices[:] mesh_loops = mesh.loops[:] @@ -540,21 +539,14 @@ def export(file, mesh_polygons_materials = [p.material_index for p in mesh_polygons] mesh_polygons_vertices = [p.vertices[:] for p in mesh_polygons] - if len(set(mesh_material_images)) > 0: # make sure there is at least one image - mesh_polygons_image = [mesh_material_images[material_index] for material_index in mesh_polygons_materials] - else: - mesh_polygons_image = [None] * len(mesh_polygons) - mesh_polygons_image_unique = set(mesh_polygons_image) - # group faces polygons_groups = {} - for material_index in range(len(mesh_materials)): - for image in mesh_polygons_image_unique: - polygons_groups[material_index, image] = [] - del mesh_polygons_image_unique + for material_index in range(len(mesh.materials)): + polygons_groups[material_index] = [] - for i, (material_index, image) in enumerate(zip(mesh_polygons_materials, mesh_polygons_image)): - polygons_groups[material_index, image].append(i) + + for i, material_index in enumerate(mesh_polygons_materials): + polygons_groups[material_index].append(i) # Py dict are sorted now, so we can use directly polygons_groups.items() # and still get consistent reproducible outputs. @@ -587,9 +579,9 @@ def export(file, for ltri in mesh.loop_triangles: polygons_to_loop_triangles_indices[ltri.polygon_index].append(ltri) - for (material_index, image), polygons_group in polygons_groups.items(): + for material_index, polygons_group in polygons_groups.items(): if polygons_group: - material = mesh_materials[material_index] + material = mesh.materials[material_index] fw('%s\n' % ident) ident += '\t' @@ -627,33 +619,9 @@ def export(file, fw('%s\n' % ident) ident += '\t' - if image and not use_h3d: - writeImageTexture(ident, image) - - # transform by mtex - loc = mesh_material_mtex[material_index].offset[:2] - - # mtex_scale * tex_repeat - sca_x, sca_y = mesh_material_mtex[material_index].scale[:2] - - sca_x *= mesh_material_tex[material_index].repeat_x - sca_y *= mesh_material_tex[material_index].repeat_y - - # flip x/y is a sampling feature, convert to transform - if mesh_material_tex[material_index].use_flip_axis: - rot = math.pi / -2.0 - sca_x, sca_y = sca_y, -sca_x - else: - rot = 0.0 - - ident_step = ident + (' ' * (-len(ident) + - fw('%s\n') + imageTextureNode = imageTexture_in_material(material) + if imageTextureNode: + writeImageTexture(ident, imageTextureNode) if use_h3d: mat_tmp = material if material else gpu_shader_dummy_mat @@ -1237,8 +1205,12 @@ def export(file, fw('%s\n' % ident) - def writeImageTexture(ident, image): + def writeImageTexture(ident, imageTextureNode): + image=imageTextureNode.image image_id = quoteattr(unique_name(image, IM_ + image.name, uuid_cache_image, clean_func=clean_def, sep="_")) + logger.info("write ImageTexture X3D node for %r format %r filepath %r" % (image.name, image.file_format, image.filepath )) + + if image.tag: fw('%s\n' % (ident, image_id)) @@ -1249,24 +1221,70 @@ def export(file, fw('%s 0 + if path_mode == 'COPY' and ( has_packed_file or not image.filepath) : + # write to temporary folder so that + # bpy_extras.io_utils.path_reference_copy can + # copy it final location at end of export + use_file_format = image.file_format or 'PNG' - images = [f.replace('\\', '/') for f in images] - images = [f for i, f in enumerate(images) if f not in images[:i]] + try: + image_ext = {'JPEG':'.jpg' , 'PNG':'.png'}[use_file_format] + except KeyError: + # if image data not in JPEG or PNG it is not supported in + # X3D standard. If image file_format is "FFMPEG" that is a movie + # format, and would be referenced in an X3D MovieTexture node + if use_file_format == "FFMPEG" : + logger.warning("movie textures not supported for X3D export") + else: + logger.warning("image texture file format %r not supported" % use_file_format) + else: + image_base = os.path.splitext( os.path.basename(image.name))[0] + image_filename = image_base + image_ext - fw(ident_step + "url='%s'\n" % ' '.join(['"%s"' % escape(f) for f in images])) + filepath_full = os.path.join(temporary_image_directory, image_filename) + + logger.info("writing image for texture to %s" % filepath_full) + image.save( filepath = filepath_full ) + + filepath_ref = bpy_extras.io_utils.path_reference( + filepath_full, + temporary_image_directory, + base_dst, + path_mode, + COPY_SUBDIR, + copy_set, + image.library) + else: + filepath = image.filepath + filepath_full = bpy.path.abspath(filepath, library=image.library) + filepath_ref = bpy_extras.io_utils.path_reference( + filepath_full, + base_src, + base_dst, + path_mode, + COPY_SUBDIR, + copy_set, + image.library) + + image_urls = [ filepath_ref ] + + logger.info("node urls: %s" % (image_urls,)) + fw(ident_step + "url='%s'\n" % ' '.join(['"%s"' % escape(f) for f in image_urls])) + + # default value of repeatS, repeatT fields is true, so only need to + # specify if extension value is CLIP + x3d_supported_extension = ["CLIP", "REPEAT"] + if imageTextureNode.extension not in x3d_supported_extension: + logger.warning("imageTextureNode.extension value %s unsupported in X3D" % imageTextureNode.extension) + + if imageTextureNode.extension == "CLIP": + fw(ident_step + "repeatS='false' repeatT='false'") fw(ident_step + '/>\n') def writeBackground(ident, world): @@ -1513,9 +1531,12 @@ def export(file, if use_h3d: bpy.data.materials.remove(gpu_shader_dummy_mat) - # copy all collected files. - # print(copy_set) - bpy_extras.io_utils.path_reference_copy(copy_set) + if copy_set: + for c in copy_set: + logger.info("copy_set item %r" % copy_set) + bpy_extras.io_utils.path_reference_copy(copy_set) + else: + logger.info("no items in copy_set") print('Info: finished X3D export to %r' % file.name) @@ -1559,6 +1580,7 @@ def save(context, name_decorations=True ): + logger.info("save: context %r to filepath %r" % (context,filepath)) bpy.path.ensure_ext(filepath, '.x3dz' if use_compress else '.x3d') if bpy.ops.object.mode_set.poll(): diff --git a/source/material_node_search.py b/source/material_node_search.py new file mode 100644 index 0000000..88d1065 --- /dev/null +++ b/source/material_node_search.py @@ -0,0 +1,81 @@ +# SPDX-FileCopyrightText: 2024 Vincent Marchetti +# +# SPDX-License-Identifier: GPL-3.0-or-later + +import logging +_logger = logging.getLogger("export_x3d.material_node_search") + +""" +functions implementing searching the node tree for Blender materials in search +of particular nodes enabling export of material properties into other formats +""" + + +# values of bl_idname for shader nodes to be located +# reference Python API list of subclasses of ShaderNode +# at https://docs.blender.org/api/current/bpy.types.ShaderNode.html#bpy.types.ShaderNode +class _ShaderNodeTypes: + MATERIAL_OUTPUT= "ShaderNodeOutputMaterial" + BSDF_PRINCIPLED = "ShaderNodeBsdfPrincipled" + IMAGE_TEXTURE = "ShaderNodeTexImage" + + +def _find_node_by_idname(nodes, idname): + """ + nodes a sequence of Nodes, idname a string + Each node assumed to ne an instance of Node(bpy_struct) + https://docs.blender.org/api/current/bpy.types.Node.html + + The idname is searched for in the Node instance member bl_idname + https://docs.blender.org/api/current/bpy.types.Node.html#bpy.types.Node.bl_idname + and is generally some string version of the name of the subclass + + See https://docs.blender.org/api/current/bpy.types.ShaderNode.html for list of + ShaderNode subclasses + + returns first matching node found,returns None if no matching nodes + prints warning if multiple matches found + """ + _logger.debug("enter _find_node_by_idname search for %s in %r" % (idname, nodes)) + nodelist = [nd for nd in nodes if nd.bl_idname == idname] + _logger.debug("result _find_node_by_idname found %i" % len(nodelist)) + if len(nodelist) == 0: + return None + if len(nodelist) > 1: + _logger.warn("_find_node_by_idname : multiple (%i) nodes of type %s found" % (len(nodelist), idname)) + return nodelist[0] + +def imageTexture_in_material(material): + """ + Identifies ImageTexture node used as the input to Base Color attribute of BSDF node. + Does not search for images used as textures inside a NodeGrouo node. + + argument material an instance of Material(ID) + https://docs.blender.org/api/current/bpy.types.Material.html + + returns instance of ShaderNodeTexImage + https://docs.blender.org/api/current/bpy.types.ShaderNodeTexImage.html + """ + _logger.debug("evaluating image in material %s" % material.name) + + material_output = _find_node_by_idname( material.node_tree.nodes, _ShaderNodeTypes.MATERIAL_OUTPUT) + if material_output is None: + _logger.warn("%s not found in material %s" % (_ShaderNodeTypes.MATERIAL_OUTPUT, material.name)) + return None + + SURFACE_ATTRIBUTE= "Surface" + bsdf_principled = _find_node_by_idname( + [ndlink.from_node for ndlink in material_output.inputs.get(SURFACE_ATTRIBUTE).links], + _ShaderNodeTypes.BSDF_PRINCIPLED) + if bsdf_principled is None : + return None + + BASE_COLOR_ATTRIBUTE = 'Base Color' + image_texture = _find_node_by_idname( + [ndlink.from_node for ndlink in bsdf_principled.inputs.get(BASE_COLOR_ATTRIBUTE).links], + _ShaderNodeTypes.IMAGE_TEXTURE ) + if image_texture is None: + return None + _logger.debug("located image texture node %r" % image_texture) + return image_texture +