ContentsFoundations to FrontierBezhig

eng-lab/hwe/cad/drawing.py

All lab code. Download this file.

"""Line drawings of a part: visible edges solid, hidden edges dashed.

This is how engineering drawings show a part, and it is what the report and the
manual print. build123d does the hard work: project_to_viewport projects every
edge of the solid onto a flat viewing plane and sorts the results into edges you
can see and edges hidden behind material (hidden-line removal). This module only
samples those edges into points and draws them.

    python -m hwe.cad.drawing            draws the current design's arm into out/cad/

Two outputs from one projection:
- draw(): a PNG or PDF through matplotlib (for the HTML report);
- tikz(): TikZ path commands (for the book's figures, so fonts and line weights match).
"""
from __future__ import annotations

from pathlib import Path

import numpy as np

# Viewing directions: where the eye sits relative to the part's centre.
ISOMETRIC = (-1.0, -1.0, 1.0)          # the classic isometric view
PLAN = (0.0, 0.0, 1.0)                 # looking straight down (top view)


def projected(part, view=ISOMETRIC, samples: int = 32):
    """Project `part` along `view`. Returns (visible, hidden): lists of Nx2 point arrays."""
    box = part.bounding_box()
    centre = box.center()
    distance = 4.0 * max(box.size.X, box.size.Y, box.size.Z)
    direction = np.array(view, dtype=float)
    direction /= np.linalg.norm(direction)
    origin = tuple(np.array([centre.X, centre.Y, centre.Z]) + distance * direction)
    up = (0, 1, 0) if tuple(view) == PLAN else (0, 0, 1)
    visible, hidden = part.project_to_viewport(origin, viewport_up=up, look_at=tuple(centre))

    def polylines(edges):
        out = []
        for edge in edges:
            n = 2 if edge.geom_type.name == "LINE" else samples
            pts = [edge.position_at(u) for u in np.linspace(0.0, 1.0, n)]
            out.append(np.array([[p.X, p.Y] for p in pts]))
        return out

    return polylines(visible), polylines(hidden)


def draw(part, path: Path | str, view=ISOMETRIC, *, width_in: float = 6.0, dpi: int = 220,
         show_hidden: bool = True) -> Path:
    """Save a line drawing of `part` (PNG or PDF, chosen by the file extension)."""
    import matplotlib
    matplotlib.use("Agg")
    import matplotlib.pyplot as plt

    visible, hidden = projected(part, view)
    everything = np.vstack(visible + hidden)
    lo, hi = everything.min(axis=0), everything.max(axis=0)
    aspect = (hi[1] - lo[1]) / max(hi[0] - lo[0], 1e-9)
    fig, ax = plt.subplots(figsize=(width_in, max(0.8, width_in * aspect)), dpi=dpi)
    if show_hidden:
        for line in hidden:
            ax.plot(line[:, 0], line[:, 1], color="#8a97a3", linewidth=0.6, linestyle=(0, (3, 2)))
    for line in visible:
        ax.plot(line[:, 0], line[:, 1], color="#173446", linewidth=1.1, solid_capstyle="round")
    ax.set_aspect("equal")
    ax.set_axis_off()
    fig.subplots_adjust(0, 0, 1, 1)
    path = Path(path)
    path.parent.mkdir(parents=True, exist_ok=True)
    fig.savefig(path, transparent=True, bbox_inches="tight", pad_inches=0.03)
    plt.close(fig)
    return path


def tikz(part, view=ISOMETRIC, *, width_cm: float = 12.0, show_hidden: bool = True) -> str:
    """TikZ commands for a line drawing `width_cm` wide (styles: visible, hidden)."""
    visible, hidden = projected(part, view)
    everything = np.vstack(visible + hidden)
    lo, hi = everything.min(axis=0), everything.max(axis=0)
    scale = width_cm / max(hi[0] - lo[0], 1e-9)

    def path(line):
        pts = (line - lo) * scale
        return " -- ".join(f"({x:.3f},{y:.3f})" for x, y in pts)

    out = []
    if show_hidden:
        out += [rf"\draw[hidden] {path(line)};" for line in hidden]
    out += [rf"\draw[visible] {path(line)};" for line in visible]
    return "\n".join(out)