9abeb6266a
- assembly forward proagation
1083 lines
42 KiB
Python
1083 lines
42 KiB
Python
"""
|
||
Technical Drawing workbench widget.
|
||
|
||
Embeddable QWidget for creating and exporting technical drawings
|
||
from component and assembly geometry. Designed to sit in MainWindow's
|
||
InputTab alongside Sketch, Code, and Render tabs.
|
||
|
||
The UI is intentionally minimal: the source is resolved automatically
|
||
from the currently selected component (or the first assembly), and a
|
||
single Generate button builds the drawing from the checked views. The
|
||
only remaining controls are the per-view options, the title block, and
|
||
the PDF/SVG export buttons.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from datetime import datetime
|
||
import math
|
||
from typing import Dict, List, Optional, Sequence, Tuple
|
||
|
||
from PySide6.QtCore import Qt, QRectF, QPointF, Signal
|
||
from PySide6.QtGui import QPainter, QColor, QFont, QMouseEvent, QWheelEvent
|
||
from PySide6.QtWidgets import (
|
||
QWidget,
|
||
QVBoxLayout,
|
||
QHBoxLayout,
|
||
QSplitter,
|
||
QPushButton,
|
||
QCheckBox,
|
||
QLabel,
|
||
QLineEdit,
|
||
QGroupBox,
|
||
QScrollArea,
|
||
QTextEdit,
|
||
QFileDialog,
|
||
QSizePolicy,
|
||
QButtonGroup,
|
||
)
|
||
|
||
from fluency.models.data_model import (
|
||
TechnicalDrawing,
|
||
DrawingView,
|
||
DrawingAnnotation,
|
||
)
|
||
from fluency.technical_drawing import (
|
||
DrawingRenderResult,
|
||
generate_drawing,
|
||
render_drawing,
|
||
export_drawing_svg,
|
||
export_drawing_pdf,
|
||
_STANDARD_VIEW_ROWS,
|
||
_DIM_FONT_W,
|
||
_DIM_FONT_H,
|
||
)
|
||
|
||
# Sheet layout constants.
|
||
# Sheet layout constants (A4 landscape, ISO 5457 template).
|
||
_A4_MM_W = 297.0
|
||
_A4_MM_H = 210.0
|
||
|
||
|
||
# ── 2D geometry helpers (view-plane model coordinates) ────────────────────
|
||
|
||
|
||
def _point_to_segment(
|
||
p: Tuple[float, float],
|
||
a: Tuple[float, float],
|
||
b: Tuple[float, float],
|
||
) -> Tuple[Tuple[float, float], float]:
|
||
"""Closest point *q* on segment ab to *p*, and the distance |p-q|."""
|
||
abx, aby = b[0] - a[0], b[1] - a[1]
|
||
length_sq = abx * abx + aby * aby
|
||
if length_sq <= 1e-12:
|
||
return a, math.hypot(p[0] - a[0], p[1] - a[1])
|
||
t = ((p[0] - a[0]) * abx + (p[1] - a[1]) * aby) / length_sq
|
||
t = max(0.0, min(1.0, t))
|
||
q = (a[0] + abx * t, a[1] + aby * t)
|
||
return q, math.hypot(p[0] - q[0], p[1] - q[1])
|
||
|
||
|
||
def _closest_point_on_segment(
|
||
p: Tuple[float, float],
|
||
a: Tuple[float, float],
|
||
b: Tuple[float, float],
|
||
) -> Tuple[float, float]:
|
||
"""Closest point on segment ab to *p*."""
|
||
q, _ = _point_to_segment(p, a, b)
|
||
return q
|
||
|
||
|
||
def _closest_points_on_segments(
|
||
a1: Tuple[float, float],
|
||
a2: Tuple[float, float],
|
||
b1: Tuple[float, float],
|
||
b2: Tuple[float, float],
|
||
) -> Tuple[Tuple[float, float], Tuple[float, float], float]:
|
||
"""Closest points q1 (on a1a2) and q2 (on b1b2), plus their distance.
|
||
|
||
For non-parallel segments whose infinite lines cross inside both
|
||
segments this is the exact segment crossing. Otherwise (parallel
|
||
segments, or a crossing outside the segment spans) the closest pair
|
||
is found by projecting the four endpoints — which also yields the
|
||
true perpendicular distance for overlapping parallel edges.
|
||
"""
|
||
r = (a2[0] - a1[0], a2[1] - a1[1])
|
||
s = (b2[0] - b1[0], b2[1] - b1[1])
|
||
denom = r[0] * s[1] - r[1] * s[0]
|
||
if abs(denom) > 1e-9:
|
||
qp = (b1[0] - a1[0], b1[1] - a1[1])
|
||
t = (qp[0] * s[1] - qp[1] * s[0]) / denom
|
||
u = (qp[0] * r[1] - qp[1] * r[0]) / denom
|
||
if 0.0 <= t <= 1.0 and 0.0 <= u <= 1.0:
|
||
q1 = (a1[0] + r[0] * t, a1[1] + r[1] * t)
|
||
q2 = (b1[0] + s[0] * u, b1[1] + s[1] * u)
|
||
return q1, q2, math.hypot(q1[0] - q2[0], q1[1] - q2[1])
|
||
best: Optional[Tuple[Tuple[float, float], Tuple[float, float], float]] = None
|
||
for p in (a1, a2):
|
||
q2, d = _point_to_segment(p, b1, b2)
|
||
if best is None or d < best[2]:
|
||
best = (p, q2, d)
|
||
for q in (b1, b2):
|
||
q1, d = _point_to_segment(q, a1, a2)
|
||
if best is None or d < best[2]:
|
||
best = (q1, q, d)
|
||
assert best is not None
|
||
return best
|
||
|
||
|
||
def _line_intersection(
|
||
p1: Tuple[float, float],
|
||
p2: Tuple[float, float],
|
||
p3: Tuple[float, float],
|
||
p4: Tuple[float, float],
|
||
) -> Optional[Tuple[float, float]]:
|
||
"""Intersection of the two infinite lines through p1p2 / p3p4.
|
||
|
||
Returns None when the lines are parallel.
|
||
"""
|
||
r = (p2[0] - p1[0], p2[1] - p1[1])
|
||
s = (p4[0] - p3[0], p4[1] - p3[1])
|
||
denom = r[0] * s[1] - r[1] * s[0]
|
||
if abs(denom) < 1e-9:
|
||
return None
|
||
qp = (p3[0] - p1[0], p3[1] - p1[1])
|
||
t = (qp[0] * s[1] - qp[1] * s[0]) / denom
|
||
return (p1[0] + r[0] * t, p1[1] + r[1] * t)
|
||
|
||
|
||
class DrawingCanvas(QWidget):
|
||
"""Custom QPainter canvas with zoom/pan for technical drawing display."""
|
||
|
||
# Feature picked while a dimension tool is active. Payload:
|
||
# {"kind": "segment", "view_id", "p1", "p2"} (model coords)
|
||
# {"kind": "circle", "view_id", "center", "radius"} (model coords)
|
||
# {"kind": "dimension", "candidate_key"}
|
||
featurePicked = Signal(object)
|
||
# Escape was pressed while a pick tool is active.
|
||
pickEscaped = Signal()
|
||
|
||
def __init__(self, parent=None):
|
||
super().__init__(parent)
|
||
self._render_result: Optional[DrawingRenderResult] = None
|
||
self._zoom: float = 1.0
|
||
self._pan_x: float = 0.0
|
||
self._pan_y: float = 0.0
|
||
self._last_mouse: Optional[QPointF] = None
|
||
self._pick_mode: str = ""
|
||
self.setMinimumSize(400, 300)
|
||
self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
|
||
self.setMouseTracking(True)
|
||
self.setFocusPolicy(Qt.StrongFocus)
|
||
|
||
def set_render_result(self, result: Optional[DrawingRenderResult]) -> None:
|
||
self._render_result = result
|
||
self._zoom = 1.0
|
||
self._pan_x = 0.0
|
||
self._pan_y = 0.0
|
||
self.update()
|
||
|
||
def set_pick_mode(self, mode: str) -> None:
|
||
"""Activate a picking tool ("distance","diameter","angle","delete").
|
||
|
||
An empty string returns to plain display mode.
|
||
"""
|
||
self._pick_mode = mode or ""
|
||
self.setCursor(Qt.CrossCursor if mode else Qt.ArrowCursor)
|
||
|
||
def paintEvent(self, event) -> None:
|
||
painter = QPainter(self)
|
||
painter.setRenderHint(QPainter.Antialiasing)
|
||
painter.fillRect(self.rect(), QColor(200, 200, 200))
|
||
|
||
if self._render_result is None or not self._render_result.primitives:
|
||
painter.setPen(QColor(128, 128, 128))
|
||
font = QFont("sans-serif", 14)
|
||
painter.setFont(font)
|
||
painter.drawText(self.rect(), Qt.AlignCenter, "No drawing generated")
|
||
painter.end()
|
||
return
|
||
|
||
render_drawing(painter, self._render_result, self._sheet_rect())
|
||
painter.end()
|
||
|
||
def _sheet_rect(self) -> QRectF:
|
||
"""Sheet rect in device coordinates (zoom/pan applied)."""
|
||
aspect = _A4_MM_W / _A4_MM_H
|
||
w = self.width()
|
||
h = self.height()
|
||
|
||
if w / h > aspect:
|
||
draw_h = h * 0.85 * self._zoom
|
||
draw_w = draw_h * aspect
|
||
else:
|
||
draw_w = w * 0.85 * self._zoom
|
||
draw_h = draw_w / aspect
|
||
|
||
ox = (w - draw_w) / 2.0 + self._pan_x
|
||
oy = (h - draw_h) / 2.0 + self._pan_y
|
||
return QRectF(ox, oy, draw_w, draw_h)
|
||
|
||
def to_sheet(self, pos: QPointF) -> Tuple[float, float]:
|
||
"""Convert a device pixel position to sheet mm coordinates."""
|
||
rect = self._sheet_rect()
|
||
scale = rect.width() / _A4_MM_W
|
||
if scale <= 0:
|
||
return (0.0, 0.0)
|
||
return (
|
||
(pos.x() - rect.x()) / scale,
|
||
_A4_MM_H - (pos.y() - rect.y()) / scale,
|
||
)
|
||
|
||
def _pick_tolerance_mm(self) -> float:
|
||
"""Pick radius in sheet mm (about 6 device pixels)."""
|
||
rect = self._sheet_rect()
|
||
scale = rect.width() / _A4_MM_W
|
||
return 6.0 / scale if scale > 0 else 6.0
|
||
|
||
def _sheet_to_model(
|
||
self, view_id: str, pt: Tuple[float, float]
|
||
) -> Optional[Tuple[float, float]]:
|
||
"""Invert the view's model→sheet transform for a sheet point."""
|
||
if self._render_result is None:
|
||
return None
|
||
t = self._render_result.view_transforms.get(view_id)
|
||
if t is None or t[0] <= 0:
|
||
return None
|
||
scale, ox, oy = t[0], t[1], t[2]
|
||
if len(t) >= 6:
|
||
# sheet(p) = s·R(θ)(p − c) + o → invert around the centre.
|
||
th = math.radians(t[3])
|
||
cos_t, sin_t = math.cos(th), math.sin(th)
|
||
sx, sy = (pt[0] - ox) / scale, (pt[1] - oy) / scale
|
||
return (
|
||
sx * cos_t + sy * sin_t + t[4],
|
||
-sx * sin_t + sy * cos_t + t[5],
|
||
)
|
||
return ((pt[0] - ox) / scale, (pt[1] - oy) / scale)
|
||
|
||
def _pick_feature(self, pos: QPointF) -> Optional[dict]:
|
||
"""Hit-test the pick at *pos* for the active pick mode."""
|
||
result = self._render_result
|
||
if result is None:
|
||
return None
|
||
pt = self.to_sheet(pos)
|
||
tol = self._pick_tolerance_mm()
|
||
|
||
if self._pick_mode == "delete":
|
||
return self._pick_dimension(pt, tol)
|
||
|
||
best: Optional[dict] = None
|
||
best_d = tol
|
||
for prim in result.primitives:
|
||
if prim.candidate_key is not None or prim.style not in ("visible", "hidden"):
|
||
continue
|
||
if not prim.view_id:
|
||
continue
|
||
transform = result.view_transforms.get(prim.view_id)
|
||
if transform is None or transform[0] <= 0:
|
||
continue
|
||
scale = transform[0]
|
||
|
||
if prim.kind == "line" and len(prim.points) == 2:
|
||
q, d = _point_to_segment(pt, prim.points[0], prim.points[1])
|
||
if d < best_d:
|
||
best_d = d
|
||
m1 = self._sheet_to_model(prim.view_id, prim.points[0])
|
||
m2 = self._sheet_to_model(prim.view_id, prim.points[1])
|
||
if m1 and m2:
|
||
best = {
|
||
"kind": "segment",
|
||
"view_id": prim.view_id,
|
||
"p1": m1,
|
||
"p2": m2,
|
||
}
|
||
elif prim.kind == "circle" and prim.center and prim.radius:
|
||
dc = math.hypot(pt[0] - prim.center[0], pt[1] - prim.center[1])
|
||
if dc <= tol:
|
||
# Hit the circle's centre mark: a point feature — circle
|
||
# centres are first-class dimension references.
|
||
mc = self._sheet_to_model(prim.view_id, prim.center)
|
||
if mc:
|
||
return {
|
||
"kind": "point",
|
||
"view_id": prim.view_id,
|
||
"point": mc,
|
||
"radius": prim.radius / scale,
|
||
}
|
||
d = abs(dc - prim.radius)
|
||
if d < best_d:
|
||
best_d = d
|
||
mc = self._sheet_to_model(prim.view_id, prim.center)
|
||
if mc:
|
||
best = {
|
||
"kind": "circle",
|
||
"view_id": prim.view_id,
|
||
"center": mc,
|
||
"radius": prim.radius / scale,
|
||
}
|
||
return best
|
||
|
||
def _pick_dimension(self, pt: Tuple[float, float], tol: float) -> Optional[dict]:
|
||
"""Hit-test placed dimension primitives (for the delete tool)."""
|
||
assert self._render_result is not None
|
||
for prim in self._render_result.primitives:
|
||
if prim.style != "dimension" or not prim.candidate_key:
|
||
continue
|
||
if prim.kind == "text" and prim.points and prim.text:
|
||
x, y = prim.points[0]
|
||
w = len(prim.text) * _DIM_FONT_W
|
||
if (
|
||
x - 1.0 <= pt[0] <= x + w + 1.0
|
||
and y - 2.0 <= pt[1] <= y + _DIM_FONT_H
|
||
):
|
||
return {"kind": "dimension", "candidate_key": prim.candidate_key}
|
||
elif prim.kind == "line" and len(prim.points) == 2:
|
||
_, d = _point_to_segment(pt, prim.points[0], prim.points[1])
|
||
if d <= tol:
|
||
return {"kind": "dimension", "candidate_key": prim.candidate_key}
|
||
return None
|
||
|
||
def wheelEvent(self, event: QWheelEvent) -> None:
|
||
factor = 1.1 if event.angleDelta().y() > 0 else 0.9
|
||
self._zoom = max(0.1, min(10.0, self._zoom * factor))
|
||
self.update()
|
||
|
||
def mousePressEvent(self, event: QMouseEvent) -> None:
|
||
if event.button() == Qt.MiddleButton:
|
||
self._last_mouse = event.position()
|
||
self.setCursor(Qt.ClosedHandCursor)
|
||
return
|
||
if event.button() == Qt.LeftButton and self._pick_mode:
|
||
info = self._pick_feature(event.position())
|
||
if info is not None:
|
||
self.featurePicked.emit(info)
|
||
|
||
def mouseMoveEvent(self, event: QMouseEvent) -> None:
|
||
if self._last_mouse is not None:
|
||
delta = event.position() - self._last_mouse
|
||
self._pan_x += delta.x()
|
||
self._pan_y += delta.y()
|
||
self._last_mouse = event.position()
|
||
self.update()
|
||
|
||
def mouseReleaseEvent(self, event: QMouseEvent) -> None:
|
||
if event.button() == Qt.MiddleButton:
|
||
self._last_mouse = None
|
||
self.setCursor(Qt.CrossCursor if self._pick_mode else Qt.ArrowCursor)
|
||
|
||
def keyPressEvent(self, event) -> None:
|
||
if event.key() == Qt.Key_Escape and self._pick_mode:
|
||
self.pickEscaped.emit()
|
||
return
|
||
super().keyPressEvent(event)
|
||
|
||
|
||
class TechnicalDrawingWidget(QWidget):
|
||
"""Embeddable technical drawing workbench."""
|
||
|
||
# Emitted whenever the drawing definition is modified by the user
|
||
# (dimensions added/removed, auto toggle, view changes) so the host
|
||
# can mark the project dirty.
|
||
drawing_changed = Signal()
|
||
|
||
def __init__(self, parent=None):
|
||
super().__init__(parent)
|
||
|
||
self._drawing: Optional[TechnicalDrawing] = None
|
||
self._render_result: Optional[DrawingRenderResult] = None
|
||
self._project = None
|
||
self._kernel = None
|
||
|
||
# Source last selected via set_active_component / set_drawing.
|
||
# Falls back to the project's active component when unset.
|
||
self._active_source_kind: Optional[str] = None
|
||
self._active_source_id: Optional[str] = None
|
||
|
||
# First pick of the active dimension tool (edge/circle info dict).
|
||
self._first_pick: Optional[dict] = None
|
||
|
||
self._view_checkboxes: Dict[str, QCheckBox] = {}
|
||
self._hidden_line_checks: Dict[str, QCheckBox] = {}
|
||
self._centerline_checks: Dict[str, QCheckBox] = {}
|
||
|
||
self._title_edit: Optional[QLineEdit] = None
|
||
self._part_no_edit: Optional[QLineEdit] = None
|
||
self._material_edit: Optional[QLineEdit] = None
|
||
self._revision_edit: Optional[QLineEdit] = None
|
||
self._notes_edit: Optional[QTextEdit] = None
|
||
|
||
self._init_ui()
|
||
|
||
# ── Public API ──────────────────────────────────────────────────────────
|
||
|
||
def set_project(self, project, kernel) -> None:
|
||
"""Set the current project and kernel reference."""
|
||
self._project = project
|
||
self._kernel = kernel
|
||
|
||
def set_active_component(self, component) -> None:
|
||
"""Use the given component as the drawing source."""
|
||
self._active_source_kind = "component"
|
||
self._active_source_id = component.id
|
||
|
||
def set_active_assembly(self, assembly) -> None:
|
||
"""Use the given assembly as the drawing source."""
|
||
self._active_source_kind = "assembly"
|
||
self._active_source_id = assembly.id
|
||
|
||
|
||
def generate(self) -> None:
|
||
"""Public entry point: generate for the current source."""
|
||
self._on_generate()
|
||
|
||
def set_drawing(self, drawing: Optional[TechnicalDrawing]) -> None:
|
||
"""Load a drawing definition."""
|
||
self._drawing = drawing
|
||
self._render_result = None
|
||
if drawing is not None:
|
||
self._active_source_kind = drawing.source_kind
|
||
self._active_source_id = drawing.source_id
|
||
self._populate_from_drawing(drawing)
|
||
self._auto_dim_check.blockSignals(True)
|
||
self._auto_dim_check.setChecked(drawing.auto_dimensions)
|
||
self._auto_dim_check.blockSignals(False)
|
||
self._clear_tool_selection()
|
||
self._first_pick = None
|
||
self._canvas.set_pick_mode("")
|
||
self._canvas.set_render_result(None)
|
||
self._update_export_state()
|
||
|
||
def get_drawing(self) -> Optional[TechnicalDrawing]:
|
||
"""Get the current drawing (may be modified by UI)."""
|
||
if self._drawing is None:
|
||
return None
|
||
self._sync_views_to_drawing()
|
||
self._sync_title_block_to_drawing()
|
||
return self._drawing
|
||
|
||
def set_render_result(self, result: Optional[DrawingRenderResult]) -> None:
|
||
"""Display a generated render result."""
|
||
self._render_result = result
|
||
self._canvas.set_render_result(result)
|
||
if result is not None:
|
||
self._status_label.setText(
|
||
f"Generated — {len(result.primitives)} primitives"
|
||
)
|
||
if result.warnings:
|
||
self._status_label.setText(
|
||
self._status_label.text() + f" ({len(result.warnings)} warnings)"
|
||
)
|
||
self._update_export_state()
|
||
|
||
# ── UI construction ─────────────────────────────────────────────────────
|
||
|
||
def _init_ui(self) -> None:
|
||
layout = QHBoxLayout(self)
|
||
|
||
# ── Left controls ───────────────────────────────────────────────
|
||
left = QWidget()
|
||
left.setFixedWidth(300)
|
||
left_layout = QVBoxLayout(left)
|
||
left_layout.setContentsMargins(4, 4, 4, 4)
|
||
|
||
# Views.
|
||
views_group = QGroupBox("Views")
|
||
views_layout = QVBoxLayout(views_group)
|
||
|
||
for vid, vname, vdir, vup in _STANDARD_VIEW_ROWS:
|
||
row = QHBoxLayout()
|
||
cb = QCheckBox(vname)
|
||
self._view_checkboxes[vid] = cb
|
||
row.addWidget(cb)
|
||
|
||
hl = QCheckBox("HL")
|
||
hl.setToolTip("Show hidden lines")
|
||
self._hidden_line_checks[vid] = hl
|
||
row.addWidget(hl)
|
||
|
||
cl = QCheckBox("CL")
|
||
cl.setToolTip("Show centerlines")
|
||
self._centerline_checks[vid] = cl
|
||
row.addWidget(cl)
|
||
|
||
views_layout.addLayout(row)
|
||
|
||
# Wire view/HL/CL toggles to regenerate.
|
||
for vid, cb in self._view_checkboxes.items():
|
||
cb.toggled.connect(lambda checked, v=vid: self._on_view_toggled(v))
|
||
for vid, cb in self._hidden_line_checks.items():
|
||
cb.toggled.connect(lambda checked, v=vid: self._on_view_toggled(v))
|
||
for vid, cb in self._centerline_checks.items():
|
||
cb.toggled.connect(lambda checked, v=vid: self._on_view_toggled(v))
|
||
# Default: Front, Top, Right, Isometric.
|
||
for vid in ("front", "top", "right", "isometric"):
|
||
self._view_checkboxes[vid].setChecked(True)
|
||
|
||
left_layout.addWidget(views_group)
|
||
|
||
# Dimensions (manual placement).
|
||
dim_group = QGroupBox("Dimensions")
|
||
dim_layout = QVBoxLayout(dim_group)
|
||
|
||
tool_row = QHBoxLayout()
|
||
self._tool_group = QButtonGroup(self)
|
||
self._tool_group.setExclusive(True)
|
||
self._tool_buttons: Dict[str, QPushButton] = {}
|
||
for tool, label, tip in (
|
||
(
|
||
"distance",
|
||
"Distance",
|
||
"Pick two edges, an edge and a circle centre, or two "
|
||
"circle centres — measures the distance between them",
|
||
),
|
||
("diameter", "Diameter", "Pick a circle — its diameter is added"),
|
||
("angle", "Angle", "Pick two edges — the angle between them is added"),
|
||
):
|
||
btn = QPushButton(label)
|
||
btn.setCheckable(True)
|
||
btn.setToolTip(tip)
|
||
btn.toggled.connect(
|
||
lambda checked, t=tool: self._on_tool_toggled(t, checked)
|
||
)
|
||
self._tool_group.addButton(btn)
|
||
self._tool_buttons[tool] = btn
|
||
tool_row.addWidget(btn)
|
||
dim_layout.addLayout(tool_row)
|
||
|
||
self._delete_dim_btn = QPushButton("Delete")
|
||
self._delete_dim_btn.setToolTip("Click a placed manual dimension to remove it")
|
||
self._delete_dim_btn.clicked.connect(self._on_delete_clicked)
|
||
dim_layout.addWidget(self._delete_dim_btn)
|
||
|
||
self._auto_dim_check = QCheckBox("Auto dimensions")
|
||
self._auto_dim_check.setChecked(False)
|
||
self._auto_dim_check.setToolTip(
|
||
"Automatically place dimensions on all views. Off by default; "
|
||
"manual dimensions added above are always kept."
|
||
)
|
||
self._auto_dim_check.toggled.connect(self._on_auto_toggled)
|
||
dim_layout.addWidget(self._auto_dim_check)
|
||
|
||
self._clear_dims_btn = QPushButton("Clear manual dimensions")
|
||
self._clear_dims_btn.setToolTip("Remove all manually placed dimensions")
|
||
self._clear_dims_btn.clicked.connect(self._on_clear_clicked)
|
||
dim_layout.addWidget(self._clear_dims_btn)
|
||
|
||
left_layout.addWidget(dim_group)
|
||
|
||
# Sheet / title block.
|
||
sheet_group = QGroupBox("Title Block")
|
||
sheet_layout = QVBoxLayout(sheet_group)
|
||
|
||
for lbl, attr in [
|
||
("Title", "_title_edit"),
|
||
("Part No.", "_part_no_edit"),
|
||
("Material", "_material_edit"),
|
||
("Revision", "_revision_edit"),
|
||
]:
|
||
row = QHBoxLayout()
|
||
row.addWidget(QLabel(lbl))
|
||
edit = QLineEdit()
|
||
setattr(self, attr, edit)
|
||
row.addWidget(edit)
|
||
sheet_layout.addLayout(row)
|
||
|
||
sheet_layout.addWidget(QLabel("Notes:"))
|
||
self._notes_edit = QTextEdit()
|
||
self._notes_edit.setMaximumHeight(80)
|
||
sheet_layout.addWidget(self._notes_edit)
|
||
|
||
left_layout.addWidget(sheet_group)
|
||
|
||
# Actions.
|
||
actions_group = QGroupBox("Actions")
|
||
actions_layout = QVBoxLayout(actions_group)
|
||
|
||
self._generate_btn = QPushButton("Generate")
|
||
self._generate_btn.setToolTip(
|
||
"Build a drawing of the selected component with the views checked above"
|
||
)
|
||
self._generate_btn.clicked.connect(self._on_generate)
|
||
actions_layout.addWidget(self._generate_btn)
|
||
|
||
export_row = QHBoxLayout()
|
||
self._export_pdf_btn = QPushButton("Export PDF")
|
||
self._export_pdf_btn.setEnabled(False)
|
||
self._export_pdf_btn.clicked.connect(lambda: self._on_export("pdf"))
|
||
export_row.addWidget(self._export_pdf_btn)
|
||
|
||
self._export_svg_btn = QPushButton("Export SVG")
|
||
self._export_svg_btn.setEnabled(False)
|
||
self._export_svg_btn.clicked.connect(lambda: self._on_export("svg"))
|
||
export_row.addWidget(self._export_svg_btn)
|
||
|
||
actions_layout.addLayout(export_row)
|
||
left_layout.addWidget(actions_group)
|
||
|
||
# Status.
|
||
self._status_label = QLabel("No drawing loaded")
|
||
self._status_label.setWordWrap(True)
|
||
left_layout.addWidget(self._status_label)
|
||
|
||
left_layout.addStretch()
|
||
left.setLayout(left_layout)
|
||
|
||
# ── Scroll area for left panel ──────────────────────────────────
|
||
scroll = QScrollArea()
|
||
scroll.setWidget(left)
|
||
scroll.setWidgetResizable(True)
|
||
scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
|
||
|
||
# ── Right side: canvas ──────────────────────────────────────────
|
||
self._canvas = DrawingCanvas()
|
||
self._canvas.featurePicked.connect(self._on_feature_picked)
|
||
self._canvas.pickEscaped.connect(self._cancel_pick)
|
||
|
||
splitter = QSplitter(Qt.Horizontal)
|
||
splitter.addWidget(scroll)
|
||
splitter.addWidget(self._canvas)
|
||
splitter.setStretchFactor(0, 0)
|
||
splitter.setStretchFactor(1, 1)
|
||
|
||
layout.addWidget(splitter)
|
||
|
||
# ── Internal helpers ──────────────────────────────────────────────────
|
||
|
||
def _resolve_source(self):
|
||
"""Resolve ``(source_kind, source_id)``.
|
||
|
||
Prefers the source last selected via ``set_active_component`` or
|
||
``set_drawing``; otherwise falls back to the project's active
|
||
component, then the first assembly. Returns ``(None, None)`` when
|
||
no source is available.
|
||
"""
|
||
if self._active_source_id and self._project is not None:
|
||
catalog = (
|
||
self._project.components
|
||
if self._active_source_kind == "component"
|
||
else self._project.assemblies
|
||
)
|
||
if self._active_source_id in catalog:
|
||
return self._active_source_kind, self._active_source_id
|
||
self._active_source_kind = None
|
||
self._active_source_id = None
|
||
|
||
if self._project is not None:
|
||
comp = self._project.get_active_component()
|
||
if comp and any(b.visible and b.geometry for b in comp.bodies.values()):
|
||
return "component", comp.id
|
||
if self._project.assemblies:
|
||
return "assembly", next(iter(self._project.assemblies.keys()))
|
||
return None, None
|
||
|
||
def _build_views(self) -> List[DrawingView]:
|
||
"""Build the DrawingView list from the view checkboxes."""
|
||
views: List[DrawingView] = []
|
||
for vid, vname, vdir, vup in _STANDARD_VIEW_ROWS:
|
||
if not self._view_checkboxes[vid].isChecked():
|
||
continue
|
||
views.append(
|
||
DrawingView(
|
||
name=vname,
|
||
kind=vid,
|
||
direction=vdir,
|
||
up_vector=vup,
|
||
show_hidden_lines=self._hidden_line_checks[vid].isChecked(),
|
||
show_centerlines=self._centerline_checks[vid].isChecked(),
|
||
)
|
||
)
|
||
if not views:
|
||
# Nothing checked — fall back to the full standard set so
|
||
# Generate always produces a drawing.
|
||
for vid, vname, vdir, vup in _STANDARD_VIEW_ROWS:
|
||
views.append(
|
||
DrawingView(kind=vid, name=vname, direction=vdir, up_vector=vup)
|
||
)
|
||
return views
|
||
|
||
def _populate_from_drawing(self, drawing: TechnicalDrawing) -> None:
|
||
"""Sync view and title-block controls from a drawing definition."""
|
||
# Block checkbox signals during population to avoid triggering
|
||
# _on_view_toggled mid-update.
|
||
for cb in self._view_checkboxes.values():
|
||
cb.blockSignals(True)
|
||
for cb in self._hidden_line_checks.values():
|
||
cb.blockSignals(True)
|
||
for cb in self._centerline_checks.values():
|
||
cb.blockSignals(True)
|
||
|
||
try:
|
||
enabled = {v.kind: v for v in drawing.views}
|
||
for vid, cb in self._view_checkboxes.items():
|
||
cb.setChecked(vid in enabled)
|
||
for vid, cb in self._hidden_line_checks.items():
|
||
v = enabled.get(vid)
|
||
cb.setChecked(v is not None and v.show_hidden_lines)
|
||
for vid, cb in self._centerline_checks.items():
|
||
v = enabled.get(vid)
|
||
cb.setChecked(v is not None and v.show_centerlines)
|
||
|
||
# Auto-dimensions toggle.
|
||
self._auto_dim_check.blockSignals(True)
|
||
self._auto_dim_check.setChecked(drawing.auto_dimensions)
|
||
self._auto_dim_check.blockSignals(False)
|
||
|
||
# Title block.
|
||
assert self._title_edit is not None and self._part_no_edit is not None
|
||
assert self._material_edit is not None and self._revision_edit is not None
|
||
assert self._notes_edit is not None
|
||
self._title_edit.setText(drawing.title)
|
||
self._part_no_edit.setText(drawing.part_number)
|
||
self._material_edit.setText(drawing.material)
|
||
self._revision_edit.setText(drawing.revision)
|
||
self._notes_edit.setPlainText(drawing.notes)
|
||
finally:
|
||
for cb in self._view_checkboxes.values():
|
||
cb.blockSignals(False)
|
||
for cb in self._hidden_line_checks.values():
|
||
cb.blockSignals(False)
|
||
for cb in self._centerline_checks.values():
|
||
cb.blockSignals(False)
|
||
|
||
def _sync_views_to_drawing(self) -> None:
|
||
"""Write current view checkboxes back to the drawing."""
|
||
if self._drawing is None:
|
||
return
|
||
self._drawing.views = self._build_views()
|
||
|
||
def _sync_title_block_to_drawing(self) -> None:
|
||
"""Write title-block edits back to the drawing."""
|
||
if self._drawing is None:
|
||
return
|
||
assert self._title_edit is not None and self._part_no_edit is not None
|
||
assert self._material_edit is not None and self._revision_edit is not None
|
||
assert self._notes_edit is not None
|
||
self._drawing.title = self._title_edit.text()
|
||
self._drawing.part_number = self._part_no_edit.text()
|
||
self._drawing.material = self._material_edit.text()
|
||
self._drawing.revision = self._revision_edit.text()
|
||
self._drawing.notes = self._notes_edit.toPlainText()
|
||
|
||
def _update_export_state(self) -> None:
|
||
has_result = self._render_result is not None
|
||
self._export_pdf_btn.setEnabled(has_result)
|
||
self._export_svg_btn.setEnabled(has_result)
|
||
|
||
def _on_view_toggled(self, vid: str) -> None:
|
||
"""Regenerate when any view/HL/CL checkbox changes."""
|
||
if self._drawing is not None:
|
||
self._on_generate()
|
||
self.drawing_changed.emit()
|
||
|
||
# ── Slots ──────────────────────────────────────────────────────────────
|
||
|
||
def _on_generate(self) -> None:
|
||
"""Generate a drawing for the current source.
|
||
|
||
Reuses the existing drawing (preserving title-block edits) when it
|
||
already targets the resolved source, otherwise builds a fresh one
|
||
from the view checkboxes.
|
||
"""
|
||
if self._project is None or self._kernel is None:
|
||
self._status_label.setText("No project/kernel available")
|
||
return
|
||
|
||
source_kind, source_id = self._resolve_source()
|
||
if source_kind is None or not source_id:
|
||
self._status_label.setText("No source available for drawing")
|
||
return
|
||
|
||
comp = None
|
||
if source_kind == "component":
|
||
comp = self._project.get_component_by_id(source_id)
|
||
|
||
if (
|
||
self._drawing is not None
|
||
and self._drawing.source_kind == source_kind
|
||
and self._drawing.source_id == source_id
|
||
):
|
||
drawing = self._drawing
|
||
else:
|
||
# Adopt the drawing stored with the project for this source —
|
||
# that is where manual dimensions and the auto toggle persist.
|
||
drawing = self._project.get_drawing_for(source_kind, source_id)
|
||
if drawing is None:
|
||
drawing = TechnicalDrawing(
|
||
name=f"Drawing of {comp.name if comp else 'Assembly'}",
|
||
source_kind=source_kind,
|
||
source_id=source_id,
|
||
title=comp.name if comp else "Assembly Drawing",
|
||
revision="A",
|
||
)
|
||
self._project.add_drawing(drawing)
|
||
|
||
drawing.views = self._build_views()
|
||
# Carry user edits forward before repopulating the UI from the
|
||
# drawing (which would otherwise overwrite them).
|
||
self._sync_title_block_to_drawing()
|
||
self._drawing = drawing
|
||
self._populate_from_drawing(drawing)
|
||
try:
|
||
result = generate_drawing(drawing, self._project, self._kernel)
|
||
self.set_render_result(result)
|
||
except Exception as e:
|
||
self._status_label.setText(f"Generation failed: {e}")
|
||
|
||
# ── Dimension tools ───────────────────────────────────────────────────
|
||
|
||
def _on_tool_toggled(self, tool: str, checked: bool) -> None:
|
||
"""A dimension pick tool was toggled on/off."""
|
||
if checked:
|
||
self._first_pick = None
|
||
self._canvas.set_pick_mode(tool)
|
||
prompts = {
|
||
"distance": "Distance: click an edge or a circle centre",
|
||
"diameter": "Diameter: click a circle",
|
||
"angle": "Angle: click the first edge",
|
||
}
|
||
self._status_label.setText(prompts.get(tool, ""))
|
||
else:
|
||
self._canvas.set_pick_mode("")
|
||
|
||
def _on_delete_clicked(self) -> None:
|
||
self._first_pick = None
|
||
self._canvas.set_pick_mode("delete")
|
||
self._status_label.setText(
|
||
"Delete: click a manual dimension to remove it (Esc cancels)"
|
||
)
|
||
|
||
def _cancel_pick(self) -> None:
|
||
"""Deactivate all pick tools (Esc or a completed pick)."""
|
||
self._clear_tool_selection()
|
||
self._canvas.set_pick_mode("")
|
||
self._first_pick = None
|
||
|
||
def _clear_tool_selection(self) -> None:
|
||
"""Uncheck every dimension tool button.
|
||
|
||
``setChecked(False)`` is a no-op for the currently checked button
|
||
of an *exclusive* QButtonGroup, so exclusivity is dropped while
|
||
the buttons are cleared and restored afterwards.
|
||
"""
|
||
self._tool_group.setExclusive(False)
|
||
for btn in self._tool_buttons.values():
|
||
btn.blockSignals(True)
|
||
btn.setChecked(False)
|
||
btn.blockSignals(False)
|
||
self._tool_group.setExclusive(True)
|
||
|
||
def _on_auto_toggled(self, checked: bool) -> None:
|
||
if self._drawing is None:
|
||
return
|
||
self._drawing.auto_dimensions = checked
|
||
self._on_generate()
|
||
self.drawing_changed.emit()
|
||
|
||
def _on_clear_clicked(self) -> None:
|
||
if self._drawing is None:
|
||
return
|
||
remaining = [a for a in self._drawing.annotations if not a.dimension_kind]
|
||
removed = len(self._drawing.annotations) - len(remaining)
|
||
if not removed:
|
||
self._status_label.setText("No manual dimensions to clear")
|
||
return
|
||
self._drawing.annotations = remaining
|
||
self._drawing.modified_at = datetime.now()
|
||
self._on_generate()
|
||
self.drawing_changed.emit()
|
||
self._status_label.setText(f"Removed {removed} manual dimension(s)")
|
||
|
||
def _on_feature_picked(self, info: dict) -> None:
|
||
mode = self._canvas._pick_mode
|
||
if mode == "delete":
|
||
self._on_delete_pick(info)
|
||
elif mode == "diameter":
|
||
self._on_diameter_pick(info)
|
||
elif mode in ("distance", "angle"):
|
||
self._on_edge_pick(info, mode)
|
||
|
||
@staticmethod
|
||
def _pick_point(info: dict) -> Optional[Tuple[float, float]]:
|
||
"""The measurable point of a pick: a point pick is its point, a
|
||
circle pick counts as its centre; a bare segment is None."""
|
||
if info.get("kind") == "point":
|
||
return info["point"]
|
||
if info.get("kind") == "circle":
|
||
return info["center"]
|
||
return None
|
||
|
||
@classmethod
|
||
def _distance_anchors(
|
||
cls, first: dict, second: dict
|
||
) -> Tuple[Tuple[float, float], Tuple[float, float]]:
|
||
"""Anchor pair for a distance between two picks (model coords).
|
||
|
||
A pick may be a segment (edge), a circle (measured at its centre)
|
||
or a point (a picked circle centre). Point/segment mixes use the
|
||
closest point on the segment so the dimension lands perpendicular
|
||
to the edge, ISO style.
|
||
"""
|
||
fp = cls._pick_point(first)
|
||
sp = cls._pick_point(second)
|
||
if fp is not None and sp is not None:
|
||
return fp, sp
|
||
if fp is not None:
|
||
return fp, _closest_point_on_segment(fp, second["p1"], second["p2"])
|
||
if sp is not None:
|
||
return sp, _closest_point_on_segment(sp, first["p1"], first["p2"])
|
||
q1, q2, _d = _closest_points_on_segments(
|
||
first["p1"], first["p2"], second["p1"], second["p2"]
|
||
)
|
||
return q1, q2
|
||
|
||
def _on_edge_pick(self, info: dict, mode: str) -> None:
|
||
if (
|
||
self._first_pick is None
|
||
or info.get("view_id") != self._first_pick.get("view_id")
|
||
):
|
||
# First edge (or picked in a different view: restart there).
|
||
self._first_pick = info
|
||
self._status_label.setText(
|
||
"Select the second feature in the same view (Esc cancels)"
|
||
)
|
||
return
|
||
|
||
first = self._first_pick
|
||
view_id = info["view_id"]
|
||
|
||
if mode == "distance":
|
||
p1, p2 = self._distance_anchors(first, info)
|
||
dist = math.hypot(p2[0] - p1[0], p2[1] - p1[1])
|
||
if dist < 0.01:
|
||
self._status_label.setText(
|
||
"The two edges coincide — no distance to measure"
|
||
)
|
||
return
|
||
self._add_manual_dimension(
|
||
"length",
|
||
anchors=(p1, p2),
|
||
view_id=view_id,
|
||
direction=((p2[0] - p1[0]) / dist, (p2[1] - p1[1]) / dist),
|
||
done_msg=f"Distance dimension added: {dist:.2f}",
|
||
)
|
||
else: # angle
|
||
if first.get("kind") != "segment" or info.get("kind") != "segment":
|
||
self._status_label.setText(
|
||
"Angle needs two edges — cancel and pick edge lines"
|
||
)
|
||
self._first_pick = None
|
||
return
|
||
a1, a2 = first["p1"], first["p2"]
|
||
b1, b2 = info["p1"], info["p2"]
|
||
vertex = _line_intersection(a1, a2, b1, b2)
|
||
if vertex is None:
|
||
self._status_label.setText(
|
||
"The two edges are parallel — no angle to measure"
|
||
)
|
||
return
|
||
arm1 = _closest_point_on_segment(vertex, a1, a2)
|
||
arm2 = _closest_point_on_segment(vertex, b1, b2)
|
||
d1 = math.hypot(arm1[0] - vertex[0], arm1[1] - vertex[1])
|
||
d2 = math.hypot(arm2[0] - vertex[0], arm2[1] - vertex[1])
|
||
if d1 < 1e-6 or d2 < 1e-6:
|
||
self._status_label.setText(
|
||
"The edges only meet at an endpoint — no angle to measure"
|
||
)
|
||
return
|
||
self._add_manual_dimension(
|
||
"angle",
|
||
anchors=(vertex, arm1, arm2),
|
||
view_id=view_id,
|
||
done_msg="Angle dimension added",
|
||
)
|
||
|
||
def _on_diameter_pick(self, info: dict) -> None:
|
||
if info.get("kind") == "point" and info.get("radius"):
|
||
# Picked the centre mark of a circle.
|
||
cx, cy = info["point"]
|
||
r = info["radius"]
|
||
self._add_manual_dimension(
|
||
"diameter",
|
||
anchors=((cx - r, cy), (cx + r, cy)),
|
||
view_id=info["view_id"],
|
||
done_msg=f"Diameter dimension added: Ø{2 * r:.2f}",
|
||
)
|
||
return
|
||
if info.get("kind") != "circle":
|
||
return
|
||
cx, cy = info["center"]
|
||
r = info["radius"]
|
||
self._add_manual_dimension(
|
||
"diameter",
|
||
anchors=((cx - r, cy), (cx + r, cy)),
|
||
view_id=info["view_id"],
|
||
done_msg=f"Diameter dimension added: Ø{2 * r:.2f}",
|
||
)
|
||
|
||
def _on_delete_pick(self, info: dict) -> None:
|
||
key = info.get("candidate_key") or ""
|
||
self._canvas.set_pick_mode("")
|
||
if not key.startswith("manual:"):
|
||
self._status_label.setText(
|
||
"That dimension is auto-placed — untick Auto dimensions to remove it"
|
||
)
|
||
return
|
||
ann_id = key[len("manual:"):]
|
||
if self._drawing is None:
|
||
return
|
||
self._drawing.annotations = [
|
||
a for a in self._drawing.annotations if a.id != ann_id
|
||
]
|
||
self._drawing.modified_at = datetime.now()
|
||
self._on_generate()
|
||
self.drawing_changed.emit()
|
||
self._status_label.setText("Dimension removed")
|
||
|
||
def _add_manual_dimension(
|
||
self,
|
||
dimension_kind: str,
|
||
anchors: Sequence[Tuple[float, float]],
|
||
view_id: str,
|
||
direction: Optional[Tuple[float, float]] = None,
|
||
done_msg: str = "Dimension added",
|
||
) -> None:
|
||
"""Append a manual dimension annotation and regenerate."""
|
||
if self._drawing is None:
|
||
return
|
||
ann = DrawingAnnotation(
|
||
kind="dimension",
|
||
dimension_kind=dimension_kind,
|
||
view_id=view_id,
|
||
anchors=[(float(x), float(y)) for x, y in anchors],
|
||
direction=(float(direction[0]), float(direction[1]))
|
||
if direction
|
||
else None,
|
||
)
|
||
self._drawing.annotations.append(ann)
|
||
self._drawing.modified_at = datetime.now()
|
||
self._on_generate()
|
||
self.drawing_changed.emit()
|
||
self._cancel_pick()
|
||
self._status_label.setText(done_msg)
|
||
|
||
|
||
def _on_export(self, fmt: str) -> None:
|
||
"""Export the current render result to PDF or SVG."""
|
||
if self._drawing is None or self._render_result is None:
|
||
return
|
||
self._sync_views_to_drawing()
|
||
title = "Export PDF" if fmt == "pdf" else "Export SVG"
|
||
filter_ = "PDF Files (*.pdf)" if fmt == "pdf" else "SVG Files (*.svg)"
|
||
path, _ = QFileDialog.getSaveFileName(self, title, "", filter_)
|
||
if not path:
|
||
return
|
||
try:
|
||
if fmt == "pdf":
|
||
export_drawing_pdf(self._render_result, path)
|
||
else:
|
||
export_drawing_svg(self._render_result, path)
|
||
self._status_label.setText(f"Exported to {path}")
|
||
except Exception as e:
|
||
self._status_label.setText(f"Export failed: {e}")
|