User, 18 Oct 2026: use an EtherCAT based system. - Spindle and conveyor on the StepperOnline A6-EC100L2A1-M17 kit (100 W, A6-200EC drive, USD 123.29), spindle through a 1:6 belt in cyclic synchronous position, conveyor in cyclic synchronous velocity. - The A6-EC drive has no divided encoder output, so an RS-422 incremental encoder on the spindle shaft clocks both line-scans; its index goes to the drive touch probe for one angle reference. - Station controller: fanless N100 mini PC with 2x Intel i226, NixOS PREEMPT_RT, EtherCAT master EtherCrab (Rust, MIT/Apache) first, IgH (nixpkgs, igc native driver on 6.12 LTS) as fallback; no PLC. - EtherCAT I/O: EK1100, EL1809, EL2809, EL3102 (warp lasers sampled with the spindle position, ~1,000 points a turn at 1 ms). - Safety stays hardwired (safety relay, contactor; no STO at 100 W). - Edge PC with the GPU stays off the real-time loop. - BOM: drives, encoder, controller and I/O lines with sources; total USD 16,112 to 32,938 (THB 0.53M to 1.09M). Next step: EtherCAT bench. - Investor render: cabinet and outfeed labels.
161 lines
7.2 KiB
Python
161 lines
7.2 KiB
Python
# 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 sets each cooled 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 sleeving"),
|
||
("cabinet", V(1420, -200, 450), "Control cabinet", "EtherCAT controller, 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)
|