Generating UV on imported OSM Buildings

When working with object manipulation in PHP, it’s not just buildings that can benefit from creative scripting—any object can! This script takes a composite object, breaks it into separate components, and applies a random texture to each part. While simple in concept, it’s a fun way to experiment with procedural generation, whether for games, simulations, or visualizations.

The script assumes the object is pre-structured for separation (e.g., an array of parts). It then iterates through each segment, assigning a random texture from a predefined set—think wood, metal, or stone. The result? A visually diverse object with minimal effort. However, there’s a catch: it’s not optimized. For objects with a huge number of parts, the script can take forever to run, as it processes each component individually without performance tweaks like caching or batching.

Here’s the code:

import bpy
import secrets
import os
import random
import mathutils
from mathutils import Vector

uv_image_scale=4
num_images_to_use=40

image_name_3= '/home/gavin/Pictures/blender/seamless-texture-facade-building.jpg'
image_name_1= '/home/gavin/Pictures/blender/buildings-apartments-shopfronts-shutters/building_side2.png'
image_name_4= '/home/gavin/Pictures/blender/buildings-apartments-shopfronts-shutters/building_office13.png'
image_name_2= '/home/gavin/Pictures/blender/buildings-apartments-shopfronts-shutters/apartments6.png'
image_name_1='/home/gavin/Pictures/blender/sidewalk-displace.png'

texture_dir = '/home/gavin/Pictures/blender/buildings-apartments-shopfronts-shutters/'

roof_texture = '/home/gavin/Pictures/blender/sidewalk-displace.png'

def get_random_texture_from_directory(directory):
    """Get a random texture file from the directory."""
    textures = [f for f in os.listdir(directory) if f.endswith(('png', 'jpg', 'jpeg'))]
    if textures:
        return os.path.join(directory, random.choice(textures))
    else:
        return None
    
def map_uvs_to_normal(part, collection):
    """Map UVs based on face normal, rotating face to align with Z-axis and then back."""
    
    uv_layer = get_or_create_uv_layer(part, 'UVMap_NormalAligned')

    for face in part.data.polygons:
        loop_indices = face.loop_indices
        face_normal = face.normal.normalized()

        # Step 1: Create a rotation matrix that aligns the face normal with the Z-axis
        # Calculate the axis of rotation and the angle required to rotate face normal to align with Z-axis
        x_axis = mathutils.Vector((1, 0, 0))
        y_axis = mathutils.Vector((0, 1, 0))
        z_axis = mathutils.Vector((0, 0, 1))
        
    
        axis_of_rotation = face_normal.cross(z_axis)
        angle_of_rotation = face_normal.angle(z_axis)

        # If the angle is non-zero, apply a rotation matrix
        if axis_of_rotation.length > 0.0:
            rotation_matrix = mathutils.Matrix.Rotation(angle_of_rotation, 4, axis_of_rotation)
        else:
            rotation_matrix = mathutils.Matrix.Identity(4)  # No rotation needed if already aligned

        
        # Step 2: Apply the rotation matrix to the face vertices
        uv_data = uv_layer.data
        transformed_coords = []

        for i in loop_indices:
            vertex = part.data.vertices[part.data.loops[i].vertex_index]
            transformed_pos = rotation_matrix @ vertex.co  # Apply rotation to align face normal with Z-axis
            transformed_coords.append(transformed_pos.xy)  # Keep the 2D XY coordinates


            # Step 3: Compute the bounding box in 2D (after rotation)
            min_x, min_y = min(c[0] for c in transformed_coords), min(c[1] for c in transformed_coords)
            max_x, max_y = max(c[0] for c in transformed_coords), max(c[1] for c in transformed_coords)

            # Compute face width and height
            face_width = max_x - min_x
            face_height = max_y - min_y
            # Compute aspect ratio scaling
            if face_width > face_height:
                scale_x = 1.0
                scale_y = face_height / face_width  # Shrink Y to maintain aspect ratio
            else:
                scale_x = face_width / face_height  # Shrink X to maintain aspect ratio
                scale_y = 1.0

            # Step 4: Normalize UV coordinates while maintaining aspect ratio
            for i, loop_index in enumerate(loop_indices):
                uv_x = (transformed_coords[i][0] - min_x) / face_width if face_width > 0 else 0.5
                uv_y = (transformed_coords[i][1] - min_y) / face_height if face_height > 0 else 0.5
            
                # Apply aspect ratio scaling and center the texture
                uv_x = (uv_x - 0.5) * scale_x + 0.5
                uv_y = (uv_y - 0.5) * scale_y + 0.5
                uv_data[loop_index].uv = (uv_x, uv_y)

    # Link the object to the collection and update its data
    collection.objects.link(part)
    part.data.update()


    
