User, 18 Oct 2026: use real 40x40 profiles, make a render with text pointing out the parts to show the investor; the ring lights looked like they were floating. - build_station.py: frame drawn from the real 8-slot 40 series section (profile_shape: slots, centre bore, corner cavities; 40x80 with centre cavity) and 24 cast angle brackets; top beams now butt between the uprights; label-camera posts moved beside the cameras so both ring lights clamp straight to them; top line-scan on a side post with visible camera plate and light hangers; slide strips on carrier angles, pulley axles, end-stop posts, presence-sensor brackets, backlight post, cabinet on base crosses, chute on two supports and clear of the rail, pneumatics rail, stack light on the back upright, touch screen on the front end upright. A connectivity check at the end lists any part that does not reach the floor through touching parts: none. - render_annotated.py (FreeCAD): front three-quarter view, camera backed off so nothing is clipped, no navigation cube or preselection, label positions projected from the camera and checked with marker renders. - annotate_render.py (Pillow): title, 16 labels in two columns, leaders assigned so none cross -> inline-qc-station-annotated.png. - render_station.py: same camera and preselection fixes; all six views re-rendered. - BOM: 32 m of 40x40 in 45 pieces; total still USD 16k to 33k. - inline-optical-qc.md and README updated; .gitignore for the render intermediates.
481 lines
25 KiB
Python
481 lines
25 KiB
Python
# Build the CNX Vinyl inline optical QC station (concept) in FreeCAD.
|
|
#
|
|
# Run inside the FreeCAD GUI (26.3):
|
|
# exec(open(r"C:/dev/cnx_vinyl/docs/equipment/inline-qc-station/build_station.py", encoding="utf-8").read())
|
|
# It creates the document "InlineQCStation", saves inline-qc-station.FCStd next
|
|
# to this script and leaves the view open for rendering (render_station.py).
|
|
#
|
|
# Concept, not a fabrication drawing: the frame uses the real 8-slot 40 series
|
|
# section (profile_shape), bought parts are drawn as their envelope boxes. Sizes
|
|
# follow ../inline-optical-qc.md. At the end the script lists any part that does
|
|
# not reach the floor through parts it touches (panels not counted).
|
|
#
|
|
# Axes: x = record flow (infeed at x = 0, outfeed at x = L), y = across
|
|
# (operator front at -y), z = up from the floor. All values in mm.
|
|
|
|
import os
|
|
import FreeCAD as App
|
|
import Part
|
|
|
|
try:
|
|
import FreeCADGui as Gui
|
|
HAVE_GUI = App.GuiUp
|
|
except Exception:
|
|
HAVE_GUI = False
|
|
|
|
HERE = os.path.dirname(os.path.abspath(__file__)) if "__file__" in dir() else r"C:/dev/cnx_vinyl/docs/equipment/inline-qc-station"
|
|
OUT = os.path.join(HERE, "inline-qc-station.FCStd")
|
|
V = App.Vector
|
|
|
|
# ---------------------------------------------------------------- parameters
|
|
L = 1900 # frame length along the flow
|
|
YF = 300 # centre of the outer frame profiles, +/- y
|
|
P = 40 # profile size (40 x 40 slot profile; 40 x 80 for the base)
|
|
ZB = 900 # top of the rim belts = record underside while conveyed
|
|
R_REC = 150.8 # 12" record radius (IEC 98: 301.6 mm diameter)
|
|
T_REC = 2.0 # record thickness (approx.)
|
|
R_LABEL = 50.0 # label radius
|
|
R_HOLE = 3.62 # centre hole 7.24 mm
|
|
R_REC_AREA = 146.3 # recorded outer radius (292.6 mm max): the rim outside it carries no grooves
|
|
Y_BELT = 148.5 # round belts run under the unrecorded rim
|
|
ZL = 960 # record underside when the chuck has lifted it
|
|
LIFT = 75 # chuck stroke: pad top 885 (below the belts) -> 960
|
|
|
|
X_IN = 200 # infeed: operator or Classic unloader puts the record here
|
|
X_LB = 520 # label gate: both label cameras, on the fly with strobe
|
|
X_IS = 880 # inspection: lift, clamp, one turn under the line-scans
|
|
X_RJ = 1320 # reject pusher
|
|
X_OUT = L - 30 - R_REC - 4 # outfeed end stop: good records wait here
|
|
XT0, XT1 = 320, 1100 # light-tight tunnel uprights (inner faces 360 / 1100)
|
|
|
|
TOP_LINE = (X_IS + 50, X_IS + 155) # top line-scan sees the +x radius, r 50..155
|
|
BOT_LINE = (X_IS - 155, X_IS - 50) # bottom line-scan sees the -x radius
|
|
WD_LINE = 330 # 4k x 7 um sensor, 160 mm field, f = 50 mm -> ~330 mm working distance
|
|
WD_LABEL = 303 # 2840 px x 2.74 um sensor, 140 mm field, f = 16 mm
|
|
|
|
# ---------------------------------------------------------------- colours
|
|
ALU = (0.78, 0.80, 0.83)
|
|
DARK = (0.18, 0.19, 0.21)
|
|
BLACK = (0.04, 0.04, 0.05)
|
|
CAM = (0.36, 0.52, 0.70) # camera bodies
|
|
LENS = (0.08, 0.08, 0.09)
|
|
LIGHT = (0.97, 0.95, 0.80) # emitting faces
|
|
LIGHTBODY = (0.25, 0.25, 0.27)
|
|
BELT = (0.20, 0.62, 0.34) # green PU round belt
|
|
UHMW = (0.95, 0.95, 0.93)
|
|
PNEU = (0.70, 0.72, 0.75)
|
|
SERVO = (0.12, 0.12, 0.14)
|
|
RED = (0.85, 0.10, 0.10)
|
|
YELLOW = (0.95, 0.80, 0.10)
|
|
GREEN = (0.15, 0.70, 0.25)
|
|
AMBER = (0.98, 0.60, 0.10)
|
|
PANEL = (0.06, 0.06, 0.07)
|
|
CLEAR = (0.75, 0.85, 0.95)
|
|
CABINET = (0.72, 0.73, 0.70)
|
|
LASER = (1.0, 0.05, 0.05)
|
|
|
|
for _d in list(App.listDocuments().values()):
|
|
if _d.Name.startswith("InlineQCStation") or _d.FileName.replace("\\", "/").endswith("inline-qc-station.FCStd"):
|
|
App.closeDocument(_d.Name)
|
|
doc = App.newDocument("InlineQCStation")
|
|
doc.Label = "CNX inline optical QC station (concept)"
|
|
groups = {}
|
|
|
|
|
|
def grp(name):
|
|
if name not in groups:
|
|
g = doc.addObject("App::DocumentObjectGroup", name)
|
|
groups[name] = g
|
|
return groups[name]
|
|
|
|
|
|
def add(group, name, shape, color=ALU, transp=0):
|
|
o = doc.addObject("Part::Feature", name)
|
|
o.Shape = shape
|
|
grp(group).addObject(o)
|
|
if HAVE_GUI:
|
|
vo = o.ViewObject
|
|
vo.ShapeColor = color
|
|
vo.Transparency = transp
|
|
vo.LineColor = (0.25, 0.25, 0.25)
|
|
return o
|
|
|
|
|
|
def box(x0, y0, z0, x1, y1, z1):
|
|
return Part.makeBox(x1 - x0, y1 - y0, z1 - z0, V(x0, y0, z0))
|
|
|
|
|
|
def cyl(r, h, base, d=V(0, 0, 1)):
|
|
return Part.makeCylinder(r, h, base, d)
|
|
|
|
|
|
# ---------------------------------------------------------------- real slot profiles
|
|
# 8-slot 40 series (item / Misumi HFS8 class): 8.2 mm slot opening, 4.3 mm lip,
|
|
# undercut chamber 16 mm wide, slot depth 12 mm, centre bore 6.8 mm (M8 tap),
|
|
# corner cavities, 0.8 mm corner chamfer. 40 x 80 adds a centre cavity.
|
|
ALU_BRACKET = (0.60, 0.62, 0.65)
|
|
_FACES = {}
|
|
|
|
|
|
def _slot(cx, cy, nx, ny):
|
|
td = [(-4.1, -1), (-4.1, 4.3), (-8.0, 4.3), (-8.0, 7.5), (-4.1, 12.0),
|
|
(4.1, 12.0), (8.0, 7.5), (8.0, 4.3), (4.1, 4.3), (4.1, -1)]
|
|
tx, ty = -ny, nx
|
|
pts = [V(cx + t * tx + d * nx, cy + t * ty + d * ny, 0) for t, d in td]
|
|
return Part.Face(Part.makePolygon(pts + [pts[0]]))
|
|
|
|
|
|
def profile_face(a, b):
|
|
"""Cross-section a (local X) by b (local Y), both multiples of 40, corner at the origin."""
|
|
if (a, b) in _FACES:
|
|
return _FACES[(a, b)]
|
|
c = 0.8
|
|
o = [V(c, 0, 0), V(a - c, 0, 0), V(a, c, 0), V(a, b - c, 0), V(a - c, b, 0), V(c, b, 0), V(0, b - c, 0), V(0, c, 0)]
|
|
f = Part.Face(Part.makePolygon(o + [o[0]]))
|
|
cuts = []
|
|
for i in range(int(a // 40)):
|
|
cuts += [_slot(20 + 40 * i, 0, 0, 1), _slot(20 + 40 * i, b, 0, -1)]
|
|
for j in range(int(b // 40)):
|
|
cuts += [_slot(0, 20 + 40 * j, 1, 0), _slot(a, 20 + 40 * j, -1, 0)]
|
|
for i in range(int(a // 40)):
|
|
for j in range(int(b // 40)):
|
|
cuts.append(Part.Face(Part.Wire(Part.makeCircle(3.4, V(20 + 40 * i, 20 + 40 * j, 0)))))
|
|
for cx, cy in [(5.4, 5.4), (a - 5.4, 5.4), (a - 5.4, b - 5.4), (5.4, b - 5.4)]:
|
|
cuts.append(Part.Face(Part.Wire(Part.makeCircle(2.2, V(cx, cy, 0)))))
|
|
if a == 40 and b == 80:
|
|
cuts.append(Part.Face(Part.makePolygon([V(14, 33, 0), V(26, 33, 0), V(26, 47, 0), V(14, 47, 0), V(14, 33, 0)])))
|
|
if a == 80 and b == 40:
|
|
cuts.append(Part.Face(Part.makePolygon([V(33, 14, 0), V(47, 14, 0), V(47, 26, 0), V(33, 26, 0), V(33, 14, 0)])))
|
|
for k in cuts:
|
|
f = f.cut(k)
|
|
_FACES[(a, b)] = f
|
|
return f
|
|
|
|
|
|
def profile_shape(x0, y0, z0, x1, y1, z1):
|
|
"""A real slot profile filling the axis-aligned bar x0..x1, y0..y1, z0..z1."""
|
|
dx, dy, dz = x1 - x0, y1 - y0, z1 - z0
|
|
long_axis = max((dx, "x"), (dy, "y"), (dz, "z"))[1]
|
|
if long_axis == "x": # local X -> Y, local Y -> Z, extrusion -> X
|
|
a, b, n = dy, dz, dx
|
|
m = App.Matrix(0, 0, 1, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1)
|
|
elif long_axis == "y": # local X -> Z, local Y -> X, extrusion -> Y
|
|
a, b, n = dz, dx, dy
|
|
m = App.Matrix(0, 1, 0, 0, 0, 0, 1, 0, 1, 0, 0, 0, 0, 0, 0, 1)
|
|
else:
|
|
a, b, n = dx, dy, dz
|
|
m = App.Matrix()
|
|
a, b = round(a), round(b)
|
|
if a % 40 or b % 40:
|
|
return box(x0, y0, z0, x1, y1, z1)
|
|
sol = profile_face(a, b).extrude(V(0, 0, n))
|
|
if long_axis != "z":
|
|
sol.transformShape(m)
|
|
sol.translate(V(x0, y0, z0))
|
|
return sol
|
|
|
|
|
|
def prof(group, name, x0, y0, z0, x1, y1, z1):
|
|
return add(group, name, profile_shape(x0, y0, z0, x1, y1, z1), ALU)
|
|
|
|
|
|
def gusset(group, name, corner, d1, d2, w):
|
|
"""Cast angle bracket in the inside corner: legs 32 mm along d1 and d2, 34 mm wide along w."""
|
|
p0 = corner - w * 17
|
|
pts = [p0, p0 + d1 * 32, p0 + d2 * 32, p0]
|
|
tri = Part.Face(Part.makePolygon(pts))
|
|
return add(group, name, tri.extrude(w * 34), ALU_BRACKET)
|
|
|
|
|
|
def rot(shape, center, axis, deg):
|
|
s = shape.copy()
|
|
s.rotate(center, axis, deg)
|
|
return s
|
|
|
|
|
|
def ring(r_out, r_in, h, base):
|
|
return cyl(r_out, h, base).cut(cyl(r_in, h + 2, base - V(0, 0, 1)))
|
|
|
|
|
|
# ---------------------------------------------------------------- frame
|
|
G = "Frame"
|
|
for i, (x, y) in enumerate([(20, -YF), (20, YF), (XT0 + 20, -YF), (XT0 + 20, YF),
|
|
(XT1 + 20, -YF), (XT1 + 20, YF), (L - 20, -YF), (L - 20, YF)]):
|
|
add(G, f"Foot{i}", cyl(30, 12, V(x, y, 0)).fuse(cyl(8, 50, V(x, y, 12))), DARK)
|
|
for s in (-1, 1):
|
|
prof(G, f"BaseBeam{'F' if s < 0 else 'B'}", 0, s * YF - 20, 60, L, s * YF + 20, 140) # 40 x 80
|
|
for x0, z1, nm in [(0, 870, "End0"), (XT0, 1500, "Tunnel0"), (XT1, 1500, "Tunnel1"),
|
|
(L - P, 1340 if s < 0 else 1080, "End1")]: # front end upright carries the touch screen
|
|
prof(G, f"Upright{nm}{'F' if s < 0 else 'B'}", x0, s * YF - 20, 140, x0 + P, s * YF + 20, z1)
|
|
prof(G, f"TopBeam{'F' if s < 0 else 'B'}", XT0 + P, s * YF - 20, 1460, XT1, s * YF + 20, 1500)
|
|
prof(G, f"LowBeam{'F' if s < 0 else 'B'}", XT0 + P, s * YF - 20, 420, XT1, s * YF + 20, 460)
|
|
prof(G, f"RejectBeam{'F' if s < 0 else 'B'}", XT1 + P, s * YF - 20, 1040, L - P, s * YF + 20, 1080)
|
|
for x0, nm in [(0, "End0"), (XT0, "Tunnel0"), (XT1, "Tunnel1"), (L - P, "End1")]:
|
|
prof(G, f"BaseCross{nm}", x0, -YF + 20, 100, x0 + P, YF - 20, 140)
|
|
prof(G, f"ConvCross{nm}", x0, -YF + 20, 790, x0 + P, YF - 20, 830)
|
|
# top bridge: label camera and top line-scan
|
|
prof(G, "TopCrossLabel", X_LB + 70, -YF + 20, 1460, X_LB + 110, YF - 20, 1500)
|
|
xt = sum(TOP_LINE) / 2
|
|
prof(G, "TopCrossLineScan", xt + 48, -YF + 20, 1460, xt + 88, YF - 20, 1500)
|
|
prof(G, "TopCrossThermal", XT0 + P, -YF + 20, 1460, XT0 + P + 40, YF - 20, 1500)
|
|
prof(G, "TopCrossBacklight", sum(BOT_LINE) / 2 - 38, -YF + 20, 1460, sum(BOT_LINE) / 2 + 2, YF - 20, 1500)
|
|
# lower bridge: bottom label camera, bottom line-scan, chuck lift
|
|
xb = sum(BOT_LINE) / 2
|
|
prof(G, "LowCrossLabel", X_LB + 70, -YF + 20, 420, X_LB + 110, YF - 20, 460)
|
|
prof(G, "LowCrossLineScan", xb - 20, -YF + 20, 420, xb + 20, YF - 20, 460)
|
|
prof(G, "LowCrossChuck", X_IS - 20, -YF + 20, 420, X_IS + 20, YF - 20, 460)
|
|
prof(G, "LowCrossBacklight", xt - 20, -YF + 20, 420, xt + 20, YF - 20, 460)
|
|
prof(G, "BaseCrossCabinet0", 1200, -YF + 20, 100, 1240, YF - 20, 140)
|
|
prof(G, "BaseCrossCabinet1", 1580, -YF + 20, 100, 1620, YF - 20, 140)
|
|
prof(G, "RejectBridgeCross", X_RJ - 20, -YF + 20, 1040, X_RJ + 20, YF - 20, 1080)
|
|
|
|
# cast angle brackets in the visible frame corners
|
|
EX, EZ, EY = V(1, 0, 0), V(0, 0, 1), V(0, 1, 0)
|
|
_k = 0
|
|
for s in (-1, 1):
|
|
y = s * YF
|
|
for x, dirs in [(P, (1,)), (XT0, (-1,)), (XT0 + P, (1,)), (XT1, (-1,)), (XT1 + P, (1,)), (L - P, (-1,))]:
|
|
for d in dirs:
|
|
gusset(G, f"Bracket{_k}", V(x, y, 140), EX * d, EZ, EY); _k += 1
|
|
for x, d, z in [(XT0 + P, 1, 1460), (XT1, -1, 1460), (XT0 + P, 1, 420), (XT1, -1, 420),
|
|
(XT1 + P, 1, 1040), (L - P, -1, 1040)]:
|
|
gusset(G, f"Bracket{_k}", V(x, y, z), EX * d, EZ * -1, EY); _k += 1
|
|
|
|
# ---------------------------------------------------------------- conveyor
|
|
G = "Conveyor"
|
|
for s in (-1, 1):
|
|
side = "F" if s < 0 else "B"
|
|
prof(G, f"Rail{side}", 0, s * 180 - 20, 830, L, s * 180 + 20, 870)
|
|
add(G, f"BeltSlideCarrier{side}", box(45, min(s * 140, s * 200), 870, L - 45, max(s * 140, s * 200), 880), DARK)
|
|
add(G, f"BeltSlide{side}", box(45, s * Y_BELT - 5, 880, L - 45, s * Y_BELT + 5, 894), UHMW)
|
|
add(G, f"BeltTop{side}", cyl(3, L - 40, V(20, s * Y_BELT, ZB - 3), V(1, 0, 0)), BELT)
|
|
add(G, f"BeltReturn{side}", cyl(3, L - 40, V(20, s * Y_BELT, ZB - 49), V(1, 0, 0)), BELT)
|
|
for x, nm in [(20, "Idler"), (L - 20, "Drive")]:
|
|
add(G, f"Pulley{nm}{side}", cyl(23, 14, V(x, s * Y_BELT - 7, ZB - 26), V(0, 1, 0)), DARK)
|
|
add(G, f"PulleyAxle{nm}{side}", cyl(6, 6, V(x, min(s * (Y_BELT + 6), s * 160), ZB - 26), V(0, 1, 0)), PNEU)
|
|
# infeed side guides with a flared mouth, PU strip on the rim height
|
|
add(G, f"SideGuide{side}", box(110, s * 157 - 3, ZB + 2, XT0 - 10, s * 157 + 3, ZB + 14), UHMW)
|
|
add(G, f"SideGuideFlare{side}", rot(box(40, s * 157 - 3, ZB + 2, 112, s * 157 + 3, ZB + 14),
|
|
V(110, s * 157, ZB), V(0, 0, 1), s * 25), UHMW)
|
|
add(G, f"GuidePost{side}", box(150, min(s * 160, s * 172), 870, 170, max(s * 160, s * 172), ZB + 14), DARK)
|
|
add(G, "DriveShaft", cyl(6, 2 * Y_BELT + 60, V(L - 20, -Y_BELT - 30, ZB - 26), V(0, 1, 0)), DARK)
|
|
add(G, "DriveGearmotor", cyl(35, 150, V(L - 20, 210, ZB - 26), V(0, 1, 0))
|
|
.fuse(box(L - 60, 200, ZB - 70, L + 15, 230, ZB + 15)), SERVO)
|
|
add(G, "EndStopBar", box(L - 30, -200, ZB + 30, L - 12, 200, ZB + 48), DARK)
|
|
for s in (-1, 1):
|
|
add(G, f"EndStopPost{'F' if s < 0 else 'B'}", box(L - 30, min(s * 170, s * 190), 870, L - 12, max(s * 170, s * 190), ZB + 30), DARK)
|
|
add(G, "EndStopBumper", box(L - 32, -40, ZB + 4, L - 26, 40, ZB + 30), (0.9, 0.5, 0.1))
|
|
|
|
# presence sensors (diffuse, looking up through the open centre) and the stop beam
|
|
G = "Sensors"
|
|
for x, nm in [(X_IN, "Infeed"), (X_LB - 120, "LabelTrigger"), (L - 120, "Outfeed")]:
|
|
add(G, f"Presence{nm}", box(x - 15, -120, 850, x + 15, -100, 885), (0.15, 0.35, 0.75))
|
|
add(G, f"PresenceBracket{nm}", box(x - 10, -160, 840, x + 10, -100, 850), DARK)
|
|
xs = X_IS + R_REC
|
|
for s in (-1, 1):
|
|
add(G, f"StopBeam{'Tx' if s < 0 else 'Rx'}", box(xs - 10, min(s * 200, s * 222), 870, xs + 10, max(s * 200, s * 222), ZB + 25),
|
|
(0.15, 0.35, 0.75))
|
|
add(G, "StopBeamRay", cyl(0.8, 400, V(xs, -200, ZB + 8), V(0, 1, 0)), LASER, 40)
|
|
|
|
# ---------------------------------------------------------------- label gate
|
|
G = "LabelGate"
|
|
add(G, "LabelCamTop", box(X_LB - 15, -15, ZB + WD_LABEL + 50, X_LB + 15, 15, ZB + WD_LABEL + 92), CAM)
|
|
add(G, "LabelLensTop", cyl(17, 50, V(X_LB, 0, ZB + WD_LABEL)), LENS)
|
|
add(G, "LabelRingTop", ring(70, 42, 22, V(X_LB, 0, ZB + 170)), LIGHTBODY)
|
|
add(G, "LabelRingTopFace", ring(62, 48, 1, V(X_LB, 0, ZB + 169)), LIGHT)
|
|
prof(G, "LabelCamTopPost", X_LB + 70, -20, ZB + 160, X_LB + 110, 20, 1460)
|
|
add(G, "LabelCamTopPlate", box(X_LB + 15, -12, ZB + WD_LABEL + 55, X_LB + 70, 12, ZB + WD_LABEL + 87), DARK)
|
|
add(G, "LabelRingTopClamp", box(X_LB + 58, -14, ZB + 166, X_LB + 70, 14, ZB + 196), DARK)
|
|
zl = ZB - WD_LABEL
|
|
add(G, "LabelCamBottom", box(X_LB - 15, -15, zl - 92, X_LB + 15, 15, zl - 50), CAM)
|
|
add(G, "LabelLensBottom", cyl(17, 50, V(X_LB, 0, zl - 50)), LENS)
|
|
add(G, "LabelRingBottom", ring(70, 42, 22, V(X_LB, 0, ZB - 80)), LIGHTBODY)
|
|
add(G, "LabelRingBottomFace", ring(62, 48, 1, V(X_LB, 0, ZB - 58)), LIGHT)
|
|
prof(G, "LabelCamBottomPost", X_LB + 70, -20, 460, X_LB + 110, 20, ZB - 48)
|
|
add(G, "LabelCamBottomPlate", box(X_LB + 15, -12, zl - 87, X_LB + 70, 12, zl - 55), DARK)
|
|
add(G, "LabelRingBottomClamp", box(X_LB + 58, -14, ZB - 84, X_LB + 70, 14, ZB - 54), DARK)
|
|
|
|
# ---------------------------------------------------------------- inspection: lift, clamp, one turn
|
|
G = "Chuck"
|
|
zp = ZL # pad top, lifted (shown lifted)
|
|
add(G, "ChuckPad", cyl(40, 10, V(X_IS, 0, zp - 10)), (0.85, 0.45, 0.10))
|
|
add(G, "ChuckMandrel", cyl(3.5, 9, V(X_IS, 0, zp)).fuse(Part.makeCone(3.5, 1.2, 4, V(X_IS, 0, zp + 9))), DARK)
|
|
add(G, "SpindleHousing", cyl(25, 110, V(X_IS, 0, zp - 120)), PNEU)
|
|
add(G, "LiftPlate", box(X_IS - 40, -40, zp - 132, X_IS + 40, 150, zp - 120), DARK)
|
|
add(G, "SpindleServo", cyl(30, 110, V(X_IS, 110, zp - 242)), SERVO)
|
|
add(G, "SpindleBeltCover", box(X_IS - 32, -32, zp - 120, X_IS + 32, 142, zp - 100), (0.3, 0.3, 0.32))
|
|
add(G, "LiftCylinderRods", cyl(6, 100, V(X_IS - 30, 0, zp - 232)).fuse(cyl(6, 100, V(X_IS + 30, 0, zp - 232))), PNEU)
|
|
add(G, "LiftCylinder", box(X_IS - 50, -25, zp - 322, X_IS + 50, 25, zp - 232), PNEU)
|
|
prof(G, "LiftPost", X_IS - 20, -20, 460, X_IS + 20, 20, zp - 322)
|
|
|
|
G = "LineScanTop"
|
|
zr = ZL + T_REC # top face of the lifted record
|
|
add(G, "LineCamTop", box(xt - 31, -31, zr + WD_LINE + 60, xt + 31, 31, zr + WD_LINE + 105), CAM)
|
|
add(G, "LineLensTop", cyl(25, 60, V(xt, 0, zr + WD_LINE)), LENS)
|
|
prof(G, "LineCamTopPost", xt + 48, -20, zr + 150, xt + 88, 20, 1460)
|
|
add(G, "LightHangerTop", box(xt + 58, -112, zr + 130, xt + 78, 112, zr + 150), DARK)
|
|
add(G, "CoaxHangerTop", box(xt + 58, -10, zr + 80, xt + 78, 10, zr + 130), DARK)
|
|
add(G, "LineCamTopPlate", box(xt + 31, -20, zr + WD_LINE + 62, xt + 48, 20, zr + WD_LINE + 103), DARK)
|
|
x0, x1 = TOP_LINE[0] - 5, TOP_LINE[1] + 5
|
|
add(G, "CoaxLightTop", box(x0, -30, zr + 50, x1, 30, zr + 110), LIGHTBODY)
|
|
add(G, "CoaxLightTopFace", box(x0 + 3, -26, zr + 49, x1 - 3, 26, zr + 50), LIGHT)
|
|
for s in (-1, 1):
|
|
b = box(x0, -15, 0, x1, 15, 30)
|
|
b.translate(V(0, s * 100, zr + 15))
|
|
add(G, f"DarkFieldTop{'F' if s < 0 else 'B'}", rot(b, V(0, s * 100, zr + 30), V(1, 0, 0), s * 20), LIGHTBODY)
|
|
add(G, f"DarkFieldTopBracket{'F' if s < 0 else 'B'}", box(xt + 58, s * 100 - 5, zr + 48, xt + 78, s * 100 + 5, zr + 130), DARK)
|
|
bx0, bx1 = BOT_LINE[0] - 5, BOT_LINE[1] + 5
|
|
add(G, "BacklightForBottomCam", box(bx0, -20, zr + 30, bx1, 20, zr + 52), LIGHTBODY)
|
|
add(G, "BacklightForBottomCamFace", box(bx0 + 3, -17, zr + 29, bx1 - 3, 17, zr + 30), LIGHT)
|
|
add(G, "BacklightBracketTop", box(xb - 30, -5, zr + 52, xb - 10, 5, 1460), DARK)
|
|
add(G, "ScanLineTop", box(TOP_LINE[0], -0.6, zr + 0.05, TOP_LINE[1], 0.6, zr + 0.5), LASER)
|
|
add(G, "ThermalPost", box(XT0 + 60, -240, 1400, XT0 + 80, -220, 1460), DARK)
|
|
add(G, "ThermalCam", rot(box(XT0 + 70, -260, 1380, XT0 + 130, -200, 1440), V(XT0 + 100, -230, 1410), V(1, 0, 0), -30), (0.95, 0.45, 0.10))
|
|
|
|
G = "LineScanBottom"
|
|
zu = ZL # underside of the lifted record
|
|
add(G, "LineCamBottom", box(xb - 31, -31, zu - WD_LINE - 105, xb + 31, 31, zu - WD_LINE - 60), CAM)
|
|
add(G, "LineLensBottom", cyl(25, 60, V(xb, 0, zu - WD_LINE - 60)), LENS)
|
|
prof(G, "LineCamBottomPost", xb - 20, 40, 460, xb + 20, 80, ZB - 20)
|
|
add(G, "CoaxHangerBottom", box(xb - 10, 30, ZB - 70, xb + 10, 40, ZB - 30), DARK)
|
|
add(G, "LineCamBottomPlate", box(xb - 20, 31, zu - WD_LINE - 105, xb + 20, 40, zu - WD_LINE - 60), DARK)
|
|
add(G, "CoaxLightBottom", box(bx0, -30, ZB - 80, bx1, 30, ZB - 20), LIGHTBODY)
|
|
add(G, "CoaxLightBottomFace", box(bx0 + 3, -26, ZB - 20, bx1 - 3, 26, ZB - 19), LIGHT)
|
|
for s in (-1, 1):
|
|
b = box(bx0, -15, 0, bx1, 15, 30)
|
|
b.translate(V(0, s * 175, zu - 40))
|
|
add(G, f"DarkFieldBottom{'F' if s < 0 else 'B'}", rot(b, V(0, s * 175, zu - 25), V(1, 0, 0), -s * 8), LIGHTBODY)
|
|
add(G, f"DarkFieldBottomBracket{'F' if s < 0 else 'B'}", box(bx0 + 40, s * 192 - 5, 870, bx0 + 60, s * 192 + 5, zu - 35), DARK)
|
|
add(G, "BacklightForTopCam", box(x0, -20, ZB - 40, x1, 20, ZB - 15), LIGHTBODY)
|
|
prof(G, "BacklightPost", xt - 20, -20, 460, xt + 20, 20, ZB - 40)
|
|
add(G, "BacklightForTopCamFace", box(x0 + 3, -17, ZB - 15, x1 - 3, 17, ZB - 14), LIGHT)
|
|
add(G, "ScanLineBottom", box(BOT_LINE[0], -0.6, zu - 0.5, BOT_LINE[1], 0.6, zu - 0.05), LASER)
|
|
|
|
G = "WarpLasers"
|
|
for r in (90, 130):
|
|
add(G, f"Laser_r{r}", box(X_IS - 23, -r - 10, ZB - 55, X_IS + 23, -r + 10, ZB - 15), (0.15, 0.35, 0.75))
|
|
add(G, f"LaserBeam_r{r}", cyl(0.8, zu - (ZB - 15), V(X_IS, -r, ZB - 15)), LASER, 30)
|
|
add(G, f"LaserBracket_r{r}", box(X_IS - 10, -r - 10, 830, X_IS + 10, -r + 10, ZB - 55), DARK)
|
|
prof(G, "LaserRail", X_IS - 20, -200, 790, X_IS + 20, -70, 830)
|
|
|
|
# ---------------------------------------------------------------- reject
|
|
G = "Reject"
|
|
add(G, "RodlessCylinder", box(X_RJ - 30, -300, 1000, X_RJ + 30, 300, 1040), PNEU)
|
|
yc = -205 # carriage retracted at the front
|
|
add(G, "PusherCarriage", box(X_RJ - 35, yc - 30, 960, X_RJ + 35, yc + 30, 1000), DARK)
|
|
add(G, "PusherArm", box(X_RJ - 10, yc - 10, ZB + 26, X_RJ + 10, yc + 10, 960), DARK)
|
|
add(G, "PusherPaddle", box(X_RJ - 100, yc + 10, ZB + 4, X_RJ + 100, yc + 22, ZB + 26), (0.9, 0.5, 0.1))
|
|
CX0, CX1 = X_RJ - 170, X_RJ + 170
|
|
Y_CH, Z_CH = 205, ZB - 7 # chute top edge: just outside the back rail, below the belt plane
|
|
chute = box(CX0, 0, 0, CX1, 360, 6)
|
|
chute = rot(chute, V(0, 0, 0), V(1, 0, 0), -42)
|
|
chute.translate(V(0, Y_CH, Z_CH))
|
|
add(G, "RejectChute", chute, (0.55, 0.57, 0.60))
|
|
for xw in (CX0 - 6, CX1):
|
|
w = box(xw, 0, 0, xw + 6, 360, 80)
|
|
w = rot(w, V(0, 0, 0), V(1, 0, 0), -42)
|
|
w.translate(V(0, Y_CH, Z_CH))
|
|
add(G, f"ChuteWall{xw}", w, (0.55, 0.57, 0.60))
|
|
import math
|
|
_zc = Z_CH - math.tan(math.radians(42)) * (YF + 20 - Y_CH) # chute underside above the back frame line
|
|
for i, xs_ in enumerate((CX0 + 10, CX1 - 50)):
|
|
prof(G, f"ChuteSupport{i}", xs_, YF - 20, 140, xs_ + 40, YF + 20, math.floor(_zc))
|
|
binb = box(CX0 - 20, 380, 0, CX1 + 20, 720, 560).cut(box(CX0 - 8, 392, 12, CX1 + 8, 708, 600))
|
|
add(G, "RejectBin", binb, (0.85, 0.20, 0.15), 20)
|
|
|
|
# ---------------------------------------------------------------- enclosure (black, light-tight tunnel)
|
|
G = "Enclosure"
|
|
slot = box(XT0 - 10, -205, 860, XT1 + P + 10, 205, ZB + 30)
|
|
for x, nm in [(XT0 - 3, "Infeed"), (XT1 + P, "Outfeed")]:
|
|
up = box(x, -YF - 22, 870, x + 3, YF + 22, 1500).cut(slot)
|
|
add(G, f"EndWallUpper{nm}", up, PANEL, 55)
|
|
lo = box(x, -YF - 22, 140, x + 3, YF + 22, 870).cut(box(x - 1, -205, 815, x + 4, 205, 880))
|
|
add(G, f"EndWallLower{nm}", lo, PANEL, 55)
|
|
for s in (-1, 1):
|
|
side = "Front" if s < 0 else "Back"
|
|
y0 = s * (YF + 20) if s > 0 else -YF - 23
|
|
add(G, f"Panel{side}Upper", box(XT0, y0, 870, XT1 + P, y0 + 3, 1500), PANEL, 55)
|
|
add(G, f"Panel{side}Lower", box(XT0, y0, 140, XT1 + P, y0 + 3, 870), PANEL, 55)
|
|
add(G, "PanelTop", box(XT0, -YF - 23, 1500, XT1 + P, YF + 23, 1503), PANEL, 55)
|
|
add(G, "PanelBottom", box(XT0, -YF - 20, 140, XT1 + P, YF + 20, 143), PANEL, 55)
|
|
add(G, "DoorHandle", box(700, -YF - 45, 1150, 860, -YF - 23, 1175), DARK)
|
|
add(G, "GuardRejectFront", box(XT1 + P, -YF - 23, 870, L, -YF - 20, 1100), CLEAR, 75)
|
|
add(G, "GuardRejectTop", box(XT1 + P, -YF - 23, 1100, L, YF + 23, 1103), CLEAR, 75)
|
|
|
|
# ---------------------------------------------------------------- electrical and operator
|
|
G = "Electrical"
|
|
add(G, "Cabinet", box(1180, -200, 140, 1640, 170, 750), CABINET)
|
|
add(G, "CabinetHandle", box(1600, -215, 400, 1615, -200, 520), DARK)
|
|
add(G, "StackLightPole", cyl(10, 123, V(XT1 + 20, YF, 1500)), DARK)
|
|
for i, c in enumerate([GREEN, AMBER, RED]):
|
|
add(G, f"StackLight{i}", cyl(25, 57, V(XT1 + 20, YF, 1623 + i * 57)), c)
|
|
add(G, "HMIArm", box(L - 40, -YF - 80, 1225, L, -YF - 20, 1245), DARK)
|
|
add(G, "HMI", rot(box(L - 260, -YF - 100, 1150, L, -YF - 80, 1320), V(L - 130, -YF - 90, 1235), V(1, 0, 0), -15), (0.10, 0.10, 0.12))
|
|
add(G, "EStopBox", box(XT1, -YF - 60, 930, XT1 + 80, -YF - 20, 1010), YELLOW)
|
|
add(G, "EStop", cyl(22, 25, V(XT1 + 40, -YF - 60, 970), V(0, -1, 0)), RED)
|
|
add(G, "ValveTerminal", box(XT0 + 80, YF + 20, 300, XT0 + 330, YF + 70, 380), PNEU)
|
|
add(G, "FRL", box(XT0 + 360, YF + 20, 280, XT0 + 440, YF + 80, 420), (0.2, 0.4, 0.8))
|
|
prof(G, "PneumaticsRail", XT0 + P, YF - 20, 260, XT1, YF + 20, 300)
|
|
|
|
# ---------------------------------------------------------------- records
|
|
G = "Records"
|
|
|
|
|
|
def record(name, x, y, z, label_color, body=BLACK, transp=0, tilt=None):
|
|
disc = cyl(R_REC, T_REC, V(x, y, z)).cut(cyl(R_HOLE, T_REC + 2, V(x, y, z - 1)))
|
|
lab = cyl(R_LABEL, 0.3, V(x, y, z + T_REC)).cut(cyl(R_HOLE, 2, V(x, y, z + T_REC - 0.5)))
|
|
labb = cyl(R_LABEL, 0.3, V(x, y, z - 0.3)).cut(cyl(R_HOLE, 2, V(x, y, z - 1)))
|
|
if tilt:
|
|
c, ax, deg = tilt
|
|
disc, lab, labb = rot(disc, c, ax, deg), rot(lab, c, ax, deg), rot(labb, c, ax, deg)
|
|
add(G, name, disc, body, transp)
|
|
add(G, name + "LabelA", lab, label_color)
|
|
add(G, name + "LabelB", labb, label_color)
|
|
|
|
|
|
record("RecordInfeed", X_IN, 0, ZB, (0.95, 0.85, 0.75), body=(0.80, 0.12, 0.16), transp=25) # translucent red special
|
|
record("RecordInspection", X_IS, 0, ZL, (0.20, 0.45, 0.80))
|
|
record("RecordOutfeed", X_OUT, 0, ZB, (0.95, 0.60, 0.15))
|
|
import math
|
|
sc, a = 200, math.radians(42)
|
|
yr = Y_CH + math.cos(a) * sc + 6 * math.sin(a)
|
|
zr2 = Z_CH - math.sin(a) * sc + 6 * math.cos(a)
|
|
record("RecordReject", X_RJ, yr, zr2, (0.6, 0.6, 0.6), tilt=(V(X_RJ, yr, zr2), V(1, 0, 0), -42))
|
|
|
|
doc.recompute()
|
|
doc.saveAs(OUT)
|
|
n = len([o for o in doc.Objects if o.TypeId == "Part::Feature"])
|
|
bb = None
|
|
for o in doc.Objects:
|
|
if o.TypeId == "Part::Feature":
|
|
bb = o.Shape.BoundBox if bb is None else bb.united(o.Shape.BoundBox)
|
|
print("objects", n, "bbox", round(bb.XMin), round(bb.XMax), round(bb.YMin), round(bb.YMax), round(bb.ZMin), round(bb.ZMax))
|
|
print("saved", OUT)
|
|
|
|
# every part should touch another one, without counting the panels (hidden in the open views);
|
|
# list the ones that do not (gap > 1 mm)
|
|
_panels = set(o.Name for o in groups["Enclosure"].Group)
|
|
_parts = [o for o in doc.Objects if o.TypeId == "Part::Feature" and o.Name not in _panels]
|
|
# connectivity: every part must reach the floor through touching parts (gap <= 1 mm)
|
|
_adj = {o.Name: set() for o in _parts}
|
|
for ia, oa in enumerate(_parts):
|
|
ba = oa.Shape.BoundBox
|
|
for ob in _parts[ia + 1:]:
|
|
bq = ob.Shape.BoundBox
|
|
if (bq.XMin > ba.XMax + 2 or bq.XMax < ba.XMin - 2 or bq.YMin > ba.YMax + 2 or bq.YMax < ba.YMin - 2
|
|
or bq.ZMin > ba.ZMax + 2 or bq.ZMax < ba.ZMin - 2):
|
|
continue
|
|
if oa.Shape.distToShape(ob.Shape)[0] <= 1.0:
|
|
_adj[oa.Name].add(ob.Name)
|
|
_adj[ob.Name].add(oa.Name)
|
|
_seen = [o.Name for o in _parts if o.Shape.BoundBox.ZMin < 1]
|
|
_todo = list(_seen)
|
|
_seen = set(_seen)
|
|
while _todo:
|
|
for nb in _adj[_todo.pop()]:
|
|
if nb not in _seen:
|
|
_seen.add(nb)
|
|
_todo.append(nb)
|
|
_floating = sorted(n for n in _adj if n not in _seen)
|
|
print("parts not connected to the floor:", _floating if _floating else "none")
|