I'm able to create a blocky voxel mesh with this thread different materials thanks to Markus von Broady. Now I continued the setup to add center point for each surface voxel. I added Mesh Lines going along the normal of each voxel face and extended them inward by half voxel size so they intersect. And realize instances so they become vertices and will do a Merge By Distance. I just use these intersected vertexes as reference so I could get the voxel position so these Mesh Lines are not really needed if ever there is a better method to get the center position of each voxel. enter image description here

Now I'm trying to use python to print out a list of all the voxel positions (based on the intersecting center points) with their corresponding hex color (as if I used an eye-dropper tool to get the color 0xHHHHHH) but I'm stuck because I'm unsure how to do that.

enter image description here

enter image description here

This is my blend file https://blend-exchange.com/b/1RVKOMpZ

  • $\begingroup$ By color, what do you mean? Is it the material index, or the value you've plugged as output of the node group (which is not valid)? $\endgroup$
    – lemon
    Commented Mar 4 at 7:45
  • $\begingroup$ hi lemon, by color i literally meant the diffuse color of the output as i can see in the 3d view. so in this example there are voxels that have green, red, and different shades of gray since that's from a texture. as if i would use an eye-dropper tool on each voxel i would like to get the hex value 0xHHHHHH $\endgroup$
    – Megan Love
    Commented Mar 4 at 7:47
  • $\begingroup$ To be sure there is no more simple way to do it, could you describe the purpose of this? Because I don't know what is the role of the mesh lines here. $\endgroup$
    – lemon
    Commented Mar 4 at 7:55
  • $\begingroup$ i want to store the result as a qb file using github.com/dee-gmiterko/qb-reader/blob/master/qb-reader.py but first i would need a list of all the voxels positions and their colors. i dont need the mesh lines, i just needed the intersecting vertex as a reference for voxel position $\endgroup$
    – Megan Love
    Commented Mar 4 at 8:01
  • 1
    $\begingroup$ "I just use these intersected vertexes as reference so I could get the voxel position so these Mesh Lines are not really needed if ever there is a better method to get the center position of each voxel. " - for each face, you can get the center of the face, offset it by the face's normal scaled by half the width of the voxel - that's the center of the voxel. $\endgroup$ Commented Mar 4 at 12:14

1 Answer 1


A script that should work (please check this is ok with no bug...)

Note: it supposes the uv coordinates are calculated as done in your gn setup. Meaning for a given voxel 'cube' all uv coords are at the same location.

The principle:

Calculate key data for each material associated to the object.

A material (through Principled BSDF) can be either its color or an image texture. The first part of the script retreives that.

Then, it calculates voxel centers corresponding to each face as face center minus half normal scaled by edge length.

And populate a kdtree with it.

Last, loop over the faces and get the color of it if not already encountered in the kdtree.

Commented code:

import bpy
import bmesh
from math import sqrt
from mathutils import Color, kdtree

# get material key data: either direct color or image
def get_material(i, m):
    # assume we only have a "Principled BSDF" 
    # which have either a texture input or a simple color
    # many things should be checked, but we go straight as the materials are here
    p = m.node_tree.nodes['Principled BSDF']
    if len(p.inputs[0].links) == 0:
        c = p.inputs[0].default_value
        return (i, m, Color((c[0], c[1], c[2])), None, None)
        tex_node = p.inputs[0].links[0].from_node
        image = tex_node.image
        return (i, m, None, image.size, image.pixels[:])

# get all materials key data
def get_materials(obj):
    return [get_material(i, m) for i, m in enumerate(obj.data.materials) if m]

# get the color from a give face index
def get_color(bm, materials, i):
    m = materials[bm.faces[i].material_index]
    if m[2]:
        # either direct color
        return m[2]
        # or the color in the image texture
        uv = bm.faces[i].loops[0][bm.loops.layers.uv[0]].uv
        px = int((m[3][0]-1) * uv.x)
        py = int((m[3][1]-1) * uv.y)
        pixel = 4 * (m[3][0] * py + px)
        color = Color((m[4][pixel], m[4][pixel+1], m[4][pixel+2]))
        return color

# create a bmesh object
dg = bpy.context.evaluated_depsgraph_get()
o = bpy.context.object
e = o.evaluated_get(dg)

bm.from_object(o, dg)

# get the object materials
materials = get_materials(e)

# create a kd tree for fast distance comparison
centers_kd = kdtree.KDTree(len(bm.faces))

# calculate voxel center for each face
# as a face is a square, edge length is sqrt the surface
centers_voxel = [f.calc_center_median() + (-0.5 * f.normal * sqrt(f.calc_area())) for f in bm.faces]

# populate the tree
for i, f in enumerate(bm.faces):
    centers_kd.insert(centers_voxel[i], i)


epsilon = 0.001

for i, c in enumerate(centers_voxel):
    # if not processed
    if c:
        # get the color
        col = get_color(bm, materials, i)
        # mark as processed
        for _, fi, d in centers_kd.find_n(c, 6):
            if d < epsilon:
                centers_voxel[fi] = None
        # print the result
        print(c, col)

Thanks to your other question, I realized that we finally need integer coordinates for the Qubicle matrix.

In consequence, using a KDTree is useless, as we can simply use a dictionary with the tuple of integer coordinates as keys (no more float rounding issues).

