new-site/scripts/workers/ink_signature_cli.py
justin b0a8563a93 ink-signature: pen-plotter pipeline for original wet-ink CMS signatures
The Standard no-login CMS path needs an ORIGINAL ink signature on paper
(CMS-10114: 'Stamped, faxed or copied signatures will not be accepted'). This
adds a pipeline to redraw the provider's own captured strokes in real ink with a
pen on a CR-10 V2 (or any Marlin/GRBL machine) — original, in ink, never copied.

- migration 090: esign_records.signature_vector (JSONB stroke paths, 0..1).
- signing page now captures normalized stroke paths alongside the PNG; API
  stores a size-bounded vector for drawn signatures.
- ink_signature_plotter.py (hardware-independent): fit strokes to the signature
  anchor box, PDF-pt -> bed-mm via jig offset, emit Marlin/GRBL G-code (Z pen or
  M280 servo/BLTouch), SVG toolpath preview, and render_signature_on_pdf (a
  digital twin that proves the toolpath lands on the cert line). Gated serial
  sender (dry_run default).
- ink_signature_cli.py: end-to-end load-record -> gcode+preview, --test-box jig
  calibration, --plot to stream over USB.
- Corrected CMS-10114 signature anchor to sit inside the Section 4A signing cell
  (above the bottom rule, below the label).
- docs/ink-signature-plotter.md documents the CR-10 retrofit + interpretive risk.

Tests: test_ink_signature.py 30/30, test_cms10114.py 27/27, test_paper_batch.py
15/15, API tsc clean, Astro build 58 pages.
2026-06-07 02:34:17 -05:00

157 lines
6.3 KiB
Python

