new-site/docs/ink-signature-plotter.md
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

182 lines
8.6 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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<angle>` 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.
## 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:
1. **Reach checking.** `check_reach()` validates every planned point against the
annulus (`reach_min_units..reach_max_units` from the shoulder). The CLI prints
the report and **refuses to `--plot` on Line-us if any point is out of reach**
(an unreachable point would distort the signature).
2. **Jig solving.** The signature must be slid into the sweet spot. `--solve-jig`
calls `compute_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 computed `jig_x_mm/jig_y_mm` automatically.
A small registration jig (a corner stop + a marked cell on a card) holds the
paper so the solved offset is repeatable.
```bash
# 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
```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` (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-jig` brings 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_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 |