2
$\begingroup$

My problem is mainly with UV texturing from Python.

I am building a complex model from Python. The model consists of several cubes which have different colors. On top of each cube there's supposed to be an image texture. The goal is to add the cubes from Python, color and texturize them, then union everything with a boolean modifier to get rid of overlapping parts and then to bake the separate textures into a single texture file. In the end I'll upload the 3D model to Shapeways to 3D print it.

In the basic example provided I have 5 cubes. The textures for the cubes are in 5 separate PNG files. The output from my script looks like this:

Demo result

The output is fine, textures are in the right place and the overlaps are gone. I can now export this to X3D and upload to Shapeways, but on more complex models I'll have 500 separate unoptimized PNG files. This doesn't play well with Shapeways, so I'd like to bake the separate textures into a single texture of 2048x2048. But when I try to do this manually, I get the following result:

Wrong output

This is supposed to be the texture for the whole model, so clearly something isn't right.

The question: How to texturize the top of cubes, so it will survive the Union boolean modifier and Texture bake operation from Python?

My code (I'm 100% sure my approach to applying the Union boolean modifier is wrong, but other approaches I tried didn't maintain the materials):

import json
import bpy
import mathutils
import os
from mathutils import Vector

bpy.ops.object.select_all(action='SELECT')
bpy.ops.object.delete()

# Configurable variables
path_to_json_and_textures = "/path/to/question/folder"
max_model_dimension = 20 # Blender units are in meters
cube_height_relative_to_width = 0.1
overlap_fraction = 0.25 # overlap of cubes stacked on top each other
scale_factor = 0.01

def isJipObject(ob):
    return ob.name.startswith('jip-') and ob.type == 'MESH'

def union_model():
    scene = bpy.context.scene
    web_space = scene.web_space
    obs = []
    # Only union objects made by this script
    for ob in scene.objects:
        if isJipObject(ob):
            obs.append(ob)

    nr_of_cubes = len(obs)
    if not nr_of_cubes:
        return

    # Add some noise, give each object has unique dimensions
    # Boolean modifier doesn't work with perfectly aligned cubes
    boolean_hack = 0.0
    boolean_hack_step_size = float(0.001/nr_of_cubes) # spread out 0.1mm of noise over all cubes

    # Slightly change the scale of all of the cubes, before union
    for ob in obs:
        ob.scale.x += boolean_hack
        ob.scale.y += boolean_hack
        ob.scale.z += boolean_hack
        boolean_hack += boolean_hack_step_size

    # Don't touch current context...
    ctx = bpy.context.copy()
    # one of the objects to join
    ctx['active_object'] = obs[0]
    ctx['selected_objects'] = obs
    # we need the scene bases as well for joining
    ctx['selected_editable_bases'] = [scene.object_bases[ob.name] for ob in obs]
    # Join the objects - this will assign all materials correctly to all objects
    bpy.ops.object.join(ctx)
    # Break them apart again - not sure what happens,
    # but joining and then breaking apart allows to do a union while perserving correct UV mapping...
    # Perhaps it has to do with the origin of the cubes? Joining then separating assigns all cubes
    # the same point of origin, as well as the same location, scale and z dimension
    # The only variance is in the x and y dimension
    bpy.ops.mesh.separate(type='LOOSE')

    prevCube = False

    scene = bpy.context.scene
    # Need to reselect the objects, because of 'separate by loose parts' the objects in obs no longer exist
    for ob in scene.objects:
        # whatever objects you want to join...
        if isJipObject(ob):
            if(not prevCube):
                prevCube = ob
            else:                
                modifier = prevCube.modifiers.new('Modifier', 'BOOLEAN')
                modifier.object = ob
                modifier.operation = 'UNION'
                bpy.context.scene.objects.active = prevCube
                bpy.ops.object.modifier_apply(apply_as='DATA', modifier=modifier.name)
                scene.objects.unlink(ob)

    bpy.context.scene.objects.active = prevCube

