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.
No files matched your search
@@ -142,9 +142,19 @@ A concept model of one station, drawn in FreeCAD from the script
|
||||
[`build_station.py`](inline-qc-station/build_station.py) (views from
|
||||
[`render_station.py`](inline-qc-station/render_station.py); model file
|
||||
[`inline-qc-station.FCStd`](inline-qc-station/inline-qc-station.FCStd)).
|
||||
It is a layout to take to a machine builder, not a fabrication drawing:
|
||||
the profiles are drawn as plain 40 mm bars and the bought parts as their
|
||||
outline boxes.
|
||||
It is a layout to take to a machine builder, not a fabrication drawing.
|
||||
The frame uses the real 8-slot 40 series section (slots, centre bore,
|
||||
corner cavities) with cast angle brackets in the corners; bought parts
|
||||
are drawn as their outline boxes. The script checks that every part
|
||||
reaches the floor through parts it touches, so nothing floats.
|
||||
|
||||

|
||||
|
||||
*The labelled view for presentations, made with
|
||||
[`render_annotated.py`](inline-qc-station/render_annotated.py) (FreeCAD
|
||||
view and label positions) and
|
||||
[`annotate_render.py`](inline-qc-station/annotate_render.py) (labels,
|
||||
Pillow).*
|
||||
|
||||

