"""Exports of Tender_Aethel.blend: blender -b Tender_Aethel.blend --python build/ae_export.py * collision: convex hulls for Unreal, UCX__NN, in the collection '05 COLLISION': the hull in slices along its length, the nacelles, upper pods, fin, stern cap and chin of the fittings (bmesh convex hull, checked convex); * fbx/: one FBX per mesh (static meshes at the world origin; moving parts at the origin, unturned, vertices relative to their pivot) plus Aethel_Assembled.fbx (every mesh in place, at rest); * Aethel_layout.json: pivots, axes, slider ranges, sockets, materials and textures, mesh stats, the checks' results. Unreal: import with Force Front X Axis, Convert Scene, scale 1; ue_cm = (-y, -x, z) * 100 (nose +X).""" import os import sys import json import math import bpy import bmesh import numpy as np ROOT = os.path.dirname(bpy.data.filepath) HERE = os.path.join(ROOT, 'build') sys.path.insert(0, HERE) import ae_lib as L FBX = os.path.join(ROOT, 'fbx'); os.makedirs(FBX, exist_ok=True) S = L.S coll = bpy.data.collections['05 COLLISION'] def world_verts(ob): me = ob.data V = np.empty(len(me.vertices) * 3); me.vertices.foreach_get('co', V) V = V.reshape(-1, 3) M = np.array(ob.matrix_world) return V @ M[:3, :3].T + M[:3, 3] def hull_object(name, pts, max_pts=120): pts = np.asarray(pts, float) if len(pts) > max_pts: # farthest-point subsampling keeps the extremes sel = [int(np.argmax(np.linalg.norm(pts - pts.mean(0), axis=1)))] d = np.linalg.norm(pts - pts[sel[0]], axis=1) for _ in range(max_pts - 1): k = int(np.argmax(d)); sel.append(k) d = np.minimum(d, np.linalg.norm(pts - pts[k], axis=1)) pts = pts[sel] bm = bmesh.new() vs = [bm.verts.new(p) for p in pts] bmesh.ops.convex_hull(bm, input=vs) for v in list(bm.verts): # interior points: no faces if not v.link_faces: bm.verts.remove(v) allv = np.array([v.co for v in bm.verts]) for f in bm.faces: # convex: every vertex lies behind each face; flip a face that disagrees f.normal_update() sd = (allv - np.array(f.calc_center_median())) @ np.array(f.normal) if sd.max() > -sd.min(): f.normal_flip() me = bpy.data.meshes.new(name) bm.to_mesh(me); bm.free() ob = bpy.data.objects.new(name, me) coll.objects.link(ob) ob.display_type = 'WIRE' ob.hide_render = True # convexity: every vertex on the inner side of every face plane V = np.array([v.co for v in me.vertices]) worst = 0.0 for p in me.polygons: n = np.array(p.normal); c = np.array(p.center) worst = max(worst, float(((V - c) @ n).max())) return ob, worst def main(): for ob in list(coll.objects): bpy.data.objects.remove(ob) bpy.context.view_layer.update() rep = {'ucx': []} cuts = [L.V_TIP, 262, 300, 350, 410, 470, 540, 600, L.V_END] k = 0 for a, b in zip(cuts[:-1], cuts[1:]): # the analytic surface between the cuts (the skin's vertices lie on it): 5 stations x 28 round vv, pp = np.meshgrid(np.linspace(a, b, 5), np.arange(28) / 28.0, indexing='ij') sel = L.surf_world(vv.ravel(), pp.ravel()) k += 1 ob, w = hull_object(f'UCX_SM_Aethel_Hull_{k:02d}', sel) rep['ucx'].append(dict(name=ob.name, verts=len(ob.data.vertices), tris=sum(len(p.vertices) - 2 for p in ob.data.polygons), convex_within_mm=round(w * 1000, 2))) # fittings: by box regions of the fittings mesh (px boxes: v range, |X| range, y range), per side fit = bpy.data.objects['SM_Aethel_Fittings'] FV = world_verts(fit) fv = FV[:, 1] / S + L.V0; fy = L.Y0 - FV[:, 2] / S; fX = FV[:, 0] / S regions = {'nacelle_front': ((311, 456), (60, 160), (255, 370)), 'nacelle_aft': ((455, 610), (55, 140), (255, 370)), 'pod': ((547, 609), (78, 165), (238, 334)), 'side_lamp': ((323, 381), (85, 115), (236, 268))} k = 0 for side, sg in (('L', 1), ('R', -1)): for nm, ((v0, v1), (x0, x1), (y0, y1)) in regions.items(): sel = FV[(fv >= v0) & (fv <= v1) & (sg * fX >= x0) & (sg * fX <= x1) & (fy >= y0) & (fy <= y1)] if len(sel) < 8: continue k += 1 ob, w = hull_object(f'UCX_SM_Aethel_Fittings_{k:02d}', sel) rep['ucx'].append(dict(name=ob.name, part=f'{nm}_{side}', verts=len(ob.data.vertices), tris=sum(len(p.vertices) - 2 for p in ob.data.polygons), convex_within_mm=round(w * 1000, 2))) # the fin: its planform with its thickness (8 points; its thin fan of vertices makes a poor hull) f = L.FIN (le0, y0_), (te0, _) = f['root']; (le1, y1_), (te1, _) = f['tip'] yr = 141.0; le_r = le0 - (yr - y0_) * (le1 - le0) / (y0_ - y1_) fin_pts = [] for (le, te, yy, th) in ((le_r, te0, yr, f['t_root']), (le1, te1, y1_, f['t_tip'])): for vv in (le, te): for sg in (1, -1): fin_pts.append(L.to_world(sg * th / 2, vv, yy)) k += 1 ob, w = hull_object(f'UCX_SM_Aethel_Fittings_{k:02d}', np.array(fin_pts)) rep['ucx'].append(dict(name=ob.name, part='fin', verts=len(ob.data.vertices), tris=sum(len(p.vertices) - 2 for p in ob.data.polygons), convex_within_mm=round(w * 1000, 2))) for nm, ((v0, v1), (x0, x1), (y0, y1)) in {'stern_cap': ((650, 700), (-70, 70), (154, 220)), 'chin': ((262, 301), (-30, 30), (326, 358))}.items(): sel = FV[(fv >= v0) & (fv <= v1) & (fX >= x0) & (fX <= x1) & (fy >= y0) & (fy <= y1)] k += 1 ob, w = hull_object(f'UCX_SM_Aethel_Fittings_{k:02d}', sel) rep['ucx'].append(dict(name=ob.name, part=nm, verts=len(ob.data.vertices), tris=sum(len(p.vertices) - 2 for p in ob.data.polygons), convex_within_mm=round(w * 1000, 2))) # ---- FBX --------------------------------------------------------------------------------------------------------- def export(objs, path): bpy.ops.object.select_all(action='DESELECT') for o in objs: o.select_set(True) bpy.context.view_layer.objects.active = objs[0] bpy.ops.export_scene.fbx(filepath=path, use_selection=True, object_types={'MESH'}, use_mesh_modifiers=False, mesh_smooth_type='OFF', add_leaf_bones=False, bake_anim=False, axis_forward='-Y', axis_up='Z', global_scale=1.0, apply_unit_scale=True, apply_scale_options='FBX_SCALE_NONE', use_space_transform=True, bake_space_transform=False, path_mode='STRIP', use_metadata=False, use_tspace=True, use_custom_props=False) meshes = [o for o in bpy.data.objects if o.type == 'MESH' and not o.name.startswith('UCX_')] root = bpy.data.objects['Aethel_Root'] moving = [o for o in meshes if o.parent is not None] static = [o for o in meshes if o.parent is None] files = [] for o in static: objs = [o] + [u for u in coll.objects if u.name.startswith(f'UCX_{o.name}_')] p = os.path.join(FBX, f'{o.name}.fbx'); export(objs, p); files.append(os.path.basename(p)) # moving parts: a temporary copy at the origin, unturned (its vertices are already relative to its pivot) pivots = {} from mathutils import Matrix for o in moving: name = o.name o.name = name + '__rig' # the copy carries the real name into the FBX tmp = bpy.data.objects.new(name, o.data) bpy.context.scene.collection.objects.link(tmp) tmp.matrix_world = Matrix.Identity(4) bpy.context.view_layer.update() p = os.path.join(FBX, f'{name}.fbx') export([tmp], p) bpy.data.objects.remove(tmp) o.name = name files.append(os.path.basename(p)) mw = o.matrix_world loc = np.array(mw.translation) pivots[o.name] = dict(parent=o.parent.name, location_m=[round(float(c), 5) for c in loc], ue_cm=[round(float(-loc[1] * 100), 2), round(float(-loc[0] * 100), 2), round(float(loc[2] * 100), 2)], rest_rotation_euler_deg=[round(math.degrees(a), 3) for a in o.rotation_euler], rotation_mode=o.rotation_mode) p = os.path.join(FBX, 'Aethel_Assembled.fbx'); export(meshes, p); files.append(os.path.basename(p)) # ---- layout -------------------------------------------------------------------------------------------------------- drivers = {} for o in bpy.data.objects: if o.animation_data: for fc in o.animation_data.drivers: drivers.setdefault(o.name, []).append(dict(path=fc.data_path, index=fc.array_index, expression=fc.driver.expression)) sliders = {k: dict(default=root[k], min=root.id_properties_ui(k).as_dict().get('min'), max=root.id_properties_ui(k).as_dict().get('max')) for k in root.keys() if not k.startswith('_')} checks = {} for f in ('motion.json',): pth = os.path.join(HERE, 'checks', f) if os.path.exists(pth): checks[f[:-5]] = json.load(open(pth)) stats = {o.name: dict(tris=sum(len(p.vertices) - 2 for p in o.data.polygons), faces=len(o.data.polygons), verts=len(o.data.vertices), materials=[m.name for m in o.data.materials if m]) for o in meshes} allv = np.vstack([world_verts(o) for o in meshes]) lo, hi = allv.min(0), allv.max(0) layout = dict( name='Aethel-9 (the sheet\'s name; working folder tender-aethel)', units='metres; Blender axes: nose -Y, port +X, up +Z', unreal='FBX forward -Y, up Z; import with Force Front X Axis, Convert Scene, scale 1; ue_cm = (-y, -x, z) * 100 (nose +X)', origin='keel line (Z 0) at mid hull (SIDE x 465), on the centre line', scale_m_per_sheet_px=S, bounds_m=dict(min=[round(float(c), 3) for c in lo], max=[round(float(c), 3) for c in hi], size=[round(float(c), 3) for c in (hi - lo)]), sliders=sliders, drivers=drivers, pivots=pivots, joints=dict(rotors='spin about local +Y (the fan axis), fans_rpm', hatch='local X at the lid\'s aft edge, 0..110 deg up', arm_upper='rotation XZY: X = shoulder pitch, Z = yaw', arm_fore='X = elbow', arm_wrist='rotation YXZ: X holds the rest angle (local +Y along the forearm), Y = roll', fingers='X, +-0.45 rad at claw 1'), textures=dict(hull=['textures/T_Aethel_Hull_BaseColor.png (sRGB, UV0)', 'textures/T_Aethel_Hull_ORM.png (linear, UV0: R AO, G rough, B metal)', 'textures/T_Aethel_Hull_Normal.png (linear, UV0, OpenGL: flip green in Unreal)'], tiling='UV1 (UV_Tile, metres): the user\'s Season 2 pack ScratchedMetal (paints), StainedSteel (metals), ' 'models/vessel-interior/source_textures/leather_white (seats)'), collision=rep['ucx'], meshes=stats, files=files, checks=checks) json.dump(layout, open(os.path.join(ROOT, 'Aethel_layout.json'), 'w'), indent=1) bpy.ops.wm.save_mainfile() print('EXPORTED', len(files), 'files;', len(rep['ucx']), 'UCX; worst convexity mm', max(u['convex_within_mm'] for u in rep['ucx'])) main()