Deploy test site / deploy (push) Successful in 55s
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.
161 lines
7.1 KiB
Python
161 lines
7.1 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 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)
|