Files
grabowski e8674795f5 Inline QC station: real 40x40 profiles, labelled investor render
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.
2026-10-18 14:07:41 +07:00

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")