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