From 052f53a7d48113de62e88fb3bff23a42b23080e5 Mon Sep 17 00:00:00 2001 From: Vincent Marchetti Date: Sun, 3 Nov 2024 16:56:40 -0500 Subject: [PATCH 01/14] Edited the code to identity images used as textures, properly write the ImageTexture node even when there is no explicit file name (as the case when image is read from a glTF file) and save the image file to disk --- source/export_x3d.py | 295 ++++++++++++++++++++++++++++++++----------- 1 file changed, 218 insertions(+), 77 deletions(-) diff --git a/source/export_x3d.py b/source/export_x3d.py index 898847d..88b92ab 100644 --- a/source/export_x3d.py +++ b/source/export_x3d.py @@ -7,6 +7,9 @@ # - 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 @@ -185,6 +188,154 @@ def h3d_is_object_view(scene, obj): return False +# node based material identifiers +BSDF_PRINCIPLED = 'BSDF_PRINCIPLED' +BASE_COLOR = 'Base Color' +IMAGE = 'image' + +# 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 +MATERIAL_OUTPUT= "ShaderNodeOutputMaterial" +BSDF_PRINCIPLED = "ShaderNodeBsdfPrincipled" +IMAGE_TEXTURE = "ShaderNodeTexImage" + +# supported values of Image Texture Node extension property +REPEAT="REPEAT" +CLIP ="CLIP" + +class ImageInMaterial: + """ + An instance of this class is a callable object that takes as input arguemnt + a Material object, and returns an Image instance or None. + It is defined through a class so as to have a built-in cache mechanism + """ + class ImageExportRecord: + """ + a collection of properties of an image used as a texture, relevant + for export of an X3D ImageTextureNode + + reference: + Blender ImageTexture: https://docs.blender.org/manual/en/latest/render/shader_nodes/textures/image.html + + instance properties: + image -- the Blender Image instance + + extension -- a string enum value which, in the X3D context, governs + whether the image should be repeated for UV coordinates + outside the (0,0) to (1,1) square. Blender TextureImages + have "extension" options not implemented by X3D + + is_packed -- a boolean value, if true then the image data is to be + saved to a disk file associated with the output X3D file. + The name packed comes from the initial method of determining + whether this file needs to be exported by the presence of + a Blender PackedFile instance connected to the Blender image + + url -- if packed, then this string value is to be the URL + included in the ImageTexture node and also used to + define where the image should be saved (relative to the + location of the X3D file. + """ + def __init__(self): + self.image = None + self.extension = None + self.url = None + + @property + def is_packed(self): + return self.url is not None + + + + def __init__(self): + """ + cachedResult will be a dictionary whose keys are Material instances + and values are instance of + """ + self.cachedResult = dict() + + def __call__(self, material): + if material not in self.cachedResult: + self.cachedResult[material] = self.evaluate(material) + return self.cachedResult[material] + + @staticmethod + def _find_node_by_idname_(nodes, idname): + """ + nodes a sequence of Nodes, idname a string + if 0 targets are found, returns None + if 1 target is found returns that Node + if more than 1 targets are found prints warning string and returns first 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 evaluate(self, material): + logger.debug("evaluating image in material %s" % material.name) + + material_output = self._find_node_by_idname_( material.node_tree.nodes, MATERIAL_OUTPUT) + if material_output is None: + logger.warn("%s not found in material %s" % (MATERIAL_OUTPUT, material.name)) + return None + + bsdf_principled = self._find_node_by_idname_( + [ndlink.from_node for ndlink in material_output.inputs.get("Surface").links], + BSDF_PRINCIPLED) + if bsdf_principled is None : return None + + image_texture = self._find_node_by_idname_( + [ndlink.from_node for ndlink in bsdf_principled.inputs.get(BASE_COLOR).links], + IMAGE_TEXTURE ) + if image_texture is None: return None + + logger.debug("located %s ShaderNode" % IMAGE_TEXTURE) + + + x3d_supported_file_extension = {"PNG" : ".png","JPEG" : ".jpg"} + if image_texture.image.file_format not in x3d_supported_file_extension: + logger.warn("images of format %s not supported" % image_texture.image.file_format) + return None + + retVal = self.ImageExportRecord() + retVal.image = image_texture.image + + # ref https://docs.blender.org/api/current/bpy.types.ShaderNodeTexImage.html#bpy.types.ShaderNodeTexImage.extension + x3d_supported_extension = [CLIP, REPEAT] + if image_texture.extension in x3d_supported_extension: + retVal.extension = image_texture.extension + else: + logger.warn("image_texture.extension value %s unsupported in X3D" % image_texture.extension) + retVal.extension=REPEAT + + packed_file = image_texture.image.packed_file + if packed_file is not None and packed_file.size > 0: + from os.path import splitext, basename + name_base = splitext( basename(image_texture.image.name))[0] + url_ext = x3d_supported_file_extension[image_texture.image.file_format] + retVal.url = name_base +url_ext + + logger.info("ImageExportRecord : %s packed: %r url: %s : extension: %r" % \ + (retVal.image.name, retVal.is_packed, + retVal.url , retVal.extension)) + + return retVal + + def imagesToSave(self): + """ + a generator that yields the ImageExportRecord instances + for images that have packed attribute True + """ + for image_export_record in self.cachedResult.values(): + if image_export_record and ( image_export_record.is_packed or True ): + yield image_export_record + # ----------------------------------------------------------------------------- # Functions for writing output file # ----------------------------------------------------------------------------- @@ -275,6 +426,7 @@ def export(file, gpu_shader_cache[None] = gpu.export_shader(scene, gpu_shader_dummy_mat) h3d_material_route = [] + imageInMaterial = ImageInMaterial() # ------------------------------------------------------------------------- # File Writing Functions # ------------------------------------------------------------------------- @@ -512,27 +664,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 +671,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 +711,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 +751,15 @@ 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') + # this is a location at which we should + # invoke + # writeImageTexture(ident, image) + # and also potentially write + # the TextureTransform + imageRecord = imageInMaterial(material) + if imageRecord: + logger.info("output ImageTexture for %s" % imageRecord.image.name ) + writeImageTexture(ident, imageRecord) if use_h3d: mat_tmp = material if material else gpu_shader_dummy_mat @@ -1237,8 +1343,12 @@ def export(file, fw('%s\n' % ident) - def writeImageTexture(ident, image): + def writeImageTexture(ident, image_record): + image=image_record.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 %s format %s" % (image.name, image.file_format )) + + if image.tag: fw('%s\n' % (ident, image_id)) @@ -1249,24 +1359,49 @@ def export(file, fw('%s\n') def writeBackground(ident, world): @@ -1517,6 +1652,11 @@ def export(file, # print(copy_set) bpy_extras.io_utils.path_reference_copy(copy_set) + for svRecord in imageInMaterial.imagesToSave(): + image_filepath = os.path.join(base_dst, svRecord.url) + logger.info("writing image for texture to %s" % image_filepath) + svRecord.image.save( filepath = image_filepath ) + print('Info: finished X3D export to %r' % file.name) @@ -1559,6 +1699,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(): -- 2.30.2 From 9be8742e766c2e4e19597a599a208a8628649142 Mon Sep 17 00:00:00 2001 From: Vincent Marchetti Date: Fri, 15 Nov 2024 17:59:52 -0500 Subject: [PATCH 02/14] Added a UI panel with a selector for the value of the path_mode to be used in the export. For purposes of development the default setting in the UI is set to COPY, this is contrary to other default value for path_mode. It may be appropriate to set the default UI setting to AUTO upon merging into release code. Added logging messages to export_x3d.py. The intent here is to extend the copy_set mechanism to handle cases where the image file does not exist on disk at time of export. --- source/__init__.py | 26 ++++++++++++++++++++++++++ source/export_x3d.py | 9 +++++++-- 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/source/__init__.py b/source/__init__.py index 0c39d6e..e16da82 100644 --- a/source/__init__.py +++ b/source/__init__.py @@ -162,6 +162,29 @@ 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 = "Images and Movies" + 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 + + operator.path_mode="COPY" + layout.prop(operator, "path_mode") class X3D_PT_export_transform(bpy.types.Panel): bl_space_type = 'FILE_BROWSER' @@ -198,6 +221,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 +444,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 88b92ab..3526e64 100644 --- a/source/export_x3d.py +++ b/source/export_x3d.py @@ -357,6 +357,7 @@ def export(file, name_decorations=True, ): + logger.info("export with path_mode: %r" % path_mode) # ------------------------------------------------------------------------- # Global Setup # ------------------------------------------------------------------------- @@ -1648,8 +1649,12 @@ def export(file, if use_h3d: bpy.data.materials.remove(gpu_shader_dummy_mat) - # copy all collected files. - # print(copy_set) + if copy_set: + for c in copy_set: + logger.info("copy_set item %r" % copy_set) + else: + logger.info("no items in copy_set") + bpy_extras.io_utils.path_reference_copy(copy_set) for svRecord in imageInMaterial.imagesToSave(): -- 2.30.2 From 35fbe6c6f8de3afe4aca4f04d540a87720534718 Mon Sep 17 00:00:00 2001 From: Vincent Marchetti Date: Sat, 16 Nov 2024 14:11:31 -0500 Subject: [PATCH 03/14] A new source file intented for Python code implementing search through material node-tree --- source/material_node_search.py | 114 +++++++++++++++++++++++++++++++++ 1 file changed, 114 insertions(+) create mode 100644 source/material_node_search.py diff --git a/source/material_node_search.py b/source/material_node_search.py new file mode 100644 index 0000000..7b4ab3a --- /dev/null +++ b/source/material_node_search.py @@ -0,0 +1,114 @@ +# SPDX-FileCopyrightText: 2011-2024 Blender Foundation +# +# 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 +""" + + +# node based material identifiers +#BSDF_PRINCIPLED = 'BSDF_PRINCIPLED' + +# IMAGE = 'image' + +# 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" + +# supported values of Image Texture Node extension property +# REPEAT="REPEAT" +# CLIP ="CLIP" + +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 + + if 0 targets are found, returns None + if 1 target is found returns that Node + if more than 1 targets are found prints warning string and returns first 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): + """ + 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 + +## x3d_supported_file_extension = {"PNG" : ".png","JPEG" : ".jpg"} +## if image_texture.image.file_format not in x3d_supported_file_extension: +## logger.warn("images of format %s not supported" % image_texture.image.file_format) +## return None +## +## retVal = self.ImageExportRecord() +## retVal.image = image_texture.image +## +## # ref https://docs.blender.org/api/current/bpy.types.ShaderNodeTexImage.html#bpy.types.ShaderNodeTexImage.extension +## x3d_supported_extension = [CLIP, REPEAT] +## if image_texture.extension in x3d_supported_extension: +## retVal.extension = image_texture.extension +## else: +## logger.warn("image_texture.extension value %s unsupported in X3D" % image_texture.extension) +## retVal.extension=REPEAT +## +## packed_file = image_texture.image.packed_file +## if packed_file is not None and packed_file.size > 0: +## from os.path import splitext, basename +## name_base = splitext( basename(image_texture.image.name))[0] +## url_ext = x3d_supported_file_extension[image_texture.image.file_format] +## retVal.url = name_base +url_ext +## +## logger.info("ImageExportRecord : %s packed: %r url: %s : extension: %r" % \ +## (retVal.image.name, retVal.is_packed, +## retVal.url , retVal.extension)) +## +## return retVal + -- 2.30.2 From b17bc0b40ac40b1468557ef575f795929fb6d049 Mon Sep 17 00:00:00 2001 From: Vincent Marchetti Date: Sat, 16 Nov 2024 14:12:38 -0500 Subject: [PATCH 04/14] partial refactoring to use the code in material_node_search Not ready for pull-request --- source/export_x3d.py | 174 ++++--------------------------------------- 1 file changed, 14 insertions(+), 160 deletions(-) diff --git a/source/export_x3d.py b/source/export_x3d.py index 3526e64..dc13629 100644 --- a/source/export_x3d.py +++ b/source/export_x3d.py @@ -18,6 +18,7 @@ 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' @@ -188,153 +189,7 @@ def h3d_is_object_view(scene, obj): return False -# node based material identifiers -BSDF_PRINCIPLED = 'BSDF_PRINCIPLED' -BASE_COLOR = 'Base Color' -IMAGE = 'image' -# 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 -MATERIAL_OUTPUT= "ShaderNodeOutputMaterial" -BSDF_PRINCIPLED = "ShaderNodeBsdfPrincipled" -IMAGE_TEXTURE = "ShaderNodeTexImage" - -# supported values of Image Texture Node extension property -REPEAT="REPEAT" -CLIP ="CLIP" - -class ImageInMaterial: - """ - An instance of this class is a callable object that takes as input arguemnt - a Material object, and returns an Image instance or None. - It is defined through a class so as to have a built-in cache mechanism - """ - class ImageExportRecord: - """ - a collection of properties of an image used as a texture, relevant - for export of an X3D ImageTextureNode - - reference: - Blender ImageTexture: https://docs.blender.org/manual/en/latest/render/shader_nodes/textures/image.html - - instance properties: - image -- the Blender Image instance - - extension -- a string enum value which, in the X3D context, governs - whether the image should be repeated for UV coordinates - outside the (0,0) to (1,1) square. Blender TextureImages - have "extension" options not implemented by X3D - - is_packed -- a boolean value, if true then the image data is to be - saved to a disk file associated with the output X3D file. - The name packed comes from the initial method of determining - whether this file needs to be exported by the presence of - a Blender PackedFile instance connected to the Blender image - - url -- if packed, then this string value is to be the URL - included in the ImageTexture node and also used to - define where the image should be saved (relative to the - location of the X3D file. - """ - def __init__(self): - self.image = None - self.extension = None - self.url = None - - @property - def is_packed(self): - return self.url is not None - - - - def __init__(self): - """ - cachedResult will be a dictionary whose keys are Material instances - and values are instance of - """ - self.cachedResult = dict() - - def __call__(self, material): - if material not in self.cachedResult: - self.cachedResult[material] = self.evaluate(material) - return self.cachedResult[material] - - @staticmethod - def _find_node_by_idname_(nodes, idname): - """ - nodes a sequence of Nodes, idname a string - if 0 targets are found, returns None - if 1 target is found returns that Node - if more than 1 targets are found prints warning string and returns first 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 evaluate(self, material): - logger.debug("evaluating image in material %s" % material.name) - - material_output = self._find_node_by_idname_( material.node_tree.nodes, MATERIAL_OUTPUT) - if material_output is None: - logger.warn("%s not found in material %s" % (MATERIAL_OUTPUT, material.name)) - return None - - bsdf_principled = self._find_node_by_idname_( - [ndlink.from_node for ndlink in material_output.inputs.get("Surface").links], - BSDF_PRINCIPLED) - if bsdf_principled is None : return None - - image_texture = self._find_node_by_idname_( - [ndlink.from_node for ndlink in bsdf_principled.inputs.get(BASE_COLOR).links], - IMAGE_TEXTURE ) - if image_texture is None: return None - - logger.debug("located %s ShaderNode" % IMAGE_TEXTURE) - - - x3d_supported_file_extension = {"PNG" : ".png","JPEG" : ".jpg"} - if image_texture.image.file_format not in x3d_supported_file_extension: - logger.warn("images of format %s not supported" % image_texture.image.file_format) - return None - - retVal = self.ImageExportRecord() - retVal.image = image_texture.image - - # ref https://docs.blender.org/api/current/bpy.types.ShaderNodeTexImage.html#bpy.types.ShaderNodeTexImage.extension - x3d_supported_extension = [CLIP, REPEAT] - if image_texture.extension in x3d_supported_extension: - retVal.extension = image_texture.extension - else: - logger.warn("image_texture.extension value %s unsupported in X3D" % image_texture.extension) - retVal.extension=REPEAT - - packed_file = image_texture.image.packed_file - if packed_file is not None and packed_file.size > 0: - from os.path import splitext, basename - name_base = splitext( basename(image_texture.image.name))[0] - url_ext = x3d_supported_file_extension[image_texture.image.file_format] - retVal.url = name_base +url_ext - - logger.info("ImageExportRecord : %s packed: %r url: %s : extension: %r" % \ - (retVal.image.name, retVal.is_packed, - retVal.url , retVal.extension)) - - return retVal - - def imagesToSave(self): - """ - a generator that yields the ImageExportRecord instances - for images that have packed attribute True - """ - for image_export_record in self.cachedResult.values(): - if image_export_record and ( image_export_record.is_packed or True ): - yield image_export_record # ----------------------------------------------------------------------------- # Functions for writing output file @@ -427,7 +282,6 @@ def export(file, gpu_shader_cache[None] = gpu.export_shader(scene, gpu_shader_dummy_mat) h3d_material_route = [] - imageInMaterial = ImageInMaterial() # ------------------------------------------------------------------------- # File Writing Functions # ------------------------------------------------------------------------- @@ -757,10 +611,9 @@ def export(file, # writeImageTexture(ident, image) # and also potentially write # the TextureTransform - imageRecord = imageInMaterial(material) - if imageRecord: - logger.info("output ImageTexture for %s" % imageRecord.image.name ) - writeImageTexture(ident, imageRecord) + imageTextureNode = imageTexture_in_material(material) + if imageTextureNode: + writeImageTexture(ident, imageTextureNode) if use_h3d: mat_tmp = material if material else gpu_shader_dummy_mat @@ -1344,11 +1197,11 @@ def export(file, fw('%s\n' % ident) - def writeImageTexture(ident, image_record): - image=image_record.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 %s format %s" % (image.name, image.file_format )) - + logger.info("write ImageTexture X3D node for %r format %r filepath %r" % (image.name, image.file_format, image.filepath )) + return if image.tag: @@ -1652,15 +1505,16 @@ def export(file, 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") - bpy_extras.io_utils.path_reference_copy(copy_set) - for svRecord in imageInMaterial.imagesToSave(): - image_filepath = os.path.join(base_dst, svRecord.url) - logger.info("writing image for texture to %s" % image_filepath) - svRecord.image.save( filepath = image_filepath ) + +# for svRecord in imageInMaterial.imagesToSave(): +# image_filepath = os.path.join(base_dst, svRecord.url) +# logger.info("writing image for texture to %s" % image_filepath) +# svRecord.image.save( filepath = image_filepath ) print('Info: finished X3D export to %r' % file.name) -- 2.30.2 From 88a6970387ffadc0699847ec77ea33314e59e71f Mon Sep 17 00:00:00 2001 From: Vincent Marchetti Date: Sat, 16 Nov 2024 16:07:45 -0500 Subject: [PATCH 05/14] An implementation that works for the earth_imagetexture example in COPY mode --- source/export_x3d.py | 77 +++++++++++++++++++++++++++++++++----------- 1 file changed, 59 insertions(+), 18 deletions(-) diff --git a/source/export_x3d.py b/source/export_x3d.py index dc13629..55233d2 100644 --- a/source/export_x3d.py +++ b/source/export_x3d.py @@ -266,6 +266,18 @@ def export(file, # store files to copy copy_set = set() + # if path_mode is copy, create a temporary folder particular + # to this export operation, to stored image data to an image_file, + # for those image in a packed set or otherwise not existing on disk + if path_mode == 'COPY': + blender_tempfolder = bpy.app.tempdir + import uuid, os, os.path + 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() @@ -1201,7 +1213,7 @@ def export(file, 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 )) - return + if image.tag: @@ -1213,12 +1225,43 @@ def export(file, fw('%s 0 + + if has_packed_file or not imageTextureNode.image.filepath : + # assume that if the data has a packed file or it + # an empty file path then in the url field of the node + # where just going to give a filename + use_file_format = image.file_format or 'PNG' + image_ext = {'JPEG':'.jpg' , 'PNG':'.png'}[use_file_format] + image_base = os.path.splitext( os.path.basename(image.name))[0] + image_filename = image_base + image_ext + + + if temporary_image_directory: + 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, + "textures", + copy_set, + image.library) + image_urls = [filepath_ref] + else: + image_urls = [image_filename] + logger.warn("dangling image url %r" % [image_filename]) + else: # this is the legacy algorithm for determining # a set of urls based on value of image.filepath and the # argument path_mode passed in to the export function @@ -1239,22 +1282,20 @@ def export(file, images.append(filepath_full) images = [f.replace('\\', '/') for f in images] - else: - # this is the choice where image_record.is_packed is true - # meaning that the image data can be stored in the blend data - # This happens when the image is retrieved from the binary data - # in a glTF/glb - images = [ image_record.url ] + # note: the following has the effect of eliminating duplicate paths + # while preserving order-priority + image_urls = [f for i, f in enumerate(images) if f not in images[:i]] - # note: the following has the effect of eliminating duplicate paths - # while preserving order-priority - images = [f for i, f in enumerate(images) if f not in images[:i]] - logger.info("node urls: %s" % (images,)) - fw(ident_step + "url='%s'\n" % ' '.join(['"%s"' % escape(f) for f in images])) + 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 - if image_record.extension == CLIP: + x3d_supported_extension = ["CLIP", "REPEAT"] + if imageTextureNode.extension not in x3d_supported_extension: + logger.warn("imageTextureNode.extension value %s unsupported in X3D" % imageTextureNode.extension) + + if imageTextureNode.extension == "CLIP": fw(ident_step + "repeatS='false' repeatT='false'") fw(ident_step + '/>\n') -- 2.30.2 From d35c730b9071e8371efe08683f97d6fbe4e97fe6 Mon Sep 17 00:00:00 2001 From: Vincent Marchetti Date: Mon, 18 Nov 2024 14:43:35 -0500 Subject: [PATCH 06/14] Removed the UI based default setting of path_mode to 'COPY' The way it was done prevents path_mode from being changed --- source/__init__.py | 1 - 1 file changed, 1 deletion(-) diff --git a/source/__init__.py b/source/__init__.py index e16da82..b808070 100644 --- a/source/__init__.py +++ b/source/__init__.py @@ -183,7 +183,6 @@ class X3D_PT_export_external_resource(bpy.types.Panel): sfile = context.space_data operator = sfile.active_operator - operator.path_mode="COPY" layout.prop(operator, "path_mode") class X3D_PT_export_transform(bpy.types.Panel): -- 2.30.2 From 832ed4b4dd49e084708ae9b73512d5cb062b807b Mon Sep 17 00:00:00 2001 From: Vincent Marchetti Date: Mon, 18 Nov 2024 15:19:52 -0500 Subject: [PATCH 07/14] Fixed a broken import, the os os.path modules were not visible when needed --- source/export_x3d.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/source/export_x3d.py b/source/export_x3d.py index 55233d2..0b7117b 100644 --- a/source/export_x3d.py +++ b/source/export_x3d.py @@ -11,7 +11,7 @@ import logging logger = logging.getLogger("export_x3d") import math -import os + import bpy import mathutils @@ -269,9 +269,10 @@ def export(file, # if path_mode is copy, create a temporary folder particular # to this export operation, to stored image data to an image_file, # for those image in a packed set or otherwise not existing on disk + import os, os.path if path_mode == 'COPY': blender_tempfolder = bpy.app.tempdir - import uuid, os, os.path + 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 ) -- 2.30.2 From fd279dd544c514f2b31c13e78df6f8ce8ca9d9b3 Mon Sep 17 00:00:00 2001 From: Vincent Marchetti Date: Thu, 21 Nov 2024 05:55:39 -0500 Subject: [PATCH 08/14] cleaned up comments and commented out code as suggested by reviewer --- source/export_x3d.py | 33 +++++++-------------------------- 1 file changed, 7 insertions(+), 26 deletions(-) diff --git a/source/export_x3d.py b/source/export_x3d.py index 0b7117b..f0490a4 100644 --- a/source/export_x3d.py +++ b/source/export_x3d.py @@ -266,11 +266,11 @@ def export(file, # store files to copy copy_set = set() - # if path_mode is copy, create a temporary folder particular - # to this export operation, to stored image data to an image_file, - # for those image in a packed set or otherwise not existing on disk 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) @@ -619,11 +619,6 @@ def export(file, fw('%s\n' % ident) ident += '\t' - # this is a location at which we should - # invoke - # writeImageTexture(ident, image) - # and also potentially write - # the TextureTransform imageTextureNode = imageTexture_in_material(material) if imageTextureNode: writeImageTexture(ident, imageTextureNode) @@ -1235,9 +1230,8 @@ def export(file, has_packed_file = image.packed_file and image.packed_file.size > 0 if has_packed_file or not imageTextureNode.image.filepath : - # assume that if the data has a packed file or it - # an empty file path then in the url field of the node - # where just going to give a filename + # write to temporary folder if image does not have filepath + # or if it is in a packed file. use_file_format = image.file_format or 'PNG' image_ext = {'JPEG':'.jpg' , 'PNG':'.png'}[use_file_format] image_base = os.path.splitext( os.path.basename(image.name))[0] @@ -1261,14 +1255,8 @@ def export(file, image_urls = [filepath_ref] else: image_urls = [image_filename] - logger.warn("dangling image url %r" % [image_filename]) + logger.warning("dangling image url %r" % [image_filename]) else: - # this is the legacy algorithm for determining - # a set of urls based on value of image.filepath and the - # argument path_mode passed in to the export function - # the idea is that this image has been from a file - # and that same file locationwill be used as the source of texture image - # for the X3D exported file. logger.debug("image.filepath: %s" % image.filepath) filepath = image.filepath filepath_full = bpy.path.abspath(filepath, library=image.library) @@ -1294,7 +1282,7 @@ def export(file, # specify if extension value is CLIP x3d_supported_extension = ["CLIP", "REPEAT"] if imageTextureNode.extension not in x3d_supported_extension: - logger.warn("imageTextureNode.extension value %s unsupported in X3D" % imageTextureNode.extension) + logger.warning("imageTextureNode.extension value %s unsupported in X3D" % imageTextureNode.extension) if imageTextureNode.extension == "CLIP": fw(ident_step + "repeatS='false' repeatT='false'") @@ -1551,13 +1539,6 @@ def export(file, else: logger.info("no items in copy_set") - - -# for svRecord in imageInMaterial.imagesToSave(): -# image_filepath = os.path.join(base_dst, svRecord.url) -# logger.info("writing image for texture to %s" % image_filepath) -# svRecord.image.save( filepath = image_filepath ) - print('Info: finished X3D export to %r' % file.name) -- 2.30.2 From f29d79fd5f662a9c3776d6d9e4cbc9ff985162b9 Mon Sep 17 00:00:00 2001 From: Vincent Marchetti Date: Thu, 21 Nov 2024 06:29:14 -0500 Subject: [PATCH 09/14] Removed commented out code, whitespace reformatting --- source/material_node_search.py | 50 +++++----------------------------- 1 file changed, 7 insertions(+), 43 deletions(-) diff --git a/source/material_node_search.py b/source/material_node_search.py index 7b4ab3a..7ff1157 100644 --- a/source/material_node_search.py +++ b/source/material_node_search.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2011-2024 Blender Foundation +# SPDX-FileCopyrightText: 2024 Vincent Marchetti # # SPDX-License-Identifier: GPL-3.0-or-later @@ -11,11 +11,6 @@ of particular nodes enabling export of material properties into other formats """ -# node based material identifiers -#BSDF_PRINCIPLED = 'BSDF_PRINCIPLED' - -# IMAGE = 'image' - # 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 @@ -24,9 +19,6 @@ class _ShaderNodeTypes: BSDF_PRINCIPLED = "ShaderNodeBsdfPrincipled" IMAGE_TEXTURE = "ShaderNodeTexImage" -# supported values of Image Texture Node extension property -# REPEAT="REPEAT" -# CLIP ="CLIP" def _find_node_by_idname(nodes, idname): """ @@ -41,9 +33,8 @@ def _find_node_by_idname(nodes, idname): See https://docs.blender.org/api/current/bpy.types.ShaderNode.html for list of ShaderNode subclasses - if 0 targets are found, returns None - if 1 target is found returns that Node - if more than 1 targets are found prints warning string and returns first found + 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] @@ -73,42 +64,15 @@ def imageTexture_in_material(material): 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 + 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 + if image_texture is None: + return None _logger.debug("located image texture node %r" % image_texture) return image_texture -## x3d_supported_file_extension = {"PNG" : ".png","JPEG" : ".jpg"} -## if image_texture.image.file_format not in x3d_supported_file_extension: -## logger.warn("images of format %s not supported" % image_texture.image.file_format) -## return None -## -## retVal = self.ImageExportRecord() -## retVal.image = image_texture.image -## -## # ref https://docs.blender.org/api/current/bpy.types.ShaderNodeTexImage.html#bpy.types.ShaderNodeTexImage.extension -## x3d_supported_extension = [CLIP, REPEAT] -## if image_texture.extension in x3d_supported_extension: -## retVal.extension = image_texture.extension -## else: -## logger.warn("image_texture.extension value %s unsupported in X3D" % image_texture.extension) -## retVal.extension=REPEAT -## -## packed_file = image_texture.image.packed_file -## if packed_file is not None and packed_file.size > 0: -## from os.path import splitext, basename -## name_base = splitext( basename(image_texture.image.name))[0] -## url_ext = x3d_supported_file_extension[image_texture.image.file_format] -## retVal.url = name_base +url_ext -## -## logger.info("ImageExportRecord : %s packed: %r url: %s : extension: %r" % \ -## (retVal.image.name, retVal.is_packed, -## retVal.url , retVal.extension)) -## -## return retVal - -- 2.30.2 From caaca85ca62a43fc65e8ef91826b15b9b8720ce6 Mon Sep 17 00:00:00 2001 From: Vincent Marchetti Date: Thu, 21 Nov 2024 07:27:03 -0500 Subject: [PATCH 10/14] Documented the limitation that the imageTexture_in_material function does not search for image textures in NodeGroup structures --- source/material_node_search.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/source/material_node_search.py b/source/material_node_search.py index 7ff1157..88d1065 100644 --- a/source/material_node_search.py +++ b/source/material_node_search.py @@ -47,6 +47,9 @@ def _find_node_by_idname(nodes, idname): 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 -- 2.30.2 From ce0590abcde99dae79438effc172af3bd34103d3 Mon Sep 17 00:00:00 2001 From: Vincent Marchetti Date: Thu, 21 Nov 2024 23:04:02 -0500 Subject: [PATCH 11/14] modified code to: only include 1 path to image file in the url attribute of the ImageTexture node, and that path to an image file is determined by the bpy_extras.io_utils.path_reference utility configured the Path Mode == COPY case to not write image files to a subdirectory, copy those image files into the same folder as the export X3D. --- source/export_x3d.py | 63 ++++++++++++++++++++++---------------------- 1 file changed, 31 insertions(+), 32 deletions(-) diff --git a/source/export_x3d.py b/source/export_x3d.py index f0490a4..f73d915 100644 --- a/source/export_x3d.py +++ b/source/export_x3d.py @@ -1222,23 +1222,31 @@ def export(file, fw('DEF=%s\n' % image_id) - # determine condition under which we want to directly write the the - # image data to a temporary location for purposes of export. This will - # only be done if the path_mode is COPY and either the filepath is empty - # or the image has a packed file embedded in the .blend data - + # setting COPY_SUBDIR to None in calls to + # bpy_extras.io_utils.path_reference will cause copied images + # to be placed in same directory as the exported X3D file. + COPY_SUBDIR=None has_packed_file = image.packed_file and image.packed_file.size > 0 - - if has_packed_file or not imageTextureNode.image.filepath : - # write to temporary folder if image does not have filepath - # or if it is in a packed file. + 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' - image_ext = {'JPEG':'.jpg' , 'PNG':'.png'}[use_file_format] - image_base = os.path.splitext( os.path.basename(image.name))[0] - image_filename = image_base + image_ext + 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 - if temporary_image_directory: filepath_full = os.path.join(temporary_image_directory, image_filename) logger.info("writing image for texture to %s" % filepath_full) @@ -1249,31 +1257,22 @@ def export(file, temporary_image_directory, base_dst, path_mode, - "textures", + COPY_SUBDIR, copy_set, image.library) - image_urls = [filepath_ref] - else: - image_urls = [image_filename] - logger.warning("dangling image url %r" % [image_filename]) else: - logger.debug("image.filepath: %s" % image.filepath) 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, "textures", copy_set, image.library) - filepath_base = os.path.basename(filepath_full) + filepath_ref = bpy_extras.io_utils.path_reference( + filepath_full, + base_src, + base_dst, + path_mode, + COPY_SUBDIR, + copy_set, + image.library) - images = [ - filepath_ref, - filepath_base, - ] - if path_mode != 'RELATIVE': - images.append(filepath_full) - - images = [f.replace('\\', '/') for f in images] - # note: the following has the effect of eliminating duplicate paths - # while preserving order-priority - image_urls = [f for i, f in enumerate(images) if f not in images[:i]] + 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])) -- 2.30.2 From 54ec65fe57e0eb47b65f665424839fb3c9d4caba Mon Sep 17 00:00:00 2001 From: Vincent Marchetti Date: Thu, 21 Nov 2024 23:09:39 -0500 Subject: [PATCH 12/14] Modified the UI panel label to read "Image Textures" ; removed the term "Movie" until this code implements the export of Movie textures. The code will output a WARN level logging message if the Blender data contains a movie file format in an image texture to export. --- source/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/source/__init__.py b/source/__init__.py index b808070..bf5eb90 100644 --- a/source/__init__.py +++ b/source/__init__.py @@ -165,7 +165,7 @@ class X3D_PT_export_include(bpy.types.Panel): class X3D_PT_export_external_resource(bpy.types.Panel): bl_space_type = 'FILE_BROWSER' bl_region_type = 'TOOL_PROPS' - bl_label = "Images and Movies" + bl_label = "Image Textures" bl_parent_id = "FILE_PT_operator" @classmethod -- 2.30.2 From 3bb071c4860d2d12b0a803fddc660a43566d1c35 Mon Sep 17 00:00:00 2001 From: Vincent Marchetti Date: Fri, 22 Nov 2024 09:29:29 -0500 Subject: [PATCH 13/14] Added a warning message to logging when the image in an ImageTexture node does not have a value for the filepath property and the selected Path Mode is notnCOPY. The reason for the warning is that in this case the value in the ImageTexturenurl property will not be a valid file location The case of no value for the filepath property can occur in at least two ways - the user creates the image using Blender UI and does not save it to disk - the image is imported from a format such as glTF which supports embedded image data --- source/export_x3d.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/source/export_x3d.py b/source/export_x3d.py index f73d915..36fe0d2 100644 --- a/source/export_x3d.py +++ b/source/export_x3d.py @@ -1221,6 +1221,11 @@ def export(file, fw('%s Date: Fri, 22 Nov 2024 20:16:14 -0500 Subject: [PATCH 14/14] uses a string.replace operation ton change Window filepath separator to POSIX/Unixy filepath separator in the url property of a ImageTexture X3D node. --- source/export_x3d.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/source/export_x3d.py b/source/export_x3d.py index 36fe0d2..3a8f7db 100644 --- a/source/export_x3d.py +++ b/source/export_x3d.py @@ -1277,6 +1277,10 @@ def export(file, copy_set, image.library) + # following replaces Windows filepath separator with POSIX /Unix + # separator + filepath_ref = filepath_ref.replace("\\", "/") + image_urls = [ filepath_ref ] logger.info("node urls: %s" % (image_urls,)) -- 2.30.2