- tech draw draft

This commit is contained in:
bklronin
2026-08-16 15:54:41 +02:00
parent a00129603c
commit 1eee203ba8
5 changed files with 2202 additions and 76 deletions
+42 -44
View File
@@ -6,11 +6,8 @@
<component name="ChangeListManager"> <component name="ChangeListManager">
<list default="true" id="8f0bafd6-58a0-4b20-aa2b-ddc3ba278873" name="Changes" comment="- Operation highlighting, body highlighting"> <list default="true" id="8f0bafd6-58a0-4b20-aa2b-ddc3ba278873" name="Changes" comment="- Operation highlighting, body highlighting">
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" /> <change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/fluency/geometry_occ/sketch.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/geometry_occ/sketch.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/fluency/io/project_io.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/io/project_io.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/fluency/models/data_model.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/models/data_model.py" afterDir="false" /> <change beforePath="$PROJECT_DIR$/src/fluency/models/data_model.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/models/data_model.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/fluency/ui/main_window.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/ui/main_window.py" afterDir="false" /> <change beforePath="$PROJECT_DIR$/src/fluency/ui/main_window.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/ui/main_window.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/fluency/ui/sketch_widget.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/ui/sketch_widget.py" afterDir="false" />
</list> </list>
<option name="SHOW_DIALOG" value="false" /> <option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" /> <option name="HIGHLIGHT_CONFLICTS" value="true" />
@@ -47,47 +44,48 @@
<option name="hideEmptyMiddlePackages" value="true" /> <option name="hideEmptyMiddlePackages" value="true" />
<option name="showLibraryContents" value="true" /> <option name="showLibraryContents" value="true" />
</component> </component>
<component name="PropertiesComponent">{ <component name="PropertiesComponent"><![CDATA[{
&quot;keyToString&quot;: { "keyToString": {
&quot;Python.2dtest.executor&quot;: &quot;Run&quot;, "Python.2dtest.executor": "Run",
&quot;Python.3d_windows.executor&quot;: &quot;Run&quot;, "Python.3d_windows.executor": "Run",
&quot;Python.Unnamed.executor&quot;: &quot;Run&quot;, "Python.Unnamed.executor": "Run",
&quot;Python.base.executor&quot;: &quot;Run&quot;, "Python.base.executor": "Run",
&quot;Python.data_model.executor&quot;: &quot;Run&quot;, "Python.data_model.executor": "Run",
&quot;Python.debug_dragging.executor&quot;: &quot;Run&quot;, "Python.debug_dragging.executor": "Run",
&quot;Python.draw_widget2d.executor&quot;: &quot;Run&quot;, "Python.draw_widget2d.executor": "Run",
&quot;Python.draw_widget_solve.executor&quot;: &quot;Run&quot;, "Python.draw_widget_solve.executor": "Run",
&quot;Python.fluency.executor&quot;: &quot;Run&quot;, "Python.fluency.executor": "Run",
&quot;Python.fluencyb.executor&quot;: &quot;Run&quot;, "Python.fluencyb.executor": "Run",
&quot;Python.gl_widget.executor&quot;: &quot;Run&quot;, "Python.gl_widget.executor": "Run",
&quot;Python.gui_ui.executor&quot;: &quot;Run&quot;, "Python.gui_ui.executor": "Run",
&quot;Python.kernel.executor&quot;: &quot;Run&quot;, "Python.kernel.executor": "Run",
&quot;Python.main.executor&quot;: &quot;Run&quot;, "Python.main.executor": "Run",
&quot;Python.main_window.executor&quot;: &quot;Run&quot;, "Python.main_window.executor": "Run",
&quot;Python.meshtest.executor&quot;: &quot;Run&quot;, "Python.meshtest.executor": "Run",
&quot;Python.occ_renderer.executor&quot;: &quot;Run&quot;, "Python.occ_renderer.executor": "Run",
&quot;Python.occ_to_mesh.executor&quot;: &quot;Run&quot;, "Python.occ_to_mesh.executor": "Run",
&quot;Python.render_backend.executor&quot;: &quot;Run&quot;, "Python.render_backend.executor": "Run",
&quot;Python.side_fluency.executor&quot;: &quot;Run&quot;, "Python.side_fluency.executor": "Run",
&quot;Python.simple_mesh.executor&quot;: &quot;Run&quot;, "Python.simple_mesh.executor": "Run",
&quot;Python.sketch.executor&quot;: &quot;Run&quot;, "Python.sketch.executor": "Run",
&quot;Python.vtk_widget.executor&quot;: &quot;Run&quot;, "Python.technical_drawing_widget.executor": "Run",
&quot;Python.vulkan.executor&quot;: &quot;Run&quot;, "Python.vtk_widget.executor": "Run",
&quot;RunOnceActivity.OpenProjectViewOnStart&quot;: &quot;true&quot;, "Python.vulkan.executor": "Run",
&quot;RunOnceActivity.ShowReadmeOnStart&quot;: &quot;true&quot;, "RunOnceActivity.OpenProjectViewOnStart": "true",
&quot;RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252&quot;: &quot;true&quot;, "RunOnceActivity.ShowReadmeOnStart": "true",
&quot;RunOnceActivity.git.unshallow&quot;: &quot;true&quot;, "RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252": "true",
&quot;RunOnceActivity.typescript.service.memoryLimit.init&quot;: &quot;true&quot;, "RunOnceActivity.git.unshallow": "true",
&quot;codeWithMe.voiceChat.enabledByDefault&quot;: &quot;false&quot;, "RunOnceActivity.typescript.service.memoryLimit.init": "true",
&quot;git-widget-placeholder&quot;: &quot;feature/occ-migration&quot;, "codeWithMe.voiceChat.enabledByDefault": "false",
&quot;last_opened_file_path&quot;: &quot;/Volumes/Data_drive/Programming/fluency/src/fluency/Tesfiles&quot;, "git-widget-placeholder": "feature/occ-migration",
&quot;node.js.detected.package.eslint&quot;: &quot;true&quot;, "last_opened_file_path": "/Volumes/Data_drive/Programming/fluency",
&quot;node.js.selected.package.eslint&quot;: &quot;(autodetect)&quot;, "node.js.detected.package.eslint": "true",
&quot;node.js.selected.package.tslint&quot;: &quot;(autodetect)&quot;, "node.js.selected.package.eslint": "(autodetect)",
&quot;nodejs_package_manager_path&quot;: &quot;npm&quot;, "node.js.selected.package.tslint": "(autodetect)",
&quot;settings.editor.selected.configurable&quot;: &quot;project.propVCSSupport.DirectoryMappings&quot; "nodejs_package_manager_path": "npm",
"settings.editor.selected.configurable": "project.propVCSSupport.DirectoryMappings"
} }
}</component> }]]></component>
<component name="QodanaReportsService"> <component name="QodanaReportsService">
<option name="descriptions"> <option name="descriptions">
<ReportDescription localRun="true" path="/private/var/folders/kg/zm48w_r96yb68mlbzvb9gtq40000gn/T/qodana_output/qodana.sarif.json" reportGuid="5f5b823c-c594-48c5-ae1f-062e30303918" reportId="fluency/qodana/2024-02-04" /> <ReportDescription localRun="true" path="/private/var/folders/kg/zm48w_r96yb68mlbzvb9gtq40000gn/T/qodana_output/qodana.sarif.json" reportGuid="5f5b823c-c594-48c5-ae1f-062e30303918" reportId="fluency/qodana/2024-02-04" />
@@ -95,8 +93,8 @@
</component> </component>
<component name="RecentsManager"> <component name="RecentsManager">
<key name="CopyFile.RECENT_KEYS"> <key name="CopyFile.RECENT_KEYS">
<recent name="$PROJECT_DIR$/src/fluency/Tesfiles" />
<recent name="$PROJECT_DIR$" /> <recent name="$PROJECT_DIR$" />
<recent name="$PROJECT_DIR$/src/fluency/Tesfiles" />
<recent name="$PROJECT_DIR$/src/fluency" /> <recent name="$PROJECT_DIR$/src/fluency" />
<recent name="$PROJECT_DIR$/src/fluency/rendering" /> <recent name="$PROJECT_DIR$/src/fluency/rendering" />
<recent name="$PROJECT_DIR$/drawing_modules" /> <recent name="$PROJECT_DIR$/drawing_modules" />
+81 -30
View File
@@ -800,36 +800,6 @@ class Project:
"""Look up a component by id across all project components.""" """Look up a component by id across all project components."""
return self.components.get(component_id) return self.components.get(component_id)
def add_component(self, component: Optional[Component] = None) -> Component:
"""Add a component to the project."""
if component is None:
component = Component(name=f"Component {len(self.components) + 1}")
self.components[component.id] = component
if self.active_component is None:
self.active_component = component.id
self.modified_at = datetime.now()
return component
def remove_component(self, component_id: str) -> bool:
"""Remove a component from the project."""
if component_id in self.components:
del self.components[component_id]
if self.active_component == component_id:
self.active_component = next(iter(self.components.keys()), None)
self.modified_at = datetime.now()
return True
return False
def get_active_component(self) -> Optional[Component]:
"""Get the currently active component."""
if self.active_component and self.active_component in self.components:
return self.components[self.active_component]
return None
def set_active_component(self, component_id: Optional[str]) -> None:
"""Set the active component."""
self.active_component = component_id
self.modified_at = datetime.now()
def export_step(self, filepath: str) -> bool: def export_step(self, filepath: str) -> bool:
"""Export all visible bodies to STEP.""" """Export all visible bodies to STEP."""
@@ -898,3 +868,84 @@ class Project:
for comp in self.components.values(): for comp in self.components.values():
sketches.extend(comp.sketches.values()) sketches.extend(comp.sketches.values())
return sketches return sketches
def compute_source_fingerprint(
self, source_kind: str, source_id: str
) -> str:
"""Compute a simple fingerprint for a source reference."""
import hashlib
data = f"{source_kind}:{source_id}"
return hashlib.sha256(data.encode()).hexdigest()[:16]
# ── Technical Drawing models ───────────────────────────────────────────────
@dataclass
class DrawingView:
"""One projected view in a technical drawing."""
id: str = field(default_factory=lambda: str(uuid.uuid4()))
kind: str = "front" # front, back, top, bottom, right, left, isometric, custom
name: Optional[str] = None # human-readable label; defaults from kind
# View direction and up vector in world coords (for custom views).
# Ignored when kind is one of the standard presets.
direction: Optional[Tuple[float, float, float]] = None
up_vector: Optional[Tuple[float, float, float]] = None
show_hidden_lines: bool = True
show_centerlines: bool = True
scale: float = 1.0
# Sheet position (mm from sheet origin) — set by layout engine.
sheet_origin: Tuple[float, float] = (0.0, 0.0)
@dataclass
class DrawingAnnotation:
"""One annotation (dimension or note) on a technical drawing."""
id: str = field(default_factory=lambda: str(uuid.uuid4()))
kind: str = "dimension" # dimension, note, tolerance, surface_finish, weld_symbol
text: Optional[str] = None
visible: bool = True
# References to DrawingCandidate keys this annotation is bound to.
references: List[str] = field(default_factory=list)
# Sheet position (mm). For dimensions, anchor point; for notes, placement.
sheet_position: Tuple[float, float] = (0.0, 0.0)
# Associated view id (empty means global/note block).
view_id: str = ""
@dataclass
class TechnicalDrawing:
"""A complete technical drawing definition."""
id: str = field(default_factory=lambda: str(uuid.uuid4()))
name: str = "Untitled Drawing"
# Source geometry reference.
source_kind: str = "component" # component, assembly
source_id: str = ""
views: List[DrawingView] = field(default_factory=list)
annotations: List[DrawingAnnotation] = field(default_factory=list)
# Title block metadata.
title: str = ""
part_number: str = ""
material: str = ""
revision: str = ""
notes: str = ""
# Sheet size (A0..A4 or custom mm). Default A3.
sheet_size: str = "A3"
units: str = "mm" # mm, in
# Auto-generation flags for future use.
auto_dimensions: bool = True
auto_views: bool = True
created_at: datetime = field(default_factory=datetime.now)
modified_at: datetime = field(default_factory=datetime.now)
File diff suppressed because it is too large Load Diff
+91 -2
View File
@@ -1911,6 +1911,13 @@ class MainWindow(QMainWindow):
self._render_tab = RenderTabContent() self._render_tab = RenderTabContent()
self._ui.InputTab.addTab(self._render_tab, "Render") self._ui.InputTab.addTab(self._render_tab, "Render")
# ── Drawing tab (adds "Drawing" tab to InputTab) ───────────
from fluency.ui.technical_drawing_widget import TechnicalDrawingWidget
self._drawing_tab = TechnicalDrawingWidget()
self._ui.InputTab.addTab(self._drawing_tab, "Drawing")
self._drawing_tab.set_project(self._project, self._kernel)
self._refresh_drawing_source_catalog()
# Component buttons (dynamically generated per component, not in UI). # Component buttons (dynamically generated per component, not in UI).
# Wrapped in a QScrollArea so many components can scroll horizontally. # Wrapped in a QScrollArea so many components can scroll horizontally.
@@ -2515,6 +2522,7 @@ class MainWindow(QMainWindow):
logger.info(f"Created component: {comp.name}") logger.info(f"Created component: {comp.name}")
# No sketch in the fresh component — drop the world triad. # No sketch in the fresh component — drop the world triad.
self._sync_sketch_gizmo() self._sync_sketch_gizmo()
self._refresh_drawing_source_catalog()
def _delete_component(self): def _delete_component(self):
idx = self._get_active_component_index() idx = self._get_active_component_index()
@@ -2533,6 +2541,7 @@ class MainWindow(QMainWindow):
self._refresh_lists() self._refresh_lists()
logger.info("Deleted component") logger.info("Deleted component")
self._refresh_drawing_source_catalog()
def _on_component_button_clicked(self): def _on_component_button_clicked(self):
idx = self._get_active_component_index() idx = self._get_active_component_index()
@@ -2542,6 +2551,13 @@ class MainWindow(QMainWindow):
self._assembly_view_active = False self._assembly_view_active = False
self._refresh_lists() self._refresh_lists()
self._redraw_bodies() self._redraw_bodies()
self._refresh_drawing_source_catalog()
# Propagate the new selection to the drawing tab.
if self._current_component:
try:
self._drawing_tab.set_active_component(self._current_component)
except Exception as e:
logger.warning(f"Failed to update drawing tab source: {e}")
# Scroll to the selected button. # Scroll to the selected button.
if 0 <= idx < len(self._component_buttons): if 0 <= idx < len(self._component_buttons):
_scroll_to_button(self._component_buttons[idx], self._component_scroll) _scroll_to_button(self._component_buttons[idx], self._component_scroll)
@@ -2571,6 +2587,14 @@ class MainWindow(QMainWindow):
self._body_list.addItem(item) self._body_list.addItem(item)
self._refresh_operations_list() self._refresh_operations_list()
def _refresh_drawing_source_catalog(self) -> None:
"""Update the drawing tab's list of available components/assemblies."""
component_ids = list(self._project.components.keys())
assembly_ids = list(self._project.assemblies.keys()) if self._project.assemblies else []
try:
self._drawing_tab.set_source_catalog(component_ids, assembly_ids)
except Exception as e:
logger.warning(f"Failed to refresh drawing source catalog: {e}")
# ── Body operations list (per-body feature history) ─────────────────── # ── Body operations list (per-body feature history) ───────────────────
@@ -7306,6 +7330,13 @@ class MainWindow(QMainWindow):
self._btn_to_sketch.setEnabled(False) self._btn_to_sketch.setEnabled(False)
self._create_initial_component() self._create_initial_component()
# Re-point the drawing tab at the new project — its combo and
# project reference still belong to the previous project.
self._drawing_tab.set_project(self._project, self._kernel)
self._refresh_drawing_source_catalog()
if self._current_component:
self._drawing_tab.set_active_component(self._current_component)
finally: finally:
self._suspend_dirty = False self._suspend_dirty = False
self._project_path = None self._project_path = None
@@ -7648,6 +7679,14 @@ class MainWindow(QMainWindow):
if sk.occ_sketch is not None: if sk.occ_sketch is not None:
self._sketch_widget.set_sketch(sk.occ_sketch) self._sketch_widget.set_sketch(sk.occ_sketch)
self._current_sketch = sk self._current_sketch = sk
# Re-point the drawing tab at the new project/kernel and sync
# its source selection — it still holds the references captured
# at setup, which made Generate draw from the previous project.
self._drawing_tab.set_project(self._project, self._kernel)
self._refresh_drawing_source_catalog()
if self._current_component:
self._drawing_tab.set_active_component(self._current_component)
finally: finally:
self._suspend_dirty = False self._suspend_dirty = False
@@ -7862,10 +7901,60 @@ class MainWindow(QMainWindow):
else: else:
self._render_tab.set_assembly(assembly_parts, camera=viewport_camera) self._render_tab.set_assembly(assembly_parts, camera=viewport_camera)
def _load_drawing_tab_source(self) -> None:
"""Auto-populate the drawing tab with the current component or assembly."""
from fluency.models.data_model import TechnicalDrawing, DrawingView
from fluency.technical_drawing import generate_drawing, _STANDARD_VIEWS
source_kind = "component"
source_id = ""
# Prefer _current_component (set by component button clicks).
comp = self._current_component or self._project.get_active_component()
if comp and any(b.visible and b.geometry for b in comp.bodies.values()):
source_kind = "component"
source_id = comp.id
elif self._project.assemblies:
asm_id = next(iter(self._project.assemblies.keys()))
source_kind = "assembly"
source_id = asm_id
if not source_id:
return
# Build default drawing with front, top, right, isometric views.
views = []
for kind in ("front", "top", "right", "isometric"):
dir_, up_ = _STANDARD_VIEWS[kind]
vname = kind.capitalize() if kind != "isometric" else "Isometric"
views.append(DrawingView(kind=kind, name=vname, direction=dir_, up_vector=up_))
drawing = TechnicalDrawing(
name=f"Drawing of {comp.name if comp else 'Assembly'}",
source_kind=source_kind,
source_id=source_id,
views=views,
title=comp.name if comp else "Assembly Drawing",
part_number="",
material="",
revision="A",
)
self._drawing_tab.set_drawing(drawing)
# Auto-generate.
try:
result = generate_drawing(drawing, self._project, self._kernel)
self._drawing_tab.set_render_result(result)
except Exception as e:
logger.warning(f"Failed to auto-generate drawing: {e}")
def _on_tab_changed(self, index: int) -> None: def _on_tab_changed(self, index: int) -> None:
"""When the user switches to the Render tab, auto-load the selected body.""" """When the user switches to Render or Drawing tab, auto-load selected geometry."""
if self._ui.InputTab.widget(index) is self._render_tab: widget = self._ui.InputTab.widget(index)
if widget is self._render_tab:
self._load_render_tab_shape() self._load_render_tab_shape()
elif widget is self._drawing_tab:
self._load_drawing_tab_source()
# ─── Sketch Undo/Redo ───────────────────────────────────────────────── # ─── Sketch Undo/Redo ─────────────────────────────────────────────────
+800
View File
@@ -0,0 +1,800 @@
"""
Technical Drawing workbench widget.
Embeddable QWidget for creating, editing, and exporting technical drawings
from component and assembly geometry. Designed to sit in MainWindow's
InputTab alongside Sketch, Code, and Render tabs.
"""
from __future__ import annotations
from typing import Dict, List, Optional
from PySide6.QtCore import Qt, Signal, QRectF, QPointF
from PySide6.QtGui import QPainter, QColor, QFont, QMouseEvent, QWheelEvent
from PySide6.QtWidgets import (
QWidget,
QVBoxLayout,
QHBoxLayout,
QSplitter,
QPushButton,
QCheckBox,
QRadioButton,
QButtonGroup,
QComboBox,
QLabel,
QLineEdit,
QGroupBox,
QScrollArea,
QTableWidget,
QTableWidgetItem,
QHeaderView,
QTextEdit,
QAbstractItemView,
QSizePolicy,
)
from fluency.models.data_model import TechnicalDrawing, DrawingView, DrawingAnnotation
from fluency.technical_drawing import (
DrawingRenderResult,
DrawingPrimitive,
generate_drawing,
render_drawing,
export_drawing_svg,
export_drawing_pdf,
_STANDARD_VIEW_ROWS,
)
# Sheet layout constants.
_A3_MM_W = 420.0
_A3_MM_H = 297.0
class DrawingCanvas(QWidget):
"""Custom QPainter canvas with zoom/pan for technical drawing display."""
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.setMinimumSize(400, 300)
self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
self.setMouseTracking(True)
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 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
# Compute sheet rect in device coords with zoom/pan.
aspect = _A3_MM_W / _A3_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
sheet_rect = QRectF(ox, oy, draw_w, draw_h)
render_drawing(painter, self._render_result, sheet_rect)
painter.end()
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)
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.ArrowCursor)
class TechnicalDrawingWidget(QWidget):
"""Embeddable technical drawing workbench."""
drawing_changed = Signal()
source_requested = Signal(str, str) # source_kind, source_id
export_requested = Signal(str, object) # format ("pdf"/"svg"), drawing
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
self._component_ids: List[str] = []
self._assembly_ids: List[str] = []
self._view_checkboxes: Dict[str, QCheckBox] = {}
self._hidden_line_checks: Dict[str, QCheckBox] = {}
self._selected_dim_keys: set = set()
self._centerline_checks: Dict[str, QCheckBox] = {}
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_source_catalog(
self, component_ids: List[str], assembly_ids: List[str]
) -> None:
"""Update the source combo with available components/assemblies."""
self._component_ids = component_ids
self._assembly_ids = assembly_ids
self._rebuild_source_combo()
def set_active_component(self, component) -> None:
"""Update the drawing tab to use the given component/assembly as source.
Selects it in the combo and regenerates."""
self._mode_component_btn.setChecked(True)
# Ensure the combo is in component mode and has this component selected.
self._rebuild_source_combo()
idx = self._source_combo.findData(component.id)
if idx >= 0:
self._source_combo.setCurrentIndex(idx)
# Delegate to the same path as the Generate button.
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._populate_from_drawing(drawing)
self._canvas.set_render_result(None)
self._generate_btn.setEnabled(self._drawing is not None)
self._refresh_annotation_table()
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._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()
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, "
f"{len(result.candidates)} candidates"
)
if result.warnings:
self._status_label.setText(
self._status_label.text() + f" ({len(result.warnings)} warnings)"
)
self._export_pdf_btn.setEnabled(True)
self._export_svg_btn.setEnabled(True)
else:
self._export_pdf_btn.setEnabled(False)
self._export_svg_btn.setEnabled(False)
self._refresh_dim_table()
# ── 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)
# Source selection.
src_group = QGroupBox("Source")
src_layout = QVBoxLayout(src_group)
mode_layout = QHBoxLayout()
self._mode_component_btn = QRadioButton("Component")
self._mode_assembly_btn = QRadioButton("Assembly")
self._mode_component_btn.setChecked(True)
# Rebuild the combo on user click so the selected id always matches
# the active mode — otherwise Generate combines a kind from one mode
# with an id from the other.
self._mode_component_btn.clicked.connect(self._rebuild_source_combo)
self._mode_assembly_btn.clicked.connect(self._rebuild_source_combo)
mode_layout.addWidget(self._mode_component_btn)
mode_layout.addWidget(self._mode_assembly_btn)
src_layout.addLayout(mode_layout)
self._source_combo = QComboBox()
src_layout.addWidget(self._source_combo)
self._use_selection_btn = QPushButton("Use Current Selection")
self._use_selection_btn.clicked.connect(self._on_use_selection)
src_layout.addWidget(self._use_selection_btn)
left_layout.addWidget(src_group)
# 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)
# Match the DrawingView model defaults (hidden lines + centerlines
# on) so Generate produces the same drawing as the tab-switch
# auto-load in MainWindow._load_drawing_tab_source.
for cb in self._hidden_line_checks.values():
cb.setChecked(True)
for cb in self._centerline_checks.values():
cb.setChecked(True)
left_layout.addWidget(views_group)
# Sheet / title block.
sheet_group = QGroupBox("Title Block")
sheet_layout = QVBoxLayout(sheet_group)
for lbl, attr in [
("Title", "title"),
("Part No.", "part_number"),
("Material", "material"),
("Revision", "revision"),
]:
row = QHBoxLayout()
row.addWidget(QLabel(lbl))
edit = QLineEdit()
setattr(self, f"_{attr}_edit", edit)
row.addWidget(edit)
sheet_layout.addLayout(row)
self._title_edit = self.__dict__.get("_title_edit", QLineEdit())
self._part_no_edit = self.__dict__.get("_part_no_edit", QLineEdit())
self._material_edit = self.__dict__.get("_material_edit", QLineEdit())
self._revision_edit = self.__dict__.get("_revision_edit", QLineEdit())
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._auto_generate_btn = QPushButton("Auto-Generate Drawing")
self._auto_generate_btn.setToolTip(
"Create a new drawing from current source with optimal views and dimensions"
)
self._auto_generate_btn.clicked.connect(self._on_auto_generate)
actions_layout.addWidget(self._auto_generate_btn)
self._generate_btn = QPushButton("Generate / Regenerate")
self._generate_btn.setEnabled(True)
self._generate_btn.clicked.connect(self._on_generate)
actions_layout.addWidget(self._generate_btn)
# Dimension options.
dim_row = QHBoxLayout()
self._auto_dim_check = QCheckBox("Auto dimensions")
self._auto_dim_check.setChecked(True)
dim_row.addWidget(self._auto_dim_check)
actions_layout.addLayout(dim_row)
self._save_btn = QPushButton("Save Drawing")
self._save_btn.clicked.connect(self._on_save)
actions_layout.addWidget(self._save_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 + annotation table ───────────────────────
right = QWidget()
right_layout = QVBoxLayout(right)
right_layout.setContentsMargins(0, 0, 0, 0)
self._canvas = DrawingCanvas()
right_layout.addWidget(self._canvas, 1)
# Annotation table.
ann_group = QGroupBox("Annotations")
ann_layout = QVBoxLayout(ann_group)
self._annotation_table = QTableWidget(0, 5)
self._annotation_table.setHorizontalHeaderLabels(
["Kind", "Value / Text", "Visible", "Orphaned", "Action"]
)
self._annotation_table.horizontalHeader().setSectionResizeMode(
QHeaderView.Stretch
)
self._annotation_table.setMaximumHeight(150)
self._annotation_table.setSelectionBehavior(QAbstractItemView.SelectRows)
ann_layout.addWidget(self._annotation_table)
add_ann_row = QHBoxLayout()
add_ann_row.addWidget(QLabel("Add:"))
self._add_note_btn = QPushButton("Note")
self._add_note_btn.clicked.connect(self._on_add_note)
add_ann_row.addWidget(self._add_note_btn)
add_ann_row.addStretch()
ann_layout.addLayout(add_ann_row)
right_layout.addWidget(ann_group)
# Dimension candidates table (for user selection).
dim_group = QGroupBox("Dimension Candidates")
dim_layout = QVBoxLayout(dim_group)
self._dim_table = QTableWidget(0, 5)
self._dim_table.setHorizontalHeaderLabels(["View", "Kind", "Value", "Include", "Action"])
self._dim_table.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch)
self._dim_table.setMaximumHeight(120)
self._dim_table.setSelectionBehavior(QAbstractItemView.SelectRows)
dim_layout.addWidget(self._dim_table)
self._refresh_dim_table()
right_layout.addWidget(dim_group)
splitter = QSplitter(Qt.Horizontal)
splitter.addWidget(scroll)
splitter.addWidget(right)
splitter.setStretchFactor(0, 0)
splitter.setStretchFactor(1, 1)
layout.addWidget(splitter)
# ── Internal helpers ──────────────────────────────────────────────────
def _rebuild_source_combo(self) -> None:
self._source_combo.clear()
if self._mode_component_btn.isChecked():
for cid in self._component_ids:
self._source_combo.addItem(cid, userData=cid)
else:
for aid in self._assembly_ids:
self._source_combo.addItem(aid, userData=aid)
def _resolve_source(self):
"""Resolve ``(source_kind, source_id)`` from the current UI state.
Prefers the combo selection when it still refers to an id present in
the current project. Otherwise falls back to the project's active
component, then the first assembly mirroring the tab-switch
auto-load in ``MainWindow._load_drawing_tab_source``. Returns
``(None, None)`` when no source is available.
"""
source_kind = (
"component" if self._mode_component_btn.isChecked() else "assembly"
)
source_id = self._source_combo.currentData()
# The combo may still hold an id from a previous project after a
# project swap — treat that as unselected.
catalog = (
self._project.components
if source_kind == "component"
else self._project.assemblies
)
if source_id not in catalog:
source_id = None
if not source_id:
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
return source_kind, source_id
def _populate_from_drawing(self, drawing: TechnicalDrawing) -> None:
"""Sync UI controls from a drawing definition."""
# Block checkbox signals during population to avoid triggering
# _on_view_toggled mid-update (which would call _on_generate with
# partially-set checkboxes and clear the views).
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:
# Source mode.
if drawing.source_kind == "component":
self._mode_component_btn.setChecked(True)
else:
self._mode_assembly_btn.setChecked(True)
# Select source in combo (match by text since ID may not be in the list yet).
idx = self._source_combo.findText(drawing.source_id)
if idx >= 0:
self._source_combo.setCurrentIndex(idx)
else:
# Fallback: set userData directly so currentData() returns it.
self._source_combo.clear()
if drawing.source_kind == "component":
for cid in self._component_ids:
self._source_combo.addItem(cid, userData=cid)
else:
for aid in self._assembly_ids:
self._source_combo.addItem(aid, userData=aid)
idx = self._source_combo.findText(drawing.source_id)
if idx >= 0:
self._source_combo.setCurrentIndex(idx)
# Views.
enabled_views = {v.kind for v in drawing.views}
for vid, cb in self._view_checkboxes.items():
cb.setChecked(vid in enabled_views)
for v in drawing.views:
if v.kind in self._hidden_line_checks:
self._hidden_line_checks[v.kind].setChecked(v.show_hidden_lines)
if v.kind in self._centerline_checks:
self._centerline_checks[v.kind].setChecked(v.show_centerlines)
# Title block.
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)
self._refresh_annotation_table()
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
new_views: List[DrawingView] = []
for vid, vname, vdir, vup in _STANDARD_VIEW_ROWS:
if not self._view_checkboxes[vid].isChecked():
continue
# Keep existing view if present, else create.
existing = None
for v in self._drawing.views:
if v.kind == vid:
existing = v
break
if existing is not None:
existing.show_hidden_lines = self._hidden_line_checks[vid].isChecked()
existing.show_centerlines = self._centerline_checks[vid].isChecked()
new_views.append(existing)
else:
new_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(),
)
)
self._drawing.views = new_views
def _refresh_annotation_table(self) -> None:
"""Refresh annotation table from current drawing."""
self._annotation_table.setRowCount(0)
if self._drawing is None:
return
for i, ann in enumerate(self._drawing.annotations):
self._annotation_table.insertRow(i)
self._annotation_table.setItem(
i, 0, QTableWidgetItem(ann.kind)
)
self._annotation_table.setItem(
i, 1, QTableWidgetItem(
ann.text or ""
)
)
vis_cb = QCheckBox()
vis_cb.setChecked(ann.visible)
vis_cb.toggled.connect(
lambda checked, a=ann: setattr(a, "visible", checked)
)
self._annotation_table.setCellWidget(i, 2, vis_cb)
orphan_label = QLabel("")
self._annotation_table.setCellWidget(i, 3, orphan_label)
del_btn = QPushButton("×")
del_btn.setFixedWidth(24)
del_btn.clicked.connect(
lambda checked=False, aid=ann.id: self._delete_annotation(aid)
)
self._annotation_table.setCellWidget(i, 4, del_btn)
def _refresh_dim_table(self) -> None:
"""Refresh the dimension candidates table from render result."""
self._dim_table.setRowCount(0)
if self._render_result is None:
return
for i, c in enumerate(self._render_result.candidates):
self._dim_table.insertRow(i)
self._dim_table.setItem(i, 0, QTableWidgetItem(c.view_id))
self._dim_table.setItem(i, 1, QTableWidgetItem(c.kind))
self._dim_table.setItem(i, 2, QTableWidgetItem(c.label))
inc_cb = QCheckBox()
inc_cb.setChecked(c.key in self._selected_dim_keys)
inc_cb.toggled.connect(lambda checked, k=c.key: self._on_dim_include(k, checked))
self._dim_table.setCellWidget(i, 3, inc_cb)
del_btn = QPushButton("×")
del_btn.setFixedWidth(24)
del_btn.clicked.connect(lambda checked=False, k=c.key: self._on_dim_remove(k))
self._dim_table.setCellWidget(i, 4, del_btn)
def _on_dim_include(self, key: str, checked: bool) -> None:
if checked:
self._selected_dim_keys.add(key)
else:
self._selected_dim_keys.discard(key)
def _on_view_toggled(self, vid: str) -> None:
"""Regenerate when any view/HL/CL checkbox changes."""
if self._drawing is not None and self._project is not None and self._kernel is not None:
try:
result = generate_drawing(self._drawing, self._project, self._kernel)
self.set_render_result(result)
except Exception as e:
self._status_label.setText(f"Regeneration failed: {e}")
def _delete_annotation(self, ann_id: str) -> None:
if self._drawing is None:
return
self._drawing.annotations = [
a for a in self._drawing.annotations if a.id != ann_id
]
self._refresh_annotation_table()
# ── Slots ──────────────────────────────────────────────────────────────
def _on_use_selection(self) -> None:
src_kind = (
"component" if self._mode_component_btn.isChecked() else "assembly"
)
self.source_requested.emit(src_kind, "")
def _on_auto_generate(self) -> None:
"""Create a new drawing from current source with optimal views."""
from fluency.models.data_model import TechnicalDrawing, DrawingView
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 not source_id:
self._status_label.setText("No source selected")
return
# Build default drawing with standard views.
views = []
for vid, vname, vdir, vup in _STANDARD_VIEW_ROWS:
if self._view_checkboxes[vid].isChecked():
views.append(
DrawingView(
kind=vid,
name=vname,
direction=vdir,
show_hidden_lines=self._hidden_line_checks[vid].isChecked(),
show_centerlines=self._centerline_checks[vid].isChecked(),
)
)
comp = None
if source_kind == "component":
comp = self._project.get_component_by_id(source_id)
drawing = TechnicalDrawing(
name=f"Drawing of {comp.name if comp else 'Assembly'}",
source_kind=source_kind,
source_id=source_id,
views=views,
title=comp.name if comp else "Assembly Drawing",
part_number="",
material="",
revision="A",
auto_dimensions=self._auto_dim_check.isChecked(),
)
self._drawing = drawing
self._populate_from_drawing(drawing)
self._on_generate()
def _on_generate(self) -> None:
"""Generate the drawing — always re-reads the current source and
builds a fresh TechnicalDrawing, same as tab-switching."""
from fluency.models.data_model import TechnicalDrawing, DrawingView
if self._project is None or self._kernel is None:
self._status_label.setText("No project/kernel available")
return
# Determine source: prefer combo selection, fall back to the
# project's active component / first assembly (see _resolve_source).
source_kind, source_id = self._resolve_source()
if not source_id:
self._status_label.setText("No source available for drawing")
return
# Build views from current checkbox state.
views = []
for vid, vname, vdir, vup in _STANDARD_VIEW_ROWS:
if self._view_checkboxes[vid].isChecked():
views.append(
DrawingView(
kind=vid,
name=vname,
direction=vdir,
up_vector=vup,
show_hidden_lines=self._hidden_line_checks[vid].isChecked(),
show_centerlines=self._centerline_checks[vid].isChecked(),
)
)
if not views:
for vid, vname, vdir, vup in _STANDARD_VIEW_ROWS:
views.append(
DrawingView(kind=vid, name=vname, direction=vdir, up_vector=vup)
)
# Resolve component name for the title.
comp = None
if source_kind == "component":
comp = self._project.get_component_by_id(source_id)
drawing = TechnicalDrawing(
name=f"Drawing of {comp.name if comp else 'Assembly'}",
source_kind=source_kind,
source_id=source_id,
views=views,
title=comp.name if comp else "Assembly Drawing",
part_number="",
material="",
revision="A",
auto_dimensions=self._auto_dim_check.isChecked(),
)
self._drawing = drawing
self._populate_from_drawing(drawing)
try:
result = generate_drawing(drawing, self._project, self._kernel)
self.set_render_result(result)
self.drawing_changed.emit()
except Exception as e:
self._status_label.setText(f"Generation failed: {e}")
def _on_export(self, fmt: str) -> None:
if self._drawing is None:
return
self._sync_views_to_drawing()
self.export_requested.emit(fmt, self._drawing)
def _on_add_note(self) -> None:
if self._drawing is None:
return
ann = DrawingAnnotation(kind="note", text="New note")
self._drawing.annotations.append(ann)
self._refresh_annotation_table()
def _on_save(self) -> None:
"""Save the current drawing."""
if self._drawing is None:
return
self._sync_views_to_drawing()
from pathlib import Path
from qtpy.QtWidgets import QFileDialog
path, _ = QFileDialog.getSaveFileName(
self, "Save Drawing", "", "Drawing Files (*.drawing.json)"
)
if not path:
return
try:
Path(path).write_text(self._drawing.model_dump_json(indent=2))
self._status_label.setText(f"Saved to {path}")
except Exception as e:
self._status_label.setText(f"Save failed: {e}")