1
+ import xml .etree .ElementTree as ET
2
+ from os import path
3
+ from xml .dom import minidom
4
+
1
5
import bpy
2
- import os .path
3
- from bpy_extras .io_utils import ImportHelper
4
- from bpy .props import StringProperty , BoolProperty
6
+ from bpy .props import StringProperty
5
7
from bpy .types import Operator
8
+ from bpy_extras .io_utils import ImportHelper
6
9
7
- import xml .etree .ElementTree as ET
8
- from xml .dom import minidom
10
+ # Tested Blender version: 4.2/4.3
9
11
12
+ ########################################################################################################################
13
+ ### Exports model.dae of the scene with textures, its corresponding model.sdf file, and a default model.config file ####
14
+ ########################################################################################################################
10
15
def export_sdf (prefix_path ):
11
- dae_filename = 'model.dae'
12
- sdf_filename = 'model.sdf'
13
- model_config_filename = 'model.config'
14
- lightmap_filename = 'LightmapBaked.png'
15
- model_name = 'my_model'
16
- meshes_folder_prefix = 'meshes/'
17
-
16
+
17
+ dae_filename = "model.dae"
18
+ sdf_filename = "model.sdf"
19
+ model_config_filename = "model.config"
20
+ lightmap_filename = "LightmapBaked.png"
21
+ model_name = "my_model"
22
+ meshes_folder_prefix = "meshes/"
23
+
18
24
# Exports the dae file and its associated textures
19
- bpy .ops .wm .collada_export (filepath = prefix_path + meshes_folder_prefix + dae_filename ,
20
- check_existing = False ,
21
- filter_blender = False ,
22
- filter_image = False ,
23
- filter_movie = False ,
24
- filter_python = False ,
25
- filter_font = False ,
26
- filter_sound = False ,
27
- filter_text = False ,
28
- filter_btx = False ,
29
- filter_collada = True ,
30
- filter_folder = True ,
31
- filemode = 8 )
25
+ bpy .ops .wm .collada_export (
26
+ filepath = path .join (prefix_path , meshes_folder_prefix , dae_filename ),
27
+ check_existing = False ,
28
+ filter_blender = False ,
29
+ filter_image = False ,
30
+ filter_movie = False ,
31
+ filter_python = False ,
32
+ filter_font = False ,
33
+ filter_sound = False ,
34
+ filter_text = False ,
35
+ filter_btx = False ,
36
+ filter_collada = True ,
37
+ filter_folder = True ,
38
+ filemode = 8 ,
39
+ )
32
40
41
+ # objects = bpy.context.selected_objects
33
42
objects = bpy .context .selectable_objects
34
- mesh_objects = [o for o in objects if o .type == 'MESH' ]
35
- light_objects = [o for o in objects if o .type == 'LIGHT' ]
43
+ mesh_objects = [o for o in objects if o .type == "MESH" ]
44
+ light_objects = [o for o in objects if o .type == "LIGHT" ]
45
+
46
+ #############################################
47
+ #### export sdf xml based off the scene #####
48
+ #############################################
49
+ sdf = ET .Element ("sdf" , attrib = {"version" : "1.8" })
36
50
37
- sdf = ET . Element ( 'sdf' , attrib = { 'version' : '1.8' })
38
- model = ET .SubElement (sdf , "model" , attrib = {"name" :"test" })
51
+ # 1 model and 1 link
52
+ model = ET .SubElement (sdf , "model" , attrib = {"name" : "test" })
39
53
static = ET .SubElement (model , "static" )
40
54
static .text = "true"
41
- link = ET .SubElement (model , "link" , attrib = {"name" :"testlink" })
42
-
43
- def get_diffuse_map (material ):
44
- """Helper function to safely get diffuse map from material"""
45
- if not material or not material .use_nodes or not material .node_tree :
46
- return ""
47
-
48
- nodes = material .node_tree .nodes
49
- principled = next ((n for n in nodes if n .type == 'BSDF_PRINCIPLED' ), None )
50
-
51
- if not principled :
52
- return ""
53
-
54
- base_color = principled .inputs .get ('Base Color' ) or principled .inputs [0 ]
55
- if not base_color .links :
56
- return ""
57
-
58
- link_node = base_color .links [0 ].from_node
59
- if not hasattr (link_node , 'image' ) or not link_node .image :
60
- return ""
61
-
62
- return link_node .image .name
63
-
55
+ link = ET .SubElement (model , "link" , attrib = {"name" : "testlink" })
64
56
# for each geometry in geometry library add a <visual> tag
65
57
for o in mesh_objects :
66
- visual = ET .SubElement (link , "visual" , attrib = {"name" :o .name })
67
-
58
+ visual = ET .SubElement (link , "visual" , attrib = {"name" : o .name })
59
+
68
60
geometry = ET .SubElement (visual , "geometry" )
69
61
mesh = ET .SubElement (geometry , "mesh" )
70
62
uri = ET .SubElement (mesh , "uri" )
71
- uri .text = dae_filename
63
+ uri .text = path . join ( meshes_folder_prefix , dae_filename )
72
64
submesh = ET .SubElement (mesh , "submesh" )
73
65
submesh_name = ET .SubElement (submesh , "name" )
74
66
submesh_name .text = o .name
75
-
76
- # Material handling
67
+
68
+ # grab diffuse/albedo map
69
+ diffuse_map = ""
70
+ if o .active_material is not None :
71
+ nodes = o .active_material .node_tree .nodes
72
+ principled = next (n for n in nodes if n .type == "BSDF_PRINCIPLED" )
73
+ if principled is not None :
74
+ base_color = principled .inputs ["Base Color" ]
75
+ if len (base_color .links ):
76
+ link_node = base_color .links [0 ].from_node
77
+ diffuse_map = link_node .image .name
78
+
79
+ # setup diffuse/specular color
77
80
material = ET .SubElement (visual , "material" )
78
81
diffuse = ET .SubElement (material , "diffuse" )
79
82
diffuse .text = "1.0 1.0 1.0 1.0"
80
83
specular = ET .SubElement (material , "specular" )
81
84
specular .text = "0.0 0.0 0.0 1.0"
82
85
pbr = ET .SubElement (material , "pbr" )
83
86
metal = ET .SubElement (pbr , "metal" )
84
-
85
- # Get diffuse map if material exists
86
- if o .active_material :
87
- diffuse_map = get_diffuse_map (o .active_material )
88
- if diffuse_map :
89
- albedo_map = ET .SubElement (metal , "albedo_map" )
90
- albedo_map .text = meshes_folder_prefix + diffuse_map
91
-
92
- # Lightmap handling
93
- if os .path .isfile (lightmap_filename ):
94
- light_map = ET .SubElement (metal , "light_map" , attrib = {"uv_set" :"1" })
95
- light_map .text = meshes_folder_prefix + lightmap_filename
87
+ if diffuse_map != "" :
88
+ albedo_map = ET .SubElement (metal , "albedo_map" )
89
+ albedo_map .text = path .join (meshes_folder_prefix , diffuse_map )
90
+
91
+ # for lightmapping, add the UV and turn off casting of shadows
92
+ if path .isfile (lightmap_filename ):
93
+ light_map = ET .SubElement (metal , "light_map" , attrib = {"uv_set" : "1" })
94
+ light_map .text = path .join (meshes_folder_prefix , lightmap_filename )
95
+
96
96
cast_shadows = ET .SubElement (visual , "cast_shadows" )
97
97
cast_shadows .text = "0"
98
98
99
99
def add_attenuation_tags (light_tag , blender_light ):
100
- """Helper function to add attenuation tags based on light type"""
101
100
attenuation = ET .SubElement (light_tag , "attenuation" )
102
-
103
- # Range (cutoff distance)
104
- range_tag = ET .SubElement (attenuation , "range" )
105
- range_tag .text = str (blender_light .cutoff_distance )
106
-
107
- # Linear attenuation factor
101
+ range = ET .SubElement (attenuation , "range" )
102
+ range .text = str (blender_light .cutoff_distance )
108
103
linear = ET .SubElement (attenuation , "linear" )
109
- linear .text = "1.0" # Default linear attenuation
110
-
111
- # Quadratic attenuation factor
104
+ linear .text = "1.0"
112
105
quadratic = ET .SubElement (attenuation , "quadratic" )
113
- quadratic .text = "0.0" # Default quadratic attenuation
114
-
115
- # Constant attenuation factor
106
+ quadratic .text = "0.0"
116
107
constant = ET .SubElement (attenuation , "constant" )
117
- constant .text = "1.0" # Default constant attenuation
108
+ constant .text = "1.0"
109
+
118
110
119
- # Export lights
111
+ # export lights
120
112
for l in light_objects :
121
113
blender_light = l .data
122
-
114
+
123
115
if blender_light .type == "POINT" :
124
- light = ET .SubElement (link , "light" , attrib = {"name" :l .name , "type" :"point" })
116
+ light = ET .SubElement (link , "light" , attrib = {"name" : l .name , "type" : "point" })
125
117
diffuse = ET .SubElement (light , "diffuse" )
126
118
diffuse .text = f"{ blender_light .color .r } { blender_light .color .g } { blender_light .color .b } 1.0"
127
119
add_attenuation_tags (light , blender_light )
128
-
129
- elif blender_light .type == "SPOT" :
130
- light = ET .SubElement (link , "light" , attrib = {"name" :l .name , "type" :"spot" })
120
+
121
+ if blender_light .type == "SPOT" :
122
+ light = ET .SubElement (link , "light" , attrib = {"name" : l .name , "type" : "spot" })
131
123
diffuse = ET .SubElement (light , "diffuse" )
132
124
diffuse .text = f"{ blender_light .color .r } { blender_light .color .g } { blender_light .color .b } 1.0"
133
125
add_attenuation_tags (light , blender_light )
134
126
135
- # Add spot light specific parameters
127
+ # note: unsupported < spot> tags in blender
136
128
spot = ET .SubElement (light , "spot" )
137
129
inner_angle = ET .SubElement (spot , "inner_angle" )
138
- inner_angle .text = str (blender_light .spot_size * 0.5 ) # Convert to inner angle
130
+ inner_angle .text = str (blender_light .spot_size * 0.5 )
139
131
outer_angle = ET .SubElement (spot , "outer_angle" )
140
- outer_angle .text = str (blender_light .spot_size ) # Outer angle
132
+ outer_angle .text = str (blender_light .spot_size )
141
133
falloff = ET .SubElement (spot , "falloff" )
142
- falloff .text = str (blender_light .spot_blend * 10 ) # Approximate falloff from blend
143
-
144
- elif blender_light .type == "SUN" :
145
- light = ET .SubElement (link , "light" , attrib = {"name" :l .name , "type" :"directional" })
134
+ falloff .text = str (blender_light .spot_blend * 10 )
135
+
136
+ if blender_light .type == "SUN" :
137
+ light = ET .SubElement (link , "light" , attrib = {"name" : l .name , "type" : "directional" })
146
138
diffuse = ET .SubElement (light , "diffuse" )
147
139
diffuse .text = f"{ blender_light .color .r } { blender_light .color .g } { blender_light .color .b } 1.0"
148
-
140
+
149
141
if blender_light .type in ["SUN" , "SPOT" ]:
150
142
direction = ET .SubElement (light , "direction" )
151
143
direction .text = f"{ l .matrix_world [0 ][2 ]} { l .matrix_world [1 ][2 ]} { l .matrix_world [2 ][2 ]} "
152
144
153
- # Common light properties
145
+ # unsupported: AREA lights
146
+
154
147
cast_shadows = ET .SubElement (light , "cast_shadows" )
155
148
cast_shadows .text = "true"
156
-
149
+
150
+ # todo : bpy.types.light script api lacks an intensity value, possible candidate is energy/power(Watts)?
157
151
intensity = ET .SubElement (light , "intensity" )
158
- intensity .text = str (blender_light .energy ) # Use the light's energy as intensity
159
-
160
- # Collision tags
161
- collision = ET .SubElement (link , "collision" , attrib = {"name" :"collision" })
152
+ intensity .text = str (blender_light .energy )
153
+
154
+ ## sdf collision tags
155
+ collision = ET .SubElement (link , "collision" , attrib = {"name" : "collision" })
156
+
162
157
geometry = ET .SubElement (collision , "geometry" )
163
158
mesh = ET .SubElement (geometry , "mesh" )
164
159
uri = ET .SubElement (mesh , "uri" )
165
- uri .text = dae_filename
160
+ uri .text = path .join (meshes_folder_prefix , dae_filename )
161
+
166
162
surface = ET .SubElement (collision , "surface" )
167
- contact = ET .SubElement (collision , "contact" )
168
- collide_bitmask = ET .SubElement (collision , "collide_bitmask" )
163
+ contact = ET .SubElement (surface , "contact" )
164
+ collide_bitmask = ET .SubElement (contact , "collide_bitmask" )
169
165
collide_bitmask .text = "0x01"
170
166
171
- # Write SDF file
172
- xml_string = ET .tostring (sdf , encoding = ' unicode' )
167
+ ## sdf write to file
168
+ xml_string = ET .tostring (sdf , encoding = " unicode" )
173
169
reparsed = minidom .parseString (xml_string )
174
- with open (prefix_path + sdf_filename , "w" ) as sdf_file :
170
+ with open (path . join ( prefix_path , sdf_filename ) , "w" ) as sdf_file :
175
171
sdf_file .write (reparsed .toprettyxml (indent = " " ))
176
172
177
- # Generate model.config
178
- model = ET .Element ('model' )
179
- name = ET .SubElement (model , 'name' )
173
+ ##############################
174
+ ### generate model.config ####
175
+ ##############################
176
+ model = ET .Element ("model" )
177
+ name = ET .SubElement (model , "name" )
180
178
name .text = model_name
181
- version = ET .SubElement (model , ' version' )
179
+ version = ET .SubElement (model , " version" )
182
180
version .text = "1.0"
183
- sdf_tag = ET .SubElement (model , "sdf" , attrib = {"version" :"1.8" })
181
+ sdf_tag = ET .SubElement (model , "sdf" , attrib = {"version" : "1.8" })
184
182
sdf_tag .text = sdf_filename
185
183
186
- author = ET .SubElement (model , ' author' )
187
- name = ET .SubElement (author , ' name' )
184
+ author = ET .SubElement (model , " author" )
185
+ name = ET .SubElement (author , " name" )
188
186
name .text = "Generated by blender SDF tools"
189
187
190
- xml_string = ET .tostring (model , encoding = ' unicode' )
188
+ xml_string = ET .tostring (model , encoding = " unicode" )
191
189
reparsed = minidom .parseString (xml_string )
192
- with open (prefix_path + model_config_filename , "w" ) as config_file :
190
+ with open (path . join ( prefix_path , model_config_filename ) , "w" ) as config_file :
193
191
config_file .write (reparsed .toprettyxml (indent = " " ))
194
192
193
+ #### UI Handling ####
195
194
class OT_TestOpenFilebrowser (Operator , ImportHelper ):
196
195
bl_idname = "test.open_filebrowser"
197
196
bl_label = "Save"
198
-
199
- directory : bpy . props . StringProperty (name = "Outdir Path" )
200
-
197
+
198
+ directory : StringProperty (name = "Outdir Path" )
199
+
201
200
def execute (self , context ):
202
- if not os .path .isdir (self .directory ):
203
- print (self .directory + " is not a directory!" )
201
+ """Do the export with the selected file."""
202
+ if not path .isdir (self .directory ):
203
+ print (f"{ self .directory } is not a directory!" )
204
204
else :
205
- print ("exporting to directory: " + self .directory )
205
+ print (f "exporting to directory: { self .directory } " )
206
206
export_sdf (self .directory )
207
- return {' FINISHED' }
207
+ return {" FINISHED" }
208
208
209
- def register ():
210
- bpy .utils .register_class (OT_TestOpenFilebrowser )
209
+ def register ():
210
+ bpy .utils .register_class (OT_TestOpenFilebrowser )
211
211
212
- def unregister ():
212
+ def unregister ():
213
213
bpy .utils .unregister_class (OT_TestOpenFilebrowser )
214
-
214
+
215
215
if __name__ == "__main__" :
216
- register ()
217
- bpy .ops .test .open_filebrowser ('INVOKE_DEFAULT' )
216
+ register ()
217
+ bpy .ops .test .open_filebrowser ("INVOKE_DEFAULT" )
218
+
219
+ # alternatively comment the main code block and do a function call without going through all the ui
220
+ # prefix_path = '/home/ddeng/blender_lightmap/final_office/office/'
221
+ # export_sdf(prefix_path)
0 commit comments