pyvterm 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
pyvterm/__init__.py ADDED
@@ -0,0 +1,66 @@
1
+ """pyvterm — drive a pitrex/Vectrex over a serial port from Python.
2
+
3
+ pyvterm speaks the **USB-DVG / *vecterm* serial protocol** used by the
4
+ `gtoal/pitrex <https://github.com/gtoal/pitrex>`_ project: the same wire
5
+ format a custom MAME build uses to push vector frames to a Vectrex. With it
6
+ you can act as the "custom MAME" and draw vectors on real hardware from
7
+ Python.
8
+
9
+ Quick start
10
+ -----------
11
+ >>> from pyvterm import VectorTerminal
12
+ >>> with VectorTerminal(port="/dev/ttyACM0") as vt: # doctest: +SKIP
13
+ ... with vt.frame():
14
+ ... vt.set_intensity(15)
15
+ ... vt.polyline([(0, 0), (100, 100), (-100, 100)], closed=True)
16
+
17
+ For tests and dry runs, pass a :class:`MemoryTransport` instead of a port.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from . import geometry, protocol
23
+ from .frame import FrameBuilder
24
+ from .geometry import clip_line, vector_length
25
+ from .protocol import (
26
+ DEFAULT_BOUNDS,
27
+ DVG_RENDER_QUALITY,
28
+ DVG_RES_MAX,
29
+ DVG_RES_MIN,
30
+ Bounds,
31
+ Flag,
32
+ )
33
+ from .terminal import VectorTerminal
34
+ from .transport import (
35
+ DEFAULT_BAUDRATE,
36
+ DEFAULT_PORT,
37
+ MemoryTransport,
38
+ SerialTransport,
39
+ Transport,
40
+ )
41
+
42
+ __version__ = "0.1.0"
43
+
44
+ __all__ = [
45
+ "__version__",
46
+ # high level
47
+ "VectorTerminal",
48
+ "FrameBuilder",
49
+ # transports
50
+ "Transport",
51
+ "SerialTransport",
52
+ "MemoryTransport",
53
+ "DEFAULT_PORT",
54
+ "DEFAULT_BAUDRATE",
55
+ # protocol + geometry
56
+ "protocol",
57
+ "geometry",
58
+ "Flag",
59
+ "Bounds",
60
+ "DEFAULT_BOUNDS",
61
+ "DVG_RES_MIN",
62
+ "DVG_RES_MAX",
63
+ "DVG_RENDER_QUALITY",
64
+ "clip_line",
65
+ "vector_length",
66
+ ]
pyvterm/frame.py ADDED
@@ -0,0 +1,160 @@
1
+ """Stateful frame assembly.
2
+
3
+ :class:`FrameBuilder` accumulates colour and vector commands and serialises a
4
+ complete frame to bytes. It is **transport-agnostic and pure** — it never
5
+ touches a serial port — so the exact wire output can be asserted in tests.
6
+
7
+ The emitted frame layout matches ``zvgFrame.c``'s ``serial_send``::
8
+
9
+ [FRAME | total_vector_length] <- header, written first
10
+ [RGB ...] [XY ...] [XY ...] ... <- body (colours and vectors)
11
+ [QUALITY | value]
12
+ [COMPLETE]
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from . import protocol
18
+ from .geometry import clip_line, vector_length
19
+ from .protocol import DEFAULT_BOUNDS, DVG_RENDER_QUALITY, DVG_RES_MAX, DVG_RES_MIN, Bounds
20
+
21
+ __all__ = ["FrameBuilder"]
22
+
23
+ Window = tuple[int, int, int, int]
24
+
25
+
26
+ def _clamp(value: int, low: int, high: int) -> int:
27
+ return max(low, min(high, value))
28
+
29
+
30
+ class FrameBuilder:
31
+ """Accumulate a single frame of vectors and serialise it to bytes.
32
+
33
+ Parameters
34
+ ----------
35
+ bounds:
36
+ Host coordinate space mapped onto the device grid.
37
+ clip_window:
38
+ ``(x_min, y_min, x_max, y_max)`` clip rectangle in host coordinates.
39
+ Defaults to the full ``bounds`` (the C code requires the caller to set
40
+ this; we default it so drawing works out of the box).
41
+ quality:
42
+ Value sent in the per-frame ``QUALITY`` command.
43
+ monochrome:
44
+ Set the monochrome bit in the ``COMPLETE`` marker.
45
+ """
46
+
47
+ def __init__(
48
+ self,
49
+ bounds: Bounds = DEFAULT_BOUNDS,
50
+ clip_window: Window | None = None,
51
+ quality: int = DVG_RENDER_QUALITY,
52
+ monochrome: bool = False,
53
+ ) -> None:
54
+ self.bounds = bounds
55
+ self.quality = quality
56
+ self.monochrome = monochrome
57
+ if clip_window is None:
58
+ clip_window = (bounds.x_min, bounds.y_min, bounds.x_max, bounds.y_max)
59
+ self.clip_window: Window = clip_window
60
+ self.reset()
61
+
62
+ def reset(self) -> None:
63
+ """Discard all accumulated commands and start a fresh frame."""
64
+ self._body = bytearray()
65
+ self._vector_length = 0
66
+ self._last: tuple[int, int] | None = None # last device-space endpoint
67
+ self._last_black = True # colour starts at (0, 0, 0)
68
+ self._count = 0
69
+
70
+ # -- properties --------------------------------------------------------
71
+
72
+ @property
73
+ def vector_count(self) -> int:
74
+ """Number of vectors accepted into the current frame."""
75
+ return self._count
76
+
77
+ @property
78
+ def total_length(self) -> int:
79
+ """Accumulated beam-travel length (sent in the FRAME header)."""
80
+ return self._vector_length
81
+
82
+ def __len__(self) -> int:
83
+ return self._count
84
+
85
+ # -- drawing -----------------------------------------------------------
86
+
87
+ def set_clip_window(self, x_min: int, y_min: int, x_max: int, y_max: int) -> None:
88
+ """Set the clip rectangle in host coordinates (``zvgFrameSetClipWin``)."""
89
+ self.clip_window = (x_min, y_min, x_max, y_max)
90
+
91
+ def set_rgb(self, r: int, g: int, b: int) -> None:
92
+ """Set the colour of subsequent vectors from ~4-bit channels.
93
+
94
+ Mirrors ``zvgFrameSetRGB15``: each channel is scaled ``<< 4`` and
95
+ clamped to 255. A colour of ``(0, 0, 0)`` blanks subsequent draws.
96
+ """
97
+ r8 = protocol.scale_color(r)
98
+ g8 = protocol.scale_color(g)
99
+ b8 = protocol.scale_color(b)
100
+ self._last_black = r8 == 0 and g8 == 0 and b8 == 0
101
+ self._body += protocol.rgb(r8, g8, b8)
102
+
103
+ def vector(self, x0: float, y0: float, x1: float, y1: float) -> bool:
104
+ """Add a vector from ``(x0, y0)`` to ``(x1, y1)`` in host coordinates.
105
+
106
+ The line is clipped to the clip window; returns ``False`` (emitting
107
+ nothing) when it lies entirely off-screen. A beam-off reposition is
108
+ inserted automatically when the start does not continue the previous
109
+ vector, so connected polylines cost only one extra move overall.
110
+ """
111
+ clipped = clip_line(x0, y0, x1, y1, self.clip_window)
112
+ if clipped is None:
113
+ return False
114
+ cx0, cy0, cx1, cy1 = clipped
115
+
116
+ start = (
117
+ _clamp(self.bounds.conv_x(cx0), DVG_RES_MIN, DVG_RES_MAX),
118
+ _clamp(self.bounds.conv_y(cy0), DVG_RES_MIN, DVG_RES_MAX),
119
+ )
120
+ end = (
121
+ _clamp(self.bounds.conv_x(cx1), DVG_RES_MIN, DVG_RES_MAX),
122
+ _clamp(self.bounds.conv_y(cy1), DVG_RES_MIN, DVG_RES_MAX),
123
+ )
124
+
125
+ if self._last is not None:
126
+ self._vector_length += vector_length(self._last[0], self._last[1], *start)
127
+ self._vector_length += vector_length(start[0], start[1], end[0], end[1])
128
+
129
+ if self._last != start:
130
+ # Reposition the beam (always off) to the start of this vector.
131
+ self._body += protocol.xy(start[0], start[1], blank=True)
132
+ self._body += protocol.xy(end[0], end[1], blank=self._last_black)
133
+
134
+ self._last = end
135
+ self._count += 1
136
+ return True
137
+
138
+ def polyline(self, points: list[tuple[float, float]], closed: bool = False) -> int:
139
+ """Draw a connected sequence of points; returns the vectors emitted."""
140
+ emitted = 0
141
+ for (x0, y0), (x1, y1) in zip(points, points[1:]):
142
+ if self.vector(x0, y0, x1, y1):
143
+ emitted += 1
144
+ if closed and len(points) > 2:
145
+ x0, y0 = points[-1]
146
+ x1, y1 = points[0]
147
+ if self.vector(x0, y0, x1, y1):
148
+ emitted += 1
149
+ return emitted
150
+
151
+ # -- serialisation -----------------------------------------------------
152
+
153
+ def to_bytes(self) -> bytes:
154
+ """Serialise the accumulated frame (header + body + quality + complete)."""
155
+ out = bytearray()
156
+ out += protocol.frame_header(self._vector_length)
157
+ out += self._body
158
+ out += protocol.quality(self.quality)
159
+ out += protocol.complete(self.monochrome)
160
+ return bytes(out)
pyvterm/geometry.py ADDED
@@ -0,0 +1,81 @@
1
+ """Geometry helpers used by the frame builder.
2
+
3
+ Pure functions only — no hardware or I/O. ``clip_line`` is a direct port of
4
+ the Cohen-Sutherland clipper in ``zvgFrame.c`` (some games emit coordinates
5
+ outside the view window, so vectors must be clipped before transmission).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import math
11
+
12
+ __all__ = ["INSIDE", "LEFT", "RIGHT", "BOTTOM", "TOP", "clip_line", "vector_length"]
13
+
14
+ # Region codes (matching zvgFrame.c).
15
+ INSIDE = 0
16
+ LEFT = 1
17
+ RIGHT = 2
18
+ BOTTOM = 4
19
+ TOP = 8
20
+
21
+ Window = tuple[float, float, float, float] # (x_min, y_min, x_max, y_max)
22
+ Point = tuple[float, float]
23
+ Segment = tuple[float, float, float, float]
24
+
25
+
26
+ def _region_code(x: float, y: float, window: Window) -> int:
27
+ x_min, y_min, x_max, y_max = window
28
+ code = INSIDE
29
+ if x < x_min:
30
+ code |= LEFT
31
+ elif x > x_max:
32
+ code |= RIGHT
33
+ if y < y_min:
34
+ code |= BOTTOM
35
+ elif y > y_max:
36
+ code |= TOP
37
+ return code
38
+
39
+
40
+ def clip_line(x1: float, y1: float, x2: float, y2: float, window: Window) -> Segment | None:
41
+ """Cohen-Sutherland line clip.
42
+
43
+ Returns the clipped ``(x1, y1, x2, y2)`` segment, or ``None`` if the line
44
+ lies entirely outside ``window`` (``(x_min, y_min, x_max, y_max)``).
45
+ """
46
+ x_min, y_min, x_max, y_max = window
47
+ code1 = _region_code(x1, y1, window)
48
+ code2 = _region_code(x2, y2, window)
49
+
50
+ while True:
51
+ if code1 == 0 and code2 == 0:
52
+ return (x1, y1, x2, y2)
53
+ if code1 & code2:
54
+ return None
55
+
56
+ code_out = code1 or code2
57
+ x = y = 0.0
58
+ if code_out & TOP:
59
+ x = x1 + (x2 - x1) * (y_max - y1) / (y2 - y1)
60
+ y = y_max
61
+ elif code_out & BOTTOM:
62
+ x = x1 + (x2 - x1) * (y_min - y1) / (y2 - y1)
63
+ y = y_min
64
+ elif code_out & RIGHT:
65
+ y = y1 + (y2 - y1) * (x_max - x1) / (x2 - x1)
66
+ x = x_max
67
+ elif code_out & LEFT:
68
+ y = y1 + (y2 - y1) * (x_min - x1) / (x2 - x1)
69
+ x = x_min
70
+
71
+ if code_out == code1:
72
+ x1, y1 = x, y
73
+ code1 = _region_code(x1, y1, window)
74
+ else:
75
+ x2, y2 = x, y
76
+ code2 = _region_code(x2, y2, window)
77
+
78
+
79
+ def vector_length(x0: float, y0: float, x1: float, y1: float) -> int:
80
+ """Integer Euclidean length of a segment (``vector_length`` in C)."""
81
+ return int(math.hypot(x1 - x0, y1 - y0))
pyvterm/preview.py ADDED
@@ -0,0 +1,149 @@
1
+ """Render pyvterm frames as a glowing vector display — previews without hardware.
2
+
3
+ Optional module: needs the ``preview`` extra (``pip install "pyvterm[preview]"``,
4
+ i.e. numpy + Pillow). It is **not** imported by ``pyvterm`` itself, so the core
5
+ package stays dependency-light.
6
+
7
+ Frames are decoded from the *actual wire bytes* back into beam segments, so a
8
+ preview shows exactly what the device would draw. Capture frames with a
9
+ :class:`PreviewTransport`, then save an animated PNG::
10
+
11
+ from pyvterm import VectorTerminal
12
+ from pyvterm.preview import PreviewTransport
13
+
14
+ preview = PreviewTransport(width=440, height=330)
15
+ vt = VectorTerminal(transport=preview)
16
+ for ...:
17
+ with vt.frame():
18
+ vt.polyline(points)
19
+ preview.save_apng("out.png", fps=25)
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from collections.abc import Sequence
25
+
26
+ from . import protocol
27
+ from .protocol import DVG_RES_MAX, Flag
28
+ from .transport import Transport
29
+
30
+ try:
31
+ import numpy as np
32
+ from PIL import Image, ImageDraw, ImageFilter
33
+ except ImportError as exc: # pragma: no cover - exercised only without the extra
34
+ raise ImportError(
35
+ "pyvterm.preview needs the 'preview' extra: pip install 'pyvterm[preview]'"
36
+ ) from exc
37
+
38
+ __all__ = ["Segment", "PHOSPHOR", "decode_segments", "rasterize", "save_apng", "PreviewTransport"]
39
+
40
+ #: A lit beam segment in device space: ``(x0, y0, x1, y1, intensity)``.
41
+ Segment = tuple[int, int, int, int, int]
42
+ Color = tuple[float, float, float]
43
+
44
+ #: Default phosphor tint (green-cyan glow with white-hot cores).
45
+ PHOSPHOR: Color = (0.18, 1.0, 0.55)
46
+
47
+
48
+ def decode_segments(frame: bytes) -> list[Segment]:
49
+ """Decode a pyvterm frame into the lit segments the beam would draw.
50
+
51
+ Coordinates are device space (``0..DVG_RES_MAX``). Blanked moves reposition
52
+ the pen without producing a segment; a segment is emitted for each lit draw.
53
+ """
54
+ segments: list[Segment] = []
55
+ pen_x = pen_y = 0
56
+ intensity = 0
57
+ for offset in range(0, len(frame) - 3, 4):
58
+ info = protocol.decode_word(int.from_bytes(frame[offset : offset + 4], "big"))
59
+ flag = info["flag"]
60
+ if flag is Flag.RGB:
61
+ intensity = max(info["r"], info["g"], info["b"])
62
+ elif flag is Flag.XY:
63
+ x, y = info["x"], info["y"]
64
+ if not info["blank"] and intensity > 0:
65
+ segments.append((pen_x, pen_y, x, y, intensity))
66
+ pen_x, pen_y = x, y
67
+ return segments
68
+
69
+
70
+ def rasterize(
71
+ segments: Sequence[Segment], width: int, height: int, color: Color = PHOSPHOR
72
+ ) -> Image.Image:
73
+ """Rasterize device-space ``segments`` into a glowing RGB frame."""
74
+ core = Image.new("L", (width, height), 0)
75
+ draw = ImageDraw.Draw(core)
76
+ sx = (width - 1) / DVG_RES_MAX
77
+ sy = (height - 1) / DVG_RES_MAX
78
+ for x0, y0, x1, y1, intensity in segments:
79
+ value = 90 + int(165 * intensity / 255)
80
+ # Device Y grows upward, image Y downward -> flip.
81
+ draw.line((x0 * sx, (height - 1) - y0 * sy, x1 * sx, (height - 1) - y1 * sy), fill=value)
82
+
83
+ base = np.asarray(core, dtype=np.float32) / 255.0
84
+ glow1 = np.asarray(core.filter(ImageFilter.GaussianBlur(2.0)), dtype=np.float32) / 255.0
85
+ glow2 = np.asarray(core.filter(ImageFilter.GaussianBlur(5.0)), dtype=np.float32) / 255.0
86
+ intensity_map = np.clip(base + 0.8 * glow1 + 0.5 * glow2 - 0.05, 0.0, 1.5)
87
+ hot = np.clip((base - 0.45) * 2.2, 0.0, 1.0) # white-hot line cores
88
+
89
+ r, g, b = color
90
+ rgb = np.zeros((height, width, 3), dtype=np.float32)
91
+ rgb[..., 0] = r * intensity_map + 0.9 * hot
92
+ rgb[..., 1] = g * intensity_map + 0.5 * hot
93
+ rgb[..., 2] = b * intensity_map + 0.9 * hot
94
+ out = (np.clip(rgb, 0.0, 1.0) * 255).astype(np.uint8)
95
+ return Image.fromarray(out, mode="RGB")
96
+
97
+
98
+ def save_apng(images: Sequence[Image.Image], path: str, fps: float = 25.0) -> int:
99
+ """Write ``images`` as an animated PNG; returns the number of frames."""
100
+ if not images:
101
+ raise ValueError("no frames to save")
102
+ duration = int(1000 / fps) if fps > 0 else 50
103
+ images[0].save(
104
+ path,
105
+ save_all=True,
106
+ append_images=list(images[1:]),
107
+ duration=duration,
108
+ loop=0,
109
+ format="PNG",
110
+ )
111
+ return len(images)
112
+
113
+
114
+ class PreviewTransport(Transport):
115
+ """A :class:`~pyvterm.transport.Transport` that captures frames as images.
116
+
117
+ Drop it into a :class:`~pyvterm.VectorTerminal` in place of a serial port;
118
+ each ``send_frame`` is captured, and :meth:`save_apng` writes the animation.
119
+ """
120
+
121
+ def __init__(self, width: int = 480, height: int = 360, color: Color = PHOSPHOR) -> None:
122
+ self.width = width
123
+ self.height = height
124
+ self.color = color
125
+ self.frames: list[bytes] = []
126
+
127
+ def write(self, data: bytes) -> int:
128
+ self.frames.append(bytes(data))
129
+ return len(data)
130
+
131
+ def _frame_blobs(self) -> list[bytes]:
132
+ # Keep only real frames (skip the trailing EXIT-only write from close()).
133
+ return [
134
+ blob
135
+ for blob in self.frames
136
+ if len(blob) >= 4
137
+ and (int.from_bytes(blob[:4], "big") >> protocol.FLAG_SHIFT) == Flag.FRAME
138
+ ]
139
+
140
+ def images(self) -> list[Image.Image]:
141
+ """Rasterize every captured frame to a list of images."""
142
+ return [
143
+ rasterize(decode_segments(blob), self.width, self.height, self.color)
144
+ for blob in self._frame_blobs()
145
+ ]
146
+
147
+ def save_apng(self, path: str, fps: float = 25.0) -> int:
148
+ """Render captured frames and write them to ``path`` as an animated PNG."""
149
+ return save_apng(self.images(), path, fps=fps)
pyvterm/protocol.py ADDED
@@ -0,0 +1,236 @@
1
+ """Wire-level encoding for the USB-DVG / pitrex *vecterm* serial protocol.
2
+
3
+ This module is **pure**: it has no I/O dependencies and can be imported and
4
+ unit-tested without a serial port or any hardware. It mirrors the command
5
+ encoding in `gtoal/pitrex`'s ``VMMenu/Win32/dvg/zvgFrame.c`` (the USB-DVG
6
+ drivers written by Mario Montminy, 2020) and cross-checks against the
7
+ canonical AdvanceMAME ``advance/osd/dvg.c`` implementation.
8
+
9
+ Every command is a single 32-bit word transmitted **big-endian** (most
10
+ significant byte first). The most significant three bits ``[31:29]`` select
11
+ the command::
12
+
13
+ bit 31 29 28 0
14
+ +---------+------------------------------------------------+
15
+ | flag | payload |
16
+ +---------+------------------------------------------------+
17
+
18
+ See ``docs/PROTOCOL.md`` for the full specification.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import struct
24
+ from dataclasses import dataclass
25
+ from enum import IntEnum
26
+ from typing import Any
27
+
28
+ __all__ = [
29
+ "Flag",
30
+ "Bounds",
31
+ "DEFAULT_BOUNDS",
32
+ "FLAG_SHIFT",
33
+ "BLANK_SHIFT",
34
+ "COORD_BITS",
35
+ "COORD_MASK",
36
+ "PAYLOAD_MASK",
37
+ "DVG_RES_MIN",
38
+ "DVG_RES_MAX",
39
+ "DVG_RENDER_QUALITY",
40
+ "COMPLETE_MONOCHROME",
41
+ "pack_word",
42
+ "scale_color",
43
+ "encode_rgb_word",
44
+ "encode_xy_word",
45
+ "encode_frame_word",
46
+ "encode_quality_word",
47
+ "encode_complete_word",
48
+ "encode_exit_word",
49
+ "decode_word",
50
+ "rgb",
51
+ "rgb_scaled",
52
+ "xy",
53
+ "frame_header",
54
+ "quality",
55
+ "complete",
56
+ "exit_command",
57
+ ]
58
+
59
+
60
+ class Flag(IntEnum):
61
+ """Command selector occupying the top three bits of every word."""
62
+
63
+ COMPLETE = 0x0 #: End-of-frame marker (payload 0, or the monochrome bit).
64
+ RGB = 0x1 #: Set the colour/intensity of subsequent vectors.
65
+ XY = 0x2 #: Move (beam off) or draw (beam on) to a coordinate.
66
+ QUALITY = 0x3 #: Render-quality hint (pitrex/zvgFrame variant).
67
+ FRAME = 0x4 #: Frame header carrying total beam-travel length.
68
+ CMD = 0x5 #: Device command channel (AdvanceMAME; e.g. GET_DVG_INFO).
69
+ EXIT = 0x7 #: Tell the device the session is over.
70
+
71
+
72
+ # --- Bit layout -----------------------------------------------------------
73
+
74
+ FLAG_SHIFT = 29 #: The flag occupies bits [31:29].
75
+ BLANK_SHIFT = 28 #: The XY blank (beam-off) bit.
76
+ COORD_BITS = 14 #: Each XY coordinate is 14 bits wide.
77
+ COORD_MASK = (1 << COORD_BITS) - 1 #: 0x3FFF.
78
+ PAYLOAD_MASK = (1 << FLAG_SHIFT) - 1 #: 0x1FFFFFFF — bits available below the flag.
79
+
80
+ # --- Device resolution ----------------------------------------------------
81
+
82
+ DVG_RES_MIN = 0 #: Minimum DVG coordinate.
83
+ DVG_RES_MAX = 4095 #: Maximum DVG coordinate (12-bit DAC range).
84
+ DVG_RENDER_QUALITY = 5 #: Default quality value sent once per frame.
85
+
86
+ #: OR'd into a ``COMPLETE`` word to flag a black & white game (AdvanceMAME).
87
+ COMPLETE_MONOCHROME = 1 << 28
88
+
89
+
90
+ @dataclass(frozen=True)
91
+ class Bounds:
92
+ """The host coordinate space, mapped onto the device's ``0..4095`` grid.
93
+
94
+ The defaults match ``zvgFrame.h`` (a 1024x768 space centred on the
95
+ origin), which is the standard MAME vector resolution.
96
+ """
97
+
98
+ x_min: int = -512
99
+ x_max: int = 511
100
+ y_min: int = -384
101
+ y_max: int = 383
102
+
103
+ @property
104
+ def width(self) -> int:
105
+ return self.x_max - self.x_min
106
+
107
+ @property
108
+ def height(self) -> int:
109
+ return self.y_max - self.y_min
110
+
111
+ def conv_x(self, x: float) -> int:
112
+ """Map a host X coordinate onto ``0..DVG_RES_MAX`` (``CONVX`` in C)."""
113
+ return int(((x - self.x_min) * DVG_RES_MAX) // self.width)
114
+
115
+ def conv_y(self, y: float) -> int:
116
+ """Map a host Y coordinate onto ``0..DVG_RES_MAX`` (``CONVY`` in C)."""
117
+ return int(((y - self.y_min) * DVG_RES_MAX) // self.height)
118
+
119
+
120
+ DEFAULT_BOUNDS = Bounds()
121
+
122
+
123
+ # --- Word encoders --------------------------------------------------------
124
+
125
+
126
+ def pack_word(word: int) -> bytes:
127
+ """Serialise a 32-bit command ``word`` as 4 big-endian bytes."""
128
+ return struct.pack(">I", word & 0xFFFFFFFF)
129
+
130
+
131
+ def scale_color(value: int) -> int:
132
+ """Scale a ~4-bit colour channel to 8 bits, clamped to 255.
133
+
134
+ Matches ``zvgFrameSetRGB15``: the input is shifted left by 4 (so ``15``
135
+ maps to ``240``) and clamped, so values ``>= 16`` saturate at ``255``.
136
+ """
137
+ scaled = value << 4
138
+ return 255 if scaled > 255 else scaled
139
+
140
+
141
+ def encode_rgb_word(r: int, g: int, b: int) -> int:
142
+ """Encode a raw 8-bit-per-channel ``RGB`` word."""
143
+ return (Flag.RGB << FLAG_SHIFT) | ((r & 0xFF) << 16) | ((g & 0xFF) << 8) | (b & 0xFF)
144
+
145
+
146
+ def encode_xy_word(x: int, y: int, blank: bool) -> int:
147
+ """Encode an ``XY`` word.
148
+
149
+ ``blank`` selects beam-off (a move) when ``True`` and beam-on (a draw)
150
+ when ``False``.
151
+ """
152
+ return (
153
+ (Flag.XY << FLAG_SHIFT)
154
+ | ((1 if blank else 0) << BLANK_SHIFT)
155
+ | ((x & COORD_MASK) << COORD_BITS)
156
+ | (y & COORD_MASK)
157
+ )
158
+
159
+
160
+ def encode_frame_word(vector_length: int) -> int:
161
+ """Encode the ``FRAME`` header carrying total beam-travel length."""
162
+ return (Flag.FRAME << FLAG_SHIFT) | (vector_length & PAYLOAD_MASK)
163
+
164
+
165
+ def encode_quality_word(value: int = DVG_RENDER_QUALITY) -> int:
166
+ """Encode the ``QUALITY`` render hint."""
167
+ return (Flag.QUALITY << FLAG_SHIFT) | (value & PAYLOAD_MASK)
168
+
169
+
170
+ def encode_complete_word(monochrome: bool = False) -> int:
171
+ """Encode the ``COMPLETE`` end-of-frame marker."""
172
+ return (Flag.COMPLETE << FLAG_SHIFT) | (COMPLETE_MONOCHROME if monochrome else 0)
173
+
174
+
175
+ def encode_exit_word() -> int:
176
+ """Encode the ``EXIT`` (session over) command."""
177
+ return Flag.EXIT << FLAG_SHIFT
178
+
179
+
180
+ def decode_word(word: int) -> dict[str, Any]:
181
+ """Decode a 32-bit word into a human-readable ``dict`` (for tests/debug)."""
182
+ flag = Flag((word >> FLAG_SHIFT) & 0x7)
183
+ info: dict[str, Any] = {"flag": flag}
184
+ if flag is Flag.RGB:
185
+ info.update(r=(word >> 16) & 0xFF, g=(word >> 8) & 0xFF, b=word & 0xFF)
186
+ elif flag is Flag.XY:
187
+ info.update(
188
+ blank=bool((word >> BLANK_SHIFT) & 0x1),
189
+ x=(word >> COORD_BITS) & COORD_MASK,
190
+ y=word & COORD_MASK,
191
+ )
192
+ elif flag is Flag.FRAME:
193
+ info["vector_length"] = word & PAYLOAD_MASK
194
+ elif flag is Flag.QUALITY:
195
+ info["value"] = word & PAYLOAD_MASK
196
+ elif flag is Flag.COMPLETE:
197
+ info["monochrome"] = bool(word & COMPLETE_MONOCHROME)
198
+ return info
199
+
200
+
201
+ # --- Byte-producing convenience wrappers ----------------------------------
202
+
203
+
204
+ def rgb(r: int, g: int, b: int) -> bytes:
205
+ """4 bytes setting a raw 8-bit-per-channel colour."""
206
+ return pack_word(encode_rgb_word(r, g, b))
207
+
208
+
209
+ def rgb_scaled(r: int, g: int, b: int) -> bytes:
210
+ """4 bytes setting a colour from ~4-bit channels (``zvgFrameSetRGB15``)."""
211
+ return pack_word(encode_rgb_word(scale_color(r), scale_color(g), scale_color(b)))
212
+
213
+
214
+ def xy(x: int, y: int, blank: bool) -> bytes:
215
+ """4 bytes moving (``blank=True``) or drawing (``blank=False``) to ``x,y``."""
216
+ return pack_word(encode_xy_word(x, y, blank))
217
+
218
+
219
+ def frame_header(vector_length: int) -> bytes:
220
+ """4 bytes for the frame header."""
221
+ return pack_word(encode_frame_word(vector_length))
222
+
223
+
224
+ def quality(value: int = DVG_RENDER_QUALITY) -> bytes:
225
+ """4 bytes for the quality hint."""
226
+ return pack_word(encode_quality_word(value))
227
+
228
+
229
+ def complete(monochrome: bool = False) -> bytes:
230
+ """4 bytes for the end-of-frame marker."""
231
+ return pack_word(encode_complete_word(monochrome))
232
+
233
+
234
+ def exit_command() -> bytes:
235
+ """4 bytes telling the device the session is over."""
236
+ return pack_word(encode_exit_word())
pyvterm/py.typed ADDED
File without changes