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

Merged
Cedric Steiert merged 14 commits from vmarchetti/io_scene_x3d:image-export into main 2024-11-23 03:26:03 +01:00
3 changed files with 219 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
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)
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,79 @@ 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
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,
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)
# 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,))
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 +1540,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 +1589,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
#
# 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