diff --git a/boxes/dxf_generator.py b/boxes/dxf_generator.py new file mode 100644 index 000000000..0be51881d --- /dev/null +++ b/boxes/dxf_generator.py @@ -0,0 +1,778 @@ +from __future__ import annotations + +import io +import logging +import math +from typing import Any, Literal, TypedDict, Union, cast +from collections.abc import Iterable, MutableMapping, Sequence + +import ezdxf +from ezdxf import units +from ezdxf.math import Vec3 +from .drawing import Surface + +Command = Sequence[Any] + +__all__ = ["EZDXFBuilder", "DXFSurface"] + + +_ezdxf_logger = logging.getLogger("ezdxf") +_ezdxf_logger.setLevel(logging.ERROR) +_ezdxf_logger.propagate = False + + +class LineSegment(TypedDict): + type: Literal["line"] + start: Vec3 + end: Vec3 + + +class ArcSegment(TypedDict): + type: Literal["arc"] + center: Vec3 + radius: float + start_angle: float + end_angle: float + orientation: int + start: Vec3 + end: Vec3 + full_circle: bool + + +class CircleSummary(TypedDict): + center: Vec3 + radius: float + + +Segment = Union[LineSegment, ArcSegment] +TextParams = dict[str, Any] +TextSpec = tuple[float, float, str, TextParams] + + +class EZDXFBuilder: + """Render drawing commands into DXF entities using ezdxf.""" + + _POINT_TOL = 1e-6 + _ARC_TOL = 1e-3 + _FULL_CIRCLE_TOL = 1e-2 + + def __init__(self, *, layer: str = "0", lineweight: float | None = None) -> None: + self.doc = ezdxf.new("R2010", setup=True) + self.doc.units = units.MM + header = cast(MutableMapping[str, Any], self.doc.header) + header["$INSUNITS"] = units.MM + header["$MEASUREMENT"] = 1 + self.msp = self.doc.modelspace() + self.layer = layer + lw_value = self._lineweight_to_hundredths(lineweight) if lineweight is not None else None + self.entity_attribs: dict[str, Any] = {"layer": layer} + if lw_value is not None: + self.entity_attribs["lineweight"] = lw_value + + def set_lineweight(self, value: float | None) -> None: + """Update the current entity lineweight in hundredths of millimeters.""" + if value is None or value <= 0.0: + self.entity_attribs.pop("lineweight", None) + return + self.entity_attribs["lineweight"] = self._lineweight_to_hundredths(value) + + def set_extents(self, xmin: float, ymin: float, xmax: float, ymax: float) -> None: + """Write drawing extents to the DXF header.""" + self.doc.header["$EXTMIN"] = (float(xmin), float(ymin), 0.0) + self.doc.header["$EXTMAX"] = (float(xmax), float(ymax), 0.0) + + def to_buffer(self) -> io.BytesIO: + """Return the DXF document encoded into an in-memory buffer.""" + stream = io.StringIO() + self.doc.write(stream) + data = self.doc.encode(stream.getvalue()) + buffer = io.BytesIO(data) + buffer.seek(0) + return buffer + + @staticmethod + def _lineweight_to_hundredths(value: float) -> int: + hundredths = int(round(value * 100.0)) + return min(211, max(0, hundredths)) + + @staticmethod + def _vec(x: float, y: float) -> Vec3: + return Vec3(float(x), float(y), 0.0) + + @staticmethod + def _evaluate_cubic(p0: Vec3, p1: Vec3, p2: Vec3, p3: Vec3, t: float) -> Vec3: + mt = 1.0 - t + return ( + p0 * (mt ** 3) + + p1 * (3.0 * mt * mt * t) + + p2 * (3.0 * mt * t * t) + + p3 * (t ** 3) + ) + + def _approximate_cubic(self, p0: Vec3, p1: Vec3, p2: Vec3, p3: Vec3, steps: int = 16) -> list[Vec3]: + points = [p0] + for step in range(1, steps): + t = step / steps + points.append(self._evaluate_cubic(p0, p1, p2, p3, t)) + points.append(p3) + return points + + @staticmethod + def _line_segment(start: Vec3, end: Vec3) -> LineSegment: + return {"type": "line", "start": start, "end": end} + + def _points_close(self, a: Vec3, b: Vec3, *, tol: float | None = None) -> bool: + threshold = self._POINT_TOL if tol is None else tol + return (a - b).magnitude <= threshold + + def _vectors_collinear( + self, v1: Vec3, v2: Vec3, *, tol: float | None = None, allow_reverse: bool = False + ) -> bool: + threshold = self._POINT_TOL if tol is None else tol + len1 = v1.magnitude + len2 = v2.magnitude + if len1 <= threshold or len2 <= threshold: + return True + cross = abs(v1.x * v2.y - v1.y * v2.x) + max_len = max(len1, len2, 1e-9) + if cross > threshold * max_len: + return False + norm = len1 * len2 + if norm <= threshold * threshold: + return True + cosine = v1.dot(v2) / norm + angle_tol = min(1e-6, threshold / max_len) + if allow_reverse: + return abs(cosine) >= 1.0 - angle_tol + return cosine >= -angle_tol + + def _merge_line_run_once(self, run: Sequence[LineSegment]) -> tuple[list[LineSegment], bool]: + if not run: + return [], False + merged: list[LineSegment] = [] + tol = self._POINT_TOL + changed = False + for segment in run: + start = segment["start"] + end = segment["end"] + if (end - start).magnitude <= tol: + changed = True + continue + if merged: + prev = merged[-1] + v_prev = prev["end"] - prev["start"] + v_curr = end - start + tol_vec = max(v_prev.magnitude, v_curr.magnitude, 1.0) * self._POINT_TOL + if self._points_close(prev["end"], start, tol=tol_vec): + if self._vectors_collinear(v_prev, v_curr, tol=tol_vec): + merged[-1] = self._line_segment(prev["start"], end) + changed = True + continue + merged.append(self._line_segment(start, end)) + + if len(merged) <= 1: + return merged, changed + + first = merged[0] + last = merged[-1] + v_first = first["end"] - first["start"] + v_last = last["end"] - last["start"] + tol_vec = max(v_first.magnitude, v_last.magnitude, 1.0) * self._POINT_TOL + share_both = ( + ( + self._points_close(first["start"], last["start"], tol=tol_vec) + and self._points_close(first["end"], last["end"], tol=tol_vec) + ) + or ( + self._points_close(first["start"], last["end"], tol=tol_vec) + and self._points_close(first["end"], last["start"], tol=tol_vec) + ) + ) + if share_both: + merged.pop() + changed = True + return merged, changed + if self._points_close(first["start"], last["end"], tol=tol_vec): + if ( + v_first.magnitude > tol + and v_last.magnitude > tol + and self._vectors_collinear(v_last, v_first, tol=tol_vec, allow_reverse=True) + ): + merged[0] = self._line_segment(last["start"], first["end"]) + merged.pop() + changed = True + elif self._points_close(first["end"], last["start"], tol=tol_vec): + if ( + v_first.magnitude > tol + and v_last.magnitude > tol + and self._vectors_collinear(v_first, v_last, tol=tol_vec, allow_reverse=True) + ): + merged[-1] = self._line_segment(first["start"], last["end"]) + merged.pop(0) + changed = True + return merged, changed + + def _merge_line_run(self, run: Sequence[LineSegment]) -> tuple[list[LineSegment], bool]: + current = list(run) + overall_changed = False + while True: + current, changed = self._merge_line_run_once(current) + overall_changed = overall_changed or changed + if not changed: + break + return current, overall_changed + + def _merge_line_segments_once(self, segments: Sequence[Segment]) -> tuple[list[Segment], bool]: + merged: list[Segment] = [] + line_run: list[LineSegment] = [] + changed = False + + def flush_run() -> None: + nonlocal changed + if not line_run: + return + merged_run, run_changed = self._merge_line_run(line_run) + if run_changed: + changed = True + merged.extend(merged_run) + line_run.clear() + + for segment in segments: + if segment["type"] == "line": + line_run.append(segment) + else: + flush_run() + merged.append(segment) + flush_run() + return merged, changed + + def _merge_line_segments(self, segments: Sequence[Segment]) -> list[Segment]: + if not segments: + return [] + merged, changed = self._merge_line_segments_once(segments) + if len(merged) >= 2 and merged[0]["type"] == "line" and merged[-1]["type"] == "line": + first = merged[0] + last = merged[-1] + v_first = first["end"] - first["start"] + v_last = last["end"] - last["start"] + tol_vec = max(v_first.magnitude, v_last.magnitude, 1.0) * self._POINT_TOL + same_direction = self._points_close(first["start"], last["start"], tol=tol_vec) and self._points_close( + first["end"], last["end"], tol=tol_vec + ) + opposite_direction = self._points_close(first["start"], last["end"], tol=tol_vec) and self._points_close( + first["end"], last["start"], tol=tol_vec + ) + if same_direction or opposite_direction: + merged = list(merged[:-1]) + return self._merge_line_segments(merged) + if self._points_close(first["start"], last["end"], tol=tol_vec) and self._vectors_collinear( + v_last, v_first, tol=tol_vec, allow_reverse=True + ): + replacement_first: Segment = self._line_segment(last["start"], first["end"]) + if not self._points_close(replacement_first["start"], replacement_first["end"], tol=tol_vec): + merged = [replacement_first, *merged[1:-1]] + else: + merged = list(merged[1:-1]) + return self._merge_line_segments(merged) + if self._points_close(first["end"], last["start"], tol=tol_vec) and self._vectors_collinear( + v_first, v_last, tol=tol_vec, allow_reverse=True + ): + replacement_second: Segment = self._line_segment(first["start"], last["end"]) + if not self._points_close(replacement_second["start"], replacement_second["end"], tol=tol_vec): + merged = [replacement_second, *merged[1:-1]] + else: + merged = list(merged[1:-1]) + return self._merge_line_segments(merged) + if not changed: + return merged + return self._merge_line_segments(merged) + + def _merge_arc_segments(self, segments: Sequence[Segment]) -> list[Segment]: + merged: list[Segment] = [] + pending: ArcSegment | None = None + pending_start_angle = 0.0 + sweep_acc = 0.0 + + def start_pending(arc: ArcSegment) -> None: + nonlocal pending, pending_start_angle, sweep_acc + pending = self._arc_segment( + center=arc["center"], + radius=arc["radius"], + start_angle=arc["start_angle"], + end_angle=arc["end_angle"], + orientation=arc["orientation"], + start=arc["start"], + end=arc["end"], + full_circle=arc["full_circle"], + ) + pending_start_angle = arc["start_angle"] + sweep_acc = self._arc_sweep(arc) + + def flush_pending() -> None: + nonlocal pending, sweep_acc, pending_start_angle + if pending is None: + return + tol = max(pending["radius"], 1.0) * self._ARC_TOL + if ( + not pending["full_circle"] + and self._points_close(pending["start"], pending["end"], tol=tol) + and abs(sweep_acc - 2.0 * math.pi) <= self._FULL_CIRCLE_TOL + ): + pending = self._arc_segment( + center=pending["center"], + radius=pending["radius"], + start_angle=pending["start_angle"], + end_angle=pending["end_angle"], + orientation=pending["orientation"], + start=pending["start"], + end=pending["end"], + full_circle=True, + ) + pending_start_angle = 0.0 + merged.append(pending) + pending = None + sweep_acc = 0.0 + + for segment in segments: + if segment["type"] != "arc": + flush_pending() + merged.append(segment) + continue + + arc: ArcSegment = segment + if arc["full_circle"]: + flush_pending() + merged.append( + self._arc_segment( + center=arc["center"], + radius=arc["radius"], + start_angle=arc["start_angle"], + end_angle=arc["end_angle"], + orientation=arc["orientation"], + start=arc["start"], + end=arc["end"], + full_circle=True, + ) + ) + continue + + if pending is None: + start_pending(arc) + continue + + tol = max(pending["radius"], arc["radius"], 1.0) * self._ARC_TOL + centers_close = (arc["center"] - pending["center"]).magnitude <= tol + radii_close = abs(arc["radius"] - pending["radius"]) <= tol + contiguous = self._points_close(pending["end"], arc["start"], tol=tol) + same_orientation = arc["orientation"] == pending["orientation"] + next_sweep = sweep_acc + self._arc_sweep(arc) + + if ( + centers_close + and radii_close + and contiguous + and same_orientation + and next_sweep <= 2.0 * math.pi + self._FULL_CIRCLE_TOL + ): + sweep_acc = next_sweep + orientation = pending["orientation"] + if orientation > 0: + end_angle = pending_start_angle + sweep_acc + else: + end_angle = pending_start_angle - sweep_acc + pending = self._arc_segment( + center=pending["center"], + radius=pending["radius"], + start_angle=pending_start_angle, + end_angle=end_angle, + orientation=orientation, + start=pending["start"], + end=arc["end"], + full_circle=pending["full_circle"] or arc["full_circle"], + ) + continue + + flush_pending() + start_pending(arc) + + flush_pending() + return merged + + @staticmethod + def _arc_segment( + *, + center: Vec3, + radius: float, + start_angle: float, + end_angle: float, + orientation: int, + start: Vec3, + end: Vec3, + full_circle: bool, + ) -> ArcSegment: + return { + "type": "arc", + "center": center, + "radius": radius, + "start_angle": start_angle, + "end_angle": end_angle, + "orientation": orientation, + "start": start, + "end": end, + "full_circle": full_circle, + } + + @staticmethod + def _arc_sweep(segment: ArcSegment) -> float: + if segment["orientation"] > 0: + return segment["end_angle"] - segment["start_angle"] + return segment["start_angle"] - segment["end_angle"] + + @classmethod + def _try_cubic_as_arc(cls, start: Vec3, ctrl1: Vec3, ctrl2: Vec3, end: Vec3) -> ArcSegment | None: + span = (end - start).magnitude + if span <= cls._POINT_TOL: + return None + + t_start = (ctrl1 - start) * 3.0 + t_end = (end - ctrl2) * 3.0 + det = t_start.x * t_end.y - t_start.y * t_end.x + if abs(det) <= 1e-8: + return None + + s_dot = start.x * t_start.x + start.y * t_start.y + e_dot = end.x * t_end.x + end.y * t_end.y + cx = (s_dot * t_end.y - e_dot * t_start.y) / det + cy = (e_dot * t_start.x - s_dot * t_end.x) / det + center = Vec3(cx, cy, 0.0) + + radius_start = (start - center).magnitude + radius_end = (end - center).magnitude + if radius_start <= cls._POINT_TOL: + return None + if abs(radius_start - radius_end) > max(radius_start, 1.0) * 1e-3: + return None + + if abs((start - center).dot(t_start)) > max(radius_start, 1.0) * 1e-3: + return None + if abs((end - center).dot(t_end)) > max(radius_start, 1.0) * 1e-3: + return None + + max_dev = 0.0 + for t in (0.25, 0.5, 0.75): + point = cls._evaluate_cubic(start, ctrl1, ctrl2, end, t) + deviation = abs((point - center).magnitude - radius_start) + max_dev = max(max_dev, deviation) + if max_dev > max(radius_start, 1.0) * cls._ARC_TOL: + return None + + r0 = start - center + r1 = end - center + cross = r0.x * r1.y - r0.y * r1.x + if abs(cross) <= max(radius_start, 1.0) * 1e-6: + return None + orientation = 1 if cross > 0 else -1 + + start_angle = math.atan2(r0.y, r0.x) + end_angle = math.atan2(r1.y, r1.x) + if orientation > 0: + while end_angle <= start_angle: + end_angle += 2.0 * math.pi + else: + while end_angle >= start_angle: + end_angle -= 2.0 * math.pi + + sweep = abs(end_angle - start_angle) + if sweep <= 1e-3: + return None + + full_circle = ( + (end - start).magnitude <= max(radius_start, 1.0) * 1e-3 + and abs(sweep - 2.0 * math.pi) <= cls._FULL_CIRCLE_TOL + ) + return cls._arc_segment( + center=center, + radius=radius_start, + start_angle=start_angle, + end_angle=end_angle, + orientation=orientation, + start=start, + end=end, + full_circle=full_circle, + ) + + def _segments_form_circle(self, segments: list[Segment]) -> CircleSummary | None: + if not segments: + return None + first = segments[0] + if first["type"] != "arc": + return None + first_arc: ArcSegment = first + if len(segments) == 1 and first_arc["full_circle"]: + return {"center": first_arc["center"], "radius": first_arc["radius"]} + center = first_arc["center"] + radius = first_arc["radius"] + orientation = first_arc["orientation"] + tol = max(radius, 1.0) * self._ARC_TOL + sweep = 0.0 + start_point = first_arc["start"] + prev_end = start_point + for segment in segments: + if segment["type"] != "arc": + return None + arc: ArcSegment = segment + if arc["orientation"] != orientation: + return None + if (arc["center"] - center).magnitude > tol: + return None + if abs(arc["radius"] - radius) > tol: + return None + if (arc["start"] - prev_end).magnitude > tol: + return None + sweep += self._arc_sweep(arc) + prev_end = arc["end"] + if (prev_end - start_point).magnitude > tol: + return None + if abs(abs(sweep) - 2.0 * math.pi) > self._FULL_CIRCLE_TOL: + return None + return {"center": center, "radius": radius} + + def _emit_circle(self, center: Vec3, radius: float) -> None: + self.msp.add_circle( + (center.x, center.y), + radius, + dxfattribs=self.entity_attribs, + ) + + def _emit_polyline(self, segments: list[Segment]) -> None: + if not segments: + return + segments = self._merge_line_segments(segments) + vertices: list[list[float]] = [] + tol = self._POINT_TOL + + def add_vertex(point: Vec3, bulge: float) -> None: + if vertices: + last = Vec3(vertices[-1][0], vertices[-1][1], 0.0) + if (point - last).magnitude <= tol: + vertices[-1][2] = bulge + return + vertices.append([point.x, point.y, bulge]) + + for idx, seg in enumerate(segments): + if seg["type"] == "line": + start = seg["start"] + end = seg["end"] + if (end - start).magnitude <= tol: + continue + if not vertices: + add_vertex(start, 0.0) + vertices[-1][2] = 0.0 + add_vertex(end, 0.0) + elif seg["type"] == "arc": + start = seg["start"] + end = seg["end"] + if (end - start).magnitude <= tol and not seg["full_circle"]: + continue + if not vertices: + add_vertex(start, 0.0) + sweep = self._arc_sweep(seg) + bulge = math.tan(abs(sweep) / 4.0) + if seg["orientation"] < 0: + bulge = -bulge + vertices[-1][2] = bulge + add_vertex(end, 0.0) + else: + raise ValueError(f"Unsupported segment type in polyline: {seg['type']}") + + if len(vertices) < 2: + return + start_pt = Vec3(vertices[0][0], vertices[0][1], 0.0) + end_pt = Vec3(vertices[-1][0], vertices[-1][1], 0.0) + is_closed = (end_pt - start_pt).magnitude <= tol + if is_closed: + vertices.pop() # closing flag already handles closing edge + + self.msp.add_lwpolyline( + [tuple(v) for v in vertices], + format="xyb", + close=is_closed, + dxfattribs=self.entity_attribs, + ) + + def _emit_texts(self, texts: list[TextSpec]) -> None: + if not texts: + return + align_map = { + "left": "LEFT", + "middle": "CENTER", + "end": "RIGHT", + } + for x, y, text, params in texts: + if not text: + continue + height = float(params.get("fs", 2.0)) + alignment = align_map.get(params.get("align", "left"), "LEFT") + text_entity = self.msp.add_text( + text, + dxfattribs={ + "height": height, + "layer": self.layer, + }, + ) + text_entity.dxf.insert = (x, y, 0.0) + if alignment != "LEFT": + halign_codes = {"CENTER": 1, "RIGHT": 2} + text_entity.dxf.halign = halign_codes.get(alignment, 0) + text_entity.dxf.align_point = (x, y, 0.0) + + def _flush_block(self, segments: list[Segment], texts: list[TextSpec]) -> None: + if not segments: + self._emit_texts(texts) + return + merged_segments = self._merge_arc_segments(segments) + circle = self._segments_form_circle(merged_segments) + if circle: + self._emit_circle(circle["center"], circle["radius"]) + else: + self._emit_polyline(merged_segments) + self._emit_texts(texts) + + def add_commands(self, commands: Iterable[Command]) -> None: + current_point: Vec3 | None = None + segments: list[Segment] = [] + texts: list[TextSpec] = [] + + def flush(): + self._flush_block(segments, texts) + segments.clear() + texts.clear() + + for cmd in commands: + if not cmd: + continue + letter = str(cmd[0]) + if letter == "M": + flush() + current_point = self._vec(float(cmd[1]), float(cmd[2])) + elif letter == "L": + if current_point is None: + raise ValueError("Line command without a starting point.") + end_point = self._vec(float(cmd[1]), float(cmd[2])) + if (end_point - current_point).magnitude > self._POINT_TOL: + segments.append(self._line_segment(current_point, end_point)) + current_point = end_point + elif letter == "C": + if current_point is None: + raise ValueError("Cubic command without a starting point.") + end_point = self._vec(float(cmd[1]), float(cmd[2])) + ctrl1 = self._vec(float(cmd[3]), float(cmd[4])) + ctrl2 = self._vec(float(cmd[5]), float(cmd[6])) + arc = self._try_cubic_as_arc(current_point, ctrl1, ctrl2, end_point) + if arc: + segments.append(arc) + else: + points = self._approximate_cubic(current_point, ctrl1, ctrl2, end_point) + for idx in range(1, len(points)): + start = points[idx - 1] + end = points[idx] + if (end - start).magnitude > self._POINT_TOL: + segments.append(self._line_segment(start, end)) + current_point = end_point + elif letter == "A": + if current_point is None: + raise ValueError("Arc command without a starting point.") + end_point = self._vec(float(cmd[1]), float(cmd[2])) + center = self._vec(float(cmd[3]), float(cmd[4])) + radius = abs(float(cmd[5])) + start_angle = math.atan2(current_point.y - center.y, current_point.x - center.x) + end_angle = math.atan2(end_point.y - center.y, end_point.x - center.x) + orientation = int(cmd[8]) + if orientation > 0: + while end_angle <= start_angle: + end_angle += 2.0 * math.pi + else: + while end_angle >= start_angle: + end_angle -= 2.0 * math.pi + + sweep_param = float(cmd[7]) - float(cmd[6]) + if orientation < 0: + sweep_param = -sweep_param + is_full_circle = ( + math.hypot(end_point.x - current_point.x, end_point.y - current_point.y) <= 1e-4 + and math.isclose(abs(sweep_param), 2.0 * math.pi, rel_tol=1e-6) + ) + segments.append( + self._arc_segment( + center=center, + radius=radius, + start_angle=start_angle, + end_angle=end_angle, + orientation=orientation if orientation != 0 else 1, + start=current_point, + end=end_point, + full_circle=is_full_circle, + ) + ) + current_point = end_point + elif letter == "T": + x, y = float(cmd[1]), float(cmd[2]) + text = str(cmd[4]) + params: TextParams = dict(cmd[5]) if len(cmd) > 5 and isinstance(cmd[5], dict) else {} + texts.append((x, y, text, params)) + else: + raise ValueError(f"Unsupported drawing command: {letter}") + + flush() + +class DXFSurface(Surface): + """Surface capable of producing DXF output via EZDXFBuilder.""" + + def finish(self, inner_corners: str = "loop", dogbone_radius=None): + try: + prepare_paths = super().prepare_paths # type: ignore[misc] + except AttributeError: + extents = self._prepare_paths_for_dxf(inner_corners, dogbone_radius) + else: + try: + extents = prepare_paths(inner_corners, dogbone_radius) + except TypeError: + extents = prepare_paths(inner_corners) + builder = EZDXFBuilder() + builder.set_extents(extents.xmin, extents.ymin, extents.xmax, extents.ymax) + for part in self.parts: + if not part.pathes: + continue + for path in part.pathes: + if not path.path: + continue + builder.set_lineweight(path.params.get("lw")) + builder.add_commands(path.path) + return builder.to_buffer() + + def _prepare_paths_for_dxf(self, inner_corners: str, dogbone_radius=None): + for part in self.parts: + for path in getattr(part, "pathes", ()): + faster_edges = getattr(path, "faster_edges", None) + if not callable(faster_edges): + continue + self._harmonize_path_commands(path) + if dogbone_radius is None: + try: + faster_edges(inner_corners) + except TypeError: + faster_edges(inner_corners, dogbone_radius) + else: + try: + faster_edges(inner_corners, dogbone_radius) + except TypeError: + faster_edges(inner_corners) + self._harmonize_path_commands(path) + return self._adjust_coordinates() + + @staticmethod + def _harmonize_path_commands(path): + try: + path.path = [list(cmd) if isinstance(cmd, tuple) else cmd for cmd in path.path] + except AttributeError: + return diff --git a/boxes/formats.py b/boxes/formats.py index 808f19b10..7f200e7a5 100644 --- a/boxes/formats.py +++ b/boxes/formats.py @@ -20,6 +20,7 @@ import tempfile import io from boxes.drawing import Context, LBRN2Surface, PSSurface, SVGSurface +from boxes.dxf_generator import DXFSurface class Formats: @@ -27,14 +28,17 @@ class Formats: pstoedit_candidates = ["/usr/bin/pstoedit", "pstoedit", r"C:\Program Files\pstoedit\pstoedit.exe", "pstoedit.exe"] ps2pdf_candidates = ["/usr/bin/ps2pdf", "ps2pdf", "ps2pdf.exe"] - _BASE_FORMATS = ['svg', 'svg_Ponoko', 'ps', 'lbrn2'] + _BASE_FORMATS = ['svg', 'svg_Ponoko', 'ps', 'lbrn2', 'dxf'] formats = { "svg": None, "svg_Ponoko": None, "ps": None, "lbrn2": None, - "dxf": "{pstoedit} -flat 0.1 -f dxf:-mm {input} {output}", +<<<<<<< HEAD +======= + "legacy.dxf": "{pstoedit} -flat 0.1 -f dxf:-mm {input} {output}", +>>>>>>> 4f34f201aa342eebb890f044667ecb88608b9e2a "gcode": "{pstoedit} -f gcode {input} {output}", "plt": "{pstoedit} -f hpgl {input} {output}", # "ai": "{pstoedit} -f ps2ai", @@ -47,6 +51,7 @@ class Formats: "ps": [('Content-type', 'application/postscript')], "lbrn2": [('Content-type', 'application/lbrn2')], "dxf": [('Content-type', 'image/vnd.dxf')], + "legacy.dxf": [('Content-type', 'image/vnd.dxf')], "plt": [('Content-type', ' application/vnd.hp-hpgl')], "gcode": [('Content-type', 'text/plain; charset=utf-8')], @@ -64,13 +69,16 @@ def __init__(self) -> None: break def getFormats(self): + available = set(self._BASE_FORMATS) if self.pstoedit: - return sorted(self.formats.keys()) - return self._BASE_FORMATS + available.update(self.formats.keys()) + return sorted(available) def getSurface(self, fmt): if fmt in ("svg", "svg_Ponoko"): surface = SVGSurface() + elif fmt == "dxf": + surface = DXFSurface() elif fmt == "lbrn2": surface = LBRN2Surface() else: diff --git a/requirements.txt b/requirements.txt index 58ee28c6f..ab2bfa4bd 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,5 @@ affine>=2.0 +ezdxf>=1.3 markdown qrcode>=7.3.1 setuptools diff --git a/scripts/boxes2inkscape b/scripts/boxes2inkscape index 34b3eeba8..2b342f9cd 100755 --- a/scripts/boxes2inkscape +++ b/scripts/boxes2inkscape @@ -1,153 +1,153 @@ -#!/usr/bin/env python3 -# Copyright (C) 2017 Florian Festi -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -import argparse -import os.path -import sys -from xml.sax.saxutils import quoteattr - -try: - import boxes.generators -except ImportError: - sys.path.append(os.path.dirname(__file__) + "/..") - import boxes.generators - - -class Boxes2INX: - def __init__(self) -> None: - self.boxes = {b.__name__: b() for b in boxes.generators.getAllBoxGenerators().values() if b.webinterface} - self.groups = boxes.generators.ui_groups - self.groups_by_name = boxes.generators.ui_groups_by_name - - for name, box in self.boxes.items(): - self.groups_by_name.get(box.ui_group, self.groups_by_name["Misc"]).add(box) - - def arg2inx(self, a, prefix): - name = a.option_strings[0].replace("-", "") - - if isinstance(a, argparse._HelpAction): - return "" - - viewname = name - if prefix and name.startswith(prefix + '_'): - viewname = name[len(prefix) + 1:] - - if (isinstance(a, argparse._StoreAction) and hasattr(a.type, "inx")): - return a.type.inx(name, viewname, a) # see boxes.__init__.py - elif a.dest == "layout": - return "" - # val = a.default.split("\n") - # input = f"""""" - elif a.choices: - uniqueChoices = [] - for e in a.choices: - if e not in uniqueChoices: - uniqueChoices.append(e) - return (f'''\n''' + - "".join(f'\n' for e in uniqueChoices) + ' \n') - else: - default = a.default - if isinstance(a.type, boxes.BoolArg): - t = '"bool"' - default = str(a.default).lower() - - elif a.type is boxes.argparseSections: - t = '"string"' - - else: - t = {int: '"int"', - float: '"float" precision="2"', - str: '"string"', - }.get(a.type, '"string"') - - if t == '"int"' or t == '"float" precision="2"': - return f'''{default}\n''' - - else: - return f'''{default}\n''' - - def generator2inx(self, name, box): - result = [f""" - -{name} -info.festi.boxes.py.{name} -{name.lower()} -"""] - groupid = 0 - for group in box.argparser._action_groups: - if not group._group_actions: - continue - prefix = getattr(group, "prefix", None) - title = group.title - if title.startswith("Settings for "): - title = title[len("Settings for "):] - if title.endswith(" Settings"): - title = title[:-len(" Settings")] - - pageParams = [] - for a in group._group_actions: - if a.dest in ("input", "output", "format"): - continue - if self.arg2inx(a, prefix) != "": - pageParams.append(self.arg2inx(a, prefix)) - if len(pageParams) > 0: - result.append(f"""""") - result.extend(pageParams) - result.append("\n") - - groupid += 1 - result.append(f"""\n""") - result.append(f"""./{name}-thumb.jpg\n""") - result.append("\n") - result.append(f""" - - - all - - - - - - {name}-thumb.svg - - -""") - return b''.join(s.encode("utf-8") for s in result) - - def writeINX(self, name, box, path): - with open(os.path.join(path, "boxes.py." + name + '.inx'), "wb") as f: - f.write(self.generator2inx(name, box)) - - def writeAllINX(self, path): - for name, box in self.boxes.items(): - if name.startswith("TrayLayout"): - # The two stage thing does not work (yet?) - continue - self.writeINX(name, box, path) - - -def main() -> None: - if len(sys.argv) != 2: - print("Usage: boxes2inkscape TARGETPATH") - return - b = Boxes2INX() - b.writeAllINX(sys.argv[1]) - - -if __name__ == "__main__": - main() +#!/usr/bin/env python3 +# Copyright (C) 2017 Florian Festi +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import argparse +import os.path +import sys +from xml.sax.saxutils import quoteattr + +try: + import boxes.generators +except ImportError: + sys.path.append(os.path.dirname(__file__) + "/..") + import boxes.generators + + +class Boxes2INX: + def __init__(self) -> None: + self.boxes = {b.__name__: b() for b in boxes.generators.getAllBoxGenerators().values() if b.webinterface} + self.groups = boxes.generators.ui_groups + self.groups_by_name = boxes.generators.ui_groups_by_name + + for name, box in self.boxes.items(): + self.groups_by_name.get(box.ui_group, self.groups_by_name["Misc"]).add(box) + + def arg2inx(self, a, prefix): + name = a.option_strings[0].replace("-", "") + + if isinstance(a, argparse._HelpAction): + return "" + + viewname = name + if prefix and name.startswith(prefix + '_'): + viewname = name[len(prefix) + 1:] + + if (isinstance(a, argparse._StoreAction) and hasattr(a.type, "inx")): + return a.type.inx(name, viewname, a) # see boxes.__init__.py + elif a.dest == "layout": + return "" + # val = a.default.split("\n") + # input = f"""""" + elif a.choices: + uniqueChoices = [] + for e in a.choices: + if e not in uniqueChoices: + uniqueChoices.append(e) + return (f'''\n''' + + "".join(f'\n' for e in uniqueChoices) + ' \n') + else: + default = a.default + if isinstance(a.type, boxes.BoolArg): + t = '"bool"' + default = str(a.default).lower() + + elif a.type is boxes.argparseSections: + t = '"string"' + + else: + t = {int: '"int"', + float: '"float" precision="2"', + str: '"string"', + }.get(a.type, '"string"') + + if t == '"int"' or t == '"float" precision="2"': + return f'''{default}\n''' + + else: + return f'''{default}\n''' + + def generator2inx(self, name, box): + result = [f""" + +{name} +info.festi.boxes.py.{name} +{name.lower()} +"""] + groupid = 0 + for group in box.argparser._action_groups: + if not group._group_actions: + continue + prefix = getattr(group, "prefix", None) + title = group.title + if title.startswith("Settings for "): + title = title[len("Settings for "):] + if title.endswith(" Settings"): + title = title[:-len(" Settings")] + + pageParams = [] + for a in group._group_actions: + if a.dest in ("input", "output", "format"): + continue + if self.arg2inx(a, prefix) != "": + pageParams.append(self.arg2inx(a, prefix)) + if len(pageParams) > 0: + result.append(f"""""") + result.extend(pageParams) + result.append("\n") + + groupid += 1 + result.append(f"""\n""") + result.append(f"""./{name}-thumb.jpg\n""") + result.append("\n") + result.append(f""" + + + all + + + + + + {name}-thumb.svg + + +""") + return b''.join(s.encode("utf-8") for s in result) + + def writeINX(self, name, box, path): + with open(os.path.join(path, "boxes.py." + name + '.inx'), "wb") as f: + f.write(self.generator2inx(name, box)) + + def writeAllINX(self, path): + for name, box in self.boxes.items(): + if name.startswith("TrayLayout"): + # The two stage thing does not work (yet?) + continue + self.writeINX(name, box, path) + + +def main() -> None: + if len(sys.argv) != 2: + print("Usage: boxes2inkscape TARGETPATH") + return + b = Boxes2INX() + b.writeAllINX(sys.argv[1]) + + +if __name__ == "__main__": + main()