def map_uvs_to_bbox(part,collection):
    """Map UVs to a 0.0 - 1.0 bounding box based on face orientation."""
    uv_layer_flat = get_or_create_uv_layer(part, 'UVMap_NormalAligned')
    uv_layer_vertical = get_or_create_uv_layer(part, 'UVMap_NormalAligned')
    uv_layer_other = get_or_create_uv_layer(part, 'UVMap_NormalAligned')

    # Assign materials
    
    roof_index = -1
    for idx, mat in enumerate(obj.data.materials):
        if mat.name == "Material_Roof":
            roof_index = idx
            break

    obj_material=random.randint(4, num_images_to_use+3)


    for face in part.data.polygons:
        loop_indices = face.loop_indices
        face_normal = face.normal.normalized()
        if abs(face_normal.z) > 0.95:
            uv_layer = uv_layer_flat
            face.material_index = roof_index
        elif (abs(face_normal.x) > 0.95) or (abs(face_normal.y) > 0.95):
            uv_layer = uv_layer_vertical
            face.material_index = obj_material
        else:
            uv_layer = uv_layer_other
            face.material_index = obj_material
        uv_data = uv_layer.data
        if len(loop_indices) == 4:  # Quads
            uv_coords = [(0.0, 0.0), (uv_image_scale, 0.0), (uv_image_scale, uv_image_scale), (0.0, uv_image_scale)]
            for i, loop_index in enumerate(loop_indices):
                uv_data[loop_index].uv = uv_coords[i]
        elif len(loop_indices) == 3:  # Triangles
            uv_coords = [(0.0, 0.0), (1.0, 0.0), (0.5, 1.0)]
            for i, loop_index in enumerate(loop_indices):
                uv_data[loop_index].uv = uv_coords[i]
        else:
            # Select UV projection based on dominant axis
            if abs(face_normal.z) > 0.9:  # Flat (XZ plane)
                uv_layer = get_or_create_uv_layer(part, 'UVMap_NormalAligned')
                projection_axis = ('x', 'y')  # Map to X-Y space
            elif abs(face_normal.x) > 0.99 or abs(face_normal.y) > 0.99:  # Vertical (YZ plane)
                uv_layer = get_or_create_uv_layer(part, 'UVMap_NormalAligned')
                projection_axis = ('y', 'z')  # Map to Y-Z space
            else:  # Other orientations
                uv_layer = get_or_create_uv_layer(part, 'UVMap_NormalAligned')
                projection_axis = ('x', 'z')  # Map to X-Z space
            uv_data = uv_layer.data
            # Get face vertex positions in the selected 2D projection
            coords = [(getattr(part.data.vertices[part.data.loops[i].vertex_index].co, projection_axis[0]),
                getattr(part.data.vertices[part.data.loops[i].vertex_index].co, projection_axis[1]))
            for i in loop_indices]
            # Compute bounds
            min_x, min_y = min(c[0] for c in coords), min(c[1] for c in coords)
            max_x, max_y = max(c[0] for c in coords), max(c[1] for c in coords)
            for i, loop_index in enumerate(loop_indices):
                uv_x = (coords[i][0] - min_x) / (max_x - min_x) if max_x > min_x else 0.5
                uv_y = (coords[i][1] - min_y) / (max_y - min_y) if max_y > min_y else 0.5
                uv_data[loop_index].uv = (uv_x, uv_y)
    collection.objects.link(part)
    part.data.update()


