"""Tests for the pen-plotter ink-signature pipeline (hardware-independent). Verifies the geometry + emitters without any plotter: - strokes (0..1) fit inside the signature anchor box, aspect preserved - Y is flipped (capture top-left -> PDF bottom-left), ink rests on the rule - PDF-point -> bed-mm conversion uses the jig offset + correct unit scale - emitted G-code has pen up/down framing and stays on the bed - servo (BLTouch) pen mode emits M280 instead of Z moves - the test-box routine traces the anchor rectangle - render_signature_on_pdf stamps the strokes onto the real CMS-10114 cert page inside the signature cell (the visual correctness proof, checked numerically) Run: python scripts/tests/test_ink_signature.py """ from __future__ import annotations import importlib.util import math import sys from pathlib import Path ROOT = Path(__file__).resolve().parents[2] sys.path.insert(0, str(ROOT / "scripts")) def _load(name, rel): spec = importlib.util.spec_from_file_location(name, ROOT / rel) mod = importlib.util.module_from_spec(spec) sys.modules[name] = mod # register so dataclass introspection works spec.loader.exec_module(mod) return mod ink = _load("ink_signature_plotter", "scripts/workers/services/ink_signature_plotter.py") filler = _load("cms10114_pdf_filler", "scripts/document_gen/templates/cms10114_pdf_filler.py") _fails = 0 def check(name, cond): global _fails if not cond: _fails += 1 print(f" {'PASS' if cond else 'FAIL'} {name}") def _synth_vector(): def wave(n, x0, x1, y0, amp, ph): return [{"x": x0 + (x1 - x0) * k / (n - 1), "y": y0 + amp * math.sin(2 * math.pi * (k / (n - 1) * 2) + ph), "t": k * 8} for k in range(n)] return {"v": 1, "w": 600, "h": 160, "strokes": [ wave(40, 0.08, 0.45, 0.55, 0.18, 0.0), wave(40, 0.50, 0.92, 0.55, 0.15, 1.2), ]} BOX = {"field": "signer", "page": 4, "x": 48.0, "y": 443.0, "w": 422.0, "h": 14.0, "page_w": 612.0, "page_h": 792.0} def main(): vector = _synth_vector() print("fit_strokes_to_box (PDF points)") fitted = ink.fit_strokes_to_box(vector, BOX) pts = [p for s in fitted for p in s] xs = [p[0] for p in pts] ys = [p[1] for p in pts] check("strokes preserved", len(fitted) == 2) check("all x within box width", all(BOX["x"] - 0.5 <= x <= BOX["x"] + BOX["w"] + 0.5 for x in xs)) check("all y within box height", all(BOX["y"] - 0.5 <= y <= BOX["y"] + BOX["h"] + 1.5 for y in ys)) check("ink rests near the rule (min y close to box bottom)", min(ys) <= BOX["y"] + 3.0) # Aspect ratio preserved: drawn width/height ratio matches source ratio. src = vector["strokes"] sxs = [p["x"] for s in src for p in s]; sys_ = [p["y"] for s in src for p in s] src_ratio = (max(sxs) - min(sxs)) / (max(sys_) - min(sys_)) drawn_ratio = (max(xs) - min(xs)) / (max(ys) - min(ys)) check("aspect ratio preserved (±5%)", abs(drawn_ratio - src_ratio) / src_ratio < 0.05) print("pdf points -> bed mm") cfg = ink.PlotterConfig() planned = ink.pdf_points_to_bed_mm(fitted, cfg) allmm = [p for s in planned for p in s.points_mm] # Box bottom-left in PDF (48,443) pt -> bed mm = jig + pt*PT_TO_MM. # The ink is left-aligned with a small horizontal pad (h_pad_frac of box w). exp_x = cfg.jig_x_mm + (48.0 + BOX["w"] * 0.04) * ink.PT_TO_MM exp_y = cfg.jig_y_mm + 443.0 * ink.PT_TO_MM minx = min(p[0] for p in allmm); miny = min(p[1] for p in allmm) check("bed x near jig + padded box-left in mm", abs(minx - exp_x) < 2.0) check("bed y near jig + box-bottom in mm", abs(miny - exp_y) < 3.0) check("all points within bed envelope", all(0 <= x <= cfg.bed_max_x_mm and 0 <= y <= cfg.bed_max_y_mm for (x, y) in allmm)) print("G-code emission (Z pen)") g = ink.emit_gcode(planned, cfg) check("declares mm + absolute", "G21" in g and "G90" in g) check("has pen-up Z move", f"Z{cfg.pen_up_mm:.2f}" in g) check("has pen-down Z move", f"Z{cfg.pen_down_mm:.2f}" in g) check("uses draw feed on G1 XY", f"F{cfg.draw_feed:.0f}" in g) check("uses travel feed on G0", f"F{cfg.travel_feed:.0f}" in g) check("no over-bed warning for in-bounds plot", "outside the configured bed" not in g) check("parks at end", "X0 Y0" in g) print("G-code emission (servo / BLTouch pen)") cfg_servo = ink.PlotterConfig(servo_pen=True) gs = ink.emit_gcode(ink.pdf_points_to_bed_mm(fitted, cfg_servo), cfg_servo) check("servo mode uses M280 deploy", f"M280 P0 S{cfg_servo.servo_down_angle}" in gs) check("servo mode uses M280 stow", f"M280 P0 S{cfg_servo.servo_up_angle}" in gs) check("servo mode does not Z-lift", "Z3.00" not in gs) print("calibration test-box") tb = ink.emit_test_box_gcode(cfg, BOX) check("test box has pen down + up", "pen down" in tb and "pen up" in tb) check("test box draws 4 segments back to start", tb.count("G1 X") >= 4) print("over-bed safety warning") big_box = {**BOX, "x": 40000.0} # absurd -> way off bed g_big = ink.emit_gcode(ink.plan_signature(vector, big_box, cfg), cfg) check("over-bed plot warns", "outside the configured bed" in g_big) print("profiles: cr10 / axidraw / lineus") cr10 = ink.load_profile("cr10") axi = ink.load_profile("axidraw") lu = ink.load_profile("lineus") check("cr10 is marlin Z-pen", cr10.dialect == "marlin" and not cr10.servo_pen) check("axidraw is marlin servo + A4 bed", axi.dialect == "marlin" and axi.servo_pen and axi.bed_max_y_mm == 297.0) check("lineus dialect + has LineUsConfig", lu.dialect == "lineus" and lu.lineus is not None) raised = False try: ink.load_profile("nope") except ValueError: raised = True check("unknown profile raises", raised) print("Line-us reach check + jig solve") # With the default jig (0,0), the CMS box (PDF ~48,443 pt) maps far outside # the small arm's reach -> reach check should fail. lu_planned_raw = ink.plan_signature(vector, BOX, lu) rep_raw = ink.check_reach(lu_planned_raw, lu) check("raw lineus plot is out of reach", rep_raw["dialect"] == "lineus" and not rep_raw["ok"]) # Solve the jig: should bring the whole cell into reach. solved = ink.compute_jig_offset_for_box(BOX, lu, vector=vector) check("solve-jig reports ok", solved["ok"] is True) check("solve-jig returns jig offsets", "jig_x_mm" in solved and "jig_y_mm" in solved) from dataclasses import replace as _replace lu_fixed = _replace(lu, jig_x_mm=solved["jig_x_mm"], jig_y_mm=solved["jig_y_mm"]) lu_planned = ink.plan_signature(vector, BOX, lu_fixed) rep_fixed = ink.check_reach(lu_planned, lu_fixed) check("after solve-jig the signature is in reach", rep_fixed["ok"] is True) print("Line-us G-code emission") glu = ink.emit_gcode(lu_planned, lu_fixed) check("lineus homes the arm", "G28" in glu) check("lineus encodes pen height as Z in G01", "G01 X" in glu and f"Z{lu.lineus.pen_up_z:d}" in glu) check("lineus uses pen-down Z", f"Z{lu.lineus.pen_down_z:d}" in glu) check("lineus has no Marlin mm header", "G21" not in glu) check("in-reach lineus plot has no reach warning", "fall outside the arm reach" not in glu) # Out-of-reach plot should carry the warning comment. glu_bad = ink.emit_gcode(lu_planned_raw, lu) check("out-of-reach lineus plot warns", "fall outside the arm reach" in glu_bad) print("Line-us sender is dry-run safe") res_tcp = ink.send_lineus(glu, dry_run=True) check("lineus dry-run does not send (tcp)", res_tcp["sent"] is False and res_tcp["transport"] == "tcp") res_usb = ink.send_lineus(glu, serial_port="/dev/ttyACM0", dry_run=True) check("lineus dry-run does not send (usb)", res_usb["sent"] is False and res_usb["transport"] == "serial") print("preview SVG") svg = ink.render_preview_svg(planned, cfg) check("svg has path(s)", " len(pdf_bytes)) # Confirm the fitted strokes for the real anchor land inside the cell band # (label at ~458 pt, bottom rule at ~441 pt; ink must sit between). fitted_real = ink.fit_strokes_to_box(vector, anchors[0]) ys_real = [p[1] for s in fitted_real for p in s] check("real-anchor ink above bottom rule (>=441)", min(ys_real) >= 441.0) check("real-anchor ink below label (<=458)", max(ys_real) <= 458.0) print() if _fails: print(f"FAILED: {_fails} checks") sys.exit(1) print("all ink-signature checks passed") if __name__ == "__main__": main()