# -*- 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 import sys sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) try: sys.stdout.reconfigure(encoding="utf-8") except Exception: pass import cadquery as cq from armcore import OUT, DOCS, solids_of, bb, mesh_of 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 import human_layout as HL 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 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 = {} 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.""" meta = json.load(io.open(OUT.replace("/out", "/web") + "/mesh_arm.json", encoding="utf-8")) TREE = ["root", "J1", "J2", "J3", "J4", "J5", "J6"] tot = {} for p in meta["parts"]: 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 # 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 [ ("吊臂根座 @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), ("小臂骨架中段 @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), ("腕偏转座根部 @z=150", "P08_腕偏转座_螺旋体25%_墙4底5_竖打", (0, 0, 1), (HL.X_ARM, Y, 150.0), "J5", 1), ("工具法兰 @z=55", "P09_工具法兰_螺旋体100%_墙4底5_竖打", (0, 0, 1), (HL.X_ARM, Y, 55.0), "J6", 0), ] MOMENT = { # worst reachable bending moment per joint, N*mm # computed in main() from the reachable pose + masses } def main(): masses, per = arm_masses() # Moment arms DERIVED from the swept travel (docs/travel.json), not hand-typed: they were # stale (J4 said 24 deg while the arm now sweeps 360, and J1 was missing entirely), which # silently under-loaded those sections. A continuous or >90 deg axis is capped at 90 deg # because that is where a gravity load sits furthest off the axis. TRV = json.load(io.open(DOCS + "/travel.json", encoding="utf-8")) dev = {t["axis"]: min(max(abs(t["hi"]), abs(t["lo"])), 90.0) for t in TRV} # distance from each joint axis out to the fingertip load, mm dist = {"J1": 564.0, "J2": 444.0, "J3": 380.0, "J4": 264.0, "J5": 129.0, "J6": 60.0} reach = {k: dist[k] * math.sin(math.radians(dev.get(k, 90.0))) for k in dist} 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) m_g = masses[jid] * DYN + PAYLOAD * 1000.0 * DYN F = m_g * G981 / 1000.0 # N # the section carries everything distal to its own joint, at that joint's worst # reachable moment arm, plus the grip reaction on the finger root M = F * reach[jid] # N*mm if jid == "J6": M = max(M, GRIP_T * 60.0) I = sp["I"] # already the real bend axis, not min(Iu,Iv) sigma = M * sp["c"] / max(I, 1e-6) tau = 1.5 * F / sp["A"] return (lab, part, dict(sp, M=M, F=F, sigma=sigma, tau=tau, SF=ALLOW[MAT] / sigma if sigma > 0 else 99.0), jid) with ThreadPoolExecutor(max_workers=min(13, os.cpu_count() or 4)) as executor: rows = list(executor.map(compute_cut, cuts())) print("材料 %s 许用弯曲 %g MPa(跨层、含缺口与蠕变折减) 末端负载 %g kg 动载系数 %g\n" % (MAT, ALLOW[MAT], PAYLOAD, DYN)) print("打印件按 %d%% 实体率计质量(壁+填充),电机按 %.0f g/台" % (INFILL * 100, RHO["motor_g"])) print("%-26s %-10s %7s %9s %6s %8s %7s %5s %9s" % ("截面", "零件", "面积mm2", "Imin mm4", "c mm", "M N*mm", "sigma", "SF", "需Z mm3")) bad = [] for lab, part, d, jid in rows: if not d: print("%-26s %-10s 剖切失败" % (lab, part)) continue Zreq = d["M"] / ALLOW[MAT] * 1.5 # section modulus x1.5 SF print("%-26s %-10s %7.0f %9.0f %6.1f %8.0f %7.1f %5.2f %9.0f%s" % (lab, part, d["A"], d["I"], d["c"], d["M"], d["sigma"], d["SF"], Zreq, " <<< 不足" if d["SF"] < 1.5 else "")) if d["SF"] < 1.5: bad.append((lab, part, d)) print("\n各关节以下质量(g, 含电机): " + " ".join("%s=%.0f" % (k, v) for k, v in masses.items() if k != "all")) print("整机质量(含 6 电机) %.0f g" % masses["all"]) 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 if __name__ == "__main__": b = main() print("\n不合格截面 %d 处" % len(b)) os._exit(0)