Skip to content

Commit d9adfd9

Browse files
Merge pull request #22 from PassiveModding/feature/bake
Feature/bake
2 parents 30f6b19 + f416caf commit d9adfd9

20 files changed

+2465
-2380
lines changed

MeddleTools/bake/Bake Guide.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
Bake Guide
2+
- Reproject and retile
3+
- Bake
4+
- Atlas
5+
- Export

MeddleTools/bake/__init__.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
from .bake import RunBake
2+
from .panel import MeddleBakePanel
3+
from .atlas import RunAtlas
4+
from .export_fbx import ExportFBX
5+
from .reproject_retile import ReprojectRetile
6+
7+
classes = [
8+
RunBake,
9+
MeddleBakePanel,
10+
RunAtlas,
11+
ExportFBX,
12+
ReprojectRetile,
13+
]

MeddleTools/bake/atlas.py

Lines changed: 506 additions & 0 deletions
Large diffs are not rendered by default.

MeddleTools/bake/bake.py

Lines changed: 711 additions & 0 deletions
Large diffs are not rendered by default.

MeddleTools/bake/bake_utils.py

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import bpy
2+
3+
def require_mesh_or_armature_selected(context):
4+
"""Check if operation can be executed"""
5+
# Require at least one mesh or armature selected
6+
return any(obj.type in {'MESH', 'ARMATURE'} for obj in context.selected_objects)
7+
8+
def get_selected_armatures(context):
9+
"""Get selected armature objects"""
10+
return [obj for obj in context.selected_objects if obj.type == 'ARMATURE']
11+
12+
def get_selected_meshes(context):
13+
"""Get selected mesh objects"""
14+
return [obj for obj in context.selected_objects if obj.type == 'MESH']
15+
16+
def get_all_selected_meshes(context):
17+
"""Get all selected mesh objects, including those parented to selected armatures"""
18+
selected_armatures = get_selected_armatures(context)
19+
selected_meshes = get_selected_meshes(context)
20+
21+
# Include meshes parented to selected armatures
22+
for armature in selected_armatures:
23+
for child in armature.children:
24+
if child.type == 'MESH' and child not in selected_meshes:
25+
selected_meshes.append(child)
26+
27+
return selected_meshes
28+
29+
def get_uv_islands(mesh):
30+
visited = set()
31+
islands = []
32+
33+
# Build adjacency map based on UV edges
34+
# Two loops are connected if they share an edge with matching UVs
35+
# def build_uv_adjacency():
36+
# adjacency = {}
37+
# for poly in mesh.polygons:
38+
# loop_indices = list(poly.loop_indices)
39+
# for i, li in enumerate(loop_indices):
40+
# next_li = loop_indices[(i + 1) % len(loop_indices)]
41+
# if li not in adjacency:
42+
# adjacency[li] = []
43+
# if next_li not in adjacency:
44+
# adjacency[next_li] = []
45+
# adjacency[li].append(next_li)
46+
# adjacency[next_li].append(li)
47+
# return adjacency
48+
uv_layer = mesh.uv_layers.active
49+
if uv_layer is None:
50+
return []
51+
52+
def build_uv_adjacency():
53+
# Map UV coordinates to loop indices
54+
uv_to_loops = {}
55+
for loop_index, uv in enumerate(uv_layer.data):
56+
uv_coord = (round(uv.uv.x, 6), round(uv.uv.y, 6)) # Round to handle float precision
57+
if uv_coord not in uv_to_loops:
58+
uv_to_loops[uv_coord] = []
59+
uv_to_loops[uv_coord].append(loop_index)
60+
61+
# Build adjacency based on shared UV coordinates
62+
adjacency = {}
63+
for loops in uv_to_loops.values():
64+
for li in loops:
65+
if li not in adjacency:
66+
adjacency[li] = []
67+
# Connect all loops that share this UV coordinate
68+
adjacency[li].extend([other_li for other_li in loops if other_li != li])
69+
70+
return adjacency
71+
72+
adjacency = build_uv_adjacency()
73+
74+
def get_connected_loops(start_loop_index):
75+
to_visit = [start_loop_index]
76+
island_loops = set()
77+
while to_visit:
78+
loop_index = to_visit.pop()
79+
if loop_index in visited:
80+
continue
81+
visited.add(loop_index)
82+
island_loops.add(loop_index)
83+
84+
# Add adjacent loops (within same polygon)
85+
if loop_index in adjacency:
86+
for adjacent_li in adjacency[loop_index]:
87+
if adjacent_li not in visited:
88+
to_visit.append(adjacent_li)
89+
return island_loops
90+
91+
# Find all islands
92+
for poly in mesh.polygons:
93+
for loop_index in poly.loop_indices:
94+
if loop_index not in visited:
95+
island_loops = get_connected_loops(loop_index)
96+
islands.append(island_loops)
97+
98+
return islands

