Basic OBJ import (meshes, materials, vertex colors) (#713)

This commit is contained in:
Cristian Balas
2022-04-22 13:20:16 +03:00
committed by GitHub
parent 8aa7ed3b47
commit 58b67f5e62
13 changed files with 305772 additions and 4942 deletions
@@ -0,0 +1,120 @@
import json
from specklepy.objects import Base
from specklepy.objects.other import RenderMaterial
from specklepy.objects.geometry import Mesh, Point, Line
from specklepy.transports.server import ServerTransport
from specklepy.api.client import SpeckleClient
from specklepy.api.credentials import get_default_account
from specklepy.api import operations
import sys, os
from obj_file import ObjFile
TMP_RESULTS_PATH = '/tmp/import_result.json'
DEFAULT_BRANCH = 'uploads'
def convert_material(obj_mat):
speckle_mat = RenderMaterial()
speckle_mat.name = obj_mat['name']
if 'diffuse' in obj_mat:
argb = [1,] + obj_mat['diffuse']
speckle_mat.diffuse = int.from_bytes([int(val * 255) for val in argb], byteorder="big", signed=True)
if 'dissolved' in obj_mat:
speckle_mat.opacity = obj_mat['dissolved']
return speckle_mat
def import_obj():
file_path, user_id, stream_id, branch_name, commit_message = sys.argv[1:]
print(f'ImportOBJ argv[1:]: {sys.argv[1:]}')
# Parse input
obj = ObjFile(file_path)
print(f'Parsed obj with {len(obj.faces)} faces ({len(obj.vertices) * 3} vertices)')
speckle_root = Base()
speckle_root['@objects'] = []
for objname in obj.objects:
print(f' Converting {objname}...')
speckle_obj = Base()
speckle_obj.name = objname
speckle_obj['@displayValue'] = []
speckle_root['@objects'].append(speckle_obj)
for obj_mesh in obj.objects[objname]:
speckle_vertices = [coord for point in obj_mesh['vertices'] for coord in point]
speckle_faces = []
for obj_face in obj_mesh['faces']:
if len(obj_face) == 3:
speckle_faces.append(0)
elif len(obj_face) == 4:
speckle_faces.append(1)
else:
speckle_faces.append(len(obj_face))
speckle_faces.extend(obj_face)
has_vertex_colors = False
for vc in obj_mesh['vertex_colors']:
if vc is not None:
has_vertex_colors = True
colors = []
if has_vertex_colors:
for vc in obj_mesh['vertex_colors']:
if vc is None:
r, g, b = (1.0, 1.0, 1.0)
else:
r, g, b = vc
argb = (1.0, r, g, b)
color = int.from_bytes([int(val * 255) for val in argb], byteorder="big", signed=True)
colors.append(color)
speckle_mesh = Mesh(
vertices=speckle_vertices,
faces=speckle_faces,
colors=colors,
textureCoordinates=[]
)
obj_material = obj_mesh['material']
if obj_material:
speckle_mesh['renderMaterial'] = convert_material(obj_material)
speckle_obj['@displayValue'].append(speckle_mesh)
# Commit
client = SpeckleClient(host=os.getenv('SPECKLE_SERVER_URL', 'localhost:3000'), use_ssl=False)
client.authenticate(os.environ['USER_TOKEN'])
if not client.branch.get(stream_id, branch_name):
client.branch.create(stream_id, branch_name, 'File upload branch' if branch_name == 'uploads' else '')
transport = ServerTransport(client=client, stream_id=stream_id)
id = operations.send(base=speckle_root, transports=[transport])
commit_id = client.commit.create(
stream_id=stream_id,
object_id=id,
branch_name=(branch_name or DEFAULT_BRANCH),
message=(commit_message or 'OBJ file upload'),
source_application='OBJ'
)
return commit_id
if __name__ == '__main__':
try:
commit_id = import_obj()
if not commit_id:
raise Exception("Can't create commit")
results = {'success': True, 'commitId': commit_id}
except Exception as ex:
print('ERROR: ' + str(ex))
results = {'success': False, 'error': str(ex)}
with open(TMP_RESULTS_PATH, 'w') as f:
json.dump(results, f)
@@ -0,0 +1,76 @@
import os
class MtlFileCollection(object):
def __init__(self, base_dir):
self.base_dir = base_dir
self.logged_unsupported = set()
self.materials = {}
self.crt_mat = None
def ensure_mat(self, directive):
if self.crt_mat is None and f'no_mat_{directive}' not in self.logged_unsupported:
print(f'Directive found outside material definition: {directive}')
self.logged_unsupported.add(f'no_mat_{directive}')
return self.crt_mat is not None
def mtllib(self, fpath):
fpath = os.path.join(self.base_dir, os.path.basename(fpath))
if not os.path.isfile(fpath):
print(f'Missing MTL file: {fpath}')
return
with open(fpath, 'r') as f:
while True:
line = f.readline()
if not line:
break
if not line.strip() or line.startswith('#'):
continue
parts = line.strip().split(' ')
if parts[0] == 'newmtl':
mat_name = ' '.join(parts[1:])
self.crt_mat = {'name': mat_name}
self.materials[mat_name] = self.crt_mat
elif parts[0] == 'Ka':
if self.ensure_mat('Ka'):
self.crt_mat['ambient'] = [float(x) for x in parts[1:]]
elif parts[0] == 'Kd':
if self.ensure_mat('Kd'):
self.crt_mat['diffuse'] = [float(x) for x in parts[1:]]
elif parts[0] == 'Ks':
if self.ensure_mat('Ks'):
self.crt_mat['specular_color'] = [float(x) for x in parts[1:]]
elif parts[0] == 'Ns':
if self.ensure_mat('Ns'):
self.crt_mat['specular_exponent'] = float(parts[1])
elif parts[0] == 'd':
if self.ensure_mat('d'):
self.crt_mat['dissolved'] = float(parts[1])
elif parts[0] == 'Tr':
if self.ensure_mat('Tr'):
self.crt_mat['dissolved'] = 1.0 - float(parts[1])
elif parts[0] == 'Ni':
if self.ensure_mat('Ni'):
self.crt_mat['refraction_index'] = float(parts[1])
elif parts[0] == 'illum':
if self.ensure_mat('illum'):
self.crt_mat['illumination_mode'] = int(parts[1])
elif parts[0] == 'Pr':
if self.ensure_mat('Pr'):
self.crt_mat['roughness'] = float(parts[1])
elif parts[0] == 'Pm':
if self.ensure_mat('Pm'):
self.crt_mat['metallic'] = float(parts[1])
elif parts[0] == 'Ke':
if self.ensure_mat('Ke'):
self.crt_mat['emissive'] = [float(x) for x in parts[1:]]
else:
if parts[0] not in self.logged_unsupported:
print('Unsupported MTL directive: ' + parts[0])
self.logged_unsupported.add(parts[0])
self.crt_mat = None
def get_material(self, name):
return self.materials.get(name, None)
+120
View File
@@ -0,0 +1,120 @@
from mtl_file_collection import MtlFileCollection
import os
class ObjFile(object):
def __init__(self, file_path) -> None:
self.logged_unsupported = set()
self.mtl_files = MtlFileCollection(os.path.dirname(file_path))
self.crt_object = ''
self.crt_mtl = ''
self.vertices = []
self.vertex_colors = []
self.faces = []
# Constructed in the post-process phase
self.objects = {}
with open(file_path, 'r') as f:
while True:
line = f.readline()
if not line:
break
if not line.strip() or line.startswith('#'):
continue
parts = line.strip().split(' ')
if parts[0] == 'v':
self.on_v(parts[1:])
elif parts[0] == 'l':
self.on_l(parts[1:])
elif parts[0] == 'f':
self.on_f(parts[1:])
elif parts[0] == 'mtllib':
self.mtl_files.mtllib(' '.join(parts[1:]))
elif parts[0] == 'usemtl':
self.crt_mtl = ' '.join(parts[1:])
elif parts[0] == 'o':
self.crt_object = parts[1]
else:
if parts[0] not in self.logged_unsupported:
print('Unsupported OBJ directive: ' + parts[0])
self.logged_unsupported.add(parts[0])
self.post_process()
def flatten_vertices(self):
return [coord for point in self.vertices for coord in point]
def on_v(self, params):
r, g, b = None, None, None
w = 1.0
if len(params) == 3:
x, y, z = [float(param) for param in params]
if len(params) == 4:
x, y, z, w = [float(param) for param in params]
if len(params) == 6:
x, y, z, r, g, b = [float(param) for param in params]
self.vertices.append((x, z, y))
if r is None or g is None or b is None:
self.vertex_colors.append(None)
else:
self.vertex_colors.append((r, g, b))
def on_l(self, params):
# TODO: handle lines
pass
def on_f(self, params):
indices = []
for param in params:
# TODO: use texture coordinate index / use vertex normal index?
v_index = int(param.split('/')[0])
# If an index is positive then it refers to the offset in that vertex list, starting at 1.
# If an index is negative then it relatively refers to the end of the vertex list, -1 referring to the last element.
if v_index > 0:
v_index -= 1
indices.append(v_index)
self.faces.append({
'indices': indices,
'object': self.crt_object,
'mtl': self.crt_mtl
})
def post_process(self):
# Step 1: group into object_id/material_id/[faces_with_global_indices]
objects = {}
for face in self.faces:
if face['object'] not in objects:
objects[face['object']] = {}
obj = objects[face['object']]
if face['mtl'] not in obj:
obj[face['mtl']] = []
obj[face['mtl']].append(face['indices'])
# Step 2: construct final structure: object_id / [{material, local_vertices, vertex_colors, faces_with_local_indices}]
for object in objects:
self.objects[object] = []
for mtl in objects[object].keys():
material = self.mtl_files.get_material(mtl)
vertices = []
vertex_colors = []
faces = []
v_global2local_id = {}
for face in objects[object][mtl]:
for global_v in face:
if global_v not in v_global2local_id:
v_global2local_id[global_v] = len(vertices)
vertices.append(self.vertices[global_v])
vertex_colors.append(self.vertex_colors[global_v])
faces.append([v_global2local_id[global_id] for global_id in face])
self.objects[object].append({
'material': material,
'vertices': vertices,
'vertex_colors': vertex_colors,
'faces': faces
})
@@ -0,0 +1,47 @@
# Blender MTL File: 'untitled.blend'
# Material Count: 5
newmtl Black
Ns 96.078431
Ka 0.000000 0.000000 0.000000
Kd 0.066825 0.066825 0.066825
Ks 0.500000 0.500000 0.500000
Ni 1.000000
d 1.000000
illum 2
newmtl Material.001
Ns 96.078431
Ka 0.000000 0.000000 0.000000
Kd 0.640000 0.640000 0.640000
Ks 0.500000 0.500000 0.500000
Ni 1.000000
d 1.000000
illum 2
newmtl Material.002
Ns 96.078431
Ka 0.000000 0.000000 0.000000
Kd 0.640000 0.640000 0.640000
Ks 0.500000 0.500000 0.500000
Ni 1.000000
d 1.000000
illum 2
newmtl Material.003
Ns 96.078431
Ka 0.000000 0.000000 0.000000
Kd 0.640000 0.640000 0.640000
Ks 0.500000 0.500000 0.500000
Ni 1.000000
d 1.000000
illum 2
newmtl White
Ns 96.078431
Ka 0.000000 0.000000 0.000000
Kd 0.640000 0.640000 0.640000
Ks 0.500000 0.500000 0.500000
Ni 1.000000
d 1.000000
illum 2
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1
View File
@@ -28,6 +28,7 @@
"knex": "^1.0.3",
"node-fetch": "^2.6.5",
"pg": "^8.7.1",
"valid-filename": "^3.1.0",
"web-ifc": "^0.0.33"
},
"devDependencies": {
+29 -2
View File
@@ -8,10 +8,12 @@ const fs = require('fs')
const { spawn } = require('child_process')
const ServerAPI = require('../ifc/api')
const objDependencies = require('./objDependencies')
const HEALTHCHECK_FILE_PATH = '/tmp/last_successful_query'
const TMP_FILE_PATH = '/tmp/file_to_import'
const TMP_INPUT_DIR = '/tmp/file_to_import'
const TMP_FILE_PATH = '/tmp/file_to_import/file'
const TMP_RESULTS_PATH = '/tmp/import_result.json'
let shouldExit = false
@@ -55,6 +57,8 @@ async function doTask(task) {
throw new Error('Internal error: DB inconsistent')
}
fs.mkdirSync(TMP_INPUT_DIR, { recursive: true })
const upstreamFileStream = await getFileStream({ fileId: info.fileId })
const diskFileStream = fs.createWriteStream(TMP_FILE_PATH)
@@ -103,6 +107,29 @@ async function doTask(task) {
},
10 * 60 * 1000
)
} else if (info.fileType === 'obj') {
await objDependencies.downloadDependencies({
objFilePath: TMP_FILE_PATH,
streamId: info.streamId,
destinationDir: TMP_INPUT_DIR
})
await runProcessWithTimeout(
'python3',
[
'-u',
'./obj/import_file.py',
TMP_FILE_PATH,
info.userId,
info.streamId,
info.branchName,
`File upload: ${info.fileName}`
],
{
USER_TOKEN: tempUserToken
},
10 * 60 * 1000
)
} else {
throw new Error(`File type ${info.fileType} is not supported`)
}
@@ -140,7 +167,7 @@ async function doTask(task) {
)
}
if (fs.existsSync(TMP_FILE_PATH)) fs.unlinkSync(TMP_FILE_PATH)
fs.rmSync(TMP_INPUT_DIR, { force: true, recursive: true })
if (fs.existsSync(TMP_RESULTS_PATH)) fs.unlinkSync(TMP_RESULTS_PATH)
if (tempUserToken) {
@@ -0,0 +1,18 @@
const knex = require('../knex')
module.exports = {
async getFileInfoByName({ streamId, fileName }) {
const { rows } = await knex.raw(
`
SELECT
id as "fileId", "streamId", "branchName", "userId", "fileName", "fileType"
FROM file_uploads
WHERE "streamId" = ? AND "fileName" = ?
ORDER BY "uploadDate" DESC
LIMIT 1
`,
[streamId, fileName]
)
return rows[0]
}
}
@@ -0,0 +1,64 @@
'use strict'
const events = require('events')
const fs = require('fs')
const readline = require('readline')
const path = require('path')
const { getFileInfoByName } = require('./filesMetadata')
const { getFileStream } = require('./filesApi')
const isValidFilename = require('valid-filename')
async function tryDownloadFile({ fileName, streamId, destinationDir }) {
if (!isValidFilename(fileName)) {
console.log(`Invalid filename reference in OBJ dependencies: ${fileName}`)
return false
}
const fileInfo = await getFileInfoByName({ streamId, fileName })
if (!fileInfo) {
console.log(`OBJ dependency file not found in stream: ${fileName}`)
return false
}
const filePath = path.join(destinationDir, fileName)
const upstreamFileStream = await getFileStream({ fileId: fileInfo.fileId })
const diskFileStream = fs.createWriteStream(filePath)
upstreamFileStream.pipe(diskFileStream)
await new Promise((fulfill) => diskFileStream.on('finish', fulfill))
return true
}
module.exports = {
async getReferencedMtlFiles({ objFilePath }) {
const mtlFiles = []
try {
const rl = readline.createInterface({
input: fs.createReadStream(objFilePath),
crlfDelay: Infinity
})
rl.on('line', (line) => {
if (line.startsWith('mtllib ')) {
const mtlFile = line.slice('mtllib '.length).trim()
mtlFiles.push(mtlFile)
}
})
await events.once(rl, 'close')
} catch (err) {
console.error(`Error getting dependencies for file ${objFilePath}: ${err}`)
}
return mtlFiles
},
async downloadDependencies({ objFilePath, streamId, destinationDir }) {
const dependencies = await this.getReferencedMtlFiles({ objFilePath })
console.log(`Obj file depends on ${dependencies}`)
for (const mtlFile of dependencies) {
await tryDownloadFile({ fileName: mtlFile, streamId, destinationDir })
}
}
}
@@ -25,7 +25,7 @@
<v-card-text>
Speckle can now process files and store them as a commit (snapshot). You can
then access it from the Speckle API, and receive it in other applications.
Current supported formats are: IFC and STL. Thanks to the Open Source
Current supported formats are: IFC, STL and OBJ. Thanks to the Open Source
<a
href="https://ifcjs.github.io/info/docs/Guide/web-ifc/Introduction"
target="_blank"
@@ -79,7 +79,7 @@
<input
id="myid"
type="file"
accept=".ifc,.IFC,.stl,.STL"
accept=".ifc,.IFC,.stl,.STL,.obj,.OBJ,.mtl,.MTL"
style="display: none"
multiple
@change="onFileSelect($event)"
@@ -202,14 +202,17 @@ export default {
this.dragError = null
for (const file of files) {
const extension = file.name.split('.')[1]
if (!extension || !['ifc', 'stl'].includes(extension.toLowerCase())) {
if (
!extension ||
!['ifc', 'stl', 'obj', 'mtl'].includes(extension.toLowerCase())
) {
this.dragError = `The ${extension.toLowerCase()} file extension is not yet supported`
return
}
if (file.size > 50626997) {
if (file.size > 104857600) {
this.dragError =
'Your files are too powerful (for now). Maximum upload size is 50mb!'
'Your files are too powerful (for now). Maximum upload size is 100mb!'
return
}
+6 -2
View File
@@ -13,7 +13,8 @@ const {
const {
checkBucket,
uploadFile,
startUploadFile,
finishUploadFile,
getFileInfo,
getFileStream
} = require('./services/fileuploads')
@@ -125,7 +126,7 @@ exports.init = async (app) => {
if (fileType === 'autodetect')
fileType = filename.split('.').pop().toLowerCase()
const promise = uploadFile({
const promise = startUploadFile({
streamId: req.params.streamId,
branchName: req.params.branchName || '',
userId: req.context.userId,
@@ -143,6 +144,9 @@ exports.init = async (app) => {
const fileId = await promise
fileIds.push(fileId)
}
for (const fileId of fileIds) {
await finishUploadFile({ fileId })
}
res.send(fileIds)
})
@@ -60,7 +60,14 @@ module.exports = {
return fileStream
},
async uploadFile({ streamId, branchName, userId, fileName, fileType, fileStream }) {
async startUploadFile({
streamId,
branchName,
userId,
fileName,
fileType,
fileStream
}) {
// Create ID and db entry
const fileId = crs({ length: 10 })
const dbFile = {
@@ -81,12 +88,19 @@ module.exports = {
await s3.upload({ Bucket, Key, Body: fileStream }).promise()
return fileId
},
async finishUploadFile({ fileId }) {
const s3 = new S3(getS3Config())
const Bucket = process.env.S3_BUCKET
// TODO: error if missing
const Key = `files/${fileId}`
// Get file size and update db entry
const headResponse = await s3.headObject({ Key, Bucket }).promise()
const fileSize = headResponse.ContentLength
await FileUploads().where({ id: fileId }).update({ uploadComplete: true, fileSize })
return fileId
}
}