#!/usr/bin/env python3 from __future__ import annotations import argparse import json import os import sys try: import bpy from mathutils import Vector except ImportError: print('This script must run inside Blender.') sys.exit(1) def parse_args(): argv = sys.argv argv = argv[argv.index('--') + 1:] if '--' in argv else [] ap = argparse.ArgumentParser(description='Render NPZ pointcloud assets with Blender Workbench') ap.add_argument('--asset_npz', type=str, required=True) ap.add_argument('--out_png', type=str, required=True) ap.add_argument('--out_blend', type=str, default=None) ap.add_argument( '--background', choices=('light', 'dark', 'white'), default=None, help="World background: white=pure #fff (default when meta omits); light=legacy off-white; dark=legacy dark gray", ) return ap.parse_args(argv) def camera_from_coord(coord): import numpy as np mn = coord.min(axis=0) mx = coord.max(axis=0) center = (mn + mx) * 0.5 diag = float(np.linalg.norm(mx - mn)) + 1e-6 off = Vector((diag * 0.9, -diag * 0.85, diag * 0.55)) loc = Vector((float(center[0]), float(center[1]), float(center[2]))) + off direction = Vector((float(center[0]), float(center[1]), float(center[2]))) - loc rot = direction.to_track_quat('-Z', 'Y') mat = rot.to_matrix().to_4x4() mat.translation = loc right = mat.col[0].xyz.copy() up = mat.col[1].xyz.copy() return loc, rot, right, up, diag def build_triangle_mesh(coord, rgb_u8, right, up, diag): import numpy as np n = int(coord.shape[0]) scale = float(diag) * 0.0025 r = np.array([right.x, right.y, right.z], dtype=np.float64) u = np.array([up.x, up.y, up.z], dtype=np.float64) v0 = coord + (0.866 * r + 0.0 * u) * scale v1 = coord + (-0.433 * r + 0.75 * u) * scale v2 = coord + (-0.433 * r - 0.75 * u) * scale verts = np.stack([v0, v1, v2], axis=1).reshape(-1, 3) faces = [(i * 3, i * 3 + 1, i * 3 + 2) for i in range(n)] mesh = bpy.data.meshes.new('StoryPoints') mesh.from_pydata([tuple(v) for v in verts], [], faces) mesh.update() attr = mesh.color_attributes.new(name='Col', type='FLOAT_COLOR', domain='POINT') col = (rgb_u8.astype(np.float32) / 255.0).repeat(3, axis=0) for i in range(len(attr.data)): attr.data[i].color = (float(col[i, 0]), float(col[i, 1]), float(col[i, 2]), 1.0) return mesh def setup_camera(scene, loc, rot): bpy.ops.object.camera_add(location=loc) cam = bpy.context.active_object cam.rotation_mode = 'QUATERNION' cam.rotation_quaternion = rot cam.data.lens = 35 scene.camera = cam def set_background(scene, mode): world = scene.world or bpy.data.worlds.new('World') scene.world = world world.use_nodes = True bg = world.node_tree.nodes.get('Background') if bg is None: bg = world.node_tree.nodes.new(type='ShaderNodeBackground') if mode == 'dark': bg.inputs[0].default_value = (0.02, 0.02, 0.025, 1.0) bg.inputs[1].default_value = 1.0 elif mode == 'light': bg.inputs[0].default_value = (0.95, 0.95, 0.96, 1.0) bg.inputs[1].default_value = 1.0 else: bg.inputs[0].default_value = (1.0, 1.0, 1.0, 1.0) bg.inputs[1].default_value = 1.0 def render_png(scene, path, dark): scene.render.engine = 'BLENDER_WORKBENCH' scene.render.resolution_x = 1920 scene.render.resolution_y = 1080 scene.render.image_settings.file_format = 'PNG' scene.render.image_settings.color_mode = 'RGB' scene.render.film_transparent = False scene.display.shading.light = 'STUDIO' scene.display.shading.color_type = 'VERTEX' scene.display.shading.show_cavity = True scene.display.shading.cavity_type = 'BOTH' scene.display.shading.curvature_ridge_factor = 0.5 scene.display.shading.curvature_valley_factor = 0.5 if dark: scene.display.shading.single_color = (0.05, 0.05, 0.05) scene.render.filepath = path bpy.ops.render.render(write_still=True) def main(): import numpy as np args = parse_args() asset = np.load(args.asset_npz) coord = asset['coord'].astype(np.float32) rgb = asset['rgb'].astype(np.uint8) meta_path = os.path.splitext(args.asset_npz)[0] + '.json' meta = {} if os.path.isfile(meta_path): meta = json.loads(open(meta_path, 'r', encoding='utf-8').read()) bg = args.background or meta.get('background', 'light') if args.background is None: if bg in ('light', 'dark'): bg = 'white' bpy.ops.wm.read_factory_settings(use_empty=True) loc, rot, right, up, diag = camera_from_coord(coord) mesh = build_triangle_mesh(coord, rgb, right, up, diag) obj = bpy.data.objects.new('StoryObject', mesh) bpy.context.collection.objects.link(obj) setup_camera(bpy.context.scene, loc, rot) set_background(bpy.context.scene, bg) out_png = os.path.abspath(args.out_png) os.makedirs(os.path.dirname(out_png) or '.', exist_ok=True) render_png(bpy.context.scene, out_png, bg == 'dark') print('BLENDER_PNG', out_png) if args.out_blend: out_blend = os.path.abspath(args.out_blend) os.makedirs(os.path.dirname(out_blend) or '.', exist_ok=True) bpy.ops.wm.save_as_mainfile(filepath=out_blend) print('BLENDER_BLEND', out_blend) if __name__ == '__main__': main()