Skip to content

Instantly share code, notes, and snippets.

@SalatielSauer
Last active January 5, 2025 00:26
Show Gist options
  • Save SalatielSauer/ba1d96e664b7accbeaae3a504acecdc0 to your computer and use it in GitHub Desktop.
Save SalatielSauer/ba1d96e664b7accbeaae3a504acecdc0 to your computer and use it in GitHub Desktop.
Blender python script to export object vertices to Sauerbraten

Blender + OGZ-Editor = beauty

This is a Blender script that exports the positions of an object's vertices to the format used by OGZ-Editor to generate .ogz (map) files.

If you want a preset scene, there is one available for blender 2.79 here: sauer_vertex_world_reference.blend (although it might work on newer versions).

The .blend comes with "automatic" scripts for versions 2.79 and 2.80 and a default 1024x1024 map with skybox, with them the workflow consists of simply adding the object and running the script.

# Sauer-Vertex for JSOCTA by @SalatielSauer
#
# Description:
# This Blender 2.79 script exports all vertices and textures of a selected object to a .json file.
# The resulting .json file can be imported into OGZ-Editor to generate a valid .ogz file.
#
# Usage:
# 1. In Blender 2.79, select the object you want to export.
# 2. Run this Python script.
# 3. Open OGZ Editor (salatielsauer.github.io/OGZ-Editor)
# 4. Upload the JSON file.
# 5. Download the OGZ.
# 6. Paste the OGZ in your Sauerbraten packages/base folder.
import bpy
import json
import os
# -----------------------------------------------------------------------------------
# User Parameters
# -----------------------------------------------------------------------------------
G_VALUE = 1 # Gridpower (0 = 1x1, 1 = 2x2, 2 = 4x4..).
AF_VALUE = 1462 # Texture index in Sauer (default is white).
JSON_FILENAME = "geometry_data.json" # Output JSON file name
# Remesh parameters
REMESH_MODE = 'BLOCKS' # Or 'SHARP', 'SMOOTH'
OCTREE_DEPTH = 7 # The higher this is, the more detail in remesh (more cubes!)
USE_REMOVE_DISCONNECTED = True # Set to False if working with text fonts
# Subdivision parameters
SUBDIVISION_CUTS = 2 # Number of cuts per subdivision operation (increases texture detail)
# -----------------------------------------------------------------------------------
# Internal helpers
# -----------------------------------------------------------------------------------
def create_2d_pixel_array(image):
"""Convert Image pixels into a 2D list of (r,g,b)."""
width, height = image.size
all_pixels = list(image.pixels) # RGBA array
pixel2d = []
for y in range(height):
row_start = y * width * 4
row = []
for x in range(width):
i = row_start + x * 4
r = all_pixels[i + 0]
g = all_pixels[i + 1]
b = all_pixels[i + 2]
row.append((r, g, b))
pixel2d.append(row)
return pixel2d
def sample_2d_pixel(pixel2d, width, height, u, v):
"""Sample (r,g,b) from pixel2d given normalized UV (0..1)."""
# Clamp
if u < 0.0:
u = 0.0
elif u > 1.0:
u = 1.0
if v < 0.0:
v = 0.0
elif v > 1.0:
v = 1.0
x = int(u * (width - 1))
y = int(v * (height - 1))
return pixel2d[y][x]
def get_image_from_material_internal(mat):
"""Retrieve the first image from Blender Internal material slots (2.79)."""
if not mat or not mat.texture_slots:
return None
for slot in mat.texture_slots:
if slot and slot.texture and slot.texture.type == 'IMAGE':
return slot.texture.image
return None
def get_vertex_uvs_average(obj):
"""
Build dict: vertex_index -> (avg_u, avg_v).
Averages all loops referencing that vertex.
"""
mesh = obj.data
uv_layer = mesh.uv_layers.active
if not uv_layer:
print("No active UV map on object:", obj.name)
return {}
uv_map = {}
for poly in mesh.polygons:
for loop_idx in poly.loop_indices:
v_idx = mesh.loops[loop_idx].vertex_index
uv = uv_layer.data[loop_idx].uv
if v_idx not in uv_map:
uv_map[v_idx] = {"sum_u": 0.0, "sum_v": 0.0, "count": 0}
uv_map[v_idx]["sum_u"] += uv.x
uv_map[v_idx]["sum_v"] += uv.y
uv_map[v_idx]["count"] += 1
avg_uvs = {}
for v_idx, data in uv_map.items():
c = data["count"]
avg_uvs[v_idx] = (data["sum_u"] / c, data["sum_v"] / c)
return avg_uvs
def subdivide_object(obj, number_cuts):
"""Subdivide mesh for better UV detail (in Edit Mode)."""
bpy.ops.object.select_all(action='DESELECT')
obj.select = True
bpy.context.scene.objects.active = obj
bpy.ops.object.mode_set(mode='EDIT')
bpy.ops.mesh.select_all(action='SELECT')
bpy.ops.mesh.subdivide(number_cuts=number_cuts)
bpy.ops.object.mode_set(mode='OBJECT')
def remesh_object(obj, mode, octree_depth, remove_disconnected):
"""Remesh via a Remesh modifier, then apply it."""
bpy.ops.object.select_all(action='DESELECT')
obj.select = True
bpy.context.scene.objects.active = obj
mod = obj.modifiers.new(name="Remesh", type='REMESH')
mod.mode = mode
mod.octree_depth = octree_depth
mod.use_remove_disconnected = remove_disconnected
bpy.ops.object.modifier_apply(modifier="Remesh")
def transfer_uv_data(source_obj, dest_obj):
"""Transfer UV data from source to dest (2.79 approach)."""
bpy.ops.object.select_all(action='DESELECT')
dest_obj.select = True
source_obj.select = True
bpy.context.scene.objects.active = source_obj
bpy.ops.object.data_transfer(data_type='UV')
def apply_armature_modifier(obj):
"""
If the object has an Armature modifier, apply it so that the
current pose is baked into the mesh.
"""
if not obj.modifiers:
return
for mod in obj.modifiers:
if mod.type == 'ARMATURE':
bpy.ops.object.select_all(action='DESELECT')
obj.select = True
bpy.context.scene.objects.active = obj
bpy.ops.object.modifier_apply(modifier=mod.name)
# -----------------------------------------------------------------------------------
# Popups: Error and Finished
# -----------------------------------------------------------------------------------
def show_error_popup(error_message):
"""
Display an error popup with the given message.
"""
def draw_error(self, context):
self.layout.label(text="Error:")
self.layout.label(text=error_message)
bpy.context.window_manager.popup_menu(draw_error, title="Error", icon='ERROR')
def draw_finished_popup(self, context):
"""
Draw the final "success" popup, directing user to OGZ Editor site.
"""
self.layout.label(text="It can be found next to the .blend file.")
self.layout.separator()
self.layout.operator("wm.url_open", text="[Click here to Open OGZ Editor]", icon='GROUP_VERTEX').url = "https://salatielsauer.github.io/OGZ-Editor"
# click here to open the json folder
self.layout.operator("wm.url_open", text="[Click here to Open JSON Folder]", icon='FILE_FOLDER').url = bpy.path.abspath("//")
# -----------------------------------------------------------------------------------
# Main script
# -----------------------------------------------------------------------------------
def main():
# Ensure Object Mode
bpy.ops.object.mode_set(mode='OBJECT')
# Check if we have a valid active object (and is a mesh)
original_obj = bpy.context.active_object
if not original_obj or original_obj.type != 'MESH':
show_error_popup("Please select a single mesh object before running this script.")
return
# 1. Create a temporary copy for Subdivision
subdiv_obj = original_obj.copy()
subdiv_obj.data = original_obj.data.copy()
subdiv_obj.name = original_obj.name + "_temp_subdiv"
bpy.context.scene.objects.link(subdiv_obj)
# Apply the Armature modifier (if any)
apply_armature_modifier(subdiv_obj)
# 2. Subdivide the subdiv copy for better UV resolution
subdivide_object(subdiv_obj, SUBDIVISION_CUTS)
# 3. Create a separate temporary copy for Remesh
remesh_obj = original_obj.copy()
remesh_obj.data = original_obj.data.copy()
remesh_obj.name = original_obj.name + "_temp_remesh"
bpy.context.scene.objects.link(remesh_obj)
# Apply the Armature modifier on the remesh copy as well
apply_armature_modifier(remesh_obj)
# 4. Perform the Remesh on that second copy
remesh_object(remesh_obj, REMESH_MODE, OCTREE_DEPTH, USE_REMOVE_DISCONNECTED)
# 5. Transfer UV from the subdiv copy -> remeshed copy
transfer_uv_data(subdiv_obj, remesh_obj)
# 6. Export JSON from the remeshed copy
mat = remesh_obj.active_material
image = get_image_from_material_internal(mat)
pixel2d = None
width, height = 0, 0
if image:
pixel2d = create_2d_pixel_array(image)
width, height = image.size
else:
if mat:
print("No valid image texture found in material slots.")
else:
print("No material found on object. Continuing without vcolor...")
avg_uvs = get_vertex_uvs_average(remesh_obj)
geometry_data = []
world_matrix = remesh_obj.matrix_world
mesh = remesh_obj.data
for v_idx, vertex in enumerate(mesh.vertices):
world_coord = world_matrix * vertex.co
x, y, z = world_coord.y, world_coord.x, world_coord.z
vert_dict = {
"x": x,
"y": y,
"z": z,
"g": G_VALUE,
"af": AF_VALUE
}
# If there's an image and UV data, sample color
if pixel2d is not None and v_idx in avg_uvs:
uv_pair = avg_uvs[v_idx]
u = uv_pair[0]
v = uv_pair[1]
color_tuple = sample_2d_pixel(pixel2d, width, height, u, v)
vert_dict["vcolor"] = [color_tuple[0], color_tuple[1], color_tuple[2]]
geometry_data.append(vert_dict)
output_dict = {"geometry": geometry_data}
json_file_path = os.path.join(bpy.path.abspath("//"), JSON_FILENAME)
try:
with open(json_file_path, "w") as f:
json.dump(output_dict, f, indent=2)
print("Successfully wrote vertex data to:", json_file_path)
except Exception as e:
error_msg = "Failed to write JSON file: " + str(e)
print(error_msg)
show_error_popup(error_msg)
# Cleanup temporary objects, then return
bpy.ops.object.select_all(action='DESELECT')
subdiv_obj.select = True
remesh_obj.select = True
bpy.context.scene.objects.active = remesh_obj
bpy.ops.object.delete()
return
# 7. Cleanup: delete the two temporary objects
bpy.ops.object.select_all(action='DESELECT')
subdiv_obj.select = True
remesh_obj.select = True
bpy.context.scene.objects.active = remesh_obj
bpy.ops.object.delete()
original_obj.select = True
bpy.context.scene.objects.active = original_obj
# 8. Show a popup to inform the user that we are finished
bpy.context.window_manager.popup_menu(draw_finished_popup,
title="Your JSON was exported successfully :)",
icon='FILE_TICK')
print("Done! Original object remains untouched.")
# -----------------------------------------------------------------------------------
# Run
# -----------------------------------------------------------------------------------
if __name__ == "__main__":
main()
# Sauer-Vertex for JSOCTA by @SalatielSauer
#
# Description:
# This Blender 2.80+ script exports all vertices and textures of a selected object to a .json file.
# The resulting .json file can be imported into OGZ-Editor to generate a valid .ogz file.
#
# Usage:
# 1. In Blender 2.80+, select the object you want to export.
# 2. Run this Python script.
# 3. Open OGZ Editor (salatielsauer.github.io/OGZ-Editor)
# 4. Upload the JSON file.
# 5. Download the OGZ.
# 6. Paste the OGZ in your Sauerbraten packages/base folder.
import bpy
import json
import os
# -----------------------------------------------------------------------------------
# User Parameters
# -----------------------------------------------------------------------------------
G_VALUE = 1 # Gridpower (0 = 1x1, 1 = 2x2, 2 = 4x4..).
AF_VALUE = 1462 # Texture index in Sauer (default is white).
JSON_FILENAME = "geometry_data.json" # Output JSON file name
# Remesh parameters
REMESH_MODE = 'BLOCKS' # Or 'SHARP', 'SMOOTH'
OCTREE_DEPTH = 7 # The higher this is, the more detail in remesh (more cubes!)
USE_REMOVE_DISCONNECTED = True # Set to False if working with text fonts
# Subdivision parameters
SUBDIVISION_CUTS = 2 # Number of cuts per subdivision operation (increases texture detail)
# -----------------------------------------------------------------------------------
# Internal helpers
# -----------------------------------------------------------------------------------
def create_2d_pixel_array(image):
"""Convert Image pixels into a 2D list of (r,g,b)."""
width, height = image.size
all_pixels = list(image.pixels) # RGBA array
pixel2d = []
for y in range(height):
row_start = y * width * 4
row = []
for x in range(width):
i = row_start + x * 4
r = all_pixels[i + 0]
g = all_pixels[i + 1]
b = all_pixels[i + 2]
row.append((r, g, b))
pixel2d.append(row)
return pixel2d
def sample_2d_pixel(pixel2d, width, height, u, v):
"""Sample (r,g,b) from pixel2d given normalized UV (0..1)."""
# Clamp
if u < 0.0:
u = 0.0
elif u > 1.0:
u = 1.0
if v < 0.0:
v = 0.0
elif v > 1.0:
v = 1.0
x = int(u * (width - 1))
y = int(v * (height - 1))
return pixel2d[y][x]
def get_image_from_material(mat):
"""
Retrieve the first image from a material using nodes (Blender 2.8+).
Looks for the first Image Texture node.
"""
if not mat or not mat.use_nodes:
return None
for node in mat.node_tree.nodes:
if node.type == 'TEX_IMAGE' and node.image is not None:
return node.image
return None
def get_vertex_uvs_average(obj):
"""
Build dict: vertex_index -> (avg_u, avg_v).
Averages all loops referencing that vertex.
"""
mesh = obj.data
uv_layer = mesh.uv_layers.active
if not uv_layer:
print("No active UV map on object:", obj.name)
return {}
uv_map = {}
for poly in mesh.polygons:
for loop_idx in poly.loop_indices:
v_idx = mesh.loops[loop_idx].vertex_index
uv = uv_layer.data[loop_idx].uv
if v_idx not in uv_map:
uv_map[v_idx] = {"sum_u": 0.0, "sum_v": 0.0, "count": 0}
uv_map[v_idx]["sum_u"] += uv.x
uv_map[v_idx]["sum_v"] += uv.y
uv_map[v_idx]["count"] += 1
avg_uvs = {}
for v_idx, data in uv_map.items():
c = data["count"]
avg_uvs[v_idx] = (data["sum_u"] / c, data["sum_v"] / c)
return avg_uvs
def subdivide_object(obj, number_cuts):
"""Subdivide mesh for better UV detail (in Edit Mode)."""
# Deselect everything
bpy.ops.object.select_all(action='DESELECT')
# Select and activate our object
obj.select_set(True)
bpy.context.view_layer.objects.active = obj
bpy.ops.object.mode_set(mode='EDIT')
bpy.ops.mesh.select_all(action='SELECT')
bpy.ops.mesh.subdivide(number_cuts=number_cuts)
bpy.ops.object.mode_set(mode='OBJECT')
def remesh_object(obj, mode, octree_depth, remove_disconnected):
"""Remesh via a Remesh modifier, then apply it."""
bpy.ops.object.select_all(action='DESELECT')
obj.select_set(True)
bpy.context.view_layer.objects.active = obj
mod = obj.modifiers.new(name="Remesh", type='REMESH')
mod.mode = mode
mod.octree_depth = octree_depth
mod.use_remove_disconnected = remove_disconnected
bpy.ops.object.modifier_apply(modifier="Remesh")
def transfer_uv_data(source_obj, dest_obj):
"""Transfer UV data from source to dest."""
# In 2.8+, the data_transfer operator uses the active object as the source
# and the selected object(s) as destination (unless reversed).
bpy.ops.object.select_all(action='DESELECT')
# Make sure destination is selected first
dest_obj.select_set(True)
# Then select source as well
source_obj.select_set(True)
# Make source the active object
bpy.context.view_layer.objects.active = source_obj
# Perform data transfer: type='UV'
bpy.ops.object.data_transfer(data_type='UV')
def apply_armature_modifier(obj):
"""
If the object has an Armature modifier, apply it so that the
current pose is baked into the mesh.
"""
if not obj.modifiers:
return
for mod in obj.modifiers:
if mod.type == 'ARMATURE':
bpy.ops.object.select_all(action='DESELECT')
obj.select_set(True)
bpy.context.view_layer.objects.active = obj
bpy.ops.object.modifier_apply(modifier=mod.name)
# -----------------------------------------------------------------------------------
# Popups: Error and Finished
# -----------------------------------------------------------------------------------
def show_error_popup(error_message):
"""
Display an error popup with the given message.
"""
def draw_error(self, context):
self.layout.label(text="Error:")
self.layout.label(text=error_message)
bpy.context.window_manager.popup_menu(draw_error, title="Error", icon='ERROR')
def draw_finished_popup(self, context):
"""
Draw the final "success" popup, directing user to OGZ Editor site.
"""
self.layout.label(text="It can be found next to the .blend file.")
self.layout.separator()
self.layout.operator("wm.url_open", text="[Click here to Open OGZ Editor]", icon='GROUP_VERTEX').url = "https://salatielsauer.github.io/OGZ-Editor"
# click here to open the json folder
self.layout.operator("wm.url_open", text="[Click here to Open JSON Folder]", icon='FILE_FOLDER').url = bpy.path.abspath("//")
# -----------------------------------------------------------------------------------
# Main script
# -----------------------------------------------------------------------------------
def main():
# Ensure Object Mode
bpy.ops.object.mode_set(mode='OBJECT')
# Check if we have a valid active object (and is a mesh)
original_obj = bpy.context.view_layer.objects.active
if not original_obj or original_obj.type != 'MESH':
show_error_popup("Please select a single mesh object before running this script.")
return
# 1. Create a temporary copy for Subdivision
subdiv_obj = original_obj.copy()
subdiv_obj.data = original_obj.data.copy()
subdiv_obj.name = original_obj.name + "_temp_subdiv"
bpy.context.collection.objects.link(subdiv_obj)
# Apply the Armature modifier (if any)
apply_armature_modifier(subdiv_obj)
# 2. Subdivide the subdiv copy for better UV resolution
subdivide_object(subdiv_obj, SUBDIVISION_CUTS)
# 3. Create a separate temporary copy for Remesh
remesh_obj = original_obj.copy()
remesh_obj.data = original_obj.data.copy()
remesh_obj.name = original_obj.name + "_temp_remesh"
bpy.context.collection.objects.link(remesh_obj)
# Apply the Armature modifier on the remesh copy as well
apply_armature_modifier(remesh_obj)
# 4. Perform the Remesh on that second copy
remesh_object(remesh_obj, REMESH_MODE, OCTREE_DEPTH, USE_REMOVE_DISCONNECTED)
# 5. Transfer UV from the subdiv copy -> remeshed copy
transfer_uv_data(subdiv_obj, remesh_obj)
# 6. Export JSON from the remeshed copy
mat = remesh_obj.active_material
image = get_image_from_material(mat)
pixel2d = None
width, height = 0, 0
if image:
pixel2d = create_2d_pixel_array(image)
width, height = image.size
else:
if mat:
print("No valid image texture node found in material. Continuing without vcolor...")
else:
print("No material found on object. Continuing without vcolor...")
avg_uvs = get_vertex_uvs_average(remesh_obj)
geometry_data = []
world_matrix = remesh_obj.matrix_world
mesh = remesh_obj.data
for v_idx, vertex in enumerate(mesh.vertices):
world_coord = world_matrix @ vertex.co
# NOTE: If you want to keep the same "x,y,z" orientation as in your old script, adjust as needed:
x, y, z = world_coord.y, world_coord.x, world_coord.z
vert_dict = {
"x": x,
"y": y,
"z": z,
"g": G_VALUE,
"af": AF_VALUE
}
# If there's an image and UV data, sample color
if pixel2d is not None and v_idx in avg_uvs:
u = avg_uvs[v_idx][0]
v = avg_uvs[v_idx][1]
color_tuple = sample_2d_pixel(pixel2d, width, height, u, v)
vert_dict["vcolor"] = [color_tuple[0], color_tuple[1], color_tuple[2]]
geometry_data.append(vert_dict)
output_dict = {"geometry": geometry_data}
json_file_path = os.path.join(bpy.path.abspath("//"), JSON_FILENAME)
try:
with open(json_file_path, "w") as f:
json.dump(output_dict, f, indent=2)
print("Successfully wrote vertex data to:", json_file_path)
except Exception as e:
error_msg = "Failed to write JSON file: " + str(e)
print(error_msg)
show_error_popup(error_msg)
# Cleanup temporary objects, then return
bpy.ops.object.select_all(action='DESELECT')
subdiv_obj.select_set(True)
remesh_obj.select_set(True)
bpy.context.view_layer.objects.active = remesh_obj
bpy.ops.object.delete()
return
# 7. Cleanup: delete the two temporary objects
bpy.ops.object.select_all(action='DESELECT')
subdiv_obj.select_set(True)
remesh_obj.select_set(True)
bpy.context.view_layer.objects.active = remesh_obj
bpy.ops.object.delete()
original_obj.select_set(True)
bpy.context.view_layer.objects.active = original_obj
# 8. Show a popup to inform the user that we are finished
bpy.context.window_manager.popup_menu(
draw_finished_popup,
title="Your JSON was exported successfully :)",
icon='FILE_TICK'
)
print("Done! Original object remains untouched.")
# -----------------------------------------------------------------------------------
# Run
# -----------------------------------------------------------------------------------
if __name__ == "__main__":
main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment