44 Commits

Author SHA1 Message Date
bklronin 742d06d242 - Render improvements, camera plane, update 2026-07-13 23:01:35 +02:00
bklronin c78d0af78c - added renderer
- Added undo
2026-07-13 06:54:21 +02:00
bklronin dda9db822b - added renderer
- Added undo
2026-07-12 23:25:59 +02:00
bklronin 9f1387fe68 - added renderer
- Added undo
2026-07-12 22:21:43 +02:00
bklronin 210e3cfb5d - added renderer 2026-07-12 22:21:20 +02:00
bklronin b8516fff91 - Working assembly multi :) 2026-07-11 21:42:08 +02:00
bklronin d7e5929a13 - Working assembly multi :) 2026-07-11 21:29:58 +02:00
bklronin 2b2afbc479 - Added save file foramt
- Split main.py refactor
2026-07-11 15:39:30 +02:00
bklronin b0aebdc04f - Added save file foramt
- Split main.py refactor
2026-07-11 09:34:38 +02:00
bklronin be22c44a3f - Added save file foramt
- Split main.py refactor
2026-07-07 22:40:40 +02:00
bklronin 80ba3cc70a - Added save file foramt
- Split main.py refactor
2026-07-07 21:51:27 +02:00
bklronin 5269c0897c - Added save file foramt
- Split main.py refactor
2026-07-05 22:16:08 +02:00
bklronin 3a169007f7 - assembly draft 2026-07-05 19:36:27 +02:00
bklronin b595b88e04 - assembly draft 2026-07-05 10:16:49 +02:00
bklronin 9f10a5c5e5 - UI refinement, button position ui file as source no dirty drafting anymore 2026-07-04 16:16:04 +02:00
bklronin 6ba742ddf5 - sketch enhacements 2026-07-04 12:10:58 +02:00
bklronin 01833e4af2 - sketch enhacements 2026-07-03 21:49:05 +02:00
bklronin f860ff3e77 - removed cadquery deoendency 2026-07-01 20:03:00 +02:00
bklronin 9938f4ddd4 - Basic operations 2026-06-29 23:30:02 +02:00
bklronin f6422e0847 - Tons of addtions 2026-06-28 22:51:52 +02:00
bklronin f8f16ea800 - Tons of addtions 2026-06-28 21:12:34 +02:00
bklronin 54ac2c098a - Improved sketching 2026-06-14 10:10:37 +02:00
bklronin ea34e7e29d - Improved sketching 2026-06-14 10:10:33 +02:00
bklronin 7091f530ee fix: use RenderWidget and add animation callback for camera controls
- Change from RenderCanvas to RenderWidget for embedded Qt widget
- Add _animate() callback for canvas.request_draw()
- Update render() to use request_draw() for continuous rendering
- This enables OrbitController to work properly with mouse events
2026-03-14 09:12:12 +01:00
bklronin 75d4820292 fix: register OrbitController events with renderer
Use controller.register_events(renderer) instead of canvas to properly
enable camera rotation/pan/zoom controls in the 3D viewer.
2026-03-14 09:09:32 +01:00
bklronin d52106a48a fix: update pygfx integration for current API
- Use rendercanvas.qt.RenderCanvas instead of wgpu.gui.qt.WgpuCanvas
- Fix camera position API: use camera.local.position instead of camera.position.set()
- Fix light position API: use light.local.position
- Fix PygfxRenderObject to properly inherit from RenderObject
- Change add_mesh/add_wireframe/add_points/add_grid/add_axes to return string ID
- Update base Renderer class to return str instead of RenderObject
- Add custom compute_normals() function for mesh normals
2026-03-14 09:06:38 +01:00
bklronin d7ebbf45d5 feat: add CLI logging for debugging
- Add logging module with DEBUG level
- Log MainWindow initialization
- Log Viewer3DWidget initialization and mesh operations
- Log mouse events in Sketch2DWidget
- Log extrude operations with detailed steps
- Log component and sketch management
2026-03-14 09:01:20 +01:00
bklronin daed79dac6 chore: update .gitignore and remove pycache from tracking 2026-03-14 08:59:25 +01:00
bklronin e13769840b fix: ensure renderer is initialized before operations
- Add _ensure_initialized() method to Viewer3DWidget
- Queue pending meshes until widget is shown
- Add remove_mesh() method to PygfxRenderer
- Fix all viewer methods to check initialization
2026-03-14 08:59:12 +01:00
bklronin 5371bf7c38 fix: implement custom compute_normals for pygfx
pygfx doesn't have a compute_normals function, so we implement
our own vertex normal computation from positions and face indices.
2026-03-14 08:57:52 +01:00
bklronin bf00310889 feat: implement full GUI with all features from old codebase
- Main window with left/center/right panel layout
- 2D sketch widget with drawing tools (line, rectangle, circle)
- Constraint tools (coincident, horizontal, vertical, distance, midpoint)
- Snapping system (point, midpoint, horizontal, vertical, angle, grid)
- 3D viewer widget using pygfx
- Component timeline with buttons
- Sketch and body list management
- Operations (extrude, cut, combine, revolve)
- Workplane tools (origin, face, flip, move)
- Export functionality (STEP, IGES, STL)
- Import STEP/IGES files
- Code tab for CadQuery scripting
2026-03-14 08:56:13 +01:00
bklronin 8c6a413137 fix: correct OCP API usage for mesh, bounding box, and volume
- Fix BRep_Tool.Triangulation_s to use TopoDS.Face_s for face casting
- Fix BRepBndLib.AddClose_s import and usage
- Fix BRepGProp.VolumeProperties_s and SurfaceProperties_s imports
- Fix _get_shape to handle Workplane objects stored in shape attribute
- Fix OCCSketchEntity to properly inherit from SketchEntity
- Update pyproject.toml dependency versions
2026-03-14 08:52:45 +01:00
bklronin fe23ca610c feat: Replace SDF kernel with OpenCASCADE, VTK with pygfx
Major architecture migration:

- Remove SDF-based geometry kernel (sdf/)
- Remove VTK renderer (drawing_modules/)
- Remove old mesh modules (mesh_modules/)

New components:
- geometry/base.py: Abstract geometry kernel interface
- geometry_occ/kernel.py: OpenCASCADE implementation via CadQuery/OCP
- geometry_occ/sketch.py: 2D sketching with constraint solving
- rendering/base.py: Abstract renderer interface
- rendering/pygfx_renderer.py: WebGPU-based renderer
- models/data_model.py: Project, Component, Sketch, Body classes
- main.py: New Qt-based application

Features:
- STEP/IGES import/export
- Exact BRep geometry (vs approximate SDF mesh)
- Parametric sketching with constraints
- Boolean operations (union, difference, intersection)
- Fillet and chamfer operations
- Modern pygfx renderer (~30MB vs VTK ~200MB)

Dependencies:
- cadquery >= 2.4
- ocp >= 7.9.3
- pygfx >= 0.7.0
- wgpu >= 0.19.0
- PySide6 >= 6.9.0
2026-03-14 08:45:07 +01:00
bklronin d6044e551a - Improved sketching 2025-11-16 17:48:05 +01:00
bklronin 11d053fda4 Fix sketcher mode handling to prevent unintended line creation during drag operations
Major changes:
- Fixed right-click handler to directly set mode to NONE instead of relying on main app signal handling
- Added safety checks in left-click handler to prevent drawing when no draggable point is found in NONE mode
- Enhanced mode compatibility by treating Python None as SketchMode.NONE in set_mode() method
- Added comprehensive debug logging for mode changes and interaction state tracking
- Resolved integration issue where persistent constraint modes were prematurely reset by main app
- Ensured point dragging is only enabled in NONE mode, preventing accidental polyline creation