def makeImageMaterial(index):  
    material_name = "jip-material"+str(index)
    if material_name in bpy.data.materials:
        return bpy.data.materials[material_name]

    # Create shadeless material
    mat = bpy.data.materials.new(material_name)
    mat.use_shadeless = True

    # Try, if not exists don't make texture
    try:
        realpath = os.path.join(path_to_json_and_textures, 'cropped', str(index)+'.png')
        tex = bpy.data.textures.new(material_name, type = 'IMAGE')
        tex.image = bpy.data.images.load(realpath)
        tex.use_alpha = True    
        # Create texture
        mtex = mat.texture_slots.add()
        mtex.texture = tex
        mtex.texture_coords = 'UV'
        mtex.use_map_color_diffuse = True
    except:
        print("Texture %d not found" % index)

    return mat

def makeMaterial(red,green,blue):
    # return existing
    name = "jip-material-%d-%d-%d" % (red,green,blue)
    if name in bpy.data.materials:
        return bpy.data.materials[name]

    colorName = "jip-material"

    material = bpy.data.materials.new(name)
    material.diffuse_color = (red/100., green/100., blue/100.)
    material.use_shadeless = True
    # return new
    return material

with open(os.path.join(path_to_json_and_textures, "data.json")) as data_file:    
    model_data = json.load(data_file)

    # Non-configurable variables
    model_width = (model_data['right'] - model_data['left']) * scale_factor
    model_height = (model_data['bottom'] - model_data['top']) * scale_factor

    initial_cube_height = model_width*cube_height_relative_to_width
    half_current_cube_height = initial_cube_height/2 # divide by 2 because the scale operation increases height towards top and bottom
    non_overlapping_fraction = 1 - overlap_fraction
    current_z = -half_current_cube_height + overlap_fraction*initial_cube_height

    mylayers = [False] * 20
    mylayers[0] = True
    add_cube = bpy.ops.mesh.primitive_cube_add
    prevCube = False
    count = 0

    # Make initial object
    origin = Vector((0,0,0))
    bpy.ops.mesh.primitive_cube_add(location=origin)

    ob = bpy.context.object
    bpy.ops.mesh.uv_texture_add()
    obs = []
    sce = bpy.context.scene

    # Loop over each level and make all the cubes, with color and texture
    for i, level in enumerate(model_data['levels']):
        current_z += half_current_cube_height*2 * non_overlapping_fraction # compensate for overlap in z coordinate
        for element in level:

            copy = ob.copy()
            node_width = element['w']
            node_height = element['h']
            copy.location = Vector(((element['y']*2 + node_height)*scale_factor,
                                    (element['x']*2 + node_width)*scale_factor,
                                    current_z))
            copy.scale = (node_height*scale_factor, node_width*scale_factor, half_current_cube_height)
            copy.name = "jip-" + str(element['nodeIndex']).zfill(6) # leading zeros for correct sorting in GUI
            copy.data = copy.data.copy() # also duplicate mesh, remove for linked duplicate

            material = makeMaterial(element['r'], element['g'], element['b'])
            copy.data.materials.append(material)

            image_material = makeImageMaterial(element['nodeIndex'])
            copy.data.materials.append(image_material)

            # Set image material for top face only
            copy.data.polygons[5].material_index = 1

            obs.append(copy)

    # remove initial object
    bpy.ops.object.delete()

    # Now add them to the scene, adding large number of cubes is quicker this way
    for ob in obs:
        sce.objects.link(ob)

    union_model()

You can download the .blend file, script and textures from here: https://db.tt/dhTMLXe0

$\endgroup$
6
  • $\begingroup$ rapid reading... but can that be because you need to apply the modified scales before the union ? $\endgroup$
    – lemon
    Commented Aug 15, 2016 at 15:46
  • $\begingroup$ Unfortunately the result is the same. Steps taken: Apply scale for each cube. Manually assign all the materials to the bottom cube. Manually apply boolean modifier (union) 4 times to unite with the other cubes. Then in UV Editing window I bake to a newly created image. Same thing happens when I also apply the location. $\endgroup$
    – Jip
    Commented Aug 15, 2016 at 17:15
  • $\begingroup$ The UVs from the sides and bottom overlap, is that expected ? $\endgroup$
    – lemon
    Commented Aug 15, 2016 at 17:32
  • $\begingroup$ What do you mean by that @lemon? How can I check this? The bottom and side should have the same material and color. But overlapping UVs doesn't sound right... Also, do you mean before the union or the end result? $\endgroup$
    – Jip
    Commented Aug 15, 2016 at 18:26
  • $\begingroup$ you are baking all the faces, including the sides. And the sides (UVs) overlap the top picture. So probably the result is dependent on the order (materials) the bake is working $\endgroup$
    – lemon
    Commented Aug 15, 2016 at 18:27

