"""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. Two control dialects are supported: - "marlin" (default): Marlin/GRBL G-code in millimetres, pen up/down via Z or M280 servo. For the CR-10 and AxiDraw-class machines. - "lineus": the Line-us palm-size drawing arm. It speaks its own GCode in its OWN units over a small, fan-shaped reach envelope, with the pen height encoded as Z in each G01. See LineUsConfig. The internal geometry pipeline always works in millimetres; the emitter for each dialect converts at the end. """ # Control dialect: "marlin" or "lineus". dialect: str = "marlin" # Human label for the profile (e.g. "cr10", "axidraw", "lineus"). name: str = "cr10" # 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 # Line-us mapping (only used when dialect == "lineus"). lineus: "LineUsConfig | None" = None @dataclass(frozen=True) class LineUsConfig: """Maps bed millimetres to the Line-us arm's native coordinate space. The Line-us is a small 2-link arm that draws with a real pen. It accepts GCode (G01 X Y Z, G28 home) but in its OWN units over a fan-shaped reach, NOT millimetres, and the pen height is the Z value in each move (low Z = pen down, high Z = pen up). Its usable area is roughly a half-disc in front of the shoulder pivot. We model the reachable area as: distance from the shoulder pivot (in Line-us units) must lie within [reach_min, reach_max], and we affine-map bed mm to Line-us units with a uniform scale + origin. Because the work area is small, the paper jig must place the signature cell inside reach — check_reach() verifies this BEFORE plotting and the jig calculator helps position the form. Defaults follow the documented Line-us envelope (X ~700..1775, Y ~-1000..1000, Z pen 0=down .. 1000=up). units_per_mm is the conversion derived from the arm's ~A6 usable width. """ units_per_mm: float = 13.0 # Line-us units per millimetre on the page origin_x_units: float = 1000.0 # Line-us X where the jig's bed-origin sits origin_y_units: float = 0.0 # Line-us Y where the jig's bed-origin sits pen_down_z: int = 0 # Z value for pen on paper pen_up_z: int = 1000 # Z value for pen lifted # Reach envelope, measured from the shoulder pivot at Line-us (0,0), in units. shoulder_x_units: float = 0.0 shoulder_y_units: float = 0.0 reach_min_units: float = 700.0 reach_max_units: float = 2000.0 feed: int = 0 # Line-us ignores F; kept for parity @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) # ── Built-in machine profiles ──────────────────────────────────────────────── # # Named presets so the home CR-10 and a portable Line-us are both first-class. # Geometry/jig values are starting points; calibrate per device with --test-box. def _profile_cr10() -> PlotterConfig: """Home station: Creality CR-10 V2 with a corner jig (Marlin, Z pen-lift).""" return PlotterConfig(dialect="marlin", name="cr10") def _profile_axidraw() -> PlotterConfig: """Portable A4 pen plotter (AxiDraw/iDraw class): Marlin/GRBL with a servo lift.""" return PlotterConfig( dialect="marlin", name="axidraw", bed_max_x_mm=210.0, bed_max_y_mm=297.0, servo_pen=True, servo_up_angle=70, servo_down_angle=30, jig_x_mm=10.0, jig_y_mm=10.0, ) def _profile_lineus() -> PlotterConfig: """Pocket station: Line-us folding pen-arm (own GCode/units, small reach). The arm's reach is small, so the jig must place the signature cell in the sweet spot — use compute_jig_offset_for_box() (the CLI --solve-jig does this) to get jig_x_mm/jig_y_mm for a given form before plotting. """ return PlotterConfig( dialect="lineus", name="lineus", # mm bookkeeping is still used internally; reach is enforced in Line-us units. bed_max_x_mm=150.0, bed_max_y_mm=150.0, jig_x_mm=0.0, jig_y_mm=0.0, lineus=LineUsConfig(), ) PROFILES: dict[str, "callable[[], PlotterConfig]"] = { "cr10": _profile_cr10, "axidraw": _profile_axidraw, "lineus": _profile_lineus, } def load_profile(name: str) -> PlotterConfig: """Return a PlotterConfig for a named profile (cr10 | axidraw | lineus).""" key = (name or "cr10").lower() if key not in PROFILES: raise ValueError(f"unknown plotter profile '{name}'; choices: {sorted(PROFILES)}") return PROFILES[key]() # ── 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) # ── Line-us coordinate mapping + reach checking ────────────────────────────── # # The Line-us arm has a small, fan-shaped reach. Bed-mm coordinates (the same # internal representation every dialect uses) are affine-mapped to Line-us units # with a uniform scale + origin, then reach-checked against the shoulder pivot. def bed_mm_to_lineus_units(x_mm: float, y_mm: float, lu: LineUsConfig) -> tuple[float, float]: """Map a bed-mm point to Line-us native units.""" return ( lu.origin_x_units + x_mm * lu.units_per_mm, lu.origin_y_units + y_mm * lu.units_per_mm, ) def _lineus_reach_ok(ux: float, uy: float, lu: LineUsConfig) -> bool: """True if a Line-us-unit point lies within the arm's reach annulus.""" d = math.hypot(ux - lu.shoulder_x_units, uy - lu.shoulder_y_units) return lu.reach_min_units <= d <= lu.reach_max_units def check_reach(planned: list[PlannedStroke], cfg: PlotterConfig) -> dict: """Verify EVERY planned point is reachable by the configured machine. For "lineus" this checks the fan-shaped reach annulus; for "marlin" it checks the rectangular bed envelope. Returns a report dict: { ok, dialect, total_points, out_of_reach, first_bad, min_d, max_d } Call this BEFORE plotting so a tiny machine never tries to draw where it cannot reach (which would distort the signature). """ pts = [(x, y) for s in planned for (x, y) in s.points_mm] total = len(pts) if cfg.dialect == "lineus": lu = cfg.lineus or LineUsConfig() bad = 0 first_bad: tuple[float, float] | None = None dists: list[float] = [] for (x_mm, y_mm) in pts: ux, uy = bed_mm_to_lineus_units(x_mm, y_mm, lu) d = math.hypot(ux - lu.shoulder_x_units, uy - lu.shoulder_y_units) dists.append(d) if not (lu.reach_min_units <= d <= lu.reach_max_units): bad += 1 if first_bad is None: first_bad = (round(x_mm, 2), round(y_mm, 2)) return { "ok": bad == 0, "dialect": "lineus", "total_points": total, "out_of_reach": bad, "first_bad": first_bad, "min_d": round(min(dists), 1) if dists else None, "max_d": round(max(dists), 1) if dists else None, "reach": [lu.reach_min_units, lu.reach_max_units], } # marlin: rectangular bed bad = 0 first_bad = None for (x, y) in pts: if x < 0 or y < 0 or x > cfg.bed_max_x_mm or y > cfg.bed_max_y_mm: bad += 1 if first_bad is None: first_bad = (round(x, 2), round(y, 2)) return { "ok": bad == 0, "dialect": "marlin", "total_points": total, "out_of_reach": bad, "first_bad": first_bad, "bed": [cfg.bed_max_x_mm, cfg.bed_max_y_mm], } def compute_jig_offset_for_box( box: dict, cfg: PlotterConfig, *, vector: dict | None = None, margin_units: float = 50.0, ) -> dict: """For a small machine, find a jig offset that brings the signature into reach. The signature box (PDF points) defines where ink must land on the sheet. On a small arm we cannot reach an arbitrary page location, so we slide the paper (i.e. choose jig_x_mm/jig_y_mm) until the signature sits in the sweet spot of the reach annulus. This returns the recommended jig offset (mm) plus whether the whole signature then fits. When ``vector`` is given, we solve + reach-check against the ACTUAL fitted ink extent (left-aligned, sub-cell) rather than the full cell rectangle. A full cell line (e.g. CMS-855 spans ~422pt) is far wider than the real signature, so using the ink extent is both more correct and far more likely to fit a small arm. When ``vector`` is None we fall back to the whole-cell corners. Strategy: aim the CENTRE of the (ink or cell) bbox at the mid-radius of the reach annulus, straight ahead of the shoulder (uy = shoulder_y). Solve for the jig offset that places it there, then reach-check the bbox corners. """ if cfg.dialect != "lineus": return {"ok": True, "note": "Reach is rectangular; no jig solve needed.", "jig_x_mm": cfg.jig_x_mm, "jig_y_mm": cfg.jig_y_mm} lu = cfg.lineus or LineUsConfig() # Determine the target bbox (PDF points): the actual ink extent if we have the # vector, otherwise the full cell. if vector and (vector.get("strokes")): fitted = fit_strokes_to_box(vector, box) ink_pts = [p for s in fitted for p in s] if ink_pts: bx = min(p[0] for p in ink_pts) by = min(p[1] for p in ink_pts) bw = max(p[0] for p in ink_pts) - bx bh = max(p[1] for p in ink_pts) - by else: bx, by = float(box["x"]), float(box["y"]) bw, bh = float(box["w"]), float(box["h"]) else: bx, by = float(box["x"]), float(box["y"]) bw, bh = float(box["w"]), float(box["h"]) # Centre of the target bbox in PDF points -> the bed-mm point we want at the # annulus mid-radius, directly ahead of the shoulder. cx_pt = bx + bw / 2.0 cy_pt = by + bh / 2.0 cx_mm_in_sheet = cx_pt * PT_TO_MM cy_mm_in_sheet = cy_pt * PT_TO_MM mid_r = (lu.reach_min_units + lu.reach_max_units) / 2.0 target_ux = lu.shoulder_x_units + mid_r target_uy = lu.shoulder_y_units # We need: origin + (jig + sheet_offset)*units_per_mm = target_units. # => jig_mm = (target_units - origin_units)/units_per_mm - sheet_offset_mm new_jig_x = (target_ux - lu.origin_x_units) / lu.units_per_mm - cx_mm_in_sheet new_jig_y = (target_uy - lu.origin_y_units) / lu.units_per_mm - cy_mm_in_sheet test_cfg = _replace_cfg(cfg, jig_x_mm=new_jig_x, jig_y_mm=new_jig_y) # Reach-check: against the real fitted ink if we have it, else the cell corners. if vector and (vector.get("strokes")): check_planned = plan_signature(vector, box, test_cfg) else: corners_pt = [(bx, by), (bx + bw, by), (bx + bw, by + bh), (bx, by + bh)] check_planned = pdf_points_to_bed_mm([corners_pt], test_cfg) rep = check_reach(check_planned, test_cfg) return { "ok": rep["ok"], "jig_x_mm": round(new_jig_x, 2), "jig_y_mm": round(new_jig_y, 2), "reach_report": rep, "note": ( "Cell fits in reach with this jig offset." if rep["ok"] else "Cell does NOT fully fit; the machine's reach is too small for this " "signature-cell size — reduce the cell or use a larger machine." ), } def _replace_cfg(cfg: PlotterConfig, **changes: Any) -> PlotterConfig: """Return a copy of cfg with fields overridden (frozen dataclass helper).""" from dataclasses import replace return replace(cfg, **changes) # ── G-code emission (Marlin/GRBL) ──────────────────────────────────────────── def emit_gcode(planned: list[PlannedStroke], cfg: PlotterConfig) -> str: """Emit machine code for the configured dialect. Dispatches to the Marlin/GRBL emitter (CR-10, AxiDraw) or the Line-us emitter. Both consume the same bed-mm planned strokes; the per-dialect converter runs at the end. """ if cfg.dialect == "lineus": return emit_lineus_gcode(planned, cfg) return emit_marlin_gcode(planned, cfg) def emit_marlin_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_lineus_gcode(planned: list[PlannedStroke], cfg: PlotterConfig) -> str: """Emit Line-us GCode for the planned strokes. The Line-us speaks a GCode subset in its OWN units, where the pen height is the Z value of each move (low Z = down, high Z = up). It acks each command with "ok". We convert bed-mm to Line-us units, raise the pen between strokes, and emit one G01 per point. A leading comment carries a reach warning if any point falls outside the arm's envelope (call check_reach() to gate plotting). """ lu = cfg.lineus or LineUsConfig() out: list[str] = [] w = out.append def to_units(x_mm: float, y_mm: float) -> tuple[float, float]: return bed_mm_to_lineus_units(x_mm, y_mm, lu) report = check_reach(planned, cfg) w("; --- Performance West ink-signature plot (Line-us) ---") w("; Line-us native GCode; pen height is the Z value of each move.") if not report["ok"]: w(f"; !! WARNING: {report['out_of_reach']} of {report['total_points']} points " f"fall outside the arm reach {report.get('reach')}. Re-jig the form " f"(compute_jig_offset_for_box) before plotting.") w("G28 ; home the arm") # Start pen up at the first stroke's start to avoid a dragging line. for s in planned: if not s.points_mm: continue x0, y0 = s.points_mm[0] ux0, uy0 = to_units(x0, y0) # travel with pen up, then drop pen w(f"G01 X{ux0:.0f} Y{uy0:.0f} Z{lu.pen_up_z:d} ; travel to stroke start (pen up)") w(f"G01 X{ux0:.0f} Y{uy0:.0f} Z{lu.pen_down_z:d} ; pen down") for (x, y) in s.points_mm[1:]: ux, uy = to_units(x, y) w(f"G01 X{ux:.0f} Y{uy:.0f} Z{lu.pen_down_z:d}") # lift pen at end of stroke lx, ly = s.points_mm[-1] ulx, uly = to_units(lx, ly) w(f"G01 X{ulx:.0f} Y{uly:.0f} Z{lu.pen_up_z:d} ; pen up") # Park: pen up, return to a safe rest position ahead of the shoulder. park_x = lu.shoulder_x_units + (lu.reach_min_units + lu.reach_max_units) / 2.0 w(f"G01 X{park_x:.0f} Y{lu.shoulder_y_units:.0f} Z{lu.pen_up_z:d} ; park") 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() def send_lineus( gcode: str, *, host: str = "line-us.local", tcp_port: int = 1337, serial_port: str | None = None, baud: int = 115200, dry_run: bool = True, line_timeout: float = 30.0, ) -> dict: """Send Line-us GCode over WiFi (TCP 1337) or USB serial. The Line-us connects over its own TCP socket (sends a "hello"/greeting on connect) OR over USB serial, and acks each command with "ok". Defaults to ``dry_run=True`` so it never moves the arm unless explicitly enabled. Pass serial_port to use USB instead of WiFi. """ lines = [ln for ln in (l.strip() for l in gcode.splitlines()) if ln and not ln.startswith(";")] if dry_run: return { "sent": False, "dry_run": True, "transport": "serial" if serial_port else "tcp", "target": serial_port or f"{host}:{tcp_port}", "lines": len(lines), "note": "DRY RUN — no I/O. Set dry_run=False to plot.", } import time def _wait_ok(read_line) -> None: deadline = time.time() + line_timeout while time.time() < deadline: resp = read_line().decode("ascii", "ignore").strip().lower() if resp.startswith("ok"): return if resp.startswith("error") or resp.startswith("!!"): raise RuntimeError(f"Line-us error: {resp}") raise RuntimeError("Line-us ack timeout") if serial_port: try: import serial # pyserial except ImportError as exc: # pragma: no cover raise RuntimeError("pyserial required for USB (pip install pyserial)") from exc ser = serial.Serial(serial_port, baud, timeout=line_timeout) try: time.sleep(2.0) ser.reset_input_buffer() sent = 0 for ln in lines: ser.write((ln + "\n").encode("ascii")) ser.flush() _wait_ok(ser.readline) sent += 1 return {"sent": True, "dry_run": False, "transport": "serial", "target": serial_port, "lines": sent} finally: ser.close() # WiFi: raw TCP socket. import socket sock = socket.create_connection((host, tcp_port), timeout=line_timeout) sock.settimeout(line_timeout) buf = b"" def _readline() -> bytes: nonlocal buf while b"\x00" not in buf and b"\n" not in buf: chunk = sock.recv(256) if not chunk: break buf += chunk # Line-us terminates responses with a NUL byte. sep = b"\x00" if b"\x00" in buf else b"\n" if sep in buf: line, _, buf = buf.partition(sep) return line line, buf = buf, b"" return line try: _readline() # consume the connect greeting sent = 0 for ln in lines: sock.sendall(ln.encode("ascii") + b"\x00") _wait_ok(_readline) sent += 1 return {"sent": True, "dry_run": False, "transport": "tcp", "target": f"{host}:{tcp_port}", "lines": sent} finally: sock.close() # ── Preview rendering (verify WITHOUT hardware) ────────────────────────────── def render_preview_svg( planned: list[PlannedStroke], cfg: PlotterConfig, *, show_bed: bool = True, ) -> str: """Render the planned pen path as an SVG in bed mm (origin bottom-left). Pen-down strokes are solid; pen-up travels are dashed grey. Lets us eyeball the toolpath without a plotter. """ W = cfg.bed_max_x_mm H = cfg.bed_max_y_mm parts = [ f'', # Flip Y so bottom-left origin reads naturally. f'', ] if show_bed: parts.append(f'') prev_end: tuple[float, float] | None = None for s in planned: if not s.points_mm: continue if prev_end is not None: x0, y0 = s.points_mm[0] parts.append( f'' ) d = "M " + " L ".join(f"{x:.2f},{y:.2f}" for (x, y) in s.points_mm) parts.append(f'') prev_end = s.points_mm[-1] parts.append("") return "\n".join(parts) def render_signature_on_pdf( pdf_bytes: bytes, vector: dict, anchors: list[dict], *, signer_field: str | None = None, ) -> bytes: """Stamp the VECTOR strokes onto the PDF at the anchor box(es). This is the digital twin of what the pen will draw — same geometry path as the plotter — so a visual diff against the real plotted sheet (or just an eyeball of this PDF) proves the toolpath lands on the cert line. Returns a flattened signed PDF. """ from pypdf import PdfReader, PdfWriter from reportlab.pdfgen import canvas as rl_canvas reader = PdfReader(io.BytesIO(pdf_bytes)) writer = PdfWriter() # Group anchors by page. by_page: dict[int, list[dict]] = {} for a in anchors: if signer_field and a.get("field") != signer_field: continue by_page.setdefault(int(a.get("page", 0)), []).append(a) for i, page in enumerate(reader.pages): page_anchors = by_page.get(i) if page_anchors: mb = page.mediabox pw = float(mb.width) ph = float(mb.height) buf = io.BytesIO() c = rl_canvas.Canvas(buf, pagesize=(pw, ph)) c.setStrokeColorRGB(0.06, 0.14, 0.25) c.setLineWidth(1.3) c.setLineCap(1) c.setLineJoin(1) for box in page_anchors: # Scale anchor from authored page size to this page size. sx = pw / (box.get("page_w") or pw) sy = ph / (box.get("page_h") or ph) scaled = { "x": box["x"] * sx, "y": box["y"] * sy, "w": box["w"] * sx, "h": box["h"] * sy, } for stroke in fit_strokes_to_box(vector, scaled): if len(stroke) < 2: continue path = c.beginPath() path.moveTo(*stroke[0]) for pt in stroke[1:]: path.lineTo(*pt) c.drawPath(path, stroke=1, fill=0) c.save() buf.seek(0) overlay = PdfReader(buf) page.merge_page(overlay.pages[0]) writer.add_page(page) out = io.BytesIO() writer.write(out) return out.getvalue() if __name__ == "__main__": # local demo: synth a signature, plan, preview, gcode import json # Synthetic cursive-ish signature (a few strokes), normalized 0..1. def wave(n, x0, x1, y0, amp, ph): return [{"x": x0 + (x1 - x0) * k / (n - 1), "y": y0 + amp * math.sin(2 * math.pi * (k / (n - 1) * 2) + ph), "t": k * 8} for k in range(n)] vector = {"v": 1, "w": 600, "h": 160, "strokes": [ wave(40, 0.08, 0.45, 0.55, 0.18, 0.0), wave(40, 0.50, 0.92, 0.55, 0.15, 1.2), ]} box = {"field": "signer", "page": 4, "x": 64.0, "y": 471.0, "w": 402.0, "h": 19.0, "page_w": 612.0, "page_h": 792.0} cfg = PlotterConfig() planned = plan_signature(vector, box, cfg) print(f"planned strokes: {len(planned)}; points: {sum(len(s.points_mm) for s in planned)}") with open("/tmp/ink_preview.svg", "w") as f: f.write(render_preview_svg(planned, cfg)) gcode = emit_gcode(planned, cfg) with open("/tmp/ink_signature.gcode", "w") as f: f.write(gcode) print("wrote /tmp/ink_preview.svg and /tmp/ink_signature.gcode") print("first 12 gcode lines:") print("\n".join(gcode.splitlines()[:12]))