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.
This commit is contained in:
grabowski committed 2026-10-18 14:07:41 +07:00
1 parent 609424b598
commit e8674795f5
16 files changed
+483 -46

No files matched your search

+2 -1
View File
@@ -348,7 +348,8 @@ docs/
│ ├── galvanics.md Electroforming and stamper prep equipment plan
│ ├── inline-optical-qc.md Devices for the record inspection station
│ ├── inline-qc-station/ Concept model of the station (FreeCAD script,
│ │ .FCStd, renders, bill of materials)
│ │ .FCStd, renders, labelled investor view,
│ │ bill of materials)
│ └── pressing-line.md Press fleet strategy and equipment plan
├── finance/ Project cost overviews, revenue model,
│ construction RFQ, PEA electricity tariff
+16 -6
View File
@@ -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.
![Inline QC station with the parts named, for presentations](inline-qc-station/inline-qc-station-annotated.png)
*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).*
![Inline QC station, open: front panels and lid removed](inline-qc-station/inline-qc-station-iso.png)
@@ -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)
+169 -36
View File
@@ -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")
Binary file not shown.

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
1 group item qty example unit_usd_low unit_usd_high line_usd_low line_usd_high source
2 Frame 40x40 slot profile, cut to length 27 m (37 pieces) 32 m (45 pieces) 8-slot 4040 (Misumi HFS8-4040 class, or Thai/Chinese generic) 11 21 297 354 567 676 per m; Shopee Thailand listing from THB 356; aluxprofile EUR 19 to 24 per m
3 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
4 Frame Angle brackets, T-nuts, screws, end caps Cast angle brackets (24 drawn), T-nuts, screws, end caps 1 lot (about 120 joints) 1 lot 250 600 250 600 estimate
5 Frame Levelling feet M16 8 8 25 64 200 each; estimate
6 Enclosure Black panels, 4 mm ACM or PP, light-tight tunnel 4.3 m2 25 60 108 258 per m2; estimate
7 Enclosure Clear polycarbonate guard, reject zone 1.4 m2 30 70 42 98 per m2; estimate
31 Control HMI touch panel, 10 inch 1 400 900 400 900 estimate
32 Control Safety relay, e-stop, door interlock wiring 1 lot 300 600 300 600 estimate
33 Control Cabinet, power supply, breakers, terminals, wiring, stack light 1 lot 800 2000 800 2000 estimate
34 Total Hardware per station, thermal camera excluded 16056 16114 32715 32824 sum of the lines above
Binary file not shown.

Before

Width:  |  Height:  |  Size: 290 KiB

After

Width:  |  Height:  |  Size: 443 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 197 KiB

After

Width:  |  Height:  |  Size: 322 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 358 KiB

After

Width:  |  Height:  |  Size: 528 KiB

Binary file not shown.

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