diff --git a/README.md b/README.md index 74a5882..8318327 100644 --- a/README.md +++ b/README.md @@ -348,7 +348,8 @@ docs/ │ ├── galvanics.md Electroforming and stamper prep equipment plan │ ├── inline-optical-qc.md Devices for the record inspection station │ ├── inline-qc-station/ Concept model of the station (FreeCAD script, -│ │ .FCStd, renders, bill of materials) +│ │ .FCStd, renders, labelled investor view, +│ │ bill of materials) │ └── pressing-line.md Press fleet strategy and equipment plan ├── finance/ Project cost overviews, revenue model, │ construction RFQ, PEA electricity tariff diff --git a/docs/equipment/inline-optical-qc.md b/docs/equipment/inline-optical-qc.md index 8352457..4b92638 100644 --- a/docs/equipment/inline-optical-qc.md +++ b/docs/equipment/inline-optical-qc.md @@ -142,9 +142,19 @@ A concept model of one station, drawn in FreeCAD from the script [`build_station.py`](inline-qc-station/build_station.py) (views from [`render_station.py`](inline-qc-station/render_station.py); model file [`inline-qc-station.FCStd`](inline-qc-station/inline-qc-station.FCStd)). -It is a layout to take to a machine builder, not a fabrication drawing: -the profiles are drawn as plain 40 mm bars and the bought parts as their -outline boxes. +It is a layout to take to a machine builder, not a fabrication drawing. +The frame uses the real 8-slot 40 series section (slots, centre bore, +corner cavities) with cast angle brackets in the corners; bought parts +are drawn as their outline boxes. The script checks that every part +reaches the floor through parts it touches, so nothing floats. + +![Inline QC station with the parts named, for presentations](inline-qc-station/inline-qc-station-annotated.png) + +*The labelled view for presentations, made with +[`render_annotated.py`](inline-qc-station/render_annotated.py) (FreeCAD +view and label positions) and +[`annotate_render.py`](inline-qc-station/annotate_render.py) (labels, +Pillow).* ![Inline QC station, open: front panels and lid removed](inline-qc-station/inline-qc-station-iso.png) @@ -154,8 +164,8 @@ outfeed stop (front right). The cabinet sits under the outfeed, the reject bin behind it.* **Frame.** The frame is built from 40 × 40 mm 8-slot aluminium profile -(27 m in 37 pieces) on two 40 × 80 mm base beams, with eight levelling -feet. It is 1.9 m long, 0.65 m wide and 1.5 m high, plus the stack +(32 m in 45 pieces) on two 40 × 80 mm base beams, with 24 cast angle +brackets in the corners and eight levelling feet. It is 1.9 m long, 0.65 m wide and 1.5 m high, plus the stack light. The reject bin adds 0.4 m behind it. Records travel at 900 mm, a normal working height. @@ -251,7 +261,7 @@ By group, per station: | Group | USD | Main parts | |---|---|---| -| Frame | 0.7k to 1.5k | 27 m of 40 × 40, 3.8 m of 40 × 80, brackets, feet | +| Frame | 0.8k to 1.6k | 32 m of 40 × 40, 3.8 m of 40 × 80, angle brackets, feet | | Enclosure and guards | 0.3k to 0.7k | black panels, polycarbonate, door switch | | Conveyor | 0.5k to 1.4k | round belts, pulleys, slide strips, drive | | Handling | 2.0k to 4.9k | guided lift cylinder, 200 W spindle servo, spindle head with mandrel, rodless cylinder, pneumatics | diff --git a/docs/equipment/inline-qc-station/.gitignore b/docs/equipment/inline-qc-station/.gitignore new file mode 100644 index 0000000..29ab3ae --- /dev/null +++ b/docs/equipment/inline-qc-station/.gitignore @@ -0,0 +1,5 @@ +# intermediate files of render_annotated.py / annotate_render.py +_*.png +_anchors.json +*-annotated-base.png +*.FCBak diff --git a/docs/equipment/inline-qc-station/annotate_render.py b/docs/equipment/inline-qc-station/annotate_render.py new file mode 100644 index 0000000..b1f9c02 --- /dev/null +++ b/docs/equipment/inline-qc-station/annotate_render.py @@ -0,0 +1,111 @@ +# Draw the part labels onto the investor render of the inline QC station. +# uv run --with pillow python docs/equipment/inline-qc-station/annotate_render.py +# Reads inline-qc-station-annotated-base.png and _anchors.json from render_annotated.py +# (FreeCAD) and writes inline-qc-station-annotated.png. +import json +import os +from PIL import Image, ImageChops, ImageDraw, ImageFont + +D = os.path.dirname(os.path.abspath(__file__)) +FONTS = "C:/Windows/Fonts/" +SCALE = 0.75 # 4000 px render, drawn at 3/4 size (anti-aliased) + +TITLE = "Inline optical QC station" +SUBTITLE = ("Every record checked on both faces in one turn: surface, label, warp and centring. " + "Failed records leave the line automatically.") +FOOTER = ("CNX Vinyl, concept model (FreeCAD); bought parts drawn as outlines. 1.9 × 0.65 × 1.5 m, " + "front panels and lid removed for the view. Hardware about USD 16k to 33k per station (estimate).") +NAVY, GREY, LINE = (18, 53, 91), (85, 95, 105), (60, 74, 90) + + +def font(name, size): + return ImageFont.truetype(FONTS + name, size) + + +F_TITLE, F_SUB = font("seguisb.ttf", 76), font("segoeui.ttf", 36) +F_LAB, F_DESC, F_FOOT = font("seguisb.ttf", 40), font("segoeui.ttf", 31), font("segoeui.ttf", 27) + +meta = json.load(open(os.path.join(D, "_anchors.json"), encoding="utf-8")) +img = Image.open(os.path.join(D, "inline-qc-station-annotated-base.png")).convert("RGB") +img = img.resize((round(img.width * SCALE), round(img.height * SCALE)), Image.LANCZOS) +bb = ImageChops.difference(img, Image.new("RGB", img.size, (255, 255, 255))).getbbox() +pad = 30 +crop = (max(0, bb[0] - pad), max(0, bb[1] - pad), min(img.width, bb[2] + pad), min(img.height, bb[3] + pad)) +img = img.crop(crop) +anchors = [dict(a, xy=(a["xy"][0] * SCALE - crop[0], a["xy"][1] * SCALE - crop[1])) for a in meta["anchors"]] + +COL_W, GAP, TOP, BOTTOM, MX = 760, 90, 280, 140, 90 +W = img.width + 2 * (MX + COL_W + GAP) +H = img.height + TOP + BOTTOM +canvas = Image.new("RGB", (W, H), (255, 255, 255)) +ox, oy = MX + COL_W + GAP, TOP +canvas.paste(img, (ox, oy)) +d = ImageDraw.Draw(canvas) + +d.text((MX, 60), TITLE, font=F_TITLE, fill=NAVY) +d.text((MX + 4, 165), SUBTITLE, font=F_SUB, fill=GREY) +d.line([(MX, 238), (W - MX, 238)], fill=(215, 220, 226), width=2) +d.text((MX, H - 90), FOOTER, font=F_FOOT, fill=GREY) + +# split: the left-most half of the anchors on the left, the rest on the right +order = sorted(anchors, key=lambda a: a["xy"][0]) +half = (len(order) + 1) // 2 +cols = {"L": order[:half], "R": order[half:]} +BLOCK = 108 # label height incl. spacing + + +def _cross(p1, p2, p3, p4): + def o(a, b, c): + return (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]) + return (o(p1, p2, p3) > 0) != (o(p1, p2, p4) > 0) and (o(p3, p4, p1) > 0) != (o(p3, p4, p2) > 0) + + +def place(items, edge_x): + """Label slots kept near each anchor's height without overlapping, then the anchor-to-slot + assignment that makes no two leader lines cross (fewest crossings, then least drift).""" + import itertools + items = sorted(items, key=lambda a: a["xy"][1]) + lo, hi = oy - 20, oy + img.height - BLOCK + 20 + ys = [min(max(a["xy"][1] + oy - 26, lo), hi) for a in items] + for i in range(1, len(ys)): + ys[i] = max(ys[i], ys[i - 1] + BLOCK) + over = ys[-1] - hi + if over > 0: + ys = [y - over for y in ys] + for i in range(len(ys) - 2, -1, -1): + ys[i] = min(ys[i], ys[i + 1] - BLOCK) + best = None + for perm in itertools.permutations(range(len(items))): + segs = [((edge_x, ys[k] + 26), (items[i]["xy"][0] + ox, items[i]["xy"][1] + oy)) for k, i in enumerate(perm)] + n = sum(_cross(*segs[a], *segs[b]) for a in range(len(segs)) for b in range(a + 1, len(segs))) + drift = sum(abs(ys[k] + 26 - items[i]["xy"][1] - oy) for k, i in enumerate(perm)) + score = n * 1e6 + drift + if best is None or score < best[0]: + best = (score, perm) + print("crossings", int(best[0] // 1e6)) + return [(items[i], ys[k]) for k, i in enumerate(best[1])] + + +for side, items in cols.items(): + edge = MX + COL_W + GAP - 20 if side == "L" else ox + img.width + 20 + for a, y in place(items, edge): + ax, ay = a["xy"][0] + ox, a["xy"][1] + oy + # text in a fixed column, left-aligned; the leader leaves from the end of the title line, + # runs level to the column edge, then straight to the part + tl = d.textlength(a["title"], font=F_LAB) + ly = y + 26 + if side == "L": + tx = MX + pts = [(tx + tl + 16, ly), (MX + COL_W + GAP - 20, ly), (ax, ay)] + else: + tx = ox + img.width + GAP + pts = [(tx - 16, ly), (ox + img.width + 20, ly), (ax, ay)] + d.line(pts, fill=LINE, width=3, joint="curve") + d.ellipse([ax - 10, ay - 10, ax + 10, ay + 10], fill=(255, 255, 255)) + d.ellipse([ax - 7, ay - 7, ax + 7, ay + 7], fill=NAVY) + d.text((tx, y), a["title"], font=F_LAB, fill=NAVY) + d.text((tx, y + 52), a["desc"], font=F_DESC, fill=GREY) + +out = os.path.join(D, "inline-qc-station-annotated.png") +canvas.save(out, optimize=True) +print("saved", out, canvas.size) diff --git a/docs/equipment/inline-qc-station/build_station.py b/docs/equipment/inline-qc-station/build_station.py index f7b42ab..0207ae0 100644 --- a/docs/equipment/inline-qc-station/build_station.py +++ b/docs/equipment/inline-qc-station/build_station.py @@ -5,8 +5,10 @@ # It creates the document "InlineQCStation", saves inline-qc-station.FCStd next # to this script and leaves the view open for rendering (render_station.py). # -# Concept, not a fabrication drawing: profiles are drawn as plain 40 mm bars, -# bought parts as their envelope boxes. Sizes follow ../inline-optical-qc.md. +# Concept, not a fabrication drawing: the frame uses the real 8-slot 40 series +# section (profile_shape), bought parts are drawn as their envelope boxes. Sizes +# follow ../inline-optical-qc.md. At the end the script lists any part that does +# not reach the floor through parts it touches (panels not counted). # # Axes: x = record flow (infeed at x = 0, outfeed at x = L), y = across # (operator front at -y), z = up from the floor. All values in mm. @@ -107,8 +109,82 @@ def cyl(r, h, base, d=V(0, 0, 1)): return Part.makeCylinder(r, h, base, d) +# ---------------------------------------------------------------- real slot profiles +# 8-slot 40 series (item / Misumi HFS8 class): 8.2 mm slot opening, 4.3 mm lip, +# undercut chamber 16 mm wide, slot depth 12 mm, centre bore 6.8 mm (M8 tap), +# corner cavities, 0.8 mm corner chamfer. 40 x 80 adds a centre cavity. +ALU_BRACKET = (0.60, 0.62, 0.65) +_FACES = {} + + +def _slot(cx, cy, nx, ny): + td = [(-4.1, -1), (-4.1, 4.3), (-8.0, 4.3), (-8.0, 7.5), (-4.1, 12.0), + (4.1, 12.0), (8.0, 7.5), (8.0, 4.3), (4.1, 4.3), (4.1, -1)] + tx, ty = -ny, nx + pts = [V(cx + t * tx + d * nx, cy + t * ty + d * ny, 0) for t, d in td] + return Part.Face(Part.makePolygon(pts + [pts[0]])) + + +def profile_face(a, b): + """Cross-section a (local X) by b (local Y), both multiples of 40, corner at the origin.""" + if (a, b) in _FACES: + return _FACES[(a, b)] + c = 0.8 + o = [V(c, 0, 0), V(a - c, 0, 0), V(a, c, 0), V(a, b - c, 0), V(a - c, b, 0), V(c, b, 0), V(0, b - c, 0), V(0, c, 0)] + f = Part.Face(Part.makePolygon(o + [o[0]])) + cuts = [] + for i in range(int(a // 40)): + cuts += [_slot(20 + 40 * i, 0, 0, 1), _slot(20 + 40 * i, b, 0, -1)] + for j in range(int(b // 40)): + cuts += [_slot(0, 20 + 40 * j, 1, 0), _slot(a, 20 + 40 * j, -1, 0)] + for i in range(int(a // 40)): + for j in range(int(b // 40)): + cuts.append(Part.Face(Part.Wire(Part.makeCircle(3.4, V(20 + 40 * i, 20 + 40 * j, 0))))) + for cx, cy in [(5.4, 5.4), (a - 5.4, 5.4), (a - 5.4, b - 5.4), (5.4, b - 5.4)]: + cuts.append(Part.Face(Part.Wire(Part.makeCircle(2.2, V(cx, cy, 0))))) + if a == 40 and b == 80: + cuts.append(Part.Face(Part.makePolygon([V(14, 33, 0), V(26, 33, 0), V(26, 47, 0), V(14, 47, 0), V(14, 33, 0)]))) + if a == 80 and b == 40: + cuts.append(Part.Face(Part.makePolygon([V(33, 14, 0), V(47, 14, 0), V(47, 26, 0), V(33, 26, 0), V(33, 14, 0)]))) + for k in cuts: + f = f.cut(k) + _FACES[(a, b)] = f + return f + + +def profile_shape(x0, y0, z0, x1, y1, z1): + """A real slot profile filling the axis-aligned bar x0..x1, y0..y1, z0..z1.""" + dx, dy, dz = x1 - x0, y1 - y0, z1 - z0 + long_axis = max((dx, "x"), (dy, "y"), (dz, "z"))[1] + if long_axis == "x": # local X -> Y, local Y -> Z, extrusion -> X + a, b, n = dy, dz, dx + m = App.Matrix(0, 0, 1, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1) + elif long_axis == "y": # local X -> Z, local Y -> X, extrusion -> Y + a, b, n = dz, dx, dy + m = App.Matrix(0, 1, 0, 0, 0, 0, 1, 0, 1, 0, 0, 0, 0, 0, 0, 1) + else: + a, b, n = dx, dy, dz + m = App.Matrix() + a, b = round(a), round(b) + if a % 40 or b % 40: + return box(x0, y0, z0, x1, y1, z1) + sol = profile_face(a, b).extrude(V(0, 0, n)) + if long_axis != "z": + sol.transformShape(m) + sol.translate(V(x0, y0, z0)) + return sol + + def prof(group, name, x0, y0, z0, x1, y1, z1): - return add(group, name, box(x0, y0, z0, x1, y1, z1), ALU) + return add(group, name, profile_shape(x0, y0, z0, x1, y1, z1), ALU) + + +def gusset(group, name, corner, d1, d2, w): + """Cast angle bracket in the inside corner: legs 32 mm along d1 and d2, 34 mm wide along w.""" + p0 = corner - w * 17 + pts = [p0, p0 + d1 * 32, p0 + d2 * 32, p0] + tri = Part.Face(Part.makePolygon(pts)) + return add(group, name, tri.extrude(w * 34), ALU_BRACKET) def rot(shape, center, axis, deg): @@ -129,37 +205,54 @@ for i, (x, y) in enumerate([(20, -YF), (20, YF), (XT0 + 20, -YF), (XT0 + 20, YF) for s in (-1, 1): prof(G, f"BaseBeam{'F' if s < 0 else 'B'}", 0, s * YF - 20, 60, L, s * YF + 20, 140) # 40 x 80 for x0, z1, nm in [(0, 870, "End0"), (XT0, 1500, "Tunnel0"), (XT1, 1500, "Tunnel1"), - (L - P, 1080, "End1")]: + (L - P, 1340 if s < 0 else 1080, "End1")]: # front end upright carries the touch screen prof(G, f"Upright{nm}{'F' if s < 0 else 'B'}", x0, s * YF - 20, 140, x0 + P, s * YF + 20, z1) - prof(G, f"TopBeam{'F' if s < 0 else 'B'}", XT0, s * YF - 20, 1460, XT1 + P, s * YF + 20, 1500) + prof(G, f"TopBeam{'F' if s < 0 else 'B'}", XT0 + P, s * YF - 20, 1460, XT1, s * YF + 20, 1500) prof(G, f"LowBeam{'F' if s < 0 else 'B'}", XT0 + P, s * YF - 20, 420, XT1, s * YF + 20, 460) prof(G, f"RejectBeam{'F' if s < 0 else 'B'}", XT1 + P, s * YF - 20, 1040, L - P, s * YF + 20, 1080) for x0, nm in [(0, "End0"), (XT0, "Tunnel0"), (XT1, "Tunnel1"), (L - P, "End1")]: prof(G, f"BaseCross{nm}", x0, -YF + 20, 100, x0 + P, YF - 20, 140) prof(G, f"ConvCross{nm}", x0, -YF + 20, 790, x0 + P, YF - 20, 830) # top bridge: label camera and top line-scan -prof(G, "TopCrossLabel", X_LB - 20, -YF + 20, 1460, X_LB + 20, YF - 20, 1500) +prof(G, "TopCrossLabel", X_LB + 70, -YF + 20, 1460, X_LB + 110, YF - 20, 1500) xt = sum(TOP_LINE) / 2 -prof(G, "TopCrossLineScan", xt - 20, -YF + 20, 1460, xt + 20, YF - 20, 1500) +prof(G, "TopCrossLineScan", xt + 48, -YF + 20, 1460, xt + 88, YF - 20, 1500) prof(G, "TopCrossThermal", XT0 + P, -YF + 20, 1460, XT0 + P + 40, YF - 20, 1500) prof(G, "TopCrossBacklight", sum(BOT_LINE) / 2 - 38, -YF + 20, 1460, sum(BOT_LINE) / 2 + 2, YF - 20, 1500) # lower bridge: bottom label camera, bottom line-scan, chuck lift xb = sum(BOT_LINE) / 2 -prof(G, "LowCrossLabel", X_LB - 20, -YF + 20, 420, X_LB + 20, YF - 20, 460) +prof(G, "LowCrossLabel", X_LB + 70, -YF + 20, 420, X_LB + 110, YF - 20, 460) prof(G, "LowCrossLineScan", xb - 20, -YF + 20, 420, xb + 20, YF - 20, 460) prof(G, "LowCrossChuck", X_IS - 20, -YF + 20, 420, X_IS + 20, YF - 20, 460) +prof(G, "LowCrossBacklight", xt - 20, -YF + 20, 420, xt + 20, YF - 20, 460) +prof(G, "BaseCrossCabinet0", 1200, -YF + 20, 100, 1240, YF - 20, 140) +prof(G, "BaseCrossCabinet1", 1580, -YF + 20, 100, 1620, YF - 20, 140) prof(G, "RejectBridgeCross", X_RJ - 20, -YF + 20, 1040, X_RJ + 20, YF - 20, 1080) +# cast angle brackets in the visible frame corners +EX, EZ, EY = V(1, 0, 0), V(0, 0, 1), V(0, 1, 0) +_k = 0 +for s in (-1, 1): + y = s * YF + for x, dirs in [(P, (1,)), (XT0, (-1,)), (XT0 + P, (1,)), (XT1, (-1,)), (XT1 + P, (1,)), (L - P, (-1,))]: + for d in dirs: + gusset(G, f"Bracket{_k}", V(x, y, 140), EX * d, EZ, EY); _k += 1 + for x, d, z in [(XT0 + P, 1, 1460), (XT1, -1, 1460), (XT0 + P, 1, 420), (XT1, -1, 420), + (XT1 + P, 1, 1040), (L - P, -1, 1040)]: + gusset(G, f"Bracket{_k}", V(x, y, z), EX * d, EZ * -1, EY); _k += 1 + # ---------------------------------------------------------------- conveyor G = "Conveyor" for s in (-1, 1): side = "F" if s < 0 else "B" prof(G, f"Rail{side}", 0, s * 180 - 20, 830, L, s * 180 + 20, 870) - add(G, f"BeltSlide{side}", box(30, s * Y_BELT - 5, 880, L - 30, s * Y_BELT + 5, 894), UHMW) + add(G, f"BeltSlideCarrier{side}", box(45, min(s * 140, s * 200), 870, L - 45, max(s * 140, s * 200), 880), DARK) + add(G, f"BeltSlide{side}", box(45, s * Y_BELT - 5, 880, L - 45, s * Y_BELT + 5, 894), UHMW) add(G, f"BeltTop{side}", cyl(3, L - 40, V(20, s * Y_BELT, ZB - 3), V(1, 0, 0)), BELT) add(G, f"BeltReturn{side}", cyl(3, L - 40, V(20, s * Y_BELT, ZB - 49), V(1, 0, 0)), BELT) for x, nm in [(20, "Idler"), (L - 20, "Drive")]: add(G, f"Pulley{nm}{side}", cyl(23, 14, V(x, s * Y_BELT - 7, ZB - 26), V(0, 1, 0)), DARK) + add(G, f"PulleyAxle{nm}{side}", cyl(6, 6, V(x, min(s * (Y_BELT + 6), s * 160), ZB - 26), V(0, 1, 0)), PNEU) # infeed side guides with a flared mouth, PU strip on the rim height add(G, f"SideGuide{side}", box(110, s * 157 - 3, ZB + 2, XT0 - 10, s * 157 + 3, ZB + 14), UHMW) add(G, f"SideGuideFlare{side}", rot(box(40, s * 157 - 3, ZB + 2, 112, s * 157 + 3, ZB + 14), @@ -169,12 +262,15 @@ add(G, "DriveShaft", cyl(6, 2 * Y_BELT + 60, V(L - 20, -Y_BELT - 30, ZB - 26), V add(G, "DriveGearmotor", cyl(35, 150, V(L - 20, 210, ZB - 26), V(0, 1, 0)) .fuse(box(L - 60, 200, ZB - 70, L + 15, 230, ZB + 15)), SERVO) add(G, "EndStopBar", box(L - 30, -200, ZB + 30, L - 12, 200, ZB + 48), DARK) +for s in (-1, 1): + add(G, f"EndStopPost{'F' if s < 0 else 'B'}", box(L - 30, min(s * 170, s * 190), 870, L - 12, max(s * 170, s * 190), ZB + 30), DARK) add(G, "EndStopBumper", box(L - 32, -40, ZB + 4, L - 26, 40, ZB + 30), (0.9, 0.5, 0.1)) # presence sensors (diffuse, looking up through the open centre) and the stop beam G = "Sensors" for x, nm in [(X_IN, "Infeed"), (X_LB - 120, "LabelTrigger"), (L - 120, "Outfeed")]: add(G, f"Presence{nm}", box(x - 15, -120, 850, x + 15, -100, 885), (0.15, 0.35, 0.75)) + add(G, f"PresenceBracket{nm}", box(x - 10, -160, 840, x + 10, -100, 850), DARK) xs = X_IS + R_REC for s in (-1, 1): add(G, f"StopBeam{'Tx' if s < 0 else 'Rx'}", box(xs - 10, min(s * 200, s * 222), 870, xs + 10, max(s * 200, s * 222), ZB + 25), @@ -187,15 +283,17 @@ add(G, "LabelCamTop", box(X_LB - 15, -15, ZB + WD_LABEL + 50, X_LB + 15, 15, ZB add(G, "LabelLensTop", cyl(17, 50, V(X_LB, 0, ZB + WD_LABEL)), LENS) add(G, "LabelRingTop", ring(70, 42, 22, V(X_LB, 0, ZB + 170)), LIGHTBODY) add(G, "LabelRingTopFace", ring(62, 48, 1, V(X_LB, 0, ZB + 169)), LIGHT) -add(G, "LabelCamTopPost", box(X_LB - 20, 40, ZB + WD_LABEL + 50, X_LB + 20, 80, 1460), ALU) -add(G, "LabelCamTopPlate", box(X_LB - 20, 15, ZB + WD_LABEL + 50, X_LB + 20, 40, ZB + WD_LABEL + 92), DARK) +prof(G, "LabelCamTopPost", X_LB + 70, -20, ZB + 160, X_LB + 110, 20, 1460) +add(G, "LabelCamTopPlate", box(X_LB + 15, -12, ZB + WD_LABEL + 55, X_LB + 70, 12, ZB + WD_LABEL + 87), DARK) +add(G, "LabelRingTopClamp", box(X_LB + 58, -14, ZB + 166, X_LB + 70, 14, ZB + 196), DARK) zl = ZB - WD_LABEL add(G, "LabelCamBottom", box(X_LB - 15, -15, zl - 92, X_LB + 15, 15, zl - 50), CAM) add(G, "LabelLensBottom", cyl(17, 50, V(X_LB, 0, zl - 50)), LENS) add(G, "LabelRingBottom", ring(70, 42, 22, V(X_LB, 0, ZB - 80)), LIGHTBODY) add(G, "LabelRingBottomFace", ring(62, 48, 1, V(X_LB, 0, ZB - 58)), LIGHT) -add(G, "LabelCamBottomPost", box(X_LB - 20, 40, 460, X_LB + 20, 80, zl - 50), ALU) -add(G, "LabelCamBottomPlate", box(X_LB - 20, 15, zl - 92, X_LB + 20, 40, zl - 50), DARK) +prof(G, "LabelCamBottomPost", X_LB + 70, -20, 460, X_LB + 110, 20, ZB - 48) +add(G, "LabelCamBottomPlate", box(X_LB + 15, -12, zl - 87, X_LB + 70, 12, zl - 55), DARK) +add(G, "LabelRingBottomClamp", box(X_LB + 58, -14, ZB - 84, X_LB + 70, 14, ZB - 54), DARK) # ---------------------------------------------------------------- inspection: lift, clamp, one turn G = "Chuck" @@ -208,16 +306,16 @@ add(G, "SpindleServo", cyl(30, 110, V(X_IS, 110, zp - 242)), SERVO) add(G, "SpindleBeltCover", box(X_IS - 32, -32, zp - 120, X_IS + 32, 142, zp - 100), (0.3, 0.3, 0.32)) add(G, "LiftCylinderRods", cyl(6, 100, V(X_IS - 30, 0, zp - 232)).fuse(cyl(6, 100, V(X_IS + 30, 0, zp - 232))), PNEU) add(G, "LiftCylinder", box(X_IS - 50, -25, zp - 322, X_IS + 50, 25, zp - 232), PNEU) -add(G, "LiftPost", box(X_IS - 20, -20, 460, X_IS + 20, 20, zp - 322), ALU) +prof(G, "LiftPost", X_IS - 20, -20, 460, X_IS + 20, 20, zp - 322) G = "LineScanTop" zr = ZL + T_REC # top face of the lifted record add(G, "LineCamTop", box(xt - 31, -31, zr + WD_LINE + 60, xt + 31, 31, zr + WD_LINE + 105), CAM) add(G, "LineLensTop", cyl(25, 60, V(xt, 0, zr + WD_LINE)), LENS) -add(G, "LineCamTopPost", box(xt - 20, 40, zr + 150, xt + 20, 80, 1460), ALU) -add(G, "LightHangerTop", box(xt - 10, -112, zr + 130, xt + 10, 112, zr + 150), DARK) -add(G, "CoaxHangerTop", box(xt - 10, 30, zr + 80, xt + 10, 40, zr + 130), DARK) -add(G, "LineCamTopPlate", box(xt - 20, 31, zr + WD_LINE + 60, xt + 20, 40, zr + WD_LINE + 105), DARK) +prof(G, "LineCamTopPost", xt + 48, -20, zr + 150, xt + 88, 20, 1460) +add(G, "LightHangerTop", box(xt + 58, -112, zr + 130, xt + 78, 112, zr + 150), DARK) +add(G, "CoaxHangerTop", box(xt + 58, -10, zr + 80, xt + 78, 10, zr + 130), DARK) +add(G, "LineCamTopPlate", box(xt + 31, -20, zr + WD_LINE + 62, xt + 48, 20, zr + WD_LINE + 103), DARK) x0, x1 = TOP_LINE[0] - 5, TOP_LINE[1] + 5 add(G, "CoaxLightTop", box(x0, -30, zr + 50, x1, 30, zr + 110), LIGHTBODY) add(G, "CoaxLightTopFace", box(x0 + 3, -26, zr + 49, x1 - 3, 26, zr + 50), LIGHT) @@ -225,20 +323,21 @@ for s in (-1, 1): b = box(x0, -15, 0, x1, 15, 30) b.translate(V(0, s * 100, zr + 15)) add(G, f"DarkFieldTop{'F' if s < 0 else 'B'}", rot(b, V(0, s * 100, zr + 30), V(1, 0, 0), s * 20), LIGHTBODY) - add(G, f"DarkFieldTopBracket{'F' if s < 0 else 'B'}", box(xt - 10, s * 100 - 5, zr + 48, xt + 10, s * 100 + 5, zr + 130), DARK) + add(G, f"DarkFieldTopBracket{'F' if s < 0 else 'B'}", box(xt + 58, s * 100 - 5, zr + 48, xt + 78, s * 100 + 5, zr + 130), DARK) bx0, bx1 = BOT_LINE[0] - 5, BOT_LINE[1] + 5 add(G, "BacklightForBottomCam", box(bx0, -20, zr + 30, bx1, 20, zr + 52), LIGHTBODY) add(G, "BacklightForBottomCamFace", box(bx0 + 3, -17, zr + 29, bx1 - 3, 17, zr + 30), LIGHT) add(G, "BacklightBracketTop", box(xb - 30, -5, zr + 52, xb - 10, 5, 1460), DARK) add(G, "ScanLineTop", box(TOP_LINE[0], -0.6, zr + 0.05, TOP_LINE[1], 0.6, zr + 0.5), LASER) -add(G, "ThermalPost", box(XT0 + 90, -240, 1430, XT0 + 110, -220, 1460), DARK) +add(G, "ThermalPost", box(XT0 + 60, -240, 1400, XT0 + 80, -220, 1460), DARK) add(G, "ThermalCam", rot(box(XT0 + 70, -260, 1380, XT0 + 130, -200, 1440), V(XT0 + 100, -230, 1410), V(1, 0, 0), -30), (0.95, 0.45, 0.10)) G = "LineScanBottom" zu = ZL # underside of the lifted record add(G, "LineCamBottom", box(xb - 31, -31, zu - WD_LINE - 105, xb + 31, 31, zu - WD_LINE - 60), CAM) add(G, "LineLensBottom", cyl(25, 60, V(xb, 0, zu - WD_LINE - 60)), LENS) -add(G, "LineCamBottomPost", box(xb - 20, 40, 460, xb + 20, 80, zu - WD_LINE - 60), ALU) +prof(G, "LineCamBottomPost", xb - 20, 40, 460, xb + 20, 80, ZB - 20) +add(G, "CoaxHangerBottom", box(xb - 10, 30, ZB - 70, xb + 10, 40, ZB - 30), DARK) add(G, "LineCamBottomPlate", box(xb - 20, 31, zu - WD_LINE - 105, xb + 20, 40, zu - WD_LINE - 60), DARK) add(G, "CoaxLightBottom", box(bx0, -30, ZB - 80, bx1, 30, ZB - 20), LIGHTBODY) add(G, "CoaxLightBottomFace", box(bx0 + 3, -26, ZB - 20, bx1 - 3, 26, ZB - 19), LIGHT) @@ -248,6 +347,7 @@ for s in (-1, 1): add(G, f"DarkFieldBottom{'F' if s < 0 else 'B'}", rot(b, V(0, s * 175, zu - 25), V(1, 0, 0), -s * 8), LIGHTBODY) add(G, f"DarkFieldBottomBracket{'F' if s < 0 else 'B'}", box(bx0 + 40, s * 192 - 5, 870, bx0 + 60, s * 192 + 5, zu - 35), DARK) add(G, "BacklightForTopCam", box(x0, -20, ZB - 40, x1, 20, ZB - 15), LIGHTBODY) +prof(G, "BacklightPost", xt - 20, -20, 460, xt + 20, 20, ZB - 40) add(G, "BacklightForTopCamFace", box(x0 + 3, -17, ZB - 15, x1 - 3, 17, ZB - 14), LIGHT) add(G, "ScanLineBottom", box(BOT_LINE[0], -0.6, zu - 0.5, BOT_LINE[1], 0.6, zu - 0.05), LASER) @@ -256,7 +356,7 @@ for r in (90, 130): add(G, f"Laser_r{r}", box(X_IS - 23, -r - 10, ZB - 55, X_IS + 23, -r + 10, ZB - 15), (0.15, 0.35, 0.75)) add(G, f"LaserBeam_r{r}", cyl(0.8, zu - (ZB - 15), V(X_IS, -r, ZB - 15)), LASER, 30) add(G, f"LaserBracket_r{r}", box(X_IS - 10, -r - 10, 830, X_IS + 10, -r + 10, ZB - 55), DARK) -add(G, "LaserRail", box(X_IS - 20, -200, 810, X_IS + 20, -70, 830), ALU) +prof(G, "LaserRail", X_IS - 20, -200, 790, X_IS + 20, -70, 830) # ---------------------------------------------------------------- reject G = "Reject" @@ -266,15 +366,20 @@ add(G, "PusherCarriage", box(X_RJ - 35, yc - 30, 960, X_RJ + 35, yc + 30, 1000), add(G, "PusherArm", box(X_RJ - 10, yc - 10, ZB + 26, X_RJ + 10, yc + 10, 960), DARK) add(G, "PusherPaddle", box(X_RJ - 100, yc + 10, ZB + 4, X_RJ + 100, yc + 22, ZB + 26), (0.9, 0.5, 0.1)) CX0, CX1 = X_RJ - 170, X_RJ + 170 +Y_CH, Z_CH = 205, ZB - 7 # chute top edge: just outside the back rail, below the belt plane chute = box(CX0, 0, 0, CX1, 360, 6) chute = rot(chute, V(0, 0, 0), V(1, 0, 0), -42) -chute.translate(V(0, 165, ZB - 12)) +chute.translate(V(0, Y_CH, Z_CH)) add(G, "RejectChute", chute, (0.55, 0.57, 0.60)) for xw in (CX0 - 6, CX1): w = box(xw, 0, 0, xw + 6, 360, 80) w = rot(w, V(0, 0, 0), V(1, 0, 0), -42) - w.translate(V(0, 165, ZB - 12)) + w.translate(V(0, Y_CH, Z_CH)) add(G, f"ChuteWall{xw}", w, (0.55, 0.57, 0.60)) +import math +_zc = Z_CH - math.tan(math.radians(42)) * (YF + 20 - Y_CH) # chute underside above the back frame line +for i, xs_ in enumerate((CX0 + 10, CX1 - 50)): + prof(G, f"ChuteSupport{i}", xs_, YF - 20, 140, xs_ + 40, YF + 20, math.floor(_zc)) binb = box(CX0 - 20, 380, 0, CX1 + 20, 720, 560).cut(box(CX0 - 8, 392, 12, CX1 + 8, 708, 600)) add(G, "RejectBin", binb, (0.85, 0.20, 0.15), 20) @@ -299,17 +404,18 @@ add(G, "GuardRejectTop", box(XT1 + P, -YF - 23, 1100, L, YF + 23, 1103), CLEAR, # ---------------------------------------------------------------- electrical and operator G = "Electrical" -add(G, "Cabinet", box(1180, -200, 160, 1640, 170, 770), CABINET) +add(G, "Cabinet", box(1180, -200, 140, 1640, 170, 750), CABINET) add(G, "CabinetHandle", box(1600, -215, 400, 1615, -200, 520), DARK) -add(G, "StackLightPole", cyl(10, 120, V(XT1 + 20, -YF, 1503)), DARK) +add(G, "StackLightPole", cyl(10, 123, V(XT1 + 20, YF, 1500)), DARK) for i, c in enumerate([GREEN, AMBER, RED]): - add(G, f"StackLight{i}", cyl(25, 55, V(XT1 + 20, -YF, 1623 + i * 57)), c) -add(G, "HMIArm", box(XT1 + 50, -YF - 80, 1200, XT1 + 70, -YF - 20, 1220), DARK) -add(G, "HMI", rot(box(XT1 + 70, -YF - 100, 1130, XT1 + 330, -YF - 80, 1300), V(XT1 + 200, -YF - 90, 1215), V(1, 0, 0), -15), (0.10, 0.10, 0.12)) -add(G, "EStopBox", box(XT1 + 60, -YF - 60, 930, XT1 + 140, -YF - 20, 1010), YELLOW) -add(G, "EStop", cyl(22, 25, V(XT1 + 100, -YF - 60, 970), V(0, -1, 0)), RED) -add(G, "ValveTerminal", box(XT0 + 80, YF + 25, 300, XT0 + 330, YF + 75, 380), PNEU) -add(G, "FRL", box(XT0 + 360, YF + 25, 280, XT0 + 440, YF + 85, 420), (0.2, 0.4, 0.8)) + add(G, f"StackLight{i}", cyl(25, 57, V(XT1 + 20, YF, 1623 + i * 57)), c) +add(G, "HMIArm", box(L - 40, -YF - 80, 1225, L, -YF - 20, 1245), DARK) +add(G, "HMI", rot(box(L - 260, -YF - 100, 1150, L, -YF - 80, 1320), V(L - 130, -YF - 90, 1235), V(1, 0, 0), -15), (0.10, 0.10, 0.12)) +add(G, "EStopBox", box(XT1, -YF - 60, 930, XT1 + 80, -YF - 20, 1010), YELLOW) +add(G, "EStop", cyl(22, 25, V(XT1 + 40, -YF - 60, 970), V(0, -1, 0)), RED) +add(G, "ValveTerminal", box(XT0 + 80, YF + 20, 300, XT0 + 330, YF + 70, 380), PNEU) +add(G, "FRL", box(XT0 + 360, YF + 20, 280, XT0 + 440, YF + 80, 420), (0.2, 0.4, 0.8)) +prof(G, "PneumaticsRail", XT0 + P, YF - 20, 260, XT1, YF + 20, 300) # ---------------------------------------------------------------- records G = "Records" @@ -331,9 +437,9 @@ record("RecordInfeed", X_IN, 0, ZB, (0.95, 0.85, 0.75), body=(0.80, 0.12, 0.16), record("RecordInspection", X_IS, 0, ZL, (0.20, 0.45, 0.80)) record("RecordOutfeed", X_OUT, 0, ZB, (0.95, 0.60, 0.15)) import math -sc, a = 230, math.radians(42) -yr = 165 + math.cos(a) * sc + 6 * math.sin(a) -zr2 = ZB - 12 - math.sin(a) * sc + 6 * math.cos(a) +sc, a = 200, math.radians(42) +yr = Y_CH + math.cos(a) * sc + 6 * math.sin(a) +zr2 = Z_CH - math.sin(a) * sc + 6 * math.cos(a) record("RecordReject", X_RJ, yr, zr2, (0.6, 0.6, 0.6), tilt=(V(X_RJ, yr, zr2), V(1, 0, 0), -42)) doc.recompute() @@ -345,3 +451,30 @@ for o in doc.Objects: bb = o.Shape.BoundBox if bb is None else bb.united(o.Shape.BoundBox) print("objects", n, "bbox", round(bb.XMin), round(bb.XMax), round(bb.YMin), round(bb.YMax), round(bb.ZMin), round(bb.ZMax)) print("saved", OUT) + +# every part should touch another one, without counting the panels (hidden in the open views); +# list the ones that do not (gap > 1 mm) +_panels = set(o.Name for o in groups["Enclosure"].Group) +_parts = [o for o in doc.Objects if o.TypeId == "Part::Feature" and o.Name not in _panels] +# connectivity: every part must reach the floor through touching parts (gap <= 1 mm) +_adj = {o.Name: set() for o in _parts} +for ia, oa in enumerate(_parts): + ba = oa.Shape.BoundBox + for ob in _parts[ia + 1:]: + bq = ob.Shape.BoundBox + if (bq.XMin > ba.XMax + 2 or bq.XMax < ba.XMin - 2 or bq.YMin > ba.YMax + 2 or bq.YMax < ba.YMin - 2 + or bq.ZMin > ba.ZMax + 2 or bq.ZMax < ba.ZMin - 2): + continue + if oa.Shape.distToShape(ob.Shape)[0] <= 1.0: + _adj[oa.Name].add(ob.Name) + _adj[ob.Name].add(oa.Name) +_seen = [o.Name for o in _parts if o.Shape.BoundBox.ZMin < 1] +_todo = list(_seen) +_seen = set(_seen) +while _todo: + for nb in _adj[_todo.pop()]: + if nb not in _seen: + _seen.add(nb) + _todo.append(nb) +_floating = sorted(n for n in _adj if n not in _seen) +print("parts not connected to the floor:", _floating if _floating else "none") diff --git a/docs/equipment/inline-qc-station/inline-qc-station-annotated.png b/docs/equipment/inline-qc-station/inline-qc-station-annotated.png new file mode 100644 index 0000000..48747fb Binary files /dev/null and b/docs/equipment/inline-qc-station/inline-qc-station-annotated.png differ diff --git a/docs/equipment/inline-qc-station/inline-qc-station-bom.csv b/docs/equipment/inline-qc-station/inline-qc-station-bom.csv index 38c572b..e2f9def 100644 --- a/docs/equipment/inline-qc-station/inline-qc-station-bom.csv +++ b/docs/equipment/inline-qc-station/inline-qc-station-bom.csv @@ -1,7 +1,7 @@ group,item,qty,example,unit_usd_low,unit_usd_high,line_usd_low,line_usd_high,source -Frame,"40x40 slot profile, cut to length",27 m (37 pieces),"8-slot 4040 (Misumi HFS8-4040 class, or Thai/Chinese generic)",11,21,297,567,per m; Shopee Thailand listing from THB 356; aluxprofile EUR 19 to 24 per m +Frame,"40x40 slot profile, cut to length",32 m (45 pieces),"8-slot 4040 (Misumi HFS8-4040 class, or Thai/Chinese generic)",11,21,354,676,per m; Shopee Thailand listing from THB 356; aluxprofile EUR 19 to 24 per m Frame,"40x80 slot profile, base beams",3.8 m (2 pieces),8-slot 4080,22,42,84,160,"per m; estimate, about twice the 4040 price" -Frame,"Angle brackets, T-nuts, screws, end caps",1 lot (about 120 joints),,250,600,250,600,estimate +Frame,"Cast angle brackets (24 drawn), T-nuts, screws, end caps",1 lot,,250,600,250,600,estimate Frame,Levelling feet M16,8,,8,25,64,200,each; estimate Enclosure,"Black panels, 4 mm ACM or PP, light-tight tunnel",4.3 m2,,25,60,108,258,per m2; estimate Enclosure,"Clear polycarbonate guard, reject zone",1.4 m2,,30,70,42,98,per m2; estimate @@ -31,4 +31,4 @@ Control,"Edge PC with GPU, 4 GigE PoE ports, NixOS",1,,1500,3200,1500,3200,"qsco Control,"HMI touch panel, 10 inch",1,,400,900,400,900,estimate Control,"Safety relay, e-stop, door interlock wiring",1 lot,,300,600,300,600,estimate Control,"Cabinet, power supply, breakers, terminals, wiring, stack light",1 lot,,800,2000,800,2000,estimate -Total,"Hardware per station, thermal camera excluded",,,,,16056,32715,sum of the lines above +Total,"Hardware per station, thermal camera excluded",,,,,16114,32824,sum of the lines above diff --git a/docs/equipment/inline-qc-station/inline-qc-station-closed.png b/docs/equipment/inline-qc-station/inline-qc-station-closed.png index 0c71462..22b6127 100644 Binary files a/docs/equipment/inline-qc-station/inline-qc-station-closed.png and b/docs/equipment/inline-qc-station/inline-qc-station-closed.png differ diff --git a/docs/equipment/inline-qc-station/inline-qc-station-detail-front.png b/docs/equipment/inline-qc-station/inline-qc-station-detail-front.png index 20522d7..c204c22 100644 Binary files a/docs/equipment/inline-qc-station/inline-qc-station-detail-front.png and b/docs/equipment/inline-qc-station/inline-qc-station-detail-front.png differ diff --git a/docs/equipment/inline-qc-station/inline-qc-station-detail.png b/docs/equipment/inline-qc-station/inline-qc-station-detail.png index 33648be..01ef6be 100644 Binary files a/docs/equipment/inline-qc-station/inline-qc-station-detail.png and b/docs/equipment/inline-qc-station/inline-qc-station-detail.png differ diff --git a/docs/equipment/inline-qc-station/inline-qc-station-front.png b/docs/equipment/inline-qc-station/inline-qc-station-front.png index 3f4aabb..f069008 100644 Binary files a/docs/equipment/inline-qc-station/inline-qc-station-front.png and b/docs/equipment/inline-qc-station/inline-qc-station-front.png differ diff --git a/docs/equipment/inline-qc-station/inline-qc-station-iso.png b/docs/equipment/inline-qc-station/inline-qc-station-iso.png index 2088061..90146bb 100644 Binary files a/docs/equipment/inline-qc-station/inline-qc-station-iso.png and b/docs/equipment/inline-qc-station/inline-qc-station-iso.png differ diff --git a/docs/equipment/inline-qc-station/inline-qc-station-top.png b/docs/equipment/inline-qc-station/inline-qc-station-top.png index d92dd6c..e1ba523 100644 Binary files a/docs/equipment/inline-qc-station/inline-qc-station-top.png and b/docs/equipment/inline-qc-station/inline-qc-station-top.png differ diff --git a/docs/equipment/inline-qc-station/inline-qc-station.FCStd b/docs/equipment/inline-qc-station/inline-qc-station.FCStd index ee8f502..a06935b 100644 Binary files a/docs/equipment/inline-qc-station/inline-qc-station.FCStd and b/docs/equipment/inline-qc-station/inline-qc-station.FCStd differ diff --git a/docs/equipment/inline-qc-station/render_annotated.py b/docs/equipment/inline-qc-station/render_annotated.py new file mode 100644 index 0000000..9f4dd77 --- /dev/null +++ b/docs/equipment/inline-qc-station/render_annotated.py @@ -0,0 +1,160 @@ +# 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) diff --git a/docs/equipment/inline-qc-station/render_station.py b/docs/equipment/inline-qc-station/render_station.py index 3c18e13..44de356 100644 --- a/docs/equipment/inline-qc-station/render_station.py +++ b/docs/equipment/inline-qc-station/render_station.py @@ -13,6 +13,13 @@ doc = App.getDocument("InlineQCStation") _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) +Gui.Selection.clearPreselection() +Gui.Selection.clearSelection() +from pivy import coin def prep(d): @@ -51,8 +58,16 @@ def hide(groups=(), names=()): def shoot(v, fname, orient, w=3200, h=2400): getattr(v, orient)() + Gui.updateGui() v.fitAll() Gui.updateGui() + # move the orthographic camera well back so no part 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 * 8000) + c.nearDistance.setValue(100) + c.farDistance.setValue(32000) + Gui.updateGui() path = os.path.join(OUTDIR, fname) v.saveImage(path, w, h, "White") print("saved", path) @@ -132,4 +147,6 @@ App.closeDocument(d6.Name) Gui.setActiveDocument(doc.Name) _vp.SetBool("ShowNaviCube", _navi_before) +_vp.SetBool("UseNavigationAnimations", _anim_before) +_vp.SetBool("EnablePreselection", _presel_before) print("done")