diff --git a/.idea/workspace.xml b/.idea/workspace.xml
index db4ca12..f498644 100644
--- a/.idea/workspace.xml
+++ b/.idea/workspace.xml
@@ -4,11 +4,31 @@
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
@@ -25,7 +45,7 @@
@@ -59,12 +79,14 @@
"Python.meshtest.executor": "Run",
"Python.side_fluency.executor": "Run",
"Python.simple_mesh.executor": "Run",
+ "Python.test_vtk_sketch_widget.executor": "Run",
+ "Python.vtk_sketch_widget.executor": "Run",
"Python.vtk_widget.executor": "Run",
"Python.vulkan.executor": "Run",
"RunOnceActivity.OpenProjectViewOnStart": "true",
"RunOnceActivity.ShowReadmeOnStart": "true",
"RunOnceActivity.git.unshallow": "true",
- "git-widget-placeholder": "master",
+ "git-widget-placeholder": "single__window",
"last_opened_file_path": "/Volumes/Data_drive/Programming/fluency",
"settings.editor.selected.configurable": "project.propVCSSupport.DirectoryMappings"
}
@@ -81,13 +103,14 @@
+
-
+
@@ -252,7 +275,15 @@
1755369224187
-
+
+
+ 1763311700335
+
+
+
+ 1763311700335
+
+
@@ -287,6 +318,7 @@
-
+
+
\ No newline at end of file
diff --git a/Gui.py b/Gui.py
deleted file mode 100644
index 3ac61e0..0000000
--- a/Gui.py
+++ /dev/null
@@ -1,727 +0,0 @@
-# -*- coding: utf-8 -*-
-
-################################################################################
-## Form generated from reading UI file 'gui.ui'
-##
-## Created by: Qt User Interface Compiler version 6.6.1
-##
-## WARNING! All changes made in this file will be lost when recompiling UI file!
-################################################################################
-
-from PySide6.QtCore import (QCoreApplication, QDate, QDateTime, QLocale,
- QMetaObject, QObject, QPoint, QRect,
- QSize, QTime, QUrl, Qt)
-from PySide6.QtGui import (QAction, QBrush, QColor, QConicalGradient,
- QCursor, QFont, QFontDatabase, QGradient,
- QIcon, QImage, QKeySequence, QLinearGradient,
- QPainter, QPalette, QPixmap, QRadialGradient,
- QTransform)
-from PySide6.QtWidgets import (QApplication, QFrame, QGridLayout, QGroupBox,
- QHBoxLayout, QLabel, QListWidget, QListWidgetItem,
- QMainWindow, QMenu, QMenuBar, QPushButton,
- QSizePolicy, QSpinBox, QStatusBar, QTabWidget,
- QTextEdit, QVBoxLayout, QWidget)
-
-class Ui_fluencyCAD(object):
- def setupUi(self, fluencyCAD):
- if not fluencyCAD.objectName():
- fluencyCAD.setObjectName(u"fluencyCAD")
- fluencyCAD.resize(2192, 1109)
- self.actionNew_Project = QAction(fluencyCAD)
- self.actionNew_Project.setObjectName(u"actionNew_Project")
- self.actionLoad_Project = QAction(fluencyCAD)
- self.actionLoad_Project.setObjectName(u"actionLoad_Project")
- self.actionRecent = QAction(fluencyCAD)
- self.actionRecent.setObjectName(u"actionRecent")
- self.centralwidget = QWidget(fluencyCAD)
- self.centralwidget.setObjectName(u"centralwidget")
- self.gridLayout = QGridLayout(self.centralwidget)
- self.gridLayout.setObjectName(u"gridLayout")
- self.InputTab = QTabWidget(self.centralwidget)
- self.InputTab.setObjectName(u"InputTab")
- sizePolicy = QSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
- sizePolicy.setHorizontalStretch(0)
- sizePolicy.setVerticalStretch(0)
- sizePolicy.setHeightForWidth(self.InputTab.sizePolicy().hasHeightForWidth())
- self.InputTab.setSizePolicy(sizePolicy)
- self.sketch_tab = QWidget()
- self.sketch_tab.setObjectName(u"sketch_tab")
- self.verticalLayout_4 = QVBoxLayout(self.sketch_tab)
- self.verticalLayout_4.setObjectName(u"verticalLayout_4")
- self.InputTab.addTab(self.sketch_tab, "")
- self.code_tab = QWidget()
- self.code_tab.setObjectName(u"code_tab")
- self.verticalLayout = QVBoxLayout(self.code_tab)
- self.verticalLayout.setObjectName(u"verticalLayout")
- self.textEdit = QTextEdit(self.code_tab)
- self.textEdit.setObjectName(u"textEdit")
-
- self.verticalLayout.addWidget(self.textEdit)
-
- self.groupBox_7 = QGroupBox(self.code_tab)
- self.groupBox_7.setObjectName(u"groupBox_7")
- self.gridLayout_5 = QGridLayout(self.groupBox_7)
- self.gridLayout_5.setObjectName(u"gridLayout_5")
- self.pushButton_5 = QPushButton(self.groupBox_7)
- self.pushButton_5.setObjectName(u"pushButton_5")
-
- self.gridLayout_5.addWidget(self.pushButton_5, 2, 0, 1, 1)
-
- self.pushButton_4 = QPushButton(self.groupBox_7)
- self.pushButton_4.setObjectName(u"pushButton_4")
-
- self.gridLayout_5.addWidget(self.pushButton_4, 2, 1, 1, 1)
-
- self.pb_apply_code = QPushButton(self.groupBox_7)
- self.pb_apply_code.setObjectName(u"pb_apply_code")
-
- self.gridLayout_5.addWidget(self.pb_apply_code, 1, 0, 1, 1)
-
- self.pushButton = QPushButton(self.groupBox_7)
- self.pushButton.setObjectName(u"pushButton")
-
- self.gridLayout_5.addWidget(self.pushButton, 1, 1, 1, 1)
-
-
- self.verticalLayout.addWidget(self.groupBox_7)
-
- self.InputTab.addTab(self.code_tab, "")
-
- self.gridLayout.addWidget(self.InputTab, 0, 1, 9, 1)
-
- self.gl_box = QGroupBox(self.centralwidget)
- self.gl_box.setObjectName(u"gl_box")
- sizePolicy1 = QSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
- sizePolicy1.setHorizontalStretch(0)
- sizePolicy1.setVerticalStretch(4)
- sizePolicy1.setHeightForWidth(self.gl_box.sizePolicy().hasHeightForWidth())
- self.gl_box.setSizePolicy(sizePolicy1)
- font = QFont()
- font.setPointSize(12)
- self.gl_box.setFont(font)
- self.horizontalLayout_4 = QHBoxLayout(self.gl_box)
-#ifndef Q_OS_MAC
- self.horizontalLayout_4.setSpacing(-1)
-#endif
- self.horizontalLayout_4.setObjectName(u"horizontalLayout_4")
- self.horizontalLayout_4.setContentsMargins(12, -1, -1, -1)
-
- self.gridLayout.addWidget(self.gl_box, 0, 2, 9, 1)
-
- self.groupBox = QGroupBox(self.centralwidget)
- self.groupBox.setObjectName(u"groupBox")
- self.gridLayout_3 = QGridLayout(self.groupBox)
- self.gridLayout_3.setObjectName(u"gridLayout_3")
- self.pb_revop = QPushButton(self.groupBox)
- self.pb_revop.setObjectName(u"pb_revop")
-
- self.gridLayout_3.addWidget(self.pb_revop, 2, 1, 1, 1)
-
- self.pb_extrdop = QPushButton(self.groupBox)
- self.pb_extrdop.setObjectName(u"pb_extrdop")
-
- self.gridLayout_3.addWidget(self.pb_extrdop, 0, 0, 1, 1)
-
- self.pb_arrayop = QPushButton(self.groupBox)
- self.pb_arrayop.setObjectName(u"pb_arrayop")
-
- self.gridLayout_3.addWidget(self.pb_arrayop, 2, 0, 1, 1)
-
- self.pb_cutop = QPushButton(self.groupBox)
- self.pb_cutop.setObjectName(u"pb_cutop")
-
- self.gridLayout_3.addWidget(self.pb_cutop, 0, 1, 1, 1)
-
- self.pb_combop = QPushButton(self.groupBox)
- self.pb_combop.setObjectName(u"pb_combop")
-
- self.gridLayout_3.addWidget(self.pb_combop, 1, 0, 1, 1)
-
- self.pb_moveop = QPushButton(self.groupBox)
- self.pb_moveop.setObjectName(u"pb_moveop")
-
- self.gridLayout_3.addWidget(self.pb_moveop, 1, 1, 1, 1)
-
-
- self.gridLayout.addWidget(self.groupBox, 0, 3, 1, 1, Qt.AlignTop)
-
- self.compo_box = QGroupBox(self.centralwidget)
- self.compo_box.setObjectName(u"compo_box")
- self.compo_box.setMinimumSize(QSize(0, 50))
-
- self.gridLayout.addWidget(self.compo_box, 9, 1, 1, 2)
-
- self.groupBox_10 = QGroupBox(self.centralwidget)
- self.groupBox_10.setObjectName(u"groupBox_10")
- sizePolicy2 = QSizePolicy(QSizePolicy.Preferred, QSizePolicy.Expanding)
- sizePolicy2.setHorizontalStretch(0)
- sizePolicy2.setVerticalStretch(0)
- sizePolicy2.setHeightForWidth(self.groupBox_10.sizePolicy().hasHeightForWidth())
- self.groupBox_10.setSizePolicy(sizePolicy2)
- self.groupBox_10.setMaximumSize(QSize(200, 16777215))
- self.verticalLayout_6 = QVBoxLayout(self.groupBox_10)
- self.verticalLayout_6.setObjectName(u"verticalLayout_6")
- self.verticalLayout_6.setContentsMargins(5, 5, 5, 5)
- self.body_list = QListWidget(self.groupBox_10)
- self.body_list.setObjectName(u"body_list")
- self.body_list.setSelectionRectVisible(True)
-
- self.verticalLayout_6.addWidget(self.body_list)
-
- self.groupBox_8 = QGroupBox(self.groupBox_10)
- self.groupBox_8.setObjectName(u"groupBox_8")
- sizePolicy3 = QSizePolicy(QSizePolicy.Preferred, QSizePolicy.Preferred)
- sizePolicy3.setHorizontalStretch(0)
- sizePolicy3.setVerticalStretch(0)
- sizePolicy3.setHeightForWidth(self.groupBox_8.sizePolicy().hasHeightForWidth())
- self.groupBox_8.setSizePolicy(sizePolicy3)
- self.groupBox_8.setMaximumSize(QSize(200, 16777215))
- self.gridLayout_8 = QGridLayout(self.groupBox_8)
- self.gridLayout_8.setObjectName(u"gridLayout_8")
- self.gridLayout_8.setContentsMargins(2, 2, 2, 2)
- self.pb_del_body = QPushButton(self.groupBox_8)
- self.pb_del_body.setObjectName(u"pb_del_body")
-
- self.gridLayout_8.addWidget(self.pb_del_body, 0, 2, 1, 1)
-
- self.pb_update_body = QPushButton(self.groupBox_8)
- self.pb_update_body.setObjectName(u"pb_update_body")
-
- self.gridLayout_8.addWidget(self.pb_update_body, 0, 0, 1, 1)
-
- self.pb_edt_sktch_3 = QPushButton(self.groupBox_8)
- self.pb_edt_sktch_3.setObjectName(u"pb_edt_sktch_3")
-
- self.gridLayout_8.addWidget(self.pb_edt_sktch_3, 0, 1, 1, 1)
-
-
- self.verticalLayout_6.addWidget(self.groupBox_8)
-
-
- self.gridLayout.addWidget(self.groupBox_10, 7, 3, 2, 1)
-
- self.groupBox_11 = QGroupBox(self.centralwidget)
- self.groupBox_11.setObjectName(u"groupBox_11")
- sizePolicy2.setHeightForWidth(self.groupBox_11.sizePolicy().hasHeightForWidth())
- self.groupBox_11.setSizePolicy(sizePolicy2)
- self.groupBox_11.setMaximumSize(QSize(200, 16777215))
- self.verticalLayout_7 = QVBoxLayout(self.groupBox_11)
- self.verticalLayout_7.setObjectName(u"verticalLayout_7")
- self.verticalLayout_7.setContentsMargins(5, 5, 5, 5)
- self.sketch_list = QListWidget(self.groupBox_11)
- self.sketch_list.setObjectName(u"sketch_list")
- sizePolicy4 = QSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
- sizePolicy4.setHorizontalStretch(0)
- sizePolicy4.setVerticalStretch(0)
- sizePolicy4.setHeightForWidth(self.sketch_list.sizePolicy().hasHeightForWidth())
- self.sketch_list.setSizePolicy(sizePolicy4)
- self.sketch_list.setSelectionRectVisible(True)
-
- self.verticalLayout_7.addWidget(self.sketch_list)
-
- self.groupBox_6 = QGroupBox(self.groupBox_11)
- self.groupBox_6.setObjectName(u"groupBox_6")
- sizePolicy3.setHeightForWidth(self.groupBox_6.sizePolicy().hasHeightForWidth())
- self.groupBox_6.setSizePolicy(sizePolicy3)
- self.gridLayout_6 = QGridLayout(self.groupBox_6)
- self.gridLayout_6.setObjectName(u"gridLayout_6")
- self.gridLayout_6.setContentsMargins(2, 2, 2, 2)
- self.pb_edt_sktch = QPushButton(self.groupBox_6)
- self.pb_edt_sktch.setObjectName(u"pb_edt_sktch")
-
- self.gridLayout_6.addWidget(self.pb_edt_sktch, 1, 1, 1, 1)
-
- self.pb_nw_sktch = QPushButton(self.groupBox_6)
- self.pb_nw_sktch.setObjectName(u"pb_nw_sktch")
-
- self.gridLayout_6.addWidget(self.pb_nw_sktch, 1, 0, 1, 1)
-
- self.pb_del_sketch = QPushButton(self.groupBox_6)
- self.pb_del_sketch.setObjectName(u"pb_del_sketch")
-
- self.gridLayout_6.addWidget(self.pb_del_sketch, 1, 2, 1, 1)
-
-
- self.verticalLayout_7.addWidget(self.groupBox_6)
-
-
- self.gridLayout.addWidget(self.groupBox_11, 6, 0, 3, 1)
-
- self.assmbly_box = QGroupBox(self.centralwidget)
- self.assmbly_box.setObjectName(u"assmbly_box")
- self.assmbly_box.setMinimumSize(QSize(0, 50))
- self.gridLayout_10 = QGridLayout(self.assmbly_box)
- self.gridLayout_10.setObjectName(u"gridLayout_10")
- self.pushButton_3 = QPushButton(self.assmbly_box)
- self.pushButton_3.setObjectName(u"pushButton_3")
- self.pushButton_3.setMinimumSize(QSize(50, 50))
- self.pushButton_3.setMaximumSize(QSize(50, 50))
-
- self.gridLayout_10.addWidget(self.pushButton_3, 0, 0, 1, 1)
-
- self.pushButton_6 = QPushButton(self.assmbly_box)
- self.pushButton_6.setObjectName(u"pushButton_6")
- self.pushButton_6.setMinimumSize(QSize(50, 50))
- self.pushButton_6.setMaximumSize(QSize(50, 50))
-
- self.gridLayout_10.addWidget(self.pushButton_6, 0, 1, 1, 1)
-
-
- self.gridLayout.addWidget(self.assmbly_box, 9, 3, 1, 1)
-
- self.groupBox_4 = QGroupBox(self.centralwidget)
- self.groupBox_4.setObjectName(u"groupBox_4")
- self.verticalLayout_2 = QVBoxLayout(self.groupBox_4)
- self.verticalLayout_2.setObjectName(u"verticalLayout_2")
- self.pushButton_2 = QPushButton(self.groupBox_4)
- self.pushButton_2.setObjectName(u"pushButton_2")
-
- self.verticalLayout_2.addWidget(self.pushButton_2)
-
-
- self.gridLayout.addWidget(self.groupBox_4, 6, 3, 1, 1)
-
- self.compo_tool_box = QGroupBox(self.centralwidget)
- self.compo_tool_box.setObjectName(u"compo_tool_box")
- self.compo_tool_box.setMinimumSize(QSize(0, 50))
- self.gridLayout_9 = QGridLayout(self.compo_tool_box)
- self.gridLayout_9.setObjectName(u"gridLayout_9")
- self.new_compo = QPushButton(self.compo_tool_box)
- self.new_compo.setObjectName(u"new_compo")
- self.new_compo.setMinimumSize(QSize(50, 50))
- self.new_compo.setMaximumSize(QSize(50, 50))
-
- self.gridLayout_9.addWidget(self.new_compo, 0, 0, 1, 1)
-
- self.del_compo = QPushButton(self.compo_tool_box)
- self.del_compo.setObjectName(u"del_compo")
- self.del_compo.setEnabled(True)
- sizePolicy3.setHeightForWidth(self.del_compo.sizePolicy().hasHeightForWidth())
- self.del_compo.setSizePolicy(sizePolicy3)
- self.del_compo.setMinimumSize(QSize(50, 50))
- self.del_compo.setMaximumSize(QSize(50, 50))
- self.del_compo.setLayoutDirection(Qt.LeftToRight)
-
- self.gridLayout_9.addWidget(self.del_compo, 0, 1, 1, 1)
-
-
- self.gridLayout.addWidget(self.compo_tool_box, 9, 0, 1, 1)
-
- self.groupBox_9 = QGroupBox(self.centralwidget)
- self.groupBox_9.setObjectName(u"groupBox_9")
- self.groupBox_9.setMaximumSize(QSize(200, 16777215))
- self.gridLayout_7 = QGridLayout(self.groupBox_9)
- self.gridLayout_7.setObjectName(u"gridLayout_7")
- self.pb_origin_wp = QPushButton(self.groupBox_9)
- self.pb_origin_wp.setObjectName(u"pb_origin_wp")
-
- self.gridLayout_7.addWidget(self.pb_origin_wp, 0, 0, 1, 1)
-
- self.pb_origin_face = QPushButton(self.groupBox_9)
- self.pb_origin_face.setObjectName(u"pb_origin_face")
-
- self.gridLayout_7.addWidget(self.pb_origin_face, 0, 1, 1, 1)
-
- self.pb_flip_face = QPushButton(self.groupBox_9)
- self.pb_flip_face.setObjectName(u"pb_flip_face")
-
- self.gridLayout_7.addWidget(self.pb_flip_face, 1, 0, 1, 1)
-
- self.pb_move_wp = QPushButton(self.groupBox_9)
- self.pb_move_wp.setObjectName(u"pb_move_wp")
-
- self.gridLayout_7.addWidget(self.pb_move_wp, 1, 1, 1, 1)
-
-
- self.gridLayout.addWidget(self.groupBox_9, 0, 0, 1, 1)
-
- self.groupBox_2 = QGroupBox(self.centralwidget)
- self.groupBox_2.setObjectName(u"groupBox_2")
- sizePolicy3.setHeightForWidth(self.groupBox_2.sizePolicy().hasHeightForWidth())
- self.groupBox_2.setSizePolicy(sizePolicy3)
- self.groupBox_2.setMaximumSize(QSize(200, 16777215))
- self.gridLayout_2 = QGridLayout(self.groupBox_2)
- self.gridLayout_2.setObjectName(u"gridLayout_2")
- self.gridLayout_2.setContentsMargins(10, -1, -1, -1)
- self.line = QFrame(self.groupBox_2)
- self.line.setObjectName(u"line")
- self.line.setFrameShape(QFrame.HLine)
- self.line.setFrameShadow(QFrame.Sunken)
-
- self.gridLayout_2.addWidget(self.line, 4, 0, 1, 2)
-
- self.pb_circtool = QPushButton(self.groupBox_2)
- self.pb_circtool.setObjectName(u"pb_circtool")
- self.pb_circtool.setCheckable(True)
- self.pb_circtool.setAutoExclusive(False)
-
- self.gridLayout_2.addWidget(self.pb_circtool, 2, 0, 1, 1, Qt.AlignTop)
-
- self.pb_slotool = QPushButton(self.groupBox_2)
- self.pb_slotool.setObjectName(u"pb_slotool")
- self.pb_slotool.setCheckable(True)
- self.pb_slotool.setAutoExclusive(False)
- self.pb_slotool.setAutoRepeatInterval(98)
-
- self.gridLayout_2.addWidget(self.pb_slotool, 2, 1, 1, 1, Qt.AlignTop)
-
- self.pb_linetool = QPushButton(self.groupBox_2)
- self.pb_linetool.setObjectName(u"pb_linetool")
- self.pb_linetool.setCheckable(True)
- self.pb_linetool.setAutoExclusive(False)
-
- self.gridLayout_2.addWidget(self.pb_linetool, 1, 0, 1, 1)
-
- self.pb_rectool = QPushButton(self.groupBox_2)
- self.pb_rectool.setObjectName(u"pb_rectool")
- self.pb_rectool.setCheckable(True)
- self.pb_rectool.setAutoExclusive(False)
-
- self.gridLayout_2.addWidget(self.pb_rectool, 1, 1, 1, 1, Qt.AlignTop)
-
- self.pb_enable_construct = QPushButton(self.groupBox_2)
- self.pb_enable_construct.setObjectName(u"pb_enable_construct")
- self.pb_enable_construct.setCheckable(True)
-
- self.gridLayout_2.addWidget(self.pb_enable_construct, 5, 0, 1, 1)
-
- self.pb_enable_snap = QPushButton(self.groupBox_2)
- self.pb_enable_snap.setObjectName(u"pb_enable_snap")
- self.pb_enable_snap.setCheckable(True)
- self.pb_enable_snap.setChecked(True)
-
- self.gridLayout_2.addWidget(self.pb_enable_snap, 5, 1, 1, 1)
-
-
- self.gridLayout.addWidget(self.groupBox_2, 1, 0, 1, 1)
-
- self.groupBox_3 = QGroupBox(self.centralwidget)
- self.groupBox_3.setObjectName(u"groupBox_3")
- sizePolicy3.setHeightForWidth(self.groupBox_3.sizePolicy().hasHeightForWidth())
- self.groupBox_3.setSizePolicy(sizePolicy3)
- self.groupBox_3.setMaximumSize(QSize(200, 16777213))
- self.gridLayout_4 = QGridLayout(self.groupBox_3)
- self.gridLayout_4.setObjectName(u"gridLayout_4")
- self.pb_con_sym = QPushButton(self.groupBox_3)
- self.pb_con_sym.setObjectName(u"pb_con_sym")
- self.pb_con_sym.setCheckable(True)
- self.pb_con_sym.setAutoExclusive(False)
-
- self.gridLayout_4.addWidget(self.pb_con_sym, 3, 1, 1, 1)
-
- self.pb_con_vert = QPushButton(self.groupBox_3)
- self.pb_con_vert.setObjectName(u"pb_con_vert")
- self.pb_con_vert.setCheckable(True)
- self.pb_con_vert.setAutoExclusive(False)
-
- self.gridLayout_4.addWidget(self.pb_con_vert, 2, 1, 1, 1)
-
- self.pb_con_perp = QPushButton(self.groupBox_3)
- self.pb_con_perp.setObjectName(u"pb_con_perp")
- self.pb_con_perp.setCheckable(True)
- self.pb_con_perp.setAutoExclusive(False)
-
- self.gridLayout_4.addWidget(self.pb_con_perp, 1, 1, 1, 1)
-
- self.pb_con_horiz = QPushButton(self.groupBox_3)
- self.pb_con_horiz.setObjectName(u"pb_con_horiz")
- self.pb_con_horiz.setCheckable(True)
- self.pb_con_horiz.setAutoExclusive(False)
-
- self.gridLayout_4.addWidget(self.pb_con_horiz, 2, 0, 1, 1)
-
- self.pb_con_ptpt = QPushButton(self.groupBox_3)
- self.pb_con_ptpt.setObjectName(u"pb_con_ptpt")
- self.pb_con_ptpt.setCheckable(True)
- self.pb_con_ptpt.setAutoExclusive(False)
-
- self.gridLayout_4.addWidget(self.pb_con_ptpt, 0, 0, 1, 1)
-
- self.pb_con_line = QPushButton(self.groupBox_3)
- self.pb_con_line.setObjectName(u"pb_con_line")
- self.pb_con_line.setCheckable(True)
- self.pb_con_line.setAutoExclusive(False)
-
- self.gridLayout_4.addWidget(self.pb_con_line, 0, 1, 1, 1)
-
- self.pb_con_dist = QPushButton(self.groupBox_3)
- self.pb_con_dist.setObjectName(u"pb_con_dist")
- self.pb_con_dist.setCheckable(True)
- self.pb_con_dist.setAutoExclusive(False)
- self.pb_con_dist.setAutoRepeatDelay(297)
-
- self.gridLayout_4.addWidget(self.pb_con_dist, 3, 0, 1, 1)
-
- self.pb_con_mid = QPushButton(self.groupBox_3)
- self.pb_con_mid.setObjectName(u"pb_con_mid")
- self.pb_con_mid.setCheckable(True)
- self.pb_con_mid.setAutoExclusive(False)
-
- self.gridLayout_4.addWidget(self.pb_con_mid, 1, 0, 1, 1)
-
-
- self.gridLayout.addWidget(self.groupBox_3, 2, 0, 1, 1)
-
- self.tabWidget = QTabWidget(self.centralwidget)
- self.tabWidget.setObjectName(u"tabWidget")
- sizePolicy5 = QSizePolicy(QSizePolicy.Maximum, QSizePolicy.Expanding)
- sizePolicy5.setHorizontalStretch(0)
- sizePolicy5.setVerticalStretch(0)
- sizePolicy5.setHeightForWidth(self.tabWidget.sizePolicy().hasHeightForWidth())
- self.tabWidget.setSizePolicy(sizePolicy5)
- self.tabWidget.setMaximumSize(QSize(200, 16777215))
- self.tabWidget.setTabPosition(QTabWidget.South)
- self.snaps = QWidget()
- self.snaps.setObjectName(u"snaps")
- self.verticalLayout_3 = QVBoxLayout(self.snaps)
- self.verticalLayout_3.setObjectName(u"verticalLayout_3")
- self.groupBox_5 = QGroupBox(self.snaps)
- self.groupBox_5.setObjectName(u"groupBox_5")
- sizePolicy6 = QSizePolicy(QSizePolicy.Fixed, QSizePolicy.Preferred)
- sizePolicy6.setHorizontalStretch(0)
- sizePolicy6.setVerticalStretch(0)
- sizePolicy6.setHeightForWidth(self.groupBox_5.sizePolicy().hasHeightForWidth())
- self.groupBox_5.setSizePolicy(sizePolicy6)
- self.gridLayout_11 = QGridLayout(self.groupBox_5)
- self.gridLayout_11.setObjectName(u"gridLayout_11")
- self.gridLayout_11.setContentsMargins(2, 2, 2, 2)
- self.label = QLabel(self.groupBox_5)
- self.label.setObjectName(u"label")
-
- self.gridLayout_11.addWidget(self.label, 5, 0, 1, 1)
-
- self.pb_snap_vert = QPushButton(self.groupBox_5)
- self.pb_snap_vert.setObjectName(u"pb_snap_vert")
- self.pb_snap_vert.setCheckable(True)
- self.pb_snap_vert.setAutoExclusive(False)
-
- self.gridLayout_11.addWidget(self.pb_snap_vert, 2, 1, 1, 1)
-
- self.line_2 = QFrame(self.groupBox_5)
- self.line_2.setObjectName(u"line_2")
- self.line_2.setFrameShape(QFrame.HLine)
- self.line_2.setFrameShadow(QFrame.Sunken)
-
- self.gridLayout_11.addWidget(self.line_2, 4, 0, 1, 2)
-
- self.label_2 = QLabel(self.groupBox_5)
- self.label_2.setObjectName(u"label_2")
-
- self.gridLayout_11.addWidget(self.label_2, 5, 1, 1, 1)
-
- self.spinbox_snap_distance = QSpinBox(self.groupBox_5)
- self.spinbox_snap_distance.setObjectName(u"spinbox_snap_distance")
- self.spinbox_snap_distance.setMaximum(30)
- self.spinbox_snap_distance.setValue(10)
-
- self.gridLayout_11.addWidget(self.spinbox_snap_distance, 6, 0, 1, 1)
-
- self.pushButton_7 = QPushButton(self.groupBox_5)
- self.pushButton_7.setObjectName(u"pushButton_7")
- self.pushButton_7.setCheckable(True)
- self.pushButton_7.setAutoExclusive(False)
-
- self.gridLayout_11.addWidget(self.pushButton_7, 3, 0, 1, 1)
-
- self.pb_snap_horiz = QPushButton(self.groupBox_5)
- self.pb_snap_horiz.setObjectName(u"pb_snap_horiz")
- self.pb_snap_horiz.setCheckable(True)
- self.pb_snap_horiz.setAutoExclusive(False)
-
- self.gridLayout_11.addWidget(self.pb_snap_horiz, 2, 0, 1, 1)
-
- self.spinbox_angle_steps = QSpinBox(self.groupBox_5)
- self.spinbox_angle_steps.setObjectName(u"spinbox_angle_steps")
- self.spinbox_angle_steps.setMaximum(180)
- self.spinbox_angle_steps.setValue(15)
-
- self.gridLayout_11.addWidget(self.spinbox_angle_steps, 6, 1, 1, 1)
-
- self.pushButton_8 = QPushButton(self.groupBox_5)
- self.pushButton_8.setObjectName(u"pushButton_8")
- self.pushButton_8.setCheckable(True)
- self.pushButton_8.setAutoExclusive(False)
-
- self.gridLayout_11.addWidget(self.pushButton_8, 0, 0, 1, 1)
-
- self.pb_snap_midp = QPushButton(self.groupBox_5)
- self.pb_snap_midp.setObjectName(u"pb_snap_midp")
- self.pb_snap_midp.setCheckable(True)
- self.pb_snap_midp.setAutoExclusive(False)
-
- self.gridLayout_11.addWidget(self.pb_snap_midp, 0, 1, 1, 1)
-
- self.pb_snap_angle = QPushButton(self.groupBox_5)
- self.pb_snap_angle.setObjectName(u"pb_snap_angle")
- self.pb_snap_angle.setCheckable(True)
- self.pb_snap_angle.setAutoExclusive(False)
-
- self.gridLayout_11.addWidget(self.pb_snap_angle, 3, 1, 1, 1)
-
-
- self.verticalLayout_3.addWidget(self.groupBox_5)
-
- self.tabWidget.addTab(self.snaps, "")
- self.settings = QWidget()
- self.settings.setObjectName(u"settings")
- self.tabWidget.addTab(self.settings, "")
-
- self.gridLayout.addWidget(self.tabWidget, 3, 0, 1, 1)
-
- fluencyCAD.setCentralWidget(self.centralwidget)
- self.menubar = QMenuBar(fluencyCAD)
- self.menubar.setObjectName(u"menubar")
- self.menubar.setGeometry(QRect(0, 0, 2192, 24))
- self.menuFile = QMenu(self.menubar)
- self.menuFile.setObjectName(u"menuFile")
- self.menuSettings = QMenu(self.menubar)
- self.menuSettings.setObjectName(u"menuSettings")
- fluencyCAD.setMenuBar(self.menubar)
- self.statusbar = QStatusBar(fluencyCAD)
- self.statusbar.setObjectName(u"statusbar")
- fluencyCAD.setStatusBar(self.statusbar)
-
- self.menubar.addAction(self.menuFile.menuAction())
- self.menubar.addAction(self.menuSettings.menuAction())
- self.menuFile.addAction(self.actionNew_Project)
- self.menuFile.addAction(self.actionLoad_Project)
- self.menuFile.addAction(self.actionRecent)
- self.menuFile.addSeparator()
-
- self.retranslateUi(fluencyCAD)
-
- self.InputTab.setCurrentIndex(0)
- self.tabWidget.setCurrentIndex(0)
-
-
- QMetaObject.connectSlotsByName(fluencyCAD)
- # setupUi
-
- def retranslateUi(self, fluencyCAD):
- fluencyCAD.setWindowTitle(QCoreApplication.translate("fluencyCAD", u"fluencyCAD", None))
- self.actionNew_Project.setText(QCoreApplication.translate("fluencyCAD", u"New", None))
- self.actionLoad_Project.setText(QCoreApplication.translate("fluencyCAD", u"Load", None))
- self.actionRecent.setText(QCoreApplication.translate("fluencyCAD", u"Recent", None))
- self.InputTab.setTabText(self.InputTab.indexOf(self.sketch_tab), QCoreApplication.translate("fluencyCAD", u"Sketch", None))
- self.groupBox_7.setTitle(QCoreApplication.translate("fluencyCAD", u"Executive", None))
- self.pushButton_5.setText(QCoreApplication.translate("fluencyCAD", u"Load Code", None))
- self.pushButton_4.setText(QCoreApplication.translate("fluencyCAD", u"Save code", None))
- self.pb_apply_code.setText(QCoreApplication.translate("fluencyCAD", u"Apply Code", None))
- self.pushButton.setText(QCoreApplication.translate("fluencyCAD", u"Delete Code", None))
- self.InputTab.setTabText(self.InputTab.indexOf(self.code_tab), QCoreApplication.translate("fluencyCAD", u"Code", None))
- self.gl_box.setTitle(QCoreApplication.translate("fluencyCAD", u"Model Viewer", None))
- self.groupBox.setTitle(QCoreApplication.translate("fluencyCAD", u"Modify", None))
- self.pb_revop.setText(QCoreApplication.translate("fluencyCAD", u"Rev", None))
- self.pb_extrdop.setText(QCoreApplication.translate("fluencyCAD", u"Extrd", None))
- self.pb_arrayop.setText(QCoreApplication.translate("fluencyCAD", u"Arry", None))
- self.pb_cutop.setText(QCoreApplication.translate("fluencyCAD", u"Cut", None))
- self.pb_combop.setText(QCoreApplication.translate("fluencyCAD", u"Comb", None))
- self.pb_moveop.setText(QCoreApplication.translate("fluencyCAD", u"Mve", None))
- self.compo_box.setTitle(QCoreApplication.translate("fluencyCAD", u"Components", None))
- self.groupBox_10.setTitle(QCoreApplication.translate("fluencyCAD", u"Bodys / Operations", None))
- self.groupBox_8.setTitle(QCoreApplication.translate("fluencyCAD", u"Tools", None))
- self.pb_del_body.setText(QCoreApplication.translate("fluencyCAD", u"Del", None))
- self.pb_update_body.setText(QCoreApplication.translate("fluencyCAD", u"Upd", None))
- self.pb_edt_sktch_3.setText(QCoreApplication.translate("fluencyCAD", u"Nothing", None))
- self.groupBox_11.setTitle(QCoreApplication.translate("fluencyCAD", u"Sketch", None))
- self.groupBox_6.setTitle(QCoreApplication.translate("fluencyCAD", u"Tools", None))
- self.pb_edt_sktch.setText(QCoreApplication.translate("fluencyCAD", u"Edt", None))
- self.pb_nw_sktch.setText(QCoreApplication.translate("fluencyCAD", u"Add", None))
- self.pb_del_sketch.setText(QCoreApplication.translate("fluencyCAD", u"Del", None))
- self.assmbly_box.setTitle(QCoreApplication.translate("fluencyCAD", u"Assembly Tools", None))
- self.pushButton_3.setText(QCoreApplication.translate("fluencyCAD", u"+ Cnct", None))
- self.pushButton_6.setText(QCoreApplication.translate("fluencyCAD", u"- Cnct", None))
- self.groupBox_4.setTitle(QCoreApplication.translate("fluencyCAD", u"Export", None))
- self.pushButton_2.setText(QCoreApplication.translate("fluencyCAD", u"STL", None))
- self.compo_tool_box.setTitle(QCoreApplication.translate("fluencyCAD", u"Component Tools", None))
- self.new_compo.setText(QCoreApplication.translate("fluencyCAD", u"New", None))
- self.del_compo.setText(QCoreApplication.translate("fluencyCAD", u"Del", None))
- self.groupBox_9.setTitle(QCoreApplication.translate("fluencyCAD", u"Workplanes", None))
-#if QT_CONFIG(tooltip)
- self.pb_origin_wp.setToolTip(QCoreApplication.translate("fluencyCAD", u"orking Plane at 0, 0, 0", None))
-#endif // QT_CONFIG(tooltip)
- self.pb_origin_wp.setText(QCoreApplication.translate("fluencyCAD", u"WP Origin", None))
-#if QT_CONFIG(shortcut)
- self.pb_origin_wp.setShortcut(QCoreApplication.translate("fluencyCAD", u"W", None))
-#endif // QT_CONFIG(shortcut)
-#if QT_CONFIG(tooltip)
- self.pb_origin_face.setToolTip(QCoreApplication.translate("fluencyCAD", u"Working Plane >PNMS QPoint:
- # Step 1: Subtract widget center
- center_x = self.width() / 2
- center_y = self.height() / 2
-
- # Step 2: Apply pan offset
- viewport_x = viewport_pos.x() - center_x - (self.pan_offset.x() * self.zoom_factor)
- viewport_y = viewport_pos.y() - center_y - (self.pan_offset.y() * self.zoom_factor)
-
- # Step 3: Apply inverse zoom and Y-flip
- local_x = viewport_x / self.zoom_factor
- local_y = -viewport_y / self.zoom_factor
-
- return QPoint(int(local_x), int(local_y))
-```
-
-#### Rendering Transform Setup
-```python
-def _setup_coordinate_system(self, painter: QPainter):
- transform = QTransform()
-
- # Translate to center and apply pan
- center = QPointF(self.width() / 2, self.height() / 2)
- transform.translate(center.x() + self.pan_offset.x() * self.zoom_factor,
- center.y() + self.pan_offset.y() * self.zoom_factor)
-
- # Apply zoom and flip Y-axis
- transform.scale(self.zoom_factor, -self.zoom_factor)
-
- painter.setTransform(transform)
-```
-
-## Interaction System
-
-### Mode-Based Interaction
-
-The sketcher supports multiple interaction modes with robust mode management:
-
-#### Drawing Modes
-- `SketchMode.LINE`: Two-point line creation
-- `SketchMode.RECTANGLE`: Two-corner rectangle creation
-- `SketchMode.CIRCLE`: Center-radius circle creation
-- `SketchMode.POINT`: Single point creation
-
-#### Constraint Modes
-- `SketchMode.COINCIDENT_PT_PT`: Point-to-point coincidence
-- `SketchMode.HORIZONTAL`: Horizontal line constraint
-- `SketchMode.VERTICAL`: Vertical line constraint
-- `SketchMode.DISTANCE`: Distance/length constraint
-
-#### Selection Mode
-- `SketchMode.NONE`: Selection and manipulation mode (enables point dragging)
-
-### Selection and Deletion System
-
-The sketcher now includes a comprehensive selection and deletion system that allows users to select and remove elements from the sketch.
-
-#### Selection Methods
-
-1. **Single Element Selection**: Click on individual points or lines to select/deselect them
-2. **Rectangle Selection**: Click and drag to create a selection rectangle for multiple elements
-3. **Visual Feedback**: Selected elements are highlighted in yellow with increased size
-
-#### Deletion Methods
-
-1. **Keyboard Deletion**: Press Delete or Backspace to remove selected elements
-2. **Proper Cleanup**: Elements are removed from both the sketch and constraint solver
-3. **Dependency Handling**: Lines are deleted before points to maintain geometric integrity
-
-#### Implementation Details
-
-The selection system is implemented through the following components:
-
-- **Selection Tracking**: `selected_elements` list tracks currently selected elements
-- **Rectangle Selection**: `selection_rect_start` and `selection_rect_end` track rectangle selection bounds
-- **Visual Feedback**: Modified drawing methods highlight selected elements in yellow
-- **Keyboard Support**: `keyPressEvent` handles Delete/Backspace keys
-- **Deletion Method**: `delete_selected_elements` handles removal of elements from sketch and solver
-
-#### Selection Workflow
-
-1. **Default Selection Mode**: The sketcher defaults to selection mode when no drawing tool is active
-2. **Element Selection**:
- - Click on points or lines to select/deselect them (they turn yellow)
- - Click and drag to create a rectangle selection for multiple elements
-3. **Element Deletion**:
- - Press Delete or Backspace to remove all selected elements
- - Elements are removed from both the sketch and constraint solver
-4. **Visual Feedback**:
- - Selected elements are highlighted in yellow
- - Rectangle selection is shown with a yellow dashed border
-
-#### Constraints Handling
-
-When elements are deleted:
-- Lines are removed first to avoid issues with points being used by lines
-- Points are only removed if they are not used by any remaining lines
-- The constraint solver is re-run after deletion to update remaining constraints
-- Proper error handling ensures the UI remains responsive even if solver operations fail
-
-### Mode Management System
-
-The mode system has been enhanced to provide intuitive selection and deletion functionality:
-
-#### Mode Compatibility
-- Python `None` is automatically converted to `SketchMode.NONE` for backward compatibility
-- The `set_mode()` method ensures the mode is always a valid `SketchMode` enum value
-- Mode changes reset all interaction buffers and state
-
-#### Default Selection Behavior
-- `SketchMode.NONE` now serves as the default selection mode
-- When no drawing tool is active, the sketcher is in selection mode by default
-- Users can click on elements to select/deselect them (they turn yellow)
-- Users can click and drag to create rectangle selections
-- Pressing Delete or Backspace removes all selected elements
-
-#### Right-Click Behavior
-- Right-clicking **always** exits any active mode and returns to `SketchMode.NONE`
-- This enables point dragging and prevents unintended geometry creation
-- The mode reset happens directly in the sketcher, not through main app signals
-
-#### Point Dragging Safety
-- Point dragging is **only** enabled when in `SketchMode.NONE` mode
-- Left-clicks in `NONE` mode check for draggable points first
-- If no point is found, the click is processed as a selection operation
-
-### Mouse Event Handling
-
-#### Click Processing Flow
-```python
-def mousePressEvent(self, event):
- local_pos = self._viewport_to_local(event.pos())
-
- if event.button() == Qt.LeftButton:
- self._handle_left_click(local_pos)
- elif event.button() == Qt.RightButton:
- self._handle_right_click(local_pos)
- elif event.button() == Qt.MiddleButton:
- self._start_panning(event.pos())
-```
-
-#### Enhanced Left-Click Handler
-```python
-def _handle_left_click(self, pos: QPoint):
- # Safety check for NONE mode (dragging enabled)
- if self.current_mode == SketchMode.NONE or self.current_mode is None:
- point = self.sketch.get_point_near(pos, self.snap_settings.snap_distance)
- if point:
- self._start_point_drag(point, pos)
- return
- else:
- # No point found - ignore click to prevent unintended drawing
- return
-
- # Handle active drawing/constraint modes
- if self.current_mode == SketchMode.LINE:
- self._handle_line_creation(pos)
- elif self.current_mode == SketchMode.HORIZONTAL:
- self._handle_horizontal_constraint(pos)
- # ... other modes
-```
-
-#### Right-Click Mode Reset
-```python
-def _handle_right_click(self, pos: QPoint):
- # Reset interaction state
- self._reset_interaction_state()
-
- # Force mode to NONE to enable dragging
- self.current_mode = SketchMode.NONE
-
- # Emit signal to inform main app
- self.constraint_applied.emit()
-```
-
-### Point Dragging System
-
-The point dragging system is optimized for performance and maintains constraint consistency:
-
-#### Drag Phases
-
-1. **Drag Start** (`_start_point_drag`):
- - Identifies dragged point
- - Stores initial position
- - Sets dragging state
-
-2. **Drag Update** (`_handle_point_drag`):
- - Updates point visual position only
- - Applies snapping
- - No solver execution (for performance)
-
-3. **Drag End** (`_end_point_drag`):
- - Updates solver parameters with final position
- - Runs constraint solver
- - Updates all connected geometry
- - Resets drag state
-
-```python
-def _end_point_drag(self):
- if not self.dragging_point:
- return
-
- # Update solver parameters with final position
- if self.dragging_point.handle:
- new_x = self.dragging_point.x
- new_y = self.dragging_point.y
- self.sketch.set_params(self.dragging_point.handle.params, [new_x, new_y])
-
- # Run solver to update all connected geometry
- result = self.sketch.solve_system()
- if result == ResultFlag.OKAY:
- self.sketch_modified.emit()
-```
-
-## Rendering System
-
-### Rendering Pipeline
-
-The rendering system uses Qt's QPainter with a multi-layer approach:
-
-1. **Coordinate System Setup**: Apply zoom, pan, and Y-flip transforms
-2. **Background Rendering**: Grid, axes, and origin marker
-3. **Geometry Rendering**: Points, lines, circles with proper styling
-4. **Dynamic Elements**: Preview geometry during creation
-5. **UI Overlays**: Mode indicators, measurements, snap highlights
-
-### Rendering Layers
-
-#### Layer 1: Background
-- Coordinate axes (dashed gray lines)
-- Grid (if enabled)
-- Origin marker (red circle)
-
-#### Layer 2: Geometry
-- Construction geometry (green, dotted)
-- Normal geometry (gray, solid)
-- Constraint annotations
-
-#### Layer 3: Interactive Elements
-- Hover highlights (red)
-- Dynamic previews (gray, dashed)
-- Measurements during creation
-
-#### Layer 4: UI Overlays
-- Snap point indicators
-- Mode and zoom information
-- Status messages
-
-### Styling System
-
-Rendering appearance is controlled by the `RenderSettings` class:
-
-```python
-@dataclass
-class RenderSettings:
- normal_pen_width: float = 2.0
- construction_pen_width: float = 1.0
- highlight_pen_width: float = 3.0
-
- normal_color = QColor(128, 128, 128) # Gray
- construction_color = QColor(0, 255, 0) # Green
- highlight_color = QColor(255, 0, 0) # Red
- solver_color = QColor(0, 255, 0) # Green
- dynamic_color = QColor(128, 128, 128) # Gray
- text_color = QColor(255, 255, 255) # White
-```
-
-### Dynamic Previews
-
-During geometry creation, dynamic previews show:
-- **Line Creation**: Dashed line from start to cursor with length annotation
-- **Rectangle Creation**: Dashed rectangle outline
-- **Circle Creation**: Dashed circle with radius line and annotation
-
-## Snapping System
-
-### Snap Modes
-
-The snapping system supports multiple simultaneous snap modes:
-
-#### SnapMode.POINT
-- Snaps to existing geometry points
-- Priority: Highest
-- Visual: Red circle highlight
-
-#### SnapMode.MIDPOINT
-- Snaps to line midpoints
-- Priority: Medium
-- Visual: Red diamond highlight
-
-#### SnapMode.GRID
-- Snaps to grid intersections
-- Priority: Lowest
-- Visual: Green cross highlight
-
-#### SnapMode.HORIZONTAL/VERTICAL
-- Angular snapping (future implementation)
-- Constrains to horizontal/vertical directions
-
-#### SnapMode.INTERSECTION
-- Snaps to line intersections (future implementation)
-
-### Snap Algorithm
-
-```python
-def _get_snapped_position(self, pos: QPoint) -> QPoint:
- min_distance = float('inf')
- snapped_pos = pos
- snap_threshold = self.snap_settings.snap_distance
-
- # Point snapping (highest priority)
- if SnapMode.POINT in self.snap_settings.enabled_modes:
- for point in self.sketch.points:
- distance = math.sqrt((pos.x() - point.x)**2 + (pos.y() - point.y)**2)
- if distance < snap_threshold and distance < min_distance:
- snapped_pos = QPoint(int(point.x), int(point.y))
- min_distance = distance
-
- # Midpoint snapping (medium priority)
- if SnapMode.MIDPOINT in self.snap_settings.enabled_modes and min_distance > snap_threshold:
- for line in self.sketch.lines:
- midpoint = line.midpoint
- distance = math.sqrt((pos.x() - midpoint.x)**2 + (pos.y() - midpoint.y)**2)
- if distance < snap_threshold and distance < min_distance:
- snapped_pos = QPoint(int(midpoint.x), int(midpoint.y))
- min_distance = distance
-
- return snapped_pos
-```
-
-### Snap Settings
-
-```python
-@dataclass
-class SnapSettings:
- snap_distance: float = 20.0 # Snap threshold in pixels
- angle_increment: float = 15.0 # Angular snap increment
- grid_spacing: float = 50.0 # Grid spacing
- enabled_modes: Set[SnapMode] # Active snap modes
-```
-
-## Working Plane Integration
-
-### Projected Geometry Workflow
-
-The sketcher integrates with 3D working planes through projected geometry:
-
-1. **3D Geometry Selection**: User selects 3D lines/points in VTK widget
-2. **Plane Definition**: System computes working plane from selections
-3. **Geometry Projection**: 3D geometry is projected onto 2D working plane
-4. **Sketch Import**: Projected geometry is imported as construction geometry
-
-### Projection Import Methods
-
-#### `convert_proj_points(proj_points)`
-Imports projected 3D points as 2D construction points:
-```python
-def convert_proj_points(self, proj_points):
- for point_data in proj_points:
- if hasattr(point_data, 'x') and hasattr(point_data, 'y'):
- point = Point2D(point_data.x, point_data.y, True) # Construction
- self.sketch.add_point(point)
-```
-
-#### `convert_proj_lines(proj_lines)`
-Imports projected 3D lines as 2D construction lines:
-```python
-def convert_proj_lines(self, proj_lines):
- for line_data in proj_lines:
- # Handle object format
- if hasattr(line_data, 'start') and hasattr(line_data, 'end'):
- x1, y1 = line_data.start.x, line_data.start.y
- x2, y2 = line_data.end.x, line_data.end.y
-
- # Skip degenerate lines
- if abs(x1 - x2) < 1e-6 and abs(y1 - y2) < 1e-6:
- continue
-
- start = Point2D(x1, y1, True)
- end = Point2D(x2, y2, True)
- self.sketch.add_point(start)
- self.sketch.add_point(end)
- line = Line2D(start, end, True)
- self.sketch.add_line(line)
-```
-
-### Construction vs Normal Geometry
-
-- **Construction Geometry**:
- - Rendered in green with dotted lines
- - Used for reference and alignment
- - Created from projected 3D geometry
- - Flag: `is_construction=True`
-
-- **Normal Geometry**:
- - Rendered in gray with solid lines
- - Part of the actual sketch design
- - Created by user drawing actions
- - Flag: `is_construction=False`
-
-## API Reference
-
-### Main Widget Class
-
-#### ImprovedSketchWidget
-
-**Initialization:**
-```python
-widget = ImprovedSketchWidget()
-widget.show()
-```
-
-**Mode Control:**
-```python
-# Set drawing modes
-widget.set_mode(SketchMode.LINE)
-widget.set_mode(SketchMode.NONE) # Enable selection/dragging
-widget.set_mode(None) # Also converted to SketchMode.NONE
-
-# Construction geometry
-widget.set_construction_mode(True)
-```
-
-**Snapping Control:**
-```python
-widget.set_snap_mode(SnapMode.POINT, True)
-widget.toggle_snap_mode(SnapMode.MIDPOINT, enabled)
-```
-
-**View Control:**
-```python
-widget.zoom_to_fit()
-```
-
-**Sketch Access:**
-```python
-sketch = widget.get_sketch()
-widget.set_sketch(imported_sketch)
-```
-
-### Sketch Management
-
-#### ImprovedSketch
-
-**Geometry Addition:**
-```python
-sketch = ImprovedSketch()
-point = Point2D(10, 20)
-line = Line2D(start_point, end_point)
-circle = Circle2D(center_point, radius)
-
-sketch.add_point(point)
-sketch.add_line(line)
-sketch.add_circle(circle)
-```
-
-**Constraint Application:**
-```python
-# Distance constraint
-sketch.distance(point1.handle, point2.handle, 50.0, sketch.wp)
-
-# Coincident constraint
-sketch.coincident(point1.handle, point2.handle, sketch.wp)
-
-# Line constraints
-sketch.horizontal(line.handle, sketch.wp)
-sketch.vertical(line.handle, sketch.wp)
-
-# Solve system
-result = sketch.solve_system()
-```
-
-### Signals
-
-The widget emits several signals for integration:
-
-```python
-# Emitted when constraint is successfully applied
-widget.constraint_applied.connect(callback)
-
-# Emitted when new geometry is created
-widget.geometry_created.connect(callback) # Parameter: geometry type string
-
-# Emitted when sketch is modified
-widget.sketch_modified.connect(callback)
-```
-
-## Performance Considerations
-
-### Optimization Strategies
-
-1. **Lazy Solving**: Solver only runs when necessary (after constraints or drag end)
-2. **Efficient Rendering**: Uses Qt's optimized drawing primitives
-3. **Smart Updates**: Only redraws affected regions when possible
-4. **Handle Caching**: SolverSpace handles are cached to avoid recreation
-
-### Memory Management
-
-- Geometry objects use weak references where possible
-- SolverSpace handles are properly cleaned up
-- Qt objects follow parent-child hierarchy for automatic cleanup
-
-### Scalability Limits
-
-- Recommended maximum: ~1000 geometric entities
-- Solver performance degrades with complex constraint networks
-- Rendering remains smooth up to ~10,000 entities
-
-## Troubleshooting
-
-### Common Issues
-
-#### Mode Handling Problems
-**Symptoms**: Unintended line creation when dragging, tools not deactivating properly
-**Causes**: Mode not properly reset to NONE, Python None vs SketchMode.NONE confusion
-**Solutions**:
-- Always right-click to exit active modes
-- Ensure `set_mode(None)` is converted to `SketchMode.NONE`
-- Verify mode state after tool deactivation in main app
-
-#### Point Dragging Issues
-**Symptoms**: Cannot drag points, dragging creates unwanted lines
-**Causes**: Mode not set to NONE, safety checks preventing drag detection
-**Solutions**:
-- Verify current mode is `SketchMode.NONE` before attempting to drag
-- Right-click to ensure proper mode exit from drawing tools
-- Check that point detection threshold is appropriate
-
-#### Solver Failures
-**Symptoms**: Constraints not applied, geometry not updating
-**Causes**: Over-constrained systems, conflicting constraints
-**Solutions**:
-- Check constraint compatibility
-- Verify geometry validity
-- Use `ResultFlag` inspection for error details
-
-#### Coordinate Transform Issues
-**Symptoms**: Mouse clicks don't match visual geometry
-**Causes**: Incorrect transform calculations, zoom/pan state corruption
-**Solutions**:
-- Verify `_viewport_to_local` and `_setup_coordinate_system` consistency
-- Reset view with `zoom_to_fit()`
-
-#### Performance Problems
-**Symptoms**: Slow dragging, UI lag
-**Causes**: Solver running during drag, excessive redraws
-**Solutions**:
-- Ensure solver only runs in `_end_point_drag`
-- Check render loop efficiency
-- Profile with Qt performance tools
-
-#### Snap Behavior Issues
-**Symptoms**: Inconsistent snapping, incorrect snap points
-**Causes**: Priority conflicts, threshold settings, coordinate errors
-**Solutions**:
-- Adjust snap threshold in `SnapSettings`
-- Verify snap priority order
-- Check coordinate conversion in snap calculations
-
-### Debug Logging
-
-Enable detailed logging for troubleshooting:
-```python
-import logging
-logging.basicConfig(level=logging.DEBUG)
-logger = logging.getLogger('improved_sketcher')
-```
-
-Key log messages include:
-- Geometry addition/removal
-- Constraint application results
-- Solver execution status
-- Coordinate transformations
-- Snap calculations
-
-### Testing Guidelines
-
-#### Unit Testing
-- Test geometry classes with edge cases
-- Verify coordinate transformations
-- Test constraint application logic
-
-#### Integration Testing
-- Test with various sketch sizes
-- Verify working plane integration
-- Test complex constraint networks
-
-#### Performance Testing
-- Measure solver execution time
-- Profile rendering performance
-- Test with large geometry sets
-
----
-
-## Recent Improvements (2025-08-16)
-
-### Mode Handling Enhancements
-
-Significant improvements have been made to the mode management system:
-
-#### Fixed Issues
-1. **Unintended Line Creation**: Resolved issue where dragging with line tool deactivated would still create lines
-2. **Mode Reset Reliability**: Right-click now reliably exits any active mode and returns to NONE
-3. **Backward Compatibility**: Python `None` mode values are automatically converted to `SketchMode.NONE`
-4. **Safety Checks**: Added comprehensive checks to prevent drawing operations in NONE mode
-
-#### Implementation Details
-- Enhanced `_handle_right_click()` to directly set mode to NONE
-- Added safety checks in `_handle_left_click()` for NONE mode behavior
-- Improved `set_mode()` method to handle None input gracefully
-- Added comprehensive debug logging for mode transitions
-
-#### Integration Improvements
-- Fixed main app integration where constraint modes were prematurely reset
-- Ensured persistent constraint behavior until explicit user cancellation
-- Maintained UI button state consistency with actual sketcher mode
-
-These improvements ensure reliable mode transitions and prevent common user frustrations with unintended geometry creation.
-
-## Conclusion
-
-The ImprovedSketchWidget provides a robust, extensible foundation for 2D parametric sketching in Fluency CAD. Its architecture separates concerns effectively, uses proven libraries (SolverSpace, PySide6), and provides rich interaction capabilities while maintaining good performance characteristics.
-
-The system is designed for extensibility - new geometry types, constraint types, and interaction modes can be added following the established patterns. The comprehensive API allows for both direct use and integration with larger CAD systems.
-
-With the recent mode handling improvements, the sketcher now provides a more reliable and intuitive user experience, with proper separation between drawing modes and selection/manipulation operations.
diff --git a/doc/commands.md b/doc/commands.md
deleted file mode 100644
index 646f0a3..0000000
--- a/doc/commands.md
+++ /dev/null
@@ -1 +0,0 @@
- pyside6-uic gui.ui > Gui.py -g python
diff --git a/doc/flow.md b/doc/flow.md
deleted file mode 100644
index 613ac32..0000000
--- a/doc/flow.md
+++ /dev/null
@@ -1,35 +0,0 @@
-# Signal Flow
-## 2D SketchWidget
-
-- 2D QPoint form custom Qpainter widget in linear space
-- 2D QPoint ot cartesian space
-- 2D tuple into slvspace dict system and solvespace
-- get calced position from Solvespace solver
-- add to internal reference dict
-- Transform to linear QPainter space for display to show
-
-## 3D custom Widget
-
-- Take Tuple points form solvespace main dict
-- Draw Interactor and sdfCAD model
-
-### Select and Project
-
-- Project cartesian flattened mesh into 2D
-- Transform to 2D xy
-- Transform to linear space for 2D widget to draw.
-- Result into 2D cartesian for body interaction extrude etc
-
-### Elements
-
-So far these are the elements:
-
-- Project: Main File
-- Timeline : Used to track the steps
-- Assembly: Uses Components and Connectors to from Assemblies
-- Component: Container for multiple smaller elements "part"
-- Connector: Preserves connections between parts even if the part in between is deleted
-- Code: A special type that directly builds bodys from sdfCAD code.
-- Body: The 3D meshed result from sdfCAD
-- Sketch: The base to draw new entities.
-- Interactor (edges): A special component mesh that is used to manipulate the bodys in 3d view.
\ No newline at end of file
diff --git a/doc/helper_commands.md b/doc/helper_commands.md
deleted file mode 100644
index c95e6ad..0000000
--- a/doc/helper_commands.md
+++ /dev/null
@@ -1,3 +0,0 @@
-## Compile ui file
-pyside6-uic gui.ui > Gui.py -g python
-
diff --git a/drawing_modules/.DS_Store b/drawing_modules/.DS_Store
deleted file mode 100644
index 047d28e..0000000
Binary files a/drawing_modules/.DS_Store and /dev/null differ
diff --git a/drawing_modules/__pycache__/draw_widget2d.cpython-311.pyc b/drawing_modules/__pycache__/draw_widget2d.cpython-311.pyc
deleted file mode 100644
index f1bd7bc..0000000
Binary files a/drawing_modules/__pycache__/draw_widget2d.cpython-311.pyc and /dev/null differ
diff --git a/drawing_modules/__pycache__/draw_widget_solve.cpython-311.pyc b/drawing_modules/__pycache__/draw_widget_solve.cpython-311.pyc
deleted file mode 100644
index e97eff3..0000000
Binary files a/drawing_modules/__pycache__/draw_widget_solve.cpython-311.pyc and /dev/null differ
diff --git a/drawing_modules/__pycache__/draw_widget_solve.cpython-312.pyc b/drawing_modules/__pycache__/draw_widget_solve.cpython-312.pyc
deleted file mode 100644
index 0926924..0000000
Binary files a/drawing_modules/__pycache__/draw_widget_solve.cpython-312.pyc and /dev/null differ
diff --git a/drawing_modules/__pycache__/gl_widget.cpython-311.pyc b/drawing_modules/__pycache__/gl_widget.cpython-311.pyc
deleted file mode 100644
index 2500b11..0000000
Binary files a/drawing_modules/__pycache__/gl_widget.cpython-311.pyc and /dev/null differ
diff --git a/drawing_modules/__pycache__/improved_sketcher.cpython-312.pyc b/drawing_modules/__pycache__/improved_sketcher.cpython-312.pyc
deleted file mode 100644
index ec8192a..0000000
Binary files a/drawing_modules/__pycache__/improved_sketcher.cpython-312.pyc and /dev/null differ
diff --git a/drawing_modules/__pycache__/vtk_widget.cpython-311.pyc b/drawing_modules/__pycache__/vtk_widget.cpython-311.pyc
deleted file mode 100644
index 52785d3..0000000
Binary files a/drawing_modules/__pycache__/vtk_widget.cpython-311.pyc and /dev/null differ
diff --git a/drawing_modules/__pycache__/vtk_widget.cpython-312.pyc b/drawing_modules/__pycache__/vtk_widget.cpython-312.pyc
deleted file mode 100644
index da9cf53..0000000
Binary files a/drawing_modules/__pycache__/vtk_widget.cpython-312.pyc and /dev/null differ
diff --git a/drawing_modules/__pycache__/vysta_widget.cpython-311.pyc b/drawing_modules/__pycache__/vysta_widget.cpython-311.pyc
deleted file mode 100644
index 1d7135c..0000000
Binary files a/drawing_modules/__pycache__/vysta_widget.cpython-311.pyc and /dev/null differ
diff --git a/drawing_modules/__pycache__/vysta_widget.cpython-312.pyc b/drawing_modules/__pycache__/vysta_widget.cpython-312.pyc
deleted file mode 100644
index 5d204c2..0000000
Binary files a/drawing_modules/__pycache__/vysta_widget.cpython-312.pyc and /dev/null differ
diff --git a/drawing_modules/case.stl b/drawing_modules/case.stl
deleted file mode 100644
index b7ad53a..0000000
Binary files a/drawing_modules/case.stl and /dev/null differ
diff --git a/drawing_modules/draw_widget2d.py b/drawing_modules/draw_widget2d.py
deleted file mode 100644
index ea7f469..0000000
--- a/drawing_modules/draw_widget2d.py
+++ /dev/null
@@ -1,916 +0,0 @@
-import math
-import re
-from copy import copy
-from typing import Optional
-
-import numpy as np
-from PySide6.QtWidgets import QApplication, QWidget, QMessageBox, QInputDialog
-from PySide6.QtGui import QPainter, QPen, QColor, QTransform
-from PySide6.QtCore import Qt, QPoint, QPointF, Signal, QLine
-from python_solvespace import SolverSystem, ResultFlag
-
-
-class SketchWidget(QWidget):
- constrain_done = Signal()
-
- def __init__(self):
- super().__init__()
-
- self.line_draw_buffer = [None, None]
- self.drag_buffer = [None, None]
- self.main_buffer = [None, None]
-
- self.hovered_point = None
- self.selected_line = None
-
- self.snapping_range = 20 # Range in pixels for snapping
- self.zoom = 1
-
- self.setMouseTracking(True)
- self.mouse_mode = False
- self.solv = SolverSystem()
-
- self.sketch = None
-
- def set_sketch(self, sketch) -> None:
- print(sketch)
- self.sketch = sketch
- self.create_workplane()
-
- def get_sketch(self):
- return self.sketch
-
- def reset_buffers(self):
- self.line_draw_buffer = [None, None]
- self.drag_buffer = [None, None]
- self.main_buffer = [None, None]
-
- def set_points(self, points: list):
- self.points = points
- #self.update()
-
- def create_workplane(self):
- self.sketch.working_plane = self.solv.create_2d_base()
-
- def create_workplane_projected(self):
- self.sketch.working_plane = self.solv.create_2d_base()
-
- def convert_proj_points(self):
- out_points = []
- for point in self.sketch.proj_points:
- x, y = point
- coord = QPoint(x, y)
- out_points.append(coord)
-
- self.sketch.proj_points = out_points
-
- def convert_proj_lines(self):
- out_lines = []
- for line in self.sketch.proj_lines:
- start = QPoint(line[0][0], line[0][1])
- end = QPoint(line[1][0], line[1][1])
- coord = QLine(start, end)
- out_lines.append(coord)
- self.sketch.proj_lines = out_lines
-
- def find_duplicate_points_2d(self, edges):
- points = []
- seen = set()
- duplicates = []
-
- for edge in edges:
- for point in edge:
- # Extract only x and y coordinates
- point_2d = (point[0], point[1])
- if point_2d in seen:
- if point_2d not in duplicates:
- duplicates.append(point_2d)
- else:
- seen.add(point_2d)
- points.append(point_2d)
-
- return duplicates
-
- def normal_to_quaternion(self, normal):
- normal = np.array(normal)
- #normal = normal / np.linalg.norm(normal)
-
- axis = np.cross([0, 0, 1], normal)
- if np.allclose(axis, 0):
- axis = np.array([1, 0, 0])
- else:
- axis = axis / np.linalg.norm(axis) # Normalize the axis
-
- angle = np.arccos(np.dot([0, 0, 1], normal))
-
- qw = np.cos(angle / 2)
- sin_half_angle = np.sin(angle / 2)
- qx, qy, qz = axis * sin_half_angle # This will now work correctly
-
- return qw, qx, qy, qz
-
- def create_workplane_space(self, points, normal):
- print("edges", points)
- origin = self.find_duplicate_points_2d(points)
- print(origin)
- x, y = origin[0]
- origin = QPoint(x, y)
-
- origin_handle = self.get_handle_from_ui_point(origin)
- qw, qx, qy, qz = self.normal_to_quaternion(normal)
-
- slv_normal = self.solv.add_normal_3d(qw, qx, qy, qz)
- self.sketch.working_plane = self.solv.add_work_plane(origin_handle, slv_normal)
- print(self.sketch.working_plane)
-
- def get_handle_nr(self, input_str: str) -> int:
- # Define the regex pattern to extract the handle number
- pattern = r"handle=(\d+)"
-
- # Use re.search to find the handle number in the string
- match = re.search(pattern, input_str)
-
- if match:
- handle_number = int(match.group(1))
- print(f"Handle number: {handle_number}")
- return int(handle_number)
-
- else:
- print("Handle number not found.")
- return 0
-
- def get_keys(self, d: dict, target: QPoint) -> list:
- result = []
- path = []
- print(d)
- print(target)
- for k, v in d.items():
- path.append(k)
- if isinstance(v, dict):
- self.get_keys(v, target)
- if v == target:
- result.append(copy(path))
- path.pop()
-
- return result
-
- def get_handle_from_ui_point(self, ui_point: QPoint):
- """Input QPoint and you shall reveive a slvs entity handle!"""
- for point in self.sketch.slv_points:
- if ui_point == point['ui_point']:
- slv_handle = point['solv_handle']
-
- return slv_handle
-
- def get_line_handle_from_ui_point(self, ui_point: QPoint):
- """Input Qpoint that is on a line and you shall receive the handle of the line!"""
- for target_line_con in self.sketch.slv_lines:
- if self.is_point_on_line(ui_point, target_line_con['ui_points'][0], target_line_con['ui_points'][1]):
- slv_handle = target_line_con['solv_handle']
-
- return slv_handle
-
- def get_point_line_handles_from_ui_point(self, ui_point: QPoint) -> tuple:
- """Input Qpoint that is on a line and you shall receive the handles of the points of the line!"""
- for target_line_con in self.sketch.slv_lines:
- if self.is_point_on_line(ui_point, target_line_con['ui_points'][0], target_line_con['ui_points'][1]):
- lines_to_cons = target_line_con['solv_entity_points']
-
- return lines_to_cons
-
- def distance(self, p1, p2):
- return math.sqrt((p1.x() - p2.x())**2 + (p1.y() - p2.y())**2)
-
- def calculate_midpoint(self, point1, point2):
- mx = (point1.x() + point2.x()) // 2
- my = (point1.y() + point2.y()) // 2
- return QPoint(mx, my)
-
- def is_point_on_line(self, p, p1, p2, tolerance=5):
- # Calculate the lengths of the sides of the triangle
- a = self.distance(p, p1)
- b = self.distance(p, p2)
- c = self.distance(p1, p2)
-
- # Calculate the semi-perimeter
- s = (a + b + c) / 2
-
- # Calculate the area using Heron's formula
- area = math.sqrt(s * (s - a) * (s - b) * (s - c))
-
- # Calculate the height (perpendicular distance from the point to the line)
- if c > 0:
- height = (2 * area) / c
- # Check if the height is within the tolerance distance to the line
- if height > tolerance:
- return False
-
- # Check if the projection of the point onto the line is within the line segment
- dot_product = ((p.x() - p1.x()) * (p2.x() - p1.x()) + (p.y() - p1.y()) * (p2.y() - p1.y())) / (c ** 2)
-
- return 0 <= dot_product <= 1
- else:
- return None
-
- def viewport_to_local_coord(self, qt_pos : QPoint) -> QPoint:
- return QPoint(self.to_quadrant_coords(qt_pos))
-
- def check_all_points(self,) -> list:
- old_points_ui = []
- new_points_ui = []
-
- for old_point_ui in self.sketch.slv_points:
- old_points_ui.append(old_point_ui['ui_point'])
-
- for i in range(self.solv.entity_len()):
- # Iterate though full length because mixed list from SS
- entity = self.solv.entity(i)
- if entity.is_point_2d() and self.solv.params(entity.params):
- x_tbu, y_tbu = self.solv.params(entity.params)
- point_solved = QPoint(x_tbu, y_tbu)
- new_points_ui.append(point_solved)
-
- # Now we have old_points_ui and new_points_ui, let's compare them
- differences = []
-
- if len(old_points_ui) != len(new_points_ui):
- print(f"Length mismatch {len(old_points_ui)} - {len(new_points_ui)}")
-
- for index, (old_point, new_point) in enumerate(zip(old_points_ui, new_points_ui)):
- if old_point != new_point:
- differences.append((index, old_point, new_point))
-
- return differences
-
- def update_ui_points(self, point_list: list):
- # Print initial state of slv_points_main
- # print("Initial slv_points_main:", self.slv_points_main)
- print("Change list:", point_list)
-
- if len(point_list) > 0:
- for tbu_points_idx in point_list:
- # Each tbu_points_idx is a tuple: (index, old_point, new_point)
- index, old_point, new_point = tbu_points_idx
-
- # Update the point in slv_points_main
- self.sketch.slv_points[index]['ui_point'] = new_point
- # Print updated state
- # print("Updated slv_points_main:", self.slv_points_main)
-
- def check_all_lines_and_update(self,changed_points: list):
- for tbu_points_idx in changed_points:
- index, old_point, new_point = tbu_points_idx
- for line_needs_update in self.sketch.slv_lines:
- if old_point == line_needs_update['ui_points'][0]:
- line_needs_update['ui_points'][0] = new_point
- elif old_point == line_needs_update['ui_points'][1]:
- line_needs_update['ui_points'][1] = new_point
-
- def mouseReleaseEvent(self, event):
- local_event_pos = self.viewport_to_local_coord(event.pos())
-
- if event.button() == Qt.LeftButton and not self.mouse_mode:
- self.drag_buffer[1] = local_event_pos
-
- print("Le main buffer", self.drag_buffer)
-
- if len(self.main_buffer) == 2:
- entry = self.drag_buffer[0]
- new_params = self.drag_buffer[1].x(), self.drag_buffer[1].y()
- self.solv.set_params(entry.params, new_params)
-
- self.solv.solve()
-
- points_need_update = self.check_all_points()
- self.update_ui_points(points_need_update)
- self.check_all_lines_and_update(points_need_update)
-
- self.update()
- self.drag_buffer = [None, None]
-
- def mousePressEvent(self, event):
- local_event_pos = self.viewport_to_local_coord(event.pos())
-
- relation_point = {
- 'handle_nr': None,
- 'solv_handle': None,
- 'ui_point': None,
- 'part_of_entity': None
- }
-
- relation_line = {
- 'handle_nr': None,
- 'solv_handle': None,
- 'solv_entity_points': None,
- 'ui_points': None
- }
-
- if event.button() == Qt.LeftButton and not self.mouse_mode:
- self.drag_buffer[0] = self.get_handle_from_ui_point(self.hovered_point)
-
- if event.button() == Qt.RightButton and self.mouse_mode:
- self.reset_buffers()
-
- if event.button() == Qt.LeftButton and self.mouse_mode == "line":
- if self.hovered_point:
- clicked_pos = self.hovered_point
- else:
- clicked_pos = local_event_pos
-
- if not self.line_draw_buffer[0]:
- self.line_draw_buffer[0] = clicked_pos
- u = clicked_pos.x()
- v = clicked_pos.y()
-
- point = self.solv.add_point_2d(u, v, self.sketch.working_plane)
-
- relation_point = {} # Reinitialize the dictionary
- handle_nr = self.get_handle_nr(str(point))
- relation_point['handle_nr'] = handle_nr
- relation_point['solv_handle'] = point
- relation_point['ui_point'] = clicked_pos
-
- self.sketch.slv_points.append(relation_point)
-
- print("points", self.sketch.slv_points)
- print("lines", self.sketch.slv_lines)
-
- elif self.line_draw_buffer[0]:
- self.line_draw_buffer[1] = clicked_pos
- u = clicked_pos.x()
- v = clicked_pos.y()
-
- point2 = self.solv.add_point_2d(u, v, self.sketch.working_plane)
-
- relation_point = {} # Reinitialize the dictionary
- handle_nr = self.get_handle_nr(str(point2))
- relation_point['handle_nr'] = handle_nr
- relation_point['solv_handle'] = point2
- relation_point['ui_point'] = clicked_pos
-
- self.sketch.slv_points.append(relation_point)
-
- print("points", self.sketch.slv_points)
- print("lines", self.sketch.slv_lines)
-
- print("Buffer state", self.line_draw_buffer)
-
- if self.line_draw_buffer[0] and self.line_draw_buffer[1]:
-
- point_slv1 = self.get_handle_from_ui_point(self.line_draw_buffer[0])
- point_slv2 = self.get_handle_from_ui_point(self.line_draw_buffer[1])
- print(point_slv1)
- print(point_slv2)
-
- line = self.solv.add_line_2d(point_slv1, point_slv2, self.sketch.working_plane)
-
- relation_line = {} # Reinitialize the dictionary
- handle_nr_line = self.get_handle_nr(str(line))
- relation_line['handle_nr'] = handle_nr_line
- relation_line['solv_handle'] = line
- relation_line['solv_entity_points'] = (point_slv1, point_slv2)
- relation_line['ui_points'] = [self.line_draw_buffer[0], self.line_draw_buffer[1]]
-
- # Track relationship of point in line
- relation_point['part_of_entity'] = handle_nr_line
-
- self.sketch.slv_lines.append(relation_line)
-
- # Reset the buffer for the next line segment
- self.line_draw_buffer[0] = self.line_draw_buffer[1]
- self.line_draw_buffer[1] = None
-
- # Track Relationship
- # Points
-
- if event.button() == Qt.LeftButton and self.mouse_mode == "pt_pt":
- if self.hovered_point and not self.main_buffer[0]:
- self.main_buffer[0] = self.get_handle_from_ui_point(self.hovered_point)
-
- elif self.main_buffer[0]:
- self.main_buffer[1] = self.get_handle_from_ui_point(self.hovered_point)
-
- if self.main_buffer[0] and self.main_buffer[1]:
- print("buf", self.main_buffer)
-
- self.solv.coincident(self.main_buffer[0], self.main_buffer[1], self.sketch.working_plane)
-
- if self.solv.solve() == ResultFlag.OKAY:
- print("Fuck yeah")
-
- elif self.solv.solve() == ResultFlag.DIDNT_CONVERGE:
- print("Solve_failed - Converge")
-
- elif self.solv.solve() == ResultFlag.TOO_MANY_UNKNOWNS:
- print("Solve_failed - Unknowns")
-
- elif self.solv.solve() == ResultFlag.INCONSISTENT:
- print("Solve_failed - Incons")
- self.constrain_done.emit()
- self.main_buffer = [None, None]
-
- if event.button() == Qt.LeftButton and self.mouse_mode == "pt_line":
- print("ptline")
- line_selected = None
-
- if self.hovered_point and not self.main_buffer[1]:
- self.main_buffer[0] = self.get_handle_from_ui_point(self.hovered_point)
-
- elif self.main_buffer[0]:
- self.main_buffer[1] = self.get_line_handle_from_ui_point(local_event_pos)
-
- # Contrain point to line
- if self.main_buffer[1]:
- self.solv.coincident(self.main_buffer[0], self.main_buffer[1], self.sketch.working_plane)
-
- if self.solv.solve() == ResultFlag.OKAY:
- print("Fuck yeah")
- self.constrain_done.emit()
-
- elif self.solv.solve() == ResultFlag.DIDNT_CONVERGE:
- print("Solve_failed - Converge")
-
- elif self.solv.solve() == ResultFlag.TOO_MANY_UNKNOWNS:
- print("Solve_failed - Unknowns")
-
- elif self.solv.solve() == ResultFlag.INCONSISTENT:
- print("Solve_failed - Incons")
-
- self.constrain_done.emit()
- # Clear saved_points after solve attempt
- self.main_buffer = [None, None]
-
- if event.button() == Qt.LeftButton and self.mouse_mode == "pb_con_mid":
- print("ptline")
- line_selected = None
-
- if self.hovered_point and not self.main_buffer[1]:
- self.main_buffer[0] = self.get_handle_from_ui_point(self.hovered_point)
-
- elif self.main_buffer[0]:
- self.main_buffer[1] = self.get_line_handle_from_ui_point(local_event_pos)
-
- # Contrain point to line
- if self.main_buffer[1]:
- self.solv.midpoint(self.main_buffer[0], self.main_buffer[1], self.sketch.working_plane)
-
- if self.solv.solve() == ResultFlag.OKAY:
- print("Fuck yeah")
-
- elif self.solv.solve() == ResultFlag.DIDNT_CONVERGE:
- print("Solve_failed - Converge")
-
- elif self.solv.solve() == ResultFlag.TOO_MANY_UNKNOWNS:
- print("Solve_failed - Unknowns")
-
- elif self.solv.solve() == ResultFlag.INCONSISTENT:
- print("Solve_failed - Incons")
- self.constrain_done.emit()
-
- self.main_buffer = [None, None]
-
- if event.button() == Qt.LeftButton and self.mouse_mode == "horiz":
-
- line_selected = self.get_line_handle_from_ui_point(local_event_pos)
-
- if line_selected:
- self.solv.horizontal(line_selected, self.sketch.working_plane)
-
- if self.solv.solve() == ResultFlag.OKAY:
- print("Fuck yeah")
-
- elif self.solv.solve() == ResultFlag.DIDNT_CONVERGE:
- print("Solve_failed - Converge")
-
- elif self.solv.solve() == ResultFlag.TOO_MANY_UNKNOWNS:
- print("Solve_failed - Unknowns")
-
- elif self.solv.solve() == ResultFlag.INCONSISTENT:
- print("Solve_failed - Incons")
-
- if event.button() == Qt.LeftButton and self.mouse_mode == "vert":
- line_selected = self.get_line_handle_from_ui_point(local_event_pos)
-
- if line_selected:
- self.solv.vertical(line_selected, self.sketch.working_plane)
-
- if self.solv.solve() == ResultFlag.OKAY:
- print("Fuck yeah")
-
- elif self.solv.solve() == ResultFlag.DIDNT_CONVERGE:
- print("Solve_failed - Converge")
-
- elif self.solv.solve() == ResultFlag.TOO_MANY_UNKNOWNS:
- print("Solve_failed - Unknowns")
-
- elif self.solv.solve() == ResultFlag.INCONSISTENT:
- print("Solve_failed - Incons")
-
- if event.button() == Qt.LeftButton and self.mouse_mode == "distance":
- # Depending on selected elemnts either point line or line distance
- #print("distance")
- e1 = None
- e2 = None
-
- if self.hovered_point:
- print("buf point")
- # Get the point as UI point as buffer
- self.main_buffer[0] = self.hovered_point
-
- elif self.selected_line:
- # Get the point as UI point as buffer
- self.main_buffer[1] = local_event_pos
-
- if self.main_buffer[0] and self.main_buffer[1]:
- # Define point line combination
- e1 = self.get_handle_from_ui_point(self.main_buffer[0])
- e2 = self.get_line_handle_from_ui_point(self.main_buffer[1])
-
- elif not self.main_buffer[0]:
- # Define only line selection
- e1, e2 = self.get_point_line_handles_from_ui_point(local_event_pos)
-
- if e1 and e2:
- # Ask fo the dimension and solve if both elements are present
- length, ok = QInputDialog.getDouble(self, 'Distance', 'Enter a mm value:', value=100, decimals=2)
- self.solv.distance(e1, e2, length, self.sketch.working_plane)
-
- if self.solv.solve() == ResultFlag.OKAY:
- print("Fuck yeah")
-
- elif self.solv.solve() == ResultFlag.DIDNT_CONVERGE:
- print("Solve_failed - Converge")
-
- elif self.solv.solve() == ResultFlag.TOO_MANY_UNKNOWNS:
- print("Solve_failed - Unknowns")
-
- elif self.solv.solve() == ResultFlag.INCONSISTENT:
- print("Solve_failed - Incons")
-
- self.constrain_done.emit()
- self.main_buffer = [None, None]
-
- # Update the main point list with the new elements and draw them
- points_need_update = self.check_all_points()
- self.update_ui_points(points_need_update)
- self.check_all_lines_and_update(points_need_update)
-
- self.update()
-
- def mouseMoveEvent(self, event):
- local_event_pos = self.viewport_to_local_coord(event.pos())
-
- closest_point = None
- min_distance = float('inf')
- threshold = 10 # Distance threshold for highlighting
-
- if self.sketch:
-
- for point in self.sketch.slv_points:
- distance = (local_event_pos - point['ui_point']).manhattanLength()
- if distance < threshold and distance < min_distance:
- closest_point = point['ui_point']
- min_distance = distance
-
- for point in self.sketch.proj_points:
- distance = (local_event_pos - point).manhattanLength()
- if distance < threshold and distance < min_distance:
- closest_point = point
- min_distance = distance
-
- if closest_point != self.hovered_point:
- self.hovered_point = closest_point
- print(self.hovered_point)
-
- for dic in self.sketch.slv_lines:
- p1 = dic['ui_points'][0]
- p2 = dic['ui_points'][1]
-
- if self.is_point_on_line(local_event_pos, p1, p2):
- self.selected_line = p1, p2
- break
- else:
- self.selected_line = None
-
- self.update()
-
- def mouseDoubleClickEvent(self, event):
- pass
-
- def drawBackgroundGrid(self, painter):
- """Draw a background grid."""
- grid_spacing = 50
- pen = QPen(QColor(200, 200, 200), 1, Qt.SolidLine)
- painter.setPen(pen)
-
- # Draw vertical grid lines
- for x in range(-self.width() // 2, self.width() // 2, grid_spacing):
- painter.drawLine(x, -self.height() // 2, x, self.height() // 2)
-
- # Draw horizontal grid lines
- for y in range(-self.height() // 2, self.height() // 2, grid_spacing):
- painter.drawLine(-self.width() // 2, y, self.width() // 2, y)
-
- def drawAxes(self, painter):
- painter.setRenderHint(QPainter.Antialiasing)
-
- # Set up pen for dashed lines
- pen = QPen(Qt.gray, 1, Qt.DashLine)
- painter.setPen(pen)
-
- middle_x = self.width() // 2
- middle_y = self.height() // 2
-
- # Draw X axis as dashed line
- painter.drawLine(0, middle_y, self.width(), middle_y)
-
- # Draw Y axis as dashed line
- painter.drawLine(middle_x, 0, middle_x, self.height())
-
- # Draw tick marks
- tick_length = int(10 * self.zoom)
- tick_spacing = int(50 * self.zoom)
-
- pen = QPen(Qt.gray, 1, Qt.SolidLine)
- painter.setPen(pen)
-
- # Draw tick marks on the X axis to the right and left from the middle point
- for x in range(0, self.width() // 2, tick_spacing):
- painter.drawLine(middle_x + x, middle_y - tick_length // 2, middle_x + x, middle_y + tick_length // 2)
- painter.drawLine(middle_x - x, middle_y - tick_length // 2, middle_x - x, middle_y + tick_length // 2)
-
- # Draw tick marks on the Y axis upwards and downwards from the middle point
- for y in range(0, self.height() // 2, tick_spacing):
- painter.drawLine(middle_x - tick_length // 2, middle_y + y, middle_x + tick_length // 2, middle_y + y)
- painter.drawLine(middle_x - tick_length // 2, middle_y - y, middle_x + tick_length // 2, middle_y - y)
-
- # Draw the origin point in red
- painter.setPen(QPen(Qt.red, 4))
- painter.drawPoint(middle_x, middle_y)
-
- def draw_cross(self, painter, pos: QPoint, size=10):
- # Set up the pen
- pen = QPen(QColor('green')) # You can change the color as needed
- pen.setWidth(int(2 / self.zoom)) # Set the line widt)h
- painter.setPen(pen)
- x = pos.x()
- y = pos.y()
-
- # Calculate the endpoints of the cross
- half_size = size // 2
-
- # Draw the horizontal line
- painter.drawLine(x - half_size, y, x + half_size, y)
-
- # Draw the vertical line
- painter.drawLine(x, y - half_size, x, y + half_size)
-
- def to_quadrant_coords(self, point):
- """Translate linear coordinates to quadrant coordinates."""
- center_x = self.width() // 2
- center_y = self.height() // 2
- quadrant_x = point.x() - center_x
- quadrant_y = center_y - point.y() # Note the change here
- return QPoint(quadrant_x, quadrant_y) / self.zoom
-
- def from_quadrant_coords(self, point: QPoint):
- """Translate quadrant coordinates to linear coordinates."""
- center_x = self.width() // 2
- center_y = self.height() // 2
- widget_x = center_x + point.x() * self.zoom
- widget_y = center_y - point.y() * self.zoom # Note the subtraction here
-
- return QPoint(int(widget_x), int(widget_y))
-
- def from_quadrant_coords_no_center(self, point):
- """Invert Y Coordinate for mesh"""
- center_x = 0
- center_y = 0
- widget_x = point.x()
- widget_y = -point.y()
- return QPoint(int(widget_x), int(widget_y))
-
- def paintEvent(self, event):
- painter = QPainter(self)
- painter.setRenderHint(QPainter.Antialiasing)
-
- self.drawAxes(painter)
-
- # Create a QTransform object
- transform = QTransform()
-
- # Translate the origin to the center of the widget
- center = QPointF(self.width() / 2, self.height() / 2)
- transform.translate(center.x(), center.y())
-
- # Apply the zoom factor
- transform.scale(self.zoom, -self.zoom) # Negative y-scale to invert y-axis
-
- # Set the transform to the painter
- painter.setTransform(transform)
-
- pen = QPen(Qt.gray)
- pen.setWidthF(2 / self.zoom)
- painter.setPen(pen)
-
- # Draw points
- if self.sketch:
- for point in self.sketch.slv_points:
- painter.drawEllipse(point['ui_point'], 3 / self.zoom, 3 / self.zoom)
-
- for dic in self.sketch.slv_lines:
- p1 = dic['ui_points'][0]
- p2 = dic['ui_points'][1]
- painter.drawLine(p1, p2)
-
- dis = self.distance(p1, p2)
- mid = self.calculate_midpoint(p1, p2)
- painter.drawText(mid, str(round(dis, 2)))
-
- pen = QPen(Qt.green)
- pen.setWidthF(2 / self.zoom)
- painter.setPen(pen)
-
- if self.solv.entity_len():
- for i in range(self.solv.entity_len()):
- entity = self.solv.entity(i)
- if entity.is_point_2d() and self.solv.params(entity.params):
- x, y = self.solv.params(entity.params)
- point = QPointF(x, y)
- painter.drawEllipse(point, 6 / self.zoom, 6 / self.zoom)
-
- # Highlight point hovered
- if self.hovered_point:
- highlight_pen = QPen(QColor(255, 0, 0))
- highlight_pen.setWidthF(2 / self.zoom)
- painter.setPen(highlight_pen)
- painter.drawEllipse(self.hovered_point, 5 / self.zoom, 5 / self.zoom)
-
- # Highlight line hovered
- if self.selected_line and not self.hovered_point:
- p1, p2 = self.selected_line
- painter.setPen(QPen(Qt.red, 2 / self.zoom))
- painter.drawLine(p1, p2)
-
- for cross in self.sketch.proj_points:
- self.draw_cross(painter, cross, 10 / self.zoom)
-
- for selected in self.sketch.proj_lines:
- pen = QPen(Qt.white, 1, Qt.DashLine)
- painter.setPen(pen)
- painter.drawLine(selected)
-
- painter.end()
-
- def wheelEvent(self, event):
- delta = event.angleDelta().y()
- self.zoom += (delta / 200) * 0.1
- self.update()
-
- def aspect_ratio(self):
- return self.width() / self.height() * (1.0 / abs(self.zoom))
-
-
-class Point2D:
- """Improved oop aaproach?"""
- def __init__(self):
- self.ui_point = None
- self.solve_handle_nr = None
- self.solve_handle = None
- self.part_of_entity = None
-
- def to_quadrant_coords(self, point):
- """Translate linear coordinates to quadrant coordinates."""
- center_x = self.width() // 2
- center_y = self.height() // 2
- quadrant_x = point.x() - center_x
- quadrant_y = center_y - point.y() # Note the change here
-
- return QPoint(quadrant_x, quadrant_y) / self.zoom
-
- def from_quadrant_coords(self, point: QPoint):
- """Translate quadrant coordinates to linear coordinates."""
- center_x = self.width() // 2
- center_y = self.height() // 2
- widget_x = center_x + point.x() * self.zoom
- widget_y = center_y - point.y() * self.zoom # Note the subtraction here
-
- return QPoint(int(widget_x), int(widget_y))
-
- def from_quadrant_coords_no_center(self, point):
- """Invert Y Coordinate for mesh"""
- center_x = 0
- center_y = 0
- widget_x = point.x()
- widget_y = -point.y()
-
- return QPoint(int(widget_x), int(widget_y))
-
- def get_handle_nr(self, input_str: str) -> int:
- # Define the regex pattern to extract the handle number
- pattern = r"handle=(\d+)"
-
- # Use re.search to find the handle number in the string
- match = re.search(pattern, input_str)
-
- if match:
- handle_number = int(match.group(1))
- print(f"Handle number: {handle_number}")
- return int(handle_number)
-
- else:
- print("Handle number not found.")
- return 0
-
- def get_keys(self, d: dict, target: QPoint) -> list:
- result = []
- path = []
- print(d)
- print(target)
- for k, v in d.items():
- path.append(k)
- if isinstance(v, dict):
- self.get_keys(v, target)
- if v == target:
- result.append(copy(path))
- path.pop()
-
- return result
-
- def get_handle_from_ui_point(self, ui_point: QPoint):
- """Input QPoint and you shall reveive a slvs entity handle!"""
- for point in self.sketch.slv_points:
- if ui_point == point['ui_point']:
- slv_handle = point['solv_handle']
-
- return slv_handle
-
- def get_line_handle_from_ui_point(self, ui_point: QPoint):
- """Input Qpoint that is on a line and you shall receive the handle of the line!"""
- for target_line_con in self.sketch.slv_lines:
- if self.is_point_on_line(ui_point, target_line_con['ui_points'][0], target_line_con['ui_points'][1]):
- slv_handle = target_line_con['solv_handle']
-
- return slv_handle
-
- def get_point_line_handles_from_ui_point(self, ui_point: QPoint) -> tuple:
- """Input Qpoint that is on a line and you shall receive the handles of the points of the line!"""
- for target_line_con in self.sketch.slv_lines:
- if self.is_point_on_line(ui_point, target_line_con['ui_points'][0], target_line_con['ui_points'][1]):
- lines_to_cons = target_line_con['solv_entity_points']
-
- return lines_to_cons
-
- def distance(self, p1, p2):
- return math.sqrt((p1.x() - p2.x())**2 + (p1.y() - p2.y())**2)
-
- def calculate_midpoint(self, point1, point2):
- mx = (point1.x() + point2.x()) // 2
- my = (point1.y() + point2.y()) // 2
- return QPoint(mx, my)
-
- def is_point_on_line(self, p, p1, p2, tolerance=5):
- # Calculate the lengths of the sides of the triangle
- a = self.distance(p, p1)
- b = self.distance(p, p2)
- c = self.distance(p1, p2)
-
- # Calculate the semi-perimeter
- s = (a + b + c) / 2
-
- # Calculate the area using Heron's formula
- area = math.sqrt(s * (s - a) * (s - b) * (s - c))
-
- # Calculate the height (perpendicular distance from the point to the line)
- if c > 0:
- height = (2 * area) / c
- # Check if the height is within the tolerance distance to the line
- if height > tolerance:
- return False
-
- # Check if the projection of the point onto the line is within the line segment
- dot_product = ((p.x() - p1.x()) * (p2.x() - p1.x()) + (p.y() - p1.y()) * (p2.y() - p1.y())) / (c ** 2)
-
- return 0 <= dot_product <= 1
- else:
- return None
-
- def viewport_to_local_coord(self, qt_pos : QPoint) -> QPoint:
- return QPoint(self.to_quadrant_coords(qt_pos))
-
-
-class Line2D:
- pass
-
-class Sketch2d(SolverSystem):
-
-
-if __name__ == "__main__":
- import sys
-
- app = QApplication(sys.argv)
- window = SketchWidget()
- window.setWindowTitle("Snap Line Widget")
- window.resize(800, 600)
- window.show()
- sys.exit(app.exec())
diff --git a/drawing_modules/draw_widget_solve.py b/drawing_modules/draw_widget_solve.py
deleted file mode 100644
index 23f3716..0000000
--- a/drawing_modules/draw_widget_solve.py
+++ /dev/null
@@ -1,1098 +0,0 @@
-import math
-import re
-from copy import copy
-import uuid
-
-import numpy as np
-from PySide6.QtWidgets import QApplication, QWidget, QMessageBox, QInputDialog
-from PySide6.QtGui import QPainter, QPen, QColor, QTransform
-from PySide6.QtCore import Qt, QPoint, QPointF, Signal, QLine
-from python_solvespace import SolverSystem, ResultFlag
-
-
-class SketchWidget(QWidget):
- constrain_done = Signal()
-
- def __init__(self):
- super().__init__()
-
- self.line_draw_buffer = [None, None]
- self.drag_buffer = [None, None]
- self.main_buffer = [None, None]
- self.dynamic_line_end = None # Cursor position for dynamic drawing
-
- self.hovered_point = None
- self.selected_line = None
-
- ### Display Settings
- self.snapping_range = 20 # Range in pixels for snapping
- self.zoom = 1
-
- # Mouse Input
- self.setMouseTracking(True)
- self.mouse_mode = False
- self.is_construct = False
-
- self.snap_mode = {
- "point": False,
- "mpoint": False,
- "horiz": False,
- "vert":False,
- "grid": False,
- "angle": False
- }
-
- # Solver
- self.solv = SolverSystem()
-
- self.sketch = Sketch2d()
-
- def act_line_mode(self, checked):
- if checked:
- self.mouse_mode = 'line'
- print(self.mouse_mode)
- else:
- self.mouse_mode = None
-
- def act_constrain_pt_pt_mode(self):
- self.mouse_mode = 'pt_pt'
-
- def act_constrain_pt_line_mode(self):
- self.mouse_mode = 'pt_line'
-
- def act_constrain_horiz_line_mode(self):
- self.mouse_mode = 'horiz'
-
- def act_constrain_vert_line_mode(self):
- self.mouse_mode = 'vert'
-
- def act_constrain_distance_mode(self):
- self.mouse_mode = 'distance'
-
- def act_constrain_mid_point_mode(self):
- self.mouse_mode = 'pb_con_mid'
-
- def on_snap_mode_change(self, helper_type: str, value: bool):
- self.snap_mode[helper_type] = value
-
- def on_construct_change(self, checked):
- self.is_construct = checked
-
- def create_sketch(self, sketch_in ):
- self.sketch = Sketch2d()
- self.sketch.id = sketch_in.id
- self.sketch.origin = sketch_in.origin
-
- def set_sketch(self, sketch_in):
- """Needs to be an already defined Sketch object coming from the widget itself"""
- self.sketch = sketch_in
-
- def get_sketch(self):
- return self.sketch
-
- def reset_buffers(self):
- self.mouse_mode = None
- self.line_draw_buffer = [None, None]
- self.drag_buffer = [None, None]
- self.main_buffer = [None, None]
-
- def set_points(self, points: list):
- self.points = points
- #self.update()
-
- def create_workplane(self):
- self.sketch.wp = self.sketch.create_2d_base()
-
- def create_workplane_projected(self):
- self.sketch.wp = self.sketch.create_2d_base()
-
- def convert_proj_points(self, proj_points: list):
- ### This needs to create a proper Point2D class with bool construction enbaled
- out_points = []
- for point in proj_points:
- pnt = Point2D(point[0], point[1])
- # Construction
- pnt.is_helper = True
- print(point)
- self.sketch.add_point(pnt)
-
- def convert_proj_lines(self, proj_lines: list):
- ### same as for point
- out_lines = []
- for line in proj_lines:
- start = Point2D(line[0][0], line[0][1])
- end = Point2D(line[1][0], line[1][1])
- start.is_helper = True
- end.is_helper = True
-
- self.sketch.add_point(start)
- self.sketch.add_point(end)
-
- lne = Line2D(start, end)
-
- #Construction
- lne.is_helper = True
- self.sketch.add_line(lne)
-
- def find_duplicate_points_2d(self, edges):
- points = []
- seen = set()
- duplicates = []
-
- for edge in edges:
- for point in edge:
- # Extract only x and y coordinates
- point_2d = (point[0], point[1])
- if point_2d in seen:
- if point_2d not in duplicates:
- duplicates.append(point_2d)
- else:
- seen.add(point_2d)
- points.append(point_2d)
-
- return duplicates
-
- def normal_to_quaternion(self, normal):
- normal = np.array(normal)
- #normal = normal / np.linalg.norm(normal)
-
- axis = np.cross([0, 0, 1], normal)
- if np.allclose(axis, 0):
- axis = np.array([1, 0, 0])
- else:
- axis = axis / np.linalg.norm(axis) # Normalize the axis
-
- angle = np.arccos(np.dot([0, 0, 1], normal))
-
- qw = np.cos(angle / 2)
- sin_half_angle = np.sin(angle / 2)
- qx, qy, qz = axis * sin_half_angle # This will now work correctly
-
- return qw, qx, qy, qz
-
- def create_workplane_space(self, points, normal):
- print("edges", points)
- origin = self.find_duplicate_points_2d(points)
- print(origin)
- x, y = origin[0]
- origin = QPoint(x, y)
-
- origin_handle = self.get_handle_from_ui_point(origin)
- qw, qx, qy, qz = self.normal_to_quaternion(normal)
-
- slv_normal = self.sketch.add_normal_3d(qw, qx, qy, qz)
- self.sketch.wp = self.sketch.add_work_plane(origin_handle, slv_normal)
- print(self.sketch.wp)
-
- def get_handle_nr(self, input_str: str) -> int:
- # Define the regex pattern to extract the handle number
- pattern = r"handle=(\d+)"
-
- # Use re.search to find the handle number in the string
- match = re.search(pattern, input_str)
-
- if match:
- handle_number = int(match.group(1))
- print(f"Handle number: {handle_number}")
- return int(handle_number)
-
- else:
- print("Handle number not found.")
- return 0
-
- def get_keys(self, d: dict, target: QPoint) -> list:
- result = []
- path = []
- print(d)
- print(target)
- for k, v in d.items():
- path.append(k)
- if isinstance(v, dict):
- self.get_keys(v, target)
- if v == target:
- result.append(copy(path))
- path.pop()
-
- return result
-
- def get_handle_from_ui_point(self, ui_point: QPoint):
- """Input QPoint and you shall reveive a slvs entity handle!"""
- for point in self.sketch.points:
- if ui_point == point.ui_point:
- slv_handle = point.handle
-
- return slv_handle
-
- def get_line_handle_from_ui_point(self, ui_point: QPoint):
- """Input Qpoint that is on a line and you shall receive the handle of the line!"""
- for target_line_con in self.sketch.lines:
- if self.is_point_on_line(ui_point, target_line_con.crd1.ui_point, target_line_con.crd2.ui_point):
- slv_handle = target_line_con.handle
-
- return slv_handle
-
- def get_point_line_handles_from_ui_point(self, ui_point: QPoint) -> tuple:
- """Input Qpoint that is on a line and you shall receive the handles of the points of the line!"""
- for target_line_con in self.sketch.lines:
- if self.is_point_on_line(ui_point, target_line_con.crd1.ui_point, target_line_con.crd2.ui_point):
- lines_to_cons = target_line_con.crd1.handle, target_line_con.crd2.handle
-
- return lines_to_cons
-
- def distance(self, p1, p2):
- return math.sqrt((p1.x() - p2.x())**2 + (p1.y() - p2.y())**2)
-
- def calculate_midpoint(self, point1, point2):
- mx = (point1.x() + point2.x()) // 2
- my = (point1.y() + point2.y()) // 2
- return QPoint(mx, my)
-
- def is_point_on_line(self, p, p1, p2, tolerance=5):
- # Calculate the lengths of the sides of the triangle
- a = self.distance(p, p1)
- b = self.distance(p, p2)
- c = self.distance(p1, p2)
-
- # Calculate the semi-perimeter
- s = (a + b + c) / 2
-
- # Calculate the area using Heron's formula
- area = math.sqrt(s * (s - a) * (s - b) * (s - c))
-
- # Calculate the height (perpendicular distance from the point to the line)
- if c > 0:
- height = (2 * area) / c
- # Check if the height is within the tolerance distance to the line
- if height > tolerance:
- return False
-
- # Check if the projection of the point onto the line is within the line segment
- dot_product = ((p.x() - p1.x()) * (p2.x() - p1.x()) + (p.y() - p1.y()) * (p2.y() - p1.y())) / (c ** 2)
-
- return 0 <= dot_product <= 1
- else:
- return None
-
- def viewport_to_local_coord(self, qt_pos : QPoint) -> QPoint:
- return QPoint(self.to_quadrant_coords(qt_pos))
-
- def check_all_points(self) -> list:
- """
- Go through solversystem and check points2d for changes in position after solving
- :return: List with points that now have a different position
- """
-
- old_points_ui = []
- new_points_ui = []
-
- for index, old_point_ui in enumerate(self.sketch.points):
- old_points_ui.append((index, old_point_ui.ui_point))
-
- for i in range(self.sketch.entity_len()):
- # Iterate though full length because mixed list from SS
- entity = self.sketch.entity(i)
- if entity.is_point_2d() and self.sketch.params(entity.params):
- x_tbu, y_tbu = self.sketch.params(entity.params)
- point_solved = QPoint(x_tbu, y_tbu)
- new_points_ui.append(point_solved)
-
- # Now we have old_points_ui and new_points_ui, let's compare them
- differences = []
-
- if len(old_points_ui) != len(new_points_ui):
- print(f"Length mismatch {len(old_points_ui)} - {len(new_points_ui)}")
-
- for (old_index, old_point), new_point in zip(old_points_ui, new_points_ui):
- if old_point != new_point:
- #print(old_point)
- differences.append((old_index, old_point, new_point))
-
- return differences
-
- def update_ui_points(self, point_list: list):
- # Print initial state of slv_points_main
- # print("Initial slv_points_main:", self.slv_points_main)
- print("Change list:", point_list)
-
- """for point in point_list:
- new = Point2D(point.x(), point.y())
- self.sketch.points.append(new)"""
-
- if len(point_list) > 1:
- for tbu_points_idx in point_list:
- # Each tbu_points_idx is a tuple: (index, old_point, new_point)
- index, old_point, new_point = tbu_points_idx
-
- # Update the point in slv_points_main
- self.sketch.points[index].ui_point = new_point
-
- """for points in self.sketch.points:
- print(points.ui_point)"""
- # Print updated state
- # print("Updated slv_points_main:", self.slv_points_main)
-
- def check_all_lines_and_update(self,changed_points: list):
- for tbu_points_idx in changed_points:
- index, old_point, new_point = tbu_points_idx
- for line_needs_update in self.sketch.lines:
- if old_point == line_needs_update.crd1.ui_point:
- line_needs_update.crd1.ui_point = new_point
- elif old_point == line_needs_update.crd2.ui_point:
- line_needs_update.crd2.ui_point = new_point
-
- def mouseReleaseEvent(self, event):
- local_event_pos = self.viewport_to_local_coord(event.pos())
-
- if event.button() == Qt.LeftButton and not self.mouse_mode:
- self.drag_buffer[1] = local_event_pos
-
- #print("Le main buffer", self.drag_buffer)
-
- if not None in self.main_buffer and len(self.main_buffer) == 2:
- entry = self.drag_buffer[0]
- new_params = self.drag_buffer[1].x(), self.drag_buffer[1].y()
- self.sketch.set_params(entry.params, new_params)
-
- self.sketch.solve()
-
- #points_need_update = self.check_all_points()
- #self.update_ui_points(points_need_update)
- #self.check_all_lines_and_update(points_need_update)
-
- self.update()
- self.drag_buffer = [None, None]
-
- def mousePressEvent(self, event):
- local_event_pos = self.viewport_to_local_coord(event.pos())
-
-
- if event.button() == Qt.LeftButton and not self.mouse_mode:
- self.drag_buffer[0] = self.get_handle_from_ui_point(self.hovered_point)
-
- if event.button() == Qt.RightButton and self.mouse_mode:
- self.constrain_done.emit()
- self.reset_buffers()
-
- if event.button() == Qt.LeftButton and self.mouse_mode == "line":
- if self.hovered_point:
- clicked_pos = self.hovered_point
- else:
- clicked_pos = local_event_pos
-
- if not self.line_draw_buffer[0]:
-
- u = clicked_pos.x()
- v = clicked_pos.y()
-
- point = Point2D(u,v)
- print("construct", self.is_construct )
- # Construction mode
- point.is_helper = self.is_construct
-
- self.sketch.add_point(point)
-
- self.line_draw_buffer[0] = point
-
- elif self.line_draw_buffer[0]:
-
- u = clicked_pos.x()
- v = clicked_pos.y()
- print("construct", self.is_construct)
- point = Point2D(u, v)
-
- # Construction mode
- point.is_helper = self.is_construct
-
- self.sketch.add_point(point)
-
- self.line_draw_buffer[1] = point
-
- #print("Buffer state", self.line_draw_buffer)
-
- if self.line_draw_buffer[0] and self.line_draw_buffer[1]:
-
- line = Line2D(self.line_draw_buffer[0], self.line_draw_buffer[1])
-
- # Construction mode
- line.is_helper = self.is_construct
-
- self.sketch.add_line(line)
-
- # Reset the buffer for the next line segment
- self.line_draw_buffer[0] = self.line_draw_buffer[1]
- self.line_draw_buffer[1] = None
-
- # Track Relationship
- # Points
-
- # CONSTRAINTS
-
- if event.button() == Qt.LeftButton and self.mouse_mode == "pt_pt":
- if self.hovered_point and not self.main_buffer[0]:
- self.main_buffer[0] = self.get_handle_from_ui_point(self.hovered_point)
-
- elif self.main_buffer[0]:
- self.main_buffer[1] = self.get_handle_from_ui_point(self.hovered_point)
-
- if self.main_buffer[0] and self.main_buffer[1]:
- # print("buf", self.main_buffer)
-
- self.sketch.coincident(self.main_buffer[0], self.main_buffer[1], self.sketch.wp)
-
- if self.sketch.solve() == ResultFlag.OKAY:
- print("Fuck yeah")
-
- elif self.sketch.solve() == ResultFlag.DIDNT_CONVERGE:
- print("Solve_failed - Converge")
-
- elif self.sketch.solve() == ResultFlag.TOO_MANY_UNKNOWNS:
- print("Solve_failed - Unknowns")
-
- elif self.sketch.solve() == ResultFlag.INCONSISTENT:
- print("Solve_failed - Incons")
-
- self.constrain_done.emit()
- self.main_buffer = [None, None]
-
- if event.button() == Qt.LeftButton and self.mouse_mode == "pt_line":
- #print("ptline")
- line_selected = None
-
- if self.hovered_point and not self.main_buffer[1]:
- self.main_buffer[0] = self.get_handle_from_ui_point(self.hovered_point)
-
- elif self.main_buffer[0]:
- self.main_buffer[1] = self.get_line_handle_from_ui_point(local_event_pos)
-
- # Contrain point to line
- if self.main_buffer[1]:
- self.sketch.coincident(self.main_buffer[0], self.main_buffer[1], self.sketch.wp)
-
- if self.sketch.solve() == ResultFlag.OKAY:
- print("Fuck yeah")
- self.constrain_done.emit()
-
- elif self.sketch.solve() == ResultFlag.DIDNT_CONVERGE:
- print("Solve_failed - Converge")
-
- elif self.sketch.solve() == ResultFlag.TOO_MANY_UNKNOWNS:
- print("Solve_failed - Unknowns")
-
- elif self.sketch.solve() == ResultFlag.INCONSISTENT:
- print("Solve_failed - Incons")
-
- self.constrain_done.emit()
- # Clear saved_points after solve attempt
- self.main_buffer = [None, None]
-
- if event.button() == Qt.LeftButton and self.mouse_mode == "pb_con_mid":
- #print("ptline")
- line_selected = None
-
-
- if self.hovered_point and not self.main_buffer[1]:
- self.main_buffer[0] = self.get_handle_from_ui_point(self.hovered_point)
-
- elif self.main_buffer[0]:
- self.main_buffer[1] = self.get_line_handle_from_ui_point(local_event_pos)
-
- # Contrain point to line
- if self.main_buffer[1]:
- self.sketch.midpoint(self.main_buffer[0], self.main_buffer[1], self.sketch.wp)
-
- if self.sketch.solve() == ResultFlag.OKAY:
- print("Fuck yeah")
- for idx, line in enumerate(self.sketch.lines):
- # print(line.crd1.ui_point)
- if self.is_point_on_line(local_event_pos, line.crd1.ui_point, line.crd2.ui_point):
- self.sketch.lines[idx].constraints.append("mid")
- print(self.sketch.lines[idx].constraints)
-
-
- elif self.sketch.solve() == ResultFlag.DIDNT_CONVERGE:
- print("Solve_failed - Converge")
-
- elif self.sketch.solve() == ResultFlag.TOO_MANY_UNKNOWNS:
- print("Solve_failed - Unknowns")
-
- elif self.sketch.solve() == ResultFlag.INCONSISTENT:
- print("Solve_failed - Incons")
- self.constrain_done.emit()
-
- self.main_buffer = [None, None]
-
- if event.button() == Qt.LeftButton and self.mouse_mode == "horiz":
-
- line_selected = self.get_line_handle_from_ui_point(local_event_pos)
-
-
-
- if line_selected:
- self.sketch.horizontal(line_selected, self.sketch.wp)
-
- if self.sketch.solve() == ResultFlag.OKAY:
- print("Fuck yeah")
-
- # Add succesful constraint to constrain draw list so it gets drawn in paint function
- for idx, line in enumerate(self.sketch.lines):
- #print(line.crd1.ui_point)
- if self.is_point_on_line(local_event_pos, line.crd1.ui_point, line.crd2.ui_point):
- self.sketch.lines[idx].constraints.append("hrz")
- #print(self.sketch.lines[idx].constraints)
-
- elif self.sketch.solve() == ResultFlag.DIDNT_CONVERGE:
- print("Solve_failed - Converge")
-
- elif self.sketch.solve() == ResultFlag.TOO_MANY_UNKNOWNS:
- print("Solve_failed - Unknowns")
-
- elif self.sketch.solve() == ResultFlag.INCONSISTENT:
- print("Solve_failed - Incons")
-
- if event.button() == Qt.LeftButton and self.mouse_mode == "vert":
- line_selected = self.get_line_handle_from_ui_point(local_event_pos)
-
- if line_selected:
- self.sketch.vertical(line_selected, self.sketch.wp)
-
- if self.sketch.solve() == ResultFlag.OKAY:
- print("Fuck yeah")
- for idx, line in enumerate(self.sketch.lines):
- # print(line.crd1.ui_point)
- if self.is_point_on_line(local_event_pos, line.crd1.ui_point, line.crd2.ui_point):
- self.sketch.lines[idx].constraints.append("vrt")
- # print(self.sketch.lines[idx].constraints)
-
- elif self.sketch.solve() == ResultFlag.DIDNT_CONVERGE:
- print("Solve_failed - Converge")
-
- elif self.sketch.solve() == ResultFlag.TOO_MANY_UNKNOWNS:
- print("Solve_failed - Unknowns")
-
- elif self.sketch.solve() == ResultFlag.INCONSISTENT:
- print("Solve_failed - Incons")
-
- if event.button() == Qt.LeftButton and self.mouse_mode == "distance":
- # Depending on selected elemnts either point line or line distance
- #print("distance")
- e1 = None
- e2 = None
-
- if self.hovered_point:
- # print("buf point")
- # Get the point as UI point as buffer
- self.main_buffer[0] = self.hovered_point
-
- elif self.selected_line:
- # Get the point as UI point as buffer
- self.main_buffer[1] = local_event_pos
-
- if self.main_buffer[0] and self.main_buffer[1]:
- # Define point line combination
- e1 = self.get_handle_from_ui_point(self.main_buffer[0])
- e2 = self.get_line_handle_from_ui_point(self.main_buffer[1])
-
- elif not self.main_buffer[0]:
- # Define only line selection
- e1, e2 = self.get_point_line_handles_from_ui_point(local_event_pos)
-
- if e1 and e2:
- # Ask fo the dimension and solve if both elements are present
- length, ok = QInputDialog.getDouble(self, 'Distance', 'Enter a mm value:', value=100, decimals=2)
- self.sketch.distance(e1, e2, length, self.sketch.wp)
-
- if self.sketch.solve() == ResultFlag.OKAY:
- print("Fuck yeah")
- for idx, line in enumerate(self.sketch.lines):
- # print(line.crd1.ui_point)
- if self.is_point_on_line(local_event_pos, line.crd1.ui_point, line.crd2.ui_point):
- if "dist" not in self.sketch.lines[idx].constraints:
- self.sketch.lines[idx].constraints.append("dst")
-
- elif self.sketch.solve() == ResultFlag.DIDNT_CONVERGE:
- print("Solve_failed - Converge")
-
- elif self.sketch.solve() == ResultFlag.TOO_MANY_UNKNOWNS:
- print("Solve_failed - Unknowns")
-
- elif self.sketch.solve() == ResultFlag.INCONSISTENT:
- print("Solve_failed - Incons")
-
- self.constrain_done.emit()
- self.main_buffer = [None, None]
-
- # Update the main point list with the new elements and draw them
- points_need_update = self.check_all_points()
- self.update_ui_points(points_need_update)
- self.check_all_lines_and_update(points_need_update)
-
-
-
- self.update()
-
- def mouseMoveEvent(self, event):
- local_event_pos = self.viewport_to_local_coord(event.pos())
- #print(local_event_pos)
-
- closest_point = None
- min_distance = float('inf')
- threshold = 15 # Distance threshold for highlighting
-
- if self.mouse_mode == "line" and self.line_draw_buffer[0]:
- # Update the current cursor position as the second point
- self.dynamic_line_end = self.viewport_to_local_coord(event.pos())
- self.update() # Trigger a repaint
-
- if self.sketch.points is not None and len(self.sketch.points) > 0:
- for point in self.sketch.points:
- distance = (local_event_pos - point.ui_point).manhattanLength()
- if distance < threshold and distance < min_distance:
- closest_point = point.ui_point
- min_distance = distance
-
- """for point in self.sketch.proj_points:
- distance = (local_event_pos - point).manhattanLength()
- if distance < threshold and distance < min_distance:
- closest_point = point
- min_distance = distance"""
-
- if closest_point != self.hovered_point:
- self.hovered_point = closest_point
- #print(self.hovered_point)
-
- for line in self.sketch.lines:
- p1 = line.crd1.ui_point
- p2 = line.crd2.ui_point
-
- if self.is_point_on_line(local_event_pos, p1, p2):
- self.selected_line = p1, p2
-
- if self.snap_mode.get("mpoint"):
- # Midpointsnap only in drawer not solver
- mid = self.calculate_midpoint(p1, p2)
- distance = (local_event_pos - mid).manhattanLength()
- if distance < threshold and distance < min_distance:
- self.hovered_point = mid
- break
-
- break
- else:
- self.selected_line = None
-
- self.update()
-
- def mouseDoubleClickEvent(self, event):
- pass
-
- def drawBackgroundGrid(self, painter):
- """Draw a background grid."""
- grid_spacing = 50
- pen = QPen(QColor(200, 200, 200), 1, Qt.SolidLine)
- painter.setPen(pen)
-
- # Draw vertical grid lines
- for x in range(-self.width() // 2, self.width() // 2, grid_spacing):
- painter.drawLine(x, -self.height() // 2, x, self.height() // 2)
-
- # Draw horizontal grid lines
- for y in range(-self.height() // 2, self.height() // 2, grid_spacing):
- painter.drawLine(-self.width() // 2, y, self.width() // 2, y)
-
- def drawAxes(self, painter):
- painter.setRenderHint(QPainter.Antialiasing)
-
- # Set up pen for dashed lines
- pen = QPen(Qt.gray, 1, Qt.DashLine)
- painter.setPen(pen)
-
- middle_x = self.width() // 2
- middle_y = self.height() // 2
-
- # Draw X axis as dashed line
- painter.drawLine(0, middle_y, self.width(), middle_y)
-
- # Draw Y axis as dashed line
- painter.drawLine(middle_x, 0, middle_x, self.height())
-
- # Draw tick marks
- tick_length = int(10 * self.zoom)
- tick_spacing = int(50 * self.zoom)
-
- pen = QPen(Qt.gray, 1, Qt.SolidLine)
- painter.setPen(pen)
-
- # Draw tick marks on the X axis to the right and left from the middle point
- for x in range(0, self.width() // 2, tick_spacing):
- painter.drawLine(middle_x + x, middle_y - tick_length // 2, middle_x + x, middle_y + tick_length // 2)
- painter.drawLine(middle_x - x, middle_y - tick_length // 2, middle_x - x, middle_y + tick_length // 2)
-
- # Draw tick marks on the Y axis upwards and downwards from the middle point
- for y in range(0, self.height() // 2, tick_spacing):
- painter.drawLine(middle_x - tick_length // 2, middle_y + y, middle_x + tick_length // 2, middle_y + y)
- painter.drawLine(middle_x - tick_length // 2, middle_y - y, middle_x + tick_length // 2, middle_y - y)
-
- # Draw the origin point in red
- painter.setPen(QPen(Qt.red, 4))
- painter.drawPoint(middle_x, middle_y)
-
- def draw_cross(self, painter, pos: QPoint, size=10):
- # Set up the pen
- pen = QPen(QColor('green')) # You can change the color as needed
- pen.setWidth(int(2 / self.zoom)) # Set the line widt)h
- painter.setPen(pen)
- x = pos.x()
- y = pos.y()
-
- # Calculate the endpoints of the cross
- half_size = size // 2
-
- # Draw the horizontal line
- painter.drawLine(x - half_size, y, x + half_size, y)
-
- # Draw the vertical line
- painter.drawLine(x, y - half_size, x, y + half_size)
-
- def to_quadrant_coords(self, point):
- """Translate linear coordinates to quadrant coordinates."""
- center_x = self.width() // 2
- center_y = self.height() // 2
- quadrant_x = point.x() - center_x
- quadrant_y = center_y - point.y() # Note the change here
- return QPoint(quadrant_x, quadrant_y) / self.zoom
-
- def from_quadrant_coords(self, point: QPoint):
- """Translate quadrant coordinates to linear coordinates."""
- center_x = self.width() // 2
- center_y = self.height() // 2
- widget_x = center_x + point.x() * self.zoom
- widget_y = center_y - point.y() * self.zoom # Note the subtraction here
-
- return QPoint(int(widget_x), int(widget_y))
-
- def from_quadrant_coords_no_center(self, point):
- """Invert Y Coordinate for mesh"""
- center_x = 0
- center_y = 0
- widget_x = point.x()
- widget_y = -point.y()
- return QPoint(int(widget_x), int(widget_y))
-
- def draw_measurement(self,painter, start_point, end_point):
-
- pen_normal = QPen(Qt.gray)
- pen_normal.setWidthF(2 / self.zoom)
-
- pen_planned = QPen(Qt.gray)
- pen_planned.setStyle(Qt.PenStyle.DotLine)
- pen_planned.setWidthF(2 / self.zoom)
-
- pen_construct = QPen(Qt.cyan)
- pen_construct.setStyle(Qt.PenStyle.DotLine)
- pen_construct.setWidthF(1 / self.zoom)
-
- pen_solver = QPen(Qt.green)
- pen_solver.setWidthF(2 / self.zoom)
-
- pen_text = QPen(Qt.white)
- pen_text.setWidthF(1 / self.zoom)
-
- # Calculate the direction of the line
- dx = end_point.x() - start_point.x()
- dy = end_point.y() - start_point.y()
-
- # Swap and negate to get a perpendicular vector
- perp_dx = -dy
- perp_dy = dx
-
- # Normalize the perpendicular vector
- length = (perp_dx ** 2 + perp_dy ** 2) ** 0.5
- if length == 0: # Prevent division by zero
- return
- perp_dx /= length
- perp_dy /= length
-
- # Fixed length for the perpendicular lines
- fixed_length = 40 # Adjust as needed
- half_length = fixed_length # fixed_length / 2
-
- # Calculate endpoints for the perpendicular line at the start
- start_perp_start_x = start_point.x() + perp_dx
- start_perp_start_y = start_point.y() + perp_dy
-
- start_perp_end_x = start_point.x() + perp_dx * half_length * (1 / self.zoom)
- start_perp_end_y = start_point.y() + perp_dy * half_length * (1 / self.zoom)
-
- start_perp_end_x_conn_line = start_point.x() + perp_dx * half_length * 0.75 * (1 / self.zoom)
- start_perp_end_y_conn_line = start_point.y() + perp_dy * half_length * 0.75 * (1 / self.zoom)
-
- # Draw the perpendicular line at the start
- painter.setPen(pen_construct) # Different color for the perpendicular line
- painter.drawLine(
- QPointF(start_perp_start_x, start_perp_start_y),
- QPointF(start_perp_end_x, start_perp_end_y)
- )
-
- # Calculate endpoints for the perpendicular line at the end
- end_perp_start_x = end_point.x() + perp_dx
- end_perp_start_y = end_point.y() + perp_dy
-
- end_perp_end_x = end_point.x() + perp_dx * half_length * (1 / self.zoom)
- end_perp_end_y = end_point.y() + perp_dy * half_length * (1 / self.zoom)
-
- end_perp_end_x_conn_line = end_point.x() + perp_dx * half_length * .75 * (1 / self.zoom)
- end_perp_end_y_conn_line = end_point.y() + perp_dy * half_length * .75 * (1 / self.zoom)
-
- # Draw the perpendicular line at the end
- painter.drawLine(
- QPointF(end_perp_start_x, end_perp_start_y),
- QPointF(end_perp_end_x, end_perp_end_y)
- )
-
- painter.drawLine(
- QPointF(end_perp_end_x_conn_line, end_perp_end_y_conn_line),
- QPointF(start_perp_end_x_conn_line, start_perp_end_y_conn_line)
- )
-
- # Save painter state
- painter.save()
- painter.setPen(pen_text)
-
- # Calculate the distance and midpoint
- dis = self.distance(start_point, end_point)
- mid = self.calculate_midpoint(QPointF(start_perp_end_x_conn_line, start_perp_end_y_conn_line),
- QPointF(end_perp_end_x_conn_line, end_perp_end_y_conn_line))
-
- # mid = self.calculate_midpoint(start_point, end_point)
-
- # Transform for text
- painter.translate(mid.x(), mid.y()) # Move to the midpoint
- painter.scale(1, -1) # Flip y-axis back to make text readable
-
- # Draw the text
- painter.drawText(0, 0, str(round(dis, 2))) # Draw text at transformed position
-
- # Restore painter state
- painter.restore()
-
- def paintEvent(self, event):
- painter = QPainter(self)
- painter.setRenderHint(QPainter.Antialiasing)
-
- self.drawAxes(painter)
-
- # Create a QTransform object
- transform = QTransform()
-
- # Translate the origin to the center of the widget
- center = QPointF(self.width() / 2, self.height() / 2)
- transform.translate(center.x(), center.y())
-
- # Apply the zoom factor
- transform.scale(self.zoom, -self.zoom) # Negative y-scale to invert y-axis
-
- # Set the transform to the painter
- painter.setTransform(transform)
-
- pen_normal = QPen(Qt.gray)
- pen_normal.setWidthF(2 / self.zoom)
-
- pen_planned = QPen(Qt.gray)
- pen_planned.setStyle(Qt.PenStyle.DotLine)
- pen_planned.setWidthF(2 / self.zoom)
-
- pen_construct = QPen(Qt.green)
- pen_construct.setStyle(Qt.PenStyle.DotLine)
- pen_construct.setWidthF(1 / self.zoom)
-
- pen_solver = QPen(Qt.green)
- pen_solver.setWidthF(2 / self.zoom)
-
- pen_text = QPen(Qt.white)
- pen_text.setWidthF(1 / self.zoom)
-
- # Draw points and lines
- if self.sketch:
- painter.setPen(pen_normal)
- for point in self.sketch.points:
- if point.is_helper:
- painter.setPen(pen_construct)
- painter.drawEllipse(point.ui_point, 10 / self.zoom, 10 / self.zoom)
- else:
- # Normal point
- painter.setPen(pen_normal)
- painter.drawEllipse(point.ui_point, 3 / self.zoom, 3 / self.zoom)
-
- # Draw the dynamic line
- if self.mouse_mode == "line" and self.line_draw_buffer[0] and self.dynamic_line_end is not None:
- start_point = self.line_draw_buffer[0].ui_point
- end_point = self.dynamic_line_end
- painter.setPen(pen_planned) # Use a different color for the dynamic line
- painter.drawLine(start_point, end_point)
-
- self.draw_measurement(painter, start_point, end_point)
-
- for line in self.sketch.lines:
- if line.is_helper:
- painter.setPen(pen_construct)
- p1 = line.crd1.ui_point
- p2 = line.crd2.ui_point
- painter.drawLine(p1, p2)
- else:
- painter.setPen(pen_normal)
- p1 = line.crd1.ui_point
- p2 = line.crd2.ui_point
- painter.drawLine(p1, p2)
-
- if not self.selected_line:
-
- painter.save()
- midp = self.calculate_midpoint(p1, p2)
- painter.translate(midp)
- painter.scale(1, -1)
-
- for i, text in enumerate(line.constraints):
- painter.drawText(0, i * 15, f"> {text} <")
- painter.restore()
-
- # Draw all solver points
- if self.sketch.entity_len():
- painter.setPen(pen_solver)
- for i in range(self.sketch.entity_len()):
- entity = self.sketch.entity(i)
- if entity.is_point_2d() and self.sketch.params(entity.params):
- x, y = self.sketch.params(entity.params)
- point = QPointF(x, y)
- painter.drawEllipse(point, 6 / self.zoom, 6 / self.zoom)
-
- # Highlight point hovered
- if self.hovered_point:
- highlight_pen = QPen(QColor(255, 0, 0))
- highlight_pen.setWidthF(2 / self.zoom)
- painter.setPen(highlight_pen)
- painter.drawEllipse(self.hovered_point, 5 / self.zoom, 5 / self.zoom)
-
- # Highlight line hovered
- if self.selected_line and not self.hovered_point:
- p1, p2 = self.selected_line
- painter.setPen(QPen(Qt.red, 2 / self.zoom))
- painter.drawLine(p1, p2)
-
- self.draw_measurement(painter, p1, p2)
-
-
- """for cross in self.sketch.proj_points:
- self.draw_cross(painter, cross, 10 / self.zoom)
-
- for selected in self.sketch.proj_lines:
- pen = QPen(Qt.white, 1, Qt.DashLine)
- painter.setPen(pen)
- painter.drawLine(selected)"""
-
- painter.end()
-
- def wheelEvent(self, event):
- delta = event.angleDelta().y()
- self.zoom += (delta / 200) * 0.1
- self.update()
-
- def aspect_ratio(self):
- return self.width() / self.height() * (1.0 / abs(self.zoom))
-
-
-### GEOMETRY CLASSES
-class Point2D:
- def __init__(self, x, y):
- self.id = None
- self.ui_x: int = x
- self.ui_y: int = y
- self.ui_point = QPoint(self.ui_x, self.ui_y)
- self.handle = None
- self.handle_nr: int = None
-
- # Construction Geometry
- self.is_helper: bool = False
-
-class Line2D:
- def __init__(self, point_s: Point2D, point_e: Point2D):
- self.id = None
-
- self.crd1: Point2D = point_s
- self.crd2: Point2D = point_e
- self.handle = None
- self.handle_nr: int = None
-
- # Construction Geometry
- self.is_helper: bool = False
- # String list with applied constraints
- self.constraints: list = []
-
-
-class Sketch2d(SolverSystem):
- """
- Primary class for internal drawing based on the SolveSpace libray
- """
-
- def __init__(self):
- self.id = uuid.uuid1()
-
- self.wp = self.create_2d_base()
- self.points = []
- self.lines = []
- self.origin = [0,0,0]
-
- def add_point(self, point: Point2D):
- """
- Adds a point into the solversystem and returns the handle.
- Appends the added point to the points list.
- :param point: 2D point in Point2D class format
- :return:
- """
-
- point.handle = self.add_point_2d(point.ui_x, point.ui_y, self.wp)
- point.handle_nr = self.get_handle_nr(str(point.handle))
- point.id = uuid.uuid1()
-
- self.points.append(point)
-
- def add_line(self, line: Line2D):
- """
- Adds a line into the solversystem and returns the handle.
- Appends the added line to the line list.
- :param line:
- :param point: 2D point in Point2D class format
- :return:
- """
-
- line.id = uuid.uuid1()
-
- line.handle = self.add_line_2d(line.crd1.handle, line.crd2.handle, self.wp)
- line.handle_nr = self.get_handle_nr(str(line.handle))
-
- self.lines.append(line)
-
- ### HELPER AND TOOLS
- def get_handle_nr(self, input_str: str) -> int:
- # Define the regex pattern to extract the handle number
- pattern = r"handle=(\d+)"
-
- # Use re.search to find the handle number in the string
- match = re.search(pattern, input_str)
-
- if match:
- handle_number = int(match.group(1))
- print(f"Handle number: {handle_number}")
- return int(handle_number)
-
- else:
- print("Handle number not found.")
- return 0
-
-if __name__ == "__main__":
- import sys
-
- app = QApplication(sys.argv)
- window = SketchWidget()
- window.setWindowTitle("Snap Line Widget")
- window.resize(800, 600)
- window.show()
- sys.exit(app.exec())
diff --git a/drawing_modules/gl_widget.py b/drawing_modules/gl_widget.py
deleted file mode 100644
index 6bfe56e..0000000
--- a/drawing_modules/gl_widget.py
+++ /dev/null
@@ -1,504 +0,0 @@
-import sys
-import numpy as np
-from PySide6.QtWidgets import QApplication, QMainWindow, QVBoxLayout, QWidget
-from PySide6.QtOpenGLWidgets import QOpenGLWidget
-from PySide6.QtCore import Qt, QPoint
-from OpenGL.GL import *
-from OpenGL.GLU import *
-
-##testing
-
-def create_cube(scale=1):
- vertices = np.array([
- [0, 0, 0],
- [2, 0, 0],
- [2, 2, 0],
- [0, 2, 0],
- [0, 0, 2],
- [2, 0, 2],
- [2, 2, 2],
- [0, 2, 2]
- ]) * scale
-
- faces = np.array([
- [0, 1, 2],
- [2, 3, 0],
- [4, 5, 6],
- [6, 7, 4],
- [0, 1, 5],
- [5, 4, 0],
- [2, 3, 7],
- [7, 6, 2],
- [0, 3, 7],
- [7, 4, 0],
- [1, 2, 6],
- [6, 5, 1]
- ])
-
- return vertices, faces
-
-
-class MainWindow(QMainWindow):
- def __init__(self):
- super().__init__()
- self.setWindowTitle("OpenGL Cube Viewer")
- self.setGeometry(100, 100, 800, 600)
-
- self.opengl_widget = OpenGLWidget()
-
- central_widget = QWidget()
- layout = QVBoxLayout()
- layout.addWidget(self.opengl_widget)
- central_widget.setLayout(layout)
- self.setCentralWidget(central_widget)
-
- # Load cube data
- vertices, faces = create_cube()
- self.opengl_widget.load_interactor_mesh((vertices, faces))
-
-
-class OpenGLWidget(QOpenGLWidget):
- def __init__(self, parent=None):
- super().__init__(parent)
- self.vertices = None
- self.faces = None
- self.selected_face = -1
- self.scale_factor = 1
- self.mesh_loaded = None
- self.interactor_loaded = None
- self.centroid = None
- self.stl_file = "out.stl" # Replace with your STL file path
- self.lastPos = QPoint()
- self.startPos = None
- self.endPos = None
- self.xRot = 180
- self.yRot = 0
- self.zoom = -2
- self.sketch = []
- self.gl_width = self.width()
- self.gl_height = self.height()
-
- def map_value_to_range(self, value, value_min=0, value_max=1920, range_min=-1, range_max=1):
- value = max(value_min, min(value_max, value))
- mapped_value = ((value - value_min) / (value_max - value_min)) * (range_max - range_min) + range_min
-
- return mapped_value
-
- def load_stl(self, filename: str) -> object:
- try:
- stl_mesh = mesh.Mesh.from_file(filename)
-
- # Extract vertices
- vertices = np.concatenate([stl_mesh.v0, stl_mesh.v1, stl_mesh.v2])
-
- # Calculate bounding box
- min_x, min_y, min_z = vertices.min(axis=0)
- max_x, max_y, max_z = vertices.max(axis=0)
-
- # Calculate centroid
- centroid_x = (min_x + max_x) / 2.0
- centroid_y = (min_y + max_y) / 2.0
- centroid_z = (min_z + max_z) / 2.0
-
- self.mesh_loaded = stl_mesh.vectors
- self.centroid = (centroid_x, centroid_y, centroid_z)
-
- except FileNotFoundError:
- print(f"Error: File {filename} not found.")
- except Exception as e:
- print(f"Error loading {filename}: {e}")
-
- return None, (0, 0, 0)
-
- def load_interactor_mesh(self, simp_mesh):
- self.interactor_loaded = simp_mesh
- # Calculate centroid based on the average position of vertices
- centroid = np.mean(simp_mesh[0], axis=0)
-
- self.centroid = tuple(centroid)
- print(f"Centroid: {self.centroid}")
-
- self.update()
-
- def load_mesh_direct(self, mesh):
- try:
- stl_mesh = mesh
-
- # Extract vertices
- vertices = np.array(stl_mesh)
-
- # Calculate centroid based on the average position of vertices
- centroid = np.mean(vertices, axis=0)
-
- self.mesh_loaded = vertices
- self.centroid = tuple(centroid)
- print(f"Centroid: {self.centroid}")
- self.update()
- except Exception as e:
- print(e)
-
- def clear_mesh(self):
- self.mesh_loaded = None
-
- def initializeGL(self):
- glClearColor(0, 0, 0, 1)
- glEnable(GL_DEPTH_TEST)
-
- def resizeGL(self, width, height):
- glViewport(0, 0, width, height)
- glMatrixMode(GL_PROJECTION)
- glLoadIdentity()
-
- aspect = width / float(height)
-
- self.gl_width = self.width()
- self.gl_height = self.height()
-
- gluPerspective(45.0, aspect, 0.01, 1000.0)
- glMatrixMode(GL_MODELVIEW)
-
- def unproject(self, x, y, z, modelview, projection, viewport):
- mvp = np.dot(projection, modelview)
- mvp_inv = np.linalg.inv(mvp)
-
- ndc = np.array([(x - viewport[0]) / viewport[2] * 2 - 1,
- (y - viewport[1]) / viewport[3] * 2 - 1,
- 2 * z - 1,
- 1])
-
- world = np.dot(mvp_inv, ndc)
- print("world undproj", world)
- return world[:3] / world[3]
-
- def draw_ray(self, ray_start, ray_end):
- glColor3f(1.0, 0.0, 0.0) # Set the color of the ray (red)
- glBegin(GL_LINES)
- glVertex3f(*ray_start)
- glVertex3f(*ray_end)
- glEnd()
-
- def mousePressEvent(self, event):
- if event.buttons() & Qt.RightButton:
- self.select_face(event)
-
- def select_face(self, event):
- x = event.position().x()
- y = event.position().y()
-
- modelview = glGetDoublev(GL_MODELVIEW_MATRIX)
- projection = glGetDoublev(GL_PROJECTION_MATRIX)
- viewport = glGetIntegerv(GL_VIEWPORT)
-
- # Unproject near and far points in world space
- ray_start = gluUnProject(x, y, 0.0, modelview, projection, viewport)
- ray_end = gluUnProject(x, y, 1.0, modelview, projection, viewport)
-
- ray_start = np.array(ray_start)
- ray_end = np.array(ray_end)
- ray_direction = ray_end - ray_start
- ray_direction /= np.linalg.norm(ray_direction)
-
- print(f"Ray start: {ray_start}")
- print(f"Ray end: {ray_end}")
- print(f"Ray direction: {ray_direction}")
-
- self.selected_face = self.check_intersection(ray_start, ray_end)
- print(f"Selected face: {self.selected_face}")
-
- self.update()
-
- def ray_box_intersection(self, ray_origin, ray_direction, box_min, box_max):
- inv_direction = 1 / (ray_direction + 1e-7) # Add small value to avoid division by zero
- t1 = (box_min - ray_origin) * inv_direction
- t2 = (box_max - ray_origin) * inv_direction
-
- t_min = np.max(np.minimum(t1, t2))
- t_max = np.min(np.maximum(t1, t2))
-
- print(f"min: {t_min}, max: {t_max}" )
-
- return t_max >= t_min and t_max > 0
-
- def check_intersection(self, ray_start, ray_end):
- # Get the current modelview matrix
- modelview = glGetDoublev(GL_MODELVIEW_MATRIX)
-
- # Transform vertices to camera space
- vertices_cam = [np.dot(modelview, np.append(v, 1))[:3] for v in self.interactor_loaded[0]]
-
- ray_direction = ray_end - ray_start
- ray_direction /= np.linalg.norm(ray_direction)
-
- print(f"Checking intersection with {len(self.interactor_loaded[1])} faces")
- for face_idx, face in enumerate(self.interactor_loaded[1]):
- v0, v1, v2 = [vertices_cam[i] for i in face]
- intersection = self.moller_trumbore(ray_start, ray_direction, v0, v1, v2)
- if intersection is not None:
- print(f"Intersection found with face {face_idx}")
- return face_idx
-
- print("No intersection found")
- return None
-
- def moller_trumbore(self, ray_origin, ray_direction, v0, v1, v2):
- epsilon = 1e-6
- # Find vectors for two edges sharing v0
- edge1 = v1 - v0
- edge2 = v2 - v0
- pvec = np.cross(ray_direction, edge2)
-
- det = np.dot(edge1, pvec)
- print(det)
-
- """if det < epsilon:
- return None"""
-
- inv_det = 1.0 / det
- tvec = ray_origin - v0
- u = np.dot(tvec, pvec) * inv_det
-
- print("u", u )
-
- if u < 0.0 or u > 1.0:
- return None
-
- qvec = np.cross(tvec, edge1)
-
- # Calculate v parameter and test bounds
- v = np.dot(ray_direction, qvec) * inv_det
- print("v", v)
-
- if v < 0.0 or u + v > 1.0:
- return None
-
- # Calculate t, ray intersects triangle
- t = np.dot(edge2, qvec) * inv_det
- print("t",t)
-
- if t > epsilon:
- return ray_origin + t * ray_direction
-
- return None
-
- def ray_triangle_intersection(self, ray_origin, ray_direction, v0, v1, v2):
- epsilon = 1e-5
- edge1 = v1 - v0
- edge2 = v2 - v0
- h = np.cross(ray_direction, edge2)
- a = np.dot(edge1, h)
-
- print(f"Triangle vertices: {v0}, {v1}, {v2}")
- print(f"a: {a}")
-
- if abs(a) < epsilon:
- print("Ray is parallel to the triangle")
- return None # Ray is parallel to the triangle
-
- f = 1.0 / a
- s = ray_origin - v0
- u = f * np.dot(s, h)
-
- print(f"u: {u}")
-
- if u < 0.0 or u > 1.0:
- print("u is out of range")
- return None
-
- q = np.cross(s, edge1)
- v = f * np.dot(ray_direction, q)
-
- print(f"v: {v}")
-
- if v < 0.0 or u + v > 1.0:
- print("v is out of range")
- return None
-
- t = f * np.dot(edge2, q)
-
- print(f"t: {t}")
-
- if t > epsilon:
- intersection_point = ray_origin + t * ray_direction
- print(f"Intersection point: {intersection_point}")
- return intersection_point
-
- print("t is too small")
- return None
- def paintGL(self):
- glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT)
- glMatrixMode(GL_MODELVIEW)
- glLoadIdentity()
-
- # Apply camera transformation
- glTranslatef(0, 0, self.zoom)
- glRotatef(self.xRot, 1.0, 0.0, 0.0)
- glRotatef(self.yRot, 0.0, 1.0, 0.0)
-
- """# Apply model transformation
- glTranslatef(self.tx, self.ty, self.tz)
- glScalef(self.scale, self.scale, self.scale)
- glRotatef(self.model_xRot, 1.0, 0.0, 0.0)
- glRotatef(self.model_yRot, 0.0, 1.0, 0.0)
- glRotatef(self.model_zRot, 0.0, 0.0, 1.0)"""
-
- glColor3f(0.9, 0.8, 0.8)
- self.draw_area()
-
- if self.mesh_loaded is not None:
- # Adjust the camera for the STL mesh
- if self.centroid:
- glPushMatrix() # Save current transformation matrix
- glScalef(self.scale_factor, self.scale_factor, self.scale_factor) # Apply scaling
-
- cx, cy, cz = self.centroid
- gluLookAt(cx, cy, cz + 100, cx, cy, cz, 0, 1, 0)
-
- self.draw_mesh_direct(self.mesh_loaded)
- glPopMatrix() # Restore transformation matrix
-
- if self.interactor_loaded is not None:
- # Draw interactor mesh
- glPushMatrix() # Save current transformation matrix
- glScalef(self.scale_factor, self.scale_factor, self.scale_factor) # Apply scaling
-
- self.draw_interactor(self.interactor_loaded)
- glPopMatrix() # Restore transformation matrix
-
- if self.selected_face is not None:
- glColor3f(0.0, 1.0, 0.0) # Red color for selected face
- glBegin(GL_TRIANGLES)
- for vertex_idx in self.interactor_loaded[1][self.selected_face]:
- glVertex3fv(self.interactor_loaded[0][vertex_idx])
- glEnd()
-
- # Flush the OpenGL pipeline and swap buffers
-
-
- if hasattr(self, 'ray_start') and hasattr(self, 'ray_end'):
- self.draw_ray(self.ray_start, self.ray_end)
-
- glFlush()
-
- def draw_stl(self, vertices):
- glEnable(GL_LIGHTING)
- glEnable(GL_LIGHT0)
- glEnable(GL_DEPTH_TEST)
- glEnable(GL_COLOR_MATERIAL)
- glColorMaterial(GL_FRONT_AND_BACK, GL_AMBIENT_AND_DIFFUSE)
-
- glLightfv(GL_LIGHT0, GL_POSITION, (0, 1, 1, 0))
- glLightfv(GL_LIGHT0, GL_DIFFUSE, (0.6, 0.6, 0.6, 1.0))
-
- glBegin(GL_TRIANGLES)
- for triangle in vertices:
- for vertex in triangle:
- glVertex3fv(vertex)
- glEnd()
- self.update()
-
- def draw_interactor(self, simp_mesh: tuple):
- vertices, faces = simp_mesh
-
- glEnable(GL_LIGHTING)
- glEnable(GL_LIGHT0)
- glEnable(GL_DEPTH_TEST)
- glEnable(GL_COLOR_MATERIAL)
- glColorMaterial(GL_FRONT_AND_BACK, GL_AMBIENT_AND_DIFFUSE)
-
- glLightfv(GL_LIGHT0, GL_POSITION, (0, 0.6, 0.6, 0))
- glLightfv(GL_LIGHT0, GL_DIFFUSE, (0.4, 0.4, 0.4, 0.6))
-
- # Draw the faces
- glDisable(GL_LIGHTING)
- glColor3f(0.2, 0.0, 0.0) # Set face color to red (or any color you prefer)
-
- glBegin(GL_TRIANGLES)
- for face in faces:
- for vertex_index in face:
- glVertex3fv(vertices[vertex_index])
- glEnd()
-
- # Draw the lines (edges of the triangles)
- glColor3f(0.0, 1.0, 0.0) # Set line color to green (or any color you prefer)
-
- glBegin(GL_LINES)
- for face in faces:
- for i in range(len(face)):
- glVertex3fv(vertices[face[i]])
- glVertex3fv(vertices[face[(i + 1) % len(face)]])
- glEnd()
-
- glEnable(GL_LIGHTING) # Re-enable lighting if further drawing requires it
-
- def draw_mesh_direct(self, points):
- glEnable(GL_LIGHTING)
- glEnable(GL_LIGHT0)
- glEnable(GL_DEPTH_TEST)
- glEnable(GL_COLOR_MATERIAL)
- glColorMaterial(GL_FRONT_AND_BACK, GL_AMBIENT_AND_DIFFUSE)
-
- glLightfv(GL_LIGHT0, GL_POSITION, (0, 0.6, 0.6, 0))
- glLightfv(GL_LIGHT0, GL_DIFFUSE, (0.4, 0.4, 0.4, 0.6))
-
- glDisable(GL_LIGHTING)
- glBegin(GL_TRIANGLES)
- for vertex in points:
- glVertex3fv(vertex)
- glEnd()
-
- # Draw the lines (edges of the triangles)
- #glDisable(GL_LIGHTING) # Disable lighting to avoid affecting the line color
- glColor3f(0.0, 0.0, 0.0) # Set line color to black (or any color you prefer)
-
- glBegin(GL_LINES)
- for i in range(0, len(points), 3):
- glVertex3fv(points[i])
- glVertex3fv(points[i + 1])
-
- glVertex3fv(points[i + 1])
- glVertex3fv(points[i + 2])
-
- glVertex3fv(points[i + 2])
- glVertex3fv(points[i])
- glEnd()
-
- glEnable(GL_LIGHTING) # Re-enable lighting if further drawing requires it
-
- def draw_area(self):
- glColor3f(0.5, 0.5, 0.5) # Gray color
-
- glBegin(GL_LINES)
- for x in range(0, self.width(), 1):
- x_ndc = self.map_value_to_range(x, 0, value_max=self.width(), range_min=-self.gl_width, range_max=self.gl_width)
- glVertex2f(x_ndc, -self.gl_height) # Start from y = -1
- glVertex2f(x_ndc, self.gl_height) # End at y = 1
-
- for y in range(0, self.height(), 1):
- y_ndc = self.map_value_to_range(y, 0, value_max=self.height(), range_min=-self.gl_height, range_max=self.gl_height)
- glVertex2f(-self.gl_width, y_ndc) # Start from x = -1
- glVertex2f(self.gl_width, y_ndc) # End at x = 1
- glEnd()
-
- def mouseMoveEvent(self, event):
- dx = event.x() - self.lastPos.x()
- dy = event.y() - self.lastPos.y()
-
- if event.buttons() & Qt.MouseButton.LeftButton :
- self.xRot += 0.5 * dy
- self.yRot += 0.5 * dx
- self.lastPos = event.pos()
- self.update()
-
- def wheelEvent(self, event):
- delta = event.angleDelta().y()
- self.zoom += delta / 200
- self.update()
-
- def aspect_ratio(self):
- return self.width() / self.height() * (1.0 / abs(self.zoom))
-
-if __name__ == "__main__":
- app = QApplication(sys.argv)
- window = MainWindow()
- window.show()
- sys.exit(app.exec())
\ No newline at end of file
diff --git a/drawing_modules/improved_sketcher.py b/drawing_modules/improved_sketcher.py
deleted file mode 100644
index 6934f48..0000000
--- a/drawing_modules/improved_sketcher.py
+++ /dev/null
@@ -1,1702 +0,0 @@
-"""
-Improved Sketcher Widget for Fluency CAD
-Provides parametric 2D sketching with constraint solving integration
-"""
-
-import math
-import re
-import uuid
-import logging
-from copy import copy
-from enum import Enum, auto
-from dataclasses import dataclass
-from typing import Optional, List, Tuple, Dict, Any
-
-import numpy as np
-from PySide6.QtWidgets import QApplication, QWidget, QMessageBox, QInputDialog
-from PySide6.QtGui import QPainter, QPen, QColor, QTransform, QFont
-from PySide6.QtCore import Qt, QPoint, QPointF, Signal, QLine, QRect
-from python_solvespace import SolverSystem, ResultFlag
-
-
-# Configure logging
-logging.basicConfig(level=logging.INFO)
-logger = logging.getLogger(__name__)
-
-
-class SketchMode(Enum):
- """Enumeration of available sketching modes"""
- NONE = auto()
- LINE = auto()
- RECTANGLE = auto()
- CIRCLE = auto()
- ARC = auto()
- POINT = auto()
-
- # Constraint modes
- COINCIDENT_PT_PT = auto()
- COINCIDENT_PT_LINE = auto()
- HORIZONTAL = auto()
- VERTICAL = auto()
- DISTANCE = auto()
- MIDPOINT = auto()
- PARALLEL = auto()
- PERPENDICULAR = auto()
- TANGENT = auto()
-
-
-class SnapMode(Enum):
- """Enumeration of snapping modes"""
- POINT = auto()
- MIDPOINT = auto()
- HORIZONTAL = auto()
- VERTICAL = auto()
- GRID = auto()
- ANGLE = auto()
- INTERSECTION = auto()
-
-
-@dataclass
-class SnapSettings:
- """Configuration for snapping behavior"""
- snap_distance: float = 20.0
- angle_increment: float = 15.0
- grid_spacing: float = 50.0
-
- def __post_init__(self):
- if not hasattr(self, 'enabled_modes'):
- self.enabled_modes = {SnapMode.POINT, SnapMode.MIDPOINT}
-
-
-@dataclass
-class RenderSettings:
- """Configuration for rendering appearance"""
- normal_pen_width: float = 2.0
- construction_pen_width: float = 1.0
- highlight_pen_width: float = 3.0
-
- def __post_init__(self):
- if not hasattr(self, 'normal_color'):
- self.normal_color = QColor(128, 128, 128)
- if not hasattr(self, 'construction_color'):
- self.construction_color = QColor(0, 255, 0) # Green
- if not hasattr(self, 'highlight_color'):
- self.highlight_color = QColor(255, 0, 0) # Red
- if not hasattr(self, 'solver_color'):
- self.solver_color = QColor(0, 255, 0) # Green
- if not hasattr(self, 'dynamic_color'):
- self.dynamic_color = QColor(128, 128, 128)
- if not hasattr(self, 'text_color'):
- self.text_color = QColor(255, 255, 255) # White
-
-
-class GeometryError(Exception):
- """Custom exception for geometry-related errors"""
- pass
-
-
-class SolverError(Exception):
- """Custom exception for solver-related errors"""
- pass
-
-
-class Point2D:
- """Enhanced 2D point class with validation and better integration"""
-
- def __init__(self, x: float, y: float, is_construction: bool = False):
- self.id = uuid.uuid4()
- self.x = float(x)
- self.y = float(y)
- self.ui_point = QPoint(int(x), int(y))
- self.handle = None
- self.handle_nr: Optional[int] = None
- self.is_helper = is_construction
- self._validate()
-
- def _validate(self):
- """Validate point coordinates"""
- if not (-1e6 <= self.x <= 1e6) or not (-1e6 <= self.y <= 1e6):
- raise GeometryError(f"Point coordinates out of range: ({self.x}, {self.y})")
-
- def update_ui_point(self):
- """Update the Qt point from internal coordinates"""
- self.ui_point = QPoint(int(self.x), int(self.y))
-
- def distance_to(self, other: 'Point2D') -> float:
- """Calculate distance to another point"""
- return math.sqrt((self.x - other.x)**2 + (self.y - other.y)**2)
-
- def __eq__(self, other):
- if not isinstance(other, Point2D):
- return False
- return abs(self.x - other.x) < 1e-6 and abs(self.y - other.y) < 1e-6
-
- def __repr__(self):
- return f"Point2D({self.x:.2f}, {self.y:.2f}, helper={self.is_helper})"
-
-
-class Line2D:
- """Enhanced 2D line class with validation and constraint tracking"""
-
- def __init__(self, start_point: Point2D, end_point: Point2D, is_construction: bool = False):
- self.id = uuid.uuid4()
- self.start = start_point
- self.end = end_point
- self.handle = None
- self.handle_nr: Optional[int] = None
- self.is_helper = is_construction
- self.constraints: List[str] = []
- self._validate()
-
- def _validate(self):
- """Validate line geometry"""
- if self.start == self.end:
- raise GeometryError("Line start and end points cannot be identical")
-
- @property
- def length(self) -> float:
- """Calculate line length"""
- return self.start.distance_to(self.end)
-
- @property
- def midpoint(self) -> Point2D:
- """Calculate line midpoint"""
- mid_x = (self.start.x + self.end.x) / 2
- mid_y = (self.start.y + self.end.y) / 2
- return Point2D(mid_x, mid_y)
-
- @property
- def angle(self) -> float:
- """Calculate line angle in radians"""
- dx = self.end.x - self.start.x
- dy = self.end.y - self.start.y
- return math.atan2(dy, dx)
-
- def is_point_on_line(self, point: Point2D, tolerance: float = 5.0) -> bool:
- """Check if a point lies on this line within tolerance"""
- # Vector from start to point
- ap_x = point.x - self.start.x
- ap_y = point.y - self.start.y
-
- # Vector from start to end
- ab_x = self.end.x - self.start.x
- ab_y = self.end.y - self.start.y
-
- # Length squared of line
- ab_length_sq = ab_x**2 + ab_y**2
- if ab_length_sq == 0:
- return False
-
- # Project point onto line
- t = (ap_x * ab_x + ap_y * ab_y) / ab_length_sq
- t = max(0, min(1, t)) # Clamp to line segment
-
- # Find closest point on line
- closest_x = self.start.x + t * ab_x
- closest_y = self.start.y + t * ab_y
-
- # Check distance
- distance = math.sqrt((point.x - closest_x)**2 + (point.y - closest_y)**2)
- return distance <= tolerance
-
- def __repr__(self):
- return f"Line2D({self.start} -> {self.end}, helper={self.is_helper})"
-
-
-class Circle2D:
- """2D circle class for enhanced sketching"""
-
- def __init__(self, center: Point2D, radius: float, is_construction: bool = False):
- self.id = uuid.uuid4()
- self.center = center
- self.radius = float(radius)
- self.handle = None
- self.handle_nr: Optional[int] = None
- self.is_helper = is_construction
- self.constraints: List[str] = []
- self._validate()
-
- def _validate(self):
- """Validate circle geometry"""
- if self.radius <= 0:
- raise GeometryError(f"Circle radius must be positive: {self.radius}")
-
- def is_point_on_circle(self, point: Point2D, tolerance: float = 5.0) -> bool:
- """Check if a point lies on the circle within tolerance"""
- distance = self.center.distance_to(point)
- return abs(distance - self.radius) <= tolerance
-
-
-class ImprovedSketch(SolverSystem):
- """Enhanced sketch class with better solver integration and error handling"""
-
- def __init__(self):
- super().__init__()
- self.id = uuid.uuid4()
- self.wp = self.create_2d_base()
-
- # Geometry containers
- self.points: List[Point2D] = []
- self.lines: List[Line2D] = []
- self.circles: List[Circle2D] = []
-
- # Metadata
- self.origin = [0, 0, 0]
- self.name = "Untitled Sketch"
- self._handle_counter = 0
-
- def add_point(self, point: Point2D) -> Point2D:
- """Add a point to the sketch and solver system"""
- try:
- point.handle = self.add_point_2d(point.x, point.y, self.wp)
- point.handle_nr = self._extract_handle_number(str(point.handle))
- self.points.append(point)
- logger.debug(f"Added point: {point}")
- return point
- except Exception as e:
- raise SolverError(f"Failed to add point to solver: {e}")
-
- def add_line(self, line: Line2D) -> Line2D:
- """Add a line to the sketch and solver system"""
- try:
- # Ensure both points are in the system
- if line.start not in self.points:
- self.add_point(line.start)
- if line.end not in self.points:
- self.add_point(line.end)
-
- line.handle = self.add_line_2d(line.start.handle, line.end.handle, self.wp)
- line.handle_nr = self._extract_handle_number(str(line.handle))
- self.lines.append(line)
- logger.debug(f"Added line: {line}")
- return line
- except Exception as e:
- raise SolverError(f"Failed to add line to solver: {e}")
-
- def add_circle(self, circle: Circle2D) -> Circle2D:
- """Add a circle to the sketch and solver system"""
- try:
- # Ensure center point is in the system
- if circle.center not in self.points:
- self.add_point(circle.center)
-
- # Add circle to solver (using distance constraint for radius)
- radius_point = Point2D(circle.center.x + circle.radius, circle.center.y)
- self.add_point(radius_point)
-
- # Create distance constraint for radius
- circle.handle = self.distance(circle.center.handle, radius_point.handle, circle.radius, self.wp)
- circle.handle_nr = self._extract_handle_number(str(circle.handle))
- self.circles.append(circle)
- logger.debug(f"Added circle: {circle}")
- return circle
- except Exception as e:
- raise SolverError(f"Failed to add circle to solver: {e}")
-
- def remove_entity(self, handle):
- """Remove an entity from the solver system"""
- try:
- # This is a placeholder implementation
- # In a real implementation, you would need to properly remove the entity from the solver
- logger.debug(f"Removing entity with handle: {handle}")
- # For now, we'll just log the removal - the actual removal is handled in the sketcher
- except Exception as e:
- logger.warning(f"Failed to remove entity from solver: {e}")
- # We don't raise an exception here because the UI removal is more important
-
- def solve_system(self) -> ResultFlag:
- """Solve the constraint system with error handling"""
- try:
- result = self.solve()
- if result == ResultFlag.OKAY:
- self._update_geometry_positions()
- logger.debug("Solver succeeded")
- else:
- logger.warning(f"Solver failed: {result}")
- return result
- except Exception as e:
- logger.error(f"Solver error: {e}")
- raise SolverError(f"Solver execution failed: {e}")
-
- def _update_geometry_positions(self):
- """Update geometry positions from solver results"""
- for i, point in enumerate(self.points):
- try:
- if point.handle and self.params(point.handle.params):
- x, y = self.params(point.handle.params)
- point.x = x
- point.y = y
- point.update_ui_point()
- except Exception as e:
- logger.warning(f"Failed to update point {i}: {e}")
-
- def _extract_handle_number(self, handle_str: str) -> int:
- """Extract handle number from handle string representation"""
- pattern = r"handle=(\d+)"
- match = re.search(pattern, handle_str)
- return int(match.group(1)) if match else 0
-
- def get_point_near(self, target: QPoint, tolerance: float = 20.0) -> Optional[Point2D]:
- """Find point near the target location"""
- logger.debug(f"Looking for point near {target} with tolerance {tolerance}")
- for point in self.points:
- distance = (target - point.ui_point).manhattanLength()
- logger.debug(f"Point at {point.ui_point}, distance: {distance}")
- if distance <= tolerance:
- logger.debug(f"Found point within tolerance: {point}")
- return point
- logger.debug("No point found within tolerance")
- return None
-
- def get_line_near(self, target: QPoint, tolerance: float = 5.0) -> Optional[Line2D]:
- """Find line near the target location"""
- target_point = Point2D(target.x(), target.y())
- for line in self.lines:
- if line.is_point_on_line(target_point, tolerance):
- return line
- return None
-
-
-class ImprovedSketchWidget(QWidget):
- """Enhanced sketch widget with improved architecture and features"""
-
- # Signals
- constraint_applied = Signal()
- geometry_created = Signal(str) # Emits geometry type
- sketch_modified = Signal()
-
- def __init__(self):
- super().__init__()
- self._setup_widget()
- self._setup_state()
- self._setup_settings()
-
- def _setup_widget(self):
- """Initialize widget properties"""
- self.setMouseTracking(True)
- self.setMinimumSize(400, 300)
- self.setFocusPolicy(Qt.StrongFocus) # Ensure widget can receive focus
-
- def _setup_state(self):
- """Initialize widget state"""
- # Current sketch and mode
- self.sketch = ImprovedSketch()
- self.current_mode = SketchMode.NONE
- self.is_construction_mode = False
-
- # Interaction state
- self.hovered_point: Optional[Point2D] = None
- self.hovered_line: Optional[Line2D] = None
- self.hovered_circle: Optional[Circle2D] = None
- self.hovered_snap_point: Optional[QPoint] = None # For snap point display
- self.snap_type: Optional[str] = None # Type of snap ("point", "midpoint", etc.)
- self.selected_elements = [] # List of selected elements for deletion
- self.selection_rect_start: Optional[QPoint] = None # For rectangle selection
- self.selection_rect_end: Optional[QPoint] = None # For rectangle selection
-
- # Dragging state
- self.dragging_point: Optional[Point2D] = None
- self.drag_start_pos: Optional[QPoint] = None
- self.drag_solver_enabled = True # Whether to run solver during drag
-
- # Panning state
- self.panning = False
- self.pan_start_pos: Optional[QPoint] = None
- self.pan_start_offset: Optional[QPoint] = None
-
- # Drawing buffers
- self.line_buffer: List[Optional[Point2D]] = [None, None]
- self.constraint_buffer: List[Any] = [None, None]
- self.dynamic_end_point: Optional[QPoint] = None
-
- # View settings
- self.zoom_factor = 1.0
- self.pan_offset = QPoint(0, 0)
-
- def _setup_settings(self):
- """Initialize settings"""
- self.snap_settings = SnapSettings()
- self.render_settings = RenderSettings()
-
- # Public API Methods
-
- def set_mode(self, mode):
- """Set the current sketching mode"""
- # Handle None as SketchMode.NONE for backward compatibility
- if mode is None:
- mode = SketchMode.NONE
-
- if self.current_mode != mode:
- self.current_mode = mode
- self._reset_interaction_state()
- logger.info(f"Mode changed to: {mode}")
-
- # Special handling for constraint modes
- if mode in [SketchMode.DISTANCE, SketchMode.HORIZONTAL, SketchMode.VERTICAL,
- SketchMode.COINCIDENT_PT_PT, SketchMode.COINCIDENT_PT_LINE]:
- logger.info(f"Entering constraint mode: {mode}")
-
- def set_construction_mode(self, enabled: bool):
- """Enable or disable construction geometry mode"""
- self.is_construction_mode = enabled
- logger.info(f"Construction mode: {'enabled' if enabled else 'disabled'}")
-
- def set_snap_mode(self, snap_mode: SnapMode, enabled: bool):
- """Set a specific snap mode"""
- if enabled:
- self.snap_settings.enabled_modes.add(snap_mode)
- else:
- self.snap_settings.enabled_modes.discard(snap_mode)
- logger.debug(f"Snap mode {snap_mode}: {'enabled' if enabled else 'disabled'}")
-
- def toggle_snap_mode(self, snap_mode: SnapMode, enabled: bool):
- """Toggle a specific snap mode (alias for set_snap_mode)"""
- self.set_snap_mode(snap_mode, enabled)
-
- def zoom_to_fit(self):
- """Zoom to fit all geometry"""
- if not (self.sketch.points or self.sketch.lines):
- return
-
- # Calculate bounding box
- x_coords = [p.x for p in self.sketch.points]
- y_coords = [p.y for p in self.sketch.points]
-
- if not x_coords:
- return
-
- min_x, max_x = min(x_coords), max(x_coords)
- min_y, max_y = min(y_coords), max(y_coords)
-
- # Add margin
- margin = 50
- width = max_x - min_x + 2 * margin
- height = max_y - min_y + 2 * margin
-
- # Calculate zoom
- widget_width = self.width() - 100
- widget_height = self.height() - 100
-
- zoom_x = widget_width / width if width > 0 else 1
- zoom_y = widget_height / height if height > 0 else 1
- self.zoom_factor = min(zoom_x, zoom_y, 2.0)
-
- # Center on geometry
- center_x = (min_x + max_x) / 2
- center_y = (min_y + max_y) / 2
- self.pan_offset = QPoint(int(-center_x), int(-center_y))
-
- self.update()
- logger.info("Zoomed to fit geometry")
-
- # Mouse Event Handlers
-
- def mousePressEvent(self, event):
- """Handle mouse press events"""
- local_pos = self._viewport_to_local(event.pos())
-
- try:
- if event.button() == Qt.LeftButton:
- self._handle_left_click(local_pos)
- elif event.button() == Qt.RightButton:
- self._handle_right_click(local_pos)
- elif event.button() == Qt.MiddleButton:
- self._start_panning(event.pos())
- except (GeometryError, SolverError) as e:
- logger.error(f"Error in mouse press: {e}")
- QMessageBox.warning(self, "Sketching Error", str(e))
-
- self.update()
-
- def mouseMoveEvent(self, event):
- """Handle mouse move events"""
- local_pos = self._viewport_to_local(event.pos())
-
- # Handle point dragging
- if self.dragging_point and self.drag_start_pos:
- self._handle_point_drag(local_pos)
- return
-
- # Handle panning
- if self.panning and self.pan_start_pos:
- self._handle_panning(event.pos())
- return
-
- # Handle rectangle selection in NONE mode
- if ((self.current_mode == SketchMode.NONE or self.current_mode is None) and
- self.selection_rect_start):
- self.selection_rect_end = local_pos
- self.update()
- return
-
- # Update hover state
- self._update_hover_state(local_pos)
-
- # Update dynamic preview
- if (self.current_mode in [SketchMode.LINE, SketchMode.RECTANGLE, SketchMode.CIRCLE] and
- self.line_buffer[0]):
- # Use snapped position for dynamic preview
- self.dynamic_end_point = self._get_snapped_position(local_pos)
-
- self.update()
-
- def mouseReleaseEvent(self, event):
- """Handle mouse release events"""
- if event.button() == Qt.LeftButton and self.dragging_point:
- self._end_point_drag()
- elif event.button() == Qt.MiddleButton and self.panning:
- self._end_panning()
- elif event.button() == Qt.LeftButton:
- # Handle rectangle selection completion
- if ((self.current_mode == SketchMode.NONE or self.current_mode is None) and
- self.selection_rect_start and self.selection_rect_end):
- self._perform_rectangle_selection()
- self.selection_rect_start = None
- self.selection_rect_end = None
- self.update()
- self.update()
-
- def wheelEvent(self, event):
- """Handle zoom with mouse wheel"""
- delta = event.angleDelta().y()
- zoom_factor = 1.2 if delta > 0 else 1/1.2
-
- # Zoom towards mouse position
- mouse_pos = event.position().toPoint()
- old_local = self._viewport_to_local(mouse_pos)
-
- self.zoom_factor *= zoom_factor
- self.zoom_factor = max(0.1, min(10.0, self.zoom_factor))
-
- new_local = self._viewport_to_local(mouse_pos)
- delta_local = old_local - new_local
- self.pan_offset += delta_local
-
- self.update()
-
- def keyPressEvent(self, event):
- """Handle key press events"""
- if event.key() in [Qt.Key_Delete, Qt.Key_Backspace]:
- # Delete selected elements
- self.delete_selected_elements()
- else:
- super().keyPressEvent(event)
-
- # Private Helper Methods
-
- def _handle_left_click(self, pos: QPoint):
- """Handle left mouse button click"""
- logger.debug(f"Left click at local pos: {pos}, mode: {self.current_mode}")
- logger.debug(f"Line buffer state: {[str(p) if p else None for p in self.line_buffer]}")
-
- # Handle selection when in NONE mode (default selection behavior)
- if self.current_mode == SketchMode.NONE or self.current_mode is None:
- # Check if we clicked on an element for selection
- point = self.sketch.get_point_near(pos, self.snap_settings.snap_distance)
- line = self.sketch.get_line_near(pos, 5.0)
-
- # Check if we're starting a drag operation on a point
- if point and not self.selection_rect_start:
- logger.debug(f"Starting drag of point: {point}")
- self._start_point_drag(point, pos)
- return
-
- # Handle selection if not dragging
- if not self.dragging_point:
- if point or line:
- # Toggle element selection
- if point:
- if point in self.selected_elements:
- self.selected_elements.remove(point)
- else:
- self.selected_elements.append(point)
- elif line:
- if line in self.selected_elements:
- self.selected_elements.remove(line)
- else:
- self.selected_elements.append(line)
- self.update()
- return
- else:
- # Start rectangle selection
- self.selection_rect_start = pos
- self.selection_rect_end = pos
- self.update()
- return
-
- # Handle drawing modes
- if self.current_mode == SketchMode.LINE:
- self._handle_line_creation(pos)
- elif self.current_mode == SketchMode.POINT:
- self._handle_point_creation(pos)
- elif self.current_mode == SketchMode.RECTANGLE:
- self._handle_rectangle_creation(pos)
- elif self.current_mode == SketchMode.CIRCLE:
- self._handle_circle_creation(pos)
- elif self.current_mode == SketchMode.COINCIDENT_PT_PT:
- self._handle_point_coincident_constraint(pos)
- elif self.current_mode == SketchMode.HORIZONTAL:
- self._handle_horizontal_constraint(pos)
- elif self.current_mode == SketchMode.VERTICAL:
- self._handle_vertical_constraint(pos)
- elif self.current_mode == SketchMode.DISTANCE:
- self._handle_distance_constraint(pos)
-
- def _perform_rectangle_selection(self):
- """Select elements within the rectangle"""
- if not self.selection_rect_start or not self.selection_rect_end:
- return
-
- # Get the rectangle bounds
- min_x = min(self.selection_rect_start.x(), self.selection_rect_end.x())
- max_x = max(self.selection_rect_start.x(), self.selection_rect_end.x())
- min_y = min(self.selection_rect_start.y(), self.selection_rect_end.y())
- max_y = max(self.selection_rect_start.y(), self.selection_rect_end.y())
-
- # Select points within the rectangle
- for point in self.sketch.points:
- if min_x <= point.x <= max_x and min_y <= point.y <= max_y:
- if point not in self.selected_elements:
- self.selected_elements.append(point)
-
- # Select lines where both endpoints are within the rectangle
- for line in self.sketch.lines:
- start_in = (min_x <= line.start.x <= max_x and min_y <= line.start.y <= max_y)
- end_in = (min_x <= line.end.x <= max_x and min_y <= line.end.y <= max_y)
- if start_in and end_in:
- if line not in self.selected_elements:
- self.selected_elements.append(line)
-
- def delete_selected_elements(self):
- """Delete all currently selected elements"""
- if not self.selected_elements:
- return
-
- # Separate points and lines
- points_to_delete = [elem for elem in self.selected_elements if isinstance(elem, Point2D)]
- lines_to_delete = [elem for elem in self.selected_elements if isinstance(elem, Line2D)]
-
- # Delete lines first (to avoid issues with points being used by lines)
- for line in lines_to_delete:
- if line in self.sketch.lines:
- # Remove from solver
- if line.handle:
- try:
- self.sketch.remove_entity(line.handle)
- except Exception as e:
- logger.warning(f"Failed to remove line from solver: {e}")
- # Remove from sketch
- self.sketch.lines.remove(line)
-
- # Delete points
- for point in points_to_delete:
- # Check if point is used by any remaining lines
- point_in_use = any(
- line.start == point or line.end == point
- for line in self.sketch.lines
- )
-
- if not point_in_use and point in self.sketch.points:
- # Remove from solver
- if point.handle:
- try:
- self.sketch.remove_entity(point.handle)
- except Exception as e:
- logger.warning(f"Failed to remove point from solver: {e}")
- # Remove from sketch
- self.sketch.points.remove(point)
-
- # Clear selection
- self.selected_elements.clear()
-
- # Run solver to update constraints
- try:
- result = self.sketch.solve_system()
- if result == ResultFlag.OKAY:
- logger.debug("Solver succeeded after element deletion")
- else:
- logger.warning(f"Solver failed after element deletion: {result}")
- except Exception as e:
- logger.error(f"Solver error after element deletion: {e}")
-
- # Emit signals
- self.sketch_modified.emit()
- self.update()
-
- def _handle_line_creation(self, pos: QPoint):
- """Handle line creation mode"""
- # Get the actual snapped position (including midpoints, grid, etc.)
- snapped_pos = self._get_snapped_position(pos)
- # Also try to get an existing point for reuse
- snapped_point = self._get_snapped_point(pos)
-
- if not self.line_buffer[0]:
- # First point
- if snapped_point:
- # Use existing point
- self.line_buffer[0] = snapped_point
- else:
- # Create new point at snapped position
- point = Point2D(snapped_pos.x(), snapped_pos.y(), self.is_construction_mode)
- self.sketch.add_point(point)
- self.line_buffer[0] = point
- else:
- # Second point - create line
- if snapped_point:
- # Use existing point
- end_point = snapped_point
- else:
- # Create new point at snapped position (which could be midpoint, grid, etc.)
- end_point = Point2D(snapped_pos.x(), snapped_pos.y(), self.is_construction_mode)
- self.sketch.add_point(end_point)
-
- # Check if the points are identical (would create invalid line)
- if self.line_buffer[0] == end_point:
- logger.error("Line start and end points cannot be identical")
- # Reset the buffer to allow user to try again
- self.line_buffer = [None, None]
- self.dynamic_end_point = None
- return
-
- # Create line
- line = Line2D(self.line_buffer[0], end_point, self.is_construction_mode)
- self.sketch.add_line(line)
-
- # Run solver to ensure constraints are satisfied
- try:
- result = self.sketch.solve_system()
- if result == ResultFlag.OKAY:
- logger.debug("Solver succeeded after line creation")
- else:
- logger.warning(f"Solver failed after line creation: {result}")
- except Exception as e:
- logger.error(f"Solver error after line creation: {e}")
-
- # Continue line drawing from end point
- self.line_buffer[0] = end_point
- self.dynamic_end_point = None
-
- self.geometry_created.emit("line")
- self.sketch_modified.emit()
-
- def _handle_point_creation(self, pos: QPoint):
- """Handle point creation mode"""
- snapped_pos = self._get_snapped_position(pos)
- point = Point2D(snapped_pos.x(), snapped_pos.y(), self.is_construction_mode)
- self.sketch.add_point(point)
- self.geometry_created.emit("point")
- self.sketch_modified.emit()
-
- def _handle_rectangle_creation(self, pos: QPoint):
- """Handle rectangle creation mode (two-point definition)"""
- snapped_pos = self._get_snapped_position(pos)
-
- if not self.line_buffer[0]:
- # First corner
- point = Point2D(snapped_pos.x(), snapped_pos.y(), self.is_construction_mode)
- self.sketch.add_point(point)
- self.line_buffer[0] = point
- else:
- # Second corner - create rectangle
- corner2 = Point2D(snapped_pos.x(), snapped_pos.y(), self.is_construction_mode)
- self.sketch.add_point(corner2)
-
- # Create rectangle from two opposite corners
- corner1 = self.line_buffer[0]
-
- # Calculate other two corners
- corner3 = Point2D(corner1.x, corner2.y, self.is_construction_mode)
- corner4 = Point2D(corner2.x, corner1.y, self.is_construction_mode)
- self.sketch.add_point(corner3)
- self.sketch.add_point(corner4)
-
- # Create four lines for rectangle
- lines = [
- Line2D(corner1, corner4, self.is_construction_mode), # Top
- Line2D(corner4, corner2, self.is_construction_mode), # Right
- Line2D(corner2, corner3, self.is_construction_mode), # Bottom
- Line2D(corner3, corner1, self.is_construction_mode), # Left
- ]
-
- for line in lines:
- self.sketch.add_line(line)
-
- # Reset buffer
- self.line_buffer = [None, None]
- self.dynamic_end_point = None
-
- self.geometry_created.emit("rectangle")
- self.sketch_modified.emit()
-
- def _handle_circle_creation(self, pos: QPoint):
- """Handle circle creation mode (center-radius definition)"""
- snapped_pos = self._get_snapped_position(pos)
-
- if not self.line_buffer[0]:
- # Center point
- center = Point2D(snapped_pos.x(), snapped_pos.y(), self.is_construction_mode)
- self.sketch.add_point(center)
- self.line_buffer[0] = center
- else:
- # Radius point - create circle
- radius_point = Point2D(snapped_pos.x(), snapped_pos.y())
- radius = self.line_buffer[0].distance_to(radius_point)
-
- # Create circle
- circle = Circle2D(self.line_buffer[0], radius, self.is_construction_mode)
- self.sketch.add_circle(circle)
-
- # Reset buffer
- self.line_buffer = [None, None]
- self.dynamic_end_point = None
-
- self.geometry_created.emit("circle")
- self.sketch_modified.emit()
-
- def _handle_point_coincident_constraint(self, pos: QPoint):
- """Handle point-to-point coincident constraint"""
- point = self.sketch.get_point_near(pos)
- if not point:
- return
-
- if not self.constraint_buffer[0]:
- self.constraint_buffer[0] = point
- else:
- # Apply coincident constraint
- try:
- self.sketch.coincident(
- self.constraint_buffer[0].handle,
- point.handle,
- self.sketch.wp
- )
- result = self.sketch.solve_system()
- if result == ResultFlag.OKAY:
- # Only emit sketch_modified, don't emit constraint_applied
- # which would reset the mode via integration with main app
- self.sketch_modified.emit()
- logger.debug("Applied coincident constraint")
- else:
- logger.warning(f"Constraint failed: {result}")
- finally:
- # Only reset the constraint buffer, keeping the mode active
- self.constraint_buffer = [None, None]
-
- def _handle_horizontal_constraint(self, pos: QPoint):
- """Handle horizontal line constraint"""
- line = self.sketch.get_line_near(pos)
- if line and line.handle:
- try:
- self.sketch.horizontal(line.handle, self.sketch.wp)
- result = self.sketch.solve_system()
- if result == ResultFlag.OKAY:
- line.constraints.append("horizontal")
- # Only emit sketch_modified, don't emit constraint_applied
- # which would reset the mode via integration with main app
- self.sketch_modified.emit()
- logger.debug("Applied horizontal constraint")
- else:
- logger.warning(f"Horizontal constraint failed: {result}")
- except Exception as e:
- logger.error(f"Failed to apply horizontal constraint: {e}")
-
- def _handle_vertical_constraint(self, pos: QPoint):
- """Handle vertical line constraint"""
- line = self.sketch.get_line_near(pos)
- if line and line.handle:
- try:
- self.sketch.vertical(line.handle, self.sketch.wp)
- result = self.sketch.solve_system()
- if result == ResultFlag.OKAY:
- line.constraints.append("vertical")
- # Only emit sketch_modified, don't emit constraint_applied
- # which would reset the mode via integration with main app
- self.sketch_modified.emit()
- logger.debug("Applied vertical constraint")
- else:
- logger.warning(f"Vertical constraint failed: {result}")
- except Exception as e:
- logger.error(f"Failed to apply vertical constraint: {e}")
-
- def _handle_distance_constraint(self, pos: QPoint):
- """Handle distance constraint (line length or point-to-point distance)"""
- line = self.sketch.get_line_near(pos)
- if line and line.handle:
- # Get distance value from user
- current_length = line.length
- distance, ok = QInputDialog.getDouble(
- self, 'Distance Constraint',
- 'Enter distance (mm):',
- current_length,
- 0.01, # min value
- 1000.0, # max value
- 2 # decimals
- )
-
- if ok:
- try:
- self.sketch.distance(
- line.start.handle,
- line.end.handle,
- distance,
- self.sketch.wp
- )
- result = self.sketch.solve_system()
- if result == ResultFlag.OKAY:
- line.constraints.append(f"L={distance:.2f}")
- # Only emit sketch_modified, don't emit constraint_applied
- # which would reset the mode via integration with main app
- self.sketch_modified.emit()
- logger.debug(f"Applied distance constraint: {distance:.2f}")
- else:
- logger.warning(f"Distance constraint failed: {result}")
- except Exception as e:
- logger.error(f"Failed to apply distance constraint: {e}")
- else:
- # If no line found, show a message to the user
- logger.debug("No line found near cursor for distance constraint")
-
- def _handle_right_click(self, pos: QPoint):
- """Handle right mouse button click (cancels current operation and exits mode)"""
- logger.debug(f"Right click at pos: {pos}, current mode: {self.current_mode}")
-
- # Reset any interaction state (like line buffers, etc.)
- self._reset_interaction_state()
-
- # Force mode to NONE to enable dragging/selection
- self.current_mode = SketchMode.NONE
- logger.info(f"Right-click: Mode reset to NONE")
-
- # Emit signal to inform main app that constraint/drawing mode was canceled
- self.constraint_applied.emit()
-
- def _update_hover_state(self, pos: QPoint):
- """Update which elements are being hovered and detect snap points"""
- if not self.sketch:
- self.hovered_point = None
- self.hovered_line = None
- self.hovered_snap_point = None
- self.snap_type = None
- return
-
- # Reset state
- self.hovered_point = None
- self.hovered_line = None
- self.hovered_snap_point = None
- self.snap_type = None
-
- min_distance = float('inf')
- snap_threshold = self.snap_settings.snap_distance
-
- # Check for existing points first (highest priority)
- if SnapMode.POINT in self.snap_settings.enabled_modes:
- for point in self.sketch.points:
- distance = math.sqrt((pos.x() - point.x)**2 + (pos.y() - point.y)**2)
- if distance < snap_threshold and distance < min_distance:
- self.hovered_point = point
- self.hovered_snap_point = QPoint(int(point.x), int(point.y))
- self.snap_type = "point"
- min_distance = distance
-
- # Check for line midpoints if no point was found and midpoint snap is enabled
- if not self.hovered_point and SnapMode.MIDPOINT in self.snap_settings.enabled_modes:
- for line in self.sketch.lines:
- midpoint = line.midpoint
- distance = math.sqrt((pos.x() - midpoint.x)**2 + (pos.y() - midpoint.y)**2)
- if distance < snap_threshold and distance < min_distance:
- self.hovered_snap_point = QPoint(int(midpoint.x), int(midpoint.y))
- self.snap_type = "midpoint"
- self.hovered_line = line
- min_distance = distance
-
- # Check for lines (lowest priority)
- if not self.hovered_point and not self.hovered_snap_point:
- self.hovered_line = self.sketch.get_line_near(pos)
-
- def _get_snapped_point(self, pos: QPoint) -> Optional[Point2D]:
- """Get snapped point if snapping is enabled"""
- if SnapMode.POINT in self.snap_settings.enabled_modes:
- return self.sketch.get_point_near(pos, self.snap_settings.snap_distance)
- return None
-
- def _get_snapped_position(self, pos: QPoint) -> QPoint:
- """Get snapped position considering all enabled snap modes"""
- min_distance = float('inf')
- snapped_pos = pos
- snap_threshold = self.snap_settings.snap_distance
-
- # Point snapping (highest priority)
- if SnapMode.POINT in self.snap_settings.enabled_modes:
- for point in self.sketch.points:
- distance = math.sqrt((pos.x() - point.x)**2 + (pos.y() - point.y)**2)
- if distance < snap_threshold and distance < min_distance:
- snapped_pos = QPoint(int(point.x), int(point.y))
- min_distance = distance
-
- # Midpoint snapping (medium priority)
- if SnapMode.MIDPOINT in self.snap_settings.enabled_modes and min_distance > snap_threshold:
- for line in self.sketch.lines:
- midpoint = line.midpoint
- distance = math.sqrt((pos.x() - midpoint.x)**2 + (pos.y() - midpoint.y)**2)
- if distance < snap_threshold and distance < min_distance:
- snapped_pos = QPoint(int(midpoint.x), int(midpoint.y))
- min_distance = distance
-
- # Grid snapping (lowest priority)
- if SnapMode.GRID in self.snap_settings.enabled_modes and min_distance > snap_threshold:
- spacing = self.snap_settings.grid_spacing
- snapped_x = round(pos.x() / spacing) * spacing
- snapped_y = round(pos.y() / spacing) * spacing
- grid_pos = QPoint(int(snapped_x), int(snapped_y))
- distance = math.sqrt((pos.x() - snapped_x)**2 + (pos.y() - snapped_y)**2)
- if distance < snap_threshold:
- snapped_pos = grid_pos
-
- return snapped_pos
-
- def _reset_interaction_state(self):
- """Reset all interaction buffers and states"""
- logger.debug("Resetting interaction state")
- self.line_buffer = [None, None]
- self.constraint_buffer = [None, None]
- self.dynamic_end_point = None
- self.selected_elements.clear()
- self.selection_rect_start = None
- self.selection_rect_end = None
-
- def _viewport_to_local(self, viewport_pos: QPoint) -> QPoint:
- """Convert viewport coordinates to local sketch coordinates
- This must be the EXACT inverse of _setup_coordinate_system()
- """
- # Step 1: Subtract the widget center
- center_x = self.width() / 2
- center_y = self.height() / 2
-
- # Step 2: Subtract the pan offset (in viewport coordinates)
- viewport_x = viewport_pos.x() - center_x - (self.pan_offset.x() * self.zoom_factor)
- viewport_y = viewport_pos.y() - center_y - (self.pan_offset.y() * self.zoom_factor)
-
- # Step 3: Apply inverse zoom and Y-flip (reverse of scale(zoom, -zoom))
- local_x = viewport_x / self.zoom_factor
- local_y = -viewport_y / self.zoom_factor # Note: negative because rendering uses -zoom_factor for Y
-
- return QPoint(int(local_x), int(local_y))
-
- def _local_to_viewport(self, local_pos: QPoint) -> QPoint:
- """Convert local sketch coordinates to viewport coordinates"""
- center_x = self.width() // 2
- center_y = self.height() // 2
-
- # Apply pan and zoom
- viewport_x = (local_pos.x() + self.pan_offset.x()) * self.zoom_factor + center_x
- viewport_y = center_y - (local_pos.y() + self.pan_offset.y()) * self.zoom_factor
-
- return QPoint(int(viewport_x), int(viewport_y))
-
- # Rendering Methods
-
- def paintEvent(self, event):
- """Main rendering method"""
- painter = QPainter(self)
- painter.setRenderHint(QPainter.Antialiasing)
-
- try:
- self._setup_coordinate_system(painter)
- self._draw_background(painter)
- self._draw_geometry(painter)
- self._draw_dynamic_elements(painter)
- self._draw_ui_overlays(painter)
- except Exception as e:
- logger.error(f"Rendering error: {e}")
- finally:
- painter.end()
-
- def _setup_coordinate_system(self, painter: QPainter):
- """Setup coordinate system transformation"""
- transform = QTransform()
-
- # Translate to center and apply pan
- center = QPointF(self.width() / 2, self.height() / 2)
- transform.translate(center.x() + self.pan_offset.x() * self.zoom_factor,
- center.y() + self.pan_offset.y() * self.zoom_factor)
-
- # Apply zoom and flip Y-axis
- transform.scale(self.zoom_factor, -self.zoom_factor)
-
- painter.setTransform(transform)
-
- def _draw_background(self, painter: QPainter):
- """Draw background grid and axes"""
- # Draw coordinate axes
- pen = QPen(Qt.gray, 1.0 / self.zoom_factor, Qt.DashLine)
- painter.setPen(pen)
-
- # X-axis
- painter.drawLine(-1000, 0, 1000, 0)
- # Y-axis
- painter.drawLine(0, -1000, 0, 1000)
-
- # Draw origin
- painter.setPen(QPen(Qt.red, 2.0 / self.zoom_factor))
- painter.drawEllipse(QPointF(0, 0), 3 / self.zoom_factor, 3 / self.zoom_factor)
-
- def _draw_geometry(self, painter: QPainter):
- """Draw all sketch geometry"""
- if not self.sketch:
- return
-
- # Draw points
- for point in self.sketch.points:
- self._draw_point(painter, point)
-
- # Draw lines
- for line in self.sketch.lines:
- self._draw_line(painter, line)
-
- # Draw circles
- for circle in self.sketch.circles:
- self._draw_circle(painter, circle)
-
- def _draw_point(self, painter: QPainter, point: Point2D):
- """Draw a single point"""
- if point.is_helper:
- pen = QPen(self.render_settings.construction_color,
- self.render_settings.construction_pen_width / self.zoom_factor)
- radius = 8 / self.zoom_factor
- else:
- pen = QPen(self.render_settings.normal_color,
- self.render_settings.normal_pen_width / self.zoom_factor)
- radius = 3 / self.zoom_factor
-
- # Special highlighting for selected points
- if point in self.selected_elements:
- pen = QPen(QColor(255, 255, 0), # Yellow
- (self.render_settings.highlight_pen_width + 2) / self.zoom_factor)
- radius *= 1.5 # Make selected points larger
- # Special highlighting for dragged points
- elif point == self.dragging_point:
- pen = QPen(QColor(255, 165, 0),
- (self.render_settings.highlight_pen_width + 1) / self.zoom_factor) # Orange
- radius *= 1.3 # Make dragged points slightly larger
- # Regular hover highlight
- elif point == self.hovered_point:
- pen = QPen(self.render_settings.highlight_color,
- self.render_settings.highlight_pen_width / self.zoom_factor)
-
- painter.setPen(pen)
- painter.setBrush(Qt.NoBrush if point not in self.selected_elements else QColor(255, 255, 0, 100))
- painter.drawEllipse(QPointF(point.x, point.y), radius, radius)
-
- def _draw_line(self, painter: QPainter, line: Line2D):
- """Draw a single line"""
- if line.is_helper:
- pen = QPen(self.render_settings.construction_color,
- self.render_settings.construction_pen_width / self.zoom_factor,
- Qt.DotLine)
- else:
- pen = QPen(self.render_settings.normal_color,
- self.render_settings.normal_pen_width / self.zoom_factor)
-
- # Special highlighting for selected lines
- if line in self.selected_elements:
- pen = QPen(QColor(255, 255, 0), # Yellow
- (self.render_settings.highlight_pen_width + 2) / self.zoom_factor)
- # Highlight if hovered
- elif line == self.hovered_line:
- pen = QPen(self.render_settings.highlight_color,
- self.render_settings.highlight_pen_width / self.zoom_factor)
-
- painter.setPen(pen)
- painter.drawLine(QPointF(line.start.x, line.start.y),
- QPointF(line.end.x, line.end.y))
-
- # Draw constraint annotations
- if line.constraints and not line.is_helper:
- self._draw_constraint_annotations(painter, line)
-
- def _draw_circle(self, painter: QPainter, circle: Circle2D):
- """Draw a single circle"""
- if circle.is_helper:
- pen = QPen(self.render_settings.construction_color,
- self.render_settings.construction_pen_width / self.zoom_factor,
- Qt.DotLine)
- else:
- pen = QPen(self.render_settings.normal_color,
- self.render_settings.normal_pen_width / self.zoom_factor)
-
- painter.setPen(pen)
- painter.drawEllipse(QPointF(circle.center.x, circle.center.y),
- circle.radius, circle.radius)
-
- def _draw_constraint_annotations(self, painter: QPainter, line: Line2D):
- """Draw constraint annotations for a line"""
- if not line.constraints:
- return
-
- midpoint = line.midpoint
- painter.save()
-
- # Setup text rendering
- painter.setPen(QPen(self.render_settings.text_color, 1.0 / self.zoom_factor))
- font = QFont()
- font.setPointSizeF(10 / self.zoom_factor)
- painter.setFont(font)
-
- # Position text near line midpoint
- text_pos = QPointF(midpoint.x + 10 / self.zoom_factor,
- midpoint.y + 10 / self.zoom_factor)
- painter.translate(text_pos)
- painter.scale(1, -1) # Flip text to be readable
-
- # Draw constraints
- for i, constraint in enumerate(line.constraints):
- painter.drawText(0, i * 15, f"[{constraint}]")
-
- painter.restore()
-
- def _draw_dynamic_elements(self, painter: QPainter):
- """Draw dynamic/preview elements"""
- pen = QPen(self.render_settings.dynamic_color,
- 1.0 / self.zoom_factor, Qt.DashLine)
- painter.setPen(pen)
-
- # Draw selection rectangle
- if ((self.current_mode == SketchMode.NONE or self.current_mode is None) and
- self.selection_rect_start and self.selection_rect_end):
- pen = QPen(QColor(255, 255, 0), 1.0 / self.zoom_factor, Qt.DashLine) # Yellow dashed
- painter.setPen(pen)
- painter.setBrush(QColor(255, 255, 0, 50)) # Semi-transparent yellow fill
-
- rect = QRect(self.selection_rect_start, self.selection_rect_end)
- painter.drawRect(rect)
-
- # Dynamic line preview
- if (self.current_mode == SketchMode.LINE and
- self.line_buffer[0] and
- self.dynamic_end_point):
-
- start_point = QPointF(self.line_buffer[0].x, self.line_buffer[0].y)
- end_point = QPointF(self.dynamic_end_point.x(), self.dynamic_end_point.y())
- painter.drawLine(start_point, end_point)
-
- # Show length preview
- length = self.line_buffer[0].distance_to(
- Point2D(self.dynamic_end_point.x(), self.dynamic_end_point.y())
- )
- self._draw_length_preview(painter, start_point, end_point, length)
-
- # Dynamic rectangle preview
- elif (self.current_mode == SketchMode.RECTANGLE and
- self.line_buffer[0] and
- self.dynamic_end_point):
-
- corner1 = QPointF(self.line_buffer[0].x, self.line_buffer[0].y)
- corner2 = QPointF(self.dynamic_end_point.x(), self.dynamic_end_point.y())
- corner3 = QPointF(corner1.x(), corner2.y())
- corner4 = QPointF(corner2.x(), corner1.y())
-
- # Draw rectangle outline
- painter.drawLine(corner1, corner4) # Top
- painter.drawLine(corner4, corner2) # Right
- painter.drawLine(corner2, corner3) # Bottom
- painter.drawLine(corner3, corner1) # Left
-
- # Dynamic circle preview
- elif (self.current_mode == SketchMode.CIRCLE and
- self.line_buffer[0] and
- self.dynamic_end_point):
-
- center = QPointF(self.line_buffer[0].x, self.line_buffer[0].y)
- radius = self.line_buffer[0].distance_to(
- Point2D(self.dynamic_end_point.x(), self.dynamic_end_point.y())
- )
-
- # Draw circle outline
- painter.drawEllipse(center, radius, radius)
-
- # Draw radius line
- radius_point = QPointF(self.dynamic_end_point.x(), self.dynamic_end_point.y())
- painter.drawLine(center, radius_point)
-
- # Show radius preview
- self._draw_radius_preview(painter, center, radius_point, radius)
-
- # Draw snap point highlights
- if self.hovered_snap_point:
- self._draw_snap_highlight(painter, self.hovered_snap_point, self.snap_type)
-
- def _draw_length_preview(self, painter: QPainter, start: QPointF, end: QPointF, length: float):
- """Draw length preview during line creation"""
- painter.save()
-
- # Calculate midpoint and text position
- mid_x = (start.x() + end.x()) / 2
- mid_y = (start.y() + end.y()) / 2
-
- # Setup text
- painter.setPen(QPen(self.render_settings.text_color, 1.0 / self.zoom_factor))
- font = QFont()
- font.setPointSizeF(10 / self.zoom_factor)
- painter.setFont(font)
-
- # Draw text
- painter.translate(mid_x, mid_y)
- painter.scale(1, -1)
- painter.drawText(0, 0, f"{length:.2f}")
-
- painter.restore()
-
- def _draw_radius_preview(self, painter: QPainter, center: QPointF, radius_point: QPointF, radius: float):
- """Draw radius preview during circle creation"""
- painter.save()
-
- # Calculate midpoint along radius line
- mid_x = (center.x() + radius_point.x()) / 2
- mid_y = (center.y() + radius_point.y()) / 2
-
- # Setup text
- painter.setPen(QPen(self.render_settings.text_color, 1.0 / self.zoom_factor))
- font = QFont()
- font.setPointSizeF(10 / self.zoom_factor)
- painter.setFont(font)
-
- # Draw radius text
- painter.translate(mid_x, mid_y)
- painter.scale(1, -1)
- painter.drawText(0, 0, f"R={radius:.2f}")
-
- painter.restore()
-
- def _draw_snap_highlight(self, painter: QPainter, snap_point: QPoint, snap_type: str):
- """Draw visual highlight for snap points"""
- painter.save()
-
- # Set highlight style based on snap type
- if snap_type == "point":
- # Red circle for point snapping
- pen = QPen(Qt.red, 3.0 / self.zoom_factor)
- painter.setPen(pen)
- painter.setBrush(Qt.NoBrush)
- radius = 8 / self.zoom_factor
- painter.drawEllipse(QPointF(snap_point.x(), snap_point.y()), radius, radius)
-
- elif snap_type == "midpoint":
- # Red diamond for midpoint snapping
- pen = QPen(Qt.red, 2.0 / self.zoom_factor)
- painter.setPen(pen)
- painter.setBrush(Qt.NoBrush)
-
- size = 6 / self.zoom_factor
- center = QPointF(snap_point.x(), snap_point.y())
-
- # Draw diamond shape
- diamond_points = [
- QPointF(center.x(), center.y() - size), # Top
- QPointF(center.x() + size, center.y()), # Right
- QPointF(center.x(), center.y() + size), # Bottom
- QPointF(center.x() - size, center.y()) # Left
- ]
-
- for i in range(4):
- painter.drawLine(diamond_points[i], diamond_points[(i + 1) % 4])
-
- elif snap_type == "grid":
- # Green cross for grid snapping
- pen = QPen(Qt.green, 2.0 / self.zoom_factor)
- painter.setPen(pen)
-
- size = 8 / self.zoom_factor
- center = QPointF(snap_point.x(), snap_point.y())
-
- # Draw cross
- painter.drawLine(center.x() - size, center.y(), center.x() + size, center.y())
- painter.drawLine(center.x(), center.y() - size, center.x(), center.y() + size)
-
- painter.restore()
-
- def _draw_ui_overlays(self, painter: QPainter):
- """Draw UI overlays (status text, etc.)"""
- painter.resetTransform()
-
- # Draw mode indicator
- painter.setPen(QPen(Qt.white))
- font = QFont()
- font.setPointSize(12)
- painter.setFont(font)
-
- mode_name = self.current_mode.name if self.current_mode else "None"
- mode_text = f"Mode: {mode_name}"
- if self.is_construction_mode:
- mode_text += " (Construction)"
-
- painter.drawText(10, 25, mode_text)
-
- # Draw zoom level
- zoom_text = f"Zoom: {self.zoom_factor:.1f}x"
- painter.drawText(10, 50, zoom_text)
-
- # Show snap information if actively snapping
- if self.hovered_snap_point and self.snap_type:
- snap_text = f"Snap: {self.snap_type.title()}"
- painter.drawText(10, 75, snap_text)
-
- # Backward Compatibility API Methods
-
- def get_sketch(self):
- """Get the current sketch (for main app compatibility)"""
- return self.sketch
-
- def set_sketch(self, sketch):
- """Set the current sketch (for main app compatibility)"""
- if hasattr(sketch, 'points') and hasattr(sketch, 'lines'):
- # If it's already an ImprovedSketch, use it directly
- if isinstance(sketch, ImprovedSketch):
- self.sketch = sketch
- else:
- # Convert from old sketch format if needed
- self.sketch = ImprovedSketch()
- self.sketch.id = getattr(sketch, 'id', self.sketch.id)
- self.sketch.name = getattr(sketch, 'name', "Imported Sketch")
- self.sketch.origin = getattr(sketch, 'origin', [0, 0, 0])
- self.update()
-
- def create_sketch(self, sketch):
- """Create a new sketch from sketch data (for main app compatibility)"""
- self.sketch = ImprovedSketch()
- if hasattr(sketch, 'id'):
- self.sketch.id = sketch.id
- self.sketch.name = sketch.id
- if hasattr(sketch, 'origin'):
- self.sketch.origin = sketch.origin
- self.update()
-
- def reset_buffers(self):
- """Reset drawing buffers (for main app compatibility)"""
- self._reset_interaction_state()
- self.update()
-
- def create_workplane_projected(self):
- """Create workplane from projected data (for main app compatibility)"""
- # This is handled internally by the solver system
- pass
-
- def convert_proj_points(self, proj_points):
- """Convert projected points (for main app compatibility)"""
- if not proj_points:
- return
- for point_data in proj_points:
- # Convert to Point2D and add to sketch as construction points (green)
- if hasattr(point_data, 'x') and hasattr(point_data, 'y'):
- point = Point2D(point_data.x, point_data.y, True) # Construction point
- self.sketch.add_point(point)
- elif len(point_data) >= 2:
- # Apply coordinate scaling/transformation if needed for working plane alignment
- x, y = point_data[0], point_data[1]
- # Scale coordinates to match sketch widget coordinate system
- # This ensures proper alignment with the working plane orientation
- point = Point2D(x, y, True) # Construction point
- self.sketch.add_point(point)
- self.update()
-
- def convert_proj_lines(self, proj_lines):
- """Convert projected lines (for main app compatibility)"""
- if not proj_lines:
- return
-
- logger.info(f"Converting {len(proj_lines)} projected lines to construction geometry")
-
- lines_added = 0
- lines_skipped = 0
-
- for i, line_data in enumerate(proj_lines):
- logger.debug(f"Processing line {i}: {line_data}")
-
- # Convert to Line2D and add to sketch as construction lines (green)
- if hasattr(line_data, 'start') and hasattr(line_data, 'end'):
- x1, y1 = line_data.start.x, line_data.start.y
- x2, y2 = line_data.end.x, line_data.end.y
-
- # Skip degenerate lines (identical start and end points)
- if abs(x1 - x2) < 1e-6 and abs(y1 - y2) < 1e-6:
- logger.debug(f"Skipping degenerate line: ({x1}, {y1}) -> ({x2}, {y2})")
- lines_skipped += 1
- continue
-
- start = Point2D(x1, y1, True) # Construction
- end = Point2D(x2, y2, True) # Construction
- logger.debug(f"Added line from object format: ({x1}, {y1}) -> ({x2}, {y2})")
- self.sketch.add_point(start)
- self.sketch.add_point(end)
- line = Line2D(start, end, True) # Construction line
- self.sketch.add_line(line)
- lines_added += 1
-
- elif len(line_data) >= 2 and len(line_data[0]) >= 2 and len(line_data[1]) >= 2:
- # Handle tuple format [(x1, y1), (x2, y2)]
- x1, y1 = line_data[0][0], line_data[0][1]
- x2, y2 = line_data[1][0], line_data[1][1]
-
- # Skip degenerate lines (identical start and end points)
- if abs(x1 - x2) < 1e-6 and abs(y1 - y2) < 1e-6:
- logger.debug(f"Skipping degenerate line: ({x1}, {y1}) -> ({x2}, {y2})")
- lines_skipped += 1
- continue
-
- logger.debug(f"Added line from tuple format: ({x1}, {y1}) -> ({x2}, {y2})")
-
- start = Point2D(x1, y1, True) # Construction
- end = Point2D(x2, y2, True) # Construction
- self.sketch.add_point(start)
- self.sketch.add_point(end)
- line = Line2D(start, end, True) # Construction line
- self.sketch.add_line(line)
- lines_added += 1
-
- else:
- logger.warning(f"Unrecognized line data format: {line_data}")
-
- logger.info(f"Successfully added {lines_added} construction lines to sketch (skipped {lines_skipped} degenerate lines)")
- self.update()
-
- # Dragging Methods
-
- def _start_point_drag(self, point: Point2D, start_pos: QPoint):
- """Start dragging a point"""
- self.dragging_point = point
- self.drag_start_pos = start_pos
- logger.debug(f"Started dragging point: {point}")
-
- def _handle_point_drag(self, current_pos: QPoint):
- """Handle ongoing point drag - update visual position and provide immediate feedback"""
- if not self.dragging_point or not self.drag_start_pos:
- return
-
- try:
- # Get snapped position to allow snapping while dragging
- snapped_pos = self._get_snapped_position(current_pos)
- logger.debug(f"Dragging point to: {snapped_pos} (from {current_pos})")
-
- # Store original position for potential rollback if constraints fail
- if not hasattr(self.dragging_point, '_drag_original_pos'):
- self.dragging_point._drag_original_pos = (self.dragging_point.x, self.dragging_point.y)
-
- # Update point position visually during drag
- old_pos = (self.dragging_point.x, self.dragging_point.y)
- self.dragging_point.x = float(snapped_pos.x())
- self.dragging_point.y = float(snapped_pos.y())
- self.dragging_point.update_ui_point()
- logger.debug(f"Point position updated from {old_pos} to ({self.dragging_point.x}, {self.dragging_point.y})")
-
- # Update the display immediately for responsive feedback
- self.update()
-
- except Exception as e:
- logger.error(f"Error during point drag: {e}")
-
- def _end_point_drag(self):
- """End point dragging and finalize with solver"""
- if not self.dragging_point:
- return
-
- dragged_point = self.dragging_point # Store reference before clearing
-
- try:
- # Update the solver parameters with the final position
- if dragged_point.handle:
- new_x = dragged_point.x
- new_y = dragged_point.y
-
- logger.debug(f"Setting final point position to ({new_x:.2f}, {new_y:.2f})")
-
- # Update the solver parameters
- self.sketch.set_params(dragged_point.handle.params, [new_x, new_y])
-
- # Run solver to update all connected geometry
- result = self.sketch.solve_system()
- if result == ResultFlag.OKAY:
- # Clear the original position since drag was successful
- if hasattr(dragged_point, '_drag_original_pos'):
- delattr(dragged_point, '_drag_original_pos')
- self.sketch_modified.emit()
- logger.debug(f"Point drag completed successfully: {dragged_point}")
- else:
- logger.warning(f"Solver failed after point drag: {result}")
- # If solver fails, we could optionally revert to original position
- # but for now, keep the visual position as user intended
- if hasattr(dragged_point, '_drag_original_pos'):
- delattr(dragged_point, '_drag_original_pos')
-
- except Exception as e:
- logger.error(f"Error finishing point drag: {e}")
- # In case of error, could revert to original position
- if hasattr(dragged_point, '_drag_original_pos'):
- orig_x, orig_y = dragged_point._drag_original_pos
- dragged_point.x = orig_x
- dragged_point.y = orig_y
- dragged_point.update_ui_point()
- delattr(dragged_point, '_drag_original_pos')
- finally:
- # Reset drag state
- self.dragging_point = None
- self.drag_start_pos = None
-
- # Force a final update to ensure all geometry is refreshed
- self.update()
-
- # Panning Methods
-
- def _start_panning(self, start_pos: QPoint):
- """Start panning the view with middle mouse button"""
- self.panning = True
- self.pan_start_pos = start_pos
- self.pan_start_offset = QPoint(self.pan_offset)
- self.setCursor(Qt.ClosedHandCursor)
- logger.debug(f"Started panning at: {start_pos}")
-
- def _handle_panning(self, current_pos: QPoint):
- """Handle ongoing panning operation"""
- if not self.panning or not self.pan_start_pos or not self.pan_start_offset:
- return
-
- # Calculate the delta in viewport coordinates
- delta_viewport = current_pos - self.pan_start_pos
-
- # Convert to local coordinates (scale by zoom factor)
- delta_local_x = delta_viewport.x() / self.zoom_factor
- delta_local_y = -delta_viewport.y() / self.zoom_factor # Flip Y for coordinate system
-
- # Update pan offset
- self.pan_offset = QPoint(
- int(self.pan_start_offset.x() + delta_local_x),
- int(self.pan_start_offset.y() + delta_local_y)
- )
-
- self.update()
-
- def _end_panning(self):
- """End panning operation"""
- if not self.panning:
- return
-
- self.panning = False
- self.pan_start_pos = None
- self.pan_start_offset = None
- self.setCursor(Qt.ArrowCursor)
- logger.debug("Ended panning")
-
-
-# Example usage and testing
-if __name__ == "__main__":
- import sys
-
- app = QApplication(sys.argv)
-
- # Create and show the improved sketcher
- widget = ImprovedSketchWidget()
- widget.setWindowTitle("Improved Fluency Sketcher")
- widget.resize(800, 600)
- widget.show()
-
- # Set initial mode to line drawing
- widget.set_mode(SketchMode.LINE)
-
- sys.exit(app.exec())
diff --git a/drawing_modules/sketcher_integration_example.py b/drawing_modules/sketcher_integration_example.py
deleted file mode 100644
index 5678646..0000000
--- a/drawing_modules/sketcher_integration_example.py
+++ /dev/null
@@ -1,201 +0,0 @@
-"""
-Example integration of the improved sketcher with the main Fluency application
-This shows how to replace the existing sketcher with the improved version
-"""
-
-from PySide6.QtWidgets import QApplication, QMainWindow, QVBoxLayout, QHBoxLayout, QWidget, QPushButton, QButtonGroup
-from PySide6.QtCore import Qt
-
-from improved_sketcher import ImprovedSketchWidget, SketchMode, SnapMode
-
-
-class SketcherIntegrationDemo(QMainWindow):
- """Demo showing how to integrate the improved sketcher with UI controls"""
-
- def __init__(self):
- super().__init__()
- self.setWindowTitle("Improved Sketcher Integration Demo")
- self.resize(1200, 800)
-
- # Create central widget
- central_widget = QWidget()
- self.setCentralWidget(central_widget)
-
- # Create layout
- main_layout = QHBoxLayout(central_widget)
-
- # Create toolbar
- self.create_toolbar(main_layout)
-
- # Create sketcher widget
- self.sketcher = ImprovedSketchWidget()
- main_layout.addWidget(self.sketcher, stretch=1)
-
- # Connect sketcher signals
- self.connect_sketcher_signals()
-
- # Set initial mode
- self.sketcher.set_mode(SketchMode.LINE)
-
- def create_toolbar(self, parent_layout):
- """Create toolbar with sketching tools"""
- toolbar_widget = QWidget()
- toolbar_widget.setFixedWidth(200)
- toolbar_layout = QVBoxLayout(toolbar_widget)
-
- # Drawing tools group
- drawing_group = QWidget()
- drawing_layout = QVBoxLayout(drawing_group)
- drawing_layout.addWidget(self.create_label("Drawing Tools"))
-
- # Create drawing mode buttons
- self.drawing_buttons = QButtonGroup(self)
- self.drawing_buttons.setExclusive(True)
-
- drawing_modes = [
- ("Line", SketchMode.LINE),
- ("Rectangle", SketchMode.RECTANGLE),
- ("Circle", SketchMode.CIRCLE),
- ("Point", SketchMode.POINT),
- ]
-
- for name, mode in drawing_modes:
- button = QPushButton(name)
- button.setCheckable(True)
- button.clicked.connect(lambda checked, m=mode: self.set_drawing_mode(m))
- self.drawing_buttons.addButton(button)
- drawing_layout.addWidget(button)
-
- # Set line as default
- self.drawing_buttons.buttons()[0].setChecked(True)
-
- # Constraint tools group
- constraint_group = QWidget()
- constraint_layout = QVBoxLayout(constraint_group)
- constraint_layout.addWidget(self.create_label("Constraints"))
-
- # Create constraint buttons
- constraint_modes = [
- ("Coincident", SketchMode.COINCIDENT_PT_PT),
- ("Horizontal", SketchMode.HORIZONTAL),
- ("Vertical", SketchMode.VERTICAL),
- ("Distance", SketchMode.DISTANCE),
- ]
-
- for name, mode in constraint_modes:
- button = QPushButton(name)
- button.clicked.connect(lambda checked, m=mode: self.set_constraint_mode(m))
- constraint_layout.addWidget(button)
-
- # Settings group
- settings_group = QWidget()
- settings_layout = QVBoxLayout(settings_group)
- settings_layout.addWidget(self.create_label("Settings"))
-
- # Construction mode toggle
- self.construction_button = QPushButton("Construction Mode")
- self.construction_button.setCheckable(True)
- self.construction_button.toggled.connect(self.toggle_construction_mode)
- settings_layout.addWidget(self.construction_button)
-
- # Snap settings
- snap_buttons = [
- ("Point Snap", SnapMode.POINT),
- ("Grid Snap", SnapMode.GRID),
- ("Midpoint Snap", SnapMode.MIDPOINT),
- ]
-
- for name, snap_mode in snap_buttons:
- button = QPushButton(name)
- button.setCheckable(True)
- button.toggled.connect(lambda checked, sm=snap_mode: self.toggle_snap_mode(sm, checked))
- settings_layout.addWidget(button)
-
- # Set default snaps
- settings_layout.itemAt(1).widget().setChecked(True) # Point snap on by default
-
- # View controls
- view_group = QWidget()
- view_layout = QVBoxLayout(view_group)
- view_layout.addWidget(self.create_label("View"))
-
- zoom_fit_button = QPushButton("Zoom to Fit")
- zoom_fit_button.clicked.connect(self.sketcher.zoom_to_fit)
- view_layout.addWidget(zoom_fit_button)
-
- # Add groups to toolbar
- toolbar_layout.addWidget(drawing_group)
- toolbar_layout.addWidget(constraint_group)
- toolbar_layout.addWidget(settings_group)
- toolbar_layout.addWidget(view_group)
- toolbar_layout.addStretch()
-
- parent_layout.addWidget(toolbar_widget)
-
- def create_label(self, text):
- """Create a section label"""
- from PySide6.QtWidgets import QLabel
- from PySide6.QtCore import Qt
-
- label = QLabel(text)
- label.setAlignment(Qt.AlignCenter)
- label.setStyleSheet("font-weight: bold; padding: 5px; background-color: #333; color: white;")
- return label
-
- def set_drawing_mode(self, mode):
- """Set the sketcher to drawing mode"""
- self.sketcher.set_mode(mode)
- print(f"Drawing mode set to: {mode.name}")
-
- def set_constraint_mode(self, mode):
- """Set the sketcher to constraint mode"""
- self.sketcher.set_mode(mode)
- # Uncheck all drawing buttons when in constraint mode
- for button in self.drawing_buttons.buttons():
- button.setChecked(False)
- print(f"Constraint mode set to: {mode.name}")
-
- def toggle_construction_mode(self, checked):
- """Toggle construction geometry mode"""
- self.sketcher.set_construction_mode(checked)
- print(f"Construction mode: {'enabled' if checked else 'disabled'}")
-
- def toggle_snap_mode(self, snap_mode, enabled):
- """Toggle snap mode"""
- self.sketcher.toggle_snap_mode(snap_mode, enabled)
- print(f"Snap mode {snap_mode.name}: {'enabled' if enabled else 'disabled'}")
-
- def connect_sketcher_signals(self):
- """Connect to sketcher signals for feedback"""
- self.sketcher.geometry_created.connect(self.on_geometry_created)
- self.sketcher.constraint_applied.connect(self.on_constraint_applied)
- self.sketcher.sketch_modified.connect(self.on_sketch_modified)
-
- def on_geometry_created(self, geometry_type):
- """Handle geometry creation"""
- print(f"Created: {geometry_type}")
- # Update status or trigger other actions
-
- def on_constraint_applied(self):
- """Handle constraint application"""
- print("Constraint applied successfully")
- # Return to line drawing mode after constraint
- self.sketcher.set_mode(SketchMode.LINE)
- self.drawing_buttons.buttons()[0].setChecked(True)
-
- def on_sketch_modified(self):
- """Handle sketch modifications"""
- print("Sketch modified")
- # Could trigger auto-save or update displays
-
-
-def replace_sketcher_in_main_app():
- """
- Example of how to replace the existing sketcher in main.py
-
- In main.py, replace this code:
-
- ```python\n from drawing_modules.draw_widget_solve import SketchWidget\n self.sketchWidget = SketchWidget()\n ```\n \n With:\n \n ```python\n from drawing_modules.improved_sketcher import ImprovedSketchWidget, SketchMode\n self.sketchWidget = ImprovedSketchWidget()\n \n # Connect to existing signals (adapt as needed)\n self.sketchWidget.constraint_applied.connect(self.draw_op_complete)\n self.sketchWidget.sketch_modified.connect(self.on_sketch_changed)\n \n # Connect toolbar buttons to new sketcher modes\n self.ui.pb_linetool.clicked.connect(lambda: self.sketchWidget.set_mode(SketchMode.LINE))\n self.ui.pb_rectool.clicked.connect(lambda: self.sketchWidget.set_mode(SketchMode.RECTANGLE))\n # ... etc for other buttons\n ```\n \n The improved sketcher provides these advantages:\n \n 1. **Better Architecture**: Clean separation of concerns, proper error handling\n 2. **Enhanced Features**: Rectangle and circle tools, improved constraints\n 3. **Better Performance**: Optimized rendering and interaction handling\n 4. **Extensibility**: Easy to add new tools and constraints\n 5. **Type Safety**: Proper type hints and validation\n 6. **Logging**: Built-in logging for debugging\n 7. **Settings**: Configurable snap and render settings\n \n Key differences to adapt:\n \n - Use SketchMode enum instead of string modes\n - Connect to new signal names (constraint_applied, geometry_created, sketch_modified)\n - Use set_mode() instead of individual mode methods\n - Access sketch data through self.sketch property\n - Use new geometry classes (Point2D, Line2D, Circle2D)\n """\n pass
-
-
-if __name__ == "__main__":\n import sys\n \n app = QApplication(sys.argv)\n \n # Create and show the integration demo\n demo = SketcherIntegrationDemo()\n demo.show()\n \n print("Improved Sketcher Integration Demo")\n print("==================================")\n print("Features:")\n print("- Line, Rectangle, Circle, Point drawing")\n print("- Coincident, Horizontal, Vertical, Distance constraints")\n print("- Construction geometry mode")\n print("- Point, Grid, Midpoint snapping")\n print("- Zoom to fit")\n print("- Mouse wheel zoom")\n print("- Right-click to cancel operations")\n print("")\n print("Usage:")\n print("- Select a drawing tool and click in the viewport")\n print("- Right-click to finish multi-point operations")\n print("- Use constraint tools to add relationships")\n print("- Toggle construction mode for helper geometry")\n \n sys.exit(app.exec())
diff --git a/drawing_modules/solvespace_example.py b/drawing_modules/solvespace_example.py
deleted file mode 100644
index e405f59..0000000
--- a/drawing_modules/solvespace_example.py
+++ /dev/null
@@ -1,35 +0,0 @@
-from python_solvespace import SolverSystem, ResultFlag
-
-def solve_constraint():
- solv = SolverSystem()
- wp = solv.create_2d_base() # Workplane (Entity)
- p0 = solv.add_point_2d(0, 0, wp) # Entity
- p1 = solv.add_point_2d(10, 10, wp) # Entity
- p2 = solv.add_point_2d(0, 10, wp) # Entity
- solv.dragged(p0, wp) # Make a constraint with the entity
-
- line0 = solv.add_line_2d(p0, p1, wp) # Create entity with others
- line1 = solv.add_line_2d(p0, p2, wp)
- #solv.angle(line0, line1, 45, wp) # Constrain two entities
- solv.coincident(p0, p1, wp)
- solv.add_constraint(100006, wp, 0, p1,p2, line0, line1)
-
- line1 = solv.entity(-1) # Entity handle can be re-generated and negatively indexed
- solv.
- if solv.solve() == ResultFlag.OKAY:
- # Get the result (unpack from the entity or parameters)
- # x and y are actually float type
- dof = solv.dof()
- x, y = solv.params(p1.params)
- print(dof)
- print(x)
- print(y)
-
- else:
- # Error!
- # Get the list of all constraints
- failures = solv.failures()
- print(failures)
- ...
-
-solve_constraint()
\ No newline at end of file
diff --git a/drawing_modules/vtk_widget.py b/drawing_modules/vtk_widget.py
deleted file mode 100644
index db6ceaf..0000000
--- a/drawing_modules/vtk_widget.py
+++ /dev/null
@@ -1,861 +0,0 @@
-import sys
-
-import numpy as np
-import vtk
-from PySide6 import QtCore, QtWidgets
-from PySide6.QtCore import Signal
-from vtkmodules.qt.QVTKRenderWindowInteractor import QVTKRenderWindowInteractor
-from vtkmodules.util.numpy_support import vtk_to_numpy, numpy_to_vtk
-
-
-class VTKWidget(QtWidgets.QWidget):
- face_data = Signal(dict)
-
- def __init__(self, parent=None):
- super().__init__(parent)
- self.selected_vtk_line = []
- self.access_selected_points = []
- self.selected_normal = None
- self.centroid = None
- self.selected_edges = []
- self.cell_normals = None
-
- self.local_matrix = None
-
- self.project_tosketch_points = []
- self.project_tosketch_lines = []
-
- self.vtk_widget = QVTKRenderWindowInteractor(self)
-
- self.picked_edge_actors = []
- self.displayed_normal_actors = []
- self.body_actors_orig = []
- self.projected_mesh_actors = []
- self.interactor_actors = []
-
- self.flip_toggle = False
-
- # Create layout and add VTK widget
- layout = QtWidgets.QVBoxLayout()
- layout.addWidget(self.vtk_widget)
- self.setLayout(layout)
-
- # Create VTK pipeline
- self.renderer = vtk.vtkRenderer()
- self.renderer_projections = vtk.vtkRenderer()
- self.renderer_indicators = vtk.vtkRenderer()
-
- self.renderer.SetViewport(0, 0, 1, 1) # Full viewport
- self.renderer_projections.SetViewport(0, 0, 1, 1) # Full viewport, overlays the first
- self.renderer_indicators.SetViewport(0, 0, 1, 1) # Full viewport, overlays the first
-
- self.renderer.SetLayer(0)
- self.renderer_projections.SetLayer(1)
- self.renderer_indicators.SetLayer(2) # This will be on top
-
- # Preserve color and depth buffers for non-zero layers
- self.renderer_projections.SetPreserveColorBuffer(True)
- self.renderer_projections.SetPreserveDepthBuffer(True)
- self.renderer_indicators.SetPreserveColorBuffer(True)
- self.renderer_indicators.SetPreserveDepthBuffer(True)
-
- # Add renderers to the render window
- render_window = self.vtk_widget.GetRenderWindow()
- render_window.SetNumberOfLayers(3)
- render_window.AddRenderer(self.renderer)
- render_window.AddRenderer(self.renderer_projections)
- render_window.AddRenderer(self.renderer_indicators)
-
- self.camera = vtk.vtkCamera()
- self.camera.SetPosition(5, 5, 1000)
- self.camera.SetFocalPoint(0, 0, 0)
- self.camera.SetClippingRange(1, 10000) # Adjusted clipping range
-
- self.renderer.SetActiveCamera(self.camera)
- self.renderer_projections.SetActiveCamera(self.camera)
- self.renderer_indicators.SetActiveCamera(self.camera)
-
- self.interactor = self.vtk_widget.GetRenderWindow().GetInteractor()
-
- # Light Setup
- def add_light(renderer, position, color=(1, 1, 1), intensity=1.0):
- light = vtk.vtkLight()
- light.SetPosition(position)
- light.SetColor(color)
- light.SetIntensity(intensity)
- renderer.AddLight(light)
-
- # Add lights from multiple directions
- add_light(self.renderer, (1000, 0, 0), intensity=1.5)
- add_light(self.renderer, (-1000, 0, 0), intensity=1.5)
- add_light(self.renderer, (0, 1000, 0), intensity=1.5)
- add_light(self.renderer, (0, -1000, 0), intensity=1.5)
- add_light(self.renderer, (0, 0, 1000), intensity=1.5)
- add_light(self.renderer, (0, 0, -1000), intensity=1.5)
-
- # Set up picking
- self.picker = vtk.vtkCellPicker()
- self.picker.SetTolerance(0.005)
-
- # Create a mapper and actor for picked cells
- self.picked_mapper = vtk.vtkDataSetMapper()
- self.picked_actor = vtk.vtkActor()
- self.picked_actor.SetMapper(self.picked_mapper)
- self.picked_actor.GetProperty().SetColor(1.0, 0.0, 0.0) # Red color for picked faces
- self.picked_actor.VisibilityOff() # Initially hide the actor
- self.renderer.AddActor(self.picked_actor)
-
- # Create an extract selection filter
- self.extract_selection = vtk.vtkExtractSelection()
-
- # Set up interactor style
- self.style = vtk.vtkInteractorStyleTrackballCamera()
- self.interactor.SetInteractorStyle(self.style)
-
- # Add observer for mouse clicks
- self.interactor.AddObserver("RightButtonPressEvent", self.on_click)
-
- # Add axis gizmo (smaller size)
- self.axes = vtk.vtkAxesActor()
- self.axes.SetTotalLength(0.5, 0.5, 0.5) # Reduced size
- self.axes.SetShaftType(0)
- self.axes.SetAxisLabels(1)
-
- # Create an orientation marker
- self.axes_widget = vtk.vtkOrientationMarkerWidget()
- self.axes_widget.SetOrientationMarker(self.axes)
- self.axes_widget.SetInteractor(self.interactor)
- self.axes_widget.SetViewport(0.0, 0.0, 0.2, 0.2) # Set position and size
- self.axes_widget.EnabledOn()
- self.axes_widget.InteractiveOff()
-
- # Start the interactor
- self.interactor.Initialize()
- self.interactor.Start()
-
- # Create the grid
- grid = self.create_grid(size=100, spacing=10)
-
- # Setup actor and mapper
- mapper = vtk.vtkPolyDataMapper()
- mapper.SetInputData(grid)
-
- actor = vtk.vtkActor()
- actor.SetPickable(False)
- actor.SetMapper(mapper)
- actor.GetProperty().SetColor(0.5, 0.5, 0.5) # Set grid color to gray
-
- self.renderer.AddActor(actor)
-
- def reset_camera(self):
- self.renderer.ResetCamera()
- self.camera.SetClippingRange(1, 100000) # Set your desired range
- self.vtk_widget.GetRenderWindow().Render()
-
- def update_render(self):
- self.renderer.ResetCameraClippingRange()
- self.renderer_projections.ResetCameraClippingRange()
- self.renderer_indicators.ResetCameraClippingRange()
- self.camera.SetClippingRange(1, 100000)
- self.vtk_widget.GetRenderWindow().Render()
-
- def create_grid(self, size=100, spacing=10):
- # Create a vtkPoints object and store the points in it
- points = vtk.vtkPoints()
-
- # Create lines
- lines = vtk.vtkCellArray()
-
- # Create the grid
- for i in range(-size, size + 1, spacing):
- # X-direction line
- points.InsertNextPoint(i, -size, 0)
- points.InsertNextPoint(i, size, 0)
- line = vtk.vtkLine()
- line.GetPointIds().SetId(0, points.GetNumberOfPoints() - 2)
- line.GetPointIds().SetId(1, points.GetNumberOfPoints() - 1)
- lines.InsertNextCell(line)
-
- # Y-direction line
- points.InsertNextPoint(-size, i, 0)
- points.InsertNextPoint(size, i, 0)
- line = vtk.vtkLine()
- line.GetPointIds().SetId(0, points.GetNumberOfPoints() - 2)
- line.GetPointIds().SetId(1, points.GetNumberOfPoints() - 1)
- lines.InsertNextCell(line)
-
- # Create a polydata to store everything in
- grid = vtk.vtkPolyData()
-
- # Add the points to the dataset
- grid.SetPoints(points)
-
- # Add the lines to the dataset
- grid.SetLines(lines)
-
- return grid
-
- def on_receive_command(self, command):
- """Calls the individual commands pressed in main"""
- print("Receive command: ", command)
- if command == "flip":
- self.clear_actors_projection()
- self.flip_toggle = not self.flip_toggle # Toggle the flag
- self.on_invert_normal()
-
- @staticmethod
- def compute_normal_from_lines(line1, line2):
- vec1 = line1[1] - line1[0]
- vec2 = line2[1] - line2[0]
- normal = np.cross(vec1, vec2)
- print(normal)
- normal = normal / np.linalg.norm(normal)
- return normal
-
- def load_interactor_mesh(self, edges, off_vector):
- # Create vtkPoints to store all points
- points = vtk.vtkPoints()
-
- # Create vtkCellArray to store the lines
- lines = vtk.vtkCellArray()
-
- for edge in edges:
- # Add points for this edge
- point_id1 = points.InsertNextPoint(edge[0])
- point_id2 = points.InsertNextPoint(edge[1])
-
- # Create a line using the point IDs
- line = vtk.vtkLine()
- line.GetPointIds().SetId(0, point_id1)
- line.GetPointIds().SetId(1, point_id2)
-
- # Add the line to the cell array
- lines.InsertNextCell(line)
-
- # Create vtkPolyData to store the geometry
- polydata = vtk.vtkPolyData()
- polydata.SetPoints(points)
- polydata.SetLines(lines)
-
- # Create a transform for mirroring across the y-axis
- matrix_transform = vtk.vtkTransform()
-
- if self.local_matrix:
- print(self.local_matrix)
- matrix = vtk.vtkMatrix4x4()
- matrix.DeepCopy(self.local_matrix)
- matrix.Invert()
- matrix_transform.SetMatrix(matrix)
- #matrix_transform.Scale(1, 1, 1) # This mirrors across the y-axis
-
- # Apply the matrix transform
- transformFilter = vtk.vtkTransformPolyDataFilter()
- transformFilter.SetInputData(polydata)
- transformFilter.SetTransform(matrix_transform)
- transformFilter.Update()
-
- # Create and apply the offset transform
- offset_transform = vtk.vtkTransform()
- offset_transform.Translate(off_vector[0], off_vector[1], off_vector[2])
-
- offsetFilter = vtk.vtkTransformPolyDataFilter()
- offsetFilter.SetInputConnection(transformFilter.GetOutputPort())
- offsetFilter.SetTransform(offset_transform)
- offsetFilter.Update()
-
- # Create a mapper and actor
- mapper = vtk.vtkPolyDataMapper()
- mapper.SetInputConnection(offsetFilter.GetOutputPort())
-
- actor = vtk.vtkActor()
- actor.SetMapper(mapper)
- actor.GetProperty().SetColor(1.0, 1.0, 1.0)
- actor.GetProperty().SetLineWidth(4) # Set line width
-
- # Add the actor to the scene
- self.renderer.AddActor(actor)
- self.interactor_actors.append(actor)
-
- mapper.Update()
- self.vtk_widget.GetRenderWindow().Render()
-
- def render_from_points_direct_with_faces(self, vertices, faces, color=(0.1, 0.2, 0.8), line_width=2, point_size=5):
- """Sketch Widget has inverted Y axiis therefore we invert y via scale here until fix"""
-
- # Handle empty vertices or faces
- if len(vertices) == 0 or len(faces) == 0:
- print("Warning: No vertices or faces to render")
- return
-
- points = vtk.vtkPoints()
-
- # Validate vertices shape
- if vertices.ndim != 2 or vertices.shape[1] != 3:
- print(f"Warning: Invalid vertex shape {vertices.shape}. Expected Nx3.")
- return
-
- # Validate faces shape
- if faces.ndim != 2 or faces.shape[1] != 3:
- print(f"Warning: Invalid face shape {faces.shape}. Expected Nx3.")
- return
-
- # Use SetData with numpy array - ensure vertices are float32
- try:
- vertices_float = np.asarray(vertices, dtype=np.float32)
- vtk_array = numpy_to_vtk(vertices_float, deep=True)
- points.SetData(vtk_array)
- except Exception as e:
- print(f"Error converting vertices to VTK array: {e}")
- # Fallback: manually insert points
- for vertex in vertices:
- points.InsertNextPoint(vertex[0], vertex[1], vertex[2])
-
- # Create a vtkCellArray to store the triangles
- triangles = vtk.vtkCellArray()
- num_vertices = len(vertices)
-
- for i, face in enumerate(faces):
- # Validate face indices
- if (face[0] >= num_vertices or face[0] < 0 or
- face[1] >= num_vertices or face[1] < 0 or
- face[2] >= num_vertices or face[2] < 0):
- print(f"Warning: Invalid face indices {face} at index {i}. Skipping face.")
- continue
-
- triangle = vtk.vtkTriangle()
- triangle.GetPointIds().SetId(0, int(face[0]))
- triangle.GetPointIds().SetId(1, int(face[1]))
- triangle.GetPointIds().SetId(2, int(face[2]))
- triangles.InsertNextCell(triangle)
-
- # Check if we have any valid triangles
- if triangles.GetNumberOfCells() == 0:
- print("Warning: No valid triangles to render")
- return
-
- # Create a polydata object
- polydata = vtk.vtkPolyData()
- polydata.SetPoints(points)
- polydata.SetPolys(triangles)
-
- # Calculate normals
- normalGenerator = vtk.vtkPolyDataNormals()
- normalGenerator.SetInputData(polydata)
- normalGenerator.ComputePointNormalsOn()
- normalGenerator.ComputeCellNormalsOn()
- normalGenerator.Update()
-
- # Safely get cell normals, with fallback if they're not available
- cell_normals = normalGenerator.GetOutput().GetCellData().GetNormals()
- if cell_normals:
- try:
- self.cell_normals = vtk_to_numpy(cell_normals)
- except Exception as e:
- print(f"Warning: Could not convert cell normals to numpy array: {e}")
- self.cell_normals = None
- else:
- print("Warning: No cell normals available")
- self.cell_normals = None
-
- # Create a mapper and actor
- mapper = vtk.vtkPolyDataMapper()
- mapper.SetInputData(polydata)
-
- actor = vtk.vtkActor()
- actor.SetMapper(mapper)
- actor.GetProperty().SetColor(color)
- actor.GetProperty().EdgeVisibilityOff()
- actor.GetProperty().SetLineWidth(line_width)
- actor.GetProperty().SetMetallic(1)
- actor.GetProperty().SetOpacity(0.8)
- actor.SetPickable(False)
-
- self.renderer.AddActor(actor)
- self.body_actors_orig.append(actor)
- self.vtk_widget.GetRenderWindow().Render()
-
- def clear_body_actors(self):
- for actor in self.body_actors_orig:
- self.renderer.RemoveActor(actor)
-
- def visualize_matrix(self, matrix):
- points = vtk.vtkPoints()
- for i in range(4):
- for j in range(4):
- points.InsertNextPoint(matrix.GetElement(0, j),
- matrix.GetElement(1, j),
- matrix.GetElement(2, j))
-
- polydata = vtk.vtkPolyData()
- polydata.SetPoints(points)
-
- mapper = vtk.vtkPolyDataMapper()
- mapper.SetInputData(polydata)
-
- actor = vtk.vtkActor()
- actor.SetMapper(mapper)
- actor.GetProperty().SetPointSize(5)
-
- self.renderer.AddActor(actor)
-
- def numpy_to_vtk(self, array, deep=True):
- """Convert a numpy array to a vtk array."""
- vtk_array = vtk.vtkDoubleArray()
- vtk_array.SetNumberOfComponents(array.shape[1])
- vtk_array.SetNumberOfTuples(array.shape[0])
-
- for i in range(array.shape[0]):
- for j in range(array.shape[1]):
- vtk_array.SetComponent(i, j, array[i, j])
-
- return vtk_array
-
- def get_points_and_edges_from_polydata(self, polydata) -> list:
- # Extract points
- points = {}
- vtk_points = polydata.GetPoints()
- for i in range(vtk_points.GetNumberOfPoints()):
- point = vtk_points.GetPoint(i)
- points[i] = np.array(point)
-
- # Extract edges
- edges = []
- for i in range(polydata.GetNumberOfCells()):
- cell = polydata.GetCell(i)
- if cell.GetCellType() == vtk.VTK_LINE:
- point_ids = cell.GetPointIds()
- edge = (point_ids.GetId(0), point_ids.GetId(1))
- edges.append(edge)
-
- return points, edges
-
- def project_mesh_to_plane(self, input_mesh, normal, origin):
- # Create the projector
- projector = vtk.vtkProjectPointsToPlane()
- projector.SetInputData(input_mesh)
- projector.SetProjectionTypeToSpecifiedPlane()
-
- # Set the normal and origin of the plane
- projector.SetNormal(normal)
- projector.SetOrigin(origin)
-
- # Execute the projection
- projector.Update()
-
- # Get the projected mesh
- projected_mesh = projector.GetOutput()
- return projected_mesh
-
- def compute_2d_coordinates(self, projected_mesh, normal):
- # Normalize the normal vector
- normal = np.array(normal)
- normal = normal / np.linalg.norm(normal)
-
- # Create a vtkTransform
- transform = vtk.vtkTransform()
- transform.PostMultiply() # This ensures transforms are applied in the order we specify
-
- # Rotate so that the normal aligns with the Z-axis
- rotation_axis = np.cross(normal, [0, 0, 1])
- angle = np.arccos(np.dot(normal, [0, 0, 1])) * 180 / np.pi # Convert to degrees
-
- if np.linalg.norm(rotation_axis) > 1e-6: # Check if rotation is needed
- transform.RotateWXYZ(angle, rotation_axis[0], rotation_axis[1], rotation_axis[2])
-
- # Get the transformation matrix
- matrix = transform.GetMatrix()
- self.local_matrix = [matrix.GetElement(i, j) for i in range(4) for j in range(4)]
-
- # Apply the transform to the polydata
- transformFilter = vtk.vtkTransformPolyDataFilter()
- transformFilter.SetInputData(projected_mesh)
- transformFilter.SetTransform(transform)
- transformFilter.Update()
-
- # Get the transformed points
- transformed_polydata = transformFilter.GetOutput()
- points = transformed_polydata.GetPoints()
-
- # Extract 2D coordinates
- xy_coordinates = []
- for i in range(points.GetNumberOfPoints()):
- point = points.GetPoint(i)
- xy_coordinates.append((point[0], point[1]))
-
- return xy_coordinates
-
- def compute_2d_coordinates_line(self, projected_mesh, normal):
- # Normalize the normal vector
- normal = np.array(normal)
- normal = normal / np.linalg.norm(normal)
-
- # Create a vtkTransform
- transform = vtk.vtkTransform()
- transform.PostMultiply() # This ensures transforms are applied in the order we specify
-
- # Rotate so that the normal aligns with the Z-axis
- rotation_axis = np.cross(normal, [0, 0, 1])
- angle = np.arccos(np.dot(normal, [0, 0, 1])) * 180 / np.pi # Convert to degrees
-
- if np.linalg.norm(rotation_axis) > 1e-6: # Check if rotation is needed
- transform.RotateWXYZ(angle, rotation_axis[0], rotation_axis[1], rotation_axis[2])
-
- # Get the transformation matrix
- matrix = transform.GetMatrix()
- self.local_matrix = [matrix.GetElement(i, j) for i in range(4) for j in range(4)]
-
- # Apply the transform to the polydata
- transformFilter = vtk.vtkTransformPolyDataFilter()
- transformFilter.SetInputData(projected_mesh)
- transformFilter.SetTransform(transform)
- transformFilter.Update()
-
- # Get the transformed points
- transformed_polydata = transformFilter.GetOutput()
- points = transformed_polydata.GetPoints()
- lines = transformed_polydata.GetLines()
-
- # Extract 2D coordinates
- xy_coordinates = []
-
- if points and lines:
- points_data = points.GetData()
- line_ids = vtk.vtkIdList()
-
- # Loop through all the lines in the vtkCellArray
- lines.InitTraversal()
- while lines.GetNextCell(line_ids):
- line_coordinates = []
- for j in range(line_ids.GetNumberOfIds()):
- point_id = line_ids.GetId(j)
- point = points.GetPoint(point_id)
- line_coordinates.append((point[0], point[1])) # Only take x, y
- xy_coordinates.append(line_coordinates)
-
- return xy_coordinates
-
-
- def compute_2d_coordinates_line_bak(self, line_source, normal):
- # Ensure the input is a vtkLineSource
- print("line", line_source)
- if not isinstance(line_source, vtk.vtkLineSource):
- raise ValueError("Input must be a vtkLineSource")
-
- # Normalize the normal vector
- normal = np.array(normal)
- normal = normal / np.linalg.norm(normal)
-
- # Create a vtkTransform
- transform = vtk.vtkTransform()
- transform.PostMultiply() # This ensures transforms are applied in the order we specify
-
- # Rotate so that the normal aligns with the Z-axis
- rotation_axis = np.cross(normal, [0, 0, 1])
- angle = np.arccos(np.dot(normal, [0, 0, 1])) * 180 / np.pi # Convert to degrees
-
- if np.linalg.norm(rotation_axis) > 1e-6: # Check if rotation is needed
- transform.RotateWXYZ(angle, rotation_axis[0], rotation_axis[1], rotation_axis[2])
-
- # Get the transformation matrix
- matrix = transform.GetMatrix()
- local_matrix = [matrix.GetElement(i, j) for i in range(4) for j in range(4)]
-
- # Get the polydata from the line source
- line_source.Update()
- polydata = line_source.GetOutput()
-
- # Apply the transform to the polydata
- transform_filter = vtk.vtkTransformPolyDataFilter()
- transform_filter.SetInputData(polydata)
- transform_filter.SetTransform(transform)
- transform_filter.Update()
-
- # Get the transformed points
- transformed_polydata = transform_filter.GetOutput()
- transformed_points = transformed_polydata.GetPoints()
-
- # Extract 2D coordinates
- xy_coordinates = []
- for i in range(transformed_points.GetNumberOfPoints()):
- point = transformed_points.GetPoint(i)
- xy_coordinates.append((point[0], point[1]))
-
- return xy_coordinates
-
- def project_2d_to_3d(self, xy_coordinates, normal):
- # Normalize the normal vector
- normal = np.array(normal)
- normal = normal / np.linalg.norm(normal)
-
- # Create a vtkTransform for the reverse transformation
- reverse_transform = vtk.vtkTransform()
- reverse_transform.PostMultiply() # This ensures transforms are applied in the order we specify
-
- # Compute the rotation axis and angle (same as in compute_2d_coordinates)
- rotation_axis = np.cross(normal, [0, 0, 1])
- angle = np.arccos(np.dot(normal, [0, 0, 1])) * 180 / np.pi # Convert to degrees
-
- if np.linalg.norm(rotation_axis) > 1e-6: # Check if rotation is needed
- # Apply the inverse rotation
- reverse_transform.RotateWXYZ(-angle, rotation_axis[0], rotation_axis[1], rotation_axis[2])
-
- # Create vtkPoints to store the 2D points
- points_2d = vtk.vtkPoints()
- for x, y in xy_coordinates:
- points_2d.InsertNextPoint(x, y, 0) # Z-coordinate is 0 for 2D points
-
- # Create a polydata with the 2D points
- polydata_2d = vtk.vtkPolyData()
- polydata_2d.SetPoints(points_2d)
-
- # Apply the reverse transform to the polydata
- transform_filter = vtk.vtkTransformPolyDataFilter()
- transform_filter.SetInputData(polydata_2d)
- transform_filter.SetTransform(reverse_transform)
- transform_filter.Update()
-
- # Get the transformed points (now in 3D)
- transformed_polydata = transform_filter.GetOutput()
- transformed_points = transformed_polydata.GetPoints()
-
- # Extract 3D coordinates
- xyz_coordinates = []
- for i in range(transformed_points.GetNumberOfPoints()):
- point = transformed_points.GetPoint(i)
- xyz_coordinates.append((point[0], point[1], point[2]))
-
- return xyz_coordinates
-
- def add_normal_line(self, origin, normal, length=10.0, color=(1, 0, 0)):
- # Normalize the normal vector
- normal = np.array(normal)
- normal = normal / np.linalg.norm(normal)
-
- # Calculate the end point
- end_point = origin + normal * length
-
- # Create vtkPoints
- points = vtk.vtkPoints()
- points.InsertNextPoint(origin)
- points.InsertNextPoint(end_point)
-
- # Create a line
- line = vtk.vtkLine()
- line.GetPointIds().SetId(0, 0)
- line.GetPointIds().SetId(1, 1)
-
- # Create a cell array to store the line
- lines = vtk.vtkCellArray()
- lines.InsertNextCell(line)
-
- # Create a polydata to store everything in
- polyData = vtk.vtkPolyData()
- polyData.SetPoints(points)
- polyData.SetLines(lines)
-
- # Create mapper and actor
- mapper = vtk.vtkPolyDataMapper()
- mapper.SetInputData(polyData)
-
- actor = vtk.vtkActor()
- actor.SetMapper(mapper)
- actor.GetProperty().SetColor(color)
- actor.GetProperty().SetLineWidth(2) # Adjust line width as needed
-
- # Add to renderer
- self.renderer.AddActor(actor)
- self.vtk_widget.GetRenderWindow().Render()
-
- return actor # Return the actor in case you need to remove or modify it later
-
- def on_invert_normal(self):
- # Kippstufe für Normal flip
- if self.selected_normal is not None:
- self.clear_actors_normals()
- self.compute_projection(self.flip_toggle)
-
- def on_click(self, obj, event):
- click_pos = self.interactor.GetEventPosition()
-
- # Perform pick
- self.picker.Pick(click_pos[0], click_pos[1], 0, self.renderer)
-
- # Get picked cell ID
- cell_id = self.picker.GetCellId()
-
- if cell_id != -1:
- print(f"Picked cell ID: {cell_id}")
-
- # Get the polydata and the picked cell
- polydata = self.picker.GetActor().GetMapper().GetInput()
- cell = polydata.GetCell(cell_id)
-
- # Ensure it's a line
- if cell.GetCellType() == vtk.VTK_LINE:
-
- # Get the two points of the line
- point_id1 = cell.GetPointId(0)
- point_id2 = cell.GetPointId(1)
-
- proj_point1 = polydata.GetPoint(point_id1)
- proj_point2 = polydata.GetPoint(point_id2)
-
- self.access_selected_points.append((proj_point1, proj_point2))
-
- point1 = np.array(proj_point1)
- point2 = np.array(proj_point2)
-
- #print(f"Line starts at: {point1}")
- #print(f"Line ends at: {point2}")
-
- # Store this line for later use if needed
- self.selected_edges.append((point1, point2))
-
- # Create a new vtkLineSource for the picked edge
- line_source = vtk.vtkLineSource()
- line_source.SetPoint1(point1)
- line_source.SetPoint2(point2)
-
- self.selected_vtk_line.append(line_source)
-
- # Create a mapper and actor for the picked edge
- edge_mapper = vtk.vtkPolyDataMapper()
- edge_mapper.SetInputConnection(line_source.GetOutputPort())
-
- edge_actor = vtk.vtkActor()
- edge_actor.SetMapper(edge_mapper)
- edge_actor.GetProperty().SetColor(1.0, 0.0, 0.0) # Red color for picked edges
- edge_actor.GetProperty().SetLineWidth(5) # Make the line thicker
-
- # Add the actor to the renderer and store it
- self.renderer_indicators.AddActor(edge_actor)
- self.picked_edge_actors.append(edge_actor)
-
- if len(self.selected_edges) == 2:
- self.compute_projection(False)
-
- if len(self.selected_edges) > 2:
- # Clear lists for selection
- self.selected_vtk_line.clear()
- self.selected_edges.clear()
- self.clear_edge_select()
-
- # Clear Actors from view
- self.clear_actors_projection()
- self.clear_actors_sel_edges()
- self.clear_actors_normals()
-
-
- def find_origin_vertex(self, edge1, edge2):
- if edge1[0] == edge2[0]or edge1[0] == edge2[1]:
- return edge1[0]
- elif edge1[1] == edge2[0] or edge1[1] == edge2[1]:
- return edge1[1]
- else:
- return None # The edges don't share a vertex
-
- def clear_edge_select(self ):
- # Clear selection after projection was succesful
- self.selected_edges = []
- self.selected_normal = []
-
- def clear_actors_projection(self):
- """Removes all actors that were used for projection"""
- for flat_mesh in self.projected_mesh_actors:
- self.renderer_projections.RemoveActor(flat_mesh)
-
- def clear_actors_normals(self):
- for normals in self.displayed_normal_actors:
- self.renderer_indicators.RemoveActor(normals)
-
- def clear_actors_sel_edges(self):
- for edge_line in self.picked_edge_actors:
- self.renderer_indicators.RemoveActor(edge_line)
-
- def clear_actors_interactor(self):
- ### Clear the outline of the mesh
- for interactor in self.interactor_actors:
- self.renderer.RemoveActor(interactor)
-
- def compute_projection(self, direction_invert: bool = False):
-
- # Compute the normal from the two selected edges )
- edge1 = self.selected_edges[0][1] - self.selected_edges[0][0]
- edge2 = self.selected_edges[1][1] - self.selected_edges[1][0]
- selected_normal = np.cross(edge1, edge2)
- selected_normal = selected_normal / np.linalg.norm(selected_normal)
- #print("Computed normal:", self.selected_normal)
-
- # Invert the normal in local z if direction_invert is True
- if direction_invert:
- self.selected_normal = -selected_normal
- else:
- self.selected_normal = selected_normal
-
- self.centroid = np.mean([point for edge in self.selected_edges for point in edge], axis=0)
- #self.centroid = self.find_origin_vertex(edge1, edge2)
-
- # Draw the normal line
- normal_length = 50 # Adjust this value to change the length of the normal line
- normal_actor = self.add_normal_line(self.centroid, self.selected_normal, length=normal_length,
- color=(1, 0, 0))
-
- polydata = self.picker.GetActor().GetMapper().GetInput()
-
- projected_polydata = self.project_mesh_to_plane(polydata, self.selected_normal, self.centroid)
-
- # Extract 2D coordinates
- self.project_tosketch_points = self.compute_2d_coordinates(projected_polydata, self.selected_normal)
-
- # Green indicator mesh needs to be translated to xy point paris start end.
- self.project_tosketch_lines = self.compute_2d_coordinates_line(projected_polydata, self.selected_normal)
-
- print("result", self.project_tosketch_lines)
- """# Seperately rotate selected edges for drawing
- self.project_tosketch_lines.clear()
- for vtk_line in self.selected_vtk_line:
- proj_vtk_line = self.compute_2d_coordinates_line(vtk_line, self.selected_normal)
- self.project_tosketch_lines.append(proj_vtk_line)
- print("outgoing lines", self.project_tosketch_lines)"""
-
- # Create a mapper and actor for the projected data
- mapper = vtk.vtkPolyDataMapper()
- mapper.SetInputData(projected_polydata)
-
- # Projected mesh in green
- actor = vtk.vtkActor()
- actor.SetMapper(mapper)
- #actor.GetProperty().SetRenderLinesAsTubes(True)
- actor.GetProperty().SetColor(0.0, 1.0, 0.0) # Set color to green
- actor.GetProperty().SetLineWidth(4) # Set line width
-
- self.renderer_indicators.AddActor(normal_actor)
- self.displayed_normal_actors.append(normal_actor)
-
- self.renderer_projections.AddActor(actor)
- self.projected_mesh_actors.append(actor)
-
- # Render the scene
- self.update_render()
- self.vtk_widget.GetRenderWindow().Render()
-
- def start(self):
- self.interactor.Initialize()
- self.interactor.Start()
-
-
-class MainWindow(QtWidgets.QMainWindow):
- def __init__(self, parent=None):
- super().__init__(parent)
- self.vtk_widget = VTKWidget()
- self.setCentralWidget(self.vtk_widget)
- self.setWindowTitle("VTK Mesh Viewer")
- self.vtk_widget.create_cube_mesh()
- self.show()
- self.vtk_widget.start()
-
-
-if __name__ == "__main__":
- app = QtWidgets.QApplication(sys.argv)
- window = MainWindow()
- sys.exit(app.exec())
diff --git a/drawing_modules/vtk_widget_alt_methods.py b/drawing_modules/vtk_widget_alt_methods.py
deleted file mode 100644
index c1e2fa8..0000000
--- a/drawing_modules/vtk_widget_alt_methods.py
+++ /dev/null
@@ -1,337 +0,0 @@
-def are_coplanar(self, normal1, normal2, point1, point2, tolerance=1e-6):
- # Check if normals are parallel
- if np.abs(np.dot(normal1, normal2)) < 1 - tolerance:
- return False
-
- # Check if points lie on the same plane
- diff = point2 - point1
- return np.abs(np.dot(diff, normal1)) < tolerance
-
-
-def merge_coplanar_triangles(self, polydata):
- # Compute normals
- normalGenerator = vtk.vtkPolyDataNormals()
- normalGenerator.SetInputData(polydata)
- normalGenerator.ComputePointNormalsOff()
- normalGenerator.ComputeCellNormalsOn()
- normalGenerator.Update()
-
- mesh = normalGenerator.GetOutput()
- n_cells = mesh.GetNumberOfCells()
-
- # Create a map to store merged triangles
- merged = {}
-
- for i in range(n_cells):
- if i in merged:
- continue
-
- cell = mesh.GetCell(i)
- normal = np.array(mesh.GetCellData().GetNormals().GetTuple(i))
- point = np.array(cell.GetPoints().GetPoint(0))
-
- merged[i] = [i]
-
- for j in range(i + 1, n_cells):
- if j in merged:
- continue
-
- cell_j = mesh.GetCell(j)
- normal_j = np.array(mesh.GetCellData().GetNormals().GetTuple(j))
- point_j = np.array(cell_j.GetPoints().GetPoint(0))
-
- if self.are_coplanar(normal, normal_j, point, point_j):
- merged[i].append(j)
-
- # Create new polygons
- new_polygons = vtk.vtkCellArray()
- for group in merged.values():
- if len(group) > 1:
- polygon = vtk.vtkPolygon()
- points = set()
- for idx in group:
- cell = mesh.GetCell(idx)
- for j in range(3):
- point_id = cell.GetPointId(j)
- points.add(point_id)
- polygon.GetPointIds().SetNumberOfIds(len(points))
- for j, point_id in enumerate(points):
- polygon.GetPointIds().SetId(j, point_id)
- new_polygons.InsertNextCell(polygon)
- else:
- new_polygons.InsertNextCell(mesh.GetCell(group[0]))
-
- # Create new polydata
- new_polydata = vtk.vtkPolyData()
- new_polydata.SetPoints(mesh.GetPoints())
- new_polydata.SetPolys(new_polygons)
-
- return new_polydata
-
-
-def create_cube_mesh(self):
- # cube_source = vtk.vtkSuperquadricSource()
-
- reader = vtk.vtkSTLReader()
- reader.SetFileName("case.stl") # Replace with your mesh file path
- reader.Update()
-
- featureEdges = vtk.vtkFeatureEdges()
- featureEdges.SetInputConnection(reader.GetOutputPort())
- featureEdges.BoundaryEdgesOn()
- featureEdges.FeatureEdgesOn()
- featureEdges.ManifoldEdgesOff()
- featureEdges.NonManifoldEdgesOff()
- featureEdges.Update()
-
- # print(cube_source)
- mapper = vtk.vtkPolyDataMapper()
- mapper.SetInputConnection(reader.GetOutputPort())
- actor = vtk.vtkActor()
- actor.SetMapper(mapper)
- self.renderer.AddActor(actor)
-
- mapper_edge = vtk.vtkPolyDataMapper()
- mapper_edge.SetInputConnection(featureEdges.GetOutputPort())
- actor = vtk.vtkActor()
- actor.SetMapper(mapper_edge)
- self.renderer.AddActor(actor)
-
-
-def simplify_mesh(self, input_mesh, target_reduction):
- # Create the quadric decimation filter
- decimate = vtk.vtkDecimatePro()
- decimate.SetInputData(input_mesh)
-
- # Set the reduction factor (0 to 1, where 1 means maximum reduction)
- decimate.SetTargetReduction(target_reduction)
-
- # Optional: Preserve topology (if needed)
- decimate.PreserveTopologyOn()
-
- # Perform the decimation
- decimate.Update()
-
- return decimate.GetOutput()
-
-
-def combine_coplanar_faces(self, input_polydata, tolerance=0.001):
- # Clean the polydata to merge duplicate points
- clean = vtk.vtkCleanPolyData()
- clean.SetInputData(input_polydata)
- clean.SetTolerance(tolerance)
- clean.Update()
-
- # Generate normals and merge coplanar polygons
- normals = vtk.vtkPolyDataNormals()
- normals.SetInputConnection(clean.GetOutputPort())
- normals.SplittingOff() # Disable splitting of sharp edges
- normals.ConsistencyOn() # Ensure consistent polygon ordering
- normals.AutoOrientNormalsOn() # Automatically orient normals
- normals.ComputePointNormalsOff() # We only need face normals
- normals.ComputeCellNormalsOn() # Compute cell normals
- normals.Update()
-
- return normals.GetOutput()
-
-
-def poisson_reconstruction(self, points):
- # Create a polydata object from points
- point_polydata = vtk.vtkPolyData()
- point_polydata.SetPoints(points)
-
- # Create a surface reconstruction filter
- surf = vtk.vtkSurfaceReconstructionFilter()
- surf.SetInputData(point_polydata)
- surf.Update()
-
- # Create a contour filter to extract the surface
- cf = vtk.vtkContourFilter()
- cf.SetInputConnection(surf.GetOutputPort())
- cf.SetValue(0, 0.0)
- cf.Update()
-
- # Reverse normals
- reverse = vtk.vtkReverseSense()
- reverse.SetInputConnection(cf.GetOutputPort())
- reverse.ReverseCellsOn()
- reverse.ReverseNormalsOn()
- reverse.Update()
-
- return reverse.GetOutput()
-
-
-def create_simplified_outline(self, polydata):
- featureEdges = vtk.vtkFeatureEdges()
- featureEdges.SetInputData(polydata)
- featureEdges.BoundaryEdgesOn()
- featureEdges.FeatureEdgesOn()
- featureEdges.ManifoldEdgesOff()
- featureEdges.NonManifoldEdgesOff()
- featureEdges.Update()
-
- """# 3. Clean the edges to merge duplicate points
- cleaner = vtk.vtkCleanPolyData()
- cleaner.SetInputConnection(feature_edges.GetOutputPort())
- cleaner.Update()
-
- # 4. Optional: Smooth the outline
- smooth = vtk.vtkSmoothPolyDataFilter()
- smooth.SetInputConnection(cleaner.GetOutputPort())
- smooth.SetNumberOfIterations(15)
- smooth.SetRelaxationFactor(0.1)
- smooth.FeatureEdgeSmoothingOff()
- smooth.BoundarySmoothingOn()
- smooth.Update()"""
-
- return featureEdges
-
-
-def render_from_points_direct_with_faces(self, vertices, faces):
- points = vtk.vtkPoints()
- for i in range(vertices.shape[0]):
- points.InsertNextPoint(vertices[i])
-
- # Create a vtkCellArray to store the triangles
- triangles = vtk.vtkCellArray()
- for i in range(faces.shape[0]):
- triangle = vtk.vtkTriangle()
- triangle.GetPointIds().SetId(0, faces[i, 0])
- triangle.GetPointIds().SetId(1, faces[i, 1])
- triangle.GetPointIds().SetId(2, faces[i, 2])
- triangles.InsertNextCell(triangle)
-
- """vtk_points = vtk.vtkPoints()
- for point in points:
- vtk_points.InsertNextPoint(point)
-
- # Create a vtkCellArray to store the triangles
- triangles = vtk.vtkCellArray()
-
- # Assuming points are organized as triplets forming triangles
- for i in range(0, len(points), 3):
- triangle = vtk.vtkTriangle()
- triangle.GetPointIds().SetId(0, i)
- triangle.GetPointIds().SetId(1, i + 1)
- triangle.GetPointIds().SetId(2, i + 2)
- triangles.InsertNextCell(triangle)"""
-
- # Create a polydata object
- polydata = vtk.vtkPolyData()
- polydata.SetPoints(points)
- polydata.SetPolys(triangles)
-
- # Calculate normals
- normalGenerator = vtk.vtkPolyDataNormals()
- normalGenerator.SetInputData(polydata)
- normalGenerator.ComputePointNormalsOn()
- normalGenerator.ComputeCellNormalsOn()
- normalGenerator.Update()
-
- self.cell_normals = vtk_to_numpy(normalGenerator.GetOutput().GetCellData().GetNormals())
-
- # merged_polydata = self.merge_coplanar_triangles(polydata)
-
- # Create a mapper and actor
- mapper = vtk.vtkPolyDataMapper()
- mapper.SetInputData(polydata)
-
- actor = vtk.vtkActor()
- actor.SetMapper(mapper)
- actor.GetProperty().SetColor(1, 1, 1) # Set color (white in this case)
- actor.GetProperty().EdgeVisibilityOn() # Show edges
- actor.GetProperty().SetLineWidth(2) # Set line width
-
- feature_edges = self.create_simplified_outline(polydata)
-
- # Create a mapper for the feature edges
- edge_mapper = vtk.vtkPolyDataMapper()
- # Already wiht output
- edge_mapper.SetInputConnection(feature_edges.GetOutputPort())
-
- # Create an actor for the feature edges
- edge_actor = vtk.vtkActor()
- edge_actor.SetMapper(edge_mapper)
-
- # Set the properties of the edge actor
- edge_actor.GetProperty().SetColor(1, 0, 0) # Set color (red in this case)
- edge_actor.GetProperty().SetLineWidth(2) # Set line width
-
- # Optionally, if you want to keep the original mesh visible:
- # (assuming you have the original mesh mapper and actor set up)
- self.renderer.AddActor(actor) # Add the original mesh actor
- # Add the edge actor to the renderer
- self.renderer.AddActor(edge_actor)
-
- # Force an update of the pipeline
- mapper.Update()
- self.vtk_widget.GetRenderWindow().Render()
-
- """# Print statistics
- print(f"Original points: {len(points)}")
- print(f"Number of triangles: {triangles.GetNumberOfCells()}")
- print(f"Final number of points: {normals.GetOutput().GetNumberOfPoints()}")
- print(f"Final number of cells: {normals.GetOutput().GetNumberOfCells()}")"""
-
-
-def render_from_points_direct(self, points):
- ### Rendermethod for SDF mesh (output)
- # Create a vtkPoints object and store the points in it
- vtk_points = vtk.vtkPoints()
- for point in points:
- vtk_points.InsertNextPoint(point)
-
- # Create a polydata object
- point_polydata = vtk.vtkPolyData()
- point_polydata.SetPoints(vtk_points)
-
- # Surface reconstruction
- surf = vtk.vtkSurfaceReconstructionFilter()
- surf.SetInputData(point_polydata)
- surf.Update()
-
- # Create a contour filter to extract the surface
- cf = vtk.vtkContourFilter()
- cf.SetInputConnection(surf.GetOutputPort())
- cf.SetValue(0, 0.0)
- cf.Update()
-
- # Reverse the normals
- reverse = vtk.vtkReverseSense()
- reverse.SetInputConnection(cf.GetOutputPort())
- reverse.ReverseCellsOn()
- reverse.ReverseNormalsOn()
- reverse.Update()
-
- # Get the reconstructed mesh
- reconstructed_mesh = reverse.GetOutput()
-
- """# Simplify the mesh
- target_reduction = 1 # Adjust this value as needed
- simplified_mesh = self.simplify_mesh(reconstructed_mesh, target_reduction)
-
- combinded_faces = self.combine_coplanar_faces(simplified_mesh, 0.001)"""
-
- # Create a mapper and actor for the simplified mesh
- mapper = vtk.vtkPolyDataMapper()
- mapper.SetInputData(reconstructed_mesh)
-
- actor = vtk.vtkActor()
- actor.SetMapper(mapper)
- actor.GetProperty().SetColor(1, 1, 1) # Set color (white in this case)
- actor.GetProperty().EdgeVisibilityOn() # Show edges
- actor.GetProperty().SetLineWidth(2) # Set line width
-
- # Add the actor to the renderer
- self.renderer.AddActor(actor)
-
- # Force an update of the pipeline
- # mapper.Update()
- self.vtk_widget.GetRenderWindow().Render()
-
- # Print statistics
- print(f"Original points: {len(points)}")
- print(
- f"Reconstructed mesh: {reconstructed_mesh.GetNumberOfPoints()} points, {reconstructed_mesh.GetNumberOfCells()} cells")
- """print(
- f"Simplified mesh: {simplified_mesh.GetNumberOfPoints()} points, {simplified_mesh.GetNumberOfCells()} cells")"""
\ No newline at end of file
diff --git a/drawing_modules/vysta_widget.py b/drawing_modules/vysta_widget.py
deleted file mode 100644
index c3f8d51..0000000
--- a/drawing_modules/vysta_widget.py
+++ /dev/null
@@ -1,111 +0,0 @@
-import sys
-
-import numpy as np
-import pyvista as pv
-from pyvista.plotting.opts import ElementType
-from pyvistaqt import QtInteractor
-from PySide6.QtWidgets import QApplication, QMainWindow, QVBoxLayout, QWidget
-
-
-class PyVistaWidget(QWidget):
- def __init__(self, parent=None):
- super().__init__(parent)
-
- # Create the PyVista plotter
- self.plotter = QtInteractor(self)
- self.plotter.background_color = "darkgray"
-
- # Create a layout and add the PyVista widget
- layout = QVBoxLayout()
- layout.addWidget(self.plotter.interactor)
- self.setLayout(layout)
-
- # Set up the picker
- #self.plotter.enable_cell_picking(callback=self.on_cell_pick, show=True)
- self.plotter.enable_element_picking(callback=self.on_cell_pick, show=True, mode="face", left_clicking=True)
-
- def on_cell_pick(self, element):
- if element is not None:
- mesh = self.plotter.mesh # Get the current mesh
- print(mesh)
- print(element)
-
- """# Get the face data
- face = mesh.extract_cells(element)
-
- # Compute face normal
- face.compute_normals(cell_normals=True, inplace=True)
- normal = face.cell_data['Normals'][0]
-
- # Get the points of the face
- points = face.points
-
- print(f"Picked face ID: {face_id}")
- print(f"Face normal: {normal}")
- print("Face points:")
- for point in points:
- print(point)"""
- else:
- print("No face was picked or the picked element is not a face.")
- def create_simplified_outline(self, mesh, camera):
- # Project 3D to 2D
- points_2d = self.plotter.map_to_2d(mesh.points)
-
- # Detect silhouette edges (simplified approach)
- edges = mesh.extract_feature_edges(feature_angle=90, boundary_edges=False, non_manifold_edges=False)
-
- # Project edges to 2D
- edge_points_2d = self.plotter.map_to_2d(edges.points)
-
- # Create 2D outline
- self.plotter.add_lines(edge_points_2d, color='black', width=2)
- self.plotter.render()
-
- def mesh_from_points(self, points):
- # Convert points to numpy array if not already
- points = np.array(points)
-
- # Create faces array
- num_triangles = len(points) // 3
- faces = np.arange(len(points)).reshape(num_triangles, 3)
- faces = np.column_stack((np.full(num_triangles, 3), faces)) # Add 3 as first column
-
- # Create PyVista PolyData
- mesh = pv.PolyData(points, faces)
-
- # Optional: Merge duplicate points
- mesh = mesh.clean()
-
- # Optional: Compute normals
- mesh = mesh.compute_normals(point_normals=False, cell_normals=True, consistent_normals=True)
- edges = mesh.extract_feature_edges(30, non_manifold_edges=False)
-
- # Clear any existing meshes
- self.plotter.clear()
-
- # Add the mesh to the plotter
- self.plotter.add_mesh(mesh, pickable=True, color='white', show_edges=True, line_width=2, pbr=True, metallic=0.8, roughness=0.1, diffuse=1)
- self.plotter.add_mesh(edges, color="red", line_width=10)
-
- # Reset the camera to fit the new mesh
- self.plotter.reset_camera()
-
- # Update the render window
- self.plotter.update()
-
- # Print statistics
- print(f"Original points: {len(points)}")
- print(f"Number of triangles: {num_triangles}")
- print(f"Final number of points: {mesh.n_points}")
- print(f"Final number of cells: {mesh.n_cells}")
-
-
-class MainWindow(QMainWindow):
- def __init__(self):
- super().__init__()
- self.setWindowTitle("PyVista in PySide6")
- self.setGeometry(100, 100, 800, 600)
-
-
-
-
diff --git a/main.py b/main.py
deleted file mode 100644
index 7ad6956..0000000
--- a/main.py
+++ /dev/null
@@ -1,1478 +0,0 @@
-# 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()
-
-
diff --git a/main_backup.py b/main_backup.py
deleted file mode 100644
index fb03d04..0000000
--- a/main_backup.py
+++ /dev/null
@@ -1,851 +0,0 @@
-# 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.draw_widget_solve import SketchWidget
-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 = SketchWidget()
- 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
- self.ui.pb_linetool.clicked.connect(self.sketchWidget.act_line_mode)
- self.ui.pb_con_ptpt.clicked.connect(self.sketchWidget.act_constrain_pt_pt_mode)
- self.ui.pb_con_line.clicked.connect(self.sketchWidget.act_constrain_pt_line_mode)
- self.ui.pb_con_horiz.clicked.connect(self.sketchWidget.act_constrain_horiz_line_mode)
- self.ui.pb_con_vert.clicked.connect(self.sketchWidget.act_constrain_vert_line_mode)
- self.ui.pb_con_dist.clicked.connect(self.sketchWidget.act_constrain_distance_mode)
- self.ui.pb_con_mid.clicked.connect(self.sketchWidget.act_constrain_mid_point_mode)
-
- ### 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)
-
- self.sketchWidget.constrain_done.connect(self.draw_op_complete)
- 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(self.sketchWidget.on_construct_change)
- self.project = Project()
- self.new_project()
-
- ### SNAPS
-
- self.ui.pb_snap_midp.toggled.connect(lambda checked: self.sketchWidget.on_snap_mode_change("mpoint", checked))
- self.ui.pb_snap_horiz.toggled.connect(lambda checked: self.sketchWidget.on_snap_mode_change("horiz", checked))
- self.ui.pb_snap_vert.toggled.connect(lambda checked: self.sketchWidget.on_snap_mode_change("vert", checked))
- self.ui.pb_snap_angle.toggled.connect(lambda checked: self.sketchWidget.on_snap_mode_change("angle", checked))
- self.ui.pb_enable_snap.toggled.connect(lambda checked: self.sketchWidget.on_snap_mode_change("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
-
- #Get parameters
- points = [point for point in sketch_from_widget.points if hasattr(point, 'is_helper') and not point.is_helper]
-
- sketch.convert_points_for_sdf(points)
- 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.line_mode = False
-
- 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 the line modes
- self.ui.pb_linetool.setChecked(False)
- self.ui.pb_con_ptpt.setChecked(False)
- self.ui.pb_con_line.setChecked(False)
- self.ui.pb_con_dist.setChecked(False)
- self.ui.pb_con_mid.setChecked(False)
- self.ui.pb_con_perp.setChecked(False)
-
- self.sketchWidget.mouse_mode = None
- self.sketchWidget.reset_buffers()
-
- 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
-
- # detect loop that causes problems in mesh generation
- if points[-1] == points[0]:
- print("overlap")
- del points[-1]
-
- 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 point.is_helper is False:
- print("point", point)
- points_for_sdf.append(self.translate_points_tup(point.ui_point))
-
- self.sdf_points = points_for_sdf
-
- 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 point_to_poly in input_lines:
- from_coord_start = window.sketchWidget.from_quadrant_coords_no_center(point_to_poly.crd1.ui_point)
- from_coord_end = window.sketchWidget.from_quadrant_coords_no_center(point_to_poly.crd2.ui_point)
- start_draw = self.translate_points_tup(from_coord_start)
- end_draw = self.translate_points_tup(from_coord_end)
- line = start_draw, end_draw
- points_for_interact.append(line)
-
- 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()
-
-
diff --git a/mesh_modules/3d_placeholder_model.stl b/mesh_modules/3d_placeholder_model.stl
deleted file mode 100644
index 97cf189..0000000
Binary files a/mesh_modules/3d_placeholder_model.stl and /dev/null differ
diff --git a/mesh_modules/__pycache__/interactor_mesh.cpython-311.pyc b/mesh_modules/__pycache__/interactor_mesh.cpython-311.pyc
deleted file mode 100644
index 0fe3c46..0000000
Binary files a/mesh_modules/__pycache__/interactor_mesh.cpython-311.pyc and /dev/null differ
diff --git a/mesh_modules/__pycache__/interactor_mesh.cpython-312.pyc b/mesh_modules/__pycache__/interactor_mesh.cpython-312.pyc
deleted file mode 100644
index ec12602..0000000
Binary files a/mesh_modules/__pycache__/interactor_mesh.cpython-312.pyc and /dev/null differ
diff --git a/mesh_modules/__pycache__/simple_mesh.cpython-311.pyc b/mesh_modules/__pycache__/simple_mesh.cpython-311.pyc
deleted file mode 100644
index b2e606b..0000000
Binary files a/mesh_modules/__pycache__/simple_mesh.cpython-311.pyc and /dev/null differ
diff --git a/mesh_modules/__pycache__/simple_mesh.cpython-312.pyc b/mesh_modules/__pycache__/simple_mesh.cpython-312.pyc
deleted file mode 100644
index 0f2438d..0000000
Binary files a/mesh_modules/__pycache__/simple_mesh.cpython-312.pyc and /dev/null differ
diff --git a/mesh_modules/__pycache__/vesta_mesh.cpython-311.pyc b/mesh_modules/__pycache__/vesta_mesh.cpython-311.pyc
deleted file mode 100644
index a1f9f6f..0000000
Binary files a/mesh_modules/__pycache__/vesta_mesh.cpython-311.pyc and /dev/null differ
diff --git a/mesh_modules/__pycache__/vesta_mesh.cpython-312.pyc b/mesh_modules/__pycache__/vesta_mesh.cpython-312.pyc
deleted file mode 100644
index 2a945ef..0000000
Binary files a/mesh_modules/__pycache__/vesta_mesh.cpython-312.pyc and /dev/null differ
diff --git a/mesh_modules/interactor_mesh.py b/mesh_modules/interactor_mesh.py
deleted file mode 100644
index e73f46d..0000000
--- a/mesh_modules/interactor_mesh.py
+++ /dev/null
@@ -1,43 +0,0 @@
-# Draw simple boundary based on the lines and depth
-
-def generate_mesh(lines: list, z_origin: float, depth: float, invert: bool = False):
-
- origin = create_3D(lines, z_origin)
-
- if invert :
- extruded = create_3D(lines, z_origin - depth)
- else:
- extruded = create_3D(lines, z_origin + depth)
-
- vert_lines = create_vert_lines(origin, extruded)
-
- print(f"Result = {origin} / {extruded} / {vert_lines}")
-
- return origin + vert_lines + extruded
-
-
-def create_vert_lines(origin, extruded):
- vert_lines = []
- for d3_point_o, d3point_e in zip(origin, extruded):
- for sp3d_1, sp3d_2 in zip(d3_point_o, d3point_e):
- new_line = sp3d_1, sp3d_2
- vert_lines.append(new_line)
- return vert_lines
-
-
-def create_3D(lines, z_pos):
- line_loop = []
- for coordinate2d in lines:
- start, end = coordinate2d
-
- xs, ys = start
- coordinate3d_start_orig = xs, ys, z_pos
-
- xe, ye = end
- coordinate3d_end_orig = xe, ye, z_pos
-
- line3d_orig = coordinate3d_start_orig, coordinate3d_end_orig
-
- line_loop.append(line3d_orig)
-
- return line_loop
diff --git a/mesh_modules/simple_mesh.py b/mesh_modules/simple_mesh.py
deleted file mode 100644
index 2f4f027..0000000
--- a/mesh_modules/simple_mesh.py
+++ /dev/null
@@ -1,213 +0,0 @@
-import numpy as np
-from scipy.spatial import Delaunay, ConvexHull
-#from shapely.geometry import Polygon, Point
-
-
-def alpha_shape(points, alpha):
- """
- Compute the alpha shape (concave hull) of a set of points.
- """
-
- def add_edge(edges, edge_points, points, i, j):
- """Add a line between the i-th and j-th points if not in the list already"""
- if (i, j) in edges or (j, i) in edges:
- return
- edges.add((i, j))
- edge_points.append(points[[i, j]])
-
- tri = Delaunay(points)
- edges = set()
- edge_points = []
-
- # Loop over triangles:
- for ia, ib, ic in tri.simplices:
- pa = points[ia]
- pb = points[ib]
- pc = points[ic]
- # Lengths of sides of triangle
- a = np.sqrt((pa[0] - pb[0]) ** 2 + (pa[1] - pb[1]) ** 2)
- b = np.sqrt((pb[0] - pc[0]) ** 2 + (pb[1] - pc[1]) ** 2)
- c = np.sqrt((pc[0] - pa[0]) ** 2 + (pc[1] - pa[1]) ** 2)
- # Semiperimeter of triangle
- s = (a + b + c) / 2.0
- # Area of triangle by Heron's formula
- area = np.sqrt(s * (s - a) * (s - b) * (s - c))
- circum_r = a * b * c / (4.0 * area)
- # Here's the radius filter.
- if circum_r < 1.0 / alpha:
- add_edge(edges, edge_points, points, ia, ib)
- add_edge(edges, edge_points, points, ib, ic)
- add_edge(edges, edge_points, points, ic, ia)
-
- m = np.array(edge_points)
- return m
-
-
-def generate_mesh(points, depth, alpha=0.1):
- """
- Generate a mesh by extruding a 2D shape along the Z-axis, automatically detecting holes.
-
- :param points: List of (x, y) tuples representing all points of the 2D shape, including potential holes.
- :param depth: Extrusion depth along the Z-axis.
- :param alpha: Alpha value for the alpha shape algorithm (controls the "tightness" of the boundary).
- :return: Tuple of vertices and faces.
- """
- # Convert points to a numpy array
- points_2d = np.array(points)
-
- # Compute the alpha shape (outer boundary)
- boundary_edges = alpha_shape(points_2d, alpha)
-
- # Create a Polygon from the boundary
- boundary_polygon = Polygon(boundary_edges)
-
- # Separate points into boundary and interior
- boundary_points = []
- interior_points = []
- for point in points:
- if Point(point).touches(boundary_polygon) or Point(point).within(boundary_polygon):
- if Point(point).touches(boundary_polygon):
- boundary_points.append(point)
- else:
- interior_points.append(point)
-
- # Perform Delaunay triangulation on all points
- tri = Delaunay(points_2d)
-
- # Generate the top and bottom faces
- bottom_face = np.hstack((tri.points, np.zeros((tri.points.shape[0], 1))))
- top_face = np.hstack((tri.points, np.ones((tri.points.shape[0], 1)) * depth))
-
- # Combine top and bottom vertices
- vertices_array = np.vstack((bottom_face, top_face))
-
- # Create faces
- faces = []
-
- # Bottom face triangulation
- for simplex in tri.simplices:
- faces.append(simplex.tolist())
-
- # Top face triangulation (with an offset)
- top_offset = len(tri.points)
- for simplex in tri.simplices:
- faces.append([i + top_offset for i in simplex])
-
- # Side faces for the outer boundary
- for i in range(len(boundary_points)):
- next_i = (i + 1) % len(boundary_points)
- current = points.index(boundary_points[i])
- next_point = points.index(boundary_points[next_i])
- faces.append([current, top_offset + current, top_offset + next_point])
- faces.append([current, top_offset + next_point, next_point])
-
- # Convert vertices to the desired format: list of tuples
- vertices = [tuple(vertex) for vertex in vertices_array]
-
- return vertices, faces
-
-def generate_mesh_wholes(points, holes, depth):
- """
- Generate a mesh by extruding a 2D shape along the Z-axis, including holes.
-
- :param points: List of (x, y) tuples representing the outer boundary of the 2D shape.
- :param holes: List of lists, where each inner list contains (x, y) tuples representing a hole.
- :param depth: Extrusion depth along the Z-axis.
- :return: Tuple of vertices and faces.
- """
- # Convert points to a numpy array
- points_2d = np.array(points)
-
- # Prepare points for triangulation
- triangulation_points = points_2d.tolist()
- for hole in holes:
- triangulation_points.extend(hole)
-
- # Perform Delaunay triangulation
- tri = Delaunay(np.array(triangulation_points))
-
- # Generate the top and bottom faces
- bottom_face = np.hstack((tri.points, np.zeros((tri.points.shape[0], 1))))
- top_face = np.hstack((tri.points, np.ones((tri.points.shape[0], 1)) * depth))
-
- # Combine top and bottom vertices
- vertices_array = np.vstack((bottom_face, top_face))
-
- # Create faces
- faces = []
-
- # Bottom face triangulation
- for simplex in tri.simplices:
- faces.append(simplex.tolist())
-
- # Top face triangulation (with an offset)
- top_offset = len(tri.points)
- for simplex in tri.simplices:
- faces.append([i + top_offset for i in simplex])
-
- # Side faces
- for i in range(len(points)):
- next_i = (i + 1) % len(points)
- faces.append([i, top_offset + i, top_offset + next_i])
- faces.append([i, top_offset + next_i, next_i])
-
- # Side faces for holes
- start_index = len(points)
- for hole in holes:
- for i in range(len(hole)):
- current = start_index + i
- next_i = start_index + (i + 1) % len(hole)
- faces.append([current, top_offset + next_i, top_offset + current])
- faces.append([current, next_i, top_offset + next_i])
- start_index += len(hole)
-
- # Convert vertices to the desired format: list of tuples
- vertices = [tuple(vertex) for vertex in vertices_array]
-
- return vertices, faces
-
-def generate_mesh_simple(points, depth):
- """
- Generate a mesh by extruding a 2D shape along the Z-axis.
-
- :param points: List of (x, y) tuples representing the 2D shape.
- :param depth: Extrusion depth along the Z-axis.
- :return: Tuple of vertices and faces.
- """
- # Convert points to a numpy array
- points_2d = np.array(points)
-
- # Get the convex hull of the points to ensure they form a proper polygon
- hull = ConvexHull(points_2d)
- hull_points = points_2d[hull.vertices]
-
- # Generate the top and bottom faces
- bottom_face = np.hstack((hull_points, np.zeros((hull_points.shape[0], 1))))
- top_face = np.hstack((hull_points, np.ones((hull_points.shape[0], 1)) * depth))
-
- # Combine top and bottom vertices
- vertices_array = np.vstack((bottom_face, top_face))
-
- # Create faces
- faces = []
-
- # Bottom face triangulation (counter-clockwise)
- for i in range(len(hull_points) - 2):
- faces.append([0, i + 2, i + 1])
-
- # Top face triangulation (counter-clockwise, with an offset)
- top_offset = len(hull_points)
- for i in range(len(hull_points) - 2):
- faces.append([top_offset, top_offset + i + 1, top_offset + i + 2])
-
- # Side faces (ensure counter-clockwise order)
- for i in range(len(hull_points)):
- next_i = (i + 1) % len(hull_points)
- faces.append([i, top_offset + i, top_offset + next_i])
- faces.append([i, top_offset + next_i, next_i])
-
- # Convert vertices to the desired format: list of tuples
- vertices = [tuple(vertex) for vertex in vertices_array]
-
- return vertices, faces
-
diff --git a/mesh_modules/vesta_mesh.py b/mesh_modules/vesta_mesh.py
deleted file mode 100644
index 7116742..0000000
--- a/mesh_modules/vesta_mesh.py
+++ /dev/null
@@ -1,119 +0,0 @@
-import numpy as np
-from skimage import measure
-import multiprocessing
-from functools import partial
-from multiprocessing.pool import ThreadPool
-import itertools
-import time
-
-
-def _cartesian_product(*arrays):
- la = len(arrays)
- dtype = np.result_type(*arrays)
- arr = np.empty([len(a) for a in arrays] + [la], dtype=dtype)
- for i, a in enumerate(np.ix_(*arrays)):
- arr[..., i] = a
- return arr.reshape(-1, la)
-
-
-class VESTA:
- def __init__(self, sdf, bounds=None, resolution=64, threshold=0.0, workers=None):
- self.sdf = sdf
- self.bounds = bounds
- self.resolution = resolution
- self.threshold = threshold
- self.workers = workers or multiprocessing.cpu_count()
-
- def _estimate_bounds(self):
- s = 16
- x0 = y0 = z0 = -1e9
- x1 = y1 = z1 = 1e9
- prev = None
- for i in range(32):
- X = np.linspace(x0, x1, s)
- Y = np.linspace(y0, y1, s)
- Z = np.linspace(z0, z1, s)
- d = np.array([X[1] - X[0], Y[1] - Y[0], Z[1] - Z[0]])
- threshold = np.linalg.norm(d) / 2
- if threshold == prev:
- break
- prev = threshold
- P = _cartesian_product(X, Y, Z)
- volume = self.sdf(P).reshape((len(X), len(Y), len(Z)))
- where = np.argwhere(np.abs(volume) <= threshold)
- if where.size == 0:
- continue
- x1, y1, z1 = (x0, y0, z0) + where.max(axis=0) * d + d / 2
- x0, y0, z0 = (x0, y0, z0) + where.min(axis=0) * d - d / 2
- if prev is None:
- raise ValueError("Failed to estimate bounds. No points found within any threshold.")
- return ((x0, y0, z0), (x1, y1, z1))
-
- def _vesta_worker(self, chunk):
- x0, x1, y0, y1, z0, z1 = chunk
- X = np.linspace(x0, x1, self.resolution)
- Y = np.linspace(y0, y1, self.resolution)
- Z = np.linspace(z0, z1, self.resolution)
- P = _cartesian_product(X, Y, Z)
- V = self.sdf(P).reshape((self.resolution, self.resolution, self.resolution))
-
- try:
- verts, faces, _, _ = measure.marching_cubes(V, self.threshold)
- except RuntimeError:
- # Return empty arrays if marching_cubes fails
- return np.array([]), np.array([])
-
- # Scale and translate vertices to match the chunk's bounds
- verts = verts / (self.resolution - 1)
- verts[:, 0] = verts[:, 0] * (x1 - x0) + x0
- verts[:, 1] = verts[:, 1] * (y1 - y0) + y0
- verts[:, 2] = verts[:, 2] * (z1 - z0) + z0
-
- return verts, faces
-
- def _merge_meshes(self, results):
- all_verts = []
- all_faces = []
- offset = 0
- for verts, faces in results:
- if len(verts) > 0 and len(faces) > 0:
- all_verts.append(verts)
- all_faces.append(faces + offset)
- offset += len(verts)
- if not all_verts or not all_faces:
- return np.array([]), np.array([])
- return np.vstack(all_verts), np.vstack(all_faces)
-
- def generate_mesh(self):
- if self.bounds is None:
- self.bounds = self._estimate_bounds()
-
- (x0, y0, z0), (x1, y1, z1) = self.bounds
- chunks = [
- (x0, x1, y0, y1, z0, z1)
- ]
-
- with ThreadPool(self.workers) as pool:
- results = pool.map(self._vesta_worker, chunks)
-
- verts, faces = self._merge_meshes(results)
- return verts, faces
-
-
-def generate_mesh_from_sdf(sdf, bounds=None, resolution=64, threshold=0.0, workers=None):
- vesta = VESTA(sdf, bounds, resolution, threshold, workers)
- return vesta.generate_mesh()
-
-
-# Helper function to save the mesh as an STL file
-def save_mesh_as_stl(vertices, faces, filename):
- from stl import mesh
-
- # Create the mesh
- cube = mesh.Mesh(np.zeros(faces.shape[0], dtype=mesh.Mesh.dtype))
- for i, f in enumerate(faces):
- for j in range(3):
- cube.vectors[i][j] = vertices[f[j], :]
-
- # Write the mesh to file
- cube.save(filename)
\ No newline at end of file
diff --git a/meshtest.py b/meshtest.py
deleted file mode 100644
index eb1eaea..0000000
--- a/meshtest.py
+++ /dev/null
@@ -1,5 +0,0 @@
-from sdf import *
-f = box(1).translate((1,1,-0.2))
-c = hexagon(1).extrude(1).orient([0,0,-1])
-c = f & c
-f.save("out.stl")
\ No newline at end of file
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..6e30e10
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,72 @@
+[build-system]
+requires = ["setuptools>=61.0", "wheel"]
+build-backend = "setuptools.build_meta"
+
+[project]
+name = "fluency-cad"
+version = "2.0.0"
+description = "Parametric CAD application with OpenCASCADE geometry kernel"
+readme = "README.md"
+license = {text = "MIT"}
+requires-python = ">=3.10"
+authors = [
+ {name = "Fluency CAD Team"}
+]
+keywords = ["cad", "parametric", "opencascade", "3d-modeling"]
+classifiers = [
+ "Development Status :: 4 - Beta",
+ "Intended Audience :: Developers",
+ "Intended Audience :: End Users/Desktop",
+ "License :: OSI Approved :: MIT License",
+ "Programming Language :: Python :: 3",
+ "Programming Language :: Python :: 3.10",
+ "Programming Language :: Python :: 3.11",
+ "Programming Language :: Python :: 3.12",
+ "Topic :: Scientific/Engineering :: CAD",
+]
+
+dependencies = [
+ "cadquery>=2.4",
+ "ocp>=7.9.3",
+ "pygfx>=0.7.0",
+ "wgpu>=0.19.0",
+ "PySide6>=6.9.0",
+ "numpy>=2.2.0",
+ "scipy>=1.15.0",
+]
+
+[project.optional-dependencies]
+dev = [
+ "pytest>=8.0",
+ "black>=24.0",
+ "mypy>=1.8",
+ "ruff>=0.4.0",
+]
+
+[project.scripts]
+fluency-cad = "fluency.main:main"
+
+[project.urls]
+Homepage = "https://github.com/fluency-cad/fluency"
+Documentation = "https://github.com/fluency-cad/fluency#readme"
+Repository = "https://github.com/fluency-cad/fluency"
+
+[tool.setuptools.packages.find]
+where = ["src"]
+
+[tool.setuptools.package-data]
+fluency = ["py.typed", "*.pyi"]
+
+[tool.black]
+line-length = 100
+target-version = ["py310", "py311", "py312"]
+
+[tool.ruff]
+line-length = 100
+target-version = "py310"
+
+[tool.mypy]
+python_version = "3.10"
+warn_return_any = true
+warn_unused_configs = true
+disallow_untyped_defs = true
diff --git a/requirements.txt b/requirements.txt
deleted file mode 100644
index 959becb..0000000
--- a/requirements.txt
+++ /dev/null
@@ -1,61 +0,0 @@
-asttokens==3.0.0
-attrs==25.3.0
-black==24.10.0
-click==8.2.1
-contourpy==1.3.2
-cycler==0.12.1
-decorator==5.2.1
-executing==2.2.0
-flexcache==0.3
-flexparser==0.4
-fonttools==4.58.1
-h5py==3.13.0
-imageio==2.37.0
-ipython==9.3.0
-ipython_pygments_lexers==1.1.1
-jedi==0.19.2
-kiwisolver==1.4.8
-lazy_loader==0.4
-markdown-it-py==3.0.0
-matplotlib==3.10.3
-matplotlib-inline==0.1.7
-mdurl==0.1.2
-meshio==5.3.5
-mypy_extensions==1.1.0
-names==0.3.0
-networkx==3.5
-Nuitka==2.7.10
-numpy==2.2.6
-ordered-set==4.1.0
-packaging==25.0
-parso==0.8.4
-pathspec==0.12.1
-pexpect==4.9.0
-pillow==11.2.1
-Pint==0.24.4
-platformdirs==4.3.8
-prompt_toolkit==3.0.51
-ptyprocess==0.7.0
-pure_eval==0.2.3
-Pygments==2.19.1
-pyparsing==3.2.3
-PySide6==6.9.0
-PySide6_Addons==6.9.0
-PySide6_Essentials==6.9.0
-python-dateutil==2.9.0.post0
-python_solvespace==3.0.8
-rich==13.9.4
-scikit-image==0.25.2
-scipy==1.15.3
-sdfcad @ git+https://gitlab.com/nobodyinperson/sdfCAD@42505b5181c88dda2fd66ac9d387533fbe4145f3
-shiboken6==6.9.0
-six==1.17.0
-stack-data==0.6.3
-tifffile==2025.5.26
-tokenize_rt==6.2.0
-traitlets==5.14.3
-typing_extensions==4.13.2
-vtk==9.4.2
-wcwidth==0.2.13
-xlrd==2.0.2
-zstandard==0.23.0
diff --git a/sdf/__init__.py b/sdf/__init__.py
deleted file mode 100644
index 9a544f5..0000000
--- a/sdf/__init__.py
+++ /dev/null
@@ -1,26 +0,0 @@
-from . import d2, d3, ease
-
-from .util import *
-from .units import units
-
-from .d2 import *
-
-from .d3 import *
-
-from .text import (
- measure_image,
- measure_text,
- image,
- text,
-)
-
-from .mesh import (
- generate,
- save,
- sample_slice,
- show_slice,
-)
-
-from .stl import (
- write_binary_stl,
-)
diff --git a/sdf/__pycache__/__init__.cpython-312.pyc b/sdf/__pycache__/__init__.cpython-312.pyc
deleted file mode 100644
index 1a14097..0000000
Binary files a/sdf/__pycache__/__init__.cpython-312.pyc and /dev/null differ
diff --git a/sdf/__pycache__/d2.cpython-312.pyc b/sdf/__pycache__/d2.cpython-312.pyc
deleted file mode 100644
index 7167195..0000000
Binary files a/sdf/__pycache__/d2.cpython-312.pyc and /dev/null differ
diff --git a/sdf/__pycache__/d3.cpython-312.pyc b/sdf/__pycache__/d3.cpython-312.pyc
deleted file mode 100644
index c029807..0000000
Binary files a/sdf/__pycache__/d3.cpython-312.pyc and /dev/null differ
diff --git a/sdf/__pycache__/dn.cpython-312.pyc b/sdf/__pycache__/dn.cpython-312.pyc
deleted file mode 100644
index 444ec8a..0000000
Binary files a/sdf/__pycache__/dn.cpython-312.pyc and /dev/null differ
diff --git a/sdf/__pycache__/ease.cpython-312.pyc b/sdf/__pycache__/ease.cpython-312.pyc
deleted file mode 100644
index c505f5c..0000000
Binary files a/sdf/__pycache__/ease.cpython-312.pyc and /dev/null differ
diff --git a/sdf/__pycache__/errors.cpython-312.pyc b/sdf/__pycache__/errors.cpython-312.pyc
deleted file mode 100644
index 4c57edd..0000000
Binary files a/sdf/__pycache__/errors.cpython-312.pyc and /dev/null differ
diff --git a/sdf/__pycache__/mesh.cpython-312.pyc b/sdf/__pycache__/mesh.cpython-312.pyc
deleted file mode 100644
index 4ea5ede..0000000
Binary files a/sdf/__pycache__/mesh.cpython-312.pyc and /dev/null differ
diff --git a/sdf/__pycache__/progress.cpython-312.pyc b/sdf/__pycache__/progress.cpython-312.pyc
deleted file mode 100644
index 6aade2f..0000000
Binary files a/sdf/__pycache__/progress.cpython-312.pyc and /dev/null differ
diff --git a/sdf/__pycache__/stl.cpython-312.pyc b/sdf/__pycache__/stl.cpython-312.pyc
deleted file mode 100644
index 1c68abb..0000000
Binary files a/sdf/__pycache__/stl.cpython-312.pyc and /dev/null differ
diff --git a/sdf/__pycache__/text.cpython-312.pyc b/sdf/__pycache__/text.cpython-312.pyc
deleted file mode 100644
index be3fd9f..0000000
Binary files a/sdf/__pycache__/text.cpython-312.pyc and /dev/null differ
diff --git a/sdf/__pycache__/units.cpython-312.pyc b/sdf/__pycache__/units.cpython-312.pyc
deleted file mode 100644
index c6bd860..0000000
Binary files a/sdf/__pycache__/units.cpython-312.pyc and /dev/null differ
diff --git a/sdf/__pycache__/util.cpython-312.pyc b/sdf/__pycache__/util.cpython-312.pyc
deleted file mode 100644
index 6210857..0000000
Binary files a/sdf/__pycache__/util.cpython-312.pyc and /dev/null differ
diff --git a/sdf/d2.py b/sdf/d2.py
deleted file mode 100644
index 283b7c9..0000000
--- a/sdf/d2.py
+++ /dev/null
@@ -1,390 +0,0 @@
-import functools
-import numpy as np
-import operator
-import copy
-
-from . import dn, d3, ease
-
-# Constants
-
-ORIGIN = np.array((0, 0))
-
-X = np.array((1, 0))
-Y = np.array((0, 1))
-
-UP = Y
-
-# SDF Class
-
-_ops = {}
-
-
-class SDF2:
- def __init__(self, f):
- self.f = f
-
- def __call__(self, p):
- return self.f(p).reshape((-1, 1))
-
- def __getattr__(self, name):
- if name in _ops:
- f = _ops[name]
- return functools.partial(f, self)
- raise AttributeError
-
- def __or__(self, other):
- return union(self, other)
-
- def __and__(self, other):
- return intersection(self, other)
-
- def __sub__(self, other):
- return difference(self, other)
-
- def fillet(self, r):
- newSelf = copy.deepcopy(self)
- newSelf._r = r
- return newSelf
-
- def radius(self, *args, **kwargs):
- return self.fillet(*args, **kwargs)
-
- def k(self, *args, **kwargs):
- return self.fillet(*args, **kwargs)
-
- def r(self, *args, **kwargs):
- return self.fillet(*args, **kwargs)
-
- def chamfer(self, c):
- newSelf = copy.deepcopy(self)
- newSelf._c = c
- return newSelf
-
- def c(self, *args, **kwargs):
- return self.chamfer(*args, **kwargs)
-
-
-def sdf2(f):
- @functools.wraps(f)
- def wrapper(*args, **kwargs):
- return SDF2(f(*args, **kwargs))
-
- return wrapper
-
-
-def op2(f):
- @functools.wraps(f)
- def wrapper(*args, **kwargs):
- return SDF2(f(*args, **kwargs))
-
- _ops[f.__name__] = wrapper
- return wrapper
-
-
-def op23(f):
- @functools.wraps(f)
- def wrapper(*args, **kwargs):
- return d3.SDF3(f(*args, **kwargs))
-
- _ops[f.__name__] = wrapper
- return wrapper
-
-
-# Helpers
-
-
-def _length(a):
- return np.linalg.norm(a, axis=1)
-
-
-def _normalize(a):
- return a / np.linalg.norm(a)
-
-
-def _dot(a, b):
- return np.sum(a * b, axis=1)
-
-
-def _vec(*arrs):
- return np.stack(arrs, axis=-1)
-
-
-_min = np.minimum
-_max = np.maximum
-
-# Primitives
-
-
-@sdf2
-def circle(radius=None, diameter=None, center=ORIGIN):
- if (radius is not None) == (diameter is not None):
- raise ValueError(f"Specify either radius or diameter")
- if radius is None:
- radius = diameter / 2
-
- def f(p):
- return _length(p - center) - radius
-
- return f
-
-
-@sdf2
-def line(normal=UP, point=ORIGIN):
- normal = _normalize(normal)
-
- def f(p):
- return np.dot(point - p, normal)
-
- return f
-
-
-@sdf2
-def slab(x0=None, y0=None, x1=None, y1=None, r=None):
- fs = []
- if x0 is not None:
- fs.append(line(X, (x0, 0)))
- if x1 is not None:
- fs.append(line(-X, (x1, 0)))
- if y0 is not None:
- fs.append(line(Y, (0, y0)))
- if y1 is not None:
- fs.append(line(-Y, (0, y1)))
- return intersection(*fs, r=r)
-
-
-@sdf2
-def rectangle(size=1, center=ORIGIN, a=None, b=None):
- if a is not None and b is not None:
- a = np.array(a)
- b = np.array(b)
- size = b - a
- center = a + size / 2
- return rectangle(size, center)
- size = np.array(size)
-
- def f(p):
- q = np.abs(p - center) - size / 2
- return _length(_max(q, 0)) + _min(np.amax(q, axis=1), 0)
-
- return f
-
-
-@sdf2
-def rounded_rectangle(size, radius, center=ORIGIN):
- try:
- r0, r1, r2, r3 = radius
- except TypeError:
- r0 = r1 = r2 = r3 = radius
-
- def f(p):
- x = p[:, 0]
- y = p[:, 1]
- r = np.zeros(len(p)).reshape((-1, 1))
- r[np.logical_and(x > 0, y > 0)] = r0
- r[np.logical_and(x > 0, y <= 0)] = r1
- r[np.logical_and(x <= 0, y <= 0)] = r2
- r[np.logical_and(x <= 0, y > 0)] = r3
- q = np.abs(p) - size / 2 + r
- return (
- _min(_max(q[:, 0], q[:, 1]), 0).reshape((-1, 1))
- + _length(_max(q, 0)).reshape((-1, 1))
- - r
- )
-
- return f
-
-
-@sdf2
-def equilateral_triangle():
- def f(p):
- k = 3**0.5
- p = _vec(np.abs(p[:, 0]) - 1, p[:, 1] + 1 / k)
- w = p[:, 0] + k * p[:, 1] > 0
- q = _vec(p[:, 0] - k * p[:, 1], -k * p[:, 0] - p[:, 1]) / 2
- p = np.where(w.reshape((-1, 1)), q, p)
- p = _vec(p[:, 0] - np.clip(p[:, 0], -2, 0), p[:, 1])
- return -_length(p) * np.sign(p[:, 1])
-
- return f
-
-
-@sdf2
-def hexagon(radius=None, diameter=None):
- if (radius is not None) == (diameter is not None):
- raise ValueError(f"Specify either radius or diameter")
- if radius is None:
- radius = diameter / 2
- radius *= 3**0.5 / 2
-
- def f(p):
- k = np.array((3**0.5 / -2, 0.5, np.tan(np.pi / 6)))
- p = np.abs(p)
- p -= 2 * k[:2] * _min(_dot(k[:2], p), 0).reshape((-1, 1))
- p -= _vec(
- np.clip(p[:, 0], -k[2] * radius, k[2] * radius), np.zeros(len(p)) + radius
- )
- return _length(p) * np.sign(p[:, 1])
-
- return f
-
-
-@sdf2
-def rounded_x(w, r):
- def f(p):
- p = np.abs(p)
- q = (_min(p[:, 0] + p[:, 1], w) * 0.5).reshape((-1, 1))
- return _length(p - q) - r
-
- return f
-
-
-def RegularPolygon(n, r=1):
- ri = r * np.cos(np.pi / n)
- return intersection(
- *[slab(y0=-ri).rotate(a) for a in np.arange(0, 2 * np.pi, 2 * np.pi / n)]
- )
-
-
-@sdf2
-def polygon(points):
- points = [np.array(p) for p in points]
-
- def f(p):
- n = len(points)
- d = _dot(p - points[0], p - points[0])
- s = np.ones(len(p))
- for i in range(n):
- j = (i + n - 1) % n
- vi = points[i]
- vj = points[j]
- e = vj - vi
- w = p - vi
- b = w - e * np.clip(np.dot(w, e) / np.dot(e, e), 0, 1).reshape((-1, 1))
- d = _min(d, _dot(b, b))
- c1 = p[:, 1] >= vi[1]
- c2 = p[:, 1] < vj[1]
- c3 = e[0] * w[:, 1] > e[1] * w[:, 0]
- c = _vec(c1, c2, c3)
- s = np.where(np.all(c, axis=1) | np.all(~c, axis=1), -s, s)
- return s * np.sqrt(d)
-
- return f
-
-
-# Positioning
-
-
-@op2
-def translate(other, offset):
- def f(p):
- return other(p - offset)
-
- return f
-
-
-@op2
-def scale(other, factor):
- try:
- x, y = factor
- except TypeError:
- x = y = factor
- s = (x, y)
- m = min(x, y)
-
- def f(p):
- return other(p / s) * m
-
- return f
-
-
-@op2
-def rotate(other, angle):
- s = np.sin(angle)
- c = np.cos(angle)
- m = 1 - c
- matrix = np.array(
- [
- [c, -s],
- [s, c],
- ]
- ).T
-
- def f(p):
- return other(np.dot(p, matrix))
-
- return f
-
-
-@op2
-def circular_array(other, count):
- angles = [i / count * 2 * np.pi for i in range(count)]
- return union(*[other.rotate(a) for a in angles])
-
-
-# Alterations
-
-
-@op2
-def elongate(other, size):
- def f(p):
- q = np.abs(p) - size
- x = q[:, 0].reshape((-1, 1))
- y = q[:, 1].reshape((-1, 1))
- w = _min(_max(x, y), 0)
- return other(_max(q, 0)) + w
-
- return f
-
-
-# 2D => 3D Operations
-
-
-@op23
-def extrude(other, h=np.inf):
- def f(p):
- d = other(p[:, [0, 1]])
- w = _vec(d.reshape(-1), np.abs(p[:, 2]) - h / 2)
- return _min(_max(w[:, 0], w[:, 1]), 0) + _length(_max(w, 0))
-
- return f
-
-
-@op23
-def extrude_to(a, b, h, e=ease.linear):
- def f(p):
- d1 = a(p[:, [0, 1]])
- d2 = b(p[:, [0, 1]])
- t = e(np.clip(p[:, 2] / h, -0.5, 0.5) + 0.5)
- d = d1 + (d2 - d1) * t.reshape((-1, 1))
- w = _vec(d.reshape(-1), np.abs(p[:, 2]) - h / 2)
- return _min(_max(w[:, 0], w[:, 1]), 0) + _length(_max(w, 0))
-
- return f
-
-
-@op23
-def revolve(other, offset=0):
- def f(p):
- xy = p[:, [0, 1]]
- # use horizontal distance to Z axis as X coordinate in 2D shape
- # use Z coordinate as Y coordinate in 2D shape
- q = _vec(_length(xy) - offset, p[:, 2])
- return other(q)
-
- return f
-
-
-# Common
-
-union = op2(dn.union)
-difference = op2(dn.difference)
-intersection = op2(dn.intersection)
-blend = op2(dn.blend)
-negate = op2(dn.negate)
-dilate = op2(dn.dilate)
-erode = op2(dn.erode)
-shell = op2(dn.shell)
-repeat = op2(dn.repeat)
-mirror = op2(dn.mirror)
-modulate_between = op2(dn.modulate_between)
-stretch = op2(dn.stretch)
diff --git a/sdf/d3.py b/sdf/d3.py
deleted file mode 100644
index 2f4bca7..0000000
--- a/sdf/d3.py
+++ /dev/null
@@ -1,1666 +0,0 @@
-import os
-import time
-import logging
-import functools
-import numpy as np
-import operator
-import warnings
-import copy
-
-# internal modules
-from . import dn, d2, ease, mesh, errors, util
-from .units import units
-
-# external modules
-import scipy.optimize
-from scipy.linalg import LinAlgWarning
-import rich
-import matplotlib.pyplot as plt
-
-# Constants
-
-logger = logging.getLogger(__name__)
-
-ORIGIN = np.array((0, 0, 0))
-
-X = np.array((1, 0, 0))
-Y = np.array((0, 1, 0))
-Z = np.array((0, 0, 1))
-
-UP = Z
-DOWN = -Z
-RIGHT = X
-LEFT = -X
-BACK = Y
-FRONT = -Y
-
-# SDF Class
-
-_ops = {}
-
-
-class SDF3:
- def __init__(self, f):
- self.f = f
-
- def __call__(self, p):
- return self.f(p).reshape((-1, 1))
-
- def __getattr__(self, name):
- if name in _ops:
- f = _ops[name]
- return functools.partial(f, self)
- raise AttributeError
-
- def __or__(self, other):
- return union(self, other)
-
- def __and__(self, other):
- return intersection(self, other)
-
- def __sub__(self, other):
- return difference(self, other)
-
- def fillet(self, r):
- newSelf = copy.deepcopy(self)
- newSelf._r = r
- return newSelf
-
- def radius(self, *args, **kwargs):
- return self.fillet(*args, **kwargs)
-
- def k(self, *args, **kwargs):
- return self.fillet(*args, **kwargs)
-
- def r(self, *args, **kwargs):
- return self.fillet(*args, **kwargs)
-
- def chamfer(self, c):
- newSelf = copy.deepcopy(self)
- newSelf._c = c
- return newSelf
-
- def c(self, *args, **kwargs):
- return self.chamfer(*args, **kwargs)
-
- def generate(self, *args, **kwargs):
- return mesh.generate(self, *args, **kwargs)
-
- @errors.alpha_quality
- def closest_surface_point(self, point):
- def distance(p):
- # root() wants same input/output dims (yeah...)
- return np.repeat(self.f(np.expand_dims(p, axis=0)).ravel()[0], 3)
-
- dist = self.f(np.expand_dims(point, axis=0)).ravel()[0]
- optima = dict()
- with warnings.catch_warnings():
- warnings.simplefilter(
- "ignore", (LinAlgWarning, RuntimeWarning, UserWarning)
- )
- for method in (
- # loosely sorted by speed
- "lm",
- "broyden2",
- "df-sane",
- "hybr",
- "broyden1",
- "anderson",
- "linearmixing",
- "diagbroyden",
- "excitingmixing",
- "krylov",
- ):
- try:
- optima[method] = (
- opt := scipy.optimize.root(
- distance, x0=np.array(point), method=method
- )
- )
- except Exception as e:
- pass
- opt.zero_error = abs(opt.fun[0])
- opt.zero_error_rel = opt.zero_error / dist
- opt.dist_error = np.linalg.norm(opt.x - point) - dist
- opt.dist_error_rel = opt.dist_error / dist
- logger.debug(f"{method = }, {opt = }")
- # shortcut if fit is good
- if (
- np.allclose(opt.fun, 0)
- and abs(opt.dist_error / dist - 1) < 0.01
- and opt.dist_error < 0.01
- ):
- break
-
- def cost(m):
- penalty = (
- # unsuccessfulness is penaltied
- (not optima[m].success)
- # a higher status normally means something bad
- + abs(getattr(optima[m], "status", 1))
- # the more we're away from zero, the worse it is
- # „1mm of away from boundary is as bad as one status or success step”
- + optima[m].zero_error
- # the distance error can be quite large e.g. for non-uniform scaling,
- # and methods often find weird points, it makes sense to compare to the SDF
- + optima[m].dist_error
- )
- logger.debug(f"{m = :20s}: {penalty = }")
- return penalty
-
- best_root = optima[best_method := min(optima, key=cost)]
- closest_point = best_root.x
- if (
- best_root.zero_error > 1
- or best_root.zero_error_rel > 0.01
- or best_root.dist_error > 1
- or best_root.dist_error_rel > 0.01
- ):
- warnings.warn(
- f"Closest surface point to {point} acc. to method {best_method!r} seems to be {closest_point}. "
- f"The SDF there is {best_root.fun[0]} (should be 0, that's {best_root.zero_error} or {best_root.zero_error_rel*100:.2f}% off).\n"
- f"Distance between {closest_point} and {point} is {np.linalg.norm(point - closest_point)}, "
- f"SDF says it should be {dist} (that's {best_root.dist_error} or {best_root.dist_error_rel*100:.2f}% off)).\n"
- f"The root finding algorithms seem to have a problem with your SDF, "
- f"this might be caused due to operations breaking the metric like non-uniform scaling.",
- errors.SDFCADWarning,
- )
- return closest_point
-
- @errors.alpha_quality
- def surface_intersection(self, start, direction=None):
- """
- ``start`` at a point, move (back or forth) along a line following a
- ``direction`` and return surface intersection coordinates.
-
- .. note::
-
- In case there is no intersection, the result *might* (not sure
- about that) return the point on the line that's closest to the
- surface 🤔.
-
- Args:
- start (3d vector): starting point
- direction (3d vector or None): direction to move into, defaults to
- ``-start`` (”move to origin”).
-
- Returns:
- 3d vector: the optimized surface intersection
- """
- if direction is None:
- direction = -start
-
- def transform(t):
- return start + t * direction
-
- def distance(t):
- # root() wants same input/output dims (yeah...)
- return np.repeat(self.f(np.expand_dims(transform(t), axis=0)).ravel()[0], 3)
-
- dist = self.f(np.expand_dims(start, axis=0)).ravel()[0]
- optima = dict()
- with warnings.catch_warnings():
- warnings.simplefilter(
- "ignore", (LinAlgWarning, RuntimeWarning, UserWarning)
- )
- for method in (
- # loosely sorted by speed
- "lm",
- "broyden2",
- "df-sane",
- "hybr",
- "broyden1",
- "anderson",
- "linearmixing",
- "diagbroyden",
- "excitingmixing",
- "krylov",
- ):
- try:
- optima[method] = (
- opt := scipy.optimize.root(distance, x0=[0], method=method)
- )
- except Exception as e:
- pass
- opt.zero_error = abs(opt.fun[0])
- opt.zero_error_rel = opt.zero_error / dist
- opt.point = transform(opt.x[0])
- logger.debug(f"{method = }, {opt = }")
- # shortcut if fit is good
- if np.allclose(opt.fun, 0):
- break
-
- def cost(m):
- penalty = (
- # unsuccessfulness is penaltied
- (not optima[m].success)
- # a higher status normally means something bad
- + abs(getattr(optima[m], "status", 1))
- # the more we're away from zero, the worse it is
- # „1mm of away from boundary is as bad as one status or success step”
- + optima[m].zero_error
- )
- logger.debug(f"{m = :20s}: {penalty = }")
- return penalty
-
- best_root = optima[best_method := min(optima, key=cost)]
- closest_point = transform(best_root.x[0])
- if best_root.zero_error > 1 or best_root.zero_error_rel > 0.01:
- warnings.warn(
- f"Surface intersection point from {start = } to {direction = }, acc. to method {best_method!r} seems to be {closest_point}. "
- f"The SDF there is {best_root.fun[0]} (should be 0, that's {best_root.zero_error} or {best_root.zero_error_rel*100:.2f}% off).\n"
- f"The root finding algorithms seem to have a problem with your SDF, "
- f"this might be caused due to operations breaking the metric like non-uniform scaling "
- f"or just because there is no intersection...",
- errors.SDFCADWarning,
- )
- return closest_point
-
- @errors.alpha_quality
- def minimum_sdf_on_plane(self, origin, normal, return_point=False):
- """
- Find the minimum SDF distance (not necessarily the real distance if you
- have non-uniform scaling!) on a plane around an ``origin`` that points
- into the ``normal`` direction.
-
- Args:
- origin (3d vector): a point on the plane
- normal (3d vector): normal vector of the plane
- return_point (bool): whether to also return the closest point (on
- the plane!)
-
- Returns:
- float: the (minimum) distance to the plane
- float, 3d vector : distance and closest point (on the plane!) if
- ``return_point=True``
- """
- basemat = np.array(
- [(e1 := _perpendicular(normal)), (e2 := np.cross(normal, e1))]
- ).T
-
- def transform(t):
- return origin + basemat @ t
-
- def distance(t):
- return self.f(np.expand_dims(transform(t), axis=0)).ravel()[0]
-
- optima = dict()
- with warnings.catch_warnings():
- warnings.simplefilter(
- "ignore", (LinAlgWarning, RuntimeWarning, UserWarning)
- )
- for method in (
- "Nelder-Mead",
- "Powell",
- "CG",
- "BFGS",
- "L-BFGS-B",
- "TNC",
- "COBYLA",
- "SLSQP",
- "trust-constr",
- ):
- try:
- optima[method] = (
- opt := scipy.optimize.minimize(
- distance, x0=[0, 0], method=method
- )
- )
- opt.point = transform(opt.x)
- logger.debug(f"{method = }, {opt = }")
- except Exception as e:
- logger.error(f"{method = } error {e!r}")
-
- best_min = optima[min(optima, key=lambda m: optima[m].fun)]
- if return_point:
- return best_min.fun, best_min.point
- else:
- return best_min.fun
-
- @errors.alpha_quality
- def extent_in(self, direction):
- """
- Determine the largest distance from the origin in a given ``direction``
- that's still within the object.
-
- Args:
- direction (3d vector): the direction to check
-
- Returns:
- float: distance from origin
- """
- # create probing points along direction to check where object ends roughly
- # object ends when SDF is only increasing (not the case for infinite repetitions e.g.)
- probing_points = np.expand_dims(np.logspace(0, 9, 30), axis=1) * direction
- d = self.f(probing_points) # get SDF value at probing points
- n_trailing_ascending = util.n_trailing_ascending_positive(d)
- if not n_trailing_ascending:
- return np.inf
- if (ratio := n_trailing_ascending / d.size) < 0.5:
- warnings.warn(
- f"extent_in({direction = !r}): "
- f"Only {n_trailing_ascending}/{d.size} ({ratio*100:.1f}%) of probed points in "
- f"{direction = } have ascending positive SDF distance values. "
- f"This can be caused by infinite objects. "
- f"Result of might be wrong. ",
- errors.SDFCADWarning,
- )
- faraway = probing_points[
- -n_trailing_ascending + 1
- ] # choose first point after which SDF only increases
- closest_surface_point = self.closest_surface_point_to_plane(
- origin=faraway, normal=direction
- )
- extent = np.linalg.norm(closest_surface_point)
- return extent
-
- @property
- @errors.alpha_quality
- def bounds(self):
- """
- Return X Y Z bounds based on :any:`extent_in`.
-
- Return:
- 6-sequence: lower X, upper X, lower Y, upper Y, lower Z, upper Z
- """
- return tuple(
- np.sign(d.sum()) * self.extent_in(d) for d in [-X, X, -Y, Y, -Z, Z]
- )
-
- @errors.alpha_quality
- def closest_surface_point_to_plane(self, origin, normal):
- """
- Find the closest surface point to a plane around an ``origin`` that points
- into the ``normal`` direction.
-
- Args:
- origin (3d vector): a point on the plane
- normal (3d vector): normal vector of the plane
-
- Returns:
- 3d vector : closest surface point
- """
- distance, plane_point = self.minimum_sdf_on_plane(
- origin=origin, normal=normal, return_point=True
- )
- return self.surface_intersection(start=plane_point, direction=normal)
-
- @errors.alpha_quality
- def move_to_positive(self, direction=Z):
- return self.translate(self.extent_in(-direction) * direction)
-
- def cut(self, direction=UP, point=ORIGIN, at=None, return_cutouts=False, **kwargs):
- """
- Split an object along a direction and return resulting parts.
-
- Args:
- direction (3d vector): direction to cut into
- point (3d vector): point to perform the cut at
- at (sequence of float): where to perform the cuts along the
- ``direction`` starting at ``point``. Defaults to ``[0]``,
- meaning it only splits at ``point``.
- k (float): passed to :any:`intersection`
- return_cutouts (bool): whether to return the used cutout masks as
- second value
-
-
- Returns:
- sequence of SDFs: the parts
- two sequences of SDFs: the parts and cutouts if ``return_cutouts=True``
- """
- direction = np.array(direction).reshape(3)
- direction = _normalize(direction)
- point = np.array(point).reshape(3)
- if at is None:
- at = [0]
- at = [-np.inf] + list(at) + [np.inf]
- parts = []
- cutouts = []
- for start, end in zip(at[:-1], at[1:]):
- cuts = []
- if np.isfinite(start):
- cuts.append(plane(normal=direction, point=point + start * direction))
- if np.isfinite(end):
- cuts.append(plane(normal=-direction, point=point + end * direction))
- if len(cuts) > 1:
- cutout = intersection(*cuts)
- elif len(cuts) == 1:
- cutout = cuts[0]
- else:
- cutout = self
- cutouts.append(cutout)
- parts.append(intersection(self, cutout, **kwargs))
- if return_cutouts:
- return parts, cutouts
- else:
- return parts
-
- def chamfercut(self, size, at=ORIGIN, direction=Z, e=ease.linear):
- """
- Chamfer (and then cut) an object along a plane
-
- Args:
- size (float): size to chamfer along ``direction``.
- at (3d point): A point on the plane where to chamfer at. Defaults
- to ORIGIN.
- direction (3d vector): direction to chamfer to. Defaults to Z.
- e (ease.Easing): the easing to use. Will be scaled with ``size``.
- """
- direction = direction / np.linalg.norm(direction)
- result = self.stretch(at, at - size * direction)
- result = result.modulate_between(at + size * direction, at, e=-size * e)
- result &= plane(direction, point=at)
- return result
-
- def volume(
- self, bounds=None, step=None, timeout=3, convergence_precision=0.01, plot=False
- ):
- """
- Approximate the volume by sampling the SDF and counting the inside
- hits.
-
- Args:
- bounds (6-sequence, optional): bounds as returned by `extent_in()`
- step (float, optional): The (single) step size in all dimensions to use.
- The default is an iterative process gradually refining the grid until
- it looks like it converges.
- timeout (float, optional): stop iterating after this time
- convergence_precision (float, optional): relative volume variance
- threshold to allow stopping iteration. For example `0.05` means that if the
- last couple iterations don't vary more than 5% of the current
- volume estimate, stop.
- plot (bool, optional): whether to plot the process after completion
-
- Returns:
- float : the volume approximation
- """
- vols = []
- ns = []
- bounds = bounds or self.bounds
- if not np.all(np.isfinite(bounds) & ~np.isnan(bounds)):
- raise errors.SDFCADInfiniteObjectError(
- f"Object has {bounds = }, can't approximate volume with that. "
- f"You can specify bounds=(x,x,y,y,z,z) manually."
- )
- xl, xu, yl, yu, zl, zu = bounds
- # expand bounds a bit outwards
- xl -= (dx := abs(xu - xl)) * 0.05
- xu += dx * 0.05
- yl -= (dy := abs(yu - yl)) * 0.05
- yu += dy * 0.05
- zl -= (dz := abs(zu - zl)) * 0.05
- zu += dz * 0.05
- steps = (
- np.linspace(min(dx, dy, dz) / 10, min(dx, dy, dz) / 1000, 100)
- if step is None
- else [step]
- )
- _steps = []
- _samples = []
- _hits = []
- _vols = []
- _times = []
- _variances = []
- _relvariances = []
- time_start = time.perf_counter()
- for step in steps:
- if time.perf_counter() - time_start > timeout:
- logger.warning(f"volume(): timeout of {timeout}s hit")
- if not _vols:
- _vols.append(np.nan)
- break
- time_before = time.perf_counter()
- _steps.append(step)
- grid = np.stack(
- np.meshgrid(
- np.arange(bounds[0], bounds[1] + step, step),
- np.arange(bounds[2], bounds[3] + step, step),
- np.arange(bounds[4], bounds[5] + step, step),
- ),
- axis=-1,
- )
- d = self.f(grid.reshape(-1, 3)).reshape(grid.shape[:-1])
- _samples.append(samples := d.size)
- _hits.append(hits := np.sum(d < 0))
- _vols.append(vol := hits * (cellvol := step**3))
- _times.append(time.perf_counter() - time_before)
- # _variances.append(np.var(_vols[-5:]))
- _variances.append(abs(max(_vols[-5:]) - min(_vols[-5:])))
- _relvariances.append(_variances[-1] / _vols[-1])
- if len(_variances) > 5 and _relvariances[-1] < convergence_precision:
- logger.info(f"volume(): convergence_precision hit")
- break
- if plot:
- fig, axes = plt.subplots(nrows=5, sharex=True)
- axes[-1].set_xlabel("cell side size")
- axes[0].scatter(_steps, _vols)
- axes[0].set_ylabel("est. volume")
- axes[1].scatter(_steps, np.array(_hits) / np.array(_samples))
- axes[1].set_ylabel("hit ratio")
- axes[2].scatter(_steps, np.array(_times))
- axes[2].set_ylabel("time estimation took")
- axes[3].scatter(_steps, _variances)
- axes[3].set_ylabel("volume variance of last couple steps bigger than...")
- axes[4].scatter(_steps, _relvariances)
- axes[4].set_ylabel("rel variance of last couple steps bigger than...")
- plt.show()
- return _vols[-1]
-
- def save(
- self,
- path="out.stl",
- screenshot=False,
- add_text=None,
- openscad=False,
- plot=True,
- antialiasing=True,
- plot_kwargs=None,
- **kwargs,
- ):
- mesh.save(path, self, **{**dict(samples=2**18), **kwargs})
- print(f"💾 Saved mesh to {path!r} ({os.stat(path).st_size} bytes)")
- if openscad:
- with open((p := f"{path}.scad"), "w") as fh:
- fh.write(
- f"""
- import("{path}");
- """.strip()
- )
- print(f"💾 Saved OpenSCAD viewer script to {p!r}")
- if plot or screenshot:
- try:
- import pyvista as pv
- except ImportError as e:
- print(
- f"To use the plotting functionality, "
- f"install pyvista and trame (pip install pyvista trame). "
- f"While trying to import them, the following error occured:\n{e!r}"
- )
- return
- # pv.set_jupyter_backend("client")
- plotter = pv.Plotter()
- # axes = pv.Axes(show_actor=True, actor_scale=2, line_width=5)
- # plotter.camera = pv.Camera()
- # plotter.camera.position = (-1, -1, 1)
- # plotter.add_actor(axes.actor)
- plotter.enable_parallel_projection()
- if antialiasing:
- plotter.enable_anti_aliasing(aa_type="msaa")
- m = pv.read(path)
- plotter.add_mesh(m)
- # xl, xu, yl, yu, zl, zu = m.bounds
- # plotter.add_ruler(
- # pointa=[0, 0, 0],
- # pointb=[max(0, xu), 0, 0],
- # number_minor_ticks=10,
- # title="X",
- # )
- # plotter.add_ruler(
- # pointa=[0, 0, 0],
- # pointb=[0, max(0, yu), 0],
- # number_minor_ticks=10,
- # title="Y",
- # )
- # plotter.add_ruler(
- # pointa=[0, 0, 0],
- # pointb=[0, 0, max(0, zu)],
- # number_minor_ticks=10,
- # title="Z",
- # )
- plotter.add_axes()
- if add_text:
- if isinstance(add_text, str):
- add_text = dict(text=add_text)
- plotter.add_text(**add_text)
- with warnings.catch_warnings():
- warnings.simplefilter(action="ignore", category=UserWarning)
- if screenshot:
- if not isinstance(screenshot, str):
- screenshot = f"{path}.png"
- plotter.screenshot(screenshot)
- print(f"🖼️ Saved screenshot to {screenshot!r}")
- if plot:
- try:
- plotter.show(**(plot_kwargs or {}))
- except TypeError as e:
- logger.error(f"While showing the plotter: {e!r}, trying again")
- plotter.show(**(plot_kwargs or {}))
- else:
- plotter.close()
-
- def show_slice(self, *args, **kwargs):
- return mesh.show_slice(self, *args, **kwargs)
-
-
-def sdf3(f):
- @functools.wraps(f)
- def wrapper(*args, **kwargs):
- return SDF3(f(*args, **kwargs))
-
- return wrapper
-
-
-def op3(f):
- @functools.wraps(f)
- def wrapper(*args, **kwargs):
- return SDF3(f(*args, **kwargs))
-
- _ops[f.__name__] = wrapper
- return wrapper
-
-
-def op32(f):
- @functools.wraps(f)
- def wrapper(*args, **kwargs):
- return d2.SDF2(f(*args, **kwargs))
-
- _ops[f.__name__] = wrapper
- return wrapper
-
-
-# Helpers
-
-
-def _length(a):
- return np.linalg.norm(a, axis=1)
-
-
-def _normalize(a):
- return a / np.linalg.norm(a)
-
-
-def _dot(a, b):
- return np.sum(a * b, axis=1)
-
-
-def _vec(*arrs):
- return np.stack(arrs, axis=-1)
-
-
-def _perpendicular(v):
- if v[1] == 0 and v[2] == 0:
- if v[0] == 0:
- raise ValueError("zero vector")
- else:
- return np.cross(v, [0, 1, 0])
- return np.cross(v, [1, 0, 0])
-
-
-_min = np.minimum
-_max = np.maximum
-
-# Primitives
-
-
-@sdf3
-def sphere(radius=0, diameter=0, r=0, d=0, center=ORIGIN):
- if bool(radius := max(radius, r)) == bool(diameter := max(diameter, d)):
- raise ValueError(f"Specify either radius or diameter")
- if not radius:
- radius = diameter / 2
-
- def f(p):
- return _length(p - center) - radius
-
- return f
-
-
-@sdf3
-def plane(normal=UP, point=ORIGIN):
- normal = _normalize(normal)
-
- def f(p):
- return np.dot(point - p, normal)
-
- return f
-
-
-@sdf3
-def slab(
- x0=None,
- y0=None,
- z0=None,
- x1=None,
- y1=None,
- z1=None,
- dx=None,
- dy=None,
- dz=None,
- **kwargs,
-):
- # How to improve this if/None madness?
- if dx is not None:
- if x0 is None:
- x0 = -dx / 2
- if x1 is None:
- x1 = dx / 2
- if dy is not None:
- if y0 is None:
- y0 = -dy / 2
- if y1 is None:
- y1 = dy / 2
- if dz is not None:
- if z0 is None:
- z0 = -dz / 2
- if z1 is None:
- z1 = dz / 2
- fs = []
- if x0 is not None:
- fs.append(plane(X, (x0, 0, 0)))
- if x1 is not None:
- fs.append(plane(-X, (x1, 0, 0)))
- if y0 is not None:
- fs.append(plane(Y, (0, y0, 0)))
- if y1 is not None:
- fs.append(plane(-Y, (0, y1, 0)))
- if z0 is not None:
- fs.append(plane(Z, (0, 0, z0)))
- if z1 is not None:
- fs.append(plane(-Z, (0, 0, z1)))
- return intersection(*fs, **kwargs)
-
-
-@sdf3
-def box(size=1, center=ORIGIN, a=None, b=None):
- if a is not None and b is not None:
- a = np.array(a)
- b = np.array(b)
- size = b - a
- center = a + size / 2
- return box(size, center)
- size = np.array(size)
-
- def f(p):
- q = np.abs(p - center) - size / 2
- return _length(_max(q, 0)) + _min(np.amax(q, axis=1), 0)
-
- return f
-
-
-@sdf3
-def rounded_box(size, radius):
- size = np.array(size)
-
- def f(p):
- q = np.abs(p) - size / 2 + radius
- return _length(_max(q, 0)) + _min(np.amax(q, axis=1), 0) - radius
-
- return f
-
-
-@sdf3
-def wireframe_box(size, thickness):
- size = np.array(size)
-
- def g(a, b, c):
- return _length(_max(_vec(a, b, c), 0)) + _min(_max(a, _max(b, c)), 0)
-
- def f(p):
- p = np.abs(p) - size / 2 - thickness / 2
- q = np.abs(p + thickness / 2) - thickness / 2
- px, py, pz = p[:, 0], p[:, 1], p[:, 2]
- qx, qy, qz = q[:, 0], q[:, 1], q[:, 2]
- return _min(_min(g(px, qy, qz), g(qx, py, qz)), g(qx, qy, pz))
-
- return f
-
-
-@sdf3
-def torus(r1, r2):
- def f(p):
- xy = p[:, [0, 1]]
- z = p[:, 2]
- a = _length(xy) - r1
- b = _length(_vec(a, z)) - r2
- return b
-
- return f
-
-
-@sdf3
-def capsule(a, b, radius=None, diameter=None):
- if (radius is not None) == (diameter is not None):
- raise ValueError(f"Specify either radius or diameter")
- if radius is None:
- radius = diameter / 2
- a = np.array(a)
- b = np.array(b)
- ba = b - a
- babadot = np.dot(ba, ba)
-
- r = radius if hasattr(radius, "__call__") else lambda h: radius
-
- def f(p):
- pa = p - a
- h = np.clip(np.dot(pa, ba) / babadot, 0, 1).reshape((-1, 1))
- return _length(pa - np.multiply(ba, h)) - r(h.reshape(-1))
-
- return f
-
-
-def pieslice(angle, centered=False):
- """
- Make a pie slice starting at X axis, rotated ``angle`` in mathematically
- positive direction. Infinite in Z direction.
-
- Args:
- angle: the angle to use
- centered: center the slice at X axis
- """
- angle = angle % units("360°")
- if angle <= units("180°"):
- s = plane(Y) & plane(-Y).rotate(angle)
- else:
- s = plane(Y) | plane(-Y).rotate(angle)
- if centered:
- s = s.rotate(-angle / 2)
- return s
-
-
-@sdf3
-def cylinder(radius=0, r=0, diameter=0, d=0):
- if bool(radius := max(radius, r)) == bool(diameter := max(diameter, d)):
- raise ValueError(f"Specify either radius or diameter")
- if not radius:
- radius = diameter / 2
-
- def f(p):
- return _length(p[:, [0, 1]]) - radius
-
- return f
-
-
-@sdf3
-def capped_cylinder(a, b, radius=None, diameter=None):
- if (radius is not None) == (diameter is not None):
- raise ValueError(f"Specify either radius or diameter")
- if radius is None:
- radius = diameter / 2
-
- a = np.array(a)
- b = np.array(b)
-
- def f(p):
- ba = b - a
- pa = p - a
- baba = np.dot(ba, ba)
- paba = np.dot(pa, ba).reshape((-1, 1))
- x = _length(pa * baba - ba * paba) - radius * baba
- y = np.abs(paba - baba * 0.5) - baba * 0.5
- x = x.reshape((-1, 1))
- y = y.reshape((-1, 1))
- x2 = x * x
- y2 = y * y * baba
- d = np.where(
- _max(x, y) < 0,
- -_min(x2, y2),
- np.where(x > 0, x2, 0) + np.where(y > 0, y2, 0),
- )
- return np.sign(d) * np.sqrt(np.abs(d)) / baba
-
- return f
-
-
-@sdf3
-def rounded_cylinder(ra, rb, h):
- def f(p):
- d = _vec(_length(p[:, [0, 1]]) - ra + rb, np.abs(p[:, 2]) - h / 2 + rb)
- return _min(_max(d[:, 0], d[:, 1]), 0) + _length(_max(d, 0)) - rb
-
- return f
-
-
-@sdf3
-def capped_cone(a, b, ra, rb):
- a = np.array(a)
- b = np.array(b)
-
- def f(p):
- rba = rb - ra
- baba = np.dot(b - a, b - a)
- papa = _dot(p - a, p - a)
- paba = np.dot(p - a, b - a) / baba
- x = np.sqrt(papa - paba * paba * baba)
- cax = _max(0, x - np.where(paba < 0.5, ra, rb))
- cay = np.abs(paba - 0.5) - 0.5
- k = rba * rba + baba
- f = np.clip((rba * (x - ra) + paba * baba) / k, 0, 1)
- cbx = x - ra - f * rba
- cby = paba - f
- s = np.where(np.logical_and(cbx < 0, cay < 0), -1, 1)
- return s * np.sqrt(
- _min(cax * cax + cay * cay * baba, cbx * cbx + cby * cby * baba)
- )
-
- return f
-
-
-@sdf3
-def rounded_cone(r1, r2, h):
- def f(p):
- q = _vec(_length(p[:, [0, 1]]), p[:, 2])
- b = (r1 - r2) / h
- a = np.sqrt(1 - b * b)
- k = np.dot(q, _vec(-b, a))
- c1 = _length(q) - r1
- c2 = _length(q - _vec(0, h)) - r2
- c3 = np.dot(q, _vec(a, b)) - r1
- return np.where(k < 0, c1, np.where(k > a * h, c2, c3))
-
- return f
-
-
-@sdf3
-def ellipsoid(size):
- size = np.array(size)
-
- def f(p):
- k0 = _length(p / size)
- k1 = _length(p / (size * size))
- return k0 * (k0 - 1) / k1
-
- return f
-
-
-@sdf3
-def pyramid(h):
- def f(p):
- a = np.abs(p[:, [0, 1]]) - 0.5
- w = a[:, 1] > a[:, 0]
- a[w] = a[:, [1, 0]][w]
- px = a[:, 0]
- py = p[:, 2]
- pz = a[:, 1]
- m2 = h * h + 0.25
- qx = pz
- qy = h * py - 0.5 * px
- qz = h * px + 0.5 * py
- s = _max(-qx, 0)
- t = np.clip((qy - 0.5 * pz) / (m2 + 0.25), 0, 1)
- a = m2 * (qx + s) ** 2 + qy * qy
- b = m2 * (qx + 0.5 * t) ** 2 + (qy - m2 * t) ** 2
- d2 = np.where(_min(qy, -qx * m2 - qy * 0.5) > 0, 0, _min(a, b))
- return np.sqrt((d2 + qz * qz) / m2) * np.sign(_max(qz, -py))
-
- return f
-
-
-# Platonic Solids
-
-
-@sdf3
-def tetrahedron(r):
- def f(p):
- x = p[:, 0]
- y = p[:, 1]
- z = p[:, 2]
- return (_max(np.abs(x + y) - z, np.abs(x - y) + z) - r) / np.sqrt(3)
-
- return f
-
-
-@sdf3
-def octahedron(r):
- def f(p):
- return (np.sum(np.abs(p), axis=1) - r) * np.tan(np.radians(30))
-
- return f
-
-
-@sdf3
-def dodecahedron(r):
- x, y, z = _normalize(((1 + np.sqrt(5)) / 2, 1, 0))
-
- def f(p):
- p = np.abs(p / r)
- a = np.dot(p, (x, y, z))
- b = np.dot(p, (z, x, y))
- c = np.dot(p, (y, z, x))
- q = (_max(_max(a, b), c) - x) * r
- return q
-
- return f
-
-
-@sdf3
-def icosahedron(r):
- r *= 0.8506507174597755
- x, y, z = _normalize(((np.sqrt(5) + 3) / 2, 1, 0))
- w = np.sqrt(3) / 3
-
- def f(p):
- p = np.abs(p / r)
- a = np.dot(p, (x, y, z))
- b = np.dot(p, (z, x, y))
- c = np.dot(p, (y, z, x))
- d = np.dot(p, (w, w, w)) - x
- return _max(_max(_max(a, b), c) - x, d) * r
-
- return f
-
-
-# Shapes
-
-
-def lerp(x1, x2, t):
- return (1 - t) * x1 + t * x2
-
-
-def bezier_via_lerp(p1, p2, p3, p4, t):
- t = np.array(t).reshape(-1, 1)
- p12 = lerp(p1, p2, t)
- p23 = lerp(p2, p3, t)
- p34 = lerp(p3, p4, t)
- p1223 = lerp(p12, p23, t)
- p2334 = lerp(p23, p34, t)
- return lerp(p1223, p2334, t)
-
-
-@sdf3
-def bezier(
- p1=ORIGIN,
- p2=10 * X,
- p3=10 * Y,
- p4=10 * Z,
- radius=None,
- diameter=None,
- steps=20,
- **kwargs,
-):
- """
- Generate a single bezier curve :any:`capsule_chain` from four
- control points with a fixed or variable thickness
-
- Args:
- p1, p2, p3, p4 (point vectors): the control points. Segment will start at p1 and end at p4.
- radius,diameter (float or callable): either a fixed number as radius/diameter or a
- callable taking a number within [0;1] and returning a radius/diameter, e.g.
- the easing function :any:`ease.linear` or
- ``radius=ease.linear.between(10,2)`` for a linear transition
- between radii/diameters.
- **kwargs: handed to :any:`capsule_chain`
- """
- if (radius is not None) == (diameter is not None):
- raise ValueError(f"Specify either radius or diameter")
- if radius is None:
- radius = diameter / 2
- if isinstance(radius, (float, int)):
- radius = ease.constant(radius)
- points = bezier_via_lerp(p1, p2, p3, p4, (t := np.linspace(0, 1, steps)))
- lengths = np.linalg.norm(np.diff(points, axis=0), axis=1)
-
- # TODO: better steps taking curvature and changing radius into account
- t_eq = np.interp(
- np.arange(0, lengths.sum() + radius.mean / 4, radius.mean / 4),
- # np.linspace(0, lengths.sum(), steps),
- np.hstack([0, np.cumsum(lengths)]),
- t,
- )
- points = bezier_via_lerp(p1, p2, p3, p4, t_eq)
- return capsule_chain(points, radius=radius, **kwargs)
-
-
-def capsule_chain(points, radius=None, diameter=None, **kwargs):
- if (radius is not None) == (diameter is not None):
- raise ValueError(f"Specify either radius or diameter")
- if radius is None:
- radius = diameter / 2
- lengths = np.linalg.norm(np.diff(points, axis=0), axis=1)
- cumlengths = np.hstack([0, np.cumsum(lengths)])
- relcumlengths = cumlengths / lengths.sum()
- return union(
- *[
- capsule(
- p1,
- p2,
- radius=(radius[a:b] if isinstance(radius, ease.Easing) else radius),
- )
- for p1, p2, a, b in zip(
- points[0:-1], points[1:], relcumlengths[0:-1], relcumlengths[1:]
- )
- ],
- **kwargs,
- )
-
-
-def Thread(
- pitch=5,
- diameter=20,
- offset=1,
- left=False,
-):
- """
- An infinite thread
- """
- angleperz = -(2 * np.pi) / pitch
- if left:
- angleperz *= -1
- thread = (
- cylinder(swipediameter := diameter / 2 - offset)
- .translate(X * offset)
- .twist(angleperz)
- )
- thread.diameter = diameter
- thread.pitch = pitch
- thread.offset = offset
- thread.left = left
- return thread
-
-
-def Screw(
- length=40,
- head_shape=None,
- head_height=10,
- k_tip=10,
- k_head=0,
- **threadkwargs,
-):
- if head_shape is None:
- head_shape = d2.hexagon(30)
- if not (thread := threadkwargs.pop("thread", None)):
- thread = Thread(**threadkwargs)
- k_tip = np.clip(k_tip, 0, min(thread.diameter, length)) or None
- k_head = np.clip(k_head, 0, thread.diameter) or None
- head = head_shape.extrude(head_height).translate(-Z * head_height / 2)
- return head | (thread & slab(z0=0) & slab(z1=length).k(k_tip)).k(k_head)
-
-
-def RegularPolygonColumn(n, r=1):
- ri = r * np.cos(np.pi / n)
- return intersection(
- *[slab(y0=-ri).rotate(a, Z) for a in np.arange(0, 2 * np.pi, 2 * np.pi / n)]
- )
-
-
-# Positioning
-
-
-@op3
-def translate(other, offset):
- def f(p):
- return other(p - offset)
-
- return f
-
-
-@op3
-def scale(other, factor):
- try:
- x, y, z = factor
- except TypeError:
- x = y = z = factor
- s = (x, y, z)
- m = min(x, min(y, z))
-
- def f(p):
- return other(p / s) * m
-
- return f
-
-
-def rotation_matrix(angle, axis=Z):
- """
- Euler-Rodriguez Formula for arbitrary-axis rotation:
-
- https://en.m.wikipedia.org/wiki/Rodrigues%27_rotation_formula
- """
- try:
- angle = angle.to("radians").m
- except AttributeError:
- pass
- x, y, z = _normalize(axis)
- s = np.sin(angle)
- c = np.cos(angle)
- m = 1 - c
- # code moved from mfogleman's rotate() below
- return np.array(
- [
- [m * x * x + c, m * x * y + z * s, m * z * x - y * s],
- [m * x * y - z * s, m * y * y + c, m * y * z + x * s],
- [m * z * x + y * s, m * y * z - x * s, m * z * z + c],
- ]
- ).T
- # Alternative matrix construction (slightly slower than the above):
- # kx, ky, kz = _normalize(axis)
- # K = np.array([[0, -kz, ky], [kz, 0, -kx], [-ky, kx, 0]])
- # return np.identity(3) + np.sin(angle) * K + (1 - np.cos(angle)) * (K @ K)
- # Using np.linalg.matrix_power(K,2) is not faster
-
-
-@op3
-def rotate(other, angle, vector=Z):
- matrix = rotation_matrix(axis=vector, angle=angle)
-
- def f(p):
- # np.dot(p, matrix) actually rotates *backwards*,
- # (matrix @ p) or np.dot(matrix,p) would rotate
- # forwards but that doesn't work with the shapes here.
- # In this case, rotating backwards is actually what we want:
- # we want to inverse the rotation to look up the SDF there
- return other(np.dot(p, matrix))
-
- return f
-
-
-@op3
-def rotate_to(other, a, b):
- a = _normalize(np.array(a))
- b = _normalize(np.array(b))
- dot = np.dot(b, a)
- if dot == 1:
- return other
- if dot == -1:
- return rotate(other, np.pi, _perpendicular(a))
- angle = np.arccos(dot)
- v = _normalize(np.cross(b, a))
- return rotate(other, angle, v)
-
-
-@op3
-def orient(other, axis):
- # quick fix that probably is a problem in rotate_to()
- axis = axis * np.array([-1, -1, 1])
- return rotate_to(other, UP, axis)
-
-
-@op3
-def circular_array(other, count, offset=0):
- other = other.translate(X * offset)
- da = 2 * np.pi / count
-
- def f(p):
- x = p[:, 0]
- y = p[:, 1]
- z = p[:, 2]
- d = np.hypot(x, y)
- a = np.arctan2(y, x) % da
- d1 = other(_vec(np.cos(a - da) * d, np.sin(a - da) * d, z))
- d2 = other(_vec(np.cos(a) * d, np.sin(a) * d, z))
- return _min(d1, d2)
-
- return f
-
-
-# Alterations
-
-
-@op3
-def rotate_stretch(
- sdf,
- start=X,
- stop=None,
- axis=None,
- angle=None,
- stretch=ease.linear,
- shear=None,
- fling=None,
- thicken=None,
- transition=None,
- transition_ease=ease.linear,
- symmetric=False,
- verbose=False,
-):
- """
- Grab the object at point ``a``, rotate it around and ``axis`` and stretch
- it to point ``b``, optionally modulating the thickness, shearing in the
- rotation axis or flinging in the direction of 'centrifual force' along the
- way.
-
- Args:
- start (point vector): the starting control point. Defaults to ``X``.
- stop (point vector): the end control point. Must not be collinear to ``a``.
- axis (vector): the axis. auto-determined from a and b if not given.
- Specify this if a and b are collinear (e.g. opposite) or if you
- don't like the direction of rotation of the default. Defaults to Z.
- angle (float or unit): how far to rotate. Defaults to a full rotation
- (``2*np.pi`` or ``units("360°")``) if ``stop`` is not given. More
- than a full rotation is not possible.
- symmetric (bool, optional): perform the stretch symmetrically in both
- directions.
- stretch (Easing): easing to apply in the stretching direction. Using an
- easing that doesn't start at (0,0) and end at (1,1) does not make much
- sense here.
- shear (Easing): easing to apply for shearing in the rotation axis direction
- fling (Easing): easing to apply for moving in the outwards direction
- thicken (Easing): easing to apply to modify the thickness while stretching
- transition (SDF): transition to another SDF along the way
- transition_ease (Easing): easing for transition
-
- Examples
- --------
-
- .. code-block:: python
-
- from sdf import *
-
- # make a quarter capsule sweep
- # (”grab X, stretch it around origin to Y”)
- capsule(ORIGIN,30*X,10).rotate_stretch(X,Y).save()
-
- # make one edge of a box round
- # (”grab X, stretch it around origin to Y”)
- box(10).rotate_stretch(X,Y).save()
-
- # full revolve of a capsule
- # (”grab X, rotate it around Z axis to -X, symmetrically”)
- capsule(ORIGIN, 30*X,10).rotate_stretch(X,-X,Z,symmetric=True).save()
-
- # make a saddle-like thing by using an easing have the stretch look 'stretchy'
- capsule(ORIGIN,30*X,10).rotate_stretch(X,-X+Y,symmetric=True,e=ease.out_sine).save()
-
- """
- start = _normalize(start)
- if stop is None:
- axis = Z if axis is None else axis
- angle = angle or 2 * np.pi
- else: # so: stop is not None
- if axis is not None or angle is not None:
- raise errors.SDFCADError(
- f"rotate_stretch(): Too many constraints. Check arguments."
- )
- stop = _normalize(stop)
- if axis is None:
- axis = np.cross(start, stop) # rotation axis
- if np.allclose(L := np.linalg.norm(axis), 0):
- raise errors.SDFCADError(
- f"rotate_stretch(): "
- f"Your stretch points {start = } and {stop = } are collinear "
- f"(cross product length is {L}). "
- f"Either fix this or specify a rotation axis= manually. "
- )
- else:
- axis = axis / L
-
- # matrix projecting a 3D p onto 2D rotation plane coordinates
- basemat = np.array([start, _normalize(np.cross(axis, start))])
-
- def angle_on_plane(p):
- p_on_plane = (basemat @ p.T).T
- if verbose:
- # rich.print(f"angle_on_plane(): {p = }")
- rich.print(f"angle_on_plane(): {p_on_plane = }")
- angle = np.arctan2(p_on_plane[:, 1], p_on_plane[:, 0])
- if not symmetric:
- angle += 2 * np.pi
- angle %= 2 * np.pi
- return angle
-
- if angle is None and stop is not None:
- angle = angle_on_plane(np.array([stop]))[0]
-
- if hasattr(angle, "to"):
- angle = angle.to("radians").m
-
- if verbose:
- rich.print(f"{start,stop = }")
- rich.print(f"{angle = }, {np.rad2deg(angle) = }")
- rich.print(f"{axis = }")
- rich.print(f"{basemat = }")
-
- if symmetric: # mirror
- # stretch = stretch.mirror(x=0, copy=True) # not this one 🤷
- shear = shear.mirror(x=0, copy=True)
- fling = fling.mirror(x=0, copy=True)
- thicken = thicken.mirror(x=0, copy=True)
-
- def f(p):
- if verbose:
- rich.print(f"{p = }")
- p_on_plane = (basemat @ p.T).T
- if verbose:
- rich.print(f"{p_on_plane = }")
- # project p onto rotation plane
- p_angle_on_plane = angle_on_plane(p)
- if verbose:
- rich.print(f"{p_angle_on_plane = }")
- p_angle_fraction = np.clip(p_angle_on_plane / angle, -1 if symmetric else 0, 1)
- if verbose:
- rich.print(f"{p_angle_fraction = }")
- back_angle = stretch(p_angle_fraction) * angle
- verbose and rich.print(f"{back_angle = }")
- # the coordinates at which we'll look for the SDF
- plook = p # start with original points
- # ↔️ FLINGING outwards
- # get 3D vector of p projected onto plane
- if fling:
- outwards = p_on_plane @ basemat
- verbose and rich.print(f"{outwards = }")
- verbose and rich.print(f"{np.linalg.norm(outwards, axis=1) = }")
- outwards = outwards / np.linalg.norm(outwards, axis=1)[:, np.newaxis]
- if verbose:
- rich.print(f"{p_on_plane @ basemat = }")
- rich.print(f"{outwards = }")
- rich.print(f"{fling(p_angle_fraction) = }")
- flinging = (fling(p_angle_fraction) * outwards.T).T
- verbose and rich.print(f"{flinging = }")
- plook = plook - flinging
- # ↕️ SHEARING in rotation axis direction
- if shear:
- plook = plook - (shear(p_angle_fraction) * np.expand_dims(axis, axis=1)).T
- verbose and rich.print(f"after shear: {plook = }")
- # 🔄 ROTATION around rotation axis
- # create many rotation matrices (along 3rd dim) for all angles
- rotmatrix = rotation_matrix(axis=axis, angle=-back_angle)
- # (this is a slow Python loop, I wonder how to optmize this 🤔)
- plook = np.array([m @ p for p, m in zip(plook, rotmatrix)])
- if verbose:
- rich.print(f"after rotation: {plook = }")
- rich.print(f"{thicken(p_angle_fraction).T = }")
- rich.print(f"{plook = }")
- rich.print(f"{sdf(plook) = }")
- # get SDF at (reverse) flinged -> sheared -> rotated position
- distance = sdf(plook)
- verbose and rich.print(f"{distance = }")
- # ➕ THICKENING
- if thicken:
- thickening = np.expand_dims(thicken(p_angle_fraction), axis=1)
- verbose and rich.print(f"{thickening = }")
- distance = distance - thickening
- if transition is not None:
- t = transition_ease(p_angle_fraction)[:, None]
- verbose and rich.print(f"{t = }")
- verbose and rich.print(f"{transition(plook) = }")
- distance = t * transition(plook) + (1 - t) * distance
- verbose and rich.print(f"{distance = }")
- return distance
-
- return f
-
-
-@op3
-def elongate(other, size):
- def f(p):
- q = np.abs(p) - size
- x = q[:, 0].reshape((-1, 1))
- y = q[:, 1].reshape((-1, 1))
- z = q[:, 2].reshape((-1, 1))
- w = _min(_max(x, _max(y, z)), 0)
- return other(_max(q, 0)) + w
-
- return f
-
-
-@op3
-def twist(other, k):
- def f(p):
- x = p[:, 0]
- y = p[:, 1]
- z = p[:, 2]
- c = np.cos(k * z)
- s = np.sin(k * z)
- x2 = c * x - s * y
- y2 = s * x + c * y
- z2 = z
- return other(_vec(x2, y2, z2))
-
- return f
-
-
-@op3
-def twist_between(sdf, a, b, e=2 * np.pi * ease.in_out_cubic):
- """
- Twist an object between two control points
-
- Args:
- a, b (vectors): the two control points
- e (scalar function): the angle to rotate, will be called with
- values between 0 (at control point ``a``) and 1 (at control point
- ``b``). Its result will be used as rotation angle in radians.
- """
-
- # unit vector from control point a to b
- ab = (ab := b - a) / (L := np.linalg.norm(ab))
-
- def f(p):
- # project current point onto control direction, clip and apply easing
- angle = e(np.clip((p - a) @ ab / L, 0, 1))
- # move to origin ”-a”, then rotate, then move back ”+a”
- # create many rotation matrices (along 3rd dim) for all angles
- matrix = rotation_matrix(axis=ab, angle=-angle)
- # apply rotation matrix to points moved back to origin
- # (this is a slow Python loop, I wonder how to optmize this 🤔)
- rotated = np.array([m @ p for p, m in zip((p - a), matrix)])
- # move rotated points back to where they were
- return sdf(rotated + a)
-
- return f
-
-
-@op3
-def bend(other, k):
- def f(p):
- x = p[:, 0]
- y = p[:, 1]
- z = p[:, 2]
- c = np.cos(k * x)
- s = np.sin(k * x)
- x2 = c * x - s * y
- y2 = s * x + c * y
- z2 = z
- return other(_vec(x2, y2, z2))
-
- return f
-
-
-@op3
-def bend_linear(other, p0, p1, v, e=ease.linear):
- p0 = np.array(p0)
- p1 = np.array(p1)
- v = -np.array(v)
- ab = p1 - p0
-
- def f(p):
- t = np.clip(np.dot(p - p0, ab) / np.dot(ab, ab), 0, 1)
- t = e(t).reshape((-1, 1))
- return other(p + t * v)
-
- return f
-
-
-@op3
-def bend_radial(other, r0, r1, dz, e=ease.linear):
- def f(p):
- x = p[:, 0]
- y = p[:, 1]
- z = p[:, 2]
- r = np.hypot(x, y)
- t = np.clip((r - r0) / (r1 - r0), 0, 1)
- z = z - dz * e(t)
- return other(_vec(x, y, z))
-
- return f
-
-
-@op3
-def transition_linear(f0, f1, p0=-Z, p1=Z, e=ease.linear):
- p0 = np.array(p0)
- p1 = np.array(p1)
- ab = p1 - p0
-
- def f(p):
- d1 = f0(p)
- d2 = f1(p)
- t = np.clip(np.dot(p - p0, ab) / np.dot(ab, ab), 0, 1)
- t = e(t).reshape((-1, 1))
- return t * d2 + (1 - t) * d1
-
- return f
-
-
-@op3
-def transition_radial(f0, f1, r0=0, r1=1, e=ease.linear):
- def f(p):
- d1 = f0(p)
- d2 = f1(p)
- r = np.hypot(p[:, 0], p[:, 1])
- t = np.clip((r - r0) / (r1 - r0), 0, 1)
- t = e(t).reshape((-1, 1))
- return t * d2 + (1 - t) * d1
-
- return f
-
-
-@op3
-def wrap_around(other, x0, x1, r=None, e=ease.linear):
- p0 = X * x0
- p1 = X * x1
- v = -Y
- if r is None:
- r = np.linalg.norm(p1 - p0) / (2 * np.pi)
-
- def f(p):
- x = p[:, 0]
- y = p[:, 1]
- z = p[:, 2]
- d = np.hypot(x, y) - r
- d = d.reshape((-1, 1))
- a = np.arctan2(y, x)
- t = (a + np.pi) / (2 * np.pi)
- t = e(t).reshape((-1, 1))
- q = p0 + (p1 - p0) * t + v * d
- q[:, 2] = z
- return other(q)
-
- return f
-
-
-# 3D => 2D Operations
-
-
-@op32
-def slice(other):
- # TODO: support specifying a slice plane
- # TODO: probably a better way to do this
- s = slab(z0=-1e-9, z1=1e-9)
- a = other & s
- b = other.negate() & s
-
- def f(p):
- p = _vec(p[:, 0], p[:, 1], np.zeros(len(p)))
- A = a(p).reshape(-1)
- B = -b(p).reshape(-1)
- w = A <= 0
- A[w] = B[w]
- return A
-
- return f
-
-
-# Common
-
-union = op3(dn.union)
-union_legacy = op3(dn.union_legacy)
-difference = op3(dn.difference)
-difference_legacy = op3(dn.difference_legacy)
-intersection = op3(dn.intersection)
-intersection_legacy = op3(dn.intersection_legacy)
-blend = op3(dn.blend)
-negate = op3(dn.negate)
-dilate = op3(dn.dilate)
-erode = op3(dn.erode)
-shell = op3(dn.shell)
-repeat = op3(dn.repeat)
-mirror = op3(dn.mirror)
-modulate_between = op3(dn.modulate_between)
-stretch = op3(dn.stretch)
-shear = op3(dn.shear)
diff --git a/sdf/dn.py b/sdf/dn.py
deleted file mode 100644
index fef7b39..0000000
--- a/sdf/dn.py
+++ /dev/null
@@ -1,366 +0,0 @@
-import itertools
-from functools import reduce, partial
-import warnings
-
-from . import ease
-
-import numpy as np
-
-_min = np.minimum
-_max = np.maximum
-
-
-def distance_to_plane(p, origin, normal):
- """
- Calculate the distance of a point ``p`` to the plane around ``origin`` with
- normal ``normal``. This is dimension-independent, so e.g. the z-coordinate
- can be omitted.
-
- Args:
- p (array): either [x,y,z] or [[x,y,z],[x,y,z],...]
- origin (vector): a point on the plane
- normal (vector): normal vector of the plane
-
- Returns:
- int: distance to plane
- """
- normal = normal / np.linalg.norm(normal)
- return abs((p - origin) @ normal)
-
-
-def minimum(a, b, r=0):
- if r:
- Δ = b - a
- h = np.clip(0.5 + 0.5 * Δ / r, 0, 1)
- return b - Δ * h - r * h * (1 - h)
- else:
- return np.minimum(a, b)
-
-
-def maximum(a, b, r=0):
- if r:
- Δ = b - a
- h = np.clip(0.5 - 0.5 * Δ / r, 0, 1)
- return b - Δ * h + r * h * (1 - h)
- else:
- return np.maximum(a, b)
-
-
-def union(*sdfs, chamfer=0, c=0, radius=0, r=0, fillet=0, f=0):
- c = max(chamfer, c)
- r = max(radius, r, fillet, f)
- sqrt05 = np.sqrt(0.5)
-
- def f(p):
- sdfs_ = iter(sdfs)
- d1 = next(sdfs_)(p)
- for sdf in sdfs_:
- d2 = sdf(p)
- R = r or getattr(sdf, "_r", 0)
- C = c or getattr(sdf, "_c", 0)
- parts = (d1, d2)
- if C:
- parts = (minimum(d1, d2), (d1 + d2 - C) * sqrt05)
- d1 = minimum(*parts, R)
-
- return d1
-
- return f
-
-
-def intersection(*sdfs, chamfer=0, c=0, radius=0, r=0, fillet=0, f=0):
- c = max(chamfer, c)
- r = max(radius, r, fillet, f)
- sqrt05 = np.sqrt(0.5)
-
- def f(p):
- sdfs_ = iter(sdfs)
- d1 = next(sdfs_)(p)
- for sdf in sdfs_:
- d2 = sdf(p)
- R = r or getattr(sdf, "_r", 0)
- C = c or getattr(sdf, "_c", 0)
- parts = (d1, d2)
- if C:
- parts = (maximum(d1, d2), (d1 + d2 + C) * sqrt05)
- d1 = maximum(*parts, R)
-
- return d1
-
- return f
-
-
-def difference(*sdfs, chamfer=0, c=0, radius=0, r=0, fillet=0, f=0):
- c = max(chamfer, c)
- r = max(radius, r, fillet, f)
- sqrt05 = np.sqrt(0.5)
-
- def f(p):
- sdfs_ = iter(sdfs)
- d1 = next(sdfs_)(p)
- for sdf in sdfs_:
- d2 = sdf(p)
- R = r or getattr(sdf, "_r", 0)
- C = c or getattr(sdf, "_c", 0)
- parts = (d1, -d2)
- if C:
- parts = (maximum(d1, -d2), (d1 - d2 + C) * sqrt05)
- d1 = maximum(*parts, R)
-
- return d1
-
- return f
-
-
-def union_legacy(a, *bs, r=None):
- def f(p):
- d1 = a(p)
- for b in bs:
- d2 = b(p)
- K = k or getattr(b, "_r", None)
- if K is None:
- d1 = _min(d1, d2)
- else:
- h = np.clip(0.5 + 0.5 * (d2 - d1) / K, 0, 1)
- m = d2 + (d1 - d2) * h
- d1 = m - K * h * (1 - h)
- return d1
-
- return f
-
-
-def difference_legacy(a, *bs, r=None):
- def f(p):
- d1 = a(p)
- for b in bs:
- d2 = b(p)
- K = k or getattr(b, "_r", None)
- if K is None:
- d1 = _max(d1, -d2)
- else:
- h = np.clip(0.5 - 0.5 * (d2 + d1) / K, 0, 1)
- m = d1 + (-d2 - d1) * h
- d1 = m + K * h * (1 - h)
- return d1
-
- return f
-
-
-def intersection_legacy(a, *bs, r=None):
- def f(p):
- d1 = a(p)
- for b in bs:
- d2 = b(p)
- K = k or getattr(b, "_r", None)
- if K is None:
- d1 = _max(d1, d2)
- else:
- h = np.clip(0.5 - 0.5 * (d2 - d1) / K, 0, 1)
- m = d2 + (d1 - d2) * h
- d1 = m + K * h * (1 - h)
- return d1
-
- return f
-
-
-def blend(a, *bs, r=0.5):
- def f(p):
- d1 = a(p)
- for b in bs:
- d2 = b(p)
- K = k or getattr(b, "_r", None)
- d1 = K * d2 + (1 - K) * d1
- return d1
-
- return f
-
-
-def negate(other):
- def f(p):
- return -other(p)
-
- return f
-
-
-def dilate(other, r):
- def f(p):
- return other(p) - r
-
- return f
-
-
-def erode(other, r):
- def f(p):
- return other(p) + r
-
- return f
-
-
-def shell(other, thickness=1, type="center"):
- """
- Keep only a margin of a given thickness around the object's boundary.
-
- Args:
- thickness (float): the resulting thickness
- type (str): what kind of shell to generate.
-
- ``"center"`` (default)
- shell is spaced symmetrically around boundary
- ``"outer"``
- the resulting shell will be ``thickness`` larger than before
- ``"inner"``
- the resulting shell will be as large as before
- """
- return dict(
- center=lambda p: np.abs(other(p)) - thickness / 2,
- inner=other - other.erode(thickness),
- outer=other.dilate(thickness) - other,
- )[type]
-
-
-def modulate_between(sdf, a, b, e=ease.in_out_cubic):
- """
- Apply a distance offset transition between two control points
- (e.g. make a rod thicker or thinner at some point or add a bump)
-
- Args:
- a, b (vectors): the two control points
- e (scalar function): the distance offset function, will be called with
- values between 0 (at control point ``a``) and 1 (at control point
- ``b``). Its result will be subtracted from the given SDF, thus
- enlarging the object by that value.
- """
-
- # unit vector from control point a to b
- ab = (ab := b - a) / (L := np.linalg.norm(ab))
-
- def f(p):
- # project current point onto control direction, clip and apply easing
- offset = e(np.clip((p - a) @ ab / L, 0, 1))
- return (dist := sdf(p)) - offset.reshape(dist.shape)
-
- return f
-
-
-def stretch(sdf, a, b, symmetric=False, e=ease.linear):
- """
- Grab the object at point ``a`` and stretch the entire plane to ``b``.
-
- Args:
- a, b (point vectors): the control points
- symmetric (bool): also stretch the same into the other direction.
- e (Easing): easing to apply
-
- Examples
- ========
-
- .. code-block:: python
-
- # make a capsule
- sphere(5).stretch(ORIGIN, 10*Z).save() # same as capsule(ORIGIN, 10*Z, 5)
- # make an egg
- sphere(5).stretch(ORIGIN, 10*Z, e=ease.smoothstep[:0.44]).save()
- """
- ab = (ab := b - a) / (L := np.linalg.norm(ab))
-
- def f(p):
- # s = ”how far are we between a and b as fraction?”
- # if symmetric=True this also goes into the negative direction
- s = np.clip((p - a) @ ab / L, -1 if symmetric else 0, 1)
- # we return the sdf at a point 'behind' (p minus ...)
- # the current point, but we go only as far back as the stretch distance
- # at max
- return sdf(p - (np.sign(s) * e(abs(s)) * L * ab[:, np.newaxis]).T)
-
- return f
-
-
-def shear(sdf, fix, grab, move, e=ease.linear):
- """
- Grab the object at point ``grab`` and shear the entire plane in direction
- ``move``, keeping point ``fix`` in place. If ``move`` is orthogonal to the
- direction ``fix``->``grab``, then this operation is a shear.
-
- Args:
- fix, grab (point vectors): the control points
- move (point vector): direction to shear to
- e (Easing): easing to apply
-
- Examples
- ========
-
- .. code-block:: python
-
- # make a capsule
- box([20,10,50]).shear(fix=-15*Z, grab=15*Z, move=-5*X, e=ease.smoothstep)
- """
- ab = (ab := grab - fix) / (L := np.linalg.norm(ab))
-
- def f(p):
- # s = ”how far are we between a and b as fraction?”
- s = (p - fix) @ ab / L
- return sdf(p - move * np.expand_dims(e(np.clip(s, 0, 1)), axis=1))
-
- return f
-
-
-def mirror(other, direction, at=0):
- """
- Mirror around a given plane defined by ``origin`` reference point and
- ``direction``.
-
- Args:
- direction (vector): direction to mirror to (e.g. :any:`X` to mirror along X axis)
- at (3D vector): point to mirror at. Default is the origin.
- """
- direction = direction / np.linalg.norm(direction)
-
- def f(p):
- projdir = np.expand_dims((p - at) @ direction, axis=1) * direction
- # mirrored point:
- # - project 'p' onto 'direction' (result goes into 'projdir' direction)
- # - projected point is at 'at + projdir'
- # - remember direction from projected point to the original point (p - (at + projdir))
- # - from origin 'at' go backwards the projected direction (at - projdir)
- # - from that target, move along the remembered direction (p - (at + projdir))
- # - pmirr = at - projdir + (p - (at + projdir))
- # - the 'at' cancels out, the projdir is subtracted twice from the point
- return other(p - 2 * projdir)
-
- return f
-
-
-def repeat(other, spacing, count=None, padding=0):
- count = np.array(count) if count is not None else None
- spacing = np.array(spacing)
-
- def neighbors(dim, padding, spacing):
- try:
- padding = [padding[i] for i in range(dim)]
- except Exception:
- padding = [padding] * dim
- try:
- spacing = [spacing[i] for i in range(dim)]
- except Exception:
- spacing = [spacing] * dim
- for i, s in enumerate(spacing):
- if s == 0:
- padding[i] = 0
- axes = [list(range(-p, p + 1)) for p in padding]
- return list(itertools.product(*axes))
-
- def f(p):
- q = np.divide(p, spacing, out=np.zeros_like(p), where=spacing != 0)
- if count is None:
- index = np.round(q)
- else:
- index = np.clip(np.round(q), -count, count)
-
- indexes = [index + n for n in neighbors(p.shape[-1], padding, spacing)]
- A = [other(p - spacing * i) for i in indexes]
- a = A[0]
- for b in A[1:]:
- a = _min(a, b)
- return a
-
- return f
diff --git a/sdf/ease.py b/sdf/ease.py
deleted file mode 100644
index 743562a..0000000
--- a/sdf/ease.py
+++ /dev/null
@@ -1,637 +0,0 @@
-# system modules
-from dataclasses import dataclass
-from typing import Callable
-import itertools
-import functools
-import warnings
-
-# external modules
-import numpy as np
-import scipy.optimize
-
-
-@dataclass
-@functools.total_ordering
-class Extremum:
- """
- Container for min and max in Easing
- """
-
- pos: float
- value: float
-
- def __eq__(self, other):
- return self.value == other.value
-
- def __lt__(self, other):
- return self.value < other.value
-
-
-@dataclass
-@functools.total_ordering
-class Easing:
- """
- A function defined on the interval [0;1]
- """
-
- f: Callable[float, float]
- name: str
-
- def modifier(decorated_fun):
- @functools.wraps(decorated_fun)
- def wrapper(self, *args, **kwargs):
- newfun = decorated_fun(self, *args, **kwargs)
- arglist = ",".join(
- itertools.chain(map(str, args), (f"{k}={v}" for k, v in kwargs.items()))
- )
- newfun.__name__ = f"{self.f.__name__}.{decorated_fun.__name__}({arglist})"
- return type(self)(f=newfun, name=newfun.__name__)
-
- return wrapper
-
- def __repr__(self):
- return self.name
-
- def __str__(self):
- return self.name
-
- @functools.cached_property
- def is_ascending(self):
- return np.all(np.diff(self.f(np.linspace(0, 1, 100))) >= 0)
-
- @functools.cached_property
- def is_symmetric(self):
- t = np.linspace(0, 0.5, 100)
- return np.allclose(self.f(t), self.f(1 - t))
-
- @property
- @modifier
- def reverse(self):
- """
- Revert the function so it goes the other way round (starts at the end)
- """
- return lambda t: self.f(1 - t)
-
- @property
- @modifier
- def symmetric(self):
- """
- Mirror and squash function to make it symmetric
- """
- return lambda t: self.f(-2 * (np.abs(t - 0.5) - 0.5))
-
- @modifier
- def mirror(self, x=None, y=None, copy=False):
- """
- Mirror function around an x and/or y value.
-
- Args:
- x (float): x value to mirror around
- y (float): y value to mirror around
- copy (bool): when mirroring around x, do copy-mirror
- """
- if (x, y) == (None, None):
- x = 0.5
-
- def mirrored(t):
- if x is not None:
- t = 2 * x - t
- if copy:
- t = np.abs(-t)
- if y is None:
- return self.f(t)
- else:
- return y - self.f(t)
-
- return mirrored
-
- @modifier
- def clip(self, min=None, max=None):
- """
- Clip function at low and/or high values
- """
- if min is None and max is None:
- min = 0
- max = 1
- return lambda t: np.clip(self.f(t), min, max)
-
- @modifier
- def clip_input(self, min=None, max=None):
- """
- Clip input parameter, i.e. extrapolate constantly outside the interval.
- """
- if min is None and max is None:
- min = 0
- max = 1
- return lambda t: self.f(np.clip(t, min, max))
-
- @property
- @modifier
- def clipped(self):
- """
- Clipped parameter and result to [0;1]
- """
- return lambda t: np.clip(self(np.clip(t, 0, 1)), 0, 1)
-
- @modifier
- def append(self, other, e=None):
- """
- Append another easing function and squish both into the [0;1] interval
- """
- if e is None:
- e = in_out_square
-
- def f(t):
- mix = e(t)
- return self.f(t * 2) * (1 - mix) + other((t - 0.5) * 2) * mix
-
- return f
-
- @modifier
- def prepend(self, other, e=None):
- """
- Prepend another easing function and squish both into the [0;1] interval
- """
- if e is None:
- e = in_out_square
-
- def f(t):
- mix = e(t)
- return other(t * 2) * (1 - mix) + self.f((t - 0.5) * 2) * mix
-
- return f
-
- @modifier
- def shift(self, offset):
- """
- Shift function on x-axis into positive direction by ``offset``.
- """
- return lambda t: self.f(t - offset)
-
- @modifier
- def repeat(self, n=2):
- """
- Repeat the function a total of n times in the interval [0;1].
- """
- return lambda t: self.f(t % (1 / n) * n)
-
- @modifier
- def multiply(self, factor):
- """
- Scale function by ``factor``
- """
- if isinstance(factor, Easing):
- return lambda t: self(t) * factor(t)
- else:
- return lambda t: factor * self.f(t)
-
- @modifier
- def add(self, offset):
- """
- Add ``offset`` to function
- """
- if isinstance(offset, Easing):
- return lambda t: self(t) + offset(t)
- else:
- return lambda t: self.f(t) + offset
-
- def __add__(self, offset):
- return self.add(offset)
-
- def __radd__(self, offset):
- return self.add(offset)
-
- def __sub__(self, offset):
- return self.add(-offset)
-
- def __rsub__(self, offset):
- return self.add(-offset)
-
- def __mul__(self, factor):
- return self.multiply(factor)
-
- def __rmul__(self, factor):
- return self.multiply(factor)
-
- def __neg__(self):
- return self.multiply(-1)
-
- def __truediv__(self, factor):
- return self.multiply(1 / factor)
-
- def __or__(self, other):
- return self.transition(other)
-
- def __rshift__(self, offset):
- return self.shift(offset)
-
- def __lshift__(self, offset):
- return self.shift(-offset)
-
- def __getitem__(self, index):
- if isinstance(index, Easing):
- return self.chain(index)
- if isinstance(index, slice):
- return self.zoom(
- 0 if index.start is None else index.start,
- 1 if index.stop is None else index.stop,
- )
- else:
- raise ValueError(
- f"{index = } has to be slice of floats or an easing function"
- )
-
- @modifier
- def chain(self, f=None):
- """
- Feed parameter through the given function before evaluating this function.
- """
- if f is None:
- f = self.f
- return lambda t: self.f(f(t))
-
- @modifier
- def zoom(self, left, right=None):
- """
- Arrange so that the interval [left;right] is moved into [0;1]
- If only one argument is given, zoom in/out by moving edges that far.
- """
- if left is not None and right is None:
- if left >= 0.5:
- raise ValueError(
- f"{left = } is > 0.5 which doesn't make sense (bounds would cross)"
- )
- left = left
- right = 1 - left
- if left >= right:
- raise ValueError(f"{right = } bound must be greater than {left = }")
- return self.chain(linear.between(left, right)).f
-
- @modifier
- def between(self, left=0, right=1, e=None):
- """
- Arrange so ``f(0)==a`` and ``f(1)==b``.
- """
- f0, f1 = self.f(np.array([0, 1]))
- la = f0 - left
- lb = f1 - right
- if e is None: # linear is defined later
- e = (
- self # use ourself as transition when we're ascending within [0;1]
- if (self.is_ascending and np.allclose(self.f(np.array([0, 1])), [0, 1]))
- else linear
- )
-
- def f(t):
- t_ = e(t)
- return self.f(t_) - (la * (1 - t_)) - lb * t_
-
- return f
-
- @modifier
- def transition(self, other, e=None):
- """
- Transiton from one easing to another
- """
- if e is None:
- e = linear
-
- def f(t):
- t_ = e(t)
- return self.f(t) * (1 - t_) + other(t) * t_
-
- return f
-
- @classmethod
- def function(cls, decorated_fun):
- return cls(f=decorated_fun, name=decorated_fun.__name__)
-
- def plot(self, *others, xlim=(0, 1), ax=None):
- import matplotlib.pyplot as plt # lazy import for speed
- from cycler import cycler
-
- if ax is None:
- fig, ax_ = plt.subplots()
- else:
- ax_ = ax
-
- try:
- ax_.set_prop_cycle(
- cycler(linestyle=["solid", "dashed", "dotted"], linewidth=[1, 1, 2])
- * plt.rcParams["axes.prop_cycle"]
- )
- except ValueError as e:
- pass
-
- t = np.linspace(*xlim, 1000)
- funs = list(others or [])
- if isinstance(self, Easing):
- funs.insert(0, self)
- for f in funs:
- ax_.plot(t, f(t), label=getattr(f, "name", getattr(f, "__name__", str(f))))
- ax_.legend(ncol=int(np.ceil(len(ax_.get_lines()) / 10)))
- if ax is None:
- plt.show()
- return ax_
-
- @functools.cached_property
- def min(self):
- v = self.f(t := np.linspace(0, 1, 1000))
- approxmin = Extremum(pos=t[i := np.argmin(v)], value=v[i])
- opt = scipy.optimize.minimize(self, x0=[approxmin.pos], bounds=[(0, 1)])
- optmin = Extremum(pos=opt.x[0], value=opt.fun)
- return min(approxmin, optmin)
-
- @functools.cached_property
- def max(self):
- """
- Determine the maximum value
- """
- v = self.f(t := np.linspace(0, 1, 1000))
- approxmax = Extremum(pos=t[i := np.argmax(v)], value=v[i])
- opt = scipy.optimize.minimize(-self, x0=[approxmax.pos], bounds=[(0, 1)])
- optmax = Extremum(pos=opt.x[0], value=-opt.fun)
- return max(approxmax, optmax)
-
- @functools.cached_property
- def mean(self):
- return np.mean(self.f(np.linspace(0, 1, 1000)))
-
- def __lt__(self, e):
- return np.all(self.f(t := np.linspace(0, 1, 50)) < e.f(t))
-
- def __eq__(self, e):
- return np.allclose(self.f(t := np.linspace(0, 1, 50)), e.f(t))
-
- def __call__(self, t):
- return self.f(t)
-
-
-@Easing.function
-def linear(t):
- return t
-
-
-@Easing.function
-def in_quad(t):
- return t * t
-
-
-@Easing.function
-def out_quad(t):
- return -t * (t - 2)
-
-
-@Easing.function
-def in_out_quad(t):
- u = 2 * t - 1
- a = 2 * t * t
- b = -0.5 * (u * (u - 2) - 1)
- return np.where(t < 0.5, a, b)
-
-
-@Easing.function
-def in_cubic(t):
- return t * t * t
-
-
-@Easing.function
-def out_cubic(t):
- u = t - 1
- return u * u * u + 1
-
-
-@Easing.function
-def in_out_cubic(t):
- u = t * 2
- v = u - 2
- a = 0.5 * u * u * u
- b = 0.5 * (v * v * v + 2)
- return np.where(u < 1, a, b)
-
-
-@Easing.function
-def in_quart(t):
- return t * t * t * t
-
-
-@Easing.function
-def out_quart(t):
- u = t - 1
- return -(u * u * u * u - 1)
-
-
-@Easing.function
-def in_out_quart(t):
- u = t * 2
- v = u - 2
- a = 0.5 * u * u * u * u
- b = -0.5 * (v * v * v * v - 2)
- return np.where(u < 1, a, b)
-
-
-@Easing.function
-def in_quint(t):
- return t * t * t * t * t
-
-
-@Easing.function
-def out_quint(t):
- u = t - 1
- return u * u * u * u * u + 1
-
-
-@Easing.function
-def in_out_quint(t):
- u = t * 2
- v = u - 2
- a = 0.5 * u * u * u * u * u
- b = 0.5 * (v * v * v * v * v + 2)
- return np.where(u < 1, a, b)
-
-
-@Easing.function
-def in_sine(t):
- return -np.cos(t * np.pi / 2) + 1
-
-
-@Easing.function
-def out_sine(t):
- return np.sin(t * np.pi / 2)
-
-
-@Easing.function
-def in_out_sine(t):
- return -0.5 * (np.cos(np.pi * t) - 1)
-
-
-@Easing.function
-def in_expo(t):
- a = np.zeros(len(t))
- b = 2 ** (10 * (t - 1))
- return np.where(t == 0, a, b)
-
-
-@Easing.function
-def out_expo(t):
- a = np.zeros(len(t)) + 1
- b = 1 - 2 ** (-10 * t)
- return np.where(t == 1, a, b)
-
-
-@Easing.function
-def in_out_expo(t):
- zero = np.zeros(len(t))
- one = zero + 1
- a = 0.5 * 2 ** (20 * t - 10)
- b = 1 - 0.5 * 2 ** (-20 * t + 10)
- return np.where(t == 0, zero, np.where(t == 1, one, np.where(t < 0.5, a, b)))
-
-
-@Easing.function
-def in_circ(t):
- return -1 * (np.sqrt(1 - t * t) - 1)
-
-
-@Easing.function
-def out_circ(t):
- u = t - 1
- return np.sqrt(1 - u * u)
-
-
-@Easing.function
-def in_out_circ(t):
- u = t * 2
- v = u - 2
- a = -0.5 * (np.sqrt(1 - u * u) - 1)
- b = 0.5 * (np.sqrt(1 - v * v) + 1)
- return np.where(u < 1, a, b)
-
-
-@Easing.function
-def in_elastic(t, k=0.5):
- u = t - 1
- return -1 * (2 ** (10.0 * u) * np.sin((u - k / 4) * (2 * np.pi) / k))
-
-
-@Easing.function
-def out_elastic(t, k=0.5):
- return 2 ** (-10.0 * t) * np.sin((t - k / 4) * (2 * np.pi / k)) + 1
-
-
-@Easing.function
-def in_out_elastic(t, k=0.5):
- u = t * 2
- v = u - 1
- a = -0.5 * (2 ** (10 * v) * np.sin((v - k / 4) * 2 * np.pi / k))
- b = 2 ** (-10 * v) * np.sin((v - k / 4) * 2 * np.pi / k) * 0.5 + 1
- return np.where(u < 1, a, b)
-
-
-@Easing.function
-def in_back(t):
- k = 1.70158
- return t * t * ((k + 1) * t - k)
-
-
-@Easing.function
-def out_back(t):
- k = 1.70158
- u = t - 1
- return u * u * ((k + 1) * u + k) + 1
-
-
-@Easing.function
-def in_out_back(t):
- k = 1.70158 * 1.525
- u = t * 2
- v = u - 2
- a = 0.5 * (u * u * ((k + 1) * u - k))
- b = 0.5 * (v * v * ((k + 1) * v + k) + 2)
- return np.where(u < 1, a, b)
-
-
-@Easing.function
-def in_bounce(t):
- return 1 - out_bounce(1 - t)
-
-
-@Easing.function
-def out_bounce(t):
- a = (121 * t * t) / 16
- b = (363 / 40 * t * t) - (99 / 10 * t) + 17 / 5
- c = (4356 / 361 * t * t) - (35442 / 1805 * t) + 16061 / 1805
- d = (54 / 5 * t * t) - (513 / 25 * t) + 268 / 25
- return np.where(t < 4 / 11, a, np.where(t < 8 / 11, b, np.where(t < 9 / 10, c, d)))
-
-
-@Easing.function
-def in_out_bounce(t):
- a = in_bounce(2 * t) * 0.5
- b = out_bounce(2 * t - 1) * 0.5 + 0.5
- return np.where(t < 0.5, a, b)
-
-
-@Easing.function
-def in_square(t):
- return np.heaviside(t - 1, 0)
-
-
-@Easing.function
-def out_square(t):
- return np.heaviside(t + 1, 0)
-
-
-@Easing.function
-def in_out_square(t):
- return np.heaviside(t - 0.5, 0)
-
-
-def constant(x):
- return Easing(f=lambda t: np.full_like(t, x), name=f"constant({x})")
-
-
-zero = constant(0)
-one = constant(1)
-
-
-@Easing.function
-def smoothstep(t):
- t = np.clip(t, 0, 1)
- return 3 * t * t - 2 * t * t * t
-
-
-def _main():
- import matplotlib.pyplot as plt
- from cycler import cycler
-
- plt.rcParams["axes.prop_cycle"] *= cycler(
- linestyle=["solid", "dashed", "dotted"], linewidth=[1, 2, 3]
- )
- plt.rcParams["figure.autolayout"] = True
- plt.rcParams["axes.grid"] = True
- plt.rcParams["axes.axisbelow"] = True
- plt.rcParams["legend.fontsize"] = "small"
- LOCALS = globals()
- print(f"{LOCALS = }")
- fig, axes = plt.subplots(nrows=2)
- Easing.plot(
- *sorted((obj for n, obj in LOCALS.items() if isinstance(obj, Easing)), key=str),
- ax=axes[0],
- )
- Easing.plot(
- in_sine.symmetric,
- in_out_sine.symmetric.multiply(-0.6),
- linear.symmetric.multiply(-0.7),
- in_out_sine.multiply(-0.6).symmetric,
- out_sine.multiply(-0.6).reverse.symmetric.multiply(2),
- out_bounce.add(-0.5),
- ax=axes[1],
- )
- axes[0].set_title("Standard")
- axes[1].set_title("Derived")
- plt.show()
-
-
-if __name__ == "__main__":
- _main()
diff --git a/sdf/errors.py b/sdf/errors.py
deleted file mode 100644
index 52f642e..0000000
--- a/sdf/errors.py
+++ /dev/null
@@ -1,42 +0,0 @@
-import warnings
-import functools
-
-
-class SDFCADError(Exception):
- pass
-
-
-class SDFCADInfiniteObjectError(Exception):
- """
- Error raised when an infinite object is encountered where not suitable.
- """
-
- pass
-
-
-class SDFCADWarning(Warning):
- pass
-
-
-class SDFCADAlphaQualityWarning(SDFCADWarning):
- show = True
-
-
-def alpha_quality(decorated_fun):
- @functools.wraps(decorated_fun)
- def wrapper(*args, **kwargs):
- if SDFCADAlphaQualityWarning.show:
- warnings.warn(
- f"{decorated_fun.__name__}() is alpha quality "
- f"and might give wrong results. Use with care. "
- f"Hide this warning by setting sdf.errors.SDFCADAlphaQualityWarning.show=False.",
- SDFCADAlphaQualityWarning,
- )
- with warnings.catch_warnings():
- # Don't reissue nested alpha quality warnings
- warnings.simplefilter("ignore", SDFCADAlphaQualityWarning)
- return decorated_fun(*args, **kwargs)
- else:
- return decorated_fun(*args, **kwargs)
-
- return wrapper
diff --git a/sdf/mesh.py b/sdf/mesh.py
deleted file mode 100644
index c3892cd..0000000
--- a/sdf/mesh.py
+++ /dev/null
@@ -1,282 +0,0 @@
-from functools import partial
-from multiprocessing.pool import ThreadPool
-from skimage import measure
-
-import multiprocessing
-import itertools
-import numpy as np
-import time
-
-from . import progress, stl
-
-WORKERS = multiprocessing.cpu_count()
-SAMPLES = 2**18
-BATCH_SIZE = 32
-
-
-def _marching_cubes(volume, level=0):
- verts, faces, _, _ = measure.marching_cubes(volume, level)
- return verts[faces].reshape((-1, 3))
-
-
-def _cartesian_product(*arrays):
- la = len(arrays)
- dtype = np.result_type(*arrays)
- arr = np.empty([len(a) for a in arrays] + [la], dtype=dtype)
- for i, a in enumerate(np.ix_(*arrays)):
- arr[..., i] = a
- return arr.reshape(-1, la)
-
-
-def _skip(sdf, job):
- X, Y, Z = job
- x0, x1 = X[0], X[-1]
- y0, y1 = Y[0], Y[-1]
- z0, z1 = Z[0], Z[-1]
- x = (x0 + x1) / 2
- y = (y0 + y1) / 2
- z = (z0 + z1) / 2
- r = abs(sdf(np.array([(x, y, z)])).reshape(-1)[0])
- d = np.linalg.norm(np.array((x - x0, y - y0, z - z0)))
- if r <= d:
- return False
- corners = np.array(list(itertools.product((x0, x1), (y0, y1), (z0, z1))))
- values = sdf(corners).reshape(-1)
- same = np.all(values > 0) if values[0] > 0 else np.all(values < 0)
- return same
-
-
-def _worker(sdf, job, sparse):
- X, Y, Z = job
- if sparse and _skip(sdf, job):
- return None
- # return _debug_triangles(X, Y, Z)
- P = _cartesian_product(X, Y, Z)
- volume = sdf(P).reshape((len(X), len(Y), len(Z)))
- try:
- points = _marching_cubes(volume)
- except Exception:
- return []
- # return _debug_triangles(X, Y, Z)
- scale = np.array([X[1] - X[0], Y[1] - Y[0], Z[1] - Z[0]])
- offset = np.array([X[0], Y[0], Z[0]])
- return points * scale + offset
-
-
-def _estimate_bounds(sdf):
- # TODO: raise exception if bound estimation fails
- s = 16
- x0 = y0 = z0 = -1e9
- x1 = y1 = z1 = 1e9
- prev = None
- for i in range(32):
- X = np.linspace(x0, x1, s)
- Y = np.linspace(y0, y1, s)
- Z = np.linspace(z0, z1, s)
- d = np.array([X[1] - X[0], Y[1] - Y[0], Z[1] - Z[0]])
- threshold = np.linalg.norm(d) / 2
- if threshold == prev:
- break
- prev = threshold
- P = _cartesian_product(X, Y, Z)
- volume = sdf(P).reshape((len(X), len(Y), len(Z)))
- where = np.argwhere(np.abs(volume) <= threshold)
- x1, y1, z1 = (x0, y0, z0) + where.max(axis=0) * d + d / 2
- x0, y0, z0 = (x0, y0, z0) + where.min(axis=0) * d - d / 2
- return ((x0, y0, z0), (x1, y1, z1))
-
-
-def generate(
- sdf,
- step=None,
- bounds=None,
- samples=SAMPLES,
- workers=WORKERS,
- batch_size=BATCH_SIZE,
- verbose=True,
- sparse=True,
-):
- start = time.time()
-
- if bounds is None:
- bounds = _estimate_bounds(sdf)
- (x0, y0, z0), (x1, y1, z1) = bounds
-
- if step is None and samples is not None:
- volume = (x1 - x0) * (y1 - y0) * (z1 - z0)
- step = (volume / samples) ** (1 / 3)
-
- try:
- dx, dy, dz = step
- except TypeError:
- dx = dy = dz = step
-
- if verbose:
- print("min %g, %g, %g" % (x0, y0, z0))
- print("max %g, %g, %g" % (x1, y1, z1))
- print("step %g, %g, %g" % (dx, dy, dz))
-
- X = np.arange(x0, x1, dx)
- Y = np.arange(y0, y1, dy)
- Z = np.arange(z0, z1, dz)
-
- s = batch_size
- Xs = [X[i : i + s + 1] for i in range(0, len(X), s)]
- Ys = [Y[i : i + s + 1] for i in range(0, len(Y), s)]
- Zs = [Z[i : i + s + 1] for i in range(0, len(Z), s)]
-
- batches = list(itertools.product(Xs, Ys, Zs))
- num_batches = len(batches)
- num_samples = sum(len(xs) * len(ys) * len(zs) for xs, ys, zs in batches)
-
- if verbose:
- print(
- "%d samples in %d batches with %d workers"
- % (num_samples, num_batches, workers)
- )
-
- points = []
- skipped = empty = nonempty = 0
- bar = progress.Bar(num_batches, enabled=verbose)
- f = partial(_worker, sdf, sparse=sparse)
- with ThreadPool(workers) as pool:
- for result in pool.imap(f, batches):
- bar.increment(1)
- if result is None:
- skipped += 1
- elif len(result) == 0:
- empty += 1
- else:
- nonempty += 1
- points.extend(result)
- bar.done()
-
- if verbose:
- print("%d skipped, %d empty, %d nonempty" % (skipped, empty, nonempty))
- triangles = len(points) // 3
- seconds = time.time() - start
- print("%d triangles in %g seconds" % (triangles, seconds))
-
- return points
-
-
-def save(path, *args, **kwargs):
- points = generate(*args, **kwargs)
- if str(path).lower().endswith(".stl"):
- stl.write_binary_stl(path, points)
- else:
- mesh = _mesh(points)
- mesh.write(path)
-
-
-def _mesh(points):
- import meshio
-
- points, cells = np.unique(points, axis=0, return_inverse=True)
- cells = [("triangle", cells.reshape((-1, 3)))]
- return meshio.Mesh(points, cells)
-
-
-def _debug_triangles(X, Y, Z):
- x0, x1 = X[0], X[-1]
- y0, y1 = Y[0], Y[-1]
- z0, z1 = Z[0], Z[-1]
-
- p = 0.25
- x0, x1 = x0 + (x1 - x0) * p, x1 - (x1 - x0) * p
- y0, y1 = y0 + (y1 - y0) * p, y1 - (y1 - y0) * p
- z0, z1 = z0 + (z1 - z0) * p, z1 - (z1 - z0) * p
-
- v = [
- (x0, y0, z0),
- (x0, y0, z1),
- (x0, y1, z0),
- (x0, y1, z1),
- (x1, y0, z0),
- (x1, y0, z1),
- (x1, y1, z0),
- (x1, y1, z1),
- ]
-
- return [
- v[3],
- v[5],
- v[7],
- v[5],
- v[3],
- v[1],
- v[0],
- v[6],
- v[4],
- v[6],
- v[0],
- v[2],
- v[0],
- v[5],
- v[1],
- v[5],
- v[0],
- v[4],
- v[5],
- v[6],
- v[7],
- v[6],
- v[5],
- v[4],
- v[6],
- v[3],
- v[7],
- v[3],
- v[6],
- v[2],
- v[0],
- v[3],
- v[2],
- v[3],
- v[0],
- v[1],
- ]
-
-
-def sample_slice(sdf, w=1024, h=1024, x=None, y=None, z=None, bounds=None):
- if bounds is None:
- bounds = _estimate_bounds(sdf)
- (x0, y0, z0), (x1, y1, z1) = bounds
-
- if x is not None:
- X = np.array([x])
- Y = np.linspace(y0, y1, w)
- Z = np.linspace(z0, z1, h)
- extent = (Z[0], Z[-1], Y[0], Y[-1])
- axes = "ZY"
- elif y is not None:
- Y = np.array([y])
- X = np.linspace(x0, x1, w)
- Z = np.linspace(z0, z1, h)
- extent = (Z[0], Z[-1], X[0], X[-1])
- axes = "ZX"
- elif z is not None:
- Z = np.array([z])
- X = np.linspace(x0, x1, w)
- Y = np.linspace(y0, y1, h)
- extent = (Y[0], Y[-1], X[0], X[-1])
- axes = "YX"
- else:
- raise Exception("x, y, or z position must be specified")
-
- P = _cartesian_product(X, Y, Z)
- return sdf(P).reshape((w, h)), extent, axes
-
-
-def show_slice(*args, **kwargs):
- import matplotlib.pyplot as plt
-
- show_abs = kwargs.pop("abs", False)
- a, extent, axes = sample_slice(*args, **kwargs)
- if show_abs:
- a = np.abs(a)
- im = plt.imshow(a, extent=extent, origin="lower")
- plt.xlabel(axes[0])
- plt.ylabel(axes[1])
- plt.colorbar(im)
- plt.show()
diff --git a/sdf/progress.py b/sdf/progress.py
deleted file mode 100644
index 0cbbc9c..0000000
--- a/sdf/progress.py
+++ /dev/null
@@ -1,83 +0,0 @@
-import sys
-import time
-
-
-def pretty_time(seconds):
- seconds = int(round(seconds))
- s = seconds % 60
- m = (seconds // 60) % 60
- h = seconds // 3600
- return "%d:%02d:%02d" % (h, m, s)
-
-
-class Bar(object):
- def __init__(self, max_value=100, min_value=0, enabled=True):
- self.min_value = min_value
- self.max_value = max_value
- self.value = min_value
- self.start_time = time.time()
- self.enabled = enabled
-
- @property
- def percent_complete(self):
- t = (self.value - self.min_value) / (self.max_value - self.min_value)
- return t * 100
-
- @property
- def elapsed_time(self):
- return time.time() - self.start_time
-
- @property
- def eta(self):
- t = self.percent_complete / 100
- if t == 0:
- return 0
- return (1 - t) * self.elapsed_time / t
-
- def increment(self, delta):
- self.update(self.value + delta)
-
- def update(self, value):
- self.value = value
- if self.enabled:
- sys.stdout.write(" %s \r" % self.render())
- sys.stdout.flush()
-
- def done(self):
- self.update(self.max_value)
- self.stop()
-
- def stop(self):
- if self.enabled:
- sys.stdout.write("\n")
- sys.stdout.flush()
-
- def render(self):
- items = [
- self.render_percent_complete(),
- self.render_value(),
- self.render_bar(),
- self.render_elapsed_time(),
- self.render_eta(),
- ]
- return " ".join(items)
-
- def render_percent_complete(self):
- return "%3.0f%%" % self.percent_complete
-
- def render_value(self):
- if self.min_value == 0:
- return "(%g of %g)" % (self.value, self.max_value)
- else:
- return "(%g)" % (self.value)
-
- def render_bar(self, size=30):
- a = int(round(self.percent_complete / 100.0 * size))
- b = size - a
- return "[" + "#" * a + "-" * b + "]"
-
- def render_elapsed_time(self):
- return pretty_time(self.elapsed_time)
-
- def render_eta(self):
- return pretty_time(self.eta)
diff --git a/sdf/stl.py b/sdf/stl.py
deleted file mode 100644
index 6f263f5..0000000
--- a/sdf/stl.py
+++ /dev/null
@@ -1,27 +0,0 @@
-import numpy as np
-import struct
-
-
-def write_binary_stl(path, points):
- n = len(points) // 3
-
- points = np.array(points, dtype="float32").reshape((-1, 3, 3))
- normals = np.cross(points[:, 1] - points[:, 0], points[:, 2] - points[:, 0])
- normals /= np.linalg.norm(normals, axis=1).reshape((-1, 1))
-
- dtype = np.dtype(
- [
- ("normal", ("= tw - 1) | (j < 0) | (j >= th - 1)
- d[outside] = q[outside]
- return d
-
- return f
-
-
-def _bilinear_interpolate(a, x, y):
- x0 = np.floor(x).astype(int)
- x1 = x0 + 1
- y0 = np.floor(y).astype(int)
- y1 = y0 + 1
-
- x0 = np.clip(x0, 0, a.shape[1] - 1)
- x1 = np.clip(x1, 0, a.shape[1] - 1)
- y0 = np.clip(y0, 0, a.shape[0] - 1)
- y1 = np.clip(y1, 0, a.shape[0] - 1)
-
- pa = a[y0, x0]
- pb = a[y1, x0]
- pc = a[y0, x1]
- pd = a[y1, x1]
-
- wa = (x1 - x) * (y1 - y)
- wb = (x1 - x) * (y - y0)
- wc = (x - x0) * (y1 - y)
- wd = (x - x0) * (y - y0)
-
- return wa * pa + wb * pb + wc * pc + wd * pd
diff --git a/sdf/units.py b/sdf/units.py
deleted file mode 100644
index 8e9e8da..0000000
--- a/sdf/units.py
+++ /dev/null
@@ -1,3 +0,0 @@
-import pint
-
-units = pint.UnitRegistry()
diff --git a/sdf/util.py b/sdf/util.py
deleted file mode 100644
index 285e710..0000000
--- a/sdf/util.py
+++ /dev/null
@@ -1,32 +0,0 @@
-import math
-import functools
-import inspect
-import numpy as np
-
-pi = math.pi
-
-degrees = math.degrees
-radians = math.radians
-
-
-def n_trailing_ascending_positive(d):
- """
- Determine how many elements in a given sequence are positive and ascending.
-
- Args:
- d (sequence of numbers): the sequence to check
-
- Returns:
- int : the amount of trailing ascending positive elements
- """
- d = np.array(d).flatten()
- # is the next element larger than previous and positive?
- order = (d[1:] > d[:-1]) & (d[:-1] > 0)
- # TODO: Not happy at all with this if/else mess. Is there no easier way to find the
- # index in a numpy array after which the values are only ascending? 🤔
- if np.all(order): # all ascending
- return d.size
- elif np.all(~order): # none ascending
- return 0
- else: # count from end how many are ascending
- return np.argmin(order[::-1]) + 1
diff --git a/src/fluency/__init__.py b/src/fluency/__init__.py
new file mode 100644
index 0000000..c6e0c5a
--- /dev/null
+++ b/src/fluency/__init__.py
@@ -0,0 +1,30 @@
+"""
+Fluency CAD - Parametric CAD Application
+
+A modern parametric CAD application built on OpenCASCADE Technology (OCCT)
+with a clean Python API using CadQuery.
+"""
+
+__version__ = "2.0.0"
+__author__ = "Fluency CAD Team"
+
+from fluency.geometry.base import (
+ Point2D,
+ Point3D,
+ GeometryObject,
+ GeometryKernel,
+ SketchInterface,
+)
+
+from fluency.geometry_occ.kernel import OCGeometryKernel
+from fluency.geometry_occ.sketch import OCCSketch
+
+__all__ = [
+ "Point2D",
+ "Point3D",
+ "GeometryObject",
+ "GeometryKernel",
+ "SketchInterface",
+ "OCGeometryKernel",
+ "OCCSketch",
+]
diff --git a/src/fluency/geometry/__init__.py b/src/fluency/geometry/__init__.py
new file mode 100644
index 0000000..d96ff5d
--- /dev/null
+++ b/src/fluency/geometry/__init__.py
@@ -0,0 +1,19 @@
+"""Geometry abstraction layer for Fluency CAD."""
+
+from fluency.geometry.base import (
+ Point2D,
+ Point3D,
+ GeometryObject,
+ GeometryKernel,
+ SketchInterface,
+ SketchEntity,
+)
+
+__all__ = [
+ "Point2D",
+ "Point3D",
+ "GeometryObject",
+ "GeometryKernel",
+ "SketchInterface",
+ "SketchEntity",
+]
diff --git a/src/fluency/geometry/base.py b/src/fluency/geometry/base.py
new file mode 100644
index 0000000..0c2c7e8
--- /dev/null
+++ b/src/fluency/geometry/base.py
@@ -0,0 +1,437 @@
+"""
+Geometry abstraction layer for Fluency CAD.
+
+This module defines abstract interfaces for geometry operations,
+allowing different geometry kernels to be used interchangeably.
+"""
+
+from abc import ABC, abstractmethod
+from dataclasses import dataclass
+from typing import List, Tuple, Optional, Any, Dict
+import numpy as np
+
+
+@dataclass
+class Point2D:
+ """2D point representation."""
+
+ x: float
+ y: float
+
+ def to_tuple(self) -> Tuple[float, float]:
+ return (self.x, self.y)
+
+ def to_array(self) -> np.ndarray:
+ return np.array([self.x, self.y])
+
+ def distance_to(self, other: "Point2D") -> float:
+ return np.sqrt((self.x - other.x) ** 2 + (self.y - other.y) ** 2)
+
+ def __eq__(self, other: object) -> bool:
+ if not isinstance(other, Point2D):
+ return False
+ return abs(self.x - other.x) < 1e-6 and abs(self.y - other.y) < 1e-6
+
+
+@dataclass
+class Point3D:
+ """3D point representation."""
+
+ x: float
+ y: float
+ z: float
+
+ def to_tuple(self) -> Tuple[float, float, float]:
+ return (self.x, self.y, self.z)
+
+ def to_array(self) -> np.ndarray:
+ return np.array([self.x, self.y, self.z])
+
+ def distance_to(self, other: "Point3D") -> float:
+ return np.sqrt((self.x - other.x) ** 2 + (self.y - other.y) ** 2 + (self.z - other.z) ** 2)
+
+ def __eq__(self, other: object) -> bool:
+ if not isinstance(other, Point3D):
+ return False
+ return (
+ abs(self.x - other.x) < 1e-6
+ and abs(self.y - other.y) < 1e-6
+ and abs(self.z - other.z) < 1e-6
+ )
+
+
+class GeometryObject:
+ """Base class for geometry objects."""
+
+ def __init__(self, shape: Any = None, metadata: Optional[Dict] = None):
+ self.shape = shape
+ self.metadata = metadata or {}
+ self._mesh_cache: Optional[Tuple[np.ndarray, np.ndarray]] = None
+
+ def invalidate_cache(self) -> None:
+ """Invalidate any cached data."""
+ self._mesh_cache = None
+
+
+class SketchEntity:
+ """Base class for sketch entities (points, lines, circles)."""
+
+ def __init__(self, entity_id: int, entity_type: str):
+ self.id = entity_id
+ self.entity_type = entity_type
+ self.constraints: List[str] = []
+ self.is_construction: bool = False
+
+ def add_constraint(self, constraint_type: str) -> None:
+ self.constraints.append(constraint_type)
+
+
+class GeometryKernel(ABC):
+ """
+ Abstract base class for geometry kernels.
+
+ A geometry kernel provides primitives, operations, and export capabilities
+ for CAD geometry.
+ """
+
+ @abstractmethod
+ def create_point(self, x: float, y: float) -> GeometryObject:
+ """Create a 2D point."""
+ pass
+
+ @abstractmethod
+ def create_line(self, start: Point2D, end: Point2D) -> GeometryObject:
+ """Create a 2D line segment."""
+ pass
+
+ @abstractmethod
+ def create_circle(self, center: Point2D, radius: float) -> GeometryObject:
+ """Create a 2D circle."""
+ pass
+
+ @abstractmethod
+ def create_arc(
+ self, center: Point2D, radius: float, start_angle: float, end_angle: float
+ ) -> GeometryObject:
+ """Create a 2D arc."""
+ pass
+
+ @abstractmethod
+ def create_polygon(self, points: List[Point2D]) -> GeometryObject:
+ """Create a closed polygon from points."""
+ pass
+
+ @abstractmethod
+ def create_rectangle(
+ self, width: float, height: float, center: Optional[Point2D] = None
+ ) -> GeometryObject:
+ """Create a rectangle."""
+ pass
+
+ @abstractmethod
+ def extrude(
+ self,
+ sketch: GeometryObject,
+ height: float,
+ direction: Tuple[float, float, float] = (0, 0, 1),
+ symmetric: bool = False,
+ ) -> GeometryObject:
+ """Extrude a 2D sketch into a 3D solid."""
+ pass
+
+ @abstractmethod
+ def revolve(
+ self,
+ sketch: GeometryObject,
+ angle: float = 360.0,
+ axis: Tuple[float, float, float] = (0, 0, 1),
+ origin: Tuple[float, float, float] = (0, 0, 0),
+ ) -> GeometryObject:
+ """Revolve a 2D sketch around an axis."""
+ pass
+
+ @abstractmethod
+ def loft(self, profiles: List[GeometryObject], ruled: bool = False) -> GeometryObject:
+ """Create a loft between multiple profiles."""
+ pass
+
+ @abstractmethod
+ def sweep(
+ self, profile: GeometryObject, path: GeometryObject, is_frenet: bool = False
+ ) -> GeometryObject:
+ """Sweep a profile along a path."""
+ pass
+
+ @abstractmethod
+ def boolean_union(self, *bodies: GeometryObject) -> GeometryObject:
+ """Union multiple bodies."""
+ pass
+
+ @abstractmethod
+ def boolean_difference(self, base: GeometryObject, tool: GeometryObject) -> GeometryObject:
+ """Subtract tool from base."""
+ pass
+
+ @abstractmethod
+ def boolean_intersection(self, body1: GeometryObject, body2: GeometryObject) -> GeometryObject:
+ """Intersect two bodies."""
+ pass
+
+ @abstractmethod
+ def fillet(
+ self, body: GeometryObject, radius: float, edges: Optional[List[Any]] = None
+ ) -> GeometryObject:
+ """Apply fillet to edges."""
+ pass
+
+ @abstractmethod
+ def chamfer(
+ self, body: GeometryObject, size: float, edges: Optional[List[Any]] = None
+ ) -> GeometryObject:
+ """Apply chamfer to edges."""
+ pass
+
+ @abstractmethod
+ def shell(
+ self, body: GeometryObject, thickness: float, faces_to_remove: Optional[List[Any]] = None
+ ) -> GeometryObject:
+ """Create a shell (hollow body)."""
+ pass
+
+ @abstractmethod
+ def offset(self, face: GeometryObject, distance: float) -> GeometryObject:
+ """Offset a face or surface."""
+ pass
+
+ @abstractmethod
+ def translate(self, body: GeometryObject, vector: Tuple[float, float, float]) -> GeometryObject:
+ """Translate a body."""
+ pass
+
+ @abstractmethod
+ def rotate(
+ self,
+ body: GeometryObject,
+ axis: Tuple[float, float, float],
+ angle: float,
+ origin: Tuple[float, float, float] = (0, 0, 0),
+ ) -> GeometryObject:
+ """Rotate a body around an axis."""
+ pass
+
+ @abstractmethod
+ def scale(self, body: GeometryObject, factor: float) -> GeometryObject:
+ """Scale a body uniformly."""
+ pass
+
+ @abstractmethod
+ def mirror(
+ self,
+ body: GeometryObject,
+ plane_normal: Tuple[float, float, float],
+ plane_origin: Tuple[float, float, float] = (0, 0, 0),
+ ) -> GeometryObject:
+ """Mirror a body across a plane."""
+ pass
+
+ @abstractmethod
+ def export_step(self, body: GeometryObject, filepath: str, schema: str = "AP214") -> bool:
+ """Export to STEP format."""
+ pass
+
+ @abstractmethod
+ def export_iges(self, body: GeometryObject, filepath: str) -> bool:
+ """Export to IGES format."""
+ pass
+
+ @abstractmethod
+ def export_stl(
+ self, body: GeometryObject, filepath: str, tolerance: float = 0.1, ascii_mode: bool = False
+ ) -> bool:
+ """Export to STL format."""
+ pass
+
+ @abstractmethod
+ def import_step(self, filepath: str) -> GeometryObject:
+ """Import from STEP format."""
+ pass
+
+ @abstractmethod
+ def import_iges(self, filepath: str) -> GeometryObject:
+ """Import from IGES format."""
+ pass
+
+ @abstractmethod
+ def get_mesh(
+ self, body: GeometryObject, tolerance: float = 0.1
+ ) -> Tuple[np.ndarray, np.ndarray]:
+ """
+ Get triangulated mesh for rendering.
+
+ Returns:
+ Tuple of (vertices, faces) where:
+ - vertices: Nx3 numpy array of vertex positions
+ - faces: Mx3 numpy array of triangle indices
+ """
+ pass
+
+ @abstractmethod
+ def get_edges(self, body: GeometryObject) -> Tuple[np.ndarray, np.ndarray]:
+ """
+ Get edge wireframe for rendering.
+
+ Returns:
+ Tuple of (vertices, edges) where:
+ - vertices: Nx3 numpy array of vertex positions
+ - edges: Mx2 numpy array of edge vertex indices
+ """
+ pass
+
+ @abstractmethod
+ def get_bounding_box(self, body: GeometryObject) -> Tuple[Point3D, Point3D]:
+ """Get the bounding box of a body."""
+ pass
+
+ @abstractmethod
+ def get_volume(self, body: GeometryObject) -> float:
+ """Calculate the volume of a solid body."""
+ pass
+
+ @abstractmethod
+ def get_surface_area(self, body: GeometryObject) -> float:
+ """Calculate the surface area of a body."""
+ pass
+
+ @abstractmethod
+ def get_center_of_mass(self, body: GeometryObject) -> Point3D:
+ """Calculate the center of mass of a solid body."""
+ pass
+
+
+class SketchInterface(ABC):
+ """
+ Abstract interface for 2D sketching with constraints.
+
+ A sketch provides 2D geometry creation and constraint solving
+ capabilities for parametric CAD.
+ """
+
+ @abstractmethod
+ def add_point(self, x: float, y: float) -> SketchEntity:
+ """Add a point to the sketch."""
+ pass
+
+ @abstractmethod
+ def add_line(self, start: SketchEntity, end: SketchEntity) -> SketchEntity:
+ """Add a line between two points."""
+ pass
+
+ @abstractmethod
+ def add_circle(self, center: SketchEntity, radius: float) -> SketchEntity:
+ """Add a circle."""
+ pass
+
+ @abstractmethod
+ def add_arc(
+ self,
+ center: SketchEntity,
+ radius: float,
+ start_point: SketchEntity,
+ end_point: SketchEntity,
+ ) -> SketchEntity:
+ """Add an arc."""
+ pass
+
+ @abstractmethod
+ def add_rectangle(
+ self, corner1: Tuple[float, float], corner2: Tuple[float, float]
+ ) -> List[SketchEntity]:
+ """Add a rectangle, returning the created entities."""
+ pass
+
+ @abstractmethod
+ def constrain_coincident(self, *entities: SketchEntity) -> bool:
+ """Make entities coincident."""
+ pass
+
+ @abstractmethod
+ def constrain_horizontal(self, line: SketchEntity) -> bool:
+ """Constrain a line to be horizontal."""
+ pass
+
+ @abstractmethod
+ def constrain_vertical(self, line: SketchEntity) -> bool:
+ """Constrain a line to be vertical."""
+ pass
+
+ @abstractmethod
+ def constrain_distance(
+ self, entity1: SketchEntity, entity2: SketchEntity, distance: float
+ ) -> bool:
+ """Constrain distance between two entities."""
+ pass
+
+ @abstractmethod
+ def constrain_angle(self, line1: SketchEntity, line2: SketchEntity, angle: float) -> bool:
+ """Constrain angle between two lines."""
+ pass
+
+ @abstractmethod
+ def constrain_parallel(self, line1: SketchEntity, line2: SketchEntity) -> bool:
+ """Constrain two lines to be parallel."""
+ pass
+
+ @abstractmethod
+ def constrain_perpendicular(self, line1: SketchEntity, line2: SketchEntity) -> bool:
+ """Constrain two lines to be perpendicular."""
+ pass
+
+ @abstractmethod
+ def constrain_midpoint(self, point: SketchEntity, line: SketchEntity) -> bool:
+ """Constrain a point to be at the midpoint of a line."""
+ pass
+
+ @abstractmethod
+ def constrain_tangent(self, entity1: SketchEntity, entity2: SketchEntity) -> bool:
+ """Constrain two entities to be tangent."""
+ pass
+
+ @abstractmethod
+ def constrain_equal_length(self, line1: SketchEntity, line2: SketchEntity) -> bool:
+ """Constrain two lines to have equal length."""
+ pass
+
+ @abstractmethod
+ def constrain_equal_radius(self, circle1: SketchEntity, circle2: SketchEntity) -> bool:
+ """Constrain two circles to have equal radius."""
+ pass
+
+ @abstractmethod
+ def constrain_fixed(self, entity: SketchEntity) -> bool:
+ """Fix an entity in place."""
+ pass
+
+ @abstractmethod
+ def solve(self) -> bool:
+ """Solve all constraints."""
+ pass
+
+ @abstractmethod
+ def get_geometry(self) -> GeometryObject:
+ """Get the solved geometry for operations."""
+ pass
+
+ @abstractmethod
+ def get_points(self) -> List[Point2D]:
+ """Get all point positions."""
+ pass
+
+ @abstractmethod
+ def clear(self) -> None:
+ """Clear all geometry and constraints."""
+ pass
+
+ @abstractmethod
+ def delete_entity(self, entity: SketchEntity) -> bool:
+ """Delete an entity and its constraints."""
+ pass
diff --git a/src/fluency/geometry_occ/__init__.py b/src/fluency/geometry_occ/__init__.py
new file mode 100644
index 0000000..392216a
--- /dev/null
+++ b/src/fluency/geometry_occ/__init__.py
@@ -0,0 +1,11 @@
+"""OpenCASCADE geometry module."""
+
+from fluency.geometry_occ.kernel import OCGeometryKernel, OCCGeometryObject
+from fluency.geometry_occ.sketch import OCCSketch, OCCSketchEntity
+
+__all__ = [
+ "OCGeometryKernel",
+ "OCCGeometryObject",
+ "OCCSketch",
+ "OCCSketchEntity",
+]
diff --git a/src/fluency/geometry_occ/kernel.py b/src/fluency/geometry_occ/kernel.py
new file mode 100644
index 0000000..37e372d
--- /dev/null
+++ b/src/fluency/geometry_occ/kernel.py
@@ -0,0 +1,726 @@
+"""
+OpenCASCADE-based geometry kernel for Fluency CAD.
+
+This module provides a concrete implementation of the geometry kernel
+using CadQuery and OCP (OpenCASCADE Python bindings).
+"""
+
+from typing import List, Tuple, Optional, Any, Dict
+import numpy as np
+
+from fluency.geometry.base import (
+ GeometryKernel,
+ GeometryObject,
+ Point2D,
+ Point3D,
+)
+
+
+class OCCGeometryObject(GeometryObject):
+ """Geometry object wrapper for OpenCASCADE shapes."""
+
+ def __init__(self, shape: Any = None, metadata: Optional[Dict] = None):
+ super().__init__(shape, metadata)
+ self._cadquery_obj: Any = None
+
+ @property
+ def cq_obj(self) -> Any:
+ """Get the CadQuery object if available."""
+ return self._cadquery_obj
+
+ @cq_obj.setter
+ def cq_obj(self, value: Any) -> None:
+ self._cadquery_obj = value
+
+
+class OCGeometryKernel(GeometryKernel):
+ """
+ OpenCASCADE-based geometry kernel implementation.
+
+ This kernel uses CadQuery for high-level operations and
+ OCP for direct OpenCASCADE access when needed.
+ """
+
+ def __init__(self) -> None:
+ self._tolerance: float = 0.001
+ self._mesh_tolerance: float = 0.1
+
+ def _get_shape(self, obj: GeometryObject) -> Any:
+ """Extract the underlying OCC shape from a GeometryObject."""
+ if isinstance(obj, OCCGeometryObject):
+ if obj._cadquery_obj is not None:
+ shape = obj._cadquery_obj.val()
+ if hasattr(shape, "wrapped"):
+ return shape.wrapped
+ return shape
+ if obj.shape is not None:
+ if hasattr(obj.shape, "wrapped"):
+ return obj.shape.wrapped
+ return obj.shape
+ return obj.shape if obj.shape else obj
+
+ def _get_cq_obj(self, obj: GeometryObject) -> Any:
+ """Get CadQuery object from GeometryObject."""
+ if isinstance(obj, OCCGeometryObject) and obj._cadquery_obj is not None:
+ return obj._cadquery_obj
+ return obj.shape
+
+ def create_point(self, x: float, y: float) -> GeometryObject:
+ """Create a 2D point."""
+ import cadquery as cq
+
+ point = cq.Vector(x, y, 0)
+ return OCCGeometryObject(point)
+
+ def create_line(self, start: Point2D, end: Point2D) -> GeometryObject:
+ """Create a 2D line segment."""
+ import cadquery as cq
+
+ wire = cq.Workplane("XY").moveTo(start.x, start.y).lineTo(end.x, end.y)
+ return OCCGeometryObject(wire.val(), {"type": "line"})
+
+ def create_circle(self, center: Point2D, radius: float) -> GeometryObject:
+ """Create a 2D circle."""
+ import cadquery as cq
+
+ wire = cq.Workplane("XY").center(center.x, center.y).circle(radius)
+ return OCCGeometryObject(wire.val(), {"type": "circle"})
+
+ def create_arc(
+ self, center: Point2D, radius: float, start_angle: float, end_angle: float
+ ) -> GeometryObject:
+ """Create a 2D arc."""
+ import cadquery as cq
+ import math
+
+ start_rad = math.radians(start_angle)
+ end_rad = math.radians(end_angle)
+
+ start_x = center.x + radius * math.cos(start_rad)
+ start_y = center.y + radius * math.sin(start_rad)
+
+ wire = (
+ cq.Workplane("XY")
+ .moveTo(start_x, start_y)
+ .radiusArc(
+ (center.x + radius * math.cos(end_rad), center.y + radius * math.sin(end_rad)),
+ radius,
+ )
+ )
+ return OCCGeometryObject(wire.val(), {"type": "arc"})
+
+ def create_polygon(self, points: List[Point2D]) -> GeometryObject:
+ """Create a closed polygon from points."""
+ import cadquery as cq
+
+ if len(points) < 3:
+ raise ValueError("Polygon requires at least 3 points")
+
+ wp = cq.Workplane("XY").moveTo(points[0].x, points[0].y)
+ for pt in points[1:]:
+ wp = wp.lineTo(pt.x, pt.y)
+ wp = wp.close()
+
+ return OCCGeometryObject(wp.val(), {"type": "polygon"})
+
+ def create_rectangle(
+ self, width: float, height: float, center: Optional[Point2D] = None
+ ) -> GeometryObject:
+ """Create a rectangle."""
+ import cadquery as cq
+
+ cx = center.x if center else 0
+ cy = center.y if center else 0
+
+ wire = cq.Workplane("XY").center(cx, cy).rect(width, height)
+ return OCCGeometryObject(wire.val(), {"type": "rectangle"})
+
+ def extrude(
+ self,
+ sketch: GeometryObject,
+ height: float,
+ direction: Tuple[float, float, float] = (0, 0, 1),
+ symmetric: bool = False,
+ ) -> GeometryObject:
+ """Extrude a 2D sketch into a 3D solid."""
+ import cadquery as cq
+
+ cq_obj = self._get_cq_obj(sketch)
+
+ if symmetric:
+ half_height = height / 2
+ if isinstance(cq_obj, cq.Workplane):
+ solid = cq_obj.extrude(half_height, both=True)
+ else:
+ face = cq.Face.makeFromWires(cq_obj)
+ solid = face.extrude(cq.Vector(0, 0, half_height) * 2)
+ else:
+ if isinstance(cq_obj, cq.Workplane):
+ solid = cq_obj.extrude(height)
+ else:
+ face = cq.Face.makeFromWires(cq_obj)
+ dir_vec = cq.Vector(*direction).normalized() * height
+ solid = face.extrude(dir_vec)
+
+ return OCCGeometryObject(solid, {"type": "extrusion"})
+
+ def revolve(
+ self,
+ sketch: GeometryObject,
+ angle: float = 360.0,
+ axis: Tuple[float, float, float] = (0, 0, 1),
+ origin: Tuple[float, float, float] = (0, 0, 0),
+ ) -> GeometryObject:
+ """Revolve a 2D sketch around an axis."""
+ import cadquery as cq
+
+ cq_obj = self._get_cq_obj(sketch)
+
+ if isinstance(cq_obj, cq.Workplane):
+ solid = cq_obj.revolve(angle)
+ else:
+ face = cq.Face.makeFromWires(cq_obj)
+ axis_vec = cq.Vector(*axis)
+ origin_vec = cq.Vector(*origin)
+ solid = face.revolve(axis_vec, origin_vec, angle)
+
+ return OCCGeometryObject(solid, {"type": "revolution"})
+
+ def loft(self, profiles: List[GeometryObject], ruled: bool = False) -> GeometryObject:
+ """Create a loft between multiple profiles."""
+ import cadquery as cq
+
+ if len(profiles) < 2:
+ raise ValueError("Loft requires at least 2 profiles")
+
+ wires = []
+ for profile in profiles:
+ cq_obj = self._get_cq_obj(profile)
+ if isinstance(cq_obj, cq.Workplane):
+ wires.append(cq_obj.val())
+ else:
+ wires.append(cq_obj)
+
+ loft = cq.Solid.loft(wires, ruled)
+ return OCCGeometryObject(loft, {"type": "loft"})
+
+ def sweep(
+ self, profile: GeometryObject, path: GeometryObject, is_frenet: bool = False
+ ) -> GeometryObject:
+ """Sweep a profile along a path."""
+ import cadquery as cq
+
+ profile_obj = self._get_cq_obj(profile)
+ path_obj = self._get_cq_obj(path)
+
+ if isinstance(profile_obj, cq.Workplane):
+ profile_wire = profile_obj.val()
+ else:
+ profile_wire = profile_obj
+
+ if isinstance(path_obj, cq.Workplane):
+ path_wire = path_obj.val()
+ else:
+ path_wire = path_obj
+
+ solid = cq.Solid.sweep(profile_wire, path_wire, is_frenet)
+ return OCCGeometryObject(solid, {"type": "sweep"})
+
+ def boolean_union(self, *bodies: GeometryObject) -> GeometryObject:
+ """Union multiple bodies."""
+ import cadquery as cq
+
+ if len(bodies) < 2:
+ return bodies[0] if bodies else OCCGeometryObject(None)
+
+ result = self._get_shape(bodies[0])
+ for body in bodies[1:]:
+ shape = self._get_shape(body)
+ from OCP.BRepAlgoAPI import BRepAlgoAPI_Fuse
+
+ fuse = BRepAlgoAPI_Fuse(result, shape)
+ fuse.Build()
+ result = fuse.Shape()
+
+ return OCCGeometryObject(cq.Shape(result), {"type": "union"})
+
+ def boolean_difference(self, base: GeometryObject, tool: GeometryObject) -> GeometryObject:
+ """Subtract tool from base."""
+ import cadquery as cq
+
+ base_shape = self._get_shape(base)
+ tool_shape = self._get_shape(tool)
+
+ from OCP.BRepAlgoAPI import BRepAlgoAPI_Cut
+
+ cut = BRepAlgoAPI_Cut(base_shape, tool_shape)
+ cut.Build()
+
+ return OCCGeometryObject(cq.Shape(cut.Shape()), {"type": "difference"})
+
+ def boolean_intersection(self, body1: GeometryObject, body2: GeometryObject) -> GeometryObject:
+ """Intersect two bodies."""
+ import cadquery as cq
+
+ shape1 = self._get_shape(body1)
+ shape2 = self._get_shape(body2)
+
+ from OCP.BRepAlgoAPI import BRepAlgoAPI_Common
+
+ common = BRepAlgoAPI_Common(shape1, shape2)
+ common.Build()
+
+ return OCCGeometryObject(cq.Shape(common.Shape()), {"type": "intersection"})
+
+ def fillet(
+ self, body: GeometryObject, radius: float, edges: Optional[List[Any]] = None
+ ) -> GeometryObject:
+ """Apply fillet to edges."""
+ import cadquery as cq
+
+ cq_obj = self._get_cq_obj(body)
+
+ if isinstance(cq_obj, cq.Workplane):
+ if edges:
+ result = cq_obj.edges(edges).fillet(radius)
+ else:
+ result = cq_obj.edges().fillet(radius)
+ else:
+ shape = self._get_shape(body)
+ from OCP.BRepFilletAPI import BRepFilletAPI_MakeFillet
+
+ fillet = BRepFilletAPI_MakeFillet(shape)
+
+ from OCP.TopExp import TopExp_Explorer
+ from OCP.TopAbs import TopAbs_EDGE
+
+ explorer = TopExp_Explorer(shape, TopAbs_EDGE)
+ while explorer.More():
+ fillet.Add(radius, explorer.Current())
+ explorer.Next()
+
+ result = cq.Shape(fillet.Shape())
+
+ return OCCGeometryObject(result, {"type": "fillet"})
+
+ def chamfer(
+ self, body: GeometryObject, size: float, edges: Optional[List[Any]] = None
+ ) -> GeometryObject:
+ """Apply chamfer to edges."""
+ import cadquery as cq
+
+ cq_obj = self._get_cq_obj(body)
+
+ if isinstance(cq_obj, cq.Workplane):
+ if edges:
+ result = cq_obj.edges(edges).chamfer(size)
+ else:
+ result = cq_obj.edges().chamfer(size)
+ else:
+ shape = self._get_shape(body)
+ from OCP.BRepFilletAPI import BRepFilletAPI_MakeChamfer
+
+ chamfer = BRepFilletAPI_MakeChamfer(shape)
+
+ from OCP.TopExp import TopExp_Explorer
+ from OCP.TopAbs import TopAbs_EDGE
+
+ explorer = TopExp_Explorer(shape, TopAbs_EDGE)
+ while explorer.More():
+ chamfer.Add(size, explorer.Current())
+ explorer.Next()
+
+ result = cq.Shape(chamfer.Shape())
+
+ return OCCGeometryObject(result, {"type": "chamfer"})
+
+ def shell(
+ self, body: GeometryObject, thickness: float, faces_to_remove: Optional[List[Any]] = None
+ ) -> GeometryObject:
+ """Create a shell (hollow body)."""
+ import cadquery as cq
+
+ cq_obj = self._get_cq_obj(body)
+
+ if isinstance(cq_obj, cq.Workplane):
+ if faces_to_remove:
+ result = cq_obj.faces(faces_to_remove).shell(thickness)
+ else:
+ result = cq_obj.shell(thickness)
+ else:
+ shape = self._get_shape(body)
+ from OCP.BRepOffsetAPI import BRepOffsetAPI_MakeThickSolid
+ from OCP.TopTools import TopTools_ListOfShape
+
+ faces_list = TopTools_ListOfShape()
+ if faces_to_remove:
+ for face in faces_to_remove:
+ faces_list.Append(face)
+
+ shell_maker = BRepOffsetAPI_MakeThickSolid()
+ shell_maker.MakeThickSolidByJoin(shape, faces_list, thickness, 0.001)
+ shell_maker.Build()
+ result = cq.Shape(shell_maker.Shape())
+
+ return OCCGeometryObject(result, {"type": "shell"})
+
+ def offset(self, face: GeometryObject, distance: float) -> GeometryObject:
+ """Offset a face or surface."""
+ import cadquery as cq
+
+ shape = self._get_shape(face)
+ from OCP.BRepOffsetAPI import BRepOffsetAPI_MakeOffset
+
+ offset_maker = BRepOffsetAPI_MakeOffset(shape, False)
+ offset_maker.Perform(distance)
+
+ return OCCGeometryObject(cq.Shape(offset_maker.Shape()), {"type": "offset"})
+
+ def translate(self, body: GeometryObject, vector: Tuple[float, float, float]) -> GeometryObject:
+ """Translate a body."""
+ import cadquery as cq
+
+ cq_obj = self._get_cq_obj(body)
+
+ if isinstance(cq_obj, cq.Workplane):
+ result = cq_obj.translate(vector)
+ else:
+ shape = self._get_shape(body)
+ from OCP.BRepBuilderAPI import BRepBuilderAPI_Transform
+ from OCP.gp import gp_Trsf, gp_Vec
+
+ transform = gp_Trsf()
+ transform.SetTranslation(gp_Vec(*vector))
+ transformer = BRepBuilderAPI_Transform(shape, transform)
+ result = cq.Shape(transformer.Shape())
+
+ return OCCGeometryObject(result, {"type": "translated"})
+
+ def rotate(
+ self,
+ body: GeometryObject,
+ axis: Tuple[float, float, float],
+ angle: float,
+ origin: Tuple[float, float, float] = (0, 0, 0),
+ ) -> GeometryObject:
+ """Rotate a body around an axis."""
+ import cadquery as cq
+ import math
+
+ cq_obj = self._get_cq_obj(body)
+
+ if isinstance(cq_obj, cq.Workplane):
+ result = cq_obj.rotate(origin, axis, math.degrees(angle))
+ else:
+ shape = self._get_shape(body)
+ from OCP.BRepBuilderAPI import BRepBuilderAPI_Transform
+ from OCP.gp import gp_Trsf, gp_Ax1, gp_Pnt, gp_Dir, gp_Vec
+
+ ax1 = gp_Ax1(gp_Pnt(*origin), gp_Dir(*axis))
+ transform = gp_Trsf()
+ transform.SetRotation(ax1, angle)
+ transformer = BRepBuilderAPI_Transform(shape, transform)
+ result = cq.Shape(transformer.Shape())
+
+ return OCCGeometryObject(result, {"type": "rotated"})
+
+ def scale(self, body: GeometryObject, factor: float) -> GeometryObject:
+ """Scale a body uniformly."""
+ import cadquery as cq
+
+ shape = self._get_shape(body)
+ from OCP.BRepBuilderAPI import BRepBuilderAPI_Transform
+ from OCP.gp import gp_Trsf
+
+ transform = gp_Trsf()
+ transform.SetScale(factor)
+ transformer = BRepBuilderAPI_Transform(shape, transform)
+
+ return OCCGeometryObject(cq.Shape(transformer.Shape()), {"type": "scaled"})
+
+ def mirror(
+ self,
+ body: GeometryObject,
+ plane_normal: Tuple[float, float, float],
+ plane_origin: Tuple[float, float, float] = (0, 0, 0),
+ ) -> GeometryObject:
+ """Mirror a body across a plane."""
+ import cadquery as cq
+
+ shape = self._get_shape(body)
+ from OCP.BRepBuilderAPI import BRepBuilderAPI_Transform
+ from OCP.gp import gp_Trsf, gp_Ax2, gp_Pnt, gp_Dir
+
+ ax2 = gp_Ax2(gp_Pnt(*plane_origin), gp_Dir(*plane_normal))
+ transform = gp_Trsf()
+ transform.SetMirror(ax2)
+ transformer = BRepBuilderAPI_Transform(shape, transform)
+
+ return OCCGeometryObject(cq.Shape(transformer.Shape()), {"type": "mirrored"})
+
+ def export_step(self, body: GeometryObject, filepath: str, schema: str = "AP214") -> bool:
+ """Export to STEP format."""
+ try:
+ import cadquery as cq
+
+ shape = self._get_shape(body)
+
+ if hasattr(shape, "exportStep"):
+ shape.exportStep(filepath)
+ return True
+
+ from OCP.STEPControl import STEPControl_Writer, STEPControl_AsIs
+ from OCP.Interface import Interface_Static
+
+ writer = STEPControl_Writer()
+ if schema == "AP214":
+ Interface_Static.SetCVal_s("write.step.schema", "AP214")
+ elif schema == "AP203":
+ Interface_Static.SetCVal_s("write.step.schema", "AP203")
+
+ writer.Transfer(shape, STEPControl_AsIs)
+ writer.Write(filepath)
+ return True
+ except Exception as e:
+ print(f"STEP export error: {e}")
+ return False
+
+ def export_iges(self, body: GeometryObject, filepath: str) -> bool:
+ """Export to IGES format."""
+ try:
+ import cadquery as cq
+
+ shape = self._get_shape(body)
+
+ from OCP.IGESControl import IGESControl_Writer
+ from OCP.Interface import Interface_Static
+
+ Interface_Static.SetCVal_s("write.iges.schema", "5.3")
+ writer = IGESControl_Writer()
+ writer.AddShape(shape)
+ writer.Write(filepath)
+ return True
+ except Exception as e:
+ print(f"IGES export error: {e}")
+ return False
+
+ def export_stl(
+ self, body: GeometryObject, filepath: str, tolerance: float = 0.1, ascii_mode: bool = False
+ ) -> bool:
+ """Export to STL format."""
+ try:
+ import cadquery as cq
+
+ shape = self._get_shape(body)
+
+ if hasattr(shape, "exportStl"):
+ shape.exportStl(filepath, tolerance)
+ return True
+
+ from OCP.StlAPI import StlAPI_Writer
+ from OCP.BRepMesh import BRepMesh_IncrementalMesh
+
+ mesh = BRepMesh_IncrementalMesh(shape, tolerance)
+ mesh.Perform()
+
+ writer = StlAPI_Writer()
+ writer.ASCIIMode = ascii_mode
+ writer.Write(shape, filepath)
+ return True
+ except Exception as e:
+ print(f"STL export error: {e}")
+ return False
+
+ def import_step(self, filepath: str) -> GeometryObject:
+ """Import from STEP format."""
+ import cadquery as cq
+
+ result = cq.importers.importStep(filepath)
+ return OCCGeometryObject(result, {"type": "imported_step"})
+
+ def import_iges(self, filepath: str) -> GeometryObject:
+ """Import from IGES format."""
+ import cadquery as cq
+
+ from OCP.IGESControl import IGESControl_Reader
+ from OCP.IFSelect import IFSelect_RetDone
+
+ reader = IGESControl_Reader()
+ status = reader.ReadFile(filepath)
+
+ if status != IFSelect_RetDone:
+ raise ValueError(f"Failed to read IGES file: {filepath}")
+
+ reader.TransferRoots()
+ shape = reader.OneShape()
+
+ return OCCGeometryObject(cq.Shape(shape), {"type": "imported_iges"})
+
+ def get_mesh(
+ self, body: GeometryObject, tolerance: float = 0.1
+ ) -> Tuple[np.ndarray, np.ndarray]:
+ """Get triangulated mesh for rendering."""
+ import cadquery as cq
+
+ shape = self._get_shape(body)
+
+ if hasattr(shape, "tessellate"):
+ vertices, faces = shape.tessellate(tolerance)
+ return np.array(vertices), np.array(faces)
+
+ from OCP.BRepMesh import BRepMesh_IncrementalMesh
+ from OCP.TopExp import TopExp_Explorer
+ from OCP.TopAbs import TopAbs_FACE
+ from OCP.BRep import BRep_Tool
+ from OCP.Poly import Poly_Triangulation
+ from OCP.TopLoc import TopLoc_Location
+
+ mesh = BRepMesh_IncrementalMesh(shape, tolerance)
+ mesh.Perform()
+
+ vertices_list: List[List[float]] = []
+ faces_list: List[List[int]] = []
+ vertex_offset = 0
+
+ explorer = TopExp_Explorer(shape, TopAbs_FACE)
+ while explorer.More():
+ face = explorer.Current()
+ location = TopLoc_Location()
+ triangulation = BRep_Tool.Triangulation_s(face, location)
+
+ if triangulation is not None:
+ n_vertices = triangulation.NbNodes()
+ for i in range(1, n_vertices + 1):
+ p = triangulation.Node(i)
+ vertices_list.append([p.X(), p.Y(), p.Z()])
+
+ n_triangles = triangulation.NbTriangles()
+ for i in range(1, n_triangles + 1):
+ tri = triangulation.Triangle(i)
+ faces_list.append(
+ [
+ tri.Value(1) - 1 + vertex_offset,
+ tri.Value(2) - 1 + vertex_offset,
+ tri.Value(3) - 1 + vertex_offset,
+ ]
+ )
+
+ vertex_offset += n_vertices
+
+ explorer.Next()
+
+ return np.array(vertices_list, dtype=np.float32), np.array(faces_list, dtype=np.int32)
+
+ def get_edges(self, body: GeometryObject) -> Tuple[np.ndarray, np.ndarray]:
+ """Get edge wireframe for rendering."""
+ import cadquery as cq
+
+ shape = self._get_shape(body)
+
+ from OCP.TopExp import TopExp_Explorer
+ from OCP.TopAbs import TopAbs_EDGE
+ from OCP.BRep import BRep_Tool
+ from OCP.TopLoc import TopLoc_Location
+ from OCP.BRepAdaptor import BRepAdaptor_Curve
+ from OCP.GeomAbs import GeomAbs_Line, GeomAbs_Circle, GeomAbs_Ellipse, GeomAbs_BSplineCurve
+
+ vertices_list: List[List[float]] = []
+ edges_list: List[List[int]] = []
+ vertex_offset = 0
+
+ def discretize_edge(edge: Any, num_points: int = 20) -> List[List[float]]:
+ curve = BRepAdaptor_Curve(edge)
+ curve_type = curve.GetType()
+
+ points = []
+
+ if curve_type == GeomAbs_Line:
+ first = curve.FirstParameter()
+ last = curve.LastParameter()
+ p1 = curve.Value(first)
+ p2 = curve.Value(last)
+ points = [[p1.X(), p1.Y(), p1.Z()], [p2.X(), p2.Y(), p2.Z()]]
+ else:
+ first = curve.FirstParameter()
+ last = curve.LastParameter()
+
+ for i in range(num_points + 1):
+ t = first + (last - first) * i / num_points
+ p = curve.Value(t)
+ points.append([p.X(), p.Y(), p.Z()])
+
+ return points
+
+ explorer = TopExp_Explorer(shape, TopAbs_EDGE)
+ while explorer.More():
+ edge = explorer.Current()
+ edge_points = discretize_edge(edge)
+
+ for i, pt in enumerate(edge_points):
+ vertices_list.append(pt)
+ if i < len(edge_points) - 1:
+ edges_list.append([vertex_offset + i, vertex_offset + i + 1])
+
+ vertex_offset += len(edge_points)
+ explorer.Next()
+
+ return np.array(vertices_list, dtype=np.float32), np.array(edges_list, dtype=np.int32)
+
+ def get_bounding_box(self, body: GeometryObject) -> Tuple[Point3D, Point3D]:
+ """Get the bounding box of a body."""
+ import cadquery as cq
+
+ shape = self._get_shape(body)
+
+ from OCP.Bnd import Bnd_Box
+ from OCP.BRepBndLib import BRepBndLib_AddClose
+
+ bbox = Bnd_Box()
+ BRepBndLib_AddClose(shape, bbox)
+
+ xmin, ymin, zmin, xmax, ymax, zmax = bbox.Get()
+
+ return Point3D(xmin, ymin, zmin), Point3D(xmax, ymax, zmax)
+
+ def get_volume(self, body: GeometryObject) -> float:
+ """Calculate the volume of a solid body."""
+ import cadquery as cq
+
+ shape = self._get_shape(body)
+
+ from OCP.GProp import GProp_GProps
+ from OCP.BRepGProp import BRepGProp_VolumeProperties
+
+ props = GProp_GProps()
+ BRepGProp_VolumeProperties(shape, props)
+
+ return props.Mass()
+
+ def get_surface_area(self, body: GeometryObject) -> float:
+ """Calculate the surface area of a body."""
+ import cadquery as cq
+
+ shape = self._get_shape(body)
+
+ from OCP.GProp import GProp_GProps
+ from OCP.BRepGProp import BRepGProp_SurfaceProperties
+
+ props = GProp_GProps()
+ BRepGProp_SurfaceProperties(shape, props)
+
+ return props.Mass()
+
+ def get_center_of_mass(self, body: GeometryObject) -> Point3D:
+ """Calculate the center of mass of a solid body."""
+ import cadquery as cq
+
+ shape = self._get_shape(body)
+
+ from OCP.GProp import GProp_GProps
+ from OCP.BRepGProp import BRepGProp_VolumeProperties
+
+ props = GProp_GProps()
+ BRepGProp_VolumeProperties(shape, props)
+
+ cg = props.CentreOfMass()
+ return Point3D(cg.X(), cg.Y(), cg.Z())
diff --git a/src/fluency/geometry_occ/sketch.py b/src/fluency/geometry_occ/sketch.py
new file mode 100644
index 0000000..774f541
--- /dev/null
+++ b/src/fluency/geometry_occ/sketch.py
@@ -0,0 +1,383 @@
+"""
+OpenCASCADE-based sketch with constraint solving for Fluency CAD.
+
+This module provides 2D sketching with parametric constraints using
+CadQuery's built-in constraint solver.
+"""
+
+from typing import List, Tuple, Optional, Dict, Any
+from dataclasses import dataclass, field
+import numpy as np
+
+from fluency.geometry.base import (
+ SketchInterface,
+ SketchEntity,
+ GeometryObject,
+ Point2D,
+)
+from fluency.geometry_occ.kernel import OCCGeometryObject
+
+
+@dataclass
+class OCCSketchEntity(SketchEntity):
+ """Sketch entity for OpenCASCADE-based sketch."""
+
+ geometry: Any = None
+ handle: Any = None
+
+ def __post_init__(self) -> None:
+ if self.constraints is None:
+ self.constraints = []
+
+
+class OCCSketch(SketchInterface):
+ """
+ CadQuery-based sketch with constraint solving.
+
+ This sketch uses CadQuery's Sketch class which provides
+ built-in constraint solving capabilities.
+ """
+
+ def __init__(self) -> None:
+ import cadquery as cq
+
+ self._sketch = cq.Sketch()
+ self._entities: Dict[int, OCCSketchEntity] = {}
+ self._entity_counter: int = 0
+ self._points: Dict[int, Tuple[float, float]] = {}
+ self._lines: Dict[int, Tuple[int, int]] = {}
+ self._circles: Dict[int, Tuple[int, float]] = {}
+ self._arcs: Dict[int, Any] = {}
+ self._constraint_count: int = 0
+
+ def _next_id(self) -> int:
+ self._entity_counter += 1
+ return self._entity_counter
+
+ def add_point(self, x: float, y: float) -> OCCSketchEntity:
+ """Add a point to the sketch."""
+ entity_id = self._next_id()
+
+ self._sketch = self._sketch.point(x, y)
+
+ entity = OCCSketchEntity(entity_id=entity_id, entity_type="point", geometry=(x, y))
+
+ self._entities[entity_id] = entity
+ self._points[entity_id] = (x, y)
+
+ return entity
+
+ def add_line(self, start: SketchEntity, end: SketchEntity) -> OCCSketchEntity:
+ """Add a line between two points."""
+ entity_id = self._next_id()
+
+ start_geom = self._entities.get(start.id)
+ end_geom = self._entities.get(end.id)
+
+ if start_geom is None or end_geom is None:
+ raise ValueError("Start or end point not found in sketch")
+
+ x1, y1 = start_geom.geometry
+ x2, y2 = end_geom.geometry
+
+ self._sketch = self._sketch.line((x1, y1), (x2, y2))
+
+ entity = OCCSketchEntity(
+ entity_id=entity_id, entity_type="line", geometry=((x1, y1), (x2, y2))
+ )
+
+ self._entities[entity_id] = entity
+ self._lines[entity_id] = (start.id, end.id)
+
+ return entity
+
+ def add_circle(self, center: SketchEntity, radius: float) -> OCCSketchEntity:
+ """Add a circle."""
+ entity_id = self._next_id()
+
+ center_entity = self._entities.get(center.id)
+ if center_entity is None:
+ raise ValueError("Center point not found in sketch")
+
+ cx, cy = center_entity.geometry
+
+ self._sketch = self._sketch.circle((cx, cy), radius)
+
+ entity = OCCSketchEntity(
+ entity_id=entity_id, entity_type="circle", geometry=((cx, cy), radius)
+ )
+
+ self._entities[entity_id] = entity
+ self._circles[entity_id] = (center.id, radius)
+
+ return entity
+
+ def add_arc(
+ self,
+ center: SketchEntity,
+ radius: float,
+ start_point: SketchEntity,
+ end_point: SketchEntity,
+ ) -> OCCSketchEntity:
+ """Add an arc."""
+ entity_id = self._next_id()
+
+ center_entity = self._entities.get(center.id)
+ start_entity = self._entities.get(start_point.id)
+ end_entity = self._entities.get(end_point.id)
+
+ if center_entity is None or start_entity is None or end_entity is None:
+ raise ValueError("Arc points not found in sketch")
+
+ cx, cy = center_entity.geometry
+ sx, sy = start_entity.geometry
+ ex, ey = end_entity.geometry
+
+ self._sketch = self._sketch.arc((sx, sy), (ex, ey), (cx, cy))
+
+ entity = OCCSketchEntity(
+ entity_id=entity_id,
+ entity_type="arc",
+ geometry={"center": (cx, cy), "radius": radius, "start": (sx, sy), "end": (ex, ey)},
+ )
+
+ self._entities[entity_id] = entity
+ self._arcs[entity_id] = {
+ "center": center.id,
+ "start": start_point.id,
+ "end": end_point.id,
+ "radius": radius,
+ }
+
+ return entity
+
+ def add_rectangle(
+ self, corner1: Tuple[float, float], corner2: Tuple[float, float]
+ ) -> List[OCCSketchEntity]:
+ """Add a rectangle, returning the created entities."""
+ x1, y1 = corner1
+ x2, y2 = corner2
+
+ entities: List[OCCSketchEntity] = []
+
+ p1 = self.add_point(x1, y1)
+ p2 = self.add_point(x2, y1)
+ p3 = self.add_point(x2, y2)
+ p4 = self.add_point(x1, y2)
+
+ entities.extend([p1, p2, p3, p4])
+
+ l1 = self.add_line(p1, p2)
+ l2 = self.add_line(p2, p3)
+ l3 = self.add_line(p3, p4)
+ l4 = self.add_line(p4, p1)
+
+ entities.extend([l1, l2, l3, l4])
+
+ return entities
+
+ def constrain_coincident(self, *entities: SketchEntity) -> bool:
+ """Make entities coincident."""
+ if len(entities) < 2:
+ return False
+
+ ids = [e.id for e in entities]
+
+ self._sketch = self._sketch.constrain(ids[0], ids[1], "Coincident")
+
+ self._constraint_count += 1
+ return True
+
+ def constrain_horizontal(self, line: SketchEntity) -> bool:
+ """Constrain a line to be horizontal."""
+ self._sketch = self._sketch.constrain(line.id, "Horizontal")
+
+ self._constraint_count += 1
+ return True
+
+ def constrain_vertical(self, line: SketchEntity) -> bool:
+ """Constrain a line to be vertical."""
+ self._sketch = self._sketch.constrain(line.id, "Vertical")
+
+ self._constraint_count += 1
+ return True
+
+ def constrain_distance(
+ self, entity1: SketchEntity, entity2: SketchEntity, distance: float
+ ) -> bool:
+ """Constrain distance between two entities."""
+ self._sketch = self._sketch.constrain(entity1.id, entity2.id, "Distance", distance)
+
+ self._constraint_count += 1
+ return True
+
+ def constrain_angle(self, line1: SketchEntity, line2: SketchEntity, angle: float) -> bool:
+ """Constrain angle between two lines."""
+ self._sketch = self._sketch.constrain(line1.id, line2.id, "Angle", angle)
+
+ self._constraint_count += 1
+ return True
+
+ def constrain_parallel(self, line1: SketchEntity, line2: SketchEntity) -> bool:
+ """Constrain two lines to be parallel."""
+ self._sketch = self._sketch.constrain(line1.id, line2.id, "Parallel")
+
+ self._constraint_count += 1
+ return True
+
+ def constrain_perpendicular(self, line1: SketchEntity, line2: SketchEntity) -> bool:
+ """Constrain two lines to be perpendicular."""
+ self._sketch = self._sketch.constrain(line1.id, line2.id, "Perpendicular")
+
+ self._constraint_count += 1
+ return True
+
+ def constrain_midpoint(self, point: SketchEntity, line: SketchEntity) -> bool:
+ """Constrain a point to be at the midpoint of a line."""
+ self._sketch = self._sketch.constrain(point.id, line.id, "Midpoint")
+
+ self._constraint_count += 1
+ return True
+
+ def constrain_tangent(self, entity1: SketchEntity, entity2: SketchEntity) -> bool:
+ """Constrain two entities to be tangent."""
+ self._sketch = self._sketch.constrain(entity1.id, entity2.id, "Tangent")
+
+ self._constraint_count += 1
+ return True
+
+ def constrain_equal_length(self, line1: SketchEntity, line2: SketchEntity) -> bool:
+ """Constrain two lines to have equal length."""
+ self._sketch = self._sketch.constrain(line1.id, line2.id, "EqualLength")
+
+ self._constraint_count += 1
+ return True
+
+ def constrain_equal_radius(self, circle1: SketchEntity, circle2: SketchEntity) -> bool:
+ """Constrain two circles to have equal radius."""
+ self._sketch = self._sketch.constrain(circle1.id, circle2.id, "EqualRadius")
+
+ self._constraint_count += 1
+ return True
+
+ def constrain_fixed(self, entity: SketchEntity) -> bool:
+ """Fix an entity in place."""
+ self._sketch = self._sketch.constrain(entity.id, "Fixed")
+
+ self._constraint_count += 1
+ return True
+
+ def solve(self) -> bool:
+ """Solve all constraints."""
+ try:
+ self._sketch = self._sketch.solve()
+ self._update_entity_geometry()
+ return True
+ except Exception as e:
+ print(f"Solver error: {e}")
+ return False
+
+ def _update_entity_geometry(self) -> None:
+ """Update entity geometry after solving."""
+ pass
+
+ def get_geometry(self) -> GeometryObject:
+ """Get the solved geometry for operations."""
+ return OCCGeometryObject(self._sketch.val())
+
+ def get_points(self) -> List[Point2D]:
+ """Get all point positions."""
+ points: List[Point2D] = []
+
+ for entity_id, entity in self._entities.items():
+ if entity.entity_type == "point":
+ x, y = entity.geometry
+ points.append(Point2D(x, y))
+
+ return points
+
+ def get_polygon_points(self) -> List[Point2D]:
+ """Get ordered polygon points from connected lines."""
+ adjacency: Dict[Tuple[float, float], List[Tuple[float, float]]] = {}
+
+ for entity in self._entities.values():
+ if entity.entity_type == "line":
+ p1, p2 = entity.geometry
+ if p1 not in adjacency:
+ adjacency[p1] = []
+ if p2 not in adjacency:
+ adjacency[p2] = []
+ adjacency[p1].append(p2)
+ adjacency[p2].append(p1)
+
+ if not adjacency:
+ return []
+
+ points: List[Point2D] = []
+ visited: set = set()
+ current = next(iter(adjacency.keys()))
+
+ while current and current not in visited:
+ points.append(Point2D(current[0], current[1]))
+ visited.add(current)
+
+ neighbors = adjacency.get(current, [])
+ next_point = None
+ for n in neighbors:
+ if n not in visited:
+ next_point = n
+ break
+
+ current = next_point
+
+ if len(points) > 2:
+ points.append(points[0])
+
+ return points
+
+ def clear(self) -> None:
+ """Clear all geometry and constraints."""
+ import cadquery as cq
+
+ self._sketch = cq.Sketch()
+ self._entities.clear()
+ self._points.clear()
+ self._lines.clear()
+ self._circles.clear()
+ self._arcs.clear()
+ self._entity_counter = 0
+ self._constraint_count = 0
+
+ def delete_entity(self, entity: SketchEntity) -> bool:
+ """Delete an entity and its constraints."""
+ if entity.id not in self._entities:
+ return False
+
+ del self._entities[entity.id]
+
+ if entity.id in self._points:
+ del self._points[entity.id]
+ if entity.id in self._lines:
+ del self._lines[entity.id]
+ if entity.id in self._circles:
+ del self._circles[entity.id]
+ if entity.id in self._arcs:
+ del self._arcs[entity.id]
+
+ return True
+
+ def get_sketch_object(self) -> Any:
+ """Get the underlying CadQuery sketch object."""
+ return self._sketch
+
+ def get_entity_count(self) -> int:
+ """Get the number of entities in the sketch."""
+ return len(self._entities)
+
+ def get_constraint_count(self) -> int:
+ """Get the number of constraints in the sketch."""
+ return self._constraint_count
+
+ def is_fully_constrained(self) -> bool:
+ """Check if the sketch is fully constrained."""
+ return self._sketch.is_fully_constrained()
diff --git a/src/fluency/main.py b/src/fluency/main.py
new file mode 100644
index 0000000..f03a063
--- /dev/null
+++ b/src/fluency/main.py
@@ -0,0 +1,553 @@
+"""
+Fluency CAD - Main Application
+
+A parametric CAD application built on OpenCASCADE Technology (OCCT)
+with a modern pygfx-based 3D renderer.
+"""
+
+import sys
+from typing import Optional, List
+from PySide6.QtWidgets import (
+ QApplication,
+ QMainWindow,
+ QWidget,
+ QVBoxLayout,
+ QHBoxLayout,
+ QToolBar,
+ QStatusBar,
+ QFileDialog,
+ QMessageBox,
+ QDockWidget,
+ QTreeWidget,
+ QTreeWidgetItem,
+ QLabel,
+ QDoubleSpinBox,
+ QComboBox,
+ QPushButton,
+ QGroupBox,
+)
+from PySide6.QtCore import Qt, Signal, Slot
+from PySide6.QtGui import QAction, QIcon, QKeySequence
+
+from fluency.geometry_occ.kernel import OCGeometryKernel
+from fluency.geometry_occ.sketch import OCCSketch
+from fluency.rendering.pygfx_renderer import PygfxRenderer
+from fluency.models.data_model import Project, Component, Sketch, Body
+
+
+class SketchWidget(QWidget):
+ """2D sketching widget."""
+
+ sketch_changed = Signal()
+
+ def __init__(self, parent: Optional[QWidget] = None):
+ super().__init__(parent)
+ self._sketch: Optional[OCCSketch] = None
+ self._mode: str = "select"
+ self._points: List = []
+ self._setup_ui()
+
+ def _setup_ui(self) -> None:
+ layout = QVBoxLayout(self)
+
+ toolbar = QToolBar()
+ toolbar.addAction("Select", lambda: self._set_mode("select"))
+ toolbar.addAction("Line", lambda: self._set_mode("line"))
+ toolbar.addAction("Rectangle", lambda: self._set_mode("rectangle"))
+ toolbar.addAction("Circle", lambda: self._set_mode("circle"))
+ toolbar.addSeparator()
+ toolbar.addAction("Coincident", self._add_coincident_constraint)
+ toolbar.addAction("Horizontal", self._add_horizontal_constraint)
+ toolbar.addAction("Vertical", self._add_vertical_constraint)
+ toolbar.addAction("Distance", self._add_distance_constraint)
+
+ layout.addWidget(toolbar)
+
+ self._canvas = QLabel("Sketch Canvas (Click to draw)")
+ self._canvas.setMinimumSize(400, 300)
+ self._canvas.setStyleSheet("background-color: #1a1a2e; color: white;")
+ self._canvas.setAlignment(Qt.AlignCenter)
+
+ layout.addWidget(self._canvas)
+
+ def _set_mode(self, mode: str) -> None:
+ self._mode = mode
+ self._canvas.setText(f"Mode: {mode.upper()}")
+
+ def _add_coincident_constraint(self) -> None:
+ pass
+
+ def _add_horizontal_constraint(self) -> None:
+ pass
+
+ def _add_vertical_constraint(self) -> None:
+ pass
+
+ def _add_distance_constraint(self) -> None:
+ pass
+
+ def set_sketch(self, sketch: Optional[OCCSketch]) -> None:
+ self._sketch = sketch
+
+ def get_sketch(self) -> Optional[OCCSketch]:
+ return self._sketch
+
+
+class PropertiesWidget(QWidget):
+ """Properties panel widget."""
+
+ def __init__(self, parent: Optional[QWidget] = None):
+ super().__init__(parent)
+ self._setup_ui()
+
+ def _setup_ui(self) -> None:
+ layout = QVBoxLayout(self)
+
+ extrude_group = QGroupBox("Extrude")
+ extrude_layout = QVBoxLayout(extrude_group)
+
+ height_layout = QHBoxLayout()
+ height_layout.addWidget(QLabel("Height:"))
+ self._height_spin = QDoubleSpinBox()
+ self._height_spin.setRange(-10000, 10000)
+ self._height_spin.setValue(10)
+ height_layout.addWidget(self._height_spin)
+ extrude_layout.addLayout(height_layout)
+
+ self._extrude_btn = QPushButton("Extrude")
+ extrude_layout.addWidget(self._extrude_btn)
+
+ layout.addWidget(extrude_group)
+
+ fillet_group = QGroupBox("Fillet")
+ fillet_layout = QVBoxLayout(fillet_group)
+
+ radius_layout = QHBoxLayout()
+ radius_layout.addWidget(QLabel("Radius:"))
+ self._fillet_spin = QDoubleSpinBox()
+ self._fillet_spin.setRange(0.01, 1000)
+ self._fillet_spin.setValue(1)
+ radius_layout.addWidget(self._fillet_spin)
+ fillet_layout.addLayout(radius_layout)
+
+ self._fillet_btn = QPushButton("Apply Fillet")
+ fillet_layout.addWidget(self._fillet_btn)
+
+ layout.addWidget(fillet_group)
+
+ layout.addStretch()
+
+ def get_extrude_height(self) -> float:
+ return self._height_spin.value()
+
+ def get_fillet_radius(self) -> float:
+ return self._fillet_spin.value()
+
+
+class BrowserWidget(QWidget):
+ """Feature browser tree widget."""
+
+ item_selected = Signal(str, str)
+
+ def __init__(self, parent: Optional[QWidget] = None):
+ super().__init__(parent)
+ self._setup_ui()
+
+ def _setup_ui(self) -> None:
+ layout = QVBoxLayout(self)
+ layout.setContentsMargins(0, 0, 0, 0)
+
+ self._tree = QTreeWidget()
+ self._tree.setHeaderLabel("Features")
+ self._tree.itemClicked.connect(self._on_item_clicked)
+
+ layout.addWidget(self._tree)
+
+ def _on_item_clicked(self, item: QTreeWidgetItem, column: int) -> None:
+ data = item.data(0, Qt.UserRole)
+ if data:
+ item_type, item_id = data.split(":")
+ self.item_selected.emit(item_type, item_id)
+
+ def update_from_project(self, project: Project) -> None:
+ self._tree.clear()
+
+ for comp_id, comp in project.components.items():
+ comp_item = QTreeWidgetItem([comp.name])
+ comp_item.setData(0, Qt.UserRole, f"component:{comp_id}")
+ self._tree.addTopLevelItem(comp_item)
+
+ sketches_item = QTreeWidgetItem(["Sketches"])
+ comp_item.addChild(sketches_item)
+
+ for sketch_id, sketch in comp.sketches.items():
+ sketch_item = QTreeWidgetItem([sketch.name])
+ sketch_item.setData(0, Qt.UserRole, f"sketch:{sketch_id}")
+ sketches_item.addChild(sketch_item)
+
+ bodies_item = QTreeWidgetItem(["Bodies"])
+ comp_item.addChild(bodies_item)
+
+ for body_id, body in comp.bodies.items():
+ body_item = QTreeWidgetItem([body.name])
+ body_item.setData(0, Qt.UserRole, f"body:{body_id}")
+ bodies_item.addChild(body_item)
+
+ comp_item.setExpanded(True)
+
+
+class MainWindow(QMainWindow):
+ """Main application window."""
+
+ def __init__(self) -> None:
+ super().__init__()
+
+ self._project = Project()
+ self._kernel = OCGeometryKernel()
+ self._renderer = PygfxRenderer()
+
+ self._current_sketch: Optional[Sketch] = None
+ self._selected_body: Optional[Body] = None
+
+ self._setup_ui()
+ self._setup_connections()
+ self._create_initial_component()
+
+ def _setup_ui(self) -> None:
+ self.setWindowTitle("Fluency CAD 2.0")
+ self.setMinimumSize(1200, 800)
+
+ self._create_menus()
+ self._create_toolbars()
+ self._create_dock_widgets()
+ self._create_central_widget()
+
+ self.statusBar().showMessage("Ready")
+
+ def _create_menus(self) -> None:
+ menubar = self.menuBar()
+
+ file_menu = menubar.addMenu("&File")
+
+ new_action = QAction("&New Project", self)
+ new_action.setShortcut(QKeySequence.New)
+ new_action.triggered.connect(self._new_project)
+ file_menu.addAction(new_action)
+
+ open_action = QAction("&Open...", self)
+ open_action.setShortcut(QKeySequence.Open)
+ open_action.triggered.connect(self._open_project)
+ file_menu.addAction(open_action)
+
+ file_menu.addSeparator()
+
+ export_step = QAction("Export &STEP...", self)
+ export_step.triggered.connect(self._export_step)
+ file_menu.addAction(export_step)
+
+ export_iges = QAction("Export &IGES...", self)
+ export_iges.triggered.connect(self._export_iges)
+ file_menu.addAction(export_iges)
+
+ export_stl = QAction("Export S&TL...", self)
+ export_stl.triggered.connect(self._export_stl)
+ file_menu.addAction(export_stl)
+
+ file_menu.addSeparator()
+
+ exit_action = QAction("E&xit", self)
+ exit_action.setShortcut(QKeySequence.Quit)
+ exit_action.triggered.connect(self.close)
+ file_menu.addAction(exit_action)
+
+ edit_menu = menubar.addMenu("&Edit")
+ edit_menu.addAction("Undo")
+ edit_menu.addAction("Redo")
+ edit_menu.addSeparator()
+ edit_menu.addAction("Delete")
+
+ view_menu = menubar.addMenu("&View")
+ view_menu.addAction("Fit All", self._fit_view)
+ view_menu.addAction("Reset View", self._reset_view)
+ view_menu.addSeparator()
+
+ iso_view = QAction("Isometric", self)
+ iso_view.triggered.connect(lambda: self._set_view("iso"))
+ view_menu.addAction(iso_view)
+
+ top_view = QAction("Top", self)
+ top_view.triggered.connect(lambda: self._set_view("top"))
+ view_menu.addAction(top_view)
+
+ front_view = QAction("Front", self)
+ front_view.triggered.connect(lambda: self._set_view("front"))
+ view_menu.addAction(front_view)
+
+ right_view = QAction("Right", self)
+ right_view.triggered.connect(lambda: self._set_view("right"))
+ view_menu.addAction(right_view)
+
+ help_menu = menubar.addMenu("&Help")
+ help_menu.addAction("About", self._show_about)
+
+ def _create_toolbars(self) -> None:
+ toolbar = self.addToolBar("Main")
+ toolbar.addAction("New Sketch", self._new_sketch)
+ toolbar.addSeparator()
+ toolbar.addAction("Extrude", self._extrude_sketch)
+ toolbar.addAction("Revolve", self._revolve_sketch)
+ toolbar.addSeparator()
+ toolbar.addAction("Union", self._boolean_union)
+ toolbar.addAction("Subtract", self._boolean_subtract)
+ toolbar.addAction("Intersect", self._boolean_intersect)
+ toolbar.addSeparator()
+ toolbar.addAction("Fillet", self._apply_fillet)
+ toolbar.addAction("Chamfer", self._apply_chamfer)
+
+ def _create_dock_widgets(self) -> None:
+ browser_dock = QDockWidget("Browser", self)
+ self._browser = BrowserWidget()
+ browser_dock.setWidget(self._browser)
+ self.addDockWidget(Qt.LeftDockWidgetArea, browser_dock)
+
+ properties_dock = QDockWidget("Properties", self)
+ self._properties = PropertiesWidget()
+ properties_dock.setWidget(self._properties)
+ self.addDockWidget(Qt.RightDockWidgetArea, properties_dock)
+
+ def _create_central_widget(self) -> None:
+ central = QWidget()
+ layout = QVBoxLayout(central)
+ layout.setContentsMargins(0, 0, 0, 0)
+
+ self._view_container = QWidget()
+ self._renderer.initialize(self._view_container)
+ layout.addWidget(self._view_container)
+
+ self.setCentralWidget(central)
+
+ def _setup_connections(self) -> None:
+ self._properties._extrude_btn.clicked.connect(self._extrude_sketch)
+ self._properties._fillet_btn.clicked.connect(self._apply_fillet)
+ self._browser.item_selected.connect(self._on_item_selected)
+
+ def _create_initial_component(self) -> None:
+ comp = self._project.add_component()
+ self._browser.update_from_project(self._project)
+
+ def _new_project(self) -> None:
+ self._project = Project()
+ self._create_initial_component()
+ self._renderer.clear_scene()
+ self._renderer.render()
+ self.statusBar().showMessage("New project created")
+
+ def _open_project(self) -> None:
+ filepath, _ = QFileDialog.getOpenFileName(
+ self, "Open Project", "", "STEP Files (*.step *.stp)"
+ )
+ if filepath:
+ try:
+ geometry = self._kernel.import_step(filepath)
+ comp = self._project.add_component()
+ body = comp.add_body(Body(name="Imported", geometry=geometry))
+ self._render_body(body)
+ self._browser.update_from_project(self._project)
+ self.statusBar().showMessage(f"Opened: {filepath}")
+ except Exception as e:
+ QMessageBox.critical(self, "Error", f"Failed to open file: {e}")
+
+ def _export_step(self) -> None:
+ filepath, _ = QFileDialog.getSaveFileName(
+ self, "Export STEP", "", "STEP Files (*.step *.stp)"
+ )
+ if filepath:
+ if self._project.export_step(filepath):
+ self.statusBar().showMessage(f"Exported: {filepath}")
+ else:
+ QMessageBox.warning(self, "Export Failed", "No bodies to export")
+
+ def _export_iges(self) -> None:
+ filepath, _ = QFileDialog.getSaveFileName(
+ self, "Export IGES", "", "IGES Files (*.iges *.igs)"
+ )
+ if filepath:
+ if self._project.export_iges(filepath):
+ self.statusBar().showMessage(f"Exported: {filepath}")
+ else:
+ QMessageBox.warning(self, "Export Failed", "No bodies to export")
+
+ def _export_stl(self) -> None:
+ filepath, _ = QFileDialog.getSaveFileName(self, "Export STL", "", "STL Files (*.stl)")
+ if filepath:
+ if self._project.export_stl(filepath):
+ self.statusBar().showMessage(f"Exported: {filepath}")
+ else:
+ QMessageBox.warning(self, "Export Failed", "No bodies to export")
+
+ def _new_sketch(self) -> None:
+ comp = self._project.get_active_component()
+ if comp:
+ sketch = comp.add_sketch()
+ self._current_sketch = sketch
+ self._browser.update_from_project(self._project)
+ self.statusBar().showMessage(f"Created: {sketch.name}")
+
+ def _extrude_sketch(self) -> None:
+ comp = self._project.get_active_component()
+ if not comp:
+ return
+
+ sketch = comp.get_active_sketch()
+ if not sketch:
+ sketch = self._current_sketch
+
+ if not sketch or not sketch.occ_sketch:
+ QMessageBox.warning(self, "No Sketch", "Please create a sketch first")
+ return
+
+ sketch.solve()
+ geometry = sketch.get_geometry()
+
+ if not geometry:
+ QMessageBox.warning(self, "No Geometry", "Sketch has no valid geometry")
+ return
+
+ height = self._properties.get_extrude_height()
+
+ try:
+ body_geometry = self._kernel.extrude(geometry, height)
+
+ body = comp.add_body(
+ Body(
+ name=f"Extrusion {len(comp.bodies) + 1}",
+ geometry=body_geometry,
+ source_sketch=sketch,
+ source_operation="extrude",
+ )
+ )
+
+ self._render_body(body)
+ self._browser.update_from_project(self._project)
+ self.statusBar().showMessage(f"Extruded: {body.name}")
+
+ except Exception as e:
+ QMessageBox.critical(self, "Error", f"Extrude failed: {e}")
+
+ def _revolve_sketch(self) -> None:
+ self.statusBar().showMessage("Revolve not yet implemented")
+
+ def _boolean_union(self) -> None:
+ self.statusBar().showMessage("Boolean union not yet implemented")
+
+ def _boolean_subtract(self) -> None:
+ self.statusBar().showMessage("Boolean subtract not yet implemented")
+
+ def _boolean_intersect(self) -> None:
+ self.statusBar().showMessage("Boolean intersect not yet implemented")
+
+ def _apply_fillet(self) -> None:
+ if not self._selected_body:
+ QMessageBox.warning(self, "No Selection", "Please select a body")
+ return
+
+ radius = self._properties.get_fillet_radius()
+
+ try:
+ self._selected_body.geometry = self._kernel.fillet(self._selected_body.geometry, radius)
+ self._render_body(self._selected_body)
+ self.statusBar().showMessage(f"Applied fillet: {radius}")
+ except Exception as e:
+ QMessageBox.critical(self, "Error", f"Fillet failed: {e}")
+
+ def _apply_chamfer(self) -> None:
+ if not self._selected_body:
+ QMessageBox.warning(self, "No Selection", "Please select a body")
+ return
+
+ size = self._properties.get_fillet_radius()
+
+ try:
+ self._selected_body.geometry = self._kernel.chamfer(self._selected_body.geometry, size)
+ self._render_body(self._selected_body)
+ self.statusBar().showMessage(f"Applied chamfer: {size}")
+ except Exception as e:
+ QMessageBox.critical(self, "Error", f"Chamfer failed: {e}")
+
+ def _render_body(self, body: Body) -> None:
+ if not body.geometry:
+ return
+
+ vertices, faces = body.get_mesh(self._kernel)
+
+ if body.render_object:
+ self._renderer.update_mesh(body.render_object, vertices, faces)
+ else:
+ body.render_object = self._renderer.add_mesh(vertices, faces, body.color, body.name)
+
+ self._renderer.fit_camera()
+ self._renderer.render()
+
+ def _fit_view(self) -> None:
+ self._renderer.fit_camera()
+ self._renderer.render()
+
+ def _reset_view(self) -> None:
+ self._renderer.set_camera_position((100, 100, 100), (0, 0, 0))
+ self._renderer.render()
+
+ def _set_view(self, view: str) -> None:
+ positions = {
+ "iso": ((100, 100, 100), (0, 0, 0)),
+ "top": ((0, 0, 200), (0, 0, 0)),
+ "front": ((0, -200, 0), (0, 0, 0)),
+ "right": ((200, 0, 0), (0, 0, 0)),
+ }
+ if view in positions:
+ pos, target = positions[view]
+ self._renderer.set_camera_position(pos, target)
+ self._renderer.render()
+
+ def _on_item_selected(self, item_type: str, item_id: str) -> None:
+ if item_type == "body":
+ comp = self._project.get_active_component()
+ if comp and item_id in comp.bodies:
+ self._selected_body = comp.bodies[item_id]
+ self.statusBar().showMessage(f"Selected: {self._selected_body.name}")
+ elif item_type == "sketch":
+ comp = self._project.get_active_component()
+ if comp and item_id in comp.sketches:
+ self._current_sketch = comp.sketches[item_id]
+ comp.set_active_sketch(item_id)
+ self.statusBar().showMessage(f"Selected: {self._current_sketch.name}")
+
+ def _show_about(self) -> None:
+ QMessageBox.about(
+ self,
+ "About Fluency CAD",
+ "Fluency CAD 2.0\n\n"
+ "A parametric CAD application built on:\n"
+ "- OpenCASCADE Technology (OCCT)\n"
+ "- CadQuery Python bindings\n"
+ "- pygfx WebGPU renderer\n\n"
+ "Features:\n"
+ "- STEP/IGES import/export\n"
+ "- Parametric sketching\n"
+ "- Boolean operations\n"
+ "- Fillets and chamfers",
+ )
+
+
+def main() -> int:
+ """Application entry point."""
+ app = QApplication(sys.argv)
+ app.setStyle("Fusion")
+
+ window = MainWindow()
+ window.show()
+
+ return app.exec()
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/src/fluency/models/__init__.py b/src/fluency/models/__init__.py
new file mode 100644
index 0000000..717be46
--- /dev/null
+++ b/src/fluency/models/__init__.py
@@ -0,0 +1,15 @@
+"""Models module."""
+
+from fluency.models.data_model import (
+ Project,
+ Component,
+ Sketch,
+ Body,
+)
+
+__all__ = [
+ "Project",
+ "Component",
+ "Sketch",
+ "Body",
+]
diff --git a/src/fluency/models/data_model.py b/src/fluency/models/data_model.py
new file mode 100644
index 0000000..945c05d
--- /dev/null
+++ b/src/fluency/models/data_model.py
@@ -0,0 +1,339 @@
+"""
+Data models for Fluency CAD.
+
+This module defines the core data structures for the CAD application
+including projects, components, sketches, and bodies.
+"""
+
+from dataclasses import dataclass, field
+from typing import Dict, List, Optional, Any
+from datetime import datetime
+import uuid
+import numpy as np
+
+from fluency.geometry.base import (
+ Point2D,
+ Point3D,
+ GeometryObject,
+ SketchInterface,
+)
+from fluency.geometry_occ.kernel import OCGeometryKernel, OCCGeometryObject
+from fluency.geometry_occ.sketch import OCCSketch
+
+
+@dataclass
+class Sketch:
+ """
+ 2D sketch with constraints.
+
+ A sketch contains 2D geometry on a workplane that can be
+ extruded or revolved to create 3D bodies.
+ """
+
+ id: str = field(default_factory=lambda: str(uuid.uuid4()))
+ name: str = "Untitled Sketch"
+
+ workplane_origin: np.ndarray = field(default_factory=lambda: np.array([0.0, 0.0, 0.0]))
+ workplane_normal: np.ndarray = field(default_factory=lambda: np.array([0.0, 0.0, 1.0]))
+ workplane_x_dir: np.ndarray = field(default_factory=lambda: np.array([1.0, 0.0, 0.0]))
+
+ occ_sketch: Optional[OCCSketch] = field(default_factory=OCCSketch)
+ geometry: Optional[OCCGeometryObject] = None
+
+ is_solved: bool = False
+ is_fully_constrained: bool = False
+
+ created_at: datetime = field(default_factory=datetime.now)
+ modified_at: datetime = field(default_factory=datetime.now)
+
+ def add_point(self, x: float, y: float) -> Any:
+ """Add a point to the sketch."""
+ self.modified_at = datetime.now()
+ self.is_solved = False
+ if self.occ_sketch:
+ return self.occ_sketch.add_point(x, y)
+ return None
+
+ def add_line(self, start: Any, end: Any) -> Any:
+ """Add a line to the sketch."""
+ self.modified_at = datetime.now()
+ self.is_solved = False
+ if self.occ_sketch:
+ return self.occ_sketch.add_line(start, end)
+ return None
+
+ def add_circle(self, center: Any, radius: float) -> Any:
+ """Add a circle to the sketch."""
+ self.modified_at = datetime.now()
+ self.is_solved = False
+ if self.occ_sketch:
+ return self.occ_sketch.add_circle(center, radius)
+ return None
+
+ def add_rectangle(self, corner1: tuple, corner2: tuple) -> List[Any]:
+ """Add a rectangle to the sketch."""
+ self.modified_at = datetime.now()
+ self.is_solved = False
+ if self.occ_sketch:
+ return self.occ_sketch.add_rectangle(corner1, corner2)
+ return []
+
+ def solve(self) -> bool:
+ """Solve all constraints."""
+ if self.occ_sketch:
+ result = self.occ_sketch.solve()
+ self.is_solved = result
+ self.is_fully_constrained = self.occ_sketch.is_fully_constrained()
+ self.modified_at = datetime.now()
+ return result
+ return False
+
+ def get_geometry(self) -> Optional[GeometryObject]:
+ """Get the solved geometry."""
+ if self.occ_sketch:
+ return self.occ_sketch.get_geometry()
+ return None
+
+ def get_polygon_points(self) -> List[Point2D]:
+ """Get ordered polygon points."""
+ if self.occ_sketch:
+ return self.occ_sketch.get_polygon_points()
+ return []
+
+ def clear(self) -> None:
+ """Clear all geometry."""
+ if self.occ_sketch:
+ self.occ_sketch.clear()
+ self.geometry = None
+ self.is_solved = False
+ self.is_fully_constrained = False
+ self.modified_at = datetime.now()
+
+
+@dataclass
+class Body:
+ """
+ 3D solid body.
+
+ A body is created from a sketch through operations like
+ extrude, revolve, loft, or sweep.
+ """
+
+ id: str = field(default_factory=lambda: str(uuid.uuid4()))
+ name: str = "Untitled Body"
+
+ geometry: Optional[OCCGeometryObject] = None
+ source_sketch: Optional[Sketch] = None
+ source_operation: str = "extrude"
+
+ position: np.ndarray = field(default_factory=lambda: np.array([0.0, 0.0, 0.0]))
+ rotation: np.ndarray = field(default_factory=lambda: np.eye(3))
+
+ color: tuple = (0.2, 0.4, 0.8)
+ opacity: float = 1.0
+ visible: bool = True
+
+ render_object: Any = None
+
+ created_at: datetime = field(default_factory=datetime.now)
+ modified_at: datetime = field(default_factory=datetime.now)
+
+ def get_mesh(self, kernel: OCGeometryKernel, tolerance: float = 0.1) -> tuple:
+ """Get mesh for rendering."""
+ if self.geometry and kernel:
+ return kernel.get_mesh(self.geometry, tolerance)
+ return np.array([]), np.array([])
+
+ def get_edges(self, kernel: OCGeometryKernel) -> tuple:
+ """Get edges for wireframe rendering."""
+ if self.geometry and kernel:
+ return kernel.get_edges(self.geometry)
+ return np.array([]), np.array([])
+
+
+@dataclass
+class Component:
+ """
+ Component containing sketches and bodies.
+
+ A component is a logical grouping of geometry, similar to
+ a part in a CAD system.
+ """
+
+ id: str = field(default_factory=lambda: str(uuid.uuid4()))
+ name: str = "Untitled Component"
+ description: str = ""
+
+ sketches: Dict[str, Sketch] = field(default_factory=dict)
+ bodies: Dict[str, Body] = field(default_factory=dict)
+
+ active_sketch: Optional[str] = None
+
+ created_at: datetime = field(default_factory=datetime.now)
+ modified_at: datetime = field(default_factory=datetime.now)
+
+ def add_sketch(self, sketch: Optional[Sketch] = None) -> Sketch:
+ """Add a sketch to the component."""
+ if sketch is None:
+ sketch = Sketch(name=f"Sketch {len(self.sketches) + 1}")
+ self.sketches[sketch.id] = sketch
+ self.modified_at = datetime.now()
+ return sketch
+
+ def add_body(self, body: Optional[Body] = None) -> Body:
+ """Add a body to the component."""
+ if body is None:
+ body = Body(name=f"Body {len(self.bodies) + 1}")
+ self.bodies[body.id] = body
+ self.modified_at = datetime.now()
+ return body
+
+ def remove_sketch(self, sketch_id: str) -> bool:
+ """Remove a sketch from the component."""
+ if sketch_id in self.sketches:
+ del self.sketches[sketch_id]
+ if self.active_sketch == sketch_id:
+ self.active_sketch = None
+ self.modified_at = datetime.now()
+ return True
+ return False
+
+ def remove_body(self, body_id: str) -> bool:
+ """Remove a body from the component."""
+ if body_id in self.bodies:
+ del self.bodies[body_id]
+ self.modified_at = datetime.now()
+ return True
+ return False
+
+ def get_active_sketch(self) -> Optional[Sketch]:
+ """Get the currently active sketch."""
+ if self.active_sketch and self.active_sketch in self.sketches:
+ return self.sketches[self.active_sketch]
+ return None
+
+ def set_active_sketch(self, sketch_id: Optional[str]) -> None:
+ """Set the active sketch."""
+ self.active_sketch = sketch_id
+ self.modified_at = datetime.now()
+
+
+@dataclass
+class Project:
+ """
+ Top-level project container.
+
+ A project contains components and provides access to the
+ geometry kernel for operations.
+ """
+
+ name: str = "Untitled Project"
+ description: str = ""
+
+ components: Dict[str, Component] = field(default_factory=dict)
+ active_component: Optional[str] = None
+
+ kernel: OCGeometryKernel = field(default_factory=OCGeometryKernel)
+
+ created_at: datetime = field(default_factory=datetime.now)
+ modified_at: datetime = field(default_factory=datetime.now)
+ file_path: Optional[str] = None
+
+ def add_component(self, component: Optional[Component] = None) -> Component:
+ """Add a component to the project."""
+ if component is None:
+ component = Component(name=f"Component {len(self.components) + 1}")
+ self.components[component.id] = component
+ if self.active_component is None:
+ self.active_component = component.id
+ self.modified_at = datetime.now()
+ return component
+
+ def remove_component(self, component_id: str) -> bool:
+ """Remove a component from the project."""
+ if component_id in self.components:
+ del self.components[component_id]
+ if self.active_component == component_id:
+ self.active_component = next(iter(self.components.keys()), None)
+ self.modified_at = datetime.now()
+ return True
+ return False
+
+ def get_active_component(self) -> Optional[Component]:
+ """Get the currently active component."""
+ if self.active_component and self.active_component in self.components:
+ return self.components[self.active_component]
+ return None
+
+ def set_active_component(self, component_id: Optional[str]) -> None:
+ """Set the active component."""
+ self.active_component = component_id
+ self.modified_at = datetime.now()
+
+ def export_step(self, filepath: str) -> bool:
+ """Export all visible bodies to STEP."""
+ all_bodies: List[OCCGeometryObject] = []
+
+ for comp in self.components.values():
+ for body in comp.bodies.values():
+ if body.visible and body.geometry:
+ all_bodies.append(body.geometry)
+
+ if not all_bodies:
+ return False
+
+ if len(all_bodies) == 1:
+ return self.kernel.export_step(all_bodies[0], filepath)
+
+ result = self.kernel.boolean_union(*all_bodies)
+ return self.kernel.export_step(result, filepath)
+
+ def export_iges(self, filepath: str) -> bool:
+ """Export all visible bodies to IGES."""
+ all_bodies: List[OCCGeometryObject] = []
+
+ for comp in self.components.values():
+ for body in comp.bodies.values():
+ if body.visible and body.geometry:
+ all_bodies.append(body.geometry)
+
+ if not all_bodies:
+ return False
+
+ if len(all_bodies) == 1:
+ return self.kernel.export_iges(all_bodies[0], filepath)
+
+ result = self.kernel.boolean_union(*all_bodies)
+ return self.kernel.export_iges(result, filepath)
+
+ def export_stl(self, filepath: str, tolerance: float = 0.1) -> bool:
+ """Export all visible bodies to STL."""
+ all_bodies: List[OCCGeometryObject] = []
+
+ for comp in self.components.values():
+ for body in comp.bodies.values():
+ if body.visible and body.geometry:
+ all_bodies.append(body.geometry)
+
+ if not all_bodies:
+ return False
+
+ if len(all_bodies) == 1:
+ return self.kernel.export_stl(all_bodies[0], filepath, tolerance)
+
+ result = self.kernel.boolean_union(*all_bodies)
+ return self.kernel.export_stl(result, filepath, tolerance)
+
+ def get_all_bodies(self) -> List[Body]:
+ """Get all bodies from all components."""
+ bodies: List[Body] = []
+ for comp in self.components.values():
+ bodies.extend(comp.bodies.values())
+ return bodies
+
+ def get_all_sketches(self) -> List[Sketch]:
+ """Get all sketches from all components."""
+ sketches: List[Sketch] = []
+ for comp in self.components.values():
+ sketches.extend(comp.sketches.values())
+ return sketches
diff --git a/src/fluency/rendering/__init__.py b/src/fluency/rendering/__init__.py
new file mode 100644
index 0000000..bc219f1
--- /dev/null
+++ b/src/fluency/rendering/__init__.py
@@ -0,0 +1,16 @@
+"""Rendering module."""
+
+from fluency.rendering.base import (
+ Renderer,
+ RenderObject,
+ RenderColor,
+)
+from fluency.rendering.pygfx_renderer import PygfxRenderer, PygfxRenderObject
+
+__all__ = [
+ "Renderer",
+ "RenderObject",
+ "RenderColor",
+ "PygfxRenderer",
+ "PygfxRenderObject",
+]
diff --git a/src/fluency/rendering/base.py b/src/fluency/rendering/base.py
new file mode 100644
index 0000000..1974072
--- /dev/null
+++ b/src/fluency/rendering/base.py
@@ -0,0 +1,380 @@
+"""
+Rendering abstraction layer for Fluency CAD.
+
+This module defines abstract interfaces for 3D rendering,
+allowing different rendering backends to be used interchangeably.
+"""
+
+from abc import ABC, abstractmethod
+from typing import List, Tuple, Optional, Callable, Any
+from dataclasses import dataclass
+import numpy as np
+
+
+@dataclass
+class RenderColor:
+ """RGB color representation."""
+
+ r: float
+ g: float
+ b: float
+ a: float = 1.0
+
+ def to_tuple(self) -> Tuple[float, float, float, float]:
+ return (self.r, self.g, self.b, self.a)
+
+ def to_tuple_rgb(self) -> Tuple[float, float, float]:
+ return (self.r, self.g, self.b)
+
+ @classmethod
+ def from_hex(cls, hex_color: str) -> "RenderColor":
+ """Create color from hex string (#RRGGBB or #RRGGBBAA)."""
+ hex_color = hex_color.lstrip("#")
+ if len(hex_color) == 6:
+ r = int(hex_color[0:2], 16) / 255.0
+ g = int(hex_color[2:4], 16) / 255.0
+ b = int(hex_color[4:6], 16) / 255.0
+ return cls(r, g, b)
+ elif len(hex_color) == 8:
+ r = int(hex_color[0:2], 16) / 255.0
+ g = int(hex_color[2:4], 16) / 255.0
+ b = int(hex_color[4:6], 16) / 255.0
+ a = int(hex_color[6:8], 16) / 255.0
+ return cls(r, g, b, a)
+ raise ValueError(f"Invalid hex color: {hex_color}")
+
+
+class RenderObject:
+ """Base class for renderable objects."""
+
+ def __init__(self, name: Optional[str] = None):
+ self.name = name
+ self.visible: bool = True
+ self.selected: bool = False
+ self.color: RenderColor = RenderColor(0.2, 0.4, 0.8)
+ self._scene_node: Any = None
+
+ def set_color(self, color: RenderColor) -> None:
+ self.color = color
+
+ def set_visible(self, visible: bool) -> None:
+ self.visible = visible
+
+ def set_selected(self, selected: bool) -> None:
+ self.selected = selected
+
+
+class Renderer(ABC):
+ """
+ Abstract base class for 3D renderers.
+
+ A renderer provides 3D visualization capabilities including
+ mesh display, camera control, and object selection.
+ """
+
+ @abstractmethod
+ def initialize(self, parent_widget: Any) -> bool:
+ """
+ Initialize the renderer with a parent widget.
+
+ Args:
+ parent_widget: Qt widget to embed the renderer in
+
+ Returns:
+ True if initialization succeeded
+ """
+ pass
+
+ @abstractmethod
+ def shutdown(self) -> None:
+ """Clean up renderer resources."""
+ pass
+
+ @abstractmethod
+ def add_mesh(
+ self,
+ vertices: np.ndarray,
+ faces: np.ndarray,
+ color: Tuple[float, float, float] = (0.2, 0.4, 0.8),
+ name: Optional[str] = None,
+ ) -> RenderObject:
+ """
+ Add a mesh to the scene.
+
+ Args:
+ vertices: Nx3 array of vertex positions
+ faces: Mx3 array of triangle indices
+ color: RGB color tuple
+ name: Optional name for the object
+
+ Returns:
+ RenderObject representing the mesh
+ """
+ pass
+
+ @abstractmethod
+ def add_wireframe(
+ self,
+ vertices: np.ndarray,
+ edges: np.ndarray,
+ color: Tuple[float, float, float] = (1.0, 1.0, 1.0),
+ line_width: float = 1.0,
+ name: Optional[str] = None,
+ ) -> RenderObject:
+ """
+ Add a wireframe to the scene.
+
+ Args:
+ vertices: Nx3 array of vertex positions
+ edges: Mx2 array of edge vertex indices
+ color: RGB color tuple
+ line_width: Width of lines
+ name: Optional name for the object
+
+ Returns:
+ RenderObject representing the wireframe
+ """
+ pass
+
+ @abstractmethod
+ def add_points(
+ self,
+ points: np.ndarray,
+ color: Tuple[float, float, float] = (1.0, 0.0, 0.0),
+ size: float = 5.0,
+ name: Optional[str] = None,
+ ) -> RenderObject:
+ """
+ Add points to the scene.
+
+ Args:
+ points: Nx3 array of point positions
+ color: RGB color tuple
+ size: Point size
+ name: Optional name for the object
+
+ Returns:
+ RenderObject representing the points
+ """
+ pass
+
+ @abstractmethod
+ def add_lines(
+ self,
+ start_points: np.ndarray,
+ end_points: np.ndarray,
+ color: Tuple[float, float, float] = (1.0, 1.0, 1.0),
+ line_width: float = 1.0,
+ name: Optional[str] = None,
+ ) -> RenderObject:
+ """
+ Add line segments to the scene.
+
+ Args:
+ start_points: Nx3 array of line start positions
+ end_points: Nx3 array of line end positions
+ color: RGB color tuple
+ line_width: Width of lines
+ name: Optional name for the object
+
+ Returns:
+ RenderObject representing the lines
+ """
+ pass
+
+ @abstractmethod
+ def remove_object(self, obj: RenderObject) -> bool:
+ """
+ Remove an object from the scene.
+
+ Args:
+ obj: Object to remove
+
+ Returns:
+ True if removal succeeded
+ """
+ pass
+
+ @abstractmethod
+ def clear_scene(self) -> None:
+ """Remove all objects from the scene."""
+ pass
+
+ @abstractmethod
+ def update_mesh(self, obj: RenderObject, vertices: np.ndarray, faces: np.ndarray) -> bool:
+ """
+ Update mesh geometry.
+
+ Args:
+ obj: Object to update
+ vertices: New Nx3 array of vertex positions
+ faces: New Mx3 array of triangle indices
+
+ Returns:
+ True if update succeeded
+ """
+ pass
+
+ @abstractmethod
+ def set_object_color(self, obj: RenderObject, color: Tuple[float, float, float]) -> None:
+ """Set the color of an object."""
+ pass
+
+ @abstractmethod
+ def set_object_visible(self, obj: RenderObject, visible: bool) -> None:
+ """Set the visibility of an object."""
+ pass
+
+ @abstractmethod
+ def set_camera_position(
+ self,
+ position: Tuple[float, float, float],
+ target: Tuple[float, float, float] = (0, 0, 0),
+ up: Tuple[float, float, float] = (0, 0, 1),
+ ) -> None:
+ """
+ Set camera position and orientation.
+
+ Args:
+ position: Camera position
+ target: Point camera is looking at
+ up: Up vector
+ """
+ pass
+
+ @abstractmethod
+ def get_camera_position(self) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
+ """
+ Get camera position, target, and up vector.
+
+ Returns:
+ Tuple of (position, target, up) as numpy arrays
+ """
+ pass
+
+ @abstractmethod
+ def fit_camera(self, padding: float = 1.1) -> None:
+ """
+ Fit camera to show all objects.
+
+ Args:
+ padding: Padding factor (1.0 = exact fit)
+ """
+ pass
+
+ @abstractmethod
+ def set_camera_perspective(
+ self, fov: float = 50.0, near: float = 0.1, far: float = 10000.0
+ ) -> None:
+ """Set camera perspective parameters."""
+ pass
+
+ @abstractmethod
+ def set_camera_orthographic(
+ self, width: float = 100.0, near: float = 0.1, far: float = 10000.0
+ ) -> None:
+ """Set camera orthographic parameters."""
+ pass
+
+ @abstractmethod
+ def render(self) -> None:
+ """Trigger a render."""
+ pass
+
+ @abstractmethod
+ def on_pick(self, callback: Callable[[Any], None]) -> None:
+ """
+ Register a callback for picking/selection.
+
+ Args:
+ callback: Function called with pick info when object is clicked
+ """
+ pass
+
+ @abstractmethod
+ def on_camera_change(self, callback: Callable[[], None]) -> None:
+ """
+ Register a callback for camera changes.
+
+ Args:
+ callback: Function called when camera moves
+ """
+ pass
+
+ @abstractmethod
+ def set_background_color(self, color: Tuple[float, float, float]) -> None:
+ """Set the background color."""
+ pass
+
+ @abstractmethod
+ def add_grid(
+ self,
+ size: float = 100.0,
+ divisions: int = 10,
+ color: Tuple[float, float, float] = (0.3, 0.3, 0.3),
+ ) -> RenderObject:
+ """Add a reference grid."""
+ pass
+
+ @abstractmethod
+ def add_axes(self, size: float = 10.0, visible: bool = True) -> RenderObject:
+ """Add coordinate axes."""
+ pass
+
+ @abstractmethod
+ def get_screen_size(self) -> Tuple[int, int]:
+ """Get the screen size in pixels."""
+ pass
+
+ @abstractmethod
+ def project_to_screen(self, point: Tuple[float, float, float]) -> Tuple[int, int]:
+ """
+ Project a 3D point to screen coordinates.
+
+ Args:
+ point: 3D point to project
+
+ Returns:
+ Screen (x, y) coordinates
+ """
+ pass
+
+ @abstractmethod
+ def unproject_from_screen(
+ self, screen_x: int, screen_y: int, depth: float = 0.0
+ ) -> Tuple[float, float, float]:
+ """
+ Unproject screen coordinates to 3D.
+
+ Args:
+ screen_x: Screen x coordinate
+ screen_y: Screen y coordinate
+ depth: Depth value (0=near, 1=far)
+
+ Returns:
+ 3D point coordinates
+ """
+ pass
+
+ @abstractmethod
+ def take_screenshot(self) -> np.ndarray:
+ """
+ Take a screenshot of the current view.
+
+ Returns:
+ RGBA image as numpy array
+ """
+ pass
+
+ @abstractmethod
+ def save_screenshot(self, filepath: str) -> bool:
+ """
+ Save a screenshot to file.
+
+ Args:
+ filepath: Path to save screenshot
+
+ Returns:
+ True if save succeeded
+ """
+ pass
diff --git a/src/fluency/rendering/pygfx_renderer.py b/src/fluency/rendering/pygfx_renderer.py
new file mode 100644
index 0000000..786bfe0
--- /dev/null
+++ b/src/fluency/rendering/pygfx_renderer.py
@@ -0,0 +1,411 @@
+"""
+pygfx-based renderer for Fluency CAD.
+
+This module provides a modern WebGPU-based renderer using pygfx,
+offering a smaller dependency footprint than VTK while providing
+excellent 3D visualization capabilities.
+"""
+
+from typing import List, Tuple, Optional, Callable, Any
+import numpy as np
+from dataclasses import dataclass
+
+from fluency.rendering.base import (
+ Renderer,
+ RenderObject,
+ RenderColor,
+)
+
+
+@dataclass
+class PygfxRenderObject(RenderObject):
+ """pygfx render object wrapper."""
+
+ scene_node: Any = None
+ geometry: Any = None
+ material: Any = None
+
+ def __post_init__(self) -> None:
+ if self.scene_node is not None:
+ self._scene_node = self.scene_node
+
+
+class PygfxRenderer(Renderer):
+ """
+ pygfx-based renderer implementation.
+
+ This renderer uses pygfx (WebGPU-based) for 3D visualization,
+ providing modern rendering with a small dependency footprint.
+ """
+
+ def __init__(self) -> None:
+ self._canvas: Any = None
+ self._renderer: Any = None
+ self._scene: Any = None
+ self._camera: Any = None
+ self._controller: Any = None
+ self._objects: List[PygfxRenderObject] = []
+ self._pick_callback: Optional[Callable[[Any], None]] = None
+ self._camera_change_callback: Optional[Callable[[], None]] = None
+ self._background_color: Tuple[float, float, float] = (0.1, 0.1, 0.15)
+ self._initialized: bool = False
+
+ def initialize(self, parent_widget: Any) -> bool:
+ """Initialize pygfx with Qt widget."""
+ try:
+ import pygfx as gfx
+ from wgpu.gui.qt import WgpuCanvas
+ from PySide6.QtWidgets import QVBoxLayout
+
+ self._canvas = WgpuCanvas(parent=parent_widget)
+ self._renderer = gfx.renderers.WgpuRenderer(self._canvas)
+ self._scene = gfx.Scene()
+
+ self._camera = gfx.PerspectiveCamera(50, 16 / 9)
+ self._camera.position.set(100, 100, 100)
+
+ self._controller = gfx.OrbitController(self._camera, register_events=self._renderer)
+
+ self._setup_lighting()
+ self._add_grid()
+
+ layout = QVBoxLayout(parent_widget)
+ layout.setContentsMargins(0, 0, 0, 0)
+ layout.addWidget(self._canvas)
+
+ self._setup_picking()
+ self._initialized = True
+
+ return True
+
+ except Exception as e:
+ print(f"Failed to initialize pygfx: {e}")
+ return False
+
+ def _setup_lighting(self) -> None:
+ """Setup scene lighting."""
+ import pygfx as gfx
+
+ ambient = gfx.AmbientLight(intensity=0.3)
+ self._scene.add(ambient)
+
+ directional = gfx.DirectionalLight(intensity=1.0)
+ directional.position.set(100, 100, 100)
+ self._scene.add(directional)
+
+ fill = gfx.DirectionalLight(intensity=0.5)
+ fill.position.set(-100, 50, 50)
+ self._scene.add(fill)
+
+ def _add_grid(self) -> None:
+ """Add reference grid."""
+ import pygfx as gfx
+
+ grid = gfx.GridHelper(
+ size=200, divisions=20, color1=(0.3, 0.3, 0.3, 1), color2=(0.2, 0.2, 0.2, 1)
+ )
+ self._scene.add(grid)
+
+ def _setup_picking(self) -> None:
+ """Setup mesh picking."""
+ pass
+
+ def shutdown(self) -> None:
+ """Clean up renderer resources."""
+ self._objects.clear()
+ self._initialized = False
+
+ def add_mesh(
+ self,
+ vertices: np.ndarray,
+ faces: np.ndarray,
+ color: Tuple[float, float, float] = (0.2, 0.4, 0.8),
+ name: Optional[str] = None,
+ ) -> PygfxRenderObject:
+ """Add a mesh to the scene."""
+ import pygfx as gfx
+
+ vertices = np.asarray(vertices, dtype=np.float32)
+ faces = np.asarray(faces, dtype=np.int32)
+
+ geometry = gfx.Geometry(positions=vertices, indices=faces)
+
+ normals = gfx.compute_normals(geometry)
+ geometry.normals = normals
+
+ material = gfx.MeshPhongMaterial(color=color, flat_shading=False)
+
+ mesh = gfx.Mesh(geometry, material)
+ self._scene.add(mesh)
+
+ obj = PygfxRenderObject(name=name, scene_node=mesh, geometry=geometry, material=material)
+ obj.color = RenderColor(*color)
+ self._objects.append(obj)
+
+ return obj
+
+ def add_wireframe(
+ self,
+ vertices: np.ndarray,
+ edges: np.ndarray,
+ color: Tuple[float, float, float] = (1.0, 1.0, 1.0),
+ line_width: float = 1.0,
+ name: Optional[str] = None,
+ ) -> PygfxRenderObject:
+ """Add a wireframe to the scene."""
+ import pygfx as gfx
+
+ positions: List[List[float]] = []
+ for edge in edges:
+ positions.append(vertices[edge[0]])
+ positions.append(vertices[edge[1]])
+
+ positions_arr = np.array(positions, dtype=np.float32)
+
+ geometry = gfx.Geometry(positions=positions_arr)
+ material = gfx.LineMaterial(color=color, thickness=line_width)
+
+ lines = gfx.Line(geometry, material)
+ self._scene.add(lines)
+
+ obj = PygfxRenderObject(name=name, scene_node=lines, geometry=geometry, material=material)
+ obj.color = RenderColor(*color)
+ self._objects.append(obj)
+
+ return obj
+
+ def add_points(
+ self,
+ points: np.ndarray,
+ color: Tuple[float, float, float] = (1.0, 0.0, 0.0),
+ size: float = 5.0,
+ name: Optional[str] = None,
+ ) -> PygfxRenderObject:
+ """Add points to the scene."""
+ import pygfx as gfx
+
+ points_arr = np.asarray(points, dtype=np.float32)
+
+ geometry = gfx.Geometry(positions=points_arr)
+ material = gfx.PointsMaterial(color=color, size=size)
+
+ points_obj = gfx.Points(geometry, material)
+ self._scene.add(points_obj)
+
+ obj = PygfxRenderObject(
+ name=name, scene_node=points_obj, geometry=geometry, material=material
+ )
+ obj.color = RenderColor(*color)
+ self._objects.append(obj)
+
+ return obj
+
+ def add_lines(
+ self,
+ start_points: np.ndarray,
+ end_points: np.ndarray,
+ color: Tuple[float, float, float] = (1.0, 1.0, 1.0),
+ line_width: float = 1.0,
+ name: Optional[str] = None,
+ ) -> PygfxRenderObject:
+ """Add line segments to the scene."""
+ import pygfx as gfx
+
+ positions = np.hstack([start_points, end_points]).flatten()
+ positions = positions.reshape(-1, 3).astype(np.float32)
+
+ geometry = gfx.Geometry(positions=positions)
+ material = gfx.LineMaterial(color=color, thickness=line_width)
+
+ lines = gfx.Line(geometry, material)
+ self._scene.add(lines)
+
+ obj = PygfxRenderObject(name=name, scene_node=lines, geometry=geometry, material=material)
+ obj.color = RenderColor(*color)
+ self._objects.append(obj)
+
+ return obj
+
+ def remove_object(self, obj: RenderObject) -> bool:
+ """Remove an object from the scene."""
+ if isinstance(obj, PygfxRenderObject) and obj in self._objects:
+ if obj.scene_node is not None:
+ self._scene.remove(obj.scene_node)
+ self._objects.remove(obj)
+ return True
+ return False
+
+ def clear_scene(self) -> None:
+ """Remove all objects from the scene."""
+ for obj in self._objects[:]:
+ if obj.scene_node is not None:
+ self._scene.remove(obj.scene_node)
+ self._objects.clear()
+
+ def update_mesh(self, obj: RenderObject, vertices: np.ndarray, faces: np.ndarray) -> bool:
+ """Update mesh geometry."""
+ if not isinstance(obj, PygfxRenderObject):
+ return False
+
+ import pygfx as gfx
+
+ vertices = np.asarray(vertices, dtype=np.float32)
+ faces = np.asarray(faces, dtype=np.int32)
+
+ geometry = gfx.Geometry(positions=vertices, indices=faces)
+ geometry.normals = gfx.compute_normals(geometry)
+
+ obj.geometry = geometry
+ if obj.scene_node is not None:
+ obj.scene_node.geometry = geometry
+
+ return True
+
+ def set_object_color(self, obj: RenderObject, color: Tuple[float, float, float]) -> None:
+ """Set the color of an object."""
+ if isinstance(obj, PygfxRenderObject):
+ obj.color = RenderColor(*color)
+ if obj.material is not None:
+ obj.material.color = color
+
+ def set_object_visible(self, obj: RenderObject, visible: bool) -> None:
+ """Set the visibility of an object."""
+ if isinstance(obj, PygfxRenderObject):
+ obj.visible = visible
+ if obj.scene_node is not None:
+ obj.scene_node.visible = visible
+
+ def set_camera_position(
+ self,
+ position: Tuple[float, float, float],
+ target: Tuple[float, float, float] = (0, 0, 0),
+ up: Tuple[float, float, float] = (0, 0, 1),
+ ) -> None:
+ """Set camera position and orientation."""
+ self._camera.position.set(*position)
+ self._camera.look_at(*target)
+ self._camera.up.set(*up)
+
+ def get_camera_position(self) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
+ """Get camera position, target, and up vector."""
+ pos = np.array(self._camera.position.to_array())
+ target = np.array([0, 0, 0])
+ up = np.array(self._camera.up.to_array())
+ return pos, target, up
+
+ def fit_camera(self, padding: float = 1.1) -> None:
+ """Fit camera to show all objects."""
+ if not self._objects:
+ return
+
+ all_positions: List[np.ndarray] = []
+ for obj in self._objects:
+ if obj.geometry is not None and hasattr(obj.geometry, "positions"):
+ positions = obj.geometry.positions.data
+ all_positions.append(positions)
+
+ if all_positions:
+ positions = np.vstack(all_positions)
+ min_pos = positions.min(axis=0)
+ max_pos = positions.max(axis=0)
+ center = (min_pos + max_pos) / 2
+ size = np.linalg.norm(max_pos - min_pos) * padding
+
+ self._camera.position.set(center[0] + size, center[1] + size, center[2] + size)
+ self._camera.look_at(*center)
+
+ def set_camera_perspective(
+ self, fov: float = 50.0, near: float = 0.1, far: float = 10000.0
+ ) -> None:
+ """Set camera perspective parameters."""
+ self._camera.fov = fov
+ self._camera.near = near
+ self._camera.far = far
+
+ def set_camera_orthographic(
+ self, width: float = 100.0, near: float = 0.1, far: float = 10000.0
+ ) -> None:
+ """Set camera orthographic parameters."""
+ import pygfx as gfx
+
+ self._camera = gfx.OrthographicCamera(width=width, near=near, far=far)
+ self._controller.camera = self._camera
+
+ def render(self) -> None:
+ """Trigger a render."""
+ if self._initialized:
+ self._renderer.render(self._scene, self._camera)
+ self._canvas.request_draw()
+
+ def on_pick(self, callback: Callable[[Any], None]) -> None:
+ """Register a callback for picking/selection."""
+ self._pick_callback = callback
+
+ def on_camera_change(self, callback: Callable[[], None]) -> None:
+ """Register a callback for camera changes."""
+ self._camera_change_callback = callback
+
+ def set_background_color(self, color: Tuple[float, float, float]) -> None:
+ """Set the background color."""
+ self._background_color = color
+ self._scene.background = color
+
+ def add_grid(
+ self,
+ size: float = 100.0,
+ divisions: int = 10,
+ color: Tuple[float, float, float] = (0.3, 0.3, 0.3),
+ ) -> PygfxRenderObject:
+ """Add a reference grid."""
+ import pygfx as gfx
+
+ grid = gfx.GridHelper(
+ size=size, divisions=divisions, color1=(*color, 1), color2=(*color, 0.5)
+ )
+ self._scene.add(grid)
+
+ obj = PygfxRenderObject(name="grid", scene_node=grid)
+ return obj
+
+ def add_axes(self, size: float = 10.0, visible: bool = True) -> PygfxRenderObject:
+ """Add coordinate axes."""
+ import pygfx as gfx
+
+ axes = gfx.AxesHelper(size=size)
+ axes.visible = visible
+ self._scene.add(axes)
+
+ obj = PygfxRenderObject(name="axes", scene_node=axes)
+ return obj
+
+ def get_screen_size(self) -> Tuple[int, int]:
+ """Get the screen size in pixels."""
+ if self._canvas is not None:
+ return self._canvas.get_physical_size()
+ return (800, 600)
+
+ def project_to_screen(self, point: Tuple[float, float, float]) -> Tuple[int, int]:
+ """Project a 3D point to screen coordinates."""
+ return (0, 0)
+
+ def unproject_from_screen(
+ self, screen_x: int, screen_y: int, depth: float = 0.0
+ ) -> Tuple[float, float, float]:
+ """Unproject screen coordinates to 3D."""
+ return (0.0, 0.0, 0.0)
+
+ def take_screenshot(self) -> np.ndarray:
+ """Take a screenshot of the current view."""
+ return np.zeros((100, 100, 4), dtype=np.uint8)
+
+ def save_screenshot(self, filepath: str) -> bool:
+ """Save a screenshot to file."""
+ try:
+ img = self.take_screenshot()
+ from PIL import Image
+
+ Image.fromarray(img).save(filepath)
+ return True
+ except Exception as e:
+ print(f"Screenshot error: {e}")
+ return False
diff --git a/src/fluency/utils/__init__.py b/src/fluency/utils/__init__.py
new file mode 100644
index 0000000..ca549b0
--- /dev/null
+++ b/src/fluency/utils/__init__.py
@@ -0,0 +1,3 @@
+"""Utilities module."""
+
+__all__ = []
diff --git a/src/fluency/widgets/__init__.py b/src/fluency/widgets/__init__.py
new file mode 100644
index 0000000..9d95dcd
--- /dev/null
+++ b/src/fluency/widgets/__init__.py
@@ -0,0 +1,3 @@
+"""Widgets module."""
+
+__all__ = []
diff --git a/test_coordinates.py b/test_coordinates.py
deleted file mode 100644
index a80d6c4..0000000
--- a/test_coordinates.py
+++ /dev/null
@@ -1,137 +0,0 @@
-#!/usr/bin/env python3
-"""
-Simple test to verify coordinate systems and point detection
-"""
-
-import sys
-sys.path.append('/Volumes/Data_drive/Programming/fluency')
-
-from PySide6.QtWidgets import QApplication, QMainWindow, QVBoxLayout, QWidget, QPushButton, QHBoxLayout
-from PySide6.QtCore import Qt, QPoint
-from drawing_modules.improved_sketcher import ImprovedSketchWidget, SketchMode, Point2D
-import logging
-
-# Set up more detailed logging
-logging.basicConfig(level=logging.DEBUG, format='%(name)s - %(levelname)s - %(message)s')
-logger = logging.getLogger(__name__)
-
-class CoordinateTestWindow(QMainWindow):
- def __init__(self):
- super().__init__()
- self.setWindowTitle("Coordinate System Test")
- self.resize(1000, 700)
-
- # Create central widget
- central_widget = QWidget()
- self.setCentralWidget(central_widget)
- layout = QVBoxLayout(central_widget)
-
- # Create button layout
- button_layout = QHBoxLayout()
-
- # Add test points button
- add_points_btn = QPushButton("Add Points at (0,0), (100,100), (-100,-100)")
- add_points_btn.clicked.connect(self.add_test_points)
- button_layout.addWidget(add_points_btn)
-
- # Test coordinate conversion
- test_coords_btn = QPushButton("Test Coordinate Conversion")
- test_coords_btn.clicked.connect(self.test_coordinate_conversion)
- button_layout.addWidget(test_coords_btn)
-
- layout.addLayout(button_layout)
-
- # Create the sketcher widget
- self.sketcher = ImprovedSketchWidget()
- self.sketcher.set_mode(SketchMode.NONE)
- layout.addWidget(self.sketcher)
-
- # Override mouse events to add logging
- self.original_mouse_press = self.sketcher.mousePressEvent
- self.sketcher.mousePressEvent = self.logged_mouse_press
-
- def logged_mouse_press(self, event):
- """Logged mouse press to see coordinates"""
- viewport_pos = event.pos()
- local_pos = self.sketcher._viewport_to_local(viewport_pos)
- print(f"Mouse press at viewport: {viewport_pos}, local: {local_pos}")
-
- # Check if we can find a point near this location
- point = self.sketcher.sketch.get_point_near(local_pos)
- print(f"Point near click: {point}")
-
- # Call original method
- return self.original_mouse_press(event)
-
- def add_test_points(self):
- """Add specific test points"""
- print("\n--- Adding test points ---")
-
- # Clear existing points
- self.sketcher.sketch.points.clear()
-
- # Add points at specific locations
- test_points = [
- (0, 0, "Origin"),
- (100, 100, "Positive quadrant"),
- (-100, -100, "Negative quadrant"),
- (150, -50, "Mixed quadrant")
- ]
-
- for x, y, label in test_points:
- print(f"Creating {label} point at ({x}, {y})")
- point = Point2D(x, y)
-
- # Add to sketch - this will add to solver too
- added_point = self.sketcher.sketch.add_point(point)
-
- print(f" Before solver: Point at ({added_point.x}, {added_point.y})")
- print(f" UI point: {added_point.ui_point}")
-
- # Run solver to see if it changes
- result = self.sketcher.sketch.solve_system()
-
- print(f" After solver ({result}): Point at ({added_point.x}, {added_point.y})")
- print(f" UI point after: {added_point.ui_point}")
- print()
-
- self.sketcher.update()
- print(f"Total points in sketch: {len(self.sketcher.sketch.points)}")
-
- def test_coordinate_conversion(self):
- """Test coordinate conversion functions"""
- print("\n--- Testing coordinate conversion ---")
-
- # Test some viewport positions
- test_viewport_positions = [
- QPoint(500, 350), # Center of widget (approximately)
- QPoint(600, 250), # Right and up from center
- QPoint(400, 450), # Left and down from center
- ]
-
- for vp_pos in test_viewport_positions:
- local_pos = self.sketcher._viewport_to_local(vp_pos)
- back_to_viewport = self.sketcher._local_to_viewport(local_pos)
-
- print(f"Viewport {vp_pos} -> Local {local_pos} -> Viewport {back_to_viewport}")
-
- # Check if conversion is accurate
- diff_x = abs(vp_pos.x() - back_to_viewport.x())
- diff_y = abs(vp_pos.y() - back_to_viewport.y())
- print(f" Difference: ({diff_x}, {diff_y})")
-
-if __name__ == "__main__":
- app = QApplication(sys.argv)
-
- window = CoordinateTestWindow()
- window.show()
-
- print("\n" + "="*60)
- print("COORDINATE SYSTEM TEST")
- print("1. Click 'Add Points...' to create test points")
- print("2. Click 'Test Coordinate Conversion' to test conversions")
- print("3. Try clicking near the points to see if they're detected")
- print("4. Try dragging points (should work if mode is NONE)")
- print("="*60 + "\n")
-
- sys.exit(app.exec())
diff --git a/test_dragging_specific.py b/test_dragging_specific.py
deleted file mode 100644
index b7b9714..0000000
--- a/test_dragging_specific.py
+++ /dev/null
@@ -1,180 +0,0 @@
-#!/usr/bin/env python3
-"""
-Specific test for dragging functionality
-"""
-
-import sys
-sys.path.append('/Volumes/Data_drive/Programming/fluency')
-
-from PySide6.QtWidgets import QApplication, QMainWindow, QVBoxLayout, QWidget, QPushButton, QHBoxLayout
-from PySide6.QtCore import Qt, QPoint
-from PySide6.QtGui import QMouseEvent
-from drawing_modules.improved_sketcher import ImprovedSketchWidget, SketchMode, Point2D
-import logging
-
-# Set up more detailed logging
-logging.basicConfig(level=logging.DEBUG, format='%(name)s - %(levelname)s - %(message)s')
-logger = logging.getLogger(__name__)
-
-class DragTestWindow(QMainWindow):
- def __init__(self):
- super().__init__()
- self.setWindowTitle("Dragging Test")
- self.resize(1000, 700)
-
- # Create central widget
- central_widget = QWidget()
- self.setCentralWidget(central_widget)
- layout = QVBoxLayout(central_widget)
-
- # Create button layout
- button_layout = QHBoxLayout()
-
- # Add test points button
- add_points_btn = QPushButton("Add Test Points")
- add_points_btn.clicked.connect(self.add_test_points)
- button_layout.addWidget(add_points_btn)
-
- # Test manual drag
- test_drag_btn = QPushButton("Test Manual Drag")
- test_drag_btn.clicked.connect(self.test_manual_drag)
- button_layout.addWidget(test_drag_btn)
-
- # Check state
- check_state_btn = QPushButton("Check State")
- check_state_btn.clicked.connect(self.check_state)
- button_layout.addWidget(check_state_btn)
-
- layout.addLayout(button_layout)
-
- # Create the sketcher widget
- self.sketcher = ImprovedSketchWidget()
- self.sketcher.set_mode(SketchMode.NONE)
- layout.addWidget(self.sketcher)
-
- # Override mouse events to add detailed logging
- self.override_mouse_events()
-
- def override_mouse_events(self):
- """Override mouse events with logging"""
- self.original_mouse_press = self.sketcher.mousePressEvent
- self.original_mouse_move = self.sketcher.mouseMoveEvent
- self.original_mouse_release = self.sketcher.mouseReleaseEvent
-
- self.sketcher.mousePressEvent = self.logged_mouse_press
- self.sketcher.mouseMoveEvent = self.logged_mouse_move
- self.sketcher.mouseReleaseEvent = self.logged_mouse_release
-
- def logged_mouse_press(self, event):
- """Logged mouse press"""
- viewport_pos = event.pos()
- local_pos = self.sketcher._viewport_to_local(viewport_pos)
- print(f"\n=== MOUSE PRESS ===")
- print(f"Button: {event.button()}")
- print(f"Viewport: {viewport_pos}, Local: {local_pos}")
- print(f"Current mode: {self.sketcher.current_mode}")
- print(f"Dragging point before: {self.sketcher.dragging_point}")
-
- # Call original method
- result = self.original_mouse_press(event)
-
- print(f"Dragging point after: {self.sketcher.dragging_point}")
- print(f"Drag start pos: {self.sketcher.drag_start_pos}")
-
- return result
-
- def logged_mouse_move(self, event):
- """Logged mouse move"""
- if self.sketcher.dragging_point:
- viewport_pos = event.pos()
- local_pos = self.sketcher._viewport_to_local(viewport_pos)
- print(f"DRAG MOVE - Viewport: {viewport_pos}, Local: {local_pos}")
-
- return self.original_mouse_move(event)
-
- def logged_mouse_release(self, event):
- """Logged mouse release"""
- if self.sketcher.dragging_point:
- print(f"DRAG END - Button: {event.button()}")
-
- return self.original_mouse_release(event)
-
- def add_test_points(self):
- """Add test points"""
- print("\\n--- Adding test points ---")
-
- # Clear existing points
- self.sketcher.sketch.points.clear()
-
- # Add a few test points
- test_points = [
- (0, 0),
- (100, 100),
- (-100, -100)
- ]
-
- for x, y in test_points:
- point = Point2D(x, y)
- self.sketcher.sketch.add_point(point)
- print(f"Added point at ({x}, {y})")
-
- self.sketcher.update()
- print(f"Total points: {len(self.sketcher.sketch.points)}")
-
- def test_manual_drag(self):
- """Test manual drag simulation"""
- print("\\n--- Testing manual drag ---")
-
- if not self.sketcher.sketch.points:
- print("No points to drag! Add test points first.")
- return
-
- # Get the first point
- point = self.sketcher.sketch.points[0]
- print(f"Testing drag of point: {point}")
-
- # Simulate starting a drag
- start_pos = QPoint(int(point.x), int(point.y))
- print(f"Starting drag at local position: {start_pos}")
-
- self.sketcher._start_point_drag(point, start_pos)
- print(f"Dragging point is now: {self.sketcher.dragging_point}")
-
- # Simulate moving the point
- new_pos = QPoint(int(point.x + 50), int(point.y + 30))
- print(f"Moving to local position: {new_pos}")
-
- self.sketcher._handle_point_drag(new_pos)
- print(f"Point position after drag: ({point.x}, {point.y})")
-
- # End the drag
- self.sketcher._end_point_drag()
- print(f"Point position after drag end: ({point.x}, {point.y})")
- print(f"Dragging point after end: {self.sketcher.dragging_point}")
-
- def check_state(self):
- """Check current state"""
- print("\\n--- Current State ---")
- print(f"Mode: {self.sketcher.current_mode}")
- print(f"Dragging point: {self.sketcher.dragging_point}")
- print(f"Drag start pos: {self.sketcher.drag_start_pos}")
- print(f"Number of points: {len(self.sketcher.sketch.points)}")
-
- for i, point in enumerate(self.sketcher.sketch.points):
- print(f" Point {i}: ({point.x}, {point.y}) at UI: {point.ui_point}")
-
-if __name__ == "__main__":
- app = QApplication(sys.argv)
-
- window = DragTestWindow()
- window.show()
-
- print("\\n" + "="*60)
- print("DRAGGING FUNCTIONALITY TEST")
- print("1. Click 'Add Test Points' to create points")
- print("2. Click 'Test Manual Drag' to simulate dragging programmatically")
- print("3. Click 'Check State' to see current state")
- print("4. Try manual dragging with mouse (watch console output)")
- print("="*60 + "\\n")
-
- sys.exit(app.exec())
diff --git a/test_final_dragging.py b/test_final_dragging.py
deleted file mode 100644
index dd0eadd..0000000
--- a/test_final_dragging.py
+++ /dev/null
@@ -1,160 +0,0 @@
-#!/usr/bin/env python3
-"""
-Final comprehensive test for the enhanced dragging functionality
-"""
-
-import sys
-sys.path.append('/Volumes/Data_drive/Programming/fluency')
-
-from PySide6.QtWidgets import QApplication, QMainWindow, QVBoxLayout, QWidget, QPushButton, QHBoxLayout, QLabel
-from PySide6.QtCore import Qt, QPoint
-from drawing_modules.improved_sketcher import ImprovedSketchWidget, SketchMode, Point2D
-import logging
-
-# Set up detailed logging
-logging.basicConfig(level=logging.INFO, format='%(name)s - %(levelname)s - %(message)s')
-logger = logging.getLogger(__name__)
-
-class EnhancedDragTestWindow(QMainWindow):
- def __init__(self):
- super().__init__()
- self.setWindowTitle("Enhanced Dragging Test - Final Version")
- self.resize(1200, 800)
-
- # Create central widget
- central_widget = QWidget()
- self.setCentralWidget(central_widget)
- layout = QVBoxLayout(central_widget)
-
- # Add instructions
- instructions = QLabel("""
-ENHANCED DRAGGING TEST - INSTRUCTIONS:
-1. Click 'Add Test Points' to create some points
-2. Make sure Mode is 'NONE' for dragging (check status bottom-left)
-3. Click and drag any point - it will turn ORANGE while dragging
-4. Points snap to other points when dragged near them
-5. Try dragging with constraints (after creating lines)
-6. Right-click to cancel any operation
- """)
- instructions.setStyleSheet("QLabel { background-color: #333; color: white; padding: 10px; }")
- layout.addWidget(instructions)
-
- # Create button layout
- button_layout = QHBoxLayout()
-
- # Add test points button
- add_points_btn = QPushButton("Add Test Points")
- add_points_btn.clicked.connect(self.add_test_points)
- button_layout.addWidget(add_points_btn)
-
- # Add lines button
- add_lines_btn = QPushButton("Add Test Lines")
- add_lines_btn.clicked.connect(self.add_test_lines)
- button_layout.addWidget(add_lines_btn)
-
- # Set mode buttons
- mode_none_btn = QPushButton("Mode: NONE (Drag)")
- mode_none_btn.clicked.connect(lambda: self.sketcher.set_mode(SketchMode.NONE))
- button_layout.addWidget(mode_none_btn)
-
- mode_line_btn = QPushButton("Mode: LINE")
- mode_line_btn.clicked.connect(lambda: self.sketcher.set_mode(SketchMode.LINE))
- button_layout.addWidget(mode_line_btn)
-
- # Clear sketch
- clear_btn = QPushButton("Clear Sketch")
- clear_btn.clicked.connect(self.clear_sketch)
- button_layout.addWidget(clear_btn)
-
- layout.addLayout(button_layout)
-
- # Create the sketcher widget
- self.sketcher = ImprovedSketchWidget()
- self.sketcher.set_mode(SketchMode.NONE) # Start in drag mode
- layout.addWidget(self.sketcher)
-
- print("Enhanced Dragging Test Window created!")
- print("Current mode:", self.sketcher.current_mode)
-
- def add_test_points(self):
- """Add test points in a pattern"""
- print("\n--- Adding test points ---")
-
- # Add points in a pattern that's good for testing
- test_points = [
- (0, 0, "Origin"),
- (150, 150, "Upper-right"),
- (-150, 150, "Upper-left"),
- (-150, -150, "Lower-left"),
- (150, -150, "Lower-right"),
- (0, 200, "Top center"),
- (200, 0, "Right center"),
- ]
-
- for x, y, label in test_points:
- point = Point2D(x, y)
- self.sketcher.sketch.add_point(point)
- print(f"Added {label} at ({x}, {y})")
-
- self.sketcher.update()
- print(f"Total points: {len(self.sketcher.sketch.points)}")
-
- def add_test_lines(self):
- """Add some test lines between points"""
- print("\n--- Adding test lines ---")
-
- if len(self.sketcher.sketch.points) < 4:
- print("Need at least 4 points to create test lines!")
- return
-
- points = self.sketcher.sketch.points
-
- # Create lines between some points
- from drawing_modules.improved_sketcher import Line2D
- test_lines = [
- (points[0], points[1]), # Origin to upper-right
- (points[1], points[2]), # Upper-right to upper-left
- (points[2], points[3]), # Upper-left to lower-left
- (points[3], points[0]), # Lower-left back to origin
- ]
-
- for start, end in test_lines:
- line = Line2D(start, end)
- self.sketcher.sketch.add_line(line)
- print(f"Added line from ({start.x}, {start.y}) to ({end.x}, {end.y})")
-
- # Run solver to establish constraints
- result = self.sketcher.sketch.solve_system()
- print(f"Solver result: {result}")
-
- self.sketcher.update()
- print(f"Total lines: {len(self.sketcher.sketch.lines)}")
-
- def clear_sketch(self):
- """Clear the sketch"""
- print("\n--- Clearing sketch ---")
- self.sketcher.sketch.points.clear()
- self.sketcher.sketch.lines.clear()
- self.sketcher.sketch.circles.clear()
- self.sketcher.update()
- print("Sketch cleared!")
-
-if __name__ == "__main__":
- app = QApplication(sys.argv)
-
- window = EnhancedDragTestWindow()
- window.show()
-
- print("\n" + "="*70)
- print("ENHANCED DRAGGING TEST - READY!")
- print("="*70)
- print("Features to test:")
- print("✓ Basic point dragging (orange highlight during drag)")
- print("✓ Point snapping while dragging")
- print("✓ Drag error recovery")
- print("✓ Immediate visual feedback")
- print("✓ Proper coordinate handling")
- print("✓ Constraint satisfaction after drag")
- print("="*70)
-
- sys.exit(app.exec())
diff --git a/test_main_app_integration.py b/test_main_app_integration.py
deleted file mode 100644
index 5776e40..0000000
--- a/test_main_app_integration.py
+++ /dev/null
@@ -1,88 +0,0 @@
-#!/usr/bin/env python3
-"""
-Test the enhanced sketcher integration in the main app
-"""
-
-import sys
-import os
-sys.path.append('/Volumes/Data_drive/Programming/fluency')
-
-from main import MainWindow
-from PySide6.QtWidgets import QApplication
-from drawing_modules.improved_sketcher import SketchMode, Point2D
-
-def test_main_app_integration():
- """Test that the enhanced features work in the main app"""
- app = QApplication(sys.argv)
-
- # Create the main window
- window = MainWindow()
- window.show()
-
- print("=== MAIN APP INTEGRATION TEST ===")
- print()
-
- # Test 1: Check initial state
- print("Test 1: Initial state")
- print(f" Sketcher mode: {window.sketchWidget.current_mode}")
- print(f" Expected: SketchMode.NONE")
- print()
-
- # Test 2: Add some geometry to test dragging
- print("Test 2: Adding test points for dragging")
- # Create a new sketch first
- window.add_new_sketch_origin()
-
- # Add some test points
- test_points = [
- Point2D(100, 100),
- Point2D(200, 150),
- Point2D(-100, -100)
- ]
-
- for point in test_points:
- window.sketchWidget.sketch.add_point(point)
-
- window.sketchWidget.update()
- print(f" Added {len(test_points)} test points")
- print(f" Total points in sketch: {len(window.sketchWidget.sketch.points)}")
- print()
-
- # Test 3: Set constraint mode and check persistence
- print("Test 3: Testing persistent constraint modes")
- print(" Setting horizontal constraint mode...")
- window.sketchWidget.set_mode(SketchMode.HORIZONTAL)
- print(f" Current mode: {window.sketchWidget.current_mode}")
-
- # Simulate constraint application (but not right-click exit)
- print(" Simulating constraint application (should stay in mode)...")
- window.sketchWidget.sketch_modified.emit() # This should not reset mode
- print(f" Mode after sketch_modified: {window.sketchWidget.current_mode}")
-
- # Now simulate right-click (constraint_applied with mode reset)
- print(" Simulating right-click exit...")
- window.sketchWidget._reset_interaction_state()
- window.sketchWidget.set_mode(SketchMode.NONE)
- window.sketchWidget.constraint_applied.emit() # This should reset buttons
- print(f" Mode after right-click: {window.sketchWidget.current_mode}")
- print()
-
- # Test 4: Test dragging preparation
- print("Test 4: Dragging preparation")
- print(f" Current mode for dragging: {window.sketchWidget.current_mode}")
- print(f" Points available for dragging: {len(window.sketchWidget.sketch.points)}")
- for i, point in enumerate(window.sketchWidget.sketch.points):
- print(f" Point {i}: ({point.x}, {point.y}) at UI: {point.ui_point}")
- print()
-
- print("=== INTEGRATION TEST COMPLETE ===")
- print("You can now:")
- print("1. Try dragging points (should work when mode is NONE)")
- print("2. Test persistent constraints by clicking constraint buttons")
- print("3. Right-click should exit constraint modes")
- print("4. Points should highlight orange when dragged")
-
- return app.exec()
-
-if __name__ == "__main__":
- sys.exit(test_main_app_integration())
diff --git a/test_mode_fix.py b/test_mode_fix.py
deleted file mode 100644
index 7ddb1d9..0000000
--- a/test_mode_fix.py
+++ /dev/null
@@ -1,86 +0,0 @@
-#!/usr/bin/env python3
-"""
-Test the fix for None vs SketchMode.NONE handling
-"""
-
-import sys
-sys.path.append('/Volumes/Data_drive/Programming/fluency')
-
-from PySide6.QtWidgets import QApplication
-from PySide6.QtCore import QPoint
-from drawing_modules.improved_sketcher import ImprovedSketchWidget, SketchMode, Point2D
-import logging
-
-# Set up logging
-logging.basicConfig(level=logging.DEBUG, format='%(levelname)s: %(message)s')
-logger = logging.getLogger(__name__)
-
-def test_mode_handling():
- """Test that None is properly converted to SketchMode.NONE"""
- app = QApplication(sys.argv)
-
- widget = ImprovedSketchWidget()
-
- print("=== MODE HANDLING TEST ===")
-
- # Test 1: Initial mode should be SketchMode.NONE
- print(f"Initial mode: {widget.current_mode}")
- print(f"Initial mode type: {type(widget.current_mode)}")
- assert widget.current_mode == SketchMode.NONE
-
- # Test 2: Setting mode to None should convert to SketchMode.NONE
- print("\nSetting mode to None...")
- widget.set_mode(None)
- print(f"Mode after set_mode(None): {widget.current_mode}")
- print(f"Mode type: {type(widget.current_mode)}")
- assert widget.current_mode == SketchMode.NONE
-
- # Test 3: Setting mode to SketchMode.NONE should work normally
- print("\nSetting mode to SketchMode.NONE...")
- widget.set_mode(SketchMode.NONE)
- print(f"Mode after set_mode(SketchMode.NONE): {widget.current_mode}")
- assert widget.current_mode == SketchMode.NONE
-
- # Test 4: Test drag detection with both None and SketchMode.NONE
- print("\n--- Testing drag detection ---")
-
- # Add a test point
- point = Point2D(100, 100)
- widget.sketch.add_point(point)
-
- # Test with SketchMode.NONE
- widget.set_mode(SketchMode.NONE)
- click_pos = QPoint(100, 100) # Click right on the point
-
- print(f"Mode before drag test: {widget.current_mode}")
- print(f"Point at: ({point.x}, {point.y})")
- print(f"Click at: {click_pos}")
-
- # Simulate the drag detection logic
- if widget.current_mode == SketchMode.NONE or widget.current_mode is None:
- found_point = widget.sketch.get_point_near(click_pos, widget.snap_settings.snap_distance)
- print(f"Found point for drag: {found_point}")
- if found_point:
- print("✅ Drag would be initiated (SketchMode.NONE)")
- else:
- print("❌ Drag would NOT be initiated (SketchMode.NONE)")
-
- # Test with None mode
- widget.current_mode = None # Simulate main app setting None
- print(f"\nMode set to Python None: {widget.current_mode}")
-
- if widget.current_mode == SketchMode.NONE or widget.current_mode is None:
- found_point = widget.sketch.get_point_near(click_pos, widget.snap_settings.snap_distance)
- print(f"Found point for drag: {found_point}")
- if found_point:
- print("✅ Drag would be initiated (None mode)")
- else:
- print("❌ Drag would NOT be initiated (None mode)")
-
- print("\n=== TEST COMPLETE ===")
- print("The fix should allow dragging in both cases now!")
-
- return 0
-
-if __name__ == "__main__":
- sys.exit(test_mode_handling())
diff --git a/tests/test_geometry.py b/tests/test_geometry.py
new file mode 100644
index 0000000..5bdfd8f
--- /dev/null
+++ b/tests/test_geometry.py
@@ -0,0 +1,187 @@
+"""Tests for Fluency CAD geometry kernel."""
+
+import pytest
+import numpy as np
+
+from fluency.geometry_occ.kernel import OCGeometryKernel, OCCGeometryObject
+from fluency.geometry_occ.sketch import OCCSketch
+from fluency.geometry.base import Point2D
+
+
+class TestOCGeometryKernel:
+ """Tests for the OpenCASCADE geometry kernel."""
+
+ def test_kernel_creation(self):
+ """Test kernel can be created."""
+ kernel = OCGeometryKernel()
+ assert kernel is not None
+
+ def test_create_point(self):
+ """Test point creation."""
+ kernel = OCGeometryKernel()
+ point = kernel.create_point(10.0, 20.0)
+ assert point is not None
+
+ def test_create_line(self):
+ """Test line creation."""
+ kernel = OCGeometryKernel()
+ start = Point2D(0, 0)
+ end = Point2D(10, 10)
+ line = kernel.create_line(start, end)
+ assert line is not None
+
+ def test_create_circle(self):
+ """Test circle creation."""
+ kernel = OCGeometryKernel()
+ center = Point2D(0, 0)
+ circle = kernel.create_circle(center, 5.0)
+ assert circle is not None
+
+ def test_create_rectangle(self):
+ """Test rectangle creation."""
+ kernel = OCGeometryKernel()
+ rect = kernel.create_rectangle(10.0, 20.0)
+ assert rect is not None
+
+ def test_create_polygon(self):
+ """Test polygon creation."""
+ kernel = OCGeometryKernel()
+ points = [
+ Point2D(0, 0),
+ Point2D(10, 0),
+ Point2D(10, 10),
+ Point2D(0, 10),
+ ]
+ polygon = kernel.create_polygon(points)
+ assert polygon is not None
+
+ def test_extrude_polygon(self):
+ """Test extruding a polygon."""
+ kernel = OCGeometryKernel()
+ points = [
+ Point2D(0, 0),
+ Point2D(10, 0),
+ Point2D(10, 10),
+ Point2D(0, 10),
+ ]
+ polygon = kernel.create_polygon(points)
+ extruded = kernel.extrude(polygon, 20.0)
+ assert extruded is not None
+
+ def test_get_mesh(self):
+ """Test mesh generation."""
+ kernel = OCGeometryKernel()
+ points = [
+ Point2D(0, 0),
+ Point2D(10, 0),
+ Point2D(10, 10),
+ Point2D(0, 10),
+ ]
+ polygon = kernel.create_polygon(points)
+ extruded = kernel.extrude(polygon, 20.0)
+
+ vertices, faces = kernel.get_mesh(extruded)
+
+ assert len(vertices) > 0
+ assert len(faces) > 0
+ assert vertices.shape[1] == 3
+ assert faces.shape[1] == 3
+
+ def test_get_bounding_box(self):
+ """Test bounding box calculation."""
+ kernel = OCGeometryKernel()
+ points = [
+ Point2D(0, 0),
+ Point2D(10, 0),
+ Point2D(10, 10),
+ Point2D(0, 10),
+ ]
+ polygon = kernel.create_polygon(points)
+ extruded = kernel.extrude(polygon, 20.0)
+
+ min_pt, max_pt = kernel.get_bounding_box(extruded)
+
+ assert min_pt.x <= max_pt.x
+ assert min_pt.y <= max_pt.y
+ assert min_pt.z <= max_pt.z
+
+ def test_get_volume(self):
+ """Test volume calculation."""
+ kernel = OCGeometryKernel()
+ points = [
+ Point2D(0, 0),
+ Point2D(10, 0),
+ Point2D(10, 10),
+ Point2D(0, 10),
+ ]
+ polygon = kernel.create_polygon(points)
+ extruded = kernel.extrude(polygon, 20.0)
+
+ volume = kernel.get_volume(extruded)
+
+ assert volume > 0
+ assert abs(volume - 2000.0) < 0.1
+
+
+class TestOCCSketch:
+ """Tests for the OpenCASCADE sketch."""
+
+ def test_sketch_creation(self):
+ """Test sketch can be created."""
+ sketch = OCCSketch()
+ assert sketch is not None
+
+ def test_add_point(self):
+ """Test adding a point."""
+ sketch = OCCSketch()
+ point = sketch.add_point(10.0, 20.0)
+ assert point is not None
+ assert point.entity_type == "point"
+
+ def test_add_line(self):
+ """Test adding a line."""
+ sketch = OCCSketch()
+ p1 = sketch.add_point(0, 0)
+ p2 = sketch.add_point(10, 10)
+ line = sketch.add_line(p1, p2)
+ assert line is not None
+ assert line.entity_type == "line"
+
+ def test_add_circle(self):
+ """Test adding a circle."""
+ sketch = OCCSketch()
+ center = sketch.add_point(0, 0)
+ circle = sketch.add_circle(center, 5.0)
+ assert circle is not None
+ assert circle.entity_type == "circle"
+
+ def test_add_rectangle(self):
+ """Test adding a rectangle."""
+ sketch = OCCSketch()
+ entities = sketch.add_rectangle((0, 0), (10, 10))
+ assert len(entities) == 8
+
+ def test_get_points(self):
+ """Test getting points."""
+ sketch = OCCSketch()
+ sketch.add_point(0, 0)
+ sketch.add_point(10, 10)
+ sketch.add_point(20, 20)
+
+ points = sketch.get_points()
+ assert len(points) == 3
+
+ def test_clear(self):
+ """Test clearing sketch."""
+ sketch = OCCSketch()
+ sketch.add_point(0, 0)
+ sketch.add_point(10, 10)
+
+ sketch.clear()
+
+ points = sketch.get_points()
+ assert len(points) == 0
+
+
+if __name__ == "__main__":
+ pytest.main([__file__, "-v"])