new-site/scripts/workers/ink_signature_cli.py
justin 894d989445 Add portable Line-us pen-arm support to ink-signature pipeline
Adds a second machine class (small fan-shaped reach arm) alongside the
CR-10/AxiDraw rectangular-bed plotters, so wet signatures can be produced
while away from the home station.

ink_signature_plotter.py:
- PlotterConfig gains dialect (marlin|lineus) + name; new LineUsConfig
  (native units, pen height = per-move Z, reach annulus from shoulder pivot).
- Named machine profiles (cr10 default, axidraw, lineus) via load_profile().
- bed_mm_to_lineus_units(), check_reach() (annulus for lineus, rectangle for
  marlin), compute_jig_offset_for_box() (solves jig from the ACTUAL fitted ink
  extent so a wide cell line doesn't over-constrain a small arm).
- emit_gcode() dispatches to emit_marlin_gcode()/emit_lineus_gcode().
- send_lineus(): WiFi TCP 1337 (NUL-terminated, ok-acked) or USB serial,
  dry_run=True default (same gating as the CR-10 path).

ink_signature_cli.py: --profile, --solve-jig (auto-applies jig offset),
--lineus-host/--lineus-usb, reach-check that refuses to --plot out-of-reach
on Line-us.

Tests: 43 checks (was 30) covering profiles, reach check, jig solve, lineus
emitter, dry-run sender. Docs updated with profiles + portable workflow.
2026-06-07 03:45:46 -05:00

206 lines
8.9 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("--profile", default="cr10",
help="machine profile: cr10 | axidraw | lineus (default cr10)")
ap.add_argument("--port", default="/dev/ttyUSB0", help="USB serial port (marlin or lineus-USB)")
ap.add_argument("--baud", type=int, default=115200)
ap.add_argument("--lineus-host", default="line-us.local", help="Line-us WiFi host (TCP 1337)")
ap.add_argument("--lineus-usb", action="store_true", help="send Line-us over USB serial (--port) instead of WiFi")
ap.add_argument("--jig-x", type=float, default=None, help="override jig X offset mm")
ap.add_argument("--jig-y", type=float, default=None, help="override jig Y offset mm")
ap.add_argument("--pen-down", type=float, default=None, help="override pen-down Z mm")
ap.add_argument("--pen-up", type=float, default=None, help="override pen-up Z mm")
ap.add_argument("--servo-pen", action="store_true", help="force M280 servo pen (marlin profiles)")
ap.add_argument("--solve-jig", action="store_true",
help="for small machines (lineus): compute + apply the jig offset that "
"brings the signature cell into reach, then proceed")
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 (marlin)")
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.")
# Start from the named profile, then apply any explicit overrides.
try:
cfg = ink.load_profile(args.profile)
except ValueError as exc:
sys.exit(str(exc))
overrides = {}
if args.jig_x is not None: overrides["jig_x_mm"] = args.jig_x
if args.jig_y is not None: overrides["jig_y_mm"] = args.jig_y
if args.pen_down is not None: overrides["pen_down_mm"] = args.pen_down
if args.pen_up is not None: overrides["pen_up_mm"] = args.pen_up
if args.servo_pen: overrides["servo_pen"] = True
if overrides:
from dataclasses import replace
cfg = replace(cfg, **overrides)
# Small-machine jig solve: position the signature into the arm's reach.
# Load the captured signature vector up-front (needed for solve-jig too).
vector = _coerce_json(rec.get("signature_vector"))
if args.solve_jig:
solved = ink.compute_jig_offset_for_box(box, cfg, vector=vector)
print(f"[solve-jig] {json.dumps(solved)}")
if not solved.get("ok"):
sys.exit("solve-jig: signature does not fit the machine's reach — "
"use a larger machine or a smaller signature cell.")
from dataclasses import replace
cfg = replace(cfg, jig_x_mm=solved["jig_x_mm"], jig_y_mm=solved["jig_y_mm"])
print(f"[solve-jig] applied jig offset: x={cfg.jig_x_mm}mm y={cfg.jig_y_mm}mm")
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:
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}")
# Reach check (gates plotting on small machines, informational on large).
reach = ink.check_reach(planned, cfg)
print(f"reach: {json.dumps(reach)}")
if not reach["ok"]:
print("WARNING: some points are out of the machine's reach. "
"For lineus, re-run with --solve-jig (or adjust the jig).")
if args.plot and cfg.dialect == "lineus":
sys.exit("Refusing to plot out-of-reach signature on Line-us; fix the jig first.")
if cfg.dialect == "lineus":
result = ink.send_lineus(
gcode,
host=args.lineus_host,
serial_port=(args.port if args.lineus_usb else None),
baud=args.baud,
dry_run=not args.plot,
)
print(f"lineus: {json.dumps(result)}")
else:
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()