# 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()