"""PROOF of the door robot arm design (headless Blender, reads only, saves nothing): the closed-form IK over the doors' real animation (frames 1-120, step 1), for a door and a version. - joint angles inside the limits, no singularity (wrist |q5|, elbow inner angle, WC off the J1 axis), no jumps - the flange pose error (forward kinematics through the object chain the rig will use vs the door target): ~0 - carriage inside its travel; J3 cylinder length, stroke and piston inside the barrel; barrel rear inside the lower arm - coarse clearance: oriented-box proxies per link (design_parts.PROXIES, at the design's sizes) against the context meshes (deck, both rails, end walls, hull lining, basin channel, aperture rim, lamps and housings, the doors) and the added rail kit (end stops, chain tray); self-clearance between links that can meet Run: blender -b --factory-startup source/ship_context_2026-10-16.blend --python build/design_ik_check.py -- AFT A build/check_AFT_A.json """ import bpy, json, math, sys, os, time from mathutils import Matrix, Vector from mathutils.bvhtree import BVHTree HERE = os.path.dirname(os.path.abspath(__file__)) ROOT = os.path.dirname(HERE) sys.path.insert(0, HERE) import design_kin as K import design_parts as DP argv = sys.argv[sys.argv.index("--") + 1:] DOOR_KEY, VERSION, OUT = argv[0], argv[1], argv[2] STEP = int(argv[3]) if len(argv) > 3 else 1 SPACING = float(argv[4]) if len(argv) > 4 else 0.05 D = json.load(open(os.environ.get("DRA_OUT", os.path.join(ROOT, "design.json")))) S = D["scale"]["m_per_ref1_px"] KIN = D["kinematics"] DR = D["doors"][DOOR_KEY] TOOL = D["tools"][VERSION] arm = K.Arm(S) t0 = time.time() scn = bpy.context.scene for o in bpy.data.objects: if not o.users_scene: scn.collection.objects.link(o) scn.frame_start, scn.frame_end = 1, 120 bpy.context.view_layer.update() door = bpy.data.objects[DR["door"]] # ---- context BVHs -------------------------------------------------------------------------------------------------- STATIC = ["Moonpool | deck around open well", "Pocket AFT | upper rail", "Pocket FORE | upper rail", "Pocket AFT | conformal closed end", "Pocket FORE | conformal closed end", "Moonpool | hull lining", "Moonpool | curved dry basin channel", "Moonpool | titanium aperture rim", "Moonpool | docking lamp", "Moonpool | docking lamp.001", "Moonpool | recessed docking lamp housing", "Moonpool | recessed docking lamp housing.001"] DOORS = ["Moonpool_Door_Aft", "Moonpool_Door_Fore"] scn.frame_set(1) dg = bpy.context.evaluated_depsgraph_get() def mesh_tris(o, world=True): ev = o.evaluated_get(dg) me = ev.to_mesh() me.calc_loop_triangles() mw = ev.matrix_world.copy() if world else Matrix.Identity(4) vs = [mw @ v.co for v in me.vertices] ts = [tuple(t.vertices) for t in me.loop_triangles] ev.to_mesh_clear() return vs, ts CTX = {} for n in STATIC: vs, ts = mesh_tris(bpy.data.objects[n]) lo = Vector((min(v.x for v in vs), min(v.y for v in vs), min(v.z for v in vs))) hi = Vector((max(v.x for v in vs), max(v.y for v in vs), max(v.z for v in vs))) CTX[n] = dict(bvh=BVHTree.FromPolygons(vs, ts, epsilon=0.0), lo=lo, hi=hi, moving=None) for n in DOORS: o = bpy.data.objects[n] vs, ts = mesh_tris(o, world=False) # door local: the door only translates lo = Vector((min(v.x for v in vs), min(v.y for v in vs), min(v.z for v in vs))) hi = Vector((max(v.x for v in vs), max(v.y for v in vs), max(v.z for v in vs))) CTX[n] = dict(bvh=BVHTree.FromPolygons(vs, ts, epsilon=0.0), lo=lo, hi=hi, moving=o) def add_box(name, lo, hi): lo, hi = Vector(lo), Vector(hi) vs = [Vector((x, y, z)) for x in (lo.x, hi.x) for y in (lo.y, hi.y) for z in (lo.z, hi.z)] fs = [(0, 1, 3, 2), (4, 6, 7, 5), (0, 4, 5, 1), (2, 3, 7, 6), (0, 2, 6, 4), (1, 5, 7, 3)] CTX[name] = dict(bvh=BVHTree.FromPolygons(vs, fs, epsilon=0.0), lo=lo, hi=hi, moving=None) # added rail-kit obstacles (our own static parts): end stops and the chain tray of THIS pocket (mirrored for FORE) mx = (lambda x: x) if DOOR_KEY == "AFT" else (lambda x: 2 * D["scene"]["mirror_plane_x"] - x) for nm, (x0, x1), (y0, y1), (z0, z1) in (("kit | end stop wall", (-8.685, -8.555), (-5.945, -5.645), (-4.13, -3.98)), ("kit | end stop inner", (-2.575, -2.475), (-5.945, -5.645), (-4.13, -3.98)), ("kit | chain tray", (-9.00, -2.95), (-5.28, -4.88), (-3.905, -3.775))): a, b = sorted((mx(x0), mx(x1))) add_box(nm, (a, y0, z0), (b, y1, z1)) # ---- proxies ------------------------------------------------------------------------------------------------------- ORIG = { # link origin in the A frame at rest (px) "Turret": (0, 0, 0), "LowerArm": K.PX["J2"], "UpperArm": K.PX["J3"], "Forearm": (-35, 937), "WristHead": K.PX["WC"], "ToolA": K.PX["WC"], "ToolB": K.PX["WC"], } def o3(p): return Vector((p[0], 0, p[1])) if len(p) == 2 else Vector(p) def box_samples(h, spacing): """points on a box surface (local, metres), grid <= spacing""" pts = [] for ax in range(3): a1, a2 = [i for i in range(3) if i != ax] n1 = max(1, int(math.ceil(2 * h[a1] / spacing))) n2 = max(1, int(math.ceil(2 * h[a2] / spacing))) for sgn in (-1, 1): for i in range(n1 + 1): for j in range(n2 + 1): p = [0, 0, 0] p[ax] = sgn * h[ax] p[a1] = -h[a1] + 2 * h[a1] * i / n1 p[a2] = -h[a2] + 2 * h[a2] * j / n2 pts.append(Vector(p)) return pts def cyl_samples(r, hl, ax, spacing): pts = [] n = max(12, int(math.ceil(2 * math.pi * r / spacing))) m = max(1, int(math.ceil(2 * hl / spacing))) rings = max(1, int(math.ceil(r / spacing))) for i in range(n): a = 2 * math.pi * i / n for j in range(m + 1): pts.append((r * math.cos(a), -hl + 2 * hl * j / m, r * math.sin(a))) for k in range(1, rings + 1): rr = r * k / rings for sgn in (-1, 1): pts.append((rr * math.cos(a), sgn * hl, rr * math.sin(a))) pts.append((0, -hl, 0)) pts.append((0, hl, 0)) out = [] for (u, a, w) in pts: # (radial1, axial, radial2) -> local by axis out.append(Vector((a, u, w)) if ax == "X" else (Vector((u, a, w)) if ax == "Y" else Vector((u, w, a)))) return out def hull_pts(p): if "r" in p: r, hl, ax = p["r"], p["hl"], p["ax"] vs = [] for sgn in (-1, 1): for i in range(16): a = 2 * math.pi * i / 16 u, w = r / math.cos(math.pi / 16) * math.cos(a), r / math.cos(math.pi / 16) * math.sin(a) vs.append(Vector((sgn * hl, u, w)) if ax == "X" else (Vector((u, sgn * hl, w)) if ax == "Y" else Vector((u, w, sgn * hl)))) fs = [tuple(range(16)), tuple(range(31, 15, -1))] + [(i, (i + 1) % 16, 16 + (i + 1) % 16, 16 + i) for i in range(16)] return vs, fs h = p["h"] vs = [Vector((sx * h.x, sy * h.y, sz * h.z)) for sx in (-1, 1) for sy in (-1, 1) for sz in (-1, 1)] return vs, [(0, 1, 3, 2), (4, 6, 7, 5), (0, 4, 5, 1), (2, 3, 7, 6), (0, 2, 6, 4), (1, 5, 7, 3)] PROX = [] for link, lst in DP.PROXIES.items(): if link.startswith("Tool") and link != "Tool" + VERSION: continue for b in lst: c = Vector(b["c"]) * S tilt = math.radians(b.get("tilt", 0.0)) local = Matrix.Translation(c - o3(ORIG[link]) * S) @ Matrix.Rotation(-tilt, 4, 'Y') @ Matrix.Rotation(math.radians(b.get("tiltZ", 0.0)), 4, 'Z') if "r" in b: r, hl = b["r"] * S, b["hl"] * S ext = Vector((hl, r, r)) if b["ax"] == "X" else (Vector((r, hl, r)) if b["ax"] == "Y" else Vector((r, r, hl))) pr = dict(link=link, n=b["n"], r=r, hl=hl, ax=b["ax"], h=ext, local=local, samples=cyl_samples(r, hl, b["ax"], SPACING)) else: h = Vector(b["h"]) * S pr = dict(link=link, n=b["n"], h=h, local=local, samples=box_samples(h, SPACING)) pr["hull"] = hull_pts(pr) PROX.append(pr) print("proxies", len(PROX), "sample points", sum(len(p["samples"]) for p in PROX)) SELF_PAIRS = [("Turret", "UpperArm"), ("Turret", "Forearm"), ("Turret", "WristHead"), ("Turret", "Tool" + VERSION), ("LowerArm", "Forearm"), ("LowerArm", "WristHead"), ("LowerArm", "Tool" + VERSION), ("UpperArm", "Tool" + VERSION), ("UpperArm", "WristHead"), ("Carriage", "LowerArm"), ("Carriage", "UpperArm"), ("Carriage", "Forearm"), ("Carriage", "WristHead"), ("Carriage", "Tool" + VERSION)] # the upper arm folding onto the lower arm (adjacent links, but bodies away from the shared elbow can meet) SELF_NAMED = [(("UpperArm", "housing"), ("LowerArm", "beam core")), (("UpperArm", "housing"), ("LowerArm", "root+beam core")), (("UpperArm", "housing"), ("LowerArm", "knee caps")), (("UpperArm", "front block"), ("LowerArm", "beam core")), (("UpperArm", "truss"), ("LowerArm", "beam core")), (("UpperArm", "knob"), ("LowerArm", "beam core")), (("UpperArm", "knob"), ("LowerArm", "beam sides")), (("UpperArm", "knob"), ("LowerArm", "beam sides R")), (("UpperArm", "truss"), ("LowerArm", "knee caps")), (("UpperArm", "knob"), ("LowerArm", "knee caps")), (("UpperArm", "knob"), ("LowerArm", "handle L")), (("UpperArm", "knob"), ("LowerArm", "handle R")), (("UpperArm", "gland+cables"), ("LowerArm", "yoke L")), (("UpperArm", "front block"), ("LowerArm", "knee caps"))] for _tp in (("hub", "fingers (closed envelope)", "finger roots", "finger motor top", "finger motor bottom", "finger motor left", "finger motor right") if VERSION == "A" else ("neck", "plate")): for _fp in ("wrist block", "wrist lower web", "wrist lobe L", "wrist lobe R", "motor cyl", "hose+handle", "dial housing", "belly"): SELF_NAMED.append((("Tool" + VERSION, _tp), ("Forearm", _fp))) # the lower arm's outer parts against the turret (adjacent at J2, but the knee and beam swing past the turret's housings) for _la in ("knee caps", "beam sides L", "beam sides R", "beam core", "yoke L", "yoke R", "elbow caps", "handle L", "handle R"): for _tb in ("lower block", "front housing", "ear L", "ear R", "cap L", "cap R", "belt drive", "cable bundle", "side cables L", "side cables R"): SELF_NAMED.append((("LowerArm", _la), ("Turret", _tb))) # ---- frames ------------------------------------------------------------------------------------------------------ yb = D["carriage"]["centre_y"] zb = D["carriage"]["underside_z"] c_lo, c_hi = sorted(D["carriage"]["travel_ends"][DOOR_KEY]) CA, CB = DR["carriage"]["A"], DR["carriage"]["B"] P_L = Vector(DR["attach_local"]) xt_w = Vector(DR["tool_axes_world"]["x_t"]); yt_w = Vector(DR["tool_axes_world"]["y_t"]); zt_w = Vector(DR["tool_axes_world"]["z_t"]) tool_c = (K.w2c(tuple(xt_w)), K.w2c(tuple(yt_w)), K.w2c(tuple(zt_w))) d_t = TOOL["d_t"] ROOT_M = Matrix.Translation((0, yb, zb)) @ Matrix.Rotation(math.pi, 4, 'X') J2v = o3(K.PX["J2"]) * S J3v = o3(K.PX["J3"]) * S F0v = Vector((-35, 0, 937)) * S WCv = o3(K.PX["WC"]) * S TRv = o3(K.PX["knee"]) * S KNv = o3(K.PX["knob"]) * S d0 = (KNv - TRv) L0 = d0.length beta0 = math.atan2(d0.z, d0.x) BARREL_BACK, BARREL_FRONT, ROD_LEN = 0.30, 0.50, 0.80 def Rx(a): return Matrix.Rotation(a, 4, 'X') def Ry(a): return Matrix.Rotation(a, 4, 'Y') def Rz(a): return Matrix.Rotation(a, 4, 'Z') def T(v): return Matrix.Translation(v) def chain(c, q): M = {} M["Carriage"] = ROOT_M @ T((c, 0, 0)) M["Turret"] = M["Carriage"] @ Rz(q["q1"]) M["LowerArm"] = M["Turret"] @ T(J2v) @ Ry(-q["q2"]) M["UpperArm"] = M["LowerArm"] @ T(J3v - J2v) @ Ry(-q["q3"]) M["Forearm"] = M["UpperArm"] @ T(F0v - J3v) @ Rx(q["q4"]) M["WristHead"] = M["Forearm"] @ T(WCv - F0v) @ Ry(q["q5"]) M["Flange"] = M["WristHead"] @ Rx(q["q6"]) M["ToolA"] = M["Flange"] M["ToolB"] = M["Flange"] # J3 cylinder in the lower-arm frame kn = (J3v - J2v) + (Ry(-q["q3"]).to_3x3() @ (KNv - J3v)) tr = TRv - J2v v = kn - tr L = v.length beta = math.atan2(v.z, v.x) M["Cylinder"] = M["LowerArm"] @ T(tr) @ Ry(-(beta - beta0)) rear_local = tr - v.normalized() * BARREL_BACK # barrel rear end, lower-arm frame return M, L, beta - beta0, rear_local CAR_H = Vector((D["carriage"]["size"]["along_rail"] / 2, D["carriage"]["size"]["across"] / 2, D["carriage"]["size"]["height"] / 2)) CAR_LOCAL = Matrix.Translation((0, 0, -D["carriage"]["size"]["height"] / 2)) # A frame: carriage at Z -H..0 def obb_world(M, p): W = M[p["link"]] @ p["local"] return W def point_prox_dist(pw, Winv, p): if "r" not in p: return point_box_dist(pw, Winv, p["h"]) pl = Winv @ pw if p["ax"] == "X": a, u, w = pl.x, pl.y, pl.z elif p["ax"] == "Y": a, u, w = pl.y, pl.x, pl.z else: a, u, w = pl.z, pl.x, pl.y dr = math.sqrt(u * u + w * w) - p["r"] da = abs(a) - p["hl"] if dr > 0 or da > 0: return math.sqrt(max(dr, 0) ** 2 + max(da, 0) ** 2) return max(dr, da) def point_box_dist(pw, Winv, h): pl = Winv @ pw dx = max(abs(pl.x) - h.x, 0.0); dy = max(abs(pl.y) - h.y, 0.0); dz = max(abs(pl.z) - h.z, 0.0) out = math.sqrt(dx * dx + dy * dy + dz * dz) if out > 0: return out return -min(h.x - abs(pl.x), h.y - abs(pl.y), h.z - abs(pl.z)) # ---- run ----------------------------------------------------------------------------------------------------------- lim = {k: [math.radians(a) for a in KIN["joints"][k]["limits"]] for k in KIN["joints"]} rep = dict(door=DOOR_KEY, version=VERSION, frames=0, step=STEP, spacing=SPACING, d_t=d_t, joints={}, max_step_deg={}, pose_err_mm=0.0, pose_err_deg=0.0, min_abs_q5_deg=999, elbow_inner_deg=[999, -999], reach_ratio=[9, -9], wc_from_J1_axis_min=99, carriage=[99, -99], carriage_ok=True, rod=dict(length=[9, -9], cyl_angle_deg=[999, -999], piston=[9, -9], rear_in_lower=[]), limits_ok=True, clear={}, self={}, per_frame=[]) prev = None frames = list(range(1, 121, STEP)) if 120 not in frames: frames.append(120) for f in frames: scn.frame_set(f) dl = door.matrix_world.translation.copy() c = CA + CB * dl.x P = dl + P_L WC = P - xt_w * d_t wc_c = K.w2c((WC.x - c, WC.y - yb, WC.z - zb)) q = K.solve(arm, wc_c, tool_c, branch5=DR["wrist_branch"]) if q is None: rep["fail"] = "unreachable at frame %d" % f break M, L, cyl_ang, rear = chain(c, q) # pose error Fm = M["Flange"] pos = Fm.translation + Fm.to_3x3().col[0] * d_t e_mm = (pos - P).length * 1000 R = Fm.to_3x3() Rt = Matrix((xt_w, yt_w, zt_w)).transposed() tr = (R.transposed() @ Rt) e_deg = math.degrees(math.acos(max(-1.0, min(1.0, (tr[0][0] + tr[1][1] + tr[2][2] - 1) / 2)))) rep["pose_err_mm"] = max(rep["pose_err_mm"], e_mm) rep["pose_err_deg"] = max(rep["pose_err_deg"], e_deg) # joints for k in ("q1", "q2", "q3", "q4", "q5", "q6"): J = "J" + k[1] a = q[k] jr = rep["joints"].setdefault(J, [999, -999]) jr[0] = min(jr[0], math.degrees(a)); jr[1] = max(jr[1], math.degrees(a)) if not (lim[J][0] <= a <= lim[J][1]): rep["limits_ok"] = False rep.setdefault("limit_breaks", []).append([f, J, round(math.degrees(a), 2)]) if prev is not None: rep["max_step_deg"][J] = max(rep["max_step_deg"].get(J, 0), abs(math.degrees(a - prev[k])) / STEP) prev = q rep["min_abs_q5_deg"] = min(rep["min_abs_q5_deg"], abs(math.degrees(q["q5"]))) ei = math.degrees(q["elbow_inner"]) rep["elbow_inner_deg"] = [min(rep["elbow_inner_deg"][0], ei), max(rep["elbow_inner_deg"][1], ei)] rr = q["D"] / (arm.L2 + arm.L3) rep["reach_ratio"] = [min(rep["reach_ratio"][0], rr), max(rep["reach_ratio"][1], rr)] rep["wc_from_J1_axis_min"] = min(rep["wc_from_J1_axis_min"], q["rho"]) rep["carriage"] = [min(rep["carriage"][0], c), max(rep["carriage"][1], c)] if not (c_lo - 1e-6 <= c <= c_hi + 1e-6): rep["carriage_ok"] = False rr_ = rep["rod"] rr_["length"] = [min(rr_["length"][0], L), max(rr_["length"][1], L)] rr_["cyl_angle_deg"] = [min(rr_["cyl_angle_deg"][0], math.degrees(cyl_ang)), max(rr_["cyl_angle_deg"][1], math.degrees(cyl_ang))] pist = L - ROD_LEN rr_["piston"] = [min(rr_["piston"][0], pist), max(rr_["piston"][1], pist)] # barrel rear end vs the lower arm's core proxies (inside = negative distance) best_in = 9 for p in PROX: if p["link"] == "LowerArm" and "core" in p["n"]: Wl = p["local"] best_in = min(best_in, point_prox_dist(rear, Wl.inverted(), p)) rr_["rear_in_lower"].append(round(best_in, 4)) # clearance to the context for p in PROX: W = obb_world(M, p) ctr = W.translation rad = p["h"].length pts = None for n, cx in CTX.items(): off = cx["moving"].matrix_world.translation if cx["moving"] is not None else Vector((0, 0, 0)) lo, hi = cx["lo"] + off, cx["hi"] + off if any(ctr[i] + rad + 0.6 < lo[i] or ctr[i] - rad - 0.6 > hi[i] for i in range(3)): continue if pts is None: pts = [W @ s for s in p["samples"]] best = (9.0, None) for pw in pts: hit = cx["bvh"].find_nearest(pw - off, 0.6) if hit[0] is None: continue if hit[3] < best[0]: best = (hit[3], f) hv, hf = p["hull"] bt = BVHTree.FromPolygons([W @ v - off for v in hv], hf, epsilon=0.0) if bt.overlap(cx["bvh"]): best = (-0.001 if best[1] is None else -max(best[0], 0.001), f) if best[1] is None: continue key = p["link"] + " | " + p["n"] + " x " + n cur = rep["clear"].get(key) if cur is None or best[0] < cur[0]: rep["clear"][key] = [round(best[0], 4), best[1]] # carriage box vs context (deck, lamps): its fixed 20 mm under the deck is by design; report only # self clearance worlds = {} for p in PROX: worlds[id(p)] = obb_world(M, p) carW = M["Carriage"] @ CAR_LOCAL group = {} for p in PROX: group.setdefault(p["link"], []).append(p) def pair_min(pa, Wa, ha, sa, pb, Wb, hb, sb): Wbi, Wai = Wb.inverted(), Wa.inverted() m = 9.0 for s in sa: m = min(m, point_prox_dist(Wa @ s, Wbi, pb)) for s in sb: m = min(m, point_prox_dist(Wb @ s, Wai, pa)) return m car_s = box_samples(CAR_H, 0.08) for la, lb in SELF_PAIRS: A_ = [(dict(h=CAR_H), carW, CAR_H, car_s, "body")] if la == "Carriage" else [(p, worlds[id(p)], p["h"], p["samples"], p["n"]) for p in group.get(la, [])] B_ = [(p, worlds[id(p)], p["h"], p["samples"], p["n"]) for p in group.get(lb, [])] for pa_, Wa, ha, sa, na in A_: for pb_, Wb, hb, sb, nb in B_: if (Wa.translation - Wb.translation).length > ha.length + hb.length + 0.3: continue m = pair_min(pa_, Wa, ha, sa, pb_, Wb, hb, sb) key = "%s | %s x %s | %s" % (la, na, lb, nb) cur = rep["self"].get(key) if cur is None or m < cur[0]: rep["self"][key] = [round(m, 4), f] for (la, na), (lb, nb) in SELF_NAMED: pa = next((p for p in group.get(la, []) if p["n"] == na), None) pb = next((p for p in group.get(lb, []) if p["n"] == nb), None) if pa is None or pb is None: continue m = pair_min(pa, worlds[id(pa)], pa["h"], pa["samples"], pb, worlds[id(pb)], pb["h"], pb["samples"]) key = "%s | %s x %s | %s" % (la, na, lb, nb) cur = rep["self"].get(key) if cur is None or m < cur[0]: rep["self"][key] = [round(m, 4), f] rep["frames"] += 1 rep["per_frame"].append([f, round(c, 4)] + [round(math.degrees(q[k]), 3) for k in ("q1", "q2", "q3", "q4", "q5", "q6")] + [round(L, 4), round(math.degrees(cyl_ang), 3)]) # summary rep["rod"]["rear_in_lower_max"] = max(rep["rod"]["rear_in_lower"]) if rep["rod"]["rear_in_lower"] else None del rep["rod"]["rear_in_lower"] rep["clear_min"] = min(rep["clear"].items(), key=lambda kv: kv[1][0]) if rep["clear"] else None rep["self_min"] = min(rep["self"].items(), key=lambda kv: kv[1][0]) if rep["self"] else None rep["clear_sorted"] = sorted(([k] + v for k, v in rep["clear"].items()), key=lambda t: t[1])[:25] rep["self_sorted"] = sorted(([k] + v for k, v in rep["self"].items()), key=lambda t: t[1])[:25] rep["seconds"] = round(time.time() - t0, 1) for k in ("elbow_inner_deg", "reach_ratio", "carriage"): rep[k] = [round(v, 4) for v in rep[k]] rep["joints"] = {k: [round(a, 3) for a in v] for k, v in rep["joints"].items()} rep["max_step_deg"] = {k: round(v, 3) for k, v in rep["max_step_deg"].items()} for k in ("length", "cyl_angle_deg", "piston"): rep["rod"][k] = [round(v, 4) for v in rep["rod"][k]] json.dump(rep, open(OUT, "w"), indent=1) print("CHECK", DOOR_KEY, VERSION, "frames", rep["frames"], "pose_err_mm %.6f deg %.6f" % (rep["pose_err_mm"], rep["pose_err_deg"]), "limits_ok", rep["limits_ok"], "carriage_ok", rep["carriage_ok"], "min|q5| %.1f" % rep["min_abs_q5_deg"], "elbow", rep["elbow_inner_deg"], "clear_min", rep["clear_min"], "self_min", rep["self_min"], "secs", rep["seconds"])