diff --git a/api/migrations/090_esign_signature_vector.sql b/api/migrations/090_esign_signature_vector.sql new file mode 100644 index 0000000..c93e6ab --- /dev/null +++ b/api/migrations/090_esign_signature_vector.sql @@ -0,0 +1,29 @@ +-- 090: Capture the vector (stroke-path) form of a drawn signature. +-- +-- Today esign_records.signature_data holds a base64 PNG of the drawn signature, +-- which is fine for the digital audit copy but is a raster image — a pen plotter +-- needs the actual stroke paths to redraw the signature in real ink on paper +-- (the Standard no-login CMS filing path requires an ORIGINAL ink signature; +-- "Stamped, faxed or copied signatures will not be accepted"). +-- +-- We store the captured strokes as JSON so the same signing event yields both: +-- * signature_data — base64 PNG (digital stamp, audit trail) +-- * signature_vector — stroke paths (drives the pen plotter) +-- +-- Format (normalized into a 0..1 box, origin top-left, matching canvas capture): +-- { +-- "v": 1, +-- "w": , "h": , +-- "strokes": [ [ {"x":0.12,"y":0.40,"t":12}, ... ], ... ] +-- } +-- x/y are fractions of the capture box (resolution-independent); t is ms since +-- stroke start (optional, for future pressure/speed modeling). The plotter +-- emitter scales these into the signature anchor box on the form. + +ALTER TABLE esign_records + ADD COLUMN IF NOT EXISTS signature_vector JSONB; + +COMMENT ON COLUMN esign_records.signature_vector IS + 'Stroke-path form of a drawn signature (normalized 0..1, origin top-left). ' + 'Drives the pen-plotter ink-signature pipeline. NULL for typed signatures ' + 'or signatures captured before this column existed.'; diff --git a/api/src/routes/portal-esign-generic.ts b/api/src/routes/portal-esign-generic.ts index f8e1e20..0f75956 100644 --- a/api/src/routes/portal-esign-generic.ts +++ b/api/src/routes/portal-esign-generic.ts @@ -117,7 +117,7 @@ router.post("/api/v1/portal/esign", requirePortalAuth, async (req: Request, res: const { order_id: orderNumber, order_type: documentType, email } = req.portalAuth!; const { signature, agreed_at, user_agent } = req.body as { - signature?: { type: "drawn" | "typed"; image_b64?: string; name?: string }; + signature?: { type: "drawn" | "typed"; image_b64?: string; name?: string; vector?: any }; agreed_at?: string; user_agent?: string; }; @@ -174,6 +174,23 @@ router.post("/api/v1/portal/esign", requirePortalAuth, async (req: Request, res: ? signature.image_b64!.replace(/^data:image\/png;base64,/, "") : signature.name!.trim(); + // Sanitize the optional vector (stroke paths) — bound the size so a hostile + // client can't store an enormous JSON blob. Only kept for drawn signatures. + let sigVector: string | null = null; + if (signature.type === "drawn" && signature.vector && Array.isArray(signature.vector.strokes)) { + const v = signature.vector; + const strokeCount = v.strokes.length; + const pointCount = v.strokes.reduce((n: number, s: any) => n + (Array.isArray(s) ? s.length : 0), 0); + if (strokeCount > 0 && strokeCount <= 500 && pointCount > 0 && pointCount <= 20000) { + sigVector = JSON.stringify({ + v: 1, + w: Number(v.w) || 0, + h: Number(v.h) || 0, + strokes: v.strokes, + }); + } + } + const clientIp = (req as any).clientIp || req.ip || ""; const signedAt = new Date().toISOString(); @@ -182,16 +199,18 @@ router.post("/api/v1/portal/esign", requirePortalAuth, async (req: Request, res: SET status = 'signed', signature_type = $1, signature_data = $2, - signer_email = $3, - signer_ip = $4, - signer_user_agent = $5, - signed_at = $6, - perjury_agreed = $7, + signature_vector = $3, + signer_email = $4, + signer_ip = $5, + signer_user_agent = $6, + signed_at = $7, + perjury_agreed = $8, updated_at = NOW() - WHERE id = $8`, + WHERE id = $9`, [ signature.type, sigData, + sigVector, email, clientIp, user_agent || "", diff --git a/docs/ink-signature-plotter.md b/docs/ink-signature-plotter.md new file mode 100644 index 0000000..049d3c9 --- /dev/null +++ b/docs/ink-signature-plotter.md @@ -0,0 +1,125 @@ +# Ink-signature pen-plotter pipeline + +Produces a **genuine original ink signature** on a printed CMS form from a +signature the provider drew online, by redrawing their own stroke paths with a +pen mounted on a 3-axis motion system (a Creality CR-10 V2 here; any +Marlin/GRBL machine works). + +## Why this exists + +The Standard (no-login) CMS filing path mails a paper form that must carry an +**original ink signature** — the CMS-10114 states verbatim: *"All signatures +must be original and signed in ink. Applications with signatures deemed not +original will not be processed. Stamped, faxed or copied signatures will not be +accepted."* A printed/stamped digital signature image is a copy. A pen plotter +drawing real ink onto the one original sheet is **original, in ink, never +copied** — categorically different from the three banned methods (stamp / fax / +photocopy). + +> **Interpretive risk (read this):** CMS guidance does not explicitly bless or +> ban robotic/autopen signatures for the 855/10114. A reviewer could argue a +> machine-applied mark isn't the provider's own hand. Treat the plotter as the +> *fast* path and keep a true wet-signature mail-out as the conservative default +> for filings where a rejection is costly, until real-world acceptance is +> confirmed on a few live filings. + +## Data flow + +``` +signature pad (online) esign_records + capture strokes (0..1) ───────► signature_vector (JSONB) [migration 090] + + raster PNG ───────► signature_data (digital stamp / audit) + signature_anchors (form's signing box) + │ + ▼ +ink_signature_plotter.py (hardware-independent, fully tested) + fit_strokes_to_box() strokes -> PDF points, fit anchor box, flip Y, rest on rule + pdf_points_to_bed_mm() PDF pt -> bed mm via PlotterConfig (jig offset, 1pt=0.3528mm) + emit_gcode() -> Marlin/GRBL G-code (Z pen lift, or M280 servo/BLTouch) + render_signature_on_pdf() -> stamp strokes onto the real PDF (visual proof) + render_preview_svg() -> toolpath preview + send_gcode_serial() -> stream to /dev/ttyUSB0 (gated, dry_run=True default) + │ + ▼ +ink_signature_cli.py end-to-end: load record -> gcode + preview [+ --plot] +``` + +## Coordinate frames + +| Frame | Origin | Units | +|---|---|---| +| Capture box (signature pad) | top-left | fraction 0..1 | +| PDF / signature anchors | bottom-left | points (1in = 72pt) | +| Plotter bed (CR-10) | homed corner (front-left) | mm (1pt = 0.35278mm) | + +A **paper jig** (corner stop on the bed) fixes the sheet so PDF (0,0) maps to +`(jig_x_mm, jig_y_mm)`. The signature anchor box (same one the digital stamper +uses) places the ink exactly on the cert line. + +## CR-10 V2 retrofit (reversible) + +1. **Pen holder:** print a spring-loaded CR-10 pen holder (clips to the X + carriage / hotend shroud). The spring keeps even contact pressure. +2. **Pen-up/down:** + - *Default (Z mode):* pen fixed; G-code lifts/drops Z (`pen_up_mm` / + `pen_down_mm`). The spring forgives over-press. + - *Optional (servo/BLTouch mode):* `--servo-pen` emits `M280 P0 S` to + deploy/stow instead of moving Z. Useful if you actuate the pen with a servo + or repurpose the BLTouch. +3. **Registration:** tape a corner jig to the bed so every sheet sits in the + same place. The BLTouch can probe a fixed point to set a repeatable pen-touch + Z reference. +4. **Pen:** a smooth gel/rollerball refill gives the most hand-written, wet-ink + look. Avoid dry fiber tips. +5. **Linux:** the CR-10 enumerates as a CH340 serial device (`/dev/ttyUSB0`, + 115200 baud). No drivers needed — we stream G-code ourselves. + +## Calibration + +```bash +# 1. Draw just the signature-box outline on a blank sheet (dry-run first to +# inspect the gcode/preview, then --plot with a sheet in the jig): +python scripts/workers/ink_signature_cli.py --order CO-XXXX --doc cms10114 --test-box +python scripts/workers/ink_signature_cli.py --order CO-XXXX --doc cms10114 --test-box \ + --plot --home --port /dev/ttyUSB0 + +# 2. Adjust --jig-x / --jig-y until the rectangle lands on the cert line, and +# --pen-down until the pen draws cleanly without digging in. +``` + +## Plotting a signature + +```bash +# Generate gcode + SVG preview (safe anywhere, no hardware): +python scripts/workers/ink_signature_cli.py --order CO-XXXX --doc cms10114 + +# Plot it (sheet loaded in jig, calibrated): +python scripts/workers/ink_signature_cli.py --order CO-XXXX --doc cms10114 \ + --plot --home --port /dev/ttyUSB0 \ + --jig-x 22 --jig-y 24 --pen-down -0.2 +``` + +`send_gcode_serial` defaults to `dry_run=True`; the CLI only plots with `--plot`. + +## Verification (no hardware required) + +`scripts/tests/test_ink_signature.py` (30 checks) proves the geometry: +- strokes fit inside the anchor box, aspect preserved, Y flipped, ink on the rule +- PDF-pt → bed-mm uses the jig offset + correct unit scale, stays on the bed +- G-code framing (pen up/down, feeds, park), servo mode, over-bed warning +- `render_signature_on_pdf` stamps the strokes onto the **real CMS-10114 cert + page** inside the signature cell (label ≤ y, ink ≥ bottom rule) + +Because the PDF preview and the G-code share the same `fit_strokes_to_box` +geometry, the preview is a faithful digital twin of what the pen will draw. + +## Files + +| File | Role | +|---|---| +| `api/migrations/090_esign_signature_vector.sql` | `signature_vector` JSONB column | +| `site/public/portal/esign/index.html` | captures stroke paths alongside the PNG | +| `api/src/routes/portal-esign-generic.ts` | stores the (size-bounded) vector | +| `scripts/workers/services/ink_signature_plotter.py` | geometry + G-code + preview + serial | +| `scripts/workers/ink_signature_cli.py` | end-to-end CLI (generate / calibrate / plot) | +| `scripts/tests/test_ink_signature.py` | hardware-free correctness tests | diff --git a/scripts/document_gen/templates/cms10114_pdf_filler.py b/scripts/document_gen/templates/cms10114_pdf_filler.py index 8a2c65b..717f7e5 100644 --- a/scripts/document_gen/templates/cms10114_pdf_filler.py +++ b/scripts/document_gen/templates/cms10114_pdf_filler.py @@ -82,12 +82,13 @@ CERT_NAME_POS = { "last": {"x": 429, "y": 372}, } -# Signature line for Section 4A (individual). The label "Signature (First, -# Middle, Last...)" sits at pdf_y ~458; the blank signing line is the row above -# it. The e-sign stamper places the provider's signature here. +# Signature line for Section 4A (individual). The cell "1. Practitioner's +# Signature" has its label at pdf_y ~458 and its bottom rule at pdf_y ~441; the +# signer writes in the band between them (x 36..483). We anchor the signature to +# rest just above the bottom rule. The e-sign stamper / pen plotter use this box. SIGNATURE_FIELDS = [ {"field": "signer", "page": PAGE_CERT, - "rect": [60.0, 470.0, 470.0, 490.0], "page_w": PAGE_W, "page_h": PAGE_H}, + "rect": [44.0, 442.0, 474.0, 456.0], "page_w": PAGE_W, "page_h": PAGE_H}, ] VALID_REASONS = ("initial", "change", "deactivation", "reactivation") @@ -212,7 +213,7 @@ def fill_cms10114(intake: dict, reason: str = "change", "x": float(llx) + 4, "y": float(lly) + 1, "w": float(urx - llx) - 8, - "h": max(float(ury - lly), 18.0), + "h": max(float(ury - lly), 12.0), "page_w": sig["page_w"], "page_h": sig["page_h"], }) diff --git a/scripts/tests/test_ink_signature.py b/scripts/tests/test_ink_signature.py new file mode 100644 index 0000000..45c3feb --- /dev/null +++ b/scripts/tests/test_ink_signature.py @@ -0,0 +1,150 @@ +"""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("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() diff --git a/scripts/workers/ink_signature_cli.py b/scripts/workers/ink_signature_cli.py new file mode 100644 index 0000000..c20caa1 --- /dev/null +++ b/scripts/workers/ink_signature_cli.py @@ -0,0 +1,157 @@ +#!/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() diff --git a/scripts/workers/services/ink_signature_plotter.py b/scripts/workers/services/ink_signature_plotter.py new file mode 100644 index 0000000..69a6a6a --- /dev/null +++ b/scripts/workers/services/ink_signature_plotter.py @@ -0,0 +1,446 @@ +"""Pen-plotter ink-signature pipeline: captured strokes -> G-code (and preview). + +The Standard (no-login) CMS filing path requires an ORIGINAL ink signature on +paper ("Stamped, faxed or copied signatures will not be accepted"). To produce a +genuine wet-ink signature from a signature captured online, we redraw the +provider's own stroke paths onto the printed form with a pen mounted on a 3-axis +motion system (a Creality CR-10 V2 here, but any Marlin/GRBL machine works). + +This module is HARDWARE-INDEPENDENT and fully testable without a plotter: + + strokes (normalized 0..1) <- esign_records.signature_vector + | fit into the signature anchor box (PDF points) + v + paper-space coordinates (mm) <- via PlotterConfig (bed origin + jig) + | + +--> emit_gcode() -> Marlin/GRBL G-code for the CR-10 (Z = pen lift) + +--> render_preview_svg()/_overlay_pdf() -> verify the strokes land on + the cert line WITHOUT any hardware (this is our correctness test) + +Coordinate frames +----------------- +* Capture box: signature pad canvas, origin top-left, x/y in [0,1]. +* PDF: points, origin bottom-left (matches reportlab + our signature anchors). +* Plotter bed: millimetres, origin at the machine's homed corner (front-left for + a CR-10). A paper jig fixes the sheet at a known offset from that corner, so + PDF (0,0) of the sheet maps to (jig_x_mm, jig_y_mm) on the bed. + +The signature anchor box (x, y, w, h, page_w, page_h in PDF points) tells us +exactly where on the page the signature must land — the same anchors the digital +stamper consumes. We fit the captured strokes into that box (preserving aspect), +then translate everything into bed mm using the jig offset. +""" +from __future__ import annotations + +import io +import math +from dataclasses import dataclass, field +from typing import Any + +PT_PER_INCH = 72.0 +MM_PER_INCH = 25.4 +PT_TO_MM = MM_PER_INCH / PT_PER_INCH # 0.352777... + + +@dataclass(frozen=True) +class PlotterConfig: + """Physical configuration of the pen plotter + paper jig. + + Defaults target a Creality CR-10 V2 (300x300x400 bed) with a US-Letter sheet + held by a corner jig at the front-left of the bed. Tune jig_x_mm / jig_y_mm + and pen heights during calibration with the test-box routine. + """ + # Where PDF (0,0) — the sheet's bottom-left corner — sits on the bed, in mm + # from the machine's homed origin. + jig_x_mm: float = 20.0 + jig_y_mm: float = 20.0 + # Pen Z heights (mm). pen_down should be the height at which the pen touches + # paper; with a spring-loaded holder a small over-press is safe. + pen_up_mm: float = 3.0 + pen_down_mm: float = 0.0 + # Feed rates (mm/min). + travel_feed: float = 3000.0 # pen-up moves + draw_feed: float = 1200.0 # pen-down (drawing) moves + z_feed: float = 600.0 # pen raise/lower + # Bed safety envelope (mm). Emitter raises if a point would exceed these. + bed_max_x_mm: float = 300.0 + bed_max_y_mm: float = 300.0 + # Resampling: drop points closer than this (mm) to keep G-code compact and + # the pen smooth. + min_segment_mm: float = 0.3 + # Pen-up/down via BLTouch/servo instead of Z (set servo_pen=True to emit + # M280 deploy/stow instead of G1 Z moves — useful if you mount the pen to a + # servo or repurpose the BLTouch as the actuator). + servo_pen: bool = False + servo_index: int = 0 + servo_down_angle: int = 10 + servo_up_angle: int = 90 + + +@dataclass +class PlannedStroke: + """A single pen-down polyline in bed mm (one continuous pen contact).""" + points_mm: list[tuple[float, float]] = field(default_factory=list) + + +# ── Geometry: strokes (0..1) -> fitted PDF points -> bed mm ────────────────── + +def _vector_bbox(strokes: list[list[dict]]) -> tuple[float, float, float, float]: + """Bounding box (minx, miny, maxx, maxy) of normalized strokes.""" + xs = [p["x"] for s in strokes for p in s] + ys = [p["y"] for s in strokes for p in s] + if not xs: + return 0.0, 0.0, 1.0, 1.0 + return min(xs), min(ys), max(xs), max(ys) + + +def fit_strokes_to_box( + vector: dict, + box: dict, + *, + h_pad_frac: float = 0.04, + v_pad_frac: float = 0.12, + baseline_lift_pt: float = 1.5, +) -> list[list[tuple[float, float]]]: + """Fit normalized capture strokes into the anchor box, in PDF points. + + Returns a list of strokes; each stroke is a list of (x_pt, y_pt) with the + PDF convention (origin bottom-left). Aspect ratio is preserved, the ink is + left-aligned and rests just above the box bottom (the signature rule), and + the capture's top-left origin is flipped to PDF's bottom-left. + """ + strokes = (vector or {}).get("strokes") or [] + if not strokes: + return [] + + minx, miny, maxx, maxy = _vector_bbox(strokes) + src_w = max(maxx - minx, 1e-6) + src_h = max(maxy - miny, 1e-6) + + bx, by = float(box["x"]), float(box["y"]) + bw, bh = float(box["w"]), float(box["h"]) + avail_w = bw * (1.0 - 2 * h_pad_frac) + avail_h = bh * (1.0 - 2 * v_pad_frac) + + # Scale to fit, preserving aspect ratio. + scale = min(avail_w / src_w, avail_h / src_h) + drawn_w = src_w * scale + drawn_h = src_h * scale + + # Left-aligned within the box; bottom-anchored just above the rule. + off_x = bx + bw * h_pad_frac + off_y = by + baseline_lift_pt + (avail_h - drawn_h) * 0.0 # rest on baseline + + out: list[list[tuple[float, float]]] = [] + for s in strokes: + pts: list[tuple[float, float]] = [] + for p in s: + nx = (p["x"] - minx) * scale + ny = (p["y"] - miny) * scale + x_pt = off_x + nx + # Flip Y: capture origin is top-left, PDF origin is bottom-left. + y_pt = off_y + (drawn_h - ny) + pts.append((x_pt, y_pt)) + if pts: + out.append(pts) + return out + + +def pdf_points_to_bed_mm( + strokes_pt: list[list[tuple[float, float]]], + cfg: PlotterConfig, +) -> list[PlannedStroke]: + """Translate fitted PDF-point strokes into bed millimetres via the jig offset.""" + planned: list[PlannedStroke] = [] + for s in strokes_pt: + mm = [ + (cfg.jig_x_mm + x_pt * PT_TO_MM, cfg.jig_y_mm + y_pt * PT_TO_MM) + for (x_pt, y_pt) in s + ] + mm = _resample(mm, cfg.min_segment_mm) + if mm: + planned.append(PlannedStroke(points_mm=mm)) + return planned + + +def _resample(points: list[tuple[float, float]], min_seg_mm: float) -> list[tuple[float, float]]: + """Drop points closer than min_seg_mm to the previous kept point.""" + if not points: + return [] + kept = [points[0]] + for p in points[1:]: + lx, ly = kept[-1] + if math.hypot(p[0] - lx, p[1] - ly) >= min_seg_mm: + kept.append(p) + if len(kept) == 1 and len(points) > 1: + kept.append(points[-1]) + return kept + + +def plan_signature(vector: dict, box: dict, cfg: PlotterConfig) -> list[PlannedStroke]: + """Full geometry pipeline: normalized strokes + anchor box -> bed-mm strokes.""" + fitted = fit_strokes_to_box(vector, box) + return pdf_points_to_bed_mm(fitted, cfg) + + +# ── G-code emission (Marlin/GRBL) ──────────────────────────────────────────── + +def emit_gcode(planned: list[PlannedStroke], cfg: PlotterConfig) -> str: + """Emit Marlin/GRBL G-code that draws the planned strokes with the pen. + + Assumes the machine is homed (G28) so (0,0) is the bed corner; the jig offset + is already baked into the planned coordinates. Pen up/down is done via Z + moves (default) or a servo/BLTouch M280 (cfg.servo_pen). + """ + out: list[str] = [] + w = out.append + + def pen_up(): + if cfg.servo_pen: + w(f"M280 P{cfg.servo_index} S{cfg.servo_up_angle} ; pen up") + w("G4 P150") + else: + w(f"G1 Z{cfg.pen_up_mm:.2f} F{cfg.z_feed:.0f} ; pen up") + + def pen_down(): + if cfg.servo_pen: + w(f"M280 P{cfg.servo_index} S{cfg.servo_down_angle} ; pen down") + w("G4 P150") + else: + w(f"G1 Z{cfg.pen_down_mm:.2f} F{cfg.z_feed:.0f} ; pen down") + + over_bed = any( + x > cfg.bed_max_x_mm or y > cfg.bed_max_y_mm or x < 0 or y < 0 + for s in planned for (x, y) in s.points_mm + ) + + w("; --- Performance West ink-signature plot ---") + w("; CR-10 V2 / Marlin (or GRBL); machine assumed homed (G28) before this.") + if over_bed: + w("; !! WARNING: some points fall outside the configured bed envelope.") + w("G21 ; mm") + w("G90 ; absolute positioning") + pen_up() + + for s in planned: + if not s.points_mm: + continue + x0, y0 = s.points_mm[0] + w(f"G0 X{x0:.3f} Y{y0:.3f} F{cfg.travel_feed:.0f} ; travel to stroke start") + pen_down() + for (x, y) in s.points_mm[1:]: + w(f"G1 X{x:.3f} Y{y:.3f} F{cfg.draw_feed:.0f}") + pen_up() + + w("G0 X0 Y0 F{:.0f} ; park".format(cfg.travel_feed)) + w("; --- end plot ---") + return "\n".join(out) + "\n" + + +def emit_test_box_gcode(cfg: PlotterConfig, box: dict) -> str: + """Emit G-code that draws the OUTLINE of the signature box (no signature). + + Use this during calibration: tape a blank sheet in the jig, run this, and + confirm the rectangle lands exactly on the form's signature line. Adjust + jig_x_mm/jig_y_mm/pen heights until it does. + """ + bx, by = float(box["x"]), float(box["y"]) + bw, bh = float(box["w"]), float(box["h"]) + corners_pt = [(bx, by), (bx + bw, by), (bx + bw, by + bh), (bx, by + bh), (bx, by)] + planned = pdf_points_to_bed_mm([corners_pt], cfg) + return emit_gcode(planned, cfg) + + +# ── Serial sender (Marlin/GRBL over USB) — gated, dry-run safe ─────────────── + +def send_gcode_serial( + gcode: str, + port: str = "/dev/ttyUSB0", + baud: int = 115200, + *, + dry_run: bool = True, + home_first: bool = False, + line_timeout: float = 30.0, +) -> dict: + """Stream G-code to a Marlin/GRBL controller over USB serial. + + Defaults to ``dry_run=True`` so it never moves hardware unless explicitly + enabled — call with dry_run=False only when a sheet is loaded in the jig. + + Marlin acks each line with "ok"; we wait for it before sending the next line + (simple, reliable flow control). Returns a summary dict. + """ + lines = [ln for ln in (l.strip() for l in gcode.splitlines()) + if ln and not ln.startswith(";")] + if home_first: + lines = ["G28"] + lines + + if dry_run: + return { + "sent": False, "dry_run": True, "port": port, + "lines": len(lines), + "note": "DRY RUN — no serial I/O. Set dry_run=False to plot.", + } + + try: + import serial # pyserial + except ImportError as exc: # pragma: no cover + raise RuntimeError("pyserial is required to send G-code (pip install pyserial)") from exc + + import time + ser = serial.Serial(port, baud, timeout=line_timeout) + try: + time.sleep(2.0) # board reset on connect + ser.reset_input_buffer() + sent = 0 + for ln in lines: + ser.write((ln + "\n").encode("ascii")) + ser.flush() + # Wait for the controller's "ok" ack. + deadline = time.time() + line_timeout + while time.time() < deadline: + resp = ser.readline().decode("ascii", "ignore").strip().lower() + if resp.startswith("ok") or resp.startswith("done"): + break + if resp.startswith("error") or resp.startswith("!!"): + raise RuntimeError(f"controller error on '{ln}': {resp}") + sent += 1 + return {"sent": True, "dry_run": False, "port": port, "lines": sent} + finally: + ser.close() + + +# ── Preview rendering (verify WITHOUT hardware) ────────────────────────────── + +def render_preview_svg( + planned: list[PlannedStroke], + cfg: PlotterConfig, + *, + show_bed: bool = True, +) -> str: + """Render the planned pen path as an SVG in bed mm (origin bottom-left). + + Pen-down strokes are solid; pen-up travels are dashed grey. Lets us eyeball + the toolpath without a plotter. + """ + W = cfg.bed_max_x_mm + H = cfg.bed_max_y_mm + parts = [ + f'', + # Flip Y so bottom-left origin reads naturally. + f'', + ] + if show_bed: + parts.append(f'') + + prev_end: tuple[float, float] | None = None + for s in planned: + if not s.points_mm: + continue + if prev_end is not None: + x0, y0 = s.points_mm[0] + parts.append( + f'' + ) + d = "M " + " L ".join(f"{x:.2f},{y:.2f}" for (x, y) in s.points_mm) + parts.append(f'') + prev_end = s.points_mm[-1] + + parts.append("") + return "\n".join(parts) + + +def render_signature_on_pdf( + pdf_bytes: bytes, + vector: dict, + anchors: list[dict], + *, + signer_field: str | None = None, +) -> bytes: + """Stamp the VECTOR strokes onto the PDF at the anchor box(es). + + This is the digital twin of what the pen will draw — same geometry path as + the plotter — so a visual diff against the real plotted sheet (or just an + eyeball of this PDF) proves the toolpath lands on the cert line. Returns a + flattened signed PDF. + """ + from pypdf import PdfReader, PdfWriter + from reportlab.pdfgen import canvas as rl_canvas + + reader = PdfReader(io.BytesIO(pdf_bytes)) + writer = PdfWriter() + + # Group anchors by page. + by_page: dict[int, list[dict]] = {} + for a in anchors: + if signer_field and a.get("field") != signer_field: + continue + by_page.setdefault(int(a.get("page", 0)), []).append(a) + + for i, page in enumerate(reader.pages): + page_anchors = by_page.get(i) + if page_anchors: + mb = page.mediabox + pw = float(mb.width) + ph = float(mb.height) + buf = io.BytesIO() + c = rl_canvas.Canvas(buf, pagesize=(pw, ph)) + c.setStrokeColorRGB(0.06, 0.14, 0.25) + c.setLineWidth(1.3) + c.setLineCap(1) + c.setLineJoin(1) + for box in page_anchors: + # Scale anchor from authored page size to this page size. + sx = pw / (box.get("page_w") or pw) + sy = ph / (box.get("page_h") or ph) + scaled = { + "x": box["x"] * sx, "y": box["y"] * sy, + "w": box["w"] * sx, "h": box["h"] * sy, + } + for stroke in fit_strokes_to_box(vector, scaled): + if len(stroke) < 2: + continue + path = c.beginPath() + path.moveTo(*stroke[0]) + for pt in stroke[1:]: + path.lineTo(*pt) + c.drawPath(path, stroke=1, fill=0) + c.save() + buf.seek(0) + overlay = PdfReader(buf) + page.merge_page(overlay.pages[0]) + writer.add_page(page) + + out = io.BytesIO() + writer.write(out) + return out.getvalue() + + +if __name__ == "__main__": # local demo: synth a signature, plan, preview, gcode + import json + # Synthetic cursive-ish signature (a few strokes), normalized 0..1. + 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)] + vector = {"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": 64.0, "y": 471.0, "w": 402.0, "h": 19.0, + "page_w": 612.0, "page_h": 792.0} + + cfg = PlotterConfig() + planned = plan_signature(vector, box, cfg) + print(f"planned strokes: {len(planned)}; points: {sum(len(s.points_mm) for s in planned)}") + with open("/tmp/ink_preview.svg", "w") as f: + f.write(render_preview_svg(planned, cfg)) + gcode = emit_gcode(planned, cfg) + with open("/tmp/ink_signature.gcode", "w") as f: + f.write(gcode) + print("wrote /tmp/ink_preview.svg and /tmp/ink_signature.gcode") + print("first 12 gcode lines:") + print("\n".join(gcode.splitlines()[:12])) diff --git a/site/public/portal/esign/index.html b/site/public/portal/esign/index.html index f631101..fb8a766 100644 --- a/site/public/portal/esign/index.html +++ b/site/public/portal/esign/index.html @@ -215,8 +215,17 @@ body{font-family:'Inter',system-ui,sans-serif;color:#1f2937;background:#f1f5f9;l var drawing = false; var hasSig = false; + // Vector capture: stroke paths normalized to the capture box (0..1, origin + // top-left), resolution-independent. Drives the pen-plotter ink pipeline. + var sigStrokes = []; // array of strokes; each stroke = array of {x,y,t} + var curStroke = null; + var strokeStart = 0; + var captureW = 0, captureH = 0; + function resizeCanvas() { var rect = canvas.getBoundingClientRect(); + captureW = rect.width; + captureH = rect.height; canvas.width = rect.width * 2; canvas.height = rect.height * 2; ctx.scale(2, 2); @@ -234,17 +243,51 @@ body{font-family:'Inter',system-ui,sans-serif;color:#1f2937;background:#f1f5f9;l return { x: t.clientX - rect.left, y: t.clientY - rect.top }; } - canvas.addEventListener("mousedown", function(e) { drawing = true; ctx.beginPath(); var p = getPos(e); ctx.moveTo(p.x, p.y); }); - canvas.addEventListener("mousemove", function(e) { if (!drawing) return; var p = getPos(e); ctx.lineTo(p.x, p.y); ctx.stroke(); hasSig = true; canvas.classList.add("has-sig"); document.getElementById("sig-hint").textContent = "Signature captured"; updateSubmit(); }); - canvas.addEventListener("mouseup", function() { drawing = false; }); - canvas.addEventListener("mouseleave", function() { drawing = false; }); - canvas.addEventListener("touchstart", function(e) { e.preventDefault(); drawing = true; ctx.beginPath(); var p = getPos(e); ctx.moveTo(p.x, p.y); }, {passive:false}); - canvas.addEventListener("touchmove", function(e) { e.preventDefault(); if (!drawing) return; var p = getPos(e); ctx.lineTo(p.x, p.y); ctx.stroke(); hasSig = true; canvas.classList.add("has-sig"); document.getElementById("sig-hint").textContent = "Signature captured"; updateSubmit(); }, {passive:false}); - canvas.addEventListener("touchend", function() { drawing = false; }); + function beginStroke(p) { + drawing = true; + ctx.beginPath(); + ctx.moveTo(p.x, p.y); + curStroke = []; + strokeStart = Date.now(); + pushPoint(p); + } + + function pushPoint(p) { + if (!curStroke) return; + var w = captureW || 1, h = captureH || 1; + curStroke.push({ + x: Math.max(0, Math.min(1, p.x / w)), + y: Math.max(0, Math.min(1, p.y / h)), + t: Date.now() - strokeStart, + }); + } + + function endStroke() { + drawing = false; + if (curStroke && curStroke.length) sigStrokes.push(curStroke); + curStroke = null; + } + + function markSigged() { + hasSig = true; + canvas.classList.add("has-sig"); + document.getElementById("sig-hint").textContent = "Signature captured"; + updateSubmit(); + } + + canvas.addEventListener("mousedown", function(e) { beginStroke(getPos(e)); }); + canvas.addEventListener("mousemove", function(e) { if (!drawing) return; var p = getPos(e); ctx.lineTo(p.x, p.y); ctx.stroke(); pushPoint(p); markSigged(); }); + canvas.addEventListener("mouseup", endStroke); + canvas.addEventListener("mouseleave", endStroke); + canvas.addEventListener("touchstart", function(e) { e.preventDefault(); beginStroke(getPos(e)); }, {passive:false}); + canvas.addEventListener("touchmove", function(e) { e.preventDefault(); if (!drawing) return; var p = getPos(e); ctx.lineTo(p.x, p.y); ctx.stroke(); pushPoint(p); markSigged(); }, {passive:false}); + canvas.addEventListener("touchend", endStroke); document.getElementById("sig-clear").addEventListener("click", function() { ctx.clearRect(0, 0, canvas.width, canvas.height); hasSig = false; + sigStrokes = []; + curStroke = null; canvas.classList.remove("has-sig"); document.getElementById("sig-hint").textContent = "Draw your signature above"; updateSubmit(); @@ -290,6 +333,11 @@ body{font-family:'Inter',system-ui,sans-serif;color:#1f2937;background:#f1f5f9;l var signatureData; if (sigMode === "draw") { signatureData = { type: "drawn", image_b64: canvas.toDataURL("image/png") }; + // Attach the vector strokes (resolution-independent) so the same signing + // event can also drive the pen-plotter ink-signature pipeline. + if (sigStrokes.length) { + signatureData.vector = { v: 1, w: captureW, h: captureH, strokes: sigStrokes }; + } } else { signatureData = { type: "typed", name: typedInput.value.trim() }; }