Skip to content

Instantly share code, notes, and snippets.

@patriciogonzalezvivo
Last active August 5, 2023 02:46
Show Gist options
  • Save patriciogonzalezvivo/1a286bf26a4a7518c7c457f4e2e8bd6e to your computer and use it in GitHub Desktop.
Save patriciogonzalezvivo/1a286bf26a4a7518c7c457f4e2e8bd6e to your computer and use it in GitHub Desktop.
Export Normals of Point Cloud PLY in blender
# SPDX-License-Identifier: GPL-2.0-or-later
"""
This script imports Stanford PLY files from Blender.
Vertex normals will be imported as "Normal" custum attributes
Replace you Blender_3.X.X/X.X/scripts/addons/io_mesh_ply/import_ply.py for this code
"""
class ElementSpec:
__slots__ = (
"name",
"count",
"properties",
)
def __init__(self, name, count):
self.name = name
self.count = count
self.properties = []
def load(self, format, stream):
if format == b'ascii':
stream = stream.readline().split()
return [x.load(format, stream) for x in self.properties]
def index(self, name):
for i, p in enumerate(self.properties):
if p.name == name:
return i
return -1
class PropertySpec:
__slots__ = (
"name",
"list_type",
"numeric_type",
)
def __init__(self, name, list_type, numeric_type):
self.name = name
self.list_type = list_type
self.numeric_type = numeric_type
def read_format(self, format, count, num_type, stream):
import struct
if format == b'ascii':
if num_type == 's':
ans = []
for i in range(count):
s = stream[i]
if not (len(s) >= 2 and s.startswith(b'"') and s.endswith(b'"')):
print("Invalid string", s)
print("Note: ply_import.py does not handle whitespace in strings")
return None
ans.append(s[1:-1])
stream[:count] = []
return ans
if num_type == 'f' or num_type == 'd':
mapper = float
else:
mapper = int
ans = [mapper(x) for x in stream[:count]]
stream[:count] = []
return ans
else:
if num_type == 's':
ans = []
for i in range(count):
fmt = format + 'i'
data = stream.read(struct.calcsize(fmt))
length = struct.unpack(fmt, data)[0]
fmt = '%s%is' % (format, length)
data = stream.read(struct.calcsize(fmt))
s = struct.unpack(fmt, data)[0]
ans.append(s[:-1]) # strip the NULL
return ans
else:
fmt = '%s%i%s' % (format, count, num_type)
data = stream.read(struct.calcsize(fmt))
return struct.unpack(fmt, data)
def load(self, format, stream):
if self.list_type is not None:
count = int(self.read_format(format, 1, self.list_type, stream)[0])
return self.read_format(format, count, self.numeric_type, stream)
else:
return self.read_format(format, 1, self.numeric_type, stream)[0]
class ObjectSpec:
__slots__ = ("specs",)
def __init__(self):
# A list of element_specs
self.specs = []
def load(self, format, stream):
return {
i.name: [
i.load(format, stream) for j in range(i.count)
]
for i in self.specs
}
def read(filepath):
import re
format = b''
texture = b''
version = b'1.0'
format_specs = {
b'binary_little_endian': '<',
b'binary_big_endian': '>',
b'ascii': b'ascii',
}
type_specs = {
b'char': 'b',
b'uchar': 'B',
b'int8': 'b',
b'uint8': 'B',
b'int16': 'h',
b'uint16': 'H',
b'short': 'h',
b'ushort': 'H',
b'int': 'i',
b'int32': 'i',
b'uint': 'I',
b'uint32': 'I',
b'float': 'f',
b'float32': 'f',
b'float64': 'd',
b'double': 'd',
b'string': 's',
}
obj_spec = ObjectSpec()
invalid_ply = (None, None, None)
with open(filepath, 'rb') as plyf:
signature = plyf.peek(5)
if not signature.startswith(b'ply') or not len(signature) >= 5:
print("Signature line was invalid")
return invalid_ply
custom_line_sep = None
if signature[3] != ord(b'\n'):
if signature[3] != ord(b'\r'):
print("Unknown line separator")
return invalid_ply
if signature[4] == ord(b'\n'):
custom_line_sep = b"\r\n"
else:
custom_line_sep = b"\r"
# Work around binary file reading only accepting "\n" as line separator.
plyf_header_line_iterator = lambda plyf: plyf
if custom_line_sep is not None:
def _plyf_header_line_iterator(plyf):
buff = plyf.peek(2**16)
while len(buff) != 0:
read_bytes = 0
buff = buff.split(custom_line_sep)
for line in buff[:-1]:
read_bytes += len(line) + len(custom_line_sep)
if line.startswith(b'end_header'):
# Since reader code might (will) break iteration at this point,
# we have to ensure file is read up to here, yield, amd return...
plyf.read(read_bytes)
yield line
return
yield line
plyf.read(read_bytes)
buff = buff[-1] + plyf.peek(2**16)
plyf_header_line_iterator = _plyf_header_line_iterator
valid_header = False
for line in plyf_header_line_iterator(plyf):
tokens = re.split(br'[ \r\n]+', line)
if len(tokens) == 0:
continue
if tokens[0] == b'end_header':
valid_header = True
break
elif tokens[0] == b'comment':
if len(tokens) < 2:
continue
elif tokens[1] == b'TextureFile':
if len(tokens) < 4:
print("Invalid texture line")
else:
texture = tokens[2]
continue
elif tokens[0] == b'obj_info':
continue
elif tokens[0] == b'format':
if len(tokens) < 3:
print("Invalid format line")
return invalid_ply
if tokens[1] not in format_specs:
print("Unknown format", tokens[1])
return invalid_ply
try:
version_test = float(tokens[2])
except Exception as ex:
print("Unknown version", ex)
version_test = None
if version_test != float(version):
print("Unknown version", tokens[2])
return invalid_ply
del version_test
format = tokens[1]
elif tokens[0] == b'element':
if len(tokens) < 3:
print("Invalid element line")
return invalid_ply
obj_spec.specs.append(ElementSpec(tokens[1], int(tokens[2])))
elif tokens[0] == b'property':
if not len(obj_spec.specs):
print("Property without element")
return invalid_ply
if tokens[1] == b'list':
obj_spec.specs[-1].properties.append(PropertySpec(tokens[4], type_specs[tokens[2]], type_specs[tokens[3]]))
else:
obj_spec.specs[-1].properties.append(PropertySpec(tokens[2], None, type_specs[tokens[1]]))
if not valid_header:
print("Invalid header ('end_header' line not found!)")
return invalid_ply
obj = obj_spec.load(format_specs[format], plyf)
return obj_spec, obj, texture
def load_ply_mesh(filepath, ply_name):
import bpy
obj_spec, obj, texture = read(filepath)
# XXX28: use texture
if obj is None:
print("Invalid file")
return
uvindices = colindices = None
colmultiply = None
# TODO import normals
noindices = None
for el in obj_spec.specs:
if el.name == b'vertex':
# Position
vindices_x, vindices_y, vindices_z = el.index(b'x'), el.index(b'y'), el.index(b'z')
# Normals
noindices = (el.index(b'nx'), el.index(b'ny'), el.index(b'nz'))
if -1 in noindices:
noindices = None
# UVs
uvindices = (el.index(b's'), el.index(b't'))
if -1 in uvindices:
uvindices = None
# Colors
# ignore alpha if not present
if el.index(b'alpha') == -1:
colindices = el.index(b'red'), el.index(b'green'), el.index(b'blue')
else:
colindices = el.index(b'red'), el.index(b'green'), el.index(b'blue'), el.index(b'alpha')
if -1 in colindices:
if any(idx > -1 for idx in colindices):
print("Warning: At least one obligatory color channel is missing, ignoring vertex colors.")
colindices = None
else: # if not a float assume uchar
colmultiply = [1.0 if el.properties[i].numeric_type in {'f', 'd'} else (1.0 / 255.0) for i in colindices]
elif el.name == b'face':
findex = el.index(b'vertex_indices')
elif el.name == b'tristrips':
trindex = el.index(b'vertex_indices')
elif el.name == b'edge':
eindex1, eindex2 = el.index(b'vertex1'), el.index(b'vertex2')
mesh_uvs = []
mesh_faces = []
mesh_colors = []
mesh_normals = []
def add_face(vertices, indices, uvindices, colindices):
mesh_faces.append(indices)
if uvindices:
mesh_uvs.extend([(vertices[index][uvindices[0]], vertices[index][uvindices[1]]) for index in indices])
if colindices:
if len(colindices) == 3:
mesh_colors.extend([
(
vertices[index][colindices[0]] * colmultiply[0],
vertices[index][colindices[1]] * colmultiply[1],
vertices[index][colindices[2]] * colmultiply[2],
1.0,
)
for index in indices
])
elif len(colindices) == 4:
mesh_colors.extend([
(
vertices[index][colindices[0]] * colmultiply[0],
vertices[index][colindices[1]] * colmultiply[1],
vertices[index][colindices[2]] * colmultiply[2],
vertices[index][colindices[3]] * colmultiply[3],
)
for index in indices
])
if uvindices or colindices:
# If we have Cols or UVs then we need to check the face order.
add_face_simple = add_face
# EVIL EEKADOODLE - face order annoyance.
def add_face(vertices, indices, uvindices, colindices):
if len(indices) == 4:
if indices[2] == 0 or indices[3] == 0:
indices = indices[2], indices[3], indices[0], indices[1]
elif len(indices) == 3:
if indices[2] == 0:
indices = indices[1], indices[2], indices[0]
add_face_simple(vertices, indices, uvindices, colindices)
verts = obj[b'vertex']
if b'face' in obj:
for f in obj[b'face']:
ind = f[findex]
add_face(verts, ind, uvindices, colindices)
if b'tristrips' in obj:
for t in obj[b'tristrips']:
ind = t[trindex]
len_ind = len(ind)
for j in range(len_ind - 2):
add_face(verts, (ind[j], ind[j + 1], ind[j + 2]), uvindices, colindices)
mesh = bpy.data.meshes.new(name=ply_name)
mesh.vertices.add(len(obj[b'vertex']))
mesh.vertices.foreach_set("co", [a for v in obj[b'vertex'] for a in (v[vindices_x], v[vindices_y], v[vindices_z])])
if noindices:
mesh.vertices.foreach_set("normal", [a for v in obj[b'vertex'] for a in (v[noindices[0]], v[noindices[1]], v[noindices[2]])])
for i, v in enumerate(obj[b'vertex']):
mesh_normals.append( (v[noindices[0]], v[noindices[1]], v[noindices[2]]) )
if b'edge' in obj:
mesh.edges.add(len(obj[b'edge']))
mesh.edges.foreach_set("vertices", [a for e in obj[b'edge'] for a in (e[eindex1], e[eindex2])])
if mesh_faces:
loops_vert_idx = []
faces_loop_start = []
faces_loop_total = []
lidx = 0
for f in mesh_faces:
nbr_vidx = len(f)
loops_vert_idx.extend(f)
faces_loop_start.append(lidx)
faces_loop_total.append(nbr_vidx)
lidx += nbr_vidx
mesh.loops.add(len(loops_vert_idx))
mesh.polygons.add(len(mesh_faces))
mesh.loops.foreach_set("vertex_index", loops_vert_idx)
mesh.polygons.foreach_set("loop_start", faces_loop_start)
mesh.polygons.foreach_set("loop_total", faces_loop_total)
if uvindices:
uv_layer = mesh.uv_layers.new()
for i, uv in enumerate(uv_layer.data):
uv.uv = mesh_uvs[i]
if colindices:
vcol_lay = mesh.vertex_colors.new()
for i, col in enumerate(vcol_lay.data):
col.color[0] = mesh_colors[i][0]
col.color[1] = mesh_colors[i][1]
col.color[2] = mesh_colors[i][2]
col.color[3] = mesh_colors[i][3]
if noindices:
# Create our new object here
for ob in bpy.context.selected_objects:
ob.select_set(False)
obj = bpy.data.objects.new(ply_name, mesh)
bpy.context.collection.objects.link(obj)
bpy.context.view_layer.objects.active = obj
obj.select_set(True)
bpy.context.active_object.data.attributes.new(name="Normal", type='FLOAT_VECTOR', domain='POINT')
normal = bpy.context.active_object.data
print("verts", len(mesh_normals))
for i, n in enumerate(mesh_normals):
normal.attributes['Normal'].data[i].vector[0] = n[0]
normal.attributes['Normal'].data[i].vector[1] = n[1]
normal.attributes['Normal'].data[i].vector[2] = n[2]
mesh.update()
mesh.validate()
if texture and uvindices:
pass
# TODO add support for using texture.
# import os
# import sys
# from bpy_extras.image_utils import load_image
# encoding = sys.getfilesystemencoding()
# encoded_texture = texture.decode(encoding=encoding)
# name = bpy.path.display_name_from_filepath(texture)
# image = load_image(encoded_texture, os.path.dirname(filepath), recursive=True, place_holder=True)
# if image:
# texture = bpy.data.textures.new(name=name, type='IMAGE')
# texture.image = image
# material = bpy.data.materials.new(name=name)
# material.use_shadeless = True
# mtex = material.texture_slots.add()
# mtex.texture = texture
# mtex.texture_coords = 'UV'
# mtex.use_map_color_diffuse = True
# mesh.materials.append(material)
# for face in mesh.uv_textures[0].data:
# face.image = image
return mesh
def load_ply(filepath):
import time
import bpy
t = time.time()
ply_name = bpy.path.display_name_from_filepath(filepath)
mesh = load_ply_mesh(filepath, ply_name)
if not mesh:
return {'CANCELLED'}
# for ob in bpy.context.selected_objects:
# ob.select_set(False)
# obj = bpy.data.objects.new(ply_name, mesh)
# bpy.context.collection.objects.link(obj)
# bpy.context.view_layer.objects.active = obj
# obj.select_set(True)
print("\nSuccessfully imported %r in %.3f sec" % (filepath, time.time() - t))
return {'FINISHED'}
def load(operator, context, filepath=""):
return load_ply(filepath)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment