"""Builds models/tender-aethel/Tender_Aethel.blend headless from the numpy geometry (ae_hullmesh, ae_parts). blender -b --factory-startup -t 0 --python build/ae_build.py Static parts are merged by role (hull skin, fittings, frames, glass, interior); moving parts keep their own objects with the origin on their pivot: four fan rotors, the hatch lid, and each arm's upper arm, forearm, wrist and two fingers (a parented chain). Aethel_Root carries the sliders; the drivers are simple expressions (the user's Blender runs no scripts in files). """ import os import sys import math import bpy import numpy as np HERE = os.path.dirname(os.path.abspath(__file__)) sys.path.insert(0, HERE) import ae_lib as L import ae_hullmesh as HM import ae_parts as A import ae_textures as TX from ae_geo import Mesh, orient OUT = os.path.join(os.path.dirname(HERE), 'Tender_Aethel.blend') MATS = { # name: (base colour, metallic, roughness, emission colour, emission strength, alpha) -- the concept's palette 'Hull': ((0.034, 0.045, 0.066), 0.35, 0.40, None, 0, 1), 'HullDark': ((0.02, 0.022, 0.026), 0.4, 0.5, None, 0, 1), 'Paint_Hull': ((0.034, 0.045, 0.066), 0.35, 0.40, None, 0, 1), 'Paint_Graphite': ((0.022, 0.026, 0.034), 0.45, 0.42, None, 0, 1), 'Metal_Dark': ((0.030, 0.033, 0.040), 0.85, 0.36, None, 0, 1), 'Metal_Light': ((0.55, 0.57, 0.60), 1.0, 0.25, None, 0, 1), 'Frame': ((0.012, 0.013, 0.016), 0.3, 0.40, None, 0, 1), 'Glass': ((0.30, 0.42, 0.55), 0.0, 0.03, None, 0, 0.18), 'GlassDark': ((0.01, 0.015, 0.025), 0.0, 0.05, (0.05, 0.12, 0.25), 0.4, 1), 'GlowStrip': ((0.30, 0.34, 1.0), 0.0, 0.3, (0.32, 0.40, 1.0), 5.0, 1), 'GlowStripSide': ((0.18, 0.18, 0.9), 0.0, 0.3, (0.22, 0.20, 1.0), 2.5, 1), 'GlowFan': ((0.2, 0.45, 1.0), 0.0, 0.3, (0.20, 0.48, 1.0), 3.0, 1), 'GlowFanHot': ((1.0, 0.40, 0.5), 0.0, 0.3, (1.0, 0.38, 0.50), 4.0, 1), 'GlowFanDim': ((0.12, 0.25, 0.9), 0.0, 0.3, (0.12, 0.26, 1.0), 1.5, 1), 'GlowHub': ((0.7, 0.85, 1.0), 0.0, 0.3, (0.62, 0.82, 1.0), 6.0, 1), 'Lamp': ((0.95, 0.96, 1.0), 0.0, 0.1, (0.95, 0.97, 1.0), 25.0, 1), 'LampAmber': ((1.0, 0.6, 0.2), 0.0, 0.2, (1.0, 0.55, 0.15), 8.0, 1), 'LampSoft': ((0.4, 0.6, 1.0), 0.0, 0.2, (0.30, 0.55, 1.0), 40.0, 1), 'Screen': ((0.02, 0.05, 0.08), 0.0, 0.15, (0.22, 0.58, 1.0), 6.0, 1), 'Lining': ((0.10, 0.13, 0.17), 0.2, 0.55, None, 0, 1), 'Floor': ((0.05, 0.055, 0.06), 0.6, 0.55, None, 0, 1), 'Seat': ((0.03, 0.03, 0.035), 0.0, 0.7, None, 0, 1), 'Console': ((0.06, 0.07, 0.08), 0.4, 0.5, None, 0, 1), 'Logo': ((0.85, 0.87, 0.9), 0.0, 0.5, None, 0, 1), } def material(name): if name in bpy.data.materials: return bpy.data.materials[name] base, met, rough, emc, ems, alpha = MATS[name] m = bpy.data.materials.new('M_Aethel_' + name) m.use_nodes = True nt = m.node_tree p = nt.nodes['Principled BSDF'] p.inputs['Base Color'].default_value = (*base, 1) p.inputs['Metallic'].default_value = met p.inputs['Roughness'].default_value = rough if emc: p.inputs['Emission Color'].default_value = (*emc, 1) p.inputs['Emission Strength'].default_value = ems if alpha < 1: p.inputs['Alpha'].default_value = alpha p.inputs['Transmission Weight'].default_value = 0.0 try: m.surface_render_method = 'BLENDED' except Exception: pass m.name = name return m def make_object(name, mesh, coll, pivot=None, smooth_angle=35.0, all_smooth=False, rest_x=0.0, fix=True): """rest_x: the object's rest rotation about X (radians); vertices are stored in that rotated local frame. fix: orient each connected piece outward (ae_geo.orient) before building.""" if fix: orient(mesh) V, F, UV, M = mesh.arrays() if pivot is not None: V = V - np.asarray(pivot) if rest_x: c, s_ = math.cos(rest_x), math.sin(rest_x) Rx = np.array([[1, 0, 0], [0, c, -s_], [0, s_, c]]) V = V @ Rx # = Rx^T applied to each row vector me = bpy.data.meshes.new(name) me.from_pydata(V.tolist(), [], [list(f) for f in F]) me.update() uvl = me.uv_layers.new(name='UVMap') k = 0 for poly, uv in zip(me.polygons, UV): for li, u in zip(poly.loop_indices, uv): uvl.data[li].uv = u names = sorted(set(M), key=M.index) for n in names: me.materials.append(material(n)) mi = {n: i for i, n in enumerate(names)} me.polygons.foreach_set('material_index', [mi[n] for n in M]) me.polygons.foreach_set('use_smooth', [True] * len(me.polygons)) if not all_smooth: me.set_sharp_from_angle(angle=math.radians(smooth_angle)) me.update() ob = bpy.data.objects.new(name, me) coll.objects.link(ob) if pivot is not None: ob.location = pivot if rest_x: ob.rotation_euler[0] = rest_x return ob def recalc(ob): import bmesh bm = bmesh.new(); bm.from_mesh(ob.data) bmesh.ops.recalc_face_normals(bm, faces=bm.faces) bm.to_mesh(ob.data); bm.free(); ob.data.update() def merged(*meshes): m = Mesh() for x in meshes: m.merge(x) return m def parent_keep(child, parent): bpy.context.view_layer.update() # matrix_world of objects made in this run is stale until updated child.parent = parent child.matrix_parent_inverse = parent.matrix_world.inverted() def driver(ob, path, index, expr, root, props): fc = ob.driver_add(path, index) d = fc.driver d.type = 'SCRIPTED' for p in props: var = d.variables.new(); var.name = p; var.type = 'SINGLE_PROP' var.targets[0].id_type = 'OBJECT'; var.targets[0].id = root var.targets[0].data_path = f'["{p}"]' d.expression = expr return fc def main(): for ob in list(bpy.data.objects): bpy.data.objects.remove(ob) scn = bpy.context.scene scn.unit_settings.system = 'METRIC' root_coll = bpy.data.collections.new('Aethel-9') scn.collection.children.link(root_coll) cols = {} for n in ('01 HULL', '02 FITTINGS', '03 MOVING', '04 INTERIOR', '05 COLLISION'): c = bpy.data.collections.new(n); root_coll.children.link(c); cols[n] = c hull = HM.build() g = hull['grid'] skin = make_object('SM_Aethel_Hull', hull['skin'], cols['01 HULL'], all_smooth=True, fix=False) make_object('SM_Aethel_Frames', hull['frames'], cols['01 HULL'], smooth_angle=30, fix=False) glass = make_object('SM_Aethel_Glass', hull['glass'], cols['01 HULL'], smooth_angle=40) nac, nrot, npiv = A.nacelle() pod, prot, ppiv = A.upper_pod() lamp = A.side_lamp() arm = A.arm() port = merged(nac, pod, lamp, arm['Arm_Mount'][0], A.flank_plate()) fittings = merged(port, port.mirror_x(), A.nose_belt(), A.chin(), A.roof_hood(), A.roof_block(), A.fin(), A.fin_logo(), A.stern_cap(), A.strips(), A.strips().mirror_x()) fit = make_object('SM_Aethel_Fittings', fittings, cols['02 FITTINGS'], smooth_angle=35) root = bpy.data.objects.new('Aethel_Root', None) root.empty_display_type = 'ARROWS'; root.empty_display_size = 1.0 cols['03 MOVING'].objects.link(root) props = {'hatch_open': (0.0, 0.0, 1.0), 'fans_rpm': (60.0, 0.0, 600.0), # arm ranges: the clear region found by build/ae_check.py (the folded-up poses reach into the nose) 'arm_L_yaw': (0.0, -20.0, 17.5), 'arm_L_shoulder': (0.0, -40.0, 70.0), 'arm_L_elbow': (0.0, -50.0, 80.0), 'arm_L_wrist': (0.0, -180.0, 180.0), 'arm_L_claw': (0.0, 0.0, 1.0), # the starboard arm's default pose is the concept's (it hangs lower than the port arm there) 'arm_R_yaw': (0.0, -20.0, 17.5), 'arm_R_shoulder': (-7.4, -40.0, 70.0), 'arm_R_elbow': (62.6, -50.0, 80.0), 'arm_R_wrist': (0.0, -180.0, 180.0), 'arm_R_claw': (0.0, 0.0, 1.0)} for k, (val, lo, hi) in props.items(): root[k] = val ui = root.id_properties_ui(k); ui.update(min=lo, max=hi) # rotors: spin about +Y at fans_rpm (frame-driven) for name, mesh, piv in (('SM_Aethel_Rotor_Lower', nrot, npiv), ('SM_Aethel_Rotor_Upper', prot, ppiv)): for side, mm, pv in (('L', mesh, piv), ('R', mesh.mirror_x(), piv * np.array([-1, 1, 1]))): nm = f'{name}_{side}' ob = make_object(nm, mm, cols['03 MOVING'], pivot=pv, smooth_angle=30) parent_keep(ob, root) sgn = '' if side == 'L' else '-' driver(ob, 'rotation_euler', 1, f'{sgn}frame*fans_rpm*0.0034907', root, ['fans_rpm']) lid, hpiv = A.hatch_lid() hob = make_object('SM_Aethel_Hatch', lid, cols['03 MOVING'], pivot=hpiv, smooth_angle=35) parent_keep(hob, root) driver(hob, 'rotation_euler', 0, '-hatch_open*1.92', root, ['hatch_open']) # arms for side in ('L', 'R'): mir = side == 'R' prev = root chain = [('Arm_Upper', 'SM_Aethel_Arm_Upper'), ('Arm_Fore', 'SM_Aethel_Arm_Fore'), ('Arm_Wrist', 'SM_Aethel_Arm_Wrist')] obs = {} d = np.array(L.ARM['wrist']) - np.array(L.ARM['elbow']) fore_ang = math.atan2(-d[1], d[0]) # local +Y (world (0, cos, sin)) along the forearm, to the claw for key, nm in chain: mm, pv = arm[key] if mir: mm = mm.mirror_x(); pv = pv * np.array([-1, 1, 1]) ob = make_object(f'{nm}_{side}', mm, cols['03 MOVING'], pivot=pv, smooth_angle=35, rest_x=fore_ang if key == 'Arm_Wrist' else 0.0) parent_keep(ob, prev); prev = ob; obs[key] = ob for key in ('Arm_Finger_A', 'Arm_Finger_B'): mm, pv = arm[key] if mir: mm = mm.mirror_x(); pv = pv * np.array([-1, 1, 1]) ob = make_object(f'SM_Aethel_{key[4:]}_{side}', mm, cols['03 MOVING'], pivot=pv, smooth_angle=35) parent_keep(ob, obs['Arm_Wrist']); obs[key] = ob s = side up, fo, wr = obs['Arm_Upper'], obs['Arm_Fore'], obs['Arm_Wrist'] up.rotation_mode = 'XZY' # pitch about the local X, then yaw about Z driver(up, 'rotation_euler', 2, f'radians(arm_{s}_yaw)' if not mir else f'-radians(arm_{s}_yaw)', root, [f'arm_{s}_yaw']) driver(up, 'rotation_euler', 0, f'radians(arm_{s}_shoulder)', root, [f'arm_{s}_shoulder']) driver(fo, 'rotation_euler', 0, f'radians(arm_{s}_elbow)', root, [f'arm_{s}_elbow']) # wrist roll about the forearm axis: rest rotation about X puts local +Y along the forearm; 'YXZ' rolls first wr.rotation_mode = 'YXZ' wr.rotation_euler[0] = fore_ang driver(wr, 'rotation_euler', 1, f'radians(arm_{s}_wrist)', root, [f'arm_{s}_wrist']) for key, sg in (('Arm_Finger_A', 1), ('Arm_Finger_B', -1)): driver(obs[key], 'rotation_euler', 0, f'{-sg}*arm_{s}_claw*0.45', root, [f'arm_{s}_claw']) # interior: lining, cabin furniture inter = merged(hull['lining'], orient(A.cabin(g), ref_point=(0.0, 0.0, -2.0))) # flat pieces face up into the cabin iob = make_object('SM_Aethel_Interior', inter, cols['04 INTERIOR'], smooth_angle=35, fix=False) # hull texture set (painted from the sheet) TX.apply_hull_material(skin, os.path.join(os.path.dirname(HERE), 'textures')) scn.frame_start, scn.frame_end = 1, 240 bpy.ops.wm.save_as_mainfile(filepath=OUT, compress=True) stats = {o.name: len(o.data.polygons) for o in bpy.data.objects if o.type == 'MESH'} print('SAVED', OUT, sum(stats.values()), 'faces') for k, v in sorted(stats.items()): print(' ', k, v) main()