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:
parent
e6a630ada1
commit
b0a8563a93
8 changed files with 994 additions and 19 deletions
29
api/migrations/090_esign_signature_vector.sql
Normal file
29
api/migrations/090_esign_signature_vector.sql
Normal file
|
|
@ -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": <canvas css width px>, "h": <canvas css height px>,
|
||||
-- "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.';
|
||||
|
|
@ -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 || "",
|
||||
|
|
|
|||
125
docs/ink-signature-plotter.md
Normal file
125
docs/ink-signature-plotter.md
Normal file
|
|
@ -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<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.
|
||||
|
||||
## 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 |
|
||||
|
|
@ -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"],
|
||||
})
|
||||
|
|
|
|||
150
scripts/tests/test_ink_signature.py
Normal file
150
scripts/tests/test_ink_signature.py
Normal 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()
|
||||
157
scripts/workers/ink_signature_cli.py
Normal file
157
scripts/workers/ink_signature_cli.py
Normal 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()
|
||||
446
scripts/workers/services/ink_signature_plotter.py
Normal file
446
scripts/workers/services/ink_signature_plotter.py
Normal 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]))
|
||||
|
|
@ -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() };
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue