Compare commits
28 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 |
+35
@@ -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
+219
-60
@@ -4,31 +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="- Improved sketching">
|
<list default="true" id="8f0bafd6-58a0-4b20-aa2b-ddc3ba278873" name="Changes" comment="- added renderer - Added undo">
|
||||||
<change afterPath="$PROJECT_DIR$/README.md" afterDir="false" />
|
|
||||||
<change afterPath="$PROJECT_DIR$/doc/BUGFIX_DRAWING_ISSUES.md" afterDir="false" />
|
|
||||||
<change afterPath="$PROJECT_DIR$/doc/IMPLEMENTATION_SUMMARY.md" afterDir="false" />
|
|
||||||
<change afterPath="$PROJECT_DIR$/doc/PHASE1_COMPLETE.md" afterDir="false" />
|
|
||||||
<change afterPath="$PROJECT_DIR$/doc/PHASE2_COMPLETE.md" afterDir="false" />
|
|
||||||
<change afterPath="$PROJECT_DIR$/doc/SELECTION_DELETION.md" afterDir="false" />
|
|
||||||
<change afterPath="$PROJECT_DIR$/doc/SINGLE_WINDOW_MIGRATION_PLAN.md" afterDir="false" />
|
|
||||||
<change afterPath="$PROJECT_DIR$/doc/TEST_REPORT_PHASE1.md" afterDir="false" />
|
|
||||||
<change afterPath="$PROJECT_DIR$/doc/changes_log.md" afterDir="false" />
|
|
||||||
<change afterPath="$PROJECT_DIR$/doc/current_state.md" afterDir="false" />
|
|
||||||
<change afterPath="$PROJECT_DIR$/doc/technical_documentation.md" afterDir="false" />
|
|
||||||
<change afterPath="$PROJECT_DIR$/drawing_modules/constraints_3d.py" afterDir="false" />
|
|
||||||
<change afterPath="$PROJECT_DIR$/drawing_modules/vtk_sketch_widget.py" afterDir="false" />
|
|
||||||
<change afterPath="$PROJECT_DIR$/drawing_modules/workplane.py" afterDir="false" />
|
|
||||||
<change afterPath="$PROJECT_DIR$/minimal_main.py" afterDir="false" />
|
|
||||||
<change afterPath="$PROJECT_DIR$/ouput_test.md" afterDir="false" />
|
|
||||||
<change afterPath="$PROJECT_DIR$/state.png" afterDir="false" />
|
|
||||||
<change afterPath="$PROJECT_DIR$/test_delete_functionality.py" afterDir="false" />
|
|
||||||
<change afterPath="$PROJECT_DIR$/test_minimal_vtk.py" afterDir="false" />
|
|
||||||
<change afterPath="$PROJECT_DIR$/test_projection.py" afterDir="false" />
|
|
||||||
<change afterPath="$PROJECT_DIR$/test_selection.py" afterDir="false" />
|
|
||||||
<change afterPath="$PROJECT_DIR$/test_vtk_sketch_widget.py" afterDir="false" />
|
|
||||||
<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$/main.py" beforeDir="false" afterPath="$PROJECT_DIR$/main.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$/src/fluency/rendering/occ_renderer.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/rendering/occ_renderer.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" />
|
||||||
@@ -45,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="master" />
|
<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$" />
|
||||||
@@ -65,32 +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.test_vtk_sketch_widget.executor": "Run",
|
"Python.main.executor": "Run",
|
||||||
"Python.vtk_sketch_widget.executor": "Run",
|
"Python.meshtest.executor": "Run",
|
||||||
"Python.vtk_widget.executor": "Run",
|
"Python.occ_renderer.executor": "Run",
|
||||||
"Python.vulkan.executor": "Run",
|
"Python.occ_to_mesh.executor": "Run",
|
||||||
"RunOnceActivity.OpenProjectViewOnStart": "true",
|
"Python.render_backend.executor": "Run",
|
||||||
"RunOnceActivity.ShowReadmeOnStart": "true",
|
"Python.side_fluency.executor": "Run",
|
||||||
"RunOnceActivity.git.unshallow": "true",
|
"Python.simple_mesh.executor": "Run",
|
||||||
"git-widget-placeholder": "single__window",
|
"Python.sketch.executor": "Run",
|
||||||
"last_opened_file_path": "/Volumes/Data_drive/Programming/fluency",
|
"Python.vtk_widget.executor": "Run",
|
||||||
"settings.editor.selected.configurable": "project.propVCSSupport.DirectoryMappings"
|
"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" />
|
||||||
@@ -98,19 +94,20 @@
|
|||||||
</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" />
|
||||||
</key>
|
</key>
|
||||||
<key name="MoveFile.RECENT_KEYS">
|
<key name="MoveFile.RECENT_KEYS">
|
||||||
<recent name="$PROJECT_DIR$/doc" />
|
|
||||||
<recent name="$PROJECT_DIR$" />
|
<recent name="$PROJECT_DIR$" />
|
||||||
</key>
|
</key>
|
||||||
</component>
|
</component>
|
||||||
<component name="SharedIndexes">
|
<component name="SharedIndexes">
|
||||||
<attachedChunks>
|
<attachedChunks>
|
||||||
<set>
|
<set>
|
||||||
<option value="bundled-python-sdk-4762d8aabb82-6d6dccd035ac-com.jetbrains.pycharm.pro.sharedIndexes.bundled-PY-253.30387.173" />
|
<option value="bundled-python-sdk-c59985aa861c-c2ffad84badb-com.jetbrains.pycharm.pro.sharedIndexes.bundled-PY-261.24374.152" />
|
||||||
</set>
|
</set>
|
||||||
</attachedChunks>
|
</attachedChunks>
|
||||||
</component>
|
</component>
|
||||||
@@ -275,17 +272,172 @@
|
|||||||
<option name="project" value="LOCAL" />
|
<option name="project" value="LOCAL" />
|
||||||
<updated>1755369224187</updated>
|
<updated>1755369224187</updated>
|
||||||
</task>
|
</task>
|
||||||
<task id="LOCAL-00020" summary="- Improved sketching">
|
<task id="LOCAL-00020" summary="- Tons of addtions">
|
||||||
<option name="closed" value="true" />
|
<option name="closed" value="true" />
|
||||||
<created>1763311700335</created>
|
<created>1782673954850</created>
|
||||||
<option name="number" value="00020" />
|
<option name="number" value="00020" />
|
||||||
<option name="presentableId" value="LOCAL-00020" />
|
<option name="presentableId" value="LOCAL-00020" />
|
||||||
<option name="project" value="LOCAL" />
|
<option name="project" value="LOCAL" />
|
||||||
<updated>1763311700335</updated>
|
<updated>1782673954850</updated>
|
||||||
</task>
|
</task>
|
||||||
<option name="localTasksCounter" value="21" />
|
<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>
|
||||||
@@ -301,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" />
|
||||||
@@ -318,7 +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=)" />
|
||||||
<MESSAGE value="- Improved sketching" />
|
<MESSAGE value="- Tons of addtions" />
|
||||||
<option name="LAST_COMMIT_MESSAGE" value="- Improved sketching" />
|
<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>
|
||||||
@@ -6,7 +6,7 @@ A parametric CAD application built on OpenCASCADE Technology (OCCT) with a moder
|
|||||||
|
|
||||||
- **OpenCASCADE Geometry Kernel**: Industry-standard BRep geometry with exact precision
|
- **OpenCASCADE Geometry Kernel**: Industry-standard BRep geometry with exact precision
|
||||||
- **STEP/IGES Import/Export**: Full support for industry-standard CAD file formats
|
- **STEP/IGES Import/Export**: Full support for industry-standard CAD file formats
|
||||||
- **Parametric Sketching**: 2D sketching with constraint solving using CadQuery
|
- **Parametric Sketching**: 2D sketching with constraint solving using SolveSpace
|
||||||
- **Boolean Operations**: Union, difference, and intersection
|
- **Boolean Operations**: Union, difference, and intersection
|
||||||
- **Fillet & Chamfer**: Apply edge treatments to solid bodies
|
- **Fillet & Chamfer**: Apply edge treatments to solid bodies
|
||||||
- **Modern Renderer**: WebGPU-based rendering with pygfx (smaller footprint than VTK)
|
- **Modern Renderer**: WebGPU-based rendering with pygfx (smaller footprint than VTK)
|
||||||
@@ -47,8 +47,7 @@ pip install -e ".[dev]"
|
|||||||
|
|
||||||
| Package | Purpose |
|
| Package | Purpose |
|
||||||
|---------|---------|
|
|---------|---------|
|
||||||
| cadquery | High-level OpenCASCADE Python bindings |
|
| cadquery-ocp | OpenCASCADE Python bindings (OCP) |
|
||||||
| ocp | Low-level OpenCASCADE Python bindings |
|
|
||||||
| pygfx | WebGPU-based 3D renderer |
|
| pygfx | WebGPU-based 3D renderer |
|
||||||
| wgpu | WebGPU Python bindings |
|
| wgpu | WebGPU Python bindings |
|
||||||
| PySide6 | Qt GUI framework |
|
| PySide6 | Qt GUI framework |
|
||||||
@@ -105,7 +104,7 @@ kernel.export_stl(body, "part.stl")
|
|||||||
| File Size | Large (mesh) | Small (BRep) |
|
| File Size | Large (mesh) | Small (BRep) |
|
||||||
| Fillet/Chamfer | Approximate | Exact |
|
| Fillet/Chamfer | Approximate | Exact |
|
||||||
| Dependency Size | ~200MB (VTK) | ~30MB (pygfx) |
|
| Dependency Size | ~200MB (VTK) | ~30MB (pygfx) |
|
||||||
| Constraint Solver | SolveSpace (separate) | CadQuery (integrated) |
|
| Constraint Solver | SolveSpace (separate) | SolveSpace (integrated) |
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
```
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,906 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
################################################################################
|
||||||
|
## Form generated from reading UI file 'gui.ui'
|
||||||
|
##
|
||||||
|
## Created by: Qt User Interface Compiler version 6.10.2
|
||||||
|
##
|
||||||
|
## WARNING! All changes made in this file will be lost when recompiling UI file!
|
||||||
|
################################################################################
|
||||||
|
|
||||||
|
from PySide6.QtCore import (QCoreApplication, QDate, QDateTime, QLocale,
|
||||||
|
QMetaObject, QObject, QPoint, QRect,
|
||||||
|
QSize, QTime, QUrl, Qt)
|
||||||
|
from PySide6.QtGui import (QAction, QBrush, QColor, QConicalGradient,
|
||||||
|
QCursor, QFont, QFontDatabase, QGradient,
|
||||||
|
QIcon, QImage, QKeySequence, QLinearGradient,
|
||||||
|
QPainter, QPalette, QPixmap, QRadialGradient,
|
||||||
|
QTransform)
|
||||||
|
from PySide6.QtWidgets import (QApplication, QFrame, QGridLayout, QGroupBox,
|
||||||
|
QHBoxLayout, QLabel, QListWidget, QListWidgetItem,
|
||||||
|
QMainWindow, QMenu, QMenuBar, QPushButton,
|
||||||
|
QSizePolicy, QSpinBox, QStatusBar, QTabWidget,
|
||||||
|
QTextEdit, QVBoxLayout, QWidget)
|
||||||
|
|
||||||
|
class Ui_fluencyCAD(object):
|
||||||
|
def setupUi(self, fluencyCAD):
|
||||||
|
if not fluencyCAD.objectName():
|
||||||
|
fluencyCAD.setObjectName(u"fluencyCAD")
|
||||||
|
fluencyCAD.resize(2359, 1285)
|
||||||
|
self.actionNew_Project = QAction(fluencyCAD)
|
||||||
|
self.actionNew_Project.setObjectName(u"actionNew_Project")
|
||||||
|
self.actionOpen_Project = QAction(fluencyCAD)
|
||||||
|
self.actionOpen_Project.setObjectName(u"actionOpen_Project")
|
||||||
|
self.actionSave_Project = QAction(fluencyCAD)
|
||||||
|
self.actionSave_Project.setObjectName(u"actionSave_Project")
|
||||||
|
self.actionSave_Project_As = QAction(fluencyCAD)
|
||||||
|
self.actionSave_Project_As.setObjectName(u"actionSave_Project_As")
|
||||||
|
self.actionImport_File = QAction(fluencyCAD)
|
||||||
|
self.actionImport_File.setObjectName(u"actionImport_File")
|
||||||
|
self.actionExport_Step = QAction(fluencyCAD)
|
||||||
|
self.actionExport_Step.setObjectName(u"actionExport_Step")
|
||||||
|
self.actionExport_Iges = QAction(fluencyCAD)
|
||||||
|
self.actionExport_Iges.setObjectName(u"actionExport_Iges")
|
||||||
|
self.actionExport_Stl = QAction(fluencyCAD)
|
||||||
|
self.actionExport_Stl.setObjectName(u"actionExport_Stl")
|
||||||
|
self.actionExit = QAction(fluencyCAD)
|
||||||
|
self.actionExit.setObjectName(u"actionExit")
|
||||||
|
self.centralwidget = QWidget(fluencyCAD)
|
||||||
|
self.centralwidget.setObjectName(u"centralwidget")
|
||||||
|
self.gridLayout = QGridLayout(self.centralwidget)
|
||||||
|
self.gridLayout.setObjectName(u"gridLayout")
|
||||||
|
self.InputTab = QTabWidget(self.centralwidget)
|
||||||
|
self.InputTab.setObjectName(u"InputTab")
|
||||||
|
sizePolicy = QSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
|
||||||
|
sizePolicy.setHorizontalStretch(0)
|
||||||
|
sizePolicy.setVerticalStretch(0)
|
||||||
|
sizePolicy.setHeightForWidth(self.InputTab.sizePolicy().hasHeightForWidth())
|
||||||
|
self.InputTab.setSizePolicy(sizePolicy)
|
||||||
|
self.sketch_tab = QWidget()
|
||||||
|
self.sketch_tab.setObjectName(u"sketch_tab")
|
||||||
|
self.verticalLayout_4 = QVBoxLayout(self.sketch_tab)
|
||||||
|
self.verticalLayout_4.setObjectName(u"verticalLayout_4")
|
||||||
|
self.InputTab.addTab(self.sketch_tab, "")
|
||||||
|
self.code_tab = QWidget()
|
||||||
|
self.code_tab.setObjectName(u"code_tab")
|
||||||
|
self.verticalLayout = QVBoxLayout(self.code_tab)
|
||||||
|
self.verticalLayout.setObjectName(u"verticalLayout")
|
||||||
|
self.textEdit = QTextEdit(self.code_tab)
|
||||||
|
self.textEdit.setObjectName(u"textEdit")
|
||||||
|
|
||||||
|
self.verticalLayout.addWidget(self.textEdit)
|
||||||
|
|
||||||
|
self.groupBox_7 = QGroupBox(self.code_tab)
|
||||||
|
self.groupBox_7.setObjectName(u"groupBox_7")
|
||||||
|
self.gridLayout_5 = QGridLayout(self.groupBox_7)
|
||||||
|
self.gridLayout_5.setObjectName(u"gridLayout_5")
|
||||||
|
self.pushButton_5 = QPushButton(self.groupBox_7)
|
||||||
|
self.pushButton_5.setObjectName(u"pushButton_5")
|
||||||
|
|
||||||
|
self.gridLayout_5.addWidget(self.pushButton_5, 2, 0, 1, 1)
|
||||||
|
|
||||||
|
self.pushButton_4 = QPushButton(self.groupBox_7)
|
||||||
|
self.pushButton_4.setObjectName(u"pushButton_4")
|
||||||
|
|
||||||
|
self.gridLayout_5.addWidget(self.pushButton_4, 2, 1, 1, 1)
|
||||||
|
|
||||||
|
self.pb_apply_code = QPushButton(self.groupBox_7)
|
||||||
|
self.pb_apply_code.setObjectName(u"pb_apply_code")
|
||||||
|
|
||||||
|
self.gridLayout_5.addWidget(self.pb_apply_code, 1, 0, 1, 1)
|
||||||
|
|
||||||
|
self.pushButton = QPushButton(self.groupBox_7)
|
||||||
|
self.pushButton.setObjectName(u"pushButton")
|
||||||
|
|
||||||
|
self.gridLayout_5.addWidget(self.pushButton, 1, 1, 1, 1)
|
||||||
|
|
||||||
|
|
||||||
|
self.verticalLayout.addWidget(self.groupBox_7)
|
||||||
|
|
||||||
|
self.InputTab.addTab(self.code_tab, "")
|
||||||
|
|
||||||
|
self.gridLayout.addWidget(self.InputTab, 0, 1, 12, 1)
|
||||||
|
|
||||||
|
self.groupBox_9 = QGroupBox(self.centralwidget)
|
||||||
|
self.groupBox_9.setObjectName(u"groupBox_9")
|
||||||
|
self.groupBox_9.setMaximumSize(QSize(200, 16777215))
|
||||||
|
self.gridLayout_7 = QGridLayout(self.groupBox_9)
|
||||||
|
self.gridLayout_7.setObjectName(u"gridLayout_7")
|
||||||
|
self.pb_origin_wp = QPushButton(self.groupBox_9)
|
||||||
|
self.pb_origin_wp.setObjectName(u"pb_origin_wp")
|
||||||
|
|
||||||
|
self.gridLayout_7.addWidget(self.pb_origin_wp, 0, 0, 1, 1)
|
||||||
|
|
||||||
|
self.pb_origin_face = QPushButton(self.groupBox_9)
|
||||||
|
self.pb_origin_face.setObjectName(u"pb_origin_face")
|
||||||
|
self.pb_origin_face.setCheckable(True)
|
||||||
|
|
||||||
|
self.gridLayout_7.addWidget(self.pb_origin_face, 0, 1, 1, 1)
|
||||||
|
|
||||||
|
self.pb_flip_face = QPushButton(self.groupBox_9)
|
||||||
|
self.pb_flip_face.setObjectName(u"pb_flip_face")
|
||||||
|
|
||||||
|
self.gridLayout_7.addWidget(self.pb_flip_face, 1, 0, 1, 1)
|
||||||
|
|
||||||
|
self.pb_underlay = QPushButton(self.groupBox_9)
|
||||||
|
self.pb_underlay.setObjectName(u"pb_underlay")
|
||||||
|
self.pb_underlay.setEnabled(False)
|
||||||
|
self.pb_underlay.setCheckable(True)
|
||||||
|
self.pb_underlay.setChecked(True)
|
||||||
|
|
||||||
|
self.gridLayout_7.addWidget(self.pb_underlay, 3, 0, 1, 1)
|
||||||
|
|
||||||
|
self.pb_clr_face = QPushButton(self.groupBox_9)
|
||||||
|
self.pb_clr_face.setObjectName(u"pb_clr_face")
|
||||||
|
self.pb_clr_face.setEnabled(False)
|
||||||
|
|
||||||
|
self.gridLayout_7.addWidget(self.pb_clr_face, 3, 1, 1, 1)
|
||||||
|
|
||||||
|
self.pb_to_sketch = QPushButton(self.groupBox_9)
|
||||||
|
self.pb_to_sketch.setObjectName(u"pb_to_sketch")
|
||||||
|
self.pb_to_sketch.setEnabled(False)
|
||||||
|
|
||||||
|
self.gridLayout_7.addWidget(self.pb_to_sketch, 4, 0, 1, 2)
|
||||||
|
|
||||||
|
self.pb_wp_new = QPushButton(self.groupBox_9)
|
||||||
|
self.pb_wp_new.setObjectName(u"pb_wp_new")
|
||||||
|
|
||||||
|
self.gridLayout_7.addWidget(self.pb_wp_new, 1, 1, 1, 1)
|
||||||
|
|
||||||
|
|
||||||
|
self.gridLayout.addWidget(self.groupBox_9, 0, 0, 1, 1)
|
||||||
|
|
||||||
|
self.assembly_box = QGroupBox(self.centralwidget)
|
||||||
|
self.assembly_box.setObjectName(u"assembly_box")
|
||||||
|
self.assembly_box.setMinimumSize(QSize(0, 50))
|
||||||
|
|
||||||
|
self.gridLayout.addWidget(self.assembly_box, 13, 1, 1, 2)
|
||||||
|
|
||||||
|
self.joint_tools = QGroupBox(self.centralwidget)
|
||||||
|
self.joint_tools.setObjectName(u"joint_tools")
|
||||||
|
self.joint_tools.setMinimumSize(QSize(0, 50))
|
||||||
|
self.gridLayout_10 = QGridLayout(self.joint_tools)
|
||||||
|
self.gridLayout_10.setObjectName(u"gridLayout_10")
|
||||||
|
self.pb_remove_connector = QPushButton(self.joint_tools)
|
||||||
|
self.pb_remove_connector.setObjectName(u"pb_remove_connector")
|
||||||
|
self.pb_remove_connector.setMinimumSize(QSize(50, 50))
|
||||||
|
self.pb_remove_connector.setMaximumSize(QSize(50, 50))
|
||||||
|
|
||||||
|
self.gridLayout_10.addWidget(self.pb_remove_connector, 0, 2, 1, 1)
|
||||||
|
|
||||||
|
self.pb_add_connector = QPushButton(self.joint_tools)
|
||||||
|
self.pb_add_connector.setObjectName(u"pb_add_connector")
|
||||||
|
self.pb_add_connector.setMinimumSize(QSize(50, 50))
|
||||||
|
self.pb_add_connector.setMaximumSize(QSize(50, 50))
|
||||||
|
|
||||||
|
self.gridLayout_10.addWidget(self.pb_add_connector, 0, 1, 1, 1)
|
||||||
|
|
||||||
|
self.pb_add_connector_2 = QPushButton(self.joint_tools)
|
||||||
|
self.pb_add_connector_2.setObjectName(u"pb_add_connector_2")
|
||||||
|
self.pb_add_connector_2.setMinimumSize(QSize(50, 50))
|
||||||
|
self.pb_add_connector_2.setMaximumSize(QSize(50, 50))
|
||||||
|
|
||||||
|
self.gridLayout_10.addWidget(self.pb_add_connector_2, 1, 1, 1, 1)
|
||||||
|
|
||||||
|
self.pb_add_connector_3 = QPushButton(self.joint_tools)
|
||||||
|
self.pb_add_connector_3.setObjectName(u"pb_add_connector_3")
|
||||||
|
self.pb_add_connector_3.setMinimumSize(QSize(50, 50))
|
||||||
|
self.pb_add_connector_3.setMaximumSize(QSize(50, 50))
|
||||||
|
|
||||||
|
self.gridLayout_10.addWidget(self.pb_add_connector_3, 1, 2, 1, 1)
|
||||||
|
|
||||||
|
|
||||||
|
self.gridLayout.addWidget(self.joint_tools, 12, 3, 2, 1)
|
||||||
|
|
||||||
|
self.gl_box = QGroupBox(self.centralwidget)
|
||||||
|
self.gl_box.setObjectName(u"gl_box")
|
||||||
|
sizePolicy1 = QSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
|
||||||
|
sizePolicy1.setHorizontalStretch(0)
|
||||||
|
sizePolicy1.setVerticalStretch(4)
|
||||||
|
sizePolicy1.setHeightForWidth(self.gl_box.sizePolicy().hasHeightForWidth())
|
||||||
|
self.gl_box.setSizePolicy(sizePolicy1)
|
||||||
|
font = QFont()
|
||||||
|
font.setPointSize(12)
|
||||||
|
self.gl_box.setFont(font)
|
||||||
|
self.horizontalLayout_4 = QHBoxLayout(self.gl_box)
|
||||||
|
#ifndef Q_OS_MAC
|
||||||
|
self.horizontalLayout_4.setSpacing(-1)
|
||||||
|
#endif
|
||||||
|
self.horizontalLayout_4.setObjectName(u"horizontalLayout_4")
|
||||||
|
self.horizontalLayout_4.setContentsMargins(12, -1, -1, -1)
|
||||||
|
|
||||||
|
self.gridLayout.addWidget(self.gl_box, 0, 2, 12, 1)
|
||||||
|
|
||||||
|
self.groupBox_11 = QGroupBox(self.centralwidget)
|
||||||
|
self.groupBox_11.setObjectName(u"groupBox_11")
|
||||||
|
sizePolicy2 = QSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Expanding)
|
||||||
|
sizePolicy2.setHorizontalStretch(0)
|
||||||
|
sizePolicy2.setVerticalStretch(0)
|
||||||
|
sizePolicy2.setHeightForWidth(self.groupBox_11.sizePolicy().hasHeightForWidth())
|
||||||
|
self.groupBox_11.setSizePolicy(sizePolicy2)
|
||||||
|
self.groupBox_11.setMaximumSize(QSize(200, 16777215))
|
||||||
|
self.verticalLayout_7 = QVBoxLayout(self.groupBox_11)
|
||||||
|
self.verticalLayout_7.setObjectName(u"verticalLayout_7")
|
||||||
|
self.verticalLayout_7.setContentsMargins(5, 5, 5, 5)
|
||||||
|
self.sketch_list = QListWidget(self.groupBox_11)
|
||||||
|
self.sketch_list.setObjectName(u"sketch_list")
|
||||||
|
sizePolicy3 = QSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
|
||||||
|
sizePolicy3.setHorizontalStretch(0)
|
||||||
|
sizePolicy3.setVerticalStretch(0)
|
||||||
|
sizePolicy3.setHeightForWidth(self.sketch_list.sizePolicy().hasHeightForWidth())
|
||||||
|
self.sketch_list.setSizePolicy(sizePolicy3)
|
||||||
|
self.sketch_list.setSelectionRectVisible(True)
|
||||||
|
|
||||||
|
self.verticalLayout_7.addWidget(self.sketch_list)
|
||||||
|
|
||||||
|
self.groupBox_6 = QGroupBox(self.groupBox_11)
|
||||||
|
self.groupBox_6.setObjectName(u"groupBox_6")
|
||||||
|
sizePolicy4 = QSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Preferred)
|
||||||
|
sizePolicy4.setHorizontalStretch(0)
|
||||||
|
sizePolicy4.setVerticalStretch(0)
|
||||||
|
sizePolicy4.setHeightForWidth(self.groupBox_6.sizePolicy().hasHeightForWidth())
|
||||||
|
self.groupBox_6.setSizePolicy(sizePolicy4)
|
||||||
|
self.gridLayout_6 = QGridLayout(self.groupBox_6)
|
||||||
|
self.gridLayout_6.setObjectName(u"gridLayout_6")
|
||||||
|
self.gridLayout_6.setContentsMargins(2, 2, 2, 2)
|
||||||
|
self.pb_edt_sktch = QPushButton(self.groupBox_6)
|
||||||
|
self.pb_edt_sktch.setObjectName(u"pb_edt_sktch")
|
||||||
|
|
||||||
|
self.gridLayout_6.addWidget(self.pb_edt_sktch, 1, 1, 1, 1)
|
||||||
|
|
||||||
|
self.pb_nw_sktch = QPushButton(self.groupBox_6)
|
||||||
|
self.pb_nw_sktch.setObjectName(u"pb_nw_sktch")
|
||||||
|
|
||||||
|
self.gridLayout_6.addWidget(self.pb_nw_sktch, 1, 0, 1, 1)
|
||||||
|
|
||||||
|
self.pb_del_sketch = QPushButton(self.groupBox_6)
|
||||||
|
self.pb_del_sketch.setObjectName(u"pb_del_sketch")
|
||||||
|
|
||||||
|
self.gridLayout_6.addWidget(self.pb_del_sketch, 1, 2, 1, 1)
|
||||||
|
|
||||||
|
|
||||||
|
self.verticalLayout_7.addWidget(self.groupBox_6)
|
||||||
|
|
||||||
|
|
||||||
|
self.gridLayout.addWidget(self.groupBox_11, 6, 0, 6, 1)
|
||||||
|
|
||||||
|
self.assembly_tools = QGroupBox(self.centralwidget)
|
||||||
|
self.assembly_tools.setObjectName(u"assembly_tools")
|
||||||
|
self.assembly_tools.setMinimumSize(QSize(0, 50))
|
||||||
|
self.gridLayout_12 = QGridLayout(self.assembly_tools)
|
||||||
|
self.gridLayout_12.setObjectName(u"gridLayout_12")
|
||||||
|
self.pb_compo_to_assembly = QPushButton(self.assembly_tools)
|
||||||
|
self.pb_compo_to_assembly.setObjectName(u"pb_compo_to_assembly")
|
||||||
|
self.pb_compo_to_assembly.setMinimumSize(QSize(50, 50))
|
||||||
|
self.pb_compo_to_assembly.setMaximumSize(QSize(50, 50))
|
||||||
|
|
||||||
|
self.gridLayout_12.addWidget(self.pb_compo_to_assembly, 0, 0, 1, 1)
|
||||||
|
|
||||||
|
self.pb_remove_compo_from_assembly = QPushButton(self.assembly_tools)
|
||||||
|
self.pb_remove_compo_from_assembly.setObjectName(u"pb_remove_compo_from_assembly")
|
||||||
|
self.pb_remove_compo_from_assembly.setEnabled(True)
|
||||||
|
sizePolicy4.setHeightForWidth(self.pb_remove_compo_from_assembly.sizePolicy().hasHeightForWidth())
|
||||||
|
self.pb_remove_compo_from_assembly.setSizePolicy(sizePolicy4)
|
||||||
|
self.pb_remove_compo_from_assembly.setMinimumSize(QSize(50, 50))
|
||||||
|
self.pb_remove_compo_from_assembly.setMaximumSize(QSize(50, 50))
|
||||||
|
self.pb_remove_compo_from_assembly.setLayoutDirection(Qt.LeftToRight)
|
||||||
|
|
||||||
|
self.gridLayout_12.addWidget(self.pb_remove_compo_from_assembly, 0, 1, 1, 1)
|
||||||
|
|
||||||
|
|
||||||
|
self.gridLayout.addWidget(self.assembly_tools, 13, 0, 1, 1)
|
||||||
|
|
||||||
|
self.compo_tool_box = QGroupBox(self.centralwidget)
|
||||||
|
self.compo_tool_box.setObjectName(u"compo_tool_box")
|
||||||
|
self.compo_tool_box.setMinimumSize(QSize(0, 50))
|
||||||
|
self.gridLayout_9 = QGridLayout(self.compo_tool_box)
|
||||||
|
self.gridLayout_9.setObjectName(u"gridLayout_9")
|
||||||
|
self.pb_new_compo = QPushButton(self.compo_tool_box)
|
||||||
|
self.pb_new_compo.setObjectName(u"pb_new_compo")
|
||||||
|
self.pb_new_compo.setMinimumSize(QSize(50, 50))
|
||||||
|
self.pb_new_compo.setMaximumSize(QSize(50, 50))
|
||||||
|
|
||||||
|
self.gridLayout_9.addWidget(self.pb_new_compo, 0, 0, 1, 1)
|
||||||
|
|
||||||
|
self.pb_del_compo = QPushButton(self.compo_tool_box)
|
||||||
|
self.pb_del_compo.setObjectName(u"pb_del_compo")
|
||||||
|
self.pb_del_compo.setEnabled(True)
|
||||||
|
sizePolicy4.setHeightForWidth(self.pb_del_compo.sizePolicy().hasHeightForWidth())
|
||||||
|
self.pb_del_compo.setSizePolicy(sizePolicy4)
|
||||||
|
self.pb_del_compo.setMinimumSize(QSize(50, 50))
|
||||||
|
self.pb_del_compo.setMaximumSize(QSize(50, 50))
|
||||||
|
self.pb_del_compo.setLayoutDirection(Qt.LeftToRight)
|
||||||
|
|
||||||
|
self.gridLayout_9.addWidget(self.pb_del_compo, 0, 1, 1, 1)
|
||||||
|
|
||||||
|
|
||||||
|
self.gridLayout.addWidget(self.compo_tool_box, 12, 0, 1, 1)
|
||||||
|
|
||||||
|
self.compo_box = QGroupBox(self.centralwidget)
|
||||||
|
self.compo_box.setObjectName(u"compo_box")
|
||||||
|
self.compo_box.setMinimumSize(QSize(0, 50))
|
||||||
|
|
||||||
|
self.gridLayout.addWidget(self.compo_box, 12, 1, 1, 2)
|
||||||
|
|
||||||
|
self.groupBox_12 = QGroupBox(self.centralwidget)
|
||||||
|
self.groupBox_12.setObjectName(u"groupBox_12")
|
||||||
|
sizePolicy2.setHeightForWidth(self.groupBox_12.sizePolicy().hasHeightForWidth())
|
||||||
|
self.groupBox_12.setSizePolicy(sizePolicy2)
|
||||||
|
self.groupBox_12.setMaximumSize(QSize(200, 16777215))
|
||||||
|
self.verticalLayout_8 = QVBoxLayout(self.groupBox_12)
|
||||||
|
self.verticalLayout_8.setObjectName(u"verticalLayout_8")
|
||||||
|
self.verticalLayout_8.setContentsMargins(5, 5, 5, 5)
|
||||||
|
self.connection_list = QListWidget(self.groupBox_12)
|
||||||
|
self.connection_list.setObjectName(u"connection_list")
|
||||||
|
self.connection_list.setSelectionRectVisible(True)
|
||||||
|
|
||||||
|
self.verticalLayout_8.addWidget(self.connection_list)
|
||||||
|
|
||||||
|
self.groupBox_13 = QGroupBox(self.groupBox_12)
|
||||||
|
self.groupBox_13.setObjectName(u"groupBox_13")
|
||||||
|
sizePolicy4.setHeightForWidth(self.groupBox_13.sizePolicy().hasHeightForWidth())
|
||||||
|
self.groupBox_13.setSizePolicy(sizePolicy4)
|
||||||
|
self.groupBox_13.setMaximumSize(QSize(200, 16777215))
|
||||||
|
self.gridLayout_13 = QGridLayout(self.groupBox_13)
|
||||||
|
self.gridLayout_13.setObjectName(u"gridLayout_13")
|
||||||
|
self.gridLayout_13.setContentsMargins(2, 2, 2, 2)
|
||||||
|
self.pb_del_connection = QPushButton(self.groupBox_13)
|
||||||
|
self.pb_del_connection.setObjectName(u"pb_del_connection")
|
||||||
|
|
||||||
|
self.gridLayout_13.addWidget(self.pb_del_connection, 0, 2, 1, 1)
|
||||||
|
|
||||||
|
self.pb_update_connection = QPushButton(self.groupBox_13)
|
||||||
|
self.pb_update_connection.setObjectName(u"pb_update_connection")
|
||||||
|
|
||||||
|
self.gridLayout_13.addWidget(self.pb_update_connection, 0, 0, 1, 1)
|
||||||
|
|
||||||
|
self.pb_edt_sktch_4 = QPushButton(self.groupBox_13)
|
||||||
|
self.pb_edt_sktch_4.setObjectName(u"pb_edt_sktch_4")
|
||||||
|
|
||||||
|
self.gridLayout_13.addWidget(self.pb_edt_sktch_4, 0, 1, 1, 1)
|
||||||
|
|
||||||
|
|
||||||
|
self.verticalLayout_8.addWidget(self.groupBox_13)
|
||||||
|
|
||||||
|
|
||||||
|
self.gridLayout.addWidget(self.groupBox_12, 6, 3, 6, 1)
|
||||||
|
|
||||||
|
self.groupBox_10 = QGroupBox(self.centralwidget)
|
||||||
|
self.groupBox_10.setObjectName(u"groupBox_10")
|
||||||
|
sizePolicy2.setHeightForWidth(self.groupBox_10.sizePolicy().hasHeightForWidth())
|
||||||
|
self.groupBox_10.setSizePolicy(sizePolicy2)
|
||||||
|
self.groupBox_10.setMaximumSize(QSize(200, 16777215))
|
||||||
|
self.verticalLayout_6 = QVBoxLayout(self.groupBox_10)
|
||||||
|
self.verticalLayout_6.setObjectName(u"verticalLayout_6")
|
||||||
|
self.verticalLayout_6.setContentsMargins(5, 5, 5, 5)
|
||||||
|
self.body_list = QListWidget(self.groupBox_10)
|
||||||
|
self.body_list.setObjectName(u"body_list")
|
||||||
|
self.body_list.setSelectionRectVisible(True)
|
||||||
|
|
||||||
|
self.verticalLayout_6.addWidget(self.body_list)
|
||||||
|
|
||||||
|
self.groupBox_8 = QGroupBox(self.groupBox_10)
|
||||||
|
self.groupBox_8.setObjectName(u"groupBox_8")
|
||||||
|
sizePolicy4.setHeightForWidth(self.groupBox_8.sizePolicy().hasHeightForWidth())
|
||||||
|
self.groupBox_8.setSizePolicy(sizePolicy4)
|
||||||
|
self.groupBox_8.setMaximumSize(QSize(200, 16777215))
|
||||||
|
self.gridLayout_8 = QGridLayout(self.groupBox_8)
|
||||||
|
self.gridLayout_8.setObjectName(u"gridLayout_8")
|
||||||
|
self.gridLayout_8.setContentsMargins(2, 2, 2, 2)
|
||||||
|
self.pb_del_body = QPushButton(self.groupBox_8)
|
||||||
|
self.pb_del_body.setObjectName(u"pb_del_body")
|
||||||
|
|
||||||
|
self.gridLayout_8.addWidget(self.pb_del_body, 0, 2, 1, 1)
|
||||||
|
|
||||||
|
self.pb_update_body = QPushButton(self.groupBox_8)
|
||||||
|
self.pb_update_body.setObjectName(u"pb_update_body")
|
||||||
|
|
||||||
|
self.gridLayout_8.addWidget(self.pb_update_body, 0, 0, 1, 1)
|
||||||
|
|
||||||
|
self.pb_edt_sktch_3 = QPushButton(self.groupBox_8)
|
||||||
|
self.pb_edt_sktch_3.setObjectName(u"pb_edt_sktch_3")
|
||||||
|
|
||||||
|
self.gridLayout_8.addWidget(self.pb_edt_sktch_3, 0, 1, 1, 1)
|
||||||
|
|
||||||
|
|
||||||
|
self.verticalLayout_6.addWidget(self.groupBox_8)
|
||||||
|
|
||||||
|
|
||||||
|
self.gridLayout.addWidget(self.groupBox_10, 3, 3, 3, 1)
|
||||||
|
|
||||||
|
self.groupBox_2 = QGroupBox(self.centralwidget)
|
||||||
|
self.groupBox_2.setObjectName(u"groupBox_2")
|
||||||
|
sizePolicy4.setHeightForWidth(self.groupBox_2.sizePolicy().hasHeightForWidth())
|
||||||
|
self.groupBox_2.setSizePolicy(sizePolicy4)
|
||||||
|
self.groupBox_2.setMaximumSize(QSize(200, 16777215))
|
||||||
|
self.gridLayout_2 = QGridLayout(self.groupBox_2)
|
||||||
|
self.gridLayout_2.setObjectName(u"gridLayout_2")
|
||||||
|
self.gridLayout_2.setContentsMargins(10, -1, -1, -1)
|
||||||
|
self.pb_arc_tool = QPushButton(self.groupBox_2)
|
||||||
|
self.pb_arc_tool.setObjectName(u"pb_arc_tool")
|
||||||
|
self.pb_arc_tool.setCheckable(True)
|
||||||
|
|
||||||
|
self.gridLayout_2.addWidget(self.pb_arc_tool, 2, 0, 1, 1)
|
||||||
|
|
||||||
|
self.pb_rectool = QPushButton(self.groupBox_2)
|
||||||
|
self.pb_rectool.setObjectName(u"pb_rectool")
|
||||||
|
self.pb_rectool.setCheckable(True)
|
||||||
|
self.pb_rectool.setAutoExclusive(False)
|
||||||
|
|
||||||
|
self.gridLayout_2.addWidget(self.pb_rectool, 0, 1, 1, 1)
|
||||||
|
|
||||||
|
self.pb_circtool = QPushButton(self.groupBox_2)
|
||||||
|
self.pb_circtool.setObjectName(u"pb_circtool")
|
||||||
|
self.pb_circtool.setCheckable(True)
|
||||||
|
self.pb_circtool.setAutoExclusive(False)
|
||||||
|
|
||||||
|
self.gridLayout_2.addWidget(self.pb_circtool, 1, 0, 1, 1, Qt.AlignTop)
|
||||||
|
|
||||||
|
self.pb_enable_construct = QPushButton(self.groupBox_2)
|
||||||
|
self.pb_enable_construct.setObjectName(u"pb_enable_construct")
|
||||||
|
self.pb_enable_construct.setCheckable(True)
|
||||||
|
|
||||||
|
self.gridLayout_2.addWidget(self.pb_enable_construct, 4, 0, 1, 1)
|
||||||
|
|
||||||
|
self.pb_enable_snap = QPushButton(self.groupBox_2)
|
||||||
|
self.pb_enable_snap.setObjectName(u"pb_enable_snap")
|
||||||
|
self.pb_enable_snap.setIconSize(QSize(13, 16))
|
||||||
|
self.pb_enable_snap.setCheckable(True)
|
||||||
|
self.pb_enable_snap.setChecked(True)
|
||||||
|
|
||||||
|
self.gridLayout_2.addWidget(self.pb_enable_snap, 4, 1, 1, 1)
|
||||||
|
|
||||||
|
self.pb_linetool = QPushButton(self.groupBox_2)
|
||||||
|
self.pb_linetool.setObjectName(u"pb_linetool")
|
||||||
|
self.pb_linetool.setCheckable(True)
|
||||||
|
self.pb_linetool.setAutoExclusive(False)
|
||||||
|
|
||||||
|
self.gridLayout_2.addWidget(self.pb_linetool, 0, 0, 1, 1)
|
||||||
|
|
||||||
|
self.pb_slotool = QPushButton(self.groupBox_2)
|
||||||
|
self.pb_slotool.setObjectName(u"pb_slotool")
|
||||||
|
self.pb_slotool.setCheckable(True)
|
||||||
|
self.pb_slotool.setAutoExclusive(False)
|
||||||
|
|
||||||
|
self.gridLayout_2.addWidget(self.pb_slotool, 1, 1, 1, 1, Qt.AlignTop)
|
||||||
|
|
||||||
|
self.line = QFrame(self.groupBox_2)
|
||||||
|
self.line.setObjectName(u"line")
|
||||||
|
self.line.setFrameShape(QFrame.Shape.HLine)
|
||||||
|
self.line.setFrameShadow(QFrame.Shadow.Sunken)
|
||||||
|
|
||||||
|
self.gridLayout_2.addWidget(self.line, 3, 0, 1, 2)
|
||||||
|
|
||||||
|
self.pb_offset_tool = QPushButton(self.groupBox_2)
|
||||||
|
self.pb_offset_tool.setObjectName(u"pb_offset_tool")
|
||||||
|
|
||||||
|
self.gridLayout_2.addWidget(self.pb_offset_tool, 2, 1, 1, 1)
|
||||||
|
|
||||||
|
|
||||||
|
self.gridLayout.addWidget(self.groupBox_2, 1, 0, 1, 1)
|
||||||
|
|
||||||
|
self.groupBox_3 = QGroupBox(self.centralwidget)
|
||||||
|
self.groupBox_3.setObjectName(u"groupBox_3")
|
||||||
|
sizePolicy4.setHeightForWidth(self.groupBox_3.sizePolicy().hasHeightForWidth())
|
||||||
|
self.groupBox_3.setSizePolicy(sizePolicy4)
|
||||||
|
self.groupBox_3.setMaximumSize(QSize(200, 16777213))
|
||||||
|
self.gridLayout_4 = QGridLayout(self.groupBox_3)
|
||||||
|
self.gridLayout_4.setObjectName(u"gridLayout_4")
|
||||||
|
self.pb_con_ptpt = QPushButton(self.groupBox_3)
|
||||||
|
self.pb_con_ptpt.setObjectName(u"pb_con_ptpt")
|
||||||
|
self.pb_con_ptpt.setCheckable(True)
|
||||||
|
self.pb_con_ptpt.setAutoExclusive(False)
|
||||||
|
|
||||||
|
self.gridLayout_4.addWidget(self.pb_con_ptpt, 1, 0, 1, 1)
|
||||||
|
|
||||||
|
self.pb_con_vert = QPushButton(self.groupBox_3)
|
||||||
|
self.pb_con_vert.setObjectName(u"pb_con_vert")
|
||||||
|
self.pb_con_vert.setCheckable(True)
|
||||||
|
self.pb_con_vert.setAutoExclusive(False)
|
||||||
|
|
||||||
|
self.gridLayout_4.addWidget(self.pb_con_vert, 3, 1, 1, 1)
|
||||||
|
|
||||||
|
self.pb_con_sym = QPushButton(self.groupBox_3)
|
||||||
|
self.pb_con_sym.setObjectName(u"pb_con_sym")
|
||||||
|
self.pb_con_sym.setCheckable(True)
|
||||||
|
self.pb_con_sym.setAutoExclusive(False)
|
||||||
|
|
||||||
|
self.gridLayout_4.addWidget(self.pb_con_sym, 4, 1, 1, 1)
|
||||||
|
|
||||||
|
self.pb_con_mid = QPushButton(self.groupBox_3)
|
||||||
|
self.pb_con_mid.setObjectName(u"pb_con_mid")
|
||||||
|
self.pb_con_mid.setCheckable(True)
|
||||||
|
self.pb_con_mid.setAutoExclusive(False)
|
||||||
|
|
||||||
|
self.gridLayout_4.addWidget(self.pb_con_mid, 2, 0, 1, 1)
|
||||||
|
|
||||||
|
self.pb_con_line = QPushButton(self.groupBox_3)
|
||||||
|
self.pb_con_line.setObjectName(u"pb_con_line")
|
||||||
|
self.pb_con_line.setCheckable(True)
|
||||||
|
self.pb_con_line.setAutoExclusive(False)
|
||||||
|
|
||||||
|
self.gridLayout_4.addWidget(self.pb_con_line, 1, 1, 1, 1)
|
||||||
|
|
||||||
|
self.pb_con_horiz = QPushButton(self.groupBox_3)
|
||||||
|
self.pb_con_horiz.setObjectName(u"pb_con_horiz")
|
||||||
|
self.pb_con_horiz.setCheckable(True)
|
||||||
|
self.pb_con_horiz.setAutoExclusive(False)
|
||||||
|
|
||||||
|
self.gridLayout_4.addWidget(self.pb_con_horiz, 3, 0, 1, 1)
|
||||||
|
|
||||||
|
self.pb_con_dist = QPushButton(self.groupBox_3)
|
||||||
|
self.pb_con_dist.setObjectName(u"pb_con_dist")
|
||||||
|
self.pb_con_dist.setCheckable(True)
|
||||||
|
self.pb_con_dist.setAutoExclusive(False)
|
||||||
|
self.pb_con_dist.setAutoRepeatDelay(297)
|
||||||
|
|
||||||
|
self.gridLayout_4.addWidget(self.pb_con_dist, 4, 0, 1, 1)
|
||||||
|
|
||||||
|
self.pb_con_perp = QPushButton(self.groupBox_3)
|
||||||
|
self.pb_con_perp.setObjectName(u"pb_con_perp")
|
||||||
|
self.pb_con_perp.setCheckable(True)
|
||||||
|
self.pb_con_perp.setAutoExclusive(False)
|
||||||
|
|
||||||
|
self.gridLayout_4.addWidget(self.pb_con_perp, 2, 1, 1, 1)
|
||||||
|
|
||||||
|
self.pb_con_diameter = QPushButton(self.groupBox_3)
|
||||||
|
self.pb_con_diameter.setObjectName(u"pb_con_diameter")
|
||||||
|
|
||||||
|
self.gridLayout_4.addWidget(self.pb_con_diameter, 5, 0, 1, 1)
|
||||||
|
|
||||||
|
|
||||||
|
self.gridLayout.addWidget(self.groupBox_3, 2, 0, 1, 1)
|
||||||
|
|
||||||
|
self.groupBox_5 = QGroupBox(self.centralwidget)
|
||||||
|
self.groupBox_5.setObjectName(u"groupBox_5")
|
||||||
|
sizePolicy4.setHeightForWidth(self.groupBox_5.sizePolicy().hasHeightForWidth())
|
||||||
|
self.groupBox_5.setSizePolicy(sizePolicy4)
|
||||||
|
self.gridLayout_11 = QGridLayout(self.groupBox_5)
|
||||||
|
self.gridLayout_11.setObjectName(u"gridLayout_11")
|
||||||
|
self.gridLayout_11.setContentsMargins(12, 12, 12, 12)
|
||||||
|
self.label = QLabel(self.groupBox_5)
|
||||||
|
self.label.setObjectName(u"label")
|
||||||
|
|
||||||
|
self.gridLayout_11.addWidget(self.label, 5, 0, 1, 1)
|
||||||
|
|
||||||
|
self.pb_snap_vert = QPushButton(self.groupBox_5)
|
||||||
|
self.pb_snap_vert.setObjectName(u"pb_snap_vert")
|
||||||
|
self.pb_snap_vert.setCheckable(True)
|
||||||
|
self.pb_snap_vert.setAutoExclusive(False)
|
||||||
|
|
||||||
|
self.gridLayout_11.addWidget(self.pb_snap_vert, 2, 1, 1, 1)
|
||||||
|
|
||||||
|
self.line_2 = QFrame(self.groupBox_5)
|
||||||
|
self.line_2.setObjectName(u"line_2")
|
||||||
|
self.line_2.setFrameShape(QFrame.Shape.HLine)
|
||||||
|
self.line_2.setFrameShadow(QFrame.Shadow.Sunken)
|
||||||
|
|
||||||
|
self.gridLayout_11.addWidget(self.line_2, 4, 0, 1, 2)
|
||||||
|
|
||||||
|
self.label_2 = QLabel(self.groupBox_5)
|
||||||
|
self.label_2.setObjectName(u"label_2")
|
||||||
|
|
||||||
|
self.gridLayout_11.addWidget(self.label_2, 5, 1, 1, 1)
|
||||||
|
|
||||||
|
self.spinbox_snap_distance = QSpinBox(self.groupBox_5)
|
||||||
|
self.spinbox_snap_distance.setObjectName(u"spinbox_snap_distance")
|
||||||
|
self.spinbox_snap_distance.setMaximum(30)
|
||||||
|
self.spinbox_snap_distance.setValue(10)
|
||||||
|
|
||||||
|
self.gridLayout_11.addWidget(self.spinbox_snap_distance, 6, 0, 1, 1)
|
||||||
|
|
||||||
|
self.pushButton_7 = QPushButton(self.groupBox_5)
|
||||||
|
self.pushButton_7.setObjectName(u"pushButton_7")
|
||||||
|
self.pushButton_7.setCheckable(True)
|
||||||
|
self.pushButton_7.setAutoExclusive(False)
|
||||||
|
|
||||||
|
self.gridLayout_11.addWidget(self.pushButton_7, 3, 0, 1, 1)
|
||||||
|
|
||||||
|
self.pb_snap_horiz = QPushButton(self.groupBox_5)
|
||||||
|
self.pb_snap_horiz.setObjectName(u"pb_snap_horiz")
|
||||||
|
self.pb_snap_horiz.setCheckable(True)
|
||||||
|
self.pb_snap_horiz.setAutoExclusive(False)
|
||||||
|
|
||||||
|
self.gridLayout_11.addWidget(self.pb_snap_horiz, 2, 0, 1, 1)
|
||||||
|
|
||||||
|
self.spinbox_angle_steps = QSpinBox(self.groupBox_5)
|
||||||
|
self.spinbox_angle_steps.setObjectName(u"spinbox_angle_steps")
|
||||||
|
self.spinbox_angle_steps.setMaximum(180)
|
||||||
|
self.spinbox_angle_steps.setValue(15)
|
||||||
|
|
||||||
|
self.gridLayout_11.addWidget(self.spinbox_angle_steps, 6, 1, 1, 1)
|
||||||
|
|
||||||
|
self.pushButton_8 = QPushButton(self.groupBox_5)
|
||||||
|
self.pushButton_8.setObjectName(u"pushButton_8")
|
||||||
|
self.pushButton_8.setCheckable(True)
|
||||||
|
self.pushButton_8.setAutoExclusive(False)
|
||||||
|
|
||||||
|
self.gridLayout_11.addWidget(self.pushButton_8, 0, 0, 1, 1)
|
||||||
|
|
||||||
|
self.pb_snap_midp = QPushButton(self.groupBox_5)
|
||||||
|
self.pb_snap_midp.setObjectName(u"pb_snap_midp")
|
||||||
|
self.pb_snap_midp.setCheckable(True)
|
||||||
|
self.pb_snap_midp.setAutoExclusive(False)
|
||||||
|
|
||||||
|
self.gridLayout_11.addWidget(self.pb_snap_midp, 0, 1, 1, 1)
|
||||||
|
|
||||||
|
self.pb_snap_angle = QPushButton(self.groupBox_5)
|
||||||
|
self.pb_snap_angle.setObjectName(u"pb_snap_angle")
|
||||||
|
self.pb_snap_angle.setCheckable(True)
|
||||||
|
self.pb_snap_angle.setAutoExclusive(False)
|
||||||
|
|
||||||
|
self.gridLayout_11.addWidget(self.pb_snap_angle, 3, 1, 1, 1)
|
||||||
|
|
||||||
|
|
||||||
|
self.gridLayout.addWidget(self.groupBox_5, 3, 0, 1, 1)
|
||||||
|
|
||||||
|
self.groupBox = QGroupBox(self.centralwidget)
|
||||||
|
self.groupBox.setObjectName(u"groupBox")
|
||||||
|
self.gridLayout_3 = QGridLayout(self.groupBox)
|
||||||
|
self.gridLayout_3.setObjectName(u"gridLayout_3")
|
||||||
|
self.pb_revop = QPushButton(self.groupBox)
|
||||||
|
self.pb_revop.setObjectName(u"pb_revop")
|
||||||
|
|
||||||
|
self.gridLayout_3.addWidget(self.pb_revop, 2, 1, 1, 1)
|
||||||
|
|
||||||
|
self.pb_extrdop = QPushButton(self.groupBox)
|
||||||
|
self.pb_extrdop.setObjectName(u"pb_extrdop")
|
||||||
|
|
||||||
|
self.gridLayout_3.addWidget(self.pb_extrdop, 0, 0, 1, 1)
|
||||||
|
|
||||||
|
self.pb_arrayop = QPushButton(self.groupBox)
|
||||||
|
self.pb_arrayop.setObjectName(u"pb_arrayop")
|
||||||
|
|
||||||
|
self.gridLayout_3.addWidget(self.pb_arrayop, 2, 0, 1, 1)
|
||||||
|
|
||||||
|
self.pb_cutop = QPushButton(self.groupBox)
|
||||||
|
self.pb_cutop.setObjectName(u"pb_cutop")
|
||||||
|
|
||||||
|
self.gridLayout_3.addWidget(self.pb_cutop, 0, 1, 1, 1)
|
||||||
|
|
||||||
|
self.pb_combop = QPushButton(self.groupBox)
|
||||||
|
self.pb_combop.setObjectName(u"pb_combop")
|
||||||
|
|
||||||
|
self.gridLayout_3.addWidget(self.pb_combop, 1, 0, 1, 1)
|
||||||
|
|
||||||
|
self.pb_moveop = QPushButton(self.groupBox)
|
||||||
|
self.pb_moveop.setObjectName(u"pb_moveop")
|
||||||
|
|
||||||
|
self.gridLayout_3.addWidget(self.pb_moveop, 1, 1, 1, 1)
|
||||||
|
|
||||||
|
|
||||||
|
self.gridLayout.addWidget(self.groupBox, 0, 3, 1, 1)
|
||||||
|
|
||||||
|
self.groupBox_4 = QGroupBox(self.centralwidget)
|
||||||
|
self.groupBox_4.setObjectName(u"groupBox_4")
|
||||||
|
self.verticalLayout_2 = QVBoxLayout(self.groupBox_4)
|
||||||
|
self.verticalLayout_2.setObjectName(u"verticalLayout_2")
|
||||||
|
self.pushButton_2 = QPushButton(self.groupBox_4)
|
||||||
|
self.pushButton_2.setObjectName(u"pushButton_2")
|
||||||
|
|
||||||
|
self.verticalLayout_2.addWidget(self.pushButton_2)
|
||||||
|
|
||||||
|
self.pb_export_step = QPushButton(self.groupBox_4)
|
||||||
|
self.pb_export_step.setObjectName(u"pb_export_step")
|
||||||
|
|
||||||
|
self.verticalLayout_2.addWidget(self.pb_export_step)
|
||||||
|
|
||||||
|
self.pb_export_iges = QPushButton(self.groupBox_4)
|
||||||
|
self.pb_export_iges.setObjectName(u"pb_export_iges")
|
||||||
|
|
||||||
|
self.verticalLayout_2.addWidget(self.pb_export_iges)
|
||||||
|
|
||||||
|
|
||||||
|
self.gridLayout.addWidget(self.groupBox_4, 2, 3, 1, 1)
|
||||||
|
|
||||||
|
fluencyCAD.setCentralWidget(self.centralwidget)
|
||||||
|
self.menubar = QMenuBar(fluencyCAD)
|
||||||
|
self.menubar.setObjectName(u"menubar")
|
||||||
|
self.menubar.setGeometry(QRect(0, 0, 2359, 24))
|
||||||
|
self.menuFile = QMenu(self.menubar)
|
||||||
|
self.menuFile.setObjectName(u"menuFile")
|
||||||
|
self.menuSettings = QMenu(self.menubar)
|
||||||
|
self.menuSettings.setObjectName(u"menuSettings")
|
||||||
|
fluencyCAD.setMenuBar(self.menubar)
|
||||||
|
self.statusbar = QStatusBar(fluencyCAD)
|
||||||
|
self.statusbar.setObjectName(u"statusbar")
|
||||||
|
fluencyCAD.setStatusBar(self.statusbar)
|
||||||
|
|
||||||
|
self.menubar.addAction(self.menuFile.menuAction())
|
||||||
|
self.menubar.addAction(self.menuSettings.menuAction())
|
||||||
|
self.menuFile.addAction(self.actionNew_Project)
|
||||||
|
self.menuFile.addAction(self.actionOpen_Project)
|
||||||
|
self.menuFile.addAction(self.actionSave_Project)
|
||||||
|
self.menuFile.addAction(self.actionSave_Project_As)
|
||||||
|
self.menuFile.addSeparator()
|
||||||
|
self.menuFile.addAction(self.actionImport_File)
|
||||||
|
self.menuFile.addSeparator()
|
||||||
|
self.menuFile.addAction(self.actionExport_Step)
|
||||||
|
self.menuFile.addAction(self.actionExport_Iges)
|
||||||
|
self.menuFile.addAction(self.actionExport_Stl)
|
||||||
|
self.menuFile.addSeparator()
|
||||||
|
self.menuFile.addAction(self.actionExit)
|
||||||
|
|
||||||
|
self.retranslateUi(fluencyCAD)
|
||||||
|
|
||||||
|
self.InputTab.setCurrentIndex(0)
|
||||||
|
|
||||||
|
|
||||||
|
QMetaObject.connectSlotsByName(fluencyCAD)
|
||||||
|
# setupUi
|
||||||
|
|
||||||
|
def retranslateUi(self, fluencyCAD):
|
||||||
|
fluencyCAD.setWindowTitle(QCoreApplication.translate("fluencyCAD", u"fluencyCAD", None))
|
||||||
|
self.actionNew_Project.setText(QCoreApplication.translate("fluencyCAD", u"New Project", None))
|
||||||
|
#if QT_CONFIG(shortcut)
|
||||||
|
self.actionNew_Project.setShortcut(QCoreApplication.translate("fluencyCAD", u"Ctrl+N", None))
|
||||||
|
#endif // QT_CONFIG(shortcut)
|
||||||
|
self.actionOpen_Project.setText(QCoreApplication.translate("fluencyCAD", u"Open Project...", None))
|
||||||
|
#if QT_CONFIG(shortcut)
|
||||||
|
self.actionOpen_Project.setShortcut(QCoreApplication.translate("fluencyCAD", u"Ctrl+O", None))
|
||||||
|
#endif // QT_CONFIG(shortcut)
|
||||||
|
self.actionSave_Project.setText(QCoreApplication.translate("fluencyCAD", u"Save Project", None))
|
||||||
|
#if QT_CONFIG(shortcut)
|
||||||
|
self.actionSave_Project.setShortcut(QCoreApplication.translate("fluencyCAD", u"Ctrl+S", None))
|
||||||
|
#endif // QT_CONFIG(shortcut)
|
||||||
|
self.actionSave_Project_As.setText(QCoreApplication.translate("fluencyCAD", u"Save Project As...", None))
|
||||||
|
#if QT_CONFIG(shortcut)
|
||||||
|
self.actionSave_Project_As.setShortcut(QCoreApplication.translate("fluencyCAD", u"Ctrl+Shift+S", None))
|
||||||
|
#endif // QT_CONFIG(shortcut)
|
||||||
|
self.actionImport_File.setText(QCoreApplication.translate("fluencyCAD", u"Import STEP/IGES...", None))
|
||||||
|
self.actionExport_Step.setText(QCoreApplication.translate("fluencyCAD", u"Export STEP...", None))
|
||||||
|
self.actionExport_Iges.setText(QCoreApplication.translate("fluencyCAD", u"Export IGES...", None))
|
||||||
|
self.actionExport_Stl.setText(QCoreApplication.translate("fluencyCAD", u"Export STL...", None))
|
||||||
|
self.actionExit.setText(QCoreApplication.translate("fluencyCAD", u"Exit", None))
|
||||||
|
#if QT_CONFIG(shortcut)
|
||||||
|
self.actionExit.setShortcut(QCoreApplication.translate("fluencyCAD", u"Ctrl+Q", None))
|
||||||
|
#endif // QT_CONFIG(shortcut)
|
||||||
|
self.InputTab.setTabText(self.InputTab.indexOf(self.sketch_tab), QCoreApplication.translate("fluencyCAD", u"Sketch", None))
|
||||||
|
self.groupBox_7.setTitle(QCoreApplication.translate("fluencyCAD", u"Executive", None))
|
||||||
|
self.pushButton_5.setText(QCoreApplication.translate("fluencyCAD", u"Load Code", None))
|
||||||
|
self.pushButton_4.setText(QCoreApplication.translate("fluencyCAD", u"Save code", None))
|
||||||
|
self.pb_apply_code.setText(QCoreApplication.translate("fluencyCAD", u"Apply Code", None))
|
||||||
|
self.pushButton.setText(QCoreApplication.translate("fluencyCAD", u"Delete Code", None))
|
||||||
|
self.InputTab.setTabText(self.InputTab.indexOf(self.code_tab), QCoreApplication.translate("fluencyCAD", u"Code", None))
|
||||||
|
self.groupBox_9.setTitle(QCoreApplication.translate("fluencyCAD", u"Workplanes", None))
|
||||||
|
#if QT_CONFIG(tooltip)
|
||||||
|
self.pb_origin_wp.setToolTip(QCoreApplication.translate("fluencyCAD", u"<W>orking Plane at 0, 0, 0", None))
|
||||||
|
#endif // QT_CONFIG(tooltip)
|
||||||
|
self.pb_origin_wp.setText(QCoreApplication.translate("fluencyCAD", u"WP Origin", None))
|
||||||
|
#if QT_CONFIG(shortcut)
|
||||||
|
self.pb_origin_wp.setShortcut(QCoreApplication.translate("fluencyCAD", u"W", None))
|
||||||
|
#endif // QT_CONFIG(shortcut)
|
||||||
|
#if QT_CONFIG(tooltip)
|
||||||
|
self.pb_origin_face.setToolTip(QCoreApplication.translate("fluencyCAD", u"Working Plane >P<rojection at selected edges face", None))
|
||||||
|
#endif // QT_CONFIG(tooltip)
|
||||||
|
self.pb_origin_face.setText(QCoreApplication.translate("fluencyCAD", u" WP Face", None))
|
||||||
|
#if QT_CONFIG(shortcut)
|
||||||
|
self.pb_origin_face.setShortcut(QCoreApplication.translate("fluencyCAD", u"P", None))
|
||||||
|
#endif // QT_CONFIG(shortcut)
|
||||||
|
#if QT_CONFIG(tooltip)
|
||||||
|
self.pb_flip_face.setToolTip(QCoreApplication.translate("fluencyCAD", u"Flip >N<ormal of projected mesh.", None))
|
||||||
|
#endif // QT_CONFIG(tooltip)
|
||||||
|
self.pb_flip_face.setText(QCoreApplication.translate("fluencyCAD", u"WP Flip", None))
|
||||||
|
#if QT_CONFIG(shortcut)
|
||||||
|
self.pb_flip_face.setShortcut(QCoreApplication.translate("fluencyCAD", u"N", None))
|
||||||
|
#endif // QT_CONFIG(shortcut)
|
||||||
|
#if QT_CONFIG(tooltip)
|
||||||
|
self.pb_underlay.setToolTip(QCoreApplication.translate("fluencyCAD", u"Show / hide the construction lines projected from the source face", None))
|
||||||
|
#endif // QT_CONFIG(tooltip)
|
||||||
|
self.pb_underlay.setText(QCoreApplication.translate("fluencyCAD", u"Underlay", None))
|
||||||
|
#if QT_CONFIG(tooltip)
|
||||||
|
self.pb_clr_face.setToolTip(QCoreApplication.translate("fluencyCAD", u"Forget the picked source face (keep the workplane)", None))
|
||||||
|
#endif // QT_CONFIG(tooltip)
|
||||||
|
self.pb_clr_face.setText(QCoreApplication.translate("fluencyCAD", u"ClrFace", None))
|
||||||
|
#if QT_CONFIG(tooltip)
|
||||||
|
self.pb_to_sketch.setToolTip(QCoreApplication.translate("fluencyCAD", u"Convert projected construction lines into real sketch geometry", None))
|
||||||
|
#endif // QT_CONFIG(tooltip)
|
||||||
|
self.pb_to_sketch.setText(QCoreApplication.translate("fluencyCAD", u"ToSketch", None))
|
||||||
|
#if QT_CONFIG(tooltip)
|
||||||
|
self.pb_wp_new.setToolTip(QCoreApplication.translate("fluencyCAD", u"Create a new independent workplane (datum plane)", None))
|
||||||
|
#endif // QT_CONFIG(tooltip)
|
||||||
|
self.pb_wp_new.setText(QCoreApplication.translate("fluencyCAD", u"WP New", None))
|
||||||
|
#if QT_CONFIG(shortcut)
|
||||||
|
self.pb_wp_new.setShortcut(QCoreApplication.translate("fluencyCAD", u"Shift+W", None))
|
||||||
|
#endif // QT_CONFIG(shortcut)
|
||||||
|
self.assembly_box.setTitle(QCoreApplication.translate("fluencyCAD", u"Assembly", None))
|
||||||
|
self.joint_tools.setTitle(QCoreApplication.translate("fluencyCAD", u"Joint Tools", None))
|
||||||
|
self.pb_remove_connector.setText(QCoreApplication.translate("fluencyCAD", u"- Cnct", None))
|
||||||
|
self.pb_add_connector.setText(QCoreApplication.translate("fluencyCAD", u"+ Cnct", None))
|
||||||
|
self.pb_add_connector_2.setText(QCoreApplication.translate("fluencyCAD", u"+Jnt", None))
|
||||||
|
self.pb_add_connector_3.setText(QCoreApplication.translate("fluencyCAD", u"-Jnt", None))
|
||||||
|
self.gl_box.setTitle(QCoreApplication.translate("fluencyCAD", u"Model Viewer", None))
|
||||||
|
self.groupBox_11.setTitle(QCoreApplication.translate("fluencyCAD", u"Sketch", None))
|
||||||
|
self.groupBox_6.setTitle(QCoreApplication.translate("fluencyCAD", u"Tools", None))
|
||||||
|
self.pb_edt_sktch.setText(QCoreApplication.translate("fluencyCAD", u"Edt", None))
|
||||||
|
self.pb_nw_sktch.setText(QCoreApplication.translate("fluencyCAD", u"Add", None))
|
||||||
|
self.pb_del_sketch.setText(QCoreApplication.translate("fluencyCAD", u"Del", None))
|
||||||
|
self.assembly_tools.setTitle(QCoreApplication.translate("fluencyCAD", u"Assembly Tools", None))
|
||||||
|
self.pb_compo_to_assembly.setText(QCoreApplication.translate("fluencyCAD", u"Add", None))
|
||||||
|
self.pb_remove_compo_from_assembly.setText(QCoreApplication.translate("fluencyCAD", u"Rem", None))
|
||||||
|
self.compo_tool_box.setTitle(QCoreApplication.translate("fluencyCAD", u"Component Tools", None))
|
||||||
|
self.pb_new_compo.setText(QCoreApplication.translate("fluencyCAD", u"New", None))
|
||||||
|
self.pb_del_compo.setText(QCoreApplication.translate("fluencyCAD", u"Del", None))
|
||||||
|
self.compo_box.setTitle(QCoreApplication.translate("fluencyCAD", u"Components", None))
|
||||||
|
self.groupBox_12.setTitle(QCoreApplication.translate("fluencyCAD", u"Component Connections", None))
|
||||||
|
self.groupBox_13.setTitle(QCoreApplication.translate("fluencyCAD", u"Tools", None))
|
||||||
|
self.pb_del_connection.setText(QCoreApplication.translate("fluencyCAD", u"Del", None))
|
||||||
|
self.pb_update_connection.setText(QCoreApplication.translate("fluencyCAD", u"Upd", None))
|
||||||
|
self.pb_edt_sktch_4.setText(QCoreApplication.translate("fluencyCAD", u"Nothing", None))
|
||||||
|
self.groupBox_10.setTitle(QCoreApplication.translate("fluencyCAD", u"Bodys / Operations", None))
|
||||||
|
self.groupBox_8.setTitle(QCoreApplication.translate("fluencyCAD", u"Tools", None))
|
||||||
|
self.pb_del_body.setText(QCoreApplication.translate("fluencyCAD", u"Del", None))
|
||||||
|
self.pb_update_body.setText(QCoreApplication.translate("fluencyCAD", u"Upd", None))
|
||||||
|
self.pb_edt_sktch_3.setText(QCoreApplication.translate("fluencyCAD", u"Nothing", None))
|
||||||
|
self.groupBox_2.setTitle(QCoreApplication.translate("fluencyCAD", u"Drawing", None))
|
||||||
|
self.pb_arc_tool.setText(QCoreApplication.translate("fluencyCAD", u"Arc", None))
|
||||||
|
self.pb_rectool.setText(QCoreApplication.translate("fluencyCAD", u"Rctgl", None))
|
||||||
|
self.pb_circtool.setText(QCoreApplication.translate("fluencyCAD", u"Circle", None))
|
||||||
|
self.pb_enable_construct.setText(QCoreApplication.translate("fluencyCAD", u"Cstrct", None))
|
||||||
|
self.pb_enable_snap.setText(QCoreApplication.translate("fluencyCAD", u"Snap", None))
|
||||||
|
self.pb_linetool.setText(QCoreApplication.translate("fluencyCAD", u"Line", None))
|
||||||
|
#if QT_CONFIG(shortcut)
|
||||||
|
self.pb_linetool.setShortcut(QCoreApplication.translate("fluencyCAD", u"S", None))
|
||||||
|
#endif // QT_CONFIG(shortcut)
|
||||||
|
self.pb_slotool.setText(QCoreApplication.translate("fluencyCAD", u"Slot", None))
|
||||||
|
#if QT_CONFIG(tooltip)
|
||||||
|
self.pb_offset_tool.setToolTip(QCoreApplication.translate("fluencyCAD", u"Offset selected sketch face (duplicate + offset boundary)", None))
|
||||||
|
#endif // QT_CONFIG(tooltip)
|
||||||
|
self.pb_offset_tool.setText(QCoreApplication.translate("fluencyCAD", u"Offst", None))
|
||||||
|
self.groupBox_3.setTitle(QCoreApplication.translate("fluencyCAD", u"Constrain", None))
|
||||||
|
#if QT_CONFIG(tooltip)
|
||||||
|
self.pb_con_ptpt.setToolTip(QCoreApplication.translate("fluencyCAD", u"Poin to Point Constrain", None))
|
||||||
|
#endif // QT_CONFIG(tooltip)
|
||||||
|
self.pb_con_ptpt.setText(QCoreApplication.translate("fluencyCAD", u"Pt_Pt", None))
|
||||||
|
#if QT_CONFIG(tooltip)
|
||||||
|
self.pb_con_vert.setToolTip(QCoreApplication.translate("fluencyCAD", u"Vertical Constrain", None))
|
||||||
|
#endif // QT_CONFIG(tooltip)
|
||||||
|
self.pb_con_vert.setText(QCoreApplication.translate("fluencyCAD", u"Vert", None))
|
||||||
|
self.pb_con_sym.setText(QCoreApplication.translate("fluencyCAD", u"Symetrc", None))
|
||||||
|
#if QT_CONFIG(tooltip)
|
||||||
|
self.pb_con_mid.setToolTip(QCoreApplication.translate("fluencyCAD", u"Point to Middle Point Constrain", None))
|
||||||
|
#endif // QT_CONFIG(tooltip)
|
||||||
|
self.pb_con_mid.setText(QCoreApplication.translate("fluencyCAD", u"Pt_Mid_L", None))
|
||||||
|
#if QT_CONFIG(tooltip)
|
||||||
|
self.pb_con_line.setToolTip(QCoreApplication.translate("fluencyCAD", u"Point to Line Constrain", None))
|
||||||
|
#endif // QT_CONFIG(tooltip)
|
||||||
|
self.pb_con_line.setText(QCoreApplication.translate("fluencyCAD", u"Pt_Lne", None))
|
||||||
|
#if QT_CONFIG(tooltip)
|
||||||
|
self.pb_con_horiz.setToolTip(QCoreApplication.translate("fluencyCAD", u"Horizontal Constrain ", None))
|
||||||
|
#endif // QT_CONFIG(tooltip)
|
||||||
|
self.pb_con_horiz.setText(QCoreApplication.translate("fluencyCAD", u"Horiz", None))
|
||||||
|
#if QT_CONFIG(tooltip)
|
||||||
|
self.pb_con_dist.setToolTip(QCoreApplication.translate("fluencyCAD", u"Dimension of Line of Distance from Point to Line", None))
|
||||||
|
#endif // QT_CONFIG(tooltip)
|
||||||
|
self.pb_con_dist.setText(QCoreApplication.translate("fluencyCAD", u"Distnce", None))
|
||||||
|
#if QT_CONFIG(tooltip)
|
||||||
|
self.pb_con_perp.setToolTip(QCoreApplication.translate("fluencyCAD", u"Constrain Line perpendicular to another line.", None))
|
||||||
|
#endif // QT_CONFIG(tooltip)
|
||||||
|
self.pb_con_perp.setText(QCoreApplication.translate("fluencyCAD", u"Perp_Lne", None))
|
||||||
|
self.pb_con_diameter.setText(QCoreApplication.translate("fluencyCAD", u"Diameter", None))
|
||||||
|
self.groupBox_5.setTitle(QCoreApplication.translate("fluencyCAD", u"Snapping Points", None))
|
||||||
|
self.label.setText(QCoreApplication.translate("fluencyCAD", u"Snp Dst", None))
|
||||||
|
self.pb_snap_vert.setText(QCoreApplication.translate("fluencyCAD", u"Vert", None))
|
||||||
|
self.label_2.setText(QCoreApplication.translate("fluencyCAD", u"Angl Stps", None))
|
||||||
|
self.spinbox_snap_distance.setSuffix(QCoreApplication.translate("fluencyCAD", u"mm", None))
|
||||||
|
self.pushButton_7.setText(QCoreApplication.translate("fluencyCAD", u"Grid", None))
|
||||||
|
self.pb_snap_horiz.setText(QCoreApplication.translate("fluencyCAD", u"Horiz", None))
|
||||||
|
self.spinbox_angle_steps.setSuffix(QCoreApplication.translate("fluencyCAD", u"\u00b0", None))
|
||||||
|
self.pushButton_8.setText(QCoreApplication.translate("fluencyCAD", u"Pnt", None))
|
||||||
|
self.pb_snap_midp.setText(QCoreApplication.translate("fluencyCAD", u"MidP", None))
|
||||||
|
self.pb_snap_angle.setText(QCoreApplication.translate("fluencyCAD", u"Angles", None))
|
||||||
|
self.groupBox.setTitle(QCoreApplication.translate("fluencyCAD", u"Modify", None))
|
||||||
|
self.pb_revop.setText(QCoreApplication.translate("fluencyCAD", u"Rev", None))
|
||||||
|
self.pb_extrdop.setText(QCoreApplication.translate("fluencyCAD", u"Extrd", None))
|
||||||
|
self.pb_arrayop.setText(QCoreApplication.translate("fluencyCAD", u"Arry", None))
|
||||||
|
self.pb_cutop.setText(QCoreApplication.translate("fluencyCAD", u"Cut", None))
|
||||||
|
self.pb_combop.setText(QCoreApplication.translate("fluencyCAD", u"Comb", None))
|
||||||
|
self.pb_moveop.setText(QCoreApplication.translate("fluencyCAD", u"Mve", None))
|
||||||
|
self.groupBox_4.setTitle(QCoreApplication.translate("fluencyCAD", u"Export", None))
|
||||||
|
self.pushButton_2.setText(QCoreApplication.translate("fluencyCAD", u"STL", None))
|
||||||
|
self.pb_export_step.setText(QCoreApplication.translate("fluencyCAD", u"STEP", None))
|
||||||
|
self.pb_export_iges.setText(QCoreApplication.translate("fluencyCAD", u"IGES", None))
|
||||||
|
self.menuFile.setTitle(QCoreApplication.translate("fluencyCAD", u"File", None))
|
||||||
|
self.menuSettings.setTitle(QCoreApplication.translate("fluencyCAD", u"Settings", None))
|
||||||
|
# retranslateUi
|
||||||
|
|
||||||
+1
-1
@@ -26,13 +26,13 @@ classifiers = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cadquery>=2.4",
|
|
||||||
"pygfx>=0.1.0",
|
"pygfx>=0.1.0",
|
||||||
"wgpu>=0.1.0",
|
"wgpu>=0.1.0",
|
||||||
"PySide6>=6.4.0",
|
"PySide6>=6.4.0",
|
||||||
"numpy>=1.24.0",
|
"numpy>=1.24.0",
|
||||||
"scipy>=1.10.0",
|
"scipy>=1.10.0",
|
||||||
"pillow>=10.0.0",
|
"pillow>=10.0.0",
|
||||||
|
"python_solvespace>=3.0.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
Fluency CAD - Parametric CAD Application
|
Fluency CAD - Parametric CAD Application
|
||||||
|
|
||||||
A modern parametric CAD application built on OpenCASCADE Technology (OCCT)
|
A modern parametric CAD application built on OpenCASCADE Technology (OCCT)
|
||||||
with a clean Python API using CadQuery.
|
with a clean Python API using OCP (OpenCASCADE Python bindings).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__version__ = "2.0.0"
|
__version__ = "2.0.0"
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -406,6 +406,11 @@ class SketchInterface(ABC):
|
|||||||
"""Constrain two circles to have equal radius."""
|
"""Constrain two circles to have equal radius."""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def constrain_diameter(self, circle: SketchEntity, diameter: float) -> bool:
|
||||||
|
"""Set the diameter of a circle."""
|
||||||
|
pass
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def constrain_fixed(self, entity: SketchEntity) -> bool:
|
def constrain_fixed(self, entity: SketchEntity) -> bool:
|
||||||
"""Fix an entity in place."""
|
"""Fix an entity in place."""
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
+284
-287
@@ -2,7 +2,7 @@
|
|||||||
OpenCASCADE-based geometry kernel for Fluency CAD.
|
OpenCASCADE-based geometry kernel for Fluency CAD.
|
||||||
|
|
||||||
This module provides a concrete implementation of the geometry kernel
|
This module provides a concrete implementation of the geometry kernel
|
||||||
using CadQuery and OCP (OpenCASCADE Python bindings).
|
using OCP (OpenCASCADE Python bindings).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import List, Tuple, Optional, Any, Dict
|
from typing import List, Tuple, Optional, Any, Dict
|
||||||
@@ -21,24 +21,14 @@ class OCCGeometryObject(GeometryObject):
|
|||||||
|
|
||||||
def __init__(self, shape: Any = None, metadata: Optional[Dict] = None):
|
def __init__(self, shape: Any = None, metadata: Optional[Dict] = None):
|
||||||
super().__init__(shape, metadata)
|
super().__init__(shape, metadata)
|
||||||
self._cadquery_obj: Any = None
|
|
||||||
|
|
||||||
@property
|
|
||||||
def cq_obj(self) -> Any:
|
|
||||||
"""Get the CadQuery object if available."""
|
|
||||||
return self._cadquery_obj
|
|
||||||
|
|
||||||
@cq_obj.setter
|
|
||||||
def cq_obj(self, value: Any) -> None:
|
|
||||||
self._cadquery_obj = value
|
|
||||||
|
|
||||||
|
|
||||||
class OCGeometryKernel(GeometryKernel):
|
class OCGeometryKernel(GeometryKernel):
|
||||||
"""
|
"""
|
||||||
OpenCASCADE-based geometry kernel implementation.
|
OpenCASCADE-based geometry kernel implementation.
|
||||||
|
|
||||||
This kernel uses CadQuery for high-level operations and
|
This kernel uses OCP (OpenCASCADE Python bindings) for all geometry
|
||||||
OCP for direct OpenCASCADE access when needed.
|
operations.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
@@ -46,109 +36,98 @@ class OCGeometryKernel(GeometryKernel):
|
|||||||
self._mesh_tolerance: float = 0.1
|
self._mesh_tolerance: float = 0.1
|
||||||
|
|
||||||
def _get_shape(self, obj: GeometryObject) -> Any:
|
def _get_shape(self, obj: GeometryObject) -> Any:
|
||||||
"""Extract the underlying OCC shape from a GeometryObject."""
|
"""Extract the underlying OCC shape from a GeometryObject.
|
||||||
import cadquery as cq
|
|
||||||
|
|
||||||
|
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 isinstance(obj, OCCGeometryObject):
|
||||||
if obj._cadquery_obj is not None:
|
if obj.shape is not None and hasattr(obj.shape, "wrapped"):
|
||||||
shape = obj._cadquery_obj.val()
|
return obj.shape.wrapped
|
||||||
if hasattr(shape, "wrapped"):
|
return obj.shape
|
||||||
return shape.wrapped
|
# Non-OCCGeometryObject: return its shape if present, else None.
|
||||||
return shape
|
# (Use explicit identity/truth checks — some OCP TopoDS objects have a
|
||||||
if obj.shape is not None:
|
# falsy __bool__, so ``obj.shape if obj.shape`` is unsafe.)
|
||||||
if isinstance(obj.shape, cq.Workplane):
|
shape = getattr(obj, "shape", None)
|
||||||
shape = obj.shape.val()
|
return shape if shape is not None else None
|
||||||
if hasattr(shape, "wrapped"):
|
|
||||||
return shape.wrapped
|
|
||||||
return shape
|
|
||||||
if hasattr(obj.shape, "wrapped"):
|
|
||||||
return obj.shape.wrapped
|
|
||||||
return obj.shape
|
|
||||||
return obj.shape if obj.shape else obj
|
|
||||||
|
|
||||||
def _get_cq_obj(self, obj: GeometryObject) -> Any:
|
|
||||||
"""Get CadQuery object from GeometryObject."""
|
|
||||||
if isinstance(obj, OCCGeometryObject) and obj._cadquery_obj is not None:
|
|
||||||
return obj._cadquery_obj
|
|
||||||
return obj.shape
|
|
||||||
|
|
||||||
def create_point(self, x: float, y: float) -> GeometryObject:
|
def create_point(self, x: float, y: float) -> GeometryObject:
|
||||||
"""Create a 2D point."""
|
"""Create a 2D point."""
|
||||||
import cadquery as cq
|
from OCP.gp import gp_Pnt
|
||||||
|
return OCCGeometryObject(gp_Pnt(x, y, 0))
|
||||||
point = cq.Vector(x, y, 0)
|
|
||||||
return OCCGeometryObject(point)
|
|
||||||
|
|
||||||
def create_line(self, start: Point2D, end: Point2D) -> GeometryObject:
|
def create_line(self, start: Point2D, end: Point2D) -> GeometryObject:
|
||||||
"""Create a 2D line segment."""
|
"""Create a 2D line segment."""
|
||||||
import cadquery as cq
|
from OCP.gp import gp_Pnt
|
||||||
|
from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeEdge
|
||||||
|
|
||||||
wp = cq.Workplane("XY").moveTo(start.x, start.y).lineTo(end.x, end.y)
|
edge = BRepBuilderAPI_MakeEdge(
|
||||||
obj = OCCGeometryObject(wp.val(), {"type": "line"})
|
gp_Pnt(start.x, start.y, 0), gp_Pnt(end.x, end.y, 0)
|
||||||
obj._cadquery_obj = wp
|
).Edge()
|
||||||
return obj
|
return OCCGeometryObject(edge, {"type": "line"})
|
||||||
|
|
||||||
def create_circle(self, center: Point2D, radius: float) -> GeometryObject:
|
def create_circle(self, center: Point2D, radius: float) -> GeometryObject:
|
||||||
"""Create a 2D circle."""
|
"""Create a 2D circle."""
|
||||||
import cadquery as cq
|
from OCP.gp import gp_Pnt, gp_Dir, gp_Ax2, gp_Circ
|
||||||
|
from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeEdge
|
||||||
|
|
||||||
wp = cq.Workplane("XY").center(center.x, center.y).circle(radius)
|
circ = gp_Circ(
|
||||||
obj = OCCGeometryObject(wp.val(), {"type": "circle"})
|
gp_Ax2(gp_Pnt(center.x, center.y, 0), gp_Dir(0, 0, 1)), radius
|
||||||
obj._cadquery_obj = wp
|
)
|
||||||
return obj
|
edge = BRepBuilderAPI_MakeEdge(circ).Edge()
|
||||||
|
return OCCGeometryObject(edge, {"type": "circle"})
|
||||||
|
|
||||||
def create_arc(
|
def create_arc(
|
||||||
self, center: Point2D, radius: float, start_angle: float, end_angle: float
|
self, center: Point2D, radius: float, start_angle: float, end_angle: float
|
||||||
) -> GeometryObject:
|
) -> GeometryObject:
|
||||||
"""Create a 2D arc."""
|
"""Create a 2D arc."""
|
||||||
import cadquery as cq
|
|
||||||
import math
|
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)
|
start_rad = math.radians(start_angle)
|
||||||
end_rad = math.radians(end_angle)
|
end_rad = math.radians(end_angle)
|
||||||
|
|
||||||
start_x = center.x + radius * math.cos(start_rad)
|
circ = gp_Circ(
|
||||||
start_y = center.y + radius * math.sin(start_rad)
|
gp_Ax2(gp_Pnt(center.x, center.y, 0), gp_Dir(0, 0, 1)), radius
|
||||||
|
|
||||||
wire = (
|
|
||||||
cq.Workplane("XY")
|
|
||||||
.moveTo(start_x, start_y)
|
|
||||||
.radiusArc(
|
|
||||||
(center.x + radius * math.cos(end_rad), center.y + radius * math.sin(end_rad)),
|
|
||||||
radius,
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
return OCCGeometryObject(wire.val(), {"type": "arc"})
|
edge = BRepBuilderAPI_MakeEdge(circ, start_rad, end_rad).Edge()
|
||||||
|
return OCCGeometryObject(edge, {"type": "arc"})
|
||||||
|
|
||||||
def create_polygon(self, points: List[Point2D]) -> GeometryObject:
|
def create_polygon(self, points: List[Point2D]) -> GeometryObject:
|
||||||
"""Create a closed polygon from points."""
|
"""Create a closed polygon from points."""
|
||||||
import cadquery as cq
|
from OCP.gp import gp_Pnt
|
||||||
|
from OCP.BRepBuilderAPI import BRepBuilderAPI_MakePolygon
|
||||||
|
|
||||||
if len(points) < 3:
|
if len(points) < 3:
|
||||||
raise ValueError("Polygon requires at least 3 points")
|
raise ValueError("Polygon requires at least 3 points")
|
||||||
|
|
||||||
wp = cq.Workplane("XY").moveTo(points[0].x, points[0].y)
|
mp = BRepBuilderAPI_MakePolygon()
|
||||||
for pt in points[1:]:
|
for pt in points:
|
||||||
wp = wp.lineTo(pt.x, pt.y)
|
mp.Add(gp_Pnt(pt.x, pt.y, 0))
|
||||||
wp = wp.close()
|
mp.Close()
|
||||||
|
return OCCGeometryObject(mp.Wire(), {"type": "polygon"})
|
||||||
obj = OCCGeometryObject(wp.val(), {"type": "polygon"})
|
|
||||||
obj._cadquery_obj = wp
|
|
||||||
return obj
|
|
||||||
|
|
||||||
def create_rectangle(
|
def create_rectangle(
|
||||||
self, width: float, height: float, center: Optional[Point2D] = None
|
self, width: float, height: float, center: Optional[Point2D] = None
|
||||||
) -> GeometryObject:
|
) -> GeometryObject:
|
||||||
"""Create a rectangle."""
|
"""Create a rectangle."""
|
||||||
import cadquery as cq
|
from OCP.gp import gp_Pnt
|
||||||
|
from OCP.BRepBuilderAPI import BRepBuilderAPI_MakePolygon
|
||||||
|
|
||||||
cx = center.x if center else 0
|
cx = center.x if center else 0
|
||||||
cy = center.y if center else 0
|
cy = center.y if center else 0
|
||||||
|
|
||||||
wp = cq.Workplane("XY").center(cx, cy).rect(width, height)
|
hw = width / 2.0
|
||||||
obj = OCCGeometryObject(wp.val(), {"type": "rectangle"})
|
hh = height / 2.0
|
||||||
obj._cadquery_obj = wp
|
|
||||||
return obj
|
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(
|
def extrude(
|
||||||
self,
|
self,
|
||||||
@@ -157,24 +136,109 @@ class OCGeometryKernel(GeometryKernel):
|
|||||||
direction: Tuple[float, float, float] = (0, 0, 1),
|
direction: Tuple[float, float, float] = (0, 0, 1),
|
||||||
symmetric: bool = False,
|
symmetric: bool = False,
|
||||||
) -> GeometryObject:
|
) -> GeometryObject:
|
||||||
"""Extrude a 2D sketch into a 3D solid."""
|
"""Extrude a sketch face into a 3D solid along the sketch plane normal.
|
||||||
import cadquery as cq
|
|
||||||
|
|
||||||
cq_obj = self._get_cq_obj(sketch)
|
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
|
||||||
|
|
||||||
if isinstance(cq_obj, cq.Workplane):
|
# Defensive: figure out the actual shape from whatever the caller
|
||||||
if symmetric:
|
# hands us, and surface a clear error if we can't get one.
|
||||||
solid = cq_obj.extrude(height / 2, both=True)
|
if isinstance(sketch, OCCGeometryObject):
|
||||||
else:
|
face = self._get_shape(sketch)
|
||||||
solid = cq_obj.extrude(height)
|
elif isinstance(sketch, TopoDS_Shape):
|
||||||
|
face = sketch
|
||||||
else:
|
else:
|
||||||
wp = cq.Workplane("XY").add(cq_obj)
|
face = self._get_shape(sketch)
|
||||||
if symmetric:
|
if face is None:
|
||||||
solid = wp.extrude(height / 2, both=True)
|
raise ValueError(
|
||||||
else:
|
"Cannot extrude: sketch has no geometry. "
|
||||||
solid = wp.extrude(height)
|
"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)."
|
||||||
|
)
|
||||||
|
|
||||||
return OCCGeometryObject(solid, {"type": "extrusion"})
|
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(
|
def revolve(
|
||||||
self,
|
self,
|
||||||
@@ -183,65 +247,79 @@ class OCGeometryKernel(GeometryKernel):
|
|||||||
axis: Tuple[float, float, float] = (0, 0, 1),
|
axis: Tuple[float, float, float] = (0, 0, 1),
|
||||||
origin: Tuple[float, float, float] = (0, 0, 0),
|
origin: Tuple[float, float, float] = (0, 0, 0),
|
||||||
) -> GeometryObject:
|
) -> GeometryObject:
|
||||||
"""Revolve a 2D sketch around an axis."""
|
"""Revolve a sketch face around an axis."""
|
||||||
import cadquery as cq
|
import math
|
||||||
|
|
||||||
cq_obj = self._get_cq_obj(sketch)
|
# Get the OCC shape directly (a TopoDS_Face for new sketch geometry).
|
||||||
|
shape = self._get_shape(sketch)
|
||||||
|
face = self._ensure_face(shape)
|
||||||
|
|
||||||
if isinstance(cq_obj, cq.Workplane):
|
from OCP.gp import gp_Ax1, gp_Pnt, gp_Dir
|
||||||
solid = cq_obj.revolve(angle)
|
from OCP.BRepPrimAPI import BRepPrimAPI_MakeRevol
|
||||||
else:
|
|
||||||
face = cq.Face.makeFromWires(cq_obj)
|
|
||||||
axis_vec = cq.Vector(*axis)
|
|
||||||
origin_vec = cq.Vector(*origin)
|
|
||||||
solid = face.revolve(axis_vec, origin_vec, angle)
|
|
||||||
|
|
||||||
return OCCGeometryObject(solid, {"type": "revolution"})
|
# 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:
|
def loft(self, profiles: List[GeometryObject], ruled: bool = False) -> GeometryObject:
|
||||||
"""Create a loft between multiple profiles."""
|
"""Create a loft between multiple profiles."""
|
||||||
import cadquery as cq
|
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:
|
if len(profiles) < 2:
|
||||||
raise ValueError("Loft requires at least 2 profiles")
|
raise ValueError("Loft requires at least 2 profiles")
|
||||||
|
|
||||||
wires = []
|
loft_maker = BRepOffsetAPI_ThruSections(True, ruled)
|
||||||
for profile in profiles:
|
for profile in profiles:
|
||||||
cq_obj = self._get_cq_obj(profile)
|
shape = self._get_shape(profile)
|
||||||
if isinstance(cq_obj, cq.Workplane):
|
explorer = TopExp_Explorer(shape, TopAbs_WIRE)
|
||||||
wires.append(cq_obj.val())
|
while explorer.More():
|
||||||
else:
|
wire = TopoDS.Wire_s(explorer.Current())
|
||||||
wires.append(cq_obj)
|
loft_maker.AddWire(wire)
|
||||||
|
explorer.Next()
|
||||||
|
|
||||||
loft = cq.Solid.loft(wires, ruled)
|
loft_maker.Build()
|
||||||
return OCCGeometryObject(loft, {"type": "loft"})
|
solid = loft_maker.Shape()
|
||||||
|
return OCCGeometryObject(solid, {"type": "loft"})
|
||||||
|
|
||||||
def sweep(
|
def sweep(
|
||||||
self, profile: GeometryObject, path: GeometryObject, is_frenet: bool = False
|
self, profile: GeometryObject, path: GeometryObject, is_frenet: bool = False
|
||||||
) -> GeometryObject:
|
) -> GeometryObject:
|
||||||
"""Sweep a profile along a path."""
|
"""Sweep a profile along a path."""
|
||||||
import cadquery as cq
|
from OCP.BRepOffsetAPI import BRepOffsetAPI_MakePipeShell
|
||||||
|
from OCP.TopExp import TopExp_Explorer
|
||||||
|
from OCP.TopAbs import TopAbs_WIRE
|
||||||
|
from OCP.TopoDS import TopoDS
|
||||||
|
|
||||||
profile_obj = self._get_cq_obj(profile)
|
profile_shape = self._get_shape(profile)
|
||||||
path_obj = self._get_cq_obj(path)
|
path_shape = self._get_shape(path)
|
||||||
|
|
||||||
if isinstance(profile_obj, cq.Workplane):
|
def _first_wire(shape):
|
||||||
profile_wire = profile_obj.val()
|
exp = TopExp_Explorer(shape, TopAbs_WIRE)
|
||||||
else:
|
if exp.More():
|
||||||
profile_wire = profile_obj
|
return TopoDS.Wire_s(exp.Current())
|
||||||
|
raise ValueError("No wire found in shape for sweep")
|
||||||
|
|
||||||
if isinstance(path_obj, cq.Workplane):
|
profile_wire = _first_wire(profile_shape)
|
||||||
path_wire = path_obj.val()
|
path_wire = _first_wire(path_shape)
|
||||||
else:
|
|
||||||
path_wire = path_obj
|
|
||||||
|
|
||||||
solid = cq.Solid.sweep(profile_wire, path_wire, is_frenet)
|
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"})
|
return OCCGeometryObject(solid, {"type": "sweep"})
|
||||||
|
|
||||||
def boolean_union(self, *bodies: GeometryObject) -> GeometryObject:
|
def boolean_union(self, *bodies: GeometryObject) -> GeometryObject:
|
||||||
"""Union multiple bodies."""
|
"""Union multiple bodies."""
|
||||||
import cadquery as cq
|
|
||||||
|
|
||||||
if len(bodies) < 2:
|
if len(bodies) < 2:
|
||||||
return bodies[0] if bodies else OCCGeometryObject(None)
|
return bodies[0] if bodies else OCCGeometryObject(None)
|
||||||
|
|
||||||
@@ -254,12 +332,10 @@ class OCGeometryKernel(GeometryKernel):
|
|||||||
fuse.Build()
|
fuse.Build()
|
||||||
result = fuse.Shape()
|
result = fuse.Shape()
|
||||||
|
|
||||||
return OCCGeometryObject(cq.Shape(result), {"type": "union"})
|
return OCCGeometryObject(result, {"type": "union"})
|
||||||
|
|
||||||
def boolean_difference(self, base: GeometryObject, tool: GeometryObject) -> GeometryObject:
|
def boolean_difference(self, base: GeometryObject, tool: GeometryObject) -> GeometryObject:
|
||||||
"""Subtract tool from base."""
|
"""Subtract tool from base."""
|
||||||
import cadquery as cq
|
|
||||||
|
|
||||||
base_shape = self._get_shape(base)
|
base_shape = self._get_shape(base)
|
||||||
tool_shape = self._get_shape(tool)
|
tool_shape = self._get_shape(tool)
|
||||||
|
|
||||||
@@ -268,12 +344,10 @@ class OCGeometryKernel(GeometryKernel):
|
|||||||
cut = BRepAlgoAPI_Cut(base_shape, tool_shape)
|
cut = BRepAlgoAPI_Cut(base_shape, tool_shape)
|
||||||
cut.Build()
|
cut.Build()
|
||||||
|
|
||||||
return OCCGeometryObject(cq.Shape(cut.Shape()), {"type": "difference"})
|
return OCCGeometryObject(cut.Shape(), {"type": "difference"})
|
||||||
|
|
||||||
def boolean_intersection(self, body1: GeometryObject, body2: GeometryObject) -> GeometryObject:
|
def boolean_intersection(self, body1: GeometryObject, body2: GeometryObject) -> GeometryObject:
|
||||||
"""Intersect two bodies."""
|
"""Intersect two bodies."""
|
||||||
import cadquery as cq
|
|
||||||
|
|
||||||
shape1 = self._get_shape(body1)
|
shape1 = self._get_shape(body1)
|
||||||
shape2 = self._get_shape(body2)
|
shape2 = self._get_shape(body2)
|
||||||
|
|
||||||
@@ -282,27 +356,21 @@ class OCGeometryKernel(GeometryKernel):
|
|||||||
common = BRepAlgoAPI_Common(shape1, shape2)
|
common = BRepAlgoAPI_Common(shape1, shape2)
|
||||||
common.Build()
|
common.Build()
|
||||||
|
|
||||||
return OCCGeometryObject(cq.Shape(common.Shape()), {"type": "intersection"})
|
return OCCGeometryObject(common.Shape(), {"type": "intersection"})
|
||||||
|
|
||||||
def fillet(
|
def fillet(
|
||||||
self, body: GeometryObject, radius: float, edges: Optional[List[Any]] = None
|
self, body: GeometryObject, radius: float, edges: Optional[List[Any]] = None
|
||||||
) -> GeometryObject:
|
) -> GeometryObject:
|
||||||
"""Apply fillet to edges."""
|
"""Apply fillet to edges."""
|
||||||
import cadquery as cq
|
shape = self._get_shape(body)
|
||||||
|
from OCP.BRepFilletAPI import BRepFilletAPI_MakeFillet
|
||||||
|
|
||||||
cq_obj = self._get_cq_obj(body)
|
fillet = BRepFilletAPI_MakeFillet(shape)
|
||||||
|
|
||||||
if isinstance(cq_obj, cq.Workplane):
|
if edges:
|
||||||
if edges:
|
for edge in edges:
|
||||||
result = cq_obj.edges(edges).fillet(radius)
|
fillet.Add(radius, edge)
|
||||||
else:
|
|
||||||
result = cq_obj.edges().fillet(radius)
|
|
||||||
else:
|
else:
|
||||||
shape = self._get_shape(body)
|
|
||||||
from OCP.BRepFilletAPI import BRepFilletAPI_MakeFillet
|
|
||||||
|
|
||||||
fillet = BRepFilletAPI_MakeFillet(shape)
|
|
||||||
|
|
||||||
from OCP.TopExp import TopExp_Explorer
|
from OCP.TopExp import TopExp_Explorer
|
||||||
from OCP.TopAbs import TopAbs_EDGE
|
from OCP.TopAbs import TopAbs_EDGE
|
||||||
|
|
||||||
@@ -311,29 +379,22 @@ class OCGeometryKernel(GeometryKernel):
|
|||||||
fillet.Add(radius, explorer.Current())
|
fillet.Add(radius, explorer.Current())
|
||||||
explorer.Next()
|
explorer.Next()
|
||||||
|
|
||||||
result = cq.Shape(fillet.Shape())
|
fillet.Build()
|
||||||
|
return OCCGeometryObject(fillet.Shape(), {"type": "fillet"})
|
||||||
return OCCGeometryObject(result, {"type": "fillet"})
|
|
||||||
|
|
||||||
def chamfer(
|
def chamfer(
|
||||||
self, body: GeometryObject, size: float, edges: Optional[List[Any]] = None
|
self, body: GeometryObject, size: float, edges: Optional[List[Any]] = None
|
||||||
) -> GeometryObject:
|
) -> GeometryObject:
|
||||||
"""Apply chamfer to edges."""
|
"""Apply chamfer to edges."""
|
||||||
import cadquery as cq
|
shape = self._get_shape(body)
|
||||||
|
from OCP.BRepFilletAPI import BRepFilletAPI_MakeChamfer
|
||||||
|
|
||||||
cq_obj = self._get_cq_obj(body)
|
chamfer = BRepFilletAPI_MakeChamfer(shape)
|
||||||
|
|
||||||
if isinstance(cq_obj, cq.Workplane):
|
if edges:
|
||||||
if edges:
|
for edge in edges:
|
||||||
result = cq_obj.edges(edges).chamfer(size)
|
chamfer.Add(size, edge)
|
||||||
else:
|
|
||||||
result = cq_obj.edges().chamfer(size)
|
|
||||||
else:
|
else:
|
||||||
shape = self._get_shape(body)
|
|
||||||
from OCP.BRepFilletAPI import BRepFilletAPI_MakeChamfer
|
|
||||||
|
|
||||||
chamfer = BRepFilletAPI_MakeChamfer(shape)
|
|
||||||
|
|
||||||
from OCP.TopExp import TopExp_Explorer
|
from OCP.TopExp import TopExp_Explorer
|
||||||
from OCP.TopAbs import TopAbs_EDGE
|
from OCP.TopAbs import TopAbs_EDGE
|
||||||
|
|
||||||
@@ -342,71 +403,47 @@ class OCGeometryKernel(GeometryKernel):
|
|||||||
chamfer.Add(size, explorer.Current())
|
chamfer.Add(size, explorer.Current())
|
||||||
explorer.Next()
|
explorer.Next()
|
||||||
|
|
||||||
result = cq.Shape(chamfer.Shape())
|
chamfer.Build()
|
||||||
|
return OCCGeometryObject(chamfer.Shape(), {"type": "chamfer"})
|
||||||
return OCCGeometryObject(result, {"type": "chamfer"})
|
|
||||||
|
|
||||||
def shell(
|
def shell(
|
||||||
self, body: GeometryObject, thickness: float, faces_to_remove: Optional[List[Any]] = None
|
self, body: GeometryObject, thickness: float, faces_to_remove: Optional[List[Any]] = None
|
||||||
) -> GeometryObject:
|
) -> GeometryObject:
|
||||||
"""Create a shell (hollow body)."""
|
"""Create a shell (hollow body)."""
|
||||||
import cadquery as cq
|
shape = self._get_shape(body)
|
||||||
|
from OCP.BRepOffsetAPI import BRepOffsetAPI_MakeThickSolid
|
||||||
|
from OCP.TopTools import TopTools_ListOfShape
|
||||||
|
|
||||||
cq_obj = self._get_cq_obj(body)
|
faces_list = TopTools_ListOfShape()
|
||||||
|
if faces_to_remove:
|
||||||
|
for face in faces_to_remove:
|
||||||
|
faces_list.Append(face)
|
||||||
|
|
||||||
if isinstance(cq_obj, cq.Workplane):
|
shell_maker = BRepOffsetAPI_MakeThickSolid()
|
||||||
if faces_to_remove:
|
shell_maker.MakeThickSolidByJoin(shape, faces_list, thickness, 0.001)
|
||||||
result = cq_obj.faces(faces_to_remove).shell(thickness)
|
shell_maker.Build()
|
||||||
else:
|
return OCCGeometryObject(shell_maker.Shape(), {"type": "shell"})
|
||||||
result = cq_obj.shell(thickness)
|
|
||||||
else:
|
|
||||||
shape = self._get_shape(body)
|
|
||||||
from OCP.BRepOffsetAPI import BRepOffsetAPI_MakeThickSolid
|
|
||||||
from OCP.TopTools import TopTools_ListOfShape
|
|
||||||
|
|
||||||
faces_list = TopTools_ListOfShape()
|
|
||||||
if faces_to_remove:
|
|
||||||
for face in faces_to_remove:
|
|
||||||
faces_list.Append(face)
|
|
||||||
|
|
||||||
shell_maker = BRepOffsetAPI_MakeThickSolid()
|
|
||||||
shell_maker.MakeThickSolidByJoin(shape, faces_list, thickness, 0.001)
|
|
||||||
shell_maker.Build()
|
|
||||||
result = cq.Shape(shell_maker.Shape())
|
|
||||||
|
|
||||||
return OCCGeometryObject(result, {"type": "shell"})
|
|
||||||
|
|
||||||
def offset(self, face: GeometryObject, distance: float) -> GeometryObject:
|
def offset(self, face: GeometryObject, distance: float) -> GeometryObject:
|
||||||
"""Offset a face or surface."""
|
"""Offset a face or surface."""
|
||||||
import cadquery as cq
|
|
||||||
|
|
||||||
shape = self._get_shape(face)
|
shape = self._get_shape(face)
|
||||||
from OCP.BRepOffsetAPI import BRepOffsetAPI_MakeOffset
|
from OCP.BRepOffsetAPI import BRepOffsetAPI_MakeOffset
|
||||||
|
|
||||||
offset_maker = BRepOffsetAPI_MakeOffset(shape, False)
|
offset_maker = BRepOffsetAPI_MakeOffset(shape, False)
|
||||||
offset_maker.Perform(distance)
|
offset_maker.Perform(distance)
|
||||||
|
|
||||||
return OCCGeometryObject(cq.Shape(offset_maker.Shape()), {"type": "offset"})
|
return OCCGeometryObject(offset_maker.Shape(), {"type": "offset"})
|
||||||
|
|
||||||
def translate(self, body: GeometryObject, vector: Tuple[float, float, float]) -> GeometryObject:
|
def translate(self, body: GeometryObject, vector: Tuple[float, float, float]) -> GeometryObject:
|
||||||
"""Translate a body."""
|
"""Translate a body."""
|
||||||
import cadquery as cq
|
shape = self._get_shape(body)
|
||||||
|
from OCP.BRepBuilderAPI import BRepBuilderAPI_Transform
|
||||||
|
from OCP.gp import gp_Trsf, gp_Vec
|
||||||
|
|
||||||
cq_obj = self._get_cq_obj(body)
|
transform = gp_Trsf()
|
||||||
|
transform.SetTranslation(gp_Vec(*vector))
|
||||||
if isinstance(cq_obj, cq.Workplane):
|
transformer = BRepBuilderAPI_Transform(shape, transform)
|
||||||
result = cq_obj.translate(vector)
|
return OCCGeometryObject(transformer.Shape(), {"type": "translated"})
|
||||||
else:
|
|
||||||
shape = self._get_shape(body)
|
|
||||||
from OCP.BRepBuilderAPI import BRepBuilderAPI_Transform
|
|
||||||
from OCP.gp import gp_Trsf, gp_Vec
|
|
||||||
|
|
||||||
transform = gp_Trsf()
|
|
||||||
transform.SetTranslation(gp_Vec(*vector))
|
|
||||||
transformer = BRepBuilderAPI_Transform(shape, transform)
|
|
||||||
result = cq.Shape(transformer.Shape())
|
|
||||||
|
|
||||||
return OCCGeometryObject(result, {"type": "translated"})
|
|
||||||
|
|
||||||
def rotate(
|
def rotate(
|
||||||
self,
|
self,
|
||||||
@@ -416,30 +453,19 @@ class OCGeometryKernel(GeometryKernel):
|
|||||||
origin: Tuple[float, float, float] = (0, 0, 0),
|
origin: Tuple[float, float, float] = (0, 0, 0),
|
||||||
) -> GeometryObject:
|
) -> GeometryObject:
|
||||||
"""Rotate a body around an axis."""
|
"""Rotate a body around an axis."""
|
||||||
import cadquery as cq
|
|
||||||
import math
|
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
|
||||||
|
|
||||||
cq_obj = self._get_cq_obj(body)
|
ax1 = gp_Ax1(gp_Pnt(*origin), gp_Dir(*axis))
|
||||||
|
transform = gp_Trsf()
|
||||||
if isinstance(cq_obj, cq.Workplane):
|
transform.SetRotation(ax1, angle)
|
||||||
result = cq_obj.rotate(origin, axis, math.degrees(angle))
|
transformer = BRepBuilderAPI_Transform(shape, transform)
|
||||||
else:
|
return OCCGeometryObject(transformer.Shape(), {"type": "rotated"})
|
||||||
shape = self._get_shape(body)
|
|
||||||
from OCP.BRepBuilderAPI import BRepBuilderAPI_Transform
|
|
||||||
from OCP.gp import gp_Trsf, gp_Ax1, gp_Pnt, gp_Dir, gp_Vec
|
|
||||||
|
|
||||||
ax1 = gp_Ax1(gp_Pnt(*origin), gp_Dir(*axis))
|
|
||||||
transform = gp_Trsf()
|
|
||||||
transform.SetRotation(ax1, angle)
|
|
||||||
transformer = BRepBuilderAPI_Transform(shape, transform)
|
|
||||||
result = cq.Shape(transformer.Shape())
|
|
||||||
|
|
||||||
return OCCGeometryObject(result, {"type": "rotated"})
|
|
||||||
|
|
||||||
def scale(self, body: GeometryObject, factor: float) -> GeometryObject:
|
def scale(self, body: GeometryObject, factor: float) -> GeometryObject:
|
||||||
"""Scale a body uniformly."""
|
"""Scale a body uniformly."""
|
||||||
import cadquery as cq
|
|
||||||
|
|
||||||
shape = self._get_shape(body)
|
shape = self._get_shape(body)
|
||||||
from OCP.BRepBuilderAPI import BRepBuilderAPI_Transform
|
from OCP.BRepBuilderAPI import BRepBuilderAPI_Transform
|
||||||
from OCP.gp import gp_Trsf
|
from OCP.gp import gp_Trsf
|
||||||
@@ -448,7 +474,7 @@ class OCGeometryKernel(GeometryKernel):
|
|||||||
transform.SetScale(factor)
|
transform.SetScale(factor)
|
||||||
transformer = BRepBuilderAPI_Transform(shape, transform)
|
transformer = BRepBuilderAPI_Transform(shape, transform)
|
||||||
|
|
||||||
return OCCGeometryObject(cq.Shape(transformer.Shape()), {"type": "scaled"})
|
return OCCGeometryObject(transformer.Shape(), {"type": "scaled"})
|
||||||
|
|
||||||
def mirror(
|
def mirror(
|
||||||
self,
|
self,
|
||||||
@@ -457,8 +483,6 @@ class OCGeometryKernel(GeometryKernel):
|
|||||||
plane_origin: Tuple[float, float, float] = (0, 0, 0),
|
plane_origin: Tuple[float, float, float] = (0, 0, 0),
|
||||||
) -> GeometryObject:
|
) -> GeometryObject:
|
||||||
"""Mirror a body across a plane."""
|
"""Mirror a body across a plane."""
|
||||||
import cadquery as cq
|
|
||||||
|
|
||||||
shape = self._get_shape(body)
|
shape = self._get_shape(body)
|
||||||
from OCP.BRepBuilderAPI import BRepBuilderAPI_Transform
|
from OCP.BRepBuilderAPI import BRepBuilderAPI_Transform
|
||||||
from OCP.gp import gp_Trsf, gp_Ax2, gp_Pnt, gp_Dir
|
from OCP.gp import gp_Trsf, gp_Ax2, gp_Pnt, gp_Dir
|
||||||
@@ -468,19 +492,12 @@ class OCGeometryKernel(GeometryKernel):
|
|||||||
transform.SetMirror(ax2)
|
transform.SetMirror(ax2)
|
||||||
transformer = BRepBuilderAPI_Transform(shape, transform)
|
transformer = BRepBuilderAPI_Transform(shape, transform)
|
||||||
|
|
||||||
return OCCGeometryObject(cq.Shape(transformer.Shape()), {"type": "mirrored"})
|
return OCCGeometryObject(transformer.Shape(), {"type": "mirrored"})
|
||||||
|
|
||||||
def export_step(self, body: GeometryObject, filepath: str, schema: str = "AP214") -> bool:
|
def export_step(self, body: GeometryObject, filepath: str, schema: str = "AP214") -> bool:
|
||||||
"""Export to STEP format."""
|
"""Export to STEP format."""
|
||||||
try:
|
try:
|
||||||
import cadquery as cq
|
|
||||||
|
|
||||||
shape = self._get_shape(body)
|
shape = self._get_shape(body)
|
||||||
|
|
||||||
if hasattr(shape, "exportStep"):
|
|
||||||
shape.exportStep(filepath)
|
|
||||||
return True
|
|
||||||
|
|
||||||
from OCP.STEPControl import STEPControl_Writer, STEPControl_AsIs
|
from OCP.STEPControl import STEPControl_Writer, STEPControl_AsIs
|
||||||
from OCP.Interface import Interface_Static
|
from OCP.Interface import Interface_Static
|
||||||
|
|
||||||
@@ -491,8 +508,7 @@ class OCGeometryKernel(GeometryKernel):
|
|||||||
Interface_Static.SetCVal_s("write.step.schema", "AP203")
|
Interface_Static.SetCVal_s("write.step.schema", "AP203")
|
||||||
|
|
||||||
writer.Transfer(shape, STEPControl_AsIs)
|
writer.Transfer(shape, STEPControl_AsIs)
|
||||||
writer.Write(filepath)
|
return writer.Write(filepath)
|
||||||
return True
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"STEP export error: {e}")
|
print(f"STEP export error: {e}")
|
||||||
return False
|
return False
|
||||||
@@ -500,18 +516,14 @@ class OCGeometryKernel(GeometryKernel):
|
|||||||
def export_iges(self, body: GeometryObject, filepath: str) -> bool:
|
def export_iges(self, body: GeometryObject, filepath: str) -> bool:
|
||||||
"""Export to IGES format."""
|
"""Export to IGES format."""
|
||||||
try:
|
try:
|
||||||
import cadquery as cq
|
|
||||||
|
|
||||||
shape = self._get_shape(body)
|
shape = self._get_shape(body)
|
||||||
|
|
||||||
from OCP.IGESControl import IGESControl_Writer
|
from OCP.IGESControl import IGESControl_Writer
|
||||||
from OCP.Interface import Interface_Static
|
from OCP.Interface import Interface_Static
|
||||||
|
|
||||||
Interface_Static.SetCVal_s("write.iges.schema", "5.3")
|
Interface_Static.SetCVal_s("write.iges.schema", "5.3")
|
||||||
writer = IGESControl_Writer()
|
writer = IGESControl_Writer()
|
||||||
writer.AddShape(shape)
|
writer.AddShape(shape)
|
||||||
writer.Write(filepath)
|
return writer.Write(filepath)
|
||||||
return True
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"IGES export error: {e}")
|
print(f"IGES export error: {e}")
|
||||||
return False
|
return False
|
||||||
@@ -521,14 +533,7 @@ class OCGeometryKernel(GeometryKernel):
|
|||||||
) -> bool:
|
) -> bool:
|
||||||
"""Export to STL format."""
|
"""Export to STL format."""
|
||||||
try:
|
try:
|
||||||
import cadquery as cq
|
|
||||||
|
|
||||||
shape = self._get_shape(body)
|
shape = self._get_shape(body)
|
||||||
|
|
||||||
if hasattr(shape, "exportStl"):
|
|
||||||
shape.exportStl(filepath, tolerance)
|
|
||||||
return True
|
|
||||||
|
|
||||||
from OCP.StlAPI import StlAPI_Writer
|
from OCP.StlAPI import StlAPI_Writer
|
||||||
from OCP.BRepMesh import BRepMesh_IncrementalMesh
|
from OCP.BRepMesh import BRepMesh_IncrementalMesh
|
||||||
|
|
||||||
@@ -537,23 +542,28 @@ class OCGeometryKernel(GeometryKernel):
|
|||||||
|
|
||||||
writer = StlAPI_Writer()
|
writer = StlAPI_Writer()
|
||||||
writer.ASCIIMode = ascii_mode
|
writer.ASCIIMode = ascii_mode
|
||||||
writer.Write(shape, filepath)
|
return writer.Write(shape, filepath)
|
||||||
return True
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"STL export error: {e}")
|
print(f"STL export error: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def import_step(self, filepath: str) -> GeometryObject:
|
def import_step(self, filepath: str) -> GeometryObject:
|
||||||
"""Import from STEP format."""
|
"""Import from STEP format."""
|
||||||
import cadquery as cq
|
from OCP.STEPControl import STEPControl_Reader
|
||||||
|
from OCP.IFSelect import IFSelect_RetDone
|
||||||
|
|
||||||
result = cq.importers.importStep(filepath)
|
reader = STEPControl_Reader()
|
||||||
return OCCGeometryObject(result, {"type": "imported_step"})
|
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:
|
def import_iges(self, filepath: str) -> GeometryObject:
|
||||||
"""Import from IGES format."""
|
"""Import from IGES format."""
|
||||||
import cadquery as cq
|
|
||||||
|
|
||||||
from OCP.IGESControl import IGESControl_Reader
|
from OCP.IGESControl import IGESControl_Reader
|
||||||
from OCP.IFSelect import IFSelect_RetDone
|
from OCP.IFSelect import IFSelect_RetDone
|
||||||
|
|
||||||
@@ -566,26 +576,13 @@ class OCGeometryKernel(GeometryKernel):
|
|||||||
reader.TransferRoots()
|
reader.TransferRoots()
|
||||||
shape = reader.OneShape()
|
shape = reader.OneShape()
|
||||||
|
|
||||||
return OCCGeometryObject(cq.Shape(shape), {"type": "imported_iges"})
|
return OCCGeometryObject(shape, {"type": "imported_iges"})
|
||||||
|
|
||||||
def get_mesh(
|
def get_mesh(
|
||||||
self, body: GeometryObject, tolerance: float = 0.1
|
self, body: GeometryObject, tolerance: float = 0.1
|
||||||
) -> Tuple[np.ndarray, np.ndarray]:
|
) -> Tuple[np.ndarray, np.ndarray]:
|
||||||
"""Get triangulated mesh for rendering."""
|
"""Get triangulated mesh for rendering."""
|
||||||
import cadquery as cq
|
shape = self._get_shape(body)
|
||||||
|
|
||||||
cq_obj = self._get_cq_obj(body)
|
|
||||||
|
|
||||||
if isinstance(cq_obj, cq.Workplane):
|
|
||||||
shape = cq_obj.val()
|
|
||||||
if hasattr(shape, "wrapped"):
|
|
||||||
shape = shape.wrapped
|
|
||||||
else:
|
|
||||||
shape = self._get_shape(body)
|
|
||||||
|
|
||||||
if hasattr(shape, "tessellate"):
|
|
||||||
vertices, faces = shape.tessellate(tolerance)
|
|
||||||
return np.array(vertices), np.array(faces)
|
|
||||||
|
|
||||||
from OCP.BRepMesh import BRepMesh_IncrementalMesh
|
from OCP.BRepMesh import BRepMesh_IncrementalMesh
|
||||||
from OCP.TopExp import TopExp_Explorer
|
from OCP.TopExp import TopExp_Explorer
|
||||||
@@ -593,7 +590,9 @@ class OCGeometryKernel(GeometryKernel):
|
|||||||
from OCP.BRep import BRep_Tool
|
from OCP.BRep import BRep_Tool
|
||||||
from OCP.TopLoc import TopLoc_Location
|
from OCP.TopLoc import TopLoc_Location
|
||||||
|
|
||||||
mesh = BRepMesh_IncrementalMesh(shape, tolerance, False, 0.5, False)
|
# 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()
|
mesh.Perform()
|
||||||
|
|
||||||
vertices_list: List[List[float]] = []
|
vertices_list: List[List[float]] = []
|
||||||
@@ -601,6 +600,7 @@ class OCGeometryKernel(GeometryKernel):
|
|||||||
vertex_offset = 0
|
vertex_offset = 0
|
||||||
|
|
||||||
from OCP.TopoDS import TopoDS
|
from OCP.TopoDS import TopoDS
|
||||||
|
from OCP.TopAbs import TopAbs_Orientation
|
||||||
|
|
||||||
explorer = TopExp_Explorer(shape, TopAbs_FACE)
|
explorer = TopExp_Explorer(shape, TopAbs_FACE)
|
||||||
while explorer.More():
|
while explorer.More():
|
||||||
@@ -615,15 +615,21 @@ class OCGeometryKernel(GeometryKernel):
|
|||||||
vertices_list.append([p.X(), p.Y(), p.Z()])
|
vertices_list.append([p.X(), p.Y(), p.Z()])
|
||||||
|
|
||||||
n_triangles = triangulation.NbTriangles()
|
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):
|
for i in range(1, n_triangles + 1):
|
||||||
tri = triangulation.Triangle(i)
|
tri = triangulation.Triangle(i)
|
||||||
faces_list.append(
|
v0, v1, v2 = (
|
||||||
[
|
tri.Value(1) - 1 + vertex_offset,
|
||||||
tri.Value(1) - 1 + vertex_offset,
|
tri.Value(2) - 1 + vertex_offset,
|
||||||
tri.Value(2) - 1 + vertex_offset,
|
tri.Value(3) - 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
|
vertex_offset += n_vertices
|
||||||
|
|
||||||
@@ -633,8 +639,6 @@ class OCGeometryKernel(GeometryKernel):
|
|||||||
|
|
||||||
def get_edges(self, body: GeometryObject) -> Tuple[np.ndarray, np.ndarray]:
|
def get_edges(self, body: GeometryObject) -> Tuple[np.ndarray, np.ndarray]:
|
||||||
"""Get edge wireframe for rendering."""
|
"""Get edge wireframe for rendering."""
|
||||||
import cadquery as cq
|
|
||||||
|
|
||||||
shape = self._get_shape(body)
|
shape = self._get_shape(body)
|
||||||
|
|
||||||
from OCP.TopExp import TopExp_Explorer
|
from OCP.TopExp import TopExp_Explorer
|
||||||
@@ -673,7 +677,8 @@ class OCGeometryKernel(GeometryKernel):
|
|||||||
|
|
||||||
explorer = TopExp_Explorer(shape, TopAbs_EDGE)
|
explorer = TopExp_Explorer(shape, TopAbs_EDGE)
|
||||||
while explorer.More():
|
while explorer.More():
|
||||||
edge = explorer.Current()
|
from OCP.TopoDS import TopoDS
|
||||||
|
edge = TopoDS.Edge_s(explorer.Current())
|
||||||
edge_points = discretize_edge(edge)
|
edge_points = discretize_edge(edge)
|
||||||
|
|
||||||
for i, pt in enumerate(edge_points):
|
for i, pt in enumerate(edge_points):
|
||||||
@@ -688,8 +693,6 @@ class OCGeometryKernel(GeometryKernel):
|
|||||||
|
|
||||||
def get_bounding_box(self, body: GeometryObject) -> Tuple[Point3D, Point3D]:
|
def get_bounding_box(self, body: GeometryObject) -> Tuple[Point3D, Point3D]:
|
||||||
"""Get the bounding box of a body."""
|
"""Get the bounding box of a body."""
|
||||||
import cadquery as cq
|
|
||||||
|
|
||||||
shape = self._get_shape(body)
|
shape = self._get_shape(body)
|
||||||
|
|
||||||
from OCP.Bnd import Bnd_Box
|
from OCP.Bnd import Bnd_Box
|
||||||
@@ -704,8 +707,6 @@ class OCGeometryKernel(GeometryKernel):
|
|||||||
|
|
||||||
def get_volume(self, body: GeometryObject) -> float:
|
def get_volume(self, body: GeometryObject) -> float:
|
||||||
"""Calculate the volume of a solid body."""
|
"""Calculate the volume of a solid body."""
|
||||||
import cadquery as cq
|
|
||||||
|
|
||||||
shape = self._get_shape(body)
|
shape = self._get_shape(body)
|
||||||
|
|
||||||
from OCP.GProp import GProp_GProps
|
from OCP.GProp import GProp_GProps
|
||||||
@@ -718,8 +719,6 @@ class OCGeometryKernel(GeometryKernel):
|
|||||||
|
|
||||||
def get_surface_area(self, body: GeometryObject) -> float:
|
def get_surface_area(self, body: GeometryObject) -> float:
|
||||||
"""Calculate the surface area of a body."""
|
"""Calculate the surface area of a body."""
|
||||||
import cadquery as cq
|
|
||||||
|
|
||||||
shape = self._get_shape(body)
|
shape = self._get_shape(body)
|
||||||
|
|
||||||
from OCP.GProp import GProp_GProps
|
from OCP.GProp import GProp_GProps
|
||||||
@@ -732,8 +731,6 @@ class OCGeometryKernel(GeometryKernel):
|
|||||||
|
|
||||||
def get_center_of_mass(self, body: GeometryObject) -> Point3D:
|
def get_center_of_mass(self, body: GeometryObject) -> Point3D:
|
||||||
"""Calculate the center of mass of a solid body."""
|
"""Calculate the center of mass of a solid body."""
|
||||||
import cadquery as cq
|
|
||||||
|
|
||||||
shape = self._get_shape(body)
|
shape = self._get_shape(body)
|
||||||
|
|
||||||
from OCP.GProp import GProp_GProps
|
from OCP.GProp import GProp_GProps
|
||||||
|
|||||||
+1634
-66
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
|
||||||
+42
-1608
File diff suppressed because it is too large
Load Diff
Binary file not shown.
Binary file not shown.
@@ -6,7 +6,7 @@ including projects, components, sketches, and bodies.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Dict, List, Optional, Any
|
from typing import Dict, List, Optional, Any, Tuple
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import uuid
|
import uuid
|
||||||
import numpy as np
|
import numpy as np
|
||||||
@@ -21,6 +21,76 @@ from fluency.geometry_occ.kernel import OCGeometryKernel, OCCGeometryObject
|
|||||||
from fluency.geometry_occ.sketch import OCCSketch
|
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
|
@dataclass
|
||||||
class Sketch:
|
class Sketch:
|
||||||
"""
|
"""
|
||||||
@@ -46,6 +116,32 @@ class Sketch:
|
|||||||
created_at: datetime = field(default_factory=datetime.now)
|
created_at: datetime = field(default_factory=datetime.now)
|
||||||
modified_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:
|
def add_point(self, x: float, y: float) -> Any:
|
||||||
"""Add a point to the sketch."""
|
"""Add a point to the sketch."""
|
||||||
self.modified_at = datetime.now()
|
self.modified_at = datetime.now()
|
||||||
@@ -126,6 +222,20 @@ class Body:
|
|||||||
source_sketch: Optional[Sketch] = None
|
source_sketch: Optional[Sketch] = None
|
||||||
source_operation: str = "extrude"
|
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]))
|
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))
|
rotation: np.ndarray = field(default_factory=lambda: np.eye(3))
|
||||||
|
|
||||||
@@ -166,12 +276,34 @@ class Component:
|
|||||||
|
|
||||||
sketches: Dict[str, Sketch] = field(default_factory=dict)
|
sketches: Dict[str, Sketch] = field(default_factory=dict)
|
||||||
bodies: Dict[str, Body] = 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_sketch: Optional[str] = None
|
||||||
|
active_workplane: Optional[str] = None
|
||||||
|
|
||||||
created_at: datetime = field(default_factory=datetime.now)
|
created_at: datetime = field(default_factory=datetime.now)
|
||||||
modified_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:
|
def add_sketch(self, sketch: Optional[Sketch] = None) -> Sketch:
|
||||||
"""Add a sketch to the component."""
|
"""Add a sketch to the component."""
|
||||||
if sketch is None:
|
if sketch is None:
|
||||||
@@ -218,6 +350,264 @@ class Component:
|
|||||||
self.modified_at = datetime.now()
|
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
|
@dataclass
|
||||||
class Project:
|
class Project:
|
||||||
"""
|
"""
|
||||||
@@ -233,12 +623,85 @@ class Project:
|
|||||||
components: Dict[str, Component] = field(default_factory=dict)
|
components: Dict[str, Component] = field(default_factory=dict)
|
||||||
active_component: Optional[str] = None
|
active_component: Optional[str] = None
|
||||||
|
|
||||||
|
assemblies: Dict[str, Assembly] = field(default_factory=dict)
|
||||||
|
active_assembly: Optional[str] = None
|
||||||
|
|
||||||
kernel: OCGeometryKernel = field(default_factory=OCGeometryKernel)
|
kernel: OCGeometryKernel = field(default_factory=OCGeometryKernel)
|
||||||
|
|
||||||
created_at: datetime = field(default_factory=datetime.now)
|
created_at: datetime = field(default_factory=datetime.now)
|
||||||
modified_at: datetime = field(default_factory=datetime.now)
|
modified_at: datetime = field(default_factory=datetime.now)
|
||||||
file_path: Optional[str] = None
|
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:
|
def add_component(self, component: Optional[Component] = None) -> Component:
|
||||||
"""Add a component to the project."""
|
"""Add a component to the project."""
|
||||||
if component is None:
|
if component is None:
|
||||||
|
|||||||
@@ -1,11 +1,21 @@
|
|||||||
"""Rendering module."""
|
"""Rendering module."""
|
||||||
|
|
||||||
|
# OCC/OpenGL viewport renderers
|
||||||
from fluency.rendering.base import (
|
from fluency.rendering.base import (
|
||||||
Renderer,
|
Renderer,
|
||||||
RenderObject,
|
RenderObject,
|
||||||
RenderColor,
|
RenderColor,
|
||||||
)
|
)
|
||||||
from fluency.rendering.pygfx_renderer import PygfxRenderer, PygfxRenderObject
|
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__ = [
|
__all__ = [
|
||||||
"Renderer",
|
"Renderer",
|
||||||
@@ -13,4 +23,8 @@ __all__ = [
|
|||||||
"RenderColor",
|
"RenderColor",
|
||||||
"PygfxRenderer",
|
"PygfxRenderer",
|
||||||
"PygfxRenderObject",
|
"PygfxRenderObject",
|
||||||
|
"RenderBackend",
|
||||||
|
"RenderMaterial",
|
||||||
|
"RenderCamera",
|
||||||
|
"RenderSettings",
|
||||||
]
|
]
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -97,7 +97,7 @@ class Renderer(ABC):
|
|||||||
faces: np.ndarray,
|
faces: np.ndarray,
|
||||||
color: Tuple[float, float, float] = (0.2, 0.4, 0.8),
|
color: Tuple[float, float, float] = (0.2, 0.4, 0.8),
|
||||||
name: Optional[str] = None,
|
name: Optional[str] = None,
|
||||||
) -> RenderObject:
|
) -> str:
|
||||||
"""
|
"""
|
||||||
Add a mesh to the scene.
|
Add a mesh to the scene.
|
||||||
|
|
||||||
@@ -108,7 +108,7 @@ class Renderer(ABC):
|
|||||||
name: Optional name for the object
|
name: Optional name for the object
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
RenderObject representing the mesh
|
String ID of the mesh
|
||||||
"""
|
"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -120,7 +120,7 @@ class Renderer(ABC):
|
|||||||
color: Tuple[float, float, float] = (1.0, 1.0, 1.0),
|
color: Tuple[float, float, float] = (1.0, 1.0, 1.0),
|
||||||
line_width: float = 1.0,
|
line_width: float = 1.0,
|
||||||
name: Optional[str] = None,
|
name: Optional[str] = None,
|
||||||
) -> RenderObject:
|
) -> str:
|
||||||
"""
|
"""
|
||||||
Add a wireframe to the scene.
|
Add a wireframe to the scene.
|
||||||
|
|
||||||
@@ -132,7 +132,7 @@ class Renderer(ABC):
|
|||||||
name: Optional name for the object
|
name: Optional name for the object
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
RenderObject representing the wireframe
|
String ID of the wireframe
|
||||||
"""
|
"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -143,7 +143,7 @@ class Renderer(ABC):
|
|||||||
color: Tuple[float, float, float] = (1.0, 0.0, 0.0),
|
color: Tuple[float, float, float] = (1.0, 0.0, 0.0),
|
||||||
size: float = 5.0,
|
size: float = 5.0,
|
||||||
name: Optional[str] = None,
|
name: Optional[str] = None,
|
||||||
) -> RenderObject:
|
) -> str:
|
||||||
"""
|
"""
|
||||||
Add points to the scene.
|
Add points to the scene.
|
||||||
|
|
||||||
@@ -154,7 +154,7 @@ class Renderer(ABC):
|
|||||||
name: Optional name for the object
|
name: Optional name for the object
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
RenderObject representing the points
|
String ID of the points
|
||||||
"""
|
"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -166,7 +166,7 @@ class Renderer(ABC):
|
|||||||
color: Tuple[float, float, float] = (1.0, 1.0, 1.0),
|
color: Tuple[float, float, float] = (1.0, 1.0, 1.0),
|
||||||
line_width: float = 1.0,
|
line_width: float = 1.0,
|
||||||
name: Optional[str] = None,
|
name: Optional[str] = None,
|
||||||
) -> RenderObject:
|
) -> str:
|
||||||
"""
|
"""
|
||||||
Add line segments to the scene.
|
Add line segments to the scene.
|
||||||
|
|
||||||
@@ -178,7 +178,7 @@ class Renderer(ABC):
|
|||||||
name: Optional name for the object
|
name: Optional name for the object
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
RenderObject representing the lines
|
String ID of the lines
|
||||||
"""
|
"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -312,13 +312,13 @@ class Renderer(ABC):
|
|||||||
size: float = 100.0,
|
size: float = 100.0,
|
||||||
divisions: int = 10,
|
divisions: int = 10,
|
||||||
color: Tuple[float, float, float] = (0.3, 0.3, 0.3),
|
color: Tuple[float, float, float] = (0.3, 0.3, 0.3),
|
||||||
) -> RenderObject:
|
) -> str:
|
||||||
"""Add a reference grid."""
|
"""Add a reference grid. Returns the grid ID."""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def add_axes(self, size: float = 10.0, visible: bool = True) -> RenderObject:
|
def add_axes(self, size: float = 10.0, visible: bool = True) -> str:
|
||||||
"""Add coordinate axes."""
|
"""Add coordinate axes. Returns the axes ID."""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
|
|||||||
@@ -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])))
|
||||||
@@ -55,9 +55,17 @@ class PygfxRenderObject(RenderObject):
|
|||||||
geometry: Any = None
|
geometry: Any = None
|
||||||
material: Any = None
|
material: Any = None
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
def __init__(
|
||||||
if self.scene_node is not None:
|
self,
|
||||||
self._scene_node = self.scene_node
|
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):
|
class PygfxRenderer(Renderer):
|
||||||
@@ -84,17 +92,18 @@ class PygfxRenderer(Renderer):
|
|||||||
"""Initialize pygfx with Qt widget."""
|
"""Initialize pygfx with Qt widget."""
|
||||||
try:
|
try:
|
||||||
import pygfx as gfx
|
import pygfx as gfx
|
||||||
from wgpu.gui.qt import WgpuCanvas
|
from rendercanvas.qt import RenderWidget
|
||||||
from PySide6.QtWidgets import QVBoxLayout
|
from PySide6.QtWidgets import QVBoxLayout
|
||||||
|
|
||||||
self._canvas = WgpuCanvas(parent=parent_widget)
|
self._canvas = RenderWidget(parent=parent_widget)
|
||||||
self._renderer = gfx.renderers.WgpuRenderer(self._canvas)
|
self._renderer = gfx.renderers.WgpuRenderer(self._canvas)
|
||||||
self._scene = gfx.Scene()
|
self._scene = gfx.Scene()
|
||||||
|
|
||||||
self._camera = gfx.PerspectiveCamera(50, 16 / 9)
|
self._camera = gfx.PerspectiveCamera(50, 16 / 9)
|
||||||
self._camera.position.set(100, 100, 100)
|
self._camera.local.position = (100, 100, 100)
|
||||||
|
|
||||||
self._controller = gfx.OrbitController(self._camera, register_events=self._renderer)
|
self._controller = gfx.OrbitController(self._camera)
|
||||||
|
self._controller.register_events(self._renderer)
|
||||||
|
|
||||||
self._setup_lighting()
|
self._setup_lighting()
|
||||||
self._add_grid()
|
self._add_grid()
|
||||||
@@ -103,6 +112,8 @@ class PygfxRenderer(Renderer):
|
|||||||
layout.setContentsMargins(0, 0, 0, 0)
|
layout.setContentsMargins(0, 0, 0, 0)
|
||||||
layout.addWidget(self._canvas)
|
layout.addWidget(self._canvas)
|
||||||
|
|
||||||
|
self._canvas.request_draw(self._animate)
|
||||||
|
|
||||||
self._setup_picking()
|
self._setup_picking()
|
||||||
self._initialized = True
|
self._initialized = True
|
||||||
|
|
||||||
@@ -110,8 +121,16 @@ class PygfxRenderer(Renderer):
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Failed to initialize pygfx: {e}")
|
print(f"Failed to initialize pygfx: {e}")
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
traceback.print_exc()
|
||||||
return False
|
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:
|
def _setup_lighting(self) -> None:
|
||||||
"""Setup scene lighting."""
|
"""Setup scene lighting."""
|
||||||
import pygfx as gfx
|
import pygfx as gfx
|
||||||
@@ -120,11 +139,11 @@ class PygfxRenderer(Renderer):
|
|||||||
self._scene.add(ambient)
|
self._scene.add(ambient)
|
||||||
|
|
||||||
directional = gfx.DirectionalLight(intensity=1.0)
|
directional = gfx.DirectionalLight(intensity=1.0)
|
||||||
directional.position.set(100, 100, 100)
|
directional.local.position = (100, 100, 100)
|
||||||
self._scene.add(directional)
|
self._scene.add(directional)
|
||||||
|
|
||||||
fill = gfx.DirectionalLight(intensity=0.5)
|
fill = gfx.DirectionalLight(intensity=0.5)
|
||||||
fill.position.set(-100, 50, 50)
|
fill.local.position = (-100, 50, 50)
|
||||||
self._scene.add(fill)
|
self._scene.add(fill)
|
||||||
|
|
||||||
def _add_grid(self) -> None:
|
def _add_grid(self) -> None:
|
||||||
@@ -151,9 +170,10 @@ class PygfxRenderer(Renderer):
|
|||||||
faces: np.ndarray,
|
faces: np.ndarray,
|
||||||
color: Tuple[float, float, float] = (0.2, 0.4, 0.8),
|
color: Tuple[float, float, float] = (0.2, 0.4, 0.8),
|
||||||
name: Optional[str] = None,
|
name: Optional[str] = None,
|
||||||
) -> PygfxRenderObject:
|
) -> str:
|
||||||
"""Add a mesh to the scene."""
|
"""Add a mesh to the scene. Returns the mesh ID."""
|
||||||
import pygfx as gfx
|
import pygfx as gfx
|
||||||
|
import uuid
|
||||||
|
|
||||||
vertices = np.asarray(vertices, dtype=np.float32)
|
vertices = np.asarray(vertices, dtype=np.float32)
|
||||||
faces = np.asarray(faces, dtype=np.int32)
|
faces = np.asarray(faces, dtype=np.int32)
|
||||||
@@ -167,11 +187,12 @@ class PygfxRenderer(Renderer):
|
|||||||
mesh = gfx.Mesh(geometry, material)
|
mesh = gfx.Mesh(geometry, material)
|
||||||
self._scene.add(mesh)
|
self._scene.add(mesh)
|
||||||
|
|
||||||
obj = PygfxRenderObject(name=name, scene_node=mesh, geometry=geometry, material=material)
|
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)
|
obj.color = RenderColor(*color)
|
||||||
self._objects.append(obj)
|
self._objects.append(obj)
|
||||||
|
|
||||||
return obj
|
return mesh_id
|
||||||
|
|
||||||
def add_wireframe(
|
def add_wireframe(
|
||||||
self,
|
self,
|
||||||
@@ -180,9 +201,10 @@ class PygfxRenderer(Renderer):
|
|||||||
color: Tuple[float, float, float] = (1.0, 1.0, 1.0),
|
color: Tuple[float, float, float] = (1.0, 1.0, 1.0),
|
||||||
line_width: float = 1.0,
|
line_width: float = 1.0,
|
||||||
name: Optional[str] = None,
|
name: Optional[str] = None,
|
||||||
) -> PygfxRenderObject:
|
) -> str:
|
||||||
"""Add a wireframe to the scene."""
|
"""Add a wireframe to the scene. Returns the wireframe ID."""
|
||||||
import pygfx as gfx
|
import pygfx as gfx
|
||||||
|
import uuid
|
||||||
|
|
||||||
positions: List[List[float]] = []
|
positions: List[List[float]] = []
|
||||||
for edge in edges:
|
for edge in edges:
|
||||||
@@ -197,11 +219,14 @@ class PygfxRenderer(Renderer):
|
|||||||
lines = gfx.Line(geometry, material)
|
lines = gfx.Line(geometry, material)
|
||||||
self._scene.add(lines)
|
self._scene.add(lines)
|
||||||
|
|
||||||
obj = PygfxRenderObject(name=name, scene_node=lines, geometry=geometry, material=material)
|
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)
|
obj.color = RenderColor(*color)
|
||||||
self._objects.append(obj)
|
self._objects.append(obj)
|
||||||
|
|
||||||
return obj
|
return wireframe_id
|
||||||
|
|
||||||
def add_points(
|
def add_points(
|
||||||
self,
|
self,
|
||||||
@@ -209,9 +234,10 @@ class PygfxRenderer(Renderer):
|
|||||||
color: Tuple[float, float, float] = (1.0, 0.0, 0.0),
|
color: Tuple[float, float, float] = (1.0, 0.0, 0.0),
|
||||||
size: float = 5.0,
|
size: float = 5.0,
|
||||||
name: Optional[str] = None,
|
name: Optional[str] = None,
|
||||||
) -> PygfxRenderObject:
|
) -> str:
|
||||||
"""Add points to the scene."""
|
"""Add points to the scene. Returns the points ID."""
|
||||||
import pygfx as gfx
|
import pygfx as gfx
|
||||||
|
import uuid
|
||||||
|
|
||||||
points_arr = np.asarray(points, dtype=np.float32)
|
points_arr = np.asarray(points, dtype=np.float32)
|
||||||
|
|
||||||
@@ -221,13 +247,14 @@ class PygfxRenderer(Renderer):
|
|||||||
points_obj = gfx.Points(geometry, material)
|
points_obj = gfx.Points(geometry, material)
|
||||||
self._scene.add(points_obj)
|
self._scene.add(points_obj)
|
||||||
|
|
||||||
|
points_id = name or f"points_{uuid.uuid4().hex[:8]}"
|
||||||
obj = PygfxRenderObject(
|
obj = PygfxRenderObject(
|
||||||
name=name, scene_node=points_obj, geometry=geometry, material=material
|
name=points_id, scene_node=points_obj, geometry=geometry, material=material
|
||||||
)
|
)
|
||||||
obj.color = RenderColor(*color)
|
obj.color = RenderColor(*color)
|
||||||
self._objects.append(obj)
|
self._objects.append(obj)
|
||||||
|
|
||||||
return obj
|
return points_id
|
||||||
|
|
||||||
def add_lines(
|
def add_lines(
|
||||||
self,
|
self,
|
||||||
@@ -236,9 +263,10 @@ class PygfxRenderer(Renderer):
|
|||||||
color: Tuple[float, float, float] = (1.0, 1.0, 1.0),
|
color: Tuple[float, float, float] = (1.0, 1.0, 1.0),
|
||||||
line_width: float = 1.0,
|
line_width: float = 1.0,
|
||||||
name: Optional[str] = None,
|
name: Optional[str] = None,
|
||||||
) -> PygfxRenderObject:
|
) -> str:
|
||||||
"""Add line segments to the scene."""
|
"""Add line segments to the scene. Returns the lines ID."""
|
||||||
import pygfx as gfx
|
import pygfx as gfx
|
||||||
|
import uuid
|
||||||
|
|
||||||
positions = np.hstack([start_points, end_points]).flatten()
|
positions = np.hstack([start_points, end_points]).flatten()
|
||||||
positions = positions.reshape(-1, 3).astype(np.float32)
|
positions = positions.reshape(-1, 3).astype(np.float32)
|
||||||
@@ -249,11 +277,14 @@ class PygfxRenderer(Renderer):
|
|||||||
lines = gfx.Line(geometry, material)
|
lines = gfx.Line(geometry, material)
|
||||||
self._scene.add(lines)
|
self._scene.add(lines)
|
||||||
|
|
||||||
obj = PygfxRenderObject(name=name, scene_node=lines, geometry=geometry, material=material)
|
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)
|
obj.color = RenderColor(*color)
|
||||||
self._objects.append(obj)
|
self._objects.append(obj)
|
||||||
|
|
||||||
return obj
|
return lines_id
|
||||||
|
|
||||||
def remove_object(self, obj: RenderObject) -> bool:
|
def remove_object(self, obj: RenderObject) -> bool:
|
||||||
"""Remove an object from the scene."""
|
"""Remove an object from the scene."""
|
||||||
@@ -319,15 +350,14 @@ class PygfxRenderer(Renderer):
|
|||||||
up: Tuple[float, float, float] = (0, 0, 1),
|
up: Tuple[float, float, float] = (0, 0, 1),
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Set camera position and orientation."""
|
"""Set camera position and orientation."""
|
||||||
self._camera.position.set(*position)
|
self._camera.local.position = position
|
||||||
self._camera.look_at(*target)
|
self._camera.look_at(target)
|
||||||
self._camera.up.set(*up)
|
|
||||||
|
|
||||||
def get_camera_position(self) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
|
def get_camera_position(self) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
|
||||||
"""Get camera position, target, and up vector."""
|
"""Get camera position, target, and up vector."""
|
||||||
pos = np.array(self._camera.position.to_array())
|
pos = np.array(self._camera.local.position)
|
||||||
target = np.array([0, 0, 0])
|
target = np.array([0, 0, 0])
|
||||||
up = np.array(self._camera.up.to_array())
|
up = np.array(self._camera.local.up)
|
||||||
return pos, target, up
|
return pos, target, up
|
||||||
|
|
||||||
def fit_camera(self, padding: float = 1.1) -> None:
|
def fit_camera(self, padding: float = 1.1) -> None:
|
||||||
@@ -348,8 +378,8 @@ class PygfxRenderer(Renderer):
|
|||||||
center = (min_pos + max_pos) / 2
|
center = (min_pos + max_pos) / 2
|
||||||
size = np.linalg.norm(max_pos - min_pos) * padding
|
size = np.linalg.norm(max_pos - min_pos) * padding
|
||||||
|
|
||||||
self._camera.position.set(center[0] + size, center[1] + size, center[2] + size)
|
self._camera.local.position = (center[0] + size, center[1] + size, center[2] + size)
|
||||||
self._camera.look_at(*center)
|
self._camera.look_at(tuple(center))
|
||||||
|
|
||||||
def set_camera_perspective(
|
def set_camera_perspective(
|
||||||
self, fov: float = 50.0, near: float = 0.1, far: float = 10000.0
|
self, fov: float = 50.0, near: float = 0.1, far: float = 10000.0
|
||||||
@@ -371,7 +401,6 @@ class PygfxRenderer(Renderer):
|
|||||||
def render(self) -> None:
|
def render(self) -> None:
|
||||||
"""Trigger a render."""
|
"""Trigger a render."""
|
||||||
if self._initialized:
|
if self._initialized:
|
||||||
self._renderer.render(self._scene, self._camera)
|
|
||||||
self._canvas.request_draw()
|
self._canvas.request_draw()
|
||||||
|
|
||||||
def on_pick(self, callback: Callable[[Any], None]) -> None:
|
def on_pick(self, callback: Callable[[Any], None]) -> None:
|
||||||
@@ -392,8 +421,8 @@ class PygfxRenderer(Renderer):
|
|||||||
size: float = 100.0,
|
size: float = 100.0,
|
||||||
divisions: int = 10,
|
divisions: int = 10,
|
||||||
color: Tuple[float, float, float] = (0.3, 0.3, 0.3),
|
color: Tuple[float, float, float] = (0.3, 0.3, 0.3),
|
||||||
) -> PygfxRenderObject:
|
) -> str:
|
||||||
"""Add a reference grid."""
|
"""Add a reference grid. Returns the grid ID."""
|
||||||
import pygfx as gfx
|
import pygfx as gfx
|
||||||
|
|
||||||
grid = gfx.GridHelper(
|
grid = gfx.GridHelper(
|
||||||
@@ -402,10 +431,11 @@ class PygfxRenderer(Renderer):
|
|||||||
self._scene.add(grid)
|
self._scene.add(grid)
|
||||||
|
|
||||||
obj = PygfxRenderObject(name="grid", scene_node=grid)
|
obj = PygfxRenderObject(name="grid", scene_node=grid)
|
||||||
return obj
|
self._objects.append(obj)
|
||||||
|
return "grid"
|
||||||
|
|
||||||
def add_axes(self, size: float = 10.0, visible: bool = True) -> PygfxRenderObject:
|
def add_axes(self, size: float = 10.0, visible: bool = True) -> str:
|
||||||
"""Add coordinate axes."""
|
"""Add coordinate axes. Returns the axes ID."""
|
||||||
import pygfx as gfx
|
import pygfx as gfx
|
||||||
|
|
||||||
axes = gfx.AxesHelper(size=size)
|
axes = gfx.AxesHelper(size=size)
|
||||||
@@ -413,7 +443,8 @@ class PygfxRenderer(Renderer):
|
|||||||
self._scene.add(axes)
|
self._scene.add(axes)
|
||||||
|
|
||||||
obj = PygfxRenderObject(name="axes", scene_node=axes)
|
obj = PygfxRenderObject(name="axes", scene_node=axes)
|
||||||
return obj
|
self._objects.append(obj)
|
||||||
|
return "axes"
|
||||||
|
|
||||||
def get_screen_size(self) -> Tuple[int, int]:
|
def get_screen_size(self) -> Tuple[int, int]:
|
||||||
"""Get the screen size in pixels."""
|
"""Get the screen size in pixels."""
|
||||||
|
|||||||
@@ -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] = []
|
||||||
@@ -0,0 +1,497 @@
|
|||||||
|
"""Dialogs for Fluency CAD operations: extrude, revolve, offset, workplane orientation."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import math
|
||||||
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
|
from PySide6.QtCore import Qt, QPoint, QPointF
|
||||||
|
from PySide6.QtGui import QColor, QFont, QKeySequence
|
||||||
|
from PySide6.QtWidgets import (
|
||||||
|
QButtonGroup,
|
||||||
|
QCheckBox,
|
||||||
|
QComboBox,
|
||||||
|
QDialog,
|
||||||
|
QDialogButtonBox,
|
||||||
|
QDoubleSpinBox,
|
||||||
|
QFormLayout,
|
||||||
|
QFrame,
|
||||||
|
QGridLayout,
|
||||||
|
QHBoxLayout,
|
||||||
|
QLabel,
|
||||||
|
QLineEdit,
|
||||||
|
QPushButton,
|
||||||
|
QRadioButton,
|
||||||
|
QVBoxLayout,
|
||||||
|
QWidget,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
class ExtrudeDialog(QDialog):
|
||||||
|
"""Dialog for extrude options.
|
||||||
|
|
||||||
|
Carries an optional ``preview_callback`` that is invoked whenever the
|
||||||
|
user changes any option; the host uses it to render a live transparent
|
||||||
|
preview of the operation result in the 3D view. Passing *False* (or
|
||||||
|
*None*) to the callback tells the host to clear the preview.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.setWindowTitle("Extrude Options")
|
||||||
|
self.setMinimumWidth(320)
|
||||||
|
|
||||||
|
self._preview_callback = None
|
||||||
|
|
||||||
|
layout = QVBoxLayout(self)
|
||||||
|
|
||||||
|
length_layout = QHBoxLayout()
|
||||||
|
length_layout.addWidget(QLabel("Extrude Length (mm):"))
|
||||||
|
self.length_input = QDoubleSpinBox()
|
||||||
|
self.length_input.setDecimals(2)
|
||||||
|
self.length_input.setRange(-10000, 10000)
|
||||||
|
self.length_input.setValue(10)
|
||||||
|
length_layout.addWidget(self.length_input)
|
||||||
|
layout.addLayout(length_layout)
|
||||||
|
|
||||||
|
self.symmetric_checkbox = QCheckBox("Symmetric Extrude")
|
||||||
|
layout.addWidget(self.symmetric_checkbox)
|
||||||
|
|
||||||
|
self.invert_checkbox = QCheckBox("Invert Extrusion")
|
||||||
|
layout.addWidget(self.invert_checkbox)
|
||||||
|
|
||||||
|
self.cut_checkbox = QCheckBox("Perform Cut")
|
||||||
|
layout.addWidget(self.cut_checkbox)
|
||||||
|
|
||||||
|
self.union_checkbox = QCheckBox("Combine (Union)")
|
||||||
|
layout.addWidget(self.union_checkbox)
|
||||||
|
|
||||||
|
self.through_all_checkbox = QCheckBox("Through All (cut/union target)")
|
||||||
|
self.through_all_checkbox.setToolTip(
|
||||||
|
"Ignore the typed length and extrude far enough to fully pass "
|
||||||
|
"through the cut/union target body. Applies when Perform Cut or "
|
||||||
|
"Combine (Union) is checked."
|
||||||
|
)
|
||||||
|
layout.addWidget(self.through_all_checkbox)
|
||||||
|
|
||||||
|
self.rounded_checkbox = QCheckBox("Round Edges")
|
||||||
|
layout.addWidget(self.rounded_checkbox)
|
||||||
|
|
||||||
|
line = QFrame()
|
||||||
|
line.setFrameShape(QFrame.HLine)
|
||||||
|
line.setFrameShadow(QFrame.Sunken)
|
||||||
|
layout.addWidget(line)
|
||||||
|
|
||||||
|
button_layout = QHBoxLayout()
|
||||||
|
ok_button = QPushButton("OK")
|
||||||
|
ok_button.clicked.connect(self.accept)
|
||||||
|
cancel_button = QPushButton("Cancel")
|
||||||
|
cancel_button.clicked.connect(self.reject)
|
||||||
|
button_layout.addWidget(ok_button)
|
||||||
|
button_layout.addWidget(cancel_button)
|
||||||
|
layout.addLayout(button_layout)
|
||||||
|
|
||||||
|
# Live preview: recompute on every option change. Use a light-
|
||||||
|
# weight guard so we don't emit before the host has wired up the
|
||||||
|
# callback.
|
||||||
|
for w in (
|
||||||
|
self.length_input,
|
||||||
|
self.symmetric_checkbox,
|
||||||
|
self.invert_checkbox,
|
||||||
|
self.cut_checkbox,
|
||||||
|
self.union_checkbox,
|
||||||
|
self.through_all_checkbox,
|
||||||
|
self.rounded_checkbox,
|
||||||
|
):
|
||||||
|
# The spinbox has valueChanged; the checkboxes have stateChanged.
|
||||||
|
# Each must be wired in its own try/except so that a missing
|
||||||
|
# signal on one widget type doesn't skip the OTHER signal's
|
||||||
|
# connection (the prior single-try version accidentally
|
||||||
|
# left checkboxes un-connected when valueChanged raised first).
|
||||||
|
try:
|
||||||
|
w.valueChanged.connect(self._emit_preview)
|
||||||
|
except AttributeError:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
w.stateChanged.connect(self._emit_preview)
|
||||||
|
except AttributeError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def set_preview_callback(self, callback) -> None:
|
||||||
|
"""Install the live-preview callback (or *None* to disable)."""
|
||||||
|
self._preview_callback = callback
|
||||||
|
# Emit once so the initial state shows a preview right away.
|
||||||
|
self._emit_preview()
|
||||||
|
|
||||||
|
def _emit_preview(self, *args) -> None:
|
||||||
|
if self._preview_callback is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
self._preview_callback(self.get_values())
|
||||||
|
except Exception as exc: # preview must never break the dialog
|
||||||
|
logger.debug("extrude preview callback raised: %s", exc)
|
||||||
|
|
||||||
|
def hideEvent(self, event):
|
||||||
|
# Tell the host to clear the preview when the dialog goes away
|
||||||
|
# (accept, reject, or close). The host is responsible for the
|
||||||
|
# actual viewer cleanup.
|
||||||
|
if self._preview_callback is not None:
|
||||||
|
try:
|
||||||
|
self._preview_callback(None)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
super().hideEvent(event)
|
||||||
|
|
||||||
|
def get_values(self) -> Tuple[float, bool, bool, bool, bool, bool, bool]:
|
||||||
|
return (
|
||||||
|
self.length_input.value(),
|
||||||
|
self.symmetric_checkbox.isChecked(),
|
||||||
|
self.invert_checkbox.isChecked(),
|
||||||
|
self.cut_checkbox.isChecked(),
|
||||||
|
self.union_checkbox.isChecked(),
|
||||||
|
self.through_all_checkbox.isChecked(),
|
||||||
|
self.rounded_checkbox.isChecked(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class RevolveDialog(QDialog):
|
||||||
|
"""Dialog for revolve options."""
|
||||||
|
|
||||||
|
def __init__(self, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.setWindowTitle("Revolve Options")
|
||||||
|
self.setMinimumWidth(300)
|
||||||
|
|
||||||
|
layout = QVBoxLayout(self)
|
||||||
|
|
||||||
|
angle_layout = QHBoxLayout()
|
||||||
|
angle_layout.addWidget(QLabel("Revolve Angle (°):"))
|
||||||
|
self.angle_input = QDoubleSpinBox()
|
||||||
|
self.angle_input.setDecimals(1)
|
||||||
|
self.angle_input.setRange(1, 360)
|
||||||
|
self.angle_input.setValue(360)
|
||||||
|
self.angle_input.setSuffix("°")
|
||||||
|
angle_layout.addWidget(self.angle_input)
|
||||||
|
layout.addLayout(angle_layout)
|
||||||
|
|
||||||
|
line = QFrame()
|
||||||
|
line.setFrameShape(QFrame.HLine)
|
||||||
|
line.setFrameShadow(QFrame.Sunken)
|
||||||
|
layout.addWidget(line)
|
||||||
|
|
||||||
|
button_layout = QHBoxLayout()
|
||||||
|
ok_button = QPushButton("OK")
|
||||||
|
ok_button.clicked.connect(self.accept)
|
||||||
|
cancel_button = QPushButton("Cancel")
|
||||||
|
cancel_button.clicked.connect(self.reject)
|
||||||
|
button_layout.addWidget(ok_button)
|
||||||
|
button_layout.addWidget(cancel_button)
|
||||||
|
layout.addLayout(button_layout)
|
||||||
|
|
||||||
|
|
||||||
|
class OffsetDialog(QDialog):
|
||||||
|
"""Dialog for 2D sketch offset options.
|
||||||
|
|
||||||
|
Shows a number input for the offset distance with a live preview
|
||||||
|
callback so the sketch widget can render the offset result in real
|
||||||
|
time. On accept the caller retrieves ``get_values()`` → distance.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.setWindowTitle("Offset Sketch")
|
||||||
|
self.setMinimumWidth(300)
|
||||||
|
|
||||||
|
self._preview_callback = None
|
||||||
|
|
||||||
|
layout = QVBoxLayout(self)
|
||||||
|
|
||||||
|
dist_layout = QHBoxLayout()
|
||||||
|
dist_layout.addWidget(QLabel("Offset Distance (mm):"))
|
||||||
|
self.distance_input = QDoubleSpinBox()
|
||||||
|
self.distance_input.setDecimals(2)
|
||||||
|
self.distance_input.setRange(-10000, 10000)
|
||||||
|
self.distance_input.setValue(10.0)
|
||||||
|
self.distance_input.setSingleStep(0.5)
|
||||||
|
dist_layout.addWidget(self.distance_input)
|
||||||
|
layout.addLayout(dist_layout)
|
||||||
|
|
||||||
|
self.inward_checkbox = QCheckBox("Offset Inward (negative)")
|
||||||
|
self.inward_checkbox.setToolTip("Offset is applied inward instead of outward.")
|
||||||
|
layout.addWidget(self.inward_checkbox)
|
||||||
|
|
||||||
|
line = QFrame()
|
||||||
|
line.setFrameShape(QFrame.HLine)
|
||||||
|
line.setFrameShadow(QFrame.Sunken)
|
||||||
|
layout.addWidget(line)
|
||||||
|
|
||||||
|
button_layout = QHBoxLayout()
|
||||||
|
ok_button = QPushButton("OK")
|
||||||
|
ok_button.clicked.connect(self.accept)
|
||||||
|
cancel_button = QPushButton("Cancel")
|
||||||
|
cancel_button.clicked.connect(self.reject)
|
||||||
|
button_layout.addWidget(ok_button)
|
||||||
|
button_layout.addWidget(cancel_button)
|
||||||
|
layout.addLayout(button_layout)
|
||||||
|
|
||||||
|
# Live preview on every value change.
|
||||||
|
self.distance_input.valueChanged.connect(self._emit_preview)
|
||||||
|
self.inward_checkbox.stateChanged.connect(self._emit_preview)
|
||||||
|
|
||||||
|
def set_preview_callback(self, callback) -> None:
|
||||||
|
"""Install the live-preview callback (or *None* to disable)."""
|
||||||
|
self._preview_callback = callback
|
||||||
|
self._emit_preview()
|
||||||
|
|
||||||
|
def _emit_preview(self, *args) -> None:
|
||||||
|
if self._preview_callback is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
self._preview_callback(self.get_values())
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug("offset preview callback raised: %s", exc)
|
||||||
|
|
||||||
|
def hideEvent(self, event):
|
||||||
|
if self._preview_callback is not None:
|
||||||
|
try:
|
||||||
|
self._preview_callback(None)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
super().hideEvent(event)
|
||||||
|
|
||||||
|
def get_values(self) -> Tuple[float, bool]:
|
||||||
|
return (self.distance_input.value(), self.inward_checkbox.isChecked())
|
||||||
|
|
||||||
|
|
||||||
|
class WorkplaneOrientationDialog(QDialog):
|
||||||
|
"""Modal dialog to choose the orientation of a new workplane.
|
||||||
|
|
||||||
|
Offers XY, XZ, YZ, and custom angle presets. On accept, the caller
|
||||||
|
can retrieve the chosen orientation via :meth:`get_orientation`, which
|
||||||
|
returns (normal, x_dir) pair (both as 3-tuples).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.setWindowTitle("New Workplane Orientation")
|
||||||
|
self.setMinimumWidth(320)
|
||||||
|
|
||||||
|
self._normal: Tuple[float, float, float] = (0.0, 0.0, 1.0)
|
||||||
|
self._x_dir: Tuple[float, float, float] = (1.0, 0.0, 0.0)
|
||||||
|
|
||||||
|
# Optional callback for live 3D preview of the workplane.
|
||||||
|
# The host installs it via ``set_preview_callback``. The callback
|
||||||
|
# receives ``(normal, x_dir)`` or *None* to clear.
|
||||||
|
self._preview_callback = None
|
||||||
|
|
||||||
|
layout = QVBoxLayout(self)
|
||||||
|
|
||||||
|
# ── Orientation presets ──
|
||||||
|
lbl = QLabel("Choose orientation:")
|
||||||
|
layout.addWidget(lbl)
|
||||||
|
|
||||||
|
self._preset_group = QButtonGroup(self)
|
||||||
|
preset_layout = QGridLayout()
|
||||||
|
presets = [
|
||||||
|
("XY (Top)", (0, 0, 1), (1, 0, 0)),
|
||||||
|
("XZ (Front)", (0, 1, 0), (1, 0, 0)),
|
||||||
|
("YZ (Right)", (1, 0, 0), (0, 1, 0)),
|
||||||
|
("-XY (Bottom)", (0, 0, -1), (1, 0, 0)),
|
||||||
|
("-XZ (Back)", (0, -1, 0), (1, 0, 0)),
|
||||||
|
("-YZ (Left)", (-1, 0, 0), (0, 1, 0)),
|
||||||
|
]
|
||||||
|
for idx, (label, normal, x_dir) in enumerate(presets):
|
||||||
|
btn = QRadioButton(label)
|
||||||
|
btn.setChecked(idx == 0)
|
||||||
|
self._preset_group.addButton(btn, idx)
|
||||||
|
btn.normal = normal
|
||||||
|
btn.x_dir = x_dir
|
||||||
|
preset_layout.addWidget(btn, idx // 2, idx % 2)
|
||||||
|
layout.addLayout(preset_layout)
|
||||||
|
|
||||||
|
# ── Custom angle (offset from XY) ──
|
||||||
|
line = QFrame()
|
||||||
|
line.setFrameShape(QFrame.HLine)
|
||||||
|
line.setFrameShadow(QFrame.Sunken)
|
||||||
|
layout.addWidget(line)
|
||||||
|
|
||||||
|
self._custom_radio = QRadioButton("Custom (angle from XY):")
|
||||||
|
self._custom_radio.setChecked(False)
|
||||||
|
layout.addWidget(self._custom_radio)
|
||||||
|
|
||||||
|
angle_layout = QHBoxLayout()
|
||||||
|
angle_layout.addWidget(QLabel("Angle X (°):"))
|
||||||
|
self._angle_x = QDoubleSpinBox()
|
||||||
|
self._angle_x.setDecimals(1)
|
||||||
|
self._angle_x.setRange(-360, 360)
|
||||||
|
self._angle_x.setValue(0.0)
|
||||||
|
self._angle_x.setSuffix("°")
|
||||||
|
angle_layout.addWidget(self._angle_x)
|
||||||
|
angle_layout.addWidget(QLabel("Angle Y (°):"))
|
||||||
|
self._angle_y = QDoubleSpinBox()
|
||||||
|
self._angle_y.setDecimals(1)
|
||||||
|
self._angle_y.setRange(-360, 360)
|
||||||
|
self._angle_y.setValue(0.0)
|
||||||
|
self._angle_y.setSuffix("°")
|
||||||
|
angle_layout.addWidget(self._angle_y)
|
||||||
|
layout.addLayout(angle_layout)
|
||||||
|
|
||||||
|
self._name_label = QLabel("Workplane Name:")
|
||||||
|
layout.addWidget(self._name_label)
|
||||||
|
|
||||||
|
self._name_input = QLineEdit()
|
||||||
|
self._name_input.setText("Workplane 1")
|
||||||
|
layout.addWidget(self._name_input)
|
||||||
|
|
||||||
|
# ── Buttons ──
|
||||||
|
line2 = QFrame()
|
||||||
|
line2.setFrameShape(QFrame.HLine)
|
||||||
|
line2.setFrameShadow(QFrame.Sunken)
|
||||||
|
layout.addWidget(line2)
|
||||||
|
|
||||||
|
button_layout = QHBoxLayout()
|
||||||
|
ok_button = QPushButton("Create")
|
||||||
|
ok_button.clicked.connect(self._on_ok)
|
||||||
|
cancel_button = QPushButton("Cancel")
|
||||||
|
cancel_button.clicked.connect(self.reject)
|
||||||
|
button_layout.addWidget(ok_button)
|
||||||
|
button_layout.addWidget(cancel_button)
|
||||||
|
layout.addLayout(button_layout)
|
||||||
|
|
||||||
|
# Live preview: update the 3D view whenever the user changes
|
||||||
|
# the preset, custom radio toggle, or angle values.
|
||||||
|
self._preset_group.buttonClicked.connect(self._on_preset_changed)
|
||||||
|
self._custom_radio.toggled.connect(self._emit_preview)
|
||||||
|
self._angle_x.valueChanged.connect(self._emit_preview)
|
||||||
|
self._angle_y.valueChanged.connect(self._emit_preview)
|
||||||
|
|
||||||
|
def set_preview_callback(self, callback) -> None:
|
||||||
|
"""Install a callback for live 3D preview of the workplane orientation.
|
||||||
|
|
||||||
|
*callback* is called with ``(normal, x_dir)`` whenever the user
|
||||||
|
changes the selection, or with *None* when the dialog closes.
|
||||||
|
"""
|
||||||
|
self._preview_callback = callback
|
||||||
|
# Emit once so the initial state shows a preview right away.
|
||||||
|
self._emit_preview()
|
||||||
|
|
||||||
|
def _emit_preview(self, *args) -> None:
|
||||||
|
"""Call the preview callback with the current orientation, if installed."""
|
||||||
|
if self._preview_callback is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
normal, x_dir, _name = self.get_orientation()
|
||||||
|
self._preview_callback((normal, x_dir))
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug("workplane preview callback raised: %s", exc)
|
||||||
|
|
||||||
|
def hideEvent(self, event):
|
||||||
|
"""Clear the live preview when the dialog closes."""
|
||||||
|
if self._preview_callback is not None:
|
||||||
|
try:
|
||||||
|
self._preview_callback(None)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
super().hideEvent(event)
|
||||||
|
|
||||||
|
def _on_preset_changed(self, btn):
|
||||||
|
"""When a preset is selected, deselect the custom radio and emit preview."""
|
||||||
|
self._custom_radio.setChecked(False)
|
||||||
|
self._emit_preview()
|
||||||
|
|
||||||
|
def _on_ok(self):
|
||||||
|
"""Compute the final orientation and accept."""
|
||||||
|
import numpy as np
|
||||||
|
import math
|
||||||
|
|
||||||
|
if self._custom_radio.isChecked():
|
||||||
|
# Custom: start from XY normal and rotate by the two angles.
|
||||||
|
ax = math.radians(self._angle_x.value())
|
||||||
|
ay = math.radians(self._angle_y.value())
|
||||||
|
# Start from +Z normal, rotate around X then Y
|
||||||
|
n = np.array([0.0, 0.0, 1.0])
|
||||||
|
# Rotate around X
|
||||||
|
rx = np.array([
|
||||||
|
[1, 0, 0],
|
||||||
|
[0, math.cos(ax), -math.sin(ax)],
|
||||||
|
[0, math.sin(ax), math.cos(ax)],
|
||||||
|
])
|
||||||
|
n = rx @ n
|
||||||
|
# Rotate around Y
|
||||||
|
ry = np.array([
|
||||||
|
[math.cos(ay), 0, math.sin(ay)],
|
||||||
|
[0, 1, 0],
|
||||||
|
[-math.sin(ay), 0, math.cos(ay)],
|
||||||
|
])
|
||||||
|
n = ry @ n
|
||||||
|
n = n / np.linalg.norm(n)
|
||||||
|
# x_dir: cross product of normal with world Y, or world Z if normal ~ Y
|
||||||
|
world_y = np.array([0.0, 1.0, 0.0])
|
||||||
|
if abs(np.dot(n, world_y)) > 0.99:
|
||||||
|
world_y = np.array([0.0, 0.0, 1.0])
|
||||||
|
x = np.cross(world_y, n)
|
||||||
|
x_norm = np.linalg.norm(x)
|
||||||
|
if x_norm > 1e-9:
|
||||||
|
x = x / x_norm
|
||||||
|
else:
|
||||||
|
x = np.array([1.0, 0.0, 0.0])
|
||||||
|
self._normal = tuple(float(v) for v in n)
|
||||||
|
self._x_dir = tuple(float(v) for v in x)
|
||||||
|
|
||||||
|
else:
|
||||||
|
btn = self._preset_group.checkedButton()
|
||||||
|
if btn is not None:
|
||||||
|
self._normal = btn.normal
|
||||||
|
self._x_dir = btn.x_dir
|
||||||
|
self.accept()
|
||||||
|
|
||||||
|
def get_orientation(self) -> Tuple[Tuple[float, float, float], Tuple[float, float, float], str]:
|
||||||
|
"""Return (normal, x_dir, name) for the chosen workplane.
|
||||||
|
|
||||||
|
Computes the current selection from the UI state so it works
|
||||||
|
whether called before or after ``_on_ok``.
|
||||||
|
"""
|
||||||
|
import numpy as np
|
||||||
|
import math
|
||||||
|
|
||||||
|
if self._custom_radio.isChecked():
|
||||||
|
ax = math.radians(self._angle_x.value())
|
||||||
|
ay = math.radians(self._angle_y.value())
|
||||||
|
n = np.array([0.0, 0.0, 1.0])
|
||||||
|
rx = np.array([
|
||||||
|
[1, 0, 0],
|
||||||
|
[0, math.cos(ax), -math.sin(ax)],
|
||||||
|
[0, math.sin(ax), math.cos(ax)],
|
||||||
|
])
|
||||||
|
n = rx @ n
|
||||||
|
ry = np.array([
|
||||||
|
[math.cos(ay), 0, math.sin(ay)],
|
||||||
|
[0, 1, 0],
|
||||||
|
[-math.sin(ay), 0, math.cos(ay)],
|
||||||
|
])
|
||||||
|
n = ry @ n
|
||||||
|
n = n / np.linalg.norm(n)
|
||||||
|
world_y = np.array([0.0, 1.0, 0.0])
|
||||||
|
if abs(np.dot(n, world_y)) > 0.99:
|
||||||
|
world_y = np.array([0.0, 0.0, 1.0])
|
||||||
|
x = np.cross(world_y, n)
|
||||||
|
x_norm = np.linalg.norm(x)
|
||||||
|
if x_norm > 1e-9:
|
||||||
|
x = x / x_norm
|
||||||
|
else:
|
||||||
|
x = np.array([1.0, 0.0, 0.0])
|
||||||
|
return (
|
||||||
|
tuple(float(v) for v in n),
|
||||||
|
tuple(float(v) for v in x),
|
||||||
|
self._name_input.text().strip() or "Workplane",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
btn = self._preset_group.checkedButton()
|
||||||
|
if btn is not None:
|
||||||
|
return (btn.normal, btn.x_dir, self._name_input.text().strip() or "Workplane")
|
||||||
|
# Fallback: XY default.
|
||||||
|
return ((0.0, 0.0, 1.0), (1.0, 0.0, 0.0), self._name_input.text().strip() or "Workplane")
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,906 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
################################################################################
|
||||||
|
## Form generated from reading UI file 'gui.ui'
|
||||||
|
##
|
||||||
|
## Created by: Qt User Interface Compiler version 6.10.2
|
||||||
|
##
|
||||||
|
## WARNING! All changes made in this file will be lost when recompiling UI file!
|
||||||
|
################################################################################
|
||||||
|
|
||||||
|
from PySide6.QtCore import (QCoreApplication, QDate, QDateTime, QLocale,
|
||||||
|
QMetaObject, QObject, QPoint, QRect,
|
||||||
|
QSize, QTime, QUrl, Qt)
|
||||||
|
from PySide6.QtGui import (QAction, QBrush, QColor, QConicalGradient,
|
||||||
|
QCursor, QFont, QFontDatabase, QGradient,
|
||||||
|
QIcon, QImage, QKeySequence, QLinearGradient,
|
||||||
|
QPainter, QPalette, QPixmap, QRadialGradient,
|
||||||
|
QTransform)
|
||||||
|
from PySide6.QtWidgets import (QApplication, QFrame, QGridLayout, QGroupBox,
|
||||||
|
QHBoxLayout, QLabel, QListWidget, QListWidgetItem,
|
||||||
|
QMainWindow, QMenu, QMenuBar, QPushButton,
|
||||||
|
QSizePolicy, QSpinBox, QStatusBar, QTabWidget,
|
||||||
|
QTextEdit, QVBoxLayout, QWidget)
|
||||||
|
|
||||||
|
class Ui_fluencyCAD(object):
|
||||||
|
def setupUi(self, fluencyCAD):
|
||||||
|
if not fluencyCAD.objectName():
|
||||||
|
fluencyCAD.setObjectName(u"fluencyCAD")
|
||||||
|
fluencyCAD.resize(2359, 1285)
|
||||||
|
self.actionNew_Project = QAction(fluencyCAD)
|
||||||
|
self.actionNew_Project.setObjectName(u"actionNew_Project")
|
||||||
|
self.actionOpen_Project = QAction(fluencyCAD)
|
||||||
|
self.actionOpen_Project.setObjectName(u"actionOpen_Project")
|
||||||
|
self.actionSave_Project = QAction(fluencyCAD)
|
||||||
|
self.actionSave_Project.setObjectName(u"actionSave_Project")
|
||||||
|
self.actionSave_Project_As = QAction(fluencyCAD)
|
||||||
|
self.actionSave_Project_As.setObjectName(u"actionSave_Project_As")
|
||||||
|
self.actionImport_File = QAction(fluencyCAD)
|
||||||
|
self.actionImport_File.setObjectName(u"actionImport_File")
|
||||||
|
self.actionExport_Step = QAction(fluencyCAD)
|
||||||
|
self.actionExport_Step.setObjectName(u"actionExport_Step")
|
||||||
|
self.actionExport_Iges = QAction(fluencyCAD)
|
||||||
|
self.actionExport_Iges.setObjectName(u"actionExport_Iges")
|
||||||
|
self.actionExport_Stl = QAction(fluencyCAD)
|
||||||
|
self.actionExport_Stl.setObjectName(u"actionExport_Stl")
|
||||||
|
self.actionExit = QAction(fluencyCAD)
|
||||||
|
self.actionExit.setObjectName(u"actionExit")
|
||||||
|
self.centralwidget = QWidget(fluencyCAD)
|
||||||
|
self.centralwidget.setObjectName(u"centralwidget")
|
||||||
|
self.gridLayout = QGridLayout(self.centralwidget)
|
||||||
|
self.gridLayout.setObjectName(u"gridLayout")
|
||||||
|
self.InputTab = QTabWidget(self.centralwidget)
|
||||||
|
self.InputTab.setObjectName(u"InputTab")
|
||||||
|
sizePolicy = QSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
|
||||||
|
sizePolicy.setHorizontalStretch(0)
|
||||||
|
sizePolicy.setVerticalStretch(0)
|
||||||
|
sizePolicy.setHeightForWidth(self.InputTab.sizePolicy().hasHeightForWidth())
|
||||||
|
self.InputTab.setSizePolicy(sizePolicy)
|
||||||
|
self.sketch_tab = QWidget()
|
||||||
|
self.sketch_tab.setObjectName(u"sketch_tab")
|
||||||
|
self.verticalLayout_4 = QVBoxLayout(self.sketch_tab)
|
||||||
|
self.verticalLayout_4.setObjectName(u"verticalLayout_4")
|
||||||
|
self.InputTab.addTab(self.sketch_tab, "")
|
||||||
|
self.code_tab = QWidget()
|
||||||
|
self.code_tab.setObjectName(u"code_tab")
|
||||||
|
self.verticalLayout = QVBoxLayout(self.code_tab)
|
||||||
|
self.verticalLayout.setObjectName(u"verticalLayout")
|
||||||
|
self.textEdit = QTextEdit(self.code_tab)
|
||||||
|
self.textEdit.setObjectName(u"textEdit")
|
||||||
|
|
||||||
|
self.verticalLayout.addWidget(self.textEdit)
|
||||||
|
|
||||||
|
self.groupBox_7 = QGroupBox(self.code_tab)
|
||||||
|
self.groupBox_7.setObjectName(u"groupBox_7")
|
||||||
|
self.gridLayout_5 = QGridLayout(self.groupBox_7)
|
||||||
|
self.gridLayout_5.setObjectName(u"gridLayout_5")
|
||||||
|
self.pushButton_5 = QPushButton(self.groupBox_7)
|
||||||
|
self.pushButton_5.setObjectName(u"pushButton_5")
|
||||||
|
|
||||||
|
self.gridLayout_5.addWidget(self.pushButton_5, 2, 0, 1, 1)
|
||||||
|
|
||||||
|
self.pushButton_4 = QPushButton(self.groupBox_7)
|
||||||
|
self.pushButton_4.setObjectName(u"pushButton_4")
|
||||||
|
|
||||||
|
self.gridLayout_5.addWidget(self.pushButton_4, 2, 1, 1, 1)
|
||||||
|
|
||||||
|
self.pb_apply_code = QPushButton(self.groupBox_7)
|
||||||
|
self.pb_apply_code.setObjectName(u"pb_apply_code")
|
||||||
|
|
||||||
|
self.gridLayout_5.addWidget(self.pb_apply_code, 1, 0, 1, 1)
|
||||||
|
|
||||||
|
self.pushButton = QPushButton(self.groupBox_7)
|
||||||
|
self.pushButton.setObjectName(u"pushButton")
|
||||||
|
|
||||||
|
self.gridLayout_5.addWidget(self.pushButton, 1, 1, 1, 1)
|
||||||
|
|
||||||
|
|
||||||
|
self.verticalLayout.addWidget(self.groupBox_7)
|
||||||
|
|
||||||
|
self.InputTab.addTab(self.code_tab, "")
|
||||||
|
|
||||||
|
self.gridLayout.addWidget(self.InputTab, 0, 1, 12, 1)
|
||||||
|
|
||||||
|
self.groupBox_9 = QGroupBox(self.centralwidget)
|
||||||
|
self.groupBox_9.setObjectName(u"groupBox_9")
|
||||||
|
self.groupBox_9.setMaximumSize(QSize(200, 16777215))
|
||||||
|
self.gridLayout_7 = QGridLayout(self.groupBox_9)
|
||||||
|
self.gridLayout_7.setObjectName(u"gridLayout_7")
|
||||||
|
self.pb_origin_wp = QPushButton(self.groupBox_9)
|
||||||
|
self.pb_origin_wp.setObjectName(u"pb_origin_wp")
|
||||||
|
|
||||||
|
self.gridLayout_7.addWidget(self.pb_origin_wp, 0, 0, 1, 1)
|
||||||
|
|
||||||
|
self.pb_origin_face = QPushButton(self.groupBox_9)
|
||||||
|
self.pb_origin_face.setObjectName(u"pb_origin_face")
|
||||||
|
self.pb_origin_face.setCheckable(True)
|
||||||
|
|
||||||
|
self.gridLayout_7.addWidget(self.pb_origin_face, 0, 1, 1, 1)
|
||||||
|
|
||||||
|
self.pb_flip_face = QPushButton(self.groupBox_9)
|
||||||
|
self.pb_flip_face.setObjectName(u"pb_flip_face")
|
||||||
|
|
||||||
|
self.gridLayout_7.addWidget(self.pb_flip_face, 1, 0, 1, 1)
|
||||||
|
|
||||||
|
self.pb_underlay = QPushButton(self.groupBox_9)
|
||||||
|
self.pb_underlay.setObjectName(u"pb_underlay")
|
||||||
|
self.pb_underlay.setEnabled(False)
|
||||||
|
self.pb_underlay.setCheckable(True)
|
||||||
|
self.pb_underlay.setChecked(True)
|
||||||
|
|
||||||
|
self.gridLayout_7.addWidget(self.pb_underlay, 3, 0, 1, 1)
|
||||||
|
|
||||||
|
self.pb_clr_face = QPushButton(self.groupBox_9)
|
||||||
|
self.pb_clr_face.setObjectName(u"pb_clr_face")
|
||||||
|
self.pb_clr_face.setEnabled(False)
|
||||||
|
|
||||||
|
self.gridLayout_7.addWidget(self.pb_clr_face, 3, 1, 1, 1)
|
||||||
|
|
||||||
|
self.pb_to_sketch = QPushButton(self.groupBox_9)
|
||||||
|
self.pb_to_sketch.setObjectName(u"pb_to_sketch")
|
||||||
|
self.pb_to_sketch.setEnabled(False)
|
||||||
|
|
||||||
|
self.gridLayout_7.addWidget(self.pb_to_sketch, 4, 0, 1, 2)
|
||||||
|
|
||||||
|
self.pb_wp_new = QPushButton(self.groupBox_9)
|
||||||
|
self.pb_wp_new.setObjectName(u"pb_wp_new")
|
||||||
|
|
||||||
|
self.gridLayout_7.addWidget(self.pb_wp_new, 1, 1, 1, 1)
|
||||||
|
|
||||||
|
|
||||||
|
self.gridLayout.addWidget(self.groupBox_9, 0, 0, 1, 1)
|
||||||
|
|
||||||
|
self.assembly_box = QGroupBox(self.centralwidget)
|
||||||
|
self.assembly_box.setObjectName(u"assembly_box")
|
||||||
|
self.assembly_box.setMinimumSize(QSize(0, 50))
|
||||||
|
|
||||||
|
self.gridLayout.addWidget(self.assembly_box, 13, 1, 1, 2)
|
||||||
|
|
||||||
|
self.joint_tools = QGroupBox(self.centralwidget)
|
||||||
|
self.joint_tools.setObjectName(u"joint_tools")
|
||||||
|
self.joint_tools.setMinimumSize(QSize(0, 50))
|
||||||
|
self.gridLayout_10 = QGridLayout(self.joint_tools)
|
||||||
|
self.gridLayout_10.setObjectName(u"gridLayout_10")
|
||||||
|
self.pb_remove_connector = QPushButton(self.joint_tools)
|
||||||
|
self.pb_remove_connector.setObjectName(u"pb_remove_connector")
|
||||||
|
self.pb_remove_connector.setMinimumSize(QSize(50, 50))
|
||||||
|
self.pb_remove_connector.setMaximumSize(QSize(50, 50))
|
||||||
|
|
||||||
|
self.gridLayout_10.addWidget(self.pb_remove_connector, 0, 2, 1, 1)
|
||||||
|
|
||||||
|
self.pb_add_connector = QPushButton(self.joint_tools)
|
||||||
|
self.pb_add_connector.setObjectName(u"pb_add_connector")
|
||||||
|
self.pb_add_connector.setMinimumSize(QSize(50, 50))
|
||||||
|
self.pb_add_connector.setMaximumSize(QSize(50, 50))
|
||||||
|
|
||||||
|
self.gridLayout_10.addWidget(self.pb_add_connector, 0, 1, 1, 1)
|
||||||
|
|
||||||
|
self.pb_add_connector_2 = QPushButton(self.joint_tools)
|
||||||
|
self.pb_add_connector_2.setObjectName(u"pb_add_connector_2")
|
||||||
|
self.pb_add_connector_2.setMinimumSize(QSize(50, 50))
|
||||||
|
self.pb_add_connector_2.setMaximumSize(QSize(50, 50))
|
||||||
|
|
||||||
|
self.gridLayout_10.addWidget(self.pb_add_connector_2, 1, 1, 1, 1)
|
||||||
|
|
||||||
|
self.pb_add_connector_3 = QPushButton(self.joint_tools)
|
||||||
|
self.pb_add_connector_3.setObjectName(u"pb_add_connector_3")
|
||||||
|
self.pb_add_connector_3.setMinimumSize(QSize(50, 50))
|
||||||
|
self.pb_add_connector_3.setMaximumSize(QSize(50, 50))
|
||||||
|
|
||||||
|
self.gridLayout_10.addWidget(self.pb_add_connector_3, 1, 2, 1, 1)
|
||||||
|
|
||||||
|
|
||||||
|
self.gridLayout.addWidget(self.joint_tools, 12, 3, 2, 1)
|
||||||
|
|
||||||
|
self.gl_box = QGroupBox(self.centralwidget)
|
||||||
|
self.gl_box.setObjectName(u"gl_box")
|
||||||
|
sizePolicy1 = QSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
|
||||||
|
sizePolicy1.setHorizontalStretch(0)
|
||||||
|
sizePolicy1.setVerticalStretch(4)
|
||||||
|
sizePolicy1.setHeightForWidth(self.gl_box.sizePolicy().hasHeightForWidth())
|
||||||
|
self.gl_box.setSizePolicy(sizePolicy1)
|
||||||
|
font = QFont()
|
||||||
|
font.setPointSize(12)
|
||||||
|
self.gl_box.setFont(font)
|
||||||
|
self.horizontalLayout_4 = QHBoxLayout(self.gl_box)
|
||||||
|
#ifndef Q_OS_MAC
|
||||||
|
self.horizontalLayout_4.setSpacing(-1)
|
||||||
|
#endif
|
||||||
|
self.horizontalLayout_4.setObjectName(u"horizontalLayout_4")
|
||||||
|
self.horizontalLayout_4.setContentsMargins(12, -1, -1, -1)
|
||||||
|
|
||||||
|
self.gridLayout.addWidget(self.gl_box, 0, 2, 12, 1)
|
||||||
|
|
||||||
|
self.groupBox_11 = QGroupBox(self.centralwidget)
|
||||||
|
self.groupBox_11.setObjectName(u"groupBox_11")
|
||||||
|
sizePolicy2 = QSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Expanding)
|
||||||
|
sizePolicy2.setHorizontalStretch(0)
|
||||||
|
sizePolicy2.setVerticalStretch(0)
|
||||||
|
sizePolicy2.setHeightForWidth(self.groupBox_11.sizePolicy().hasHeightForWidth())
|
||||||
|
self.groupBox_11.setSizePolicy(sizePolicy2)
|
||||||
|
self.groupBox_11.setMaximumSize(QSize(200, 16777215))
|
||||||
|
self.verticalLayout_7 = QVBoxLayout(self.groupBox_11)
|
||||||
|
self.verticalLayout_7.setObjectName(u"verticalLayout_7")
|
||||||
|
self.verticalLayout_7.setContentsMargins(5, 5, 5, 5)
|
||||||
|
self.sketch_list = QListWidget(self.groupBox_11)
|
||||||
|
self.sketch_list.setObjectName(u"sketch_list")
|
||||||
|
sizePolicy3 = QSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
|
||||||
|
sizePolicy3.setHorizontalStretch(0)
|
||||||
|
sizePolicy3.setVerticalStretch(0)
|
||||||
|
sizePolicy3.setHeightForWidth(self.sketch_list.sizePolicy().hasHeightForWidth())
|
||||||
|
self.sketch_list.setSizePolicy(sizePolicy3)
|
||||||
|
self.sketch_list.setSelectionRectVisible(True)
|
||||||
|
|
||||||
|
self.verticalLayout_7.addWidget(self.sketch_list)
|
||||||
|
|
||||||
|
self.groupBox_6 = QGroupBox(self.groupBox_11)
|
||||||
|
self.groupBox_6.setObjectName(u"groupBox_6")
|
||||||
|
sizePolicy4 = QSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Preferred)
|
||||||
|
sizePolicy4.setHorizontalStretch(0)
|
||||||
|
sizePolicy4.setVerticalStretch(0)
|
||||||
|
sizePolicy4.setHeightForWidth(self.groupBox_6.sizePolicy().hasHeightForWidth())
|
||||||
|
self.groupBox_6.setSizePolicy(sizePolicy4)
|
||||||
|
self.gridLayout_6 = QGridLayout(self.groupBox_6)
|
||||||
|
self.gridLayout_6.setObjectName(u"gridLayout_6")
|
||||||
|
self.gridLayout_6.setContentsMargins(2, 2, 2, 2)
|
||||||
|
self.pb_edt_sktch = QPushButton(self.groupBox_6)
|
||||||
|
self.pb_edt_sktch.setObjectName(u"pb_edt_sktch")
|
||||||
|
|
||||||
|
self.gridLayout_6.addWidget(self.pb_edt_sktch, 1, 1, 1, 1)
|
||||||
|
|
||||||
|
self.pb_nw_sktch = QPushButton(self.groupBox_6)
|
||||||
|
self.pb_nw_sktch.setObjectName(u"pb_nw_sktch")
|
||||||
|
|
||||||
|
self.gridLayout_6.addWidget(self.pb_nw_sktch, 1, 0, 1, 1)
|
||||||
|
|
||||||
|
self.pb_del_sketch = QPushButton(self.groupBox_6)
|
||||||
|
self.pb_del_sketch.setObjectName(u"pb_del_sketch")
|
||||||
|
|
||||||
|
self.gridLayout_6.addWidget(self.pb_del_sketch, 1, 2, 1, 1)
|
||||||
|
|
||||||
|
|
||||||
|
self.verticalLayout_7.addWidget(self.groupBox_6)
|
||||||
|
|
||||||
|
|
||||||
|
self.gridLayout.addWidget(self.groupBox_11, 6, 0, 6, 1)
|
||||||
|
|
||||||
|
self.assembly_tools = QGroupBox(self.centralwidget)
|
||||||
|
self.assembly_tools.setObjectName(u"assembly_tools")
|
||||||
|
self.assembly_tools.setMinimumSize(QSize(0, 50))
|
||||||
|
self.gridLayout_12 = QGridLayout(self.assembly_tools)
|
||||||
|
self.gridLayout_12.setObjectName(u"gridLayout_12")
|
||||||
|
self.pb_compo_to_assembly = QPushButton(self.assembly_tools)
|
||||||
|
self.pb_compo_to_assembly.setObjectName(u"pb_compo_to_assembly")
|
||||||
|
self.pb_compo_to_assembly.setMinimumSize(QSize(50, 50))
|
||||||
|
self.pb_compo_to_assembly.setMaximumSize(QSize(50, 50))
|
||||||
|
|
||||||
|
self.gridLayout_12.addWidget(self.pb_compo_to_assembly, 0, 0, 1, 1)
|
||||||
|
|
||||||
|
self.pb_remove_compo_from_assembly = QPushButton(self.assembly_tools)
|
||||||
|
self.pb_remove_compo_from_assembly.setObjectName(u"pb_remove_compo_from_assembly")
|
||||||
|
self.pb_remove_compo_from_assembly.setEnabled(True)
|
||||||
|
sizePolicy4.setHeightForWidth(self.pb_remove_compo_from_assembly.sizePolicy().hasHeightForWidth())
|
||||||
|
self.pb_remove_compo_from_assembly.setSizePolicy(sizePolicy4)
|
||||||
|
self.pb_remove_compo_from_assembly.setMinimumSize(QSize(50, 50))
|
||||||
|
self.pb_remove_compo_from_assembly.setMaximumSize(QSize(50, 50))
|
||||||
|
self.pb_remove_compo_from_assembly.setLayoutDirection(Qt.LeftToRight)
|
||||||
|
|
||||||
|
self.gridLayout_12.addWidget(self.pb_remove_compo_from_assembly, 0, 1, 1, 1)
|
||||||
|
|
||||||
|
|
||||||
|
self.gridLayout.addWidget(self.assembly_tools, 13, 0, 1, 1)
|
||||||
|
|
||||||
|
self.compo_tool_box = QGroupBox(self.centralwidget)
|
||||||
|
self.compo_tool_box.setObjectName(u"compo_tool_box")
|
||||||
|
self.compo_tool_box.setMinimumSize(QSize(0, 50))
|
||||||
|
self.gridLayout_9 = QGridLayout(self.compo_tool_box)
|
||||||
|
self.gridLayout_9.setObjectName(u"gridLayout_9")
|
||||||
|
self.pb_new_compo = QPushButton(self.compo_tool_box)
|
||||||
|
self.pb_new_compo.setObjectName(u"pb_new_compo")
|
||||||
|
self.pb_new_compo.setMinimumSize(QSize(50, 50))
|
||||||
|
self.pb_new_compo.setMaximumSize(QSize(50, 50))
|
||||||
|
|
||||||
|
self.gridLayout_9.addWidget(self.pb_new_compo, 0, 0, 1, 1)
|
||||||
|
|
||||||
|
self.pb_del_compo = QPushButton(self.compo_tool_box)
|
||||||
|
self.pb_del_compo.setObjectName(u"pb_del_compo")
|
||||||
|
self.pb_del_compo.setEnabled(True)
|
||||||
|
sizePolicy4.setHeightForWidth(self.pb_del_compo.sizePolicy().hasHeightForWidth())
|
||||||
|
self.pb_del_compo.setSizePolicy(sizePolicy4)
|
||||||
|
self.pb_del_compo.setMinimumSize(QSize(50, 50))
|
||||||
|
self.pb_del_compo.setMaximumSize(QSize(50, 50))
|
||||||
|
self.pb_del_compo.setLayoutDirection(Qt.LeftToRight)
|
||||||
|
|
||||||
|
self.gridLayout_9.addWidget(self.pb_del_compo, 0, 1, 1, 1)
|
||||||
|
|
||||||
|
|
||||||
|
self.gridLayout.addWidget(self.compo_tool_box, 12, 0, 1, 1)
|
||||||
|
|
||||||
|
self.compo_box = QGroupBox(self.centralwidget)
|
||||||
|
self.compo_box.setObjectName(u"compo_box")
|
||||||
|
self.compo_box.setMinimumSize(QSize(0, 50))
|
||||||
|
|
||||||
|
self.gridLayout.addWidget(self.compo_box, 12, 1, 1, 2)
|
||||||
|
|
||||||
|
self.groupBox_12 = QGroupBox(self.centralwidget)
|
||||||
|
self.groupBox_12.setObjectName(u"groupBox_12")
|
||||||
|
sizePolicy2.setHeightForWidth(self.groupBox_12.sizePolicy().hasHeightForWidth())
|
||||||
|
self.groupBox_12.setSizePolicy(sizePolicy2)
|
||||||
|
self.groupBox_12.setMaximumSize(QSize(200, 16777215))
|
||||||
|
self.verticalLayout_8 = QVBoxLayout(self.groupBox_12)
|
||||||
|
self.verticalLayout_8.setObjectName(u"verticalLayout_8")
|
||||||
|
self.verticalLayout_8.setContentsMargins(5, 5, 5, 5)
|
||||||
|
self.connection_list = QListWidget(self.groupBox_12)
|
||||||
|
self.connection_list.setObjectName(u"connection_list")
|
||||||
|
self.connection_list.setSelectionRectVisible(True)
|
||||||
|
|
||||||
|
self.verticalLayout_8.addWidget(self.connection_list)
|
||||||
|
|
||||||
|
self.groupBox_13 = QGroupBox(self.groupBox_12)
|
||||||
|
self.groupBox_13.setObjectName(u"groupBox_13")
|
||||||
|
sizePolicy4.setHeightForWidth(self.groupBox_13.sizePolicy().hasHeightForWidth())
|
||||||
|
self.groupBox_13.setSizePolicy(sizePolicy4)
|
||||||
|
self.groupBox_13.setMaximumSize(QSize(200, 16777215))
|
||||||
|
self.gridLayout_13 = QGridLayout(self.groupBox_13)
|
||||||
|
self.gridLayout_13.setObjectName(u"gridLayout_13")
|
||||||
|
self.gridLayout_13.setContentsMargins(2, 2, 2, 2)
|
||||||
|
self.pb_del_connection = QPushButton(self.groupBox_13)
|
||||||
|
self.pb_del_connection.setObjectName(u"pb_del_connection")
|
||||||
|
|
||||||
|
self.gridLayout_13.addWidget(self.pb_del_connection, 0, 2, 1, 1)
|
||||||
|
|
||||||
|
self.pb_update_connection = QPushButton(self.groupBox_13)
|
||||||
|
self.pb_update_connection.setObjectName(u"pb_update_connection")
|
||||||
|
|
||||||
|
self.gridLayout_13.addWidget(self.pb_update_connection, 0, 0, 1, 1)
|
||||||
|
|
||||||
|
self.pb_edt_sktch_4 = QPushButton(self.groupBox_13)
|
||||||
|
self.pb_edt_sktch_4.setObjectName(u"pb_edt_sktch_4")
|
||||||
|
|
||||||
|
self.gridLayout_13.addWidget(self.pb_edt_sktch_4, 0, 1, 1, 1)
|
||||||
|
|
||||||
|
|
||||||
|
self.verticalLayout_8.addWidget(self.groupBox_13)
|
||||||
|
|
||||||
|
|
||||||
|
self.gridLayout.addWidget(self.groupBox_12, 6, 3, 6, 1)
|
||||||
|
|
||||||
|
self.groupBox_10 = QGroupBox(self.centralwidget)
|
||||||
|
self.groupBox_10.setObjectName(u"groupBox_10")
|
||||||
|
sizePolicy2.setHeightForWidth(self.groupBox_10.sizePolicy().hasHeightForWidth())
|
||||||
|
self.groupBox_10.setSizePolicy(sizePolicy2)
|
||||||
|
self.groupBox_10.setMaximumSize(QSize(200, 16777215))
|
||||||
|
self.verticalLayout_6 = QVBoxLayout(self.groupBox_10)
|
||||||
|
self.verticalLayout_6.setObjectName(u"verticalLayout_6")
|
||||||
|
self.verticalLayout_6.setContentsMargins(5, 5, 5, 5)
|
||||||
|
self.body_list = QListWidget(self.groupBox_10)
|
||||||
|
self.body_list.setObjectName(u"body_list")
|
||||||
|
self.body_list.setSelectionRectVisible(True)
|
||||||
|
|
||||||
|
self.verticalLayout_6.addWidget(self.body_list)
|
||||||
|
|
||||||
|
self.groupBox_8 = QGroupBox(self.groupBox_10)
|
||||||
|
self.groupBox_8.setObjectName(u"groupBox_8")
|
||||||
|
sizePolicy4.setHeightForWidth(self.groupBox_8.sizePolicy().hasHeightForWidth())
|
||||||
|
self.groupBox_8.setSizePolicy(sizePolicy4)
|
||||||
|
self.groupBox_8.setMaximumSize(QSize(200, 16777215))
|
||||||
|
self.gridLayout_8 = QGridLayout(self.groupBox_8)
|
||||||
|
self.gridLayout_8.setObjectName(u"gridLayout_8")
|
||||||
|
self.gridLayout_8.setContentsMargins(2, 2, 2, 2)
|
||||||
|
self.pb_del_body = QPushButton(self.groupBox_8)
|
||||||
|
self.pb_del_body.setObjectName(u"pb_del_body")
|
||||||
|
|
||||||
|
self.gridLayout_8.addWidget(self.pb_del_body, 0, 2, 1, 1)
|
||||||
|
|
||||||
|
self.pb_update_body = QPushButton(self.groupBox_8)
|
||||||
|
self.pb_update_body.setObjectName(u"pb_update_body")
|
||||||
|
|
||||||
|
self.gridLayout_8.addWidget(self.pb_update_body, 0, 0, 1, 1)
|
||||||
|
|
||||||
|
self.pb_edt_sktch_3 = QPushButton(self.groupBox_8)
|
||||||
|
self.pb_edt_sktch_3.setObjectName(u"pb_edt_sktch_3")
|
||||||
|
|
||||||
|
self.gridLayout_8.addWidget(self.pb_edt_sktch_3, 0, 1, 1, 1)
|
||||||
|
|
||||||
|
|
||||||
|
self.verticalLayout_6.addWidget(self.groupBox_8)
|
||||||
|
|
||||||
|
|
||||||
|
self.gridLayout.addWidget(self.groupBox_10, 3, 3, 3, 1)
|
||||||
|
|
||||||
|
self.groupBox_2 = QGroupBox(self.centralwidget)
|
||||||
|
self.groupBox_2.setObjectName(u"groupBox_2")
|
||||||
|
sizePolicy4.setHeightForWidth(self.groupBox_2.sizePolicy().hasHeightForWidth())
|
||||||
|
self.groupBox_2.setSizePolicy(sizePolicy4)
|
||||||
|
self.groupBox_2.setMaximumSize(QSize(200, 16777215))
|
||||||
|
self.gridLayout_2 = QGridLayout(self.groupBox_2)
|
||||||
|
self.gridLayout_2.setObjectName(u"gridLayout_2")
|
||||||
|
self.gridLayout_2.setContentsMargins(10, -1, -1, -1)
|
||||||
|
self.pb_arc_tool = QPushButton(self.groupBox_2)
|
||||||
|
self.pb_arc_tool.setObjectName(u"pb_arc_tool")
|
||||||
|
self.pb_arc_tool.setCheckable(True)
|
||||||
|
|
||||||
|
self.gridLayout_2.addWidget(self.pb_arc_tool, 2, 0, 1, 1)
|
||||||
|
|
||||||
|
self.pb_rectool = QPushButton(self.groupBox_2)
|
||||||
|
self.pb_rectool.setObjectName(u"pb_rectool")
|
||||||
|
self.pb_rectool.setCheckable(True)
|
||||||
|
self.pb_rectool.setAutoExclusive(False)
|
||||||
|
|
||||||
|
self.gridLayout_2.addWidget(self.pb_rectool, 0, 1, 1, 1)
|
||||||
|
|
||||||
|
self.pb_circtool = QPushButton(self.groupBox_2)
|
||||||
|
self.pb_circtool.setObjectName(u"pb_circtool")
|
||||||
|
self.pb_circtool.setCheckable(True)
|
||||||
|
self.pb_circtool.setAutoExclusive(False)
|
||||||
|
|
||||||
|
self.gridLayout_2.addWidget(self.pb_circtool, 1, 0, 1, 1, Qt.AlignTop)
|
||||||
|
|
||||||
|
self.pb_enable_construct = QPushButton(self.groupBox_2)
|
||||||
|
self.pb_enable_construct.setObjectName(u"pb_enable_construct")
|
||||||
|
self.pb_enable_construct.setCheckable(True)
|
||||||
|
|
||||||
|
self.gridLayout_2.addWidget(self.pb_enable_construct, 4, 0, 1, 1)
|
||||||
|
|
||||||
|
self.pb_enable_snap = QPushButton(self.groupBox_2)
|
||||||
|
self.pb_enable_snap.setObjectName(u"pb_enable_snap")
|
||||||
|
self.pb_enable_snap.setIconSize(QSize(13, 16))
|
||||||
|
self.pb_enable_snap.setCheckable(True)
|
||||||
|
self.pb_enable_snap.setChecked(True)
|
||||||
|
|
||||||
|
self.gridLayout_2.addWidget(self.pb_enable_snap, 4, 1, 1, 1)
|
||||||
|
|
||||||
|
self.pb_linetool = QPushButton(self.groupBox_2)
|
||||||
|
self.pb_linetool.setObjectName(u"pb_linetool")
|
||||||
|
self.pb_linetool.setCheckable(True)
|
||||||
|
self.pb_linetool.setAutoExclusive(False)
|
||||||
|
|
||||||
|
self.gridLayout_2.addWidget(self.pb_linetool, 0, 0, 1, 1)
|
||||||
|
|
||||||
|
self.pb_slotool = QPushButton(self.groupBox_2)
|
||||||
|
self.pb_slotool.setObjectName(u"pb_slotool")
|
||||||
|
self.pb_slotool.setCheckable(True)
|
||||||
|
self.pb_slotool.setAutoExclusive(False)
|
||||||
|
|
||||||
|
self.gridLayout_2.addWidget(self.pb_slotool, 1, 1, 1, 1, Qt.AlignTop)
|
||||||
|
|
||||||
|
self.line = QFrame(self.groupBox_2)
|
||||||
|
self.line.setObjectName(u"line")
|
||||||
|
self.line.setFrameShape(QFrame.Shape.HLine)
|
||||||
|
self.line.setFrameShadow(QFrame.Shadow.Sunken)
|
||||||
|
|
||||||
|
self.gridLayout_2.addWidget(self.line, 3, 0, 1, 2)
|
||||||
|
|
||||||
|
self.pb_offset_tool = QPushButton(self.groupBox_2)
|
||||||
|
self.pb_offset_tool.setObjectName(u"pb_offset_tool")
|
||||||
|
|
||||||
|
self.gridLayout_2.addWidget(self.pb_offset_tool, 2, 1, 1, 1)
|
||||||
|
|
||||||
|
|
||||||
|
self.gridLayout.addWidget(self.groupBox_2, 1, 0, 1, 1)
|
||||||
|
|
||||||
|
self.groupBox_3 = QGroupBox(self.centralwidget)
|
||||||
|
self.groupBox_3.setObjectName(u"groupBox_3")
|
||||||
|
sizePolicy4.setHeightForWidth(self.groupBox_3.sizePolicy().hasHeightForWidth())
|
||||||
|
self.groupBox_3.setSizePolicy(sizePolicy4)
|
||||||
|
self.groupBox_3.setMaximumSize(QSize(200, 16777213))
|
||||||
|
self.gridLayout_4 = QGridLayout(self.groupBox_3)
|
||||||
|
self.gridLayout_4.setObjectName(u"gridLayout_4")
|
||||||
|
self.pb_con_ptpt = QPushButton(self.groupBox_3)
|
||||||
|
self.pb_con_ptpt.setObjectName(u"pb_con_ptpt")
|
||||||
|
self.pb_con_ptpt.setCheckable(True)
|
||||||
|
self.pb_con_ptpt.setAutoExclusive(False)
|
||||||
|
|
||||||
|
self.gridLayout_4.addWidget(self.pb_con_ptpt, 1, 0, 1, 1)
|
||||||
|
|
||||||
|
self.pb_con_vert = QPushButton(self.groupBox_3)
|
||||||
|
self.pb_con_vert.setObjectName(u"pb_con_vert")
|
||||||
|
self.pb_con_vert.setCheckable(True)
|
||||||
|
self.pb_con_vert.setAutoExclusive(False)
|
||||||
|
|
||||||
|
self.gridLayout_4.addWidget(self.pb_con_vert, 3, 1, 1, 1)
|
||||||
|
|
||||||
|
self.pb_con_sym = QPushButton(self.groupBox_3)
|
||||||
|
self.pb_con_sym.setObjectName(u"pb_con_sym")
|
||||||
|
self.pb_con_sym.setCheckable(True)
|
||||||
|
self.pb_con_sym.setAutoExclusive(False)
|
||||||
|
|
||||||
|
self.gridLayout_4.addWidget(self.pb_con_sym, 4, 1, 1, 1)
|
||||||
|
|
||||||
|
self.pb_con_mid = QPushButton(self.groupBox_3)
|
||||||
|
self.pb_con_mid.setObjectName(u"pb_con_mid")
|
||||||
|
self.pb_con_mid.setCheckable(True)
|
||||||
|
self.pb_con_mid.setAutoExclusive(False)
|
||||||
|
|
||||||
|
self.gridLayout_4.addWidget(self.pb_con_mid, 2, 0, 1, 1)
|
||||||
|
|
||||||
|
self.pb_con_line = QPushButton(self.groupBox_3)
|
||||||
|
self.pb_con_line.setObjectName(u"pb_con_line")
|
||||||
|
self.pb_con_line.setCheckable(True)
|
||||||
|
self.pb_con_line.setAutoExclusive(False)
|
||||||
|
|
||||||
|
self.gridLayout_4.addWidget(self.pb_con_line, 1, 1, 1, 1)
|
||||||
|
|
||||||
|
self.pb_con_horiz = QPushButton(self.groupBox_3)
|
||||||
|
self.pb_con_horiz.setObjectName(u"pb_con_horiz")
|
||||||
|
self.pb_con_horiz.setCheckable(True)
|
||||||
|
self.pb_con_horiz.setAutoExclusive(False)
|
||||||
|
|
||||||
|
self.gridLayout_4.addWidget(self.pb_con_horiz, 3, 0, 1, 1)
|
||||||
|
|
||||||
|
self.pb_con_dist = QPushButton(self.groupBox_3)
|
||||||
|
self.pb_con_dist.setObjectName(u"pb_con_dist")
|
||||||
|
self.pb_con_dist.setCheckable(True)
|
||||||
|
self.pb_con_dist.setAutoExclusive(False)
|
||||||
|
self.pb_con_dist.setAutoRepeatDelay(297)
|
||||||
|
|
||||||
|
self.gridLayout_4.addWidget(self.pb_con_dist, 4, 0, 1, 1)
|
||||||
|
|
||||||
|
self.pb_con_perp = QPushButton(self.groupBox_3)
|
||||||
|
self.pb_con_perp.setObjectName(u"pb_con_perp")
|
||||||
|
self.pb_con_perp.setCheckable(True)
|
||||||
|
self.pb_con_perp.setAutoExclusive(False)
|
||||||
|
|
||||||
|
self.gridLayout_4.addWidget(self.pb_con_perp, 2, 1, 1, 1)
|
||||||
|
|
||||||
|
self.pb_con_diameter = QPushButton(self.groupBox_3)
|
||||||
|
self.pb_con_diameter.setObjectName(u"pb_con_diameter")
|
||||||
|
|
||||||
|
self.gridLayout_4.addWidget(self.pb_con_diameter, 5, 0, 1, 1)
|
||||||
|
|
||||||
|
|
||||||
|
self.gridLayout.addWidget(self.groupBox_3, 2, 0, 1, 1)
|
||||||
|
|
||||||
|
self.groupBox_5 = QGroupBox(self.centralwidget)
|
||||||
|
self.groupBox_5.setObjectName(u"groupBox_5")
|
||||||
|
sizePolicy4.setHeightForWidth(self.groupBox_5.sizePolicy().hasHeightForWidth())
|
||||||
|
self.groupBox_5.setSizePolicy(sizePolicy4)
|
||||||
|
self.gridLayout_11 = QGridLayout(self.groupBox_5)
|
||||||
|
self.gridLayout_11.setObjectName(u"gridLayout_11")
|
||||||
|
self.gridLayout_11.setContentsMargins(12, 12, 12, 12)
|
||||||
|
self.label = QLabel(self.groupBox_5)
|
||||||
|
self.label.setObjectName(u"label")
|
||||||
|
|
||||||
|
self.gridLayout_11.addWidget(self.label, 5, 0, 1, 1)
|
||||||
|
|
||||||
|
self.pb_snap_vert = QPushButton(self.groupBox_5)
|
||||||
|
self.pb_snap_vert.setObjectName(u"pb_snap_vert")
|
||||||
|
self.pb_snap_vert.setCheckable(True)
|
||||||
|
self.pb_snap_vert.setAutoExclusive(False)
|
||||||
|
|
||||||
|
self.gridLayout_11.addWidget(self.pb_snap_vert, 2, 1, 1, 1)
|
||||||
|
|
||||||
|
self.line_2 = QFrame(self.groupBox_5)
|
||||||
|
self.line_2.setObjectName(u"line_2")
|
||||||
|
self.line_2.setFrameShape(QFrame.Shape.HLine)
|
||||||
|
self.line_2.setFrameShadow(QFrame.Shadow.Sunken)
|
||||||
|
|
||||||
|
self.gridLayout_11.addWidget(self.line_2, 4, 0, 1, 2)
|
||||||
|
|
||||||
|
self.label_2 = QLabel(self.groupBox_5)
|
||||||
|
self.label_2.setObjectName(u"label_2")
|
||||||
|
|
||||||
|
self.gridLayout_11.addWidget(self.label_2, 5, 1, 1, 1)
|
||||||
|
|
||||||
|
self.spinbox_snap_distance = QSpinBox(self.groupBox_5)
|
||||||
|
self.spinbox_snap_distance.setObjectName(u"spinbox_snap_distance")
|
||||||
|
self.spinbox_snap_distance.setMaximum(30)
|
||||||
|
self.spinbox_snap_distance.setValue(10)
|
||||||
|
|
||||||
|
self.gridLayout_11.addWidget(self.spinbox_snap_distance, 6, 0, 1, 1)
|
||||||
|
|
||||||
|
self.pushButton_7 = QPushButton(self.groupBox_5)
|
||||||
|
self.pushButton_7.setObjectName(u"pushButton_7")
|
||||||
|
self.pushButton_7.setCheckable(True)
|
||||||
|
self.pushButton_7.setAutoExclusive(False)
|
||||||
|
|
||||||
|
self.gridLayout_11.addWidget(self.pushButton_7, 3, 0, 1, 1)
|
||||||
|
|
||||||
|
self.pb_snap_horiz = QPushButton(self.groupBox_5)
|
||||||
|
self.pb_snap_horiz.setObjectName(u"pb_snap_horiz")
|
||||||
|
self.pb_snap_horiz.setCheckable(True)
|
||||||
|
self.pb_snap_horiz.setAutoExclusive(False)
|
||||||
|
|
||||||
|
self.gridLayout_11.addWidget(self.pb_snap_horiz, 2, 0, 1, 1)
|
||||||
|
|
||||||
|
self.spinbox_angle_steps = QSpinBox(self.groupBox_5)
|
||||||
|
self.spinbox_angle_steps.setObjectName(u"spinbox_angle_steps")
|
||||||
|
self.spinbox_angle_steps.setMaximum(180)
|
||||||
|
self.spinbox_angle_steps.setValue(15)
|
||||||
|
|
||||||
|
self.gridLayout_11.addWidget(self.spinbox_angle_steps, 6, 1, 1, 1)
|
||||||
|
|
||||||
|
self.pushButton_8 = QPushButton(self.groupBox_5)
|
||||||
|
self.pushButton_8.setObjectName(u"pushButton_8")
|
||||||
|
self.pushButton_8.setCheckable(True)
|
||||||
|
self.pushButton_8.setAutoExclusive(False)
|
||||||
|
|
||||||
|
self.gridLayout_11.addWidget(self.pushButton_8, 0, 0, 1, 1)
|
||||||
|
|
||||||
|
self.pb_snap_midp = QPushButton(self.groupBox_5)
|
||||||
|
self.pb_snap_midp.setObjectName(u"pb_snap_midp")
|
||||||
|
self.pb_snap_midp.setCheckable(True)
|
||||||
|
self.pb_snap_midp.setAutoExclusive(False)
|
||||||
|
|
||||||
|
self.gridLayout_11.addWidget(self.pb_snap_midp, 0, 1, 1, 1)
|
||||||
|
|
||||||
|
self.pb_snap_angle = QPushButton(self.groupBox_5)
|
||||||
|
self.pb_snap_angle.setObjectName(u"pb_snap_angle")
|
||||||
|
self.pb_snap_angle.setCheckable(True)
|
||||||
|
self.pb_snap_angle.setAutoExclusive(False)
|
||||||
|
|
||||||
|
self.gridLayout_11.addWidget(self.pb_snap_angle, 3, 1, 1, 1)
|
||||||
|
|
||||||
|
|
||||||
|
self.gridLayout.addWidget(self.groupBox_5, 3, 0, 1, 1)
|
||||||
|
|
||||||
|
self.groupBox = QGroupBox(self.centralwidget)
|
||||||
|
self.groupBox.setObjectName(u"groupBox")
|
||||||
|
self.gridLayout_3 = QGridLayout(self.groupBox)
|
||||||
|
self.gridLayout_3.setObjectName(u"gridLayout_3")
|
||||||
|
self.pb_revop = QPushButton(self.groupBox)
|
||||||
|
self.pb_revop.setObjectName(u"pb_revop")
|
||||||
|
|
||||||
|
self.gridLayout_3.addWidget(self.pb_revop, 2, 1, 1, 1)
|
||||||
|
|
||||||
|
self.pb_extrdop = QPushButton(self.groupBox)
|
||||||
|
self.pb_extrdop.setObjectName(u"pb_extrdop")
|
||||||
|
|
||||||
|
self.gridLayout_3.addWidget(self.pb_extrdop, 0, 0, 1, 1)
|
||||||
|
|
||||||
|
self.pb_arrayop = QPushButton(self.groupBox)
|
||||||
|
self.pb_arrayop.setObjectName(u"pb_arrayop")
|
||||||
|
|
||||||
|
self.gridLayout_3.addWidget(self.pb_arrayop, 2, 0, 1, 1)
|
||||||
|
|
||||||
|
self.pb_cutop = QPushButton(self.groupBox)
|
||||||
|
self.pb_cutop.setObjectName(u"pb_cutop")
|
||||||
|
|
||||||
|
self.gridLayout_3.addWidget(self.pb_cutop, 0, 1, 1, 1)
|
||||||
|
|
||||||
|
self.pb_combop = QPushButton(self.groupBox)
|
||||||
|
self.pb_combop.setObjectName(u"pb_combop")
|
||||||
|
|
||||||
|
self.gridLayout_3.addWidget(self.pb_combop, 1, 0, 1, 1)
|
||||||
|
|
||||||
|
self.pb_moveop = QPushButton(self.groupBox)
|
||||||
|
self.pb_moveop.setObjectName(u"pb_moveop")
|
||||||
|
|
||||||
|
self.gridLayout_3.addWidget(self.pb_moveop, 1, 1, 1, 1)
|
||||||
|
|
||||||
|
|
||||||
|
self.gridLayout.addWidget(self.groupBox, 0, 3, 1, 1)
|
||||||
|
|
||||||
|
self.groupBox_4 = QGroupBox(self.centralwidget)
|
||||||
|
self.groupBox_4.setObjectName(u"groupBox_4")
|
||||||
|
self.verticalLayout_2 = QVBoxLayout(self.groupBox_4)
|
||||||
|
self.verticalLayout_2.setObjectName(u"verticalLayout_2")
|
||||||
|
self.pushButton_2 = QPushButton(self.groupBox_4)
|
||||||
|
self.pushButton_2.setObjectName(u"pushButton_2")
|
||||||
|
|
||||||
|
self.verticalLayout_2.addWidget(self.pushButton_2)
|
||||||
|
|
||||||
|
self.pb_export_step = QPushButton(self.groupBox_4)
|
||||||
|
self.pb_export_step.setObjectName(u"pb_export_step")
|
||||||
|
|
||||||
|
self.verticalLayout_2.addWidget(self.pb_export_step)
|
||||||
|
|
||||||
|
self.pb_export_iges = QPushButton(self.groupBox_4)
|
||||||
|
self.pb_export_iges.setObjectName(u"pb_export_iges")
|
||||||
|
|
||||||
|
self.verticalLayout_2.addWidget(self.pb_export_iges)
|
||||||
|
|
||||||
|
|
||||||
|
self.gridLayout.addWidget(self.groupBox_4, 2, 3, 1, 1)
|
||||||
|
|
||||||
|
fluencyCAD.setCentralWidget(self.centralwidget)
|
||||||
|
self.menubar = QMenuBar(fluencyCAD)
|
||||||
|
self.menubar.setObjectName(u"menubar")
|
||||||
|
self.menubar.setGeometry(QRect(0, 0, 2359, 24))
|
||||||
|
self.menuFile = QMenu(self.menubar)
|
||||||
|
self.menuFile.setObjectName(u"menuFile")
|
||||||
|
self.menuSettings = QMenu(self.menubar)
|
||||||
|
self.menuSettings.setObjectName(u"menuSettings")
|
||||||
|
fluencyCAD.setMenuBar(self.menubar)
|
||||||
|
self.statusbar = QStatusBar(fluencyCAD)
|
||||||
|
self.statusbar.setObjectName(u"statusbar")
|
||||||
|
fluencyCAD.setStatusBar(self.statusbar)
|
||||||
|
|
||||||
|
self.menubar.addAction(self.menuFile.menuAction())
|
||||||
|
self.menubar.addAction(self.menuSettings.menuAction())
|
||||||
|
self.menuFile.addAction(self.actionNew_Project)
|
||||||
|
self.menuFile.addAction(self.actionOpen_Project)
|
||||||
|
self.menuFile.addAction(self.actionSave_Project)
|
||||||
|
self.menuFile.addAction(self.actionSave_Project_As)
|
||||||
|
self.menuFile.addSeparator()
|
||||||
|
self.menuFile.addAction(self.actionImport_File)
|
||||||
|
self.menuFile.addSeparator()
|
||||||
|
self.menuFile.addAction(self.actionExport_Step)
|
||||||
|
self.menuFile.addAction(self.actionExport_Iges)
|
||||||
|
self.menuFile.addAction(self.actionExport_Stl)
|
||||||
|
self.menuFile.addSeparator()
|
||||||
|
self.menuFile.addAction(self.actionExit)
|
||||||
|
|
||||||
|
self.retranslateUi(fluencyCAD)
|
||||||
|
|
||||||
|
self.InputTab.setCurrentIndex(0)
|
||||||
|
|
||||||
|
|
||||||
|
QMetaObject.connectSlotsByName(fluencyCAD)
|
||||||
|
# setupUi
|
||||||
|
|
||||||
|
def retranslateUi(self, fluencyCAD):
|
||||||
|
fluencyCAD.setWindowTitle(QCoreApplication.translate("fluencyCAD", u"fluencyCAD", None))
|
||||||
|
self.actionNew_Project.setText(QCoreApplication.translate("fluencyCAD", u"New Project", None))
|
||||||
|
#if QT_CONFIG(shortcut)
|
||||||
|
self.actionNew_Project.setShortcut(QCoreApplication.translate("fluencyCAD", u"Ctrl+N", None))
|
||||||
|
#endif // QT_CONFIG(shortcut)
|
||||||
|
self.actionOpen_Project.setText(QCoreApplication.translate("fluencyCAD", u"Open Project...", None))
|
||||||
|
#if QT_CONFIG(shortcut)
|
||||||
|
self.actionOpen_Project.setShortcut(QCoreApplication.translate("fluencyCAD", u"Ctrl+O", None))
|
||||||
|
#endif // QT_CONFIG(shortcut)
|
||||||
|
self.actionSave_Project.setText(QCoreApplication.translate("fluencyCAD", u"Save Project", None))
|
||||||
|
#if QT_CONFIG(shortcut)
|
||||||
|
self.actionSave_Project.setShortcut(QCoreApplication.translate("fluencyCAD", u"Ctrl+S", None))
|
||||||
|
#endif // QT_CONFIG(shortcut)
|
||||||
|
self.actionSave_Project_As.setText(QCoreApplication.translate("fluencyCAD", u"Save Project As...", None))
|
||||||
|
#if QT_CONFIG(shortcut)
|
||||||
|
self.actionSave_Project_As.setShortcut(QCoreApplication.translate("fluencyCAD", u"Ctrl+Shift+S", None))
|
||||||
|
#endif // QT_CONFIG(shortcut)
|
||||||
|
self.actionImport_File.setText(QCoreApplication.translate("fluencyCAD", u"Import STEP/IGES...", None))
|
||||||
|
self.actionExport_Step.setText(QCoreApplication.translate("fluencyCAD", u"Export STEP...", None))
|
||||||
|
self.actionExport_Iges.setText(QCoreApplication.translate("fluencyCAD", u"Export IGES...", None))
|
||||||
|
self.actionExport_Stl.setText(QCoreApplication.translate("fluencyCAD", u"Export STL...", None))
|
||||||
|
self.actionExit.setText(QCoreApplication.translate("fluencyCAD", u"Exit", None))
|
||||||
|
#if QT_CONFIG(shortcut)
|
||||||
|
self.actionExit.setShortcut(QCoreApplication.translate("fluencyCAD", u"Ctrl+Q", None))
|
||||||
|
#endif // QT_CONFIG(shortcut)
|
||||||
|
self.InputTab.setTabText(self.InputTab.indexOf(self.sketch_tab), QCoreApplication.translate("fluencyCAD", u"Sketch", None))
|
||||||
|
self.groupBox_7.setTitle(QCoreApplication.translate("fluencyCAD", u"Executive", None))
|
||||||
|
self.pushButton_5.setText(QCoreApplication.translate("fluencyCAD", u"Load Code", None))
|
||||||
|
self.pushButton_4.setText(QCoreApplication.translate("fluencyCAD", u"Save code", None))
|
||||||
|
self.pb_apply_code.setText(QCoreApplication.translate("fluencyCAD", u"Apply Code", None))
|
||||||
|
self.pushButton.setText(QCoreApplication.translate("fluencyCAD", u"Delete Code", None))
|
||||||
|
self.InputTab.setTabText(self.InputTab.indexOf(self.code_tab), QCoreApplication.translate("fluencyCAD", u"Code", None))
|
||||||
|
self.groupBox_9.setTitle(QCoreApplication.translate("fluencyCAD", u"Workplanes", None))
|
||||||
|
#if QT_CONFIG(tooltip)
|
||||||
|
self.pb_origin_wp.setToolTip(QCoreApplication.translate("fluencyCAD", u"<W>orking Plane at 0, 0, 0", None))
|
||||||
|
#endif // QT_CONFIG(tooltip)
|
||||||
|
self.pb_origin_wp.setText(QCoreApplication.translate("fluencyCAD", u"WP Origin", None))
|
||||||
|
#if QT_CONFIG(shortcut)
|
||||||
|
self.pb_origin_wp.setShortcut(QCoreApplication.translate("fluencyCAD", u"W", None))
|
||||||
|
#endif // QT_CONFIG(shortcut)
|
||||||
|
#if QT_CONFIG(tooltip)
|
||||||
|
self.pb_origin_face.setToolTip(QCoreApplication.translate("fluencyCAD", u"Working Plane >P<rojection at selected edges face", None))
|
||||||
|
#endif // QT_CONFIG(tooltip)
|
||||||
|
self.pb_origin_face.setText(QCoreApplication.translate("fluencyCAD", u" WP Face", None))
|
||||||
|
#if QT_CONFIG(shortcut)
|
||||||
|
self.pb_origin_face.setShortcut(QCoreApplication.translate("fluencyCAD", u"P", None))
|
||||||
|
#endif // QT_CONFIG(shortcut)
|
||||||
|
#if QT_CONFIG(tooltip)
|
||||||
|
self.pb_flip_face.setToolTip(QCoreApplication.translate("fluencyCAD", u"Flip >N<ormal of projected mesh.", None))
|
||||||
|
#endif // QT_CONFIG(tooltip)
|
||||||
|
self.pb_flip_face.setText(QCoreApplication.translate("fluencyCAD", u"WP Flip", None))
|
||||||
|
#if QT_CONFIG(shortcut)
|
||||||
|
self.pb_flip_face.setShortcut(QCoreApplication.translate("fluencyCAD", u"N", None))
|
||||||
|
#endif // QT_CONFIG(shortcut)
|
||||||
|
#if QT_CONFIG(tooltip)
|
||||||
|
self.pb_underlay.setToolTip(QCoreApplication.translate("fluencyCAD", u"Show / hide the construction lines projected from the source face", None))
|
||||||
|
#endif // QT_CONFIG(tooltip)
|
||||||
|
self.pb_underlay.setText(QCoreApplication.translate("fluencyCAD", u"Underlay", None))
|
||||||
|
#if QT_CONFIG(tooltip)
|
||||||
|
self.pb_clr_face.setToolTip(QCoreApplication.translate("fluencyCAD", u"Forget the picked source face (keep the workplane)", None))
|
||||||
|
#endif // QT_CONFIG(tooltip)
|
||||||
|
self.pb_clr_face.setText(QCoreApplication.translate("fluencyCAD", u"ClrFace", None))
|
||||||
|
#if QT_CONFIG(tooltip)
|
||||||
|
self.pb_to_sketch.setToolTip(QCoreApplication.translate("fluencyCAD", u"Convert projected construction lines into real sketch geometry", None))
|
||||||
|
#endif // QT_CONFIG(tooltip)
|
||||||
|
self.pb_to_sketch.setText(QCoreApplication.translate("fluencyCAD", u"ToSketch", None))
|
||||||
|
#if QT_CONFIG(tooltip)
|
||||||
|
self.pb_wp_new.setToolTip(QCoreApplication.translate("fluencyCAD", u"Create a new independent workplane (datum plane)", None))
|
||||||
|
#endif // QT_CONFIG(tooltip)
|
||||||
|
self.pb_wp_new.setText(QCoreApplication.translate("fluencyCAD", u"WP New", None))
|
||||||
|
#if QT_CONFIG(shortcut)
|
||||||
|
self.pb_wp_new.setShortcut(QCoreApplication.translate("fluencyCAD", u"Shift+W", None))
|
||||||
|
#endif // QT_CONFIG(shortcut)
|
||||||
|
self.assembly_box.setTitle(QCoreApplication.translate("fluencyCAD", u"Assembly", None))
|
||||||
|
self.joint_tools.setTitle(QCoreApplication.translate("fluencyCAD", u"Joint Tools", None))
|
||||||
|
self.pb_remove_connector.setText(QCoreApplication.translate("fluencyCAD", u"- Cnct", None))
|
||||||
|
self.pb_add_connector.setText(QCoreApplication.translate("fluencyCAD", u"+ Cnct", None))
|
||||||
|
self.pb_add_connector_2.setText(QCoreApplication.translate("fluencyCAD", u"+Jnt", None))
|
||||||
|
self.pb_add_connector_3.setText(QCoreApplication.translate("fluencyCAD", u"-Jnt", None))
|
||||||
|
self.gl_box.setTitle(QCoreApplication.translate("fluencyCAD", u"Model Viewer", None))
|
||||||
|
self.groupBox_11.setTitle(QCoreApplication.translate("fluencyCAD", u"Sketch", None))
|
||||||
|
self.groupBox_6.setTitle(QCoreApplication.translate("fluencyCAD", u"Tools", None))
|
||||||
|
self.pb_edt_sktch.setText(QCoreApplication.translate("fluencyCAD", u"Edt", None))
|
||||||
|
self.pb_nw_sktch.setText(QCoreApplication.translate("fluencyCAD", u"Add", None))
|
||||||
|
self.pb_del_sketch.setText(QCoreApplication.translate("fluencyCAD", u"Del", None))
|
||||||
|
self.assembly_tools.setTitle(QCoreApplication.translate("fluencyCAD", u"Assembly Tools", None))
|
||||||
|
self.pb_compo_to_assembly.setText(QCoreApplication.translate("fluencyCAD", u"Add", None))
|
||||||
|
self.pb_remove_compo_from_assembly.setText(QCoreApplication.translate("fluencyCAD", u"Rem", None))
|
||||||
|
self.compo_tool_box.setTitle(QCoreApplication.translate("fluencyCAD", u"Component Tools", None))
|
||||||
|
self.pb_new_compo.setText(QCoreApplication.translate("fluencyCAD", u"New", None))
|
||||||
|
self.pb_del_compo.setText(QCoreApplication.translate("fluencyCAD", u"Del", None))
|
||||||
|
self.compo_box.setTitle(QCoreApplication.translate("fluencyCAD", u"Components", None))
|
||||||
|
self.groupBox_12.setTitle(QCoreApplication.translate("fluencyCAD", u"Component Connections", None))
|
||||||
|
self.groupBox_13.setTitle(QCoreApplication.translate("fluencyCAD", u"Tools", None))
|
||||||
|
self.pb_del_connection.setText(QCoreApplication.translate("fluencyCAD", u"Del", None))
|
||||||
|
self.pb_update_connection.setText(QCoreApplication.translate("fluencyCAD", u"Upd", None))
|
||||||
|
self.pb_edt_sktch_4.setText(QCoreApplication.translate("fluencyCAD", u"Nothing", None))
|
||||||
|
self.groupBox_10.setTitle(QCoreApplication.translate("fluencyCAD", u"Bodys / Operations", None))
|
||||||
|
self.groupBox_8.setTitle(QCoreApplication.translate("fluencyCAD", u"Tools", None))
|
||||||
|
self.pb_del_body.setText(QCoreApplication.translate("fluencyCAD", u"Del", None))
|
||||||
|
self.pb_update_body.setText(QCoreApplication.translate("fluencyCAD", u"Upd", None))
|
||||||
|
self.pb_edt_sktch_3.setText(QCoreApplication.translate("fluencyCAD", u"Nothing", None))
|
||||||
|
self.groupBox_2.setTitle(QCoreApplication.translate("fluencyCAD", u"Drawing", None))
|
||||||
|
self.pb_arc_tool.setText(QCoreApplication.translate("fluencyCAD", u"Arc", None))
|
||||||
|
self.pb_rectool.setText(QCoreApplication.translate("fluencyCAD", u"Rctgl", None))
|
||||||
|
self.pb_circtool.setText(QCoreApplication.translate("fluencyCAD", u"Circle", None))
|
||||||
|
self.pb_enable_construct.setText(QCoreApplication.translate("fluencyCAD", u"Cstrct", None))
|
||||||
|
self.pb_enable_snap.setText(QCoreApplication.translate("fluencyCAD", u"Snap", None))
|
||||||
|
self.pb_linetool.setText(QCoreApplication.translate("fluencyCAD", u"Line", None))
|
||||||
|
#if QT_CONFIG(shortcut)
|
||||||
|
self.pb_linetool.setShortcut(QCoreApplication.translate("fluencyCAD", u"S", None))
|
||||||
|
#endif // QT_CONFIG(shortcut)
|
||||||
|
self.pb_slotool.setText(QCoreApplication.translate("fluencyCAD", u"Slot", None))
|
||||||
|
#if QT_CONFIG(tooltip)
|
||||||
|
self.pb_offset_tool.setToolTip(QCoreApplication.translate("fluencyCAD", u"Offset selected sketch face (duplicate + offset boundary)", None))
|
||||||
|
#endif // QT_CONFIG(tooltip)
|
||||||
|
self.pb_offset_tool.setText(QCoreApplication.translate("fluencyCAD", u"Offst", None))
|
||||||
|
self.groupBox_3.setTitle(QCoreApplication.translate("fluencyCAD", u"Constrain", None))
|
||||||
|
#if QT_CONFIG(tooltip)
|
||||||
|
self.pb_con_ptpt.setToolTip(QCoreApplication.translate("fluencyCAD", u"Poin to Point Constrain", None))
|
||||||
|
#endif // QT_CONFIG(tooltip)
|
||||||
|
self.pb_con_ptpt.setText(QCoreApplication.translate("fluencyCAD", u"Pt_Pt", None))
|
||||||
|
#if QT_CONFIG(tooltip)
|
||||||
|
self.pb_con_vert.setToolTip(QCoreApplication.translate("fluencyCAD", u"Vertical Constrain", None))
|
||||||
|
#endif // QT_CONFIG(tooltip)
|
||||||
|
self.pb_con_vert.setText(QCoreApplication.translate("fluencyCAD", u"Vert", None))
|
||||||
|
self.pb_con_sym.setText(QCoreApplication.translate("fluencyCAD", u"Symetrc", None))
|
||||||
|
#if QT_CONFIG(tooltip)
|
||||||
|
self.pb_con_mid.setToolTip(QCoreApplication.translate("fluencyCAD", u"Point to Middle Point Constrain", None))
|
||||||
|
#endif // QT_CONFIG(tooltip)
|
||||||
|
self.pb_con_mid.setText(QCoreApplication.translate("fluencyCAD", u"Pt_Mid_L", None))
|
||||||
|
#if QT_CONFIG(tooltip)
|
||||||
|
self.pb_con_line.setToolTip(QCoreApplication.translate("fluencyCAD", u"Point to Line Constrain", None))
|
||||||
|
#endif // QT_CONFIG(tooltip)
|
||||||
|
self.pb_con_line.setText(QCoreApplication.translate("fluencyCAD", u"Pt_Lne", None))
|
||||||
|
#if QT_CONFIG(tooltip)
|
||||||
|
self.pb_con_horiz.setToolTip(QCoreApplication.translate("fluencyCAD", u"Horizontal Constrain ", None))
|
||||||
|
#endif // QT_CONFIG(tooltip)
|
||||||
|
self.pb_con_horiz.setText(QCoreApplication.translate("fluencyCAD", u"Horiz", None))
|
||||||
|
#if QT_CONFIG(tooltip)
|
||||||
|
self.pb_con_dist.setToolTip(QCoreApplication.translate("fluencyCAD", u"Dimension of Line of Distance from Point to Line", None))
|
||||||
|
#endif // QT_CONFIG(tooltip)
|
||||||
|
self.pb_con_dist.setText(QCoreApplication.translate("fluencyCAD", u"Distnce", None))
|
||||||
|
#if QT_CONFIG(tooltip)
|
||||||
|
self.pb_con_perp.setToolTip(QCoreApplication.translate("fluencyCAD", u"Constrain Line perpendicular to another line.", None))
|
||||||
|
#endif // QT_CONFIG(tooltip)
|
||||||
|
self.pb_con_perp.setText(QCoreApplication.translate("fluencyCAD", u"Perp_Lne", None))
|
||||||
|
self.pb_con_diameter.setText(QCoreApplication.translate("fluencyCAD", u"Diameter", None))
|
||||||
|
self.groupBox_5.setTitle(QCoreApplication.translate("fluencyCAD", u"Snapping Points", None))
|
||||||
|
self.label.setText(QCoreApplication.translate("fluencyCAD", u"Snp Dst", None))
|
||||||
|
self.pb_snap_vert.setText(QCoreApplication.translate("fluencyCAD", u"Vert", None))
|
||||||
|
self.label_2.setText(QCoreApplication.translate("fluencyCAD", u"Angl Stps", None))
|
||||||
|
self.spinbox_snap_distance.setSuffix(QCoreApplication.translate("fluencyCAD", u"mm", None))
|
||||||
|
self.pushButton_7.setText(QCoreApplication.translate("fluencyCAD", u"Grid", None))
|
||||||
|
self.pb_snap_horiz.setText(QCoreApplication.translate("fluencyCAD", u"Horiz", None))
|
||||||
|
self.spinbox_angle_steps.setSuffix(QCoreApplication.translate("fluencyCAD", u"\u00b0", None))
|
||||||
|
self.pushButton_8.setText(QCoreApplication.translate("fluencyCAD", u"Pnt", None))
|
||||||
|
self.pb_snap_midp.setText(QCoreApplication.translate("fluencyCAD", u"MidP", None))
|
||||||
|
self.pb_snap_angle.setText(QCoreApplication.translate("fluencyCAD", u"Angles", None))
|
||||||
|
self.groupBox.setTitle(QCoreApplication.translate("fluencyCAD", u"Modify", None))
|
||||||
|
self.pb_revop.setText(QCoreApplication.translate("fluencyCAD", u"Rev", None))
|
||||||
|
self.pb_extrdop.setText(QCoreApplication.translate("fluencyCAD", u"Extrd", None))
|
||||||
|
self.pb_arrayop.setText(QCoreApplication.translate("fluencyCAD", u"Arry", None))
|
||||||
|
self.pb_cutop.setText(QCoreApplication.translate("fluencyCAD", u"Cut", None))
|
||||||
|
self.pb_combop.setText(QCoreApplication.translate("fluencyCAD", u"Comb", None))
|
||||||
|
self.pb_moveop.setText(QCoreApplication.translate("fluencyCAD", u"Mve", None))
|
||||||
|
self.groupBox_4.setTitle(QCoreApplication.translate("fluencyCAD", u"Export", None))
|
||||||
|
self.pushButton_2.setText(QCoreApplication.translate("fluencyCAD", u"STL", None))
|
||||||
|
self.pb_export_step.setText(QCoreApplication.translate("fluencyCAD", u"STEP", None))
|
||||||
|
self.pb_export_iges.setText(QCoreApplication.translate("fluencyCAD", u"IGES", None))
|
||||||
|
self.menuFile.setTitle(QCoreApplication.translate("fluencyCAD", u"File", None))
|
||||||
|
self.menuSettings.setTitle(QCoreApplication.translate("fluencyCAD", u"Settings", None))
|
||||||
|
# retranslateUi
|
||||||
|
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,918 @@
|
|||||||
|
"""3D viewer widget — wraps OCC's AIS/V3d native display for use inside Qt."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
|
from PySide6.QtCore import Qt, Signal
|
||||||
|
from PySide6.QtWidgets import QWidget
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class Viewer3DWidget(QWidget):
|
||||||
|
"""3D viewer widget using OCC's native AIS display."""
|
||||||
|
|
||||||
|
# Emitted when the user picks a planar face to sketch on.
|
||||||
|
# Payload: (origin, normal, x_dir, face_shape) — all tuples are (x,y,z).
|
||||||
|
facePicked = Signal(tuple, tuple, tuple, object)
|
||||||
|
# Emitted when face-pick mode is cancelled (Esc) so the host can uncheck.
|
||||||
|
pickFaceCancelled = Signal()
|
||||||
|
|
||||||
|
# Emitted when the user picks an entity for a connector point (assembly).
|
||||||
|
# Payload: (origin, normal, x_dir, entity_type, face_or_edge_or_vertex, owner_obj_id).
|
||||||
|
connectorPicked = Signal(tuple, tuple, tuple, str, object, str)
|
||||||
|
# Emitted when connector pick mode is cancelled.
|
||||||
|
connectorPickCancelled = Signal()
|
||||||
|
# Emitted on mouse move in connector mode to show snap preview.
|
||||||
|
# Payload: (origin, normal, entity_type, owner_obj_id) or None if nothing.
|
||||||
|
connectorHover = Signal(object)
|
||||||
|
|
||||||
|
# Emitted when a body is clicked in assembly move mode.
|
||||||
|
# Payload: owner_obj_id.
|
||||||
|
assemblyComponentActivated = Signal(str)
|
||||||
|
# Emitted during a drag move: owner_obj_id, world dx, dy, dz.
|
||||||
|
assemblyComponentDragged = Signal(str, float, float, float)
|
||||||
|
# Emitted when a drag move finishes.
|
||||||
|
assemblyMoveFinished = Signal(str)
|
||||||
|
|
||||||
|
# Emitted whenever the camera changes (orbit, pan, zoom).
|
||||||
|
# Payload: (eye, at, up) — each is a tuple of 3 floats.
|
||||||
|
cameraChanged = Signal(tuple, tuple, tuple)
|
||||||
|
|
||||||
|
def __init__(self, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
# For OCC's direct OpenGL rendering we need Qt to not paint over it.
|
||||||
|
self.setAttribute(Qt.WA_PaintOnScreen)
|
||||||
|
self.setAttribute(Qt.WA_OpaquePaintEvent)
|
||||||
|
self.setAutoFillBackground(False)
|
||||||
|
# Accept keyboard focus so navigation shortcuts (F, R, 1-7, P, O) work.
|
||||||
|
self.setFocusPolicy(Qt.StrongFocus)
|
||||||
|
# Enable mouse tracking so ``mouseMoveEvent`` fires even without a
|
||||||
|
# button held — required for the connector-pick hover gizmo (and any
|
||||||
|
# status-bar hover feedback) to show under the cursor as the user
|
||||||
|
# moves the mouse over candidate snap entities before clicking.
|
||||||
|
self.setMouseTracking(True)
|
||||||
|
# Try OCC renderer first; fall back to pygfx if unavailable.
|
||||||
|
self._renderer: Any = None
|
||||||
|
self._initialized = False
|
||||||
|
self._meshes: Dict[str, Any] = {}
|
||||||
|
self._selected_normal: Optional[Tuple[float, float, float]] = None
|
||||||
|
self._centroid: Optional[Tuple[float, float, float]] = None
|
||||||
|
self._pending_meshes: List[Tuple] = []
|
||||||
|
# When True, a left-click picks a planar face (for sketch-on-surface)
|
||||||
|
# instead of orbiting the camera. Set via set_pick_face_mode().
|
||||||
|
self._pick_face_mode: bool = False
|
||||||
|
# When True, a left-click picks an entity for a connector point
|
||||||
|
# (assembly component connection).
|
||||||
|
self._connector_pick_mode: bool = False
|
||||||
|
# Current snap highlight object id (for hover during connector mode).
|
||||||
|
self._connector_snap_id: Optional[str] = None
|
||||||
|
# Throttle connector hover probes to avoid UI lag on fast mouse moves.
|
||||||
|
self._connector_last_hover_time: float = 0.0
|
||||||
|
self._connector_hover_interval: float = 0.05 # 50 ms between probes
|
||||||
|
# When True, left-click on a body activates assembly drag-to-move.
|
||||||
|
self._assembly_move_mode: bool = False
|
||||||
|
# State for ongoing assembly drag.
|
||||||
|
self._move_drag_active: bool = False
|
||||||
|
self._move_owner_obj_id: str = ""
|
||||||
|
self._move_click_3d: Optional[Tuple[float, float, float]] = None
|
||||||
|
self._move_click_screen: Optional[Any] = None
|
||||||
|
self._move_plane_normal: Optional[Tuple[float, float, float]] = None
|
||||||
|
self._move_initial_position: Optional[Tuple[float, float, float]] = None
|
||||||
|
# Most recently recorded owning obj_id for the face returned by
|
||||||
|
# ``pick_planar_face``. Stashed on each pick pass so the host can
|
||||||
|
# pair the picked face with the body it belongs to (used to auto-
|
||||||
|
# target a cut/union extrude against the body the sketch was
|
||||||
|
# projected onto).
|
||||||
|
self._last_pick_owner_obj_id: Optional[str] = None
|
||||||
|
|
||||||
|
def _init_renderer(self) -> None:
|
||||||
|
"""Create the best available renderer."""
|
||||||
|
if self._renderer is not None:
|
||||||
|
return
|
||||||
|
import sys as _sys
|
||||||
|
|
||||||
|
_sys.stdout.flush()
|
||||||
|
logger.info("Renderer: starting import...")
|
||||||
|
from fluency.rendering.occ_renderer import OCCRenderer
|
||||||
|
from fluency.rendering.pygfx_renderer import PygfxRenderer
|
||||||
|
|
||||||
|
logger.info("Renderer: imports done, creating OCCRenderer...")
|
||||||
|
occ = OCCRenderer()
|
||||||
|
logger.info("Renderer: calling occ.initialize...")
|
||||||
|
try:
|
||||||
|
ok = occ.initialize(self)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(f"OCCRenderer init raised: {exc}")
|
||||||
|
ok = False
|
||||||
|
logger.info(f"Renderer: OCC result={ok}")
|
||||||
|
if ok:
|
||||||
|
self._renderer = occ
|
||||||
|
logger.info("Using OCCRenderer (native BRep display)")
|
||||||
|
else:
|
||||||
|
logger.info("Falling back to PygfxRenderer")
|
||||||
|
self._renderer = PygfxRenderer()
|
||||||
|
logger.info("Renderer: calling pygfx initialize...")
|
||||||
|
self._renderer.initialize(self)
|
||||||
|
logger.info("Renderer: pygfx init done")
|
||||||
|
self._initialized = True
|
||||||
|
logger.info("Renderer: initialization complete")
|
||||||
|
|
||||||
|
def showEvent(self, event):
|
||||||
|
logger.info("Viewer3DWidget showEvent - initializing renderer")
|
||||||
|
if not self._initialized:
|
||||||
|
self._init_renderer()
|
||||||
|
logger.info(f"Renderer initialized, pending meshes: {len(self._pending_meshes)}")
|
||||||
|
for args in self._pending_meshes:
|
||||||
|
self.add_mesh(*args)
|
||||||
|
self._pending_meshes.clear()
|
||||||
|
self._renderer.render()
|
||||||
|
|
||||||
|
def _ensure_initialized(self):
|
||||||
|
if not self._initialized:
|
||||||
|
logger.debug("Ensuring renderer is initialized")
|
||||||
|
self._init_renderer()
|
||||||
|
|
||||||
|
def get_renderer(self):
|
||||||
|
self._ensure_initialized()
|
||||||
|
return self._renderer
|
||||||
|
|
||||||
|
def show_shape(self, shape: Any, color=None, name=None) -> str:
|
||||||
|
"""Display an OCC TopoDS_Shape.
|
||||||
|
|
||||||
|
Uses OCCRenderer.add_shape for native AIS display, or falls back to
|
||||||
|
triangulation + add_mesh for the PygfxRenderer.
|
||||||
|
"""
|
||||||
|
self._ensure_initialized()
|
||||||
|
from fluency.rendering.occ_renderer import OCCRenderer
|
||||||
|
|
||||||
|
if isinstance(self._renderer, OCCRenderer):
|
||||||
|
oid = self._renderer.add_shape(shape, color, name)
|
||||||
|
self._renderer.render()
|
||||||
|
return oid
|
||||||
|
# Fallback: tessellate and use the mesh pipeline.
|
||||||
|
from fluency.geometry_occ.kernel import OCGeometryKernel
|
||||||
|
|
||||||
|
k = OCGeometryKernel()
|
||||||
|
# Build a temporary OCCGeometryObject to use the kernel's mesh helpers.
|
||||||
|
from fluency.geometry_occ.kernel import OCCGeometryObject
|
||||||
|
|
||||||
|
obj = OCCGeometryObject(shape)
|
||||||
|
verts, faces = k.get_mesh(obj)
|
||||||
|
oid = self._renderer.add_mesh(verts, faces, color, name)
|
||||||
|
# Edges
|
||||||
|
try:
|
||||||
|
e_verts, e_edges = k.get_edges(obj)
|
||||||
|
if len(e_verts) > 0:
|
||||||
|
self._renderer.add_wireframe(
|
||||||
|
e_verts, e_edges, (0.9, 0.9, 0.9), line_width=1.5, name=f"{name}_edges"
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self._renderer.render()
|
||||||
|
return oid
|
||||||
|
|
||||||
|
def add_mesh(self, vertices, faces, color=None, name=None) -> str:
|
||||||
|
logger.debug(
|
||||||
|
f"add_mesh called: initialized={self._initialized}, vertices={len(vertices)}, faces={len(faces)}, name={name}"
|
||||||
|
)
|
||||||
|
if not self._initialized:
|
||||||
|
self._pending_meshes.append((vertices, faces, color, name))
|
||||||
|
logger.info(f"Queued pending mesh, total pending: {len(self._pending_meshes)}")
|
||||||
|
return f"pending_{len(self._pending_meshes)}"
|
||||||
|
|
||||||
|
self._ensure_initialized()
|
||||||
|
mesh_id = self._renderer.add_mesh(vertices, faces, color, name)
|
||||||
|
self._meshes[mesh_id] = {"vertices": vertices, "faces": faces, "name": name}
|
||||||
|
self._renderer.render()
|
||||||
|
logger.info(f"Added mesh: {mesh_id}, name={name}")
|
||||||
|
return mesh_id
|
||||||
|
|
||||||
|
def update_mesh(self, mesh_id: str, vertices, faces):
|
||||||
|
self._ensure_initialized()
|
||||||
|
self._renderer.update_mesh(mesh_id, vertices, faces)
|
||||||
|
self._meshes[mesh_id] = {"vertices": vertices, "faces": faces}
|
||||||
|
self._renderer.render()
|
||||||
|
|
||||||
|
def add_wireframe(self, vertices, edges, color=None, line_width=1.0, name=None) -> str:
|
||||||
|
self._ensure_initialized()
|
||||||
|
wid = self._renderer.add_wireframe(
|
||||||
|
vertices, edges, color or (0.9, 0.9, 0.9), line_width, name
|
||||||
|
)
|
||||||
|
self._renderer.render()
|
||||||
|
return wid
|
||||||
|
|
||||||
|
def remove_mesh(self, mesh_id: str):
|
||||||
|
self._ensure_initialized()
|
||||||
|
self._renderer.remove_mesh(mesh_id)
|
||||||
|
if mesh_id in self._meshes:
|
||||||
|
del self._meshes[mesh_id]
|
||||||
|
self._renderer.render()
|
||||||
|
|
||||||
|
def set_visibility(self, mesh_id: str, visible: bool) -> bool:
|
||||||
|
"""Show or hide a previously-added mesh without removing it.
|
||||||
|
|
||||||
|
Used by the per-body visibility toggle on the body list so the
|
||||||
|
user can quickly hide a body (e.g. to verify a cut worked on
|
||||||
|
another body). Returns True on success, False if the mesh is
|
||||||
|
unknown to the renderer or the renderer doesn't support it
|
||||||
|
(e.g. the Pygfx fallback ABI).
|
||||||
|
"""
|
||||||
|
self._ensure_initialized()
|
||||||
|
fn = getattr(self._renderer, "set_visibility", None)
|
||||||
|
if fn is None:
|
||||||
|
return False
|
||||||
|
ok = fn(mesh_id, visible)
|
||||||
|
if ok:
|
||||||
|
self._renderer.render()
|
||||||
|
return ok
|
||||||
|
|
||||||
|
def set_transparency(self, mesh_id: str, transparency: float) -> bool:
|
||||||
|
"""Set a previously-added mesh's transparency (0..1).
|
||||||
|
|
||||||
|
Used by the live extrude preview to dim the target body so the
|
||||||
|
previewed result reads on top of it.
|
||||||
|
"""
|
||||||
|
self._ensure_initialized()
|
||||||
|
fn = getattr(self._renderer, "set_object_transparency", None)
|
||||||
|
if fn is None:
|
||||||
|
return False
|
||||||
|
return fn(mesh_id, transparency)
|
||||||
|
|
||||||
|
def show_preview(self, shape: Any, color=None, transparency: float = 0.60) -> None:
|
||||||
|
"""Display a temporary transparent preview of *shape* in the 3D view.
|
||||||
|
|
||||||
|
Used by the ExtrudeDialog live preview: as the user drags the
|
||||||
|
length spinner or toggles Cut/Through-All, the host recomputes
|
||||||
|
the operation result and shows it here. Call clear_preview()
|
||||||
|
when the dialog closes.
|
||||||
|
"""
|
||||||
|
self._ensure_initialized()
|
||||||
|
fn = getattr(self._renderer, "preview_shape", None)
|
||||||
|
if fn is None:
|
||||||
|
return
|
||||||
|
fn(shape, color, transparency)
|
||||||
|
|
||||||
|
def clear_preview(self) -> None:
|
||||||
|
"""Remove the live extrude preview shape, if any."""
|
||||||
|
if not self._initialized or self._renderer is None:
|
||||||
|
return
|
||||||
|
fn = getattr(self._renderer, "clear_preview", None)
|
||||||
|
if fn is None:
|
||||||
|
return
|
||||||
|
fn()
|
||||||
|
|
||||||
|
def clear_scene(self):
|
||||||
|
self._ensure_initialized()
|
||||||
|
self._renderer.clear_scene()
|
||||||
|
self._meshes.clear()
|
||||||
|
self._renderer.render()
|
||||||
|
|
||||||
|
def fit_camera(self):
|
||||||
|
self._ensure_initialized()
|
||||||
|
self._renderer.fit_camera()
|
||||||
|
self._renderer.render()
|
||||||
|
|
||||||
|
# ─── Workplane visualization ───────────────────────────────────────────
|
||||||
|
|
||||||
|
def show_workplane(
|
||||||
|
self,
|
||||||
|
origin: Tuple[float, float, float] = (0, 0, 0),
|
||||||
|
normal: Tuple[float, float, float] = (0, 0, 1),
|
||||||
|
x_dir: Tuple[float, float, float] = (1, 0, 0),
|
||||||
|
size: float = 200.0,
|
||||||
|
name: Optional[str] = None,
|
||||||
|
) -> Optional[str]:
|
||||||
|
"""Display a semi-transparent workplane plane in the 3D view.
|
||||||
|
|
||||||
|
Returns the object ID (for later removal) or None if the renderer
|
||||||
|
doesn't support workplane planes.
|
||||||
|
"""
|
||||||
|
self._ensure_initialized()
|
||||||
|
fn = getattr(self._renderer, "show_workplane_plane", None)
|
||||||
|
if fn is None:
|
||||||
|
return None
|
||||||
|
oid = fn(origin, normal, x_dir, size, name)
|
||||||
|
self._renderer.render()
|
||||||
|
return oid
|
||||||
|
|
||||||
|
def remove_workplane(self, obj_id: str) -> bool:
|
||||||
|
"""Remove a workplane plane visual by its ID."""
|
||||||
|
self._ensure_initialized()
|
||||||
|
fn = getattr(self._renderer, "remove_workplane_plane", None)
|
||||||
|
if fn is None:
|
||||||
|
return False
|
||||||
|
ok = fn(obj_id)
|
||||||
|
if ok:
|
||||||
|
self._renderer.render()
|
||||||
|
return ok
|
||||||
|
|
||||||
|
def mousePressEvent(self, event):
|
||||||
|
self._ensure_initialized()
|
||||||
|
# Face-pick mode: a left-click selects a planar face to sketch on.
|
||||||
|
if self._pick_face_mode and event.button() == Qt.LeftButton:
|
||||||
|
self._handle_face_pick(event)
|
||||||
|
return
|
||||||
|
# Connector pick mode: a left-click selects a face for a connection point.
|
||||||
|
if self._connector_pick_mode and event.button() == Qt.LeftButton:
|
||||||
|
self._handle_connector_pick(event)
|
||||||
|
return
|
||||||
|
# Assembly move mode: start dragging the clicked body.
|
||||||
|
if self._assembly_move_mode and event.button() == Qt.LeftButton:
|
||||||
|
self._handle_assembly_move_press(event)
|
||||||
|
return
|
||||||
|
self._renderer.handle_mouse_press(event)
|
||||||
|
super().mousePressEvent(event)
|
||||||
|
|
||||||
|
def mouseMoveEvent(self, event):
|
||||||
|
self._ensure_initialized()
|
||||||
|
# In connector mode, show snap hover.
|
||||||
|
# Selection modes are deactivated so we skip the idle MoveTo
|
||||||
|
# (dynamic highlighting) — only the gizmo hover handler runs.
|
||||||
|
if self._connector_pick_mode:
|
||||||
|
self._handle_connector_hover(event)
|
||||||
|
super().mouseMoveEvent(event)
|
||||||
|
return
|
||||||
|
# If connector mode was just exited (gizmo persists after pick),
|
||||||
|
# clear any lingering gizmo on first mouse move.
|
||||||
|
gizmo_objs = getattr(self._renderer, "_gizmo_objects", None)
|
||||||
|
if self._connector_snap_id is not None or (gizmo_objs and len(gizmo_objs) > 0):
|
||||||
|
self._clear_connector_snap()
|
||||||
|
# In face-pick mode, keep dynamic highlighting.
|
||||||
|
if self._pick_face_mode:
|
||||||
|
if hasattr(self._renderer, "handle_mouse_move"):
|
||||||
|
self._renderer.handle_mouse_move(event)
|
||||||
|
super().mouseMoveEvent(event)
|
||||||
|
return
|
||||||
|
# Active drag in assembly move mode.
|
||||||
|
if self._move_drag_active:
|
||||||
|
self._handle_assembly_move_move(event)
|
||||||
|
super().mouseMoveEvent(event)
|
||||||
|
return
|
||||||
|
self._renderer.handle_mouse_move(event)
|
||||||
|
super().mouseMoveEvent(event)
|
||||||
|
|
||||||
|
def paintEngine(self):
|
||||||
|
"""Return None to prevent Qt from painting over OCC's direct OpenGL."""
|
||||||
|
return None
|
||||||
|
|
||||||
|
def paintEvent(self, event):
|
||||||
|
"""Empty paintEvent — OCC draws directly via OpenGL."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
def mouseReleaseEvent(self, event):
|
||||||
|
self._ensure_initialized()
|
||||||
|
# Finish assembly drag.
|
||||||
|
if self._move_drag_active:
|
||||||
|
self._handle_assembly_move_release(event)
|
||||||
|
return
|
||||||
|
self._renderer.handle_mouse_release(event)
|
||||||
|
super().mouseReleaseEvent(event)
|
||||||
|
# Camera may have changed during orbit/pan — emit signal.
|
||||||
|
try:
|
||||||
|
eye, at_, up = self.get_camera_position()
|
||||||
|
self.cameraChanged.emit(
|
||||||
|
tuple(float(v) for v in eye),
|
||||||
|
tuple(float(v) for v in at_),
|
||||||
|
tuple(float(v) for v in up),
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def wheelEvent(self, event):
|
||||||
|
self._ensure_initialized()
|
||||||
|
self._renderer.handle_wheel(event)
|
||||||
|
super().wheelEvent(event)
|
||||||
|
# Zoom changed the camera — emit signal.
|
||||||
|
try:
|
||||||
|
eye, at_, up = self.get_camera_position()
|
||||||
|
self.cameraChanged.emit(
|
||||||
|
tuple(float(v) for v in eye),
|
||||||
|
tuple(float(v) for v in at_),
|
||||||
|
tuple(float(v) for v in up),
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def resizeEvent(self, event):
|
||||||
|
super().resizeEvent(event)
|
||||||
|
self._ensure_initialized()
|
||||||
|
self._renderer.handle_resize(event.size().width(), event.size().height())
|
||||||
|
|
||||||
|
def set_camera_position(self, position, target):
|
||||||
|
self._ensure_initialized()
|
||||||
|
self._renderer.set_camera_position(position, target)
|
||||||
|
self._renderer.render()
|
||||||
|
|
||||||
|
def get_camera_position(self):
|
||||||
|
"""Return the current camera ``(eye, at, up)`` triple.
|
||||||
|
|
||||||
|
The underlying renderer's ``get_camera_position`` returns three
|
||||||
|
``np.ndarray``s. We forward the call so callers (notably
|
||||||
|
:meth:`MainWindow._collect_view_state`) can persist the camera.
|
||||||
|
Returns a tuple of ``(np.zeros(3), np.zeros(3), (0,0,1))`` if the
|
||||||
|
renderer hasn't been initialised yet (e.g. when the window is
|
||||||
|
being constructed).
|
||||||
|
"""
|
||||||
|
self._ensure_initialized()
|
||||||
|
if hasattr(self._renderer, "get_camera_position"):
|
||||||
|
return self._renderer.get_camera_position()
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
return (
|
||||||
|
np.zeros(3, dtype=float),
|
||||||
|
np.zeros(3, dtype=float),
|
||||||
|
np.array([0.0, 0.0, 1.0], dtype=float),
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_camera_fov(self) -> float:
|
||||||
|
"""Return the current vertical FOV in degrees from the renderer.
|
||||||
|
|
||||||
|
Falls back to 45.0 if the renderer doesn't support it.
|
||||||
|
"""
|
||||||
|
self._ensure_initialized()
|
||||||
|
if hasattr(self._renderer, "get_camera_fov"):
|
||||||
|
return self._renderer.get_camera_fov()
|
||||||
|
return 45.0
|
||||||
|
|
||||||
|
# ─── Face-pick mode (sketch-on-surface) ────────────────────────────────
|
||||||
|
|
||||||
|
def set_pick_face_mode(self, enabled: bool) -> None:
|
||||||
|
"""Toggle face-pick mode.
|
||||||
|
|
||||||
|
When enabled, the cursor selects planar faces for sketch placement
|
||||||
|
instead of orbiting the camera. Middle button still pans; wheel zooms.
|
||||||
|
"""
|
||||||
|
self._pick_face_mode = bool(enabled)
|
||||||
|
if enabled:
|
||||||
|
self.setCursor(Qt.CrossCursor)
|
||||||
|
else:
|
||||||
|
self.unsetCursor()
|
||||||
|
|
||||||
|
def is_pick_face_mode(self) -> bool:
|
||||||
|
return self._pick_face_mode
|
||||||
|
|
||||||
|
def highlight_face(self, face: Any) -> None:
|
||||||
|
"""Tint the picked face light-blue/transparent in the 3D viewer."""
|
||||||
|
self._ensure_initialized()
|
||||||
|
fn = getattr(self._renderer, "highlight_face", None)
|
||||||
|
if fn is not None:
|
||||||
|
fn(face)
|
||||||
|
self._renderer.render()
|
||||||
|
|
||||||
|
def clear_face_highlight(self) -> None:
|
||||||
|
"""Remove the persistent face-selection tint."""
|
||||||
|
self._ensure_initialized()
|
||||||
|
fn = getattr(self._renderer, "clear_face_highlight", None)
|
||||||
|
if fn is not None:
|
||||||
|
fn()
|
||||||
|
self._renderer.render()
|
||||||
|
|
||||||
|
# ─── Connector pick mode (assembly) ────────────────────────────────────
|
||||||
|
|
||||||
|
def set_connector_pick_mode(self, enabled: bool, clear_gizmo: bool = True) -> None:
|
||||||
|
"""Toggle connector pick mode for placing connection points.
|
||||||
|
|
||||||
|
When enabled, clicking an entity (face, edge, vertex, hole)
|
||||||
|
on a body in the assembly view captures its position and
|
||||||
|
direction as a connection point for the SolveSpace solver.
|
||||||
|
|
||||||
|
Entering connector mode deactivates standard OCC face/edge/vertex
|
||||||
|
selection so dynamic highlighting does not clash with the gizmo
|
||||||
|
visuals. Selection is re-activated on exit.
|
||||||
|
|
||||||
|
*clear_gizmo*: if False the gizmo marker is not cleared on exit,
|
||||||
|
allowing it to persist until the next hover event (used after a
|
||||||
|
successful pick so the user sees what was selected).
|
||||||
|
"""
|
||||||
|
self._connector_pick_mode = bool(enabled)
|
||||||
|
if enabled:
|
||||||
|
self.setCursor(Qt.CrossCursor)
|
||||||
|
# Disable standard OCC selection so gizmo visuals are not
|
||||||
|
# interfered with by dynamic face highlighting.
|
||||||
|
fn = getattr(self._renderer, "deactivate_selection_modes", None)
|
||||||
|
if fn is not None:
|
||||||
|
fn()
|
||||||
|
else:
|
||||||
|
if clear_gizmo:
|
||||||
|
self._clear_connector_snap()
|
||||||
|
# Restore standard OCC selection for face-pick / normal modes.
|
||||||
|
fn = getattr(self._renderer, "activate_selection_modes", None)
|
||||||
|
if fn is not None:
|
||||||
|
fn()
|
||||||
|
if not self._pick_face_mode:
|
||||||
|
self.unsetCursor()
|
||||||
|
|
||||||
|
def is_connector_pick_mode(self) -> bool:
|
||||||
|
return self._connector_pick_mode
|
||||||
|
|
||||||
|
def _clear_connector_snap(self) -> None:
|
||||||
|
"""Remove the hover gizmo."""
|
||||||
|
fn = getattr(self._renderer, "clear_entity_gizmo", None)
|
||||||
|
if fn is not None:
|
||||||
|
fn()
|
||||||
|
# Backwards compat: also try the old method.
|
||||||
|
if self._connector_snap_id is not None:
|
||||||
|
fn2 = getattr(self._renderer, "remove_highlight_snap", None)
|
||||||
|
if fn2 is not None:
|
||||||
|
fn2(self._connector_snap_id)
|
||||||
|
self._connector_snap_id = None
|
||||||
|
|
||||||
|
def _handle_connector_hover(self, event) -> None:
|
||||||
|
"""Update the hover snap gizmo during connector pick mode.
|
||||||
|
|
||||||
|
Uses geometric probing (direct topology walk) which does not depend
|
||||||
|
on OCC's selection system — this avoids clashing with the gizmo
|
||||||
|
visuals since selection modes are deactivated in connector mode.
|
||||||
|
Probes are throttled to at most once every 50 ms to avoid UI lag
|
||||||
|
on fast mouse moves.
|
||||||
|
"""
|
||||||
|
import time
|
||||||
|
|
||||||
|
now = time.monotonic()
|
||||||
|
if now - self._connector_last_hover_time < self._connector_hover_interval:
|
||||||
|
return # throttled — skip this mouse move
|
||||||
|
self._connector_last_hover_time = now
|
||||||
|
|
||||||
|
self._ensure_initialized()
|
||||||
|
probe = getattr(self._renderer, "probe_snap_candidates_geometric", None)
|
||||||
|
pos = event.position().toPoint() if hasattr(event, "position") else event.pos()
|
||||||
|
|
||||||
|
if probe is not None:
|
||||||
|
candidates = probe(pos.x(), pos.y())
|
||||||
|
if not candidates:
|
||||||
|
self._clear_connector_snap()
|
||||||
|
self.connectorHover.emit(None)
|
||||||
|
return
|
||||||
|
# Primary = the nearest candidate (probe sorts nearest-first).
|
||||||
|
info = candidates[0]
|
||||||
|
else:
|
||||||
|
# Fall back to the selection-system-based probe.
|
||||||
|
probe2 = getattr(self._renderer, "probe_snap_candidates", None)
|
||||||
|
if probe2 is not None:
|
||||||
|
candidates = probe2(pos.x(), pos.y())
|
||||||
|
if not candidates:
|
||||||
|
self._clear_connector_snap()
|
||||||
|
self.connectorHover.emit(None)
|
||||||
|
return
|
||||||
|
info = candidates[0]
|
||||||
|
else:
|
||||||
|
# Last resort: single-pixel pick.
|
||||||
|
picker = getattr(self._renderer, "pick_entity", None)
|
||||||
|
if picker is None:
|
||||||
|
return
|
||||||
|
info = picker(pos.x(), pos.y())
|
||||||
|
candidates = [info] if info else []
|
||||||
|
if info is None or info.get("owner_obj_id") is None:
|
||||||
|
self._clear_connector_snap()
|
||||||
|
self.connectorHover.emit(None)
|
||||||
|
return
|
||||||
|
|
||||||
|
origin = info["position"]
|
||||||
|
normal = info.get("normal")
|
||||||
|
entity_type = info["type"]
|
||||||
|
owner = info.get("owner_obj_id", "")
|
||||||
|
|
||||||
|
# ── Feature recognition ──
|
||||||
|
# Enhance candidates with composite feature info (holes, edge loops, etc.)
|
||||||
|
recognize = getattr(self._renderer, "recognize_composite_features", None)
|
||||||
|
if recognize is not None and candidates:
|
||||||
|
candidates = recognize(candidates, pos.x(), pos.y())
|
||||||
|
info = candidates[0] # re-read primary after enhancement
|
||||||
|
origin = info["position"]
|
||||||
|
normal = info.get("normal")
|
||||||
|
entity_type = info.get("type", info.get("feature_type", entity_type))
|
||||||
|
|
||||||
|
# Show smart entity gizmo — dim candidate markers + bright primary.
|
||||||
|
self._clear_connector_snap()
|
||||||
|
gizmo_fn = getattr(self._renderer, "show_entity_gizmo", None)
|
||||||
|
if gizmo_fn is not None:
|
||||||
|
gizmo_fn(
|
||||||
|
entity_type=entity_type,
|
||||||
|
position=origin,
|
||||||
|
normal=normal,
|
||||||
|
x_dir=info.get("x_dir"),
|
||||||
|
radius=info.get("radius"),
|
||||||
|
candidates=candidates,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Fallback to old highlight_snap.
|
||||||
|
fn = getattr(self._renderer, "highlight_snap", None)
|
||||||
|
if fn is not None:
|
||||||
|
colors = {
|
||||||
|
"planar_face": (0.0, 0.8, 1.0), # cyan
|
||||||
|
"cylindrical_face": (1.0, 0.4, 0.0), # orange (hole)
|
||||||
|
"edge": (0.0, 1.0, 0.4), # green
|
||||||
|
"vertex": (1.0, 1.0, 0.0), # yellow
|
||||||
|
}
|
||||||
|
c = colors.get(entity_type, (1.0, 0.6, 0.0))
|
||||||
|
self._connector_snap_id = fn(origin, color=c, size=3.0)
|
||||||
|
|
||||||
|
# Build payload with feature recognition info.
|
||||||
|
payload = {
|
||||||
|
"origin": origin,
|
||||||
|
"normal": normal,
|
||||||
|
"type": entity_type,
|
||||||
|
"owner_obj_id": owner,
|
||||||
|
}
|
||||||
|
# Attach feature info if available.
|
||||||
|
if "feature_type" in info:
|
||||||
|
payload["feature_type"] = info["feature_type"]
|
||||||
|
if "suggestion" in info:
|
||||||
|
payload["suggestion"] = info["suggestion"]
|
||||||
|
if "feature_data" in info:
|
||||||
|
payload["feature_data"] = info["feature_data"]
|
||||||
|
|
||||||
|
self.connectorHover.emit(payload)
|
||||||
|
|
||||||
|
def _handle_connector_pick(self, event) -> None:
|
||||||
|
"""Detect an entity under the click and emit connectorPicked.
|
||||||
|
|
||||||
|
Uses geometric probing (direct topology walk) so a click selects the
|
||||||
|
PRIMARY (nearest) snap target — the same one the hover gizmo
|
||||||
|
emphasised. Falls back to selection-system probe, then single-pixel
|
||||||
|
``pick_entity``, then ``pick_planar_face``.
|
||||||
|
"""
|
||||||
|
self._ensure_initialized()
|
||||||
|
pos = event.position().toPoint() if hasattr(event, "position") else event.pos()
|
||||||
|
info: Optional[Dict[str, Any]] = None
|
||||||
|
|
||||||
|
probe = getattr(self._renderer, "probe_snap_candidates_geometric", None)
|
||||||
|
if probe is not None:
|
||||||
|
candidates = probe(pos.x(), pos.y())
|
||||||
|
if candidates:
|
||||||
|
info = candidates[0] # nearest = primary
|
||||||
|
|
||||||
|
if info is None:
|
||||||
|
probe2 = getattr(self._renderer, "probe_snap_candidates", None)
|
||||||
|
if probe2 is not None:
|
||||||
|
candidates = probe2(pos.x(), pos.y())
|
||||||
|
if candidates:
|
||||||
|
info = candidates[0]
|
||||||
|
|
||||||
|
if info is None:
|
||||||
|
picker = getattr(self._renderer, "pick_entity", None)
|
||||||
|
if picker is None:
|
||||||
|
# Fallback to planar face only.
|
||||||
|
picker = getattr(self._renderer, "pick_planar_face", None)
|
||||||
|
if picker is None:
|
||||||
|
logger.warning("Renderer has no entity picking support")
|
||||||
|
return
|
||||||
|
pinfo = picker(pos.x(), pos.y())
|
||||||
|
if pinfo is None:
|
||||||
|
logger.info("Connector pick: no planar face under cursor")
|
||||||
|
return
|
||||||
|
owner_obj_id = pinfo.get("owner_obj_id", "")
|
||||||
|
self.connectorPicked.emit(
|
||||||
|
tuple(pinfo["origin"]),
|
||||||
|
tuple(pinfo["normal"]),
|
||||||
|
tuple(pinfo["x_dir"]),
|
||||||
|
"planar_face",
|
||||||
|
pinfo["face"],
|
||||||
|
owner_obj_id,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
info = picker(pos.x(), pos.y())
|
||||||
|
|
||||||
|
if info is None:
|
||||||
|
logger.info("Connector pick: no entity under cursor")
|
||||||
|
return
|
||||||
|
owner_obj_id = info.get("owner_obj_id", "")
|
||||||
|
if not owner_obj_id:
|
||||||
|
return
|
||||||
|
|
||||||
|
entity_type = info["type"]
|
||||||
|
origin = info["position"]
|
||||||
|
normal = info.get("normal") or (0.0, 0.0, 1.0)
|
||||||
|
x_dir = info.get("x_dir") or (1.0, 0.0, 0.0)
|
||||||
|
|
||||||
|
# For vertices, pick a sensible normal from the parent face if possible.
|
||||||
|
if entity_type == "vertex" and normal is None:
|
||||||
|
normal = (0.0, 0.0, 1.0)
|
||||||
|
|
||||||
|
# Package the raw shape appropriately.
|
||||||
|
raw_shape = info.get("face") or info.get("edge") or info.get("vertex")
|
||||||
|
|
||||||
|
self.connectorPicked.emit(
|
||||||
|
tuple(origin),
|
||||||
|
tuple(normal),
|
||||||
|
tuple(x_dir) if x_dir else (1.0, 0.0, 0.0),
|
||||||
|
entity_type,
|
||||||
|
raw_shape,
|
||||||
|
owner_obj_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ─── Assembly move mode (3D drag) ─────────────────────────────────────
|
||||||
|
|
||||||
|
def set_assembly_move_mode(self, enabled: bool) -> None:
|
||||||
|
"""Toggle assembly move mode.
|
||||||
|
|
||||||
|
When enabled, clicking on a body and dragging moves its
|
||||||
|
assembly component in the view plane. Shift+drag moves in Z.
|
||||||
|
"""
|
||||||
|
self._assembly_move_mode = bool(enabled)
|
||||||
|
if enabled:
|
||||||
|
self.setCursor(Qt.SizeAllCursor)
|
||||||
|
elif not self._pick_face_mode and not self._connector_pick_mode:
|
||||||
|
self.unsetCursor()
|
||||||
|
if not enabled:
|
||||||
|
self._move_drag_active = False
|
||||||
|
self._move_owner_obj_id = ""
|
||||||
|
self._move_click_3d = None
|
||||||
|
self._move_click_screen = None
|
||||||
|
self._move_plane_normal = None
|
||||||
|
self._move_initial_position = None
|
||||||
|
|
||||||
|
def _handle_assembly_move_press(self, event) -> None:
|
||||||
|
"""Start a drag-to-move for the body under the cursor."""
|
||||||
|
self._ensure_initialized()
|
||||||
|
picker = getattr(self._renderer, "pick_planar_face", None)
|
||||||
|
if picker is None:
|
||||||
|
return
|
||||||
|
pos = event.position().toPoint() if hasattr(event, "position") else event.pos()
|
||||||
|
info = picker(pos.x(), pos.y())
|
||||||
|
if info is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
owner_obj_id = info.get("owner_obj_id", "")
|
||||||
|
if not owner_obj_id or not owner_obj_id.startswith("asm_"):
|
||||||
|
return
|
||||||
|
|
||||||
|
# Store drag state.
|
||||||
|
self._move_drag_active = True
|
||||||
|
self._move_owner_obj_id = owner_obj_id
|
||||||
|
self._move_click_3d = tuple(info["origin"])
|
||||||
|
self._move_click_screen = pos
|
||||||
|
self._move_plane_normal = tuple(info["normal"])
|
||||||
|
|
||||||
|
# Emit activation signal so MainWindow stores initial position.
|
||||||
|
self.assemblyComponentActivated.emit(owner_obj_id)
|
||||||
|
|
||||||
|
def _handle_assembly_move_move(self, event) -> None:
|
||||||
|
"""Continue the drag: project mouse delta to world-space and emit."""
|
||||||
|
if not self._move_drag_active or self._move_click_screen is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
pos = event.position().toPoint() if hasattr(event, "position") else event.pos()
|
||||||
|
|
||||||
|
# Screen delta (Qt Y is inverted vs OCC).
|
||||||
|
dx = pos.x() - self._move_click_screen.x()
|
||||||
|
dy = -(pos.y() - self._move_click_screen.y()) # invert Y
|
||||||
|
|
||||||
|
# Convert screen delta to world units using the view scale.
|
||||||
|
# view.Scale() returns a scale factor — the smaller the value the
|
||||||
|
# more world distance per pixel. We use an empirical conversion:
|
||||||
|
# at scale=1.0, ~1 pixel ≈ 0.3 world units at typical depth.
|
||||||
|
scale = self._renderer._view.Scale() if hasattr(self._renderer, "_view") else 1.0
|
||||||
|
world_per_pixel = 2.0 / max(scale, 0.001)
|
||||||
|
|
||||||
|
# Get camera vectors for proper view-plane projection.
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Get camera direction and up from the OCC view.
|
||||||
|
camera = self._renderer._view.Camera()
|
||||||
|
dir_ = camera.Direction()
|
||||||
|
up_ = camera.Up()
|
||||||
|
cam_dir = np.array([dir_.X(), dir_.Y(), dir_.Z()])
|
||||||
|
cam_up = np.array([up_.X(), up_.Y(), up_.Z()])
|
||||||
|
cam_right = np.cross(cam_dir, cam_up)
|
||||||
|
cam_right = cam_right / np.linalg.norm(cam_right)
|
||||||
|
cam_up = cam_up / np.linalg.norm(cam_up)
|
||||||
|
except Exception:
|
||||||
|
# Fallback: assume XY plane.
|
||||||
|
cam_right = np.array([1.0, 0.0, 0.0])
|
||||||
|
cam_up = np.array([0.0, 0.0, 1.0])
|
||||||
|
|
||||||
|
# Compute world-space delta.
|
||||||
|
modifiers = event.modifiers()
|
||||||
|
if modifiers & Qt.ShiftModifier:
|
||||||
|
# Shift+drag: move along camera direction (Z-depth).
|
||||||
|
dz_world = dx * world_per_pixel
|
||||||
|
dx_world = 0.0
|
||||||
|
dy_world = 0.0
|
||||||
|
else:
|
||||||
|
# Normal drag: move in view plane.
|
||||||
|
dx_world = float(cam_right[0] * dx * world_per_pixel + cam_up[0] * dy * world_per_pixel)
|
||||||
|
dy_world = float(cam_right[1] * dx * world_per_pixel + cam_up[1] * dy * world_per_pixel)
|
||||||
|
dz_world = float(cam_right[2] * dx * world_per_pixel + cam_up[2] * dy * world_per_pixel)
|
||||||
|
|
||||||
|
self.assemblyComponentDragged.emit(self._move_owner_obj_id, dx_world, dy_world, dz_world)
|
||||||
|
|
||||||
|
def _handle_assembly_move_release(self, event) -> None:
|
||||||
|
"""Finish the drag, emit final position."""
|
||||||
|
self.assemblyMoveFinished.emit(self._move_owner_obj_id)
|
||||||
|
self._move_drag_active = False
|
||||||
|
self._move_owner_obj_id = ""
|
||||||
|
self._move_click_3d = None
|
||||||
|
self._move_click_screen = None
|
||||||
|
self._move_plane_normal = None
|
||||||
|
self._move_initial_position = None
|
||||||
|
|
||||||
|
def _handle_face_pick(self, event) -> None:
|
||||||
|
"""Detect a planar face under the click and emit facePicked."""
|
||||||
|
self._ensure_initialized()
|
||||||
|
picker = getattr(self._renderer, "pick_planar_face", None)
|
||||||
|
if picker is None:
|
||||||
|
logger.warning("Renderer has no pick_planar_face support")
|
||||||
|
return
|
||||||
|
# Qt6: prefer position().toPoint() over deprecated pos().
|
||||||
|
pos = event.position().toPoint() if hasattr(event, "position") else event.pos()
|
||||||
|
info = picker(pos.x(), pos.y())
|
||||||
|
if info is None:
|
||||||
|
logger.info("Face pick: no planar face under cursor")
|
||||||
|
return
|
||||||
|
# Stash the owning obj_id so MainWindow._on_face_picked can pair the
|
||||||
|
# picked face with the body it belongs to (for auto-targeted cut).
|
||||||
|
self._last_pick_owner_obj_id = info.get("owner_obj_id")
|
||||||
|
self.facePicked.emit(
|
||||||
|
tuple(info["origin"]),
|
||||||
|
tuple(info["normal"]),
|
||||||
|
tuple(info["x_dir"]),
|
||||||
|
info["face"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def set_view(self, view: str):
|
||||||
|
# Prefer the renderer's native orientation snap (preserves target,
|
||||||
|
# refits the scene). Falls back to absolute eye positions for
|
||||||
|
# renderers that don't implement set_view_orientation.
|
||||||
|
self._ensure_initialized()
|
||||||
|
if hasattr(self._renderer, "set_view_orientation"):
|
||||||
|
self._renderer.set_view_orientation(view)
|
||||||
|
self._renderer.render()
|
||||||
|
return
|
||||||
|
positions = {
|
||||||
|
"iso": ((100, 100, 100), (0, 0, 0)),
|
||||||
|
"top": ((0, 0, 200), (0, 0, 0)),
|
||||||
|
"front": ((0, -200, 0), (0, 0, 0)),
|
||||||
|
"right": ((200, 0, 0), (0, 0, 0)),
|
||||||
|
"back": ((0, 200, 0), (0, 0, 0)),
|
||||||
|
"left": ((-200, 0, 0), (0, 0, 0)),
|
||||||
|
"bottom": ((0, 0, -200), (0, 0, 0)),
|
||||||
|
}
|
||||||
|
if view in positions:
|
||||||
|
pos, target = positions[view]
|
||||||
|
self.set_camera_position(pos, target)
|
||||||
|
|
||||||
|
def mouseDoubleClickEvent(self, event):
|
||||||
|
# Double-click → fit all (common CAD convention).
|
||||||
|
self._ensure_initialized()
|
||||||
|
if event.button() == Qt.LeftButton:
|
||||||
|
self.fit_camera()
|
||||||
|
super().mouseDoubleClickEvent(event)
|
||||||
|
|
||||||
|
def keyPressEvent(self, event):
|
||||||
|
# Esc cancels face-pick mode.
|
||||||
|
if self._pick_face_mode and event.key() == Qt.Key_Escape:
|
||||||
|
self.set_pick_face_mode(False)
|
||||||
|
self.pickFaceCancelled.emit()
|
||||||
|
return
|
||||||
|
# Esc cancels connector pick mode.
|
||||||
|
if self._connector_pick_mode and event.key() == Qt.Key_Escape:
|
||||||
|
self.set_connector_pick_mode(False)
|
||||||
|
self.connectorPickCancelled.emit()
|
||||||
|
return
|
||||||
|
# Esc cancels assembly move mode.
|
||||||
|
if self._assembly_move_mode and event.key() == Qt.Key_Escape:
|
||||||
|
self.set_assembly_move_mode(False)
|
||||||
|
return
|
||||||
|
# Navigation shortcuts (lowercase = view presets, F = fit,
|
||||||
|
# P/O = perspective/orthographic, R = reset).
|
||||||
|
self._ensure_initialized()
|
||||||
|
key = event.text().lower()
|
||||||
|
mapping = {
|
||||||
|
"f": "fit",
|
||||||
|
"r": "reset",
|
||||||
|
"1": "front",
|
||||||
|
"2": "back",
|
||||||
|
"3": "top",
|
||||||
|
"4": "bottom",
|
||||||
|
"5": "left",
|
||||||
|
"6": "right",
|
||||||
|
"7": "iso",
|
||||||
|
}
|
||||||
|
action = mapping.get(key)
|
||||||
|
if action == "fit":
|
||||||
|
self.fit_camera()
|
||||||
|
return
|
||||||
|
if action == "reset":
|
||||||
|
if hasattr(self._renderer, "reset_camera"):
|
||||||
|
self._renderer.reset_camera()
|
||||||
|
self._renderer.render()
|
||||||
|
else:
|
||||||
|
self.set_view("iso")
|
||||||
|
return
|
||||||
|
if action in ("front", "back", "top", "bottom", "left", "right", "iso"):
|
||||||
|
self.set_view(action)
|
||||||
|
return
|
||||||
|
if key == "p" and hasattr(self._renderer, "set_camera_perspective"):
|
||||||
|
self._renderer.set_camera_perspective()
|
||||||
|
self._renderer.render()
|
||||||
|
return
|
||||||
|
if key == "o" and hasattr(self._renderer, "set_camera_orthographic"):
|
||||||
|
self._renderer.set_camera_orthographic()
|
||||||
|
self._renderer.render()
|
||||||
|
return
|
||||||
|
super().keyPressEvent(event)
|
||||||
@@ -1,146 +0,0 @@
|
|||||||
Metadata-Version: 2.4
|
|
||||||
Name: fluency-cad
|
|
||||||
Version: 2.0.0
|
|
||||||
Summary: Parametric CAD application with OpenCASCADE geometry kernel
|
|
||||||
Author: Fluency CAD Team
|
|
||||||
License: MIT
|
|
||||||
Project-URL: Homepage, https://github.com/fluency-cad/fluency
|
|
||||||
Project-URL: Documentation, https://github.com/fluency-cad/fluency#readme
|
|
||||||
Project-URL: Repository, https://github.com/fluency-cad/fluency
|
|
||||||
Keywords: cad,parametric,opencascade,3d-modeling
|
|
||||||
Classifier: Development Status :: 4 - Beta
|
|
||||||
Classifier: Intended Audience :: Developers
|
|
||||||
Classifier: Intended Audience :: End Users/Desktop
|
|
||||||
Classifier: License :: OSI Approved :: MIT License
|
|
||||||
Classifier: Programming Language :: Python :: 3
|
|
||||||
Classifier: Programming Language :: Python :: 3.10
|
|
||||||
Classifier: Programming Language :: Python :: 3.11
|
|
||||||
Classifier: Programming Language :: Python :: 3.12
|
|
||||||
Classifier: Topic :: Scientific/Engineering :: CAD
|
|
||||||
Requires-Python: >=3.10
|
|
||||||
Description-Content-Type: text/markdown
|
|
||||||
Requires-Dist: cadquery>=2.4
|
|
||||||
Requires-Dist: pygfx>=0.1.0
|
|
||||||
Requires-Dist: wgpu>=0.1.0
|
|
||||||
Requires-Dist: PySide6>=6.4.0
|
|
||||||
Requires-Dist: numpy>=1.24.0
|
|
||||||
Requires-Dist: scipy>=1.10.0
|
|
||||||
Requires-Dist: pillow>=10.0.0
|
|
||||||
Provides-Extra: dev
|
|
||||||
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
||||||
Requires-Dist: black>=24.0; extra == "dev"
|
|
||||||
Requires-Dist: mypy>=1.8; extra == "dev"
|
|
||||||
Requires-Dist: ruff>=0.4.0; extra == "dev"
|
|
||||||
|
|
||||||
# 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 CadQuery
|
|
||||||
- **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 | High-level OpenCASCADE Python bindings |
|
|
||||||
| ocp | Low-level OpenCASCADE Python bindings |
|
|
||||||
| 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) | CadQuery (integrated) |
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
MIT License
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
README.md
|
|
||||||
pyproject.toml
|
|
||||||
src/fluency/__init__.py
|
|
||||||
src/fluency/main.py
|
|
||||||
src/fluency/geometry/__init__.py
|
|
||||||
src/fluency/geometry/base.py
|
|
||||||
src/fluency/geometry_occ/__init__.py
|
|
||||||
src/fluency/geometry_occ/kernel.py
|
|
||||||
src/fluency/geometry_occ/sketch.py
|
|
||||||
src/fluency/models/__init__.py
|
|
||||||
src/fluency/models/data_model.py
|
|
||||||
src/fluency/rendering/__init__.py
|
|
||||||
src/fluency/rendering/base.py
|
|
||||||
src/fluency/rendering/pygfx_renderer.py
|
|
||||||
src/fluency/utils/__init__.py
|
|
||||||
src/fluency/widgets/__init__.py
|
|
||||||
src/fluency_cad.egg-info/PKG-INFO
|
|
||||||
src/fluency_cad.egg-info/SOURCES.txt
|
|
||||||
src/fluency_cad.egg-info/dependency_links.txt
|
|
||||||
src/fluency_cad.egg-info/entry_points.txt
|
|
||||||
src/fluency_cad.egg-info/requires.txt
|
|
||||||
src/fluency_cad.egg-info/top_level.txt
|
|
||||||
tests/test_geometry.py
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
[console_scripts]
|
|
||||||
fluency-cad = fluency.main:main
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
cadquery>=2.4
|
|
||||||
pygfx>=0.1.0
|
|
||||||
wgpu>=0.1.0
|
|
||||||
PySide6>=6.4.0
|
|
||||||
numpy>=1.24.0
|
|
||||||
scipy>=1.10.0
|
|
||||||
pillow>=10.0.0
|
|
||||||
|
|
||||||
[dev]
|
|
||||||
pytest>=8.0
|
|
||||||
black>=24.0
|
|
||||||
mypy>=1.8
|
|
||||||
ruff>=0.4.0
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
fluency
|
|
||||||
Binary file not shown.
@@ -182,6 +182,951 @@ class TestOCCSketch:
|
|||||||
points = sketch.get_points()
|
points = sketch.get_points()
|
||||||
assert len(points) == 0
|
assert len(points) == 0
|
||||||
|
|
||||||
|
def test_workplane_extrude_along_normal(self):
|
||||||
|
"""A sketch on a tilted plane extrudes along that plane's normal."""
|
||||||
|
import math
|
||||||
|
from OCP.GProp import GProp_GProps
|
||||||
|
from OCP.BRepGProp import BRepGProp
|
||||||
|
|
||||||
|
ang = math.radians(35)
|
||||||
|
normal = (math.sin(ang), 0.0, math.cos(ang))
|
||||||
|
x_dir = (math.cos(ang), 0.0, -math.sin(ang))
|
||||||
|
|
||||||
|
sk = OCCSketch()
|
||||||
|
sk.set_workplane((10.0, 0.0, 5.0), normal, x_dir)
|
||||||
|
# 20x20 square in UV
|
||||||
|
p0 = sk.add_point(-10, -10); p1 = sk.add_point(10, -10)
|
||||||
|
p2 = sk.add_point(10, 10); p3 = sk.add_point(-10, 10)
|
||||||
|
sk.add_line(p0, p1); sk.add_line(p1, p2)
|
||||||
|
sk.add_line(p2, p3); sk.add_line(p3, p0)
|
||||||
|
|
||||||
|
geom = sk.get_geometry()
|
||||||
|
# The face must carry the plane normal for the kernel.
|
||||||
|
assert geom.metadata.get("normal") is not None
|
||||||
|
|
||||||
|
kernel = OCGeometryKernel()
|
||||||
|
solid = kernel.extrude(geom, 15.0)
|
||||||
|
s = kernel._get_shape(solid)
|
||||||
|
g = GProp_GProps()
|
||||||
|
BRepGProp.VolumeProperties_s(s, g)
|
||||||
|
# 20 * 20 * 15 = 6000 regardless of plane orientation.
|
||||||
|
assert abs(g.Mass() - 6000.0) < 0.1
|
||||||
|
|
||||||
|
def test_workplane_extrude_with_hole(self):
|
||||||
|
"""A square with a circular hole on a custom plane extrudes correctly."""
|
||||||
|
import math
|
||||||
|
from OCP.GProp import GProp_GProps
|
||||||
|
from OCP.BRepGProp import BRepGProp
|
||||||
|
|
||||||
|
sk = OCCSketch()
|
||||||
|
sk.set_workplane((0, 0, 0), (0, 0, 1), (1, 0, 0))
|
||||||
|
a = sk.add_point(-10, -10); b = sk.add_point(10, -10)
|
||||||
|
c = sk.add_point(10, 10); d = sk.add_point(-10, 10)
|
||||||
|
sk.add_line(a, b); sk.add_line(b, c)
|
||||||
|
sk.add_line(c, d); sk.add_line(d, a)
|
||||||
|
ctr = sk.add_point(0, 0)
|
||||||
|
sk.add_circle(ctr, 3.0)
|
||||||
|
|
||||||
|
geom = sk.get_geometry()
|
||||||
|
kernel = OCGeometryKernel()
|
||||||
|
solid = kernel.extrude(geom, 5.0)
|
||||||
|
s = kernel._get_shape(solid)
|
||||||
|
g = GProp_GProps()
|
||||||
|
BRepGProp.VolumeProperties_s(s, g)
|
||||||
|
expected = 20 * 20 * 5 - math.pi * 3 * 3 * 5
|
||||||
|
assert abs(g.Mass() - expected) < 0.1
|
||||||
|
|
||||||
|
|
||||||
|
class TestExternalEntities:
|
||||||
|
"""Tests for the underlay / face-projected reference entity API.
|
||||||
|
|
||||||
|
External entities live in the solver so user constraints can reference
|
||||||
|
them (e.g. "hole center 50 mm from the body's top edge"), but they
|
||||||
|
are *not* part of the sketch profile and must be excluded from
|
||||||
|
detect_faces / get_geometry.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_add_external_point_flags_and_fixes(self):
|
||||||
|
sk = OCCSketch()
|
||||||
|
ep = sk.add_external_point(5.0, 7.0)
|
||||||
|
assert ep is not None
|
||||||
|
assert ep.is_external is True
|
||||||
|
assert ep.is_construction is True
|
||||||
|
# External point is in the solver, with a non-None handle.
|
||||||
|
assert ep.handle is not None
|
||||||
|
# The point is in the entities / points dicts.
|
||||||
|
assert ep.id in sk._entities
|
||||||
|
assert ep.id in sk._points
|
||||||
|
# It's tracked as external.
|
||||||
|
assert ep.id in sk.get_external_entity_ids()
|
||||||
|
|
||||||
|
def test_add_external_line_requires_external_endpoints(self):
|
||||||
|
sk = OCCSketch()
|
||||||
|
a = sk.add_external_point(0, 0)
|
||||||
|
b = sk.add_external_point(10, 0)
|
||||||
|
line = sk.add_external_line(a, b)
|
||||||
|
assert line is not None
|
||||||
|
assert line.is_external is True
|
||||||
|
assert line.is_construction is True
|
||||||
|
assert line.handle is not None
|
||||||
|
assert line.id in sk._lines
|
||||||
|
assert line.id in sk.get_external_entity_ids()
|
||||||
|
|
||||||
|
def test_add_external_polyline_shares_corners(self):
|
||||||
|
sk = OCCSketch()
|
||||||
|
# Closed rectangle: 4 unique corners reused at the joints.
|
||||||
|
pts = [(0, 0), (10, 0), (10, 10), (0, 10), (0, 0)]
|
||||||
|
points, lines = sk.add_external_polyline(pts)
|
||||||
|
# 4 segments, 5 UV samples but the 1st and last are the same corner.
|
||||||
|
assert len(lines) == 4
|
||||||
|
# The 5 samples share the rectangle's 4 corners → 4 unique point entities.
|
||||||
|
assert len(set(p.id for p in points)) == 4
|
||||||
|
# All are external.
|
||||||
|
assert all(p.is_external for p in points)
|
||||||
|
assert all(ln.is_external for ln in lines)
|
||||||
|
|
||||||
|
def test_external_entities_excluded_from_detect_faces(self):
|
||||||
|
sk = OCCSketch()
|
||||||
|
# Underlay: a 20x20 square projected from a face (closed polyline).
|
||||||
|
sk.add_external_polyline([(0, 0), (20, 0), (20, 20), (0, 20), (0, 0)])
|
||||||
|
# User profile: a 5x5 square — this is what should be extruded.
|
||||||
|
a = sk.add_point(2, 2); b = sk.add_point(8, 2)
|
||||||
|
c = sk.add_point(8, 8); d = sk.add_point(2, 8)
|
||||||
|
sk.add_line(a, b); sk.add_line(b, c)
|
||||||
|
sk.add_line(c, d); sk.add_line(d, a)
|
||||||
|
faces = sk.detect_faces()
|
||||||
|
# Only the user-drawn face (5x5 square) should be detected.
|
||||||
|
assert len(faces) == 1
|
||||||
|
outer = faces[0]["outer"]
|
||||||
|
assert outer["type"] == "polygon"
|
||||||
|
# 5 vertices on the outer loop (4 corners + closing point).
|
||||||
|
assert len(outer["points"]) == 5
|
||||||
|
# It must be the user square, not the underlay.
|
||||||
|
xs = [p[0] for p in outer["points"][:4]]
|
||||||
|
ys = [p[1] for p in outer["points"][:4]]
|
||||||
|
assert min(xs) >= 2 and max(xs) <= 8
|
||||||
|
assert min(ys) >= 2 and max(ys) <= 8
|
||||||
|
|
||||||
|
def test_external_entities_excluded_from_get_polygon_points(self):
|
||||||
|
sk = OCCSketch()
|
||||||
|
sk.add_external_polyline([(0, 0), (100, 0), (100, 100), (0, 100), (0, 0)])
|
||||||
|
a = sk.add_point(1, 1); b = sk.add_point(2, 1)
|
||||||
|
c = sk.add_point(2, 2); d = sk.add_point(1, 2)
|
||||||
|
sk.add_line(a, b); sk.add_line(b, c)
|
||||||
|
sk.add_line(c, d); sk.add_line(d, a)
|
||||||
|
poly = sk.get_polygon_points()
|
||||||
|
# The user square (1..2 range) should appear, not the 0..100 underlay.
|
||||||
|
assert all(1.0 <= p.x <= 2.0 for p in poly)
|
||||||
|
assert all(1.0 <= p.y <= 2.0 for p in poly)
|
||||||
|
|
||||||
|
def test_external_entities_excluded_from_get_geometry(self):
|
||||||
|
"""Underlay must never appear in the extruded face."""
|
||||||
|
from OCP.GProp import GProp_GProps
|
||||||
|
from OCP.BRepGProp import BRepGProp
|
||||||
|
|
||||||
|
sk = OCCSketch()
|
||||||
|
# Underlay (NOT to be extruded).
|
||||||
|
sk.add_external_polyline([(0, 0), (10, 0), (10, 10), (0, 10), (0, 0)])
|
||||||
|
# User profile: a 2x2 square inside the underlay.
|
||||||
|
a = sk.add_point(1, 1); b = sk.add_point(3, 1)
|
||||||
|
c = sk.add_point(3, 3); d = sk.add_point(1, 3)
|
||||||
|
sk.add_line(a, b); sk.add_line(b, c)
|
||||||
|
sk.add_line(c, d); sk.add_line(d, a)
|
||||||
|
geom = sk.get_geometry()
|
||||||
|
# Volume = 2 * 2 * 4 = 16, NOT 10 * 10 * 4 = 400.
|
||||||
|
kernel = OCGeometryKernel()
|
||||||
|
solid = kernel.extrude(geom, 4.0)
|
||||||
|
s = kernel._get_shape(solid)
|
||||||
|
g = GProp_GProps()
|
||||||
|
BRepGProp.VolumeProperties_s(s, g)
|
||||||
|
assert abs(g.Mass() - 16.0) < 0.5
|
||||||
|
|
||||||
|
def test_distance_to_external_point_constraint(self):
|
||||||
|
"""The headline use case: hole position fixed relative to a face edge.
|
||||||
|
|
||||||
|
User draws a circle (the hole) and a distance from its centre to
|
||||||
|
a face-projected point. After solve, the circle centre should be
|
||||||
|
exactly the requested distance from the external point.
|
||||||
|
"""
|
||||||
|
sk = OCCSketch()
|
||||||
|
# Underlay corner: pick a known anchor on the projected face.
|
||||||
|
anchor = sk.add_external_point(0.0, 0.0)
|
||||||
|
# User geometry: a 1mm circle for the hole.
|
||||||
|
hole_centre = sk.add_point(7.0, 4.0) # start position: 7 from anchor
|
||||||
|
sk.add_circle(hole_centre, 1.0)
|
||||||
|
# Constrain the hole centre 50 mm from the underlay corner.
|
||||||
|
ok = sk.constrain_distance(anchor, hole_centre, 50.0)
|
||||||
|
assert ok
|
||||||
|
assert sk.solve()
|
||||||
|
solved = sk.get_solved_point(hole_centre.id)
|
||||||
|
assert solved is not None
|
||||||
|
# The starting (7, 4) is well short of 50, so the constraint
|
||||||
|
# forces the centre out to a point on the 50mm circle around (0,0).
|
||||||
|
x, y = solved
|
||||||
|
assert abs(math_hypot(x, y) - 50.0) < 0.01
|
||||||
|
|
||||||
|
def test_remove_external_entities_clears_them(self):
|
||||||
|
sk = OCCSketch()
|
||||||
|
sk.add_external_polyline([(0, 0), (10, 0), (10, 10), (0, 10), (0, 0)])
|
||||||
|
assert len(sk.get_external_entity_ids()) > 0
|
||||||
|
sk.remove_external_entities()
|
||||||
|
assert len(sk.get_external_entity_ids()) == 0
|
||||||
|
# No external points/lines left in the tracking dicts.
|
||||||
|
for eid in sk._entities:
|
||||||
|
assert not getattr(sk._entities[eid], "is_external", False)
|
||||||
|
|
||||||
|
def test_remove_external_entities_prunes_related_constraints(self):
|
||||||
|
"""Constraints referencing external entities are pruned on removal.
|
||||||
|
|
||||||
|
A distance to an external point is recorded in the constraint log
|
||||||
|
on the ids of both endpoints. After remove_external_entities(),
|
||||||
|
those entries are gone and the solver rebuilds without them.
|
||||||
|
"""
|
||||||
|
sk = OCCSketch()
|
||||||
|
anchor = sk.add_external_point(0.0, 0.0)
|
||||||
|
user = sk.add_point(20.0, 0.0)
|
||||||
|
sk.constrain_distance(anchor, user, 5.0)
|
||||||
|
sk.solve()
|
||||||
|
# At least one log entry references the external anchor.
|
||||||
|
assert any(anchor.id in entry["ids"] for entry in sk._constraint_log)
|
||||||
|
# Now wipe the underlay.
|
||||||
|
sk.remove_external_entities()
|
||||||
|
# The distance constraint is gone, and the user point is free.
|
||||||
|
assert not any(anchor.id in entry["ids"] for entry in sk._constraint_log)
|
||||||
|
assert sk.solve()
|
||||||
|
|
||||||
|
def test_external_polyline_dedupes_close_points(self):
|
||||||
|
"""Co-located UV samples share a single point entity (closed loops)."""
|
||||||
|
sk = OCCSketch()
|
||||||
|
# Closed rectangle (closing point == start point).
|
||||||
|
pts = [(1.0, 1.0), (9.0, 1.0), (9.0, 9.0), (1.0, 9.0), (1.0, 1.0)]
|
||||||
|
points, lines = sk.add_external_polyline(pts)
|
||||||
|
# 5 samples → 4 unique points (start/end collapse).
|
||||||
|
assert len(set(p.id for p in points)) == 4
|
||||||
|
# 4 segments connect them.
|
||||||
|
assert len(lines) == 4
|
||||||
|
# Every line's endpoints are among the 4 points.
|
||||||
|
point_ids = {p.id for p in points}
|
||||||
|
for line_id, (sid, eid2) in sk._lines.items():
|
||||||
|
if line_id in sk.get_external_entity_ids():
|
||||||
|
assert sid in point_ids and eid2 in point_ids
|
||||||
|
|
||||||
|
def test_external_point_is_solver_fixed(self):
|
||||||
|
"""An external point's solver parameters must not change on re-solve.
|
||||||
|
|
||||||
|
python_solvespace drags the first user point; external points use
|
||||||
|
``dragged`` directly so dragging a user point near an external
|
||||||
|
reference doesn't shift the reference.
|
||||||
|
"""
|
||||||
|
sk = OCCSketch()
|
||||||
|
ep = sk.add_external_point(3.0, 4.0)
|
||||||
|
# Add a user point; solve; record the external point's solved
|
||||||
|
# params. Then delete the user point and add another one; the
|
||||||
|
# external point's params must not have moved.
|
||||||
|
sk.add_point(100.0, 0.0)
|
||||||
|
sk.solve()
|
||||||
|
x0, y0 = sk.solver.params(ep.handle.params)
|
||||||
|
for dx in range(-5, 6):
|
||||||
|
sk.add_point(100.0 + dx, 0.0)
|
||||||
|
sk.solve()
|
||||||
|
x1, y1 = sk.solver.params(ep.handle.params)
|
||||||
|
assert abs(x1 - x0) < 1e-6
|
||||||
|
assert abs(y1 - y0) < 1e-6
|
||||||
|
|
||||||
|
def test_horizontal_constraint_on_external_line(self):
|
||||||
|
"""Horizontal constraint involving a partly-external line is solvable.
|
||||||
|
|
||||||
|
Both external endpoints are dragged (fixed), so a horizontal
|
||||||
|
constraint between them is over-determined when their y values
|
||||||
|
differ. To make the system solvable we add a free user point
|
||||||
|
connected to one external point via a line, then constrain that
|
||||||
|
line horizontal — the user endpoint is dragged to a y that
|
||||||
|
matches the external one, satisfying the constraint.
|
||||||
|
"""
|
||||||
|
sk = OCCSketch()
|
||||||
|
a = sk.add_external_point(0.0, 0.0)
|
||||||
|
# Add a free user point first (skipped auto-drag because external
|
||||||
|
# points exist, so this one is free).
|
||||||
|
free = sk.add_point(7.0, 5.0)
|
||||||
|
# And an external endpoint to pair with the free point in a line.
|
||||||
|
b = sk.add_external_point(0.0, 0.0)
|
||||||
|
line = sk.add_external_line(b, free)
|
||||||
|
# Constrain it horizontal; the free point should drop to y=0.
|
||||||
|
sk.constrain_horizontal(line)
|
||||||
|
assert sk.solve()
|
||||||
|
sa = sk.get_solved_point(b.id)
|
||||||
|
sf = sk.get_solved_point(free.id)
|
||||||
|
assert sa is not None and sf is not None
|
||||||
|
assert abs(sa[1] - sf[1]) < 1e-6
|
||||||
|
|
||||||
|
def test_cleared_sketch_drops_external_entities(self):
|
||||||
|
sk = OCCSketch()
|
||||||
|
sk.add_external_polyline([(0, 0), (1, 0), (1, 1), (0, 1), (0, 0)])
|
||||||
|
sk.add_point(5, 5)
|
||||||
|
assert len(sk.get_external_entity_ids()) > 0
|
||||||
|
sk.clear()
|
||||||
|
assert len(sk.get_external_entity_ids()) == 0
|
||||||
|
assert sk.get_entity_count() == 0
|
||||||
|
|
||||||
|
|
||||||
|
class TestExtrudeCutFix:
|
||||||
|
"""Tests for the cut/union logic in MainWindow._extrude_sketch.
|
||||||
|
|
||||||
|
The old code stored the boolean result in the *tool* (newly extruded)
|
||||||
|
body, leaving the *target* body untouched — so the user would see a
|
||||||
|
separate "cavity-shaped" body next to the original instead of a
|
||||||
|
cavity in the original. After deleting that extra body, the next
|
||||||
|
extrude-cutter saw ``len(existing) <= 1`` and silently skipped the
|
||||||
|
cut, producing an unconstrained new body that looked "added without
|
||||||
|
cut". The fix:
|
||||||
|
|
||||||
|
1. Apply the boolean to the *target* (existing[0]) body.
|
||||||
|
2. Remove the tool body from the component.
|
||||||
|
3. Re-render the target in place.
|
||||||
|
|
||||||
|
These tests verify the boolean operation produces the right solid and
|
||||||
|
that the post-extrude bookkeeping leaves exactly the right bodies
|
||||||
|
in the component.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_boolean_difference_modifies_target_not_tool(self):
|
||||||
|
"""The fix: cut goes into the target, tool is removed.
|
||||||
|
|
||||||
|
Reproduces the cut/merge flow from ``_extrude_sketch`` without
|
||||||
|
spinning up the full MainWindow: build a target + tool body,
|
||||||
|
run boolean_difference, then verify the target's volume dropped
|
||||||
|
and the tool is no longer needed.
|
||||||
|
"""
|
||||||
|
from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox
|
||||||
|
from fluency.geometry_occ.kernel import OCGeometryKernel, OCCGeometryObject
|
||||||
|
from OCP.GProp import GProp_GProps
|
||||||
|
from OCP.BRepGProp import BRepGProp
|
||||||
|
import math
|
||||||
|
|
||||||
|
k = OCGeometryKernel()
|
||||||
|
target_shape = BRepPrimAPI_MakeBox(100, 100, 100).Shape()
|
||||||
|
target_obj = OCCGeometryObject(target_shape, {"type": "box"})
|
||||||
|
|
||||||
|
# Tool: a 20x20x200 cuboid at the corner of the box, to make the
|
||||||
|
# expected volume easy to compute.
|
||||||
|
from OCP.BRepPrimAPI import BRepPrimAPI_MakePrism
|
||||||
|
from OCP.gp import gp_Pnt, gp_Vec
|
||||||
|
# 20x20 square at (0,0,0), extruded along +Z by 200.
|
||||||
|
from OCP.BRepBuilderAPI import BRepBuilderAPI_MakePolygon
|
||||||
|
mp = BRepBuilderAPI_MakePolygon()
|
||||||
|
for (x, y) in [(0, 0), (20, 0), (20, 20), (0, 20)]:
|
||||||
|
mp.Add(gp_Pnt(x, y, 0))
|
||||||
|
mp.Close()
|
||||||
|
from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeFace
|
||||||
|
face = BRepBuilderAPI_MakeFace(mp.Wire()).Face()
|
||||||
|
tool_shape = BRepPrimAPI_MakePrism(
|
||||||
|
face, gp_Vec(0, 0, 200)
|
||||||
|
).Shape()
|
||||||
|
tool_obj = OCCGeometryObject(tool_shape, {"type": "prism"})
|
||||||
|
|
||||||
|
# Before cut: target is 100^3 = 1_000_000.
|
||||||
|
g0 = GProp_GProps()
|
||||||
|
BRepGProp.VolumeProperties_s(k._get_shape(target_obj), g0)
|
||||||
|
assert abs(g0.Mass() - 1_000_000.0) < 1.0
|
||||||
|
|
||||||
|
# Apply the fix: result goes to the target, not the tool.
|
||||||
|
result = k.boolean_difference(target_obj, tool_obj)
|
||||||
|
target_obj_geometry = result
|
||||||
|
|
||||||
|
# After cut: target is 1_000_000 - 20*20*100 = 960_000
|
||||||
|
# (the prism only intersects the box in z=[0,100], i.e. 100 deep).
|
||||||
|
g1 = GProp_GProps()
|
||||||
|
BRepGProp.VolumeProperties_s(
|
||||||
|
k._get_shape(target_obj_geometry), g1
|
||||||
|
)
|
||||||
|
assert abs(g1.Mass() - 960_000.0) < 1.0
|
||||||
|
|
||||||
|
def test_boolean_difference_does_not_leave_separate_cavity_body(self):
|
||||||
|
"""Sanity: the cut result is a single body (not two).
|
||||||
|
|
||||||
|
The OLD bug stored the cut result in a SECOND body, so after a
|
||||||
|
cut the user would see the original body PLUS a "cavity-shaped"
|
||||||
|
body — the user thought the cut worked but it was just two
|
||||||
|
separate solids. With the fix the cut is folded into the
|
||||||
|
target, so a single body remains.
|
||||||
|
"""
|
||||||
|
from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox
|
||||||
|
from OCP.BRepAlgoAPI import BRepAlgoAPI_Cut
|
||||||
|
from OCP.TopExp import TopExp_Explorer
|
||||||
|
from OCP.TopAbs import TopAbs_SOLID
|
||||||
|
from fluency.geometry_occ.kernel import OCGeometryKernel, OCCGeometryObject
|
||||||
|
|
||||||
|
k = OCGeometryKernel()
|
||||||
|
target_shape = BRepPrimAPI_MakeBox(100, 100, 100).Shape()
|
||||||
|
target_obj = OCCGeometryObject(target_shape, {})
|
||||||
|
|
||||||
|
# Tool: small box at the centre, fully inside the target.
|
||||||
|
from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox as BBox
|
||||||
|
tool_shape = BBox(20, 20, 20).Shape()
|
||||||
|
tool_obj = OCCGeometryObject(tool_shape, {})
|
||||||
|
|
||||||
|
# The fixed cut flow:
|
||||||
|
# 1. Apply boolean to target.
|
||||||
|
# 2. Remove the tool from the component dict.
|
||||||
|
result = k.boolean_difference(target_obj, tool_obj)
|
||||||
|
target_obj.geometry = result # the fix: result goes in target
|
||||||
|
# (the tool_obj is then discarded; the simulated flow above
|
||||||
|
# keeps it locally but doesn't use it for display).
|
||||||
|
|
||||||
|
# Count solids in the cut result. It should be exactly 1 (the
|
||||||
|
# target with a cavity), not 2 (target + cavity-shaped tool).
|
||||||
|
shape = k._get_shape(target_obj)
|
||||||
|
explorer = TopExp_Explorer(shape, TopAbs_SOLID)
|
||||||
|
n_solids = 0
|
||||||
|
while explorer.More():
|
||||||
|
n_solids += 1
|
||||||
|
explorer.Next()
|
||||||
|
assert n_solids == 1, f"Cut result has {n_solids} solids, expected 1"
|
||||||
|
|
||||||
|
|
||||||
|
class TestBodyVisibilityToggle:
|
||||||
|
"""Tests for the per-body visibility toggle on the right-hand body list.
|
||||||
|
|
||||||
|
The user asked for a visibility checkbox per body so they could
|
||||||
|
easily verify whether an operation (e.g. cut) had actually modified
|
||||||
|
a body. Hiding the second body and seeing whether the first still
|
||||||
|
has the cut shape is the intended workflow.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _make_window(self):
|
||||||
|
import os
|
||||||
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
from PySide6.QtWidgets import QApplication
|
||||||
|
app = QApplication.instance() or QApplication([])
|
||||||
|
from fluency.main import MainWindow
|
||||||
|
return MainWindow()
|
||||||
|
|
||||||
|
def test_body_list_uses_checkable_items(self):
|
||||||
|
"""Each body list item must be a checkable QListWidgetItem."""
|
||||||
|
from PySide6.QtCore import Qt
|
||||||
|
win = self._make_window()
|
||||||
|
# Add a fake body to the current component so the list isn't empty.
|
||||||
|
from fluency.models.data_model import Body
|
||||||
|
from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox
|
||||||
|
from fluency.geometry_occ.kernel import OCCGeometryObject
|
||||||
|
box = OCCGeometryObject(
|
||||||
|
BRepPrimAPI_MakeBox(10, 10, 10).Shape(), {}
|
||||||
|
)
|
||||||
|
win._current_component.bodies["a"] = Body(name="A", geometry=box)
|
||||||
|
win._refresh_lists()
|
||||||
|
items = win._body_list.findItems("A", Qt.MatchExactly)
|
||||||
|
assert len(items) == 1
|
||||||
|
# Item is checkable (so the user can toggle visibility).
|
||||||
|
assert items[0].flags() & Qt.ItemIsUserCheckable
|
||||||
|
# And the body id is stored on the item for the toggle handler.
|
||||||
|
assert items[0].data(Qt.UserRole) == "a"
|
||||||
|
# Default state is checked (= visible).
|
||||||
|
assert items[0].checkState() == Qt.Checked
|
||||||
|
|
||||||
|
def test_toggling_visibility_updates_body_model(self):
|
||||||
|
"""Flipping the checkbox should set body.visible accordingly."""
|
||||||
|
from PySide6.QtCore import Qt
|
||||||
|
win = self._make_window()
|
||||||
|
from fluency.models.data_model import Body
|
||||||
|
from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox
|
||||||
|
from fluency.geometry_occ.kernel import OCCGeometryObject
|
||||||
|
box = OCCGeometryObject(
|
||||||
|
BRepPrimAPI_MakeBox(10, 10, 10).Shape(), {}
|
||||||
|
)
|
||||||
|
win._current_component.bodies["a"] = Body(name="A", geometry=box)
|
||||||
|
win._refresh_lists()
|
||||||
|
item = win._body_list.findItems("A", Qt.MatchExactly)[0]
|
||||||
|
|
||||||
|
# Toggle off.
|
||||||
|
item.setCheckState(Qt.Unchecked)
|
||||||
|
win._on_body_visibility_changed(item)
|
||||||
|
assert win._current_component.bodies["a"].visible is False
|
||||||
|
|
||||||
|
# Toggle back on.
|
||||||
|
item.setCheckState(Qt.Checked)
|
||||||
|
win._on_body_visibility_changed(item)
|
||||||
|
assert win._current_component.bodies["a"].visible is True
|
||||||
|
|
||||||
|
def test_visibility_no_op_when_unchanged(self):
|
||||||
|
"""Re-emitting the same state must not trigger a viewer call.
|
||||||
|
|
||||||
|
The set_visibility call into the viewer is cheap but not free;
|
||||||
|
spamming it on every selection change would be wasteful. The
|
||||||
|
handler short-circuits when the new state matches the model's.
|
||||||
|
"""
|
||||||
|
from PySide6.QtCore import Qt
|
||||||
|
win = self._make_window()
|
||||||
|
from fluency.models.data_model import Body
|
||||||
|
from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox
|
||||||
|
from fluency.geometry_occ.kernel import OCCGeometryObject
|
||||||
|
box = OCCGeometryObject(
|
||||||
|
BRepPrimAPI_MakeBox(10, 10, 10).Shape(), {}
|
||||||
|
)
|
||||||
|
win._current_component.bodies["a"] = Body(name="A", geometry=box)
|
||||||
|
win._refresh_lists()
|
||||||
|
item = win._body_list.findItems("A", Qt.MatchExactly)[0]
|
||||||
|
|
||||||
|
# Force the model's visibility to False to mimic a desync.
|
||||||
|
win._current_component.bodies["a"].visible = False
|
||||||
|
# Set the checkbox to Unchecked — this matches the model, so the
|
||||||
|
# handler should short-circuit (not call set_visibility).
|
||||||
|
item.setCheckState(Qt.Unchecked)
|
||||||
|
# We can't directly assert "viewer was not called" without
|
||||||
|
# monkey-patching; instead assert that re-firing the handler
|
||||||
|
# doesn't raise and the state is consistent.
|
||||||
|
win._on_body_visibility_changed(item)
|
||||||
|
assert win._current_component.bodies["a"].visible is False
|
||||||
|
|
||||||
|
|
||||||
|
def math_hypot(x, y):
|
||||||
|
import math
|
||||||
|
return math.hypot(x, y)
|
||||||
|
|
||||||
|
|
||||||
|
class TestConstraintTagRendering:
|
||||||
|
"""Tests for constraint tag rendering when a tag references a line id.
|
||||||
|
|
||||||
|
The constraint log stores entity ids. A constraint that targets a
|
||||||
|
line (e.g. point-on-line coincident) puts a *line* id in the log,
|
||||||
|
and the tag rendering code used to naively unpack that line's
|
||||||
|
geometry ``((x1,y1), (x2,y2))`` as if it were a point's ``(x, y)``,
|
||||||
|
calling ``round()`` on a tuple and raising
|
||||||
|
``TypeError: type tuple doesn't define __round__ method``.
|
||||||
|
These tests pin the fix in ``Sketch2DWidget._compute_constraint_tags``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _make_widget_with_sketch(self, sk):
|
||||||
|
"""Build a Sketch2DWidget in offscreen mode and attach *sk* to it."""
|
||||||
|
import os
|
||||||
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
from PySide6.QtWidgets import QApplication
|
||||||
|
app = QApplication.instance() or QApplication([])
|
||||||
|
from fluency.main import Sketch2DWidget
|
||||||
|
w = Sketch2DWidget()
|
||||||
|
w.set_sketch(sk)
|
||||||
|
return w
|
||||||
|
|
||||||
|
def test_point_on_line_coincident_tag_renders(self):
|
||||||
|
"""A coincident between a point and a line must not crash the paint event.
|
||||||
|
|
||||||
|
Reproduces the user-reported error: ids[1] is a line id, the old
|
||||||
|
code unpacked the line's geometry ``((x1,y1), (x2,y2))`` as a
|
||||||
|
point and called ``round()`` on the inner tuple.
|
||||||
|
"""
|
||||||
|
sk = OCCSketch()
|
||||||
|
a = sk.add_point(0, 0)
|
||||||
|
b = sk.add_point(10, 0)
|
||||||
|
line = sk.add_line(a, b) # 3rd entity: the line itself
|
||||||
|
# Point-on-line: the line id is in the constraint log.
|
||||||
|
p3 = sk.add_point(5, 5)
|
||||||
|
sk.constrain_coincident(p3, line)
|
||||||
|
sk.solve()
|
||||||
|
w = self._make_widget_with_sketch(sk)
|
||||||
|
# Must not raise.
|
||||||
|
tags = w._compute_constraint_tags()
|
||||||
|
# One tag for the coincident.
|
||||||
|
coin_tags = [t for t in tags if "coin" in t["label"]]
|
||||||
|
assert len(coin_tags) == 1
|
||||||
|
# The tag was anchored (non-None center) and renders successfully.
|
||||||
|
assert coin_tags[0]["center"] is not None
|
||||||
|
|
||||||
|
def test_point_world_rejects_line_geometry(self):
|
||||||
|
"""_point_world must return None (not crash) when given a line id."""
|
||||||
|
sk = OCCSketch()
|
||||||
|
a = sk.add_point(0, 0)
|
||||||
|
b = sk.add_point(10, 0)
|
||||||
|
line = sk.add_line(a, b)
|
||||||
|
w = self._make_widget_with_sketch(sk)
|
||||||
|
# Old behaviour: round(<tuple>) raised TypeError.
|
||||||
|
# New behaviour: _point_world returns None for non-point entities.
|
||||||
|
result = w._point_world(line.id)
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
def test_point_world_rejects_circle_geometry(self):
|
||||||
|
"""_point_world must return None for circle entities too.
|
||||||
|
|
||||||
|
A circle's geometry is ``((cx, cy), radius)`` — also not a flat
|
||||||
|
2-tuple of numbers. Same shape check rejects it.
|
||||||
|
"""
|
||||||
|
sk = OCCSketch()
|
||||||
|
c = sk.add_point(0, 0)
|
||||||
|
circle = sk.add_circle(c, 5.0)
|
||||||
|
w = self._make_widget_with_sketch(sk)
|
||||||
|
result = w._point_world(circle.id)
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
def test_entity_anchor_routes_to_line_midpoint(self):
|
||||||
|
"""_entity_anchor returns the line midpoint for line ids."""
|
||||||
|
sk = OCCSketch()
|
||||||
|
a = sk.add_point(0, 0)
|
||||||
|
b = sk.add_point(10, 0)
|
||||||
|
line = sk.add_line(a, b)
|
||||||
|
w = self._make_widget_with_sketch(sk)
|
||||||
|
anchor = w._entity_anchor(line.id)
|
||||||
|
assert anchor is not None
|
||||||
|
# Midpoint of (0,0) and (10,0) is (5, 0).
|
||||||
|
assert anchor.x() == 5
|
||||||
|
assert anchor.y() == 0
|
||||||
|
|
||||||
|
def test_distance_constraint_with_line_id(self):
|
||||||
|
"""A distance constraint involving a line id must not crash.
|
||||||
|
|
||||||
|
Future enhancements might add a point-to-line distance; even
|
||||||
|
without that, the defensive routing through _entity_anchor
|
||||||
|
ensures the tag renders cleanly when such an entry is logged.
|
||||||
|
"""
|
||||||
|
sk = OCCSketch()
|
||||||
|
a = sk.add_point(0, 0)
|
||||||
|
b = sk.add_point(10, 0)
|
||||||
|
line = sk.add_line(a, b)
|
||||||
|
p3 = sk.add_point(15, 5)
|
||||||
|
# Simulate a point-to-line distance by directly appending a log
|
||||||
|
# entry — this matches the solver's surface (it would call
|
||||||
|
# _record_constraint with these ids once a point-to-line
|
||||||
|
# distance is added to the solver wrapper).
|
||||||
|
sk._record_constraint("distance", (p3.id, line.id), (12.0,))
|
||||||
|
w = self._make_widget_with_sketch(sk)
|
||||||
|
tags = w._compute_constraint_tags()
|
||||||
|
dst_tags = [t for t in tags if "dst" in t["label"]]
|
||||||
|
assert len(dst_tags) == 1
|
||||||
|
assert dst_tags[0]["center"] is not None
|
||||||
|
|
||||||
|
def test_paint_tolerates_corrupted_entity_geometry(self):
|
||||||
|
"""Paint must not crash if an entity's geometry is weird.
|
||||||
|
|
||||||
|
Simulates the user-reported case: after constraining many
|
||||||
|
points, the solver log still references an entity whose
|
||||||
|
geometry was corrupted (e.g. line-shape ``((x,y), r)`` on a
|
||||||
|
point entity, a 3-element list, or a value with a __round__
|
||||||
|
that raises). _compute_constraint_tags should drop the bad
|
||||||
|
tag and keep rendering the rest.
|
||||||
|
"""
|
||||||
|
sk = OCCSketch()
|
||||||
|
a = sk.add_point(0, 0)
|
||||||
|
b = sk.add_point(10, 0)
|
||||||
|
c = sk.add_point(5, 5)
|
||||||
|
sk.constrain_coincident(c, a)
|
||||||
|
sk.solve()
|
||||||
|
w = self._make_widget_with_sketch(sk)
|
||||||
|
|
||||||
|
# Case 1: point entity has line-shape geometry.
|
||||||
|
sk._entities[c.id].geometry = ((1.0, 2.0), 3.0)
|
||||||
|
tags = w._compute_constraint_tags()
|
||||||
|
# Bad entry is dropped; the good one still renders.
|
||||||
|
assert all(t["center"] is not None for t in tags)
|
||||||
|
|
||||||
|
# Case 2: wrong-shape geometry (3-element list).
|
||||||
|
sk._entities[c.id].geometry = [1.0, 2.0, 3.0]
|
||||||
|
tags = w._compute_constraint_tags()
|
||||||
|
assert all(t["center"] is not None for t in tags)
|
||||||
|
|
||||||
|
# Case 3: exotic type whose __round__ raises.
|
||||||
|
class _BadRound:
|
||||||
|
def __round__(self, ndigits=0):
|
||||||
|
raise TypeError("cannot round")
|
||||||
|
sk._entities[c.id].geometry = (_BadRound(), _BadRound())
|
||||||
|
tags = w._compute_constraint_tags()
|
||||||
|
assert all(t["center"] is not None for t in tags)
|
||||||
|
|
||||||
|
def test_paint_tolerates_dangling_constraint_ids(self):
|
||||||
|
"""Paint must not crash if the log references an entity that was deleted.
|
||||||
|
|
||||||
|
The log can briefly reference a stale id after a delete (e.g.
|
||||||
|
if a constraint handler logs first and deletes second). The
|
||||||
|
render path must skip such entries, not raise KeyError or
|
||||||
|
TypeError.
|
||||||
|
"""
|
||||||
|
sk = OCCSketch()
|
||||||
|
a = sk.add_point(0, 0)
|
||||||
|
sk.constrain_fixed(a)
|
||||||
|
sk.solve()
|
||||||
|
# Simulate the entity being removed without pruning the log.
|
||||||
|
sk._entities.pop(a.id)
|
||||||
|
w = self._make_widget_with_sketch(sk)
|
||||||
|
tags = w._compute_constraint_tags()
|
||||||
|
# No crash; the dangling tag is dropped.
|
||||||
|
assert isinstance(tags, list)
|
||||||
|
|
||||||
|
|
||||||
|
class TestExtrudeRedesign:
|
||||||
|
"""Tests for the cut-through / source-body auto-target / live-preview
|
||||||
|
redesign (2026-06-29).
|
||||||
|
|
||||||
|
Headline workflow: a sketch projected on a face of a body, plus "Perform
|
||||||
|
Cut"
|
||||||
|
1. auto-targets the body it was projected onto,
|
||||||
|
2. auto-directs the cut INTO the body (the picked face's outward normal
|
||||||
|
points away, so a plain cut would carve nothing),
|
||||||
|
3. with "Through All" ticked, fully passes through the body.
|
||||||
|
A live transparent preview is computed from the same shared helper, and
|
||||||
|
a freshly-projected sketch is auto-selected in the row-left list so the
|
||||||
|
user can Extrude/Cut without hunting for the row.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _make_window_with_box(self, box_side=100.0):
|
||||||
|
import os
|
||||||
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
from PySide6.QtWidgets import QApplication
|
||||||
|
app = QApplication.instance() or QApplication([])
|
||||||
|
from fluency.main import MainWindow
|
||||||
|
from fluency.models.data_model import Sketch, Body
|
||||||
|
from fluency.geometry_occ.kernel import OCCGeometryObject
|
||||||
|
from fluency.geometry_occ.sketch import OCCSketch
|
||||||
|
from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox
|
||||||
|
|
||||||
|
win = MainWindow()
|
||||||
|
k = win._kernel
|
||||||
|
box_shape = BRepPrimAPI_MakeBox(box_side, box_side, box_side).Shape()
|
||||||
|
box_obj = OCCGeometryObject(box_shape, {"type": "box"})
|
||||||
|
win._current_component.bodies["b1"] = Body(name="Box1", geometry=box_obj)
|
||||||
|
|
||||||
|
# Sketch on the TOP face of the box (normal +Z points outward).
|
||||||
|
sk = OCCSketch()
|
||||||
|
origin = (box_side / 2.0, box_side / 2.0, box_side)
|
||||||
|
normal = (0.0, 0.0, 1.0)
|
||||||
|
x_dir = (1.0, 0.0, 0.0)
|
||||||
|
sk.set_workplane(origin, normal, x_dir)
|
||||||
|
sketch = Sketch(name="S on top")
|
||||||
|
sketch.occ_sketch = sk
|
||||||
|
sketch.set_workplane(origin, normal, x_dir)
|
||||||
|
sketch._source_body_id = "b1"
|
||||||
|
win._current_component.sketches[sketch.id] = sketch
|
||||||
|
win._current_sketch = sketch
|
||||||
|
# Return all the fixtures.
|
||||||
|
return win, sketch, sk, box_obj
|
||||||
|
|
||||||
|
def _add_circle(self, sk, r=10.0):
|
||||||
|
from fluency.geometry_occ.sketch import OCCSketch
|
||||||
|
c = sk.add_point(0, 0)
|
||||||
|
sk.add_circle(c, r)
|
||||||
|
sk.solve()
|
||||||
|
return sk.get_geometry()
|
||||||
|
|
||||||
|
def _geometry_volume(self, win, geom):
|
||||||
|
from OCP.GProp import GProp_GProps
|
||||||
|
from OCP.BRepGProp import BRepGProp
|
||||||
|
sh = win._kernel._get_shape(geom)
|
||||||
|
g = GProp_GProps()
|
||||||
|
BRepGProp.VolumeProperties_s(sh, g)
|
||||||
|
return g.Mass()
|
||||||
|
|
||||||
|
def test_cut_auto_directs_into_body(self):
|
||||||
|
"""A plain "Perform Cut" on a sketch-on-top-of-body carves a pocket.
|
||||||
|
|
||||||
|
Without the redesign a non-inverted extrude goes *outward* (up),
|
||||||
|
missing the box and carving nothing. The redesign auto-flips the
|
||||||
|
extrusion to go *into* the body regardless of the Invert checkbox,
|
||||||
|
so a 5 mm cut makes a real 5 mm-deep pocket.
|
||||||
|
"""
|
||||||
|
import math
|
||||||
|
win, sketch, sk, box_obj = self._make_window_with_box(100.0)
|
||||||
|
face_geom = self._add_circle(sk, r=10.0)
|
||||||
|
# Plain cut, length=5, NOT inverted. Pre-redesign this would have
|
||||||
|
# removed nothing; post-redesign it must remove a 5 mm cylinder.
|
||||||
|
result = win._compute_extrude_result(
|
||||||
|
sketch, face_geom,
|
||||||
|
length=5.0, symmetric=False, invert=False,
|
||||||
|
cut=True, union=False, through_all=False,
|
||||||
|
)
|
||||||
|
assert result is not None
|
||||||
|
assert result["target_body"] is not None
|
||||||
|
assert result["target_body"].name == "Box1"
|
||||||
|
vol = self._geometry_volume(win, result["result_geom"])
|
||||||
|
expected = 100.0 ** 3 - math.pi * (10.0 ** 2) * 5.0
|
||||||
|
assert abs(vol - expected) < 1.0
|
||||||
|
|
||||||
|
def test_cut_through_all_passes_through(self):
|
||||||
|
""""Through All" cut fully passes through the body."""
|
||||||
|
import math
|
||||||
|
win, sketch, sk, box_obj = self._make_window_with_box(100.0)
|
||||||
|
face_geom = self._add_circle(sk, r=10.0)
|
||||||
|
result = win._compute_extrude_result(
|
||||||
|
sketch, face_geom,
|
||||||
|
length=5.0, # ignored when through_all
|
||||||
|
symmetric=False, invert=False,
|
||||||
|
cut=True, union=False, through_all=True,
|
||||||
|
)
|
||||||
|
assert result is not None
|
||||||
|
vol = self._geometry_volume(win, result["result_geom"])
|
||||||
|
# Full through cylinder = pi * r^2 * box_depth.
|
||||||
|
expected = 100.0 ** 3 - math.pi * (10.0 ** 2) * 100.0
|
||||||
|
assert abs(vol - expected) < 1.0
|
||||||
|
|
||||||
|
def test_cut_auto_targets_source_body_not_existing_zero(self):
|
||||||
|
"""Cut should target the source body, not the dict's first body.
|
||||||
|
|
||||||
|
Constructs a 2-body scenario where the first body in the dict is NOT
|
||||||
|
the source, and verifies the cut goes into the source.
|
||||||
|
"""
|
||||||
|
import math
|
||||||
|
import os
|
||||||
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
from PySide6.QtWidgets import QApplication
|
||||||
|
app = QApplication.instance() or QApplication([])
|
||||||
|
from fluency.main import MainWindow
|
||||||
|
from fluency.models.data_model import Sketch, Body
|
||||||
|
from fluency.geometry_occ.kernel import OCCGeometryObject
|
||||||
|
from fluency.geometry_occ.sketch import OCCSketch
|
||||||
|
from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox
|
||||||
|
|
||||||
|
win = MainWindow()
|
||||||
|
# First body in the dict: a 50-millimetre box ALSO.
|
||||||
|
first = OCCGeometryObject(
|
||||||
|
BRepPrimAPI_MakeBox(50, 50, 50).Shape(), {}
|
||||||
|
)
|
||||||
|
win._current_component.bodies["first"] = Body(
|
||||||
|
name="First", geometry=first
|
||||||
|
)
|
||||||
|
# Source body: a 100-millimetre box (drawn over).
|
||||||
|
src = OCCGeometryObject(
|
||||||
|
BRepPrimAPI_MakeBox(100, 100, 100).Shape(), {}
|
||||||
|
)
|
||||||
|
win._current_component.bodies["src"] = Body(
|
||||||
|
name="Src", geometry=src
|
||||||
|
)
|
||||||
|
# Sketch circle on top of the SOURCE box (0,0 so normal +Z).
|
||||||
|
sk = OCCSketch()
|
||||||
|
sk.set_workplane((50, 50, 100), (0, 0, 1), (1, 0, 0))
|
||||||
|
centre = sk.add_point(0, 0)
|
||||||
|
sk.add_circle(centre, 10.0)
|
||||||
|
sk.solve()
|
||||||
|
sketch = Sketch(name="S")
|
||||||
|
sketch.occ_sketch = sk
|
||||||
|
sketch.set_workplane((50, 50, 100), (0, 0, 1), (1, 0, 0))
|
||||||
|
sketch._source_body_id = "src" # explicitly the source box.
|
||||||
|
win._current_component.sketches[sketch.id] = sketch
|
||||||
|
win._current_sketch = sketch
|
||||||
|
|
||||||
|
face_geom = sk.get_geometry()
|
||||||
|
result = win._compute_extrude_result(
|
||||||
|
sketch, face_geom,
|
||||||
|
length=5.0, symmetric=False, invert=False,
|
||||||
|
cut=True, union=False, through_all=True,
|
||||||
|
)
|
||||||
|
assert result is not None
|
||||||
|
# Target is the source box, NOT the dict's first body.
|
||||||
|
assert result["target_body"].name == "Src"
|
||||||
|
vol = self._geometry_volume(win, result["result_geom"])
|
||||||
|
# 100^3 - pi*100*100 (through-all full-depth cut on the 100 box).
|
||||||
|
expected = 100.0 ** 3 - math.pi * (10.0 ** 2) * 100.0
|
||||||
|
assert abs(vol - expected) < 1.0
|
||||||
|
|
||||||
|
def test_union_default_builds_outward(self):
|
||||||
|
"""Combine (Union) builds a boss OUTWARD (no auto-into-body flip).
|
||||||
|
|
||||||
|
Union semantics: the new material adds on TOP of the face, not
|
||||||
|
into the body. So a 10 mm union adds a 10 mm cylinder of material
|
||||||
|
rather than "subtracting" from the existing box.
|
||||||
|
"""
|
||||||
|
import math
|
||||||
|
win, sketch, sk, box_obj = self._make_window_with_box(100.0)
|
||||||
|
face_geom = self._add_circle(sk, r=10.0)
|
||||||
|
result = win._compute_extrude_result(
|
||||||
|
sketch, face_geom,
|
||||||
|
length=10.0, symmetric=False, invert=False,
|
||||||
|
cut=False, union=True, through_all=False,
|
||||||
|
)
|
||||||
|
assert result is not None
|
||||||
|
vol = self._geometry_volume(win, result["result_geom"])
|
||||||
|
# 100^3 + pi*100*10 — material added on top.
|
||||||
|
expected = 100.0 ** 3 + math.pi * (10.0 ** 2) * 10.0
|
||||||
|
assert abs(vol - expected) < 1.0
|
||||||
|
|
||||||
|
def test_plain_extrude_untouched_by_source_body(self):
|
||||||
|
"""Without cut/union, the extrusion is a standalone new body."""
|
||||||
|
win, sketch, sk, box_obj = self._make_window_with_box(100.0)
|
||||||
|
face_geom = self._add_circle(sk, r=10.0)
|
||||||
|
result = win._compute_extrude_result(
|
||||||
|
sketch, face_geom,
|
||||||
|
length=10.0, symmetric=False, invert=False,
|
||||||
|
cut=False, union=False, through_all=False,
|
||||||
|
)
|
||||||
|
assert result is not None
|
||||||
|
# No boolean target; result is the standalone tool extrusion.
|
||||||
|
assert result["target_body"] is None
|
||||||
|
vol = self._geometry_volume(win, result["result_geom"])
|
||||||
|
# Standalone cylinder 10 mm tall.
|
||||||
|
import math
|
||||||
|
assert abs(vol - math.pi * (10.0 ** 2) * 10.0) < 1.0
|
||||||
|
|
||||||
|
def test_freshly_picked_sketch_is_auto_selected(self):
|
||||||
|
"""After _on_face_picked, the new sketch is the current list row.
|
||||||
|
|
||||||
|
The user should be able to click Extrude/Cut immediately without
|
||||||
|
first hunting for the new sketch in the left list.
|
||||||
|
"""
|
||||||
|
from fluency.geometry_occ.kernel import OCCGeometryObject
|
||||||
|
win, _, sk, box_obj = self._make_window_with_box(100.0)
|
||||||
|
# Simulate _on_face_picked by calling it through a fake face
|
||||||
|
# shape — but the simplest behavioural check is to call the
|
||||||
|
# bookkeeping directly: a new sketch matching src exists and is
|
||||||
|
# set as _current_sketch, and it appears (and is selected) in
|
||||||
|
# the list after _refresh_lists + setCurrentRow.
|
||||||
|
from fluency.models.data_model import Sketch
|
||||||
|
sketch = Sketch(name="Sketch on face 99")
|
||||||
|
sketch._source_body_id = "b1"
|
||||||
|
sketch.set_workplane((50, 50, 100), (0, 0, 1), (1, 0, 0))
|
||||||
|
win._current_component.sketches[sketch.id] = sketch
|
||||||
|
win._current_sketch = sketch
|
||||||
|
win._refresh_lists()
|
||||||
|
# The auto-select block from _on_face_picked — re-derive it
|
||||||
|
# here since we can't run the full pick path offscreen.
|
||||||
|
target_row = None
|
||||||
|
for row in range(win._sketch_list.count()):
|
||||||
|
if win._sketch_list.item(row).text() == sketch.name:
|
||||||
|
target_row = row
|
||||||
|
break
|
||||||
|
assert target_row is not None
|
||||||
|
win._sketch_list.setCurrentRow(target_row)
|
||||||
|
assert win._sketch_list.currentRow() == target_row
|
||||||
|
assert win._sketch_list.currentItem().text() == sketch.name
|
||||||
|
|
||||||
|
def test_preview_callback_invoked_on_value_change(self):
|
||||||
|
"""The live preview callback fires on spinbox/checkbox changes."""
|
||||||
|
import os
|
||||||
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
from PySide6.QtWidgets import QApplication
|
||||||
|
app = QApplication.instance() or QApplication([])
|
||||||
|
from fluency.main import ExtrudeDialog
|
||||||
|
|
||||||
|
calls = []
|
||||||
|
dialog = ExtrudeDialog()
|
||||||
|
dialog.set_preview_callback(lambda v: calls.append(v))
|
||||||
|
# set_preview_callback emits once for the initial state.
|
||||||
|
assert len(calls) == 1
|
||||||
|
# Changing the length should emit a new values tuple.
|
||||||
|
dialog.length_input.setValue(42.0)
|
||||||
|
assert len(calls) == 2
|
||||||
|
# Toggling "Through All" should emit again.
|
||||||
|
dialog.through_all_checkbox.setChecked(True)
|
||||||
|
assert len(calls) >= 3
|
||||||
|
# Passing None clears the preview (as the host does on close).
|
||||||
|
dialog.set_preview_callback(None)
|
||||||
|
# New callback None → no further emissions.
|
||||||
|
before = len(calls)
|
||||||
|
dialog.length_input.setValue(99.0)
|
||||||
|
assert len(calls) == before # callback gone → no emit
|
||||||
|
|
||||||
|
def test_preview_hidden_event_sends_none(self):
|
||||||
|
"""hideEvent should deliver None to the callback so the host clears."""
|
||||||
|
import os
|
||||||
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
from PySide6.QtWidgets import QApplication
|
||||||
|
app = QApplication.instance() or QApplication([])
|
||||||
|
from fluency.main import ExtrudeDialog
|
||||||
|
|
||||||
|
seen = []
|
||||||
|
dialog = ExtrudeDialog()
|
||||||
|
dialog.set_preview_callback(lambda v: seen.append(v))
|
||||||
|
# hideEvent only fires when the dialog was previously visible, so
|
||||||
|
# show it first (window system / offscreen both honour this) and
|
||||||
|
# then hide it — which is exactly what dialog.exec() does when the
|
||||||
|
# user accepts or cancels.
|
||||||
|
dialog.show()
|
||||||
|
dialog.hide()
|
||||||
|
# The last value emitted to the callback must be None (clear).
|
||||||
|
assert seen and seen[-1] is None
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
pytest.main([__file__, "-v"])
|
pytest.main([__file__, "-v"])
|
||||||
|
|||||||
Reference in New Issue
Block a user