diff --git a/.idea/workspace.xml b/.idea/workspace.xml
index 789cb0d..997a4df 100644
--- a/.idea/workspace.xml
+++ b/.idea/workspace.xml
@@ -4,16 +4,18 @@
-
+
+
+
-
-
+
-
+
+
@@ -30,7 +32,7 @@
@@ -57,6 +59,7 @@
"Python.Unnamed.executor": "Run",
"Python.base.executor": "Run",
"Python.data_model.executor": "Run",
+ "Python.debug_dragging.executor": "Run",
"Python.draw_widget2d.executor": "Run",
"Python.draw_widget_solve.executor": "Run",
"Python.fluency.executor": "Run",
@@ -65,6 +68,7 @@
"Python.gui_ui.executor": "Run",
"Python.kernel.executor": "Run",
"Python.main.executor": "Run",
+ "Python.main_window.executor": "Run",
"Python.meshtest.executor": "Run",
"Python.occ_renderer.executor": "Run",
"Python.occ_to_mesh.executor": "Run",
@@ -81,7 +85,7 @@
"RunOnceActivity.typescript.service.memoryLimit.init": "true",
"codeWithMe.voiceChat.enabledByDefault": "false",
"git-widget-placeholder": "feature/occ-migration",
- "last_opened_file_path": "/Volumes/Data_drive/Programming/fluency",
+ "last_opened_file_path": "/Volumes/Data_drive/Programming/fluency/src/fluency",
"node.js.detected.package.eslint": "true",
"node.js.selected.package.eslint": "(autodetect)",
"node.js.selected.package.tslint": "(autodetect)",
@@ -96,9 +100,9 @@
+
-
diff --git a/gui.ui b/gui.ui
index d8f251f..0c51197 100644
--- a/gui.ui
+++ b/gui.ui
@@ -6,63 +6,56 @@
0
0
- 2668
- 1287
+ 2551
+ 1248
+
+
+ 0
+ 0
+
+
fluencyCAD
- -
-
-
-
- 0
- 120
-
-
-
- Assembly
-
-
-
- -
-
+
-
+
0
0
-
-
- 200
- 16777215
-
-
- Drawing
+ Snapping Points
-
+
- 10
+ 12
-
-
-
+
+ 12
+
+
+ 12
+
+
+ 12
+
+
-
+
- Arc
-
-
- true
+ Snp Dst
- -
-
+
-
+
- Rctgl
+ Vert
true
@@ -72,112 +65,116 @@
- -
-
-
- Circle
-
-
- true
-
-
- false
-
-
-
- -
-
-
- Cstrct
-
-
- true
-
-
-
- -
-
-
- Snap
-
-
-
- 13
- 16
-
-
-
- true
-
-
- true
-
-
-
- -
-
-
- Line
-
-
- S
-
-
- true
-
-
- false
-
-
-
- -
-
-
- Slot
-
-
- true
-
-
- false
-
-
-
- -
-
+
-
+
Qt::Horizontal
- -
-
-
- Offset selected sketch face (duplicate + offset boundary)
-
+
-
+
- Offst
+ Angl Stps
+
+
+
+ -
+
+
+ mm
+
+
+ 30
+
+
+ 10
+
+
+
+ -
+
+
+ Grid
+
+
+ true
+
+
+ false
+
+
+
+ -
+
+
+ Horiz
+
+
+ true
+
+
+ false
+
+
+
+ -
+
+
+ °
+
+
+ 180
+
+
+ 15
+
+
+
+ -
+
+
+ Pnt
+
+
+ true
+
+
+ false
+
+
+
+ -
+
+
+ MidP
+
+
+ true
+
+
+ false
+
+
+
+ -
+
+
+ Angles
+
+
+ true
+
+
+ false
- -
-
-
- Qt::Horizontal
-
-
-
- 40
- 20
-
-
-
-
- -
-
+
-
+
200
@@ -185,106 +182,164 @@
- Workplanes
+ Export
-
+
-
-
-
- <W>orking Plane at 0, 0, 0
-
+
- WP Origin
+ STL
-
- W
+
+
+ -
+
+
+ IGES
-
-
-
- Working Plane >P<rojection at selected edges face
+
+
+ STEP
+
+
+
+
+
+
+ -
+
+
+
+ 0
+ 0
+
+
+
+ 1
+
+
+
+ Sketch
+
+
+
+
+
+ Code
+
+
+
-
+
+
+ -
+
+
+ Executive
+
+
+
-
+
+
+ Load Code
+
+
+
+ -
+
+
+ Save code
+
+
+
+ -
+
+
+ Apply Code
+
+
+
+ -
+
+
+ Delete Code
+
+
+
+
+
+
+
+
+
+
+ -
+
+
+
+ 0
+ 0
+
+
+
+
+ 0
+ 50
+
+
+
+ Component Tools
+
+
+
-
+
+
+
+ 50
+ 50
+
+
+
+
+ 50
+ 50
+
- WP Face
+ New
-
- P
-
-
+
+
+ -
+
+
true
-
-
- -
-
-
- Flip >N<ormal of projected mesh.
+
+
+ 0
+ 0
+
+
+
+
+ 50
+ 50
+
+
+
+
+ 50
+ 50
+
+
+
+ Qt::LeftToRight
- WP Flip
-
-
- N
-
-
-
- -
-
-
- false
-
-
- Show / hide the construction lines projected from the source face
-
-
- Underlay
-
-
- true
-
-
- true
-
-
-
- -
-
-
- false
-
-
- Forget the picked source face (keep the workplane)
-
-
- ClrFace
-
-
-
- -
-
-
- false
-
-
- Convert projected construction lines into real sketch geometry
-
-
- ToSketch
-
-
-
- -
-
-
- Create a new independent workplane (datum plane)
-
-
- WP New
-
-
- Shift+W
+ Del
@@ -447,78 +502,8 @@
- -
-
-
-
- 200
- 16777215
-
-
-
- Modify
-
-
-
-
-
-
- Rev
-
-
-
- -
-
-
- Extrd
-
-
-
- -
-
-
- Arry
-
-
-
- -
-
-
- Cut
-
-
-
- -
-
-
- Comb
-
-
-
- -
-
-
- Mve
-
-
-
-
-
-
- -
-
-
-
- 0
- 120
-
-
-
- Components
-
-
-
- -
-
+
-
+
0
@@ -527,16 +512,16 @@
- 0
+ 113
50
- Component Tools
+ Assembly Tools
-
+
-
-
+
50
@@ -550,12 +535,12 @@
- New
+ Add
-
-
+
true
@@ -581,166 +566,317 @@
Qt::LeftToRight
- Del
+ Rem
- -
-
-
-
- 0
- 0
-
+
-
+
+
+
+ 200
+ 16777215
+
- Snapping Points
+ Workplanes
-
-
- 12
-
-
- 12
-
-
- 12
-
-
- 12
-
-
-
-
-
- Snp Dst
-
-
-
- -
-
-
- Vert
-
-
- true
-
-
- false
-
-
-
- -
-
-
- Qt::Horizontal
-
-
-
- -
-
-
- Angl Stps
-
-
-
- -
-
-
- mm
-
-
- 30
-
-
- 10
-
-
-
- -
-
-
- Grid
-
-
- true
-
-
- false
-
-
-
- -
-
-
- Horiz
-
-
- true
-
-
- false
-
-
-
- -
-
-
- °
-
-
- 180
-
-
- 15
-
-
-
+
-
-
+
+
+ <W>orking Plane at 0, 0, 0
+
- Pnt
+ WP Origin
-
- true
-
-
- false
+
+ W
-
-
+
+
+ Working Plane >P<rojection at selected edges face
+
- MidP
+ WP Face
+
+
+ P
true
-
+
+
+ -
+
+
+ Flip >N<ormal of projected mesh.
+
+
+ WP Flip
+
+
+ N
+
+
+
+ -
+
+
false
+
+ Show / hide the construction lines projected from the source face
+
+
+ Underlay
+
+
+ true
+
+
+ true
+
-
-
-
- Angles
-
-
- true
-
-
+
+
false
+
+ Forget the picked source face (keep the workplane)
+
+
+ ClrFace
+
+
+
+ -
+
+
+ false
+
+
+ Convert projected construction lines into real sketch geometry
+
+
+ ToSketch
+
+
+
+ -
+
+
+ Create a new independent workplane (datum plane)
+
+
+ WP New
+
+
+ Shift+W
+
- -
+
-
+
+
+
+ 0
+ 0
+
+
+
+
+ 200
+ 16777215
+
+
+
+ Component Connections
+
+
+
+ 5
+
+
+ 5
+
+
+ 5
+
+
+ 5
+
+
-
+
+
+ true
+
+
+
+ -
+
+
+
+ 0
+ 0
+
+
+
+
+ 200
+ 16777215
+
+
+
+ Tools
+
+
+
+ 2
+
+
+ 2
+
+
+ 2
+
+
+ 2
+
+
-
+
+
+ Del
+
+
+
+ -
+
+
+ Upd
+
+
+
+ -
+
+
+ Nothing
+
+
+
+
+
+
+
+
+
+ -
+
+
+
+ 0
+ 0
+
+
+
+
+ 200
+ 16777215
+
+
+
+ Bodys / Operations
+
+
+
+ 5
+
+
+ 5
+
+
+ 5
+
+
+ 5
+
+
-
+
+
+ true
+
+
+
+ -
+
+
+
+ 0
+ 0
+
+
+
+
+ 200
+ 16777215
+
+
+
+ Tools
+
+
+
+ 2
+
+
+ 2
+
+
+ 2
+
+
+ 2
+
+
-
+
+
+ Hide
+
+
+
+ -
+
+
+ Upd
+
+
+
+ -
+
+
+ Del
+
+
+
+
+
+
+
+
+
+ -
@@ -831,111 +967,43 @@
- -
-
-
-
- 200
- 16777215
-
+
-
+
+
+
+ 0
+ 4
+
+
+
+
+ 12
+
- Export
+ Model Viewer
-
-
-
-
-
- STL
-
-
-
- -
-
-
- IGES
-
-
-
- -
-
-
- STEP
-
-
-
+
+
+ -1
+
+
+ 12
+
- -
-
-
-
- 0
- 0
-
-
+
-
+
- 113
- 50
+ 0
+ 120
- Assembly Tools
+ Components
-
-
-
-
-
-
- 50
- 50
-
-
-
-
- 50
- 50
-
-
-
- Add
-
-
-
- -
-
-
- true
-
-
-
- 0
- 0
-
-
-
-
- 50
- 50
-
-
-
-
- 50
- 50
-
-
-
- Qt::LeftToRight
-
-
- Rem
-
-
-
-
-
@@ -1032,102 +1100,10 @@
- -
-
+
-
+
-
- 0
- 0
-
-
-
- 0
-
-
-
- Sketch
-
-
-
-
-
- Code
-
-
-
-
-
-
- -
-
-
- Executive
-
-
-
-
-
-
- Load Code
-
-
-
- -
-
-
- Save code
-
-
-
- -
-
-
- Apply Code
-
-
-
- -
-
-
- Delete Code
-
-
-
-
-
-
-
-
-
-
- -
-
-
-
- 0
- 4
-
-
-
-
- 12
-
-
-
- Model Viewer
-
-
-
- -1
-
-
- 12
-
-
-
-
- -
-
-
-
+
0
0
@@ -1139,93 +1115,141 @@
- Component Connections
+ Drawing
-
+
- 5
+ 10
-
- 5
-
-
- 5
-
-
- 5
-
-
-
-
-
+
-
+
+
+ Arc
+
+
true
- -
-
-
-
- 0
- 0
-
+
-
+
+
+ Rctgl
-
+
+ true
+
+
+ false
+
+
+
+ -
+
+
+ Circle
+
+
+ true
+
+
+ false
+
+
+
+ -
+
+
+ Cstrct
+
+
+ true
+
+
+
+ -
+
+
+ Snap
+
+
- 200
- 16777215
+ 13
+ 16
-
- Tools
+
+ true
+
+
+ true
+
+
+
+ -
+
+
+ Line
+
+
+ S
+
+
+ true
+
+
+ false
+
+
+
+ -
+
+
+ Slot
+
+
+ true
+
+
+ false
+
+
+
+ -
+
+
+ Qt::Horizontal
+
+
+
+ -
+
+
+ Offset selected sketch face (duplicate + offset boundary)
+
+
+ Offst
-
-
- 2
-
-
- 2
-
-
- 2
-
-
- 2
-
-
-
-
-
- Del
-
-
-
- -
-
-
- Upd
-
-
-
- -
-
-
- Nothing
-
-
-
-
- -
-
-
-
- 0
- 0
-
+
-
+
+
+
+ 0
+ 120
+
+
+ Assembly
+
+
+
+ -
+
200
@@ -1233,85 +1257,61 @@
- Bodys / Operations
+ Modify
-
-
- 5
-
-
- 5
-
-
- 5
-
-
- 5
-
-
-
-
-
- true
+
+
-
+
+
+ Rev
- -
-
-
-
- 0
- 0
-
+
-
+
+
+ Extrd
-
-
- 200
- 16777215
-
+
+
+ -
+
+
+ Arry
-
- Tools
+
+
+ -
+
+
+ Cut
+
+
+
+ -
+
+
+ Comb
+
+
+
+ -
+
+
+ Mve
-
-
- 2
-
-
- 2
-
-
- 2
-
-
- 2
-
-
-
-
-
- Del
-
-
-
- -
-
-
- Upd
-
-
-
- -
-
-
- Nothing
-
-
-
-
+ -
+
+
+ Qt::Horizontal
+
+
+
diff --git a/gui_ui.py b/gui_ui.py
index aec3c76..d0c550b 100644
--- a/gui_ui.py
+++ b/gui_ui.py
@@ -19,14 +19,19 @@ from PySide6.QtGui import (QAction, QBrush, QColor, QConicalGradient,
from PySide6.QtWidgets import (QApplication, QFrame, QGridLayout, QGroupBox,
QHBoxLayout, QLabel, QListWidget, QListWidgetItem,
QMainWindow, QMenu, QMenuBar, QPushButton,
- QSizePolicy, QSpacerItem, QSpinBox, QStatusBar,
- QTabWidget, QTextEdit, QVBoxLayout, QWidget)
+ 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(2668, 1287)
+ fluencyCAD.resize(2551, 1248)
+ sizePolicy = QSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Preferred)
+ sizePolicy.setHorizontalStretch(0)
+ sizePolicy.setVerticalStretch(0)
+ sizePolicy.setHeightForWidth(fluencyCAD.sizePolicy().hasHeightForWidth())
+ fluencyCAD.setSizePolicy(sizePolicy)
self.actionNew_Project = QAction(fluencyCAD)
self.actionNew_Project.setObjectName(u"actionNew_Project")
self.actionOpen_Project = QAction(fluencyCAD)
@@ -49,286 +54,6 @@ class Ui_fluencyCAD(object):
self.centralwidget.setObjectName(u"centralwidget")
self.gridLayout = QGridLayout(self.centralwidget)
self.gridLayout.setObjectName(u"gridLayout")
- self.assembly_box = QGroupBox(self.centralwidget)
- self.assembly_box.setObjectName(u"assembly_box")
- self.assembly_box.setMinimumSize(QSize(0, 120))
-
- self.gridLayout.addWidget(self.assembly_box, 11, 1, 1, 3)
-
- self.groupBox_2 = QGroupBox(self.centralwidget)
- self.groupBox_2.setObjectName(u"groupBox_2")
- sizePolicy = QSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Preferred)
- sizePolicy.setHorizontalStretch(0)
- sizePolicy.setVerticalStretch(0)
- sizePolicy.setHeightForWidth(self.groupBox_2.sizePolicy().hasHeightForWidth())
- self.groupBox_2.setSizePolicy(sizePolicy)
- 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.pb_arc_tool = QPushButton(self.groupBox_2)
- self.pb_arc_tool.setObjectName(u"pb_arc_tool")
- self.pb_arc_tool.setCheckable(True)
-
- self.gridLayout_2.addWidget(self.pb_arc_tool, 2, 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, 0, 1, 1, 1)
-
- 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, 1, 0, 1, 1)
-
- 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, 4, 0, 1, 1)
-
- self.pb_enable_snap = QPushButton(self.groupBox_2)
- self.pb_enable_snap.setObjectName(u"pb_enable_snap")
- self.pb_enable_snap.setIconSize(QSize(13, 16))
- self.pb_enable_snap.setCheckable(True)
- self.pb_enable_snap.setChecked(True)
-
- self.gridLayout_2.addWidget(self.pb_enable_snap, 4, 1, 1, 1)
-
- 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, 0, 0, 1, 1)
-
- 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.gridLayout_2.addWidget(self.pb_slotool, 1, 1, 1, 1)
-
- self.line = QFrame(self.groupBox_2)
- self.line.setObjectName(u"line")
- self.line.setFrameShape(QFrame.Shape.HLine)
- self.line.setFrameShadow(QFrame.Shadow.Sunken)
-
- self.gridLayout_2.addWidget(self.line, 3, 0, 1, 2)
-
- self.pb_offset_tool = QPushButton(self.groupBox_2)
- self.pb_offset_tool.setObjectName(u"pb_offset_tool")
-
- self.gridLayout_2.addWidget(self.pb_offset_tool, 2, 1, 1, 1)
-
-
- self.gridLayout.addWidget(self.groupBox_2, 1, 0, 1, 1)
-
- self.horizontalSpacer = QSpacerItem(40, 20, QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum)
-
- self.gridLayout.addItem(self.horizontalSpacer, 6, 0, 1, 5)
-
- 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.pb_origin_face.setCheckable(True)
-
- 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_underlay = QPushButton(self.groupBox_9)
- self.pb_underlay.setObjectName(u"pb_underlay")
- self.pb_underlay.setEnabled(False)
- self.pb_underlay.setCheckable(True)
- self.pb_underlay.setChecked(True)
-
- self.gridLayout_7.addWidget(self.pb_underlay, 3, 0, 1, 1)
-
- self.pb_clr_face = QPushButton(self.groupBox_9)
- self.pb_clr_face.setObjectName(u"pb_clr_face")
- self.pb_clr_face.setEnabled(False)
-
- self.gridLayout_7.addWidget(self.pb_clr_face, 3, 1, 1, 1)
-
- self.pb_to_sketch = QPushButton(self.groupBox_9)
- self.pb_to_sketch.setObjectName(u"pb_to_sketch")
- self.pb_to_sketch.setEnabled(False)
-
- self.gridLayout_7.addWidget(self.pb_to_sketch, 4, 0, 1, 2)
-
- self.pb_wp_new = QPushButton(self.groupBox_9)
- self.pb_wp_new.setObjectName(u"pb_wp_new")
-
- self.gridLayout_7.addWidget(self.pb_wp_new, 1, 1, 1, 1)
-
-
- self.gridLayout.addWidget(self.groupBox_9, 0, 0, 1, 1)
-
- self.groupBox_3 = QGroupBox(self.centralwidget)
- self.groupBox_3.setObjectName(u"groupBox_3")
- sizePolicy.setHeightForWidth(self.groupBox_3.sizePolicy().hasHeightForWidth())
- self.groupBox_3.setSizePolicy(sizePolicy)
- self.groupBox_3.setMaximumSize(QSize(200, 16777213))
- self.gridLayout_4 = QGridLayout(self.groupBox_3)
- self.gridLayout_4.setObjectName(u"gridLayout_4")
- 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, 1, 0, 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, 3, 1, 1, 1)
-
- 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, 4, 1, 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, 2, 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, 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, 3, 0, 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, 4, 0, 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, 2, 1, 1, 1)
-
- self.pb_con_diameter = QPushButton(self.groupBox_3)
- self.pb_con_diameter.setObjectName(u"pb_con_diameter")
-
- self.gridLayout_4.addWidget(self.pb_con_diameter, 5, 0, 1, 1)
-
-
- self.gridLayout.addWidget(self.groupBox_3, 2, 0, 1, 1)
-
- self.groupBox = QGroupBox(self.centralwidget)
- self.groupBox.setObjectName(u"groupBox")
- self.groupBox.setMaximumSize(QSize(200, 16777215))
- 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, 4, 1, 1)
-
- self.compo_box = QGroupBox(self.centralwidget)
- self.compo_box.setObjectName(u"compo_box")
- self.compo_box.setMinimumSize(QSize(0, 120))
-
- self.gridLayout.addWidget(self.compo_box, 8, 1, 2, 3)
-
- self.compo_tool_box = QGroupBox(self.centralwidget)
- self.compo_tool_box.setObjectName(u"compo_tool_box")
- sizePolicy1 = QSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Preferred)
- sizePolicy1.setHorizontalStretch(0)
- sizePolicy1.setVerticalStretch(0)
- sizePolicy1.setHeightForWidth(self.compo_tool_box.sizePolicy().hasHeightForWidth())
- self.compo_tool_box.setSizePolicy(sizePolicy1)
- self.compo_tool_box.setMinimumSize(QSize(0, 50))
- self.horizontalLayout = QHBoxLayout(self.compo_tool_box)
- self.horizontalLayout.setObjectName(u"horizontalLayout")
- self.pb_new_compo = QPushButton(self.compo_tool_box)
- self.pb_new_compo.setObjectName(u"pb_new_compo")
- self.pb_new_compo.setMinimumSize(QSize(50, 50))
- self.pb_new_compo.setMaximumSize(QSize(50, 50))
-
- self.horizontalLayout.addWidget(self.pb_new_compo)
-
- self.pb_del_compo = QPushButton(self.compo_tool_box)
- self.pb_del_compo.setObjectName(u"pb_del_compo")
- self.pb_del_compo.setEnabled(True)
- sizePolicy.setHeightForWidth(self.pb_del_compo.sizePolicy().hasHeightForWidth())
- self.pb_del_compo.setSizePolicy(sizePolicy)
- self.pb_del_compo.setMinimumSize(QSize(50, 50))
- self.pb_del_compo.setMaximumSize(QSize(50, 50))
- self.pb_del_compo.setLayoutDirection(Qt.LeftToRight)
-
- self.horizontalLayout.addWidget(self.pb_del_compo)
-
-
- self.gridLayout.addWidget(self.compo_tool_box, 8, 0, 2, 1)
-
self.groupBox_5 = QGroupBox(self.centralwidget)
self.groupBox_5.setObjectName(u"groupBox_5")
sizePolicy.setHeightForWidth(self.groupBox_5.sizePolicy().hasHeightForWidth())
@@ -412,42 +137,6 @@ class Ui_fluencyCAD(object):
self.gridLayout.addWidget(self.groupBox_5, 3, 0, 1, 1)
- self.joint_tools = QGroupBox(self.centralwidget)
- self.joint_tools.setObjectName(u"joint_tools")
- self.joint_tools.setMinimumSize(QSize(0, 50))
- self.gridLayout_10 = QGridLayout(self.joint_tools)
- self.gridLayout_10.setObjectName(u"gridLayout_10")
- self.pb_add_connector = QPushButton(self.joint_tools)
- self.pb_add_connector.setObjectName(u"pb_add_connector")
- self.pb_add_connector.setMinimumSize(QSize(50, 50))
- self.pb_add_connector.setMaximumSize(QSize(50, 50))
-
- self.gridLayout_10.addWidget(self.pb_add_connector, 0, 0, 1, 1)
-
- self.pb_remove_connector = QPushButton(self.joint_tools)
- self.pb_remove_connector.setObjectName(u"pb_remove_connector")
- self.pb_remove_connector.setMinimumSize(QSize(50, 50))
- self.pb_remove_connector.setMaximumSize(QSize(50, 50))
-
- self.gridLayout_10.addWidget(self.pb_remove_connector, 0, 1, 1, 1)
-
- self.pb_add_connector_3 = QPushButton(self.joint_tools)
- self.pb_add_connector_3.setObjectName(u"pb_add_connector_3")
- self.pb_add_connector_3.setMinimumSize(QSize(50, 50))
- self.pb_add_connector_3.setMaximumSize(QSize(50, 50))
-
- self.gridLayout_10.addWidget(self.pb_add_connector_3, 1, 1, 1, 1)
-
- self.pb_add_connector_2 = QPushButton(self.joint_tools)
- self.pb_add_connector_2.setObjectName(u"pb_add_connector_2")
- self.pb_add_connector_2.setMinimumSize(QSize(50, 50))
- self.pb_add_connector_2.setMaximumSize(QSize(50, 50))
-
- self.gridLayout_10.addWidget(self.pb_add_connector_2, 1, 0, 1, 1)
-
-
- self.gridLayout.addWidget(self.joint_tools, 8, 4, 4, 1)
-
self.groupBox_4 = QGroupBox(self.centralwidget)
self.groupBox_4.setObjectName(u"groupBox_4")
self.groupBox_4.setMaximumSize(QSize(200, 16777215))
@@ -469,93 +158,12 @@ class Ui_fluencyCAD(object):
self.gridLayout_9.addWidget(self.pb_export_step, 0, 1, 1, 1)
- self.gridLayout.addWidget(self.groupBox_4, 1, 4, 1, 1)
-
- self.assembly_tools = QGroupBox(self.centralwidget)
- self.assembly_tools.setObjectName(u"assembly_tools")
- sizePolicy1.setHeightForWidth(self.assembly_tools.sizePolicy().hasHeightForWidth())
- self.assembly_tools.setSizePolicy(sizePolicy1)
- self.assembly_tools.setMinimumSize(QSize(113, 50))
- self.horizontalLayout_2 = QHBoxLayout(self.assembly_tools)
- self.horizontalLayout_2.setObjectName(u"horizontalLayout_2")
- self.pb_compo_to_assembly = QPushButton(self.assembly_tools)
- self.pb_compo_to_assembly.setObjectName(u"pb_compo_to_assembly")
- self.pb_compo_to_assembly.setMinimumSize(QSize(50, 50))
- self.pb_compo_to_assembly.setMaximumSize(QSize(50, 50))
-
- self.horizontalLayout_2.addWidget(self.pb_compo_to_assembly)
-
- self.pb_remove_compo_from_assembly = QPushButton(self.assembly_tools)
- self.pb_remove_compo_from_assembly.setObjectName(u"pb_remove_compo_from_assembly")
- self.pb_remove_compo_from_assembly.setEnabled(True)
- sizePolicy.setHeightForWidth(self.pb_remove_compo_from_assembly.sizePolicy().hasHeightForWidth())
- self.pb_remove_compo_from_assembly.setSizePolicy(sizePolicy)
- self.pb_remove_compo_from_assembly.setMinimumSize(QSize(50, 50))
- self.pb_remove_compo_from_assembly.setMaximumSize(QSize(50, 50))
- self.pb_remove_compo_from_assembly.setLayoutDirection(Qt.LeftToRight)
-
- self.horizontalLayout_2.addWidget(self.pb_remove_compo_from_assembly)
-
-
- self.gridLayout.addWidget(self.assembly_tools, 11, 0, 1, 1)
-
- self.groupBox_11 = QGroupBox(self.centralwidget)
- self.groupBox_11.setObjectName(u"groupBox_11")
- sizePolicy2 = QSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Expanding)
- sizePolicy2.setHorizontalStretch(0)
- sizePolicy2.setVerticalStretch(0)
- 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")
- sizePolicy3 = QSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
- sizePolicy3.setHorizontalStretch(0)
- sizePolicy3.setVerticalStretch(0)
- sizePolicy3.setHeightForWidth(self.sketch_list.sizePolicy().hasHeightForWidth())
- self.sketch_list.setSizePolicy(sizePolicy3)
- 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")
- sizePolicy.setHeightForWidth(self.groupBox_6.sizePolicy().hasHeightForWidth())
- self.groupBox_6.setSizePolicy(sizePolicy)
- 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, 4, 0, 1, 1)
+ self.gridLayout.addWidget(self.groupBox_4, 1, 3, 1, 1)
self.InputTab = QTabWidget(self.centralwidget)
self.InputTab.setObjectName(u"InputTab")
- sizePolicy4 = QSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
- sizePolicy4.setHorizontalStretch(0)
- sizePolicy4.setVerticalStretch(0)
- sizePolicy4.setHeightForWidth(self.InputTab.sizePolicy().hasHeightForWidth())
- self.InputTab.setSizePolicy(sizePolicy4)
+ 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)
@@ -601,27 +209,191 @@ class Ui_fluencyCAD(object):
self.gridLayout.addWidget(self.InputTab, 0, 1, 5, 1)
- self.gl_box = QGroupBox(self.centralwidget)
- self.gl_box.setObjectName(u"gl_box")
- sizePolicy5 = QSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
- sizePolicy5.setHorizontalStretch(0)
- sizePolicy5.setVerticalStretch(4)
- sizePolicy5.setHeightForWidth(self.gl_box.sizePolicy().hasHeightForWidth())
- self.gl_box.setSizePolicy(sizePolicy5)
- 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.compo_tool_box = QGroupBox(self.centralwidget)
+ self.compo_tool_box.setObjectName(u"compo_tool_box")
+ sizePolicy1 = QSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Preferred)
+ sizePolicy1.setHorizontalStretch(0)
+ sizePolicy1.setVerticalStretch(0)
+ sizePolicy1.setHeightForWidth(self.compo_tool_box.sizePolicy().hasHeightForWidth())
+ self.compo_tool_box.setSizePolicy(sizePolicy1)
+ self.compo_tool_box.setMinimumSize(QSize(0, 50))
+ self.horizontalLayout = QHBoxLayout(self.compo_tool_box)
+ self.horizontalLayout.setObjectName(u"horizontalLayout")
+ self.pb_new_compo = QPushButton(self.compo_tool_box)
+ self.pb_new_compo.setObjectName(u"pb_new_compo")
+ self.pb_new_compo.setMinimumSize(QSize(50, 50))
+ self.pb_new_compo.setMaximumSize(QSize(50, 50))
- self.gridLayout.addWidget(self.gl_box, 0, 3, 5, 1)
+ self.horizontalLayout.addWidget(self.pb_new_compo)
+
+ self.pb_del_compo = QPushButton(self.compo_tool_box)
+ self.pb_del_compo.setObjectName(u"pb_del_compo")
+ self.pb_del_compo.setEnabled(True)
+ sizePolicy.setHeightForWidth(self.pb_del_compo.sizePolicy().hasHeightForWidth())
+ self.pb_del_compo.setSizePolicy(sizePolicy)
+ self.pb_del_compo.setMinimumSize(QSize(50, 50))
+ self.pb_del_compo.setMaximumSize(QSize(50, 50))
+ self.pb_del_compo.setLayoutDirection(Qt.LeftToRight)
+
+ self.horizontalLayout.addWidget(self.pb_del_compo)
+
+
+ self.gridLayout.addWidget(self.compo_tool_box, 7, 0, 1, 1)
+
+ self.groupBox_3 = QGroupBox(self.centralwidget)
+ self.groupBox_3.setObjectName(u"groupBox_3")
+ sizePolicy.setHeightForWidth(self.groupBox_3.sizePolicy().hasHeightForWidth())
+ self.groupBox_3.setSizePolicy(sizePolicy)
+ self.groupBox_3.setMaximumSize(QSize(200, 16777213))
+ self.gridLayout_4 = QGridLayout(self.groupBox_3)
+ self.gridLayout_4.setObjectName(u"gridLayout_4")
+ 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, 1, 0, 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, 3, 1, 1, 1)
+
+ 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, 4, 1, 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, 2, 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, 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, 3, 0, 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, 4, 0, 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, 2, 1, 1, 1)
+
+ self.pb_con_diameter = QPushButton(self.groupBox_3)
+ self.pb_con_diameter.setObjectName(u"pb_con_diameter")
+
+ self.gridLayout_4.addWidget(self.pb_con_diameter, 5, 0, 1, 1)
+
+
+ self.gridLayout.addWidget(self.groupBox_3, 2, 0, 1, 1)
+
+ self.assembly_tools = QGroupBox(self.centralwidget)
+ self.assembly_tools.setObjectName(u"assembly_tools")
+ sizePolicy1.setHeightForWidth(self.assembly_tools.sizePolicy().hasHeightForWidth())
+ self.assembly_tools.setSizePolicy(sizePolicy1)
+ self.assembly_tools.setMinimumSize(QSize(113, 50))
+ self.horizontalLayout_2 = QHBoxLayout(self.assembly_tools)
+ self.horizontalLayout_2.setObjectName(u"horizontalLayout_2")
+ self.pb_compo_to_assembly = QPushButton(self.assembly_tools)
+ self.pb_compo_to_assembly.setObjectName(u"pb_compo_to_assembly")
+ self.pb_compo_to_assembly.setMinimumSize(QSize(50, 50))
+ self.pb_compo_to_assembly.setMaximumSize(QSize(50, 50))
+
+ self.horizontalLayout_2.addWidget(self.pb_compo_to_assembly)
+
+ self.pb_remove_compo_from_assembly = QPushButton(self.assembly_tools)
+ self.pb_remove_compo_from_assembly.setObjectName(u"pb_remove_compo_from_assembly")
+ self.pb_remove_compo_from_assembly.setEnabled(True)
+ sizePolicy.setHeightForWidth(self.pb_remove_compo_from_assembly.sizePolicy().hasHeightForWidth())
+ self.pb_remove_compo_from_assembly.setSizePolicy(sizePolicy)
+ self.pb_remove_compo_from_assembly.setMinimumSize(QSize(50, 50))
+ self.pb_remove_compo_from_assembly.setMaximumSize(QSize(50, 50))
+ self.pb_remove_compo_from_assembly.setLayoutDirection(Qt.LeftToRight)
+
+ self.horizontalLayout_2.addWidget(self.pb_remove_compo_from_assembly)
+
+
+ self.gridLayout.addWidget(self.assembly_tools, 8, 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.pb_origin_face.setCheckable(True)
+
+ 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_underlay = QPushButton(self.groupBox_9)
+ self.pb_underlay.setObjectName(u"pb_underlay")
+ self.pb_underlay.setEnabled(False)
+ self.pb_underlay.setCheckable(True)
+ self.pb_underlay.setChecked(True)
+
+ self.gridLayout_7.addWidget(self.pb_underlay, 3, 0, 1, 1)
+
+ self.pb_clr_face = QPushButton(self.groupBox_9)
+ self.pb_clr_face.setObjectName(u"pb_clr_face")
+ self.pb_clr_face.setEnabled(False)
+
+ self.gridLayout_7.addWidget(self.pb_clr_face, 3, 1, 1, 1)
+
+ self.pb_to_sketch = QPushButton(self.groupBox_9)
+ self.pb_to_sketch.setObjectName(u"pb_to_sketch")
+ self.pb_to_sketch.setEnabled(False)
+
+ self.gridLayout_7.addWidget(self.pb_to_sketch, 4, 0, 1, 2)
+
+ self.pb_wp_new = QPushButton(self.groupBox_9)
+ self.pb_wp_new.setObjectName(u"pb_wp_new")
+
+ self.gridLayout_7.addWidget(self.pb_wp_new, 1, 1, 1, 1)
+
+
+ self.gridLayout.addWidget(self.groupBox_9, 0, 0, 1, 1)
self.groupBox_12 = QGroupBox(self.centralwidget)
self.groupBox_12.setObjectName(u"groupBox_12")
+ sizePolicy2 = QSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Expanding)
+ sizePolicy2.setHorizontalStretch(0)
+ sizePolicy2.setVerticalStretch(0)
sizePolicy2.setHeightForWidth(self.groupBox_12.sizePolicy().hasHeightForWidth())
self.groupBox_12.setSizePolicy(sizePolicy2)
self.groupBox_12.setMaximumSize(QSize(200, 16777215))
@@ -661,7 +433,7 @@ class Ui_fluencyCAD(object):
self.verticalLayout_8.addWidget(self.groupBox_13)
- self.gridLayout.addWidget(self.groupBox_12, 4, 4, 1, 1)
+ self.gridLayout.addWidget(self.groupBox_12, 4, 3, 1, 1)
self.groupBox_10 = QGroupBox(self.centralwidget)
self.groupBox_10.setObjectName(u"groupBox_10")
@@ -685,31 +457,261 @@ class Ui_fluencyCAD(object):
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.pb_body_hide = QPushButton(self.groupBox_8)
+ self.pb_body_hide.setObjectName(u"pb_body_hide")
- self.gridLayout_8.addWidget(self.pb_del_body, 0, 2, 1, 1)
+ self.gridLayout_8.addWidget(self.pb_body_hide, 0, 1, 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.pb_del_body = QPushButton(self.groupBox_8)
+ self.pb_del_body.setObjectName(u"pb_del_body")
- self.gridLayout_8.addWidget(self.pb_edt_sktch_3, 0, 1, 1, 1)
+ self.gridLayout_8.addWidget(self.pb_del_body, 0, 2, 1, 1)
self.verticalLayout_6.addWidget(self.groupBox_8)
- self.gridLayout.addWidget(self.groupBox_10, 3, 4, 1, 1)
+ self.gridLayout.addWidget(self.groupBox_10, 2, 3, 1, 1)
+
+ self.joint_tools = QGroupBox(self.centralwidget)
+ self.joint_tools.setObjectName(u"joint_tools")
+ self.joint_tools.setMinimumSize(QSize(0, 50))
+ self.gridLayout_10 = QGridLayout(self.joint_tools)
+ self.gridLayout_10.setObjectName(u"gridLayout_10")
+ self.pb_add_connector = QPushButton(self.joint_tools)
+ self.pb_add_connector.setObjectName(u"pb_add_connector")
+ self.pb_add_connector.setMinimumSize(QSize(50, 50))
+ self.pb_add_connector.setMaximumSize(QSize(50, 50))
+
+ self.gridLayout_10.addWidget(self.pb_add_connector, 0, 0, 1, 1)
+
+ self.pb_remove_connector = QPushButton(self.joint_tools)
+ self.pb_remove_connector.setObjectName(u"pb_remove_connector")
+ self.pb_remove_connector.setMinimumSize(QSize(50, 50))
+ self.pb_remove_connector.setMaximumSize(QSize(50, 50))
+
+ self.gridLayout_10.addWidget(self.pb_remove_connector, 0, 1, 1, 1)
+
+ self.pb_add_connector_3 = QPushButton(self.joint_tools)
+ self.pb_add_connector_3.setObjectName(u"pb_add_connector_3")
+ self.pb_add_connector_3.setMinimumSize(QSize(50, 50))
+ self.pb_add_connector_3.setMaximumSize(QSize(50, 50))
+
+ self.gridLayout_10.addWidget(self.pb_add_connector_3, 1, 1, 1, 1)
+
+ self.pb_add_connector_2 = QPushButton(self.joint_tools)
+ self.pb_add_connector_2.setObjectName(u"pb_add_connector_2")
+ self.pb_add_connector_2.setMinimumSize(QSize(50, 50))
+ self.pb_add_connector_2.setMaximumSize(QSize(50, 50))
+
+ self.gridLayout_10.addWidget(self.pb_add_connector_2, 1, 0, 1, 1)
+
+
+ self.gridLayout.addWidget(self.joint_tools, 7, 3, 2, 1)
+
+ self.gl_box = QGroupBox(self.centralwidget)
+ self.gl_box.setObjectName(u"gl_box")
+ sizePolicy3 = QSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Preferred)
+ sizePolicy3.setHorizontalStretch(0)
+ sizePolicy3.setVerticalStretch(4)
+ sizePolicy3.setHeightForWidth(self.gl_box.sizePolicy().hasHeightForWidth())
+ self.gl_box.setSizePolicy(sizePolicy3)
+ 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, 5, 1)
+
+ self.compo_box = QGroupBox(self.centralwidget)
+ self.compo_box.setObjectName(u"compo_box")
+ self.compo_box.setMinimumSize(QSize(0, 120))
+
+ self.gridLayout.addWidget(self.compo_box, 7, 1, 1, 2)
+
+ 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.Policy.Expanding, QSizePolicy.Policy.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")
+ sizePolicy.setHeightForWidth(self.groupBox_6.sizePolicy().hasHeightForWidth())
+ self.groupBox_6.setSizePolicy(sizePolicy)
+ 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, 4, 0, 1, 1)
+
+ self.groupBox_2 = QGroupBox(self.centralwidget)
+ self.groupBox_2.setObjectName(u"groupBox_2")
+ sizePolicy.setHeightForWidth(self.groupBox_2.sizePolicy().hasHeightForWidth())
+ self.groupBox_2.setSizePolicy(sizePolicy)
+ 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.pb_arc_tool = QPushButton(self.groupBox_2)
+ self.pb_arc_tool.setObjectName(u"pb_arc_tool")
+ self.pb_arc_tool.setCheckable(True)
+
+ self.gridLayout_2.addWidget(self.pb_arc_tool, 2, 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, 0, 1, 1, 1)
+
+ 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, 1, 0, 1, 1)
+
+ 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, 4, 0, 1, 1)
+
+ self.pb_enable_snap = QPushButton(self.groupBox_2)
+ self.pb_enable_snap.setObjectName(u"pb_enable_snap")
+ self.pb_enable_snap.setIconSize(QSize(13, 16))
+ self.pb_enable_snap.setCheckable(True)
+ self.pb_enable_snap.setChecked(True)
+
+ self.gridLayout_2.addWidget(self.pb_enable_snap, 4, 1, 1, 1)
+
+ 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, 0, 0, 1, 1)
+
+ 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.gridLayout_2.addWidget(self.pb_slotool, 1, 1, 1, 1)
+
+ self.line = QFrame(self.groupBox_2)
+ self.line.setObjectName(u"line")
+ self.line.setFrameShape(QFrame.Shape.HLine)
+ self.line.setFrameShadow(QFrame.Shadow.Sunken)
+
+ self.gridLayout_2.addWidget(self.line, 3, 0, 1, 2)
+
+ self.pb_offset_tool = QPushButton(self.groupBox_2)
+ self.pb_offset_tool.setObjectName(u"pb_offset_tool")
+
+ self.gridLayout_2.addWidget(self.pb_offset_tool, 2, 1, 1, 1)
+
+
+ self.gridLayout.addWidget(self.groupBox_2, 1, 0, 1, 1)
+
+ self.assembly_box = QGroupBox(self.centralwidget)
+ self.assembly_box.setObjectName(u"assembly_box")
+ self.assembly_box.setMinimumSize(QSize(0, 120))
+
+ self.gridLayout.addWidget(self.assembly_box, 8, 1, 1, 2)
+
+ self.groupBox = QGroupBox(self.centralwidget)
+ self.groupBox.setObjectName(u"groupBox")
+ self.groupBox.setMaximumSize(QSize(200, 16777215))
+ 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)
+
+ self.line_3 = QFrame(self.centralwidget)
+ self.line_3.setObjectName(u"line_3")
+ self.line_3.setFrameShape(QFrame.Shape.HLine)
+ self.line_3.setFrameShadow(QFrame.Shadow.Sunken)
+
+ self.gridLayout.addWidget(self.line_3, 5, 0, 1, 4)
fluencyCAD.setCentralWidget(self.centralwidget)
self.menubar = QMenuBar(fluencyCAD)
self.menubar.setObjectName(u"menubar")
- self.menubar.setGeometry(QRect(0, 0, 2668, 24))
+ self.menubar.setGeometry(QRect(0, 0, 2551, 24))
self.menuFile = QMenu(self.menubar)
self.menuFile.setObjectName(u"menuFile")
self.menuSettings = QMenu(self.menubar)
@@ -768,22 +770,65 @@ class Ui_fluencyCAD(object):
#if QT_CONFIG(shortcut)
self.actionExit.setShortcut(QCoreApplication.translate("fluencyCAD", u"Ctrl+Q", None))
#endif // QT_CONFIG(shortcut)
- self.assembly_box.setTitle(QCoreApplication.translate("fluencyCAD", u"Assembly", None))
- self.groupBox_2.setTitle(QCoreApplication.translate("fluencyCAD", u"Drawing", None))
- self.pb_arc_tool.setText(QCoreApplication.translate("fluencyCAD", u"Arc", None))
- self.pb_rectool.setText(QCoreApplication.translate("fluencyCAD", u"Rctgl", None))
- self.pb_circtool.setText(QCoreApplication.translate("fluencyCAD", u"Circle", None))
- self.pb_enable_construct.setText(QCoreApplication.translate("fluencyCAD", u"Cstrct", None))
- self.pb_enable_snap.setText(QCoreApplication.translate("fluencyCAD", u"Snap", None))
- self.pb_linetool.setText(QCoreApplication.translate("fluencyCAD", u"Line", None))
-#if QT_CONFIG(shortcut)
- self.pb_linetool.setShortcut(QCoreApplication.translate("fluencyCAD", u"S", None))
-#endif // QT_CONFIG(shortcut)
- self.pb_slotool.setText(QCoreApplication.translate("fluencyCAD", u"Slot", None))
+ self.groupBox_5.setTitle(QCoreApplication.translate("fluencyCAD", u"Snapping Points", None))
+ self.label.setText(QCoreApplication.translate("fluencyCAD", u"Snp Dst", None))
+ self.pb_snap_vert.setText(QCoreApplication.translate("fluencyCAD", u"Vert", None))
+ self.label_2.setText(QCoreApplication.translate("fluencyCAD", u"Angl Stps", None))
+ self.spinbox_snap_distance.setSuffix(QCoreApplication.translate("fluencyCAD", u"mm", None))
+ self.pushButton_7.setText(QCoreApplication.translate("fluencyCAD", u"Grid", None))
+ self.pb_snap_horiz.setText(QCoreApplication.translate("fluencyCAD", u"Horiz", None))
+ self.spinbox_angle_steps.setSuffix(QCoreApplication.translate("fluencyCAD", u"\u00b0", None))
+ self.pushButton_8.setText(QCoreApplication.translate("fluencyCAD", u"Pnt", None))
+ self.pb_snap_midp.setText(QCoreApplication.translate("fluencyCAD", u"MidP", None))
+ self.pb_snap_angle.setText(QCoreApplication.translate("fluencyCAD", u"Angles", None))
+ self.groupBox_4.setTitle(QCoreApplication.translate("fluencyCAD", u"Export", None))
+ self.pushButton_2.setText(QCoreApplication.translate("fluencyCAD", u"STL", None))
+ self.pb_export_iges.setText(QCoreApplication.translate("fluencyCAD", u"IGES", None))
+ self.pb_export_step.setText(QCoreApplication.translate("fluencyCAD", u"STEP", 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.compo_tool_box.setTitle(QCoreApplication.translate("fluencyCAD", u"Component Tools", None))
+ self.pb_new_compo.setText(QCoreApplication.translate("fluencyCAD", u"New", None))
+ self.pb_del_compo.setText(QCoreApplication.translate("fluencyCAD", u"Del", None))
+ self.groupBox_3.setTitle(QCoreApplication.translate("fluencyCAD", u"Constrain", None))
#if QT_CONFIG(tooltip)
- self.pb_offset_tool.setToolTip(QCoreApplication.translate("fluencyCAD", u"Offset selected sketch face (duplicate + offset boundary)", None))
+ self.pb_con_ptpt.setToolTip(QCoreApplication.translate("fluencyCAD", u"Poin to Point Constrain", None))
#endif // QT_CONFIG(tooltip)
- self.pb_offset_tool.setText(QCoreApplication.translate("fluencyCAD", u"Offst", None))
+ self.pb_con_ptpt.setText(QCoreApplication.translate("fluencyCAD", u"Pt_Pt", None))
+#if QT_CONFIG(tooltip)
+ self.pb_con_vert.setToolTip(QCoreApplication.translate("fluencyCAD", u"Vertical Constrain", None))
+#endif // QT_CONFIG(tooltip)
+ self.pb_con_vert.setText(QCoreApplication.translate("fluencyCAD", u"Vert", None))
+ self.pb_con_sym.setText(QCoreApplication.translate("fluencyCAD", u"Symetrc", None))
+#if QT_CONFIG(tooltip)
+ self.pb_con_mid.setToolTip(QCoreApplication.translate("fluencyCAD", u"Point to Middle Point Constrain", None))
+#endif // QT_CONFIG(tooltip)
+ self.pb_con_mid.setText(QCoreApplication.translate("fluencyCAD", u"Pt_Mid_L", None))
+#if QT_CONFIG(tooltip)
+ self.pb_con_line.setToolTip(QCoreApplication.translate("fluencyCAD", u"Point to Line Constrain", None))
+#endif // QT_CONFIG(tooltip)
+ self.pb_con_line.setText(QCoreApplication.translate("fluencyCAD", u"Pt_Lne", None))
+#if QT_CONFIG(tooltip)
+ self.pb_con_horiz.setToolTip(QCoreApplication.translate("fluencyCAD", u"Horizontal Constrain ", None))
+#endif // QT_CONFIG(tooltip)
+ self.pb_con_horiz.setText(QCoreApplication.translate("fluencyCAD", u"Horiz", None))
+#if QT_CONFIG(tooltip)
+ self.pb_con_dist.setToolTip(QCoreApplication.translate("fluencyCAD", u"Dimension of Line of Distance from Point to Line", None))
+#endif // QT_CONFIG(tooltip)
+ self.pb_con_dist.setText(QCoreApplication.translate("fluencyCAD", u"Distnce", None))
+#if QT_CONFIG(tooltip)
+ self.pb_con_perp.setToolTip(QCoreApplication.translate("fluencyCAD", u"Constrain Line perpendicular to another line.", None))
+#endif // QT_CONFIG(tooltip)
+ self.pb_con_perp.setText(QCoreApplication.translate("fluencyCAD", u"Perp_Lne", None))
+ self.pb_con_diameter.setText(QCoreApplication.translate("fluencyCAD", u"Diameter", None))
+ self.assembly_tools.setTitle(QCoreApplication.translate("fluencyCAD", u"Assembly Tools", None))
+ self.pb_compo_to_assembly.setText(QCoreApplication.translate("fluencyCAD", u"Add", None))
+ self.pb_remove_compo_from_assembly.setText(QCoreApplication.translate("fluencyCAD", u"Rem", 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))
@@ -825,84 +870,6 @@ class Ui_fluencyCAD(object):
#if QT_CONFIG(shortcut)
self.pb_wp_new.setShortcut(QCoreApplication.translate("fluencyCAD", u"Shift+W", None))
#endif // QT_CONFIG(shortcut)
- self.groupBox_3.setTitle(QCoreApplication.translate("fluencyCAD", u"Constrain", None))
-#if QT_CONFIG(tooltip)
- self.pb_con_ptpt.setToolTip(QCoreApplication.translate("fluencyCAD", u"Poin to Point Constrain", None))
-#endif // QT_CONFIG(tooltip)
- self.pb_con_ptpt.setText(QCoreApplication.translate("fluencyCAD", u"Pt_Pt", None))
-#if QT_CONFIG(tooltip)
- self.pb_con_vert.setToolTip(QCoreApplication.translate("fluencyCAD", u"Vertical Constrain", None))
-#endif // QT_CONFIG(tooltip)
- self.pb_con_vert.setText(QCoreApplication.translate("fluencyCAD", u"Vert", None))
- self.pb_con_sym.setText(QCoreApplication.translate("fluencyCAD", u"Symetrc", None))
-#if QT_CONFIG(tooltip)
- self.pb_con_mid.setToolTip(QCoreApplication.translate("fluencyCAD", u"Point to Middle Point Constrain", None))
-#endif // QT_CONFIG(tooltip)
- self.pb_con_mid.setText(QCoreApplication.translate("fluencyCAD", u"Pt_Mid_L", None))
-#if QT_CONFIG(tooltip)
- self.pb_con_line.setToolTip(QCoreApplication.translate("fluencyCAD", u"Point to Line Constrain", None))
-#endif // QT_CONFIG(tooltip)
- self.pb_con_line.setText(QCoreApplication.translate("fluencyCAD", u"Pt_Lne", None))
-#if QT_CONFIG(tooltip)
- self.pb_con_horiz.setToolTip(QCoreApplication.translate("fluencyCAD", u"Horizontal Constrain ", None))
-#endif // QT_CONFIG(tooltip)
- self.pb_con_horiz.setText(QCoreApplication.translate("fluencyCAD", u"Horiz", None))
-#if QT_CONFIG(tooltip)
- self.pb_con_dist.setToolTip(QCoreApplication.translate("fluencyCAD", u"Dimension of Line of Distance from Point to Line", None))
-#endif // QT_CONFIG(tooltip)
- self.pb_con_dist.setText(QCoreApplication.translate("fluencyCAD", u"Distnce", None))
-#if QT_CONFIG(tooltip)
- self.pb_con_perp.setToolTip(QCoreApplication.translate("fluencyCAD", u"Constrain Line perpendicular to another line.", None))
-#endif // QT_CONFIG(tooltip)
- self.pb_con_perp.setText(QCoreApplication.translate("fluencyCAD", u"Perp_Lne", None))
- self.pb_con_diameter.setText(QCoreApplication.translate("fluencyCAD", u"Diameter", 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.compo_tool_box.setTitle(QCoreApplication.translate("fluencyCAD", u"Component Tools", None))
- self.pb_new_compo.setText(QCoreApplication.translate("fluencyCAD", u"New", None))
- self.pb_del_compo.setText(QCoreApplication.translate("fluencyCAD", u"Del", None))
- self.groupBox_5.setTitle(QCoreApplication.translate("fluencyCAD", u"Snapping Points", None))
- self.label.setText(QCoreApplication.translate("fluencyCAD", u"Snp Dst", None))
- self.pb_snap_vert.setText(QCoreApplication.translate("fluencyCAD", u"Vert", None))
- self.label_2.setText(QCoreApplication.translate("fluencyCAD", u"Angl Stps", None))
- self.spinbox_snap_distance.setSuffix(QCoreApplication.translate("fluencyCAD", u"mm", None))
- self.pushButton_7.setText(QCoreApplication.translate("fluencyCAD", u"Grid", None))
- self.pb_snap_horiz.setText(QCoreApplication.translate("fluencyCAD", u"Horiz", None))
- self.spinbox_angle_steps.setSuffix(QCoreApplication.translate("fluencyCAD", u"\u00b0", None))
- self.pushButton_8.setText(QCoreApplication.translate("fluencyCAD", u"Pnt", None))
- self.pb_snap_midp.setText(QCoreApplication.translate("fluencyCAD", u"MidP", None))
- self.pb_snap_angle.setText(QCoreApplication.translate("fluencyCAD", u"Angles", None))
- self.joint_tools.setTitle(QCoreApplication.translate("fluencyCAD", u"Joint Tools", None))
- self.pb_add_connector.setText(QCoreApplication.translate("fluencyCAD", u"+ Cnct", None))
- self.pb_remove_connector.setText(QCoreApplication.translate("fluencyCAD", u"- Cnct", None))
- self.pb_add_connector_3.setText(QCoreApplication.translate("fluencyCAD", u"-Jnt", None))
- self.pb_add_connector_2.setText(QCoreApplication.translate("fluencyCAD", u"+Jnt", None))
- self.groupBox_4.setTitle(QCoreApplication.translate("fluencyCAD", u"Export", None))
- self.pushButton_2.setText(QCoreApplication.translate("fluencyCAD", u"STL", None))
- self.pb_export_iges.setText(QCoreApplication.translate("fluencyCAD", u"IGES", None))
- self.pb_export_step.setText(QCoreApplication.translate("fluencyCAD", u"STEP", None))
- self.assembly_tools.setTitle(QCoreApplication.translate("fluencyCAD", u"Assembly Tools", None))
- self.pb_compo_to_assembly.setText(QCoreApplication.translate("fluencyCAD", u"Add", None))
- self.pb_remove_compo_from_assembly.setText(QCoreApplication.translate("fluencyCAD", u"Rem", 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.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_12.setTitle(QCoreApplication.translate("fluencyCAD", u"Component Connections", None))
self.groupBox_13.setTitle(QCoreApplication.translate("fluencyCAD", u"Tools", None))
self.pb_del_connection.setText(QCoreApplication.translate("fluencyCAD", u"Del", None))
@@ -910,9 +877,44 @@ class Ui_fluencyCAD(object):
self.pb_edt_sktch_4.setText(QCoreApplication.translate("fluencyCAD", u"Nothing", 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_body_hide.setText(QCoreApplication.translate("fluencyCAD", u"Hide", 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.pb_del_body.setText(QCoreApplication.translate("fluencyCAD", u"Del", None))
+ self.joint_tools.setTitle(QCoreApplication.translate("fluencyCAD", u"Joint Tools", None))
+ self.pb_add_connector.setText(QCoreApplication.translate("fluencyCAD", u"+ Cnct", None))
+ self.pb_remove_connector.setText(QCoreApplication.translate("fluencyCAD", u"- Cnct", None))
+ self.pb_add_connector_3.setText(QCoreApplication.translate("fluencyCAD", u"-Jnt", None))
+ self.pb_add_connector_2.setText(QCoreApplication.translate("fluencyCAD", u"+Jnt", None))
+ self.gl_box.setTitle(QCoreApplication.translate("fluencyCAD", u"Model Viewer", None))
+ self.compo_box.setTitle(QCoreApplication.translate("fluencyCAD", u"Components", 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.groupBox_2.setTitle(QCoreApplication.translate("fluencyCAD", u"Drawing", None))
+ self.pb_arc_tool.setText(QCoreApplication.translate("fluencyCAD", u"Arc", None))
+ self.pb_rectool.setText(QCoreApplication.translate("fluencyCAD", u"Rctgl", None))
+ self.pb_circtool.setText(QCoreApplication.translate("fluencyCAD", u"Circle", None))
+ self.pb_enable_construct.setText(QCoreApplication.translate("fluencyCAD", u"Cstrct", None))
+ self.pb_enable_snap.setText(QCoreApplication.translate("fluencyCAD", u"Snap", None))
+ self.pb_linetool.setText(QCoreApplication.translate("fluencyCAD", u"Line", None))
+#if QT_CONFIG(shortcut)
+ self.pb_linetool.setShortcut(QCoreApplication.translate("fluencyCAD", u"S", None))
+#endif // QT_CONFIG(shortcut)
+ self.pb_slotool.setText(QCoreApplication.translate("fluencyCAD", u"Slot", None))
+#if QT_CONFIG(tooltip)
+ self.pb_offset_tool.setToolTip(QCoreApplication.translate("fluencyCAD", u"Offset selected sketch face (duplicate + offset boundary)", None))
+#endif // QT_CONFIG(tooltip)
+ self.pb_offset_tool.setText(QCoreApplication.translate("fluencyCAD", u"Offst", None))
+ self.assembly_box.setTitle(QCoreApplication.translate("fluencyCAD", u"Assembly", 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.menuFile.setTitle(QCoreApplication.translate("fluencyCAD", u"File", None))
self.menuSettings.setTitle(QCoreApplication.translate("fluencyCAD", u"Settings", None))
# retranslateUi
diff --git a/src/fluency/geometry_occ/kernel.py b/src/fluency/geometry_occ/kernel.py
index 74e42fa..eb8a082 100644
--- a/src/fluency/geometry_occ/kernel.py
+++ b/src/fluency/geometry_occ/kernel.py
@@ -235,6 +235,93 @@ class OCGeometryKernel(GeometryKernel):
pass
return None
+ @staticmethod
+ def find_coplanar_face(
+ shape: Any,
+ origin: Tuple[float, float, float],
+ normal: Tuple[float, float, float],
+ ref_center: Optional[Tuple[float, float, float]] = None,
+ angle_tol_deg: float = 5.0,
+ dist_tol: float = 1e-3,
+ ) -> Optional[Tuple[Any, Tuple[float, float, float]]]:
+ """Find a planar face on *shape* coplanar with the given plane.
+
+ Iterates the faces of *shape* and returns the first planar face whose
+ plane normal is parallel to *normal* (within *angle_tol_deg* degrees)
+ and whose plane passes through *origin* (within *dist_tol* distance).
+ When several faces match, the one whose surface centre is closest to
+ *ref_center* (if provided) is preferred.
+
+ Returns ``(face, center)`` where *center* is the surface centroid as a
+ 3-tuple, or *None* if no matching face is found.
+ """
+ import math
+
+ from OCP.TopExp import TopExp_Explorer
+ from OCP.TopAbs import TopAbs_FACE
+ from OCP.TopoDS import TopoDS
+ from OCP.BRepAdaptor import BRepAdaptor_Surface
+ from OCP.GeomAbs import GeomAbs_Plane
+ from OCP.BRepGProp import BRepGProp
+ from OCP.GProp import GProp_GProps
+
+ import numpy as np
+
+ if shape is None:
+ return None
+ n = np.asarray(normal, dtype=float)
+ n = n / (np.linalg.norm(n) + 1e-30)
+ ox, oy, oz = origin
+
+ cos_tol = math.cos(math.radians(angle_tol_deg))
+ candidates: list = []
+
+ explorer = TopExp_Explorer(shape, TopAbs_FACE)
+ while explorer.More():
+ face = TopoDS.Face_s(explorer.Current())
+ try:
+ surf = BRepAdaptor_Surface(face)
+ if surf.GetType() != GeomAbs_Plane:
+ explorer.Next()
+ continue
+ plane = surf.Plane()
+ pn = np.array(
+ [
+ plane.Axis().Direction().X(),
+ plane.Axis().Direction().Y(),
+ plane.Axis().Direction().Z(),
+ ],
+ dtype=float,
+ )
+ # Check normals parallel (same or opposite direction)
+ cos_angle = abs(float(np.dot(n, pn)))
+ if cos_angle < cos_tol:
+ explorer.Next()
+ continue
+ # Check distance from plane to origin
+ pp = plane.Location()
+ d = abs(float(np.dot(n, np.array([pp.X() - ox, pp.Y() - oy, pp.Z() - oz]))))
+ if d > dist_tol:
+ explorer.Next()
+ continue
+ # Surface centroid via GProp (SurfaceProperties for faces)
+ props = GProp_GProps()
+ BRepGProp.SurfaceProperties_s(face, props)
+ c = props.CentreOfMass()
+ center = (float(c.X()), float(c.Y()), float(c.Z()))
+ candidates.append((face, center))
+ except Exception:
+ pass
+ explorer.Next()
+
+ if not candidates:
+ return None
+ if ref_center is not None and len(candidates) > 1:
+ rc = np.asarray(ref_center, dtype=float)
+ best = min(candidates, key=lambda fc: float(np.linalg.norm(np.asarray(fc[1]) - rc)))
+ return best
+ return candidates[0]
+
def revolve(
self,
sketch: GeometryObject,
diff --git a/src/fluency/geometry_occ/sketch.py b/src/fluency/geometry_occ/sketch.py
index b674cd4..f75530c 100644
--- a/src/fluency/geometry_occ/sketch.py
+++ b/src/fluency/geometry_occ/sketch.py
@@ -109,7 +109,6 @@ class OCCSketch(SketchInterface):
orthonormalised here. ``y_dir`` is derived as ``normal × x_dir``.
Existing UV coordinates are unchanged; only their world mapping moves.
"""
- import numpy as np
n = np.asarray(normal, dtype=float)
x = np.asarray(x_dir, dtype=float)
@@ -138,6 +137,7 @@ class OCCSketch(SketchInterface):
def _uv_to_world(self, u: float, v: float):
"""Map a UV point to a world ``gp_Pnt`` on the workplane."""
from OCP.gp import gp_Pnt
+
ox, oy, oz = self._wp_origin
xx, xy, xz = self._wp_x_dir
yx, yy, yz = self._wp_y_dir
@@ -150,6 +150,7 @@ class OCCSketch(SketchInterface):
def _circle_axis(self, u: float, v: float):
"""Return a ``gp_Ax2`` for a circle centred at UV on the workplane."""
from OCP.gp import gp_Ax2, gp_Dir
+
center = self._uv_to_world(u, v)
return gp_Ax2(
center,
@@ -275,6 +276,7 @@ class OCCSketch(SketchInterface):
When *None* the rendering will infer the shortest path between start and end.
"""
import math
+
entity_id = self._next_id()
center_entity = self._entities.get(center.id)
@@ -345,8 +347,10 @@ class OCCSketch(SketchInterface):
self._solver.dragged(solver_handle, self._wp)
entity = OCCSketchEntity(
- entity_id=entity_id, entity_type="point",
- geometry=(x, y), handle=solver_handle,
+ entity_id=entity_id,
+ entity_type="point",
+ geometry=(x, y),
+ handle=solver_handle,
)
entity.is_external = True
entity.is_construction = True # external points are reference / dashed
@@ -377,7 +381,8 @@ class OCCSketch(SketchInterface):
x1, y1 = s_ent.geometry
x2, y2 = e_ent.geometry
entity = OCCSketchEntity(
- entity_id=entity_id, entity_type="line",
+ entity_id=entity_id,
+ entity_type="line",
geometry=((x1, y1), (x2, y2)),
handle=solver_handle,
)
@@ -388,6 +393,10 @@ class OCCSketch(SketchInterface):
self._external_entity_ids.add(entity_id)
return entity
+ #: UV distance below which two projected points are considered the same
+ #: corner when importing / re-projecting external underlay geometry.
+ _EXTERNAL_MERGE_TOL: float = 1e-6
+
def add_external_polyline(
self, uv_points: List[Tuple[float, float]]
) -> Tuple[List[OCCSketchEntity], List[OCCSketchEntity]]:
@@ -398,33 +407,108 @@ class OCCSketch(SketchInterface):
``(points, lines)`` in the order they were created so the caller can
keep references (e.g. for rendering or for toggling).
- Points very close to each other (within 1e-6 UV units) are merged
- into a single shared point, so a closed rectangle becomes 4 unique
- points and 4 line segments (not 4 points and 4 lines + 4 duplicates
- at the corners).
+ Points very close to each other (within ``_EXTERNAL_MERGE_TOL`` UV
+ units) are merged into a single shared point, so a closed rectangle
+ becomes 4 unique points and 4 line segments (not 4 points and 4
+ lines + 4 duplicates at the corners). Merging also applies against
+ *previously imported* external points, so consecutive polylines that
+ share a corner (separate face edges meeting at a vertex) reuse one
+ point entity — the corner becomes a single connection hub for
+ coincident constraints instead of two stacked duplicates.
"""
- if len(uv_points) < 2:
- return [], []
- # Deduplicate nearby points so shared corners (e.g. a rectangle's
- # four vertices) are *one* point entity reused by two line segments.
- eps = 1e-6
- points: List[OCCSketchEntity] = []
- coord_to_entity: Dict[Tuple[int, int], OCCSketchEntity] = {}
- for (u, v) in uv_points:
- key = (int(round(u / eps)), int(round(v / eps)))
- ent = coord_to_entity.get(key)
- if ent is None:
- ent = self.add_external_point(float(u), float(v))
- coord_to_entity[key] = ent
- points.append(ent)
- lines: List[OCCSketchEntity] = []
- for i in range(len(points) - 1):
- try:
- ln = self.add_external_line(points[i], points[i + 1])
- lines.append(ln)
- except ValueError:
- pass
- return points, lines
+ points, lines = self.add_external_polylines([uv_points])
+ return (points[0] if points else []), lines
+
+ def add_external_polylines(
+ self, polylines: List[List[Tuple[float, float]]]
+ ) -> Tuple[List[List[OCCSketchEntity]], List[OCCSketchEntity]]:
+ """Bulk-import several polylines with corner dedup *across* polylines.
+
+ A face projection yields one polyline per boundary edge; edges that
+ meet at a vertex must share a single external point entity, otherwise
+ every corner ends up as two independent fixed points and user geometry
+ coincident to one duplicate is *not* connected to geometry coincident
+ to the other. Returns ``(points_per_polyline, all_lines)``.
+ """
+ tol = self._EXTERNAL_MERGE_TOL
+
+ def find_or_create(u: float, v: float) -> OCCSketchEntity:
+ # Tolerance-based nearest lookup against existing external points
+ # (entity counts are small — a face boundary has tens of points).
+ best: Optional[OCCSketchEntity] = None
+ best_d = tol
+ for eid in self._external_entity_ids:
+ ent = self._entities.get(eid)
+ if ent is None or ent.entity_type != "point" or ent.geometry is None:
+ continue
+ d = math.hypot(ent.geometry[0] - u, ent.geometry[1] - v)
+ if d <= best_d:
+ best_d = d
+ best = ent
+ if best is None:
+ best = self.add_external_point(float(u), float(v))
+ return best
+
+ all_points: List[List[OCCSketchEntity]] = []
+ all_lines: List[OCCSketchEntity] = []
+ for uv_points in polylines:
+ if len(uv_points) < 2:
+ continue
+ points = [find_or_create(float(u), float(v)) for (u, v) in uv_points]
+ all_points.append(points)
+ for i in range(len(points) - 1):
+ if points[i] is points[i + 1]:
+ continue # degenerate zero-length segment after merging
+ # Skip duplicate segments (two edges projecting onto the
+ # same pair of corner points).
+ dupe = False
+ for lid, (sid, eid2) in self._lines.items():
+ if lid not in self._external_entity_ids:
+ continue
+ if (sid == points[i].id and eid2 == points[i + 1].id) or (
+ sid == points[i + 1].id and eid2 == points[i].id
+ ):
+ dupe = True
+ break
+ if dupe:
+ continue
+ try:
+ ln = self.add_external_line(points[i], points[i + 1])
+ all_lines.append(ln)
+ except ValueError:
+ pass
+ return all_points, all_lines
+
+ def _drop_external_entities(self) -> set:
+ """Remove external entities from local tracking + prune their constraints.
+
+ Does NOT rebuild the solver — the caller decides when to rebuild
+ (removal-only flows rebuild immediately; re-projection flows first
+ import the new externals so the rebuild sees them and doesn't
+ auto-anchor a user point instead).
+ """
+ removed = set(self._external_entity_ids)
+ if not removed:
+ return removed
+ # Wipe external entities from local tracking.
+ for eid in list(removed):
+ if eid in self._entities:
+ del self._entities[eid]
+ self._points.pop(eid, None)
+ self._lines.pop(eid, None)
+ self._circles.pop(eid, None)
+ self._arcs.pop(eid, None)
+ # Also clean lines that USE an external point as an endpoint but
+ # somehow aren't themselves external (defensive — shouldn't happen
+ # via the public API, but rebuild_solver needs a clean graph).
+ for lid, (sid, eid2) in list(self._lines.items()):
+ if sid in removed or eid2 in removed:
+ del self._lines[lid]
+ if lid in self._entities:
+ del self._entities[lid]
+ self._external_entity_ids.clear()
+ self._prune_log_for(removed)
+ return removed
def remove_external_entities(self) -> None:
"""Remove every external / underlay entity and prune related constraints.
@@ -437,25 +521,7 @@ class OCCSketch(SketchInterface):
"""
if not self._external_entity_ids:
return
- # Wipe external entities from local tracking.
- for eid in list(self._external_entity_ids):
- if eid in self._entities:
- del self._entities[eid]
- self._points.pop(eid, None)
- self._lines.pop(eid, None)
- self._circles.pop(eid, None)
- self._arcs.pop(eid, None)
- # Also clean lines that USE an external point as an endpoint but
- # somehow aren't themselves external (defensive — shouldn't happen
- # via the public API, but rebuild_solver needs a clean graph).
- for lid, (sid, eid2) in list(self._lines.items()):
- if sid in self._external_entity_ids or eid2 in self._external_entity_ids:
- del self._lines[lid]
- if lid in self._entities:
- del self._entities[lid]
- removed = set(self._external_entity_ids)
- self._external_entity_ids.clear()
- self._prune_log_for(removed)
+ self._drop_external_entities()
self._rebuild_solver()
self._rebuild_labels()
@@ -463,6 +529,184 @@ class OCCSketch(SketchInterface):
"""Return the set of external (underlay) entity ids currently in the sketch."""
return set(self._external_entity_ids)
+ def update_external_entities(self, polylines: List[List[Tuple[float, float]]]) -> bool:
+ """Re-project external (underlay) entities from updated source geometry.
+
+ Called when the 3D body the underlay was projected from has been
+ rebuilt (e.g. its source sketch was edited and re-extruded) and the
+ face edges were re-projected to UV. The underlay must follow the
+ body so user geometry constrained to it propagates through the
+ solver.
+
+ Two paths:
+
+ * **In-place update** (same topology): when the new projection has
+ the same number of unique corner points and segments, each existing
+ external point is paired with the nearest new position (greedy
+ one-to-one) and moved via ``set_params``. Entity ids and every
+ constraint referencing them survive untouched, and the next
+ :meth:`solve` pulls the user geometry along.
+ * **Rebuild + rebind** (topology changed): external entities are
+ removed and re-imported; constraints that referenced external
+ entities are re-created against the nearest new external entity
+ (point-to-point for coincident on corners, point-on-line for
+ coincident on edges) so user geometry stays anchored.
+
+ Returns True when the underlay was updated and solved OK.
+ """
+ # Flatten the new projection into unique corner positions + segments.
+ tol = self._EXTERNAL_MERGE_TOL
+ new_pts: List[Tuple[float, float]] = []
+
+ def new_index(u: float, v: float) -> int:
+ for i, (x, y) in enumerate(new_pts):
+ if math.hypot(x - u, y - v) <= tol:
+ return i
+ new_pts.append((float(u), float(v)))
+ return len(new_pts) - 1
+
+ new_segs: List[Tuple[int, int]] = []
+ for poly in polylines:
+ if len(poly) < 2:
+ continue
+ idx = [new_index(float(u), float(v)) for (u, v) in poly]
+ for i in range(len(idx) - 1):
+ if idx[i] == idx[i + 1]:
+ continue
+ seg = (min(idx[i], idx[i + 1]), max(idx[i], idx[i + 1]))
+ if seg not in new_segs:
+ new_segs.append(seg)
+
+ old_ext_points = [
+ self._entities[eid]
+ for eid in sorted(self._external_entity_ids)
+ if eid in self._entities and self._entities[eid].entity_type == "point"
+ ]
+ old_ext_lines = [
+ lid for lid in sorted(self._lines.keys()) if lid in self._external_entity_ids
+ ]
+
+ same_topology = len(new_pts) == len(old_ext_points) and len(new_segs) == len(old_ext_lines)
+
+ if same_topology and old_ext_points:
+ # Greedy one-to-one nearest matching old point -> new position.
+ pairs: List[Tuple[float, int, int]] = [] # (dist, old_idx, new_idx)
+ for oi, ent in enumerate(old_ext_points):
+ ox, oy = ent.geometry
+ for ni, (nx, ny) in enumerate(new_pts):
+ pairs.append((math.hypot(ox - nx, oy - ny), oi, ni))
+ pairs.sort()
+ match: Dict[int, int] = {}
+ used_new: set = set()
+ for d, oi, ni in pairs:
+ if oi in match or ni in used_new:
+ continue
+ match[oi] = ni
+ used_new.add(ni)
+ if len(match) == len(old_ext_points):
+ # Verify segment connectivity is preserved under the matching
+ # (same corners, but edges rewired -> rebuild instead).
+ mapped_segs = set()
+ for a, b in new_segs:
+ mapped_segs.add((a, b))
+ connectivity_ok = True
+ for lid in old_ext_lines:
+ sid, eid2 = self._lines[lid]
+ oi_s = next((i for i, e in enumerate(old_ext_points) if e.id == sid), None)
+ oi_e = next((i for i, e in enumerate(old_ext_points) if e.id == eid2), None)
+ if oi_s is None or oi_e is None:
+ connectivity_ok = False
+ break
+ seg = (min(match[oi_s], match[oi_e]), max(match[oi_s], match[oi_e]))
+ if seg not in mapped_segs:
+ connectivity_ok = False
+ break
+ if connectivity_ok:
+ for oi, ent in enumerate(old_ext_points):
+ nx, ny = new_pts[match[oi]]
+ self.set_entity_position(ent, nx, ny)
+ return self.solve()
+
+ # ── Rebuild + rebind path (topology changed, or no externals yet) ──
+ # Capture constraints that tie USER entities to external entities so
+ # they can be re-created against the nearest new external entity.
+ saved: List[Dict[str, Any]] = []
+ for entry in self._constraint_log:
+ ext_ids = [i for i in entry["ids"] if i in self._external_entity_ids]
+ user_ids = [i for i in entry["ids"] if i not in self._external_entity_ids]
+ if not ext_ids or not user_ids:
+ continue
+ for ext_id in ext_ids:
+ ent = self._entities.get(ext_id)
+ if ent is None:
+ continue
+ if ent.entity_type == "point" and ent.geometry is not None:
+ anchor: Any = ("point", ext_id, tuple(ent.geometry))
+ elif ent.entity_type == "line" and ext_id in self._lines:
+ sid, eid2 = self._lines[ext_id]
+ s_ent = self._entities.get(sid)
+ e_ent = self._entities.get(eid2)
+ if s_ent is None or e_ent is None:
+ continue
+ mx = (s_ent.geometry[0] + e_ent.geometry[0]) / 2.0
+ my = (s_ent.geometry[1] + e_ent.geometry[1]) / 2.0
+ anchor = ("line", ext_id, (mx, my))
+ else:
+ continue
+ saved.append({"type": entry["type"], "user_ids": list(user_ids), "anchor": anchor})
+
+ # Drop old externals, import the new projection, then rebuild the
+ # solver exactly once. The import MUST happen before the rebuild:
+ # with external ids present the rebuild re-fixes the new underlay
+ # points and (per the add_point guard) does not auto-anchor a user
+ # point — which would conflict with the re-bound coincidents below.
+ self._drop_external_entities()
+ self.add_external_polylines(polylines)
+ self._rebuild_solver()
+ self._rebuild_labels()
+
+ # Rebind saved constraints to the nearest new external entity.
+ rebound = 0
+ for item in saved:
+ kind, _old_id, pos = item["anchor"]
+ target: Optional[OCCSketchEntity] = None
+ best_d = float("inf")
+ if kind == "point":
+ for eid in self._external_entity_ids:
+ ent = self._entities.get(eid)
+ if ent is None or ent.entity_type != "point" or ent.geometry is None:
+ continue
+ d = math.hypot(ent.geometry[0] - pos[0], ent.geometry[1] - pos[1])
+ if d < best_d:
+ best_d = d
+ target = ent
+ else: # line: nearest segment midpoint
+ for lid, (sid, eid2) in self._lines.items():
+ if lid not in self._external_entity_ids:
+ continue
+ s_ent = self._entities.get(sid)
+ e_ent = self._entities.get(eid2)
+ if s_ent is None or e_ent is None:
+ continue
+ mx = (s_ent.geometry[0] + e_ent.geometry[0]) / 2.0
+ my = (s_ent.geometry[1] + e_ent.geometry[1]) / 2.0
+ d = math.hypot(mx - pos[0], my - pos[1])
+ if d < best_d:
+ best_d = d
+ target = self._entities.get(lid)
+ if target is None:
+ continue
+ for uid in item["user_ids"]:
+ user_ent = self._entities.get(uid)
+ if user_ent is None:
+ continue
+ if item["type"] == "coincident":
+ if self.constrain_coincident(user_ent, target):
+ rebound += 1
+ if rebound:
+ logger.info("Rebound %d constraint(s) to re-projected underlay", rebound)
+ return self.solve()
+
# ── Centerlines (X and Y reference axes) ────────────────────────────
_CENTERLINE_EXTENT: float = 10000.0 # large enough to span any sketch
@@ -514,8 +758,13 @@ class OCCSketch(SketchInterface):
self.constrain_fixed(yb)
self._centerline_ids = {
- origin.id, xl.id, xr.id, xline.id,
- yb.id, yt.id, yline.id,
+ origin.id,
+ xl.id,
+ xr.id,
+ xline.id,
+ yb.id,
+ yt.id,
+ yline.id,
}
self.solve()
@@ -557,7 +806,12 @@ class OCCSketch(SketchInterface):
"""Count and log a constraint so the solver can be rebuilt after deletions."""
self._constraint_count += 1
self._constraint_log.append(
- {"type": ctype, "ids": tuple(int(i) for i in ids), "params": tuple(params), "labels": set(labels)}
+ {
+ "type": ctype,
+ "ids": tuple(int(i) for i in ids),
+ "params": tuple(params),
+ "labels": set(labels),
+ }
)
def _apply_constraint_log(self, entry: Dict[str, Any]) -> bool:
@@ -666,7 +920,16 @@ class OCCSketch(SketchInterface):
x, y = saved_pos.get(pid, (0.0, 0.0))
new_handle = self._solver.add_point_2d(x, y, self._wp)
ent.handle = new_handle
- if self._first_point_id is None:
+ if pid in self._external_entity_ids:
+ # External (underlay) points are ALWAYS fixed — the dragged
+ # applied at creation isn't in the constraint log, so it must
+ # be re-applied here or the underlay becomes draggable after
+ # any solver rebuild (e.g. deleting an unrelated user point).
+ self._solver.dragged(new_handle, self._wp)
+ elif self._first_point_id is None and not self._external_entity_ids:
+ # Mirror add_point's guard: when the sketch carries external
+ # underlay points those are the natural anchors, and fixing a
+ # user point too would over-constrain the system.
self._first_point_id = pid
self._solver.dragged(new_handle, self._wp)
@@ -1008,7 +1271,12 @@ class OCCSketch(SketchInterface):
adjacency: Dict[Tuple[float, float], List[Tuple[float, float]]] = {}
for entity in self._entities.values():
- if entity.entity_type == "line" and entity.geometry and not entity.is_external and not entity.is_construction:
+ if (
+ entity.entity_type == "line"
+ and entity.geometry
+ and not entity.is_external
+ and not entity.is_construction
+ ):
p1, p2 = entity.geometry
if p1 not in adjacency:
adjacency[p1] = []
@@ -1043,7 +1311,7 @@ class OCCSketch(SketchInterface):
# ─── Closed-loop / face detection (for region selection + holes) ──────
- _SNAP_TOL: float = 1e-4 # world-unit tolerance for snapping line endpoints
+ _SNAP_TOL: float = 1e-2 # world-unit tolerance for snapping line endpoints in loop detection
def _line_segments(self) -> List[Tuple[Tuple[float, float], Tuple[float, float]]]:
"""Current line segments as world-coordinate tuples (uses solved positions).
@@ -1068,8 +1336,12 @@ class OCCSketch(SketchInterface):
s_ent = self._entities.get(sid)
e_ent = self._entities.get(eid2)
if s_ent and e_ent and s_ent.geometry and e_ent.geometry:
- segs.append(((float(s_ent.geometry[0]), float(s_ent.geometry[1])),
- (float(e_ent.geometry[0]), float(e_ent.geometry[1]))))
+ segs.append(
+ (
+ (float(s_ent.geometry[0]), float(s_ent.geometry[1])),
+ (float(e_ent.geometry[0]), float(e_ent.geometry[1])),
+ )
+ )
# ── Arc segments (tessellated) ──
for arc_id, arc_data in self._arcs.items():
@@ -1086,8 +1358,9 @@ class OCCSketch(SketchInterface):
c_ent = self._entities.get(center_id)
s_ent = self._entities.get(start_id)
e_ent = self._entities.get(end_id)
- if not (c_ent and s_ent and e_ent
- and c_ent.geometry and s_ent.geometry and e_ent.geometry):
+ if not (
+ c_ent and s_ent and e_ent and c_ent.geometry and s_ent.geometry and e_ent.geometry
+ ):
continue
cx, cy = c_ent.geometry
sx, sy = s_ent.geometry
@@ -1099,10 +1372,8 @@ class OCCSketch(SketchInterface):
t2 = (i + 1) / n
a1 = start_angle + t1 * sweep
a2 = start_angle + t2 * sweep
- p1 = (cx + radius * math.cos(a1),
- cy + radius * math.sin(a1))
- p2 = (cx + radius * math.cos(a2),
- cy + radius * math.sin(a2))
+ p1 = (cx + radius * math.cos(a1), cy + radius * math.sin(a1))
+ p2 = (cx + radius * math.cos(a2), cy + radius * math.sin(a2))
segs.append((p1, p2))
return segs
@@ -1180,8 +1451,13 @@ class OCCSketch(SketchInterface):
for cid, (center_id, r) in self._circles.items():
c_ent = self._entities.get(center_id)
if c_ent and c_ent.geometry and r > 0:
- loops.append({"type": "circle", "center": (float(c_ent.geometry[0]), float(c_ent.geometry[1])),
- "radius": float(r)})
+ loops.append(
+ {
+ "type": "circle",
+ "center": (float(c_ent.geometry[0]), float(c_ent.geometry[1])),
+ "radius": float(r),
+ }
+ )
return loops
@staticmethod
@@ -1212,7 +1488,10 @@ class OCCSketch(SketchInterface):
# Point-on-segment test — exclude strict boundary hits.
# First check bounding box of the segment.
bbox_tol = max(eps, margin)
- if min(xi, xj) - bbox_tol <= x <= max(xi, xj) + bbox_tol and min(yi, yj) - bbox_tol <= y <= max(yi, yj) + bbox_tol:
+ if (
+ min(xi, xj) - bbox_tol <= x <= max(xi, xj) + bbox_tol
+ and min(yi, yj) - bbox_tol <= y <= max(yi, yj) + bbox_tol
+ ):
# Check collinearity
cross = (x - xi) * (yj - yi) - (y - yi) * (xj - xi)
abs_cross = abs(cross)
@@ -1269,18 +1548,14 @@ class OCCSketch(SketchInterface):
if inner["type"] == "circle":
# Circle in polygon: centre must be inside with margin
cx, cy = inner["center"]
- return OCCSketch._point_in_polygon(
- (cx, cy), outer["points"], margin=1e-3
- )
+ return OCCSketch._point_in_polygon((cx, cy), outer["points"], margin=1e-3)
else:
# Polygon in polygon: ALL inner vertices inside outer
pts = inner["points"]
if len(pts) > 1 and pts[0] == pts[-1]:
pts = pts[:-1]
for pt in pts:
- if not OCCSketch._point_in_polygon(
- pt, outer["points"], margin=eps
- ):
+ if not OCCSketch._point_in_polygon(pt, outer["points"], margin=eps):
return False
return True
@@ -1293,7 +1568,11 @@ class OCCSketch(SketchInterface):
the centre for circles.
"""
if loop["type"] == "polygon":
- pts = loop["points"][:-1] if len(loop["points"]) > 1 and loop["points"][0] == loop["points"][-1] else loop["points"]
+ pts = (
+ loop["points"][:-1]
+ if len(loop["points"]) > 1 and loop["points"][0] == loop["points"][-1]
+ else loop["points"]
+ )
n = len(pts)
if n < 3:
return loop.get("center", (0.0, 0.0))
@@ -1374,11 +1653,13 @@ class OCCSketch(SketchInterface):
for h in face["holes"]:
if h["type"] == "polygon":
if OCCSketch._point_in_polygon(pt, h["points"]):
- in_hole = True; break
+ in_hole = True
+ break
else:
hcx, hcy = h["center"]
if math.hypot(pt[0] - hcx, pt[1] - hcy) < h["radius"]:
- in_hole = True; break
+ in_hole = True
+ break
if in_hole:
continue
area = OCCSketch._loop_area(outer)
@@ -1426,17 +1707,19 @@ class OCCSketch(SketchInterface):
from top-left to bottom-right.
"""
from OCP.BRepBuilderAPI import (
- BRepBuilderAPI_MakePolygon, BRepBuilderAPI_MakeFace,
- BRepBuilderAPI_MakeWire, BRepBuilderAPI_MakeEdge,
+ BRepBuilderAPI_MakePolygon,
+ BRepBuilderAPI_MakeFace,
+ BRepBuilderAPI_MakeWire,
+ BRepBuilderAPI_MakeEdge,
)
- from OCP.gp import gp_Pnt, gp_Circ, gp_Ax2, gp_Dir
+ from OCP.gp import gp_Circ
from OCP.TopoDS import TopoDS as _TopoDS
def _wire_from_loop(loop: Dict[str, Any]):
"""Build a wire from a loop dict. No orientation adjustment."""
if loop["type"] == "polygon":
mp = BRepBuilderAPI_MakePolygon()
- for (pu, pv) in loop["points"]:
+ for pu, pv in loop["points"]:
mp.Add(self._uv_to_world(pu, pv))
mp.Close()
mp.Build()
@@ -1463,17 +1746,22 @@ class OCCSketch(SketchInterface):
# wire (material on the other side). We reverse the hole wire
# only when its natural winding matches the outer's; if they
# already differ the wire is left as-is.
- if (hole_winding >= 0 and outer_winding >= 0) or (hole_winding < 0 and outer_winding < 0):
+ if (hole_winding >= 0 and outer_winding >= 0) or (
+ hole_winding < 0 and outer_winding < 0
+ ):
hole_wire = _TopoDS.Wire_s(hole_wire.Reversed())
face_maker.Add(hole_wire)
face_maker.Build()
occ_face = face_maker.Face()
- obj = OCCGeometryObject(occ_face, {
- "type": "sketch_face",
- "normal": self._wp_normal,
- "origin": self._wp_origin,
- })
+ obj = OCCGeometryObject(
+ occ_face,
+ {
+ "type": "sketch_face",
+ "normal": self._wp_normal,
+ "origin": self._wp_origin,
+ },
+ )
return obj
def get_solver_dof(self) -> int:
@@ -1579,7 +1867,8 @@ class OCCSketch(SketchInterface):
removed_ids: set = {point.id}
# Remove lines that use this point as an endpoint.
removed_line_keys: List[int] = [
- lid for lid, (sid, eid2) in list(self._lines.items())
+ lid
+ for lid, (sid, eid2) in list(self._lines.items())
if sid == point.id or eid2 == point.id
]
for lid in removed_line_keys:
@@ -1593,8 +1882,7 @@ class OCCSketch(SketchInterface):
del self._entities[point.id]
# Circles anchored on the point are also invalid.
removed_circle_keys: List[int] = [
- cid for cid, (center_id, _r) in list(self._circles.items())
- if center_id == point.id
+ cid for cid, (center_id, _r) in list(self._circles.items()) if center_id == point.id
]
for cid in removed_circle_keys:
removed_ids.add(cid)
@@ -1603,7 +1891,8 @@ class OCCSketch(SketchInterface):
del self._entities[cid]
# Arcs referencing this point (as centre, start, or end) are invalid.
removed_arc_keys: List[int] = [
- aid for aid, adata in list(self._arcs.items())
+ aid
+ for aid, adata in list(self._arcs.items())
if adata.get("center") == point.id
or adata.get("start") == point.id
or adata.get("end") == point.id
@@ -1820,9 +2109,7 @@ class OCCSketch(SketchInterface):
s_id = self._find_point_at(x1, y1)
e_id = self._find_point_at(x2, y2)
if s_id is None or e_id is None:
- logger.warning(
- "Skipping line %s during load: endpoints not found", eid
- )
+ logger.warning("Skipping line %s during load: endpoints not found", eid)
continue
if is_external:
ent = self.add_external_line(entities_by_id[s_id], entities_by_id[e_id])
@@ -1832,9 +2119,7 @@ class OCCSketch(SketchInterface):
(cx, cy), radius = geom
c_id = self._find_point_at(cx, cy)
if c_id is None:
- logger.warning(
- "Skipping circle %s during load: center not found", eid
- )
+ logger.warning("Skipping circle %s during load: center not found", eid)
continue
ent = self.add_circle(entities_by_id[c_id], float(radius))
elif etype == "arc":
@@ -1847,9 +2132,7 @@ class OCCSketch(SketchInterface):
s_id = self._find_point_at(*start_pos)
e_id = self._find_point_at(*end_pos)
if c_id is None or s_id is None or e_id is None:
- logger.warning(
- "Skipping arc %s during load: endpoints not found", eid
- )
+ logger.warning("Skipping arc %s during load: endpoints not found", eid)
continue
ent = self.add_arc(
entities_by_id[c_id],
diff --git a/src/fluency/geometry_occ/surface_modifier.py b/src/fluency/geometry_occ/surface_modifier.py
new file mode 100644
index 0000000..13a7330
--- /dev/null
+++ b/src/fluency/geometry_occ/surface_modifier.py
@@ -0,0 +1,385 @@
+"""Surface modifier for OpenCASCADE geometry.
+
+Applies geometric patterns (pyramids, bumps, grooves) to 3D surfaces using boolean operations.
+This enables grip-enhancing textures and visual surface modifications on CAD models.
+"""
+
+from __future__ import annotations
+
+import logging
+import math
+from typing import Any, Optional, Tuple
+
+# OCC imports at module level for common types
+
+
+logger = logging.getLogger(__name__)
+
+
+class SurfaceModifier:
+ """Applies geometric patterns to 3D surfaces using OCC boolean operations."""
+
+ def __init__(self):
+ self._patterns_applied = []
+
+ def apply_pyramid_pattern(
+ self,
+ face_shape,
+ pyramid_height: float = 1.0,
+ base_radius: float = 2.0,
+ spacing: float = 5.0,
+ num_rings: Optional[int] = None,
+ direction: Tuple[float, float, float] = (0, 0, 1),
+ ) -> Optional[Any]:
+ """Apply a pyramid pattern to a face surface.
+
+ Args:
+ face_shape: OCC TopoDS_Shape representing the face or solid
+ pyramid_height: Height of each pyramid
+ base_radius: Radius of pyramid base
+ spacing: Distance between pyramids
+ num_rings: Number of concentric rings (auto-calculated if None)
+ direction: Normal direction for pyramids
+
+ Returns:
+ Modified shape on success, None on failure
+ """
+ try:
+ from OCP.TopAbs import TopAbs_FACE
+ from OCP.TopoDS import TopoDS_Face, TopoDS_Shape
+ from OCP.BRepAlgoAPI import BRepAlgoAPI_Fuse
+ from OCP.TopExp import TopExp_Explorer
+ from OCP.BRepAdaptor import BRepAdaptor_Surface
+
+ # Validate face shape
+ if not isinstance(face_shape, (TopoDS_Shape, TopoDS_Face)):
+ logger.error("Invalid face shape type")
+ return None
+
+ # Extract the first face for surface parameterization
+ if isinstance(face_shape, TopoDS_Shape):
+ explorer = TopExp_Explorer(face_shape, TopAbs_FACE)
+ if not explorer.More():
+ logger.error("No faces found in shape")
+ return None
+ from OCP import TopoDS
+ face = TopoDS.TopoDS.Face_s(explorer.Current())
+ else:
+ face = face_shape
+
+ # Get face surface for UV parameterization
+ surf = BRepAdaptor_Surface(face)
+
+ u_min, u_max = surf.FirstUParameter(), surf.LastUParameter()
+ v_min, v_max = surf.FirstVParameter(), surf.LastVParameter()
+
+ # Calculate number of rings if not specified
+ if num_rings is None:
+ # Estimate based on face area and spacing
+ u_range = u_max - u_min
+ v_range = v_max - v_min
+ avg_dim = (u_range + v_range) / 2.0
+ num_rings = max(1, min(int(avg_dim / spacing), 5))
+
+ logger.info(
+ f"Applying pyramid pattern: {num_rings} rings, "
+ f"{base_radius:.2f} radius, {pyramid_height:.2f} height"
+ )
+
+ # Create pyramids distributed across the face UV space
+ result_shape = face_shape
+ pyramid_count = 0
+
+ for ring_idx in range(num_rings):
+ # Distribute rings evenly across UV parameter space
+ u_fraction = (ring_idx + 1) / (num_rings + 1)
+ v_fraction = 0.5 # Center vertically
+
+ # Map to actual UV coordinates on the face
+ u_pos = u_min + u_fraction * (u_max - u_min)
+ v_pos = v_min + v_fraction * (v_max - v_min)
+
+ # Get 3D position and tangent vectors at this UV point
+ from OCP.gp import gp_Pnt, gp_Vec
+
+ center_pt = gp_Pnt()
+ d1u = gp_Vec()
+ d1v = gp_Vec()
+ surf.D1(u_pos, v_pos, center_pt, d1u, d1v)
+ # Normal is cross product of tangent vectors
+ normal = d1u.Crossed(d1v)
+ normal.Normalize()
+
+ # Calculate number of pyramids in this ring based on spacing
+ if ring_idx == 0:
+ num_pyramids = 1 # Center pyramid
+ else:
+ circumference = 2.0 * math.pi * (ring_idx * spacing)
+ num_pyramids = max(3, int(circumference / spacing))
+
+ for i in range(num_pyramids):
+ if ring_idx == 0:
+ # Center pyramid - place at face center
+ place_u = u_pos
+ place_v = v_pos
+ else:
+ angle = (2.0 * math.pi * i) / num_pyramids
+ # Offset in UV space based on ring radius
+ offset_u = (ring_idx * spacing / (u_max - u_min)) * math.cos(angle)
+ offset_v = (ring_idx * spacing / (v_max - v_min)) * math.sin(angle)
+ place_u = max(u_min, min(u_max, u_pos + offset_u))
+ place_v = max(v_min, min(v_max, v_pos + offset_v))
+
+ try:
+ # Get 3D position and normal for this pyramid
+ pyramid_pt = gp_Pnt()
+ pd1u = gp_Vec()
+ pd1v = gp_Vec()
+ surf.D1(place_u, place_v, pyramid_pt, pd1u, pd1v)
+ pyramid_normal = pd1u.Crossed(pd1v)
+ pyramid_normal.Normalize()
+
+ # Create solid pyramid at this position
+ pyramid_shape = self._create_solid_pyramid(
+ pyramid_pt,
+ pyramid_normal,
+ pyramid_height,
+ base_radius,
+ )
+
+ if pyramid_shape is not None:
+ # Fuse with existing geometry
+ fuse = BRepAlgoAPI_Fuse(result_shape, pyramid_shape)
+ fuse.Build()
+
+ if fuse.IsDone():
+ result_shape = fuse.Shape()
+ pyramid_count += 1
+ else:
+ logger.warning(
+ f"Failed to fuse pyramid at ({place_u:.2f}, {place_v:.2f})"
+ )
+ except Exception as e:
+ logger.debug(
+ f"Error creating pyramid at ring {ring_idx}, pyramid {i}: {e}"
+ )
+
+ self._patterns_applied.append(
+ {
+ "type": "pyramid",
+ "parameters": {
+ "height": pyramid_height,
+ "base_radius": base_radius,
+ "spacing": spacing,
+ "num_rings": num_rings,
+ "direction": direction,
+ },
+ }
+ )
+
+ logger.info(f"Successfully applied {pyramid_count} pyramids")
+
+ return result_shape
+
+ except Exception as e:
+ logger.error(f"Error applying pyramid pattern: {e}", exc_info=True)
+ return None
+
+ def _create_solid_pyramid(
+ self,
+ base_point, # gp_Pnt - position on the face
+ normal_vec, # gp_Dir or gp_Vec - surface normal direction
+ height: float,
+ base_radius: float,
+ ) -> Optional[Any]:
+ """Create a solid pyramid at the specified position and orientation.
+
+ Uses BRepPrimAPI_MakePrism to extrude a square base into a solid pyramid.
+
+ Args:
+ base_point: 3D point where pyramid base is centered
+ normal_vec: Direction vector for pyramid growth (surface normal)
+ height: Height of the pyramid from base to apex
+ base_radius: Half-width of the square base
+
+ Returns:
+ OCC solid shape for the pyramid, or None on failure
+ """
+ try:
+ from OCP.gp import gp_Dir, gp_Ax2, gp_Vec
+ from OCP.BRepBuilderAPI import (
+ BRepBuilderAPI_MakeEdge,
+ BRepBuilderAPI_MakeWire,
+ )
+ from OCP.BRepPrimAPI import BRepPrimAPI_MakePrism
+
+ half = base_radius / 2.0
+
+ # Build orthonormal basis from normal vector
+ if isinstance(normal_vec, gp_Vec):
+ n_dir = gp_Dir(normal_vec.XYZ())
+ else:
+ n_dir = normal_vec
+
+ # Create a local coordinate system at the base point
+ local_ax2 = gp_Ax2(base_point, n_dir)
+
+ # Get X and Y axes from the local coordinate system
+ x_dir = local_ax2.XDirection()
+ y_dir = local_ax2.YDirection()
+
+ # Create 4 corners of the square base in the local plane
+ corner_points = [
+ base_point + gp_Vec(x_dir).Multiplied(half) + gp_Vec(y_dir).Multiplied(half),
+ base_point + gp_Vec(x_dir).Multiplied(-half) + gp_Vec(y_dir).Multiplied(half),
+ base_point + gp_Vec(x_dir).Multiplied(-half) + gp_Vec(y_dir).Multiplied(-half),
+ base_point + gp_Vec(x_dir).Multiplied(half) + gp_Vec(y_dir).Multiplied(-half),
+ ]
+
+ # Create edges connecting the corners
+ wire_maker = BRepBuilderAPI_MakeWire()
+ for idx in range(4):
+ next_idx = (idx + 1) % 4
+ edge = BRepBuilderAPI_MakeEdge(
+ corner_points[idx], corner_points[next_idx]
+ ).Edge()
+ wire_maker.Add(edge)
+
+ if not wire_maker.IsDone():
+ logger.warning("Failed to create pyramid base wire")
+ return None
+
+ # Extrude the base wire in the normal direction by height to form a prism
+ extrusion_vec = gp_Vec(n_dir).Multiplied(height)
+ prism_maker = BRepPrimAPI_MakePrism(
+ wire_maker.Wire(), extrusion_vec, False # no check intersection
+ )
+ prism_maker.Build()
+
+ if not prism_maker.IsDone():
+ logger.warning("Failed to create pyramid prism")
+ return None
+
+ return prism_maker.Shape()
+
+ except Exception as e:
+ logger.debug(f"Error creating solid pyramid: {e}")
+ return None
+
+ def apply_bump_pattern(
+ self,
+ face_shape,
+ bump_height: float = 1.0,
+ bump_radius: float = 2.0,
+ spacing: float = 5.0,
+ num_rings: Optional[int] = None,
+ ) -> Optional[Any]:
+ """Apply a simple bump pattern to a face surface.
+
+ Args:
+ face_shape: OCC TopoDS_Shape representing the face
+ bump_height: Height of each bump
+ bump_radius: Radius of each bump base
+ spacing: Distance between bumps
+ num_rings: Number of concentric rings
+
+ Returns:
+ Modified shape on success, None on failure
+ """
+ return self.apply_pyramid_pattern(
+ face_shape,
+ pyramid_height=bump_height,
+ base_radius=bump_radius,
+ spacing=spacing,
+ num_rings=num_rings,
+ )
+
+
+def apply_surface_modifier_to_body(
+ body_geometry, modifier_type: str = "pyramid", **parameters
+) -> Optional[Any]:
+ """Apply a surface modifier to a body geometry.
+
+ Args:
+ body_geometry: OCCGeometryObject or similar geometry object
+ modifier_type: Type of modifier ('pyramid', 'bump')
+ **parameters: Modifier-specific parameters
+
+ Returns:
+ Modified shape, or None on failure
+ """
+ from fluency.geometry_occ.kernel import OCGeometryKernel
+
+ kernel = OCGeometryKernel()
+ shape = kernel._get_shape(body_geometry)
+
+ if shape is None:
+ logger.error("No geometry found in body")
+ return None
+
+ modifier = SurfaceModifier()
+
+ try:
+ if modifier_type == "pyramid":
+ success = modifier.apply_pyramid_pattern(shape, **parameters)
+ elif modifier_type == "bump":
+ success = modifier.apply_bump_pattern(shape, **parameters)
+ else:
+ logger.error(f"Unknown modifier type: {modifier_type}")
+ return None
+
+ if not success:
+ logger.error("Surface modifier application failed")
+ return None
+
+ # Return the modified shape wrapped in OCCGeometryObject
+ from fluency.geometry_occ.kernel import OCCGeometryObject
+
+ return OCCGeometryObject(shape)
+
+ except Exception as e:
+ logger.error(f"Error applying surface modifier: {e}", exc_info=True)
+ return None
+
+
+# Example usage and testing
+if __name__ == "__main__":
+ # Create a simple test case
+ from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox
+
+ # Create a box to modify
+ box_maker = BRepPrimAPI_MakeBox(50, 50, 10)
+ box_maker.Build()
+
+ if box_maker.IsDone():
+ print("Created test box")
+
+ # Apply pyramid pattern to top face (Z direction)
+ modifier = SurfaceModifier()
+ success = modifier.apply_pyramid_pattern(
+ box_maker.Shape(),
+ pyramid_height=2.0,
+ base_radius=3.0,
+ spacing=8.0,
+ num_rings=2,
+ direction=(0, 0, 1),
+ )
+
+ if success:
+ print("Successfully applied pyramid pattern")
+
+ # Export modified shape
+ from OCP.StlAPI import StlAPI_Writer
+ from OCP.BRepMesh import BRepMesh_IncrementalMesh
+
+ tess = BRepMesh_IncrementalMesh(box_maker.Shape(), 0.1)
+ tess.Perform()
+
+ writer = StlAPI_Writer()
+ writer.SetASCIIMode(False)
+ writer.Write(box_maker.Shape(), "/tmp/test_pyramid_pattern.stl")
+ print("Exported modified shape to STL")
+ else:
+ print("Failed to apply pyramid pattern")
+ else:
+ print("Failed to create test box")
diff --git a/src/fluency/io/project_io.py b/src/fluency/io/project_io.py
index 1b36b44..8557b1b 100644
--- a/src/fluency/io/project_io.py
+++ b/src/fluency/io/project_io.py
@@ -177,6 +177,7 @@ def _body_to_dict(body: Body) -> Dict[str, Any]:
"extrude_cut": body.extrude_cut,
"extrude_union": body.extrude_union,
"extrude_through_all": body.extrude_through_all,
+ "extrude_cut_all_bodies": body.extrude_cut_all_bodies,
"extrude_face_index": body.extrude_face_index,
"extrude_target_body_id": body.extrude_target_body_id,
"position": _coerce_listlike(body.position),
@@ -212,6 +213,7 @@ def _body_from_dict(
extrude_cut=bool(data.get("extrude_cut", False)),
extrude_union=bool(data.get("extrude_union", False)),
extrude_through_all=bool(data.get("extrude_through_all", False)),
+ extrude_cut_all_bodies=bool(data.get("extrude_cut_all_bodies", False)),
extrude_face_index=data.get("extrude_face_index"),
extrude_target_body_id=data.get("extrude_target_body_id"),
position=_to_3vec(data.get("position")),
@@ -454,7 +456,7 @@ def _assembly_from_dict(data: Dict[str, Any]) -> Assembly:
asm.modified_at = _parse_iso(data.get("modified_at"))
for cid, ac_data in (data.get("components") or {}).items():
asm.components[cid] = _assembly_component_from_dict(ac_data)
- for c_data in (data.get("connections") or []):
+ for c_data in data.get("connections") or []:
asm.connections.append(_assembly_connection_from_dict(c_data))
return asm
@@ -691,8 +693,13 @@ def load_project(filepath: str) -> Tuple[Project, Dict[str, Any]]:
sk_data["occ_sketch"] = meta.get("occ_sketch")
# Workplane fields on the sketch-level file override the
# embedded ones (source of truth lives in the sidecar).
- for k in ("workplane_origin", "workplane_normal", "workplane_x_dir",
- "is_solved", "is_fully_constrained"):
+ for k in (
+ "workplane_origin",
+ "workplane_normal",
+ "workplane_x_dir",
+ "is_solved",
+ "is_fully_constrained",
+ ):
if k in meta:
sk_data[k] = meta[k]
diff --git a/src/fluency/models/data_model.py b/src/fluency/models/data_model.py
index 6eb662e..c980462 100644
--- a/src/fluency/models/data_model.py
+++ b/src/fluency/models/data_model.py
@@ -232,6 +232,7 @@ class Body:
extrude_cut: bool = False
extrude_union: bool = False
extrude_through_all: bool = False
+ extrude_cut_all_bodies: bool = False # cut through all bodies in component
extrude_face_index: Optional[int] = None # which sketch face was selected
extrude_target_body_id: Optional[str] = None # for cut/union: target body id
needs_update: bool = False # True when source sketch changed since last extrude
diff --git a/src/fluency/ui/dialogs.py b/src/fluency/ui/dialogs.py
index e76f4b9..f3f815c 100644
--- a/src/fluency/ui/dialogs.py
+++ b/src/fluency/ui/dialogs.py
@@ -4,18 +4,13 @@ from __future__ import annotations
import logging
import math
-from typing import Any, Dict, List, Optional, Tuple
+from typing import Tuple
-from PySide6.QtCore import Qt, QPoint, QPointF
-from PySide6.QtGui import QColor, QFont, QKeySequence
from PySide6.QtWidgets import (
QButtonGroup,
QCheckBox,
- QComboBox,
QDialog,
- QDialogButtonBox,
QDoubleSpinBox,
- QFormLayout,
QFrame,
QGridLayout,
QHBoxLayout,
@@ -24,11 +19,11 @@ from PySide6.QtWidgets import (
QPushButton,
QRadioButton,
QVBoxLayout,
- QWidget,
)
logger = logging.getLogger(__name__)
+
class ExtrudeDialog(QDialog):
"""Dialog for extrude options.
@@ -76,6 +71,13 @@ class ExtrudeDialog(QDialog):
)
layout.addWidget(self.through_all_checkbox)
+ self.cut_all_bodies_checkbox = QCheckBox("Cut All Bodies")
+ self.cut_all_bodies_checkbox.setToolTip(
+ "Apply the boolean cut to every body in the current component, "
+ "not just the one the sketch was drawn on. Requires Perform Cut."
+ )
+ layout.addWidget(self.cut_all_bodies_checkbox)
+
self.rounded_checkbox = QCheckBox("Round Edges")
layout.addWidget(self.rounded_checkbox)
@@ -103,6 +105,7 @@ class ExtrudeDialog(QDialog):
self.cut_checkbox,
self.union_checkbox,
self.through_all_checkbox,
+ self.cut_all_bodies_checkbox,
self.rounded_checkbox,
):
# The spinbox has valueChanged; the checkboxes have stateChanged.
@@ -144,7 +147,7 @@ class ExtrudeDialog(QDialog):
pass
super().hideEvent(event)
- def get_values(self) -> Tuple[float, bool, bool, bool, bool, bool, bool]:
+ def get_values(self) -> Tuple[float, bool, bool, bool, bool, bool, bool, bool]:
return (
self.length_input.value(),
self.symmetric_checkbox.isChecked(),
@@ -152,6 +155,7 @@ class ExtrudeDialog(QDialog):
self.cut_checkbox.isChecked(),
self.union_checkbox.isChecked(),
self.through_all_checkbox.isChecked(),
+ self.cut_all_bodies_checkbox.isChecked(),
self.rounded_checkbox.isChecked(),
)
@@ -295,12 +299,12 @@ class WorkplaneOrientationDialog(QDialog):
self._preset_group = QButtonGroup(self)
preset_layout = QGridLayout()
presets = [
- ("XY (Top)", (0, 0, 1), (1, 0, 0)),
- ("XZ (Front)", (0, 1, 0), (1, 0, 0)),
- ("YZ (Right)", (1, 0, 0), (0, 1, 0)),
+ ("XY (Top)", (0, 0, 1), (1, 0, 0)),
+ ("XZ (Front)", (0, 1, 0), (1, 0, 0)),
+ ("YZ (Right)", (1, 0, 0), (0, 1, 0)),
("-XY (Bottom)", (0, 0, -1), (1, 0, 0)),
- ("-XZ (Back)", (0, -1, 0), (1, 0, 0)),
- ("-YZ (Left)", (-1, 0, 0), (0, 1, 0)),
+ ("-XZ (Back)", (0, -1, 0), (1, 0, 0)),
+ ("-YZ (Left)", (-1, 0, 0), (0, 1, 0)),
]
for idx, (label, normal, x_dir) in enumerate(presets):
btn = QRadioButton(label)
@@ -404,7 +408,6 @@ class WorkplaneOrientationDialog(QDialog):
def _on_ok(self):
"""Compute the final orientation and accept."""
import numpy as np
- import math
if self._custom_radio.isChecked():
# Custom: start from XY normal and rotate by the two angles.
@@ -413,18 +416,22 @@ class WorkplaneOrientationDialog(QDialog):
# Start from +Z normal, rotate around X then Y
n = np.array([0.0, 0.0, 1.0])
# Rotate around X
- rx = np.array([
- [1, 0, 0],
- [0, math.cos(ax), -math.sin(ax)],
- [0, math.sin(ax), math.cos(ax)],
- ])
+ rx = np.array(
+ [
+ [1, 0, 0],
+ [0, math.cos(ax), -math.sin(ax)],
+ [0, math.sin(ax), math.cos(ax)],
+ ]
+ )
n = rx @ n
# Rotate around Y
- ry = np.array([
- [math.cos(ay), 0, math.sin(ay)],
- [0, 1, 0],
- [-math.sin(ay), 0, math.cos(ay)],
- ])
+ ry = np.array(
+ [
+ [math.cos(ay), 0, math.sin(ay)],
+ [0, 1, 0],
+ [-math.sin(ay), 0, math.cos(ay)],
+ ]
+ )
n = ry @ n
n = n / np.linalg.norm(n)
# x_dir: cross product of normal with world Y, or world Z if normal ~ Y
@@ -454,23 +461,26 @@ class WorkplaneOrientationDialog(QDialog):
whether called before or after ``_on_ok``.
"""
import numpy as np
- import math
if self._custom_radio.isChecked():
ax = math.radians(self._angle_x.value())
ay = math.radians(self._angle_y.value())
n = np.array([0.0, 0.0, 1.0])
- rx = np.array([
- [1, 0, 0],
- [0, math.cos(ax), -math.sin(ax)],
- [0, math.sin(ax), math.cos(ax)],
- ])
+ rx = np.array(
+ [
+ [1, 0, 0],
+ [0, math.cos(ax), -math.sin(ax)],
+ [0, math.sin(ax), math.cos(ax)],
+ ]
+ )
n = rx @ n
- ry = np.array([
- [math.cos(ay), 0, math.sin(ay)],
- [0, 1, 0],
- [-math.sin(ay), 0, math.cos(ay)],
- ])
+ ry = np.array(
+ [
+ [math.cos(ay), 0, math.sin(ay)],
+ [0, 1, 0],
+ [-math.sin(ay), 0, math.cos(ay)],
+ ]
+ )
n = ry @ n
n = n / np.linalg.norm(n)
world_y = np.array([0.0, 1.0, 0.0])
@@ -492,6 +502,8 @@ class WorkplaneOrientationDialog(QDialog):
if btn is not None:
return (btn.normal, btn.x_dir, self._name_input.text().strip() or "Workplane")
# Fallback: XY default.
- return ((0.0, 0.0, 1.0), (1.0, 0.0, 0.0), self._name_input.text().strip() or "Workplane")
-
-
+ return (
+ (0.0, 0.0, 1.0),
+ (1.0, 0.0, 0.0),
+ self._name_input.text().strip() or "Workplane",
+ )
diff --git a/src/fluency/ui/main_window.py b/src/fluency/ui/main_window.py
index 52cc57c..34e3b8f 100644
--- a/src/fluency/ui/main_window.py
+++ b/src/fluency/ui/main_window.py
@@ -183,6 +183,157 @@ def _make_body_thumbnail(
return None
+def _make_component_thumbnail(
+ component,
+ kernel,
+ size: QSize = QSize(96, 96),
+):
+ """Render a small isometric thumbnail of all bodies in a component.
+
+ Combines the meshes of all visible bodies and renders them together.
+ Returns a QPixmap or None on failure.
+ """
+ try:
+ import numpy as np
+ from PIL import Image, ImageDraw
+ from PySide6.QtGui import QImage, QPixmap
+
+ # Collect meshes from all visible bodies with geometry
+ all_verts = []
+ all_faces = []
+ vertex_offset = 0
+
+ for body in component.bodies.values():
+ if not body.visible or not body.geometry:
+ continue
+ verts, faces = body.get_mesh(kernel)
+ if verts is None or len(verts) == 0:
+ continue
+
+ verts = np.asarray(verts, dtype=np.float64)
+ faces = np.asarray(faces, dtype=np.int32)
+
+ # Apply body transform
+ if hasattr(body, "position") and body.position is not None:
+ if hasattr(body, "rotation") and body.rotation is not None:
+ verts = verts @ body.rotation.T
+ verts = verts + body.position
+
+ all_verts.append(verts)
+ all_faces.append(faces + vertex_offset)
+ vertex_offset += len(verts)
+
+ if not all_verts:
+ return None
+
+ verts = np.concatenate(all_verts, axis=0)
+ faces = np.concatenate(all_faces, axis=0)
+
+ mins = verts.min(axis=0)
+ maxs = verts.max(axis=0)
+ center = (mins + maxs) / 2
+ extent = maxs - mins
+ max_dim = max(extent)
+ if max_dim < 1e-10:
+ return None
+
+ # Normalize vertices to [-1, 1] range centered at origin
+ v = (verts - center) / (max_dim * 0.7)
+
+ # Simple isometric projection (rotation + orthographic)
+ angle_y = np.radians(-45)
+ angle_x = np.radians(25)
+ cos_y, sin_y = np.cos(angle_y), np.sin(angle_y)
+ cos_x, sin_x = np.cos(angle_x), np.sin(angle_x)
+
+ # Rotate Y
+ x1 = v[:, 0] * cos_y - v[:, 2] * sin_y
+ z1 = v[:, 0] * sin_y + v[:, 2] * cos_y
+ y1 = v[:, 1]
+
+ # Rotate X
+ y2 = y1 * cos_x - z1 * sin_x
+ z2 = y1 * sin_x + z1 * cos_x
+ x2 = x1
+
+ # Project to 2D (orthographic)
+ w_px, h_px = size.width(), size.height()
+
+ # Compute 2D bounding box of projected vertices for tight framing
+ all_px = x2
+ all_py = -y2
+ px_min, px_max = all_px.min(), all_px.max()
+ py_min, py_max = all_py.min(), all_py.max()
+ span_x = px_max - px_min
+ span_y = py_max - py_min
+ if span_x < 1e-10 or span_y < 1e-10:
+ return None
+
+ # Scale to fill ~90% of the image
+ margin = 0.10
+ avail_w = w_px * (1.0 - margin)
+ avail_h = h_px * (1.0 - margin)
+ scale = min(avail_w / span_x, avail_h / span_y)
+
+ center_x = (px_min + px_max) / 2
+ center_y = (py_min + py_max) / 2
+ px = (all_px * scale + w_px / 2 - center_x * scale).astype(np.float64)
+ py = (all_py * scale + h_px / 2 - center_y * scale).astype(np.float64)
+ depth = z2 # for painter's algorithm
+
+ # Dark grey background
+ img = Image.new("RGBA", (w_px, h_px), (55, 55, 60, 255))
+ draw = ImageDraw.Draw(img)
+
+ # Compute face normals for backface culling & shading
+ v0 = np.stack([px[faces[:, 0]], py[faces[:, 0]], depth[faces[:, 0]]], axis=1)
+ v1 = np.stack([px[faces[:, 1]], py[faces[:, 1]], depth[faces[:, 1]]], axis=1)
+ v2 = np.stack([px[faces[:, 2]], py[faces[:, 2]], depth[faces[:, 2]]], axis=1)
+
+ # 2D cross product for winding
+ cross = (v1[:, 0] - v0[:, 0]) * (v2[:, 1] - v0[:, 1]) - (v1[:, 1] - v0[:, 1]) * (
+ v2[:, 0] - v0[:, 0]
+ )
+
+ # Average depth per face
+ avg_depth = (v0[:, 2] + v1[:, 2] + v2[:, 2]) / 3.0
+
+ # Sort faces by depth (painter's algorithm: draw far faces first)
+ order = np.argsort(-avg_depth)
+
+ # Ceramic white body with shading
+ base_r, base_g, base_b = 220, 218, 215
+
+ for i in order:
+ # Backface culling
+ if cross[i] <= 0:
+ continue
+
+ pts = [
+ (float(px[faces[i, 0]]), float(py[faces[i, 0]])),
+ (float(px[faces[i, 1]]), float(py[faces[i, 1]])),
+ (float(px[faces[i, 2]]), float(py[faces[i, 2]])),
+ ]
+
+ # Shading: stronger contrast for depth perception
+ brightness = 0.5 + 0.5 * max(0.0, min(1.0, (avg_depth[i] + 1) / 2))
+ r = int(base_r * brightness)
+ g = int(base_g * brightness)
+ b = int(base_b * brightness)
+
+ draw.polygon(pts, fill=(r, g, b, 255))
+
+ # Convert PIL image to QPixmap
+ data = img.tobytes("raw", "RGBA")
+ qimg = QImage(data, w_px, h_px, w_px * 4, QImage.Format_RGBA8888)
+ pixmap = QPixmap.fromImage(qimg.copy())
+ return pixmap
+
+ except Exception as e:
+ logger.debug(f"Component thumbnail generation failed: {e}")
+ return None
+
+
# ── Button sizing & styling constants ────────────────────────────────
_BTN_MIN = 40 # minimum button dimension (px)
_BTN_MAX = 160 # maximum button dimension (px)
@@ -246,7 +397,7 @@ def _scroll_to_button(btn: QPushButton, scroll_area: QScrollArea) -> None:
def _create_component_button(
num: int,
name: str,
- body,
+ component,
kernel,
group: QButtonGroup,
layout: QHBoxLayout,
@@ -261,11 +412,14 @@ def _create_component_button(
btn.clicked.connect(click_handler)
_set_button_style(btn)
- pixmap = _make_body_thumbnail(body, kernel, QSize(96, 96))
- if pixmap is not None:
- btn.setIcon(pixmap)
- btn.setIconSize(QSize(96, 96))
- btn.setText("")
+ # Render thumbnail from all bodies in the component
+ has_geometry = any(b.visible and b.geometry for b in component.bodies.values())
+ if has_geometry:
+ pixmap = _make_component_thumbnail(component, kernel, QSize(96, 96))
+ if pixmap is not None:
+ btn.setIcon(pixmap)
+ btn.setIconSize(QSize(96, 96))
+ btn.setText("")
group.addButton(btn)
layout.addWidget(btn)
@@ -727,7 +881,7 @@ class MainWindow(QMainWindow):
self._btn_del_sketch = ui.pb_del_sketch
# ── Body tools ──
self._btn_update_body = ui.pb_update_body
- self._btn_edit_sketch_3 = ui.pb_edt_sktch_3
+ self._btn_body_hide = ui.pb_body_hide
self._btn_del_body = ui.pb_del_body
# ── Component tools ──
self._btn_new_compo = ui.pb_new_compo
@@ -891,7 +1045,10 @@ class MainWindow(QMainWindow):
self._btn_move.clicked.connect(self._translate_body)
self._btn_array.clicked.connect(self._pattern_array)
self._btn_offset.clicked.connect(self._offset_sketch)
- self._btn_edit_sketch_3.clicked.connect(self._edit_sketch)
+ # Per-body hide/show toggle: the user clicks pb_body_hide next
+ # to a body name in the right-hand list. We update the body's
+ # ``visible`` flag and ask the viewer to show/hide the mesh.
+ self._btn_body_hide.clicked.connect(self._on_body_hide_button_clicked)
# Snap toggle
self._btn_snap.clicked.connect(lambda c: self._sketch_widget.set_snap_mode("point", c))
@@ -998,11 +1155,12 @@ class MainWindow(QMainWindow):
if component_index >= len(comp_ids):
return
comp = self._project.components[comp_ids[component_index]]
- first_body = next(iter(comp.bodies.values()), None)
- if not first_body or not first_body.geometry:
+ # Check if component has any bodies with geometry
+ has_geometry = any(b.visible and b.geometry for b in comp.bodies.values())
+ if not has_geometry:
return
btn = self._component_buttons[component_index]
- pixmap = _make_body_thumbnail(first_body, self._kernel, QSize(96, 96))
+ pixmap = _make_component_thumbnail(comp, self._kernel, QSize(96, 96))
if pixmap is not None:
btn.setIcon(pixmap)
btn.setIconSize(QSize(96, 96))
@@ -1076,18 +1234,13 @@ class MainWindow(QMainWindow):
self._sketch_list.addItem(sketch.name)
for body_id, body in self._current_component.bodies.items():
- # QListWidgetItem with a checkbox so the user can toggle
- # each body's visibility in the 3D viewer. The item's
- # data role stores the body id so the toggle handler can
+ # QListWidgetItem with a data role so the toggle handler can
# look up the right body without relying on display text.
display_name = body.name
if body.needs_update:
display_name = f"⚠ {body.name}"
item = QListWidgetItem(display_name)
item.setData(Qt.UserRole, body_id)
- # Qt.Checked = visible, Qt.Unchecked = hidden. Default
- # is whatever the body model says.
- item.setCheckState(Qt.Checked if body.visible else Qt.Unchecked)
# Greying out a hidden body's name is a nice UX touch.
if not body.visible:
item.setForeground(QColor("#6c7086"))
@@ -1103,6 +1256,8 @@ class MainWindow(QMainWindow):
changes to all assembly instances, and recalculates connectors.
"""
self._update_bodies_from_sketch()
+ self._update_sketches_from_bodies()
+ self._update_bodies_from_sketch() # re-extrude bodies whose sketches just moved
self._redraw_bodies()
self._propagate_to_assembly()
self._recalculate_connectors()
@@ -1172,6 +1327,49 @@ class MainWindow(QMainWindow):
target = b
break
+ # Handle cut_all_bodies: apply the cut to every body in the
+ # component, not just the target.
+ if body.extrude_cut and body.extrude_cut_all_bodies:
+ try:
+ if body.extrude_through_all and target is not None:
+ cut_length = self._through_all_length(target, sketch)
+ cut_symmetric = True
+ cut_invert = False
+ else:
+ cut_length = body.extrude_length or 10.0
+ cut_symmetric = body.extrude_symmetric
+ cut_invert = body.extrude_invert
+ tool_geom = self._kernel.extrude(
+ face_geom,
+ -cut_length if cut_invert else cut_length,
+ symmetric=cut_symmetric,
+ )
+ if tool_geom is None:
+ logger.warning(f"Body '{body.name}': cut-all tool geometry is empty")
+ continue
+ cut_count = 0
+ for other_id, other in list(self._current_component.bodies.items()):
+ if other.geometry is None:
+ continue
+ try:
+ other.geometry = self._kernel.boolean_difference(
+ other.geometry, tool_geom
+ )
+ other.needs_update = False
+ other.modified_at = datetime.now()
+ cut_count += 1
+ except Exception:
+ pass # body doesn't intersect tool, skip
+ body.needs_update = False
+ body.modified_at = datetime.now()
+ updated += 1
+ logger.info(
+ f"Re-extruded cut-all body '{body.name}': cut {cut_count} body(ies)"
+ )
+ except Exception as exc:
+ logger.exception(f"Body '{body.name}': re-extrude cut-all failed: {exc}")
+ continue # skip the single-target path below
+
# Compute the new result.
try:
if body.extrude_through_all and target is not None:
@@ -1215,6 +1413,87 @@ class MainWindow(QMainWindow):
if updated > 0:
logger.info(f"Updated {updated} body(ies) from sketch")
+ def _update_sketches_from_bodies(self) -> None:
+ """Re-project underlay construction lines from updated 3D bodies.
+
+ For every sketch in the current component that carries a
+ ``_source_face`` (a face-projected underlay) and a
+ ``_source_body_id``, find the corresponding face on the updated
+ body geometry, re-project its edges to UV, and update the sketch's
+ external entities *in place* (preserving entity ids so existing
+ constraints survive). The solver is re-run so any user geometry
+ anchored to the underlay follows the body.
+ """
+ if not self._current_component:
+ return
+ from fluency.geometry_occ.kernel import OCGeometryKernel
+
+ kernel = OCGeometryKernel()
+ updated = 0
+ for sketch in self._current_component.sketches.values():
+ src_body_id = getattr(sketch, "_source_body_id", None)
+ src_face = getattr(sketch, "_source_face", None)
+ if src_body_id is None or src_face is None:
+ continue
+ if sketch.occ_sketch is None:
+ continue
+ body = self._current_component.bodies.get(src_body_id)
+ if body is None or body.geometry is None:
+ continue
+ body_shape = kernel._get_shape(body.geometry)
+ if body_shape is None:
+ continue
+ # Find the face on the updated body that matches the original
+ # face's plane (normal parallel, origin coplanar).
+ wp = sketch.occ_sketch.get_workplane()
+ origin, normal = wp[0], wp[1]
+ ref_center = getattr(sketch, "_source_face_center", None)
+ match = OCGeometryKernel.find_coplanar_face(
+ body_shape,
+ origin,
+ normal,
+ ref_center=ref_center,
+ )
+ if match is None:
+ logger.debug(
+ "Sketch '%s': no matching face on body '%s', skipping",
+ sketch.name,
+ body.name,
+ )
+ continue
+ new_face, new_center = match
+ sketch._source_face = new_face
+ sketch._source_face_center = new_center
+ # Re-project the new face's edges into UV.
+ from fluency.ui.sketch_widget import _project_face_to_uv
+
+ try:
+ polys = _project_face_to_uv(new_face, wp)
+ except Exception as exc:
+ logger.debug("re-projection failed for sketch '%s': %s", sketch.name, exc)
+ continue
+ if not polys:
+ continue
+ # Update external entities in-place (preserves ids + constraints).
+ ok = sketch.occ_sketch.update_external_entities(polys)
+ if ok:
+ updated += 1
+ logger.info(
+ "Re-projected underlay for sketch '%s' from body '%s'",
+ sketch.name,
+ body.name,
+ )
+ # If the sketch is currently loaded in the widget, refresh
+ # the underlay data so the view reflects the new projection
+ # WITHOUT re-importing (which would break constraints).
+ if sketch.occ_sketch is self._sketch_widget._sketch:
+ self._sketch_widget._source_face = new_face
+ self._sketch_widget._source_underlay_uv = polys
+ self._sketch_widget._rebuild_from_sketch()
+ self._sketch_widget.update()
+ if updated > 0:
+ logger.info("Re-projected underlays for %d sketch(es)", updated)
+
def _propagate_to_assembly(self):
"""Refresh all assembly instances that reference the current component.
@@ -1390,12 +1669,12 @@ class MainWindow(QMainWindow):
btn.clicked.connect(self._on_assembly_component_clicked)
_set_button_style(btn)
- # Thumbnail from the component's first body.
+ # Thumbnail from all bodies in the component.
src_comp = self._current_component
if src_comp:
- first_body = next(iter(src_comp.bodies.values()), None)
- if first_body and first_body.geometry:
- pixmap = _make_body_thumbnail(first_body, self._kernel, QSize(96, 96))
+ has_geometry = any(b.visible and b.geometry for b in src_comp.bodies.values())
+ if has_geometry:
+ pixmap = _make_component_thumbnail(src_comp, self._kernel, QSize(96, 96))
if pixmap is not None:
btn.setIcon(pixmap)
btn.setIconSize(QSize(96, 96))
@@ -3225,6 +3504,17 @@ class MainWindow(QMainWindow):
sketch.set_workplane(origin, normal, x_dir)
# Keep the face reference for the projection underlay (Phase 3).
sketch._source_face = face_shape
+ # Store the face centroid for re-matching when the body updates.
+ try:
+ from OCP.BRepGProp import BRepGProp
+ from OCP.GProp import GProp_GProps
+
+ props = GProp_GProps()
+ BRepGProp.VolumeProperties_s(face_shape, props)
+ c = props.CentreOfMass()
+ sketch._source_face_center = (float(c.X()), float(c.Y()), float(c.Z()))
+ except Exception:
+ sketch._source_face_center = tuple(float(v) for v in origin)
# Remember which body the sketch lives on so a later cut / combine
# extrude auto-targets it. ``source_body`` may be None if the
# pick landed on an untracked shape (e.g. an imported STEP that
@@ -3452,11 +3742,9 @@ class MainWindow(QMainWindow):
break
def _on_body_visibility_changed(self, item: QListWidgetItem) -> None:
- """Toggle a body's 3D visibility when the user flips its checkbox.
+ """Toggle a body's 3D visibility when the user clicks pb_body_hide.
- itemChanged also fires for selection (not just check-state) changes,
- so we filter on the check state being the changed role. The body
- is looked up via the UserRole data we set in _refresh_lists.
+ The body is looked up via the UserRole data we set in _refresh_lists.
"""
if self._current_component is None:
return
@@ -3466,16 +3754,11 @@ class MainWindow(QMainWindow):
body = self._current_component.bodies.get(body_id)
if body is None:
return
- new_visible = item.checkState() == Qt.Checked
+ new_visible = not body.visible # toggle
if body.visible == new_visible:
return # no change
body.visible = new_visible
- # Greying out hidden bodies gives a quick visual hint in the list.
item.setForeground(QColor("#1e1e2e") if new_visible else QColor("#6c7086"))
- # Apply to the 3D viewer: if the body has a rendered object, hide
- # or show it. Bodies without a render_object (e.g. just-created,
- # not yet displayed) don't need viewer updates; they'll pick up
- # the visibility at the next redraw.
if body.render_object is not None:
ok = self._viewer_3d.set_visibility(body.render_object, new_visible)
if not ok:
@@ -3486,6 +3769,12 @@ class MainWindow(QMainWindow):
)
logger.info(f"{'Visible' if new_visible else 'Hidden'}: {body.name}")
+ def _on_body_hide_button_clicked(self) -> None:
+ """Handle click on pb_body_hide button - toggle visibility of selected body."""
+ current_item = self._body_list.currentItem()
+ if current_item is not None:
+ self._on_body_visibility_changed(current_item)
+
# ─── Extrude / cut helpers (shared by live preview + apply) ────────
def _resolve_extrude_target(
@@ -3558,6 +3847,7 @@ class MainWindow(QMainWindow):
cut: bool,
union: bool,
through_all: bool,
+ cut_all_bodies: bool = False,
) -> Optional[Dict[str, Any]]:
"""Compute the *previewable* result of an extrude/cut/union.
@@ -3566,6 +3856,7 @@ class MainWindow(QMainWindow):
- "target_body": the Body being modified (None for plain extrude)
- "tool_geom": the extruded profile geometry (the boolean tool)
- "tool_shape": same, as a TopoDS_Shape (for show/remove)
+ - "all_targets": list of all bodies affected when cut_all_bodies
Or *None* if the geometry can't be built (e.g. empty sketch).
Mutates nothing on the project — safe to call repeatedly for the
@@ -3577,6 +3868,15 @@ class MainWindow(QMainWindow):
return None
# Resolve target (only meaningful for cut / union).
target = self._resolve_extrude_target(sketch) if (cut or union) else None
+ # When cut_all_bodies, collect all bodies in the component as targets.
+ all_targets: list = []
+ if cut_all_bodies and cut and self._current_component is not None:
+ all_targets = [
+ b for b in self._current_component.bodies.values() if b.geometry is not None
+ ]
+ # Use the first non-tool body as the primary target for preview.
+ if target is None and all_targets:
+ target = all_targets[0]
# Determine the extrude length and direction.
if through_all and target is not None:
# Pass-through: symmetric extrude large enough to clear the body
@@ -3617,6 +3917,7 @@ class MainWindow(QMainWindow):
"target_body": target,
"tool_geom": tool_geom,
"tool_shape": tool_shape,
+ "all_targets": all_targets,
}
# Plain extrude: the tool IS the result.
return {
@@ -3625,6 +3926,7 @@ class MainWindow(QMainWindow):
"target_body": None,
"tool_geom": tool_geom,
"tool_shape": tool_shape,
+ "all_targets": [],
}
def _start_extrude_preview(self, dialog: ExtrudeDialog, sketch: Sketch, face_geom: Any) -> None:
@@ -3639,17 +3941,24 @@ class MainWindow(QMainWindow):
# which case we leave them alone).
state = {"dimmed": []}
- def _apply_dim(target: Optional[Body]):
- # Undo any prior dim, then dim the new target.
- for bid, tval in state["dimmed"]:
+ def _apply_dim(targets):
+ """Dim one or more bodies for preview clarity."""
+ # Undo any prior dim.
+ for bid, _tval in state["dimmed"]:
body = self._current_component.bodies.get(bid) if self._current_component else None
if body is not None and body.render_object is not None:
self._viewer_3d.set_transparency(body.render_object, 0.0)
state["dimmed"].clear()
- if target is not None and target.render_object is not None:
- ok = self._viewer_3d.set_transparency(target.render_object, 0.6)
- if ok:
- state["dimmed"].append((target.id, 0.6))
+ # Normalize to a list.
+ if targets is None:
+ targets = []
+ elif isinstance(targets, Body):
+ targets = [targets]
+ for t in targets:
+ if t is not None and t.render_object is not None:
+ ok = self._viewer_3d.set_transparency(t.render_object, 0.6)
+ if ok:
+ state["dimmed"].append((t.id, 0.6))
def _clear():
self._viewer_3d.clear_preview()
@@ -3663,7 +3972,7 @@ class MainWindow(QMainWindow):
if values is None:
_clear()
return
- length, symmetric, invert, cut, union, through_all, _rounded = values
+ length, symmetric, invert, cut, union, through_all, cut_all_bodies, _rounded = values
result = self._compute_extrude_result(
sketch,
face_geom,
@@ -3673,13 +3982,19 @@ class MainWindow(QMainWindow):
bool(cut),
bool(union),
bool(through_all),
+ cut_all_bodies=bool(cut_all_bodies),
)
if result is None or result["result_shape"] is None:
self._viewer_3d.clear_preview()
_apply_dim(None)
return
self._viewer_3d.show_preview(result["result_shape"])
- _apply_dim(result["target_body"])
+ # Dim all affected bodies when cut_all_bodies is active.
+ all_targets = result.get("all_targets", [])
+ if all_targets:
+ _apply_dim(all_targets)
+ else:
+ _apply_dim(result["target_body"])
dialog.set_preview_callback(_callback)
@@ -3731,10 +4046,13 @@ class MainWindow(QMainWindow):
logger.info("Extrude dialog cancelled")
return
- length, symmetric, invert, cut, union, through_all, rounded = dialog.get_values()
+ length, symmetric, invert, cut, union, through_all, cut_all_bodies, rounded = (
+ dialog.get_values()
+ )
logger.info(
f"Extrude params: length={length}, symmetric={symmetric}, "
- f"invert={invert}, cut={cut}, union={union}, through_all={through_all}"
+ f"invert={invert}, cut={cut}, union={union}, through_all={through_all}, "
+ f"cut_all_bodies={cut_all_bodies}"
)
# Capture the face index before the dialog closes (the selected
@@ -3751,6 +4069,7 @@ class MainWindow(QMainWindow):
bool(cut),
bool(union),
bool(through_all),
+ cut_all_bodies=bool(cut_all_bodies),
)
if result is None or result["result_geom"] is None:
logger.warning("Extrude produced no geometry")
@@ -3758,12 +4077,41 @@ class MainWindow(QMainWindow):
return
target = result["target_body"]
- if target is not None:
- # Cut / union: commit the result onto the *target* body in
- # place (don't create a separate tool body — the previous
- # implementation did, and that was the user-perceived
- # "added without cut" bug once the spurious body was
- # deleted).
+ all_targets = result.get("all_targets", [])
+
+ if target is not None and bool(cut) and all_targets:
+ # Cut all bodies: apply the boolean difference to every body
+ # in the component that has geometry.
+ tool_geom = result["tool_geom"]
+ updated_count = 0
+ for body in all_targets:
+ try:
+ new_geom = self._kernel.boolean_difference(body.geometry, tool_geom)
+ body.geometry = new_geom
+ except Exception as exc:
+ logger.debug("Cut-all: boolean failed for %s: %s", body.name, exc)
+ continue
+ body.extrude_length = length
+ body.extrude_symmetric = symmetric
+ body.extrude_invert = invert
+ body.extrude_cut = True
+ body.extrude_union = False
+ body.extrude_through_all = bool(through_all)
+ body.extrude_cut_all_bodies = True
+ body.extrude_face_index = face_index
+ body.source_sketch = sketch
+ body.source_operation = "cut"
+ body.extrude_target_body_id = body.id
+ if body.render_object is not None:
+ self._viewer_3d.remove_mesh(body.render_object)
+ shape = self._kernel._get_shape(body.geometry)
+ body.render_object = self._viewer_3d.show_shape(shape, body.color, body.name)
+ updated_count += 1
+ logger.info(f"Cut-all applied to {updated_count} body(ies)")
+ body_name = f"{updated_count} body(ies)"
+ elif target is not None:
+ # Single-body cut / union: commit the result onto the *target*
+ # body in place.
target.geometry = result["result_geom"]
# Store extrude params so the body can be rebuilt later.
target.extrude_length = length
@@ -3772,6 +4120,7 @@ class MainWindow(QMainWindow):
target.extrude_cut = bool(cut)
target.extrude_union = bool(union)
target.extrude_through_all = bool(through_all)
+ target.extrude_cut_all_bodies = False
target.extrude_face_index = face_index
target.source_sketch = sketch
target.source_operation = "cut" if cut else "union"
@@ -3797,6 +4146,7 @@ class MainWindow(QMainWindow):
extrude_cut=False,
extrude_union=False,
extrude_through_all=bool(through_all),
+ extrude_cut_all_bodies=False,
extrude_face_index=face_index,
)
)
@@ -4356,12 +4706,12 @@ class MainWindow(QMainWindow):
# Rebuild component buttons (one per component, with thumbnails).
for idx, comp in enumerate(self._project.components.values(), start=1):
- first_body = next(iter(comp.bodies.values()), None)
- if first_body and first_body.geometry:
+ has_geometry = any(b.visible and b.geometry for b in comp.bodies.values())
+ if has_geometry:
btn = _create_component_button(
idx,
comp.name,
- first_body,
+ comp,
self._kernel,
self._component_group,
self._component_box_layout,
@@ -4409,12 +4759,16 @@ class MainWindow(QMainWindow):
btn.clicked.connect(self._on_assembly_component_clicked)
_set_button_style(btn)
- # Thumbnail from the source component's first body.
+ # Thumbnail from the source component's all bodies.
src_comp = self._project.components.get(ac.component_id)
if src_comp:
- first_body = next(iter(src_comp.bodies.values()), None)
- if first_body and first_body.geometry:
- pixmap = _make_body_thumbnail(first_body, self._kernel, QSize(96, 96))
+ has_geometry = any(
+ b.visible and b.geometry for b in src_comp.bodies.values()
+ )
+ if has_geometry:
+ pixmap = _make_component_thumbnail(
+ src_comp, self._kernel, QSize(96, 96)
+ )
if pixmap is not None:
btn.setIcon(pixmap)
btn.setIconSize(QSize(96, 96))
@@ -4512,7 +4866,7 @@ class MainWindow(QMainWindow):
btn = _create_component_button(
btn_num,
name,
- body,
+ comp,
self._kernel,
self._component_group,
self._component_box_layout,
diff --git a/src/fluency/ui/sketch_widget.py b/src/fluency/ui/sketch_widget.py
index 1147b61..f881c2b 100644
--- a/src/fluency/ui/sketch_widget.py
+++ b/src/fluency/ui/sketch_widget.py
@@ -185,6 +185,7 @@ class Sketch2DWidget(QWidget):
# Rectangle first-click snap target (stored so the second click
# doesn't overwrite it and the correct corner gets constrained).
self._rect_first_snap_target: Optional[OCCSketchEntity] = None
+ self._rect_first_line_target: Optional[OCCSketchEntity] = None
# Offset preview state (live preview while the OffsetDialog is open).
self._offset_preview_points: List[Tuple[float, float]] = []
@@ -375,17 +376,20 @@ class Sketch2DWidget(QWidget):
# Clear any prior external entities before importing fresh ones so a
# repeated face pick doesn't pile up duplicate construction lines.
self._sketch.remove_external_entities()
+ # Import ALL polylines in one call so corners shared between edges
+ # become a single external point entity (one connection hub per
+ # corner) instead of stacked duplicates.
+ polys = [
+ [(float(u), float(v)) for (u, v) in poly]
+ for poly in self._source_underlay_uv
+ if len(poly) >= 2
+ ]
imported = 0
- for poly in self._source_underlay_uv:
- if len(poly) < 2:
- continue
- try:
- _, lines = self._sketch.add_external_polyline(
- [(float(u), float(v)) for (u, v) in poly]
- )
- imported += len(lines)
- except Exception as exc:
- logger.debug("underlay polyline import failed: %s", exc)
+ try:
+ _, lines = self._sketch.add_external_polylines(polys)
+ imported = len(lines)
+ except Exception as exc:
+ logger.debug("underlay polyline import failed: %s", exc)
logger.info("Imported %d construction-line segments from source face", imported)
# Pull the new external entities into the UI lists so they're
# snap/hover/paint targets.
@@ -560,6 +564,7 @@ class Sketch2DWidget(QWidget):
self._mode = None
self._clear_move_state()
self._rect_first_snap_target = None
+ self._rect_first_line_target = None
self.clear_offset_preview()
self.update()
@@ -597,6 +602,7 @@ class Sketch2DWidget(QWidget):
self._snap_horizontal = False
self._snap_vertical = False
self._rect_first_snap_target = None
+ self._rect_first_line_target = None
self._arc_accum_sweep = 0.0
self._arc_prev_angle = None
@@ -671,22 +677,16 @@ class Sketch2DWidget(QWidget):
# ─── Snapping ─────────────────────────────────────────────────────────
def _find_nearest_point(self, pos: QPoint, max_distance: int = 15) -> Optional[QPoint]:
- if not self._snap_mode.get("point", False):
+ # Delegates to _find_nearest_point_entity so the position snap and
+ # the constraint target ALWAYS agree — previously this loop ignored
+ # underlay visibility, so a hidden underlay corner would snap the
+ # cursor position while the entity lookup returned None and no
+ # coincident constraint was created (silent disconnect).
+ entity = self._find_nearest_point_entity(pos, max_distance)
+ if entity is None or entity.geometry is None:
return None
- nearest = None
- min_dist = max_distance
- for entity in self._points:
- if entity.geometry:
- x, y = entity.geometry
- point = QPoint(int(round(x)), int(round(y)))
- screen_point = self._world_to_screen(point)
- dist = math.sqrt(
- (pos.x() - screen_point.x()) ** 2 + (pos.y() - screen_point.y()) ** 2
- )
- if dist < min_dist:
- min_dist = dist
- nearest = point
- return nearest
+ x, y = entity.geometry
+ return QPoint(int(round(x)), int(round(y)))
def _find_nearest_point_entity(
self, pos: QPoint, max_distance: int = 15
@@ -721,6 +721,14 @@ class Sketch2DWidget(QWidget):
if not self._snap_mode.get("mpoint", False):
return None
for p1, p2 in self._lines:
+ line_ent = self._find_line_sketch_entity(p1, p2)
+ # Skip hidden underlay lines (consistent with point snap) and
+ # centerlines (their midpoint is the origin — covered by point snap).
+ if line_ent is not None and (
+ (self._is_external(line_ent) and not self._underlay_visible)
+ or self._is_centerline(line_ent)
+ ):
+ continue
if p1.geometry and p2.geometry:
x1, y1 = p1.geometry
x2, y2 = p2.geometry
@@ -731,6 +739,52 @@ class Sketch2DWidget(QWidget):
return mid
return None
+ def _find_line_snap(
+ self, pos: QPoint, max_distance: int = 15
+ ) -> Optional[Tuple[QPoint, OCCSketchEntity]]:
+ """Snap onto the nearest line (projected point on the line).
+
+ Returns ``(world_point_on_line, line_entity)`` for the closest line
+ within ``max_distance`` screen pixels, or None. External (underlay)
+ lines participate when the underlay is visible — this is what lets a
+ click anywhere along a projected construction edge anchor the new
+ point with a point-on-line constraint. Centerlines count as
+ infinite reference axes (perpendicular projection, no clamping).
+ """
+ if not self._snap_mode.get("point", False):
+ return None
+ wx, wy = self._screen_to_world_f(pos)
+ best: Optional[Tuple[QPoint, OCCSketchEntity]] = None
+ best_dist = float(max_distance)
+ for p1, p2 in self._lines:
+ line_ent = self._find_line_sketch_entity(p1, p2)
+ if line_ent is None:
+ continue
+ if self._is_external(line_ent) and not self._underlay_visible:
+ continue
+ if p1.geometry is None or p2.geometry is None:
+ continue
+ x1, y1 = p1.geometry
+ x2, y2 = p2.geometry
+ dx = x2 - x1
+ dy = y2 - y1
+ len_sq = dx * dx + dy * dy
+ if len_sq == 0:
+ continue
+ if self._is_centerline(line_ent):
+ # Infinite reference axis: unclamped perpendicular projection.
+ t = ((wx - x1) * dx + (wy - y1) * dy) / len_sq
+ else:
+ t = max(0.0, min(1.0, ((wx - x1) * dx + (wy - y1) * dy) / len_sq))
+ proj_x = x1 + t * dx
+ proj_y = y1 + t * dy
+ screen_proj = self._world_to_screen(QPoint(int(round(proj_x)), int(round(proj_y))))
+ dist = math.sqrt((pos.x() - screen_proj.x()) ** 2 + (pos.y() - screen_proj.y()) ** 2)
+ if dist < best_dist:
+ best_dist = dist
+ best = (QPoint(int(round(proj_x)), int(round(proj_y))), line_ent)
+ return best
+
def _apply_angle_snap(self, start: QPoint, end: QPoint) -> QPoint:
if not self._snap_mode.get("angle", False):
return end
@@ -771,6 +825,14 @@ class Sketch2DWidget(QWidget):
mid_snap = self._find_midpoint_snap(pos)
if mid_snap:
return self._world_to_screen(mid_snap)
+ # Line snap: click anywhere along a line (incl. projected construction
+ # edges) lands exactly on it and carries a point-on-line constraint
+ # target so the new endpoint is actually connected, not just close.
+ line_snap = self._find_line_snap(pos)
+ if line_snap is not None:
+ snap_pt, line_ent = line_snap
+ self._snap_line_target = line_ent
+ return self._world_to_screen(snap_pt)
if start:
if self._snap_mode.get("horiz", False):
horiz = self._apply_horizontal_snap(start, result)
@@ -860,6 +922,44 @@ class Sketch2DWidget(QWidget):
if nearest is not None:
return nearest
+ # Line snap (excluding lines whose both endpoints are being moved)
+ if self._snap_mode.get("point", False):
+ wx, wy = self._screen_to_world_f(pos)
+ best_line: Optional[Tuple[QPoint, OCCSketchEntity]] = None
+ best_line_dist = float(self._snap_distance)
+ for p1, p2 in self._lines:
+ if p1.id in exclude_ids and p2.id in exclude_ids:
+ continue
+ line_ent = self._find_line_sketch_entity(p1, p2)
+ if line_ent is None:
+ continue
+ if self._is_external(line_ent) and not self._underlay_visible:
+ continue
+ if p1.geometry is None or p2.geometry is None:
+ continue
+ x1, y1 = p1.geometry
+ x2, y2 = p2.geometry
+ dx = x2 - x1
+ dy = y2 - y1
+ len_sq = dx * dx + dy * dy
+ if len_sq == 0:
+ continue
+ if self._is_centerline(line_ent):
+ t = ((wx - x1) * dx + (wy - y1) * dy) / len_sq
+ else:
+ t = max(0.0, min(1.0, ((wx - x1) * dx + (wy - y1) * dy) / len_sq))
+ proj_x = x1 + t * dx
+ proj_y = y1 + t * dy
+ sp = self._world_to_screen(QPoint(int(round(proj_x)), int(round(proj_y))))
+ d = math.sqrt((pos.x() - sp.x()) ** 2 + (pos.y() - sp.y()) ** 2)
+ if d < best_line_dist:
+ best_line_dist = d
+ best_line = (QPoint(int(round(proj_x)), int(round(proj_y))), line_ent)
+ if best_line is not None:
+ snap_pt, line_ent = best_line
+ self._snap_line_target = line_ent
+ return self._world_to_screen(snap_pt)
+
# Horizontal / vertical / angle snaps are relative to the original anchor
result = pos
if self._snap_mode.get("horiz", False):
@@ -1116,6 +1216,8 @@ class Sketch2DWidget(QWidget):
fm = QFontMetrics(QFont("Monospace", 9))
# Track how many tags already share an anchor so we stack them vertically.
stack_count: Dict[Tuple[int, int], int] = {}
+ # Compute sketch centroid once so all pills stack inside the shape.
+ centroid_screen = self._sketch_centroid_screen()
for idx, entry in enumerate(self._sketch._constraint_log):
# One bad log entry (e.g. a dangling id after a delete, an
@@ -1128,6 +1230,11 @@ class Sketch2DWidget(QWidget):
params = entry["params"]
anchor: Optional[QPoint] = None
label = ""
+ # World-space endpoints for distance dimension lines.
+ # Initialised every iteration so stale values from a prior
+ # distance constraint are never accidentally reused.
+ tag_p1: Optional[QPoint] = None
+ tag_p2: Optional[QPoint] = None
if ctype == "horizontal":
anchor = self._line_world_mid(ids[0])
@@ -1149,7 +1256,10 @@ class Sketch2DWidget(QWidget):
# NOTE: use `is not None`, not truthiness — QPoint(0,0) is falsy in PySide6.
if a is not None and b is not None:
anchor = QPoint((a.x() + b.x()) // 2, (a.y() + b.y()) // 2)
- label = f"dst {params[0]:.1f}" if params else "dst"
+ label = "dstc"
+ # Store world-space positions for dimension-line rendering.
+ tag_p1 = a
+ tag_p2 = b
elif ctype == "parallel":
anchor = self._line_world_mid(ids[0])
label = "par"
@@ -1184,8 +1294,15 @@ class Sketch2DWidget(QWidget):
else:
continue
+ # For distance constraints, allow rendering even without an
+ # anchor — we only need tag_p1/tag_p2 for the dimension line.
if anchor is None:
- continue
+ if ctype != "distance":
+ continue
+ # Use first valid endpoint as fallback anchor for the pill.
+ anchor = tag_p1 if tag_p1 is not None else tag_p2
+ if anchor is None:
+ continue
sc = self._world_to_screen(anchor)
key = (sc.x(), sc.y())
slot = stack_count.get(key, 0)
@@ -1194,11 +1311,34 @@ class Sketch2DWidget(QWidget):
text = f"> {label} <"
w = fm.horizontalAdvance(text) + 10
h = 16
- # Stack successive tags above the anchor so they don't overlap.
- cx = sc.x()
- cy = sc.y() - 14 - slot * (h + 2)
+ # Push pill toward the sketch centroid (inside the closed
+ # shape) so it sits inside — dimensions are outside.
+ if centroid_screen is not None:
+ dir_x = centroid_screen.x() - sc.x()
+ dir_y = centroid_screen.y() - sc.y()
+ dist = math.sqrt(dir_x * dir_x + dir_y * dir_y)
+ if dist > 0.5:
+ nx, ny = dir_x / dist, dir_y / dist
+ else:
+ # Anchor is at the centroid — default to upward.
+ nx, ny = 0.0, -1.0
+ else:
+ # No centroid available — default to upward.
+ nx, ny = 0.0, -1.0
+ # Place the first pill 14 px inside from anchor,
+ # stack subsequent pills further in.
+ offset = 14 + slot * (h + 2)
+ cx = sc.x() + nx * offset
+ cy = sc.y() + ny * offset
rect = QRect(cx - w // 2, cy - h // 2, w, h)
- tags.append({"idx": idx, "label": text, "rect": rect, "center": QPoint(cx, cy)})
+ tag_entry = {"idx": idx, "label": text, "rect": rect, "center": QPoint(cx, cy)}
+ # Attach world-space endpoints so paintEvent can draw dimension lines.
+ if ctype == "distance":
+ if tag_p1 is not None and tag_p2 is not None:
+ tag_entry["p1_world"] = tag_p1
+ tag_entry["p2_world"] = tag_p2
+ tag_entry["distance"] = params[0] if params else 0.0
+ tags.append(tag_entry)
except Exception as exc:
# Catch any failure while building this one tag (bad
# geometry, missing entity, numpy round weirdness, etc.)
@@ -1817,11 +1957,20 @@ class Sketch2DWidget(QWidget):
self._solve_and_sync()
self._snap_point_target = None
else:
- # Auto-constrain: point snap during move → coincident
- if self._snap_point_target is not None and self._move_anchor is not None:
- self._sketch.constrain_coincident(
- self._move_anchor, self._snap_point_target
- )
+ # Auto-constrain: snap → coincident / point-on-line
+ target = None
+ if self._snap_point_target is not None:
+ target = self._snap_point_target
+ elif self._snap_line_target is not None and self._move_anchor is not None:
+ # Don't constrain a point onto a line it belongs to.
+ line_ent = self._snap_line_target
+ ep = self._sketch._lines.get(line_ent.id)
+ if ep is None or (
+ self._move_anchor.id != ep[0] and self._move_anchor.id != ep[1]
+ ):
+ target = line_ent
+ if target is not None and self._move_anchor is not None:
+ self._sketch.constrain_coincident(self._move_anchor, target)
self._solve_and_sync()
# Snap modes are honoured during the move (see _apply_move_snaps
# in mouseMoveEvent), so the committed positions are already snapped.
@@ -2100,6 +2249,32 @@ class Sketch2DWidget(QWidget):
# ─── Drawing handlers ─────────────────────────────────────────────────
+ def _auto_constrain_new_point(self, point: OCCSketchEntity, solve: bool = False) -> None:
+ """Anchor a freshly created point to whatever the cursor snapped to.
+
+ Point-to-point (coincident) wins; otherwise a line snap produces a
+ point-on-line constraint (the solver's coincident accepts a point and
+ a line). Without the line fallback, a click along a projected
+ construction edge placed a free point that merely *looked* connected
+ — the endpoint never moved with the edge and loops broke on drag.
+ """
+ if self._sketch is None:
+ return
+ target = None
+ if self._snap_point_target is not None:
+ target = self._snap_point_target
+ elif self._snap_line_target is not None:
+ # Don't constrain a point onto a line it is itself an endpoint of
+ # (degenerate / redundant for the solver).
+ line_ent = self._snap_line_target
+ ep = self._sketch._lines.get(line_ent.id)
+ if ep is None or (point.id != ep[0] and point.id != ep[1]):
+ target = line_ent
+ if target is not None:
+ self._sketch.constrain_coincident(point, target)
+ if solve:
+ self._solve_and_sync()
+
def _handle_line_click(self, pos: QPoint):
self._ensure_sketch_with_centerlines()
@@ -2112,10 +2287,8 @@ class Sketch2DWidget(QWidget):
self._points.append(point)
self._draw_buffer.append(pos)
- # Auto-constrain: point snap → coincident on start point
- if self._snap_point_target is not None:
- self._sketch.constrain_coincident(point, self._snap_point_target)
- self._solve_and_sync()
+ # Auto-constrain: snap → coincident / point-on-line on start point
+ self._auto_constrain_new_point(point, solve=True)
else:
point = self._sketch.add_point(pos.x(), pos.y())
point.is_construction = self._is_construct
@@ -2125,9 +2298,8 @@ class Sketch2DWidget(QWidget):
line = self._sketch.add_line(self._points[-2], self._points[-1])
self._lines.append((self._points[-2], self._points[-1]))
- # Auto-constrain: point snap → coincident on end point
- if self._snap_point_target is not None:
- self._sketch.constrain_coincident(self._points[-1], self._snap_point_target)
+ # Auto-constrain: snap → coincident / point-on-line on end point
+ self._auto_constrain_new_point(self._points[-1])
# Auto-constrain: detect horizontal / vertical from geometry
if self._snap_mode.get("horiz", False) or self._snap_mode.get("vert", False):
@@ -2157,6 +2329,7 @@ class Sketch2DWidget(QWidget):
self._undo_manager.save_state()
self._draw_buffer.append(pos)
self._rect_first_snap_target = self._snap_point_target
+ self._rect_first_line_target = self._snap_line_target
else:
p1 = self._draw_buffer[0]
p2 = pos
@@ -2179,13 +2352,13 @@ class Sketch2DWidget(QWidget):
self._lines.append((pts[i], pts[(i + 1) % 4]))
line_entities.append(line)
- # Auto-constrain: point snap → coincident on the correct corners.
- # pts[0] = first click snapped position
- # pts[2] = second click snapped position
+ # Auto-constrain: snap → coincident / point-on-line on the
+ # correct corners. pts[0] = first click, pts[2] = second click.
if self._rect_first_snap_target is not None:
self._sketch.constrain_coincident(pts[0], self._rect_first_snap_target)
- if self._snap_point_target is not None:
- self._sketch.constrain_coincident(pts[2], self._snap_point_target)
+ elif self._rect_first_line_target is not None:
+ self._sketch.constrain_coincident(pts[0], self._rect_first_line_target)
+ self._auto_constrain_new_point(pts[2])
# Auto-constrain: detect horizontal / vertical from geometry
if self._snap_mode.get("horiz", False):
@@ -2225,10 +2398,8 @@ class Sketch2DWidget(QWidget):
self._points.append(center)
self._draw_buffer.append(pos)
- # Auto-constrain: point snap → coincident on center
- if self._snap_point_target is not None:
- self._sketch.constrain_coincident(center, self._snap_point_target)
- self._solve_and_sync()
+ # Auto-constrain: snap → coincident / point-on-line on center
+ self._auto_constrain_new_point(center, solve=True)
else:
center = self._points[-1]
cx, cy = center.geometry if center.geometry else (0, 0)
@@ -2257,10 +2428,8 @@ class Sketch2DWidget(QWidget):
self._points.append(center)
self._draw_buffer.append(pos)
- # Auto-constrain: point snap → coincident on center
- if self._snap_point_target is not None:
- self._sketch.constrain_coincident(center, self._snap_point_target)
- self._solve_and_sync()
+ # Auto-constrain: snap → coincident / point-on-line on center
+ self._auto_constrain_new_point(center, solve=True)
elif len(self._draw_buffer) == 1:
# Click 2: place start point (defines radius + start angle)
start = self._sketch.add_point(pos.x(), pos.y())
@@ -2274,9 +2443,8 @@ class Sketch2DWidget(QWidget):
self._arc_prev_angle = math.atan2(pos.y() - cy, pos.x() - cx)
self._arc_accum_sweep = 0.0
- # Auto-constrain: point snap → coincident on start
- if self._snap_point_target is not None:
- self._sketch.constrain_coincident(start, self._snap_point_target)
+ # Auto-constrain: snap → coincident / point-on-line on start
+ self._auto_constrain_new_point(start)
self._solve_and_sync()
else:
@@ -2321,9 +2489,8 @@ class Sketch2DWidget(QWidget):
self._sketch.add_arc(center, radius, start_point, end, sweep=sweep)
self._arcs.append((center, radius, start_point, end, sweep))
- # Auto-constrain: point snap → coincident on end point
- if self._snap_point_target is not None:
- self._sketch.constrain_coincident(end, self._snap_point_target)
+ # Auto-constrain: snap → coincident / point-on-line on end point
+ self._auto_constrain_new_point(end)
self._solve_and_sync()
@@ -2352,9 +2519,7 @@ class Sketch2DWidget(QWidget):
self._points.append(c1)
self._draw_buffer.append(pos)
- if self._snap_point_target is not None:
- self._sketch.constrain_coincident(c1, self._snap_point_target)
- self._solve_and_sync()
+ self._auto_constrain_new_point(c1, solve=True)
self.sketch_updated.emit()
self.update()
@@ -2367,8 +2532,7 @@ class Sketch2DWidget(QWidget):
self._points.append(c2)
self._draw_buffer.append(pos)
- if self._snap_point_target is not None:
- self._sketch.constrain_coincident(c2, self._snap_point_target)
+ self._auto_constrain_new_point(c2)
self._solve_and_sync()
self.sketch_updated.emit()
@@ -2850,42 +3014,146 @@ class Sketch2DWidget(QWidget):
def _point_distance(self, p1: QPoint, p2: QPoint) -> float:
return math.sqrt((p1.x() - p2.x()) ** 2 + (p1.y() - p2.y()) ** 2)
- def _draw_distance_measurement(self, painter: QPainter, p1: QPoint, p2: QPoint):
- """Draw dimension lines and distance value between two world-coord points."""
+ def _sketch_centroid_screen(self) -> Optional[QPointF]:
+ """Screen-space centroid of all non-external sketch points.
+
+ ``_points`` maps point_id → (x, y) tuple. We look up the entity
+ in ``_entities`` to check ``is_external`` and get the geometry.
+ """
+ if not self._sketch:
+ return None
+ sx = sy = 0.0
+ n = 0
+ for pid, geom in self._sketch._points.items():
+ ent = self._sketch._entities.get(pid)
+ if ent is not None and self._is_external(ent):
+ continue
+ if isinstance(geom, tuple) and len(geom) == 2:
+ x, y = geom
+ if isinstance(x, (int, float)) and isinstance(y, (int, float)):
+ sp = self._world_to_screen(QPoint(int(round(x)), int(round(y))))
+ sx += sp.x()
+ sy += sp.y()
+ n += 1
+ return QPointF(sx / n, sy / n) if n > 0 else None
+
+ def _draw_technical_dimension(
+ self,
+ painter: QPainter,
+ p1: QPoint,
+ p2: QPoint,
+ value: float,
+ color: QColor = QColor("#a6e3a1"),
+ ):
+ """Draw an ISO-style dimension between two world-space points.
+
+ Renders extension lines from each endpoint, a dimension line offset
+ from the measured edge (on the side *away* from the sketch centroid),
+ arrowheads at both ends, and the value with "mm" centred on the
+ dimension line.
+ """
sp1 = self._world_to_screen(p1)
sp2 = self._world_to_screen(p2)
dx = sp2.x() - sp1.x()
dy = sp2.y() - sp1.y()
- length = math.sqrt(dx * dx + dy * dy)
- if length == 0:
+ screen_len = math.sqrt(dx * dx + dy * dy)
+ if screen_len < 1:
return
- # Perpendicular direction for offset lines
- perp_dx = -dy / length
- perp_dy = dx / length
- offset = 25.0
+ # Unit vectors along and perpendicular to the dimension line.
+ ux = dx / screen_len
+ uy = dy / screen_len
+ px = -uy
+ py = ux
- p1a = QPointF(sp1.x() + perp_dx, sp1.y() + perp_dy)
- p1b = QPointF(sp1.x() + perp_dx * offset, sp1.y() + perp_dy * offset)
- p2a = QPointF(sp2.x() + perp_dx, sp2.y() + perp_dy)
- p2b = QPointF(sp2.x() + perp_dx * offset, sp2.y() + perp_dy * offset)
- mid = QPointF((p1b.x() + p2b.x()) / 2, (p1b.y() + p2b.y()) / 2)
+ # Flip the perpendicular so the dimension line is on the side
+ # *away* from the sketch centroid (outside the closed shape).
+ centroid = self._sketch_centroid_screen()
+ if centroid is not None:
+ # Cross product: positive → centroid is left of p1→p2
+ cross = dx * (centroid.y() - sp1.y()) - dy * (centroid.x() - sp1.x())
+ if cross > 0:
+ px, py = -px, -py
+ else:
+ # Fallback: pick the side with the smaller screen coord.
+ if py > 0 or (py == 0 and px < 0):
+ px, py = -px, -py
- pen_dim = QPen(QColor("#a6e3a1"), 1.5, Qt.DotLine)
+ # ── Scale factors ──
+ ext_gap = 4.0
+ ext_overrun = 5.0
+ dim_offset = 25.0
+ arrow_len = 6.0
+ arrow_w = 2.0
+ gap = 40.0
+
+ # ── Extension lines ──
+ e1s = QPointF(sp1.x() + px * ext_gap, sp1.y() + py * ext_gap)
+ e1e = QPointF(
+ sp1.x() + px * (dim_offset + ext_overrun), sp1.y() + py * (dim_offset + ext_overrun)
+ )
+ e2s = QPointF(sp2.x() + px * ext_gap, sp2.y() + py * ext_gap)
+ e2e = QPointF(
+ sp2.x() + px * (dim_offset + ext_overrun), sp2.y() + py * (dim_offset + ext_overrun)
+ )
+
+ pen_ext = QPen(color, 0.8)
+ painter.setPen(pen_ext)
+ painter.drawLine(e1s, e1e)
+ painter.drawLine(e2s, e2e)
+
+ # ── Dimension line (with gap for text) ──
+ d1 = QPointF(sp1.x() + px * dim_offset, sp1.y() + py * dim_offset)
+ d2 = QPointF(sp2.x() + px * dim_offset, sp2.y() + py * dim_offset)
+ dmid = QPointF((d1.x() + d2.x()) / 2, (d1.y() + d2.y()) / 2)
+
+ pen_dim = QPen(color, 1.2)
painter.setPen(pen_dim)
- painter.drawLine(p1a.toPoint(), p1b.toPoint())
- painter.drawLine(p2a.toPoint(), p2b.toPoint())
- painter.drawLine(p1b.toPoint(), p2b.toPoint())
- # Draw distance text
- dist = self._point_distance(p1, p2)
+ le = QPointF(dmid.x() - ux * (gap / 2), dmid.y() - uy * (gap / 2))
+ painter.drawLine(d1, le)
+ rs = QPointF(dmid.x() + ux * (gap / 2), dmid.y() + uy * (gap / 2))
+ painter.drawLine(rs, d2)
+
+ # ── Arrowheads ──
+ def _arrow(tip: QPointF, idx: float, idy: float):
+ bx = tip.x() - idx * arrow_len
+ by = tip.y() - idy * arrow_len
+ b1 = QPointF(bx + px * arrow_w, by + py * arrow_w)
+ b2 = QPointF(bx - px * arrow_w, by - py * arrow_w)
+ path = QPainterPath()
+ path.moveTo(tip)
+ path.lineTo(b1)
+ path.lineTo(b2)
+ path.closeSubpath()
+ painter.setPen(Qt.NoPen)
+ painter.setBrush(QBrush(color))
+ painter.drawPath(path)
+ painter.setPen(pen_dim)
+
+ _arrow(d1, ux, uy)
+ _arrow(d2, -ux, -uy)
+
+ # ── Dimension text ──
painter.save()
- painter.translate(mid)
- painter.scale(1, -1)
- painter.setPen(QPen(QColor("#a6e3a1"), 1))
- painter.drawText(0, 0, f"{dist:.2f}")
- painter.restore()
+ try:
+ painter.translate(dmid)
+ angle = math.degrees(math.atan2(uy, ux))
+ if angle > 90 or angle < -90:
+ angle += 180
+ painter.rotate(angle)
+ painter.setPen(QPen(color, 1))
+ f = QFont("Helvetica", 11)
+ f.setBold(True)
+ painter.setFont(f)
+ text = f"{value:.2f} mm"
+ fm = QFontMetrics(f)
+ tw = fm.horizontalAdvance(text)
+ th = fm.height()
+ painter.drawText(int(-tw / 2), int(th / 3), text)
+ finally:
+ painter.restore()
def paintEvent(self, event):
painter = QPainter(self)
@@ -3097,6 +3365,23 @@ class Sketch2DWidget(QWidget):
painter.setPen(QPen(QColor("#f38ba8") if hovered else QColor("#f9e2af"), 1))
painter.drawText(rect, Qt.AlignCenter, tag["label"])
+ # ── Technical dimension lines for distance constraints ──
+ # Draw proper measurement lines (extension lines + dimension line
+ # with arrowheads + centred text) for every distance constraint.
+ for tag in self._constraint_tags:
+ if "p1_world" in tag and "p2_world" in tag:
+ try:
+ self._draw_technical_dimension(
+ painter,
+ tag["p1_world"],
+ tag["p2_world"],
+ tag["distance"],
+ )
+ except Exception:
+ import traceback
+
+ traceback.print_exc()
+
# ── Circles ──
for center_ent, radius in self._circles:
if center_ent.geometry:
diff --git a/tests/test_geometry.py b/tests/test_geometry.py
index ee77006..06ab59e 100644
--- a/tests/test_geometry.py
+++ b/tests/test_geometry.py
@@ -1,7 +1,6 @@
"""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
@@ -195,10 +194,14 @@ class TestOCCSketch:
sk = OCCSketch()
sk.set_workplane((10.0, 0.0, 5.0), normal, x_dir)
# 20x20 square in UV
- p0 = sk.add_point(-10, -10); p1 = sk.add_point(10, -10)
- p2 = sk.add_point(10, 10); p3 = sk.add_point(-10, 10)
- sk.add_line(p0, p1); sk.add_line(p1, p2)
- sk.add_line(p2, p3); sk.add_line(p3, p0)
+ p0 = sk.add_point(-10, -10)
+ p1 = sk.add_point(10, -10)
+ p2 = sk.add_point(10, 10)
+ p3 = sk.add_point(-10, 10)
+ sk.add_line(p0, p1)
+ sk.add_line(p1, p2)
+ sk.add_line(p2, p3)
+ sk.add_line(p3, p0)
geom = sk.get_geometry()
# The face must carry the plane normal for the kernel.
@@ -220,10 +223,14 @@ class TestOCCSketch:
sk = OCCSketch()
sk.set_workplane((0, 0, 0), (0, 0, 1), (1, 0, 0))
- a = sk.add_point(-10, -10); b = sk.add_point(10, -10)
- c = sk.add_point(10, 10); d = sk.add_point(-10, 10)
- sk.add_line(a, b); sk.add_line(b, c)
- sk.add_line(c, d); sk.add_line(d, a)
+ a = sk.add_point(-10, -10)
+ b = sk.add_point(10, -10)
+ c = sk.add_point(10, 10)
+ d = sk.add_point(-10, 10)
+ sk.add_line(a, b)
+ sk.add_line(b, c)
+ sk.add_line(c, d)
+ sk.add_line(d, a)
ctr = sk.add_point(0, 0)
sk.add_circle(ctr, 3.0)
@@ -290,10 +297,14 @@ class TestExternalEntities:
# Underlay: a 20x20 square projected from a face (closed polyline).
sk.add_external_polyline([(0, 0), (20, 0), (20, 20), (0, 20), (0, 0)])
# User profile: a 5x5 square — this is what should be extruded.
- a = sk.add_point(2, 2); b = sk.add_point(8, 2)
- c = sk.add_point(8, 8); d = sk.add_point(2, 8)
- sk.add_line(a, b); sk.add_line(b, c)
- sk.add_line(c, d); sk.add_line(d, a)
+ a = sk.add_point(2, 2)
+ b = sk.add_point(8, 2)
+ c = sk.add_point(8, 8)
+ d = sk.add_point(2, 8)
+ sk.add_line(a, b)
+ sk.add_line(b, c)
+ sk.add_line(c, d)
+ sk.add_line(d, a)
faces = sk.detect_faces()
# Only the user-drawn face (5x5 square) should be detected.
assert len(faces) == 1
@@ -310,10 +321,14 @@ class TestExternalEntities:
def test_external_entities_excluded_from_get_polygon_points(self):
sk = OCCSketch()
sk.add_external_polyline([(0, 0), (100, 0), (100, 100), (0, 100), (0, 0)])
- a = sk.add_point(1, 1); b = sk.add_point(2, 1)
- c = sk.add_point(2, 2); d = sk.add_point(1, 2)
- sk.add_line(a, b); sk.add_line(b, c)
- sk.add_line(c, d); sk.add_line(d, a)
+ a = sk.add_point(1, 1)
+ b = sk.add_point(2, 1)
+ c = sk.add_point(2, 2)
+ d = sk.add_point(1, 2)
+ sk.add_line(a, b)
+ sk.add_line(b, c)
+ sk.add_line(c, d)
+ sk.add_line(d, a)
poly = sk.get_polygon_points()
# The user square (1..2 range) should appear, not the 0..100 underlay.
assert all(1.0 <= p.x <= 2.0 for p in poly)
@@ -328,10 +343,14 @@ class TestExternalEntities:
# Underlay (NOT to be extruded).
sk.add_external_polyline([(0, 0), (10, 0), (10, 10), (0, 10), (0, 0)])
# User profile: a 2x2 square inside the underlay.
- a = sk.add_point(1, 1); b = sk.add_point(3, 1)
- c = sk.add_point(3, 3); d = sk.add_point(1, 3)
- sk.add_line(a, b); sk.add_line(b, c)
- sk.add_line(c, d); sk.add_line(d, a)
+ a = sk.add_point(1, 1)
+ b = sk.add_point(3, 1)
+ c = sk.add_point(3, 3)
+ d = sk.add_point(1, 3)
+ sk.add_line(a, b)
+ sk.add_line(b, c)
+ sk.add_line(c, d)
+ sk.add_line(d, a)
geom = sk.get_geometry()
# Volume = 2 * 2 * 4 = 16, NOT 10 * 10 * 4 = 400.
kernel = OCGeometryKernel()
@@ -498,10 +517,9 @@ class TestExtrudeCutFix:
and the tool is no longer needed.
"""
from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox
- from fluency.geometry_occ.kernel import OCGeometryKernel, OCCGeometryObject
+ from fluency.geometry_occ.kernel import OCGeometryKernel
from OCP.GProp import GProp_GProps
from OCP.BRepGProp import BRepGProp
- import math
k = OCGeometryKernel()
target_shape = BRepPrimAPI_MakeBox(100, 100, 100).Shape()
@@ -511,17 +529,18 @@ class TestExtrudeCutFix:
# expected volume easy to compute.
from OCP.BRepPrimAPI import BRepPrimAPI_MakePrism
from OCP.gp import gp_Pnt, gp_Vec
+
# 20x20 square at (0,0,0), extruded along +Z by 200.
from OCP.BRepBuilderAPI import BRepBuilderAPI_MakePolygon
+
mp = BRepBuilderAPI_MakePolygon()
- for (x, y) in [(0, 0), (20, 0), (20, 20), (0, 20)]:
+ for x, y in [(0, 0), (20, 0), (20, 20), (0, 20)]:
mp.Add(gp_Pnt(x, y, 0))
mp.Close()
from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeFace
+
face = BRepBuilderAPI_MakeFace(mp.Wire()).Face()
- tool_shape = BRepPrimAPI_MakePrism(
- face, gp_Vec(0, 0, 200)
- ).Shape()
+ tool_shape = BRepPrimAPI_MakePrism(face, gp_Vec(0, 0, 200)).Shape()
tool_obj = OCCGeometryObject(tool_shape, {"type": "prism"})
# Before cut: target is 100^3 = 1_000_000.
@@ -536,9 +555,7 @@ class TestExtrudeCutFix:
# After cut: target is 1_000_000 - 20*20*100 = 960_000
# (the prism only intersects the box in z=[0,100], i.e. 100 deep).
g1 = GProp_GProps()
- BRepGProp.VolumeProperties_s(
- k._get_shape(target_obj_geometry), g1
- )
+ BRepGProp.VolumeProperties_s(k._get_shape(target_obj_geometry), g1)
assert abs(g1.Mass() - 960_000.0) < 1.0
def test_boolean_difference_does_not_leave_separate_cavity_body(self):
@@ -551,10 +568,9 @@ class TestExtrudeCutFix:
target, so a single body remains.
"""
from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox
- from OCP.BRepAlgoAPI import BRepAlgoAPI_Cut
from OCP.TopExp import TopExp_Explorer
from OCP.TopAbs import TopAbs_SOLID
- from fluency.geometry_occ.kernel import OCGeometryKernel, OCCGeometryObject
+ from fluency.geometry_occ.kernel import OCGeometryKernel
k = OCGeometryKernel()
target_shape = BRepPrimAPI_MakeBox(100, 100, 100).Shape()
@@ -562,6 +578,7 @@ class TestExtrudeCutFix:
# Tool: small box at the centre, fully inside the target.
from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox as BBox
+
tool_shape = BBox(20, 20, 20).Shape()
tool_obj = OCCGeometryObject(tool_shape, {})
@@ -595,91 +612,59 @@ class TestBodyVisibilityToggle:
def _make_window(self):
import os
+
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PySide6.QtWidgets import QApplication
+
app = QApplication.instance() or QApplication([])
from fluency.main import MainWindow
+
return MainWindow()
def test_body_list_uses_checkable_items(self):
- """Each body list item must be a checkable QListWidgetItem."""
+ """Each body list item has a data role for the toggle handler."""
from PySide6.QtCore import Qt
+
win = self._make_window()
# Add a fake body to the current component so the list isn't empty.
from fluency.models.data_model import Body
from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox
- from fluency.geometry_occ.kernel import OCCGeometryObject
- box = OCCGeometryObject(
- BRepPrimAPI_MakeBox(10, 10, 10).Shape(), {}
- )
+
+ box = OCCGeometryObject(BRepPrimAPI_MakeBox(10, 10, 10).Shape(), {})
win._current_component.bodies["a"] = Body(name="A", geometry=box)
win._refresh_lists()
items = win._body_list.findItems("A", Qt.MatchExactly)
assert len(items) == 1
- # Item is checkable (so the user can toggle visibility).
- assert items[0].flags() & Qt.ItemIsUserCheckable
# And the body id is stored on the item for the toggle handler.
assert items[0].data(Qt.UserRole) == "a"
- # Default state is checked (= visible).
- assert items[0].checkState() == Qt.Checked
+ # Default state is visible.
+ assert win._current_component.bodies["a"].visible is True
def test_toggling_visibility_updates_body_model(self):
- """Flipping the checkbox should set body.visible accordingly."""
+ """Toggling visibility via _on_body_visibility_changed updates the model."""
from PySide6.QtCore import Qt
+
win = self._make_window()
from fluency.models.data_model import Body
from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox
- from fluency.geometry_occ.kernel import OCCGeometryObject
- box = OCCGeometryObject(
- BRepPrimAPI_MakeBox(10, 10, 10).Shape(), {}
- )
+
+ box = OCCGeometryObject(BRepPrimAPI_MakeBox(10, 10, 10).Shape(), {})
win._current_component.bodies["a"] = Body(name="A", geometry=box)
win._refresh_lists()
item = win._body_list.findItems("A", Qt.MatchExactly)[0]
# Toggle off.
- item.setCheckState(Qt.Unchecked)
win._on_body_visibility_changed(item)
assert win._current_component.bodies["a"].visible is False
# Toggle back on.
- item.setCheckState(Qt.Checked)
win._on_body_visibility_changed(item)
assert win._current_component.bodies["a"].visible is True
- def test_visibility_no_op_when_unchanged(self):
- """Re-emitting the same state must not trigger a viewer call.
-
- The set_visibility call into the viewer is cheap but not free;
- spamming it on every selection change would be wasteful. The
- handler short-circuits when the new state matches the model's.
- """
- from PySide6.QtCore import Qt
- win = self._make_window()
- from fluency.models.data_model import Body
- from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox
- from fluency.geometry_occ.kernel import OCCGeometryObject
- box = OCCGeometryObject(
- BRepPrimAPI_MakeBox(10, 10, 10).Shape(), {}
- )
- win._current_component.bodies["a"] = Body(name="A", geometry=box)
- win._refresh_lists()
- item = win._body_list.findItems("A", Qt.MatchExactly)[0]
-
- # Force the model's visibility to False to mimic a desync.
- win._current_component.bodies["a"].visible = False
- # Set the checkbox to Unchecked — this matches the model, so the
- # handler should short-circuit (not call set_visibility).
- item.setCheckState(Qt.Unchecked)
- # We can't directly assert "viewer was not called" without
- # monkey-patching; instead assert that re-firing the handler
- # doesn't raise and the state is consistent.
- win._on_body_visibility_changed(item)
- assert win._current_component.bodies["a"].visible is False
-
def math_hypot(x, y):
import math
+
return math.hypot(x, y)
@@ -698,10 +683,13 @@ class TestConstraintTagRendering:
def _make_widget_with_sketch(self, sk):
"""Build a Sketch2DWidget in offscreen mode and attach *sk* to it."""
import os
+
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PySide6.QtWidgets import QApplication
+
app = QApplication.instance() or QApplication([])
from fluency.main import Sketch2DWidget
+
w = Sketch2DWidget()
w.set_sketch(sk)
return w
@@ -824,6 +812,7 @@ class TestConstraintTagRendering:
class _BadRound:
def __round__(self, ndigits=0):
raise TypeError("cannot round")
+
sk._entities[c.id].geometry = (_BadRound(), _BadRound())
tags = w._compute_constraint_tags()
assert all(t["center"] is not None for t in tags)
@@ -865,12 +854,13 @@ class TestExtrudeRedesign:
def _make_window_with_box(self, box_side=100.0):
import os
+
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PySide6.QtWidgets import QApplication
+
app = QApplication.instance() or QApplication([])
from fluency.main import MainWindow
from fluency.models.data_model import Sketch, Body
- from fluency.geometry_occ.kernel import OCCGeometryObject
from fluency.geometry_occ.sketch import OCCSketch
from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox
@@ -896,7 +886,6 @@ class TestExtrudeRedesign:
return win, sketch, sk, box_obj
def _add_circle(self, sk, r=10.0):
- from fluency.geometry_occ.sketch import OCCSketch
c = sk.add_point(0, 0)
sk.add_circle(c, r)
sk.solve()
@@ -905,6 +894,7 @@ class TestExtrudeRedesign:
def _geometry_volume(self, win, geom):
from OCP.GProp import GProp_GProps
from OCP.BRepGProp import BRepGProp
+
sh = win._kernel._get_shape(geom)
g = GProp_GProps()
BRepGProp.VolumeProperties_s(sh, g)
@@ -919,37 +909,48 @@ class TestExtrudeRedesign:
so a 5 mm cut makes a real 5 mm-deep pocket.
"""
import math
+
win, sketch, sk, box_obj = self._make_window_with_box(100.0)
face_geom = self._add_circle(sk, r=10.0)
# Plain cut, length=5, NOT inverted. Pre-redesign this would have
# removed nothing; post-redesign it must remove a 5 mm cylinder.
result = win._compute_extrude_result(
- sketch, face_geom,
- length=5.0, symmetric=False, invert=False,
- cut=True, union=False, through_all=False,
+ sketch,
+ face_geom,
+ length=5.0,
+ symmetric=False,
+ invert=False,
+ cut=True,
+ union=False,
+ through_all=False,
)
assert result is not None
assert result["target_body"] is not None
assert result["target_body"].name == "Box1"
vol = self._geometry_volume(win, result["result_geom"])
- expected = 100.0 ** 3 - math.pi * (10.0 ** 2) * 5.0
+ expected = 100.0**3 - math.pi * (10.0**2) * 5.0
assert abs(vol - expected) < 1.0
def test_cut_through_all_passes_through(self):
- """"Through All" cut fully passes through the body."""
+ """ "Through All" cut fully passes through the body."""
import math
+
win, sketch, sk, box_obj = self._make_window_with_box(100.0)
face_geom = self._add_circle(sk, r=10.0)
result = win._compute_extrude_result(
- sketch, face_geom,
+ sketch,
+ face_geom,
length=5.0, # ignored when through_all
- symmetric=False, invert=False,
- cut=True, union=False, through_all=True,
+ symmetric=False,
+ invert=False,
+ cut=True,
+ union=False,
+ through_all=True,
)
assert result is not None
vol = self._geometry_volume(win, result["result_geom"])
# Full through cylinder = pi * r^2 * box_depth.
- expected = 100.0 ** 3 - math.pi * (10.0 ** 2) * 100.0
+ expected = 100.0**3 - math.pi * (10.0**2) * 100.0
assert abs(vol - expected) < 1.0
def test_cut_auto_targets_source_body_not_existing_zero(self):
@@ -960,30 +961,23 @@ class TestExtrudeRedesign:
"""
import math
import os
+
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PySide6.QtWidgets import QApplication
+
app = QApplication.instance() or QApplication([])
from fluency.main import MainWindow
from fluency.models.data_model import Sketch, Body
- from fluency.geometry_occ.kernel import OCCGeometryObject
from fluency.geometry_occ.sketch import OCCSketch
from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox
win = MainWindow()
# First body in the dict: a 50-millimetre box ALSO.
- first = OCCGeometryObject(
- BRepPrimAPI_MakeBox(50, 50, 50).Shape(), {}
- )
- win._current_component.bodies["first"] = Body(
- name="First", geometry=first
- )
+ first = OCCGeometryObject(BRepPrimAPI_MakeBox(50, 50, 50).Shape(), {})
+ win._current_component.bodies["first"] = Body(name="First", geometry=first)
# Source body: a 100-millimetre box (drawn over).
- src = OCCGeometryObject(
- BRepPrimAPI_MakeBox(100, 100, 100).Shape(), {}
- )
- win._current_component.bodies["src"] = Body(
- name="Src", geometry=src
- )
+ src = OCCGeometryObject(BRepPrimAPI_MakeBox(100, 100, 100).Shape(), {})
+ win._current_component.bodies["src"] = Body(name="Src", geometry=src)
# Sketch circle on top of the SOURCE box (0,0 so normal +Z).
sk = OCCSketch()
sk.set_workplane((50, 50, 100), (0, 0, 1), (1, 0, 0))
@@ -999,16 +993,21 @@ class TestExtrudeRedesign:
face_geom = sk.get_geometry()
result = win._compute_extrude_result(
- sketch, face_geom,
- length=5.0, symmetric=False, invert=False,
- cut=True, union=False, through_all=True,
+ sketch,
+ face_geom,
+ length=5.0,
+ symmetric=False,
+ invert=False,
+ cut=True,
+ union=False,
+ through_all=True,
)
assert result is not None
# Target is the source box, NOT the dict's first body.
assert result["target_body"].name == "Src"
vol = self._geometry_volume(win, result["result_geom"])
# 100^3 - pi*100*100 (through-all full-depth cut on the 100 box).
- expected = 100.0 ** 3 - math.pi * (10.0 ** 2) * 100.0
+ expected = 100.0**3 - math.pi * (10.0**2) * 100.0
assert abs(vol - expected) < 1.0
def test_union_default_builds_outward(self):
@@ -1019,17 +1018,23 @@ class TestExtrudeRedesign:
rather than "subtracting" from the existing box.
"""
import math
+
win, sketch, sk, box_obj = self._make_window_with_box(100.0)
face_geom = self._add_circle(sk, r=10.0)
result = win._compute_extrude_result(
- sketch, face_geom,
- length=10.0, symmetric=False, invert=False,
- cut=False, union=True, through_all=False,
+ sketch,
+ face_geom,
+ length=10.0,
+ symmetric=False,
+ invert=False,
+ cut=False,
+ union=True,
+ through_all=False,
)
assert result is not None
vol = self._geometry_volume(win, result["result_geom"])
# 100^3 + pi*100*10 — material added on top.
- expected = 100.0 ** 3 + math.pi * (10.0 ** 2) * 10.0
+ expected = 100.0**3 + math.pi * (10.0**2) * 10.0
assert abs(vol - expected) < 1.0
def test_plain_extrude_untouched_by_source_body(self):
@@ -1037,9 +1042,14 @@ class TestExtrudeRedesign:
win, sketch, sk, box_obj = self._make_window_with_box(100.0)
face_geom = self._add_circle(sk, r=10.0)
result = win._compute_extrude_result(
- sketch, face_geom,
- length=10.0, symmetric=False, invert=False,
- cut=False, union=False, through_all=False,
+ sketch,
+ face_geom,
+ length=10.0,
+ symmetric=False,
+ invert=False,
+ cut=False,
+ union=False,
+ through_all=False,
)
assert result is not None
# No boolean target; result is the standalone tool extrusion.
@@ -1047,7 +1057,8 @@ class TestExtrudeRedesign:
vol = self._geometry_volume(win, result["result_geom"])
# Standalone cylinder 10 mm tall.
import math
- assert abs(vol - math.pi * (10.0 ** 2) * 10.0) < 1.0
+
+ assert abs(vol - math.pi * (10.0**2) * 10.0) < 1.0
def test_freshly_picked_sketch_is_auto_selected(self):
"""After _on_face_picked, the new sketch is the current list row.
@@ -1055,7 +1066,6 @@ class TestExtrudeRedesign:
The user should be able to click Extrude/Cut immediately without
first hunting for the new sketch in the left list.
"""
- from fluency.geometry_occ.kernel import OCCGeometryObject
win, _, sk, box_obj = self._make_window_with_box(100.0)
# Simulate _on_face_picked by calling it through a fake face
# shape — but the simplest behavioural check is to call the
@@ -1063,6 +1073,7 @@ class TestExtrudeRedesign:
# set as _current_sketch, and it appears (and is selected) in
# the list after _refresh_lists + setCurrentRow.
from fluency.models.data_model import Sketch
+
sketch = Sketch(name="Sketch on face 99")
sketch._source_body_id = "b1"
sketch.set_workplane((50, 50, 100), (0, 0, 1), (1, 0, 0))
@@ -1084,8 +1095,10 @@ class TestExtrudeRedesign:
def test_preview_callback_invoked_on_value_change(self):
"""The live preview callback fires on spinbox/checkbox changes."""
import os
+
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PySide6.QtWidgets import QApplication
+
app = QApplication.instance() or QApplication([])
from fluency.main import ExtrudeDialog
@@ -1110,8 +1123,10 @@ class TestExtrudeRedesign:
def test_preview_hidden_event_sends_none(self):
"""hideEvent should deliver None to the callback so the host clears."""
import os
+
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PySide6.QtWidgets import QApplication
+
app = QApplication.instance() or QApplication([])
from fluency.main import ExtrudeDialog