1 Answer 1

0
$\begingroup$

The PNG files were actually cropped form an original large PNG containing all the image data already. So I didn't have to bake to a new file.

This answer focusses on adding the UV mapping via Python.

Join all the cubes (ctrl+j). With this script I was able to automatically unwrap all the cubes as if doing "UVs -> Unwrap -> Project From View (bounds)" in an orthographic top view.

import bpy, bmesh
from mathutils import Vector

# https://blender.stackexchange.com/questions/32283/what-are-all-values-in-bound-box
def bounds(obj, local=False):

    local_coords = obj.bound_box[:]
    om = obj.matrix_world

    if not local:    
        worldify = lambda p: om * Vector(p[:]) 
        coords = [worldify(p).to_tuple() for p in local_coords]
    else:
        coords = [p[:] for p in local_coords]

    rotated = zip(*coords[::-1])

    push_axis = []
    for (axis, _list) in zip('xyz', rotated):
        info = lambda: None
        info.max = max(_list)
        info.min = min(_list)
        info.distance = info.max - info.min
        push_axis.append(info)

    import collections

    originals = dict(zip(['x', 'y', 'z'], push_axis))

    o_details = collections.namedtuple('object_details', 'x y z')
    return o_details(**originals)

obj = bpy.context.scene.objects.active

if not obj is None:

    # Get the lowest and highest x and y values from the bounding box corners
    object_details = bounds(obj)

    lowest_x_cor = object_details.x.min
    highest_x_cor = object_details.x.max

    lowest_y_cor = object_details.y.min
    highest_y_cor = object_details.y.max

    obj.data.uv_textures.new("map1")
    # Get a BMesh representation
    bm = bmesh.new()   # create an empty BMesh
    bm.from_mesh(obj.data)   # fill it in from a Mesh

    bm.faces.ensure_lookup_table()

    uv_layer = bm.loops.layers.uv[0]

    nFaces = len(bm.faces)
    for fi in range(nFaces):
        face = bm.faces[fi]

        for loop in face.loops:
            # Scale the vertex coordinates into 0 to 1 range
            NewXValue = (((loop.vert.co[0] - lowest_x_cor) * 1) / (highest_x_cor - lowest_x_cor))
            NewYValue = (((loop.vert.co[1] - lowest_y_cor) * 1) / (highest_y_cor - lowest_y_cor))
            loop[uv_layer].uv = (NewXValue, NewYValue)


    # Finish up, write the bmesh back to the mesh
    bm.to_mesh(obj.data)
    bm.free()

This als UV maps the side and bottom faces. It stretches the pixels on the border of top of the cubes down the sides, which I like. To only texture the top I should not loop over all the faces, but instead only the faces with a normal of (0, 0, 1 ).

For this I could use these functions:

# https://blender.stackexchange.com/questions/75517/selecting-faces-in-python

def NormalInDirection( normal, direction, limit = 0.5 ):
    return direction.dot( normal ) > limit

def GoingUp( normal, limit = 0.5 ):
    return NormalInDirection( normal, Vector( (0, 0, 1 ) ), limit )

def GoingDown( normal, limit = 0.5 ):
    return NormalInDirection( normal, Vector( (0, 0, -1 ) ), limit )

To get rid of the internal geometry I found Netfabb (for Windows only) is the way to go if the goal is to prepare for 3D-printing, instead of the Boolean modifier in Blender. After UV unwrapping I export a 3DS file from Blender, then import into Netfabb. Let it do auto repair upon import. Export to 3DS again and import into Blender. The UV mapping is still correct but the internal geometry is removed.

$\endgroup$

You must log in to answer this question.

Not the answer you're looking for? Browse other questions tagged .