def get_or_create_uv_layer(obj, name):
    """Check if UV layer exists; if not, create a new one."""
    if name not in obj.data.uv_layers:
        return obj.data.uv_layers.new(name=name)
    return obj.data.uv_layers.get(name)


def get_or_create_material(name, image_name, uv_map_name, imagename):
    """Create or retrieve a material and assign a UV map to it."""
    mat = bpy.data.materials.get(name)
    if mat is None:
        mat = bpy.data.materials.new(name=name)

    mat.use_nodes = True

    # Create the image
    texImage = mat.node_tree.nodes.new('ShaderNodeTexImage')
    texImage.image = bpy.data.images.load(imagename)

    img = bpy.data.images.new(name=image_name, width=1024, height=1024)
    img.generated_type = 'UV_GRID'

    bsdf = mat.node_tree.nodes.get("Principled BSDF")
    uv_map_node = mat.node_tree.nodes.new('ShaderNodeUVMap')
    uv_map_node.uv_map = uv_map_name

    if bsdf:
        tex_image = mat.node_tree.nodes.new('ShaderNodeTexImage')
        tex_image.image = img
        
        mat.node_tree.links.new(texImage.outputs['Color'], bsdf.inputs['Base Color'])
        mat.node_tree.links.new(uv_map_node.outputs['UV'], texImage.inputs['Vector'])

    return mat


# Ensure we're in OBJECT mode
bpy.ops.object.mode_set(mode='OBJECT')

# Get the active object
obj = bpy.context.object


if obj and obj.type == 'MESH':
    bpy.ops.object.mode_set(mode='EDIT')
    bpy.ops.mesh.select_all(action='SELECT')
    bpy.ops.object.mode_set(mode='OBJECT')

    obj.data.materials.clear()

    # Generate unique names for images
    image_name1 = secrets.token_hex(5)
    image_name2 = secrets.token_hex(5)
    image_name3 = secrets.token_hex(5)


    # Create materials
    material_roof = get_or_create_material("Material_Roof", secrets.token_hex(5), "UVMap_NormalAligned", roof_texture)    
    material_flat = get_or_create_material("Material_1", image_name1, "UVMap_NormalAligned",image_name_1)
    material_vertical = get_or_create_material("Material_2", image_name2, "UVMap_NormalAligned",image_name_2)
    material_other = get_or_create_material("Material_3", image_name3, "UVMap_NormalAligned",image_name_3)
    obj.data.materials.append(material_roof)
    obj.data.materials.append(material_flat)
    obj.data.materials.append(material_vertical)
    obj.data.materials.append(material_other)    
    # Create 20 random materials with random textures
    for i in range(num_images_to_use):
        random_texture = get_random_texture_from_directory(texture_dir)
        if random_texture:
            material_name = f"Material_{i+1}"
            image_name = secrets.token_hex(5)
            material = get_or_create_material(material_name, image_name, "UVMap_NormalAligned", random_texture)
            obj.data.materials.append(material)
            
    

    print(f"Assigned materials to {len(obj.data.polygons)} faces.")

    # Separate by loose parts
    bpy.ops.object.mode_set(mode='EDIT')
    bpy.ops.mesh.separate(type='LOOSE')
    bpy.ops.object.mode_set(mode='OBJECT')

    separated_objects = [o for o in bpy.context.selected_objects if o != obj]

    new_collection = bpy.data.collections.new("Separated_Meshes")
    scene_collection = bpy.context.view_layer.layer_collection.collection
    scene_collection.children.link(new_collection)
    
    bpy.context.view_layer.update()

    for part in separated_objects:
        map_uvs_to_bbox(part,new_collection)

    #original_obj = bpy.context.object
    #copy_obj = original_obj.copy()
    #copy_obj.data = original_obj.data.copy()
    #new_collection.objects.link(copy_obj)
    #map_uvs_to_bbox(obj,new_collection)
    map_uvs_to_bbox(obj,new_collection)

    print("UV mapping applied and meshes separated successfully.")

else:
    print("No valid mesh object selected.")

This approach works for small-scale projects but needs refinement for efficiency. Still, it’s a great starting point for hobbyists looking to play with object customization in PHP!