"""Three-armed Tender docking carousel for The Descent. Builds the model from nothing, saves the .blend, writes the layout JSON and exports the FBX files. blender.exe -b --python-exit-code 1 --python build_dockarm.py Frame (Blender, metres, Z up): origin = the carousel hub axis at carrier z 0, i.e. Unreal's `ArmsRoot` (DescentTune_Tender.h: HubX/HubY/HubZ). Unreal = (bx, -by, bz) * 100 cm. The bay ceiling is at z 1.20. Numbers that come from the Unreal blockout (CarrierCarousel.cpp, DESIGN_UE.md 3.5): three arms 120 degrees apart, cradle radius 6.0, arm 1 over the pool at hub angle 0 (Unreal +Y = Blender -Y), Tender origin z -1.0 stowed / -6.3 at BOARD, clamp on the hatch collar. Numbers measured on the Kazah (models/Kazah-stern-cavity-fixedv4.blend): the hatch collar is a cylinder of radius 0.53-0.55, flange top at Tender z 1.38, centred at Tender (x 0.09, y 0); two posts on the flange reach 1.545. """ import bpy, bmesh, math, os, sys, json from mathutils import Vector, Matrix, Euler from mathutils.bvhtree import BVHTree HERE = os.path.dirname(os.path.abspath(__file__)) exec(open(os.path.join(HERE, "lib2.py")).read()) OUT = os.path.dirname(HERE) TEX = os.path.join(OUT, "textures") bpy.ops.wm.read_factory_settings(use_empty=True) # ------------------------------------------------------------------ constants CEIL_Z = 1.20 CRADLE_R, COLLAR_X = 6.0, 0.09 # blockout cradle radius; collar centre ahead of the Tender origin R_TIP = math.hypot(CRADLE_R, COLLAR_X) # the booms aim at the collar centre, so the head is symmetric DELTA = math.atan2(COLLAR_X, CRADLE_R) # 0.859 degrees ZS = 0.94 # shoulder axis height LINK = 3.0 # both links of the knee hoist STOWED_Z, BOARD_Z = -1.0, -6.3 # Tender origin WRIST_ABOVE_TENDER = ZS - STOWED_Z # 1.94: links lie flat when the Tender is at STOWED CLAW_AZ = (45.0, 135.0, 225.0, 315.0) CLAW_HINGE = (0.655, -0.43) # radius from the head centre, z below the wrist axis CLAW_OPEN_DEG = 25.0 def arm_yaw(k): # Blender yaw (ccw from +X) of boom k return math.radians(-90.0 - 120.0 * k) + DELTA RZ = lambda a: Matrix.Rotation(a, 4, 'Z') T = lambda *p: Matrix.Translation(p) YPOS = Matrix.Rotation(-math.pi / 2, 4, 'X') # lathe axis (local +Z) -> +Y YNEG = Matrix.Rotation(math.pi / 2, 4, 'X') # lathe axis (local +Z) -> -Y exec(open(os.path.join(HERE, "kinematics.py")).read()) # XJ, ZJ, LJ, POOL_M, Y_MAX, arm_pose(), the driver chain (the boom joint, 2026-10-16) # ------------------------------------------------------------------ materials (named here; surfaces.py builds them once the maps are baked) PAINT = MAT("M_DockArm_Paint", (0.72, 0.73, 0.74), metal=0.1, rough=0.42) GRAPH = MAT("M_DockArm_Graphite", (0.085, 0.09, 0.10), metal=0.6, rough=0.45) STEEL = MAT("M_DockArm_Steel", (0.55, 0.56, 0.58), metal=1.0, rough=0.28) RUBBER = MAT("M_DockArm_Rubber", (0.02, 0.02, 0.022), metal=0.0, rough=0.85) SAFETY = MAT("M_DockArm_SafetyOrange", (0.95, 0.36, 0.04), metal=0.1, rough=0.45) CYAN = MAT("M_DockArm_GlowCyan", (0.02, 0.06, 0.07), rough=0.3, emit=(0.10, 0.85, 1.0), emit_str=6.0) AMBER = MAT("M_DockArm_GlowAmber", (0.07, 0.04, 0.0), rough=0.3, emit=(1.0, 0.55, 0.08), emit_str=5.0) GLASS = MAT("M_DockArm_LensGlass", (0.01, 0.012, 0.015), rough=0.08) # sensor lenses under the hub and under the head BAND = MAT("M_DockArm_HoseBand", (0.60, 0.61, 0.63), metal=1.0, rough=0.3) # clamp bands of the ore hose (the hose has its own UVs, no atlas) def decal_material(): m = bpy.data.materials.new("M_DockArm_Decals") m.use_nodes = True nt = m.node_tree b = nt.nodes.get("Principled BSDF") tex = nt.nodes.new('ShaderNodeTexImage') img = bpy.data.images.load(os.path.join(TEX, "T_DockArm_Decals_BaseColor.png")) img.alpha_mode = 'STRAIGHT' tex.image = img tex.location = (-400, 200) nt.links.new(tex.outputs['Color'], b.inputs['Base Color']) nt.links.new(tex.outputs['Alpha'], b.inputs['Alpha']) b.inputs['Roughness'].default_value = 0.5 try: m.surface_render_method = 'DITHERED' except Exception: pass m.diffuse_color = (0.72, 0.73, 0.74, 0.0) # invisible in Solid shading, where the atlas is not shown return m DECAL = decal_material() ATLAS = 1024.0 RECT = {"n01": (0, 0, 320, 224), "n02": (320, 0, 320, 224), "n03": (640, 0, 320, 224), "chev": (64, 256, 128, 128), "warn": (288, 256, 160, 160), "turn": (480, 256, 160, 160), "label": (0, 448, 640, 160)} # ------------------------------------------------------------------ builders (quads, no booleans) def lathe(mb, prof, mats, segs=32, xf=None): """Revolve a profile [(r, a)] about local +Z (a = height). mats: one material or one per profile segment. The profile starts and ends on the axis, so the shell is closed; the two ends are triangle fans. Writes the UV_Tile layer in metres: flat bands as seen along the axis, walls unrolled (arc length, profile length).""" bm = bmesh.new() uvl = bm.loops.layers.uv.new("UV_Tile") cols = [] for (r, a) in prof: if r < 1e-9: cols.append([bm.verts.new((0, 0, a))]) else: cols.append([bm.verts.new((r * math.cos(2 * math.pi * k / segs), r * math.sin(2 * math.pi * k / segs), a)) for k in range(segs)]) ml = list(mats) if isinstance(mats, (list, tuple)) else [mats] * (len(prof) - 1) assert len(ml) == len(prof) - 1, (len(ml), len(prof)) sp = [0.0] for i in range(len(prof) - 1): sp.append(sp[-1] + math.hypot(prof[i + 1][0] - prof[i][0], prof[i + 1][1] - prof[i][1])) for i in range(len(prof) - 1): A, B = cols[i], cols[i + 1] mi = mb.mi(ml[i]) flat = abs(prof[i + 1][0] - prof[i][0]) >= abs(prof[i + 1][1] - prof[i][1]) rm = (prof[i][0] + prof[i + 1][0]) / 2 for k in range(segs): k2 = (k + 1) % segs if len(A) == 1 and len(B) == 1: continue if len(A) == 1: f = bm.faces.new((A[0], B[k], B[k2])) elif len(B) == 1: f = bm.faces.new((A[k], B[0], A[k2])) else: f = bm.faces.new((A[k], B[k], B[k2], A[k2])) f.material_index = mi if flat: for l in f.loops: l[uvl].uv = (l.vert.co.x, l.vert.co.y) else: u_a, u_b = rm * 2 * math.pi * k / segs, rm * 2 * math.pi * (k + 1) / segs uvs = {} for col, s in ((A, sp[i]), (B, sp[i + 1])): if len(col) == 1: uvs[col[0]] = ((u_a + u_b) / 2, s) else: uvs[col[k]] = (u_a, s); uvs[col[k2]] = (u_b, s) for l in f.loops: l[uvl].uv = uvs[l.vert] bm.normal_update() bmesh.ops.recalc_face_normals(bm, faces=bm.faces[:]) mb.merge(bm, None, xf) SIDE_K = {'B': 0, 'R': 2, 'T': 4, 'L': 6} # bottom, +y, top, -y def loft(mb, secs, mat, pockets=(), pmat=None, inset=0.035, depth=0.018, xf=None): """Beam along +X through chamfered-rectangle sections [(x, half width, z0, z1, chamfer)]: 8 quads per bay, 3 quads per end. pockets [(bay index, side)] are recessed panels (inset + sunk floor) on that bay's face.""" bm = bmesh.new() rings = [] for (x, w, z0, z1, c) in secs: pts = [(-w + c, z0), (w - c, z0), (w, z0 + c), (w, z1 - c), (w - c, z1), (-w + c, z1), (-w, z1 - c), (-w, z0 + c)] rings.append([bm.verts.new((x, y, z)) for y, z in pts]) fmap = {} for i in range(len(rings) - 1): for k in range(8): k2 = (k + 1) % 8 fmap[(i, k)] = bm.faces.new((rings[i][k], rings[i + 1][k], rings[i + 1][k2], rings[i][k2])) for r in (rings[0], rings[-1]): bm.faces.new((r[7], r[0], r[1], r[2])); bm.faces.new((r[7], r[2], r[3], r[6])); bm.faces.new((r[6], r[3], r[4], r[5])) bm.normal_update() bmesh.ops.recalc_face_normals(bm, faces=bm.faces[:]) mi = mb.mi(mat) for f in bm.faces: f.material_index = mi if pockets: pmi = mb.mi(pmat) for (i, s) in pockets: for nf in pocket(bm, fmap[(i, SIDE_K[s])], inset, depth): nf.material_index = pmi mb.merge(bm, None, xf) def ycyl(mb, r, y0, y1, x, z, mat, segs=16, ch=0.0): """plain closed cylinder along Y between y0 and y1 (y0 < y1).""" prof = [(0, y0), (r - ch, y0), (r, y0 + ch), (r, y1 - ch), (r - ch, y1), (0, y1)] if ch > 0 else [(0, y0), (r, y0), (r, y1), (0, y1)] lathe(mb, prof, mat, segs, T(x, 0, z) @ YPOS) def strip_unused_mats(o): me = o.data idx = [p.material_index for p in me.polygons] used = sorted(set(idx)) remap = {old: new for new, old in enumerate(used)} keep = [me.materials[i] for i in used] me.materials.clear() # this also resets every face to slot 0, so the indices are written after it for m in keep: me.materials.append(m) me.polygons.foreach_set("material_index", [remap[i] for i in idx]) me.update() def signed_volume(o): bm = bmesh.new(); bm.from_mesh(o.data) bmesh.ops.triangulate(bm, faces=bm.faces[:]) v = sum(f.verts[0].co.dot(f.verts[1].co.cross(f.verts[2].co)) for f in bm.faces) / 6.0 bm.free() return v def add_decals(o, quads): """quads [(key, centre, outward normal, up, w, h)] floated 1.5 mm off the surface; unmirrored for a viewer outside.""" me = o.data if DECAL.name not in [m.name for m in me.materials]: me.materials.append(DECAL) mi = [m.name for m in me.materials].index(DECAL.name) bm = bmesh.new(); bm.from_mesh(me) uvl, tll = bm.loops.layers.uv["UVMap"], bm.loops.layers.uv["UV_Tile"] for (key, c, n, up, w, h) in quads: n = Vector(n).normalized(); up = Vector(up).normalized() r = up.cross(n) c = Vector(c) + n * 0.0015 ps = (c - r * w / 2 - up * h / 2, c + r * w / 2 - up * h / 2, c + r * w / 2 + up * h / 2, c - r * w / 2 + up * h / 2) f = bm.faces.new([bm.verts.new(p) for p in ps]) f.material_index = mi x, y, ww, hh = RECT[key] m = 0 if key == "chev" else 4 u0, u1 = (x + m) / ATLAS, (x + ww - m) / ATLAS v0, v1 = 1 - (y + hh - m) / ATLAS, 1 - (y + m) / ATLAS for l, uv in zip(f.loops, ((u0, v0), (u1, v0), (u1, v1), (u0, v1))): l[uvl].uv = uv l[tll].uv = tile_uv(l.vert.co, n) bm.to_mesh(me); bm.free() # ------------------------------------------------------------------ ore hose: path, brackets, mesh, material (hose.py) exec(open(os.path.join(HERE, "hose.py")).read()) # ------------------------------------------------------------------ surfaces: UV layers, baked maps, materials (surfaces.py) exec(open(os.path.join(HERE, "surfaces.py")).read()) HOSE_FILM = bpy.data.materials.new("M_DockArm_OreHose") dress_duct(HOSE_FILM, *make_duct_textures(TEX)) # clear ducting with a spiral rib dress(BAND, HOSE_BAND, uv_tile="UVMap") # the bands' own coordinates are metres # ------------------------------------------------------------------ collections, root C_ASM = get_coll("DockArm_Assembly") C_PARTS = get_coll("DockArm_Parts") # the three shared meshes at their own origin (source of the instances) C_COL = get_coll("DockArm_Collision") root = bpy.data.objects.new("DockArm_Root", None) root.empty_display_size = 0.5 C_ASM.objects.link(root) # ================================================================== 1. ceiling mount (fixed to the carrier) mb = MB("SM_DockArm_Mount", []) lathe(mb, [(0, 1.20), (1.34, 1.20), (1.35, 1.19), (1.35, 1.165), (1.34, 1.155), (1.10, 1.155), (1.10, 1.08), (1.10, 1.05), (1.085, 1.035), (0, 1.035)], [PAINT, PAINT, PAINT, PAINT, PAINT, GRAPH, GRAPH, GRAPH, GRAPH], segs=48) for i in range(18): # flange bolts a = 2 * math.pi * (i + 0.5) / 18 lathe(mb, [(0, 1.125), (0.03, 1.125), (0.03, 1.155), (0, 1.155)], STEEL, 6, T(1.23 * math.cos(a), 1.23 * math.sin(a), 0) @ RZ(a)) # fan caps, turned with its place: the mount stays exactly 6-fold symmetric (UV sectors) for i in range(6): # stiffening ribs between flange and stator a = 2 * math.pi * i / 6 mb.box((0.20, 0.04, 0.07), tuple(RZ(a) @ Vector((1.195, 0, 1.12))), GRAPH, rot=(0, 0, a), bevel=0.006) mount = mb.build(C_ASM, parent=root, recalc=True) # servo can: a lathe profile (r, a) along its axis, used by the elbows and the boom joints MOTOR = [(0, 0.066), (0.17, 0.066), (0.17, 0.087), (0.152, 0.092), (0.152, 0.102), (0.17, 0.107), (0.17, 0.122), (0.152, 0.127), (0.152, 0.137), (0.17, 0.142), (0.17, 0.175), (0.155, 0.195), (0.125, 0.195), (0.12, 0.19), (0.095, 0.19), (0.09, 0.195), (0.085, 0.202), (0, 0.202)] MOTOR_M = [GRAPH] * 13 + [CYAN, GRAPH, STEEL, STEEL] # ================================================================== 2. turret: rotor drum, sensor pod, three root stubs tb = MB("SM_DockArm_Turret", []) lathe(tb, [(0, 1.02), (1.06, 1.02), (1.08, 1.00), (1.08, 0.895), (1.06, 0.885), (1.06, 0.855), (1.08, 0.845), (1.08, 0.72), (1.04, 0.68), (0.80, 0.64), (0.80, 0.58), (0.74, 0.52), (0.50, 0.44), (0.46, 0.40), (0.30, 0.40), (0.28, 0.385), (0.16, 0.376), (0, 0.372)], [PAINT, PAINT, PAINT, GRAPH, GRAPH, GRAPH, PAINT, PAINT, GRAPH, GRAPH, GRAPH, GRAPH, GRAPH, STEEL, STEEL, GLASS, GLASS], segs=48) # the groove is no longer a light ring # the root stub of each boom is part of the turret: the wide root, the clevis that holds the joint pin, the pitch servos on # its cheeks, and under it the pack with the arm number. The boom proper (SM_DockArm_Boom, a part of its own) hangs on the # pin at (XJ, 0, ZJ) and pitches down to let the Tender into the moon pool (kinematics.py). bb = MB("_stub", []) bb.mats = tb.mats # shared slot list, so indices agree when merged STUB = [(0.85, 0.46, 0.66, 1.00, 0.05), (1.22, 0.44, 0.64, 1.04, 0.05)] # ends 2 cm short of the eye's swing (r 0.26 about the pin) loft(bb, STUB, PAINT) CHEEK_R, CHEEK_Y0, CHEEK_Y1 = 0.30, 0.31, 0.40 # the clevis plates: 9 cm thick, 2 cm clear of the eye, rounded about the pin for s in (1, -1): bb.box((XJ - 1.20, CHEEK_Y1 - CHEEK_Y0, 2 * CHEEK_R), ((1.20 + XJ) / 2, s * (CHEEK_Y0 + CHEEK_Y1) / 2, ZJ), PAINT) y0, y1 = sorted((s * CHEEK_Y0, s * CHEEK_Y1)) lathe(bb, [(0, y0), (CHEEK_R, y0), (CHEEK_R, y1), (0, y1)], PAINT, 32, T(XJ, 0, ZJ) @ YPOS) lathe(bb, MOTOR, MOTOR_M, 24, T(XJ, s * (CHEEK_Y1 - 0.066), ZJ) @ (YPOS if s > 0 else YNEG)) # pitch servo on the cheek, coaxial with the pin bb.box((0.50, 0.44, 0.14), (1.00, 0, 0.57), PAINT, bevel=0.015) # servo pack under the root (its underside carries the arm number) for i in range(4): for s in (1, -1): bb.box((0.03, 0.03, 0.10), (0.84 + 0.11 * i, s * 0.232, 0.57), GRAPH, bevel=0.004) # cooling fins bb.box((0.012, 0.22, 0.022), (1.253, 0, 0.57), CYAN) # status bar on the pack hose_turret_parts(bb) # the ore hose's port on the hub pod for k in range(3): tb.merge(bb.bm.copy(), None, RZ(arm_yaw(k))) for d in (-15.0, 0.0, 15.0): # fins on the drum underside, between the booms a = arm_yaw(k) + math.radians(60.0 + d) tb.box((0.22, 0.03, 0.09), tuple(RZ(a) @ Vector((0.92, 0, 0.645))), GRAPH, rot=(0, 0, a), bevel=0.005) bb.bm.free() turret = tb.build(C_ASM, parent=root, recalc=True) # ================================================================== 2b. boom (pivot: the joint pin at XJ, ZJ; lies along +X when level) # Built in the boom frame like the stub, then moved so that its origin is the pin: the eye between the clevis cheeks, the # beam, the shoulder axle housing at the tip, a junction box with the two keel hoses, and the ore hose's clips and saddles. bm_ = MB("SM_DockArm_Boom", []) c = 0.04 BOOM = [(1.60, 0.29, 0.62, 1.08, 0.04), (1.85, 0.29, 0.62, 1.09, 0.04), (2.10, 0.26, 0.62, 1.11, 0.045), (2.55, 0.17, 0.62, 1.12, c), (2.75, 0.17, 0.62, 1.12, c), (3.70, 0.17, 0.62, 1.115, c), (3.78, 0.17, 0.62, 1.115, c), (4.73, 0.17, 0.62, 1.11, c), (4.81, 0.17, 0.62, 1.11, c), (5.25, 0.17, 0.77, 1.105, c), (5.33, 0.17, 0.77, 1.105, c), (5.95, 0.17, 0.78, 1.10, c)] loft(bm_, BOOM, PAINT, pockets=[(4, 'R'), (4, 'L'), (4, 'B'), (6, 'R'), (6, 'L'), (6, 'B'), (8, 'R'), (8, 'L')], pmat=GRAPH) EYE_R, EYE_HW = 0.26, 0.29 lathe(bm_, [(0, -EYE_HW), (EYE_R - 0.02, -EYE_HW), (EYE_R, -EYE_HW + 0.02), (EYE_R, EYE_HW - 0.02), (EYE_R - 0.02, EYE_HW), (0, EYE_HW)], [GRAPH, GRAPH, PAINT, GRAPH, GRAPH], 32, T(XJ, 0, ZJ) @ YPOS) # the eye on the pin, between the cheeks lathe(bm_, [(0, -0.18), (0.15, -0.18), (0.18, -0.155), (0.18, 0.155), (0.15, 0.18), (0, 0.18)], GRAPH, 32, T(R_TIP, 0, ZS) @ YPOS) # shoulder axle housing bm_.box((0.24, 0.36, 0.10), (2.05, 0, 0.575), GRAPH, bevel=0.008) # junction box on the keel: the keel hoses start here for s in (1, -1): # power / data hoses along the keel pts = [(2.14, s * 0.195, 0.600), (2.30, s * 0.235, 0.624), (2.55, s * 0.167, 0.623), (2.75, s * 0.167, 0.623), (3.74, s * 0.167, 0.623), (4.81, s * 0.167, 0.623), (5.25, s * 0.167, 0.773), (5.33, s * 0.167, 0.773), (5.92, s * 0.15, 0.80)] bm_.sweep(catmull(pts, per=3), 0.02, RUBBER, sides=8) for x in (2.90, 3.74, 4.77): bm_.box((0.05, 0.062, 0.062), (x, s * 0.167, 0.623), STEEL, bevel=0.005) # hose clamps hose_boom_parts(bm_) # clips beside the climb, saddles on top, the tip bar bm_.bm.transform(T(-XJ, 0, -ZJ)) # origin = the pin boom_src = bm_.build(C_PARTS, recalc=True) # ================================================================== 3. upper link pair (pivot: shoulder axis; lies along -X when stowed) # joint eye: hw 0.074 is 4 mm proud of the 0.07 plates (no coplanar faces), the boss reaches hw + 8 mm EYE = lambda r, hw: [(0, -hw - 0.008), (r - 0.04, -hw - 0.008), (r - 0.035, -hw), (r, -hw), (r, hw), (r - 0.035, hw), (r - 0.04, hw + 0.008), (0, hw + 0.008)] ub = MB("SM_DockArm_UpperLink", []) for s in (1, -1): yc = s * 0.27 secs = [(-3.00, 0.07, -0.135, 0.135, 0.02), (-2.75, 0.07, -0.145, 0.145, 0.02), (-1.55, 0.07, -0.150, 0.150, 0.02), (-1.45, 0.07, -0.150, 0.150, 0.02), (-0.25, 0.07, -0.155, 0.155, 0.02), (0.0, 0.07, -0.150, 0.150, 0.02)] loft(ub, secs, PAINT, pockets=[(1, 'R'), (1, 'L'), (3, 'R'), (3, 'L')], pmat=GRAPH, xf=T(0, yc, 0)) lathe(ub, EYE(0.16, 0.074), GRAPH, 24, T(0, yc, 0) @ YPOS) # shoulder eye lathe(ub, EYE(0.16, 0.074), GRAPH, 24, T(-LINK, yc, 0) @ YPOS) # elbow eye y0, y1 = (0.352, 0.367) if s > 0 else (-0.367, -0.352) ycyl(ub, 0.07, y0, y1, -LINK, 0, STEEL, 12) # elbow pin (stops 1 mm short of the lower link) hose_upper_parts(ub) upper_src = ub.build(C_PARTS, recalc=True) # ================================================================== 4. lower link pair (pivot: elbow axis; lies along +X when stowed) lb = MB("SM_DockArm_LowerLink", []) for s in (1, -1): yc = s * 0.45 AX = YPOS if s > 0 else YNEG secs = [(0.0, 0.07, -0.150, 0.150, 0.02), (0.30, 0.07, -0.155, 0.155, 0.02), (0.45, 0.07, -0.153, 0.153, 0.02), (1.55, 0.07, -0.140, 0.140, 0.02), (1.65, 0.07, -0.139, 0.139, 0.02), (2.75, 0.07, -0.125, 0.125, 0.02), (3.0, 0.07, -0.120, 0.120, 0.02)] loft(lb, secs, PAINT, pockets=[(2, 'R'), (2, 'L'), (4, 'R'), (4, 'L')], pmat=GRAPH, xf=T(0, yc, 0)) lathe(lb, EYE(0.16, 0.074), GRAPH, 24, T(0, yc, 0) @ YPOS) # elbow eye lathe(lb, EYE(0.13, 0.074), GRAPH, 24, T(LINK, yc, 0) @ YPOS) # wrist eye lathe(lb, MOTOR, MOTOR_M, 24, T(0, yc, 0) @ AX) # elbow servo, outboard y0, y1 = (0.532, 0.544) if s > 0 else (-0.544, -0.532) ycyl(lb, 0.06, y0, y1, LINK, 0, STEEL, 12) # wrist pin pts = [(0.08, s * 0.60, 0.08), (0.24, s * 0.585, 0.11), (0.45, s * 0.542, 0.105), (1.40, s * 0.542, 0.10), (2.35, s * 0.542, 0.09), (2.50, s * 0.512, 0.085)] lb.sweep(catmull(pts, per=3), 0.018, RUBBER, sides=8) # servo cable for x in (1.0, 1.9): lb.box((0.04, 0.03, 0.055), (x, s * 0.535, 0.10 - 0.005 * (x > 1.5)), STEEL, bevel=0.004) hose_lower_parts(lb) lower_src = lb.build(C_PARTS, recalc=True) # ================================================================== 5. clamp head (pivot: wrist axis; centred on the hatch collar) hb = MB("SM_DockArm_Head", []) lathe(hb, [(0, -0.22), (0.665, -0.22), (0.70, -0.245), (0.70, -0.305), (0.688, -0.31), (0.688, -0.33), (0.70, -0.335), (0.675, -0.36), (0.30, -0.36), (0.28, -0.345), (0.14, -0.345), (0.12, -0.36), (0.10, -0.366), (0, -0.37)], [PAINT, PAINT, PAINT, GRAPH, CYAN, GRAPH, PAINT, PAINT, GRAPH, GRAPH, STEEL, STEEL, GLASS], segs=48) LUG = [(0, 0.545), (0.14, 0.545), (0.15, 0.555), (0.15, 0.665), (0.14, 0.675), (0.12, 0.675), (0.12, 0.70), (0.105, 0.715), (0.085, 0.715), (0.08, 0.71), (0.06, 0.71), (0.055, 0.715), (0, 0.715)] LUG_M = [GRAPH] * 9 + [AMBER, GRAPH, STEEL] for s in (1, -1): hb.box((0.30, 0.12, 0.23), (0, s * 0.61, -0.105), PAINT, bevel=0.012) # wrist lug lathe(hb, LUG, LUG_M, 24, YPOS if s > 0 else YNEG) # trunnion and wrist servo cap for az in CLAW_AZ: a = math.radians(az) P = lambda u, v, z: tuple(RZ(a) @ Vector((u, v, z))) hb.box((0.16, 0.20, 0.145), P(0.52, 0, -0.4275), GRAPH, rot=(0, 0, a), bevel=0.008) # claw block hb.box((0.12, 0.20, 0.035), P(0.46, 0, -0.5175), RUBBER, rot=(0, 0, a), bevel=0.004) # landing foot on the collar flange for s in (1, -1): hb.box((0.11, 0.02, 0.09), P(0.65, s * 0.095, -0.43), GRAPH, rot=(0, 0, a), bevel=0.004) # hinge ears for AX in (YPOS, YNEG): # pin heads on the ears (the claw barrel sits between the ears, 5 mm clear) lathe(hb, [(0, 0.105), (0.016, 0.105), (0.016, 0.113), (0, 0.113)], STEEL, 8, RZ(a) @ T(CLAW_HINGE[0], 0, CLAW_HINGE[1]) @ AX) hb.box((0.14, 0.08, 0.03), P(0.37, 0, -0.37), GRAPH, rot=(0, 0, a), bevel=0.004) # radial rib # top side (seen from the D2 gallery when the head is at BOARD): actuator housing on the outboard half, where nothing # folds over it; cap and bolt ring stay under 2 cm because the boom tip passes 4 cm above the plate when stowed HUMP = [(0.22, 0.33, -0.225, -0.115, 0.02), (0.30, 0.33, -0.225, -0.10, 0.02), (0.50, 0.33, -0.225, -0.10, 0.02), (0.58, 0.30, -0.225, -0.13, 0.02)] loft(hb, HUMP, PAINT, pockets=[(1, 'T')], pmat=GRAPH, inset=0.03, depth=0.012) hb.box((0.012, 0.36, 0.02), (0.584, 0, -0.165), CYAN) lathe(hb, [(0, -0.20), (0.15, -0.20), (0.16, -0.21), (0.16, -0.225), (0, -0.225)], GRAPH, 24) for i in range(24): a = 2 * math.pi * i / 24 x, y = 0.625 * math.cos(a), 0.625 * math.sin(a) if (abs(x) < 0.21 and abs(y) > 0.5) or (0.18 < x < 0.62 and -0.62 < y < 0.37): # under the lugs, the housing, the hose duct continue hb.cyl(0.018, 0.018, 0.016, (x, y, -0.214), STEEL, segs=6) hose_head_parts(hb) head_src = hb.build(C_PARTS, recalc=True) # ================================================================== 6. claw (pivot: its hinge; local +X = radially out) cb = MB("SM_DockArm_Claw", []) lathe(cb, [(0, -0.08), (0.027, -0.08), (0.032, -0.075), (0.032, 0.075), (0.027, 0.08), (0, 0.08)], GRAPH, 12, YPOS) # hinge barrel # finger: lofted downward (loft X -> -Z, loft Z -> +X radial); it widens inward into the pad carrier below the flange DOWN = Matrix.Rotation(math.pi / 2, 4, 'Y') FINGER = [(-0.03, 0.068, -0.035, 0.030, 0.012), (0.0, 0.075, -0.040, 0.035, 0.012), (0.10, 0.075, -0.040, 0.035, 0.012), (0.16, 0.070, -0.085, 0.030, 0.012), (0.28, 0.065, -0.085, 0.020, 0.012), (0.295, 0.055, -0.070, 0.005, 0.010)] loft(cb, FINGER, SAFETY, pockets=[(1, 'T')], pmat=GRAPH, inset=0.018, depth=0.008, xf=DOWN) cb.box((0.02, 0.12, 0.10), (-0.09, 0, -0.22), RUBBER, bevel=0.003) # grip pad on the collar wall (face at r 0.555) cb.box((0.006, 0.07, 0.012), (0.0285, 0, -0.05), CYAN) # lock light in the finger's outer panel claw_src = cb.build(C_PARTS, recalc=True) PARTS = [mount, turret, boom_src, upper_src, lower_src, head_src, claw_src] # ================================================================== 7. ore hose (one skinned mesh shared by the three arms) hose_src, HOSE_ST, HOSE_INFO = build_hose_mesh(HOSE_FILM, BAND) C_PARTS.objects.link(hose_src) HOSE_ARM = make_hose_armature("DockArm_HoseRig", Vector((XJ, 0.0, ZJ)), S_REST, E_REST, S_REST) # ------------------------------------------------------------------ audit, UVs, decals bpy.context.view_layer.update() report = {"parts": []} for o in PARTS: strip_unused_mats(o) a = audit(o) a["volume_m3"] = round(signed_volume(o), 4) a.update(uv_layers(o)) # UVMap (atlas) + UV_Tile (metres) a["uv"] = len(o.data.uv_layers) report["parts"].append(a) # numbers on the servo packs (read from below), chevron band on the head rim, labels in the lower link pockets dq = [] for k in range(3): M = RZ(arm_yaw(k)) dq.append(("n0%d" % (k + 1), tuple(M @ Vector((1.00, 0, 0.50))), (0, 0, -1), tuple(M.to_3x3() @ Vector((1, 0, 0))), 0.34, 0.24)) add_decals(turret, dq) dq = [] N = 48 for i in range(N): a0, a1 = 2 * math.pi * i / N, 2 * math.pi * (i + 1) / N am = (a0 + a1) / 2 n = Vector((math.cos(am), math.sin(am), 0)) dq.append(("chev", tuple(n * 0.70 * math.cos(math.pi / N) + Vector((0, 0, -0.275))), tuple(n), (0, 0, 1), 2 * 0.70 * math.sin(math.pi / N), 0.052)) add_decals(head_src, dq) dq = [] for s in (1, -1): yf = s * (0.52 - 0.018) dq.append(("label", (1.02, yf, -0.012), (0, s, 0), (0, 0, 1), 0.44, 0.11)) dq.append(("warn", (2.25, yf, 0.0), (0, s, 0), (0, 0, 1), 0.10, 0.10)) dq.append(("turn", (0.62, yf, 0.0), (0, s, 0), (0, 0, 1), 0.10, 0.10)) add_decals(lower_src, dq) # ------------------------------------------------------------------ baked maps, then the materials (surfaces.py) MASKS, BEVEL_N, report["bake"] = bake_atlas(PARTS, DECAL, TEX) for _name, _r in SURF.items(): dress(bpy.data.materials[_name], _r, MASKS, BEVEL_N) for _name, (_col, _str) in GLOW.items(): dress_glow(bpy.data.materials[_name], _col, _str) dress_glass(GLASS) dress_decal(DECAL, next(nd.image for nd in DECAL.node_tree.nodes if nd.type == 'TEX_IMAGE')) # ------------------------------------------------------------------ collision (UCX__NN convex boxes, part-local) def ucx(part, i, lo, hi, xf=None): bm = cube_bm((hi[0] - lo[0], hi[1] - lo[1], hi[2] - lo[2]), ((lo[0] + hi[0]) / 2, (lo[1] + hi[1]) / 2, (lo[2] + hi[2]) / 2)) if xf is not None: bm.transform(xf) m = bpy.data.meshes.new("UCX_%s_%02d" % (part.name, i)) bm.to_mesh(m); bm.free() o = bpy.data.objects.new(m.name, m) C_COL.objects.link(o) o.parent = part o.display_type = 'WIRE' o.hide_render = True return o ucx(mount, 0, (-1.35, -1.35, 1.035), (1.35, 1.35, 1.20)) ucx(turret, 0, (-0.78, -0.78, 0.37), (0.78, 0.78, 1.02)) for k in range(3): ucx(turret, k + 1, (0.60, -0.56, 0.50), (XJ + CHEEK_R + 0.02, 0.56, ZJ + CHEEK_R + 0.02), RZ(arm_yaw(k))) # root stub, clevis, pitch servos ucx(boom_src, 0, (-EYE_R - 0.02, -0.30, -0.27), (2.55 - XJ, 0.30, 0.27)) # eye and root of the beam (part-local: origin = the pin) ucx(boom_src, 1, (2.55 - XJ, -0.20, 0.62 - ZJ), (R_TIP - XJ + 0.18, 0.20, 1.12 - ZJ)) for i, s in enumerate((1, -1)): ucx(upper_src, i, (-3.16, min(s * 0.19, s * 0.375), -0.16), (0.16, max(s * 0.19, s * 0.375), 0.16)) ucx(lower_src, i, (-0.17, min(s * 0.37, s * 0.655), -0.17), (3.13, max(s * 0.37, s * 0.655), 0.17)) ucx(head_src, i + 1, (-0.15, min(s * 0.545, s * 0.715), -0.22), (0.15, max(s * 0.545, s * 0.715), 0.15)) ucx(head_src, 0, (-0.70, -0.70, -0.54), (0.70, 0.70, -0.22)) ucx(claw_src, 0, (-0.10, -0.08, -0.295), (0.035, 0.08, 0.032)) # ------------------------------------------------------------------ assembly: instances, pivots, drivers def drive(obj, expr, arm, prop): fc = obj.driver_add("rotation_euler", 1) d = fc.driver d.type = 'SCRIPTED' v = d.variables.new() v.name = "p"; v.type = 'SINGLE_PROP' v.targets[0].id = arm v.targets[0].data_path = '["%s"]' % prop d.expression = expr def inst(name, src, parent, loc, rot=(0, 0, 0)): o = bpy.data.objects.new(name, src.data) C_ASM.objects.link(o) o.parent = parent o.location = loc o.rotation_euler = rot return o arms = [] for k in range(3): n = "Arm%d" % (k + 1) arm = bpy.data.objects.new(n, None) arm.empty_display_type = 'ARROWS'; arm.empty_display_size = 0.6 C_ASM.objects.link(arm) arm.parent = turret arm.rotation_euler = (0, 0, arm_yaw(k)) arm["lower_m"] = 0.0 # how far the Tender is lowered below STOWED: 0 .. 5.3 (BOARD) .. 9.3 (POOL) arm["claw_open"] = 0.0 # 0 closed on the collar .. 1 open arm["reach_in"] = 0.0 # how far the head has moved in toward the hub when it reaches BOARD (the ship: 2.34 on the launch arm) arm.id_properties_ui("lower_m").update(min=0.0, max=Y_MAX, soft_min=0.0, soft_max=Y_MAX, description="Tender lowered below STOWED, metres: 5.3 = BOARD; below that the boom joint takes it on down, 9.3 = POOL") arm.id_properties_ui("claw_open").update(min=0.0, max=1.0, description="0 closed on the hatch collar, 1 open") arm.id_properties_ui("reach_in").update(min=0.0, max=2.5, soft_min=0.0, soft_max=2.5, description="How far the head has moved in toward the hub when the hoist reaches BOARD, metres. 0 = straight down") boom = inst(n + "_Boom", boom_src, arm, (XJ, 0, ZJ)) up = inst(n + "_UpperLink", upper_src, boom, (R_TIP - XJ, 0, ZS - ZJ)) lo = inst(n + "_LowerLink", lower_src, up, (-LINK, 0, 0)) hd = inst(n + "_Head", head_src, lo, (LINK, 0, 0)) # the mechanism (kinematics.py): six small empties hold the staged values in their location channels; boom, links and head # read the results. All simple expressions: nothing in the file needs Python to run. mech = {} for si, (stage, _ch) in enumerate(CHAIN): e = bpy.data.objects.new("%s_Mech%d" % (n, si + 1), None) e.empty_display_type = 'PLAIN_AXES'; e.empty_display_size = 0.03 C_ASM.objects.link(e) e.parent = arm mech[stage] = e inputs = {"y": (arm, '["lower_m"]'), "r": (arm, '["reach_in"]')} channel_of = lambda stage, mech=mech: {"id": mech[stage], "bone_target": None} build_chain(lambda stage, i, mech=mech: mech[stage].driver_add("location", i), inputs, channel_of) for obj, key, sign in ((boom, "boom", 1.0), (up, "shoulder", -1.0), (lo, "elbow", 1.0), (hd, "wrist", -1.0)): drive_output(obj.driver_add("rotation_euler", 1), key, sign, inputs, channel_of) # rotation about the hinge (local Y) claws = [] for j, az in enumerate(CLAW_AZ): a = math.radians(az) cl = inst("%s_Claw%d" % (n, j + 1), claw_src, hd, tuple(RZ(a) @ Vector((CLAW_HINGE[0], 0, CLAW_HINGE[1]))), (0, 0, a)) drive(cl, "-%.5f*p" % math.radians(CLAW_OPEN_DEG), arm, "claw_open") claws.append(cl) hrig, hose = setup_hose_rig(arm, boom, up, lo, hd, HOSE_ARM, hose_src.data, C_ASM, n) arms.append(dict(arm=arm, boom=boom, up=up, lo=lo, hd=hd, claws=claws, hrig=hrig, hose=hose, mech=list(mech.values()))) def pose(k, lower, claw=0.0, reach=0.0): arms[k]["arm"]["lower_m"] = float(lower) arms[k]["arm"]["claw_open"] = float(claw) arms[k]["arm"]["reach_in"] = float(reach) for key in ("arm", "boom", "up", "lo", "hd", "hrig", "hose"): # objects made in this session: tag each, or the hose rig evaluates stale arms[k][key].update_tag() for e in arms[k]["mech"]: e.update_tag() bpy.context.view_layer.update() bpy.context.evaluated_depsgraph_get().update() # ------------------------------------------------------------------ checks: drivers, envelope, interference def world_tris(o): dg = bpy.context.evaluated_depsgraph_get() e = o.evaluated_get(dg) me = e.data me.calc_loop_triangles() M = e.matrix_world return [M @ v.co for v in me.vertices], [tuple(t.vertices) for t in me.loop_triangles] def interference(objs): trees = {} for o in objs: vs, ts = world_tris(o) trees[o.name] = (BVHTree.FromPolygons(vs, ts), max(v.z for v in vs), min(v.z for v in vs)) hits = [] names = list(trees) for i in range(len(names)): for j in range(i + 1, len(names)): n = len(trees[names[i]][0].overlap(trees[names[j]][0])) if n: hits.append((names[i], names[j], n)) return hits, max(t[1] for t in trees.values()), min(t[2] for t in trees.values()) movers = [mount, turret] + [o for a in arms for o in [a["boom"], a["up"], a["lo"], a["hd"]] + a["claws"]] NR = HOSE_INFO["stations"] ATTACH = [0, NR - 1] + [i for i, s in enumerate(HOSE_ST) if s["band"]] # where the hose is meant to touch the arm SEG = HOSE_INFO["seg_ranges"] def hose_check(arm_i): """Unintended contacts of one arm's hose: with any part (away from its clamps and end fittings) and with itself. Also the largest and smallest spacing of neighbouring rings (stretch and bunching).""" hv, ht = world_tris(arms[arm_i]["hose"]) centres = [sum((hv[i * HOSE_SIDES + k] for k in range(HOSE_SIDES)), Vector()) / HOSE_SIDES for i in range(NR)] att = [centres[i] for i in ATTACH] htree = BVHTree.FromPolygons(hv, ht) bad = {} inv = arms[arm_i]["arm"].matrix_world.inverted() for o in movers: ov, ot = world_tris(o) for (a, b) in htree.overlap(BVHTree.FromPolygons(ov, ot)): c = (hv[ht[a][0]] + hv[ht[a][1]] + hv[ht[a][2]]) / 3 if min((c - p).length for p in att) > 0.17: bad[o.name] = bad.get(o.name, 0) + 1 pc = (ov[ot[b][0]] + ov[ot[b][1]] + ov[ot[b][2]]) / 3 ring = min(range(NR), key=lambda i: (centres[i] - c).length) bad.setdefault("where (boom frame)", []).append([round(x, 3) for x in (inv @ c)] + [o.name, "part tri at"] + [round(x, 3) for x in (inv @ pc)] + ["hose ring", ring, HOSE_ST[ring]["w"], HOSE_ST[ring]["seg"]]) def run(names): lo = min(SEG[n][0] for n in names) * HOSE_SIDES hi = (max(SEG[n][1] for n in names) + 1) * HOSE_SIDES return BVHTree.FromPolygons(hv, [t for t in ht if all(lo <= v < hi for v in t)]) runs = {"hub": run(["hub"]), "boom": run(["climb", "top", "tipU"]), "upper": run(["upper"]), "elbow": run(["elbow"]), "lower": run(["lower", "wrist"])} for a, b in (("hub", "boom"), ("hub", "upper"), ("hub", "lower"), ("boom", "upper"), ("boom", "elbow"), ("boom", "lower"), ("upper", "lower")): n = len(runs[a].overlap(runs[b])) if n: bad["hose %s x %s" % (a, b)] = n gaps = [(centres[i + 1] - centres[i]).length for i in range(NR - 1)] return bad, round(max(gaps), 4), round(min(gaps), 4), round(max(v.z for v in hv), 4) rest_gap = None checks = [] POSES = [(lower, claw, 0.0) for lower in (0.0, 0.25, 1.0, 2.5, 4.0, 5.3) for claw in (0.0, 1.0)] POSES += [(5.3, 0.0, 2.34), (6.3, 0.0, 2.34), (7.3, 0.0, 2.34), (8.3, 0.0, 2.34), (9.3, 0.0, 2.34), (9.3, 1.0, 2.34), # the ship's launch arm, on down (7.3, 0.0, 0.0), (8.8, 0.0, 0.0), (9.3, 0.0, 0.0), (9.3, 0.0, 2.5), (8.0, 1.0, 1.2)] # the corners of the travel for lower, claw, reach in POSES: pose(0, lower, claw, reach) hw = arms[0]["hd"].matrix_world.translation ref = arm_pose(lower, reach) hits, zmax, zmin = interference(movers) hbad, gmax, gmin, htop = hose_check(0) if rest_gap is None: rest_gap = (gmax, gmin) report["hose_other_arms_stowed"] = [hose_check(1)[0], hose_check(2)[0]] checks.append(dict(lower_m=lower, claw_open=claw, reach_in=reach, wrist_z=round(hw.z, 4), wrist_z_expected=round(ZS - lower, 4), wrist_r=round(hw.xy.length, 4), wrist_r_expected=round(ref["W"][0], 4), joints_deg={k_: round(math.degrees(ref[k_]), 2) for k_ in ("boom", "shoulder", "elbow", "wrist")}, head_tilt_deg=round(math.degrees(arms[0]["hd"].matrix_world.to_euler().y), 4), top_z=round(zmax, 4), lowest_z=round(zmin, 4), interfering_pairs=hits, hose_unintended_contacts=hbad, hose_ring_spacing_max_min=(gmax, gmin), hose_top_z=htop)) c_ = checks[-1] assert abs(c_["wrist_z"] - c_["wrist_z_expected"]) < 1e-3 and abs(c_["wrist_r"] - c_["wrist_r_expected"]) < 1e-3, ("the drivers do not put the wrist where kinematics.py says", c_) assert abs(c_["head_tilt_deg"]) < 0.01, ("the head is not level", c_) json.dump(checks, open(os.path.join(HERE, "pose_checks.json"), "w"), indent=1) bad_poses = [c_ for c_ in checks if c_["interfering_pairs"] or c_["hose_unintended_contacts"]] for c_ in bad_poses: print("POSE PROBLEM", c_) report["pose_problems"] = len(bad_poses) # the build goes on to save and export, then fails at the end # reference points for the in-place update of an open scene: one vertex per ring of arm 1's hose, in the root frame hose_ref = {} for lower in (5.3, 0.0): pose(0, lower, 0.0) hv, _t = world_tris(arms[0]["hose"]) hose_ref["%.1f" % lower] = [[round(c, 5) for c in hv[i * HOSE_SIDES]] for i in range(NR)] json.dump(hose_ref, open(os.path.join(HERE, "hose_ref.json"), "w")) pose(0, 0.0, 0.0) report["pose_checks"] = checks report["stowed_top_z"] = checks[0]["top_z"] report["ceiling_z"] = CEIL_Z report["hose"] = dict(HOSE_INFO, rest_ring_spacing_max_min=rest_gap, stowed_top_z=checks[0]["hose_top_z"], note="the hose rises above the Unreal blockout's ceiling (z 1.20); it needs the taller bay of the vessel interior") # ------------------------------------------------------------------ studio (render only; not exported) C_STU = get_coll("Studio") sc = bpy.context.scene use_gpu() # Cycles: reflections and bounced light are what show the surfaces sc.cycles.samples = 192 sc.cycles.use_denoising = True try: sc.view_settings.view_transform = 'Khronos PBR Neutral' except Exception: pass w = bpy.data.worlds.new("Studio_World") sc.world = w w.use_nodes = True bg = w.node_tree.nodes.get("Background") bg.inputs['Color'].default_value = (0.16, 0.18, 0.21, 1) bg.inputs['Strength'].default_value = 0.9 cam = bpy.data.objects.new("Camera", bpy.data.cameras.new("Camera")) C_STU.objects.link(cam) sc.camera = cam cam.data.lens = 28 cam.location = (8.5, -10.5, -3.6) cam.rotation_euler = (Vector((0, -1.2, 0.2)) - cam.location).to_track_quat('-Z', 'Y').to_euler() for nm, loc, e, size in (("Key_Below", (5, -7, -5), 700, 6), ("Fill_Below", (-7, 4, -4), 350, 8), ("Rim_Side", (-2, 9, -1.5), 400, 5)): ld = bpy.data.lights.new(nm, 'AREA') ld.energy = e; ld.size = size lo_ = bpy.data.objects.new(nm, ld) C_STU.objects.link(lo_) lo_.location = loc lo_.rotation_euler = (Vector((0, 0, 0.6)) - Vector(loc)).to_track_quat('-Z', 'Y').to_euler() sc.render.resolution_x, sc.render.resolution_y = 1600, 1000 # ------------------------------------------------------------------ layout JSON (what a coder needs to assemble it in Unreal) def ue_cm(v): return [round(v[0] * 100, 3), round(-v[1] * 100, 3), round(v[2] * 100, 3)] layout = { "what": "Three-armed Tender docking carousel. Meshes are in each part's own frame; assemble them by this table.", "axes": "Blender metres here are (bx, by, bz); Unreal = (bx, -by, bz) * 100 cm. Root frame = ArmsRoot of UCarrierCarouselComponent (hub axis, carrier z 0).", "from_unreal_blockout": {"cradle_radius_m": CRADLE_R, "stowed_z_m": STOWED_Z, "board_z_m": BOARD_Z, "ceiling_z_m": CEIL_Z, "arm_yaw_deg_unreal": "90 + AngleDeg + 120 * Arm (ArmYawDeg)"}, "boom_aim": {"note": "each boom is turned %.4f deg from its nominal yaw so its tip is over the hatch collar centre (Tender x +0.09), not the Tender origin" % math.degrees(DELTA), "unreal_yaw_deg": [round(90.0 + 120.0 * k - math.degrees(DELTA), 4) for k in range(3)], "tip_radius_cm": round(R_TIP * 100, 3)}, "parts": [ {"mesh": "SM_DockArm_Mount", "parent": "carrier (does not turn)", "location_cm": [0, 0, 0], "moves": "never"}, {"mesh": "SM_DockArm_Turret", "parent": "ArmsRoot", "location_cm": [0, 0, 0], "moves": "yaw = hub AngleDeg (the drum and the three root stubs with the boom joints' clevises are this mesh)"}, {"mesh": "SM_DockArm_Boom", "count": 3, "parent": "Turret, in the boom frame (yaw from boom_aim)", "location_cm": ue_cm((XJ, 0, ZJ)), "moves": "Unreal pitch = -theta (the boom joint, added 2026-10-16: 0 down to BOARD, then it pitches the arm down into the moon pool: see 'hoist')"}, {"mesh": "SM_DockArm_UpperLink", "count": 3, "parent": "Boom", "location_cm": ue_cm((R_TIP - XJ, 0, ZS - ZJ)), "moves": "Unreal pitch = +shoulder (phi on the plain hoist)"}, {"mesh": "SM_DockArm_LowerLink", "count": 3, "parent": "UpperLink", "location_cm": ue_cm((-LINK, 0, 0)), "moves": "Unreal pitch = -elbow (2 phi on the plain hoist)"}, {"mesh": "SM_DockArm_Head", "count": 3, "parent": "LowerLink", "location_cm": ue_cm((LINK, 0, 0)), "moves": "Unreal pitch = +wrist = elbow - shoulder + theta (the head stays level)"}, {"mesh": "SM_DockArm_Claw", "count": 12, "parent": "Head", "moves": "Unreal pitch = +open * %.0f deg" % CLAW_OPEN_DEG, "instances": [{"location_cm": ue_cm(RZ(math.radians(az)) @ Vector((CLAW_HINGE[0], 0, CLAW_HINGE[1]))), "unreal_yaw_deg": -az} for az in CLAW_AZ]}, ], "hoist": {"what": "y = StowedZ - TenderOriginZ (metres let down, 0 .. %.1f); r = reach_in (0 .. 2.5 m; the vessel interior uses 2.34 on the launch arm). Angles in radians, the boom frame has x radial out, z up" % Y_MAX, "to_BOARD": "y <= 5.3, the boom level (theta 0): x = r (y/5.3)^2, D = sqrt(x^2 + y^2); shoulder = atan2(y, x) - acos(D/6), elbow = pi - 2 acos(D/6), wrist = elbow - shoulder. " "r = 0 gives phi = asin(y/6): shoulder phi, elbow 2 phi, wrist phi; %.3f deg at BOARD" % math.degrees(math.asin((STOWED_Z - BOARD_Z) / (2 * LINK))), "on_down_to_POOL": "y > 5.3 (POOL at %.1f): the wrist W = (R_TIP - r, ZS - y) goes straight on down; the knee keeps its BOARD bend D = sqrt(r^2 + 5.3^2); " "the shoulder S is the upper intersection of the circle of radius LJ = %.4f about the joint J = (%.2f, %.2f) with the circle of radius D about W: " "L = |W - J|, a = (LJ^2 - D^2 + L^2)/(2 L), h = sqrt(LJ^2 - a^2), c = (W - J)/L, S = J + a c + h (-c.z, c.x); " "theta = %.4f deg - atan2(S.z - J.z, S.x - J.x) (the boom's pitch down). In the boom frame turned down by theta: " "x_in = -((W - S) . (cos theta, -sin theta)), y_dn = -((W - S) . (sin theta, cos theta)); shoulder = atan2(y_dn, x_in) - acos(D/6), " "elbow = pi - 2 acos(D/6), wrist = elbow - shoulder + theta. If the circles do not meet (r near 0, deep) the knee opens: D = L - LJ + 0.05 (at most 5.94)" % (Y_MAX, LJ, XJ, ZJ, math.degrees(A0)), "at_POOL_on_the_launch_arm_deg": {k_: round(math.degrees(v), 2) for k_, v in arm_pose(Y_MAX, 2.34).items() if k_ in ("boom", "shoulder", "elbow", "wrist")}, "joint_ranges_deg": {"boom": [0, BOOM_PITCH_LIMIT_DEG], "shoulder": [0, SHOULDER_LIMIT_DEG], "elbow": [0, 180], "wrist": [-WRIST_LIMIT_DEG, WRIST_LIMIT_DEG]}, "link_length_cm": LINK * 100, "wrist_axis_above_tender_origin_cm": round(WRIST_ABOVE_TENDER * 100, 3), "in_the_ship": "hub 7.5 m over the sub bay's deck; at POOL the Tender's top is 1.3 m below the deck and its keel 3.2 m above the hull's aperture. " "The rig's drivers are these formulas in staged form (build/kinematics.py)"}, "tender_origin_in_head_frame_cm": ue_cm((CRADLE_R * math.cos(DELTA) - R_TIP, -CRADLE_R * math.sin(DELTA), -WRIST_ABOVE_TENDER)), "tender_yaw_relative_to_head_deg_unreal": round(-90.0 + math.degrees(DELTA), 4), } layout["ore_hose"] = { "what": "SM_DockArm_OreHose: a film duct of 11 cm radius per arm, built in the boom frame (rest = stowed), every ring skinned to ONE bone with weight 1", "route": "hub pod port (22 deg off the arm) -> under the root stub -> through the boom joint's axis line, outside its clevis -> top of the boom -> flat U-turn round the tip -> lane beside the arm -> over the shoulder -> down the upper link -> U-turn round the elbow -> down the lower link -> through the wrist -> coupling on the head", "lane": "the hose runs beside the arm at Unreal y = +%.0f cm of the boom frame (Blender y %.2f m); the middle of the arm is taken by the boom when the arm is folded" % (-LANE_Y * 100, LANE_Y), "bones": {"Hub": "rigid with the turret, in the boom frame (the port to the boom joint)", "Boom / Upper / Lower / Head": "follow the arm's boom, upper link, lower link and head (pivots in 'parts' above)", "R1..R%d" % (FAN_STEPS["R"] - 1): "at the boom joint: i/%d of the boom's pitch" % FAN_STEPS["R"], "S1..S%d" % (FAN_STEPS["S"] - 1): "at the shoulder pivot: i/%d of the upper link's rotation" % FAN_STEPS["S"], "E1..E%d" % (FAN_STEPS["E"] - 1): "at the elbow pivot, carried by the upper link: plus i/%d of the lower link's rotation relative to it" % FAN_STEPS["E"], "W1..W%d" % (FAN_STEPS["W"] - 1): "at the wrist pivot, carried by the lower link: plus i/%d of the head's rotation relative to it" % FAN_STEPS["W"]}, "material": "M_DockArm_OreHose: clear ducting with a spiral rib (T_DockArm_Duct_BaseColor with the opacity in its alpha: film about 0.2 to 0.3, rib 1; T_DockArm_Duct_Roughness; T_DockArm_Duct_Normal is OpenGL, flip green for Unreal). Two-sided, translucent, reflections by Fresnel. One texture repeat = one circumference (0.69 m) of hose, 14 rib turns in it. Clamp bands are M_DockArm_HoseBand", "needs_headroom": "top of the hose is at z %.2f m in the root frame when stowed: above the old blockout bay's ceiling (1.20), fine under the vessel interior's roof" % checks[0]["hose_top_z"], } layout["surfaces"] = { "uv0": "UVMap: a unique unwrap in the shared 4096 px atlas (the two baked maps). Turret and mount unwrap one sector; their other sectors lie whole tiles further along U, so these samplers must wrap. Decal quads carry decal-sheet coordinates here", "uv1": "UV_Tile: metres. Tiling sets are sampled at uv1 / repeat_m", "masks": "T_DockArm_Masks (linear): R ambient occlusion, G edge (1 on a hard edge, 0 from %d mm away), B crevice (1 open, lower in a gap)" % (EDGE_R * 1000), "bevel_normal": "T_DockArm_BevelNormal: tangent space on UV0, OpenGL (flip green for Unreal); rounds every hard edge by %d mm" % (BEVEL_R * 1000), "sets_from": "ModSci_Engineer/Textures (Modular SciFi Season 2, already in the Unreal project); smudge mask " + DIRT, "materials": surface_table(), "glow": {k: {"color": list(c), "strength": s} for k, (c, s) in GLOW.items()}, "lens": "M_DockArm_LensGlass: near-black smoked glass, roughness 0.16 to 0.24 by the smudge mask", "value_range_of_each_map": {k: list(v) for k, v in _RANGE.items()}, "recipe": "base colour = tint * lerp(shade_range, luminance of the diffuse map over its value range); roughness = lerp(roughness_range, roughness map over its value range) + smudge * amount; " "crevice shade = (1 - B)^1.4 * amount toward near-black, + 0.35 of it on the roughness; " "edge chips = smoothstep(0.38, 0.72, G * lerp(1.25, 0.55, roughness map) * smoothstep(0.72, 0.93, R) * amount) toward the bare-metal colour with metallic 1", } json.dump(layout, open(os.path.join(OUT, "dockarm_layout.json"), "w"), indent=2) # ------------------------------------------------------------------ save def exclude(coll, flag): def walk(lc): if lc.collection == coll: lc.exclude = flag for ch in lc.children: walk(ch) walk(bpy.context.view_layer.layer_collection) exclude(C_PARTS, True); exclude(C_COL, True) txt = bpy.data.texts.new("DOCKARM README") txt.write("Three-armed Tender docking carousel. See README.md next to this file.\n" "Pose an arm: select Arm1/Arm2/Arm3 (empties) > Object properties > Custom Properties:\n" " lower_m 0 (stowed) .. 5.3 (BOARD) .. 9.3 (POOL: below BOARD the boom joint pitches the arm down into the moon pool),\n" " reach_in 0 .. 2.5 (how far the head moves in toward the hub by BOARD; the ship uses 2.34 on the launch arm), claw_open 0 .. 1.\n" "ArmN_Mech1..6 are the mechanism's helper values (drivers): leave them be.\n" "DockArm_Parts and DockArm_Collision are excluded from the view layer; enable them to see the unique meshes and UCX hulls.\n") BLEND = os.path.join(OUT, "DockArm_Carousel.blend") bpy.ops.wm.save_as_mainfile(filepath=BLEND) bpy.ops.file.make_paths_relative() # textures\\ beside the file, the pack's sets under ..\\vessel-interior\\textures bpy.ops.wm.save_mainfile() # ------------------------------------------------------------------ FBX (nothing is saved after this point) FBX = os.path.join(OUT, "fbx") def fbx(path, objs, types): for o in bpy.context.view_layer.objects: o.select_set(False) 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=types, 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) # the project's proven import pair (forward -Y, up Z + "convert scene" + "force front X") gives ue = (-by, -bx, bz); # turning the data -90 degrees about Z first makes it ue = (bx, -by, bz), as export_arm.py does. PRE = Matrix.Rotation(-math.pi / 2, 4, 'Z') root.rotation_euler = (0, 0, -math.pi / 2) bpy.context.view_layer.update() fbx(os.path.join(FBX, "DockArm_Carousel_Assembled.fbx"), [o for o in C_ASM.objects if "_Mech" not in o.name], {"MESH", "EMPTY"}) root.rotation_euler = (0, 0, 0) exclude(C_PARTS, False); exclude(C_COL, False) bpy.context.view_layer.update() done = set() for p in PARTS: hulls = [o for o in C_COL.objects if o.parent == p] for o in [p] + hulls: if o.data.name not in done: o.data.transform(PRE) done.add(o.data.name) keep_parent = p.parent p.parent = None # a part file holds the mesh at identity for h in hulls: h.parent = None bpy.context.view_layer.update() fbx(os.path.join(FBX, p.name + ".fbx"), [p] + hulls, {"MESH"}) # the ore hose as a small skeletal mesh: one arm's hose in the boom frame with the bones of hose_rig_setup.py. Written in # centimetres with no scale on any node (data x100, the exporter's own x100 cancelled by global_scale 0.01), turned like the parts. SKPRE = Matrix.Scale(100.0, 4) @ PRE sk_rig = bpy.data.objects.new("Armature", HOSE_ARM) sk_hose = bpy.data.objects.new("SK_DockArm_OreHose", hose_src.data) for o in (sk_rig, sk_hose): C_PARTS.objects.link(o) sk_hose.parent = sk_rig sk_hose.modifiers.new("Armature", 'ARMATURE').object = sk_rig HOSE_ARM.transform(SKPRE) hose_src.data.transform(SKPRE) bpy.context.view_layer.update() for o in bpy.context.view_layer.objects: o.select_set(False) sk_rig.select_set(True); sk_hose.select_set(True) bpy.context.view_layer.objects.active = sk_rig bpy.ops.export_scene.fbx(filepath=os.path.join(FBX, "SK_DockArm_OreHose.fbx"), use_selection=True, object_types={"ARMATURE", "MESH"}, use_mesh_modifiers=False, mesh_smooth_type="EDGE", add_leaf_bones=False, use_armature_deform_only=False, bake_anim=False, axis_forward="-Y", axis_up="Z", global_scale=0.01, apply_unit_scale=True, apply_scale_options="FBX_SCALE_NONE", use_space_transform=True, bake_space_transform=False, path_mode="STRIP", use_metadata=False) report["tris_total"] = sum(a["tris_total"] for a in report["parts"]) + HOSE_INFO["tris"] report["tris_assembled"] = sum(a["tris_total"] * n for a, n in zip(report["parts"], (1, 1, 3, 3, 3, 3, 12))) + 3 * HOSE_INFO["tris"] report["value_range_of_each_map"] = {k: list(v) for k, v in _RANGE.items()} json.dump(report, open(os.path.join(HERE, "build_report.json"), "w"), indent=1) assert not bad_poses, "%d poses with parts crossing or the hose touching something (pose_checks.json)" % len(bad_poses) print("BUILD OK", BLEND)