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.
8.6 KiB
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)
- Pen holder: print a spring-loaded CR-10 pen holder (clips to the X carriage / hotend shroud). The spring keeps even contact pressure.
- 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-penemitsM280 P0 S<angle>to deploy/stow instead of moving Z. Useful if you actuate the pen with a servo or repurpose the BLTouch.
- Default (Z mode): pen fixed; G-code lifts/drops Z (
- 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.
- Pen: a smooth gel/rollerball refill gives the most hand-written, wet-ink look. Avoid dry fiber tips.
- Linux: the CR-10 enumerates as a CH340 serial device (
/dev/ttyUSB0, 115200 baud). No drivers needed — we stream G-code ourselves.
Machine profiles
The pipeline is machine-agnostic via named profiles (--profile):
| Profile | Machine | Dialect | Pen lift | Bed/reach |
|---|---|---|---|---|
cr10 (default) |
Creality CR-10 V2 (home station) | Marlin | Z move | 300×300 mm rectangular |
axidraw |
AxiDraw / iDraw A4 pen plotter | Marlin/GRBL | servo (M280) | 210×297 mm rectangular |
lineus |
Line-us folding pen-arm (pocket) | Line-us GCode | pen Z per move | small fan-shaped annulus |
load_profile() returns a PlotterConfig; CLI flags (--jig-x/-y,
--pen-down/-up, --servo-pen) override individual fields.
Portable option: Line-us (pocket pen-arm)
When away from the home CR-10, the Line-us is a palm-size folding 2-link arm
that draws with a real pen. It speaks its own GCode subset over WiFi (TCP
1337) or USB serial, acks each command with "ok", and encodes pen height
as the Z value of each move (low Z = pen down, high Z = pen up), not mm.
Because the arm's reachable area is a small fan-shaped annulus in front of its shoulder pivot (not a rectangular bed), two things differ from the CR-10:
- Reach checking.
check_reach()validates every planned point against the annulus (reach_min_units..reach_max_unitsfrom the shoulder). The CLI prints the report and refuses to--ploton Line-us if any point is out of reach (an unreachable point would distort the signature). - Jig solving. The signature must be slid into the sweet spot.
--solve-jigcallscompute_jig_offset_for_box(box, cfg, vector=...), which aims the centre of the actual fitted ink extent (not the full cell, which can be far wider than the signature) at the annulus mid-radius and reach-checks the result. It then applies the computedjig_x_mm/jig_y_mmautomatically.
A small registration jig (a corner stop + a marked cell on a card) holds the paper so the solved offset is repeatable.
# Generate gcode + preview for Line-us, auto-solving the jig (safe, dry-run):
python scripts/workers/ink_signature_cli.py --order CO-XXXX --doc cms10114 \
--profile lineus --solve-jig
# Plot over WiFi (default host line-us.local:1337), sheet in the jig:
python scripts/workers/ink_signature_cli.py --order CO-XXXX --doc cms10114 \
--profile lineus --solve-jig --plot --lineus-host line-us.local
# Or plot over USB serial instead of WiFi:
python scripts/workers/ink_signature_cli.py --order CO-XXXX --doc cms10114 \
--profile lineus --solve-jig --plot --lineus-usb --port /dev/ttyACM0
send_lineus() defaults to dry_run=True (same gating as the CR-10 path); it
only moves the arm with --plot.
Calibration
# 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
# 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 (43 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
- machine profiles (cr10 / axidraw / lineus) load with the right dialect/bed
- Line-us reach check rejects out-of-reach plots;
--solve-jigbrings the signature into reach; the Line-us emitter homes, encodes pen Z, and warns on out-of-reach;send_lineus()is dry-run safe over both TCP and USB render_signature_on_pdfstamps 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 |