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.
This commit is contained in:
justin 2026-06-07 02:34:17 -05:00
parent e6a630ada1
commit b0a8563a93
8 changed files with 994 additions and 19 deletions

View file

@ -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"],
})

View file

@ -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)", "<path" in svg)
check("svg has pen-up travel dashes", "stroke-dasharray" in svg)
print("render onto REAL CMS-10114 cert page (visual proof, numeric)")
sample = {"provider_name": "Jane Q Smith", "npi": "1234567893",
"practice_state": "CA", "enumeration_type": "NPI-1"}
pdf_bytes, anchors, _ = filler.fill_cms10114(sample, reason="change")
check("filler exposes a signer anchor on cert page",
anchors and anchors[0]["field"] == "signer" and anchors[0]["page"] == filler.PAGE_CERT)
signed = ink.render_signature_on_pdf(pdf_bytes, vector, anchors, signer_field="signer")
check("signed PDF is larger than blank (ink added)", len(signed) > 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()

View file

@ -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()

View file

@ -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'<svg xmlns="http://www.w3.org/2000/svg" width="{W}mm" height="{H}mm" '
f'viewBox="0 0 {W} {H}">',
# Flip Y so bottom-left origin reads naturally.
f'<g transform="translate(0,{H}) scale(1,-1)">',
]
if show_bed:
parts.append(f'<rect x="0" y="0" width="{W}" height="{H}" fill="#fff" stroke="#ccc" stroke-width="0.5"/>')
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'<line x1="{prev_end[0]:.2f}" y1="{prev_end[1]:.2f}" '
f'x2="{x0:.2f}" y2="{y0:.2f}" stroke="#bbb" stroke-width="0.2" '
f'stroke-dasharray="1,1"/>'
)
d = "M " + " L ".join(f"{x:.2f},{y:.2f}" for (x, y) in s.points_mm)
parts.append(f'<path d="{d}" fill="none" stroke="#10243f" stroke-width="0.6" stroke-linecap="round" stroke-linejoin="round"/>')
prev_end = s.points_mm[-1]
parts.append("</g></svg>")
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]))