Files
fluencyCAD/src/fluency/ui/technical_drawing_widget.py
T
bklronin 9abeb6266a - Assembly instaiated operations
- assembly forward proagation
2026-08-19 20:14:14 +02:00

1083 lines
42 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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}")