# Copyright (c) 2023, Manfred Moitzi # License: MIT License from __future__ import annotations from typing import Iterator, Iterable import string from .deps import Vec2, NULLVEC2 from .properties import RGB from .plotter import Plotter from .tokenizer import Command, pe_decode class Interpreter: """The :class:`Interpreter` is the frontend for the :class:`Plotter` class. The :meth:`run` methods interprets the low level HPGL commands from the :func:`hpgl2_commands` parser and sends the commands to the virtual plotter device, which sends his output to a low level :class:`Backend` class. Most CAD application send a very restricted subset of commands to plotters, mostly just polylines and filled polygons. Implementing the whole HPGL/2 command set is not worth the effort - unless reality proofs otherwise. Not implemented commands: - the whole character group - text is send as filled polygons or polylines - configuration group: IN, DF, RO, IW - the plotter is initialized by creating a new plotter and page rotation is handled by the add-on itself - polygon group: EA, ER, EW, FA, RR, WG, the rectangle and wedge commands - line and fill attributes group: LA, RF, SM, SV, TR, UL, WU, linetypes and hatch patterns are decomposed into simple lines by CAD applications Args: plotter: virtual :class:`Plotter` device """ def __init__(self, plotter: Plotter) -> None: self.errors: list[str] = [] self.not_implemented_commands: set[str] = set() self._disabled_commands: set[str] = set() self.plotter = plotter def add_error(self, error: str) -> None: self.errors.append(error) def run(self, commands: list[Command]) -> None: """Interprets the low level HPGL commands from the :func:`hpgl2_commands` parser and sends the commands to the virtual plotter device. """ for name, args in commands: if name in self._disabled_commands: continue method = getattr(self, f"cmd_{name.lower()}", None) if method: method(args) elif name[0] in string.ascii_letters: self.not_implemented_commands.add(name) def disable_commands(self, commands: Iterable[str]) -> None: """Disable commands manually, like the scaling command ["SC", "IP", "IR"]. This is a feature for experts, because disabling commands which changes the pen location may distort or destroy the plotter output. """ self._disabled_commands.update(commands) # Configure pens, line types, fill types def cmd_ft(self, args: list[bytes]): """Set fill type.""" fill_type = 1 spacing = 0.0 angle = 0.0 values = tuple(to_floats(args)) arg_count = len(values) if arg_count > 0: fill_type = int(values[0]) if arg_count > 1: spacing = values[1] if arg_count > 2: angle = values[2] self.plotter.set_fill_type(fill_type, spacing, angle) def cmd_pc(self, args: list[bytes]): """Set pen color as RGB tuple.""" values = list(to_ints(args)) if len(values) == 4: index, r, g, b = values self.plotter.set_pen_color(index, RGB(r, g, b)) else: self.add_error("invalid arguments for PC command") def cmd_pw(self, args: list[bytes]): """Set pen width.""" arg_count = len(args) if arg_count: width = to_float(args[0], 0.35) else: self.add_error("invalid arguments for PW command") return index = -1 if arg_count > 1: index = to_int(args[1], index) self.plotter.set_pen_width(index, width) def cmd_sp(self, args: list[bytes]): """Select pen.""" if len(args): self.plotter.set_current_pen(to_int(args[0], 1)) def cmd_np(self, args: list[bytes]): """Set number of pens.""" if len(args): self.plotter.set_max_pen_count(to_int(args[0], 2)) def cmd_ip(self, args: list[bytes]): """Set input points p1 and p2 absolute.""" if len(args) == 0: self.plotter.reset_scaling() return points = to_points(to_floats(args)) if len(points) > 1: self.plotter.set_scaling_points(points[0], points[1]) else: self.add_error("invalid arguments for IP command") def cmd_ir(self, args: list[bytes]): """Set input points p1 and p2 in percentage of page size.""" if len(args) == 0: self.plotter.reset_scaling() return values = list(to_floats(args)) if len(values) == 2: xp1 = clamp(values[0], 0.0, 100.0) yp1 = clamp(values[1], 0.0, 100.0) self.plotter.set_scaling_points_relative_1(xp1 / 100.0, yp1 / 100.0) elif len(values) == 4: xp1 = clamp(values[0], 0.0, 100.0) yp1 = clamp(values[1], 0.0, 100.0) xp2 = clamp(values[2], 0.0, 100.0) yp2 = clamp(values[3], 0.0, 100.0) self.plotter.set_scaling_points_relative_2( xp1 / 100.0, yp1 / 100.0, xp2 / 100.0, yp2 / 100.0 ) else: self.add_error("invalid arguments for IP command") def cmd_sc(self, args: list[bytes]): if len(args) == 0: self.plotter.reset_scaling() return values = list(to_floats(args)) if len(values) < 4: self.add_error("invalid arguments for SC command") return scaling_type = 0 if len(values) > 4: scaling_type = int(values[4]) if scaling_type == 1: # isotropic left = 50.0 if len(values) > 5: left = clamp(values[5], 0.0, 100.0) bottom = 50.0 if len(values) > 6: bottom = clamp(values[6], 0.0, 100.0) self.plotter.set_isotropic_scaling( values[0], values[1], values[2], values[3], left, bottom, ) elif scaling_type == 2: # point factor self.plotter.set_point_factor( Vec2(values[0], values[2]), values[1], values[3] ) else: # anisotropic self.plotter.set_anisotropic_scaling( values[0], values[1], values[2], values[3] ) def cmd_mc(self, args: list[bytes]): status = 0 if len(args): status = to_int(args[0], status) self.plotter.set_merge_control(bool(status)) def cmd_ps(self, args: list[bytes]): length = 1189 # A0 height = 841 count = len(args) if count: length = to_int(args[0], length) height = int(length * 1.5) if count > 1: height = to_int(args[1], height) self.plotter.setup_page(length, height) # pen movement: def cmd_pd(self, args: list[bytes]): """Lower pen down and plot lines.""" self.plotter.pen_down() if len(args): self.plotter.plot_polyline(to_points(to_floats(args))) def cmd_pu(self, args: list[bytes]): """Lift pen up and move pen.""" self.plotter.pen_up() if len(args): self.plotter.plot_polyline(to_points(to_floats(args))) def cmd_pa(self, args: list[bytes]): """Place pen absolute. Plots polylines if pen is down.""" self.plotter.set_absolute_mode() if len(args): self.plotter.plot_polyline(to_points(to_floats(args))) def cmd_pr(self, args: list[bytes]): """Place pen relative.Plots polylines if pen is down.""" self.plotter.set_relative_mode() if len(args): self.plotter.plot_polyline(to_points(to_floats(args))) # plot commands: def cmd_ci(self, args: list[bytes]): """Plot full circle.""" arg_count = len(args) if not arg_count: self.add_error("invalid arguments for CI command") return self.plotter.push_pen_state() # implicit pen down! self.plotter.pen_down() radius = to_float(args[0], 1.0) chord_angle = 5.0 if arg_count > 1: chord_angle = to_float(args[1], chord_angle) self.plotter.plot_abs_circle(radius, chord_angle) self.plotter.pop_pen_state() def cmd_aa(self, args: list[bytes]): """Plot arc absolute.""" if len(args) < 3: self.add_error("invalid arguments for AR command") return self._arc_out(args, self.plotter.plot_abs_arc) def cmd_ar(self, args: list[bytes]): """Plot arc relative.""" if len(args) < 3: self.add_error("invalid arguments for AR command") return self._arc_out(args, self.plotter.plot_rel_arc) @staticmethod def _arc_out(args: list[bytes], output_method): """Plot arc""" arg_count = len(args) if arg_count < 3: return x = to_float(args[0]) y = to_float(args[1]) sweep_angle = to_float(args[2]) chord_angle = 5.0 if arg_count > 3: chord_angle = to_float(args[3], chord_angle) output_method(Vec2(x, y), sweep_angle, chord_angle) def cmd_at(self, args: list[bytes]): """Plot arc absolute from three points.""" if len(args) < 4: self.add_error("invalid arguments for AT command") return self._arc_3p_out(args, self.plotter.plot_abs_arc_three_points) def cmd_rt(self, args: list[bytes]): """Plot arc relative from three points.""" if len(args) < 4: self.add_error("invalid arguments for RT command") return self._arc_3p_out(args, self.plotter.plot_rel_arc_three_points) @staticmethod def _arc_3p_out(args: list[bytes], output_method): """Plot arc from three points""" arg_count = len(args) if arg_count < 4: return points = to_points(to_floats(args)) if len(points) < 2: return chord_angle = 5.0 if arg_count > 4: chord_angle = to_float(args[4], chord_angle) try: output_method(points[0], points[1], chord_angle) except ZeroDivisionError: pass def cmd_bz(self, args: list[bytes]): """Plot cubic Bezier curves with absolute user coordinates.""" self._bezier_out(args, self.plotter.plot_abs_cubic_bezier) def cmd_br(self, args: list[bytes]): """Plot cubic Bezier curves with relative user coordinates.""" self._bezier_out(args, self.plotter.plot_rel_cubic_bezier) @staticmethod def _bezier_out(args: list[bytes], output_method): kind = 0 ctrl1 = NULLVEC2 ctrl2 = NULLVEC2 for point in to_points(to_floats(args)): if kind == 0: ctrl1 = point elif kind == 1: ctrl2 = point elif kind == 2: end = point output_method(ctrl1, ctrl2, end) kind = (kind + 1) % 3 def cmd_pe(self, args: list[bytes]): """Plot Polyline Encoded.""" if len(args): data = args[0] else: self.add_error("invalid arguments for PE command") return plotter = self.plotter # The last pen up/down state remains after leaving the PE command. pen_down = True # Ignores and preserves the current absolute/relative mode of the plotter. absolute = False frac_bin_bits = 0 base = 64 index = 0 length = len(data) point_queue: list[Vec2] = [] while index < length: char = data[index] if char in b":<>=7": index += 1 if char == 58: # ":" - select pen values, index = pe_decode(data, base=base, start=index) plotter.set_current_pen(int(values[0])) if len(values) > 1: point_queue.extend(to_points(values[1:])) elif char == 60: # "<" - pen up and goto coordinates pen_down = False elif char == 62: # ">" - fractional data values, index = pe_decode(data, base=base, start=index) frac_bin_bits = int(values[0]) if len(values) > 1: point_queue.extend(to_points(values[1:])) elif char == 61: # "=" - next coordinates are absolute absolute = True elif char == 55: # "7" - 7-bit mode base = 32 else: values, index = pe_decode( data, frac_bits=frac_bin_bits, base=base, start=index ) point_queue.extend(to_points(values)) if point_queue: plotter.pen_down() if absolute: # next point is absolute: make relative point_queue[0] = point_queue[0] - plotter.user_location if not pen_down: target = point_queue.pop(0) plotter.move_to_rel(target) if not point_queue: # last point in queue plotter.pen_up() if point_queue: plotter.plot_rel_polyline(point_queue) point_queue.clear() pen_down = True absolute = False # polygon mode: def cmd_pm(self, args: list[bytes]) -> None: """Enter/Exit polygon mode.""" status = 0 if len(args): status = to_int(args[0], status) if status == 2: self.plotter.exit_polygon_mode() else: self.plotter.enter_polygon_mode(status) def cmd_fp(self, args: list[bytes]) -> None: """Plot filled polygon.""" fill_method = 0 if len(args): fill_method = one_of(to_int(args[0], fill_method), (0, 1)) self.plotter.fill_polygon(fill_method) def cmd_ep(self, _) -> None: """Plot edged polygon.""" self.plotter.edge_polygon() def to_floats(args: Iterable[bytes]) -> Iterator[float]: for arg in args: try: yield float(arg) except ValueError: pass def to_ints(args: Iterable[bytes]) -> Iterator[int]: for arg in args: try: yield int(arg) except ValueError: pass def to_points(values: Iterable[float]) -> list[Vec2]: points: list[Vec2] = [] append_point = False buffer: float = 0.0 for value in values: if append_point: points.append(Vec2(buffer, value)) append_point = False else: buffer = value append_point = True return points def to_float(s: bytes, default=0.0) -> float: try: return float(s) except ValueError: return default def to_int(s: bytes, default=0) -> int: try: return int(s) except ValueError: return default def clamp(v, v_min, v_max): return max(min(v_max, v), v_min) def one_of(value, choice): if value in choice: return value return choice[0]