#!/usr/bin/env python3
"""ink_signature_cli — produce/plot an ink signature for a signed filing.
End-to-end glue for the pen-plotter pipeline. Pulls a signed esign record's
captured signature_vector + signature_anchors from the DB, fits the strokes to
the form's signature box, and:
* writes a G-code file (for the CR-10 V2 / any Marlin/GRBL plotter)
* writes an SVG toolpath preview (verify before plotting)
* optionally streams the G-code to the plotter over USB serial (--plot)
Calibration helper:
* --test-box draws just the signature-box outline so you can align the jig
This never plots unless --plot is passed (default is generate-only / dry-run),
so it is safe to run anywhere.
Examples:
# Generate gcode + preview for order CO-ABCD1234's CMS-10114 signature:
python scripts/workers/ink_signature_cli.py --order CO-ABCD1234 --doc cms10114
# Calibrate the jig (draw the box outline) to /tmp, dry-run:
python scripts/workers/ink_signature_cli.py --order CO-ABCD1234 --doc cms10114 --test-box
# Actually plot it (sheet loaded in jig, printer on /dev/ttyUSB0):
python scripts/workers/ink_signature_cli.py --order CO-ABCD1234 --doc cms10114 \
--plot --port /dev/ttyUSB0 --home
"""
from __future__ import annotations
import argparse
import json
import os
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / "scripts"))
# Import the plotter module directly (avoid workers.services package __init__,
# which pulls in heavy browser-automation deps we don't need here).
import importlib.util as _ilu
_spec = _ilu.spec_from_file_location(
"ink_signature_plotter",
ROOT / "scripts/workers/services/ink_signature_plotter.py",
)
ink = _ilu.module_from_spec(_spec)
sys.modules["ink_signature_plotter"] = ink
_spec.loader.exec_module(ink)
def _load_record(order: str, doc: str) -> dict:
import psycopg2
import psycopg2.extras
dsn = os.environ.get("DATABASE_URL", "")
if not dsn:
sys.exit("DATABASE_URL not set")
conn = psycopg2.connect(dsn)
try:
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
cur.execute(
"""SELECT id, order_number, document_type, signature_type,
signature_vector, signature_anchors, status
FROM esign_records
WHERE order_number = %s AND document_type = %s
AND status = 'signed'
ORDER BY signed_at DESC LIMIT 1""",
(order, doc),
)
row = cur.fetchone()
finally:
conn.close()
if not row:
sys.exit(f"No signed esign record for order={order} doc={doc}")
return dict(row)
def _coerce_json(val):
if val is None:
return None
if isinstance(val, (dict, list)):
return val
return json.loads(val)
def main():
ap = argparse.ArgumentParser(description="Generate/plot an ink signature for a signed filing.")
ap.add_argument("--order", required=True, help="order number, e.g. CO-ABCD1234")
ap.add_argument("--doc", default="cms10114", help="document_type (cms10114, cms855i, ...)")
ap.add_argument("--field", default="signer", help="signature anchor field to draw")
ap.add_argument("--out-dir", default="/tmp", help="where to write gcode/svg")
ap.add_argument("--port", default="/dev/ttyUSB0")
ap.add_argument("--baud", type=int, default=115200)
ap.add_argument("--jig-x", type=float, default=20.0, help="jig X offset mm")
ap.add_argument("--jig-y", type=float, default=20.0, help="jig Y offset mm")
ap.add_argument("--pen-down", type=float, default=0.0, help="pen-down Z mm")
ap.add_argument("--pen-up", type=float, default=3.0, help="pen-up Z mm")
ap.add_argument("--servo-pen", action="store_true", help="use M280 servo/BLTouch pen instead of Z")
ap.add_argument("--test-box", action="store_true", help="draw only the signature-box outline (calibration)")
ap.add_argument("--plot", action="store_true", help="actually stream to the plotter (default: dry-run)")
ap.add_argument("--home", action="store_true", help="prepend G28 home before plotting")
args = ap.parse_args()
rec = _load_record(args.order, args.doc)
anchors = _coerce_json(rec.get("signature_anchors")) or []
box = next((a for a in anchors if a.get("field") == args.field), anchors[0] if anchors else None)
if not box:
sys.exit("Record has no signature_anchors — cannot position the signature.")
cfg = ink.PlotterConfig(
jig_x_mm=args.jig_x, jig_y_mm=args.jig_y,
pen_down_mm=args.pen_down, pen_up_mm=args.pen_up,
servo_pen=args.servo_pen,
)
out_dir = Path(args.out_dir)
out_dir.mkdir(parents=True, exist_ok=True)
stem = f"{args.order}_{args.doc}"
if args.test_box:
gcode = ink.emit_test_box_gcode(cfg, box)
gpath = out_dir / f"{stem}_testbox.gcode"
gpath.write_text(gcode)
print(f"[calibration] wrote test-box gcode: {gpath}")
planned = ink.pdf_points_to_bed_mm(
[[(box["x"], box["y"]), (box["x"] + box["w"], box["y"]),
(box["x"] + box["w"], box["y"] + box["h"]), (box["x"], box["y"] + box["h"]),
(box["x"], box["y"])]], cfg)
else:
vector = _coerce_json(rec.get("signature_vector"))
if not vector:
sys.exit("Record has no signature_vector (typed signature, or signed before vector capture). "
"Use the digital stamp, or re-collect the signature.")
planned = ink.plan_signature(vector, box, cfg)
gcode = ink.emit_gcode(planned, cfg)
gpath = out_dir / f"{stem}_signature.gcode"
gpath.write_text(gcode)
print(f"wrote signature gcode: {gpath} "
f"({len(planned)} strokes, {sum(len(s.points_mm) for s in planned)} points)")
svg = ink.render_preview_svg(planned, cfg)
spath = out_dir / f"{stem}_preview.svg"
spath.write_text(svg)
print(f"wrote toolpath preview: {spath}")
result = ink.send_gcode_serial(
gcode, port=args.port, baud=args.baud,
dry_run=not args.plot, home_first=args.home,
)
print(f"serial: {json.dumps(result)}")
if not args.plot:
print("NOTE: dry-run (default). Load a sheet in the jig and re-run with --plot to draw.")
if __name__ == "__main__":
main()