|
||||
|
||||
@@ -154,8 +164,8 @@ outfeed stop (front right). The cabinet sits under the outfeed, the
|
||||
reject bin behind it.*
|
||||
|
||||
**Frame.** The frame is built from 40 × 40 mm 8-slot aluminium profile
|
||||
(27 m in 37 pieces) on two 40 × 80 mm base beams, with eight levelling
|
||||
feet. It is 1.9 m long, 0.65 m wide and 1.5 m high, plus the stack
|
||||
(32 m in 45 pieces) on two 40 × 80 mm base beams, with 24 cast angle
|
||||
brackets in the corners and eight levelling feet. It is 1.9 m long, 0.65 m wide and 1.5 m high, plus the stack
|
||||
light. The reject bin adds 0.4 m behind it. Records travel at 900 mm,
|
||||
a normal working height.
|
||||
|
||||
@@ -251,7 +261,7 @@ By group, per station:
|
||||
|
||||
| Group | USD | Main parts |
|
||||
|---|---|---|
|
||||
| Frame | 0.7k to 1.5k | 27 m of 40 × 40, 3.8 m of 40 × 80, brackets, feet |
|
||||
| Frame | 0.8k to 1.6k | 32 m of 40 × 40, 3.8 m of 40 × 80, angle brackets, feet |
|
||||
| Enclosure and guards | 0.3k to 0.7k | black panels, polycarbonate, door switch |
|
||||
| Conveyor | 0.5k to 1.4k | round belts, pulleys, slide strips, drive |
|
||||
| Handling | 2.0k to 4.9k | guided lift cylinder, 200 W spindle servo, spindle head with mandrel, rodless cylinder, pneumatics |
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
# intermediate files of render_annotated.py / annotate_render.py
|
||||
_*.png
|
||||
_anchors.json
|
||||
*-annotated-base.png
|
||||
*.FCBak
|
||||
@@ -0,0 +1,111 @@
|
||||
# Draw the part labels onto the investor render of the inline QC station.
|
||||
# uv run --with pillow python docs/equipment/inline-qc-station/annotate_render.py
|
||||
# Reads inline-qc-station-annotated-base.png and _anchors.json from render_annotated.py
|
||||
# (FreeCAD) and writes inline-qc-station-annotated.png.
|
||||
import json
|
||||
import os
|
||||
from PIL import Image, ImageChops, ImageDraw, ImageFont
|
||||
|
||||
D = os.path.dirname(os.path.abspath(__file__))
|
||||
FONTS = "C:/Windows/Fonts/"
|
||||
SCALE = 0.75 # 4000 px render, drawn at 3/4 size (anti-aliased)
|
||||
|
||||
TITLE = "Inline optical QC station"
|
||||
SUBTITLE = ("Every record checked on both faces in one turn: surface, label, warp and centring. "
|
||||
"Failed records leave the line automatically.")
|
||||
FOOTER = ("CNX Vinyl, concept model (FreeCAD); bought parts drawn as outlines. 1.9 × 0.65 × 1.5 m, "
|
||||
"front panels and lid removed for the view. Hardware about USD 16k to 33k per station (estimate).")
|
||||
NAVY, GREY, LINE = (18, 53, 91), (85, 95, 105), (60, 74, 90)
|
||||
|
||||
|
||||
def font(name, size):
|
||||
return ImageFont.truetype(FONTS + name, size)
|
||||
|
||||
|
||||
F_TITLE, F_SUB = font("seguisb.ttf", 76), font("segoeui.ttf", 36)
|
||||
F_LAB, F_DESC, F_FOOT = font("seguisb.ttf", 40), font("segoeui.ttf", 31), font("segoeui.ttf", 27)
|
||||
|
||||
meta = json.load(open(os.path.join(D, "_anchors.json"), encoding="utf-8"))
|
||||
img = Image.open(os.path.join(D, "inline-qc-station-annotated-base.png")).convert("RGB")
|
||||
img = img.resize((round(img.width * SCALE), round(img.height * SCALE)), Image.LANCZOS)
|
||||
bb = ImageChops.difference(img, Image.new("RGB", img.size, (255, 255, 255))).getbbox()
|
||||
pad = 30
|
||||
crop = (max(0, bb[0] - pad), max(0, bb[1] - pad), min(img.width, bb[2] + pad), min(img.height, bb[3] + pad))
|
||||
img = img.crop(crop)
|
||||
anchors = [dict(a, xy=(a["xy"][0] * SCALE - crop[0], a["xy"][1] * SCALE - crop[1])) for a in meta["anchors"]]
|
||||
|
||||
COL_W, GAP, TOP, BOTTOM, MX = 760, 90, 280, 140, 90
|
||||
W = img.width + 2 * (MX + COL_W + GAP)
|
||||
H = img.height + TOP + BOTTOM
|
||||
canvas = Image.new("RGB", (W, H), (255, 255, 255))
|
||||
ox, oy = MX + COL_W + GAP, TOP
|
||||
canvas.paste(img, (ox, oy))
|
||||
d = ImageDraw.Draw(canvas)
|
||||
|
||||
d.text((MX, 60), TITLE, font=F_TITLE, fill=NAVY)
|
||||
d.text((MX + 4, 165), SUBTITLE, font=F_SUB, fill=GREY)
|
||||
d.line([(MX, 238), (W - MX, 238)], fill=(215, 220, 226), width=2)
|
||||
d.text((MX, H - 90), FOOTER, font=F_FOOT, fill=GREY)
|
||||
|
||||
# split: the left-most half of the anchors on the left, the rest on the right
|
||||
order = sorted(anchors, key=lambda a: a["xy"][0])
|
||||
half = (len(order) + 1) // 2
|
||||
cols = {"L": order[:half], "R": order[half:]}
|
||||
BLOCK = 108 # label height incl. spacing
|
||||
|
||||
|
||||
def _cross(p1, p2, p3, p4):
|
||||
def o(a, b, c):
|
||||
return (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0])
|
||||
return (o(p1, p2, p3) > 0) != (o(p1, p2, p4) > 0) and (o(p3, p4, p1) > 0) != (o(p3, p4, p2) > 0)
|
||||
|
||||
|
||||
def place(items, edge_x):
|
||||
"""Label slots kept near each anchor's height without overlapping, then the anchor-to-slot
|
||||
assignment that makes no two leader lines cross (fewest crossings, then least drift)."""
|
||||
import itertools
|
||||
items = sorted(items, key=lambda a: a["xy"][1])
|
||||
lo, hi = oy - 20, oy + img.height - BLOCK + 20
|
||||
ys = [min(max(a["xy"][1] + oy - 26, lo), hi) for a in items]
|
||||
for i in range(1, len(ys)):
|
||||
ys[i] = max(ys[i], ys[i - 1] + BLOCK)
|
||||
over = ys[-1] - hi
|
||||
if over > 0:
|
||||
ys = [y - over for y in ys]
|
||||
for i in range(len(ys) - 2, -1, -1):
|
||||
ys[i] = min(ys[i], ys[i + 1] - BLOCK)
|
||||
best = None
|
||||
for perm in itertools.permutations(range(len(items))):
|
||||
segs = [((edge_x, ys[k] + 26), (items[i]["xy"][0] + ox, items[i]["xy"][1] + oy)) for k, i in enumerate(perm)]
|
||||
n = sum(_cross(*segs[a], *segs[b]) for a in range(len(segs)) for b in range(a + 1, len(segs)))
|
||||
drift = sum(abs(ys[k] + 26 - items[i]["xy"][1] - oy) for k, i in enumerate(perm))
|
||||
score = n * 1e6 + drift
|
||||
if best is None or score < best[0]:
|
||||
best = (score, perm)
|
||||
print("crossings", int(best[0] // 1e6))
|
||||
return [(items[i], ys[k]) for k, i in enumerate(best[1])]
|
||||
|
||||
|
||||
for side, items in cols.items():
|
||||
edge = MX + COL_W + GAP - 20 if side == "L" else ox + img.width + 20
|
||||
for a, y in place(items, edge):
|
||||
ax, ay = a["xy"][0] + ox, a["xy"][1] + oy
|
||||
# text in a fixed column, left-aligned; the leader leaves from the end of the title line,
|
||||
# runs level to the column edge, then straight to the part
|
||||
tl = d.textlength(a["title"], font=F_LAB)
|
||||
ly = y + 26
|
||||
if side == "L":
|
||||
tx = MX
|
||||
pts = [(tx + tl + 16, ly), (MX + COL_W + GAP - 20, ly), (ax, ay)]
|
||||
else:
|
||||
tx = ox + img.width + GAP
|
||||
pts = [(tx - 16, ly), (ox + img.width + 20, ly), (ax, ay)]
|
||||
d.line(pts, fill=LINE, width=3, joint="curve")
|
||||
d.ellipse([ax - 10, ay - 10, ax + 10, ay + 10], fill=(255, 255, 255))
|
||||
d.ellipse([ax - 7, ay - 7, ax + 7, ay + 7], fill=NAVY)
|
||||
d.text((tx, y), a["title"], font=F_LAB, fill=NAVY)
|
||||
d.text((tx, y + 52), a["desc"], font=F_DESC, fill=GREY)
|
||||
|
||||
out = os.path.join(D, "inline-qc-station-annotated.png")
|
||||
canvas.save(out, optimize=True)
|
||||
print("saved", out, canvas.size)
|
||||
@@ -5,8 +5,10 @@
|
||||
# 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: profiles are drawn as plain 40 mm bars,
|
||||
# bought parts as their envelope boxes. Sizes follow ../inline-optical-qc.md.
|
||||
# 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.
|
||||
@@ -107,8 +109,82 @@ 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, box(x0, y0, z0, x1, y1, z1), ALU)
|
||||
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):
|
||||
@@ -129,37 +205,54 @@ for i, (x, y) in enumerate([(20, -YF), (20, YF), (XT0 + 20, -YF), (XT0 + 20, YF)
|
||||
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, 1080, "End1")]:
|
||||
(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, s * YF - 20, 1460, XT1 + P, s * YF + 20, 1500)
|
||||
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 - 20, -YF + 20, 1460, X_LB + 20, YF - 20, 1500)
|
||||
prof(G, "TopCrossLabel", X_LB + 70, -YF + 20, 1460, X_LB + 110, YF - 20, 1500)
|
||||
xt = sum(TOP_LINE) / 2
|
||||
prof(G, "TopCrossLineScan", xt - 20, -YF + 20, 1460, xt + 20, YF - 20, 1500)
|
||||
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 - 20, -YF + 20, 420, X_LB + 20, YF - 20, 460)
|
||||
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"BeltSlide{side}", box(30, s * Y_BELT - 5, 880, L - 30, s * Y_BELT + 5, 894), UHMW)
|
||||
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),
|
||||
@@ -169,12 +262,15 @@ add(G, "DriveShaft", cyl(6, 2 * Y_BELT + 60, V(L - 20, -Y_BELT - 30, ZB - 26), V
|
||||
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),
|
||||
@@ -187,15 +283,17 @@ add(G, "LabelCamTop", box(X_LB - 15, -15, ZB + WD_LABEL + 50, X_LB + 15, 15, ZB
|
||||
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)
|
||||
add(G, "LabelCamTopPost", box(X_LB - 20, 40, ZB + WD_LABEL + 50, X_LB + 20, 80, 1460), ALU)
|
||||
add(G, "LabelCamTopPlate", box(X_LB - 20, 15, ZB + WD_LABEL + 50, X_LB + 20, 40, ZB + WD_LABEL + 92), DARK)
|
||||
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)
|
||||
add(G, "LabelCamBottomPost", box(X_LB - 20, 40, 460, X_LB + 20, 80, zl - 50), ALU)
|
||||
add(G, "LabelCamBottomPlate", box(X_LB - 20, 15, zl - 92, X_LB + 20, 40, zl - 50), DARK)
|
||||
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"
|
||||
@@ -208,16 +306,16 @@ 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)
|
||||
add(G, "LiftPost", box(X_IS - 20, -20, 460, X_IS + 20, 20, zp - 322), ALU)
|
||||
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)
|
||||
add(G, "LineCamTopPost", box(xt - 20, 40, zr + 150, xt + 20, 80, 1460), ALU)
|
||||
add(G, "LightHangerTop", box(xt - 10, -112, zr + 130, xt + 10, 112, zr + 150), DARK)
|
||||
add(G, "CoaxHangerTop", box(xt - 10, 30, zr + 80, xt + 10, 40, zr + 130), DARK)
|
||||
add(G, "LineCamTopPlate", box(xt - 20, 31, zr + WD_LINE + 60, xt + 20, 40, zr + WD_LINE + 105), DARK)
|
||||
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)
|
||||
@@ -225,20 +323,21 @@ 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 - 10, s * 100 - 5, zr + 48, xt + 10, s * 100 + 5, zr + 130), DARK)
|
||||
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 + 90, -240, 1430, XT0 + 110, -220, 1460), DARK)
|
||||
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)
|
||||
add(G, "LineCamBottomPost", box(xb - 20, 40, 460, xb + 20, 80, zu - WD_LINE - 60), ALU)
|
||||
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)
|
||||
@@ -248,6 +347,7 @@ for s in (-1, 1):
|
||||
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)
|
||||
|
||||
@@ -256,7 +356,7 @@ 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)
|
||||
add(G, "LaserRail", box(X_IS - 20, -200, 810, X_IS + 20, -70, 830), ALU)
|
||||
prof(G, "LaserRail", X_IS - 20, -200, 790, X_IS + 20, -70, 830)
|
||||
|
||||
# ---------------------------------------------------------------- reject
|
||||
G = "Reject"
|
||||
@@ -266,15 +366,20 @@ add(G, "PusherCarriage", box(X_RJ - 35, yc - 30, 960, X_RJ + 35, yc + 30, 1000),
|
||||
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, 165, ZB - 12))
|
||||
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, 165, ZB - 12))
|
||||
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)
|
||||
|
||||
@@ -299,17 +404,18 @@ add(G, "GuardRejectTop", box(XT1 + P, -YF - 23, 1100, L, YF + 23, 1103), CLEAR,
|
||||
|
||||
# ---------------------------------------------------------------- electrical and operator
|
||||
G = "Electrical"
|
||||
add(G, "Cabinet", box(1180, -200, 160, 1640, 170, 770), CABINET)
|
||||
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, 120, V(XT1 + 20, -YF, 1503)), 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, 55, V(XT1 + 20, -YF, 1623 + i * 57)), c)
|
||||
add(G, "HMIArm", box(XT1 + 50, -YF - 80, 1200, XT1 + 70, -YF - 20, 1220), DARK)
|
||||
add(G, "HMI", rot(box(XT1 + 70, -YF - 100, 1130, XT1 + 330, -YF - 80, 1300), V(XT1 + 200, -YF - 90, 1215), V(1, 0, 0), -15), (0.10, 0.10, 0.12))
|
||||
add(G, "EStopBox", box(XT1 + 60, -YF - 60, 930, XT1 + 140, -YF - 20, 1010), YELLOW)
|
||||
add(G, "EStop", cyl(22, 25, V(XT1 + 100, -YF - 60, 970), V(0, -1, 0)), RED)
|
||||
add(G, "ValveTerminal", box(XT0 + 80, YF + 25, 300, XT0 + 330, YF + 75, 380), PNEU)
|
||||
add(G, "FRL", box(XT0 + 360, YF + 25, 280, XT0 + 440, YF + 85, 420), (0.2, 0.4, 0.8))
|
||||
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"
|
||||
@@ -331,9 +437,9 @@ record("RecordInfeed", X_IN, 0, ZB, (0.95, 0.85, 0.75), body=(0.80, 0.12, 0.16),
|
||||
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 = 230, math.radians(42)
|
||||
yr = 165 + math.cos(a) * sc + 6 * math.sin(a)
|
||||
zr2 = ZB - 12 - math.sin(a) * sc + 6 * math.cos(a)
|
||||
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()
|
||||
@@ -345,3 +451,30 @@ for o in doc.Objects:
|
||||
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")
|
||||
|
After Width: | Height: | Size: 1.5 MiB |
@@ -1,7 +1,7 @@
|
||||
group,item,qty,example,unit_usd_low,unit_usd_high,line_usd_low,line_usd_high,source
|
||||
Frame,"40x40 slot profile, cut to length",27 m (37 pieces),"8-slot 4040 (Misumi HFS8-4040 class, or Thai/Chinese generic)",11,21,297,567,per m; Shopee Thailand listing from THB 356; aluxprofile EUR 19 to 24 per m
|
||||
Frame,"40x40 slot profile, cut to length",32 m (45 pieces),"8-slot 4040 (Misumi HFS8-4040 class, or Thai/Chinese generic)",11,21,354,676,per m; Shopee Thailand listing from THB 356; aluxprofile EUR 19 to 24 per m
|
||||
Frame,"40x80 slot profile, base beams",3.8 m (2 pieces),8-slot 4080,22,42,84,160,"per m; estimate, about twice the 4040 price"
|
||||
Frame,"Angle brackets, T-nuts, screws, end caps",1 lot (about 120 joints),,250,600,250,600,estimate
|
||||
Frame,"Cast angle brackets (24 drawn), T-nuts, screws, end caps",1 lot,,250,600,250,600,estimate
|
||||
Frame,Levelling feet M16,8,,8,25,64,200,each; estimate
|
||||
Enclosure,"Black panels, 4 mm ACM or PP, light-tight tunnel",4.3 m2,,25,60,108,258,per m2; estimate
|
||||
Enclosure,"Clear polycarbonate guard, reject zone",1.4 m2,,30,70,42,98,per m2; estimate
|
||||
@@ -31,4 +31,4 @@ Control,"Edge PC with GPU, 4 GigE PoE ports, NixOS",1,,1500,3200,1500,3200,"qsco
|
||||
Control,"HMI touch panel, 10 inch",1,,400,900,400,900,estimate
|
||||
Control,"Safety relay, e-stop, door interlock wiring",1 lot,,300,600,300,600,estimate
|
||||
Control,"Cabinet, power supply, breakers, terminals, wiring, stack light",1 lot,,800,2000,800,2000,estimate
|
||||
Total,"Hardware per station, thermal camera excluded",,,,,16056,32715,sum of the lines above
|
||||
Total,"Hardware per station, thermal camera excluded",,,,,16114,32824,sum of the lines above
|
||||
|
|
Before Width: | Height: | Size: 290 KiB After Width: | Height: | Size: 443 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 197 KiB After Width: | Height: | Size: 322 KiB |
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 358 KiB After Width: | Height: | Size: 528 KiB |
|
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 91 KiB |
@@ -0,0 +1,160 @@
|
||||
# Render the investor view of the inline QC station and the screen positions of
|
||||
# the parts to call out (run in the FreeCAD GUI after build_station.py):
|
||||
# exec(open(r"C:/dev/cnx_vinyl/docs/equipment/inline-qc-station/render_annotated.py", encoding="utf-8").read())
|
||||
# Writes inline-qc-station-annotated-base.png, a check image with magenta markers,
|
||||
# and anchors.json; annotate_render.py (Pillow) then draws the labels.
|
||||
import json
|
||||
import os
|
||||
import FreeCAD as App
|
||||
import FreeCADGui as Gui
|
||||
import Part
|
||||
from pivy import coin
|
||||
|
||||
OUTDIR = r"C:/dev/cnx_vinyl/docs/equipment/inline-qc-station"
|
||||
W, H = 4000, 3000
|
||||
V = App.Vector
|
||||
|
||||
# (key, 3D point on a face turned to the camera, title, one-line description)
|
||||
ANCHORS = [
|
||||
("frame", V(340, -320, 1150), "Aluminium profile frame", "40 × 40 mm slot profile, 1.9 × 0.65 × 1.5 m"),
|
||||
("belts", V(70, -151.5, 897), "Rim-belt conveyor", "touches only the record's edge, never a groove"),
|
||||
("infeed", V(200, -80, 902.5), "Infeed", "the operator or the press unloader sets the record down"),
|
||||
("label", V(520, -15, 1275), "Label cameras, top and bottom", "right label, right side, centred"),
|
||||
("tunnel", V(820, 319, 1100), "Light-tight tunnel", "black panels keep factory light out"),
|
||||
("lscan_top", V(975, -31, 1385), "Line-scan camera, top", "images a full radius while the record turns once"),
|
||||
("lights", V(935, -30, 1042), "Inspection lights", "coaxial, low-angle dark-field and backlight"),
|
||||
("chuck", V(880, -25, 690), "Lift-and-turn chuck", "lifts the record 75 mm, one turn in about 1 s"),
|
||||
("lscan_bot", V(778, -31, 547), "Line-scan camera, bottom", "the underside, in the same turn"),
|
||||
("lasers", V(880, -130, 925), "Laser warp sensors", "edge run-out and dish, to 0.1 mm"),
|
||||
("pusher", V(1320, -300, 1020), "Reject pusher", "pushes a failed record down a chute into the bin behind"),
|
||||
("outfeed", V(1715, -140, 902), "Outfeed", "good records wait for the spindle"),
|
||||
("cabinet", V(1420, -200, 450), "Control cabinet", "PLC, drives and the NixOS edge PC"),
|
||||
("hmi", V(1770, -404, 1235), "Touch screen", "every verdict with its reason"),
|
||||
("estop", V(1140, -386, 970), "Emergency stop", "and a safety switch on the tunnel door"),
|
||||
("stack", V(1120, 274, 1720), "Stack light", "green: pass, red: reject"),
|
||||
]
|
||||
AZ, EL = 12.0, 28.0 # camera from the front, 12 deg to the right, 28 deg above
|
||||
|
||||
doc = App.getDocument("InlineQCStation")
|
||||
Gui.setActiveDocument(doc.Name)
|
||||
view = Gui.getDocument(doc.Name).ActiveView
|
||||
try:
|
||||
view.setCameraType("Orthographic")
|
||||
except Exception:
|
||||
pass
|
||||
_vp = App.ParamGet("User parameter:BaseApp/Preferences/View")
|
||||
_navi_before = _vp.GetBool("ShowNaviCube", True)
|
||||
_vp.SetBool("ShowNaviCube", False)
|
||||
_anim_before = _vp.GetBool("UseNavigationAnimations", True)
|
||||
_vp.SetBool("UseNavigationAnimations", False)
|
||||
_presel_before = _vp.GetBool("EnablePreselection", True)
|
||||
_vp.SetBool("EnablePreselection", False) # no hover highlight in the pictures
|
||||
Gui.Selection.clearPreselection()
|
||||
Gui.Selection.clearSelection()
|
||||
|
||||
|
||||
def back_off(v, dist=8000):
|
||||
"""Move the orthographic camera back along its axis so nothing sits behind the near plane."""
|
||||
c = v.getCameraNode()
|
||||
d = c.orientation.getValue().multVec(coin.SbVec3f(0, 0, -1))
|
||||
c.position.setValue(c.position.getValue() - d * dist)
|
||||
c.nearDistance.setValue(100)
|
||||
c.farDistance.setValue(dist * 4)
|
||||
Gui.updateGui()
|
||||
|
||||
open_view_hidden = ["PanelTop", "PanelFrontUpper", "PanelFrontLower", "GuardRejectFront", "GuardRejectTop",
|
||||
"DoorHandle", "ThermalCam", "ThermalPost"]
|
||||
for o in doc.Objects:
|
||||
if o.TypeId == "Part::Feature":
|
||||
o.ViewObject.Visibility = o.Name not in open_view_hidden
|
||||
for o in doc.getObject("Enclosure").Group:
|
||||
o.ViewObject.Transparency = 45
|
||||
|
||||
import math
|
||||
|
||||
|
||||
def set_camera(az, el, margin=1.04):
|
||||
"""Orthographic camera looking at the whole model from the front, az to the right, el above."""
|
||||
a, e = math.radians(az), math.radians(el)
|
||||
tv = V(math.sin(a) * math.cos(e), -math.cos(a) * math.cos(e), math.sin(e)) # towards the viewer
|
||||
d = tv * -1
|
||||
right = d.cross(V(0, 0, 1)).normalize()
|
||||
up = right.cross(d).normalize()
|
||||
m = App.Matrix(right.x, up.x, -d.x, 0, right.y, up.y, -d.y, 0, right.z, up.z, -d.z, 0, 0, 0, 0, 1)
|
||||
rot = App.Rotation(m)
|
||||
bb = None
|
||||
for o in doc.Objects:
|
||||
if o.TypeId == "Part::Feature" and o.ViewObject.Visibility:
|
||||
bb = o.Shape.BoundBox if bb is None else bb.united(o.Shape.BoundBox)
|
||||
corners = [V(x, y, z) for x in (bb.XMin, bb.XMax) for y in (bb.YMin, bb.YMax) for z in (bb.ZMin, bb.ZMax)]
|
||||
us = [c.dot(right) for c in corners]
|
||||
vs = [c.dot(up) for c in corners]
|
||||
cu, cv = (min(us) + max(us)) / 2, (min(vs) + max(vs)) / 2
|
||||
centre = right * cu + up * cv + d * (bb.Center.dot(d))
|
||||
hgt = max(max(vs) - min(vs), (max(us) - min(us)) * H / W) * margin
|
||||
cam = view.getCameraNode()
|
||||
cam.orientation.setValue(*rot.Q)
|
||||
pos = centre + tv * 8000
|
||||
cam.position.setValue(pos.x, pos.y, pos.z)
|
||||
cam.height.setValue(hgt)
|
||||
cam.focalDistance.setValue(8000)
|
||||
cam.nearDistance.setValue(100)
|
||||
cam.farDistance.setValue(32000)
|
||||
Gui.updateGui()
|
||||
|
||||
|
||||
set_camera(AZ, EL)
|
||||
base = os.path.join(OUTDIR, "inline-qc-station-annotated-base.png")
|
||||
view.saveImage(base, W, H, "White")
|
||||
|
||||
cam = view.getCameraNode()
|
||||
pos = cam.position.getValue()
|
||||
inv = cam.orientation.getValue().inverse()
|
||||
hgt = cam.height.getValue()
|
||||
|
||||
|
||||
def project(p):
|
||||
c = inv.multVec(coin.SbVec3f(p.x - pos[0], p.y - pos[1], p.z - pos[2]))
|
||||
s = H / hgt if W >= H else W / hgt
|
||||
return [W / 2 + c[0] * s, H / 2 - c[1] * s]
|
||||
|
||||
|
||||
out = {"size": [W, H], "anchors": []}
|
||||
for key, p, title, desc in ANCHORS:
|
||||
out["anchors"].append({"key": key, "xy": project(p), "title": title, "desc": desc})
|
||||
|
||||
# check image: the same view with a magenta ball on every anchor (shows position and occlusion)
|
||||
balls = []
|
||||
for key, p, *_ in ANCHORS:
|
||||
b = doc.addObject("Part::Feature", "Marker_" + key)
|
||||
b.Shape = Part.makeSphere(9, p)
|
||||
b.ViewObject.ShapeColor = (1.0, 0.0, 1.0)
|
||||
b.ViewObject.LineColor = (1.0, 0.0, 1.0)
|
||||
balls.append(b)
|
||||
Gui.updateGui()
|
||||
view.saveImage(os.path.join(OUTDIR, "_markers-check.png"), W, H, "White")
|
||||
# markers alone, nothing in front of them: exact screen positions (camera unchanged)
|
||||
_vis = {o.Name: o.ViewObject.Visibility for o in doc.Objects if o.TypeId == "Part::Feature"}
|
||||
for o in doc.Objects:
|
||||
if o.TypeId == "Part::Feature" and not o.Name.startswith("Marker_"):
|
||||
o.ViewObject.Visibility = False
|
||||
for b in balls:
|
||||
b.ViewObject.Visibility = True
|
||||
Gui.updateGui()
|
||||
view.saveImage(os.path.join(OUTDIR, "_markers-only.png"), W, H, "White")
|
||||
for n, v in _vis.items():
|
||||
if not n.startswith("Marker_"):
|
||||
doc.getObject(n).ViewObject.Visibility = v
|
||||
for b in balls:
|
||||
doc.removeObject(b.Name)
|
||||
|
||||
for o in doc.getObject("Enclosure").Group:
|
||||
o.ViewObject.Transparency = 55 if o.Name not in ("GuardRejectFront", "GuardRejectTop") else 75
|
||||
for o in doc.Objects:
|
||||
if o.TypeId == "Part::Feature":
|
||||
o.ViewObject.Visibility = True
|
||||
_vp.SetBool("ShowNaviCube", _navi_before)
|
||||
_vp.SetBool("UseNavigationAnimations", _anim_before)
|
||||
_vp.SetBool("EnablePreselection", _presel_before)
|
||||
json.dump(out, open(os.path.join(OUTDIR, "_anchors.json"), "w", encoding="utf-8"), ensure_ascii=False, indent=1)
|
||||
print("saved", base)
|
||||
@@ -13,6 +13,13 @@ doc = App.getDocument("InlineQCStation")
|
||||
_vp = App.ParamGet("User parameter:BaseApp/Preferences/View")
|
||||
_navi_before = _vp.GetBool("ShowNaviCube", True)
|
||||
_vp.SetBool("ShowNaviCube", False)
|
||||
_anim_before = _vp.GetBool("UseNavigationAnimations", True)
|
||||
_vp.SetBool("UseNavigationAnimations", False)
|
||||
_presel_before = _vp.GetBool("EnablePreselection", True)
|
||||
_vp.SetBool("EnablePreselection", False)
|
||||
Gui.Selection.clearPreselection()
|
||||
Gui.Selection.clearSelection()
|
||||
from pivy import coin
|
||||
|
||||
|
||||
def prep(d):
|
||||
@@ -51,8 +58,16 @@ def hide(groups=(), names=()):
|
||||
|
||||
def shoot(v, fname, orient, w=3200, h=2400):
|
||||
getattr(v, orient)()
|
||||
Gui.updateGui()
|
||||
v.fitAll()
|
||||
Gui.updateGui()
|
||||
# move the orthographic camera well back so no part sits behind the near plane
|
||||
c = v.getCameraNode()
|
||||
d = c.orientation.getValue().multVec(coin.SbVec3f(0, 0, -1))
|
||||
c.position.setValue(c.position.getValue() - d * 8000)
|
||||
c.nearDistance.setValue(100)
|
||||
c.farDistance.setValue(32000)
|
||||
Gui.updateGui()
|
||||
path = os.path.join(OUTDIR, fname)
|
||||
v.saveImage(path, w, h, "White")
|
||||
print("saved", path)
|
||||
@@ -132,4 +147,6 @@ App.closeDocument(d6.Name)
|
||||
|
||||
Gui.setActiveDocument(doc.Name)
|
||||
_vp.SetBool("ShowNaviCube", _navi_before)
|
||||
_vp.SetBool("UseNavigationAnimations", _anim_before)
|
||||
_vp.SetBool("EnablePreselection", _presel_before)
|
||||
print("done")
|
||||