Major changes: - Fixed right-click handler to directly set mode to NONE instead of relying on main app signal handling - Added safety checks in left-click handler to prevent drawing when no draggable point is found in NONE mode - Enhanced mode compatibility by treating Python None as SketchMode.NONE in set_mode() method - Added comprehensive debug logging for mode changes and interaction state tracking - Resolved integration issue where persistent constraint modes were prematurely reset by main app - Ensured point dragging is only enabled in NONE mode, preventing accidental polyline creation This fixes the reported issue where deactivating the line tool would still create lines when dragging, and ensures proper mode transitions between drawing tools and selection/drag mode.
1008 lines
37 KiB
Python
1008 lines
37 KiB
Python
# nuitka-project: --plugin-enable=pyside6
|
|
# nuitka-project: --plugin-enable=numpy
|
|
# nuitka-project: --standalone
|
|
# nuitka-project: --macos-create-app-bundle
|
|
|
|
import uuid
|
|
import names
|
|
from PySide6.QtCore import Qt, QPoint, Signal, QSize
|
|
from PySide6.QtWidgets import QApplication, QMainWindow, QSizePolicy, QInputDialog, QDialog, QVBoxLayout, QHBoxLayout, QLabel, QDoubleSpinBox, QCheckBox, QPushButton, QButtonGroup
|
|
from Gui import Ui_fluencyCAD # Import the generated GUI module
|
|
from drawing_modules.vtk_widget import VTKWidget
|
|
import numpy as np
|
|
|
|
from drawing_modules.improved_sketcher import ImprovedSketchWidget, SketchMode, SnapMode
|
|
from sdf import *
|
|
from python_solvespace import SolverSystem, ResultFlag
|
|
from mesh_modules import simple_mesh, vesta_mesh, interactor_mesh
|
|
from dataclasses import dataclass, field
|
|
|
|
# main, draw_widget, gl_widget
|
|
|
|
class ExtrudeDialog(QDialog):
|
|
def __init__(self, parent=None):
|
|
super().__init__(parent)
|
|
self.setWindowTitle('Extrude Options')
|
|
|
|
def create_hline():
|
|
line = QLabel()
|
|
line.setStyleSheet("border-top: 1px solid #cccccc;") # Light grey line
|
|
line.setFixedHeight(1)
|
|
return line
|
|
|
|
layout = QVBoxLayout()
|
|
|
|
# Length input
|
|
length_layout = QHBoxLayout()
|
|
length_label = QLabel('Extrude Length (mm):')
|
|
self.length_input = QDoubleSpinBox()
|
|
self.length_input.setDecimals(2)
|
|
self.length_input.setRange(0, 1000) # Adjust range as needed
|
|
length_layout.addWidget(length_label)
|
|
length_layout.addWidget(self.length_input)
|
|
|
|
# Symmetric checkbox
|
|
self.symmetric_checkbox = QCheckBox('Symmetric Extrude')
|
|
self.invert_checkbox = QCheckBox('Invert Extrusion')
|
|
self.cut_checkbox = QCheckBox('Perform Cut')
|
|
self.union_checkbox = QCheckBox('Combine')
|
|
self.rounded_checkbox = QCheckBox('Round Edges')
|
|
self.seperator = create_hline()
|
|
|
|
# OK and Cancel buttons
|
|
button_layout = QHBoxLayout()
|
|
ok_button = QPushButton('OK')
|
|
cancel_button = QPushButton('Cancel')
|
|
ok_button.clicked.connect(self.accept)
|
|
cancel_button.clicked.connect(self.reject)
|
|
button_layout.addWidget(ok_button)
|
|
button_layout.addWidget(cancel_button)
|
|
|
|
# Add all widgets to main layout
|
|
layout.addLayout(length_layout)
|
|
layout.addWidget(self.seperator)
|
|
layout.addWidget(self.cut_checkbox)
|
|
layout.addWidget(self.union_checkbox)
|
|
layout.addWidget(self.seperator)
|
|
layout.addWidget(self.symmetric_checkbox)
|
|
layout.addWidget(self.invert_checkbox)
|
|
layout.addWidget(self.seperator)
|
|
layout.addWidget(self.rounded_checkbox)
|
|
|
|
layout.addLayout(button_layout)
|
|
|
|
self.setLayout(layout)
|
|
|
|
def get_values(self):
|
|
return self.length_input.value(), self.symmetric_checkbox.isChecked() ,self.invert_checkbox.isChecked(), self.cut_checkbox.isChecked(), self.union_checkbox.isChecked(), self.rounded_checkbox.isChecked()
|
|
|
|
|
|
class MainWindow(QMainWindow):
|
|
send_command = Signal(str)
|
|
|
|
def __init__(self):
|
|
super().__init__()
|
|
|
|
# Set up the UI from the generated GUI module
|
|
self.ui = Ui_fluencyCAD()
|
|
self.ui.setupUi(self)
|
|
|
|
self.custom_3D_Widget = VTKWidget()
|
|
layout = self.ui.gl_box.layout()
|
|
layout.addWidget(self.custom_3D_Widget)
|
|
size_policy = QSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.MinimumExpanding)
|
|
#self.custom_3D_Widget.setSizePolicy(size_policy)
|
|
|
|
self.sketchWidget = ImprovedSketchWidget()
|
|
layout2 = self.ui.sketch_tab.layout() # Get the layout of self.ui.gl_canvas
|
|
layout2.addWidget(self.sketchWidget)
|
|
size_policy = QSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.MinimumExpanding)
|
|
self.sketchWidget.setSizePolicy(size_policy)
|
|
|
|
### Main Model -OLD ?
|
|
"""self.model = {
|
|
'sketches': {},
|
|
'operation': {},
|
|
}"""
|
|
self.list_selected = []
|
|
|
|
#self.ui.pb_apply_code.pressed.connect(self.check_current_tab)
|
|
self.ui.sketch_list.currentItemChanged.connect(self.on_item_changed)
|
|
self.ui.sketch_list.itemChanged.connect(self.draw_mesh)
|
|
|
|
### Sketches
|
|
self.ui.pb_origin_wp.pressed.connect(self.add_new_sketch_origin)
|
|
self.ui.pb_origin_face.pressed.connect(self.add_new_sketch_wp)
|
|
|
|
self.ui.pb_nw_sktch.pressed.connect(self.add_sketch_to_compo)
|
|
self.ui.pb_del_sketch.pressed.connect(self.del_sketch)
|
|
self.ui.pb_edt_sktch.pressed.connect(self.edit_sketch)
|
|
|
|
self.ui.pb_flip_face.pressed.connect(self.on_flip_face)
|
|
|
|
###Modes - Updated for improved sketcher
|
|
# Add a selection/drag mode button if available in UI, otherwise use existing button
|
|
# For now, we'll assume there might be a selection button - adapt as needed
|
|
# self.ui.pb_select.clicked.connect(lambda: self.sketchWidget.set_mode(SketchMode.NONE))
|
|
|
|
self.ui.pb_linetool.clicked.connect(lambda: self.sketchWidget.set_mode(SketchMode.LINE))
|
|
self.ui.pb_rectool.clicked.connect(lambda: self.sketchWidget.set_mode(SketchMode.RECTANGLE))
|
|
self.ui.pb_circtool.clicked.connect(lambda: self.sketchWidget.set_mode(SketchMode.CIRCLE))
|
|
self.ui.pb_con_ptpt.clicked.connect(lambda: self.sketchWidget.set_mode(SketchMode.COINCIDENT_PT_PT))
|
|
self.ui.pb_con_line.clicked.connect(lambda: self.sketchWidget.set_mode(SketchMode.COINCIDENT_PT_LINE))
|
|
self.ui.pb_con_horiz.clicked.connect(lambda: self.sketchWidget.set_mode(SketchMode.HORIZONTAL))
|
|
self.ui.pb_con_vert.clicked.connect(lambda: self.sketchWidget.set_mode(SketchMode.VERTICAL))
|
|
self.ui.pb_con_dist.clicked.connect(lambda: self.sketchWidget.set_mode(SketchMode.DISTANCE))
|
|
self.ui.pb_con_mid.clicked.connect(lambda: self.sketchWidget.set_mode(SketchMode.MIDPOINT))
|
|
|
|
### Operations
|
|
self.ui.pb_extrdop.pressed.connect(self.send_extrude)
|
|
self.ui.pb_cutop.pressed.connect(self.send_cut)
|
|
self.ui.pb_del_body.pressed.connect(self.del_body)
|
|
|
|
# Connect new sketcher signals
|
|
self.sketchWidget.constraint_applied.connect(self.on_constraint_applied)
|
|
self.sketchWidget.geometry_created.connect(self.on_geometry_created)
|
|
self.sketchWidget.sketch_modified.connect(self.on_sketch_modified)
|
|
self.setFocusPolicy(Qt.StrongFocus)
|
|
|
|
self.send_command.connect(self.custom_3D_Widget.on_receive_command)
|
|
self.ui.actionNew_Project.triggered.connect(self.new_project)
|
|
self.ui.pb_enable_construct.clicked.connect(lambda checked: self.sketchWidget.set_construction_mode(checked))
|
|
self.project = Project()
|
|
self.new_project()
|
|
|
|
### SNAPS
|
|
self.ui.pb_snap_midp.toggled.connect(lambda checked: self.sketchWidget.set_snap_mode(SnapMode.MIDPOINT, checked))
|
|
self.ui.pb_snap_horiz.toggled.connect(lambda checked: self.sketchWidget.set_snap_mode(SnapMode.HORIZONTAL, checked))
|
|
self.ui.pb_snap_vert.toggled.connect(lambda checked: self.sketchWidget.set_snap_mode(SnapMode.VERTICAL, checked))
|
|
self.ui.pb_snap_angle.toggled.connect(lambda checked: self.sketchWidget.set_snap_mode(SnapMode.ANGLE, checked))
|
|
self.ui.pb_enable_snap.toggled.connect(lambda checked: self.sketchWidget.set_snap_mode(SnapMode.POINT, checked))
|
|
### COMPOS
|
|
### COMPOS
|
|
|
|
self.ui.new_compo.pressed.connect(self.new_component)
|
|
|
|
"""Project -> (Timeline) -> Component -> Sketch -> Body / Interactor -> Connector -> Assembly -> PB Render"""
|
|
|
|
def new_project(self):
|
|
print("New project")
|
|
timeline = []
|
|
self.project.timeline = timeline
|
|
self.new_component()
|
|
|
|
def new_component(self):
|
|
print("Creating a new component...")
|
|
|
|
# Lazily initialize self.compo_layout if it doesn't exist
|
|
if not hasattr(self, 'compo_layout'):
|
|
print("Initializing compo_layout...")
|
|
self.compo_layout = QHBoxLayout()
|
|
|
|
# Create a button group
|
|
self.compo_group = QButtonGroup(self)
|
|
self.compo_group.setExclusive(True) # Ensure exclusivity
|
|
|
|
# Ensure the QGroupBox has a layout
|
|
if not self.ui.compo_box.layout():
|
|
self.ui.compo_box.setLayout(QVBoxLayout()) # Set a default layout for QGroupBox
|
|
|
|
# Add the horizontal layout to the QGroupBox's layout
|
|
self.ui.compo_box.layout().addLayout(self.compo_layout)
|
|
|
|
# Align the layout to the left
|
|
self.compo_layout.setAlignment(Qt.AlignLeft)
|
|
|
|
# Create and initialize a new Component
|
|
compo = Component()
|
|
compo.id = f"Component {len(self.project.timeline)}"
|
|
compo.descript = "Initial Component"
|
|
compo.sketches = {}
|
|
compo.bodies = {}
|
|
self.project.timeline.append(compo)
|
|
|
|
# Create a button for the new component
|
|
button = QPushButton()
|
|
button.setToolTip(compo.id)
|
|
button.setText(str(len(self.project.timeline)))
|
|
button.setFixedSize(QSize(40, 40)) # Set button size
|
|
button.setCheckable(True)
|
|
#button.setAutoExclusive(True)
|
|
button.released.connect(self.on_compo_change)
|
|
button.setChecked(True)
|
|
|
|
# Add button to the group
|
|
self.compo_group.addButton(button)
|
|
|
|
# Add the button to the layout
|
|
self.compo_layout.addWidget(button)
|
|
|
|
# We automatically switch to the new compo hence, refresh
|
|
self.on_compo_change()
|
|
|
|
print(f"Added component {compo.id} to the layout.")
|
|
|
|
def get_activated_compo(self):
|
|
# Iterate through all items in the layout
|
|
total_elements = self.compo_layout.count()
|
|
#print(total_elements)
|
|
for i in range(total_elements):
|
|
widget = self.compo_layout.itemAt(i).widget() # Get the widget at the index
|
|
if widget: # Check if the widget is not None
|
|
if isinstance(widget, QPushButton) and widget.isCheckable():
|
|
state = widget.isChecked() # Get the checked state
|
|
print(f"{widget.text()} is {'checked' if state else 'unchecked'}.")
|
|
if state:
|
|
return i
|
|
|
|
def add_new_sketch_origin(self):
|
|
name = f"sketches-{str(names.get_first_name())}"
|
|
sketch = Sketch()
|
|
sketch.id = name
|
|
sketch.origin = [0,0,0]
|
|
|
|
self.sketchWidget.reset_buffers()
|
|
self.sketchWidget.create_sketch(sketch)
|
|
|
|
def add_new_sketch_wp(self):
|
|
## Sketch projected from 3d view into 2d
|
|
name = f"sketches-{str(names.get_first_name())}"
|
|
sketch = Sketch()
|
|
sketch.id = name
|
|
sketch.origin = self.custom_3D_Widget.centroid
|
|
sketch.normal = self.custom_3D_Widget.selected_normal
|
|
sketch.slv_points = []
|
|
sketch.slv_lines = []
|
|
sketch.proj_points = self.custom_3D_Widget.project_tosketch_points
|
|
sketch.proj_lines = self.custom_3D_Widget.project_tosketch_lines
|
|
|
|
self.sketchWidget.reset_buffers()
|
|
self.sketchWidget.create_sketch(sketch)
|
|
self.sketchWidget.create_workplane_projected()
|
|
|
|
if not sketch.proj_lines:
|
|
self.sketchWidget.convert_proj_points(sketch.proj_points)
|
|
|
|
self.sketchWidget.convert_proj_lines(sketch.proj_lines)
|
|
self.sketchWidget.update()
|
|
|
|
# CLear all selections after it has been projected
|
|
self.custom_3D_Widget.project_tosketch_points.clear()
|
|
self.custom_3D_Widget.project_tosketch_lines.clear()
|
|
self.custom_3D_Widget.clear_actors_projection()
|
|
self.custom_3D_Widget.clear_actors_normals()
|
|
|
|
def add_sketch_to_compo(self):
|
|
"""
|
|
Add sketch to component
|
|
:return:
|
|
"""
|
|
sketch = Sketch()
|
|
sketch_from_widget = self.sketchWidget.get_sketch()
|
|
|
|
#Save original for editing later
|
|
sketch.original_sketch = sketch_from_widget
|
|
|
|
# Use the new geometry conversion method that handles circles, lines, and points
|
|
sketch.convert_geometry_for_sdf(sketch_from_widget)
|
|
sketch.id = sketch_from_widget.id
|
|
|
|
sketch.filter_lines_for_interactor(sketch_from_widget.lines)
|
|
|
|
# Register sketch to timeline
|
|
### Add selection compo here
|
|
compo_id = self.get_activated_compo()
|
|
#print("newsketch_name", sketch.id)
|
|
self.project.timeline[compo_id].sketches[sketch.id] = sketch
|
|
|
|
# Add Item to slection menu
|
|
self.ui.sketch_list.addItem(sketch.id)
|
|
|
|
# Deactivate drawing
|
|
self.ui.pb_linetool.setChecked(False)
|
|
self.sketchWidget.set_mode(None)
|
|
|
|
items = self.ui.sketch_list.findItems(sketch.id, Qt.MatchExactly)[0]
|
|
self.ui.sketch_list.setCurrentItem(items)
|
|
|
|
def on_compo_change(self):
|
|
'''This function redraws the sdf and helper mesh from available bodies and adds the names back to the list entries'''
|
|
self.custom_3D_Widget.clear_body_actors()
|
|
self.custom_3D_Widget.clear_actors_interactor()
|
|
self.custom_3D_Widget.clear_actors_projection()
|
|
|
|
compo_id = self.get_activated_compo()
|
|
if compo_id is not None:
|
|
self.ui.sketch_list.clear()
|
|
self.ui.body_list.clear()
|
|
|
|
#print("id", compo_id)
|
|
#print("sketch_registry", self.project.timeline[compo_id].sketches)
|
|
|
|
for sketch in self.project.timeline[compo_id].sketches:
|
|
#print(sketch)
|
|
self.ui.sketch_list.addItem(sketch)
|
|
|
|
for body in self.project.timeline[compo_id].bodies:
|
|
self.ui.body_list.addItem(body)
|
|
|
|
if self.project.timeline[compo_id].bodies:
|
|
item = self.ui.body_list.findItems(body , Qt.MatchExactly)[0]
|
|
self.ui.body_list.setCurrentItem(item)
|
|
self.draw_mesh()
|
|
|
|
selected = self.ui.body_list.currentItem()
|
|
name = selected.text()
|
|
|
|
edges = self.project.timeline[compo_id].bodies[name].interactor.edges
|
|
offset_vec = self.project.timeline[compo_id].bodies[name].interactor.offset_vector
|
|
self.custom_3D_Widget.load_interactor_mesh(edges, offset_vec)
|
|
|
|
def edit_sketch(self):
|
|
selected = self.ui.sketch_list.currentItem()
|
|
name = selected.text()
|
|
sel_compo = self.project.timeline[self.get_activated_compo()]
|
|
sketch = sel_compo.sketches[name].original_sketch
|
|
|
|
self.sketchWidget.set_sketch(sketch)
|
|
|
|
self.sketchWidget.update()
|
|
|
|
def del_sketch(self):
|
|
selected = self.ui.sketch_list.currentItem()
|
|
name = selected.text()
|
|
sel_compo = self.project.timeline[self.get_activated_compo()]
|
|
sketch = sel_compo.sketches[name]
|
|
|
|
if sketch is not None:
|
|
sel_compo.sketches.pop(name)
|
|
row = self.ui.sketch_list.row(selected) # Get the row of the current item
|
|
self.ui.sketch_list.takeItem(row) # Remove the item from the list widget
|
|
self.sketchWidget.sketch = None
|
|
print(sketch)
|
|
else:
|
|
print("No item selected.")
|
|
|
|
def on_flip_face(self):
|
|
self.send_command.emit("flip")
|
|
|
|
def draw_op_complete(self):
|
|
# safely disable all drawing and constraint modes
|
|
self.ui.pb_linetool.setChecked(False)
|
|
self.ui.pb_rectool.setChecked(False)
|
|
self.ui.pb_circtool.setChecked(False)
|
|
|
|
# Disable all constraint buttons
|
|
self.ui.pb_con_ptpt.setChecked(False)
|
|
self.ui.pb_con_line.setChecked(False)
|
|
self.ui.pb_con_horiz.setChecked(False)
|
|
self.ui.pb_con_vert.setChecked(False)
|
|
self.ui.pb_con_dist.setChecked(False)
|
|
self.ui.pb_con_mid.setChecked(False)
|
|
self.ui.pb_con_perp.setChecked(False)
|
|
|
|
# Reset the sketch widget mode
|
|
self.sketchWidget.set_mode(None)
|
|
|
|
def on_geometry_created(self, geometry):
|
|
"""Handle geometry creation from the improved sketcher."""
|
|
print(f"Geometry created: {geometry}")
|
|
|
|
def on_sketch_modified(self):
|
|
"""Handle sketch modifications from the improved sketcher."""
|
|
print("Sketch modified")
|
|
|
|
def on_constraint_applied(self):
|
|
"""Handle constraint application - only reset UI if exiting constraint mode."""
|
|
# Only call draw_op_complete if we're actually exiting constraint mode
|
|
# This allows persistent constraint behavior - constraints stay active until right-click
|
|
current_mode = self.sketchWidget.current_mode
|
|
|
|
# Only reset if we're going back to NONE mode (right-click exit)
|
|
if current_mode == SketchMode.NONE:
|
|
self.draw_op_complete()
|
|
else:
|
|
# We're still in a constraint mode, don't reset the UI buttons
|
|
print(f"Constraint applied, staying in mode: {current_mode}")
|
|
|
|
def draw_mesh(self):
|
|
|
|
name = self.ui.body_list.currentItem().text()
|
|
print("selected_for disp", name)
|
|
|
|
compo_id = self.get_activated_compo()
|
|
model = self.project.timeline[compo_id].bodies[name].sdf_body
|
|
|
|
vesta = vesta_mesh
|
|
model_data = vesta.generate_mesh_from_sdf(model, resolution=64, threshold=0)
|
|
|
|
vertices, faces = model_data
|
|
#vesta.save_mesh_as_stl(vertices, faces, 'test.stl')
|
|
self.custom_3D_Widget.render_from_points_direct_with_faces(vertices, faces)
|
|
|
|
def on_item_changed(self, current_item, previous_item):
|
|
if current_item:
|
|
name = current_item.text()
|
|
#self.view_update()
|
|
print(f"Selected item: {name}")
|
|
|
|
def update_body(self):
|
|
pass
|
|
|
|
def del_body(self):
|
|
print("Deleting")
|
|
name = self.ui.body_list.currentItem() # Get the current item
|
|
|
|
if name is not None:
|
|
item_name = name.text()
|
|
print("obj_name", item_name)
|
|
# Check if the 'operation' key exists in the model dictionary
|
|
|
|
if 'operation' in self.model and item_name in self.model['operation']:
|
|
if self.model['operation'][item_name]['id'] == item_name:
|
|
row = self.ui.body_list.row(name) # Get the row of the current item
|
|
self.ui.body_list.takeItem(row) # Remove the item from the list widget
|
|
self.model['operation'].pop(item_name) # Remove the item from the operation dictionary
|
|
print(f"Removed operation: {item_name}")
|
|
self.custom_3D_Widget.clear_mesh()
|
|
|
|
def send_extrude(self):
|
|
# Dialog input
|
|
is_symmetric = None
|
|
length = None
|
|
invert = None
|
|
|
|
selected = self.ui.sketch_list.currentItem()
|
|
name = selected.text()
|
|
|
|
sel_compo = self.project.timeline[self.get_activated_compo()]
|
|
#print(sel_compo)
|
|
sketch = sel_compo.sketches[name]
|
|
#print(sketch)
|
|
points = sketch.sdf_points
|
|
|
|
# Note: Closed polygons should have first == last point for proper SDF generation
|
|
# No need to remove 'overlapping' points as they're intentionally closed
|
|
|
|
dialog = ExtrudeDialog(self)
|
|
if dialog.exec():
|
|
length, is_symmetric, invert, cut, union_with, rounded = dialog.get_values()
|
|
#print(f"Extrude length: {length}, Symmetric: {is_symmetric} Invert: {invert}")
|
|
else:
|
|
length = 0
|
|
#print("Extrude cancelled")
|
|
|
|
normal = self.custom_3D_Widget.selected_normal
|
|
#print("Normie enter", normal)
|
|
if normal is None:
|
|
normal = [0, 0, 1]
|
|
|
|
centroid = self.custom_3D_Widget.centroid
|
|
if centroid is None:
|
|
centroid = [0, 0, 0]
|
|
"""else:
|
|
centroid = list(centroid)"""
|
|
#print("This centroid ", centroid)
|
|
|
|
sketch.origin = centroid
|
|
sketch.normal = normal
|
|
|
|
f = sketch.extrude(length, is_symmetric, invert, 0)
|
|
|
|
# Create body element and assign known stuff
|
|
name_op = f"extrd-{name}"
|
|
|
|
body = Body()
|
|
body.sketch = sketch #we add the sketches for reference here
|
|
body.id = name_op
|
|
body.sdf_body = f
|
|
|
|
### Interactor
|
|
interactor = Interactor()
|
|
interactor.add_lines_for_interactor(sketch.interactor_lines)
|
|
interactor.invert = invert
|
|
|
|
if not invert:
|
|
edges = interactor_mesh.generate_mesh(interactor.lines, 0, length)
|
|
else:
|
|
edges = interactor_mesh.generate_mesh(interactor.lines, 0, -length)
|
|
|
|
sel_compo.bodies[name_op] = body
|
|
|
|
offset_vector = interactor.vector_to_centroid(None, centroid, normal)
|
|
#print("off_ved", offset_vector)
|
|
if len(offset_vector) == 0 :
|
|
offset_vector = [0, 0, 0]
|
|
|
|
interactor.edges = edges
|
|
interactor.offset_vector = offset_vector
|
|
body.interactor = interactor
|
|
|
|
self.custom_3D_Widget.load_interactor_mesh(edges, offset_vector)
|
|
|
|
self.ui.body_list.addItem(name_op)
|
|
items = self.ui.body_list.findItems(name_op, Qt.MatchExactly)[0]
|
|
self.ui.body_list.setCurrentItem(items)
|
|
|
|
self.draw_mesh()
|
|
|
|
def send_cut(self):
|
|
"""name = self.ui.body_list.currentItem().text()
|
|
points = self.model['operation'][name]['sdf_object']
|
|
sel_compo = self.project.timeline[self.get_activated_compo()]
|
|
points = sel_compo.bodies[].
|
|
self.list_selected.append(points)"""
|
|
|
|
selected = self.ui.body_list.currentItem()
|
|
name = selected.text()
|
|
|
|
sel_compo = self.project.timeline[self.get_activated_compo()]
|
|
# print(sel_compo)
|
|
body = sel_compo.bodies[name]
|
|
# print(sketch)
|
|
self.list_selected.append(body.sdf_body)
|
|
|
|
if len(self.list_selected) == 2:
|
|
f = difference(self.list_selected[0], self.list_selected[1]) # equivalent
|
|
|
|
element = {
|
|
'id': name,
|
|
'type': 'cut',
|
|
'sdf_object': f,
|
|
}
|
|
|
|
# Create body element and assign known stuff
|
|
name_op = f"cut-{name}"
|
|
|
|
body = Body()
|
|
body.id = name_op
|
|
body.sdf_body = f
|
|
|
|
## Add to component
|
|
sel_compo.bodies[name_op] = body
|
|
|
|
self.ui.body_list.addItem(name_op)
|
|
items = self.ui.body_list.findItems(name_op, Qt.MatchExactly)
|
|
self.ui.body_list.setCurrentItem(items[-1])
|
|
self.custom_3D_Widget.clear_body_actors()
|
|
self.draw_mesh()
|
|
|
|
elif len(self.list_selected) > 2:
|
|
self.list_selected.clear()
|
|
else:
|
|
print("mindestens 2!")
|
|
|
|
def load_and_render(self, file):
|
|
self.custom_3D_Widget.load_stl(file)
|
|
self.custom_3D_Widget.update()
|
|
|
|
@dataclass
|
|
class Timeline:
|
|
"""Timeline """
|
|
### Collection of the Components
|
|
timeline: list = None
|
|
|
|
"""add to time,
|
|
remove from time, """
|
|
|
|
class Assembly:
|
|
"""Connecting Components in 3D space based on slvs solver"""
|
|
|
|
@dataclass
|
|
class Component:
|
|
"""The base container combining all related elements
|
|
id : The unique ID
|
|
sketches : the base sketches, bodys can contain additonal sketches for features
|
|
interactor : A smiplified model used as interactor
|
|
body : The body class that contains the actual 3d information
|
|
connector : Vector and Nomral information for assembly
|
|
descript : a basic description
|
|
materil : Speicfy a material for pbr rendering
|
|
"""
|
|
id = None
|
|
sketches: dict = None
|
|
bodies: dict = None
|
|
connector = None
|
|
|
|
# Description
|
|
descript = None
|
|
|
|
# PBR
|
|
material = None
|
|
|
|
|
|
class Connector:
|
|
"""An Element that contains vectors and or normals as connection points.
|
|
These connection points can exist independently of bodies and other elements"""
|
|
id = None
|
|
vector = None
|
|
normal = None
|
|
|
|
|
|
class Code:
|
|
"""A class that holds all information from the code based approach"""
|
|
command_list = None
|
|
|
|
def generate_mesh_from_code(self, code_text: str):
|
|
local_vars = {}
|
|
|
|
try:
|
|
print(code_text)
|
|
exec(code_text, globals(), local_vars)
|
|
# Retrieve the result from the captured local variables
|
|
result = local_vars.get('result')
|
|
print("Result:", result)
|
|
|
|
except Exception as e:
|
|
print("Error executing code:", e)
|
|
|
|
|
|
@dataclass
|
|
class Sketch:
|
|
"""All of the 2D Information of a sketches"""
|
|
|
|
# Save the incomng sketch from the 2D widget for late redit
|
|
original_sketch = None
|
|
|
|
id = None
|
|
|
|
# Space Information
|
|
origin = None
|
|
slv_plane = None
|
|
normal = None
|
|
|
|
# Points in UI form the sketches widget
|
|
ui_points: list = None
|
|
ui_lines: list = None
|
|
|
|
# Points cartesian coming as result of the solver
|
|
slv_points: list = None
|
|
slv_lines: list = None
|
|
|
|
sdf_points: list = None
|
|
|
|
interactor_lines: list = None
|
|
|
|
# Points coming back from the 3D-Widget as projection to draw on
|
|
proj_points: list = None
|
|
proj_lines: list = None
|
|
|
|
# Workingplane
|
|
working_plane = None
|
|
|
|
def translate_points_tup(self, point: QPoint):
|
|
"""QPoints from Display to mesh data
|
|
input: Qpoints
|
|
output: Tuple X,Y
|
|
"""
|
|
if isinstance(point, QPoint):
|
|
return point.x(), point.y()
|
|
|
|
def vector_to_centroid(self, shape_center, centroid, normal):
|
|
|
|
if not shape_center:
|
|
# Calculate the current center of the shape
|
|
shape_center = [0, 0, 0]
|
|
|
|
# Calculate the vector from the shape's center to the centroid
|
|
center_to_centroid = np.array(centroid) - np.array(shape_center)
|
|
|
|
# Project this vector onto the normal to get the required translation along the normal
|
|
translation_along_normal = np.dot(center_to_centroid, normal) * normal
|
|
|
|
return translation_along_normal
|
|
|
|
def angle_between_normals(self, normal1, normal2):
|
|
# Ensure the vectors are normalized
|
|
n1 = normal1 / np.linalg.norm(normal1)
|
|
n2 = normal2 / np.linalg.norm(normal2)
|
|
|
|
# Compute the dot product
|
|
dot_product = np.dot(n1, n2)
|
|
|
|
# Clip the dot product to the valid range [-1, 1]
|
|
dot_product = np.clip(dot_product, -1.0, 1.0)
|
|
|
|
# Compute the angle in radians
|
|
angle_rad = np.arccos(dot_product)
|
|
|
|
# Convert to degrees if needed
|
|
angle_deg = np.degrees(angle_rad)
|
|
print("Angle deg", angle_deg)
|
|
|
|
return angle_rad
|
|
|
|
def offset_syn(self, f, length):
|
|
f = f.translate((0,0, length / 2))
|
|
return f
|
|
|
|
def distance(self, p1, p2):
|
|
"""Calculate the distance between two points."""
|
|
print("p1", p1)
|
|
print("p2", p2)
|
|
return math.sqrt((p1[0] - p2[0]) ** 2 + (p1[1] - p2[1]) ** 2)
|
|
|
|
def convert_points_for_sdf(self, points):
|
|
points_for_sdf = []
|
|
for point in points:
|
|
if hasattr(point, 'is_helper') and point.is_helper is False:
|
|
print("point", point)
|
|
# Handle improved sketcher Point2D objects
|
|
if hasattr(point, 'x') and hasattr(point, 'y'):
|
|
points_for_sdf.append((point.x, point.y))
|
|
else:
|
|
# Fallback for old-style point objects
|
|
points_for_sdf.append(self.translate_points_tup(point.ui_point))
|
|
|
|
self.sdf_points = points_for_sdf
|
|
|
|
def convert_geometry_for_sdf(self, sketch):
|
|
"""Convert sketch geometry (points, lines, circles) to SDF polygon points"""
|
|
import math
|
|
points_for_sdf = []
|
|
|
|
# Handle circles by converting them to polygons
|
|
if hasattr(sketch, 'circles') and sketch.circles:
|
|
for circle in sketch.circles:
|
|
if not circle.is_helper:
|
|
# Convert circle to polygon approximation
|
|
num_segments = 32 # Number of segments for circle approximation
|
|
center_x, center_y = circle.center.x, circle.center.y
|
|
radius = circle.radius
|
|
|
|
for i in range(num_segments):
|
|
angle = 2 * math.pi * i / num_segments
|
|
x = center_x + radius * math.cos(angle)
|
|
y = center_y + radius * math.sin(angle)
|
|
points_for_sdf.append((x, y))
|
|
|
|
# Handle lines by creating ordered polygon from connected line segments
|
|
if hasattr(sketch, 'lines') and sketch.lines:
|
|
non_helper_lines = [line for line in sketch.lines if not line.is_helper]
|
|
if non_helper_lines:
|
|
# For connected shapes like rectangles, we need to trace the outline
|
|
# to avoid duplicate corner points
|
|
ordered_points = self._trace_connected_lines(non_helper_lines)
|
|
points_for_sdf.extend(ordered_points)
|
|
|
|
# Handle individual points (if any)
|
|
if hasattr(sketch, 'points') and sketch.points:
|
|
for point in sketch.points:
|
|
if hasattr(point, 'is_helper') and not point.is_helper:
|
|
# Only add standalone points (not already part of lines/circles)
|
|
is_standalone = True
|
|
|
|
# Check if point is part of any line
|
|
if hasattr(sketch, 'lines'):
|
|
for line in sketch.lines:
|
|
if point == line.start or point == line.end:
|
|
is_standalone = False
|
|
break
|
|
|
|
# Check if point is center of any circle
|
|
if hasattr(sketch, 'circles'):
|
|
for circle in sketch.circles:
|
|
if point == circle.center:
|
|
is_standalone = False
|
|
break
|
|
|
|
if is_standalone:
|
|
points_for_sdf.append((point.x, point.y))
|
|
|
|
self.sdf_points = points_for_sdf
|
|
print(f"Generated SDF points: {len(points_for_sdf)} points")
|
|
if points_for_sdf:
|
|
print(f"First point: {points_for_sdf[0]}, Last point: {points_for_sdf[-1]}")
|
|
|
|
def _trace_connected_lines(self, lines):
|
|
"""Trace connected line segments to create ordered polygon points without duplicates"""
|
|
if not lines:
|
|
return []
|
|
|
|
# Start with the first line
|
|
current_line = lines[0]
|
|
# Keep Y coordinate as-is to match sketcher orientation
|
|
ordered_points = [(current_line.start.x, current_line.start.y)]
|
|
used_lines = {current_line}
|
|
current_point = current_line.end
|
|
|
|
while len(used_lines) < len(lines):
|
|
# Add the current transition point keeping Y coordinate as-is
|
|
point_tuple = (current_point.x, current_point.y)
|
|
if not ordered_points or ordered_points[-1] != point_tuple:
|
|
ordered_points.append(point_tuple)
|
|
|
|
# Find the next connected line
|
|
next_line = None
|
|
for line in lines:
|
|
if line in used_lines:
|
|
continue
|
|
|
|
# Check if this line connects to current_point
|
|
if self._points_equal(line.start, current_point):
|
|
next_line = line
|
|
current_point = line.end
|
|
break
|
|
elif self._points_equal(line.end, current_point):
|
|
next_line = line
|
|
current_point = line.start
|
|
break
|
|
|
|
if next_line is None:
|
|
# No more connected lines, might be separate line segments
|
|
break
|
|
|
|
used_lines.add(next_line)
|
|
|
|
# If we didn't use all lines, add remaining line endpoints keeping Y coordinate as-is
|
|
for line in lines:
|
|
if line not in used_lines:
|
|
start_point = (line.start.x, line.start.y)
|
|
end_point = (line.end.x, line.end.y)
|
|
if start_point not in ordered_points:
|
|
ordered_points.append(start_point)
|
|
if end_point not in ordered_points:
|
|
ordered_points.append(end_point)
|
|
|
|
return ordered_points
|
|
|
|
def _points_equal(self, p1, p2, tolerance=1e-6):
|
|
"""Check if two points are equal within tolerance"""
|
|
return abs(p1.x - p2.x) < tolerance and abs(p1.y - p2.y) < tolerance
|
|
|
|
def filter_lines_for_interactor(self, lines):
|
|
### Filter lines that are not meant to be drawn for the interactor like contruction lines
|
|
filtered_lines = []
|
|
for line in lines:
|
|
if not line.is_helper:
|
|
filtered_lines.append(line)
|
|
|
|
self.interactor_lines = filtered_lines
|
|
|
|
def extrude(self, height: float, symet: bool = True, invert: bool = False, offset_length: float = None):
|
|
"""
|
|
Extrude a 2D shape into 3D, orient it along the normal, and position it relative to the centroid.
|
|
"""
|
|
|
|
# Normalize the normal vector
|
|
normal = np.array(self.normal)
|
|
normal = normal / np.linalg.norm(self.normal)
|
|
|
|
# Create the 2D shape
|
|
f = polygon(self.sdf_points)
|
|
|
|
# Extrude the shape along the Z-axis
|
|
f = f.extrude(height)
|
|
|
|
# Center the shape along its extrusion axis
|
|
f = f.translate((0, 0, height / 2))
|
|
|
|
# Orient the shape along the normal vector
|
|
f = f.orient(normal)
|
|
|
|
offset_vector = self.vector_to_centroid(None, self.origin, normal)
|
|
# Adjust the offset vector by subtracting the inset distance along the normal direction
|
|
adjusted_offset = offset_vector - (normal * height)
|
|
if invert:
|
|
# Translate the shape along the adjusted offset vector
|
|
f = f.translate(adjusted_offset)
|
|
else:
|
|
f = f.translate(offset_vector)
|
|
|
|
# If offset_length is provided, adjust the offset_vector
|
|
if offset_length is not None:
|
|
# Check if offset_vector is not a zero vector
|
|
offset_vector_magnitude = np.linalg.norm(offset_vector)
|
|
if offset_vector_magnitude > 1e-10: # Use a small threshold to avoid floating-point issues
|
|
# Normalize the offset vector
|
|
offset_vector_norm = offset_vector / offset_vector_magnitude
|
|
# Scale the normalized vector by the desired length
|
|
offset_vector = offset_vector_norm * offset_length
|
|
f = f.translate(offset_vector)
|
|
else:
|
|
print("Warning: Offset vector has zero magnitude. Using original vector.")
|
|
|
|
# Translate the shape along the adjusted offset vector
|
|
|
|
return f
|
|
|
|
@dataclass
|
|
class Interactor:
|
|
"""Helper mesh consisting of edges for selection"""
|
|
lines = None
|
|
faces = None
|
|
body = None
|
|
offset_vector = None
|
|
edges = None
|
|
|
|
def translate_points_tup(self, point: QPoint):
|
|
"""QPoints from Display to mesh data
|
|
input: Qpoints
|
|
output: Tuple X,Y
|
|
"""
|
|
if isinstance(point, QPoint):
|
|
return point.x(), point.y()
|
|
|
|
def vector_to_centroid(self, shape_center, centroid, normal):
|
|
|
|
if not shape_center:
|
|
# Calculate the current center of the shape
|
|
shape_center = [0, 0, 0]
|
|
|
|
# Calculate the vector from the shape's center to the centroid
|
|
center_to_centroid = np.array(centroid) - np.array(shape_center)
|
|
|
|
# Project this vector onto the normal to get the required translation along the normal
|
|
translation_along_normal = np.dot(center_to_centroid, normal) * normal
|
|
|
|
return translation_along_normal
|
|
|
|
def add_lines_for_interactor(self, input_lines: list):
|
|
"""Takes Line2D objects from the sketch widget and preparesit for interactor mesh.
|
|
Translates coordinates."""
|
|
|
|
points_for_interact = []
|
|
for line in input_lines:
|
|
# Handle improved sketcher Line2D objects with start/end Point2D objects
|
|
if hasattr(line, 'start') and hasattr(line, 'end'):
|
|
start_point = (line.start.x, line.start.y)
|
|
end_point = (line.end.x, line.end.y)
|
|
else:
|
|
# Fallback for old-style line objects
|
|
from_coord_start = window.sketchWidget.from_quadrant_coords_no_center(line.crd1.ui_point)
|
|
from_coord_end = window.sketchWidget.from_quadrant_coords_no_center(line.crd2.ui_point)
|
|
start_point = self.translate_points_tup(from_coord_start)
|
|
end_point = self.translate_points_tup(from_coord_end)
|
|
|
|
line_tuple = (start_point, end_point)
|
|
points_for_interact.append(line_tuple)
|
|
|
|
print("packed_lines", points_for_interact)
|
|
self.lines = points_for_interact
|
|
|
|
@dataclass
|
|
class Body:
|
|
"""The actual body as sdf3 object"""
|
|
id = None
|
|
sketch = None
|
|
height = None
|
|
interactor = None
|
|
sdf_body = None
|
|
|
|
def mirror_body(self, sdf_object3d):
|
|
f = sdf_object3d.rotate(pi)
|
|
|
|
return f
|
|
|
|
class Output:
|
|
def export_mesh(self, sdf_object):
|
|
"""FINAL EXPORT"""
|
|
result_points = sdf_object.generate()
|
|
write_binary_stl('out.stl', result_points)
|
|
|
|
def generate_mesh_from_code(self, code_text: str):
|
|
local_vars = {}
|
|
|
|
try:
|
|
print(code_text)
|
|
exec(code_text, globals(), local_vars)
|
|
# Retrieve the result from the captured local variables
|
|
result = local_vars.get('result')
|
|
print("Result:", result)
|
|
|
|
except Exception as e:
|
|
print("Error executing code:", e)
|
|
|
|
class Project:
|
|
"""Project -> Timeline -> Component -> Sketch -> Body / Interactor -> Connector -> Assembly -> PB Render"""
|
|
timeline: Timeline = None
|
|
assembly: Assembly = None
|
|
|
|
if __name__ == "__main__":
|
|
app = QApplication()
|
|
window = MainWindow()
|
|
window.show()
|
|
app.exec()
|
|
|
|
|