Deploy test site / deploy (push) Successful in 57s
User, 19 Oct 2026: plan the current EtherCAT setup, keep the 100 W servo, check prices on geizhals.de. - New docs/equipment/inline-qc-control-plan.md: decisions, bus layout (drives at the end of the line because the A6-200EC takes control power from the main supply), I/O list for EL1809/EL2809/EL3152/EL5101 and the drive touch probe, PDO and DC notes from the A6-EC manual, geizhals.de price table (EUR 1,891.60 to 2,104.39 + USD 246.58 for the servo kits, THB 80k to 88k), NixOS/EtherCrab software, safety circuit (PNOZ s3, two contactors, dump valve), bench test with pass marks (parts about THB 64k, all reused), build order, spares, open points. - EL3102 replaced by EL3152: the optoNCDT 1420 output is 4 to 20 mA. - Spindle encoder index goes to an EL5101 instead of the drive touch probe; encoder 2,000 to 2,500 pulses. - BOM: control lines split per part with geizhals.de prices (+30% high end for shipping and Thai duty/VAT, estimate); total USD 16,912 to 33,061 (THB 0.56M to 1.09M). Doc, render caption and fleet calculator text now say USD 17k to 33k. - README links the plan.
112 lines
5.1 KiB
Python
112 lines
5.1 KiB
Python
# 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 17k 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)
|