"""Door robot arm: collision hulls and the FBX export for Unreal (Coder stage 3; stages 'ucx' and 'export').
ucx UCX_ convex hulls per link (k-means clusters of the link's vertices, one hull each, k by the link's size),
parented to the showroom's link objects, in the collection 'DoorArm | collision' (eye off in the view layer,
hidden in renders). They go with the static link meshes (SM_DoorRobotArm_Links.fbx) for a Blueprint build; the
skeletal mesh gets a Physics Asset in Unreal instead.
export into fbx/ (everything from temporary copies in a temporary scene; nothing is saved):
SK_DoorRobotArm_.fbx, ..._LOD1.fbx, ..._LOD2.fbx the arm per version: one skeleton (a root bone and one bone per
moving link, at the link's pivot, the A axes), one skinned mesh (every link rigid on its bone; the elbow cables
and cylinder hoses blended between their two links). LOD1 about 50 %, LOD2 about 20 % of the triangles: the
markings and small parts dropped first (screws, clips), then decimated (collapse).
A_DoorRobotArm___OpenClose.fbx the skeleton with the door animation (frames 1-120, 30 fps) baked
from the rig in the ship-fit scene (the version switched on first: a hidden version is not evaluated).
SM_DoorRobotArm_Links.fbx every link as a static mesh at its pivot with its UCX_ hulls.
SM_DoorArm_RailKit_.fbx, SM_DoorArm_Fixture_.fbx static, ship coordinates (fixtures: origin on
the door's attach point), with simple UCX_ hulls.
SK_DoorArm_Chain_.fbx + A_DoorArm_Chain__OpenClose.fbx the energy chain: one bone per link.
DoorRobotArm_layout.json (next to the .blend): every number a consumer needs.
The project's FBX convention (docking arm, pressure door): forward -Y, up Z, the data turned -90 deg about Z first, so
Unreal (import with "Convert Scene" and "Force Front X Axis") reads ue_cm = (x, -y, z) * 100 of the Blender data.
Statics: global scale 1, unit scale applied. Skeletal: data x100 and the exporter's own x100 cancelled by global
scale 0.01 (centimetres, no scale on any node), as the docking arm's hose.
"""
import math
import os
import json
import time
import numpy as np
import bpy
import bmesh
from mathutils import Vector, Matrix
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
FBX = os.path.join(ROOT, "fbx")
PRE = Matrix.Rotation(-math.pi / 2, 4, 'Z')
SKT = Matrix.Scale(100.0, 4) @ PRE
COLL = "DoorArm | collision"
LOD_RATIO = {1: 0.50, 2: 0.20}
LOD_SMALL = {1: 0.030 * 1.6, 2: 0.065 * 1.6} # parts smaller than this (bounding box diagonal, m) are dropped first (x the scale-up)
def bone_name(link):
return link.replace(" ", "_").lower()
def sm_name(link):
return "SM_DoorArm_" + "".join(w.capitalize() for w in link.replace("J3", "j3").split())
def links(ver=None):
import dra_rigdata as rd
return [l for l in rd.LINKS if ver is None or not l["tool"] or l["tool"] == ver]
def showroom_objects():
sc = bpy.data.scenes["DoorArm | Showroom"]
out = {}
for v in ("A", "B"):
for o in sc.objects:
if o.name.startswith("Showroom | arm %s |" % v) and o.type == 'MESH' and "link" in o:
out.setdefault(o["link"], o)
return out
def coll(sc, name):
c = bpy.data.collections.get(name) or bpy.data.collections.new(name)
if c.name not in [x.name for x in sc.collection.children]:
sc.collection.children.link(c)
return c
def layer_coll(vl, name):
def walk(lc):
if lc.name == name:
return lc
for ch in lc.children:
r = walk(ch)
if r:
return r
return None
return walk(vl.layer_collection)
# ------------------------------------------------------------------ parts of a link mesh
def part_faces(me):
"""{part index: [face indices]}, and the decal part indices"""
lay = me.attributes.get('part')
dec = list(me.get("decals", []))
out = {}
if lay is None:
return {0: list(range(len(me.polygons)))}, set()
pv = np.empty(len(me.polygons), np.int64)
lay.data.foreach_get("value", pv)
for i, k in enumerate(pv.tolist()):
out.setdefault(k, []).append(i)
return out, set(i for i, d in enumerate(dec) if d)
def part_sizes(me):
pf, dec = part_faces(me)
co = np.empty(len(me.vertices) * 3, np.float64)
me.vertices.foreach_get("co", co)
co = co.reshape(-1, 3)
sizes = {}
for k, fs in pf.items():
vi = sorted({v for f in fs for v in me.polygons[f].vertices})
p = co[vi]
sizes[k] = float(np.linalg.norm(p.max(0) - p.min(0)))
return sizes, pf, dec
# ------------------------------------------------------------------ UCX
def kmeans(P, k, iters=25, seed=3):
rng = np.random.default_rng(seed)
C = P[rng.choice(len(P), k, replace=False)].copy()
lab = np.zeros(len(P), np.int64)
for _ in range(iters):
d = ((P[:, None, :] - C[None, :, :]) ** 2).sum(-1)
lab = d.argmin(1)
for j in range(k):
m = lab == j
if m.any():
C[j] = P[m].mean(0)
return lab
def extreme_points(P, n=96):
"""the points of P that lie furthest along n directions spread over the sphere: a hull of them has at most n
vertices and keeps the shape (Unreal simplifies big collision hulls anyway)"""
k = np.arange(n) + 0.5
phi = np.arccos(1 - 2 * k / n)
th = math.pi * (1 + 5 ** 0.5) * k
D = np.stack([np.cos(th) * np.sin(phi), np.sin(th) * np.sin(phi), np.cos(phi)], 1)
idx = np.unique((P @ D.T).argmax(0))
return P[idx]
def convex_err(me):
"""how far (m) the worst vertex lies outside the plane of any face (0 for a convex hull)"""
co = np.empty(len(me.vertices) * 3, np.float64)
me.vertices.foreach_get("co", co)
P = co.reshape(-1, 3)
worst = 0.0
for p in me.polygons:
n = np.array(p.normal, np.float64)
d = float(n @ P[p.vertices[0]])
worst = max(worst, float((P @ n - d).max()))
return worst
def hull_mesh(name, pts):
"""a convex hull of the points, all triangles (fix round, CRITIQUE_2 nice 11: checked convex; when the hull op
leaves a vertex outside a face it is built again from slightly jittered points)"""
pts = extreme_points(np.asarray(pts, np.float64)) if len(pts) > 96 else np.asarray(pts, np.float64)
me = bpy.data.meshes.get(name) or bpy.data.meshes.new(name)
rng = np.random.default_rng(11)
for attempt in range(8):
P = pts if attempt == 0 else pts + rng.normal(0.0, 2e-5 * attempt, pts.shape)
bm = bmesh.new()
vs = [bm.verts.new(Vector(p)) for p in P]
bmesh.ops.convex_hull(bm, input=vs)
loose = [v for v in bm.verts if not v.link_faces]
bmesh.ops.delete(bm, geom=loose, context='VERTS')
bmesh.ops.triangulate(bm, faces=bm.faces[:])
bmesh.ops.recalc_face_normals(bm, faces=bm.faces[:])
me.clear_geometry()
bm.to_mesh(me)
bm.free()
if convex_err(me) < 1e-4:
break
me["convex_err_mm"] = round(convex_err(me) * 1000, 4)
return me
def box_mesh(name, lo, hi):
"""a box hull (8 corners, 12 triangles) from lo to hi"""
bm = bmesh.new()
vs = [bm.verts.new((x, y, z)) for x in (lo[0], hi[0]) for y in (lo[1], hi[1]) for z in (lo[2], hi[2])]
bmesh.ops.convex_hull(bm, input=vs)
bmesh.ops.triangulate(bm, faces=bm.faces[:])
bmesh.ops.recalc_face_normals(bm, faces=bm.faces[:])
me = bpy.data.meshes.get(name) or bpy.data.meshes.new(name)
me.clear_geometry()
bm.to_mesh(me)
bm.free()
return me
def rail_ucx(sc, name, me_src, transform=PRE):
"""the rail kit's collision (fix round, CRITIQUE_2 nice 11): one box per part and per run along the rail (the
flanges, the tray, the rack, each end buffer); parts under 6 cm (bolts, markings) left out"""
pf, dec = part_faces(me_src)
co = np.empty(len(me_src.vertices) * 3, np.float64)
me_src.vertices.foreach_get("co", co)
co = co.reshape(-1, 3)
out = []
for k in sorted(pf):
if k in dec:
continue
vi = np.array(sorted({v for f in pf[k] for v in me_src.polygons[f].vertices}))
P = co[vi]
order = np.argsort(P[:, 0])
xs = P[order, 0]
cuts = np.where(np.diff(xs) > 0.05)[0]
for grp in np.split(order, cuts + 1):
Q = P[grp]
lo, hi = Q.min(0), Q.max(0)
if np.linalg.norm(hi - lo) < 0.06:
continue
nm = "UCX_%s_%02d" % (name, len(out))
me = box_mesh(nm, lo, hi)
me.transform(transform)
ob = bpy.data.objects.new(nm, me)
sc.collection.objects.link(ob)
out.append(ob)
return out
def link_hulls(me, sm):
"""[(name, mesh)] convex hulls of the link's vertex clusters (markings left out)"""
sizes, pf, dec = part_sizes(me)
keep = sorted({v for k, fs in pf.items() if k not in dec for f in fs for v in me.polygons[f].vertices})
co = np.empty(len(me.vertices) * 3, np.float64)
me.vertices.foreach_get("co", co)
P = co.reshape(-1, 3)[keep]
diag = float(np.linalg.norm(P.max(0) - P.min(0)))
k = int(max(1, min(6, round(diag / 0.42))))
if len(P) > 6000:
P = P[np.random.default_rng(7).choice(len(P), 6000, replace=False)]
lab = kmeans(P, k) if k > 1 else np.zeros(len(P), np.int64)
out = []
for j in range(k):
Q = P[lab == j]
if len(Q) < 8:
continue
nm = "UCX_%s_%02d" % (sm, len(out))
out.append((nm, hull_mesh(nm, Q)))
return out
def make_ucx():
t0 = time.time()
sc = bpy.data.scenes["DoorArm | Showroom"]
C = coll(sc, COLL)
for o in list(C.objects):
bpy.data.objects.remove(o)
objs = showroom_objects()
rep = {}
for spec in links():
ob = objs.get(spec["name"])
if ob is None:
continue
sm = sm_name(spec["name"])
hulls = link_hulls(ob.data, sm)
for nm, me in hulls:
h = bpy.data.objects.new(nm, me)
C.objects.link(h)
h.parent = ob
h.matrix_parent_inverse = Matrix.Identity(4)
h.matrix_basis = Matrix.Identity(4)
h.display_type = 'WIRE'
h.hide_render = True
h["ucx_for"] = sm
rep[sm] = [len(me.vertices) for _, me in hulls]
rep[sm + " convex_err_mm"] = max([me.get("convex_err_mm", 0.0) for _, me in hulls] or [0.0])
C["not_exported_directly"] = "UCX hulls: exported with SM_DoorRobotArm_Links.fbx"
for vl in sc.view_layers:
lc = layer_coll(vl, COLL)
if lc is not None:
lc.hide_viewport = True # the eye off: hidden in the viewport too
return dict(hulls=rep, summary=dict(links=len([k for k in rep if not k.endswith("convex_err_mm")]),
hulls=sum(len(v) for k, v in rep.items() if not k.endswith("convex_err_mm")),
worst_convex_err_mm=max([v for k, v in rep.items() if k.endswith("convex_err_mm")] or [0.0])),
seconds=round(time.time() - t0, 1))
# ------------------------------------------------------------------ LODs
def lod_copy(me, level, tri_target):
"""a copy of a link mesh for LOD level: markings and small parts dropped, then decimated toward tri_target"""
out = me.copy()
out.name = "%s_LOD%d" % (me.name, level)
if level == 0:
return out
sizes, pf, dec = part_sizes(me)
drop = [f for k, fs in pf.items() if k in dec or sizes[k] < LOD_SMALL[level] for f in fs]
if drop and len(drop) < len(me.polygons):
bm = bmesh.new()
bm.from_mesh(out)
bm.faces.ensure_lookup_table()
bmesh.ops.delete(bm, geom=[bm.faces[i] for i in drop], context='FACES')
loose = [v for v in bm.verts if not v.link_faces]
bmesh.ops.delete(bm, geom=loose, context='VERTS')
bm.to_mesh(out)
bm.free()
out.calc_loop_triangles()
have = len(out.loop_triangles)
ratio = min(1.0, tri_target / max(1, have))
if ratio < 0.98:
sc = bpy.context.scene
tmp = bpy.data.objects.new("_lod_tmp", out)
sc.collection.objects.link(tmp)
m = tmp.modifiers.new("lod", 'DECIMATE')
m.decimate_type = 'COLLAPSE'
m.ratio = ratio
m.use_collapse_triangulate = False
bpy.context.view_layer.update()
ev = tmp.evaluated_get(bpy.context.evaluated_depsgraph_get())
red = bpy.data.meshes.new_from_object(ev)
bpy.data.objects.remove(tmp)
red.name = out.name
for mat in out.materials:
pass
bpy.data.meshes.remove(out)
out = red
return out
# ------------------------------------------------------------------ the skeleton and the skinned mesh
def new_scene():
sc = bpy.data.scenes.get("_export") or bpy.data.scenes.new("_export")
for o in list(sc.collection.objects):
sc.collection.objects.unlink(o)
wm = bpy.context.window_manager
if wm.windows:
wm.windows[0].scene = sc
sc.render.fps = 30
sc.frame_start, sc.frame_end = 1, 120 # the doors' animation (the default 1-250 would go into the FBX)
return sc
def build_skeleton(sc, ver, name):
import dra_rigdata as rd
arm = bpy.data.armatures.new(name + "_skeleton")
ob = bpy.data.objects.new(name, arm)
sc.collection.objects.link(ob)
vl = bpy.context.view_layer
vl.objects.active = ob
for o in vl.objects:
o.select_set(o is ob)
bpy.ops.object.mode_set(mode='EDIT')
root = arm.edit_bones.new("root")
root.head = (0, 0, 0)
root.tail = (0, 0.1, 0)
for spec in links(ver):
b = arm.edit_bones.new(bone_name(spec["name"]))
j = Vector(spec["joint_s"])
b.head = j
b.tail = j + Vector((0, 0.1, 0))
b.roll = 0.0
b.parent = arm.edit_bones[bone_name(spec["parent"])] if spec["parent"] else root
b.use_connect = False
bpy.ops.object.mode_set(mode='OBJECT')
return ob
def curve_weights(cu_ob, R0inv, kind):
"""a cable / hose curve at rest -> (mesh in the A frame, per-vertex [(bone, weight)])"""
dg = bpy.context.evaluated_depsgraph_get()
ev = cu_ob.evaluated_get(dg)
me = bpy.data.meshes.new_from_object(ev)
me.transform(R0inv @ cu_ob.matrix_world)
n_rings = len(cu_ob.data.splines[0].bezier_points)
# the control points' links: cables 0,1 upper arm; 2 0.65/0.35; 3 0.30/0.70; 4,5 lower arm. hoses: 0,1 the cylinder,
# 2 the lower arm
if kind == "cable":
w = [("upper_arm", 1.0), ("upper_arm", 1.0), ("upper_arm", 0.65), ("upper_arm", 0.30), ("upper_arm", 0.0),
("upper_arm", 0.0)]
other = "lower_arm"
else:
w = [("j3_cylinder", 1.0), ("j3_cylinder", 1.0), ("j3_cylinder", 0.0)]
other = "lower_arm"
# the curve's mesh: rings in order along the spline; ring r is at parameter r / (rings - 1) * (points - 1)
seg = cu_ob.data.resolution_u
n_pts = len(w)
ring_count = (n_pts - 1) * seg + 1
nv = len(me.vertices)
per = max(1, nv // ring_count)
out = []
for vi in range(nv):
r = min(ring_count - 1, vi // per)
t = r / max(1, ring_count - 1) * (n_pts - 1)
a = int(math.floor(t))
b = min(n_pts - 1, a + 1)
f = t - a
wa = w[a][1] * (1 - f) + w[b][1] * f
out.append([(w[0][0], wa), (other, 1.0 - wa)])
return me, out
def skinned_mesh(sc, ver, rig, level, name, budget):
"""every link of version ver (LOD level) joined into one mesh, rigid on its bone; the curves blended"""
import dra_rigdata as rd
objs = showroom_objects()
root = bpy.data.objects["Showroom | arm %s" % ver]
R0inv = root.matrix_world.inverted()
pieces = []
total0 = sum(len_tris(bpy.data.meshes[rd.mesh_name(s["name"])]) for s in links(ver))
for spec in links(ver):
src = bpy.data.meshes[rd.mesh_name(spec["name"])]
target = len_tris(src) * (LOD_RATIO[level] if level else 1.0)
me = lod_copy(src, level, target)
me.transform(Matrix.Translation(Vector(spec["joint_s"])))
pieces.append((me, [(bone_name(spec["name"]), 1.0)]))
curves = [o for o in bpy.data.scenes["DoorArm | Showroom"].objects
if o.name.startswith("Showroom | arm %s |" % ver) and o.type == 'CURVE']
for cu in curves:
kind = "cable" if "cable" in cu.name else "hose"
me, wts = curve_weights(cu, R0inv, kind)
if level:
pass
pieces.append((me, wts))
bm = bmesh.new()
mats = []
dvert = bm.verts.layers.deform.verify()
groups = {}
for me, wts in pieces:
idx_map = []
for m in me.materials:
m = merged_mat(m)
if m is None:
idx_map.append(0)
continue
if m.name not in [x.name for x in mats]:
mats.append(m)
idx_map.append([x.name for x in mats].index(m.name))
base = len(bm.verts)
tmp = bmesh.new()
tmp.from_mesh(me)
uv_names = [l.name for l in me.uv_layers]
for f in tmp.faces:
f.material_index = idx_map[f.material_index] if idx_map else 0
tmp_me = bpy.data.meshes.new("_tmp")
tmp.to_mesh(tmp_me)
tmp.free()
bm.from_mesh(tmp_me) # appends
bpy.data.meshes.remove(tmp_me)
bm.verts.ensure_lookup_table()
for vi in range(base, len(bm.verts)):
local = vi - base
ws = wts[local] if len(wts) == len(me.vertices) else wts[0] if len(wts) == 1 else wts[min(local, len(wts) - 1)]
if isinstance(ws, tuple):
ws = [ws]
for bn, wv in (ws if isinstance(ws, list) else [ws]):
if wv <= 0.0:
continue
gi = groups.setdefault(bn, len(groups))
bm.verts[vi][dvert][gi] = wv
bpy.data.meshes.remove(me)
out = bpy.data.meshes.new(name)
bm.to_mesh(out)
bm.free()
for m in mats:
out.materials.append(m)
out.transform(SKT)
ob = bpy.data.objects.new(name, out)
sc.collection.objects.link(ob)
for bn, gi in sorted(groups.items(), key=lambda kv: kv[1]):
ob.vertex_groups.new(name=bn)
ob.parent = rig
mod = ob.modifiers.new("Armature", 'ARMATURE')
mod.object = rig
out.calc_loop_triangles()
return ob, len(out.loop_triangles)
def settle(rig):
"""after the armature's rest changed (turned and scaled): every pose bone back to its rest, evaluated, so the FBX
nodes' default pose is the bind pose"""
for pb in rig.pose.bones:
pb.matrix_basis = Matrix.Identity(4)
rig.data.pose_position = 'POSE'
bpy.context.view_layer.update()
rig.data.pose_position = 'REST'
bpy.context.view_layer.update()
rig.data.pose_position = 'POSE'
bpy.context.view_layer.update()
def len_tris(me):
me.calc_loop_triangles()
return len(me.loop_triangles)
EXPECT = {}
def expect_of(objs, skeletal, anim):
"""what the file should hold once imported again (metres, the turned frame)"""
k = 0.01 if skeletal else 1.0
pts, tris, mats, bones, meshes, ucx = [], 0, set(), [], 0, 0
for o in objs:
if o.type == 'ARMATURE':
bones += [b.name for b in o.data.bones]
elif o.type == 'MESH':
if o.name.startswith("UCX_"):
ucx += 1
continue
meshes += 1
me = o.data
me.calc_loop_triangles()
tris += len(me.loop_triangles)
mats |= {m.name for m in me.materials if m}
M = o.matrix_world
co = np.empty(len(me.vertices) * 3, np.float64)
me.vertices.foreach_get("co", co)
co = co.reshape(-1, 3)
if len(co):
A = np.array(M)
w = co @ A[:3, :3].T + A[:3, 3]
pts.append(w.min(0))
pts.append(w.max(0))
e = dict(bones=sorted(bones), meshes=meshes, tris=tris, materials=sorted(mats), ucx=ucx, anim=bool(anim))
if pts:
P = np.array(pts) * k
e["bounds_m"] = [round(float(x), 4) for x in list(P.min(0)) + list(P.max(0))]
return e
def fbx_out(path, objs, types, anim=False, skeletal=True):
EXPECT[os.path.basename(path)] = expect_of(objs, skeletal, anim)
vl = bpy.context.view_layer
for o in vl.objects:
o.select_set(False)
for o in objs:
o.select_set(True)
vl.objects.active = objs[0]
kw = dict(filepath=path, use_selection=True, object_types=types, use_mesh_modifiers=False, mesh_smooth_type="FACE",
add_leaf_bones=False, use_armature_deform_only=False, axis_forward="-Y", axis_up="Z",
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)
kw["global_scale"] = 0.01 if skeletal else 1.0
if anim:
kw.update(bake_anim=True, bake_anim_use_all_bones=True, bake_anim_use_nla_strips=False,
bake_anim_use_all_actions=False, bake_anim_force_startend_keying=True, bake_anim_step=1.0,
bake_anim_simplify_factor=0.0)
else:
kw["bake_anim"] = False
bpy.ops.export_scene.fbx(**kw)
# ------------------------------------------------------------------ the door animation, baked from the rig
def bake_action(rig, ver, key):
"""keys on rig's pose bones for frames 1-120 from the ship-fit arm (key, ver): basis = rest^-1 pose, in the A frame,
locations x100 (the data is in centimetres)"""
import dra_rigdata as rd
sf = bpy.data.scenes["DoorArm | Ship fit"]
ctl = bpy.data.objects["DoorArm | CONTROLS"]
wm = bpy.context.window_manager
prev = wm.windows[0].scene if wm.windows else None
if wm.windows:
wm.windows[0].scene = sf
ctl["version"] = 0 if ver == "A" else 1
ctl.update_tag()
prefix = "DoorArm_%s_%s" % (key, ver)
root = bpy.data.objects[prefix]
specs = links(ver)
rest = {"root": Matrix.Identity(4)}
for s in specs:
rest[bone_name(s["name"])] = Matrix.Translation(Vector(s["joint_s"]))
parent = {bone_name(s["name"]): (bone_name(s["parent"]) if s["parent"] else "root") for s in specs}
frames = {}
for f in range(1, 121):
sf.frame_set(f)
dg = bpy.context.evaluated_depsgraph_get()
Rinv = root.evaluated_get(dg).matrix_world.inverted()
pose = {"root": Matrix.Identity(4)}
for s in specs:
o = bpy.data.objects["%s | %s" % (prefix, s["name"])]
pose[bone_name(s["name"])] = Rinv @ o.evaluated_get(dg).matrix_world
frames[f] = pose
ctl["version"] = 0
ctl.update_tag()
sf.frame_set(1)
if wm.windows and prev is not None:
wm.windows[0].scene = prev
act = bpy.data.actions.new("%s_%s_OpenClose" % (ver, key))
rig.animation_data_create()
rig.animation_data.action = act
for pb in rig.pose.bones:
pb.rotation_mode = 'QUATERNION'
for f, pose in frames.items():
for pb in rig.pose.bones:
b = pb.name
if b == "root":
basis = Matrix.Identity(4)
else:
p = parent[b]
basis = (rest[p].inverted() @ rest[b]).inverted() @ (pose[p].inverted() @ pose[b])
loc, rot, _sc = basis.decompose()
pb.location = loc * 100.0
pb.rotation_quaternion = rot
pb.keyframe_insert("location", frame=f)
pb.keyframe_insert("rotation_quaternion", frame=f)
return act, frames
# ------------------------------------------------------------------ material sections
# fix round (CRITIQUE_2 nice 12): the cables share the rubber's section and the lamp the glass's, so the skeletal mesh
# has 7 material sections in Unreal (paint white, orange, gunmetal, steel, rubber, glass, black metal where used)
MERGE_MATS = {"M_DoorArm_Cable": "M_DoorArm_Rubber", "M_DoorArm_Lamp": "M_DoorArm_Glass"}
def merged_mat(m):
if m is None:
return None
t = bpy.data.materials.get(MERGE_MATS.get(m.name, m.name))
return t or m
def merge_slots(me):
"""map the mesh's slots through MERGE_MATS and join slots that end up with the same material"""
if not me.materials:
return
target = [merged_mat(m) for m in me.materials]
names = [m.name if m else None for m in target]
uniq = []
for n in names:
if n not in uniq:
uniq.append(n)
if uniq == [m.name if m else None for m in me.materials]:
return
remap = np.array([uniq.index(n) for n in names], np.int64)
idx = np.empty(len(me.polygons), np.int64)
me.polygons.foreach_get("material_index", idx)
keep = {n: m for n, m in zip(names, target)}
me.materials.clear()
for n in uniq:
me.materials.append(keep[n])
me.polygons.foreach_set("material_index", remap[np.clip(idx, 0, len(remap) - 1)])
# ------------------------------------------------------------------ statics
def static_obj(sc, name, me_src, matrix=None, transform=PRE):
me = me_src.copy()
me.name = name
merge_slots(me)
if matrix is not None:
me.transform(matrix)
me.transform(transform)
ob = bpy.data.objects.new(name, me)
sc.collection.objects.link(ob)
return ob
def box_ucx(sc, name, me_src, transform=PRE):
"""one convex hull of a mesh (for the small statics)"""
co = np.empty(len(me_src.vertices) * 3, np.float64)
me_src.vertices.foreach_get("co", co)
P = co.reshape(-1, 3)
if len(P) > 4000:
P = P[np.random.default_rng(5).choice(len(P), 4000, replace=False)]
me = hull_mesh(name, P)
me.transform(transform)
ob = bpy.data.objects.new(name, me)
sc.collection.objects.link(ob)
return ob
# ------------------------------------------------------------------ the energy chain as a small skeletal mesh
def chain_export(sc, key, rep):
sf = bpy.data.scenes["DoorArm | Ship fit"]
wm = bpy.context.window_manager
lk = sorted([o for o in sf.objects if o.name.startswith("DoorArm_%s | chain link" % key)], key=lambda o: o.name)
if not lk:
return
if wm.windows:
wm.windows[0].scene = sf
frames = {}
for f in range(1, 121):
sf.frame_set(f)
dg = bpy.context.evaluated_depsgraph_get()
frames[f] = [o.evaluated_get(dg).matrix_world.copy() for o in lk]
sf.frame_set(1)
if wm.windows:
wm.windows[0].scene = sc
name = "SK_DoorArm_Chain_%s" % key
arm = bpy.data.armatures.new(name + "_skeleton")
rig = bpy.data.objects.new(name, arm)
sc.collection.objects.link(rig)
vl = bpy.context.view_layer
vl.objects.active = rig
for o in vl.objects:
o.select_set(o is rig)
bpy.ops.object.mode_set(mode='EDIT')
root = arm.edit_bones.new("root")
root.head, root.tail = (0, 0, 0), (0, 0.1, 0)
rest = frames[1]
for i, M in enumerate(rest):
b = arm.edit_bones.new("link_%02d" % i)
h = M.translation
b.head = h
b.tail = h + M.to_3x3() @ Vector((0, 0.05, 0))
b.align_roll(M.to_3x3() @ Vector((0, 0, 1)))
b.parent = root
bpy.ops.object.mode_set(mode='OBJECT')
rest_m = {bb.name: bb.matrix_local.copy() for bb in arm.bones}
chain_me = lk[0].data
bm = bmesh.new()
dvert = bm.verts.layers.deform.verify()
for i, M in enumerate(rest):
tmp = chain_me.copy()
tmp.transform(M)
base = len(bm.verts)
bm.from_mesh(tmp)
bpy.data.meshes.remove(tmp)
bm.verts.ensure_lookup_table()
for vi in range(base, len(bm.verts)):
bm.verts[vi][dvert][i] = 1.0
me = bpy.data.meshes.new(name + "_mesh")
bm.to_mesh(me)
bm.free()
for m in chain_me.materials:
me.materials.append(m)
merge_slots(me)
mo = bpy.data.objects.new(name + "_mesh", me)
sc.collection.objects.link(mo)
for i in range(len(rest)):
mo.vertex_groups.new(name="link_%02d" % i)
act = bpy.data.actions.new("Chain_%s_OpenClose" % key)
rig.animation_data_create()
rig.animation_data.action = act
for pb in rig.pose.bones:
pb.rotation_mode = 'QUATERNION'
for f, Ms in frames.items():
for i, M in enumerate(Ms):
pb = rig.pose.bones["link_%02d" % i]
R = rest_m["link_%02d" % i]
P = M @ (rest[i].inverted() @ R) # the bone moves with its link
basis = R.inverted() @ P
loc, rot, _s = basis.decompose()
pb.location = loc
pb.rotation_quaternion = rot
pb.keyframe_insert("location", frame=f)
pb.keyframe_insert("rotation_quaternion", frame=f)
# into centimetres and turned, as the arm (rest and keys conjugated: locations x100 after the turn)
arm.transform(SKT)
me.transform(SKT)
for fc_owner in [act]:
pass
# the keys were made in metres in the untransformed frame: scale the location curves x100 and turn them with the bones
# (a pure turn about Z of every bone's rest leaves rotation keys unchanged; locations are bone-local)
from bpy_extras.anim_utils import action_get_channelbag_for_slot
cb = action_get_channelbag_for_slot(act, rig.animation_data.action_slot)
for fc in cb.fcurves:
if fc.data_path.endswith(".location"):
for kp in fc.keyframe_points:
kp.co[1] *= 100.0
kp.handle_left[1] *= 100.0
kp.handle_right[1] *= 100.0
mo.parent = rig
mm = mo.modifiers.new("Armature", 'ARMATURE')
mm.object = rig
act_keep = rig.animation_data.action
rig.animation_data.action = None
settle(rig)
fbx_out(os.path.join(FBX, name + ".fbx"), [rig, mo], {"ARMATURE", "MESH"})
rig.animation_data.action = act_keep
bpy.context.view_layer.update()
fbx_out(os.path.join(FBX, "A_DoorArm_Chain_%s_OpenClose.fbx" % key), [rig], {"ARMATURE"}, anim=True)
rep["chain_" + key] = dict(bones=len(arm.bones), tris=len_tris(me))
# ------------------------------------------------------------------ everything
def export(only=None):
t0 = time.time()
import dra_rigdata as rd
os.makedirs(FBX, exist_ok=True)
rep = {"files": [], "lods": {}, "skeleton": {}}
src_scene = bpy.context.window_manager.windows[0].scene if bpy.context.window_manager.windows else None
sc = new_scene()
want = set(only) if only else {"links", "kit", "skeletal", "anim", "chain"}
# 1. the links as statics with their UCX hulls
if "links" in want:
objs = []
ucx_by = {}
for o in bpy.data.objects:
if o.name.startswith("UCX_") and o.get("ucx_for"):
ucx_by.setdefault(o["ucx_for"], []).append(o)
for spec in links():
me = bpy.data.meshes[rd.mesh_name(spec["name"])]
sm = sm_name(spec["name"])
objs.append(static_obj(sc, sm, me))
for h in sorted(ucx_by.get(sm, []), key=lambda o: o.name):
objs.append(static_obj(sc, h.name, h.data))
p = os.path.join(FBX, "SM_DoorRobotArm_Links.fbx")
fbx_out(p, objs, {"MESH"}, skeletal=False)
rep["files"].append(os.path.basename(p))
rep["links_static"] = dict(meshes=len([o for o in objs if not o.name.startswith("UCX_")]),
ucx=len([o for o in objs if o.name.startswith("UCX_")]))
# 2. the kit: rail kits, fixtures (static, ship coordinates; fixtures at the attach point)
if "kit" in want:
for key in ("AFT", "FORE"):
for nm, src in (("SM_DoorArm_RailKit_%s" % key, "DoorArm | rail kit %s" % key),
("SM_DoorArm_RailBeam_%s" % key, "DoorArm | rail beam %s" % key),
("SM_DoorArm_FixtureA_%s" % key, "DoorArm_%s | door fixture A" % key),
("SM_DoorArm_FixtureB_%s" % key, "DoorArm_%s | door fixture B" % key)):
me = bpy.data.meshes.get(src)
if me is None:
continue
o = static_obj(sc, nm, me)
objs2 = [o]
if "Fixture" in nm:
objs2.append(box_ucx(sc, "UCX_%s_00" % nm, me))
else:
objs2 += rail_ucx(sc, nm, me)
p = os.path.join(FBX, nm + ".fbx")
fbx_out(p, objs2, {"MESH"}, skeletal=False)
rep["files"].append(os.path.basename(p))
# 3. the skeletal mesh per version and LOD, 4. the door animations
for ver in ("A", "B"):
if not ({"skeletal", "anim"} & want):
break
name = "SK_DoorRobotArm_%s" % ver
rig = build_skeleton(sc, ver, name + "_rig")
rig_rest = rig
if "skeletal" in want:
tris = {}
for level in (0, 1, 2):
mo, n = skinned_mesh(sc, ver, rig, level, name if level == 0 else "%s_LOD%d" % (name, level), None)
tris["LOD%d" % level] = n
if level == 0 and not rig.get("_scaled"):
rig.data.transform(SKT)
rig["_scaled"] = True
settle(rig)
p = os.path.join(FBX, (name if level == 0 else "%s_LOD%d" % (name, level)) + ".fbx")
fbx_out(p, [rig, mo], {"ARMATURE", "MESH"})
rep["files"].append(os.path.basename(p))
bpy.data.objects.remove(mo)
rep["lods"][ver] = tris
rep["skeleton"][ver] = dict(bones=len(rig.data.bones), names=[b.name for b in rig.data.bones])
if "anim" in want:
if not rig.get("_scaled"):
rig.data.transform(SKT)
rig["_scaled"] = True
settle(rig)
for key in ("AFT", "FORE"):
act, frames = bake_action(rig, ver, key)
p = os.path.join(FBX, "A_DoorRobotArm_%s_%s_OpenClose.fbx" % (ver, key))
fbx_out(p, [rig], {"ARMATURE"}, anim=True)
rep["files"].append(os.path.basename(p))
rig.animation_data.action = None
bpy.data.objects.remove(rig)
# 5. the energy chains
if "chain" in want:
for key in ("AFT", "FORE"):
chain_export(sc, key, rep)
rep["files"] += ["SK_DoorArm_Chain_%s.fbx" % key, "A_DoorArm_Chain_%s_OpenClose.fbx" % key]
# back to the file's own scene; the temporary one goes
wm = bpy.context.window_manager
if wm.windows and src_scene is not None:
wm.windows[0].scene = src_scene
bpy.data.scenes.remove(sc)
old = {}
ep = os.path.join(HERE, "export_report.json")
if os.path.exists(ep):
try:
old = json.load(open(ep)).get("expect", {})
except Exception:
old = {}
old.update(EXPECT)
rep["expect"] = old
rep["layout"] = write_layout(rep)
rep["summary"] = dict(files=len(rep["files"]), lods=rep["lods"], seconds=round(time.time() - t0, 1))
return rep
# ------------------------------------------------------------------ the layout json
def write_layout(rep):
import dra_rigdata as rd
with open(os.path.join(ROOT, "design.json")) as f:
D = json.load(f)
rr = {}
p = os.path.join(HERE, "rig_report.json")
if os.path.exists(p):
rr = json.load(open(p))
K = D["kinematics"]
ue = lambda v: [round(100 * v[0], 2), round(-100 * v[1], 2), round(100 * v[2], 2)]
bones = []
for s in rd.LINKS:
j = list(Vector(s["joint_s"]))
bones.append(dict(link=s["name"], bone=bone_name(s["name"]), parent=bone_name(s["parent"]) if s["parent"] else "root",
version=s["tool"] or "both", pivot_A_m=[round(c, 4) for c in j],
static_mesh=sm_name(s["name"])))
chan = {"carriage": "location.x = c (ship x of the turntable centre)", "turret": "rotation_euler.z = q1",
"lower arm": "rotation_euler.y = -q2", "upper arm": "rotation_euler.y = -q3", "forearm": "rotation_euler.x = q4",
"wrist head": "rotation_euler.y = q5", "flange": "rotation_euler.x = q6", "J3 cylinder": "rotation_euler.y = -cyl",
"J3 piston": "location.x/z = rest + u * (L - L0) along the rod",
"carriage pinion": "rotation_euler.y = (c - c_closed) / %.4f" % (0.040 * rd.S)}
for b in bones:
b["channel"] = chan.get(b["link"], "fixed to its parent (fingers: grip)")
lay = {
"asset": "door robot arm (models/door-robot-arm); version B is installed in the ship by build/dra_ship_install.py "
"(README, 'In the ship')",
"scale_up": {k: D.get("scale_up", {}).get(k) for k in ("date", "S", "rail_y", "lengths_m", "length_changes_pct",
"plate_B", "pad_B", "d_t", "j3_limits", "j1_limits")},
"units": "metres in Blender, Z up; FBX in centimetres; Unreal = (x, -y, z) * 100 of the Blender data (forward -Y, "
"up Z, data turned -90 deg about Z; import with Convert Scene + Force Front X Axis)",
"arm_frame_A": K["frame_A"],
"roots": {key: dict(object="DoorArm_%s_" % key, location_m=[0.0, D["carriage"]["centre_y"],
D["carriage"]["underside_z"]],
rotation_deg=[180.0, 0.0, 0.0], location_ue_cm=ue([0.0, D["carriage"]["centre_y"],
D["carriage"]["underside_z"]]),
note="the arm hangs: its frame A turned 180 deg about x under the rail")
for key in ("AFT", "FORE")},
"bones": bones,
"joints": {k: dict(axis=v["axis"], origin_A_m=v["origin"], limits_deg=v["limits"],
used_deg={a: rr.get("arms", {}).get(a, {}).get("joints_deg", {}).get(k)
for a in ("AFT_A", "FORE_A", "AFT_B", "FORE_B")})
for k, v in K["joints"].items()},
"carriage": dict(travel_x_m=D["carriage"]["travel_ends"], schedule="c = A + B * door_x",
A_B={k: [D["doors"][k]["carriage"]["A"], D["doors"][k]["carriage"]["B"]] for k in ("AFT", "FORE")},
rail_y_m=D["scene"]["rail"]["y"], rail_z_m=D["scene"]["rail"]["z"],
pinion_pitch_radius_m=round(0.040 * rd.S, 4)),
"doors": {k: dict(object=D["doors"][k]["door"], closed_location_m=D["doors"][k]["closed_location"],
attach_local_m=D["doors"][k]["attach_local"], face_normal_local=D["doors"][k]["normal_local"],
tool_axes_world=D["doors"][k]["tool_axes_world"],
tool_axes_world_A=D["doors"][k].get("tool_axes_world_A"),
tool_roll_A_deg=D["doors"][k].get("tool_roll_A_deg"),
wrist_centre_off_face_m={v: D["tools"][v]["d_t"] for v in ("A", "B")},
frames=D["scene"]["door_frames"]) for k in ("AFT", "FORE")},
"ik_closed_form": {"note": "DESIGN.md section 5; the rig's drivers and build/dra_ik.py compute it",
"L2": K["L2"], "L3": K["L3"], "beta_rad": K["beta_rad"], "phi_l0_rad": K["phi_l0_rad"],
"j2_r": K["j2_r"], "j2_h": K["j2_h"], "rod": K["rod"]},
"version_switch": dict(control="DoorArm | CONTROLS", property="version", values={"0": "A gripper (grab limbs)",
"1": "B plate (red sketch)"},
grip="CONTROLS['grip'] 0 open .. 1 closed on the knob (default 1)",
hides="hide_viewport and hide_render of every object of the other version (arms, curves, "
"fixtures); collections 'DoorArm | version A' / 'DoorArm | version B'",
unreal="two skeletal meshes on one skeleton layout: SK_DoorRobotArm_A / _B; show one"),
"fbx": rep.get("files", []),
"lods_tris": rep.get("lods", {}),
"materials": None,
}
sp = os.path.join(HERE, "surf_report.json")
if os.path.exists(sp):
lay["materials"] = json.load(open(sp)).get("table")
out = os.path.join(ROOT, "DoorRobotArm_layout.json")
with open(out, "w") as f:
json.dump(lay, f, indent=1)
return os.path.basename(out)