This fixes the reported issue where deactivating the line tool would still create lines when dragging,
and ensures proper mode transitions between drawing tools and selection/drag mode.
2025-08-16 22:30:18 +02:00
bklronin 54261bb8fd - added sdf folder ( doesnt work via pip or git=) 2025-08-16 20:33:44 +02:00
bklronin d373b50644 - added screenshot 2025-06-01 10:08:35 +02:00
BKLronin 0a9d557ce0 Merge pull request 'structure' (#7) from structure into master
Reviewed-on: BKLronin/fluency#7
2025-06-01 10:05:29 +02:00
bklronin c1911e3fac - added MIT license 2025-06-01 10:00:12 +02:00
bklronin 6cf70b9ae2 - Fixed redraw when component changed 2025-04-13 16:40:54 +02:00
bklronin 4d7b2cdbad - Added enabling of midpsnap and prepared others
- Show dimesnion on hover
2025-03-29 22:36:11 +01:00
bklronin f26a596159 - Added contrain displayed next to line
- Slight change to point check from solver.
2025-03-28 21:17:19 +01:00
bklronin 2a7f718b3e - Added construction lines switching
- Moved callbacks into sketchwidget from main.
- Changed reset on right click
2025-02-16 22:00:59 +01:00
bklronin 878b6093b7 - Added new buttons and settings 2025-01-02 19:28:43 +01:00
903 changed files with 190719 additions and 6005 deletions
Vendored
BIN
View File
Binary file not shown.
+35
View File
@@ -1,3 +1,38 @@
*.xml *.xml
*.iml *.iml
.idea .idea
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
# Virtual environments
.venv/
venv/
ENV/
# Lock files
uv.lock
# IDE
.vscode/
*.swp
*.swo
-11
View File
@@ -1,11 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="PYTHON_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<sourceFolder url="file://$MODULE_DIR$/sdfcad" isTestSource="false" />
<excludeFolder url="file://$MODULE_DIR$/.venv" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>
+1 -1
View File
@@ -3,7 +3,7 @@
<component name="Black"> <component name="Black">
<option name="sdkName" value="Python 3.11 (fluency)" /> <option name="sdkName" value="Python 3.11 (fluency)" />
</component> </component>
<component name="ProjectRootManager" version="2" project-jdk-name="Python 3.11 (fluency)" project-jdk-type="Python SDK" /> <component name="ProjectRootManager" version="2" project-jdk-name="Python 3.12 (fluency)" project-jdk-type="Python SDK" />
<component name="PyCharmProfessionalAdvertiser"> <component name="PyCharmProfessionalAdvertiser">
<option name="shown" value="true" /> <option name="shown" value="true" />
</component> </component>
+1 -1
View File
@@ -2,7 +2,7 @@
<project version="4"> <project version="4">
<component name="ProjectModuleManager"> <component name="ProjectModuleManager">
<modules> <modules>
<module fileurl="file://$PROJECT_DIR$/.idea/fluency.iml" filepath="$PROJECT_DIR$/.idea/fluency.iml" /> <module fileurl="file://$PROJECT_DIR$/.idea/fluency-cad.iml" filepath="$PROJECT_DIR$/.idea/fluency-cad.iml" />
</modules> </modules>
</component> </component>
</project> </project>
Generated
-1
View File
@@ -2,6 +2,5 @@
<project version="4"> <project version="4">
<component name="VcsDirectoryMappings"> <component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" /> <mapping directory="$PROJECT_DIR$" vcs="Git" />
<mapping directory="$PROJECT_DIR$/sdfcad" vcs="Git" />
</component> </component>
</project> </project>
+390 -39
View File
@@ -4,23 +4,14 @@
<option name="autoReloadType" value="SELECTIVE" /> <option name="autoReloadType" value="SELECTIVE" />
</component> </component>
<component name="ChangeListManager"> <component name="ChangeListManager">
<list default="true" id="8f0bafd6-58a0-4b20-aa2b-ddc3ba278873" name="Changes" comment="init"> <list default="true" id="8f0bafd6-58a0-4b20-aa2b-ddc3ba278873" name="Changes" comment="- added renderer&#10;- Added undo">
<change afterPath="$PROJECT_DIR$/.idea/fluency.iml" afterDir="false" /> <change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
<change afterPath="$PROJECT_DIR$/.idea/inspectionProfiles/Project_Default.xml" afterDir="false" /> <change beforePath="$PROJECT_DIR$/src/fluency/rendering/mitsuba_backend.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/rendering/mitsuba_backend.py" afterDir="false" />
<change afterPath="$PROJECT_DIR$/.idea/inspectionProfiles/profiles_settings.xml" afterDir="false" /> <change beforePath="$PROJECT_DIR$/src/fluency/rendering/occ_renderer.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/rendering/occ_renderer.py" afterDir="false" />
<change afterPath="$PROJECT_DIR$/.idea/misc.xml" afterDir="false" /> <change beforePath="$PROJECT_DIR$/src/fluency/rendering/render_backend.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/rendering/render_backend.py" afterDir="false" />
<change afterPath="$PROJECT_DIR$/.idea/modules.xml" afterDir="false" /> <change beforePath="$PROJECT_DIR$/src/fluency/ui/main_window.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/ui/main_window.py" afterDir="false" />
<change afterPath="$PROJECT_DIR$/.idea/vcs.xml" afterDir="false" /> <change beforePath="$PROJECT_DIR$/src/fluency/ui/render_window.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/ui/render_window.py" afterDir="false" />
<change afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" /> <change beforePath="$PROJECT_DIR$/src/fluency/ui/viewer_widget.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/ui/viewer_widget.py" afterDir="false" />
<change afterPath="$PROJECT_DIR$/2dtest.py" afterDir="false" />
<change afterPath="$PROJECT_DIR$/fluencyb.py" afterDir="false" />
<change afterPath="$PROJECT_DIR$/main.py" afterDir="false" />
<change afterPath="$PROJECT_DIR$/meshtest.py" afterDir="false" />
<change afterPath="$PROJECT_DIR$/modules/gl_widget.py" afterDir="false" />
<change afterPath="$PROJECT_DIR$/modules/out.stl" afterDir="false" />
<change afterPath="$PROJECT_DIR$/side_fluency.py" afterDir="false" />
<change afterPath="$PROJECT_DIR$/vulkan.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/fluency.py" beforeDir="false" afterPath="$PROJECT_DIR$/fluency.py" afterDir="false" />
</list> </list>
<option name="SHOW_DIALOG" value="false" /> <option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" /> <option name="HIGHLIGHT_CONFLICTS" value="true" />
@@ -35,6 +26,11 @@
</option> </option>
</component> </component>
<component name="Git.Settings"> <component name="Git.Settings">
<option name="RECENT_BRANCH_BY_REPOSITORY">
<map>
<entry key="$PROJECT_DIR$" value="single_window" />
</map>
</option>
<option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" /> <option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
<option name="ROOT_SYNC" value="DONT_SYNC" /> <option name="ROOT_SYNC" value="DONT_SYNC" />
</component> </component>
@@ -48,29 +44,49 @@
&quot;associatedIndex&quot;: 6 &quot;associatedIndex&quot;: 6
}</component> }</component>
<component name="ProjectId" id="2aDywQvESFCKbJK4JUVHIhkN4S6" /> <component name="ProjectId" id="2aDywQvESFCKbJK4JUVHIhkN4S6" />
<component name="ProjectLevelVcsManager" settingsEditedManually="true" />
<component name="ProjectViewState"> <component name="ProjectViewState">
<option name="hideEmptyMiddlePackages" value="true" /> <option name="hideEmptyMiddlePackages" value="true" />
<option name="showLibraryContents" value="true" /> <option name="showLibraryContents" value="true" />
</component> </component>
<component name="PropertiesComponent"><![CDATA[{ <component name="PropertiesComponent">{
"keyToString": { &quot;keyToString&quot;: {
"Python.2dtest.executor": "Run", &quot;Python.2dtest.executor&quot;: &quot;Run&quot;,
"Python.3d_windows.executor": "Run", &quot;Python.3d_windows.executor&quot;: &quot;Run&quot;,
"Python.fluency.executor": "Run", &quot;Python.Unnamed.executor&quot;: &quot;Run&quot;,
"Python.fluencyb.executor": "Run", &quot;Python.base.executor&quot;: &quot;Run&quot;,
"Python.gl_widget.executor": "Run", &quot;Python.data_model.executor&quot;: &quot;Run&quot;,
"Python.main.executor": "Run", &quot;Python.draw_widget2d.executor&quot;: &quot;Run&quot;,
"Python.meshtest.executor": "Run", &quot;Python.draw_widget_solve.executor&quot;: &quot;Run&quot;,
"Python.side_fluency.executor": "Run", &quot;Python.fluency.executor&quot;: &quot;Run&quot;,
"Python.vulkan.executor": "Run", &quot;Python.fluencyb.executor&quot;: &quot;Run&quot;,
"RunOnceActivity.OpenProjectViewOnStart": "true", &quot;Python.gl_widget.executor&quot;: &quot;Run&quot;,
"RunOnceActivity.ShowReadmeOnStart": "true", &quot;Python.gui_ui.executor&quot;: &quot;Run&quot;,
"git-widget-placeholder": "master", &quot;Python.kernel.executor&quot;: &quot;Run&quot;,
"last_opened_file_path": "/Volumes/Data_drive/Programming/fluency/modules", &quot;Python.main.executor&quot;: &quot;Run&quot;,
"settings.editor.selected.configurable": "project.propVCSSupport.DirectoryMappings" &quot;Python.meshtest.executor&quot;: &quot;Run&quot;,
&quot;Python.occ_renderer.executor&quot;: &quot;Run&quot;,
&quot;Python.occ_to_mesh.executor&quot;: &quot;Run&quot;,
&quot;Python.render_backend.executor&quot;: &quot;Run&quot;,
&quot;Python.side_fluency.executor&quot;: &quot;Run&quot;,
&quot;Python.simple_mesh.executor&quot;: &quot;Run&quot;,
&quot;Python.sketch.executor&quot;: &quot;Run&quot;,
&quot;Python.vtk_widget.executor&quot;: &quot;Run&quot;,
&quot;Python.vulkan.executor&quot;: &quot;Run&quot;,
&quot;RunOnceActivity.OpenProjectViewOnStart&quot;: &quot;true&quot;,
&quot;RunOnceActivity.ShowReadmeOnStart&quot;: &quot;true&quot;,
&quot;RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252&quot;: &quot;true&quot;,
&quot;RunOnceActivity.git.unshallow&quot;: &quot;true&quot;,
&quot;RunOnceActivity.typescript.service.memoryLimit.init&quot;: &quot;true&quot;,
&quot;codeWithMe.voiceChat.enabledByDefault&quot;: &quot;false&quot;,
&quot;git-widget-placeholder&quot;: &quot;feature/occ-migration&quot;,
&quot;last_opened_file_path&quot;: &quot;/Volumes/Data_drive/Programming/fluency/src/fluency/rendering&quot;,
&quot;node.js.detected.package.eslint&quot;: &quot;true&quot;,
&quot;node.js.selected.package.eslint&quot;: &quot;(autodetect)&quot;,
&quot;node.js.selected.package.tslint&quot;: &quot;(autodetect)&quot;,
&quot;nodejs_package_manager_path&quot;: &quot;npm&quot;,
&quot;settings.editor.selected.configurable&quot;: &quot;project.propVCSSupport.DirectoryMappings&quot;
} }
}]]></component> }</component>
<component name="QodanaReportsService"> <component name="QodanaReportsService">
<option name="descriptions"> <option name="descriptions">
<ReportDescription localRun="true" path="/private/var/folders/kg/zm48w_r96yb68mlbzvb9gtq40000gn/T/qodana_output/qodana.sarif.json" reportGuid="5f5b823c-c594-48c5-ae1f-062e30303918" reportId="fluency/qodana/2024-02-04" /> <ReportDescription localRun="true" path="/private/var/folders/kg/zm48w_r96yb68mlbzvb9gtq40000gn/T/qodana_output/qodana.sarif.json" reportGuid="5f5b823c-c594-48c5-ae1f-062e30303918" reportId="fluency/qodana/2024-02-04" />
@@ -78,6 +94,10 @@
</component> </component>
<component name="RecentsManager"> <component name="RecentsManager">
<key name="CopyFile.RECENT_KEYS"> <key name="CopyFile.RECENT_KEYS">
<recent name="$PROJECT_DIR$/src/fluency/rendering" />
<recent name="$PROJECT_DIR$/src/fluency" />
<recent name="$PROJECT_DIR$" />
<recent name="$PROJECT_DIR$/drawing_modules" />
<recent name="$PROJECT_DIR$/modules" /> <recent name="$PROJECT_DIR$/modules" />
</key> </key>
<key name="MoveFile.RECENT_KEYS"> <key name="MoveFile.RECENT_KEYS">
@@ -87,7 +107,7 @@
<component name="SharedIndexes"> <component name="SharedIndexes">
<attachedChunks> <attachedChunks>
<set> <set>
<option value="bundled-python-sdk-09665e90c3a7-d3b881c8e49f-com.jetbrains.pycharm.community.sharedIndexes.bundled-PC-233.15026.15" /> <option value="bundled-python-sdk-c59985aa861c-c2ffad84badb-com.jetbrains.pycharm.pro.sharedIndexes.bundled-PY-261.24374.152" />
</set> </set>
</attachedChunks> </attachedChunks>
</component> </component>
@@ -108,9 +128,316 @@
<option name="project" value="LOCAL" /> <option name="project" value="LOCAL" />
<updated>1703951701948</updated> <updated>1703951701948</updated>
</task> </task>
<option name="localTasksCounter" value="2" /> <task id="LOCAL-00002" summary="- Basic oop sketch widget implement">
<option name="closed" value="true" />
<created>1729958532384</created>
<option name="number" value="00002" />
<option name="presentableId" value="LOCAL-00002" />
<option name="project" value="LOCAL" />
<updated>1729958532384</updated>
</task>
<task id="LOCAL-00003" summary="- Sketch projection partly works again :)">
<option name="closed" value="true" />
<created>1735563255455</created>
<option name="number" value="00003" />
<option name="presentableId" value="LOCAL-00003" />
<option name="project" value="LOCAL" />
<updated>1735563255455</updated>
</task>
<task id="LOCAL-00004" summary="- Sketch projection partly works again :)">
<option name="closed" value="true" />
<created>1735585968733</created>
<option name="number" value="00004" />
<option name="presentableId" value="LOCAL-00004" />
<option name="project" value="LOCAL" />
<updated>1735585968733</updated>
</task>
<task id="LOCAL-00005" summary="- Added new componnt controls">
<option name="closed" value="true" />
<created>1735601610504</created>
<option name="number" value="00005" />
<option name="presentableId" value="LOCAL-00005" />
<option name="project" value="LOCAL" />
<updated>1735601610504</updated>
</task>
<task id="LOCAL-00006" summary="- Added new componnt controls">
<option name="closed" value="true" />
<created>1735601786207</created>
<option name="number" value="00006" />
<option name="presentableId" value="LOCAL-00006" />
<option name="project" value="LOCAL" />
<updated>1735601786207</updated>
</task>
<task id="LOCAL-00007" summary="- changing compos for sketches works">
<option name="closed" value="true" />
<created>1735652081552</created>
<option name="number" value="00007" />
<option name="presentableId" value="LOCAL-00007" />
<option name="project" value="LOCAL" />
<updated>1735652081552</updated>
</task>
<task id="LOCAL-00008" summary="- changing compos including sketches and bodies">
<option name="closed" value="true" />
<created>1735662119176</created>
<option name="number" value="00008" />
<option name="presentableId" value="LOCAL-00008" />
<option name="project" value="LOCAL" />
<updated>1735662119176</updated>
</task>
<task id="LOCAL-00009" summary="- Drawing bodys depending on the selected compo&#10;- Cut working&#10;- Edit sketch working">
<option name="closed" value="true" />
<created>1735685300102</created>
<option name="number" value="00009" />
<option name="presentableId" value="LOCAL-00009" />
<option name="project" value="LOCAL" />
<updated>1735685300102</updated>
</task>
<task id="LOCAL-00010" summary="- delete sketch working&#10;- added mid point snap&#10;- added hovering line with distance">
<option name="closed" value="true" />
<created>1735763743346</created>
<option name="number" value="00010" />
<option name="presentableId" value="LOCAL-00010" />
<option name="project" value="LOCAL" />
<updated>1735763743346</updated>
</task>
<task id="LOCAL-00011" summary="- Added new buttons and settings">
<option name="closed" value="true" />
<created>1735825176611</created>
<option name="number" value="00011" />
<option name="presentableId" value="LOCAL-00011" />
<option name="project" value="LOCAL" />
<updated>1735825176611</updated>
</task>
<task id="LOCAL-00012" summary="- Added new buttons and settings">
<option name="closed" value="true" />
<created>1735842523870</created>
<option name="number" value="00012" />
<option name="presentableId" value="LOCAL-00012" />
<option name="project" value="LOCAL" />
<updated>1735842523870</updated>
</task>
<task id="LOCAL-00013" summary="- Added construction lines switching&#10;- Moved callbacks into sketchwidget from main.&#10;- Changed reset on right click">
<option name="closed" value="true" />
<created>1739739664763</created>
<option name="number" value="00013" />
<option name="presentableId" value="LOCAL-00013" />
<option name="project" value="LOCAL" />
<updated>1739739664763</updated>
</task>
<task id="LOCAL-00014" summary="- Added contrain displayed next to line&#10;- Slight change to point check from solver.">
<option name="closed" value="true" />
<created>1743193041868</created>
<option name="number" value="00014" />
<option name="presentableId" value="LOCAL-00014" />
<option name="project" value="LOCAL" />
<updated>1743193041868</updated>
</task>
<task id="LOCAL-00015" summary="- Added enabling of midpsnap and prepared others&#10;- Show dimesnion on hover">
<option name="closed" value="true" />
<created>1743284173326</created>
<option name="number" value="00015" />
<option name="presentableId" value="LOCAL-00015" />
<option name="project" value="LOCAL" />
<updated>1743284173326</updated>
</task>
<task id="LOCAL-00016" summary="- Fixed redraw when component changed">
<option name="closed" value="true" />
<created>1744555255868</created>
<option name="number" value="00016" />
<option name="presentableId" value="LOCAL-00016" />
<option name="project" value="LOCAL" />
<updated>1744555255868</updated>
</task>
<task id="LOCAL-00017" summary="- added MIT license">
<option name="closed" value="true" />
<created>1748764814845</created>
<option name="number" value="00017" />
<option name="presentableId" value="LOCAL-00017" />
<option name="project" value="LOCAL" />
<updated>1748764814845</updated>
</task>
<task id="LOCAL-00018" summary="- added screenshot">
<option name="closed" value="true" />
<created>1748765318267</created>
<option name="number" value="00018" />
<option name="presentableId" value="LOCAL-00018" />
<option name="project" value="LOCAL" />
<updated>1748765318267</updated>
</task>
<task id="LOCAL-00019" summary="- added sdf folder ( doesnt work via pip or git=)">
<option name="closed" value="true" />
<created>1755369224187</created>
<option name="number" value="00019" />
<option name="presentableId" value="LOCAL-00019" />
<option name="project" value="LOCAL" />
<updated>1755369224187</updated>
</task>
<task id="LOCAL-00020" summary="- Tons of addtions">
<option name="closed" value="true" />
<created>1782673954850</created>
<option name="number" value="00020" />
<option name="presentableId" value="LOCAL-00020" />
<option name="project" value="LOCAL" />
<updated>1782673954850</updated>
</task>
<task id="LOCAL-00021" summary="- Tons of addtions">
<option name="closed" value="true" />
<created>1782679912834</created>
<option name="number" value="00021" />
<option name="presentableId" value="LOCAL-00021" />
<option name="project" value="LOCAL" />
<updated>1782679912834</updated>
</task>
<task id="LOCAL-00022" summary="- Basic operations">
<option name="closed" value="true" />
<created>1782768610475</created>
<option name="number" value="00022" />
<option name="presentableId" value="LOCAL-00022" />
<option name="project" value="LOCAL" />
<updated>1782768610475</updated>
</task>
<task id="LOCAL-00023" summary="- removed cadquery deoendency">
<option name="closed" value="true" />
<created>1782928990792</created>
<option name="number" value="00023" />
<option name="presentableId" value="LOCAL-00023" />
<option name="project" value="LOCAL" />
<updated>1782928990792</updated>
</task>
<task id="LOCAL-00024" summary="- sketch enhacements">
<option name="closed" value="true" />
<created>1783108151675</created>
<option name="number" value="00024" />
<option name="presentableId" value="LOCAL-00024" />
<option name="project" value="LOCAL" />
<updated>1783108151676</updated>
</task>
<task id="LOCAL-00025" summary="- sketch enhacements">
<option name="closed" value="true" />
<created>1783159860774</created>
<option name="number" value="00025" />
<option name="presentableId" value="LOCAL-00025" />
<option name="project" value="LOCAL" />
<updated>1783159860774</updated>
</task>
<task id="LOCAL-00026" summary="- UI refinement, button position ui file as source no dirty drafting anymore">
<option name="closed" value="true" />
<created>1783174566362</created>
<option name="number" value="00026" />
<option name="presentableId" value="LOCAL-00026" />
<option name="project" value="LOCAL" />
<updated>1783174566362</updated>
</task>
<task id="LOCAL-00027" summary="- assembly draft">
<option name="closed" value="true" />
<created>1783239410744</created>
<option name="number" value="00027" />
<option name="presentableId" value="LOCAL-00027" />
<option name="project" value="LOCAL" />
<updated>1783239410744</updated>
</task>
<task id="LOCAL-00028" summary="- assembly draft">
<option name="closed" value="true" />
<created>1783272988957</created>
<option name="number" value="00028" />
<option name="presentableId" value="LOCAL-00028" />
<option name="project" value="LOCAL" />
<updated>1783272988957</updated>
</task>
<task id="LOCAL-00029" summary="- Added save file foramt&#10;- Split main.py refactor">
<option name="closed" value="true" />
<created>1783282570014</created>
<option name="number" value="00029" />
<option name="presentableId" value="LOCAL-00029" />
<option name="project" value="LOCAL" />
<updated>1783282570014</updated>
</task>
<task id="LOCAL-00030" summary="- Added save file foramt&#10;- Split main.py refactor">
<option name="closed" value="true" />
<created>1783453889199</created>
<option name="number" value="00030" />
<option name="presentableId" value="LOCAL-00030" />
<option name="project" value="LOCAL" />
<updated>1783453889199</updated>
</task>
<task id="LOCAL-00031" summary="- Added save file foramt&#10;- Split main.py refactor">
<option name="closed" value="true" />
<created>1783456842297</created>
<option name="number" value="00031" />
<option name="presentableId" value="LOCAL-00031" />
<option name="project" value="LOCAL" />
<updated>1783456842297</updated>
</task>
<task id="LOCAL-00032" summary="- Added save file foramt&#10;- Split main.py refactor">
<option name="closed" value="true" />
<created>1783755278516</created>
<option name="number" value="00032" />
<option name="presentableId" value="LOCAL-00032" />
<option name="project" value="LOCAL" />
<updated>1783755278516</updated>
</task>
<task id="LOCAL-00033" summary="- Added save file foramt&#10;- Split main.py refactor">
<option name="closed" value="true" />
<created>1783777171864</created>
<option name="number" value="00033" />
<option name="presentableId" value="LOCAL-00033" />
<option name="project" value="LOCAL" />
<updated>1783777171864</updated>
</task>
<task id="LOCAL-00034" summary="- Working assembly multi :)">
<option name="closed" value="true" />
<created>1783798201133</created>
<option name="number" value="00034" />
<option name="presentableId" value="LOCAL-00034" />
<option name="project" value="LOCAL" />
<updated>1783798201133</updated>
</task>
<task id="LOCAL-00035" summary="- Working assembly multi :)">
<option name="closed" value="true" />
<created>1783798929531</created>
<option name="number" value="00035" />
<option name="presentableId" value="LOCAL-00035" />
<option name="project" value="LOCAL" />
<updated>1783798929531</updated>
</task>
<task id="LOCAL-00036" summary="- added renderer">
<option name="closed" value="true" />
<created>1783887682052</created>
<option name="number" value="00036" />
<option name="presentableId" value="LOCAL-00036" />
<option name="project" value="LOCAL" />
<updated>1783887682052</updated>
</task>
<task id="LOCAL-00037" summary="- added renderer&#10;- Added undo">
<option name="closed" value="true" />
<created>1783887704766</created>
<option name="number" value="00037" />
<option name="presentableId" value="LOCAL-00037" />
<option name="project" value="LOCAL" />
<updated>1783887704766</updated>
</task>
<task id="LOCAL-00038" summary="- added renderer&#10;- Added undo">
<option name="closed" value="true" />
<created>1783891561116</created>
<option name="number" value="00038" />
<option name="presentableId" value="LOCAL-00038" />
<option name="project" value="LOCAL" />
<updated>1783891561116</updated>
</task>
<task id="LOCAL-00039" summary="- added renderer&#10;- Added undo">
<option name="closed" value="true" />
<created>1783918463034</created>
<option name="number" value="00039" />
<option name="presentableId" value="LOCAL-00039" />
<option name="project" value="LOCAL" />
<updated>1783918463034</updated>
</task>
<option name="localTasksCounter" value="40" />
<servers /> <servers />
</component> </component>
<component name="TypeScriptGeneratedFilesManager">
<option name="version" value="3" />
</component>
<component name="Vcs.Log.Tabs.Properties"> <component name="Vcs.Log.Tabs.Properties">
<option name="TAB_STATES"> <option name="TAB_STATES">
<map> <map>
@@ -126,7 +453,31 @@
<ignored-roots> <ignored-roots>
<path value="$PROJECT_DIR$/pythonProject" /> <path value="$PROJECT_DIR$/pythonProject" />
</ignored-roots> </ignored-roots>
<MESSAGE value="init" /> <MESSAGE value="- Renabled extrusion with new object system" />
<option name="LAST_COMMIT_MESSAGE" value="init" /> <MESSAGE value="- Sketch projection partly works again :)" />
<MESSAGE value="- Added new componnt controls" />
<MESSAGE value="- changing compos for sketches works" />
<MESSAGE value="- changing compos including sketches and bodies" />
<MESSAGE value="- Drawing bodys depending on the selected compo&#10;- Cut working&#10;- Edit sketch working" />
<MESSAGE value="- delete sketch working&#10;- added mid point snap&#10;- added hovering line with distance" />
<MESSAGE value="- Added new buttons and settings" />
<MESSAGE value="- Added construction lines switching&#10;- Moved callbacks into sketchwidget from main.&#10;- Changed reset on right click" />
<MESSAGE value="- Added contrain displayed next to line&#10;- Slight change to point check from solver." />
<MESSAGE value="- Added enabling of midpsnap and prepared others&#10;- Show dimesnion on hover" />
<MESSAGE value="- Fixed redraw when component changed" />
<MESSAGE value="- added MIT license" />
<MESSAGE value="- added screenshot" />
<MESSAGE value="- added sdf folder ( doesnt work via pip or git=)" />
<MESSAGE value="- Tons of addtions" />
<MESSAGE value="- Basic operations" />
<MESSAGE value="- removed cadquery deoendency" />
<MESSAGE value="- sketch enhacements" />
<MESSAGE value="- UI refinement, button position ui file as source no dirty drafting anymore" />
<MESSAGE value="- assembly draft" />
<MESSAGE value="- Added save file foramt&#10;- Split main.py refactor" />
<MESSAGE value="- Working assembly multi :)" />
<MESSAGE value="- added renderer" />
<MESSAGE value="- added renderer&#10;- Added undo" />
<option name="LAST_COMMIT_MESSAGE" value="- added renderer&#10;- Added undo" />
</component> </component>
</project> </project>
+4804
View File
File diff suppressed because it is too large Load Diff
+111
View File
@@ -0,0 +1,111 @@
# Fluency CAD 2.0
A parametric CAD application built on OpenCASCADE Technology (OCCT) with a modern pygfx-based 3D renderer.
## Features
- **OpenCASCADE Geometry Kernel**: Industry-standard BRep geometry with exact precision
- **STEP/IGES Import/Export**: Full support for industry-standard CAD file formats
- **Parametric Sketching**: 2D sketching with constraint solving using SolveSpace
- **Boolean Operations**: Union, difference, and intersection
- **Fillet & Chamfer**: Apply edge treatments to solid bodies
- **Modern Renderer**: WebGPU-based rendering with pygfx (smaller footprint than VTK)
## Architecture
```
fluency/
├── src/fluency/
│ ├── geometry/ # Geometry abstraction layer
│ │ └── base.py # Abstract interfaces
│ ├── geometry_occ/ # OpenCASCADE implementation
│ │ ├── kernel.py # OCGeometryKernel
│ │ └── sketch.py # OCCSketch with constraints
│ ├── rendering/ # Rendering abstraction
│ │ ├── base.py # Abstract renderer
│ │ └── pygfx_renderer.py
│ ├── models/ # Data models
│ │ └── data_model.py # Project, Component, Sketch, Body
│ └── main.py # Application entry point
├── tests/
│ └── test_geometry.py
└── pyproject.toml
```
## Installation
```bash
# Create virtual environment
python -m venv .venv
source .venv/bin/activate # On Windows: .venv\Scripts\activate
# Install dependencies
pip install -e ".[dev]"
```
## Dependencies
| Package | Purpose |
|---------|---------|
| cadquery-ocp | OpenCASCADE Python bindings (OCP) |
| pygfx | WebGPU-based 3D renderer |
| wgpu | WebGPU Python bindings |
| PySide6 | Qt GUI framework |
| numpy | Numerical computing |
| scipy | Scientific computing |
## Usage
```bash
# Run the application
fluency-cad
# Or directly
python -m fluency.main
```
## API Example
```python
from fluency.geometry_occ.kernel import OCGeometryKernel
from fluency.geometry.base import Point2D
# Create kernel
kernel = OCGeometryKernel()
# Create a sketch
points = [
Point2D(0, 0),
Point2D(10, 0),
Point2D(10, 10),
Point2D(0, 10),
]
polygon = kernel.create_polygon(points)
# Extrude to 3D
body = kernel.extrude(polygon, height=20.0)
# Apply fillet
body = kernel.fillet(body, radius=2.0)
# Export to STEP
kernel.export_step(body, "part.step")
# Export to STL
kernel.export_stl(body, "part.stl")
```
## Comparison: Before vs After
| Aspect | Before (SDF + VTK) | After (OCC + pygfx) |
|--------|-------------------|---------------------|
| Geometry Precision | Approximate (mesh) | Exact (BRep) |
| Export Formats | STL only | STEP, IGES, STL, BREP |
| File Size | Large (mesh) | Small (BRep) |
| Fillet/Chamfer | Approximate | Exact |
| Dependency Size | ~200MB (VTK) | ~30MB (pygfx) |
| Constraint Solver | SolveSpace (separate) | SolveSpace (integrated) |
## License
MIT License
+147
View File
@@ -0,0 +1,147 @@
# WARP.md
This file provides guidance to WARP (warp.dev) when working with code in this repository.
## Project Overview
Fluency is a CAD (Computer Aided Design) application built with Python/PySide6 that provides parametric 3D modeling through a timeline-based project system. The application combines 2D sketching with constraint solving, 3D visualization using VTK, and SDF (Signed Distance Function) based mesh generation.
## Common Commands
### Development Environment Setup
```bash
# Activate virtual environment (if exists)
source .venv/bin/activate
# Install dependencies
pip install -r requirements.txt
```
### Running the Application
```bash
# Run the main application
python main.py
# Run with debugging
python -u main.py
```
### UI Development
```bash
# Convert Qt Designer UI file to Python code
pyside6-uic gui.ui > Gui.py -g python
```
### Building Executable
The project uses Nuitka for compilation (configured in `main.py` header):
```bash
# Build standalone executable
nuitka --standalone --plugin-enable=pyside6 --plugin-enable=numpy --macos-create-app-bundle main.py
```
### Testing
```bash
# Run mesh generation test
python meshtest.py
```
## Architecture Overview
### Core Components
#### Main Application (`main.py`)
- **MainWindow**: Central UI controller that manages all widgets and user interactions
- **Project System**: Hierarchical structure: `Project → Timeline → Component → Sketch/Body`
- **Signal-based Communication**: Qt signals coordinate between 2D sketching and 3D rendering
#### Project Hierarchy
```
Project
├── Timeline (list of Components)
└── Component
├── Sketches (dict)
├── Bodies (dict)
└── Connectors (for assembly)
```
#### Drawing Modules (`drawing_modules/`)
- **SketchWidget** (`draw_widget_solve.py`): 2D parametric sketching with SolverSpace constraint solving
- **VTKWidget** (`vtk_widget.py`): 3D visualization and mesh interaction using VTK
- **PyVistaWidget** (`vysta_widget.py`): Alternative 3D rendering backend
#### Mesh Generation (`mesh_modules/`)
- **VESTA** (`vesta_mesh.py`): Multi-threaded SDF-to-mesh conversion using marching cubes
- **Interactor Mesh** (`interactor_mesh.py`): Simplified edge-based meshes for 3D selection
- **Simple Mesh** (`simple_mesh.py`): Basic mesh utilities
### Data Flow Architecture
#### 2D to 3D Pipeline
1. **2D Sketching**: User draws in SketchWidget using Qt coordinate system
2. **Constraint Solving**: SolverSpace resolves geometric constraints
3. **SDF Generation**: Sketch converted to Signed Distance Functions for 3D operations
4. **Mesh Generation**: VESTA generates triangle meshes from SDF using marching cubes
5. **3D Rendering**: VTK displays both solid meshes and interactive edges
#### Signal Flow (from `doc/flow.md`)
- 2D QPoint → cartesian space → SolverSpace dict → constraint solving → display
- 3D mesh selection → projection to 2D → sketch widget integration
### Key Classes
#### Core Data Structures
- **Sketch**: 2D geometric data with origin, normal, points, and constraints
- **Body**: 3D mesh representation containing SDF objects and interactor meshes
- **Component**: Container grouping related sketches and bodies
- **Interactor**: Simplified edge-based mesh for 3D manipulation
#### Constraint Solving
The application uses `python_solvespace` for parametric constraint solving:
- Point-to-point constraints
- Distance constraints
- Horizontal/vertical line constraints
- Point-to-line constraints
### Technology Stack
- **GUI**: PySide6 (Qt for Python)
- **3D Graphics**: VTK for rendering, PyVista as alternative
- **Constraint Solving**: SolverSpace for parametric geometry
- **Mesh Generation**: SDF library with custom VESTA marching cubes implementation
- **Scientific Computing**: NumPy for mathematical operations
## Development Workflow
### Adding New Sketch Tools
1. Add UI button in `gui.ui`
2. Convert UI: `pyside6-uic gui.ui > Gui.py -g python`
3. Connect signal in `MainWindow.__init__()`
4. Implement tool logic in `SketchWidget`
### Adding New 3D Operations
1. Extend operation buttons in the Modify group
2. Implement operation logic using SDF functions
3. Update Body creation and timeline management
4. Handle interactor mesh generation for selection
### Debugging Tips
- Monitor solver results through `SolverSystem` status
- Use VTK's built-in debugging for rendering issues
- Check coordinate transformations between 2D sketch and 3D space
- Verify SDF function outputs before mesh generation
### File Structure
- `main.py`: Application entry point and main window
- `Gui.py`: Auto-generated UI code (do not edit directly)
- `gui.ui`: Qt Designer UI definition file
- `drawing_modules/`: 2D and 3D rendering widgets
- `mesh_modules/`: Mesh generation and processing
- `doc/`: Architecture and command documentation
## Dependencies
Primary external libraries:
- `PySide6`: Qt GUI framework
- `vtk`: 3D visualization toolkit
- `python-solvespace`: Constraint solving
- `sdf`: Signed Distance Function operations
- `numpy`: Numerical computations
- `scikit-image`: Marching cubes algorithm
- `names`: Random name generation for sketches
+219
View File
@@ -0,0 +1,219 @@
# Fluency CAD — Agent Guide
## Project Overview
**Fluency CAD 2.0** is a parametric CAD application built on **OpenCASCADE Technology (OCCT)** with a modern **pygfx-based 3D renderer**. It provides 2D sketching with SolveSpace constraint solving, boolean operations, STEP/IGES/STL import/export, and exact BRep geometry.
- Language: **Python 3.10+**
- GUI: **PySide6** (Qt6)
- Geometry Kernel: **OCP** (cadquery-ocp — OpenCASCADE Python bindings)
- Constraint Solver: **python_solvespace**
- Renderer: **pygfx** (WebGPU) + **OCCRenderer** (native OCC AIS display)
---
## Architecture
```
src/fluency/
├── __init__.py # Package entry (version, imports)
├── main.py # Application entry point, MainWindow, Sketch2DWidget (~5000 lines)
├── sketch_solver.py # SolveSpace constraint solver wrapper (legacy)
├── geometry/
│ └── base.py # Abstract interfaces: GeometryKernel, SketchInterface, data classes
├── geometry_occ/
│ ├── kernel.py # OCGeometryKernel (extrude, boolean, fillet, import/export, mesh)
│ └── sketch.py # OCCSketch with SolveSpace integration (face detection, constraints)
├── models/
│ └── data_model.py # Project, Component, Sketch, Body dataclasses
├── rendering/
│ ├── base.py # Abstract Renderer interface
│ ├── occ_renderer.py # OCC AIS renderer (preferred — smooth BRep display)
│ └── pygfx_renderer.py # Legacy pygfx renderer
├── utils/ # Utility modules
└── widgets/ # Custom widgets
tests/
└── test_geometry.py # Comprehensive test suite (52+ tests)
```
### Key Classes & Responsibilities
| Class | File | Purpose |
|-------|------|---------|
| `OCGeometryKernel` | `kernel.py` | OCC shape ops: extrude, boolean, fillet, mesh, import/export |
| `OCCSketch` | `sketch.py` | 2D sketch with SolveSpace solver, face detection, workplane |
| `OCCSketchEntity` | `sketch.py` | Entity (point/line/circle/arc) with solver handle, is_construction, is_external |
| `Sketch2DWidget` | `main.py` | Qt widget for interactive 2D sketching (draw, snap, constrain) |
| `MainWindow` | `main.py` | Main application window, toolbars, 3D viewer, operations |
| `OCCRenderer` | `occ_renderer.py` | Native OCC AIS display (shaded + edges, face pick) |
| `Sketch` | `data_model.py` | Data model: workplane, occ_sketch ref, source_body_id |
| `Body` | `data_model.py` | 3D solid with geometry, visibility, render object |
| `Component` | `data_model.py` | Container for sketches and bodies |
| `Project` | `data_model.py` | Top-level container with kernel |
### Data Flow
```
User draws in Sketch2DWidget
→ OCCSketch entities created in solver
→ Constraint solving (python_solvespace)
→ OCCSketch.get_geometry() → detect_faces() → build_face_geometry()
→ OCGeometryKernel.extrude() → BRepPrimAPI_MakePrism
→ Boolean operations → Body added to Component
→ OCCRenderer.add_shape() → AIS display
```
---
## Development Commands
```bash
# Install editable
pip install -e ".[dev]"
# Run app
python -m fluency.main
# Run tests (52 tests)
python -m pytest tests/test_geometry.py -v
# Run single test
python -m pytest tests/test_geometry.py::TestOCCSketch::test_workplane_extrude_with_hole -xvs
# Quck geometry test (raw OCC, no Qt)
python -c "from fluency.geometry_occ.sketch import OCCSketch; ..."
```
---
## Code Conventions
### General
- Line length: **100 chars** (black/ruff config)
- Target Python: **3.10+** (uses `from __future__ import annotations`, walrus, pattern matching)
- Docstrings: Google/NumPy style preferred
- Logging: `logger = logging.getLogger(__name__)` with `logging.DEBUG` level
### OCC / OCP
- Always use `is not None` for OCP objects — `TopoDS_Shape.__bool__` can be falsy even for valid shapes
- `BRepBuilderAPI_MakeFace.Add(wire)` expects a `TopoDS_Wire`. `wire.Reversed()` returns `TopoDS_Shape` → cast via `_TopoDS.Wire_s(wire.Reversed())`
- Face normal direction: check `face.Orientation()` vs `TopAbs_REVERSED` — a REVERSED face's outward normal is the NEGATION of the surface axis
- `TopoDS_Wire_s(shape)`, `TopoDS_Face_s(shape)` — use `_s` suffix from OCP for downcasts
- Mesh: `BRepMesh_IncrementalMesh(shape, tol, False, 0.15, True)` — default deflection 0.15 rad for smooth curves
### Extrude / Cut Workflow
- Snapshot `list(self._current_component.bodies.items())` **BEFORE** `add_body()` — the new body must not be in the target set
- Cut targets the **source body** (`sketch._source_body_id` from face pick), not `bodies[0]`
- The fix: apply boolean to **target** geometry, then remove tool body
- Plain extrude with holes: inner wires must have **OPPOSITE** geometric winding to the outer wire (see `build_face_geometry` and `_loop_signed_area`)
### Sketch / Solvers
- `python_solvespace` has NO remove API for entities/constraints. Deleting requires: drop from `_points`/`_lines`, prune `_constraint_log`, `_rebuild_solver()` (recreates entire system), `_rebuild_labels()`, re-solve
- `_constraint_log`: each entry is `{"type": str, "ids": tuple[int,...], "params": tuple, "labels": set[str]}`
- Constraint labels: stored on **point** entities for paintEvent rendering; rebuilt via `_rebuild_labels()`
- Line constraints (`horizontal`/`vertical`/`parallel`/`perpendicular`) need the **line's** solver handle, not a point's. Use `_find_line_sketch_entity()` to get the correct handle
- External entities (underlay): `is_external=True`, `is_construction=True`, fixed in solver (always `dragged`). Stored in `_external_entity_ids`, excluded from `_line_segments()`, `get_polygon_points()`, `get_closed_loops()`, `detect_faces()`, `get_geometry()`
### Face Detection
- `get_closed_loops()`: uses snapped-coordinate graph (`_SNAP_TOL = 1e-4`) from line endpoint adjacency. Only accepts simple cycles (all nodes degree 2)
- `detect_faces()`: even-odd nesting rule via `_loop_contains`. Even depth = outer boundary, odd = hole
- `_loop_rep_point`: midpoint between centroid and first vertex. **Fragile** — can land inside a nested shape for certain geometries (e.g., a small hole near the centroid's direction from the first vertex)
- `_loop_signed_area`: shoelace formula for polygons, `πr²` (positive = CCW) for circles
### Rendering
- **OCCRenderer** is the main renderer (not pygfx). Uses `AIS_Shape`, `V3d_Viewer`, `AIS_InteractiveContext`
- Face pick: `pick_planar_face(x, y)``MoveTo``DetectedShape``TopoDS_Face_s``BRepAdaptor_Surface` plane check
- Highlight: `highlight_face(face)` creates a transparent AIS overlay; `clear_face_highlight()` removes it
- Preview: `preview_shape(shape)` for live transparent extrude preview
- Navigation: Left=orbit, Middle=pan, Wheel=zoom. **Right is RESERVED** — check user before reassigning
### Paint-Event Safety
- Every constraint-tag rendering loop wraps each entry in `try/except` so a bad entry (dangling id, corrupted geometry) doesn't crash the entire paint event
- `_point_world()` and `_entity_anchor()` return `None` (not raise) for malformed input
---
## Known Bugs & Fix Patterns
### 1. Hole Orientation in Extrusion (FIXED 2026-07-03)
**Symptom**: Inner shapes (circle/triangle/slot) inside a rectangle become solid islands instead of holes when extruding, depending on drag direction.
**Root Cause**: `wire_loop` in `build_face_geometry` unconditionally reversed hole wires (`w.Reversed()`). When the outer polygon was CW-winding (e.g., dragging from top-left to bottom-right), the reversed inner had the SAME effective direction as the outer, making OCC treat it as solid.
**Fix**: Added `_loop_signed_area()` to compute geometric winding. Hole wires are only reversed when their natural winding matches the outer's (ensuring opposite winding for holes).
**Relevant code**: `sketch.py`, `build_face_geometry()` and `_loop_signed_area()`
### 2. _loop_rep_point Fragility (KNOWN)
**Symptom**: Face detection fails when a nested shape contains the outer loop's representative point (midpoint between centroid and first vertex).
**Would-be fix**: Use a guaranteed-interior point (maximum inscribed circle center or perturbed centroid) instead of the centroid-first-vertex midpoint.
### 3. Extrude Cut / Target Selection (FIXED 2026-06-29)
**Symptom**: Cut created a separate "cavity-shaped" body next to the original instead of modifying the target.
**Fix**: Boolean result stored on TARGET body geometry; tool body removed from component. Auto-target via `sketch._source_body_id`.
### 4. Workplane Preservation (FIXED 2026-06-29)
**Symptom**: Sketch placed on a face lost its workplane after being added to component.
**Fix**: Copy `occ_sketch` workplane fields into `Sketch` dataclass BEFORE `apply_workplane()`.
---
## API Quirks
- **`QPoint(0,0)`**: falsy via `isNull()` in PySide6 → always use `is not None` for `Optional[QPoint]`
- **`QMouseEvent`/`QPainterPath`**: live in `PySide6.QtGui` (NOT `QtCore`)
- **`BRepBuilderAPI_MakeFace.Add()`**: needs `TopoDS_Wire`. `wire.Reversed()` returns `TopoDS_Shape` — cast via `TopoDS_Wire_s()`
- **python_solvespace**: NO entity/constraint remove API — workaround via `_rebuild_solver()`. Parameters read via `solver.params(handle.params)` → returns `(x, y)` tuple
- **OCGeometryKernel.extrude**: unwraps `OCCGeometryObject`, raw `TopoDS_Shape`, or cadquery `Workplane`. Always use `is not None` for the shape (not truthiness)
- **Sketch._source_body_id**: dynamic attribute set on `Sketch` dataclass, set during face-pick flow
- **`_get_shape(obj)`**: returns `obj.shape.wrapped` for `OCCGeometryObject`, `obj.shape` for raw shapes, `None` for empty. Use `is not None` guards everywhere
---
## Memory / Agent Context
This project has extensive Pi memory (hermes-memory) for:
- `project="fluency"` with `target="failure"`: bugs, fixes, corrections, insights
- `project="fluency"` with `target="memory"`: conventions, decisions, workflow patterns
- Available skills: `fix-cad-app-pipeline`, `refactor-from-cadquery-to-ocp`
Key memory queries for debugging:
- "hole orientation" → `_loop_signed_area` / `build_face_geometry` fix
- "extrude cut auto-target" → cut/target body fix
- "workplane preservation" → _add_sketch_to_component fix
- "_loop_rep_point" → face detection fragility
- "paint-event safety" → try/except per entry pattern
- "solver rebuild" → delete workflow via _rebuild_solver
- "face pick origin" → pick_planar_face face bbox centre
---
## Testing Patterns
```python
# Direct OCC test (no Qt)
from OCP.BRepBuilderAPI import BRepBuilderAPI_MakePolygon, BRepBuilderAPI_MakeFace
from OCP.gp import gp_Pnt
from OCP.BRepPrimAPI import BRepPrimAPI_MakePrism
from OCP.GProp import GProp_GProps; from OCP.BRepGProp import BRepGProp
# Build test shape, extrude, verify volume
mp = BRepBuilderAPI_MakePolygon(); ...
g = GProp_GProps(); BRepGProp.VolumeProperties_s(shape, g)
assert abs(g.Mass() - expected) < 0.1
```
```python
# Sketch-based test
from fluency.geometry_occ.sketch import OCCSketch
from fluency.geometry_occ.kernel import OCGeometryKernel
sk = OCCSketch()
# ... add points, lines, circles ...
sk.solve()
geom = sk.get_geometry()
solid = OCGeometryKernel().extrude(geom, 10.0)
```
+108
View File
@@ -0,0 +1,108 @@
#!/usr/bin/env python3
"""
Debug script to test point dragging functionality in ImprovedSketchWidget
"""
import sys
import os
sys.path.append('/Volumes/Data_drive/Programming/fluency')
from PySide6.QtWidgets import QApplication, QMainWindow, QVBoxLayout, QWidget, QPushButton, QHBoxLayout
from PySide6.QtCore import Qt
from drawing_modules.improved_sketcher import ImprovedSketchWidget, SketchMode, Point2D
import logging
# Set up logging to see debug messages
logging.basicConfig(level=logging.DEBUG, format='%(levelname)s: %(message)s')
logger = logging.getLogger(__name__)
class DebugMainWindow(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle("Debug Point Dragging")
self.resize(1000, 700)
# Create central widget
central_widget = QWidget()
self.setCentralWidget(central_widget)
layout = QVBoxLayout(central_widget)
# Create button layout
button_layout = QHBoxLayout()
# Add test points button
add_points_btn = QPushButton("Add Test Points")
add_points_btn.clicked.connect(self.add_test_points)
button_layout.addWidget(add_points_btn)
# Check mode button
check_mode_btn = QPushButton("Check Mode")
check_mode_btn.clicked.connect(self.check_mode)
button_layout.addWidget(check_mode_btn)
# Reset mode button
reset_mode_btn = QPushButton("Reset to NONE Mode")
reset_mode_btn.clicked.connect(self.reset_mode)
button_layout.addWidget(reset_mode_btn)
layout.addLayout(button_layout)
# Create the sketcher widget
self.sketcher = ImprovedSketchWidget()
layout.addWidget(self.sketcher)
print("Debug window created. Current mode:", self.sketcher.current_mode)
def add_test_points(self):
"""Add some test points to the sketch"""
print("Adding test points...")
# Add a few points at different locations
points = [
Point2D(100, 100),
Point2D(200, 150),
Point2D(150, 200),
Point2D(50, 250)
]
for point in points:
self.sketcher.sketch.add_point(point)
print(f"Added point at ({point.x}, {point.y})")
self.sketcher.update()
print(f"Total points in sketch: {len(self.sketcher.sketch.points)}")
def check_mode(self):
"""Check current mode and dragging state"""
print(f"Current mode: {self.sketcher.current_mode}")
print(f"Dragging point: {self.sketcher.dragging_point}")
print(f"Drag start pos: {self.sketcher.drag_start_pos}")
print(f"Hovered point: {self.sketcher.hovered_point}")
print(f"Number of points: {len(self.sketcher.sketch.points)}")
# Check if points have solver handles
for i, point in enumerate(self.sketcher.sketch.points):
print(f"Point {i}: ({point.x}, {point.y}), handle: {point.handle}")
def reset_mode(self):
"""Reset to NONE mode to enable dragging"""
print("Resetting mode to NONE")
self.sketcher.set_mode(SketchMode.NONE)
print(f"Mode after reset: {self.sketcher.current_mode}")
if __name__ == "__main__":
app = QApplication(sys.argv)
window = DebugMainWindow()
window.show()
print("\n" + "="*50)
print("DEBUG INSTRUCTIONS:")
print("1. Click 'Add Test Points' to create some points")
print("2. Click 'Check Mode' to verify the current state")
print("3. Click 'Reset to NONE Mode' to ensure dragging is enabled")
print("4. Try to drag points by clicking and dragging them")
print("5. Watch the console for debug messages")
print("="*50 + "\n")
sys.exit(app.exec())
-1
View File
@@ -1 +0,0 @@
pyside6-uic gui.ui > Gui.py -g python
-35
View File
@@ -1,35 +0,0 @@
# Signal Flow
## 2D SketchWidget
- 2D QPoint form custom Qpainter widget in linear space
- 2D QPoint ot cartesian space
- 2D tuple into slvspace dict system and solvespace
- get calced position from Solvespace solver
- add to internal reference dict
- Transform to linear QPainter space for display to show
## 3D custom Widget
- Take Tuple points form solvespace main dict
- Draw Interactor and sdfCAD model
### Select and Project
- Project cartesian flattened mesh into 2D
- Transform to 2D xy
- Transform to linear space for 2D widget to draw.
- Result into 2D cartesian for body interaction extrude etc
### Elements
So far these are the elements:
- Project: Main File
- Timeline : Used to track the steps
- Assembly: Uses Components and Connectors to from Assemblies
- Component: Container for multiple smaller elements "part"
- Connector: Preserves connections between parts even if the part in between is deleted
- Code: A special type that directly builds bodys from sdfCAD code.
- Body: The 3D meshed result from sdfCAD
- Sketch: The base to draw new entities.
- Interactor: A special component mesh that is used to manipulate the bodys in 3d view.
-3
View File
@@ -1,3 +0,0 @@
## Compile ui file
pyside6-uic gui.ui > Gui.py -g python
+253
View File
@@ -0,0 +1,253 @@
# Realistic Render View — Implementation Plan
## Context
Add a **"Render"** feature to Fluency CAD that opens a separate window for photorealistic rendering of the selected component or assembly (like KeyShot/Cacles).
**Constraints:**
- Open in a **new window** — don't clutter the workspace
- **Keep existing OCCRenderer** for the interactive 3D viewport — untouched
- Render backend must be a **separate, swappable module** so we can change the renderer later
- Use **Mitsuba 3** as the initial backend (`pip install mitsuba`, ~50MB)
---
## Architecture
```
┌─────────────────────────────────────────────────────────┐
│ Main Fluency Window (existing OCCRenderer — untouched) │
│ │
│ [Select body/assembly] → [Click "Render"] │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────┐ │
│ │ RenderWindow (separate QMainWindow)│ │
│ │ │ │
│ │ ┌───────────────────────────────┐ │ │
│ │ │ RenderBackend (ABC) │ │ │
│ │ │ ├─ MitsubaBackend ← current │ │ │
│ │ │ ├─ (future: BlenderBackend) │ │ │
│ │ │ └─ (future: CyclesBackend) │ │ │
│ │ └───────────────────────────────┘ │ │
│ │ │ │\n│ │ [Image preview] [Progress bar] │ │
│ │ [Material ▾] [Quality ▾] [Render] │ │
│ │ [Export PNG] │ │
│ └─────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
```
### Swappable Backend Interface
```python
from abc import ABC, abstractmethod
from dataclasses import dataclass
import numpy as np
@dataclass
class RenderMaterial:
name: str
color: tuple[float, float, float] = (0.7, 0.7, 0.7)
metallic: float = 0.0 # 0.01.0
roughness: float = 0.5 # 0.01.0
bsdf_type: str = "diffuse" # diffuse | roughconductor | roughdielectric | plastic
@dataclass
class RenderCamera:
origin: tuple[float, float, float] = (100, 100, 100)
target: tuple[float, float, float] = (0, 0, 0)
up: tuple[float, float, float] = (0, 0, 1)
fov: float = 45.0
@dataclass
class RenderSettings:
width: int = 1920
height: int = 1080
spp: int = 256 # samples per pixel
max_depth: int = 8 # path tracer bounces
class RenderBackend(ABC):
"""Swap this to change the rendering engine."""
@abstractmethod
def render(self, obj_path: str, material: RenderMaterial,
camera: RenderCamera, settings: RenderSettings) -> np.ndarray: ...
@abstractmethod
def render_preview(self, obj_path: str, material: RenderMaterial,
camera: RenderCamera, settings: RenderSettings) -> np.ndarray: ...
@abstractmethod
def name(self) -> str: ...
```
Switching backends later = write a new class implementing `RenderBackend`. One import change.
---
## Mitsuba 3 Backend
### Why Mitsuba
| Feature | Status |
|---------|--------|
| `pip install mitsuba` | Single install, no system deps |
| True path tracing | GI, caustics, spectral rendering |
| PBR materials | `roughconductor`, `roughdielectric`, `diffuse`, `plastic` |
| Python dict API | Build scenes programmatically, no XML |
| CPU + GPU backends | `scalar_rgb` (CPU), `cuda_rgb` (NVIDIA) |
| Output formats | PNG, EXR (HDR) with tonemapping |
### OCC → OBJ Conversion Path
```python
from OCP.BRepMesh import BRepMesh_IncrementalMesh
from OCP.StlAPI import StlAPI_Writer
from OCP.BRep import BRep_Builder
import tempfile, os
def occ_shape_to_obj(shape, obj_path: str, linear_deflection: float = 0.1):
"""Tessellate OCC shape and write as OBJ for Mitsuba."""
tess = BRepMesh_IncrementalMesh(shape, linear_deflection, False, 0.5, True)
tess.Perform()
# Write STL (reliable), then convert to OBJ via trimesh or direct
writer = StlAPI_Writer()
writer.SetASCIIMode(False)
stl_path = obj_path.replace(".obj", ".stl")
writer.Write(shape, stl_path)
# Mitsuba can read STL directly, or we convert to OBJ
return stl_path
```
### Mitsuba Scene Construction
```python
import mitsuba as mi
mi.set_variant("scalar_rgb")
def build_scene(mesh_path: str, material: RenderMaterial,
camera: RenderCamera, settings: RenderSettings) -> mi.Scene:
# Map our material to Mitsuba BSDF
bsdf_map = {
"diffuse": {"type": "diffuse", "reflectance": {"type": "rgb", "value": material.color}},
"roughconductor": {
"type": "roughconductor",
"material": "copper", # or铝, 钢, etc.
"alpha": material.roughness,
},
"roughdielectric": {
"type": "roughdielectric",
"int_ior": 1.5,
"alpha": material.roughness,
},
"plastic": {
"type": "plastic",
"diffuse_reflectance": {"type": "rgb", "value": material.color},
"int_ior": 1.5,
},
}
return mi.load_dict({
"type": "scene",
"integrator": {"type": "path", "max_depth": settings.max_depth},
"sensor": {
"type": "perspective",
"fov": camera.fov,
"to_world": mi.ScalarTransform4f.look_at(
origin=camera.origin, target=camera.target, up=camera.up
),
"film": {"type": "hdrfilm", "width": settings.width, "height": settings.height},
"sampler": {"type": "independent", "sample_count": settings.spp},
},
"emitter": {"type": "constant"},
"shape": {
"type": "stl", # or "obj"
"filename": mesh_path,
"bsdf": bsdf_map.get(material.bsdf_type, bsdf_map["diffuse"]),
},
})
```
---
## Files to Create/Modify
| File | Action | Description |
|------|--------|-------------|
| `src/fluency/rendering/render_backend.py` | **NEW** | Abstract `RenderBackend`, `RenderMaterial`, `RenderCamera`, `RenderSettings` |
| `src/fluency/rendering/mitsuba_backend.py` | **NEW** | `MitsubaBackend(RenderBackend)` implementation |
| `src/fluency/rendering/occ_to_mesh.py` | **NEW** | OCC `TopoDS_Shape` → STL/OBJ tessellation |
| `src/fluency/rendering/material_presets.py` | **NEW** | Preset library: Steel, Aluminum, Brass, Chrome, Plastic, Rubber, Wood |
| `src/fluency/ui/render_window.py` | **NEW** | `RenderWindow(QMainWindow)` — image preview, material/quality controls, render/export |
| `src/fluency/ui/main_window.py` | MODIFY | Add "Render" button → get selected shapes → open `RenderWindow` |
---
## UI: RenderWindow
```
┌──────────────────────────────────────────┐
│ Render — [Part Name] [─][□][×] │
├──────────────────────────────────────────┤
│ │
│ ┌──────────────────────────────────┐ │
│ │ │ │
│ │ Rendered Image Preview │ │
│ │ (QLabel with QPixmap) │ │
│ │ │ │
│ └──────────────────────────────────┘ │
│ │
│ Material: [Steel ▾] │
│ Quality: [256 SPP ▾] │
│ Resolution: [1920×1080 ▾] │
│ │
│ [▶ Render] [⏹ Cancel] [💾 Export PNG] │
│ │
│ ████████████████░░░░░░ 65% (23s left) │
└──────────────────────────────────────────┘
```
- **Preview**: progressive refinement (low SPP first, then ramp)
- **Cancel**: kill Mitsuba render thread
- **Export**: save to PNG/EXR
---
## Material Presets
| Preset | Color | Metallic | Roughness | BSDF |
|--------|-------|----------|-----------|------|
| Brushed Steel | (0.65, 0.67, 0.72) | 0.9 | 0.35 | roughconductor |
| Polished Chrome | (0.8, 0.8, 0.8) | 1.0 | 0.05 | roughconductor |
| Brushed Aluminum | (0.75, 0.75, 0.75) | 0.85 | 0.25 | roughconductor |
| Copper | (0.95, 0.64, 0.54) | 0.95 | 0.15 | roughconductor |
| Gold | (1.0, 0.76, 0.33) | 1.0 | 0.1 | roughconductor |
| Blackened Steel | (0.15, 0.15, 0.17) | 0.8 | 0.4 | roughconductor |
| Matte Plastic | (0.2, 0.5, 0.8) | 0.0 | 0.6 | plastic |
| Glossy Plastic | (0.2, 0.5, 0.8) | 0.0 | 0.1 | plastic |
| White Nylon | (0.85, 0.85, 0.83) | 0.0 | 0.45 | plastic |
| Black ABS | (0.05, 0.05, 0.05) | 0.0 | 0.35 | plastic |
| Red PA12 | (0.75, 0.08, 0.08) | 0.0 | 0.4 | plastic |
| Rubber | (0.1, 0.1, 0.1) | 0.0 | 0.9 | diffuse |
| Ceramic White | (0.92, 0.91, 0.88) | 0.0 | 0.15 | dielectric |
| Glass | (0.95, 0.95, 0.95) | 0.0 | 0.0 | dielectric |
| Wood | (0.6, 0.4, 0.2) | 0.0 | 0.7 | diffuse |
**Note:** Mitsuba pip installs don't include spectral metal data files (iron.spd, copper.spd, etc.), so metal presets use `material="none"` with `specular_reflectance` set to the metal color instead.
---
## Risks & Mitigations
| Risk | Mitigation |
|------|-----------|
| Mitsuba not installed | Graceful error: "pip install mitsuba" shown in UI |
| Slow CPU rendering | Default to low SPP (64) for preview; offer GPU variant if CUDA available |
| Large meshes slow to tessellate | Progress indicator; optional mesh decimation |
| Mitsuba STL/OCC compatibility | Test tessellation quality; tune `linear_deflection` |
---
## Estimated Effort
- **Phase 1** (abstract backend + OCC→mesh + Mitsuba impl): ~4-6 hours
- **Phase 2** (render window UI + material presets): ~3-4 hours
- **Phase 3** (polish, export, swap test): ~2-3 hours
- **Total**: ~9-13 hours
-916
View File
@@ -1,916 +0,0 @@
import math
import re
from copy import copy
from typing import Optional
import numpy as np
from PySide6.QtWidgets import QApplication, QWidget, QMessageBox, QInputDialog
from PySide6.QtGui import QPainter, QPen, QColor, QTransform
from PySide6.QtCore import Qt, QPoint, QPointF, Signal, QLine
from python_solvespace import SolverSystem, ResultFlag
class SketchWidget(QWidget):
constrain_done = Signal()
def __init__(self):
super().__init__()
self.line_draw_buffer = [None, None]
self.drag_buffer = [None, None]
self.main_buffer = [None, None]
self.hovered_point = None
self.selected_line = None
self.snapping_range = 20 # Range in pixels for snapping
self.zoom = 1
self.setMouseTracking(True)
self.mouse_mode = False
self.solv = SolverSystem()
self.sketch = None
def set_sketch(self, sketch) -> None:
print(sketch)
self.sketch = sketch
self.create_workplane()
def get_sketch(self):
return self.sketch
def reset_buffers(self):
self.line_draw_buffer = [None, None]
self.drag_buffer = [None, None]
self.main_buffer = [None, None]
def set_points(self, points: list):
self.points = points
#self.update()
def create_workplane(self):
self.sketch.working_plane = self.solv.create_2d_base()
def create_workplane_projected(self):
self.sketch.working_plane = self.solv.create_2d_base()
def convert_proj_points(self):
out_points = []
for point in self.sketch.proj_points:
x, y = point
coord = QPoint(x, y)
out_points.append(coord)
self.sketch.proj_points = out_points
def convert_proj_lines(self):
out_lines = []
for line in self.sketch.proj_lines:
start = QPoint(line[0][0], line[0][1])
end = QPoint(line[1][0], line[1][1])
coord = QLine(start, end)
out_lines.append(coord)
self.sketch.proj_lines = out_lines
def find_duplicate_points_2d(self, edges):
points = []
seen = set()
duplicates = []
for edge in edges:
for point in edge:
# Extract only x and y coordinates
point_2d = (point[0], point[1])
if point_2d in seen:
if point_2d not in duplicates:
duplicates.append(point_2d)
else:
seen.add(point_2d)
points.append(point_2d)
return duplicates
def normal_to_quaternion(self, normal):
normal = np.array(normal)
#normal = normal / np.linalg.norm(normal)
axis = np.cross([0, 0, 1], normal)
if np.allclose(axis, 0):
axis = np.array([1, 0, 0])
else:
axis = axis / np.linalg.norm(axis) # Normalize the axis
angle = np.arccos(np.dot([0, 0, 1], normal))
qw = np.cos(angle / 2)
sin_half_angle = np.sin(angle / 2)
qx, qy, qz = axis * sin_half_angle # This will now work correctly
return qw, qx, qy, qz
def create_workplane_space(self, points, normal):
print("edges", points)
origin = self.find_duplicate_points_2d(points)
print(origin)
x, y = origin[0]
origin = QPoint(x, y)
origin_handle = self.get_handle_from_ui_point(origin)
qw, qx, qy, qz = self.normal_to_quaternion(normal)
slv_normal = self.solv.add_normal_3d(qw, qx, qy, qz)
self.sketch.working_plane = self.solv.add_work_plane(origin_handle, slv_normal)
print(self.sketch.working_plane)
def get_handle_nr(self, input_str: str) -> int:
# Define the regex pattern to extract the handle number
pattern = r"handle=(\d+)"
# Use re.search to find the handle number in the string
match = re.search(pattern, input_str)
if match:
handle_number = int(match.group(1))
print(f"Handle number: {handle_number}")
return int(handle_number)
else:
print("Handle number not found.")
return 0
def get_keys(self, d: dict, target: QPoint) -> list:
result = []
path = []
print(d)
print(target)
for k, v in d.items():
path.append(k)
if isinstance(v, dict):
self.get_keys(v, target)
if v == target:
result.append(copy(path))
path.pop()
return result
def get_handle_from_ui_point(self, ui_point: QPoint):
"""Input QPoint and you shall reveive a slvs entity handle!"""
for point in self.sketch.slv_points:
if ui_point == point['ui_point']:
slv_handle = point['solv_handle']
return slv_handle
def get_line_handle_from_ui_point(self, ui_point: QPoint):
"""Input Qpoint that is on a line and you shall receive the handle of the line!"""
for target_line_con in self.sketch.slv_lines:
if self.is_point_on_line(ui_point, target_line_con['ui_points'][0], target_line_con['ui_points'][1]):
slv_handle = target_line_con['solv_handle']
return slv_handle
def get_point_line_handles_from_ui_point(self, ui_point: QPoint) -> tuple:
"""Input Qpoint that is on a line and you shall receive the handles of the points of the line!"""
for target_line_con in self.sketch.slv_lines:
if self.is_point_on_line(ui_point, target_line_con['ui_points'][0], target_line_con['ui_points'][1]):
lines_to_cons = target_line_con['solv_entity_points']
return lines_to_cons
def distance(self, p1, p2):
return math.sqrt((p1.x() - p2.x())**2 + (p1.y() - p2.y())**2)
def calculate_midpoint(self, point1, point2):
mx = (point1.x() + point2.x()) // 2
my = (point1.y() + point2.y()) // 2
return QPoint(mx, my)
def is_point_on_line(self, p, p1, p2, tolerance=5):
# Calculate the lengths of the sides of the triangle
a = self.distance(p, p1)
b = self.distance(p, p2)
c = self.distance(p1, p2)
# Calculate the semi-perimeter
s = (a + b + c) / 2
# Calculate the area using Heron's formula
area = math.sqrt(s * (s - a) * (s - b) * (s - c))
# Calculate the height (perpendicular distance from the point to the line)
if c > 0:
height = (2 * area) / c
# Check if the height is within the tolerance distance to the line
if height > tolerance:
return False
# Check if the projection of the point onto the line is within the line segment
dot_product = ((p.x() - p1.x()) * (p2.x() - p1.x()) + (p.y() - p1.y()) * (p2.y() - p1.y())) / (c ** 2)
return 0 <= dot_product <= 1
else:
return None
def viewport_to_local_coord(self, qt_pos : QPoint) -> QPoint:
return QPoint(self.to_quadrant_coords(qt_pos))
def check_all_points(self,) -> list:
old_points_ui = []
new_points_ui = []
for old_point_ui in self.sketch.slv_points:
old_points_ui.append(old_point_ui['ui_point'])
for i in range(self.solv.entity_len()):
# Iterate though full length because mixed list from SS
entity = self.solv.entity(i)
if entity.is_point_2d() and self.solv.params(entity.params):
x_tbu, y_tbu = self.solv.params(entity.params)
point_solved = QPoint(x_tbu, y_tbu)
new_points_ui.append(point_solved)
# Now we have old_points_ui and new_points_ui, let's compare them
differences = []
if len(old_points_ui) != len(new_points_ui):
print(f"Length mismatch {len(old_points_ui)} - {len(new_points_ui)}")
for index, (old_point, new_point) in enumerate(zip(old_points_ui, new_points_ui)):
if old_point != new_point:
differences.append((index, old_point, new_point))
return differences
def update_ui_points(self, point_list: list):
# Print initial state of slv_points_main
# print("Initial slv_points_main:", self.slv_points_main)
print("Change list:", point_list)
if len(point_list) > 0:
for tbu_points_idx in point_list:
# Each tbu_points_idx is a tuple: (index, old_point, new_point)
index, old_point, new_point = tbu_points_idx
# Update the point in slv_points_main
self.sketch.slv_points[index]['ui_point'] = new_point
# Print updated state
# print("Updated slv_points_main:", self.slv_points_main)
def check_all_lines_and_update(self,changed_points: list):
for tbu_points_idx in changed_points:
index, old_point, new_point = tbu_points_idx
for line_needs_update in self.sketch.slv_lines:
if old_point == line_needs_update['ui_points'][0]:
line_needs_update['ui_points'][0] = new_point
elif old_point == line_needs_update['ui_points'][1]:
line_needs_update['ui_points'][1] = new_point
def mouseReleaseEvent(self, event):
local_event_pos = self.viewport_to_local_coord(event.pos())
if event.button() == Qt.LeftButton and not self.mouse_mode:
self.drag_buffer[1] = local_event_pos
print("Le main buffer", self.drag_buffer)
if len(self.main_buffer) == 2:
entry = self.drag_buffer[0]
new_params = self.drag_buffer[1].x(), self.drag_buffer[1].y()
self.solv.set_params(entry.params, new_params)
self.solv.solve()
points_need_update = self.check_all_points()
self.update_ui_points(points_need_update)
self.check_all_lines_and_update(points_need_update)
self.update()
self.drag_buffer = [None, None]
def mousePressEvent(self, event):
local_event_pos = self.viewport_to_local_coord(event.pos())
relation_point = {
'handle_nr': None,
'solv_handle': None,
'ui_point': None,
'part_of_entity': None
}
relation_line = {
'handle_nr': None,
'solv_handle': None,
'solv_entity_points': None,
'ui_points': None
}
if event.button() == Qt.LeftButton and not self.mouse_mode:
self.drag_buffer[0] = self.get_handle_from_ui_point(self.hovered_point)
if event.button() == Qt.RightButton and self.mouse_mode:
self.reset_buffers()
if event.button() == Qt.LeftButton and self.mouse_mode == "line":
if self.hovered_point:
clicked_pos = self.hovered_point
else:
clicked_pos = local_event_pos
if not self.line_draw_buffer[0]:
self.line_draw_buffer[0] = clicked_pos
u = clicked_pos.x()
v = clicked_pos.y()
point = self.solv.add_point_2d(u, v, self.sketch.working_plane)
relation_point = {} # Reinitialize the dictionary
handle_nr = self.get_handle_nr(str(point))
relation_point['handle_nr'] = handle_nr
relation_point['solv_handle'] = point
relation_point['ui_point'] = clicked_pos
self.sketch.slv_points.append(relation_point)
print("points", self.sketch.slv_points)
print("lines", self.sketch.slv_lines)
elif self.line_draw_buffer[0]:
self.line_draw_buffer[1] = clicked_pos
u = clicked_pos.x()
v = clicked_pos.y()
point2 = self.solv.add_point_2d(u, v, self.sketch.working_plane)
relation_point = {} # Reinitialize the dictionary
handle_nr = self.get_handle_nr(str(point2))
relation_point['handle_nr'] = handle_nr
relation_point['solv_handle'] = point2
relation_point['ui_point'] = clicked_pos
self.sketch.slv_points.append(relation_point)
print("points", self.sketch.slv_points)
print("lines", self.sketch.slv_lines)
print("Buffer state", self.line_draw_buffer)
if self.line_draw_buffer[0] and self.line_draw_buffer[1]:
point_slv1 = self.get_handle_from_ui_point(self.line_draw_buffer[0])
point_slv2 = self.get_handle_from_ui_point(self.line_draw_buffer[1])
print(point_slv1)
print(point_slv2)
line = self.solv.add_line_2d(point_slv1, point_slv2, self.sketch.working_plane)
relation_line = {} # Reinitialize the dictionary
handle_nr_line = self.get_handle_nr(str(line))
relation_line['handle_nr'] = handle_nr_line
relation_line['solv_handle'] = line
relation_line['solv_entity_points'] = (point_slv1, point_slv2)
relation_line['ui_points'] = [self.line_draw_buffer[0], self.line_draw_buffer[1]]
# Track relationship of point in line
relation_point['part_of_entity'] = handle_nr_line
self.sketch.slv_lines.append(relation_line)
# Reset the buffer for the next line segment
self.line_draw_buffer[0] = self.line_draw_buffer[1]
self.line_draw_buffer[1] = None
# Track Relationship
# Points
if event.button() == Qt.LeftButton and self.mouse_mode == "pt_pt":
if self.hovered_point and not self.main_buffer[0]:
self.main_buffer[0] = self.get_handle_from_ui_point(self.hovered_point)
elif self.main_buffer[0]:
self.main_buffer[1] = self.get_handle_from_ui_point(self.hovered_point)
if self.main_buffer[0] and self.main_buffer[1]:
print("buf", self.main_buffer)
self.solv.coincident(self.main_buffer[0], self.main_buffer[1], self.sketch.working_plane)
if self.solv.solve() == ResultFlag.OKAY:
print("Fuck yeah")
elif self.solv.solve() == ResultFlag.DIDNT_CONVERGE:
print("Solve_failed - Converge")
elif self.solv.solve() == ResultFlag.TOO_MANY_UNKNOWNS:
print("Solve_failed - Unknowns")
elif self.solv.solve() == ResultFlag.INCONSISTENT:
print("Solve_failed - Incons")
self.constrain_done.emit()
self.main_buffer = [None, None]
if event.button() == Qt.LeftButton and self.mouse_mode == "pt_line":
print("ptline")
line_selected = None
if self.hovered_point and not self.main_buffer[1]:
self.main_buffer[0] = self.get_handle_from_ui_point(self.hovered_point)
elif self.main_buffer[0]:
self.main_buffer[1] = self.get_line_handle_from_ui_point(local_event_pos)
# Contrain point to line
if self.main_buffer[1]:
self.solv.coincident(self.main_buffer[0], self.main_buffer[1], self.sketch.working_plane)
if self.solv.solve() == ResultFlag.OKAY:
print("Fuck yeah")
self.constrain_done.emit()
elif self.solv.solve() == ResultFlag.DIDNT_CONVERGE:
print("Solve_failed - Converge")
elif self.solv.solve() == ResultFlag.TOO_MANY_UNKNOWNS:
print("Solve_failed - Unknowns")
elif self.solv.solve() == ResultFlag.INCONSISTENT:
print("Solve_failed - Incons")
self.constrain_done.emit()
# Clear saved_points after solve attempt
self.main_buffer = [None, None]
if event.button() == Qt.LeftButton and self.mouse_mode == "pb_con_mid":
print("ptline")
line_selected = None
if self.hovered_point and not self.main_buffer[1]:
self.main_buffer[0] = self.get_handle_from_ui_point(self.hovered_point)
elif self.main_buffer[0]:
self.main_buffer[1] = self.get_line_handle_from_ui_point(local_event_pos)
# Contrain point to line
if self.main_buffer[1]:
self.solv.midpoint(self.main_buffer[0], self.main_buffer[1], self.sketch.working_plane)
if self.solv.solve() == ResultFlag.OKAY:
print("Fuck yeah")
elif self.solv.solve() == ResultFlag.DIDNT_CONVERGE:
print("Solve_failed - Converge")
elif self.solv.solve() == ResultFlag.TOO_MANY_UNKNOWNS:
print("Solve_failed - Unknowns")
elif self.solv.solve() == ResultFlag.INCONSISTENT:
print("Solve_failed - Incons")
self.constrain_done.emit()
self.main_buffer = [None, None]
if event.button() == Qt.LeftButton and self.mouse_mode == "horiz":
line_selected = self.get_line_handle_from_ui_point(local_event_pos)
if line_selected:
self.solv.horizontal(line_selected, self.sketch.working_plane)
if self.solv.solve() == ResultFlag.OKAY:
print("Fuck yeah")
elif self.solv.solve() == ResultFlag.DIDNT_CONVERGE:
print("Solve_failed - Converge")
elif self.solv.solve() == ResultFlag.TOO_MANY_UNKNOWNS:
print("Solve_failed - Unknowns")
elif self.solv.solve() == ResultFlag.INCONSISTENT:
print("Solve_failed - Incons")
if event.button() == Qt.LeftButton and self.mouse_mode == "vert":
line_selected = self.get_line_handle_from_ui_point(local_event_pos)
if line_selected:
self.solv.vertical(line_selected, self.sketch.working_plane)
if self.solv.solve() == ResultFlag.OKAY:
print("Fuck yeah")
elif self.solv.solve() == ResultFlag.DIDNT_CONVERGE:
print("Solve_failed - Converge")
elif self.solv.solve() == ResultFlag.TOO_MANY_UNKNOWNS:
print("Solve_failed - Unknowns")
elif self.solv.solve() == ResultFlag.INCONSISTENT:
print("Solve_failed - Incons")
if event.button() == Qt.LeftButton and self.mouse_mode == "distance":
# Depending on selected elemnts either point line or line distance
#print("distance")
e1 = None
e2 = None
if self.hovered_point:
print("buf point")
# Get the point as UI point as buffer
self.main_buffer[0] = self.hovered_point
elif self.selected_line:
# Get the point as UI point as buffer
self.main_buffer[1] = local_event_pos
if self.main_buffer[0] and self.main_buffer[1]:
# Define point line combination
e1 = self.get_handle_from_ui_point(self.main_buffer[0])
e2 = self.get_line_handle_from_ui_point(self.main_buffer[1])
elif not self.main_buffer[0]:
# Define only line selection
e1, e2 = self.get_point_line_handles_from_ui_point(local_event_pos)
if e1 and e2:
# Ask fo the dimension and solve if both elements are present
length, ok = QInputDialog.getDouble(self, 'Distance', 'Enter a mm value:', value=100, decimals=2)
self.solv.distance(e1, e2, length, self.sketch.working_plane)
if self.solv.solve() == ResultFlag.OKAY:
print("Fuck yeah")
elif self.solv.solve() == ResultFlag.DIDNT_CONVERGE:
print("Solve_failed - Converge")
elif self.solv.solve() == ResultFlag.TOO_MANY_UNKNOWNS:
print("Solve_failed - Unknowns")
elif self.solv.solve() == ResultFlag.INCONSISTENT:
print("Solve_failed - Incons")
self.constrain_done.emit()
self.main_buffer = [None, None]
# Update the main point list with the new elements and draw them
points_need_update = self.check_all_points()
self.update_ui_points(points_need_update)
self.check_all_lines_and_update(points_need_update)
self.update()
def mouseMoveEvent(self, event):
local_event_pos = self.viewport_to_local_coord(event.pos())
closest_point = None
min_distance = float('inf')
threshold = 10 # Distance threshold for highlighting
if self.sketch:
for point in self.sketch.slv_points:
distance = (local_event_pos - point['ui_point']).manhattanLength()
if distance < threshold and distance < min_distance:
closest_point = point['ui_point']
min_distance = distance
for point in self.sketch.proj_points:
distance = (local_event_pos - point).manhattanLength()
if distance < threshold and distance < min_distance:
closest_point = point
min_distance = distance
if closest_point != self.hovered_point:
self.hovered_point = closest_point
print(self.hovered_point)
for dic in self.sketch.slv_lines:
p1 = dic['ui_points'][0]
p2 = dic['ui_points'][1]
if self.is_point_on_line(local_event_pos, p1, p2):
self.selected_line = p1, p2
break
else:
self.selected_line = None
self.update()
def mouseDoubleClickEvent(self, event):
pass
def drawBackgroundGrid(self, painter):
"""Draw a background grid."""
grid_spacing = 50
pen = QPen(QColor(200, 200, 200), 1, Qt.SolidLine)
painter.setPen(pen)
# Draw vertical grid lines
for x in range(-self.width() // 2, self.width() // 2, grid_spacing):
painter.drawLine(x, -self.height() // 2, x, self.height() // 2)
# Draw horizontal grid lines
for y in range(-self.height() // 2, self.height() // 2, grid_spacing):
painter.drawLine(-self.width() // 2, y, self.width() // 2, y)
def drawAxes(self, painter):
painter.setRenderHint(QPainter.Antialiasing)
# Set up pen for dashed lines
pen = QPen(Qt.gray, 1, Qt.DashLine)
painter.setPen(pen)
middle_x = self.width() // 2
middle_y = self.height() // 2
# Draw X axis as dashed line
painter.drawLine(0, middle_y, self.width(), middle_y)
# Draw Y axis as dashed line
painter.drawLine(middle_x, 0, middle_x, self.height())
# Draw tick marks
tick_length = int(10 * self.zoom)
tick_spacing = int(50 * self.zoom)
pen = QPen(Qt.gray, 1, Qt.SolidLine)
painter.setPen(pen)
# Draw tick marks on the X axis to the right and left from the middle point
for x in range(0, self.width() // 2, tick_spacing):
painter.drawLine(middle_x + x, middle_y - tick_length // 2, middle_x + x, middle_y + tick_length // 2)
painter.drawLine(middle_x - x, middle_y - tick_length // 2, middle_x - x, middle_y + tick_length // 2)
# Draw tick marks on the Y axis upwards and downwards from the middle point
for y in range(0, self.height() // 2, tick_spacing):
painter.drawLine(middle_x - tick_length // 2, middle_y + y, middle_x + tick_length // 2, middle_y + y)
painter.drawLine(middle_x - tick_length // 2, middle_y - y, middle_x + tick_length // 2, middle_y - y)
# Draw the origin point in red
painter.setPen(QPen(Qt.red, 4))
painter.drawPoint(middle_x, middle_y)
def draw_cross(self, painter, pos: QPoint, size=10):
# Set up the pen
pen = QPen(QColor('green')) # You can change the color as needed
pen.setWidth(int(2 / self.zoom)) # Set the line widt)h
painter.setPen(pen)
x = pos.x()
y = pos.y()
# Calculate the endpoints of the cross
half_size = size // 2
# Draw the horizontal line
painter.drawLine(x - half_size, y, x + half_size, y)
# Draw the vertical line
painter.drawLine(x, y - half_size, x, y + half_size)
def to_quadrant_coords(self, point):
"""Translate linear coordinates to quadrant coordinates."""
center_x = self.width() // 2
center_y = self.height() // 2
quadrant_x = point.x() - center_x
quadrant_y = center_y - point.y() # Note the change here
return QPoint(quadrant_x, quadrant_y) / self.zoom
def from_quadrant_coords(self, point: QPoint):
"""Translate quadrant coordinates to linear coordinates."""
center_x = self.width() // 2
center_y = self.height() // 2
widget_x = center_x + point.x() * self.zoom
widget_y = center_y - point.y() * self.zoom # Note the subtraction here
return QPoint(int(widget_x), int(widget_y))
def from_quadrant_coords_no_center(self, point):
"""Invert Y Coordinate for mesh"""
center_x = 0
center_y = 0
widget_x = point.x()
widget_y = -point.y()
return QPoint(int(widget_x), int(widget_y))
def paintEvent(self, event):
painter = QPainter(self)
painter.setRenderHint(QPainter.Antialiasing)
self.drawAxes(painter)
# Create a QTransform object
transform = QTransform()
# Translate the origin to the center of the widget
center = QPointF(self.width() / 2, self.height() / 2)
transform.translate(center.x(), center.y())
# Apply the zoom factor
transform.scale(self.zoom, -self.zoom) # Negative y-scale to invert y-axis
# Set the transform to the painter
painter.setTransform(transform)
pen = QPen(Qt.gray)
pen.setWidthF(2 / self.zoom)
painter.setPen(pen)
# Draw points
if self.sketch:
for point in self.sketch.slv_points:
painter.drawEllipse(point['ui_point'], 3 / self.zoom, 3 / self.zoom)
for dic in self.sketch.slv_lines:
p1 = dic['ui_points'][0]
p2 = dic['ui_points'][1]
painter.drawLine(p1, p2)
dis = self.distance(p1, p2)
mid = self.calculate_midpoint(p1, p2)
painter.drawText(mid, str(round(dis, 2)))
pen = QPen(Qt.green)
pen.setWidthF(2 / self.zoom)
painter.setPen(pen)
if self.solv.entity_len():
for i in range(self.solv.entity_len()):
entity = self.solv.entity(i)
if entity.is_point_2d() and self.solv.params(entity.params):
x, y = self.solv.params(entity.params)
point = QPointF(x, y)
painter.drawEllipse(point, 6 / self.zoom, 6 / self.zoom)
# Highlight point hovered
if self.hovered_point:
highlight_pen = QPen(QColor(255, 0, 0))
highlight_pen.setWidthF(2 / self.zoom)
painter.setPen(highlight_pen)
painter.drawEllipse(self.hovered_point, 5 / self.zoom, 5 / self.zoom)
# Highlight line hovered
if self.selected_line and not self.hovered_point:
p1, p2 = self.selected_line
painter.setPen(QPen(Qt.red, 2 / self.zoom))
painter.drawLine(p1, p2)
for cross in self.sketch.proj_points:
self.draw_cross(painter, cross, 10 / self.zoom)
for selected in self.sketch.proj_lines:
pen = QPen(Qt.white, 1, Qt.DashLine)
painter.setPen(pen)
painter.drawLine(selected)
painter.end()
def wheelEvent(self, event):
delta = event.angleDelta().y()
self.zoom += (delta / 200) * 0.1
self.update()
def aspect_ratio(self):
return self.width() / self.height() * (1.0 / abs(self.zoom))
class Point2D:
"""Improved oop aaproach?"""
def __init__(self):
self.ui_point = None
self.solve_handle_nr = None
self.solve_handle = None
self.part_of_entity = None
def to_quadrant_coords(self, point):
"""Translate linear coordinates to quadrant coordinates."""
center_x = self.width() // 2
center_y = self.height() // 2
quadrant_x = point.x() - center_x
quadrant_y = center_y - point.y() # Note the change here
return QPoint(quadrant_x, quadrant_y) / self.zoom
def from_quadrant_coords(self, point: QPoint):
"""Translate quadrant coordinates to linear coordinates."""
center_x = self.width() // 2
center_y = self.height() // 2
widget_x = center_x + point.x() * self.zoom
widget_y = center_y - point.y() * self.zoom # Note the subtraction here
return QPoint(int(widget_x), int(widget_y))
def from_quadrant_coords_no_center(self, point):
"""Invert Y Coordinate for mesh"""
center_x = 0
center_y = 0
widget_x = point.x()
widget_y = -point.y()
return QPoint(int(widget_x), int(widget_y))
def get_handle_nr(self, input_str: str) -> int:
# Define the regex pattern to extract the handle number
pattern = r"handle=(\d+)"
# Use re.search to find the handle number in the string
match = re.search(pattern, input_str)
if match:
handle_number = int(match.group(1))
print(f"Handle number: {handle_number}")
return int(handle_number)
else:
print("Handle number not found.")
return 0
def get_keys(self, d: dict, target: QPoint) -> list:
result = []
path = []
print(d)
print(target)
for k, v in d.items():
path.append(k)
if isinstance(v, dict):
self.get_keys(v, target)
if v == target:
result.append(copy(path))
path.pop()
return result
def get_handle_from_ui_point(self, ui_point: QPoint):
"""Input QPoint and you shall reveive a slvs entity handle!"""
for point in self.sketch.slv_points:
if ui_point == point['ui_point']:
slv_handle = point['solv_handle']
return slv_handle
def get_line_handle_from_ui_point(self, ui_point: QPoint):
"""Input Qpoint that is on a line and you shall receive the handle of the line!"""
for target_line_con in self.sketch.slv_lines:
if self.is_point_on_line(ui_point, target_line_con['ui_points'][0], target_line_con['ui_points'][1]):
slv_handle = target_line_con['solv_handle']
return slv_handle
def get_point_line_handles_from_ui_point(self, ui_point: QPoint) -> tuple:
"""Input Qpoint that is on a line and you shall receive the handles of the points of the line!"""
for target_line_con in self.sketch.slv_lines:
if self.is_point_on_line(ui_point, target_line_con['ui_points'][0], target_line_con['ui_points'][1]):
lines_to_cons = target_line_con['solv_entity_points']
return lines_to_cons
def distance(self, p1, p2):
return math.sqrt((p1.x() - p2.x())**2 + (p1.y() - p2.y())**2)
def calculate_midpoint(self, point1, point2):
mx = (point1.x() + point2.x()) // 2
my = (point1.y() + point2.y()) // 2
return QPoint(mx, my)
def is_point_on_line(self, p, p1, p2, tolerance=5):
# Calculate the lengths of the sides of the triangle
a = self.distance(p, p1)
b = self.distance(p, p2)
c = self.distance(p1, p2)
# Calculate the semi-perimeter
s = (a + b + c) / 2
# Calculate the area using Heron's formula
area = math.sqrt(s * (s - a) * (s - b) * (s - c))
# Calculate the height (perpendicular distance from the point to the line)
if c > 0:
height = (2 * area) / c
# Check if the height is within the tolerance distance to the line
if height > tolerance:
return False
# Check if the projection of the point onto the line is within the line segment
dot_product = ((p.x() - p1.x()) * (p2.x() - p1.x()) + (p.y() - p1.y()) * (p2.y() - p1.y())) / (c ** 2)
return 0 <= dot_product <= 1
else:
return None
def viewport_to_local_coord(self, qt_pos : QPoint) -> QPoint:
return QPoint(self.to_quadrant_coords(qt_pos))
class Line2D:
pass
class Sketch2d(SolverSystem):
if __name__ == "__main__":
import sys
app = QApplication(sys.argv)
window = SketchWidget()
window.setWindowTitle("Snap Line Widget")
window.resize(800, 600)
window.show()
sys.exit(app.exec())
-897
View File
@@ -1,897 +0,0 @@
import math
import re
from copy import copy
import uuid
import numpy as np
from PySide6.QtWidgets import QApplication, QWidget, QMessageBox, QInputDialog
from PySide6.QtGui import QPainter, QPen, QColor, QTransform
from PySide6.QtCore import Qt, QPoint, QPointF, Signal, QLine
from python_solvespace import SolverSystem, ResultFlag
class SketchWidget(QWidget):
constrain_done = Signal()
def __init__(self):
super().__init__()
self.line_draw_buffer = [None, None]
self.drag_buffer = [None, None]
self.main_buffer = [None, None]
self.dynamic_line_end = None # Cursor position for dynamic drawing
self.hovered_point = None
self.selected_line = None
self.snapping_range = 20 # Range in pixels for snapping
self.zoom = 1
self.setMouseTracking(True)
self.mouse_mode = False
self.solv = SolverSystem()
self.sketch = Sketch2d()
def create_sketch(self, sketch_in ):
self.sketch = Sketch2d()
self.sketch.id = sketch_in.id
self.sketch.origin = sketch_in.origin
def set_sketch(self, sketch_in):
"""Needs to be an already defined Sketch object coming from the widget itself"""
self.sketch = sketch_in
def get_sketch(self):
return self.sketch
def reset_buffers(self):
self.line_draw_buffer = [None, None]
self.drag_buffer = [None, None]
self.main_buffer = [None, None]
def set_points(self, points: list):
self.points = points
#self.update()
def create_workplane(self):
self.sketch.wp = self.sketch.create_2d_base()
def create_workplane_projected(self):
self.sketch.wp = self.sketch.create_2d_base()
def convert_proj_points(self, proj_points: list):
### This needs to create a proper Point2D class with bool construction enbaled
out_points = []
for point in proj_points:
pnt = Point2D(point[0], point[1])
# Construction
pnt.is_helper = True
print(point)
self.sketch.add_point(pnt)
def convert_proj_lines(self, proj_lines: list):
### same as for point
out_lines = []
for line in proj_lines:
start = Point2D(line[0][0], line[0][1])
end = Point2D(line[1][0], line[1][1])
start.is_helper = True
end.is_helper = True
self.sketch.add_point(start)
self.sketch.add_point(end)
lne = Line2D(start, end)
#Construction
lne.is_helper = True
self.sketch.add_line(lne)
def find_duplicate_points_2d(self, edges):
points = []
seen = set()
duplicates = []
for edge in edges:
for point in edge:
# Extract only x and y coordinates
point_2d = (point[0], point[1])
if point_2d in seen:
if point_2d not in duplicates:
duplicates.append(point_2d)
else:
seen.add(point_2d)
points.append(point_2d)
return duplicates
def normal_to_quaternion(self, normal):
normal = np.array(normal)
#normal = normal / np.linalg.norm(normal)
axis = np.cross([0, 0, 1], normal)
if np.allclose(axis, 0):
axis = np.array([1, 0, 0])
else:
axis = axis / np.linalg.norm(axis) # Normalize the axis
angle = np.arccos(np.dot([0, 0, 1], normal))
qw = np.cos(angle / 2)
sin_half_angle = np.sin(angle / 2)
qx, qy, qz = axis * sin_half_angle # This will now work correctly
return qw, qx, qy, qz
def create_workplane_space(self, points, normal):
print("edges", points)
origin = self.find_duplicate_points_2d(points)
print(origin)
x, y = origin[0]
origin = QPoint(x, y)
origin_handle = self.get_handle_from_ui_point(origin)
qw, qx, qy, qz = self.normal_to_quaternion(normal)
slv_normal = self.sketch.add_normal_3d(qw, qx, qy, qz)
self.sketch.wp = self.sketch.add_work_plane(origin_handle, slv_normal)
print(self.sketch.wp)
def get_handle_nr(self, input_str: str) -> int:
# Define the regex pattern to extract the handle number
pattern = r"handle=(\d+)"
# Use re.search to find the handle number in the string
match = re.search(pattern, input_str)
if match:
handle_number = int(match.group(1))
print(f"Handle number: {handle_number}")
return int(handle_number)
else:
print("Handle number not found.")
return 0
def get_keys(self, d: dict, target: QPoint) -> list:
result = []
path = []
print(d)
print(target)
for k, v in d.items():
path.append(k)
if isinstance(v, dict):
self.get_keys(v, target)
if v == target:
result.append(copy(path))
path.pop()
return result
def get_handle_from_ui_point(self, ui_point: QPoint):
"""Input QPoint and you shall reveive a slvs entity handle!"""
for point in self.sketch.points:
if ui_point == point.ui_point:
slv_handle = point.handle
return slv_handle
def get_line_handle_from_ui_point(self, ui_point: QPoint):
"""Input Qpoint that is on a line and you shall receive the handle of the line!"""
for target_line_con in self.sketch.lines:
if self.is_point_on_line(ui_point, target_line_con.crd1.ui_point, target_line_con.crd2.ui_point):
slv_handle = target_line_con.handle
return slv_handle
def get_point_line_handles_from_ui_point(self, ui_point: QPoint) -> tuple:
"""Input Qpoint that is on a line and you shall receive the handles of the points of the line!"""
for target_line_con in self.sketch.lines:
if self.is_point_on_line(ui_point, target_line_con.crd1.ui_point, target_line_con.crd2.ui_point):
lines_to_cons = target_line_con.crd1.handle, target_line_con.crd2.handle
return lines_to_cons
def distance(self, p1, p2):
return math.sqrt((p1.x() - p2.x())**2 + (p1.y() - p2.y())**2)
def calculate_midpoint(self, point1, point2):
mx = (point1.x() + point2.x()) // 2
my = (point1.y() + point2.y()) // 2
return QPoint(mx, my)
def is_point_on_line(self, p, p1, p2, tolerance=5):
# Calculate the lengths of the sides of the triangle
a = self.distance(p, p1)
b = self.distance(p, p2)
c = self.distance(p1, p2)
# Calculate the semi-perimeter
s = (a + b + c) / 2
# Calculate the area using Heron's formula
area = math.sqrt(s * (s - a) * (s - b) * (s - c))
# Calculate the height (perpendicular distance from the point to the line)
if c > 0:
height = (2 * area) / c
# Check if the height is within the tolerance distance to the line
if height > tolerance:
return False
# Check if the projection of the point onto the line is within the line segment
dot_product = ((p.x() - p1.x()) * (p2.x() - p1.x()) + (p.y() - p1.y()) * (p2.y() - p1.y())) / (c ** 2)
return 0 <= dot_product <= 1
else:
return None
def viewport_to_local_coord(self, qt_pos : QPoint) -> QPoint:
return QPoint(self.to_quadrant_coords(qt_pos))
def check_all_points(self) -> list:
"""
Go through solversystem and check points2d for changes in position after solving
:return: List with points that now have a different position
"""
old_points_ui = []
new_points_ui = []
for old_point_ui in self.sketch.points:
old_points_ui.append(old_point_ui.ui_point)
for i in range(self.sketch.entity_len()):
# Iterate though full length because mixed list from SS
entity = self.sketch.entity(i)
if entity.is_point_2d() and self.sketch.params(entity.params):
x_tbu, y_tbu = self.sketch.params(entity.params)
point_solved = QPoint(x_tbu, y_tbu)
new_points_ui.append(point_solved)
# Now we have old_points_ui and new_points_ui, let's compare them
differences = []
if len(old_points_ui) != len(new_points_ui):
print(f"Length mismatch {len(old_points_ui)} - {len(new_points_ui)}")
for index, (old_point, new_point) in enumerate(zip(old_points_ui, new_points_ui)):
if old_point != new_point:
differences.append((index, old_point, new_point))
return differences
def update_ui_points(self, point_list: list):
# Print initial state of slv_points_main
# print("Initial slv_points_main:", self.slv_points_main)
print("Change list:", point_list)
if len(point_list) > 0:
for tbu_points_idx in point_list:
# Each tbu_points_idx is a tuple: (index, old_point, new_point)
index, old_point, new_point = tbu_points_idx
# Update the point in slv_points_main
self.sketch.points[index].ui_point = new_point
# Print updated state
# print("Updated slv_points_main:", self.slv_points_main)
def check_all_lines_and_update(self,changed_points: list):
for tbu_points_idx in changed_points:
index, old_point, new_point = tbu_points_idx
for line_needs_update in self.sketch.lines:
if old_point == line_needs_update.crd1.ui_point:
line_needs_update.crd1.ui_point = new_point
elif old_point == line_needs_update.crd2.ui_point:
line_needs_update.crd2.ui_point = new_point
def mouseReleaseEvent(self, event):
local_event_pos = self.viewport_to_local_coord(event.pos())
if event.button() == Qt.LeftButton and not self.mouse_mode:
self.drag_buffer[1] = local_event_pos
print("Le main buffer", self.drag_buffer)
if not None in self.main_buffer and len(self.main_buffer) == 2:
entry = self.drag_buffer[0]
new_params = self.drag_buffer[1].x(), self.drag_buffer[1].y()
self.sketch.set_params(entry.params, new_params)
self.sketch.solve()
points_need_update = self.check_all_points()
self.update_ui_points(points_need_update)
self.check_all_lines_and_update(points_need_update)
self.update()
self.drag_buffer = [None, None]
def mousePressEvent(self, event):
local_event_pos = self.viewport_to_local_coord(event.pos())
if event.button() == Qt.LeftButton and not self.mouse_mode:
self.drag_buffer[0] = self.get_handle_from_ui_point(self.hovered_point)
if event.button() == Qt.RightButton and self.mouse_mode:
self.reset_buffers()
if event.button() == Qt.LeftButton and self.mouse_mode == "line":
if self.hovered_point:
clicked_pos = self.hovered_point
else:
clicked_pos = local_event_pos
if not self.line_draw_buffer[0]:
u = clicked_pos.x()
v = clicked_pos.y()
point = Point2D(u,v)
self.sketch.add_point(point)
self.line_draw_buffer[0] = point
elif self.line_draw_buffer[0]:
u = clicked_pos.x()
v = clicked_pos.y()
point = Point2D(u, v)
self.sketch.add_point(point)
self.line_draw_buffer[1] = point
print("Buffer state", self.line_draw_buffer)
if self.line_draw_buffer[0] and self.line_draw_buffer[1]:
line = Line2D(self.line_draw_buffer[0], self.line_draw_buffer[1])
self.sketch.add_line(line)
# Reset the buffer for the next line segment
self.line_draw_buffer[0] = self.line_draw_buffer[1]
self.line_draw_buffer[1] = None
# Track Relationship
# Points
# CONSTRAINTS
if event.button() == Qt.LeftButton and self.mouse_mode == "pt_pt":
if self.hovered_point and not self.main_buffer[0]:
self.main_buffer[0] = self.get_handle_from_ui_point(self.hovered_point)
elif self.main_buffer[0]:
self.main_buffer[1] = self.get_handle_from_ui_point(self.hovered_point)
if self.main_buffer[0] and self.main_buffer[1]:
print("buf", self.main_buffer)
self.sketch.coincident(self.main_buffer[0], self.main_buffer[1], self.sketch.wp)
if self.sketch.solve() == ResultFlag.OKAY:
print("Fuck yeah")
elif self.sketch.solve() == ResultFlag.DIDNT_CONVERGE:
print("Solve_failed - Converge")
elif self.sketch.solve() == ResultFlag.TOO_MANY_UNKNOWNS:
print("Solve_failed - Unknowns")
elif self.sketch.solve() == ResultFlag.INCONSISTENT:
print("Solve_failed - Incons")
self.constrain_done.emit()
self.main_buffer = [None, None]
if event.button() == Qt.LeftButton and self.mouse_mode == "pt_line":
print("ptline")
line_selected = None
if self.hovered_point and not self.main_buffer[1]:
self.main_buffer[0] = self.get_handle_from_ui_point(self.hovered_point)
elif self.main_buffer[0]:
self.main_buffer[1] = self.get_line_handle_from_ui_point(local_event_pos)
# Contrain point to line
if self.main_buffer[1]:
self.sketch.coincident(self.main_buffer[0], self.main_buffer[1], self.sketch.wp)
if self.sketch.solve() == ResultFlag.OKAY:
print("Fuck yeah")
self.constrain_done.emit()
elif self.sketch.solve() == ResultFlag.DIDNT_CONVERGE:
print("Solve_failed - Converge")
elif self.sketch.solve() == ResultFlag.TOO_MANY_UNKNOWNS:
print("Solve_failed - Unknowns")
elif self.sketch.solve() == ResultFlag.INCONSISTENT:
print("Solve_failed - Incons")
self.constrain_done.emit()
# Clear saved_points after solve attempt
self.main_buffer = [None, None]
if event.button() == Qt.LeftButton and self.mouse_mode == "pb_con_mid":
print("ptline")
line_selected = None
if self.hovered_point and not self.main_buffer[1]:
self.main_buffer[0] = self.get_handle_from_ui_point(self.hovered_point)
elif self.main_buffer[0]:
self.main_buffer[1] = self.get_line_handle_from_ui_point(local_event_pos)
# Contrain point to line
if self.main_buffer[1]:
self.sketch.midpoint(self.main_buffer[0], self.main_buffer[1], self.sketch.wp)
if self.sketch.solve() == ResultFlag.OKAY:
print("Fuck yeah")
elif self.sketch.solve() == ResultFlag.DIDNT_CONVERGE:
print("Solve_failed - Converge")
elif self.sketch.solve() == ResultFlag.TOO_MANY_UNKNOWNS:
print("Solve_failed - Unknowns")
elif self.sketch.solve() == ResultFlag.INCONSISTENT:
print("Solve_failed - Incons")
self.constrain_done.emit()
self.main_buffer = [None, None]
if event.button() == Qt.LeftButton and self.mouse_mode == "horiz":
line_selected = self.get_line_handle_from_ui_point(local_event_pos)
if line_selected:
self.sketch.horizontal(line_selected, self.sketch.wp)
if self.sketch.solve() == ResultFlag.OKAY:
print("Fuck yeah")
elif self.sketch.solve() == ResultFlag.DIDNT_CONVERGE:
print("Solve_failed - Converge")
elif self.sketch.solve() == ResultFlag.TOO_MANY_UNKNOWNS:
print("Solve_failed - Unknowns")
elif self.sketch.solve() == ResultFlag.INCONSISTENT:
print("Solve_failed - Incons")
if event.button() == Qt.LeftButton and self.mouse_mode == "vert":
line_selected = self.get_line_handle_from_ui_point(local_event_pos)
if line_selected:
self.sketch.vertical(line_selected, self.sketch.wp)
if self.sketch.solve() == ResultFlag.OKAY:
print("Fuck yeah")
elif self.sketch.solve() == ResultFlag.DIDNT_CONVERGE:
print("Solve_failed - Converge")
elif self.sketch.solve() == ResultFlag.TOO_MANY_UNKNOWNS:
print("Solve_failed - Unknowns")
elif self.sketch.solve() == ResultFlag.INCONSISTENT:
print("Solve_failed - Incons")
if event.button() == Qt.LeftButton and self.mouse_mode == "distance":
# Depending on selected elemnts either point line or line distance
#print("distance")
e1 = None
e2 = None
if self.hovered_point:
print("buf point")
# Get the point as UI point as buffer
self.main_buffer[0] = self.hovered_point
elif self.selected_line:
# Get the point as UI point as buffer
self.main_buffer[1] = local_event_pos
if self.main_buffer[0] and self.main_buffer[1]:
# Define point line combination
e1 = self.get_handle_from_ui_point(self.main_buffer[0])
e2 = self.get_line_handle_from_ui_point(self.main_buffer[1])
elif not self.main_buffer[0]:
# Define only line selection
e1, e2 = self.get_point_line_handles_from_ui_point(local_event_pos)
if e1 and e2:
# Ask fo the dimension and solve if both elements are present
length, ok = QInputDialog.getDouble(self, 'Distance', 'Enter a mm value:', value=100, decimals=2)
self.sketch.distance(e1, e2, length, self.sketch.wp)
if self.sketch.solve() == ResultFlag.OKAY:
print("Fuck yeah")
elif self.sketch.solve() == ResultFlag.DIDNT_CONVERGE:
print("Solve_failed - Converge")
elif self.sketch.solve() == ResultFlag.TOO_MANY_UNKNOWNS:
print("Solve_failed - Unknowns")
elif self.sketch.solve() == ResultFlag.INCONSISTENT:
print("Solve_failed - Incons")
self.constrain_done.emit()
self.main_buffer = [None, None]
# Update the main point list with the new elements and draw them
points_need_update = self.check_all_points()
self.update_ui_points(points_need_update)
self.check_all_lines_and_update(points_need_update)
self.update()
def mouseMoveEvent(self, event):
local_event_pos = self.viewport_to_local_coord(event.pos())
#print(local_event_pos)
closest_point = None
min_distance = float('inf')
threshold = 10 # Distance threshold for highlighting
if self.mouse_mode == "line" and self.line_draw_buffer[0]:
# Update the current cursor position as the second point
self.dynamic_line_end = self.viewport_to_local_coord(event.pos())
self.update() # Trigger a repaint
if self.sketch.points is not None and len(self.sketch.points) > 0:
for point in self.sketch.points:
distance = (local_event_pos - point.ui_point).manhattanLength()
if distance < threshold and distance < min_distance:
closest_point = point.ui_point
min_distance = distance
"""for point in self.sketch.proj_points:
distance = (local_event_pos - point).manhattanLength()
if distance < threshold and distance < min_distance:
closest_point = point
min_distance = distance"""
if closest_point != self.hovered_point:
self.hovered_point = closest_point
print(self.hovered_point)
for line in self.sketch.lines:
p1 = line.crd1.ui_point
p2 = line.crd2.ui_point
if self.is_point_on_line(local_event_pos, p1, p2):
self.selected_line = p1, p2
# Midpointsnap only in drawer not solver
mid = self.calculate_midpoint(p1, p2)
distance = (local_event_pos - mid).manhattanLength()
if distance < threshold and distance < min_distance:
self.hovered_point = mid
break
else:
self.selected_line = None
self.update()
def mouseDoubleClickEvent(self, event):
pass
def drawBackgroundGrid(self, painter):
"""Draw a background grid."""
grid_spacing = 50
pen = QPen(QColor(200, 200, 200), 1, Qt.SolidLine)
painter.setPen(pen)
# Draw vertical grid lines
for x in range(-self.width() // 2, self.width() // 2, grid_spacing):
painter.drawLine(x, -self.height() // 2, x, self.height() // 2)
# Draw horizontal grid lines
for y in range(-self.height() // 2, self.height() // 2, grid_spacing):
painter.drawLine(-self.width() // 2, y, self.width() // 2, y)
def drawAxes(self, painter):
painter.setRenderHint(QPainter.Antialiasing)
# Set up pen for dashed lines
pen = QPen(Qt.gray, 1, Qt.DashLine)
painter.setPen(pen)
middle_x = self.width() // 2
middle_y = self.height() // 2
# Draw X axis as dashed line
painter.drawLine(0, middle_y, self.width(), middle_y)
# Draw Y axis as dashed line
painter.drawLine(middle_x, 0, middle_x, self.height())
# Draw tick marks
tick_length = int(10 * self.zoom)
tick_spacing = int(50 * self.zoom)
pen = QPen(Qt.gray, 1, Qt.SolidLine)
painter.setPen(pen)
# Draw tick marks on the X axis to the right and left from the middle point
for x in range(0, self.width() // 2, tick_spacing):
painter.drawLine(middle_x + x, middle_y - tick_length // 2, middle_x + x, middle_y + tick_length // 2)
painter.drawLine(middle_x - x, middle_y - tick_length // 2, middle_x - x, middle_y + tick_length // 2)
# Draw tick marks on the Y axis upwards and downwards from the middle point
for y in range(0, self.height() // 2, tick_spacing):
painter.drawLine(middle_x - tick_length // 2, middle_y + y, middle_x + tick_length // 2, middle_y + y)
painter.drawLine(middle_x - tick_length // 2, middle_y - y, middle_x + tick_length // 2, middle_y - y)
# Draw the origin point in red
painter.setPen(QPen(Qt.red, 4))
painter.drawPoint(middle_x, middle_y)
def draw_cross(self, painter, pos: QPoint, size=10):
# Set up the pen
pen = QPen(QColor('green')) # You can change the color as needed
pen.setWidth(int(2 / self.zoom)) # Set the line widt)h
painter.setPen(pen)
x = pos.x()
y = pos.y()
# Calculate the endpoints of the cross
half_size = size // 2
# Draw the horizontal line
painter.drawLine(x - half_size, y, x + half_size, y)
# Draw the vertical line
painter.drawLine(x, y - half_size, x, y + half_size)
def to_quadrant_coords(self, point):
"""Translate linear coordinates to quadrant coordinates."""
center_x = self.width() // 2
center_y = self.height() // 2
quadrant_x = point.x() - center_x
quadrant_y = center_y - point.y() # Note the change here
return QPoint(quadrant_x, quadrant_y) / self.zoom
def from_quadrant_coords(self, point: QPoint):
"""Translate quadrant coordinates to linear coordinates."""
center_x = self.width() // 2
center_y = self.height() // 2
widget_x = center_x + point.x() * self.zoom
widget_y = center_y - point.y() * self.zoom # Note the subtraction here
return QPoint(int(widget_x), int(widget_y))
def from_quadrant_coords_no_center(self, point):
"""Invert Y Coordinate for mesh"""
center_x = 0
center_y = 0
widget_x = point.x()
widget_y = -point.y()
return QPoint(int(widget_x), int(widget_y))
def paintEvent(self, event):
painter = QPainter(self)
painter.setRenderHint(QPainter.Antialiasing)
self.drawAxes(painter)
# Create a QTransform object
transform = QTransform()
# Translate the origin to the center of the widget
center = QPointF(self.width() / 2, self.height() / 2)
transform.translate(center.x(), center.y())
# Apply the zoom factor
transform.scale(self.zoom, -self.zoom) # Negative y-scale to invert y-axis
# Set the transform to the painter
painter.setTransform(transform)
pen_normal = QPen(Qt.gray)
pen_normal.setWidthF(2 / self.zoom)
pen_construct = QPen(Qt.cyan)
pen_construct.setStyle(Qt.PenStyle.DotLine)
pen_construct.setWidthF(1 / self.zoom)
pen_solver = QPen(Qt.green)
pen_solver.setWidthF(2 / self.zoom)
pen_text = QPen(Qt.white)
pen_text.setWidthF(1 / self.zoom)
# Draw points and lines
if self.sketch:
painter.setPen(pen_normal)
for point in self.sketch.points:
if point.is_helper:
painter.setPen(pen_construct)
painter.drawEllipse(point.ui_point, 10 / self.zoom, 10 / self.zoom)
else:
# Normal point
painter.setPen(pen_normal)
painter.drawEllipse(point.ui_point, 3 / self.zoom, 3 / self.zoom)
# Draw the dynamic line
if self.mouse_mode == "line" and self.line_draw_buffer[0] and self.dynamic_line_end is not None:
start_point = self.line_draw_buffer[0].ui_point
end_point = self.dynamic_line_end
painter.setPen(Qt.red) # Use a different color for the dynamic line
painter.drawLine(start_point, end_point)
# Save painter state
painter.save()
painter.setPen(pen_text)
# Calculate the distance and midpoint
dis = self.distance(start_point, end_point)
mid = self.calculate_midpoint(start_point, end_point)
# Transform for text
painter.translate(mid.x(), mid.y()) # Move to the midpoint
painter.scale(1, -1) # Flip y-axis back to make text readable
# Draw the text
painter.drawText(0, 0, str(round(dis, 2))) # Draw text at transformed position
# Restore painter state
painter.restore()
for line in self.sketch.lines:
if line.is_helper:
painter.setPen(pen_construct)
p1 = line.crd1.ui_point
p2 = line.crd2.ui_point
painter.drawLine(p1, p2)
else:
painter.setPen(pen_normal)
p1 = line.crd1.ui_point
p2 = line.crd2.ui_point
painter.drawLine(p1, p2)
# Draw all solver points
if self.sketch.entity_len():
painter.setPen(pen_solver)
for i in range(self.sketch.entity_len()):
entity = self.sketch.entity(i)
if entity.is_point_2d() and self.sketch.params(entity.params):
x, y = self.sketch.params(entity.params)
point = QPointF(x, y)
painter.drawEllipse(point, 6 / self.zoom, 6 / self.zoom)
# Highlight point hovered
if self.hovered_point:
highlight_pen = QPen(QColor(255, 0, 0))
highlight_pen.setWidthF(2 / self.zoom)
painter.setPen(highlight_pen)
painter.drawEllipse(self.hovered_point, 5 / self.zoom, 5 / self.zoom)
# Highlight line hovered
if self.selected_line and not self.hovered_point:
p1, p2 = self.selected_line
painter.setPen(QPen(Qt.red, 2 / self.zoom))
painter.drawLine(p1, p2)
"""for cross in self.sketch.proj_points:
self.draw_cross(painter, cross, 10 / self.zoom)
for selected in self.sketch.proj_lines:
pen = QPen(Qt.white, 1, Qt.DashLine)
painter.setPen(pen)
painter.drawLine(selected)"""
painter.end()
def wheelEvent(self, event):
delta = event.angleDelta().y()
self.zoom += (delta / 200) * 0.1
self.update()
def aspect_ratio(self):
return self.width() / self.height() * (1.0 / abs(self.zoom))
### GEOMETRY CLASSES
class Point2D:
def __init__(self, x, y):
self.id = None
self.ui_x: int = x
self.ui_y: int = y
self.ui_point = QPoint(self.ui_x, self.ui_y)
self.handle = None
self.handle_nr: int = None
# Construction Geometry
self.is_helper: bool = False
class Line2D:
def __init__(self, point_s: Point2D, point_e: Point2D):
self.id = None
self.crd1: Point2D = point_s
self.crd2: Point2D = point_e
self.handle = None
self.handle_nr: int = None
# Construction Geometry
self.is_helper: bool = False
class Sketch2d(SolverSystem):
"""
Primary class for internal drawing based on the SolveSpace libray
"""
def __init__(self):
self.id = uuid.uuid1()
self.wp = self.create_2d_base()
self.points = []
self.lines = []
self.origin = [0,0,0]
def add_point(self, point: Point2D):
"""
Adds a point into the solversystem and returns the handle.
Appends the added point to the points list.
:param point: 2D point in Point2D class format
:return:
"""
point.handle = self.add_point_2d(point.ui_x, point.ui_y, self.wp)
point.handle_nr = self.get_handle_nr(str(point.handle))
point.id = uuid.uuid1()
self.points.append(point)
def add_line(self, line: Line2D):
"""
Adds a line into the solversystem and returns the handle.
Appends the added line to the line list.
:param line:
:param point: 2D point in Point2D class format
:return:
"""
line.id = uuid.uuid1()
line.handle = self.add_line_2d(line.crd1.handle, line.crd2.handle, self.wp)
line.handle_nr = self.get_handle_nr(str(line.handle))
self.lines.append(line)
### HELPER AND TOOLS
def get_handle_nr(self, input_str: str) -> int:
# Define the regex pattern to extract the handle number
pattern = r"handle=(\d+)"
# Use re.search to find the handle number in the string
match = re.search(pattern, input_str)
if match:
handle_number = int(match.group(1))
print(f"Handle number: {handle_number}")
return int(handle_number)
else:
print("Handle number not found.")
return 0
if __name__ == "__main__":
import sys
app = QApplication(sys.argv)
window = SketchWidget()
window.setWindowTitle("Snap Line Widget")
window.resize(800, 600)
window.show()
sys.exit(app.exec())
-504
View File
@@ -1,504 +0,0 @@
import sys
import numpy as np
from PySide6.QtWidgets import QApplication, QMainWindow, QVBoxLayout, QWidget
from PySide6.QtOpenGLWidgets import QOpenGLWidget
from PySide6.QtCore import Qt, QPoint
from OpenGL.GL import *
from OpenGL.GLU import *
##testing
def create_cube(scale=1):
vertices = np.array([
[0, 0, 0],
[2, 0, 0],
[2, 2, 0],
[0, 2, 0],
[0, 0, 2],
[2, 0, 2],
[2, 2, 2],
[0, 2, 2]
]) * scale
faces = np.array([
[0, 1, 2],
[2, 3, 0],
[4, 5, 6],
[6, 7, 4],
[0, 1, 5],
[5, 4, 0],
[2, 3, 7],
[7, 6, 2],
[0, 3, 7],
[7, 4, 0],
[1, 2, 6],
[6, 5, 1]
])
return vertices, faces
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle("OpenGL Cube Viewer")
self.setGeometry(100, 100, 800, 600)
self.opengl_widget = OpenGLWidget()
central_widget = QWidget()
layout = QVBoxLayout()
layout.addWidget(self.opengl_widget)
central_widget.setLayout(layout)
self.setCentralWidget(central_widget)
# Load cube data
vertices, faces = create_cube()
self.opengl_widget.load_interactor_mesh((vertices, faces))
class OpenGLWidget(QOpenGLWidget):
def __init__(self, parent=None):
super().__init__(parent)
self.vertices = None
self.faces = None
self.selected_face = -1
self.scale_factor = 1
self.mesh_loaded = None
self.interactor_loaded = None
self.centroid = None
self.stl_file = "out.stl" # Replace with your STL file path
self.lastPos = QPoint()
self.startPos = None
self.endPos = None
self.xRot = 180
self.yRot = 0
self.zoom = -2
self.sketch = []
self.gl_width = self.width()
self.gl_height = self.height()
def map_value_to_range(self, value, value_min=0, value_max=1920, range_min=-1, range_max=1):
value = max(value_min, min(value_max, value))
mapped_value = ((value - value_min) / (value_max - value_min)) * (range_max - range_min) + range_min
return mapped_value
def load_stl(self, filename: str) -> object:
try:
stl_mesh = mesh.Mesh.from_file(filename)
# Extract vertices
vertices = np.concatenate([stl_mesh.v0, stl_mesh.v1, stl_mesh.v2])
# Calculate bounding box
min_x, min_y, min_z = vertices.min(axis=0)
max_x, max_y, max_z = vertices.max(axis=0)
# Calculate centroid
centroid_x = (min_x + max_x) / 2.0
centroid_y = (min_y + max_y) / 2.0
centroid_z = (min_z + max_z) / 2.0
self.mesh_loaded = stl_mesh.vectors
self.centroid = (centroid_x, centroid_y, centroid_z)
except FileNotFoundError:
print(f"Error: File {filename} not found.")
except Exception as e:
print(f"Error loading {filename}: {e}")
return None, (0, 0, 0)
def load_interactor_mesh(self, simp_mesh):
self.interactor_loaded = simp_mesh
# Calculate centroid based on the average position of vertices
centroid = np.mean(simp_mesh[0], axis=0)
self.centroid = tuple(centroid)
print(f"Centroid: {self.centroid}")
self.update()
def load_mesh_direct(self, mesh):
try:
stl_mesh = mesh
# Extract vertices
vertices = np.array(stl_mesh)
# Calculate centroid based on the average position of vertices
centroid = np.mean(vertices, axis=0)
self.mesh_loaded = vertices
self.centroid = tuple(centroid)
print(f"Centroid: {self.centroid}")
self.update()
except Exception as e:
print(e)
def clear_mesh(self):
self.mesh_loaded = None
def initializeGL(self):
glClearColor(0, 0, 0, 1)
glEnable(GL_DEPTH_TEST)
def resizeGL(self, width, height):
glViewport(0, 0, width, height)
glMatrixMode(GL_PROJECTION)
glLoadIdentity()
aspect = width / float(height)
self.gl_width = self.width()
self.gl_height = self.height()
gluPerspective(45.0, aspect, 0.01, 1000.0)
glMatrixMode(GL_MODELVIEW)
def unproject(self, x, y, z, modelview, projection, viewport):
mvp = np.dot(projection, modelview)
mvp_inv = np.linalg.inv(mvp)
ndc = np.array([(x - viewport[0]) / viewport[2] * 2 - 1,
(y - viewport[1]) / viewport[3] * 2 - 1,
2 * z - 1,
1])
world = np.dot(mvp_inv, ndc)
print("world undproj", world)
return world[:3] / world[3]
def draw_ray(self, ray_start, ray_end):
glColor3f(1.0, 0.0, 0.0) # Set the color of the ray (red)
glBegin(GL_LINES)
glVertex3f(*ray_start)
glVertex3f(*ray_end)
glEnd()
def mousePressEvent(self, event):
if event.buttons() & Qt.RightButton:
self.select_face(event)
def select_face(self, event):
x = event.position().x()
y = event.position().y()
modelview = glGetDoublev(GL_MODELVIEW_MATRIX)
projection = glGetDoublev(GL_PROJECTION_MATRIX)
viewport = glGetIntegerv(GL_VIEWPORT)
# Unproject near and far points in world space
ray_start = gluUnProject(x, y, 0.0, modelview, projection, viewport)
ray_end = gluUnProject(x, y, 1.0, modelview, projection, viewport)
ray_start = np.array(ray_start)
ray_end = np.array(ray_end)
ray_direction = ray_end - ray_start
ray_direction /= np.linalg.norm(ray_direction)
print(f"Ray start: {ray_start}")
print(f"Ray end: {ray_end}")
print(f"Ray direction: {ray_direction}")
self.selected_face = self.check_intersection(ray_start, ray_end)
print(f"Selected face: {self.selected_face}")
self.update()
def ray_box_intersection(self, ray_origin, ray_direction, box_min, box_max):
inv_direction = 1 / (ray_direction + 1e-7) # Add small value to avoid division by zero
t1 = (box_min - ray_origin) * inv_direction
t2 = (box_max - ray_origin) * inv_direction
t_min = np.max(np.minimum(t1, t2))
t_max = np.min(np.maximum(t1, t2))
print(f"min: {t_min}, max: {t_max}" )
return t_max >= t_min and t_max > 0
def check_intersection(self, ray_start, ray_end):
# Get the current modelview matrix
modelview = glGetDoublev(GL_MODELVIEW_MATRIX)
# Transform vertices to camera space
vertices_cam = [np.dot(modelview, np.append(v, 1))[:3] for v in self.interactor_loaded[0]]
ray_direction = ray_end - ray_start
ray_direction /= np.linalg.norm(ray_direction)
print(f"Checking intersection with {len(self.interactor_loaded[1])} faces")
for face_idx, face in enumerate(self.interactor_loaded[1]):
v0, v1, v2 = [vertices_cam[i] for i in face]
intersection = self.moller_trumbore(ray_start, ray_direction, v0, v1, v2)
if intersection is not None:
print(f"Intersection found with face {face_idx}")
return face_idx
print("No intersection found")
return None
def moller_trumbore(self, ray_origin, ray_direction, v0, v1, v2):
epsilon = 1e-6
# Find vectors for two edges sharing v0
edge1 = v1 - v0
edge2 = v2 - v0
pvec = np.cross(ray_direction, edge2)
det = np.dot(edge1, pvec)
print(det)
"""if det < epsilon:
return None"""
inv_det = 1.0 / det
tvec = ray_origin - v0
u = np.dot(tvec, pvec) * inv_det
print("u", u )
if u < 0.0 or u > 1.0:
return None
qvec = np.cross(tvec, edge1)
# Calculate v parameter and test bounds
v = np.dot(ray_direction, qvec) * inv_det
print("v", v)
if v < 0.0 or u + v > 1.0:
return None
# Calculate t, ray intersects triangle
t = np.dot(edge2, qvec) * inv_det
print("t",t)
if t > epsilon:
return ray_origin + t * ray_direction
return None
def ray_triangle_intersection(self, ray_origin, ray_direction, v0, v1, v2):
epsilon = 1e-5
edge1 = v1 - v0
edge2 = v2 - v0
h = np.cross(ray_direction, edge2)
a = np.dot(edge1, h)
print(f"Triangle vertices: {v0}, {v1}, {v2}")
print(f"a: {a}")
if abs(a) < epsilon:
print("Ray is parallel to the triangle")
return None # Ray is parallel to the triangle
f = 1.0 / a
s = ray_origin - v0
u = f * np.dot(s, h)
print(f"u: {u}")
if u < 0.0 or u > 1.0:
print("u is out of range")
return None
q = np.cross(s, edge1)
v = f * np.dot(ray_direction, q)
print(f"v: {v}")
if v < 0.0 or u + v > 1.0:
print("v is out of range")
return None
t = f * np.dot(edge2, q)
print(f"t: {t}")
if t > epsilon:
intersection_point = ray_origin + t * ray_direction
print(f"Intersection point: {intersection_point}")
return intersection_point
print("t is too small")
return None
def paintGL(self):
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT)
glMatrixMode(GL_MODELVIEW)
glLoadIdentity()
# Apply camera transformation
glTranslatef(0, 0, self.zoom)
glRotatef(self.xRot, 1.0, 0.0, 0.0)
glRotatef(self.yRot, 0.0, 1.0, 0.0)
"""# Apply model transformation
glTranslatef(self.tx, self.ty, self.tz)
glScalef(self.scale, self.scale, self.scale)
glRotatef(self.model_xRot, 1.0, 0.0, 0.0)
glRotatef(self.model_yRot, 0.0, 1.0, 0.0)
glRotatef(self.model_zRot, 0.0, 0.0, 1.0)"""
glColor3f(0.9, 0.8, 0.8)
self.draw_area()
if self.mesh_loaded is not None:
# Adjust the camera for the STL mesh
if self.centroid:
glPushMatrix() # Save current transformation matrix
glScalef(self.scale_factor, self.scale_factor, self.scale_factor) # Apply scaling
cx, cy, cz = self.centroid
gluLookAt(cx, cy, cz + 100, cx, cy, cz, 0, 1, 0)
self.draw_mesh_direct(self.mesh_loaded)
glPopMatrix() # Restore transformation matrix
if self.interactor_loaded is not None:
# Draw interactor mesh
glPushMatrix() # Save current transformation matrix
glScalef(self.scale_factor, self.scale_factor, self.scale_factor) # Apply scaling
self.draw_interactor(self.interactor_loaded)
glPopMatrix() # Restore transformation matrix
if self.selected_face is not None:
glColor3f(0.0, 1.0, 0.0) # Red color for selected face
glBegin(GL_TRIANGLES)
for vertex_idx in self.interactor_loaded[1][self.selected_face]:
glVertex3fv(self.interactor_loaded[0][vertex_idx])
glEnd()
# Flush the OpenGL pipeline and swap buffers
if hasattr(self, 'ray_start') and hasattr(self, 'ray_end'):
self.draw_ray(self.ray_start, self.ray_end)
glFlush()
def draw_stl(self, vertices):
glEnable(GL_LIGHTING)
glEnable(GL_LIGHT0)
glEnable(GL_DEPTH_TEST)
glEnable(GL_COLOR_MATERIAL)
glColorMaterial(GL_FRONT_AND_BACK, GL_AMBIENT_AND_DIFFUSE)
glLightfv(GL_LIGHT0, GL_POSITION, (0, 1, 1, 0))
glLightfv(GL_LIGHT0, GL_DIFFUSE, (0.6, 0.6, 0.6, 1.0))
glBegin(GL_TRIANGLES)
for triangle in vertices:
for vertex in triangle:
glVertex3fv(vertex)
glEnd()
self.update()
def draw_interactor(self, simp_mesh: tuple):
vertices, faces = simp_mesh
glEnable(GL_LIGHTING)
glEnable(GL_LIGHT0)
glEnable(GL_DEPTH_TEST)
glEnable(GL_COLOR_MATERIAL)
glColorMaterial(GL_FRONT_AND_BACK, GL_AMBIENT_AND_DIFFUSE)
glLightfv(GL_LIGHT0, GL_POSITION, (0, 0.6, 0.6, 0))
glLightfv(GL_LIGHT0, GL_DIFFUSE, (0.4, 0.4, 0.4, 0.6))
# Draw the faces
glDisable(GL_LIGHTING)
glColor3f(0.2, 0.0, 0.0) # Set face color to red (or any color you prefer)
glBegin(GL_TRIANGLES)
for face in faces:
for vertex_index in face:
glVertex3fv(vertices[vertex_index])
glEnd()
# Draw the lines (edges of the triangles)
glColor3f(0.0, 1.0, 0.0) # Set line color to green (or any color you prefer)
glBegin(GL_LINES)
for face in faces:
for i in range(len(face)):
glVertex3fv(vertices[face[i]])
glVertex3fv(vertices[face[(i + 1) % len(face)]])
glEnd()
glEnable(GL_LIGHTING) # Re-enable lighting if further drawing requires it
def draw_mesh_direct(self, points):
glEnable(GL_LIGHTING)
glEnable(GL_LIGHT0)
glEnable(GL_DEPTH_TEST)
glEnable(GL_COLOR_MATERIAL)
glColorMaterial(GL_FRONT_AND_BACK, GL_AMBIENT_AND_DIFFUSE)
glLightfv(GL_LIGHT0, GL_POSITION, (0, 0.6, 0.6, 0))
glLightfv(GL_LIGHT0, GL_DIFFUSE, (0.4, 0.4, 0.4, 0.6))
glDisable(GL_LIGHTING)
glBegin(GL_TRIANGLES)
for vertex in points:
glVertex3fv(vertex)
glEnd()
# Draw the lines (edges of the triangles)
#glDisable(GL_LIGHTING) # Disable lighting to avoid affecting the line color
glColor3f(0.0, 0.0, 0.0) # Set line color to black (or any color you prefer)
glBegin(GL_LINES)
for i in range(0, len(points), 3):
glVertex3fv(points[i])
glVertex3fv(points[i + 1])
glVertex3fv(points[i + 1])
glVertex3fv(points[i + 2])
glVertex3fv(points[i + 2])
glVertex3fv(points[i])
glEnd()
glEnable(GL_LIGHTING) # Re-enable lighting if further drawing requires it
def draw_area(self):
glColor3f(0.5, 0.5, 0.5) # Gray color
glBegin(GL_LINES)
for x in range(0, self.width(), 1):
x_ndc = self.map_value_to_range(x, 0, value_max=self.width(), range_min=-self.gl_width, range_max=self.gl_width)
glVertex2f(x_ndc, -self.gl_height) # Start from y = -1
glVertex2f(x_ndc, self.gl_height) # End at y = 1
for y in range(0, self.height(), 1):
y_ndc = self.map_value_to_range(y, 0, value_max=self.height(), range_min=-self.gl_height, range_max=self.gl_height)
glVertex2f(-self.gl_width, y_ndc) # Start from x = -1
glVertex2f(self.gl_width, y_ndc) # End at x = 1
glEnd()
def mouseMoveEvent(self, event):
dx = event.x() - self.lastPos.x()
dy = event.y() - self.lastPos.y()
if event.buttons() & Qt.MouseButton.LeftButton :
self.xRot += 0.5 * dy
self.yRot += 0.5 * dx
self.lastPos = event.pos()
self.update()
def wheelEvent(self, event):
delta = event.angleDelta().y()
self.zoom += delta / 200
self.update()
def aspect_ratio(self):
return self.width() / self.height() * (1.0 / abs(self.zoom))
if __name__ == "__main__":
app = QApplication(sys.argv)
window = MainWindow()
window.show()
sys.exit(app.exec())
-35
View File
@@ -1,35 +0,0 @@
from python_solvespace import SolverSystem, ResultFlag
def solve_constraint():
solv = SolverSystem()
wp = solv.create_2d_base() # Workplane (Entity)
p0 = solv.add_point_2d(0, 0, wp) # Entity
p1 = solv.add_point_2d(10, 10, wp) # Entity
p2 = solv.add_point_2d(0, 10, wp) # Entity
solv.dragged(p0, wp) # Make a constraint with the entity
line0 = solv.add_line_2d(p0, p1, wp) # Create entity with others
line1 = solv.add_line_2d(p0, p2, wp)
#solv.angle(line0, line1, 45, wp) # Constrain two entities
solv.coincident(p0, p1, wp)
solv.add_constraint(100006, wp, 0, p1,p2, line0, line1)
line1 = solv.entity(-1) # Entity handle can be re-generated and negatively indexed
solv.
if solv.solve() == ResultFlag.OKAY:
# Get the result (unpack from the entity or parameters)
# x and y are actually float type
dof = solv.dof()
x, y = solv.params(p1.params)
print(dof)
print(x)
print(y)
else:
# Error!
# Get the list of all constraints
failures = solv.failures()
print(failures)
...
solve_constraint()
-815
View File
@@ -1,815 +0,0 @@
import sys
import numpy as np
import vtk
from PySide6 import QtCore, QtWidgets
from PySide6.QtCore import Signal
from vtkmodules.qt.QVTKRenderWindowInteractor import QVTKRenderWindowInteractor
from vtkmodules.util.numpy_support import vtk_to_numpy, numpy_to_vtk
class VTKWidget(QtWidgets.QWidget):
face_data = Signal(dict)
def __init__(self, parent=None):
super().__init__(parent)
self.selected_vtk_line = []
self.access_selected_points = []
self.selected_normal = None
self.centroid = None
self.selected_edges = []
self.cell_normals = None
self.local_matrix = None
self.project_tosketch_points = []
self.project_tosketch_lines = []
self.vtk_widget = QVTKRenderWindowInteractor(self)
self.picked_edge_actors = []
self.displayed_normal_actors = []
self.body_actors_orig = []
self.projected_mesh_actors = []
self.interactor_actors = []
self.flip_toggle = False
# Create layout and add VTK widget
layout = QtWidgets.QVBoxLayout()
layout.addWidget(self.vtk_widget)
self.setLayout(layout)
# Create VTK pipeline
self.renderer = vtk.vtkRenderer()
self.renderer_projections = vtk.vtkRenderer()
self.renderer_indicators = vtk.vtkRenderer()
self.renderer.SetViewport(0, 0, 1, 1) # Full viewport
self.renderer_projections.SetViewport(0, 0, 1, 1) # Full viewport, overlays the first
self.renderer_indicators.SetViewport(0, 0, 1, 1) # Full viewport, overlays the first
self.renderer.SetLayer(0)
self.renderer_projections.SetLayer(1)
self.renderer_indicators.SetLayer(2) # This will be on top
# Preserve color and depth buffers for non-zero layers
self.renderer_projections.SetPreserveColorBuffer(True)
self.renderer_projections.SetPreserveDepthBuffer(True)
self.renderer_indicators.SetPreserveColorBuffer(True)
self.renderer_indicators.SetPreserveDepthBuffer(True)
# Add renderers to the render window
render_window = self.vtk_widget.GetRenderWindow()
render_window.SetNumberOfLayers(3)
render_window.AddRenderer(self.renderer)
render_window.AddRenderer(self.renderer_projections)
render_window.AddRenderer(self.renderer_indicators)
self.camera = vtk.vtkCamera()
self.camera.SetPosition(5, 5, 1000)
self.camera.SetFocalPoint(0, 0, 0)
self.camera.SetClippingRange(1, 10000) # Adjusted clipping range
self.renderer.SetActiveCamera(self.camera)
self.renderer_projections.SetActiveCamera(self.camera)
self.renderer_indicators.SetActiveCamera(self.camera)
self.interactor = self.vtk_widget.GetRenderWindow().GetInteractor()
# Light Setup
def add_light(renderer, position, color=(1, 1, 1), intensity=1.0):
light = vtk.vtkLight()
light.SetPosition(position)
light.SetColor(color)
light.SetIntensity(intensity)
renderer.AddLight(light)
# Add lights from multiple directions
add_light(self.renderer, (1000, 0, 0), intensity=1.5)
add_light(self.renderer, (-1000, 0, 0), intensity=1.5)
add_light(self.renderer, (0, 1000, 0), intensity=1.5)
add_light(self.renderer, (0, -1000, 0), intensity=1.5)
add_light(self.renderer, (0, 0, 1000), intensity=1.5)
add_light(self.renderer, (0, 0, -1000), intensity=1.5)
# Set up picking
self.picker = vtk.vtkCellPicker()
self.picker.SetTolerance(0.005)
# Create a mapper and actor for picked cells
self.picked_mapper = vtk.vtkDataSetMapper()
self.picked_actor = vtk.vtkActor()
self.picked_actor.SetMapper(self.picked_mapper)
self.picked_actor.GetProperty().SetColor(1.0, 0.0, 0.0) # Red color for picked faces
self.picked_actor.VisibilityOff() # Initially hide the actor
self.renderer.AddActor(self.picked_actor)
# Create an extract selection filter
self.extract_selection = vtk.vtkExtractSelection()
# Set up interactor style
self.style = vtk.vtkInteractorStyleTrackballCamera()
self.interactor.SetInteractorStyle(self.style)
# Add observer for mouse clicks
self.interactor.AddObserver("RightButtonPressEvent", self.on_click)
# Add axis gizmo (smaller size)
self.axes = vtk.vtkAxesActor()
self.axes.SetTotalLength(0.5, 0.5, 0.5) # Reduced size
self.axes.SetShaftType(0)
self.axes.SetAxisLabels(1)
# Create an orientation marker
self.axes_widget = vtk.vtkOrientationMarkerWidget()
self.axes_widget.SetOrientationMarker(self.axes)
self.axes_widget.SetInteractor(self.interactor)
self.axes_widget.SetViewport(0.0, 0.0, 0.2, 0.2) # Set position and size
self.axes_widget.EnabledOn()
self.axes_widget.InteractiveOff()
# Start the interactor
self.interactor.Initialize()
self.interactor.Start()
# Create the grid
grid = self.create_grid(size=100, spacing=10)
# Setup actor and mapper
mapper = vtk.vtkPolyDataMapper()
mapper.SetInputData(grid)
actor = vtk.vtkActor()
actor.SetPickable(False)
actor.SetMapper(mapper)
actor.GetProperty().SetColor(0.5, 0.5, 0.5) # Set grid color to gray
self.renderer.AddActor(actor)
def reset_camera(self):
self.renderer.ResetCamera()
self.camera.SetClippingRange(1, 100000) # Set your desired range
self.vtk_widget.GetRenderWindow().Render()
def update_render(self):
self.renderer.ResetCameraClippingRange()
self.renderer_projections.ResetCameraClippingRange()
self.renderer_indicators.ResetCameraClippingRange()
self.camera.SetClippingRange(1, 100000)
self.vtk_widget.GetRenderWindow().Render()
def create_grid(self, size=100, spacing=10):
# Create a vtkPoints object and store the points in it
points = vtk.vtkPoints()
# Create lines
lines = vtk.vtkCellArray()
# Create the grid
for i in range(-size, size + 1, spacing):
# X-direction line
points.InsertNextPoint(i, -size, 0)
points.InsertNextPoint(i, size, 0)
line = vtk.vtkLine()
line.GetPointIds().SetId(0, points.GetNumberOfPoints() - 2)
line.GetPointIds().SetId(1, points.GetNumberOfPoints() - 1)
lines.InsertNextCell(line)
# Y-direction line
points.InsertNextPoint(-size, i, 0)
points.InsertNextPoint(size, i, 0)
line = vtk.vtkLine()
line.GetPointIds().SetId(0, points.GetNumberOfPoints() - 2)
line.GetPointIds().SetId(1, points.GetNumberOfPoints() - 1)
lines.InsertNextCell(line)
# Create a polydata to store everything in
grid = vtk.vtkPolyData()
# Add the points to the dataset
grid.SetPoints(points)
# Add the lines to the dataset
grid.SetLines(lines)
return grid
def on_receive_command(self, command):
"""Calls the individual commands pressed in main"""
print("Receive command: ", command)
if command == "flip":
self.clear_actors_projection()
self.flip_toggle = not self.flip_toggle # Toggle the flag
self.on_invert_normal()
@staticmethod
def compute_normal_from_lines(line1, line2):
vec1 = line1[1] - line1[0]
vec2 = line2[1] - line2[0]
normal = np.cross(vec1, vec2)
print(normal)
normal = normal / np.linalg.norm(normal)
return normal
def load_interactor_mesh(self, edges, off_vector):
# Create vtkPoints to store all points
points = vtk.vtkPoints()
# Create vtkCellArray to store the lines
lines = vtk.vtkCellArray()
for edge in edges:
# Add points for this edge
point_id1 = points.InsertNextPoint(edge[0])
point_id2 = points.InsertNextPoint(edge[1])
# Create a line using the point IDs
line = vtk.vtkLine()
line.GetPointIds().SetId(0, point_id1)
line.GetPointIds().SetId(1, point_id2)
# Add the line to the cell array
lines.InsertNextCell(line)
# Create vtkPolyData to store the geometry
polydata = vtk.vtkPolyData()
polydata.SetPoints(points)
polydata.SetLines(lines)
# Create a transform for mirroring across the y-axis
matrix_transform = vtk.vtkTransform()
if self.local_matrix:
print(self.local_matrix)
matrix = vtk.vtkMatrix4x4()
matrix.DeepCopy(self.local_matrix)
matrix.Invert()
matrix_transform.SetMatrix(matrix)
#matrix_transform.Scale(1, 1, 1) # This mirrors across the y-axis
# Apply the matrix transform
transformFilter = vtk.vtkTransformPolyDataFilter()
transformFilter.SetInputData(polydata)
transformFilter.SetTransform(matrix_transform)
transformFilter.Update()
# Create and apply the offset transform
offset_transform = vtk.vtkTransform()
offset_transform.Translate(off_vector[0], off_vector[1], off_vector[2])
offsetFilter = vtk.vtkTransformPolyDataFilter()
offsetFilter.SetInputConnection(transformFilter.GetOutputPort())
offsetFilter.SetTransform(offset_transform)
offsetFilter.Update()
# Create a mapper and actor
mapper = vtk.vtkPolyDataMapper()
mapper.SetInputConnection(offsetFilter.GetOutputPort())
actor = vtk.vtkActor()
actor.SetMapper(mapper)
actor.GetProperty().SetColor(1.0, 1.0, 1.0)
actor.GetProperty().SetLineWidth(4) # Set line width
# Add the actor to the scene
self.renderer.AddActor(actor)
self.interactor_actors.append(actor)
mapper.Update()
self.vtk_widget.GetRenderWindow().Render()
def render_from_points_direct_with_faces(self, vertices, faces, color=(0.1, 0.2, 0.8), line_width=2, point_size=5):
"""Sketch Widget has inverted Y axiis therefore we invert y via scale here until fix"""
points = vtk.vtkPoints()
# Use SetData with numpy array
vtk_array = numpy_to_vtk(vertices, deep=True)
points.SetData(vtk_array)
# Create a vtkCellArray to store the triangles
triangles = vtk.vtkCellArray()
for face in faces:
triangle = vtk.vtkTriangle()
triangle.GetPointIds().SetId(0, face[0])
triangle.GetPointIds().SetId(1, face[1])
triangle.GetPointIds().SetId(2, face[2])
triangles.InsertNextCell(triangle)
# Create a polydata object
polydata = vtk.vtkPolyData()
polydata.SetPoints(points)
polydata.SetPolys(triangles)
# Calculate normals
normalGenerator = vtk.vtkPolyDataNormals()
normalGenerator.SetInputData(polydata)
normalGenerator.ComputePointNormalsOn()
normalGenerator.ComputeCellNormalsOn()
normalGenerator.Update()
self.cell_normals = vtk_to_numpy(normalGenerator.GetOutput().GetCellData().GetNormals())
# Create a mapper and actor
mapper = vtk.vtkPolyDataMapper()
mapper.SetInputData(polydata)
actor = vtk.vtkActor()
actor.SetMapper(mapper)
actor.GetProperty().SetColor(color)
actor.GetProperty().EdgeVisibilityOff()
actor.GetProperty().SetLineWidth(line_width)
actor.GetProperty().SetMetallic(1)
actor.GetProperty().SetOpacity(0.8)
actor.SetPickable(False)
self.renderer.AddActor(actor)
self.body_actors_orig.append(actor)
self.vtk_widget.GetRenderWindow().Render()
def clear_body_actors(self):
for actor in self.body_actors_orig:
self.renderer.RemoveActor(actor)
def visualize_matrix(self, matrix):
points = vtk.vtkPoints()
for i in range(4):
for j in range(4):
points.InsertNextPoint(matrix.GetElement(0, j),
matrix.GetElement(1, j),
matrix.GetElement(2, j))
polydata = vtk.vtkPolyData()
polydata.SetPoints(points)
mapper = vtk.vtkPolyDataMapper()
mapper.SetInputData(polydata)
actor = vtk.vtkActor()
actor.SetMapper(mapper)
actor.GetProperty().SetPointSize(5)
self.renderer.AddActor(actor)
def numpy_to_vtk(self, array, deep=True):
"""Convert a numpy array to a vtk array."""
vtk_array = vtk.vtkDoubleArray()
vtk_array.SetNumberOfComponents(array.shape[1])
vtk_array.SetNumberOfTuples(array.shape[0])
for i in range(array.shape[0]):
for j in range(array.shape[1]):
vtk_array.SetComponent(i, j, array[i, j])
return vtk_array
def get_points_and_edges_from_polydata(self, polydata) -> list:
# Extract points
points = {}
vtk_points = polydata.GetPoints()
for i in range(vtk_points.GetNumberOfPoints()):
point = vtk_points.GetPoint(i)
points[i] = np.array(point)
# Extract edges
edges = []
for i in range(polydata.GetNumberOfCells()):
cell = polydata.GetCell(i)
if cell.GetCellType() == vtk.VTK_LINE:
point_ids = cell.GetPointIds()
edge = (point_ids.GetId(0), point_ids.GetId(1))
edges.append(edge)
return points, edges
def project_mesh_to_plane(self, input_mesh, normal, origin):
# Create the projector
projector = vtk.vtkProjectPointsToPlane()
projector.SetInputData(input_mesh)
projector.SetProjectionTypeToSpecifiedPlane()
# Set the normal and origin of the plane
projector.SetNormal(normal)
projector.SetOrigin(origin)
# Execute the projection
projector.Update()
# Get the projected mesh
projected_mesh = projector.GetOutput()
return projected_mesh
def compute_2d_coordinates(self, projected_mesh, normal):
# Normalize the normal vector
normal = np.array(normal)
normal = normal / np.linalg.norm(normal)
# Create a vtkTransform
transform = vtk.vtkTransform()
transform.PostMultiply() # This ensures transforms are applied in the order we specify
# Rotate so that the normal aligns with the Z-axis
rotation_axis = np.cross(normal, [0, 0, 1])
angle = np.arccos(np.dot(normal, [0, 0, 1])) * 180 / np.pi # Convert to degrees
if np.linalg.norm(rotation_axis) > 1e-6: # Check if rotation is needed
transform.RotateWXYZ(angle, rotation_axis[0], rotation_axis[1], rotation_axis[2])
# Get the transformation matrix
matrix = transform.GetMatrix()
self.local_matrix = [matrix.GetElement(i, j) for i in range(4) for j in range(4)]
# Apply the transform to the polydata
transformFilter = vtk.vtkTransformPolyDataFilter()
transformFilter.SetInputData(projected_mesh)
transformFilter.SetTransform(transform)
transformFilter.Update()
# Get the transformed points
transformed_polydata = transformFilter.GetOutput()
points = transformed_polydata.GetPoints()
# Extract 2D coordinates
xy_coordinates = []
for i in range(points.GetNumberOfPoints()):
point = points.GetPoint(i)
xy_coordinates.append((point[0], point[1]))
return xy_coordinates
def compute_2d_coordinates_line(self, projected_mesh, normal):
# Normalize the normal vector
normal = np.array(normal)
normal = normal / np.linalg.norm(normal)
# Create a vtkTransform
transform = vtk.vtkTransform()
transform.PostMultiply() # This ensures transforms are applied in the order we specify
# Rotate so that the normal aligns with the Z-axis
rotation_axis = np.cross(normal, [0, 0, 1])
angle = np.arccos(np.dot(normal, [0, 0, 1])) * 180 / np.pi # Convert to degrees
if np.linalg.norm(rotation_axis) > 1e-6: # Check if rotation is needed
transform.RotateWXYZ(angle, rotation_axis[0], rotation_axis[1], rotation_axis[2])
# Get the transformation matrix
matrix = transform.GetMatrix()
self.local_matrix = [matrix.GetElement(i, j) for i in range(4) for j in range(4)]
# Apply the transform to the polydata
transformFilter = vtk.vtkTransformPolyDataFilter()
transformFilter.SetInputData(projected_mesh)
transformFilter.SetTransform(transform)
transformFilter.Update()
# Get the transformed points
transformed_polydata = transformFilter.GetOutput()
points = transformed_polydata.GetPoints()
lines = transformed_polydata.GetLines()
# Extract 2D coordinates
xy_coordinates = []
if points and lines:
points_data = points.GetData()
line_ids = vtk.vtkIdList()
# Loop through all the lines in the vtkCellArray
lines.InitTraversal()
while lines.GetNextCell(line_ids):
line_coordinates = []
for j in range(line_ids.GetNumberOfIds()):
point_id = line_ids.GetId(j)
point = points.GetPoint(point_id)
line_coordinates.append((point[0], point[1])) # Only take x, y
xy_coordinates.append(line_coordinates)
return xy_coordinates
def compute_2d_coordinates_line_bak(self, line_source, normal):
# Ensure the input is a vtkLineSource
print("line", line_source)
if not isinstance(line_source, vtk.vtkLineSource):
raise ValueError("Input must be a vtkLineSource")
# Normalize the normal vector
normal = np.array(normal)
normal = normal / np.linalg.norm(normal)
# Create a vtkTransform
transform = vtk.vtkTransform()
transform.PostMultiply() # This ensures transforms are applied in the order we specify
# Rotate so that the normal aligns with the Z-axis
rotation_axis = np.cross(normal, [0, 0, 1])
angle = np.arccos(np.dot(normal, [0, 0, 1])) * 180 / np.pi # Convert to degrees
if np.linalg.norm(rotation_axis) > 1e-6: # Check if rotation is needed
transform.RotateWXYZ(angle, rotation_axis[0], rotation_axis[1], rotation_axis[2])
# Get the transformation matrix
matrix = transform.GetMatrix()
local_matrix = [matrix.GetElement(i, j) for i in range(4) for j in range(4)]
# Get the polydata from the line source
line_source.Update()
polydata = line_source.GetOutput()
# Apply the transform to the polydata
transform_filter = vtk.vtkTransformPolyDataFilter()
transform_filter.SetInputData(polydata)
transform_filter.SetTransform(transform)
transform_filter.Update()
# Get the transformed points
transformed_polydata = transform_filter.GetOutput()
transformed_points = transformed_polydata.GetPoints()
# Extract 2D coordinates
xy_coordinates = []
for i in range(transformed_points.GetNumberOfPoints()):
point = transformed_points.GetPoint(i)
xy_coordinates.append((point[0], point[1]))
return xy_coordinates
def project_2d_to_3d(self, xy_coordinates, normal):
# Normalize the normal vector
normal = np.array(normal)
normal = normal / np.linalg.norm(normal)
# Create a vtkTransform for the reverse transformation
reverse_transform = vtk.vtkTransform()
reverse_transform.PostMultiply() # This ensures transforms are applied in the order we specify
# Compute the rotation axis and angle (same as in compute_2d_coordinates)
rotation_axis = np.cross(normal, [0, 0, 1])
angle = np.arccos(np.dot(normal, [0, 0, 1])) * 180 / np.pi # Convert to degrees
if np.linalg.norm(rotation_axis) > 1e-6: # Check if rotation is needed
# Apply the inverse rotation
reverse_transform.RotateWXYZ(-angle, rotation_axis[0], rotation_axis[1], rotation_axis[2])
# Create vtkPoints to store the 2D points
points_2d = vtk.vtkPoints()
for x, y in xy_coordinates:
points_2d.InsertNextPoint(x, y, 0) # Z-coordinate is 0 for 2D points
# Create a polydata with the 2D points
polydata_2d = vtk.vtkPolyData()
polydata_2d.SetPoints(points_2d)
# Apply the reverse transform to the polydata
transform_filter = vtk.vtkTransformPolyDataFilter()
transform_filter.SetInputData(polydata_2d)
transform_filter.SetTransform(reverse_transform)
transform_filter.Update()
# Get the transformed points (now in 3D)
transformed_polydata = transform_filter.GetOutput()
transformed_points = transformed_polydata.GetPoints()
# Extract 3D coordinates
xyz_coordinates = []
for i in range(transformed_points.GetNumberOfPoints()):
point = transformed_points.GetPoint(i)
xyz_coordinates.append((point[0], point[1], point[2]))
return xyz_coordinates
def add_normal_line(self, origin, normal, length=10.0, color=(1, 0, 0)):
# Normalize the normal vector
normal = np.array(normal)
normal = normal / np.linalg.norm(normal)
# Calculate the end point
end_point = origin + normal * length
# Create vtkPoints
points = vtk.vtkPoints()
points.InsertNextPoint(origin)
points.InsertNextPoint(end_point)
# Create a line
line = vtk.vtkLine()
line.GetPointIds().SetId(0, 0)
line.GetPointIds().SetId(1, 1)
# Create a cell array to store the line
lines = vtk.vtkCellArray()
lines.InsertNextCell(line)
# Create a polydata to store everything in
polyData = vtk.vtkPolyData()
polyData.SetPoints(points)
polyData.SetLines(lines)
# Create mapper and actor
mapper = vtk.vtkPolyDataMapper()
mapper.SetInputData(polyData)
actor = vtk.vtkActor()
actor.SetMapper(mapper)
actor.GetProperty().SetColor(color)
actor.GetProperty().SetLineWidth(2) # Adjust line width as needed
# Add to renderer
self.renderer.AddActor(actor)
self.vtk_widget.GetRenderWindow().Render()
return actor # Return the actor in case you need to remove or modify it later
def on_invert_normal(self):
# Kippstufe für Normal flip
if self.selected_normal is not None:
self.clear_actors_normals()
self.compute_projection(self.flip_toggle)
def on_click(self, obj, event):
click_pos = self.interactor.GetEventPosition()
# Perform pick
self.picker.Pick(click_pos[0], click_pos[1], 0, self.renderer)
# Get picked cell ID
cell_id = self.picker.GetCellId()
if cell_id != -1:
print(f"Picked cell ID: {cell_id}")
# Get the polydata and the picked cell
polydata = self.picker.GetActor().GetMapper().GetInput()
cell = polydata.GetCell(cell_id)
# Ensure it's a line
if cell.GetCellType() == vtk.VTK_LINE:
# Get the two points of the line
point_id1 = cell.GetPointId(0)
point_id2 = cell.GetPointId(1)
proj_point1 = polydata.GetPoint(point_id1)
proj_point2 = polydata.GetPoint(point_id2)
self.access_selected_points.append((proj_point1, proj_point2))
point1 = np.array(proj_point1)
point2 = np.array(proj_point2)
#print(f"Line starts at: {point1}")
#print(f"Line ends at: {point2}")
# Store this line for later use if needed
self.selected_edges.append((point1, point2))
# Create a new vtkLineSource for the picked edge
line_source = vtk.vtkLineSource()
line_source.SetPoint1(point1)
line_source.SetPoint2(point2)
self.selected_vtk_line.append(line_source)
# Create a mapper and actor for the picked edge
edge_mapper = vtk.vtkPolyDataMapper()
edge_mapper.SetInputConnection(line_source.GetOutputPort())
edge_actor = vtk.vtkActor()
edge_actor.SetMapper(edge_mapper)
edge_actor.GetProperty().SetColor(1.0, 0.0, 0.0) # Red color for picked edges
edge_actor.GetProperty().SetLineWidth(5) # Make the line thicker
# Add the actor to the renderer and store it
self.renderer_indicators.AddActor(edge_actor)
self.picked_edge_actors.append(edge_actor)
if len(self.selected_edges) == 2:
self.compute_projection(False)
if len(self.selected_edges) > 2:
# Clear lists for selection
self.selected_vtk_line.clear()
self.selected_edges.clear()
self.clear_edge_select()
# Clear Actors from view
self.clear_actors_projection()
self.clear_actors_sel_edges()
self.clear_actors_normals()
def find_origin_vertex(self, edge1, edge2):
if edge1[0] == edge2[0]or edge1[0] == edge2[1]:
return edge1[0]
elif edge1[1] == edge2[0] or edge1[1] == edge2[1]:
return edge1[1]
else:
return None # The edges don't share a vertex
def clear_edge_select(self ):
# Clear selection after projection was succesful
self.selected_edges = []
self.selected_normal = []
def clear_actors_projection(self):
"""Removes all actors that were used for projection"""
for flat_mesh in self.projected_mesh_actors:
self.renderer_projections.RemoveActor(flat_mesh)
def clear_actors_normals(self):
for normals in self.displayed_normal_actors:
self.renderer_indicators.RemoveActor(normals)
def clear_actors_sel_edges(self):
for edge_line in self.picked_edge_actors:
self.renderer_indicators.RemoveActor(edge_line)
def clear_actors_interactor(self):
### Clear the outline of the mesh
for interactor in self.interactor_actors:
self.renderer.RemoveActor(interactor)
def compute_projection(self, direction_invert: bool = False):
# Compute the normal from the two selected edges )
edge1 = self.selected_edges[0][1] - self.selected_edges[0][0]
edge2 = self.selected_edges[1][1] - self.selected_edges[1][0]
selected_normal = np.cross(edge1, edge2)
selected_normal = selected_normal / np.linalg.norm(selected_normal)
#print("Computed normal:", self.selected_normal)
# Invert the normal in local z if direction_invert is True
if direction_invert:
self.selected_normal = -selected_normal
else:
self.selected_normal = selected_normal
self.centroid = np.mean([point for edge in self.selected_edges for point in edge], axis=0)
#self.centroid = self.find_origin_vertex(edge1, edge2)
# Draw the normal line
normal_length = 50 # Adjust this value to change the length of the normal line
normal_actor = self.add_normal_line(self.centroid, self.selected_normal, length=normal_length,
color=(1, 0, 0))
polydata = self.picker.GetActor().GetMapper().GetInput()
projected_polydata = self.project_mesh_to_plane(polydata, self.selected_normal, self.centroid)
# Extract 2D coordinates
self.project_tosketch_points = self.compute_2d_coordinates(projected_polydata, self.selected_normal)
# Green indicator mesh needs to be translated to xy point paris start end.
self.project_tosketch_lines = self.compute_2d_coordinates_line(projected_polydata, self.selected_normal)
print("result", self.project_tosketch_lines)
"""# Seperately rotate selected edges for drawing
self.project_tosketch_lines.clear()
for vtk_line in self.selected_vtk_line:
proj_vtk_line = self.compute_2d_coordinates_line(vtk_line, self.selected_normal)
self.project_tosketch_lines.append(proj_vtk_line)
print("outgoing lines", self.project_tosketch_lines)"""
# Create a mapper and actor for the projected data
mapper = vtk.vtkPolyDataMapper()
mapper.SetInputData(projected_polydata)
# Projected mesh in green
actor = vtk.vtkActor()
actor.SetMapper(mapper)
#actor.GetProperty().SetRenderLinesAsTubes(True)
actor.GetProperty().SetColor(0.0, 1.0, 0.0) # Set color to green
actor.GetProperty().SetLineWidth(4) # Set line width
self.renderer_indicators.AddActor(normal_actor)
self.displayed_normal_actors.append(normal_actor)
self.renderer_projections.AddActor(actor)
self.projected_mesh_actors.append(actor)
# Render the scene
self.update_render()
self.vtk_widget.GetRenderWindow().Render()
def start(self):
self.interactor.Initialize()
self.interactor.Start()
class MainWindow(QtWidgets.QMainWindow):
def __init__(self, parent=None):
super().__init__(parent)
self.vtk_widget = VTKWidget()
self.setCentralWidget(self.vtk_widget)
self.setWindowTitle("VTK Mesh Viewer")
self.vtk_widget.create_cube_mesh()
self.show()
self.vtk_widget.start()
if __name__ == "__main__":
app = QtWidgets.QApplication(sys.argv)
window = MainWindow()
sys.exit(app.exec())
-337
View File
@@ -1,337 +0,0 @@
def are_coplanar(self, normal1, normal2, point1, point2, tolerance=1e-6):
# Check if normals are parallel
if np.abs(np.dot(normal1, normal2)) < 1 - tolerance:
return False
# Check if points lie on the same plane
diff = point2 - point1
return np.abs(np.dot(diff, normal1)) < tolerance
def merge_coplanar_triangles(self, polydata):
# Compute normals
normalGenerator = vtk.vtkPolyDataNormals()
normalGenerator.SetInputData(polydata)
normalGenerator.ComputePointNormalsOff()
normalGenerator.ComputeCellNormalsOn()
normalGenerator.Update()
mesh = normalGenerator.GetOutput()
n_cells = mesh.GetNumberOfCells()
# Create a map to store merged triangles
merged = {}
for i in range(n_cells):
if i in merged:
continue
cell = mesh.GetCell(i)
normal = np.array(mesh.GetCellData().GetNormals().GetTuple(i))
point = np.array(cell.GetPoints().GetPoint(0))
merged[i] = [i]
for j in range(i + 1, n_cells):
if j in merged:
continue
cell_j = mesh.GetCell(j)
normal_j = np.array(mesh.GetCellData().GetNormals().GetTuple(j))
point_j = np.array(cell_j.GetPoints().GetPoint(0))
if self.are_coplanar(normal, normal_j, point, point_j):
merged[i].append(j)
# Create new polygons
new_polygons = vtk.vtkCellArray()
for group in merged.values():
if len(group) > 1:
polygon = vtk.vtkPolygon()
points = set()
for idx in group:
cell = mesh.GetCell(idx)
for j in range(3):
point_id = cell.GetPointId(j)
points.add(point_id)
polygon.GetPointIds().SetNumberOfIds(len(points))
for j, point_id in enumerate(points):
polygon.GetPointIds().SetId(j, point_id)
new_polygons.InsertNextCell(polygon)
else:
new_polygons.InsertNextCell(mesh.GetCell(group[0]))
# Create new polydata
new_polydata = vtk.vtkPolyData()
new_polydata.SetPoints(mesh.GetPoints())
new_polydata.SetPolys(new_polygons)
return new_polydata
def create_cube_mesh(self):
# cube_source = vtk.vtkSuperquadricSource()
reader = vtk.vtkSTLReader()
reader.SetFileName("case.stl") # Replace with your mesh file path
reader.Update()
featureEdges = vtk.vtkFeatureEdges()
featureEdges.SetInputConnection(reader.GetOutputPort())
featureEdges.BoundaryEdgesOn()
featureEdges.FeatureEdgesOn()
featureEdges.ManifoldEdgesOff()
featureEdges.NonManifoldEdgesOff()
featureEdges.Update()
# print(cube_source)
mapper = vtk.vtkPolyDataMapper()
mapper.SetInputConnection(reader.GetOutputPort())
actor = vtk.vtkActor()
actor.SetMapper(mapper)
self.renderer.AddActor(actor)
mapper_edge = vtk.vtkPolyDataMapper()
mapper_edge.SetInputConnection(featureEdges.GetOutputPort())
actor = vtk.vtkActor()
actor.SetMapper(mapper_edge)
self.renderer.AddActor(actor)
def simplify_mesh(self, input_mesh, target_reduction):
# Create the quadric decimation filter
decimate = vtk.vtkDecimatePro()
decimate.SetInputData(input_mesh)
# Set the reduction factor (0 to 1, where 1 means maximum reduction)
decimate.SetTargetReduction(target_reduction)
# Optional: Preserve topology (if needed)
decimate.PreserveTopologyOn()
# Perform the decimation
decimate.Update()
return decimate.GetOutput()
def combine_coplanar_faces(self, input_polydata, tolerance=0.001):
# Clean the polydata to merge duplicate points
clean = vtk.vtkCleanPolyData()
clean.SetInputData(input_polydata)
clean.SetTolerance(tolerance)
clean.Update()
# Generate normals and merge coplanar polygons
normals = vtk.vtkPolyDataNormals()
normals.SetInputConnection(clean.GetOutputPort())
normals.SplittingOff() # Disable splitting of sharp edges
normals.ConsistencyOn() # Ensure consistent polygon ordering
normals.AutoOrientNormalsOn() # Automatically orient normals
normals.ComputePointNormalsOff() # We only need face normals
normals.ComputeCellNormalsOn() # Compute cell normals
normals.Update()
return normals.GetOutput()
def poisson_reconstruction(self, points):
# Create a polydata object from points
point_polydata = vtk.vtkPolyData()
point_polydata.SetPoints(points)
# Create a surface reconstruction filter
surf = vtk.vtkSurfaceReconstructionFilter()
surf.SetInputData(point_polydata)
surf.Update()
# Create a contour filter to extract the surface
cf = vtk.vtkContourFilter()
cf.SetInputConnection(surf.GetOutputPort())
cf.SetValue(0, 0.0)
cf.Update()
# Reverse normals
reverse = vtk.vtkReverseSense()
reverse.SetInputConnection(cf.GetOutputPort())
reverse.ReverseCellsOn()
reverse.ReverseNormalsOn()
reverse.Update()
return reverse.GetOutput()
def create_simplified_outline(self, polydata):
featureEdges = vtk.vtkFeatureEdges()
featureEdges.SetInputData(polydata)
featureEdges.BoundaryEdgesOn()
featureEdges.FeatureEdgesOn()
featureEdges.ManifoldEdgesOff()
featureEdges.NonManifoldEdgesOff()
featureEdges.Update()
"""# 3. Clean the edges to merge duplicate points
cleaner = vtk.vtkCleanPolyData()
cleaner.SetInputConnection(feature_edges.GetOutputPort())
cleaner.Update()
# 4. Optional: Smooth the outline
smooth = vtk.vtkSmoothPolyDataFilter()
smooth.SetInputConnection(cleaner.GetOutputPort())
smooth.SetNumberOfIterations(15)
smooth.SetRelaxationFactor(0.1)
smooth.FeatureEdgeSmoothingOff()
smooth.BoundarySmoothingOn()
smooth.Update()"""
return featureEdges
def render_from_points_direct_with_faces(self, vertices, faces):
points = vtk.vtkPoints()
for i in range(vertices.shape[0]):
points.InsertNextPoint(vertices[i])
# Create a vtkCellArray to store the triangles
triangles = vtk.vtkCellArray()
for i in range(faces.shape[0]):
triangle = vtk.vtkTriangle()
triangle.GetPointIds().SetId(0, faces[i, 0])
triangle.GetPointIds().SetId(1, faces[i, 1])
triangle.GetPointIds().SetId(2, faces[i, 2])
triangles.InsertNextCell(triangle)
"""vtk_points = vtk.vtkPoints()
for point in points:
vtk_points.InsertNextPoint(point)
# Create a vtkCellArray to store the triangles
triangles = vtk.vtkCellArray()
# Assuming points are organized as triplets forming triangles
for i in range(0, len(points), 3):
triangle = vtk.vtkTriangle()
triangle.GetPointIds().SetId(0, i)
triangle.GetPointIds().SetId(1, i + 1)
triangle.GetPointIds().SetId(2, i + 2)
triangles.InsertNextCell(triangle)"""
# Create a polydata object
polydata = vtk.vtkPolyData()
polydata.SetPoints(points)
polydata.SetPolys(triangles)
# Calculate normals
normalGenerator = vtk.vtkPolyDataNormals()
normalGenerator.SetInputData(polydata)
normalGenerator.ComputePointNormalsOn()
normalGenerator.ComputeCellNormalsOn()
normalGenerator.Update()
self.cell_normals = vtk_to_numpy(normalGenerator.GetOutput().GetCellData().GetNormals())
# merged_polydata = self.merge_coplanar_triangles(polydata)
# Create a mapper and actor
mapper = vtk.vtkPolyDataMapper()
mapper.SetInputData(polydata)
actor = vtk.vtkActor()
actor.SetMapper(mapper)
actor.GetProperty().SetColor(1, 1, 1) # Set color (white in this case)
actor.GetProperty().EdgeVisibilityOn() # Show edges
actor.GetProperty().SetLineWidth(2) # Set line width
feature_edges = self.create_simplified_outline(polydata)
# Create a mapper for the feature edges
edge_mapper = vtk.vtkPolyDataMapper()
# Already wiht output
edge_mapper.SetInputConnection(feature_edges.GetOutputPort())
# Create an actor for the feature edges
edge_actor = vtk.vtkActor()
edge_actor.SetMapper(edge_mapper)
# Set the properties of the edge actor
edge_actor.GetProperty().SetColor(1, 0, 0) # Set color (red in this case)
edge_actor.GetProperty().SetLineWidth(2) # Set line width
# Optionally, if you want to keep the original mesh visible:
# (assuming you have the original mesh mapper and actor set up)
self.renderer.AddActor(actor) # Add the original mesh actor
# Add the edge actor to the renderer
self.renderer.AddActor(edge_actor)
# Force an update of the pipeline
mapper.Update()
self.vtk_widget.GetRenderWindow().Render()
"""# Print statistics
print(f"Original points: {len(points)}")
print(f"Number of triangles: {triangles.GetNumberOfCells()}")
print(f"Final number of points: {normals.GetOutput().GetNumberOfPoints()}")
print(f"Final number of cells: {normals.GetOutput().GetNumberOfCells()}")"""
def render_from_points_direct(self, points):
### Rendermethod for SDF mesh (output)
# Create a vtkPoints object and store the points in it
vtk_points = vtk.vtkPoints()
for point in points:
vtk_points.InsertNextPoint(point)
# Create a polydata object
point_polydata = vtk.vtkPolyData()
point_polydata.SetPoints(vtk_points)
# Surface reconstruction
surf = vtk.vtkSurfaceReconstructionFilter()
surf.SetInputData(point_polydata)
surf.Update()
# Create a contour filter to extract the surface
cf = vtk.vtkContourFilter()
cf.SetInputConnection(surf.GetOutputPort())
cf.SetValue(0, 0.0)
cf.Update()
# Reverse the normals
reverse = vtk.vtkReverseSense()
reverse.SetInputConnection(cf.GetOutputPort())
reverse.ReverseCellsOn()
reverse.ReverseNormalsOn()
reverse.Update()
# Get the reconstructed mesh
reconstructed_mesh = reverse.GetOutput()
"""# Simplify the mesh
target_reduction = 1 # Adjust this value as needed
simplified_mesh = self.simplify_mesh(reconstructed_mesh, target_reduction)
combinded_faces = self.combine_coplanar_faces(simplified_mesh, 0.001)"""
# Create a mapper and actor for the simplified mesh
mapper = vtk.vtkPolyDataMapper()
mapper.SetInputData(reconstructed_mesh)
actor = vtk.vtkActor()
actor.SetMapper(mapper)
actor.GetProperty().SetColor(1, 1, 1) # Set color (white in this case)
actor.GetProperty().EdgeVisibilityOn() # Show edges
actor.GetProperty().SetLineWidth(2) # Set line width
# Add the actor to the renderer
self.renderer.AddActor(actor)
# Force an update of the pipeline
# mapper.Update()
self.vtk_widget.GetRenderWindow().Render()
# Print statistics
print(f"Original points: {len(points)}")
print(
f"Reconstructed mesh: {reconstructed_mesh.GetNumberOfPoints()} points, {reconstructed_mesh.GetNumberOfCells()} cells")
"""print(
f"Simplified mesh: {simplified_mesh.GetNumberOfPoints()} points, {simplified_mesh.GetNumberOfCells()} cells")"""
-111
View File
@@ -1,111 +0,0 @@
import sys
import numpy as np
import pyvista as pv
from pyvista.plotting.opts import ElementType
from pyvistaqt import QtInteractor
from PySide6.QtWidgets import QApplication, QMainWindow, QVBoxLayout, QWidget
class PyVistaWidget(QWidget):
def __init__(self, parent=None):
super().__init__(parent)
# Create the PyVista plotter
self.plotter = QtInteractor(self)
self.plotter.background_color = "darkgray"
# Create a layout and add the PyVista widget
layout = QVBoxLayout()
layout.addWidget(self.plotter.interactor)
self.setLayout(layout)
# Set up the picker
#self.plotter.enable_cell_picking(callback=self.on_cell_pick, show=True)
self.plotter.enable_element_picking(callback=self.on_cell_pick, show=True, mode="face", left_clicking=True)
def on_cell_pick(self, element):
if element is not None:
mesh = self.plotter.mesh # Get the current mesh
print(mesh)
print(element)
"""# Get the face data
face = mesh.extract_cells(element)
# Compute face normal
face.compute_normals(cell_normals=True, inplace=True)
normal = face.cell_data['Normals'][0]
# Get the points of the face
points = face.points
print(f"Picked face ID: {face_id}")
print(f"Face normal: {normal}")
print("Face points:")
for point in points:
print(point)"""
else:
print("No face was picked or the picked element is not a face.")
def create_simplified_outline(self, mesh, camera):
# Project 3D to 2D
points_2d = self.plotter.map_to_2d(mesh.points)
# Detect silhouette edges (simplified approach)
edges = mesh.extract_feature_edges(feature_angle=90, boundary_edges=False, non_manifold_edges=False)
# Project edges to 2D
edge_points_2d = self.plotter.map_to_2d(edges.points)
# Create 2D outline
self.plotter.add_lines(edge_points_2d, color='black', width=2)
self.plotter.render()
def mesh_from_points(self, points):
# Convert points to numpy array if not already
points = np.array(points)
# Create faces array
num_triangles = len(points) // 3
faces = np.arange(len(points)).reshape(num_triangles, 3)
faces = np.column_stack((np.full(num_triangles, 3), faces)) # Add 3 as first column
# Create PyVista PolyData
mesh = pv.PolyData(points, faces)
# Optional: Merge duplicate points
mesh = mesh.clean()
# Optional: Compute normals
mesh = mesh.compute_normals(point_normals=False, cell_normals=True, consistent_normals=True)
edges = mesh.extract_feature_edges(30, non_manifold_edges=False)
# Clear any existing meshes
self.plotter.clear()
# Add the mesh to the plotter
self.plotter.add_mesh(mesh, pickable=True, color='white', show_edges=True, line_width=2, pbr=True, metallic=0.8, roughness=0.1, diffuse=1)
self.plotter.add_mesh(edges, color="red", line_width=10)
# Reset the camera to fit the new mesh
self.plotter.reset_camera()
# Update the render window
self.plotter.update()
# Print statistics
print(f"Original points: {len(points)}")
print(f"Number of triangles: {num_triangles}")
print(f"Final number of points: {mesh.n_points}")
print(f"Final number of cells: {mesh.n_cells}")
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle("PyVista in PySide6")
self.setGeometry(100, 100, 800, 600)
Binary file not shown.

After

Width:  |  Height:  |  Size: 319 KiB

+839 -494
View File
File diff suppressed because it is too large Load Diff
+659 -471
View File
File diff suppressed because it is too large Load Diff
+16
View File
@@ -0,0 +1,16 @@
Copyright (C) 2025 Thomas Herrmann
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+22
View File
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDisplayName</key>
<string>main</string>
<key>CFBundleExecutable</key>
<string>main</string>
<key>CFBundleIdentifier</key>
<string>main</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>main</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>NSHighResolutionCapable</key>
<true/>
</dict>
</plist>
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

Some files were not shown because too many files have changed in this diff Show More