Files
cnx_vinyl/docs/equipment/inline-qc-station/render_annotated.py
T
grabowski 8c2143630c
Deploy test site / deploy (push) Successful in 55s
Optical QC: one station for both presses, after the cooling racks
User, 18 Oct 2026: for now one QC station for both machines; let the
records cool in a queue before testing them.

- inline-optical-qc.md "Where it sits": launch setup. Spindle stacks
  from the Duplex system and the Classic, labelled at the press (press,
  stamper set, first and last cycle), cool in FIFO racks (24 h accepted
  practice, 8 h at the shortest, Stamper Discs; about one day of output,
  2,800 to 3,100 records); the finishing operator feeds the station from
  the cooled stacks. Gains: warp and centring measured on settled records
  (the release value), one station instead of two, no Classic unloader
  change, no warm-record sag or thermal camera. Cost: defects found hours
  later, covered by the Duplex operator, per-cycle telemetry and an early
  lane for the first records of each run and after stamper changes.
  Record key from the stack label and stack position, checked against the
  run-out stamper number. Capacity 10 to 12 a minute against 5 to 6:
  one station to about two Duplex systems and two Classics. A station at
  each press stays the later option. Cost table, open points, infeed row,
  thermal camera note and the Newbilt question follow.
- Fleet calculator: the per-press stations and the release station are
  gone; the finishing block shows the cooling racks with record stacks
  and the one optical station by the corridor glass; tooltips, corridor
  and conveyor texts; machine park back to its legend, finishing box
  "cooling, QC, packing".
- quality-automation.md layer 1, digital-factory.md QC row,
  process-flow.md stage 6, site-candidates.md block ground floor,
  crane-workshop-tours.md tour stop 6.
- Investor render: infeed label "the operator sets each cooled record
  down".

Checks: node --check; pw_bp_calc.py 0 fails; pw_iso_view_check.py OK;
info for every data-id at all presets, levels and rotations; GF nodes
2,414 at the full preset; zoomed shots in two rotations.
2026-10-18 15:18:00 +07:00

161 lines
7.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 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)