|
# 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() |