Export ImageTexture node for an image identified in material node tree #39

Open
Vincent Marchetti wants to merge 13 commits from vmarchetti/io_scene_x3d:image-export into main

When changing the target branch, be careful to rebase the branch in your fork to match. See documentation.
3 changed files with 215 additions and 82 deletions

View File

@ -162,6 +162,28 @@ class X3D_PT_export_include(bpy.types.Panel):
col.enabled = not blender_version_higher_279 col.enabled = not blender_version_higher_279
col.prop(operator, "use_h3d") 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): class X3D_PT_export_transform(bpy.types.Panel):
bl_space_type = 'FILE_BROWSER' bl_space_type = 'FILE_BROWSER'
@ -198,6 +220,8 @@ class X3D_PT_export_transform(bpy.types.Panel):
layout.prop(operator, "axis_up") layout.prop(operator, "axis_up")
class X3D_PT_export_geometry(bpy.types.Panel): class X3D_PT_export_geometry(bpy.types.Panel):
bl_space_type = 'FILE_BROWSER' bl_space_type = 'FILE_BROWSER'
bl_region_type = 'TOOL_PROPS' bl_region_type = 'TOOL_PROPS'
@ -419,6 +443,7 @@ classes = (
X3D_PT_export_include, X3D_PT_export_include,
X3D_PT_export_transform, X3D_PT_export_transform,
X3D_PT_export_geometry, X3D_PT_export_geometry,
X3D_PT_export_external_resource,
ImportX3D, ImportX3D,
X3D_PT_import_general, X3D_PT_import_general,
X3D_PT_import_transform, X3D_PT_import_transform,

View File

@ -7,14 +7,18 @@
# - Doesn't handle multiple UV textures on a single mesh (create a mesh for each texture). # - 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. # - Can't get the texture array associated with material * not the UV ones.
import logging
logger = logging.getLogger("export_x3d")
import math import math
import os
import bpy import bpy
import mathutils import mathutils
from bpy_extras.io_utils import create_derived_objects from bpy_extras.io_utils import create_derived_objects
from .material_node_search import imageTexture_in_material
# h3d defines # h3d defines
H3D_TOP_LEVEL = 'TOP_LEVEL_TI' H3D_TOP_LEVEL = 'TOP_LEVEL_TI'
@ -185,6 +189,8 @@ def h3d_is_object_view(scene, obj):
return False return False
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Functions for writing output file # Functions for writing output file
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@ -206,6 +212,7 @@ def export(file,
name_decorations=True, name_decorations=True,
): ):
logger.info("export with path_mode: %r" % path_mode)
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
# Global Setup # Global Setup
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
@ -259,6 +266,19 @@ def export(file,
# store files to copy # store files to copy
copy_set = set() copy_set = set()
import os, os.path

why is this temporary folder needed? Could you add that to the comment?

why is this temporary folder needed? Could you add that to the comment?
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 # store names of newly created meshes, so we dont overlap
mesh_name_set = set() mesh_name_set = set()
@ -512,27 +532,6 @@ def export(file,
is_coords_written = False 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! # fast access!
mesh_vertices = mesh.vertices[:] mesh_vertices = mesh.vertices[:]
mesh_loops = mesh.loops[:] mesh_loops = mesh.loops[:]
@ -540,21 +539,14 @@ def export(file,
mesh_polygons_materials = [p.material_index for p in mesh_polygons] mesh_polygons_materials = [p.material_index for p in mesh_polygons]
mesh_polygons_vertices = [p.vertices[:] 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 # group faces
polygons_groups = {} polygons_groups = {}
for material_index in range(len(mesh_materials)): for material_index in range(len(mesh.materials)):
for image in mesh_polygons_image_unique: polygons_groups[material_index] = []
polygons_groups[material_index, image] = []
del mesh_polygons_image_unique
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() # Py dict are sorted now, so we can use directly polygons_groups.items()
# and still get consistent reproducible outputs. # and still get consistent reproducible outputs.
@ -587,9 +579,9 @@ def export(file,
for ltri in mesh.loop_triangles: for ltri in mesh.loop_triangles:
polygons_to_loop_triangles_indices[ltri.polygon_index].append(ltri) 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: if polygons_group:
material = mesh_materials[material_index] material = mesh.materials[material_index]
fw('%s<Shape>\n' % ident) fw('%s<Shape>\n' % ident)
ident += '\t' ident += '\t'
@ -627,33 +619,9 @@ def export(file,
fw('%s<Appearance>\n' % ident) fw('%s<Appearance>\n' % ident)
ident += '\t' ident += '\t'
if image and not use_h3d: imageTextureNode = imageTexture_in_material(material)

Could you shorten the comment down? For example "Find and write ImageTexture and potentially TextureTransform if present" reads a bit better in my opinion

Could you shorten the comment down? For example "Find and write ImageTexture and potentially TextureTransform if present" reads a bit better in my opinion
writeImageTexture(ident, image) if imageTextureNode:
writeImageTexture(ident, imageTextureNode)
# 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<TextureTransform ' % ident)))
fw('\n')
# fw('center="%.6f %.6f" ' % (0.0, 0.0))
fw(ident_step + 'translation="%.6f %.6f"\n' % loc)
fw(ident_step + 'scale="%.6f %.6f"\n' % (sca_x, sca_y))
fw(ident_step + 'rotation="%.6f"\n' % rot)
fw(ident_step + '/>\n')
if use_h3d: if use_h3d:
mat_tmp = material if material else gpu_shader_dummy_mat mat_tmp = material if material else gpu_shader_dummy_mat
@ -1237,8 +1205,12 @@ def export(file,
fw('%s</ComposedShader>\n' % ident) fw('%s</ComposedShader>\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="_")) 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: if image.tag:
fw('%s<ImageTexture USE=%s />\n' % (ident, image_id)) fw('%s<ImageTexture USE=%s />\n' % (ident, image_id))
@ -1249,24 +1221,75 @@ def export(file,
fw('%s<ImageTexture ' % ident))) fw('%s<ImageTexture ' % ident)))
fw('DEF=%s\n' % image_id) fw('DEF=%s\n' % image_id)
# collect image paths, can load multiple if (path_mode != 'COPY') and not image.filepath :
# [relative, name-only, absolute] # this is the case where the path_mode choice is intended to
filepath = image.filepath # reference an existing image file, but no filepath for that
filepath_full = bpy.path.abspath(filepath, library=image.library) # file is specified in the image data structure
filepath_ref = bpy_extras.io_utils.path_reference(filepath_full, base_src, base_dst, path_mode, "textures", copy_set, image.library) logger.warning("no filepath available for Path Mode %s" % path_mode )
filepath_base = os.path.basename(filepath_full)
images = [ # setting COPY_SUBDIR to None in calls to
filepath_ref, # bpy_extras.io_utils.path_reference will cause copied images
filepath_base, # to be placed in same directory as the exported X3D file.
] COPY_SUBDIR=None
if path_mode != 'RELATIVE': has_packed_file = image.packed_file and image.packed_file.size > 0
images.append(filepath_full) 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

comment is a bit tricky to read, maybe simplify: "Write only the filename if image is packed or has no filepath provided"

comment is a bit tricky to read, maybe simplify: "Write only the filename if image is packed or has no filepath provided"
use_file_format = image.file_format or 'PNG'
images = [f.replace('\\', '/') for f in images] try:
images = [f for i, f in enumerate(images) if f not in images[:i]] 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,

warn is deprecated, use warning instead

warn is deprecated, use warning instead
COPY_SUBDIR,
copy_set,

Could also get simplified: "Legacy Algorithm for preparing and normalizing image filepaths: Get the full path, reference path (e.g. relative), filename"

Could also get simplified: "Legacy Algorithm for preparing and normalizing image filepaths: Get the full path, reference path (e.g. relative), filename"
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') fw(ident_step + '/>\n')
def writeBackground(ident, world): def writeBackground(ident, world):
@ -1513,9 +1536,12 @@ def export(file,
if use_h3d: if use_h3d:
bpy.data.materials.remove(gpu_shader_dummy_mat) bpy.data.materials.remove(gpu_shader_dummy_mat)
# copy all collected files. if copy_set:
# print(copy_set) for c in copy_set:
bpy_extras.io_utils.path_reference_copy(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) print('Info: finished X3D export to %r' % file.name)
@ -1559,6 +1585,7 @@ def save(context,
name_decorations=True name_decorations=True
): ):
logger.info("save: context %r to filepath %r" % (context,filepath))
bpy.path.ensure_ext(filepath, '.x3dz' if use_compress else '.x3d') bpy.path.ensure_ext(filepath, '.x3dz' if use_compress else '.x3d')
if bpy.ops.object.mode_set.poll(): if bpy.ops.object.mode_set.poll():

View File

@ -0,0 +1,81 @@
# SPDX-FileCopyrightText: 2024 Vincent Marchetti

Not sure about the copyright. Technically this is no longer property of bf, so if you want, you can put in your name (or Web3D Consortium as you're a member there) and current year

Not sure about the copyright. Technically this is no longer property of bf, so if you want, you can put in your name (or Web3D Consortium as you're a member there) and current year
#
# 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
Review

a few typos here

a few typos here
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:
Review

the output as code is not really needed in the docstring, but if you want simplify: Returns None if no targets are found or returns the first found target

the output as code is not really needed in the docstring, but if you want simplify: Returns None if no targets are found or returns the first found target
_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 )
Review

for better readability put the return into it's own line

for better readability put the return into it's own line
if image_texture is None:
return None
_logger.debug("located image texture node %r" % image_texture)
return image_texture