MeddleTools/bake/export_fbx.py

Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
from . import bake_utils
2+
import bpy
3+
import logging
4+
from bpy.types import Operator
5+
import os
6+
7+
logger = logging.getLogger(__name__)
8+
logger.addHandler(logging.NullHandler())
9+
10+
class ExportFBX(Operator):
11+
"""Export selected mesh objects to FBX format with textures"""
12+
bl_idname = "meddle.export_fbx"
13+
bl_label = "Export FBX"
14+
bl_description = "Export selected mesh objects to FBX format with textures in a subfolder"
15+
bl_options = {'REGISTER', 'UNDO'}
16+
17+
# Directory path for export
18+
directory: bpy.props.StringProperty(
19+
name="Export Directory",
20+
description="Directory to export FBX and textures",
21+
subtype='DIR_PATH'
22+
)
23+
24+
def invoke(self, context, event):
25+
"""Open file browser for directory selection"""
26+
# Set default directory to blend file location or user home
27+
if bpy.data.filepath:
28+
self.directory = os.path.dirname(bpy.data.filepath)
29+
else:
30+
self.directory = os.path.expanduser("~")
31+
32+
context.window_manager.fileselect_add(self)
33+
return {'RUNNING_MODAL'}
34+
35+
@classmethod
36+
def poll(cls, context):
37+
return bake_utils.require_mesh_or_armature_selected(context)
38+
39+
def execute(self, context):
40+
"""Execute the FBX export operation"""
41+
if not self.directory:
42+
self.report({'ERROR'}, "No directory selected")
43+
return {'CANCELLED'}
44+
45+
try:
46+
# Get selected objects
47+
selected_objects = context.selected_objects
48+
mesh_objects = [obj for obj in selected_objects if obj.type == 'MESH']
49+
armature_objects = [obj for obj in selected_objects if obj.type == 'ARMATURE']
50+
51+
# If armatures are selected, include their child meshes
52+
for armature in armature_objects:
53+
for obj in bpy.data.objects:
54+
if obj.type == 'MESH' and obj.parent == armature:
55+
if obj not in mesh_objects and obj not in selected_objects:
56+
selected_objects.append(obj)
57+
if obj not in mesh_objects:
58+
mesh_objects.append(obj)
59+
60+
if not mesh_objects and not armature_objects:
61+
self.report({'ERROR'}, "No mesh or armature objects selected")
62+
return {'CANCELLED'}
63+
64+
# Create textures subfolder
65+
textures_folder = os.path.join(self.directory, "textures")
66+
os.makedirs(textures_folder, exist_ok=True)
67+
logger.info(f"Created textures folder: {textures_folder}")
68+
69+
# Export images to textures folder
70+
self.export_textures(context, mesh_objects, textures_folder)
71+
72+
# Generate FBX filename
73+
fbx_filename = self.generate_fbx_filename(context, selected_objects)
74+
fbx_path = os.path.join(self.directory, fbx_filename)
75+
76+
# Export FBX with packed images
77+
self.export_fbx_file(context, selected_objects, fbx_path)
78+
79+
self.report({'INFO'}, f"Successfully exported to {fbx_path}")
80+
logger.info(f"Export complete: {fbx_path}")
81+
82+
return {'FINISHED'}
83+
84+
except Exception as e:
85+
self.report({'ERROR'}, f"Export failed: {str(e)}")
86+
logger.error(f"Export failed: {e}", exc_info=True)
87+
return {'CANCELLED'}
88+
89+
def export_textures(self, context, mesh_objects, textures_folder):
90+
"""Export all textures used by mesh materials to the textures folder"""
91+
exported_images = set()
92+
image_count = 0
93+
94+
for mesh_obj in mesh_objects:
95+
if not mesh_obj.data.materials:
96+
continue
97+
98+
for mat_slot in mesh_obj.data.materials:
99+
if not mat_slot or not mat_slot.use_nodes:
100+
continue
101+
102+
material = mat_slot
103+
for node in material.node_tree.nodes:
104+
if node.type != 'TEX_IMAGE' or not node.image:
105+
continue
106+
107+
image = node.image
108+
109+
# Skip if already exported
110+
if image.name in exported_images:
111+
continue
112+
113+
# Determine output filename
114+
if image.filepath:
115+
# Use existing filename
116+
base_name = os.path.basename(image.filepath)
117+
if not base_name:
118+
base_name = f"{image.name}.png"
119+
else:
120+
base_name = f"{image.name}.png"
121+
122+
# Ensure .png extension
123+
if not base_name.lower().endswith('.png'):
124+
base_name = os.path.splitext(base_name)[0] + '.png'
125+
126+
output_path = os.path.join(textures_folder, base_name)
127+
128+
# Save the image
129+
try:
130+
# Save a copy of the image
131+
original_filepath = image.filepath_raw
132+
original_format = image.file_format
133+
134+
image.filepath_raw = output_path
135+
image.file_format = 'PNG'
136+
image.save()
137+
138+
# Restore original settings
139+
image.filepath_raw = original_filepath
140+
image.file_format = original_format
141+
142+
logger.info(f"Exported texture: {base_name}")
143+
exported_images.add(image.name)
144+
image_count += 1
145+
except Exception as e:
146+
logger.warning(f"Failed to export texture {image.name}: {e}")
147+
148+
self.report({'INFO'}, f"Exported {image_count} texture(s) to textures folder")
149+
logger.info(f"Exported {image_count} textures to {textures_folder}")
150+
151+
def generate_fbx_filename(self, context, selected_objects):
152+
"""Generate a filename for the FBX export"""
153+
# Try to use armature name, or first mesh name, or default
154+
armature = next((obj for obj in selected_objects if obj.type == 'ARMATURE'), None)
155+
156+
if armature:
157+
base_name = armature.name
158+
elif selected_objects:
159+
base_name = selected_objects[0].name
160+
else:
161+
base_name = "export"
162+
163+
# Sanitize filename
164+
base_name = "".join(c for c in base_name if c.isalnum() or c in (' ', '_', '-')).strip()
165+
if not base_name:
166+
base_name = "export"
167+
168+
return f"{base_name}.fbx"
169+
170+
def export_fbx_file(self, context, selected_objects, fbx_path):
171+
"""Export the FBX file with selected objects"""
172+
# Ensure objects are selected
173+
bpy.ops.object.select_all(action='DESELECT')
174+
for obj in selected_objects:
175+
obj.select_set(True)
176+
177+
# Set active object
178+
armature = next((obj for obj in selected_objects if obj.type == 'ARMATURE'), None)
179+
if armature:
180+
context.view_layer.objects.active = armature
181+
elif selected_objects:
182+
context.view_layer.objects.active = selected_objects[0]
183+
184+
# Export FBX with appropriate settings
185+
bpy.ops.export_scene.fbx(
186+
filepath=fbx_path,
187+
use_selection=True,
188+
global_scale=1.0,
189+
apply_unit_scale=True,
190+
apply_scale_options='FBX_SCALE_NONE',
191+
bake_space_transform=False,
192+
object_types={'ARMATURE', 'MESH'},
193+
use_mesh_modifiers=True,
194+
use_mesh_modifiers_render=True,
195+
mesh_smooth_type='FACE',
196+
use_tspace=True,
197+
use_custom_props=False,
198+
add_leaf_bones=False,
199+
primary_bone_axis='Y',
200+
secondary_bone_axis='X',
201+
armature_nodetype='NULL',
202+
bake_anim=False,
203+
path_mode='COPY', # Copy textures to export location
204+
embed_textures=True, # Embed textures in FBX
205+
batch_mode='OFF'
206+
)
207+
208+
logger.info(f"FBX exported to: {fbx_path}")