That should gain a significant step in terms of performance.

Here is the modified part, from your last complete code.

The KDTree is removed and the face colors are calculated directly in the first loop, mapping over bmesh faces.

I've also modified QB matrix data calculation as the min and max dimensions are not known when calculating the colors.

class FaceColorReader:
    def __init__(self, object, voxel_size):

        dg = bpy.context.evaluated_depsgraph_get()
        e = object.evaluated_get(dg)

        self.bm = bmesh.new()
        self.bm.from_object(object, dg)

        self.materials = [self.get_material(m) for i, m in enumerate(e.data.materials) if m]

        self.colors = {}

        min_x, min_y, min_z = float('inf'), float('inf'), float('inf')
        max_x, max_y, max_z = -float('inf'), -float('inf'), -float('inf')

        for f in self.bm.faces:
            center = f.calc_center_median()
            normal = f.normal
            area = sqrt(f.calc_area()) # as a face is a square, edge length is sqrt the surface
            displacement = -0.5 * normal * area
            voxel_center = center + displacement

            # translate to integer and make it a tuple
            center_x, center_y, center_z = round(voxel_center.x / voxel_size - 0.5), round(voxel_center.y / voxel_size - 0.5), round(voxel_center.z / voxel_size - 0.5)
            center = (center_x, center_y, center_z)

            if not center in self.colors:
                col = self.get_face_color(f.index)
                self.colors[center] = col
                min_x, min_y, min_z = min(min_x, center_x), min(min_y, center_y), min(min_z, center_z)
                max_x, max_y, max_z = max(max_x, center_x), max(max_y, center_y), max(max_z, center_z)

        self.min_values = Vector((round(min_x), round(min_y), round(min_z)))
        self.max_values = Vector((round(max_x), round(max_y), round(max_z)))

    def get_material(self, m):
        p = m.node_tree.nodes['Principled BSDF']
        base_color = p.inputs[0]
        link = base_color.links[0] if len(base_color.links) else None
        if not link: # nothing connected to Base Color socket
            c = base_color.default_value
            return (round(c[0]*255), round(c[1]*255), round(c[2]*255), 255)
        if link.from_node.type == 'TEX_IMAGE':
            tex_node = p.inputs[0].links[0].from_node
            image = tex_node.image
            return (image.size, image.pixels[:])
        if link.from_node.type == 'VERTEX_COLOR':
            c = base_color.default_value
            return (round(c[0]*255), round(c[1]*255), round(c[2]*255), 255)
        return (0, 0, 0, 255) # unhandled or undefined linked node

    def get_face_color(self, face_index):
        f = self.bm.faces[face_index]
        m = self.materials[f.material_index]
        tuple_len = len(m)
        if tuple_len == 4: # either direct color as tuple length 4
            return m
        elif tuple_len == 2: # or the color in the image texture
            uv = f.loops[0][self.bm.loops.layers.uv[0]].uv
            size = m[0]
            px = int((size[0]-1) * uv.x)
            py = int((size[1]-1) * uv.y)
            pixel = 4 * (size[0] * py + px)
            pxs = m[1]
            return (round(pxs[pixel]*255), round(pxs[pixel+1]*255), round(pxs[pixel+2]*255), 255)
        #TODO: get vertex colors

    def get_voxel_dimensions(self):
        min_x, min_y, min_z, max_x, max_y, max_z = self.get_voxel_ranges()
        return max_x-min_x+1, max_y-min_y+1, max_z-min_z+1

    def get_voxel_ranges(self):
        min_x, min_y, min_z = round(self.min_values.x), round(self.min_values.y), round(self.min_values.z)
        max_x, max_y, max_z = round(self.max_values.x), round(self.max_values.y), round(self.max_values.z)
        return min_x, min_y, min_z, max_x, max_y, max_z

    def get_color_data(self):
        min_x, min_y, min_z, max_x, max_y, max_z = self.get_voxel_ranges()
        empty = (0, 0, 0, 0)
        data = [self.colors[x, y, z] if self.colors.get((x,y,z)) else empty 
                for z in range(min_z, max_z + 1)
                for y in range(min_y, max_y + 1)
                for x in range(min_x, max_x + 1)]
        return data
  • $\begingroup$ Comments have been moved to chat; please do not continue the discussion here. Before posting a comment below this one, please review the purposes of comments. Comments that do not request clarification or suggest improvements usually belong as an answer, on Blender Meta, or in Blender Chat. Comments continuing discussion may be removed. $\endgroup$ Commented Mar 5 at 16:07
  • $\begingroup$ The code works wonderfully. I have implemented a bake feature that UV maps each face to a 1x1 px square on the UV map like in this image but I had to change px = int((size[0]-1) * uv.x) to px = round((size[0]-1) * uv.x) to make it work perfectly. Using int cast would make the colors jumble around in the final QB file. Do you see any potential issues with this change in the future? Would have been cool if float values for pixels worked so I can be sure I sample exactly the center of the 1x1px UV squares. $\endgroup$
    – Megan Love
    Commented Apr 1 at 8:27
  • 1
    $\begingroup$ I think round is fine. $\endgroup$
    – lemon
    Commented Apr 2 at 9:04

You must log in to answer this question.

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