2026-09-26 18:05:21 +08:00
|
|
|
|
# -*- coding: utf-8 -*-
|
|
|
|
|
|
"""Structural check of the printed arm, using section properties measured from the SOLIDS.
|
|
|
|
|
|
|
|
|
|
|
|
Nothing here is hand-waved from a sketch dimension: for each critical cut plane the part is
|
|
|
|
|
|
boolean-cut and the freshly created planar face is taken and integrated with
|
|
|
|
|
|
BRepGProp.SurfaceProperties_s, so area, centroid and the second moments are the real
|
|
|
|
|
|
section of the real printed part (including whatever the lightening windows left behind).
|
|
|
|
|
|
|
|
|
|
|
|
Why the allowables are so low: a printed part is not the material datasheet. Bending
|
|
|
|
|
|
across layers, an un-filleted internal corner (Kt ~ 2.5 measured on square corners), and
|
|
|
|
|
|
PLA creep under permanent static load together cut 35-50 MPa down to single digits, which
|
|
|
|
|
|
is exactly the failure mode to design against. See docs/strength.md.
|
|
|
|
|
|
|
|
|
|
|
|
Run: python strength.py -> docs/strength.md (+ console table)
|
|
|
|
|
|
"""
|
|
|
|
|
|
import io
|
|
|
|
|
|
import json
|
|
|
|
|
|
import math
|
|
|
|
|
|
import os
|
2026-10-02 12:06:23 +08:00
|
|
|
|
import re
|
2026-09-26 18:05:21 +08:00
|
|
|
|
import sys
|
|
|
|
|
|
try:
|
|
|
|
|
|
sys.stdout.reconfigure(encoding="utf-8")
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
|
|
|
|
|
import cadquery as cq
|
2026-10-15 03:31:20 +08:00
|
|
|
|
from arm.paths import OUT, WEB, DOCS
|
|
|
|
|
|
from arm.cad.helpers import solids_of, bb, mesh_of
|
2026-09-26 18:05:21 +08:00
|
|
|
|
from OCP.BRepAlgoAPI import BRepAlgoAPI_Common
|
|
|
|
|
|
from OCP.BRepGProp import BRepGProp
|
|
|
|
|
|
from OCP.GProp import GProp_GProps
|
|
|
|
|
|
from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox
|
|
|
|
|
|
from OCP.gp import gp_Pnt, gp_Dir, gp_Ax2, gp_Vec
|
2026-10-15 03:31:20 +08:00
|
|
|
|
from arm import kinematics as HL
|
2026-09-26 18:05:21 +08:00
|
|
|
|
RHO = {"PLA": 1.24e-3, "motor_g": 210.0} # g/mm3 for printed, g per module
|
|
|
|
|
|
# a slicer does not print solid: 3 perimeters + 30% infill lands near this fraction of
|
|
|
|
|
|
# the CAD envelope, and using the solid mass would blame the part for plastic that is not there
|
|
|
|
|
|
INFILL = float(os.environ.get("ARMINFILL", "0.38"))
|
|
|
|
|
|
# allowable bending stress across the print layers, after notch + creep derating
|
|
|
|
|
|
ALLOW = {"PLA": 12.0, "PETG": 20.0, "PA_CF": 30.0} # MPa
|
2026-10-02 12:06:23 +08:00
|
|
|
|
# Young's modulus of the printed lattice, for the deflection estimate only
|
|
|
|
|
|
EMOD = {"PLA": 3000.0, "PETG": 2100.0, "PA_CF": 8000.0} # MPa
|
2026-09-26 18:05:21 +08:00
|
|
|
|
MAT = os.environ.get("ARMMAT", "PLA")
|
|
|
|
|
|
PAYLOAD = float(os.environ.get("ARMPAY", "0.5")) # kg held at the fingertip
|
|
|
|
|
|
DYN = float(os.environ.get("ARMDYN", "2.0")) # dynamic factor on the moving mass
|
|
|
|
|
|
|
|
|
|
|
|
G981 = 9.81
|
|
|
|
|
|
GRIP_T = 12.0 # N*mm per motor is far too small to matter; the grip force
|
|
|
|
|
|
# case is carried separately below as a normal force at the pad
|
|
|
|
|
|
_cache = {}
|
2026-10-02 12:06:23 +08:00
|
|
|
|
GRP = {"root": 0, "J1": 1, "J2": 2, "J3": 3, "J4": 4, "J5": 5, "J6": 6}
|
2026-09-26 18:05:21 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def load(name):
|
|
|
|
|
|
if name not in _cache:
|
|
|
|
|
|
_cache[name] = cq.importers.importStep("%s/parts_step/%s.step" % (OUT, name)).val()
|
|
|
|
|
|
return _cache[name]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def volume(sh):
|
|
|
|
|
|
p = GProp_GProps()
|
|
|
|
|
|
BRepGProp.VolumeProperties_s(sh.wrapped, p)
|
|
|
|
|
|
return p.Mass()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _caps_at(sh, n, o, t):
|
|
|
|
|
|
"""The section faces of (solid ∩ thin slab), taken on the +normal side of the slab.
|
|
|
|
|
|
|
|
|
|
|
|
Two things this replaces. (1) 'Zero extent along the normal' on a HALF-SPACE cut also
|
|
|
|
|
|
matches the part's own horizontal faces (top chord, gusset crowns) sitting anywhere above
|
|
|
|
|
|
the plane - that over-read the shoulder fork by 2.4x (480 mm2 true vs 1155 reported).
|
|
|
|
|
|
(2) The half-space Common outright fails on the filleted fork at z=600 (returns an empty
|
|
|
|
|
|
shape) while a thin slab of the same solid cuts fine.
|
|
|
|
|
|
A slab is also self-checking: its volume / t must equal the integrated area.
|
|
|
|
|
|
"""
|
|
|
|
|
|
c0 = sum(n[k] * o[k] for k in range(3))
|
|
|
|
|
|
lo, hi = [], []
|
|
|
|
|
|
for k in range(3):
|
|
|
|
|
|
if n[k] > 0:
|
|
|
|
|
|
lo.append(o[k] - t / 2.0); hi.append(o[k] + t / 2.0)
|
|
|
|
|
|
elif n[k] < 0:
|
|
|
|
|
|
lo.append(o[k] - t / 2.0); hi.append(o[k] + t / 2.0)
|
|
|
|
|
|
else:
|
|
|
|
|
|
lo.append(o[k] - 400.0); hi.append(o[k] + 400.0)
|
|
|
|
|
|
box = BRepPrimAPI_MakeBox(gp_Pnt(*lo), gp_Pnt(*hi)).Shape()
|
|
|
|
|
|
cut = BRepAlgoAPI_Common(sh.wrapped, box)
|
|
|
|
|
|
cut.Build()
|
|
|
|
|
|
if not cut.IsDone():
|
|
|
|
|
|
return []
|
|
|
|
|
|
off = c0 + t / 2.0
|
|
|
|
|
|
out = []
|
|
|
|
|
|
for f in cq.Shape(cut.Shape()).Faces():
|
|
|
|
|
|
if f.geomType() != "PLANE" or f.Area() <= 1e-6:
|
|
|
|
|
|
continue
|
|
|
|
|
|
cx, cy, cz = f.Center().toTuple()
|
|
|
|
|
|
if abs(n[0] * cx + n[1] * cy + n[2] * cz - off) > 1e-4:
|
|
|
|
|
|
continue
|
|
|
|
|
|
out.append(f)
|
|
|
|
|
|
return out
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def section_props(sh, normal, origin, bend=2, size=400.0):
|
|
|
|
|
|
"""Cut the solid with a thin slab and integrate the section face.
|
|
|
|
|
|
|
|
|
|
|
|
Returns (area mm2, Ixx mm4, Iyy mm4, cmax mm, bbox) about the face centroid, with the
|
|
|
|
|
|
in-plane axes taken as (u, v) where u x v = normal.
|
|
|
|
|
|
"""
|
|
|
|
|
|
n = [float(x) for x in normal]
|
|
|
|
|
|
o = [float(x) for x in origin]
|
|
|
|
|
|
caps = []
|
|
|
|
|
|
for t in (0.2, 0.05, 1.0):
|
|
|
|
|
|
for nudge in (0.0, 0.05, -0.05, 0.25, -0.25):
|
|
|
|
|
|
oo = [o[k] + nudge * n[k] for k in range(3)]
|
|
|
|
|
|
caps = _caps_at(sh, n, oo, t)
|
|
|
|
|
|
if caps:
|
|
|
|
|
|
break
|
|
|
|
|
|
if caps:
|
|
|
|
|
|
break
|
|
|
|
|
|
if not caps:
|
|
|
|
|
|
return None
|
|
|
|
|
|
plane_ax = [k for k in (0, 1, 2) if abs(n[k]) < 0.5]
|
|
|
|
|
|
p_ax, q_ax = plane_ax
|
|
|
|
|
|
bend_ax = bend if bend in plane_ax else q_ax
|
|
|
|
|
|
far_ax = p_ax if bend_ax == q_ax else q_ax
|
|
|
|
|
|
A = Sv = Suu = Svv = S = 0.0
|
|
|
|
|
|
for f in caps:
|
|
|
|
|
|
verts, tris = mesh_of(f, 0.02)
|
|
|
|
|
|
fa = fu = fv = fuu = fvv = 0.0
|
|
|
|
|
|
for t in tris:
|
|
|
|
|
|
(u1, v1), (u2, v2), (u3, v3) = [(verts[k][p_ax], verts[k][q_ax]) for k in t]
|
|
|
|
|
|
ar = ((u2 - u1) * (v3 - v1) - (u3 - u1) * (v2 - v1)) / 2.0
|
|
|
|
|
|
fa += ar # SIGNED: inner wires (holes) cancel
|
|
|
|
|
|
fu += ar * (u1 + u2 + u3) / 3.0
|
|
|
|
|
|
fv += ar * (v1 + v2 + v3) / 3.0
|
|
|
|
|
|
fvv += ar * (v1**2 + v2**2 + v3**2 + v1*v2 + v2*v3 + v3*v1) / 6.0
|
|
|
|
|
|
fuu += ar * (u1**2 + u2**2 + u3**2 + u1*u2 + u2*u3 + u3*u1) / 6.0
|
|
|
|
|
|
if fa < 0: # face normal opposed to the cut normal
|
|
|
|
|
|
fa, fu, fv, fvv, fuu = -fa, -fu, -fv, -fvv, -fuu
|
|
|
|
|
|
A += fa; S += fu; Sv += fv; Svv += fvv; Suu += fuu
|
|
|
|
|
|
if A <= 1e-9:
|
|
|
|
|
|
return None
|
|
|
|
|
|
uc, vc = S / A, Sv / A
|
|
|
|
|
|
best = max(caps, key=lambda f: f.Area()) # only for the bounding box below
|
|
|
|
|
|
allb = [f.BoundingBox() for f in caps]
|
|
|
|
|
|
lo = [min(getattr(b, ("xmin", "ymin", "zmin")[k]) for b in allb) for k in range(3)]
|
|
|
|
|
|
hi = [max(getattr(b, ("xmax", "ymax", "zmax")[k]) for b in allb) for k in range(3)]
|
|
|
|
|
|
cmax = max(hi[far_ax] - uc if far_ax == p_ax else hi[far_ax] - vc,
|
|
|
|
|
|
uc - lo[far_ax] if far_ax == p_ax else vc - lo[far_ax]) / 1.0
|
|
|
|
|
|
# distance from the neutral axis to the extreme fibre, measured in the bending direction
|
|
|
|
|
|
ext = (hi[far_ax] - lo[far_ax])
|
|
|
|
|
|
cen = uc if far_ax == p_ax else vc
|
|
|
|
|
|
cmax = max(hi[far_ax] - cen, cen - lo[far_ax])
|
|
|
|
|
|
I = (Svv - A * vc * vc) if far_ax == q_ax else (Suu - A * uc * uc)
|
|
|
|
|
|
return dict(A=A, I=I, c=cmax, bend=bend_ax, centroid=(uc, vc))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def arm_masses():
|
|
|
|
|
|
"""Everything distal to each joint, in grams, from the model itself."""
|
2026-10-15 03:31:20 +08:00
|
|
|
|
meta = json.load(io.open(WEB + "/mesh_arm.json", encoding="utf-8"))
|
2026-09-26 18:05:21 +08:00
|
|
|
|
TREE = ["root", "J1", "J2", "J3", "J4", "J5", "J6"]
|
|
|
|
|
|
tot = {}
|
|
|
|
|
|
for p in meta["parts"]:
|
2026-10-02 04:39:12 +08:00
|
|
|
|
if not os.path.exists("%s/parts_step/%s.step" % (OUT, p["name"])):
|
|
|
|
|
|
continue # 螺钉实例 / 可达包络网格等非 CAD 构件没有 STEP,跳过
|
2026-09-26 18:05:21 +08:00
|
|
|
|
sh = load(p["name"])
|
|
|
|
|
|
v = volume(sh) / 1000.0
|
|
|
|
|
|
g = (v * RHO["PLA"] * 1000.0 * INFILL) if p["printed"] else RHO["motor_g"]
|
|
|
|
|
|
tot[p["name"]] = (g, p["grp"])
|
|
|
|
|
|
out = {}
|
|
|
|
|
|
for k, jid in enumerate(TREE[1:], 1):
|
|
|
|
|
|
down = set(TREE[k:])
|
|
|
|
|
|
out[jid] = sum(g for g, grp in tot.values() if grp in down)
|
|
|
|
|
|
out["all"] = sum(g for g, _ in tot.values())
|
|
|
|
|
|
return out, tot
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-10-02 12:06:23 +08:00
|
|
|
|
# ------------------------------------------------------------------ 零件表:质量 + 零位世界质心
|
|
|
|
|
|
def _mesh_vol_cg(v, t):
|
|
|
|
|
|
"""四面体法求闭合三角网格的体积与体积加权质心。
|
|
|
|
|
|
|
|
|
|
|
|
网格顶点已在零位世界系(mesh_arm.json 由 arm_model 直接落盘),所以质心就是零件
|
|
|
|
|
|
在装配中的真实位置。实测体积与 STEP/BOM 差 <0.1%(电机包络 1.7%),可作载荷输入。
|
|
|
|
|
|
"""
|
|
|
|
|
|
vol = mx = my = mz = 0.0
|
|
|
|
|
|
for a, b, c in t:
|
|
|
|
|
|
x1, y1, z1 = v[a]
|
|
|
|
|
|
x2, y2, z2 = v[b]
|
|
|
|
|
|
x3, y3, z3 = v[c]
|
|
|
|
|
|
det = x1 * (y2 * z3 - y3 * z2) - x2 * (y1 * z3 - y3 * z1) + x3 * (y1 * z2 - y2 * z1)
|
|
|
|
|
|
vol += det
|
|
|
|
|
|
mx += (x1 + x2 + x3) * det
|
|
|
|
|
|
my += (y1 + y2 + y3) * det
|
|
|
|
|
|
mz += (z1 + z2 + z3) * det
|
|
|
|
|
|
if vol <= 0.0:
|
|
|
|
|
|
return 0.0, None
|
|
|
|
|
|
V = vol / 6.0
|
|
|
|
|
|
return V, (mx / (24.0 * V), my / (24.0 * V), mz / (24.0 * V))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def part_table(payload_kg=None):
|
|
|
|
|
|
"""[(name, mass_g, cg_xyz, grp)],含末端负载这个集中质量点。"""
|
2026-10-15 03:31:20 +08:00
|
|
|
|
meta = json.load(io.open(WEB + "/mesh_arm.json", encoding="utf-8"))
|
2026-10-02 12:06:23 +08:00
|
|
|
|
rows = []
|
|
|
|
|
|
for p in meta["parts"]:
|
|
|
|
|
|
nm = p["name"]
|
|
|
|
|
|
if nm.startswith("ENV_") or not p.get("v") or not p.get("t") or p.get("vol", 0) <= 0:
|
|
|
|
|
|
continue # 可达包络网格 / 无实体螺钉实例,不承载
|
|
|
|
|
|
V, cg = _mesh_vol_cg(p["v"], p["t"])
|
|
|
|
|
|
if V <= 0.0 or cg is None:
|
|
|
|
|
|
continue
|
|
|
|
|
|
if p.get("printed"):
|
|
|
|
|
|
m = V * RHO["PLA"] * INFILL
|
|
|
|
|
|
elif re.match(r"^M\d", nm):
|
|
|
|
|
|
m = RHO["motor_g"]
|
|
|
|
|
|
else:
|
|
|
|
|
|
continue
|
|
|
|
|
|
rows.append([nm, m, cg, GRP.get(p["grp"], 0)])
|
|
|
|
|
|
if payload_kg:
|
|
|
|
|
|
rows.append(["PAYLOAD", payload_kg * 1000.0,
|
|
|
|
|
|
(HL.X_ARM, HL.Y_AXIS, HL.Z_TIP), 6])
|
|
|
|
|
|
return rows
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ------------------------------------------------------------------ 运动学:产品外参链
|
|
|
|
|
|
def _rodrigues(deg, d, p):
|
|
|
|
|
|
"""绕世界轴 d 过点 p 转 deg -> (R, t),使 x -> R x + t。"""
|
|
|
|
|
|
n = math.sqrt(sum(c * c for c in d)) or 1.0
|
|
|
|
|
|
x, y, z = d[0] / n, d[1] / n, d[2] / n
|
|
|
|
|
|
a = math.radians(deg)
|
|
|
|
|
|
c, s = math.cos(a), math.sin(a)
|
|
|
|
|
|
t1 = 1.0 - c
|
|
|
|
|
|
R = [[t1 * x * x + c, t1 * x * y - s * z, t1 * x * z + s * y],
|
|
|
|
|
|
[t1 * x * y + s * z, t1 * y * y + c, t1 * y * z - s * x],
|
|
|
|
|
|
[t1 * x * z - s * y, t1 * y * z + s * x, t1 * z * z + c]]
|
|
|
|
|
|
t = [p[i] - (R[i][0] * p[0] + R[i][1] * p[1] + R[i][2] * p[2]) for i in range(3)]
|
|
|
|
|
|
return R, t
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def chain(ang):
|
|
|
|
|
|
"""acc[g] = (R, t):第 g 组零件的刚体变换。
|
|
|
|
|
|
|
|
|
|
|
|
acc[g] = acc[g-1] o R_g(产品外参,右侧先作用),与 sweep_v2.accum_trsf、
|
|
|
|
|
|
查看器嵌套 pivot 三者一致。
|
|
|
|
|
|
"""
|
|
|
|
|
|
R = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]
|
|
|
|
|
|
t = [0.0, 0.0, 0.0]
|
|
|
|
|
|
out = [(R, t)]
|
|
|
|
|
|
for j in range(1, 7):
|
|
|
|
|
|
ax = HL.AXIS["J%d" % j]
|
|
|
|
|
|
Rj, tj = _rodrigues(ang.get(j, 0.0), ax["d"], ax["rear"])
|
|
|
|
|
|
Rn = [[sum(R[i][k] * Rj[k][j2] for k in range(3)) for j2 in range(3)] for i in range(3)]
|
|
|
|
|
|
tn = [sum(R[i][k] * tj[k] for k in range(3)) + t[i] for i in range(3)]
|
|
|
|
|
|
R, t = Rn, tn
|
|
|
|
|
|
out.append((R, t))
|
|
|
|
|
|
return out
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _xf(T, c):
|
|
|
|
|
|
R, t = T
|
|
|
|
|
|
return [sum(R[i][k] * c[k] for k in range(3)) + t[i] for i in range(3)]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _dir(T, v):
|
|
|
|
|
|
"""只取旋转部分——方向向量绝不能带平移,否则投影出的"力矩"会被平移量放大。"""
|
|
|
|
|
|
R = T[0]
|
|
|
|
|
|
return [sum(R[i][k] * v[k] for k in range(3)) for i in range(3)]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _cross(a, b):
|
|
|
|
|
|
return [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def joint_loads(parts, payload_kg=None):
|
|
|
|
|
|
"""在可达姿态网格上搜索,返回每个关节的:
|
|
|
|
|
|
|
|
|
|
|
|
F 该关节以下的总重力 (N)(与姿态无关)
|
|
|
|
|
|
Mxyz 重力对关节轴点取矩后,三个全局分量各自的最大绝对值 (N*mm)
|
|
|
|
|
|
tau 重力绕该关节自身轴的最大力矩 (N*mm) —— 电机真正要出的力
|
|
|
|
|
|
Lp 出现 tau 最大值时,TCP 到该关节轴点的距离 (mm)
|
|
|
|
|
|
|
|
|
|
|
|
为什么搜索姿态:力臂不是常数。J1 是绕 X 的自转轴,手臂在轴正下方时重力对它
|
|
|
|
|
|
只产生绕 X 的扭矩(不产生弯曲),手臂摆到侧方时扭矩最大。旧算法把全部 distal
|
|
|
|
|
|
质量当作集中在指尖,量级完全错掉。
|
|
|
|
|
|
"""
|
|
|
|
|
|
trv = {t["axis"]: t for t in json.load(io.open(DOCS + "/travel.json", encoding="utf-8"))}
|
|
|
|
|
|
|
|
|
|
|
|
def picks(j):
|
|
|
|
|
|
t = trv.get("J%d" % j, {"lo": -180.0, "hi": 180.0})
|
|
|
|
|
|
if t.get("continuous"):
|
|
|
|
|
|
return [0.0, 60.0, -60.0, 120.0, -120.0, 180.0]
|
|
|
|
|
|
lo, hi = t["lo"], t["hi"]
|
|
|
|
|
|
return sorted({lo, 0.0 if lo < 0 < hi else lo, hi, 0.5 * (lo + hi)})
|
|
|
|
|
|
|
|
|
|
|
|
grid = [picks(j) for j in range(1, 7)]
|
|
|
|
|
|
res = {}
|
|
|
|
|
|
for j in range(1, 7):
|
|
|
|
|
|
res["J%d" % j] = dict(F=0.0, Mxyz=[0.0, 0.0, 0.0], tau=0.0, Lp=0.0, I=0.0, Iarm=0.0)
|
|
|
|
|
|
|
|
|
|
|
|
import itertools
|
|
|
|
|
|
for combo in itertools.product(*grid):
|
|
|
|
|
|
ang = {k + 1: v for k, v in enumerate(combo)}
|
|
|
|
|
|
acc = chain(ang)
|
|
|
|
|
|
pos = [(nm, m, _xf(acc[g], cg), g) for nm, m, cg, g in parts]
|
|
|
|
|
|
tcp = None
|
|
|
|
|
|
for nm, m, c, g in pos:
|
|
|
|
|
|
if nm == "PAYLOAD":
|
|
|
|
|
|
tcp = c
|
|
|
|
|
|
for j in range(1, 7):
|
|
|
|
|
|
ax = HL.AXIS["J%d" % j]
|
|
|
|
|
|
pj = _xf(acc[j - 1], ax["rear"])
|
|
|
|
|
|
dj = _dir(acc[j - 1], ax["d"])
|
|
|
|
|
|
F = M = [0.0, 0.0, 0.0]
|
|
|
|
|
|
wsum = 0.0
|
|
|
|
|
|
I = Iarm = 0.0
|
|
|
|
|
|
for _nm, m, c, g in pos:
|
|
|
|
|
|
if g < j:
|
|
|
|
|
|
continue
|
|
|
|
|
|
w = m * G981 / 1000.0
|
|
|
|
|
|
wsum += w
|
|
|
|
|
|
r = [c[i] - pj[i] for i in range(3)]
|
|
|
|
|
|
proj = sum(r[i] * dj[i] for i in range(3))
|
|
|
|
|
|
perp2 = sum(r[i] * r[i] for i in range(3)) - proj * proj
|
|
|
|
|
|
I += m * perp2 / 1e9 # g*mm^2 -> kg*m^2
|
|
|
|
|
|
if _nm != "PAYLOAD":
|
|
|
|
|
|
Iarm += m * perp2 / 1e9
|
|
|
|
|
|
rc = _cross(r, [0.0, 0.0, -w])
|
|
|
|
|
|
for i in range(3):
|
|
|
|
|
|
M[i] += rc[i]
|
|
|
|
|
|
r = res["J%d" % j]
|
|
|
|
|
|
r["F"] = max(r["F"], wsum)
|
|
|
|
|
|
for i in range(3):
|
|
|
|
|
|
r["Mxyz"][i] = max(r["Mxyz"][i], abs(M[i]))
|
|
|
|
|
|
tau = abs(M[0] * dj[0] + M[1] * dj[1] + M[2] * dj[2])
|
|
|
|
|
|
if tau > r["tau"]:
|
|
|
|
|
|
r["tau"] = tau
|
|
|
|
|
|
r["Lp"] = math.dist(tcp, pj) if tcp else 0.0
|
|
|
|
|
|
r["I"] = max(r["I"], I)
|
|
|
|
|
|
r["Iarm"] = max(r["Iarm"], Iarm)
|
|
|
|
|
|
return res
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def section_moment(jid, bend_axis, ref, refgrp, parts):
|
|
|
|
|
|
"""截面绕自身形心、沿 bend_axis 的弯矩最大值 (N*mm),以及该姿态下 TCP 到该
|
|
|
|
|
|
截面的距离 (mm)(挠度估算用)。
|
|
|
|
|
|
|
|
|
|
|
|
参考点取【剖面形心】而不是关节轴:这两个参考点差得很远。P03 在 z=480 的剖面
|
|
|
|
|
|
形心 x≈-111,而臂身所有零件质心 x≈+19.5,真实力臂 130mm;若误用关节轴点
|
|
|
|
|
|
(J1 在 x=-51)只会得到 70mm,把弯矩低估 1.85 倍。
|
|
|
|
|
|
"""
|
|
|
|
|
|
trv = {t["axis"]: t for t in json.load(io.open(DOCS + "/travel.json", encoding="utf-8"))}
|
|
|
|
|
|
jj = int(jid[1])
|
|
|
|
|
|
|
|
|
|
|
|
def picks(j):
|
|
|
|
|
|
t = trv.get("J%d" % j, {"lo": -180.0, "hi": 180.0})
|
|
|
|
|
|
if t.get("continuous"):
|
|
|
|
|
|
return [0.0, 60.0, -60.0, 120.0, -120.0, 180.0]
|
|
|
|
|
|
lo, hi = t["lo"], t["hi"]
|
|
|
|
|
|
return sorted({lo, 0.0 if lo < 0 < hi else lo, hi, 0.5 * (lo + hi)})
|
|
|
|
|
|
|
|
|
|
|
|
import itertools
|
|
|
|
|
|
best = 0.0
|
|
|
|
|
|
best_L = 0.0
|
|
|
|
|
|
for combo in itertools.product(*[picks(j) for j in range(1, 7)]):
|
|
|
|
|
|
acc = chain({k + 1: v for k, v in enumerate(combo)})
|
|
|
|
|
|
rw = _xf(acc[refgrp], ref)
|
|
|
|
|
|
s = 0.0
|
|
|
|
|
|
tcp = rw
|
|
|
|
|
|
for nm, m, cg, g in parts:
|
|
|
|
|
|
if g < jj:
|
|
|
|
|
|
continue
|
|
|
|
|
|
c = _xf(acc[g], cg)
|
|
|
|
|
|
if nm == "PAYLOAD":
|
|
|
|
|
|
tcp = c
|
|
|
|
|
|
s += m * (c[bend_axis] - rw[bend_axis])
|
|
|
|
|
|
if abs(s) > best:
|
|
|
|
|
|
best = abs(s)
|
|
|
|
|
|
best_L = math.dist(tcp, rw)
|
|
|
|
|
|
return best * G981 / 1000.0, best_L
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-26 18:05:21 +08:00
|
|
|
|
# critical cuts: (label, part, plane normal, plane origin, which load case owns it)
|
|
|
|
|
|
def cuts():
|
|
|
|
|
|
"""(label, part, cut-plane normal, point, joint whose load it carries, bend axis)
|
|
|
|
|
|
bend axis: 0=X (roll of the shoulder/forearm), 1=Y (every pitch joint), 2=Z."""
|
|
|
|
|
|
Y = HL.Y_AXIS
|
|
|
|
|
|
return [
|
2026-09-28 03:33:27 +08:00
|
|
|
|
("吊臂根座 @z=480", "P03_肩吊臂_螺旋体35%_墙5底5_竖打", (0, 0, 1), (0.0, 55.0, 480.0), "J1", 1),
|
|
|
|
|
|
("吊臂颊板 @z=560", "P03_肩吊臂_螺旋体35%_墙5底5_竖打", (0, 0, 1), (0.0, 100.0, 560.0), "J1", 1),
|
|
|
|
|
|
("肩叉腹板 @z=560", "P04_肩叉_螺旋体30%_墙5底5_竖打", (0, 0, 1), (HL.X_ARM, Y, 560.0), "J2", 1),
|
|
|
|
|
|
("肩叉腹板 @z=600", "P04_肩叉_螺旋体30%_墙5底5_竖打", (0, 0, 1), (HL.X_ARM, Y, 600.0), "J2", 1),
|
|
|
|
|
|
("大臂旋转座横梁 @z=480", "P05_大臂旋转座_螺旋体30%_墙4底5_竖打", (0, 0, 1), (HL.X_ARM, Y, 480.0), "J2", 1),
|
|
|
|
|
|
("大臂旋转座套筒 @z=440", "P05_大臂旋转座_螺旋体30%_墙4底5_竖打", (0, 0, 1), (HL.X_ARM, Y, 440.0), "J3", 1),
|
|
|
|
|
|
("大臂骨架中段 @z=350", "P06_大臂骨架_螺旋体25%_墙4底5_竖打", (0, 0, 1), (HL.X_ARM, Y, 350.0), "J3", 1),
|
|
|
|
|
|
("大臂骨架抱箍 @z=325", "P06_大臂骨架_螺旋体25%_墙4底5_竖打", (0, 0, 1), (HL.X_ARM, Y, 325.0), "J4", 1),
|
2026-10-04 13:16:32 +08:00
|
|
|
|
# 【2026-10-02 补】P06 原减重窗在 Z358~374,原有剖面(350/325)全在它下面,
|
|
|
|
|
|
# 根本校核不到被挖的区域。z=365 穿过该段过渡块,承载同 J3。
|
|
|
|
|
|
# 【2026-10-03】减重窗已取消、该段恢复实心,此剖面保留为该高度的安全裕度监控点。
|
|
|
|
|
|
("大臂骨架过渡块 @z=365", "P06_大臂骨架_螺旋体25%_墙4底5_竖打", (0, 0, 1), (HL.X_ARM, Y, 365.0), "J3", 1),
|
2026-09-28 03:33:27 +08:00
|
|
|
|
("小臂骨架中段 @z=245", "P07_小臂骨架_螺旋体30%_墙5底5_竖打", (0, 0, 1), (HL.X_ARM, Y, 245.0), "J4", 1),
|
|
|
|
|
|
("小臂骨架抱箍 @z=195", "P07_小臂骨架_螺旋体30%_墙5底5_竖打", (0, 0, 1), (HL.X_ARM, Y, 195.0), "J5", 1),
|
2026-10-04 13:16:32 +08:00
|
|
|
|
# 【2026-10-02 补】P07 减重槽在 Z255~280,同理原有剖面(245/195)没覆盖到。
|
|
|
|
|
|
# z=270 穿过槽体本身,承载同 J4;z=280 是槽口所在的顶板上边缘,壁最薄。
|
|
|
|
|
|
("小臂骨架减重槽 @z=270", "P07_小臂骨架_螺旋体30%_墙5底5_竖打", (0, 0, 1), (HL.X_ARM, Y, 270.0), "J4", 1),
|
|
|
|
|
|
("小臂骨架减重槽口 @z=280", "P07_小臂骨架_螺旋体30%_墙5底5_竖打", (0, 0, 1), (HL.X_ARM, Y, 280.0), "J4", 1),
|
2026-09-28 03:33:27 +08:00
|
|
|
|
("腕偏转座根部 @z=150", "P08_腕偏转座_螺旋体25%_墙4底5_竖打", (0, 0, 1), (HL.X_ARM, Y, 150.0), "J5", 1),
|
2026-10-02 12:06:23 +08:00
|
|
|
|
# P09 包络 z=93..121,剖面取 107(法兰中截面)。原 -55 落在零件之外,
|
|
|
|
|
|
# 剖切返回空,工具法兰其实一直没被校核过。
|
2026-10-04 13:16:32 +08:00
|
|
|
|
("工具法兰 @z=107", "P09_工具法兰_螺旋体30%_墙4底5_竖打", (0, 0, 1), (HL.X_ARM, Y, 107.0), "J6", 1),
|
2026-09-26 18:05:21 +08:00
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
MOMENT = { # worst reachable bending moment per joint, N*mm
|
|
|
|
|
|
# computed in main() from the reachable pose + masses
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def main():
|
2026-10-02 12:06:23 +08:00
|
|
|
|
parts = part_table(PAYLOAD)
|
|
|
|
|
|
grpof = {nm: g for nm, _, _, g in parts}
|
|
|
|
|
|
jl = joint_loads(parts)
|
|
|
|
|
|
masses = {"J%d" % j: sum(m for _, m, _, g in parts if g >= j) for j in range(1, 7)}
|
|
|
|
|
|
masses["all"] = sum(m for _, m, _, _ in parts)
|
|
|
|
|
|
|
2026-09-26 18:05:21 +08:00
|
|
|
|
from concurrent.futures import ThreadPoolExecutor
|
|
|
|
|
|
|
|
|
|
|
|
def compute_cut(c_item):
|
|
|
|
|
|
lab, part, n, o, jid, bend = c_item
|
|
|
|
|
|
sh = load(part)
|
|
|
|
|
|
sp = section_props(sh, n, o, bend)
|
|
|
|
|
|
if not sp:
|
|
|
|
|
|
return (lab, part, None, jid)
|
2026-10-02 12:06:23 +08:00
|
|
|
|
# 剖面形心的三维重建 + 弯矩所绕的轴
|
|
|
|
|
|
nvec = [float(x) for x in n]
|
|
|
|
|
|
p_ax, q_ax = [k for k in (0, 1, 2) if abs(nvec[k]) < 0.5]
|
|
|
|
|
|
bend_ax = sp["bend"]
|
|
|
|
|
|
far_ax = p_ax if bend_ax == q_ax else q_ax # I 绕 bend_ax、c 沿 far_ax
|
|
|
|
|
|
ref = list(o)
|
|
|
|
|
|
ref[p_ax] += sp["centroid"][0]
|
|
|
|
|
|
ref[q_ax] += sp["centroid"][1]
|
|
|
|
|
|
M, L = section_moment(jid, far_ax, ref, grpof.get(part, 0), parts)
|
|
|
|
|
|
M *= DYN
|
|
|
|
|
|
F = jl[jid]["F"] * DYN
|
|
|
|
|
|
I, c = sp["I"], sp["c"]
|
|
|
|
|
|
sigma = M * c / max(I, 1e-6)
|
2026-09-26 18:05:21 +08:00
|
|
|
|
tau = 1.5 * F / sp["A"]
|
2026-10-02 12:06:23 +08:00
|
|
|
|
defl = M * L * L / (2.0 * EMOD[MAT] * I) if I > 1e-6 and L > 0 else 0.0
|
|
|
|
|
|
return (lab, part, dict(sp, M=M, F=F, sigma=sigma, tau=tau, L=L, defl=defl,
|
|
|
|
|
|
bend_axis=bend_ax, SF=ALLOW[MAT] / sigma if sigma > 0 else 99.0), jid)
|
2026-09-26 18:05:21 +08:00
|
|
|
|
|
|
|
|
|
|
with ThreadPoolExecutor(max_workers=min(13, os.cpu_count() or 4)) as executor:
|
|
|
|
|
|
rows = list(executor.map(compute_cut, cuts()))
|
2026-10-02 12:06:23 +08:00
|
|
|
|
print("材料 %s 许用弯曲 %g MPa(跨层、含缺口与蠕变折减) 弹性模量 %g MPa 末端负载 %g kg 动载系数 %g"
|
|
|
|
|
|
% (MAT, ALLOW[MAT], EMOD[MAT], PAYLOAD, DYN))
|
|
|
|
|
|
print("载荷模型:每个零件按真实质心(网格体积加权)随姿态变换,在可达姿态网格上取弯矩最大值;"
|
|
|
|
|
|
"参考点为剖面形心\n")
|
2026-09-26 18:05:21 +08:00
|
|
|
|
print("打印件按 %d%% 实体率计质量(壁+填充),电机按 %.0f g/台" % (INFILL * 100, RHO["motor_g"]))
|
2026-10-02 12:06:23 +08:00
|
|
|
|
print("%-24s %-9s %7s %10s %6s %9s %7s %6s %7s %7s"
|
|
|
|
|
|
% ("截面", "零件", "面积mm2", "I mm4", "c mm", "M N*mm", "sigma", "SF", "挠度mm", "需Z mm3"))
|
2026-09-26 18:05:21 +08:00
|
|
|
|
bad = []
|
|
|
|
|
|
for lab, part, d, jid in rows:
|
|
|
|
|
|
if not d:
|
2026-10-02 12:06:23 +08:00
|
|
|
|
print("%-24s %-9s 剖切失败" % (lab, part))
|
2026-09-26 18:05:21 +08:00
|
|
|
|
continue
|
|
|
|
|
|
Zreq = d["M"] / ALLOW[MAT] * 1.5 # section modulus x1.5 SF
|
2026-10-02 12:06:23 +08:00
|
|
|
|
print("%-24s %-9s %7.0f %10.0f %6.1f %9.0f %7.2f %6.2f %7.1f %7.0f%s"
|
|
|
|
|
|
% (lab, part[:9], d["A"], d["I"], d["c"], d["M"], d["sigma"], d["SF"], d["defl"],
|
|
|
|
|
|
Zreq, " <<< 不足" if d["SF"] < 1.5 else ""))
|
2026-09-26 18:05:21 +08:00
|
|
|
|
if d["SF"] < 1.5:
|
|
|
|
|
|
bad.append((lab, part, d))
|
2026-10-02 12:06:23 +08:00
|
|
|
|
print("\n各关节以下质量(g, 含电机与末端负载): "
|
|
|
|
|
|
+ " ".join("%s=%.0f" % (k, v) for k, v in masses.items() if k != "all"))
|
|
|
|
|
|
print("合计 %.0f g (其中末端负载 %.0f g)" % (masses["all"], PAYLOAD * 1000))
|
|
|
|
|
|
print("\n各关节电机需出的重力力矩(绕自身轴,姿态搜索最大值):")
|
|
|
|
|
|
for j in range(1, 7):
|
|
|
|
|
|
r = jl["J%d" % j]
|
|
|
|
|
|
print(" J%d %6.2f N·m (该关节以下总重 %.1f N,力臂 %.0f mm)"
|
|
|
|
|
|
% (j, r["tau"] / 1000.0, r["F"], r["Lp"]))
|
2026-09-26 18:05:21 +08:00
|
|
|
|
io.open(DOCS + "/strength.json", "w", encoding="utf-8").write(
|
|
|
|
|
|
json.dumps([dict(截面=a, 零件=b, 关节=d, **(c or {})) for a, b, c, d in rows],
|
|
|
|
|
|
ensure_ascii=False, indent=1, default=float))
|
|
|
|
|
|
return bad
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-10-02 12:06:23 +08:00
|
|
|
|
|
2026-09-26 18:05:21 +08:00
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
|
b = main()
|
|
|
|
|
|
print("\n不合格截面 %d 处" % len(b))
|
2026-10-15 03:31:20 +08:00
|
|
|
|
|
|
|
|
|
|
# OCC/cadquery 的静态析构在解释器正常退出时必定段错误(实测:光是
|
|
|
|
|
|
# `python -c "import arm.cad.helpers"` 就返回 139,与本项目代码无关)。所以入口
|
|
|
|
|
|
# 脚本跑完后显式刷缓冲再 os._exit —— 否则全流程已经成功、exit code 却是 139,
|
|
|
|
|
|
# 会把上游脚本的 set -e 判成失败。只在 __main__ 出口生效, 被 import 时不触发。
|
|
|
|
|
|
sys.stdout.flush()
|
2026-09-26 18:05:21 +08:00
|
|
|
|
os._exit(0)
|