MeddleTools/bake/panel.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import bpy
2+
from .bake import RunBake
3+
from .atlas import RunAtlas
4+
from .export_fbx import ExportFBX
5+
from .reproject_retile import ReprojectRetile
6+
7+
class MeddleBakePanel(bpy.types.Panel):
8+
bl_idname = "OBJECT_PT_MeddleBakePanel"
9+
bl_label = "Baking"
10+
bl_space_type = 'VIEW_3D'
11+
bl_region_type = 'UI'
12+
bl_category = "Meddle Tools"
13+
14+
def draw(self, context):
15+
layout = self.layout
16+
settings = context.scene.meddle_settings
17+
18+
# UV Reproject and Retile Section
19+
box = layout.box()
20+
box.label(text="UV Operations", icon='UV')
21+
box.operator(ReprojectRetile.bl_idname, text="Reproject and Retile UVs")
22+
23+
# Baking Section
24+
box = layout.box()
25+
box.label(text="Baking", icon='RENDER_STILL')
26+
27+
# Warning message in a sub-box
28+
if True: # Always show warning
29+
col = box.column(align=True)
30+
col.alert = True
31+
col.label(text="Warning: Decals may bake incorrectly", icon='ERROR')
32+
col.label(text="due to face symmetry. Disable UV1")
33+
col.label(text="related layers before baking.")
34+
box.separator(factor=0.5)
35+
36+
# Project save warning
37+
if not bpy.data.is_saved:
38+
col = box.column(align=True)
39+
col.alert = True
40+
col.label(text="Save project before baking!", icon='ERROR')
41+
box.separator(factor=0.5)
42+
43+
box.prop(settings, "bake_samples")
44+
box.operator(RunBake.bl_idname, text="Run Bake", icon='RENDER_STILL')
45+
46+
# Texture Atlas Section
47+
box = layout.box()
48+
box.label(text="Texture Atlas", icon='TEXTURE')
49+
box.prop(settings, "pack_alpha")
50+
box.operator(RunAtlas.bl_idname, text="Create Atlas from Selection", icon='TEXTURE')
51+
52+
# Export Section
53+
box = layout.box()
54+
box.label(text="Export", icon='EXPORT')
55+
box.operator(ExportFBX.bl_idname, text="Export FBX with Textures", icon='EXPORT')

0 commit comments

Comments
 (0)