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