"""A copy of the robot cell with the moon pool's door robot arm (version B, the one in the ship) in place of the cell's robot, headless (the user, 2026-10-22: "create another copy with robot arm replaced with moon pool door arm"): blender.exe -b ../RobotCell.blend --python rc_doorarm.py -> ../RobotCell_DoorArm.blend (RobotCell.blend untouched) From models/door-robot-arm/DoorRobotArm.blend, scene "DoorArm | Showroom", the arm "Showroom | arm B" at rest (1.6x ship size): turret, lower arm, upper arm, forearm, wrist head, flange, the plate (tool B), the J3 hydraulic cylinder and its piston, two elbow cables and two cylinder hoses. Left out: the rail carriage and its pinion (there is no rail in the cell); in their place a plain black base ring of mine on the cell's pedestal. Scaled S = 0.28 so it fits the cell: reach from the shoulder about 2.0 m, at rest 1.11 m tall over its base, under the ceiling's lights. The arm's plate is replaced by a welding torch of mine (the user: "make a welding tip instead of plain plate"): a mounting plate, an insulator, a collision sensor, a neck clamp, a chrome swan neck bent 45 degrees, a copper gas nozzle, the contact tip and 12 mm of wire, and the torch hose back along the forearm. Its bend lets the tip point down although this arm's wrist only bends 20 degrees that way. The work cycle is a weld: down onto the turntable, held while the plate turns once under the torch. Rig like the cell's robot: an armature RC_DoorArm_Rig (bones J1..J6, Tool, J3_Cylinder, J3_Piston; J3_Pin a helper), the parts joined into one rigid-skinned mesh SK_RobotCell_DoorArm (cables weighted between the parts they join). The cylinder follows J3 by constraints (Damped Track to the pin, Copy Location of the rod eye): no Python needed. Joint axes and limits are the door arm's own (DoorRobotArm_layout.json), turned into this rig's signs (+ about the arm's +y leans forward / tips down); poses solved by inverse kinematics like the cell robot's. """ import bpy, bmesh, math, os, json import numpy as np from mathutils import Vector, Matrix from mathutils.bvhtree import BVHTree HERE = os.path.dirname(os.path.abspath(__file__)) FOLDER = os.path.dirname(HERE) SRC = os.path.normpath(os.path.join(FOLDER, "..", "door-robot-arm", "DoorRobotArm.blend")) OUT = os.path.join(FOLDER, "RobotCell_DoorArm.blend") exec(open(os.path.join(HERE, "rc_lib.py")).read()) exec(open(os.path.join(HERE, "rc_robot.py")).read()) # the cell robot: ROB, fk() (its picture pose = targets) S = 0.28 BASE_H = 0.06 # my base ring under the turret PRE = "Showroom | arm B" PARTS = {"turret": "J1", "lower arm": "J2", "upper arm": "J3", "forearm": "J4", "wrist head": "J5", "flange": "J6", "J3 cylinder": "J3_Cylinder", "J3 piston": "J3_Piston"} # tool B (the plate) is replaced by the torch NECK = math.radians(45.0) # the swan neck's bend, toward the arm's -z at rest CABLES = ["cable elbow 1", "cable elbow 2", "J3 hose front", "J3 hose rear"] # the door arm's limits (its own signs: J2, J3 about -Y; J5 about +Y) in this rig's signs (+ about the arm's +Y) DA_LIMITS = {"J1": (-170, 170), "J2": (-45, 95), "J3": (-55, 40), "J4": (-170, 170), "J5": (-120, 120), "J6": (-170, 170)} # narrowed for this copy (rc_check.py sweeps, check_report_doorarm.json): the wrist bends one way only (past +23 the # flange and the plate run into the forearm's fork), the lower arm leaning back past -42 or forward past 72 meets the # turret, J5 at -95 brings the plate onto the forearm; J3 is cut to # where the piston stays in its barrel (worked out in main() from the meshes) DA_LIMITS.update(J2=(-42, 72), J5=(-80, 20), J6=(-55, 55)) # J5 past -80 / J6 past 55: the torch's neck and hose meet the forearm J3_MAX = 16.0 # past it the forearm dips into the turret (from the picture's pose) DA_AXIS = {"J1": "Y", "J2": "X", "J3": "X", "J4": "Y", "J5": "X", "J6": "Y"} ARM_Z = ROB.z + BASE_H # the turret's face (the arm frame's origin) over the pedestal def scene_clean(): for n in ("RC_Robot_Rig", "SK_RobotCell_Robot"): ob = bpy.data.objects.get(n) if ob: data = ob.data bpy.data.objects.remove(ob) if data and data.users == 0: (bpy.data.armatures if isinstance(data, bpy.types.Armature) else bpy.data.meshes).remove(data) for a in list(bpy.data.actions): if a.name.startswith("Robot |"): bpy.data.actions.remove(a) def append_arm(): names = [PRE] + ["%s | %s" % (PRE, n) for n in list(PARTS) + CABLES] with bpy.data.libraries.load(SRC, link=False, relative=True) as (src, dst): dst.objects = [n for n in src.objects if n in names] tmp = bpy.data.collections.new("_doorarm_src") bpy.context.scene.collection.children.link(tmp) for o in dst.objects: tmp.objects.link(o) bpy.context.view_layer.update() return tmp, {o.name: o for o in dst.objects} def part_mesh(ob, root_inv): """the part's evaluated geometry in the arm frame (root's), scaled by S""" dg = bpy.context.evaluated_depsgraph_get() ev = ob.evaluated_get(dg) me = bpy.data.meshes.new_from_object(ev, preserve_all_data_layers=True, depsgraph=dg) me.transform(Matrix.Scale(S, 4) @ root_inv @ ob.matrix_world) if "UV_Tile" in me.uv_layers: # the tiling sets keep their size on the smaller arm a = np.zeros(len(me.loops) * 2, np.float32) me.uv_layers["UV_Tile"].data.foreach_get("uv", a) me.uv_layers["UV_Tile"].data.foreach_set("uv", a * S) return me def fix_image_paths(): """images came with paths relative to the arm's own file: point them at it from here""" src_dir = os.path.dirname(SRC) out = [] for im in bpy.data.images: fp = im.filepath if not fp or not fp.startswith("//") or im.library: continue here = os.path.normpath(bpy.path.abspath(fp, start=FOLDER)) if os.path.exists(here): continue there = os.path.normpath(os.path.join(src_dir, fp[2:])) if os.path.exists(there): im.filepath = bpy.path.relpath(there, start=FOLDER) out.append(im.name) return out def build(): scene_clean() tmp, objs = append_arm() root = objs[PRE] root_inv = root.matrix_world.inverted() piv = {} pieces = [] for nm, bone in PARTS.items(): ob = objs["%s | %s" % (PRE, nm)] piv[bone] = (Matrix.Scale(S, 4) @ root_inv @ ob.matrix_world).translation.copy() pieces.append((bone, part_mesh(ob, root_inv), None)) for nm in CABLES: pieces.append((None, part_mesh(objs["%s | %s" % (PRE, nm)], root_inv), nm)) # rigid parts' BVHs to weight the cables between the parts they run along bvh = {} for bone, me, _ in pieces: if bone: bvh[bone] = BVHTree.FromPolygons([v.co.copy() for v in me.vertices], [list(p.vertices) for p in me.polygons]) rob_coll = bpy.data.collections["RC | robot"] made = [] cable_report = {} for bone, me, cname in pieces: ob = bpy.data.objects.new("_da_" + (bone or cname), me) rob_coll.objects.link(ob) if bone: vg = ob.vertex_groups.new(name=bone) vg.add(list(range(len(me.vertices))), 1.0, 'REPLACE') else: cand = ["J2", "J3", "J3_Cylinder", "J3_Piston"] groups = {b: ob.vertex_groups.new(name=b) for b in cand} used = {} for v in me.vertices: d = sorted((bvh[b].find_nearest(v.co)[3] or 9.0, b) for b in cand) (d0, b0), (d1, b1) = d[0], d[1] w0 = (d1 + 1e-4) / (d0 + d1 + 2e-4) if d1 > 3 * d0 + 0.01: # clearly on one part groups[b0].add([v.index], 1.0, 'REPLACE') used[b0] = used.get(b0, 0) + 1 else: groups[b0].add([v.index], w0, 'REPLACE') groups[b1].add([v.index], 1.0 - w0, 'REPLACE') used[b0 + "/" + b1] = used.get(b0 + "/" + b1, 0) + 1 cable_report[cname] = used made.append(ob) # the welding torch (mine), on the flange's face, and its hose back to the forearm xf = max(v.co.x for bone, me, _ in pieces if bone == "J6" for v in me.vertices) tmb, torch = build_torch(Vector((xf, 0.0, piv["J5"].z))) tme, _ = tmb.to_mesh("_da_torch", TORCH_MATS) to = bpy.data.objects.new("_da_torch", tme) rob_coll.objects.link(to) tg = {} for i, g in enumerate(tmb.grp): for bn, w in g.items(): tg.setdefault(bn, []).append((i, w)) for bn, lst in tg.items(): vg = to.vertex_groups.new(name=bn) for i, w in lst: vg.add([i], w, 'REPLACE') plain_uvs(tme) made.append(to) # the base ring (mine): a black bearing housing on the pedestal under the turret bm = MBG() bm.bone("Base") cyl(bm, [(0.0, -BASE_H + 0.001), (0.270, -BASE_H + 0.001), (0.270, -0.012), (0.262, -0.004), (0.240, -0.002), (0.0, -0.002)], (0, 0, 0), Vector((0, 0, 1)), 40, "M_DoorArm_Gunmetal", open0=True) bolt_ring(bm, (0, 0, -0.012), Vector((0, 0, 1)), 0.255, 10, 0.008, 0.006, "M_DoorArm_Steel") bme, _ = bm.to_mesh("_da_base", ["M_DoorArm_Gunmetal", "M_DoorArm_Steel"]) bo = bpy.data.objects.new("_da_base", bme) rob_coll.objects.link(bo) bo.vertex_groups.new(name="Base").add(list(range(len(bme.vertices))), 1.0, 'REPLACE') plain_uvs(bme) made.append(bo) # join into one skinned mesh vl = bpy.context.view_layer for o in list(vl.objects): if o is not None: o.select_set(False) for o in made: o.select_set(True) vl.objects.active = made[0] bpy.ops.object.join() mesh_ob = vl.objects.active mesh_ob.name = "SK_RobotCell_DoorArm" mesh_ob.data.name = "SK_RobotCell_DoorArm" for nm in [l.name for l in mesh_ob.data.uv_layers if l.name not in ("UVMap", "UV_Tile")]: mesh_ob.data.uv_layers.remove(mesh_ob.data.uv_layers[nm]) # my base ring's builder layer mesh_ob.data.shade_smooth() try: mesh_ob.data.set_sharp_from_angle(angle=math.radians(32)) except Exception: pass # drop what came with the append (the source objects, their data) for o in list(tmp.objects): d = o.data bpy.data.objects.remove(o) if d is not None and d.users == 0: if isinstance(d, bpy.types.Mesh): bpy.data.meshes.remove(d) elif isinstance(d, bpy.types.Curve): bpy.data.curves.remove(d) bpy.data.collections.remove(tmp) return mesh_ob, piv, cable_report, torch TORCH_MATS = ["M_DoorArm_Gunmetal", "M_DoorArm_Steel", "M_RC_Torch_Copper", "M_DoorArm_Rubber", "M_RC_Gap"] def plain_uvs(me): """UVMap parked on a neutral corner of the arm's mask atlas (my parts carry no baked masks); UV_Tile in metres""" for nm in [l.name for l in me.uv_layers]: me.uv_layers.remove(me.uv_layers[nm]) me.uv_layers.new(name="UVMap").data.foreach_set("uv", np.full(len(me.loops) * 2, 0.002, np.float32)) tl = me.uv_layers.new(name="UV_Tile") for p in me.polygons: n = p.normal ax = 0 if abs(n.x) >= abs(n.y) and abs(n.x) >= abs(n.z) else (1 if abs(n.y) >= abs(n.z) else 2) for li in range(p.loop_start, p.loop_start + p.loop_total): c = me.vertices[me.loops[li].vertex_index].co tl.data[li].uv = (c.y, c.z) if ax == 0 else ((c.x, c.z) if ax == 1 else (c.x, c.y)) def build_torch(F): """a robot MIG welding torch on the flange face F (arm frame, the tool axis +x at rest); returns the builder and the tip (the wire's end) and the nozzle's direction""" mb = MBG() X, Z = Vector((1, 0, 0)), Vector((0, 0, 1)) GM, ST, CU, RB, GP = TORCH_MATS mb.bone("Tool") cyl(mb, [(0.0, 0.0), (0.040, 0.0), (0.040, 0.008), (0.036, 0.011), (0.0, 0.011)], F, X, 32, GM, open0=True) # mount bolt_ring(mb, F + X * 0.011, X, 0.0335, 6, 0.0035, 0.003, ST, phase=math.pi / 6) cyl(mb, [(0.0, 0.011), (0.030, 0.011), (0.030, 0.020), (0.0, 0.020)], F, X, 32, GM) # insulator cyl(mb, [(0.0, 0.020), (0.027, 0.020), (0.027, 0.034), (0.024, 0.036), (0.024, 0.040), (0.027, 0.042), (0.027, 0.070), (0.023, 0.075), (0.0, 0.075)], F, X, 32, ST) # collision sensor cyl(mb, [(0.0, 0.075), (0.019, 0.075), (0.019, 0.098), (0.015, 0.102), (0.0, 0.102)], F, X, 24, GM) # neck clamp # the swan neck: straight, a 45 degree bend toward -z, straight again rb = 0.05 P1 = F + X * 0.135 C = P1 - Z * rb path = [F + X * 0.098, F + X * 0.118, P1] + [C + rb * (math.sin(a) * X + math.cos(a) * Z) for a in (NECK / 3, 2 * NECK / 3, NECK)] d = (math.cos(NECK) * X - math.sin(NECK) * Z).normalized() E = path[-1] + d * 0.040 path += [path[-1] + d * 0.020, E] tube(mb, path, 0.0105, 16, ST, n_per=4) # gas nozzle (copper), the diffuser's face inside it (dark), the contact tip and the wire cyl(mb, [(0.0105, -0.004), (0.0125, 0.004), (0.0125, 0.040), (0.0104, 0.058), (0.0096, 0.060), (0.0077, 0.060), (0.0075, 0.056), (0.0075, 0.018), (0.0, 0.018)], E, d, 32, [CU, CU, CU, CU, CU, CU, CU, GP]) cyl(mb, [(0.0, 0.018), (0.0045, 0.018), (0.0045, 0.040), (0.0030, 0.064), (0.0026, 0.070), (0.0, 0.070)], E, d, 16, CU) cyl(mb, [(0.0, 0.070), (0.0006, 0.070), (0.0006, 0.082), (0.0, 0.082)], E, d, 8, ST) tip = E + d * 0.082 # the torch hose: from the sensor's top, over the wrist, along the forearm into it (Tool -> J5 -> J4) zw = F.z pts = [F + X * 0.050 + Z * 0.022, F + X * 0.022 + Z * 0.070, Vector((F.x - 0.10, 0, zw + 0.112)), Vector((0.42, 0, zw + 0.124)), Vector((0.22, 0, zw + 0.124)), Vector((0.10, 0, zw + 0.100))] cable(mb, pts, 0.011, blend((0.0, "Tool"), (0.18, "Tool"), (0.34, "J5"), (0.46, "J4"), (1.0, "J4")), RB, nseg=12) return mb, dict(flange_face=[round(c, 4) for c in F], tip=tip, dir=d) def build_rig(mesh_ob, piv, torch): trunnion, pin = piv["J3_Cylinder"], piv["J3_Piston"] wrist = piv["J5"] tool_face = Vector(torch["flange_face"]) tip = torch["tip"] up = Vector((0, 0, 1)) Yr = Vector((0, 1, 0)) roll = lambda d: Yr.cross(Vector(d).normalized()) # makes the bone's local X the arm's +Y (bend joints) bones = [ # name, parent, head, tail, roll-to, deform ("Base", None, (0, 0, -BASE_H), (0, 0, 0.0), (1, 0, 0), True), ("J1", "Base", (0, 0, 0), (0, 0, piv["J2"].z), (1, 0, 0), True), ("J2", "J1", piv["J2"], piv["J3"], roll(piv["J3"] - piv["J2"]), True), ("J3", "J2", piv["J3"], piv["J4"], roll(piv["J4"] - piv["J3"]), True), ("J4", "J3", piv["J4"], wrist, (0, 0, -1), True), ("J5", "J4", wrist, wrist + Vector((0.06, 0, 0)), (0, 0, -1), True), ("J6", "J5", wrist + Vector((0.0001, 0, 0)), wrist + Vector((0.05, 0, 0)), (0, 0, -1), True), ("Tool", "J6", tool_face, tip, (0, 1, 0), True), ("J3_Cylinder", "J2", trunnion, pin, roll(pin - trunnion), True), ("J3_Piston", "J3_Cylinder", pin, pin + (trunnion - pin).normalized() * 0.12, roll(trunnion - pin), True), ("J3_Pin", "J3", pin, pin + Vector((0, 0.04, 0)), (0, 0, 1), False), ] data = bpy.data.armatures.new("RC_DoorArm_Rig") arm = bpy.data.objects.new("RC_DoorArm_Rig", data) bpy.data.collections["RC | robot"].objects.link(arm) data.display_type = 'OCTAHEDRAL' arm.show_in_front = True vl = bpy.context.view_layer vl.update() for o in list(vl.objects): if o is not None: o.select_set(False) arm.select_set(True) vl.objects.active = arm bpy.ops.object.mode_set(mode='EDIT') eb = data.edit_bones for nm, par, h, t, r, dfm in bones: b = eb.new(nm) b.head, b.tail = Vector(h), Vector(t) b.align_roll(Vector(r)) b.use_deform = dfm if par: b.parent = eb[par] bpy.ops.object.mode_set(mode='OBJECT') for pb in arm.pose.bones: pb.rotation_mode = 'XYZ' ax = DA_AXIS.get(pb.name) pb.lock_location = (True, True, True) pb.lock_scale = (True, True, True) pb.lock_rotation = tuple(c != ax for c in "XYZ") if ax else (True, True, True) lim = DA_LIMITS.get(pb.name) if lim: con = pb.constraints.new('LIMIT_ROTATION') con.name = "Joint limit" con.owner_space = 'LOCAL' setattr(con, "use_limit_" + ax.lower(), True) setattr(con, "min_" + ax.lower(), math.radians(lim[0])) setattr(con, "max_" + ax.lower(), math.radians(lim[1])) cy = arm.pose.bones["J3_Cylinder"].constraints.new('DAMPED_TRACK') cy.name = "Aim at the knob pin" cy.target, cy.subtarget, cy.track_axis = arm, "J3_Pin", 'TRACK_Y' pi = arm.pose.bones["J3_Piston"].constraints.new('COPY_LOCATION') pi.name = "Rod eye on the knob pin" pi.target, pi.subtarget = arm, "J3_Pin" arm.location = (ROB.x, ROB.y, ARM_Z) arm.parent = bpy.data.objects["RobotCell_Root"] mesh_ob.parent = arm mesh_ob.matrix_parent_inverse = Matrix.Identity(4) mesh_ob.location = (0, 0, 0) mod = mesh_ob.modifiers.new("Armature", 'ARMATURE') mod.object = arm arm["axes"] = json.dumps(DA_AXIS) arm["limits_deg"] = json.dumps(DA_LIMITS) arm["skip_pairs"] = json.dumps([["J2", "J3_Cylinder"], ["J3", "J3_Piston"], ["J3_Cylinder", "J3_Piston"], ["J6", "Tool"], ["J5", "J6"], ["J4", "J5"], ["Base", "J1"]]) arm["scale_of_door_arm_B"] = S return arm, dict(pivots={k: [round(c, 4) for c in v] for k, v in piv.items()}, tool_face=[round(c, 4) for c in tool_face], tip=[round(c, 4) for c in tip], tip_dir=[round(c, 4) for c in torch["dir"]]) # ------------------------------------------------------------------ kinematics of the door arm (arm frame, bind = rest) def fk_da(th, piv, tip_rel): g = lambda k: math.radians(th.get(k, 0.0)) T = {} T["J1"] = Matrix.Rotation(g("J1"), 4, 'Z') T["J2"] = T["J1"] @ Matrix.Translation(piv["J2"]) @ Matrix.Rotation(g("J2"), 4, 'Y') T["J3"] = T["J2"] @ Matrix.Translation(piv["J3"] - piv["J2"]) @ Matrix.Rotation(g("J3"), 4, 'Y') T["J4"] = T["J3"] @ Matrix.Translation(piv["J4"] - piv["J3"]) @ Matrix.Rotation(g("J4"), 4, 'X') T["J5"] = T["J4"] @ Matrix.Translation(piv["J5"] - piv["J4"]) @ Matrix.Rotation(g("J5"), 4, 'Y') T["J6"] = T["J5"] @ Matrix.Rotation(g("J6"), 4, 'X') T["FACE"] = T["J6"] @ Matrix.Translation(tip_rel) @ Matrix.Rotation(NECK, 4, 'Y') # the wire's end, x along the nozzle return T def solve(piv, tfx, target_w, axis_w, seed, keys=("J1", "J2", "J3", "J5"), w_axis=0.3): """angles (deg) putting the torch's tip (the wire's end) on target_w with the nozzle along axis_w (J4 = J6 = 0); tfx is the tip's offset from the wrist centre at rest""" origin = Vector((ROB.x, ROB.y, ARM_Z)) tgt = Vector(target_w) - origin ax = Vector(axis_w).normalized() q = np.array([seed.get(k, 0.0) for k in keys], float) def res(q): th = dict(zip(keys, q)) T = fk_da(th, piv, tfx) face = T["FACE"].translation d = (T["FACE"].to_3x3() @ Vector((1, 0, 0))).normalized() pen = [max(0.0, lo - th[k]) + max(0.0, th[k] - hi) for k in keys for lo, hi in [DA_LIMITS[k]]] wall = [max(0.0, WALL_X - (origin.x + (T[b] @ (p - piv[b])).x)) * 5.0 for b, pts in TAIL.items() for p in pts] return np.array(list(face - tgt) + list((d - ax) * w_axis) + [p * 0.05 for p in pen] + wall) lam = 1e-2 r = res(q) for _ in range(400): J = np.zeros((len(r), len(q))) for k in range(len(q)): dq = np.zeros(len(q)); dq[k] = 1e-3 J[:, k] = (res(q + dq) - r) / 1e-3 A, gg = J.T @ J, J.T @ r st = np.linalg.solve(A + lam * np.eye(len(q)), -gg) r2 = res(q + st) if r2 @ r2 < r @ r: q, r = q + st, r2 lam *= 0.5 else: lam *= 4 if r @ r < 1e-12 or lam > 1e9: break out = dict(zip(keys, [float(x) for x in q])) for k in ("J1", "J2", "J3", "J4", "J5", "J6"): out.setdefault(k, 0.0) T = fk_da(out, piv, tfx) d = (T["FACE"].to_3x3() @ Vector((1, 0, 0))).normalized() return out, (float(np.sqrt(r[:3] @ r[:3])), round(math.degrees(d.angle(ax)), 1)) WALL_X = -1.470 # the left glass's inner face is at -1.496 TAIL = {} # bone -> points of its part farthest behind (filled in main) def set_pose_da(arm, th): for nm, ax in DA_AXIS.items(): e = [0.0, 0.0, 0.0] e["XYZ".index(ax)] = math.radians(th.get(nm, 0.0)) arm.pose.bones[nm].rotation_euler = e def dress_copper(): """the torch's nozzle and contact tip: copper, slightly worn (plain nodes: no baked masks on my parts)""" m = bpy.data.materials.get("M_RC_Torch_Copper") or bpy.data.materials.new("M_RC_Torch_Copper") m.use_nodes = True nt = m.node_tree nt.nodes.clear() b = nt.nodes.new('ShaderNodeBsdfPrincipled') o = nt.nodes.new('ShaderNodeOutputMaterial') nt.links.new(b.outputs[0], o.inputs["Surface"]) b.inputs["Base Color"].default_value = (0.82, 0.42, 0.25, 1) b.inputs["Metallic"].default_value = 1.0 nz = nt.nodes.new('ShaderNodeTexNoise') nz.inputs["Scale"].default_value = 180.0 mr = nt.nodes.new('ShaderNodeMapRange') mr.inputs["To Min"].default_value = 0.22 mr.inputs["To Max"].default_value = 0.40 nt.links.new(nz.outputs["Fac"], mr.inputs["Value"]) nt.links.new(mr.outputs[0], b.inputs["Roughness"]) m.diffuse_color = (0.82, 0.42, 0.25, 1) m.metallic = 1.0 def main(): mesh_ob, piv, cables, torch = build() # J3's range where the piston stays in its barrel: barrel and rod measured along the cylinder from the meshes T0, K0, P3 = piv["J3_Cylinder"], piv["J3_Piston"], piv["J3"] u = (K0 - T0).normalized() gi = {g.name: g.index for g in mesh_ob.vertex_groups} tb_, rp_ = [], [] for v in mesh_ob.data.vertices: gs = {g.group: g.weight for g in v.groups} if gs.get(gi["J3_Cylinder"], 0) > 0.999: tb_.append((v.co - T0).dot(u)) elif gs.get(gi["J3_Piston"], 0) > 0.999: rp_.append((K0 - v.co).dot(u)) lo_L, hi_L = min(tb_) + max(rp_) + 0.01, max(tb_) + max(rp_) - 0.02 ok = [a for a in np.arange(-55.0, 40.01, 0.5) if lo_L <= ((Matrix.Rotation(math.radians(a), 3, 'Y') @ (K0 - P3)) + P3 - T0).length <= hi_L] DA_LIMITS["J3"] = (float(math.ceil(min(ok))), min(float(math.floor(max(ok))), J3_MAX)) arm, info = build_rig(mesh_ob, piv, torch) info["j3_limits_from_stroke"] = DA_LIMITS["J3"] tip_rel = torch["tip"] - piv["J5"] dress_copper() for b in ("J2", "J3"): # the parts that reach farthest behind and beside the turret g = gi[b] pts = [v.co.copy() for v in mesh_ob.data.vertices if any(x.group == g and x.weight > 0.999 for x in v.groups)] TAIL[b] = pts[:: max(1, len(pts) // 250)] # an even sample of the whole part # the picture's pose: the torch's tip where the cell robot's tool works in the picture, pointing the same way pic = json.load(open(os.path.join(HERE, "pose_fit.json")))["pose_deg"] T = fk({k: math.radians(v) for k, v in pic.items()}) tip_w = ROB + T["TIP"].translation tdir = (T["TIP"].translation - T["FL"].translation).normalized() seed = dict(J1=pic["A1"], J2=40.0, J3=-10.0, J5=10.0) P_pic, e0 = solve(piv, tip_rel, tip_w, tdir, seed, w_axis=0.15) # the weld: the wire's end 4 mm over the turntable plate, 0.28 m from its axis on the robot's side; the torch down, # leaning 15 degrees back from the robot (a pushing torch); the plate turns once under it W = Vector((0.58 - 0.28, -0.12, 0.72 + 0.004)) dxy = (W - ROB).to_2d().normalized() lean = math.radians(15.0) down = Vector((dxy.x * math.sin(lean), dxy.y * math.sin(lean), -math.cos(lean))) P_up, e1 = solve(piv, tip_rel, W + Vector((0, 0, 0.18)), down, P_pic, w_axis=0.15) P_weld, e2 = solve(piv, tip_rel, W, down, P_up, w_axis=0.15) for nm, (lo, hi) in DA_LIMITS.items(): for P in (P_pic, P_up, P_weld): P[nm] = min(max(P[nm], lo), hi) keys = [(1, P_pic), (30, P_pic), (70, P_up), (85, P_weld), (205, P_weld), (225, P_up), (260, P_pic), (460, P_pic)] arm.animation_data_create() for f, P in keys: set_pose_da(arm, P) for nm, ax in DA_AXIS.items(): arm.pose.bones[nm].keyframe_insert("rotation_euler", index="XYZ".index(ax), frame=f) arm.animation_data.action.name = "DoorArm | WELD_CYCLE" # the turntable turns once while the torch holds (the root's demo action: its turntable keys only) root = bpy.data.objects["RobotCell_Root"] for f in (1, 195, 260): try: root.keyframe_delete('["turntable"]', frame=f) except Exception: pass for f, v in ((1, 0.0), (85, 0.0), (205, 360.0)): root["turntable"] = v root.keyframe_insert('["turntable"]', frame=f) root["turntable"] = 0.0 arm["poses_deg"] = json.dumps(dict(picture=P_pic, weld_up=P_up, weld=P_weld)) arm["ik_error"] = json.dumps(dict(zip(("picture", "weld_up", "weld"), [[round(e[0], 4), e[1]] for e in (e0, e1, e2)]))) arm["limits_deg"] = json.dumps(DA_LIMITS) for nm, (lo, hi) in DA_LIMITS.items(): # the constraints carry the narrowed limits con = arm.pose.bones[nm].constraints["Joint limit"] ax = DA_AXIS[nm].lower() setattr(con, "min_" + ax, math.radians(lo)) setattr(con, "max_" + ax, math.radians(hi)) sc = bpy.context.scene sc.frame_set(1) paths = fix_image_paths() for m in list(bpy.data.materials): if m.users == 0: bpy.data.materials.remove(m) bpy.ops.wm.save_as_mainfile(filepath=OUT, relative_remap=True) rep = dict(info, cables=cables, poses={k: {j: round(v, 2) for j, v in P.items()} for k, P in dict(picture=P_pic, weld_up=P_up, weld=P_weld).items()}, ik_error_m_deg=[[round(e[0], 4), e[1]] for e in (e0, e1, e2)], images_repointed=paths, limits=DA_LIMITS, weld_point=[round(c, 4) for c in W], triangles=sum(len(p.vertices) - 2 for p in mesh_ob.data.polygons), materials=[m.name for m in mesh_ob.data.materials]) with open(os.path.join(HERE, "doorarm_report.json"), "w") as fh: json.dump(rep, fh, indent=1, default=str) print("DOORARM OK", json.dumps(rep, default=str)[:3000]) main()