Compare commits
33 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 742d06d242 | |||
| c78d0af78c | |||
| dda9db822b | |||
| 9f1387fe68 | |||
| 210e3cfb5d | |||
| b8516fff91 | |||
| d7e5929a13 | |||
| 2b2afbc479 | |||
| b0aebdc04f | |||
| be22c44a3f | |||
| 80ba3cc70a | |||
| 5269c0897c | |||
| 3a169007f7 | |||
| b595b88e04 | |||
| 9f10a5c5e5 | |||
| 6ba742ddf5 | |||
| 01833e4af2 | |||
| f860ff3e77 | |||
| 9938f4ddd4 | |||
| f6422e0847 | |||
| f8f16ea800 | |||
| 54ac2c098a | |||
| ea34e7e29d | |||
| 7091f530ee | |||
| 75d4820292 | |||
| d52106a48a | |||
| d7ebbf45d5 | |||
| daed79dac6 | |||
| e13769840b | |||
| 5371bf7c38 | |||
| bf00310889 | |||
| 8c6a413137 | |||
| fe23ca610c |
+36
-1
@@ -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
|
||||||
Generated
-11
@@ -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="jdk" jdkName="Python 3.12 (fluency)" jdkType="Python SDK" />
|
|
||||||
<orderEntry type="sourceFolder" forTests="false" />
|
|
||||||
</component>
|
|
||||||
</module>
|
|
||||||
Generated
+1
-1
@@ -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
+224
-33
@@ -4,11 +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="- added sdf folder ( doesnt work via pip or git=)">
|
<list default="true" id="8f0bafd6-58a0-4b20-aa2b-ddc3ba278873" name="Changes" comment="- added renderer - Added undo">
|
||||||
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
|
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
|
||||||
<change beforePath="$PROJECT_DIR$/drawing_modules/draw_widget_solve.py" beforeDir="false" afterPath="$PROJECT_DIR$/drawing_modules/draw_widget_solve.py" 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 beforePath="$PROJECT_DIR$/main.py" beforeDir="false" afterPath="$PROJECT_DIR$/main.py" 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 beforePath="$PROJECT_DIR$/mesh_modules/interactor_mesh.py" beforeDir="false" afterPath="$PROJECT_DIR$/mesh_modules/interactor_mesh.py" 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 beforePath="$PROJECT_DIR$/src/fluency/ui/main_window.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/ui/main_window.py" 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 beforePath="$PROJECT_DIR$/src/fluency/ui/viewer_widget.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/ui/viewer_widget.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" />
|
||||||
@@ -25,7 +28,7 @@
|
|||||||
<component name="Git.Settings">
|
<component name="Git.Settings">
|
||||||
<option name="RECENT_BRANCH_BY_REPOSITORY">
|
<option name="RECENT_BRANCH_BY_REPOSITORY">
|
||||||
<map>
|
<map>
|
||||||
<entry key="$PROJECT_DIR$" value="structure" />
|
<entry key="$PROJECT_DIR$" value="single_window" />
|
||||||
</map>
|
</map>
|
||||||
</option>
|
</option>
|
||||||
<option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
|
<option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
|
||||||
@@ -45,30 +48,45 @@
|
|||||||
<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": {
|
"keyToString": {
|
||||||
"Python.2dtest.executor": "Run",
|
"Python.2dtest.executor": "Run",
|
||||||
"Python.3d_windows.executor": "Run",
|
"Python.3d_windows.executor": "Run",
|
||||||
"Python.Unnamed.executor": "Run",
|
"Python.Unnamed.executor": "Run",
|
||||||
"Python.draw_widget2d.executor": "Run",
|
"Python.base.executor": "Run",
|
||||||
"Python.draw_widget_solve.executor": "Run",
|
"Python.data_model.executor": "Run",
|
||||||
"Python.fluency.executor": "Run",
|
"Python.draw_widget2d.executor": "Run",
|
||||||
"Python.fluencyb.executor": "Run",
|
"Python.draw_widget_solve.executor": "Run",
|
||||||
"Python.gl_widget.executor": "Run",
|
"Python.fluency.executor": "Run",
|
||||||
"Python.main.executor": "Run",
|
"Python.fluencyb.executor": "Run",
|
||||||
"Python.meshtest.executor": "Run",
|
"Python.gl_widget.executor": "Run",
|
||||||
"Python.side_fluency.executor": "Run",
|
"Python.gui_ui.executor": "Run",
|
||||||
"Python.simple_mesh.executor": "Run",
|
"Python.kernel.executor": "Run",
|
||||||
"Python.vtk_widget.executor": "Run",
|
"Python.main.executor": "Run",
|
||||||
"Python.vulkan.executor": "Run",
|
"Python.meshtest.executor": "Run",
|
||||||
"RunOnceActivity.OpenProjectViewOnStart": "true",
|
"Python.occ_renderer.executor": "Run",
|
||||||
"RunOnceActivity.ShowReadmeOnStart": "true",
|
"Python.occ_to_mesh.executor": "Run",
|
||||||
"RunOnceActivity.git.unshallow": "true",
|
"Python.render_backend.executor": "Run",
|
||||||
"git-widget-placeholder": "master",
|
"Python.side_fluency.executor": "Run",
|
||||||
"last_opened_file_path": "/Volumes/Data_drive/Programming/fluency",
|
"Python.simple_mesh.executor": "Run",
|
||||||
"settings.editor.selected.configurable": "project.propVCSSupport.DirectoryMappings"
|
"Python.sketch.executor": "Run",
|
||||||
|
"Python.vtk_widget.executor": "Run",
|
||||||
|
"Python.vulkan.executor": "Run",
|
||||||
|
"RunOnceActivity.OpenProjectViewOnStart": "true",
|
||||||
|
"RunOnceActivity.ShowReadmeOnStart": "true",
|
||||||
|
"RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252": "true",
|
||||||
|
"RunOnceActivity.git.unshallow": "true",
|
||||||
|
"RunOnceActivity.typescript.service.memoryLimit.init": "true",
|
||||||
|
"codeWithMe.voiceChat.enabledByDefault": "false",
|
||||||
|
"git-widget-placeholder": "feature/occ-migration",
|
||||||
|
"last_opened_file_path": "/Volumes/Data_drive/Programming/fluency/src/fluency/rendering",
|
||||||
|
"node.js.detected.package.eslint": "true",
|
||||||
|
"node.js.selected.package.eslint": "(autodetect)",
|
||||||
|
"node.js.selected.package.tslint": "(autodetect)",
|
||||||
|
"nodejs_package_manager_path": "npm",
|
||||||
|
"settings.editor.selected.configurable": "project.propVCSSupport.DirectoryMappings"
|
||||||
}
|
}
|
||||||
}]]></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" />
|
||||||
@@ -76,6 +94,8 @@
|
|||||||
</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$" />
|
||||||
<recent name="$PROJECT_DIR$/drawing_modules" />
|
<recent name="$PROJECT_DIR$/drawing_modules" />
|
||||||
<recent name="$PROJECT_DIR$/modules" />
|
<recent name="$PROJECT_DIR$/modules" />
|
||||||
@@ -87,7 +107,7 @@
|
|||||||
<component name="SharedIndexes">
|
<component name="SharedIndexes">
|
||||||
<attachedChunks>
|
<attachedChunks>
|
||||||
<set>
|
<set>
|
||||||
<option value="bundled-python-sdk-4c141bd692a7-e2d783800521-com.jetbrains.pycharm.community.sharedIndexes.bundled-PC-251.26927.90" />
|
<option value="bundled-python-sdk-c59985aa861c-c2ffad84badb-com.jetbrains.pycharm.pro.sharedIndexes.bundled-PY-261.24374.152" />
|
||||||
</set>
|
</set>
|
||||||
</attachedChunks>
|
</attachedChunks>
|
||||||
</component>
|
</component>
|
||||||
@@ -252,9 +272,172 @@
|
|||||||
<option name="project" value="LOCAL" />
|
<option name="project" value="LOCAL" />
|
||||||
<updated>1755369224187</updated>
|
<updated>1755369224187</updated>
|
||||||
</task>
|
</task>
|
||||||
<option name="localTasksCounter" value="20" />
|
<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 - 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 - 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 - 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 - 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 - 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 - 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 - 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 - 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>
|
||||||
@@ -270,8 +453,6 @@
|
|||||||
<ignored-roots>
|
<ignored-roots>
|
||||||
<path value="$PROJECT_DIR$/pythonProject" />
|
<path value="$PROJECT_DIR$/pythonProject" />
|
||||||
</ignored-roots>
|
</ignored-roots>
|
||||||
<MESSAGE value="init" />
|
|
||||||
<MESSAGE value="- Basic oop sketch widget implement" />
|
|
||||||
<MESSAGE value="- Renabled extrusion with new object system" />
|
<MESSAGE value="- Renabled extrusion with new object system" />
|
||||||
<MESSAGE value="- Sketch projection partly works again :)" />
|
<MESSAGE value="- Sketch projection partly works again :)" />
|
||||||
<MESSAGE value="- Added new componnt controls" />
|
<MESSAGE value="- Added new componnt controls" />
|
||||||
@@ -287,6 +468,16 @@
|
|||||||
<MESSAGE value="- added MIT license" />
|
<MESSAGE value="- added MIT license" />
|
||||||
<MESSAGE value="- added screenshot" />
|
<MESSAGE value="- added screenshot" />
|
||||||
<MESSAGE value="- added sdf folder ( doesnt work via pip or git=)" />
|
<MESSAGE value="- added sdf folder ( doesnt work via pip or git=)" />
|
||||||
<option name="LAST_COMMIT_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 - Split main.py refactor" />
|
||||||
|
<MESSAGE value="- Working assembly multi :)" />
|
||||||
|
<MESSAGE value="- added renderer" />
|
||||||
|
<MESSAGE value="- added renderer - Added undo" />
|
||||||
|
<option name="LAST_COMMIT_MESSAGE" value="- added renderer - Added undo" />
|
||||||
</component>
|
</component>
|
||||||
</project>
|
</project>
|
||||||
@@ -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
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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)
|
||||||
|
```
|
||||||
@@ -1,849 +0,0 @@
|
|||||||
# Fluency CAD - Improved Sketcher Technical Documentation
|
|
||||||
|
|
||||||
## Table of Contents
|
|
||||||
1. [Overview](#overview)
|
|
||||||
2. [Architecture](#architecture)
|
|
||||||
3. [Core Components](#core-components)
|
|
||||||
4. [Geometry System](#geometry-system)
|
|
||||||
5. [Constraint Solving](#constraint-solving)
|
|
||||||
6. [Coordinate Systems](#coordinate-systems)
|
|
||||||
7. [Interaction System](#interaction-system)
|
|
||||||
8. [Rendering System](#rendering-system)
|
|
||||||
9. [Snapping System](#snapping-system)
|
|
||||||
10. [Working Plane Integration](#working-plane-integration)
|
|
||||||
11. [API Reference](#api-reference)
|
|
||||||
12. [Performance Considerations](#performance-considerations)
|
|
||||||
13. [Troubleshooting](#troubleshooting)
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
The ImprovedSketchWidget is a parametric 2D sketching system built for Fluency CAD. It provides constraint-based geometric modeling with real-time solving, integrated snapping, and seamless integration with 3D working planes. The system is built on top of the SolverSpace constraint solver and PySide6 for the user interface.
|
|
||||||
|
|
||||||
### Key Features
|
|
||||||
- **Parametric Geometry**: All geometry is constraint-driven and automatically updates
|
|
||||||
- **Real-time Solving**: Constraints are solved dynamically as geometry is modified
|
|
||||||
- **Advanced Snapping**: Multi-mode snapping system (points, midpoints, grid, angles)
|
|
||||||
- **Construction Geometry**: Support for helper/construction geometry
|
|
||||||
- **Working Plane Integration**: Seamless 2D/3D workflow with projected geometry
|
|
||||||
- **Interactive Dragging**: Smooth point dragging with constraint preservation
|
|
||||||
- **Multiple Drawing Modes**: Lines, rectangles, circles, arcs, and points
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────────────────────────────────────────────────┐
|
|
||||||
│ ImprovedSketchWidget │
|
|
||||||
│ ┌─────────────────┐ ┌─────────────────────────────┐ │
|
|
||||||
│ │ User Interface │ │ Rendering System │ │
|
|
||||||
│ │ - Mouse Events │ │ - Coordinate Transform │ │
|
|
||||||
│ │ - Keyboard │ │ - Geometry Drawing │ │
|
|
||||||
│ │ - Mode Control │ │ - UI Overlays │ │
|
|
||||||
│ └─────────────────┘ └─────────────────────────────┘ │
|
|
||||||
│ │ │ │
|
|
||||||
│ └─────────┬───────────────┘ │
|
|
||||||
│ │ │
|
|
||||||
│ ┌─────────────────────────────────────────────────┐ │
|
|
||||||
│ │ Interaction System │ │
|
|
||||||
│ │ - Snapping Engine │ │
|
|
||||||
│ │ - Dragging Logic │ │
|
|
||||||
│ │ - Selection Management │ │
|
|
||||||
│ └─────────────────────────────────────────────────┘ │
|
|
||||||
│ │ │
|
|
||||||
│ ┌─────────────────────────────────────────────────┐ │
|
|
||||||
│ │ Geometry System │ │
|
|
||||||
│ │ ┌─────────────┐ ┌─────────────────────────┐ │ │
|
|
||||||
│ │ │ Point2D │ │ Line2D │ │ │
|
|
||||||
│ │ │ Circle2D │ │ Arc2D (future) │ │ │
|
|
||||||
│ │ └─────────────┘ └─────────────────────────┘ │ │
|
|
||||||
│ └─────────────────────────────────────────────────┘ │
|
|
||||||
│ │ │
|
|
||||||
│ ┌─────────────────────────────────────────────────┐ │
|
|
||||||
│ │ ImprovedSketch │ │
|
|
||||||
│ │ (Enhanced SolverSystem) │ │
|
|
||||||
│ │ - Constraint Management │ │
|
|
||||||
│ │ - Solver Integration │ │
|
|
||||||
│ │ - Geometry Storage │ │
|
|
||||||
│ └─────────────────────────────────────────────────┘ │
|
|
||||||
│ │ │
|
|
||||||
│ ┌─────────────────────────────────────────────────┐ │
|
|
||||||
│ │ SolverSpace Library │ │
|
|
||||||
│ │ - Constraint Solving Engine │ │
|
|
||||||
│ │ - Geometric Relationships │ │
|
|
||||||
│ └─────────────────────────────────────────────────┘ │
|
|
||||||
└─────────────────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
## Core Components
|
|
||||||
|
|
||||||
### 1. ImprovedSketchWidget
|
|
||||||
The main widget class that handles user interaction and rendering.
|
|
||||||
|
|
||||||
**Key Responsibilities:**
|
|
||||||
- Mouse and keyboard event handling
|
|
||||||
- Mode management (line, circle, constraint modes, etc.)
|
|
||||||
- Coordinate system transformations
|
|
||||||
- Rendering pipeline orchestration
|
|
||||||
- Integration with external systems (working planes)
|
|
||||||
|
|
||||||
### 2. ImprovedSketch
|
|
||||||
Enhanced wrapper around SolverSpace's SolverSystem.
|
|
||||||
|
|
||||||
**Key Responsibilities:**
|
|
||||||
- Geometry storage and management
|
|
||||||
- Constraint system integration
|
|
||||||
- Solver result processing
|
|
||||||
- Handle management for solver objects
|
|
||||||
|
|
||||||
### 3. Geometry Classes
|
|
||||||
Type-safe geometry representations with validation.
|
|
||||||
|
|
||||||
**Classes:**
|
|
||||||
- `Point2D`: 2D points with solver integration
|
|
||||||
- `Line2D`: 2D lines with constraint tracking
|
|
||||||
- `Circle2D`: 2D circles with radius constraints
|
|
||||||
|
|
||||||
## Geometry System
|
|
||||||
|
|
||||||
### Point2D Class
|
|
||||||
```python
|
|
||||||
class Point2D:
|
|
||||||
def __init__(self, x: float, y: float, is_construction: bool = False):
|
|
||||||
self.id = uuid.uuid4() # Unique identifier
|
|
||||||
self.x = float(x) # X coordinate
|
|
||||||
self.y = float(y) # Y coordinate
|
|
||||||
self.ui_point = QPoint(int(x), int(y)) # Qt UI point
|
|
||||||
self.handle = None # SolverSpace handle
|
|
||||||
self.handle_nr = None # Handle number
|
|
||||||
self.is_helper = is_construction # Construction geometry flag
|
|
||||||
```
|
|
||||||
|
|
||||||
**Key Features:**
|
|
||||||
- Automatic coordinate validation
|
|
||||||
- SolverSpace handle integration
|
|
||||||
- Construction/normal geometry support
|
|
||||||
- Distance calculations and equality testing
|
|
||||||
|
|
||||||
### Line2D Class
|
|
||||||
```python
|
|
||||||
class Line2D:
|
|
||||||
def __init__(self, start_point: Point2D, end_point: Point2D, is_construction: bool = False):
|
|
||||||
self.id = uuid.uuid4()
|
|
||||||
self.start = start_point # Start point reference
|
|
||||||
self.end = end_point # End point reference
|
|
||||||
self.handle = None # SolverSpace handle
|
|
||||||
self.constraints = [] # Applied constraints list
|
|
||||||
self.is_helper = is_construction
|
|
||||||
```
|
|
||||||
|
|
||||||
**Key Features:**
|
|
||||||
- Automatic degenerate line detection
|
|
||||||
- Length, midpoint, and angle calculations
|
|
||||||
- Point-on-line testing with tolerance
|
|
||||||
- Constraint tracking and annotation
|
|
||||||
|
|
||||||
### Circle2D Class
|
|
||||||
```python
|
|
||||||
class Circle2D:
|
|
||||||
def __init__(self, center: Point2D, radius: float, is_construction: bool = False):
|
|
||||||
self.id = uuid.uuid4()
|
|
||||||
self.center = center # Center point reference
|
|
||||||
self.radius = float(radius) # Radius value
|
|
||||||
self.handle = None # SolverSpace handle
|
|
||||||
self.constraints = [] # Applied constraints
|
|
||||||
self.is_helper = is_construction
|
|
||||||
```
|
|
||||||
|
|
||||||
## Constraint Solving
|
|
||||||
|
|
||||||
### SolverSpace Integration
|
|
||||||
|
|
||||||
The system uses the `python-solvespace` library for constraint solving. The `ImprovedSketch` class wraps the SolverSpace API and provides:
|
|
||||||
|
|
||||||
1. **Automatic Handle Management**: Each geometry object gets a unique handle
|
|
||||||
2. **Error Handling**: Robust error handling for solver failures
|
|
||||||
3. **Position Updates**: Automatic geometry position updates after solving
|
|
||||||
|
|
||||||
### Constraint Types
|
|
||||||
|
|
||||||
#### Geometric Constraints
|
|
||||||
- **Coincident**: Point-to-point or point-to-line coincidence
|
|
||||||
- **Horizontal**: Forces lines to be horizontal
|
|
||||||
- **Vertical**: Forces lines to be vertical
|
|
||||||
- **Distance**: Fixes distance between points or line length
|
|
||||||
- **Parallel**: Makes lines parallel (future implementation)
|
|
||||||
- **Perpendicular**: Makes lines perpendicular (future implementation)
|
|
||||||
|
|
||||||
#### Constraint Application Workflow
|
|
||||||
```python
|
|
||||||
def _handle_distance_constraint(self, pos: QPoint):
|
|
||||||
line = self.sketch.get_line_near(pos)
|
|
||||||
if line and line.handle:
|
|
||||||
# Get user input for distance
|
|
||||||
distance, ok = QInputDialog.getDouble(...)
|
|
||||||
if ok:
|
|
||||||
# Apply constraint to solver
|
|
||||||
self.sketch.distance(line.start.handle, line.end.handle, distance, self.sketch.wp)
|
|
||||||
# Solve system
|
|
||||||
result = self.sketch.solve_system()
|
|
||||||
if result == ResultFlag.OKAY:
|
|
||||||
line.constraints.append(f"L={distance:.2f}")
|
|
||||||
```
|
|
||||||
|
|
||||||
### Solver Workflow
|
|
||||||
|
|
||||||
1. **Constraint Addition**: Constraints are added to the solver system
|
|
||||||
2. **System Solving**: The solver attempts to find a valid solution
|
|
||||||
3. **Result Processing**: If successful, geometry positions are updated
|
|
||||||
4. **UI Updates**: The display is refreshed to show new positions
|
|
||||||
|
|
||||||
## Coordinate Systems
|
|
||||||
|
|
||||||
The sketcher uses multiple coordinate systems that must be properly transformed between:
|
|
||||||
|
|
||||||
### 1. Sketch Coordinates (Local)
|
|
||||||
- Origin at sketch center
|
|
||||||
- Y-axis points up (mathematical convention)
|
|
||||||
- Units in millimeters
|
|
||||||
- Range: typically -1000 to +1000
|
|
||||||
|
|
||||||
### 2. Viewport Coordinates (Screen)
|
|
||||||
- Origin at top-left of widget
|
|
||||||
- Y-axis points down (computer graphics convention)
|
|
||||||
- Units in pixels
|
|
||||||
- Range: 0 to widget dimensions
|
|
||||||
|
|
||||||
### 3. Working Plane Coordinates (3D)
|
|
||||||
- 3D coordinates projected onto 2D working plane
|
|
||||||
- Transformation handled by external VTK system
|
|
||||||
- Converted to sketch coordinates for display
|
|
||||||
|
|
||||||
### Coordinate Transformations
|
|
||||||
|
|
||||||
#### Viewport to Local (Mouse Input)
|
|
||||||
```python
|
|
||||||
def _viewport_to_local(self, viewport_pos: QPoint) -> QPoint:
|
|
||||||
# Step 1: Subtract widget center
|
|
||||||
center_x = self.width() / 2
|
|
||||||
center_y = self.height() / 2
|
|
||||||
|
|
||||||
# Step 2: Apply pan offset
|
|
||||||
viewport_x = viewport_pos.x() - center_x - (self.pan_offset.x() * self.zoom_factor)
|
|
||||||
viewport_y = viewport_pos.y() - center_y - (self.pan_offset.y() * self.zoom_factor)
|
|
||||||
|
|
||||||
# Step 3: Apply inverse zoom and Y-flip
|
|
||||||
local_x = viewport_x / self.zoom_factor
|
|
||||||
local_y = -viewport_y / self.zoom_factor
|
|
||||||
|
|
||||||
return QPoint(int(local_x), int(local_y))
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Rendering Transform Setup
|
|
||||||
```python
|
|
||||||
def _setup_coordinate_system(self, painter: QPainter):
|
|
||||||
transform = QTransform()
|
|
||||||
|
|
||||||
# Translate to center and apply pan
|
|
||||||
center = QPointF(self.width() / 2, self.height() / 2)
|
|
||||||
transform.translate(center.x() + self.pan_offset.x() * self.zoom_factor,
|
|
||||||
center.y() + self.pan_offset.y() * self.zoom_factor)
|
|
||||||
|
|
||||||
# Apply zoom and flip Y-axis
|
|
||||||
transform.scale(self.zoom_factor, -self.zoom_factor)
|
|
||||||
|
|
||||||
painter.setTransform(transform)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Interaction System
|
|
||||||
|
|
||||||
### Mode-Based Interaction
|
|
||||||
|
|
||||||
The sketcher supports multiple interaction modes with robust mode management:
|
|
||||||
|
|
||||||
#### Drawing Modes
|
|
||||||
- `SketchMode.LINE`: Two-point line creation
|
|
||||||
- `SketchMode.RECTANGLE`: Two-corner rectangle creation
|
|
||||||
- `SketchMode.CIRCLE`: Center-radius circle creation
|
|
||||||
- `SketchMode.POINT`: Single point creation
|
|
||||||
|
|
||||||
#### Constraint Modes
|
|
||||||
- `SketchMode.COINCIDENT_PT_PT`: Point-to-point coincidence
|
|
||||||
- `SketchMode.HORIZONTAL`: Horizontal line constraint
|
|
||||||
- `SketchMode.VERTICAL`: Vertical line constraint
|
|
||||||
- `SketchMode.DISTANCE`: Distance/length constraint
|
|
||||||
|
|
||||||
#### Selection Mode
|
|
||||||
- `SketchMode.NONE`: Selection and manipulation mode (enables point dragging)
|
|
||||||
|
|
||||||
### Selection and Deletion System
|
|
||||||
|
|
||||||
The sketcher now includes a comprehensive selection and deletion system that allows users to select and remove elements from the sketch.
|
|
||||||
|
|
||||||
#### Selection Methods
|
|
||||||
|
|
||||||
1. **Single Element Selection**: Click on individual points or lines to select/deselect them
|
|
||||||
2. **Rectangle Selection**: Click and drag to create a selection rectangle for multiple elements
|
|
||||||
3. **Visual Feedback**: Selected elements are highlighted in yellow with increased size
|
|
||||||
|
|
||||||
#### Deletion Methods
|
|
||||||
|
|
||||||
1. **Keyboard Deletion**: Press Delete or Backspace to remove selected elements
|
|
||||||
2. **Proper Cleanup**: Elements are removed from both the sketch and constraint solver
|
|
||||||
3. **Dependency Handling**: Lines are deleted before points to maintain geometric integrity
|
|
||||||
|
|
||||||
#### Implementation Details
|
|
||||||
|
|
||||||
The selection system is implemented through the following components:
|
|
||||||
|
|
||||||
- **Selection Tracking**: `selected_elements` list tracks currently selected elements
|
|
||||||
- **Rectangle Selection**: `selection_rect_start` and `selection_rect_end` track rectangle selection bounds
|
|
||||||
- **Visual Feedback**: Modified drawing methods highlight selected elements in yellow
|
|
||||||
- **Keyboard Support**: `keyPressEvent` handles Delete/Backspace keys
|
|
||||||
- **Deletion Method**: `delete_selected_elements` handles removal of elements from sketch and solver
|
|
||||||
|
|
||||||
#### Selection Workflow
|
|
||||||
|
|
||||||
1. **Default Selection Mode**: The sketcher defaults to selection mode when no drawing tool is active
|
|
||||||
2. **Element Selection**:
|
|
||||||
- Click on points or lines to select/deselect them (they turn yellow)
|
|
||||||
- Click and drag to create a rectangle selection for multiple elements
|
|
||||||
3. **Element Deletion**:
|
|
||||||
- Press Delete or Backspace to remove all selected elements
|
|
||||||
- Elements are removed from both the sketch and constraint solver
|
|
||||||
4. **Visual Feedback**:
|
|
||||||
- Selected elements are highlighted in yellow
|
|
||||||
- Rectangle selection is shown with a yellow dashed border
|
|
||||||
|
|
||||||
#### Constraints Handling
|
|
||||||
|
|
||||||
When elements are deleted:
|
|
||||||
- Lines are removed first to avoid issues with points being used by lines
|
|
||||||
- Points are only removed if they are not used by any remaining lines
|
|
||||||
- The constraint solver is re-run after deletion to update remaining constraints
|
|
||||||
- Proper error handling ensures the UI remains responsive even if solver operations fail
|
|
||||||
|
|
||||||
### Mode Management System
|
|
||||||
|
|
||||||
The mode system has been enhanced to provide intuitive selection and deletion functionality:
|
|
||||||
|
|
||||||
#### Mode Compatibility
|
|
||||||
- Python `None` is automatically converted to `SketchMode.NONE` for backward compatibility
|
|
||||||
- The `set_mode()` method ensures the mode is always a valid `SketchMode` enum value
|
|
||||||
- Mode changes reset all interaction buffers and state
|
|
||||||
|
|
||||||
#### Default Selection Behavior
|
|
||||||
- `SketchMode.NONE` now serves as the default selection mode
|
|
||||||
- When no drawing tool is active, the sketcher is in selection mode by default
|
|
||||||
- Users can click on elements to select/deselect them (they turn yellow)
|
|
||||||
- Users can click and drag to create rectangle selections
|
|
||||||
- Pressing Delete or Backspace removes all selected elements
|
|
||||||
|
|
||||||
#### Right-Click Behavior
|
|
||||||
- Right-clicking **always** exits any active mode and returns to `SketchMode.NONE`
|
|
||||||
- This enables point dragging and prevents unintended geometry creation
|
|
||||||
- The mode reset happens directly in the sketcher, not through main app signals
|
|
||||||
|
|
||||||
#### Point Dragging Safety
|
|
||||||
- Point dragging is **only** enabled when in `SketchMode.NONE` mode
|
|
||||||
- Left-clicks in `NONE` mode check for draggable points first
|
|
||||||
- If no point is found, the click is processed as a selection operation
|
|
||||||
|
|
||||||
### Mouse Event Handling
|
|
||||||
|
|
||||||
#### Click Processing Flow
|
|
||||||
```python
|
|
||||||
def mousePressEvent(self, event):
|
|
||||||
local_pos = self._viewport_to_local(event.pos())
|
|
||||||
|
|
||||||
if event.button() == Qt.LeftButton:
|
|
||||||
self._handle_left_click(local_pos)
|
|
||||||
elif event.button() == Qt.RightButton:
|
|
||||||
self._handle_right_click(local_pos)
|
|
||||||
elif event.button() == Qt.MiddleButton:
|
|
||||||
self._start_panning(event.pos())
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Enhanced Left-Click Handler
|
|
||||||
```python
|
|
||||||
def _handle_left_click(self, pos: QPoint):
|
|
||||||
# Safety check for NONE mode (dragging enabled)
|
|
||||||
if self.current_mode == SketchMode.NONE or self.current_mode is None:
|
|
||||||
point = self.sketch.get_point_near(pos, self.snap_settings.snap_distance)
|
|
||||||
if point:
|
|
||||||
self._start_point_drag(point, pos)
|
|
||||||
return
|
|
||||||
else:
|
|
||||||
# No point found - ignore click to prevent unintended drawing
|
|
||||||
return
|
|
||||||
|
|
||||||
# Handle active drawing/constraint modes
|
|
||||||
if self.current_mode == SketchMode.LINE:
|
|
||||||
self._handle_line_creation(pos)
|
|
||||||
elif self.current_mode == SketchMode.HORIZONTAL:
|
|
||||||
self._handle_horizontal_constraint(pos)
|
|
||||||
# ... other modes
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Right-Click Mode Reset
|
|
||||||
```python
|
|
||||||
def _handle_right_click(self, pos: QPoint):
|
|
||||||
# Reset interaction state
|
|
||||||
self._reset_interaction_state()
|
|
||||||
|
|
||||||
# Force mode to NONE to enable dragging
|
|
||||||
self.current_mode = SketchMode.NONE
|
|
||||||
|
|
||||||
# Emit signal to inform main app
|
|
||||||
self.constraint_applied.emit()
|
|
||||||
```
|
|
||||||
|
|
||||||
### Point Dragging System
|
|
||||||
|
|
||||||
The point dragging system is optimized for performance and maintains constraint consistency:
|
|
||||||
|
|
||||||
#### Drag Phases
|
|
||||||
|
|
||||||
1. **Drag Start** (`_start_point_drag`):
|
|
||||||
- Identifies dragged point
|
|
||||||
- Stores initial position
|
|
||||||
- Sets dragging state
|
|
||||||
|
|
||||||
2. **Drag Update** (`_handle_point_drag`):
|
|
||||||
- Updates point visual position only
|
|
||||||
- Applies snapping
|
|
||||||
- No solver execution (for performance)
|
|
||||||
|
|
||||||
3. **Drag End** (`_end_point_drag`):
|
|
||||||
- Updates solver parameters with final position
|
|
||||||
- Runs constraint solver
|
|
||||||
- Updates all connected geometry
|
|
||||||
- Resets drag state
|
|
||||||
|
|
||||||
```python
|
|
||||||
def _end_point_drag(self):
|
|
||||||
if not self.dragging_point:
|
|
||||||
return
|
|
||||||
|
|
||||||
# Update solver parameters with final position
|
|
||||||
if self.dragging_point.handle:
|
|
||||||
new_x = self.dragging_point.x
|
|
||||||
new_y = self.dragging_point.y
|
|
||||||
self.sketch.set_params(self.dragging_point.handle.params, [new_x, new_y])
|
|
||||||
|
|
||||||
# Run solver to update all connected geometry
|
|
||||||
result = self.sketch.solve_system()
|
|
||||||
if result == ResultFlag.OKAY:
|
|
||||||
self.sketch_modified.emit()
|
|
||||||
```
|
|
||||||
|
|
||||||
## Rendering System
|
|
||||||
|
|
||||||
### Rendering Pipeline
|
|
||||||
|
|
||||||
The rendering system uses Qt's QPainter with a multi-layer approach:
|
|
||||||
|
|
||||||
1. **Coordinate System Setup**: Apply zoom, pan, and Y-flip transforms
|
|
||||||
2. **Background Rendering**: Grid, axes, and origin marker
|
|
||||||
3. **Geometry Rendering**: Points, lines, circles with proper styling
|
|
||||||
4. **Dynamic Elements**: Preview geometry during creation
|
|
||||||
5. **UI Overlays**: Mode indicators, measurements, snap highlights
|
|
||||||
|
|
||||||
### Rendering Layers
|
|
||||||
|
|
||||||
#### Layer 1: Background
|
|
||||||
- Coordinate axes (dashed gray lines)
|
|
||||||
- Grid (if enabled)
|
|
||||||
- Origin marker (red circle)
|
|
||||||
|
|
||||||
#### Layer 2: Geometry
|
|
||||||
- Construction geometry (green, dotted)
|
|
||||||
- Normal geometry (gray, solid)
|
|
||||||
- Constraint annotations
|
|
||||||
|
|
||||||
#### Layer 3: Interactive Elements
|
|
||||||
- Hover highlights (red)
|
|
||||||
- Dynamic previews (gray, dashed)
|
|
||||||
- Measurements during creation
|
|
||||||
|
|
||||||
#### Layer 4: UI Overlays
|
|
||||||
- Snap point indicators
|
|
||||||
- Mode and zoom information
|
|
||||||
- Status messages
|
|
||||||
|
|
||||||
### Styling System
|
|
||||||
|
|
||||||
Rendering appearance is controlled by the `RenderSettings` class:
|
|
||||||
|
|
||||||
```python
|
|
||||||
@dataclass
|
|
||||||
class RenderSettings:
|
|
||||||
normal_pen_width: float = 2.0
|
|
||||||
construction_pen_width: float = 1.0
|
|
||||||
highlight_pen_width: float = 3.0
|
|
||||||
|
|
||||||
normal_color = QColor(128, 128, 128) # Gray
|
|
||||||
construction_color = QColor(0, 255, 0) # Green
|
|
||||||
highlight_color = QColor(255, 0, 0) # Red
|
|
||||||
solver_color = QColor(0, 255, 0) # Green
|
|
||||||
dynamic_color = QColor(128, 128, 128) # Gray
|
|
||||||
text_color = QColor(255, 255, 255) # White
|
|
||||||
```
|
|
||||||
|
|
||||||
### Dynamic Previews
|
|
||||||
|
|
||||||
During geometry creation, dynamic previews show:
|
|
||||||
- **Line Creation**: Dashed line from start to cursor with length annotation
|
|
||||||
- **Rectangle Creation**: Dashed rectangle outline
|
|
||||||
- **Circle Creation**: Dashed circle with radius line and annotation
|
|
||||||
|
|
||||||
## Snapping System
|
|
||||||
|
|
||||||
### Snap Modes
|
|
||||||
|
|
||||||
The snapping system supports multiple simultaneous snap modes:
|
|
||||||
|
|
||||||
#### SnapMode.POINT
|
|
||||||
- Snaps to existing geometry points
|
|
||||||
- Priority: Highest
|
|
||||||
- Visual: Red circle highlight
|
|
||||||
|
|
||||||
#### SnapMode.MIDPOINT
|
|
||||||
- Snaps to line midpoints
|
|
||||||
- Priority: Medium
|
|
||||||
- Visual: Red diamond highlight
|
|
||||||
|
|
||||||
#### SnapMode.GRID
|
|
||||||
- Snaps to grid intersections
|
|
||||||
- Priority: Lowest
|
|
||||||
- Visual: Green cross highlight
|
|
||||||
|
|
||||||
#### SnapMode.HORIZONTAL/VERTICAL
|
|
||||||
- Angular snapping (future implementation)
|
|
||||||
- Constrains to horizontal/vertical directions
|
|
||||||
|
|
||||||
#### SnapMode.INTERSECTION
|
|
||||||
- Snaps to line intersections (future implementation)
|
|
||||||
|
|
||||||
### Snap Algorithm
|
|
||||||
|
|
||||||
```python
|
|
||||||
def _get_snapped_position(self, pos: QPoint) -> QPoint:
|
|
||||||
min_distance = float('inf')
|
|
||||||
snapped_pos = pos
|
|
||||||
snap_threshold = self.snap_settings.snap_distance
|
|
||||||
|
|
||||||
# Point snapping (highest priority)
|
|
||||||
if SnapMode.POINT in self.snap_settings.enabled_modes:
|
|
||||||
for point in self.sketch.points:
|
|
||||||
distance = math.sqrt((pos.x() - point.x)**2 + (pos.y() - point.y)**2)
|
|
||||||
if distance < snap_threshold and distance < min_distance:
|
|
||||||
snapped_pos = QPoint(int(point.x), int(point.y))
|
|
||||||
min_distance = distance
|
|
||||||
|
|
||||||
# Midpoint snapping (medium priority)
|
|
||||||
if SnapMode.MIDPOINT in self.snap_settings.enabled_modes and min_distance > snap_threshold:
|
|
||||||
for line in self.sketch.lines:
|
|
||||||
midpoint = line.midpoint
|
|
||||||
distance = math.sqrt((pos.x() - midpoint.x)**2 + (pos.y() - midpoint.y)**2)
|
|
||||||
if distance < snap_threshold and distance < min_distance:
|
|
||||||
snapped_pos = QPoint(int(midpoint.x), int(midpoint.y))
|
|
||||||
min_distance = distance
|
|
||||||
|
|
||||||
return snapped_pos
|
|
||||||
```
|
|
||||||
|
|
||||||
### Snap Settings
|
|
||||||
|
|
||||||
```python
|
|
||||||
@dataclass
|
|
||||||
class SnapSettings:
|
|
||||||
snap_distance: float = 20.0 # Snap threshold in pixels
|
|
||||||
angle_increment: float = 15.0 # Angular snap increment
|
|
||||||
grid_spacing: float = 50.0 # Grid spacing
|
|
||||||
enabled_modes: Set[SnapMode] # Active snap modes
|
|
||||||
```
|
|
||||||
|
|
||||||
## Working Plane Integration
|
|
||||||
|
|
||||||
### Projected Geometry Workflow
|
|
||||||
|
|
||||||
The sketcher integrates with 3D working planes through projected geometry:
|
|
||||||
|
|
||||||
1. **3D Geometry Selection**: User selects 3D lines/points in VTK widget
|
|
||||||
2. **Plane Definition**: System computes working plane from selections
|
|
||||||
3. **Geometry Projection**: 3D geometry is projected onto 2D working plane
|
|
||||||
4. **Sketch Import**: Projected geometry is imported as construction geometry
|
|
||||||
|
|
||||||
### Projection Import Methods
|
|
||||||
|
|
||||||
#### `convert_proj_points(proj_points)`
|
|
||||||
Imports projected 3D points as 2D construction points:
|
|
||||||
```python
|
|
||||||
def convert_proj_points(self, proj_points):
|
|
||||||
for point_data in proj_points:
|
|
||||||
if hasattr(point_data, 'x') and hasattr(point_data, 'y'):
|
|
||||||
point = Point2D(point_data.x, point_data.y, True) # Construction
|
|
||||||
self.sketch.add_point(point)
|
|
||||||
```
|
|
||||||
|
|
||||||
#### `convert_proj_lines(proj_lines)`
|
|
||||||
Imports projected 3D lines as 2D construction lines:
|
|
||||||
```python
|
|
||||||
def convert_proj_lines(self, proj_lines):
|
|
||||||
for line_data in proj_lines:
|
|
||||||
# Handle object format
|
|
||||||
if hasattr(line_data, 'start') and hasattr(line_data, 'end'):
|
|
||||||
x1, y1 = line_data.start.x, line_data.start.y
|
|
||||||
x2, y2 = line_data.end.x, line_data.end.y
|
|
||||||
|
|
||||||
# Skip degenerate lines
|
|
||||||
if abs(x1 - x2) < 1e-6 and abs(y1 - y2) < 1e-6:
|
|
||||||
continue
|
|
||||||
|
|
||||||
start = Point2D(x1, y1, True)
|
|
||||||
end = Point2D(x2, y2, True)
|
|
||||||
self.sketch.add_point(start)
|
|
||||||
self.sketch.add_point(end)
|
|
||||||
line = Line2D(start, end, True)
|
|
||||||
self.sketch.add_line(line)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Construction vs Normal Geometry
|
|
||||||
|
|
||||||
- **Construction Geometry**:
|
|
||||||
- Rendered in green with dotted lines
|
|
||||||
- Used for reference and alignment
|
|
||||||
- Created from projected 3D geometry
|
|
||||||
- Flag: `is_construction=True`
|
|
||||||
|
|
||||||
- **Normal Geometry**:
|
|
||||||
- Rendered in gray with solid lines
|
|
||||||
- Part of the actual sketch design
|
|
||||||
- Created by user drawing actions
|
|
||||||
- Flag: `is_construction=False`
|
|
||||||
|
|
||||||
## API Reference
|
|
||||||
|
|
||||||
### Main Widget Class
|
|
||||||
|
|
||||||
#### ImprovedSketchWidget
|
|
||||||
|
|
||||||
**Initialization:**
|
|
||||||
```python
|
|
||||||
widget = ImprovedSketchWidget()
|
|
||||||
widget.show()
|
|
||||||
```
|
|
||||||
|
|
||||||
**Mode Control:**
|
|
||||||
```python
|
|
||||||
# Set drawing modes
|
|
||||||
widget.set_mode(SketchMode.LINE)
|
|
||||||
widget.set_mode(SketchMode.NONE) # Enable selection/dragging
|
|
||||||
widget.set_mode(None) # Also converted to SketchMode.NONE
|
|
||||||
|
|
||||||
# Construction geometry
|
|
||||||
widget.set_construction_mode(True)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Snapping Control:**
|
|
||||||
```python
|
|
||||||
widget.set_snap_mode(SnapMode.POINT, True)
|
|
||||||
widget.toggle_snap_mode(SnapMode.MIDPOINT, enabled)
|
|
||||||
```
|
|
||||||
|
|
||||||
**View Control:**
|
|
||||||
```python
|
|
||||||
widget.zoom_to_fit()
|
|
||||||
```
|
|
||||||
|
|
||||||
**Sketch Access:**
|
|
||||||
```python
|
|
||||||
sketch = widget.get_sketch()
|
|
||||||
widget.set_sketch(imported_sketch)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Sketch Management
|
|
||||||
|
|
||||||
#### ImprovedSketch
|
|
||||||
|
|
||||||
**Geometry Addition:**
|
|
||||||
```python
|
|
||||||
sketch = ImprovedSketch()
|
|
||||||
point = Point2D(10, 20)
|
|
||||||
line = Line2D(start_point, end_point)
|
|
||||||
circle = Circle2D(center_point, radius)
|
|
||||||
|
|
||||||
sketch.add_point(point)
|
|
||||||
sketch.add_line(line)
|
|
||||||
sketch.add_circle(circle)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Constraint Application:**
|
|
||||||
```python
|
|
||||||
# Distance constraint
|
|
||||||
sketch.distance(point1.handle, point2.handle, 50.0, sketch.wp)
|
|
||||||
|
|
||||||
# Coincident constraint
|
|
||||||
sketch.coincident(point1.handle, point2.handle, sketch.wp)
|
|
||||||
|
|
||||||
# Line constraints
|
|
||||||
sketch.horizontal(line.handle, sketch.wp)
|
|
||||||
sketch.vertical(line.handle, sketch.wp)
|
|
||||||
|
|
||||||
# Solve system
|
|
||||||
result = sketch.solve_system()
|
|
||||||
```
|
|
||||||
|
|
||||||
### Signals
|
|
||||||
|
|
||||||
The widget emits several signals for integration:
|
|
||||||
|
|
||||||
```python
|
|
||||||
# Emitted when constraint is successfully applied
|
|
||||||
widget.constraint_applied.connect(callback)
|
|
||||||
|
|
||||||
# Emitted when new geometry is created
|
|
||||||
widget.geometry_created.connect(callback) # Parameter: geometry type string
|
|
||||||
|
|
||||||
# Emitted when sketch is modified
|
|
||||||
widget.sketch_modified.connect(callback)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Performance Considerations
|
|
||||||
|
|
||||||
### Optimization Strategies
|
|
||||||
|
|
||||||
1. **Lazy Solving**: Solver only runs when necessary (after constraints or drag end)
|
|
||||||
2. **Efficient Rendering**: Uses Qt's optimized drawing primitives
|
|
||||||
3. **Smart Updates**: Only redraws affected regions when possible
|
|
||||||
4. **Handle Caching**: SolverSpace handles are cached to avoid recreation
|
|
||||||
|
|
||||||
### Memory Management
|
|
||||||
|
|
||||||
- Geometry objects use weak references where possible
|
|
||||||
- SolverSpace handles are properly cleaned up
|
|
||||||
- Qt objects follow parent-child hierarchy for automatic cleanup
|
|
||||||
|
|
||||||
### Scalability Limits
|
|
||||||
|
|
||||||
- Recommended maximum: ~1000 geometric entities
|
|
||||||
- Solver performance degrades with complex constraint networks
|
|
||||||
- Rendering remains smooth up to ~10,000 entities
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Common Issues
|
|
||||||
|
|
||||||
#### Mode Handling Problems
|
|
||||||
**Symptoms**: Unintended line creation when dragging, tools not deactivating properly
|
|
||||||
**Causes**: Mode not properly reset to NONE, Python None vs SketchMode.NONE confusion
|
|
||||||
**Solutions**:
|
|
||||||
- Always right-click to exit active modes
|
|
||||||
- Ensure `set_mode(None)` is converted to `SketchMode.NONE`
|
|
||||||
- Verify mode state after tool deactivation in main app
|
|
||||||
|
|
||||||
#### Point Dragging Issues
|
|
||||||
**Symptoms**: Cannot drag points, dragging creates unwanted lines
|
|
||||||
**Causes**: Mode not set to NONE, safety checks preventing drag detection
|
|
||||||
**Solutions**:
|
|
||||||
- Verify current mode is `SketchMode.NONE` before attempting to drag
|
|
||||||
- Right-click to ensure proper mode exit from drawing tools
|
|
||||||
- Check that point detection threshold is appropriate
|
|
||||||
|
|
||||||
#### Solver Failures
|
|
||||||
**Symptoms**: Constraints not applied, geometry not updating
|
|
||||||
**Causes**: Over-constrained systems, conflicting constraints
|
|
||||||
**Solutions**:
|
|
||||||
- Check constraint compatibility
|
|
||||||
- Verify geometry validity
|
|
||||||
- Use `ResultFlag` inspection for error details
|
|
||||||
|
|
||||||
#### Coordinate Transform Issues
|
|
||||||
**Symptoms**: Mouse clicks don't match visual geometry
|
|
||||||
**Causes**: Incorrect transform calculations, zoom/pan state corruption
|
|
||||||
**Solutions**:
|
|
||||||
- Verify `_viewport_to_local` and `_setup_coordinate_system` consistency
|
|
||||||
- Reset view with `zoom_to_fit()`
|
|
||||||
|
|
||||||
#### Performance Problems
|
|
||||||
**Symptoms**: Slow dragging, UI lag
|
|
||||||
**Causes**: Solver running during drag, excessive redraws
|
|
||||||
**Solutions**:
|
|
||||||
- Ensure solver only runs in `_end_point_drag`
|
|
||||||
- Check render loop efficiency
|
|
||||||
- Profile with Qt performance tools
|
|
||||||
|
|
||||||
#### Snap Behavior Issues
|
|
||||||
**Symptoms**: Inconsistent snapping, incorrect snap points
|
|
||||||
**Causes**: Priority conflicts, threshold settings, coordinate errors
|
|
||||||
**Solutions**:
|
|
||||||
- Adjust snap threshold in `SnapSettings`
|
|
||||||
- Verify snap priority order
|
|
||||||
- Check coordinate conversion in snap calculations
|
|
||||||
|
|
||||||
### Debug Logging
|
|
||||||
|
|
||||||
Enable detailed logging for troubleshooting:
|
|
||||||
```python
|
|
||||||
import logging
|
|
||||||
logging.basicConfig(level=logging.DEBUG)
|
|
||||||
logger = logging.getLogger('improved_sketcher')
|
|
||||||
```
|
|
||||||
|
|
||||||
Key log messages include:
|
|
||||||
- Geometry addition/removal
|
|
||||||
- Constraint application results
|
|
||||||
- Solver execution status
|
|
||||||
- Coordinate transformations
|
|
||||||
- Snap calculations
|
|
||||||
|
|
||||||
### Testing Guidelines
|
|
||||||
|
|
||||||
#### Unit Testing
|
|
||||||
- Test geometry classes with edge cases
|
|
||||||
- Verify coordinate transformations
|
|
||||||
- Test constraint application logic
|
|
||||||
|
|
||||||
#### Integration Testing
|
|
||||||
- Test with various sketch sizes
|
|
||||||
- Verify working plane integration
|
|
||||||
- Test complex constraint networks
|
|
||||||
|
|
||||||
#### Performance Testing
|
|
||||||
- Measure solver execution time
|
|
||||||
- Profile rendering performance
|
|
||||||
- Test with large geometry sets
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Recent Improvements (2025-08-16)
|
|
||||||
|
|
||||||
### Mode Handling Enhancements
|
|
||||||
|
|
||||||
Significant improvements have been made to the mode management system:
|
|
||||||
|
|
||||||
#### Fixed Issues
|
|
||||||
1. **Unintended Line Creation**: Resolved issue where dragging with line tool deactivated would still create lines
|
|
||||||
2. **Mode Reset Reliability**: Right-click now reliably exits any active mode and returns to NONE
|
|
||||||
3. **Backward Compatibility**: Python `None` mode values are automatically converted to `SketchMode.NONE`
|
|
||||||
4. **Safety Checks**: Added comprehensive checks to prevent drawing operations in NONE mode
|
|
||||||
|
|
||||||
#### Implementation Details
|
|
||||||
- Enhanced `_handle_right_click()` to directly set mode to NONE
|
|
||||||
- Added safety checks in `_handle_left_click()` for NONE mode behavior
|
|
||||||
- Improved `set_mode()` method to handle None input gracefully
|
|
||||||
- Added comprehensive debug logging for mode transitions
|
|
||||||
|
|
||||||
#### Integration Improvements
|
|
||||||
- Fixed main app integration where constraint modes were prematurely reset
|
|
||||||
- Ensured persistent constraint behavior until explicit user cancellation
|
|
||||||
- Maintained UI button state consistency with actual sketcher mode
|
|
||||||
|
|
||||||
These improvements ensure reliable mode transitions and prevent common user frustrations with unintended geometry creation.
|
|
||||||
|
|
||||||
## Conclusion
|
|
||||||
|
|
||||||
The ImprovedSketchWidget provides a robust, extensible foundation for 2D parametric sketching in Fluency CAD. Its architecture separates concerns effectively, uses proven libraries (SolverSpace, PySide6), and provides rich interaction capabilities while maintaining good performance characteristics.
|
|
||||||
|
|
||||||
The system is designed for extensibility - new geometry types, constraint types, and interaction modes can be added following the established patterns. The comprehensive API allows for both direct use and integration with larger CAD systems.
|
|
||||||
|
|
||||||
With the recent mode handling improvements, the sketcher now provides a more reliable and intuitive user experience, with proper separation between drawing modes and selection/manipulation operations.
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
pyside6-uic gui.ui > Gui.py -g python
|
|
||||||
-35
@@ -1,35 +0,0 @@
|
|||||||
# Signal Flow
|
|
||||||
## 2D SketchWidget
|
|
||||||
|
|
||||||
- 2D QPoint form custom Qpainter widget in linear space
|
|
||||||
- 2D QPoint ot cartesian space
|
|
||||||
- 2D tuple into slvspace dict system and solvespace
|
|
||||||
- get calced position from Solvespace solver
|
|
||||||
- add to internal reference dict
|
|
||||||
- Transform to linear QPainter space for display to show
|
|
||||||
|
|
||||||
## 3D custom Widget
|
|
||||||
|
|
||||||
- Take Tuple points form solvespace main dict
|
|
||||||
- Draw Interactor and sdfCAD model
|
|
||||||
|
|
||||||
### Select and Project
|
|
||||||
|
|
||||||
- Project cartesian flattened mesh into 2D
|
|
||||||
- Transform to 2D xy
|
|
||||||
- Transform to linear space for 2D widget to draw.
|
|
||||||
- Result into 2D cartesian for body interaction extrude etc
|
|
||||||
|
|
||||||
### Elements
|
|
||||||
|
|
||||||
So far these are the elements:
|
|
||||||
|
|
||||||
- Project: Main File
|
|
||||||
- Timeline : Used to track the steps
|
|
||||||
- Assembly: Uses Components and Connectors to from Assemblies
|
|
||||||
- Component: Container for multiple smaller elements "part"
|
|
||||||
- Connector: Preserves connections between parts even if the part in between is deleted
|
|
||||||
- Code: A special type that directly builds bodys from sdfCAD code.
|
|
||||||
- Body: The 3D meshed result from sdfCAD
|
|
||||||
- Sketch: The base to draw new entities.
|
|
||||||
- Interactor (edges): A special component mesh that is used to manipulate the bodys in 3d view.
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
## Compile ui file
|
|
||||||
pyside6-uic gui.ui > Gui.py -g python
|
|
||||||
|
|
||||||
@@ -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.0–1.0
|
||||||
|
roughness: float = 0.5 # 0.0–1.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
|
||||||
Vendored
BIN
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.
@@ -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())
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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())
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,201 +0,0 @@
|
|||||||
"""
|
|
||||||
Example integration of the improved sketcher with the main Fluency application
|
|
||||||
This shows how to replace the existing sketcher with the improved version
|
|
||||||
"""
|
|
||||||
|
|
||||||
from PySide6.QtWidgets import QApplication, QMainWindow, QVBoxLayout, QHBoxLayout, QWidget, QPushButton, QButtonGroup
|
|
||||||
from PySide6.QtCore import Qt
|
|
||||||
|
|
||||||
from improved_sketcher import ImprovedSketchWidget, SketchMode, SnapMode
|
|
||||||
|
|
||||||
|
|
||||||
class SketcherIntegrationDemo(QMainWindow):
|
|
||||||
"""Demo showing how to integrate the improved sketcher with UI controls"""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
super().__init__()
|
|
||||||
self.setWindowTitle("Improved Sketcher Integration Demo")
|
|
||||||
self.resize(1200, 800)
|
|
||||||
|
|
||||||
# Create central widget
|
|
||||||
central_widget = QWidget()
|
|
||||||
self.setCentralWidget(central_widget)
|
|
||||||
|
|
||||||
# Create layout
|
|
||||||
main_layout = QHBoxLayout(central_widget)
|
|
||||||
|
|
||||||
# Create toolbar
|
|
||||||
self.create_toolbar(main_layout)
|
|
||||||
|
|
||||||
# Create sketcher widget
|
|
||||||
self.sketcher = ImprovedSketchWidget()
|
|
||||||
main_layout.addWidget(self.sketcher, stretch=1)
|
|
||||||
|
|
||||||
# Connect sketcher signals
|
|
||||||
self.connect_sketcher_signals()
|
|
||||||
|
|
||||||
# Set initial mode
|
|
||||||
self.sketcher.set_mode(SketchMode.LINE)
|
|
||||||
|
|
||||||
def create_toolbar(self, parent_layout):
|
|
||||||
"""Create toolbar with sketching tools"""
|
|
||||||
toolbar_widget = QWidget()
|
|
||||||
toolbar_widget.setFixedWidth(200)
|
|
||||||
toolbar_layout = QVBoxLayout(toolbar_widget)
|
|
||||||
|
|
||||||
# Drawing tools group
|
|
||||||
drawing_group = QWidget()
|
|
||||||
drawing_layout = QVBoxLayout(drawing_group)
|
|
||||||
drawing_layout.addWidget(self.create_label("Drawing Tools"))
|
|
||||||
|
|
||||||
# Create drawing mode buttons
|
|
||||||
self.drawing_buttons = QButtonGroup(self)
|
|
||||||
self.drawing_buttons.setExclusive(True)
|
|
||||||
|
|
||||||
drawing_modes = [
|
|
||||||
("Line", SketchMode.LINE),
|
|
||||||
("Rectangle", SketchMode.RECTANGLE),
|
|
||||||
("Circle", SketchMode.CIRCLE),
|
|
||||||
("Point", SketchMode.POINT),
|
|
||||||
]
|
|
||||||
|
|
||||||
for name, mode in drawing_modes:
|
|
||||||
button = QPushButton(name)
|
|
||||||
button.setCheckable(True)
|
|
||||||
button.clicked.connect(lambda checked, m=mode: self.set_drawing_mode(m))
|
|
||||||
self.drawing_buttons.addButton(button)
|
|
||||||
drawing_layout.addWidget(button)
|
|
||||||
|
|
||||||
# Set line as default
|
|
||||||
self.drawing_buttons.buttons()[0].setChecked(True)
|
|
||||||
|
|
||||||
# Constraint tools group
|
|
||||||
constraint_group = QWidget()
|
|
||||||
constraint_layout = QVBoxLayout(constraint_group)
|
|
||||||
constraint_layout.addWidget(self.create_label("Constraints"))
|
|
||||||
|
|
||||||
# Create constraint buttons
|
|
||||||
constraint_modes = [
|
|
||||||
("Coincident", SketchMode.COINCIDENT_PT_PT),
|
|
||||||
("Horizontal", SketchMode.HORIZONTAL),
|
|
||||||
("Vertical", SketchMode.VERTICAL),
|
|
||||||
("Distance", SketchMode.DISTANCE),
|
|
||||||
]
|
|
||||||
|
|
||||||
for name, mode in constraint_modes:
|
|
||||||
button = QPushButton(name)
|
|
||||||
button.clicked.connect(lambda checked, m=mode: self.set_constraint_mode(m))
|
|
||||||
constraint_layout.addWidget(button)
|
|
||||||
|
|
||||||
# Settings group
|
|
||||||
settings_group = QWidget()
|
|
||||||
settings_layout = QVBoxLayout(settings_group)
|
|
||||||
settings_layout.addWidget(self.create_label("Settings"))
|
|
||||||
|
|
||||||
# Construction mode toggle
|
|
||||||
self.construction_button = QPushButton("Construction Mode")
|
|
||||||
self.construction_button.setCheckable(True)
|
|
||||||
self.construction_button.toggled.connect(self.toggle_construction_mode)
|
|
||||||
settings_layout.addWidget(self.construction_button)
|
|
||||||
|
|
||||||
# Snap settings
|
|
||||||
snap_buttons = [
|
|
||||||
("Point Snap", SnapMode.POINT),
|
|
||||||
("Grid Snap", SnapMode.GRID),
|
|
||||||
("Midpoint Snap", SnapMode.MIDPOINT),
|
|
||||||
]
|
|
||||||
|
|
||||||
for name, snap_mode in snap_buttons:
|
|
||||||
button = QPushButton(name)
|
|
||||||
button.setCheckable(True)
|
|
||||||
button.toggled.connect(lambda checked, sm=snap_mode: self.toggle_snap_mode(sm, checked))
|
|
||||||
settings_layout.addWidget(button)
|
|
||||||
|
|
||||||
# Set default snaps
|
|
||||||
settings_layout.itemAt(1).widget().setChecked(True) # Point snap on by default
|
|
||||||
|
|
||||||
# View controls
|
|
||||||
view_group = QWidget()
|
|
||||||
view_layout = QVBoxLayout(view_group)
|
|
||||||
view_layout.addWidget(self.create_label("View"))
|
|
||||||
|
|
||||||
zoom_fit_button = QPushButton("Zoom to Fit")
|
|
||||||
zoom_fit_button.clicked.connect(self.sketcher.zoom_to_fit)
|
|
||||||
view_layout.addWidget(zoom_fit_button)
|
|
||||||
|
|
||||||
# Add groups to toolbar
|
|
||||||
toolbar_layout.addWidget(drawing_group)
|
|
||||||
toolbar_layout.addWidget(constraint_group)
|
|
||||||
toolbar_layout.addWidget(settings_group)
|
|
||||||
toolbar_layout.addWidget(view_group)
|
|
||||||
toolbar_layout.addStretch()
|
|
||||||
|
|
||||||
parent_layout.addWidget(toolbar_widget)
|
|
||||||
|
|
||||||
def create_label(self, text):
|
|
||||||
"""Create a section label"""
|
|
||||||
from PySide6.QtWidgets import QLabel
|
|
||||||
from PySide6.QtCore import Qt
|
|
||||||
|
|
||||||
label = QLabel(text)
|
|
||||||
label.setAlignment(Qt.AlignCenter)
|
|
||||||
label.setStyleSheet("font-weight: bold; padding: 5px; background-color: #333; color: white;")
|
|
||||||
return label
|
|
||||||
|
|
||||||
def set_drawing_mode(self, mode):
|
|
||||||
"""Set the sketcher to drawing mode"""
|
|
||||||
self.sketcher.set_mode(mode)
|
|
||||||
print(f"Drawing mode set to: {mode.name}")
|
|
||||||
|
|
||||||
def set_constraint_mode(self, mode):
|
|
||||||
"""Set the sketcher to constraint mode"""
|
|
||||||
self.sketcher.set_mode(mode)
|
|
||||||
# Uncheck all drawing buttons when in constraint mode
|
|
||||||
for button in self.drawing_buttons.buttons():
|
|
||||||
button.setChecked(False)
|
|
||||||
print(f"Constraint mode set to: {mode.name}")
|
|
||||||
|
|
||||||
def toggle_construction_mode(self, checked):
|
|
||||||
"""Toggle construction geometry mode"""
|
|
||||||
self.sketcher.set_construction_mode(checked)
|
|
||||||
print(f"Construction mode: {'enabled' if checked else 'disabled'}")
|
|
||||||
|
|
||||||
def toggle_snap_mode(self, snap_mode, enabled):
|
|
||||||
"""Toggle snap mode"""
|
|
||||||
self.sketcher.toggle_snap_mode(snap_mode, enabled)
|
|
||||||
print(f"Snap mode {snap_mode.name}: {'enabled' if enabled else 'disabled'}")
|
|
||||||
|
|
||||||
def connect_sketcher_signals(self):
|
|
||||||
"""Connect to sketcher signals for feedback"""
|
|
||||||
self.sketcher.geometry_created.connect(self.on_geometry_created)
|
|
||||||
self.sketcher.constraint_applied.connect(self.on_constraint_applied)
|
|
||||||
self.sketcher.sketch_modified.connect(self.on_sketch_modified)
|
|
||||||
|
|
||||||
def on_geometry_created(self, geometry_type):
|
|
||||||
"""Handle geometry creation"""
|
|
||||||
print(f"Created: {geometry_type}")
|
|
||||||
# Update status or trigger other actions
|
|
||||||
|
|
||||||
def on_constraint_applied(self):
|
|
||||||
"""Handle constraint application"""
|
|
||||||
print("Constraint applied successfully")
|
|
||||||
# Return to line drawing mode after constraint
|
|
||||||
self.sketcher.set_mode(SketchMode.LINE)
|
|
||||||
self.drawing_buttons.buttons()[0].setChecked(True)
|
|
||||||
|
|
||||||
def on_sketch_modified(self):
|
|
||||||
"""Handle sketch modifications"""
|
|
||||||
print("Sketch modified")
|
|
||||||
# Could trigger auto-save or update displays
|
|
||||||
|
|
||||||
|
|
||||||
def replace_sketcher_in_main_app():
|
|
||||||
"""
|
|
||||||
Example of how to replace the existing sketcher in main.py
|
|
||||||
|
|
||||||
In main.py, replace this code:
|
|
||||||
|
|
||||||
```python\n from drawing_modules.draw_widget_solve import SketchWidget\n self.sketchWidget = SketchWidget()\n ```\n \n With:\n \n ```python\n from drawing_modules.improved_sketcher import ImprovedSketchWidget, SketchMode\n self.sketchWidget = ImprovedSketchWidget()\n \n # Connect to existing signals (adapt as needed)\n self.sketchWidget.constraint_applied.connect(self.draw_op_complete)\n self.sketchWidget.sketch_modified.connect(self.on_sketch_changed)\n \n # Connect toolbar buttons to new sketcher modes\n self.ui.pb_linetool.clicked.connect(lambda: self.sketchWidget.set_mode(SketchMode.LINE))\n self.ui.pb_rectool.clicked.connect(lambda: self.sketchWidget.set_mode(SketchMode.RECTANGLE))\n # ... etc for other buttons\n ```\n \n The improved sketcher provides these advantages:\n \n 1. **Better Architecture**: Clean separation of concerns, proper error handling\n 2. **Enhanced Features**: Rectangle and circle tools, improved constraints\n 3. **Better Performance**: Optimized rendering and interaction handling\n 4. **Extensibility**: Easy to add new tools and constraints\n 5. **Type Safety**: Proper type hints and validation\n 6. **Logging**: Built-in logging for debugging\n 7. **Settings**: Configurable snap and render settings\n \n Key differences to adapt:\n \n - Use SketchMode enum instead of string modes\n - Connect to new signal names (constraint_applied, geometry_created, sketch_modified)\n - Use set_mode() instead of individual mode methods\n - Access sketch data through self.sketch property\n - Use new geometry classes (Point2D, Line2D, Circle2D)\n """\n pass
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":\n import sys\n \n app = QApplication(sys.argv)\n \n # Create and show the integration demo\n demo = SketcherIntegrationDemo()\n demo.show()\n \n print("Improved Sketcher Integration Demo")\n print("==================================")\n print("Features:")\n print("- Line, Rectangle, Circle, Point drawing")\n print("- Coincident, Horizontal, Vertical, Distance constraints")\n print("- Construction geometry mode")\n print("- Point, Grid, Midpoint snapping")\n print("- Zoom to fit")\n print("- Mouse wheel zoom")\n print("- Right-click to cancel operations")\n print("")\n print("Usage:")\n print("- Select a drawing tool and click in the viewport")\n print("- Right-click to finish multi-point operations")\n print("- Use constraint tools to add relationships")\n print("- Toggle construction mode for helper geometry")\n \n sys.exit(app.exec())
|
|
||||||
@@ -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()
|
|
||||||
@@ -1,861 +0,0 @@
|
|||||||
import sys
|
|
||||||
|
|
||||||
import numpy as np
|
|
||||||
import vtk
|
|
||||||
from PySide6 import QtCore, QtWidgets
|
|
||||||
from PySide6.QtCore import Signal
|
|
||||||
from vtkmodules.qt.QVTKRenderWindowInteractor import QVTKRenderWindowInteractor
|
|
||||||
from vtkmodules.util.numpy_support import vtk_to_numpy, numpy_to_vtk
|
|
||||||
|
|
||||||
|
|
||||||
class VTKWidget(QtWidgets.QWidget):
|
|
||||||
face_data = Signal(dict)
|
|
||||||
|
|
||||||
def __init__(self, parent=None):
|
|
||||||
super().__init__(parent)
|
|
||||||
self.selected_vtk_line = []
|
|
||||||
self.access_selected_points = []
|
|
||||||
self.selected_normal = None
|
|
||||||
self.centroid = None
|
|
||||||
self.selected_edges = []
|
|
||||||
self.cell_normals = None
|
|
||||||
|
|
||||||
self.local_matrix = None
|
|
||||||
|
|
||||||
self.project_tosketch_points = []
|
|
||||||
self.project_tosketch_lines = []
|
|
||||||
|
|
||||||
self.vtk_widget = QVTKRenderWindowInteractor(self)
|
|
||||||
|
|
||||||
self.picked_edge_actors = []
|
|
||||||
self.displayed_normal_actors = []
|
|
||||||
self.body_actors_orig = []
|
|
||||||
self.projected_mesh_actors = []
|
|
||||||
self.interactor_actors = []
|
|
||||||
|
|
||||||
self.flip_toggle = False
|
|
||||||
|
|
||||||
# Create layout and add VTK widget
|
|
||||||
layout = QtWidgets.QVBoxLayout()
|
|
||||||
layout.addWidget(self.vtk_widget)
|
|
||||||
self.setLayout(layout)
|
|
||||||
|
|
||||||
# Create VTK pipeline
|
|
||||||
self.renderer = vtk.vtkRenderer()
|
|
||||||
self.renderer_projections = vtk.vtkRenderer()
|
|
||||||
self.renderer_indicators = vtk.vtkRenderer()
|
|
||||||
|
|
||||||
self.renderer.SetViewport(0, 0, 1, 1) # Full viewport
|
|
||||||
self.renderer_projections.SetViewport(0, 0, 1, 1) # Full viewport, overlays the first
|
|
||||||
self.renderer_indicators.SetViewport(0, 0, 1, 1) # Full viewport, overlays the first
|
|
||||||
|
|
||||||
self.renderer.SetLayer(0)
|
|
||||||
self.renderer_projections.SetLayer(1)
|
|
||||||
self.renderer_indicators.SetLayer(2) # This will be on top
|
|
||||||
|
|
||||||
# Preserve color and depth buffers for non-zero layers
|
|
||||||
self.renderer_projections.SetPreserveColorBuffer(True)
|
|
||||||
self.renderer_projections.SetPreserveDepthBuffer(True)
|
|
||||||
self.renderer_indicators.SetPreserveColorBuffer(True)
|
|
||||||
self.renderer_indicators.SetPreserveDepthBuffer(True)
|
|
||||||
|
|
||||||
# Add renderers to the render window
|
|
||||||
render_window = self.vtk_widget.GetRenderWindow()
|
|
||||||
render_window.SetNumberOfLayers(3)
|
|
||||||
render_window.AddRenderer(self.renderer)
|
|
||||||
render_window.AddRenderer(self.renderer_projections)
|
|
||||||
render_window.AddRenderer(self.renderer_indicators)
|
|
||||||
|
|
||||||
self.camera = vtk.vtkCamera()
|
|
||||||
self.camera.SetPosition(5, 5, 1000)
|
|
||||||
self.camera.SetFocalPoint(0, 0, 0)
|
|
||||||
self.camera.SetClippingRange(1, 10000) # Adjusted clipping range
|
|
||||||
|
|
||||||
self.renderer.SetActiveCamera(self.camera)
|
|
||||||
self.renderer_projections.SetActiveCamera(self.camera)
|
|
||||||
self.renderer_indicators.SetActiveCamera(self.camera)
|
|
||||||
|
|
||||||
self.interactor = self.vtk_widget.GetRenderWindow().GetInteractor()
|
|
||||||
|
|
||||||
# Light Setup
|
|
||||||
def add_light(renderer, position, color=(1, 1, 1), intensity=1.0):
|
|
||||||
light = vtk.vtkLight()
|
|
||||||
light.SetPosition(position)
|
|
||||||
light.SetColor(color)
|
|
||||||
light.SetIntensity(intensity)
|
|
||||||
renderer.AddLight(light)
|
|
||||||
|
|
||||||
# Add lights from multiple directions
|
|
||||||
add_light(self.renderer, (1000, 0, 0), intensity=1.5)
|
|
||||||
add_light(self.renderer, (-1000, 0, 0), intensity=1.5)
|
|
||||||
add_light(self.renderer, (0, 1000, 0), intensity=1.5)
|
|
||||||
add_light(self.renderer, (0, -1000, 0), intensity=1.5)
|
|
||||||
add_light(self.renderer, (0, 0, 1000), intensity=1.5)
|
|
||||||
add_light(self.renderer, (0, 0, -1000), intensity=1.5)
|
|
||||||
|
|
||||||
# Set up picking
|
|
||||||
self.picker = vtk.vtkCellPicker()
|
|
||||||
self.picker.SetTolerance(0.005)
|
|
||||||
|
|
||||||
# Create a mapper and actor for picked cells
|
|
||||||
self.picked_mapper = vtk.vtkDataSetMapper()
|
|
||||||
self.picked_actor = vtk.vtkActor()
|
|
||||||
self.picked_actor.SetMapper(self.picked_mapper)
|
|
||||||
self.picked_actor.GetProperty().SetColor(1.0, 0.0, 0.0) # Red color for picked faces
|
|
||||||
self.picked_actor.VisibilityOff() # Initially hide the actor
|
|
||||||
self.renderer.AddActor(self.picked_actor)
|
|
||||||
|
|
||||||
# Create an extract selection filter
|
|
||||||
self.extract_selection = vtk.vtkExtractSelection()
|
|
||||||
|
|
||||||
# Set up interactor style
|
|
||||||
self.style = vtk.vtkInteractorStyleTrackballCamera()
|
|
||||||
self.interactor.SetInteractorStyle(self.style)
|
|
||||||
|
|
||||||
# Add observer for mouse clicks
|
|
||||||
self.interactor.AddObserver("RightButtonPressEvent", self.on_click)
|
|
||||||
|
|
||||||
# Add axis gizmo (smaller size)
|
|
||||||
self.axes = vtk.vtkAxesActor()
|
|
||||||
self.axes.SetTotalLength(0.5, 0.5, 0.5) # Reduced size
|
|
||||||
self.axes.SetShaftType(0)
|
|
||||||
self.axes.SetAxisLabels(1)
|
|
||||||
|
|
||||||
# Create an orientation marker
|
|
||||||
self.axes_widget = vtk.vtkOrientationMarkerWidget()
|
|
||||||
self.axes_widget.SetOrientationMarker(self.axes)
|
|
||||||
self.axes_widget.SetInteractor(self.interactor)
|
|
||||||
self.axes_widget.SetViewport(0.0, 0.0, 0.2, 0.2) # Set position and size
|
|
||||||
self.axes_widget.EnabledOn()
|
|
||||||
self.axes_widget.InteractiveOff()
|
|
||||||
|
|
||||||
# Start the interactor
|
|
||||||
self.interactor.Initialize()
|
|
||||||
self.interactor.Start()
|
|
||||||
|
|
||||||
# Create the grid
|
|
||||||
grid = self.create_grid(size=100, spacing=10)
|
|
||||||
|
|
||||||
# Setup actor and mapper
|
|
||||||
mapper = vtk.vtkPolyDataMapper()
|
|
||||||
mapper.SetInputData(grid)
|
|
||||||
|
|
||||||
actor = vtk.vtkActor()
|
|
||||||
actor.SetPickable(False)
|
|
||||||
actor.SetMapper(mapper)
|
|
||||||
actor.GetProperty().SetColor(0.5, 0.5, 0.5) # Set grid color to gray
|
|
||||||
|
|
||||||
self.renderer.AddActor(actor)
|
|
||||||
|
|
||||||
def reset_camera(self):
|
|
||||||
self.renderer.ResetCamera()
|
|
||||||
self.camera.SetClippingRange(1, 100000) # Set your desired range
|
|
||||||
self.vtk_widget.GetRenderWindow().Render()
|
|
||||||
|
|
||||||
def update_render(self):
|
|
||||||
self.renderer.ResetCameraClippingRange()
|
|
||||||
self.renderer_projections.ResetCameraClippingRange()
|
|
||||||
self.renderer_indicators.ResetCameraClippingRange()
|
|
||||||
self.camera.SetClippingRange(1, 100000)
|
|
||||||
self.vtk_widget.GetRenderWindow().Render()
|
|
||||||
|
|
||||||
def create_grid(self, size=100, spacing=10):
|
|
||||||
# Create a vtkPoints object and store the points in it
|
|
||||||
points = vtk.vtkPoints()
|
|
||||||
|
|
||||||
# Create lines
|
|
||||||
lines = vtk.vtkCellArray()
|
|
||||||
|
|
||||||
# Create the grid
|
|
||||||
for i in range(-size, size + 1, spacing):
|
|
||||||
# X-direction line
|
|
||||||
points.InsertNextPoint(i, -size, 0)
|
|
||||||
points.InsertNextPoint(i, size, 0)
|
|
||||||
line = vtk.vtkLine()
|
|
||||||
line.GetPointIds().SetId(0, points.GetNumberOfPoints() - 2)
|
|
||||||
line.GetPointIds().SetId(1, points.GetNumberOfPoints() - 1)
|
|
||||||
lines.InsertNextCell(line)
|
|
||||||
|
|
||||||
# Y-direction line
|
|
||||||
points.InsertNextPoint(-size, i, 0)
|
|
||||||
points.InsertNextPoint(size, i, 0)
|
|
||||||
line = vtk.vtkLine()
|
|
||||||
line.GetPointIds().SetId(0, points.GetNumberOfPoints() - 2)
|
|
||||||
line.GetPointIds().SetId(1, points.GetNumberOfPoints() - 1)
|
|
||||||
lines.InsertNextCell(line)
|
|
||||||
|
|
||||||
# Create a polydata to store everything in
|
|
||||||
grid = vtk.vtkPolyData()
|
|
||||||
|
|
||||||
# Add the points to the dataset
|
|
||||||
grid.SetPoints(points)
|
|
||||||
|
|
||||||
# Add the lines to the dataset
|
|
||||||
grid.SetLines(lines)
|
|
||||||
|
|
||||||
return grid
|
|
||||||
|
|
||||||
def on_receive_command(self, command):
|
|
||||||
"""Calls the individual commands pressed in main"""
|
|
||||||
print("Receive command: ", command)
|
|
||||||
if command == "flip":
|
|
||||||
self.clear_actors_projection()
|
|
||||||
self.flip_toggle = not self.flip_toggle # Toggle the flag
|
|
||||||
self.on_invert_normal()
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def compute_normal_from_lines(line1, line2):
|
|
||||||
vec1 = line1[1] - line1[0]
|
|
||||||
vec2 = line2[1] - line2[0]
|
|
||||||
normal = np.cross(vec1, vec2)
|
|
||||||
print(normal)
|
|
||||||
normal = normal / np.linalg.norm(normal)
|
|
||||||
return normal
|
|
||||||
|
|
||||||
def load_interactor_mesh(self, edges, off_vector):
|
|
||||||
# Create vtkPoints to store all points
|
|
||||||
points = vtk.vtkPoints()
|
|
||||||
|
|
||||||
# Create vtkCellArray to store the lines
|
|
||||||
lines = vtk.vtkCellArray()
|
|
||||||
|
|
||||||
for edge in edges:
|
|
||||||
# Add points for this edge
|
|
||||||
point_id1 = points.InsertNextPoint(edge[0])
|
|
||||||
point_id2 = points.InsertNextPoint(edge[1])
|
|
||||||
|
|
||||||
# Create a line using the point IDs
|
|
||||||
line = vtk.vtkLine()
|
|
||||||
line.GetPointIds().SetId(0, point_id1)
|
|
||||||
line.GetPointIds().SetId(1, point_id2)
|
|
||||||
|
|
||||||
# Add the line to the cell array
|
|
||||||
lines.InsertNextCell(line)
|
|
||||||
|
|
||||||
# Create vtkPolyData to store the geometry
|
|
||||||
polydata = vtk.vtkPolyData()
|
|
||||||
polydata.SetPoints(points)
|
|
||||||
polydata.SetLines(lines)
|
|
||||||
|
|
||||||
# Create a transform for mirroring across the y-axis
|
|
||||||
matrix_transform = vtk.vtkTransform()
|
|
||||||
|
|
||||||
if self.local_matrix:
|
|
||||||
print(self.local_matrix)
|
|
||||||
matrix = vtk.vtkMatrix4x4()
|
|
||||||
matrix.DeepCopy(self.local_matrix)
|
|
||||||
matrix.Invert()
|
|
||||||
matrix_transform.SetMatrix(matrix)
|
|
||||||
#matrix_transform.Scale(1, 1, 1) # This mirrors across the y-axis
|
|
||||||
|
|
||||||
# Apply the matrix transform
|
|
||||||
transformFilter = vtk.vtkTransformPolyDataFilter()
|
|
||||||
transformFilter.SetInputData(polydata)
|
|
||||||
transformFilter.SetTransform(matrix_transform)
|
|
||||||
transformFilter.Update()
|
|
||||||
|
|
||||||
# Create and apply the offset transform
|
|
||||||
offset_transform = vtk.vtkTransform()
|
|
||||||
offset_transform.Translate(off_vector[0], off_vector[1], off_vector[2])
|
|
||||||
|
|
||||||
offsetFilter = vtk.vtkTransformPolyDataFilter()
|
|
||||||
offsetFilter.SetInputConnection(transformFilter.GetOutputPort())
|
|
||||||
offsetFilter.SetTransform(offset_transform)
|
|
||||||
offsetFilter.Update()
|
|
||||||
|
|
||||||
# Create a mapper and actor
|
|
||||||
mapper = vtk.vtkPolyDataMapper()
|
|
||||||
mapper.SetInputConnection(offsetFilter.GetOutputPort())
|
|
||||||
|
|
||||||
actor = vtk.vtkActor()
|
|
||||||
actor.SetMapper(mapper)
|
|
||||||
actor.GetProperty().SetColor(1.0, 1.0, 1.0)
|
|
||||||
actor.GetProperty().SetLineWidth(4) # Set line width
|
|
||||||
|
|
||||||
# Add the actor to the scene
|
|
||||||
self.renderer.AddActor(actor)
|
|
||||||
self.interactor_actors.append(actor)
|
|
||||||
|
|
||||||
mapper.Update()
|
|
||||||
self.vtk_widget.GetRenderWindow().Render()
|
|
||||||
|
|
||||||
def render_from_points_direct_with_faces(self, vertices, faces, color=(0.1, 0.2, 0.8), line_width=2, point_size=5):
|
|
||||||
"""Sketch Widget has inverted Y axiis therefore we invert y via scale here until fix"""
|
|
||||||
|
|
||||||
# Handle empty vertices or faces
|
|
||||||
if len(vertices) == 0 or len(faces) == 0:
|
|
||||||
print("Warning: No vertices or faces to render")
|
|
||||||
return
|
|
||||||
|
|
||||||
points = vtk.vtkPoints()
|
|
||||||
|
|
||||||
# Validate vertices shape
|
|
||||||
if vertices.ndim != 2 or vertices.shape[1] != 3:
|
|
||||||
print(f"Warning: Invalid vertex shape {vertices.shape}. Expected Nx3.")
|
|
||||||
return
|
|
||||||
|
|
||||||
# Validate faces shape
|
|
||||||
if faces.ndim != 2 or faces.shape[1] != 3:
|
|
||||||
print(f"Warning: Invalid face shape {faces.shape}. Expected Nx3.")
|
|
||||||
return
|
|
||||||
|
|
||||||
# Use SetData with numpy array - ensure vertices are float32
|
|
||||||
try:
|
|
||||||
vertices_float = np.asarray(vertices, dtype=np.float32)
|
|
||||||
vtk_array = numpy_to_vtk(vertices_float, deep=True)
|
|
||||||
points.SetData(vtk_array)
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Error converting vertices to VTK array: {e}")
|
|
||||||
# Fallback: manually insert points
|
|
||||||
for vertex in vertices:
|
|
||||||
points.InsertNextPoint(vertex[0], vertex[1], vertex[2])
|
|
||||||
|
|
||||||
# Create a vtkCellArray to store the triangles
|
|
||||||
triangles = vtk.vtkCellArray()
|
|
||||||
num_vertices = len(vertices)
|
|
||||||
|
|
||||||
for i, face in enumerate(faces):
|
|
||||||
# Validate face indices
|
|
||||||
if (face[0] >= num_vertices or face[0] < 0 or
|
|
||||||
face[1] >= num_vertices or face[1] < 0 or
|
|
||||||
face[2] >= num_vertices or face[2] < 0):
|
|
||||||
print(f"Warning: Invalid face indices {face} at index {i}. Skipping face.")
|
|
||||||
continue
|
|
||||||
|
|
||||||
triangle = vtk.vtkTriangle()
|
|
||||||
triangle.GetPointIds().SetId(0, int(face[0]))
|
|
||||||
triangle.GetPointIds().SetId(1, int(face[1]))
|
|
||||||
triangle.GetPointIds().SetId(2, int(face[2]))
|
|
||||||
triangles.InsertNextCell(triangle)
|
|
||||||
|
|
||||||
# Check if we have any valid triangles
|
|
||||||
if triangles.GetNumberOfCells() == 0:
|
|
||||||
print("Warning: No valid triangles to render")
|
|
||||||
return
|
|
||||||
|
|
||||||
# Create a polydata object
|
|
||||||
polydata = vtk.vtkPolyData()
|
|
||||||
polydata.SetPoints(points)
|
|
||||||
polydata.SetPolys(triangles)
|
|
||||||
|
|
||||||
# Calculate normals
|
|
||||||
normalGenerator = vtk.vtkPolyDataNormals()
|
|
||||||
normalGenerator.SetInputData(polydata)
|
|
||||||
normalGenerator.ComputePointNormalsOn()
|
|
||||||
normalGenerator.ComputeCellNormalsOn()
|
|
||||||
normalGenerator.Update()
|
|
||||||
|
|
||||||
# Safely get cell normals, with fallback if they're not available
|
|
||||||
cell_normals = normalGenerator.GetOutput().GetCellData().GetNormals()
|
|
||||||
if cell_normals:
|
|
||||||
try:
|
|
||||||
self.cell_normals = vtk_to_numpy(cell_normals)
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Warning: Could not convert cell normals to numpy array: {e}")
|
|
||||||
self.cell_normals = None
|
|
||||||
else:
|
|
||||||
print("Warning: No cell normals available")
|
|
||||||
self.cell_normals = None
|
|
||||||
|
|
||||||
# Create a mapper and actor
|
|
||||||
mapper = vtk.vtkPolyDataMapper()
|
|
||||||
mapper.SetInputData(polydata)
|
|
||||||
|
|
||||||
actor = vtk.vtkActor()
|
|
||||||
actor.SetMapper(mapper)
|
|
||||||
actor.GetProperty().SetColor(color)
|
|
||||||
actor.GetProperty().EdgeVisibilityOff()
|
|
||||||
actor.GetProperty().SetLineWidth(line_width)
|
|
||||||
actor.GetProperty().SetMetallic(1)
|
|
||||||
actor.GetProperty().SetOpacity(0.8)
|
|
||||||
actor.SetPickable(False)
|
|
||||||
|
|
||||||
self.renderer.AddActor(actor)
|
|
||||||
self.body_actors_orig.append(actor)
|
|
||||||
self.vtk_widget.GetRenderWindow().Render()
|
|
||||||
|
|
||||||
def clear_body_actors(self):
|
|
||||||
for actor in self.body_actors_orig:
|
|
||||||
self.renderer.RemoveActor(actor)
|
|
||||||
|
|
||||||
def visualize_matrix(self, matrix):
|
|
||||||
points = vtk.vtkPoints()
|
|
||||||
for i in range(4):
|
|
||||||
for j in range(4):
|
|
||||||
points.InsertNextPoint(matrix.GetElement(0, j),
|
|
||||||
matrix.GetElement(1, j),
|
|
||||||
matrix.GetElement(2, j))
|
|
||||||
|
|
||||||
polydata = vtk.vtkPolyData()
|
|
||||||
polydata.SetPoints(points)
|
|
||||||
|
|
||||||
mapper = vtk.vtkPolyDataMapper()
|
|
||||||
mapper.SetInputData(polydata)
|
|
||||||
|
|
||||||
actor = vtk.vtkActor()
|
|
||||||
actor.SetMapper(mapper)
|
|
||||||
actor.GetProperty().SetPointSize(5)
|
|
||||||
|
|
||||||
self.renderer.AddActor(actor)
|
|
||||||
|
|
||||||
def numpy_to_vtk(self, array, deep=True):
|
|
||||||
"""Convert a numpy array to a vtk array."""
|
|
||||||
vtk_array = vtk.vtkDoubleArray()
|
|
||||||
vtk_array.SetNumberOfComponents(array.shape[1])
|
|
||||||
vtk_array.SetNumberOfTuples(array.shape[0])
|
|
||||||
|
|
||||||
for i in range(array.shape[0]):
|
|
||||||
for j in range(array.shape[1]):
|
|
||||||
vtk_array.SetComponent(i, j, array[i, j])
|
|
||||||
|
|
||||||
return vtk_array
|
|
||||||
|
|
||||||
def get_points_and_edges_from_polydata(self, polydata) -> list:
|
|
||||||
# Extract points
|
|
||||||
points = {}
|
|
||||||
vtk_points = polydata.GetPoints()
|
|
||||||
for i in range(vtk_points.GetNumberOfPoints()):
|
|
||||||
point = vtk_points.GetPoint(i)
|
|
||||||
points[i] = np.array(point)
|
|
||||||
|
|
||||||
# Extract edges
|
|
||||||
edges = []
|
|
||||||
for i in range(polydata.GetNumberOfCells()):
|
|
||||||
cell = polydata.GetCell(i)
|
|
||||||
if cell.GetCellType() == vtk.VTK_LINE:
|
|
||||||
point_ids = cell.GetPointIds()
|
|
||||||
edge = (point_ids.GetId(0), point_ids.GetId(1))
|
|
||||||
edges.append(edge)
|
|
||||||
|
|
||||||
return points, edges
|
|
||||||
|
|
||||||
def project_mesh_to_plane(self, input_mesh, normal, origin):
|
|
||||||
# Create the projector
|
|
||||||
projector = vtk.vtkProjectPointsToPlane()
|
|
||||||
projector.SetInputData(input_mesh)
|
|
||||||
projector.SetProjectionTypeToSpecifiedPlane()
|
|
||||||
|
|
||||||
# Set the normal and origin of the plane
|
|
||||||
projector.SetNormal(normal)
|
|
||||||
projector.SetOrigin(origin)
|
|
||||||
|
|
||||||
# Execute the projection
|
|
||||||
projector.Update()
|
|
||||||
|
|
||||||
# Get the projected mesh
|
|
||||||
projected_mesh = projector.GetOutput()
|
|
||||||
return projected_mesh
|
|
||||||
|
|
||||||
def compute_2d_coordinates(self, projected_mesh, normal):
|
|
||||||
# Normalize the normal vector
|
|
||||||
normal = np.array(normal)
|
|
||||||
normal = normal / np.linalg.norm(normal)
|
|
||||||
|
|
||||||
# Create a vtkTransform
|
|
||||||
transform = vtk.vtkTransform()
|
|
||||||
transform.PostMultiply() # This ensures transforms are applied in the order we specify
|
|
||||||
|
|
||||||
# Rotate so that the normal aligns with the Z-axis
|
|
||||||
rotation_axis = np.cross(normal, [0, 0, 1])
|
|
||||||
angle = np.arccos(np.dot(normal, [0, 0, 1])) * 180 / np.pi # Convert to degrees
|
|
||||||
|
|
||||||
if np.linalg.norm(rotation_axis) > 1e-6: # Check if rotation is needed
|
|
||||||
transform.RotateWXYZ(angle, rotation_axis[0], rotation_axis[1], rotation_axis[2])
|
|
||||||
|
|
||||||
# Get the transformation matrix
|
|
||||||
matrix = transform.GetMatrix()
|
|
||||||
self.local_matrix = [matrix.GetElement(i, j) for i in range(4) for j in range(4)]
|
|
||||||
|
|
||||||
# Apply the transform to the polydata
|
|
||||||
transformFilter = vtk.vtkTransformPolyDataFilter()
|
|
||||||
transformFilter.SetInputData(projected_mesh)
|
|
||||||
transformFilter.SetTransform(transform)
|
|
||||||
transformFilter.Update()
|
|
||||||
|
|
||||||
# Get the transformed points
|
|
||||||
transformed_polydata = transformFilter.GetOutput()
|
|
||||||
points = transformed_polydata.GetPoints()
|
|
||||||
|
|
||||||
# Extract 2D coordinates
|
|
||||||
xy_coordinates = []
|
|
||||||
for i in range(points.GetNumberOfPoints()):
|
|
||||||
point = points.GetPoint(i)
|
|
||||||
xy_coordinates.append((point[0], point[1]))
|
|
||||||
|
|
||||||
return xy_coordinates
|
|
||||||
|
|
||||||
def compute_2d_coordinates_line(self, projected_mesh, normal):
|
|
||||||
# Normalize the normal vector
|
|
||||||
normal = np.array(normal)
|
|
||||||
normal = normal / np.linalg.norm(normal)
|
|
||||||
|
|
||||||
# Create a vtkTransform
|
|
||||||
transform = vtk.vtkTransform()
|
|
||||||
transform.PostMultiply() # This ensures transforms are applied in the order we specify
|
|
||||||
|
|
||||||
# Rotate so that the normal aligns with the Z-axis
|
|
||||||
rotation_axis = np.cross(normal, [0, 0, 1])
|
|
||||||
angle = np.arccos(np.dot(normal, [0, 0, 1])) * 180 / np.pi # Convert to degrees
|
|
||||||
|
|
||||||
if np.linalg.norm(rotation_axis) > 1e-6: # Check if rotation is needed
|
|
||||||
transform.RotateWXYZ(angle, rotation_axis[0], rotation_axis[1], rotation_axis[2])
|
|
||||||
|
|
||||||
# Get the transformation matrix
|
|
||||||
matrix = transform.GetMatrix()
|
|
||||||
self.local_matrix = [matrix.GetElement(i, j) for i in range(4) for j in range(4)]
|
|
||||||
|
|
||||||
# Apply the transform to the polydata
|
|
||||||
transformFilter = vtk.vtkTransformPolyDataFilter()
|
|
||||||
transformFilter.SetInputData(projected_mesh)
|
|
||||||
transformFilter.SetTransform(transform)
|
|
||||||
transformFilter.Update()
|
|
||||||
|
|
||||||
# Get the transformed points
|
|
||||||
transformed_polydata = transformFilter.GetOutput()
|
|
||||||
points = transformed_polydata.GetPoints()
|
|
||||||
lines = transformed_polydata.GetLines()
|
|
||||||
|
|
||||||
# Extract 2D coordinates
|
|
||||||
xy_coordinates = []
|
|
||||||
|
|
||||||
if points and lines:
|
|
||||||
points_data = points.GetData()
|
|
||||||
line_ids = vtk.vtkIdList()
|
|
||||||
|
|
||||||
# Loop through all the lines in the vtkCellArray
|
|
||||||
lines.InitTraversal()
|
|
||||||
while lines.GetNextCell(line_ids):
|
|
||||||
line_coordinates = []
|
|
||||||
for j in range(line_ids.GetNumberOfIds()):
|
|
||||||
point_id = line_ids.GetId(j)
|
|
||||||
point = points.GetPoint(point_id)
|
|
||||||
line_coordinates.append((point[0], point[1])) # Only take x, y
|
|
||||||
xy_coordinates.append(line_coordinates)
|
|
||||||
|
|
||||||
return xy_coordinates
|
|
||||||
|
|
||||||
|
|
||||||
def compute_2d_coordinates_line_bak(self, line_source, normal):
|
|
||||||
# Ensure the input is a vtkLineSource
|
|
||||||
print("line", line_source)
|
|
||||||
if not isinstance(line_source, vtk.vtkLineSource):
|
|
||||||
raise ValueError("Input must be a vtkLineSource")
|
|
||||||
|
|
||||||
# Normalize the normal vector
|
|
||||||
normal = np.array(normal)
|
|
||||||
normal = normal / np.linalg.norm(normal)
|
|
||||||
|
|
||||||
# Create a vtkTransform
|
|
||||||
transform = vtk.vtkTransform()
|
|
||||||
transform.PostMultiply() # This ensures transforms are applied in the order we specify
|
|
||||||
|
|
||||||
# Rotate so that the normal aligns with the Z-axis
|
|
||||||
rotation_axis = np.cross(normal, [0, 0, 1])
|
|
||||||
angle = np.arccos(np.dot(normal, [0, 0, 1])) * 180 / np.pi # Convert to degrees
|
|
||||||
|
|
||||||
if np.linalg.norm(rotation_axis) > 1e-6: # Check if rotation is needed
|
|
||||||
transform.RotateWXYZ(angle, rotation_axis[0], rotation_axis[1], rotation_axis[2])
|
|
||||||
|
|
||||||
# Get the transformation matrix
|
|
||||||
matrix = transform.GetMatrix()
|
|
||||||
local_matrix = [matrix.GetElement(i, j) for i in range(4) for j in range(4)]
|
|
||||||
|
|
||||||
# Get the polydata from the line source
|
|
||||||
line_source.Update()
|
|
||||||
polydata = line_source.GetOutput()
|
|
||||||
|
|
||||||
# Apply the transform to the polydata
|
|
||||||
transform_filter = vtk.vtkTransformPolyDataFilter()
|
|
||||||
transform_filter.SetInputData(polydata)
|
|
||||||
transform_filter.SetTransform(transform)
|
|
||||||
transform_filter.Update()
|
|
||||||
|
|
||||||
# Get the transformed points
|
|
||||||
transformed_polydata = transform_filter.GetOutput()
|
|
||||||
transformed_points = transformed_polydata.GetPoints()
|
|
||||||
|
|
||||||
# Extract 2D coordinates
|
|
||||||
xy_coordinates = []
|
|
||||||
for i in range(transformed_points.GetNumberOfPoints()):
|
|
||||||
point = transformed_points.GetPoint(i)
|
|
||||||
xy_coordinates.append((point[0], point[1]))
|
|
||||||
|
|
||||||
return xy_coordinates
|
|
||||||
|
|
||||||
def project_2d_to_3d(self, xy_coordinates, normal):
|
|
||||||
# Normalize the normal vector
|
|
||||||
normal = np.array(normal)
|
|
||||||
normal = normal / np.linalg.norm(normal)
|
|
||||||
|
|
||||||
# Create a vtkTransform for the reverse transformation
|
|
||||||
reverse_transform = vtk.vtkTransform()
|
|
||||||
reverse_transform.PostMultiply() # This ensures transforms are applied in the order we specify
|
|
||||||
|
|
||||||
# Compute the rotation axis and angle (same as in compute_2d_coordinates)
|
|
||||||
rotation_axis = np.cross(normal, [0, 0, 1])
|
|
||||||
angle = np.arccos(np.dot(normal, [0, 0, 1])) * 180 / np.pi # Convert to degrees
|
|
||||||
|
|
||||||
if np.linalg.norm(rotation_axis) > 1e-6: # Check if rotation is needed
|
|
||||||
# Apply the inverse rotation
|
|
||||||
reverse_transform.RotateWXYZ(-angle, rotation_axis[0], rotation_axis[1], rotation_axis[2])
|
|
||||||
|
|
||||||
# Create vtkPoints to store the 2D points
|
|
||||||
points_2d = vtk.vtkPoints()
|
|
||||||
for x, y in xy_coordinates:
|
|
||||||
points_2d.InsertNextPoint(x, y, 0) # Z-coordinate is 0 for 2D points
|
|
||||||
|
|
||||||
# Create a polydata with the 2D points
|
|
||||||
polydata_2d = vtk.vtkPolyData()
|
|
||||||
polydata_2d.SetPoints(points_2d)
|
|
||||||
|
|
||||||
# Apply the reverse transform to the polydata
|
|
||||||
transform_filter = vtk.vtkTransformPolyDataFilter()
|
|
||||||
transform_filter.SetInputData(polydata_2d)
|
|
||||||
transform_filter.SetTransform(reverse_transform)
|
|
||||||
transform_filter.Update()
|
|
||||||
|
|
||||||
# Get the transformed points (now in 3D)
|
|
||||||
transformed_polydata = transform_filter.GetOutput()
|
|
||||||
transformed_points = transformed_polydata.GetPoints()
|
|
||||||
|
|
||||||
# Extract 3D coordinates
|
|
||||||
xyz_coordinates = []
|
|
||||||
for i in range(transformed_points.GetNumberOfPoints()):
|
|
||||||
point = transformed_points.GetPoint(i)
|
|
||||||
xyz_coordinates.append((point[0], point[1], point[2]))
|
|
||||||
|
|
||||||
return xyz_coordinates
|
|
||||||
|
|
||||||
def add_normal_line(self, origin, normal, length=10.0, color=(1, 0, 0)):
|
|
||||||
# Normalize the normal vector
|
|
||||||
normal = np.array(normal)
|
|
||||||
normal = normal / np.linalg.norm(normal)
|
|
||||||
|
|
||||||
# Calculate the end point
|
|
||||||
end_point = origin + normal * length
|
|
||||||
|
|
||||||
# Create vtkPoints
|
|
||||||
points = vtk.vtkPoints()
|
|
||||||
points.InsertNextPoint(origin)
|
|
||||||
points.InsertNextPoint(end_point)
|
|
||||||
|
|
||||||
# Create a line
|
|
||||||
line = vtk.vtkLine()
|
|
||||||
line.GetPointIds().SetId(0, 0)
|
|
||||||
line.GetPointIds().SetId(1, 1)
|
|
||||||
|
|
||||||
# Create a cell array to store the line
|
|
||||||
lines = vtk.vtkCellArray()
|
|
||||||
lines.InsertNextCell(line)
|
|
||||||
|
|
||||||
# Create a polydata to store everything in
|
|
||||||
polyData = vtk.vtkPolyData()
|
|
||||||
polyData.SetPoints(points)
|
|
||||||
polyData.SetLines(lines)
|
|
||||||
|
|
||||||
# Create mapper and actor
|
|
||||||
mapper = vtk.vtkPolyDataMapper()
|
|
||||||
mapper.SetInputData(polyData)
|
|
||||||
|
|
||||||
actor = vtk.vtkActor()
|
|
||||||
actor.SetMapper(mapper)
|
|
||||||
actor.GetProperty().SetColor(color)
|
|
||||||
actor.GetProperty().SetLineWidth(2) # Adjust line width as needed
|
|
||||||
|
|
||||||
# Add to renderer
|
|
||||||
self.renderer.AddActor(actor)
|
|
||||||
self.vtk_widget.GetRenderWindow().Render()
|
|
||||||
|
|
||||||
return actor # Return the actor in case you need to remove or modify it later
|
|
||||||
|
|
||||||
def on_invert_normal(self):
|
|
||||||
# Kippstufe für Normal flip
|
|
||||||
if self.selected_normal is not None:
|
|
||||||
self.clear_actors_normals()
|
|
||||||
self.compute_projection(self.flip_toggle)
|
|
||||||
|
|
||||||
def on_click(self, obj, event):
|
|
||||||
click_pos = self.interactor.GetEventPosition()
|
|
||||||
|
|
||||||
# Perform pick
|
|
||||||
self.picker.Pick(click_pos[0], click_pos[1], 0, self.renderer)
|
|
||||||
|
|
||||||
# Get picked cell ID
|
|
||||||
cell_id = self.picker.GetCellId()
|
|
||||||
|
|
||||||
if cell_id != -1:
|
|
||||||
print(f"Picked cell ID: {cell_id}")
|
|
||||||
|
|
||||||
# Get the polydata and the picked cell
|
|
||||||
polydata = self.picker.GetActor().GetMapper().GetInput()
|
|
||||||
cell = polydata.GetCell(cell_id)
|
|
||||||
|
|
||||||
# Ensure it's a line
|
|
||||||
if cell.GetCellType() == vtk.VTK_LINE:
|
|
||||||
|
|
||||||
# Get the two points of the line
|
|
||||||
point_id1 = cell.GetPointId(0)
|
|
||||||
point_id2 = cell.GetPointId(1)
|
|
||||||
|
|
||||||
proj_point1 = polydata.GetPoint(point_id1)
|
|
||||||
proj_point2 = polydata.GetPoint(point_id2)
|
|
||||||
|
|
||||||
self.access_selected_points.append((proj_point1, proj_point2))
|
|
||||||
|
|
||||||
point1 = np.array(proj_point1)
|
|
||||||
point2 = np.array(proj_point2)
|
|
||||||
|
|
||||||
#print(f"Line starts at: {point1}")
|
|
||||||
#print(f"Line ends at: {point2}")
|
|
||||||
|
|
||||||
# Store this line for later use if needed
|
|
||||||
self.selected_edges.append((point1, point2))
|
|
||||||
|
|
||||||
# Create a new vtkLineSource for the picked edge
|
|
||||||
line_source = vtk.vtkLineSource()
|
|
||||||
line_source.SetPoint1(point1)
|
|
||||||
line_source.SetPoint2(point2)
|
|
||||||
|
|
||||||
self.selected_vtk_line.append(line_source)
|
|
||||||
|
|
||||||
# Create a mapper and actor for the picked edge
|
|
||||||
edge_mapper = vtk.vtkPolyDataMapper()
|
|
||||||
edge_mapper.SetInputConnection(line_source.GetOutputPort())
|
|
||||||
|
|
||||||
edge_actor = vtk.vtkActor()
|
|
||||||
edge_actor.SetMapper(edge_mapper)
|
|
||||||
edge_actor.GetProperty().SetColor(1.0, 0.0, 0.0) # Red color for picked edges
|
|
||||||
edge_actor.GetProperty().SetLineWidth(5) # Make the line thicker
|
|
||||||
|
|
||||||
# Add the actor to the renderer and store it
|
|
||||||
self.renderer_indicators.AddActor(edge_actor)
|
|
||||||
self.picked_edge_actors.append(edge_actor)
|
|
||||||
|
|
||||||
if len(self.selected_edges) == 2:
|
|
||||||
self.compute_projection(False)
|
|
||||||
|
|
||||||
if len(self.selected_edges) > 2:
|
|
||||||
# Clear lists for selection
|
|
||||||
self.selected_vtk_line.clear()
|
|
||||||
self.selected_edges.clear()
|
|
||||||
self.clear_edge_select()
|
|
||||||
|
|
||||||
# Clear Actors from view
|
|
||||||
self.clear_actors_projection()
|
|
||||||
self.clear_actors_sel_edges()
|
|
||||||
self.clear_actors_normals()
|
|
||||||
|
|
||||||
|
|
||||||
def find_origin_vertex(self, edge1, edge2):
|
|
||||||
if edge1[0] == edge2[0]or edge1[0] == edge2[1]:
|
|
||||||
return edge1[0]
|
|
||||||
elif edge1[1] == edge2[0] or edge1[1] == edge2[1]:
|
|
||||||
return edge1[1]
|
|
||||||
else:
|
|
||||||
return None # The edges don't share a vertex
|
|
||||||
|
|
||||||
def clear_edge_select(self ):
|
|
||||||
# Clear selection after projection was succesful
|
|
||||||
self.selected_edges = []
|
|
||||||
self.selected_normal = []
|
|
||||||
|
|
||||||
def clear_actors_projection(self):
|
|
||||||
"""Removes all actors that were used for projection"""
|
|
||||||
for flat_mesh in self.projected_mesh_actors:
|
|
||||||
self.renderer_projections.RemoveActor(flat_mesh)
|
|
||||||
|
|
||||||
def clear_actors_normals(self):
|
|
||||||
for normals in self.displayed_normal_actors:
|
|
||||||
self.renderer_indicators.RemoveActor(normals)
|
|
||||||
|
|
||||||
def clear_actors_sel_edges(self):
|
|
||||||
for edge_line in self.picked_edge_actors:
|
|
||||||
self.renderer_indicators.RemoveActor(edge_line)
|
|
||||||
|
|
||||||
def clear_actors_interactor(self):
|
|
||||||
### Clear the outline of the mesh
|
|
||||||
for interactor in self.interactor_actors:
|
|
||||||
self.renderer.RemoveActor(interactor)
|
|
||||||
|
|
||||||
def compute_projection(self, direction_invert: bool = False):
|
|
||||||
|
|
||||||
# Compute the normal from the two selected edges )
|
|
||||||
edge1 = self.selected_edges[0][1] - self.selected_edges[0][0]
|
|
||||||
edge2 = self.selected_edges[1][1] - self.selected_edges[1][0]
|
|
||||||
selected_normal = np.cross(edge1, edge2)
|
|
||||||
selected_normal = selected_normal / np.linalg.norm(selected_normal)
|
|
||||||
#print("Computed normal:", self.selected_normal)
|
|
||||||
|
|
||||||
# Invert the normal in local z if direction_invert is True
|
|
||||||
if direction_invert:
|
|
||||||
self.selected_normal = -selected_normal
|
|
||||||
else:
|
|
||||||
self.selected_normal = selected_normal
|
|
||||||
|
|
||||||
self.centroid = np.mean([point for edge in self.selected_edges for point in edge], axis=0)
|
|
||||||
#self.centroid = self.find_origin_vertex(edge1, edge2)
|
|
||||||
|
|
||||||
# Draw the normal line
|
|
||||||
normal_length = 50 # Adjust this value to change the length of the normal line
|
|
||||||
normal_actor = self.add_normal_line(self.centroid, self.selected_normal, length=normal_length,
|
|
||||||
color=(1, 0, 0))
|
|
||||||
|
|
||||||
polydata = self.picker.GetActor().GetMapper().GetInput()
|
|
||||||
|
|
||||||
projected_polydata = self.project_mesh_to_plane(polydata, self.selected_normal, self.centroid)
|
|
||||||
|
|
||||||
# Extract 2D coordinates
|
|
||||||
self.project_tosketch_points = self.compute_2d_coordinates(projected_polydata, self.selected_normal)
|
|
||||||
|
|
||||||
# Green indicator mesh needs to be translated to xy point paris start end.
|
|
||||||
self.project_tosketch_lines = self.compute_2d_coordinates_line(projected_polydata, self.selected_normal)
|
|
||||||
|
|
||||||
print("result", self.project_tosketch_lines)
|
|
||||||
"""# Seperately rotate selected edges for drawing
|
|
||||||
self.project_tosketch_lines.clear()
|
|
||||||
for vtk_line in self.selected_vtk_line:
|
|
||||||
proj_vtk_line = self.compute_2d_coordinates_line(vtk_line, self.selected_normal)
|
|
||||||
self.project_tosketch_lines.append(proj_vtk_line)
|
|
||||||
print("outgoing lines", self.project_tosketch_lines)"""
|
|
||||||
|
|
||||||
# Create a mapper and actor for the projected data
|
|
||||||
mapper = vtk.vtkPolyDataMapper()
|
|
||||||
mapper.SetInputData(projected_polydata)
|
|
||||||
|
|
||||||
# Projected mesh in green
|
|
||||||
actor = vtk.vtkActor()
|
|
||||||
actor.SetMapper(mapper)
|
|
||||||
#actor.GetProperty().SetRenderLinesAsTubes(True)
|
|
||||||
actor.GetProperty().SetColor(0.0, 1.0, 0.0) # Set color to green
|
|
||||||
actor.GetProperty().SetLineWidth(4) # Set line width
|
|
||||||
|
|
||||||
self.renderer_indicators.AddActor(normal_actor)
|
|
||||||
self.displayed_normal_actors.append(normal_actor)
|
|
||||||
|
|
||||||
self.renderer_projections.AddActor(actor)
|
|
||||||
self.projected_mesh_actors.append(actor)
|
|
||||||
|
|
||||||
# Render the scene
|
|
||||||
self.update_render()
|
|
||||||
self.vtk_widget.GetRenderWindow().Render()
|
|
||||||
|
|
||||||
def start(self):
|
|
||||||
self.interactor.Initialize()
|
|
||||||
self.interactor.Start()
|
|
||||||
|
|
||||||
|
|
||||||
class MainWindow(QtWidgets.QMainWindow):
|
|
||||||
def __init__(self, parent=None):
|
|
||||||
super().__init__(parent)
|
|
||||||
self.vtk_widget = VTKWidget()
|
|
||||||
self.setCentralWidget(self.vtk_widget)
|
|
||||||
self.setWindowTitle("VTK Mesh Viewer")
|
|
||||||
self.vtk_widget.create_cube_mesh()
|
|
||||||
self.show()
|
|
||||||
self.vtk_widget.start()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
app = QtWidgets.QApplication(sys.argv)
|
|
||||||
window = MainWindow()
|
|
||||||
sys.exit(app.exec())
|
|
||||||
@@ -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")"""
|
|
||||||
@@ -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)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
+514
-335
File diff suppressed because it is too large
Load Diff
-851
@@ -1,851 +0,0 @@
|
|||||||
# nuitka-project: --plugin-enable=pyside6
|
|
||||||
# nuitka-project: --plugin-enable=numpy
|
|
||||||
# nuitka-project: --standalone
|
|
||||||
# nuitka-project: --macos-create-app-bundle
|
|
||||||
|
|
||||||
import uuid
|
|
||||||
import names
|
|
||||||
from PySide6.QtCore import Qt, QPoint, Signal, QSize
|
|
||||||
from PySide6.QtWidgets import QApplication, QMainWindow, QSizePolicy, QInputDialog, QDialog, QVBoxLayout, QHBoxLayout, QLabel, QDoubleSpinBox, QCheckBox, QPushButton, QButtonGroup
|
|
||||||
from Gui import Ui_fluencyCAD # Import the generated GUI module
|
|
||||||
from drawing_modules.vtk_widget import VTKWidget
|
|
||||||
import numpy as np
|
|
||||||
|
|
||||||
from drawing_modules.draw_widget_solve import SketchWidget
|
|
||||||
from sdf import *
|
|
||||||
from python_solvespace import SolverSystem, ResultFlag
|
|
||||||
from mesh_modules import simple_mesh, vesta_mesh, interactor_mesh
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
|
|
||||||
# main, draw_widget, gl_widget
|
|
||||||
|
|
||||||
class ExtrudeDialog(QDialog):
|
|
||||||
def __init__(self, parent=None):
|
|
||||||
super().__init__(parent)
|
|
||||||
self.setWindowTitle('Extrude Options')
|
|
||||||
|
|
||||||
def create_hline():
|
|
||||||
line = QLabel()
|
|
||||||
line.setStyleSheet("border-top: 1px solid #cccccc;") # Light grey line
|
|
||||||
line.setFixedHeight(1)
|
|
||||||
return line
|
|
||||||
|
|
||||||
layout = QVBoxLayout()
|
|
||||||
|
|
||||||
# Length input
|
|
||||||
length_layout = QHBoxLayout()
|
|
||||||
length_label = QLabel('Extrude Length (mm):')
|
|
||||||
self.length_input = QDoubleSpinBox()
|
|
||||||
self.length_input.setDecimals(2)
|
|
||||||
self.length_input.setRange(0, 1000) # Adjust range as needed
|
|
||||||
length_layout.addWidget(length_label)
|
|
||||||
length_layout.addWidget(self.length_input)
|
|
||||||
|
|
||||||
# Symmetric checkbox
|
|
||||||
self.symmetric_checkbox = QCheckBox('Symmetric Extrude')
|
|
||||||
self.invert_checkbox = QCheckBox('Invert Extrusion')
|
|
||||||
self.cut_checkbox = QCheckBox('Perform Cut')
|
|
||||||
self.union_checkbox = QCheckBox('Combine')
|
|
||||||
self.rounded_checkbox = QCheckBox('Round Edges')
|
|
||||||
self.seperator = create_hline()
|
|
||||||
|
|
||||||
# OK and Cancel buttons
|
|
||||||
button_layout = QHBoxLayout()
|
|
||||||
ok_button = QPushButton('OK')
|
|
||||||
cancel_button = QPushButton('Cancel')
|
|
||||||
ok_button.clicked.connect(self.accept)
|
|
||||||
cancel_button.clicked.connect(self.reject)
|
|
||||||
button_layout.addWidget(ok_button)
|
|
||||||
button_layout.addWidget(cancel_button)
|
|
||||||
|
|
||||||
# Add all widgets to main layout
|
|
||||||
layout.addLayout(length_layout)
|
|
||||||
layout.addWidget(self.seperator)
|
|
||||||
layout.addWidget(self.cut_checkbox)
|
|
||||||
layout.addWidget(self.union_checkbox)
|
|
||||||
layout.addWidget(self.seperator)
|
|
||||||
layout.addWidget(self.symmetric_checkbox)
|
|
||||||
layout.addWidget(self.invert_checkbox)
|
|
||||||
layout.addWidget(self.seperator)
|
|
||||||
layout.addWidget(self.rounded_checkbox)
|
|
||||||
|
|
||||||
layout.addLayout(button_layout)
|
|
||||||
|
|
||||||
self.setLayout(layout)
|
|
||||||
|
|
||||||
def get_values(self):
|
|
||||||
return self.length_input.value(), self.symmetric_checkbox.isChecked() ,self.invert_checkbox.isChecked(), self.cut_checkbox.isChecked(), self.union_checkbox.isChecked(), self.rounded_checkbox.isChecked()
|
|
||||||
|
|
||||||
|
|
||||||
class MainWindow(QMainWindow):
|
|
||||||
send_command = Signal(str)
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
super().__init__()
|
|
||||||
|
|
||||||
# Set up the UI from the generated GUI module
|
|
||||||
self.ui = Ui_fluencyCAD()
|
|
||||||
self.ui.setupUi(self)
|
|
||||||
|
|
||||||
self.custom_3D_Widget = VTKWidget()
|
|
||||||
layout = self.ui.gl_box.layout()
|
|
||||||
layout.addWidget(self.custom_3D_Widget)
|
|
||||||
size_policy = QSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.MinimumExpanding)
|
|
||||||
#self.custom_3D_Widget.setSizePolicy(size_policy)
|
|
||||||
|
|
||||||
self.sketchWidget = SketchWidget()
|
|
||||||
layout2 = self.ui.sketch_tab.layout() # Get the layout of self.ui.gl_canvas
|
|
||||||
layout2.addWidget(self.sketchWidget)
|
|
||||||
size_policy = QSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.MinimumExpanding)
|
|
||||||
self.sketchWidget.setSizePolicy(size_policy)
|
|
||||||
|
|
||||||
### Main Model -OLD ?
|
|
||||||
"""self.model = {
|
|
||||||
'sketches': {},
|
|
||||||
'operation': {},
|
|
||||||
}"""
|
|
||||||
self.list_selected = []
|
|
||||||
|
|
||||||
#self.ui.pb_apply_code.pressed.connect(self.check_current_tab)
|
|
||||||
self.ui.sketch_list.currentItemChanged.connect(self.on_item_changed)
|
|
||||||
self.ui.sketch_list.itemChanged.connect(self.draw_mesh)
|
|
||||||
|
|
||||||
### Sketches
|
|
||||||
self.ui.pb_origin_wp.pressed.connect(self.add_new_sketch_origin)
|
|
||||||
self.ui.pb_origin_face.pressed.connect(self.add_new_sketch_wp)
|
|
||||||
|
|
||||||
self.ui.pb_nw_sktch.pressed.connect(self.add_sketch_to_compo)
|
|
||||||
self.ui.pb_del_sketch.pressed.connect(self.del_sketch)
|
|
||||||
self.ui.pb_edt_sktch.pressed.connect(self.edit_sketch)
|
|
||||||
|
|
||||||
self.ui.pb_flip_face.pressed.connect(self.on_flip_face)
|
|
||||||
|
|
||||||
###Modes
|
|
||||||
self.ui.pb_linetool.clicked.connect(self.sketchWidget.act_line_mode)
|
|
||||||
self.ui.pb_con_ptpt.clicked.connect(self.sketchWidget.act_constrain_pt_pt_mode)
|
|
||||||
self.ui.pb_con_line.clicked.connect(self.sketchWidget.act_constrain_pt_line_mode)
|
|
||||||
self.ui.pb_con_horiz.clicked.connect(self.sketchWidget.act_constrain_horiz_line_mode)
|
|
||||||
self.ui.pb_con_vert.clicked.connect(self.sketchWidget.act_constrain_vert_line_mode)
|
|
||||||
self.ui.pb_con_dist.clicked.connect(self.sketchWidget.act_constrain_distance_mode)
|
|
||||||
self.ui.pb_con_mid.clicked.connect(self.sketchWidget.act_constrain_mid_point_mode)
|
|
||||||
|
|
||||||
### Operations
|
|
||||||
self.ui.pb_extrdop.pressed.connect(self.send_extrude)
|
|
||||||
self.ui.pb_cutop.pressed.connect(self.send_cut)
|
|
||||||
self.ui.pb_del_body.pressed.connect(self.del_body)
|
|
||||||
|
|
||||||
self.sketchWidget.constrain_done.connect(self.draw_op_complete)
|
|
||||||
self.setFocusPolicy(Qt.StrongFocus)
|
|
||||||
|
|
||||||
self.send_command.connect(self.custom_3D_Widget.on_receive_command)
|
|
||||||
self.ui.actionNew_Project.triggered.connect(self.new_project)
|
|
||||||
self.ui.pb_enable_construct.clicked.connect(self.sketchWidget.on_construct_change)
|
|
||||||
self.project = Project()
|
|
||||||
self.new_project()
|
|
||||||
|
|
||||||
### SNAPS
|
|
||||||
|
|
||||||
self.ui.pb_snap_midp.toggled.connect(lambda checked: self.sketchWidget.on_snap_mode_change("mpoint", checked))
|
|
||||||
self.ui.pb_snap_horiz.toggled.connect(lambda checked: self.sketchWidget.on_snap_mode_change("horiz", checked))
|
|
||||||
self.ui.pb_snap_vert.toggled.connect(lambda checked: self.sketchWidget.on_snap_mode_change("vert", checked))
|
|
||||||
self.ui.pb_snap_angle.toggled.connect(lambda checked: self.sketchWidget.on_snap_mode_change("angle", checked))
|
|
||||||
self.ui.pb_enable_snap.toggled.connect(lambda checked: self.sketchWidget.on_snap_mode_change("point", checked))
|
|
||||||
### COMPOS
|
|
||||||
### COMPOS
|
|
||||||
|
|
||||||
self.ui.new_compo.pressed.connect(self.new_component)
|
|
||||||
|
|
||||||
"""Project -> (Timeline) -> Component -> Sketch -> Body / Interactor -> Connector -> Assembly -> PB Render"""
|
|
||||||
|
|
||||||
def new_project(self):
|
|
||||||
print("New project")
|
|
||||||
timeline = []
|
|
||||||
self.project.timeline = timeline
|
|
||||||
self.new_component()
|
|
||||||
|
|
||||||
def new_component(self):
|
|
||||||
print("Creating a new component...")
|
|
||||||
|
|
||||||
# Lazily initialize self.compo_layout if it doesn't exist
|
|
||||||
if not hasattr(self, 'compo_layout'):
|
|
||||||
print("Initializing compo_layout...")
|
|
||||||
self.compo_layout = QHBoxLayout()
|
|
||||||
|
|
||||||
# Create a button group
|
|
||||||
self.compo_group = QButtonGroup(self)
|
|
||||||
self.compo_group.setExclusive(True) # Ensure exclusivity
|
|
||||||
|
|
||||||
# Ensure the QGroupBox has a layout
|
|
||||||
if not self.ui.compo_box.layout():
|
|
||||||
self.ui.compo_box.setLayout(QVBoxLayout()) # Set a default layout for QGroupBox
|
|
||||||
|
|
||||||
# Add the horizontal layout to the QGroupBox's layout
|
|
||||||
self.ui.compo_box.layout().addLayout(self.compo_layout)
|
|
||||||
|
|
||||||
# Align the layout to the left
|
|
||||||
self.compo_layout.setAlignment(Qt.AlignLeft)
|
|
||||||
|
|
||||||
# Create and initialize a new Component
|
|
||||||
compo = Component()
|
|
||||||
compo.id = f"Component {len(self.project.timeline)}"
|
|
||||||
compo.descript = "Initial Component"
|
|
||||||
compo.sketches = {}
|
|
||||||
compo.bodies = {}
|
|
||||||
self.project.timeline.append(compo)
|
|
||||||
|
|
||||||
# Create a button for the new component
|
|
||||||
button = QPushButton()
|
|
||||||
button.setToolTip(compo.id)
|
|
||||||
button.setText(str(len(self.project.timeline)))
|
|
||||||
button.setFixedSize(QSize(40, 40)) # Set button size
|
|
||||||
button.setCheckable(True)
|
|
||||||
#button.setAutoExclusive(True)
|
|
||||||
button.released.connect(self.on_compo_change)
|
|
||||||
button.setChecked(True)
|
|
||||||
|
|
||||||
# Add button to the group
|
|
||||||
self.compo_group.addButton(button)
|
|
||||||
|
|
||||||
# Add the button to the layout
|
|
||||||
self.compo_layout.addWidget(button)
|
|
||||||
|
|
||||||
# We automatically switch to the new compo hence, refresh
|
|
||||||
self.on_compo_change()
|
|
||||||
|
|
||||||
print(f"Added component {compo.id} to the layout.")
|
|
||||||
|
|
||||||
def get_activated_compo(self):
|
|
||||||
# Iterate through all items in the layout
|
|
||||||
total_elements = self.compo_layout.count()
|
|
||||||
#print(total_elements)
|
|
||||||
for i in range(total_elements):
|
|
||||||
widget = self.compo_layout.itemAt(i).widget() # Get the widget at the index
|
|
||||||
if widget: # Check if the widget is not None
|
|
||||||
if isinstance(widget, QPushButton) and widget.isCheckable():
|
|
||||||
state = widget.isChecked() # Get the checked state
|
|
||||||
print(f"{widget.text()} is {'checked' if state else 'unchecked'}.")
|
|
||||||
if state:
|
|
||||||
return i
|
|
||||||
|
|
||||||
def add_new_sketch_origin(self):
|
|
||||||
name = f"sketches-{str(names.get_first_name())}"
|
|
||||||
sketch = Sketch()
|
|
||||||
sketch.id = name
|
|
||||||
sketch.origin = [0,0,0]
|
|
||||||
|
|
||||||
self.sketchWidget.reset_buffers()
|
|
||||||
self.sketchWidget.create_sketch(sketch)
|
|
||||||
|
|
||||||
def add_new_sketch_wp(self):
|
|
||||||
## Sketch projected from 3d view into 2d
|
|
||||||
name = f"sketches-{str(names.get_first_name())}"
|
|
||||||
sketch = Sketch()
|
|
||||||
sketch.id = name
|
|
||||||
sketch.origin = self.custom_3D_Widget.centroid
|
|
||||||
sketch.normal = self.custom_3D_Widget.selected_normal
|
|
||||||
sketch.slv_points = []
|
|
||||||
sketch.slv_lines = []
|
|
||||||
sketch.proj_points = self.custom_3D_Widget.project_tosketch_points
|
|
||||||
sketch.proj_lines = self.custom_3D_Widget.project_tosketch_lines
|
|
||||||
|
|
||||||
self.sketchWidget.reset_buffers()
|
|
||||||
self.sketchWidget.create_sketch(sketch)
|
|
||||||
self.sketchWidget.create_workplane_projected()
|
|
||||||
|
|
||||||
if not sketch.proj_lines:
|
|
||||||
self.sketchWidget.convert_proj_points(sketch.proj_points)
|
|
||||||
|
|
||||||
self.sketchWidget.convert_proj_lines(sketch.proj_lines)
|
|
||||||
self.sketchWidget.update()
|
|
||||||
|
|
||||||
# CLear all selections after it has been projected
|
|
||||||
self.custom_3D_Widget.project_tosketch_points.clear()
|
|
||||||
self.custom_3D_Widget.project_tosketch_lines.clear()
|
|
||||||
self.custom_3D_Widget.clear_actors_projection()
|
|
||||||
self.custom_3D_Widget.clear_actors_normals()
|
|
||||||
|
|
||||||
def add_sketch_to_compo(self):
|
|
||||||
"""
|
|
||||||
Add sketch to component
|
|
||||||
:return:
|
|
||||||
"""
|
|
||||||
sketch = Sketch()
|
|
||||||
sketch_from_widget = self.sketchWidget.get_sketch()
|
|
||||||
|
|
||||||
#Save original for editing later
|
|
||||||
sketch.original_sketch = sketch_from_widget
|
|
||||||
|
|
||||||
#Get parameters
|
|
||||||
points = [point for point in sketch_from_widget.points if hasattr(point, 'is_helper') and not point.is_helper]
|
|
||||||
|
|
||||||
sketch.convert_points_for_sdf(points)
|
|
||||||
sketch.id = sketch_from_widget.id
|
|
||||||
|
|
||||||
sketch.filter_lines_for_interactor(sketch_from_widget.lines)
|
|
||||||
|
|
||||||
# Register sketch to timeline
|
|
||||||
### Add selection compo here
|
|
||||||
compo_id = self.get_activated_compo()
|
|
||||||
#print("newsketch_name", sketch.id)
|
|
||||||
self.project.timeline[compo_id].sketches[sketch.id] = sketch
|
|
||||||
|
|
||||||
# Add Item to slection menu
|
|
||||||
self.ui.sketch_list.addItem(sketch.id)
|
|
||||||
|
|
||||||
# Deactivate drawing
|
|
||||||
self.ui.pb_linetool.setChecked(False)
|
|
||||||
self.sketchWidget.line_mode = False
|
|
||||||
|
|
||||||
items = self.ui.sketch_list.findItems(sketch.id, Qt.MatchExactly)[0]
|
|
||||||
self.ui.sketch_list.setCurrentItem(items)
|
|
||||||
|
|
||||||
def on_compo_change(self):
|
|
||||||
'''This function redraws the sdf and helper mesh from available bodies and adds the names back to the list entries'''
|
|
||||||
self.custom_3D_Widget.clear_body_actors()
|
|
||||||
self.custom_3D_Widget.clear_actors_interactor()
|
|
||||||
self.custom_3D_Widget.clear_actors_projection()
|
|
||||||
|
|
||||||
compo_id = self.get_activated_compo()
|
|
||||||
if compo_id is not None:
|
|
||||||
self.ui.sketch_list.clear()
|
|
||||||
self.ui.body_list.clear()
|
|
||||||
|
|
||||||
#print("id", compo_id)
|
|
||||||
#print("sketch_registry", self.project.timeline[compo_id].sketches)
|
|
||||||
|
|
||||||
for sketch in self.project.timeline[compo_id].sketches:
|
|
||||||
#print(sketch)
|
|
||||||
self.ui.sketch_list.addItem(sketch)
|
|
||||||
|
|
||||||
for body in self.project.timeline[compo_id].bodies:
|
|
||||||
self.ui.body_list.addItem(body)
|
|
||||||
|
|
||||||
if self.project.timeline[compo_id].bodies:
|
|
||||||
item = self.ui.body_list.findItems(body , Qt.MatchExactly)[0]
|
|
||||||
self.ui.body_list.setCurrentItem(item)
|
|
||||||
self.draw_mesh()
|
|
||||||
|
|
||||||
selected = self.ui.body_list.currentItem()
|
|
||||||
name = selected.text()
|
|
||||||
|
|
||||||
edges = self.project.timeline[compo_id].bodies[name].interactor.edges
|
|
||||||
offset_vec = self.project.timeline[compo_id].bodies[name].interactor.offset_vector
|
|
||||||
self.custom_3D_Widget.load_interactor_mesh(edges, offset_vec)
|
|
||||||
|
|
||||||
def edit_sketch(self):
|
|
||||||
selected = self.ui.sketch_list.currentItem()
|
|
||||||
name = selected.text()
|
|
||||||
sel_compo = self.project.timeline[self.get_activated_compo()]
|
|
||||||
sketch = sel_compo.sketches[name].original_sketch
|
|
||||||
|
|
||||||
self.sketchWidget.set_sketch(sketch)
|
|
||||||
|
|
||||||
self.sketchWidget.update()
|
|
||||||
|
|
||||||
def del_sketch(self):
|
|
||||||
selected = self.ui.sketch_list.currentItem()
|
|
||||||
name = selected.text()
|
|
||||||
sel_compo = self.project.timeline[self.get_activated_compo()]
|
|
||||||
sketch = sel_compo.sketches[name]
|
|
||||||
|
|
||||||
if sketch is not None:
|
|
||||||
sel_compo.sketches.pop(name)
|
|
||||||
row = self.ui.sketch_list.row(selected) # Get the row of the current item
|
|
||||||
self.ui.sketch_list.takeItem(row) # Remove the item from the list widget
|
|
||||||
self.sketchWidget.sketch = None
|
|
||||||
print(sketch)
|
|
||||||
else:
|
|
||||||
print("No item selected.")
|
|
||||||
|
|
||||||
def on_flip_face(self):
|
|
||||||
self.send_command.emit("flip")
|
|
||||||
|
|
||||||
def draw_op_complete(self):
|
|
||||||
# safely disable the line modes
|
|
||||||
self.ui.pb_linetool.setChecked(False)
|
|
||||||
self.ui.pb_con_ptpt.setChecked(False)
|
|
||||||
self.ui.pb_con_line.setChecked(False)
|
|
||||||
self.ui.pb_con_dist.setChecked(False)
|
|
||||||
self.ui.pb_con_mid.setChecked(False)
|
|
||||||
self.ui.pb_con_perp.setChecked(False)
|
|
||||||
|
|
||||||
self.sketchWidget.mouse_mode = None
|
|
||||||
self.sketchWidget.reset_buffers()
|
|
||||||
|
|
||||||
def draw_mesh(self):
|
|
||||||
|
|
||||||
name = self.ui.body_list.currentItem().text()
|
|
||||||
print("selected_for disp", name)
|
|
||||||
|
|
||||||
compo_id = self.get_activated_compo()
|
|
||||||
model = self.project.timeline[compo_id].bodies[name].sdf_body
|
|
||||||
|
|
||||||
vesta = vesta_mesh
|
|
||||||
model_data = vesta.generate_mesh_from_sdf(model, resolution=64, threshold=0)
|
|
||||||
|
|
||||||
vertices, faces = model_data
|
|
||||||
#vesta.save_mesh_as_stl(vertices, faces, 'test.stl')
|
|
||||||
self.custom_3D_Widget.render_from_points_direct_with_faces(vertices, faces)
|
|
||||||
|
|
||||||
def on_item_changed(self, current_item, previous_item):
|
|
||||||
if current_item:
|
|
||||||
name = current_item.text()
|
|
||||||
#self.view_update()
|
|
||||||
print(f"Selected item: {name}")
|
|
||||||
|
|
||||||
def update_body(self):
|
|
||||||
pass
|
|
||||||
|
|
||||||
def del_body(self):
|
|
||||||
print("Deleting")
|
|
||||||
name = self.ui.body_list.currentItem() # Get the current item
|
|
||||||
|
|
||||||
if name is not None:
|
|
||||||
item_name = name.text()
|
|
||||||
print("obj_name", item_name)
|
|
||||||
# Check if the 'operation' key exists in the model dictionary
|
|
||||||
|
|
||||||
if 'operation' in self.model and item_name in self.model['operation']:
|
|
||||||
if self.model['operation'][item_name]['id'] == item_name:
|
|
||||||
row = self.ui.body_list.row(name) # Get the row of the current item
|
|
||||||
self.ui.body_list.takeItem(row) # Remove the item from the list widget
|
|
||||||
self.model['operation'].pop(item_name) # Remove the item from the operation dictionary
|
|
||||||
print(f"Removed operation: {item_name}")
|
|
||||||
self.custom_3D_Widget.clear_mesh()
|
|
||||||
|
|
||||||
def send_extrude(self):
|
|
||||||
# Dialog input
|
|
||||||
is_symmetric = None
|
|
||||||
length = None
|
|
||||||
invert = None
|
|
||||||
|
|
||||||
selected = self.ui.sketch_list.currentItem()
|
|
||||||
name = selected.text()
|
|
||||||
|
|
||||||
sel_compo = self.project.timeline[self.get_activated_compo()]
|
|
||||||
#print(sel_compo)
|
|
||||||
sketch = sel_compo.sketches[name]
|
|
||||||
#print(sketch)
|
|
||||||
points = sketch.sdf_points
|
|
||||||
|
|
||||||
# detect loop that causes problems in mesh generation
|
|
||||||
if points[-1] == points[0]:
|
|
||||||
print("overlap")
|
|
||||||
del points[-1]
|
|
||||||
|
|
||||||
dialog = ExtrudeDialog(self)
|
|
||||||
if dialog.exec():
|
|
||||||
length, is_symmetric, invert, cut, union_with, rounded = dialog.get_values()
|
|
||||||
#print(f"Extrude length: {length}, Symmetric: {is_symmetric} Invert: {invert}")
|
|
||||||
else:
|
|
||||||
length = 0
|
|
||||||
#print("Extrude cancelled")
|
|
||||||
|
|
||||||
normal = self.custom_3D_Widget.selected_normal
|
|
||||||
#print("Normie enter", normal)
|
|
||||||
if normal is None:
|
|
||||||
normal = [0, 0, 1]
|
|
||||||
|
|
||||||
centroid = self.custom_3D_Widget.centroid
|
|
||||||
if centroid is None:
|
|
||||||
centroid = [0, 0, 0]
|
|
||||||
"""else:
|
|
||||||
centroid = list(centroid)"""
|
|
||||||
#print("This centroid ", centroid)
|
|
||||||
|
|
||||||
sketch.origin = centroid
|
|
||||||
sketch.normal = normal
|
|
||||||
|
|
||||||
f = sketch.extrude(length, is_symmetric, invert, 0)
|
|
||||||
|
|
||||||
# Create body element and assign known stuff
|
|
||||||
name_op = f"extrd-{name}"
|
|
||||||
|
|
||||||
body = Body()
|
|
||||||
body.sketch = sketch #we add the sketches for reference here
|
|
||||||
body.id = name_op
|
|
||||||
body.sdf_body = f
|
|
||||||
|
|
||||||
### Interactor
|
|
||||||
interactor = Interactor()
|
|
||||||
interactor.add_lines_for_interactor(sketch.interactor_lines)
|
|
||||||
interactor.invert = invert
|
|
||||||
|
|
||||||
if not invert:
|
|
||||||
edges = interactor_mesh.generate_mesh(interactor.lines, 0, length)
|
|
||||||
else:
|
|
||||||
edges = interactor_mesh.generate_mesh(interactor.lines, 0, -length)
|
|
||||||
|
|
||||||
sel_compo.bodies[name_op] = body
|
|
||||||
|
|
||||||
offset_vector = interactor.vector_to_centroid(None, centroid, normal)
|
|
||||||
#print("off_ved", offset_vector)
|
|
||||||
if len(offset_vector) == 0 :
|
|
||||||
offset_vector = [0, 0, 0]
|
|
||||||
|
|
||||||
interactor.edges = edges
|
|
||||||
interactor.offset_vector = offset_vector
|
|
||||||
body.interactor = interactor
|
|
||||||
|
|
||||||
self.custom_3D_Widget.load_interactor_mesh(edges, offset_vector)
|
|
||||||
|
|
||||||
self.ui.body_list.addItem(name_op)
|
|
||||||
items = self.ui.body_list.findItems(name_op, Qt.MatchExactly)[0]
|
|
||||||
self.ui.body_list.setCurrentItem(items)
|
|
||||||
|
|
||||||
self.draw_mesh()
|
|
||||||
|
|
||||||
def send_cut(self):
|
|
||||||
"""name = self.ui.body_list.currentItem().text()
|
|
||||||
points = self.model['operation'][name]['sdf_object']
|
|
||||||
sel_compo = self.project.timeline[self.get_activated_compo()]
|
|
||||||
points = sel_compo.bodies[].
|
|
||||||
self.list_selected.append(points)"""
|
|
||||||
|
|
||||||
selected = self.ui.body_list.currentItem()
|
|
||||||
name = selected.text()
|
|
||||||
|
|
||||||
sel_compo = self.project.timeline[self.get_activated_compo()]
|
|
||||||
# print(sel_compo)
|
|
||||||
body = sel_compo.bodies[name]
|
|
||||||
# print(sketch)
|
|
||||||
self.list_selected.append(body.sdf_body)
|
|
||||||
|
|
||||||
if len(self.list_selected) == 2:
|
|
||||||
f = difference(self.list_selected[0], self.list_selected[1]) # equivalent
|
|
||||||
|
|
||||||
element = {
|
|
||||||
'id': name,
|
|
||||||
'type': 'cut',
|
|
||||||
'sdf_object': f,
|
|
||||||
}
|
|
||||||
|
|
||||||
# Create body element and assign known stuff
|
|
||||||
name_op = f"cut-{name}"
|
|
||||||
|
|
||||||
body = Body()
|
|
||||||
body.id = name_op
|
|
||||||
body.sdf_body = f
|
|
||||||
|
|
||||||
## Add to component
|
|
||||||
sel_compo.bodies[name_op] = body
|
|
||||||
|
|
||||||
self.ui.body_list.addItem(name_op)
|
|
||||||
items = self.ui.body_list.findItems(name_op, Qt.MatchExactly)
|
|
||||||
self.ui.body_list.setCurrentItem(items[-1])
|
|
||||||
self.custom_3D_Widget.clear_body_actors()
|
|
||||||
self.draw_mesh()
|
|
||||||
|
|
||||||
elif len(self.list_selected) > 2:
|
|
||||||
self.list_selected.clear()
|
|
||||||
else:
|
|
||||||
print("mindestens 2!")
|
|
||||||
|
|
||||||
def load_and_render(self, file):
|
|
||||||
self.custom_3D_Widget.load_stl(file)
|
|
||||||
self.custom_3D_Widget.update()
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class Timeline:
|
|
||||||
"""Timeline """
|
|
||||||
### Collection of the Components
|
|
||||||
timeline: list = None
|
|
||||||
|
|
||||||
"""add to time,
|
|
||||||
remove from time, """
|
|
||||||
|
|
||||||
class Assembly:
|
|
||||||
"""Connecting Components in 3D space based on slvs solver"""
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class Component:
|
|
||||||
"""The base container combining all related elements
|
|
||||||
id : The unique ID
|
|
||||||
sketches : the base sketches, bodys can contain additonal sketches for features
|
|
||||||
interactor : A smiplified model used as interactor
|
|
||||||
body : The body class that contains the actual 3d information
|
|
||||||
connector : Vector and Nomral information for assembly
|
|
||||||
descript : a basic description
|
|
||||||
materil : Speicfy a material for pbr rendering
|
|
||||||
"""
|
|
||||||
id = None
|
|
||||||
sketches: dict = None
|
|
||||||
bodies: dict = None
|
|
||||||
connector = None
|
|
||||||
|
|
||||||
# Description
|
|
||||||
descript = None
|
|
||||||
|
|
||||||
# PBR
|
|
||||||
material = None
|
|
||||||
|
|
||||||
|
|
||||||
class Connector:
|
|
||||||
"""An Element that contains vectors and or normals as connection points.
|
|
||||||
These connection points can exist independently of bodies and other elements"""
|
|
||||||
id = None
|
|
||||||
vector = None
|
|
||||||
normal = None
|
|
||||||
|
|
||||||
|
|
||||||
class Code:
|
|
||||||
"""A class that holds all information from the code based approach"""
|
|
||||||
command_list = None
|
|
||||||
|
|
||||||
def generate_mesh_from_code(self, code_text: str):
|
|
||||||
local_vars = {}
|
|
||||||
|
|
||||||
try:
|
|
||||||
print(code_text)
|
|
||||||
exec(code_text, globals(), local_vars)
|
|
||||||
# Retrieve the result from the captured local variables
|
|
||||||
result = local_vars.get('result')
|
|
||||||
print("Result:", result)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print("Error executing code:", e)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class Sketch:
|
|
||||||
"""All of the 2D Information of a sketches"""
|
|
||||||
|
|
||||||
# Save the incomng sketch from the 2D widget for late redit
|
|
||||||
original_sketch = None
|
|
||||||
|
|
||||||
id = None
|
|
||||||
|
|
||||||
# Space Information
|
|
||||||
origin = None
|
|
||||||
slv_plane = None
|
|
||||||
normal = None
|
|
||||||
|
|
||||||
# Points in UI form the sketches widget
|
|
||||||
ui_points: list = None
|
|
||||||
ui_lines: list = None
|
|
||||||
|
|
||||||
# Points cartesian coming as result of the solver
|
|
||||||
slv_points: list = None
|
|
||||||
slv_lines: list = None
|
|
||||||
|
|
||||||
sdf_points: list = None
|
|
||||||
|
|
||||||
interactor_lines: list = None
|
|
||||||
|
|
||||||
# Points coming back from the 3D-Widget as projection to draw on
|
|
||||||
proj_points: list = None
|
|
||||||
proj_lines: list = None
|
|
||||||
|
|
||||||
# Workingplane
|
|
||||||
working_plane = None
|
|
||||||
|
|
||||||
def translate_points_tup(self, point: QPoint):
|
|
||||||
"""QPoints from Display to mesh data
|
|
||||||
input: Qpoints
|
|
||||||
output: Tuple X,Y
|
|
||||||
"""
|
|
||||||
if isinstance(point, QPoint):
|
|
||||||
return point.x(), point.y()
|
|
||||||
|
|
||||||
def vector_to_centroid(self, shape_center, centroid, normal):
|
|
||||||
|
|
||||||
if not shape_center:
|
|
||||||
# Calculate the current center of the shape
|
|
||||||
shape_center = [0, 0, 0]
|
|
||||||
|
|
||||||
# Calculate the vector from the shape's center to the centroid
|
|
||||||
center_to_centroid = np.array(centroid) - np.array(shape_center)
|
|
||||||
|
|
||||||
# Project this vector onto the normal to get the required translation along the normal
|
|
||||||
translation_along_normal = np.dot(center_to_centroid, normal) * normal
|
|
||||||
|
|
||||||
return translation_along_normal
|
|
||||||
|
|
||||||
def angle_between_normals(self, normal1, normal2):
|
|
||||||
# Ensure the vectors are normalized
|
|
||||||
n1 = normal1 / np.linalg.norm(normal1)
|
|
||||||
n2 = normal2 / np.linalg.norm(normal2)
|
|
||||||
|
|
||||||
# Compute the dot product
|
|
||||||
dot_product = np.dot(n1, n2)
|
|
||||||
|
|
||||||
# Clip the dot product to the valid range [-1, 1]
|
|
||||||
dot_product = np.clip(dot_product, -1.0, 1.0)
|
|
||||||
|
|
||||||
# Compute the angle in radians
|
|
||||||
angle_rad = np.arccos(dot_product)
|
|
||||||
|
|
||||||
# Convert to degrees if needed
|
|
||||||
angle_deg = np.degrees(angle_rad)
|
|
||||||
print("Angle deg", angle_deg)
|
|
||||||
|
|
||||||
return angle_rad
|
|
||||||
|
|
||||||
def offset_syn(self, f, length):
|
|
||||||
f = f.translate((0,0, length / 2))
|
|
||||||
return f
|
|
||||||
|
|
||||||
def distance(self, p1, p2):
|
|
||||||
"""Calculate the distance between two points."""
|
|
||||||
print("p1", p1)
|
|
||||||
print("p2", p2)
|
|
||||||
return math.sqrt((p1[0] - p2[0]) ** 2 + (p1[1] - p2[1]) ** 2)
|
|
||||||
|
|
||||||
def convert_points_for_sdf(self, points):
|
|
||||||
points_for_sdf = []
|
|
||||||
for point in points:
|
|
||||||
if point.is_helper is False:
|
|
||||||
print("point", point)
|
|
||||||
points_for_sdf.append(self.translate_points_tup(point.ui_point))
|
|
||||||
|
|
||||||
self.sdf_points = points_for_sdf
|
|
||||||
|
|
||||||
def filter_lines_for_interactor(self, lines):
|
|
||||||
### Filter lines that are not meant to be drawn for the interactor like contruction lines
|
|
||||||
filtered_lines = []
|
|
||||||
for line in lines:
|
|
||||||
if not line.is_helper:
|
|
||||||
filtered_lines.append(line)
|
|
||||||
|
|
||||||
self.interactor_lines = filtered_lines
|
|
||||||
|
|
||||||
def extrude(self, height: float, symet: bool = True, invert: bool = False, offset_length: float = None):
|
|
||||||
"""
|
|
||||||
Extrude a 2D shape into 3D, orient it along the normal, and position it relative to the centroid.
|
|
||||||
"""
|
|
||||||
|
|
||||||
# Normalize the normal vector
|
|
||||||
normal = np.array(self.normal)
|
|
||||||
normal = normal / np.linalg.norm(self.normal)
|
|
||||||
|
|
||||||
# Create the 2D shape
|
|
||||||
f = polygon(self.sdf_points)
|
|
||||||
|
|
||||||
# Extrude the shape along the Z-axis
|
|
||||||
f = f.extrude(height)
|
|
||||||
|
|
||||||
# Center the shape along its extrusion axis
|
|
||||||
f = f.translate((0, 0, height / 2))
|
|
||||||
|
|
||||||
# Orient the shape along the normal vector
|
|
||||||
f = f.orient(normal)
|
|
||||||
|
|
||||||
offset_vector = self.vector_to_centroid(None, self.origin, normal)
|
|
||||||
# Adjust the offset vector by subtracting the inset distance along the normal direction
|
|
||||||
adjusted_offset = offset_vector - (normal * height)
|
|
||||||
if invert:
|
|
||||||
# Translate the shape along the adjusted offset vector
|
|
||||||
f = f.translate(adjusted_offset)
|
|
||||||
else:
|
|
||||||
f = f.translate(offset_vector)
|
|
||||||
|
|
||||||
# If offset_length is provided, adjust the offset_vector
|
|
||||||
if offset_length is not None:
|
|
||||||
# Check if offset_vector is not a zero vector
|
|
||||||
offset_vector_magnitude = np.linalg.norm(offset_vector)
|
|
||||||
if offset_vector_magnitude > 1e-10: # Use a small threshold to avoid floating-point issues
|
|
||||||
# Normalize the offset vector
|
|
||||||
offset_vector_norm = offset_vector / offset_vector_magnitude
|
|
||||||
# Scale the normalized vector by the desired length
|
|
||||||
offset_vector = offset_vector_norm * offset_length
|
|
||||||
f = f.translate(offset_vector)
|
|
||||||
else:
|
|
||||||
print("Warning: Offset vector has zero magnitude. Using original vector.")
|
|
||||||
|
|
||||||
# Translate the shape along the adjusted offset vector
|
|
||||||
|
|
||||||
return f
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class Interactor:
|
|
||||||
"""Helper mesh consisting of edges for selection"""
|
|
||||||
lines = None
|
|
||||||
faces = None
|
|
||||||
body = None
|
|
||||||
offset_vector = None
|
|
||||||
edges = None
|
|
||||||
|
|
||||||
def translate_points_tup(self, point: QPoint):
|
|
||||||
"""QPoints from Display to mesh data
|
|
||||||
input: Qpoints
|
|
||||||
output: Tuple X,Y
|
|
||||||
"""
|
|
||||||
if isinstance(point, QPoint):
|
|
||||||
return point.x(), point.y()
|
|
||||||
|
|
||||||
def vector_to_centroid(self, shape_center, centroid, normal):
|
|
||||||
|
|
||||||
if not shape_center:
|
|
||||||
# Calculate the current center of the shape
|
|
||||||
shape_center = [0, 0, 0]
|
|
||||||
|
|
||||||
# Calculate the vector from the shape's center to the centroid
|
|
||||||
center_to_centroid = np.array(centroid) - np.array(shape_center)
|
|
||||||
|
|
||||||
# Project this vector onto the normal to get the required translation along the normal
|
|
||||||
translation_along_normal = np.dot(center_to_centroid, normal) * normal
|
|
||||||
|
|
||||||
return translation_along_normal
|
|
||||||
|
|
||||||
def add_lines_for_interactor(self, input_lines: list):
|
|
||||||
"""Takes Line2D objects from the sketch widget and preparesit for interactor mesh.
|
|
||||||
Translates coordinates."""
|
|
||||||
|
|
||||||
points_for_interact = []
|
|
||||||
for point_to_poly in input_lines:
|
|
||||||
from_coord_start = window.sketchWidget.from_quadrant_coords_no_center(point_to_poly.crd1.ui_point)
|
|
||||||
from_coord_end = window.sketchWidget.from_quadrant_coords_no_center(point_to_poly.crd2.ui_point)
|
|
||||||
start_draw = self.translate_points_tup(from_coord_start)
|
|
||||||
end_draw = self.translate_points_tup(from_coord_end)
|
|
||||||
line = start_draw, end_draw
|
|
||||||
points_for_interact.append(line)
|
|
||||||
|
|
||||||
print("packed_lines", points_for_interact)
|
|
||||||
|
|
||||||
self.lines = points_for_interact
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class Body:
|
|
||||||
"""The actual body as sdf3 object"""
|
|
||||||
id = None
|
|
||||||
sketch = None
|
|
||||||
height = None
|
|
||||||
interactor = None
|
|
||||||
sdf_body = None
|
|
||||||
|
|
||||||
def mirror_body(self, sdf_object3d):
|
|
||||||
f = sdf_object3d.rotate(pi)
|
|
||||||
|
|
||||||
return f
|
|
||||||
|
|
||||||
class Output:
|
|
||||||
def export_mesh(self, sdf_object):
|
|
||||||
"""FINAL EXPORT"""
|
|
||||||
result_points = sdf_object.generate()
|
|
||||||
write_binary_stl('out.stl', result_points)
|
|
||||||
|
|
||||||
def generate_mesh_from_code(self, code_text: str):
|
|
||||||
local_vars = {}
|
|
||||||
|
|
||||||
try:
|
|
||||||
print(code_text)
|
|
||||||
exec(code_text, globals(), local_vars)
|
|
||||||
# Retrieve the result from the captured local variables
|
|
||||||
result = local_vars.get('result')
|
|
||||||
print("Result:", result)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print("Error executing code:", e)
|
|
||||||
|
|
||||||
class Project:
|
|
||||||
"""Project -> Timeline -> Component -> Sketch -> Body / Interactor -> Connector -> Assembly -> PB Render"""
|
|
||||||
timeline: Timeline = None
|
|
||||||
assembly: Assembly = None
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
app = QApplication()
|
|
||||||
window = MainWindow()
|
|
||||||
window.show()
|
|
||||||
app.exec()
|
|
||||||
|
|
||||||
|
|
||||||
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.
@@ -1,43 +0,0 @@
|
|||||||
# Draw simple boundary based on the lines and depth
|
|
||||||
|
|
||||||
def generate_mesh(lines: list, z_origin: float, depth: float, invert: bool = False):
|
|
||||||
|
|
||||||
origin = create_3D(lines, z_origin)
|
|
||||||
|
|
||||||
if invert :
|
|
||||||
extruded = create_3D(lines, z_origin - depth)
|
|
||||||
else:
|
|
||||||
extruded = create_3D(lines, z_origin + depth)
|
|
||||||
|
|
||||||
vert_lines = create_vert_lines(origin, extruded)
|
|
||||||
|
|
||||||
print(f"Result = {origin} / {extruded} / {vert_lines}")
|
|
||||||
|
|
||||||
return origin + vert_lines + extruded
|
|
||||||
|
|
||||||
|
|
||||||
def create_vert_lines(origin, extruded):
|
|
||||||
vert_lines = []
|
|
||||||
for d3_point_o, d3point_e in zip(origin, extruded):
|
|
||||||
for sp3d_1, sp3d_2 in zip(d3_point_o, d3point_e):
|
|
||||||
new_line = sp3d_1, sp3d_2
|
|
||||||
vert_lines.append(new_line)
|
|
||||||
return vert_lines
|
|
||||||
|
|
||||||
|
|
||||||
def create_3D(lines, z_pos):
|
|
||||||
line_loop = []
|
|
||||||
for coordinate2d in lines:
|
|
||||||
start, end = coordinate2d
|
|
||||||
|
|
||||||
xs, ys = start
|
|
||||||
coordinate3d_start_orig = xs, ys, z_pos
|
|
||||||
|
|
||||||
xe, ye = end
|
|
||||||
coordinate3d_end_orig = xe, ye, z_pos
|
|
||||||
|
|
||||||
line3d_orig = coordinate3d_start_orig, coordinate3d_end_orig
|
|
||||||
|
|
||||||
line_loop.append(line3d_orig)
|
|
||||||
|
|
||||||
return line_loop
|
|
||||||
@@ -1,213 +0,0 @@
|
|||||||
import numpy as np
|
|
||||||
from scipy.spatial import Delaunay, ConvexHull
|
|
||||||
#from shapely.geometry import Polygon, Point
|
|
||||||
|
|
||||||
|
|
||||||
def alpha_shape(points, alpha):
|
|
||||||
"""
|
|
||||||
Compute the alpha shape (concave hull) of a set of points.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def add_edge(edges, edge_points, points, i, j):
|
|
||||||
"""Add a line between the i-th and j-th points if not in the list already"""
|
|
||||||
if (i, j) in edges or (j, i) in edges:
|
|
||||||
return
|
|
||||||
edges.add((i, j))
|
|
||||||
edge_points.append(points[[i, j]])
|
|
||||||
|
|
||||||
tri = Delaunay(points)
|
|
||||||
edges = set()
|
|
||||||
edge_points = []
|
|
||||||
|
|
||||||
# Loop over triangles:
|
|
||||||
for ia, ib, ic in tri.simplices:
|
|
||||||
pa = points[ia]
|
|
||||||
pb = points[ib]
|
|
||||||
pc = points[ic]
|
|
||||||
# Lengths of sides of triangle
|
|
||||||
a = np.sqrt((pa[0] - pb[0]) ** 2 + (pa[1] - pb[1]) ** 2)
|
|
||||||
b = np.sqrt((pb[0] - pc[0]) ** 2 + (pb[1] - pc[1]) ** 2)
|
|
||||||
c = np.sqrt((pc[0] - pa[0]) ** 2 + (pc[1] - pa[1]) ** 2)
|
|
||||||
# Semiperimeter of triangle
|
|
||||||
s = (a + b + c) / 2.0
|
|
||||||
# Area of triangle by Heron's formula
|
|
||||||
area = np.sqrt(s * (s - a) * (s - b) * (s - c))
|
|
||||||
circum_r = a * b * c / (4.0 * area)
|
|
||||||
# Here's the radius filter.
|
|
||||||
if circum_r < 1.0 / alpha:
|
|
||||||
add_edge(edges, edge_points, points, ia, ib)
|
|
||||||
add_edge(edges, edge_points, points, ib, ic)
|
|
||||||
add_edge(edges, edge_points, points, ic, ia)
|
|
||||||
|
|
||||||
m = np.array(edge_points)
|
|
||||||
return m
|
|
||||||
|
|
||||||
|
|
||||||
def generate_mesh(points, depth, alpha=0.1):
|
|
||||||
"""
|
|
||||||
Generate a mesh by extruding a 2D shape along the Z-axis, automatically detecting holes.
|
|
||||||
|
|
||||||
:param points: List of (x, y) tuples representing all points of the 2D shape, including potential holes.
|
|
||||||
:param depth: Extrusion depth along the Z-axis.
|
|
||||||
:param alpha: Alpha value for the alpha shape algorithm (controls the "tightness" of the boundary).
|
|
||||||
:return: Tuple of vertices and faces.
|
|
||||||
"""
|
|
||||||
# Convert points to a numpy array
|
|
||||||
points_2d = np.array(points)
|
|
||||||
|
|
||||||
# Compute the alpha shape (outer boundary)
|
|
||||||
boundary_edges = alpha_shape(points_2d, alpha)
|
|
||||||
|
|
||||||
# Create a Polygon from the boundary
|
|
||||||
boundary_polygon = Polygon(boundary_edges)
|
|
||||||
|
|
||||||
# Separate points into boundary and interior
|
|
||||||
boundary_points = []
|
|
||||||
interior_points = []
|
|
||||||
for point in points:
|
|
||||||
if Point(point).touches(boundary_polygon) or Point(point).within(boundary_polygon):
|
|
||||||
if Point(point).touches(boundary_polygon):
|
|
||||||
boundary_points.append(point)
|
|
||||||
else:
|
|
||||||
interior_points.append(point)
|
|
||||||
|
|
||||||
# Perform Delaunay triangulation on all points
|
|
||||||
tri = Delaunay(points_2d)
|
|
||||||
|
|
||||||
# Generate the top and bottom faces
|
|
||||||
bottom_face = np.hstack((tri.points, np.zeros((tri.points.shape[0], 1))))
|
|
||||||
top_face = np.hstack((tri.points, np.ones((tri.points.shape[0], 1)) * depth))
|
|
||||||
|
|
||||||
# Combine top and bottom vertices
|
|
||||||
vertices_array = np.vstack((bottom_face, top_face))
|
|
||||||
|
|
||||||
# Create faces
|
|
||||||
faces = []
|
|
||||||
|
|
||||||
# Bottom face triangulation
|
|
||||||
for simplex in tri.simplices:
|
|
||||||
faces.append(simplex.tolist())
|
|
||||||
|
|
||||||
# Top face triangulation (with an offset)
|
|
||||||
top_offset = len(tri.points)
|
|
||||||
for simplex in tri.simplices:
|
|
||||||
faces.append([i + top_offset for i in simplex])
|
|
||||||
|
|
||||||
# Side faces for the outer boundary
|
|
||||||
for i in range(len(boundary_points)):
|
|
||||||
next_i = (i + 1) % len(boundary_points)
|
|
||||||
current = points.index(boundary_points[i])
|
|
||||||
next_point = points.index(boundary_points[next_i])
|
|
||||||
faces.append([current, top_offset + current, top_offset + next_point])
|
|
||||||
faces.append([current, top_offset + next_point, next_point])
|
|
||||||
|
|
||||||
# Convert vertices to the desired format: list of tuples
|
|
||||||
vertices = [tuple(vertex) for vertex in vertices_array]
|
|
||||||
|
|
||||||
return vertices, faces
|
|
||||||
|
|
||||||
def generate_mesh_wholes(points, holes, depth):
|
|
||||||
"""
|
|
||||||
Generate a mesh by extruding a 2D shape along the Z-axis, including holes.
|
|
||||||
|
|
||||||
:param points: List of (x, y) tuples representing the outer boundary of the 2D shape.
|
|
||||||
:param holes: List of lists, where each inner list contains (x, y) tuples representing a hole.
|
|
||||||
:param depth: Extrusion depth along the Z-axis.
|
|
||||||
:return: Tuple of vertices and faces.
|
|
||||||
"""
|
|
||||||
# Convert points to a numpy array
|
|
||||||
points_2d = np.array(points)
|
|
||||||
|
|
||||||
# Prepare points for triangulation
|
|
||||||
triangulation_points = points_2d.tolist()
|
|
||||||
for hole in holes:
|
|
||||||
triangulation_points.extend(hole)
|
|
||||||
|
|
||||||
# Perform Delaunay triangulation
|
|
||||||
tri = Delaunay(np.array(triangulation_points))
|
|
||||||
|
|
||||||
# Generate the top and bottom faces
|
|
||||||
bottom_face = np.hstack((tri.points, np.zeros((tri.points.shape[0], 1))))
|
|
||||||
top_face = np.hstack((tri.points, np.ones((tri.points.shape[0], 1)) * depth))
|
|
||||||
|
|
||||||
# Combine top and bottom vertices
|
|
||||||
vertices_array = np.vstack((bottom_face, top_face))
|
|
||||||
|
|
||||||
# Create faces
|
|
||||||
faces = []
|
|
||||||
|
|
||||||
# Bottom face triangulation
|
|
||||||
for simplex in tri.simplices:
|
|
||||||
faces.append(simplex.tolist())
|
|
||||||
|
|
||||||
# Top face triangulation (with an offset)
|
|
||||||
top_offset = len(tri.points)
|
|
||||||
for simplex in tri.simplices:
|
|
||||||
faces.append([i + top_offset for i in simplex])
|
|
||||||
|
|
||||||
# Side faces
|
|
||||||
for i in range(len(points)):
|
|
||||||
next_i = (i + 1) % len(points)
|
|
||||||
faces.append([i, top_offset + i, top_offset + next_i])
|
|
||||||
faces.append([i, top_offset + next_i, next_i])
|
|
||||||
|
|
||||||
# Side faces for holes
|
|
||||||
start_index = len(points)
|
|
||||||
for hole in holes:
|
|
||||||
for i in range(len(hole)):
|
|
||||||
current = start_index + i
|
|
||||||
next_i = start_index + (i + 1) % len(hole)
|
|
||||||
faces.append([current, top_offset + next_i, top_offset + current])
|
|
||||||
faces.append([current, next_i, top_offset + next_i])
|
|
||||||
start_index += len(hole)
|
|
||||||
|
|
||||||
# Convert vertices to the desired format: list of tuples
|
|
||||||
vertices = [tuple(vertex) for vertex in vertices_array]
|
|
||||||
|
|
||||||
return vertices, faces
|
|
||||||
|
|
||||||
def generate_mesh_simple(points, depth):
|
|
||||||
"""
|
|
||||||
Generate a mesh by extruding a 2D shape along the Z-axis.
|
|
||||||
|
|
||||||
:param points: List of (x, y) tuples representing the 2D shape.
|
|
||||||
:param depth: Extrusion depth along the Z-axis.
|
|
||||||
:return: Tuple of vertices and faces.
|
|
||||||
"""
|
|
||||||
# Convert points to a numpy array
|
|
||||||
points_2d = np.array(points)
|
|
||||||
|
|
||||||
# Get the convex hull of the points to ensure they form a proper polygon
|
|
||||||
hull = ConvexHull(points_2d)
|
|
||||||
hull_points = points_2d[hull.vertices]
|
|
||||||
|
|
||||||
# Generate the top and bottom faces
|
|
||||||
bottom_face = np.hstack((hull_points, np.zeros((hull_points.shape[0], 1))))
|
|
||||||
top_face = np.hstack((hull_points, np.ones((hull_points.shape[0], 1)) * depth))
|
|
||||||
|
|
||||||
# Combine top and bottom vertices
|
|
||||||
vertices_array = np.vstack((bottom_face, top_face))
|
|
||||||
|
|
||||||
# Create faces
|
|
||||||
faces = []
|
|
||||||
|
|
||||||
# Bottom face triangulation (counter-clockwise)
|
|
||||||
for i in range(len(hull_points) - 2):
|
|
||||||
faces.append([0, i + 2, i + 1])
|
|
||||||
|
|
||||||
# Top face triangulation (counter-clockwise, with an offset)
|
|
||||||
top_offset = len(hull_points)
|
|
||||||
for i in range(len(hull_points) - 2):
|
|
||||||
faces.append([top_offset, top_offset + i + 1, top_offset + i + 2])
|
|
||||||
|
|
||||||
# Side faces (ensure counter-clockwise order)
|
|
||||||
for i in range(len(hull_points)):
|
|
||||||
next_i = (i + 1) % len(hull_points)
|
|
||||||
faces.append([i, top_offset + i, top_offset + next_i])
|
|
||||||
faces.append([i, top_offset + next_i, next_i])
|
|
||||||
|
|
||||||
# Convert vertices to the desired format: list of tuples
|
|
||||||
vertices = [tuple(vertex) for vertex in vertices_array]
|
|
||||||
|
|
||||||
return vertices, faces
|
|
||||||
|
|
||||||
@@ -1,119 +0,0 @@
|
|||||||
import numpy as np
|
|
||||||
from skimage import measure
|
|
||||||
import multiprocessing
|
|
||||||
from functools import partial
|
|
||||||
from multiprocessing.pool import ThreadPool
|
|
||||||
import itertools
|
|
||||||
import time
|
|
||||||
|
|
||||||
|
|
||||||
def _cartesian_product(*arrays):
|
|
||||||
la = len(arrays)
|
|
||||||
dtype = np.result_type(*arrays)
|
|
||||||
arr = np.empty([len(a) for a in arrays] + [la], dtype=dtype)
|
|
||||||
for i, a in enumerate(np.ix_(*arrays)):
|
|
||||||
arr[..., i] = a
|
|
||||||
return arr.reshape(-1, la)
|
|
||||||
|
|
||||||
|
|
||||||
class VESTA:
|
|
||||||
def __init__(self, sdf, bounds=None, resolution=64, threshold=0.0, workers=None):
|
|
||||||
self.sdf = sdf
|
|
||||||
self.bounds = bounds
|
|
||||||
self.resolution = resolution
|
|
||||||
self.threshold = threshold
|
|
||||||
self.workers = workers or multiprocessing.cpu_count()
|
|
||||||
|
|
||||||
def _estimate_bounds(self):
|
|
||||||
s = 16
|
|
||||||
x0 = y0 = z0 = -1e9
|
|
||||||
x1 = y1 = z1 = 1e9
|
|
||||||
prev = None
|
|
||||||
for i in range(32):
|
|
||||||
X = np.linspace(x0, x1, s)
|
|
||||||
Y = np.linspace(y0, y1, s)
|
|
||||||
Z = np.linspace(z0, z1, s)
|
|
||||||
d = np.array([X[1] - X[0], Y[1] - Y[0], Z[1] - Z[0]])
|
|
||||||
threshold = np.linalg.norm(d) / 2
|
|
||||||
if threshold == prev:
|
|
||||||
break
|
|
||||||
prev = threshold
|
|
||||||
P = _cartesian_product(X, Y, Z)
|
|
||||||
volume = self.sdf(P).reshape((len(X), len(Y), len(Z)))
|
|
||||||
where = np.argwhere(np.abs(volume) <= threshold)
|
|
||||||
if where.size == 0:
|
|
||||||
continue
|
|
||||||
x1, y1, z1 = (x0, y0, z0) + where.max(axis=0) * d + d / 2
|
|
||||||
x0, y0, z0 = (x0, y0, z0) + where.min(axis=0) * d - d / 2
|
|
||||||
if prev is None:
|
|
||||||
raise ValueError("Failed to estimate bounds. No points found within any threshold.")
|
|
||||||
return ((x0, y0, z0), (x1, y1, z1))
|
|
||||||
|
|
||||||
def _vesta_worker(self, chunk):
|
|
||||||
x0, x1, y0, y1, z0, z1 = chunk
|
|
||||||
X = np.linspace(x0, x1, self.resolution)
|
|
||||||
Y = np.linspace(y0, y1, self.resolution)
|
|
||||||
Z = np.linspace(z0, z1, self.resolution)
|
|
||||||
P = _cartesian_product(X, Y, Z)
|
|
||||||
V = self.sdf(P).reshape((self.resolution, self.resolution, self.resolution))
|
|
||||||
|
|
||||||
try:
|
|
||||||
verts, faces, _, _ = measure.marching_cubes(V, self.threshold)
|
|
||||||
except RuntimeError:
|
|
||||||
# Return empty arrays if marching_cubes fails
|
|
||||||
return np.array([]), np.array([])
|
|
||||||
|
|
||||||
# Scale and translate vertices to match the chunk's bounds
|
|
||||||
verts = verts / (self.resolution - 1)
|
|
||||||
verts[:, 0] = verts[:, 0] * (x1 - x0) + x0
|
|
||||||
verts[:, 1] = verts[:, 1] * (y1 - y0) + y0
|
|
||||||
verts[:, 2] = verts[:, 2] * (z1 - z0) + z0
|
|
||||||
|
|
||||||
return verts, faces
|
|
||||||
|
|
||||||
def _merge_meshes(self, results):
|
|
||||||
all_verts = []
|
|
||||||
all_faces = []
|
|
||||||
offset = 0
|
|
||||||
for verts, faces in results:
|
|
||||||
if len(verts) > 0 and len(faces) > 0:
|
|
||||||
all_verts.append(verts)
|
|
||||||
all_faces.append(faces + offset)
|
|
||||||
offset += len(verts)
|
|
||||||
if not all_verts or not all_faces:
|
|
||||||
return np.array([]), np.array([])
|
|
||||||
return np.vstack(all_verts), np.vstack(all_faces)
|
|
||||||
|
|
||||||
def generate_mesh(self):
|
|
||||||
if self.bounds is None:
|
|
||||||
self.bounds = self._estimate_bounds()
|
|
||||||
|
|
||||||
(x0, y0, z0), (x1, y1, z1) = self.bounds
|
|
||||||
chunks = [
|
|
||||||
(x0, x1, y0, y1, z0, z1)
|
|
||||||
]
|
|
||||||
|
|
||||||
with ThreadPool(self.workers) as pool:
|
|
||||||
results = pool.map(self._vesta_worker, chunks)
|
|
||||||
|
|
||||||
verts, faces = self._merge_meshes(results)
|
|
||||||
return verts, faces
|
|
||||||
|
|
||||||
|
|
||||||
def generate_mesh_from_sdf(sdf, bounds=None, resolution=64, threshold=0.0, workers=None):
|
|
||||||
vesta = VESTA(sdf, bounds, resolution, threshold, workers)
|
|
||||||
return vesta.generate_mesh()
|
|
||||||
|
|
||||||
|
|
||||||
# Helper function to save the mesh as an STL file
|
|
||||||
def save_mesh_as_stl(vertices, faces, filename):
|
|
||||||
from stl import mesh
|
|
||||||
|
|
||||||
# Create the mesh
|
|
||||||
cube = mesh.Mesh(np.zeros(faces.shape[0], dtype=mesh.Mesh.dtype))
|
|
||||||
for i, f in enumerate(faces):
|
|
||||||
for j in range(3):
|
|
||||||
cube.vectors[i][j] = vertices[f[j], :]
|
|
||||||
|
|
||||||
# Write the mesh to file
|
|
||||||
cube.save(filename)
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
from sdf import *
|
|
||||||
f = box(1).translate((1,1,-0.2))
|
|
||||||
c = hexagon(1).extrude(1).orient([0,0,-1])
|
|
||||||
c = f & c
|
|
||||||
f.save("out.stl")
|
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=61.0", "wheel"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "fluency-cad"
|
||||||
|
version = "2.0.0"
|
||||||
|
description = "Parametric CAD application with OpenCASCADE geometry kernel"
|
||||||
|
readme = "README.md"
|
||||||
|
license = {text = "MIT"}
|
||||||
|
requires-python = ">=3.10"
|
||||||
|
authors = [
|
||||||
|
{name = "Fluency CAD Team"}
|
||||||
|
]
|
||||||
|
keywords = ["cad", "parametric", "opencascade", "3d-modeling"]
|
||||||
|
classifiers = [
|
||||||
|
"Development Status :: 4 - Beta",
|
||||||
|
"Intended Audience :: Developers",
|
||||||
|
"Intended Audience :: End Users/Desktop",
|
||||||
|
"License :: OSI Approved :: MIT License",
|
||||||
|
"Programming Language :: Python :: 3",
|
||||||
|
"Programming Language :: Python :: 3.10",
|
||||||
|
"Programming Language :: Python :: 3.11",
|
||||||
|
"Programming Language :: Python :: 3.12",
|
||||||
|
"Topic :: Scientific/Engineering :: CAD",
|
||||||
|
]
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
"pygfx>=0.1.0",
|
||||||
|
"wgpu>=0.1.0",
|
||||||
|
"PySide6>=6.4.0",
|
||||||
|
"numpy>=1.24.0",
|
||||||
|
"scipy>=1.10.0",
|
||||||
|
"pillow>=10.0.0",
|
||||||
|
"python_solvespace>=3.0.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
dev = [
|
||||||
|
"pytest>=8.0",
|
||||||
|
"black>=24.0",
|
||||||
|
"mypy>=1.8",
|
||||||
|
"ruff>=0.4.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
fluency-cad = "fluency.main:main"
|
||||||
|
|
||||||
|
[project.urls]
|
||||||
|
Homepage = "https://github.com/fluency-cad/fluency"
|
||||||
|
Documentation = "https://github.com/fluency-cad/fluency#readme"
|
||||||
|
Repository = "https://github.com/fluency-cad/fluency"
|
||||||
|
|
||||||
|
[tool.setuptools.packages.find]
|
||||||
|
where = ["src"]
|
||||||
|
|
||||||
|
[tool.setuptools.package-data]
|
||||||
|
fluency = ["py.typed", "*.pyi"]
|
||||||
|
|
||||||
|
[tool.black]
|
||||||
|
line-length = 100
|
||||||
|
target-version = ["py310", "py311", "py312"]
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
line-length = 100
|
||||||
|
target-version = "py310"
|
||||||
|
|
||||||
|
[tool.mypy]
|
||||||
|
python_version = "3.10"
|
||||||
|
warn_return_any = true
|
||||||
|
warn_unused_configs = true
|
||||||
|
disallow_untyped_defs = true
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
asttokens==3.0.0
|
|
||||||
attrs==25.3.0
|
|
||||||
black==24.10.0
|
|
||||||
click==8.2.1
|
|
||||||
contourpy==1.3.2
|
|
||||||
cycler==0.12.1
|
|
||||||
decorator==5.2.1
|
|
||||||
executing==2.2.0
|
|
||||||
flexcache==0.3
|
|
||||||
flexparser==0.4
|
|
||||||
fonttools==4.58.1
|
|
||||||
h5py==3.13.0
|
|
||||||
imageio==2.37.0
|
|
||||||
ipython==9.3.0
|
|
||||||
ipython_pygments_lexers==1.1.1
|
|
||||||
jedi==0.19.2
|
|
||||||
kiwisolver==1.4.8
|
|
||||||
lazy_loader==0.4
|
|
||||||
markdown-it-py==3.0.0
|
|
||||||
matplotlib==3.10.3
|
|
||||||
matplotlib-inline==0.1.7
|
|
||||||
mdurl==0.1.2
|
|
||||||
meshio==5.3.5
|
|
||||||
mypy_extensions==1.1.0
|
|
||||||
names==0.3.0
|
|
||||||
networkx==3.5
|
|
||||||
Nuitka==2.7.10
|
|
||||||
numpy==2.2.6
|
|
||||||
ordered-set==4.1.0
|
|
||||||
packaging==25.0
|
|
||||||
parso==0.8.4
|
|
||||||
pathspec==0.12.1
|
|
||||||
pexpect==4.9.0
|
|
||||||
pillow==11.2.1
|
|
||||||
Pint==0.24.4
|
|
||||||
platformdirs==4.3.8
|
|
||||||
prompt_toolkit==3.0.51
|
|
||||||
ptyprocess==0.7.0
|
|
||||||
pure_eval==0.2.3
|
|
||||||
Pygments==2.19.1
|
|
||||||
pyparsing==3.2.3
|
|
||||||
PySide6==6.9.0
|
|
||||||
PySide6_Addons==6.9.0
|
|
||||||
PySide6_Essentials==6.9.0
|
|
||||||
python-dateutil==2.9.0.post0
|
|
||||||
python_solvespace==3.0.8
|
|
||||||
rich==13.9.4
|
|
||||||
scikit-image==0.25.2
|
|
||||||
scipy==1.15.3
|
|
||||||
sdfcad @ git+https://gitlab.com/nobodyinperson/sdfCAD@42505b5181c88dda2fd66ac9d387533fbe4145f3
|
|
||||||
shiboken6==6.9.0
|
|
||||||
six==1.17.0
|
|
||||||
stack-data==0.6.3
|
|
||||||
tifffile==2025.5.26
|
|
||||||
tokenize_rt==6.2.0
|
|
||||||
traitlets==5.14.3
|
|
||||||
typing_extensions==4.13.2
|
|
||||||
vtk==9.4.2
|
|
||||||
wcwidth==0.2.13
|
|
||||||
xlrd==2.0.2
|
|
||||||
zstandard==0.23.0
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
from . import d2, d3, ease
|
|
||||||
|
|
||||||
from .util import *
|
|
||||||
from .units import units
|
|
||||||
|
|
||||||
from .d2 import *
|
|
||||||
|
|
||||||
from .d3 import *
|
|
||||||
|
|
||||||
from .text import (
|
|
||||||
measure_image,
|
|
||||||
measure_text,
|
|
||||||
image,
|
|
||||||
text,
|
|
||||||
)
|
|
||||||
|
|
||||||
from .mesh import (
|
|
||||||
generate,
|
|
||||||
save,
|
|
||||||
sample_slice,
|
|
||||||
show_slice,
|
|
||||||
)
|
|
||||||
|
|
||||||
from .stl import (
|
|
||||||
write_binary_stl,
|
|
||||||
)
|
|
||||||
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.
@@ -1,390 +0,0 @@
|
|||||||
import functools
|
|
||||||
import numpy as np
|
|
||||||
import operator
|
|
||||||
import copy
|
|
||||||
|
|
||||||
from . import dn, d3, ease
|
|
||||||
|
|
||||||
# Constants
|
|
||||||
|
|
||||||
ORIGIN = np.array((0, 0))
|
|
||||||
|
|
||||||
X = np.array((1, 0))
|
|
||||||
Y = np.array((0, 1))
|
|
||||||
|
|
||||||
UP = Y
|
|
||||||
|
|
||||||
# SDF Class
|
|
||||||
|
|
||||||
_ops = {}
|
|
||||||
|
|
||||||
|
|
||||||
class SDF2:
|
|
||||||
def __init__(self, f):
|
|
||||||
self.f = f
|
|
||||||
|
|
||||||
def __call__(self, p):
|
|
||||||
return self.f(p).reshape((-1, 1))
|
|
||||||
|
|
||||||
def __getattr__(self, name):
|
|
||||||
if name in _ops:
|
|
||||||
f = _ops[name]
|
|
||||||
return functools.partial(f, self)
|
|
||||||
raise AttributeError
|
|
||||||
|
|
||||||
def __or__(self, other):
|
|
||||||
return union(self, other)
|
|
||||||
|
|
||||||
def __and__(self, other):
|
|
||||||
return intersection(self, other)
|
|
||||||
|
|
||||||
def __sub__(self, other):
|
|
||||||
return difference(self, other)
|
|
||||||
|
|
||||||
def fillet(self, r):
|
|
||||||
newSelf = copy.deepcopy(self)
|
|
||||||
newSelf._r = r
|
|
||||||
return newSelf
|
|
||||||
|
|
||||||
def radius(self, *args, **kwargs):
|
|
||||||
return self.fillet(*args, **kwargs)
|
|
||||||
|
|
||||||
def k(self, *args, **kwargs):
|
|
||||||
return self.fillet(*args, **kwargs)
|
|
||||||
|
|
||||||
def r(self, *args, **kwargs):
|
|
||||||
return self.fillet(*args, **kwargs)
|
|
||||||
|
|
||||||
def chamfer(self, c):
|
|
||||||
newSelf = copy.deepcopy(self)
|
|
||||||
newSelf._c = c
|
|
||||||
return newSelf
|
|
||||||
|
|
||||||
def c(self, *args, **kwargs):
|
|
||||||
return self.chamfer(*args, **kwargs)
|
|
||||||
|
|
||||||
|
|
||||||
def sdf2(f):
|
|
||||||
@functools.wraps(f)
|
|
||||||
def wrapper(*args, **kwargs):
|
|
||||||
return SDF2(f(*args, **kwargs))
|
|
||||||
|
|
||||||
return wrapper
|
|
||||||
|
|
||||||
|
|
||||||
def op2(f):
|
|
||||||
@functools.wraps(f)
|
|
||||||
def wrapper(*args, **kwargs):
|
|
||||||
return SDF2(f(*args, **kwargs))
|
|
||||||
|
|
||||||
_ops[f.__name__] = wrapper
|
|
||||||
return wrapper
|
|
||||||
|
|
||||||
|
|
||||||
def op23(f):
|
|
||||||
@functools.wraps(f)
|
|
||||||
def wrapper(*args, **kwargs):
|
|
||||||
return d3.SDF3(f(*args, **kwargs))
|
|
||||||
|
|
||||||
_ops[f.__name__] = wrapper
|
|
||||||
return wrapper
|
|
||||||
|
|
||||||
|
|
||||||
# Helpers
|
|
||||||
|
|
||||||
|
|
||||||
def _length(a):
|
|
||||||
return np.linalg.norm(a, axis=1)
|
|
||||||
|
|
||||||
|
|
||||||
def _normalize(a):
|
|
||||||
return a / np.linalg.norm(a)
|
|
||||||
|
|
||||||
|
|
||||||
def _dot(a, b):
|
|
||||||
return np.sum(a * b, axis=1)
|
|
||||||
|
|
||||||
|
|
||||||
def _vec(*arrs):
|
|
||||||
return np.stack(arrs, axis=-1)
|
|
||||||
|
|
||||||
|
|
||||||
_min = np.minimum
|
|
||||||
_max = np.maximum
|
|
||||||
|
|
||||||
# Primitives
|
|
||||||
|
|
||||||
|
|
||||||
@sdf2
|
|
||||||
def circle(radius=None, diameter=None, center=ORIGIN):
|
|
||||||
if (radius is not None) == (diameter is not None):
|
|
||||||
raise ValueError(f"Specify either radius or diameter")
|
|
||||||
if radius is None:
|
|
||||||
radius = diameter / 2
|
|
||||||
|
|
||||||
def f(p):
|
|
||||||
return _length(p - center) - radius
|
|
||||||
|
|
||||||
return f
|
|
||||||
|
|
||||||
|
|
||||||
@sdf2
|
|
||||||
def line(normal=UP, point=ORIGIN):
|
|
||||||
normal = _normalize(normal)
|
|
||||||
|
|
||||||
def f(p):
|
|
||||||
return np.dot(point - p, normal)
|
|
||||||
|
|
||||||
return f
|
|
||||||
|
|
||||||
|
|
||||||
@sdf2
|
|
||||||
def slab(x0=None, y0=None, x1=None, y1=None, r=None):
|
|
||||||
fs = []
|
|
||||||
if x0 is not None:
|
|
||||||
fs.append(line(X, (x0, 0)))
|
|
||||||
if x1 is not None:
|
|
||||||
fs.append(line(-X, (x1, 0)))
|
|
||||||
if y0 is not None:
|
|
||||||
fs.append(line(Y, (0, y0)))
|
|
||||||
if y1 is not None:
|
|
||||||
fs.append(line(-Y, (0, y1)))
|
|
||||||
return intersection(*fs, r=r)
|
|
||||||
|
|
||||||
|
|
||||||
@sdf2
|
|
||||||
def rectangle(size=1, center=ORIGIN, a=None, b=None):
|
|
||||||
if a is not None and b is not None:
|
|
||||||
a = np.array(a)
|
|
||||||
b = np.array(b)
|
|
||||||
size = b - a
|
|
||||||
center = a + size / 2
|
|
||||||
return rectangle(size, center)
|
|
||||||
size = np.array(size)
|
|
||||||
|
|
||||||
def f(p):
|
|
||||||
q = np.abs(p - center) - size / 2
|
|
||||||
return _length(_max(q, 0)) + _min(np.amax(q, axis=1), 0)
|
|
||||||
|
|
||||||
return f
|
|
||||||
|
|
||||||
|
|
||||||
@sdf2
|
|
||||||
def rounded_rectangle(size, radius, center=ORIGIN):
|
|
||||||
try:
|
|
||||||
r0, r1, r2, r3 = radius
|
|
||||||
except TypeError:
|
|
||||||
r0 = r1 = r2 = r3 = radius
|
|
||||||
|
|
||||||
def f(p):
|
|
||||||
x = p[:, 0]
|
|
||||||
y = p[:, 1]
|
|
||||||
r = np.zeros(len(p)).reshape((-1, 1))
|
|
||||||
r[np.logical_and(x > 0, y > 0)] = r0
|
|
||||||
r[np.logical_and(x > 0, y <= 0)] = r1
|
|
||||||
r[np.logical_and(x <= 0, y <= 0)] = r2
|
|
||||||
r[np.logical_and(x <= 0, y > 0)] = r3
|
|
||||||
q = np.abs(p) - size / 2 + r
|
|
||||||
return (
|
|
||||||
_min(_max(q[:, 0], q[:, 1]), 0).reshape((-1, 1))
|
|
||||||
+ _length(_max(q, 0)).reshape((-1, 1))
|
|
||||||
- r
|
|
||||||
)
|
|
||||||
|
|
||||||
return f
|
|
||||||
|
|
||||||
|
|
||||||
@sdf2
|
|
||||||
def equilateral_triangle():
|
|
||||||
def f(p):
|
|
||||||
k = 3**0.5
|
|
||||||
p = _vec(np.abs(p[:, 0]) - 1, p[:, 1] + 1 / k)
|
|
||||||
w = p[:, 0] + k * p[:, 1] > 0
|
|
||||||
q = _vec(p[:, 0] - k * p[:, 1], -k * p[:, 0] - p[:, 1]) / 2
|
|
||||||
p = np.where(w.reshape((-1, 1)), q, p)
|
|
||||||
p = _vec(p[:, 0] - np.clip(p[:, 0], -2, 0), p[:, 1])
|
|
||||||
return -_length(p) * np.sign(p[:, 1])
|
|
||||||
|
|
||||||
return f
|
|
||||||
|
|
||||||
|
|
||||||
@sdf2
|
|
||||||
def hexagon(radius=None, diameter=None):
|
|
||||||
if (radius is not None) == (diameter is not None):
|
|
||||||
raise ValueError(f"Specify either radius or diameter")
|
|
||||||
if radius is None:
|
|
||||||
radius = diameter / 2
|
|
||||||
radius *= 3**0.5 / 2
|
|
||||||
|
|
||||||
def f(p):
|
|
||||||
k = np.array((3**0.5 / -2, 0.5, np.tan(np.pi / 6)))
|
|
||||||
p = np.abs(p)
|
|
||||||
p -= 2 * k[:2] * _min(_dot(k[:2], p), 0).reshape((-1, 1))
|
|
||||||
p -= _vec(
|
|
||||||
np.clip(p[:, 0], -k[2] * radius, k[2] * radius), np.zeros(len(p)) + radius
|
|
||||||
)
|
|
||||||
return _length(p) * np.sign(p[:, 1])
|
|
||||||
|
|
||||||
return f
|
|
||||||
|
|
||||||
|
|
||||||
@sdf2
|
|
||||||
def rounded_x(w, r):
|
|
||||||
def f(p):
|
|
||||||
p = np.abs(p)
|
|
||||||
q = (_min(p[:, 0] + p[:, 1], w) * 0.5).reshape((-1, 1))
|
|
||||||
return _length(p - q) - r
|
|
||||||
|
|
||||||
return f
|
|
||||||
|
|
||||||
|
|
||||||
def RegularPolygon(n, r=1):
|
|
||||||
ri = r * np.cos(np.pi / n)
|
|
||||||
return intersection(
|
|
||||||
*[slab(y0=-ri).rotate(a) for a in np.arange(0, 2 * np.pi, 2 * np.pi / n)]
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@sdf2
|
|
||||||
def polygon(points):
|
|
||||||
points = [np.array(p) for p in points]
|
|
||||||
|
|
||||||
def f(p):
|
|
||||||
n = len(points)
|
|
||||||
d = _dot(p - points[0], p - points[0])
|
|
||||||
s = np.ones(len(p))
|
|
||||||
for i in range(n):
|
|
||||||
j = (i + n - 1) % n
|
|
||||||
vi = points[i]
|
|
||||||
vj = points[j]
|
|
||||||
e = vj - vi
|
|
||||||
w = p - vi
|
|
||||||
b = w - e * np.clip(np.dot(w, e) / np.dot(e, e), 0, 1).reshape((-1, 1))
|
|
||||||
d = _min(d, _dot(b, b))
|
|
||||||
c1 = p[:, 1] >= vi[1]
|
|
||||||
c2 = p[:, 1] < vj[1]
|
|
||||||
c3 = e[0] * w[:, 1] > e[1] * w[:, 0]
|
|
||||||
c = _vec(c1, c2, c3)
|
|
||||||
s = np.where(np.all(c, axis=1) | np.all(~c, axis=1), -s, s)
|
|
||||||
return s * np.sqrt(d)
|
|
||||||
|
|
||||||
return f
|
|
||||||
|
|
||||||
|
|
||||||
# Positioning
|
|
||||||
|
|
||||||
|
|
||||||
@op2
|
|
||||||
def translate(other, offset):
|
|
||||||
def f(p):
|
|
||||||
return other(p - offset)
|
|
||||||
|
|
||||||
return f
|
|
||||||
|
|
||||||
|
|
||||||
@op2
|
|
||||||
def scale(other, factor):
|
|
||||||
try:
|
|
||||||
x, y = factor
|
|
||||||
except TypeError:
|
|
||||||
x = y = factor
|
|
||||||
s = (x, y)
|
|
||||||
m = min(x, y)
|
|
||||||
|
|
||||||
def f(p):
|
|
||||||
return other(p / s) * m
|
|
||||||
|
|
||||||
return f
|
|
||||||
|
|
||||||
|
|
||||||
@op2
|
|
||||||
def rotate(other, angle):
|
|
||||||
s = np.sin(angle)
|
|
||||||
c = np.cos(angle)
|
|
||||||
m = 1 - c
|
|
||||||
matrix = np.array(
|
|
||||||
[
|
|
||||||
[c, -s],
|
|
||||||
[s, c],
|
|
||||||
]
|
|
||||||
).T
|
|
||||||
|
|
||||||
def f(p):
|
|
||||||
return other(np.dot(p, matrix))
|
|
||||||
|
|
||||||
return f
|
|
||||||
|
|
||||||
|
|
||||||
@op2
|
|
||||||
def circular_array(other, count):
|
|
||||||
angles = [i / count * 2 * np.pi for i in range(count)]
|
|
||||||
return union(*[other.rotate(a) for a in angles])
|
|
||||||
|
|
||||||
|
|
||||||
# Alterations
|
|
||||||
|
|
||||||
|
|
||||||
@op2
|
|
||||||
def elongate(other, size):
|
|
||||||
def f(p):
|
|
||||||
q = np.abs(p) - size
|
|
||||||
x = q[:, 0].reshape((-1, 1))
|
|
||||||
y = q[:, 1].reshape((-1, 1))
|
|
||||||
w = _min(_max(x, y), 0)
|
|
||||||
return other(_max(q, 0)) + w
|
|
||||||
|
|
||||||
return f
|
|
||||||
|
|
||||||
|
|
||||||
# 2D => 3D Operations
|
|
||||||
|
|
||||||
|
|
||||||
@op23
|
|
||||||
def extrude(other, h=np.inf):
|
|
||||||
def f(p):
|
|
||||||
d = other(p[:, [0, 1]])
|
|
||||||
w = _vec(d.reshape(-1), np.abs(p[:, 2]) - h / 2)
|
|
||||||
return _min(_max(w[:, 0], w[:, 1]), 0) + _length(_max(w, 0))
|
|
||||||
|
|
||||||
return f
|
|
||||||
|
|
||||||
|
|
||||||
@op23
|
|
||||||
def extrude_to(a, b, h, e=ease.linear):
|
|
||||||
def f(p):
|
|
||||||
d1 = a(p[:, [0, 1]])
|
|
||||||
d2 = b(p[:, [0, 1]])
|
|
||||||
t = e(np.clip(p[:, 2] / h, -0.5, 0.5) + 0.5)
|
|
||||||
d = d1 + (d2 - d1) * t.reshape((-1, 1))
|
|
||||||
w = _vec(d.reshape(-1), np.abs(p[:, 2]) - h / 2)
|
|
||||||
return _min(_max(w[:, 0], w[:, 1]), 0) + _length(_max(w, 0))
|
|
||||||
|
|
||||||
return f
|
|
||||||
|
|
||||||
|
|
||||||
@op23
|
|
||||||
def revolve(other, offset=0):
|
|
||||||
def f(p):
|
|
||||||
xy = p[:, [0, 1]]
|
|
||||||
# use horizontal distance to Z axis as X coordinate in 2D shape
|
|
||||||
# use Z coordinate as Y coordinate in 2D shape
|
|
||||||
q = _vec(_length(xy) - offset, p[:, 2])
|
|
||||||
return other(q)
|
|
||||||
|
|
||||||
return f
|
|
||||||
|
|
||||||
|
|
||||||
# Common
|
|
||||||
|
|
||||||
union = op2(dn.union)
|
|
||||||
difference = op2(dn.difference)
|
|
||||||
intersection = op2(dn.intersection)
|
|
||||||
blend = op2(dn.blend)
|
|
||||||
negate = op2(dn.negate)
|
|
||||||
dilate = op2(dn.dilate)
|
|
||||||
erode = op2(dn.erode)
|
|
||||||
shell = op2(dn.shell)
|
|
||||||
repeat = op2(dn.repeat)
|
|
||||||
mirror = op2(dn.mirror)
|
|
||||||
modulate_between = op2(dn.modulate_between)
|
|
||||||
stretch = op2(dn.stretch)
|
|
||||||
@@ -1,366 +0,0 @@
|
|||||||
import itertools
|
|
||||||
from functools import reduce, partial
|
|
||||||
import warnings
|
|
||||||
|
|
||||||
from . import ease
|
|
||||||
|
|
||||||
import numpy as np
|
|
||||||
|
|
||||||
_min = np.minimum
|
|
||||||
_max = np.maximum
|
|
||||||
|
|
||||||
|
|
||||||
def distance_to_plane(p, origin, normal):
|
|
||||||
"""
|
|
||||||
Calculate the distance of a point ``p`` to the plane around ``origin`` with
|
|
||||||
normal ``normal``. This is dimension-independent, so e.g. the z-coordinate
|
|
||||||
can be omitted.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
p (array): either [x,y,z] or [[x,y,z],[x,y,z],...]
|
|
||||||
origin (vector): a point on the plane
|
|
||||||
normal (vector): normal vector of the plane
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
int: distance to plane
|
|
||||||
"""
|
|
||||||
normal = normal / np.linalg.norm(normal)
|
|
||||||
return abs((p - origin) @ normal)
|
|
||||||
|
|
||||||
|
|
||||||
def minimum(a, b, r=0):
|
|
||||||
if r:
|
|
||||||
Δ = b - a
|
|
||||||
h = np.clip(0.5 + 0.5 * Δ / r, 0, 1)
|
|
||||||
return b - Δ * h - r * h * (1 - h)
|
|
||||||
else:
|
|
||||||
return np.minimum(a, b)
|
|
||||||
|
|
||||||
|
|
||||||
def maximum(a, b, r=0):
|
|
||||||
if r:
|
|
||||||
Δ = b - a
|
|
||||||
h = np.clip(0.5 - 0.5 * Δ / r, 0, 1)
|
|
||||||
return b - Δ * h + r * h * (1 - h)
|
|
||||||
else:
|
|
||||||
return np.maximum(a, b)
|
|
||||||
|
|
||||||
|
|
||||||
def union(*sdfs, chamfer=0, c=0, radius=0, r=0, fillet=0, f=0):
|
|
||||||
c = max(chamfer, c)
|
|
||||||
r = max(radius, r, fillet, f)
|
|
||||||
sqrt05 = np.sqrt(0.5)
|
|
||||||
|
|
||||||
def f(p):
|
|
||||||
sdfs_ = iter(sdfs)
|
|
||||||
d1 = next(sdfs_)(p)
|
|
||||||
for sdf in sdfs_:
|
|
||||||
d2 = sdf(p)
|
|
||||||
R = r or getattr(sdf, "_r", 0)
|
|
||||||
C = c or getattr(sdf, "_c", 0)
|
|
||||||
parts = (d1, d2)
|
|
||||||
if C:
|
|
||||||
parts = (minimum(d1, d2), (d1 + d2 - C) * sqrt05)
|
|
||||||
d1 = minimum(*parts, R)
|
|
||||||
|
|
||||||
return d1
|
|
||||||
|
|
||||||
return f
|
|
||||||
|
|
||||||
|
|
||||||
def intersection(*sdfs, chamfer=0, c=0, radius=0, r=0, fillet=0, f=0):
|
|
||||||
c = max(chamfer, c)
|
|
||||||
r = max(radius, r, fillet, f)
|
|
||||||
sqrt05 = np.sqrt(0.5)
|
|
||||||
|
|
||||||
def f(p):
|
|
||||||
sdfs_ = iter(sdfs)
|
|
||||||
d1 = next(sdfs_)(p)
|
|
||||||
for sdf in sdfs_:
|
|
||||||
d2 = sdf(p)
|
|
||||||
R = r or getattr(sdf, "_r", 0)
|
|
||||||
C = c or getattr(sdf, "_c", 0)
|
|
||||||
parts = (d1, d2)
|
|
||||||
if C:
|
|
||||||
parts = (maximum(d1, d2), (d1 + d2 + C) * sqrt05)
|
|
||||||
d1 = maximum(*parts, R)
|
|
||||||
|
|
||||||
return d1
|
|
||||||
|
|
||||||
return f
|
|
||||||
|
|
||||||
|
|
||||||
def difference(*sdfs, chamfer=0, c=0, radius=0, r=0, fillet=0, f=0):
|
|
||||||
c = max(chamfer, c)
|
|
||||||
r = max(radius, r, fillet, f)
|
|
||||||
sqrt05 = np.sqrt(0.5)
|
|
||||||
|
|
||||||
def f(p):
|
|
||||||
sdfs_ = iter(sdfs)
|
|
||||||
d1 = next(sdfs_)(p)
|
|
||||||
for sdf in sdfs_:
|
|
||||||
d2 = sdf(p)
|
|
||||||
R = r or getattr(sdf, "_r", 0)
|
|
||||||
C = c or getattr(sdf, "_c", 0)
|
|
||||||
parts = (d1, -d2)
|
|
||||||
if C:
|
|
||||||
parts = (maximum(d1, -d2), (d1 - d2 + C) * sqrt05)
|
|
||||||
d1 = maximum(*parts, R)
|
|
||||||
|
|
||||||
return d1
|
|
||||||
|
|
||||||
return f
|
|
||||||
|
|
||||||
|
|
||||||
def union_legacy(a, *bs, r=None):
|
|
||||||
def f(p):
|
|
||||||
d1 = a(p)
|
|
||||||
for b in bs:
|
|
||||||
d2 = b(p)
|
|
||||||
K = k or getattr(b, "_r", None)
|
|
||||||
if K is None:
|
|
||||||
d1 = _min(d1, d2)
|
|
||||||
else:
|
|
||||||
h = np.clip(0.5 + 0.5 * (d2 - d1) / K, 0, 1)
|
|
||||||
m = d2 + (d1 - d2) * h
|
|
||||||
d1 = m - K * h * (1 - h)
|
|
||||||
return d1
|
|
||||||
|
|
||||||
return f
|
|
||||||
|
|
||||||
|
|
||||||
def difference_legacy(a, *bs, r=None):
|
|
||||||
def f(p):
|
|
||||||
d1 = a(p)
|
|
||||||
for b in bs:
|
|
||||||
d2 = b(p)
|
|
||||||
K = k or getattr(b, "_r", None)
|
|
||||||
if K is None:
|
|
||||||
d1 = _max(d1, -d2)
|
|
||||||
else:
|
|
||||||
h = np.clip(0.5 - 0.5 * (d2 + d1) / K, 0, 1)
|
|
||||||
m = d1 + (-d2 - d1) * h
|
|
||||||
d1 = m + K * h * (1 - h)
|
|
||||||
return d1
|
|
||||||
|
|
||||||
return f
|
|
||||||
|
|
||||||
|
|
||||||
def intersection_legacy(a, *bs, r=None):
|
|
||||||
def f(p):
|
|
||||||
d1 = a(p)
|
|
||||||
for b in bs:
|
|
||||||
d2 = b(p)
|
|
||||||
K = k or getattr(b, "_r", None)
|
|
||||||
if K is None:
|
|
||||||
d1 = _max(d1, d2)
|
|
||||||
else:
|
|
||||||
h = np.clip(0.5 - 0.5 * (d2 - d1) / K, 0, 1)
|
|
||||||
m = d2 + (d1 - d2) * h
|
|
||||||
d1 = m + K * h * (1 - h)
|
|
||||||
return d1
|
|
||||||
|
|
||||||
return f
|
|
||||||
|
|
||||||
|
|
||||||
def blend(a, *bs, r=0.5):
|
|
||||||
def f(p):
|
|
||||||
d1 = a(p)
|
|
||||||
for b in bs:
|
|
||||||
d2 = b(p)
|
|
||||||
K = k or getattr(b, "_r", None)
|
|
||||||
d1 = K * d2 + (1 - K) * d1
|
|
||||||
return d1
|
|
||||||
|
|
||||||
return f
|
|
||||||
|
|
||||||
|
|
||||||
def negate(other):
|
|
||||||
def f(p):
|
|
||||||
return -other(p)
|
|
||||||
|
|
||||||
return f
|
|
||||||
|
|
||||||
|
|
||||||
def dilate(other, r):
|
|
||||||
def f(p):
|
|
||||||
return other(p) - r
|
|
||||||
|
|
||||||
return f
|
|
||||||
|
|
||||||
|
|
||||||
def erode(other, r):
|
|
||||||
def f(p):
|
|
||||||
return other(p) + r
|
|
||||||
|
|
||||||
return f
|
|
||||||
|
|
||||||
|
|
||||||
def shell(other, thickness=1, type="center"):
|
|
||||||
"""
|
|
||||||
Keep only a margin of a given thickness around the object's boundary.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
thickness (float): the resulting thickness
|
|
||||||
type (str): what kind of shell to generate.
|
|
||||||
|
|
||||||
``"center"`` (default)
|
|
||||||
shell is spaced symmetrically around boundary
|
|
||||||
``"outer"``
|
|
||||||
the resulting shell will be ``thickness`` larger than before
|
|
||||||
``"inner"``
|
|
||||||
the resulting shell will be as large as before
|
|
||||||
"""
|
|
||||||
return dict(
|
|
||||||
center=lambda p: np.abs(other(p)) - thickness / 2,
|
|
||||||
inner=other - other.erode(thickness),
|
|
||||||
outer=other.dilate(thickness) - other,
|
|
||||||
)[type]
|
|
||||||
|
|
||||||
|
|
||||||
def modulate_between(sdf, a, b, e=ease.in_out_cubic):
|
|
||||||
"""
|
|
||||||
Apply a distance offset transition between two control points
|
|
||||||
(e.g. make a rod thicker or thinner at some point or add a bump)
|
|
||||||
|
|
||||||
Args:
|
|
||||||
a, b (vectors): the two control points
|
|
||||||
e (scalar function): the distance offset function, will be called with
|
|
||||||
values between 0 (at control point ``a``) and 1 (at control point
|
|
||||||
``b``). Its result will be subtracted from the given SDF, thus
|
|
||||||
enlarging the object by that value.
|
|
||||||
"""
|
|
||||||
|
|
||||||
# unit vector from control point a to b
|
|
||||||
ab = (ab := b - a) / (L := np.linalg.norm(ab))
|
|
||||||
|
|
||||||
def f(p):
|
|
||||||
# project current point onto control direction, clip and apply easing
|
|
||||||
offset = e(np.clip((p - a) @ ab / L, 0, 1))
|
|
||||||
return (dist := sdf(p)) - offset.reshape(dist.shape)
|
|
||||||
|
|
||||||
return f
|
|
||||||
|
|
||||||
|
|
||||||
def stretch(sdf, a, b, symmetric=False, e=ease.linear):
|
|
||||||
"""
|
|
||||||
Grab the object at point ``a`` and stretch the entire plane to ``b``.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
a, b (point vectors): the control points
|
|
||||||
symmetric (bool): also stretch the same into the other direction.
|
|
||||||
e (Easing): easing to apply
|
|
||||||
|
|
||||||
Examples
|
|
||||||
========
|
|
||||||
|
|
||||||
.. code-block:: python
|
|
||||||
|
|
||||||
# make a capsule
|
|
||||||
sphere(5).stretch(ORIGIN, 10*Z).save() # same as capsule(ORIGIN, 10*Z, 5)
|
|
||||||
# make an egg
|
|
||||||
sphere(5).stretch(ORIGIN, 10*Z, e=ease.smoothstep[:0.44]).save()
|
|
||||||
"""
|
|
||||||
ab = (ab := b - a) / (L := np.linalg.norm(ab))
|
|
||||||
|
|
||||||
def f(p):
|
|
||||||
# s = ”how far are we between a and b as fraction?”
|
|
||||||
# if symmetric=True this also goes into the negative direction
|
|
||||||
s = np.clip((p - a) @ ab / L, -1 if symmetric else 0, 1)
|
|
||||||
# we return the sdf at a point 'behind' (p minus ...)
|
|
||||||
# the current point, but we go only as far back as the stretch distance
|
|
||||||
# at max
|
|
||||||
return sdf(p - (np.sign(s) * e(abs(s)) * L * ab[:, np.newaxis]).T)
|
|
||||||
|
|
||||||
return f
|
|
||||||
|
|
||||||
|
|
||||||
def shear(sdf, fix, grab, move, e=ease.linear):
|
|
||||||
"""
|
|
||||||
Grab the object at point ``grab`` and shear the entire plane in direction
|
|
||||||
``move``, keeping point ``fix`` in place. If ``move`` is orthogonal to the
|
|
||||||
direction ``fix``->``grab``, then this operation is a shear.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
fix, grab (point vectors): the control points
|
|
||||||
move (point vector): direction to shear to
|
|
||||||
e (Easing): easing to apply
|
|
||||||
|
|
||||||
Examples
|
|
||||||
========
|
|
||||||
|
|
||||||
.. code-block:: python
|
|
||||||
|
|
||||||
# make a capsule
|
|
||||||
box([20,10,50]).shear(fix=-15*Z, grab=15*Z, move=-5*X, e=ease.smoothstep)
|
|
||||||
"""
|
|
||||||
ab = (ab := grab - fix) / (L := np.linalg.norm(ab))
|
|
||||||
|
|
||||||
def f(p):
|
|
||||||
# s = ”how far are we between a and b as fraction?”
|
|
||||||
s = (p - fix) @ ab / L
|
|
||||||
return sdf(p - move * np.expand_dims(e(np.clip(s, 0, 1)), axis=1))
|
|
||||||
|
|
||||||
return f
|
|
||||||
|
|
||||||
|
|
||||||
def mirror(other, direction, at=0):
|
|
||||||
"""
|
|
||||||
Mirror around a given plane defined by ``origin`` reference point and
|
|
||||||
``direction``.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
direction (vector): direction to mirror to (e.g. :any:`X` to mirror along X axis)
|
|
||||||
at (3D vector): point to mirror at. Default is the origin.
|
|
||||||
"""
|
|
||||||
direction = direction / np.linalg.norm(direction)
|
|
||||||
|
|
||||||
def f(p):
|
|
||||||
projdir = np.expand_dims((p - at) @ direction, axis=1) * direction
|
|
||||||
# mirrored point:
|
|
||||||
# - project 'p' onto 'direction' (result goes into 'projdir' direction)
|
|
||||||
# - projected point is at 'at + projdir'
|
|
||||||
# - remember direction from projected point to the original point (p - (at + projdir))
|
|
||||||
# - from origin 'at' go backwards the projected direction (at - projdir)
|
|
||||||
# - from that target, move along the remembered direction (p - (at + projdir))
|
|
||||||
# - pmirr = at - projdir + (p - (at + projdir))
|
|
||||||
# - the 'at' cancels out, the projdir is subtracted twice from the point
|
|
||||||
return other(p - 2 * projdir)
|
|
||||||
|
|
||||||
return f
|
|
||||||
|
|
||||||
|
|
||||||
def repeat(other, spacing, count=None, padding=0):
|
|
||||||
count = np.array(count) if count is not None else None
|
|
||||||
spacing = np.array(spacing)
|
|
||||||
|
|
||||||
def neighbors(dim, padding, spacing):
|
|
||||||
try:
|
|
||||||
padding = [padding[i] for i in range(dim)]
|
|
||||||
except Exception:
|
|
||||||
padding = [padding] * dim
|
|
||||||
try:
|
|
||||||
spacing = [spacing[i] for i in range(dim)]
|
|
||||||
except Exception:
|
|
||||||
spacing = [spacing] * dim
|
|
||||||
for i, s in enumerate(spacing):
|
|
||||||
if s == 0:
|
|
||||||
padding[i] = 0
|
|
||||||
axes = [list(range(-p, p + 1)) for p in padding]
|
|
||||||
return list(itertools.product(*axes))
|
|
||||||
|
|
||||||
def f(p):
|
|
||||||
q = np.divide(p, spacing, out=np.zeros_like(p), where=spacing != 0)
|
|
||||||
if count is None:
|
|
||||||
index = np.round(q)
|
|
||||||
else:
|
|
||||||
index = np.clip(np.round(q), -count, count)
|
|
||||||
|
|
||||||
indexes = [index + n for n in neighbors(p.shape[-1], padding, spacing)]
|
|
||||||
A = [other(p - spacing * i) for i in indexes]
|
|
||||||
a = A[0]
|
|
||||||
for b in A[1:]:
|
|
||||||
a = _min(a, b)
|
|
||||||
return a
|
|
||||||
|
|
||||||
return f
|
|
||||||
-637
@@ -1,637 +0,0 @@
|
|||||||
# system modules
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from typing import Callable
|
|
||||||
import itertools
|
|
||||||
import functools
|
|
||||||
import warnings
|
|
||||||
|
|
||||||
# external modules
|
|
||||||
import numpy as np
|
|
||||||
import scipy.optimize
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
@functools.total_ordering
|
|
||||||
class Extremum:
|
|
||||||
"""
|
|
||||||
Container for min and max in Easing
|
|
||||||
"""
|
|
||||||
|
|
||||||
pos: float
|
|
||||||
value: float
|
|
||||||
|
|
||||||
def __eq__(self, other):
|
|
||||||
return self.value == other.value
|
|
||||||
|
|
||||||
def __lt__(self, other):
|
|
||||||
return self.value < other.value
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
@functools.total_ordering
|
|
||||||
class Easing:
|
|
||||||
"""
|
|
||||||
A function defined on the interval [0;1]
|
|
||||||
"""
|
|
||||||
|
|
||||||
f: Callable[float, float]
|
|
||||||
name: str
|
|
||||||
|
|
||||||
def modifier(decorated_fun):
|
|
||||||
@functools.wraps(decorated_fun)
|
|
||||||
def wrapper(self, *args, **kwargs):
|
|
||||||
newfun = decorated_fun(self, *args, **kwargs)
|
|
||||||
arglist = ",".join(
|
|
||||||
itertools.chain(map(str, args), (f"{k}={v}" for k, v in kwargs.items()))
|
|
||||||
)
|
|
||||||
newfun.__name__ = f"{self.f.__name__}.{decorated_fun.__name__}({arglist})"
|
|
||||||
return type(self)(f=newfun, name=newfun.__name__)
|
|
||||||
|
|
||||||
return wrapper
|
|
||||||
|
|
||||||
def __repr__(self):
|
|
||||||
return self.name
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return self.name
|
|
||||||
|
|
||||||
@functools.cached_property
|
|
||||||
def is_ascending(self):
|
|
||||||
return np.all(np.diff(self.f(np.linspace(0, 1, 100))) >= 0)
|
|
||||||
|
|
||||||
@functools.cached_property
|
|
||||||
def is_symmetric(self):
|
|
||||||
t = np.linspace(0, 0.5, 100)
|
|
||||||
return np.allclose(self.f(t), self.f(1 - t))
|
|
||||||
|
|
||||||
@property
|
|
||||||
@modifier
|
|
||||||
def reverse(self):
|
|
||||||
"""
|
|
||||||
Revert the function so it goes the other way round (starts at the end)
|
|
||||||
"""
|
|
||||||
return lambda t: self.f(1 - t)
|
|
||||||
|
|
||||||
@property
|
|
||||||
@modifier
|
|
||||||
def symmetric(self):
|
|
||||||
"""
|
|
||||||
Mirror and squash function to make it symmetric
|
|
||||||
"""
|
|
||||||
return lambda t: self.f(-2 * (np.abs(t - 0.5) - 0.5))
|
|
||||||
|
|
||||||
@modifier
|
|
||||||
def mirror(self, x=None, y=None, copy=False):
|
|
||||||
"""
|
|
||||||
Mirror function around an x and/or y value.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
x (float): x value to mirror around
|
|
||||||
y (float): y value to mirror around
|
|
||||||
copy (bool): when mirroring around x, do copy-mirror
|
|
||||||
"""
|
|
||||||
if (x, y) == (None, None):
|
|
||||||
x = 0.5
|
|
||||||
|
|
||||||
def mirrored(t):
|
|
||||||
if x is not None:
|
|
||||||
t = 2 * x - t
|
|
||||||
if copy:
|
|
||||||
t = np.abs(-t)
|
|
||||||
if y is None:
|
|
||||||
return self.f(t)
|
|
||||||
else:
|
|
||||||
return y - self.f(t)
|
|
||||||
|
|
||||||
return mirrored
|
|
||||||
|
|
||||||
@modifier
|
|
||||||
def clip(self, min=None, max=None):
|
|
||||||
"""
|
|
||||||
Clip function at low and/or high values
|
|
||||||
"""
|
|
||||||
if min is None and max is None:
|
|
||||||
min = 0
|
|
||||||
max = 1
|
|
||||||
return lambda t: np.clip(self.f(t), min, max)
|
|
||||||
|
|
||||||
@modifier
|
|
||||||
def clip_input(self, min=None, max=None):
|
|
||||||
"""
|
|
||||||
Clip input parameter, i.e. extrapolate constantly outside the interval.
|
|
||||||
"""
|
|
||||||
if min is None and max is None:
|
|
||||||
min = 0
|
|
||||||
max = 1
|
|
||||||
return lambda t: self.f(np.clip(t, min, max))
|
|
||||||
|
|
||||||
@property
|
|
||||||
@modifier
|
|
||||||
def clipped(self):
|
|
||||||
"""
|
|
||||||
Clipped parameter and result to [0;1]
|
|
||||||
"""
|
|
||||||
return lambda t: np.clip(self(np.clip(t, 0, 1)), 0, 1)
|
|
||||||
|
|
||||||
@modifier
|
|
||||||
def append(self, other, e=None):
|
|
||||||
"""
|
|
||||||
Append another easing function and squish both into the [0;1] interval
|
|
||||||
"""
|
|
||||||
if e is None:
|
|
||||||
e = in_out_square
|
|
||||||
|
|
||||||
def f(t):
|
|
||||||
mix = e(t)
|
|
||||||
return self.f(t * 2) * (1 - mix) + other((t - 0.5) * 2) * mix
|
|
||||||
|
|
||||||
return f
|
|
||||||
|
|
||||||
@modifier
|
|
||||||
def prepend(self, other, e=None):
|
|
||||||
"""
|
|
||||||
Prepend another easing function and squish both into the [0;1] interval
|
|
||||||
"""
|
|
||||||
if e is None:
|
|
||||||
e = in_out_square
|
|
||||||
|
|
||||||
def f(t):
|
|
||||||
mix = e(t)
|
|
||||||
return other(t * 2) * (1 - mix) + self.f((t - 0.5) * 2) * mix
|
|
||||||
|
|
||||||
return f
|
|
||||||
|
|
||||||
@modifier
|
|
||||||
def shift(self, offset):
|
|
||||||
"""
|
|
||||||
Shift function on x-axis into positive direction by ``offset``.
|
|
||||||
"""
|
|
||||||
return lambda t: self.f(t - offset)
|
|
||||||
|
|
||||||
@modifier
|
|
||||||
def repeat(self, n=2):
|
|
||||||
"""
|
|
||||||
Repeat the function a total of n times in the interval [0;1].
|
|
||||||
"""
|
|
||||||
return lambda t: self.f(t % (1 / n) * n)
|
|
||||||
|
|
||||||
@modifier
|
|
||||||
def multiply(self, factor):
|
|
||||||
"""
|
|
||||||
Scale function by ``factor``
|
|
||||||
"""
|
|
||||||
if isinstance(factor, Easing):
|
|
||||||
return lambda t: self(t) * factor(t)
|
|
||||||
else:
|
|
||||||
return lambda t: factor * self.f(t)
|
|
||||||
|
|
||||||
@modifier
|
|
||||||
def add(self, offset):
|
|
||||||
"""
|
|
||||||
Add ``offset`` to function
|
|
||||||
"""
|
|
||||||
if isinstance(offset, Easing):
|
|
||||||
return lambda t: self(t) + offset(t)
|
|
||||||
else:
|
|
||||||
return lambda t: self.f(t) + offset
|
|
||||||
|
|
||||||
def __add__(self, offset):
|
|
||||||
return self.add(offset)
|
|
||||||
|
|
||||||
def __radd__(self, offset):
|
|
||||||
return self.add(offset)
|
|
||||||
|
|
||||||
def __sub__(self, offset):
|
|
||||||
return self.add(-offset)
|
|
||||||
|
|
||||||
def __rsub__(self, offset):
|
|
||||||
return self.add(-offset)
|
|
||||||
|
|
||||||
def __mul__(self, factor):
|
|
||||||
return self.multiply(factor)
|
|
||||||
|
|
||||||
def __rmul__(self, factor):
|
|
||||||
return self.multiply(factor)
|
|
||||||
|
|
||||||
def __neg__(self):
|
|
||||||
return self.multiply(-1)
|
|
||||||
|
|
||||||
def __truediv__(self, factor):
|
|
||||||
return self.multiply(1 / factor)
|
|
||||||
|
|
||||||
def __or__(self, other):
|
|
||||||
return self.transition(other)
|
|
||||||
|
|
||||||
def __rshift__(self, offset):
|
|
||||||
return self.shift(offset)
|
|
||||||
|
|
||||||
def __lshift__(self, offset):
|
|
||||||
return self.shift(-offset)
|
|
||||||
|
|
||||||
def __getitem__(self, index):
|
|
||||||
if isinstance(index, Easing):
|
|
||||||
return self.chain(index)
|
|
||||||
if isinstance(index, slice):
|
|
||||||
return self.zoom(
|
|
||||||
0 if index.start is None else index.start,
|
|
||||||
1 if index.stop is None else index.stop,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
raise ValueError(
|
|
||||||
f"{index = } has to be slice of floats or an easing function"
|
|
||||||
)
|
|
||||||
|
|
||||||
@modifier
|
|
||||||
def chain(self, f=None):
|
|
||||||
"""
|
|
||||||
Feed parameter through the given function before evaluating this function.
|
|
||||||
"""
|
|
||||||
if f is None:
|
|
||||||
f = self.f
|
|
||||||
return lambda t: self.f(f(t))
|
|
||||||
|
|
||||||
@modifier
|
|
||||||
def zoom(self, left, right=None):
|
|
||||||
"""
|
|
||||||
Arrange so that the interval [left;right] is moved into [0;1]
|
|
||||||
If only one argument is given, zoom in/out by moving edges that far.
|
|
||||||
"""
|
|
||||||
if left is not None and right is None:
|
|
||||||
if left >= 0.5:
|
|
||||||
raise ValueError(
|
|
||||||
f"{left = } is > 0.5 which doesn't make sense (bounds would cross)"
|
|
||||||
)
|
|
||||||
left = left
|
|
||||||
right = 1 - left
|
|
||||||
if left >= right:
|
|
||||||
raise ValueError(f"{right = } bound must be greater than {left = }")
|
|
||||||
return self.chain(linear.between(left, right)).f
|
|
||||||
|
|
||||||
@modifier
|
|
||||||
def between(self, left=0, right=1, e=None):
|
|
||||||
"""
|
|
||||||
Arrange so ``f(0)==a`` and ``f(1)==b``.
|
|
||||||
"""
|
|
||||||
f0, f1 = self.f(np.array([0, 1]))
|
|
||||||
la = f0 - left
|
|
||||||
lb = f1 - right
|
|
||||||
if e is None: # linear is defined later
|
|
||||||
e = (
|
|
||||||
self # use ourself as transition when we're ascending within [0;1]
|
|
||||||
if (self.is_ascending and np.allclose(self.f(np.array([0, 1])), [0, 1]))
|
|
||||||
else linear
|
|
||||||
)
|
|
||||||
|
|
||||||
def f(t):
|
|
||||||
t_ = e(t)
|
|
||||||
return self.f(t_) - (la * (1 - t_)) - lb * t_
|
|
||||||
|
|
||||||
return f
|
|
||||||
|
|
||||||
@modifier
|
|
||||||
def transition(self, other, e=None):
|
|
||||||
"""
|
|
||||||
Transiton from one easing to another
|
|
||||||
"""
|
|
||||||
if e is None:
|
|
||||||
e = linear
|
|
||||||
|
|
||||||
def f(t):
|
|
||||||
t_ = e(t)
|
|
||||||
return self.f(t) * (1 - t_) + other(t) * t_
|
|
||||||
|
|
||||||
return f
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def function(cls, decorated_fun):
|
|
||||||
return cls(f=decorated_fun, name=decorated_fun.__name__)
|
|
||||||
|
|
||||||
def plot(self, *others, xlim=(0, 1), ax=None):
|
|
||||||
import matplotlib.pyplot as plt # lazy import for speed
|
|
||||||
from cycler import cycler
|
|
||||||
|
|
||||||
if ax is None:
|
|
||||||
fig, ax_ = plt.subplots()
|
|
||||||
else:
|
|
||||||
ax_ = ax
|
|
||||||
|
|
||||||
try:
|
|
||||||
ax_.set_prop_cycle(
|
|
||||||
cycler(linestyle=["solid", "dashed", "dotted"], linewidth=[1, 1, 2])
|
|
||||||
* plt.rcParams["axes.prop_cycle"]
|
|
||||||
)
|
|
||||||
except ValueError as e:
|
|
||||||
pass
|
|
||||||
|
|
||||||
t = np.linspace(*xlim, 1000)
|
|
||||||
funs = list(others or [])
|
|
||||||
if isinstance(self, Easing):
|
|
||||||
funs.insert(0, self)
|
|
||||||
for f in funs:
|
|
||||||
ax_.plot(t, f(t), label=getattr(f, "name", getattr(f, "__name__", str(f))))
|
|
||||||
ax_.legend(ncol=int(np.ceil(len(ax_.get_lines()) / 10)))
|
|
||||||
if ax is None:
|
|
||||||
plt.show()
|
|
||||||
return ax_
|
|
||||||
|
|
||||||
@functools.cached_property
|
|
||||||
def min(self):
|
|
||||||
v = self.f(t := np.linspace(0, 1, 1000))
|
|
||||||
approxmin = Extremum(pos=t[i := np.argmin(v)], value=v[i])
|
|
||||||
opt = scipy.optimize.minimize(self, x0=[approxmin.pos], bounds=[(0, 1)])
|
|
||||||
optmin = Extremum(pos=opt.x[0], value=opt.fun)
|
|
||||||
return min(approxmin, optmin)
|
|
||||||
|
|
||||||
@functools.cached_property
|
|
||||||
def max(self):
|
|
||||||
"""
|
|
||||||
Determine the maximum value
|
|
||||||
"""
|
|
||||||
v = self.f(t := np.linspace(0, 1, 1000))
|
|
||||||
approxmax = Extremum(pos=t[i := np.argmax(v)], value=v[i])
|
|
||||||
opt = scipy.optimize.minimize(-self, x0=[approxmax.pos], bounds=[(0, 1)])
|
|
||||||
optmax = Extremum(pos=opt.x[0], value=-opt.fun)
|
|
||||||
return max(approxmax, optmax)
|
|
||||||
|
|
||||||
@functools.cached_property
|
|
||||||
def mean(self):
|
|
||||||
return np.mean(self.f(np.linspace(0, 1, 1000)))
|
|
||||||
|
|
||||||
def __lt__(self, e):
|
|
||||||
return np.all(self.f(t := np.linspace(0, 1, 50)) < e.f(t))
|
|
||||||
|
|
||||||
def __eq__(self, e):
|
|
||||||
return np.allclose(self.f(t := np.linspace(0, 1, 50)), e.f(t))
|
|
||||||
|
|
||||||
def __call__(self, t):
|
|
||||||
return self.f(t)
|
|
||||||
|
|
||||||
|
|
||||||
@Easing.function
|
|
||||||
def linear(t):
|
|
||||||
return t
|
|
||||||
|
|
||||||
|
|
||||||
@Easing.function
|
|
||||||
def in_quad(t):
|
|
||||||
return t * t
|
|
||||||
|
|
||||||
|
|
||||||
@Easing.function
|
|
||||||
def out_quad(t):
|
|
||||||
return -t * (t - 2)
|
|
||||||
|
|
||||||
|
|
||||||
@Easing.function
|
|
||||||
def in_out_quad(t):
|
|
||||||
u = 2 * t - 1
|
|
||||||
a = 2 * t * t
|
|
||||||
b = -0.5 * (u * (u - 2) - 1)
|
|
||||||
return np.where(t < 0.5, a, b)
|
|
||||||
|
|
||||||
|
|
||||||
@Easing.function
|
|
||||||
def in_cubic(t):
|
|
||||||
return t * t * t
|
|
||||||
|
|
||||||
|
|
||||||
@Easing.function
|
|
||||||
def out_cubic(t):
|
|
||||||
u = t - 1
|
|
||||||
return u * u * u + 1
|
|
||||||
|
|
||||||
|
|
||||||
@Easing.function
|
|
||||||
def in_out_cubic(t):
|
|
||||||
u = t * 2
|
|
||||||
v = u - 2
|
|
||||||
a = 0.5 * u * u * u
|
|
||||||
b = 0.5 * (v * v * v + 2)
|
|
||||||
return np.where(u < 1, a, b)
|
|
||||||
|
|
||||||
|
|
||||||
@Easing.function
|
|
||||||
def in_quart(t):
|
|
||||||
return t * t * t * t
|
|
||||||
|
|
||||||
|
|
||||||
@Easing.function
|
|
||||||
def out_quart(t):
|
|
||||||
u = t - 1
|
|
||||||
return -(u * u * u * u - 1)
|
|
||||||
|
|
||||||
|
|
||||||
@Easing.function
|
|
||||||
def in_out_quart(t):
|
|
||||||
u = t * 2
|
|
||||||
v = u - 2
|
|
||||||
a = 0.5 * u * u * u * u
|
|
||||||
b = -0.5 * (v * v * v * v - 2)
|
|
||||||
return np.where(u < 1, a, b)
|
|
||||||
|
|
||||||
|
|
||||||
@Easing.function
|
|
||||||
def in_quint(t):
|
|
||||||
return t * t * t * t * t
|
|
||||||
|
|
||||||
|
|
||||||
@Easing.function
|
|
||||||
def out_quint(t):
|
|
||||||
u = t - 1
|
|
||||||
return u * u * u * u * u + 1
|
|
||||||
|
|
||||||
|
|
||||||
@Easing.function
|
|
||||||
def in_out_quint(t):
|
|
||||||
u = t * 2
|
|
||||||
v = u - 2
|
|
||||||
a = 0.5 * u * u * u * u * u
|
|
||||||
b = 0.5 * (v * v * v * v * v + 2)
|
|
||||||
return np.where(u < 1, a, b)
|
|
||||||
|
|
||||||
|
|
||||||
@Easing.function
|
|
||||||
def in_sine(t):
|
|
||||||
return -np.cos(t * np.pi / 2) + 1
|
|
||||||
|
|
||||||
|
|
||||||
@Easing.function
|
|
||||||
def out_sine(t):
|
|
||||||
return np.sin(t * np.pi / 2)
|
|
||||||
|
|
||||||
|
|
||||||
@Easing.function
|
|
||||||
def in_out_sine(t):
|
|
||||||
return -0.5 * (np.cos(np.pi * t) - 1)
|
|
||||||
|
|
||||||
|
|
||||||
@Easing.function
|
|
||||||
def in_expo(t):
|
|
||||||
a = np.zeros(len(t))
|
|
||||||
b = 2 ** (10 * (t - 1))
|
|
||||||
return np.where(t == 0, a, b)
|
|
||||||
|
|
||||||
|
|
||||||
@Easing.function
|
|
||||||
def out_expo(t):
|
|
||||||
a = np.zeros(len(t)) + 1
|
|
||||||
b = 1 - 2 ** (-10 * t)
|
|
||||||
return np.where(t == 1, a, b)
|
|
||||||
|
|
||||||
|
|
||||||
@Easing.function
|
|
||||||
def in_out_expo(t):
|
|
||||||
zero = np.zeros(len(t))
|
|
||||||
one = zero + 1
|
|
||||||
a = 0.5 * 2 ** (20 * t - 10)
|
|
||||||
b = 1 - 0.5 * 2 ** (-20 * t + 10)
|
|
||||||
return np.where(t == 0, zero, np.where(t == 1, one, np.where(t < 0.5, a, b)))
|
|
||||||
|
|
||||||
|
|
||||||
@Easing.function
|
|
||||||
def in_circ(t):
|
|
||||||
return -1 * (np.sqrt(1 - t * t) - 1)
|
|
||||||
|
|
||||||
|
|
||||||
@Easing.function
|
|
||||||
def out_circ(t):
|
|
||||||
u = t - 1
|
|
||||||
return np.sqrt(1 - u * u)
|
|
||||||
|
|
||||||
|
|
||||||
@Easing.function
|
|
||||||
def in_out_circ(t):
|
|
||||||
u = t * 2
|
|
||||||
v = u - 2
|
|
||||||
a = -0.5 * (np.sqrt(1 - u * u) - 1)
|
|
||||||
b = 0.5 * (np.sqrt(1 - v * v) + 1)
|
|
||||||
return np.where(u < 1, a, b)
|
|
||||||
|
|
||||||
|
|
||||||
@Easing.function
|
|
||||||
def in_elastic(t, k=0.5):
|
|
||||||
u = t - 1
|
|
||||||
return -1 * (2 ** (10.0 * u) * np.sin((u - k / 4) * (2 * np.pi) / k))
|
|
||||||
|
|
||||||
|
|
||||||
@Easing.function
|
|
||||||
def out_elastic(t, k=0.5):
|
|
||||||
return 2 ** (-10.0 * t) * np.sin((t - k / 4) * (2 * np.pi / k)) + 1
|
|
||||||
|
|
||||||
|
|
||||||
@Easing.function
|
|
||||||
def in_out_elastic(t, k=0.5):
|
|
||||||
u = t * 2
|
|
||||||
v = u - 1
|
|
||||||
a = -0.5 * (2 ** (10 * v) * np.sin((v - k / 4) * 2 * np.pi / k))
|
|
||||||
b = 2 ** (-10 * v) * np.sin((v - k / 4) * 2 * np.pi / k) * 0.5 + 1
|
|
||||||
return np.where(u < 1, a, b)
|
|
||||||
|
|
||||||
|
|
||||||
@Easing.function
|
|
||||||
def in_back(t):
|
|
||||||
k = 1.70158
|
|
||||||
return t * t * ((k + 1) * t - k)
|
|
||||||
|
|
||||||
|
|
||||||
@Easing.function
|
|
||||||
def out_back(t):
|
|
||||||
k = 1.70158
|
|
||||||
u = t - 1
|
|
||||||
return u * u * ((k + 1) * u + k) + 1
|
|
||||||
|
|
||||||
|
|
||||||
@Easing.function
|
|
||||||
def in_out_back(t):
|
|
||||||
k = 1.70158 * 1.525
|
|
||||||
u = t * 2
|
|
||||||
v = u - 2
|
|
||||||
a = 0.5 * (u * u * ((k + 1) * u - k))
|
|
||||||
b = 0.5 * (v * v * ((k + 1) * v + k) + 2)
|
|
||||||
return np.where(u < 1, a, b)
|
|
||||||
|
|
||||||
|
|
||||||
@Easing.function
|
|
||||||
def in_bounce(t):
|
|
||||||
return 1 - out_bounce(1 - t)
|
|
||||||
|
|
||||||
|
|
||||||
@Easing.function
|
|
||||||
def out_bounce(t):
|
|
||||||
a = (121 * t * t) / 16
|
|
||||||
b = (363 / 40 * t * t) - (99 / 10 * t) + 17 / 5
|
|
||||||
c = (4356 / 361 * t * t) - (35442 / 1805 * t) + 16061 / 1805
|
|
||||||
d = (54 / 5 * t * t) - (513 / 25 * t) + 268 / 25
|
|
||||||
return np.where(t < 4 / 11, a, np.where(t < 8 / 11, b, np.where(t < 9 / 10, c, d)))
|
|
||||||
|
|
||||||
|
|
||||||
@Easing.function
|
|
||||||
def in_out_bounce(t):
|
|
||||||
a = in_bounce(2 * t) * 0.5
|
|
||||||
b = out_bounce(2 * t - 1) * 0.5 + 0.5
|
|
||||||
return np.where(t < 0.5, a, b)
|
|
||||||
|
|
||||||
|
|
||||||
@Easing.function
|
|
||||||
def in_square(t):
|
|
||||||
return np.heaviside(t - 1, 0)
|
|
||||||
|
|
||||||
|
|
||||||
@Easing.function
|
|
||||||
def out_square(t):
|
|
||||||
return np.heaviside(t + 1, 0)
|
|
||||||
|
|
||||||
|
|
||||||
@Easing.function
|
|
||||||
def in_out_square(t):
|
|
||||||
return np.heaviside(t - 0.5, 0)
|
|
||||||
|
|
||||||
|
|
||||||
def constant(x):
|
|
||||||
return Easing(f=lambda t: np.full_like(t, x), name=f"constant({x})")
|
|
||||||
|
|
||||||
|
|
||||||
zero = constant(0)
|
|
||||||
one = constant(1)
|
|
||||||
|
|
||||||
|
|
||||||
@Easing.function
|
|
||||||
def smoothstep(t):
|
|
||||||
t = np.clip(t, 0, 1)
|
|
||||||
return 3 * t * t - 2 * t * t * t
|
|
||||||
|
|
||||||
|
|
||||||
def _main():
|
|
||||||
import matplotlib.pyplot as plt
|
|
||||||
from cycler import cycler
|
|
||||||
|
|
||||||
plt.rcParams["axes.prop_cycle"] *= cycler(
|
|
||||||
linestyle=["solid", "dashed", "dotted"], linewidth=[1, 2, 3]
|
|
||||||
)
|
|
||||||
plt.rcParams["figure.autolayout"] = True
|
|
||||||
plt.rcParams["axes.grid"] = True
|
|
||||||
plt.rcParams["axes.axisbelow"] = True
|
|
||||||
plt.rcParams["legend.fontsize"] = "small"
|
|
||||||
LOCALS = globals()
|
|
||||||
print(f"{LOCALS = }")
|
|
||||||
fig, axes = plt.subplots(nrows=2)
|
|
||||||
Easing.plot(
|
|
||||||
*sorted((obj for n, obj in LOCALS.items() if isinstance(obj, Easing)), key=str),
|
|
||||||
ax=axes[0],
|
|
||||||
)
|
|
||||||
Easing.plot(
|
|
||||||
in_sine.symmetric,
|
|
||||||
in_out_sine.symmetric.multiply(-0.6),
|
|
||||||
linear.symmetric.multiply(-0.7),
|
|
||||||
in_out_sine.multiply(-0.6).symmetric,
|
|
||||||
out_sine.multiply(-0.6).reverse.symmetric.multiply(2),
|
|
||||||
out_bounce.add(-0.5),
|
|
||||||
ax=axes[1],
|
|
||||||
)
|
|
||||||
axes[0].set_title("Standard")
|
|
||||||
axes[1].set_title("Derived")
|
|
||||||
plt.show()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
_main()
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
import warnings
|
|
||||||
import functools
|
|
||||||
|
|
||||||
|
|
||||||
class SDFCADError(Exception):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class SDFCADInfiniteObjectError(Exception):
|
|
||||||
"""
|
|
||||||
Error raised when an infinite object is encountered where not suitable.
|
|
||||||
"""
|
|
||||||
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class SDFCADWarning(Warning):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class SDFCADAlphaQualityWarning(SDFCADWarning):
|
|
||||||
show = True
|
|
||||||
|
|
||||||
|
|
||||||
def alpha_quality(decorated_fun):
|
|
||||||
@functools.wraps(decorated_fun)
|
|
||||||
def wrapper(*args, **kwargs):
|
|
||||||
if SDFCADAlphaQualityWarning.show:
|
|
||||||
warnings.warn(
|
|
||||||
f"{decorated_fun.__name__}() is alpha quality "
|
|
||||||
f"and might give wrong results. Use with care. "
|
|
||||||
f"Hide this warning by setting sdf.errors.SDFCADAlphaQualityWarning.show=False.",
|
|
||||||
SDFCADAlphaQualityWarning,
|
|
||||||
)
|
|
||||||
with warnings.catch_warnings():
|
|
||||||
# Don't reissue nested alpha quality warnings
|
|
||||||
warnings.simplefilter("ignore", SDFCADAlphaQualityWarning)
|
|
||||||
return decorated_fun(*args, **kwargs)
|
|
||||||
else:
|
|
||||||
return decorated_fun(*args, **kwargs)
|
|
||||||
|
|
||||||
return wrapper
|
|
||||||
-282
@@ -1,282 +0,0 @@
|
|||||||
from functools import partial
|
|
||||||
from multiprocessing.pool import ThreadPool
|
|
||||||
from skimage import measure
|
|
||||||
|
|
||||||
import multiprocessing
|
|
||||||
import itertools
|
|
||||||
import numpy as np
|
|
||||||
import time
|
|
||||||
|
|
||||||
from . import progress, stl
|
|
||||||
|
|
||||||
WORKERS = multiprocessing.cpu_count()
|
|
||||||
SAMPLES = 2**18
|
|
||||||
BATCH_SIZE = 32
|
|
||||||
|
|
||||||
|
|
||||||
def _marching_cubes(volume, level=0):
|
|
||||||
verts, faces, _, _ = measure.marching_cubes(volume, level)
|
|
||||||
return verts[faces].reshape((-1, 3))
|
|
||||||
|
|
||||||
|
|
||||||
def _cartesian_product(*arrays):
|
|
||||||
la = len(arrays)
|
|
||||||
dtype = np.result_type(*arrays)
|
|
||||||
arr = np.empty([len(a) for a in arrays] + [la], dtype=dtype)
|
|
||||||
for i, a in enumerate(np.ix_(*arrays)):
|
|
||||||
arr[..., i] = a
|
|
||||||
return arr.reshape(-1, la)
|
|
||||||
|
|
||||||
|
|
||||||
def _skip(sdf, job):
|
|
||||||
X, Y, Z = job
|
|
||||||
x0, x1 = X[0], X[-1]
|
|
||||||
y0, y1 = Y[0], Y[-1]
|
|
||||||
z0, z1 = Z[0], Z[-1]
|
|
||||||
x = (x0 + x1) / 2
|
|
||||||
y = (y0 + y1) / 2
|
|
||||||
z = (z0 + z1) / 2
|
|
||||||
r = abs(sdf(np.array([(x, y, z)])).reshape(-1)[0])
|
|
||||||
d = np.linalg.norm(np.array((x - x0, y - y0, z - z0)))
|
|
||||||
if r <= d:
|
|
||||||
return False
|
|
||||||
corners = np.array(list(itertools.product((x0, x1), (y0, y1), (z0, z1))))
|
|
||||||
values = sdf(corners).reshape(-1)
|
|
||||||
same = np.all(values > 0) if values[0] > 0 else np.all(values < 0)
|
|
||||||
return same
|
|
||||||
|
|
||||||
|
|
||||||
def _worker(sdf, job, sparse):
|
|
||||||
X, Y, Z = job
|
|
||||||
if sparse and _skip(sdf, job):
|
|
||||||
return None
|
|
||||||
# return _debug_triangles(X, Y, Z)
|
|
||||||
P = _cartesian_product(X, Y, Z)
|
|
||||||
volume = sdf(P).reshape((len(X), len(Y), len(Z)))
|
|
||||||
try:
|
|
||||||
points = _marching_cubes(volume)
|
|
||||||
except Exception:
|
|
||||||
return []
|
|
||||||
# return _debug_triangles(X, Y, Z)
|
|
||||||
scale = np.array([X[1] - X[0], Y[1] - Y[0], Z[1] - Z[0]])
|
|
||||||
offset = np.array([X[0], Y[0], Z[0]])
|
|
||||||
return points * scale + offset
|
|
||||||
|
|
||||||
|
|
||||||
def _estimate_bounds(sdf):
|
|
||||||
# TODO: raise exception if bound estimation fails
|
|
||||||
s = 16
|
|
||||||
x0 = y0 = z0 = -1e9
|
|
||||||
x1 = y1 = z1 = 1e9
|
|
||||||
prev = None
|
|
||||||
for i in range(32):
|
|
||||||
X = np.linspace(x0, x1, s)
|
|
||||||
Y = np.linspace(y0, y1, s)
|
|
||||||
Z = np.linspace(z0, z1, s)
|
|
||||||
d = np.array([X[1] - X[0], Y[1] - Y[0], Z[1] - Z[0]])
|
|
||||||
threshold = np.linalg.norm(d) / 2
|
|
||||||
if threshold == prev:
|
|
||||||
break
|
|
||||||
prev = threshold
|
|
||||||
P = _cartesian_product(X, Y, Z)
|
|
||||||
volume = sdf(P).reshape((len(X), len(Y), len(Z)))
|
|
||||||
where = np.argwhere(np.abs(volume) <= threshold)
|
|
||||||
x1, y1, z1 = (x0, y0, z0) + where.max(axis=0) * d + d / 2
|
|
||||||
x0, y0, z0 = (x0, y0, z0) + where.min(axis=0) * d - d / 2
|
|
||||||
return ((x0, y0, z0), (x1, y1, z1))
|
|
||||||
|
|
||||||
|
|
||||||
def generate(
|
|
||||||
sdf,
|
|
||||||
step=None,
|
|
||||||
bounds=None,
|
|
||||||
samples=SAMPLES,
|
|
||||||
workers=WORKERS,
|
|
||||||
batch_size=BATCH_SIZE,
|
|
||||||
verbose=True,
|
|
||||||
sparse=True,
|
|
||||||
):
|
|
||||||
start = time.time()
|
|
||||||
|
|
||||||
if bounds is None:
|
|
||||||
bounds = _estimate_bounds(sdf)
|
|
||||||
(x0, y0, z0), (x1, y1, z1) = bounds
|
|
||||||
|
|
||||||
if step is None and samples is not None:
|
|
||||||
volume = (x1 - x0) * (y1 - y0) * (z1 - z0)
|
|
||||||
step = (volume / samples) ** (1 / 3)
|
|
||||||
|
|
||||||
try:
|
|
||||||
dx, dy, dz = step
|
|
||||||
except TypeError:
|
|
||||||
dx = dy = dz = step
|
|
||||||
|
|
||||||
if verbose:
|
|
||||||
print("min %g, %g, %g" % (x0, y0, z0))
|
|
||||||
print("max %g, %g, %g" % (x1, y1, z1))
|
|
||||||
print("step %g, %g, %g" % (dx, dy, dz))
|
|
||||||
|
|
||||||
X = np.arange(x0, x1, dx)
|
|
||||||
Y = np.arange(y0, y1, dy)
|
|
||||||
Z = np.arange(z0, z1, dz)
|
|
||||||
|
|
||||||
s = batch_size
|
|
||||||
Xs = [X[i : i + s + 1] for i in range(0, len(X), s)]
|
|
||||||
Ys = [Y[i : i + s + 1] for i in range(0, len(Y), s)]
|
|
||||||
Zs = [Z[i : i + s + 1] for i in range(0, len(Z), s)]
|
|
||||||
|
|
||||||
batches = list(itertools.product(Xs, Ys, Zs))
|
|
||||||
num_batches = len(batches)
|
|
||||||
num_samples = sum(len(xs) * len(ys) * len(zs) for xs, ys, zs in batches)
|
|
||||||
|
|
||||||
if verbose:
|
|
||||||
print(
|
|
||||||
"%d samples in %d batches with %d workers"
|
|
||||||
% (num_samples, num_batches, workers)
|
|
||||||
)
|
|
||||||
|
|
||||||
points = []
|
|
||||||
skipped = empty = nonempty = 0
|
|
||||||
bar = progress.Bar(num_batches, enabled=verbose)
|
|
||||||
f = partial(_worker, sdf, sparse=sparse)
|
|
||||||
with ThreadPool(workers) as pool:
|
|
||||||
for result in pool.imap(f, batches):
|
|
||||||
bar.increment(1)
|
|
||||||
if result is None:
|
|
||||||
skipped += 1
|
|
||||||
elif len(result) == 0:
|
|
||||||
empty += 1
|
|
||||||
else:
|
|
||||||
nonempty += 1
|
|
||||||
points.extend(result)
|
|
||||||
bar.done()
|
|
||||||
|
|
||||||
if verbose:
|
|
||||||
print("%d skipped, %d empty, %d nonempty" % (skipped, empty, nonempty))
|
|
||||||
triangles = len(points) // 3
|
|
||||||
seconds = time.time() - start
|
|
||||||
print("%d triangles in %g seconds" % (triangles, seconds))
|
|
||||||
|
|
||||||
return points
|
|
||||||
|
|
||||||
|
|
||||||
def save(path, *args, **kwargs):
|
|
||||||
points = generate(*args, **kwargs)
|
|
||||||
if str(path).lower().endswith(".stl"):
|
|
||||||
stl.write_binary_stl(path, points)
|
|
||||||
else:
|
|
||||||
mesh = _mesh(points)
|
|
||||||
mesh.write(path)
|
|
||||||
|
|
||||||
|
|
||||||
def _mesh(points):
|
|
||||||
import meshio
|
|
||||||
|
|
||||||
points, cells = np.unique(points, axis=0, return_inverse=True)
|
|
||||||
cells = [("triangle", cells.reshape((-1, 3)))]
|
|
||||||
return meshio.Mesh(points, cells)
|
|
||||||
|
|
||||||
|
|
||||||
def _debug_triangles(X, Y, Z):
|
|
||||||
x0, x1 = X[0], X[-1]
|
|
||||||
y0, y1 = Y[0], Y[-1]
|
|
||||||
z0, z1 = Z[0], Z[-1]
|
|
||||||
|
|
||||||
p = 0.25
|
|
||||||
x0, x1 = x0 + (x1 - x0) * p, x1 - (x1 - x0) * p
|
|
||||||
y0, y1 = y0 + (y1 - y0) * p, y1 - (y1 - y0) * p
|
|
||||||
z0, z1 = z0 + (z1 - z0) * p, z1 - (z1 - z0) * p
|
|
||||||
|
|
||||||
v = [
|
|
||||||
(x0, y0, z0),
|
|
||||||
(x0, y0, z1),
|
|
||||||
(x0, y1, z0),
|
|
||||||
(x0, y1, z1),
|
|
||||||
(x1, y0, z0),
|
|
||||||
(x1, y0, z1),
|
|
||||||
(x1, y1, z0),
|
|
||||||
(x1, y1, z1),
|
|
||||||
]
|
|
||||||
|
|
||||||
return [
|
|
||||||
v[3],
|
|
||||||
v[5],
|
|
||||||
v[7],
|
|
||||||
v[5],
|
|
||||||
v[3],
|
|
||||||
v[1],
|
|
||||||
v[0],
|
|
||||||
v[6],
|
|
||||||
v[4],
|
|
||||||
v[6],
|
|
||||||
v[0],
|
|
||||||
v[2],
|
|
||||||
v[0],
|
|
||||||
v[5],
|
|
||||||
v[1],
|
|
||||||
v[5],
|
|
||||||
v[0],
|
|
||||||
v[4],
|
|
||||||
v[5],
|
|
||||||
v[6],
|
|
||||||
v[7],
|
|
||||||
v[6],
|
|
||||||
v[5],
|
|
||||||
v[4],
|
|
||||||
v[6],
|
|
||||||
v[3],
|
|
||||||
v[7],
|
|
||||||
v[3],
|
|
||||||
v[6],
|
|
||||||
v[2],
|
|
||||||
v[0],
|
|
||||||
v[3],
|
|
||||||
v[2],
|
|
||||||
v[3],
|
|
||||||
v[0],
|
|
||||||
v[1],
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def sample_slice(sdf, w=1024, h=1024, x=None, y=None, z=None, bounds=None):
|
|
||||||
if bounds is None:
|
|
||||||
bounds = _estimate_bounds(sdf)
|
|
||||||
(x0, y0, z0), (x1, y1, z1) = bounds
|
|
||||||
|
|
||||||
if x is not None:
|
|
||||||
X = np.array([x])
|
|
||||||
Y = np.linspace(y0, y1, w)
|
|
||||||
Z = np.linspace(z0, z1, h)
|
|
||||||
extent = (Z[0], Z[-1], Y[0], Y[-1])
|
|
||||||
axes = "ZY"
|
|
||||||
elif y is not None:
|
|
||||||
Y = np.array([y])
|
|
||||||
X = np.linspace(x0, x1, w)
|
|
||||||
Z = np.linspace(z0, z1, h)
|
|
||||||
extent = (Z[0], Z[-1], X[0], X[-1])
|
|
||||||
axes = "ZX"
|
|
||||||
elif z is not None:
|
|
||||||
Z = np.array([z])
|
|
||||||
X = np.linspace(x0, x1, w)
|
|
||||||
Y = np.linspace(y0, y1, h)
|
|
||||||
extent = (Y[0], Y[-1], X[0], X[-1])
|
|
||||||
axes = "YX"
|
|
||||||
else:
|
|
||||||
raise Exception("x, y, or z position must be specified")
|
|
||||||
|
|
||||||
P = _cartesian_product(X, Y, Z)
|
|
||||||
return sdf(P).reshape((w, h)), extent, axes
|
|
||||||
|
|
||||||
|
|
||||||
def show_slice(*args, **kwargs):
|
|
||||||
import matplotlib.pyplot as plt
|
|
||||||
|
|
||||||
show_abs = kwargs.pop("abs", False)
|
|
||||||
a, extent, axes = sample_slice(*args, **kwargs)
|
|
||||||
if show_abs:
|
|
||||||
a = np.abs(a)
|
|
||||||
im = plt.imshow(a, extent=extent, origin="lower")
|
|
||||||
plt.xlabel(axes[0])
|
|
||||||
plt.ylabel(axes[1])
|
|
||||||
plt.colorbar(im)
|
|
||||||
plt.show()
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
import sys
|
|
||||||
import time
|
|
||||||
|
|
||||||
|
|
||||||
def pretty_time(seconds):
|
|
||||||
seconds = int(round(seconds))
|
|
||||||
s = seconds % 60
|
|
||||||
m = (seconds // 60) % 60
|
|
||||||
h = seconds // 3600
|
|
||||||
return "%d:%02d:%02d" % (h, m, s)
|
|
||||||
|
|
||||||
|
|
||||||
class Bar(object):
|
|
||||||
def __init__(self, max_value=100, min_value=0, enabled=True):
|
|
||||||
self.min_value = min_value
|
|
||||||
self.max_value = max_value
|
|
||||||
self.value = min_value
|
|
||||||
self.start_time = time.time()
|
|
||||||
self.enabled = enabled
|
|
||||||
|
|
||||||
@property
|
|
||||||
def percent_complete(self):
|
|
||||||
t = (self.value - self.min_value) / (self.max_value - self.min_value)
|
|
||||||
return t * 100
|
|
||||||
|
|
||||||
@property
|
|
||||||
def elapsed_time(self):
|
|
||||||
return time.time() - self.start_time
|
|
||||||
|
|
||||||
@property
|
|
||||||
def eta(self):
|
|
||||||
t = self.percent_complete / 100
|
|
||||||
if t == 0:
|
|
||||||
return 0
|
|
||||||
return (1 - t) * self.elapsed_time / t
|
|
||||||
|
|
||||||
def increment(self, delta):
|
|
||||||
self.update(self.value + delta)
|
|
||||||
|
|
||||||
def update(self, value):
|
|
||||||
self.value = value
|
|
||||||
if self.enabled:
|
|
||||||
sys.stdout.write(" %s \r" % self.render())
|
|
||||||
sys.stdout.flush()
|
|
||||||
|
|
||||||
def done(self):
|
|
||||||
self.update(self.max_value)
|
|
||||||
self.stop()
|
|
||||||
|
|
||||||
def stop(self):
|
|
||||||
if self.enabled:
|
|
||||||
sys.stdout.write("\n")
|
|
||||||
sys.stdout.flush()
|
|
||||||
|
|
||||||
def render(self):
|
|
||||||
items = [
|
|
||||||
self.render_percent_complete(),
|
|
||||||
self.render_value(),
|
|
||||||
self.render_bar(),
|
|
||||||
self.render_elapsed_time(),
|
|
||||||
self.render_eta(),
|
|
||||||
]
|
|
||||||
return " ".join(items)
|
|
||||||
|
|
||||||
def render_percent_complete(self):
|
|
||||||
return "%3.0f%%" % self.percent_complete
|
|
||||||
|
|
||||||
def render_value(self):
|
|
||||||
if self.min_value == 0:
|
|
||||||
return "(%g of %g)" % (self.value, self.max_value)
|
|
||||||
else:
|
|
||||||
return "(%g)" % (self.value)
|
|
||||||
|
|
||||||
def render_bar(self, size=30):
|
|
||||||
a = int(round(self.percent_complete / 100.0 * size))
|
|
||||||
b = size - a
|
|
||||||
return "[" + "#" * a + "-" * b + "]"
|
|
||||||
|
|
||||||
def render_elapsed_time(self):
|
|
||||||
return pretty_time(self.elapsed_time)
|
|
||||||
|
|
||||||
def render_eta(self):
|
|
||||||
return pretty_time(self.eta)
|
|
||||||
-27
@@ -1,27 +0,0 @@
|
|||||||
import numpy as np
|
|
||||||
import struct
|
|
||||||
|
|
||||||
|
|
||||||
def write_binary_stl(path, points):
|
|
||||||
n = len(points) // 3
|
|
||||||
|
|
||||||
points = np.array(points, dtype="float32").reshape((-1, 3, 3))
|
|
||||||
normals = np.cross(points[:, 1] - points[:, 0], points[:, 2] - points[:, 0])
|
|
||||||
normals /= np.linalg.norm(normals, axis=1).reshape((-1, 1))
|
|
||||||
|
|
||||||
dtype = np.dtype(
|
|
||||||
[
|
|
||||||
("normal", ("<f", 3)),
|
|
||||||
("points", ("<f", (3, 3))),
|
|
||||||
("attr", "<H"),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
a = np.zeros(n, dtype=dtype)
|
|
||||||
a["points"] = points
|
|
||||||
a["normal"] = normals
|
|
||||||
|
|
||||||
with open(path, "wb") as fp:
|
|
||||||
fp.write(b"\x00" * 80)
|
|
||||||
fp.write(struct.pack("<I", n))
|
|
||||||
fp.write(a.tobytes())
|
|
||||||
-160
@@ -1,160 +0,0 @@
|
|||||||
from PIL import Image, ImageFont, ImageDraw
|
|
||||||
import scipy.ndimage as nd
|
|
||||||
import numpy as np
|
|
||||||
|
|
||||||
from . import d2
|
|
||||||
|
|
||||||
# TODO: add support for newlines?
|
|
||||||
|
|
||||||
PIXELS = 2**22
|
|
||||||
|
|
||||||
|
|
||||||
def _load_image(thing):
|
|
||||||
if isinstance(thing, str):
|
|
||||||
return Image.open(thing)
|
|
||||||
elif isinstance(thing, (np.ndarray, np.generic)):
|
|
||||||
return Image.fromarray(thing)
|
|
||||||
return Image.fromarray(np.array(thing))
|
|
||||||
|
|
||||||
|
|
||||||
def measure_text(name, text, width=None, height=None):
|
|
||||||
font = ImageFont.truetype(name, 96)
|
|
||||||
x0, y0, x1, y1 = font.getbbox(text)
|
|
||||||
aspect = (x1 - x0) / (y1 - y0)
|
|
||||||
if width is None and height is None:
|
|
||||||
height = 1
|
|
||||||
if width is None:
|
|
||||||
width = height * aspect
|
|
||||||
if height is None:
|
|
||||||
height = width / aspect
|
|
||||||
return (width, height)
|
|
||||||
|
|
||||||
|
|
||||||
def measure_image(thing, width=None, height=None):
|
|
||||||
im = _load_image(thing)
|
|
||||||
w, h = im.size
|
|
||||||
aspect = w / h
|
|
||||||
if width is None and height is None:
|
|
||||||
height = 1
|
|
||||||
if width is None:
|
|
||||||
width = height * aspect
|
|
||||||
if height is None:
|
|
||||||
height = width / aspect
|
|
||||||
return (width, height)
|
|
||||||
|
|
||||||
|
|
||||||
@d2.sdf2
|
|
||||||
def text(font_name, text, width=None, height=None, pixels=PIXELS, points=512):
|
|
||||||
# load font file
|
|
||||||
font = ImageFont.truetype(font_name, points)
|
|
||||||
|
|
||||||
# compute texture bounds
|
|
||||||
p = 0.2
|
|
||||||
x0, y0, x1, y1 = font.getbbox(text)
|
|
||||||
px = int((x1 - x0) * p)
|
|
||||||
py = int((y1 - y0) * p)
|
|
||||||
tw = x1 - x0 + 1 + px * 2
|
|
||||||
th = y1 - y0 + 1 + py * 2
|
|
||||||
|
|
||||||
# render text to image
|
|
||||||
im = Image.new("L", (tw, th))
|
|
||||||
draw = ImageDraw.Draw(im)
|
|
||||||
draw.text((px - x0, py - y0), text, font=font, fill=255)
|
|
||||||
|
|
||||||
return _sdf(width, height, pixels, px, py, im)
|
|
||||||
|
|
||||||
|
|
||||||
@d2.sdf2
|
|
||||||
def image(thing, width=None, height=None, pixels=PIXELS):
|
|
||||||
im = _load_image(thing).convert("L")
|
|
||||||
return _sdf(width, height, pixels, 0, 0, im)
|
|
||||||
|
|
||||||
|
|
||||||
def _sdf(width, height, pixels, px, py, im):
|
|
||||||
tw, th = im.size
|
|
||||||
|
|
||||||
# downscale image if necessary
|
|
||||||
factor = (pixels / (tw * th)) ** 0.5
|
|
||||||
if factor < 1:
|
|
||||||
tw, th = int(round(tw * factor)), int(round(th * factor))
|
|
||||||
px, py = int(round(px * factor)), int(round(py * factor))
|
|
||||||
im = im.resize((tw, th))
|
|
||||||
|
|
||||||
# convert to numpy array and apply distance transform
|
|
||||||
im = im.convert("1")
|
|
||||||
a = np.array(im)
|
|
||||||
inside = -nd.distance_transform_edt(a)
|
|
||||||
outside = nd.distance_transform_edt(~a)
|
|
||||||
texture = np.zeros(a.shape)
|
|
||||||
texture[a] = inside[a]
|
|
||||||
texture[~a] = outside[~a]
|
|
||||||
|
|
||||||
# save debug image
|
|
||||||
# a = np.abs(texture)
|
|
||||||
# lo, hi = a.min(), a.max()
|
|
||||||
# a = (a - lo) / (hi - lo) * 255
|
|
||||||
# im = Image.fromarray(a.astype('uint8'))
|
|
||||||
# im.save('debug.png')
|
|
||||||
|
|
||||||
# compute world bounds
|
|
||||||
pw = tw - px * 2
|
|
||||||
ph = th - py * 2
|
|
||||||
aspect = pw / ph
|
|
||||||
if width is None and height is None:
|
|
||||||
height = 1
|
|
||||||
if width is None:
|
|
||||||
width = height * aspect
|
|
||||||
if height is None:
|
|
||||||
height = width / aspect
|
|
||||||
x0 = -width / 2
|
|
||||||
y0 = -height / 2
|
|
||||||
x1 = width / 2
|
|
||||||
y1 = height / 2
|
|
||||||
|
|
||||||
# scale texture distances
|
|
||||||
scale = width / tw
|
|
||||||
texture *= scale
|
|
||||||
|
|
||||||
# prepare fallback rectangle
|
|
||||||
# TODO: reduce size based on mesh resolution instead of dividing by 2
|
|
||||||
rectangle = d2.rectangle((width / 2, height / 2))
|
|
||||||
|
|
||||||
def f(p):
|
|
||||||
x = p[:, 0]
|
|
||||||
y = p[:, 1]
|
|
||||||
u = (x - x0) / (x1 - x0)
|
|
||||||
v = (y - y0) / (y1 - y0)
|
|
||||||
v = 1 - v
|
|
||||||
i = u * pw + px
|
|
||||||
j = v * ph + py
|
|
||||||
d = _bilinear_interpolate(texture, i, j)
|
|
||||||
q = rectangle(p).reshape(-1)
|
|
||||||
outside = (i < 0) | (i >= tw - 1) | (j < 0) | (j >= th - 1)
|
|
||||||
d[outside] = q[outside]
|
|
||||||
return d
|
|
||||||
|
|
||||||
return f
|
|
||||||
|
|
||||||
|
|
||||||
def _bilinear_interpolate(a, x, y):
|
|
||||||
x0 = np.floor(x).astype(int)
|
|
||||||
x1 = x0 + 1
|
|
||||||
y0 = np.floor(y).astype(int)
|
|
||||||
y1 = y0 + 1
|
|
||||||
|
|
||||||
x0 = np.clip(x0, 0, a.shape[1] - 1)
|
|
||||||
x1 = np.clip(x1, 0, a.shape[1] - 1)
|
|
||||||
y0 = np.clip(y0, 0, a.shape[0] - 1)
|
|
||||||
y1 = np.clip(y1, 0, a.shape[0] - 1)
|
|
||||||
|
|
||||||
pa = a[y0, x0]
|
|
||||||
pb = a[y1, x0]
|
|
||||||
pc = a[y0, x1]
|
|
||||||
pd = a[y1, x1]
|
|
||||||
|
|
||||||
wa = (x1 - x) * (y1 - y)
|
|
||||||
wb = (x1 - x) * (y - y0)
|
|
||||||
wc = (x - x0) * (y1 - y)
|
|
||||||
wd = (x - x0) * (y - y0)
|
|
||||||
|
|
||||||
return wa * pa + wb * pb + wc * pc + wd * pd
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import pint
|
|
||||||
|
|
||||||
units = pint.UnitRegistry()
|
|
||||||
-32
@@ -1,32 +0,0 @@
|
|||||||
import math
|
|
||||||
import functools
|
|
||||||
import inspect
|
|
||||||
import numpy as np
|
|
||||||
|
|
||||||
pi = math.pi
|
|
||||||
|
|
||||||
degrees = math.degrees
|
|
||||||
radians = math.radians
|
|
||||||
|
|
||||||
|
|
||||||
def n_trailing_ascending_positive(d):
|
|
||||||
"""
|
|
||||||
Determine how many elements in a given sequence are positive and ascending.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
d (sequence of numbers): the sequence to check
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
int : the amount of trailing ascending positive elements
|
|
||||||
"""
|
|
||||||
d = np.array(d).flatten()
|
|
||||||
# is the next element larger than previous and positive?
|
|
||||||
order = (d[1:] > d[:-1]) & (d[:-1] > 0)
|
|
||||||
# TODO: Not happy at all with this if/else mess. Is there no easier way to find the
|
|
||||||
# index in a numpy array after which the values are only ascending? 🤔
|
|
||||||
if np.all(order): # all ascending
|
|
||||||
return d.size
|
|
||||||
elif np.all(~order): # none ascending
|
|
||||||
return 0
|
|
||||||
else: # count from end how many are ascending
|
|
||||||
return np.argmin(order[::-1]) + 1
|
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
"""
|
||||||
|
Fluency CAD - Parametric CAD Application
|
||||||
|
|
||||||
|
A modern parametric CAD application built on OpenCASCADE Technology (OCCT)
|
||||||
|
with a clean Python API using OCP (OpenCASCADE Python bindings).
|
||||||
|
"""
|
||||||
|
|
||||||
|
__version__ = "2.0.0"
|
||||||
|
__author__ = "Fluency CAD Team"
|
||||||
|
|
||||||
|
from fluency.geometry.base import (
|
||||||
|
Point2D,
|
||||||
|
Point3D,
|
||||||
|
GeometryObject,
|
||||||
|
GeometryKernel,
|
||||||
|
SketchInterface,
|
||||||
|
)
|
||||||
|
|
||||||
|
from fluency.geometry_occ.kernel import OCGeometryKernel
|
||||||
|
from fluency.geometry_occ.sketch import OCCSketch
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"Point2D",
|
||||||
|
"Point3D",
|
||||||
|
"GeometryObject",
|
||||||
|
"GeometryKernel",
|
||||||
|
"SketchInterface",
|
||||||
|
"OCGeometryKernel",
|
||||||
|
"OCCSketch",
|
||||||
|
]
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
"""Geometry abstraction layer for Fluency CAD."""
|
||||||
|
|
||||||
|
from fluency.geometry.base import (
|
||||||
|
Point2D,
|
||||||
|
Point3D,
|
||||||
|
GeometryObject,
|
||||||
|
GeometryKernel,
|
||||||
|
SketchInterface,
|
||||||
|
SketchEntity,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"Point2D",
|
||||||
|
"Point3D",
|
||||||
|
"GeometryObject",
|
||||||
|
"GeometryKernel",
|
||||||
|
"SketchInterface",
|
||||||
|
"SketchEntity",
|
||||||
|
]
|
||||||
@@ -0,0 +1,442 @@
|
|||||||
|
"""
|
||||||
|
Geometry abstraction layer for Fluency CAD.
|
||||||
|
|
||||||
|
This module defines abstract interfaces for geometry operations,
|
||||||
|
allowing different geometry kernels to be used interchangeably.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import List, Tuple, Optional, Any, Dict
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Point2D:
|
||||||
|
"""2D point representation."""
|
||||||
|
|
||||||
|
x: float
|
||||||
|
y: float
|
||||||
|
|
||||||
|
def to_tuple(self) -> Tuple[float, float]:
|
||||||
|
return (self.x, self.y)
|
||||||
|
|
||||||
|
def to_array(self) -> np.ndarray:
|
||||||
|
return np.array([self.x, self.y])
|
||||||
|
|
||||||
|
def distance_to(self, other: "Point2D") -> float:
|
||||||
|
return np.sqrt((self.x - other.x) ** 2 + (self.y - other.y) ** 2)
|
||||||
|
|
||||||
|
def __eq__(self, other: object) -> bool:
|
||||||
|
if not isinstance(other, Point2D):
|
||||||
|
return False
|
||||||
|
return abs(self.x - other.x) < 1e-6 and abs(self.y - other.y) < 1e-6
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Point3D:
|
||||||
|
"""3D point representation."""
|
||||||
|
|
||||||
|
x: float
|
||||||
|
y: float
|
||||||
|
z: float
|
||||||
|
|
||||||
|
def to_tuple(self) -> Tuple[float, float, float]:
|
||||||
|
return (self.x, self.y, self.z)
|
||||||
|
|
||||||
|
def to_array(self) -> np.ndarray:
|
||||||
|
return np.array([self.x, self.y, self.z])
|
||||||
|
|
||||||
|
def distance_to(self, other: "Point3D") -> float:
|
||||||
|
return np.sqrt((self.x - other.x) ** 2 + (self.y - other.y) ** 2 + (self.z - other.z) ** 2)
|
||||||
|
|
||||||
|
def __eq__(self, other: object) -> bool:
|
||||||
|
if not isinstance(other, Point3D):
|
||||||
|
return False
|
||||||
|
return (
|
||||||
|
abs(self.x - other.x) < 1e-6
|
||||||
|
and abs(self.y - other.y) < 1e-6
|
||||||
|
and abs(self.z - other.z) < 1e-6
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class GeometryObject:
|
||||||
|
"""Base class for geometry objects."""
|
||||||
|
|
||||||
|
def __init__(self, shape: Any = None, metadata: Optional[Dict] = None):
|
||||||
|
self.shape = shape
|
||||||
|
self.metadata = metadata or {}
|
||||||
|
self._mesh_cache: Optional[Tuple[np.ndarray, np.ndarray]] = None
|
||||||
|
|
||||||
|
def invalidate_cache(self) -> None:
|
||||||
|
"""Invalidate any cached data."""
|
||||||
|
self._mesh_cache = None
|
||||||
|
|
||||||
|
|
||||||
|
class SketchEntity:
|
||||||
|
"""Base class for sketch entities (points, lines, circles)."""
|
||||||
|
|
||||||
|
def __init__(self, entity_id: int, entity_type: str):
|
||||||
|
self.id = entity_id
|
||||||
|
self.entity_type = entity_type
|
||||||
|
self.constraints: List[str] = []
|
||||||
|
self.is_construction: bool = False
|
||||||
|
|
||||||
|
def add_constraint(self, constraint_type: str) -> None:
|
||||||
|
self.constraints.append(constraint_type)
|
||||||
|
|
||||||
|
|
||||||
|
class GeometryKernel(ABC):
|
||||||
|
"""
|
||||||
|
Abstract base class for geometry kernels.
|
||||||
|
|
||||||
|
A geometry kernel provides primitives, operations, and export capabilities
|
||||||
|
for CAD geometry.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def create_point(self, x: float, y: float) -> GeometryObject:
|
||||||
|
"""Create a 2D point."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def create_line(self, start: Point2D, end: Point2D) -> GeometryObject:
|
||||||
|
"""Create a 2D line segment."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def create_circle(self, center: Point2D, radius: float) -> GeometryObject:
|
||||||
|
"""Create a 2D circle."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def create_arc(
|
||||||
|
self, center: Point2D, radius: float, start_angle: float, end_angle: float
|
||||||
|
) -> GeometryObject:
|
||||||
|
"""Create a 2D arc."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def create_polygon(self, points: List[Point2D]) -> GeometryObject:
|
||||||
|
"""Create a closed polygon from points."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def create_rectangle(
|
||||||
|
self, width: float, height: float, center: Optional[Point2D] = None
|
||||||
|
) -> GeometryObject:
|
||||||
|
"""Create a rectangle."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def extrude(
|
||||||
|
self,
|
||||||
|
sketch: GeometryObject,
|
||||||
|
height: float,
|
||||||
|
direction: Tuple[float, float, float] = (0, 0, 1),
|
||||||
|
symmetric: bool = False,
|
||||||
|
) -> GeometryObject:
|
||||||
|
"""Extrude a 2D sketch into a 3D solid."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def revolve(
|
||||||
|
self,
|
||||||
|
sketch: GeometryObject,
|
||||||
|
angle: float = 360.0,
|
||||||
|
axis: Tuple[float, float, float] = (0, 0, 1),
|
||||||
|
origin: Tuple[float, float, float] = (0, 0, 0),
|
||||||
|
) -> GeometryObject:
|
||||||
|
"""Revolve a 2D sketch around an axis."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def loft(self, profiles: List[GeometryObject], ruled: bool = False) -> GeometryObject:
|
||||||
|
"""Create a loft between multiple profiles."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def sweep(
|
||||||
|
self, profile: GeometryObject, path: GeometryObject, is_frenet: bool = False
|
||||||
|
) -> GeometryObject:
|
||||||
|
"""Sweep a profile along a path."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def boolean_union(self, *bodies: GeometryObject) -> GeometryObject:
|
||||||
|
"""Union multiple bodies."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def boolean_difference(self, base: GeometryObject, tool: GeometryObject) -> GeometryObject:
|
||||||
|
"""Subtract tool from base."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def boolean_intersection(self, body1: GeometryObject, body2: GeometryObject) -> GeometryObject:
|
||||||
|
"""Intersect two bodies."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def fillet(
|
||||||
|
self, body: GeometryObject, radius: float, edges: Optional[List[Any]] = None
|
||||||
|
) -> GeometryObject:
|
||||||
|
"""Apply fillet to edges."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def chamfer(
|
||||||
|
self, body: GeometryObject, size: float, edges: Optional[List[Any]] = None
|
||||||
|
) -> GeometryObject:
|
||||||
|
"""Apply chamfer to edges."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def shell(
|
||||||
|
self, body: GeometryObject, thickness: float, faces_to_remove: Optional[List[Any]] = None
|
||||||
|
) -> GeometryObject:
|
||||||
|
"""Create a shell (hollow body)."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def offset(self, face: GeometryObject, distance: float) -> GeometryObject:
|
||||||
|
"""Offset a face or surface."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def translate(self, body: GeometryObject, vector: Tuple[float, float, float]) -> GeometryObject:
|
||||||
|
"""Translate a body."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def rotate(
|
||||||
|
self,
|
||||||
|
body: GeometryObject,
|
||||||
|
axis: Tuple[float, float, float],
|
||||||
|
angle: float,
|
||||||
|
origin: Tuple[float, float, float] = (0, 0, 0),
|
||||||
|
) -> GeometryObject:
|
||||||
|
"""Rotate a body around an axis."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def scale(self, body: GeometryObject, factor: float) -> GeometryObject:
|
||||||
|
"""Scale a body uniformly."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def mirror(
|
||||||
|
self,
|
||||||
|
body: GeometryObject,
|
||||||
|
plane_normal: Tuple[float, float, float],
|
||||||
|
plane_origin: Tuple[float, float, float] = (0, 0, 0),
|
||||||
|
) -> GeometryObject:
|
||||||
|
"""Mirror a body across a plane."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def export_step(self, body: GeometryObject, filepath: str, schema: str = "AP214") -> bool:
|
||||||
|
"""Export to STEP format."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def export_iges(self, body: GeometryObject, filepath: str) -> bool:
|
||||||
|
"""Export to IGES format."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def export_stl(
|
||||||
|
self, body: GeometryObject, filepath: str, tolerance: float = 0.1, ascii_mode: bool = False
|
||||||
|
) -> bool:
|
||||||
|
"""Export to STL format."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def import_step(self, filepath: str) -> GeometryObject:
|
||||||
|
"""Import from STEP format."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def import_iges(self, filepath: str) -> GeometryObject:
|
||||||
|
"""Import from IGES format."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def get_mesh(
|
||||||
|
self, body: GeometryObject, tolerance: float = 0.1
|
||||||
|
) -> Tuple[np.ndarray, np.ndarray]:
|
||||||
|
"""
|
||||||
|
Get triangulated mesh for rendering.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (vertices, faces) where:
|
||||||
|
- vertices: Nx3 numpy array of vertex positions
|
||||||
|
- faces: Mx3 numpy array of triangle indices
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def get_edges(self, body: GeometryObject) -> Tuple[np.ndarray, np.ndarray]:
|
||||||
|
"""
|
||||||
|
Get edge wireframe for rendering.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (vertices, edges) where:
|
||||||
|
- vertices: Nx3 numpy array of vertex positions
|
||||||
|
- edges: Mx2 numpy array of edge vertex indices
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def get_bounding_box(self, body: GeometryObject) -> Tuple[Point3D, Point3D]:
|
||||||
|
"""Get the bounding box of a body."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def get_volume(self, body: GeometryObject) -> float:
|
||||||
|
"""Calculate the volume of a solid body."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def get_surface_area(self, body: GeometryObject) -> float:
|
||||||
|
"""Calculate the surface area of a body."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def get_center_of_mass(self, body: GeometryObject) -> Point3D:
|
||||||
|
"""Calculate the center of mass of a solid body."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class SketchInterface(ABC):
|
||||||
|
"""
|
||||||
|
Abstract interface for 2D sketching with constraints.
|
||||||
|
|
||||||
|
A sketch provides 2D geometry creation and constraint solving
|
||||||
|
capabilities for parametric CAD.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def add_point(self, x: float, y: float) -> SketchEntity:
|
||||||
|
"""Add a point to the sketch."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def add_line(self, start: SketchEntity, end: SketchEntity) -> SketchEntity:
|
||||||
|
"""Add a line between two points."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def add_circle(self, center: SketchEntity, radius: float) -> SketchEntity:
|
||||||
|
"""Add a circle."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def add_arc(
|
||||||
|
self,
|
||||||
|
center: SketchEntity,
|
||||||
|
radius: float,
|
||||||
|
start_point: SketchEntity,
|
||||||
|
end_point: SketchEntity,
|
||||||
|
) -> SketchEntity:
|
||||||
|
"""Add an arc."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def add_rectangle(
|
||||||
|
self, corner1: Tuple[float, float], corner2: Tuple[float, float]
|
||||||
|
) -> List[SketchEntity]:
|
||||||
|
"""Add a rectangle, returning the created entities."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def constrain_coincident(self, *entities: SketchEntity) -> bool:
|
||||||
|
"""Make entities coincident."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def constrain_horizontal(self, line: SketchEntity) -> bool:
|
||||||
|
"""Constrain a line to be horizontal."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def constrain_vertical(self, line: SketchEntity) -> bool:
|
||||||
|
"""Constrain a line to be vertical."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def constrain_distance(
|
||||||
|
self, entity1: SketchEntity, entity2: SketchEntity, distance: float
|
||||||
|
) -> bool:
|
||||||
|
"""Constrain distance between two entities."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def constrain_angle(self, line1: SketchEntity, line2: SketchEntity, angle: float) -> bool:
|
||||||
|
"""Constrain angle between two lines."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def constrain_parallel(self, line1: SketchEntity, line2: SketchEntity) -> bool:
|
||||||
|
"""Constrain two lines to be parallel."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def constrain_perpendicular(self, line1: SketchEntity, line2: SketchEntity) -> bool:
|
||||||
|
"""Constrain two lines to be perpendicular."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def constrain_midpoint(self, point: SketchEntity, line: SketchEntity) -> bool:
|
||||||
|
"""Constrain a point to be at the midpoint of a line."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def constrain_tangent(self, entity1: SketchEntity, entity2: SketchEntity) -> bool:
|
||||||
|
"""Constrain two entities to be tangent."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def constrain_equal_length(self, line1: SketchEntity, line2: SketchEntity) -> bool:
|
||||||
|
"""Constrain two lines to have equal length."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def constrain_equal_radius(self, circle1: SketchEntity, circle2: SketchEntity) -> bool:
|
||||||
|
"""Constrain two circles to have equal radius."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def constrain_diameter(self, circle: SketchEntity, diameter: float) -> bool:
|
||||||
|
"""Set the diameter of a circle."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def constrain_fixed(self, entity: SketchEntity) -> bool:
|
||||||
|
"""Fix an entity in place."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def solve(self) -> bool:
|
||||||
|
"""Solve all constraints."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def get_geometry(self) -> GeometryObject:
|
||||||
|
"""Get the solved geometry for operations."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def get_points(self) -> List[Point2D]:
|
||||||
|
"""Get all point positions."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def clear(self) -> None:
|
||||||
|
"""Clear all geometry and constraints."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def delete_entity(self, entity: SketchEntity) -> bool:
|
||||||
|
"""Delete an entity and its constraints."""
|
||||||
|
pass
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
"""OpenCASCADE geometry module."""
|
||||||
|
|
||||||
|
from fluency.geometry_occ.kernel import OCGeometryKernel, OCCGeometryObject
|
||||||
|
from fluency.geometry_occ.sketch import OCCSketch, OCCSketchEntity
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"OCGeometryKernel",
|
||||||
|
"OCCGeometryObject",
|
||||||
|
"OCCSketch",
|
||||||
|
"OCCSketchEntity",
|
||||||
|
]
|
||||||
@@ -0,0 +1,743 @@
|
|||||||
|
"""
|
||||||
|
OpenCASCADE-based geometry kernel for Fluency CAD.
|
||||||
|
|
||||||
|
This module provides a concrete implementation of the geometry kernel
|
||||||
|
using OCP (OpenCASCADE Python bindings).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import List, Tuple, Optional, Any, Dict
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from fluency.geometry.base import (
|
||||||
|
GeometryKernel,
|
||||||
|
GeometryObject,
|
||||||
|
Point2D,
|
||||||
|
Point3D,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class OCCGeometryObject(GeometryObject):
|
||||||
|
"""Geometry object wrapper for OpenCASCADE shapes."""
|
||||||
|
|
||||||
|
def __init__(self, shape: Any = None, metadata: Optional[Dict] = None):
|
||||||
|
super().__init__(shape, metadata)
|
||||||
|
|
||||||
|
|
||||||
|
class OCGeometryKernel(GeometryKernel):
|
||||||
|
"""
|
||||||
|
OpenCASCADE-based geometry kernel implementation.
|
||||||
|
|
||||||
|
This kernel uses OCP (OpenCASCADE Python bindings) for all geometry
|
||||||
|
operations.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._tolerance: float = 0.001
|
||||||
|
self._mesh_tolerance: float = 0.1
|
||||||
|
|
||||||
|
def _get_shape(self, obj: GeometryObject) -> Any:
|
||||||
|
"""Extract the underlying OCC shape from a GeometryObject.
|
||||||
|
|
||||||
|
Returns *None* if the object carries no shape (e.g. an empty sketch) —
|
||||||
|
callers should check for None before using the result.
|
||||||
|
"""
|
||||||
|
if isinstance(obj, OCCGeometryObject):
|
||||||
|
if obj.shape is not None and hasattr(obj.shape, "wrapped"):
|
||||||
|
return obj.shape.wrapped
|
||||||
|
return obj.shape
|
||||||
|
# Non-OCCGeometryObject: return its shape if present, else None.
|
||||||
|
# (Use explicit identity/truth checks — some OCP TopoDS objects have a
|
||||||
|
# falsy __bool__, so ``obj.shape if obj.shape`` is unsafe.)
|
||||||
|
shape = getattr(obj, "shape", None)
|
||||||
|
return shape if shape is not None else None
|
||||||
|
|
||||||
|
def create_point(self, x: float, y: float) -> GeometryObject:
|
||||||
|
"""Create a 2D point."""
|
||||||
|
from OCP.gp import gp_Pnt
|
||||||
|
return OCCGeometryObject(gp_Pnt(x, y, 0))
|
||||||
|
|
||||||
|
def create_line(self, start: Point2D, end: Point2D) -> GeometryObject:
|
||||||
|
"""Create a 2D line segment."""
|
||||||
|
from OCP.gp import gp_Pnt
|
||||||
|
from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeEdge
|
||||||
|
|
||||||
|
edge = BRepBuilderAPI_MakeEdge(
|
||||||
|
gp_Pnt(start.x, start.y, 0), gp_Pnt(end.x, end.y, 0)
|
||||||
|
).Edge()
|
||||||
|
return OCCGeometryObject(edge, {"type": "line"})
|
||||||
|
|
||||||
|
def create_circle(self, center: Point2D, radius: float) -> GeometryObject:
|
||||||
|
"""Create a 2D circle."""
|
||||||
|
from OCP.gp import gp_Pnt, gp_Dir, gp_Ax2, gp_Circ
|
||||||
|
from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeEdge
|
||||||
|
|
||||||
|
circ = gp_Circ(
|
||||||
|
gp_Ax2(gp_Pnt(center.x, center.y, 0), gp_Dir(0, 0, 1)), radius
|
||||||
|
)
|
||||||
|
edge = BRepBuilderAPI_MakeEdge(circ).Edge()
|
||||||
|
return OCCGeometryObject(edge, {"type": "circle"})
|
||||||
|
|
||||||
|
def create_arc(
|
||||||
|
self, center: Point2D, radius: float, start_angle: float, end_angle: float
|
||||||
|
) -> GeometryObject:
|
||||||
|
"""Create a 2D arc."""
|
||||||
|
import math
|
||||||
|
from OCP.gp import gp_Pnt, gp_Dir, gp_Ax2, gp_Circ
|
||||||
|
from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeEdge
|
||||||
|
|
||||||
|
start_rad = math.radians(start_angle)
|
||||||
|
end_rad = math.radians(end_angle)
|
||||||
|
|
||||||
|
circ = gp_Circ(
|
||||||
|
gp_Ax2(gp_Pnt(center.x, center.y, 0), gp_Dir(0, 0, 1)), radius
|
||||||
|
)
|
||||||
|
edge = BRepBuilderAPI_MakeEdge(circ, start_rad, end_rad).Edge()
|
||||||
|
return OCCGeometryObject(edge, {"type": "arc"})
|
||||||
|
|
||||||
|
def create_polygon(self, points: List[Point2D]) -> GeometryObject:
|
||||||
|
"""Create a closed polygon from points."""
|
||||||
|
from OCP.gp import gp_Pnt
|
||||||
|
from OCP.BRepBuilderAPI import BRepBuilderAPI_MakePolygon
|
||||||
|
|
||||||
|
if len(points) < 3:
|
||||||
|
raise ValueError("Polygon requires at least 3 points")
|
||||||
|
|
||||||
|
mp = BRepBuilderAPI_MakePolygon()
|
||||||
|
for pt in points:
|
||||||
|
mp.Add(gp_Pnt(pt.x, pt.y, 0))
|
||||||
|
mp.Close()
|
||||||
|
return OCCGeometryObject(mp.Wire(), {"type": "polygon"})
|
||||||
|
|
||||||
|
def create_rectangle(
|
||||||
|
self, width: float, height: float, center: Optional[Point2D] = None
|
||||||
|
) -> GeometryObject:
|
||||||
|
"""Create a rectangle."""
|
||||||
|
from OCP.gp import gp_Pnt
|
||||||
|
from OCP.BRepBuilderAPI import BRepBuilderAPI_MakePolygon
|
||||||
|
|
||||||
|
cx = center.x if center else 0
|
||||||
|
cy = center.y if center else 0
|
||||||
|
|
||||||
|
hw = width / 2.0
|
||||||
|
hh = height / 2.0
|
||||||
|
|
||||||
|
mp = BRepBuilderAPI_MakePolygon()
|
||||||
|
mp.Add(gp_Pnt(cx - hw, cy - hh, 0))
|
||||||
|
mp.Add(gp_Pnt(cx + hw, cy - hh, 0))
|
||||||
|
mp.Add(gp_Pnt(cx + hw, cy + hh, 0))
|
||||||
|
mp.Add(gp_Pnt(cx - hw, cy + hh, 0))
|
||||||
|
mp.Close()
|
||||||
|
return OCCGeometryObject(mp.Wire(), {"type": "rectangle"})
|
||||||
|
|
||||||
|
def extrude(
|
||||||
|
self,
|
||||||
|
sketch: GeometryObject,
|
||||||
|
height: float,
|
||||||
|
direction: Tuple[float, float, float] = (0, 0, 1),
|
||||||
|
symmetric: bool = False,
|
||||||
|
) -> GeometryObject:
|
||||||
|
"""Extrude a sketch face into a 3D solid along the sketch plane normal.
|
||||||
|
|
||||||
|
The sketch's plane normal is read from ``sketch.metadata["normal"]``
|
||||||
|
(set by ``OCCSketch.build_face_geometry``); it defaults to +Z for
|
||||||
|
legacy objects that don't carry one. *direction* is accepted for API
|
||||||
|
compatibility but ignored — the plane normal is authoritative. A
|
||||||
|
negative *height* extrudes against the normal.
|
||||||
|
"""
|
||||||
|
from OCP.gp import gp_Vec
|
||||||
|
from OCP.BRepPrimAPI import BRepPrimAPI_MakePrism
|
||||||
|
from OCP.BRepAlgoAPI import BRepAlgoAPI_Fuse
|
||||||
|
from OCP.TopoDS import TopoDS_Shape
|
||||||
|
|
||||||
|
# Defensive: figure out the actual shape from whatever the caller
|
||||||
|
# hands us, and surface a clear error if we can't get one.
|
||||||
|
if isinstance(sketch, OCCGeometryObject):
|
||||||
|
face = self._get_shape(sketch)
|
||||||
|
elif isinstance(sketch, TopoDS_Shape):
|
||||||
|
face = sketch
|
||||||
|
else:
|
||||||
|
face = self._get_shape(sketch)
|
||||||
|
if face is None:
|
||||||
|
raise ValueError(
|
||||||
|
"Cannot extrude: sketch has no geometry. "
|
||||||
|
"Draw a closed profile before extruding."
|
||||||
|
)
|
||||||
|
# If the wrapper class itself leaked through somehow, surface a
|
||||||
|
# clear error instead of letting BRepPrimAPI_MakePrism raise an
|
||||||
|
# opaque TypeError.
|
||||||
|
if isinstance(face, OCCGeometryObject):
|
||||||
|
raise ValueError(
|
||||||
|
"Cannot extrude: sketch geometry is a wrapper, not a shape. "
|
||||||
|
"This is a bug — please report it."
|
||||||
|
)
|
||||||
|
# ``face`` may be a TopoDS_Face (new path) or a compound/wire.
|
||||||
|
# If it's not already a face, build one.
|
||||||
|
face = self._ensure_face(face)
|
||||||
|
if face is None:
|
||||||
|
raise ValueError(
|
||||||
|
"Cannot extrude: sketch geometry is not a valid face. "
|
||||||
|
"Ensure the profile is closed (no open ends)."
|
||||||
|
)
|
||||||
|
|
||||||
|
normal = self._sketch_normal(sketch)
|
||||||
|
nx, ny, nz = normal
|
||||||
|
|
||||||
|
def _prism(h: float):
|
||||||
|
vec = gp_Vec(nx * h, ny * h, nz * h)
|
||||||
|
maker = BRepPrimAPI_MakePrism(face, vec, False, True)
|
||||||
|
maker.Build()
|
||||||
|
return maker.Shape()
|
||||||
|
|
||||||
|
if symmetric:
|
||||||
|
half = height / 2.0
|
||||||
|
pos = _prism(half)
|
||||||
|
neg = _prism(-half)
|
||||||
|
fuse = BRepAlgoAPI_Fuse(pos, neg)
|
||||||
|
fuse.Build()
|
||||||
|
solid = fuse.Shape()
|
||||||
|
else:
|
||||||
|
solid = _prism(height)
|
||||||
|
|
||||||
|
return OCCGeometryObject(solid, {"type": "extrusion", "normal": normal})
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _sketch_normal(obj: GeometryObject) -> Tuple[float, float, float]:
|
||||||
|
"""Return the normal stored on a sketch-derived geometry object, else +Z."""
|
||||||
|
import numpy as np
|
||||||
|
meta = getattr(obj, "metadata", None) or {}
|
||||||
|
n = meta.get("normal")
|
||||||
|
if n is None:
|
||||||
|
return (0.0, 0.0, 1.0)
|
||||||
|
arr = np.asarray(n, dtype=float)
|
||||||
|
norm = float(np.linalg.norm(arr))
|
||||||
|
if norm < 1e-12:
|
||||||
|
return (0.0, 0.0, 1.0)
|
||||||
|
arr = arr / norm
|
||||||
|
return (float(arr[0]), float(arr[1]), float(arr[2]))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _ensure_face(shape: Any) -> Any:
|
||||||
|
"""Return a ``TopoDS_Face`` from *shape*, or *None* if impossible.
|
||||||
|
|
||||||
|
If *shape* is already a face, return it unchanged; otherwise try to
|
||||||
|
build a planar face from it (wire/edge/compound). Returns *None* for
|
||||||
|
empty/invalid input so callers can surface a clear error instead of
|
||||||
|
feeding a non-face to ``BRepPrimAPI_MakePrism``.
|
||||||
|
"""
|
||||||
|
from OCP.TopoDS import TopoDS_Face
|
||||||
|
from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeFace
|
||||||
|
|
||||||
|
if shape is None:
|
||||||
|
return None
|
||||||
|
if isinstance(shape, TopoDS_Face):
|
||||||
|
return shape
|
||||||
|
try:
|
||||||
|
maker = BRepBuilderAPI_MakeFace(shape, True)
|
||||||
|
maker.Build()
|
||||||
|
if maker.IsDone():
|
||||||
|
return maker.Face()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
def revolve(
|
||||||
|
self,
|
||||||
|
sketch: GeometryObject,
|
||||||
|
angle: float = 360.0,
|
||||||
|
axis: Tuple[float, float, float] = (0, 0, 1),
|
||||||
|
origin: Tuple[float, float, float] = (0, 0, 0),
|
||||||
|
) -> GeometryObject:
|
||||||
|
"""Revolve a sketch face around an axis."""
|
||||||
|
import math
|
||||||
|
|
||||||
|
# Get the OCC shape directly (a TopoDS_Face for new sketch geometry).
|
||||||
|
shape = self._get_shape(sketch)
|
||||||
|
face = self._ensure_face(shape)
|
||||||
|
|
||||||
|
from OCP.gp import gp_Ax1, gp_Pnt, gp_Dir
|
||||||
|
from OCP.BRepPrimAPI import BRepPrimAPI_MakeRevol
|
||||||
|
|
||||||
|
# Revolve the face around the axis
|
||||||
|
revolve_axis = gp_Ax1(gp_Pnt(*origin), gp_Dir(*axis))
|
||||||
|
angle_rad = math.radians(angle)
|
||||||
|
revolver = BRepPrimAPI_MakeRevol(face, revolve_axis, angle_rad)
|
||||||
|
revolver.Build()
|
||||||
|
solid_shape = revolver.Shape()
|
||||||
|
|
||||||
|
return OCCGeometryObject(solid_shape, {"type": "revolution"})
|
||||||
|
|
||||||
|
def loft(self, profiles: List[GeometryObject], ruled: bool = False) -> GeometryObject:
|
||||||
|
"""Create a loft between multiple profiles."""
|
||||||
|
from OCP.BRepOffsetAPI import BRepOffsetAPI_ThruSections
|
||||||
|
from OCP.TopExp import TopExp_Explorer
|
||||||
|
from OCP.TopAbs import TopAbs_WIRE
|
||||||
|
from OCP.TopoDS import TopoDS
|
||||||
|
|
||||||
|
if len(profiles) < 2:
|
||||||
|
raise ValueError("Loft requires at least 2 profiles")
|
||||||
|
|
||||||
|
loft_maker = BRepOffsetAPI_ThruSections(True, ruled)
|
||||||
|
for profile in profiles:
|
||||||
|
shape = self._get_shape(profile)
|
||||||
|
explorer = TopExp_Explorer(shape, TopAbs_WIRE)
|
||||||
|
while explorer.More():
|
||||||
|
wire = TopoDS.Wire_s(explorer.Current())
|
||||||
|
loft_maker.AddWire(wire)
|
||||||
|
explorer.Next()
|
||||||
|
|
||||||
|
loft_maker.Build()
|
||||||
|
solid = loft_maker.Shape()
|
||||||
|
return OCCGeometryObject(solid, {"type": "loft"})
|
||||||
|
|
||||||
|
def sweep(
|
||||||
|
self, profile: GeometryObject, path: GeometryObject, is_frenet: bool = False
|
||||||
|
) -> GeometryObject:
|
||||||
|
"""Sweep a profile along a path."""
|
||||||
|
from OCP.BRepOffsetAPI import BRepOffsetAPI_MakePipeShell
|
||||||
|
from OCP.TopExp import TopExp_Explorer
|
||||||
|
from OCP.TopAbs import TopAbs_WIRE
|
||||||
|
from OCP.TopoDS import TopoDS
|
||||||
|
|
||||||
|
profile_shape = self._get_shape(profile)
|
||||||
|
path_shape = self._get_shape(path)
|
||||||
|
|
||||||
|
def _first_wire(shape):
|
||||||
|
exp = TopExp_Explorer(shape, TopAbs_WIRE)
|
||||||
|
if exp.More():
|
||||||
|
return TopoDS.Wire_s(exp.Current())
|
||||||
|
raise ValueError("No wire found in shape for sweep")
|
||||||
|
|
||||||
|
profile_wire = _first_wire(profile_shape)
|
||||||
|
path_wire = _first_wire(path_shape)
|
||||||
|
|
||||||
|
pipe = BRepOffsetAPI_MakePipeShell(path_wire)
|
||||||
|
pipe.Add(profile_wire, False, False)
|
||||||
|
if is_frenet:
|
||||||
|
pipe.SetMode(True)
|
||||||
|
pipe.Build()
|
||||||
|
solid = pipe.Shape()
|
||||||
|
return OCCGeometryObject(solid, {"type": "sweep"})
|
||||||
|
|
||||||
|
def boolean_union(self, *bodies: GeometryObject) -> GeometryObject:
|
||||||
|
"""Union multiple bodies."""
|
||||||
|
if len(bodies) < 2:
|
||||||
|
return bodies[0] if bodies else OCCGeometryObject(None)
|
||||||
|
|
||||||
|
result = self._get_shape(bodies[0])
|
||||||
|
for body in bodies[1:]:
|
||||||
|
shape = self._get_shape(body)
|
||||||
|
from OCP.BRepAlgoAPI import BRepAlgoAPI_Fuse
|
||||||
|
|
||||||
|
fuse = BRepAlgoAPI_Fuse(result, shape)
|
||||||
|
fuse.Build()
|
||||||
|
result = fuse.Shape()
|
||||||
|
|
||||||
|
return OCCGeometryObject(result, {"type": "union"})
|
||||||
|
|
||||||
|
def boolean_difference(self, base: GeometryObject, tool: GeometryObject) -> GeometryObject:
|
||||||
|
"""Subtract tool from base."""
|
||||||
|
base_shape = self._get_shape(base)
|
||||||
|
tool_shape = self._get_shape(tool)
|
||||||
|
|
||||||
|
from OCP.BRepAlgoAPI import BRepAlgoAPI_Cut
|
||||||
|
|
||||||
|
cut = BRepAlgoAPI_Cut(base_shape, tool_shape)
|
||||||
|
cut.Build()
|
||||||
|
|
||||||
|
return OCCGeometryObject(cut.Shape(), {"type": "difference"})
|
||||||
|
|
||||||
|
def boolean_intersection(self, body1: GeometryObject, body2: GeometryObject) -> GeometryObject:
|
||||||
|
"""Intersect two bodies."""
|
||||||
|
shape1 = self._get_shape(body1)
|
||||||
|
shape2 = self._get_shape(body2)
|
||||||
|
|
||||||
|
from OCP.BRepAlgoAPI import BRepAlgoAPI_Common
|
||||||
|
|
||||||
|
common = BRepAlgoAPI_Common(shape1, shape2)
|
||||||
|
common.Build()
|
||||||
|
|
||||||
|
return OCCGeometryObject(common.Shape(), {"type": "intersection"})
|
||||||
|
|
||||||
|
def fillet(
|
||||||
|
self, body: GeometryObject, radius: float, edges: Optional[List[Any]] = None
|
||||||
|
) -> GeometryObject:
|
||||||
|
"""Apply fillet to edges."""
|
||||||
|
shape = self._get_shape(body)
|
||||||
|
from OCP.BRepFilletAPI import BRepFilletAPI_MakeFillet
|
||||||
|
|
||||||
|
fillet = BRepFilletAPI_MakeFillet(shape)
|
||||||
|
|
||||||
|
if edges:
|
||||||
|
for edge in edges:
|
||||||
|
fillet.Add(radius, edge)
|
||||||
|
else:
|
||||||
|
from OCP.TopExp import TopExp_Explorer
|
||||||
|
from OCP.TopAbs import TopAbs_EDGE
|
||||||
|
|
||||||
|
explorer = TopExp_Explorer(shape, TopAbs_EDGE)
|
||||||
|
while explorer.More():
|
||||||
|
fillet.Add(radius, explorer.Current())
|
||||||
|
explorer.Next()
|
||||||
|
|
||||||
|
fillet.Build()
|
||||||
|
return OCCGeometryObject(fillet.Shape(), {"type": "fillet"})
|
||||||
|
|
||||||
|
def chamfer(
|
||||||
|
self, body: GeometryObject, size: float, edges: Optional[List[Any]] = None
|
||||||
|
) -> GeometryObject:
|
||||||
|
"""Apply chamfer to edges."""
|
||||||
|
shape = self._get_shape(body)
|
||||||
|
from OCP.BRepFilletAPI import BRepFilletAPI_MakeChamfer
|
||||||
|
|
||||||
|
chamfer = BRepFilletAPI_MakeChamfer(shape)
|
||||||
|
|
||||||
|
if edges:
|
||||||
|
for edge in edges:
|
||||||
|
chamfer.Add(size, edge)
|
||||||
|
else:
|
||||||
|
from OCP.TopExp import TopExp_Explorer
|
||||||
|
from OCP.TopAbs import TopAbs_EDGE
|
||||||
|
|
||||||
|
explorer = TopExp_Explorer(shape, TopAbs_EDGE)
|
||||||
|
while explorer.More():
|
||||||
|
chamfer.Add(size, explorer.Current())
|
||||||
|
explorer.Next()
|
||||||
|
|
||||||
|
chamfer.Build()
|
||||||
|
return OCCGeometryObject(chamfer.Shape(), {"type": "chamfer"})
|
||||||
|
|
||||||
|
def shell(
|
||||||
|
self, body: GeometryObject, thickness: float, faces_to_remove: Optional[List[Any]] = None
|
||||||
|
) -> GeometryObject:
|
||||||
|
"""Create a shell (hollow body)."""
|
||||||
|
shape = self._get_shape(body)
|
||||||
|
from OCP.BRepOffsetAPI import BRepOffsetAPI_MakeThickSolid
|
||||||
|
from OCP.TopTools import TopTools_ListOfShape
|
||||||
|
|
||||||
|
faces_list = TopTools_ListOfShape()
|
||||||
|
if faces_to_remove:
|
||||||
|
for face in faces_to_remove:
|
||||||
|
faces_list.Append(face)
|
||||||
|
|
||||||
|
shell_maker = BRepOffsetAPI_MakeThickSolid()
|
||||||
|
shell_maker.MakeThickSolidByJoin(shape, faces_list, thickness, 0.001)
|
||||||
|
shell_maker.Build()
|
||||||
|
return OCCGeometryObject(shell_maker.Shape(), {"type": "shell"})
|
||||||
|
|
||||||
|
def offset(self, face: GeometryObject, distance: float) -> GeometryObject:
|
||||||
|
"""Offset a face or surface."""
|
||||||
|
shape = self._get_shape(face)
|
||||||
|
from OCP.BRepOffsetAPI import BRepOffsetAPI_MakeOffset
|
||||||
|
|
||||||
|
offset_maker = BRepOffsetAPI_MakeOffset(shape, False)
|
||||||
|
offset_maker.Perform(distance)
|
||||||
|
|
||||||
|
return OCCGeometryObject(offset_maker.Shape(), {"type": "offset"})
|
||||||
|
|
||||||
|
def translate(self, body: GeometryObject, vector: Tuple[float, float, float]) -> GeometryObject:
|
||||||
|
"""Translate a body."""
|
||||||
|
shape = self._get_shape(body)
|
||||||
|
from OCP.BRepBuilderAPI import BRepBuilderAPI_Transform
|
||||||
|
from OCP.gp import gp_Trsf, gp_Vec
|
||||||
|
|
||||||
|
transform = gp_Trsf()
|
||||||
|
transform.SetTranslation(gp_Vec(*vector))
|
||||||
|
transformer = BRepBuilderAPI_Transform(shape, transform)
|
||||||
|
return OCCGeometryObject(transformer.Shape(), {"type": "translated"})
|
||||||
|
|
||||||
|
def rotate(
|
||||||
|
self,
|
||||||
|
body: GeometryObject,
|
||||||
|
axis: Tuple[float, float, float],
|
||||||
|
angle: float,
|
||||||
|
origin: Tuple[float, float, float] = (0, 0, 0),
|
||||||
|
) -> GeometryObject:
|
||||||
|
"""Rotate a body around an axis."""
|
||||||
|
import math
|
||||||
|
shape = self._get_shape(body)
|
||||||
|
from OCP.BRepBuilderAPI import BRepBuilderAPI_Transform
|
||||||
|
from OCP.gp import gp_Trsf, gp_Ax1, gp_Pnt, gp_Dir
|
||||||
|
|
||||||
|
ax1 = gp_Ax1(gp_Pnt(*origin), gp_Dir(*axis))
|
||||||
|
transform = gp_Trsf()
|
||||||
|
transform.SetRotation(ax1, angle)
|
||||||
|
transformer = BRepBuilderAPI_Transform(shape, transform)
|
||||||
|
return OCCGeometryObject(transformer.Shape(), {"type": "rotated"})
|
||||||
|
|
||||||
|
def scale(self, body: GeometryObject, factor: float) -> GeometryObject:
|
||||||
|
"""Scale a body uniformly."""
|
||||||
|
shape = self._get_shape(body)
|
||||||
|
from OCP.BRepBuilderAPI import BRepBuilderAPI_Transform
|
||||||
|
from OCP.gp import gp_Trsf
|
||||||
|
|
||||||
|
transform = gp_Trsf()
|
||||||
|
transform.SetScale(factor)
|
||||||
|
transformer = BRepBuilderAPI_Transform(shape, transform)
|
||||||
|
|
||||||
|
return OCCGeometryObject(transformer.Shape(), {"type": "scaled"})
|
||||||
|
|
||||||
|
def mirror(
|
||||||
|
self,
|
||||||
|
body: GeometryObject,
|
||||||
|
plane_normal: Tuple[float, float, float],
|
||||||
|
plane_origin: Tuple[float, float, float] = (0, 0, 0),
|
||||||
|
) -> GeometryObject:
|
||||||
|
"""Mirror a body across a plane."""
|
||||||
|
shape = self._get_shape(body)
|
||||||
|
from OCP.BRepBuilderAPI import BRepBuilderAPI_Transform
|
||||||
|
from OCP.gp import gp_Trsf, gp_Ax2, gp_Pnt, gp_Dir
|
||||||
|
|
||||||
|
ax2 = gp_Ax2(gp_Pnt(*plane_origin), gp_Dir(*plane_normal))
|
||||||
|
transform = gp_Trsf()
|
||||||
|
transform.SetMirror(ax2)
|
||||||
|
transformer = BRepBuilderAPI_Transform(shape, transform)
|
||||||
|
|
||||||
|
return OCCGeometryObject(transformer.Shape(), {"type": "mirrored"})
|
||||||
|
|
||||||
|
def export_step(self, body: GeometryObject, filepath: str, schema: str = "AP214") -> bool:
|
||||||
|
"""Export to STEP format."""
|
||||||
|
try:
|
||||||
|
shape = self._get_shape(body)
|
||||||
|
from OCP.STEPControl import STEPControl_Writer, STEPControl_AsIs
|
||||||
|
from OCP.Interface import Interface_Static
|
||||||
|
|
||||||
|
writer = STEPControl_Writer()
|
||||||
|
if schema == "AP214":
|
||||||
|
Interface_Static.SetCVal_s("write.step.schema", "AP214")
|
||||||
|
elif schema == "AP203":
|
||||||
|
Interface_Static.SetCVal_s("write.step.schema", "AP203")
|
||||||
|
|
||||||
|
writer.Transfer(shape, STEPControl_AsIs)
|
||||||
|
return writer.Write(filepath)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"STEP export error: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def export_iges(self, body: GeometryObject, filepath: str) -> bool:
|
||||||
|
"""Export to IGES format."""
|
||||||
|
try:
|
||||||
|
shape = self._get_shape(body)
|
||||||
|
from OCP.IGESControl import IGESControl_Writer
|
||||||
|
from OCP.Interface import Interface_Static
|
||||||
|
|
||||||
|
Interface_Static.SetCVal_s("write.iges.schema", "5.3")
|
||||||
|
writer = IGESControl_Writer()
|
||||||
|
writer.AddShape(shape)
|
||||||
|
return writer.Write(filepath)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"IGES export error: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def export_stl(
|
||||||
|
self, body: GeometryObject, filepath: str, tolerance: float = 0.1, ascii_mode: bool = False
|
||||||
|
) -> bool:
|
||||||
|
"""Export to STL format."""
|
||||||
|
try:
|
||||||
|
shape = self._get_shape(body)
|
||||||
|
from OCP.StlAPI import StlAPI_Writer
|
||||||
|
from OCP.BRepMesh import BRepMesh_IncrementalMesh
|
||||||
|
|
||||||
|
mesh = BRepMesh_IncrementalMesh(shape, tolerance)
|
||||||
|
mesh.Perform()
|
||||||
|
|
||||||
|
writer = StlAPI_Writer()
|
||||||
|
writer.ASCIIMode = ascii_mode
|
||||||
|
return writer.Write(shape, filepath)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"STL export error: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def import_step(self, filepath: str) -> GeometryObject:
|
||||||
|
"""Import from STEP format."""
|
||||||
|
from OCP.STEPControl import STEPControl_Reader
|
||||||
|
from OCP.IFSelect import IFSelect_RetDone
|
||||||
|
|
||||||
|
reader = STEPControl_Reader()
|
||||||
|
status = reader.ReadFile(filepath)
|
||||||
|
|
||||||
|
if status != IFSelect_RetDone:
|
||||||
|
raise ValueError(f"Failed to read STEP file: {filepath}")
|
||||||
|
|
||||||
|
reader.TransferRoots()
|
||||||
|
shape = reader.OneShape()
|
||||||
|
return OCCGeometryObject(shape, {"type": "imported_step"})
|
||||||
|
|
||||||
|
def import_iges(self, filepath: str) -> GeometryObject:
|
||||||
|
"""Import from IGES format."""
|
||||||
|
from OCP.IGESControl import IGESControl_Reader
|
||||||
|
from OCP.IFSelect import IFSelect_RetDone
|
||||||
|
|
||||||
|
reader = IGESControl_Reader()
|
||||||
|
status = reader.ReadFile(filepath)
|
||||||
|
|
||||||
|
if status != IFSelect_RetDone:
|
||||||
|
raise ValueError(f"Failed to read IGES file: {filepath}")
|
||||||
|
|
||||||
|
reader.TransferRoots()
|
||||||
|
shape = reader.OneShape()
|
||||||
|
|
||||||
|
return OCCGeometryObject(shape, {"type": "imported_iges"})
|
||||||
|
|
||||||
|
def get_mesh(
|
||||||
|
self, body: GeometryObject, tolerance: float = 0.1
|
||||||
|
) -> Tuple[np.ndarray, np.ndarray]:
|
||||||
|
"""Get triangulated mesh for rendering."""
|
||||||
|
shape = self._get_shape(body)
|
||||||
|
|
||||||
|
from OCP.BRepMesh import BRepMesh_IncrementalMesh
|
||||||
|
from OCP.TopExp import TopExp_Explorer
|
||||||
|
from OCP.TopAbs import TopAbs_FACE
|
||||||
|
from OCP.BRep import BRep_Tool
|
||||||
|
from OCP.TopLoc import TopLoc_Location
|
||||||
|
|
||||||
|
# Use finer angular deflection (0.15 rad ≈ 24 segments/circle) so
|
||||||
|
# curved surfaces like cylinders render smoothly instead of faceted.
|
||||||
|
mesh = BRepMesh_IncrementalMesh(shape, tolerance, False, 0.15, True)
|
||||||
|
mesh.Perform()
|
||||||
|
|
||||||
|
vertices_list: List[List[float]] = []
|
||||||
|
faces_list: List[List[int]] = []
|
||||||
|
vertex_offset = 0
|
||||||
|
|
||||||
|
from OCP.TopoDS import TopoDS
|
||||||
|
from OCP.TopAbs import TopAbs_Orientation
|
||||||
|
|
||||||
|
explorer = TopExp_Explorer(shape, TopAbs_FACE)
|
||||||
|
while explorer.More():
|
||||||
|
face = TopoDS.Face_s(explorer.Current())
|
||||||
|
location = TopLoc_Location()
|
||||||
|
triangulation = BRep_Tool.Triangulation_s(face, location)
|
||||||
|
|
||||||
|
if triangulation is not None:
|
||||||
|
n_vertices = triangulation.NbNodes()
|
||||||
|
for i in range(1, n_vertices + 1):
|
||||||
|
p = triangulation.Node(i)
|
||||||
|
vertices_list.append([p.X(), p.Y(), p.Z()])
|
||||||
|
|
||||||
|
n_triangles = triangulation.NbTriangles()
|
||||||
|
# REVERSED faces store triangle winding in the natural (surface)
|
||||||
|
# orientation — we must flip it so the computed normals point
|
||||||
|
# outward (away from solid interior). TopAbs_REVERSED = 1.
|
||||||
|
reverse_winding = face.Orientation() == TopAbs_Orientation.TopAbs_REVERSED
|
||||||
|
for i in range(1, n_triangles + 1):
|
||||||
|
tri = triangulation.Triangle(i)
|
||||||
|
v0, v1, v2 = (
|
||||||
|
tri.Value(1) - 1 + vertex_offset,
|
||||||
|
tri.Value(2) - 1 + vertex_offset,
|
||||||
|
tri.Value(3) - 1 + vertex_offset,
|
||||||
|
)
|
||||||
|
if reverse_winding:
|
||||||
|
# Swap last two vertices to flip winding direction.
|
||||||
|
v1, v2 = v2, v1
|
||||||
|
faces_list.append([v0, v1, v2])
|
||||||
|
|
||||||
|
vertex_offset += n_vertices
|
||||||
|
|
||||||
|
explorer.Next()
|
||||||
|
|
||||||
|
return np.array(vertices_list, dtype=np.float32), np.array(faces_list, dtype=np.int32)
|
||||||
|
|
||||||
|
def get_edges(self, body: GeometryObject) -> Tuple[np.ndarray, np.ndarray]:
|
||||||
|
"""Get edge wireframe for rendering."""
|
||||||
|
shape = self._get_shape(body)
|
||||||
|
|
||||||
|
from OCP.TopExp import TopExp_Explorer
|
||||||
|
from OCP.TopAbs import TopAbs_EDGE
|
||||||
|
from OCP.BRep import BRep_Tool
|
||||||
|
from OCP.TopLoc import TopLoc_Location
|
||||||
|
from OCP.BRepAdaptor import BRepAdaptor_Curve
|
||||||
|
from OCP.GeomAbs import GeomAbs_Line, GeomAbs_Circle, GeomAbs_Ellipse, GeomAbs_BSplineCurve
|
||||||
|
|
||||||
|
vertices_list: List[List[float]] = []
|
||||||
|
edges_list: List[List[int]] = []
|
||||||
|
vertex_offset = 0
|
||||||
|
|
||||||
|
def discretize_edge(edge: Any, num_points: int = 20) -> List[List[float]]:
|
||||||
|
curve = BRepAdaptor_Curve(edge)
|
||||||
|
curve_type = curve.GetType()
|
||||||
|
|
||||||
|
points = []
|
||||||
|
|
||||||
|
if curve_type == GeomAbs_Line:
|
||||||
|
first = curve.FirstParameter()
|
||||||
|
last = curve.LastParameter()
|
||||||
|
p1 = curve.Value(first)
|
||||||
|
p2 = curve.Value(last)
|
||||||
|
points = [[p1.X(), p1.Y(), p1.Z()], [p2.X(), p2.Y(), p2.Z()]]
|
||||||
|
else:
|
||||||
|
first = curve.FirstParameter()
|
||||||
|
last = curve.LastParameter()
|
||||||
|
|
||||||
|
for i in range(num_points + 1):
|
||||||
|
t = first + (last - first) * i / num_points
|
||||||
|
p = curve.Value(t)
|
||||||
|
points.append([p.X(), p.Y(), p.Z()])
|
||||||
|
|
||||||
|
return points
|
||||||
|
|
||||||
|
explorer = TopExp_Explorer(shape, TopAbs_EDGE)
|
||||||
|
while explorer.More():
|
||||||
|
from OCP.TopoDS import TopoDS
|
||||||
|
edge = TopoDS.Edge_s(explorer.Current())
|
||||||
|
edge_points = discretize_edge(edge)
|
||||||
|
|
||||||
|
for i, pt in enumerate(edge_points):
|
||||||
|
vertices_list.append(pt)
|
||||||
|
if i < len(edge_points) - 1:
|
||||||
|
edges_list.append([vertex_offset + i, vertex_offset + i + 1])
|
||||||
|
|
||||||
|
vertex_offset += len(edge_points)
|
||||||
|
explorer.Next()
|
||||||
|
|
||||||
|
return np.array(vertices_list, dtype=np.float32), np.array(edges_list, dtype=np.int32)
|
||||||
|
|
||||||
|
def get_bounding_box(self, body: GeometryObject) -> Tuple[Point3D, Point3D]:
|
||||||
|
"""Get the bounding box of a body."""
|
||||||
|
shape = self._get_shape(body)
|
||||||
|
|
||||||
|
from OCP.Bnd import Bnd_Box
|
||||||
|
from OCP.BRepBndLib import BRepBndLib
|
||||||
|
|
||||||
|
bbox = Bnd_Box()
|
||||||
|
BRepBndLib.AddClose_s(shape, bbox)
|
||||||
|
|
||||||
|
xmin, ymin, zmin, xmax, ymax, zmax = bbox.Get()
|
||||||
|
|
||||||
|
return Point3D(xmin, ymin, zmin), Point3D(xmax, ymax, zmax)
|
||||||
|
|
||||||
|
def get_volume(self, body: GeometryObject) -> float:
|
||||||
|
"""Calculate the volume of a solid body."""
|
||||||
|
shape = self._get_shape(body)
|
||||||
|
|
||||||
|
from OCP.GProp import GProp_GProps
|
||||||
|
from OCP.BRepGProp import BRepGProp
|
||||||
|
|
||||||
|
props = GProp_GProps()
|
||||||
|
BRepGProp.VolumeProperties_s(shape, props)
|
||||||
|
|
||||||
|
return props.Mass()
|
||||||
|
|
||||||
|
def get_surface_area(self, body: GeometryObject) -> float:
|
||||||
|
"""Calculate the surface area of a body."""
|
||||||
|
shape = self._get_shape(body)
|
||||||
|
|
||||||
|
from OCP.GProp import GProp_GProps
|
||||||
|
from OCP.BRepGProp import BRepGProp
|
||||||
|
|
||||||
|
props = GProp_GProps()
|
||||||
|
BRepGProp.SurfaceProperties_s(shape, props)
|
||||||
|
|
||||||
|
return props.Mass()
|
||||||
|
|
||||||
|
def get_center_of_mass(self, body: GeometryObject) -> Point3D:
|
||||||
|
"""Calculate the center of mass of a solid body."""
|
||||||
|
shape = self._get_shape(body)
|
||||||
|
|
||||||
|
from OCP.GProp import GProp_GProps
|
||||||
|
from OCP.BRepGProp import BRepGProp
|
||||||
|
|
||||||
|
props = GProp_GProps()
|
||||||
|
BRepGProp.VolumeProperties_s(shape, props)
|
||||||
|
|
||||||
|
cg = props.CentreOfMass()
|
||||||
|
return Point3D(cg.X(), cg.Y(), cg.Z())
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,5 @@
|
|||||||
|
"""I/O module: project save/load."""
|
||||||
|
|
||||||
|
from fluency.io.project_io import save_project, load_project, project_zip_path
|
||||||
|
|
||||||
|
__all__ = ["save_project", "load_project", "project_zip_path"]
|
||||||
@@ -0,0 +1,734 @@
|
|||||||
|
"""Project save/load — ``.fluency`` ZIP files.
|
||||||
|
|
||||||
|
The on-disk format is a single ZIP archive:
|
||||||
|
|
||||||
|
project.json # project tree: components, sketches, bodies,
|
||||||
|
# workplanes, assemblies, connectors, view state
|
||||||
|
bodies/<id>.step # one STEP file per Body (BRep geometry)
|
||||||
|
sketches/<id>/meta.json # sketch entities + constraints (kept separately
|
||||||
|
# so a single huge sketch doesn't bloat the
|
||||||
|
# main project.json)
|
||||||
|
sketches/<id>/solved.step # the sketch's solved face geometry
|
||||||
|
|
||||||
|
Sketch constraint solving and 3D body geometry are both preserved by using
|
||||||
|
OpenCASCADE's native STEP exporter (which is lossless for BRep). Everything
|
||||||
|
else is JSON.
|
||||||
|
|
||||||
|
The :func:`save_project` function is the entry point used by the File menu.
|
||||||
|
The :func:`load_project` function returns a fully populated
|
||||||
|
:class:`fluency.models.Project` (with a fresh ``OCGeometryKernel``) and an
|
||||||
|
optional view-state dict that the main window can hand back to the renderer
|
||||||
|
to restore the camera.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import tempfile
|
||||||
|
import zipfile
|
||||||
|
from dataclasses import asdict, is_dataclass
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any, Callable, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from fluency.models.data_model import (
|
||||||
|
Assembly,
|
||||||
|
AssemblyComponent,
|
||||||
|
AssemblyConnection,
|
||||||
|
Body,
|
||||||
|
Component,
|
||||||
|
Connector,
|
||||||
|
Project,
|
||||||
|
Sketch,
|
||||||
|
Workplane,
|
||||||
|
)
|
||||||
|
from fluency.geometry_occ.kernel import OCCGeometryObject, OCGeometryKernel
|
||||||
|
from fluency.geometry_occ.sketch import OCCSketch
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# ── JSON-friendly type coercion ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _json_default(obj: Any) -> Any:
|
||||||
|
"""Default JSON encoder for numpy / dataclass / datetime values."""
|
||||||
|
if isinstance(obj, np.ndarray):
|
||||||
|
return obj.tolist()
|
||||||
|
if isinstance(obj, (datetime,)):
|
||||||
|
return obj.isoformat()
|
||||||
|
if isinstance(obj, (set, frozenset)):
|
||||||
|
return sorted(obj)
|
||||||
|
if isinstance(obj, tuple):
|
||||||
|
return list(obj)
|
||||||
|
if is_dataclass(obj):
|
||||||
|
return asdict(obj)
|
||||||
|
raise TypeError(f"Object of type {type(obj).__name__} is not JSON serializable")
|
||||||
|
|
||||||
|
|
||||||
|
def _to_json(data: Any) -> str:
|
||||||
|
return json.dumps(data, default=_json_default, indent=2, sort_keys=False)
|
||||||
|
|
||||||
|
|
||||||
|
def _coerce_listlike(value: Any) -> List[Any]:
|
||||||
|
"""Cast arrays / tuples / numpy arrays to plain lists for JSON friendliness."""
|
||||||
|
if value is None:
|
||||||
|
return []
|
||||||
|
if isinstance(value, np.ndarray):
|
||||||
|
return value.tolist()
|
||||||
|
if isinstance(value, (list, tuple)):
|
||||||
|
return [v for v in value]
|
||||||
|
return list(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _to_3tuple(value: Any) -> Tuple[float, float, float]:
|
||||||
|
"""Coerce a saved 3-vector to a tuple of floats (for OCC)."""
|
||||||
|
if value is None:
|
||||||
|
return (0.0, 0.0, 0.0)
|
||||||
|
if isinstance(value, np.ndarray):
|
||||||
|
seq = value.tolist()
|
||||||
|
else:
|
||||||
|
seq = list(value)
|
||||||
|
if len(seq) < 3:
|
||||||
|
seq = list(seq) + [0.0] * (3 - len(seq))
|
||||||
|
return (float(seq[0]), float(seq[1]), float(seq[2]))
|
||||||
|
|
||||||
|
|
||||||
|
def _to_3vec(value: Any) -> np.ndarray:
|
||||||
|
"""Coerce a saved 3-vector to a 3-element numpy array."""
|
||||||
|
if isinstance(value, np.ndarray):
|
||||||
|
return value.astype(float).reshape(3)
|
||||||
|
if value is None:
|
||||||
|
return np.zeros(3, dtype=float)
|
||||||
|
seq = list(value)
|
||||||
|
if len(seq) < 3:
|
||||||
|
seq = list(seq) + [0.0] * (3 - len(seq))
|
||||||
|
return np.array([float(seq[0]), float(seq[1]), float(seq[2])], dtype=float)
|
||||||
|
|
||||||
|
|
||||||
|
def _to_mat3(value: Any) -> np.ndarray:
|
||||||
|
"""Coerce a saved 3×3 matrix (flat 9-list or nested) to np.ndarray."""
|
||||||
|
if isinstance(value, np.ndarray):
|
||||||
|
arr = value.astype(float)
|
||||||
|
return arr.reshape(3, 3)
|
||||||
|
if value is None:
|
||||||
|
return np.eye(3, dtype=float)
|
||||||
|
flat = list(np.asarray(value, dtype=float).flatten())
|
||||||
|
if len(flat) < 9:
|
||||||
|
flat = flat + [0.0] * (9 - len(flat))
|
||||||
|
return np.array(flat[:9], dtype=float).reshape(3, 3)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_iso(value: Optional[str]) -> datetime:
|
||||||
|
"""Parse an ISO-8601 timestamp, falling back to ``now`` on failure."""
|
||||||
|
if not value:
|
||||||
|
return datetime.now()
|
||||||
|
try:
|
||||||
|
return datetime.fromisoformat(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return datetime.now()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Model serialization (to_dict) ──────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _workplane_to_dict(wp: Workplane) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": wp.id,
|
||||||
|
"name": wp.name,
|
||||||
|
"origin": list(wp.origin),
|
||||||
|
"normal": list(wp.normal),
|
||||||
|
"x_dir": list(wp.x_dir),
|
||||||
|
"visible": bool(wp.visible),
|
||||||
|
"created_at": wp.created_at.isoformat() if wp.created_at else None,
|
||||||
|
"modified_at": wp.modified_at.isoformat() if wp.modified_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _workplane_from_dict(data: Dict[str, Any]) -> Workplane:
|
||||||
|
wp = Workplane(
|
||||||
|
id=data.get("id") or None, # Workplane generates uuid if None
|
||||||
|
name=data.get("name", "Untitled Workplane"),
|
||||||
|
origin=tuple(data.get("origin", (0.0, 0.0, 0.0))),
|
||||||
|
normal=tuple(data.get("normal", (0.0, 0.0, 1.0))),
|
||||||
|
x_dir=tuple(data.get("x_dir", (1.0, 0.0, 0.0))),
|
||||||
|
visible=bool(data.get("visible", True)),
|
||||||
|
)
|
||||||
|
wp.created_at = _parse_iso(data.get("created_at"))
|
||||||
|
wp.modified_at = _parse_iso(data.get("modified_at"))
|
||||||
|
return wp
|
||||||
|
|
||||||
|
|
||||||
|
def _body_to_dict(body: Body) -> Dict[str, Any]:
|
||||||
|
"""Body serialization. ``geometry_ref`` is set later by the ZIP writer
|
||||||
|
once the STEP file is written."""
|
||||||
|
return {
|
||||||
|
"id": body.id,
|
||||||
|
"name": body.name,
|
||||||
|
"source_sketch_id": body.source_sketch.id if body.source_sketch else None,
|
||||||
|
"source_operation": body.source_operation,
|
||||||
|
"extrude_length": body.extrude_length,
|
||||||
|
"extrude_symmetric": body.extrude_symmetric,
|
||||||
|
"extrude_invert": body.extrude_invert,
|
||||||
|
"extrude_cut": body.extrude_cut,
|
||||||
|
"extrude_union": body.extrude_union,
|
||||||
|
"extrude_through_all": body.extrude_through_all,
|
||||||
|
"extrude_face_index": body.extrude_face_index,
|
||||||
|
"extrude_target_body_id": body.extrude_target_body_id,
|
||||||
|
"position": _coerce_listlike(body.position),
|
||||||
|
"rotation": _coerce_listlike(body.rotation),
|
||||||
|
"color": list(body.color) if body.color else [0.2, 0.4, 0.8],
|
||||||
|
"opacity": float(body.opacity),
|
||||||
|
"visible": bool(body.visible),
|
||||||
|
"has_geometry": body.geometry is not None,
|
||||||
|
"geometry_ref": None, # filled in by save_project
|
||||||
|
"created_at": body.created_at.isoformat() if body.created_at else None,
|
||||||
|
"modified_at": body.modified_at.isoformat() if body.modified_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _body_from_dict(
|
||||||
|
data: Dict[str, Any],
|
||||||
|
geometry_loader: Optional[Callable[[str], Optional[OCCGeometryObject]]] = None,
|
||||||
|
source_sketch: Optional[Sketch] = None,
|
||||||
|
) -> Body:
|
||||||
|
geometry: Optional[OCCGeometryObject] = None
|
||||||
|
if geometry_loader is not None and data.get("geometry_ref"):
|
||||||
|
geometry = geometry_loader(data["geometry_ref"]) if data.get("has_geometry") else None
|
||||||
|
|
||||||
|
body = Body(
|
||||||
|
id=data.get("id") or None,
|
||||||
|
name=data.get("name", "Untitled Body"),
|
||||||
|
geometry=geometry,
|
||||||
|
source_sketch=source_sketch,
|
||||||
|
source_operation=data.get("source_operation", "extrude"),
|
||||||
|
extrude_length=data.get("extrude_length"),
|
||||||
|
extrude_symmetric=bool(data.get("extrude_symmetric", False)),
|
||||||
|
extrude_invert=bool(data.get("extrude_invert", False)),
|
||||||
|
extrude_cut=bool(data.get("extrude_cut", False)),
|
||||||
|
extrude_union=bool(data.get("extrude_union", False)),
|
||||||
|
extrude_through_all=bool(data.get("extrude_through_all", False)),
|
||||||
|
extrude_face_index=data.get("extrude_face_index"),
|
||||||
|
extrude_target_body_id=data.get("extrude_target_body_id"),
|
||||||
|
position=_to_3vec(data.get("position")),
|
||||||
|
rotation=_to_mat3(data.get("rotation")),
|
||||||
|
color=tuple(data.get("color", [0.2, 0.4, 0.8])),
|
||||||
|
opacity=float(data.get("opacity", 1.0)),
|
||||||
|
visible=bool(data.get("visible", True)),
|
||||||
|
)
|
||||||
|
body.created_at = _parse_iso(data.get("created_at"))
|
||||||
|
body.modified_at = _parse_iso(data.get("modified_at"))
|
||||||
|
return body
|
||||||
|
|
||||||
|
|
||||||
|
def _sketch_to_dict(sketch: Sketch) -> Dict[str, Any]:
|
||||||
|
occ_dict: Optional[Dict[str, Any]] = None
|
||||||
|
if sketch.occ_sketch is not None and isinstance(sketch.occ_sketch, OCCSketch):
|
||||||
|
try:
|
||||||
|
occ_dict = sketch.occ_sketch.to_dict()
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Sketch %s occ_sketch.to_dict() failed: %s", sketch.id, exc)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": sketch.id,
|
||||||
|
"name": sketch.name,
|
||||||
|
"workplane_origin": _coerce_listlike(sketch.workplane_origin),
|
||||||
|
"workplane_normal": _coerce_listlike(sketch.workplane_normal),
|
||||||
|
"workplane_x_dir": _coerce_listlike(sketch.workplane_x_dir),
|
||||||
|
"is_solved": bool(sketch.is_solved),
|
||||||
|
"is_fully_constrained": bool(sketch.is_fully_constrained),
|
||||||
|
"occ_sketch": occ_dict,
|
||||||
|
"has_geometry": sketch.geometry is not None,
|
||||||
|
"geometry_ref": None, # filled in by save_project
|
||||||
|
"created_at": sketch.created_at.isoformat() if sketch.created_at else None,
|
||||||
|
"modified_at": sketch.modified_at.isoformat() if sketch.modified_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _sketch_from_dict(
|
||||||
|
data: Dict[str, Any],
|
||||||
|
geometry_loader: Optional[Callable[[str], Optional[OCCGeometryObject]]] = None,
|
||||||
|
) -> Sketch:
|
||||||
|
occ_dict = data.get("occ_sketch")
|
||||||
|
occ_sketch: Optional[OCCSketch] = None
|
||||||
|
if occ_dict is not None:
|
||||||
|
try:
|
||||||
|
occ_sketch = OCCSketch.from_dict(occ_dict)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Sketch %s OCCSketch.from_dict() failed: %s", data.get("id"), exc)
|
||||||
|
occ_sketch = OCCSketch()
|
||||||
|
else:
|
||||||
|
occ_sketch = OCCSketch()
|
||||||
|
|
||||||
|
# Re-apply the workplane (from_dict already does this internally, but be
|
||||||
|
# defensive in case the saved dict didn't carry the workplane fields).
|
||||||
|
occ_sketch.set_workplane(
|
||||||
|
tuple(data.get("workplane_origin", (0.0, 0.0, 0.0))),
|
||||||
|
tuple(data.get("workplane_normal", (0.0, 0.0, 1.0))),
|
||||||
|
tuple(data.get("workplane_x_dir", (1.0, 0.0, 0.0))),
|
||||||
|
)
|
||||||
|
|
||||||
|
geometry: Optional[OCCGeometryObject] = None
|
||||||
|
if geometry_loader is not None and data.get("geometry_ref"):
|
||||||
|
geometry = geometry_loader(data["geometry_ref"]) if data.get("has_geometry") else None
|
||||||
|
|
||||||
|
sk = Sketch(
|
||||||
|
id=data.get("id") or None,
|
||||||
|
name=data.get("name", "Untitled Sketch"),
|
||||||
|
occ_sketch=occ_sketch,
|
||||||
|
geometry=geometry,
|
||||||
|
is_solved=bool(data.get("is_solved", False)),
|
||||||
|
is_fully_constrained=bool(data.get("is_fully_constrained", False)),
|
||||||
|
)
|
||||||
|
sk.workplane_origin = _to_3vec(data.get("workplane_origin"))
|
||||||
|
sk.workplane_normal = _to_3vec(data.get("workplane_normal"))
|
||||||
|
sk.workplane_x_dir = _to_3vec(data.get("workplane_x_dir"))
|
||||||
|
sk.apply_workplane()
|
||||||
|
sk.created_at = _parse_iso(data.get("created_at"))
|
||||||
|
sk.modified_at = _parse_iso(data.get("modified_at"))
|
||||||
|
return sk
|
||||||
|
|
||||||
|
|
||||||
|
def _component_to_dict(comp: Component) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": comp.id,
|
||||||
|
"name": comp.name,
|
||||||
|
"description": comp.description,
|
||||||
|
"active_sketch": comp.active_sketch,
|
||||||
|
"active_workplane": comp.active_workplane,
|
||||||
|
"sketches": {sid: _sketch_to_dict(s) for sid, s in comp.sketches.items()},
|
||||||
|
"bodies": {bid: _body_to_dict(b) for bid, b in comp.bodies.items()},
|
||||||
|
"workplanes": {wid: _workplane_to_dict(w) for wid, w in comp.workplanes.items()},
|
||||||
|
"created_at": comp.created_at.isoformat() if comp.created_at else None,
|
||||||
|
"modified_at": comp.modified_at.isoformat() if comp.modified_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _component_from_dict(
|
||||||
|
data: Dict[str, Any],
|
||||||
|
body_geometry_loader: Optional[Callable[[str], Optional[OCCGeometryObject]]] = None,
|
||||||
|
sketch_geometry_loader: Optional[Callable[[str], Optional[OCCGeometryObject]]] = None,
|
||||||
|
) -> Component:
|
||||||
|
comp = Component(
|
||||||
|
id=data.get("id") or None,
|
||||||
|
name=data.get("name", "Untitled Component"),
|
||||||
|
description=data.get("description", ""),
|
||||||
|
active_sketch=data.get("active_sketch"),
|
||||||
|
active_workplane=data.get("active_workplane"),
|
||||||
|
)
|
||||||
|
comp.created_at = _parse_iso(data.get("created_at"))
|
||||||
|
comp.modified_at = _parse_iso(data.get("modified_at"))
|
||||||
|
|
||||||
|
for wid, wp_data in (data.get("workplanes") or {}).items():
|
||||||
|
comp.workplanes[wid] = _workplane_from_dict(wp_data)
|
||||||
|
|
||||||
|
# Sketches first so bodies can reference them.
|
||||||
|
for sid, sk_data in (data.get("sketches") or {}).items():
|
||||||
|
comp.sketches[sid] = _sketch_from_dict(sk_data, sketch_geometry_loader)
|
||||||
|
|
||||||
|
for bid, body_data in (data.get("bodies") or {}).items():
|
||||||
|
src_sketch = None
|
||||||
|
src_id = body_data.get("source_sketch_id")
|
||||||
|
if src_id and src_id in comp.sketches:
|
||||||
|
src_sketch = comp.sketches[src_id]
|
||||||
|
comp.bodies[bid] = _body_from_dict(body_data, body_geometry_loader, src_sketch)
|
||||||
|
|
||||||
|
return comp
|
||||||
|
|
||||||
|
|
||||||
|
def _connector_to_dict(conn: Connector) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": conn.id,
|
||||||
|
"name": conn.name,
|
||||||
|
"position": list(conn.position),
|
||||||
|
"normal": list(conn.normal),
|
||||||
|
"x_dir": list(conn.x_dir),
|
||||||
|
"axis_rotation": float(conn.axis_rotation),
|
||||||
|
"offset": float(conn.offset),
|
||||||
|
"assembly_component_id": conn.assembly_component_id,
|
||||||
|
"source_obj_id": conn.source_obj_id,
|
||||||
|
"partner_ac_id": conn.partner_ac_id,
|
||||||
|
"partner_connector_id": conn.partner_connector_id,
|
||||||
|
"is_grounded": bool(conn.is_grounded),
|
||||||
|
"is_invalid": bool(conn.is_invalid),
|
||||||
|
"created_at": conn.created_at.isoformat() if conn.created_at else None,
|
||||||
|
"modified_at": conn.modified_at.isoformat() if conn.modified_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _connector_from_dict(data: Dict[str, Any]) -> Connector:
|
||||||
|
conn = Connector(
|
||||||
|
id=data.get("id") or None,
|
||||||
|
name=data.get("name", "Untitled Connector"),
|
||||||
|
position=_to_3tuple(data.get("position")),
|
||||||
|
normal=_to_3tuple(data.get("normal")),
|
||||||
|
x_dir=_to_3tuple(data.get("x_dir")),
|
||||||
|
axis_rotation=float(data.get("axis_rotation", 0.0)),
|
||||||
|
offset=float(data.get("offset", 0.0)),
|
||||||
|
assembly_component_id=data.get("assembly_component_id", ""),
|
||||||
|
source_obj_id=data.get("source_obj_id", ""),
|
||||||
|
)
|
||||||
|
conn.partner_ac_id = data.get("partner_ac_id")
|
||||||
|
conn.partner_connector_id = data.get("partner_connector_id")
|
||||||
|
conn.is_grounded = bool(data.get("is_grounded", False))
|
||||||
|
conn.is_invalid = bool(data.get("is_invalid", False))
|
||||||
|
conn.created_at = _parse_iso(data.get("created_at"))
|
||||||
|
conn.modified_at = _parse_iso(data.get("modified_at"))
|
||||||
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
def _assembly_component_to_dict(ac: AssemblyComponent) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": ac.id,
|
||||||
|
"component_id": ac.component_id,
|
||||||
|
"name": ac.name,
|
||||||
|
"position": _coerce_listlike(ac.position),
|
||||||
|
"rotation": _coerce_listlike(ac.rotation),
|
||||||
|
"connectors": {cid: _connector_to_dict(c) for cid, c in ac.connectors.items()},
|
||||||
|
"created_at": ac.created_at.isoformat() if ac.created_at else None,
|
||||||
|
"modified_at": ac.modified_at.isoformat() if ac.modified_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _assembly_component_from_dict(data: Dict[str, Any]) -> AssemblyComponent:
|
||||||
|
ac = AssemblyComponent(
|
||||||
|
id=data.get("id") or None,
|
||||||
|
component_id=data.get("component_id", ""),
|
||||||
|
name=data.get("name", "Untitled Instance"),
|
||||||
|
position=_to_3vec(data.get("position")),
|
||||||
|
rotation=_to_mat3(data.get("rotation")),
|
||||||
|
)
|
||||||
|
ac.created_at = _parse_iso(data.get("created_at"))
|
||||||
|
ac.modified_at = _parse_iso(data.get("modified_at"))
|
||||||
|
for cid, c_data in (data.get("connectors") or {}).items():
|
||||||
|
ac.connectors[cid] = _connector_from_dict(c_data)
|
||||||
|
return ac
|
||||||
|
|
||||||
|
|
||||||
|
def _assembly_connection_to_dict(c: AssemblyConnection) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": c.id,
|
||||||
|
"first_ac_id": c.first_ac_id,
|
||||||
|
"second_ac_id": c.second_ac_id,
|
||||||
|
"first_connector_id": c.first_connector_id,
|
||||||
|
"second_connector_id": c.second_connector_id,
|
||||||
|
"created_at": c.created_at.isoformat() if c.created_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _assembly_connection_from_dict(data: Dict[str, Any]) -> AssemblyConnection:
|
||||||
|
conn = AssemblyConnection(
|
||||||
|
id=data.get("id") or None,
|
||||||
|
first_ac_id=data.get("first_ac_id", ""),
|
||||||
|
second_ac_id=data.get("second_ac_id", ""),
|
||||||
|
first_connector_id=data.get("first_connector_id"),
|
||||||
|
second_connector_id=data.get("second_connector_id"),
|
||||||
|
)
|
||||||
|
conn.created_at = _parse_iso(data.get("created_at"))
|
||||||
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
def _assembly_to_dict(asm: Assembly) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": asm.id,
|
||||||
|
"name": asm.name,
|
||||||
|
"active_assembly_component": asm.active_assembly_component,
|
||||||
|
"components": {cid: _assembly_component_to_dict(ac) for cid, ac in asm.components.items()},
|
||||||
|
"connections": [_assembly_connection_to_dict(c) for c in asm.connections],
|
||||||
|
"created_at": asm.created_at.isoformat() if asm.created_at else None,
|
||||||
|
"modified_at": asm.modified_at.isoformat() if asm.modified_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _assembly_from_dict(data: Dict[str, Any]) -> Assembly:
|
||||||
|
asm = Assembly(
|
||||||
|
id=data.get("id") or None,
|
||||||
|
name=data.get("name", "Untitled Assembly"),
|
||||||
|
active_assembly_component=data.get("active_assembly_component"),
|
||||||
|
)
|
||||||
|
asm.created_at = _parse_iso(data.get("created_at"))
|
||||||
|
asm.modified_at = _parse_iso(data.get("modified_at"))
|
||||||
|
for cid, ac_data in (data.get("components") or {}).items():
|
||||||
|
asm.components[cid] = _assembly_component_from_dict(ac_data)
|
||||||
|
for c_data in (data.get("connections") or []):
|
||||||
|
asm.connections.append(_assembly_connection_from_dict(c_data))
|
||||||
|
return asm
|
||||||
|
|
||||||
|
|
||||||
|
def _project_to_dict(
|
||||||
|
project: Project,
|
||||||
|
view_state: Optional[Dict[str, Any]] = None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"format_version": 1,
|
||||||
|
"name": project.name,
|
||||||
|
"description": project.description,
|
||||||
|
"active_component": project.active_component,
|
||||||
|
"active_assembly": project.active_assembly,
|
||||||
|
"components": {cid: _component_to_dict(c) for cid, c in project.components.items()},
|
||||||
|
"assemblies": {aid: _assembly_to_dict(a) for aid, a in project.assemblies.items()},
|
||||||
|
"created_at": project.created_at.isoformat() if project.created_at else None,
|
||||||
|
"modified_at": project.modified_at.isoformat() if project.modified_at else None,
|
||||||
|
"view_state": view_state or {},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Geometry (STEP) write/read helpers ─────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _write_step_for_body(
|
||||||
|
kernel: OCGeometryKernel,
|
||||||
|
geometry: OCCGeometryObject,
|
||||||
|
) -> Optional[bytes]:
|
||||||
|
"""Serialize a single body geometry to a STEP byte string.
|
||||||
|
|
||||||
|
Returns *None* if OCC reports the shape is empty (so the ZIP can omit
|
||||||
|
the file and the body is restored as geometry-less). The temporary
|
||||||
|
file is created and immediately deleted; we never touch the user's
|
||||||
|
filesystem outside of ``tempfile``.
|
||||||
|
"""
|
||||||
|
fd, tmp_path = tempfile.mkstemp(suffix=".step")
|
||||||
|
os.close(fd)
|
||||||
|
try:
|
||||||
|
ok = kernel.export_step(geometry, tmp_path)
|
||||||
|
if not ok:
|
||||||
|
return None
|
||||||
|
with open(tmp_path, "rb") as f:
|
||||||
|
return f.read()
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
os.unlink(tmp_path)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _read_step_bytes(
|
||||||
|
kernel: OCGeometryKernel,
|
||||||
|
data: bytes,
|
||||||
|
) -> Optional[OCCGeometryObject]:
|
||||||
|
"""Parse a STEP byte string back into an OCCGeometryObject."""
|
||||||
|
fd, tmp_path = tempfile.mkstemp(suffix=".step")
|
||||||
|
os.close(fd)
|
||||||
|
try:
|
||||||
|
with open(tmp_path, "wb") as f:
|
||||||
|
f.write(data)
|
||||||
|
geom = kernel.import_step(tmp_path)
|
||||||
|
return geom
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Failed to read STEP: %s", exc)
|
||||||
|
return None
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
os.unlink(tmp_path)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# ── Save / Load entry points ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def project_zip_path(path: str) -> str:
|
||||||
|
"""Return *path* with the ``.fluency`` extension added if missing."""
|
||||||
|
base, ext = os.path.splitext(path)
|
||||||
|
if ext.lower() == ".fluency":
|
||||||
|
return path
|
||||||
|
return base + ".fluency"
|
||||||
|
|
||||||
|
|
||||||
|
def save_project(
|
||||||
|
project: Project,
|
||||||
|
filepath: str,
|
||||||
|
view_state: Optional[Dict[str, Any]] = None,
|
||||||
|
kernel: Optional[OCGeometryKernel] = None,
|
||||||
|
) -> str:
|
||||||
|
"""Save *project* to a ``.fluency`` ZIP at *filepath*.
|
||||||
|
|
||||||
|
*view_state* (optional) is a free-form dict that the main window uses to
|
||||||
|
record camera position, active tab, etc. It is stored verbatim inside
|
||||||
|
``project.json`` under the ``view_state`` key.
|
||||||
|
|
||||||
|
*kernel* is the OCGeometryKernel to use for STEP export. A new one is
|
||||||
|
created if not provided (slightly slower startup, but never holds stale
|
||||||
|
state). Pass the app's kernel to keep one canonical instance.
|
||||||
|
|
||||||
|
Returns the actual file path that was written.
|
||||||
|
"""
|
||||||
|
filepath = project_zip_path(filepath)
|
||||||
|
kernel = kernel or OCGeometryKernel()
|
||||||
|
|
||||||
|
# Build the manifest in two passes:
|
||||||
|
# pass 1: serialize all metadata + collect body/sketches that need
|
||||||
|
# STEP files written alongside. We track the in-zip path of
|
||||||
|
# each STEP file in the body/sketches' ``geometry_ref`` slot.
|
||||||
|
# pass 2: write the ZIP, streaming each body/sketches's STEP data
|
||||||
|
# into its own archive member.
|
||||||
|
manifest = _project_to_dict(project, view_state)
|
||||||
|
|
||||||
|
# Per-body STEP files. Skipped if the body has no geometry.
|
||||||
|
body_files: List[Tuple[str, bytes]] = []
|
||||||
|
for comp_id, comp in project.components.items():
|
||||||
|
for body_id, body in comp.bodies.items():
|
||||||
|
if body.geometry is None:
|
||||||
|
continue
|
||||||
|
step_bytes = _write_step_for_body(kernel, body.geometry)
|
||||||
|
if step_bytes is None:
|
||||||
|
continue
|
||||||
|
arcname = f"bodies/{body_id}.step"
|
||||||
|
body_files.append((arcname, step_bytes))
|
||||||
|
manifest["components"][comp_id]["bodies"][body_id]["geometry_ref"] = arcname
|
||||||
|
|
||||||
|
# Per-sketch STEP files (solved face geometry).
|
||||||
|
sketch_files: List[Tuple[str, bytes]] = []
|
||||||
|
sketch_meta_files: List[Tuple[str, bytes]] = []
|
||||||
|
for comp_id, comp in project.components.items():
|
||||||
|
for sketch_id, sketch in comp.sketches.items():
|
||||||
|
# Save the OCCSketch state to its own JSON file so the
|
||||||
|
# main project.json stays compact.
|
||||||
|
occ = sketch.occ_sketch.to_dict() if sketch.occ_sketch is not None else None
|
||||||
|
meta = {
|
||||||
|
"id": sketch.id,
|
||||||
|
"name": sketch.name,
|
||||||
|
"workplane_origin": _coerce_listlike(sketch.workplane_origin),
|
||||||
|
"workplane_normal": _coerce_listlike(sketch.workplane_normal),
|
||||||
|
"workplane_x_dir": _coerce_listlike(sketch.workplane_x_dir),
|
||||||
|
"is_solved": bool(sketch.is_solved),
|
||||||
|
"is_fully_constrained": bool(sketch.is_fully_constrained),
|
||||||
|
"occ_sketch": occ,
|
||||||
|
}
|
||||||
|
meta_arc = f"sketches/{sketch_id}/meta.json"
|
||||||
|
sketch_meta_files.append((meta_arc, _to_json(meta).encode("utf-8")))
|
||||||
|
# Drop the heavy occ_sketch payload from the main manifest so
|
||||||
|
# the file is smaller and edits are localised.
|
||||||
|
manifest["components"][comp_id]["sketches"][sketch_id]["occ_sketch"] = None
|
||||||
|
manifest["components"][comp_id]["sketches"][sketch_id]["occ_sketch_ref"] = meta_arc
|
||||||
|
|
||||||
|
if sketch.geometry is None:
|
||||||
|
continue
|
||||||
|
step_bytes = _write_step_for_body(kernel, sketch.geometry)
|
||||||
|
if step_bytes is None:
|
||||||
|
continue
|
||||||
|
arcname = f"sketches/{sketch_id}/solved.step"
|
||||||
|
sketch_files.append((arcname, step_bytes))
|
||||||
|
manifest["components"][comp_id]["sketches"][sketch_id]["geometry_ref"] = arcname
|
||||||
|
|
||||||
|
# Write the ZIP. Use a temp file + rename so a partial write can't
|
||||||
|
# clobber an existing good file.
|
||||||
|
tmp_fd, tmp_path = tempfile.mkstemp(suffix=".fluency")
|
||||||
|
os.close(tmp_fd)
|
||||||
|
try:
|
||||||
|
with zipfile.ZipFile(tmp_path, "w", compression=zipfile.ZIP_DEFLATED) as zf:
|
||||||
|
zf.writestr("project.json", _to_json(manifest))
|
||||||
|
for arcname, data in body_files + sketch_files + sketch_meta_files:
|
||||||
|
zf.writestr(arcname, data)
|
||||||
|
# Atomic-ish replace.
|
||||||
|
shutil.move(tmp_path, filepath)
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
os.unlink(tmp_path)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
raise
|
||||||
|
|
||||||
|
return filepath
|
||||||
|
|
||||||
|
|
||||||
|
def load_project(filepath: str) -> Tuple[Project, Dict[str, Any]]:
|
||||||
|
"""Load a project from a ``.fluency`` ZIP.
|
||||||
|
|
||||||
|
Returns ``(project, view_state)``. The caller is responsible for
|
||||||
|
handing *view_state* to the renderer (camera, etc.) and for re-rendering
|
||||||
|
the scene with the freshly-loaded bodies.
|
||||||
|
"""
|
||||||
|
if not os.path.exists(filepath):
|
||||||
|
raise FileNotFoundError(filepath)
|
||||||
|
|
||||||
|
kernel = OCGeometryKernel()
|
||||||
|
body_cache: Dict[str, Optional[OCCGeometryObject]] = {}
|
||||||
|
|
||||||
|
# Body geometry can be reused across bodies if the same STEP appears
|
||||||
|
# under multiple names (rare, but cheap to handle). We cache by zip
|
||||||
|
# member name.
|
||||||
|
def body_geometry_loader(member_name: str) -> Optional[OCCGeometryObject]:
|
||||||
|
if member_name in body_cache:
|
||||||
|
return body_cache[member_name]
|
||||||
|
try:
|
||||||
|
data = zipf.read(member_name)
|
||||||
|
except KeyError:
|
||||||
|
logger.warning("Body STEP missing in archive: %s", member_name)
|
||||||
|
body_cache[member_name] = None
|
||||||
|
return None
|
||||||
|
geom = _read_step_bytes(kernel, data)
|
||||||
|
body_cache[member_name] = geom
|
||||||
|
return geom
|
||||||
|
|
||||||
|
# Sketch geometry loader shares the same byte path. Sketches that have
|
||||||
|
# solved faces point at sketches/<id>/solved.step.
|
||||||
|
def sketch_geometry_loader(member_name: str) -> Optional[OCCGeometryObject]:
|
||||||
|
return body_geometry_loader(member_name)
|
||||||
|
|
||||||
|
with zipfile.ZipFile(filepath, "r") as zipf:
|
||||||
|
manifest_raw = zipf.read("project.json")
|
||||||
|
manifest = json.loads(manifest_raw.decode("utf-8"))
|
||||||
|
view_state: Dict[str, Any] = manifest.get("view_state") or {}
|
||||||
|
|
||||||
|
# If a sketch's occ_sketch is referenced as a separate file, read
|
||||||
|
# it in now and patch the manifest so _sketch_from_dict sees it.
|
||||||
|
for comp_id, comp_data in (manifest.get("components") or {}).items():
|
||||||
|
for sk_id, sk_data in (comp_data.get("sketches") or {}).items():
|
||||||
|
ref = sk_data.get("occ_sketch_ref")
|
||||||
|
if not ref:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
meta_bytes = zipf.read(ref)
|
||||||
|
except KeyError:
|
||||||
|
logger.warning("Sketch meta missing in archive: %s", ref)
|
||||||
|
continue
|
||||||
|
meta = json.loads(meta_bytes.decode("utf-8"))
|
||||||
|
sk_data["occ_sketch"] = meta.get("occ_sketch")
|
||||||
|
# Workplane fields on the sketch-level file override the
|
||||||
|
# embedded ones (source of truth lives in the sidecar).
|
||||||
|
for k in ("workplane_origin", "workplane_normal", "workplane_x_dir",
|
||||||
|
"is_solved", "is_fully_constrained"):
|
||||||
|
if k in meta:
|
||||||
|
sk_data[k] = meta[k]
|
||||||
|
|
||||||
|
project = Project(
|
||||||
|
name=manifest.get("name", "Untitled Project"),
|
||||||
|
description=manifest.get("description", ""),
|
||||||
|
active_component=manifest.get("active_component"),
|
||||||
|
active_assembly=manifest.get("active_assembly"),
|
||||||
|
kernel=kernel,
|
||||||
|
)
|
||||||
|
project.file_path = filepath
|
||||||
|
project.created_at = _parse_iso(manifest.get("created_at"))
|
||||||
|
project.modified_at = _parse_iso(manifest.get("modified_at"))
|
||||||
|
|
||||||
|
for cid, c_data in (manifest.get("components") or {}).items():
|
||||||
|
project.components[cid] = _component_from_dict(
|
||||||
|
c_data,
|
||||||
|
body_geometry_loader=body_geometry_loader,
|
||||||
|
sketch_geometry_loader=sketch_geometry_loader,
|
||||||
|
)
|
||||||
|
|
||||||
|
for aid, a_data in (manifest.get("assemblies") or {}).items():
|
||||||
|
project.assemblies[aid] = _assembly_from_dict(a_data)
|
||||||
|
|
||||||
|
# After all components are loaded, re-wire connector partner ids so
|
||||||
|
# they point to the freshly-loaded AssemblyComponents. (The dict
|
||||||
|
# round-trip preserves the raw strings; we just make sure the partner
|
||||||
|
# ids are still present in the project so the assembly-move handler
|
||||||
|
# can follow the rigid-group graph.)
|
||||||
|
for asm in project.assemblies.values():
|
||||||
|
for conn in asm.connections:
|
||||||
|
if conn.first_connector_id and conn.first_ac_id in asm.components:
|
||||||
|
first_ac = asm.components[conn.first_ac_id]
|
||||||
|
if conn.first_connector_id in first_ac.connectors:
|
||||||
|
first_ac.connectors[conn.first_connector_id].is_grounded = True
|
||||||
|
if conn.second_connector_id and conn.second_ac_id in asm.components:
|
||||||
|
pass # already set in the connector itself
|
||||||
|
|
||||||
|
return project, view_state
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
"""Fluency CAD - Main entry point.
|
||||||
|
|
||||||
|
This module is intentionally thin. The actual UI lives in the
|
||||||
|
``fluency.ui`` package:
|
||||||
|
|
||||||
|
ui.dialogs – 4 modal dialogs (Extrude, Revolve, Offset, WorkplaneOrientation)
|
||||||
|
ui.viewer_widget – Viewer3DWidget (3D canvas)
|
||||||
|
ui.sketch_widget – Sketch2DWidget (2D sketcher + constraint solver)
|
||||||
|
ui.main_window – MainWindow (application shell)
|
||||||
|
|
||||||
|
The public classes are re-exported here so that existing call sites
|
||||||
|
that do ``from fluency.main import MainWindow`` (notably
|
||||||
|
``tests/test_geometry.py``) keep working.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from PySide6.QtWidgets import QApplication
|
||||||
|
|
||||||
|
from fluency.ui.dialogs import (
|
||||||
|
ExtrudeDialog,
|
||||||
|
OffsetDialog,
|
||||||
|
RevolveDialog,
|
||||||
|
WorkplaneOrientationDialog,
|
||||||
|
)
|
||||||
|
from fluency.ui.main_window import MainWindow
|
||||||
|
from fluency.ui.sketch_widget import Sketch2DWidget
|
||||||
|
from fluency.ui.viewer_widget import Viewer3DWidget
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"MainWindow",
|
||||||
|
"Sketch2DWidget",
|
||||||
|
"Viewer3DWidget",
|
||||||
|
"ExtrudeDialog",
|
||||||
|
"RevolveDialog",
|
||||||
|
"OffsetDialog",
|
||||||
|
"WorkplaneOrientationDialog",
|
||||||
|
"main",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
"""Launch the Fluency CAD application.
|
||||||
|
|
||||||
|
Returns the ``QApplication.exec()`` exit code so that the console-script
|
||||||
|
entry point declared in ``pyproject.toml`` can forward it.
|
||||||
|
"""
|
||||||
|
app = QApplication(sys.argv)
|
||||||
|
app.setStyle("Fusion")
|
||||||
|
|
||||||
|
window = MainWindow()
|
||||||
|
window.show()
|
||||||
|
|
||||||
|
return app.exec()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
"""Models module."""
|
||||||
|
|
||||||
|
from fluency.models.data_model import (
|
||||||
|
Project,
|
||||||
|
Component,
|
||||||
|
Sketch,
|
||||||
|
Body,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"Project",
|
||||||
|
"Component",
|
||||||
|
"Sketch",
|
||||||
|
"Body",
|
||||||
|
]
|
||||||
@@ -0,0 +1,802 @@
|
|||||||
|
"""
|
||||||
|
Data models for Fluency CAD.
|
||||||
|
|
||||||
|
This module defines the core data structures for the CAD application
|
||||||
|
including projects, components, sketches, and bodies.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Dict, List, Optional, Any, Tuple
|
||||||
|
from datetime import datetime
|
||||||
|
import uuid
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from fluency.geometry.base import (
|
||||||
|
Point2D,
|
||||||
|
Point3D,
|
||||||
|
GeometryObject,
|
||||||
|
SketchInterface,
|
||||||
|
)
|
||||||
|
from fluency.geometry_occ.kernel import OCGeometryKernel, OCCGeometryObject
|
||||||
|
from fluency.geometry_occ.sketch import OCCSketch
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Workplane:
|
||||||
|
"""
|
||||||
|
An independent working plane (datum plane) not tied to a face.
|
||||||
|
|
||||||
|
Workplanes can be created at any time and serve as the foundation for
|
||||||
|
sketching and subsequent 3D operations (extrude, cut, revolve, etc.).
|
||||||
|
They are visible in the 3D view as a semi-transparent reference grid.
|
||||||
|
"""
|
||||||
|
|
||||||
|
id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
||||||
|
name: str = "Untitled Workplane"
|
||||||
|
|
||||||
|
origin: Tuple[float, float, float] = (0.0, 0.0, 0.0)
|
||||||
|
normal: Tuple[float, float, float] = (0.0, 0.0, 1.0)
|
||||||
|
x_dir: Tuple[float, float, float] = (1.0, 0.0, 0.0)
|
||||||
|
|
||||||
|
# OCC AIS shape (visual plane) object id in the renderer
|
||||||
|
render_object: Any = None
|
||||||
|
visible: bool = True
|
||||||
|
|
||||||
|
created_at: datetime = field(default_factory=datetime.now)
|
||||||
|
modified_at: datetime = field(default_factory=datetime.now)
|
||||||
|
|
||||||
|
def __post_init__(self):
|
||||||
|
# Normalise normal and x_dir on construction.
|
||||||
|
import numpy as np
|
||||||
|
n = np.asarray(self.normal, dtype=float)
|
||||||
|
n = n / np.linalg.norm(n)
|
||||||
|
x = np.asarray(self.x_dir, dtype=float)
|
||||||
|
# Remove any component of x along n, then renormalise.
|
||||||
|
x = x - np.dot(x, n) * n
|
||||||
|
x_norm = np.linalg.norm(x)
|
||||||
|
if x_norm < 1e-9:
|
||||||
|
fallback = np.array([1.0, 0.0, 0.0]) if abs(n[0]) < 0.9 else np.array([0.0, 1.0, 0.0])
|
||||||
|
x = fallback - np.dot(fallback, n) * n
|
||||||
|
x_norm = np.linalg.norm(x)
|
||||||
|
x = x / x_norm
|
||||||
|
y = np.cross(n, x)
|
||||||
|
y = y / np.linalg.norm(y)
|
||||||
|
self.normal = tuple(float(v) for v in n)
|
||||||
|
self.x_dir = tuple(float(v) for v in x)
|
||||||
|
self._y_dir = tuple(float(v) for v in y)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def y_dir(self) -> Tuple[float, float, float]:
|
||||||
|
"""Derived in-plane Y axis (normal × x_dir)."""
|
||||||
|
return self._y_dir
|
||||||
|
|
||||||
|
def uv_to_world(self, u: float, v: float) -> Tuple[float, float, float]:
|
||||||
|
"""Map a UV point to 3D world coordinates on this plane."""
|
||||||
|
ox, oy, oz = self.origin
|
||||||
|
xx, xy, xz = self.x_dir
|
||||||
|
yx, yy, yz = self._y_dir
|
||||||
|
return (
|
||||||
|
ox + u * xx + v * yx,
|
||||||
|
oy + u * xy + v * yy,
|
||||||
|
oz + u * xz + v * yz,
|
||||||
|
)
|
||||||
|
|
||||||
|
def world_to_uv(self, p: Tuple[float, float, float]) -> Tuple[float, float]:
|
||||||
|
"""Map a 3D world point to UV coordinates on this plane."""
|
||||||
|
import numpy as np
|
||||||
|
ox, oy, oz = self.origin
|
||||||
|
v = np.array([p[0] - ox, p[1] - oy, p[2] - oz])
|
||||||
|
xd = np.array(self.x_dir, dtype=float)
|
||||||
|
yd = np.array(self._y_dir, dtype=float)
|
||||||
|
return (float(np.dot(v, xd)), float(np.dot(v, yd)))
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Sketch:
|
||||||
|
"""
|
||||||
|
2D sketch with constraints.
|
||||||
|
|
||||||
|
A sketch contains 2D geometry on a workplane that can be
|
||||||
|
extruded or revolved to create 3D bodies.
|
||||||
|
"""
|
||||||
|
|
||||||
|
id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
||||||
|
name: str = "Untitled Sketch"
|
||||||
|
|
||||||
|
workplane_origin: np.ndarray = field(default_factory=lambda: np.array([0.0, 0.0, 0.0]))
|
||||||
|
workplane_normal: np.ndarray = field(default_factory=lambda: np.array([0.0, 0.0, 1.0]))
|
||||||
|
workplane_x_dir: np.ndarray = field(default_factory=lambda: np.array([1.0, 0.0, 0.0]))
|
||||||
|
|
||||||
|
occ_sketch: Optional[OCCSketch] = field(default_factory=OCCSketch)
|
||||||
|
geometry: Optional[OCCGeometryObject] = None
|
||||||
|
|
||||||
|
is_solved: bool = False
|
||||||
|
is_fully_constrained: bool = False
|
||||||
|
|
||||||
|
created_at: datetime = field(default_factory=datetime.now)
|
||||||
|
modified_at: datetime = field(default_factory=datetime.now)
|
||||||
|
|
||||||
|
def set_workplane(
|
||||||
|
self,
|
||||||
|
origin: Tuple[float, float, float],
|
||||||
|
normal: Tuple[float, float, float],
|
||||||
|
x_dir: Tuple[float, float, float],
|
||||||
|
) -> None:
|
||||||
|
"""Set this sketch's 3D workplane and sync it to the OCC sketch.
|
||||||
|
|
||||||
|
Call this when the sketch is placed on a face/datum plane. UV
|
||||||
|
coordinates are unchanged; only their world mapping moves.
|
||||||
|
"""
|
||||||
|
self.workplane_origin = np.asarray(origin, dtype=float)
|
||||||
|
self.workplane_normal = np.asarray(normal, dtype=float)
|
||||||
|
self.workplane_x_dir = np.asarray(x_dir, dtype=float)
|
||||||
|
self.apply_workplane()
|
||||||
|
self.modified_at = datetime.now()
|
||||||
|
|
||||||
|
def apply_workplane(self) -> None:
|
||||||
|
"""Push the stored workplane fields into the underlying OCCSketch."""
|
||||||
|
if self.occ_sketch is not None:
|
||||||
|
self.occ_sketch.set_workplane(
|
||||||
|
tuple(self.workplane_origin.tolist()),
|
||||||
|
tuple(self.workplane_normal.tolist()),
|
||||||
|
tuple(self.workplane_x_dir.tolist()),
|
||||||
|
)
|
||||||
|
|
||||||
|
def add_point(self, x: float, y: float) -> Any:
|
||||||
|
"""Add a point to the sketch."""
|
||||||
|
self.modified_at = datetime.now()
|
||||||
|
self.is_solved = False
|
||||||
|
if self.occ_sketch:
|
||||||
|
return self.occ_sketch.add_point(x, y)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def add_line(self, start: Any, end: Any) -> Any:
|
||||||
|
"""Add a line to the sketch."""
|
||||||
|
self.modified_at = datetime.now()
|
||||||
|
self.is_solved = False
|
||||||
|
if self.occ_sketch:
|
||||||
|
return self.occ_sketch.add_line(start, end)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def add_circle(self, center: Any, radius: float) -> Any:
|
||||||
|
"""Add a circle to the sketch."""
|
||||||
|
self.modified_at = datetime.now()
|
||||||
|
self.is_solved = False
|
||||||
|
if self.occ_sketch:
|
||||||
|
return self.occ_sketch.add_circle(center, radius)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def add_rectangle(self, corner1: tuple, corner2: tuple) -> List[Any]:
|
||||||
|
"""Add a rectangle to the sketch."""
|
||||||
|
self.modified_at = datetime.now()
|
||||||
|
self.is_solved = False
|
||||||
|
if self.occ_sketch:
|
||||||
|
return self.occ_sketch.add_rectangle(corner1, corner2)
|
||||||
|
return []
|
||||||
|
|
||||||
|
def solve(self) -> bool:
|
||||||
|
"""Solve all constraints."""
|
||||||
|
if self.occ_sketch:
|
||||||
|
result = self.occ_sketch.solve()
|
||||||
|
self.is_solved = result
|
||||||
|
self.is_fully_constrained = self.occ_sketch.is_fully_constrained()
|
||||||
|
self.modified_at = datetime.now()
|
||||||
|
return result
|
||||||
|
return False
|
||||||
|
|
||||||
|
def get_geometry(self) -> Optional[GeometryObject]:
|
||||||
|
"""Get the solved geometry."""
|
||||||
|
if self.occ_sketch:
|
||||||
|
return self.occ_sketch.get_geometry()
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_polygon_points(self) -> List[Point2D]:
|
||||||
|
"""Get ordered polygon points."""
|
||||||
|
if self.occ_sketch:
|
||||||
|
return self.occ_sketch.get_polygon_points()
|
||||||
|
return []
|
||||||
|
|
||||||
|
def clear(self) -> None:
|
||||||
|
"""Clear all geometry."""
|
||||||
|
if self.occ_sketch:
|
||||||
|
self.occ_sketch.clear()
|
||||||
|
self.geometry = None
|
||||||
|
self.is_solved = False
|
||||||
|
self.is_fully_constrained = False
|
||||||
|
self.modified_at = datetime.now()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Body:
|
||||||
|
"""
|
||||||
|
3D solid body.
|
||||||
|
|
||||||
|
A body is created from a sketch through operations like
|
||||||
|
extrude, revolve, loft, or sweep.
|
||||||
|
"""
|
||||||
|
|
||||||
|
id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
||||||
|
name: str = "Untitled Body"
|
||||||
|
|
||||||
|
geometry: Optional[OCCGeometryObject] = None
|
||||||
|
source_sketch: Optional[Sketch] = None
|
||||||
|
source_operation: str = "extrude"
|
||||||
|
|
||||||
|
# Re-extrusion parameters — stored so the body can be rebuilt from
|
||||||
|
# its source sketch when the sketch is edited. None means the body
|
||||||
|
# was not created by an extrude-type operation and cannot be auto-
|
||||||
|
# rebuilt.
|
||||||
|
extrude_length: Optional[float] = None
|
||||||
|
extrude_symmetric: bool = False
|
||||||
|
extrude_invert: bool = False
|
||||||
|
extrude_cut: bool = False
|
||||||
|
extrude_union: bool = False
|
||||||
|
extrude_through_all: bool = False
|
||||||
|
extrude_face_index: Optional[int] = None # which sketch face was selected
|
||||||
|
extrude_target_body_id: Optional[str] = None # for cut/union: target body id
|
||||||
|
needs_update: bool = False # True when source sketch changed since last extrude
|
||||||
|
|
||||||
|
position: np.ndarray = field(default_factory=lambda: np.array([0.0, 0.0, 0.0]))
|
||||||
|
rotation: np.ndarray = field(default_factory=lambda: np.eye(3))
|
||||||
|
|
||||||
|
color: tuple = (0.2, 0.4, 0.8)
|
||||||
|
opacity: float = 1.0
|
||||||
|
visible: bool = True
|
||||||
|
|
||||||
|
render_object: Any = None
|
||||||
|
|
||||||
|
created_at: datetime = field(default_factory=datetime.now)
|
||||||
|
modified_at: datetime = field(default_factory=datetime.now)
|
||||||
|
|
||||||
|
def get_mesh(self, kernel: OCGeometryKernel, tolerance: float = 0.1) -> tuple:
|
||||||
|
"""Get mesh for rendering."""
|
||||||
|
if self.geometry and kernel:
|
||||||
|
return kernel.get_mesh(self.geometry, tolerance)
|
||||||
|
return np.array([]), np.array([])
|
||||||
|
|
||||||
|
def get_edges(self, kernel: OCGeometryKernel) -> tuple:
|
||||||
|
"""Get edges for wireframe rendering."""
|
||||||
|
if self.geometry and kernel:
|
||||||
|
return kernel.get_edges(self.geometry)
|
||||||
|
return np.array([]), np.array([])
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Component:
|
||||||
|
"""
|
||||||
|
Component containing sketches and bodies.
|
||||||
|
|
||||||
|
A component is a logical grouping of geometry, similar to
|
||||||
|
a part in a CAD system.
|
||||||
|
"""
|
||||||
|
|
||||||
|
id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
||||||
|
name: str = "Untitled Component"
|
||||||
|
description: str = ""
|
||||||
|
|
||||||
|
sketches: Dict[str, Sketch] = field(default_factory=dict)
|
||||||
|
bodies: Dict[str, Body] = field(default_factory=dict)
|
||||||
|
workplanes: Dict[str, Workplane] = field(default_factory=dict)
|
||||||
|
|
||||||
|
active_sketch: Optional[str] = None
|
||||||
|
active_workplane: Optional[str] = None
|
||||||
|
|
||||||
|
created_at: datetime = field(default_factory=datetime.now)
|
||||||
|
modified_at: datetime = field(default_factory=datetime.now)
|
||||||
|
|
||||||
|
def add_workplane(self, workplane: Optional[Workplane] = None) -> Workplane:
|
||||||
|
"""Add an independent workplane to the component."""
|
||||||
|
if workplane is None:
|
||||||
|
workplane = Workplane(name=f"Workplane {len(self.workplanes) + 1}")
|
||||||
|
self.workplanes[workplane.id] = workplane
|
||||||
|
if self.active_workplane is None:
|
||||||
|
self.active_workplane = workplane.id
|
||||||
|
self.modified_at = datetime.now()
|
||||||
|
return workplane
|
||||||
|
|
||||||
|
def remove_workplane(self, wp_id: str) -> bool:
|
||||||
|
"""Remove a workplane from the component."""
|
||||||
|
if wp_id in self.workplanes:
|
||||||
|
del self.workplanes[wp_id]
|
||||||
|
if self.active_workplane == wp_id:
|
||||||
|
self.active_workplane = next(iter(self.workplanes.keys()), None)
|
||||||
|
self.modified_at = datetime.now()
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def add_sketch(self, sketch: Optional[Sketch] = None) -> Sketch:
|
||||||
|
"""Add a sketch to the component."""
|
||||||
|
if sketch is None:
|
||||||
|
sketch = Sketch(name=f"Sketch {len(self.sketches) + 1}")
|
||||||
|
self.sketches[sketch.id] = sketch
|
||||||
|
self.modified_at = datetime.now()
|
||||||
|
return sketch
|
||||||
|
|
||||||
|
def add_body(self, body: Optional[Body] = None) -> Body:
|
||||||
|
"""Add a body to the component."""
|
||||||
|
if body is None:
|
||||||
|
body = Body(name=f"Body {len(self.bodies) + 1}")
|
||||||
|
self.bodies[body.id] = body
|
||||||
|
self.modified_at = datetime.now()
|
||||||
|
return body
|
||||||
|
|
||||||
|
def remove_sketch(self, sketch_id: str) -> bool:
|
||||||
|
"""Remove a sketch from the component."""
|
||||||
|
if sketch_id in self.sketches:
|
||||||
|
del self.sketches[sketch_id]
|
||||||
|
if self.active_sketch == sketch_id:
|
||||||
|
self.active_sketch = None
|
||||||
|
self.modified_at = datetime.now()
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def remove_body(self, body_id: str) -> bool:
|
||||||
|
"""Remove a body from the component."""
|
||||||
|
if body_id in self.bodies:
|
||||||
|
del self.bodies[body_id]
|
||||||
|
self.modified_at = datetime.now()
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def get_active_sketch(self) -> Optional[Sketch]:
|
||||||
|
"""Get the currently active sketch."""
|
||||||
|
if self.active_sketch and self.active_sketch in self.sketches:
|
||||||
|
return self.sketches[self.active_sketch]
|
||||||
|
return None
|
||||||
|
|
||||||
|
def set_active_sketch(self, sketch_id: Optional[str]) -> None:
|
||||||
|
"""Set the active sketch."""
|
||||||
|
self.active_sketch = sketch_id
|
||||||
|
self.modified_at = datetime.now()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Connector:
|
||||||
|
"""
|
||||||
|
A connection point on an assembly component instance.
|
||||||
|
|
||||||
|
Stores the position and orientation of a connection point
|
||||||
|
(e.g. a hole center, face midpoint, or edge point) that will
|
||||||
|
later be used by the SolveSpace solver to mate components.
|
||||||
|
The *normal* defines the connection axis direction (e.g. the
|
||||||
|
hole axis for a screw connection).
|
||||||
|
"""
|
||||||
|
|
||||||
|
id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
||||||
|
name: str = "Untitled Connector"
|
||||||
|
|
||||||
|
# 3D position of the connection point (component-local coords).
|
||||||
|
# Transformed to world coords at render time using the parent
|
||||||
|
# AssemblyComponent's position/rotation, so connectors move with
|
||||||
|
# their component automatically.
|
||||||
|
position: Tuple[float, float, float] = (0.0, 0.0, 0.0)
|
||||||
|
# Normal direction of the connection (e.g. hole axis) in local coords.
|
||||||
|
normal: Tuple[float, float, float] = (0.0, 0.0, 1.0)
|
||||||
|
# In-plane X direction for defining the reference frame (local coords).
|
||||||
|
x_dir: Tuple[float, float, float] = (1.0, 0.0, 0.0)
|
||||||
|
|
||||||
|
# Rotation around the normal axis (degrees).
|
||||||
|
axis_rotation: float = 0.0
|
||||||
|
# Offset distance along the normal.
|
||||||
|
offset: float = 0.0
|
||||||
|
|
||||||
|
# Which AssemblyComponent this connector belongs to.
|
||||||
|
assembly_component_id: str = ""
|
||||||
|
# Which body/face this connector was placed on (renderer obj_id).
|
||||||
|
source_obj_id: str = ""
|
||||||
|
|
||||||
|
# --- Rigid-group pairing (set when two connectors are mated) ---
|
||||||
|
# The id of the partner AssemblyComponent this connector is mated to.
|
||||||
|
# The FIRST-picked component is the grounded reference of the pair;
|
||||||
|
# 'is_grounded' marks that side so the move handler knows which half
|
||||||
|
# is the fixed frame of the rigid group.
|
||||||
|
partner_ac_id: Optional[str] = None
|
||||||
|
# The id of the partner Connector on the partner component.
|
||||||
|
partner_connector_id: Optional[str] = None
|
||||||
|
# True on the first-picked (grounded) connector of a mated pair.
|
||||||
|
is_grounded: bool = False
|
||||||
|
|
||||||
|
# True when the source geometry this connector was placed on has
|
||||||
|
# changed or disappeared — the connector needs re-placement.
|
||||||
|
is_invalid: bool = False
|
||||||
|
|
||||||
|
created_at: datetime = field(default_factory=datetime.now)
|
||||||
|
modified_at: datetime = field(default_factory=datetime.now)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AssemblyComponent:
|
||||||
|
"""
|
||||||
|
An instance of a component within an assembly.
|
||||||
|
|
||||||
|
References a component in the project and stores its relative
|
||||||
|
position and rotation for placement within the assembly.
|
||||||
|
Holds connectors that define connection points for the
|
||||||
|
SolveSpace solver.
|
||||||
|
"""
|
||||||
|
|
||||||
|
id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
||||||
|
component_id: str = ""
|
||||||
|
name: str = "Untitled Instance"
|
||||||
|
|
||||||
|
# Position and orientation relative to the assembly origin.
|
||||||
|
position: np.ndarray = field(default_factory=lambda: np.array([0.0, 0.0, 0.0]))
|
||||||
|
rotation: np.ndarray = field(default_factory=lambda: np.eye(3))
|
||||||
|
|
||||||
|
# Connectors defined on this component instance.
|
||||||
|
connectors: Dict[str, Connector] = field(default_factory=dict)
|
||||||
|
|
||||||
|
created_at: datetime = field(default_factory=datetime.now)
|
||||||
|
modified_at: datetime = field(default_factory=datetime.now)
|
||||||
|
|
||||||
|
def add_connector(
|
||||||
|
self,
|
||||||
|
position: Tuple[float, float, float],
|
||||||
|
normal: Tuple[float, float, float],
|
||||||
|
x_dir: Tuple[float, float, float],
|
||||||
|
source_obj_id: str = "",
|
||||||
|
name: Optional[str] = None,
|
||||||
|
) -> Connector:
|
||||||
|
"""Add a connector to this component instance."""
|
||||||
|
conn = Connector(
|
||||||
|
name=name or f"Connector {len(self.connectors) + 1}",
|
||||||
|
position=position,
|
||||||
|
normal=normal,
|
||||||
|
x_dir=x_dir,
|
||||||
|
assembly_component_id=self.id,
|
||||||
|
source_obj_id=source_obj_id,
|
||||||
|
)
|
||||||
|
self.connectors[conn.id] = conn
|
||||||
|
self.modified_at = datetime.now()
|
||||||
|
return conn
|
||||||
|
|
||||||
|
def remove_connector(self, connector_id: str) -> bool:
|
||||||
|
"""Remove a connector from this component instance."""
|
||||||
|
if connector_id in self.connectors:
|
||||||
|
del self.connectors[connector_id]
|
||||||
|
self.modified_at = datetime.now()
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AssemblyConnection:
|
||||||
|
"""A mated connector pair linking two AssemblyComponents.
|
||||||
|
|
||||||
|
Records which component is the grounded reference (``first_ac_id``) and
|
||||||
|
which was solved against it (``second_ac_id``), plus the partner
|
||||||
|
connector ids so the linkage can be followed / removed symmetrically.
|
||||||
|
Used by the assembly-move handler to propagate translations across the
|
||||||
|
rigid group.
|
||||||
|
"""
|
||||||
|
|
||||||
|
id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
||||||
|
first_ac_id: str = "" # grounded reference side
|
||||||
|
second_ac_id: str = "" # solved side
|
||||||
|
first_connector_id: Optional[str] = None
|
||||||
|
second_connector_id: Optional[str] = None
|
||||||
|
created_at: datetime = field(default_factory=datetime.now)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Assembly:
|
||||||
|
"""
|
||||||
|
An assembly of multiple component instances.
|
||||||
|
|
||||||
|
An assembly groups component instances with relative positions,
|
||||||
|
ready for constraint solving and joint definition between them.
|
||||||
|
Components can be instanced multiple times, each at a different
|
||||||
|
position and rotation.
|
||||||
|
"""
|
||||||
|
|
||||||
|
id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
||||||
|
name: str = "Untitled Assembly"
|
||||||
|
|
||||||
|
components: Dict[str, AssemblyComponent] = field(default_factory=dict)
|
||||||
|
active_assembly_component: Optional[str] = None
|
||||||
|
|
||||||
|
# Mated connector pairs — each entry links two AssemblyComponents so the
|
||||||
|
# assembly-move handler can propagate rigid-group translations. The
|
||||||
|
# 'first_ac_id' side is the grounded reference of the pair.
|
||||||
|
connections: List["AssemblyConnection"] = field(default_factory=list)
|
||||||
|
|
||||||
|
created_at: datetime = field(default_factory=datetime.now)
|
||||||
|
modified_at: datetime = field(default_factory=datetime.now)
|
||||||
|
|
||||||
|
def add_connection(self, first_ac_id: str, second_ac_id: str) -> "AssemblyConnection":
|
||||||
|
"""Record a mated connector pair between two component instances.
|
||||||
|
|
||||||
|
The first-picked component (``first_ac_id``) is treated as the
|
||||||
|
grounded reference of the pair. Guards against duplicate entries.
|
||||||
|
Returns the AssemblyConnection for further bookkeeping.
|
||||||
|
"""
|
||||||
|
# Guard: deduplicate — same pair in either order
|
||||||
|
for c in self.connections:
|
||||||
|
if (c.first_ac_id == first_ac_id and c.second_ac_id == second_ac_id) or \
|
||||||
|
(c.first_ac_id == second_ac_id and c.second_ac_id == first_ac_id):
|
||||||
|
return c
|
||||||
|
conn = AssemblyConnection(
|
||||||
|
first_ac_id=first_ac_id,
|
||||||
|
second_ac_id=second_ac_id,
|
||||||
|
)
|
||||||
|
self.connections.append(conn)
|
||||||
|
self.modified_at = datetime.now()
|
||||||
|
return conn
|
||||||
|
|
||||||
|
def remove_connections_for(self, ac_id: str) -> None:
|
||||||
|
"""Drop every connection that involves *ac_id* (e.g. on removal)."""
|
||||||
|
self.connections = [
|
||||||
|
c for c in self.connections
|
||||||
|
if c.first_ac_id != ac_id and c.second_ac_id != ac_id
|
||||||
|
]
|
||||||
|
|
||||||
|
def get_rigid_group(self, ac_id: str) -> List[str]:
|
||||||
|
"""Return ids of all components rigidly linked to *ac_id* (BFS).
|
||||||
|
|
||||||
|
Includes *ac_id* itself. Two components are linked when a mated
|
||||||
|
connector pair (in ``connections``) joins them; linkage is
|
||||||
|
transitive, so the whole connected subgraph forms one rigid group.
|
||||||
|
"""
|
||||||
|
if ac_id not in self.components:
|
||||||
|
return []
|
||||||
|
# Build adjacency from the connection list.
|
||||||
|
adj: Dict[str, List[str]] = {}
|
||||||
|
for c in self.connections:
|
||||||
|
adj.setdefault(c.first_ac_id, []).append(c.second_ac_id)
|
||||||
|
adj.setdefault(c.second_ac_id, []).append(c.first_ac_id)
|
||||||
|
seen: List[str] = []
|
||||||
|
queue: List[str] = [ac_id]
|
||||||
|
while queue:
|
||||||
|
cur = queue.pop(0)
|
||||||
|
if cur in seen:
|
||||||
|
continue
|
||||||
|
seen.append(cur)
|
||||||
|
for nb in adj.get(cur, []):
|
||||||
|
if nb not in seen:
|
||||||
|
queue.append(nb)
|
||||||
|
return seen
|
||||||
|
|
||||||
|
def is_grounded_reference(self, ac_id: str) -> bool:
|
||||||
|
"""True if *ac_id* is the grounded (first-picked) side of any pair."""
|
||||||
|
return any(c.first_ac_id == ac_id for c in self.connections)
|
||||||
|
|
||||||
|
def get_group_size(self, ac_id: str) -> int:
|
||||||
|
"""Number of components rigidly linked to *ac_id* (including itself)."""
|
||||||
|
return len(self.get_rigid_group(ac_id))
|
||||||
|
|
||||||
|
def add_component_instance(
|
||||||
|
self, component_id: str, name: Optional[str] = None
|
||||||
|
) -> AssemblyComponent:
|
||||||
|
"""Add a component instance to the assembly.
|
||||||
|
|
||||||
|
Returns the newly created AssemblyComponent. The same
|
||||||
|
component can be added multiple times (multiple instances).
|
||||||
|
"""
|
||||||
|
ac = AssemblyComponent(
|
||||||
|
component_id=component_id,
|
||||||
|
name=name or f"Instance {len(self.components) + 1}",
|
||||||
|
)
|
||||||
|
self.components[ac.id] = ac
|
||||||
|
if self.active_assembly_component is None:
|
||||||
|
self.active_assembly_component = ac.id
|
||||||
|
self.modified_at = datetime.now()
|
||||||
|
return ac
|
||||||
|
|
||||||
|
def remove_component_instance(self, assembly_component_id: str) -> bool:
|
||||||
|
"""Remove a component instance from the assembly."""
|
||||||
|
if assembly_component_id in self.components:
|
||||||
|
del self.components[assembly_component_id]
|
||||||
|
# Also drop any mated-connector links that referenced this
|
||||||
|
# instance — otherwise stale connection edges would remain in
|
||||||
|
# the rigid-group graph and point at a missing component.
|
||||||
|
self.remove_connections_for(assembly_component_id)
|
||||||
|
if self.active_assembly_component == assembly_component_id:
|
||||||
|
self.active_assembly_component = next(
|
||||||
|
iter(self.components.keys()), None
|
||||||
|
)
|
||||||
|
self.modified_at = datetime.now()
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def get_active_instance(self) -> Optional[AssemblyComponent]:
|
||||||
|
"""Get the currently active assembly component instance."""
|
||||||
|
if (
|
||||||
|
self.active_assembly_component
|
||||||
|
and self.active_assembly_component in self.components
|
||||||
|
):
|
||||||
|
return self.components[self.active_assembly_component]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Project:
|
||||||
|
"""
|
||||||
|
Top-level project container.
|
||||||
|
|
||||||
|
A project contains components and provides access to the
|
||||||
|
geometry kernel for operations.
|
||||||
|
"""
|
||||||
|
|
||||||
|
name: str = "Untitled Project"
|
||||||
|
description: str = ""
|
||||||
|
|
||||||
|
components: Dict[str, Component] = field(default_factory=dict)
|
||||||
|
active_component: Optional[str] = None
|
||||||
|
|
||||||
|
assemblies: Dict[str, Assembly] = field(default_factory=dict)
|
||||||
|
active_assembly: Optional[str] = None
|
||||||
|
|
||||||
|
kernel: OCGeometryKernel = field(default_factory=OCGeometryKernel)
|
||||||
|
|
||||||
|
created_at: datetime = field(default_factory=datetime.now)
|
||||||
|
modified_at: datetime = field(default_factory=datetime.now)
|
||||||
|
file_path: Optional[str] = None
|
||||||
|
|
||||||
|
# ── Component helpers ──
|
||||||
|
|
||||||
|
def add_component(self, component: Optional[Component] = None) -> Component:
|
||||||
|
"""Add a component to the project."""
|
||||||
|
if component is None:
|
||||||
|
component = Component(name=f"Component {len(self.components) + 1}")
|
||||||
|
self.components[component.id] = component
|
||||||
|
if self.active_component is None:
|
||||||
|
self.active_component = component.id
|
||||||
|
self.modified_at = datetime.now()
|
||||||
|
return component
|
||||||
|
|
||||||
|
def remove_component(self, component_id: str) -> bool:
|
||||||
|
"""Remove a component from the project."""
|
||||||
|
if component_id in self.components:
|
||||||
|
del self.components[component_id]
|
||||||
|
if self.active_component == component_id:
|
||||||
|
self.active_component = next(iter(self.components.keys()), None)
|
||||||
|
self.modified_at = datetime.now()
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def get_active_component(self) -> Optional[Component]:
|
||||||
|
"""Get the currently active component."""
|
||||||
|
if self.active_component and self.active_component in self.components:
|
||||||
|
return self.components[self.active_component]
|
||||||
|
return None
|
||||||
|
|
||||||
|
def set_active_component(self, component_id: Optional[str]) -> None:
|
||||||
|
"""Set the active component."""
|
||||||
|
self.active_component = component_id
|
||||||
|
self.modified_at = datetime.now()
|
||||||
|
|
||||||
|
# ── Assembly helpers ──
|
||||||
|
|
||||||
|
def add_assembly(self, assembly: Optional[Assembly] = None) -> Assembly:
|
||||||
|
"""Add an assembly to the project."""
|
||||||
|
if assembly is None:
|
||||||
|
assembly = Assembly(name=f"Assembly {len(self.assemblies) + 1}")
|
||||||
|
self.assemblies[assembly.id] = assembly
|
||||||
|
if self.active_assembly is None:
|
||||||
|
self.active_assembly = assembly.id
|
||||||
|
self.modified_at = datetime.now()
|
||||||
|
return assembly
|
||||||
|
|
||||||
|
def remove_assembly(self, assembly_id: str) -> bool:
|
||||||
|
"""Remove an assembly from the project."""
|
||||||
|
if assembly_id in self.assemblies:
|
||||||
|
del self.assemblies[assembly_id]
|
||||||
|
if self.active_assembly == assembly_id:
|
||||||
|
self.active_assembly = next(iter(self.assemblies.keys()), None)
|
||||||
|
self.modified_at = datetime.now()
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def get_active_assembly(self) -> Optional[Assembly]:
|
||||||
|
"""Get the currently active assembly."""
|
||||||
|
if self.active_assembly and self.active_assembly in self.assemblies:
|
||||||
|
return self.assemblies[self.active_assembly]
|
||||||
|
return None
|
||||||
|
|
||||||
|
def set_active_assembly(self, assembly_id: Optional[str]) -> None:
|
||||||
|
"""Set the active assembly."""
|
||||||
|
self.active_assembly = assembly_id
|
||||||
|
self.modified_at = datetime.now()
|
||||||
|
|
||||||
|
def get_component_by_id(self, component_id: str) -> Optional[Component]:
|
||||||
|
"""Look up a component by id across all project components."""
|
||||||
|
return self.components.get(component_id)
|
||||||
|
|
||||||
|
def add_component(self, component: Optional[Component] = None) -> Component:
|
||||||
|
"""Add a component to the project."""
|
||||||
|
if component is None:
|
||||||
|
component = Component(name=f"Component {len(self.components) + 1}")
|
||||||
|
self.components[component.id] = component
|
||||||
|
if self.active_component is None:
|
||||||
|
self.active_component = component.id
|
||||||
|
self.modified_at = datetime.now()
|
||||||
|
return component
|
||||||
|
|
||||||
|
def remove_component(self, component_id: str) -> bool:
|
||||||
|
"""Remove a component from the project."""
|
||||||
|
if component_id in self.components:
|
||||||
|
del self.components[component_id]
|
||||||
|
if self.active_component == component_id:
|
||||||
|
self.active_component = next(iter(self.components.keys()), None)
|
||||||
|
self.modified_at = datetime.now()
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def get_active_component(self) -> Optional[Component]:
|
||||||
|
"""Get the currently active component."""
|
||||||
|
if self.active_component and self.active_component in self.components:
|
||||||
|
return self.components[self.active_component]
|
||||||
|
return None
|
||||||
|
|
||||||
|
def set_active_component(self, component_id: Optional[str]) -> None:
|
||||||
|
"""Set the active component."""
|
||||||
|
self.active_component = component_id
|
||||||
|
self.modified_at = datetime.now()
|
||||||
|
|
||||||
|
def export_step(self, filepath: str) -> bool:
|
||||||
|
"""Export all visible bodies to STEP."""
|
||||||
|
all_bodies: List[OCCGeometryObject] = []
|
||||||
|
|
||||||
|
for comp in self.components.values():
|
||||||
|
for body in comp.bodies.values():
|
||||||
|
if body.visible and body.geometry:
|
||||||
|
all_bodies.append(body.geometry)
|
||||||
|
|
||||||
|
if not all_bodies:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if len(all_bodies) == 1:
|
||||||
|
return self.kernel.export_step(all_bodies[0], filepath)
|
||||||
|
|
||||||
|
result = self.kernel.boolean_union(*all_bodies)
|
||||||
|
return self.kernel.export_step(result, filepath)
|
||||||
|
|
||||||
|
def export_iges(self, filepath: str) -> bool:
|
||||||
|
"""Export all visible bodies to IGES."""
|
||||||
|
all_bodies: List[OCCGeometryObject] = []
|
||||||
|
|
||||||
|
for comp in self.components.values():
|
||||||
|
for body in comp.bodies.values():
|
||||||
|
if body.visible and body.geometry:
|
||||||
|
all_bodies.append(body.geometry)
|
||||||
|
|
||||||
|
if not all_bodies:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if len(all_bodies) == 1:
|
||||||
|
return self.kernel.export_iges(all_bodies[0], filepath)
|
||||||
|
|
||||||
|
result = self.kernel.boolean_union(*all_bodies)
|
||||||
|
return self.kernel.export_iges(result, filepath)
|
||||||
|
|
||||||
|
def export_stl(self, filepath: str, tolerance: float = 0.1) -> bool:
|
||||||
|
"""Export all visible bodies to STL."""
|
||||||
|
all_bodies: List[OCCGeometryObject] = []
|
||||||
|
|
||||||
|
for comp in self.components.values():
|
||||||
|
for body in comp.bodies.values():
|
||||||
|
if body.visible and body.geometry:
|
||||||
|
all_bodies.append(body.geometry)
|
||||||
|
|
||||||
|
if not all_bodies:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if len(all_bodies) == 1:
|
||||||
|
return self.kernel.export_stl(all_bodies[0], filepath, tolerance)
|
||||||
|
|
||||||
|
result = self.kernel.boolean_union(*all_bodies)
|
||||||
|
return self.kernel.export_stl(result, filepath, tolerance)
|
||||||
|
|
||||||
|
def get_all_bodies(self) -> List[Body]:
|
||||||
|
"""Get all bodies from all components."""
|
||||||
|
bodies: List[Body] = []
|
||||||
|
for comp in self.components.values():
|
||||||
|
bodies.extend(comp.bodies.values())
|
||||||
|
return bodies
|
||||||
|
|
||||||
|
def get_all_sketches(self) -> List[Sketch]:
|
||||||
|
"""Get all sketches from all components."""
|
||||||
|
sketches: List[Sketch] = []
|
||||||
|
for comp in self.components.values():
|
||||||
|
sketches.extend(comp.sketches.values())
|
||||||
|
return sketches
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
"""Rendering module."""
|
||||||
|
|
||||||
|
# OCC/OpenGL viewport renderers
|
||||||
|
from fluency.rendering.base import (
|
||||||
|
Renderer,
|
||||||
|
RenderObject,
|
||||||
|
RenderColor,
|
||||||
|
)
|
||||||
|
from fluency.rendering.pygfx_renderer import PygfxRenderer, PygfxRenderObject
|
||||||
|
from fluency.rendering.occ_renderer import OCCRenderer, OCCRenderObject
|
||||||
|
|
||||||
|
# Photorealistic render backends
|
||||||
|
from fluency.rendering.render_backend import (
|
||||||
|
RenderBackend,
|
||||||
|
RenderMaterial,
|
||||||
|
RenderCamera,
|
||||||
|
RenderSettings,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"Renderer",
|
||||||
|
"RenderObject",
|
||||||
|
"RenderColor",
|
||||||
|
"PygfxRenderer",
|
||||||
|
"PygfxRenderObject",
|
||||||
|
"RenderBackend",
|
||||||
|
"RenderMaterial",
|
||||||
|
"RenderCamera",
|
||||||
|
"RenderSettings",
|
||||||
|
]
|
||||||
@@ -0,0 +1,380 @@
|
|||||||
|
"""
|
||||||
|
Rendering abstraction layer for Fluency CAD.
|
||||||
|
|
||||||
|
This module defines abstract interfaces for 3D rendering,
|
||||||
|
allowing different rendering backends to be used interchangeably.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from typing import List, Tuple, Optional, Callable, Any
|
||||||
|
from dataclasses import dataclass
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class RenderColor:
|
||||||
|
"""RGB color representation."""
|
||||||
|
|
||||||
|
r: float
|
||||||
|
g: float
|
||||||
|
b: float
|
||||||
|
a: float = 1.0
|
||||||
|
|
||||||
|
def to_tuple(self) -> Tuple[float, float, float, float]:
|
||||||
|
return (self.r, self.g, self.b, self.a)
|
||||||
|
|
||||||
|
def to_tuple_rgb(self) -> Tuple[float, float, float]:
|
||||||
|
return (self.r, self.g, self.b)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_hex(cls, hex_color: str) -> "RenderColor":
|
||||||
|
"""Create color from hex string (#RRGGBB or #RRGGBBAA)."""
|
||||||
|
hex_color = hex_color.lstrip("#")
|
||||||
|
if len(hex_color) == 6:
|
||||||
|
r = int(hex_color[0:2], 16) / 255.0
|
||||||
|
g = int(hex_color[2:4], 16) / 255.0
|
||||||
|
b = int(hex_color[4:6], 16) / 255.0
|
||||||
|
return cls(r, g, b)
|
||||||
|
elif len(hex_color) == 8:
|
||||||
|
r = int(hex_color[0:2], 16) / 255.0
|
||||||
|
g = int(hex_color[2:4], 16) / 255.0
|
||||||
|
b = int(hex_color[4:6], 16) / 255.0
|
||||||
|
a = int(hex_color[6:8], 16) / 255.0
|
||||||
|
return cls(r, g, b, a)
|
||||||
|
raise ValueError(f"Invalid hex color: {hex_color}")
|
||||||
|
|
||||||
|
|
||||||
|
class RenderObject:
|
||||||
|
"""Base class for renderable objects."""
|
||||||
|
|
||||||
|
def __init__(self, name: Optional[str] = None):
|
||||||
|
self.name = name
|
||||||
|
self.visible: bool = True
|
||||||
|
self.selected: bool = False
|
||||||
|
self.color: RenderColor = RenderColor(0.2, 0.4, 0.8)
|
||||||
|
self._scene_node: Any = None
|
||||||
|
|
||||||
|
def set_color(self, color: RenderColor) -> None:
|
||||||
|
self.color = color
|
||||||
|
|
||||||
|
def set_visible(self, visible: bool) -> None:
|
||||||
|
self.visible = visible
|
||||||
|
|
||||||
|
def set_selected(self, selected: bool) -> None:
|
||||||
|
self.selected = selected
|
||||||
|
|
||||||
|
|
||||||
|
class Renderer(ABC):
|
||||||
|
"""
|
||||||
|
Abstract base class for 3D renderers.
|
||||||
|
|
||||||
|
A renderer provides 3D visualization capabilities including
|
||||||
|
mesh display, camera control, and object selection.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def initialize(self, parent_widget: Any) -> bool:
|
||||||
|
"""
|
||||||
|
Initialize the renderer with a parent widget.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
parent_widget: Qt widget to embed the renderer in
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if initialization succeeded
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def shutdown(self) -> None:
|
||||||
|
"""Clean up renderer resources."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def add_mesh(
|
||||||
|
self,
|
||||||
|
vertices: np.ndarray,
|
||||||
|
faces: np.ndarray,
|
||||||
|
color: Tuple[float, float, float] = (0.2, 0.4, 0.8),
|
||||||
|
name: Optional[str] = None,
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Add a mesh to the scene.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
vertices: Nx3 array of vertex positions
|
||||||
|
faces: Mx3 array of triangle indices
|
||||||
|
color: RGB color tuple
|
||||||
|
name: Optional name for the object
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
String ID of the mesh
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def add_wireframe(
|
||||||
|
self,
|
||||||
|
vertices: np.ndarray,
|
||||||
|
edges: np.ndarray,
|
||||||
|
color: Tuple[float, float, float] = (1.0, 1.0, 1.0),
|
||||||
|
line_width: float = 1.0,
|
||||||
|
name: Optional[str] = None,
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Add a wireframe to the scene.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
vertices: Nx3 array of vertex positions
|
||||||
|
edges: Mx2 array of edge vertex indices
|
||||||
|
color: RGB color tuple
|
||||||
|
line_width: Width of lines
|
||||||
|
name: Optional name for the object
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
String ID of the wireframe
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def add_points(
|
||||||
|
self,
|
||||||
|
points: np.ndarray,
|
||||||
|
color: Tuple[float, float, float] = (1.0, 0.0, 0.0),
|
||||||
|
size: float = 5.0,
|
||||||
|
name: Optional[str] = None,
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Add points to the scene.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
points: Nx3 array of point positions
|
||||||
|
color: RGB color tuple
|
||||||
|
size: Point size
|
||||||
|
name: Optional name for the object
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
String ID of the points
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def add_lines(
|
||||||
|
self,
|
||||||
|
start_points: np.ndarray,
|
||||||
|
end_points: np.ndarray,
|
||||||
|
color: Tuple[float, float, float] = (1.0, 1.0, 1.0),
|
||||||
|
line_width: float = 1.0,
|
||||||
|
name: Optional[str] = None,
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Add line segments to the scene.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
start_points: Nx3 array of line start positions
|
||||||
|
end_points: Nx3 array of line end positions
|
||||||
|
color: RGB color tuple
|
||||||
|
line_width: Width of lines
|
||||||
|
name: Optional name for the object
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
String ID of the lines
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def remove_object(self, obj: RenderObject) -> bool:
|
||||||
|
"""
|
||||||
|
Remove an object from the scene.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
obj: Object to remove
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if removal succeeded
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def clear_scene(self) -> None:
|
||||||
|
"""Remove all objects from the scene."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def update_mesh(self, obj: RenderObject, vertices: np.ndarray, faces: np.ndarray) -> bool:
|
||||||
|
"""
|
||||||
|
Update mesh geometry.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
obj: Object to update
|
||||||
|
vertices: New Nx3 array of vertex positions
|
||||||
|
faces: New Mx3 array of triangle indices
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if update succeeded
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def set_object_color(self, obj: RenderObject, color: Tuple[float, float, float]) -> None:
|
||||||
|
"""Set the color of an object."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def set_object_visible(self, obj: RenderObject, visible: bool) -> None:
|
||||||
|
"""Set the visibility of an object."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def set_camera_position(
|
||||||
|
self,
|
||||||
|
position: Tuple[float, float, float],
|
||||||
|
target: Tuple[float, float, float] = (0, 0, 0),
|
||||||
|
up: Tuple[float, float, float] = (0, 0, 1),
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Set camera position and orientation.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
position: Camera position
|
||||||
|
target: Point camera is looking at
|
||||||
|
up: Up vector
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def get_camera_position(self) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
|
||||||
|
"""
|
||||||
|
Get camera position, target, and up vector.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (position, target, up) as numpy arrays
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def fit_camera(self, padding: float = 1.1) -> None:
|
||||||
|
"""
|
||||||
|
Fit camera to show all objects.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
padding: Padding factor (1.0 = exact fit)
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def set_camera_perspective(
|
||||||
|
self, fov: float = 50.0, near: float = 0.1, far: float = 10000.0
|
||||||
|
) -> None:
|
||||||
|
"""Set camera perspective parameters."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def set_camera_orthographic(
|
||||||
|
self, width: float = 100.0, near: float = 0.1, far: float = 10000.0
|
||||||
|
) -> None:
|
||||||
|
"""Set camera orthographic parameters."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def render(self) -> None:
|
||||||
|
"""Trigger a render."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def on_pick(self, callback: Callable[[Any], None]) -> None:
|
||||||
|
"""
|
||||||
|
Register a callback for picking/selection.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
callback: Function called with pick info when object is clicked
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def on_camera_change(self, callback: Callable[[], None]) -> None:
|
||||||
|
"""
|
||||||
|
Register a callback for camera changes.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
callback: Function called when camera moves
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def set_background_color(self, color: Tuple[float, float, float]) -> None:
|
||||||
|
"""Set the background color."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def add_grid(
|
||||||
|
self,
|
||||||
|
size: float = 100.0,
|
||||||
|
divisions: int = 10,
|
||||||
|
color: Tuple[float, float, float] = (0.3, 0.3, 0.3),
|
||||||
|
) -> str:
|
||||||
|
"""Add a reference grid. Returns the grid ID."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def add_axes(self, size: float = 10.0, visible: bool = True) -> str:
|
||||||
|
"""Add coordinate axes. Returns the axes ID."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def get_screen_size(self) -> Tuple[int, int]:
|
||||||
|
"""Get the screen size in pixels."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def project_to_screen(self, point: Tuple[float, float, float]) -> Tuple[int, int]:
|
||||||
|
"""
|
||||||
|
Project a 3D point to screen coordinates.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
point: 3D point to project
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Screen (x, y) coordinates
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def unproject_from_screen(
|
||||||
|
self, screen_x: int, screen_y: int, depth: float = 0.0
|
||||||
|
) -> Tuple[float, float, float]:
|
||||||
|
"""
|
||||||
|
Unproject screen coordinates to 3D.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
screen_x: Screen x coordinate
|
||||||
|
screen_y: Screen y coordinate
|
||||||
|
depth: Depth value (0=near, 1=far)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
3D point coordinates
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def take_screenshot(self) -> np.ndarray:
|
||||||
|
"""
|
||||||
|
Take a screenshot of the current view.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
RGBA image as numpy array
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def save_screenshot(self, filepath: str) -> bool:
|
||||||
|
"""
|
||||||
|
Save a screenshot to file.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
filepath: Path to save screenshot
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if save succeeded
|
||||||
|
"""
|
||||||
|
pass
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
"""Material presets for the render backend.
|
||||||
|
|
||||||
|
Each preset is a RenderMaterial with physically-plausible values.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Dict, List
|
||||||
|
|
||||||
|
from .render_backend import RenderMaterial
|
||||||
|
|
||||||
|
# ── Preset library ──────────────────────────────────────────────────────
|
||||||
|
# Note: Mitsuba pip installs don't include spectral metal data files,
|
||||||
|
# so metal_preset is not used. Instead, metals use material="none" with
|
||||||
|
# specular_reflectance set to the metal color.
|
||||||
|
|
||||||
|
PRESETS: Dict[str, RenderMaterial] = {
|
||||||
|
# ── Metals ──────────────────────────────────────────────────────
|
||||||
|
"Brushed Steel": RenderMaterial(
|
||||||
|
name="Brushed Steel",
|
||||||
|
color=(0.65, 0.67, 0.72),
|
||||||
|
metallic=0.9,
|
||||||
|
roughness=0.35,
|
||||||
|
bsdf_type="roughconductor",
|
||||||
|
),
|
||||||
|
"Polished Chrome": RenderMaterial(
|
||||||
|
name="Polished Chrome",
|
||||||
|
color=(0.8, 0.8, 0.8),
|
||||||
|
metallic=1.0,
|
||||||
|
roughness=0.05,
|
||||||
|
bsdf_type="roughconductor",
|
||||||
|
),
|
||||||
|
"Brushed Aluminum": RenderMaterial(
|
||||||
|
name="Brushed Aluminum",
|
||||||
|
color=(0.75, 0.75, 0.75),
|
||||||
|
metallic=0.85,
|
||||||
|
roughness=0.25,
|
||||||
|
bsdf_type="roughconductor",
|
||||||
|
),
|
||||||
|
"Copper": RenderMaterial(
|
||||||
|
name="Copper",
|
||||||
|
color=(0.95, 0.64, 0.54),
|
||||||
|
metallic=0.95,
|
||||||
|
roughness=0.15,
|
||||||
|
bsdf_type="roughconductor",
|
||||||
|
),
|
||||||
|
"Gold": RenderMaterial(
|
||||||
|
name="Gold",
|
||||||
|
color=(1.0, 0.76, 0.33),
|
||||||
|
metallic=1.0,
|
||||||
|
roughness=0.1,
|
||||||
|
bsdf_type="roughconductor",
|
||||||
|
),
|
||||||
|
"Blackened Steel": RenderMaterial(
|
||||||
|
name="Blackened Steel",
|
||||||
|
color=(0.15, 0.15, 0.17),
|
||||||
|
metallic=0.8,
|
||||||
|
roughness=0.4,
|
||||||
|
bsdf_type="roughconductor",
|
||||||
|
),
|
||||||
|
# ── Plastics ────────────────────────────────────────────────────
|
||||||
|
"Matte Plastic": RenderMaterial(
|
||||||
|
name="Matte Plastic",
|
||||||
|
color=(0.2, 0.5, 0.8),
|
||||||
|
metallic=0.0,
|
||||||
|
roughness=0.6,
|
||||||
|
bsdf_type="plastic",
|
||||||
|
int_ior=1.5,
|
||||||
|
),
|
||||||
|
"Glossy Plastic": RenderMaterial(
|
||||||
|
name="Glossy Plastic",
|
||||||
|
color=(0.2, 0.5, 0.8),
|
||||||
|
metallic=0.0,
|
||||||
|
roughness=0.1,
|
||||||
|
bsdf_type="plastic",
|
||||||
|
int_ior=1.5,
|
||||||
|
),
|
||||||
|
"White Nylon": RenderMaterial(
|
||||||
|
name="White Nylon",
|
||||||
|
color=(0.85, 0.85, 0.83),
|
||||||
|
metallic=0.0,
|
||||||
|
roughness=0.45,
|
||||||
|
bsdf_type="plastic",
|
||||||
|
int_ior=1.53,
|
||||||
|
),
|
||||||
|
"Black ABS": RenderMaterial(
|
||||||
|
name="Black ABS",
|
||||||
|
color=(0.05, 0.05, 0.05),
|
||||||
|
metallic=0.0,
|
||||||
|
roughness=0.35,
|
||||||
|
bsdf_type="plastic",
|
||||||
|
int_ior=1.54,
|
||||||
|
),
|
||||||
|
"Red PA12": RenderMaterial(
|
||||||
|
name="Red PA12",
|
||||||
|
color=(0.75, 0.08, 0.08),
|
||||||
|
metallic=0.0,
|
||||||
|
roughness=0.4,
|
||||||
|
bsdf_type="plastic",
|
||||||
|
int_ior=1.53,
|
||||||
|
),
|
||||||
|
# ── Other ───────────────────────────────────────────────────────
|
||||||
|
"Rubber": RenderMaterial(
|
||||||
|
name="Rubber",
|
||||||
|
color=(0.1, 0.1, 0.1),
|
||||||
|
metallic=0.0,
|
||||||
|
roughness=0.9,
|
||||||
|
bsdf_type="diffuse",
|
||||||
|
),
|
||||||
|
"Ceramic White": RenderMaterial(
|
||||||
|
name="Ceramic White",
|
||||||
|
color=(0.92, 0.91, 0.88),
|
||||||
|
metallic=0.0,
|
||||||
|
roughness=0.15,
|
||||||
|
bsdf_type="dielectric",
|
||||||
|
int_ior=1.55,
|
||||||
|
),
|
||||||
|
"Glass": RenderMaterial(
|
||||||
|
name="Glass",
|
||||||
|
color=(0.95, 0.95, 0.95),
|
||||||
|
metallic=0.0,
|
||||||
|
roughness=0.0,
|
||||||
|
bsdf_type="dielectric",
|
||||||
|
int_ior=1.52,
|
||||||
|
),
|
||||||
|
"Wood": RenderMaterial(
|
||||||
|
name="Wood",
|
||||||
|
color=(0.6, 0.4, 0.2),
|
||||||
|
metallic=0.0,
|
||||||
|
roughness=0.7,
|
||||||
|
bsdf_type="diffuse",
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_preset(name: str) -> RenderMaterial:
|
||||||
|
"""Get a material preset by name. Falls back to default if not found."""
|
||||||
|
if name in PRESETS:
|
||||||
|
return PRESETS[name]
|
||||||
|
return default_material()
|
||||||
|
|
||||||
|
|
||||||
|
def default_material() -> RenderMaterial:
|
||||||
|
"""Return the default grey material."""
|
||||||
|
return RenderMaterial(
|
||||||
|
name="Default",
|
||||||
|
color=(0.7, 0.7, 0.7),
|
||||||
|
metallic=0.0,
|
||||||
|
roughness=0.5,
|
||||||
|
bsdf_type="diffuse",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def preset_names() -> List[str]:
|
||||||
|
"""Return sorted list of available preset names."""
|
||||||
|
return sorted(PRESETS.keys())
|
||||||
@@ -0,0 +1,382 @@
|
|||||||
|
"""Mitsuba 3 photorealistic render backend.
|
||||||
|
|
||||||
|
Requires: ``pip install mitsuba``
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from typing import Callable, Optional
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from .render_backend import RenderBackend, RenderCamera, RenderMaterial, RenderSettings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class MitsubaBackend(RenderBackend):
|
||||||
|
"""Render backend using Mitsuba 3 path tracer."""
|
||||||
|
|
||||||
|
def name(self) -> str:
|
||||||
|
return "Mitsuba 3"
|
||||||
|
|
||||||
|
def is_available(self) -> bool:
|
||||||
|
import sys
|
||||||
|
import io
|
||||||
|
|
||||||
|
old_stderr = sys.stderr
|
||||||
|
sys.stderr = io.StringIO()
|
||||||
|
try:
|
||||||
|
import mitsuba # noqa: F401
|
||||||
|
|
||||||
|
return True
|
||||||
|
except ImportError:
|
||||||
|
return False
|
||||||
|
finally:
|
||||||
|
sys.stderr = old_stderr
|
||||||
|
|
||||||
|
# ── Scene construction ──────────────────────────────────────────
|
||||||
|
|
||||||
|
def _build_scene_dict(
|
||||||
|
self,
|
||||||
|
mesh_path: str,
|
||||||
|
material: RenderMaterial,
|
||||||
|
camera: RenderCamera,
|
||||||
|
settings: RenderSettings,
|
||||||
|
) -> dict:
|
||||||
|
"""Build a Mitsuba scene dictionary from our data classes.
|
||||||
|
|
||||||
|
Uses a 3-point lighting setup (key + fill + rim) plus an ambient
|
||||||
|
environment emitter for soft fill, giving well-balanced shading on
|
||||||
|
all faces of the model. Lighting intensities and colors come from
|
||||||
|
``settings.lighting``; ground plane comes from ``settings.ground_plane``.
|
||||||
|
"""
|
||||||
|
import mitsuba as mi
|
||||||
|
|
||||||
|
lighting = settings.lighting
|
||||||
|
ground = settings.ground_plane
|
||||||
|
|
||||||
|
# Map our BSDF types to Mitsuba BSDF dicts
|
||||||
|
bsdf = self._make_bsdf(material)
|
||||||
|
|
||||||
|
# Determine mesh file type from extension
|
||||||
|
ext = os.path.splitext(mesh_path)[1].lower()
|
||||||
|
shape_type = "ply" if ext == ".ply" else "obj"
|
||||||
|
|
||||||
|
# Build camera-to-world transform using the Python API
|
||||||
|
cam_to_world = mi.ScalarTransform4f.look_at(
|
||||||
|
origin=list(camera.origin),
|
||||||
|
target=list(camera.target),
|
||||||
|
up=list(camera.up),
|
||||||
|
)
|
||||||
|
|
||||||
|
scene = {
|
||||||
|
"type": "scene",
|
||||||
|
# Integrator
|
||||||
|
"integrator": {
|
||||||
|
"type": "path",
|
||||||
|
"max_depth": settings.max_depth,
|
||||||
|
},
|
||||||
|
# Camera
|
||||||
|
"sensor": {
|
||||||
|
"type": "perspective",
|
||||||
|
"fov": camera.fov,
|
||||||
|
"to_world": cam_to_world,
|
||||||
|
"film": {
|
||||||
|
"type": "hdrfilm",
|
||||||
|
"width": settings.width,
|
||||||
|
"height": settings.height,
|
||||||
|
"rfilter": {"type": "gaussian"},
|
||||||
|
},
|
||||||
|
"sampler": {
|
||||||
|
"type": "independent",
|
||||||
|
"sample_count": settings.spp,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
# Ambient environment fill
|
||||||
|
"emitter": {
|
||||||
|
"type": "constant",
|
||||||
|
"radiance": {
|
||||||
|
"type": "rgb",
|
||||||
|
"value": [
|
||||||
|
lighting.ambient_intensity,
|
||||||
|
lighting.ambient_intensity * 0.97,
|
||||||
|
lighting.ambient_intensity * 0.94,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
# Shape
|
||||||
|
"shape": {
|
||||||
|
"type": shape_type,
|
||||||
|
"filename": mesh_path,
|
||||||
|
"bsdf": bsdf,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── 3-point lighting (colors and intensities from config) ───
|
||||||
|
key_rgb = [c * lighting.key_intensity for c in lighting.key_color]
|
||||||
|
key_to_world = mi.ScalarTransform4f.look_at(
|
||||||
|
origin=[1.0, -0.8, 1.2],
|
||||||
|
target=[0.0, 0.0, 0.0],
|
||||||
|
up=[0.0, 0.0, 1.0],
|
||||||
|
)
|
||||||
|
scene["key_light"] = {
|
||||||
|
"type": "directional",
|
||||||
|
"to_world": key_to_world,
|
||||||
|
"irradiance": {"type": "rgb", "value": key_rgb},
|
||||||
|
}
|
||||||
|
|
||||||
|
fill_rgb = [c * lighting.fill_intensity for c in lighting.fill_color]
|
||||||
|
fill_to_world = mi.ScalarTransform4f.look_at(
|
||||||
|
origin=[-1.0, 0.6, 0.8],
|
||||||
|
target=[0.0, 0.0, 0.0],
|
||||||
|
up=[0.0, 0.0, 1.0],
|
||||||
|
)
|
||||||
|
scene["fill_light"] = {
|
||||||
|
"type": "directional",
|
||||||
|
"to_world": fill_to_world,
|
||||||
|
"irradiance": {"type": "rgb", "value": fill_rgb},
|
||||||
|
}
|
||||||
|
|
||||||
|
rim_rgb = [c * lighting.rim_intensity for c in lighting.rim_color]
|
||||||
|
rim_to_world = mi.ScalarTransform4f.look_at(
|
||||||
|
origin=[-0.3, 1.2, -0.8],
|
||||||
|
target=[0.0, 0.0, 0.0],
|
||||||
|
up=[0.0, 0.0, 1.0],
|
||||||
|
)
|
||||||
|
scene["rim_light"] = {
|
||||||
|
"type": "directional",
|
||||||
|
"to_world": rim_to_world,
|
||||||
|
"irradiance": {"type": "rgb", "value": rim_rgb},
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Ground plane / backdrop (optional) ─────────────────────
|
||||||
|
if ground.enabled:
|
||||||
|
try:
|
||||||
|
# Load mesh to compute bounds for ground/backdrop placement
|
||||||
|
mesh_shape = mi.load_dict({"type": shape_type, "filename": mesh_path})
|
||||||
|
bbox = mesh_shape.bbox()
|
||||||
|
bbox_min, bbox_max = bbox[0], bbox[1]
|
||||||
|
# Ground at model's lowest Z with 0.1% offset
|
||||||
|
model_height = bbox_max[2] - bbox_min[2]
|
||||||
|
ground_z = bbox_min[2] - 0.001 * model_height
|
||||||
|
dx = bbox_max[0] - bbox_min[0]
|
||||||
|
dy = bbox_max[1] - bbox_min[1]
|
||||||
|
dz = bbox_max[2] - bbox_min[2]
|
||||||
|
diag = float((dx * dx + dy * dy + dz * dz) ** 0.5)
|
||||||
|
except Exception:
|
||||||
|
# Fallback: place at origin with large default size
|
||||||
|
ground_z = -ground.distance_below
|
||||||
|
diag = 1000.0
|
||||||
|
|
||||||
|
bsdf_ground = {
|
||||||
|
"type": "diffuse",
|
||||||
|
"reflectance": {"type": "rgb", "value": list(ground.color)},
|
||||||
|
}
|
||||||
|
|
||||||
|
if ground.curved_backdrop:
|
||||||
|
# Photo booth style curved leinwand:
|
||||||
|
# - Flat floor section in front of the model
|
||||||
|
# - Curved cylinder behind that sweeps up and over
|
||||||
|
half_size = diag * 50.0 # huge floor
|
||||||
|
radius = diag * 3.0 # curvature radius
|
||||||
|
cyl_height = diag * 20.0 # width of the cylinder (along its axis)
|
||||||
|
scene["ground_floor"] = {
|
||||||
|
"type": "rectangle",
|
||||||
|
"to_world": mi.ScalarTransform4f.translate([0.0, 0.0, ground_z])
|
||||||
|
@ mi.ScalarTransform4f.scale([half_size, half_size, 1.0]),
|
||||||
|
"bsdf": bsdf_ground,
|
||||||
|
}
|
||||||
|
# Cylinder: axis along Y, positioned behind model, radius sweeps up
|
||||||
|
scene["ground_backdrop"] = {
|
||||||
|
"type": "cylinder",
|
||||||
|
"radius": radius,
|
||||||
|
"p0": [-cyl_height / 2, -radius, ground_z],
|
||||||
|
"p1": [cyl_height / 2, -radius, ground_z],
|
||||||
|
"to_world": mi.ScalarTransform4f.rotate([1, 0, 0], 90)
|
||||||
|
@ mi.ScalarTransform4f.translate([0.0, 0.0, -radius]),
|
||||||
|
"bsdf": bsdf_ground,
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
# Simple flat ground plane — very large so edges aren't visible
|
||||||
|
half_size = diag * 50.0
|
||||||
|
scene["ground_plane"] = {
|
||||||
|
"type": "rectangle",
|
||||||
|
"to_world": mi.ScalarTransform4f.translate([0.0, 0.0, ground_z])
|
||||||
|
@ mi.ScalarTransform4f.scale([half_size, half_size, 1.0]),
|
||||||
|
"bsdf": bsdf_ground,
|
||||||
|
}
|
||||||
|
|
||||||
|
return scene
|
||||||
|
|
||||||
|
def _make_bsdf(self, material: RenderMaterial) -> dict:
|
||||||
|
"""Convert a RenderMaterial to a Mitsuba BSDF dict."""
|
||||||
|
mt = material.bsdf_type
|
||||||
|
|
||||||
|
if mt == "roughconductor":
|
||||||
|
# Use material="none" with specular_reflectance set to the
|
||||||
|
# metal color. The pip-installed Mitsuba doesn't include
|
||||||
|
# spectral metal data files (iron.spd, copper.spd, etc.).
|
||||||
|
return {
|
||||||
|
"type": "roughconductor",
|
||||||
|
"material": "none",
|
||||||
|
"alpha": max(material.roughness, 0.01),
|
||||||
|
"specular_reflectance": {
|
||||||
|
"type": "rgb",
|
||||||
|
"value": list(material.color),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if mt == "roughdielectric":
|
||||||
|
return {
|
||||||
|
"type": "roughdielectric",
|
||||||
|
"int_ior": material.int_ior,
|
||||||
|
"ext_ior": 1.0,
|
||||||
|
"alpha": max(material.roughness, 0.01),
|
||||||
|
}
|
||||||
|
|
||||||
|
if mt == "dielectric":
|
||||||
|
return {
|
||||||
|
"type": "dielectric",
|
||||||
|
"int_ior": material.int_ior,
|
||||||
|
"ext_ior": 1.0,
|
||||||
|
}
|
||||||
|
|
||||||
|
if mt == "plastic":
|
||||||
|
return {
|
||||||
|
"type": "plastic",
|
||||||
|
"diffuse_reflectance": {
|
||||||
|
"type": "rgb",
|
||||||
|
"value": list(material.color),
|
||||||
|
},
|
||||||
|
"int_ior": material.int_ior,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Default: diffuse
|
||||||
|
return {
|
||||||
|
"type": "diffuse",
|
||||||
|
"reflectance": {
|
||||||
|
"type": "rgb",
|
||||||
|
"value": list(material.color),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Rendering ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def render(
|
||||||
|
self,
|
||||||
|
mesh_path: str,
|
||||||
|
material: RenderMaterial,
|
||||||
|
camera: RenderCamera,
|
||||||
|
settings: RenderSettings,
|
||||||
|
progress_callback: Optional[Callable[[float], None]] = None,
|
||||||
|
) -> np.ndarray:
|
||||||
|
"""Render a mesh file and return (H, W, 3) float32 RGB array."""
|
||||||
|
self._set_variant()
|
||||||
|
import mitsuba as mi
|
||||||
|
|
||||||
|
scene_dict = self._build_scene_dict(mesh_path, material, camera, settings)
|
||||||
|
scene = mi.load_dict(scene_dict)
|
||||||
|
|
||||||
|
logger.info(f"Rendering {settings.width}x{settings.height} @ {settings.spp} spp")
|
||||||
|
|
||||||
|
# Render
|
||||||
|
try:
|
||||||
|
image = mi.render(scene, spp=settings.spp, seed=int(settings.seed or 0)) # type: ignore[arg-type] # Mitsuba accepts int at runtime
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Mitsuba render failed: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
if progress_callback:
|
||||||
|
progress_callback(1.0)
|
||||||
|
|
||||||
|
# Convert to numpy (H, W, 3)
|
||||||
|
arr = np.array(image, dtype=np.float32)
|
||||||
|
|
||||||
|
# Apply approximate sRGB tonemapping
|
||||||
|
arr = np.clip(arr, 0.0, None)
|
||||||
|
arr = np.power(arr, 1.0 / 2.2) # gamma
|
||||||
|
arr = np.clip(arr, 0.0, 1.0)
|
||||||
|
|
||||||
|
return arr
|
||||||
|
|
||||||
|
def render_preview(
|
||||||
|
self,
|
||||||
|
mesh_path: str,
|
||||||
|
material: RenderMaterial,
|
||||||
|
camera: RenderCamera,
|
||||||
|
settings: RenderSettings,
|
||||||
|
) -> np.ndarray:
|
||||||
|
"""Quick low-quality preview (4x fewer spp)."""
|
||||||
|
preview_settings = RenderSettings(
|
||||||
|
width=settings.width // 2,
|
||||||
|
height=settings.height // 2,
|
||||||
|
spp=max(settings.spp // 4, 16),
|
||||||
|
max_depth=min(settings.max_depth, 4),
|
||||||
|
seed=settings.seed,
|
||||||
|
lighting=settings.lighting,
|
||||||
|
ground_plane=settings.ground_plane,
|
||||||
|
)
|
||||||
|
return self.render(mesh_path, material, camera, preview_settings)
|
||||||
|
|
||||||
|
# ── Export ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def export_image(self, image: np.ndarray, path: str) -> None:
|
||||||
|
"""Save a rendered image to PNG or EXR."""
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
ext = os.path.splitext(path)[1].lower()
|
||||||
|
|
||||||
|
if ext == ".exr":
|
||||||
|
# Save as EXR (HDR) — no tonemapping
|
||||||
|
try:
|
||||||
|
import OpenEXR # type: ignore[import-not-found]
|
||||||
|
import Imath # type: ignore[import-not-found]
|
||||||
|
|
||||||
|
h, w = image.shape[:2]
|
||||||
|
header = OpenEXR.Header(w, h)
|
||||||
|
header["channels"] = {
|
||||||
|
"R": Imath.PixelType(Imath.PixelType.FLOAT),
|
||||||
|
"G": Imath.PixelType(Imath.PixelType.FLOAT),
|
||||||
|
"B": Imath.PixelType(Imath.PixelType.FLOAT),
|
||||||
|
}
|
||||||
|
exr = OpenEXR.OutputFile(path, header)
|
||||||
|
exr.write(
|
||||||
|
{
|
||||||
|
"R": image[:, :, 0].tobytes(),
|
||||||
|
"G": image[:, :, 1].tobytes(),
|
||||||
|
"B": image[:, :, 2].tobytes(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
exr.close()
|
||||||
|
except ImportError:
|
||||||
|
# Fallback: save as 16-bit PNG
|
||||||
|
logger.warning("OpenEXR not available, saving as 16-bit PNG")
|
||||||
|
img = Image.fromarray((image * 65535).astype(np.uint16), "RGB")
|
||||||
|
img.save(path)
|
||||||
|
else:
|
||||||
|
# PNG / JPEG — already tonemapped
|
||||||
|
img = Image.fromarray((image * 255).astype(np.uint8), "RGB")
|
||||||
|
img.save(path)
|
||||||
|
|
||||||
|
logger.info(f"Exported render to {path}")
|
||||||
|
|
||||||
|
# ── Helpers ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _set_variant(self) -> None:
|
||||||
|
"""Set the Mitsuba variant (called once)."""
|
||||||
|
import sys
|
||||||
|
import io
|
||||||
|
|
||||||
|
# Suppress the harmless "LLVM API initialization failed" warning
|
||||||
|
# that drjit emits on macOS ARM when scalar variant is used.
|
||||||
|
old_stderr = sys.stderr
|
||||||
|
sys.stderr = io.StringIO()
|
||||||
|
try:
|
||||||
|
import mitsuba as mi
|
||||||
|
|
||||||
|
mi.set_variant("scalar_rgb")
|
||||||
|
finally:
|
||||||
|
sys.stderr = old_stderr
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,252 @@
|
|||||||
|
"""Convert OCC BRep shapes to mesh files for render backends.
|
||||||
|
|
||||||
|
Outputs PLY files (preferred by Mitsuba) or STL files.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
from typing import List, Optional, Tuple
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def occ_shape_to_ply(
|
||||||
|
shape,
|
||||||
|
output_path: Optional[str] = None,
|
||||||
|
linear_deflection: float = 0.1,
|
||||||
|
angular_deflection: float = 0.15,
|
||||||
|
) -> str:
|
||||||
|
"""Tessellate an OCC TopoDS_Shape and write as PLY.
|
||||||
|
|
||||||
|
Returns the path to the written PLY file.
|
||||||
|
"""
|
||||||
|
from OCP.BRepMesh import BRepMesh_IncrementalMesh
|
||||||
|
from OCP.TopExp import TopExp_Explorer
|
||||||
|
from OCP.TopAbs import TopAbs_FACE
|
||||||
|
from OCP.TopoDS import TopoDS
|
||||||
|
from OCP.BRep import BRep_Tool
|
||||||
|
from OCP.TopLoc import TopLoc_Location
|
||||||
|
|
||||||
|
# Tessellate
|
||||||
|
tess = BRepMesh_IncrementalMesh(
|
||||||
|
shape, linear_deflection, False, angular_deflection, True
|
||||||
|
)
|
||||||
|
tess.Perform()
|
||||||
|
|
||||||
|
# Extract triangulation from all faces
|
||||||
|
all_vertices: List[List[float]] = []
|
||||||
|
all_faces: List[List[int]] = []
|
||||||
|
vertex_offset = 0
|
||||||
|
|
||||||
|
from OCP.TopAbs import TopAbs_FORWARD
|
||||||
|
|
||||||
|
explorer = TopExp_Explorer(shape, TopAbs_FACE)
|
||||||
|
while explorer.More():
|
||||||
|
face = TopoDS.Face_s(explorer.Current())
|
||||||
|
location = TopLoc_Location()
|
||||||
|
triangulation = BRep_Tool.Triangulation_s(face, location)
|
||||||
|
|
||||||
|
if triangulation is None:
|
||||||
|
explorer.Next()
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Transform
|
||||||
|
trsf = location.Transformation()
|
||||||
|
|
||||||
|
# Check face orientation: FORWARD means the surface normal points
|
||||||
|
# outward from the solid; REVERSED means it points inward.
|
||||||
|
is_forward = (face.Orientation() == TopAbs_FORWARD)
|
||||||
|
|
||||||
|
# Extract vertices (apply location transform to positions)
|
||||||
|
nb_nodes = triangulation.NbNodes()
|
||||||
|
for i in range(1, nb_nodes + 1):
|
||||||
|
node = triangulation.Node(i)
|
||||||
|
pnt = node.Transformed(trsf)
|
||||||
|
all_vertices.append([pnt.X(), pnt.Y(), pnt.Z()])
|
||||||
|
|
||||||
|
# Extract triangles
|
||||||
|
# For REVERSED faces, swap winding order (n1, n3, n2) so that
|
||||||
|
# the computed normal points outward consistently.
|
||||||
|
nb_triangles = triangulation.NbTriangles()
|
||||||
|
for i in range(1, nb_triangles + 1):
|
||||||
|
tri = triangulation.Triangle(i)
|
||||||
|
n1, n2, n3 = tri.Get()
|
||||||
|
if is_forward:
|
||||||
|
all_faces.append([
|
||||||
|
n1 - 1 + vertex_offset,
|
||||||
|
n2 - 1 + vertex_offset,
|
||||||
|
n3 - 1 + vertex_offset,
|
||||||
|
])
|
||||||
|
else:
|
||||||
|
# Swap winding for REVERSED faces
|
||||||
|
all_faces.append([
|
||||||
|
n1 - 1 + vertex_offset,
|
||||||
|
n3 - 1 + vertex_offset,
|
||||||
|
n2 - 1 + vertex_offset,
|
||||||
|
])
|
||||||
|
|
||||||
|
vertex_offset += nb_nodes
|
||||||
|
explorer.Next()
|
||||||
|
|
||||||
|
if not all_vertices:
|
||||||
|
raise ValueError("Tessellation produced no vertices")
|
||||||
|
|
||||||
|
vertices = np.array(all_vertices, dtype=np.float32)
|
||||||
|
faces = np.array(all_faces, dtype=np.uint32)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Tessellation: {len(vertices)} vertices, {len(faces)} triangles"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Compute smooth vertex normals from face normals.
|
||||||
|
# Winding is already corrected during tessellation using OCC face orientation.
|
||||||
|
normals, corrected_faces = _compute_outward_normals(vertices, faces, shape)
|
||||||
|
|
||||||
|
# Write PLY with corrected faces and normals
|
||||||
|
if output_path is None:
|
||||||
|
fd, output_path = tempfile.mkstemp(suffix=".ply", prefix="fluency_render_")
|
||||||
|
os.close(fd)
|
||||||
|
|
||||||
|
_write_ply(output_path, vertices, corrected_faces, normals)
|
||||||
|
logger.info(f"Wrote PLY: {output_path}")
|
||||||
|
return output_path
|
||||||
|
|
||||||
|
|
||||||
|
def _compute_outward_normals(
|
||||||
|
vertices: np.ndarray,
|
||||||
|
faces: np.ndarray,
|
||||||
|
shape,
|
||||||
|
) -> Tuple[np.ndarray, np.ndarray]:
|
||||||
|
"""Compute outward-facing vertex normals and correct face winding.
|
||||||
|
|
||||||
|
The winding is already corrected during tessellation using OCC's face
|
||||||
|
orientation (TopAbs_FORWARD/REVERSED). This function computes smooth
|
||||||
|
vertex normals by averaging face normals at shared vertices.
|
||||||
|
|
||||||
|
Returns (normals, corrected_faces) for PLY export.
|
||||||
|
"""
|
||||||
|
n_verts = len(vertices)
|
||||||
|
v_normals = np.zeros((n_verts, 3), dtype=np.float64)
|
||||||
|
|
||||||
|
# Ensure faces is 2D (numpy creates (3,) for single-face meshes)
|
||||||
|
if faces.ndim == 1:
|
||||||
|
faces = faces.reshape(1, -1)
|
||||||
|
|
||||||
|
# Winding is already correct from tessellation (face orientation check).
|
||||||
|
# Just compute face normals and accumulate to vertices.
|
||||||
|
v0 = vertices[faces[:, 0]]
|
||||||
|
v1 = vertices[faces[:, 1]]
|
||||||
|
v2 = vertices[faces[:, 2]]
|
||||||
|
|
||||||
|
edge1 = v1 - v0
|
||||||
|
edge2 = v2 - v0
|
||||||
|
fn = np.cross(edge1, edge2)
|
||||||
|
|
||||||
|
# Normalize face normals
|
||||||
|
lengths = np.linalg.norm(fn, axis=1, keepdims=True)
|
||||||
|
lengths[lengths < 1e-10] = 1.0
|
||||||
|
fn /= lengths
|
||||||
|
|
||||||
|
# Accumulate to vertices
|
||||||
|
for i in range(len(faces)):
|
||||||
|
idx = faces[i]
|
||||||
|
v_normals[idx[0]] += fn[i]
|
||||||
|
v_normals[idx[1]] += fn[i]
|
||||||
|
v_normals[idx[2]] += fn[i]
|
||||||
|
|
||||||
|
# Normalize vertex normals
|
||||||
|
v_lengths = np.linalg.norm(v_normals, axis=1, keepdims=True)
|
||||||
|
v_lengths[v_lengths < 1e-10] = 1.0
|
||||||
|
v_normals /= v_lengths
|
||||||
|
|
||||||
|
return v_normals.astype(np.float32), faces.astype(np.uint32)
|
||||||
|
|
||||||
|
|
||||||
|
def occ_shape_to_stl(
|
||||||
|
shape,
|
||||||
|
output_path: Optional[str] = None,
|
||||||
|
linear_deflection: float = 0.1,
|
||||||
|
) -> str:
|
||||||
|
"""Tessellate an OCC TopoDS_Shape and write as binary STL.
|
||||||
|
|
||||||
|
Returns the path to the written STL file.
|
||||||
|
"""
|
||||||
|
from OCP.BRepMesh import BRepMesh_IncrementalMesh
|
||||||
|
from OCP.StlAPI import StlAPI_Writer
|
||||||
|
|
||||||
|
# Tessellate
|
||||||
|
tess = BRepMesh_IncrementalMesh(shape, linear_deflection, False, 0.5, True)
|
||||||
|
tess.Perform()
|
||||||
|
|
||||||
|
if output_path is None:
|
||||||
|
fd, output_path = tempfile.mkstemp(suffix=".stl", prefix="fluency_render_")
|
||||||
|
os.close(fd)
|
||||||
|
|
||||||
|
writer = StlAPI_Writer()
|
||||||
|
writer.SetASCIIMode(False)
|
||||||
|
writer.Write(shape, output_path)
|
||||||
|
logger.info(f"Wrote STL: {output_path}")
|
||||||
|
return output_path
|
||||||
|
|
||||||
|
|
||||||
|
def occ_shape_bounds(shape) -> Tuple[Tuple[float, float, float], Tuple[float, float, float]]:
|
||||||
|
"""Return (min_xyz, max_xyz) bounding box of an OCC shape."""
|
||||||
|
from OCP.Bnd import Bnd_Box
|
||||||
|
from OCP.BRepBndLib import BRepBndLib
|
||||||
|
|
||||||
|
bbox = Bnd_Box()
|
||||||
|
BRepBndLib.Add_s(shape, bbox)
|
||||||
|
xmin, ymin, zmin, xmax, ymax, zmax = bbox.Get()
|
||||||
|
return (xmin, ymin, zmin), (xmax, ymax, zmax)
|
||||||
|
|
||||||
|
|
||||||
|
def _write_ply(
|
||||||
|
path: str,
|
||||||
|
vertices: np.ndarray,
|
||||||
|
faces: np.ndarray,
|
||||||
|
normals: Optional[np.ndarray] = None,
|
||||||
|
) -> None:
|
||||||
|
"""Write a binary PLY file (little-endian) with optional vertex normals."""
|
||||||
|
import struct
|
||||||
|
|
||||||
|
n_verts = len(vertices)
|
||||||
|
n_faces = len(faces)
|
||||||
|
has_normals = normals is not None and len(normals) == n_verts
|
||||||
|
|
||||||
|
with open(path, "wb") as f:
|
||||||
|
# Header
|
||||||
|
header_lines = [
|
||||||
|
"ply",
|
||||||
|
"format binary_little_endian 1.0",
|
||||||
|
f"element vertex {n_verts}",
|
||||||
|
"property float x",
|
||||||
|
"property float y",
|
||||||
|
"property float z",
|
||||||
|
]
|
||||||
|
if has_normals:
|
||||||
|
header_lines.extend([
|
||||||
|
"property float nx",
|
||||||
|
"property float ny",
|
||||||
|
"property float nz",
|
||||||
|
])
|
||||||
|
header_lines.append(f"element face {n_faces}")
|
||||||
|
header_lines.append("property list uchar int vertex_indices")
|
||||||
|
header_lines.append("end_header")
|
||||||
|
|
||||||
|
f.write(("\n".join(header_lines) + "\n").encode("ascii"))
|
||||||
|
|
||||||
|
# Vertex positions (+ normals if available)
|
||||||
|
for i in range(n_verts):
|
||||||
|
f.write(struct.pack("<fff", vertices[i, 0], vertices[i, 1], vertices[i, 2]))
|
||||||
|
if has_normals:
|
||||||
|
f.write(struct.pack("<fff", normals[i, 0], normals[i, 1], normals[i, 2]))
|
||||||
|
|
||||||
|
# Faces
|
||||||
|
for face in faces:
|
||||||
|
f.write(struct.pack("<B", 3))
|
||||||
|
f.write(struct.pack("<iii", int(face[0]), int(face[1]), int(face[2])))
|
||||||
@@ -0,0 +1,479 @@
|
|||||||
|
"""
|
||||||
|
pygfx-based renderer for Fluency CAD.
|
||||||
|
|
||||||
|
This module provides a modern WebGPU-based renderer using pygfx,
|
||||||
|
offering a smaller dependency footprint than VTK while providing
|
||||||
|
excellent 3D visualization capabilities.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import List, Tuple, Optional, Callable, Any
|
||||||
|
import numpy as np
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from fluency.rendering.base import (
|
||||||
|
Renderer,
|
||||||
|
RenderObject,
|
||||||
|
RenderColor,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def compute_normals(vertices: np.ndarray, faces: np.ndarray) -> np.ndarray:
|
||||||
|
"""Compute vertex normals from positions and face indices."""
|
||||||
|
vertices = np.asarray(vertices, dtype=np.float32)
|
||||||
|
faces = np.asarray(faces, dtype=np.int32)
|
||||||
|
|
||||||
|
normals = np.zeros_like(vertices)
|
||||||
|
|
||||||
|
for face in faces:
|
||||||
|
v0, v1, v2 = vertices[face[0]], vertices[face[1]], vertices[face[2]]
|
||||||
|
|
||||||
|
edge1 = v1 - v0
|
||||||
|
edge2 = v2 - v0
|
||||||
|
|
||||||
|
face_normal = np.cross(edge1, edge2)
|
||||||
|
|
||||||
|
length = np.linalg.norm(face_normal)
|
||||||
|
if length > 1e-10:
|
||||||
|
face_normal = face_normal / length
|
||||||
|
|
||||||
|
normals[face[0]] += face_normal
|
||||||
|
normals[face[1]] += face_normal
|
||||||
|
normals[face[2]] += face_normal
|
||||||
|
|
||||||
|
lengths = np.linalg.norm(normals, axis=1, keepdims=True)
|
||||||
|
lengths = np.where(lengths > 1e-10, lengths, 1)
|
||||||
|
normals = normals / lengths
|
||||||
|
|
||||||
|
return normals.astype(np.float32)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PygfxRenderObject(RenderObject):
|
||||||
|
"""pygfx render object wrapper."""
|
||||||
|
|
||||||
|
scene_node: Any = None
|
||||||
|
geometry: Any = None
|
||||||
|
material: Any = None
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
scene_node: Any = None,
|
||||||
|
geometry: Any = None,
|
||||||
|
material: Any = None,
|
||||||
|
name: Optional[str] = None,
|
||||||
|
):
|
||||||
|
super().__init__(name=name)
|
||||||
|
self.scene_node = scene_node
|
||||||
|
self.geometry = geometry
|
||||||
|
self.material = material
|
||||||
|
|
||||||
|
|
||||||
|
class PygfxRenderer(Renderer):
|
||||||
|
"""
|
||||||
|
pygfx-based renderer implementation.
|
||||||
|
|
||||||
|
This renderer uses pygfx (WebGPU-based) for 3D visualization,
|
||||||
|
providing modern rendering with a small dependency footprint.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._canvas: Any = None
|
||||||
|
self._renderer: Any = None
|
||||||
|
self._scene: Any = None
|
||||||
|
self._camera: Any = None
|
||||||
|
self._controller: Any = None
|
||||||
|
self._objects: List[PygfxRenderObject] = []
|
||||||
|
self._pick_callback: Optional[Callable[[Any], None]] = None
|
||||||
|
self._camera_change_callback: Optional[Callable[[], None]] = None
|
||||||
|
self._background_color: Tuple[float, float, float] = (0.1, 0.1, 0.15)
|
||||||
|
self._initialized: bool = False
|
||||||
|
|
||||||
|
def initialize(self, parent_widget: Any) -> bool:
|
||||||
|
"""Initialize pygfx with Qt widget."""
|
||||||
|
try:
|
||||||
|
import pygfx as gfx
|
||||||
|
from rendercanvas.qt import RenderWidget
|
||||||
|
from PySide6.QtWidgets import QVBoxLayout
|
||||||
|
|
||||||
|
self._canvas = RenderWidget(parent=parent_widget)
|
||||||
|
self._renderer = gfx.renderers.WgpuRenderer(self._canvas)
|
||||||
|
self._scene = gfx.Scene()
|
||||||
|
|
||||||
|
self._camera = gfx.PerspectiveCamera(50, 16 / 9)
|
||||||
|
self._camera.local.position = (100, 100, 100)
|
||||||
|
|
||||||
|
self._controller = gfx.OrbitController(self._camera)
|
||||||
|
self._controller.register_events(self._renderer)
|
||||||
|
|
||||||
|
self._setup_lighting()
|
||||||
|
self._add_grid()
|
||||||
|
|
||||||
|
layout = QVBoxLayout(parent_widget)
|
||||||
|
layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
layout.addWidget(self._canvas)
|
||||||
|
|
||||||
|
self._canvas.request_draw(self._animate)
|
||||||
|
|
||||||
|
self._setup_picking()
|
||||||
|
self._initialized = True
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Failed to initialize pygfx: {e}")
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
traceback.print_exc()
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _animate(self):
|
||||||
|
"""Animation callback for the canvas."""
|
||||||
|
if self._initialized:
|
||||||
|
self._renderer.render(self._scene, self._camera)
|
||||||
|
|
||||||
|
def _setup_lighting(self) -> None:
|
||||||
|
"""Setup scene lighting."""
|
||||||
|
import pygfx as gfx
|
||||||
|
|
||||||
|
ambient = gfx.AmbientLight(intensity=0.3)
|
||||||
|
self._scene.add(ambient)
|
||||||
|
|
||||||
|
directional = gfx.DirectionalLight(intensity=1.0)
|
||||||
|
directional.local.position = (100, 100, 100)
|
||||||
|
self._scene.add(directional)
|
||||||
|
|
||||||
|
fill = gfx.DirectionalLight(intensity=0.5)
|
||||||
|
fill.local.position = (-100, 50, 50)
|
||||||
|
self._scene.add(fill)
|
||||||
|
|
||||||
|
def _add_grid(self) -> None:
|
||||||
|
"""Add reference grid."""
|
||||||
|
import pygfx as gfx
|
||||||
|
|
||||||
|
grid = gfx.GridHelper(
|
||||||
|
size=200, divisions=20, color1=(0.3, 0.3, 0.3, 1), color2=(0.2, 0.2, 0.2, 1)
|
||||||
|
)
|
||||||
|
self._scene.add(grid)
|
||||||
|
|
||||||
|
def _setup_picking(self) -> None:
|
||||||
|
"""Setup mesh picking."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
def shutdown(self) -> None:
|
||||||
|
"""Clean up renderer resources."""
|
||||||
|
self._objects.clear()
|
||||||
|
self._initialized = False
|
||||||
|
|
||||||
|
def add_mesh(
|
||||||
|
self,
|
||||||
|
vertices: np.ndarray,
|
||||||
|
faces: np.ndarray,
|
||||||
|
color: Tuple[float, float, float] = (0.2, 0.4, 0.8),
|
||||||
|
name: Optional[str] = None,
|
||||||
|
) -> str:
|
||||||
|
"""Add a mesh to the scene. Returns the mesh ID."""
|
||||||
|
import pygfx as gfx
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
vertices = np.asarray(vertices, dtype=np.float32)
|
||||||
|
faces = np.asarray(faces, dtype=np.int32)
|
||||||
|
|
||||||
|
normals = compute_normals(vertices, faces)
|
||||||
|
|
||||||
|
geometry = gfx.Geometry(positions=vertices, indices=faces, normals=normals)
|
||||||
|
|
||||||
|
material = gfx.MeshPhongMaterial(color=color, flat_shading=False)
|
||||||
|
|
||||||
|
mesh = gfx.Mesh(geometry, material)
|
||||||
|
self._scene.add(mesh)
|
||||||
|
|
||||||
|
mesh_id = name or f"mesh_{uuid.uuid4().hex[:8]}"
|
||||||
|
obj = PygfxRenderObject(name=mesh_id, scene_node=mesh, geometry=geometry, material=material)
|
||||||
|
obj.color = RenderColor(*color)
|
||||||
|
self._objects.append(obj)
|
||||||
|
|
||||||
|
return mesh_id
|
||||||
|
|
||||||
|
def add_wireframe(
|
||||||
|
self,
|
||||||
|
vertices: np.ndarray,
|
||||||
|
edges: np.ndarray,
|
||||||
|
color: Tuple[float, float, float] = (1.0, 1.0, 1.0),
|
||||||
|
line_width: float = 1.0,
|
||||||
|
name: Optional[str] = None,
|
||||||
|
) -> str:
|
||||||
|
"""Add a wireframe to the scene. Returns the wireframe ID."""
|
||||||
|
import pygfx as gfx
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
positions: List[List[float]] = []
|
||||||
|
for edge in edges:
|
||||||
|
positions.append(vertices[edge[0]])
|
||||||
|
positions.append(vertices[edge[1]])
|
||||||
|
|
||||||
|
positions_arr = np.array(positions, dtype=np.float32)
|
||||||
|
|
||||||
|
geometry = gfx.Geometry(positions=positions_arr)
|
||||||
|
material = gfx.LineMaterial(color=color, thickness=line_width)
|
||||||
|
|
||||||
|
lines = gfx.Line(geometry, material)
|
||||||
|
self._scene.add(lines)
|
||||||
|
|
||||||
|
wireframe_id = name or f"wireframe_{uuid.uuid4().hex[:8]}"
|
||||||
|
obj = PygfxRenderObject(
|
||||||
|
name=wireframe_id, scene_node=lines, geometry=geometry, material=material
|
||||||
|
)
|
||||||
|
obj.color = RenderColor(*color)
|
||||||
|
self._objects.append(obj)
|
||||||
|
|
||||||
|
return wireframe_id
|
||||||
|
|
||||||
|
def add_points(
|
||||||
|
self,
|
||||||
|
points: np.ndarray,
|
||||||
|
color: Tuple[float, float, float] = (1.0, 0.0, 0.0),
|
||||||
|
size: float = 5.0,
|
||||||
|
name: Optional[str] = None,
|
||||||
|
) -> str:
|
||||||
|
"""Add points to the scene. Returns the points ID."""
|
||||||
|
import pygfx as gfx
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
points_arr = np.asarray(points, dtype=np.float32)
|
||||||
|
|
||||||
|
geometry = gfx.Geometry(positions=points_arr)
|
||||||
|
material = gfx.PointsMaterial(color=color, size=size)
|
||||||
|
|
||||||
|
points_obj = gfx.Points(geometry, material)
|
||||||
|
self._scene.add(points_obj)
|
||||||
|
|
||||||
|
points_id = name or f"points_{uuid.uuid4().hex[:8]}"
|
||||||
|
obj = PygfxRenderObject(
|
||||||
|
name=points_id, scene_node=points_obj, geometry=geometry, material=material
|
||||||
|
)
|
||||||
|
obj.color = RenderColor(*color)
|
||||||
|
self._objects.append(obj)
|
||||||
|
|
||||||
|
return points_id
|
||||||
|
|
||||||
|
def add_lines(
|
||||||
|
self,
|
||||||
|
start_points: np.ndarray,
|
||||||
|
end_points: np.ndarray,
|
||||||
|
color: Tuple[float, float, float] = (1.0, 1.0, 1.0),
|
||||||
|
line_width: float = 1.0,
|
||||||
|
name: Optional[str] = None,
|
||||||
|
) -> str:
|
||||||
|
"""Add line segments to the scene. Returns the lines ID."""
|
||||||
|
import pygfx as gfx
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
positions = np.hstack([start_points, end_points]).flatten()
|
||||||
|
positions = positions.reshape(-1, 3).astype(np.float32)
|
||||||
|
|
||||||
|
geometry = gfx.Geometry(positions=positions)
|
||||||
|
material = gfx.LineMaterial(color=color, thickness=line_width)
|
||||||
|
|
||||||
|
lines = gfx.Line(geometry, material)
|
||||||
|
self._scene.add(lines)
|
||||||
|
|
||||||
|
lines_id = name or f"lines_{uuid.uuid4().hex[:8]}"
|
||||||
|
obj = PygfxRenderObject(
|
||||||
|
name=lines_id, scene_node=lines, geometry=geometry, material=material
|
||||||
|
)
|
||||||
|
obj.color = RenderColor(*color)
|
||||||
|
self._objects.append(obj)
|
||||||
|
|
||||||
|
return lines_id
|
||||||
|
|
||||||
|
def remove_object(self, obj: RenderObject) -> bool:
|
||||||
|
"""Remove an object from the scene."""
|
||||||
|
if isinstance(obj, PygfxRenderObject) and obj in self._objects:
|
||||||
|
if obj.scene_node is not None:
|
||||||
|
self._scene.remove(obj.scene_node)
|
||||||
|
self._objects.remove(obj)
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def remove_mesh(self, mesh_id: str) -> bool:
|
||||||
|
"""Remove a mesh by its ID."""
|
||||||
|
for obj in self._objects:
|
||||||
|
if obj.name == mesh_id:
|
||||||
|
return self.remove_object(obj)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def clear_scene(self) -> None:
|
||||||
|
"""Remove all objects from the scene."""
|
||||||
|
for obj in self._objects[:]:
|
||||||
|
if obj.scene_node is not None:
|
||||||
|
self._scene.remove(obj.scene_node)
|
||||||
|
self._objects.clear()
|
||||||
|
|
||||||
|
def update_mesh(self, obj: RenderObject, vertices: np.ndarray, faces: np.ndarray) -> bool:
|
||||||
|
"""Update mesh geometry."""
|
||||||
|
if not isinstance(obj, PygfxRenderObject):
|
||||||
|
return False
|
||||||
|
|
||||||
|
import pygfx as gfx
|
||||||
|
|
||||||
|
vertices = np.asarray(vertices, dtype=np.float32)
|
||||||
|
faces = np.asarray(faces, dtype=np.int32)
|
||||||
|
|
||||||
|
normals = compute_normals(vertices, faces)
|
||||||
|
|
||||||
|
geometry = gfx.Geometry(positions=vertices, indices=faces, normals=normals)
|
||||||
|
|
||||||
|
obj.geometry = geometry
|
||||||
|
if obj.scene_node is not None:
|
||||||
|
obj.scene_node.geometry = geometry
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def set_object_color(self, obj: RenderObject, color: Tuple[float, float, float]) -> None:
|
||||||
|
"""Set the color of an object."""
|
||||||
|
if isinstance(obj, PygfxRenderObject):
|
||||||
|
obj.color = RenderColor(*color)
|
||||||
|
if obj.material is not None:
|
||||||
|
obj.material.color = color
|
||||||
|
|
||||||
|
def set_object_visible(self, obj: RenderObject, visible: bool) -> None:
|
||||||
|
"""Set the visibility of an object."""
|
||||||
|
if isinstance(obj, PygfxRenderObject):
|
||||||
|
obj.visible = visible
|
||||||
|
if obj.scene_node is not None:
|
||||||
|
obj.scene_node.visible = visible
|
||||||
|
|
||||||
|
def set_camera_position(
|
||||||
|
self,
|
||||||
|
position: Tuple[float, float, float],
|
||||||
|
target: Tuple[float, float, float] = (0, 0, 0),
|
||||||
|
up: Tuple[float, float, float] = (0, 0, 1),
|
||||||
|
) -> None:
|
||||||
|
"""Set camera position and orientation."""
|
||||||
|
self._camera.local.position = position
|
||||||
|
self._camera.look_at(target)
|
||||||
|
|
||||||
|
def get_camera_position(self) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
|
||||||
|
"""Get camera position, target, and up vector."""
|
||||||
|
pos = np.array(self._camera.local.position)
|
||||||
|
target = np.array([0, 0, 0])
|
||||||
|
up = np.array(self._camera.local.up)
|
||||||
|
return pos, target, up
|
||||||
|
|
||||||
|
def fit_camera(self, padding: float = 1.1) -> None:
|
||||||
|
"""Fit camera to show all objects."""
|
||||||
|
if not self._objects:
|
||||||
|
return
|
||||||
|
|
||||||
|
all_positions: List[np.ndarray] = []
|
||||||
|
for obj in self._objects:
|
||||||
|
if obj.geometry is not None and hasattr(obj.geometry, "positions"):
|
||||||
|
positions = obj.geometry.positions.data
|
||||||
|
all_positions.append(positions)
|
||||||
|
|
||||||
|
if all_positions:
|
||||||
|
positions = np.vstack(all_positions)
|
||||||
|
min_pos = positions.min(axis=0)
|
||||||
|
max_pos = positions.max(axis=0)
|
||||||
|
center = (min_pos + max_pos) / 2
|
||||||
|
size = np.linalg.norm(max_pos - min_pos) * padding
|
||||||
|
|
||||||
|
self._camera.local.position = (center[0] + size, center[1] + size, center[2] + size)
|
||||||
|
self._camera.look_at(tuple(center))
|
||||||
|
|
||||||
|
def set_camera_perspective(
|
||||||
|
self, fov: float = 50.0, near: float = 0.1, far: float = 10000.0
|
||||||
|
) -> None:
|
||||||
|
"""Set camera perspective parameters."""
|
||||||
|
self._camera.fov = fov
|
||||||
|
self._camera.near = near
|
||||||
|
self._camera.far = far
|
||||||
|
|
||||||
|
def set_camera_orthographic(
|
||||||
|
self, width: float = 100.0, near: float = 0.1, far: float = 10000.0
|
||||||
|
) -> None:
|
||||||
|
"""Set camera orthographic parameters."""
|
||||||
|
import pygfx as gfx
|
||||||
|
|
||||||
|
self._camera = gfx.OrthographicCamera(width=width, near=near, far=far)
|
||||||
|
self._controller.camera = self._camera
|
||||||
|
|
||||||
|
def render(self) -> None:
|
||||||
|
"""Trigger a render."""
|
||||||
|
if self._initialized:
|
||||||
|
self._canvas.request_draw()
|
||||||
|
|
||||||
|
def on_pick(self, callback: Callable[[Any], None]) -> None:
|
||||||
|
"""Register a callback for picking/selection."""
|
||||||
|
self._pick_callback = callback
|
||||||
|
|
||||||
|
def on_camera_change(self, callback: Callable[[], None]) -> None:
|
||||||
|
"""Register a callback for camera changes."""
|
||||||
|
self._camera_change_callback = callback
|
||||||
|
|
||||||
|
def set_background_color(self, color: Tuple[float, float, float]) -> None:
|
||||||
|
"""Set the background color."""
|
||||||
|
self._background_color = color
|
||||||
|
self._scene.background = color
|
||||||
|
|
||||||
|
def add_grid(
|
||||||
|
self,
|
||||||
|
size: float = 100.0,
|
||||||
|
divisions: int = 10,
|
||||||
|
color: Tuple[float, float, float] = (0.3, 0.3, 0.3),
|
||||||
|
) -> str:
|
||||||
|
"""Add a reference grid. Returns the grid ID."""
|
||||||
|
import pygfx as gfx
|
||||||
|
|
||||||
|
grid = gfx.GridHelper(
|
||||||
|
size=size, divisions=divisions, color1=(*color, 1), color2=(*color, 0.5)
|
||||||
|
)
|
||||||
|
self._scene.add(grid)
|
||||||
|
|
||||||
|
obj = PygfxRenderObject(name="grid", scene_node=grid)
|
||||||
|
self._objects.append(obj)
|
||||||
|
return "grid"
|
||||||
|
|
||||||
|
def add_axes(self, size: float = 10.0, visible: bool = True) -> str:
|
||||||
|
"""Add coordinate axes. Returns the axes ID."""
|
||||||
|
import pygfx as gfx
|
||||||
|
|
||||||
|
axes = gfx.AxesHelper(size=size)
|
||||||
|
axes.visible = visible
|
||||||
|
self._scene.add(axes)
|
||||||
|
|
||||||
|
obj = PygfxRenderObject(name="axes", scene_node=axes)
|
||||||
|
self._objects.append(obj)
|
||||||
|
return "axes"
|
||||||
|
|
||||||
|
def get_screen_size(self) -> Tuple[int, int]:
|
||||||
|
"""Get the screen size in pixels."""
|
||||||
|
if self._canvas is not None:
|
||||||
|
return self._canvas.get_physical_size()
|
||||||
|
return (800, 600)
|
||||||
|
|
||||||
|
def project_to_screen(self, point: Tuple[float, float, float]) -> Tuple[int, int]:
|
||||||
|
"""Project a 3D point to screen coordinates."""
|
||||||
|
return (0, 0)
|
||||||
|
|
||||||
|
def unproject_from_screen(
|
||||||
|
self, screen_x: int, screen_y: int, depth: float = 0.0
|
||||||
|
) -> Tuple[float, float, float]:
|
||||||
|
"""Unproject screen coordinates to 3D."""
|
||||||
|
return (0.0, 0.0, 0.0)
|
||||||
|
|
||||||
|
def take_screenshot(self) -> np.ndarray:
|
||||||
|
"""Take a screenshot of the current view."""
|
||||||
|
return np.zeros((100, 100, 4), dtype=np.uint8)
|
||||||
|
|
||||||
|
def save_screenshot(self, filepath: str) -> bool:
|
||||||
|
"""Save a screenshot to file."""
|
||||||
|
try:
|
||||||
|
img = self.take_screenshot()
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
Image.fromarray(img).save(filepath)
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Screenshot error: {e}")
|
||||||
|
return False
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
"""Abstract render backend interface.
|
||||||
|
|
||||||
|
Any photorealistic renderer (Mitsuba, Blender, Cycles, ...) implements
|
||||||
|
:class:`RenderBackend`. The UI only talks to this ABC so backends can be
|
||||||
|
swapped by changing one import.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class RenderMaterial:
|
||||||
|
"""PBR material description for the render backend."""
|
||||||
|
|
||||||
|
name: str = "Default"
|
||||||
|
color: tuple[float, float, float] = (0.7, 0.7, 0.7)
|
||||||
|
metallic: float = 0.0 # 0.0 = dielectric, 1.0 = metal
|
||||||
|
roughness: float = 0.5 # 0.0 = mirror, 1.0 = fully rough
|
||||||
|
bsdf_type: str = "diffuse" # diffuse | roughconductor | roughdielectric | plastic
|
||||||
|
# Optional: named metal preset (copper, aluminium, gold, chrome, steel)
|
||||||
|
metal_preset: Optional[str] = None
|
||||||
|
# For dielectric / plastic
|
||||||
|
int_ior: float = 1.5
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class RenderCamera:
|
||||||
|
"""Camera parameters for the render."""
|
||||||
|
|
||||||
|
origin: tuple[float, float, float] = (100.0, 100.0, 100.0)
|
||||||
|
target: tuple[float, float, float] = (0.0, 0.0, 0.0)
|
||||||
|
up: tuple[float, float, float] = (0.0, 0.0, 1.0)
|
||||||
|
fov: float = 60.0 # vertical field of view in degrees
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class LightingConfig:
|
||||||
|
"""Lighting configuration for the render scene."""
|
||||||
|
|
||||||
|
ambient_intensity: float = 0.3 # constant environment fill [0..1]
|
||||||
|
key_color: tuple[float, float, float] = (1.0, 0.98, 0.95) # RGB key light color
|
||||||
|
key_intensity: float = 3.5 # key light irradiance multiplier
|
||||||
|
fill_color: tuple[float, float, float] = (0.92, 0.94, 1.0) # RGB fill light color
|
||||||
|
fill_intensity: float = 1.5 # fill light irradiance multiplier
|
||||||
|
rim_color: tuple[float, float, float] = (1.0, 0.98, 0.96) # RGB rim light color
|
||||||
|
rim_intensity: float = 1.2 # rim light irradiance multiplier
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class GroundPlaneConfig:
|
||||||
|
"""Ground plane configuration for the render scene."""
|
||||||
|
|
||||||
|
enabled: bool = False
|
||||||
|
color: tuple[float, float, float] = (0.5, 0.5, 0.5) # RGB diffuse color
|
||||||
|
roughness: float = 0.8 # surface roughness [0..1]
|
||||||
|
distance_below: float = 0.0 # mm below origin (positive = below)
|
||||||
|
curved_backdrop: bool = False # photo booth style curved leinwand
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class RenderSettings:
|
||||||
|
"""Quality / resolution settings."""
|
||||||
|
|
||||||
|
width: int = 1920
|
||||||
|
height: int = 1080
|
||||||
|
spp: int = 256 # samples per pixel
|
||||||
|
max_depth: int = 8 # max bounces for path tracer
|
||||||
|
seed: int = 0 # random seed (0 = auto)
|
||||||
|
lighting: LightingConfig = field(default_factory=LightingConfig)
|
||||||
|
ground_plane: GroundPlaneConfig = field(default_factory=GroundPlaneConfig)
|
||||||
|
|
||||||
|
|
||||||
|
class RenderBackend(ABC):
|
||||||
|
"""Abstract photorealistic renderer.
|
||||||
|
|
||||||
|
Implementations live in separate modules so backends can be swapped
|
||||||
|
without touching the UI. Typical call::
|
||||||
|
|
||||||
|
backend = MitsubaBackend()
|
||||||
|
image = backend.render(obj_path, material, camera, settings)
|
||||||
|
"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def name(self) -> str:
|
||||||
|
"""Human-readable backend name (shown in UI)."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def is_available(self) -> bool:
|
||||||
|
"""Return True if this backend's dependencies are installed."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def render(
|
||||||
|
self,
|
||||||
|
mesh_path: str,
|
||||||
|
material: RenderMaterial,
|
||||||
|
camera: RenderCamera,
|
||||||
|
settings: RenderSettings,
|
||||||
|
progress_callback=None,
|
||||||
|
) -> "np.ndarray":
|
||||||
|
"""Render a mesh file and return an (H, W, 3) float32 RGB array.
|
||||||
|
|
||||||
|
*mesh_path* is an STL or OBJ file on disk.
|
||||||
|
*progress_callback(fraction)* is called with 0.0–1.0 progress.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def render_preview(
|
||||||
|
self,
|
||||||
|
mesh_path: str,
|
||||||
|
material: RenderMaterial,
|
||||||
|
camera: RenderCamera,
|
||||||
|
settings: RenderSettings,
|
||||||
|
) -> "np.ndarray":
|
||||||
|
"""Quick low-quality preview (fewer spp)."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def export_image(self, image: "np.ndarray", path: str) -> None:
|
||||||
|
"""Save a rendered image to PNG / EXR."""
|
||||||
|
|
||||||
|
def default_camera_from_bounds(
|
||||||
|
self, bounds_min: tuple[float, float, float], bounds_max: tuple[float, float, float]
|
||||||
|
) -> RenderCamera:
|
||||||
|
"""Compute a sensible default camera looking at the bbox centre."""
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
mn = np.asarray(bounds_min, dtype=float)
|
||||||
|
mx = np.asarray(bounds_max, dtype=float)
|
||||||
|
centre = (mn + mx) / 2.0
|
||||||
|
diag = float(np.linalg.norm(mx - mn))
|
||||||
|
# Place camera at iso-ish position, far enough to see everything.
|
||||||
|
eye = centre + np.array([0.7, -0.7, 0.5]) * diag * 0.8
|
||||||
|
return RenderCamera(
|
||||||
|
origin=tuple(eye.tolist()),
|
||||||
|
target=tuple(centre.tolist()),
|
||||||
|
up=(0.0, 0.0, 1.0),
|
||||||
|
fov=45.0,
|
||||||
|
)
|
||||||
@@ -0,0 +1,326 @@
|
|||||||
|
"""
|
||||||
|
SolveSpace-based constraint solver for Fluency CAD.
|
||||||
|
|
||||||
|
Provides integration between python-solvespace (SolverSystem) and
|
||||||
|
the Fluency CAD sketch pipeline (OCCSketch). Drawing operations
|
||||||
|
add entities to BOTH the OCCSketch (for OCC->render pipeline) and
|
||||||
|
the SolverSketch (for constraint solving). After constraint solving,
|
||||||
|
solved positions are synced back to the OCCSketch.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
import re
|
||||||
|
import uuid
|
||||||
|
import logging
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import List, Optional, Tuple, Any, Dict
|
||||||
|
|
||||||
|
from python_solvespace import SolverSystem, ResultFlag
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Data classes ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SolverPoint:
|
||||||
|
"""A 2D point tracked by the solver system."""
|
||||||
|
|
||||||
|
x: float
|
||||||
|
y: float
|
||||||
|
handle: Any = None
|
||||||
|
handle_nr: int = 0
|
||||||
|
entity_id: int = -1 # Corresponding OCCSketch entity id
|
||||||
|
is_helper: bool = False
|
||||||
|
id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
||||||
|
|
||||||
|
def to_tuple(self) -> Tuple[float, float]:
|
||||||
|
return (self.x, self.y)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SolverLine:
|
||||||
|
"""A line segment tracked by the solver system."""
|
||||||
|
|
||||||
|
start: SolverPoint
|
||||||
|
end: SolverPoint
|
||||||
|
handle: Any = None
|
||||||
|
handle_nr: int = 0
|
||||||
|
entity_ids: Tuple[int, int] = (-1, -1) # Corresponding OCCSketch entity ids
|
||||||
|
is_helper: bool = False
|
||||||
|
constraints: List[str] = field(default_factory=list)
|
||||||
|
id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def length(self) -> float:
|
||||||
|
return math.sqrt(
|
||||||
|
(self.end.x - self.start.x) ** 2 + (self.end.y - self.start.y) ** 2
|
||||||
|
)
|
||||||
|
|
||||||
|
def midpoint(self) -> Tuple[float, float]:
|
||||||
|
return (
|
||||||
|
(self.start.x + self.end.x) / 2,
|
||||||
|
(self.start.y + self.end.y) / 2,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SolverCircle:
|
||||||
|
"""A circle tracked by the solver system."""
|
||||||
|
|
||||||
|
center: SolverPoint
|
||||||
|
radius: float
|
||||||
|
handle: Any = None
|
||||||
|
handle_nr: int = 0
|
||||||
|
entity_id: int = -1 # Corresponding OCCSketch entity id
|
||||||
|
is_helper: bool = False
|
||||||
|
id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
||||||
|
|
||||||
|
|
||||||
|
# ── Solver wrapper ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class SolverSketch(SolverSystem):
|
||||||
|
"""
|
||||||
|
Sketch that uses python-solvespace for parametric constraint solving.
|
||||||
|
|
||||||
|
Maintains its own lists of points, lines, and circles with solve-space
|
||||||
|
handles. Provides methods for creating geometry, applying constraints,
|
||||||
|
solving, and syncing solved positions back to an OCCSketch.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.id = str(uuid.uuid4())
|
||||||
|
self.wp = self.create_2d_base()
|
||||||
|
self.points: List[SolverPoint] = []
|
||||||
|
self.lines: List[SolverLine] = []
|
||||||
|
self.circles: List[SolverCircle] = []
|
||||||
|
self._last_solve_result: int = 0
|
||||||
|
|
||||||
|
# ── Geometry creation ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def add_solver_point(self, x: float, y: float, is_helper: bool = False) -> SolverPoint:
|
||||||
|
"""Add a point to the solver system and return a SolverPoint."""
|
||||||
|
handle = self.add_point_2d(x, y, self.wp)
|
||||||
|
handle_nr = _extract_handle_nr(str(handle))
|
||||||
|
point = SolverPoint(
|
||||||
|
x=x, y=y, handle=handle, handle_nr=handle_nr,
|
||||||
|
is_helper=is_helper,
|
||||||
|
)
|
||||||
|
self.points.append(point)
|
||||||
|
return point
|
||||||
|
|
||||||
|
def add_solver_line(
|
||||||
|
self, start: SolverPoint, end: SolverPoint, is_helper: bool = False
|
||||||
|
) -> SolverLine:
|
||||||
|
"""Add a line to the solver system and return a SolverLine."""
|
||||||
|
handle = self.add_line_2d(start.handle, end.handle, self.wp)
|
||||||
|
handle_nr = _extract_handle_nr(str(handle))
|
||||||
|
line = SolverLine(
|
||||||
|
start=start, end=end, handle=handle, handle_nr=handle_nr,
|
||||||
|
is_helper=is_helper,
|
||||||
|
)
|
||||||
|
self.lines.append(line)
|
||||||
|
return line
|
||||||
|
|
||||||
|
def add_solver_circle(
|
||||||
|
self, center: SolverPoint, radius: float, is_helper: bool = False
|
||||||
|
) -> SolverCircle:
|
||||||
|
"""Add a circle to the solver system and return a SolverCircle.
|
||||||
|
|
||||||
|
Note: python-solvespace handles circles via diameter, so we
|
||||||
|
store radius but pass 2*radius to the solver if needed.
|
||||||
|
"""
|
||||||
|
# For now, circles are tracked for OCC output but the solver
|
||||||
|
# doesn't have a dedicated add_circle_2d in the standard API.
|
||||||
|
# We'll handle radius/diameter constraints through the points.
|
||||||
|
circle = SolverCircle(
|
||||||
|
center=center, radius=radius,
|
||||||
|
is_helper=is_helper,
|
||||||
|
)
|
||||||
|
self.circles.append(circle)
|
||||||
|
return circle
|
||||||
|
|
||||||
|
# ── Constraint methods ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
def constrain_coincident(self, entity_a, entity_b) -> bool:
|
||||||
|
"""Make two entities coincident (point-point or point-line)."""
|
||||||
|
try:
|
||||||
|
if isinstance(entity_a, SolverPoint) and isinstance(entity_b, SolverPoint):
|
||||||
|
self.coincident(entity_a.handle, entity_b.handle, self.wp)
|
||||||
|
elif isinstance(entity_a, SolverPoint) and isinstance(entity_b, SolverLine):
|
||||||
|
self.coincident(entity_a.handle, entity_b.handle, self.wp)
|
||||||
|
|
||||||
|
elif isinstance(entity_a, SolverLine) and isinstance(entity_b, SolverPoint):
|
||||||
|
self.coincident(entity_b.handle, entity_a.handle, self.wp)
|
||||||
|
|
||||||
|
else:
|
||||||
|
logger.warning(f"coincident: unsupported types {type(entity_a)}, {type(entity_b)}")
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"coincident constraint failed: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def constrain_horizontal(self, line: SolverLine) -> bool:
|
||||||
|
"""Constrain a line to be horizontal."""
|
||||||
|
try:
|
||||||
|
self.horizontal(line.handle, self.wp)
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"horizontal constraint failed: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def constrain_vertical(self, line: SolverLine) -> bool:
|
||||||
|
"""Constrain a line to be vertical."""
|
||||||
|
try:
|
||||||
|
self.vertical(line.handle, self.wp)
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"vertical constraint failed: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def constrain_distance(
|
||||||
|
self, entity_a, entity_b, distance: float
|
||||||
|
) -> bool:
|
||||||
|
"""Constrain distance between point-point or point-line."""
|
||||||
|
try:
|
||||||
|
handle_a = entity_a.handle if isinstance(entity_a, SolverPoint) else entity_a.handle
|
||||||
|
handle_b = entity_b.handle if isinstance(entity_b, SolverPoint) else entity_b.handle
|
||||||
|
|
||||||
|
if isinstance(entity_a, SolverPoint) and isinstance(entity_b, SolverLine):
|
||||||
|
self.distance(handle_a, handle_b, distance, self.wp)
|
||||||
|
elif isinstance(entity_a, SolverLine) and isinstance(entity_b, SolverPoint):
|
||||||
|
self.distance(handle_b, handle_a, distance, self.wp)
|
||||||
|
elif isinstance(entity_a, SolverPoint) and isinstance(entity_b, SolverPoint):
|
||||||
|
self.distance(handle_a, handle_b, distance, self.wp)
|
||||||
|
elif isinstance(entity_a, SolverLine) and isinstance(entity_b, SolverLine):
|
||||||
|
self.distance(handle_a, handle_b, distance, self.wp)
|
||||||
|
else:
|
||||||
|
logger.warning(f"distance: unsupported types {type(entity_a)}, {type(entity_b)}")
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"distance constraint failed: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def constrain_midpoint(self, point: SolverPoint, line: SolverLine) -> bool:
|
||||||
|
"""Constrain a point to be at the midpoint of a line."""
|
||||||
|
try:
|
||||||
|
self.midpoint(point.handle, line.handle, self.wp)
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"midpoint constraint failed: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def constrain_parallel(self, line_a: SolverLine, line_b: SolverLine) -> bool:
|
||||||
|
"""Constrain two lines to be parallel."""
|
||||||
|
try:
|
||||||
|
self.parallel(line_a.handle, line_b.handle, self.wp)
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"parallel constraint failed: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def constrain_perpendicular(self, line_a: SolverLine, line_b: SolverLine) -> bool:
|
||||||
|
"""Constrain two lines to be perpendicular."""
|
||||||
|
try:
|
||||||
|
self.perpendicular(line_a.handle, line_b.handle, self.wp)
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"perpendicular constraint failed: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def constrain_angle(self, line_a: SolverLine, line_b: SolverLine, angle_deg: float) -> bool:
|
||||||
|
"""Constrain angle between two lines in degrees."""
|
||||||
|
try:
|
||||||
|
angle_rad = math.radians(angle_deg)
|
||||||
|
self.angle(line_a.handle, line_b.handle, angle_rad, self.wp)
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"angle constraint failed: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def constrain_equal_length(self, line_a: SolverLine, line_b: SolverLine) -> bool:
|
||||||
|
"""Constrain two lines to have equal length."""
|
||||||
|
try:
|
||||||
|
self.equal(line_a.handle, line_b.handle, self.wp)
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"equal length constraint failed: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# ── Solving ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def solve(self) -> int:
|
||||||
|
"""Solve all constraints. Returns ResultFlag as int."""
|
||||||
|
result = super().solve()
|
||||||
|
self._last_solve_result = result
|
||||||
|
|
||||||
|
if result == ResultFlag.OKAY:
|
||||||
|
# Update our stored point positions from solver params
|
||||||
|
self._sync_solved_positions()
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _sync_solved_positions(self) -> None:
|
||||||
|
"""Update SolverPoint coordinates from solver's solved params."""
|
||||||
|
for point in self.points:
|
||||||
|
if point.handle and self.params(point.handle.params):
|
||||||
|
x, y = self.params(point.handle.params)
|
||||||
|
point.x = x
|
||||||
|
point.y = y
|
||||||
|
|
||||||
|
# ── Query ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def get_solved_point_positions(self) -> Dict[int, Tuple[float, float]]:
|
||||||
|
"""Get map of entity_id -> (x, y) after solving."""
|
||||||
|
positions: Dict[int, Tuple[float, float]] = {}
|
||||||
|
for point in self.points:
|
||||||
|
if point.handle and self.params(point.handle.params):
|
||||||
|
x, y = self.params(point.handle.params)
|
||||||
|
positions[point.entity_id] = (x, y)
|
||||||
|
return positions
|
||||||
|
|
||||||
|
def is_point_on_line(
|
||||||
|
self, px: float, py: float, line: SolverLine, tolerance: float = 5.0
|
||||||
|
) -> bool:
|
||||||
|
"""Check if a point lies on a solver line (in world coords)."""
|
||||||
|
# Vector from start to point
|
||||||
|
ap_x = px - line.start.x
|
||||||
|
ap_y = py - line.start.y
|
||||||
|
# Vector from start to end
|
||||||
|
ab_x = line.end.x - line.start.x
|
||||||
|
ab_y = line.end.y - line.start.y
|
||||||
|
ab_len_sq = ab_x**2 + ab_y**2
|
||||||
|
if ab_len_sq == 0:
|
||||||
|
return False
|
||||||
|
# Project point onto line
|
||||||
|
t = (ap_x * ab_x + ap_y * ab_y) / ab_len_sq
|
||||||
|
t = max(0, min(1, t))
|
||||||
|
closest_x = line.start.x + t * ab_x
|
||||||
|
closest_y = line.start.y + t * ab_y
|
||||||
|
dist = math.sqrt((px - closest_x) ** 2 + (py - closest_y) ** 2)
|
||||||
|
return dist <= tolerance
|
||||||
|
|
||||||
|
# ── Clear / reset ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def clear(self) -> None:
|
||||||
|
"""Clear all geometry from this solver sketch."""
|
||||||
|
self.points.clear()
|
||||||
|
self.lines.clear()
|
||||||
|
self.circles.clear()
|
||||||
|
self.wp = self.create_2d_base()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Helpers ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_handle_nr(handle_str: str) -> int:
|
||||||
|
"""Extract numeric handle from string like 'Entity(handle=7, ...)'."""
|
||||||
|
match = re.search(r"handle=(\d+)", handle_str)
|
||||||
|
return int(match.group(1)) if match else 0
|
||||||
@@ -0,0 +1,372 @@
|
|||||||
|
"""
|
||||||
|
Undo/Redo manager for OCCSketch using snapshot-based approach.
|
||||||
|
|
||||||
|
python_solvespace has no per-entity delete API — the solver is rebuilt from
|
||||||
|
scratch after every modification. This makes snapshot-based undo natural:
|
||||||
|
we capture the complete sketch state (entities, geometry, constraints) and
|
||||||
|
restore it by rebuilding the solver from the snapshot data.
|
||||||
|
|
||||||
|
Each snapshot is ~10-50 KB depending on sketch complexity, and we cap the
|
||||||
|
stack at a configurable depth (default 50).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import copy
|
||||||
|
import logging
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any, Dict, List, Optional, Set, Tuple
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SketchSnapshot:
|
||||||
|
"""Immutable snapshot of sketch state for undo/redo.
|
||||||
|
|
||||||
|
Captures everything needed to fully reconstruct an OCCSketch:
|
||||||
|
entities, points, lines, circles, arcs, counter, constraint log,
|
||||||
|
and the special entity id sets (centerlines, external/underlay).
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Entity registry: id → (entity_type, geometry, is_construction, is_external, constraints_list)
|
||||||
|
entities: Dict[int, Tuple[str, Any, bool, bool, List[str]]] = field(default_factory=dict)
|
||||||
|
|
||||||
|
# Geometry sub-indices
|
||||||
|
points: Dict[int, Tuple[float, float]] = field(default_factory=dict)
|
||||||
|
lines: Dict[int, Tuple[int, int]] = field(default_factory=dict)
|
||||||
|
circles: Dict[int, Tuple[int, float]] = field(default_factory=dict)
|
||||||
|
arcs: Dict[int, Dict[str, Any]] = field(default_factory=dict)
|
||||||
|
|
||||||
|
# Counters and flags
|
||||||
|
entity_counter: int = 0
|
||||||
|
constraint_count: int = 0
|
||||||
|
first_point_id: Optional[int] = None
|
||||||
|
|
||||||
|
# Constraint replay log
|
||||||
|
constraint_log: List[Dict[str, Any]] = field(default_factory=list)
|
||||||
|
|
||||||
|
# Special entity sets
|
||||||
|
centerline_ids: Set[int] = field(default_factory=set)
|
||||||
|
external_entity_ids: Set[int] = field(default_factory=set)
|
||||||
|
|
||||||
|
# Workplane (so undo doesn't lose the placement plane)
|
||||||
|
wp_origin: Tuple[float, float, float] = (0.0, 0.0, 0.0)
|
||||||
|
wp_normal: Tuple[float, float, float] = (0.0, 0.0, 1.0)
|
||||||
|
wp_x_dir: Tuple[float, float, float] = (1.0, 0.0, 0.0)
|
||||||
|
wp_y_dir: Tuple[float, float, float] = (0.0, 1.0, 0.0)
|
||||||
|
|
||||||
|
|
||||||
|
class SketchUndoManager:
|
||||||
|
"""Manages undo/redo stacks of SketchSnapshot for an OCCSketch.
|
||||||
|
|
||||||
|
Usage::
|
||||||
|
|
||||||
|
undo_mgr = SketchUndoManager(sketch)
|
||||||
|
|
||||||
|
# Before any modification:
|
||||||
|
undo_mgr.save_state()
|
||||||
|
|
||||||
|
# ... perform modification ...
|
||||||
|
|
||||||
|
# Ctrl+Z:
|
||||||
|
undo_mgr.undo()
|
||||||
|
|
||||||
|
# Ctrl+Y / Ctrl+Shift+Z:
|
||||||
|
undo_mgr.redo()
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, sketch: Any, max_stack_size: int = 50) -> None:
|
||||||
|
from fluency.geometry_occ.sketch import OCCSketch
|
||||||
|
|
||||||
|
self._sketch: OCCSketch = sketch
|
||||||
|
self._max_stack_size = max_stack_size
|
||||||
|
self._undo_stack: List[SketchSnapshot] = []
|
||||||
|
self._redo_stack: List[SketchSnapshot] = []
|
||||||
|
|
||||||
|
# ─── Public API ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@property
|
||||||
|
def can_undo(self) -> bool:
|
||||||
|
"""True if there is a state to undo to."""
|
||||||
|
return len(self._undo_stack) > 0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def can_redo(self) -> bool:
|
||||||
|
"""True if there is a state to redo to."""
|
||||||
|
return len(self._redo_stack) > 0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def undo_depth(self) -> int:
|
||||||
|
"""Number of undo levels available."""
|
||||||
|
return len(self._undo_stack)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def redo_depth(self) -> int:
|
||||||
|
"""Number of redo levels available."""
|
||||||
|
return len(self._redo_stack)
|
||||||
|
|
||||||
|
def save_state(self) -> None:
|
||||||
|
"""Capture the current sketch state and push it onto the undo stack.
|
||||||
|
|
||||||
|
Call this **before** any modifying operation (draw, delete, move,
|
||||||
|
constraint add/remove, construction toggle, etc.).
|
||||||
|
|
||||||
|
The redo stack is cleared whenever a new state is saved (i.e. when
|
||||||
|
the user makes a new change after undoing).
|
||||||
|
"""
|
||||||
|
snapshot = self._capture()
|
||||||
|
self._undo_stack.append(snapshot)
|
||||||
|
|
||||||
|
# Cap the stack size.
|
||||||
|
if len(self._undo_stack) > self._max_stack_size:
|
||||||
|
self._undo_stack.pop(0)
|
||||||
|
|
||||||
|
# New mutation invalidates the redo history.
|
||||||
|
self._redo_stack.clear()
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
f"save_state: undo_depth={len(self._undo_stack)} "
|
||||||
|
f"entities={len(snapshot.entities)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def undo(self) -> bool:
|
||||||
|
"""Restore the previous sketch state.
|
||||||
|
|
||||||
|
Returns True if a state was restored, False if the undo stack is empty.
|
||||||
|
"""
|
||||||
|
if not self._undo_stack:
|
||||||
|
logger.debug("undo: stack empty")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Save current state to redo stack before restoring.
|
||||||
|
current_snapshot = self._capture()
|
||||||
|
self._redo_stack.append(current_snapshot)
|
||||||
|
|
||||||
|
# Pop and restore.
|
||||||
|
snapshot = self._undo_stack.pop()
|
||||||
|
self._restore(snapshot)
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
f"undo: restored state with {len(snapshot.entities)} entities, "
|
||||||
|
f"undo_depth={len(self._undo_stack)} redo_depth={len(self._redo_stack)}"
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def redo(self) -> bool:
|
||||||
|
"""Re-apply the most recently undone state.
|
||||||
|
|
||||||
|
Returns True if a state was restored, False if the redo stack is empty.
|
||||||
|
"""
|
||||||
|
if not self._redo_stack:
|
||||||
|
logger.debug("redo: stack empty")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Save current state to undo stack before restoring.
|
||||||
|
current_snapshot = self._capture()
|
||||||
|
self._undo_stack.append(current_snapshot)
|
||||||
|
|
||||||
|
# Pop and restore.
|
||||||
|
snapshot = self._redo_stack.pop()
|
||||||
|
self._restore(snapshot)
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
f"redo: restored state with {len(snapshot.entities)} entities, "
|
||||||
|
f"undo_depth={len(self._undo_stack)} redo_depth={len(self._redo_stack)}"
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def clear(self) -> None:
|
||||||
|
"""Clear both stacks (e.g. when loading a new sketch)."""
|
||||||
|
self._undo_stack.clear()
|
||||||
|
self._redo_stack.clear()
|
||||||
|
logger.debug("undo stacks cleared")
|
||||||
|
|
||||||
|
# ─── Snapshot Capture ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _capture(self) -> SketchSnapshot:
|
||||||
|
"""Capture the current sketch state into a snapshot."""
|
||||||
|
sketch = self._sketch
|
||||||
|
|
||||||
|
# Capture entities: id → (type, geometry, is_construction, is_external, constraints)
|
||||||
|
entities: Dict[int, Tuple[str, Any, bool, bool, List[str]]] = {}
|
||||||
|
for eid, ent in sketch._entities.items():
|
||||||
|
entities[eid] = (
|
||||||
|
ent.entity_type,
|
||||||
|
ent.geometry,
|
||||||
|
ent.is_construction,
|
||||||
|
ent.is_external,
|
||||||
|
list(ent.constraints), # copy the constraints list
|
||||||
|
)
|
||||||
|
|
||||||
|
# Deep copy the mutable dicts (points coords are tuples, so shallow is fine,
|
||||||
|
# but arcs contain dicts so we deep-copy those).
|
||||||
|
points = dict(sketch._points)
|
||||||
|
lines = dict(sketch._lines)
|
||||||
|
circles = dict(sketch._circles)
|
||||||
|
arcs = {k: copy.deepcopy(v) for k, v in sketch._arcs.items()}
|
||||||
|
|
||||||
|
# Constraint log entries contain tuples and sets — need careful copy.
|
||||||
|
constraint_log = []
|
||||||
|
for entry in sketch._constraint_log:
|
||||||
|
copied = {
|
||||||
|
"type": entry["type"],
|
||||||
|
"ids": tuple(entry["ids"]),
|
||||||
|
"params": tuple(entry["params"]) if entry.get("params") else (),
|
||||||
|
"labels": set(entry["labels"]) if entry.get("labels") else set(),
|
||||||
|
}
|
||||||
|
constraint_log.append(copied)
|
||||||
|
|
||||||
|
return SketchSnapshot(
|
||||||
|
entities=entities,
|
||||||
|
points=points,
|
||||||
|
lines=lines,
|
||||||
|
circles=circles,
|
||||||
|
arcs=arcs,
|
||||||
|
entity_counter=sketch._entity_counter,
|
||||||
|
constraint_count=sketch._constraint_count,
|
||||||
|
first_point_id=sketch._first_point_id,
|
||||||
|
constraint_log=constraint_log,
|
||||||
|
centerline_ids=set(sketch._centerline_ids),
|
||||||
|
external_entity_ids=set(sketch._external_entity_ids),
|
||||||
|
wp_origin=sketch._wp_origin,
|
||||||
|
wp_normal=sketch._wp_normal,
|
||||||
|
wp_x_dir=sketch._wp_x_dir,
|
||||||
|
wp_y_dir=sketch._wp_y_dir,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ─── Snapshot Restore ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _restore(self, snapshot: SketchSnapshot) -> None:
|
||||||
|
"""Restore the sketch to a previously captured snapshot state."""
|
||||||
|
from fluency.geometry_occ.sketch import OCCSketch, OCCSketchEntity
|
||||||
|
|
||||||
|
sketch = self._sketch
|
||||||
|
|
||||||
|
# Clear the current solver and rebuild from scratch.
|
||||||
|
sketch._solver = sketch._solver.__class__() # SolverSystem()
|
||||||
|
sketch._wp = sketch._solver.create_2d_base()
|
||||||
|
sketch._first_point_id = None
|
||||||
|
|
||||||
|
# Restore counters and flags.
|
||||||
|
sketch._entity_counter = snapshot.entity_counter
|
||||||
|
sketch._constraint_count = snapshot.constraint_count
|
||||||
|
sketch._centerline_ids = set(snapshot.centerline_ids)
|
||||||
|
sketch._external_entity_ids = set(snapshot.external_entity_ids)
|
||||||
|
|
||||||
|
# Restore workplane.
|
||||||
|
sketch._wp_origin = snapshot.wp_origin
|
||||||
|
sketch._wp_normal = snapshot.wp_normal
|
||||||
|
sketch._wp_x_dir = snapshot.wp_x_dir
|
||||||
|
sketch._wp_y_dir = snapshot.wp_y_dir
|
||||||
|
|
||||||
|
# Rebuild entity objects from the snapshot.
|
||||||
|
sketch._entities.clear()
|
||||||
|
sketch._points.clear()
|
||||||
|
sketch._lines.clear()
|
||||||
|
sketch._circles.clear()
|
||||||
|
sketch._arcs.clear()
|
||||||
|
|
||||||
|
# First pass: re-add all points to the solver.
|
||||||
|
for eid in sorted(snapshot.entities.keys()):
|
||||||
|
etype, geometry, is_constr, is_ext, constraints = snapshot.entities[eid]
|
||||||
|
if etype == "point" and eid in snapshot.points:
|
||||||
|
x, y = snapshot.points[eid]
|
||||||
|
solver_handle = sketch._solver.add_point_2d(x, y, sketch._wp)
|
||||||
|
ent = OCCSketchEntity(
|
||||||
|
entity_id=eid,
|
||||||
|
entity_type="point",
|
||||||
|
geometry=(x, y),
|
||||||
|
handle=solver_handle,
|
||||||
|
)
|
||||||
|
ent.is_construction = is_constr
|
||||||
|
ent.is_external = is_ext
|
||||||
|
ent.constraints = list(constraints)
|
||||||
|
sketch._entities[eid] = ent
|
||||||
|
sketch._points[eid] = (x, y)
|
||||||
|
|
||||||
|
# Anchor the first point (or first external point) for solver stability.
|
||||||
|
if sketch._first_point_id is None and not is_ext:
|
||||||
|
sketch._first_point_id = eid
|
||||||
|
sketch._solver.dragged(solver_handle, sketch._wp)
|
||||||
|
elif sketch._first_point_id is None and is_ext:
|
||||||
|
sketch._first_point_id = eid
|
||||||
|
sketch._solver.dragged(solver_handle, sketch._wp)
|
||||||
|
|
||||||
|
# Second pass: re-add all lines.
|
||||||
|
for lid in sorted(snapshot.lines.keys()):
|
||||||
|
sid, eid2 = snapshot.lines[lid]
|
||||||
|
s_ent = sketch._entities.get(sid)
|
||||||
|
e_ent = sketch._entities.get(eid2)
|
||||||
|
if s_ent is None or e_ent is None or s_ent.handle is None or e_ent.handle is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
solver_handle = sketch._solver.add_line_2d(s_ent.handle, e_ent.handle, sketch._wp)
|
||||||
|
|
||||||
|
etype, geometry, is_constr, is_ext, constraints = snapshot.entities[lid]
|
||||||
|
ent = OCCSketchEntity(
|
||||||
|
entity_id=lid,
|
||||||
|
entity_type="line",
|
||||||
|
geometry=geometry,
|
||||||
|
handle=solver_handle,
|
||||||
|
)
|
||||||
|
ent.is_construction = is_constr
|
||||||
|
ent.is_external = is_ext
|
||||||
|
ent.constraints = list(constraints)
|
||||||
|
sketch._entities[lid] = ent
|
||||||
|
sketch._lines[lid] = (sid, eid2)
|
||||||
|
|
||||||
|
# Restore circles (not in solver, just tracked).
|
||||||
|
for cid, (center_id, radius) in snapshot.circles.items():
|
||||||
|
if cid in snapshot.entities:
|
||||||
|
etype, geometry, is_constr, is_ext, constraints = snapshot.entities[cid]
|
||||||
|
center_ent = sketch._entities.get(center_id)
|
||||||
|
ent = OCCSketchEntity(
|
||||||
|
entity_id=cid,
|
||||||
|
entity_type="circle",
|
||||||
|
geometry=geometry,
|
||||||
|
handle=None,
|
||||||
|
)
|
||||||
|
ent.is_construction = is_constr
|
||||||
|
ent.is_external = is_ext
|
||||||
|
ent.constraints = list(constraints)
|
||||||
|
sketch._entities[cid] = ent
|
||||||
|
sketch._circles[cid] = (center_id, radius)
|
||||||
|
|
||||||
|
# Restore arcs (not in solver, just tracked).
|
||||||
|
for aid, arc_data in snapshot.arcs.items():
|
||||||
|
if aid in snapshot.entities:
|
||||||
|
etype, geometry, is_constr, is_ext, constraints = snapshot.entities[aid]
|
||||||
|
ent = OCCSketchEntity(
|
||||||
|
entity_id=aid,
|
||||||
|
entity_type="arc",
|
||||||
|
geometry=geometry,
|
||||||
|
handle=None,
|
||||||
|
)
|
||||||
|
ent.is_construction = is_constr
|
||||||
|
ent.is_external = is_ext
|
||||||
|
ent.constraints = list(constraints)
|
||||||
|
sketch._entities[aid] = ent
|
||||||
|
sketch._arcs[aid] = copy.deepcopy(arc_data)
|
||||||
|
|
||||||
|
# Rebuild the constraint log (entries were deep-copied on capture).
|
||||||
|
sketch._constraint_log = []
|
||||||
|
for entry in snapshot.constraint_log:
|
||||||
|
sketch._constraint_log.append({
|
||||||
|
"type": entry["type"],
|
||||||
|
"ids": tuple(entry["ids"]),
|
||||||
|
"params": tuple(entry["params"]) if entry.get("params") else (),
|
||||||
|
"labels": set(entry["labels"]) if entry.get("labels") else set(),
|
||||||
|
})
|
||||||
|
|
||||||
|
# Re-apply all constraints to the solver.
|
||||||
|
for entry in sketch._constraint_log:
|
||||||
|
sketch._apply_constraint_log(entry)
|
||||||
|
|
||||||
|
# Solve to update geometry positions.
|
||||||
|
sketch.solve()
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
f"Restored snapshot: {len(sketch._entities)} entities, "
|
||||||
|
f"{len(sketch._constraint_log)} constraints"
|
||||||
|
)
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
"""Smoke test for project_io save/load round-trip.
|
||||||
|
|
||||||
|
Builds a small project (a Component with a sketch, an extrude body, a
|
||||||
|
workplane, plus an assembly with two instances and a connector) and
|
||||||
|
verifies that saving then loading it preserves the data.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
# Allow running this file directly: ``python tests/test_project_io.py``.
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir, "src"))
|
||||||
|
|
||||||
|
from fluency.io.project_io import save_project, load_project
|
||||||
|
from fluency.models.data_model import (
|
||||||
|
Project,
|
||||||
|
Component,
|
||||||
|
Body,
|
||||||
|
Workplane,
|
||||||
|
Assembly,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestProjectIO(unittest.TestCase):
|
||||||
|
"""Round-trip the same project through save/load and check equivalence."""
|
||||||
|
|
||||||
|
def _build_project(self) -> Project:
|
||||||
|
project = Project(name="Test Project", description="A tiny test")
|
||||||
|
project.file_path = None # simulate untitled
|
||||||
|
|
||||||
|
comp = project.add_component(Component(name="Part1"))
|
||||||
|
|
||||||
|
# Add a workplane.
|
||||||
|
wp = Workplane(
|
||||||
|
name="Top",
|
||||||
|
origin=(0.0, 0.0, 0.0),
|
||||||
|
normal=(0.0, 0.0, 1.0),
|
||||||
|
x_dir=(1.0, 0.0, 0.0),
|
||||||
|
)
|
||||||
|
comp.add_workplane(wp)
|
||||||
|
|
||||||
|
# Add a sketch with a square.
|
||||||
|
sk = comp.add_sketch()
|
||||||
|
sk.occ_sketch.add_rectangle((0.0, 0.0), (10.0, 10.0))
|
||||||
|
sk.solve()
|
||||||
|
# Build a face geometry for the sketch (needed for export / restore).
|
||||||
|
faces = sk.occ_sketch.detect_faces()
|
||||||
|
if faces:
|
||||||
|
sk.geometry = sk.occ_sketch.build_face_geometry(faces[0])
|
||||||
|
|
||||||
|
# Extrude into a body.
|
||||||
|
if sk.geometry:
|
||||||
|
kernel = project.kernel
|
||||||
|
body_shape = kernel.extrude(sk.geometry, height=20.0)
|
||||||
|
body = comp.add_body(
|
||||||
|
Body(
|
||||||
|
name="Block",
|
||||||
|
geometry=body_shape,
|
||||||
|
source_sketch=sk,
|
||||||
|
source_operation="extrude",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
body.color = (0.4, 0.2, 0.8)
|
||||||
|
|
||||||
|
# Add an assembly with two instances and a mated connector pair.
|
||||||
|
asm = project.add_assembly(Assembly(name="Asm1"))
|
||||||
|
ac1 = asm.add_component_instance(comp.id, name="Inst1")
|
||||||
|
ac2 = asm.add_component_instance(comp.id, name="Inst2")
|
||||||
|
c1 = ac1.add_connector(
|
||||||
|
position=(5.0, 5.0, 0.0),
|
||||||
|
normal=(0.0, 0.0, 1.0),
|
||||||
|
x_dir=(1.0, 0.0, 0.0),
|
||||||
|
)
|
||||||
|
c2 = ac2.add_connector(
|
||||||
|
position=(10.0, 10.0, 5.0),
|
||||||
|
normal=(0.0, 0.0, -1.0),
|
||||||
|
x_dir=(1.0, 0.0, 0.0),
|
||||||
|
)
|
||||||
|
# Record a mated pair (UI normally does this on connector-pick).
|
||||||
|
conn = asm.add_connection(ac1.id, ac2.id)
|
||||||
|
conn.first_connector_id = c1.id
|
||||||
|
conn.second_connector_id = c2.id
|
||||||
|
c1.partner_ac_id = ac2.id
|
||||||
|
c1.partner_connector_id = c2.id
|
||||||
|
c2.partner_ac_id = ac1.id
|
||||||
|
c2.partner_connector_id = c1.id
|
||||||
|
c1.is_grounded = True
|
||||||
|
|
||||||
|
return project
|
||||||
|
|
||||||
|
def test_round_trip(self):
|
||||||
|
original = self._build_project()
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
path = os.path.join(tmp, "test.fluency")
|
||||||
|
saved_path = save_project(original, path)
|
||||||
|
self.assertTrue(os.path.exists(saved_path))
|
||||||
|
self.assertGreater(os.path.getsize(saved_path), 100)
|
||||||
|
|
||||||
|
loaded, view_state = load_project(saved_path)
|
||||||
|
|
||||||
|
# ── Project metadata ──
|
||||||
|
self.assertEqual(loaded.name, "Test Project")
|
||||||
|
self.assertEqual(loaded.description, "A tiny test")
|
||||||
|
self.assertEqual(len(loaded.components), 1)
|
||||||
|
self.assertEqual(len(loaded.assemblies), 1)
|
||||||
|
|
||||||
|
# ── Component / Workplane / Sketch / Body ──
|
||||||
|
comp = next(iter(loaded.components.values()))
|
||||||
|
self.assertEqual(comp.name, "Part1")
|
||||||
|
self.assertEqual(len(comp.workplanes), 1)
|
||||||
|
self.assertEqual(len(comp.sketches), 1)
|
||||||
|
self.assertEqual(len(comp.bodies), 1)
|
||||||
|
|
||||||
|
sk = next(iter(comp.sketches.values()))
|
||||||
|
self.assertIsNotNone(sk.occ_sketch)
|
||||||
|
# OCCSketch replayed the rectangle: 4 points + 4 lines + 1 implicit
|
||||||
|
# origin anchor (first-point-fix). We only check that the entities
|
||||||
|
# exist.
|
||||||
|
self.assertGreaterEqual(sk.occ_sketch.get_entity_count(), 4)
|
||||||
|
# Solved geometry should round-trip through STEP.
|
||||||
|
self.assertIsNotNone(sk.geometry)
|
||||||
|
|
||||||
|
body = next(iter(comp.bodies.values()))
|
||||||
|
self.assertIsNotNone(body.geometry)
|
||||||
|
self.assertEqual(body.name, "Block")
|
||||||
|
self.assertEqual(tuple(body.color), (0.4, 0.2, 0.8))
|
||||||
|
# BRep topology should still be valid.
|
||||||
|
self.assertGreater(body.get_mesh(loaded.kernel, 0.5)[0].size, 0)
|
||||||
|
|
||||||
|
# ── Assembly / connector / connection ──
|
||||||
|
asm = next(iter(loaded.assemblies.values()))
|
||||||
|
self.assertEqual(len(asm.components), 2)
|
||||||
|
self.assertEqual(len(asm.connections), 1)
|
||||||
|
ac1, ac2 = list(asm.components.values())
|
||||||
|
self.assertEqual(len(ac1.connectors), 1)
|
||||||
|
conn = next(iter(ac1.connectors.values()))
|
||||||
|
self.assertEqual(conn.position, (5.0, 5.0, 0.0))
|
||||||
|
# The grounded-reference flag was re-applied to the first connector.
|
||||||
|
self.assertTrue(conn.is_grounded)
|
||||||
|
# Rigid-group BFS should still link the two instances.
|
||||||
|
self.assertEqual(set(asm.get_rigid_group(ac1.id)), {ac1.id, ac2.id})
|
||||||
|
|
||||||
|
|
||||||
|
class TestProjectIOWithConstraints(unittest.TestCase):
|
||||||
|
"""Round-trip with parametric constraints on the sketch.
|
||||||
|
|
||||||
|
Builds a slightly more interesting sketch (rectangle with horizontal +
|
||||||
|
vertical constraints + a distance) so the constraint-log replay path
|
||||||
|
is exercised, not just the entity-construction path.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _build_project(self) -> Project:
|
||||||
|
project = Project(name="Constraints Project", description="")
|
||||||
|
|
||||||
|
comp = project.add_component(Component(name="Part1"))
|
||||||
|
|
||||||
|
# A square with a 25.4mm horizontal distance + a vertical distance,
|
||||||
|
# both anchored on a single fixed corner. This is the minimum
|
||||||
|
# number of constraints to fully define a square in 2D.
|
||||||
|
sk = comp.add_sketch()
|
||||||
|
sk.occ_sketch.set_workplane(
|
||||||
|
(0.0, 0.0, 0.0),
|
||||||
|
(0.0, 0.0, 1.0),
|
||||||
|
(1.0, 0.0, 0.0),
|
||||||
|
)
|
||||||
|
p1 = sk.occ_sketch.add_point(0.0, 0.0) # fixed anchor (auto)
|
||||||
|
p2 = sk.occ_sketch.add_point(25.4, 0.0)
|
||||||
|
p3 = sk.occ_sketch.add_point(25.4, 25.4)
|
||||||
|
p4 = sk.occ_sketch.add_point(0.0, 25.4)
|
||||||
|
sk.occ_sketch.add_line(p1, p2)
|
||||||
|
sk.occ_sketch.add_line(p2, p3)
|
||||||
|
sk.occ_sketch.add_line(p3, p4)
|
||||||
|
sk.occ_sketch.add_line(p4, p1)
|
||||||
|
# Constraint the right side to a known length (instead of relying
|
||||||
|
# on the construction positions, which would just be redundant).
|
||||||
|
sk.occ_sketch.constrain_distance(p2, p3, 25.4)
|
||||||
|
sk.solve()
|
||||||
|
sk.is_fully_constrained = sk.occ_sketch.is_fully_constrained()
|
||||||
|
faces = sk.occ_sketch.detect_faces()
|
||||||
|
if faces:
|
||||||
|
sk.geometry = sk.occ_sketch.build_face_geometry(faces[0])
|
||||||
|
|
||||||
|
# View state to persist.
|
||||||
|
view_state = {
|
||||||
|
"active_tab": 0,
|
||||||
|
"active_component_id": comp.id,
|
||||||
|
"active_sketch_id": sk.id,
|
||||||
|
"camera_eye": [50.0, 50.0, 50.0],
|
||||||
|
"camera_at": [0.0, 0.0, 0.0],
|
||||||
|
"camera_up": [0.0, 0.0, 1.0],
|
||||||
|
"panel_focus": "sketch",
|
||||||
|
"assembly_view_active": False,
|
||||||
|
}
|
||||||
|
self._view_state = view_state
|
||||||
|
return project
|
||||||
|
|
||||||
|
def test_round_trip_with_view_state(self):
|
||||||
|
original = self._build_project()
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
path = os.path.join(tmp, "constrained.fluency")
|
||||||
|
save_project(original, path, view_state=self._view_state)
|
||||||
|
loaded, view_state = load_project(path)
|
||||||
|
|
||||||
|
# View state must survive (modulo float-to-list round-tripping).
|
||||||
|
self.assertEqual(view_state.get("active_component_id"),
|
||||||
|
self._view_state["active_component_id"])
|
||||||
|
self.assertEqual(view_state.get("active_sketch_id"),
|
||||||
|
self._view_state["active_sketch_id"])
|
||||||
|
self.assertEqual(view_state.get("camera_eye"),
|
||||||
|
self._view_state["camera_eye"])
|
||||||
|
self.assertEqual(view_state.get("panel_focus"),
|
||||||
|
self._view_state["panel_focus"])
|
||||||
|
|
||||||
|
# Sketch must have replayed its constraints and remain solvable.
|
||||||
|
comp = next(iter(loaded.components.values()))
|
||||||
|
sk = next(iter(comp.sketches.values()))
|
||||||
|
# Re-solve on the loaded sketch to confirm the post-replay
|
||||||
|
# configuration is still consistent.
|
||||||
|
self.assertTrue(sk.occ_sketch.solve())
|
||||||
|
# The right edge of the square should still be 25.4mm tall.
|
||||||
|
import math
|
||||||
|
for lid, line in sk.occ_sketch._lines.items():
|
||||||
|
sid, eid = line
|
||||||
|
sx, sy = sk.occ_sketch._points[sid]
|
||||||
|
ex, ey = sk.occ_sketch._points[eid]
|
||||||
|
length = math.hypot(ex - sx, ey - sy)
|
||||||
|
if abs(length) > 1e-3:
|
||||||
|
self.assertAlmostEqual(
|
||||||
|
length, 25.4, places=3,
|
||||||
|
msg=f"Constraint replay broke: line {lid} = {length}",
|
||||||
|
)
|
||||||
|
break
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
"""Fluency CAD UI package.
|
||||||
|
|
||||||
|
Contains the Qt widgets, dialogs, and the main window:
|
||||||
|
|
||||||
|
ui/
|
||||||
|
dialogs.py – All 4 modal dialogs (Extrude, Revolve, Offset, WorkplaneOrientation)
|
||||||
|
sketch_widget.py – Sketch2DWidget (2D sketcher with constraint solver)
|
||||||
|
viewer_widget.py – Viewer3DWidget (3D viewer / OCC canvas)
|
||||||
|
main_window.py – MainWindow (application shell)
|
||||||
|
|
||||||
|
The public classes are re-exported from `fluency.main` so existing code that
|
||||||
|
does `from fluency.main import MainWindow` continues to work.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__all__: list[str] = []
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user