#!/usr/bin/env python3 """ notepat keymap slide — built entirely from native HP-GL primitives. Emits: - notepat-slide.hpgl (sendable to the 7585B) - notepat-slide.svg (preview, written to the user's Desktop) Primitives used (all stock HP-GL, no extensions): IN, SP, VS, FS initialise / pen / speed / force PA, PU, PD absolute moves, pen down/up EA edge rectangle absolute (key outlines) RA fill rectangle absolute (black piano keys, highlights) CI circle (chip dots) LT line type (dashed connectors) SI, DI character size / direction LB, DT label / label terminator The plotter unit is 0.025 mm. Coordinate origin is the centre of the plotter's hard-clip area (the 7585B reports P1/P2 centred). Layout zones (plotter units, ±X / ±Y): title 4000 .. 3300 piano 3100 .. 1500 connectors 1400 .. 900 qwerty 800 .. -2200 tagline -2400 .. -2900 chips -3100 .. -3800 """ from __future__ import annotations import argparse import os import sys import time import webbrowser from pathlib import Path import serial XON, XOFF = 0x11, 0x13 # --------------------------------------------------------------------------- # Live limits query # --------------------------------------------------------------------------- def _ask(ser: serial.Serial, cmd: bytes) -> str: ser.reset_input_buffer() ser.write(cmd) ser.flush() deadline = time.time() + 1.0 buf = bytearray() while time.time() < deadline: c = ser.read(64) if c: for b in c: if b not in (XON, XOFF): buf.append(b) if b"\n" in buf or b"\r" in buf: break else: time.sleep(0.05) return buf.decode("ascii", errors="replace").strip() def _ints(s: str) -> list[int]: out = [] for tok in s.replace(",", " ").split(): try: out.append(int(tok)) except ValueError: pass return out def query_limits(port: str, baud: int) -> tuple[tuple[int, int, int, int], tuple[int, int]]: ser = serial.Serial( port=port, baudrate=baud, bytesize=serial.EIGHTBITS, parity=serial.PARITY_NONE, stopbits=serial.STOPBITS_ONE, xonxoff=False, rtscts=True, dsrdtr=True, timeout=0.5, ) ser.setDTR(True) ser.setRTS(True) try: pp = _ints(_ask(ser, b"OP;")) of = _ints(_ask(ser, b"OF;")) finally: ser.close() if len(pp) != 4 or len(of) < 2: raise SystemExit("plotter did not respond to OP;/OF;") return (pp[0], pp[1], pp[2], pp[3]), (of[0], of[1]) # --------------------------------------------------------------------------- # Composer that emits HPGL + SVG simultaneously # --------------------------------------------------------------------------- class Composer: """Each primitive call appends both an HP-GL string and an SVG element.""" def __init__(self, p1p2: tuple[int, int, int, int], factor: tuple[int, int]): self.p1p2 = p1p2 self.fx, self.fy = factor # Explicit *printable* label terminator. Our 7585B firmware doesn't # accept the optional suppress flag on DT (E2 = wrong # of params), # so we use plain `DT~;` and accept that '~' draws as a small visible # mark after each label. Better visible than runaway labels. self.hpgl: list[str] = ["IN;DT~;DI1,0;LT;SP1;VS12;FS3;"] self.svg: list[str] = [] self._line_type: int | None = None # current LT setting (None = solid) x1, y1, x2, y2 = p1p2 self.w_units = x2 - x1 self.h_units = y2 - y1 # SVG viewBox in mm (drawing space). Origin at center, y-up like HPGL. self.svg_w_mm = self.w_units / self.fx self.svg_h_mm = self.h_units / self.fy # --- coord helpers --- def _to_svg_xy(self, x: int, y: int) -> tuple[float, float]: # plotter is y-up at center; SVG is y-down at top-left. sx = (x - self.p1p2[0]) / self.fx sy = (self.p1p2[3] - y) / self.fy return sx, sy def set_line_type(self, lt: int | None) -> None: if lt == self._line_type: return if lt is None: self.hpgl.append("LT;") else: # Pattern length in percent of P1-P2 diagonal. 0.3% ≈ 1.4mm # on this sheet — very fine tics. self.hpgl.append(f"LT{lt},0.3;") self._line_type = lt # --- primitives --- def edge_rect(self, x1: int, y1: int, x2: int, y2: int) -> None: self.set_line_type(None) self.hpgl.append(f"PA{x1},{y1};EA{x2},{y2};") sx1, sy1 = self._to_svg_xy(x1, y1) sx2, sy2 = self._to_svg_xy(x2, y2) self.svg.append( f'' ) def double_rect(self, x1: int, y1: int, x2: int, y2: int, inset: int = 80) -> None: """Outer + inner offset rect — for highlighting without filling.""" self.edge_rect(x1, y1, x2, y2) self.edge_rect(x1 + inset, y1 + inset, x2 - inset, y2 - inset) # ---- richer HP-GL primitives ---- def set_fill(self, fill_type: int, spacing: int = 0, angle_deg: int = 0) -> None: """FT n[,sp,a]; — set the fill pattern used by FP / RA / WG. type 1 = solid, 2 = parallel hatch, 3 = cross-hatch, 4 = dots. spacing is in plotter units; angle is degrees.""" if spacing > 0: self.hpgl.append(f"FT{fill_type},{spacing},{angle_deg};") else: self.hpgl.append(f"FT{fill_type};") def hatched_rect( self, x1: int, y1: int, x2: int, y2: int, spacing: int = 80, angle: int = 45 ) -> None: """Cross-hatched rectangle — outline + hatched fill via FT3 + RA.""" self.set_line_type(None) # FT 3 = cross-hatch, then RA fills, then EA edges. self.hpgl.append( f"FT3,{spacing},{angle};" f"PA{x1},{y1};RA{x2},{y2};EA{x2},{y2};FT;" ) sx1, sy1 = self._to_svg_xy(x1, y1) sx2, sy2 = self._to_svg_xy(x2, y2) self.svg.append( f'' f'' f'' f'' f'' ) def edge_polygon(self, points: list[tuple[int, int]]) -> None: """EP — outline a polygon defined via PM.""" if len(points) < 3: return self.set_line_type(None) cmd = [f"PA{points[0][0]},{points[0][1]};PM0;"] for x, y in points[1:]: cmd.append(f"PA{x},{y};") cmd.append("PM2;EP;") self.hpgl.append("".join(cmd)) svg_pts = " ".join( f"{self._to_svg_xy(x, y)[0]:.3f},{self._to_svg_xy(x, y)[1]:.3f}" for x, y in points ) self.svg.append( f'' ) def fill_polygon( self, points: list[tuple[int, int]], fill_type: int = 3, spacing: int = 80, angle: int = 45, ) -> None: """FP — fill a polygon defined via PM, with FT controlling pattern.""" if len(points) < 3: return self.set_line_type(None) cmd = [ f"FT{fill_type},{spacing},{angle};", f"PA{points[0][0]},{points[0][1]};PM0;", ] for x, y in points[1:]: cmd.append(f"PA{x},{y};") cmd.append("PM2;FP;EP;FT;") self.hpgl.append("".join(cmd)) svg_pts = " ".join( f"{self._to_svg_xy(x, y)[0]:.3f},{self._to_svg_xy(x, y)[1]:.3f}" for x, y in points ) self.svg.append( f'' ) def edge_wedge( self, cx: int, cy: int, r: int, start_deg: float, sweep_deg: float ) -> None: """EW r,start,sweep; — outline a pie-slice. Centre is current pen pos.""" self.set_line_type(None) self.hpgl.append(f"PA{cx},{cy};EW{r},{start_deg:.2f},{sweep_deg:.2f};") sx, sy = self._to_svg_xy(cx, cy) rx = r / self.fx # SVG arc path import math a1 = math.radians(start_deg) a2 = math.radians(start_deg + sweep_deg) x1s = sx + rx * math.cos(a1) y1s = sy - rx * math.sin(a1) x2s = sx + rx * math.cos(a2) y2s = sy - rx * math.sin(a2) large = 1 if abs(sweep_deg) > 180 else 0 sweep = 0 if sweep_deg > 0 else 1 # SVG arcs Y-down self.svg.append( f'' ) def set_slant(self, slant: float) -> None: """SL tan(angle); — character slant. 0 = upright; ~0.4 = italic-ish.""" self.hpgl.append(f"SL{slant:.3f};") def svg_polylines( self, svg_path: str, cx: int, cy: int, target_size: int, bezier_samples: int = 6, ) -> None: """Load an SVG file, flatten every into polylines (sampling beziers), scale to fit `target_size` plotter units in the longer axis, centre at (cx, cy), and emit as point-to-point PA/PD strokes. """ import svgelements svg = svgelements.SVG.parse(svg_path) polylines: list[list[tuple[float, float]]] = [] current: list[tuple[float, float]] = [] def flush() -> None: nonlocal current if len(current) >= 2: polylines.append(current) current = [] for el in svg.elements(): if not isinstance(el, svgelements.Path): continue for seg in el: if isinstance(seg, svgelements.Move): flush() current = [(seg.end.x, seg.end.y)] elif isinstance(seg, svgelements.Line): current.append((seg.end.x, seg.end.y)) elif isinstance(seg, (svgelements.CubicBezier, svgelements.QuadraticBezier, svgelements.Arc)): # Faceted approximation — one straight line per curve segment. # No bezier sampling: just draw start→end. Cleaner, smaller, # and the polygonal look is intentional for the plot aesthetic. current.append((seg.end.x, seg.end.y)) elif isinstance(seg, svgelements.Close): if current: current.append(current[0]) flush() flush() if not polylines: return # bounding box, scale, centre xs = [x for pl in polylines for x, _ in pl] ys = [y for pl in polylines for _, y in pl] sw = max(xs) - min(xs) sh = max(ys) - min(ys) scale = target_size / max(sw, sh) scx = (min(xs) + max(xs)) / 2 scy = (min(ys) + max(ys)) / 2 def xform(p: tuple[float, float]) -> tuple[int, int]: x, y = p px = cx + (x - scx) * scale py = cy - (y - scy) * scale # flip Y for HPGL (y-up) return int(round(px)), int(round(py)) # de-duplicate consecutive points within ~1 plotter unit for pl in polylines: tpts: list[tuple[int, int]] = [] for p in pl: tp = xform(p) if tpts and abs(tp[0] - tpts[-1][0]) + abs(tp[1] - tpts[-1][1]) < 2: continue tpts.append(tp) if len(tpts) < 2: continue self.set_line_type(None) cmd = [f"PU;PA{tpts[0][0]},{tpts[0][1]};PD;"] for x, y in tpts[1:]: cmd.append(f"PA{x},{y};") cmd.append("PU;") self.hpgl.append("".join(cmd)) svg_pts = " ".join( f"{self._to_svg_xy(x, y)[0]:.3f},{self._to_svg_xy(x, y)[1]:.3f}" for x, y in tpts ) self.svg.append( f'' ) def line(self, x1: int, y1: int, x2: int, y2: int, dashed: bool = False) -> None: self.set_line_type(2 if dashed else None) self.hpgl.append(f"PU;PA{x1},{y1};PD;PA{x2},{y2};PU;") sx1, sy1 = self._to_svg_xy(x1, y1) sx2, sy2 = self._to_svg_xy(x2, y2) dasharray = ' stroke-dasharray="2 2"' if dashed else "" self.svg.append( f'' ) def circle(self, cx: int, cy: int, r: int, fill: bool = False) -> None: self.set_line_type(None) # never fill — just an outline. self.hpgl.append(f"PA{cx},{cy};CI{r};") sx, sy = self._to_svg_xy(cx, cy) rx = r / self.fx self.svg.append( f'' ) def label( self, cx: int, cy: int, text: str, char_w_cm: float, anchor: str = "middle", ) -> None: """Draw a horizontal label centered (anchor=middle) or left (start) at (cx, cy). cy is the baseline. Auto-shrinks the character size if the label would otherwise start beyond the plot's hard-clip limit.""" self.set_line_type(None) spacing = 1.5 char_h_cm = char_w_cm * 1.5 # plot bounds in plotter units (P1..P2 with a small inner margin) x1 = int(self.p1p2[0] * 0.99) x2 = int(self.p1p2[2] * 0.99) avail = x2 - x1 text_w = int(len(text) * char_w_cm * spacing * 10 * self.fx) if text_w > avail: scale = avail / text_w char_w_cm *= scale # Floor: the 7585B refuses SI values below ~0.5 cm with E3. char_w_cm = max(char_w_cm, 0.5) char_h_cm = char_w_cm * 1.5 text_w = int(len(text) * char_w_cm * spacing * 10 * self.fx) if anchor == "middle": x = cx - text_w // 2 elif anchor == "end": x = cx - text_w else: x = cx # clamp to plot area so PA can never go off-limit x = max(x1, min(x, x2 - text_w)) # Strip any '~' (our terminator) or '\x03' from label content. safe = text.replace("~", "").replace("\x03", "") self.hpgl.append( f"SI{char_w_cm:.3f},{char_h_cm:.3f};" f"PA{x},{cy};LB{safe}~" ) sx, sy = self._to_svg_xy(cx, cy) font_px = char_h_cm * 10 # in mm self.svg.append( f'{_xml_escape(text)}' ) def end(self) -> None: self.hpgl.append("LT;PU;PA0,0;SP0;") # --- output --- def hpgl_bytes(self) -> bytes: return ("\n".join(self.hpgl) + "\n").encode("ascii") def svg_doc(self) -> str: body = "\n ".join(self.svg) return ( f'\n' f'\n' f' \n' f' {body}\n' f'\n' ) def _xml_escape(s: str) -> str: return ( s.replace("&", "&") .replace("<", "<") .replace(">", ">") .replace('"', """) ) # --------------------------------------------------------------------------- # Slide composition # --------------------------------------------------------------------------- NATURALS = list("CDEFGABCDEFGAB") # 14 white keys = 2 octaves # black-key positions: True if a sharp sits *above* this natural's right edge. HAS_SHARP_RIGHT = [True, True, False, True, True, True, False] * 2 # C# D# (skip E) F# G# A# (skip B) def compose_slide( p1p2: tuple[int, int, int, int], factor: tuple[int, int], title: str = "notepat.com keymap", tagline: str = "C D E F G A B play the notes they name", ) -> Composer: c = Composer(p1p2, factor) x1, y1, x2, y2 = p1p2 # ---- title (with a touch of slant for character) ---- c.set_slant(0.20) # ~11° forward slant c.label(0, 3500, title, char_w_cm=1.6) c.set_slant(0.0) # back to upright for the rest # divider under header c.line(int(x1 * 0.94), 3050, int(x2 * 0.94), 3050) # ---- PALS logo in top-right corner — real SVG flattened to polylines ---- logo_cx = int(x2 * 0.88) logo_cy = 3450 pals_svg = "/Users/jas/aesthetic-computer/bills/invoices/pals.svg" c.svg_polylines(pals_svg, logo_cx, logo_cy, target_size=1000, bezier_samples=5) # ---- piano (2 octaves) ---- n_white = 14 white_w = 900 white_h = 1300 piano_top = 2500 piano_bot = piano_top - white_h # = 1200 piano_x_start = -(n_white * white_w) // 2 black_w = 540 black_h = 800 # remember each white key's geometric centre for connector use white_centers: dict[str, tuple[int, int]] = {} piano_mid_y = (piano_top + piano_bot) // 2 octave = 0 for i, note in enumerate(NATURALS): kx1 = piano_x_start + i * white_w kx2 = kx1 + white_w c.edge_rect(kx1, piano_bot, kx2, piano_top) # natural label inside, lower part — bigger so it's actually legible c.label((kx1 + kx2) // 2, piano_bot + 170, note, char_w_cm=0.55) # tag this key if note == "C" and i > 0: octave = 1 white_centers[f"{note}{octave}"] = ((kx1 + kx2) // 2, piano_mid_y) # "black" keys — cross-hatched (FT3) at 80-unit spacing / 45°, so they # read as the sharps without scribbling a solid block of ink. for i, has in enumerate(HAS_SHARP_RIGHT): if not has or i >= n_white - 1: continue kx1 = piano_x_start + i * white_w + white_w - black_w // 2 kx2 = kx1 + black_w c.hatched_rect(kx1, piano_top - black_h, kx2, piano_top, spacing=80, angle=45) # ---- dashed connectors from C-D-E-F-G (lower octave) to QWERTY ---- qwerty_top = 800 qwerty_bot = -2200 # First-octave naturals and their QWERTY home keys note_to_qwerty = { "C": ("C", -1), # row, with index in row "D": ("D", -1), "E": ("E", -1), "F": ("F", -1), "G": ("G", -1), } # ---- QWERTY ---- rows = [ ("0", list("QWERTYUIOP")), ("1", list("ASDFGHJKL'")), ("2", list("ZXCVBNM,./")), ] cap_w = 1300 cap_h = 900 gap = 100 slot = cap_w + gap row_offsets = [0, 350, 800] row_y_top = [qwerty_top, qwerty_top - cap_h - 150, qwerty_top - 2 * (cap_h + 150)] qwerty_centers: dict[str, tuple[int, int]] = {} notes_set = set("CDEFGAB") for r_idx, (rname, keys) in enumerate(rows): n = len(keys) row_w = n * slot - gap row_x_start = -row_w // 2 + row_offsets[r_idx] ytop = row_y_top[r_idx] ybot = ytop - cap_h for i, k in enumerate(keys): kx1 = row_x_start + i * slot kx2 = kx1 + cap_w if k in notes_set: # highlight via double-stroked outline — no fill. c.double_rect(kx1, ybot, kx2, ytop, inset=90) else: c.edge_rect(kx1, ybot, kx2, ytop) cap_cx = (kx1 + kx2) // 2 cap_cy = (ytop + ybot) // 2 c.label(cap_cx, ybot + cap_h // 3, k, char_w_cm=0.85) qwerty_centers[k] = (cap_cx, cap_cy) # ---- connectors: centre-of-piano-key → centre-of-qwerty-cap ---- for note in "CDEFG": if note in qwerty_centers and f"{note}0" in white_centers: px, py = white_centers[f"{note}0"] qx, qy = qwerty_centers[note] c.line(px, py, qx, qy, dashed=True) # ---- tagline ---- c.label(0, -2700, tagline, char_w_cm=0.7) # ---- URL row — short enough to fit at min SI 0.5 ---- c.label(0, -3300, "notepat lives in:", char_w_cm=0.5) c.label(0, -3700, "notepat.com prompt.ac/menuband aesthetic.computer", char_w_cm=0.55) c.end() return c # --------------------------------------------------------------------------- # CLI # --------------------------------------------------------------------------- def main() -> int: p = argparse.ArgumentParser() p.add_argument("--port", default=os.environ.get("HP7585B_TTY", "")) p.add_argument("--baud", type=int, default=9600) p.add_argument("--no-query", action="store_true", help="skip plotter query, use last-known ANSI B P1/P2") p.add_argument("--out-hpgl", default=str(Path(__file__).parent / "notepat-slide.hpgl")) p.add_argument("--out-svg", default=str(Path.home() / "Desktop" / "notepat-slide-preview.svg")) p.add_argument("--open", action="store_true", help="open the SVG preview in the default app") args = p.parse_args() if args.no_query or not args.port: # last-known ANSI B landscape sheet from earlier OH;/OP; p1p2 = (-8050, -4574, 8050, 4574) factor = (40, 40) sys.stderr.write(f"using cached limits {p1p2} factor={factor}\n") else: p1p2, factor = query_limits(args.port, args.baud) sys.stderr.write(f"queried limits {p1p2} factor={factor}\n") comp = compose_slide(p1p2, factor) Path(args.out_hpgl).write_bytes(comp.hpgl_bytes()) Path(args.out_svg).write_text(comp.svg_doc()) sys.stderr.write( f"wrote HPGL: {args.out_hpgl} ({len(comp.hpgl_bytes())} B)\n" f"wrote SVG: {args.out_svg}\n" ) if args.open: webbrowser.open(f"file://{args.out_svg}") return 0 if __name__ == "__main__": sys.exit(main())