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.
This commit is contained in:
justin 2026-06-07 03:45:46 -05:00
parent aafa76df83
commit 894d989445
4 changed files with 595 additions and 19 deletions

View file

@ -74,6 +74,59 @@ uses) places the ink exactly on the cert line.
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
@ -103,10 +156,14 @@ python scripts/workers/ink_signature_cli.py --order CO-XXXX --doc cms10114 \
## Verification (no hardware required)
`scripts/tests/test_ink_signature.py` (30 checks) proves the geometry:
`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)

View file

@ -119,6 +119,53 @@ def main():
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("profiles: cr10 / axidraw / lineus")
cr10 = ink.load_profile("cr10")
axi = ink.load_profile("axidraw")
lu = ink.load_profile("lineus")
check("cr10 is marlin Z-pen", cr10.dialect == "marlin" and not cr10.servo_pen)
check("axidraw is marlin servo + A4 bed", axi.dialect == "marlin" and axi.servo_pen and axi.bed_max_y_mm == 297.0)
check("lineus dialect + has LineUsConfig", lu.dialect == "lineus" and lu.lineus is not None)
raised = False
try:
ink.load_profile("nope")
except ValueError:
raised = True
check("unknown profile raises", raised)
print("Line-us reach check + jig solve")
# With the default jig (0,0), the CMS box (PDF ~48,443 pt) maps far outside
# the small arm's reach -> reach check should fail.
lu_planned_raw = ink.plan_signature(vector, BOX, lu)
rep_raw = ink.check_reach(lu_planned_raw, lu)
check("raw lineus plot is out of reach", rep_raw["dialect"] == "lineus" and not rep_raw["ok"])
# Solve the jig: should bring the whole cell into reach.
solved = ink.compute_jig_offset_for_box(BOX, lu, vector=vector)
check("solve-jig reports ok", solved["ok"] is True)
check("solve-jig returns jig offsets", "jig_x_mm" in solved and "jig_y_mm" in solved)
from dataclasses import replace as _replace
lu_fixed = _replace(lu, jig_x_mm=solved["jig_x_mm"], jig_y_mm=solved["jig_y_mm"])
lu_planned = ink.plan_signature(vector, BOX, lu_fixed)
rep_fixed = ink.check_reach(lu_planned, lu_fixed)
check("after solve-jig the signature is in reach", rep_fixed["ok"] is True)
print("Line-us G-code emission")
glu = ink.emit_gcode(lu_planned, lu_fixed)
check("lineus homes the arm", "G28" in glu)
check("lineus encodes pen height as Z in G01", "G01 X" in glu and f"Z{lu.lineus.pen_up_z:d}" in glu)
check("lineus uses pen-down Z", f"Z{lu.lineus.pen_down_z:d}" in glu)
check("lineus has no Marlin mm header", "G21" not in glu)
check("in-reach lineus plot has no reach warning", "fall outside the arm reach" not in glu)
# Out-of-reach plot should carry the warning comment.
glu_bad = ink.emit_gcode(lu_planned_raw, lu)
check("out-of-reach lineus plot warns", "fall outside the arm reach" in glu_bad)
print("Line-us sender is dry-run safe")
res_tcp = ink.send_lineus(glu, dry_run=True)
check("lineus dry-run does not send (tcp)", res_tcp["sent"] is False and res_tcp["transport"] == "tcp")
res_usb = ink.send_lineus(glu, serial_port="/dev/ttyACM0", dry_run=True)
check("lineus dry-run does not send (usb)", res_usb["sent"] is False and res_usb["transport"] == "serial")
print("preview SVG")
svg = ink.render_preview_svg(planned, cfg)
check("svg has path(s)", "<path" in svg)

View file

@ -90,16 +90,23 @@ def main():
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("--profile", default="cr10",
help="machine profile: cr10 | axidraw | lineus (default cr10)")
ap.add_argument("--port", default="/dev/ttyUSB0", help="USB serial port (marlin or lineus-USB)")
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("--lineus-host", default="line-us.local", help="Line-us WiFi host (TCP 1337)")
ap.add_argument("--lineus-usb", action="store_true", help="send Line-us over USB serial (--port) instead of WiFi")
ap.add_argument("--jig-x", type=float, default=None, help="override jig X offset mm")
ap.add_argument("--jig-y", type=float, default=None, help="override jig Y offset mm")
ap.add_argument("--pen-down", type=float, default=None, help="override pen-down Z mm")
ap.add_argument("--pen-up", type=float, default=None, help="override pen-up Z mm")
ap.add_argument("--servo-pen", action="store_true", help="force M280 servo pen (marlin profiles)")
ap.add_argument("--solve-jig", action="store_true",
help="for small machines (lineus): compute + apply the jig offset that "
"brings the signature cell into reach, then proceed")
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")
ap.add_argument("--home", action="store_true", help="prepend G28 home before plotting (marlin)")
args = ap.parse_args()
rec = _load_record(args.order, args.doc)
@ -108,11 +115,34 @@ def main():
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,
)
# Start from the named profile, then apply any explicit overrides.
try:
cfg = ink.load_profile(args.profile)
except ValueError as exc:
sys.exit(str(exc))
overrides = {}
if args.jig_x is not None: overrides["jig_x_mm"] = args.jig_x
if args.jig_y is not None: overrides["jig_y_mm"] = args.jig_y
if args.pen_down is not None: overrides["pen_down_mm"] = args.pen_down
if args.pen_up is not None: overrides["pen_up_mm"] = args.pen_up
if args.servo_pen: overrides["servo_pen"] = True
if overrides:
from dataclasses import replace
cfg = replace(cfg, **overrides)
# Small-machine jig solve: position the signature into the arm's reach.
# Load the captured signature vector up-front (needed for solve-jig too).
vector = _coerce_json(rec.get("signature_vector"))
if args.solve_jig:
solved = ink.compute_jig_offset_for_box(box, cfg, vector=vector)
print(f"[solve-jig] {json.dumps(solved)}")
if not solved.get("ok"):
sys.exit("solve-jig: signature does not fit the machine's reach — "
"use a larger machine or a smaller signature cell.")
from dataclasses import replace
cfg = replace(cfg, jig_x_mm=solved["jig_x_mm"], jig_y_mm=solved["jig_y_mm"])
print(f"[solve-jig] applied jig offset: x={cfg.jig_x_mm}mm y={cfg.jig_y_mm}mm")
out_dir = Path(args.out_dir)
out_dir.mkdir(parents=True, exist_ok=True)
@ -128,7 +158,6 @@ def main():
(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.")
@ -144,11 +173,31 @@ def main():
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)}")
# Reach check (gates plotting on small machines, informational on large).
reach = ink.check_reach(planned, cfg)
print(f"reach: {json.dumps(reach)}")
if not reach["ok"]:
print("WARNING: some points are out of the machine's reach. "
"For lineus, re-run with --solve-jig (or adjust the jig).")
if args.plot and cfg.dialect == "lineus":
sys.exit("Refusing to plot out-of-reach signature on Line-us; fix the jig first.")
if cfg.dialect == "lineus":
result = ink.send_lineus(
gcode,
host=args.lineus_host,
serial_port=(args.port if args.lineus_usb else None),
baud=args.baud,
dry_run=not args.plot,
)
print(f"lineus: {json.dumps(result)}")
else:
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.")

View file

@ -49,7 +49,21 @@ class PlotterConfig:
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
@ -75,6 +89,41 @@ class PlotterConfig:
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
@ -83,6 +132,57 @@ class PlannedStroke:
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]:
@ -183,9 +283,188 @@ def plan_signature(vector: dict, box: dict, cfg: PlotterConfig) -> list[PlannedS
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
@ -237,6 +516,54 @@ def emit_gcode(planned: list[PlannedStroke], cfg: PlotterConfig) -> str:
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).
@ -310,6 +637,102 @@ def send_gcode_serial(
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(