""" 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}")