"""Ore hose of the docking arm. exec'd inside build_dockarm.py (shares its namespace: MB, lathe, MAT, R_TIP, ZS ...). A clear, spiral-ribbed flexible duct (path, brackets, mesh and bones here; its textures and material are in surfaces.py), one per arm: hub pod port -> under the root stub, through the boom joint's axis line -> 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)) J_REST = Vector((XJ, 0.0, ZJ)) # the boom joint (2026-10-16) JOINT_Y = -0.72 # where the climb crosses the joint's axis line: outside the clevis and its pitch servo (which reach y -0.54) ROOT_FAN_HALF = 0.32 # hose length either side of that crossing shared out over the R fan bones CLIMB_CLIPS_X = (1.95, 2.28) # clips that hold the climb to the boom's side, boom-frame x # (textures and material: surfaces.py, make_duct_textures and dress_duct. The film's coordinates are U = round the hose 0..1, # V = along it in circumferences; the clamp bands' are metres.) # ------------------------------------------------------------------ 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 under the root stub, through the boom joint's axis line (outside the clevis), and up onto the boom's 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.22, -0.62, 0.58)), Vector((XJ, JOINT_Y, ZJ)), Vector((1.78, -0.70, 1.08)), Vector((2.10, -0.50, 1.22)), Vector((2.45, -0.22, HOSE_ZT)), Vector((2.75, 0.0, HOSE_ZT)), Vector((3.0, 0.0, HOSE_ZT))] pts = [Vector(p) for p in catmull([tuple(p) for p in ctrl], per=5)] s_acc = [0.0] for i in range(1, len(pts)): s_acc.append(s_acc[-1] + (pts[i] - pts[i - 1]).length) ic = min(range(len(pts)), key=lambda i: (pts[i] - ctrl[3]).length) # the ring on the joint's axis line nR = FAN_STEPS["R"] clip_i = {min(range(len(pts)), key=lambda i: abs(pts[i].x - xc)) for xc in CLIMB_CLIPS_X} for i, p in enumerate(pts): s = s_acc[i] - s_acc[ic] j = int(round((s + ROOT_FAN_HALF) / (2 * ROOT_FAN_HALF) * nR)) if abs(s) <= ROOT_FAN_HALF else (0 if s < 0 else nR) w, seg = ("Hub", "hub") if j <= 0 else (("Boom", "climb") if j >= nR else ("R%d" % j, "rootJ")) add(p, w, seg, band=(i in clip_i or i == 1)) # a band at the port's mouth and at the two clips 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.12 * i, LANE_Y, ZS - WRIST_DROP), "Head" if i == n else "W%d" % i, "wrist") # the last ring sits in the coupling flange 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_turret_parts(mb): """the port on the hub pod (part of the turret)""" PX = RZ(PORT_AZ) @ Matrix.Rotation(math.pi / 2, 4, 'Y') # lathe axis -> radially out at the port lathe(mb, [(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, GRAPH, GRAPH, GRAPH, STEEL, STEEL, STEEL, STEEL], 20, T(0, 0, 0.50) @ PX) def boom_half_width(x): """half width of the boom's beam at boom-frame x (from the BOOM sections of build_dockarm.py)""" for (x0, w0, *_a), (x1, w1, *_b) in zip(BOOM[:-1], BOOM[1:]): if x0 <= x <= x1: return w0 + (w1 - w0) * (x - x0) / (x1 - x0) return BOOM[0][1] if x < BOOM[0][0] else BOOM[-1][1] def hose_boom_parts(mb): """on the boom: clips that hold the climb to the beam's side, saddles on its top, the bar that carries the lane leg at the tip""" climb = [s["p"] for s in hose_stations() if s["seg"] == "climb"] for xc in CLIMB_CLIPS_X: p = min(climb, key=lambda q: abs(q.x - xc)) y_in = -(boom_half_width(p.x) - 0.02) # 2 cm into the beam's side mb.box((0.05, (p.y + 0.02) - y_in, 0.03), (p.x, ((p.y + 0.02) + y_in) / 2, p.z - HOSE_R - 0.019), GRAPH, bevel=0.004) # top 4 mm under the film for x in (3.3, 4.2, 5.1): mb.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 mb.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.13, 0.335), (0.16, 0.335), (0.16, 0.375), (0.13, 0.375), (0.13, 0.335)], [STEEL, STEEL, STEEL, STEEL], 20, T(0, LANE_Y, -WRIST_DROP) @ Matrix.Rotation(math.pi / 2, 4, 'Y')) # a ring the hose runs into (bore 0.13 > hose 0.11), not a disc; 10 cm further out than first built pts = [(0.37, LANE_Y, -WRIST_DROP), (0.50, -0.84, -0.04), (0.58, -0.74, -0.07), (0.54, -0.58, -0.11), (0.44, -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) hh = BAND_R - HOSE_R * 0.96 pv = [0.0, hh, hh + BAND_W, 2 * hh + BAND_W, 2 * hh + 2 * BAND_W] # metres round the band's section ua = lambda k_: 2 * math.pi * BAND_R * k_ / 16 # metres round the hose 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 uvs = {A[k]: (ua(k), pv[j]), B[k]: (ua(k), pv[j + 1]), B[k2]: (ua(k + 1), pv[j + 1]), A[k2]: (ua(k + 1), pv[j])} for l in f.loops: l[uvl].uv = uvs[l.vert] 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