"""Ore hose of the docking arm. exec'd inside build_dockarm.py (shares its namespace: MB, lathe, MAT, R_TIP, ZS ...). A wrinkled, mostly see-through film duct (the look of the kit's fabric-coated flexible duct), one per arm: hub pod port -> up beside the boom root -> along the top of the boom -> flat U-turn round the tip -> back over the shoulder -> down the upper link -> U-turn round the elbow -> down the lower link -> through the wrist -> coupling on the side of the head. It lies in ONE plane beside the arm (the 'lane', y = LANE_Y): stowed, the boom sits between the link plates and the head sits 4 cm under the boom tip, so there is no room for a hose on the centre line. Nothing of the arm ever enters the lane (the mechanism is planar and ends at |y| 0.715), so the hose can pass every joint axis there. Rest pose = stowed. All coordinates in the boom frame (X out along the boom, Y tangential, Z up). """ import numpy as np exec(open(os.path.join(HERE, "hose_rig_setup.py")).read()) HOSE_R = 0.11 # 22 cm duct HOSE_SIDES = 12 LANE_Y = -0.86 # stern side of the Tender, clear of the canopy HOSE_ZT = 1.25 # centre height of the run on top of the boom RS = HOSE_ZT - ZS # 0.31 above the shoulder axis RE = 0.31 # U-turn radius round the elbow WRIST_DROP = 0.03 # the hose passes 3 cm under the wrist axis PORT_AZ = math.radians(-22.0) # hub port, measured from the arm BAND_R, BAND_W = HOSE_R + 0.014, 0.036 TIP_U = R_TIP + 0.05 # x of the tip U-turn's centre (and of the last boom-rigid ring of the lane leg) U_R = 0.25 # tip U-turn radius. Kept tight and off the centre line on purpose: in the vessel interior the U_Y = LANE_Y + U_R # hull closes in to 6.49 m from the hub at the hose's top; this U stays within 6.33 + 0.115 E_REST = Vector((R_TIP - LINK, 0.0, ZS)) S_REST = Vector((R_TIP, 0.0, ZS)) # ------------------------------------------------------------------ textures and material def make_hose_textures(folder, n=512, seed=7): """Tileable crumpled-film normal map (OpenGL, +Y) and a fold mask, both my own (no pack texture is used).""" rng = np.random.default_rng(seed) u = (np.arange(n) + 0.5) / n U, V = np.meshgrid(u, u) # U around the hose, V along it h = np.zeros((n, n)) for _ in range(460): cu, cv = rng.random(2) su, sv = rng.uniform(0.05, 0.17), rng.uniform(0.011, 0.028) # long round the duct, short along it ang = rng.normal(0.0, 0.20) du = (U - cu + 0.5) % 1.0 - 0.5 dv = (V - cv + 0.5) % 1.0 - 0.5 a = du * math.cos(ang) + dv * math.sin(ang) b = -du * math.sin(ang) + dv * math.cos(ang) h += rng.uniform(-1.0, 1.0) * np.exp(-(a / su) ** 2 - (b / sv) ** 2) h /= np.abs(h).max() k = 0.030 * n gx = (np.roll(h, -1, 1) - np.roll(h, 1, 1)) * 0.5 * k gy = (np.roll(h, -1, 0) - np.roll(h, 1, 0)) * 0.5 * k ln = np.sqrt(gx ** 2 + gy ** 2 + 1.0) nrm = np.stack([-gx / ln * 0.5 + 0.5, -gy / ln * 0.5 + 0.5, 1.0 / ln * 0.5 + 0.5, np.ones_like(h)], -1) m = np.clip(np.abs(h) * 1.6, 0.0, 1.0) ** 0.8 # folds: the film is denser (whiter, less see-through) there msk = np.stack([m, m, m, np.ones_like(h)], -1) out = [] for name, arr in (("T_DockArm_HoseWrinkle_Normal", nrm), ("T_DockArm_HoseFold_Mask", msk)): img = bpy.data.images.new(name, n, n, alpha=False) img.colorspace_settings.name = 'Non-Color' img.pixels.foreach_set(arr.astype(np.float32).ravel()) img.filepath_raw = os.path.join(folder, name + ".png") img.file_format = 'PNG' img.save() out.append(img) return out def hose_material(nrm_img, mask_img): m = bpy.data.materials.new("M_DockArm_OreHose") m.use_nodes = True nt = m.node_tree b = nt.nodes["Principled BSDF"] b.inputs["Roughness"].default_value = 0.26 b.inputs["Metallic"].default_value = 0.0 tn = nt.nodes.new('ShaderNodeTexImage'); tn.image = nrm_img; tn.location = (-700, -300) nm = nt.nodes.new('ShaderNodeNormalMap'); nm.location = (-400, -300) nm.inputs["Strength"].default_value = 1.2 nt.links.new(tn.outputs["Color"], nm.inputs["Color"]) nt.links.new(nm.outputs["Normal"], b.inputs["Normal"]) tm = nt.nodes.new('ShaderNodeTexImage'); tm.image = mask_img; tm.location = (-700, 200) al = nt.nodes.new('ShaderNodeMapRange'); al.location = (-400, 100) al.inputs["To Min"].default_value = 0.20 # mostly see-through film ... al.inputs["To Max"].default_value = 0.55 # ... denser in the folds nt.links.new(tm.outputs["Color"], al.inputs["Value"]) nt.links.new(al.outputs["Result"], b.inputs["Alpha"]) mx = nt.nodes.new('ShaderNodeMix'); mx.data_type = 'RGBA'; mx.location = (-400, 350) mx.inputs[6].default_value = (0.56, 0.70, 0.71, 1.0) # A (colour): nylon film, a little cool mx.inputs[7].default_value = (0.93, 0.95, 0.94, 1.0) # B (colour): the folds nt.links.new(tm.outputs["Color"], mx.inputs[0]) nt.links.new(mx.outputs[2], b.inputs["Base Color"]) try: m.surface_render_method = 'DITHERED' # front and back wall both show, no sorting artefacts except Exception: pass m.use_backface_culling = False m.diffuse_color = (0.75, 0.84, 0.83, 0.38) # Solid shading shows it see-through too return m # ------------------------------------------------------------------ rest path def hose_stations(): """[dict(p, w, seg, band)]: ring centres along the hose, the bone that carries each ring, and where a clamp band sits.""" st = [] def add(p, bone, seg, band=False): st.append(dict(p=Vector(p), w=bone, seg=seg, band=band)) def line(a, b, bone, seg, step, forced=(), bands=(), skip_first=True): a, b = Vector(a), Vector(b) L = (b - a).length n = max(1, int(math.ceil(L / step))) clip = lambda f: min(1.0, max(0.0, round(f / L, 9))) # 2.1 / 2.0999999999999996 must not make a second end ring ft = [clip(f) for f in forced] ts = sorted(set([t for t in (round(i / n, 9) for i in range(n + 1)) # a regular ring gives way to a forced one within 5 cm if t in (0.0, 1.0) or all(abs(t - f) * L > 0.05 for f in ft)] + ft)) bset = {round(clip(f), 6) for f in bands} for t in ts: if skip_first and t == 0.0: continue add(a + (b - a) * t, bone, seg, round(t, 6) in bset) # hub port, then up beside the boom root onto its top u0 = Vector((math.cos(PORT_AZ), math.sin(PORT_AZ), 0.0)) ctrl = [u0 * 0.84 + Vector((0, 0, 0.50)), u0 * 1.08 + Vector((0, 0, 0.50)), Vector((1.30, -0.60, 0.57)), Vector((1.60, -0.585, 0.78)), Vector((1.88, -0.50, 1.00)), Vector((2.13, -0.33, 1.19)), Vector((2.42, -0.12, HOSE_ZT)), Vector((2.72, 0.0, HOSE_ZT)), Vector((3.0, 0.0, HOSE_ZT))] pts = catmull([tuple(p) for p in ctrl], per=5) for i, p in enumerate(pts): add(p, "Boom", "root", band=(i in (15, 20))) # clips at the 4th and 5th control point line((3.0, 0, HOSE_ZT), (5.1, 0, HOSE_ZT), "Boom", "top", 0.16, forced=(0.3, 1.2, 2.1), bands=(0.3, 1.2, 2.1)) y1 = U_Y + U_R # the run leaves the centre line toward the lane side = catmull([(5.1, 0, HOSE_ZT), (5.38, -0.05, HOSE_ZT), (5.66, y1 + 0.09, HOSE_ZT), (5.88, y1, HOSE_ZT), (TIP_U, y1, HOSE_ZT)], per=3) for i, p in enumerate(side[1:]): add(p, "Boom", "top", band=(i == len(side) - 2)) for i in range(1, 9): # flat U-turn round the tip a = math.pi * i / 8 add((TIP_U + U_R * math.sin(a), U_Y + U_R * math.cos(a), HOSE_ZT), "Boom", "tipU", band=(i == 8)) n = FAN_STEPS["S"] # over the shoulder: boom -> upper link for i in range(1, n + 1): add((TIP_U - 0.10 * i, LANE_Y, HOSE_ZT), "Upper" if i == n else "S%d" % i, "shoulder") x_end_s = TIP_U - 0.10 * n line((x_end_s, LANE_Y, HOSE_ZT), (E_REST.x, LANE_Y, HOSE_ZT), "Upper", "upper", 0.15, forced=[x_end_s - x for x in UPPER_BRACKETS_X], bands=[x_end_s - x for x in UPPER_BRACKETS_X]) n = FAN_STEPS["E"] # U-turn round the elbow: upper -> lower link for i in range(1, n + 1): a = math.pi * i / n add((E_REST.x - RE * math.sin(a), LANE_Y, ZS + RE * math.cos(a)), "Lower" if i == n else "E%d" % i, "elbow") a0 = Vector((E_REST.x, LANE_Y, ZS - RE)) # down the lower link, rising to the wrist axis a1 = Vector((E_REST.x + 2.5, LANE_Y, ZS - WRIST_DROP)) L = (a1 - a0).length fx = [(x / 2.5) * L for x in LOWER_BRACKETS_X] line(a0, a1, "Lower", "lower", 0.15, forced=fx, bands=fx) line(a1, (R_TIP - 0.25, LANE_Y, ZS - WRIST_DROP), "Lower", "lower", 0.13) n = FAN_STEPS["W"] # through the wrist: lower link -> head for i in range(1, n + 1): add((R_TIP - 0.25 + 0.10 * i, LANE_Y, ZS - WRIST_DROP), "Head" if i == n else "W%d" % i, "wrist") return st UPPER_BRACKETS_X = [R_TIP - 1.15, R_TIP - 1.90, R_TIP - 2.62] # boom-frame x of the stand-offs on the upper link LOWER_BRACKETS_X = [0.55, 1.40, 2.25] # distance from the elbow of the hangers on the lower link def lower_hose_z(xl): """hose centre height in the lower link's frame, xl metres from the elbow""" return -RE + (RE - WRIST_DROP) * min(xl, 2.5) / 2.5 # ------------------------------------------------------------------ what the arm's own parts gain (rigid, merged into them) def hose_boom_parts(bb): """port on the hub pod, clips beside the root, saddles on the boom top, the bar that carries the lane leg at the tip""" PX = RZ(PORT_AZ) @ Matrix.Rotation(math.pi / 2, 4, 'Y') # lathe axis -> radially out at the port lathe(bb, [(0, 0.58), (0.145, 0.58), (0.145, 0.70), (0.135, 0.705), (0.135, 0.725), (0.145, 0.73), (0.145, 0.82), (0.168, 0.82), (0.168, 0.86), (0.125, 0.86), (0, 0.86)], [GRAPH, GRAPH, GRAPH, CYAN, GRAPH, GRAPH, STEEL, STEEL, STEEL, STEEL], 20, T(0, 0, 0.50) @ PX) bb.box((0.05, 0.12, 0.03), (1.60, -0.425, 0.78), GRAPH, bevel=0.004) bb.box((0.05, 0.10, 0.03), (1.88, -0.345, 1.00), GRAPH, bevel=0.004) for x in (3.3, 4.2, 5.1): bb.box((0.07, 0.16, 0.04), (x, 0, 1.1125), GRAPH, bevel=0.006) # top 4 mm under the film, in the clamp band bb.box((0.06, 0.92, 0.014), (TIP_U, -0.46, 1.118), GRAPH) def hose_upper_parts(ub): """stand-offs: a post on the stern-side plate's top edge and an arm out to the lane (above the folded lower link)""" for xb in UPPER_BRACKETS_X: xl = xb - R_TIP ub.box((0.06, 0.06, 0.055), (xl, -0.31, 0.1625), GRAPH, bevel=0.005) ub.box((0.06, 0.60, 0.02), (xl, -0.58, 0.180), GRAPH, bevel=0.004) # 1.5 cm over the folded lower link, 5 mm under the film def hose_lower_parts(lb): """hangers on the stern-side plate's outer face""" for xl in LOWER_BRACKETS_X: hz = lower_hose_z(xl) z0, z1 = hz - 0.02, max(hz + 0.03, -0.06) lb.box((0.07, 0.03, z1 - z0), (xl, -0.535, (z0 + z1) / 2), GRAPH, bevel=0.004) lb.box((0.06, 0.20, 0.03), (xl, -0.645, hz), GRAPH, bevel=0.004) def hose_head_parts(hb): """coupling flange beside the wrist and the duct from it into the actuator housing on the head""" lathe(hb, [(0, 0.235), (0.15, 0.235), (0.15, 0.275), (0.105, 0.275), (0, 0.275)], [STEEL, STEEL, STEEL, STEEL], 20, T(0, LANE_Y, -WRIST_DROP) @ Matrix.Rotation(math.pi / 2, 4, 'Y')) pts = [(0.27, LANE_Y, -WRIST_DROP), (0.42, -0.84, -0.04), (0.52, -0.74, -0.07), (0.50, -0.58, -0.11), (0.42, -0.42, -0.13), (0.40, -0.31, -0.14)] hb.sweep(catmull(pts, per=3), 0.10, GRAPH, sides=10) # ------------------------------------------------------------------ the hose mesh (one mesh, shared by the three arms) def build_hose_mesh(mat_film, mat_band): st = hose_stations() P = [s["p"] for s in st] n = len(P) # parallel-transport frames tang = [] for i in range(n): a = P[max(i - 1, 0)]; b = P[min(i + 1, n - 1)] tang.append((b - a).normalized()) nrm = Vector((0, 0, 1)) - tang[0] * tang[0].z nrm.normalize() frames = [] for i in range(n): t = tang[i] if i: ax = tang[i - 1].cross(t) if ax.length > 1e-8: nrm = Matrix.Rotation(tang[i - 1].angle(t), 3, ax.normalized()) @ nrm nrm = (nrm - t * nrm.dot(t)).normalized() frames.append((t, nrm.copy(), t.cross(nrm))) rng = np.random.default_rng(3) s_acc = [0.0] for i in range(1, n): s_acc.append(s_acc[-1] + (P[i] - P[i - 1]).length) me = bpy.data.meshes.new("SM_DockArm_OreHose") holder = bpy.data.objects.new("SM_DockArm_OreHose", me) # the part object (also owns the vertex group names) for g in HOSE_GROUPS: holder.vertex_groups.new(name=g) gi = {g: i for i, g in enumerate(HOSE_GROUPS)} bm = bmesh.new() dl = bm.verts.layers.deform.verify() uvl = bm.loops.layers.uv.new("UVMap") rings = [] for i in range(n): t, nn, bb_ = frames[i] soft = 1.0 + 0.030 * math.sin(1.9 * s_acc[i]) + 0.018 * math.sin(5.3 * s_acc[i] + 1.0) # a soft duct is never quite round ring = [] for k in range(HOSE_SIDES): a = 2 * math.pi * k / HOSE_SIDES r = HOSE_R * soft * (1.0 + rng.uniform(-0.012, 0.012)) v = bm.verts.new(P[i] + r * (math.cos(a) * nn + math.sin(a) * bb_)) v[dl][gi[st[i]["w"]]] = 1.0 ring.append(v) rings.append(ring) vs = 1.0 / (2 * math.pi * HOSE_R) # v in circumferences, so the texture stays square for i in range(n - 1): for k in range(HOSE_SIDES): k2 = (k + 1) % HOSE_SIDES f = bm.faces.new((rings[i][k], rings[i + 1][k], rings[i + 1][k2], rings[i][k2])) f.material_index = 0 f.smooth = True for l, uv in zip(f.loops, ((k / HOSE_SIDES, s_acc[i] * vs), (k / HOSE_SIDES, s_acc[i + 1] * vs), ((k + 1) / HOSE_SIDES, s_acc[i + 1] * vs), ((k + 1) / HOSE_SIDES, s_acc[i] * vs))): l[uvl].uv = uv bm.normal_update() bmesh.ops.recalc_face_normals(bm, faces=bm.faces[:]) # clamp bands (closed rings, steel), each rigid with the bone of its station nb = 0 for i in range(n): if not st[i]["band"]: continue nb += 1 t, nn, bb_ = frames[i] prof = [(-BAND_W / 2, HOSE_R * 0.96), (-BAND_W / 2, BAND_R), (BAND_W / 2, BAND_R), (BAND_W / 2, HOSE_R * 0.96)] cols = [] for (ax_, r) in prof: col = [] for k in range(16): a = 2 * math.pi * k / 16 v = bm.verts.new(P[i] + t * ax_ + r * (math.cos(a) * nn + math.sin(a) * bb_)) v[dl][gi[st[i]["w"]]] = 1.0 col.append(v) cols.append(col) fs = [] for j in range(4): A, B = cols[j], cols[(j + 1) % 4] for k in range(16): k2 = (k + 1) % 16 f = bm.faces.new((A[k], B[k], B[k2], A[k2])) f.material_index = 1 for l in f.loops: l[uvl].uv = (0.5, 0.5) fs.append(f) bmesh.ops.recalc_face_normals(bm, faces=fs) bm.to_mesh(me) bm.free() me.materials.append(mat_film) me.materials.append(mat_band) for p in me.polygons: p.use_smooth = p.material_index == 0 info = dict(stations=n, bands=nb, length_m=round(s_acc[-1], 3), verts=len(me.vertices), tris=sum(len(p.vertices) - 2 for p in me.polygons), seg_ranges={}) for i, s in enumerate(st): info["seg_ranges"].setdefault(s["seg"], [i, i])[1] = i return holder, st, info