- tech draw draft
This commit is contained in:
Generated
+42
-44
@@ -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[{
|
||||||
"keyToString": {
|
"keyToString": {
|
||||||
"Python.2dtest.executor": "Run",
|
"Python.2dtest.executor": "Run",
|
||||||
"Python.3d_windows.executor": "Run",
|
"Python.3d_windows.executor": "Run",
|
||||||
"Python.Unnamed.executor": "Run",
|
"Python.Unnamed.executor": "Run",
|
||||||
"Python.base.executor": "Run",
|
"Python.base.executor": "Run",
|
||||||
"Python.data_model.executor": "Run",
|
"Python.data_model.executor": "Run",
|
||||||
"Python.debug_dragging.executor": "Run",
|
"Python.debug_dragging.executor": "Run",
|
||||||
"Python.draw_widget2d.executor": "Run",
|
"Python.draw_widget2d.executor": "Run",
|
||||||
"Python.draw_widget_solve.executor": "Run",
|
"Python.draw_widget_solve.executor": "Run",
|
||||||
"Python.fluency.executor": "Run",
|
"Python.fluency.executor": "Run",
|
||||||
"Python.fluencyb.executor": "Run",
|
"Python.fluencyb.executor": "Run",
|
||||||
"Python.gl_widget.executor": "Run",
|
"Python.gl_widget.executor": "Run",
|
||||||
"Python.gui_ui.executor": "Run",
|
"Python.gui_ui.executor": "Run",
|
||||||
"Python.kernel.executor": "Run",
|
"Python.kernel.executor": "Run",
|
||||||
"Python.main.executor": "Run",
|
"Python.main.executor": "Run",
|
||||||
"Python.main_window.executor": "Run",
|
"Python.main_window.executor": "Run",
|
||||||
"Python.meshtest.executor": "Run",
|
"Python.meshtest.executor": "Run",
|
||||||
"Python.occ_renderer.executor": "Run",
|
"Python.occ_renderer.executor": "Run",
|
||||||
"Python.occ_to_mesh.executor": "Run",
|
"Python.occ_to_mesh.executor": "Run",
|
||||||
"Python.render_backend.executor": "Run",
|
"Python.render_backend.executor": "Run",
|
||||||
"Python.side_fluency.executor": "Run",
|
"Python.side_fluency.executor": "Run",
|
||||||
"Python.simple_mesh.executor": "Run",
|
"Python.simple_mesh.executor": "Run",
|
||||||
"Python.sketch.executor": "Run",
|
"Python.sketch.executor": "Run",
|
||||||
"Python.vtk_widget.executor": "Run",
|
"Python.technical_drawing_widget.executor": "Run",
|
||||||
"Python.vulkan.executor": "Run",
|
"Python.vtk_widget.executor": "Run",
|
||||||
"RunOnceActivity.OpenProjectViewOnStart": "true",
|
"Python.vulkan.executor": "Run",
|
||||||
"RunOnceActivity.ShowReadmeOnStart": "true",
|
"RunOnceActivity.OpenProjectViewOnStart": "true",
|
||||||
"RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252": "true",
|
"RunOnceActivity.ShowReadmeOnStart": "true",
|
||||||
"RunOnceActivity.git.unshallow": "true",
|
"RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252": "true",
|
||||||
"RunOnceActivity.typescript.service.memoryLimit.init": "true",
|
"RunOnceActivity.git.unshallow": "true",
|
||||||
"codeWithMe.voiceChat.enabledByDefault": "false",
|
"RunOnceActivity.typescript.service.memoryLimit.init": "true",
|
||||||
"git-widget-placeholder": "feature/occ-migration",
|
"codeWithMe.voiceChat.enabledByDefault": "false",
|
||||||
"last_opened_file_path": "/Volumes/Data_drive/Programming/fluency/src/fluency/Tesfiles",
|
"git-widget-placeholder": "feature/occ-migration",
|
||||||
"node.js.detected.package.eslint": "true",
|
"last_opened_file_path": "/Volumes/Data_drive/Programming/fluency",
|
||||||
"node.js.selected.package.eslint": "(autodetect)",
|
"node.js.detected.package.eslint": "true",
|
||||||
"node.js.selected.package.tslint": "(autodetect)",
|
"node.js.selected.package.eslint": "(autodetect)",
|
||||||
"nodejs_package_manager_path": "npm",
|
"node.js.selected.package.tslint": "(autodetect)",
|
||||||
"settings.editor.selected.configurable": "project.propVCSSupport.DirectoryMappings"
|
"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" />
|
||||||
|
|||||||
@@ -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
@@ -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 ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -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}")
|
||||||
Reference in New Issue
Block a user