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