From 830b69cb08a7b3923d8dc1f0ce0a2f1cfca165e2 Mon Sep 17 00:00:00 2001 From: 424 <2421412@naver.com> Date: Wed, 27 May 2026 20:59:20 +0900 Subject: [PATCH 01/25] fix 5.1 by Kelit --- src/addons/send2ue/__init__.py | 8 +- src/addons/send2ue/constants.py | 1 - src/addons/send2ue/core/export.py | 4 +- src/addons/send2ue/core/utilities.py | 79 ++++++++++++++----- .../send2ue/dependencies/rpc/factory.py | 38 ++++++--- src/addons/send2ue/release_notes.md | 6 +- .../send2ue/resources/extensions/affixes.py | 2 +- .../resources/extensions/instance_assets.py | 11 +-- 8 files changed, 102 insertions(+), 47 deletions(-) diff --git a/src/addons/send2ue/__init__.py b/src/addons/send2ue/__init__.py index 3850900f..5177224d 100644 --- a/src/addons/send2ue/__init__.py +++ b/src/addons/send2ue/__init__.py @@ -9,12 +9,12 @@ from .core import formatting, validations, settings, utilities, export, ingest, extension, io bl_info = { - "name": "Send to Unreal", - "author": "Epic Games Inc (now a community fork)", - "version": (2, 6, 8), + "name": "Send to Unreal (Kelit b5)", + "author": "Epic Games Inc, poly-hammer community fork; Blender 5.x patches by Kelit", + "version": (2, 6, 7), "blender": (3, 6, 0), "location": "Header > Pipeline > Send to Unreal", - "description": "Sends an asset to the first open Unreal Editor instance on your machine.", + "description": "Sends an asset to the first open Unreal Editor instance on your machine. Kelit b5 build: adds slotted-action (Blender 5.0+) compatibility on top of Kelit personal fixes.", "warning": "", "wiki_url": "https://poly-hammer.github.io/BlenderTools/send2ue", "category": "Pipeline", diff --git a/src/addons/send2ue/constants.py b/src/addons/send2ue/constants.py index ef64728d..e64faab8 100644 --- a/src/addons/send2ue/constants.py +++ b/src/addons/send2ue/constants.py @@ -81,5 +81,4 @@ class PathModes(Enum): class RegexPresets: INVALID_NAME_CHARACTERS = r"[^-+\w]+" - INVALID_SOCKET_CHARACTERS = r"[^-+.\w]+" diff --git a/src/addons/send2ue/core/export.py b/src/addons/send2ue/core/export.py index 06a7269d..6c6c252b 100644 --- a/src/addons/send2ue/core/export.py +++ b/src/addons/send2ue/core/export.py @@ -193,12 +193,12 @@ def get_asset_sockets(asset_name, properties): if mesh_object: for child in mesh_object.children: if child.type == 'EMPTY' and child.name.startswith(f'{PreFixToken.SOCKET.value}_'): - name = utilities.get_socket_name(child.name) + name = utilities.get_asset_name(child.name.replace(f'{PreFixToken.SOCKET.value}_', '').split('.',1)[0], properties) relative_location = utilities.convert_blender_to_unreal_location( child.matrix_local.translation ) relative_rotation = utilities.convert_blender_rotation_to_unreal_rotation( - child.rotation_euler + child.matrix_world.to_euler() ) socket_data[name] = { 'relative_location': relative_location, diff --git a/src/addons/send2ue/core/utilities.py b/src/addons/send2ue/core/utilities.py index 624023c1..c7acaed3 100644 --- a/src/addons/send2ue/core/utilities.py +++ b/src/addons/send2ue/core/utilities.py @@ -15,6 +15,61 @@ from ..constants import BlenderTypes, UnrealTypes, ToolInfo, PreFixToken, PathModes, RegexPresets from mathutils import Vector, Quaternion +# --------------------------------------------------------------------------- +# Blender 5.0+ compatibility helpers for slotted Actions. +# In Blender 5.0 the legacy action.fcurves / action.groups API was removed. +# F-Curves now live on the "channelbag" of an action slot. +# These helpers fall back to the legacy API on Blender < 5 so the same code +# path works across versions. +# --------------------------------------------------------------------------- +def _iter_action_fcurves(action): + """ + Iterate over all F-Curves of an Action in a forward-compatible way. + + On Blender 5.0+ this iterates the F-Curves of every channelbag attached + to every layer/strip/slot of the action. On older versions it falls back + to action.fcurves directly. + + :param bpy.types.Action action: The action to read F-Curves from. + :return generator: A generator yielding fcurves. + """ + if action is None: + return + if bpy.app.version[0] >= 5: + try: + for layer in action.layers: + for strip in layer.strips: + for channelbag in strip.channelbags: + for fcurve in channelbag.fcurves: + yield fcurve + return + except AttributeError: + pass + for fcurve in action.fcurves: + yield fcurve + + +def _remove_action_fcurve(action, fcurve): + """ + Remove an F-Curve from an Action in a forward-compatible way. + + :param bpy.types.Action action: The action that owns the F-Curve. + :param fcurve: The F-Curve to remove. + """ + if bpy.app.version[0] >= 5: + try: + for layer in action.layers: + for strip in layer.strips: + for channelbag in strip.channelbags: + if fcurve in list(channelbag.fcurves): + channelbag.fcurves.remove(fcurve) + return + return + except AttributeError: + pass + action.fcurves.remove(fcurve) + + def track_progress(message='', attribute=''): """ @@ -215,7 +270,7 @@ def get_custom_property_fcurve_data(action_name): action = bpy.data.actions.get(action_name) frame_rate = bpy.context.scene.render.fps if action: - for fcurve in action.fcurves: + for fcurve in _iter_action_fcurves(action): if fcurve.data_path.startswith('["') and fcurve.data_path.endswith('"]'): name = fcurve.data_path.strip('["').strip('"]') data[name] = [[(point.co[0] - 1) / frame_rate, point.co[1]] for point in fcurve.keyframe_points] @@ -436,18 +491,6 @@ def get_asset_name(asset_name, properties, lod=False): return asset_name -def get_socket_name(asset_name): - """ - Takes a given asset name and removes the prefix SOCKET_ and other non-alpha numeric characters - that unreal won't except, and allows the same socket name on multiple objects to export correctly. - - :param str asset_name: The original name of the socket asset to export. - :return str: The formatted name of the socket asset to export. - """ - socket_name = re.sub(rf"{RegexPresets.INVALID_SOCKET_CHARACTERS}|\.\d+$|{PreFixToken.SOCKET.value}_", "", asset_name) - - return socket_name - def get_parent_collection(scene_object, collection): """ @@ -904,9 +947,9 @@ def remove_object_scale_keyframes(actions): :param list actions: A list of action objects. """ for action in actions: - for fcurve in action.fcurves: - if fcurve.data_path == 'scale': - action.fcurves.remove(fcurve) + scale_fcurves = [fc for fc in _iter_action_fcurves(action) if fc.data_path == 'scale'] + for fcurve in scale_fcurves: + _remove_action_fcurve(action, fcurve) def remove_from_disk(path, directory=False): @@ -1452,7 +1495,7 @@ def round_keyframes(actions): :param list actions: A list of action objects. """ for action in actions: - for fcurve in action.fcurves: + for fcurve in _iter_action_fcurves(action): for keyframe_point in fcurve.keyframe_points: keyframe_point.co[0] = round(keyframe_point.co[0]) @@ -1562,7 +1605,7 @@ def scale_object_actions(unordered_objects, actions, scale_factor): # iterate over any imported actions first this time... for action in actions: # iterate through the location curves - for fcurve in [fcurve for fcurve in action.fcurves if fcurve.data_path.endswith('location')]: + for fcurve in [fcurve for fcurve in _iter_action_fcurves(action) if fcurve.data_path.endswith('location')]: # the location fcurve of the object if fcurve.data_path == 'location': for keyframe_point in fcurve.keyframe_points: diff --git a/src/addons/send2ue/dependencies/rpc/factory.py b/src/addons/send2ue/dependencies/rpc/factory.py index 249f9c03..c91c3d1f 100644 --- a/src/addons/send2ue/dependencies/rpc/factory.py +++ b/src/addons/send2ue/dependencies/rpc/factory.py @@ -30,21 +30,30 @@ def __init__(self, rpc_client, remap_pairs=None, default_imports=None): self.default_imports = default_imports or [] @staticmethod - def _get_docstring(code, function_name): + def _get_docstring(code, function_name, function=None): """ Gets the docstring value from the functions code. + Kelit_b5 patch: Python 3.13 (Blender 5.x) refuses to parse some legitimate + method sources fed through exec() here (e.g. due to stricter rules around + ternary "if" expressions in certain edge cases). The original implementation + re-exec'd the source just to grab the docstring; we now read it directly + from the function object when available, falling back to a regex parse of + the source as a last resort. + :param list code: A list of code lines. :param str function_name: The name of the function. + :param callable function: The function object (optional, fast path). :returns: The docstring text. :rtype: str """ - # run the function code - exec('\n'.join(code)) - # get the function from the locals - function_instance = locals().copy().get(function_name) - # get the doc strings from the function - return function_instance.__doc__ + if function is not None and getattr(function, "__doc__", None) is not None: + return function.__doc__ + # Fallback: pull the first triple-quoted block out of the source without exec. + import re + source = '\n'.join(code) + match = re.search(r'(?P\"{3}|\'{3})(?P.*?)(?P=q)', source, re.DOTALL) + return match.group('body') if match else None @staticmethod def _save_execution_history(code, function, args): @@ -147,17 +156,20 @@ def _get_code(self, function): code = [line for line in code if not line.startswith(('@', '#'))] # get the docstring from the code - doc_string = self._get_docstring(code, function.__name__) + doc_string = self._get_docstring(code, function.__name__, function=function) # get import code and insert them inside the function import_code = self._get_callstack_references(code, function) code.insert(1, import_code) - # remove the doc string - if doc_string: - code = '\n'.join(code).replace(doc_string, '') - code = [line for line in code.split('\n') if not all([char == '"' or char == "'" for char in line.strip()])] - + # Kelit_b5 patch: keep the docstring as-is. + # The original implementation tried to strip the docstring via str.replace, + # but __doc__'s indentation often does not match the dedented source, so the + # replace silently failed and left the docstring TEXT (without its triple + # quotes) inside the function body. Python 3.13 then parsed those words + # as code and raised 'expected else after if expression'. + # Docstrings are valid Python anyway -- leaving them in is harmless. + _ = doc_string # keep the call to _get_docstring for backwards compatibility return code def _register(self, function): diff --git a/src/addons/send2ue/release_notes.md b/src/addons/send2ue/release_notes.md index 63de7f4f..41b74ed0 100644 --- a/src/addons/send2ue/release_notes.md +++ b/src/addons/send2ue/release_notes.md @@ -1,9 +1,9 @@ ## Patch Changes -* Lowered aggressiveness of socket names - * [173](https://github.com/poly-hammer/BlenderTools/issues/173) +* Fixed addon preferences setter/getters to be compatible with Blender 5.0 + * [174](https://github.com/poly-hammer/BlenderTools/issues/174) ## Special Thanks -@Daerst +@kelitraynaud ## Tests Passing On * Blender `3.6`, `4.2` (installed from blender.org) diff --git a/src/addons/send2ue/resources/extensions/affixes.py b/src/addons/send2ue/resources/extensions/affixes.py index 0bc41235..bcc450bb 100644 --- a/src/addons/send2ue/resources/extensions/affixes.py +++ b/src/addons/send2ue/resources/extensions/affixes.py @@ -158,7 +158,7 @@ def get_texture_images(mesh_object): if material_slot.material: if material_slot.material.node_tree: for node in material_slot.material.node_tree.nodes: - if node.type == 'TEX_IMAGE': + if node.type == 'TEX_IMAGE' and node.image: images.append(node.image) return images diff --git a/src/addons/send2ue/resources/extensions/instance_assets.py b/src/addons/send2ue/resources/extensions/instance_assets.py index e9b7c01b..33bf675c 100644 --- a/src/addons/send2ue/resources/extensions/instance_assets.py +++ b/src/addons/send2ue/resources/extensions/instance_assets.py @@ -8,7 +8,8 @@ convert_blender_rotation_to_unreal_rotation, convert_blender_to_unreal_location, get_armature_modifier_rig_object, - get_asset_name + get_asset_name, + _iter_action_fcurves ) from send2ue.dependencies.unreal import UnrealRemoteCalls as UnrealCalls from send2ue.dependencies.rpc.factory import make_remote @@ -139,12 +140,12 @@ def post_import(self, asset_data, properties): if scene_object.parent and scene_object.parent.type == 'EMPTY': unique_name = scene_object.parent.name location = list(scene_object.parent.matrix_world.translation) - rotation = scene_object.parent.rotation_euler + rotation = scene_object.parent.matrix_world.to_euler() scale = scene_object.parent.scale[:] else: unique_name = scene_object.name location = list(scene_object.matrix_world.translation) - rotation = scene_object.rotation_euler + rotation = scene_object.matrix_world.to_euler() scale = scene_object.scale[:] # anim sequences use the transforms of the first frame of the action @@ -152,7 +153,7 @@ def post_import(self, asset_data, properties): # the transforms default to the armature object location scene_object = bpy.data.objects.get(asset_data['_armature_object_name']) location = list(scene_object.matrix_world.translation) - rotation = scene_object.rotation_euler + rotation = scene_object.matrix_world.to_euler() scale = scene_object.scale[:] action = bpy.data.actions.get(asset_data['_action_name']) @@ -160,7 +161,7 @@ def post_import(self, asset_data, properties): # otherwise the if location is in the action curves, that first frame determines # the actors location in the level - for fcurve in action.fcurves: + for fcurve in _iter_action_fcurves(action): for keyframe in fcurve.keyframe_points: if fcurve.data_path == 'location': # only get the value from the start frame From 326c6b60d9cb7e7693a897ab7fc79dcb1ab2a05b Mon Sep 17 00:00:00 2001 From: 424 <2421412@naver.com> Date: Wed, 27 May 2026 21:11:12 +0900 Subject: [PATCH 02/25] Enhance geometry validation to check evaluated geometry including modifiers --- src/addons/send2ue/core/validations.py | 27 +++++++++++++++++++++----- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/src/addons/send2ue/core/validations.py b/src/addons/send2ue/core/validations.py index 673d357c..2001182a 100644 --- a/src/addons/send2ue/core/validations.py +++ b/src/addons/send2ue/core/validations.py @@ -98,13 +98,30 @@ def validate_asset_data_exists(self): def validate_geometry_exists(self): """ - Checks the geometry of each object to see if it has vertices. + Checks the evaluated geometry of each object to see if it has vertices. + This includes geometry generated by modifiers. """ + depsgraph = bpy.context.evaluated_depsgraph_get() + for mesh_object in self.mesh_objects: - # check if vertices exist - if len(mesh_object.data.vertices) <= 0: - utilities.report_error(f'Mesh "{mesh_object.name}" has no geometry.') - return False + # Get object after modifiers / geometry nodes evaluation + evaluated_object = mesh_object.evaluated_get(depsgraph) + + evaluated_mesh = None + + try: + evaluated_mesh = evaluated_object.to_mesh() + + if evaluated_mesh is None or len(evaluated_mesh.vertices) <= 0: + utilities.report_error( + f'Mesh "{mesh_object.name}" has no evaluated geometry.' + ) + return False + + finally: + if evaluated_mesh is not None: + evaluated_object.to_mesh_clear() + return True def validate_scene_scale(self): From bae4c0cf08760b4537212800ddb940da1da1f703 Mon Sep 17 00:00:00 2001 From: 424 <2421412@naver.com> Date: Wed, 3 Jun 2026 00:11:51 +0900 Subject: [PATCH 03/25] Force send2ue imports to use Unreal FBX factory --- src/addons/send2ue/dependencies/unreal.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/addons/send2ue/dependencies/unreal.py b/src/addons/send2ue/dependencies/unreal.py index b0d9bc06..405f324b 100644 --- a/src/addons/send2ue/dependencies/unreal.py +++ b/src/addons/send2ue/dependencies/unreal.py @@ -821,6 +821,8 @@ def set_fbx_import_task_options(self): Sets the FBX import options. """ self.set_import_task_options() + # Avoid third-party FBX factories intercepting send2ue imports. + self._import_task.set_editor_property('factory', unreal.FbxFactory()) import_materials_and_textures = self._property_data.get('import_materials_and_textures', {}).get('value', True) From e88faa70a612395c91a7a2a6f06d54d81684043c Mon Sep 17 00:00:00 2001 From: 424 <2421412@naver.com> Date: Sat, 6 Jun 2026 23:14:03 +0900 Subject: [PATCH 04/25] Revert "Force send2ue imports to use Unreal FBX factory" This reverts commit bae4c0cf08760b4537212800ddb940da1da1f703. --- src/addons/send2ue/dependencies/unreal.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/addons/send2ue/dependencies/unreal.py b/src/addons/send2ue/dependencies/unreal.py index 405f324b..b0d9bc06 100644 --- a/src/addons/send2ue/dependencies/unreal.py +++ b/src/addons/send2ue/dependencies/unreal.py @@ -821,8 +821,6 @@ def set_fbx_import_task_options(self): Sets the FBX import options. """ self.set_import_task_options() - # Avoid third-party FBX factories intercepting send2ue imports. - self._import_task.set_editor_property('factory', unreal.FbxFactory()) import_materials_and_textures = self._property_data.get('import_materials_and_textures', {}).get('value', True) From 59e14aa33eca3862410c969eeae1ff450b4ee111 Mon Sep 17 00:00:00 2001 From: 424 <2421412@naver.com> Date: Tue, 16 Jun 2026 22:27:58 +0900 Subject: [PATCH 05/25] Add material pipeline extension and view-layer guard for child selection - send2ue_material_pipeline.py: post_import hook that triggers the Unreal-side material setup (ue_material_setup.process_mesh) over RPC. Pipeline dir resolves from UE_BLENDER_PIPELINE_DIR env var, defaulting to ~/Documents/UE_Blender_Pipeline. - select_all_children: only select children present in the active view layer. Co-Authored-By: Claude Opus 4.8 --- src/addons/send2ue/core/utilities.py | 3 +- .../extensions/send2ue_material_pipeline.py | 71 +++++++++++++++++++ 2 files changed, 73 insertions(+), 1 deletion(-) create mode 100644 src/addons/send2ue/resources/extensions/send2ue_material_pipeline.py diff --git a/src/addons/send2ue/core/utilities.py b/src/addons/send2ue/core/utilities.py index c7acaed3..faf31951 100644 --- a/src/addons/send2ue/core/utilities.py +++ b/src/addons/send2ue/core/utilities.py @@ -1258,7 +1258,8 @@ def select_all_children(scene_object, object_type, exclude_postfix_tokens=False) if any(child_object.name.startswith(f'{token.value}_') for token in PreFixToken): continue - child_object.select_set(True) + if any(view_layer_object == child_object for view_layer_object in bpy.context.view_layer.objects): + child_object.select_set(True) if child_object.children: select_all_children(child_object, object_type, exclude_postfix_tokens) diff --git a/src/addons/send2ue/resources/extensions/send2ue_material_pipeline.py b/src/addons/send2ue/resources/extensions/send2ue_material_pipeline.py new file mode 100644 index 00000000..c61c65d9 --- /dev/null +++ b/src/addons/send2ue/resources/extensions/send2ue_material_pipeline.py @@ -0,0 +1,71 @@ +# send2ue extension: import 직후 머티리얼 파이프라인 자동 실행 +# ============================================================= +# send2ue 가 FBX 를 언리얼로 import 한 "직후" post_import hook 이 호출된다. +# 이 hook 에서 언리얼에 RPC 로 ue_material_setup.process_mesh() 를 실행시킨다. +# +# 이 방식의 장점: +# - on_asset_post_import 델리게이트와 달리 send2ue 가 직접 호출하므로 +# Interchange/Legacy 어느 import 경로든 100% 트리거된다. +# - 이 시점엔 메쉬+텍스처가 모두 import 완료된 상태 → 타이밍 문제 없음. +# +# 설치 위치(둘 중 하나): +# A) send2ue/resources/extensions/ (자동 로드, 단 send2ue 업데이트 시 재복사 필요) +# B) send2ue Preferences 의 Extension Folder 로 이 파일이 있는 폴더를 등록 +# +# 실제 처리 로직은 언리얼 측 ue_material_setup.py 가 담당한다(수정은 거기서). + +import os +import bpy +from send2ue.core.extension import ExtensionBase +from send2ue.constants import UnrealTypes +from send2ue.dependencies.unreal import run_commands + +# 언리얼에서 ue_material_setup.py 를 찾을 경로. +# UE_BLENDER_PIPELINE_DIR 환경변수로 덮어쓸 수 있고, 없으면 ~/Documents/UE_Blender_Pipeline 사용. +PIPELINE_DIR = os.environ.get( + 'UE_BLENDER_PIPELINE_DIR', + os.path.join(os.path.expanduser('~'), 'Documents', 'UE_Blender_Pipeline') +).replace('\\', '/') + + +class MaterialPipelineExtension(ExtensionBase): + name = 'material_pipeline' + + enabled: bpy.props.BoolProperty( + name="Auto-setup materials on import", + default=True, + description=( + "Import 후 이름이 매칭되는(M_뗀 머티리얼명이 메쉬명으로 시작) StaticMesh 에 대해 " + "텍스처를 /Game/Textures 로 옮기고 M_Prop_Master 인스턴스를 생성/연결/할당한다." + ), + ) + + def post_import(self, asset_data, properties): + """각 에셋 import 직후 호출. StaticMesh 면 언리얼에서 후처리 실행.""" + if not self.enabled: + return + if asset_data.get('skip'): + return + if asset_data.get('_asset_type') != UnrealTypes.STATIC_MESH: + return + + asset_path = asset_data.get('asset_path') + if not asset_path: + return + + # 언리얼 에디터에서 실행될 파이썬 명령들 + commands = [ + 'import sys', + f'_d = r"{PIPELINE_DIR}"', + 'sys.path.append(_d) if _d not in sys.path else None', + 'import importlib', + 'import ue_material_setup as _p', + 'importlib.reload(_p)', + f'_p.process_mesh(r"{asset_path}")', + ] + run_commands(commands) + + def draw_import(self, dialog, layout, properties): + box = layout.box() + box.label(text='Material Pipeline (M_Prop_Master 자동 연결):') + dialog.draw_property(self, box, 'enabled') From 0f30304883d8111127ca88d2c3dd26843cde1076 Mon Sep 17 00:00:00 2001 From: 424 <2421412@naver.com> Date: Tue, 16 Jun 2026 22:28:24 +0900 Subject: [PATCH 06/25] Auto-derive Unreal mesh folder path from .blend file location On file load, setup_project sets scene.send2ue.unreal_mesh_folder_path from the .blend file path so it mirrors the on-disk folder structure: the Forestportfolio anchor maps to /Game/Meshes (e.g. ...\Forestportfolio\00_common\prop -> /Game/Meshes/00_common/prop/). Files outside the anchor are left untouched. Co-Authored-By: Claude Opus 4.8 --- src/addons/send2ue/core/utilities.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/addons/send2ue/core/utilities.py b/src/addons/send2ue/core/utilities.py index faf31951..25dacd2e 100644 --- a/src/addons/send2ue/core/utilities.py +++ b/src/addons/send2ue/core/utilities.py @@ -1174,6 +1174,20 @@ def setup_project(*args): if not properties: bpy.app.timers.register(setup_project, first_interval=0.1) + # auto-derive the unreal mesh folder path from the .blend file's on-disk location so it + # always mirrors the folder structure. the anchor folder maps to /Game/Meshes, e.g. + # ...\Forestportfolio\00_common\prop\foo.blend -> /Game/Meshes/00_common/prop/ + if properties and bpy.data.filepath: + anchor = 'Forestportfolio/' + file_path = bpy.data.filepath.replace('\\', '/') + if anchor in file_path: + relative_folder = file_path.split(anchor, 1)[-1].rsplit('/', 1)[0] + unreal_path = f'/Game/Meshes/{relative_folder}/' + for scene in bpy.data.scenes: + scene_properties = getattr(scene, ToolInfo.NAME.value, None) + if scene_properties: + scene_properties.unreal_mesh_folder_path = unreal_path + # ensure the extension draws are created bpy.ops.send2ue.reload_extensions() From 5c55db1e4e6455c6dc6b0eee3e6fd28e83ca3134 Mon Sep 17 00:00:00 2001 From: 424 <2421412@naver.com> Date: Tue, 16 Jun 2026 22:57:40 +0900 Subject: [PATCH 07/25] Sync mesh folder path on save too, plus edge-case hardening - Extract path derivation into sync_unreal_mesh_folder_path() and also register it on save_post, so a Save-As into a new folder updates the path (load_post alone missed saves). - Case-insensitive anchor match with a path-boundary check (avoids false matches). - Guard files sitting directly in the anchor folder (no subfolder) so the file name is not turned into a folder. - Wrap the assignment in try/except so a failure never breaks send2ue project setup. Co-Authored-By: Claude Opus 4.8 --- src/addons/send2ue/__init__.py | 5 +++ src/addons/send2ue/core/utilities.py | 56 +++++++++++++++++++++------- 2 files changed, 48 insertions(+), 13 deletions(-) diff --git a/src/addons/send2ue/__init__.py b/src/addons/send2ue/__init__.py index 5177224d..cc72aff6 100644 --- a/src/addons/send2ue/__init__.py +++ b/src/addons/send2ue/__init__.py @@ -69,6 +69,9 @@ def register(): # add an event handler that will run on new file loads bpy.app.handlers.load_post.append(bpy.app.handlers.persistent(utilities.setup_project)) + # keep the unreal mesh folder path in sync after a save (e.g. "Save As" to a new folder) + bpy.app.handlers.save_post.append(bpy.app.handlers.persistent(utilities.sync_unreal_mesh_folder_path)) + # add a function to the event timer that will fire after the addon is enabled bpy.app.timers.register(utilities.addon_enabled, first_interval=0.1) @@ -80,6 +83,8 @@ def unregister(): # remove event handlers if utilities.setup_project in bpy.app.handlers.load_post: bpy.app.handlers.load_post.remove(utilities.setup_project) + if utilities.sync_unreal_mesh_folder_path in bpy.app.handlers.save_post: + bpy.app.handlers.save_post.remove(utilities.sync_unreal_mesh_folder_path) try: # remove the pipeline menu diff --git a/src/addons/send2ue/core/utilities.py b/src/addons/send2ue/core/utilities.py index 25dacd2e..e163f00f 100644 --- a/src/addons/send2ue/core/utilities.py +++ b/src/addons/send2ue/core/utilities.py @@ -1157,6 +1157,47 @@ def addon_enabled(*args): setup_project() +def sync_unreal_mesh_folder_path(*args): + """ + Derives the unreal mesh folder path from the .blend file's on-disk location so it always + mirrors the folder structure. The anchor folder maps to /Game/Meshes, e.g. + ...\\Forestportfolio\\00_common\\prop\\foo.blend -> /Game/Meshes/00_common/prop/ + + Runs on file load (via setup_project) and after save, so a "Save As" into a new folder + updates the path too. Files outside the anchor folder are left untouched. + + :param args: This soaks up the extra arguments for the app handler. + """ + file_path = bpy.data.filepath.replace('\\', '/') + if not file_path: + return + + # locate the anchor folder (case-insensitive) and require it to sit on a path boundary + anchor = 'Forestportfolio/' + anchor_index = file_path.lower().find(anchor.lower()) + if anchor_index == -1: + return + if anchor_index != 0 and file_path[anchor_index - 1] != '/': + return + + # everything after the anchor, minus the file name; empty when the file sits directly + # in the anchor folder (avoids turning the file name into a folder) + remainder = file_path[anchor_index + len(anchor):] + relative_folder = remainder.rsplit('/', 1)[0] if '/' in remainder else '' + + unreal_path = '/Game/Meshes/' + if relative_folder: + unreal_path += f'{relative_folder}/' + + for scene in bpy.data.scenes: + scene_properties = getattr(scene, ToolInfo.NAME.value, None) + if scene_properties: + try: + scene_properties.unreal_mesh_folder_path = unreal_path + except Exception as error: + print(f'send2ue: could not set unreal_mesh_folder_path: {error}') + + def setup_project(*args): """ This is run when the integration launches, and on new file load events. @@ -1174,19 +1215,8 @@ def setup_project(*args): if not properties: bpy.app.timers.register(setup_project, first_interval=0.1) - # auto-derive the unreal mesh folder path from the .blend file's on-disk location so it - # always mirrors the folder structure. the anchor folder maps to /Game/Meshes, e.g. - # ...\Forestportfolio\00_common\prop\foo.blend -> /Game/Meshes/00_common/prop/ - if properties and bpy.data.filepath: - anchor = 'Forestportfolio/' - file_path = bpy.data.filepath.replace('\\', '/') - if anchor in file_path: - relative_folder = file_path.split(anchor, 1)[-1].rsplit('/', 1)[0] - unreal_path = f'/Game/Meshes/{relative_folder}/' - for scene in bpy.data.scenes: - scene_properties = getattr(scene, ToolInfo.NAME.value, None) - if scene_properties: - scene_properties.unreal_mesh_folder_path = unreal_path + # keep the unreal mesh folder path in sync with the .blend file's on-disk location + sync_unreal_mesh_folder_path() # ensure the extension draws are created bpy.ops.send2ue.reload_extensions() From 03859e38736d775dff37baf61d3ea059138ee081 Mon Sep 17 00:00:00 2001 From: 424 <2421412@naver.com> Date: Tue, 16 Jun 2026 23:50:58 +0900 Subject: [PATCH 08/25] Avoid Unreal RPC path validation on every save Setting unreal_mesh_folder_path runs send2ue's update callback, which calls is_connected() and UnrealRemoteCalls.directory_exists() over RPC - adding a per-save delay. Now the sync only assigns when the value actually changes and disables window_manager.send2ue.path_validation during the assignment (the same approach set_active_template uses), so saves no longer trigger an Unreal round-trip. Co-Authored-By: Claude Opus 4.8 --- src/addons/send2ue/core/utilities.py | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/src/addons/send2ue/core/utilities.py b/src/addons/send2ue/core/utilities.py index e163f00f..5881c962 100644 --- a/src/addons/send2ue/core/utilities.py +++ b/src/addons/send2ue/core/utilities.py @@ -1189,13 +1189,24 @@ def sync_unreal_mesh_folder_path(*args): if relative_folder: unreal_path += f'{relative_folder}/' - for scene in bpy.data.scenes: - scene_properties = getattr(scene, ToolInfo.NAME.value, None) - if scene_properties: - try: + # disable path validation while assigning so we don't trigger an Unreal RPC round-trip + # (a folder-exists check) on every save - same approach send2ue uses in set_active_template + window_manager_properties = getattr(bpy.context.window_manager, ToolInfo.NAME.value, None) + previous_validation = getattr(window_manager_properties, 'path_validation', None) + if window_manager_properties: + window_manager_properties.path_validation = False + try: + for scene in bpy.data.scenes: + scene_properties = getattr(scene, ToolInfo.NAME.value, None) + # only assign when the value actually changes, so repeated saves of the same file + # don't re-run the update callback at all + if scene_properties and scene_properties.unreal_mesh_folder_path != unreal_path: scene_properties.unreal_mesh_folder_path = unreal_path - except Exception as error: - print(f'send2ue: could not set unreal_mesh_folder_path: {error}') + except Exception as error: + print(f'send2ue: could not set unreal_mesh_folder_path: {error}') + finally: + if window_manager_properties and previous_validation is not None: + window_manager_properties.path_validation = previous_validation def setup_project(*args): From e8c31f13d312383001394805ec1c8b1bb5b30309 Mon Sep 17 00:00:00 2001 From: 424 <2421412@naver.com> Date: Sun, 21 Jun 2026 21:18:57 +0900 Subject: [PATCH 09/25] Add armature-child binding fix, Hair Tool export, and skip-animation toggle - armature_modifier_fix: meshes parented to an exported armature but missing an armature modifier now get a temporary single-bone binding during export, so the fbx carries valid skin weights instead of crashing Unreal's importer. Undone in post_operation, so the user's scene is left untouched. Only acts when a modifier is actually missing (unlike the always-on Hair Tool conversion). - hair_tool_export: convert live Hair Tool curve systems to temporary export-only meshes with head-bone skinning and FACE smoothing; filter prepared sources out of groom export. - skip_animation_export: one toggle to skip all animation export and stop the skeletal-mesh fbx from baking the rig's NLA strips. Co-Authored-By: Claude Opus 4.8 --- .../send2ue/core/armature_modifier_fix.py | 145 +++++++++ src/addons/send2ue/core/export.py | 32 +- src/addons/send2ue/core/hair_tool_export.py | 277 ++++++++++++++++++ src/addons/send2ue/operators.py | 18 +- src/addons/send2ue/properties.py | 10 + src/addons/send2ue/ui/dialog.py | 1 + 6 files changed, 477 insertions(+), 6 deletions(-) create mode 100644 src/addons/send2ue/core/armature_modifier_fix.py create mode 100644 src/addons/send2ue/core/hair_tool_export.py diff --git a/src/addons/send2ue/core/armature_modifier_fix.py b/src/addons/send2ue/core/armature_modifier_fix.py new file mode 100644 index 00000000..38d5f887 --- /dev/null +++ b/src/addons/send2ue/core/armature_modifier_fix.py @@ -0,0 +1,145 @@ +# Copyright Epic Games, Inc. All Rights Reserved. + +import bpy +from . import utilities +from ..constants import BlenderTypes + + +STATE_KEY = 'send2ue_armature_modifier_fix_state' + + +def _get_parent_rig_object(mesh_object, rig_objects): + """ + Gets the armature this mesh is parented to (directly or up the parent chain), + but only if that armature is one of the rigs that will be exported. + + A mesh that lives "inside" an armature like this but has no armature modifier + still gets bundled into the skeletal mesh export by ``set_parent_rig_selection``. + Without skin binding the resulting fbx makes Unreal's importer crash, so we + need to detect this case and give it a valid binding before export. + + :param object mesh_object: A object of type mesh. + :param list rig_objects: The armature objects that are being exported. + :return object: The parent armature object, or None. + """ + parent = mesh_object.parent + while parent: + if parent.type == BlenderTypes.SKELETON and parent in rig_objects: + return parent + parent = parent.parent + return None + + +def _get_bind_bone_name(mesh_object, rig_object): + """ + Picks the bone to rigidly bind a mesh to when it has no vertex groups of its own. + + Bone-parented meshes follow a specific bone, so we bind to that bone. Object + parented meshes follow the whole armature, so the root deform bone reproduces + that rigid attachment. + + :param object mesh_object: A object of type mesh. + :param object rig_object: The parent armature object. + :return str: A bone name, or None if the armature has no bones. + """ + bones = rig_object.data.bones + if not bones: + return None + + # a mesh parented directly to a bone follows that bone + if mesh_object.parent_type == 'BONE' and mesh_object.parent_bone in bones: + return mesh_object.parent_bone + + deform_bones = [bone for bone in bones if bone.use_deform] + candidate_bones = deform_bones or list(bones) + + # prefer a root bone (no parent) so the mesh follows the armature as a whole + for bone in candidate_bones: + if bone.parent is None: + return bone.name + return candidate_bones[0].name + + +def prepare(): + """ + Adds a temporary armature modifier to any export mesh that is parented to an + exported armature but is missing one. Meshes with no vertex groups are also + given a single full-weight group bound to a bone, so the fbx exports with a + valid skin instead of crashing Unreal on import. + + Unlike the Hair Tool conversion, this only acts when an armature modifier is + actually missing. Everything it adds is tracked and removed again in cleanup, + so the user's scene is left untouched. + """ + cleanup() + + mesh_objects = utilities.get_from_collection(BlenderTypes.MESH) + rig_objects = utilities.get_from_collection(BlenderTypes.SKELETON) + if not rig_objects: + return + + state = { + 'modifiers': [], # list of (object_name, modifier_name) + 'vertex_groups': [], # list of (object_name, vertex_group_name) + } + bpy.app.driver_namespace[STATE_KEY] = state + + try: + for mesh_object in mesh_objects: + # leave meshes that already have an armature modifier alone + if any(modifier.type == 'ARMATURE' for modifier in mesh_object.modifiers): + continue + + rig_object = _get_parent_rig_object(mesh_object, rig_objects) + if not rig_object: + continue + + # if the mesh has no weights at all, bind every vertex to a single bone + # so the fbx exporter writes valid skin weights + if not mesh_object.vertex_groups: + bone_name = _get_bind_bone_name(mesh_object, rig_object) + # no bone to bind to means there is nothing we can do to make this a + # valid skin, so leave the mesh untouched rather than add a dangling + # modifier with no weights + if not bone_name: + continue + vertex_group = mesh_object.vertex_groups.new(name=bone_name) + vertex_group.add( + range(len(mesh_object.data.vertices)), + 1.0, + 'REPLACE', + ) + state['vertex_groups'].append((mesh_object.name, vertex_group.name)) + + armature_modifier = mesh_object.modifiers.new(name='Armature', type='ARMATURE') + armature_modifier.object = rig_object + state['modifiers'].append((mesh_object.name, armature_modifier.name)) + + except Exception: + cleanup() + raise + + +def cleanup(): + """ + Removes the armature modifiers and vertex groups added by ``prepare``. + """ + state = bpy.app.driver_namespace.pop(STATE_KEY, None) + if not state: + return + + for object_name, modifier_name in state.get('modifiers', []): + scene_object = bpy.data.objects.get(object_name) + if not scene_object: + continue + modifier = scene_object.modifiers.get(modifier_name) + if modifier: + scene_object.modifiers.remove(modifier) + + for object_name, vertex_group_name in state.get('vertex_groups', []): + scene_object = bpy.data.objects.get(object_name) + if not scene_object: + continue + vertex_group = scene_object.vertex_groups.get(vertex_group_name) + if vertex_group: + scene_object.vertex_groups.remove(vertex_group) diff --git a/src/addons/send2ue/core/export.py b/src/addons/send2ue/core/export.py index 6c6c252b..9f7b5dba 100644 --- a/src/addons/send2ue/core/export.py +++ b/src/addons/send2ue/core/export.py @@ -4,7 +4,7 @@ import math import os import bpy -from . import utilities, validations, settings, ingest, extension, io +from . import utilities, validations, settings, ingest, extension, io, hair_tool_export from ..constants import BlenderTypes, UnrealTypes, FileTypes, PreFixToken, ToolInfo, ExtensionTasks @@ -174,6 +174,17 @@ def export_file(properties, lod=0, file_type=FileTypes.FBX): for attribute_name in group_data.keys(): export_settings[attribute_name] = settings.get_property_by_path(prefix, attribute_name, properties) + # when skipping animation export, force the fbx exporter to not bake any animation so the + # skeletal mesh fbx is not slowed down by baking the rig's (potentially very long) nla strips + if file_type == FileTypes.FBX and properties.skip_animation_export: + export_settings['bake_anim'] = False + + # Hair Tool export meshes are generated temporarily and must always carry + # FBX smoothing groups so Unreal can preserve their evaluated normals. + mesh_object = bpy.data.objects.get(asset_data.get('_mesh_object_name', '')) + if file_type == FileTypes.FBX and mesh_object and mesh_object.get(hair_tool_export.TEMP_PROPERTY): + export_settings['mesh_smooth_type'] = 'FACE' + if file_type == FileTypes.FBX: export_fbx_file(file_path, export_settings) @@ -357,6 +368,10 @@ def create_animation_data(rig_objects, properties): """ animation_data = {} + # when animation export is skipped, do not export any actions as animation fbx files + if properties.skip_animation_export: + return animation_data + if properties.import_animations: # get the asset data for the skeletal animations for rig_object in rig_objects: @@ -415,7 +430,8 @@ def create_mesh_data(mesh_objects, rig_objects, properties): # get the asset data for the scene objects for mesh_object in mesh_objects: already_exported = False - asset_name = utilities.get_asset_name(mesh_object.name, properties) + source_name = hair_tool_export.get_asset_source_name(mesh_object) + asset_name = utilities.get_asset_name(source_name, properties) # only export meshes that are lod 0 if properties.import_lods and utilities.get_lod_index(mesh_object.name, properties) != 0: @@ -431,7 +447,7 @@ def create_mesh_data(mesh_objects, rig_objects, properties): if not already_exported: asset_type = utilities.get_mesh_unreal_type(mesh_object) # get file path - file_path = get_file_path(mesh_object.name, properties, asset_type, lod=False) + file_path = get_file_path(source_name, properties, asset_type, lod=False) # export the object asset_id = utilities.get_asset_id(file_path) export_mesh(asset_id, mesh_object, properties) @@ -511,6 +527,14 @@ def create_asset_data(properties): mesh_objects = utilities.get_from_collection(BlenderTypes.MESH) rig_objects = utilities.get_from_collection(BlenderTypes.SKELETON) hair_objects = utilities.get_hair_objects(properties) + hair_objects = [ + hair_object + for hair_object in hair_objects + if not ( + isinstance(hair_object, bpy.types.Object) + and hair_tool_export.is_prepared_source(hair_object) + ) + ] # filter the rigs and meshes based on the extension filter methods rig_objects, mesh_objects, hair_objects = extension.run_extension_filters( @@ -552,4 +576,4 @@ def send2ue(properties): create_asset_data(properties) ingest.assets(properties) - bpy.context.window_manager.send2ue.object_collection_override.clear() # type: ignore \ No newline at end of file + bpy.context.window_manager.send2ue.object_collection_override.clear() # type: ignore diff --git a/src/addons/send2ue/core/hair_tool_export.py b/src/addons/send2ue/core/hair_tool_export.py new file mode 100644 index 00000000..b8e45d75 --- /dev/null +++ b/src/addons/send2ue/core/hair_tool_export.py @@ -0,0 +1,277 @@ +# Copyright Epic Games, Inc. All Rights Reserved. + +import bpy + + +STATE_KEY = 'send2ue_hair_tool_export_state' +SOURCE_NAME_PROPERTY = '_send2ue_hair_tool_source_name' +TEMP_PROPERTY = '_send2ue_hair_tool_temp' +RSAO_NAME = 'RSAO' + + +def is_hair_tool_object(scene_object): + """Return whether an object is a live Hair Tool geometry-nodes system.""" + if not scene_object or scene_object.type != 'CURVES': + return False + + node_group_names = { + modifier.node_group.name + for modifier in scene_object.modifiers + if modifier.type == 'NODES' and modifier.node_group + } + return ( + 'Hair_System_Setup' in node_group_names + and any(name.startswith('Hair_System_Profile') for name in node_group_names) + ) + + +def is_prepared_source(scene_object): + state = bpy.app.driver_namespace.get(STATE_KEY, {}) + return scene_object.name in state.get('source_names', set()) + + +def _get_armature(scene_object): + for modifier in scene_object.modifiers: + if modifier.type == 'ARMATURE' and modifier.object and modifier.object.type == 'ARMATURE': + return modifier.object + + parent = scene_object.parent + while parent: + if parent.type == 'ARMATURE': + return parent + parent = parent.parent + return None + + +def _get_head_bone_name(armature_object): + deform_bones = [bone for bone in armature_object.data.bones if bone.use_deform] + + for bone in deform_bones: + if bone.name.casefold() == 'head': + return bone.name + for bone in deform_bones: + if bone.name.casefold().endswith('_head'): + return bone.name + for bone in deform_bones: + if 'head' in bone.name.casefold(): + return bone.name + if len(deform_bones) == 1: + return deform_bones[0].name + + raise RuntimeError( + f'Unable to find a head bone on armature "{armature_object.name}". ' + 'Expected "head", a name ending in "_Head", or a single deform bone.' + ) + + +def _attribute_scalar(mesh, attribute, loop_index): + if not attribute: + return 0.0 + + loop = mesh.loops[loop_index] + if attribute.domain == 'POINT': + data_index = loop.vertex_index + elif attribute.domain == 'CORNER': + data_index = loop_index + elif attribute.domain == 'FACE': + data_index = loop.polygon_index + else: + return 0.0 + + item = attribute.data[data_index] + if hasattr(item, 'value'): + return float(item.value) + if hasattr(item, 'color'): + return float(item.color[0]) + if hasattr(item, 'vector'): + return float(item.vector[0]) + return 0.0 + + +def _pack_rsao(mesh): + random_attribute = mesh.attributes.get('Random') + system_color_attribute = mesh.attributes.get('SystemColor') + ao_attribute = mesh.attributes.get('AO') + + existing_rsao = mesh.attributes.get(RSAO_NAME) + if existing_rsao: + mesh.attributes.remove(existing_rsao) + + rsao = mesh.color_attributes.new( + name=RSAO_NAME, + type='BYTE_COLOR', + domain='CORNER', + ) + for loop_index, color_item in enumerate(rsao.data): + color_item.color = ( + _attribute_scalar(mesh, random_attribute, loop_index), + _attribute_scalar(mesh, system_color_attribute, loop_index), + _attribute_scalar(mesh, ao_attribute, loop_index), + 1.0, + ) + + for color_attribute in list(mesh.color_attributes): + if color_attribute.name != RSAO_NAME: + mesh.color_attributes.remove(color_attribute) + + mesh.color_attributes.active_color = mesh.color_attributes[RSAO_NAME] + mesh.color_attributes.render_color_index = mesh.color_attributes.find(RSAO_NAME) + + +def _write_uv_layer(mesh, source_attribute_name, target_uv_name): + source_attribute = mesh.attributes.get(source_attribute_name) + if ( + not source_attribute + or source_attribute.domain != 'CORNER' + or source_attribute.data_type not in {'FLOAT_VECTOR', 'FLOAT2'} + ): + return False + + existing_target = mesh.attributes.get(target_uv_name) + if existing_target and existing_target != source_attribute: + mesh.attributes.remove(existing_target) + + uv_layer = mesh.uv_layers.get(target_uv_name) + if not uv_layer: + uv_layer = mesh.uv_layers.new(name=target_uv_name) + + for loop_index, uv_item in enumerate(uv_layer.data): + source_item = source_attribute.data[loop_index] + source_vector = source_item.vector + uv_item.uv = (source_vector[0], source_vector[1]) + return True + + +def _write_hair_tool_uvs(mesh): + if not _write_uv_layer(mesh, 'UVMapGN', 'UVMap'): + raise RuntimeError( + f'Hair Tool mesh "{mesh.name}" has no usable UVMapGN corner attribute.' + ) + _write_uv_layer(mesh, 'UVHelperGN', 'HairTool_UV') + mesh.uv_layers.active = mesh.uv_layers.get('UVMap') + mesh.uv_layers['UVMap'].active_render = True + + +def _evaluated_mesh_objects(source_object, state): + depsgraph = bpy.context.evaluated_depsgraph_get() + created_objects = [] + + for instance in depsgraph.object_instances: + parent = instance.parent + original_object = instance.object.original + original_parent = parent.original if parent else None + if instance.object.type != 'MESH': + continue + if original_object != source_object and original_parent != source_object: + continue + + instance_matrix = instance.matrix_world.copy() + mesh = bpy.data.meshes.new_from_object(instance.object) + _write_hair_tool_uvs(mesh) + _pack_rsao(mesh) + + mesh_object = bpy.data.objects.new( + f'__S2U_HAIR_PART_{source_object.name}', + mesh, + ) + bpy.context.scene.collection.objects.link(mesh_object) + mesh_object.matrix_world = instance_matrix + created_objects.append(mesh_object) + state['temporary_object_names'].add(mesh_object.name) + + return created_objects + + +def _join_objects(objects): + if not objects: + return None + if len(objects) == 1: + return objects[0] + + bpy.ops.object.select_all(action='DESELECT') + for scene_object in objects: + scene_object.select_set(True) + bpy.context.view_layer.objects.active = objects[0] + bpy.ops.object.join() + return objects[0] + + +def _link_to_export_collection(scene_object, export_collection): + for collection in list(scene_object.users_collection): + collection.objects.unlink(scene_object) + export_collection.objects.link(scene_object) + + +def prepare(): + """Create export-only mesh copies for Hair Tool systems in the Export collection.""" + cleanup() + + export_collection = bpy.data.collections.get('Export') + if not export_collection: + return + + source_objects = [ + scene_object + for scene_object in export_collection.all_objects + if is_hair_tool_object(scene_object) + ] + state = { + 'source_names': {scene_object.name for scene_object in source_objects}, + 'temporary_object_names': set(), + } + bpy.app.driver_namespace[STATE_KEY] = state + + try: + for source_object in source_objects: + parts = _evaluated_mesh_objects(source_object, state) + if not parts: + raise RuntimeError( + f'Hair Tool object "{source_object.name}" produced no evaluated mesh geometry.' + ) + + temporary_object = _join_objects(parts) + temporary_object.name = f'{source_object.name}__S2U_HAIR' + temporary_object.data.name = f'{source_object.data.name}__S2U_HAIR' + temporary_object[SOURCE_NAME_PROPERTY] = source_object.name + temporary_object[TEMP_PROPERTY] = True + _link_to_export_collection(temporary_object, export_collection) + state['temporary_object_names'].add(temporary_object.name) + + armature_object = _get_armature(source_object) + if armature_object: + head_bone_name = _get_head_bone_name(armature_object) + vertex_group = temporary_object.vertex_groups.new(name=head_bone_name) + vertex_group.add( + range(len(temporary_object.data.vertices)), + 1.0, + 'REPLACE', + ) + armature_modifier = temporary_object.modifiers.new( + name='Armature', + type='ARMATURE', + ) + armature_modifier.object = armature_object + + except Exception: + cleanup() + raise + + +def cleanup(): + """Remove temporary Hair Tool export meshes without touching source systems.""" + state = bpy.app.driver_namespace.pop(STATE_KEY, None) + if not state: + return + + for object_name in state.get('temporary_object_names', set()): + scene_object = bpy.data.objects.get(object_name) + if not scene_object: + continue + mesh = scene_object.data if scene_object.type == 'MESH' else None + bpy.data.objects.remove(scene_object, do_unlink=True) + if mesh and mesh.users == 0: + bpy.data.meshes.remove(mesh) + + +def get_asset_source_name(mesh_object): + return mesh_object.get(SOURCE_NAME_PROPERTY, mesh_object.name) diff --git a/src/addons/send2ue/operators.py b/src/addons/send2ue/operators.py index c9b4fd64..cb092151 100644 --- a/src/addons/send2ue/operators.py +++ b/src/addons/send2ue/operators.py @@ -5,7 +5,7 @@ import queue import threading from .constants import ToolInfo, ExtensionTasks -from .core import export, utilities, settings, validations, extension +from .core import export, utilities, settings, validations, extension, hair_tool_export, armature_modifier_fix from .ui import file_browser, dialog, addon_preferences from .dependencies import unreal from .dependencies.rpc import blender_server @@ -160,7 +160,22 @@ def pre_operation(self): # run the pre export extensions extension.run_extension_tasks(ExtensionTasks.PRE_OPERATION.value) + # Convert live Hair Tool systems to export-only mesh copies. These temporary + # objects carry a single RSAO color layer and optional head-bone skinning. + hair_tool_export.prepare() + + # Give meshes that live inside an armature but lack an armature modifier a + # temporary binding, so they export with valid skin weights instead of + # crashing Unreal's importer. + armature_modifier_fix.prepare() + def post_operation(self): + # Remove the temporary armature bindings added for un-skinned child meshes. + armature_modifier_fix.cleanup() + + # Remove export-only Hair Tool meshes before restoring the user's context. + hair_tool_export.cleanup() + # run the post export extensions extension.run_extension_tasks(ExtensionTasks.POST_OPERATION.value) @@ -403,4 +418,3 @@ def unregister(): for operator_class in operator_classes: if utilities.get_operator_class_by_bl_idname(operator_class.bl_idname): bpy.utils.unregister_class(operator_class) - diff --git a/src/addons/send2ue/properties.py b/src/addons/send2ue/properties.py index 44ed2ab1..7db10c56 100644 --- a/src/addons/send2ue/properties.py +++ b/src/addons/send2ue/properties.py @@ -282,6 +282,16 @@ class Send2UeSceneProperties(property_class): "disk. The file names will match either the name of the curves object or that of the particle system." ) ) # type: ignore + skip_animation_export: bpy.props.BoolProperty( + name="Skip animation export", + default=False, + description=( + "When enabled, no animation is exported at all. This skips exporting each action as its own " + "animation FBX, AND forces the skeletal mesh FBX to be exported without baking the rig's NLA " + "strips (bake_anim off). Use this when animation is handled in a separate pipeline, to avoid the " + "very slow export caused by long animation data being baked into the FBX files" + ) + ) # type: ignore export_all_actions: bpy.props.BoolProperty( name="Export all actions", default=True, diff --git a/src/addons/send2ue/ui/dialog.py b/src/addons/send2ue/ui/dialog.py index 2ab6a2ea..b4511d69 100644 --- a/src/addons/send2ue/ui/dialog.py +++ b/src/addons/send2ue/ui/dialog.py @@ -64,6 +64,7 @@ def draw_export_tab(self, layout): self.draw_property(properties, layout, 'use_object_origin') self.draw_property(properties, layout, 'export_object_name_as_root') self.draw_property(properties, layout, 'export_custom_root_name', enabled=not properties.export_object_name_as_root) + self.draw_property(properties, layout, 'skip_animation_export') # animation settings box self.draw_expanding_section( From f343c4690673b9ed685a1b046038f676a3b7faac Mon Sep 17 00:00:00 2001 From: 424 <2421412@naver.com> Date: Tue, 23 Jun 2026 01:46:50 +0900 Subject: [PATCH 10/25] Support Hair Tool mesh export workflow --- .../send2ue/core/armature_modifier_fix.py | 19 +- src/addons/send2ue/core/hair_tool_export.py | 164 +++++++++++++++--- src/addons/send2ue/core/utilities.py | 84 +++++++-- .../resources/extensions/combine_assets.py | 3 +- 4 files changed, 229 insertions(+), 41 deletions(-) diff --git a/src/addons/send2ue/core/armature_modifier_fix.py b/src/addons/send2ue/core/armature_modifier_fix.py index 38d5f887..ffe8a127 100644 --- a/src/addons/send2ue/core/armature_modifier_fix.py +++ b/src/addons/send2ue/core/armature_modifier_fix.py @@ -8,26 +8,27 @@ STATE_KEY = 'send2ue_armature_modifier_fix_state' -def _get_parent_rig_object(mesh_object, rig_objects): +def get_top_parent_rig_object(scene_object, rig_objects): """ - Gets the armature this mesh is parented to (directly or up the parent chain), - but only if that armature is one of the rigs that will be exported. + Gets the highest exported armature in an object's parent chain. A mesh that lives "inside" an armature like this but has no armature modifier still gets bundled into the skeletal mesh export by ``set_parent_rig_selection``. Without skin binding the resulting fbx makes Unreal's importer crash, so we need to detect this case and give it a valid binding before export. - :param object mesh_object: A object of type mesh. + :param object scene_object: A Blender object. :param list rig_objects: The armature objects that are being exported. - :return object: The parent armature object, or None. + :return object: The highest parent armature object, or None. """ - parent = mesh_object.parent + rig_objects = set(rig_objects) + top_rig_object = None + parent = scene_object.parent while parent: if parent.type == BlenderTypes.SKELETON and parent in rig_objects: - return parent + top_rig_object = parent parent = parent.parent - return None + return top_rig_object def _get_bind_bone_name(mesh_object, rig_object): @@ -90,7 +91,7 @@ def prepare(): if any(modifier.type == 'ARMATURE' for modifier in mesh_object.modifiers): continue - rig_object = _get_parent_rig_object(mesh_object, rig_objects) + rig_object = get_top_parent_rig_object(mesh_object, rig_objects) if not rig_object: continue diff --git a/src/addons/send2ue/core/hair_tool_export.py b/src/addons/send2ue/core/hair_tool_export.py index b8e45d75..324ae86e 100644 --- a/src/addons/send2ue/core/hair_tool_export.py +++ b/src/addons/send2ue/core/hair_tool_export.py @@ -1,6 +1,8 @@ # Copyright Epic Games, Inc. All Rights Reserved. import bpy +from . import armature_modifier_fix, utilities +from ..constants import BlenderTypes STATE_KEY = 'send2ue_hair_tool_export_state' @@ -11,7 +13,7 @@ def is_hair_tool_object(scene_object): """Return whether an object is a live Hair Tool geometry-nodes system.""" - if not scene_object or scene_object.type != 'CURVES': + if not scene_object or scene_object.type not in {'CURVES', 'MESH'}: return False node_group_names = { @@ -20,7 +22,7 @@ def is_hair_tool_object(scene_object): if modifier.type == 'NODES' and modifier.node_group } return ( - 'Hair_System_Setup' in node_group_names + any(name.startswith('Hair_System_Setup') for name in node_group_names) and any(name.startswith('Hair_System_Profile') for name in node_group_names) ) @@ -30,17 +32,34 @@ def is_prepared_source(scene_object): return scene_object.name in state.get('source_names', set()) +def _get_hair_tool_input_object(scene_object): + """Return the upstream Hair Tool object referenced by Hair System Setup.""" + for modifier in scene_object.modifiers: + if ( + modifier.type != 'NODES' + or not modifier.node_group + or not modifier.node_group.name.startswith('Hair_System_Setup') + ): + continue + try: + input_object = modifier.get('Input_3') + except (KeyError, TypeError): + input_object = None + if isinstance(input_object, bpy.types.Object): + return input_object + return None + + def _get_armature(scene_object): for modifier in scene_object.modifiers: if modifier.type == 'ARMATURE' and modifier.object and modifier.object.type == 'ARMATURE': return modifier.object - parent = scene_object.parent - while parent: - if parent.type == 'ARMATURE': - return parent - parent = parent.parent - return None + exported_rig_objects = utilities.get_from_collection(BlenderTypes.SKELETON) + return armature_modifier_fix.get_top_parent_rig_object( + scene_object, + exported_rig_objects, + ) def _get_head_bone_name(armature_object): @@ -118,6 +137,39 @@ def _pack_rsao(mesh): mesh.color_attributes.render_color_index = mesh.color_attributes.find(RSAO_NAME) +def _evaluate_combined_ao(scene_object, state): + """Evaluate Hair Tool AO once, after all systems for an asset are joined.""" + node_group = bpy.data.node_groups.get('HT_Mesh_AO') + if not node_group: + raise RuntimeError( + 'Hair Tool node group "HT_Mesh_AO" was not found. ' + 'Load the Hair Tool AO node group before exporting.' + ) + + modifier = scene_object.modifiers.new(name='__S2U_HAIR_AO', type='NODES') + modifier.node_group = node_group + if 'Input_7' in modifier: + modifier['Input_7'] = 'AO' + + depsgraph = bpy.context.evaluated_depsgraph_get() + depsgraph.update() + evaluated_object = scene_object.evaluated_get(depsgraph) + evaluated_mesh = bpy.data.meshes.new_from_object(evaluated_object) + + old_mesh = scene_object.data + scene_object.data = evaluated_mesh + scene_object.modifiers.remove(modifier) + if old_mesh.users == 0: + bpy.data.meshes.remove(old_mesh) + + if not evaluated_mesh.attributes.get('AO'): + raise RuntimeError( + f'Hair Tool AO evaluation produced no "AO" attribute for "{scene_object.name}".' + ) + + state['temporary_mesh_names'].add(evaluated_mesh.name) + + def _write_uv_layer(mesh, source_attribute_name, target_uv_name): source_attribute = mesh.attributes.get(source_attribute_name) if ( @@ -167,8 +219,9 @@ def _evaluated_mesh_objects(source_object, state): instance_matrix = instance.matrix_world.copy() mesh = bpy.data.meshes.new_from_object(instance.object) - _write_hair_tool_uvs(mesh) - _pack_rsao(mesh) + if not mesh.vertices or not mesh.polygons: + bpy.data.meshes.remove(mesh) + continue mesh_object = bpy.data.objects.new( f'__S2U_HAIR_PART_{source_object.name}', @@ -202,6 +255,20 @@ def _link_to_export_collection(scene_object, export_collection): export_collection.objects.link(scene_object) +def _asset_group_key(scene_object): + """Group a Hair Tool system by its nearest exported Empty ancestor.""" + export_collection = bpy.data.collections.get('Export') + exported_objects = set(export_collection.all_objects) if export_collection else set() + + parent = scene_object.parent + while parent: + if parent.type == 'EMPTY' and parent in exported_objects: + return parent + parent = parent.parent + + return scene_object.parent or scene_object + + def prepare(): """Create export-only mesh copies for Hair Tool systems in the Export collection.""" cleanup() @@ -210,34 +277,86 @@ def prepare(): if not export_collection: return - source_objects = [ + source_candidates = [ scene_object for scene_object in export_collection.all_objects - if is_hair_tool_object(scene_object) + if ( + export_collection in scene_object.users_collection + and is_hair_tool_object(scene_object) + ) + ] + # If an enabled final Hair Tool output consumes another enabled Hair Tool + # object, export only the downstream output. The upstream Surface/Curve is + # construction data, not an additional hair result. + upstream_sources = { + input_object + for input_object in ( + _get_hair_tool_input_object(scene_object) + for scene_object in source_candidates + ) + if input_object in source_candidates + } + source_objects = [ + scene_object + for scene_object in source_candidates + if scene_object not in upstream_sources ] state = { 'source_names': {scene_object.name for scene_object in source_objects}, 'temporary_object_names': set(), + 'temporary_mesh_names': set(), } bpy.app.driver_namespace[STATE_KEY] = state try: + grouped_sources = {} for source_object in source_objects: - parts = _evaluated_mesh_objects(source_object, state) + grouped_sources.setdefault(_asset_group_key(source_object), []).append(source_object) + + for asset_parent, asset_sources in grouped_sources.items(): + parts = [] + for source_object in asset_sources: + source_parts = _evaluated_mesh_objects(source_object, state) + parts.extend(source_parts) + if not parts: - raise RuntimeError( - f'Hair Tool object "{source_object.name}" produced no evaluated mesh geometry.' - ) + continue + # Joining before AO is intentional: nearby cards and all Hair Tool + # systems in the final asset must occlude each other. temporary_object = _join_objects(parts) - temporary_object.name = f'{source_object.name}__S2U_HAIR' - temporary_object.data.name = f'{source_object.data.name}__S2U_HAIR' - temporary_object[SOURCE_NAME_PROPERTY] = source_object.name + asset_name = asset_parent.name if asset_parent != asset_sources[0] else asset_sources[0].name + temporary_object.name = f'{asset_name}__S2U_HAIR' + temporary_object.data.name = f'{asset_name}__S2U_HAIR' + temporary_object[SOURCE_NAME_PROPERTY] = asset_sources[0].name temporary_object[TEMP_PROPERTY] = True _link_to_export_collection(temporary_object, export_collection) state['temporary_object_names'].add(temporary_object.name) - armature_object = _get_armature(source_object) + # Keep the export copy in the same hierarchy as the source asset. + if asset_parent != asset_sources[0]: + world_matrix = temporary_object.matrix_world.copy() + temporary_object.parent = asset_parent + temporary_object.parent_type = 'OBJECT' + temporary_object.matrix_parent_inverse = ( + asset_parent.matrix_world.inverted() + ) + temporary_object.matrix_world = world_matrix + + _evaluate_combined_ao(temporary_object, state) + _write_hair_tool_uvs(temporary_object.data) + _pack_rsao(temporary_object.data) + + armatures = { + armature + for armature in (_get_armature(source) for source in asset_sources) + if armature + } + if len(armatures) > 1: + raise RuntimeError( + f'Hair Tool asset "{asset_name}" is bound to multiple armatures.' + ) + armature_object = next(iter(armatures), None) if armature_object: head_bone_name = _get_head_bone_name(armature_object) vertex_group = temporary_object.vertex_groups.new(name=head_bone_name) @@ -272,6 +391,11 @@ def cleanup(): if mesh and mesh.users == 0: bpy.data.meshes.remove(mesh) + for mesh_name in state.get('temporary_mesh_names', set()): + mesh = bpy.data.meshes.get(mesh_name) + if mesh and mesh.users == 0: + bpy.data.meshes.remove(mesh) + def get_asset_source_name(mesh_object): return mesh_object.get(SOURCE_NAME_PROPERTY, mesh_object.name) diff --git a/src/addons/send2ue/core/utilities.py b/src/addons/send2ue/core/utilities.py index 5881c962..17f288ff 100644 --- a/src/addons/send2ue/core/utilities.py +++ b/src/addons/send2ue/core/utilities.py @@ -401,7 +401,18 @@ def get_hair_objects(properties): modifiers = get_particle_system_modifiers(mesh_object) hair_objects.extend([modifier.particle_system for modifier in modifiers]) - hair_objects.extend(get_from_collection(BlenderTypes.CURVES)) + # Live Hair Tool curve systems are converted to temporary mesh objects by + # hair_tool_export before validation. Do not also collect their source + # curves as Alembic Grooms. Ordinary curve Groom objects remain unchanged. + from . import hair_tool_export + hair_objects.extend([ + curves_object + for curves_object in get_from_collection(BlenderTypes.CURVES) + if ( + not hair_tool_export.is_hair_tool_object(curves_object) + and not hair_tool_export.is_prepared_source(curves_object) + ) + ]) return hair_objects @@ -445,8 +456,19 @@ def get_from_collection(object_type): if export_collection and not collection_objects: # get all the objects in the collection for collection_object in export_collection.all_objects: # type: ignore + # Export is an explicit activation collection. Objects that only + # appear through nested working collections or parent hierarchy + # are not enabled for export. + if export_collection not in collection_object.users_collection: + continue # if the object is the correct type if collection_object.type == object_type: + # Live Hair Tool sources are replaced by one export-only, + # parent-level combined mesh during pre-operation. + if object_type == BlenderTypes.MESH: + from . import hair_tool_export + if hair_tool_export.is_prepared_source(collection_object): + continue # if the object is visible if collection_object.visible_get(): # ensure the object doesn't end with one of the post fix tokens @@ -1298,25 +1320,52 @@ def report_path_error_message(layout, send2ue_property, report_text): row.label(text=report_text) -def select_all_children(scene_object, object_type, exclude_postfix_tokens=False): +def select_all_children( + scene_object, + object_type, + exclude_postfix_tokens=False, + required_collection=None, +): """ Selects all of an objects children. :param object scene_object: A object. :param str object_type: The type of object to select. :param bool exclude_postfix_tokens: Whether or not to exclude objects that have a postfix token. + :param object required_collection: Only select objects directly linked to this collection. """ + if required_collection is None: + required_collection = bpy.data.collections.get(ToolInfo.EXPORT_COLLECTION.value) + children = scene_object.children or get_meshes_using_armature_modifier(scene_object) for child_object in children: if child_object.type == object_type: - if exclude_postfix_tokens: - if any(child_object.name.startswith(f'{token.value}_') for token in PreFixToken): - continue - - if any(view_layer_object == child_object for view_layer_object in bpy.context.view_layer.objects): + from . import hair_tool_export + if hair_tool_export.is_prepared_source(child_object): + pass + elif required_collection and required_collection not in child_object.users_collection: + pass + elif exclude_postfix_tokens and any( + child_object.name.startswith(f'{token.value}_') + for token in PreFixToken + ): + pass + elif any( + view_layer_object == child_object + for view_layer_object in bpy.context.view_layer.objects + ): child_object.select_set(True) - if child_object.children: - select_all_children(child_object, object_type, exclude_postfix_tokens) + + # Parent hierarchy can contain non-export Surface/Guide meshes and + # curves. Traverse through them, but only select explicitly enabled + # objects that satisfy required_collection above. + if child_object.children: + select_all_children( + child_object, + object_type, + exclude_postfix_tokens, + required_collection, + ) def apply_all_mesh_modifiers(scene_object): @@ -1765,6 +1814,7 @@ def unpack_textures(): :return list: A dictionary of image names and file paths that where unpacked. """ unpacked_files = {} + processed_images = set() # go through each material for material in bpy.data.materials: @@ -1774,13 +1824,25 @@ def unpack_textures(): # check for packed textures if node.type == 'TEX_IMAGE': image = node.image - if image: + if image and image.name not in processed_images: + processed_images.add(image.name) if image.source == 'FILE': if image.packed_file: + # Linked image datablocks are read-only in the + # current blend file and Blender raises + # "Image is not editable" when unpack is called. + if image.library: + continue # if the unpacked image does not exist on disk if not os.path.exists(image.filepath_from_user()): # unpack the image - image.unpack() + try: + image.unpack() + except RuntimeError: + # Other read-only image datablocks can + # exist without a library pointer. They + # must not block the entire export. + continue unpacked_files[image.name] = image.filepath_from_user() return unpacked_files diff --git a/src/addons/send2ue/resources/extensions/combine_assets.py b/src/addons/send2ue/resources/extensions/combine_assets.py index 09746b36..0f5bbdb2 100644 --- a/src/addons/send2ue/resources/extensions/combine_assets.py +++ b/src/addons/send2ue/resources/extensions/combine_assets.py @@ -145,7 +145,8 @@ def pre_mesh_export(self, asset_data, properties): utilities.select_all_children( mesh_object.parent, BlenderTypes.MESH, - exclude_postfix_tokens=True + exclude_postfix_tokens=True, + required_collection=bpy.data.collections.get('Export'), ) # rename the asset to match the empty if this is a static mesh export if mesh_object.parent.type == 'EMPTY': From 2c6769c92e05d80b8286e73ae171a18ee9216023 Mon Sep 17 00:00:00 2001 From: 424 <2421412@naver.com> Date: Wed, 24 Jun 2026 11:40:45 +0900 Subject: [PATCH 11/25] Link the shared pipeline contract from the README The PARK send2ue customizations participate in the Blender -> Painter -> Unreal pipeline; point to substance-tools/docs/pipeline_contract.md (and pipeline_contract.json) as the source of truth for the Export collection, naming, and Unreal path conventions. Co-Authored-By: Claude Opus 4.8 --- README.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/README.md b/README.md index 0dd4174d..5779d14c 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,14 @@ A repository of blender addons that improve the game development workflow betwee This is now maintained by [@JoshQuake](https://github.com/JoshQuake) and volunteers from the community and is not affiliated with Epic Games. [![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/E1E3VWL1V) +> **This fork:** the PARK send2ue customizations are part of a Blender → Substance +> Painter → Unreal pipeline. Its shared conventions (the `Export` collection, +> naming prefixes, Unreal path anchors) are the contract documented in the +> `substance-tools` repo: +> [`docs/pipeline_contract.md`](https://github.com/nsdoanfosan/substance-tools/blob/main/docs/pipeline_contract.md) +> and `pipeline_contract.json`. Treat that as the source of truth before +> changing pipeline-facing names. + ## Send to Unreal From b801969833e94ce11d1bbc74153c94d3cf8c97c5 Mon Sep 17 00:00:00 2001 From: 424 <2421412@naver.com> Date: Fri, 26 Jun 2026 18:23:48 +0900 Subject: [PATCH 12/25] Apply transfer sources before mesh export --- .../extensions/send2ue_material_pipeline.py | 136 +++++++++++++----- 1 file changed, 100 insertions(+), 36 deletions(-) diff --git a/src/addons/send2ue/resources/extensions/send2ue_material_pipeline.py b/src/addons/send2ue/resources/extensions/send2ue_material_pipeline.py index c61c65d9..534c1922 100644 --- a/src/addons/send2ue/resources/extensions/send2ue_material_pipeline.py +++ b/src/addons/send2ue/resources/extensions/send2ue_material_pipeline.py @@ -1,71 +1,135 @@ -# send2ue extension: import 직후 머티리얼 파이프라인 자동 실행 -# ============================================================= -# send2ue 가 FBX 를 언리얼로 import 한 "직후" post_import hook 이 호출된다. -# 이 hook 에서 언리얼에 RPC 로 ue_material_setup.process_mesh() 를 실행시킨다. +# send2ue extension: run the Unreal material setup after each StaticMesh import. # -# 이 방식의 장점: -# - on_asset_post_import 델리게이트와 달리 send2ue 가 직접 호출하므로 -# Interchange/Legacy 어느 import 경로든 100% 트리거된다. -# - 이 시점엔 메쉬+텍스처가 모두 import 완료된 상태 → 타이밍 문제 없음. -# -# 설치 위치(둘 중 하나): -# A) send2ue/resources/extensions/ (자동 로드, 단 send2ue 업데이트 시 재복사 필요) -# B) send2ue Preferences 의 Extension Folder 로 이 파일이 있는 폴더를 등록 -# -# 실제 처리 로직은 언리얼 측 ue_material_setup.py 가 담당한다(수정은 거기서). +# The actual material work lives in UE_Blender_Pipeline/ue_material_setup.py. +# This hook only resolves the sidecar JSON written by UE Unique Names and asks +# Unreal to process the imported mesh through the shared surface-layer pipeline. import os + import bpy -from send2ue.core.extension import ExtensionBase from send2ue.constants import UnrealTypes +from send2ue.core.extension import ExtensionBase from send2ue.dependencies.unreal import run_commands -# 언리얼에서 ue_material_setup.py 를 찾을 경로. -# UE_BLENDER_PIPELINE_DIR 환경변수로 덮어쓸 수 있고, 없으면 ~/Documents/UE_Blender_Pipeline 사용. + PIPELINE_DIR = os.environ.get( - 'UE_BLENDER_PIPELINE_DIR', - os.path.join(os.path.expanduser('~'), 'Documents', 'UE_Blender_Pipeline') -).replace('\\', '/') + "UE_BLENDER_PIPELINE_DIR", + os.path.join(os.path.expanduser("~"), "Documents", "UE_Blender_Pipeline"), +).replace("\\", "/") class MaterialPipelineExtension(ExtensionBase): - name = 'material_pipeline' + name = "material_pipeline" enabled: bpy.props.BoolProperty( name="Auto-setup materials on import", default=True, description=( - "Import 후 이름이 매칭되는(M_뗀 머티리얼명이 메쉬명으로 시작) StaticMesh 에 대해 " - "텍스처를 /Game/Textures 로 옮기고 M_Prop_Master 인스턴스를 생성/연결/할당한다." + "After import, run the surface-layer material setup for matching StaticMesh assets." ), ) + def pre_mesh_export(self, asset_data, properties): + target = bpy.data.objects.get(asset_data.get("_mesh_object_name", "")) + if not target or target.type != "MESH": + return + + shape_keys = bool(getattr(target, "ue_unique_transfer_shape_keys", False)) + weights = bool(getattr(target, "ue_unique_transfer_weights", False)) + if not shape_keys and not weights: + return + + if not hasattr(target, "vdt_object_props"): + print("[material_pipeline] Vertex Data Tools object props are unavailable.") + return + + source = target.vdt_object_props.transfer_source + if source is None: + print(f"[material_pipeline] Transfer source not set for {target.name}; skipping.") + return + + self._run_vertex_data_transfer(target, shape_keys, weights) + + def _run_vertex_data_transfer(self, target, shape_keys, weights): + active = bpy.context.view_layer.objects.active + selected = list(bpy.context.selected_objects) + mode = bpy.context.mode + + try: + if mode != "OBJECT" and active: + bpy.ops.object.mode_set(mode="OBJECT") + bpy.ops.object.select_all(action="DESELECT") + target.select_set(True) + bpy.context.view_layer.objects.active = target + + if shape_keys: + if not hasattr(bpy.ops.object, "vdt_pointer_transfer_shape_keys"): + print("[material_pipeline] Shape Key transfer operator is unavailable.") + else: + bpy.ops.object.vdt_pointer_transfer_shape_keys() + + if weights: + if not hasattr(bpy.ops.object, "vdt_pointer_transfer_weights"): + print("[material_pipeline] Weight transfer operator is unavailable.") + else: + bpy.ops.object.vdt_pointer_transfer_weights() + finally: + if bpy.context.mode != "OBJECT" and bpy.context.active_object: + bpy.ops.object.mode_set(mode="OBJECT") + bpy.ops.object.select_all(action="DESELECT") + for obj in selected: + if obj.name in bpy.context.view_layer.objects: + obj.select_set(True) + if active and active.name in bpy.context.view_layer.objects: + bpy.context.view_layer.objects.active = active + def post_import(self, asset_data, properties): - """각 에셋 import 직후 호출. StaticMesh 면 언리얼에서 후처리 실행.""" if not self.enabled: return - if asset_data.get('skip'): + if asset_data.get("skip"): return - if asset_data.get('_asset_type') != UnrealTypes.STATIC_MESH: + if asset_data.get("_asset_type") != UnrealTypes.STATIC_MESH: return - asset_path = asset_data.get('asset_path') + asset_path = asset_data.get("asset_path") if not asset_path: return - # 언리얼 에디터에서 실행될 파이썬 명령들 + json_path = self._resolve_json_path(asset_path) + json_arg = f'r"{json_path}"' if json_path else "None" + commands = [ - 'import sys', + "import sys", f'_d = r"{PIPELINE_DIR}"', - 'sys.path.append(_d) if _d not in sys.path else None', - 'import importlib', - 'import ue_material_setup as _p', - 'importlib.reload(_p)', - f'_p.process_mesh(r"{asset_path}")', + "sys.path.append(_d) if _d not in sys.path else None", + "import importlib", + "import ue_material_setup as _p", + "importlib.reload(_p)", + f'_p.process_mesh(r"{asset_path}", json_path={json_arg})', ] run_commands(commands) + def _resolve_json_path(self, asset_path): + """Return the Blender-authored sidecar JSON path for this imported mesh.""" + try: + from pathlib import Path + + import ue_unique_export_names_addon as addon + + mesh_name = asset_path.rsplit("/", 1)[-1] + props = bpy.context.scene.ue_unique_names + export_dir = addon.resolve_export_dir(props.texture_export_dir) + candidate = Path(export_dir) / f"{mesh_name}.json" + if candidate.exists(): + return str(candidate).replace("\\", "/") + except Exception as exc: + print( + "[material_pipeline] json_path resolve failed; " + f"falling back to pipeline search: {exc}" + ) + return None + def draw_import(self, dialog, layout, properties): box = layout.box() - box.label(text='Material Pipeline (M_Prop_Master 자동 연결):') - dialog.draw_property(self, box, 'enabled') + box.label(text="Material Pipeline (Surface Layers)") + dialog.draw_property(self, box, "enabled") From b6c14846af16274d315fb56d353e23e6859e0afb Mon Sep 17 00:00:00 2001 From: 424 <2421412@naver.com> Date: Fri, 26 Jun 2026 18:41:11 +0900 Subject: [PATCH 13/25] Overwrite shape keys during transfer export --- .../resources/extensions/send2ue_material_pipeline.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/addons/send2ue/resources/extensions/send2ue_material_pipeline.py b/src/addons/send2ue/resources/extensions/send2ue_material_pipeline.py index 534c1922..79b25afb 100644 --- a/src/addons/send2ue/resources/extensions/send2ue_material_pipeline.py +++ b/src/addons/send2ue/resources/extensions/send2ue_material_pipeline.py @@ -54,6 +54,11 @@ def _run_vertex_data_transfer(self, target, shape_keys, weights): active = bpy.context.view_layer.objects.active selected = list(bpy.context.selected_objects) mode = bpy.context.mode + vdt_props = getattr(bpy.context.scene, "vdt_props", None) + previous_overwrite_shape_keys = ( + getattr(vdt_props, "overwrite_shape_keys", None) + if vdt_props is not None else None + ) try: if mode != "OBJECT" and active: @@ -66,6 +71,8 @@ def _run_vertex_data_transfer(self, target, shape_keys, weights): if not hasattr(bpy.ops.object, "vdt_pointer_transfer_shape_keys"): print("[material_pipeline] Shape Key transfer operator is unavailable.") else: + if vdt_props is not None: + vdt_props.overwrite_shape_keys = True bpy.ops.object.vdt_pointer_transfer_shape_keys() if weights: @@ -82,6 +89,8 @@ def _run_vertex_data_transfer(self, target, shape_keys, weights): obj.select_set(True) if active and active.name in bpy.context.view_layer.objects: bpy.context.view_layer.objects.active = active + if vdt_props is not None and previous_overwrite_shape_keys is not None: + vdt_props.overwrite_shape_keys = previous_overwrite_shape_keys def post_import(self, asset_data, properties): if not self.enabled: From b8dac2df4c2af1aa97ad6f49f76ee8e5e9d6549b Mon Sep 17 00:00:00 2001 From: 424 <2421412@naver.com> Date: Fri, 26 Jun 2026 21:48:41 +0900 Subject: [PATCH 14/25] Read transfer contracts from handoff JSON --- src/addons/send2ue/core/hair_tool_export.py | 39 ++++++- .../extensions/send2ue_material_pipeline.py | 110 ++++++++++++++++-- 2 files changed, 141 insertions(+), 8 deletions(-) diff --git a/src/addons/send2ue/core/hair_tool_export.py b/src/addons/send2ue/core/hair_tool_export.py index 324ae86e..7401bd32 100644 --- a/src/addons/send2ue/core/hair_tool_export.py +++ b/src/addons/send2ue/core/hair_tool_export.py @@ -16,6 +16,9 @@ def is_hair_tool_object(scene_object): if not scene_object or scene_object.type not in {'CURVES', 'MESH'}: return False + if any(_is_edit_mesh_modifier(modifier) for modifier in scene_object.modifiers): + return False + node_group_names = { modifier.node_group.name for modifier in scene_object.modifiers @@ -27,6 +30,13 @@ def is_hair_tool_object(scene_object): ) +def _is_edit_mesh_modifier(modifier): + modifier_name = str(getattr(modifier, 'name', '') or '').strip().replace(' ', '_').casefold() + node_group = getattr(modifier, 'node_group', None) + node_group_name = str(getattr(node_group, 'name', '') or '').strip().replace(' ', '_').casefold() + return 'edit_mesh' in {modifier_name, node_group_name} + + def is_prepared_source(scene_object): state = bpy.app.driver_namespace.get(STATE_KEY, {}) return scene_object.name in state.get('source_names', set()) @@ -249,6 +259,32 @@ def _join_objects(objects): return objects[0] +def _copy_transfer_settings(temporary_object, source_objects): + transfer_source = None + shape_keys = False + weights = False + + for source_object in source_objects: + shape_keys = shape_keys or bool( + getattr(source_object, 'ue_unique_transfer_shape_keys', False) + ) + weights = weights or bool( + getattr(source_object, 'ue_unique_transfer_weights', False) + ) + if transfer_source is None and hasattr(source_object, 'vdt_object_props'): + transfer_source = source_object.vdt_object_props.transfer_source + + if hasattr(temporary_object, 'ue_unique_transfer_shape_keys'): + temporary_object.ue_unique_transfer_shape_keys = shape_keys + if hasattr(temporary_object, 'ue_unique_transfer_weights'): + temporary_object.ue_unique_transfer_weights = weights + if ( + transfer_source is not None + and hasattr(temporary_object, 'vdt_object_props') + ): + temporary_object.vdt_object_props.transfer_source = transfer_source + + def _link_to_export_collection(scene_object, export_collection): for collection in list(scene_object.users_collection): collection.objects.unlink(scene_object) @@ -328,8 +364,9 @@ def prepare(): asset_name = asset_parent.name if asset_parent != asset_sources[0] else asset_sources[0].name temporary_object.name = f'{asset_name}__S2U_HAIR' temporary_object.data.name = f'{asset_name}__S2U_HAIR' - temporary_object[SOURCE_NAME_PROPERTY] = asset_sources[0].name + temporary_object[SOURCE_NAME_PROPERTY] = asset_name temporary_object[TEMP_PROPERTY] = True + _copy_transfer_settings(temporary_object, asset_sources) _link_to_export_collection(temporary_object, export_collection) state['temporary_object_names'].add(temporary_object.name) diff --git a/src/addons/send2ue/resources/extensions/send2ue_material_pipeline.py b/src/addons/send2ue/resources/extensions/send2ue_material_pipeline.py index 79b25afb..e30c20b7 100644 --- a/src/addons/send2ue/resources/extensions/send2ue_material_pipeline.py +++ b/src/addons/send2ue/resources/extensions/send2ue_material_pipeline.py @@ -4,10 +4,13 @@ # This hook only resolves the sidecar JSON written by UE Unique Names and asks # Unreal to process the imported mesh through the shared surface-layer pipeline. +import json import os +from pathlib import Path import bpy from send2ue.constants import UnrealTypes +from send2ue.core import utilities from send2ue.core.extension import ExtensionBase from send2ue.dependencies.unreal import run_commands @@ -30,26 +33,119 @@ class MaterialPipelineExtension(ExtensionBase): ) def pre_mesh_export(self, asset_data, properties): + if not self.enabled: + return + + asset_data = asset_data or {} target = bpy.data.objects.get(asset_data.get("_mesh_object_name", "")) if not target or target.type != "MESH": return - shape_keys = bool(getattr(target, "ue_unique_transfer_shape_keys", False)) - weights = bool(getattr(target, "ue_unique_transfer_weights", False)) + sidecar = self._load_json_sidecar_for_export(asset_data, target) + if sidecar is None: + return + transfer = self._transfer_entry_for_target(sidecar, target) + shape_keys = bool(transfer.get("shape_keys")) + weights = bool(transfer.get("weights")) if not shape_keys and not weights: return if not hasattr(target, "vdt_object_props"): - print("[material_pipeline] Vertex Data Tools object props are unavailable.") - return + utilities.report_error( + "Vertex Data Tools object props are unavailable.", + "The UE Unique JSON requests transfer postprocess, but VDT is not available in Blender.", + ) - source = target.vdt_object_props.transfer_source + source_name = transfer.get("source") + source = bpy.data.objects.get(source_name) if source_name else None if source is None: - print(f"[material_pipeline] Transfer source not set for {target.name}; skipping.") - return + utilities.report_error( + f'Transfer source "{source_name or "-"}" not found for "{target.name}".', + "Run Check Unreal Handoff again after setting Export Transfer Source.", + ) + target.vdt_object_props.transfer_source = source self._run_vertex_data_transfer(target, shape_keys, weights) + def _load_json_sidecar_for_export(self, asset_data, target): + json_path = self._resolve_json_path_for_export(asset_data, target) + if not json_path: + utilities.report_error( + "UE Unique JSON sidecar is missing.", + f'Run "Check Unreal Handoff" before Send to Unreal. Target: "{target.name}".', + ) + return None + + try: + with open(json_path, "r", encoding="utf-8") as handle: + return json.load(handle) + except OSError as exc: + utilities.report_error( + f"Could not read UE Unique JSON sidecar: {json_path}", + str(exc), + ) + except json.JSONDecodeError as exc: + utilities.report_error( + f"Invalid UE Unique JSON sidecar: {json_path}", + str(exc), + ) + + def _resolve_json_path_for_export(self, asset_data, target): + candidates = [] + for value in ( + asset_data.get("file_path"), + asset_data.get("asset_path"), + target.name, + ): + name = self._asset_name_from_value(value) + if name and name not in candidates: + candidates.append(name) + + try: + import ue_unique_export_names_addon as addon + + props = bpy.context.scene.ue_unique_names + export_dir = Path(addon.resolve_export_dir(props.texture_export_dir)) + except Exception as exc: + utilities.report_error( + "UE Unique Names add-on is required before Send to Unreal.", + f"JSON sidecar lookup failed: {exc}", + ) + return None + + for name in candidates: + candidate = export_dir / f"{name}.json" + if candidate.exists(): + return str(candidate) + return None + + def _asset_name_from_value(self, value): + if not value: + return "" + value = str(value).replace("\\", "/").rstrip("/") + name = value.rsplit("/", 1)[-1] + if "." in name: + name = name.rsplit(".", 1)[0] + return name + + def _transfer_entry_for_target(self, sidecar, target): + transfer = sidecar.get("transfer_source") + if isinstance(transfer, dict) and transfer.get("enabled"): + return transfer + + transfers = [ + entry + for entry in sidecar.get("transfer_sources", []) + if isinstance(entry, dict) and entry.get("enabled") + ] + if not transfers: + return {} + + for entry in transfers: + if entry.get("target") == target.name: + return entry + return transfers[0] + def _run_vertex_data_transfer(self, target, shape_keys, weights): active = bpy.context.view_layer.objects.active selected = list(bpy.context.selected_objects) From 462a857acc6b0e430aec90c2aecbd45cf012f9d5 Mon Sep 17 00:00:00 2001 From: 424 <2421412@naver.com> Date: Sat, 27 Jun 2026 01:10:14 +0900 Subject: [PATCH 15/25] Bundle Send2UE material pipeline --- README.md | 6 + .../extensions/send2ue_material_pipeline.py | 58 +- .../resources/pipeline/ue_material_setup.py | 1036 +++++++++++++++++ 3 files changed, 1095 insertions(+), 5 deletions(-) create mode 100644 src/addons/send2ue/resources/pipeline/ue_material_setup.py diff --git a/README.md b/README.md index 5779d14c..698addd7 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,12 @@ This is now maintained by [@JoshQuake](https://github.com/JoshQuake) and volunte > [`docs/pipeline_contract.md`](https://github.com/nsdoanfosan/substance-tools/blob/main/docs/pipeline_contract.md) > and `pipeline_contract.json`. Treat that as the source of truth before > changing pipeline-facing names. +> +> The Send2UE material handoff extension is bundled in +> `src/addons/send2ue/resources/extensions/send2ue_material_pipeline.py`; its +> Unreal Python postprocess lives beside it in +> `src/addons/send2ue/resources/pipeline/ue_material_setup.py`. Set +> `UE_BLENDER_PIPELINE_DIR` only when deliberately overriding that bundled code. ## Send to Unreal diff --git a/src/addons/send2ue/resources/extensions/send2ue_material_pipeline.py b/src/addons/send2ue/resources/extensions/send2ue_material_pipeline.py index e30c20b7..3a22675c 100644 --- a/src/addons/send2ue/resources/extensions/send2ue_material_pipeline.py +++ b/src/addons/send2ue/resources/extensions/send2ue_material_pipeline.py @@ -1,6 +1,6 @@ # send2ue extension: run the Unreal material setup after each StaticMesh import. # -# The actual material work lives in UE_Blender_Pipeline/ue_material_setup.py. +# The actual material work lives in ../pipeline/ue_material_setup.py. # This hook only resolves the sidecar JSON written by UE Unique Names and asks # Unreal to process the imported mesh through the shared surface-layer pipeline. @@ -15,10 +15,11 @@ from send2ue.dependencies.unreal import run_commands -PIPELINE_DIR = os.environ.get( - "UE_BLENDER_PIPELINE_DIR", - os.path.join(os.path.expanduser("~"), "Documents", "UE_Blender_Pipeline"), -).replace("\\", "/") +BUNDLED_PIPELINE_DIR = (Path(__file__).resolve().parent.parent / "pipeline").as_posix() +PIPELINE_DIR = os.environ.get("UE_BLENDER_PIPELINE_DIR", BUNDLED_PIPELINE_DIR).replace( + "\\", + "/", +) class MaterialPipelineExtension(ExtensionBase): @@ -41,6 +42,8 @@ def pre_mesh_export(self, asset_data, properties): if not target or target.type != "MESH": return + self._refresh_unreal_handoff_json_or_error(target) + sidecar = self._load_json_sidecar_for_export(asset_data, target) if sidecar is None: return @@ -67,6 +70,51 @@ def pre_mesh_export(self, asset_data, properties): target.vdt_object_props.transfer_source = source self._run_vertex_data_transfer(target, shape_keys, weights) + def _refresh_unreal_handoff_json_or_error(self, target): + try: + import ue_unique_export_names_addon as addon + + props = bpy.context.scene.ue_unique_names + objects = addon.validation_scope_objects(bpy.context, props.scope) + materials = addon.unreal_handoff_materials_from_objects(objects) + texture_map = addon.material_texture_map(materials) + errors = addon._json_refresh_validation_errors( + bpy.context, + props, + objects, + materials, + texture_map, + ) + if errors: + first = errors[0] + utilities.report_error( + "Unreal handoff validation failed before Send to Unreal.", + f' Target: "{target.name}". First: {first}', + ) + + prefix = addon.asset_prefix(bpy.context, props.prefix_mode, props.custom_prefix) + export_dir = Path(addon.resolve_export_dir(props.texture_export_dir)) + json_paths = addon.write_unreal_pipeline_json( + bpy.context, + prefix, + objects, + materials, + texture_map, + export_dir, + ) + if not json_paths: + utilities.report_error( + "Unreal handoff JSON refresh produced no files.", + f' Target: "{target.name}". Run Check Unreal Handoff.', + ) + except RuntimeError: + raise + except Exception as exc: + utilities.report_error( + "Could not validate Unreal handoff before Send to Unreal.", + f' Target: "{target.name}". {exc}', + ) + def _load_json_sidecar_for_export(self, asset_data, target): json_path = self._resolve_json_path_for_export(asset_data, target) if not json_path: diff --git a/src/addons/send2ue/resources/pipeline/ue_material_setup.py b/src/addons/send2ue/resources/pipeline/ue_material_setup.py new file mode 100644 index 00000000..eeaa1992 --- /dev/null +++ b/src/addons/send2ue/resources/pipeline/ue_material_setup.py @@ -0,0 +1,1036 @@ +""" +UE Material Setup (JSON 기반 텍스처 직접 import) +================================================ +send2ue post_import extension(send2ue_material_pipeline.py)이 import 직후 +process_mesh(asset_path) 를 RPC 로 호출한다. 수동 실행도 가능. + +블렌더 애드온이 남긴 JSON 사이드카(exports/.json)를 읽어: + 1. 각 머티리얼의 텍스처를 디스크 파일에서 /Game/Textures 로 직접 import + → FBX 가 못 나르는 MetallicRoughness 포함 모든 맵이 확실히 들어온다. + 2. 텍스처 종류별로 sRGB / 노멀맵 압축을 설정. + 3. Create or load a preset material instance, then assign shared texture data. + 4. JSON 의 slot_index 로 메쉬 슬롯에 MI 할당. + +가드: 머티리얼명에서 'M_' 를 뗀 것이 메쉬명과 정확히 같거나 '메쉬명_' 으로 시작할 때만 처리. +""" + +import json +import os +import re +import unreal + +# ─── 설정 ──────────────────────────────────────────────────────────────────── +DEFAULT_MASTER_PRESET = "prop" +MASTER_PRESETS = { + "prop": { + "master": "/Game/Material/Mesh/M_Prop_Master", + "mi_folder": "/Game/Material/Mesh/MI_Prop_Master", + "assignment": "flat", + }, + "layer": { + "master": "/Game/Material/Layer/M_LayerBlend", + "mi_folder": "/Game/Material/Layer", + "assignment": "layer", + }, + "cloth": { + "master": "/Game/Material/Layer/M_LayerBlend", + "mi_folder": "/Game/Material/Layer", + "assignment": "layer", + }, + "asset_surface": { + "master": "/Game/Material/AssetSurface/M_AssetSurface_Master", + "mi_folder": "/Game/Material/AssetSurface/MI", + "assignment": "layer", + }, + "coat": { + "master": "/Game/Material/Mesh/M_Coat_Fabric_Substrate_Master", + "mi_folder": "/Game/Material/Mesh/MI_Coat_Master", + "assignment": "coat_flat", + }, +} +# 반투명(유리) 머티리얼은 전용 MI 를 만들지 않고 이 공유 글래스 MI 를 슬롯에 직접 할당한다. +# (Megascan 글래스를 프로젝트로 localize 한 인스턴스. 부모 M_MS_Glass_Material, TRANSLUCENT) +GLASS_MI_PATH = "/Game/Material/Mesh/MI_Prop_Master/MI_Prop_Glass_01" +TEXTURES_FOLDER = "/Game/Textures" +EXPORT_DIR = r"C:/Users/PARK/Documents/UE_Blender_Pipeline/exports" +JSON_SEARCH_ROOTS = [ + r"C:/Users/PARK/OneDrive/Forestportfolio", +] +# /Game/Meshes/ 은 디스크의 JSON_SEARCH_ROOTS[0]/ 에 1:1 대응(send2ue 자동경로 규칙). +# 이를 이용해 mesh_path 로부터 해당 프롭 폴더만 좁혀 JSON 을 찾는다 → 3만개 트리 전체 walk 회피. +GAME_MESHES_PREFIX = "/Game/Meshes/" + +# Shared surface-layer texture parameter names (JSON 의 param 과 동일해야 연결됨) +KNOWN_PARAMS = {"Albedo", "Extra", "Normal", "Height", "Transmission", "Emissive"} +LAYER_PARAM_BY_LEGACY_PARAM = { + "BaseColor": "Albedo", + "MetallicRoughness": "Extra", + "Roughness": "Extra", + "Metallic": "Extra", + "Occlusion": "Extra", + "Normal": "Normal", + "Height": "Height", + "Alpha": "Transmission", + "Emissive": "Emissive", + "Texture": "Albedo", +} +FLAT_PARAM_BY_LAYER_PARAM = { + "Albedo": "BaseColor", + "Extra": "MetallicRoughness", + "Normal": "Normal", + "Emissive": "Emissive", +} +COAT_PARAM_BY_LAYER_PARAM = { + "Albedo": "BaseColor", + "Extra": "ORM", + "Normal": "Normal", +} + +# import 되는 텍스처의 인게임 최대 해상도 캡(param 별). 목록에 없으면 DEFAULT 적용. +MAX_TEXTURE_SIZE_BY_PARAM = { + "Albedo": 2048, # 2K + "BaseColor": 2048, # legacy JSON + "Normal": 2048, # 2K +} +DEFAULT_MAX_TEXTURE_SIZE = 1024 # MetallicRoughness/Emissive/기타 → 1K +ENABLE_VIRTUAL_TEXTURE_STREAMING = True +# import 되는 StaticMesh 를 자동으로 Nanite 로 등록할지(반투명 머티리얼 메쉬는 자동 제외). +ENABLE_NANITE = True +# ───────────────────────────────────────────────────────────────────────────── + + +# 텍스처 재import 회피용 mtime 캐시 (텍스처 asset path -> 소스파일 mtime). +# send 마다 OneDrive 에서 모든 텍스처를 다시 읽지 않도록, 소스가 안 바뀌었고 에셋이 이미 +# 있으면 import 를 건너뛴다. 소스를 수정하면 mtime 이 바뀌어 자동으로 재import 된다. +TEXTURE_IMPORT_CACHE = os.path.join(EXPORT_DIR, "_texture_import_cache.json") + + +def _load_texture_cache() -> dict: + try: + with open(TEXTURE_IMPORT_CACHE, "r", encoding="utf-8") as f: + return json.load(f) + except Exception: + return {} + + +def _save_texture_cache(cache: dict): + try: + os.makedirs(os.path.dirname(TEXTURE_IMPORT_CACHE), exist_ok=True) + with open(TEXTURE_IMPORT_CACHE, "w", encoding="utf-8") as f: + json.dump(cache, f) + except Exception as e: + _warn(f"텍스처 캐시 저장 실패(무시): {e}") + + +def _log(msg): + unreal.log(f"[Blender Pipeline] {msg}") + + +def _warn(msg): + unreal.log_warning(f"[Blender Pipeline] {msg}") + + +def _mesh_path_to_disk_folder(mesh_path: str): + """import 된 메쉬의 /Game/Meshes 경로를 디스크 프롭 폴더로 역산. 매핑 밖이면 None. + 예: /Game/Meshes/00_common/prop/Prop_X → /00_common/prop""" + if not mesh_path or not mesh_path.startswith(GAME_MESHES_PREFIX) or not JSON_SEARCH_ROOTS: + return None + rel = mesh_path[len(GAME_MESHES_PREFIX):] + rel_folder = rel.rsplit("/", 1)[0] if "/" in rel else "" + return os.path.join(JSON_SEARCH_ROOTS[0], rel_folder.replace("/", os.sep)) + + +def _walk_for_json(roots, filename): + found = [] + for root in roots: + if not os.path.isdir(root): + continue + for dirpath, _, filenames in os.walk(root): + if filename in filenames: + found.append(os.path.join(dirpath, filename)) + return found + + +def _find_json_path(mesh_name: str, mesh_path: str = None): + # 빠른 경로: mesh_path 로 프롭 폴더를 역산해 그 작은 서브트리만 walk(3만개 OneDrive 트리 전체 walk 회피). + # 못 찾으면 기존처럼 JSON_SEARCH_ROOTS 전체로 폴백한다. + filename = f"{mesh_name}.json" + candidates = [] + + disk_folder = _mesh_path_to_disk_folder(mesh_path) + if disk_folder and os.path.isdir(disk_folder): + candidates = _walk_for_json([disk_folder], filename) + + if not candidates: + candidates = _walk_for_json([r for r in JSON_SEARCH_ROOTS if os.path.isdir(r)], filename) + + legacy_path = os.path.join(EXPORT_DIR, filename) + if os.path.exists(legacy_path): + candidates.append(legacy_path) + + if not candidates: + return None + candidates.sort(key=lambda p: os.path.getmtime(p), reverse=True) + return candidates[0] + + +def _load_json(mesh_name: str, explicit_path: str = None, mesh_path: str = None): + # extension 이 정확한 JSON 경로를 넘겨주면 walk 를 건너뛴다. 없으면 mesh_path 로 폴더를 좁힌다. + if explicit_path and os.path.exists(explicit_path): + path = explicit_path + else: + path = _find_json_path(mesh_name, mesh_path) + if not path: + return None + try: + with open(path, "r", encoding="utf-8") as f: + data = json.load(f) + _log(f"JSON sidecar: {path}") + return data + except Exception as e: + _warn(f"JSON 읽기 실패 ({mesh_name}.json): {e}") + return None + + +def _set_texture_property_if_changed(tex, property_name: str, value) -> bool: + try: + current = tex.get_editor_property(property_name) + except Exception: + return False + if current == value: + return False + tex.set_editor_property(property_name, value) + return True + + +def _configure_imported_texture(tex, param: str) -> bool: + changed = False + + if param == "Normal": + changed |= _set_texture_property_if_changed(tex, "srgb", False) + changed |= _set_texture_property_if_changed( + tex, + "compression_settings", + unreal.TextureCompressionSettings.TC_NORMALMAP, + ) + elif param in {"Extra", "MetallicRoughness", "Roughness", "Metallic", "Occlusion"}: + changed |= _set_texture_property_if_changed(tex, "srgb", False) + changed |= _set_texture_property_if_changed( + tex, + "compression_settings", + unreal.TextureCompressionSettings.TC_MASKS, + ) + else: + changed |= _set_texture_property_if_changed(tex, "srgb", True) + + max_size = MAX_TEXTURE_SIZE_BY_PARAM.get(param, DEFAULT_MAX_TEXTURE_SIZE) + if max_size: + changed |= _set_texture_property_if_changed(tex, "max_texture_size", max_size) + + if ENABLE_VIRTUAL_TEXTURE_STREAMING: + try: + before = bool(tex.get_editor_property("virtual_texture_streaming")) + except Exception: + before = False + if not before: + if hasattr(tex, "set_virtual_texture_streaming"): + tex.set_virtual_texture_streaming(True) + else: + tex.set_editor_property("virtual_texture_streaming", True) + changed = True + + return changed + + +def _import_texture( + file_path: str, + asset_name: str, + param: str, + tex_cache: dict = None, + force_reimport: bool = False, +): + """디스크 텍스처를 TEXTURES_FOLDER 로 직접 import 하고 종류별 설정 적용. asset path 반환. + + tex_cache 가 주어지면, 소스파일 mtime 이 캐시와 같고 에셋이 이미 있을 때 import 를 건너뛴다. + """ + if not asset_name: + _warn(f" 텍스처 asset_name 없음 — skip ({file_path})") + return None + if not file_path or not os.path.exists(file_path): + _warn(f" 텍스처 파일 없음: {asset_name} ({file_path})") + return None + + full_path = f"{TEXTURES_FOLDER}/{asset_name}" + + # mtime 캐시 히트면 재import 생략(OneDrive 재읽기/하이드레이션 방지). getmtime 은 메타데이터만 + # 읽으므로 클라우드 파일을 받아오지 않는다. + try: + source_mtime = os.path.getmtime(file_path) + except OSError: + source_mtime = None + if unreal.EditorAssetLibrary.does_asset_exist(full_path) and not force_reimport: + tex = unreal.load_asset(full_path) + if tex is not None and _configure_imported_texture(tex, param): + unreal.EditorAssetLibrary.save_asset(full_path) + if tex_cache is not None and source_mtime is not None: + tex_cache[full_path] = source_mtime + _log(f" texture exists, import skipped: {asset_name} ({param})") + return full_path + + if (tex_cache is not None and source_mtime is not None + and tex_cache.get(full_path) == source_mtime + and unreal.EditorAssetLibrary.does_asset_exist(full_path) + and not force_reimport): + tex = unreal.load_asset(full_path) + if tex is not None and _configure_imported_texture(tex, param): + unreal.EditorAssetLibrary.save_asset(full_path) + return full_path + + task = unreal.AssetImportTask() + task.set_editor_property('filename', file_path) + task.set_editor_property('destination_path', TEXTURES_FOLDER) + task.set_editor_property('destination_name', asset_name) + task.set_editor_property('automated', True) + task.set_editor_property('replace_existing', bool(force_reimport)) + task.set_editor_property('save', True) + unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task]) + + tex = unreal.load_asset(full_path) + if tex is None: + _warn(f" 텍스처 import 실패: {asset_name}") + return None + + _configure_imported_texture(tex, param) + + unreal.EditorAssetLibrary.save_asset(full_path) + if tex_cache is not None and source_mtime is not None: + tex_cache[full_path] = source_mtime + _log(f" 텍스처 import: {asset_name} ({param})") + return full_path + + +def _set_nanite(mesh, enabled: bool) -> bool: + """StaticMesh 의 Nanite 활성 상태를 enabled 로 맞춘다. 변경이 있었으면 True.""" + nanite = mesh.get_editor_property("nanite_settings") + if bool(nanite.get_editor_property("enabled")) == enabled: + return False + nanite.set_editor_property("enabled", enabled) + mesh.set_editor_property("nanite_settings", nanite) + _log(f" Nanite {'활성화' if enabled else '비활성화(반투명)'}") + return True + + +def _sync_browser_to_mesh(mesh_path: str): + """Content Browser 를 import 된 메쉬로 이동/선택시킨다. + (텍스처 import 가 마지막이라 브라우저가 /Game/Textures 로 튀는 것을 되돌림)""" + try: + unreal.EditorAssetLibrary.sync_browser_to_objects([mesh_path]) + except Exception as e: + _warn(f" 브라우저 동기화 실패(무시): {e}") + + +def _is_translucent(data) -> bool: + """JSON 머티리얼 중 하나라도 반투명이면 True. (블렌더 애드온이 'translucent' 플래그를 씀)""" + if not data: + return False + return any(entry.get("translucent") for entry in data.get("materials", [])) + + +def _same_asset(a, b) -> bool: + return a is not None and b is not None and a.get_path_name() == b.get_path_name() + + +def _master_preset(data: dict, entry: dict = None) -> dict: + entry = entry or {} + key = ( + entry.get("material_master") + or entry.get("master_material") + or entry.get("master_preset") + or data.get("material_master") + or data.get("master_material") + or data.get("master_preset") + or DEFAULT_MASTER_PRESET + ) + key = str(key or DEFAULT_MASTER_PRESET).strip().lower() + preset = MASTER_PRESETS.get(key) + if preset is None: + _warn(f"unknown material master preset '{key}', using '{DEFAULT_MASTER_PRESET}'") + key = DEFAULT_MASTER_PRESET + preset = MASTER_PRESETS[key] + result = dict(preset) + result["key"] = key + return result + + +def _load_master_material(preset: dict): + master_path = preset["master"] + master_mat = unreal.load_asset(master_path) + if master_mat is None: + _warn(f"마스터 머티리얼 없음: {master_path}") + return master_mat + + +def _create_or_load_mi(asset_tools, master_mat, mat_base: str, mi_folder: str): + mi_name = f"MI_{mat_base}" + mi_path = f"{mi_folder}/{mi_name}" + if unreal.EditorAssetLibrary.does_asset_exist(mi_path): + mi = unreal.load_asset(mi_path) + parent_changed = False + try: + current_parent = mi.get_editor_property("parent") + except Exception: + current_parent = None + if not _same_asset(current_parent, master_mat): + unreal.MaterialEditingLibrary.set_material_instance_parent(mi, master_mat) + _log(f" MI parent 변경: {mi_name} -> {master_mat.get_path_name()}") + parent_changed = True + return mi, mi_path, False, parent_changed, "existing" + + copy_from_path = _derive_number_suffix_copy_source(mi_path) + if copy_from_path: + mi_folder_path, _mi_name = mi_path.rsplit("/", 1) + source = unreal.load_asset(copy_from_path) + copied = None + try: + copied = unreal.EditorAssetLibrary.duplicate_asset(copy_from_path, mi_path) + except Exception as e: + _warn(f" MI suffix copy failed, trying AssetTools: {copy_from_path} -> {mi_path} ({e})") + if copied is None and source is not None: + try: + copied = asset_tools.duplicate_asset(mi_name, mi_folder_path, source) + except Exception as e: + _warn(f" MI suffix AssetTools copy failed: {copy_from_path} -> {mi_path} ({e})") + if copied is not None: + _log(f" MI copy: {copy_from_path} -> {mi_path}") + return copied, mi_path, True, False, "copy" + + unreal.EditorAssetLibrary.make_directory(mi_folder) + factory = unreal.MaterialInstanceConstantFactoryNew() + mi = asset_tools.create_asset( + mi_name, mi_folder, unreal.MaterialInstanceConstant, factory + ) + if mi is None: + _warn(f"MI 생성 실패: {mi_name}") + return None, mi_path, False, False, "missing" + unreal.MaterialEditingLibrary.set_material_instance_parent(mi, master_mat) + _log(f" MI 생성: {mi_name}") + return mi, mi_path, True, False, "new" + + +def _entry_target_material_path(entry: dict): + for key in ( + "target_material_path", + "material_instance_path", + "unreal_material_path", + ): + value = str(entry.get(key) or "").strip() + if value: + return value.split(".")[0] + return None + + +def _entry_copy_source_material_path(entry: dict): + for key in ( + "copy_from_material_path", + "source_material_path", + ): + value = str(entry.get(key) or "").strip() + if value: + return value.split(".")[0] + return None + + +def _derive_number_suffix_copy_source(target_path: str): + if not target_path or "/" not in target_path: + return None + target_folder, target_name = target_path.rsplit("/", 1) + match = re.match(r"(.+)_\d+$", target_name) + if not match: + return None + source_path = f"{target_folder}/{match.group(1)}" + if unreal.EditorAssetLibrary.does_asset_exist(source_path): + return source_path + return None + + +def _load_or_copy_target_material( + asset_tools, + target_path: str, + copy_from_path: str = None, + master_mat=None, +): + if unreal.EditorAssetLibrary.does_asset_exist(target_path): + return unreal.load_asset(target_path), target_path, False, "existing" + + if not copy_from_path: + copy_from_path = _derive_number_suffix_copy_source(target_path) + + if not copy_from_path: + if master_mat is None: + _warn(f" target material missing and no master: {target_path}") + return None, target_path, False, "missing" + target_folder, target_name = target_path.rsplit("/", 1) + unreal.EditorAssetLibrary.make_directory(target_folder) + factory = unreal.MaterialInstanceConstantFactoryNew() + mi = asset_tools.create_asset( + target_name, + target_folder, + unreal.MaterialInstanceConstant, + factory, + ) + if mi is None: + _warn(f" target material create failed: {target_path}") + return None, target_path, False, "missing" + unreal.MaterialEditingLibrary.set_material_instance_parent(mi, master_mat) + unreal.EditorAssetLibrary.save_asset(target_path, only_if_is_dirty=False) + _log(f" MI create: {target_path}") + return mi, target_path, True, "new" + if not unreal.EditorAssetLibrary.does_asset_exist(copy_from_path): + _warn(f" copy source material missing: {copy_from_path} -> {target_path}") + return None, target_path, False, "missing" + + target_folder, target_name = target_path.rsplit("/", 1) + unreal.EditorAssetLibrary.make_directory(target_folder) + + copied = None + try: + copied = unreal.EditorAssetLibrary.duplicate_asset(copy_from_path, target_path) + except Exception as e: + _warn(f" duplicate_asset failed, trying AssetTools: {copy_from_path} -> {target_path} ({e})") + + if copied is None: + source = unreal.load_asset(copy_from_path) + try: + copied = asset_tools.duplicate_asset(target_name, target_folder, source) + except Exception as e: + _warn(f" AssetTools duplicate failed: {copy_from_path} -> {target_path} ({e})") + copied = None + + if copied is None: + _warn(f" target material copy failed: {copy_from_path} -> {target_path}") + return None, target_path, False, "missing" + + unreal.EditorAssetLibrary.save_asset(target_path, only_if_is_dirty=False) + _log(f" MI copy: {copy_from_path} -> {target_path}") + return copied, target_path, True, "copy" + + +def _static_material_name_values(static_material): + names = [] + for prop in ("material_slot_name", "imported_material_slot_name"): + try: + value = static_material.get_editor_property(prop) + except Exception: + value = None + if value: + names.append(str(value)) + try: + material = static_material.get_editor_property("material_interface") + except Exception: + material = None + if material is not None: + try: + names.append(material.get_name()) + except Exception: + pass + return names + + +def _entry_slot_match_names(entry: dict, mat_name: str): + names = [] + for value in ( + mat_name, + entry.get("slot_name"), + entry.get("material_slot_name"), + entry.get("imported_slot_name"), + entry.get("imported_material_slot_name"), + entry.get("original_material_name"), + ): + value = str(value or "").strip() + if value and value not in names: + names.append(value) + return names + + +def _slot_index_for_entry(mesh, entry: dict, mat_name: str): + candidates = _entry_slot_match_names(entry, mat_name) + if not candidates: + return None + candidate_set = set(candidates) + candidate_folded = {name.casefold() for name in candidates} + static_materials = mesh.get_editor_property("static_materials") + for i, static_material in enumerate(static_materials): + slot_names = _static_material_name_values(static_material) + if any(name in candidate_set for name in slot_names): + return i + for i, static_material in enumerate(static_materials): + slot_names = _static_material_name_values(static_material) + if any(str(name).casefold() in candidate_folded for name in slot_names): + return i + return None + + +def _slot_index_for_material_name(mesh, mat_name: str): + """메쉬의 실제 슬롯 중 import 된 머티리얼명이 mat_name 인 슬롯 인덱스. 없으면 None. + Empty 결합(combine child meshes) export 처럼 JSON 의 slot_index 가 실제 결합 슬롯 순서와 + 다를 수 있으므로, 슬롯을 이름으로 매칭해 정확한 인덱스를 찾는다.""" + return _slot_index_for_entry(mesh, {"name": mat_name}, mat_name) + + +def _assign_slot(mesh, slot_index: int, material) -> bool: + """메쉬 슬롯에 머티리얼 할당. FBX import 결과 슬롯 수가 JSON 과 달라도 안전하게 가드.""" + static_materials = mesh.get_editor_property("static_materials") + if slot_index < 0 or slot_index >= len(static_materials): + _warn(f" 슬롯 인덱스 {slot_index} 가 메쉬 슬롯 수({len(static_materials)})를 벗어남 — 할당 skip") + return False + # 이미 같은 머티리얼이 할당돼 있으면 변경 없음 → 메쉬 재저장을 피한다. + current = static_materials[slot_index].get_editor_property("material_interface") + if current is not None and material is not None and current.get_path_name() == material.get_path_name(): + return False + mesh.set_material(slot_index, material) + return True + + +def _surface_layer_param(param: str) -> str: + return LAYER_PARAM_BY_LEGACY_PARAM.get(str(param or ""), str(param or "")) + + +def _assign_layer_zero_textures(mi, param_tex_map: dict) -> bool: + """Fallback for stale editors: Python can address only material layer index 0.""" + changed = False + association = unreal.MaterialParameterAssociation.LAYER_PARAMETER + for param, tex_path in param_tex_map.items(): + tex = unreal.load_asset(tex_path) + if tex is None: + continue + try: + current = unreal.MaterialEditingLibrary.get_material_instance_texture_parameter_value( + mi, + param, + association, + ) + except Exception: + current = None + if current is not None and current.get_path_name() == tex.get_path_name(): + continue + unreal.MaterialEditingLibrary.set_material_instance_texture_parameter_value( + mi, + param, + tex, + association, + ) + _log(f" layer[0] {param} -> {tex_path.split('/')[-1]} (python fallback)") + changed = True + return changed + + +def _entry_layers(entry: dict): + layers = entry.get("layers") or [] + normalized = [] + for layer_index, layer in enumerate(layers): + textures = [] + for texture in layer.get("textures", []): + item = dict(texture) + item["param"] = _surface_layer_param(item.get("param")) + textures.append(item) + normalized.append( + { + "name": str(layer.get("name") or f"Layer_{layer_index + 1:02d}"), + "index": int(layer.get("index", layer_index)), + "textures": textures, + } + ) + if normalized: + return normalized + + textures = [] + for texture in entry.get("textures", []): + item = dict(texture) + item["param"] = _surface_layer_param(item.get("param")) + textures.append(item) + return [{"name": "Base", "index": 0, "textures": textures}] if textures else [] + + +def _import_layer_textures(layers, tex_cache: dict, force_reimport: bool = False): + layer_maps = [] + for layer in layers: + param_tex_map = {} + for texture in layer.get("textures", []): + param = texture.get("param") + tex_path = _import_texture( + texture.get("file"), + texture.get("asset_name"), + param, + tex_cache, + force_reimport=force_reimport, + ) + if tex_path and param in KNOWN_PARAMS: + param_tex_map[param] = tex_path + layer_maps.append( + { + "name": layer.get("name", "Base"), + "index": int(layer.get("index", len(layer_maps))), + "textures": param_tex_map, + } + ) + return layer_maps + + +def reimport_textures_from_json(json_path: str) -> int: + json_path = os.path.abspath(str(json_path or "")) + if not os.path.isfile(json_path): + _warn(f"texture reimport JSON missing: {json_path}") + return 0 + try: + with open(json_path, "r", encoding="utf-8") as handle: + data = json.load(handle) + except Exception as exc: + _warn(f"texture reimport JSON read failed: {json_path} ({exc})") + return 0 + + tex_cache = _load_texture_cache() + imported = 0 + seen = set() + for entry in data.get("materials", []): + for layer in _entry_layers(entry): + for texture in layer.get("textures", []): + asset_name = texture.get("asset_name") + param = texture.get("param") + key = (asset_name, param) + if key in seen: + continue + seen.add(key) + tex_path = _import_texture( + texture.get("file"), + asset_name, + param, + tex_cache, + force_reimport=True, + ) + if tex_path: + imported += 1 + _save_texture_cache(tex_cache) + _log(f"texture reimport complete: {imported} texture(s)") + return imported + + +def _assign_surface_layer_textures(mi, layer_maps) -> bool: + """Assign imported textures to a material instance using shared layer params. + + The Unreal Python API can address layer parameters by association but not by + layer index. If the Codex C++ helper is available it handles indexed layer + parameters; otherwise this falls back to the first layer only. + """ + helper = getattr(unreal, "CodexMaterialToolsLibrary", None) + if helper and hasattr(helper, "set_material_instance_layer_texture_parameter_value"): + changed = False + for layer in layer_maps: + for param, tex_path in layer.get("textures", {}).items(): + tex = unreal.load_asset(tex_path) + if tex is None: + continue + if helper.set_material_instance_layer_texture_parameter_value( + mi, + str(param), + tex, + int(layer.get("index", 0)), + ): + _log( + f" layer[{int(layer.get('index', 0))}] {param} -> " + f"{tex_path.split('/')[-1]}" + ) + changed = True + return changed + + if len(layer_maps) > 1 or any(int(layer.get("index", 0)) != 0 for layer in layer_maps): + _warn(" indexed layer helper missing; assigning only layer[0] textures") + first_layer = next( + (layer.get("textures", {}) for layer in layer_maps if int(layer.get("index", 0)) == 0), + {}, + ) + return _assign_layer_zero_textures(mi, first_layer) + + +def _first_layer_textures(layer_maps) -> dict: + return next( + (layer.get("textures", {}) for layer in layer_maps if int(layer.get("index", 0)) == 0), + {}, + ) + + +def _assign_flat_textures(mi, layer_maps, param_map: dict, label: str) -> bool: + changed = False + for layer_param, tex_path in _first_layer_textures(layer_maps).items(): + flat_param = param_map.get(layer_param) + if not flat_param: + continue + tex = unreal.load_asset(tex_path) + if tex is None: + continue + try: + current = unreal.MaterialEditingLibrary.get_material_instance_texture_parameter_value( + mi, flat_param + ) + except Exception: + current = None + if current is not None and current.get_path_name() == tex.get_path_name(): + continue + unreal.MaterialEditingLibrary.set_material_instance_texture_parameter_value( + mi, flat_param, tex + ) + _log(f" {label} {flat_param} <- {tex_path.split('/')[-1]}") + changed = True + return changed + + +def _assign_master_textures(mi, layer_maps, assignment: str) -> bool: + if assignment == "layer": + return _assign_surface_layer_textures(mi, layer_maps) + if assignment == "coat_flat": + return _assign_flat_textures(mi, layer_maps, COAT_PARAM_BY_LAYER_PARAM, "coat") + return _assign_flat_textures(mi, layer_maps, FLAT_PARAM_BY_LAYER_PARAM, "prop") + + +def _material_instance_base_name(mat_name: str) -> str: + if mat_name.startswith("M_"): + return mat_name[2:] + if mat_name.startswith("MI_"): + return mat_name[3:] + return mat_name + + +def _source_asset_names(data: dict): + material_names = [] + texture_names = [] + + def add_texture_names(tex: dict): + asset_name = str(tex.get("asset_name", "")) + if asset_name: + texture_names.append(asset_name) + file_path = str(tex.get("file", "")) + if file_path: + source_name = os.path.splitext(os.path.basename(file_path))[0] + if source_name: + texture_names.append(source_name) + + for entry in data.get("materials", []): + mat_name = str(entry.get("name", "")) + if mat_name: + material_names.append(mat_name) + for tex in entry.get("textures", []): + add_texture_names(tex) + for layer in entry.get("layers", []): + for tex in layer.get("textures", []): + add_texture_names(tex) + return material_names, texture_names + + +def _delete_asset_if_type(asset_path: str, allowed_class_names: set) -> bool: + if not unreal.EditorAssetLibrary.does_asset_exist(asset_path): + return False + asset = unreal.load_asset(asset_path) + if asset is None: + return False + class_name = asset.get_class().get_name() + if class_name not in allowed_class_names: + _log(f" cleanup skip: {asset_path} ({class_name})") + return False + if unreal.EditorAssetLibrary.delete_asset(asset_path): + _log(f" cleanup delete: {asset_path}") + return True + _warn(f" cleanup delete failed: {asset_path}") + return False + + +def _cleanup_imported_source_assets(mesh_path: str, data: dict): + mesh_folder = mesh_path.rsplit("/", 1)[0] + material_names, texture_names = _source_asset_names(data) + + deleted = 0 + for name in material_names: + asset_path = f"{mesh_folder}/{name}" + if asset_path == mesh_path: + continue + if _delete_asset_if_type(asset_path, {"Material", "MaterialInstanceConstant"}): + deleted += 1 + + for name in texture_names: + asset_path = f"{mesh_folder}/{name}" + if asset_path == mesh_path: + continue + if _delete_asset_if_type(asset_path, {"Texture2D"}): + deleted += 1 + + if deleted: + # NOTE(perf): delete_asset 가 각 패키지를 디스크에서 즉시 지우고, 메쉬/MI/텍스처는 위에서 + # 이미 개별 save 됐으므로 여기서 추가로 flush 할 게 없다. 예전엔 save_directory( + # only_if_is_dirty=False)로 import 폴더 전체를 강제 재저장했는데, 모든 메쉬가 같은 폴더 + # (/Game/untitled_category/untitled_asset)로 들어오는 데다 메쉬마다 호출돼서 폴더가 + # 쌓일수록 export 가 O(N^2) 로 느려졌다. 그래서 폴더 통째 save 는 하지 않는다. + _log(f" cleanup complete: {deleted} source asset(s) removed from {mesh_folder}") + else: + _log(f" cleanup: no source material/texture assets found in {mesh_folder}") + + +def process_mesh(mesh_path: str, master_mat=None, json_path: str = None) -> bool: + """단일 StaticMesh 를 JSON 기반으로 처리. 변경이 있었으면 True. + + json_path: send2ue extension 이 넘겨주는 JSON 절대경로(있으면 OneDrive walk 생략). + """ + mesh_path = mesh_path.split(".")[0] + mesh = unreal.load_asset(mesh_path) + if not isinstance(mesh, unreal.StaticMesh): + return False + + mesh_name = mesh_path.rsplit("/", 1)[-1] + data = _load_json(mesh_name, json_path, mesh_path) + + # Nanite: import 되는 StaticMesh 에 켜되, 반투명 머티리얼 메쉬는 끈다. + # (JSON 이 없으면 불투명으로 가정 → 켬. 반투명으로 판정되면 이미 켜져 있어도 끈다.) + if ENABLE_NANITE and _set_nanite(mesh, not _is_translucent(data)): + unreal.EditorAssetLibrary.save_asset(mesh_path) + + if data is None: + _warn(f"JSON 사이드카 없음: {mesh_name}.json — skip (블렌더에서 Rename 버튼을 눌렀나요?)") + return False + + asset_tools = unreal.AssetToolsHelpers.get_asset_tools() + changed = False + json_mesh_name = str(data.get("mesh_name", "")) + if json_mesh_name and json_mesh_name != mesh_name: + _warn(f"JSON mesh_name mismatch: asset={mesh_name}, json={json_mesh_name}; using JSON data") + + # 텍스처 재import 회피 캐시(메쉬마다 reload 되므로 디스크에서 읽고, 바뀌면 끝에 저장). + tex_cache = _load_texture_cache() + tex_cache_before = dict(tex_cache) + + for entry in data.get("materials", []): + mat_name = str(entry.get("name", "")) + target_material_path = _entry_target_material_path(entry) + legacy_generated_mi_flow = mat_name.startswith("M_") + if not target_material_path and not legacy_generated_mi_flow: + continue + + # 실제 슬롯을 머티리얼 이름으로 매칭(Empty 결합 export 대응). 없으면 JSON slot_index 로 폴백. + slot_index = _slot_index_for_entry(mesh, entry, mat_name) + if slot_index is None: + if entry.get("slot_match_required"): + _warn(f" slot not found for target-only material '{mat_name}' -> skip") + continue + slot_index = int(entry.get("slot_index", 0)) + + # 반투명(유리): 전용 MI/텍스처 없이 공유 글래스 MI 를 슬롯에 바로 할당. + if entry.get("translucent"): + glass_mi = unreal.load_asset(GLASS_MI_PATH) + if glass_mi is None: + _warn(f" 글래스 MI 없음: {GLASS_MI_PATH} — 슬롯[{slot_index}] skip") + continue + if _assign_slot(mesh, slot_index, glass_mi): + _log(f" 슬롯[{slot_index}] '{mat_name}' → 글래스 MI 할당(공유)") + changed = True + continue + + preset = _master_preset(data, entry) + if target_material_path: + copy_from_path = _entry_copy_source_material_path(entry) + selected_master = master_mat or _load_master_material(preset) + if selected_master is None and not copy_from_path: + continue + mi, mi_path, mi_created, mi_source = _load_or_copy_target_material( + asset_tools, + target_material_path, + copy_from_path, + selected_master, + ) + if mi is None: + continue + _log( + f" slot[{slot_index}] '{mat_name}' -> {mi_path} " + f"(master: {preset['key']})" + ) + params_changed = False + if mi_source == "new": + layers = _entry_layers(entry) + layer_maps = _import_layer_textures(layers, tex_cache) + params_changed = _assign_master_textures(mi, layer_maps, preset["assignment"]) + if mi_created or params_changed: + unreal.EditorAssetLibrary.save_asset(mi_path) + changed = True + if _assign_slot(mesh, slot_index, mi): + changed = True + continue + + mat_base = _material_instance_base_name(mat_name) + selected_master = master_mat or _load_master_material(preset) + if selected_master is None: + continue + + _log( + f" 슬롯[{slot_index}] '{mat_name}' 처리 " + f"(base: {mat_base}, master: {preset['key']})" + ) + + # 1. 텍스처 직접 import (소스가 안 바뀌었으면 캐시 히트로 skip) + layers = None + layer_maps = None + + # 2. MI 생성/로드 + mi, mi_path, mi_created, parent_changed, mi_source = _create_or_load_mi( + asset_tools, + selected_master, + mat_base, + preset["mi_folder"], + ) + if mi is None: + continue + + # 3. Assign textures using the selected master material contract. + params_changed = False + if mi_source == "new": + layers = _entry_layers(entry) + layer_maps = _import_layer_textures(layers, tex_cache) + params_changed = _assign_master_textures(mi, layer_maps, preset["assignment"]) + if mi_created or parent_changed or params_changed: + unreal.EditorAssetLibrary.save_asset(mi_path) + changed = True + + # 4. 슬롯에 MI 할당 + if _assign_slot(mesh, slot_index, mi): + changed = True + + # 이번 처리에서 새로 import 된 텍스처가 있으면 캐시 갱신 + if tex_cache != tex_cache_before: + _save_texture_cache(tex_cache) + + _cleanup_imported_source_assets(mesh_path, data) + + if changed: + unreal.EditorAssetLibrary.save_asset(mesh_path) + _log(f"메쉬 '{mesh_name}' 완료") + + # 마지막으로 브라우저를 메쉬로 돌려 선택 상태로 끝낸다(텍스처 폴더로 튀는 것 방지). + _sync_browser_to_mesh(mesh_path) + return changed + + +def process_meshes(mesh_paths): + count = 0 + for p in mesh_paths: + if process_mesh(p): + count += 1 + _log(f"일괄 처리 완료: {count} 메쉬 변경") + + +def run(import_folder: str = "/Game/Meshes/00_common"): + """수동 폴백: 폴더(재귀) 안 모든 StaticMesh 를 처리.""" + asset_registry = unreal.AssetRegistryHelpers.get_asset_registry() + mesh_filter = unreal.ARFilter( + class_names=["StaticMesh"], + package_paths=[import_folder], + recursive_paths=True, + ) + meshes = asset_registry.get_assets(mesh_filter) + if not meshes: + _warn(f"'{import_folder}' 에서 StaticMesh 없음") + return + process_meshes([str(m.package_name) for m in meshes]) From 2aeb1db3f2b3f6f9437aab2a913ce35fc4fbb435 Mon Sep 17 00:00:00 2001 From: 424 <2421412@naver.com> Date: Sat, 27 Jun 2026 01:59:49 +0900 Subject: [PATCH 16/25] Use public handoff validator API --- .../extensions/send2ue_material_pipeline.py | 52 ++++--------------- 1 file changed, 11 insertions(+), 41 deletions(-) diff --git a/src/addons/send2ue/resources/extensions/send2ue_material_pipeline.py b/src/addons/send2ue/resources/extensions/send2ue_material_pipeline.py index 3a22675c..3e5e275c 100644 --- a/src/addons/send2ue/resources/extensions/send2ue_material_pipeline.py +++ b/src/addons/send2ue/resources/extensions/send2ue_material_pipeline.py @@ -72,19 +72,10 @@ def pre_mesh_export(self, asset_data, properties): def _refresh_unreal_handoff_json_or_error(self, target): try: - import ue_unique_export_names_addon as addon - - props = bpy.context.scene.ue_unique_names - objects = addon.validation_scope_objects(bpy.context, props.scope) - materials = addon.unreal_handoff_materials_from_objects(objects) - texture_map = addon.material_texture_map(materials) - errors = addon._json_refresh_validation_errors( - bpy.context, - props, - objects, - materials, - texture_map, - ) + from ue_unique_export_names_addon import api as handoff_api + + result = handoff_api.refresh_handoff_json(bpy.context) + errors = result.get("errors") or [] if errors: first = errors[0] utilities.report_error( @@ -92,16 +83,7 @@ def _refresh_unreal_handoff_json_or_error(self, target): f' Target: "{target.name}". First: {first}', ) - prefix = addon.asset_prefix(bpy.context, props.prefix_mode, props.custom_prefix) - export_dir = Path(addon.resolve_export_dir(props.texture_export_dir)) - json_paths = addon.write_unreal_pipeline_json( - bpy.context, - prefix, - objects, - materials, - texture_map, - export_dir, - ) + json_paths = result.get("json_paths") or [] if not json_paths: utilities.report_error( "Unreal handoff JSON refresh produced no files.", @@ -150,10 +132,7 @@ def _resolve_json_path_for_export(self, asset_data, target): candidates.append(name) try: - import ue_unique_export_names_addon as addon - - props = bpy.context.scene.ue_unique_names - export_dir = Path(addon.resolve_export_dir(props.texture_export_dir)) + from ue_unique_export_names_addon import api as handoff_api except Exception as exc: utilities.report_error( "UE Unique Names add-on is required before Send to Unreal.", @@ -161,11 +140,7 @@ def _resolve_json_path_for_export(self, asset_data, target): ) return None - for name in candidates: - candidate = export_dir / f"{name}.json" - if candidate.exists(): - return str(candidate) - return None + return handoff_api.resolve_sidecar_json_path(candidates, bpy.context) def _asset_name_from_value(self, value): if not value: @@ -265,16 +240,11 @@ def post_import(self, asset_data, properties): def _resolve_json_path(self, asset_path): """Return the Blender-authored sidecar JSON path for this imported mesh.""" try: - from pathlib import Path - - import ue_unique_export_names_addon as addon + from ue_unique_export_names_addon import api as handoff_api - mesh_name = asset_path.rsplit("/", 1)[-1] - props = bpy.context.scene.ue_unique_names - export_dir = addon.resolve_export_dir(props.texture_export_dir) - candidate = Path(export_dir) / f"{mesh_name}.json" - if candidate.exists(): - return str(candidate).replace("\\", "/") + json_path = handoff_api.resolve_sidecar_json_path(asset_path, bpy.context) + if json_path: + return str(json_path).replace("\\", "/") except Exception as exc: print( "[material_pipeline] json_path resolve failed; " From eea7d5e2bd1778e11efb430d2bed5bf8a44678c1 Mon Sep 17 00:00:00 2001 From: 424 <2421412@naver.com> Date: Sat, 27 Jun 2026 03:47:23 +0900 Subject: [PATCH 17/25] Optimize material pipeline handoff import --- src/addons/send2ue/core/export.py | 18 ++- src/addons/send2ue/core/ingest.py | 21 ++- src/addons/send2ue/core/io/fbx_b4.py | 41 +++++- .../send2ue/dependencies/remote_execution.py | 20 ++- src/addons/send2ue/operators.py | 7 +- .../extensions/send2ue_material_pipeline.py | 52 ++++++- .../resources/pipeline/ue_material_setup.py | 137 ++++++++++++++---- 7 files changed, 248 insertions(+), 48 deletions(-) diff --git a/src/addons/send2ue/core/export.py b/src/addons/send2ue/core/export.py index 9f7b5dba..adb6698a 100644 --- a/src/addons/send2ue/core/export.py +++ b/src/addons/send2ue/core/export.py @@ -250,14 +250,16 @@ def export_mesh(asset_id, mesh_object, properties, lod=0): # Note: this is a weird work around for morph targets not exporting when # particle systems are on the mesh. Making them not visible fixes this bug existing_display_options = utilities.disable_particles(mesh_object) - # export selection to a file - export_file(properties, lod) - # restore the particle system display options - utilities.restore_particles(mesh_object, existing_display_options) - - # run the post mesh export extensions - if lod == 0: - extension.run_extension_tasks(ExtensionTasks.POST_MESH_EXPORT.value) + try: + # export selection to a file + export_file(properties, lod) + finally: + # restore the particle system display options + utilities.restore_particles(mesh_object, existing_display_options) + + # run the post mesh export extensions + if lod == 0: + extension.run_extension_tasks(ExtensionTasks.POST_MESH_EXPORT.value) @utilities.track_progress(message='Exporting animation "{attribute}"...', attribute='file_path') diff --git a/src/addons/send2ue/core/ingest.py b/src/addons/send2ue/core/ingest.py index 8ff960da..98c89181 100644 --- a/src/addons/send2ue/core/ingest.py +++ b/src/addons/send2ue/core/ingest.py @@ -10,6 +10,21 @@ UnrealRemoteCalls = make_remote(UnrealCalls) +def _property_data_for_asset(asset_data, property_data): + if "_import_materials_and_textures" not in asset_data: + return property_data + + adjusted = dict(property_data) + option_data = adjusted.get("import_materials_and_textures") + if isinstance(option_data, dict): + option_data = dict(option_data) + else: + option_data = {} + option_data["value"] = bool(asset_data["_import_materials_and_textures"]) + adjusted["import_materials_and_textures"] = option_data + return adjusted + + @track_progress(message='Importing asset "{attribute}"...', attribute='file_path') def import_asset(asset_id, property_data): """ @@ -26,7 +41,11 @@ def import_asset(asset_id, property_data): if not asset_data.get('skip'): file_path = asset_data.get('file_path') - UnrealRemoteCalls.import_asset(file_path, asset_data, property_data) + UnrealRemoteCalls.import_asset( + file_path, + asset_data, + _property_data_for_asset(asset_data, property_data), + ) # import fcurves if asset_data.get('fcurve_file_path'): diff --git a/src/addons/send2ue/core/io/fbx_b4.py b/src/addons/send2ue/core/io/fbx_b4.py index ecca5a86..1a49d118 100644 --- a/src/addons/send2ue/core/io/fbx_b4.py +++ b/src/addons/send2ue/core/io/fbx_b4.py @@ -6,6 +6,7 @@ from importlib.machinery import SourceFileLoader SCALE_FACTOR = 100 +TEXTURELESS_FBX_EXPORT_FLAG = "send2ue_material_pipeline_textureless_fbx_export" def export(**keywords): @@ -67,6 +68,41 @@ def export(**keywords): from io_scene_fbx.export_fbx_bin import fbx_data_element_custom_properties + def fbx_data_from_scene_without_textures(scene, depsgraph, settings): + scene_data = original_fbx_data_from_scene(scene, depsgraph, settings) + if not bpy.app.driver_namespace.get(TEXTURELESS_FBX_EXPORT_FLAG): + return scene_data + + templates = dict(scene_data.templates) + templates_users = scene_data.templates_users + for template_key in (b"TextureFile", b"Video"): + template = templates.pop(template_key, None) + if template is not None: + templates_users -= template.nbr_users + + texture_ids = { + get_fbx_uuid_from_key(texture_key) + for texture_key, _fbx_prop in scene_data.data_textures.values() + } + video_ids = { + get_fbx_uuid_from_key(video_key) + for video_key, _texture_keys in scene_data.data_videos.values() + } + removed_ids = texture_ids | video_ids + connections = [ + connection + for connection in scene_data.connections + if connection[1] not in removed_ids and connection[2] not in removed_ids + ] + + return scene_data._replace( + templates=templates, + templates_users=templates_users, + connections=connections, + data_textures={}, + data_videos={}, + ) + def fbx_animations_do(scene_data, ref_id, f_start, f_end, start_zero, objects=None, force_keep=False): """ Generate animation data (a single AnimStack) from objects, for a given frame range. @@ -622,12 +658,14 @@ def fbx_data_bindpose_element(root, me_obj, me, scene_data, arm_obj=None, mat_wo # save a copy of the original export bin original_fbx_animations_do = export_fbx_bin.fbx_animations_do + original_fbx_data_from_scene = export_fbx_bin.fbx_data_from_scene original_fbx_data_armature_elements = export_fbx_bin.fbx_data_armature_elements original_fbx_data_object_elements = export_fbx_bin.fbx_data_object_elements original_fbx_data_bindpose_element = export_fbx_bin.fbx_data_bindpose_element # here is where we patch in our tweaked functions export_fbx_bin.fbx_animations_do = fbx_animations_do + export_fbx_bin.fbx_data_from_scene = fbx_data_from_scene_without_textures export_fbx_bin.fbx_data_armature_elements = fbx_data_armature_elements export_fbx_bin.fbx_data_object_elements = fbx_data_object_elements export_fbx_bin.fbx_data_bindpose_element = fbx_data_bindpose_element @@ -644,6 +682,7 @@ def fbx_data_bindpose_element(root, me_obj, me, scene_data, arm_obj=None, mat_wo # now re-patch back the export bin module so that the existing fbx addon still has its original code # https://github.com/EpicGamesExt/BlenderTools/issues/598 export_fbx_bin.fbx_animations_do = original_fbx_animations_do + export_fbx_bin.fbx_data_from_scene = original_fbx_data_from_scene export_fbx_bin.fbx_data_armature_elements = original_fbx_data_armature_elements export_fbx_bin.fbx_data_object_elements = original_fbx_data_object_elements - export_fbx_bin.fbx_data_bindpose_element = original_fbx_data_bindpose_element \ No newline at end of file + export_fbx_bin.fbx_data_bindpose_element = original_fbx_data_bindpose_element diff --git a/src/addons/send2ue/dependencies/remote_execution.py b/src/addons/send2ue/dependencies/remote_execution.py index a05680ac..1c9c5369 100644 --- a/src/addons/send2ue/dependencies/remote_execution.py +++ b/src/addons/send2ue/dependencies/remote_execution.py @@ -22,6 +22,7 @@ _NODE_TIMEOUT_SECONDS = 5 # Number of seconds to wait before timing out a remote node that was discovered via UDP and has stopped sending "pong" responses DEFAULT_RECEIVE_BUFFER_SIZE = 8192 # The default receive buffer size +MAX_COMMAND_RESPONSE_SIZE = 64 * 1024 * 1024 # Guard against unbounded command response reads # Execution modes (these must match the names given to LexToString for EPythonCommandExecutionMode in IPythonScriptPlugin.h) MODE_EXEC_FILE = 'ExecuteFile' # Execute the Python command as a file. This allows you to execute either a literal Python script containing multiple statements, or a file with optional arguments @@ -463,11 +464,24 @@ def _receive_message(self, expected_type): Returns: The message that was received. ''' - data = self._command_channel_socket.recv(DEFAULT_RECEIVE_BUFFER_SIZE) - if data: + data = b'' + while len(data) <= MAX_COMMAND_RESPONSE_SIZE: + chunk = self._command_channel_socket.recv(DEFAULT_RECEIVE_BUFFER_SIZE) + if not chunk: + break + + data += chunk + try: + json_str = data.decode('utf-8') + _json.loads(json_str) + except (UnicodeDecodeError, _json.JSONDecodeError): + continue + message = _RemoteExecutionMessage(None, None) - if message.from_json_bytes(data) and message.passes_receive_filter(self._node_id) and message.type_ == expected_type: + if message.from_json(json_str) and message.passes_receive_filter(self._node_id) and message.type_ == expected_type: return message + break + raise RuntimeError('Remote party failed to send a valid response!') def _init_command_listen_socket(self): diff --git a/src/addons/send2ue/operators.py b/src/addons/send2ue/operators.py index cb092151..614d4078 100644 --- a/src/addons/send2ue/operators.py +++ b/src/addons/send2ue/operators.py @@ -54,13 +54,11 @@ def modal(self, context, event): try: function, args, kwargs, message, asset_id, attribute = self.execution_queue.get() step = self.max_step - self.execution_queue.qsize() - context.window_manager.send2ue.progress = abs(((step / self.max_step) * 100) - 1) + context.window_manager.send2ue.progress = min((step / self.max_step) * 100, 99) utilities.refresh_all_areas() # set the current asset id context.window_manager.send2ue.asset_id = asset_id - # run the function - function(*args, **kwargs) # get the description file_name = context.window_manager.send2ue.asset_data[asset_id].get(attribute) @@ -68,6 +66,9 @@ def modal(self, context, event): attribute=utilities.get_asset_name_from_file_name(file_name) ) bpy.context.workspace.status_text_set_internal(description) + + # run the function + function(*args, **kwargs) except Exception as error: self.escape_operation(context) raise error diff --git a/src/addons/send2ue/resources/extensions/send2ue_material_pipeline.py b/src/addons/send2ue/resources/extensions/send2ue_material_pipeline.py index 3e5e275c..28c22eba 100644 --- a/src/addons/send2ue/resources/extensions/send2ue_material_pipeline.py +++ b/src/addons/send2ue/resources/extensions/send2ue_material_pipeline.py @@ -20,6 +20,8 @@ "\\", "/", ) +_TEXTURELESS_FBX_RESTORE = {} +TEXTURELESS_FBX_EXPORT_FLAG = "send2ue_material_pipeline_textureless_fbx_export" class MaterialPipelineExtension(ExtensionBase): @@ -39,7 +41,7 @@ def pre_mesh_export(self, asset_data, properties): asset_data = asset_data or {} target = bpy.data.objects.get(asset_data.get("_mesh_object_name", "")) - if not target or target.type != "MESH": + if not target: return self._refresh_unreal_handoff_json_or_error(target) @@ -47,6 +49,8 @@ def pre_mesh_export(self, asset_data, properties): sidecar = self._load_json_sidecar_for_export(asset_data, target) if sidecar is None: return + self._prepare_textureless_fbx_materials(target) + transfer = self._transfer_entry_for_target(sidecar, target) shape_keys = bool(transfer.get("shape_keys")) weights = bool(transfer.get("weights")) @@ -70,6 +74,37 @@ def pre_mesh_export(self, asset_data, properties): target.vdt_object_props.transfer_source = source self._run_vertex_data_transfer(target, shape_keys, weights) + def post_mesh_export(self, asset_data, properties): + target_name = (asset_data or {}).get("_mesh_object_name", "") + self._restore_textureless_fbx_materials(target_name) + + def _prepare_textureless_fbx_materials(self, target): + if target.name in _TEXTURELESS_FBX_RESTORE: + return + + namespace = bpy.app.driver_namespace + _TEXTURELESS_FBX_RESTORE[target.name] = { + "had_flag": TEXTURELESS_FBX_EXPORT_FLAG in namespace, + "previous": namespace.get(TEXTURELESS_FBX_EXPORT_FLAG), + } + namespace[TEXTURELESS_FBX_EXPORT_FLAG] = True + + def _textureless_fbx_mesh_objects(self, target): + if target.type == "MESH": + return [target] + return [child for child in target.children_recursive if child.type == "MESH"] + + def _restore_textureless_fbx_materials(self, target_name, state=None): + state = state or _TEXTURELESS_FBX_RESTORE.pop(target_name, None) + if not state: + return + + namespace = bpy.app.driver_namespace + if state["had_flag"]: + namespace[TEXTURELESS_FBX_EXPORT_FLAG] = state["previous"] + else: + namespace.pop(TEXTURELESS_FBX_EXPORT_FLAG, None) + def _refresh_unreal_handoff_json_or_error(self, target): try: from ue_unique_export_names_addon import api as handoff_api @@ -211,6 +246,21 @@ def _run_vertex_data_transfer(self, target, shape_keys, weights): if vdt_props is not None and previous_overwrite_shape_keys is not None: vdt_props.overwrite_shape_keys = previous_overwrite_shape_keys + def pre_import(self, asset_data, properties): + if not self.enabled: + return + if asset_data.get("skip"): + return + if asset_data.get("_asset_type") != UnrealTypes.STATIC_MESH: + return + if not self._resolve_json_path(asset_data.get("asset_path", "")): + return + + # The JSON material pipeline imports textures and creates/assigns MIs itself. + # Letting the FBX importer also create source materials/textures adds duplicate + # assets that immediately need cleanup, which is expensive in Unreal/P4. + asset_data["_import_materials_and_textures"] = False + def post_import(self, asset_data, properties): if not self.enabled: return diff --git a/src/addons/send2ue/resources/pipeline/ue_material_setup.py b/src/addons/send2ue/resources/pipeline/ue_material_setup.py index eeaa1992..262fead0 100644 --- a/src/addons/send2ue/resources/pipeline/ue_material_setup.py +++ b/src/addons/send2ue/resources/pipeline/ue_material_setup.py @@ -17,6 +17,7 @@ import json import os import re +import time import unreal # ─── 설정 ──────────────────────────────────────────────────────────────────── @@ -59,6 +60,8 @@ # /Game/Meshes/ 은 디스크의 JSON_SEARCH_ROOTS[0]/ 에 1:1 대응(send2ue 자동경로 규칙). # 이를 이용해 mesh_path 로부터 해당 프롭 폴더만 좁혀 JSON 을 찾는다 → 3만개 트리 전체 walk 회피. GAME_MESHES_PREFIX = "/Game/Meshes/" +DELETE_IMPORTED_SOURCE_MATERIALS = False +DELETE_IMPORTED_SOURCE_TEXTURES = False # Shared surface-layer texture parameter names (JSON 의 param 과 동일해야 연결됨) KNOWN_PARAMS = {"Albedo", "Extra", "Normal", "Height", "Transmission", "Emissive"} @@ -800,23 +803,44 @@ def _material_instance_base_name(mat_name: str) -> str: def _source_asset_names(data: dict): + cleanup = data.get("cleanup") + if isinstance(cleanup, dict): + material_names = _unique_string_list(cleanup.get("source_material_names", [])) + texture_names = _unique_string_list(cleanup.get("source_texture_names", [])) + if material_names or texture_names: + return material_names, texture_names + material_names = [] texture_names = [] + seen_material_names = set() + seen_texture_names = set() + + def append_unique(target: list, seen: set, name: str): + key = name.casefold() + if name and key not in seen: + seen.add(key) + target.append(name) + + def add_material_names(name: str): + append_unique(material_names, seen_material_names, name) + base_name = _material_instance_base_name(name) + append_unique(material_names, seen_material_names, base_name) + for prefix in ("LayerBlend_", "Prop_", "Coat_"): + if base_name.startswith(prefix): + append_unique(material_names, seen_material_names, base_name[len(prefix):]) def add_texture_names(tex: dict): - asset_name = str(tex.get("asset_name", "")) - if asset_name: - texture_names.append(asset_name) file_path = str(tex.get("file", "")) if file_path: source_name = os.path.splitext(os.path.basename(file_path))[0] - if source_name: - texture_names.append(source_name) + append_unique(texture_names, seen_texture_names, source_name) + else: + asset_name = str(tex.get("asset_name", "")) + append_unique(texture_names, seen_texture_names, asset_name) for entry in data.get("materials", []): - mat_name = str(entry.get("name", "")) - if mat_name: - material_names.append(mat_name) + add_material_names(str(entry.get("name", ""))) + add_material_names(str(entry.get("slot_name", ""))) for tex in entry.get("textures", []): add_texture_names(tex) for layer in entry.get("layers", []): @@ -825,6 +849,30 @@ def add_texture_names(tex: dict): return material_names, texture_names +def _unique_string_list(values) -> list: + unique = [] + seen = set() + if not isinstance(values, list): + return unique + for value in values: + name = str(value or "") + key = name.casefold() + if name and key not in seen: + seen.add(key) + unique.append(name) + return unique + + +def _asset_paths_by_name(folder_path: str) -> dict: + assets = unreal.EditorAssetLibrary.list_assets(folder_path, recursive=False, include_folder=False) + paths_by_name = {} + for asset_path in (str(path) for path in assets): + package_path = asset_path.split(".", 1)[0] + asset_name = package_path.rsplit("/", 1)[-1] + paths_by_name[asset_name.casefold()] = package_path + return paths_by_name + + def _delete_asset_if_type(asset_path: str, allowed_class_names: set) -> bool: if not unreal.EditorAssetLibrary.does_asset_exist(asset_path): return False @@ -833,43 +881,70 @@ def _delete_asset_if_type(asset_path: str, allowed_class_names: set) -> bool: return False class_name = asset.get_class().get_name() if class_name not in allowed_class_names: - _log(f" cleanup skip: {asset_path} ({class_name})") return False if unreal.EditorAssetLibrary.delete_asset(asset_path): - _log(f" cleanup delete: {asset_path}") return True _warn(f" cleanup delete failed: {asset_path}") return False def _cleanup_imported_source_assets(mesh_path: str, data: dict): + started = time.perf_counter() mesh_folder = mesh_path.rsplit("/", 1)[0] material_names, texture_names = _source_asset_names(data) + existing_asset_paths = _asset_paths_by_name(mesh_folder) deleted = 0 - for name in material_names: - asset_path = f"{mesh_folder}/{name}" - if asset_path == mesh_path: - continue - if _delete_asset_if_type(asset_path, {"Material", "MaterialInstanceConstant"}): - deleted += 1 + skipped_materials = 0 + skipped_textures = 0 + if DELETE_IMPORTED_SOURCE_MATERIALS: + for name in material_names: + asset_path = existing_asset_paths.get(name.casefold()) + if not asset_path or asset_path == mesh_path: + continue + if _delete_asset_if_type(asset_path, {"Material", "MaterialInstanceConstant"}): + deleted += 1 + else: + skipped_materials = sum( + 1 + for name in material_names + if (asset_path := existing_asset_paths.get(name.casefold())) and asset_path != mesh_path + ) - for name in texture_names: - asset_path = f"{mesh_folder}/{name}" - if asset_path == mesh_path: - continue - if _delete_asset_if_type(asset_path, {"Texture2D"}): - deleted += 1 - - if deleted: - # NOTE(perf): delete_asset 가 각 패키지를 디스크에서 즉시 지우고, 메쉬/MI/텍스처는 위에서 - # 이미 개별 save 됐으므로 여기서 추가로 flush 할 게 없다. 예전엔 save_directory( - # only_if_is_dirty=False)로 import 폴더 전체를 강제 재저장했는데, 모든 메쉬가 같은 폴더 - # (/Game/untitled_category/untitled_asset)로 들어오는 데다 메쉬마다 호출돼서 폴더가 - # 쌓일수록 export 가 O(N^2) 로 느려졌다. 그래서 폴더 통째 save 는 하지 않는다. - _log(f" cleanup complete: {deleted} source asset(s) removed from {mesh_folder}") + if DELETE_IMPORTED_SOURCE_TEXTURES: + for name in texture_names: + asset_path = existing_asset_paths.get(name.casefold()) + if not asset_path or asset_path == mesh_path: + continue + if _delete_asset_if_type(asset_path, {"Texture2D"}): + deleted += 1 + else: + skipped_textures = sum( + 1 + for name in texture_names + if (asset_path := existing_asset_paths.get(name.casefold())) and asset_path != mesh_path + ) + + elapsed = time.perf_counter() - started + if deleted or skipped_materials or skipped_textures: + # Source asset deletes trigger package deletion, source-control work, and shader churn. + # Keep them available for manual maintenance, but do not run them during export by default. + material_note = ( + f"; {skipped_materials} source material asset(s) left in place" + if skipped_materials + else "" + ) + texture_note = ( + f"; {skipped_textures} source texture asset(s) left in place" + if skipped_textures + else "" + ) + _log( + f" cleanup complete: {deleted} source asset(s) removed from {mesh_folder}" + f"{material_note}{texture_note} ({elapsed:.2f}s)" + ) else: - _log(f" cleanup: no source material/texture assets found in {mesh_folder}") + _log(f" cleanup: no source material/texture assets found in {mesh_folder} ({elapsed:.2f}s)") def process_mesh(mesh_path: str, master_mat=None, json_path: str = None) -> bool: From b894db83fbe227a529217ba31a291ebe663b8213 Mon Sep 17 00:00:00 2001 From: 424 <2421412@naver.com> Date: Tue, 7 Jul 2026 14:38:48 +0900 Subject: [PATCH 18/25] Document and harden Send2UE handoff automation --- .../extras/unreal-handoff-automation.md | 69 + docs/send2ue/mkdocs.yml | 3 +- .../send2ue/core/armature_modifier_fix.py | 54 +- .../resources/pipeline/ue_material_setup.py | 1184 +++++++++++++++-- 4 files changed, 1214 insertions(+), 96 deletions(-) create mode 100644 docs/send2ue/extras/unreal-handoff-automation.md diff --git a/docs/send2ue/extras/unreal-handoff-automation.md b/docs/send2ue/extras/unreal-handoff-automation.md new file mode 100644 index 00000000..fb36ff94 --- /dev/null +++ b/docs/send2ue/extras/unreal-handoff-automation.md @@ -0,0 +1,69 @@ +# Unreal Handoff Automation + +This project has a few Send to Unreal handoff rules that are applied automatically during export and post-import. They are intentionally narrow so artist-authored Blender data can drive the handoff without creating replacement Unreal assets unexpectedly. + +## Blender export preparation + +### Hidden export armatures + +Armatures that are directly placed in the `Export` collection are temporarily made visible while Send to Unreal prepares the export. Their original viewport visibility is restored during cleanup. + +This keeps skeletal mesh exports stable when an artist hides the rig in Blender for viewport work. The export still sees the intended armature, but the Blender scene is returned to the artist's previous hidden/visible state afterward. + +## Unreal post-import material setup + +### JSON sidecar contract + +The Blender material JSON sidecar is the source of truth for slot names, slot indexes, material presets, target material instance names, and whether a missing material instance may be created. + +The Unreal post-import script reads that sidecar and applies the matching material instance to the imported Static Mesh or Skeletal Mesh slot. + +### Hair material instances + +Hair materials whose Blender material names normalize to `M_HT...` are treated as existing hair material instances, not as new AssetSurface materials. + +For these entries the JSON writer emits: + +* `master_preset: "hair"` +* `material_instance_name: "MI_HT..."` +* `create_if_missing: false` +* no texture entries +* no layer entries + +The Unreal post-import step then looks for the existing legacy hair material instance set using the normalized hair base name, including common variants such as: + +* `HT...` +* `HT..._Inst` +* `HT..._LWHQ_Inst` + +When a matching source `MaterialInstanceConstant` is found outside `/Game/Material/AssetSurface/`, it is moved into the managed hair MI folder: + +```text +/Game/Material/AssetSurface/MI/Hair/MI_HT... +``` + +If a wrong generated target already exists there, it is deleted before the legacy hair instance is moved into place. If the correct target already exists and no legacy source remains, the existing target is reused. + +If no legacy source and no target can be found, the slot is skipped with a warning. The script does not create a fresh hair material instance in that case. + +### Hair non-goals + +Hair handoff deliberately does not: + +* create a new material instance from the AssetSurface master +* import or assign texture maps from Blender for hair entries +* assign AssetSurface material layers +* route hair through the translucent/glass material branch +* hard-code one exact source asset path + +The source lookup is Asset Registry based, class-filtered to `MaterialInstanceConstant`, and excludes existing AssetSurface paths so the migration prefers the original hair material set. + +### Regular AssetSurface materials + +Non-hair materials continue through the regular sidecar-driven AssetSurface flow: + +* resolve or create the configured material instance when allowed +* import textures with the expected compression and virtual texture settings +* assign flat parameters or material layer instances based on the selected preset +* write material assignments back to the imported mesh slots + diff --git a/docs/send2ue/mkdocs.yml b/docs/send2ue/mkdocs.yml index bd43fb6b..c7579c52 100644 --- a/docs/send2ue/mkdocs.yml +++ b/docs/send2ue/mkdocs.yml @@ -27,8 +27,9 @@ nav: - Instance Assets: "./extensions/instance-assets.md" - Extras: - Pipeline Menu: "./extras/pipeline-menu.md" + - Unreal Handoff Automation: "./extras/unreal-handoff-automation.md" - Addon Preferences: "./extras/addon-preferences.md" - Community Extensions: "./extras/community-extensions.md" - Trouble Shooting: - Community Extensions: "./trouble-shooting/faq.md" - - Errors: "./trouble-shooting/errors.md" \ No newline at end of file + - Errors: "./trouble-shooting/errors.md" diff --git a/src/addons/send2ue/core/armature_modifier_fix.py b/src/addons/send2ue/core/armature_modifier_fix.py index ffe8a127..36c8df9f 100644 --- a/src/addons/send2ue/core/armature_modifier_fix.py +++ b/src/addons/send2ue/core/armature_modifier_fix.py @@ -2,12 +2,44 @@ import bpy from . import utilities -from ..constants import BlenderTypes +from ..constants import BlenderTypes, ToolInfo STATE_KEY = 'send2ue_armature_modifier_fix_state' +def _export_armature_objects(): + export_collection = bpy.data.collections.get(ToolInfo.EXPORT_COLLECTION.value) + if not export_collection: + return [] + return [ + scene_object + for scene_object in export_collection.all_objects + if ( + scene_object.type == BlenderTypes.SKELETON + and export_collection in scene_object.users_collection + ) + ] + + +def _make_export_armatures_visible(state): + """ + Temporarily unhides armatures that are explicitly in Export. + + Send to Unreal collects rigs through visible objects, but artists often hide + armatures in the viewport while working. If a hidden export armature is left + hidden, the skeletal mesh export can lose the intended rig/skeleton context. + """ + for rig_object in _export_armature_objects(): + hide_get = rig_object.hide_get() + hide_viewport = rig_object.hide_viewport + if not hide_get and not hide_viewport: + continue + state['visibility'].append((rig_object.name, hide_get, hide_viewport)) + rig_object.hide_set(False) + rig_object.hide_viewport = False + + def get_top_parent_rig_object(scene_object, rig_objects): """ Gets the highest exported armature in an object's parent chain. @@ -74,18 +106,21 @@ def prepare(): """ cleanup() - mesh_objects = utilities.get_from_collection(BlenderTypes.MESH) - rig_objects = utilities.get_from_collection(BlenderTypes.SKELETON) - if not rig_objects: - return - state = { 'modifiers': [], # list of (object_name, modifier_name) 'vertex_groups': [], # list of (object_name, vertex_group_name) + 'visibility': [], # list of (object_name, hide_get, hide_viewport) } bpy.app.driver_namespace[STATE_KEY] = state try: + _make_export_armatures_visible(state) + + mesh_objects = utilities.get_from_collection(BlenderTypes.MESH) + rig_objects = utilities.get_from_collection(BlenderTypes.SKELETON) + if not rig_objects: + return + for mesh_object in mesh_objects: # leave meshes that already have an armature modifier alone if any(modifier.type == 'ARMATURE' for modifier in mesh_object.modifiers): @@ -144,3 +179,10 @@ def cleanup(): vertex_group = scene_object.vertex_groups.get(vertex_group_name) if vertex_group: scene_object.vertex_groups.remove(vertex_group) + + for object_name, hide_get, hide_viewport in state.get('visibility', []): + scene_object = bpy.data.objects.get(object_name) + if not scene_object: + continue + scene_object.hide_set(hide_get) + scene_object.hide_viewport = hide_viewport diff --git a/src/addons/send2ue/resources/pipeline/ue_material_setup.py b/src/addons/send2ue/resources/pipeline/ue_material_setup.py index 262fead0..b42cc6c9 100644 --- a/src/addons/send2ue/resources/pipeline/ue_material_setup.py +++ b/src/addons/send2ue/resources/pipeline/ue_material_setup.py @@ -20,51 +20,170 @@ import time import unreal + +def _candidate_contract_paths(): + override = os.environ.get("SUBSTANCE_TOOLS_PIPELINE_CONTRACT") + if override: + yield override + + here = os.path.abspath(__file__) + current = os.path.dirname(here) + while current and os.path.dirname(current) != current: + yield os.path.join(current, "pipeline_contract.json") + yield os.path.join(current, "substance-tools", "pipeline_contract.json") + yield os.path.join(current, "substance_tools", "pipeline_contract.json") + current = os.path.dirname(current) + + appdata = os.environ.get("APPDATA") + if appdata: + blender_root = os.path.join(appdata, "Blender Foundation", "Blender") + if os.path.isdir(blender_root): + for version in sorted(os.listdir(blender_root), reverse=True): + yield os.path.join( + blender_root, + version, + "scripts", + "addons", + "substance_tools", + "pipeline_contract.json", + ) + + +def _pipeline_contract(): + for path in _candidate_contract_paths(): + if os.path.isfile(path): + with open(path, "r", encoding="utf-8") as handle: + return json.load(handle) + return {} + + +def _contract_path_mapping(): + return _pipeline_contract().get("unreal_path_mapping", {}).get("current_default", {}) + # ─── 설정 ──────────────────────────────────────────────────────────────────── DEFAULT_MASTER_PRESET = "prop" MASTER_PRESETS = { "prop": { - "master": "/Game/Material/Mesh/M_Prop_Master", - "mi_folder": "/Game/Material/Mesh/MI_Prop_Master", - "assignment": "flat", + "master": "/Game/Material/AssetSurface/Master/M_AssetSurface_Master", + "mi_folder": "/Game/Material/AssetSurface/MI/Surface", + "assignment": "asset_surface_flat", + "virtual_textures": True, }, "layer": { - "master": "/Game/Material/Layer/M_LayerBlend", - "mi_folder": "/Game/Material/Layer", - "assignment": "layer", + "master": "/Game/Material/AssetSurface/Master/M_LayerBlend", + "mi_folder": "/Game/Material/AssetSurface/MI/LayerBlend", + "assignment": "material_layer_instance", + "layer_parent": "/Game/Material/AssetSurface/Master/MaterialLayer/MY_Mesh_UV0", + "layer_instance_folder": "/Game/Material/AssetSurface/MYI/LayerBlend", + "layer_instance_strip_prefixes": ["LayerBlend_"], + "layer_texture_remap": { + "Albedo": "Albedo", + "Extra": "Extra", + "Normal": "Normal", + "Height": "Height", + "Transmission": "Transmission", + }, + "virtual_textures": True, }, "cloth": { - "master": "/Game/Material/Layer/M_LayerBlend", - "mi_folder": "/Game/Material/Layer", - "assignment": "layer", + "master": "/Game/Material/AssetSurface/Master/M_Coat_Fabric_Substrate_Master", + "mi_folder": "/Game/Material/AssetSurface/MI/Cloth", + "assignment": "material_layer_instance", + "layer_parent": "/Game/Material/AssetSurface/Master/MaterialLayer/MY_Cloth", + "layer_instance_folder": "/Game/Material/AssetSurface/MYI/Cloth", + "layer_texture_remap": { + "Albedo": "BaseColor", + "Extra": "ORM", + "Normal": "Normal", + "Sheen Color": "Fuzz Color Map", + "Sheen Opacity": "Fuzz Mask", + "Sheen Roughness": "Fuzz Roughness Map", + }, + "virtual_textures": True, }, "asset_surface": { - "master": "/Game/Material/AssetSurface/M_AssetSurface_Master", - "mi_folder": "/Game/Material/AssetSurface/MI", - "assignment": "layer", + "master": "/Game/Material/AssetSurface/Master/M_AssetSurface_Master", + "mi_folder": "/Game/Material/AssetSurface/MI/Surface", + "assignment": "asset_surface_flat", + "virtual_textures": True, }, "coat": { - "master": "/Game/Material/Mesh/M_Coat_Fabric_Substrate_Master", - "mi_folder": "/Game/Material/Mesh/MI_Coat_Master", - "assignment": "coat_flat", + "master": "/Game/Material/AssetSurface/Master/M_Coat_Fabric_Substrate_Master", + "mi_folder": "/Game/Material/AssetSurface/MI/Cloth", + "assignment": "material_layer_instance", + "layer_parent": "/Game/Material/AssetSurface/Master/MaterialLayer/MY_Cloth", + "layer_instance_folder": "/Game/Material/AssetSurface/MYI/Cloth", + "layer_texture_remap": { + "Albedo": "BaseColor", + "Extra": "ORM", + "Normal": "Normal", + "Sheen Color": "Fuzz Color Map", + "Sheen Opacity": "Fuzz Mask", + "Sheen Roughness": "Fuzz Roughness Map", + }, + "virtual_textures": True, + }, + "hair": { + "master": "/Game/CC_Shaders/HairShader/RL_Hair", + "mi_folder": "/Game/Material/AssetSurface/MI/Hair", + "assignment": "none", + "virtual_textures": False, + "create_if_missing": False, + "exclude_path_fragments": ["/Game/Material/AssetSurface/"], + }, + "tree": { + "master": "/Game/Material/Tree/AssetTree/Master/M_TreeAsset_Master", + "mi_folder": "/Game/Material/Tree/AssetTree/MI", + "assignment": "material_layer_instance", + "layer_parent": "/Game/Material/Tree/AssetTree/Master/MaterialLayer/MY_Tree_Bark", + "layer_instance_folder": "/Game/Material/Tree/AssetTree/MYI", + "layer_parents_by_name": { + "bark": "/Game/Material/Tree/AssetTree/Master/MaterialLayer/MY_Tree_Bark", + "branch": "/Game/Material/Tree/AssetTree/Master/MaterialLayer/MY_Tree_Branch", + "leaf": "/Game/Material/Tree/AssetTree/Master/MaterialLayer/MY_Tree_Leaf", + }, + "layer_texture_remap": { + "Albedo": "Albedo", + "Extra": "Extra", + "Normal": "Normal", + "Height": "Height", + "Transmission": "Transmission", + }, + "virtual_textures": True, }, } # 반투명(유리) 머티리얼은 전용 MI 를 만들지 않고 이 공유 글래스 MI 를 슬롯에 직접 할당한다. # (Megascan 글래스를 프로젝트로 localize 한 인스턴스. 부모 M_MS_Glass_Material, TRANSLUCENT) -GLASS_MI_PATH = "/Game/Material/Mesh/MI_Prop_Master/MI_Prop_Glass_01" +GLASS_MI_PATH = "/Game/Material/AssetSurface/MI/MI_Prop_Glass_01" TEXTURES_FOLDER = "/Game/Textures" EXPORT_DIR = r"C:/Users/PARK/Documents/UE_Blender_Pipeline/exports" +_PATH_MAPPING = _contract_path_mapping() +_LOCAL_ANCHOR = str(_PATH_MAPPING.get("local_anchor") or "Forestportfolio").strip("/\\") JSON_SEARCH_ROOTS = [ - r"C:/Users/PARK/OneDrive/Forestportfolio", + os.path.join(r"C:/Users/PARK/OneDrive", _LOCAL_ANCHOR), ] +_D_DRIVE_ANCHOR = os.path.join(r"D:/OneDrive", _LOCAL_ANCHOR) +if os.path.isdir(_D_DRIVE_ANCHOR) and _D_DRIVE_ANCHOR not in JSON_SEARCH_ROOTS: + JSON_SEARCH_ROOTS.append(_D_DRIVE_ANCHOR) # /Game/Meshes/ 은 디스크의 JSON_SEARCH_ROOTS[0]/ 에 1:1 대응(send2ue 자동경로 규칙). # 이를 이용해 mesh_path 로부터 해당 프롭 폴더만 좁혀 JSON 을 찾는다 → 3만개 트리 전체 walk 회피. -GAME_MESHES_PREFIX = "/Game/Meshes/" +GAME_MESHES_PREFIX = f"{str(_PATH_MAPPING.get('unreal_anchor') or '/Game/Meshes').rstrip('/')}/" DELETE_IMPORTED_SOURCE_MATERIALS = False DELETE_IMPORTED_SOURCE_TEXTURES = False # Shared surface-layer texture parameter names (JSON 의 param 과 동일해야 연결됨) -KNOWN_PARAMS = {"Albedo", "Extra", "Normal", "Height", "Transmission", "Emissive"} +KNOWN_PARAMS = { + "Albedo", + "Extra", + "Normal", + "Height", + "Transmission", + "Emissive", + "Sheen Color", + "Sheen Opacity", + "Sheen Roughness", + "Moss Blend Mask", +} LAYER_PARAM_BY_LEGACY_PARAM = { "BaseColor": "Albedo", "MetallicRoughness": "Extra", @@ -75,6 +194,9 @@ "Height": "Height", "Alpha": "Transmission", "Emissive": "Emissive", + "SheenColor": "Sheen Color", + "SheenOpacity": "Sheen Opacity", + "SheenRoughness": "Sheen Roughness", "Texture": "Albedo", } FLAT_PARAM_BY_LAYER_PARAM = { @@ -88,6 +210,49 @@ "Extra": "ORM", "Normal": "Normal", } +DEFAULT_CLOTH_TEXTURE_PARAM_BY_LAYER_PARAM = { + "Albedo": "BaseColor", + "Extra": "ORM", + "Normal": "Normal", + "Sheen Color": "Fuzz Color Map", + "Sheen Opacity": "Fuzz Mask", + "Sheen Roughness": "Fuzz Roughness Map", +} + + +def _cloth_texture_param_by_layer_param(): + contract = _pipeline_contract() + handoff_remap = ( + contract.get("unreal_handoff_sidecar", {}) + .get("cloth_master_param_remap", {}) + ) + material_layer_remap = handoff_remap.get("material_layer", {}) + legacy_remap = ( + contract.get("unreal_material_json", {}) + .get("cloth_master_param_remap", {}) + ) + mapping = ( + material_layer_remap.get("texture_remap") + or handoff_remap.get("texture_remap") + or legacy_remap.get("material_layer", {}).get("texture_remap") + or legacy_remap.get("texture_remap") + ) + if isinstance(mapping, dict) and mapping: + merged = dict(DEFAULT_CLOTH_TEXTURE_PARAM_BY_LAYER_PARAM) + merged.update({str(key): str(value) for key, value in mapping.items()}) + return merged + return dict(DEFAULT_CLOTH_TEXTURE_PARAM_BY_LAYER_PARAM) + + +CLOTH_TEXTURE_PARAM_BY_LAYER_PARAM = _cloth_texture_param_by_layer_param() +ASSET_SURFACE_PARAM_BY_LAYER_PARAM = { + "Albedo": "Albedo", + "Extra": "Extra", + "Normal": "Normal", + "Height": "Height", + "Transmission": "Transmission", + "Moss Blend Mask": "Moss Blend Mask", +} # import 되는 텍스처의 인게임 최대 해상도 캡(param 별). 목록에 없으면 DEFAULT 적용. MAX_TEXTURE_SIZE_BY_PARAM = { @@ -99,6 +264,8 @@ ENABLE_VIRTUAL_TEXTURE_STREAMING = True # import 되는 StaticMesh 를 자동으로 Nanite 로 등록할지(반투명 머티리얼 메쉬는 자동 제외). ENABLE_NANITE = True +ENABLE_SKELETAL_NANITE_VOXELIZE = True +DYNAMIC_WIND_JSON_SUFFIX = "_dynamic_wind_import_from_megaplant_groups.json" # ───────────────────────────────────────────────────────────────────────────── @@ -206,7 +373,7 @@ def _set_texture_property_if_changed(tex, property_name: str, value) -> bool: return True -def _configure_imported_texture(tex, param: str) -> bool: +def _configure_imported_texture(tex, param: str, virtual_texture_streaming=None) -> bool: changed = False if param == "Normal": @@ -216,13 +383,28 @@ def _configure_imported_texture(tex, param: str) -> bool: "compression_settings", unreal.TextureCompressionSettings.TC_NORMALMAP, ) - elif param in {"Extra", "MetallicRoughness", "Roughness", "Metallic", "Occlusion"}: + elif param in { + "Extra", + "MetallicRoughness", + "Roughness", + "Metallic", + "Occlusion", + "Sheen Opacity", + "Sheen Roughness", + }: changed |= _set_texture_property_if_changed(tex, "srgb", False) changed |= _set_texture_property_if_changed( tex, "compression_settings", unreal.TextureCompressionSettings.TC_MASKS, ) + elif param == "Height": + changed |= _set_texture_property_if_changed(tex, "srgb", False) + changed |= _set_texture_property_if_changed( + tex, + "compression_settings", + unreal.TextureCompressionSettings.TC_GRAYSCALE, + ) else: changed |= _set_texture_property_if_changed(tex, "srgb", True) @@ -230,17 +412,19 @@ def _configure_imported_texture(tex, param: str) -> bool: if max_size: changed |= _set_texture_property_if_changed(tex, "max_texture_size", max_size) - if ENABLE_VIRTUAL_TEXTURE_STREAMING: - try: - before = bool(tex.get_editor_property("virtual_texture_streaming")) - except Exception: - before = False - if not before: - if hasattr(tex, "set_virtual_texture_streaming"): - tex.set_virtual_texture_streaming(True) - else: - tex.set_editor_property("virtual_texture_streaming", True) - changed = True + if virtual_texture_streaming is None: + virtual_texture_streaming = ENABLE_VIRTUAL_TEXTURE_STREAMING + + try: + before = bool(tex.get_editor_property("virtual_texture_streaming")) + except Exception: + before = None + if before is not None and before != bool(virtual_texture_streaming): + if hasattr(tex, "set_virtual_texture_streaming"): + tex.set_virtual_texture_streaming(bool(virtual_texture_streaming)) + else: + tex.set_editor_property("virtual_texture_streaming", bool(virtual_texture_streaming)) + changed = True return changed @@ -251,6 +435,7 @@ def _import_texture( param: str, tex_cache: dict = None, force_reimport: bool = False, + virtual_texture_streaming=None, ): """디스크 텍스처를 TEXTURES_FOLDER 로 직접 import 하고 종류별 설정 적용. asset path 반환. @@ -273,7 +458,7 @@ def _import_texture( source_mtime = None if unreal.EditorAssetLibrary.does_asset_exist(full_path) and not force_reimport: tex = unreal.load_asset(full_path) - if tex is not None and _configure_imported_texture(tex, param): + if tex is not None and _configure_imported_texture(tex, param, virtual_texture_streaming): unreal.EditorAssetLibrary.save_asset(full_path) if tex_cache is not None and source_mtime is not None: tex_cache[full_path] = source_mtime @@ -285,7 +470,7 @@ def _import_texture( and unreal.EditorAssetLibrary.does_asset_exist(full_path) and not force_reimport): tex = unreal.load_asset(full_path) - if tex is not None and _configure_imported_texture(tex, param): + if tex is not None and _configure_imported_texture(tex, param, virtual_texture_streaming): unreal.EditorAssetLibrary.save_asset(full_path) return full_path @@ -303,7 +488,7 @@ def _import_texture( _warn(f" 텍스처 import 실패: {asset_name}") return None - _configure_imported_texture(tex, param) + _configure_imported_texture(tex, param, virtual_texture_streaming) unreal.EditorAssetLibrary.save_asset(full_path) if tex_cache is not None and source_mtime is not None: @@ -312,14 +497,66 @@ def _import_texture( return full_path -def _set_nanite(mesh, enabled: bool) -> bool: - """StaticMesh 의 Nanite 활성 상태를 enabled 로 맞춘다. 변경이 있었으면 True.""" +def _nanite_shape_preservation_voxelize(): + for enum_name in ("NaniteShapePreservation", "ENaniteShapePreservation"): + enum_type = getattr(unreal, enum_name, None) + if enum_type is None: + continue + for value_name in ("VOXELIZE", "Voxelize", "voxelize"): + value = getattr(enum_type, value_name, None) + if value is not None: + return value + for value_name in dir(enum_type): + if "voxel" in value_name.lower(): + value = getattr(enum_type, value_name, None) + if value is not None: + return value + return None + + +def _set_nanite_shape_preservation(nanite, value) -> bool: + if value is None: + return False + try: + if nanite.get_editor_property("shape_preservation") == value: + return False + except Exception: + pass + try: + nanite.set_editor_property("shape_preservation", value) + return True + except Exception as exc: + _warn(f" Nanite Shape Preservation Voxelize set failed: {exc}") + return False + + +def _notify_nanite_settings_changed(mesh): + for method_name in ("notify_nanite_settings_changed", "post_edit_change"): + method = getattr(mesh, method_name, None) + if callable(method): + try: + method() + except Exception: + pass + + +def _set_nanite(mesh, enabled: bool, shape_preservation=None) -> bool: + """Set mesh Nanite settings. Returns True when any value changed.""" nanite = mesh.get_editor_property("nanite_settings") - if bool(nanite.get_editor_property("enabled")) == enabled: + changed = False + if bool(nanite.get_editor_property("enabled")) != enabled: + nanite.set_editor_property("enabled", enabled) + changed = True + if enabled: + changed = _set_nanite_shape_preservation(nanite, shape_preservation) or changed + if not changed: return False - nanite.set_editor_property("enabled", enabled) mesh.set_editor_property("nanite_settings", nanite) - _log(f" Nanite {'활성화' if enabled else '비활성화(반투명)'}") + _notify_nanite_settings_changed(mesh) + if enabled and shape_preservation is not None: + _log(" Nanite enabled + Shape Preservation Voxelize") + else: + _log(" Nanite enabled" if enabled else " Nanite disabled") return True @@ -343,17 +580,53 @@ def _same_asset(a, b) -> bool: return a is not None and b is not None and a.get_path_name() == b.get_path_name() -def _master_preset(data: dict, entry: dict = None) -> dict: +def _entry_name_blob(entry: dict) -> str: + values = [] + for key in ( + "name", + "slot_name", + "material_slot_name", + "imported_slot_name", + "imported_material_slot_name", + "original_material_name", + ): + value = str(entry.get(key) or "").strip() + if value: + values.append(value) + return " ".join(values).casefold() + + +def _tree_part_key(entry: dict): + blob = _entry_name_blob(entry) + if any(token in blob for token in ("leaf", "leaves", "foliage")): + return "leaf" + if any(token in blob for token in ("branch", "twig")): + return "branch" + if any(token in blob for token in ("bark", "trunk", "stump")): + return "bark" + return None + + +def _is_tree_asset_path(mesh_path: str) -> bool: + normalized = str(mesh_path or "").replace("\\", "/").casefold() + return "/tree/" in normalized or "/trees/" in normalized + + +def _master_preset(data: dict, entry: dict = None, mesh_path: str = "") -> dict: entry = entry or {} - key = ( - entry.get("material_master") - or entry.get("master_material") - or entry.get("master_preset") - or data.get("material_master") - or data.get("master_material") - or data.get("master_preset") - or DEFAULT_MASTER_PRESET - ) + tree_part = _tree_part_key(entry) + if tree_part or _is_tree_asset_path(mesh_path): + key = "tree" + else: + key = ( + entry.get("material_master") + or entry.get("master_material") + or entry.get("master_preset") + or data.get("material_master") + or data.get("master_material") + or data.get("master_preset") + or DEFAULT_MASTER_PRESET + ) key = str(key or DEFAULT_MASTER_PRESET).strip().lower() preset = MASTER_PRESETS.get(key) if preset is None: @@ -362,6 +635,8 @@ def _master_preset(data: dict, entry: dict = None) -> dict: preset = MASTER_PRESETS[key] result = dict(preset) result["key"] = key + if tree_part: + result["tree_part"] = tree_part return result @@ -443,6 +718,13 @@ def _entry_copy_source_material_path(entry: dict): return None +def _entry_create_if_missing(entry: dict, preset: dict) -> bool: + value = entry.get("create_if_missing", preset.get("create_if_missing", True)) + if isinstance(value, str): + return value.strip().casefold() not in {"0", "false", "no", "off"} + return bool(value) + + def _derive_number_suffix_copy_source(target_path: str): if not target_path or "/" not in target_path: return None @@ -461,6 +743,7 @@ def _load_or_copy_target_material( target_path: str, copy_from_path: str = None, master_mat=None, + create_if_missing: bool = True, ): if unreal.EditorAssetLibrary.does_asset_exist(target_path): return unreal.load_asset(target_path), target_path, False, "existing" @@ -469,6 +752,9 @@ def _load_or_copy_target_material( copy_from_path = _derive_number_suffix_copy_source(target_path) if not copy_from_path: + if not create_if_missing: + _warn(f" target material missing; creation disabled: {target_path}") + return None, target_path, False, "missing" if master_mat is None: _warn(f" target material missing and no master: {target_path}") return None, target_path, False, "missing" @@ -518,6 +804,25 @@ def _load_or_copy_target_material( return copied, target_path, True, "copy" +def _supported_mesh_classes(): + classes = [] + for class_name in ("StaticMesh", "SkeletalMesh"): + mesh_class = getattr(unreal, class_name, None) + if mesh_class is not None: + classes.append(mesh_class) + return tuple(classes) + + +def _mesh_material_entries(mesh): + for property_name in ("static_materials", "materials"): + try: + entries = mesh.get_editor_property(property_name) + except Exception: + continue + return property_name, entries + return None, [] + + def _static_material_name_values(static_material): names = [] for prop in ("material_slot_name", "imported_material_slot_name"): @@ -555,19 +860,32 @@ def _entry_slot_match_names(entry: dict, mat_name: str): return names +def _entry_slot_display_name(entry: dict, mat_name: str): + for value in ( + entry.get("slot_name"), + entry.get("material_slot_name"), + entry.get("imported_material_slot_name"), + mat_name, + ): + value = str(value or "").strip() + if value: + return value + return mat_name + + def _slot_index_for_entry(mesh, entry: dict, mat_name: str): candidates = _entry_slot_match_names(entry, mat_name) if not candidates: return None candidate_set = set(candidates) candidate_folded = {name.casefold() for name in candidates} - static_materials = mesh.get_editor_property("static_materials") - for i, static_material in enumerate(static_materials): - slot_names = _static_material_name_values(static_material) + _property_name, material_entries = _mesh_material_entries(mesh) + for i, material_entry in enumerate(material_entries): + slot_names = _static_material_name_values(material_entry) if any(name in candidate_set for name in slot_names): return i - for i, static_material in enumerate(static_materials): - slot_names = _static_material_name_values(static_material) + for i, material_entry in enumerate(material_entries): + slot_names = _static_material_name_values(material_entry) if any(str(name).casefold() in candidate_folded for name in slot_names): return i return None @@ -580,20 +898,271 @@ def _slot_index_for_material_name(mesh, mat_name: str): return _slot_index_for_entry(mesh, {"name": mat_name}, mat_name) -def _assign_slot(mesh, slot_index: int, material) -> bool: - """메쉬 슬롯에 머티리얼 할당. FBX import 결과 슬롯 수가 JSON 과 달라도 안전하게 가드.""" - static_materials = mesh.get_editor_property("static_materials") - if slot_index < 0 or slot_index >= len(static_materials): - _warn(f" 슬롯 인덱스 {slot_index} 가 메쉬 슬롯 수({len(static_materials)})를 벗어남 — 할당 skip") +def _rename_material_slot(mesh, slot_index: int, slot_name: str) -> bool: + if not slot_name: + return False + property_name, material_entries = _mesh_material_entries(mesh) + if not property_name or slot_index < 0 or slot_index >= len(material_entries): + return False + + material_entry = material_entries[slot_index] + changed = False + for prop in ("material_slot_name", "imported_material_slot_name"): + try: + current = str(material_entry.get_editor_property(prop)) + except Exception: + continue + if current == slot_name: + continue + try: + material_entry.set_editor_property(prop, slot_name) + except Exception as exc: + _warn(f" slot name update failed: {prop} -> {slot_name} ({exc})") + continue + changed = True + + if not changed: + return False + try: + mesh.set_editor_property(property_name, material_entries) + except Exception as exc: + _warn(f" material slot array update failed: {property_name} ({exc})") + return False + return True + + +def _is_skeletal_mesh(mesh) -> bool: + skeletal_mesh_class = getattr(unreal, "SkeletalMesh", None) + return skeletal_mesh_class is not None and isinstance(mesh, skeletal_mesh_class) + + +def _mesh_relative_disk_folder_candidates(mesh_path: str): + if not mesh_path or not mesh_path.startswith(GAME_MESHES_PREFIX): + return [] + rel = mesh_path[len(GAME_MESHES_PREFIX):] + rel_folder = rel.rsplit("/", 1)[0] if "/" in rel else "" + candidates = [] + for root in JSON_SEARCH_ROOTS: + if root and os.path.isdir(root): + candidates.append(os.path.join(root, rel_folder.replace("/", os.sep))) + return candidates + + +def _dynamic_wind_json_from_data(data: dict): + data = data or {} + for key in ("dynamic_wind_json", "dynamic_wind_json_path", "wind_json", "wind_json_path"): + value = str(data.get(key) or "").strip() + if value: + return value + wind = data.get("wind") + if isinstance(wind, dict): + for key in ("json", "json_path", "path"): + value = str(wind.get(key) or "").strip() + if value: + return value + return None + + +def _dynamic_wind_candidate_dirs(mesh_path: str, json_path: str = None): + dirs = [] + if json_path: + current = os.path.dirname(os.path.abspath(json_path)) + for _ in range(6): + if not current or current in dirs: + break + dirs.extend([ + current, + os.path.join(current, "JSON"), + os.path.join(current, "fbx", "JSON"), + ]) + parent = os.path.dirname(current) + if parent == current: + break + current = parent + for folder in _mesh_relative_disk_folder_candidates(mesh_path): + dirs.extend([ + folder, + os.path.join(folder, "JSON"), + os.path.join(folder, "fbx"), + os.path.join(folder, "fbx", "JSON"), + ]) + + seen = set() + result = [] + for folder in dirs: + normalized = os.path.normcase(os.path.abspath(folder)) + if normalized in seen: + continue + seen.add(normalized) + result.append(folder) + return result + + +def _find_dynamic_wind_json(data: dict, mesh_path: str, mesh_name: str, json_path: str = None): + explicit = _dynamic_wind_json_from_data(data) + dirs = _dynamic_wind_candidate_dirs(mesh_path, json_path) + if explicit: + explicit = os.path.expandvars(os.path.expanduser(explicit)) + if os.path.isabs(explicit) and os.path.isfile(explicit): + return explicit + for folder in dirs: + candidate = os.path.join(folder, explicit) + if os.path.isfile(candidate): + return candidate + + filename = f"{mesh_name}{DYNAMIC_WIND_JSON_SUFFIX}" + for folder in dirs: + candidate = os.path.join(folder, filename) + if os.path.isfile(candidate): + return candidate + return None + + +def _import_dynamic_wind_if_available(mesh, mesh_path: str, mesh_name: str, data: dict, json_path: str = None) -> bool: + if not _is_skeletal_mesh(mesh) or not _is_tree_asset_path(mesh_path): + return False + helper = getattr(unreal, "CodexDynamicWindImportLibrary", None) + if not helper or not hasattr(helper, "import_dynamic_wind_json_to_skeletal_mesh"): + _warn(" DynamicWind import helper missing; tree wind data skipped") + return False + wind_json = _find_dynamic_wind_json(data, mesh_path, mesh_name, json_path) + if not wind_json: + _warn(f" DynamicWind JSON not found for tree skeletal mesh: {mesh_name}") + return False + try: + result = helper.import_dynamic_wind_json_to_skeletal_mesh(mesh, wind_json) + except Exception as exc: + _warn(f" DynamicWind import failed: {wind_json} ({exc})") return False - # 이미 같은 머티리얼이 할당돼 있으면 변경 없음 → 메쉬 재저장을 피한다. - current = static_materials[slot_index].get_editor_property("material_interface") - if current is not None and material is not None and current.get_path_name() == material.get_path_name(): + _log(f" DynamicWind <- {wind_json} ({result})") + return True + + +def _new_skeletal_material_entry(slot_name: str, material, old_entry=None): + entry = unreal.SkeletalMaterial() + entry.set_editor_property("material_interface", material) + entry.set_editor_property("material_slot_name", slot_name) + if old_entry is not None: + for prop in ("uv_channel_data", "overlay_material_interface"): + try: + entry.set_editor_property(prop, old_entry.get_editor_property(prop)) + except Exception: + pass + return entry + + +def _assign_skeletal_slot(mesh, slot_index: int, material, slot_name: str = None) -> bool: + property_name, material_entries = _mesh_material_entries(mesh) + if property_name != "materials" or slot_index < 0 or slot_index >= len(material_entries): return False - mesh.set_material(slot_index, material) + + slot_name = str(slot_name or "").strip() + if not slot_name: + slot_name = str(material_entries[slot_index].get_editor_property("material_slot_name")) + + current_material = material_entries[slot_index].get_editor_property("material_interface") + current_slot = str(material_entries[slot_index].get_editor_property("material_slot_name")) + if ( + current_slot == slot_name + and current_material is not None + and material is not None + and current_material.get_path_name() == material.get_path_name() + ): + return False + + new_entries = list(material_entries) + new_entries[slot_index] = _new_skeletal_material_entry( + slot_name, + material, + material_entries[slot_index], + ) + mesh.set_editor_property("materials", new_entries) return True +def _normalize_skeletal_material_slots(mesh, assignments: dict) -> bool: + if not _is_skeletal_mesh(mesh) or not assignments: + return False + + property_name, material_entries = _mesh_material_entries(mesh) + if property_name != "materials": + return False + + ordered = [ + (slot_index, slot_name, material) + for slot_index, (slot_name, material) in sorted(assignments.items()) + ] + + unchanged = len(material_entries) == len(ordered) + if unchanged: + for new_index, (_old_index, slot_name, material) in enumerate(ordered): + current_entry = material_entries[new_index] + try: + current_slot = str(current_entry.get_editor_property("material_slot_name")) + current_material = current_entry.get_editor_property("material_interface") + except Exception: + unchanged = False + break + if current_slot != slot_name: + unchanged = False + break + if bool(current_material) != bool(material): + unchanged = False + break + if ( + current_material is not None + and material is not None + and current_material.get_path_name() != material.get_path_name() + ): + unchanged = False + break + if unchanged: + return False + + new_entries = [] + for old_index, slot_name, material in ordered: + old_entry = material_entries[old_index] if 0 <= old_index < len(material_entries) else None + new_entries.append(_new_skeletal_material_entry(slot_name, material, old_entry)) + + mesh.set_editor_property("materials", new_entries) + return True + + +def _set_material_interface(mesh, slot_index: int, material) -> bool: + property_name, material_entries = _mesh_material_entries(mesh) + if not property_name or slot_index < 0 or slot_index >= len(material_entries): + return False + + current = material_entries[slot_index].get_editor_property("material_interface") + if ( + current is not None + and material is not None + and current.get_path_name() == material.get_path_name() + ): + return False + + if hasattr(mesh, "set_material"): + mesh.set_material(slot_index, material) + return True + + material_entries[slot_index].set_editor_property("material_interface", material) + mesh.set_editor_property(property_name, material_entries) + return True + + +def _assign_slot(mesh, slot_index: int, material, slot_name: str = None) -> bool: + """메쉬 슬롯에 머티리얼 할당. FBX import 결과 슬롯 수가 JSON 과 달라도 안전하게 가드.""" + _property_name, material_entries = _mesh_material_entries(mesh) + if _is_skeletal_mesh(mesh): + return _assign_skeletal_slot(mesh, slot_index, material, slot_name) + if slot_index < 0 or slot_index >= len(material_entries): + _warn(f" 슬롯 인덱스 {slot_index} 가 메쉬 슬롯 수({len(material_entries)})를 벗어남 — 할당 skip") + return False + material_changed = _set_material_interface(mesh, slot_index, material) + slot_renamed = _rename_material_slot(mesh, slot_index, slot_name) + return material_changed or slot_renamed + + def _surface_layer_param(param: str) -> str: return LAYER_PARAM_BY_LEGACY_PARAM.get(str(param or ""), str(param or "")) @@ -654,7 +1223,12 @@ def _entry_layers(entry: dict): return [{"name": "Base", "index": 0, "textures": textures}] if textures else [] -def _import_layer_textures(layers, tex_cache: dict, force_reimport: bool = False): +def _import_layer_textures( + layers, + tex_cache: dict, + force_reimport: bool = False, + virtual_texture_streaming=None, +): layer_maps = [] for layer in layers: param_tex_map = {} @@ -666,6 +1240,7 @@ def _import_layer_textures(layers, tex_cache: dict, force_reimport: bool = False param, tex_cache, force_reimport=force_reimport, + virtual_texture_streaming=virtual_texture_streaming, ) if tex_path and param in KNOWN_PARAMS: param_tex_map[param] = tex_path @@ -786,9 +1361,274 @@ def _assign_flat_textures(mi, layer_maps, param_map: dict, label: str) -> bool: return changed -def _assign_master_textures(mi, layer_maps, assignment: str) -> bool: +def _texture_parameter_name(parameter_value): + try: + info = parameter_value.get_editor_property("parameter_info") + return str(info.get_editor_property("name")) + except Exception: + try: + return str(parameter_value.get_editor_property("parameter_name")) + except Exception: + return "" + + +def _prune_texture_parameter_overrides(mi, keep_names: set) -> bool: + try: + values = list(mi.get_editor_property("texture_parameter_values")) + except Exception: + return False + kept = [ + value + for value in values + if _texture_parameter_name(value) in keep_names + ] + if len(kept) == len(values): + return False + mi.set_editor_property("texture_parameter_values", kept) + try: + unreal.MaterialEditingLibrary.update_material_instance(mi) + except Exception: + pass + _log( + " stale texture overrides pruned: " + + ", ".join( + _texture_parameter_name(value) + for value in values + if _texture_parameter_name(value) not in keep_names + ) + ) + return True + + +def _layer_instance_base_name(mat_base: str, preset: dict, entry: dict) -> str: + material_layer = entry.get("material_layer") if isinstance(entry.get("material_layer"), dict) else {} + for value in ( + material_layer.get("instance_name"), + entry.get("material_layer_instance_name"), + entry.get("layer_instance_name"), + ): + value = str(value or "").strip() + if value: + return value[4:] if value.startswith("MYI_") else value + + base = str(mat_base or "").strip() + for prefix in preset.get("layer_instance_strip_prefixes", []): + prefix = str(prefix or "") + if prefix and base.startswith(prefix): + base = base[len(prefix):] + break + return base + + +def _layer_instance_path(mat_base: str, preset: dict, entry: dict): + material_layer = entry.get("material_layer") if isinstance(entry.get("material_layer"), dict) else {} + for key in ("instance_path", "path"): + value = str(material_layer.get(key) or "").strip() + if value: + return value.split(".")[0] + for key in ( + "material_layer_instance_path", + "layer_instance_path", + "target_layer_instance_path", + ): + value = str(entry.get(key) or "").strip() + if value: + return value.split(".")[0] + + folder = str(entry.get("layer_instance_folder") or preset.get("layer_instance_folder") or "").rstrip("/") + if not folder: + return None + base = _layer_instance_base_name(mat_base, preset, entry) + if not base: + return None + return f"{folder}/MYI_{base}" + + +def _layer_parent_path(preset: dict, entry: dict): + material_layer = entry.get("material_layer") if isinstance(entry.get("material_layer"), dict) else {} + for key in ("parent", "parent_layer"): + value = str(material_layer.get(key) or "").strip() + if value: + return value.split(".")[0] + for key in ("material_layer_parent", "layer_parent", "parent_layer"): + value = str(entry.get(key) or "").strip() + if value: + return value.split(".")[0] + tree_part = str(preset.get("tree_part") or "").casefold() + layer_parents = preset.get("layer_parents_by_name") + if tree_part and isinstance(layer_parents, dict): + value = str(layer_parents.get(tree_part) or "").strip() + if value: + return value.split(".")[0] + value = str(preset.get("layer_parent") or "").strip() + return value.split(".")[0] if value else None + + +def _as_float(value): + try: + return float(value) + except Exception: + return None + + +def _linear_color(value): + if isinstance(value, dict): + channels = [_as_float(value.get(key)) for key in ("r", "g", "b", "a")] + if channels[3] is None: + channels[3] = 1.0 + elif isinstance(value, (list, tuple)): + channels = [_as_float(item) for item in value[:4]] + if len(channels) == 3: + channels.append(1.0) + else: + return None + if len(channels) != 4 or any(channel is None for channel in channels): + return None + return unreal.LinearColor(*channels) + + +def _parameter_source_dicts(entry: dict): + entry = entry or {} + material_layer = entry.get("material_layer") if isinstance(entry.get("material_layer"), dict) else {} + for source in (material_layer, entry): + yield source + wind = source.get("wind") + if isinstance(wind, dict): + yield wind + + +def _layer_scalar_params(entry: dict) -> dict: + params = {} + for source in _parameter_source_dicts(entry): + for key in ("scalar_parameters", "scalars", "scalar_params", "wind_scalars"): + values = source.get(key) + if isinstance(values, dict): + for name, value in values.items(): + parsed = _as_float(value) + if parsed is not None: + params[str(name)] = parsed + for name, value in source.items(): + if "wind" not in str(name).casefold(): + continue + parsed = _as_float(value) + if parsed is not None: + params[str(name)] = parsed + return params + + +def _layer_vector_params(entry: dict) -> dict: + params = {} + for source in _parameter_source_dicts(entry): + for key in ("vector_parameters", "vectors", "vector_params", "wind_vectors"): + values = source.get(key) + if isinstance(values, dict): + for name, value in values.items(): + parsed = _linear_color(value) + if parsed is not None: + params[str(name)] = parsed + for name, value in source.items(): + if "wind" not in str(name).casefold(): + continue + parsed = _linear_color(value) + if parsed is not None: + params[str(name)] = parsed + return params + + +def _layer_texture_remap(preset: dict, entry: dict) -> dict: + material_layer = entry.get("material_layer") if isinstance(entry.get("material_layer"), dict) else {} + mapping = ( + material_layer.get("texture_remap") + or entry.get("layer_texture_remap") + or entry.get("material_layer_texture_remap") + ) + if not isinstance(mapping, dict): + mapping = preset.get("layer_texture_remap", {}) + return {str(key): str(value) for key, value in dict(mapping).items() if key and value} + + +def _call_create_or_update_layer_instance(helper, parent_layer, layer_path, texture_params, scalar_params=None, vector_params=None): + result = helper.create_or_update_material_layer_instance( + parent_layer, + layer_path, + texture_params, + scalar_params or {}, + vector_params or {}, + False, + True, + ) + if isinstance(result, tuple): + ok = bool(result[0]) if result else False + errors = result[2] if len(result) > 2 else [] + return ok, errors + return bool(result), [] + + +def _assign_material_layer_instance(mi, mat_base: str, layer_maps, preset: dict, entry: dict) -> bool: + helper = getattr(unreal, "CodexMaterialToolsLibrary", None) + if not helper or not hasattr(helper, "create_or_update_material_layer_instance"): + _warn(" CodexMaterialTools layer instance helper missing; MYI assignment skipped") + return False + if not hasattr(helper, "set_material_instance_background_layer"): + _warn(" CodexMaterialTools background layer helper missing; MYI assignment skipped") + return False + + parent_layer = _layer_parent_path(preset, entry) + layer_path = _layer_instance_path(mat_base, preset, entry) + if not parent_layer or not layer_path: + _warn(" material layer instance path is incomplete; MYI assignment skipped") + return False + + remap = _layer_texture_remap(preset, entry) + texture_params = {} + for layer_param, tex_path in _first_layer_textures(layer_maps).items(): + target_param = remap.get(layer_param) + if target_param and tex_path: + texture_params[target_param] = tex_path + scalar_params = _layer_scalar_params(entry) + vector_params = _layer_vector_params(entry) + if scalar_params or vector_params: + _log( + " material layer parameters: " + f"{len(scalar_params)} scalar(s), {len(vector_params)} vector(s)" + ) + + ok, errors = _call_create_or_update_layer_instance( + helper, + parent_layer, + layer_path, + texture_params, + scalar_params, + vector_params, + ) + if not ok: + _warn(f" MYI create/update failed: {layer_path}") + for error in errors or []: + _warn(f" {error}") + return False + + layer_asset = unreal.load_asset(layer_path) + if layer_asset is None: + _warn(f" MYI load failed after create/update: {layer_path}") + return False + + changed = bool(helper.set_material_instance_background_layer(mi, layer_asset)) + changed = _prune_texture_parameter_overrides(mi, set()) or changed + _log(f" background MYI <- {layer_path}") + return changed + + +def _assign_master_textures(mi, layer_maps, assignment: str, preset: dict = None, entry: dict = None, mat_base: str = "") -> bool: + preset = preset or {} + entry = entry or {} + if assignment == "none": + return False if assignment == "layer": return _assign_surface_layer_textures(mi, layer_maps) + if assignment == "material_layer_instance": + return _assign_material_layer_instance(mi, mat_base, layer_maps, preset, entry) + if assignment == "asset_surface_flat": + return _assign_flat_textures(mi, layer_maps, ASSET_SURFACE_PARAM_BY_LAYER_PARAM, "asset_surface") if assignment == "coat_flat": return _assign_flat_textures(mi, layer_maps, COAT_PARAM_BY_LAYER_PARAM, "coat") return _assign_flat_textures(mi, layer_maps, FLAT_PARAM_BY_LAYER_PARAM, "prop") @@ -802,6 +1642,108 @@ def _material_instance_base_name(mat_name: str) -> str: return mat_name +def _hair_target_material_name(mat_name: str, entry: dict) -> str: + for key in ("material_instance_name", "target_material_name", "unreal_material_name"): + value = str(entry.get(key) or "").strip() + if value: + return value + return f"MI_{_material_instance_base_name(str(mat_name or ''))}" + + +def _hair_target_material_path(mat_name: str, entry: dict, preset: dict): + target_path = _entry_target_material_path(entry) + if target_path: + return target_path + folder = str(preset.get("mi_folder") or "").rstrip("/") + target_name = _hair_target_material_name(mat_name, entry) + if not folder or not target_name: + return None + return f"{folder}/{target_name}" + + +def _asset_path_excluded(path: str, fragments) -> bool: + normalized = str(path or "").replace("\\", "/").casefold() + return any(str(fragment or "").casefold() in normalized for fragment in fragments or []) + + +def _asset_data_class_name(asset_data): + for property_name in ("asset_class_path", "asset_class"): + try: + value = getattr(asset_data, property_name) + except Exception: + continue + if value: + text = str(value) + return text.rsplit("/", 1)[-1].rsplit(".", 1)[-1].strip("'\"") + return "" + + +def _asset_paths_named(asset_names, excluded_fragments=None, allowed_classes=None): + wanted = {str(name or "").casefold() for name in asset_names if str(name or "").strip()} + if not wanted: + return [] + excluded_fragments = excluded_fragments or [] + allowed_classes = {str(name).casefold() for name in allowed_classes or []} + asset_registry = unreal.AssetRegistryHelpers.get_asset_registry() + results = [] + try: + assets = asset_registry.get_assets_by_path("/Game", recursive=True) + except TypeError: + assets = asset_registry.get_assets_by_path("/Game", True) + for asset_data in assets: + name = str(asset_data.asset_name) + if name.casefold() not in wanted: + continue + if allowed_classes and _asset_data_class_name(asset_data).casefold() not in allowed_classes: + continue + path = str(asset_data.package_name) + if _asset_path_excluded(path, excluded_fragments): + continue + results.append(path) + return sorted(results, key=lambda path: ("/Materials/" not in path, path.casefold())) + + +def _hair_source_material_paths(mat_name: str, entry: dict, preset: dict): + base_name = _material_instance_base_name(str(mat_name or "")) + source_names = [ + base_name, + f"{base_name}_Inst", + f"{base_name}_LWHQ_Inst", + ] + return _asset_paths_named( + source_names, + preset.get("exclude_path_fragments"), + allowed_classes={"MaterialInstanceConstant"}, + ) + + +def _load_or_migrate_hair_material(asset_tools, mat_name: str, entry: dict, preset: dict): + target_path = _hair_target_material_path(mat_name, entry, preset) + if not target_path: + _warn(f" hair material target path incomplete for '{mat_name}'") + return None, None + + source_paths = [path for path in _hair_source_material_paths(mat_name, entry, preset) if path != target_path] + if source_paths: + source_path = source_paths[0] + if unreal.EditorAssetLibrary.does_asset_exist(target_path): + if not unreal.EditorAssetLibrary.delete_asset(target_path): + _warn(f" existing wrong hair MI delete failed: {target_path}") + return None, None + _log(f" deleted wrong generated hair MI: {target_path}") + if not unreal.EditorAssetLibrary.rename_asset(source_path, target_path): + _warn(f" hair MI move/rename failed: {source_path} -> {target_path}") + return None, None + _log(f" hair MI moved: {source_path} -> {target_path}") + return unreal.load_asset(target_path), target_path + + if unreal.EditorAssetLibrary.does_asset_exist(target_path): + return unreal.load_asset(target_path), target_path + + _warn(f" hair material instance source missing for '{mat_name}' -> target {target_path}") + return None, None + + def _source_asset_names(data: dict): cleanup = data.get("cleanup") if isinstance(cleanup, dict): @@ -948,13 +1890,13 @@ def _cleanup_imported_source_assets(mesh_path: str, data: dict): def process_mesh(mesh_path: str, master_mat=None, json_path: str = None) -> bool: - """단일 StaticMesh 를 JSON 기반으로 처리. 변경이 있었으면 True. + """단일 StaticMesh/SkeletalMesh 를 JSON 기반으로 처리. 변경이 있었으면 True. json_path: send2ue extension 이 넘겨주는 JSON 절대경로(있으면 OneDrive walk 생략). """ mesh_path = mesh_path.split(".")[0] mesh = unreal.load_asset(mesh_path) - if not isinstance(mesh, unreal.StaticMesh): + if not isinstance(mesh, _supported_mesh_classes()): return False mesh_name = mesh_path.rsplit("/", 1)[-1] @@ -962,8 +1904,14 @@ def process_mesh(mesh_path: str, master_mat=None, json_path: str = None) -> bool # Nanite: import 되는 StaticMesh 에 켜되, 반투명 머티리얼 메쉬는 끈다. # (JSON 이 없으면 불투명으로 가정 → 켬. 반투명으로 판정되면 이미 켜져 있어도 끈다.) - if ENABLE_NANITE and _set_nanite(mesh, not _is_translucent(data)): - unreal.EditorAssetLibrary.save_asset(mesh_path) + if ENABLE_NANITE: + nanite_enabled = not _is_translucent(data) + if isinstance(mesh, unreal.StaticMesh) and _set_nanite(mesh, nanite_enabled): + unreal.EditorAssetLibrary.save_asset(mesh_path) + elif _is_skeletal_mesh(mesh): + voxelize = _nanite_shape_preservation_voxelize() if ENABLE_SKELETAL_NANITE_VOXELIZE else None + if _set_nanite(mesh, nanite_enabled, voxelize): + unreal.EditorAssetLibrary.save_asset(mesh_path) if data is None: _warn(f"JSON 사이드카 없음: {mesh_name}.json — skip (블렌더에서 Rename 버튼을 눌렀나요?)") @@ -974,13 +1922,18 @@ def process_mesh(mesh_path: str, master_mat=None, json_path: str = None) -> bool json_mesh_name = str(data.get("mesh_name", "")) if json_mesh_name and json_mesh_name != mesh_name: _warn(f"JSON mesh_name mismatch: asset={mesh_name}, json={json_mesh_name}; using JSON data") + if _import_dynamic_wind_if_available(mesh, mesh_path, mesh_name, data, json_path): + unreal.EditorAssetLibrary.save_asset(mesh_path) + changed = True # 텍스처 재import 회피 캐시(메쉬마다 reload 되므로 디스크에서 읽고, 바뀌면 끝에 저장). tex_cache = _load_texture_cache() tex_cache_before = dict(tex_cache) + skeletal_slot_assignments = {} for entry in data.get("materials", []): mat_name = str(entry.get("name", "")) + slot_name = _entry_slot_display_name(entry, mat_name) target_material_path = _entry_target_material_path(entry) legacy_generated_mi_flow = mat_name.startswith("M_") if not target_material_path and not legacy_generated_mi_flow: @@ -994,18 +1947,32 @@ def process_mesh(mesh_path: str, master_mat=None, json_path: str = None) -> bool continue slot_index = int(entry.get("slot_index", 0)) + preset = _master_preset(data, entry, mesh_path) + if preset.get("key") == "hair": + mi, mi_path = _load_or_migrate_hair_material(asset_tools, mat_name, entry, preset) + if mi is None: + continue + _log(f" hair slot[{slot_index}] '{mat_name}' -> {mi_path}") + if _is_skeletal_mesh(mesh): + skeletal_slot_assignments[slot_index] = (slot_name, mi) + if _assign_slot(mesh, slot_index, mi, slot_name): + changed = True + continue + # 반투명(유리): 전용 MI/텍스처 없이 공유 글래스 MI 를 슬롯에 바로 할당. if entry.get("translucent"): glass_mi = unreal.load_asset(GLASS_MI_PATH) if glass_mi is None: _warn(f" 글래스 MI 없음: {GLASS_MI_PATH} — 슬롯[{slot_index}] skip") continue - if _assign_slot(mesh, slot_index, glass_mi): + if _is_skeletal_mesh(mesh): + skeletal_slot_assignments[slot_index] = (slot_name, glass_mi) + if _assign_slot(mesh, slot_index, glass_mi, slot_name): _log(f" 슬롯[{slot_index}] '{mat_name}' → 글래스 MI 할당(공유)") changed = True continue - preset = _master_preset(data, entry) + preset = _master_preset(data, entry, mesh_path) if target_material_path: copy_from_path = _entry_copy_source_material_path(entry) selected_master = master_mat or _load_master_material(preset) @@ -1016,22 +1983,45 @@ def process_mesh(mesh_path: str, master_mat=None, json_path: str = None) -> bool target_material_path, copy_from_path, selected_master, + create_if_missing=_entry_create_if_missing(entry, preset), ) if mi is None: continue + parent_changed = False + if selected_master is not None: + try: + current_parent = mi.get_editor_property("parent") + except Exception: + current_parent = None + if not _same_asset(current_parent, selected_master): + unreal.MaterialEditingLibrary.set_material_instance_parent(mi, selected_master) + parent_changed = True + _log(f" MI parent update: {mi_path} -> {selected_master.get_path_name()}") _log( f" slot[{slot_index}] '{mat_name}' -> {mi_path} " f"(master: {preset['key']})" ) - params_changed = False - if mi_source == "new": - layers = _entry_layers(entry) - layer_maps = _import_layer_textures(layers, tex_cache) - params_changed = _assign_master_textures(mi, layer_maps, preset["assignment"]) - if mi_created or params_changed: + layers = _entry_layers(entry) + layer_maps = _import_layer_textures( + layers, + tex_cache, + virtual_texture_streaming=preset.get("virtual_textures"), + ) + mat_base = _material_instance_base_name(mat_name) + params_changed = _assign_master_textures( + mi, + layer_maps, + preset["assignment"], + preset=preset, + entry=entry, + mat_base=mat_base, + ) + if mi_created or parent_changed or params_changed: unreal.EditorAssetLibrary.save_asset(mi_path) changed = True - if _assign_slot(mesh, slot_index, mi): + if _is_skeletal_mesh(mesh): + skeletal_slot_assignments[slot_index] = (slot_name, mi) + if _assign_slot(mesh, slot_index, mi, slot_name): changed = True continue @@ -1060,20 +2050,35 @@ def process_mesh(mesh_path: str, master_mat=None, json_path: str = None) -> bool continue # 3. Assign textures using the selected master material contract. - params_changed = False - if mi_source == "new": - layers = _entry_layers(entry) - layer_maps = _import_layer_textures(layers, tex_cache) - params_changed = _assign_master_textures(mi, layer_maps, preset["assignment"]) + layers = _entry_layers(entry) + layer_maps = _import_layer_textures( + layers, + tex_cache, + virtual_texture_streaming=preset.get("virtual_textures"), + ) + params_changed = _assign_master_textures( + mi, + layer_maps, + preset["assignment"], + preset=preset, + entry=entry, + mat_base=mat_base, + ) if mi_created or parent_changed or params_changed: unreal.EditorAssetLibrary.save_asset(mi_path) changed = True # 4. 슬롯에 MI 할당 - if _assign_slot(mesh, slot_index, mi): + if _is_skeletal_mesh(mesh): + skeletal_slot_assignments[slot_index] = (slot_name, mi) + if _assign_slot(mesh, slot_index, mi, slot_name): changed = True # 이번 처리에서 새로 import 된 텍스처가 있으면 캐시 갱신 + if _normalize_skeletal_material_slots(mesh, skeletal_slot_assignments): + _log(f" skeletal material slots normalized to JSON: {len(skeletal_slot_assignments)} slot(s)") + changed = True + if tex_cache != tex_cache_before: _save_texture_cache(tex_cache) @@ -1096,16 +2101,17 @@ def process_meshes(mesh_paths): _log(f"일괄 처리 완료: {count} 메쉬 변경") -def run(import_folder: str = "/Game/Meshes/00_common"): - """수동 폴백: 폴더(재귀) 안 모든 StaticMesh 를 처리.""" +def run(import_folder: str = None): + """수동 폴백: 폴더(재귀) 안 모든 StaticMesh/SkeletalMesh 를 처리.""" + import_folder = import_folder or f"{GAME_MESHES_PREFIX.rstrip('/')}/00_common" asset_registry = unreal.AssetRegistryHelpers.get_asset_registry() mesh_filter = unreal.ARFilter( - class_names=["StaticMesh"], + class_names=["StaticMesh", "SkeletalMesh"], package_paths=[import_folder], recursive_paths=True, ) meshes = asset_registry.get_assets(mesh_filter) if not meshes: - _warn(f"'{import_folder}' 에서 StaticMesh 없음") + _warn(f"'{import_folder}' 에서 StaticMesh/SkeletalMesh 없음") return process_meshes([str(m.package_name) for m in meshes]) From 7c0a17323099d799aaaf70b05f9fbe22a60669ec Mon Sep 17 00:00:00 2001 From: 424 <2421412@naver.com> Date: Mon, 13 Jul 2026 12:36:18 +0900 Subject: [PATCH 19/25] Update Send to Unreal material pipeline --- src/addons/send2ue/constants.py | 5 +- src/addons/send2ue/core/hair_tool_export.py | 63 +++- src/addons/send2ue/core/utilities.py | 8 +- src/addons/send2ue/dependencies/unreal.py | 11 +- src/addons/send2ue/pipeline_contract.py | 38 ++ .../resources/extensions/combine_assets.py | 4 +- .../extensions/send2ue_material_pipeline.py | 64 +++- .../resources/pipeline/ue_material_setup.py | 342 ++++++++++++++++-- .../resources/setting_templates/default.json | 1 + src/addons/send2ue/resources/settings.json | 6 + 10 files changed, 492 insertions(+), 50 deletions(-) create mode 100644 src/addons/send2ue/pipeline_contract.py diff --git a/src/addons/send2ue/constants.py b/src/addons/send2ue/constants.py index e64faab8..355a01e3 100644 --- a/src/addons/send2ue/constants.py +++ b/src/addons/send2ue/constants.py @@ -2,6 +2,8 @@ import os from enum import Enum +from .pipeline_contract import collection_name + class PreFixToken(Enum): SOCKET = 'SOCKET' @@ -35,7 +37,7 @@ class ToolInfo(Enum): NAME = 'send2ue' APP = 'blender' LABEL = 'Send to Unreal' - EXPORT_COLLECTION = 'Export' + EXPORT_COLLECTION = collection_name('send_to_unreal_export', 'Export') COLLECTION_NAMES = [EXPORT_COLLECTION] TEMPLATE_VERSION = 1 FCURVE_FILE = '{file_path}_custom_property_fcurves.json' @@ -81,4 +83,3 @@ class PathModes(Enum): class RegexPresets: INVALID_NAME_CHARACTERS = r"[^-+\w]+" - diff --git a/src/addons/send2ue/core/hair_tool_export.py b/src/addons/send2ue/core/hair_tool_export.py index 7401bd32..2bfb6650 100644 --- a/src/addons/send2ue/core/hair_tool_export.py +++ b/src/addons/send2ue/core/hair_tool_export.py @@ -2,7 +2,7 @@ import bpy from . import armature_modifier_fix, utilities -from ..constants import BlenderTypes +from ..constants import BlenderTypes, ToolInfo STATE_KEY = 'send2ue_hair_tool_export_state' @@ -93,7 +93,15 @@ def _get_head_bone_name(armature_object): ) -def _attribute_scalar(mesh, attribute, loop_index): +def _loop_to_polygon_indices(mesh): + loop_to_polygon = [0] * len(mesh.loops) + for polygon in mesh.polygons: + for loop_index in polygon.loop_indices: + loop_to_polygon[loop_index] = polygon.index + return loop_to_polygon + + +def _attribute_scalar(mesh, attribute, loop_index, loop_to_polygon=None): if not attribute: return 0.0 @@ -103,7 +111,7 @@ def _attribute_scalar(mesh, attribute, loop_index): elif attribute.domain == 'CORNER': data_index = loop_index elif attribute.domain == 'FACE': - data_index = loop.polygon_index + data_index = loop_to_polygon[loop_index] if loop_to_polygon else 0 else: return 0.0 @@ -121,6 +129,7 @@ def _pack_rsao(mesh): random_attribute = mesh.attributes.get('Random') system_color_attribute = mesh.attributes.get('SystemColor') ao_attribute = mesh.attributes.get('AO') + loop_to_polygon = _loop_to_polygon_indices(mesh) existing_rsao = mesh.attributes.get(RSAO_NAME) if existing_rsao: @@ -133,9 +142,9 @@ def _pack_rsao(mesh): ) for loop_index, color_item in enumerate(rsao.data): color_item.color = ( - _attribute_scalar(mesh, random_attribute, loop_index), - _attribute_scalar(mesh, system_color_attribute, loop_index), - _attribute_scalar(mesh, ao_attribute, loop_index), + _attribute_scalar(mesh, random_attribute, loop_index, loop_to_polygon), + _attribute_scalar(mesh, system_color_attribute, loop_index, loop_to_polygon), + _attribute_scalar(mesh, ao_attribute, loop_index, loop_to_polygon), 1.0, ) @@ -147,6 +156,42 @@ def _pack_rsao(mesh): mesh.color_attributes.render_color_index = mesh.color_attributes.find(RSAO_NAME) +def _remove_empty_material_slots(scene_object): + mesh = scene_object.data + materials = [slot.material for slot in scene_object.material_slots] + if not materials or all(material is not None for material in materials): + return + + valid_materials = [] + old_to_new_index = {} + fallback_index = None + for old_index, material in enumerate(materials): + if material is None: + continue + if fallback_index is None: + fallback_index = old_index + old_to_new_index[old_index] = len(valid_materials) + valid_materials.append(material) + + if fallback_index is None: + return + + fallback_new_index = old_to_new_index[fallback_index] + for old_index, material in enumerate(materials): + if material is None: + old_to_new_index[old_index] = fallback_new_index + + for polygon in mesh.polygons: + polygon.material_index = old_to_new_index.get( + polygon.material_index, + fallback_new_index, + ) + + mesh.materials.clear() + for material in valid_materials: + mesh.materials.append(material) + + def _evaluate_combined_ao(scene_object, state): """Evaluate Hair Tool AO once, after all systems for an asset are joined.""" node_group = bpy.data.node_groups.get('HT_Mesh_AO') @@ -293,7 +338,7 @@ def _link_to_export_collection(scene_object, export_collection): def _asset_group_key(scene_object): """Group a Hair Tool system by its nearest exported Empty ancestor.""" - export_collection = bpy.data.collections.get('Export') + export_collection = bpy.data.collections.get(ToolInfo.EXPORT_COLLECTION.value) exported_objects = set(export_collection.all_objects) if export_collection else set() parent = scene_object.parent @@ -309,7 +354,7 @@ def prepare(): """Create export-only mesh copies for Hair Tool systems in the Export collection.""" cleanup() - export_collection = bpy.data.collections.get('Export') + export_collection = bpy.data.collections.get(ToolInfo.EXPORT_COLLECTION.value) if not export_collection: return @@ -318,6 +363,7 @@ def prepare(): for scene_object in export_collection.all_objects if ( export_collection in scene_object.users_collection + and scene_object.visible_get() and is_hair_tool_object(scene_object) ) ] @@ -383,6 +429,7 @@ def prepare(): _evaluate_combined_ao(temporary_object, state) _write_hair_tool_uvs(temporary_object.data) _pack_rsao(temporary_object.data) + _remove_empty_material_slots(temporary_object) armatures = { armature diff --git a/src/addons/send2ue/core/utilities.py b/src/addons/send2ue/core/utilities.py index 17f288ff..28897528 100644 --- a/src/addons/send2ue/core/utilities.py +++ b/src/addons/send2ue/core/utilities.py @@ -13,6 +13,7 @@ from ..ui import header_menu from ..dependencies import unreal from ..constants import BlenderTypes, UnrealTypes, ToolInfo, PreFixToken, PathModes, RegexPresets +from ..pipeline_contract import unreal_path_mapping from mathutils import Vector, Quaternion # --------------------------------------------------------------------------- @@ -1195,7 +1196,10 @@ def sync_unreal_mesh_folder_path(*args): return # locate the anchor folder (case-insensitive) and require it to sit on a path boundary - anchor = 'Forestportfolio/' + mapping = unreal_path_mapping() + anchor_name = str(mapping.get('local_anchor') or 'Forestportfolio').strip('/\\') + unreal_anchor = str(mapping.get('unreal_anchor') or '/Game/Meshes').rstrip('/') + anchor = f'{anchor_name}/' anchor_index = file_path.lower().find(anchor.lower()) if anchor_index == -1: return @@ -1207,7 +1211,7 @@ def sync_unreal_mesh_folder_path(*args): remainder = file_path[anchor_index + len(anchor):] relative_folder = remainder.rsplit('/', 1)[0] if '/' in remainder else '' - unreal_path = '/Game/Meshes/' + unreal_path = f'{unreal_anchor}/' if relative_folder: unreal_path += f'{relative_folder}/' diff --git a/src/addons/send2ue/dependencies/unreal.py b/src/addons/send2ue/dependencies/unreal.py index b0d9bc06..3b9d9aef 100644 --- a/src/addons/send2ue/dependencies/unreal.py +++ b/src/addons/send2ue/dependencies/unreal.py @@ -357,7 +357,11 @@ def set_settings(property_group, data_object): value=data.get('value'), unreal_type=data.get('unreal_type'), ) - data_object.set_editor_property(attribute, value) + try: + data_object.set_editor_property(attribute, value) + except Exception: + if attribute != 'build_nanite': + raise return data_object @staticmethod @@ -773,6 +777,11 @@ def set_skeletal_mesh_import_options(self): self._property_data['unreal']['import_method']['fbx']['skeletal_mesh_import_data'], import_data ) + if 'build_nanite' not in self._property_data['unreal']['import_method']['fbx']['skeletal_mesh_import_data']: + try: + import_data.set_editor_property('build_nanite', True) + except Exception: + pass self._options.skeletal_mesh_import_data = import_data def set_animation_import_options(self): diff --git a/src/addons/send2ue/pipeline_contract.py b/src/addons/send2ue/pipeline_contract.py new file mode 100644 index 00000000..24ff048f --- /dev/null +++ b/src/addons/send2ue/pipeline_contract.py @@ -0,0 +1,38 @@ +import json +import os +from functools import lru_cache +from pathlib import Path + + +def _candidate_paths(): + override = os.environ.get("SUBSTANCE_TOOLS_PIPELINE_CONTRACT") + if override: + yield Path(override) + + here = Path(__file__).resolve() + for parent in here.parents: + yield parent / "pipeline_contract.json" + yield parent / "substance-tools" / "pipeline_contract.json" + yield parent / "substance_tools" / "pipeline_contract.json" + + appdata = os.environ.get("APPDATA") + if appdata: + blender_root = Path(appdata) / "Blender Foundation" / "Blender" + for path in sorted(blender_root.glob("*/scripts/addons/substance_tools/pipeline_contract.json"), reverse=True): + yield path + + +@lru_cache(maxsize=1) +def pipeline_contract(): + for path in _candidate_paths(): + if path.is_file(): + return json.loads(path.read_text(encoding="utf-8")) + return {} + + +def collection_name(key, default): + return pipeline_contract().get("blender_collections", {}).get(key, default) + + +def unreal_path_mapping(): + return pipeline_contract().get("unreal_path_mapping", {}).get("current_default", {}) diff --git a/src/addons/send2ue/resources/extensions/combine_assets.py b/src/addons/send2ue/resources/extensions/combine_assets.py index 0f5bbdb2..7ade56e3 100644 --- a/src/addons/send2ue/resources/extensions/combine_assets.py +++ b/src/addons/send2ue/resources/extensions/combine_assets.py @@ -4,7 +4,7 @@ import bpy from send2ue.core.extension import ExtensionBase from send2ue.core import utilities -from send2ue.constants import BlenderTypes, UnrealTypes +from send2ue.constants import BlenderTypes, ToolInfo, UnrealTypes class Options: @@ -146,7 +146,7 @@ def pre_mesh_export(self, asset_data, properties): mesh_object.parent, BlenderTypes.MESH, exclude_postfix_tokens=True, - required_collection=bpy.data.collections.get('Export'), + required_collection=bpy.data.collections.get(ToolInfo.EXPORT_COLLECTION.value), ) # rename the asset to match the empty if this is a static mesh export if mesh_object.parent.type == 'EMPTY': diff --git a/src/addons/send2ue/resources/extensions/send2ue_material_pipeline.py b/src/addons/send2ue/resources/extensions/send2ue_material_pipeline.py index 28c22eba..8444a407 100644 --- a/src/addons/send2ue/resources/extensions/send2ue_material_pipeline.py +++ b/src/addons/send2ue/resources/extensions/send2ue_material_pipeline.py @@ -5,7 +5,6 @@ # Unreal to process the imported mesh through the shared surface-layer pipeline. import json -import os from pathlib import Path import bpy @@ -16,10 +15,9 @@ BUNDLED_PIPELINE_DIR = (Path(__file__).resolve().parent.parent / "pipeline").as_posix() -PIPELINE_DIR = os.environ.get("UE_BLENDER_PIPELINE_DIR", BUNDLED_PIPELINE_DIR).replace( - "\\", - "/", -) +# The material runtime is part of this Git repository. Do not allow a stale +# Documents copy or process environment override to shadow the bundled source. +PIPELINE_DIR = BUNDLED_PIPELINE_DIR _TEXTURELESS_FBX_RESTORE = {} TEXTURELESS_FBX_EXPORT_FLAG = "send2ue_material_pipeline_textureless_fbx_export" @@ -251,11 +249,34 @@ def pre_import(self, asset_data, properties): return if asset_data.get("skip"): return - if asset_data.get("_asset_type") != UnrealTypes.STATIC_MESH: + if asset_data.get("_asset_type") not in { + UnrealTypes.STATIC_MESH, + UnrealTypes.SKELETAL_MESH, + }: return - if not self._resolve_json_path(asset_data.get("asset_path", "")): + asset_path = asset_data.get("asset_path", "") + json_path = self._resolve_json_path(asset_path) + if not json_path: return + json_arg = f'r"{json_path}"' + commands = [ + "import sys", + "import importlib.util", + "import unreal", + f'_d = r"{PIPELINE_DIR}"', + f'_asset_path = r"{asset_path}".split(".")[0]', + "_pipeline_file = _d.rstrip('/') + '/ue_material_setup.py'", + "_spec = importlib.util.spec_from_file_location('send2ue_bundled_ue_material_setup_preflight', _pipeline_file)", + "if _spec is None or _spec.loader is None:", + "\traise RuntimeError('Could not load bundled material pipeline: ' + _pipeline_file)", + "_p = importlib.util.module_from_spec(_spec)", + "sys.modules[_spec.name] = _p", + "_spec.loader.exec_module(_p)", + f"_p.preflight_mesh_materials(_asset_path, json_path={json_arg})", + ] + run_commands(commands) + # The JSON material pipeline imports textures and creates/assigns MIs itself. # Letting the FBX importer also create source materials/textures adds duplicate # assets that immediately need cleanup, which is expensive in Unreal/P4. @@ -266,7 +287,10 @@ def post_import(self, asset_data, properties): return if asset_data.get("skip"): return - if asset_data.get("_asset_type") != UnrealTypes.STATIC_MESH: + if asset_data.get("_asset_type") not in { + UnrealTypes.STATIC_MESH, + UnrealTypes.SKELETAL_MESH, + }: return asset_path = asset_data.get("asset_path") @@ -278,12 +302,26 @@ def post_import(self, asset_data, properties): commands = [ "import sys", + "import importlib.util", + "import unreal", f'_d = r"{PIPELINE_DIR}"', - "sys.path.append(_d) if _d not in sys.path else None", - "import importlib", - "import ue_material_setup as _p", - "importlib.reload(_p)", - f'_p.process_mesh(r"{asset_path}", json_path={json_arg})', + f'_asset_path = r"{asset_path}".split(".")[0]', + "_pipeline_file = _d.rstrip('/') + '/ue_material_setup.py'", + "_spec = importlib.util.spec_from_file_location('send2ue_bundled_ue_material_setup', _pipeline_file)", + "if _spec is None or _spec.loader is None:", + "\traise RuntimeError('Could not load bundled material pipeline: ' + _pipeline_file)", + "_p = importlib.util.module_from_spec(_spec)", + "sys.modules[_spec.name] = _p", + "_spec.loader.exec_module(_p)", + "def _sync_to_imported_asset(_path):", + "\ttry:", + "\t\tunreal.EditorAssetLibrary.sync_browser_to_objects([_path])", + "\texcept Exception as _sync_error:", + "\t\tunreal.log_warning('[material_pipeline] content browser sync failed: ' + str(_sync_error))", + "try:", + f"\t_p.process_mesh(_asset_path, json_path={json_arg})", + "finally:", + "\t_sync_to_imported_asset(_asset_path)", ] run_commands(commands) diff --git a/src/addons/send2ue/resources/pipeline/ue_material_setup.py b/src/addons/send2ue/resources/pipeline/ue_material_setup.py index b42cc6c9..a42ef3cf 100644 --- a/src/addons/send2ue/resources/pipeline/ue_material_setup.py +++ b/src/addons/send2ue/resources/pipeline/ue_material_setup.py @@ -254,6 +254,18 @@ def _cloth_texture_param_by_layer_param(): "Moss Blend Mask": "Moss Blend Mask", } +# Send to Unreal keeps the source texture stem in the asset name. These +# suffixes are the source of truth for the tree texture set, even when an +# older sidecar uses a generic/legacy parameter name. +TEXTURE_PARAM_BY_NAME_SUFFIX = ( + ("_color", "Albedo"), + ("_extra", "Extra"), + ("_height", "Height"), + ("_normal", "Normal"), + ("_opacity", "Opacity"), + ("_subsurface", "Subsurface"), +) + # import 되는 텍스처의 인게임 최대 해상도 캡(param 별). 목록에 없으면 DEFAULT 적용. MAX_TEXTURE_SIZE_BY_PARAM = { "Albedo": 2048, # 2K @@ -373,8 +385,33 @@ def _set_texture_property_if_changed(tex, property_name: str, value) -> bool: return True -def _configure_imported_texture(tex, param: str, virtual_texture_streaming=None) -> bool: +def _texture_param_from_name(file_path=None, asset_name=None): + """Return the canonical import role for a known texture-name suffix.""" + for value in (file_path, asset_name): + if not value: + continue + stem = os.path.splitext(os.path.basename(str(value).replace("\\", "/")))[0].lower() + for suffix, role in TEXTURE_PARAM_BY_NAME_SUFFIX: + if stem.endswith(suffix): + return role + return None + + +def _effective_texture_param(param: str, file_path=None, asset_name=None) -> str: + # A recognized filename suffix wins over a legacy JSON role. This keeps + # existing MYI/MI JSON contracts working while fixing tree map imports. + return _texture_param_from_name(file_path, asset_name) or str(param or "") + + +def _configure_imported_texture( + tex, + param: str, + virtual_texture_streaming=None, + file_path=None, + asset_name=None, +) -> bool: changed = False + param = _effective_texture_param(param, file_path, asset_name) if param == "Normal": changed |= _set_texture_property_if_changed(tex, "srgb", False) @@ -398,13 +435,15 @@ def _configure_imported_texture(tex, param: str, virtual_texture_streaming=None) "compression_settings", unreal.TextureCompressionSettings.TC_MASKS, ) - elif param == "Height": + elif param in {"Height", "Opacity", "Alpha", "Transmission"}: changed |= _set_texture_property_if_changed(tex, "srgb", False) changed |= _set_texture_property_if_changed( tex, "compression_settings", unreal.TextureCompressionSettings.TC_GRAYSCALE, ) + elif param == "Subsurface": + changed |= _set_texture_property_if_changed(tex, "srgb", True) else: changed |= _set_texture_property_if_changed(tex, "srgb", True) @@ -458,7 +497,13 @@ def _import_texture( source_mtime = None if unreal.EditorAssetLibrary.does_asset_exist(full_path) and not force_reimport: tex = unreal.load_asset(full_path) - if tex is not None and _configure_imported_texture(tex, param, virtual_texture_streaming): + if tex is not None and _configure_imported_texture( + tex, + param, + virtual_texture_streaming, + file_path, + asset_name, + ): unreal.EditorAssetLibrary.save_asset(full_path) if tex_cache is not None and source_mtime is not None: tex_cache[full_path] = source_mtime @@ -470,7 +515,13 @@ def _import_texture( and unreal.EditorAssetLibrary.does_asset_exist(full_path) and not force_reimport): tex = unreal.load_asset(full_path) - if tex is not None and _configure_imported_texture(tex, param, virtual_texture_streaming): + if tex is not None and _configure_imported_texture( + tex, + param, + virtual_texture_streaming, + file_path, + asset_name, + ): unreal.EditorAssetLibrary.save_asset(full_path) return full_path @@ -488,7 +539,13 @@ def _import_texture( _warn(f" 텍스처 import 실패: {asset_name}") return None - _configure_imported_texture(tex, param, virtual_texture_streaming) + _configure_imported_texture( + tex, + param, + virtual_texture_streaming, + file_path, + asset_name, + ) unreal.EditorAssetLibrary.save_asset(full_path) if tex_cache is not None and source_mtime is not None: @@ -1372,7 +1429,7 @@ def _texture_parameter_name(parameter_value): return "" -def _prune_texture_parameter_overrides(mi, keep_names: set) -> bool: +def _prune_texture_parameter_overrides(mi, keep_names: set, update: bool = True) -> bool: try: values = list(mi.get_editor_property("texture_parameter_values")) except Exception: @@ -1385,10 +1442,11 @@ def _prune_texture_parameter_overrides(mi, keep_names: set) -> bool: if len(kept) == len(values): return False mi.set_editor_property("texture_parameter_values", kept) - try: - unreal.MaterialEditingLibrary.update_material_instance(mi) - except Exception: - pass + if update: + try: + unreal.MaterialEditingLibrary.update_material_instance(mi) + except Exception: + pass _log( " stale texture overrides pruned: " + ", ".join( @@ -1564,12 +1622,105 @@ def _call_create_or_update_layer_instance(helper, parent_layer, layer_path, text return bool(result), [] +_NORMALIZED_MATERIAL_LAYER_ASSETS = set() + + +def _normalize_material_layer_asset(helper, method_name: str, asset_path: str, label: str): + cache_key = (method_name, asset_path) + if cache_key in _NORMALIZED_MATERIAL_LAYER_ASSETS: + return + method = getattr(helper, method_name, None) + if method is None: + raise RuntimeError(f"CodexMaterialTools {label} normalization helper missing") + + result = method(asset_path) + report_text = "" + errors = [] + returned_ok = None + if isinstance(result, tuple): + if result and isinstance(result[0], bool): + returned_ok = bool(result[0]) + if len(result) > 1: + report_text = str(result[1] or "") + if len(result) > 2: + errors = [str(item) for item in (result[2] or [])] + elif result and isinstance(result[0], str): + report_text = result[0] + if len(result) > 1: + errors = [str(item) for item in (result[1] or [])] + elif isinstance(result, str): + report_text = result + elif isinstance(result, bool): + returned_ok = result + + try: + report = json.loads(report_text) if report_text else {} + except Exception: + report = {} + report_ok = bool(report.get("ok", returned_ok)) + if not report_ok or errors: + detail = " | ".join(errors) or report_text or "unknown normalization failure" + raise RuntimeError(f"{label} normalization failed: {asset_path} ({detail})") + + _NORMALIZED_MATERIAL_LAYER_ASSETS.add(cache_key) + changed = ( + report.get("removed_placeholder_count", 0) + or report.get("removed_set_declaration_count", 0) + or report.get("removed_get_declaration_count", 0) + or report.get("restored_tree_input_count", 0) + ) + if changed: + _log(f" {label} normalized: {asset_path}") + + +def _call_set_material_instance_background_layer(helper, mi, layer_asset): + if hasattr(helper, "set_material_instance_background_layer_report"): + result = helper.set_material_instance_background_layer_report(mi, layer_asset) + ok = False + report_json = "" + errors = [] + if isinstance(result, tuple): + if result and isinstance(result[0], bool): + ok = bool(result[0]) + report_json = str(result[1] if len(result) > 1 else "") + if len(result) > 2: + errors = [str(item) for item in (result[2] or [])] + elif result and isinstance(result[0], str): + report_json = result[0] + if len(result) > 1: + errors = [str(item) for item in (result[1] or [])] + elif isinstance(result, str): + report_json = result + else: + ok = bool(result) + if report_json: + try: + report = json.loads(report_json) + errors.extend(str(item) for item in (report.get("errors") or [])) + ok = bool(report.get("ok", ok) or report.get("desired_is_set")) + except Exception as exc: + return False, [f"background layer report parse failed: {exc}"] + return ok, errors + if hasattr(helper, "set_material_instance_background_layer_with_errors"): + result = helper.set_material_instance_background_layer_with_errors(mi, layer_asset) + if isinstance(result, tuple): + changed = bool(result[0]) if result else False + errors = result[1] if len(result) > 1 else [] + return changed, list(errors or []) + return bool(result), [] + return bool(helper.set_material_instance_background_layer(mi, layer_asset)), [] + + def _assign_material_layer_instance(mi, mat_base: str, layer_maps, preset: dict, entry: dict) -> bool: helper = getattr(unreal, "CodexMaterialToolsLibrary", None) if not helper or not hasattr(helper, "create_or_update_material_layer_instance"): _warn(" CodexMaterialTools layer instance helper missing; MYI assignment skipped") return False - if not hasattr(helper, "set_material_instance_background_layer"): + if not ( + hasattr(helper, "set_material_instance_background_layer_report") + or hasattr(helper, "set_material_instance_background_layer_with_errors") + or hasattr(helper, "set_material_instance_background_layer") + ): _warn(" CodexMaterialTools background layer helper missing; MYI assignment skipped") return False @@ -1579,6 +1730,19 @@ def _assign_material_layer_instance(mi, mat_base: str, layer_maps, preset: dict, _warn(" material layer instance path is incomplete; MYI assignment skipped") return False + _normalize_material_layer_asset( + helper, + "normalize_material_layer_placeholders", + str(preset.get("master") or ""), + "material master", + ) + _normalize_material_layer_asset( + helper, + "normalize_material_function_attribute_nodes", + parent_layer, + "material layer function", + ) + remap = _layer_texture_remap(preset, entry) texture_params = {} for layer_param, tex_path in _first_layer_textures(layer_maps).items(): @@ -1612,8 +1776,22 @@ def _assign_material_layer_instance(mi, mat_base: str, layer_maps, preset: dict, _warn(f" MYI load failed after create/update: {layer_path}") return False - changed = bool(helper.set_material_instance_background_layer(mi, layer_asset)) - changed = _prune_texture_parameter_overrides(mi, set()) or changed + # Remove stale flat/layer overrides before the C++ helper persists the MI. + # Do not trigger a live material preview update here; UE 5.8 can assert + # while compiling a newly-created Material Layer Instance thumbnail. + overrides_pruned = _prune_texture_parameter_overrides(mi, set(), update=False) + changed, background_errors = _call_set_material_instance_background_layer( + helper, mi, layer_asset + ) + if background_errors: + _warn(f" background MYI assignment failed: {layer_path}") + for error in background_errors: + _warn(f" {error}") + return False + if not changed: + _warn(f" background MYI assignment not verified: {layer_path}") + return False + changed = overrides_pruned or changed _log(f" background MYI <- {layer_path}") return changed @@ -1651,13 +1829,20 @@ def _hair_target_material_name(mat_name: str, entry: dict) -> str: def _hair_target_material_path(mat_name: str, entry: dict, preset: dict): - target_path = _entry_target_material_path(entry) - if target_path: - return target_path folder = str(preset.get("mi_folder") or "").rstrip("/") target_name = _hair_target_material_name(mat_name, entry) if not folder or not target_name: return None + target_path = _entry_target_material_path(entry) + if target_path: + normalized_target = str(target_path).replace("\\", "/").casefold() + normalized_folder = f"{folder}/".replace("\\", "/").casefold() + if normalized_target.startswith(normalized_folder): + return target_path + _warn( + f" ignoring non-hair target material path for hair '{mat_name}': " + f"{target_path}" + ) return f"{folder}/{target_name}" @@ -1717,6 +1902,58 @@ def _hair_source_material_paths(mat_name: str, entry: dict, preset: dict): ) +def _asset_base_material_path(asset): + if asset is None or not hasattr(asset, "get_base_material"): + return "" + try: + base_material = asset.get_base_material() + except Exception: + return "" + try: + return str(base_material.get_path_name()) + except Exception: + return str(base_material or "") + + +def _wrong_generated_hair_material_paths(mat_name: str, entry: dict, preset: dict, target_path: str): + target_name = _hair_target_material_name(mat_name, entry) + candidates = set() + + entry_target_path = _entry_target_material_path(entry) + if entry_target_path and entry_target_path != target_path: + candidates.add(entry_target_path) + + for path in _asset_paths_named( + [target_name], + allowed_classes={"MaterialInstanceConstant"}, + ): + if path == target_path: + continue + if _asset_path_excluded(path, preset.get("exclude_path_fragments")): + candidates.add(path) + + wrong_paths = [] + for path in sorted(candidates): + asset = unreal.load_asset(path) + base_path = _asset_base_material_path(asset).replace("\\", "/").casefold() + if "/game/material/assetsurface/" in base_path: + wrong_paths.append(path) + return wrong_paths + + +def _delete_wrong_generated_hair_materials(mat_name: str, entry: dict, preset: dict, target_path: str): + deleted = [] + for wrong_path in _wrong_generated_hair_material_paths(mat_name, entry, preset, target_path): + if not unreal.EditorAssetLibrary.does_asset_exist(wrong_path): + continue + if unreal.EditorAssetLibrary.delete_asset(wrong_path): + deleted.append(wrong_path) + _log(f" deleted wrong generated hair MI: {wrong_path}") + else: + _warn(f" wrong generated hair MI delete failed: {wrong_path}") + return deleted + + def _load_or_migrate_hair_material(asset_tools, mat_name: str, entry: dict, preset: dict): target_path = _hair_target_material_path(mat_name, entry, preset) if not target_path: @@ -1735,9 +1972,11 @@ def _load_or_migrate_hair_material(asset_tools, mat_name: str, entry: dict, pres _warn(f" hair MI move/rename failed: {source_path} -> {target_path}") return None, None _log(f" hair MI moved: {source_path} -> {target_path}") + _delete_wrong_generated_hair_materials(mat_name, entry, preset, target_path) return unreal.load_asset(target_path), target_path if unreal.EditorAssetLibrary.does_asset_exist(target_path): + _delete_wrong_generated_hair_materials(mat_name, entry, preset, target_path) return unreal.load_asset(target_path), target_path _warn(f" hair material instance source missing for '{mat_name}' -> target {target_path}") @@ -1889,6 +2128,49 @@ def _cleanup_imported_source_assets(mesh_path: str, data: dict): _log(f" cleanup: no source material/texture assets found in {mesh_folder} ({elapsed:.2f}s)") +def preflight_mesh_materials(mesh_path: str, json_path: str = None) -> bool: + """Normalize shared material-layer assets before Unreal touches an existing mesh. + + A skeletal-mesh reimport recompiles its currently assigned material instances + during ImportAssetTasks. Legacy SpeedTree layers therefore have to be repaired + before the FBX import, not from post_import after it. + """ + mesh_path = mesh_path.split(".")[0] + mesh_name = mesh_path.rsplit("/", 1)[-1] + data = _load_json(mesh_name, json_path, mesh_path) + if not data: + return False + + helper = getattr(unreal, "CodexMaterialToolsLibrary", None) + if helper is None: + raise RuntimeError("CodexMaterialTools material preflight helper missing") + + normalized = False + for entry in data.get("materials", []): + preset = _master_preset(data, entry, mesh_path) + if preset.get("assignment") != "material_layer_instance": + continue + master_path = str(preset.get("master") or "") + parent_layer = _layer_parent_path(preset, entry) + if master_path: + _normalize_material_layer_asset( + helper, + "normalize_material_layer_placeholders", + master_path, + "material master", + ) + normalized = True + if parent_layer: + _normalize_material_layer_asset( + helper, + "normalize_material_function_attribute_nodes", + parent_layer, + "material layer function", + ) + normalized = True + return normalized + + def process_mesh(mesh_path: str, master_mat=None, json_path: str = None) -> bool: """단일 StaticMesh/SkeletalMesh 를 JSON 기반으로 처리. 변경이 있었으면 True. @@ -1902,16 +2184,28 @@ def process_mesh(mesh_path: str, master_mat=None, json_path: str = None) -> bool mesh_name = mesh_path.rsplit("/", 1)[-1] data = _load_json(mesh_name, json_path, mesh_path) + def save_mesh_asset(): + if _is_skeletal_mesh(mesh): + helper = getattr(unreal, "CodexMaterialToolsLibrary", None) + if not helper or not hasattr(helper, "save_asset_package_without_thumbnail"): + raise RuntimeError( + "CodexMaterialTools safe skeletal-mesh save helper is missing" + ) + if not helper.save_asset_package_without_thumbnail(mesh): + raise RuntimeError(f"safe skeletal-mesh save failed: {mesh_path}") + return + unreal.EditorAssetLibrary.save_asset(mesh_path) + # Nanite: import 되는 StaticMesh 에 켜되, 반투명 머티리얼 메쉬는 끈다. # (JSON 이 없으면 불투명으로 가정 → 켬. 반투명으로 판정되면 이미 켜져 있어도 끈다.) if ENABLE_NANITE: nanite_enabled = not _is_translucent(data) if isinstance(mesh, unreal.StaticMesh) and _set_nanite(mesh, nanite_enabled): - unreal.EditorAssetLibrary.save_asset(mesh_path) + save_mesh_asset() elif _is_skeletal_mesh(mesh): voxelize = _nanite_shape_preservation_voxelize() if ENABLE_SKELETAL_NANITE_VOXELIZE else None if _set_nanite(mesh, nanite_enabled, voxelize): - unreal.EditorAssetLibrary.save_asset(mesh_path) + save_mesh_asset() if data is None: _warn(f"JSON 사이드카 없음: {mesh_name}.json — skip (블렌더에서 Rename 버튼을 눌렀나요?)") @@ -1923,7 +2217,7 @@ def process_mesh(mesh_path: str, master_mat=None, json_path: str = None) -> bool if json_mesh_name and json_mesh_name != mesh_name: _warn(f"JSON mesh_name mismatch: asset={mesh_name}, json={json_mesh_name}; using JSON data") if _import_dynamic_wind_if_available(mesh, mesh_path, mesh_name, data, json_path): - unreal.EditorAssetLibrary.save_asset(mesh_path) + save_mesh_asset() changed = True # 텍스처 재import 회피 캐시(메쉬마다 reload 되므로 디스크에서 읽고, 바뀌면 끝에 저장). @@ -2016,9 +2310,11 @@ def process_mesh(mesh_path: str, master_mat=None, json_path: str = None) -> bool entry=entry, mat_base=mat_base, ) - if mi_created or parent_changed or params_changed: + if (mi_created or parent_changed or params_changed) and preset["assignment"] != "material_layer_instance": unreal.EditorAssetLibrary.save_asset(mi_path) changed = True + elif mi_created or parent_changed or params_changed: + changed = True if _is_skeletal_mesh(mesh): skeletal_slot_assignments[slot_index] = (slot_name, mi) if _assign_slot(mesh, slot_index, mi, slot_name): @@ -2064,9 +2360,11 @@ def process_mesh(mesh_path: str, master_mat=None, json_path: str = None) -> bool entry=entry, mat_base=mat_base, ) - if mi_created or parent_changed or params_changed: + if (mi_created or parent_changed or params_changed) and preset["assignment"] != "material_layer_instance": unreal.EditorAssetLibrary.save_asset(mi_path) changed = True + elif mi_created or parent_changed or params_changed: + changed = True # 4. 슬롯에 MI 할당 if _is_skeletal_mesh(mesh): @@ -2085,7 +2383,7 @@ def process_mesh(mesh_path: str, master_mat=None, json_path: str = None) -> bool _cleanup_imported_source_assets(mesh_path, data) if changed: - unreal.EditorAssetLibrary.save_asset(mesh_path) + save_mesh_asset() _log(f"메쉬 '{mesh_name}' 완료") # 마지막으로 브라우저를 메쉬로 돌려 선택 상태로 끝낸다(텍스처 폴더로 튀는 것 방지). diff --git a/src/addons/send2ue/resources/setting_templates/default.json b/src/addons/send2ue/resources/setting_templates/default.json index 7d8ee793..3c4fb0cb 100644 --- a/src/addons/send2ue/resources/setting_templates/default.json +++ b/src/addons/send2ue/resources/setting_templates/default.json @@ -202,6 +202,7 @@ "reset_to_fbx_on_material_conflict": false, "skeletal_mesh_import_data": { "bake_pivot_in_vertex": false, + "build_nanite": true, "compute_weighted_normals": true, "convert_scene": false, "convert_scene_unit": false, diff --git a/src/addons/send2ue/resources/settings.json b/src/addons/send2ue/resources/settings.json index bb59c8b1..33847144 100644 --- a/src/addons/send2ue/resources/settings.json +++ b/src/addons/send2ue/resources/settings.json @@ -283,6 +283,12 @@ } }, "skeletal_mesh_import_data": { + "build_nanite": { + "name": "Build Nanite", + "description": "If enabled, builds Nanite data for imported skeletal meshes when supported by Unreal", + "type": "BOOLEAN", + "default": true + }, "import_content_type": { "name": "Import Content Type", "description": "Filter the content we want to import from the incoming skeletal mesh", From 03311c1e3c79ea8c1252d11fd326fcd138355a98 Mon Sep 17 00:00:00 2001 From: 424 <2421412@naver.com> Date: Mon, 13 Jul 2026 15:06:59 +0900 Subject: [PATCH 20/25] Scope Send2UE handoff and tree Nanite settings --- .../extensions/send2ue_material_pipeline.py | 5 ++++- .../resources/pipeline/ue_material_setup.py | 19 ++++++++++++++++++- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/src/addons/send2ue/resources/extensions/send2ue_material_pipeline.py b/src/addons/send2ue/resources/extensions/send2ue_material_pipeline.py index 8444a407..63474817 100644 --- a/src/addons/send2ue/resources/extensions/send2ue_material_pipeline.py +++ b/src/addons/send2ue/resources/extensions/send2ue_material_pipeline.py @@ -107,7 +107,10 @@ def _refresh_unreal_handoff_json_or_error(self, target): try: from ue_unique_export_names_addon import api as handoff_api - result = handoff_api.refresh_handoff_json(bpy.context) + result = handoff_api.refresh_handoff_json( + bpy.context, + scope="EXPORT_COLLECTION", + ) errors = result.get("errors") or [] if errors: first = errors[0] diff --git a/src/addons/send2ue/resources/pipeline/ue_material_setup.py b/src/addons/send2ue/resources/pipeline/ue_material_setup.py index a42ef3cf..dfffff5a 100644 --- a/src/addons/send2ue/resources/pipeline/ue_material_setup.py +++ b/src/addons/send2ue/resources/pipeline/ue_material_setup.py @@ -697,6 +697,18 @@ def _master_preset(data: dict, entry: dict = None, mesh_path: str = "") -> dict: return result +def _uses_tree_material_preset(data: dict, mesh_path: str) -> bool: + if _is_tree_asset_path(mesh_path): + return True + if not data: + return False + return any( + _master_preset(data, entry, mesh_path).get("key") == "tree" + for entry in data.get("materials", []) + if isinstance(entry, dict) + ) + + def _load_master_material(preset: dict): master_path = preset["master"] master_mat = unreal.load_asset(master_path) @@ -2203,7 +2215,12 @@ def save_mesh_asset(): if isinstance(mesh, unreal.StaticMesh) and _set_nanite(mesh, nanite_enabled): save_mesh_asset() elif _is_skeletal_mesh(mesh): - voxelize = _nanite_shape_preservation_voxelize() if ENABLE_SKELETAL_NANITE_VOXELIZE else None + voxelize = ( + _nanite_shape_preservation_voxelize() + if ENABLE_SKELETAL_NANITE_VOXELIZE + and _uses_tree_material_preset(data, mesh_path) + else None + ) if _set_nanite(mesh, nanite_enabled, voxelize): save_mesh_asset() From eef62b43c219b3f7a4209ede23b0b171db3794e3 Mon Sep 17 00:00:00 2001 From: 424 <2421412@naver.com> Date: Sun, 19 Jul 2026 01:02:05 +0900 Subject: [PATCH 21/25] Add Unreal groom and material handoff adapters --- src/addons/send2ue/core/export.py | 46 +- src/addons/send2ue/core/hair_tool_export.py | 88 +- src/addons/send2ue/core/ingest.py | 46 +- src/addons/send2ue/core/ue_groom_adapter.py | 1301 ++++++++++++++ src/addons/send2ue/core/utilities.py | 15 +- src/addons/send2ue/core/validations.py | 21 +- src/addons/send2ue/dependencies/unreal.py | 196 +- src/addons/send2ue/operators.py | 2 +- .../extensions/apply_groom_modifiers.py | 19 +- .../create_post_import_assets_for_groom.py | 9 +- .../extensions/send2ue_material_pipeline.py | 17 +- .../resources/extensions/ue_groom_adapter.py | 306 ++++ .../resources/pipeline/ue_material_setup.py | 1571 +++++++++++++++-- tests/test_send2ue_core.py | 5 + ...est_send2ue_material_pipeline_extension.py | 135 ++ tests/test_ue_material_setup.py | 1167 ++++++++++++ 16 files changed, 4724 insertions(+), 220 deletions(-) create mode 100644 src/addons/send2ue/core/ue_groom_adapter.py create mode 100644 src/addons/send2ue/resources/extensions/ue_groom_adapter.py create mode 100644 tests/test_send2ue_material_pipeline_extension.py create mode 100644 tests/test_ue_material_setup.py diff --git a/src/addons/send2ue/core/export.py b/src/addons/send2ue/core/export.py index adb6698a..c4aa6e01 100644 --- a/src/addons/send2ue/core/export.py +++ b/src/addons/send2ue/core/export.py @@ -4,7 +4,7 @@ import math import os import bpy -from . import utilities, validations, settings, ingest, extension, io, hair_tool_export +from . import utilities, validations, settings, ingest, extension, io, hair_tool_export, ue_groom_adapter from ..constants import BlenderTypes, UnrealTypes, FileTypes, PreFixToken, ToolInfo, ExtensionTasks @@ -328,6 +328,21 @@ def export_hair(asset_id, properties): object_type = asset_data.get('_object_type') object_name = asset_data.get('_object_name') + # Unreal 5.8 can import a Groom from USD BasisCurves carrying GroomAPI. + # This direct path preserves evaluated Hair Tool curve attributes and does + # not create a particle system or preview mesh. + if asset_data.get('_ue_groom_adapter'): + curves_object = bpy.data.objects.get(object_name) + extension.run_extension_tasks(ExtensionTasks.PRE_GROOM_EXPORT.value) + report = ue_groom_adapter.write_usda( + curves_object, + asset_data['file_path'], + properties, + ) + asset_data['_ue_groom_mappings'] = report['mappings'] + extension.run_extension_tasks(ExtensionTasks.POST_GROOM_EXPORT.value) + return + mesh_object = utilities.get_mesh_object_for_groom_name(object_name) # get all particle systems display options on all mesh objects @@ -492,18 +507,23 @@ def create_groom_data(hair_objects, properties): object_type = hair_object.settings.type particle_object_name = hair_object.settings.name + adapter_groom = ( + isinstance(hair_object, bpy.types.Object) + and ue_groom_adapter.is_hair_tool_groom(hair_object, properties) + ) + file_extension = 'usda' if adapter_groom else 'abc' file_path = get_file_path( hair_object.name, properties, UnrealTypes.GROOM, lod=False, - file_extension='abc' + file_extension=file_extension ) asset_id = utilities.get_asset_id(file_path) import_path = utilities.get_import_path(properties, UnrealTypes.GROOM) asset_name = utilities.get_asset_name(hair_object.name, properties) - groom_data[asset_id] = { + groom_asset_data = { '_asset_type': UnrealTypes.GROOM, '_object_name': hair_object.name, '_particle_object_name': particle_object_name, @@ -513,6 +533,25 @@ def create_groom_data(hair_objects, properties): 'asset_path': f'{import_path}{asset_name}', 'skip': False } + if adapter_groom: + adapter = ue_groom_adapter.get_settings(properties) + report = ue_groom_adapter.inspect_object(hair_object, properties) + preview = report.get('preview') or {} + groom_asset_data.update({ + '_ue_groom_adapter': True, + '_ue_groom_deformation_preset': adapter.deformation_preset, + '_ue_groom_group_count': report.get('data', {}).get('group_count', 1), + '_ue_groom_rigged_guide_num_curves': adapter.rigged_guide_num_curves, + '_ue_groom_rigged_guide_num_points': adapter.rigged_guide_num_points, + '_ue_groom_preview_material_name': preview.get('material_name'), + '_ue_groom_material_path': preview.get('unreal_material_path'), + '_ue_groom_hair_width_cm': preview.get('hair_width_cm'), + '_ue_groom_root_scale': preview.get('root_scale'), + '_ue_groom_tip_scale': preview.get('tip_scale'), + '_ue_groom_color': preview.get('color'), + '_ue_groom_roughness': preview.get('roughness'), + }) + groom_data[asset_id] = groom_asset_data # export particle hair systems as alembic file export_hair(asset_id, properties) @@ -535,6 +574,7 @@ def create_asset_data(properties): if not ( isinstance(hair_object, bpy.types.Object) and hair_tool_export.is_prepared_source(hair_object) + and not ue_groom_adapter.is_hair_tool_groom(hair_object, properties) ) ] diff --git a/src/addons/send2ue/core/hair_tool_export.py b/src/addons/send2ue/core/hair_tool_export.py index 2bfb6650..41c050ff 100644 --- a/src/addons/send2ue/core/hair_tool_export.py +++ b/src/addons/send2ue/core/hair_tool_export.py @@ -8,7 +8,7 @@ STATE_KEY = 'send2ue_hair_tool_export_state' SOURCE_NAME_PROPERTY = '_send2ue_hair_tool_source_name' TEMP_PROPERTY = '_send2ue_hair_tool_temp' -RSAO_NAME = 'RSAO' +RFAOS_NAME = 'RFAOS' def is_hair_tool_object(scene_object): @@ -101,9 +101,16 @@ def _loop_to_polygon_indices(mesh): return loop_to_polygon -def _attribute_scalar(mesh, attribute, loop_index, loop_to_polygon=None): +def _attribute_component( + mesh, + attribute, + loop_index, + component_index=0, + loop_to_polygon=None, + default=0.0, +): if not attribute: - return 0.0 + return default loop = mesh.loops[loop_index] if attribute.domain == 'POINT': @@ -113,47 +120,76 @@ def _attribute_scalar(mesh, attribute, loop_index, loop_to_polygon=None): elif attribute.domain == 'FACE': data_index = loop_to_polygon[loop_index] if loop_to_polygon else 0 else: - return 0.0 + return default item = attribute.data[data_index] if hasattr(item, 'value'): return float(item.value) if hasattr(item, 'color'): - return float(item.color[0]) + color = item.color + if component_index < len(color): + return float(color[component_index]) + return default if hasattr(item, 'vector'): - return float(item.vector[0]) - return 0.0 + vector = item.vector + if component_index < len(vector): + return float(vector[component_index]) + return default + return default -def _pack_rsao(mesh): +def _pack_rfaos(mesh): + """Pack Random/Factor/AO/SystemColor-alpha into the export vertex color.""" random_attribute = mesh.attributes.get('Random') + factor_attribute = mesh.attributes.get('Factor') system_color_attribute = mesh.attributes.get('SystemColor') ao_attribute = mesh.attributes.get('AO') loop_to_polygon = _loop_to_polygon_indices(mesh) - existing_rsao = mesh.attributes.get(RSAO_NAME) - if existing_rsao: - mesh.attributes.remove(existing_rsao) + existing_rfaos = mesh.attributes.get(RFAOS_NAME) + if existing_rfaos: + mesh.attributes.remove(existing_rfaos) - rsao = mesh.color_attributes.new( - name=RSAO_NAME, + rfaos = mesh.color_attributes.new( + name=RFAOS_NAME, type='BYTE_COLOR', domain='CORNER', ) - for loop_index, color_item in enumerate(rsao.data): - color_item.color = ( - _attribute_scalar(mesh, random_attribute, loop_index, loop_to_polygon), - _attribute_scalar(mesh, system_color_attribute, loop_index, loop_to_polygon), - _attribute_scalar(mesh, ao_attribute, loop_index, loop_to_polygon), - 1.0, + for loop_index, color_item in enumerate(rfaos.data): + packed = ( + _attribute_component( + mesh, random_attribute, loop_index, + loop_to_polygon=loop_to_polygon, + ), + _attribute_component( + mesh, factor_attribute, loop_index, + loop_to_polygon=loop_to_polygon, + ), + _attribute_component( + mesh, ao_attribute, loop_index, + loop_to_polygon=loop_to_polygon, + ), + _attribute_component( + mesh, system_color_attribute, loop_index, + component_index=3, + loop_to_polygon=loop_to_polygon, + ), ) + # BYTE_COLOR exposes ``color`` in scene-linear space, while FBX writes + # the underlying sRGB byte values. RFAOS contains data masks, not + # display colors, so write the sRGB-facing property to keep the numeric + # RGBA values unchanged when Unreal imports the FBX vertex colors. + if hasattr(color_item, 'color_srgb'): + color_item.color_srgb = packed + else: + color_item.color = packed for color_attribute in list(mesh.color_attributes): - if color_attribute.name != RSAO_NAME: + if color_attribute.name != RFAOS_NAME: mesh.color_attributes.remove(color_attribute) - mesh.color_attributes.active_color = mesh.color_attributes[RSAO_NAME] - mesh.color_attributes.render_color_index = mesh.color_attributes.find(RSAO_NAME) + mesh.color_attributes.active_color = mesh.color_attributes[RFAOS_NAME] + mesh.color_attributes.render_color_index = mesh.color_attributes.find(RFAOS_NAME) def _remove_empty_material_slots(scene_object): @@ -354,6 +390,12 @@ def prepare(): """Create export-only mesh copies for Hair Tool systems in the Export collection.""" cleanup() + # The UE Groom path reads evaluated Curves directly. Only the Cards and + # Cards + Groom modes need temporary evaluated mesh copies. + from . import ue_groom_adapter + if not ue_groom_adapter.wants_cards(bpy.context.scene.send2ue): + return + export_collection = bpy.data.collections.get(ToolInfo.EXPORT_COLLECTION.value) if not export_collection: return @@ -428,7 +470,7 @@ def prepare(): _evaluate_combined_ao(temporary_object, state) _write_hair_tool_uvs(temporary_object.data) - _pack_rsao(temporary_object.data) + _pack_rfaos(temporary_object.data) _remove_empty_material_slots(temporary_object) armatures = { diff --git a/src/addons/send2ue/core/ingest.py b/src/addons/send2ue/core/ingest.py index 98c89181..741abae9 100644 --- a/src/addons/send2ue/core/ingest.py +++ b/src/addons/send2ue/core/ingest.py @@ -1,13 +1,16 @@ # Copyright Epic Games, Inc. All Rights Reserved. import bpy +import copy from . import settings, extension from ..constants import PathModes, ExtensionTasks, UnrealTypes +from ..dependencies import unreal as unreal_dependency from ..dependencies.unreal import UnrealRemoteCalls as UnrealCalls from .utilities import track_progress, get_asset_id from ..dependencies.rpc.factory import make_remote UnrealRemoteCalls = make_remote(UnrealCalls) +MANIFEST_SCHEMA_VERSION = 1 def _property_data_for_asset(asset_data, property_data): @@ -25,6 +28,43 @@ def _property_data_for_asset(asset_data, property_data): return adjusted +def build_manifest_items(properties): + """Serialize the existing extension/import contract for deferred ingest.""" + property_data = settings.get_extra_property_group_data_as_dictionary( + properties, + only_key='unreal_type', + ) + manifest_items = [] + window_properties = bpy.context.window_manager.send2ue + + for asset_id, asset_data in window_properties.asset_data.items(): + window_properties.asset_id = asset_id + + with unreal_dependency.record_commands() as pre_import_commands: + extension.run_extension_tasks(ExtensionTasks.PRE_IMPORT.value) + + adjusted_property_data = _property_data_for_asset(asset_data, property_data) + + with unreal_dependency.record_commands() as post_import_commands: + extension.run_extension_tasks(ExtensionTasks.POST_IMPORT.value) + + manifest_items.append({ + 'schema_version': MANIFEST_SCHEMA_VERSION, + 'asset_id': asset_id, + 'asset_data': copy.deepcopy(asset_data), + 'property_data': copy.deepcopy(adjusted_property_data), + 'pre_import_commands': copy.deepcopy(pre_import_commands), + 'post_import_commands': copy.deepcopy(post_import_commands), + 'operations': { + 'reset_and_import_lods': bool(asset_data.get('lods')), + 'create_static_mesh_sockets': bool(asset_data.get('sockets')), + }, + }) + + window_properties.asset_id = '' + return manifest_items + + @track_progress(message='Importing asset "{attribute}"...', attribute='file_path') def import_asset(asset_id, property_data): """ @@ -41,11 +81,15 @@ def import_asset(asset_id, property_data): if not asset_data.get('skip'): file_path = asset_data.get('file_path') - UnrealRemoteCalls.import_asset( + import_result = UnrealRemoteCalls.import_asset( file_path, asset_data, _property_data_for_asset(asset_data, property_data), ) + if asset_data.get('_ue_groom_adapter') and isinstance(import_result, dict): + groom_asset_path = import_result.get('groom_asset_path') + if groom_asset_path: + asset_data['asset_path'] = groom_asset_path # import fcurves if asset_data.get('fcurve_file_path'): diff --git a/src/addons/send2ue/core/ue_groom_adapter.py b/src/addons/send2ue/core/ue_groom_adapter.py new file mode 100644 index 00000000..703001c7 --- /dev/null +++ b/src/addons/send2ue/core/ue_groom_adapter.py @@ -0,0 +1,1301 @@ +# Copyright Epic Games, Inc. All Rights Reserved. + +"""Hair Tool to Unreal Engine 5.8 Groom adapter. + +This module deliberately has no dependency on Hair Tool's Python package. It +only reads the evaluated Blender Curves geometry and its named attributes. +""" + +import math +import os +import re + +import bpy + + +OUTPUT_CARDS = 'CARDS' +OUTPUT_GROOM = 'GROOM' +OUTPUT_BOTH = 'BOTH' + +PRESET_CARD_RIG = 'CARD_RIG' +PRESET_UE_RIGGED_GUIDES = 'UE_RIGGED_GUIDES' + +EXTENSION_NAME = 'ue_groom_adapter' + +AUTO_ATTRIBUTES = { + 'id': ('groom_id', 'id'), + 'group_id': ('groom_group_id', 'group_id', 'curve_group_id_ht'), + 'root_uv': ('groom_root_uv', 'UVMap', 'surface_uv_coordinate', 'root_uv'), + 'guide': ('groom_guide', 'GUIDE', 'guide'), + 'parent_id': ('groom_parent_id', 'ParentID', 'parent_id'), + 'width': ('groom_width', 'width', 'radius'), + # groom_debug_color is intentionally excluded: it is a viewport diagnostic + # channel (group/id/root-UV modes), not authored hair color. + 'color': ('groom_color', 'HS_BaseColor', 'color'), + 'roughness': ('groom_roughness', 'roughness'), + 'ao': ('groom_ao', 'AO', 'ao'), + 'clump_id': ('groom_clump_id', 'ClumpID', 'clump_id'), + 'factor': ('groom_factor', 'Factor', 'factor'), + 'random': ('groom_random', 'Random', 'random'), + 'roundness': ('groom_roundness', 'roundness'), +} + +DEFAULT_UNREAL_GROOM_MATERIAL = '/Game/Material/Groom/M_UE_Groom_HairTool_Preview' +DEFAULT_UNREAL_GROOM_MATERIAL_SOURCE = '/HairStrands/Materials/HairDefaultMaterial' + +CYCLES_VIEW_FAST = 'FAST' +CYCLES_VIEW_BALANCED = 'BALANCED' +CYCLES_VIEW_QUALITY = 'QUALITY' + +CYCLES_VIEW_PRESETS = { + CYCLES_VIEW_FAST: { + 'preview_samples': 1, + 'use_preview_denoising': False, + 'preview_adaptive_threshold': 0.5, + 'preview_scrambling_distance': True, + }, + CYCLES_VIEW_BALANCED: { + 'preview_samples': 8, + 'use_preview_denoising': True, + 'preview_adaptive_threshold': 0.1, + 'preview_scrambling_distance': False, + }, + CYCLES_VIEW_QUALITY: { + 'preview_samples': 32, + 'use_preview_denoising': True, + 'preview_adaptive_threshold': 0.02, + 'preview_scrambling_distance': False, + }, +} + + +def get_settings(properties=None): + """Return the extension settings, or ``None`` before registration.""" + properties = properties or getattr(bpy.context.scene, 'send2ue', None) + extensions = getattr(properties, 'extensions', None) + return getattr(extensions, EXTENSION_NAME, None) + + +def is_enabled(properties=None): + adapter = get_settings(properties) + return bool(adapter and adapter.enabled) + + +def get_output_mode(properties=None): + adapter = get_settings(properties) + return getattr(adapter, 'output_mode', OUTPUT_CARDS) if adapter else OUTPUT_CARDS + + +def wants_cards(properties=None): + if not is_enabled(properties): + return True + return get_output_mode(properties) in {OUTPUT_CARDS, OUTPUT_BOTH} + + +def wants_groom(properties=None): + if not is_enabled(properties): + return False + return get_output_mode(properties) in {OUTPUT_GROOM, OUTPUT_BOTH} + + +def is_hair_tool_groom(scene_object, properties=None): + if not wants_groom(properties) or not scene_object or scene_object.type != 'CURVES': + return False + from . import hair_tool_export + return hair_tool_export.is_hair_tool_object(scene_object) + + +def get_hair_tool_grooms(properties=None): + from . import utilities + return [ + scene_object + for scene_object in utilities.get_from_collection('CURVES') + if is_hair_tool_groom(scene_object, properties) + ] + + +def _attribute_value(item, data_type): + if data_type in {'FLOAT_VECTOR', 'FLOAT2', 'FLOAT_COLOR', 'BYTE_COLOR'}: + property_name = 'color' if 'COLOR' in data_type else 'vector' + return tuple(float(value) for value in getattr(item, property_name)) + value = getattr(item, 'value') + if data_type in {'INT', 'INT8', 'BOOLEAN'}: + return int(value) + return float(value) + + +def _read_attribute(attribute): + return [_attribute_value(item, attribute.data_type) for item in attribute.data] + + +def _find_attribute(curves, setting_name, kind): + attributes = {attribute.name.casefold(): attribute for attribute in curves.attributes} + requested = str(getattr(setting_name, kind + '_attribute', '') or '').strip() + candidates = (requested,) if requested else AUTO_ATTRIBUTES[kind] + matches = [attributes.get(name.casefold()) for name in candidates] + matches = [attribute for attribute in matches if attribute] + + # Unreal requires an actual float2 Root UV. Prefer a Blender FLOAT2 source + # during auto mapping, but still permit an explicit 2/3 component override. + if kind == 'root_uv' and not requested: + float2_match = next( + (attribute for attribute in matches if attribute.data_type == 'FLOAT2'), + None, + ) + if float2_match: + return float2_match + return matches[0] if matches else None + + +def _modifier_input(modifier, socket_name): + """Read a Geometry Nodes modifier input by its stable interface label.""" + node_group = getattr(modifier, 'node_group', None) + interface = getattr(node_group, 'interface', None) + if not interface: + return None + for item in interface.items_tree: + if ( + getattr(item, 'item_type', None) == 'SOCKET' + and getattr(item, 'in_out', None) == 'INPUT' + and item.name.casefold() == socket_name.casefold() + ): + return modifier.get(item.identifier, getattr(item, 'default_value', None)) + return None + + +def _preview_settings(scene_object, adapter): + """Read the optional Blender preview node/material values used for UE parity.""" + preview_modifier = None + required_inputs = {'hair width (cm)', 'root scale', 'tip scale'} + for modifier in scene_object.modifiers: + node_group = getattr(modifier, 'node_group', None) + interface = getattr(node_group, 'interface', None) + if not interface: + continue + input_names = { + item.name.casefold() + for item in interface.items_tree + if getattr(item, 'item_type', None) == 'SOCKET' + and getattr(item, 'in_out', None) == 'INPUT' + } + if required_inputs.issubset(input_names): + preview_modifier = modifier + break + + if not preview_modifier: + return None + + preview_material = _modifier_input(preview_modifier, 'Preview Material') + color = _modifier_input(preview_modifier, 'Hair Color') + roughness = None + if preview_material and preview_material.use_nodes: + hair_node = next( + ( + node for node in preview_material.node_tree.nodes + if node.bl_idname == 'ShaderNodeBsdfHairPrincipled' + ), + None, + ) + roughness_input = hair_node.inputs.get('Roughness') if hair_node else None + if roughness_input: + roughness = float(roughness_input.default_value) + + explicit_material_path = '' + if preview_material: + explicit_material_path = str(preview_material.get('unreal_groom_material_path', '') or '') + unreal_material_path = ( + explicit_material_path + or str(getattr(adapter, 'unreal_material_path', '') or '') + or DEFAULT_UNREAL_GROOM_MATERIAL + ) + + return { + 'modifier': preview_modifier.name, + 'material_name': preview_material.name if preview_material else None, + 'unreal_material_path': unreal_material_path, + 'groom_id_offset': int(round(float( + _modifier_input(preview_modifier, 'Groom ID Offset') or 0 + ))), + 'hair_width_cm': float(_modifier_input(preview_modifier, 'Hair Width (cm)') or 0.01), + 'root_scale': float(_modifier_input(preview_modifier, 'Root Scale') or 1.0), + 'tip_scale': float(_modifier_input(preview_modifier, 'Tip Scale') or 1.0), + 'color': tuple(float(component) for component in color[:3]) if color else None, + 'roughness': roughness, + } + + +def configure_cycles_groom_view(context, preset=CYCLES_VIEW_FAST): + """Apply a Curves-only Cycles viewport preset without changing final render samples.""" + preset = preset if preset in CYCLES_VIEW_PRESETS else CYCLES_VIEW_FAST + values = CYCLES_VIEW_PRESETS[preset] + scene = context.scene + scene.render.engine = 'CYCLES' + + cycles = scene.cycles + for property_name, value in values.items(): + if hasattr(cycles, property_name): + setattr(cycles, property_name, value) + + viewport_count = 0 + window_manager = getattr(context, 'window_manager', None) + for window in getattr(window_manager, 'windows', ()): + for area in window.screen.areas: + if area.type == 'VIEW_3D': + area.spaces.active.shading.type = 'RENDERED' + viewport_count += 1 + + return { + 'preset': preset, + 'preview_samples': int(cycles.preview_samples), + 'use_preview_denoising': bool(cycles.use_preview_denoising), + 'preview_adaptive_threshold': float(cycles.preview_adaptive_threshold), + 'preview_scrambling_distance': bool(cycles.preview_scrambling_distance), + 'render_samples': int(cycles.samples), + 'device': str(cycles.device), + 'viewport_count': viewport_count, + } + + +def _curve_values(attribute, starts, counts, report_variation=False): + if not attribute: + return None, False + + values = _read_attribute(attribute) + varied = False + if attribute.domain == 'CURVE': + return values, varied + if attribute.domain == 'POINT': + roots = [] + for start, count in zip(starts, counts): + curve_values = values[start:start + count] + roots.append(curve_values[0]) + if report_variation and any(value != curve_values[0] for value in curve_values[1:]): + varied = True + return roots, varied + if attribute.domain in {'CONSTANT', 'INSTANCE'} and values: + return [values[0]] * len(counts), varied + return None, varied + + +def _point_values(attribute, starts, counts): + if not attribute: + return None + values = _read_attribute(attribute) + if attribute.domain == 'POINT': + return values + if attribute.domain == 'CURVE': + expanded = [] + for value, count in zip(values, counts): + expanded.extend([value] * count) + return expanded + if attribute.domain in {'CONSTANT', 'INSTANCE'} and values: + return [values[0]] * sum(counts) + return None + + +HAIR_TOOL_SHADER_INPUTS = { + 'Base Color', + 'Root Color', + 'Root Color Mix Factor', + 'Root Color Range', + 'Tip Color', + 'Tip Color Mix Factor', + 'Tip Color Range', + 'Debug Color Mix', + 'Factor [Map]', +} + + +def _find_hair_tool_shader(scene_object): + """Find Hair Tool's authored HairShaderMain material node, read-only.""" + candidates = [] + for modifier in scene_object.modifiers: + material = _modifier_input(modifier, 'Strands Material') + if isinstance(material, bpy.types.Material): + candidates.append(material) + candidates.extend(material for material in scene_object.data.materials if material) + candidates.extend(bpy.data.materials) + + # Adapter preview materials contain the same node group, but they are only + # driven mirrors. Resolve their recorded Hair Tool source first and never + # select the mirror itself as the authored material. + source_candidates = [] + for material in candidates: + source_name = str(material.get('hair_tool_source_material', '') or '') + source_material = bpy.data.materials.get(source_name) + if source_material: + source_candidates.append(source_material) + candidates = source_candidates + candidates + + seen = set() + for material in candidates: + if ( + material in seen + or not material.use_nodes + or material.get('unreal_groom_material_path') + or material.get('hair_tool_source_material') + ): + continue + seen.add(material) + for node in material.node_tree.nodes: + if node.bl_idname != 'ShaderNodeGroup' or not node.node_tree: + continue + input_names = {socket.name for socket in node.inputs} + if HAIR_TOOL_SHADER_INPUTS.issubset(input_names): + return material, node + return None, None + + +def _socket_value(node, name, fallback=None): + socket = node.inputs.get(name) if node else None + if not socket: + return fallback + value = socket.default_value + if hasattr(value, '__len__') and not isinstance(value, str): + return tuple(float(component) for component in value) + return float(value) + + +def _hair_tool_color_settings(scene_object): + material, node = _find_hair_tool_shader(scene_object) + if not node: + return None + return { + 'material': material.name, + 'node': node.name, + 'node_group': node.node_tree.name, + 'base_color': _socket_value(node, 'Base Color', (0.8, 0.8, 0.8, 1.0))[:3], + 'root_color': _socket_value(node, 'Root Color', (0.0, 0.0, 0.0, 1.0))[:3], + 'root_mix': _socket_value(node, 'Root Color Mix Factor', 0.0), + 'root_range': _socket_value(node, 'Root Color Range', 0.0), + 'root_texture_overlay': _socket_value(node, 'Root Texture Overaly', 0.0), + 'root_texture_brightness': _socket_value(node, 'Root Texture Brightness', 0.0), + 'tip_color': _socket_value(node, 'Tip Color', (0.0, 0.0, 0.0, 1.0))[:3], + 'tip_mix': _socket_value(node, 'Tip Color Mix Factor', 0.0), + 'tip_range': _socket_value(node, 'Tip Color Range', 0.0), + 'tip_texture_overlay': _socket_value(node, 'Tip Texture Overlay', 0.0), + 'tip_texture_brightness': _socket_value(node, 'Tip Texture Brightness', 0.0), + 'system_color_mix': _socket_value(node, 'Debug Color Mix', 0.0), + 'roughness': _socket_value(node, 'SpecRoughness', None), + 'texture_inputs': tuple( + socket.name for socket in node.inputs + if socket.is_linked and socket.name in { + 'Depth [Map]', 'Alpha [Map]', 'Random Id [Map]', 'Vert Color AO [Map]' + } + ), + } + + +def _hair_tool_profile_settings(scene_object): + """Read Hair Tool's card-profile settings without editing its node groups.""" + profile_group_names = { + 'Curve_Profile_UV', 'Flat_Profile_UV', 'Circle_Profile_UV', + 'Curls_Profile_UV', 'Multi_Curve_Profile_UV', 'Mesh_Profile', + 'No_Profile', 'Round_Profile', + } + for modifier in scene_object.modifiers: + node_group = getattr(modifier, 'node_group', None) + if not node_group: + continue + for node in node_group.nodes: + nested = getattr(node, 'node_tree', None) + if not nested or nested.name not in profile_group_names: + continue + values = {} + for name in ( + 'Width', 'Roundness', 'Influence Roundness', 'Uplift', 'Bow Up', + 'Taper UV by Strand Radius', 'Use UV Tiling', 'Profile Res', + 'Resolution', + ): + socket = node.inputs.get(name) + if socket: + value = socket.default_value + values[name] = ( + tuple(value) if hasattr(value, '__len__') and not isinstance(value, str) + else value + ) + return { + 'modifier': modifier.name, + 'modifier_enabled': bool(modifier.show_viewport), + 'node': node.name, + 'profile_type': nested.name, + 'settings': values, + 'groom_mapping': ( + 'Card profile cross-section is preserved for Cards; ' + 'Groom uses calibrated Hair Width multiplied by evaluated radius.' + ), + } + return None + + +def _drive_socket(target_tree, target_socket, source_tree, source_socket): + """Drive one external preview socket from a Hair Tool material socket.""" + source_path = source_socket.path_from_id('default_value') + value = source_socket.default_value + indices = range(len(value)) if hasattr(value, '__len__') and not isinstance(value, str) else (None,) + for index in indices: + try: + if index is None: + target_socket.driver_remove('default_value') + fcurve = target_socket.driver_add('default_value') + else: + target_socket.driver_remove('default_value', index) + fcurve = target_socket.driver_add('default_value', index) + except (TypeError, RuntimeError): + if index is None: + fcurve = target_socket.driver_add('default_value') + else: + fcurve = target_socket.driver_add('default_value', index) + driver = fcurve.driver + driver.type = 'SCRIPTED' + variable = driver.variables.new() + variable.name = 'value' + variable.type = 'SINGLE_PROP' + variable.targets[0].id_type = 'NODETREE' + variable.targets[0].id = source_tree + variable.targets[0].data_path = source_path + (f'[{index}]' if index is not None else '') + driver.expression = 'value' + + +def connect_hair_tool_preview_material(scene_object, properties=None): + """Connect the external Cycles preview to Hair Tool's authored shader rule.""" + adapter = get_settings(properties) + preview = _preview_settings(scene_object, adapter) + if not preview: + raise RuntimeError(f'{scene_object.name}: UE Groom preview modifier was not found.') + modifier = scene_object.modifiers.get(preview['modifier']) + target_material = _modifier_input(modifier, 'Preview Material') + if not target_material or not target_material.use_nodes: + raise RuntimeError(f'{scene_object.name}: UE Groom preview material was not found.') + + source_material, source_node = _find_hair_tool_shader(scene_object) + if not source_node: + raise RuntimeError(f'{scene_object.name}: Hair Tool HairShaderMain material was not found.') + if target_material == source_material: + raise RuntimeError('UE Groom preview material must remain separate from Hair Tool material.') + + tree = target_material.node_tree + output = next((node for node in tree.nodes if node.bl_idname == 'ShaderNodeOutputMaterial'), None) + if not output: + output = tree.nodes.new('ShaderNodeOutputMaterial') + output.name = 'Material Output' + + keep_names = {'Material Output', 'Hair Tool Color Bridge', 'Hair Tool Factor', 'Hair Tool SystemColor'} + for node in list(tree.nodes): + if node.name not in keep_names and node != output: + tree.nodes.remove(node) + + bridge = tree.nodes.get('Hair Tool Color Bridge') + if not bridge: + bridge = tree.nodes.new('ShaderNodeGroup') + bridge.name = 'Hair Tool Color Bridge' + bridge.node_tree = source_node.node_tree + + factor_node = tree.nodes.get('Hair Tool Factor') + if not factor_node: + factor_node = tree.nodes.new('ShaderNodeAttribute') + factor_node.name = 'Hair Tool Factor' + factor_node.attribute_name = 'Factor' + + system_node = tree.nodes.get('Hair Tool SystemColor') + if not system_node: + system_node = tree.nodes.new('ShaderNodeAttribute') + system_node.name = 'Hair Tool SystemColor' + system_node.attribute_name = 'SystemColor' + + for socket in bridge.inputs: + for link in list(socket.links): + tree.links.remove(link) + + tree.links.new(factor_node.outputs['Fac'], bridge.inputs['Factor [Map]']) + tree.links.new(system_node.outputs['Color'], bridge.inputs['Debug Color']) + for link in list(output.inputs['Surface'].links): + tree.links.remove(link) + tree.links.new(bridge.outputs['Cycles'], output.inputs['Surface']) + + excluded = { + 'Factor [Map]', 'Debug Color', 'Depth [Map]', 'Alpha [Map]', + 'Random Id [Map]', 'Vert Color AO [Map]', 'Normal', 'UV', + } + driven = [] + for source_socket in source_node.inputs: + target_socket = bridge.inputs.get(source_socket.name) + if not target_socket or source_socket.name in excluded: + continue + try: + target_socket.default_value = source_socket.default_value + _drive_socket(tree, target_socket, source_material.node_tree, source_socket) + driven.append(source_socket.name) + except (AttributeError, TypeError, ValueError, RuntimeError): + continue + + target_material['hair_tool_source_material'] = source_material.name + target_material['hair_tool_source_node'] = source_node.name + target_material['unreal_groom_material_path'] = DEFAULT_UNREAL_GROOM_MATERIAL + target_material.update_tag() + scene_object.update_tag() + bpy.context.view_layer.update() + return { + 'object': scene_object.name, + 'preview_material': target_material.name, + 'source_material': source_material.name, + 'source_node': source_node.name, + 'source_group': source_node.node_tree.name, + 'driven_inputs': driven, + } + + +def connect_hair_tool_radius_preview(scene_object, properties=None): + """Multiply the calibrated Groom radius by Hair Tool's evaluated radius.""" + adapter = get_settings(properties) + preview = _preview_settings(scene_object, adapter) + if not preview: + raise RuntimeError(f'{scene_object.name}: UE Groom preview modifier was not found.') + modifier = scene_object.modifiers.get(preview['modifier']) + node_group = getattr(modifier, 'node_group', None) + if not node_group or node_group.name.startswith('Hair_System_'): + raise RuntimeError('Hair Tool node groups are read-only for the UE Groom Adapter.') + + taper = node_group.nodes.get('Root to Tip Taper') + set_radius = node_group.nodes.get('Set Unreal Width') + if not taper or not set_radius: + raise RuntimeError('UE Groom preview radius nodes were not found.') + + hair_tool_radius = node_group.nodes.get('Hair Tool Radius') + if not hair_tool_radius: + hair_tool_radius = node_group.nodes.new('GeometryNodeInputRadius') + hair_tool_radius.name = 'Hair Tool Radius' + hair_tool_radius.label = 'Hair Tool evaluated radius' + + multiply = node_group.nodes.get('Apply Hair Tool Radius') + if not multiply: + multiply = node_group.nodes.new('ShaderNodeMath') + multiply.name = 'Apply Hair Tool Radius' + multiply.label = 'Groom radius × Hair Tool radius' + multiply.operation = 'MULTIPLY' + + _replace_node_input_link( + node_group, taper.outputs['Result'], multiply.inputs[0] + ) + _replace_node_input_link( + node_group, hair_tool_radius.outputs['Radius'], multiply.inputs[1] + ) + _replace_node_input_link( + node_group, multiply.outputs['Value'], set_radius.inputs['Radius'] + ) + + node_group.update_tag() + scene_object.update_tag() + bpy.context.view_layer.update() + return { + 'object': scene_object.name, + 'modifier': modifier.name, + 'node_group': node_group.name, + 'radius_source': 'Hair Tool evaluated radius', + 'calibration': 'Hair Width (cm) × Root/Tip Scale', + } + + +def connect_hair_tool_guide_rules(scene_object): + """Restore Hair Tool's GUIDE tag on the adapter-owned Generator copy. + + Hair Tool node groups remain read-only. The adapter only synchronizes the + local ``*_UEGroomPreview`` copy that was created for this Blender file. + """ + source_group = bpy.data.node_groups.get('Hair_System_Main') + source_node = source_group.nodes.get('Parent Tag') if source_group else None + source_socket = source_node.inputs.get('Tag') if source_node else None + source_tag = str(source_socket.default_value) if source_socket else 'GUIDE' + if not source_tag: + source_tag = 'GUIDE' + + connected = [] + for modifier in scene_object.modifiers: + node_group = getattr(modifier, 'node_group', None) + if not node_group or not node_group.name.endswith('_UEGroomPreview'): + continue + parent_tag = node_group.nodes.get('Parent Tag') + tag_socket = parent_tag.inputs.get('Tag') if parent_tag else None + if not tag_socket: + continue + tag_socket.default_value = source_tag + node_group['ue_groom_adapter_owned'] = True + node_group.update_tag() + connected.append({ + 'modifier': modifier.name, + 'node_group': node_group.name, + 'parent_tag': source_tag, + }) + + scene_object.update_tag(refresh={'DATA'}) + bpy.context.view_layer.update() + return {'object': scene_object.name, 'connected': connected} + + +def _replace_node_input_link(node_group, output_socket, input_socket): + for link in list(input_socket.links): + node_group.links.remove(link) + node_group.links.new(output_socket, input_socket) + + +def _clamp01(value): + return max(0.0, min(1.0, float(value))) + + +def _lerp(a, b, factor): + return (1.0 - factor) * a + factor * b + + +def _map_range_clamped(value, from_min, from_max, to_min, to_max): + if abs(from_max - from_min) <= 1e-12: + return to_max + factor = _clamp01((value - from_min) / (from_max - from_min)) + return _lerp(to_min, to_max, factor) + + +def _bake_hair_tool_colors(curves, starts, counts, settings): + """Bake HairShaderMain's Base→Root→Tip→SystemColor rule per point.""" + by_name = {attribute.name.casefold(): attribute for attribute in curves.attributes} + factor_attribute = by_name.get('factor') + factors = _point_values(factor_attribute, starts, counts) + if factors is None: + factors = [] + for count in counts: + denominator = max(count - 1, 1) + factors.extend(index / denominator for index in range(count)) + factors = [float(value) for value in factors] + + system_attribute = by_name.get('systemcolor') + system_colors = _point_values(system_attribute, starts, counts) + if system_colors is None: + system_colors = [(0.0, 0.0, 0.0)] * sum(counts) + + random_attribute = by_name.get('random') + random_values = _point_values(random_attribute, starts, counts) + if random_values is None: + random_values = [0.0] * sum(counts) + + root_range = _clamp01(settings['root_range']) + tip_range = _clamp01(settings['tip_range']) + colors = [] + for factor, system_color, random_value in zip(factors, system_colors, random_values): + if isinstance(random_value, (tuple, list)): + random_value = random_value[0] + random_value = float(random_value) + + root_extent = _lerp( + 1.0, + random_value + settings['root_texture_brightness'], + settings['root_texture_overlay'], + ) + root_weight = _clamp01( + _map_range_clamped(factor, 0.0, root_range, root_extent, 0.0) + * settings['root_mix'] + ) + color = tuple( + _lerp(settings['base_color'][channel], settings['root_color'][channel], root_weight) + for channel in range(3) + ) + + tip_extent = _lerp( + 1.0, + random_value + settings['tip_texture_brightness'], + settings['tip_texture_overlay'], + ) + tip_weight = _clamp01( + _map_range_clamped(factor, 1.0 - tip_range, 1.0, 0.0, tip_extent) + * settings['tip_mix'] + ) + color = tuple( + _lerp(color[channel], settings['tip_color'][channel], tip_weight) + for channel in range(3) + ) + + system_color = tuple(float(component) for component in system_color[:3]) + colors.append(tuple( + color[channel] + system_color[channel] * settings['system_color_mix'] + for channel in range(3) + )) + return colors, factor_attribute is not None, system_attribute is not None + + +def _mapping_error(report, adapter, kind, attribute): + requested = str(getattr(adapter, kind + '_attribute', '') or '').strip() + if requested and not attribute: + report['errors'].append( + f'지정한 {kind} 속성 "{requested}"을(를) 찾을 수 없습니다.' + ) + + +def _guide_source_object(scene_object): + for modifier in scene_object.modifiers: + guide_object = _modifier_input(modifier, 'Curve Guide') + if isinstance(guide_object, bpy.types.Object): + return guide_object + return None + + +def _guide_source_geometry(scene_object): + """Return actual guide counts and world-space points from the Hair Tool input.""" + guide_object = _guide_source_object(scene_object) + if not guide_object: + return None + evaluated = guide_object.evaluated_get(bpy.context.evaluated_depsgraph_get()) + world_matrix = evaluated.matrix_world.copy() + if evaluated.type == 'CURVES': + counts = [int(curve.points_length) for curve in evaluated.data.curves] + points = [ + tuple(float(value) for value in (world_matrix @ point.position)) + for point in evaluated.data.points + ] + elif evaluated.type == 'CURVE': + counts = [] + points = [] + for spline in evaluated.data.splines: + spline_points = spline.bezier_points if spline.type == 'BEZIER' else spline.points + counts.append(len(spline_points)) + for point in spline_points: + position = point.co if spline.type == 'BEZIER' else point.co.xyz + points.append(tuple(float(value) for value in (world_matrix @ position))) + else: + return None + if not counts or any(count < 2 for count in counts): + return None + return {'object': guide_object.name, 'counts': counts, 'points': points} + + +def _guide_source_info(scene_object): + """Find the actual Hair Tool Curve Guide object connected to a GN modifier.""" + geometry = _guide_source_geometry(scene_object) + if not geometry: + return None + return { + 'object': geometry['object'], + 'curve_count': len(geometry['counts']), + 'point_count': len(geometry['points']), + } + + +def inspect_object(scene_object, properties=None): + """Collect and validate one evaluated Hair Tool Curves object.""" + adapter = get_settings(properties) + report = { + 'object': scene_object.name, + 'errors': [], + 'warnings': [], + 'mappings': {}, + } + if not adapter: + report['errors'].append('UE Groom Adapter 설정이 등록되지 않았습니다.') + return report + + evaluated_object = scene_object.evaluated_get(bpy.context.evaluated_depsgraph_get()) + curves = evaluated_object.data + if evaluated_object.type != 'CURVES' or not hasattr(curves, 'curves'): + report['errors'].append('평가 결과가 Blender Curves가 아닙니다.') + return report + + counts = [int(curve.points_length) for curve in curves.curves] + starts = [int(curve.first_point_index) for curve in curves.curves] + point_count = len(curves.points) + curve_count = len(curves.curves) + report['curve_count'] = curve_count + report['point_count'] = point_count + report['guide_source'] = _guide_source_info(scene_object) + if not curve_count or not point_count: + report['errors'].append('내보낼 커브 또는 포인트가 없습니다.') + return report + if sum(counts) != point_count: + report['errors'].append('커브별 포인트 수 합계가 전체 포인트 수와 다릅니다.') + if any(count < 2 for count in counts): + report['errors'].append('포인트가 2개 미만인 커브가 있습니다.') + + attributes = {kind: _find_attribute(curves, adapter, kind) for kind in AUTO_ATTRIBUTES} + for kind, attribute in attributes.items(): + report['mappings'][kind] = attribute.name if attribute else None + _mapping_error(report, adapter, kind, attribute) + + preview = _preview_settings(scene_object, adapter) + report['preview'] = preview + if preview and not preview['unreal_material_path'].startswith('/Game/Material/'): + report['errors'].append('Unreal Groom Material path must be under /Game/Material/.') + + hair_tool_color = _hair_tool_color_settings(scene_object) + report['hair_tool_color'] = hair_tool_color + hair_tool_profile = _hair_tool_profile_settings(scene_object) + report['hair_tool_profile'] = hair_tool_profile + + ids, id_varied = _curve_values(attributes['id'], starts, counts, report_variation=True) + if ids is None: + ids = list(range(curve_count)) + report['warnings'].append('ID 속성이 없어 커브 순서로 groom_id를 생성합니다.') + elif ( + id_varied + and attributes['id'].name.casefold() == 'id' + and not str(getattr(adapter, 'id_attribute', '') or '').strip() + ): + # Blender/Hair Tool commonly exposes POINT-domain ``id`` as a point ID, + # not a strand ID. It is not valid Groom data, so auto mode generates a + # stable uniform ID instead of misinterpreting it. + ids = list(range(curve_count)) + id_varied = False + report['mappings']['id'] = 'generated curve order' + report['warnings'].append( + 'POINT id가 커브 안에서 변해 커브 순서로 groom_id를 생성합니다.' + ) + ids = [int(value) for value in ids] + if id_varied: + report['errors'].append('POINT 도메인 ID가 한 커브 안에서 변합니다.') + if len(set(ids)) != curve_count: + report['errors'].append('groom_id는 커브마다 고유해야 합니다.') + + group_ids, group_varied = _curve_values( + attributes['group_id'], starts, counts, report_variation=True + ) + if group_ids is None: + group_ids = [0] * curve_count + report['warnings'].append('Group ID 속성이 없어 모든 커브를 그룹 0으로 지정합니다.') + group_ids = [int(value) for value in group_ids] + if group_varied: + report['errors'].append('POINT 도메인 Group ID가 한 커브 안에서 변합니다.') + if any(value < 0 for value in group_ids): + report['errors'].append('groom_group_id에는 음수를 사용할 수 없습니다.') + + root_uvs, root_uv_varied = _curve_values( + attributes['root_uv'], starts, counts, report_variation=True + ) + if root_uvs is None: + report['errors'].append('Root UV 후보가 없습니다. UVMap 또는 명시적 FLOAT2 속성을 지정하세요.') + root_uvs = [(0.0, 0.0)] * curve_count + converted_root_uvs = [] + for value in root_uvs: + if not isinstance(value, (tuple, list)) or len(value) < 2: + report['errors'].append('Root UV 값은 최소 2성분이어야 합니다.') + converted_root_uvs.append((0.0, 0.0)) + else: + converted_root_uvs.append((float(value[0]), float(value[1]))) + root_uvs = converted_root_uvs + if root_uv_varied: + report['warnings'].append('POINT Root UV는 각 커브의 루트 값만 사용합니다.') + if any(not all(math.isfinite(component) for component in uv) for uv in root_uvs): + report['errors'].append('Root UV에 NaN 또는 무한대가 있습니다.') + + parent_ids, parent_varied = _curve_values( + attributes['parent_id'], starts, counts, report_variation=True + ) + if parent_ids is not None: + parent_ids = [int(value) for value in parent_ids] + if parent_varied: + report['errors'].append('POINT ParentID가 한 커브 안에서 변합니다.') + + # Hair Tool authors ParentID in its original zero-based ID space. When + # the adapter offsets groom_id for UE, ParentID must receive the same + # authored offset so both attributes remain in one reference space. + groom_id_offset = int(preview['groom_id_offset']) if preview else 0 + if ( + groom_id_offset + and attributes['id'] + and attributes['id'].name.casefold() == 'groom_id' + and attributes['parent_id'] + and attributes['parent_id'].name.casefold() == 'parentid' + ): + parent_ids = [ + value + groom_id_offset if value >= 0 else value + for value in parent_ids + ] + report['mappings']['parent_id'] = ( + f'{attributes["parent_id"].name} + Groom ID Offset ' + f'({groom_id_offset})' + ) + + guides, guide_varied = _curve_values( + attributes['guide'], starts, counts, report_variation=True + ) + if guides is not None: + guides = [int(bool(value)) for value in guides] + if guide_varied: + report['errors'].append('POINT GUIDE가 한 커브 안에서 변합니다.') + elif parent_ids and any(parent_id >= 0 for parent_id in parent_ids): + report['errors'].append( + 'ParentID 연결은 있지만 실제 GUIDE 속성이 없습니다. ' + '가이드를 추론하지 않고 Hair Tool 평가 데이터를 수정하세요.' + ) + + guide_source = report.get('guide_source') + if guide_source: + if guides is None: + report['errors'].append( + f'Curve Guide "{guide_source["object"]}"가 연결됐지만 평가 결과에 ' + 'GUIDE 속성이 없습니다. 외부 Generator 복제본의 Parent Tag를 확인하세요.' + ) + elif sum(guides) != int(guide_source['curve_count']): + report['errors'].append( + f'실제 Curve Guide 수({guide_source["curve_count"]})와 GUIDE 속성 수' + f'({sum(guides)})가 다릅니다.' + ) + if parent_ids is None or not any(parent_id >= 0 for parent_id in parent_ids): + report['errors'].append( + f'Curve Guide "{guide_source["object"]}"가 연결됐지만 ParentID 연결이 없습니다.' + ) + elif guides is not None: + unlinked_children = sum( + 1 + for guide, parent_id in zip(guides, parent_ids) + if not guide and parent_id < 0 + ) + if unlinked_children: + report['errors'].append( + f'실제 Curve Guide가 있지만 {unlinked_children}개 자식 곡선의 ' + 'ParentID가 연결되지 않았습니다.' + ) + + if parent_ids is not None: + id_to_index = {value: index for index, value in enumerate(ids)} + invalid_parent_ids = sorted({ + parent_id for parent_id in parent_ids + if parent_id >= 0 and parent_id not in id_to_index + }) + if invalid_parent_ids: + preview_ids = ', '.join(str(value) for value in invalid_parent_ids[:8]) + report['errors'].append(f'존재하지 않는 ParentID가 있습니다: {preview_ids}') + if guides: + bad_guide_parents = sorted({ + parent_id for parent_id in parent_ids + if parent_id >= 0 + and parent_id in id_to_index + and not guides[id_to_index[parent_id]] + }) + if bad_guide_parents: + preview_ids = ', '.join(str(value) for value in bad_guide_parents[:8]) + report['errors'].append(f'guide가 아닌 커브를 참조하는 ParentID가 있습니다: {preview_ids}') + if ( + all(parent_id < 0 for parent_id in parent_ids) + and getattr(adapter, 'deformation_preset', '') != PRESET_UE_RIGGED_GUIDES + ): + report['warnings'].append('ParentID가 모두 -1이라 guide-child 연결 정보가 없습니다.') + + # An all -1 ParentID array carries no relationship. Do not write it as + # Groom data when there is no authored GUIDE attribute. + if guides is None and parent_ids and all(parent_id < 0 for parent_id in parent_ids): + parent_ids = None + + width_attribute = attributes['width'] + widths = _point_values(width_attribute, starts, counts) + if widths is None: + default_width = max(float(adapter.default_width), 1e-8) + widths = [default_width] * point_count + report['warnings'].append('폭 속성이 없어 기본 Width를 사용합니다.') + else: + widths = [float(value) for value in widths] + if width_attribute.name.casefold() == 'radius': + widths = [value * 2.0 for value in widths] + + world_scale = evaluated_object.matrix_world.to_scale() + width_scale = sum(abs(float(value)) for value in world_scale) / 3.0 + widths = [value * width_scale for value in widths] + if any(not math.isfinite(value) or value <= 0.0 for value in widths): + report['errors'].append('Width에는 유한한 양수만 사용할 수 있습니다.') + + color_attribute = attributes['color'] + explicit_color = bool(str(getattr(adapter, 'color_attribute', '') or '').strip()) + if ( + color_attribute + and (explicit_color or color_attribute.name.casefold() == 'groom_color') + ): + colors = _point_values(color_attribute, starts, counts) + elif hair_tool_color: + colors, has_factor, has_system_color = _bake_hair_tool_colors( + curves, starts, counts, hair_tool_color + ) + report['mappings']['color'] = ( + f'{hair_tool_color["material"]}:{hair_tool_color["node"]} ' + '(Base→Root→Tip→SystemColor)' + ) + if not has_factor: + report['warnings'].append( + 'Hair Tool Factor 속성이 없어 커브 길이 기준 0~1 Factor를 사용합니다.' + ) + if hair_tool_color['system_color_mix'] and not has_system_color: + report['warnings'].append( + 'SystemColor 속성이 없어 Hair Tool 시스템 컬러 가산값은 0으로 사용합니다.' + ) + if hair_tool_color['texture_inputs']: + report['warnings'].append( + 'Hair Tool 카드 프로필 텍스처 입력은 네이티브 Groom에 직접 대응하지 않아 ' + 'Root/Tip/SystemColor 코어 규칙만 groom_color에 베이크합니다: ' + + ', '.join(hair_tool_color['texture_inputs']) + ) + else: + colors = _point_values(color_attribute, starts, counts) + if colors is None and preview and preview['color']: + colors = [preview['color']] * point_count + if colors is not None: + colors = [tuple(float(component) for component in value[:3]) for value in colors] + if any(not all(math.isfinite(component) and component >= 0.0 for component in value) for value in colors): + report['errors'].append('groom_color에는 유한한 0 이상의 RGB 값만 사용할 수 있습니다.') + + roughness = _point_values(attributes['roughness'], starts, counts) + if roughness is None and hair_tool_color and hair_tool_color['roughness'] is not None: + roughness = [hair_tool_color['roughness']] * point_count + report['mappings']['roughness'] = ( + f'{hair_tool_color["material"]}:{hair_tool_color["node"]}.SpecRoughness' + ) + elif roughness is None and preview and preview['roughness'] is not None: + roughness = [preview['roughness']] * point_count + if roughness is not None: + roughness = [float(value) for value in roughness] + if any(not math.isfinite(value) or value < 0.0 or value > 1.0 for value in roughness): + report['errors'].append('groom_roughness는 0~1 범위여야 합니다.') + + ao = _point_values(attributes['ao'], starts, counts) + if ao is not None: + ao = [float(value) for value in ao] + if any(not math.isfinite(value) or value < 0.0 or value > 1.0 for value in ao): + report['errors'].append('groom_ao는 0~1 범위여야 합니다.') + + clump_ids, clump_varied = _curve_values( + attributes['clump_id'], starts, counts, report_variation=True + ) + if clump_ids is not None: + clump_ids = [int(value) for value in clump_ids] + if clump_varied: + report['errors'].append('POINT Clump ID가 한 커브 안에서 변합니다.') + + factors = _point_values(attributes['factor'], starts, counts) + if factors is not None: + factors = [float(value) for value in factors] + if any(not math.isfinite(value) for value in factors): + report['errors'].append('Hair Tool Factor에 NaN 또는 무한대가 있습니다.') + + random_values = _point_values(attributes['random'], starts, counts) + if random_values is not None: + random_values = [ + float(value[0]) if isinstance(value, (tuple, list)) else float(value) + for value in random_values + ] + if any(not math.isfinite(value) for value in random_values): + report['errors'].append('Hair Tool Random에 NaN 또는 무한대가 있습니다.') + + roundness = _point_values(attributes['roundness'], starts, counts) + if roundness is not None: + roundness = [float(value) for value in roundness] + + attribute_names = {attribute.name.casefold() for attribute in curves.attributes} + report['hair_tool_features'] = { + 'id': 'groom_id / Unreal strand seed', + 'group_id': 'groom_group_id / Unreal Hair Group Index', + 'root_uv': 'FLOAT2 groom_root_uv / Unreal Root UV', + 'guide_parent': ( + 'GUIDE + ParentID' if guides is not None else + 'UE generated or rigged guides; ParentID is retained only when authored' + ), + 'factor': 'color rule + Unreal Hair U' if factors is not None else 'spline fallback', + 'system_color': ( + 'baked into groom_color' if 'systemcolor' in attribute_names else 'not authored' + ), + 'base_color': ( + 'HairShaderMain Base Color; HS_BaseColor fallback available' + if hair_tool_color else 'attribute fallback' + ), + 'random': 'color variation input' if random_values is not None else 'not authored', + 'ao': 'groom_ao' if ao is not None else 'not authored', + 'clump_id': 'groom_clump_id / Unreal Clump ID' if clump_ids is not None else 'not authored', + 'radius': 'vertex widths (diameter = radius × 2)', + 'roundness': ( + 'Cards only; native Groom has no matching per-strand cross-section channel' + if roundness is not None else 'not authored' + ), + 'profile_ids': ( + 'Profile_ID and UV_Box_ID stay in the Hair Tool card workflow' + if {'profile_id', 'uv_box_id'} & attribute_names else 'not authored' + ), + 'surface_frame': ( + 'ParentPos/ParentTan/ParentNorm/ParentData are Hair Tool internals; ' + 'Unreal derives Groom tangent data from the imported strands' + if {'parentpos', 'parenttan', 'parentnorm', 'parentdata'} & attribute_names + else 'not authored' + ), + 'profile': hair_tool_profile['groom_mapping'] if hair_tool_profile else 'not found', + } + + world_matrix = evaluated_object.matrix_world.copy() + points = [tuple(float(value) for value in (world_matrix @ point.position)) for point in curves.points] + if any(not all(math.isfinite(component) for component in point) for point in points): + report['errors'].append('포인트 위치에 NaN 또는 무한대가 있습니다.') + + report['data'] = { + 'counts': counts, + 'points': points, + 'widths': widths, + 'ids': ids, + 'group_ids': group_ids, + 'root_uvs': root_uvs, + 'guides': guides, + 'parent_ids': parent_ids, + 'colors': colors, + 'roughness': roughness, + 'ao': ao, + 'clump_ids': clump_ids, + 'factors': factors, + 'random': random_values, + 'roundness': roundness, + 'group_count': max(group_ids) + 1 if group_ids else 1, + } + return report + + +def validate_scene(properties=None): + objects = get_hair_tool_grooms(properties) + reports = [inspect_object(scene_object, properties) for scene_object in objects] + errors = [f'{report["object"]}: {message}' for report in reports for message in report['errors']] + warnings = [f'{report["object"]}: {message}' for report in reports for message in report['warnings']] + adapter = get_settings(properties) + if ( + adapter + and wants_groom(properties) + and getattr(adapter, 'deformation_preset', '') == PRESET_CARD_RIG + ): + warnings.append( + 'Card Rig (Bone)은 Hair Tool 카드 메시에만 적용됩니다. ' + '동시에 내보내는 Groom은 Unreal Generated Guides를 사용합니다.' + ) + if wants_groom(properties) and not objects: + errors.append('Export 컬렉션에서 Groom으로 보낼 Hair Tool CURVES를 찾지 못했습니다.') + return {'objects': reports, 'errors': errors, 'warnings': warnings} + + +def _usd_identifier(name): + identifier = re.sub(r'[^A-Za-z0-9_]', '_', name) + if not identifier or identifier[0].isdigit(): + identifier = '_' + identifier + return identifier + + +def _float(value): + value = float(value) + if not math.isfinite(value): + raise ValueError('USD에 NaN 또는 무한대를 기록할 수 없습니다.') + text = format(value, '.9g') + return text if any(character in text for character in '.eE') else text + '.0' + + +def _array(values, formatter, indent=' ', per_line=8): + formatted = [formatter(value) for value in values] + lines = [] + for index in range(0, len(formatted), per_line): + lines.append(indent + ', '.join(formatted[index:index + per_line])) + return '[\n' + ',\n'.join(lines) + '\n ]' + + +def _tuple2(value): + return f'({_float(value[0])}, {_float(value[1])})' + + +def _tuple3(value): + return f'({_float(value[0])}, {_float(value[1])}, {_float(value[2])})' + + +def _compensate_unreal_58_end_points(points, counts): + """Keep imported UE 5.8 strand tips at the Blender positions. + + UE 5.8's GroomBuilder appends a point 10 percent beyond each render + strand's final segment when triangle strips are enabled. Moving the final + exported control point inward by the inverse operation makes that appended + point land on Blender's original tip without changing the Blender scene. + """ + adjusted = list(points) + offset = 0 + for count in counts: + if count >= 2: + previous = points[offset + count - 2] + tip = points[offset + count - 1] + adjusted[offset + count - 1] = tuple( + (tip[axis] + 0.1 * previous[axis]) / 1.1 + for axis in range(3) + ) + offset += count + return adjusted + + +def write_usda(scene_object, file_path, properties=None): + """Write a static UE 5.8 Groom USD and return its validation report.""" + report = inspect_object(scene_object, properties) + if report['errors']: + raise RuntimeError('\n'.join(report['errors'])) + + data = report['data'] + export_points = _compensate_unreal_58_end_points(data['points'], data['counts']) + report['ue_58_end_point_compensation'] = True + prim_name = _usd_identifier(scene_object.name) + lines = [ + '#usda 1.0', + '(', + f' defaultPrim = "{prim_name}"', + ' metersPerUnit = 1', + ' upAxis = "Z"', + ')', + '', + f'def BasisCurves "{prim_name}" (', + ' prepend apiSchemas = ["GroomAPI"]', + ')', + '{', + ' uniform token type = "linear"', + ' uniform token wrap = "nonperiodic"', + ' int[] curveVertexCounts = ' + _array(data['counts'], str), + ' point3f[] points = ' + _array(export_points, _tuple3, per_line=3), + ' float[] widths = ' + _array(data['widths'], _float) + ' (', + ' interpolation = "vertex"', + ' )', + ' int[] primvars:groom_id = ' + _array(data['ids'], str) + ' (', + ' interpolation = "uniform"', + ' )', + ' int[] primvars:groom_group_id = ' + _array(data['group_ids'], str) + ' (', + ' interpolation = "uniform"', + ' )', + ' float2[] primvars:groom_root_uv = ' + _array(data['root_uvs'], _tuple2, per_line=4) + ' (', + ' interpolation = "uniform"', + ' )', + ] + if data['colors'] is not None: + lines.extend([ + ' float3[] primvars:groom_color = ' + _array(data['colors'], _tuple3, per_line=3) + ' (', + ' interpolation = "vertex"', + ' )', + ]) + if data['roughness'] is not None: + lines.extend([ + ' float[] primvars:groom_roughness = ' + _array(data['roughness'], _float) + ' (', + ' interpolation = "vertex"', + ' )', + ]) + if data['ao'] is not None: + lines.extend([ + ' float[] primvars:groom_ao = ' + _array(data['ao'], _float) + ' (', + ' interpolation = "vertex"', + ' )', + ]) + if data['clump_ids'] is not None: + lines.extend([ + ' int[] primvars:groom_clump_id = ' + _array(data['clump_ids'], str) + ' (', + ' interpolation = "uniform"', + ' )', + ]) + if data['guides'] is not None: + lines.extend([ + ' int[] primvars:groom_guide = ' + _array(data['guides'], str) + ' (', + ' interpolation = "uniform"', + ' )', + ]) + if data['parent_ids'] is not None: + lines.extend([ + ' int[] primvars:groom_parent_id = ' + _array(data['parent_ids'], str) + ' (', + ' interpolation = "uniform"', + ' )', + ]) + lines.extend(['}', '']) + + folder = os.path.dirname(os.path.abspath(file_path)) + os.makedirs(folder, exist_ok=True) + with open(file_path, 'w', encoding='utf-8', newline='\n') as usd_file: + usd_file.write('\n'.join(lines)) + return report diff --git a/src/addons/send2ue/core/utilities.py b/src/addons/send2ue/core/utilities.py index 28897528..b43316ab 100644 --- a/src/addons/send2ue/core/utilities.py +++ b/src/addons/send2ue/core/utilities.py @@ -402,16 +402,19 @@ def get_hair_objects(properties): modifiers = get_particle_system_modifiers(mesh_object) hair_objects.extend([modifier.particle_system for modifier in modifiers]) - # Live Hair Tool curve systems are converted to temporary mesh objects by - # hair_tool_export before validation. Do not also collect their source - # curves as Alembic Grooms. Ordinary curve Groom objects remain unchanged. - from . import hair_tool_export + # Hair Tool curves are normally consumed by the temporary card-mesh path. + # The UE Groom Adapter opts them into direct evaluated-Curves export without + # changing Hair Tool itself. Ordinary curve Groom objects remain unchanged. + from . import hair_tool_export, ue_groom_adapter hair_objects.extend([ curves_object for curves_object in get_from_collection(BlenderTypes.CURVES) if ( - not hair_tool_export.is_hair_tool_object(curves_object) - and not hair_tool_export.is_prepared_source(curves_object) + ue_groom_adapter.is_hair_tool_groom(curves_object, properties) + or ( + not hair_tool_export.is_hair_tool_object(curves_object) + and not hair_tool_export.is_prepared_source(curves_object) + ) ) ]) return hair_objects diff --git a/src/addons/send2ue/core/validations.py b/src/addons/send2ue/core/validations.py index 2001182a..853c94a3 100644 --- a/src/addons/send2ue/core/validations.py +++ b/src/addons/send2ue/core/validations.py @@ -3,7 +3,7 @@ import re import os import bpy -from . import utilities, formatting, extension +from . import utilities, formatting, extension, ue_groom_adapter from ..constants import BlenderTypes, PathModes, ToolInfo, Extensions, ExtensionTasks, RegexPresets from ..dependencies.unreal import UnrealRemoteCalls as UnrealCalls from ..dependencies.rpc.factory import make_remote @@ -307,10 +307,21 @@ def validate_required_unreal_plugins(self): """ if self.properties.validate_unreal_plugins and self.properties.import_grooms and self.hair_objects: # A dictionary of plugins where the key is the plugin name and value is the plugin label. - groom_plugins = { - 'HairStrands': 'Groom', - 'AlembicHairImporter': 'Alembic Groom Importer' - } + groom_plugins = {'HairStrands': 'Groom'} + if any( + isinstance(hair_object, bpy.types.Object) + and ue_groom_adapter.is_hair_tool_groom(hair_object, self.properties) + for hair_object in self.hair_objects + ): + groom_plugins['USDImporter'] = 'USD Importer' + if any( + not ( + isinstance(hair_object, bpy.types.Object) + and ue_groom_adapter.is_hair_tool_groom(hair_object, self.properties) + ) + for hair_object in self.hair_objects + ): + groom_plugins['AlembicHairImporter'] = 'Alembic Groom Importer' enabled_plugins = UnrealRemoteCalls.get_enabled_plugins() missing_plugins = [value for key, value in groom_plugins.items() if key not in enabled_plugins] plugin_names = ', '.join(missing_plugins) diff --git a/src/addons/send2ue/dependencies/unreal.py b/src/addons/send2ue/dependencies/unreal.py index 3b9d9aef..ca4b5748 100644 --- a/src/addons/send2ue/dependencies/unreal.py +++ b/src/addons/send2ue/dependencies/unreal.py @@ -5,6 +5,7 @@ import time import sys import inspect +from contextlib import contextmanager from xmlrpc.client import ProtocolError from http.client import RemoteDisconnected @@ -20,6 +21,20 @@ UNREAL_PORT = int(os.environ.get('UNREAL_PORT', 8998)) unreal_response = '' +_COMMAND_RECORDING_STACK = [] + + +@contextmanager +def record_commands(): + """Capture extension Unreal command groups without contacting an editor.""" + command_groups = [] + _COMMAND_RECORDING_STACK.append(command_groups) + try: + yield command_groups + finally: + popped = _COMMAND_RECORDING_STACK.pop() + if popped is not command_groups: + raise RuntimeError('Send2UE Unreal command recorder stack mismatch.') def get_response(): @@ -140,6 +155,10 @@ def run_commands(commands): :param list commands: A formatted string of python commands that will be run by unreal engine. :return str: The stdout produced by the remote python command. """ + if _COMMAND_RECORDING_STACK: + _COMMAND_RECORDING_STACK[-1].append(list(commands)) + return '' + from . import remote_execution # wrap the commands in a try except so that all exceptions can be logged in the output @@ -463,6 +482,97 @@ def create_binding_asset(groom_asset_path, mesh_asset_path): return binding_asset_path + @staticmethod + def configure_groom_from_blender_preview(groom_asset, asset_data): + """Apply Blender preview width/taper/material values to an imported Groom.""" + default_material_path = '/Game/Material/Groom/M_UE_Groom_HairTool_Preview' + material_path = asset_data.get( + '_ue_groom_material_path', + default_material_path, + ) + source_material_path = '/HairStrands/Materials/HairDefaultMaterial' + material = unreal.load_asset(material_path) + if not material: + material = unreal.EditorAssetLibrary.duplicate_asset( + source_material_path, + material_path, + ) + if not material: + raise RuntimeError( + f'Could not create Groom material {material_path} from {source_material_path}.' + ) + + # The default bridge material consumes the attributes exported by the + # Blender Hair Tool adapter. Never rebuild an explicitly selected user + # material: it may contain a hand-authored graph that Send to Unreal + # does not own. + if material_path == default_material_path: + graph_version = 'HairToolAttributes_v1' + version_tag = 'Send2UEGroomGraphVersion' + current_version = unreal.EditorAssetLibrary.get_metadata_tag( + material, + version_tag, + ) + if current_version != graph_version: + unreal.MaterialEditingLibrary.delete_all_material_expressions(material) + hair_attributes = unreal.MaterialEditingLibrary.create_material_expression( + material, + unreal.MaterialExpressionHairAttributes, + -360, + 0, + ) + hair_attributes.set_editor_property( + 'desc', + 'Hair Tool evaluated Base->Root->Tip->SystemColor, roughness and AO', + ) + for output_name, material_property in ( + ('BaseColor', unreal.MaterialProperty.MP_BASE_COLOR), + ('Roughness', unreal.MaterialProperty.MP_ROUGHNESS), + ('AO', unreal.MaterialProperty.MP_AMBIENT_OCCLUSION), + ): + unreal.MaterialEditingLibrary.connect_material_property( + hair_attributes, + output_name, + material_property, + ) + material.set_editor_property( + 'shading_model', + unreal.MaterialShadingModel.MSM_HAIR, + ) + material.set_editor_property('two_sided', True) + material.set_editor_property('used_with_hair_strands', True) + unreal.EditorAssetLibrary.set_metadata_tag( + material, + version_tag, + graph_version, + ) + unreal.MaterialEditingLibrary.recompile_material(material) + + slot_name = material_path.rsplit('/', 1)[-1] + material_group = unreal.HairGroupsMaterial() + material_group.set_editor_property('slot_name', slot_name) + material_group.set_editor_property('material', material) + groom_asset.set_editor_property('hair_groups_materials', [material_group]) + + hair_width = asset_data.get('_ue_groom_hair_width_cm') + root_scale = asset_data.get('_ue_groom_root_scale') + tip_scale = asset_data.get('_ue_groom_tip_scale') + rendering_groups = list(groom_asset.get_editor_property('hair_groups_rendering')) + for rendering_group in rendering_groups: + geometry = rendering_group.get_editor_property('geometry_settings') + if hair_width is not None: + geometry.set_editor_property('hair_width', float(hair_width)) + geometry.set_editor_property('hair_width_override', True) + if root_scale is not None: + geometry.set_editor_property('hair_root_scale', float(root_scale)) + if tip_scale is not None: + geometry.set_editor_property('hair_tip_scale', float(tip_scale)) + rendering_group.set_editor_property('geometry_settings', geometry) + groom_asset.set_editor_property('hair_groups_rendering', rendering_groups) + + unreal.EditorAssetLibrary.save_loaded_asset(material, only_if_is_dirty=False) + return material.get_path_name() + @staticmethod def create_blueprint_asset(blueprint_asset_path): """ @@ -867,6 +977,56 @@ def set_abc_import_task_options(self): # set the groom import options self.set_groom_import_options() + def set_usd_groom_import_task_options(self): + """Configure Unreal 5.8's USD Stage importer for a Groom asset.""" + self.set_import_task_options() + options = unreal.UsdStageImportOptions() + options.set_editor_property('import_actors', False) + options.set_editor_property('import_geometry', True) + options.set_editor_property('import_skeletal_animations', False) + options.set_editor_property('import_level_sequences', False) + options.set_editor_property('import_materials', False) + options.set_editor_property('import_groom_assets', True) + options.set_editor_property('prim_path_folder_structure', False) + + group_count = max(int(self._asset_data.get('_ue_groom_group_count', 1)), 1) + preset = self._asset_data.get('_ue_groom_deformation_preset') + interpolation_groups = [] + for _group_index in range(group_count): + group = unreal.HairGroupsInterpolation() + interpolation = group.get_editor_property('interpolation_settings') + if preset == 'UE_RIGGED_GUIDES': + interpolation.set_editor_property('guide_type', unreal.GroomGuideType.RIGGED) + interpolation.set_editor_property( + 'rigged_guide_num_curves', + int(self._asset_data.get('_ue_groom_rigged_guide_num_curves', 64)), + ) + interpolation.set_editor_property( + 'rigged_guide_num_points', + int(self._asset_data.get('_ue_groom_rigged_guide_num_points', 8)), + ) + else: + interpolation.set_editor_property('guide_type', unreal.GroomGuideType.GENERATED) + interpolation.set_editor_property('hair_to_guide_density', 0.1) + + # Low/Root avoids the expensive shape-matching build while keeping + # root proximity stable for the initial Blender-to-UE handoff. + interpolation.set_editor_property( + 'interpolation_quality', + unreal.HairInterpolationQuality.LOW, + ) + interpolation.set_editor_property( + 'interpolation_distance', + unreal.HairInterpolationWeight.ROOT, + ) + interpolation.set_editor_property('randomize_guide', False) + interpolation.set_editor_property('use_unique_guide', False) + group.set_editor_property('interpolation_settings', interpolation) + interpolation_groups.append(group) + + options.set_editor_property('groom_interpolation_settings', interpolation_groups) + self._options = options + def set_import_task_options(self): """ Sets common import options. @@ -1082,11 +1242,24 @@ def get_enabled_plugins(): :returns: Returns a list of missing plugins if any. :rtype: list[str] """ + # Query Unreal's live plugin manager first. Reading only the .uproject + # misses engine plugins that are enabled by default or pulled in as a + # dependency (notably HairStrands and USDImporter in UE 5.8). + plugin_library = getattr(unreal, 'PluginBlueprintLibrary', None) + if plugin_library: + return list(plugin_library.get_enabled_plugin_names()) + + # Compatibility fallback for older engine versions without the + # Blueprint plugin utility library. uproject_path = unreal.Paths.get_project_file_path() with open(uproject_path, 'r') as uproject: project_data = json.load(uproject) - return [plugin.get('Name') for plugin in project_data.get('Plugins', {}) if plugin.get('Enabled')] + return [ + plugin.get('Name') + for plugin in project_data.get('Plugins', []) + if plugin.get('Enabled') + ] @staticmethod def get_project_settings_value(config_name, section_name, setting_name): @@ -1274,9 +1447,28 @@ def import_asset(file_path, asset_data, property_data): unreal_import_asset.set_fbx_import_task_options() elif file_type.lower() == '.abc': unreal_import_asset.set_abc_import_task_options() + elif file_type.lower() in {'.usd', '.usda', '.usdc'} and asset_data.get('_ue_groom_adapter'): + unreal_import_asset.set_usd_groom_import_task_options() # run the import task - return unreal_import_asset.run_import() + imported_object_paths = unreal_import_asset.run_import() + if asset_data.get('_ue_groom_adapter'): + result = {'imported_object_paths': imported_object_paths} + for imported_object_path in imported_object_paths: + imported_asset = unreal.load_asset(imported_object_path) + if imported_asset and imported_asset.__class__.__name__ == 'GroomAsset': + result['groom_asset_path'] = imported_asset.get_outermost().get_name() + result['groom_material_path'] = Unreal.configure_groom_from_blender_preview( + imported_asset, + asset_data, + ) + unreal.EditorAssetLibrary.save_loaded_asset( + imported_asset, + only_if_is_dirty=False, + ) + break + return result + return imported_object_paths @staticmethod def create_asset(asset_path, asset_class=None, asset_factory=None, unique_name=True): diff --git a/src/addons/send2ue/operators.py b/src/addons/send2ue/operators.py index 614d4078..f2c01488 100644 --- a/src/addons/send2ue/operators.py +++ b/src/addons/send2ue/operators.py @@ -162,7 +162,7 @@ def pre_operation(self): extension.run_extension_tasks(ExtensionTasks.PRE_OPERATION.value) # Convert live Hair Tool systems to export-only mesh copies. These temporary - # objects carry a single RSAO color layer and optional head-bone skinning. + # objects carry a single RFAOS color layer and optional head-bone skinning. hair_tool_export.prepare() # Give meshes that live inside an armature but lack an armature modifier a diff --git a/src/addons/send2ue/resources/extensions/apply_groom_modifiers.py b/src/addons/send2ue/resources/extensions/apply_groom_modifiers.py index 5b166194..32b08c8f 100644 --- a/src/addons/send2ue/resources/extensions/apply_groom_modifiers.py +++ b/src/addons/send2ue/resources/extensions/apply_groom_modifiers.py @@ -1,5 +1,5 @@ import bpy -from send2ue.core import utilities +from send2ue.core import ue_groom_adapter, utilities from send2ue.constants import ToolInfo from send2ue.core.extension import ExtensionBase @@ -16,7 +16,20 @@ def get_temp_collection(collection_name="GroomTempCollection"): # make copies of original objects, link to temp collection def apply_groom_modifiers(): properties = bpy.context.scene.send2ue - hair_objects = utilities.get_hair_objects(properties) + hair_objects = [ + hair_object + for hair_object in utilities.get_hair_objects(properties) + if not ( + isinstance(hair_object, bpy.types.Object) + and ue_groom_adapter.is_hair_tool_groom(hair_object, properties) + ) + ] + # Adapter Grooms are evaluated non-destructively by the USD writer. Applying + # their Hair Tool and preview modifiers here can destroy the Curves component + # before export and leaves the user's object partially modified on failure. + if not hair_objects: + return + temp_collection = get_temp_collection() for hair_object in hair_objects: @@ -68,4 +81,4 @@ def pre_operation(self, properties): def post_operation(self, properties): if self.apply_groom_mods: - restore_groom_modifiers() \ No newline at end of file + restore_groom_modifiers() diff --git a/src/addons/send2ue/resources/extensions/create_post_import_assets_for_groom.py b/src/addons/send2ue/resources/extensions/create_post_import_assets_for_groom.py index c83f653a..1d4ae29a 100644 --- a/src/addons/send2ue/resources/extensions/create_post_import_assets_for_groom.py +++ b/src/addons/send2ue/resources/extensions/create_post_import_assets_for_groom.py @@ -4,7 +4,6 @@ from send2ue.core.extension import ExtensionBase from send2ue.core import utilities from send2ue.constants import UnrealTypes -from send2ue.dependencies.unreal import UnrealRemoteCalls as UnrealCalls from send2ue.dependencies.rpc.factory import make_remote @@ -55,9 +54,15 @@ def post_import(self, asset_data, properties): binding_asset_path = None # get the mesh asset data related to this groom asset data mesh_asset_data = utilities.get_related_mesh_asset_data_from_groom_asset_data(asset_data) + if mesh_asset_data.get('_asset_type') != UnrealTypes.SKELETAL_MESH: + return + groom_asset_path = asset_data.get('asset_path', '') mesh_asset_path = mesh_asset_data.get('asset_path', '') - UnrealRemoteCalls = make_remote(UnrealCalls) + # Resolve the Unreal dependency at call time so live add-on reloads do not + # retain an outdated remote-call class after the module changes. + from send2ue.dependencies import unreal as unreal_dependency + UnrealRemoteCalls = make_remote(unreal_dependency.UnrealRemoteCalls) if not UnrealRemoteCalls.asset_exists(groom_asset_path): return diff --git a/src/addons/send2ue/resources/extensions/send2ue_material_pipeline.py b/src/addons/send2ue/resources/extensions/send2ue_material_pipeline.py index 63474817..b6f8f05a 100644 --- a/src/addons/send2ue/resources/extensions/send2ue_material_pipeline.py +++ b/src/addons/send2ue/resources/extensions/send2ue_material_pipeline.py @@ -20,6 +20,7 @@ PIPELINE_DIR = BUNDLED_PIPELINE_DIR _TEXTURELESS_FBX_RESTORE = {} TEXTURELESS_FBX_EXPORT_FLAG = "send2ue_material_pipeline_textureless_fbx_export" +MATERIAL_PIPELINE_JSON_PATH_KEY = "_material_pipeline_json_path" class MaterialPipelineExtension(ExtensionBase): @@ -258,11 +259,14 @@ def pre_import(self, asset_data, properties): }: return asset_path = asset_data.get("asset_path", "") + asset_data.pop(MATERIAL_PIPELINE_JSON_PATH_KEY, None) json_path = self._resolve_json_path(asset_path) if not json_path: return + json_path = str(json_path).replace("\\", "/") + asset_data[MATERIAL_PIPELINE_JSON_PATH_KEY] = json_path - json_arg = f'r"{json_path}"' + json_arg = repr(json_path) commands = [ "import sys", "import importlib.util", @@ -300,8 +304,10 @@ def post_import(self, asset_data, properties): if not asset_path: return - json_path = self._resolve_json_path(asset_path) - json_arg = f'r"{json_path}"' if json_path else "None" + json_path = asset_data.get(MATERIAL_PIPELINE_JSON_PATH_KEY) + if not json_path: + return + json_arg = repr(str(json_path)) commands = [ "import sys", @@ -318,6 +324,9 @@ def post_import(self, asset_data, properties): "_spec.loader.exec_module(_p)", "def _sync_to_imported_asset(_path):", "\ttry:", + "\t\t_command_line = unreal.SystemLibrary.get_command_line().casefold()", + "\t\tif '-unattended' in _command_line or '-run=' in _command_line:", + "\t\t\treturn", "\t\tunreal.EditorAssetLibrary.sync_browser_to_objects([_path])", "\texcept Exception as _sync_error:", "\t\tunreal.log_warning('[material_pipeline] content browser sync failed: ' + str(_sync_error))", @@ -339,7 +348,7 @@ def _resolve_json_path(self, asset_path): except Exception as exc: print( "[material_pipeline] json_path resolve failed; " - f"falling back to pipeline search: {exc}" + f"automatic material pipeline skipped: {exc}" ) return None diff --git a/src/addons/send2ue/resources/extensions/ue_groom_adapter.py b/src/addons/send2ue/resources/extensions/ue_groom_adapter.py new file mode 100644 index 00000000..23ef844e --- /dev/null +++ b/src/addons/send2ue/resources/extensions/ue_groom_adapter.py @@ -0,0 +1,306 @@ +# Copyright Epic Games, Inc. All Rights Reserved. + +import bpy + +from send2ue.core import ue_groom_adapter, utilities +from send2ue.core.extension import ExtensionBase + + +class ValidateUeGroomData(bpy.types.Operator): + bl_idname = 'send2ue.validate_ue_groom_data' + bl_label = 'Validate Hair Tool Groom Data' + bl_description = 'Validate evaluated Hair Tool curve IDs, Root UVs, guides, parents, and widths' + + def execute(self, context): + if not ue_groom_adapter.is_enabled(context.scene.send2ue): + self.report({'INFO'}, 'UE Groom Adapter is disabled') + return {'CANCELLED'} + validation = ue_groom_adapter.validate_scene(context.scene.send2ue) + for warning in validation['warnings']: + print(f'[UE Groom Adapter] WARNING: {warning}') + if validation['errors']: + self.report({'ERROR'}, validation['errors'][0]) + for error in validation['errors'][1:]: + print(f'[UE Groom Adapter] ERROR: {error}') + return {'CANCELLED'} + + curve_count = sum(report.get('curve_count', 0) for report in validation['objects']) + self.report( + {'INFO'}, + f'UE Groom validation passed: {len(validation["objects"])} object(s), {curve_count} curves', + ) + return {'FINISHED'} + + +class ConfigureCyclesGroomView(bpy.types.Operator): + bl_idname = 'send2ue.configure_cycles_groom_view' + bl_label = 'Apply Cycles Groom View' + bl_description = ( + 'Configure only the Cycles viewport preview; does not create mesh geometry ' + 'or change final render samples' + ) + + def execute(self, context): + if not ue_groom_adapter.is_enabled(context.scene.send2ue): + self.report({'INFO'}, 'UE Groom Adapter is disabled') + return {'CANCELLED'} + adapter = ue_groom_adapter.get_settings(context.scene.send2ue) + result = ue_groom_adapter.configure_cycles_groom_view( + context, + getattr(adapter, 'cycles_view_preset', ue_groom_adapter.CYCLES_VIEW_FAST), + ) + self.report( + {'INFO'}, + f'Cycles Groom View: {result["preset"]}, {result["preview_samples"]} preview samples', + ) + return {'FINISHED'} + + +class ConnectHairData(bpy.types.Operator): + bl_idname = 'send2ue.connect_hair_data' + bl_label = 'Connect Hair Tool Data' + bl_description = ( + 'Connect the external Groom preview to Hair Tool HairShaderMain values, ' + 'Factor/SystemColor attributes, evaluated radius, and GUIDE rules without editing Hair Tool' + ) + + def execute(self, context): + properties = context.scene.send2ue + if not ue_groom_adapter.is_enabled(properties): + self.report({'INFO'}, 'UE Groom Adapter is disabled') + return {'CANCELLED'} + objects = ue_groom_adapter.get_hair_tool_grooms(properties) + active = context.view_layer.objects.active + if active in objects: + objects = [active] + if not objects: + self.report({'ERROR'}, 'No Hair Tool Groom Curves found in the Export collection') + return {'CANCELLED'} + try: + for scene_object in objects: + ue_groom_adapter.connect_hair_tool_guide_rules(scene_object) + ue_groom_adapter.connect_hair_tool_preview_material(scene_object, properties) + ue_groom_adapter.connect_hair_tool_radius_preview(scene_object, properties) + except RuntimeError as error: + self.report({'ERROR'}, str(error)) + return {'CANCELLED'} + self.report({'INFO'}, f'Connected Hair Tool data on {len(objects)} Groom object(s)') + return {'FINISHED'} + + +class UeGroomAdapterExtension(ExtensionBase): + name = ue_groom_adapter.EXTENSION_NAME + utility_operators = [ + ValidateUeGroomData, + ConfigureCyclesGroomView, + ConnectHairData, + ] + + enabled: bpy.props.BoolProperty( + name='Enable UE Groom Adapter', + default=False, + description=( + 'Opt in to the non-destructive Hair Tool Groom adapter; disabled keeps ' + 'the standard Send to Unreal workflow unchanged' + ), + ) + + output_mode: bpy.props.EnumProperty( + name='Hair Tool Output', + default=ue_groom_adapter.OUTPUT_CARDS, + items=( + (ue_groom_adapter.OUTPUT_CARDS, 'Cards', 'Export evaluated Hair Tool card meshes'), + (ue_groom_adapter.OUTPUT_GROOM, 'Groom', 'Export evaluated Hair Tool curves as an Unreal Groom'), + (ue_groom_adapter.OUTPUT_BOTH, 'Cards + Groom', 'Export both card mesh and Groom assets'), + ), + ) + + deformation_preset: bpy.props.EnumProperty( + name='Deformation Preset', + default=ue_groom_adapter.PRESET_UE_RIGGED_GUIDES, + items=( + ( + ue_groom_adapter.PRESET_CARD_RIG, + 'Card Rig (Bone)', + 'Use Hair Tool bone/weight data for Cards; a Groom exported with it uses UE Generated Guides', + ), + ( + ue_groom_adapter.PRESET_UE_RIGGED_GUIDES, + 'UE Rigged Guides', + 'Let Unreal 5.8 generate rigged guides from Groom strands; does not create Hair Tool card bones', + ), + ), + ) + + rigged_guide_num_curves: bpy.props.IntProperty( + name='Rigged Guide Curves', + default=64, + min=1, + max=1024, + description='Number of rigged guide curves generated per Groom group in Unreal', + ) + + rigged_guide_num_points: bpy.props.IntProperty( + name='Rigged Guide Points', + default=8, + min=2, + max=64, + description='Number of points/bones generated on each rigged guide in Unreal', + ) + + default_width: bpy.props.FloatProperty( + name='Default Width', + default=0.001, + min=0.000001, + soft_max=0.02, + precision=6, + subtype='DISTANCE', + description='Fallback strand diameter in Blender units when no width or radius exists', + ) + + id_attribute: bpy.props.StringProperty( + name='ID', + description='Blank = auto-map groom_id, then id', + ) + group_id_attribute: bpy.props.StringProperty( + name='Group ID', + description='Blank = auto-map groom_group_id, then group_id', + ) + root_uv_attribute: bpy.props.StringProperty( + name='Root UV', + description='Blank = prefer a FLOAT2 UVMap/root UV source', + ) + guide_attribute: bpy.props.StringProperty( + name='Guide', + description='Blank = auto-map groom_guide or GUIDE', + ) + parent_id_attribute: bpy.props.StringProperty( + name='Parent ID', + description='Blank = auto-map groom_parent_id or ParentID', + ) + width_attribute: bpy.props.StringProperty( + name='Width / Radius', + description='Blank = auto-map groom_width, width, or radius', + ) + color_attribute: bpy.props.StringProperty( + name='Color', + description='Blank = auto-map groom_color or the UE Groom preview color', + ) + roughness_attribute: bpy.props.StringProperty( + name='Roughness', + description='Blank = auto-map groom_roughness or the preview Hair BSDF roughness', + ) + ao_attribute: bpy.props.StringProperty( + name='Ambient Occlusion', + description='Blank = auto-map groom_ao or Hair Tool AO', + ) + clump_id_attribute: bpy.props.StringProperty( + name='Clump ID', + description='Blank = auto-map groom_clump_id or ClumpID', + ) + factor_attribute: bpy.props.StringProperty( + name='Hair Tool Factor', + description='Blank = auto-map Hair Tool Factor for root/tip color rules', + ) + random_attribute: bpy.props.StringProperty( + name='Hair Tool Random', + description='Blank = auto-map Hair Tool Random for authored color variation', + ) + roundness_attribute: bpy.props.StringProperty( + name='Profile Roundness', + description='Blank = inspect Hair Tool roundness; retained for Cards and reported as Groom-only limitation', + ) + unreal_material_path: bpy.props.StringProperty( + name='Unreal Groom Material', + default=ue_groom_adapter.DEFAULT_UNREAL_GROOM_MATERIAL, + description='Material asset created or reused under /Game/Material and assigned to the Groom', + ) + + cycles_view_preset: bpy.props.EnumProperty( + name='Cycles Groom View', + default=ue_groom_adapter.CYCLES_VIEW_FAST, + items=( + ( + ue_groom_adapter.CYCLES_VIEW_FAST, + 'Fast', + '1 viewport sample, no denoising, faster scrambling distance', + ), + ( + ue_groom_adapter.CYCLES_VIEW_BALANCED, + 'Balanced', + '8 viewport samples with denoising', + ), + ( + ue_groom_adapter.CYCLES_VIEW_QUALITY, + 'Quality', + '32 viewport samples with denoising', + ), + ), + ) + + def pre_validations(self, properties): + if not self.enabled or not ue_groom_adapter.wants_groom(properties): + return True + + validation = ue_groom_adapter.validate_scene(properties) + for warning in validation['warnings']: + print(f'[UE Groom Adapter] WARNING: {warning}') + if validation['errors']: + utilities.report_error( + 'UE Groom Adapter validation failed. ', + '\n'.join(validation['errors']), + ) + return False + return True + + def draw_export(self, dialog, layout, properties): + box = layout.box() + box.label(text='Hair Tool -> UE Groom Adapter:') + dialog.draw_property(self, box, 'enabled') + if not self.enabled: + return + dialog.draw_property(self, box, 'output_mode') + dialog.draw_property(self, box, 'default_width') + + mappings = box.box() + mappings.label(text='Attribute Mapping (blank = auto):') + for property_name in ( + 'id_attribute', + 'group_id_attribute', + 'root_uv_attribute', + 'guide_attribute', + 'parent_id_attribute', + 'width_attribute', + 'color_attribute', + 'roughness_attribute', + 'ao_attribute', + 'clump_id_attribute', + 'factor_attribute', + 'random_attribute', + 'roundness_attribute', + ): + dialog.draw_property(self, mappings, property_name) + + preview = box.box() + preview.label(text='Blender / Unreal preview parity:') + dialog.draw_property(self, preview, 'unreal_material_path') + preview.operator(ConnectHairData.bl_idname, icon='LINKED') + dialog.draw_property(self, preview, 'cycles_view_preset') + preview.operator(ConfigureCyclesGroomView.bl_idname, icon='RESTRICT_RENDER_OFF') + + def draw_import(self, dialog, layout, properties): + if not self.enabled: + return + box = layout.box() + box.label(text='Hair Tool deformation:') + dialog.draw_property(self, box, 'deformation_preset') + if self.deformation_preset == ue_groom_adapter.PRESET_UE_RIGGED_GUIDES: + dialog.draw_property(self, box, 'rigged_guide_num_curves') + dialog.draw_property(self, box, 'rigged_guide_num_points') + + def draw_validations(self, dialog, layout, properties): + if not self.enabled: + return + box = layout.box() + box.label(text='Hair Tool Groom data:') + box.operator(ValidateUeGroomData.bl_idname, icon='CHECKMARK') diff --git a/src/addons/send2ue/resources/pipeline/ue_material_setup.py b/src/addons/send2ue/resources/pipeline/ue_material_setup.py index dfffff5a..51f4cca3 100644 --- a/src/addons/send2ue/resources/pipeline/ue_material_setup.py +++ b/src/addons/send2ue/resources/pipeline/ue_material_setup.py @@ -14,13 +14,19 @@ 가드: 머티리얼명에서 'M_' 를 뗀 것이 메쉬명과 정확히 같거나 '메쉬명_' 으로 시작할 때만 처리. """ +import hashlib +import importlib.util import json import os import re +import sys import time import unreal +UNREAL_INSTANCE_PROFILE_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9_-]{0,63}\Z") + + def _candidate_contract_paths(): override = os.environ.get("SUBSTANCE_TOOLS_PIPELINE_CONTRACT") if override: @@ -60,6 +66,54 @@ def _pipeline_contract(): def _contract_path_mapping(): return _pipeline_contract().get("unreal_path_mapping", {}).get("current_default", {}) + +_SPEEDTREE_HANDOFF_API_UNSET = object() +_SPEEDTREE_HANDOFF_API = _SPEEDTREE_HANDOFF_API_UNSET + + +def _candidate_speedtree_handoff_api_paths(): + seen = set() + for contract_path in _candidate_contract_paths(): + module_path = os.path.join( + os.path.dirname(os.path.abspath(contract_path)), + "speedtree_handoff_contract.py", + ) + normalized = os.path.normcase(module_path) + if normalized in seen: + continue + seen.add(normalized) + yield module_path + + +def _speedtree_handoff_api(): + """Load the dependency-free shared rules without adding a repo to sys.path.""" + global _SPEEDTREE_HANDOFF_API + if _SPEEDTREE_HANDOFF_API is not _SPEEDTREE_HANDOFF_API_UNSET: + return _SPEEDTREE_HANDOFF_API + + for module_path in _candidate_speedtree_handoff_api_paths(): + if not os.path.isfile(module_path): + continue + module_name = "_send2ue_speedtree_handoff_contract" + try: + spec = importlib.util.spec_from_file_location(module_name, module_path) + if spec is None or spec.loader is None: + continue + module = importlib.util.module_from_spec(spec) + sys.modules[module_name] = module + spec.loader.exec_module(module) + _SPEEDTREE_HANDOFF_API = module + return module + except Exception as exc: + _warn( + "shared SpeedTree handoff API load failed: " + f"{module_path} ({exc})" + ) + sys.modules.pop(module_name, None) + + _SPEEDTREE_HANDOFF_API = None + return None + # ─── 설정 ──────────────────────────────────────────────────────────────────── DEFAULT_MASTER_PRESET = "prop" MASTER_PRESETS = { @@ -81,7 +135,7 @@ def _contract_path_mapping(): "Extra": "Extra", "Normal": "Normal", "Height": "Height", - "Transmission": "Transmission", + "Subsurface": "Subsurface", }, "virtual_textures": True, }, @@ -124,15 +178,20 @@ def _contract_path_mapping(): "virtual_textures": True, }, "hair": { - "master": "/Game/CC_Shaders/HairShader/RL_Hair", - "mi_folder": "/Game/Material/AssetSurface/MI/Hair", + "master": "/Game/Material/HairTool/Master/M_HT_HairCards", + "mi_folder": "/Game/Material/HairTool/MI", "assignment": "none", - "virtual_textures": False, - "create_if_missing": False, - "exclude_path_fragments": ["/Game/Material/AssetSurface/"], + "virtual_textures": None, + "create_if_missing": True, + "exclude_path_fragments": [], }, "tree": { "master": "/Game/Material/Tree/AssetTree/Master/M_TreeAsset_Master", + "masters_by_shading": { + "wood": "/Game/Material/Tree/AssetTree/Master/M_TreeAsset_Master", + "foliage": "/Game/Material/Tree/AssetTree/Master/M_TreeAsset_Foliage_Master", + "stem": "/Game/Material/Tree/AssetTree/Master/M_TreeAsset_Stem_Master", + }, "mi_folder": "/Game/Material/Tree/AssetTree/MI", "assignment": "material_layer_instance", "layer_parent": "/Game/Material/Tree/AssetTree/Master/MaterialLayer/MY_Tree_Bark", @@ -147,7 +206,7 @@ def _contract_path_mapping(): "Extra": "Extra", "Normal": "Normal", "Height": "Height", - "Transmission": "Transmission", + "Subsurface": "Subsurface", }, "virtual_textures": True, }, @@ -178,11 +237,16 @@ def _contract_path_mapping(): "Normal", "Height", "Transmission", + "Subsurface", "Emissive", "Sheen Color", "Sheen Opacity", "Sheen Roughness", "Moss Blend Mask", + "Flow Map", + "IRD Map", + "ORM Map", + "Opacity Map", } LAYER_PARAM_BY_LEGACY_PARAM = { "BaseColor": "Albedo", @@ -199,6 +263,19 @@ def _contract_path_mapping(): "SheenRoughness": "Sheen Roughness", "Texture": "Albedo", } + +# Parameters initialized from Blender only when a Hair Tool MI is first created. +# Existing instances own these values so artist edits survive later re-exports. +HAIR_INSTANCE_OWNED_SCALAR_PARAMETERS = { + "System Color Influence", + "System Mask Contrast", + "System Mask Bias", + "Roughness Multiplier", +} +HAIR_INSTANCE_OWNED_VECTOR_PARAMETERS = { + "System Color 01", + "System Color 02", +} FLAT_PARAM_BY_LAYER_PARAM = { "Albedo": "BaseColor", "Extra": "MetallicRoughness", @@ -266,13 +343,8 @@ def _cloth_texture_param_by_layer_param(): ("_subsurface", "Subsurface"), ) -# import 되는 텍스처의 인게임 최대 해상도 캡(param 별). 목록에 없으면 DEFAULT 적용. -MAX_TEXTURE_SIZE_BY_PARAM = { - "Albedo": 2048, # 2K - "BaseColor": 2048, # legacy JSON - "Normal": 2048, # 2K -} -DEFAULT_MAX_TEXTURE_SIZE = 1024 # MetallicRoughness/Emissive/기타 → 1K +# Virtual Texture Streaming을 쓰므로 모든 텍스처의 인게임 최대 해상도는 제한하지 않는다. +DEFAULT_MAX_TEXTURE_SIZE = 0 ENABLE_VIRTUAL_TEXTURE_STREAMING = True # import 되는 StaticMesh 를 자동으로 Nanite 로 등록할지(반투명 머티리얼 메쉬는 자동 제외). ENABLE_NANITE = True @@ -281,10 +353,10 @@ def _cloth_texture_param_by_layer_param(): # ───────────────────────────────────────────────────────────────────────────── -# 텍스처 재import 회피용 mtime 캐시 (텍스처 asset path -> 소스파일 mtime). -# send 마다 OneDrive 에서 모든 텍스처를 다시 읽지 않도록, 소스가 안 바뀌었고 에셋이 이미 -# 있으면 import 를 건너뛴다. 소스를 수정하면 mtime 이 바뀌어 자동으로 재import 된다. +# 텍스처 해시 캐시. 기존 asset path -> mtime(float) 엔트리는 읽을 수 있지만, +# AssetImportData FileMD5와 실제 설정을 검증한 뒤에만 v2 dict로 지연 마이그레이션한다. TEXTURE_IMPORT_CACHE = os.path.join(EXPORT_DIR, "_texture_import_cache.json") +TEXTURE_CACHE_ENTRY_VERSION = 2 def _load_texture_cache() -> dict: @@ -358,7 +430,9 @@ def _find_json_path(mesh_name: str, mesh_path: str = None): def _load_json(mesh_name: str, explicit_path: str = None, mesh_path: str = None): # extension 이 정확한 JSON 경로를 넘겨주면 walk 를 건너뛴다. 없으면 mesh_path 로 폴더를 좁힌다. - if explicit_path and os.path.exists(explicit_path): + if explicit_path: + if not os.path.isfile(explicit_path): + raise RuntimeError(f"explicit JSON sidecar is missing: {explicit_path}") path = explicit_path else: path = _find_json_path(mesh_name, mesh_path) @@ -370,10 +444,121 @@ def _load_json(mesh_name: str, explicit_path: str = None, mesh_path: str = None) _log(f"JSON sidecar: {path}") return data except Exception as e: + if explicit_path: + raise RuntimeError( + f"explicit JSON sidecar could not be read: {path} ({e})" + ) from e _warn(f"JSON 읽기 실패 ({mesh_name}.json): {e}") return None +def _speedtree_sidecar_descriptor(data: dict): + if not isinstance(data, dict): + return None + value = data.get("speedtree_handoff_contract") + return value if isinstance(value, dict) else None + + +def _validate_speedtree_handoff_contract(data: dict, expected_mesh_name: str): + """Validate new contract-authored sidecars before any Unreal mutation. + + Descriptor-free sidecars are legacy data and intentionally retain the + existing inference/search behavior. + """ + materials = data.get("materials", []) if isinstance(data, dict) else [] + has_intent = any( + isinstance(entry, dict) and "speedtree_intent" in entry + for entry in materials + ) + descriptor = _speedtree_sidecar_descriptor(data) + if descriptor is None and not has_intent: + return None + + contract_api = _speedtree_handoff_api() + if contract_api is None: + raise RuntimeError( + "SpeedTree handoff contract preflight blocked before mutation: " + "shared speedtree_handoff_contract.py is unavailable" + ) + if descriptor is None: + raise RuntimeError( + "SpeedTree handoff contract preflight blocked before mutation: " + "speedtree_intent exists without speedtree_handoff_contract" + ) + + errors = [] + try: + descriptor = contract_api.validate_sidecar_descriptor( + descriptor, + expected_mesh_name=expected_mesh_name, + ) + except Exception as exc: + errors.append(str(exc)) + + json_mesh_name = str(data.get("mesh_name") or "").strip() + if not json_mesh_name: + errors.append("contract sidecar has no mesh_name") + elif json_mesh_name.casefold() != str(expected_mesh_name or "").strip().casefold(): + errors.append( + f"sidecar mesh_name mismatch: {json_mesh_name!r} != " + f"{expected_mesh_name!r}" + ) + + for entry_index, entry in enumerate(materials): + if not isinstance(entry, dict): + errors.append(f"materials[{entry_index}] is not an object") + continue + is_tree = str(entry.get("master_preset") or "").strip().casefold() == "tree" + intent = entry.get("speedtree_intent") + if is_tree and intent is None: + errors.append( + f"{entry.get('name', '')}: tree entry has no speedtree_intent" + ) + continue + if intent is None: + continue + if not is_tree: + errors.append( + f"{entry.get('name', '')}: speedtree_intent requires master_preset 'tree'" + ) + continue + try: + validated = contract_api.validate_material_intent_for_name( + intent, + str(entry.get("name") or ""), + ) + expected = contract_api.build_material_intent( + str(entry.get("name") or ""), + explicit_tree_part=str(entry.get("tree_part") or ""), + explicit_tree_shading=str(entry.get("tree_shading") or ""), + instance_profile=str(entry.get("instance_profile") or ""), + ) + for key, expected_value in expected.items(): + if validated.get(key) != expected_value: + raise ValueError( + f"speedtree_intent {key} mismatch: " + f"{validated.get(key)!r} != {expected_value!r}" + ) + if expected.get("instance_profile"): + entry_mode = str( + entry.get("material_instance_mode") or "" + ).strip().casefold() + if entry_mode != expected.get("material_instance_mode"): + raise ValueError( + f"material_instance_mode mismatch: {entry_mode!r} != " + f"{expected.get('material_instance_mode')!r}" + ) + except Exception as exc: + errors.append(f"{entry.get('name', '')}: {exc}") + + if errors: + raise RuntimeError( + "SpeedTree handoff contract preflight blocked before mutation: " + + " | ".join(errors) + ) + return descriptor + + def _set_texture_property_if_changed(tex, property_name: str, value) -> bool: try: current = tex.get_editor_property(property_name) @@ -403,23 +588,93 @@ def _effective_texture_param(param: str, file_path=None, asset_name=None) -> str return _texture_param_from_name(file_path, asset_name) or str(param or "") -def _configure_imported_texture( - tex, +def _file_md5(file_path: str) -> str: + digest = hashlib.md5() + with open(file_path, "rb") as source_file: + for chunk in iter(lambda: source_file.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def _texture_source_fingerprint(file_path: str, cached_entry=None): + # Cache metadata is diagnostic only. A strict content gate must hash the + # current source even when mtime/size match a previously verified entry. + del cached_entry + stat_result = os.stat(file_path) + mtime_ns = int( + getattr(stat_result, "st_mtime_ns", int(stat_result.st_mtime * 1_000_000_000)) + ) + size = int(stat_result.st_size) + source_md5 = _file_md5(file_path) + return source_md5, { + "version": TEXTURE_CACHE_ENTRY_VERSION, + "source_path": os.path.normcase(os.path.abspath(file_path)), + "mtime_ns": mtime_ns, + "size": size, + "md5": source_md5, + } + + +def _asset_data_tag_value(asset_data, tag_name: str) -> str: + if asset_data is None: + return "" + try: + value = asset_data.get_tag_value(tag_name) + except Exception: + try: + value = unreal.AssetRegistryHelpers.get_tag_value(asset_data, tag_name) + except Exception: + value = "" + if isinstance(value, tuple): + value = next((item for item in reversed(value) if isinstance(item, str)), "") + if value: + return str(value) + try: + tags = asset_data.tags_and_values + return str(tags.get(tag_name, "")) + except Exception: + return "" + + +def _asset_import_file_md5(asset_path: str): + try: + asset_data = unreal.EditorAssetLibrary.find_asset_data(asset_path) + except Exception: + return None + raw_value = _asset_data_tag_value(asset_data, "AssetImportData") + if not raw_value: + return None + + try: + source_files = json.loads(raw_value) + except (TypeError, ValueError): + source_files = [] + if isinstance(source_files, dict): + source_files = [source_files] + for source_file in source_files if isinstance(source_files, list) else []: + if not isinstance(source_file, dict): + continue + file_md5 = str(source_file.get("FileMD5") or "").lower() + if re.fullmatch(r"[0-9a-f]{32}", file_md5) and file_md5 != "0" * 32: + return file_md5 + + match = re.search(r'\bFileMD5["\']?\s*[:=]\s*["\']?([0-9a-fA-F]{32})', raw_value) + if match and match.group(1) != "0" * 32: + return match.group(1).lower() + return None + + +def _desired_texture_settings( param: str, virtual_texture_streaming=None, file_path=None, asset_name=None, -) -> bool: - changed = False +) -> dict: + del virtual_texture_streaming # Project policy: every imported texture uses VT streaming. param = _effective_texture_param(param, file_path, asset_name) - if param == "Normal": - changed |= _set_texture_property_if_changed(tex, "srgb", False) - changed |= _set_texture_property_if_changed( - tex, - "compression_settings", - unreal.TextureCompressionSettings.TC_NORMALMAP, - ) + srgb = False + compression = unreal.TextureCompressionSettings.TC_NORMALMAP elif param in { "Extra", "MetallicRoughness", @@ -428,46 +683,200 @@ def _configure_imported_texture( "Occlusion", "Sheen Opacity", "Sheen Roughness", + "Flow Map", + "IRD Map", + "ORM Map", }: - changed |= _set_texture_property_if_changed(tex, "srgb", False) - changed |= _set_texture_property_if_changed( - tex, - "compression_settings", - unreal.TextureCompressionSettings.TC_MASKS, - ) - elif param in {"Height", "Opacity", "Alpha", "Transmission"}: - changed |= _set_texture_property_if_changed(tex, "srgb", False) - changed |= _set_texture_property_if_changed( - tex, - "compression_settings", - unreal.TextureCompressionSettings.TC_GRAYSCALE, - ) - elif param == "Subsurface": - changed |= _set_texture_property_if_changed(tex, "srgb", True) + srgb = False + compression = unreal.TextureCompressionSettings.TC_MASKS + elif param in {"Height", "Opacity", "Opacity Map", "Alpha", "Transmission"}: + srgb = False + compression = unreal.TextureCompressionSettings.TC_GRAYSCALE else: - changed |= _set_texture_property_if_changed(tex, "srgb", True) + srgb = True + compression = unreal.TextureCompressionSettings.TC_DEFAULT + + return { + "srgb": srgb, + "compression_settings": compression, + "max_texture_size": DEFAULT_MAX_TEXTURE_SIZE, + "virtual_texture_streaming": bool(ENABLE_VIRTUAL_TEXTURE_STREAMING), + } + - max_size = MAX_TEXTURE_SIZE_BY_PARAM.get(param, DEFAULT_MAX_TEXTURE_SIZE) - if max_size: - changed |= _set_texture_property_if_changed(tex, "max_texture_size", max_size) +def _texture_settings_match(tex, settings: dict) -> bool: + if tex is None: + return False + for property_name, expected in settings.items(): + try: + current = tex.get_editor_property(property_name) + except Exception: + return False + if property_name in {"srgb", "virtual_texture_streaming"}: + current = bool(current) + if current != expected: + return False + return True - if virtual_texture_streaming is None: - virtual_texture_streaming = ENABLE_VIRTUAL_TEXTURE_STREAMING + +def _configure_imported_texture( + tex, + param: str, + virtual_texture_streaming=None, + file_path=None, + asset_name=None, +) -> bool: + changed = False + settings = _desired_texture_settings( + param, + virtual_texture_streaming, + file_path, + asset_name, + ) + changed |= _set_texture_property_if_changed(tex, "srgb", settings["srgb"]) + changed |= _set_texture_property_if_changed( + tex, + "compression_settings", + settings["compression_settings"], + ) + changed |= _set_texture_property_if_changed( + tex, + "max_texture_size", + settings["max_texture_size"], + ) try: before = bool(tex.get_editor_property("virtual_texture_streaming")) except Exception: before = None - if before is not None and before != bool(virtual_texture_streaming): + virtual_texture_streaming = settings["virtual_texture_streaming"] + if before is not None and before != virtual_texture_streaming: if hasattr(tex, "set_virtual_texture_streaming"): - tex.set_virtual_texture_streaming(bool(virtual_texture_streaming)) + tex.set_virtual_texture_streaming(virtual_texture_streaming) else: - tex.set_editor_property("virtual_texture_streaming", bool(virtual_texture_streaming)) + tex.set_editor_property("virtual_texture_streaming", virtual_texture_streaming) changed = True return changed +def _source_control_flag(state, property_name: str) -> bool: + try: + return bool(getattr(state, property_name)) + except Exception: + try: + return bool(state.get_editor_property(property_name)) + except Exception: + return False + + +def _source_control_error(source_control) -> str: + try: + return str(source_control.last_error_msg()) + except Exception: + return "unknown source-control error" + + +def _checkout_texture_for_update(asset_path: str) -> bool: + source_control = getattr(unreal, "SourceControl", None) + if source_control is None: + raise RuntimeError("Unreal SourceControl helper is unavailable") + try: + state = source_control.query_file_state(asset_path, True, False) + except TypeError: + state = source_control.query_file_state(asset_path) + + if _source_control_flag(state, "is_checked_out_other"): + raise RuntimeError(f"texture is checked out by another user: {asset_path}") + if ( + _source_control_flag(state, "is_checked_out") + or _source_control_flag(state, "is_added") + ): + return False + + if not source_control.check_out_file(asset_path, True): + raise RuntimeError( + f"texture source-control checkout failed: {asset_path} " + f"({_source_control_error(source_control)})" + ) + _log(f" texture source-control checkout: {asset_path}") + return _source_control_flag(state, "is_valid") + + +def _revert_owned_texture_checkout(asset_path: str): + source_control = getattr(unreal, "SourceControl", None) + if source_control is None: + return + source_control.revert_unchanged_file(asset_path, True) + + +def _mark_texture_for_add(asset_path: str): + source_control = getattr(unreal, "SourceControl", None) + if source_control is None: + raise RuntimeError("Unreal SourceControl helper is unavailable") + if not source_control.mark_file_for_add(asset_path, True): + raise RuntimeError( + f"texture source-control add failed: {asset_path} " + f"({_source_control_error(source_control)})" + ) + _log(f" texture source-control add: {asset_path}") + + +def _run_texture_import(file_path: str, asset_name: str, replace_existing: bool): + task = unreal.AssetImportTask() + task.set_editor_property("filename", file_path) + task.set_editor_property("destination_path", TEXTURES_FOLDER) + task.set_editor_property("destination_name", asset_name) + task.set_editor_property("automated", True) + task.set_editor_property("replace_existing", bool(replace_existing)) + task.set_editor_property("save", False) + unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task]) + return task + + +def _texture_import_task_succeeded(task, full_path: str) -> bool: + try: + if any(obj is not None for obj in task.get_objects()): + return True + except Exception: + pass + try: + imported_paths = task.get_editor_property("imported_object_paths") or [] + except Exception: + try: + imported_paths = task.imported_object_paths or [] + except Exception: + imported_paths = [] + expected_path = full_path.split(".")[0] + return any(str(path).split(".")[0] == expected_path for path in imported_paths) + + +def _save_texture_asset(full_path: str) -> bool: + if unreal.EditorAssetLibrary.save_asset(full_path, only_if_is_dirty=False): + return True + _warn(f" texture save failed: {full_path}") + return False + + +def _cache_verified_texture( + tex_cache: dict, + full_path: str, + source_md5: str, + fingerprint: dict, + tex, + desired_settings: dict, +) -> bool: + if _asset_import_file_md5(full_path) != source_md5: + _warn(f" texture AssetImportData FileMD5 verification failed: {full_path}") + return False + if not _texture_settings_match(tex, desired_settings): + _warn(f" texture role-setting verification failed: {full_path}") + return False + if tex_cache is not None: + tex_cache[full_path] = dict(fingerprint) + return True + + def _import_texture( file_path: str, asset_name: str, @@ -478,7 +887,8 @@ def _import_texture( ): """디스크 텍스처를 TEXTURES_FOLDER 로 직접 import 하고 종류별 설정 적용. asset path 반환. - tex_cache 가 주어지면, 소스파일 mtime 이 캐시와 같고 에셋이 이미 있을 때 import 를 건너뛴다. + AssetImportData FileMD5와 역할 설정이 모두 같으면 force mode에서도 mutation 없이 건너뛴다. + legacy mtime cache entry는 검증 성공 뒤에만 v2 fingerprint dict로 교체된다. """ if not asset_name: _warn(f" 텍스처 asset_name 없음 — skip ({file_path})") @@ -488,70 +898,117 @@ def _import_texture( return None full_path = f"{TEXTURES_FOLDER}/{asset_name}" - - # mtime 캐시 히트면 재import 생략(OneDrive 재읽기/하이드레이션 방지). getmtime 은 메타데이터만 - # 읽으므로 클라우드 파일을 받아오지 않는다. try: - source_mtime = os.path.getmtime(file_path) - except OSError: - source_mtime = None - if unreal.EditorAssetLibrary.does_asset_exist(full_path) and not force_reimport: - tex = unreal.load_asset(full_path) - if tex is not None and _configure_imported_texture( - tex, - param, - virtual_texture_streaming, + source_md5, fingerprint = _texture_source_fingerprint( file_path, - asset_name, - ): - unreal.EditorAssetLibrary.save_asset(full_path) - if tex_cache is not None and source_mtime is not None: - tex_cache[full_path] = source_mtime - _log(f" texture exists, import skipped: {asset_name} ({param})") - return full_path + tex_cache.get(full_path) if tex_cache is not None else None, + ) + except OSError as exc: + _warn(f" 텍스처 MD5 읽기 실패: {asset_name} ({exc})") + return None - if (tex_cache is not None and source_mtime is not None - and tex_cache.get(full_path) == source_mtime - and unreal.EditorAssetLibrary.does_asset_exist(full_path) - and not force_reimport): + desired_settings = _desired_texture_settings( + param, + virtual_texture_streaming, + file_path, + asset_name, + ) + asset_exists = unreal.EditorAssetLibrary.does_asset_exist(full_path) + if not asset_exists: + task = _run_texture_import(file_path, asset_name, replace_existing=False) + if not _texture_import_task_succeeded(task, full_path): + _warn(f" 텍스처 import task 실패: {asset_name}") + return None tex = unreal.load_asset(full_path) - if tex is not None and _configure_imported_texture( + if tex is None: + _warn(f" 텍스처 import 실패: {asset_name}") + return None + _configure_imported_texture( tex, param, virtual_texture_streaming, file_path, asset_name, + ) + if not _save_texture_asset(full_path): + return None + _mark_texture_for_add(full_path) + if not _cache_verified_texture( + tex_cache, + full_path, + source_md5, + fingerprint, + tex, + desired_settings, ): - unreal.EditorAssetLibrary.save_asset(full_path) + return None + _log(f" 텍스처 import: {asset_name} ({param})") return full_path - task = unreal.AssetImportTask() - task.set_editor_property('filename', file_path) - task.set_editor_property('destination_path', TEXTURES_FOLDER) - task.set_editor_property('destination_name', asset_name) - task.set_editor_property('automated', True) - task.set_editor_property('replace_existing', bool(force_reimport)) - task.set_editor_property('save', True) - unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task]) - tex = unreal.load_asset(full_path) - if tex is None: - _warn(f" 텍스처 import 실패: {asset_name}") - return None + imported_md5 = _asset_import_file_md5(full_path) + source_matches = imported_md5 == source_md5 + settings_match = _texture_settings_match(tex, desired_settings) + if source_matches and settings_match: + if not _cache_verified_texture( + tex_cache, + full_path, + source_md5, + fingerprint, + tex, + desired_settings, + ): + return None + force_note = " (force ignored: verified unchanged)" if force_reimport else "" + _log(f" texture verified, import skipped: {asset_name} ({param}){force_note}") + return full_path - _configure_imported_texture( - tex, - param, - virtual_texture_streaming, - file_path, - asset_name, - ) + checkout_owned = _checkout_texture_for_update(full_path) + try: + if not source_matches or tex is None: + task = _run_texture_import(file_path, asset_name, replace_existing=True) + if not _texture_import_task_succeeded(task, full_path): + _warn(f" 텍스처 reimport task 실패: {asset_name}") + return None + tex = unreal.load_asset(full_path) + if tex is None: + _warn(f" 텍스처 reimport 실패: {asset_name}") + return None + _configure_imported_texture( + tex, + param, + virtual_texture_streaming, + file_path, + asset_name, + ) + if not _save_texture_asset(full_path): + return None + _log(f" 텍스처 reimport: {asset_name} ({param})") + else: + _configure_imported_texture( + tex, + param, + virtual_texture_streaming, + file_path, + asset_name, + ) + if not _save_texture_asset(full_path): + return None + _log(f" texture role settings updated: {asset_name} ({param})") - unreal.EditorAssetLibrary.save_asset(full_path) - if tex_cache is not None and source_mtime is not None: - tex_cache[full_path] = source_mtime - _log(f" 텍스처 import: {asset_name} ({param})") - return full_path + if not _cache_verified_texture( + tex_cache, + full_path, + source_md5, + fingerprint, + tex, + desired_settings, + ): + return None + return full_path + finally: + if checkout_owned: + _revert_owned_texture_checkout(full_path) def _nanite_shape_preservation_voxelize(): @@ -621,6 +1078,9 @@ def _sync_browser_to_mesh(mesh_path: str): """Content Browser 를 import 된 메쉬로 이동/선택시킨다. (텍스처 import 가 마지막이라 브라우저가 /Game/Textures 로 튀는 것을 되돌림)""" try: + command_line = unreal.SystemLibrary.get_command_line().casefold() + if "-unattended" in command_line or "-run=" in command_line: + return unreal.EditorAssetLibrary.sync_browser_to_objects([mesh_path]) except Exception as e: _warn(f" 브라우저 동기화 실패(무시): {e}") @@ -654,36 +1114,189 @@ def _entry_name_blob(entry: dict) -> str: def _tree_part_key(entry: dict): + aliases = { + "bark": "bark", + "trunk": "bark", + "stump": "bark", + "branch": "branch", + "branches": "branch", + "twig": "branch", + "twigs": "branch", + "stem": "branch", + "stems": "branch", + "leaf": "leaf", + "leaves": "leaf", + "foliage": "leaf", + "cluster": "leaf", + } + sources = ( + entry, + entry.get("material_layer") + if isinstance(entry.get("material_layer"), dict) + else {}, + ) + explicit_value = "" + for source in sources: + explicit = str( + source.get("tree_part") + or source.get("unreal_tree_part") + or "" + ).strip().casefold() + if explicit: + explicit_value = explicit + break + + contract_api = _speedtree_handoff_api() + if contract_api is not None: + return contract_api.classify_tree_part( + _entry_name_blob(entry), + explicit=explicit_value, + ) + + for source in sources: + explicit = str( + source.get("tree_part") + or source.get("unreal_tree_part") + or "" + ).strip().casefold() + normalized = aliases.get(explicit) + if normalized: + return normalized blob = _entry_name_blob(entry) - if any(token in blob for token in ("leaf", "leaves", "foliage")): + name_tokens = { + token + for token in re.split(r"[^a-z0-9]+", blob) + if token + } + # Leaf-atlas scope wins over a subgroup label. M_leaf_*_stem/twig uses the + # leaf UV/translucency contract even when the source collection says stem. + if name_tokens.intersection({"leaf", "leaves", "foliage", "cluster"}): return "leaf" - if any(token in blob for token in ("branch", "twig")): + if ( + name_tokens.intersection({"branch", "branches", "twig", "twigs", "stem", "stems"}) + or any(token.endswith(("branch", "twig")) for token in name_tokens) + ): return "branch" - if any(token in blob for token in ("bark", "trunk", "stump")): + if name_tokens.intersection({"bark", "trunk", "stump"}): return "bark" return None +def _tree_shading_key(entry: dict, tree_part: str = None) -> str: + aliases = { + "wood": "wood", + "opaque": "wood", + "foliage": "foliage", + "subsurface": "foliage", + "sss": "foliage", + "stem": "stem", + "wrap": "stem", + } + sources = ( + entry, + entry.get("material_layer") + if isinstance(entry.get("material_layer"), dict) + else {}, + ) + explicit_value = "" + for source in sources: + explicit = str( + source.get("tree_shading") + or source.get("unreal_tree_shading") + or source.get("tree_master_variant") + or "" + ).strip().casefold() + if explicit: + explicit_value = explicit + break + + contract_api = _speedtree_handoff_api() + if contract_api is not None: + return contract_api.classify_tree_shading( + _entry_name_blob(entry), + explicit=explicit_value, + tree_part=tree_part, + ) + + for source in sources: + explicit = str( + source.get("tree_shading") + or source.get("unreal_tree_shading") + or source.get("tree_master_variant") + or "" + ).strip().casefold() + normalized = aliases.get(explicit) + if normalized: + return normalized + tree_part = tree_part or _tree_part_key(entry) + if tree_part == "leaf": + return "foliage" + name_tokens = { + token + for token in re.split(r"[^a-z0-9]+", _entry_name_blob(entry)) + if token + } + if name_tokens.intersection({"stem", "stems"}): + return "stem" + return "wood" + + def _is_tree_asset_path(mesh_path: str) -> bool: normalized = str(mesh_path or "").replace("\\", "/").casefold() return "/tree/" in normalized or "/trees/" in normalized +def _is_speedtree_asset(data: dict, mesh_path: str) -> bool: + descriptor = _speedtree_sidecar_descriptor(data) + if descriptor is not None: + return str(descriptor.get("asset_kind") or "").casefold() == "speedtree" + return _is_tree_asset_path(mesh_path) + + +def _tree_preset_contract_overlay() -> dict: + contract_api = _speedtree_handoff_api() + if contract_api is None or not hasattr(contract_api, "tree_unreal_preset"): + return {} + value = contract_api.tree_unreal_preset() + if not isinstance(value, dict): + return {} + result = {} + for key in ( + "mi_folder", + "layer_instance_folder", + "layer_texture_remap", + "virtual_textures", + "masters_by_shading", + ): + if key in value: + result[key] = value[key] + if isinstance(value.get("layer_parents_by_part"), dict): + result["layer_parents_by_name"] = value["layer_parents_by_part"] + masters = value.get("masters_by_shading") + if isinstance(masters, dict) and masters.get("wood"): + result["master"] = masters["wood"] + return result + + def _master_preset(data: dict, entry: dict = None, mesh_path: str = "") -> dict: entry = entry or {} tree_part = _tree_part_key(entry) - if tree_part or _is_tree_asset_path(mesh_path): - key = "tree" - else: - key = ( - entry.get("material_master") - or entry.get("master_material") - or entry.get("master_preset") - or data.get("material_master") - or data.get("master_material") - or data.get("master_preset") - or DEFAULT_MASTER_PRESET - ) + tree_shading = _tree_shading_key(entry, tree_part) + key = ( + entry.get("material_master") + or entry.get("master_material") + or entry.get("master_preset") + ) + if not key: + if tree_part or _is_tree_asset_path(mesh_path): + key = "tree" + else: + key = ( + data.get("material_master") + or data.get("master_material") + or data.get("master_preset") + or DEFAULT_MASTER_PRESET + ) key = str(key or DEFAULT_MASTER_PRESET).strip().lower() preset = MASTER_PRESETS.get(key) if preset is None: @@ -691,14 +1304,23 @@ def _master_preset(data: dict, entry: dict = None, mesh_path: str = "") -> dict: key = DEFAULT_MASTER_PRESET preset = MASTER_PRESETS[key] result = dict(preset) + if key == "tree": + result.update(_tree_preset_contract_overlay()) result["key"] = key if tree_part: result["tree_part"] = tree_part + if key == "tree": + result["tree_shading"] = tree_shading + masters_by_shading = result.get("masters_by_shading") + if isinstance(masters_by_shading, dict): + shading_master = str(masters_by_shading.get(tree_shading) or "").strip() + if shading_master: + result["master"] = shading_master return result def _uses_tree_material_preset(data: dict, mesh_path: str) -> bool: - if _is_tree_asset_path(mesh_path): + if _is_speedtree_asset(data, mesh_path): return True if not data: return False @@ -794,6 +1416,318 @@ def _entry_create_if_missing(entry: dict, preset: dict) -> bool: return bool(value) +def _entry_instance_profile(entry: dict) -> str: + profile = str(entry.get("instance_profile") or "").strip() + if not profile: + return "" + contract_api = _speedtree_handoff_api() + if contract_api is not None: + try: + profile = contract_api.normalize_instance_profile(profile) + except ValueError as exc: + raise RuntimeError( + f"invalid SpeedTree instance_profile {profile!r}: {exc}" + ) from exc + else: + if not UNREAL_INSTANCE_PROFILE_RE.fullmatch(profile): + raise RuntimeError( + f"invalid SpeedTree instance_profile {profile!r}; use one key made " + "of letters, numbers, '_' or '-'" + ) + profile = profile.casefold() + mode = str( + entry.get("material_instance_mode") or "create_or_reuse" + ).strip().casefold() + if mode not in {"create_or_reuse", "assign_existing"}: + raise RuntimeError( + f"unsupported material_instance_mode {mode!r} for profile {profile!r}" + ) + return profile + + +def _instance_profile_material_paths(entry: dict, preset: dict): + profile = _entry_instance_profile(entry) + if not profile: + return None + if preset.get("key") != "tree": + raise RuntimeError( + f"instance_profile is only supported for SpeedTree materials: " + f"{entry.get('name', '')}" + ) + if _entry_target_material_path(entry): + raise RuntimeError( + "instance_profile cannot be combined with target_material_path" + ) + mat_base = _material_instance_base_name(str(entry.get("name") or "")) + mi_folder = str(preset.get("mi_folder") or "").rstrip("/") + if not mat_base or not mi_folder: + raise RuntimeError( + f"could not resolve base MI path for instance_profile {profile!r}" + ) + base_path = f"{mi_folder}/MI_{mat_base}" + contract_api = _speedtree_handoff_api() + if contract_api is not None: + target_name = contract_api.profile_target_name( + str(entry.get("name") or ""), + profile, + ) + else: + target_name = f"MI_{mat_base}_{profile}" + return { + "profile": profile, + "mode": str( + entry.get("material_instance_mode") or "create_or_reuse" + ).strip().casefold(), + "base_path": base_path, + "target_path": f"{mi_folder}/{target_name}", + } + + +def _is_material_instance_constant(asset) -> bool: + if asset is None: + return False + instance_class = getattr(unreal, "MaterialInstanceConstant", None) + if instance_class is not None: + try: + if isinstance(asset, instance_class): + return True + except TypeError: + pass + try: + return asset.get_class().get_name() == "MaterialInstanceConstant" + except Exception: + return False + + +def _validate_instance_profile_targets(data: dict, mesh_path: str = "") -> dict: + """Resolve every user-owned child MI before any pipeline mutation starts.""" + targets = {} + plans_by_target_path = {} + errors = [] + for entry_index, entry in enumerate(data.get("materials", [])): + if not isinstance(entry, dict): + continue + try: + profile = _entry_instance_profile(entry) + if not profile: + continue + if entry.get("translucent"): + raise RuntimeError("translucent entries cannot use instance_profile") + preset = _master_preset(data, entry, mesh_path) + paths = _instance_profile_material_paths(entry, preset) + base_path = paths["base_path"] + target_path = paths["target_path"] + base_exists = unreal.EditorAssetLibrary.does_asset_exist(base_path) + target_exists = unreal.EditorAssetLibrary.does_asset_exist(target_path) + base_asset = unreal.load_asset(base_path) if base_exists else None + target_asset = unreal.load_asset(target_path) if target_exists else None + if base_exists and not _is_material_instance_constant(base_asset): + raise RuntimeError( + f"base asset is not a MaterialInstanceConstant: {base_path}" + ) + if target_exists and not _is_material_instance_constant(target_asset): + raise RuntimeError( + f"target asset is not a MaterialInstanceConstant: {target_path}" + ) + if not target_exists and paths["mode"] == "assign_existing": + raise RuntimeError( + f"user-managed instance is missing: {target_path}" + ) + master_path = str(preset.get("master") or "").split(".")[0] + master_asset = unreal.load_asset(master_path) if master_path else None + if not base_exists and master_asset is None: + raise RuntimeError( + f"base MI master is missing: {master_path or ''}" + ) + intent = (base_path, master_path) + plan = plans_by_target_path.get(target_path) + if plan is not None and plan["intent"] != intent: + raise RuntimeError( + f"conflicting profile target intent: {target_path}" + ) + if plan is None: + plan = { + **paths, + "intent": intent, + "preset": preset, + "master_path": master_path, + "master_asset": master_asset, + "base_asset": base_asset, + "asset": target_asset, + "create_base": not base_exists, + "create_target": not target_exists, + "entry_indices": [], + } + plans_by_target_path[target_path] = plan + plan["entry_indices"].append(entry_index) + targets[entry_index] = plan + except Exception as exc: + errors.append(f"{entry.get('name', '')}: {exc}") + if errors: + raise RuntimeError( + "SpeedTree instance profile preflight blocked before mutation: " + + " | ".join(errors) + ) + return targets + + +def _mark_new_asset_for_add(asset_path: str, label: str = "asset"): + source_control = getattr(unreal, "SourceControl", None) + if source_control is None: + return + try: + state = source_control.query_file_state(asset_path, True, False) + except TypeError: + state = source_control.query_file_state(asset_path) + if _source_control_flag(state, "is_checked_out_other"): + raise RuntimeError( + f"{label} is checked out by another user: {asset_path}" + ) + if _source_control_flag(state, "is_added"): + return + if not source_control.mark_file_for_add(asset_path, True): + raise RuntimeError( + f"{label} source-control add failed: {asset_path} " + f"({_source_control_error(source_control)})" + ) + _log(f" {label} source-control add: {asset_path}") + + +def _mark_material_instance_for_add(asset_path: str): + _mark_new_asset_for_add(asset_path, "material instance") + + +def _save_and_mark_new_material_asset(asset_path: str, label: str = "material instance"): + try: + if not unreal.EditorAssetLibrary.save_asset( + asset_path, + only_if_is_dirty=False, + ): + raise RuntimeError(f"{label} save failed: {asset_path}") + _mark_new_asset_for_add(asset_path, label) + except Exception: + try: + unreal.EditorAssetLibrary.delete_asset(asset_path) + except Exception as rollback_exc: + _warn(f" {label} rollback failed: {asset_path} ({rollback_exc})") + raise + + +def _load_exact_material_instance(asset_path: str): + if not unreal.EditorAssetLibrary.does_asset_exist(asset_path): + return None + asset = unreal.load_asset(asset_path) + if not _is_material_instance_constant(asset): + raise RuntimeError( + f"asset is not a MaterialInstanceConstant: {asset_path}" + ) + return asset + + +def _create_profile_mi_asset(asset_tools, asset_path: str, parent): + existing = _load_exact_material_instance(asset_path) + if existing is not None: + return existing, False + + folder, name = asset_path.rsplit("/", 1) + unreal.EditorAssetLibrary.make_directory(folder) + factory = unreal.MaterialInstanceConstantFactoryNew() + create_error = None + try: + asset = asset_tools.create_asset( + name, + folder, + unreal.MaterialInstanceConstant, + factory, + ) + except Exception as exc: + asset = None + create_error = exc + if asset is None: + raced_asset = _load_exact_material_instance(asset_path) + if raced_asset is not None: + return raced_asset, False + detail = f" ({create_error})" if create_error else "" + raise RuntimeError( + f"material instance creation failed: {asset_path}{detail}" + ) + try: + unreal.MaterialEditingLibrary.set_material_instance_parent(asset, parent) + if not unreal.EditorAssetLibrary.save_asset( + asset_path, only_if_is_dirty=False + ): + raise RuntimeError(f"material instance save failed: {asset_path}") + _mark_material_instance_for_add(asset_path) + except Exception: + try: + unreal.EditorAssetLibrary.delete_asset(asset_path) + except Exception as rollback_exc: + _warn( + f" failed material instance rollback: {asset_path} " + f"({rollback_exc})" + ) + raise + return asset, True + + +def _ensure_instance_profile_targets(asset_tools, targets: dict) -> dict: + """Create missing Base/Target MIs before mesh or texture mutation.""" + unique_plans = [] + seen = set() + for plan in targets.values(): + target_path = plan["target_path"] + if target_path not in seen: + seen.add(target_path) + unique_plans.append(plan) + unique_plans.sort(key=lambda plan: plan["target_path"].casefold()) + + created_paths = [] + try: + for plan in unique_plans: + if plan["create_base"]: + plan["base_asset"], base_created = _create_profile_mi_asset( + asset_tools, + plan["base_path"], + plan["master_asset"], + ) + plan["create_base"] = False + if base_created: + created_paths.append(plan["base_path"]) + if plan["create_target"]: + plan["asset"], target_created = _create_profile_mi_asset( + asset_tools, + plan["target_path"], + plan["base_asset"], + ) + plan["create_target"] = False + if target_created: + created_paths.append(plan["target_path"]) + _log( + f" user-managed profile '{plan['profile']}' created once: " + f"{plan['target_path']}" + ) + else: + _log( + f" user-managed profile '{plan['profile']}' race-reused unchanged: " + f"{plan['target_path']}" + ) + else: + _log( + f" user-managed profile '{plan['profile']}' reused unchanged: " + f"{plan['target_path']}" + ) + except Exception: + for asset_path in reversed(created_paths): + try: + unreal.EditorAssetLibrary.delete_asset(asset_path) + except Exception as rollback_exc: + _warn( + f" profile MI rollback failed: {asset_path} ({rollback_exc})" + ) + raise + return targets + + def _derive_number_suffix_copy_source(target_path: str): if not target_path or "/" not in target_path: return None @@ -814,8 +1748,9 @@ def _load_or_copy_target_material( master_mat=None, create_if_missing: bool = True, ): - if unreal.EditorAssetLibrary.does_asset_exist(target_path): - return unreal.load_asset(target_path), target_path, False, "existing" + existing = _load_exact_material_instance(target_path) + if existing is not None: + return existing, target_path, False, "existing" if not copy_from_path: copy_from_path = _derive_number_suffix_copy_source(target_path) @@ -837,10 +1772,13 @@ def _load_or_copy_target_material( factory, ) if mi is None: + raced = _load_exact_material_instance(target_path) + if raced is not None: + return raced, target_path, False, "existing" _warn(f" target material create failed: {target_path}") return None, target_path, False, "missing" unreal.MaterialEditingLibrary.set_material_instance_parent(mi, master_mat) - unreal.EditorAssetLibrary.save_asset(target_path, only_if_is_dirty=False) + _save_and_mark_new_material_asset(target_path) _log(f" MI create: {target_path}") return mi, target_path, True, "new" if not unreal.EditorAssetLibrary.does_asset_exist(copy_from_path): @@ -868,7 +1806,14 @@ def _load_or_copy_target_material( _warn(f" target material copy failed: {copy_from_path} -> {target_path}") return None, target_path, False, "missing" - unreal.EditorAssetLibrary.save_asset(target_path, only_if_is_dirty=False) + if not _is_material_instance_constant(copied): + try: + unreal.EditorAssetLibrary.delete_asset(target_path) + finally: + raise RuntimeError( + f"copied target is not a MaterialInstanceConstant: {target_path}" + ) + _save_and_mark_new_material_asset(target_path) _log(f" MI copy: {copy_from_path} -> {target_path}") return copied, target_path, True, "copy" @@ -1017,9 +1962,24 @@ def _mesh_relative_disk_folder_candidates(mesh_path: str): return candidates +def _dynamic_wind_contract_rules() -> dict: + contract_api = _speedtree_handoff_api() + if contract_api is None or not hasattr(contract_api, "dynamic_wind_rules"): + return {} + value = contract_api.dynamic_wind_rules() + return value if isinstance(value, dict) else {} + + def _dynamic_wind_json_from_data(data: dict): data = data or {} - for key in ("dynamic_wind_json", "dynamic_wind_json_path", "wind_json", "wind_json_path"): + rules = _dynamic_wind_contract_rules() + fields = rules.get("sidecar_fields") or ( + "dynamic_wind_json", + "dynamic_wind_json_path", + "wind_json", + "wind_json_path", + ) + for key in fields: value = str(data.get(key) or "").strip() if value: return value @@ -1079,7 +2039,11 @@ def _find_dynamic_wind_json(data: dict, mesh_path: str, mesh_name: str, json_pat if os.path.isfile(candidate): return candidate - filename = f"{mesh_name}{DYNAMIC_WIND_JSON_SUFFIX}" + suffix = str( + _dynamic_wind_contract_rules().get("filename_suffix") + or DYNAMIC_WIND_JSON_SUFFIX + ) + filename = f"{mesh_name}{suffix}" for folder in dirs: candidate = os.path.join(folder, filename) if os.path.isfile(candidate): @@ -1088,7 +2052,7 @@ def _find_dynamic_wind_json(data: dict, mesh_path: str, mesh_name: str, json_pat def _import_dynamic_wind_if_available(mesh, mesh_path: str, mesh_name: str, data: dict, json_path: str = None) -> bool: - if not _is_skeletal_mesh(mesh) or not _is_tree_asset_path(mesh_path): + if not _is_skeletal_mesh(mesh) or not _is_speedtree_asset(data, mesh_path): return False helper = getattr(unreal, "CodexDynamicWindImportLibrary", None) if not helper or not hasattr(helper, "import_dynamic_wind_json_to_skeletal_mesh"): @@ -1265,7 +2229,24 @@ def _assign_layer_zero_textures(mi, param_tex_map: dict) -> bool: return changed -def _entry_layers(entry: dict): +def _tree_texture_param_allowed(param: str, preset: dict = None) -> bool: + if not preset or preset.get("key") != "tree": + return True + contract_api = _speedtree_handoff_api() + if contract_api is not None: + return contract_api.tree_texture_param_allowed( + param, + preset.get("tree_shading"), + ) + param = str(param or "").strip().casefold() + if param in {"alpha", "opacity", "opacity map", "transmission"}: + return False + if param == "subsurface" and preset.get("tree_shading") == "wood": + return False + return True + + +def _entry_layers(entry: dict, preset: dict = None): layers = entry.get("layers") or [] normalized = [] for layer_index, layer in enumerate(layers): @@ -1273,7 +2254,8 @@ def _entry_layers(entry: dict): for texture in layer.get("textures", []): item = dict(texture) item["param"] = _surface_layer_param(item.get("param")) - textures.append(item) + if _tree_texture_param_allowed(item["param"], preset): + textures.append(item) normalized.append( { "name": str(layer.get("name") or f"Layer_{layer_index + 1:02d}"), @@ -1288,7 +2270,8 @@ def _entry_layers(entry: dict): for texture in entry.get("textures", []): item = dict(texture) item["param"] = _surface_layer_param(item.get("param")) - textures.append(item) + if _tree_texture_param_allowed(item["param"], preset): + textures.append(item) return [{"name": "Base", "index": 0, "textures": textures}] if textures else [] @@ -1309,7 +2292,10 @@ def _import_layer_textures( param, tex_cache, force_reimport=force_reimport, - virtual_texture_streaming=virtual_texture_streaming, + virtual_texture_streaming=texture.get( + "virtual_texture_streaming", + virtual_texture_streaming, + ), ) if tex_path and param in KNOWN_PARAMS: param_tex_map[param] = tex_path @@ -1339,7 +2325,8 @@ def reimport_textures_from_json(json_path: str) -> int: imported = 0 seen = set() for entry in data.get("materials", []): - for layer in _entry_layers(entry): + preset = _master_preset(data, entry) + for layer in _entry_layers(entry, preset): for texture in layer.get("textures", []): asset_name = texture.get("asset_name") param = texture.get("param") @@ -1614,7 +2601,15 @@ def _layer_texture_remap(preset: dict, entry: dict) -> dict: ) if not isinstance(mapping, dict): mapping = preset.get("layer_texture_remap", {}) - return {str(key): str(value) for key, value in dict(mapping).items() if key and value} + result = {str(key): str(value) for key, value in dict(mapping).items() if key and value} + if preset.get("key") == "tree": + for unused_param in ("Alpha", "Opacity", "Opacity Map", "Transmission"): + result.pop(unused_param, None) + if preset.get("tree_shading") != "wood": + result["Subsurface"] = "Subsurface" + else: + result.pop("Subsurface", None) + return result def _call_create_or_update_layer_instance(helper, parent_layer, layer_path, texture_params, scalar_params=None, vector_params=None): @@ -1629,9 +2624,15 @@ def _call_create_or_update_layer_instance(helper, parent_layer, layer_path, text ) if isinstance(result, tuple): ok = bool(result[0]) if result else False + report = {} + if len(result) > 1 and result[1]: + try: + report = json.loads(str(result[1])) + except Exception: + report = {} errors = result[2] if len(result) > 2 else [] - return ok, errors - return bool(result), [] + return ok, errors, report + return bool(result), [], {} _NORMALIZED_MATERIAL_LAYER_ASSETS = set() @@ -1769,7 +2770,7 @@ def _assign_material_layer_instance(mi, mat_base: str, layer_maps, preset: dict, f"{len(scalar_params)} scalar(s), {len(vector_params)} vector(s)" ) - ok, errors = _call_create_or_update_layer_instance( + ok, errors, layer_report = _call_create_or_update_layer_instance( helper, parent_layer, layer_path, @@ -1787,6 +2788,18 @@ def _assign_material_layer_instance(mi, mat_base: str, layer_maps, preset: dict, if layer_asset is None: _warn(f" MYI load failed after create/update: {layer_path}") return False + if layer_report.get("created"): + try: + _mark_new_asset_for_add(layer_path, "material layer instance") + except Exception: + try: + unreal.EditorAssetLibrary.delete_asset(layer_path) + except Exception as rollback_exc: + _warn( + f" material layer instance rollback failed: " + f"{layer_path} ({rollback_exc})" + ) + raise # Remove stale flat/layer overrides before the C++ helper persists the MI. # Do not trigger a live material preview update here; UE 5.8 can assert @@ -1825,6 +2838,9 @@ def _assign_master_textures(mi, layer_maps, assignment: str, preset: dict = None def _material_instance_base_name(mat_name: str) -> str: + contract_api = _speedtree_handoff_api() + if contract_api is not None: + return contract_api.material_instance_base_name(mat_name) if mat_name.startswith("M_"): return mat_name[2:] if mat_name.startswith("MI_"): @@ -1966,33 +2982,111 @@ def _delete_wrong_generated_hair_materials(mat_name: str, entry: dict, preset: d return deleted -def _load_or_migrate_hair_material(asset_tools, mat_name: str, entry: dict, preset: dict): +def _load_or_create_hair_material(asset_tools, mat_name: str, entry: dict, preset: dict): target_path = _hair_target_material_path(mat_name, entry, preset) if not target_path: _warn(f" hair material target path incomplete for '{mat_name}'") - return None, None - - source_paths = [path for path in _hair_source_material_paths(mat_name, entry, preset) if path != target_path] - if source_paths: - source_path = source_paths[0] - if unreal.EditorAssetLibrary.does_asset_exist(target_path): - if not unreal.EditorAssetLibrary.delete_asset(target_path): - _warn(f" existing wrong hair MI delete failed: {target_path}") - return None, None - _log(f" deleted wrong generated hair MI: {target_path}") - if not unreal.EditorAssetLibrary.rename_asset(source_path, target_path): - _warn(f" hair MI move/rename failed: {source_path} -> {target_path}") - return None, None - _log(f" hair MI moved: {source_path} -> {target_path}") - _delete_wrong_generated_hair_materials(mat_name, entry, preset, target_path) - return unreal.load_asset(target_path), target_path - - if unreal.EditorAssetLibrary.does_asset_exist(target_path): - _delete_wrong_generated_hair_materials(mat_name, entry, preset, target_path) - return unreal.load_asset(target_path), target_path - - _warn(f" hair material instance source missing for '{mat_name}' -> target {target_path}") - return None, None + return None, None, False + + master = _load_master_material(preset) + if master is None: + return None, target_path, False + mi, _path, created, _source = _load_or_copy_target_material( + asset_tools, + target_path, + master_mat=master, + create_if_missing=_entry_create_if_missing(entry, preset), + ) + if mi is None: + return None, target_path, False + try: + current_parent = mi.get_editor_property("parent") + except Exception: + current_parent = None + if not _same_asset(current_parent, master): + unreal.MaterialEditingLibrary.set_material_instance_parent(mi, master) + _log(f" hair MI parent update: {target_path} -> {master.get_path_name()}") + return mi, target_path, bool(created) + + +def _assign_hair_tool_parameters( + mi, + entry: dict, + layer_maps, + initialize_instance_owned_parameters: bool = True, +) -> bool: + changed = False + for param, tex_path in _first_layer_textures(layer_maps).items(): + texture = unreal.load_asset(tex_path) + if texture is None: + continue + unreal.MaterialEditingLibrary.set_material_instance_texture_parameter_value( + mi, param, texture + ) + _log(f" hair texture {param} <- {tex_path.split('/')[-1]}") + changed = True + + hair_tool = entry.get("hair_tool") or {} + for name, value in (hair_tool.get("scalar_parameters") or {}).items(): + name = str(name) + if ( + not initialize_instance_owned_parameters + and name in HAIR_INSTANCE_OWNED_SCALAR_PARAMETERS + ): + _log(f" preserve existing hair MI scalar: {name}") + continue + unreal.MaterialEditingLibrary.set_material_instance_scalar_parameter_value( + mi, name, float(value) + ) + changed = True + for name, value in (hair_tool.get("vector_parameters") or {}).items(): + name = str(name) + if ( + not initialize_instance_owned_parameters + and name in HAIR_INSTANCE_OWNED_VECTOR_PARAMETERS + ): + _log(f" preserve existing hair MI vector: {name}") + continue + components = list(value or []) + while len(components) < 4: + components.append(1.0) + color = unreal.LinearColor( + float(components[0]), + float(components[1]), + float(components[2]), + float(components[3]), + ) + unreal.MaterialEditingLibrary.set_material_instance_vector_parameter_value( + mi, name, color + ) + changed = True + if changed: + unreal.MaterialEditingLibrary.update_material_instance(mi) + return changed + + +def _ensure_hair_master_skeletal_mesh_usage(mi) -> bool: + try: + master = mi.get_base_material() + except Exception: + master = None + if master is None: + _warn(" hair MI base material missing; skeletal usage could not be checked") + return False + try: + if bool(master.get_editor_property("used_with_skeletal_mesh")): + return False + master.set_editor_property("used_with_skeletal_mesh", True) + compile_errors = unreal.MaterialEditingLibrary.recompile_material(master) or [] + for error in compile_errors: + _warn(f" hair master skeletal shader compile: {error}") + master_path = master.get_path_name().split(".")[0] + unreal.EditorAssetLibrary.save_asset(master_path, only_if_is_dirty=False) + _log(f" enabled hair master skeletal mesh usage: {master_path}") + return True + except Exception as exc: + _warn(f" failed to enable hair master skeletal mesh usage: {exc}") + return False def _source_asset_names(data: dict): @@ -2140,6 +3234,92 @@ def _cleanup_imported_source_assets(mesh_path: str, data: dict): _log(f" cleanup: no source material/texture assets found in {mesh_folder} ({elapsed:.2f}s)") +_CHECKED_OUT_MATERIAL_PIPELINE_ASSETS = set() + + +def _material_asset_needs_checkout(asset_path: str) -> bool: + source_control = getattr(unreal, "SourceControl", None) + if source_control is None: + return True + try: + state = source_control.query_file_state(asset_path, True, False) + except TypeError: + state = source_control.query_file_state(asset_path) + if _source_control_flag(state, "is_checked_out_other"): + raise RuntimeError( + f"material pipeline asset is checked out by another user: {asset_path}" + ) + return not ( + _source_control_flag(state, "is_checked_out") + or _source_control_flag(state, "is_added") + ) + + +def _material_pipeline_mutation_paths(mesh_path: str, data: dict) -> list: + """Resolve existing packages the current material contract may mutate.""" + paths = [mesh_path] + for entry in data.get("materials", []): + mat_name = str(entry.get("name", "")) + if entry.get("translucent"): + continue + preset = _master_preset(data, entry, mesh_path) + master_path = str(preset.get("master") or "").split(".")[0] + if master_path: + paths.append(master_path) + parent_layer = _layer_parent_path(preset, entry) + if parent_layer: + paths.append(parent_layer) + + target_path = _entry_target_material_path(entry) + if preset.get("key") == "hair": + target_path = target_path or _hair_target_material_path( + mat_name, + entry, + preset, + ) + if not target_path: + mat_base = _material_instance_base_name(mat_name) + mi_folder = str(preset.get("mi_folder") or "").rstrip("/") + if mat_base and mi_folder: + target_path = f"{mi_folder}/MI_{mat_base}" + if target_path: + paths.append(target_path) + + if preset.get("assignment") == "material_layer_instance": + layer_path = _layer_instance_path( + _material_instance_base_name(mat_name), + preset, + entry, + ) + if layer_path: + paths.append(layer_path) + + return list(dict.fromkeys(path.split(".")[0] for path in paths if path)) + + +def _checkout_material_pipeline_assets(mesh_path: str, data: dict) -> list: + existing = [] + for path in _material_pipeline_mutation_paths(mesh_path, data): + if ( + path in _CHECKED_OUT_MATERIAL_PIPELINE_ASSETS + or not unreal.EditorAssetLibrary.does_asset_exist(path) + ): + continue + if _material_asset_needs_checkout(path): + existing.append(path) + if not existing: + return [] + subsystem = unreal.get_editor_subsystem(unreal.EditorAssetSubsystem) + failed = [path for path in existing if not subsystem.checkout_asset(path)] + if failed: + raise RuntimeError( + "material pipeline source-control checkout failed: " + ", ".join(failed) + ) + _CHECKED_OUT_MATERIAL_PIPELINE_ASSETS.update(existing) + _log(f" source-control checkout: {len(existing)} material pipeline asset(s)") + return existing + + def preflight_mesh_materials(mesh_path: str, json_path: str = None) -> bool: """Normalize shared material-layer assets before Unreal touches an existing mesh. @@ -2152,6 +3332,16 @@ def preflight_mesh_materials(mesh_path: str, json_path: str = None) -> bool: data = _load_json(mesh_name, json_path, mesh_path) if not data: return False + _validate_speedtree_handoff_contract(data, mesh_name) + + instance_profile_targets = _validate_instance_profile_targets( + data, mesh_path + ) + _checkout_material_pipeline_assets(mesh_path, data) + _ensure_instance_profile_targets( + unreal.AssetToolsHelpers.get_asset_tools(), + instance_profile_targets, + ) helper = getattr(unreal, "CodexMaterialToolsLibrary", None) if helper is None: @@ -2195,6 +3385,20 @@ def process_mesh(mesh_path: str, master_mat=None, json_path: str = None) -> bool mesh_name = mesh_path.rsplit("/", 1)[-1] data = _load_json(mesh_name, json_path, mesh_path) + asset_tools = None + if data: + _validate_speedtree_handoff_contract(data, mesh_name) + instance_profile_targets = _validate_instance_profile_targets( + data, mesh_path + ) + _checkout_material_pipeline_assets(mesh_path, data) + asset_tools = unreal.AssetToolsHelpers.get_asset_tools() + _ensure_instance_profile_targets( + asset_tools, + instance_profile_targets, + ) + else: + instance_profile_targets = {} def save_mesh_asset(): if _is_skeletal_mesh(mesh): @@ -2228,7 +3432,6 @@ def save_mesh_asset(): _warn(f"JSON 사이드카 없음: {mesh_name}.json — skip (블렌더에서 Rename 버튼을 눌렀나요?)") return False - asset_tools = unreal.AssetToolsHelpers.get_asset_tools() changed = False json_mesh_name = str(data.get("mesh_name", "")) if json_mesh_name and json_mesh_name != mesh_name: @@ -2237,12 +3440,12 @@ def save_mesh_asset(): save_mesh_asset() changed = True - # 텍스처 재import 회피 캐시(메쉬마다 reload 되므로 디스크에서 읽고, 바뀌면 끝에 저장). + # 검증된 텍스처 fingerprint 캐시(메쉬마다 reload하고, 검증된 entry만 끝에 저장). tex_cache = _load_texture_cache() tex_cache_before = dict(tex_cache) skeletal_slot_assignments = {} - for entry in data.get("materials", []): + for entry_index, entry in enumerate(data.get("materials", [])): mat_name = str(entry.get("name", "")) slot_name = _entry_slot_display_name(entry, mat_name) target_material_path = _entry_target_material_path(entry) @@ -2260,9 +3463,25 @@ def save_mesh_asset(): preset = _master_preset(data, entry, mesh_path) if preset.get("key") == "hair": - mi, mi_path = _load_or_migrate_hair_material(asset_tools, mat_name, entry, preset) + mi, mi_path, mi_created = _load_or_create_hair_material( + asset_tools, mat_name, entry, preset + ) if mi is None: continue + if _is_skeletal_mesh(mesh): + _ensure_hair_master_skeletal_mesh_usage(mi) + layer_maps = _import_layer_textures( + _entry_layers(entry, preset), + tex_cache, + virtual_texture_streaming=preset.get("virtual_textures"), + ) + if _assign_hair_tool_parameters( + mi, + entry, + layer_maps, + initialize_instance_owned_parameters=mi_created, + ): + unreal.EditorAssetLibrary.save_asset(mi_path, only_if_is_dirty=False) _log(f" hair slot[{slot_index}] '{mat_name}' -> {mi_path}") if _is_skeletal_mesh(mesh): skeletal_slot_assignments[slot_index] = (slot_name, mi) @@ -2312,7 +3531,7 @@ def save_mesh_asset(): f" slot[{slot_index}] '{mat_name}' -> {mi_path} " f"(master: {preset['key']})" ) - layers = _entry_layers(entry) + layers = _entry_layers(entry, preset) layer_maps = _import_layer_textures( layers, tex_cache, @@ -2363,7 +3582,7 @@ def save_mesh_asset(): continue # 3. Assign textures using the selected master material contract. - layers = _entry_layers(entry) + layers = _entry_layers(entry, preset) layer_maps = _import_layer_textures( layers, tex_cache, @@ -2377,16 +3596,28 @@ def save_mesh_asset(): entry=entry, mat_base=mat_base, ) - if (mi_created or parent_changed or params_changed) and preset["assignment"] != "material_layer_instance": + if mi_created: + _save_and_mark_new_material_asset(mi_path) + changed = True + elif (parent_changed or params_changed) and preset["assignment"] != "material_layer_instance": unreal.EditorAssetLibrary.save_asset(mi_path) changed = True - elif mi_created or parent_changed or params_changed: + elif parent_changed or params_changed: changed = True - # 4. 슬롯에 MI 할당 + # 4. Keep the base MI/MYI pipeline-managed. A profile target is + # user-owned and assignment-only: do not save, reparent, or edit it. + assigned_mi = mi + profile_target = instance_profile_targets.get(entry_index) + if profile_target: + assigned_mi = profile_target["asset"] + _log( + f" user-managed profile '{profile_target['profile']}' -> " + f"{profile_target['target_path']} (reused without mutation)" + ) if _is_skeletal_mesh(mesh): - skeletal_slot_assignments[slot_index] = (slot_name, mi) - if _assign_slot(mesh, slot_index, mi, slot_name): + skeletal_slot_assignments[slot_index] = (slot_name, assigned_mi) + if _assign_slot(mesh, slot_index, assigned_mi, slot_name): changed = True # 이번 처리에서 새로 import 된 텍스처가 있으면 캐시 갱신 diff --git a/tests/test_send2ue_core.py b/tests/test_send2ue_core.py index e43343db..e79cceb9 100644 --- a/tests/test_send2ue_core.py +++ b/tests/test_send2ue_core.py @@ -23,6 +23,11 @@ def test_extensions(self): """ self.run_extension_tests({ 'default': { + 'ue_groom_adapter': { + 'properties': { + 'enabled': False + } + }, 'ue2rigify': { 'tasks': [ 'pre_operation' diff --git a/tests/test_send2ue_material_pipeline_extension.py b/tests/test_send2ue_material_pipeline_extension.py new file mode 100644 index 00000000..fcd15fb1 --- /dev/null +++ b/tests/test_send2ue_material_pipeline_extension.py @@ -0,0 +1,135 @@ +import importlib.util +from pathlib import Path +import sys +import types +import unittest + + +MODULE_PATH = ( + Path(__file__).resolve().parents[1] + / "src" + / "addons" + / "send2ue" + / "resources" + / "extensions" + / "send2ue_material_pipeline.py" +) + + +def _load_extension_module(): + command_calls = [] + module_names = ( + "bpy", + "send2ue", + "send2ue.constants", + "send2ue.core", + "send2ue.core.utilities", + "send2ue.core.extension", + "send2ue.dependencies", + "send2ue.dependencies.unreal", + ) + previous = {name: sys.modules.get(name) for name in module_names} + + bpy = types.ModuleType("bpy") + bpy.props = types.SimpleNamespace(BoolProperty=lambda **kwargs: None) + bpy.data = types.SimpleNamespace(objects={}) + bpy.app = types.SimpleNamespace(driver_namespace={}) + bpy.context = object() + + send2ue = types.ModuleType("send2ue") + send2ue.__path__ = [] + constants = types.ModuleType("send2ue.constants") + constants.UnrealTypes = types.SimpleNamespace( + STATIC_MESH="STATIC_MESH", + SKELETAL_MESH="SKELETAL_MESH", + ) + core = types.ModuleType("send2ue.core") + core.__path__ = [] + utilities = types.ModuleType("send2ue.core.utilities") + utilities.report_error = lambda *args, **kwargs: (_ for _ in ()).throw( + RuntimeError("unexpected report_error") + ) + extension = types.ModuleType("send2ue.core.extension") + extension.ExtensionBase = object + dependencies = types.ModuleType("send2ue.dependencies") + dependencies.__path__ = [] + unreal_dependency = types.ModuleType("send2ue.dependencies.unreal") + unreal_dependency.run_commands = lambda commands: command_calls.append( + list(commands) + ) + + replacements = { + "bpy": bpy, + "send2ue": send2ue, + "send2ue.constants": constants, + "send2ue.core": core, + "send2ue.core.utilities": utilities, + "send2ue.core.extension": extension, + "send2ue.dependencies": dependencies, + "send2ue.dependencies.unreal": unreal_dependency, + } + sys.modules.update(replacements) + module_name = f"test_send2ue_material_pipeline_{id(command_calls)}" + spec = importlib.util.spec_from_file_location(module_name, MODULE_PATH) + module = importlib.util.module_from_spec(spec) + try: + spec.loader.exec_module(module) + finally: + for name, value in previous.items(): + if value is None: + sys.modules.pop(name, None) + else: + sys.modules[name] = value + return module, command_calls + + +class TestMaterialPipelineExactSidecar(unittest.TestCase): + def setUp(self): + self.module, self.command_calls = _load_extension_module() + self.extension = self.module.MaterialPipelineExtension() + self.extension.enabled = True + self.asset_data = { + "_asset_type": "STATIC_MESH", + "asset_path": "/Game/Meshes/Tree/SK_CommonGrass.SK_CommonGrass", + } + + def test_pre_and_post_import_use_the_same_exact_sidecar(self): + exact_path = "D:/OneDrive/Forestportfolio/Tree/texture/SK_CommonGrass.json" + self.extension._resolve_json_path = lambda asset_path: exact_path + + self.extension.pre_import(self.asset_data, None) + + key = self.module.MATERIAL_PIPELINE_JSON_PATH_KEY + self.assertEqual(self.asset_data[key], exact_path) + self.assertFalse(self.asset_data["_import_materials_and_textures"]) + self.assertEqual(len(self.command_calls), 1) + self.assertIn(repr(exact_path), "\n".join(self.command_calls[0])) + + self.extension._resolve_json_path = lambda asset_path: (_ for _ in ()).throw( + AssertionError("post_import must not resolve the sidecar again") + ) + self.extension.post_import(self.asset_data, None) + + self.assertEqual(len(self.command_calls), 2) + post_commands = "\n".join(self.command_calls[1]) + self.assertIn(repr(exact_path), post_commands) + self.assertNotIn("json_path=None", post_commands) + + def test_missing_pre_import_sidecar_prevents_post_import_fallback(self): + key = self.module.MATERIAL_PIPELINE_JSON_PATH_KEY + self.asset_data[key] = "D:/stale.json" + resolve_calls = [] + self.extension._resolve_json_path = lambda asset_path: resolve_calls.append( + asset_path + ) + + self.extension.pre_import(self.asset_data, None) + self.extension.post_import(self.asset_data, None) + + self.assertNotIn(key, self.asset_data) + self.assertEqual(len(resolve_calls), 1) + self.assertEqual(self.command_calls, []) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_ue_material_setup.py b/tests/test_ue_material_setup.py new file mode 100644 index 00000000..4a6a64c4 --- /dev/null +++ b/tests/test_ue_material_setup.py @@ -0,0 +1,1167 @@ +import hashlib +import importlib.util +import json +import os +from pathlib import Path +import sys +import tempfile +import types +import unittest + + +MODULE_PATH = ( + Path(__file__).resolve().parents[1] + / "src" + / "addons" + / "send2ue" + / "resources" + / "pipeline" + / "ue_material_setup.py" +) + + +class FakeTexture: + def __init__( + self, + srgb=True, + compression_settings="TC_DEFAULT", + max_texture_size=2048, + virtual_texture_streaming=False, + ): + self.properties = { + "srgb": srgb, + "compression_settings": compression_settings, + "max_texture_size": max_texture_size, + "virtual_texture_streaming": virtual_texture_streaming, + } + + def get_editor_property(self, name): + return self.properties[name] + + def set_editor_property(self, name, value): + self.properties[name] = value + + def set_virtual_texture_streaming(self, value): + self.properties["virtual_texture_streaming"] = bool(value) + + +class FakeUnrealClass: + def __init__(self, name): + self.name = name + + def get_name(self): + return self.name + + +class FakeMaterialInstanceConstant: + def __init__(self, asset_path, parent=None): + self.asset_path = asset_path + self.parent = parent + + def get_path_name(self): + return self.asset_path + + def get_editor_property(self, name): + if name == "parent": + return self.parent + raise KeyError(name) + + def get_class(self): + return FakeUnrealClass("MaterialInstanceConstant") + + +class FakeAssetData: + def __init__(self, runtime, asset_path): + self.runtime = runtime + self.asset_path = asset_path + + def get_tag_value(self, tag_name): + if tag_name != "AssetImportData": + return "" + value = self.runtime.asset_import_tags.get(self.asset_path) + if value is not None: + return value + file_md5 = self.runtime.asset_md5.get(self.asset_path, "") + return json.dumps( + [ + { + "RelativeFilename": "source.png", + "Timestamp": "0", + "FileMD5": file_md5, + "DisplayLabelName": "", + } + ] + ) + + +class FakeSourceControlState: + def __init__( + self, + is_valid=True, + is_checked_out=False, + is_added=False, + is_checked_out_other=False, + ): + self.is_valid = is_valid + self.is_checked_out = is_checked_out + self.is_added = is_added + self.is_checked_out_other = is_checked_out_other + + +class FakeSourceControl: + def __init__(self, runtime): + self.runtime = runtime + + def query_file_state(self, asset_path, *args): + self.runtime.query_calls.append(asset_path) + return self.runtime.source_control_states.get( + asset_path, + FakeSourceControlState(), + ) + + def check_out_file(self, asset_path, silent=True): + self.runtime.checkout_calls.append(asset_path) + return not self.runtime.fail_checkout + + def revert_unchanged_file(self, asset_path, silent=True): + self.runtime.revert_unchanged_calls.append(asset_path) + return True + + def mark_file_for_add(self, asset_path, silent=True): + self.runtime.mark_add_calls.append(asset_path) + return not self.runtime.fail_mark_add + + @staticmethod + def last_error_msg(): + return "fake source-control error" + + +class FakeAssetImportTask: + def __init__(self): + self.properties = {} + self.objects = [] + + def set_editor_property(self, name, value): + self.properties[name] = value + + def get_editor_property(self, name): + return self.properties[name] + + def get_objects(self): + return list(self.objects) + + +class FakeEditorAssetLibrary: + def __init__(self, runtime): + self.runtime = runtime + + def does_asset_exist(self, asset_path): + return asset_path in self.runtime.assets + + def find_asset_data(self, asset_path): + return FakeAssetData(self.runtime, asset_path) + + def save_asset(self, asset_path, *args, **kwargs): + self.runtime.save_calls.append(asset_path) + return not self.runtime.fail_save + + def make_directory(self, asset_path): + self.runtime.created_directories.append(asset_path) + return True + + def delete_asset(self, asset_path): + self.runtime.delete_calls.append(asset_path) + return self.runtime.assets.pop(asset_path, None) is not None + + +class FakeAssetTools: + def __init__(self, runtime): + self.runtime = runtime + + def import_asset_tasks(self, tasks): + for task in tasks: + properties = dict(task.properties) + self.runtime.import_tasks.append(properties) + asset_path = ( + f"{properties['destination_path'].rstrip('/')}" + f"/{properties['destination_name']}" + ) + if self.runtime.fail_import: + continue + texture = self.runtime.assets.get(asset_path) or FakeTexture() + self.runtime.assets[asset_path] = texture + self.runtime.asset_md5[asset_path] = ( + self.runtime.import_md5_override or _md5(properties["filename"]) + ) + task.objects = [texture] + task.properties["imported_object_paths"] = [asset_path] + + def create_asset(self, asset_name, asset_folder, _asset_class, _factory): + asset_path = f"{asset_folder.rstrip('/')}/{asset_name}" + raced_asset = self.runtime.race_create_assets.pop(asset_path, None) + if raced_asset is not None: + self.runtime.assets[asset_path] = raced_asset + return None + if asset_path in self.runtime.fail_create_paths: + return None + if asset_path in self.runtime.assets: + return None + asset = FakeMaterialInstanceConstant(asset_path) + self.runtime.assets[asset_path] = asset + self.runtime.created_assets.append(asset_path) + return asset + + +class FakeAssetToolsHelpers: + def __init__(self, runtime): + self.asset_tools = FakeAssetTools(runtime) + + def get_asset_tools(self): + return self.asset_tools + + +class FakeRuntime: + def __init__(self): + self.assets = {} + self.asset_md5 = {} + self.asset_import_tags = {} + self.source_control_states = {} + self.fail_import = False + self.fail_save = False + self.fail_checkout = False + self.fail_mark_add = False + self.import_md5_override = None + self.query_calls = [] + self.checkout_calls = [] + self.revert_unchanged_calls = [] + self.mark_add_calls = [] + self.import_tasks = [] + self.save_calls = [] + self.created_assets = [] + self.created_directories = [] + self.parent_changes = [] + self.delete_calls = [] + self.fail_create_paths = set() + self.race_create_assets = {} + self.logs = [] + self.warnings = [] + + unreal_module = types.ModuleType("unreal") + unreal_module.TextureCompressionSettings = types.SimpleNamespace( + TC_DEFAULT="TC_DEFAULT", + TC_NORMALMAP="TC_NORMALMAP", + TC_MASKS="TC_MASKS", + TC_GRAYSCALE="TC_GRAYSCALE", + ) + unreal_module.EditorAssetLibrary = FakeEditorAssetLibrary(self) + unreal_module.SourceControl = FakeSourceControl(self) + unreal_module.AssetImportTask = FakeAssetImportTask + unreal_module.AssetToolsHelpers = FakeAssetToolsHelpers(self) + unreal_module.AssetRegistryHelpers = types.SimpleNamespace( + get_tag_value=lambda asset_data, tag_name: asset_data.get_tag_value(tag_name) + ) + unreal_module.MaterialInstanceConstant = FakeMaterialInstanceConstant + unreal_module.MaterialInstanceConstantFactoryNew = object + unreal_module.MaterialEditingLibrary = types.SimpleNamespace( + set_material_instance_parent=self.set_material_instance_parent + ) + unreal_module.load_asset = self.load_asset + unreal_module.log = self.logs.append + unreal_module.log_warning = self.warnings.append + self.unreal_module = unreal_module + + def load_asset(self, asset_path): + return self.assets.get(asset_path) + + def set_material_instance_parent(self, asset, parent): + asset.parent = parent + self.parent_changes.append((asset.get_path_name(), parent.get_path_name())) + + +def _md5(file_path): + return hashlib.md5(Path(file_path).read_bytes()).hexdigest() + + +def _load_module(runtime): + module_name = f"test_ue_material_setup_{id(runtime)}" + spec = importlib.util.spec_from_file_location(module_name, MODULE_PATH) + module = importlib.util.module_from_spec(spec) + previous_unreal = sys.modules.get("unreal") + sys.modules["unreal"] = runtime.unreal_module + try: + spec.loader.exec_module(module) + finally: + if previous_unreal is None: + sys.modules.pop("unreal", None) + else: + sys.modules["unreal"] = previous_unreal + return module + + +class TestUeMaterialTextureImport(unittest.TestCase): + def setUp(self): + self.temp_dir = tempfile.TemporaryDirectory() + self.source_path = Path(self.temp_dir.name) / "T_Surface_extra.png" + self.source_path.write_bytes(b"current texture bytes") + self.runtime = FakeRuntime() + self.module = _load_module(self.runtime) + self.asset_name = "T_Surface_extra" + self.asset_path = f"{self.module.TEXTURES_FOLDER}/{self.asset_name}" + self.source_md5 = _md5(self.source_path) + + def tearDown(self): + self.temp_dir.cleanup() + + def add_existing_texture(self, texture=None, file_md5=None): + self.runtime.assets[self.asset_path] = texture or FakeTexture( + srgb=False, + compression_settings="TC_MASKS", + max_texture_size=0, + virtual_texture_streaming=True, + ) + self.runtime.asset_md5[self.asset_path] = file_md5 or self.source_md5 + + def import_texture(self, cache=None, force=False, param="Albedo"): + return self.module._import_texture( + str(self.source_path), + self.asset_name, + param, + cache, + force_reimport=force, + ) + + def test_matching_md5_and_settings_skip_mutation_even_when_forced(self): + self.add_existing_texture() + untouched_path = "/Game/Textures/T_Untouched" + cache = { + self.asset_path: os.path.getmtime(self.source_path), + untouched_path: 123.5, + } + + self.assertEqual(self.import_texture(cache, force=True), self.asset_path) + + self.assertEqual(self.runtime.checkout_calls, []) + self.assertEqual(self.runtime.import_tasks, []) + self.assertEqual(self.runtime.save_calls, []) + self.assertEqual(self.runtime.revert_unchanged_calls, []) + self.assertEqual(cache[self.asset_path]["version"], 2) + self.assertEqual(cache[self.asset_path]["md5"], self.source_md5) + self.assertEqual(cache[untouched_path], 123.5) + + def test_v2_cache_never_hides_same_stat_different_bytes(self): + self.source_path.write_bytes(b"A" * 32) + old_md5 = _md5(self.source_path) + stat_result = self.source_path.stat() + self.source_md5 = old_md5 + self.add_existing_texture(file_md5=old_md5) + cache = { + self.asset_path: { + "version": 2, + "source_path": str(self.source_path), + "mtime_ns": stat_result.st_mtime_ns, + "size": stat_result.st_size, + "md5": old_md5, + } + } + + self.source_path.write_bytes(b"B" * 32) + os.utime( + self.source_path, + ns=(stat_result.st_atime_ns, stat_result.st_mtime_ns), + ) + self.source_md5 = _md5(self.source_path) + + self.assertEqual(self.import_texture(cache), self.asset_path) + self.assertEqual(self.runtime.checkout_calls, [self.asset_path]) + self.assertEqual(len(self.runtime.import_tasks), 1) + self.assertEqual(cache[self.asset_path]["md5"], self.source_md5) + + def test_stale_md5_reimports_only_requested_existing_texture(self): + self.add_existing_texture(file_md5="1" * 32) + other_path = "/Game/Textures/T_Other" + self.runtime.assets[other_path] = FakeTexture() + self.runtime.asset_md5[other_path] = "2" * 32 + cache = {} + + self.assertEqual(self.import_texture(cache), self.asset_path) + + self.assertEqual(self.runtime.checkout_calls, [self.asset_path]) + self.assertEqual(self.runtime.revert_unchanged_calls, [self.asset_path]) + self.assertEqual(self.runtime.save_calls, [self.asset_path]) + self.assertEqual(len(self.runtime.import_tasks), 1) + self.assertTrue(self.runtime.import_tasks[0]["replace_existing"]) + self.assertFalse(self.runtime.import_tasks[0]["save"]) + self.assertNotIn(other_path, self.runtime.checkout_calls) + self.assertEqual(cache[self.asset_path]["md5"], self.source_md5) + + def test_matching_md5_role_drift_configures_without_reimport(self): + texture = FakeTexture( + srgb=True, + compression_settings="TC_DEFAULT", + max_texture_size=2048, + virtual_texture_streaming=False, + ) + self.add_existing_texture(texture) + + self.assertEqual(self.import_texture({}), self.asset_path) + + self.assertEqual(self.runtime.import_tasks, []) + self.assertEqual(self.runtime.checkout_calls, [self.asset_path]) + self.assertEqual(self.runtime.save_calls, [self.asset_path]) + self.assertEqual(self.runtime.revert_unchanged_calls, [self.asset_path]) + self.assertFalse(texture.properties["srgb"]) + self.assertEqual(texture.properties["compression_settings"], "TC_MASKS") + self.assertEqual(texture.properties["max_texture_size"], 0) + self.assertTrue(texture.properties["virtual_texture_streaming"]) + + def test_role_settings_cover_masks_normal_grayscale_and_color(self): + cases = ( + ("Extra", False, "TC_MASKS"), + ("Normal", False, "TC_NORMALMAP"), + ("Height", False, "TC_GRAYSCALE"), + ("Opacity", False, "TC_GRAYSCALE"), + ("Albedo", True, "TC_DEFAULT"), + ("Subsurface", True, "TC_DEFAULT"), + ) + for role, expected_srgb, expected_compression in cases: + with self.subTest(role=role): + settings = self.module._desired_texture_settings( + role, + virtual_texture_streaming=False, + ) + self.assertEqual(settings["srgb"], expected_srgb) + self.assertEqual( + settings["compression_settings"], + expected_compression, + ) + self.assertEqual(settings["max_texture_size"], 0) + self.assertTrue(settings["virtual_texture_streaming"]) + + def test_preexisting_user_checkout_is_not_reverted(self): + texture = FakeTexture(max_texture_size=1024) + self.add_existing_texture(texture) + self.runtime.source_control_states[self.asset_path] = FakeSourceControlState( + is_checked_out=True + ) + + self.assertEqual(self.import_texture({}), self.asset_path) + + self.assertEqual(self.runtime.checkout_calls, []) + self.assertEqual(self.runtime.revert_unchanged_calls, []) + self.assertEqual(self.runtime.save_calls, [self.asset_path]) + + def test_new_texture_import_marks_for_add_after_configured_save(self): + normal_source = Path(self.temp_dir.name) / "T_Surface_normal.png" + normal_source.write_bytes(self.source_path.read_bytes()) + self.source_path = normal_source + self.source_md5 = _md5(self.source_path) + self.asset_name = "T_Surface_normal" + self.asset_path = f"{self.module.TEXTURES_FOLDER}/{self.asset_name}" + cache = {} + + self.assertEqual(self.import_texture(cache, param="Normal"), self.asset_path) + + self.assertEqual(self.runtime.checkout_calls, []) + self.assertEqual(self.runtime.revert_unchanged_calls, []) + self.assertEqual(self.runtime.save_calls, [self.asset_path]) + self.assertEqual(self.runtime.mark_add_calls, [self.asset_path]) + self.assertFalse(self.runtime.import_tasks[0]["replace_existing"]) + texture = self.runtime.assets[self.asset_path] + self.assertFalse(texture.properties["srgb"]) + self.assertEqual(texture.properties["compression_settings"], "TC_NORMALMAP") + self.assertEqual(texture.properties["max_texture_size"], 0) + self.assertTrue(texture.properties["virtual_texture_streaming"]) + self.assertEqual(cache[self.asset_path]["md5"], self.source_md5) + + def test_owned_checkout_reverts_unchanged_after_failed_reimport(self): + self.add_existing_texture(file_md5="1" * 32) + cache = {self.asset_path: os.path.getmtime(self.source_path)} + self.runtime.fail_import = True + + self.assertIsNone(self.import_texture(cache)) + + self.assertEqual(self.runtime.checkout_calls, [self.asset_path]) + self.assertEqual(self.runtime.revert_unchanged_calls, [self.asset_path]) + self.assertEqual(self.runtime.save_calls, []) + self.assertIsInstance(cache[self.asset_path], float) + + def test_post_import_md5_mismatch_is_not_success_or_cached(self): + self.add_existing_texture(file_md5="1" * 32) + cache = {} + self.runtime.import_md5_override = "f" * 32 + + self.assertIsNone(self.import_texture(cache)) + + self.assertEqual(len(self.runtime.import_tasks), 1) + self.assertEqual(self.runtime.save_calls, [self.asset_path]) + self.assertEqual(self.runtime.revert_unchanged_calls, [self.asset_path]) + self.assertNotIn(self.asset_path, cache) + + def test_existing_save_failure_is_not_success_or_cached(self): + self.add_existing_texture(FakeTexture(max_texture_size=1024)) + cache = {} + self.runtime.fail_save = True + + self.assertIsNone(self.import_texture(cache)) + + self.assertEqual(self.runtime.import_tasks, []) + self.assertEqual(self.runtime.save_calls, [self.asset_path]) + self.assertEqual(self.runtime.revert_unchanged_calls, [self.asset_path]) + self.assertNotIn(self.asset_path, cache) + + def test_new_save_failure_is_not_added_or_cached(self): + cache = {} + self.runtime.fail_save = True + + self.assertIsNone(self.import_texture(cache)) + + self.assertEqual(len(self.runtime.import_tasks), 1) + self.assertEqual(self.runtime.save_calls, [self.asset_path]) + self.assertEqual(self.runtime.mark_add_calls, []) + self.assertNotIn(self.asset_path, cache) + + def test_other_user_checkout_blocks_before_mutation(self): + self.add_existing_texture(file_md5="1" * 32) + cache = {} + self.runtime.source_control_states[self.asset_path] = FakeSourceControlState( + is_checked_out_other=True + ) + + with self.assertRaises(RuntimeError): + self.import_texture(cache) + + self.assertEqual(self.runtime.checkout_calls, []) + self.assertEqual(self.runtime.import_tasks, []) + self.assertEqual(self.runtime.save_calls, []) + self.assertNotIn(self.asset_path, cache) + + def test_checkout_failure_blocks_before_mutation(self): + self.add_existing_texture(file_md5="1" * 32) + cache = {} + self.runtime.fail_checkout = True + + with self.assertRaises(RuntimeError): + self.import_texture(cache) + + self.assertEqual(self.runtime.checkout_calls, [self.asset_path]) + self.assertEqual(self.runtime.import_tasks, []) + self.assertEqual(self.runtime.save_calls, []) + self.assertNotIn(self.asset_path, cache) + + def test_mark_add_failure_does_not_cache_success(self): + cache = {} + self.runtime.fail_mark_add = True + + with self.assertRaises(RuntimeError): + self.import_texture(cache) + + self.assertEqual(self.runtime.save_calls, [self.asset_path]) + self.assertEqual(self.runtime.mark_add_calls, [self.asset_path]) + self.assertNotIn(self.asset_path, cache) + + def test_missing_source_does_not_migrate_legacy_cache(self): + self.add_existing_texture() + cache = {self.asset_path: 42.0} + self.source_path.unlink() + + self.assertIsNone(self.import_texture(cache)) + self.assertEqual(cache[self.asset_path], 42.0) + self.assertEqual(self.runtime.checkout_calls, []) + + def test_asset_import_md5_accepts_unreal_json_and_tuple_tag_result(self): + expected = "a" * 32 + + class TupleAssetData(FakeAssetData): + def get_tag_value(self, tag_name): + value = json.dumps([{"FileMD5": expected}]) + return True, value + + original_find = self.runtime.unreal_module.EditorAssetLibrary.find_asset_data + self.runtime.unreal_module.EditorAssetLibrary.find_asset_data = ( + lambda asset_path: TupleAssetData(self.runtime, asset_path) + ) + try: + self.assertEqual( + self.module._asset_import_file_md5(self.asset_path), + expected, + ) + finally: + self.runtime.unreal_module.EditorAssetLibrary.find_asset_data = original_find + + def test_preflight_mutation_paths_exclude_texture_assets(self): + self.module._master_preset = lambda data, entry, mesh_path: { + "master": "/Game/Material/M_Master", + "assignment": "none", + "mi_folder": "", + } + self.module._layer_parent_path = lambda preset, entry: None + self.module._entry_target_material_path = lambda entry: None + data = { + "materials": [ + { + "name": "M_Test", + "textures": [{"asset_name": "T_Direct"}], + "layers": [ + {"textures": [{"asset_name": "T_Layer"}]} + ], + } + ] + } + + paths = self.module._material_pipeline_mutation_paths( + "/Game/Meshes/SM_Test", + data, + ) + + self.assertIn("/Game/Meshes/SM_Test", paths) + self.assertIn("/Game/Material/M_Master", paths) + self.assertNotIn("/Game/Textures/T_Direct", paths) + self.assertNotIn("/Game/Textures/T_Layer", paths) + + def test_material_checkout_skips_added_assets_and_blocks_other_user(self): + added_path = "/Game/Material/Tree/AssetTree/MI/MI_New" + other_path = "/Game/Material/Tree/AssetTree/MI/MI_Other" + self.runtime.source_control_states[added_path] = FakeSourceControlState( + is_added=True + ) + self.runtime.source_control_states[other_path] = FakeSourceControlState( + is_checked_out_other=True + ) + + self.assertFalse(self.module._material_asset_needs_checkout(added_path)) + with self.assertRaisesRegex(RuntimeError, "checked out by another user"): + self.module._material_asset_needs_checkout(other_path) + + def test_tree_part_prefers_physical_branch_over_atlas_family_tokens(self): + self.assertEqual( + self.module._tree_part_key({"name": "M_leaf_parsley_atlas_02_stem"}), + "leaf", + ) + self.assertEqual( + self.module._tree_part_key({"name": "M_bark_deadbranch_02"}), + "branch", + ) + self.assertEqual( + self.module._tree_part_key({"name": "M_cluster_ladyfern_atlas_02"}), + "leaf", + ) + + def test_tree_shading_selects_independent_leaf_stem_and_wood_masters(self): + leaf_entry = {"name": "M_leaf_parsley_atlas_02_stem", "master_preset": "tree"} + stem_entry = {"name": "M_stem_common_04", "master_preset": "tree"} + wood_entry = {"name": "M_Branch_deadbranch_01", "master_preset": "tree"} + + leaf = self.module._master_preset({}, leaf_entry) + stem = self.module._master_preset({}, stem_entry) + wood = self.module._master_preset({}, wood_entry) + + self.assertEqual(leaf["tree_part"], "leaf") + self.assertEqual(leaf["tree_shading"], "foliage") + self.assertTrue(leaf["master"].endswith("M_TreeAsset_Foliage_Master")) + self.assertEqual(stem["tree_part"], "branch") + self.assertEqual(stem["tree_shading"], "stem") + self.assertTrue(stem["master"].endswith("M_TreeAsset_Stem_Master")) + self.assertEqual(wood["tree_shading"], "wood") + self.assertTrue(wood["master"].endswith("M_TreeAsset_Master")) + + explicit = self.module._master_preset( + {}, + { + "name": "M_Branch_living_contract", + "master_preset": "tree", + "tree_shading": "stem", + }, + ) + self.assertEqual(explicit["tree_shading"], "stem") + self.assertTrue(explicit["master"].endswith("M_TreeAsset_Stem_Master")) + + def test_speedtree_instance_profile_uses_literal_existing_child(self): + entry = { + "name": "M_stem_common_01", + "master_preset": "tree", + "tree_shading": "stem", + "instance_profile": "Dead", + "material_instance_mode": "create_or_reuse", + } + preset = self.module._master_preset({}, entry) + paths = self.module._instance_profile_material_paths(entry, preset) + self.assertTrue(paths["base_path"].endswith("/MI_stem_common_01")) + self.assertTrue(paths["target_path"].endswith("/MI_stem_common_01_dead")) + + base = FakeMaterialInstanceConstant(paths["base_path"]) + target = FakeMaterialInstanceConstant(paths["target_path"], parent=base) + self.runtime.assets[paths["base_path"]] = base + self.runtime.assets[paths["target_path"]] = target + + targets = self.module._validate_instance_profile_targets( + {"materials": [entry]}, + "/Game/Meshes/SK_CommonGrass", + ) + + self.assertIs(targets[0]["asset"], target) + self.assertEqual(targets[0]["profile"], "dead") + self.assertEqual(self.runtime.checkout_calls, []) + self.assertEqual(self.runtime.save_calls, []) + + def test_second_spm_reuses_profile_target_created_by_first_spm(self): + entry = { + "name": "M_stem_common_01", + "master_preset": "tree", + "tree_shading": "stem", + "instance_profile": "dead", + "material_instance_mode": "create_or_reuse", + } + preset = self.module._master_preset({}, entry) + paths = self.module._instance_profile_material_paths(entry, preset) + base = FakeMaterialInstanceConstant(paths["base_path"]) + self.runtime.assets[paths["base_path"]] = base + + first_spm_targets = self.module._validate_instance_profile_targets( + {"materials": [dict(entry)]}, + "/Game/Meshes/SK_FirstGrass", + ) + self.module._ensure_instance_profile_targets( + self.runtime.unreal_module.AssetToolsHelpers.get_asset_tools(), + first_spm_targets, + ) + shared_target = first_spm_targets[0]["asset"] + shared_target.user_tint = (0.09, 0.04, 0.01, 1.0) + + created_before = list(self.runtime.created_assets) + saved_before = list(self.runtime.save_calls) + parent_changes_before = list(self.runtime.parent_changes) + mark_add_before = list(self.runtime.mark_add_calls) + + # A separate SPM builds a fresh plan for the same base MI + profile key. + second_spm_targets = self.module._validate_instance_profile_targets( + {"materials": [dict(entry)]}, + "/Game/Meshes/SK_SecondGrass", + ) + self.module._ensure_instance_profile_targets( + self.runtime.unreal_module.AssetToolsHelpers.get_asset_tools(), + second_spm_targets, + ) + + self.assertIs(second_spm_targets[0]["asset"], shared_target) + self.assertEqual( + shared_target.user_tint, + (0.09, 0.04, 0.01, 1.0), + ) + self.assertEqual(self.runtime.created_assets, created_before) + self.assertEqual(self.runtime.save_calls, saved_before) + self.assertEqual(self.runtime.parent_changes, parent_changes_before) + self.assertEqual(self.runtime.mark_add_calls, mark_add_before) + self.assertEqual(self.runtime.checkout_calls, []) + + def test_missing_speedtree_profile_target_is_created_once(self): + entry = { + "name": "M_stem_common_01", + "master_preset": "tree", + "instance_profile": "dead", + "material_instance_mode": "create_or_reuse", + } + preset = self.module._master_preset({}, entry) + paths = self.module._instance_profile_material_paths(entry, preset) + base = FakeMaterialInstanceConstant(paths["base_path"]) + self.runtime.assets[paths["base_path"]] = base + + targets = self.module._validate_instance_profile_targets( + {"materials": [entry]}, + "/Game/Meshes/SK_CommonGrass", + ) + self.assertTrue(targets[0]["create_target"]) + self.assertIsNone(targets[0]["asset"]) + + self.module._ensure_instance_profile_targets( + self.runtime.unreal_module.AssetToolsHelpers.get_asset_tools(), + targets, + ) + target = targets[0]["asset"] + + self.assertEqual(self.runtime.checkout_calls, []) + self.assertEqual(self.runtime.import_tasks, []) + self.assertIs(target.parent, base) + self.assertEqual(self.runtime.created_assets, [paths["target_path"]]) + self.assertEqual(self.runtime.save_calls, [paths["target_path"]]) + self.assertEqual(self.runtime.mark_add_calls, [paths["target_path"]]) + + self.module._ensure_instance_profile_targets( + self.runtime.unreal_module.AssetToolsHelpers.get_asset_tools(), + targets, + ) + self.assertIs(targets[0]["asset"], target) + self.assertEqual(self.runtime.created_assets, [paths["target_path"]]) + self.assertEqual(self.runtime.save_calls, [paths["target_path"]]) + self.assertEqual(self.runtime.parent_changes, [ + (paths["target_path"], paths["base_path"]) + ]) + + def test_existing_speedtree_profile_preserves_parent_and_rejects_unsafe_key(self): + entry = { + "name": "M_stem_common_01", + "master_preset": "tree", + "instance_profile": "dead", + } + preset = self.module._master_preset({}, entry) + paths = self.module._instance_profile_material_paths(entry, preset) + base = FakeMaterialInstanceConstant(paths["base_path"]) + wrong_parent = FakeMaterialInstanceConstant("/Game/Material/Tree/MI_Other") + target = FakeMaterialInstanceConstant( + paths["target_path"], parent=wrong_parent + ) + self.runtime.assets[paths["base_path"]] = base + self.runtime.assets[paths["target_path"]] = target + + targets = self.module._validate_instance_profile_targets( + {"materials": [entry]}, + "/Game/Meshes/SK_CommonGrass", + ) + self.module._ensure_instance_profile_targets( + self.runtime.unreal_module.AssetToolsHelpers.get_asset_tools(), + targets, + ) + self.assertIs(target.parent, wrong_parent) + self.assertEqual(self.runtime.parent_changes, []) + self.assertEqual(self.runtime.save_calls, []) + self.assertEqual(self.runtime.checkout_calls, []) + + with self.assertRaisesRegex(RuntimeError, "invalid SpeedTree instance_profile"): + self.module._entry_instance_profile({"instance_profile": "../dead"}) + + def test_profile_creation_rolls_back_only_assets_created_in_this_call(self): + entries = [ + { + "name": "M_stem_common_01", + "master_preset": "tree", + "instance_profile": "dead", + }, + { + "name": "M_leaf_common_01", + "master_preset": "tree", + "instance_profile": "dead", + }, + ] + for entry in entries: + preset = self.module._master_preset({}, entry) + master_path = preset["master"].split(".")[0] + self.runtime.assets.setdefault( + master_path, + FakeMaterialInstanceConstant(master_path), + ) + targets = self.module._validate_instance_profile_targets( + {"materials": entries}, + "/Game/Meshes/SK_CommonGrass", + ) + ordered = sorted( + {plan["target_path"]: plan for plan in targets.values()}.values(), + key=lambda plan: plan["target_path"].casefold(), + ) + self.runtime.fail_create_paths.add(ordered[1]["target_path"]) + + with self.assertRaisesRegex(RuntimeError, "creation failed"): + self.module._ensure_instance_profile_targets( + self.runtime.unreal_module.AssetToolsHelpers.get_asset_tools(), + targets, + ) + + for path in self.runtime.created_assets: + self.assertNotIn(path, self.runtime.assets) + self.assertTrue(self.runtime.delete_calls) + + def test_material_group_names_do_not_imply_instance_profile(self): + data = { + "materials": [ + {"name": "M_Branch_deadbranch_01", "master_preset": "tree"}, + {"name": "M_Leaf_common_01_green", "master_preset": "tree"}, + {"name": "M_Leaf_common_01_yellow", "master_preset": "tree"}, + ] + } + self.assertEqual( + self.module._validate_instance_profile_targets( + data, "/Game/Meshes/SK_DeadBranch" + ), + {}, + ) + + def test_shared_speedtree_golden_vectors_match_unreal_adapter(self): + contract_api = self.module._speedtree_handoff_api() + self.assertIsNotNone(contract_api) + vectors = contract_api.golden_vectors() + + for vector in vectors["tree_axes"]: + with self.subTest(tree_material=vector["name"]): + entry = {"name": vector["name"], "master_preset": "tree"} + preset = self.module._master_preset({}, entry) + self.assertEqual(preset.get("tree_part"), vector["tree_part"]) + self.assertEqual( + preset.get("tree_shading"), + vector["tree_shading"], + ) + + for vector in vectors["profiles"]: + entry = {"instance_profile": vector["value"]} + if vector.get("error"): + with self.assertRaises(RuntimeError): + self.module._entry_instance_profile(entry) + else: + self.assertEqual( + self.module._entry_instance_profile(entry), + vector["normalized"], + ) + + for vector in vectors["tree_texture_policy"]: + self.assertEqual( + self.module._tree_texture_param_allowed( + vector["param"], + { + "key": "tree", + "tree_shading": vector["tree_shading"], + }, + ), + vector["allowed"], + ) + + def test_shared_tree_preset_overlay_supplies_unreal_paths(self): + contract_api = self.module._speedtree_handoff_api() + shared = contract_api.tree_unreal_preset() + preset = self.module._master_preset( + {}, + {"name": "M_stem_common_01", "master_preset": "tree"}, + ) + + self.assertEqual(preset["mi_folder"], shared["mi_folder"]) + self.assertEqual( + preset["layer_instance_folder"], + shared["layer_instance_folder"], + ) + self.assertEqual( + preset["master"], + shared["masters_by_shading"]["stem"], + ) + + def test_shared_api_unavailable_preserves_legacy_tree_fallbacks(self): + cached_api = self.module._SPEEDTREE_HANDOFF_API + self.module._SPEEDTREE_HANDOFF_API = None + try: + entry = { + "name": "M_stem_common_01", + "master_preset": "tree", + "instance_profile": "Dead", + } + preset = self.module._master_preset({}, entry) + self.assertEqual(preset["tree_part"], "branch") + self.assertEqual(preset["tree_shading"], "stem") + self.assertEqual( + self.module._entry_instance_profile(entry), + "dead", + ) + self.assertEqual( + self.module._material_instance_base_name("M_Bark_Mat"), + "Bark_Mat", + ) + self.assertFalse( + self.module._tree_texture_param_allowed("Opacity", preset) + ) + finally: + self.module._SPEEDTREE_HANDOFF_API = cached_api + + def _contract_sidecar(self, mesh_name="SK_CommonGrass"): + contract_api = self.module._speedtree_handoff_api() + entry = { + "name": "M_stem_common_01", + "slot_name": "M_stem_common_01", + "slot_index": 0, + "master_preset": "tree", + "tree_part": "branch", + "tree_shading": "stem", + "instance_profile": "dead", + "material_instance_mode": "create_or_reuse", + } + entry["speedtree_intent"] = contract_api.build_material_intent( + entry["name"], + explicit_tree_part=entry["tree_part"], + explicit_tree_shading=entry["tree_shading"], + instance_profile=entry["instance_profile"], + ) + return { + "schema_version": 3, + "material_pipeline": "surface_layers", + "mesh_name": mesh_name, + "material_master": "tree", + "speedtree_handoff_contract": ( + contract_api.build_sidecar_descriptor(mesh_name) + ), + "materials": [entry], + } + + def test_new_sidecar_descriptor_and_intent_validate_before_mutation(self): + data = self._contract_sidecar() + descriptor = self.module._validate_speedtree_handoff_contract( + data, + "SK_CommonGrass", + ) + + self.assertEqual(descriptor["asset_kind"], "speedtree") + self.assertTrue( + self.module._is_speedtree_asset( + data, + "/Game/Meshes/OutsideTree/SK_CommonGrass", + ) + ) + self.assertEqual(self.runtime.checkout_calls, []) + self.assertEqual(self.runtime.created_assets, []) + self.assertEqual(self.runtime.save_calls, []) + + def test_new_sidecar_mismatch_blocks_while_legacy_stays_compatible(self): + bad_descriptor = self._contract_sidecar() + bad_descriptor["speedtree_handoff_contract"]["fingerprint"] = "stale" + with self.assertRaisesRegex(RuntimeError, "fingerprint mismatch"): + self.module._validate_speedtree_handoff_contract( + bad_descriptor, + "SK_CommonGrass", + ) + + bad_intent = self._contract_sidecar() + bad_intent["materials"][0]["speedtree_intent"][ + "material_instance_base" + ] = "wrong" + with self.assertRaisesRegex(RuntimeError, "material_instance_base mismatch"): + self.module._validate_speedtree_handoff_contract( + bad_intent, + "SK_CommonGrass", + ) + + wrong_mesh = self._contract_sidecar("SK_Other") + with self.assertRaisesRegex(RuntimeError, "mesh mismatch|mesh_name mismatch"): + self.module._validate_speedtree_handoff_contract( + wrong_mesh, + "SK_CommonGrass", + ) + + legacy = { + "mesh_name": "SK_Other", + "materials": [ + {"name": "M_stem_common_01", "master_preset": "tree"} + ], + } + self.assertIsNone( + self.module._validate_speedtree_handoff_contract( + legacy, + "SK_CommonGrass", + ) + ) + + def test_explicit_json_path_never_falls_back_to_global_search(self): + calls = [] + self.module._find_json_path = lambda *args: calls.append(args) + missing = Path(self.temp_dir.name) / "missing.json" + + with self.assertRaisesRegex(RuntimeError, "explicit JSON sidecar is missing"): + self.module._load_json( + "SK_CommonGrass", + explicit_path=str(missing), + mesh_path="/Game/Meshes/SK_CommonGrass", + ) + self.assertEqual(calls, []) + + def test_profile_create_race_reuses_exact_existing_target_unchanged(self): + entry = { + "name": "M_stem_common_01", + "master_preset": "tree", + "instance_profile": "dead", + "material_instance_mode": "create_or_reuse", + } + preset = self.module._master_preset({}, entry) + paths = self.module._instance_profile_material_paths(entry, preset) + base = FakeMaterialInstanceConstant(paths["base_path"]) + raced_parent = FakeMaterialInstanceConstant("/Game/Material/UserParent") + raced_target = FakeMaterialInstanceConstant( + paths["target_path"], + parent=raced_parent, + ) + self.runtime.assets[paths["base_path"]] = base + self.runtime.race_create_assets[paths["target_path"]] = raced_target + targets = self.module._validate_instance_profile_targets( + {"materials": [entry]}, + "/Game/Meshes/SK_CommonGrass", + ) + + self.module._ensure_instance_profile_targets( + self.runtime.unreal_module.AssetToolsHelpers.get_asset_tools(), + targets, + ) + + self.assertIs(targets[0]["asset"], raced_target) + self.assertIs(raced_target.parent, raced_parent) + self.assertEqual(self.runtime.created_assets, []) + self.assertEqual(self.runtime.parent_changes, []) + self.assertEqual(self.runtime.save_calls, []) + self.assertEqual(self.runtime.mark_add_calls, []) + self.assertEqual(self.runtime.delete_calls, []) + + def test_new_explicit_and_base_mis_are_saved_and_marked_for_add(self): + asset_tools = self.runtime.unreal_module.AssetToolsHelpers.get_asset_tools() + master = FakeMaterialInstanceConstant("/Game/Material/M_Master") + explicit_path = "/Game/Material/MI/MI_Explicit" + explicit, _path, created, _source = ( + self.module._load_or_copy_target_material( + asset_tools, + explicit_path, + master_mat=master, + ) + ) + self.assertTrue(created) + self.assertIs(explicit.parent, master) + self.assertIn(explicit_path, self.runtime.save_calls) + self.assertIn(explicit_path, self.runtime.mark_add_calls) + + base, base_path, created, _parent_changed, _source = ( + self.module._create_or_load_mi( + asset_tools, + master, + "TreeBase", + "/Game/Material/Tree/MI", + ) + ) + self.assertTrue(created) + self.module._save_and_mark_new_material_asset(base_path) + self.assertIs(base.parent, master) + self.assertIn(base_path, self.runtime.save_calls) + self.assertIn(base_path, self.runtime.mark_add_calls) + + def test_new_myi_report_marks_only_created_layer_for_add(self): + layer_path = "/Game/Material/Tree/MYI/MYI_Test" + + class Helper: + def create_or_update_material_layer_instance(inner_self, *args): + self.runtime.assets[layer_path] = FakeMaterialInstanceConstant( + layer_path + ) + return True, json.dumps({"created": True}), [] + + def set_material_instance_background_layer(inner_self, *args): + return True + + self.runtime.unreal_module.CodexMaterialToolsLibrary = Helper() + self.module._normalize_material_layer_asset = lambda *args: None + self.module._layer_parent_path = lambda preset, entry: "/Game/Layer/Parent" + self.module._layer_instance_path = lambda *args: layer_path + self.module._prune_texture_parameter_overrides = lambda *args, **kwargs: False + self.module._call_set_material_instance_background_layer = ( + lambda *args: (True, []) + ) + + changed = self.module._assign_material_layer_instance( + FakeMaterialInstanceConstant("/Game/Material/MI_Test"), + "Test", + [], + {"key": "tree", "master": "/Game/Master"}, + {}, + ) + + self.assertTrue(changed) + self.assertEqual(self.runtime.mark_add_calls, [layer_path]) + + +if __name__ == "__main__": + unittest.main() From 16ad93dd1170dc8494580bcf1145a008a3092397 Mon Sep 17 00:00:00 2001 From: 424 <2421412@naver.com> Date: Mon, 20 Jul 2026 12:52:41 +0900 Subject: [PATCH 22/25] Pack hair RFAOS payload into Nanite UV channels Add payload UV helpers so the RFAOS hair attributes ride dedicated UV maps for Nanite meshes, simplify the Hair Tool color bake fallbacks in the groom adapter, and ensure hair master material instances keep skeletal mesh usage in the Unreal material setup. Co-Authored-By: Claude Fable 5 --- src/addons/send2ue/core/hair_tool_export.py | 113 +++++++++++++++++- src/addons/send2ue/core/ue_groom_adapter.py | 53 ++++---- .../resources/pipeline/ue_material_setup.py | 20 +++- 3 files changed, 146 insertions(+), 40 deletions(-) diff --git a/src/addons/send2ue/core/hair_tool_export.py b/src/addons/send2ue/core/hair_tool_export.py index 41c050ff..3fb2e95c 100644 --- a/src/addons/send2ue/core/hair_tool_export.py +++ b/src/addons/send2ue/core/hair_tool_export.py @@ -1,5 +1,7 @@ # Copyright Epic Games, Inc. All Rights Reserved. +import math + import bpy from . import armature_modifier_fix, utilities from ..constants import BlenderTypes, ToolInfo @@ -9,6 +11,10 @@ SOURCE_NAME_PROPERTY = '_send2ue_hair_tool_source_name' TEMP_PROPERTY = '_send2ue_hair_tool_temp' RFAOS_NAME = 'RFAOS' +RFAOS_NANITE_UV_RG = 'HairTool_RFAOS_RG' +RFAOS_NANITE_UV_BA = 'HairTool_RFAOS_BA' +RFAOS_NANITE_UV_TAG = 2.0 +RFAOS_NANITE_UV_START_INDEX = 2 def is_hair_tool_object(scene_object): @@ -139,13 +145,34 @@ def _attribute_component( def _pack_rfaos(mesh): - """Pack Random/Factor/AO/SystemColor-alpha into the export vertex color.""" + """Pack RFAOS into vertex color and a Skeletal-Nanite-safe UV mirror.""" random_attribute = mesh.attributes.get('Random') factor_attribute = mesh.attributes.get('Factor') system_color_attribute = mesh.attributes.get('SystemColor') ao_attribute = mesh.attributes.get('AO') loop_to_polygon = _loop_to_polygon_indices(mesh) + if factor_attribute is None: + raise RuntimeError( + f'Hair Tool mesh "{mesh.name}" has no "Factor" attribute. ' + 'Root/Tip ranges cannot be exported without a strand gradient.' + ) + + factor_values = [ + _attribute_component( + mesh, + factor_attribute, + loop_index, + loop_to_polygon=loop_to_polygon, + ) + for loop_index in range(len(mesh.loops)) + ] + if factor_values and max(factor_values) - min(factor_values) <= (1.0 / 255.0): + raise RuntimeError( + f'Hair Tool mesh "{mesh.name}" has a constant "Factor" attribute. ' + 'Root/Tip ranges require a varying 0-1 strand gradient.' + ) + existing_rfaos = mesh.attributes.get(RFAOS_NAME) if existing_rfaos: mesh.attributes.remove(existing_rfaos) @@ -155,16 +182,15 @@ def _pack_rfaos(mesh): type='BYTE_COLOR', domain='CORNER', ) - for loop_index, color_item in enumerate(rfaos.data): + packed_values = [] + channel_names = ('Random', 'Factor', 'AO', 'SystemColor Alpha') + for loop_index in range(len(mesh.loops)): packed = ( _attribute_component( mesh, random_attribute, loop_index, loop_to_polygon=loop_to_polygon, ), - _attribute_component( - mesh, factor_attribute, loop_index, - loop_to_polygon=loop_to_polygon, - ), + factor_values[loop_index], _attribute_component( mesh, ao_attribute, loop_index, loop_to_polygon=loop_to_polygon, @@ -175,6 +201,15 @@ def _pack_rfaos(mesh): loop_to_polygon=loop_to_polygon, ), ) + for channel_name, value in zip(channel_names, packed): + if not math.isfinite(value) or value < -1.0e-5 or value > 1.00001: + raise RuntimeError( + f'Hair Tool mesh "{mesh.name}" has invalid {channel_name} ' + f'value {value!r} at loop {loop_index}; RFAOS data must be finite 0-1.' + ) + packed_values.append(tuple(min(max(value, 0.0), 1.0) for value in packed)) + + for color_item, packed in zip(rfaos.data, packed_values): # BYTE_COLOR exposes ``color`` in scene-linear space, while FBX writes # the underlying sRGB byte values. RFAOS contains data masks, not # display colors, so write the sRGB-facing property to keep the numeric @@ -191,6 +226,72 @@ def _pack_rfaos(mesh): mesh.color_attributes.active_color = mesh.color_attributes[RFAOS_NAME] mesh.color_attributes.render_color_index = mesh.color_attributes.find(RFAOS_NAME) + _pack_rfaos_nanite_uvs(mesh, packed_values) + + +def _ensure_payload_uv(mesh, name, index): + uv_layers = mesh.uv_layers + layer = uv_layers.get(name) + if layer is None: + if len(uv_layers) > index: + raise RuntimeError( + f'Hair Tool Nanite payload UV{index} is occupied by ' + f'"{uv_layers[index].name}" on "{mesh.name}".' + ) + if len(uv_layers) != index: + raise RuntimeError( + f'Hair Tool Nanite payload requires UV{index}, but ' + f'"{mesh.name}" currently has {len(uv_layers)} UV channels.' + ) + layer = uv_layers.new(name=name) + + layer_index = next( + (layer_index for layer_index, candidate in enumerate(uv_layers) if candidate == layer), + -1, + ) + if layer_index != index: + raise RuntimeError( + f'Hair Tool Nanite payload "{name}" must be UV{index}, ' + f'not UV{layer_index}, on "{mesh.name}".' + ) + if len(layer.data) != len(mesh.loops): + raise RuntimeError( + f'Hair Tool Nanite payload "{name}" loop count does not match ' + f'"{mesh.name}".' + ) + return layer + + +def _pack_rfaos_nanite_uvs(mesh, packed_values): + """Mirror RFAOS into UV2/UV3 because UE 5.8 Skeletal Nanite drops colors. + + UV2 stores tagged Random in U and inverse-transported Factor in V. + UV3 stores tagged AO in U and inverse-transported SystemColor mask in V. + The FBX skeletal importer applies ``V = 1 - V``, so Unreal receives the + original Factor and SystemColor values in the V components. + """ + rg_layer = _ensure_payload_uv( + mesh, + RFAOS_NANITE_UV_RG, + RFAOS_NANITE_UV_START_INDEX, + ) + ba_layer = _ensure_payload_uv( + mesh, + RFAOS_NANITE_UV_BA, + RFAOS_NANITE_UV_START_INDEX + 1, + ) + + for index, packed in enumerate(packed_values): + random_value, factor, ao, system_mask = packed + rg_layer.data[index].uv = ( + RFAOS_NANITE_UV_TAG + random_value, + 1.0 - factor, + ) + ba_layer.data[index].uv = ( + RFAOS_NANITE_UV_TAG + ao, + 1.0 - system_mask, + ) + def _remove_empty_material_slots(scene_object): mesh = scene_object.data diff --git a/src/addons/send2ue/core/ue_groom_adapter.py b/src/addons/send2ue/core/ue_groom_adapter.py index 703001c7..43e2def3 100644 --- a/src/addons/send2ue/core/ue_groom_adapter.py +++ b/src/addons/send2ue/core/ue_groom_adapter.py @@ -654,7 +654,7 @@ def _map_range_clamped(value, from_min, from_max, to_min, to_max): def _bake_hair_tool_colors(curves, starts, counts, settings): - """Bake HairShaderMain's Base→Root→Tip→SystemColor rule per point.""" + """Bake HairShaderMain's Blender-owned Base→Root→Tip rule per point.""" by_name = {attribute.name.casefold(): attribute for attribute in curves.attributes} factor_attribute = by_name.get('factor') factors = _point_values(factor_attribute, starts, counts) @@ -665,11 +665,6 @@ def _bake_hair_tool_colors(curves, starts, counts, settings): factors.extend(index / denominator for index in range(count)) factors = [float(value) for value in factors] - system_attribute = by_name.get('systemcolor') - system_colors = _point_values(system_attribute, starts, counts) - if system_colors is None: - system_colors = [(0.0, 0.0, 0.0)] * sum(counts) - random_attribute = by_name.get('random') random_values = _point_values(random_attribute, starts, counts) if random_values is None: @@ -677,8 +672,11 @@ def _bake_hair_tool_colors(curves, starts, counts, settings): root_range = _clamp01(settings['root_range']) tip_range = _clamp01(settings['tip_range']) + root_mix = float(settings['root_mix']) + tip_mix = float(settings['tip_mix']) + range_epsilon = 1e-12 colors = [] - for factor, system_color, random_value in zip(factors, system_colors, random_values): + for factor, random_value in zip(factors, random_values): if isinstance(random_value, (tuple, list)): random_value = random_value[0] random_value = float(random_value) @@ -688,10 +686,13 @@ def _bake_hair_tool_colors(curves, starts, counts, settings): random_value + settings['root_texture_brightness'], settings['root_texture_overlay'], ) - root_weight = _clamp01( - _map_range_clamped(factor, 0.0, root_range, root_extent, 0.0) - * settings['root_mix'] - ) + if root_range <= range_epsilon or root_mix <= 0.0: + root_weight = 0.0 + else: + root_weight = _clamp01( + _map_range_clamped(factor, 0.0, root_range, root_extent, 0.0) + * root_mix + ) color = tuple( _lerp(settings['base_color'][channel], settings['root_color'][channel], root_weight) for channel in range(3) @@ -702,21 +703,19 @@ def _bake_hair_tool_colors(curves, starts, counts, settings): random_value + settings['tip_texture_brightness'], settings['tip_texture_overlay'], ) - tip_weight = _clamp01( - _map_range_clamped(factor, 1.0 - tip_range, 1.0, 0.0, tip_extent) - * settings['tip_mix'] - ) + if tip_range <= range_epsilon or tip_mix <= 0.0: + tip_weight = 0.0 + else: + tip_weight = _clamp01( + _map_range_clamped(factor, 1.0 - tip_range, 1.0, 0.0, tip_extent) + * tip_mix + ) color = tuple( _lerp(color[channel], settings['tip_color'][channel], tip_weight) for channel in range(3) ) - - system_color = tuple(float(component) for component in system_color[:3]) - colors.append(tuple( - color[channel] + system_color[channel] * settings['system_color_mix'] - for channel in range(3) - )) - return colors, factor_attribute is not None, system_attribute is not None + colors.append(color) + return colors, factor_attribute is not None def _mapping_error(report, adapter, kind, attribute): @@ -1004,25 +1003,21 @@ def inspect_object(scene_object, properties=None): ): colors = _point_values(color_attribute, starts, counts) elif hair_tool_color: - colors, has_factor, has_system_color = _bake_hair_tool_colors( + colors, has_factor = _bake_hair_tool_colors( curves, starts, counts, hair_tool_color ) report['mappings']['color'] = ( f'{hair_tool_color["material"]}:{hair_tool_color["node"]} ' - '(Base→Root→Tip→SystemColor)' + '(Base→Root→Tip)' ) if not has_factor: report['warnings'].append( 'Hair Tool Factor 속성이 없어 커브 길이 기준 0~1 Factor를 사용합니다.' ) - if hair_tool_color['system_color_mix'] and not has_system_color: - report['warnings'].append( - 'SystemColor 속성이 없어 Hair Tool 시스템 컬러 가산값은 0으로 사용합니다.' - ) if hair_tool_color['texture_inputs']: report['warnings'].append( 'Hair Tool 카드 프로필 텍스처 입력은 네이티브 Groom에 직접 대응하지 않아 ' - 'Root/Tip/SystemColor 코어 규칙만 groom_color에 베이크합니다: ' + 'Base/Root/Tip 코어 규칙만 groom_color에 베이크합니다: ' + ', '.join(hair_tool_color['texture_inputs']) ) else: diff --git a/src/addons/send2ue/resources/pipeline/ue_material_setup.py b/src/addons/send2ue/resources/pipeline/ue_material_setup.py index 51f4cca3..2336dd32 100644 --- a/src/addons/send2ue/resources/pipeline/ue_material_setup.py +++ b/src/addons/send2ue/resources/pipeline/ue_material_setup.py @@ -264,12 +264,13 @@ def _speedtree_handoff_api(): "Texture": "Albedo", } -# Parameters initialized from Blender only when a Hair Tool MI is first created. -# Existing instances own these values so artist edits survive later re-exports. +# Unreal-owned Hair Tool controls. Blender sidecars intentionally omit these, +# and existing instance overrides must survive every later re-export. HAIR_INSTANCE_OWNED_SCALAR_PARAMETERS = { "System Color Influence", "System Mask Contrast", "System Mask Bias", + "System Mask Invert", "Roughness Multiplier", } HAIR_INSTANCE_OWNED_VECTOR_PARAMETERS = { @@ -3074,15 +3075,24 @@ def _ensure_hair_master_skeletal_mesh_usage(mi) -> bool: _warn(" hair MI base material missing; skeletal usage could not be checked") return False try: - if bool(master.get_editor_property("used_with_skeletal_mesh")): + changed = False + if not bool(master.get_editor_property("used_with_skeletal_mesh")): + master.set_editor_property("used_with_skeletal_mesh", True) + changed = True + try: + if not bool(master.get_editor_property("used_with_nanite")): + master.set_editor_property("used_with_nanite", True) + changed = True + except Exception: + pass + if not changed: return False - master.set_editor_property("used_with_skeletal_mesh", True) compile_errors = unreal.MaterialEditingLibrary.recompile_material(master) or [] for error in compile_errors: _warn(f" hair master skeletal shader compile: {error}") master_path = master.get_path_name().split(".")[0] unreal.EditorAssetLibrary.save_asset(master_path, only_if_is_dirty=False) - _log(f" enabled hair master skeletal mesh usage: {master_path}") + _log(f" enabled hair master skeletal/Nanite usage: {master_path}") return True except Exception as exc: _warn(f" failed to enable hair master skeletal mesh usage: {exc}") From a2bec05d87d955fa443be79c4eda145de7cd65a6 Mon Sep 17 00:00:00 2001 From: 424 <2421412@naver.com> Date: Wed, 22 Jul 2026 17:53:44 +0900 Subject: [PATCH 23/25] Protect production materials during isolated Codex tests --- .../resources/pipeline/ue_material_setup.py | 68 ++++++++++- tests/test_ue_material_setup.py | 108 +++++++++++++++++- 2 files changed, 173 insertions(+), 3 deletions(-) diff --git a/src/addons/send2ue/resources/pipeline/ue_material_setup.py b/src/addons/send2ue/resources/pipeline/ue_material_setup.py index 2336dd32..b383db57 100644 --- a/src/addons/send2ue/resources/pipeline/ue_material_setup.py +++ b/src/addons/send2ue/resources/pipeline/ue_material_setup.py @@ -1307,6 +1307,12 @@ def _master_preset(data: dict, entry: dict = None, mesh_path: str = "") -> dict: result = dict(preset) if key == "tree": result.update(_tree_preset_contract_overlay()) + scope = data.get("codex_test_asset_scope") + if isinstance(scope, dict) and _is_codex_test_asset_path(mesh_path): + scope_root = str(scope.get("root") or "").rstrip("/") + if _is_codex_test_asset_path(scope_root): + result["mi_folder"] = scope_root + "/MI" + result["layer_instance_folder"] = scope_root + "/MYI" result["key"] = key if tree_part: result["tree_part"] = tree_part @@ -2502,6 +2508,38 @@ def _layer_instance_path(mat_base: str, preset: dict, entry: dict): return f"{folder}/MYI_{base}" +def _validate_codex_test_material_scope(data: dict, mesh_path: str) -> bool: + if not _is_codex_test_asset_path(mesh_path): + return False + scope = data.get("codex_test_asset_scope") + if not isinstance(scope, dict): + raise RuntimeError( + "Codex test material sidecar has no isolated asset scope contract" + ) + root = str(scope.get("root") or "").rstrip("/") + if not _is_codex_test_asset_path(root): + raise RuntimeError("Codex test material scope root is outside /Game/Codex/Tests") + for entry in data.get("materials", []): + target_path = _entry_target_material_path(entry) + if not target_path or not target_path.startswith(root + "/MI/"): + raise RuntimeError( + "Codex test material target is outside the isolated MI scope" + ) + material_layer = entry.get("material_layer") + if not isinstance(material_layer, dict): + raise RuntimeError("Codex test material has no isolated layer target") + layer_path = str( + material_layer.get("instance_path") + or material_layer.get("path") + or "" + ).split(".", 1)[0] + if not layer_path.startswith(root + "/MYI/"): + raise RuntimeError( + "Codex test layer target is outside the isolated MYI scope" + ) + return True + + def _layer_parent_path(preset: dict, entry: dict): material_layer = entry.get("material_layer") if isinstance(entry.get("material_layer"), dict) else {} for key in ("parent", "parent_layer"): @@ -2639,7 +2677,24 @@ def _call_create_or_update_layer_instance(helper, parent_layer, layer_path, text _NORMALIZED_MATERIAL_LAYER_ASSETS = set() -def _normalize_material_layer_asset(helper, method_name: str, asset_path: str, label: str): +def _is_codex_test_asset_path(asset_path: str) -> bool: + package_path = str(asset_path or "").split(".", 1)[0].replace("\\", "/") + return package_path.casefold().startswith("/game/codex/tests/") + + +def _normalize_material_layer_asset( + helper, + method_name: str, + asset_path: str, + label: str, + mutation_scope_path: str = "", +): + if ( + _is_codex_test_asset_path(mutation_scope_path) + and not _is_codex_test_asset_path(asset_path) + ): + _log(f" {label} kept read-only for isolated test: {asset_path}") + return cache_key = (method_name, asset_path) if cache_key in _NORMALIZED_MATERIAL_LAYER_ASSETS: return @@ -2749,12 +2804,14 @@ def _assign_material_layer_instance(mi, mat_base: str, layer_maps, preset: dict, "normalize_material_layer_placeholders", str(preset.get("master") or ""), "material master", + mutation_scope_path=layer_path, ) _normalize_material_layer_asset( helper, "normalize_material_function_attribute_nodes", parent_layer, "material layer function", + mutation_scope_path=layer_path, ) remap = _layer_texture_remap(preset, entry) @@ -3304,7 +3361,10 @@ def _material_pipeline_mutation_paths(mesh_path: str, data: dict) -> list: if layer_path: paths.append(layer_path) - return list(dict.fromkeys(path.split(".")[0] for path in paths if path)) + paths = list(dict.fromkeys(path.split(".")[0] for path in paths if path)) + if _is_codex_test_asset_path(mesh_path): + paths = [path for path in paths if _is_codex_test_asset_path(path)] + return paths def _checkout_material_pipeline_assets(mesh_path: str, data: dict) -> list: @@ -3343,6 +3403,7 @@ def preflight_mesh_materials(mesh_path: str, json_path: str = None) -> bool: if not data: return False _validate_speedtree_handoff_contract(data, mesh_name) + _validate_codex_test_material_scope(data, mesh_path) instance_profile_targets = _validate_instance_profile_targets( data, mesh_path @@ -3370,6 +3431,7 @@ def preflight_mesh_materials(mesh_path: str, json_path: str = None) -> bool: "normalize_material_layer_placeholders", master_path, "material master", + mutation_scope_path=mesh_path, ) normalized = True if parent_layer: @@ -3378,6 +3440,7 @@ def preflight_mesh_materials(mesh_path: str, json_path: str = None) -> bool: "normalize_material_function_attribute_nodes", parent_layer, "material layer function", + mutation_scope_path=mesh_path, ) normalized = True return normalized @@ -3398,6 +3461,7 @@ def process_mesh(mesh_path: str, master_mat=None, json_path: str = None) -> bool asset_tools = None if data: _validate_speedtree_handoff_contract(data, mesh_name) + _validate_codex_test_material_scope(data, mesh_path) instance_profile_targets = _validate_instance_profile_targets( data, mesh_path ) diff --git a/tests/test_ue_material_setup.py b/tests/test_ue_material_setup.py index 4a6a64c4..68ce53a4 100644 --- a/tests/test_ue_material_setup.py +++ b/tests/test_ue_material_setup.py @@ -618,6 +618,112 @@ def test_preflight_mutation_paths_exclude_texture_assets(self): self.assertNotIn("/Game/Textures/T_Direct", paths) self.assertNotIn("/Game/Textures/T_Layer", paths) + def test_codex_test_mutation_paths_keep_production_references_read_only(self): + self.module._master_preset = lambda data, entry, mesh_path: { + "master": "/Game/Material/Tree/Master/M_Tree", + "assignment": "material_layer_instance", + "mi_folder": "/Game/Codex/Tests/Elm/_MaterialPipeline/MI", + } + self.module._layer_parent_path = ( + lambda preset, entry: "/Game/Material/Tree/Layer/MY_Tree" + ) + self.module._entry_target_material_path = ( + lambda entry: "/Game/Codex/Tests/Elm/_MaterialPipeline/MI/MI_Bark" + ) + self.module._layer_instance_path = ( + lambda *args: "/Game/Codex/Tests/Elm/_MaterialPipeline/MYI/MYI_Bark" + ) + + paths = self.module._material_pipeline_mutation_paths( + "/Game/Codex/Tests/Elm/SK_Tree", + {"materials": [{"name": "M_Bark"}]}, + ) + + self.assertIn("/Game/Codex/Tests/Elm/SK_Tree", paths) + self.assertIn( + "/Game/Codex/Tests/Elm/_MaterialPipeline/MI/MI_Bark", paths + ) + self.assertIn( + "/Game/Codex/Tests/Elm/_MaterialPipeline/MYI/MYI_Bark", paths + ) + self.assertNotIn("/Game/Material/Tree/Master/M_Tree", paths) + self.assertNotIn("/Game/Material/Tree/Layer/MY_Tree", paths) + + def test_codex_test_normalization_skips_production_reference(self): + calls = [] + + class Helper: + def normalize_material_layer_placeholders(inner_self, asset_path): + calls.append(asset_path) + return True + + self.module._normalize_material_layer_asset( + Helper(), + "normalize_material_layer_placeholders", + "/Game/Material/Tree/Master/M_Tree", + "material master", + mutation_scope_path="/Game/Codex/Tests/Elm/SK_Tree", + ) + + self.assertEqual(calls, []) + + def test_codex_test_scope_requires_isolated_explicit_targets(self): + valid = { + "codex_test_asset_scope": { + "root": "/Game/Codex/Tests/Elm/_MaterialPipeline", + }, + "materials": [ + { + "name": "M_Bark", + "target_material_path": ( + "/Game/Codex/Tests/Elm/_MaterialPipeline/MI/MI_Bark" + ), + "material_layer": { + "instance_path": ( + "/Game/Codex/Tests/Elm/_MaterialPipeline/MYI/MYI_Bark" + ), + }, + } + ], + } + self.assertTrue( + self.module._validate_codex_test_material_scope( + valid, "/Game/Codex/Tests/Elm/SK_Tree" + ) + ) + + invalid = json.loads(json.dumps(valid)) + invalid["materials"][0]["target_material_path"] = ( + "/Game/Material/Tree/AssetTree/MI/MI_Bark" + ) + with self.assertRaisesRegex(RuntimeError, "isolated MI scope"): + self.module._validate_codex_test_material_scope( + invalid, "/Game/Codex/Tests/Elm/SK_Tree" + ) + + def test_codex_test_scope_overrides_tree_contract_output_folders(self): + self.module._tree_preset_contract_overlay = lambda: { + "mi_folder": "/Game/Material/Tree/AssetTree/MI", + "layer_instance_folder": "/Game/Material/Tree/AssetTree/MYI", + } + preset = self.module._master_preset( + { + "codex_test_asset_scope": { + "root": "/Game/Codex/Tests/Elm/_MaterialPipeline", + } + }, + {"name": "M_Bark", "master_preset": "tree"}, + "/Game/Codex/Tests/Elm/SK_Tree", + ) + self.assertEqual( + preset["mi_folder"], + "/Game/Codex/Tests/Elm/_MaterialPipeline/MI", + ) + self.assertEqual( + preset["layer_instance_folder"], + "/Game/Codex/Tests/Elm/_MaterialPipeline/MYI", + ) + def test_material_checkout_skips_added_assets_and_blocks_other_user(self): added_path = "/Game/Material/Tree/AssetTree/MI/MI_New" other_path = "/Game/Material/Tree/AssetTree/MI/MI_Other" @@ -1143,7 +1249,7 @@ def set_material_instance_background_layer(inner_self, *args): return True self.runtime.unreal_module.CodexMaterialToolsLibrary = Helper() - self.module._normalize_material_layer_asset = lambda *args: None + self.module._normalize_material_layer_asset = lambda *args, **kwargs: None self.module._layer_parent_path = lambda preset, entry: "/Game/Layer/Parent" self.module._layer_instance_path = lambda *args: layer_path self.module._prune_texture_parameter_overrides = lambda *args, **kwargs: False From a14e171c7fa71737b40c5e01fac006c6dedf19e7 Mon Sep 17 00:00:00 2001 From: 424 <2421412@naver.com> Date: Fri, 31 Jul 2026 01:03:59 +0900 Subject: [PATCH 24/25] Harden Send2UE material and skeletal asset handoff --- .../extensions/send2ue_material_pipeline.py | 93 ++++++- .../resources/pipeline/ue_material_setup.py | 175 +++++++++++- ...est_send2ue_material_pipeline_extension.py | 82 ++++++ tests/test_ue_material_setup.py | 253 +++++++++++++++++- 4 files changed, 569 insertions(+), 34 deletions(-) diff --git a/src/addons/send2ue/resources/extensions/send2ue_material_pipeline.py b/src/addons/send2ue/resources/extensions/send2ue_material_pipeline.py index b6f8f05a..5f032fde 100644 --- a/src/addons/send2ue/resources/extensions/send2ue_material_pipeline.py +++ b/src/addons/send2ue/resources/extensions/send2ue_material_pipeline.py @@ -21,6 +21,8 @@ _TEXTURELESS_FBX_RESTORE = {} TEXTURELESS_FBX_EXPORT_FLAG = "send2ue_material_pipeline_textureless_fbx_export" MATERIAL_PIPELINE_JSON_PATH_KEY = "_material_pipeline_json_path" +MATERIAL_PIPELINE_JSON_FROM_EXPORT_KEY = "_material_pipeline_json_from_export" +_POST_OPERATION_SKELETAL_ASSET_PATHS = [] class MaterialPipelineExtension(ExtensionBase): @@ -34,6 +36,32 @@ class MaterialPipelineExtension(ExtensionBase): ), ) + def pre_operation(self, properties): + _POST_OPERATION_SKELETAL_ASSET_PATHS.clear() + + def post_operation(self, properties): + asset_paths = list(dict.fromkeys(_POST_OPERATION_SKELETAL_ASSET_PATHS)) + _POST_OPERATION_SKELETAL_ASSET_PATHS.clear() + if not self.enabled or not asset_paths: + return + + paths_arg = repr(asset_paths) + commands = [ + "import sys", + "import importlib.util", + "import unreal", + f'_d = r"{PIPELINE_DIR}"', + "_pipeline_file = _d.rstrip('/') + '/ue_material_setup.py'", + "_spec = importlib.util.spec_from_file_location('send2ue_bundled_ue_material_setup_post_operation', _pipeline_file)", + "if _spec is None or _spec.loader is None:", + "\traise RuntimeError('Could not load bundled material pipeline: ' + _pipeline_file)", + "_p = importlib.util.module_from_spec(_spec)", + "sys.modules[_spec.name] = _p", + "_spec.loader.exec_module(_p)", + f"_p.persist_generated_skeleton_dependencies({paths_arg})", + ] + run_commands(commands) + def pre_mesh_export(self, asset_data, properties): if not self.enabled: return @@ -143,6 +171,14 @@ def _load_json_sidecar_for_export(self, asset_data, target): ) return None + # Preserve the exact asset-unit sidecar through FBX export and import. + # The pre-import asset path can still carry the source child mesh name + # even when the final Unreal asset is named after its parent Empty. + asset_data[MATERIAL_PIPELINE_JSON_PATH_KEY] = str(json_path).replace( + "\\", "/" + ) + asset_data[MATERIAL_PIPELINE_JSON_FROM_EXPORT_KEY] = True + try: with open(json_path, "r", encoding="utf-8") as handle: return json.load(handle) @@ -159,14 +195,6 @@ def _load_json_sidecar_for_export(self, asset_data, target): def _resolve_json_path_for_export(self, asset_data, target): candidates = [] - for value in ( - asset_data.get("file_path"), - asset_data.get("asset_path"), - target.name, - ): - name = self._asset_name_from_value(value) - if name and name not in candidates: - candidates.append(name) try: from ue_unique_export_names_addon import api as handoff_api @@ -177,6 +205,21 @@ def _resolve_json_path_for_export(self, asset_data, target): ) return None + # The pre-export target is the source mesh object, while Send to Unreal + # may name the imported asset after its highest Empty ancestor. Ask the + # handoff add-on for that exact asset-unit name before trying child names. + asset_unit_name = handoff_api.resolve_asset_unit_name(target, bpy.context) + if asset_unit_name: + candidates.append(asset_unit_name) + for value in ( + asset_data.get("file_path"), + asset_data.get("asset_path"), + target.name, + ): + name = self._asset_name_from_value(value) + if name and name not in candidates: + candidates.append(name) + return handoff_api.resolve_sidecar_json_path(candidates, bpy.context) def _asset_name_from_value(self, value): @@ -259,12 +302,24 @@ def pre_import(self, asset_data, properties): }: return asset_path = asset_data.get("asset_path", "") - asset_data.pop(MATERIAL_PIPELINE_JSON_PATH_KEY, None) - json_path = self._resolve_json_path(asset_path) + from_mesh_export = bool( + asset_data.pop(MATERIAL_PIPELINE_JSON_FROM_EXPORT_KEY, False) + ) + json_path = ( + asset_data.get(MATERIAL_PIPELINE_JSON_PATH_KEY) + if from_mesh_export + else None + ) + if not from_mesh_export: + asset_data.pop(MATERIAL_PIPELINE_JSON_PATH_KEY, None) if not json_path: + json_path = self._resolve_json_path(asset_path) + if not json_path: + asset_data.pop(MATERIAL_PIPELINE_JSON_PATH_KEY, None) return json_path = str(json_path).replace("\\", "/") asset_data[MATERIAL_PIPELINE_JSON_PATH_KEY] = json_path + preflight_asset_path = self._preflight_asset_path(asset_path, json_path) json_arg = repr(json_path) commands = [ @@ -272,7 +327,7 @@ def pre_import(self, asset_data, properties): "import importlib.util", "import unreal", f'_d = r"{PIPELINE_DIR}"', - f'_asset_path = r"{asset_path}".split(".")[0]', + f'_asset_path = r"{preflight_asset_path}".split(".")[0]', "_pipeline_file = _d.rstrip('/') + '/ue_material_setup.py'", "_spec = importlib.util.spec_from_file_location('send2ue_bundled_ue_material_setup_preflight', _pipeline_file)", "if _spec is None or _spec.loader is None:", @@ -289,6 +344,20 @@ def pre_import(self, asset_data, properties): # assets that immediately need cleanup, which is expensive in Unreal/P4. asset_data["_import_materials_and_textures"] = False + def _preflight_asset_path(self, asset_path, json_path): + """Match preflight identity to the exact sidecar-selected asset unit.""" + try: + with open(json_path, "r", encoding="utf-8") as handle: + sidecar = json.load(handle) + except (OSError, json.JSONDecodeError, TypeError): + return asset_path + mesh_name = str(sidecar.get("mesh_name") or "") + base_path = str(asset_path or "").split(".")[0] + if not mesh_name or "/" not in base_path: + return asset_path + folder = base_path.rsplit("/", 1)[0] + return f"{folder}/{mesh_name}" + def post_import(self, asset_data, properties): if not self.enabled: return @@ -307,6 +376,8 @@ def post_import(self, asset_data, properties): json_path = asset_data.get(MATERIAL_PIPELINE_JSON_PATH_KEY) if not json_path: return + if asset_data.get("_asset_type") == UnrealTypes.SKELETAL_MESH: + _POST_OPERATION_SKELETAL_ASSET_PATHS.append(asset_path) json_arg = repr(str(json_path)) commands = [ diff --git a/src/addons/send2ue/resources/pipeline/ue_material_setup.py b/src/addons/send2ue/resources/pipeline/ue_material_setup.py index b383db57..e33018c4 100644 --- a/src/addons/send2ue/resources/pipeline/ue_material_setup.py +++ b/src/addons/send2ue/resources/pipeline/ue_material_setup.py @@ -135,6 +135,7 @@ def _speedtree_handoff_api(): "Extra": "Extra", "Normal": "Normal", "Height": "Height", + "Opacity Map": "Opacity Map", "Subsurface": "Subsurface", }, "virtual_textures": True, @@ -701,7 +702,14 @@ def _desired_texture_settings( "srgb": srgb, "compression_settings": compression, "max_texture_size": DEFAULT_MAX_TEXTURE_SIZE, - "virtual_texture_streaming": bool(ENABLE_VIRTUAL_TEXTURE_STREAMING), + # OpacityMask is sampled by a non-VT linear grayscale parameter in the + # canonical tree foliage layer. Re-enabling VT here makes the instance + # override incompatible with that graph and restores solid white cards. + "virtual_texture_streaming": ( + False + if param in {"Opacity", "Opacity Map", "Alpha"} + else bool(ENABLE_VIRTUAL_TEXTURE_STREAMING) + ), } @@ -2082,6 +2090,10 @@ def _new_skeletal_material_entry(slot_name: str, material, old_entry=None): entry = unreal.SkeletalMaterial() entry.set_editor_property("material_interface", material) entry.set_editor_property("material_slot_name", slot_name) + try: + entry.set_editor_property("imported_material_slot_name", slot_name) + except Exception: + pass if old_entry is not None: for prop in ("uv_channel_data", "overlay_material_interface"): try: @@ -2091,6 +2103,53 @@ def _new_skeletal_material_entry(slot_name: str, material, old_entry=None): return entry +def _remap_skeletal_material_sections(mesh, ordered) -> bool: + helper = getattr(unreal, "CodexMaterialToolsLibrary", None) + method = getattr(helper, "remap_skeletal_mesh_material_sections", None) + if not callable(method): + raise RuntimeError( + "CodexMaterialTools skeletal material-section remap helper is missing" + ) + old_indices = [int(old_index) for old_index, _slot_name, _material in ordered] + new_indices = list(range(len(ordered))) + result = method(mesh, old_indices, new_indices, True) + values = result if isinstance(result, tuple) else (result,) + explicit_success = next( + (value for value in values if isinstance(value, bool)), + None, + ) + payload_text = next( + ( + value + for value in values + if isinstance(value, str) and value.lstrip().startswith("{") + ), + "{}", + ) + errors = next((value for value in values if isinstance(value, list)), []) + try: + payload = json.loads(payload_text) + except (TypeError, ValueError): + payload = {} + # Depending on the live UE Python wrapper, a BlueprintCallable function + # with output references can surface only OutJson (or OutJson/OutErrors) + # and omit its native bool return. The helper writes the same result to + # the required JSON ``ok`` field, so use that authoritative value when no + # explicit bool was exposed. + success = ( + explicit_success + if explicit_success is not None + else bool(payload.get("ok")) + ) + if not success: + raise RuntimeError( + "skeletal material-section remap failed: " + + "; ".join(str(value) for value in errors) + + (" | " + payload_text if payload_text else "") + ) + return bool(payload.get("changed")) + + def _assign_skeletal_slot(mesh, slot_index: int, material, slot_name: str = None) -> bool: property_name, material_entries = _mesh_material_entries(mesh) if property_name != "materials" or slot_index < 0 or slot_index >= len(material_entries): @@ -2156,16 +2215,21 @@ def _normalize_skeletal_material_slots(mesh, assignments: dict) -> bool: ): unchanged = False break - if unchanged: - return False + materials_changed = not unchanged + if materials_changed: + new_entries = [] + for old_index, slot_name, material in ordered: + old_entry = material_entries[old_index] if 0 <= old_index < len(material_entries) else None + new_entries.append(_new_skeletal_material_entry(slot_name, material, old_entry)) + mesh.set_editor_property("materials", new_entries) - new_entries = [] - for old_index, slot_name, material in ordered: - old_entry = material_entries[old_index] if 0 <= old_index < len(material_entries) else None - new_entries.append(_new_skeletal_material_entry(slot_name, material, old_entry)) - - mesh.set_editor_property("materials", new_entries) - return True + try: + sections_changed = _remap_skeletal_material_sections(mesh, ordered) + except Exception: + if materials_changed: + mesh.set_editor_property("materials", material_entries) + raise + return materials_changed or sections_changed def _set_material_interface(mesh, slot_index: int, material) -> bool: @@ -2246,7 +2310,7 @@ def _tree_texture_param_allowed(param: str, preset: dict = None) -> bool: preset.get("tree_shading"), ) param = str(param or "").strip().casefold() - if param in {"alpha", "opacity", "opacity map", "transmission"}: + if param == "transmission": return False if param == "subsurface" and preset.get("tree_shading") == "wood": return False @@ -2642,8 +2706,10 @@ def _layer_texture_remap(preset: dict, entry: dict) -> dict: mapping = preset.get("layer_texture_remap", {}) result = {str(key): str(value) for key, value in dict(mapping).items() if key and value} if preset.get("key") == "tree": - for unused_param in ("Alpha", "Opacity", "Opacity Map", "Transmission"): - result.pop(unused_param, None) + result.pop("Transmission", None) + result["Alpha"] = "Opacity Map" + result["Opacity"] = "Opacity Map" + result["Opacity Map"] = "Opacity Map" if preset.get("tree_shading") != "wood": result["Subsurface"] = "Subsurface" else: @@ -3446,6 +3512,51 @@ def preflight_mesh_materials(mesh_path: str, json_path: str = None) -> bool: return normalized +def _project_asset_package_file_exists(asset_path: str) -> bool: + """Return whether a /Game asset package has reached the project Content folder.""" + package_path = str(asset_path or "").split(".")[0] + if not package_path.startswith("/Game/"): + return False + try: + content_dir = unreal.Paths.convert_relative_path_to_full( + unreal.Paths.project_content_dir() + ) + except Exception: + return False + relative_path = package_path[len("/Game/") :].replace("/", os.sep) + return os.path.isfile(os.path.join(content_dir, relative_path + ".uasset")) + + +def _save_generated_skeleton_dependency(mesh, mesh_path: str, helper) -> bool: + """Persist the default Skeleton created by a Send2UE skeletal-mesh import. + + ImportAssetTasks can leave the generated ``_Skeleton`` package only in + memory. Saving the mesh alone then serializes a dependency that disappears + after an editor restart. Existing or explicitly shared Skeleton assets are + intentionally left untouched. + """ + try: + skeleton = mesh.get_editor_property("skeleton") + except Exception: + skeleton = None + if skeleton is None: + return False + + get_path_name = getattr(skeleton, "get_path_name", None) + skeleton_path = ( + str(get_path_name()).split(".")[0] if callable(get_path_name) else "" + ) + expected_path = f"{str(mesh_path).split('.')[0]}_Skeleton" + if skeleton_path != expected_path: + return False + if _project_asset_package_file_exists(skeleton_path): + return False + if not helper.save_asset_package_without_thumbnail(skeleton): + raise RuntimeError(f"generated Skeleton save failed: {skeleton_path}") + _log(f" saved generated Skeleton dependency: {skeleton_path}") + return True + + def process_mesh(mesh_path: str, master_mat=None, json_path: str = None) -> bool: """단일 StaticMesh/SkeletalMesh 를 JSON 기반으로 처리. 변경이 있었으면 True. @@ -3481,6 +3592,7 @@ def save_mesh_asset(): raise RuntimeError( "CodexMaterialTools safe skeletal-mesh save helper is missing" ) + _save_generated_skeleton_dependency(mesh, mesh_path, helper) if not helper.save_asset_package_without_thumbnail(mesh): raise RuntimeError(f"safe skeletal-mesh save failed: {mesh_path}") return @@ -3709,10 +3821,47 @@ def save_mesh_asset(): _log(f"메쉬 '{mesh_name}' 완료") # 마지막으로 브라우저를 메쉬로 돌려 선택 상태로 끝낸다(텍스처 폴더로 튀는 것 방지). + if not changed and _is_skeletal_mesh(mesh): + # A no-op reimport can still create the default Skeleton package only in + # memory. Persist that dependency even when materials and Nanite settings + # already match, then resave the mesh so the reference survives restart. + helper = getattr(unreal, "CodexMaterialToolsLibrary", None) + if not helper or not hasattr(helper, "save_asset_package_without_thumbnail"): + raise RuntimeError( + "CodexMaterialTools safe skeletal-mesh save helper is missing" + ) + if _save_generated_skeleton_dependency(mesh, mesh_path, helper): + if not helper.save_asset_package_without_thumbnail(mesh): + raise RuntimeError(f"safe skeletal-mesh save failed: {mesh_path}") + _log(f" persisted generated Skeleton reference for: {mesh_name}") + _sync_browser_to_mesh(mesh_path) return changed +def persist_generated_skeleton_dependencies(mesh_paths) -> int: + """Persist generated Skeleton packages after every Send2UE import completes.""" + helper = getattr(unreal, "CodexMaterialToolsLibrary", None) + if not helper or not hasattr(helper, "save_asset_package_without_thumbnail"): + raise RuntimeError( + "CodexMaterialTools safe skeletal-mesh save helper is missing" + ) + + saved_count = 0 + for raw_path in dict.fromkeys(mesh_paths or []): + mesh_path = str(raw_path or "").split(".")[0] + mesh = unreal.load_asset(mesh_path) + if not _is_skeletal_mesh(mesh): + continue + if not _save_generated_skeleton_dependency(mesh, mesh_path, helper): + continue + if not helper.save_asset_package_without_thumbnail(mesh): + raise RuntimeError(f"safe skeletal-mesh save failed: {mesh_path}") + _log(f" persisted generated Skeleton reference for: {mesh_path}") + saved_count += 1 + return saved_count + + def process_meshes(mesh_paths): count = 0 for p in mesh_paths: diff --git a/tests/test_send2ue_material_pipeline_extension.py b/tests/test_send2ue_material_pipeline_extension.py index fcd15fb1..35389959 100644 --- a/tests/test_send2ue_material_pipeline_extension.py +++ b/tests/test_send2ue_material_pipeline_extension.py @@ -1,6 +1,7 @@ import importlib.util from pathlib import Path import sys +import tempfile import types import unittest @@ -130,6 +131,87 @@ def test_missing_pre_import_sidecar_prevents_post_import_fallback(self): self.assertEqual(len(resolve_calls), 1) self.assertEqual(self.command_calls, []) + def test_pre_import_preserves_sidecar_selected_during_mesh_export(self): + exact_path = "D:/texture/SK_Branch_01.json" + key = self.module.MATERIAL_PIPELINE_JSON_PATH_KEY + self.asset_data[key] = exact_path + self.asset_data[self.module.MATERIAL_PIPELINE_JSON_FROM_EXPORT_KEY] = True + self.extension._resolve_json_path = lambda asset_path: (_ for _ in ()).throw( + AssertionError("pre_import must keep the pre-export asset-unit sidecar") + ) + + self.extension.pre_import(self.asset_data, None) + + self.assertEqual(self.asset_data[key], exact_path) + self.assertEqual(len(self.command_calls), 1) + self.assertIn(repr(exact_path), "\n".join(self.command_calls[0])) + + def test_pre_export_prefers_asset_unit_name_over_child_mesh_name(self): + package_name = "ue_unique_export_names_addon" + api_name = f"{package_name}.api" + previous_package = sys.modules.get(package_name) + previous_api = sys.modules.get(api_name) + package = types.ModuleType(package_name) + package.__path__ = [] + api = types.ModuleType(api_name) + captured = [] + api.resolve_asset_unit_name = lambda target, context: "SK_Branch_01" + api.resolve_sidecar_json_path = lambda candidates, context: ( + captured.append(list(candidates)) or "D:/texture/SK_Branch_01.json" + ) + sys.modules[package_name] = package + sys.modules[api_name] = api + package.api = api + try: + path = self.extension._resolve_json_path_for_export( + { + "file_path": "D:/temp/SK_Branch_01_Mesh.fbx", + "asset_path": "/Game/Meshes/SK_Branch_01_Mesh", + }, + types.SimpleNamespace(name="SK_Branch_01_Mesh"), + ) + finally: + if previous_package is None: + sys.modules.pop(package_name, None) + else: + sys.modules[package_name] = previous_package + if previous_api is None: + sys.modules.pop(api_name, None) + else: + sys.modules[api_name] = previous_api + + self.assertEqual(path, "D:/texture/SK_Branch_01.json") + self.assertEqual(captured[0][0], "SK_Branch_01") + self.assertIn("SK_Branch_01_Mesh", captured[0]) + + def test_preflight_asset_path_uses_exact_sidecar_mesh_name(self): + with tempfile.TemporaryDirectory() as temp_dir: + sidecar = Path(temp_dir) / "SK_Branch_01.json" + sidecar.write_text('{"mesh_name": "SK_Branch_01"}', encoding="utf-8") + path = self.extension._preflight_asset_path( + "/Game/Meshes/Tree/SK_Branch_01_Mesh.SK_Branch_01_Mesh", + str(sidecar), + ) + self.assertEqual(path, "/Game/Meshes/Tree/SK_Branch_01") + + def test_post_operation_persists_imported_skeletal_dependencies(self): + self.asset_data["_asset_type"] = "SKELETAL_MESH" + self.asset_data[self.module.MATERIAL_PIPELINE_JSON_PATH_KEY] = ( + "D:/texture/SK_CommonGrass.json" + ) + + self.extension.pre_operation(None) + self.extension.post_import(self.asset_data, None) + self.extension.post_operation(None) + + self.assertEqual(len(self.command_calls), 2) + post_operation_commands = "\n".join(self.command_calls[1]) + self.assertIn( + "persist_generated_skeleton_dependencies", + post_operation_commands, + ) + self.assertIn(self.asset_data["asset_path"], post_operation_commands) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_ue_material_setup.py b/tests/test_ue_material_setup.py index 68ce53a4..f818d4d6 100644 --- a/tests/test_ue_material_setup.py +++ b/tests/test_ue_material_setup.py @@ -70,6 +70,43 @@ def get_class(self): return FakeUnrealClass("MaterialInstanceConstant") +class FakeSkeletalMaterial: + def __init__(self, slot_name="", material=None): + self.properties = { + "material_interface": material, + "material_slot_name": slot_name, + "imported_material_slot_name": slot_name, + "uv_channel_data": None, + "overlay_material_interface": None, + } + + def get_editor_property(self, name): + return self.properties[name] + + def set_editor_property(self, name, value): + self.properties[name] = value + + +class FakeSkeletalMesh: + def __init__(self, materials, skeleton=None): + self.materials = list(materials) + self.skeleton = skeleton + + def get_editor_property(self, name): + if name == "static_materials": + raise KeyError(name) + if name == "materials": + return list(self.materials) + if name == "skeleton": + return self.skeleton + raise KeyError(name) + + def set_editor_property(self, name, value): + if name != "materials": + raise KeyError(name) + self.materials = list(value) + + class FakeAssetData: def __init__(self, runtime, asset_path): self.runtime = runtime @@ -416,14 +453,16 @@ def test_matching_md5_role_drift_configures_without_reimport(self): def test_role_settings_cover_masks_normal_grayscale_and_color(self): cases = ( - ("Extra", False, "TC_MASKS"), - ("Normal", False, "TC_NORMALMAP"), - ("Height", False, "TC_GRAYSCALE"), - ("Opacity", False, "TC_GRAYSCALE"), - ("Albedo", True, "TC_DEFAULT"), - ("Subsurface", True, "TC_DEFAULT"), - ) - for role, expected_srgb, expected_compression in cases: + ("Extra", False, "TC_MASKS", True), + ("Normal", False, "TC_NORMALMAP", True), + ("Height", False, "TC_GRAYSCALE", True), + ("Opacity", False, "TC_GRAYSCALE", False), + ("Opacity Map", False, "TC_GRAYSCALE", False), + ("Alpha", False, "TC_GRAYSCALE", False), + ("Albedo", True, "TC_DEFAULT", True), + ("Subsurface", True, "TC_DEFAULT", True), + ) + for role, expected_srgb, expected_compression, expected_vt in cases: with self.subTest(role=role): settings = self.module._desired_texture_settings( role, @@ -435,7 +474,28 @@ def test_role_settings_cover_masks_normal_grayscale_and_color(self): expected_compression, ) self.assertEqual(settings["max_texture_size"], 0) - self.assertTrue(settings["virtual_texture_streaming"]) + self.assertEqual( + settings["virtual_texture_streaming"], + expected_vt, + ) + + def test_opacity_role_drift_disables_virtual_texture_without_reimport(self): + texture = FakeTexture( + srgb=False, + compression_settings="TC_GRAYSCALE", + max_texture_size=0, + virtual_texture_streaming=True, + ) + + changed = self.module._configure_imported_texture( + texture, + "Opacity Map", + file_path=str(Path(self.temp_dir.name) / "T_leaf_opacity.tga"), + asset_name="T_leaf_opacity", + ) + + self.assertTrue(changed) + self.assertFalse(texture.properties["virtual_texture_streaming"]) def test_preexisting_user_checkout_is_not_reverted(self): texture = FakeTexture(max_texture_size=1024) @@ -1065,12 +1125,32 @@ def test_shared_api_unavailable_preserves_legacy_tree_fallbacks(self): self.module._material_instance_base_name("M_Bark_Mat"), "Bark_Mat", ) - self.assertFalse( + self.assertTrue( self.module._tree_texture_param_allowed("Opacity", preset) ) + self.assertFalse( + self.module._tree_texture_param_allowed("Transmission", preset) + ) finally: self.module._SPEEDTREE_HANDOFF_API = cached_api + def test_tree_opacity_remap_is_canonical_and_transmission_is_excluded(self): + preset = { + "key": "tree", + "tree_shading": "foliage", + "layer_texture_remap": { + "Albedo": "Albedo", + "Transmission": "Transmission", + }, + } + + remap = self.module._layer_texture_remap(preset, {}) + + self.assertEqual(remap["Alpha"], "Opacity Map") + self.assertEqual(remap["Opacity"], "Opacity Map") + self.assertEqual(remap["Opacity Map"], "Opacity Map") + self.assertNotIn("Transmission", remap) + def _contract_sidecar(self, mesh_name="SK_CommonGrass"): contract_api = self.module._speedtree_handoff_api() entry = { @@ -1269,5 +1349,158 @@ def set_material_instance_background_layer(inner_self, *args): self.assertEqual(self.runtime.mark_add_calls, [layer_path]) +class TestSkeletalMaterialSectionRemap(unittest.TestCase): + def setUp(self): + self.runtime = FakeRuntime() + self.runtime.unreal_module.SkeletalMesh = FakeSkeletalMesh + self.runtime.unreal_module.SkeletalMaterial = FakeSkeletalMaterial + self.calls = [] + + class Helper: + def __init__(inner_self, calls): + inner_self.calls = calls + + def remap_skeletal_mesh_material_sections( + inner_self, mesh, old_indices, new_indices, apply_fix + ): + inner_self.calls.append( + { + "slot_count": len(mesh.materials), + "old": list(old_indices), + "new": list(new_indices), + "apply": apply_fix, + } + ) + return True, json.dumps({"changed": True}), [] + + self.runtime.unreal_module.CodexMaterialToolsLibrary = Helper(self.calls) + self.module = _load_module(self.runtime) + + def test_compaction_remaps_sections_and_persists_imported_slot_names(self): + mesh = FakeSkeletalMesh( + [FakeSkeletalMaterial(f"Legacy_{index}") for index in range(4)] + ) + bark_a = FakeMaterialInstanceConstant("/Game/MI/MI_BarkA") + bark_b = FakeMaterialInstanceConstant("/Game/MI/MI_BarkB") + + changed = self.module._normalize_skeletal_material_slots( + mesh, + { + 2: ("M_BarkA", bark_a), + 3: ("M_BarkB", bark_b), + }, + ) + + self.assertTrue(changed) + self.assertEqual(len(mesh.materials), 2) + self.assertEqual(self.calls[0]["slot_count"], 2) + self.assertEqual(self.calls[0]["old"], [2, 3]) + self.assertEqual(self.calls[0]["new"], [0, 1]) + self.assertEqual( + [ + entry.get_editor_property("imported_material_slot_name") + for entry in mesh.materials + ], + ["M_BarkA", "M_BarkB"], + ) + + +class TestGeneratedSkeletonDependencySave(unittest.TestCase): + def setUp(self): + self.runtime = FakeRuntime() + self.module = _load_module(self.runtime) + self.saved = [] + + class Helper: + def __init__(inner_self, saved): + inner_self.saved = saved + + def save_asset_package_without_thumbnail(inner_self, asset): + inner_self.saved.append(asset.get_path_name()) + return True + + self.helper = Helper(self.saved) + + @staticmethod + def mesh_with_skeleton(skeleton_path): + class Skeleton: + def get_path_name(self): + return skeleton_path + + class Mesh: + def get_editor_property(self, name): + if name != "skeleton": + raise KeyError(name) + return Skeleton() + + return Mesh() + + def test_saves_missing_default_generated_skeleton(self): + mesh_path = "/Game/Test/SK_Branch" + mesh = self.mesh_with_skeleton( + "/Game/Test/SK_Branch_Skeleton.SK_Branch_Skeleton" + ) + self.module._project_asset_package_file_exists = lambda _path: False + + changed = self.module._save_generated_skeleton_dependency( + mesh, mesh_path, self.helper + ) + + self.assertTrue(changed) + self.assertEqual( + self.saved, + ["/Game/Test/SK_Branch_Skeleton.SK_Branch_Skeleton"], + ) + + def test_leaves_shared_or_already_saved_skeleton_untouched(self): + mesh_path = "/Game/Test/SK_Branch" + shared_mesh = self.mesh_with_skeleton( + "/Game/Shared/SK_Tree_Skeleton.SK_Tree_Skeleton" + ) + generated_mesh = self.mesh_with_skeleton( + "/Game/Test/SK_Branch_Skeleton.SK_Branch_Skeleton" + ) + self.module._project_asset_package_file_exists = lambda _path: True + + self.assertFalse( + self.module._save_generated_skeleton_dependency( + shared_mesh, mesh_path, self.helper + ) + ) + self.assertFalse( + self.module._save_generated_skeleton_dependency( + generated_mesh, mesh_path, self.helper + ) + ) + self.assertEqual(self.saved, []) + + def test_post_operation_persists_skeleton_then_mesh(self): + mesh_path = "/Game/Test/SK_Branch" + + class Skeleton: + def get_path_name(self): + return "/Game/Test/SK_Branch_Skeleton.SK_Branch_Skeleton" + + mesh = FakeSkeletalMesh([], skeleton=Skeleton()) + mesh.get_path_name = lambda: f"{mesh_path}.SK_Branch" + self.runtime.unreal_module.SkeletalMesh = FakeSkeletalMesh + self.runtime.assets[mesh_path] = mesh + self.runtime.unreal_module.CodexMaterialToolsLibrary = self.helper + self.module._project_asset_package_file_exists = lambda _path: False + + count = self.module.persist_generated_skeleton_dependencies( + [mesh_path, mesh_path] + ) + + self.assertEqual(count, 1) + self.assertEqual( + self.saved, + [ + "/Game/Test/SK_Branch_Skeleton.SK_Branch_Skeleton", + "/Game/Test/SK_Branch.SK_Branch", + ], + ) + + if __name__ == "__main__": unittest.main() From ac4c8f4138b52171c1b765de44ee11c8f3a1e946 Mon Sep 17 00:00:00 2001 From: 424 <2421412@naver.com> Date: Fri, 31 Jul 2026 04:10:29 +0900 Subject: [PATCH 25/25] Bind material sidecars to exact export identity --- .../extensions/send2ue_material_pipeline.py | 173 +++++++++++++----- .../resources/pipeline/ue_material_setup.py | 111 ++++++++--- ...est_send2ue_material_pipeline_extension.py | 137 ++++++++++++-- tests/test_ue_material_setup.py | 81 +++++++- 4 files changed, 414 insertions(+), 88 deletions(-) diff --git a/src/addons/send2ue/resources/extensions/send2ue_material_pipeline.py b/src/addons/send2ue/resources/extensions/send2ue_material_pipeline.py index 5f032fde..04f8afb3 100644 --- a/src/addons/send2ue/resources/extensions/send2ue_material_pipeline.py +++ b/src/addons/send2ue/resources/extensions/send2ue_material_pipeline.py @@ -4,6 +4,7 @@ # This hook only resolves the sidecar JSON written by UE Unique Names and asks # Unreal to process the imported mesh through the shared surface-layer pipeline. +import hashlib import json from pathlib import Path @@ -22,6 +23,8 @@ TEXTURELESS_FBX_EXPORT_FLAG = "send2ue_material_pipeline_textureless_fbx_export" MATERIAL_PIPELINE_JSON_PATH_KEY = "_material_pipeline_json_path" MATERIAL_PIPELINE_JSON_FROM_EXPORT_KEY = "_material_pipeline_json_from_export" +MATERIAL_PIPELINE_EXPECTED_MESH_NAME_KEY = "_material_pipeline_expected_mesh_name" +MATERIAL_PIPELINE_JSON_SHA256_KEY = "_material_pipeline_json_sha256" _POST_OPERATION_SKELETAL_ASSET_PATHS = [] @@ -71,9 +74,13 @@ def pre_mesh_export(self, asset_data, properties): if not target: return - self._refresh_unreal_handoff_json_or_error(target) + refresh_result = self._refresh_unreal_handoff_json_or_error(target) - sidecar = self._load_json_sidecar_for_export(asset_data, target) + sidecar = self._load_json_sidecar_for_export( + asset_data, + target, + refresh_result, + ) if sidecar is None: return self._prepare_textureless_fbx_materials(target) @@ -154,6 +161,7 @@ def _refresh_unreal_handoff_json_or_error(self, target): "Unreal handoff JSON refresh produced no files.", f' Target: "{target.name}". Run Check Unreal Handoff.', ) + return result except RuntimeError: raise except Exception as exc: @@ -162,8 +170,17 @@ def _refresh_unreal_handoff_json_or_error(self, target): f' Target: "{target.name}". {exc}', ) - def _load_json_sidecar_for_export(self, asset_data, target): - json_path = self._resolve_json_path_for_export(asset_data, target) + def _load_json_sidecar_for_export( + self, + asset_data, + target, + refresh_result, + ): + json_path = self._resolve_json_path_for_export( + asset_data, + target, + refresh_result, + ) if not json_path: utilities.report_error( "UE Unique JSON sidecar is missing.", @@ -171,31 +188,54 @@ def _load_json_sidecar_for_export(self, asset_data, target): ) return None - # Preserve the exact asset-unit sidecar through FBX export and import. - # The pre-import asset path can still carry the source child mesh name - # even when the final Unreal asset is named after its parent Empty. - asset_data[MATERIAL_PIPELINE_JSON_PATH_KEY] = str(json_path).replace( - "\\", "/" - ) - asset_data[MATERIAL_PIPELINE_JSON_FROM_EXPORT_KEY] = True - try: - with open(json_path, "r", encoding="utf-8") as handle: - return json.load(handle) + payload = Path(json_path).read_bytes() + sidecar = json.loads(payload.decode("utf-8")) except OSError as exc: utilities.report_error( f"Could not read UE Unique JSON sidecar: {json_path}", str(exc), ) + return None except json.JSONDecodeError as exc: utilities.report_error( f"Invalid UE Unique JSON sidecar: {json_path}", str(exc), ) + return None - def _resolve_json_path_for_export(self, asset_data, target): - candidates = [] + expected_mesh_name = str( + asset_data.get(MATERIAL_PIPELINE_EXPECTED_MESH_NAME_KEY) or "" + ).strip() + sidecar_mesh_name = str(sidecar.get("mesh_name") or "").strip() + if ( + not expected_mesh_name + or sidecar_mesh_name.casefold() != expected_mesh_name.casefold() + ): + utilities.report_error( + "UE Unique JSON sidecar identity mismatch.", + f' Expected: "{expected_mesh_name or "-"}". ' + f'JSON mesh_name: "{sidecar_mesh_name or "-"}".', + ) + return None + # Preserve both the exact asset-unit identity and the exact bytes + # selected for this export. The path alone is not execution evidence. + asset_data[MATERIAL_PIPELINE_JSON_PATH_KEY] = str(json_path).replace( + "\\", "/" + ) + asset_data[MATERIAL_PIPELINE_JSON_SHA256_KEY] = hashlib.sha256( + payload + ).hexdigest() + asset_data[MATERIAL_PIPELINE_JSON_FROM_EXPORT_KEY] = True + return sidecar + + def _resolve_json_path_for_export( + self, + asset_data, + target, + refresh_result, + ): try: from ue_unique_export_names_addon import api as handoff_api except Exception as exc: @@ -205,22 +245,34 @@ def _resolve_json_path_for_export(self, asset_data, target): ) return None - # The pre-export target is the source mesh object, while Send to Unreal - # may name the imported asset after its highest Empty ancestor. Ask the - # handoff add-on for that exact asset-unit name before trying child names. - asset_unit_name = handoff_api.resolve_asset_unit_name(target, bpy.context) - if asset_unit_name: - candidates.append(asset_unit_name) - for value in ( - asset_data.get("file_path"), - asset_data.get("asset_path"), - target.name, - ): - name = self._asset_name_from_value(value) - if name and name not in candidates: - candidates.append(name) - - return handoff_api.resolve_sidecar_json_path(candidates, bpy.context) + asset_unit_name = str( + handoff_api.resolve_asset_unit_name(target, bpy.context) or "" + ).strip() + if not asset_unit_name: + utilities.report_error( + "Could not resolve the exact Send to Unreal asset unit.", + f' Target: "{target.name}".', + ) + return None + asset_data[MATERIAL_PIPELINE_EXPECTED_MESH_NAME_KEY] = asset_unit_name + + matches = [] + for value in (refresh_result or {}).get("json_paths") or []: + path = Path(value) + if path.stem.casefold() == asset_unit_name.casefold(): + matches.append(path) + unique_matches = { + str(path.resolve(strict=False)).casefold(): path + for path in matches + } + if len(unique_matches) != 1: + utilities.report_error( + "Exact asset-unit JSON sidecar was not produced uniquely.", + f' Asset unit: "{asset_unit_name}". ' + f"Refresh matches: {len(unique_matches)}.", + ) + return None + return str(next(iter(unique_matches.values()))) def _asset_name_from_value(self, value): if not value: @@ -312,6 +364,8 @@ def pre_import(self, asset_data, properties): ) if not from_mesh_export: asset_data.pop(MATERIAL_PIPELINE_JSON_PATH_KEY, None) + asset_data.pop(MATERIAL_PIPELINE_EXPECTED_MESH_NAME_KEY, None) + asset_data.pop(MATERIAL_PIPELINE_JSON_SHA256_KEY, None) if not json_path: json_path = self._resolve_json_path(asset_path) if not json_path: @@ -319,9 +373,29 @@ def pre_import(self, asset_data, properties): return json_path = str(json_path).replace("\\", "/") asset_data[MATERIAL_PIPELINE_JSON_PATH_KEY] = json_path - preflight_asset_path = self._preflight_asset_path(asset_path, json_path) + expected_mesh_name = str( + asset_data.get(MATERIAL_PIPELINE_EXPECTED_MESH_NAME_KEY) + or self._asset_name_from_value(asset_path) + ).strip() + sidecar_sha256 = str( + asset_data.get(MATERIAL_PIPELINE_JSON_SHA256_KEY) or "" + ).strip() + if from_mesh_export and (not expected_mesh_name or not sidecar_sha256): + utilities.report_error( + "Export-bound JSON sidecar evidence is incomplete.", + f' Asset: "{asset_path}".', + ) + return + asset_data[MATERIAL_PIPELINE_EXPECTED_MESH_NAME_KEY] = expected_mesh_name + asset_data[MATERIAL_PIPELINE_JSON_SHA256_KEY] = sidecar_sha256 + preflight_asset_path = self._preflight_asset_path( + asset_path, + expected_mesh_name, + ) json_arg = repr(json_path) + expected_name_arg = repr(expected_mesh_name) + sidecar_sha_arg = repr(sidecar_sha256) commands = [ "import sys", "import importlib.util", @@ -335,7 +409,12 @@ def pre_import(self, asset_data, properties): "_p = importlib.util.module_from_spec(_spec)", "sys.modules[_spec.name] = _p", "_spec.loader.exec_module(_p)", - f"_p.preflight_mesh_materials(_asset_path, json_path={json_arg})", + ( + "_p.preflight_mesh_materials(" + f"_asset_path, json_path={json_arg}, " + f"expected_mesh_name={expected_name_arg}, " + f"sidecar_sha256={sidecar_sha_arg})" + ), ] run_commands(commands) @@ -344,14 +423,9 @@ def pre_import(self, asset_data, properties): # assets that immediately need cleanup, which is expensive in Unreal/P4. asset_data["_import_materials_and_textures"] = False - def _preflight_asset_path(self, asset_path, json_path): - """Match preflight identity to the exact sidecar-selected asset unit.""" - try: - with open(json_path, "r", encoding="utf-8") as handle: - sidecar = json.load(handle) - except (OSError, json.JSONDecodeError, TypeError): - return asset_path - mesh_name = str(sidecar.get("mesh_name") or "") + def _preflight_asset_path(self, asset_path, expected_mesh_name): + """Use the Blender-authored asset unit, never JSON, as authority.""" + mesh_name = str(expected_mesh_name or "").strip() base_path = str(asset_path or "").split(".")[0] if not mesh_name or "/" not in base_path: return asset_path @@ -376,9 +450,17 @@ def post_import(self, asset_data, properties): json_path = asset_data.get(MATERIAL_PIPELINE_JSON_PATH_KEY) if not json_path: return + expected_mesh_name = str( + asset_data.get(MATERIAL_PIPELINE_EXPECTED_MESH_NAME_KEY) or "" + ).strip() + sidecar_sha256 = str( + asset_data.get(MATERIAL_PIPELINE_JSON_SHA256_KEY) or "" + ).strip() if asset_data.get("_asset_type") == UnrealTypes.SKELETAL_MESH: _POST_OPERATION_SKELETAL_ASSET_PATHS.append(asset_path) json_arg = repr(str(json_path)) + expected_name_arg = repr(expected_mesh_name) + sidecar_sha_arg = repr(sidecar_sha256) commands = [ "import sys", @@ -402,7 +484,12 @@ def post_import(self, asset_data, properties): "\texcept Exception as _sync_error:", "\t\tunreal.log_warning('[material_pipeline] content browser sync failed: ' + str(_sync_error))", "try:", - f"\t_p.process_mesh(_asset_path, json_path={json_arg})", + ( + "\t_p.process_mesh(" + f"_asset_path, json_path={json_arg}, " + f"expected_mesh_name={expected_name_arg}, " + f"sidecar_sha256={sidecar_sha_arg})" + ), "finally:", "\t_sync_to_imported_asset(_asset_path)", ] diff --git a/src/addons/send2ue/resources/pipeline/ue_material_setup.py b/src/addons/send2ue/resources/pipeline/ue_material_setup.py index e33018c4..85ad27c6 100644 --- a/src/addons/send2ue/resources/pipeline/ue_material_setup.py +++ b/src/addons/send2ue/resources/pipeline/ue_material_setup.py @@ -426,11 +426,25 @@ def _find_json_path(mesh_name: str, mesh_path: str = None): if not candidates: return None - candidates.sort(key=lambda p: os.path.getmtime(p), reverse=True) - return candidates[0] + unique_candidates = { + os.path.normcase(os.path.abspath(path)): path + for path in candidates + } + if len(unique_candidates) != 1: + raise RuntimeError( + "ambiguous JSON sidecar fallback for " + f"{mesh_name}: " + + "; ".join(sorted(unique_candidates.values())) + ) + return next(iter(unique_candidates.values())) -def _load_json(mesh_name: str, explicit_path: str = None, mesh_path: str = None): +def _load_json( + mesh_name: str, + explicit_path: str = None, + mesh_path: str = None, + expected_sha256: str = "", +): # extension 이 정확한 JSON 경로를 넘겨주면 walk 를 건너뛴다. 없으면 mesh_path 로 폴더를 좁힌다. if explicit_path: if not os.path.isfile(explicit_path): @@ -441,8 +455,15 @@ def _load_json(mesh_name: str, explicit_path: str = None, mesh_path: str = None) if not path: return None try: - with open(path, "r", encoding="utf-8") as f: - data = json.load(f) + with open(path, "rb") as f: + payload = f.read() + actual_sha256 = hashlib.sha256(payload).hexdigest() + if expected_sha256 and actual_sha256 != str(expected_sha256).casefold(): + raise RuntimeError( + "JSON sidecar content changed after Blender export: " + f"{path}" + ) + data = json.loads(payload.decode("utf-8")) _log(f"JSON sidecar: {path}") return data except Exception as e: @@ -461,19 +482,45 @@ def _speedtree_sidecar_descriptor(data: dict): return value if isinstance(value, dict) else None -def _validate_speedtree_handoff_contract(data: dict, expected_mesh_name: str): +def _validate_speedtree_handoff_contract( + data: dict, + expected_mesh_name: str, + mesh_path: str = "", +): """Validate new contract-authored sidecars before any Unreal mutation. - Descriptor-free sidecars are legacy data and intentionally retain the - existing inference/search behavior. + Descriptor-free non-tree sidecars retain legacy compatibility. Any tree + marker requires the current descriptor and material intent contract. """ materials = data.get("materials", []) if isinstance(data, dict) else [] has_intent = any( isinstance(entry, dict) and "speedtree_intent" in entry for entry in materials ) + has_tree_entry = any( + isinstance(entry, dict) + and str(entry.get("master_preset") or "").strip().casefold() == "tree" + for entry in materials + ) + has_tree_root = str( + ( + data.get("material_master") + or data.get("master_material") + or data.get("master_preset") + or "" + ) + if isinstance(data, dict) + else "" + ).strip().casefold() == "tree" descriptor = _speedtree_sidecar_descriptor(data) - if descriptor is None and not has_intent: + requires_speedtree_contract = bool( + descriptor is not None + or has_intent + or has_tree_entry + or has_tree_root + or _is_tree_asset_path(mesh_path) + ) + if not requires_speedtree_contract: return None contract_api = _speedtree_handoff_api() @@ -485,7 +532,7 @@ def _validate_speedtree_handoff_contract(data: dict, expected_mesh_name: str): if descriptor is None: raise RuntimeError( "SpeedTree handoff contract preflight blocked before mutation: " - "speedtree_intent exists without speedtree_handoff_contract" + "tree sidecar has no speedtree_handoff_contract" ) errors = [] @@ -2740,9 +2787,6 @@ def _call_create_or_update_layer_instance(helper, parent_layer, layer_path, text return bool(result), [], {} -_NORMALIZED_MATERIAL_LAYER_ASSETS = set() - - def _is_codex_test_asset_path(asset_path: str) -> bool: package_path = str(asset_path or "").split(".", 1)[0].replace("\\", "/") return package_path.casefold().startswith("/game/codex/tests/") @@ -2761,9 +2805,6 @@ def _normalize_material_layer_asset( ): _log(f" {label} kept read-only for isolated test: {asset_path}") return - cache_key = (method_name, asset_path) - if cache_key in _NORMALIZED_MATERIAL_LAYER_ASSETS: - return method = getattr(helper, method_name, None) if method is None: raise RuntimeError(f"CodexMaterialTools {label} normalization helper missing") @@ -2797,7 +2838,6 @@ def _normalize_material_layer_asset( detail = " | ".join(errors) or report_text or "unknown normalization failure" raise RuntimeError(f"{label} normalization failed: {asset_path} ({detail})") - _NORMALIZED_MATERIAL_LAYER_ASSETS.add(cache_key) changed = ( report.get("removed_placeholder_count", 0) or report.get("removed_set_declaration_count", 0) @@ -3456,7 +3496,12 @@ def _checkout_material_pipeline_assets(mesh_path: str, data: dict) -> list: return existing -def preflight_mesh_materials(mesh_path: str, json_path: str = None) -> bool: +def preflight_mesh_materials( + mesh_path: str, + json_path: str = None, + expected_mesh_name: str = "", + sidecar_sha256: str = "", +) -> bool: """Normalize shared material-layer assets before Unreal touches an existing mesh. A skeletal-mesh reimport recompiles its currently assigned material instances @@ -3464,11 +3509,16 @@ def preflight_mesh_materials(mesh_path: str, json_path: str = None) -> bool: before the FBX import, not from post_import after it. """ mesh_path = mesh_path.split(".")[0] - mesh_name = mesh_path.rsplit("/", 1)[-1] - data = _load_json(mesh_name, json_path, mesh_path) + mesh_name = str(expected_mesh_name or mesh_path.rsplit("/", 1)[-1]).strip() + data = _load_json( + mesh_name, + json_path, + mesh_path, + expected_sha256=sidecar_sha256, + ) if not data: return False - _validate_speedtree_handoff_contract(data, mesh_name) + _validate_speedtree_handoff_contract(data, mesh_name, mesh_path) _validate_codex_test_material_scope(data, mesh_path) instance_profile_targets = _validate_instance_profile_targets( @@ -3557,7 +3607,13 @@ def _save_generated_skeleton_dependency(mesh, mesh_path: str, helper) -> bool: return True -def process_mesh(mesh_path: str, master_mat=None, json_path: str = None) -> bool: +def process_mesh( + mesh_path: str, + master_mat=None, + json_path: str = None, + expected_mesh_name: str = "", + sidecar_sha256: str = "", +) -> bool: """단일 StaticMesh/SkeletalMesh 를 JSON 기반으로 처리. 변경이 있었으면 True. json_path: send2ue extension 이 넘겨주는 JSON 절대경로(있으면 OneDrive walk 생략). @@ -3567,11 +3623,16 @@ def process_mesh(mesh_path: str, master_mat=None, json_path: str = None) -> bool if not isinstance(mesh, _supported_mesh_classes()): return False - mesh_name = mesh_path.rsplit("/", 1)[-1] - data = _load_json(mesh_name, json_path, mesh_path) + mesh_name = str(expected_mesh_name or mesh_path.rsplit("/", 1)[-1]).strip() + data = _load_json( + mesh_name, + json_path, + mesh_path, + expected_sha256=sidecar_sha256, + ) asset_tools = None if data: - _validate_speedtree_handoff_contract(data, mesh_name) + _validate_speedtree_handoff_contract(data, mesh_name, mesh_path) _validate_codex_test_material_scope(data, mesh_path) instance_profile_targets = _validate_instance_profile_targets( data, mesh_path diff --git a/tests/test_send2ue_material_pipeline_extension.py b/tests/test_send2ue_material_pipeline_extension.py index 35389959..45f4f075 100644 --- a/tests/test_send2ue_material_pipeline_extension.py +++ b/tests/test_send2ue_material_pipeline_extension.py @@ -1,4 +1,6 @@ +import hashlib import importlib.util +import json from pathlib import Path import sys import tempfile @@ -136,6 +138,10 @@ def test_pre_import_preserves_sidecar_selected_during_mesh_export(self): key = self.module.MATERIAL_PIPELINE_JSON_PATH_KEY self.asset_data[key] = exact_path self.asset_data[self.module.MATERIAL_PIPELINE_JSON_FROM_EXPORT_KEY] = True + self.asset_data[ + self.module.MATERIAL_PIPELINE_EXPECTED_MESH_NAME_KEY + ] = "SK_Branch_01" + self.asset_data[self.module.MATERIAL_PIPELINE_JSON_SHA256_KEY] = "a" * 64 self.extension._resolve_json_path = lambda asset_path: (_ for _ in ()).throw( AssertionError("pre_import must keep the pre-export asset-unit sidecar") ) @@ -144,9 +150,12 @@ def test_pre_import_preserves_sidecar_selected_during_mesh_export(self): self.assertEqual(self.asset_data[key], exact_path) self.assertEqual(len(self.command_calls), 1) - self.assertIn(repr(exact_path), "\n".join(self.command_calls[0])) + commands = "\n".join(self.command_calls[0]) + self.assertIn(repr(exact_path), commands) + self.assertIn("expected_mesh_name='SK_Branch_01'", commands) + self.assertIn(f"sidecar_sha256={'a' * 64!r}", commands) - def test_pre_export_prefers_asset_unit_name_over_child_mesh_name(self): + def test_pre_export_selects_only_exact_asset_unit_from_current_refresh(self): package_name = "ue_unique_export_names_addon" api_name = f"{package_name}.api" previous_package = sys.modules.get(package_name) @@ -154,22 +163,36 @@ def test_pre_export_prefers_asset_unit_name_over_child_mesh_name(self): package = types.ModuleType(package_name) package.__path__ = [] api = types.ModuleType(api_name) - captured = [] api.resolve_asset_unit_name = lambda target, context: "SK_Branch_01" - api.resolve_sidecar_json_path = lambda candidates, context: ( - captured.append(list(candidates)) or "D:/texture/SK_Branch_01.json" + api.resolve_sidecar_json_path = lambda *args: (_ for _ in ()).throw( + AssertionError("global sidecar resolution must not be used") ) sys.modules[package_name] = package sys.modules[api_name] = api package.api = api try: - path = self.extension._resolve_json_path_for_export( - { + with tempfile.TemporaryDirectory() as temp_dir: + exact = Path(temp_dir) / "SK_Branch_01.json" + child = Path(temp_dir) / "SK_Branch_01_Mesh.json" + exact.write_text("{}", encoding="utf-8") + child.write_text("{}", encoding="utf-8") + asset_data = { "file_path": "D:/temp/SK_Branch_01_Mesh.fbx", "asset_path": "/Game/Meshes/SK_Branch_01_Mesh", - }, - types.SimpleNamespace(name="SK_Branch_01_Mesh"), - ) + } + path = self.extension._resolve_json_path_for_export( + asset_data, + types.SimpleNamespace(name="SK_Branch_01_Mesh"), + {"json_paths": [str(child), str(exact)]}, + ) + + self.assertEqual(Path(path), exact) + self.assertEqual( + asset_data[ + self.module.MATERIAL_PIPELINE_EXPECTED_MESH_NAME_KEY + ], + "SK_Branch_01", + ) finally: if previous_package is None: sys.modules.pop(package_name, None) @@ -180,18 +203,96 @@ def test_pre_export_prefers_asset_unit_name_over_child_mesh_name(self): else: sys.modules[api_name] = previous_api - self.assertEqual(path, "D:/texture/SK_Branch_01.json") - self.assertEqual(captured[0][0], "SK_Branch_01") - self.assertIn("SK_Branch_01_Mesh", captured[0]) + def test_export_sidecar_persists_expected_name_and_content_sha(self): + package_name = "ue_unique_export_names_addon" + api_name = f"{package_name}.api" + previous_package = sys.modules.get(package_name) + previous_api = sys.modules.get(api_name) + package = types.ModuleType(package_name) + package.__path__ = [] + api = types.ModuleType(api_name) + api.resolve_asset_unit_name = lambda target, context: "SK_Branch_01" + sys.modules[package_name] = package + sys.modules[api_name] = api + package.api = api + try: + with tempfile.TemporaryDirectory() as temp_dir: + sidecar = Path(temp_dir) / "SK_Branch_01.json" + payload = json.dumps( + {"mesh_name": "SK_Branch_01"}, + sort_keys=True, + ).encode("utf-8") + sidecar.write_bytes(payload) + asset_data = {} + + loaded = self.extension._load_json_sidecar_for_export( + asset_data, + types.SimpleNamespace(name="SK_Branch_01_Mesh"), + {"json_paths": [str(sidecar)]}, + ) + + self.assertEqual(loaded["mesh_name"], "SK_Branch_01") + self.assertEqual( + asset_data[ + self.module.MATERIAL_PIPELINE_EXPECTED_MESH_NAME_KEY + ], + "SK_Branch_01", + ) + self.assertEqual( + asset_data[self.module.MATERIAL_PIPELINE_JSON_SHA256_KEY], + hashlib.sha256(payload).hexdigest(), + ) + self.assertTrue( + asset_data[self.module.MATERIAL_PIPELINE_JSON_FROM_EXPORT_KEY] + ) + finally: + if previous_package is None: + sys.modules.pop(package_name, None) + else: + sys.modules[package_name] = previous_package + if previous_api is None: + sys.modules.pop(api_name, None) + else: + sys.modules[api_name] = previous_api - def test_preflight_asset_path_uses_exact_sidecar_mesh_name(self): + def test_export_sidecar_rejects_json_mesh_name_as_authority(self): with tempfile.TemporaryDirectory() as temp_dir: sidecar = Path(temp_dir) / "SK_Branch_01.json" - sidecar.write_text('{"mesh_name": "SK_Branch_01"}', encoding="utf-8") - path = self.extension._preflight_asset_path( - "/Game/Meshes/Tree/SK_Branch_01_Mesh.SK_Branch_01_Mesh", - str(sidecar), + sidecar.write_text( + '{"mesh_name": "SK_Wrong"}', + encoding="utf-8", ) + asset_data = {} + + def resolve_exact(asset_data_arg, target, refresh_result): + asset_data_arg[ + self.module.MATERIAL_PIPELINE_EXPECTED_MESH_NAME_KEY + ] = "SK_Branch_01" + return str(sidecar) + + self.extension._resolve_json_path_for_export = resolve_exact + + with self.assertRaises(RuntimeError): + self.extension._load_json_sidecar_for_export( + asset_data, + types.SimpleNamespace(name="SK_Branch_01_Mesh"), + {"json_paths": [str(sidecar)]}, + ) + + self.assertNotIn( + self.module.MATERIAL_PIPELINE_JSON_PATH_KEY, + asset_data, + ) + self.assertNotIn( + self.module.MATERIAL_PIPELINE_JSON_SHA256_KEY, + asset_data, + ) + + def test_preflight_asset_path_uses_expected_asset_unit_not_json(self): + path = self.extension._preflight_asset_path( + "/Game/Meshes/Tree/SK_Branch_01_Mesh.SK_Branch_01_Mesh", + "SK_Branch_01", + ) self.assertEqual(path, "/Game/Meshes/Tree/SK_Branch_01") def test_post_operation_persists_imported_skeletal_dependencies(self): diff --git a/tests/test_ue_material_setup.py b/tests/test_ue_material_setup.py index f818d4d6..bc2794c4 100644 --- a/tests/test_ue_material_setup.py +++ b/tests/test_ue_material_setup.py @@ -727,6 +727,26 @@ def normalize_material_layer_placeholders(inner_self, asset_path): self.assertEqual(calls, []) + def test_material_layer_normalization_revalidates_same_asset_live(self): + calls = [] + + class Helper: + def normalize_material_layer_placeholders(inner_self, asset_path): + calls.append(asset_path) + return True + + asset_path = "/Game/Codex/Tests/Elm/_MaterialPipeline/MYI/MYI_Bark" + for _ in range(2): + self.module._normalize_material_layer_asset( + Helper(), + "normalize_material_layer_placeholders", + asset_path, + "material layer instance", + mutation_scope_path="/Game/Codex/Tests/Elm/SK_Tree", + ) + + self.assertEqual(calls, [asset_path, asset_path]) + def test_codex_test_scope_requires_isolated_explicit_targets(self): valid = { "codex_test_asset_scope": { @@ -1198,7 +1218,7 @@ def test_new_sidecar_descriptor_and_intent_validate_before_mutation(self): self.assertEqual(self.runtime.created_assets, []) self.assertEqual(self.runtime.save_calls, []) - def test_new_sidecar_mismatch_blocks_while_legacy_stays_compatible(self): + def test_new_sidecar_mismatch_and_descriptor_free_tree_are_blocked(self): bad_descriptor = self._contract_sidecar() bad_descriptor["speedtree_handoff_contract"]["fingerprint"] = "stale" with self.assertRaisesRegex(RuntimeError, "fingerprint mismatch"): @@ -1230,13 +1250,70 @@ def test_new_sidecar_mismatch_blocks_while_legacy_stays_compatible(self): {"name": "M_stem_common_01", "master_preset": "tree"} ], } - self.assertIsNone( + with self.assertRaisesRegex(RuntimeError, "no speedtree_handoff_contract"): self.module._validate_speedtree_handoff_contract( legacy, "SK_CommonGrass", ) + + legacy_prop = { + "mesh_name": "SM_Prop", + "materials": [ + {"name": "M_Prop", "master_preset": "prop"} + ], + } + self.assertIsNone( + self.module._validate_speedtree_handoff_contract( + legacy_prop, + "SM_Prop", + "/Game/Meshes/Props/SM_Prop", + ) ) + def test_json_fallback_rejects_ambiguous_candidates(self): + first = Path(self.temp_dir.name) / "first" / "SK_CommonGrass.json" + second = Path(self.temp_dir.name) / "second" / "SK_CommonGrass.json" + first.parent.mkdir() + second.parent.mkdir() + first.write_text("{}", encoding="utf-8") + second.write_text("{}", encoding="utf-8") + self.module._mesh_path_to_disk_folder = lambda mesh_path: None + self.module.JSON_SEARCH_ROOTS = [self.temp_dir.name] + self.module.EXPORT_DIR = str(Path(self.temp_dir.name) / "missing") + self.module._walk_for_json = lambda roots, filename: [ + str(first), + str(second), + ] + + with self.assertRaisesRegex(RuntimeError, "ambiguous JSON sidecar fallback"): + self.module._find_json_path( + "SK_CommonGrass", + "/Game/Meshes/Tree/SK_CommonGrass", + ) + + def test_explicit_json_sidecar_sha_detects_changed_bytes(self): + sidecar = Path(self.temp_dir.name) / "SK_CommonGrass.json" + payload = json.dumps({"mesh_name": "SK_CommonGrass"}).encode("utf-8") + sidecar.write_bytes(payload) + expected_sha256 = hashlib.sha256(payload).hexdigest() + + data = self.module._load_json( + "SK_CommonGrass", + explicit_path=str(sidecar), + mesh_path="/Game/Meshes/Tree/SK_CommonGrass", + expected_sha256=expected_sha256, + ) + self.assertEqual(data["mesh_name"], "SK_CommonGrass") + + sidecar.write_text('{"mesh_name": "SK_Changed"}', encoding="utf-8") + with self.assertRaisesRegex(RuntimeError, "content changed"): + self.module._load_json( + "SK_CommonGrass", + explicit_path=str(sidecar), + mesh_path="/Game/Meshes/Tree/SK_CommonGrass", + expected_sha256=expected_sha256, + ) + def test_explicit_json_path_never_falls_back_to_global_search(self): calls = [] self.module._find_json_path = lambda *args: calls.append(args)