Files
fluencyCAD/main.py
2025-11-16 17:48:05 +01:00

1479 lines
60 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')
# Connect the "Perform Cut" checkbox to automatically check "Combine"
self.cut_checkbox.stateChanged.connect(self.on_cut_checkbox_changed)
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 on_cut_checkbox_changed(self, state):
"""Automatically check 'Combine' when 'Perform Cut' is checked"""
if state == Qt.Checked:
self.union_checkbox.setChecked(True)
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))
# Keep the construction mode button for construction mode only
self.ui.pb_enable_construct.clicked.connect(lambda checked: self.sketchWidget.set_construction_mode(checked))
### 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()
# Reset sketch widget mode to NONE after projection to prevent line mode engagement
self.sketchWidget.set_mode(SketchMode.NONE)
self.ui.pb_linetool.setChecked(False)
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(SketchMode.NONE)
# Reset construction button
self.ui.pb_enable_construct.setChecked(False)
def on_geometry_created(self, geometry):
"""Handle geometry creation from the improved sketcher."""
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):
current_item = self.ui.body_list.currentItem()
if current_item is None:
# No item selected, clear the display
self.custom_3D_Widget.clear_body_actors()
self.custom_3D_Widget.clear_actors_interactor()
return
name = current_item.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
compo_id = self.get_activated_compo()
sel_compo = self.project.timeline[compo_id]
if item_name in sel_compo.bodies:
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
del sel_compo.bodies[item_name] # Remove the item from the operation dictionary
print(f"Removed operation: {item_name}")
# Clear both body actors and interactor actors
self.custom_3D_Widget.clear_body_actors()
self.custom_3D_Widget.clear_actors_interactor()
# Redraw remaining meshes
self.draw_mesh()
else:
print(f"Body '{item_name}' not found in component")
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)
# Apply fillet/rounding if requested
if rounded:
# Apply a small fillet radius, adjust as needed
fillet_radius = min(length * 0.1, 2.0) # 10% of height or 2mm, whichever is smaller
try:
f = f.fillet(fillet_radius)
print(f"Applied fillet with radius {fillet_radius}mm")
except Exception as e:
print(f"Warning: Could not apply fillet: {e}")
# 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)
# Handle automatic cut operation if requested
if cut and len(sel_compo.bodies) > 1:
# Find the most recently created body (other than the one we just created)
body_names = list(sel_compo.bodies.keys())
if len(body_names) > 1:
# Get the last body created before this one
previous_body_name = body_names[-2] # Second to last item
previous_body = sel_compo.bodies[previous_body_name]
# Perform cut operation: previous_body - new_body (cut the previous body with the new extrusion)
try:
cut_result = previous_body.sdf_body - body.sdf_body
# Create a new body entry for the cut result
cut_name = f"cut-{previous_body_name}"
# Create new body for the cut result
cut_body = Body()
cut_body.id = cut_name
cut_body.sdf_body = cut_result
cut_body.sketch = previous_body.sketch # Keep the sketch reference
# Create new interactor for the cut result
cut_interactor = Interactor()
# Copy interactor properties from the previous body as a starting point
# This preserves the original interactor information
if hasattr(previous_body, 'interactor') and previous_body.interactor:
# Preserve the original interactor lines (these define the sketch outline)
cut_interactor.lines = previous_body.interactor.lines
cut_interactor.invert = previous_body.interactor.invert
# Determine height from previous body for consistency
prev_height = 50.0 # Default height
if hasattr(previous_body.interactor, 'edges') and previous_body.interactor.edges:
# Try to determine height from existing edges
if len(previous_body.interactor.edges) > 0:
edge = previous_body.interactor.edges[0]
if len(edge) == 2 and len(edge[0]) == 3 and len(edge[1]) == 3:
prev_height = abs(edge[1][2] - edge[0][2])
# Generate new interactor mesh for the cut result using the same parameters
if cut_interactor.invert:
cut_edges = interactor_mesh.generate_mesh(cut_interactor.lines, 0, -prev_height)
else:
cut_edges = interactor_mesh.generate_mesh(cut_interactor.lines, 0, prev_height)
cut_interactor.edges = cut_edges
# Copy offset vector
if hasattr(previous_body.interactor, 'offset_vector'):
cut_interactor.offset_vector = previous_body.interactor.offset_vector
else:
cut_interactor.offset_vector = [0, 0, 0]
else:
# Fallback: create basic interactor
cut_interactor.lines = []
cut_interactor.invert = False
cut_interactor.edges = []
cut_interactor.offset_vector = [0, 0, 0]
cut_body.interactor = cut_interactor
# Add cut body to component
sel_compo.bodies[cut_name] = cut_body
# Add to UI
self.ui.body_list.addItem(cut_name)
cut_items = self.ui.body_list.findItems(cut_name, Qt.MatchExactly)
if cut_items:
self.ui.body_list.setCurrentItem(cut_items[-1])
# Load the interactor mesh for the cut result BEFORE cleaning up
self.custom_3D_Widget.load_interactor_mesh(cut_edges, cut_interactor.offset_vector)
# Hide the original bodies that were used in the cut operation
# Find and remove the items from the UI list
items_to_remove = []
for i in range(self.ui.body_list.count()):
item = self.ui.body_list.item(i)
if item and item.text() in [previous_body_name, name_op]:
items_to_remove.append(item)
# Actually remove items from UI
for item in items_to_remove:
row = self.ui.body_list.row(item)
self.ui.body_list.takeItem(row)
# Remove the original bodies from the component
if previous_body_name in sel_compo.bodies:
del sel_compo.bodies[previous_body_name]
if name_op in sel_compo.bodies:
del sel_compo.bodies[name_op]
# Clear the VTK widget and redraw with the cut result only
self.custom_3D_Widget.clear_body_actors()
# Don't clear interactor actors yet, as we want to show the new interactor mesh
print(f"Performed automatic cut: {previous_body_name} - {name_op} = {cut_name}")
except Exception as e:
print(f"Error performing automatic cut operation: {e}")
else:
print("Not enough bodies for cut operation")
elif cut:
print("Perform cut was checked, but no other bodies exist to cut with")
self.draw_mesh()
def send_cut(self):
"""Perform a cut operation using SDF difference"""
selected = self.ui.body_list.currentItem()
if not selected:
print("No body selected for cut operation")
return
name = selected.text()
sel_compo = self.project.timeline[self.get_activated_compo()]
body = sel_compo.bodies[name]
self.list_selected.append(body.sdf_body)
if len(self.list_selected) == 2:
# Use the SDF3 class's __sub__ operator for difference operation
try:
# First body is the base, second is the tool to cut with
base_body = self.list_selected[0]
tool_body = self.list_selected[1]
f = base_body - tool_body # This uses the __sub__ operator
# 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)
if items:
self.ui.body_list.setCurrentItem(items[-1])
self.custom_3D_Widget.clear_body_actors()
self.draw_mesh()
# Clear the selection list for next operation
self.list_selected.clear()
except Exception as e:
print(f"Error performing cut operation: {e}")
self.list_selected.clear()
elif len(self.list_selected) > 2:
self.list_selected.clear()
print("Too many bodies selected. Please select exactly two bodies.")
else:
print("Please select two bodies to perform cut operation. Currently selected: ", len(self.list_selected))
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_geometry_for_sdf(self, sketch):
"""Convert sketch geometry (points, lines, circles) to SDF polygon points"""
import math
points_for_sdf = []
# Keep track of points that are part of circles to avoid adding them as standalone points
circle_centers = []
# Handle circles by converting them to separate polygons
circle_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
circle_points_list = []
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)
circle_points_list.append((x, y))
# Close the circle by adding the first point at the end
if circle_points_list:
circle_points_list.append(circle_points_list[0])
circle_polygons.append(circle_points_list)
# Keep track of circle centers to avoid adding them as standalone points
circle_centers.append(circle.center)
# Handle lines by creating ordered polygons from connected line segments
rectangle_polygons = []
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:
# Group lines into separate connected components (separate polygons)
grouped_lines = self._group_connected_lines(non_helper_lines)
# For each group of connected lines, trace the outline
for i, group in enumerate(grouped_lines):
ordered_points = self._trace_connected_lines(group)
rectangle_polygons.append(ordered_points)
# Combine polygons with proper separation
# Each polygon needs to be closed and separated from others
all_polygons = rectangle_polygons + circle_polygons
for i, polygon in enumerate(all_polygons):
points_for_sdf.extend(polygon)
# Add a small gap between polygons if not the last one
if i < len(all_polygons) - 1:
# Add a duplicate point to separate polygons
if polygon:
points_for_sdf.append(polygon[-1])
# 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 self._points_equal(line.start, point) or self._points_equal(line.end, point):
is_standalone = False
break
# Check if point is center of any circle
if hasattr(sketch, 'circles'):
for circle in sketch.circles:
if self._points_equal(circle.center, point):
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 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
# Handle individual points (if any)
if hasattr(sketch, 'points') and sketch.points:
# Create a set of all points that are already part of lines or circles
used_points = set()
# Add line endpoint points
if hasattr(sketch, 'lines'):
for line in sketch.lines:
used_points.add(line.start)
used_points.add(line.end)
# Add circle points (both center and perimeter points)
if hasattr(sketch, 'circles'):
for circle in sketch.circles:
used_points.add(circle.center)
# Add perimeter points (approximated)
num_segments = 32
for i in range(num_segments):
angle = 2 * math.pi * i / num_segments
x = circle.center.x + circle.radius * math.cos(angle)
y = circle.center.y + circle.radius * math.sin(angle)
# We don't add perimeter points to used_points since they're not Point2D objects
# Add standalone points that are not part of any geometry
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)
if point not in used_points:
points_for_sdf.append((point.x, point.y))
def _group_connected_lines(self, lines):
"""Group lines into connected components (separate polygons)"""
if not lines:
return []
groups = []
used_lines = set()
for line in lines:
if line not in used_lines:
# Start a new group with this line
group = [line]
used_lines.add(line)
current_group_lines = {line}
# Find all connected lines
changed = True
while changed:
changed = False
for other_line in lines:
if other_line not in used_lines:
# Check if this line connects to any line in the current group
for group_line in current_group_lines:
if (self._points_equal(group_line.start, other_line.start) or
self._points_equal(group_line.start, other_line.end) or
self._points_equal(group_line.end, other_line.start) or
self._points_equal(group_line.end, other_line.end)):
group.append(other_line)
used_lines.add(other_line)
current_group_lines.add(other_line)
changed = True
break
groups.append(group)
return groups
def _trace_connected_lines(self, lines):
"""Trace connected line segments to create ordered polygon points without duplicates.
Groups lines into separate connected components and closes each polygon individually."""
if not lines:
return []
# Group lines into separate connected components (separate polygons)
grouped_lines = self._group_connected_lines(lines)
# For each group of connected lines, trace the outline and close the polygon
all_ordered_points = []
for group in grouped_lines:
if not group:
continue
# Start with the first line in the group
current_line = group[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(group):
# 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 group:
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 in this group
break
used_lines.add(next_line)
# Close this individual polygon if not already closed
if len(ordered_points) > 2:
first_point = ordered_points[0]
last_point = ordered_points[-1]
# Check if first and last points are the same (closed)
if abs(first_point[0] - last_point[0]) > 1e-6 or abs(first_point[1] - last_point[1]) > 1e-6:
# Not closed, add the first point at the end to close it
ordered_points.append(first_point)
# Add this polygon's points to the overall list
all_ordered_points.extend(ordered_points)
return all_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.
"""
# Handle zero height case
if height <= 0:
print("Warning: Extrude height must be positive. Using default height of 1.0")
height = 1.0
# Normalize the normal vector, with fallback to default if invalid
try:
normal = np.array(self.normal, dtype=float)
norm = np.linalg.norm(normal)
if norm > 1e-10: # Check if normal is not zero
normal = normal / norm
else:
print("Warning: Invalid normal vector. Using default Z-axis normal.")
normal = np.array([0, 0, 1])
except Exception as e:
print(f"Warning: Error processing normal vector: {e}. Using default Z-axis normal.")
normal = np.array([0, 0, 1])
# Create the 2D shape
try:
# Handle multiple separate polygons by creating union of shapes
if hasattr(self, 'sdf_points') and self.sdf_points:
# Split points into separate polygons (closed shapes)
polygons = self._split_into_polygons(self.sdf_points)
if len(polygons) == 1:
# Single polygon case
f = polygon(polygons[0])
elif len(polygons) > 1:
# Multiple polygons case - create union
shapes = []
for poly_points in polygons:
if len(poly_points) >= 3: # Need at least 3 points for a valid polygon
shape = polygon(poly_points)
shapes.append(shape)
# Union all shapes together
if shapes:
f = shapes[0]
for shape in shapes[1:]:
f = f | shape # Union operation
else:
# Fallback to a simple rectangle if no valid polygons
fallback_points = [(-10, -10), (10, -10), (10, 10), (-10, 10)]
f = polygon(fallback_points)
else:
# No valid polygons, fallback to a simple rectangle
fallback_points = [(-10, -10), (10, -10), (10, 10), (-10, 10)]
f = polygon(fallback_points)
else:
# Fallback to a simple rectangle if no points
fallback_points = [(-10, -10), (10, -10), (10, 10), (-10, 10)]
f = polygon(fallback_points)
except Exception as e:
print(f"Error creating polygon: {e}")
# Fallback to a simple rectangle if points are invalid
fallback_points = [(-10, -10), (10, -10), (10, 10), (-10, 10)]
f = polygon(fallback_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 (only if normal is not the default Z-axis)
default_z = np.array([0, 0, 1])
if not np.allclose(normal, default_z, atol=1e-10):
try:
f = f.orient(normal)
except Exception as e:
print(f"Warning: Failed to orient shape: {e}. Shape will remain in default orientation.")
# Calculate offset vector
try:
offset_vector = self.vector_to_centroid(None, self.origin, normal)
except Exception as e:
print(f"Warning: Error calculating offset vector: {e}. Using zero offset.")
offset_vector = np.array([0, 0, 0])
# Apply translation based on invert flag
if invert:
# Adjust the offset vector by subtracting the extrusion height along the normal direction
adjusted_offset = offset_vector - (normal * height)
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:
try:
# 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
scaled_offset = offset_vector_norm * offset_length
f = f.translate(scaled_offset)
else:
print("Warning: Offset vector has zero magnitude. Using original vector.")
except Exception as e:
print(f"Warning: Error applying offset length: {e}")
return f
def _split_into_polygons(self, points):
"""Split a list of points into separate closed polygons"""
if not points:
return []
polygons = []
current_polygon = []
for point in points:
current_polygon.append(point)
# Check if this point closes the current polygon
# A polygon is closed when the last point equals the first point
if len(current_polygon) > 2 and current_polygon[0] == current_polygon[-1]:
# This polygon is closed, add it to the list
polygons.append(current_polygon)
current_polygon = []
# If there's an incomplete polygon left, add it anyway
if current_polygon:
polygons.append(current_polygon)
return polygons
def _trace_connected_lines(self, lines):
"""Trace connected line segments to create ordered polygon points without duplicates"""
if not lines:
return []
# Group lines into separate connected components (separate polygons)
grouped_lines = self._group_connected_lines(lines)
# For each group of connected lines, trace the outline and close the polygon
all_ordered_points = []
for group in grouped_lines:
if not group:
continue
# Start with the first line in the group
current_line = group[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(group):
# 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 group:
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 in this group
break
used_lines.add(next_line)
# Close this individual polygon if not already closed
if len(ordered_points) > 2:
first_point = ordered_points[0]
last_point = ordered_points[-1]
# Check if first and last points are the same (closed)
if abs(first_point[0] - last_point[0]) > 1e-6 or abs(first_point[1] - last_point[1]) > 1e-6:
# Not closed, add the first point at the end to close it
ordered_points.append(first_point)
# Add this polygon's points to the overall list
all_ordered_points.extend(ordered_points)
return all_ordered_points
def _group_connected_lines(self, lines):
"""Group lines into connected components (separate polygons)"""
if not lines:
return []
groups = []
used_lines = set()
for line in lines:
if line not in used_lines:
# Start a new group with this line
group = [line]
used_lines.add(line)
current_group_lines = {line}
# Find all connected lines
changed = True
while changed:
changed = False
for other_line in lines:
if other_line not in used_lines:
# Check if this line connects to any line in the current group
for group_line in current_group_lines:
if (self._points_equal(group_line.start, other_line.start) or
self._points_equal(group_line.start, other_line.end) or
self._points_equal(group_line.end, other_line.start) or
self._points_equal(group_line.end, other_line.end)):
group.append(other_line)
used_lines.add(other_line)
current_group_lines.add(other_line)
changed = True
break
groups.append(group)
return groups
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
@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
# Handle case where normal might be invalid or zero
try:
normal_array = np.array(normal)
if np.linalg.norm(normal_array) > 1e-10: # Check if normal is not zero
translation_along_normal = np.dot(center_to_centroid, normal_array) * normal_array
else:
# Use default Z-axis if normal is zero
default_normal = np.array([0, 0, 1])
translation_along_normal = np.dot(center_to_centroid, default_normal) * default_normal
except Exception as e:
print(f"Warning: Error in normal computation: {e}. Using default Z-axis.")
default_normal = np.array([0, 0, 1])
translation_along_normal = np.dot(center_to_centroid, default_normal) * default_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()