Compare commits
50 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9abeb6266a | |||
| 6b6f7de5ab | |||
| b184ade967 | |||
| 67b73c13b8 | |||
| 813ddc3596 | |||
| 37e5335446 | |||
| 108ad2d5b5 | |||
| 1eee203ba8 | |||
| a00129603c | |||
| 3d63f033f2 | |||
| b29bc11b42 | |||
| 79ad85594e | |||
| 655df71cd8 | |||
| 7072dcee08 | |||
| 1e9b0cab1c | |||
| a7e7a9f2c3 | |||
| 48042659fc | |||
| baa2fd5d47 | |||
| 0daa6152ee | |||
| 9f1c29d319 | |||
| 2037106c0d | |||
| d6e829c23d | |||
| 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 |
+73
@@ -1,3 +1,76 @@
|
|||||||
*.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
|
||||||
|
/src/fluency/Tesfiles/bordo_adapter3.fluency
|
||||||
|
/CONSTRAINT_STATUS_FINAL.md
|
||||||
|
/CONSTRAINT_STATUS_IMPLEMENTATION.md
|
||||||
|
/src/fluency/rendering/first.png
|
||||||
|
/littlebrother.md
|
||||||
|
/src/fluency/Tesfiles/multiboidy.fluency
|
||||||
|
/src/fluency/rendering/nromal_test.png
|
||||||
|
/package.json
|
||||||
|
/package-lock.json
|
||||||
|
/src/fluency/Screenshot 2026-06-28 at 17.57.52.png
|
||||||
|
/src/fluency/rendering/Screenshot 2026-07-12 at 16.54.14.png
|
||||||
|
/src/fluency/Screenshot 2026-07-26 at 20.23.42.png
|
||||||
|
/Screenshot 2026-08-05 at 10.14.15.png
|
||||||
|
/Screenshot 2026-08-05 at 11.15.33.png
|
||||||
|
/Screenshot 2026-08-05 at 11.22.42.png
|
||||||
|
/SURFACE_MODIFIER_PLAN.md
|
||||||
|
/test.step
|
||||||
|
/src/fluency/test333.step
|
||||||
|
/src/fluency/tests/test_arc_attached_to_rectangle.py
|
||||||
|
/src/fluency/tests/test_array_pattern.py
|
||||||
|
/src/fluency/tests/test_chamfer.py
|
||||||
|
/src/fluency/tests/test_circle_diameter_constraint.py
|
||||||
|
/tests/test_distance_constraint_picking.py
|
||||||
|
/tests/test_extrude_geometry.py
|
||||||
|
/src/fluency/tests/test_feature_replay.py
|
||||||
|
/src/fluency/tests/test_fillet.py
|
||||||
|
/tests/test_mirror.py
|
||||||
|
/test_modifier.py
|
||||||
|
/src/fluency/tests/test_projection_constraints.py
|
||||||
|
/tests/test_re_extrude.py
|
||||||
|
/scripts/test_render_zoom.py
|
||||||
|
/test_thread.py
|
||||||
|
/test_thread_hole.step
|
||||||
|
/test_thread_m3.step
|
||||||
|
/test_thread_m5_hole.step
|
||||||
|
/test_thread_shaft.step
|
||||||
|
/test_thread_tilted.step
|
||||||
|
/src/fluency/testpart.step
|
||||||
|
|||||||
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
+362
-117
@@ -4,31 +4,17 @@
|
|||||||
<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="Fiexed highlighting of operations">
|
||||||
<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/geometry_occ/sketch.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/geometry_occ/sketch.py" afterDir="false" />
|
||||||
|
<change beforePath="$PROJECT_DIR$/src/fluency/io/project_io.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/io/project_io.py" afterDir="false" />
|
||||||
|
<change beforePath="$PROJECT_DIR$/src/fluency/models/data_model.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/models/data_model.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/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/sketch_widget.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/ui/sketch_widget.py" afterDir="false" />
|
||||||
|
<change beforePath="$PROJECT_DIR$/src/fluency/ui/technical_drawing_widget.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/ui/technical_drawing_widget.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 +31,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="feature/surface-modifier-pyramid-patterns" />
|
||||||
</map>
|
</map>
|
||||||
</option>
|
</option>
|
||||||
<option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
|
<option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
|
||||||
@@ -65,32 +51,48 @@
|
|||||||
<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.debug_dragging.executor": "Run",
|
||||||
"Python.fluencyb.executor": "Run",
|
"Python.draw_widget2d.executor": "Run",
|
||||||
"Python.gl_widget.executor": "Run",
|
"Python.draw_widget_solve.executor": "Run",
|
||||||
"Python.main.executor": "Run",
|
"Python.fluency.executor": "Run",
|
||||||
"Python.meshtest.executor": "Run",
|
"Python.fluencyb.executor": "Run",
|
||||||
"Python.side_fluency.executor": "Run",
|
"Python.gl_widget.executor": "Run",
|
||||||
"Python.simple_mesh.executor": "Run",
|
"Python.gui_ui.executor": "Run",
|
||||||
"Python.test_vtk_sketch_widget.executor": "Run",
|
"Python.kernel.executor": "Run",
|
||||||
"Python.vtk_sketch_widget.executor": "Run",
|
"Python.main.executor": "Run",
|
||||||
"Python.vtk_widget.executor": "Run",
|
"Python.main_window.executor": "Run",
|
||||||
"Python.vulkan.executor": "Run",
|
"Python.meshtest.executor": "Run",
|
||||||
"RunOnceActivity.OpenProjectViewOnStart": "true",
|
"Python.occ_renderer.executor": "Run",
|
||||||
"RunOnceActivity.ShowReadmeOnStart": "true",
|
"Python.occ_to_mesh.executor": "Run",
|
||||||
"RunOnceActivity.git.unshallow": "true",
|
"Python.render_backend.executor": "Run",
|
||||||
"git-widget-placeholder": "single__window",
|
"Python.side_fluency.executor": "Run",
|
||||||
"last_opened_file_path": "/Volumes/Data_drive/Programming/fluency",
|
"Python.simple_mesh.executor": "Run",
|
||||||
"settings.editor.selected.configurable": "project.propVCSSupport.DirectoryMappings"
|
"Python.sketch.executor": "Run",
|
||||||
|
"Python.technical_drawing_widget.executor": "Run",
|
||||||
|
"Python.vtk_widget.executor": "Run",
|
||||||
|
"Python.vulkan.executor": "Run",
|
||||||
|
"RunOnceActivity.OpenProjectViewOnStart": "true",
|
||||||
|
"RunOnceActivity.ShowReadmeOnStart": "true",
|
||||||
|
"RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252": "true",
|
||||||
|
"RunOnceActivity.git.unshallow": "true",
|
||||||
|
"RunOnceActivity.typescript.service.memoryLimit.init": "true",
|
||||||
|
"codeWithMe.voiceChat.enabledByDefault": "false",
|
||||||
|
"git-widget-placeholder": "feature/occ-migration",
|
||||||
|
"last_opened_file_path": "/Volumes/Data_drive/Programming/fluency/src/fluency",
|
||||||
|
"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 +100,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" />
|
||||||
<recent name="$PROJECT_DIR$" />
|
<recent name="$PROJECT_DIR$" />
|
||||||
|
<recent name="$PROJECT_DIR$/src/fluency/Tesfiles" />
|
||||||
|
<recent name="$PROJECT_DIR$/src/fluency/rendering" />
|
||||||
<recent name="$PROJECT_DIR$/drawing_modules" />
|
<recent name="$PROJECT_DIR$/drawing_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>
|
||||||
@@ -123,54 +126,6 @@
|
|||||||
<option name="presentableId" value="Default" />
|
<option name="presentableId" value="Default" />
|
||||||
<updated>1703867682707</updated>
|
<updated>1703867682707</updated>
|
||||||
</task>
|
</task>
|
||||||
<task id="LOCAL-00001" summary="init">
|
|
||||||
<option name="closed" value="true" />
|
|
||||||
<created>1703951701948</created>
|
|
||||||
<option name="number" value="00001" />
|
|
||||||
<option name="presentableId" value="LOCAL-00001" />
|
|
||||||
<option name="project" value="LOCAL" />
|
|
||||||
<updated>1703951701948</updated>
|
|
||||||
</task>
|
|
||||||
<task id="LOCAL-00002" summary="- Basic oop sketch widget implement">
|
|
||||||
<option name="closed" value="true" />
|
|
||||||
<created>1729958532384</created>
|
|
||||||
<option name="number" value="00002" />
|
|
||||||
<option name="presentableId" value="LOCAL-00002" />
|
|
||||||
<option name="project" value="LOCAL" />
|
|
||||||
<updated>1729958532384</updated>
|
|
||||||
</task>
|
|
||||||
<task id="LOCAL-00003" summary="- Sketch projection partly works again :)">
|
|
||||||
<option name="closed" value="true" />
|
|
||||||
<created>1735563255455</created>
|
|
||||||
<option name="number" value="00003" />
|
|
||||||
<option name="presentableId" value="LOCAL-00003" />
|
|
||||||
<option name="project" value="LOCAL" />
|
|
||||||
<updated>1735563255455</updated>
|
|
||||||
</task>
|
|
||||||
<task id="LOCAL-00004" summary="- Sketch projection partly works again :)">
|
|
||||||
<option name="closed" value="true" />
|
|
||||||
<created>1735585968733</created>
|
|
||||||
<option name="number" value="00004" />
|
|
||||||
<option name="presentableId" value="LOCAL-00004" />
|
|
||||||
<option name="project" value="LOCAL" />
|
|
||||||
<updated>1735585968733</updated>
|
|
||||||
</task>
|
|
||||||
<task id="LOCAL-00005" summary="- Added new componnt controls">
|
|
||||||
<option name="closed" value="true" />
|
|
||||||
<created>1735601610504</created>
|
|
||||||
<option name="number" value="00005" />
|
|
||||||
<option name="presentableId" value="LOCAL-00005" />
|
|
||||||
<option name="project" value="LOCAL" />
|
|
||||||
<updated>1735601610504</updated>
|
|
||||||
</task>
|
|
||||||
<task id="LOCAL-00006" summary="- Added new componnt controls">
|
|
||||||
<option name="closed" value="true" />
|
|
||||||
<created>1735601786207</created>
|
|
||||||
<option name="number" value="00006" />
|
|
||||||
<option name="presentableId" value="LOCAL-00006" />
|
|
||||||
<option name="project" value="LOCAL" />
|
|
||||||
<updated>1735601786207</updated>
|
|
||||||
</task>
|
|
||||||
<task id="LOCAL-00007" summary="- changing compos for sketches works">
|
<task id="LOCAL-00007" summary="- changing compos for sketches works">
|
||||||
<option name="closed" value="true" />
|
<option name="closed" value="true" />
|
||||||
<created>1735652081552</created>
|
<created>1735652081552</created>
|
||||||
@@ -275,17 +230,300 @@
|
|||||||
<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>
|
||||||
|
<task id="LOCAL-00040" summary="- Render improvements, camera plane, update">
|
||||||
|
<option name="closed" value="true" />
|
||||||
|
<created>1783976498520</created>
|
||||||
|
<option name="number" value="00040" />
|
||||||
|
<option name="presentableId" value="LOCAL-00040" />
|
||||||
|
<option name="project" value="LOCAL" />
|
||||||
|
<updated>1783976498520</updated>
|
||||||
|
</task>
|
||||||
|
<task id="LOCAL-00041" summary="- added "measurement lines"">
|
||||||
|
<option name="closed" value="true" />
|
||||||
|
<created>1785094789247</created>
|
||||||
|
<option name="number" value="00041" />
|
||||||
|
<option name="presentableId" value="LOCAL-00041" />
|
||||||
|
<option name="project" value="LOCAL" />
|
||||||
|
<updated>1785094789248</updated>
|
||||||
|
</task>
|
||||||
|
<task id="LOCAL-00042" summary="- added "measurement lines"">
|
||||||
|
<option name="closed" value="true" />
|
||||||
|
<created>1785697123545</created>
|
||||||
|
<option name="number" value="00042" />
|
||||||
|
<option name="presentableId" value="LOCAL-00042" />
|
||||||
|
<option name="project" value="LOCAL" />
|
||||||
|
<updated>1785697123545</updated>
|
||||||
|
</task>
|
||||||
|
<task id="LOCAL-00043" summary="- arc improvements, fillets, operations, bodys">
|
||||||
|
<option name="closed" value="true" />
|
||||||
|
<created>1785701099363</created>
|
||||||
|
<option name="number" value="00043" />
|
||||||
|
<option name="presentableId" value="LOCAL-00043" />
|
||||||
|
<option name="project" value="LOCAL" />
|
||||||
|
<updated>1785701099363</updated>
|
||||||
|
</task>
|
||||||
|
<task id="LOCAL-00044" summary="- arc improvements, fillets, operations, bodys">
|
||||||
|
<option name="closed" value="true" />
|
||||||
|
<created>1785915859137</created>
|
||||||
|
<option name="number" value="00044" />
|
||||||
|
<option name="presentableId" value="LOCAL-00044" />
|
||||||
|
<option name="project" value="LOCAL" />
|
||||||
|
<updated>1785915859137</updated>
|
||||||
|
</task>
|
||||||
|
<task id="LOCAL-00045" summary="- arc improvements, fillets, operations, bodys">
|
||||||
|
<option name="closed" value="true" />
|
||||||
|
<created>1785941246695</created>
|
||||||
|
<option name="number" value="00045" />
|
||||||
|
<option name="presentableId" value="LOCAL-00045" />
|
||||||
|
<option name="project" value="LOCAL" />
|
||||||
|
<updated>1785941246695</updated>
|
||||||
|
</task>
|
||||||
|
<task id="LOCAL-00046" summary="- arc improvements, fillets, operations, bodys">
|
||||||
|
<option name="closed" value="true" />
|
||||||
|
<created>1785948828454</created>
|
||||||
|
<option name="number" value="00046" />
|
||||||
|
<option name="presentableId" value="LOCAL-00046" />
|
||||||
|
<option name="project" value="LOCAL" />
|
||||||
|
<updated>1785948828454</updated>
|
||||||
|
</task>
|
||||||
|
<task id="LOCAL-00047" summary="- arc improvements, fillets, operations, bodys">
|
||||||
|
<option name="closed" value="true" />
|
||||||
|
<created>1786110295218</created>
|
||||||
|
<option name="number" value="00047" />
|
||||||
|
<option name="presentableId" value="LOCAL-00047" />
|
||||||
|
<option name="project" value="LOCAL" />
|
||||||
|
<updated>1786110295218</updated>
|
||||||
|
</task>
|
||||||
|
<task id="LOCAL-00048" summary="- arc improvements, fillets, operations, bodys">
|
||||||
|
<option name="closed" value="true" />
|
||||||
|
<created>1786125317688</created>
|
||||||
|
<option name="number" value="00048" />
|
||||||
|
<option name="presentableId" value="LOCAL-00048" />
|
||||||
|
<option name="project" value="LOCAL" />
|
||||||
|
<updated>1786125317688</updated>
|
||||||
|
</task>
|
||||||
|
<task id="LOCAL-00049" summary="- Operation highlighting, body highlighting">
|
||||||
|
<option name="closed" value="true" />
|
||||||
|
<created>1786180549405</created>
|
||||||
|
<option name="number" value="00049" />
|
||||||
|
<option name="presentableId" value="LOCAL-00049" />
|
||||||
|
<option name="project" value="LOCAL" />
|
||||||
|
<updated>1786180549405</updated>
|
||||||
|
</task>
|
||||||
|
<task id="LOCAL-00050" summary="- tech draw draft">
|
||||||
|
<option name="closed" value="true" />
|
||||||
|
<created>1786888483687</created>
|
||||||
|
<option name="number" value="00050" />
|
||||||
|
<option name="presentableId" value="LOCAL-00050" />
|
||||||
|
<option name="project" value="LOCAL" />
|
||||||
|
<updated>1786888483688</updated>
|
||||||
|
</task>
|
||||||
|
<task id="LOCAL-00051" summary="- tech draw draft v2">
|
||||||
|
<option name="closed" value="true" />
|
||||||
|
<created>1786910497589</created>
|
||||||
|
<option name="number" value="00051" />
|
||||||
|
<option name="presentableId" value="LOCAL-00051" />
|
||||||
|
<option name="project" value="LOCAL" />
|
||||||
|
<updated>1786910497589</updated>
|
||||||
|
</task>
|
||||||
|
<task id="LOCAL-00052" summary="- tech draw draft v2">
|
||||||
|
<option name="closed" value="true" />
|
||||||
|
<created>1786984519780</created>
|
||||||
|
<option name="number" value="00052" />
|
||||||
|
<option name="presentableId" value="LOCAL-00052" />
|
||||||
|
<option name="project" value="LOCAL" />
|
||||||
|
<updated>1786984519781</updated>
|
||||||
|
</task>
|
||||||
|
<task id="LOCAL-00053" summary="- tech draw draft v2">
|
||||||
|
<option name="closed" value="true" />
|
||||||
|
<created>1786995561375</created>
|
||||||
|
<option name="number" value="00053" />
|
||||||
|
<option name="presentableId" value="LOCAL-00053" />
|
||||||
|
<option name="project" value="LOCAL" />
|
||||||
|
<updated>1786995561376</updated>
|
||||||
|
</task>
|
||||||
|
<task id="LOCAL-00054" summary="- tech drawing and render improv">
|
||||||
|
<option name="closed" value="true" />
|
||||||
|
<created>1787058413911</created>
|
||||||
|
<option name="number" value="00054" />
|
||||||
|
<option name="presentableId" value="LOCAL-00054" />
|
||||||
|
<option name="project" value="LOCAL" />
|
||||||
|
<updated>1787058413911</updated>
|
||||||
|
</task>
|
||||||
|
<task id="LOCAL-00055" summary="Fiexed highlighting of operations">
|
||||||
|
<option name="closed" value="true" />
|
||||||
|
<created>1787091089061</created>
|
||||||
|
<option name="number" value="00055" />
|
||||||
|
<option name="presentableId" value="LOCAL-00055" />
|
||||||
|
<option name="project" value="LOCAL" />
|
||||||
|
<updated>1787091089061</updated>
|
||||||
|
</task>
|
||||||
|
<option name="localTasksCounter" value="56" />
|
||||||
<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,16 +539,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="- Sketch projection partly works again :)" />
|
|
||||||
<MESSAGE value="- Added new componnt controls" />
|
|
||||||
<MESSAGE value="- changing compos for sketches works" />
|
|
||||||
<MESSAGE value="- changing compos including sketches and bodies" />
|
|
||||||
<MESSAGE value="- Drawing bodys depending on the selected compo - Cut working - Edit sketch working" />
|
|
||||||
<MESSAGE value="- delete sketch working - added mid point snap - added hovering line with distance" />
|
|
||||||
<MESSAGE value="- Added new buttons and settings" />
|
|
||||||
<MESSAGE value="- Added construction lines switching - Moved callbacks into sketchwidget from main. - Changed reset on right click" />
|
<MESSAGE value="- Added construction lines switching - Moved callbacks into sketchwidget from main. - Changed reset on right click" />
|
||||||
<MESSAGE value="- Added contrain displayed next to line - Slight change to point check from solver." />
|
<MESSAGE value="- Added contrain displayed next to line - Slight change to point check from solver." />
|
||||||
<MESSAGE value="- Added enabling of midpsnap and prepared others - Show dimesnion on hover" />
|
<MESSAGE value="- Added enabling of midpsnap and prepared others - Show dimesnion on hover" />
|
||||||
@@ -318,7 +546,24 @@
|
|||||||
<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" />
|
||||||
|
<MESSAGE value="- Render improvements, camera plane, update" />
|
||||||
|
<MESSAGE value="- added "measurement lines"" />
|
||||||
|
<MESSAGE value="- arc improvements, fillets, operations, bodys" />
|
||||||
|
<MESSAGE value="- Operation highlighting, body highlighting" />
|
||||||
|
<MESSAGE value="- tech draw draft" />
|
||||||
|
<MESSAGE value="- tech draw draft v2" />
|
||||||
|
<MESSAGE value="- tech drawing and render improv" />
|
||||||
|
<MESSAGE value="Fiexed highlighting of operations" />
|
||||||
|
<option name="LAST_COMMIT_MESSAGE" value="Fiexed highlighting of operations" />
|
||||||
</component>
|
</component>
|
||||||
</project>
|
</project>
|
||||||
+376
@@ -0,0 +1,376 @@
|
|||||||
|
# ── Sheet layout regions ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_LAYOUT_MARGIN_MM = 10.0
|
||||||
|
_VIEW_GAP_MM = 12.0
|
||||||
|
|
||||||
|
# Title block box (see _title_block_primitives): 180 × 52 at the bottom-right
|
||||||
|
# corner with a 5 mm sheet margin. Views must clear it (plus clearance).
|
||||||
|
_TB_LEFT_MM = _A3_WIDTH_MM - 180.0 - 5.0
|
||||||
|
_TB_TOP_MM = 5.0 + 52.0
|
||||||
|
_TB_CLEARANCE_MM = 5.0
|
||||||
|
|
||||||
|
# Sheet interior (border margin) as (x0, y0, x1, y1) in sheet mm.
|
||||||
|
_SHEET_INNER = (
|
||||||
|
_LAYOUT_MARGIN_MM,
|
||||||
|
_LAYOUT_MARGIN_MM,
|
||||||
|
_A3_WIDTH_MM - _LAYOUT_MARGIN_MM,
|
||||||
|
_A3_HEIGHT_MM - _LAYOUT_MARGIN_MM,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Regions the orthographic layout may occupy, as (x0, y0, w, h):
|
||||||
|
# - UPPER: full sheet width above the title block
|
||||||
|
# - LEFT: full sheet height in the left strip beside the title block
|
||||||
|
_REGION_UPPER = (
|
||||||
|
_LAYOUT_MARGIN_MM,
|
||||||
|
_TB_TOP_MM + _TB_CLEARANCE_MM,
|
||||||
|
_A3_WIDTH_MM - 2 * _LAYOUT_MARGIN_MM,
|
||||||
|
_A3_HEIGHT_MM - _LAYOUT_MARGIN_MM - (_TB_TOP_MM + _TB_CLEARANCE_MM),
|
||||||
|
)
|
||||||
|
_REGION_LEFT = (
|
||||||
|
_LAYOUT_MARGIN_MM,
|
||||||
|
_LAYOUT_MARGIN_MM,
|
||||||
|
_TB_LEFT_MM - _TB_CLEARANCE_MM - _LAYOUT_MARGIN_MM,
|
||||||
|
_A3_HEIGHT_MM - 2 * _LAYOUT_MARGIN_MM,
|
||||||
|
)
|
||||||
|
|
||||||
|
_MID_ORDER = ("left", "front", "right", "back")
|
||||||
|
_COL_ORDER = ("top", "front", "bottom") # sheet top → bottom
|
||||||
|
_GRID_ORDER = ("front", "right", "back", "top", "left", "bottom")
|
||||||
|
|
||||||
|
_RectList = List[Tuple[str, float, float, float, float]]
|
||||||
|
|
||||||
|
|
||||||
|
def _place_cross(
|
||||||
|
dims: Dict[str, Tuple[float, float]], gap: float
|
||||||
|
) -> _RectList:
|
||||||
|
"""Classic third-angle cross: mid views run left→right (left, front,
|
||||||
|
right, back), col views stack top→bottom (top, front, bottom), with the
|
||||||
|
anchor view (front, or the first present one) at the intersection.
|
||||||
|
|
||||||
|
*dims* maps view id → ``(w, h)`` in sheet units. Returns local
|
||||||
|
``(vid, x, y, w, h)`` rects (origin arbitrary — the caller centres the
|
||||||
|
union on the sheet).
|
||||||
|
"""
|
||||||
|
mid = [k for k in _MID_ORDER if k in dims]
|
||||||
|
col = [k for k in _COL_ORDER if k in dims]
|
||||||
|
if not mid and not col:
|
||||||
|
return []
|
||||||
|
rects: Dict[str, Tuple[float, float, float, float]] = {}
|
||||||
|
|
||||||
|
if col:
|
||||||
|
# Stack bottom → top.
|
||||||
|
y = 0.0
|
||||||
|
for k in reversed(col):
|
||||||
|
w, h = dims[k]
|
||||||
|
rects[k] = (0.0, y, w, h)
|
||||||
|
y += h + gap
|
||||||
|
col_h = y - gap
|
||||||
|
cx = max(dims[k][0] for k in col) / 2.0
|
||||||
|
for k in col:
|
||||||
|
_x, yy, w, h = rects[k]
|
||||||
|
rects[k] = (cx - w / 2.0, yy, w, h)
|
||||||
|
else:
|
||||||
|
col_h = 0.0
|
||||||
|
cx = 0.0
|
||||||
|
|
||||||
|
anchor = "front" if "front" in dims else (mid[0] if mid else col[0])
|
||||||
|
if anchor in rects:
|
||||||
|
ax, ay, aw, _ah = rects[anchor]
|
||||||
|
anchor_cy = ay + _ah / 2.0
|
||||||
|
else:
|
||||||
|
aw, ah = dims[anchor]
|
||||||
|
ax = cx - aw / 2.0
|
||||||
|
ay = col_h / 2.0 - ah / 2.0
|
||||||
|
rects[anchor] = (ax, ay, aw, ah)
|
||||||
|
anchor_cy = col_h / 2.0
|
||||||
|
|
||||||
|
ia = mid.index(anchor) if anchor in mid else -1
|
||||||
|
x = ax
|
||||||
|
for k in reversed(mid[:ia]):
|
||||||
|
w, h = dims[k]
|
||||||
|
x -= w + gap
|
||||||
|
rects[k] = (x, anchor_cy - h / 2.0, w, h)
|
||||||
|
x = ax + aw
|
||||||
|
for k in mid[ia + 1 :]:
|
||||||
|
w, h = dims[k]
|
||||||
|
x += gap
|
||||||
|
rects[k] = (x, anchor_cy - h / 2.0, w, h)
|
||||||
|
x += w
|
||||||
|
return [(k, *r) for k, r in rects.items()]
|
||||||
|
|
||||||
|
|
||||||
|
def _place_swapped(dims: Dict[str, Tuple[float, float]], gap: float) -> _RectList:
|
||||||
|
"""Cross with the view families swapped: the mid views stack vertically
|
||||||
|
(left, front, right, back from the top) and the col views run
|
||||||
|
horizontally (bottom, front, top from the left) — the classic cross
|
||||||
|
turned a quarter turn, for sheets where that orientation fits more.
|
||||||
|
"""
|
||||||
|
mid = [k for k in _MID_ORDER if k in dims]
|
||||||
|
col = [k for k in _COL_ORDER if k in dims]
|
||||||
|
if not mid or not col:
|
||||||
|
return []
|
||||||
|
rects: Dict[str, Tuple[float, float, float, float]] = {}
|
||||||
|
cx = max(dims[k][0] for k in mid) / 2.0
|
||||||
|
y = 0.0
|
||||||
|
for k in mid: # top → bottom
|
||||||
|
w, h = dims[k]
|
||||||
|
rects[k] = (cx - w / 2.0, y, w, h)
|
||||||
|
y += h + gap
|
||||||
|
anchor = "front" if "front" in dims else mid[0]
|
||||||
|
anchor_cy = rects[anchor][1] + dims[anchor][1] / 2.0
|
||||||
|
|
||||||
|
row: Dict[str, Tuple[float, float, float, float]] = {}
|
||||||
|
x = 0.0
|
||||||
|
x_anchor = 0.0
|
||||||
|
for k in reversed(col): # bottom, front, top → left to right
|
||||||
|
w, h = dims[k]
|
||||||
|
if k == anchor:
|
||||||
|
x_anchor = x
|
||||||
|
row[k] = (x, anchor_cy - h / 2.0, w, h)
|
||||||
|
x += w + gap
|
||||||
|
shift = rects[anchor][0] - x_anchor
|
||||||
|
for k, r in row.items():
|
||||||
|
if k == anchor:
|
||||||
|
continue
|
||||||
|
x0, y0, w, h = r
|
||||||
|
rects[k] = (x0 + shift, y0, w, h)
|
||||||
|
return [(k, *r) for k, r in rects.items()]
|
||||||
|
|
||||||
|
|
||||||
|
def _place_grid(
|
||||||
|
dims: Dict[str, Tuple[float, float]], gap: float, rows: int
|
||||||
|
) -> _RectList:
|
||||||
|
"""Wrap the present standard views into a grid of *rows* rows, filled
|
||||||
|
bottom→top and left→right (so the primary views sit near the bottom,
|
||||||
|
like in the cross)."""
|
||||||
|
order = [k for k in _GRID_ORDER if k in dims]
|
||||||
|
if not order:
|
||||||
|
return []
|
||||||
|
cols = max(1, -(-len(order) // rows))
|
||||||
|
rects: _RectList = []
|
||||||
|
y = 0.0
|
||||||
|
for r in range(rows):
|
||||||
|
chunk = order[r * cols : (r + 1) * cols]
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
x = 0.0
|
||||||
|
row_h = 0.0
|
||||||
|
for k in chunk:
|
||||||
|
w, h = dims[k]
|
||||||
|
rects.append((k, x, y, w, h))
|
||||||
|
x += w + gap
|
||||||
|
row_h = max(row_h, h)
|
||||||
|
y += row_h + gap
|
||||||
|
return rects
|
||||||
|
|
||||||
|
|
||||||
|
def _fit_scale(
|
||||||
|
place: Callable[
|
||||||
|
[Dict[str, Tuple[float, float]], float], _RectList
|
||||||
|
],
|
||||||
|
dims_m: Dict[str, Tuple[float, float]],
|
||||||
|
region: Tuple[float, float, float, float],
|
||||||
|
) -> float:
|
||||||
|
"""Largest shared scale at which *dims_m* (model units) laid out by
|
||||||
|
*place* fits the ``(x0, y0, w, h)`` *region* of the sheet.
|
||||||
|
|
||||||
|
The union size grows monotonically with the scale, so a bisection
|
||||||
|
converges to the tight fit.
|
||||||
|
"""
|
||||||
|
_rx0, _ry0, rw, rh = region
|
||||||
|
|
||||||
|
def fits(s: float) -> bool:
|
||||||
|
rects = place(
|
||||||
|
{k: (w * s, h * s) for k, (w, h) in dims_m.items()}, _VIEW_GAP_MM
|
||||||
|
)
|
||||||
|
if not rects:
|
||||||
|
return True
|
||||||
|
minx = min(r[1] for r in rects)
|
||||||
|
miny = min(r[2] for r in rects)
|
||||||
|
maxx = max(r[1] + r[3] for r in rects)
|
||||||
|
maxy = max(r[2] + r[4] for r in rects)
|
||||||
|
return (maxx - minx) <= rw + 1e-9 and (maxy - miny) <= rh + 1e-9
|
||||||
|
|
||||||
|
s_lo, s_hi = 0.0, 1.0
|
||||||
|
if fits(s_hi):
|
||||||
|
s_lo = s_hi
|
||||||
|
while s_hi < 1.0e6 and fits(s_hi * 2.0):
|
||||||
|
s_hi *= 2.0
|
||||||
|
for _ in range(60):
|
||||||
|
mid = 0.5 * (s_lo + s_hi)
|
||||||
|
if fits(mid):
|
||||||
|
s_lo = mid
|
||||||
|
else:
|
||||||
|
s_hi = mid
|
||||||
|
return s_lo
|
||||||
|
|
||||||
|
|
||||||
|
def _free_rects(
|
||||||
|
used_rects: Sequence[Tuple[float, float, float, float]]
|
||||||
|
) -> List[Tuple[float, float, float, float]]:
|
||||||
|
"""Axis-aligned free rects ``(x0, y0, w, h)`` around *used_rects*,
|
||||||
|
clearing the sheet border and the title block zone."""
|
||||||
|
ix0, iy0, ix1, iy1 = _SHEET_INNER
|
||||||
|
if used_rects:
|
||||||
|
ux0 = min(r[0] for r in used_rects)
|
||||||
|
uy0 = min(r[1] for r in used_rects)
|
||||||
|
ux1 = max(r[0] + r[2] for r in used_rects)
|
||||||
|
uy1 = max(r[1] + r[3] for r in used_rects)
|
||||||
|
cands = [
|
||||||
|
(ux1 + _VIEW_GAP_MM, iy0, ix1, iy1), # right of the used block
|
||||||
|
(ix0, iy0, ux0 - _VIEW_GAP_MM, iy1), # left
|
||||||
|
(ix0, uy1 + _VIEW_GAP_MM, ix1, iy1), # above
|
||||||
|
(ix0, iy0, ix1, uy0 - _VIEW_GAP_MM), # below
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
cands = [(ix0, iy0, ix1, iy1)]
|
||||||
|
tb = (
|
||||||
|
_TB_LEFT_MM - _TB_CLEARANCE_MM,
|
||||||
|
0.0,
|
||||||
|
_A3_WIDTH_MM - (_TB_LEFT_MM - _TB_CLEARANCE_MM),
|
||||||
|
_TB_TOP_MM + _TB_CLEARANCE_MM,
|
||||||
|
)
|
||||||
|
out: List[Tuple[float, float, float, float]] = []
|
||||||
|
for x0, y0, x1, y1 in cands:
|
||||||
|
x0, y0 = max(x0, ix0), max(y0, iy0)
|
||||||
|
x1, y1 = min(x1, ix1), min(y1, iy1)
|
||||||
|
if x1 - x0 < 1.0 or y1 - y0 < 1.0:
|
||||||
|
continue
|
||||||
|
if not (x1 <= tb[0] or tb[2] <= x0 or y1 <= tb[1] or tb[3] <= y0):
|
||||||
|
# Overlaps the title block zone — keep the parts above/left of it.
|
||||||
|
subs = [
|
||||||
|
(x0, max(y0, tb[3]), x1, y1),
|
||||||
|
(x0, y0, min(x1, tb[0]), y1),
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
subs = [(x0, y0, x1, y1)]
|
||||||
|
for sx0, sy0, sx1, sy1 in subs:
|
||||||
|
if sx1 - sx0 > 1.0 and sy1 - sy0 > 1.0:
|
||||||
|
out.append((sx0, sy0, sx1 - sx0, sy1 - sy0))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _layout_views_on_sheet(
|
||||||
|
views: Sequence[DrawingView],
|
||||||
|
bboxes: Dict[str, Tuple[float, float, float, float]],
|
||||||
|
) -> Tuple[
|
||||||
|
Dict[str, Tuple[float, float, float, float]],
|
||||||
|
Optional[float],
|
||||||
|
Dict[str, float],
|
||||||
|
]:
|
||||||
|
"""Compute a slot rectangle and sheet rotation for each view.
|
||||||
|
|
||||||
|
*bboxes* maps view_id → ``(min_x, min_y, max_x, max_y)`` in model
|
||||||
|
units (from :func:`_edges_bounds`). Returns ``(slots, common_scale,
|
||||||
|
rotations)``: slots are ``(left, bottom, width, height)`` in sheet mm
|
||||||
|
(origin at the sheet's bottom-left corner, +y up), common_scale is the
|
||||||
|
shared model→sheet scale of the standard orthographic views, and
|
||||||
|
rotations maps view_id → sheet rotation in degrees (0 or 90).
|
||||||
|
|
||||||
|
The sheet is filled, not just used: every candidate arrangement
|
||||||
|
(classic third-angle cross, the cross with the view families swapped,
|
||||||
|
and 1/2/3-row grids) is combined with every per-view 90° rotation
|
||||||
|
assignment, and the candidate giving the largest shared scale is used.
|
||||||
|
Candidates within 0.5% of the best scale prefer the one with fewer
|
||||||
|
rotated views, then the more conventional arrangement, so layouts stay
|
||||||
|
stable and standard whenever they are already the best fit. All
|
||||||
|
orthographic views share one scale so the projections stay mutually
|
||||||
|
consistent. Isometric and custom views take the largest remaining
|
||||||
|
free rect (clearing the title block).
|
||||||
|
"""
|
||||||
|
slots: Dict[str, Tuple[float, float, float, float]] = {}
|
||||||
|
rotations: Dict[str, float] = {}
|
||||||
|
common_scale: Optional[float] = None
|
||||||
|
|
||||||
|
ortho = [
|
||||||
|
v for v in views if v.kind in _STANDARD_VIEWS and v.kind != "isometric"
|
||||||
|
]
|
||||||
|
|
||||||
|
used_rects: List[Tuple[float, float, float, float]] = []
|
||||||
|
if ortho:
|
||||||
|
dims0: Dict[str, Tuple[float, float]] = {}
|
||||||
|
for v in ortho:
|
||||||
|
b = bboxes.get(v.kind)
|
||||||
|
vs = max(v.scale, 1e-9)
|
||||||
|
if b is None:
|
||||||
|
dims0[v.kind] = (1.0 * vs, 1.0 * vs)
|
||||||
|
else:
|
||||||
|
dims0[v.kind] = (
|
||||||
|
max(b[2] - b[0], 1e-6) * vs,
|
||||||
|
max(b[3] - b[1], 1e-6) * vs,
|
||||||
|
)
|
||||||
|
keys = list(dims0)
|
||||||
|
arrangements: Tuple[
|
||||||
|
Tuple[str, int, Callable[[Dict[str, Tuple[float, float]], float], _RectList]]
|
||||||
|
] = (
|
||||||
|
("cross", 0, _place_cross),
|
||||||
|
("swapped", 1, _place_swapped),
|
||||||
|
("grid1", 2, lambda d, g: _place_grid(d, g, 1)),
|
||||||
|
("grid2", 3, lambda d, g: _place_grid(d, g, 2)),
|
||||||
|
("grid3", 4, lambda d, g: _place_grid(d, g, 3)),
|
||||||
|
)
|
||||||
|
cands: List[
|
||||||
|
Tuple[float, int, int, Tuple[float, float, float, float],
|
||||||
|
Dict[str, Tuple[float, float]],
|
||||||
|
Callable[[Dict[str, Tuple[float, float]], float], _RectList],
|
||||||
|
List[bool]]
|
||||||
|
] = []
|
||||||
|
for mask in range(1 << len(keys)):
|
||||||
|
rotated = [bool(mask & (1 << i)) for i in range(len(keys))]
|
||||||
|
dims_m = {
|
||||||
|
k: (
|
||||||
|
dims0[k][1] if rotated[i] else dims0[k][0],
|
||||||
|
dims0[k][0] if rotated[i] else dims0[k][1],
|
||||||
|
)
|
||||||
|
for i, k in enumerate(keys)
|
||||||
|
}
|
||||||
|
for _name, rank, place in arrangements:
|
||||||
|
s_up = _fit_scale(place, dims_m, _REGION_UPPER)
|
||||||
|
s_left = _fit_scale(place, dims_m, _REGION_LEFT)
|
||||||
|
if s_up >= s_left:
|
||||||
|
s, region = s_up, _REGION_UPPER
|
||||||
|
else:
|
||||||
|
s, region = s_left, _REGION_LEFT
|
||||||
|
if s <= 0.0:
|
||||||
|
continue
|
||||||
|
cands.append((s, sum(rotated), rank, region, dims_m, place, rotated))
|
||||||
|
if cands:
|
||||||
|
best_s = max(c[0] for c in cands)
|
||||||
|
s, _nrot, _rank, region, dims_m, place, rotated = min(
|
||||||
|
(c for c in cands if c[0] >= best_s * 0.995),
|
||||||
|
key=lambda c: (c[1], c[2], -c[0]),
|
||||||
|
)
|
||||||
|
rx0, _ry0, rw, rh = region
|
||||||
|
ds = {k: (w * s, h * s) for k, (w, h) in dims_m.items()}
|
||||||
|
rects = place(ds, _VIEW_GAP_MM)
|
||||||
|
minx = min(r[1] for r in rects)
|
||||||
|
miny = min(r[2] for r in rects)
|
||||||
|
maxx = max(r[1] + r[3] for r in rects)
|
||||||
|
maxy = max(r[2] + r[4] for r in rects)
|
||||||
|
ox = rx0 + (rw - (maxx - minx)) / 2.0
|
||||||
|
oy = _ry0 + (rh - (maxy - miny)) / 2.0
|
||||||
|
for vid, x, y, w, h in rects:
|
||||||
|
slots[vid] = (x - minx + ox, y - miny + oy, w, h)
|
||||||
|
rotations[vid] = 90.0 if rotated[keys.index(vid)] else 0.0
|
||||||
|
common_scale = s
|
||||||
|
used_rects = [(ox, oy, maxx - minx, maxy - miny)]
|
||||||
|
|
||||||
|
# Isometric and custom views: the largest remaining free rect each,
|
||||||
|
# clearing the title block.
|
||||||
|
extra = [
|
||||||
|
v for v in views
|
||||||
|
if v.kind == "isometric" or v.kind not in _STANDARD_VIEWS
|
||||||
|
]
|
||||||
|
assigned = list(used_rects)
|
||||||
|
for i, v in enumerate(extra):
|
||||||
|
vid = v.kind if v.kind in _STANDARD_VIEWS else (v.name or v.id)
|
||||||
|
free = _free_rects(assigned)
|
||||||
|
if free:
|
||||||
|
slot = max(free, key=lambda r: r[2] * r[3])
|
||||||
|
else:
|
||||||
|
# No free rect left — park in the bottom-left corner stack.
|
||||||
|
slot = (_LAYOUT_MARGIN_MM, _LAYOUT_MARGIN_MM + i * 60.0, 120.0, 50.0)
|
||||||
|
slots[vid] = slot
|
||||||
|
assigned.append(slot)
|
||||||
|
|
||||||
|
return slots, common_scale, rotations
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import os, sys
|
||||||
|
os.environ["QT_QPA_PLATFORM"] = "offscreen"
|
||||||
|
sys.path.insert(0, "/Volumes/Data_drive/Programming/fluency/src")
|
||||||
|
|
||||||
|
from PySide6.QtWidgets import QApplication
|
||||||
|
from PySide6.QtGui import QPixmap, QPainter, QColor
|
||||||
|
from PySide6.QtCore import QRectF
|
||||||
|
import math
|
||||||
|
|
||||||
|
app = QApplication.instance() or QApplication([])
|
||||||
|
|
||||||
|
from fluency.geometry.base import Point2D
|
||||||
|
from fluency.geometry_occ.kernel import OCGeometryKernel
|
||||||
|
from fluency.models.data_model import Body, Component, Project, DrawingView, TechnicalDrawing
|
||||||
|
from fluency.technical_drawing import generate_drawing, render_drawing, _A3_WIDTH_MM, _A3_HEIGHT_MM
|
||||||
|
|
||||||
|
kernel = OCGeometryKernel()
|
||||||
|
# Long thin bar: 120 x 25 x 30 (matches the "wide bar" screenshot case).
|
||||||
|
points = [Point2D(0, 0), Point2D(120, 0), Point2D(120, 25), Point2D(0, 25)]
|
||||||
|
box = kernel.extrude(kernel.create_polygon(points), 30.0)
|
||||||
|
body = Body(name="Bar", geometry=box)
|
||||||
|
comp = Component(name="BarComp")
|
||||||
|
comp.bodies[body.id] = body
|
||||||
|
project = Project()
|
||||||
|
project.components[comp.id] = comp
|
||||||
|
project.active_component = comp.id
|
||||||
|
|
||||||
|
W = 2400
|
||||||
|
H = int(W * _A3_HEIGHT_MM / _A3_WIDTH_MM)
|
||||||
|
pm = QPixmap(W, H)
|
||||||
|
pm.fill(QColor(255, 255, 255))
|
||||||
|
|
||||||
|
for name, kinds in [
|
||||||
|
("four", ["front", "top", "right", "back"]),
|
||||||
|
("six", ["front", "top", "right", "left", "back", "bottom"]),
|
||||||
|
("sixiso", ["front", "top", "right", "left", "back", "bottom", "isometric"]),
|
||||||
|
]:
|
||||||
|
drawing = TechnicalDrawing(
|
||||||
|
source_kind="component", source_id=comp.id,
|
||||||
|
views=[DrawingView(kind=k) for k in kinds],
|
||||||
|
auto_dimensions=True, title=name,
|
||||||
|
)
|
||||||
|
result = generate_drawing(drawing, project, kernel)
|
||||||
|
p = QPainter(pm)
|
||||||
|
render_drawing(p, result, QRectF(0, 0, W, H))
|
||||||
|
p.end()
|
||||||
|
out = f"/tmp/drawing_{name}.png"
|
||||||
|
pm.save(out)
|
||||||
|
print(name, "saved", out, "prims", len(result.primitives), "scale",
|
||||||
|
round(result.view_transforms.get("front", (None,))[0] or 0, 3))
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
"""Smoke test: layout optimizer fills the page, no overlaps, title block clear."""
|
||||||
|
import math
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
sys.path.insert(0, "/Volumes/Data_drive/Programming/fluency/src")
|
||||||
|
|
||||||
|
from fluency.models.data_model import DrawingView
|
||||||
|
from fluency.technical_drawing import (
|
||||||
|
_layout_views_on_sheet,
|
||||||
|
build_manual_candidates,
|
||||||
|
)
|
||||||
|
|
||||||
|
A3W, A3H = 420.0, 297.0
|
||||||
|
TB = (235.0, 0.0, 420.0, 62.0) # title block zone incl. clearance
|
||||||
|
|
||||||
|
|
||||||
|
def b(x, y, w, h):
|
||||||
|
return (x, y, x + w, y + h)
|
||||||
|
|
||||||
|
|
||||||
|
def overlaps(r1, r2, clear=0.0):
|
||||||
|
x0, y0, w, h = r1
|
||||||
|
x1, y1, w2, h2 = r2
|
||||||
|
return not (x0 + w <= x1 + clear or x1 + w2 <= x0 + clear
|
||||||
|
or y0 + h <= y1 + clear or y1 + h2 <= y0 + clear)
|
||||||
|
|
||||||
|
|
||||||
|
def tb_overlap(r, clear=5.0):
|
||||||
|
x0, y0, w, h = r
|
||||||
|
x1, y1, w2, h2 = TB
|
||||||
|
return not (x0 + w <= x1 + clear or x1 + w2 <= x0 + clear
|
||||||
|
or y0 + h <= y1 + clear or y1 + h2 <= y0 + clear)
|
||||||
|
|
||||||
|
|
||||||
|
def union_rect(rects):
|
||||||
|
x0 = min(r[0] for r in rects)
|
||||||
|
y0 = min(r[1] for r in rects)
|
||||||
|
x1 = max(r[0] + r[2] for r in rects)
|
||||||
|
y1 = max(r[1] + r[3] for r in rects)
|
||||||
|
return (x0, y0, x1 - x0, y1 - y0)
|
||||||
|
|
||||||
|
|
||||||
|
def check(name, kinds, boxes, expect_rot=None):
|
||||||
|
views = [DrawingView(kind=k) for k in kinds]
|
||||||
|
bboxes = {k: boxes[k] for k in kinds}
|
||||||
|
slots, scale, rots = _layout_views_on_sheet(views, bboxes)
|
||||||
|
print(f"--- {name}: scale={scale:.4f} rots={rots}")
|
||||||
|
# all slots within sheet
|
||||||
|
for k, s in slots.items():
|
||||||
|
assert 10 - 1e-6 <= s[0] and 10 - 1e-6 <= s[1], f"{k} outside sheet {s}"
|
||||||
|
assert s[0] + s[2] <= A3W - 10 + 1e-6, f"{k} beyond right {s}"
|
||||||
|
assert s[1] + s[3] <= A3H - 10 + 1e-6, f"{k} beyond top {s}"
|
||||||
|
# no overlaps between slots
|
||||||
|
ks = list(slots)
|
||||||
|
for i in range(len(ks)):
|
||||||
|
for j in range(i + 1, len(ks)):
|
||||||
|
assert not overlaps(slots[ks[i]], slots[ks[j]], 11.9), \
|
||||||
|
f"{ks[i]} overlaps {ks[j]}: {slots[ks[i]]} / {slots[ks[j]]}"
|
||||||
|
# title block clear
|
||||||
|
for k, s in slots.items():
|
||||||
|
assert not tb_overlap(s), f"{k} intrudes title block {s}"
|
||||||
|
# fill report
|
||||||
|
u = union_rect(list(slots.values()))
|
||||||
|
area = u[2] * u[3]
|
||||||
|
print(f" union: x0={u[0]:.1f} y0={u[1]:.1f} w={u[2]:.1f} h={u[3]:.1f} "
|
||||||
|
f"area={area:.0f}mm^2 ({100*area/(A3W*A3H):.0f}% of sheet)")
|
||||||
|
for k in ks:
|
||||||
|
print(f" {k}: {tuple(round(v,1) for v in slots[k])}")
|
||||||
|
if expect_rot is not None:
|
||||||
|
assert rots == expect_rot, f"expected {expect_rot}, got {rots}"
|
||||||
|
return slots, scale, rots
|
||||||
|
|
||||||
|
|
||||||
|
# 1. Two square views (front+top): should fill the page, no rotation.
|
||||||
|
check("two square", ["front", "top"],
|
||||||
|
{"front": b(0, 0, 40, 40), "top": b(0, 0, 40, 20)})
|
||||||
|
|
||||||
|
# 2. Wide bar, 4 views (old screenshot case): front+back wide, top+right.
|
||||||
|
check("wide bar 4", ["front", "top", "right", "back"],
|
||||||
|
{"front": b(0, 0, 120, 25), "top": b(0, 0, 25, 40),
|
||||||
|
"right": b(0, 0, 25, 40), "back": b(0, 0, 120, 25)})
|
||||||
|
|
||||||
|
# 3. Six views of a long thin part: rotation should kick in.
|
||||||
|
check("thin part 6", ["front", "top", "right", "left", "back", "bottom"],
|
||||||
|
{"front": b(0, 0, 200, 30), "top": b(0, 0, 30, 50),
|
||||||
|
"right": b(0, 0, 50, 30), "left": b(0, 0, 50, 30),
|
||||||
|
"back": b(0, 0, 200, 30), "bottom": b(0, 0, 30, 50)})
|
||||||
|
|
||||||
|
# 4. Single front view: fills the whole page.
|
||||||
|
check("single", ["front"], {"front": b(0, 0, 10, 20)})
|
||||||
|
|
||||||
|
# 5. All 6 + isometric + custom.
|
||||||
|
check("everything",
|
||||||
|
["front", "top", "right", "left", "back", "bottom", "isometric"],
|
||||||
|
{"front": b(0, 0, 80, 40), "top": b(0, 0, 80, 30),
|
||||||
|
"right": b(0, 0, 30, 40), "left": b(0, 0, 30, 40),
|
||||||
|
"back": b(0, 0, 80, 40), "bottom": b(0, 0, 80, 30),
|
||||||
|
"isometric": b(0, 0, 60, 60)})
|
||||||
|
|
||||||
|
# ── Inverse-transform roundtrip ─────────────────────────────────────────
|
||||||
|
# A 90°-rotated view: forward via _assemble_view's recorded 6-tuple,
|
||||||
|
# inverse via the widget's formula.
|
||||||
|
t = (2.0, 150.0, 80.0, 90.0, 10.0, 5.0) # s, o_x, o_y, deg, cx, cy
|
||||||
|
scale, ox, oy, deg, cx, cy = t
|
||||||
|
th = math.radians(deg)
|
||||||
|
cos_t, sin_t = math.cos(th), math.sin(th)
|
||||||
|
|
||||||
|
|
||||||
|
def fwd(p):
|
||||||
|
dx, dy = p[0] - cx, p[1] - cy
|
||||||
|
return ((dx * cos_t - dy * sin_t) * scale + ox,
|
||||||
|
(dx * sin_t + dy * cos_t) * scale + oy)
|
||||||
|
|
||||||
|
|
||||||
|
def inv(p):
|
||||||
|
sx, sy = (p[0] - ox) / scale, (p[1] - oy) / scale
|
||||||
|
return (sx * cos_t + sy * sin_t + cx, -sx * sin_t + sy * cos_t + cy)
|
||||||
|
|
||||||
|
|
||||||
|
for p in [(0, 0), (10, 5), (3, -7), (42.5, 11.25)]:
|
||||||
|
rt = inv(fwd(p))
|
||||||
|
assert abs(rt[0] - p[0]) < 1e-9 and abs(rt[1] - p[1]) < 1e-9, (p, rt)
|
||||||
|
print("inverse roundtrip OK")
|
||||||
|
|
||||||
|
# Legacy 3-tuple still works through build_manual_candidates.
|
||||||
|
from fluency.models.data_model import DrawingAnnotation, TechnicalDrawing
|
||||||
|
d = TechnicalDrawing(source_kind="component", source_id="c")
|
||||||
|
ann = DrawingAnnotation(kind="dimension", dimension_kind="length",
|
||||||
|
view_id="front", anchors=[(0.0, 0.0), (0.0, 12.5)],
|
||||||
|
direction=(0.0, 1.0))
|
||||||
|
d.annotations.append(ann)
|
||||||
|
cands, res, unres = build_manual_candidates(d, {"front": (2.0, 10.0, 20.0)})
|
||||||
|
assert unres == [] and cands[0].anchor_points[1] == (10.0, 45.0)
|
||||||
|
print("legacy 3-tuple OK")
|
||||||
|
|
||||||
|
# 6-tuple manual: rotated length direction must rotate too.
|
||||||
|
ann2 = DrawingAnnotation(kind="dimension", dimension_kind="length",
|
||||||
|
view_id="front",
|
||||||
|
anchors=[(0.0, 0.0), (0.0, 10.0)],
|
||||||
|
direction=(0.0, 1.0))
|
||||||
|
d2 = TechnicalDrawing(source_kind="component", source_id="c")
|
||||||
|
d2.annotations.append(ann2)
|
||||||
|
# 90° rotation about centre c=(5,5), scale 2, o=(100,80)
|
||||||
|
cands, res, unres = build_manual_candidates(
|
||||||
|
d2, {"front": (2.0, 100.0, 80.0, 90.0, 5.0, 5.0)}
|
||||||
|
)
|
||||||
|
c = cands[0]
|
||||||
|
# anchors: (0,0)->rot90 about (5,5) = (5-(0-5)*0 - ... compute: dx=-5,dy=-5
|
||||||
|
# fwd: (dx*cos - dy*sin)*2+100 = (0 - (-5))*2+100 = 110 ; (dx*sin+dy*cos)*2+80 = (-5)*2+80=70
|
||||||
|
# (0,10): dx=-5, dy=5 -> (0-5)*2+100=90 ; (−5*1+0)*2+80=70
|
||||||
|
assert c.anchor_points[0] == (110.0, 70.0), c.anchor_points
|
||||||
|
assert c.anchor_points[1] == (90.0, 70.0), c.anchor_points
|
||||||
|
# direction (0,1) rotated 90° CCW -> (-1, 0)
|
||||||
|
assert c.direction[0] == -1.0 and abs(c.direction[1]) < 1e-9, c.direction
|
||||||
|
print("6-tuple manual (rotated) OK")
|
||||||
|
|
||||||
|
print("ALL SMOKE CHECKS PASSED")
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
"""Headless test: connectors follow moved features across ALL assemblies.
|
||||||
|
|
||||||
|
Simulates the test-file scenario: a plate with a hole, mated hole-to-face
|
||||||
|
in two different assemblies. The hole is moved (rebuilt geometry) and the
|
||||||
|
body-update connector recalculation must:
|
||||||
|
1. re-locate the hole connector on every instance of the component
|
||||||
|
(both the active and a non-active assembly),
|
||||||
|
2. re-solve each mated pair so the partner parts follow,
|
||||||
|
3. never snap a planar connector onto the cylindrical hole (type match),
|
||||||
|
4. mark a connector invalid when its feature disappears.
|
||||||
|
5. auto-follow a FAR move when the candidate is unambiguous (single
|
||||||
|
feature of its class) — the demo case,
|
||||||
|
6. NOT auto-apply an ambiguous far candidate (another same-class feature
|
||||||
|
is nearer) — that needs a manual pick, simulated here.
|
||||||
|
7. backfill legacy connectors' entity_type from their auto-generated name.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "src"))
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from OCP.gp import gp_Pnt, gp_Dir, gp_Ax2
|
||||||
|
from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox, BRepPrimAPI_MakeCylinder
|
||||||
|
from OCP.BRepAlgoAPI import BRepAlgoAPI_Cut
|
||||||
|
|
||||||
|
from PySide6.QtWidgets import QApplication
|
||||||
|
|
||||||
|
from fluency.ui.main_window import MainWindow
|
||||||
|
from fluency.models.data_model import Assembly, Body
|
||||||
|
from fluency.geometry_occ.kernel import OCCGeometryObject
|
||||||
|
|
||||||
|
app = QApplication.instance() or QApplication([])
|
||||||
|
w = MainWindow()
|
||||||
|
|
||||||
|
comp = w._current_component
|
||||||
|
|
||||||
|
|
||||||
|
def make_plate(hole_xy):
|
||||||
|
box = BRepPrimAPI_MakeBox(60.0, 40.0, 5.0).Shape()
|
||||||
|
ax = gp_Ax2(gp_Pnt(float(hole_xy[0]), float(hole_xy[1]), 0.0), gp_Dir(0, 0, 1))
|
||||||
|
cyl = BRepPrimAPI_MakeCylinder(ax, 3.0, 6.0).Shape()
|
||||||
|
return BRepAlgoAPI_Cut(box, cyl).Shape()
|
||||||
|
|
||||||
|
|
||||||
|
body = Body(name="plate")
|
||||||
|
comp.bodies[body.id] = body
|
||||||
|
body.geometry = OCCGeometryObject(make_plate((10.0, 5.0)))
|
||||||
|
|
||||||
|
partner = w._project.add_component()
|
||||||
|
pbody = Body(name="partner")
|
||||||
|
partner.bodies[pbody.id] = pbody
|
||||||
|
pbody.geometry = OCCGeometryObject(BRepPrimAPI_MakeBox(30.0, 30.0, 10.0).Shape())
|
||||||
|
|
||||||
|
|
||||||
|
def make_pair(asm):
|
||||||
|
ac1 = asm.add_component_instance(comp.id, name="A")
|
||||||
|
ac2 = asm.add_component_instance(partner.id, name="B")
|
||||||
|
ac1.position = np.zeros(3)
|
||||||
|
ac1.rotation = np.eye(3)
|
||||||
|
ac2.position = np.array([0.0, 0.0, 15.0])
|
||||||
|
ac2.rotation = np.eye(3)
|
||||||
|
ac1.geom_cache[body.id] = body.geometry
|
||||||
|
|
||||||
|
c1 = ac1.add_connector(
|
||||||
|
position=(10.0, 5.0, 2.5),
|
||||||
|
normal=(0.0, 0.0, 1.0),
|
||||||
|
x_dir=(1.0, 0.0, 0.0),
|
||||||
|
source_obj_id=f"asm_{ac1.id}_{body.id}",
|
||||||
|
name="Conn hole A",
|
||||||
|
entity_type="cylindrical_face",
|
||||||
|
)
|
||||||
|
c2 = ac2.add_connector(
|
||||||
|
position=(15.0, 15.0, 0.0),
|
||||||
|
normal=(0.0, 0.0, -1.0),
|
||||||
|
x_dir=(1.0, 0.0, 0.0),
|
||||||
|
source_obj_id=f"asm_{ac2.id}_{pbody.id}",
|
||||||
|
name="Conn face B",
|
||||||
|
entity_type="planar_face",
|
||||||
|
)
|
||||||
|
c1.is_grounded = True
|
||||||
|
c1.partner_ac_id = ac2.id
|
||||||
|
c1.partner_connector_id = c2.id
|
||||||
|
c2.partner_ac_id = ac1.id
|
||||||
|
c2.partner_connector_id = c1.id
|
||||||
|
aconn = asm.add_connection(ac1.id, ac2.id)
|
||||||
|
aconn.first_connector_id = c1.id
|
||||||
|
aconn.second_connector_id = c2.id
|
||||||
|
return ac1, ac2, c1, c2, aconn
|
||||||
|
|
||||||
|
|
||||||
|
asm1 = w._project.get_active_assembly()
|
||||||
|
asm2 = w._project.add_assembly(Assembly(name="second"))
|
||||||
|
pair1 = make_pair(asm1)
|
||||||
|
pair2 = make_pair(asm2)
|
||||||
|
assert w._project.active_assembly == asm1.id # asm2 is the NON-active one
|
||||||
|
|
||||||
|
# ── Move the hole: rebuild the plate with the hole at (25, 12) ──────────
|
||||||
|
new_geom = OCCGeometryObject(make_plate((25.0, 12.0)))
|
||||||
|
body.geometry = new_geom
|
||||||
|
for asm in (asm1, asm2):
|
||||||
|
for ac in asm.components.values():
|
||||||
|
if ac.component_id == comp.id:
|
||||||
|
ac.geom_cache[body.id] = new_geom
|
||||||
|
|
||||||
|
# ── The body-update auto path ───────────────────────────────────────────
|
||||||
|
w._recalculate_connectors()
|
||||||
|
|
||||||
|
for (ac1, ac2, c1, c2, aconn), label in ((pair1, "asm1"), (pair2, "asm2")):
|
||||||
|
# The hole connector followed the hole on every instance.
|
||||||
|
assert np.allclose(c1.position, (25.0, 12.0, 2.5), atol=1e-6), (label, c1.position)
|
||||||
|
# The mated pair re-aligned: both world connectors coincide.
|
||||||
|
w1 = ac1.position + ac1.rotation @ np.asarray(c1.position)
|
||||||
|
w2 = ac2.position + ac2.rotation @ np.asarray(c2.position)
|
||||||
|
assert np.allclose(w1, w2, atol=1e-6), (label, w1, w2)
|
||||||
|
print(f"{label}: connector at {np.round(c1.position, 3)}, "
|
||||||
|
f"partner moved to {np.round(ac2.position, 3)}")
|
||||||
|
|
||||||
|
# ── The 'Upd' button path: move the hole again, re-run the handler ─────
|
||||||
|
w._refresh_connection_list()
|
||||||
|
w._connection_list.setCurrentRow(0) # active assembly = asm1
|
||||||
|
geom3 = OCCGeometryObject(make_plate((35.0, 20.0)))
|
||||||
|
body.geometry = geom3
|
||||||
|
for ac in asm1.components.values():
|
||||||
|
if ac.component_id == comp.id:
|
||||||
|
ac.geom_cache[body.id] = geom3
|
||||||
|
w._on_update_connection_from_list()
|
||||||
|
ac1, ac2, c1, c2, aconn = pair1
|
||||||
|
assert np.allclose(c1.position, (35.0, 20.0, 2.5), atol=1e-6), c1.position
|
||||||
|
w1 = ac1.position + ac1.rotation @ np.asarray(c1.position)
|
||||||
|
w2 = ac2.position + ac2.rotation @ np.asarray(c2.position)
|
||||||
|
assert np.allclose(w1, w2, atol=1e-6), (w1, w2)
|
||||||
|
print("Upd button: connector at", np.round(c1.position, 3),
|
||||||
|
"partner at", np.round(ac2.position, 3))
|
||||||
|
|
||||||
|
# ── Type matching: a planar connector must not snap onto the hole ───────
|
||||||
|
ac1 = pair1[0]
|
||||||
|
c3 = ac1.add_connector(
|
||||||
|
position=(30.0, 20.0, 5.0),
|
||||||
|
normal=(0.0, 0.0, 1.0),
|
||||||
|
x_dir=(1.0, 0.0, 0.0),
|
||||||
|
source_obj_id=f"asm_{ac1.id}_{body.id}",
|
||||||
|
name="Conn face",
|
||||||
|
entity_type="planar_face",
|
||||||
|
)
|
||||||
|
res = w._redetect_connector_on_geometry(c3, ac1, comp)
|
||||||
|
assert res is not None, "planar connector candidate missing"
|
||||||
|
assert not c3.is_invalid, "pure relocator must not mutate the connector"
|
||||||
|
assert np.allclose(c3.position, (30.0, 20.0, 5.0), atol=1e-6), c3.position
|
||||||
|
assert np.allclose(res[1], (30.0, 20.0, 5.0), atol=1e-6), res[1]
|
||||||
|
print("planar connector stayed on the face:", np.round(res[1], 3))
|
||||||
|
|
||||||
|
# ── Feature removed: relocator finds nothing; auto path marks invalid ──
|
||||||
|
plain = OCCGeometryObject(BRepPrimAPI_MakeBox(60.0, 40.0, 5.0).Shape())
|
||||||
|
body.geometry = plain
|
||||||
|
ac1.geom_cache[body.id] = plain
|
||||||
|
res = w._redetect_connector_on_geometry(pair1[2], ac1, comp)
|
||||||
|
assert res is None, "hole connector should find no candidate on a plain box"
|
||||||
|
w._recalculate_connectors()
|
||||||
|
assert pair1[2].is_invalid, "auto path must mark the connector invalid"
|
||||||
|
print("removed feature -> connector marked invalid")
|
||||||
|
|
||||||
|
# ── Far move with a decoy: ambiguous candidate is NOT auto-applied ─────
|
||||||
|
# The real hole moved to (25, 12) — ~12.8mm from the stored (35, 20) — but
|
||||||
|
# a SECOND hole now sits at (28, 16), only ~8mm away. The nearest
|
||||||
|
# candidate is ambiguous (different feature), so the auto path must leave
|
||||||
|
# the connector alone and queue it for a manual pick.
|
||||||
|
def make_plate2(holes):
|
||||||
|
box = BRepPrimAPI_MakeBox(60.0, 40.0, 5.0).Shape()
|
||||||
|
for hx, hy in holes:
|
||||||
|
ax = gp_Ax2(gp_Pnt(hx, hy, 0.0), gp_Dir(0, 0, 1))
|
||||||
|
box = BRepAlgoAPI_Cut(box, BRepPrimAPI_MakeCylinder(ax, 3.0, 6.0).Shape()).Shape()
|
||||||
|
return box
|
||||||
|
# ── Stage 1: the user's demo — a SINGLE hole moved far away ─────────────
|
||||||
|
# 12.8mm from the stored position, but the only cylindrical face on the
|
||||||
|
# body → unambiguous → must be auto-applied (and the mate re-solved).
|
||||||
|
geom4 = OCCGeometryObject(make_plate2([(25.0, 12.0)]))
|
||||||
|
body.geometry = geom4
|
||||||
|
for asm in (asm1, asm2):
|
||||||
|
for ac in asm.components.values():
|
||||||
|
if ac.component_id == comp.id:
|
||||||
|
ac.geom_cache[body.id] = geom4
|
||||||
|
w._recalculate_connectors()
|
||||||
|
assert np.allclose(pair1[2].position, (25.0, 12.0, 2.5), atol=1e-6), \
|
||||||
|
"unique far candidate must be auto-applied (the demo case)"
|
||||||
|
assert not pair1[2].is_invalid
|
||||||
|
w1 = ac1.position + ac1.rotation @ np.asarray(pair1[2].position)
|
||||||
|
w2 = pair1[1].position + pair1[1].rotation @ np.asarray(pair1[3].position)
|
||||||
|
assert np.allclose(w1, w2, atol=1e-6), (w1, w2)
|
||||||
|
print("single far hole: auto-followed to", np.round(pair1[2].position, 3))
|
||||||
|
|
||||||
|
# ── Stage 2: far move with a decoy — ambiguous, NOT auto-applied ───────
|
||||||
|
# The real hole now sits at (32, 20) — 10.6mm from the stored (25, 12) —
|
||||||
|
# while a decoy hole at (21, 7) is only 6.4mm away. The nearest
|
||||||
|
# candidate is likely a DIFFERENT feature, so the auto path must leave
|
||||||
|
# the connector alone and queue it for a manual pick.
|
||||||
|
geom5 = OCCGeometryObject(make_plate2([(32.0, 20.0), (21.0, 7.0)]))
|
||||||
|
body.geometry = geom5
|
||||||
|
for asm in (asm1, asm2):
|
||||||
|
for ac in asm.components.values():
|
||||||
|
if ac.component_id == comp.id:
|
||||||
|
ac.geom_cache[body.id] = geom5
|
||||||
|
w._recalculate_connectors()
|
||||||
|
assert np.allclose(pair1[2].position, (25.0, 12.0, 2.5), atol=1e-6), \
|
||||||
|
"ambiguous far candidate must not be auto-applied"
|
||||||
|
assert not pair1[2].is_invalid, "ambiguous candidate is not a missing feature"
|
||||||
|
|
||||||
|
# Simulate the user clicking the REAL hole in the relocate pick flow:
|
||||||
|
w._relocate_pending = [(asm1, ac1, pair1[2])]
|
||||||
|
w._on_relocate_picked(
|
||||||
|
(32.0, 20.0, 2.5), (0.0, 0.0, 1.0), (1.0, 0.0, 0.0),
|
||||||
|
"cylindrical_face", f"asm_{ac1.id}_{body.id}",
|
||||||
|
)
|
||||||
|
assert np.allclose(pair1[2].position, (32.0, 20.0, 2.5), atol=1e-6), pair1[2].position
|
||||||
|
assert not pair1[2].is_invalid, "manual pick must re-validate the connector"
|
||||||
|
assert w._relocate_pending is None, "pending queue must drain after the pick"
|
||||||
|
w1 = ac1.position + ac1.rotation @ np.asarray(pair1[2].position)
|
||||||
|
w2 = pair1[1].position + pair1[1].rotation @ np.asarray(pair1[3].position)
|
||||||
|
assert np.allclose(w1, w2, atol=1e-6), (w1, w2)
|
||||||
|
print("far move: manual pick re-homed connector at", np.round(pair1[2].position, 3),
|
||||||
|
"partner at", np.round(pair1[1].position, 3))
|
||||||
|
|
||||||
|
# ── Legacy backfill: empty entity_type recovered from the auto name ────
|
||||||
|
from fluency.models.data_model import Connector
|
||||||
|
legacy = Connector(
|
||||||
|
name="Conn cylindrical_face anchor",
|
||||||
|
position=(32.0, 20.0, 2.5),
|
||||||
|
source_obj_id=f"asm_{ac1.id}_{body.id}",
|
||||||
|
)
|
||||||
|
assert legacy.entity_type == "cylindrical_face", legacy.entity_type
|
||||||
|
res = w._redetect_connector_on_geometry(legacy, ac1, comp)
|
||||||
|
assert res is not None and res[0] < 1e-3, res
|
||||||
|
print("legacy name backfill: entity_type =", legacy.entity_type)
|
||||||
|
|
||||||
|
print("CONNECTOR_RELOCATE_OK")
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
"""Headless repro: load assemblytest.fluency, compare sketch circle centers
|
||||||
|
vs. saved body hole axes vs. connector positions, then run the real
|
||||||
|
body-update path and check where connectors land.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "src"))
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from OCP.BRepAdaptor import BRepAdaptor_Surface
|
||||||
|
from OCP.GeomAbs import GeomAbs_Cylinder
|
||||||
|
from OCP.TopAbs import TopAbs_FACE
|
||||||
|
from OCP.TopExp import TopExp_Explorer
|
||||||
|
from OCP.TopoDS import TopoDS
|
||||||
|
|
||||||
|
from PySide6.QtWidgets import QApplication
|
||||||
|
|
||||||
|
from fluency.ui.main_window import MainWindow
|
||||||
|
|
||||||
|
def etype(e):
|
||||||
|
if isinstance(e, dict):
|
||||||
|
return e.get("type")
|
||||||
|
return getattr(e, "entity_type", None) or getattr(e, "type", None)
|
||||||
|
|
||||||
|
def egeom(e):
|
||||||
|
if isinstance(e, dict):
|
||||||
|
return e.get("geometry")
|
||||||
|
return getattr(e, "geometry", None)
|
||||||
|
|
||||||
|
app = QApplication.instance() or QApplication([])
|
||||||
|
w = MainWindow()
|
||||||
|
|
||||||
|
path = os.path.join(os.path.dirname(__file__), "assemblytest.fluency")
|
||||||
|
ok = w._open_project_file(path)
|
||||||
|
assert ok, "failed to open demo file"
|
||||||
|
|
||||||
|
proj = w._project
|
||||||
|
|
||||||
|
for comp in proj.components.values():
|
||||||
|
print(f"=== {comp.name} ({comp.id})")
|
||||||
|
for sk in comp.sketches.values():
|
||||||
|
occ = sk.occ_sketch
|
||||||
|
if occ is None:
|
||||||
|
print(" sketch with no occ_sketch:", sk.id)
|
||||||
|
continue
|
||||||
|
for e in occ._entities.values():
|
||||||
|
t = etype(e)
|
||||||
|
if t == "circle":
|
||||||
|
g = egeom(e)
|
||||||
|
cid = e.get("id") if isinstance(e, dict) else e.id
|
||||||
|
print(f" circle id={cid} center=({g[0][0]!r}, {g[0][1]!r}) r={g[1]!r}")
|
||||||
|
for body in comp.bodies.values():
|
||||||
|
if not body.geometry:
|
||||||
|
print(f" body {body.name}: no geometry")
|
||||||
|
continue
|
||||||
|
shape = w._kernel._get_shape(body.geometry)
|
||||||
|
print(f" body {body.name}: extrude len={body.extrude_length}")
|
||||||
|
expl = TopExp_Explorer(shape, TopAbs_FACE)
|
||||||
|
while expl.More():
|
||||||
|
face = TopoDS.Face_s(expl.Current())
|
||||||
|
try:
|
||||||
|
adaptor = BRepAdaptor_Surface(face)
|
||||||
|
if adaptor.GetType() == GeomAbs_Cylinder:
|
||||||
|
cyl = adaptor.Cylinder()
|
||||||
|
loc = cyl.Location()
|
||||||
|
d = cyl.Axis().Direction()
|
||||||
|
print(
|
||||||
|
f" cyl axis at ({loc.X()!r}, {loc.Y()!r}) "
|
||||||
|
f"dir=({d.X():.4f},{d.Y():.4f},{d.Z():.4f})"
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
expl.Next()
|
||||||
|
|
||||||
|
asm = proj.get_active_assembly()
|
||||||
|
print("=== active assembly:", asm.name)
|
||||||
|
for ac in asm.components.values():
|
||||||
|
comp = proj.get_component_by_id(ac.component_id)
|
||||||
|
print(f" instance '{ac.name}' -> {comp.name}, pos={np.round(ac.position, 4)}")
|
||||||
|
for conn in ac.connectors.values():
|
||||||
|
print(
|
||||||
|
f" conn '{conn.name}' pos={np.round(conn.position, 6)} "
|
||||||
|
f"normal={np.round(conn.normal, 3)} et={conn.entity_type!r} "
|
||||||
|
f"invalid={conn.is_invalid}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Run the real update path: rebuild bodies from sketch, recalc connectors ──
|
||||||
|
# Activate the component with the holes (Component 1).
|
||||||
|
comp1 = None
|
||||||
|
for comp in proj.components.values():
|
||||||
|
if any(
|
||||||
|
etype(e) == "circle"
|
||||||
|
for sk in comp.sketches.values()
|
||||||
|
for e in (sk.occ_sketch._entities.values() if sk.occ_sketch else [])
|
||||||
|
):
|
||||||
|
comp1 = comp
|
||||||
|
break
|
||||||
|
assert comp1 is not None
|
||||||
|
w._current_component = comp1
|
||||||
|
print("=== running _update_bodies_from_sketch()")
|
||||||
|
w._update_bodies_from_sketch()
|
||||||
|
print("=== running _recalculate_connectors()")
|
||||||
|
w._recalculate_connectors()
|
||||||
|
|
||||||
|
print("=== after update")
|
||||||
|
for comp in proj.components.values():
|
||||||
|
for body in comp.bodies.values():
|
||||||
|
if not body.geometry:
|
||||||
|
continue
|
||||||
|
shape = w._kernel._get_shape(body.geometry)
|
||||||
|
expl = TopExp_Explorer(shape, TopAbs_FACE)
|
||||||
|
axes = []
|
||||||
|
while expl.More():
|
||||||
|
face = TopoDS.Face_s(expl.Current())
|
||||||
|
try:
|
||||||
|
adaptor = BRepAdaptor_Surface(face)
|
||||||
|
if adaptor.GetType() == GeomAbs_Cylinder:
|
||||||
|
loc = adaptor.Cylinder().Location()
|
||||||
|
axes.append((round(loc.X(), 9), round(loc.Y(), 9)))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
expl.Next()
|
||||||
|
print(f" {comp.name} body axes: {axes}")
|
||||||
|
for sk in comp.sketches.values():
|
||||||
|
for e in sk.occ_sketch._entities.values():
|
||||||
|
t = etype(e)
|
||||||
|
if t == "circle":
|
||||||
|
g = egeom(e)
|
||||||
|
print(f" {comp.name} circle: ({g[0][0]!r}, {g[0][1]!r})")
|
||||||
|
|
||||||
|
asm = proj.get_active_assembly()
|
||||||
|
for ac in asm.components.values():
|
||||||
|
for conn in ac.connectors.values():
|
||||||
|
print(
|
||||||
|
f" conn '{conn.name}' pos={np.round(conn.position, 6)} "
|
||||||
|
f"invalid={conn.is_invalid}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Partner alignment check: for each connection, world positions of the pair.
|
||||||
|
for aconn in asm.connections:
|
||||||
|
a1 = asm.components.get(aconn.first_ac_id)
|
||||||
|
a2 = asm.components.get(aconn.second_ac_id)
|
||||||
|
c1 = a1.connectors.get(aconn.first_connector_id)
|
||||||
|
c2 = a2.connectors.get(aconn.second_connector_id)
|
||||||
|
if c1 is None or c2 is None:
|
||||||
|
continue
|
||||||
|
w1 = a1.position + a1.rotation @ np.asarray(c1.position, dtype=float)
|
||||||
|
w2 = a2.position + a2.rotation @ np.asarray(c2.position, dtype=float)
|
||||||
|
print(
|
||||||
|
f" conn {aconn.id[:8]}: w1={np.round(w1, 6)} w2={np.round(w2, 6)} "
|
||||||
|
f"gap={float(np.linalg.norm(w1 - w2))!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
print("DEMO_REPRO_DONE")
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
"""Round-trip smoke test: instance-local sketches + modifiers survive save/load.
|
||||||
|
|
||||||
|
Builds a minimal project (component with a body, assembly with two instances,
|
||||||
|
one carrying an instance sketch + cut modifier + fillet modifier), saves to a
|
||||||
|
temp .fluency, reloads, and asserts:
|
||||||
|
1. the shared component is untouched by instance work,
|
||||||
|
2. the instance sketch + modifiers round-trip with sketch refs intact,
|
||||||
|
3. the plain instance has none.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from fluency.models.data_model import (
|
||||||
|
Project, Component, Body, Sketch, Feature, Assembly, AssemblyComponent,
|
||||||
|
)
|
||||||
|
from fluency.io.project_io import save_project, load_project
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
project = Project(name="inst test")
|
||||||
|
comp = project.add_component()
|
||||||
|
comp.name = "BasePart"
|
||||||
|
body = comp.add_body(Body(name="MainBody"))
|
||||||
|
base_sketch = comp.add_sketch(Sketch(name="BaseSketch"))
|
||||||
|
body.features.append(
|
||||||
|
Feature(operation="extrude", sketch=base_sketch, length=10.0)
|
||||||
|
)
|
||||||
|
|
||||||
|
asm = project.add_assembly(Assembly(name="TestAsm"))
|
||||||
|
ac1 = asm.add_component_instance(comp.id, name="Instance A")
|
||||||
|
ac1.position = np.array([0.0, 0.0, 0.0])
|
||||||
|
ac2 = asm.add_component_instance(comp.id, name="Instance B")
|
||||||
|
ac2.position = np.array([50.0, 0.0, 0.0])
|
||||||
|
|
||||||
|
# Instance A: local sketch + cut modifier referencing it + fillet.
|
||||||
|
inst_sketch = ac1.add_instance_sketch()
|
||||||
|
inst_sketch.name = "InstCutSketch"
|
||||||
|
ac1.add_modifier(body.id, Feature(operation="cut", sketch=inst_sketch,
|
||||||
|
length=5.0, through_all=True))
|
||||||
|
ac1.add_modifier(body.id, Feature(operation="fillet", radius=1.0))
|
||||||
|
|
||||||
|
# ---- save / load ----
|
||||||
|
fd, path = tempfile.mkstemp(suffix=".fluency")
|
||||||
|
os.close(fd)
|
||||||
|
try:
|
||||||
|
save_project(project, path)
|
||||||
|
loaded, _view = load_project(path)
|
||||||
|
|
||||||
|
lcomp = loaded.components[comp.id]
|
||||||
|
lac1 = None
|
||||||
|
lac2 = None
|
||||||
|
for lasm in loaded.assemblies.values():
|
||||||
|
for ac in lasm.components.values():
|
||||||
|
if ac.name == "Instance A":
|
||||||
|
lac1 = ac
|
||||||
|
elif ac.name == "Instance B":
|
||||||
|
lac2 = ac
|
||||||
|
assert lac1 is not None and lac2 is not None, "instances missing"
|
||||||
|
|
||||||
|
# 1. component untouched
|
||||||
|
assert len(lcomp.sketches) == 1, "component sketch count changed"
|
||||||
|
assert len(lcomp.bodies[body.id].features) == 1, "feature chain changed"
|
||||||
|
assert not getattr(lcomp.bodies[body.id], "modifiers", None)
|
||||||
|
|
||||||
|
# 2. instance A round-trip
|
||||||
|
assert len(lac1.sketches) == 1, "instance sketch missing"
|
||||||
|
lsk_id, lsk = next(iter(lac1.sketches.items()))
|
||||||
|
assert lsk.name == "InstCutSketch"
|
||||||
|
mods = lac1.modifiers
|
||||||
|
lbody_id = next(iter(lcomp.bodies))
|
||||||
|
assert len(mods.get(lbody_id, [])) == 2, f"modifiers missing: {mods}"
|
||||||
|
cut, fil = mods[lbody_id][0], mods[lbody_id][1]
|
||||||
|
assert cut.operation == "cut" and fil.operation == "fillet"
|
||||||
|
assert cut.sketch is not None and cut.sketch.id == lsk_id, \
|
||||||
|
"cut sketch ref did not resolve to instance sketch"
|
||||||
|
assert cut.length == 5.0 and cut.through_all
|
||||||
|
|
||||||
|
# 3. plain instance clean
|
||||||
|
assert not lac2.sketches and not lac2.modifiers
|
||||||
|
|
||||||
|
print("ROUND_TRIP_OK")
|
||||||
|
finally:
|
||||||
|
os.unlink(path)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
"""Headless smoke: the manual re-pick fallback path (prompt -> frame -> pick mode)."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "src"))
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from OCP.gp import gp_Pnt, gp_Dir, gp_Ax2
|
||||||
|
from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox, BRepPrimAPI_MakeCylinder
|
||||||
|
from OCP.BRepAlgoAPI import BRepAlgoAPI_Cut
|
||||||
|
|
||||||
|
from PySide6.QtWidgets import QApplication, QMessageBox
|
||||||
|
|
||||||
|
from fluency.ui.main_window import MainWindow
|
||||||
|
from fluency.models.data_model import Body
|
||||||
|
from fluency.geometry_occ.kernel import OCCGeometryObject
|
||||||
|
|
||||||
|
app = QApplication.instance() or QApplication([])
|
||||||
|
w = MainWindow()
|
||||||
|
|
||||||
|
comp = w._current_component
|
||||||
|
body = Body(name="plate")
|
||||||
|
comp.bodies[body.id] = body
|
||||||
|
box = BRepPrimAPI_MakeBox(60.0, 40.0, 5.0).Shape()
|
||||||
|
ax = gp_Ax2(gp_Pnt(10.0, 5.0, 0.0), gp_Dir(0, 0, 1))
|
||||||
|
box = BRepAlgoAPI_Cut(box, BRepPrimAPI_MakeCylinder(ax, 3.0, 6.0).Shape()).Shape()
|
||||||
|
body.geometry = OCCGeometryObject(box)
|
||||||
|
|
||||||
|
partner = w._project.add_component()
|
||||||
|
pbody = Body(name="partner")
|
||||||
|
partner.bodies[pbody.id] = pbody
|
||||||
|
pbody.geometry = OCCGeometryObject(BRepPrimAPI_MakeBox(30.0, 30.0, 10.0).Shape())
|
||||||
|
|
||||||
|
asm = w._project.get_active_assembly()
|
||||||
|
ac1 = asm.add_component_instance(comp.id, name="A")
|
||||||
|
ac2 = asm.add_component_instance(partner.id, name="B")
|
||||||
|
ac1.geom_cache[body.id] = body.geometry
|
||||||
|
c1 = ac1.add_connector(
|
||||||
|
position=(10.0, 5.0, 2.5), normal=(0, 0, 1), x_dir=(1, 0, 0),
|
||||||
|
source_obj_id=f"asm_{ac1.id}_{body.id}",
|
||||||
|
name="Conn hole", entity_type="cylindrical_face",
|
||||||
|
)
|
||||||
|
c1.partner_ac_id = ac2.id
|
||||||
|
|
||||||
|
# Activate the assembly view so the prompt path is taken.
|
||||||
|
w._assembly_view_active = True
|
||||||
|
w._selected_assembly_component_id = ac1.id
|
||||||
|
|
||||||
|
# Force a Yes from the question dialog, and record that it actually fired.
|
||||||
|
asked = {}
|
||||||
|
def fake_question(parent, title, text, buttons, default):
|
||||||
|
asked["title"] = title
|
||||||
|
return QMessageBox.StandardButton.Yes
|
||||||
|
QMessageBox.question = staticmethod(fake_question)
|
||||||
|
def fake_warning(*a, **k):
|
||||||
|
return QMessageBox.StandardButton.Ok
|
||||||
|
QMessageBox.warning = staticmethod(fake_warning)
|
||||||
|
|
||||||
|
w._prompt_relocate_unresolved([(asm, ac1, c1)])
|
||||||
|
assert asked.get("title") == "Connector Position Needed", asked
|
||||||
|
assert w._relocate_pending == [(asm, ac1, c1)], w._relocate_pending
|
||||||
|
assert w._viewer_3d._connector_pick_mode, "pick mode must be active"
|
||||||
|
|
||||||
|
# A wrong-part click must not consume the pending entry.
|
||||||
|
w._on_relocate_picked((0, 0, 0), (0, 0, 1), (1, 0, 0), "planar_face", f"asm_{ac2.id}_{pbody.id}")
|
||||||
|
assert w._relocate_pending == [(asm, ac1, c1)], "wrong part must not consume the pick"
|
||||||
|
|
||||||
|
# The right click re-homes and drains.
|
||||||
|
w._on_relocate_picked((40.0, 30.0, 2.5), (0, 0, 1), (1, 0, 0), "cylindrical_face", f"asm_{ac1.id}_{body.id}")
|
||||||
|
assert w._relocate_pending is None
|
||||||
|
assert np.allclose(c1.position, (40.0, 30.0, 2.5)), c1.position
|
||||||
|
assert not w._viewer_3d._connector_pick_mode, "pick mode must be off after completion"
|
||||||
|
|
||||||
|
# Esc cancel mid-flight clears the state.
|
||||||
|
w._relocate_pending = [(asm, ac1, c1)]
|
||||||
|
w._start_relocate_pick_next()
|
||||||
|
w._on_connector_pick_cancelled()
|
||||||
|
assert w._relocate_pending is None
|
||||||
|
assert not w._viewer_3d._connector_pick_mode
|
||||||
|
print("RELOCATE_PICK_FALLBACK_OK")
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
"""Headless test: update_external_entities handles circle/arc dict entries.
|
||||||
|
|
||||||
|
Reproduces the crash where a re-projected face contains a circular edge
|
||||||
|
(e.g. an instance cut hole): _project_face_to_uv returns a mixed list of
|
||||||
|
polylines + curve dicts, and update_external_entities used to unpack the
|
||||||
|
dict entries as (u, v) tuples.
|
||||||
|
|
||||||
|
Covers:
|
||||||
|
1. mixed projection (polylines + circle dict) -> rebuild + rebind path,
|
||||||
|
no crash, no duplicate/orphan curve entities, user geometry re-anchored.
|
||||||
|
2. repeated update with the same mixed projection -> stable (idempotent).
|
||||||
|
3. polylines-only same-topology projection -> in-place path still works
|
||||||
|
(external ids preserved).
|
||||||
|
"""
|
||||||
|
import math
|
||||||
|
|
||||||
|
from fluency.geometry_occ.sketch import OCCSketch
|
||||||
|
|
||||||
|
RECT = [
|
||||||
|
[(0.0, 0.0), (10.0, 0.0)],
|
||||||
|
[(10.0, 0.0), (10.0, 10.0)],
|
||||||
|
[(10.0, 10.0), (0.0, 10.0)],
|
||||||
|
[(0.0, 10.0), (0.0, 0.0)],
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _counts(sk):
|
||||||
|
ents = list(sk._entities.values())
|
||||||
|
return {
|
||||||
|
"ext_points": sum(
|
||||||
|
1
|
||||||
|
for e in ents
|
||||||
|
if e.entity_type == "point" and getattr(e, "is_external", False)
|
||||||
|
),
|
||||||
|
"ext_lines": sum(
|
||||||
|
1
|
||||||
|
for e in ents
|
||||||
|
if e.entity_type == "line" and getattr(e, "is_external", False)
|
||||||
|
),
|
||||||
|
"circles": sum(1 for e in ents if e.entity_type == "circle"),
|
||||||
|
"arcs": sum(1 for e in ents if e.entity_type == "arc"),
|
||||||
|
"user_points": sum(
|
||||||
|
1
|
||||||
|
for e in ents
|
||||||
|
if e.entity_type == "point" and not getattr(e, "is_external", False)
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_mixed_projection_rebuild():
|
||||||
|
sk = OCCSketch()
|
||||||
|
sk.add_external_polylines([list(p) for p in RECT])
|
||||||
|
center = sk.add_external_point(5.0, 5.0)
|
||||||
|
sk.add_circle(center, 2.0)
|
||||||
|
user = sk.add_point(5.0, 5.0)
|
||||||
|
assert sk.constrain_coincident(user, center)
|
||||||
|
assert sk.solve()
|
||||||
|
|
||||||
|
# Re-projection: same rectangle, circle moved + resized -> mixed list.
|
||||||
|
new_proj = [
|
||||||
|
list(p) for p in RECT
|
||||||
|
] + [
|
||||||
|
{"type": "circle", "center": [6.0, 6.0], "radius": 1.5},
|
||||||
|
]
|
||||||
|
old_ext_ids = set(sk._external_entity_ids)
|
||||||
|
assert sk.update_external_entities(new_proj), "rebuild + rebind solve failed"
|
||||||
|
|
||||||
|
c = _counts(sk)
|
||||||
|
assert c["circles"] == 1, f"duplicate circle entities: {c}"
|
||||||
|
assert c["ext_points"] == 5, f"ext point count wrong (expect 4 corners + 1 centre): {c}"
|
||||||
|
assert c["ext_lines"] == 4, f"ext line count wrong (expect 4): {c}"
|
||||||
|
assert c["user_points"] == 1
|
||||||
|
# The coincident rebind must anchor the user point to the NEW centre.
|
||||||
|
ux, uy = user.geometry
|
||||||
|
assert math.hypot(ux - 6.0, uy - 6.0) < 1e-6, f"user point at {(ux, uy)}"
|
||||||
|
# Rebuild path: fresh external ids.
|
||||||
|
assert not (old_ext_ids & sk._external_entity_ids)
|
||||||
|
|
||||||
|
# Idempotent second pass with the same projection.
|
||||||
|
assert sk.update_external_entities(list(new_proj)), "second pass failed"
|
||||||
|
c2 = _counts(sk)
|
||||||
|
assert c2 == c, f"counts changed on second pass: {c} -> {c2}"
|
||||||
|
ux, uy = user.geometry
|
||||||
|
assert math.hypot(ux - 6.0, uy - 6.0) < 1e-6
|
||||||
|
print("test_mixed_projection_rebuild OK")
|
||||||
|
|
||||||
|
|
||||||
|
def test_polylines_only_inplace():
|
||||||
|
sk = OCCSketch()
|
||||||
|
sk.add_external_polylines([list(p) for p in RECT])
|
||||||
|
corner = None
|
||||||
|
for eid in sk._external_entity_ids:
|
||||||
|
ent = sk._entities[eid]
|
||||||
|
if ent.entity_type == "point" and ent.geometry == (0.0, 0.0):
|
||||||
|
corner = ent
|
||||||
|
break
|
||||||
|
assert corner is not None
|
||||||
|
user = sk.add_point(0.0, 0.0)
|
||||||
|
assert sk.constrain_coincident(user, corner)
|
||||||
|
assert sk.solve()
|
||||||
|
|
||||||
|
# Same topology, slightly shifted rectangle -> in-place move.
|
||||||
|
moved = [[(u + 1.0, v + 2.0) for (u, v) in poly] for poly in RECT]
|
||||||
|
old_ext_ids = set(sk._external_entity_ids)
|
||||||
|
assert sk.update_external_entities(moved), "in-place solve failed"
|
||||||
|
assert sk._external_entity_ids == old_ext_ids, "in-place path must keep ids"
|
||||||
|
ux, uy = user.geometry
|
||||||
|
assert math.hypot(ux - 1.0, uy - 2.0) < 1e-6, f"user point at {(ux, uy)}"
|
||||||
|
print("test_polylines_only_inplace OK")
|
||||||
|
|
||||||
|
|
||||||
|
def test_arc_import_shares_corners():
|
||||||
|
"""_import_external_curves must merge arc endpoints with existing
|
||||||
|
polyline corner points (no floating duplicate endpoints)."""
|
||||||
|
sk = OCCSketch()
|
||||||
|
# Rectangle with the top-right corner filleted: the arc endpoints must
|
||||||
|
# land on the truncated-edge corner points, not create new ones.
|
||||||
|
r = 2.0
|
||||||
|
sk.add_external_polylines([
|
||||||
|
[(0.0, 0.0), (10.0, 0.0)],
|
||||||
|
[(10.0, 0.0), (10.0, 10.0 - r)],
|
||||||
|
[(10.0 - r, 10.0), (0.0, 10.0)],
|
||||||
|
[(0.0, 10.0), (0.0, 0.0)],
|
||||||
|
])
|
||||||
|
sk._import_external_curves(
|
||||||
|
[],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"type": "arc",
|
||||||
|
"center": [10.0 - r, 10.0 - r],
|
||||||
|
"start": [10.0, 10.0 - r],
|
||||||
|
"end": [10.0 - r, 10.0],
|
||||||
|
"radius": r,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
c = _counts(sk)
|
||||||
|
# 5 corners + 1 arc centre, NO extra endpoint entities.
|
||||||
|
assert c["ext_points"] == 6, f"expected 6 ext points, got {c}"
|
||||||
|
assert c["arcs"] == 1, f"expected 1 arc, got {c}"
|
||||||
|
assert sk.solve()
|
||||||
|
print("test_arc_import_shares_corners OK")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
test_mixed_projection_rebuild()
|
||||||
|
test_polylines_only_inplace()
|
||||||
|
test_arc_import_shares_corners()
|
||||||
|
print("UNDERLAY_CURVES_OK")
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -1,147 +0,0 @@
|
|||||||
# WARP.md
|
|
||||||
|
|
||||||
This file provides guidance to WARP (warp.dev) when working with code in this repository.
|
|
||||||
|
|
||||||
## Project Overview
|
|
||||||
Fluency is a CAD (Computer Aided Design) application built with Python/PySide6 that provides parametric 3D modeling through a timeline-based project system. The application combines 2D sketching with constraint solving, 3D visualization using VTK, and SDF (Signed Distance Function) based mesh generation.
|
|
||||||
|
|
||||||
## Common Commands
|
|
||||||
|
|
||||||
### Development Environment Setup
|
|
||||||
```bash
|
|
||||||
# Activate virtual environment (if exists)
|
|
||||||
source .venv/bin/activate
|
|
||||||
|
|
||||||
# Install dependencies
|
|
||||||
pip install -r requirements.txt
|
|
||||||
```
|
|
||||||
|
|
||||||
### Running the Application
|
|
||||||
```bash
|
|
||||||
# Run the main application
|
|
||||||
python main.py
|
|
||||||
|
|
||||||
# Run with debugging
|
|
||||||
python -u main.py
|
|
||||||
```
|
|
||||||
|
|
||||||
### UI Development
|
|
||||||
```bash
|
|
||||||
# Convert Qt Designer UI file to Python code
|
|
||||||
pyside6-uic gui.ui > Gui.py -g python
|
|
||||||
```
|
|
||||||
|
|
||||||
### Building Executable
|
|
||||||
The project uses Nuitka for compilation (configured in `main.py` header):
|
|
||||||
```bash
|
|
||||||
# Build standalone executable
|
|
||||||
nuitka --standalone --plugin-enable=pyside6 --plugin-enable=numpy --macos-create-app-bundle main.py
|
|
||||||
```
|
|
||||||
|
|
||||||
### Testing
|
|
||||||
```bash
|
|
||||||
# Run mesh generation test
|
|
||||||
python meshtest.py
|
|
||||||
```
|
|
||||||
|
|
||||||
## Architecture Overview
|
|
||||||
|
|
||||||
### Core Components
|
|
||||||
|
|
||||||
#### Main Application (`main.py`)
|
|
||||||
- **MainWindow**: Central UI controller that manages all widgets and user interactions
|
|
||||||
- **Project System**: Hierarchical structure: `Project → Timeline → Component → Sketch/Body`
|
|
||||||
- **Signal-based Communication**: Qt signals coordinate between 2D sketching and 3D rendering
|
|
||||||
|
|
||||||
#### Project Hierarchy
|
|
||||||
```
|
|
||||||
Project
|
|
||||||
├── Timeline (list of Components)
|
|
||||||
└── Component
|
|
||||||
├── Sketches (dict)
|
|
||||||
├── Bodies (dict)
|
|
||||||
└── Connectors (for assembly)
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Drawing Modules (`drawing_modules/`)
|
|
||||||
- **SketchWidget** (`draw_widget_solve.py`): 2D parametric sketching with SolverSpace constraint solving
|
|
||||||
- **VTKWidget** (`vtk_widget.py`): 3D visualization and mesh interaction using VTK
|
|
||||||
- **PyVistaWidget** (`vysta_widget.py`): Alternative 3D rendering backend
|
|
||||||
|
|
||||||
#### Mesh Generation (`mesh_modules/`)
|
|
||||||
- **VESTA** (`vesta_mesh.py`): Multi-threaded SDF-to-mesh conversion using marching cubes
|
|
||||||
- **Interactor Mesh** (`interactor_mesh.py`): Simplified edge-based meshes for 3D selection
|
|
||||||
- **Simple Mesh** (`simple_mesh.py`): Basic mesh utilities
|
|
||||||
|
|
||||||
### Data Flow Architecture
|
|
||||||
|
|
||||||
#### 2D to 3D Pipeline
|
|
||||||
1. **2D Sketching**: User draws in SketchWidget using Qt coordinate system
|
|
||||||
2. **Constraint Solving**: SolverSpace resolves geometric constraints
|
|
||||||
3. **SDF Generation**: Sketch converted to Signed Distance Functions for 3D operations
|
|
||||||
4. **Mesh Generation**: VESTA generates triangle meshes from SDF using marching cubes
|
|
||||||
5. **3D Rendering**: VTK displays both solid meshes and interactive edges
|
|
||||||
|
|
||||||
#### Signal Flow (from `doc/flow.md`)
|
|
||||||
- 2D QPoint → cartesian space → SolverSpace dict → constraint solving → display
|
|
||||||
- 3D mesh selection → projection to 2D → sketch widget integration
|
|
||||||
|
|
||||||
### Key Classes
|
|
||||||
|
|
||||||
#### Core Data Structures
|
|
||||||
- **Sketch**: 2D geometric data with origin, normal, points, and constraints
|
|
||||||
- **Body**: 3D mesh representation containing SDF objects and interactor meshes
|
|
||||||
- **Component**: Container grouping related sketches and bodies
|
|
||||||
- **Interactor**: Simplified edge-based mesh for 3D manipulation
|
|
||||||
|
|
||||||
#### Constraint Solving
|
|
||||||
The application uses `python_solvespace` for parametric constraint solving:
|
|
||||||
- Point-to-point constraints
|
|
||||||
- Distance constraints
|
|
||||||
- Horizontal/vertical line constraints
|
|
||||||
- Point-to-line constraints
|
|
||||||
|
|
||||||
### Technology Stack
|
|
||||||
- **GUI**: PySide6 (Qt for Python)
|
|
||||||
- **3D Graphics**: VTK for rendering, PyVista as alternative
|
|
||||||
- **Constraint Solving**: SolverSpace for parametric geometry
|
|
||||||
- **Mesh Generation**: SDF library with custom VESTA marching cubes implementation
|
|
||||||
- **Scientific Computing**: NumPy for mathematical operations
|
|
||||||
|
|
||||||
## Development Workflow
|
|
||||||
|
|
||||||
### Adding New Sketch Tools
|
|
||||||
1. Add UI button in `gui.ui`
|
|
||||||
2. Convert UI: `pyside6-uic gui.ui > Gui.py -g python`
|
|
||||||
3. Connect signal in `MainWindow.__init__()`
|
|
||||||
4. Implement tool logic in `SketchWidget`
|
|
||||||
|
|
||||||
### Adding New 3D Operations
|
|
||||||
1. Extend operation buttons in the Modify group
|
|
||||||
2. Implement operation logic using SDF functions
|
|
||||||
3. Update Body creation and timeline management
|
|
||||||
4. Handle interactor mesh generation for selection
|
|
||||||
|
|
||||||
### Debugging Tips
|
|
||||||
- Monitor solver results through `SolverSystem` status
|
|
||||||
- Use VTK's built-in debugging for rendering issues
|
|
||||||
- Check coordinate transformations between 2D sketch and 3D space
|
|
||||||
- Verify SDF function outputs before mesh generation
|
|
||||||
|
|
||||||
### File Structure
|
|
||||||
- `main.py`: Application entry point and main window
|
|
||||||
- `Gui.py`: Auto-generated UI code (do not edit directly)
|
|
||||||
- `gui.ui`: Qt Designer UI definition file
|
|
||||||
- `drawing_modules/`: 2D and 3D rendering widgets
|
|
||||||
- `mesh_modules/`: Mesh generation and processing
|
|
||||||
- `doc/`: Architecture and command documentation
|
|
||||||
|
|
||||||
## Dependencies
|
|
||||||
Primary external libraries:
|
|
||||||
- `PySide6`: Qt GUI framework
|
|
||||||
- `vtk`: 3D visualization toolkit
|
|
||||||
- `python-solvespace`: Constraint solving
|
|
||||||
- `sdf`: Signed Distance Function operations
|
|
||||||
- `numpy`: Numerical computations
|
|
||||||
- `scikit-image`: Marching cubes algorithm
|
|
||||||
- `names`: Random name generation for sketches
|
|
||||||
@@ -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)
|
||||||
|
```
|
||||||
Binary file not shown.
@@ -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,948 @@
|
|||||||
|
# -*- 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(2551, 1265)
|
||||||
|
sizePolicy = QSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Preferred)
|
||||||
|
sizePolicy.setHorizontalStretch(0)
|
||||||
|
sizePolicy.setVerticalStretch(0)
|
||||||
|
sizePolicy.setHeightForWidth(fluencyCAD.sizePolicy().hasHeightForWidth())
|
||||||
|
fluencyCAD.setSizePolicy(sizePolicy)
|
||||||
|
self.actionNew_Project = QAction(fluencyCAD)
|
||||||
|
self.actionNew_Project.setObjectName(u"actionNew_Project")
|
||||||
|
self.actionOpen_Project = QAction(fluencyCAD)
|
||||||
|
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.groupBox_5 = QGroupBox(self.centralwidget)
|
||||||
|
self.groupBox_5.setObjectName(u"groupBox_5")
|
||||||
|
sizePolicy.setHeightForWidth(self.groupBox_5.sizePolicy().hasHeightForWidth())
|
||||||
|
self.groupBox_5.setSizePolicy(sizePolicy)
|
||||||
|
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_4 = QGroupBox(self.centralwidget)
|
||||||
|
self.groupBox_4.setObjectName(u"groupBox_4")
|
||||||
|
self.groupBox_4.setMaximumSize(QSize(200, 16777215))
|
||||||
|
self.gridLayout_9 = QGridLayout(self.groupBox_4)
|
||||||
|
self.gridLayout_9.setObjectName(u"gridLayout_9")
|
||||||
|
self.pushButton_2 = QPushButton(self.groupBox_4)
|
||||||
|
self.pushButton_2.setObjectName(u"pushButton_2")
|
||||||
|
|
||||||
|
self.gridLayout_9.addWidget(self.pushButton_2, 0, 0, 1, 1)
|
||||||
|
|
||||||
|
self.pb_export_iges = QPushButton(self.groupBox_4)
|
||||||
|
self.pb_export_iges.setObjectName(u"pb_export_iges")
|
||||||
|
|
||||||
|
self.gridLayout_9.addWidget(self.pb_export_iges, 2, 0, 1, 1)
|
||||||
|
|
||||||
|
self.pb_export_step = QPushButton(self.groupBox_4)
|
||||||
|
self.pb_export_step.setObjectName(u"pb_export_step")
|
||||||
|
|
||||||
|
self.gridLayout_9.addWidget(self.pb_export_step, 0, 1, 1, 1)
|
||||||
|
|
||||||
|
|
||||||
|
self.gridLayout.addWidget(self.groupBox_4, 1, 3, 1, 1)
|
||||||
|
|
||||||
|
self.InputTab = QTabWidget(self.centralwidget)
|
||||||
|
self.InputTab.setObjectName(u"InputTab")
|
||||||
|
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, 5, 1)
|
||||||
|
|
||||||
|
self.compo_tool_box = QGroupBox(self.centralwidget)
|
||||||
|
self.compo_tool_box.setObjectName(u"compo_tool_box")
|
||||||
|
sizePolicy1 = QSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Preferred)
|
||||||
|
sizePolicy1.setHorizontalStretch(0)
|
||||||
|
sizePolicy1.setVerticalStretch(0)
|
||||||
|
sizePolicy1.setHeightForWidth(self.compo_tool_box.sizePolicy().hasHeightForWidth())
|
||||||
|
self.compo_tool_box.setSizePolicy(sizePolicy1)
|
||||||
|
self.compo_tool_box.setMinimumSize(QSize(0, 50))
|
||||||
|
self.horizontalLayout = QHBoxLayout(self.compo_tool_box)
|
||||||
|
self.horizontalLayout.setObjectName(u"horizontalLayout")
|
||||||
|
self.pb_new_compo = QPushButton(self.compo_tool_box)
|
||||||
|
self.pb_new_compo.setObjectName(u"pb_new_compo")
|
||||||
|
self.pb_new_compo.setMinimumSize(QSize(50, 50))
|
||||||
|
self.pb_new_compo.setMaximumSize(QSize(50, 50))
|
||||||
|
|
||||||
|
self.horizontalLayout.addWidget(self.pb_new_compo)
|
||||||
|
|
||||||
|
self.pb_del_compo = QPushButton(self.compo_tool_box)
|
||||||
|
self.pb_del_compo.setObjectName(u"pb_del_compo")
|
||||||
|
self.pb_del_compo.setEnabled(True)
|
||||||
|
sizePolicy.setHeightForWidth(self.pb_del_compo.sizePolicy().hasHeightForWidth())
|
||||||
|
self.pb_del_compo.setSizePolicy(sizePolicy)
|
||||||
|
self.pb_del_compo.setMinimumSize(QSize(50, 50))
|
||||||
|
self.pb_del_compo.setMaximumSize(QSize(50, 50))
|
||||||
|
self.pb_del_compo.setLayoutDirection(Qt.LeftToRight)
|
||||||
|
|
||||||
|
self.horizontalLayout.addWidget(self.pb_del_compo)
|
||||||
|
|
||||||
|
|
||||||
|
self.gridLayout.addWidget(self.compo_tool_box, 7, 0, 1, 1)
|
||||||
|
|
||||||
|
self.groupBox_3 = QGroupBox(self.centralwidget)
|
||||||
|
self.groupBox_3.setObjectName(u"groupBox_3")
|
||||||
|
sizePolicy.setHeightForWidth(self.groupBox_3.sizePolicy().hasHeightForWidth())
|
||||||
|
self.groupBox_3.setSizePolicy(sizePolicy)
|
||||||
|
self.groupBox_3.setMaximumSize(QSize(200, 16777213))
|
||||||
|
self.gridLayout_4 = QGridLayout(self.groupBox_3)
|
||||||
|
self.gridLayout_4.setObjectName(u"gridLayout_4")
|
||||||
|
self.pb_con_ptpt = QPushButton(self.groupBox_3)
|
||||||
|
self.pb_con_ptpt.setObjectName(u"pb_con_ptpt")
|
||||||
|
icon = QIcon()
|
||||||
|
icon.addFile(u"icons/pt_pt.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
|
||||||
|
self.pb_con_ptpt.setIcon(icon)
|
||||||
|
self.pb_con_ptpt.setCheckable(True)
|
||||||
|
self.pb_con_ptpt.setAutoExclusive(False)
|
||||||
|
|
||||||
|
self.gridLayout_4.addWidget(self.pb_con_ptpt, 1, 0, 1, 1)
|
||||||
|
|
||||||
|
self.pb_con_vert = QPushButton(self.groupBox_3)
|
||||||
|
self.pb_con_vert.setObjectName(u"pb_con_vert")
|
||||||
|
self.pb_con_vert.setCheckable(True)
|
||||||
|
self.pb_con_vert.setAutoExclusive(False)
|
||||||
|
|
||||||
|
self.gridLayout_4.addWidget(self.pb_con_vert, 3, 1, 1, 1)
|
||||||
|
|
||||||
|
self.pb_con_sym = QPushButton(self.groupBox_3)
|
||||||
|
self.pb_con_sym.setObjectName(u"pb_con_sym")
|
||||||
|
self.pb_con_sym.setCheckable(True)
|
||||||
|
self.pb_con_sym.setAutoExclusive(False)
|
||||||
|
|
||||||
|
self.gridLayout_4.addWidget(self.pb_con_sym, 4, 1, 1, 1)
|
||||||
|
|
||||||
|
self.pb_con_mid = QPushButton(self.groupBox_3)
|
||||||
|
self.pb_con_mid.setObjectName(u"pb_con_mid")
|
||||||
|
self.pb_con_mid.setCheckable(True)
|
||||||
|
self.pb_con_mid.setAutoExclusive(False)
|
||||||
|
|
||||||
|
self.gridLayout_4.addWidget(self.pb_con_mid, 2, 0, 1, 1)
|
||||||
|
|
||||||
|
self.pb_con_line = QPushButton(self.groupBox_3)
|
||||||
|
self.pb_con_line.setObjectName(u"pb_con_line")
|
||||||
|
self.pb_con_line.setCheckable(True)
|
||||||
|
self.pb_con_line.setAutoExclusive(False)
|
||||||
|
|
||||||
|
self.gridLayout_4.addWidget(self.pb_con_line, 1, 1, 1, 1)
|
||||||
|
|
||||||
|
self.pb_con_horiz = QPushButton(self.groupBox_3)
|
||||||
|
self.pb_con_horiz.setObjectName(u"pb_con_horiz")
|
||||||
|
self.pb_con_horiz.setCheckable(True)
|
||||||
|
self.pb_con_horiz.setAutoExclusive(False)
|
||||||
|
|
||||||
|
self.gridLayout_4.addWidget(self.pb_con_horiz, 3, 0, 1, 1)
|
||||||
|
|
||||||
|
self.pb_con_dist = QPushButton(self.groupBox_3)
|
||||||
|
self.pb_con_dist.setObjectName(u"pb_con_dist")
|
||||||
|
self.pb_con_dist.setCheckable(True)
|
||||||
|
self.pb_con_dist.setAutoExclusive(False)
|
||||||
|
self.pb_con_dist.setAutoRepeatDelay(297)
|
||||||
|
|
||||||
|
self.gridLayout_4.addWidget(self.pb_con_dist, 4, 0, 1, 1)
|
||||||
|
|
||||||
|
self.pb_con_perp = QPushButton(self.groupBox_3)
|
||||||
|
self.pb_con_perp.setObjectName(u"pb_con_perp")
|
||||||
|
self.pb_con_perp.setCheckable(True)
|
||||||
|
self.pb_con_perp.setAutoExclusive(False)
|
||||||
|
|
||||||
|
self.gridLayout_4.addWidget(self.pb_con_perp, 2, 1, 1, 1)
|
||||||
|
|
||||||
|
self.pb_con_diameter = QPushButton(self.groupBox_3)
|
||||||
|
self.pb_con_diameter.setObjectName(u"pb_con_diameter")
|
||||||
|
|
||||||
|
self.gridLayout_4.addWidget(self.pb_con_diameter, 5, 0, 1, 1)
|
||||||
|
|
||||||
|
|
||||||
|
self.gridLayout.addWidget(self.groupBox_3, 2, 0, 1, 1)
|
||||||
|
|
||||||
|
self.assembly_tools = QGroupBox(self.centralwidget)
|
||||||
|
self.assembly_tools.setObjectName(u"assembly_tools")
|
||||||
|
sizePolicy1.setHeightForWidth(self.assembly_tools.sizePolicy().hasHeightForWidth())
|
||||||
|
self.assembly_tools.setSizePolicy(sizePolicy1)
|
||||||
|
self.assembly_tools.setMinimumSize(QSize(113, 50))
|
||||||
|
self.horizontalLayout_2 = QHBoxLayout(self.assembly_tools)
|
||||||
|
self.horizontalLayout_2.setObjectName(u"horizontalLayout_2")
|
||||||
|
self.pb_compo_to_assembly = QPushButton(self.assembly_tools)
|
||||||
|
self.pb_compo_to_assembly.setObjectName(u"pb_compo_to_assembly")
|
||||||
|
self.pb_compo_to_assembly.setMinimumSize(QSize(50, 50))
|
||||||
|
self.pb_compo_to_assembly.setMaximumSize(QSize(50, 50))
|
||||||
|
|
||||||
|
self.horizontalLayout_2.addWidget(self.pb_compo_to_assembly)
|
||||||
|
|
||||||
|
self.pb_remove_compo_from_assembly = QPushButton(self.assembly_tools)
|
||||||
|
self.pb_remove_compo_from_assembly.setObjectName(u"pb_remove_compo_from_assembly")
|
||||||
|
self.pb_remove_compo_from_assembly.setEnabled(True)
|
||||||
|
sizePolicy.setHeightForWidth(self.pb_remove_compo_from_assembly.sizePolicy().hasHeightForWidth())
|
||||||
|
self.pb_remove_compo_from_assembly.setSizePolicy(sizePolicy)
|
||||||
|
self.pb_remove_compo_from_assembly.setMinimumSize(QSize(50, 50))
|
||||||
|
self.pb_remove_compo_from_assembly.setMaximumSize(QSize(50, 50))
|
||||||
|
self.pb_remove_compo_from_assembly.setLayoutDirection(Qt.LeftToRight)
|
||||||
|
|
||||||
|
self.horizontalLayout_2.addWidget(self.pb_remove_compo_from_assembly)
|
||||||
|
|
||||||
|
|
||||||
|
self.gridLayout.addWidget(self.assembly_tools, 8, 0, 1, 1)
|
||||||
|
|
||||||
|
self.groupBox_9 = QGroupBox(self.centralwidget)
|
||||||
|
self.groupBox_9.setObjectName(u"groupBox_9")
|
||||||
|
self.groupBox_9.setMaximumSize(QSize(200, 16777215))
|
||||||
|
self.gridLayout_7 = QGridLayout(self.groupBox_9)
|
||||||
|
self.gridLayout_7.setObjectName(u"gridLayout_7")
|
||||||
|
self.pb_origin_wp = QPushButton(self.groupBox_9)
|
||||||
|
self.pb_origin_wp.setObjectName(u"pb_origin_wp")
|
||||||
|
|
||||||
|
self.gridLayout_7.addWidget(self.pb_origin_wp, 0, 0, 1, 1)
|
||||||
|
|
||||||
|
self.pb_origin_face = QPushButton(self.groupBox_9)
|
||||||
|
self.pb_origin_face.setObjectName(u"pb_origin_face")
|
||||||
|
self.pb_origin_face.setCheckable(True)
|
||||||
|
|
||||||
|
self.gridLayout_7.addWidget(self.pb_origin_face, 0, 1, 1, 1)
|
||||||
|
|
||||||
|
self.pb_flip_face = QPushButton(self.groupBox_9)
|
||||||
|
self.pb_flip_face.setObjectName(u"pb_flip_face")
|
||||||
|
|
||||||
|
self.gridLayout_7.addWidget(self.pb_flip_face, 1, 0, 1, 1)
|
||||||
|
|
||||||
|
self.pb_underlay = QPushButton(self.groupBox_9)
|
||||||
|
self.pb_underlay.setObjectName(u"pb_underlay")
|
||||||
|
self.pb_underlay.setEnabled(False)
|
||||||
|
self.pb_underlay.setCheckable(True)
|
||||||
|
self.pb_underlay.setChecked(True)
|
||||||
|
|
||||||
|
self.gridLayout_7.addWidget(self.pb_underlay, 3, 0, 1, 1)
|
||||||
|
|
||||||
|
self.pb_clr_face = QPushButton(self.groupBox_9)
|
||||||
|
self.pb_clr_face.setObjectName(u"pb_clr_face")
|
||||||
|
self.pb_clr_face.setEnabled(False)
|
||||||
|
|
||||||
|
self.gridLayout_7.addWidget(self.pb_clr_face, 3, 1, 1, 1)
|
||||||
|
|
||||||
|
self.pb_to_sketch = QPushButton(self.groupBox_9)
|
||||||
|
self.pb_to_sketch.setObjectName(u"pb_to_sketch")
|
||||||
|
self.pb_to_sketch.setEnabled(False)
|
||||||
|
|
||||||
|
self.gridLayout_7.addWidget(self.pb_to_sketch, 4, 0, 1, 2)
|
||||||
|
|
||||||
|
self.pb_wp_new = QPushButton(self.groupBox_9)
|
||||||
|
self.pb_wp_new.setObjectName(u"pb_wp_new")
|
||||||
|
|
||||||
|
self.gridLayout_7.addWidget(self.pb_wp_new, 1, 1, 1, 1)
|
||||||
|
|
||||||
|
|
||||||
|
self.gridLayout.addWidget(self.groupBox_9, 0, 0, 1, 1)
|
||||||
|
|
||||||
|
self.groupBox_12 = QGroupBox(self.centralwidget)
|
||||||
|
self.groupBox_12.setObjectName(u"groupBox_12")
|
||||||
|
sizePolicy2 = QSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Expanding)
|
||||||
|
sizePolicy2.setHorizontalStretch(0)
|
||||||
|
sizePolicy2.setVerticalStretch(0)
|
||||||
|
sizePolicy2.setHeightForWidth(self.groupBox_12.sizePolicy().hasHeightForWidth())
|
||||||
|
self.groupBox_12.setSizePolicy(sizePolicy2)
|
||||||
|
self.groupBox_12.setMaximumSize(QSize(200, 16777215))
|
||||||
|
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")
|
||||||
|
sizePolicy.setHeightForWidth(self.groupBox_13.sizePolicy().hasHeightForWidth())
|
||||||
|
self.groupBox_13.setSizePolicy(sizePolicy)
|
||||||
|
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, 4, 3, 1, 1)
|
||||||
|
|
||||||
|
self.joint_tools = QGroupBox(self.centralwidget)
|
||||||
|
self.joint_tools.setObjectName(u"joint_tools")
|
||||||
|
self.joint_tools.setMinimumSize(QSize(0, 50))
|
||||||
|
self.gridLayout_10 = QGridLayout(self.joint_tools)
|
||||||
|
self.gridLayout_10.setObjectName(u"gridLayout_10")
|
||||||
|
self.pb_add_connector = QPushButton(self.joint_tools)
|
||||||
|
self.pb_add_connector.setObjectName(u"pb_add_connector")
|
||||||
|
self.pb_add_connector.setMinimumSize(QSize(50, 50))
|
||||||
|
self.pb_add_connector.setMaximumSize(QSize(50, 50))
|
||||||
|
|
||||||
|
self.gridLayout_10.addWidget(self.pb_add_connector, 0, 0, 1, 1)
|
||||||
|
|
||||||
|
self.pb_remove_connector = QPushButton(self.joint_tools)
|
||||||
|
self.pb_remove_connector.setObjectName(u"pb_remove_connector")
|
||||||
|
self.pb_remove_connector.setMinimumSize(QSize(50, 50))
|
||||||
|
self.pb_remove_connector.setMaximumSize(QSize(50, 50))
|
||||||
|
|
||||||
|
self.gridLayout_10.addWidget(self.pb_remove_connector, 0, 1, 1, 1)
|
||||||
|
|
||||||
|
self.pb_add_connector_3 = QPushButton(self.joint_tools)
|
||||||
|
self.pb_add_connector_3.setObjectName(u"pb_add_connector_3")
|
||||||
|
self.pb_add_connector_3.setMinimumSize(QSize(50, 50))
|
||||||
|
self.pb_add_connector_3.setMaximumSize(QSize(50, 50))
|
||||||
|
|
||||||
|
self.gridLayout_10.addWidget(self.pb_add_connector_3, 1, 1, 1, 1)
|
||||||
|
|
||||||
|
self.pb_add_connector_2 = QPushButton(self.joint_tools)
|
||||||
|
self.pb_add_connector_2.setObjectName(u"pb_add_connector_2")
|
||||||
|
self.pb_add_connector_2.setMinimumSize(QSize(50, 50))
|
||||||
|
self.pb_add_connector_2.setMaximumSize(QSize(50, 50))
|
||||||
|
|
||||||
|
self.gridLayout_10.addWidget(self.pb_add_connector_2, 1, 0, 1, 1)
|
||||||
|
|
||||||
|
|
||||||
|
self.gridLayout.addWidget(self.joint_tools, 7, 3, 2, 1)
|
||||||
|
|
||||||
|
self.gl_box = QGroupBox(self.centralwidget)
|
||||||
|
self.gl_box.setObjectName(u"gl_box")
|
||||||
|
sizePolicy3 = QSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Preferred)
|
||||||
|
sizePolicy3.setHorizontalStretch(0)
|
||||||
|
sizePolicy3.setVerticalStretch(4)
|
||||||
|
sizePolicy3.setHeightForWidth(self.gl_box.sizePolicy().hasHeightForWidth())
|
||||||
|
self.gl_box.setSizePolicy(sizePolicy3)
|
||||||
|
font = QFont()
|
||||||
|
font.setPointSize(12)
|
||||||
|
self.gl_box.setFont(font)
|
||||||
|
self.horizontalLayout_4 = QHBoxLayout(self.gl_box)
|
||||||
|
#ifndef Q_OS_MAC
|
||||||
|
self.horizontalLayout_4.setSpacing(-1)
|
||||||
|
#endif
|
||||||
|
self.horizontalLayout_4.setObjectName(u"horizontalLayout_4")
|
||||||
|
self.horizontalLayout_4.setContentsMargins(12, -1, -1, -1)
|
||||||
|
|
||||||
|
self.gridLayout.addWidget(self.gl_box, 0, 2, 5, 1)
|
||||||
|
|
||||||
|
self.compo_box = QGroupBox(self.centralwidget)
|
||||||
|
self.compo_box.setObjectName(u"compo_box")
|
||||||
|
self.compo_box.setMinimumSize(QSize(0, 120))
|
||||||
|
|
||||||
|
self.gridLayout.addWidget(self.compo_box, 7, 1, 1, 2)
|
||||||
|
|
||||||
|
self.groupBox_11 = QGroupBox(self.centralwidget)
|
||||||
|
self.groupBox_11.setObjectName(u"groupBox_11")
|
||||||
|
sizePolicy2.setHeightForWidth(self.groupBox_11.sizePolicy().hasHeightForWidth())
|
||||||
|
self.groupBox_11.setSizePolicy(sizePolicy2)
|
||||||
|
self.groupBox_11.setMaximumSize(QSize(200, 16777215))
|
||||||
|
self.verticalLayout_7 = QVBoxLayout(self.groupBox_11)
|
||||||
|
self.verticalLayout_7.setObjectName(u"verticalLayout_7")
|
||||||
|
self.verticalLayout_7.setContentsMargins(5, 5, 5, 5)
|
||||||
|
self.sketch_list = QListWidget(self.groupBox_11)
|
||||||
|
self.sketch_list.setObjectName(u"sketch_list")
|
||||||
|
sizePolicy4 = QSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
|
||||||
|
sizePolicy4.setHorizontalStretch(0)
|
||||||
|
sizePolicy4.setVerticalStretch(0)
|
||||||
|
sizePolicy4.setHeightForWidth(self.sketch_list.sizePolicy().hasHeightForWidth())
|
||||||
|
self.sketch_list.setSizePolicy(sizePolicy4)
|
||||||
|
self.sketch_list.setSelectionRectVisible(True)
|
||||||
|
|
||||||
|
self.verticalLayout_7.addWidget(self.sketch_list)
|
||||||
|
|
||||||
|
self.groupBox_6 = QGroupBox(self.groupBox_11)
|
||||||
|
self.groupBox_6.setObjectName(u"groupBox_6")
|
||||||
|
sizePolicy.setHeightForWidth(self.groupBox_6.sizePolicy().hasHeightForWidth())
|
||||||
|
self.groupBox_6.setSizePolicy(sizePolicy)
|
||||||
|
self.gridLayout_6 = QGridLayout(self.groupBox_6)
|
||||||
|
self.gridLayout_6.setObjectName(u"gridLayout_6")
|
||||||
|
self.gridLayout_6.setContentsMargins(2, 2, 2, 2)
|
||||||
|
self.pb_edt_sktch = QPushButton(self.groupBox_6)
|
||||||
|
self.pb_edt_sktch.setObjectName(u"pb_edt_sktch")
|
||||||
|
|
||||||
|
self.gridLayout_6.addWidget(self.pb_edt_sktch, 1, 1, 1, 1)
|
||||||
|
|
||||||
|
self.pb_nw_sktch = QPushButton(self.groupBox_6)
|
||||||
|
self.pb_nw_sktch.setObjectName(u"pb_nw_sktch")
|
||||||
|
|
||||||
|
self.gridLayout_6.addWidget(self.pb_nw_sktch, 1, 0, 1, 1)
|
||||||
|
|
||||||
|
self.pb_del_sketch = QPushButton(self.groupBox_6)
|
||||||
|
self.pb_del_sketch.setObjectName(u"pb_del_sketch")
|
||||||
|
|
||||||
|
self.gridLayout_6.addWidget(self.pb_del_sketch, 1, 2, 1, 1)
|
||||||
|
|
||||||
|
|
||||||
|
self.verticalLayout_7.addWidget(self.groupBox_6)
|
||||||
|
|
||||||
|
|
||||||
|
self.gridLayout.addWidget(self.groupBox_11, 4, 0, 1, 1)
|
||||||
|
|
||||||
|
self.groupBox_2 = QGroupBox(self.centralwidget)
|
||||||
|
self.groupBox_2.setObjectName(u"groupBox_2")
|
||||||
|
sizePolicy.setHeightForWidth(self.groupBox_2.sizePolicy().hasHeightForWidth())
|
||||||
|
self.groupBox_2.setSizePolicy(sizePolicy)
|
||||||
|
self.groupBox_2.setMaximumSize(QSize(200, 16777215))
|
||||||
|
self.gridLayout_2 = QGridLayout(self.groupBox_2)
|
||||||
|
self.gridLayout_2.setObjectName(u"gridLayout_2")
|
||||||
|
self.gridLayout_2.setContentsMargins(10, -1, -1, -1)
|
||||||
|
self.pb_arc_tool = QPushButton(self.groupBox_2)
|
||||||
|
self.pb_arc_tool.setObjectName(u"pb_arc_tool")
|
||||||
|
self.pb_arc_tool.setCheckable(True)
|
||||||
|
|
||||||
|
self.gridLayout_2.addWidget(self.pb_arc_tool, 2, 0, 1, 1)
|
||||||
|
|
||||||
|
self.pb_rectool = QPushButton(self.groupBox_2)
|
||||||
|
self.pb_rectool.setObjectName(u"pb_rectool")
|
||||||
|
self.pb_rectool.setCheckable(True)
|
||||||
|
self.pb_rectool.setAutoExclusive(False)
|
||||||
|
|
||||||
|
self.gridLayout_2.addWidget(self.pb_rectool, 0, 1, 1, 1)
|
||||||
|
|
||||||
|
self.pb_circtool = QPushButton(self.groupBox_2)
|
||||||
|
self.pb_circtool.setObjectName(u"pb_circtool")
|
||||||
|
self.pb_circtool.setCheckable(True)
|
||||||
|
self.pb_circtool.setAutoExclusive(False)
|
||||||
|
|
||||||
|
self.gridLayout_2.addWidget(self.pb_circtool, 1, 0, 1, 1)
|
||||||
|
|
||||||
|
self.pb_enable_construct = QPushButton(self.groupBox_2)
|
||||||
|
self.pb_enable_construct.setObjectName(u"pb_enable_construct")
|
||||||
|
self.pb_enable_construct.setCheckable(True)
|
||||||
|
|
||||||
|
self.gridLayout_2.addWidget(self.pb_enable_construct, 4, 0, 1, 1)
|
||||||
|
|
||||||
|
self.pb_enable_snap = QPushButton(self.groupBox_2)
|
||||||
|
self.pb_enable_snap.setObjectName(u"pb_enable_snap")
|
||||||
|
self.pb_enable_snap.setIconSize(QSize(13, 16))
|
||||||
|
self.pb_enable_snap.setCheckable(True)
|
||||||
|
self.pb_enable_snap.setChecked(True)
|
||||||
|
|
||||||
|
self.gridLayout_2.addWidget(self.pb_enable_snap, 4, 1, 1, 1)
|
||||||
|
|
||||||
|
self.pb_linetool = QPushButton(self.groupBox_2)
|
||||||
|
self.pb_linetool.setObjectName(u"pb_linetool")
|
||||||
|
self.pb_linetool.setCheckable(True)
|
||||||
|
self.pb_linetool.setAutoExclusive(False)
|
||||||
|
|
||||||
|
self.gridLayout_2.addWidget(self.pb_linetool, 0, 0, 1, 1)
|
||||||
|
|
||||||
|
self.pb_slotool = QPushButton(self.groupBox_2)
|
||||||
|
self.pb_slotool.setObjectName(u"pb_slotool")
|
||||||
|
self.pb_slotool.setCheckable(True)
|
||||||
|
self.pb_slotool.setAutoExclusive(False)
|
||||||
|
|
||||||
|
self.gridLayout_2.addWidget(self.pb_slotool, 1, 1, 1, 1)
|
||||||
|
|
||||||
|
self.line = QFrame(self.groupBox_2)
|
||||||
|
self.line.setObjectName(u"line")
|
||||||
|
self.line.setFrameShape(QFrame.Shape.HLine)
|
||||||
|
self.line.setFrameShadow(QFrame.Shadow.Sunken)
|
||||||
|
|
||||||
|
self.gridLayout_2.addWidget(self.line, 3, 0, 1, 2)
|
||||||
|
|
||||||
|
self.pb_offset_tool = QPushButton(self.groupBox_2)
|
||||||
|
self.pb_offset_tool.setObjectName(u"pb_offset_tool")
|
||||||
|
|
||||||
|
self.gridLayout_2.addWidget(self.pb_offset_tool, 2, 1, 1, 1)
|
||||||
|
|
||||||
|
|
||||||
|
self.gridLayout.addWidget(self.groupBox_2, 1, 0, 1, 1)
|
||||||
|
|
||||||
|
self.assembly_box = QGroupBox(self.centralwidget)
|
||||||
|
self.assembly_box.setObjectName(u"assembly_box")
|
||||||
|
self.assembly_box.setMinimumSize(QSize(0, 120))
|
||||||
|
|
||||||
|
self.gridLayout.addWidget(self.assembly_box, 8, 1, 1, 2)
|
||||||
|
|
||||||
|
self.groupBox = QGroupBox(self.centralwidget)
|
||||||
|
self.groupBox.setObjectName(u"groupBox")
|
||||||
|
self.groupBox.setMaximumSize(QSize(200, 16777215))
|
||||||
|
self.gridLayout_3 = QGridLayout(self.groupBox)
|
||||||
|
self.gridLayout_3.setObjectName(u"gridLayout_3")
|
||||||
|
self.pb_chamfer_op = QPushButton(self.groupBox)
|
||||||
|
self.pb_chamfer_op.setObjectName(u"pb_chamfer_op")
|
||||||
|
|
||||||
|
self.gridLayout_3.addWidget(self.pb_chamfer_op, 2, 1, 1, 1)
|
||||||
|
|
||||||
|
self.pb_fillet_op = QPushButton(self.groupBox)
|
||||||
|
self.pb_fillet_op.setObjectName(u"pb_fillet_op")
|
||||||
|
|
||||||
|
self.gridLayout_3.addWidget(self.pb_fillet_op, 2, 0, 1, 1)
|
||||||
|
|
||||||
|
self.pb_thread = QPushButton(self.groupBox)
|
||||||
|
self.pb_thread.setObjectName(u"pb_thread")
|
||||||
|
|
||||||
|
self.gridLayout_3.addWidget(self.pb_thread, 6, 0, 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_revop = QPushButton(self.groupBox)
|
||||||
|
self.pb_revop.setObjectName(u"pb_revop")
|
||||||
|
|
||||||
|
self.gridLayout_3.addWidget(self.pb_revop, 4, 1, 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_arrayop = QPushButton(self.groupBox)
|
||||||
|
self.pb_arrayop.setObjectName(u"pb_arrayop")
|
||||||
|
|
||||||
|
self.gridLayout_3.addWidget(self.pb_arrayop, 4, 0, 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.pb_mirror_op = QPushButton(self.groupBox)
|
||||||
|
self.pb_mirror_op.setObjectName(u"pb_mirror_op")
|
||||||
|
|
||||||
|
self.gridLayout_3.addWidget(self.pb_mirror_op, 6, 1, 1, 1)
|
||||||
|
|
||||||
|
|
||||||
|
self.gridLayout.addWidget(self.groupBox, 0, 3, 1, 1)
|
||||||
|
|
||||||
|
self.line_3 = QFrame(self.centralwidget)
|
||||||
|
self.line_3.setObjectName(u"line_3")
|
||||||
|
self.line_3.setFrameShape(QFrame.Shape.HLine)
|
||||||
|
self.line_3.setFrameShadow(QFrame.Shadow.Sunken)
|
||||||
|
|
||||||
|
self.gridLayout.addWidget(self.line_3, 5, 0, 1, 4)
|
||||||
|
|
||||||
|
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")
|
||||||
|
sizePolicy.setHeightForWidth(self.groupBox_8.sizePolicy().hasHeightForWidth())
|
||||||
|
self.groupBox_8.setSizePolicy(sizePolicy)
|
||||||
|
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_body_hide = QPushButton(self.groupBox_8)
|
||||||
|
self.pb_body_hide.setObjectName(u"pb_body_hide")
|
||||||
|
|
||||||
|
self.gridLayout_8.addWidget(self.pb_body_hide, 0, 1, 1, 1)
|
||||||
|
|
||||||
|
self.pb_update_body = QPushButton(self.groupBox_8)
|
||||||
|
self.pb_update_body.setObjectName(u"pb_update_body")
|
||||||
|
|
||||||
|
self.gridLayout_8.addWidget(self.pb_update_body, 0, 0, 1, 1)
|
||||||
|
|
||||||
|
self.pb_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.verticalLayout_6.addWidget(self.groupBox_8)
|
||||||
|
|
||||||
|
|
||||||
|
self.gridLayout.addWidget(self.groupBox_10, 2, 3, 2, 1)
|
||||||
|
|
||||||
|
fluencyCAD.setCentralWidget(self.centralwidget)
|
||||||
|
self.menubar = QMenuBar(fluencyCAD)
|
||||||
|
self.menubar.setObjectName(u"menubar")
|
||||||
|
self.menubar.setGeometry(QRect(0, 0, 2551, 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.groupBox_5.setTitle(QCoreApplication.translate("fluencyCAD", u"Snapping Points", None))
|
||||||
|
self.label.setText(QCoreApplication.translate("fluencyCAD", u"Snp Dst", None))
|
||||||
|
self.pb_snap_vert.setText(QCoreApplication.translate("fluencyCAD", u"Vert", None))
|
||||||
|
self.label_2.setText(QCoreApplication.translate("fluencyCAD", u"Angl Stps", None))
|
||||||
|
self.spinbox_snap_distance.setSuffix(QCoreApplication.translate("fluencyCAD", u"mm", None))
|
||||||
|
self.pushButton_7.setText(QCoreApplication.translate("fluencyCAD", u"Grid", None))
|
||||||
|
self.pb_snap_horiz.setText(QCoreApplication.translate("fluencyCAD", u"Horiz", None))
|
||||||
|
self.spinbox_angle_steps.setSuffix(QCoreApplication.translate("fluencyCAD", u"\u00b0", None))
|
||||||
|
self.pushButton_8.setText(QCoreApplication.translate("fluencyCAD", u"Pnt", None))
|
||||||
|
self.pb_snap_midp.setText(QCoreApplication.translate("fluencyCAD", u"MidP", None))
|
||||||
|
self.pb_snap_angle.setText(QCoreApplication.translate("fluencyCAD", u"Angles", None))
|
||||||
|
self.groupBox_4.setTitle(QCoreApplication.translate("fluencyCAD", u"Export", None))
|
||||||
|
self.pushButton_2.setText(QCoreApplication.translate("fluencyCAD", u"STL", None))
|
||||||
|
self.pb_export_iges.setText(QCoreApplication.translate("fluencyCAD", u"IGES", None))
|
||||||
|
self.pb_export_step.setText(QCoreApplication.translate("fluencyCAD", u"STEP", None))
|
||||||
|
self.InputTab.setTabText(self.InputTab.indexOf(self.sketch_tab), QCoreApplication.translate("fluencyCAD", u"Sketch", None))
|
||||||
|
self.groupBox_7.setTitle(QCoreApplication.translate("fluencyCAD", u"Executive", None))
|
||||||
|
self.pushButton_5.setText(QCoreApplication.translate("fluencyCAD", u"Load Code", None))
|
||||||
|
self.pushButton_4.setText(QCoreApplication.translate("fluencyCAD", u"Save code", None))
|
||||||
|
self.pb_apply_code.setText(QCoreApplication.translate("fluencyCAD", u"Apply Code", None))
|
||||||
|
self.pushButton.setText(QCoreApplication.translate("fluencyCAD", u"Delete Code", None))
|
||||||
|
self.InputTab.setTabText(self.InputTab.indexOf(self.code_tab), QCoreApplication.translate("fluencyCAD", u"Code", None))
|
||||||
|
self.compo_tool_box.setTitle(QCoreApplication.translate("fluencyCAD", u"Component Tools", None))
|
||||||
|
self.pb_new_compo.setText(QCoreApplication.translate("fluencyCAD", u"New", None))
|
||||||
|
self.pb_del_compo.setText(QCoreApplication.translate("fluencyCAD", u"Del", None))
|
||||||
|
self.groupBox_3.setTitle(QCoreApplication.translate("fluencyCAD", u"Constrain", None))
|
||||||
|
#if QT_CONFIG(tooltip)
|
||||||
|
self.pb_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.assembly_tools.setTitle(QCoreApplication.translate("fluencyCAD", u"Assembly Tools", None))
|
||||||
|
self.pb_compo_to_assembly.setText(QCoreApplication.translate("fluencyCAD", u"Add", None))
|
||||||
|
self.pb_remove_compo_from_assembly.setText(QCoreApplication.translate("fluencyCAD", u"Rem", None))
|
||||||
|
self.groupBox_9.setTitle(QCoreApplication.translate("fluencyCAD", u"Workplanes", None))
|
||||||
|
#if QT_CONFIG(tooltip)
|
||||||
|
self.pb_origin_wp.setToolTip(QCoreApplication.translate("fluencyCAD", u"<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.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.joint_tools.setTitle(QCoreApplication.translate("fluencyCAD", u"Joint Tools", None))
|
||||||
|
self.pb_add_connector.setText(QCoreApplication.translate("fluencyCAD", u"+ Cnct", None))
|
||||||
|
self.pb_remove_connector.setText(QCoreApplication.translate("fluencyCAD", u"- Cnct", None))
|
||||||
|
self.pb_add_connector_3.setText(QCoreApplication.translate("fluencyCAD", u"-Jnt", None))
|
||||||
|
self.pb_add_connector_2.setText(QCoreApplication.translate("fluencyCAD", u"+Jnt", None))
|
||||||
|
self.gl_box.setTitle(QCoreApplication.translate("fluencyCAD", u"Model Viewer", None))
|
||||||
|
self.compo_box.setTitle(QCoreApplication.translate("fluencyCAD", u"Components", None))
|
||||||
|
self.groupBox_11.setTitle(QCoreApplication.translate("fluencyCAD", u"Sketch", None))
|
||||||
|
self.groupBox_6.setTitle(QCoreApplication.translate("fluencyCAD", u"Tools", None))
|
||||||
|
self.pb_edt_sktch.setText(QCoreApplication.translate("fluencyCAD", u"Edt", None))
|
||||||
|
self.pb_nw_sktch.setText(QCoreApplication.translate("fluencyCAD", u"Add", None))
|
||||||
|
self.pb_del_sketch.setText(QCoreApplication.translate("fluencyCAD", u"Del", None))
|
||||||
|
self.groupBox_2.setTitle(QCoreApplication.translate("fluencyCAD", u"Drawing", None))
|
||||||
|
self.pb_arc_tool.setText(QCoreApplication.translate("fluencyCAD", u"Arc", None))
|
||||||
|
self.pb_rectool.setText(QCoreApplication.translate("fluencyCAD", u"Rctgl", None))
|
||||||
|
self.pb_circtool.setText(QCoreApplication.translate("fluencyCAD", u"Circle", None))
|
||||||
|
self.pb_enable_construct.setText(QCoreApplication.translate("fluencyCAD", u"Cstrct", None))
|
||||||
|
self.pb_enable_snap.setText(QCoreApplication.translate("fluencyCAD", u"Snap", None))
|
||||||
|
self.pb_linetool.setText(QCoreApplication.translate("fluencyCAD", u"Line", None))
|
||||||
|
#if QT_CONFIG(shortcut)
|
||||||
|
self.pb_linetool.setShortcut(QCoreApplication.translate("fluencyCAD", u"S", None))
|
||||||
|
#endif // QT_CONFIG(shortcut)
|
||||||
|
self.pb_slotool.setText(QCoreApplication.translate("fluencyCAD", u"Slot", None))
|
||||||
|
#if QT_CONFIG(tooltip)
|
||||||
|
self.pb_offset_tool.setToolTip(QCoreApplication.translate("fluencyCAD", u"Offset selected sketch face (duplicate + offset boundary)", None))
|
||||||
|
#endif // QT_CONFIG(tooltip)
|
||||||
|
self.pb_offset_tool.setText(QCoreApplication.translate("fluencyCAD", u"Offst", None))
|
||||||
|
self.assembly_box.setTitle(QCoreApplication.translate("fluencyCAD", u"Assembly", None))
|
||||||
|
self.groupBox.setTitle(QCoreApplication.translate("fluencyCAD", u"Modify", None))
|
||||||
|
self.pb_chamfer_op.setText(QCoreApplication.translate("fluencyCAD", u"Chamfer", None))
|
||||||
|
self.pb_fillet_op.setText(QCoreApplication.translate("fluencyCAD", u"Fillet", None))
|
||||||
|
self.pb_thread.setText(QCoreApplication.translate("fluencyCAD", u"Thread", None))
|
||||||
|
self.pb_extrdop.setText(QCoreApplication.translate("fluencyCAD", u"Extrd", None))
|
||||||
|
self.pb_revop.setText(QCoreApplication.translate("fluencyCAD", u"Rev", None))
|
||||||
|
self.pb_cutop.setText(QCoreApplication.translate("fluencyCAD", u"Cut", None))
|
||||||
|
self.pb_arrayop.setText(QCoreApplication.translate("fluencyCAD", u"Arry", None))
|
||||||
|
self.pb_combop.setText(QCoreApplication.translate("fluencyCAD", u"Comb", None))
|
||||||
|
self.pb_moveop.setText(QCoreApplication.translate("fluencyCAD", u"Mve", None))
|
||||||
|
self.pb_mirror_op.setText(QCoreApplication.translate("fluencyCAD", u"Mirror", None))
|
||||||
|
self.groupBox_10.setTitle(QCoreApplication.translate("fluencyCAD", u"Bodys / Operations", None))
|
||||||
|
self.groupBox_8.setTitle(QCoreApplication.translate("fluencyCAD", u"Tools", None))
|
||||||
|
self.pb_body_hide.setText(QCoreApplication.translate("fluencyCAD", u"Hide", None))
|
||||||
|
self.pb_update_body.setText(QCoreApplication.translate("fluencyCAD", u"Upd", None))
|
||||||
|
self.pb_del_body.setText(QCoreApplication.translate("fluencyCAD", u"Del", None))
|
||||||
|
self.menuFile.setTitle(QCoreApplication.translate("fluencyCAD", u"File", None))
|
||||||
|
self.menuSettings.setTitle(QCoreApplication.translate("fluencyCAD", u"Settings", None))
|
||||||
|
# retranslateUi
|
||||||
|
|
||||||
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 267 B |
+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.
+900
-294
File diff suppressed because it is too large
Load Diff
+2912
-67
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,385 @@
|
|||||||
|
"""Surface modifier for OpenCASCADE geometry.
|
||||||
|
|
||||||
|
Applies geometric patterns (pyramids, bumps, grooves) to 3D surfaces using boolean operations.
|
||||||
|
This enables grip-enhancing textures and visual surface modifications on CAD models.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import math
|
||||||
|
from typing import Any, Optional, Tuple
|
||||||
|
|
||||||
|
# OCC imports at module level for common types
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class SurfaceModifier:
|
||||||
|
"""Applies geometric patterns to 3D surfaces using OCC boolean operations."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._patterns_applied = []
|
||||||
|
|
||||||
|
def apply_pyramid_pattern(
|
||||||
|
self,
|
||||||
|
face_shape,
|
||||||
|
pyramid_height: float = 1.0,
|
||||||
|
base_radius: float = 2.0,
|
||||||
|
spacing: float = 5.0,
|
||||||
|
num_rings: Optional[int] = None,
|
||||||
|
direction: Tuple[float, float, float] = (0, 0, 1),
|
||||||
|
) -> Optional[Any]:
|
||||||
|
"""Apply a pyramid pattern to a face surface.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
face_shape: OCC TopoDS_Shape representing the face or solid
|
||||||
|
pyramid_height: Height of each pyramid
|
||||||
|
base_radius: Radius of pyramid base
|
||||||
|
spacing: Distance between pyramids
|
||||||
|
num_rings: Number of concentric rings (auto-calculated if None)
|
||||||
|
direction: Normal direction for pyramids
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Modified shape on success, None on failure
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from OCP.TopAbs import TopAbs_FACE
|
||||||
|
from OCP.TopoDS import TopoDS_Face, TopoDS_Shape
|
||||||
|
from OCP.BRepAlgoAPI import BRepAlgoAPI_Fuse
|
||||||
|
from OCP.TopExp import TopExp_Explorer
|
||||||
|
from OCP.BRepAdaptor import BRepAdaptor_Surface
|
||||||
|
|
||||||
|
# Validate face shape
|
||||||
|
if not isinstance(face_shape, (TopoDS_Shape, TopoDS_Face)):
|
||||||
|
logger.error("Invalid face shape type")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Extract the first face for surface parameterization
|
||||||
|
if isinstance(face_shape, TopoDS_Shape):
|
||||||
|
explorer = TopExp_Explorer(face_shape, TopAbs_FACE)
|
||||||
|
if not explorer.More():
|
||||||
|
logger.error("No faces found in shape")
|
||||||
|
return None
|
||||||
|
from OCP import TopoDS
|
||||||
|
face = TopoDS.TopoDS.Face_s(explorer.Current())
|
||||||
|
else:
|
||||||
|
face = face_shape
|
||||||
|
|
||||||
|
# Get face surface for UV parameterization
|
||||||
|
surf = BRepAdaptor_Surface(face)
|
||||||
|
|
||||||
|
u_min, u_max = surf.FirstUParameter(), surf.LastUParameter()
|
||||||
|
v_min, v_max = surf.FirstVParameter(), surf.LastVParameter()
|
||||||
|
|
||||||
|
# Calculate number of rings if not specified
|
||||||
|
if num_rings is None:
|
||||||
|
# Estimate based on face area and spacing
|
||||||
|
u_range = u_max - u_min
|
||||||
|
v_range = v_max - v_min
|
||||||
|
avg_dim = (u_range + v_range) / 2.0
|
||||||
|
num_rings = max(1, min(int(avg_dim / spacing), 5))
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Applying pyramid pattern: {num_rings} rings, "
|
||||||
|
f"{base_radius:.2f} radius, {pyramid_height:.2f} height"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create pyramids distributed across the face UV space
|
||||||
|
result_shape = face_shape
|
||||||
|
pyramid_count = 0
|
||||||
|
|
||||||
|
for ring_idx in range(num_rings):
|
||||||
|
# Distribute rings evenly across UV parameter space
|
||||||
|
u_fraction = (ring_idx + 1) / (num_rings + 1)
|
||||||
|
v_fraction = 0.5 # Center vertically
|
||||||
|
|
||||||
|
# Map to actual UV coordinates on the face
|
||||||
|
u_pos = u_min + u_fraction * (u_max - u_min)
|
||||||
|
v_pos = v_min + v_fraction * (v_max - v_min)
|
||||||
|
|
||||||
|
# Get 3D position and tangent vectors at this UV point
|
||||||
|
from OCP.gp import gp_Pnt, gp_Vec
|
||||||
|
|
||||||
|
center_pt = gp_Pnt()
|
||||||
|
d1u = gp_Vec()
|
||||||
|
d1v = gp_Vec()
|
||||||
|
surf.D1(u_pos, v_pos, center_pt, d1u, d1v)
|
||||||
|
# Normal is cross product of tangent vectors
|
||||||
|
normal = d1u.Crossed(d1v)
|
||||||
|
normal.Normalize()
|
||||||
|
|
||||||
|
# Calculate number of pyramids in this ring based on spacing
|
||||||
|
if ring_idx == 0:
|
||||||
|
num_pyramids = 1 # Center pyramid
|
||||||
|
else:
|
||||||
|
circumference = 2.0 * math.pi * (ring_idx * spacing)
|
||||||
|
num_pyramids = max(3, int(circumference / spacing))
|
||||||
|
|
||||||
|
for i in range(num_pyramids):
|
||||||
|
if ring_idx == 0:
|
||||||
|
# Center pyramid - place at face center
|
||||||
|
place_u = u_pos
|
||||||
|
place_v = v_pos
|
||||||
|
else:
|
||||||
|
angle = (2.0 * math.pi * i) / num_pyramids
|
||||||
|
# Offset in UV space based on ring radius
|
||||||
|
offset_u = (ring_idx * spacing / (u_max - u_min)) * math.cos(angle)
|
||||||
|
offset_v = (ring_idx * spacing / (v_max - v_min)) * math.sin(angle)
|
||||||
|
place_u = max(u_min, min(u_max, u_pos + offset_u))
|
||||||
|
place_v = max(v_min, min(v_max, v_pos + offset_v))
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Get 3D position and normal for this pyramid
|
||||||
|
pyramid_pt = gp_Pnt()
|
||||||
|
pd1u = gp_Vec()
|
||||||
|
pd1v = gp_Vec()
|
||||||
|
surf.D1(place_u, place_v, pyramid_pt, pd1u, pd1v)
|
||||||
|
pyramid_normal = pd1u.Crossed(pd1v)
|
||||||
|
pyramid_normal.Normalize()
|
||||||
|
|
||||||
|
# Create solid pyramid at this position
|
||||||
|
pyramid_shape = self._create_solid_pyramid(
|
||||||
|
pyramid_pt,
|
||||||
|
pyramid_normal,
|
||||||
|
pyramid_height,
|
||||||
|
base_radius,
|
||||||
|
)
|
||||||
|
|
||||||
|
if pyramid_shape is not None:
|
||||||
|
# Fuse with existing geometry
|
||||||
|
fuse = BRepAlgoAPI_Fuse(result_shape, pyramid_shape)
|
||||||
|
fuse.Build()
|
||||||
|
|
||||||
|
if fuse.IsDone():
|
||||||
|
result_shape = fuse.Shape()
|
||||||
|
pyramid_count += 1
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
f"Failed to fuse pyramid at ({place_u:.2f}, {place_v:.2f})"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(
|
||||||
|
f"Error creating pyramid at ring {ring_idx}, pyramid {i}: {e}"
|
||||||
|
)
|
||||||
|
|
||||||
|
self._patterns_applied.append(
|
||||||
|
{
|
||||||
|
"type": "pyramid",
|
||||||
|
"parameters": {
|
||||||
|
"height": pyramid_height,
|
||||||
|
"base_radius": base_radius,
|
||||||
|
"spacing": spacing,
|
||||||
|
"num_rings": num_rings,
|
||||||
|
"direction": direction,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"Successfully applied {pyramid_count} pyramids")
|
||||||
|
|
||||||
|
return result_shape
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error applying pyramid pattern: {e}", exc_info=True)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _create_solid_pyramid(
|
||||||
|
self,
|
||||||
|
base_point, # gp_Pnt - position on the face
|
||||||
|
normal_vec, # gp_Dir or gp_Vec - surface normal direction
|
||||||
|
height: float,
|
||||||
|
base_radius: float,
|
||||||
|
) -> Optional[Any]:
|
||||||
|
"""Create a solid pyramid at the specified position and orientation.
|
||||||
|
|
||||||
|
Uses BRepPrimAPI_MakePrism to extrude a square base into a solid pyramid.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
base_point: 3D point where pyramid base is centered
|
||||||
|
normal_vec: Direction vector for pyramid growth (surface normal)
|
||||||
|
height: Height of the pyramid from base to apex
|
||||||
|
base_radius: Half-width of the square base
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
OCC solid shape for the pyramid, or None on failure
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from OCP.gp import gp_Dir, gp_Ax2, gp_Vec
|
||||||
|
from OCP.BRepBuilderAPI import (
|
||||||
|
BRepBuilderAPI_MakeEdge,
|
||||||
|
BRepBuilderAPI_MakeWire,
|
||||||
|
)
|
||||||
|
from OCP.BRepPrimAPI import BRepPrimAPI_MakePrism
|
||||||
|
|
||||||
|
half = base_radius / 2.0
|
||||||
|
|
||||||
|
# Build orthonormal basis from normal vector
|
||||||
|
if isinstance(normal_vec, gp_Vec):
|
||||||
|
n_dir = gp_Dir(normal_vec.XYZ())
|
||||||
|
else:
|
||||||
|
n_dir = normal_vec
|
||||||
|
|
||||||
|
# Create a local coordinate system at the base point
|
||||||
|
local_ax2 = gp_Ax2(base_point, n_dir)
|
||||||
|
|
||||||
|
# Get X and Y axes from the local coordinate system
|
||||||
|
x_dir = local_ax2.XDirection()
|
||||||
|
y_dir = local_ax2.YDirection()
|
||||||
|
|
||||||
|
# Create 4 corners of the square base in the local plane
|
||||||
|
corner_points = [
|
||||||
|
base_point + gp_Vec(x_dir).Multiplied(half) + gp_Vec(y_dir).Multiplied(half),
|
||||||
|
base_point + gp_Vec(x_dir).Multiplied(-half) + gp_Vec(y_dir).Multiplied(half),
|
||||||
|
base_point + gp_Vec(x_dir).Multiplied(-half) + gp_Vec(y_dir).Multiplied(-half),
|
||||||
|
base_point + gp_Vec(x_dir).Multiplied(half) + gp_Vec(y_dir).Multiplied(-half),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Create edges connecting the corners
|
||||||
|
wire_maker = BRepBuilderAPI_MakeWire()
|
||||||
|
for idx in range(4):
|
||||||
|
next_idx = (idx + 1) % 4
|
||||||
|
edge = BRepBuilderAPI_MakeEdge(
|
||||||
|
corner_points[idx], corner_points[next_idx]
|
||||||
|
).Edge()
|
||||||
|
wire_maker.Add(edge)
|
||||||
|
|
||||||
|
if not wire_maker.IsDone():
|
||||||
|
logger.warning("Failed to create pyramid base wire")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Extrude the base wire in the normal direction by height to form a prism
|
||||||
|
extrusion_vec = gp_Vec(n_dir).Multiplied(height)
|
||||||
|
prism_maker = BRepPrimAPI_MakePrism(
|
||||||
|
wire_maker.Wire(), extrusion_vec, False # no check intersection
|
||||||
|
)
|
||||||
|
prism_maker.Build()
|
||||||
|
|
||||||
|
if not prism_maker.IsDone():
|
||||||
|
logger.warning("Failed to create pyramid prism")
|
||||||
|
return None
|
||||||
|
|
||||||
|
return prism_maker.Shape()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Error creating solid pyramid: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def apply_bump_pattern(
|
||||||
|
self,
|
||||||
|
face_shape,
|
||||||
|
bump_height: float = 1.0,
|
||||||
|
bump_radius: float = 2.0,
|
||||||
|
spacing: float = 5.0,
|
||||||
|
num_rings: Optional[int] = None,
|
||||||
|
) -> Optional[Any]:
|
||||||
|
"""Apply a simple bump pattern to a face surface.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
face_shape: OCC TopoDS_Shape representing the face
|
||||||
|
bump_height: Height of each bump
|
||||||
|
bump_radius: Radius of each bump base
|
||||||
|
spacing: Distance between bumps
|
||||||
|
num_rings: Number of concentric rings
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Modified shape on success, None on failure
|
||||||
|
"""
|
||||||
|
return self.apply_pyramid_pattern(
|
||||||
|
face_shape,
|
||||||
|
pyramid_height=bump_height,
|
||||||
|
base_radius=bump_radius,
|
||||||
|
spacing=spacing,
|
||||||
|
num_rings=num_rings,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def apply_surface_modifier_to_body(
|
||||||
|
body_geometry, modifier_type: str = "pyramid", **parameters
|
||||||
|
) -> Optional[Any]:
|
||||||
|
"""Apply a surface modifier to a body geometry.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
body_geometry: OCCGeometryObject or similar geometry object
|
||||||
|
modifier_type: Type of modifier ('pyramid', 'bump')
|
||||||
|
**parameters: Modifier-specific parameters
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Modified shape, or None on failure
|
||||||
|
"""
|
||||||
|
from fluency.geometry_occ.kernel import OCGeometryKernel
|
||||||
|
|
||||||
|
kernel = OCGeometryKernel()
|
||||||
|
shape = kernel._get_shape(body_geometry)
|
||||||
|
|
||||||
|
if shape is None:
|
||||||
|
logger.error("No geometry found in body")
|
||||||
|
return None
|
||||||
|
|
||||||
|
modifier = SurfaceModifier()
|
||||||
|
|
||||||
|
try:
|
||||||
|
if modifier_type == "pyramid":
|
||||||
|
success = modifier.apply_pyramid_pattern(shape, **parameters)
|
||||||
|
elif modifier_type == "bump":
|
||||||
|
success = modifier.apply_bump_pattern(shape, **parameters)
|
||||||
|
else:
|
||||||
|
logger.error(f"Unknown modifier type: {modifier_type}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not success:
|
||||||
|
logger.error("Surface modifier application failed")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Return the modified shape wrapped in OCCGeometryObject
|
||||||
|
from fluency.geometry_occ.kernel import OCCGeometryObject
|
||||||
|
|
||||||
|
return OCCGeometryObject(shape)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error applying surface modifier: {e}", exc_info=True)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# Example usage and testing
|
||||||
|
if __name__ == "__main__":
|
||||||
|
# Create a simple test case
|
||||||
|
from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox
|
||||||
|
|
||||||
|
# Create a box to modify
|
||||||
|
box_maker = BRepPrimAPI_MakeBox(50, 50, 10)
|
||||||
|
box_maker.Build()
|
||||||
|
|
||||||
|
if box_maker.IsDone():
|
||||||
|
print("Created test box")
|
||||||
|
|
||||||
|
# Apply pyramid pattern to top face (Z direction)
|
||||||
|
modifier = SurfaceModifier()
|
||||||
|
success = modifier.apply_pyramid_pattern(
|
||||||
|
box_maker.Shape(),
|
||||||
|
pyramid_height=2.0,
|
||||||
|
base_radius=3.0,
|
||||||
|
spacing=8.0,
|
||||||
|
num_rings=2,
|
||||||
|
direction=(0, 0, 1),
|
||||||
|
)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
print("Successfully applied pyramid pattern")
|
||||||
|
|
||||||
|
# Export modified shape
|
||||||
|
from OCP.StlAPI import StlAPI_Writer
|
||||||
|
from OCP.BRepMesh import BRepMesh_IncrementalMesh
|
||||||
|
|
||||||
|
tess = BRepMesh_IncrementalMesh(box_maker.Shape(), 0.1)
|
||||||
|
tess.Perform()
|
||||||
|
|
||||||
|
writer = StlAPI_Writer()
|
||||||
|
writer.SetASCIIMode(False)
|
||||||
|
writer.Write(box_maker.Shape(), "/tmp/test_pyramid_pattern.stl")
|
||||||
|
print("Exported modified shape to STL")
|
||||||
|
else:
|
||||||
|
print("Failed to apply pyramid pattern")
|
||||||
|
else:
|
||||||
|
print("Failed to create test box")
|
||||||
@@ -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"]
|
||||||
File diff suppressed because it is too large
Load Diff
+44
-1608
File diff suppressed because it is too large
Load Diff
Binary file not shown.
Binary file not shown.
@@ -6,21 +6,91 @@ 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
|
||||||
|
|
||||||
from fluency.geometry.base import (
|
from fluency.geometry.base import (
|
||||||
Point2D,
|
Point2D,
|
||||||
Point3D,
|
|
||||||
GeometryObject,
|
GeometryObject,
|
||||||
SketchInterface,
|
|
||||||
)
|
)
|
||||||
from fluency.geometry_occ.kernel import OCGeometryKernel, OCCGeometryObject
|
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 = (float(n[0]), float(n[1]), float(n[2]))
|
||||||
|
self.x_dir = (float(x[0]), float(x[1]), float(x[2]))
|
||||||
|
self._y_dir = (float(y[0]), float(y[1]), float(y[2]))
|
||||||
|
|
||||||
|
@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()
|
||||||
@@ -110,6 +206,101 @@ class Sketch:
|
|||||||
self.modified_at = datetime.now()
|
self.modified_at = datetime.now()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Feature:
|
||||||
|
"""
|
||||||
|
One operation in a body's parametric feature history.
|
||||||
|
|
||||||
|
Bodies rebuild their geometry by replaying their ordered feature
|
||||||
|
list from scratch (see ``Body.features``). This is what makes
|
||||||
|
sketch edits propagate: a moved circle re-cuts at the new position
|
||||||
|
on a freshly rebuilt base instead of adding to the previous result.
|
||||||
|
|
||||||
|
``operation`` is one of:
|
||||||
|
- "extrude": base solid, ``kernel.extrude`` of the sketch profile
|
||||||
|
- "revolve": base solid, ``kernel.revolve`` of the sketch profile
|
||||||
|
- "cut": boolean difference of the running geometry with the
|
||||||
|
extruded sketch profile
|
||||||
|
- "union": boolean union of the running geometry with the
|
||||||
|
extruded sketch profile
|
||||||
|
- "fillet": round a set of edges of the running geometry
|
||||||
|
(``radius``, ``tangent_propagation``, ``scope``,
|
||||||
|
``edge_refs`` — see below)
|
||||||
|
- "chamfer": bevel a set of edges of the running geometry
|
||||||
|
(same fields as "fillet"; ``radius`` holds the
|
||||||
|
chamfer size)
|
||||||
|
- "mirror": mirror the running solid across a plane defined by
|
||||||
|
``mirror_plane_origin`` and ``mirror_plane_normal``.
|
||||||
|
When ``keep_original`` is True (the default), the
|
||||||
|
original solid is boolean-union'd with its mirror
|
||||||
|
- "array" / "pattern": repeat the running solid in a linear or
|
||||||
|
circular array. ``pattern_type`` is "linear" or
|
||||||
|
"circular"; ``count`` is the total number of items
|
||||||
|
(original + copies). Linear uses ``direction``
|
||||||
|
(unit vector) and ``spacing`` (mm between adjacent
|
||||||
|
items); circular reuses ``axis`` + ``origin`` for the
|
||||||
|
rotation axis and ``angle`` for the total angular
|
||||||
|
span in degrees (copies evenly distributed).
|
||||||
|
- "base": frozen geometry snapshot (``geometry`` field) — used
|
||||||
|
to migrate legacy bodies whose original base feature
|
||||||
|
is unknown. Never the result of a user operation.
|
||||||
|
"""
|
||||||
|
|
||||||
|
id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
||||||
|
operation: str = "extrude"
|
||||||
|
|
||||||
|
sketch: Optional[Sketch] = None # runtime ref; serialized as sketch_id
|
||||||
|
length: Optional[float] = None
|
||||||
|
symmetric: bool = False
|
||||||
|
invert: bool = False
|
||||||
|
through_all: bool = False
|
||||||
|
cut_all_bodies: bool = False
|
||||||
|
face_index: Optional[int] = None # which sketch face was selected
|
||||||
|
angle: float = 360.0 # revolve only (degrees)
|
||||||
|
axis: Tuple[float, float, float] = (0, 0, 1) # revolve only: unit axis vector
|
||||||
|
origin: Tuple[float, float, float] = (0.0, 0.0, 0.0) # revolve only: axis point
|
||||||
|
axis_line_id: Optional[int] = None # revolve only: sketch line entity used as axis
|
||||||
|
|
||||||
|
# "base" features only: frozen pre-feature geometry snapshot.
|
||||||
|
geometry: Optional[OCCGeometryObject] = None
|
||||||
|
|
||||||
|
# "fillet" / "chamfer" features only: radius (mm) of the round or the
|
||||||
|
# chamfer size, whether the op should extend along edges tangent to the
|
||||||
|
# picked ones, the edge scope ("selected" = edges between the two
|
||||||
|
# picked faces, "all" = every edge of the body), and stable
|
||||||
|
# fingerprints of the selected edges so the replay can re-find them
|
||||||
|
# after the base geometry is rebuilt.
|
||||||
|
radius: Optional[float] = None
|
||||||
|
tangent_propagation: bool = False
|
||||||
|
scope: str = "selected"
|
||||||
|
edge_refs: List[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
# FaceKey references for fillet/chamfer: stable face classification
|
||||||
|
# that survives sketch dimension changes. ``face_keys`` is a list of
|
||||||
|
# (FaceKey, FaceKey) pairs — one pair per user-picked face;
|
||||||
|
# ``face_keys_sketch_id`` tracks which sketch produced the body these
|
||||||
|
# faces belong to.
|
||||||
|
face_keys: Optional[List[Tuple[Dict[str, Any], Dict[str, Any]]]] = None
|
||||||
|
face_keys_sketch_id: Optional[str] = None
|
||||||
|
|
||||||
|
# "array" / "pattern" features only: repeat the running solid.
|
||||||
|
# ``pattern_type`` is "linear" or "circular"; ``count`` is the total
|
||||||
|
# number of items including the original. Linear arrays use
|
||||||
|
# ``direction`` (unit vector) and ``spacing`` (mm); circular arrays
|
||||||
|
# reuse ``axis`` / ``origin`` / ``angle`` (total angular span, deg).
|
||||||
|
pattern_type: str = "linear"
|
||||||
|
count: int = 2
|
||||||
|
spacing: float = 10.0
|
||||||
|
direction: Tuple[float, float, float] = (1.0, 0.0, 0.0)
|
||||||
|
|
||||||
|
# "mirror" feature only: mirror plane definition.
|
||||||
|
mirror_plane_origin: Tuple[float, float, float] = (0.0, 0.0, 0.0)
|
||||||
|
mirror_plane_normal: Tuple[float, float, float] = (1.0, 0.0, 0.0)
|
||||||
|
keep_original: bool = True
|
||||||
|
|
||||||
|
created_at: datetime = field(default_factory=datetime.now)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class Body:
|
class Body:
|
||||||
"""
|
"""
|
||||||
@@ -126,6 +317,27 @@ class Body:
|
|||||||
source_sketch: Optional[Sketch] = None
|
source_sketch: Optional[Sketch] = None
|
||||||
source_operation: str = "extrude"
|
source_operation: str = "extrude"
|
||||||
|
|
||||||
|
# Parametric feature history. When non-empty, the body is rebuilt
|
||||||
|
# from scratch by replaying these features in order; the flat
|
||||||
|
# extrude_* / source_* fields below then only mirror the LAST
|
||||||
|
# feature for backward compatibility (old files, old code paths).
|
||||||
|
features: List[Feature] = field(default_factory=list)
|
||||||
|
|
||||||
|
# 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_cut_all_bodies: bool = False # cut through all bodies in component
|
||||||
|
extrude_face_index: Optional[int] = None # which sketch face was selected
|
||||||
|
extrude_target_body_id: Optional[str] = None # for cut/union: target body id
|
||||||
|
needs_update: bool = False # True when source sketch changed since last extrude
|
||||||
|
|
||||||
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))
|
||||||
|
|
||||||
@@ -134,6 +346,7 @@ class Body:
|
|||||||
visible: bool = True
|
visible: bool = True
|
||||||
|
|
||||||
render_object: Any = None
|
render_object: Any = None
|
||||||
|
render_material: Optional[str] = None # material preset name for render backend
|
||||||
|
|
||||||
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)
|
||||||
@@ -166,12 +379,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 +453,336 @@ 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 = ""
|
||||||
|
# Entity class the connector was picked on ("planar_face",
|
||||||
|
# "cylindrical_face", "edge", "vertex"). Used to re-locate the
|
||||||
|
# connector on rebuilt geometry: only features of the same class are
|
||||||
|
# considered, so a hole connector can never jump onto a flat face.
|
||||||
|
# Empty for legacy files (all classes are then searched).
|
||||||
|
entity_type: str = ""
|
||||||
|
# Flip chosen in the placement dialog (bolt enters from the opposite
|
||||||
|
# side). Re-applied when a mated pair is re-solved so the original
|
||||||
|
# mate pose is reproduced exactly.
|
||||||
|
normal_flip: bool = False
|
||||||
|
|
||||||
|
# --- 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)
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
# Legacy files predate the entity_type field: recover it from the
|
||||||
|
# auto-generated connector name ("Conn cylindrical_face anchor") so
|
||||||
|
# relocation can restrict its search to the same feature class.
|
||||||
|
if not self.entity_type:
|
||||||
|
for t in ("cylindrical_face", "planar_face", "edge", "vertex"):
|
||||||
|
if self.name in (f"Conn {t} anchor", f"Conn {t} mover"):
|
||||||
|
self.entity_type = t
|
||||||
|
break
|
||||||
|
|
||||||
|
|
||||||
|
@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)
|
||||||
|
|
||||||
|
# Instance-local (per-instantiation) state. Kept separate from the
|
||||||
|
# shared component so per-instance work never leaks back into the base
|
||||||
|
# model. ``sketches`` are instance-local sketches stored in
|
||||||
|
# component-local coordinates (so they stay valid when the instance is
|
||||||
|
# moved / rotated); ``modifiers`` maps body_id to an ordered list of
|
||||||
|
# Feature ops applied ON TOP of the live component body feature history
|
||||||
|
# when the instance geometry is rebuilt.
|
||||||
|
sketches: Dict[str, Sketch] = field(default_factory=dict)
|
||||||
|
modifiers: Dict[str, List[Feature]] = field(default_factory=dict)
|
||||||
|
|
||||||
|
# Runtime-only cache of rebuilt instance geometry (body_id -> geometry).
|
||||||
|
# Never serialized; invalidated on component updates and modifier edits.
|
||||||
|
geom_cache: Dict[str, Any] = field(default_factory=dict, repr=False)
|
||||||
|
|
||||||
|
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,
|
||||||
|
entity_type: str = "",
|
||||||
|
normal_flip: bool = False,
|
||||||
|
) -> 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,
|
||||||
|
entity_type=entity_type,
|
||||||
|
normal_flip=normal_flip,
|
||||||
|
)
|
||||||
|
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
|
||||||
|
|
||||||
|
def add_instance_sketch(self, sketch: Optional[Sketch] = None) -> Sketch:
|
||||||
|
"""Add an instance-local sketch (component-local coordinates)."""
|
||||||
|
if sketch is None:
|
||||||
|
sketch = Sketch(name=f"Instance Sketch {len(self.sketches) + 1}")
|
||||||
|
self.sketches[sketch.id] = sketch
|
||||||
|
self.modified_at = datetime.now()
|
||||||
|
return sketch
|
||||||
|
|
||||||
|
def remove_instance_sketch(self, sketch_id: str) -> bool:
|
||||||
|
"""Remove an instance sketch and every modifier that references it."""
|
||||||
|
if sketch_id not in self.sketches:
|
||||||
|
return False
|
||||||
|
del self.sketches[sketch_id]
|
||||||
|
for body_id in list(self.modifiers.keys()):
|
||||||
|
kept = [
|
||||||
|
f for f in self.modifiers[body_id]
|
||||||
|
if not (f.sketch is not None and f.sketch.id == sketch_id)
|
||||||
|
]
|
||||||
|
if kept:
|
||||||
|
self.modifiers[body_id] = kept
|
||||||
|
else:
|
||||||
|
del self.modifiers[body_id]
|
||||||
|
self.geom_cache.pop(body_id, None)
|
||||||
|
self.modified_at = datetime.now()
|
||||||
|
return True
|
||||||
|
|
||||||
|
def add_modifier(self, body_id: str, feat: Feature) -> Feature:
|
||||||
|
"""Append a modifier op to *body_id*'s instance-local history."""
|
||||||
|
self.modifiers.setdefault(body_id, []).append(feat)
|
||||||
|
self.geom_cache.pop(body_id, None)
|
||||||
|
self.modified_at = datetime.now()
|
||||||
|
return feat
|
||||||
|
|
||||||
|
def invalidate_geom_cache(self, body_id: Optional[str] = None) -> None:
|
||||||
|
"""Drop cached rebuilt instance geometry (one body, or all)."""
|
||||||
|
if body_id is None:
|
||||||
|
self.geom_cache.clear()
|
||||||
|
else:
|
||||||
|
self.geom_cache.pop(body_id, None)
|
||||||
|
|
||||||
|
@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 +798,21 @@ 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
|
||||||
|
|
||||||
|
# Technical drawings keyed to their source component/assembly.
|
||||||
|
# Manual dimensions and view options added in the drawing workbench
|
||||||
|
# are persisted here so they survive save/load.
|
||||||
|
drawings: List["TechnicalDrawing"] = field(default_factory=list)
|
||||||
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:
|
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:
|
||||||
@@ -270,6 +844,59 @@ class Project:
|
|||||||
self.active_component = component_id
|
self.active_component = component_id
|
||||||
self.modified_at = datetime.now()
|
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)
|
||||||
|
|
||||||
|
# ── Drawing helpers ──
|
||||||
|
|
||||||
|
def get_drawing_for(self, source_kind: str, source_id: str) -> Optional["TechnicalDrawing"]:
|
||||||
|
"""Return the drawing bound to *(source_kind, source_id)*, if any."""
|
||||||
|
for drawing in self.drawings:
|
||||||
|
if drawing.source_kind == source_kind and drawing.source_id == source_id:
|
||||||
|
return drawing
|
||||||
|
return None
|
||||||
|
|
||||||
|
def add_drawing(self, drawing: "TechnicalDrawing") -> "TechnicalDrawing":
|
||||||
|
"""Register *drawing* with the project and return it."""
|
||||||
|
self.drawings.append(drawing)
|
||||||
|
self.modified_at = datetime.now()
|
||||||
|
return drawing
|
||||||
|
|
||||||
|
|
||||||
def export_step(self, filepath: str) -> bool:
|
def export_step(self, filepath: str) -> bool:
|
||||||
"""Export all visible bodies to STEP."""
|
"""Export all visible bodies to STEP."""
|
||||||
all_bodies: List[OCCGeometryObject] = []
|
all_bodies: List[OCCGeometryObject] = []
|
||||||
@@ -337,3 +964,102 @@ class Project:
|
|||||||
for comp in self.components.values():
|
for comp in self.components.values():
|
||||||
sketches.extend(comp.sketches.values())
|
sketches.extend(comp.sketches.values())
|
||||||
return sketches
|
return sketches
|
||||||
|
|
||||||
|
def compute_source_fingerprint(
|
||||||
|
self, source_kind: str, source_id: str
|
||||||
|
) -> str:
|
||||||
|
"""Compute a simple fingerprint for a source reference."""
|
||||||
|
import hashlib
|
||||||
|
data = f"{source_kind}:{source_id}"
|
||||||
|
return hashlib.sha256(data.encode()).hexdigest()[:16]
|
||||||
|
# ── Technical Drawing models ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DrawingView:
|
||||||
|
"""One projected view in a technical drawing."""
|
||||||
|
|
||||||
|
id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
||||||
|
kind: str = "front" # front, back, top, bottom, right, left, isometric, custom
|
||||||
|
name: Optional[str] = None # human-readable label; defaults from kind
|
||||||
|
|
||||||
|
# View direction and up vector in world coords (for custom views).
|
||||||
|
# Ignored when kind is one of the standard presets.
|
||||||
|
direction: Optional[Tuple[float, float, float]] = None
|
||||||
|
up_vector: Optional[Tuple[float, float, float]] = None
|
||||||
|
|
||||||
|
show_hidden_lines: bool = False
|
||||||
|
show_centerlines: bool = False
|
||||||
|
scale: float = 1.0
|
||||||
|
|
||||||
|
# Sheet position (mm from sheet origin) — set by layout engine.
|
||||||
|
sheet_origin: Tuple[float, float] = (0.0, 0.0)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DrawingAnnotation:
|
||||||
|
"""One annotation (dimension or note) on a technical drawing."""
|
||||||
|
|
||||||
|
id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
||||||
|
kind: str = "dimension" # dimension, note, tolerance, surface_finish, weld_symbol
|
||||||
|
text: Optional[str] = None
|
||||||
|
visible: bool = True
|
||||||
|
|
||||||
|
# References to DrawingCandidate keys this annotation is bound to.
|
||||||
|
references: List[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
# Sheet position (mm). For dimensions, anchor point; for notes, placement.
|
||||||
|
sheet_position: Tuple[float, float] = (0.0, 0.0)
|
||||||
|
|
||||||
|
# Associated view id (empty means global/note block).
|
||||||
|
view_id: str = ""
|
||||||
|
|
||||||
|
# Manual-dimension geometry in view-plane model coordinates (model
|
||||||
|
# units in the view's projection plane). Populated for user-placed
|
||||||
|
# dimensions, empty for reference-based annotations:
|
||||||
|
# "length": (point_on_edge1, point_on_edge2) — closest points
|
||||||
|
# "diameter": (left, right) — antipodal points across the centre
|
||||||
|
# "angle": (vertex, arm1_point, arm2_point)
|
||||||
|
anchors: List[Tuple[float, float]] = field(default_factory=list)
|
||||||
|
|
||||||
|
# Sub-kind of the manual dimension: "length" | "diameter" | "angle".
|
||||||
|
# Empty for non-dimension annotations.
|
||||||
|
dimension_kind: str = ""
|
||||||
|
|
||||||
|
# Unit vector along the measured distance in view-plane coordinates
|
||||||
|
# (length dimensions only). The model→sheet transform is a uniform
|
||||||
|
# scale + translation, so the direction is valid in sheet space too.
|
||||||
|
direction: Optional[Tuple[float, float]] = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TechnicalDrawing:
|
||||||
|
"""A complete technical drawing definition."""
|
||||||
|
|
||||||
|
id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
||||||
|
name: str = "Untitled Drawing"
|
||||||
|
|
||||||
|
# Source geometry reference.
|
||||||
|
source_kind: str = "component" # component, assembly
|
||||||
|
source_id: str = ""
|
||||||
|
|
||||||
|
views: List[DrawingView] = field(default_factory=list)
|
||||||
|
annotations: List[DrawingAnnotation] = field(default_factory=list)
|
||||||
|
|
||||||
|
# Title block metadata.
|
||||||
|
title: str = ""
|
||||||
|
part_number: str = ""
|
||||||
|
material: str = ""
|
||||||
|
revision: str = ""
|
||||||
|
notes: str = ""
|
||||||
|
|
||||||
|
# Sheet size (A0..A4 or custom mm). Default A4 (ISO 5457 template).
|
||||||
|
sheet_size: str = "A4"
|
||||||
|
units: str = "mm" # mm, in
|
||||||
|
|
||||||
|
# Auto-generation flags. Auto dimensions are opt-in: the drawing
|
||||||
|
# workbench shows them only while the user has the toggle enabled.
|
||||||
|
auto_dimensions: bool = False
|
||||||
|
auto_views: bool = True
|
||||||
|
|
||||||
|
created_at: datetime = field(default_factory=datetime.now)
|
||||||
|
modified_at: datetime = field(default_factory=datetime.now)
|
||||||
|
|||||||
@@ -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,425 @@
|
|||||||
|
"""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 _base_scene_dict(
|
||||||
|
self,
|
||||||
|
camera: RenderCamera,
|
||||||
|
settings: RenderSettings,
|
||||||
|
first_mesh_path: Optional[str] = None,
|
||||||
|
) -> dict:
|
||||||
|
"""Return a scene dict with everything *except* the shape entries.
|
||||||
|
|
||||||
|
When *first_mesh_path* is given the ground plane / backdrop is sized
|
||||||
|
from its bounding box; otherwise a large default is used.
|
||||||
|
"""
|
||||||
|
import mitsuba as mi
|
||||||
|
|
||||||
|
lighting = settings.lighting
|
||||||
|
ground = settings.ground_plane
|
||||||
|
|
||||||
|
cam_to_world = mi.ScalarTransform4f.look_at(
|
||||||
|
origin=list(camera.origin),
|
||||||
|
target=list(camera.target),
|
||||||
|
up=list(camera.up),
|
||||||
|
)
|
||||||
|
|
||||||
|
scene: dict = {
|
||||||
|
"type": "scene",
|
||||||
|
"integrator": {"type": "path", "max_depth": settings.max_depth},
|
||||||
|
"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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"emitter": {
|
||||||
|
"type": "constant",
|
||||||
|
"radiance": {
|
||||||
|
"type": "rgb",
|
||||||
|
"value": [
|
||||||
|
lighting.ambient_intensity,
|
||||||
|
lighting.ambient_intensity * 0.97,
|
||||||
|
lighting.ambient_intensity * 0.94,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── 3-point lighting ──────────────────────────────────────────
|
||||||
|
key_rgb = [c * lighting.key_intensity for c in lighting.key_color]
|
||||||
|
scene["key_light"] = {
|
||||||
|
"type": "directional",
|
||||||
|
"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],
|
||||||
|
),
|
||||||
|
"irradiance": {"type": "rgb", "value": key_rgb},
|
||||||
|
}
|
||||||
|
|
||||||
|
fill_rgb = [c * lighting.fill_intensity for c in lighting.fill_color]
|
||||||
|
scene["fill_light"] = {
|
||||||
|
"type": "directional",
|
||||||
|
"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],
|
||||||
|
),
|
||||||
|
"irradiance": {"type": "rgb", "value": fill_rgb},
|
||||||
|
}
|
||||||
|
|
||||||
|
rim_rgb = [c * lighting.rim_intensity for c in lighting.rim_color]
|
||||||
|
scene["rim_light"] = {
|
||||||
|
"type": "directional",
|
||||||
|
"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],
|
||||||
|
),
|
||||||
|
"irradiance": {"type": "rgb", "value": rim_rgb},
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Ground plane / backdrop ───────────────────────────────────
|
||||||
|
if ground.enabled:
|
||||||
|
try:
|
||||||
|
ext = os.path.splitext(first_mesh_path)[1].lower() if first_mesh_path else ""
|
||||||
|
shape_type = "ply" if ext == ".ply" else "obj"
|
||||||
|
mesh_shape = mi.load_dict({"type": shape_type, "filename": first_mesh_path})
|
||||||
|
bbox = mesh_shape.bbox()
|
||||||
|
bbox_min, bbox_max = bbox[0], bbox[1]
|
||||||
|
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:
|
||||||
|
ground_z = -ground.distance_below
|
||||||
|
diag = 1000.0
|
||||||
|
|
||||||
|
bsdf_ground = {
|
||||||
|
"type": "diffuse",
|
||||||
|
"reflectance": {"type": "rgb", "value": list(ground.color)},
|
||||||
|
}
|
||||||
|
|
||||||
|
if ground.curved_backdrop:
|
||||||
|
half_size = diag * 50.0
|
||||||
|
radius = diag * 3.0
|
||||||
|
cyl_height = diag * 20.0
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
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:
|
||||||
|
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 _build_scene_dict(
|
||||||
|
self,
|
||||||
|
mesh_path: str,
|
||||||
|
material: RenderMaterial,
|
||||||
|
camera: RenderCamera,
|
||||||
|
settings: RenderSettings,
|
||||||
|
) -> dict:
|
||||||
|
"""Build a single-shape Mitsuba scene dictionary."""
|
||||||
|
ext = os.path.splitext(mesh_path)[1].lower()
|
||||||
|
shape_type = "ply" if ext == ".ply" else "obj"
|
||||||
|
|
||||||
|
scene = self._base_scene_dict(camera, settings, first_mesh_path=mesh_path)
|
||||||
|
scene["shape"] = {
|
||||||
|
"type": shape_type,
|
||||||
|
"filename": mesh_path,
|
||||||
|
"bsdf": self._make_bsdf(material),
|
||||||
|
}
|
||||||
|
return scene
|
||||||
|
|
||||||
|
def _build_assembly_scene_dict(
|
||||||
|
self,
|
||||||
|
parts: list,
|
||||||
|
camera: RenderCamera,
|
||||||
|
settings: RenderSettings,
|
||||||
|
) -> dict:
|
||||||
|
"""Build a multi-shape Mitsuba scene dictionary.
|
||||||
|
|
||||||
|
*parts* is a list of ``(mesh_path, RenderMaterial)`` tuples.
|
||||||
|
"""
|
||||||
|
first_path = parts[0][0] if parts else None
|
||||||
|
scene = self._base_scene_dict(camera, settings, first_mesh_path=first_path)
|
||||||
|
|
||||||
|
for i, (mesh_path, material) in enumerate(parts):
|
||||||
|
ext = os.path.splitext(mesh_path)[1].lower()
|
||||||
|
shape_type = "ply" if ext == ".ply" else "obj"
|
||||||
|
scene[f"shape_{i}"] = {
|
||||||
|
"type": shape_type,
|
||||||
|
"filename": mesh_path,
|
||||||
|
"bsdf": self._make_bsdf(material),
|
||||||
|
}
|
||||||
|
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")
|
||||||
|
|
||||||
|
try:
|
||||||
|
image = mi.render(scene, spp=settings.spp, seed=int(settings.seed or 0))
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Mitsuba render failed: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
if progress_callback:
|
||||||
|
progress_callback(1.0)
|
||||||
|
|
||||||
|
arr = np.array(image, dtype=np.float32)
|
||||||
|
arr = np.clip(arr, 0.0, None)
|
||||||
|
arr = np.power(arr, 1.0 / 2.2)
|
||||||
|
arr = np.clip(arr, 0.0, 1.0)
|
||||||
|
return arr
|
||||||
|
|
||||||
|
def render_assembly(
|
||||||
|
self,
|
||||||
|
parts: list,
|
||||||
|
camera: RenderCamera,
|
||||||
|
settings: RenderSettings,
|
||||||
|
progress_callback: Optional[Callable[[float], None]] = None,
|
||||||
|
) -> np.ndarray:
|
||||||
|
"""Render multiple meshes with individual materials.
|
||||||
|
|
||||||
|
*parts* is a list of ``(mesh_path, RenderMaterial)`` tuples.
|
||||||
|
Returns (H, W, 3) float32 RGB array.
|
||||||
|
"""
|
||||||
|
self._set_variant()
|
||||||
|
import mitsuba as mi
|
||||||
|
|
||||||
|
scene_dict = self._build_assembly_scene_dict(parts, camera, settings)
|
||||||
|
scene = mi.load_dict(scene_dict)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Rendering assembly ({len(parts)} parts) "
|
||||||
|
f"{settings.width}x{settings.height} @ {settings.spp} spp"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
image = mi.render(scene, spp=settings.spp, seed=int(settings.seed or 0))
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Mitsuba assembly render failed: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
if progress_callback:
|
||||||
|
progress_callback(1.0)
|
||||||
|
|
||||||
|
arr = np.array(image, dtype=np.float32)
|
||||||
|
arr = np.clip(arr, 0.0, None)
|
||||||
|
arr = np.power(arr, 1.0 / 2.2)
|
||||||
|
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,336 @@
|
|||||||
|
"""
|
||||||
|
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.
|
||||||
|
|
||||||
|
python-solvespace's ``distance`` accepts (point, line) ordering only,
|
||||||
|
so line-point pairs are normalised; line-line pairs and a point
|
||||||
|
constrained to itself are rejected (the solver cannot hold them).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if isinstance(entity_a, SolverLine) and isinstance(entity_b, SolverLine):
|
||||||
|
logger.warning("distance: line-to-line distance is not supported")
|
||||||
|
return False
|
||||||
|
if isinstance(entity_a, SolverLine) and isinstance(entity_b, SolverPoint):
|
||||||
|
# Normalise to (point, line) ordering.
|
||||||
|
entity_a, entity_b = entity_b, entity_a
|
||||||
|
if entity_a is entity_b and distance != 0.0:
|
||||||
|
logger.warning("distance: refusing point-to-itself constraint")
|
||||||
|
return False
|
||||||
|
handle_a = entity_a.handle
|
||||||
|
handle_b = 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, SolverPoint) and isinstance(entity_b, SolverPoint):
|
||||||
|
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"
|
||||||
|
)
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,213 @@
|
|||||||
|
"""Tests for the A4 ISO 5457 sheet: layout regions, first-angle view
|
||||||
|
ordering, sheet furniture primitives, scale formatting, and the A4
|
||||||
|
normalisation of drawings loaded from .fluency files.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
# Allow running this file directly: ``python tests/test_iso5457_sheet.py``.
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir, "src"))
|
||||||
|
|
||||||
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
|
||||||
|
from fluency.models.data_model import DrawingView, TechnicalDrawing
|
||||||
|
from fluency.technical_drawing import (
|
||||||
|
_SHEET_HEIGHT_MM,
|
||||||
|
_SHEET_WIDTH_MM,
|
||||||
|
_format_drawing_scale,
|
||||||
|
_layout_views_on_sheet,
|
||||||
|
_sheet_frame_primitives,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _view(kind):
|
||||||
|
return DrawingView(kind=kind, name=kind)
|
||||||
|
|
||||||
|
|
||||||
|
class TestSheetSize(unittest.TestCase):
|
||||||
|
"""The workbench renders a single A4 landscape sheet."""
|
||||||
|
|
||||||
|
def test_a4_dimensions(self):
|
||||||
|
self.assertEqual((_SHEET_WIDTH_MM, _SHEET_HEIGHT_MM), (297.0, 210.0))
|
||||||
|
|
||||||
|
def test_default_drawing_is_a4(self):
|
||||||
|
self.assertEqual(TechnicalDrawing().sheet_size, "A4")
|
||||||
|
|
||||||
|
|
||||||
|
class TestScaleFormatting(unittest.TestCase):
|
||||||
|
"""Title-block scale field formatting (``N : 1`` / ``1 : N``)."""
|
||||||
|
|
||||||
|
def test_none_and_one(self):
|
||||||
|
self.assertEqual(_format_drawing_scale(None), "1 : 1")
|
||||||
|
self.assertEqual(_format_drawing_scale(0.0), "1 : 1")
|
||||||
|
self.assertEqual(_format_drawing_scale(1.0), "1 : 1")
|
||||||
|
|
||||||
|
def test_enlarged(self):
|
||||||
|
self.assertEqual(_format_drawing_scale(2.0), "2 : 1")
|
||||||
|
self.assertEqual(_format_drawing_scale(1.5), "1.5 : 1")
|
||||||
|
|
||||||
|
def test_reduced(self):
|
||||||
|
self.assertEqual(_format_drawing_scale(0.5), "1 : 2")
|
||||||
|
self.assertEqual(_format_drawing_scale(0.33333), "1 : 3")
|
||||||
|
|
||||||
|
|
||||||
|
class TestFirstAngleLayout(unittest.TestCase):
|
||||||
|
"""View slots stay inside the ISO 5457 drawing-space frame, clear the
|
||||||
|
title-block reserve zone, and follow first-angle ordering (view from
|
||||||
|
the right left of front, view from the left right of front, top view
|
||||||
|
below front)."""
|
||||||
|
|
||||||
|
def _slots(self, kinds):
|
||||||
|
bboxes = {k: (0.0, 0.0, 40.0, 30.0) for k in kinds}
|
||||||
|
slots, scale, _rots = _layout_views_on_sheet(
|
||||||
|
[_view(k) for k in kinds], bboxes
|
||||||
|
)
|
||||||
|
return slots, scale
|
||||||
|
|
||||||
|
def test_slots_inside_frame(self):
|
||||||
|
slots, scale = self._slots(
|
||||||
|
("front", "top", "right", "left", "isometric")
|
||||||
|
)
|
||||||
|
self.assertIsNotNone(scale)
|
||||||
|
self.assertGreater(scale, 0)
|
||||||
|
for vid, (x, y, w, h) in slots.items():
|
||||||
|
# Drawing-space frame: 20..287 x 10..200.
|
||||||
|
self.assertGreaterEqual(x, 19.9, vid)
|
||||||
|
self.assertGreaterEqual(y, 9.9, vid)
|
||||||
|
self.assertLessEqual(x + w, 287.1, vid)
|
||||||
|
self.assertLessEqual(y + h, 200.1, vid)
|
||||||
|
# Title-block reserve zone: x >= 102, y <= 64.
|
||||||
|
self.assertTrue(
|
||||||
|
x + w <= 102.01 or y + h <= 0.01 or y >= 63.99,
|
||||||
|
(vid, (x, y, w, h)),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_first_angle_ordering(self):
|
||||||
|
slots, _ = self._slots(("front", "top", "right", "left"))
|
||||||
|
xf, wf = slots["front"][0], slots["front"][2]
|
||||||
|
yf = slots["front"][1]
|
||||||
|
# View from the right sits left of the front view.
|
||||||
|
self.assertLessEqual(
|
||||||
|
slots["right"][0] + slots["right"][2], xf + 0.01
|
||||||
|
)
|
||||||
|
# View from the left sits right of the front view.
|
||||||
|
self.assertGreaterEqual(slots["left"][0], xf + wf + 0.01)
|
||||||
|
# Top view sits below the front view.
|
||||||
|
self.assertLessEqual(slots["top"][1] + slots["top"][3], yf + 0.01)
|
||||||
|
|
||||||
|
def test_single_view_centered_upright(self):
|
||||||
|
slots, scale, rots = _layout_views_on_sheet(
|
||||||
|
[_view("front")], {"front": (0.0, 0.0, 40.0, 30.0)}
|
||||||
|
)
|
||||||
|
self.assertIsNotNone(scale)
|
||||||
|
self.assertEqual(rots.get("front", 0.0), 0.0)
|
||||||
|
self.assertGreater(scale, 0)
|
||||||
|
x, y, w, h = slots["front"]
|
||||||
|
self.assertGreaterEqual(x, 19.9)
|
||||||
|
self.assertGreaterEqual(y, 9.9)
|
||||||
|
|
||||||
|
|
||||||
|
class TestSheetFurniture(unittest.TestCase):
|
||||||
|
"""ISO 5457 furniture primitives mirror the reference SVG template."""
|
||||||
|
|
||||||
|
def _prims(self):
|
||||||
|
d = TechnicalDrawing(
|
||||||
|
title="Test Bracket",
|
||||||
|
part_number="PN-42",
|
||||||
|
material="Alu 6082",
|
||||||
|
revision="B",
|
||||||
|
)
|
||||||
|
return _sheet_frame_primitives(d, "1 : 2")
|
||||||
|
|
||||||
|
def test_all_geometry_inside_sheet(self):
|
||||||
|
for p in self._prims():
|
||||||
|
for (x, y) in p.points:
|
||||||
|
self.assertGreaterEqual(x, -0.01)
|
||||||
|
self.assertLessEqual(x, 297.01)
|
||||||
|
self.assertGreaterEqual(y, -0.01)
|
||||||
|
self.assertLessEqual(y, 210.01)
|
||||||
|
|
||||||
|
def test_drawing_space_frame(self):
|
||||||
|
# Frame rect 20,10 267x190: four edges present as line prims.
|
||||||
|
lines = [
|
||||||
|
(tuple(p.points[0]), tuple(p.points[1]))
|
||||||
|
for p in self._prims()
|
||||||
|
if p.kind == "line" and p.style == "frame"
|
||||||
|
]
|
||||||
|
for a, b in (
|
||||||
|
((20.0, 10.0), (287.0, 10.0)),
|
||||||
|
((287.0, 10.0), (287.0, 200.0)),
|
||||||
|
((287.0, 200.0), (20.0, 200.0)),
|
||||||
|
((20.0, 200.0), (20.0, 10.0)),
|
||||||
|
):
|
||||||
|
self.assertIn((a, b), lines)
|
||||||
|
|
||||||
|
def test_grid_reference_labels(self):
|
||||||
|
texts = [p.text for p in self._prims() if p.kind == "text" and p.text]
|
||||||
|
for t in ("1", "2", "3", "4", "5", "6", "A", "B", "C", "D", "A4"):
|
||||||
|
self.assertIn(t, texts)
|
||||||
|
|
||||||
|
def test_title_block_fields(self):
|
||||||
|
texts = [p.text for p in self._prims() if p.kind == "text" and p.text]
|
||||||
|
for expect in (
|
||||||
|
"Owner:",
|
||||||
|
"Drawing number:",
|
||||||
|
"Revision:",
|
||||||
|
"Issue date:",
|
||||||
|
"Sheet:",
|
||||||
|
"Language:",
|
||||||
|
"Title:",
|
||||||
|
"Approved by:",
|
||||||
|
"Created by:",
|
||||||
|
"Document type:",
|
||||||
|
"Part Material:",
|
||||||
|
"General tolerances:",
|
||||||
|
"Scale:",
|
||||||
|
"Test Bracket",
|
||||||
|
"PN-42",
|
||||||
|
"Alu 6082",
|
||||||
|
"B",
|
||||||
|
"1 : 2",
|
||||||
|
"EN",
|
||||||
|
"1 / 1",
|
||||||
|
"ISO 2768-m",
|
||||||
|
"Component Drawing",
|
||||||
|
):
|
||||||
|
self.assertIn(expect, texts)
|
||||||
|
|
||||||
|
def test_first_angle_projection_symbol(self):
|
||||||
|
# Two concentric circles at the symbol centre (273, 58) in sheet
|
||||||
|
# coords (SVG y-down 152 → 210 - 152 = 58).
|
||||||
|
circles = [
|
||||||
|
p
|
||||||
|
for p in self._prims()
|
||||||
|
if p.kind == "circle" and p.center == (273.0, 58.0)
|
||||||
|
]
|
||||||
|
self.assertEqual(len(circles), 2)
|
||||||
|
self.assertEqual(
|
||||||
|
sorted(c.radius for c in circles if c.radius), [2.5, 5.0]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestSheetSizeNormalisation(unittest.TestCase):
|
||||||
|
"""Drawings stored as A3 in .fluency files load as A4 — the renderer
|
||||||
|
only supports the A4 ISO 5457 sheet."""
|
||||||
|
|
||||||
|
def test_legacy_a3_loads_as_a4(self):
|
||||||
|
from fluency.io.project_io import _technical_drawing_from_dict
|
||||||
|
|
||||||
|
d = _technical_drawing_from_dict(
|
||||||
|
{
|
||||||
|
"id": "x",
|
||||||
|
"source_kind": "component",
|
||||||
|
"source_id": "c",
|
||||||
|
"sheet_size": "A3",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.assertEqual(d.sheet_size, "A4")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,282 @@
|
|||||||
|
"""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, _feature_to_dict, _feature_from_dict
|
||||||
|
from fluency.models.data_model import (
|
||||||
|
Project,
|
||||||
|
Component,
|
||||||
|
Body,
|
||||||
|
Sketch,
|
||||||
|
Workplane,
|
||||||
|
Assembly,
|
||||||
|
Feature,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestRevolveAxisSerialization(unittest.TestCase):
|
||||||
|
"""Revolve features persist their revolve axis across save/load."""
|
||||||
|
|
||||||
|
def test_axis_round_trip(self):
|
||||||
|
sk = Sketch()
|
||||||
|
feat = Feature(
|
||||||
|
operation="revolve",
|
||||||
|
angle=180.0,
|
||||||
|
axis=(1, 0, 0),
|
||||||
|
origin=(10.0, 5.0, 0.0),
|
||||||
|
axis_line_id=7,
|
||||||
|
)
|
||||||
|
feat.sketch = sk
|
||||||
|
data = _feature_to_dict(feat)
|
||||||
|
self.assertEqual(data["axis"], [1.0, 0.0, 0.0])
|
||||||
|
self.assertEqual(data["origin"], [10.0, 5.0, 0.0])
|
||||||
|
self.assertEqual(data["axis_line_id"], 7)
|
||||||
|
restored = _feature_from_dict(data, {sk.id: sk})
|
||||||
|
self.assertEqual(tuple(restored.axis), (1.0, 0.0, 0.0))
|
||||||
|
self.assertEqual(tuple(restored.origin), (10.0, 5.0, 0.0))
|
||||||
|
self.assertEqual(restored.axis_line_id, 7)
|
||||||
|
self.assertEqual(restored.angle, 180.0)
|
||||||
|
|
||||||
|
def test_default_axis(self):
|
||||||
|
"""Old files without axis fields fall back to Z axis at the origin."""
|
||||||
|
sk = Sketch()
|
||||||
|
data = _feature_to_dict(Feature(operation="revolve", angle=90.0))
|
||||||
|
restored = _feature_from_dict(data, {sk.id: sk})
|
||||||
|
self.assertEqual(tuple(restored.axis), (0, 0, 1))
|
||||||
|
self.assertEqual(tuple(restored.origin), (0.0, 0.0, 0.0))
|
||||||
|
self.assertIsNone(restored.axis_line_id)
|
||||||
|
# legacy file without the keys
|
||||||
|
del data["axis"]
|
||||||
|
del data["origin"]
|
||||||
|
del data["axis_line_id"]
|
||||||
|
restored = _feature_from_dict(data, {sk.id: sk})
|
||||||
|
self.assertEqual(tuple(restored.axis), (0, 0, 1))
|
||||||
|
self.assertEqual(tuple(restored.origin), (0.0, 0.0, 0.0))
|
||||||
|
|
||||||
|
|
||||||
|
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] = []
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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"Array", 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
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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.
+961
-1
@@ -1,7 +1,6 @@
|
|||||||
"""Tests for Fluency CAD geometry kernel."""
|
"""Tests for Fluency CAD geometry kernel."""
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
import numpy as np
|
|
||||||
|
|
||||||
from fluency.geometry_occ.kernel import OCGeometryKernel, OCCGeometryObject
|
from fluency.geometry_occ.kernel import OCGeometryKernel, OCCGeometryObject
|
||||||
from fluency.geometry_occ.sketch import OCCSketch
|
from fluency.geometry_occ.sketch import OCCSketch
|
||||||
@@ -182,6 +181,967 @@ 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
|
||||||
|
from OCP.GProp import GProp_GProps
|
||||||
|
from OCP.BRepGProp import BRepGProp
|
||||||
|
|
||||||
|
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.TopExp import TopExp_Explorer
|
||||||
|
from OCP.TopAbs import TopAbs_SOLID
|
||||||
|
from fluency.geometry_occ.kernel import OCGeometryKernel
|
||||||
|
|
||||||
|
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 has a data role for the toggle handler."""
|
||||||
|
from PySide6.QtCore import Qt
|
||||||
|
|
||||||
|
win = self._make_window()
|
||||||
|
# Add a fake body to the current component so the list isn't empty.
|
||||||
|
from fluency.models.data_model import Body
|
||||||
|
from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox
|
||||||
|
|
||||||
|
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
|
||||||
|
# And the body id is stored on the item for the toggle handler.
|
||||||
|
assert items[0].data(Qt.UserRole) == "a"
|
||||||
|
# Default state is visible.
|
||||||
|
assert win._current_component.bodies["a"].visible is True
|
||||||
|
|
||||||
|
def test_toggling_visibility_updates_body_model(self):
|
||||||
|
"""Toggling visibility via _on_body_visibility_changed updates the model."""
|
||||||
|
from PySide6.QtCore import Qt
|
||||||
|
|
||||||
|
win = self._make_window()
|
||||||
|
from fluency.models.data_model import Body
|
||||||
|
from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox
|
||||||
|
|
||||||
|
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.
|
||||||
|
win._on_body_visibility_changed(item)
|
||||||
|
assert win._current_component.bodies["a"].visible is False
|
||||||
|
|
||||||
|
# Toggle back on.
|
||||||
|
win._on_body_visibility_changed(item)
|
||||||
|
assert win._current_component.bodies["a"].visible is True
|
||||||
|
|
||||||
|
|
||||||
|
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.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):
|
||||||
|
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.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.
|
||||||
|
"""
|
||||||
|
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"])
|
||||||
|
|||||||
@@ -0,0 +1,916 @@
|
|||||||
|
"""Tests for the technical drawing workbench.
|
||||||
|
|
||||||
|
Covers the manual-dimension pipeline (model-space annotations →
|
||||||
|
sheet-space candidates → placed primitives), the 2D pick geometry
|
||||||
|
helpers, and persistence of drawings (manual dimensions included) in
|
||||||
|
the .fluency project file.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
import os
|
||||||
|
|
||||||
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from fluency.models.data_model import (
|
||||||
|
Body,
|
||||||
|
Component,
|
||||||
|
DrawingAnnotation,
|
||||||
|
DrawingView,
|
||||||
|
Project,
|
||||||
|
TechnicalDrawing,
|
||||||
|
)
|
||||||
|
from fluency.technical_drawing import (
|
||||||
|
build_manual_candidates,
|
||||||
|
generate_drawing,
|
||||||
|
_layout_views_on_sheet,
|
||||||
|
)
|
||||||
|
from fluency.io.project_io import (
|
||||||
|
_technical_drawing_from_dict,
|
||||||
|
_technical_drawing_to_dict,
|
||||||
|
load_project,
|
||||||
|
save_project,
|
||||||
|
)
|
||||||
|
from fluency.ui.technical_drawing_widget import (
|
||||||
|
_closest_point_on_segment,
|
||||||
|
_closest_points_on_segments,
|
||||||
|
_line_intersection,
|
||||||
|
_point_to_segment,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Fixtures ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def qapp():
|
||||||
|
"""Offscreen QApplication for widget-level tests."""
|
||||||
|
from PySide6.QtWidgets import QApplication
|
||||||
|
|
||||||
|
app = QApplication.instance() or QApplication([])
|
||||||
|
yield app
|
||||||
|
|
||||||
|
|
||||||
|
def _drawing_with_views(view_kinds=("front",)):
|
||||||
|
drawing = TechnicalDrawing(source_kind="component", source_id="comp-1")
|
||||||
|
for kind in view_kinds:
|
||||||
|
drawing.views.append(DrawingView(kind=kind))
|
||||||
|
return drawing
|
||||||
|
|
||||||
|
|
||||||
|
def _manual_annotation(
|
||||||
|
dimension_kind: str,
|
||||||
|
view_id: str,
|
||||||
|
anchors,
|
||||||
|
direction=None,
|
||||||
|
) -> DrawingAnnotation:
|
||||||
|
return DrawingAnnotation(
|
||||||
|
kind="dimension",
|
||||||
|
dimension_kind=dimension_kind,
|
||||||
|
view_id=view_id,
|
||||||
|
anchors=list(anchors),
|
||||||
|
direction=direction,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── build_manual_candidates ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestBuildManualCandidates:
|
||||||
|
def test_length_candidate_reprojects_anchors(self):
|
||||||
|
drawing = _drawing_with_views()
|
||||||
|
ann = _manual_annotation(
|
||||||
|
"length", "front", ((0.0, 0.0), (0.0, 12.5)), direction=(0.0, 1.0)
|
||||||
|
)
|
||||||
|
drawing.annotations.append(ann)
|
||||||
|
|
||||||
|
cands, resolved, unresolved = build_manual_candidates(
|
||||||
|
drawing, {"front": (2.0, 10.0, 20.0)}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert unresolved == []
|
||||||
|
assert resolved == [ann.id]
|
||||||
|
c = cands[0]
|
||||||
|
assert c.kind == "length"
|
||||||
|
assert c.view_id == "front"
|
||||||
|
assert c.key == f"manual:{ann.id}"
|
||||||
|
assert c.references == (ann.id,)
|
||||||
|
assert c.value == pytest.approx(12.5)
|
||||||
|
assert c.label == "12.50"
|
||||||
|
# sheet = model * scale + offset
|
||||||
|
assert c.anchor_points[0] == pytest.approx((10.0, 20.0))
|
||||||
|
assert c.anchor_points[1] == pytest.approx((10.0, 45.0))
|
||||||
|
assert c.direction == (0.0, 1.0)
|
||||||
|
|
||||||
|
def test_length_without_transform_is_unresolved(self):
|
||||||
|
drawing = _drawing_with_views()
|
||||||
|
ann = _manual_annotation("length", "front", ((0.0, 0.0), (3.0, 4.0)))
|
||||||
|
drawing.annotations.append(ann)
|
||||||
|
|
||||||
|
cands, resolved, unresolved = build_manual_candidates(drawing, {})
|
||||||
|
assert cands == []
|
||||||
|
assert unresolved == [ann.id]
|
||||||
|
|
||||||
|
cands, resolved, unresolved = build_manual_candidates(
|
||||||
|
drawing, {"front": (1.0, 0.0, 0.0)}
|
||||||
|
)
|
||||||
|
assert unresolved == []
|
||||||
|
c = cands[0]
|
||||||
|
assert c.value == pytest.approx(5.0)
|
||||||
|
assert c.direction == pytest.approx((0.6, 0.8))
|
||||||
|
|
||||||
|
def test_diameter_candidate(self):
|
||||||
|
drawing = _drawing_with_views()
|
||||||
|
ann = _manual_annotation("diameter", "top", ((-5.0, 0.0), (5.0, 0.0)))
|
||||||
|
drawing.annotations.append(ann)
|
||||||
|
|
||||||
|
cands, resolved, unresolved = build_manual_candidates(
|
||||||
|
drawing, {"top": (1.0, 0.0, 0.0)}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert unresolved == []
|
||||||
|
c = cands[0]
|
||||||
|
assert c.kind == "diameter"
|
||||||
|
assert c.value == pytest.approx(10.0)
|
||||||
|
assert c.label == "Ø10.00"
|
||||||
|
assert c.direction == ()
|
||||||
|
|
||||||
|
def test_angle_candidate(self):
|
||||||
|
drawing = _drawing_with_views()
|
||||||
|
ann = _manual_annotation(
|
||||||
|
"angle", "front", ((0.0, 0.0), (10.0, 0.0), (0.0, 10.0))
|
||||||
|
)
|
||||||
|
drawing.annotations.append(ann)
|
||||||
|
|
||||||
|
cands, resolved, unresolved = build_manual_candidates(
|
||||||
|
drawing, {"front": (1.0, 0.0, 0.0)}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert unresolved == []
|
||||||
|
c = cands[0]
|
||||||
|
assert c.kind == "angle"
|
||||||
|
assert c.value == pytest.approx(90.0)
|
||||||
|
assert c.label == "90.00°"
|
||||||
|
assert len(c.anchor_points) == 3
|
||||||
|
|
||||||
|
def test_degenerate_angle_is_unresolved(self):
|
||||||
|
# Collinear arms → 180° → not a usable angle dimension.
|
||||||
|
drawing = _drawing_with_views()
|
||||||
|
ann = _manual_annotation(
|
||||||
|
"angle", "front", ((0.0, 0.0), (10.0, 0.0), (-10.0, 0.0))
|
||||||
|
)
|
||||||
|
drawing.annotations.append(ann)
|
||||||
|
|
||||||
|
cands, resolved, unresolved = build_manual_candidates(
|
||||||
|
drawing, {"front": (1.0, 0.0, 0.0)}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert cands == []
|
||||||
|
assert resolved == []
|
||||||
|
assert unresolved == [ann.id]
|
||||||
|
def test_hidden_annotation_is_skipped(self):
|
||||||
|
drawing = _drawing_with_views()
|
||||||
|
ann = _manual_annotation("length", "front", ((0.0, 0.0), (1.0, 0.0)))
|
||||||
|
ann.visible = False
|
||||||
|
drawing.annotations.append(ann)
|
||||||
|
|
||||||
|
cands, resolved, unresolved = build_manual_candidates(
|
||||||
|
drawing, {"front": (1.0, 0.0, 0.0)}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert cands == []
|
||||||
|
assert resolved == []
|
||||||
|
assert unresolved == []
|
||||||
|
|
||||||
|
|
||||||
|
# ── generate_drawing: auto vs manual dimensions ───────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def kernel():
|
||||||
|
from fluency.geometry_occ.kernel import OCGeometryKernel
|
||||||
|
|
||||||
|
return OCGeometryKernel()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def box_project(kernel):
|
||||||
|
"""Project with one 10 x 20 x 5 box (x: 0..10, y: 0..20, z: 0..5)."""
|
||||||
|
from fluency.geometry.base import Point2D
|
||||||
|
|
||||||
|
points = [Point2D(0, 0), Point2D(10, 0), Point2D(10, 20), Point2D(0, 20)]
|
||||||
|
polygon = kernel.create_polygon(points)
|
||||||
|
box = kernel.extrude(polygon, 5.0)
|
||||||
|
body = Body(name="Box", geometry=box)
|
||||||
|
comp = Component(name="BoxComp")
|
||||||
|
comp.bodies[body.id] = body
|
||||||
|
project = Project()
|
||||||
|
project.components[comp.id] = comp
|
||||||
|
project.active_component = comp.id
|
||||||
|
return project, comp
|
||||||
|
|
||||||
|
|
||||||
|
class TestGenerateDrawingManualDimensions:
|
||||||
|
def test_manual_dimension_placed_with_auto_off(self, kernel, box_project):
|
||||||
|
project, comp = box_project
|
||||||
|
drawing = TechnicalDrawing(
|
||||||
|
source_kind="component",
|
||||||
|
source_id=comp.id,
|
||||||
|
views=[DrawingView(kind="front")],
|
||||||
|
auto_dimensions=False,
|
||||||
|
)
|
||||||
|
# Distance between the two vertical edges of the box front face.
|
||||||
|
ann = _manual_annotation(
|
||||||
|
"length", "front", ((0.0, 0.0), (10.0, 0.0)), direction=(1.0, 0.0)
|
||||||
|
)
|
||||||
|
drawing.annotations.append(ann)
|
||||||
|
|
||||||
|
result = generate_drawing(drawing, project, kernel)
|
||||||
|
|
||||||
|
assert result.view_transforms, "view transforms must be published"
|
||||||
|
manual_keys = [
|
||||||
|
p.candidate_key for p in result.primitives if p.candidate_key
|
||||||
|
]
|
||||||
|
assert f"manual:{ann.id}" in manual_keys
|
||||||
|
label_texts = [
|
||||||
|
p.text
|
||||||
|
for p in result.primitives
|
||||||
|
if p.candidate_key == f"manual:{ann.id}" and p.kind == "text"
|
||||||
|
]
|
||||||
|
assert label_texts == ["10.00"]
|
||||||
|
# Auto off → no auto-placed dimensions.
|
||||||
|
assert not any(
|
||||||
|
k for k in manual_keys if not k.startswith("manual:")
|
||||||
|
), "auto dimensions must stay out while auto_dimensions is off"
|
||||||
|
assert ann.id in result.resolved_annotation_ids
|
||||||
|
|
||||||
|
def test_auto_off_places_no_auto_dimensions(self, kernel, box_project):
|
||||||
|
project, comp = box_project
|
||||||
|
drawing = TechnicalDrawing(
|
||||||
|
source_kind="component",
|
||||||
|
source_id=comp.id,
|
||||||
|
views=[DrawingView(kind="front")],
|
||||||
|
auto_dimensions=False,
|
||||||
|
)
|
||||||
|
result = generate_drawing(drawing, project, kernel)
|
||||||
|
dim_keys = [p.candidate_key for p in result.primitives if p.candidate_key]
|
||||||
|
assert dim_keys == [], f"expected no dimensions, got {dim_keys}"
|
||||||
|
|
||||||
|
def test_auto_on_places_auto_dimensions(self, kernel, box_project):
|
||||||
|
project, comp = box_project
|
||||||
|
drawing = TechnicalDrawing(
|
||||||
|
source_kind="component",
|
||||||
|
source_id=comp.id,
|
||||||
|
views=[DrawingView(kind="front")],
|
||||||
|
auto_dimensions=True,
|
||||||
|
)
|
||||||
|
result = generate_drawing(drawing, project, kernel)
|
||||||
|
dim_keys = [p.candidate_key for p in result.primitives if p.candidate_key]
|
||||||
|
assert dim_keys, "auto dimensions expected with auto_dimensions on"
|
||||||
|
assert all(not k.startswith("manual:") for k in dim_keys)
|
||||||
|
|
||||||
|
def test_auto_and_manual_coexist(self, kernel, box_project):
|
||||||
|
project, comp = box_project
|
||||||
|
drawing = TechnicalDrawing(
|
||||||
|
source_kind="component",
|
||||||
|
source_id=comp.id,
|
||||||
|
views=[DrawingView(kind="front")],
|
||||||
|
auto_dimensions=True,
|
||||||
|
)
|
||||||
|
ann = _manual_annotation(
|
||||||
|
"length", "front", ((0.0, 0.0), (10.0, 0.0)), direction=(1.0, 0.0)
|
||||||
|
)
|
||||||
|
drawing.annotations.append(ann)
|
||||||
|
|
||||||
|
result = generate_drawing(drawing, project, kernel)
|
||||||
|
dim_keys = {p.candidate_key for p in result.primitives if p.candidate_key}
|
||||||
|
assert f"manual:{ann.id}" in dim_keys
|
||||||
|
assert any(k for k in dim_keys if not k.startswith("manual:"))
|
||||||
|
def test_diameter_manual_on_cylinder(self, kernel):
|
||||||
|
from OCP.BRepPrimAPI import BRepPrimAPI_MakeCylinder
|
||||||
|
from OCP.gp import gp_Ax2, gp_Dir, gp_Pnt
|
||||||
|
|
||||||
|
from fluency.geometry_occ.kernel import OCCGeometryObject
|
||||||
|
|
||||||
|
ax = gp_Ax2(gp_Pnt(0, 0, 0), gp_Dir(0, 0, 1))
|
||||||
|
cyl = OCCGeometryObject(
|
||||||
|
BRepPrimAPI_MakeCylinder(ax, 4.0, 8.0).Shape(),
|
||||||
|
{"type": "cylinder"},
|
||||||
|
)
|
||||||
|
body = Body(name="Cyl", geometry=cyl)
|
||||||
|
comp = Component(name="CylComp")
|
||||||
|
comp.bodies[body.id] = body
|
||||||
|
project = Project()
|
||||||
|
project.components[comp.id] = comp
|
||||||
|
project.active_component = comp.id
|
||||||
|
|
||||||
|
drawing = TechnicalDrawing(
|
||||||
|
source_kind="component",
|
||||||
|
source_id=comp.id,
|
||||||
|
views=[DrawingView(kind="front")],
|
||||||
|
auto_dimensions=False,
|
||||||
|
)
|
||||||
|
ann = _manual_annotation("diameter", "front", ((-4.0, 0.0), (4.0, 0.0)))
|
||||||
|
drawing.annotations.append(ann)
|
||||||
|
|
||||||
|
result = generate_drawing(drawing, project, kernel)
|
||||||
|
label_texts = [
|
||||||
|
p.text
|
||||||
|
for p in result.primitives
|
||||||
|
if p.candidate_key == f"manual:{ann.id}" and p.kind == "text"
|
||||||
|
]
|
||||||
|
assert label_texts == ["Ø8.00"]
|
||||||
|
|
||||||
|
|
||||||
|
# ── Circle centres: ISO center marks + centre-point dimensioning ──────────
|
||||||
|
|
||||||
|
|
||||||
|
def _cylinder_project(kernel):
|
||||||
|
"""One Ø8 x 8 cylinder (axis +Z) as a draw-able component.
|
||||||
|
|
||||||
|
HLR may split a circle's edge into sampled segments for some shapes,
|
||||||
|
so tests that need a guaranteed circle primitive build it directly
|
||||||
|
(see :class:`TestCircleCenterMarks` / :class:`TestCircleCenterPick`).
|
||||||
|
"""
|
||||||
|
from OCP.BRepPrimAPI import BRepPrimAPI_MakeCylinder
|
||||||
|
from OCP.gp import gp_Ax2, gp_Dir, gp_Pnt
|
||||||
|
|
||||||
|
from fluency.geometry_occ.kernel import OCCGeometryObject
|
||||||
|
|
||||||
|
ax = gp_Ax2(gp_Pnt(0, 0, 0), gp_Dir(0, 0, 1))
|
||||||
|
cyl = OCCGeometryObject(
|
||||||
|
BRepPrimAPI_MakeCylinder(ax, 4.0, 8.0).Shape(),
|
||||||
|
{"type": "cylinder"},
|
||||||
|
)
|
||||||
|
body = Body(name="Cyl", geometry=cyl)
|
||||||
|
comp = Component(name="CylComp")
|
||||||
|
comp.bodies[body.id] = body
|
||||||
|
project = Project()
|
||||||
|
project.components[comp.id] = comp
|
||||||
|
project.active_component = comp.id
|
||||||
|
return project, comp
|
||||||
|
|
||||||
|
|
||||||
|
class TestCircleCenterMarks:
|
||||||
|
"""ISO 14128 center marks: a thin cross at each projected circle's
|
||||||
|
centre, crossing at the centre and extending past the circle edge."""
|
||||||
|
|
||||||
|
def test_center_marks_emitted_for_circles(self):
|
||||||
|
from fluency.technical_drawing import _assemble_view
|
||||||
|
|
||||||
|
# Synthetic view plane: a 20 × 16 box with a r4 circle at (10, 8).
|
||||||
|
edges = [
|
||||||
|
((0.0, 0.0), (20.0, 0.0), "line", "visible"),
|
||||||
|
((20.0, 0.0), (20.0, 16.0), "line", "visible"),
|
||||||
|
((20.0, 16.0), (0.0, 16.0), "line", "visible"),
|
||||||
|
((0.0, 16.0), (0.0, 0.0), "line", "visible"),
|
||||||
|
((10.0, 8.0), (14.0, 8.0), "circle_full", "visible"),
|
||||||
|
]
|
||||||
|
view = DrawingView(kind="top")
|
||||||
|
prims, _cands, _warns = _assemble_view(edges, [], view, (10, 10, 200, 150))
|
||||||
|
circles = [p for p in prims if p.kind == "circle"]
|
||||||
|
assert len(circles) == 1
|
||||||
|
c = circles[0]
|
||||||
|
cx, cy = c.center
|
||||||
|
marks = [p for p in prims if p.kind == "line" and p.style == "center"]
|
||||||
|
assert len(marks) == 2, "one horizontal and one vertical center mark"
|
||||||
|
horiz = next(p for p in marks if p.points[0][1] == p.points[1][1])
|
||||||
|
vert = next(p for p in marks if p.points[0][0] == p.points[1][0])
|
||||||
|
# The marks cross at the circle centre.
|
||||||
|
assert horiz.points[0][1] == cy and horiz.points[1][1] == cy
|
||||||
|
assert vert.points[0][0] == cx and vert.points[1][0] == cx
|
||||||
|
# And each extends past the circle edge.
|
||||||
|
half_h = abs(horiz.points[1][0] - horiz.points[0][0]) / 2.0
|
||||||
|
half_v = abs(vert.points[1][1] - vert.points[0][1]) / 2.0
|
||||||
|
assert half_h > c.radius
|
||||||
|
assert half_v > c.radius
|
||||||
|
|
||||||
|
class TestCircleCenterAnchors:
|
||||||
|
"""Distance picks between circle centres, and between a centre and an
|
||||||
|
edge, resolve to the right model-space anchor pair."""
|
||||||
|
|
||||||
|
def _w(self):
|
||||||
|
from fluency.ui.technical_drawing_widget import TechnicalDrawingWidget
|
||||||
|
|
||||||
|
return TechnicalDrawingWidget
|
||||||
|
|
||||||
|
def test_pick_point_kinds(self):
|
||||||
|
W = self._w()
|
||||||
|
assert W._pick_point({"kind": "point", "point": (1.0, 2.0)}) == (1.0, 2.0)
|
||||||
|
assert W._pick_point(
|
||||||
|
{"kind": "circle", "center": (3.0, 4.0), "radius": 1.0}
|
||||||
|
) == (3.0, 4.0)
|
||||||
|
assert W._pick_point({"kind": "segment", "p1": (0, 0), "p2": (1, 1)}) is None
|
||||||
|
|
||||||
|
def test_center_to_center(self):
|
||||||
|
W = self._w()
|
||||||
|
a = {"kind": "circle", "view_id": "v", "center": (0.0, 0.0), "radius": 2.0}
|
||||||
|
b = {"kind": "circle", "view_id": "v", "center": (5.0, 12.0), "radius": 3.0}
|
||||||
|
p1, p2 = W._distance_anchors(a, b)
|
||||||
|
assert p1 == (0.0, 0.0)
|
||||||
|
assert p2 == (5.0, 12.0)
|
||||||
|
|
||||||
|
def test_center_to_edge(self):
|
||||||
|
W = self._w()
|
||||||
|
a = {"kind": "point", "view_id": "v", "point": (4.0, 6.0)}
|
||||||
|
b = {"kind": "segment", "view_id": "v", "p1": (0.0, 0.0), "p2": (10.0, 0.0)}
|
||||||
|
p1, p2 = W._distance_anchors(a, b)
|
||||||
|
assert p1 == (4.0, 6.0)
|
||||||
|
# Closest point on the edge is straight below the centre.
|
||||||
|
assert p2 == pytest.approx((4.0, 0.0))
|
||||||
|
|
||||||
|
def test_edge_to_edge_unchanged(self):
|
||||||
|
W = self._w()
|
||||||
|
a = {"kind": "segment", "view_id": "v", "p1": (0.0, 0.0), "p2": (10.0, 0.0)}
|
||||||
|
b = {"kind": "segment", "view_id": "v", "p1": (2.0, 5.0), "p2": (8.0, 5.0)}
|
||||||
|
p1, p2 = W._distance_anchors(a, b)
|
||||||
|
assert p1 == pytest.approx((2.0, 0.0))
|
||||||
|
assert p2 == pytest.approx((2.0, 5.0))
|
||||||
|
|
||||||
|
|
||||||
|
# ── View layout: page fill, no overlaps, title-block clearance ───────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestViewLayout:
|
||||||
|
"""_layout_views_on_sheet packs the views to fill the sheet, keeps
|
||||||
|
them apart and clear of the title block, and rotates individual
|
||||||
|
views when that makes the set fit more."""
|
||||||
|
|
||||||
|
A3W, A3H = 420.0, 297.0
|
||||||
|
# Title block box + 5 mm clearance zone (see _title_block_primitives).
|
||||||
|
TB = (235.0, 0.0, 420.0, 62.0)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _bbox(w, h):
|
||||||
|
return (0.0, 0.0, float(w), float(h))
|
||||||
|
|
||||||
|
def _layout(self, kinds, boxes):
|
||||||
|
views = [DrawingView(kind=k) for k in kinds]
|
||||||
|
return _layout_views_on_sheet(views, {k: boxes[k] for k in kinds})
|
||||||
|
|
||||||
|
def _assert_valid(self, slots):
|
||||||
|
for k, s in slots.items():
|
||||||
|
assert s[0] >= 10.0 - 1e-6 and s[1] >= 10.0 - 1e-6, (k, s)
|
||||||
|
assert s[0] + s[2] <= self.A3W - 10.0 + 1e-6, (k, s)
|
||||||
|
assert s[1] + s[3] <= self.A3H - 10.0 + 1e-6, (k, s)
|
||||||
|
x0, y0, w, h = s
|
||||||
|
x1, y1, w2, h2 = self.TB
|
||||||
|
assert x0 + w <= x1 or x1 + w2 <= x0 or y0 + h <= y1 or y1 + h2 <= y0, \
|
||||||
|
f"{k} intrudes title block: {s}"
|
||||||
|
ks = list(slots)
|
||||||
|
for i in range(len(ks)):
|
||||||
|
for j in range(i + 1, len(ks)):
|
||||||
|
a, b_ = slots[ks[i]], slots[ks[j]]
|
||||||
|
sep = (
|
||||||
|
a[0] + a[2] <= b_[0] + 0.1 or b_[0] + b_[2] <= a[0] + 0.1
|
||||||
|
or a[1] + a[3] <= b_[1] + 0.1 or b_[1] + b_[3] <= a[1] + 0.1
|
||||||
|
)
|
||||||
|
assert sep, f"{ks[i]} overlaps {ks[j]}: {a} / {b_}"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _fill(slots):
|
||||||
|
x0 = min(s[0] for s in slots.values())
|
||||||
|
y0 = min(s[1] for s in slots.values())
|
||||||
|
x1 = max(s[0] + s[2] for s in slots.values())
|
||||||
|
y1 = max(s[1] + s[3] for s in slots.values())
|
||||||
|
return (x1 - x0) * (y1 - y0) / (420.0 * 297.0)
|
||||||
|
|
||||||
|
def test_single_view_fills_page(self):
|
||||||
|
slots, scale, rots = self._layout(["front"], {"front": self._bbox(10, 20)})
|
||||||
|
self._assert_valid(slots)
|
||||||
|
# 1–2 views stay upright — no sideways single view.
|
||||||
|
assert rots == {"front": 0.0}
|
||||||
|
# The tall 10 × 20 view is scaled until it touches the full-height
|
||||||
|
# left strip's reduced height (0.8 × 277 mm).
|
||||||
|
assert scale == pytest.approx(221.6 / 20.0)
|
||||||
|
assert slots["front"][3] == pytest.approx(221.6)
|
||||||
|
|
||||||
|
def test_two_views_share_page(self):
|
||||||
|
slots, _scale, _rots = self._layout(
|
||||||
|
["front", "top"],
|
||||||
|
{"front": self._bbox(40, 40), "top": self._bbox(40, 20)},
|
||||||
|
)
|
||||||
|
self._assert_valid(slots)
|
||||||
|
assert self._fill(slots) > 0.30
|
||||||
|
|
||||||
|
def test_classic_three_view_keeps_cross(self):
|
||||||
|
boxes = {k: self._bbox(40, 40) for k in ("front", "top", "right")}
|
||||||
|
slots, _scale, rots = self._layout(list(boxes), boxes)
|
||||||
|
self._assert_valid(slots)
|
||||||
|
assert set(rots.values()) == {0.0}, "classic cross must not rotate"
|
||||||
|
# Top sits directly above front; right directly to its right.
|
||||||
|
assert abs(slots["top"][0] - slots["front"][0]) < 1e-6
|
||||||
|
assert slots["top"][1] > slots["front"][1] + slots["front"][3]
|
||||||
|
assert slots["right"][0] > slots["front"][0] + slots["front"][2]
|
||||||
|
assert abs(slots["right"][1] - slots["front"][1]) < 1e-6
|
||||||
|
|
||||||
|
def test_thin_part_gets_rotated_views(self):
|
||||||
|
boxes = {
|
||||||
|
"front": self._bbox(200, 30),
|
||||||
|
"top": self._bbox(30, 50),
|
||||||
|
"right": self._bbox(50, 30),
|
||||||
|
"left": self._bbox(50, 30),
|
||||||
|
"back": self._bbox(200, 30),
|
||||||
|
"bottom": self._bbox(30, 50),
|
||||||
|
}
|
||||||
|
slots, scale, rots = self._layout(list(boxes), boxes)
|
||||||
|
self._assert_valid(slots)
|
||||||
|
assert any(r == 90.0 for r in rots.values()), "rotation must kick in"
|
||||||
|
# Each slot is the (possibly swapped) model size times the scale.
|
||||||
|
for k, s in slots.items():
|
||||||
|
w, h = boxes[k][2], boxes[k][3]
|
||||||
|
sw, sh = s[2] / scale, s[3] / scale
|
||||||
|
assert (
|
||||||
|
(sw == pytest.approx(w) and sh == pytest.approx(h))
|
||||||
|
or (sw == pytest.approx(h) and sh == pytest.approx(w))
|
||||||
|
), (k, s, w, h)
|
||||||
|
|
||||||
|
def test_all_views_plus_isometric_fill_page(self):
|
||||||
|
boxes = {
|
||||||
|
"front": self._bbox(80, 40),
|
||||||
|
"top": self._bbox(80, 30),
|
||||||
|
"right": self._bbox(30, 40),
|
||||||
|
"left": self._bbox(30, 40),
|
||||||
|
"back": self._bbox(80, 40),
|
||||||
|
"bottom": self._bbox(80, 30),
|
||||||
|
"isometric": self._bbox(60, 60),
|
||||||
|
}
|
||||||
|
slots, _scale, _rots = self._layout(list(boxes), boxes)
|
||||||
|
self._assert_valid(slots)
|
||||||
|
assert self._fill(slots) > 0.55
|
||||||
|
|
||||||
|
|
||||||
|
# ── Project drawing persistence ────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _drawing_with_manual_dim():
|
||||||
|
drawing = TechnicalDrawing(
|
||||||
|
source_kind="component",
|
||||||
|
source_id="comp-42",
|
||||||
|
views=[DrawingView(kind="front"), DrawingView(kind="top")],
|
||||||
|
auto_dimensions=True,
|
||||||
|
title="Persisted Drawing",
|
||||||
|
revision="B",
|
||||||
|
)
|
||||||
|
drawing.annotations.append(
|
||||||
|
_manual_annotation(
|
||||||
|
"length", "front", ((0.0, 0.0), (0.0, 12.5)), direction=(0.0, 1.0)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
drawing.annotations.append(
|
||||||
|
_manual_annotation("diameter", "top", ((-5.0, 0.0), (5.0, 0.0)))
|
||||||
|
)
|
||||||
|
return drawing
|
||||||
|
|
||||||
|
|
||||||
|
class TestDrawingPersistence:
|
||||||
|
def test_drawing_dict_roundtrip(self):
|
||||||
|
drawing = _drawing_with_manual_dim()
|
||||||
|
data = _technical_drawing_to_dict(drawing)
|
||||||
|
restored = _technical_drawing_from_dict(json.loads(json.dumps(data)))
|
||||||
|
|
||||||
|
assert restored.id == drawing.id
|
||||||
|
assert restored.source_kind == "component"
|
||||||
|
assert restored.source_id == "comp-42"
|
||||||
|
assert restored.auto_dimensions is True
|
||||||
|
assert [v.kind for v in restored.views] == ["front", "top"]
|
||||||
|
assert len(restored.annotations) == 2
|
||||||
|
|
||||||
|
a = restored.annotations[0]
|
||||||
|
assert a.dimension_kind == "length"
|
||||||
|
assert a.view_id == "front"
|
||||||
|
assert a.anchors == [(0.0, 0.0), (0.0, 12.5)]
|
||||||
|
assert a.direction == (0.0, 1.0)
|
||||||
|
|
||||||
|
b = restored.annotations[1]
|
||||||
|
assert b.dimension_kind == "diameter"
|
||||||
|
assert b.anchors == [(-5.0, 0.0), (5.0, 0.0)]
|
||||||
|
|
||||||
|
def test_project_drawings_lookup(self):
|
||||||
|
project = Project()
|
||||||
|
drawing = _drawing_with_manual_dim()
|
||||||
|
project.add_drawing(drawing)
|
||||||
|
|
||||||
|
assert project.get_drawing_for("component", "comp-42") is drawing
|
||||||
|
assert project.get_drawing_for("assembly", "comp-42") is None
|
||||||
|
assert project.get_drawing_for("component", "other") is None
|
||||||
|
|
||||||
|
def test_project_save_load_roundtrip(self, tmp_path):
|
||||||
|
project = Project(name="Drawing Project")
|
||||||
|
drawing = _drawing_with_manual_dim()
|
||||||
|
project.add_drawing(drawing)
|
||||||
|
|
||||||
|
path = save_project(project, str(tmp_path / "proj.fluency"))
|
||||||
|
loaded, _view_state = load_project(path)
|
||||||
|
|
||||||
|
assert len(loaded.drawings) == 1
|
||||||
|
restored = loaded.drawings[0]
|
||||||
|
assert restored.source_id == "comp-42"
|
||||||
|
assert restored.auto_dimensions is True
|
||||||
|
assert restored.title == "Persisted Drawing"
|
||||||
|
assert len(restored.annotations) == 2
|
||||||
|
|
||||||
|
a = restored.annotations[0]
|
||||||
|
assert a.dimension_kind == "length"
|
||||||
|
assert a.view_id == "front"
|
||||||
|
assert a.anchors == [(0.0, 0.0), (0.0, 12.5)]
|
||||||
|
assert a.direction == (0.0, 1.0)
|
||||||
|
assert a.id == drawing.annotations[0].id
|
||||||
|
|
||||||
|
# The restored drawing must still build the same candidates.
|
||||||
|
cands, resolved, unresolved = build_manual_candidates(
|
||||||
|
restored, {"front": (1.0, 0.0, 0.0), "top": (1.0, 0.0, 0.0)}
|
||||||
|
)
|
||||||
|
assert unresolved == []
|
||||||
|
labels = sorted(c.label for c in cands)
|
||||||
|
assert labels == ["12.50", "Ø10.00"]
|
||||||
|
|
||||||
|
def test_load_ignores_corrupt_drawing_entry(self, tmp_path):
|
||||||
|
import zipfile
|
||||||
|
|
||||||
|
project = Project(name="Mixed")
|
||||||
|
project.add_drawing(_drawing_with_manual_dim())
|
||||||
|
path = save_project(project, str(tmp_path / "proj.fluency"))
|
||||||
|
|
||||||
|
with zipfile.ZipFile(path, "r") as zf:
|
||||||
|
names = zf.namelist()
|
||||||
|
contents = {n: zf.read(n) for n in names}
|
||||||
|
manifest = json.loads(contents["project.json"])
|
||||||
|
manifest["drawings"].append({"id": "broken", "views": "not-a-list"})
|
||||||
|
contents["project.json"] = json.dumps(manifest).encode("utf-8")
|
||||||
|
|
||||||
|
with zipfile.ZipFile(path, "w") as zf:
|
||||||
|
for name in names:
|
||||||
|
zf.writestr(name, contents[name])
|
||||||
|
|
||||||
|
loaded, _ = load_project(path)
|
||||||
|
# Corrupt entry skipped, valid one kept.
|
||||||
|
assert len(loaded.drawings) == 1
|
||||||
|
assert loaded.drawings[0].source_id == "comp-42"
|
||||||
|
|
||||||
|
def test_auto_dimensions_default_off(self):
|
||||||
|
assert TechnicalDrawing().auto_dimensions is False
|
||||||
|
|
||||||
|
|
||||||
|
# ── Pick geometry helpers ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestPickGeometry:
|
||||||
|
def test_point_to_segment_inside(self):
|
||||||
|
q, d = _point_to_segment((5.0, 3.0), (0.0, 0.0), (10.0, 0.0))
|
||||||
|
assert q == pytest.approx((5.0, 0.0))
|
||||||
|
assert d == pytest.approx(3.0)
|
||||||
|
|
||||||
|
def test_point_to_segment_clamps_at_endpoint(self):
|
||||||
|
q, d = _point_to_segment((-2.0, 1.0), (0.0, 0.0), (10.0, 0.0))
|
||||||
|
assert q == pytest.approx((0.0, 0.0))
|
||||||
|
assert d == pytest.approx(math.hypot(2.0, 1.0))
|
||||||
|
|
||||||
|
def test_crossing_segments(self):
|
||||||
|
q1, q2, d = _closest_points_on_segments(
|
||||||
|
(0.0, 0.0), (10.0, 0.0), (4.0, -2.0), (4.0, 8.0)
|
||||||
|
)
|
||||||
|
assert q1 == pytest.approx((4.0, 0.0))
|
||||||
|
assert q2 == pytest.approx((4.0, 0.0))
|
||||||
|
assert d == pytest.approx(0.0, abs=1e-9)
|
||||||
|
|
||||||
|
def test_parallel_overlapping_segments(self):
|
||||||
|
# The classic "distance between two parallel edges" pick:
|
||||||
|
# result must be the true perpendicular distance.
|
||||||
|
q1, q2, d = _closest_points_on_segments(
|
||||||
|
(0.0, 0.0), (10.0, 0.0), (2.0, 5.0), (8.0, 5.0)
|
||||||
|
)
|
||||||
|
assert d == pytest.approx(5.0)
|
||||||
|
assert q1[1] == pytest.approx(0.0)
|
||||||
|
assert q2[1] == pytest.approx(5.0)
|
||||||
|
assert q1[0] == pytest.approx(q2[0])
|
||||||
|
|
||||||
|
def test_parallel_disjoint_segments(self):
|
||||||
|
q1, q2, d = _closest_points_on_segments(
|
||||||
|
(0.0, 0.0), (2.0, 0.0), (5.0, 3.0), (7.0, 3.0)
|
||||||
|
)
|
||||||
|
assert d == pytest.approx(math.hypot(3.0, 3.0))
|
||||||
|
|
||||||
|
def test_line_intersection(self):
|
||||||
|
pt = _line_intersection(
|
||||||
|
(0.0, 0.0), (10.0, 0.0), (4.0, -2.0), (4.0, 8.0)
|
||||||
|
)
|
||||||
|
assert pt == pytest.approx((4.0, 0.0))
|
||||||
|
|
||||||
|
def test_line_intersection_parallel_is_none(self):
|
||||||
|
assert _line_intersection(
|
||||||
|
(0.0, 0.0), (10.0, 0.0), (2.0, 5.0), (8.0, 5.0)
|
||||||
|
) is None
|
||||||
|
|
||||||
|
def test_closest_point_on_segment(self):
|
||||||
|
q = _closest_point_on_segment((4.0, 9.0), (0.0, 0.0), (10.0, 0.0))
|
||||||
|
assert q == pytest.approx((4.0, 0.0))
|
||||||
|
|
||||||
|
|
||||||
|
# ── Widget: dimension tool plumbing (offscreen) ────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestDrawingWidgetTools:
|
||||||
|
def _widget(self, qapp):
|
||||||
|
from fluency.ui.technical_drawing_widget import TechnicalDrawingWidget
|
||||||
|
|
||||||
|
return TechnicalDrawingWidget()
|
||||||
|
|
||||||
|
def test_widget_starts_without_pick_mode(self, qapp):
|
||||||
|
w = self._widget(qapp)
|
||||||
|
assert w._canvas._pick_mode == ""
|
||||||
|
assert not w._auto_dim_check.isChecked()
|
||||||
|
assert not any(b.isChecked() for b in w._tool_buttons.values())
|
||||||
|
|
||||||
|
def test_tool_toggle_enters_pick_mode(self, qapp):
|
||||||
|
w = self._widget(qapp)
|
||||||
|
btn = w._tool_buttons["distance"]
|
||||||
|
btn.setChecked(True)
|
||||||
|
assert w._canvas._pick_mode == "distance"
|
||||||
|
assert "Distance" in w._status_label.text()
|
||||||
|
assert "edge" in w._status_label.text()
|
||||||
|
|
||||||
|
# Switching tools re-targets the canvas and unchecks the old tool.
|
||||||
|
w._tool_buttons["diameter"].setChecked(True)
|
||||||
|
assert btn.isChecked() is False
|
||||||
|
assert w._canvas._pick_mode == "diameter"
|
||||||
|
|
||||||
|
# Escape path: cancels the tool, clears all buttons and mode.
|
||||||
|
w._cancel_pick()
|
||||||
|
assert w._canvas._pick_mode == ""
|
||||||
|
for other in w._tool_buttons.values():
|
||||||
|
assert not other.isChecked()
|
||||||
|
|
||||||
|
def test_add_manual_dimension_appends_and_emits(self, qapp):
|
||||||
|
w = self._widget(qapp)
|
||||||
|
w.set_drawing(
|
||||||
|
TechnicalDrawing(source_kind="component", source_id="c1")
|
||||||
|
)
|
||||||
|
changes = []
|
||||||
|
w.drawing_changed.connect(lambda: changes.append(1))
|
||||||
|
|
||||||
|
w._add_manual_dimension(
|
||||||
|
"length",
|
||||||
|
anchors=((0.0, 0.0), (10.0, 0.0)),
|
||||||
|
view_id="front",
|
||||||
|
direction=(1.0, 0.0),
|
||||||
|
)
|
||||||
|
|
||||||
|
anns = w._drawing.annotations
|
||||||
|
assert len(anns) == 1
|
||||||
|
assert anns[0].dimension_kind == "length"
|
||||||
|
assert anns[0].view_id == "front"
|
||||||
|
assert anns[0].anchors == [(0.0, 0.0), (10.0, 0.0)]
|
||||||
|
assert changes == [1]
|
||||||
|
|
||||||
|
def test_clear_removes_manual_dimensions_only(self, qapp):
|
||||||
|
w = self._widget(qapp)
|
||||||
|
drawing = TechnicalDrawing(source_kind="component", source_id="c1")
|
||||||
|
drawing.annotations.append(
|
||||||
|
DrawingAnnotation(kind="note", text="keep me")
|
||||||
|
)
|
||||||
|
drawing.annotations.append(
|
||||||
|
_manual_annotation(
|
||||||
|
"length", "front", ((0.0, 0.0), (5.0, 0.0))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
w.set_drawing(drawing)
|
||||||
|
|
||||||
|
w._on_clear_clicked()
|
||||||
|
|
||||||
|
assert len(drawing.annotations) == 1
|
||||||
|
assert drawing.annotations[0].kind == "note"
|
||||||
|
|
||||||
|
def test_adopt_stored_project_drawing(self, qapp, kernel):
|
||||||
|
w = self._widget(qapp)
|
||||||
|
project = Project()
|
||||||
|
comp = Component(name="ExistingComp")
|
||||||
|
project.components[comp.id] = comp
|
||||||
|
stored = TechnicalDrawing(
|
||||||
|
source_kind="component", source_id=comp.id
|
||||||
|
)
|
||||||
|
stored.annotations.append(
|
||||||
|
_manual_annotation(
|
||||||
|
"diameter", "front", ((-4.0, 0.0), (4.0, 0.0))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
project.add_drawing(stored)
|
||||||
|
w.set_project(project, kernel)
|
||||||
|
|
||||||
|
w.set_active_component(comp)
|
||||||
|
|
||||||
|
# The stored drawing is re-adopted (not replaced).
|
||||||
|
assert w._drawing is stored
|
||||||
|
assert len(w._drawing.annotations) == 1
|
||||||
|
|
||||||
|
def test_new_source_creates_and_registers_drawing(self, qapp, kernel):
|
||||||
|
w = self._widget(qapp)
|
||||||
|
project = Project()
|
||||||
|
comp = Component(name="NewComp")
|
||||||
|
project.components[comp.id] = comp
|
||||||
|
w.set_project(project, kernel)
|
||||||
|
|
||||||
|
w.set_active_component(comp)
|
||||||
|
|
||||||
|
assert w._drawing is not None
|
||||||
|
assert w._drawing.source_kind == "component"
|
||||||
|
assert w._drawing.source_id == comp.id
|
||||||
|
assert len(project.drawings) == 1
|
||||||
|
assert project.drawings[0] is w._drawing
|
||||||
|
|
||||||
|
|
||||||
|
class TestCircleCenterPick:
|
||||||
|
"""Clicking a circle's centre mark while a distance tool is active
|
||||||
|
picks a point feature at the circle centre (model coords + radius).
|
||||||
|
|
||||||
|
HLR only projects a true circle for some hole shapes (a plain
|
||||||
|
cylinder discretises into segments), so the render result is built
|
||||||
|
directly with one guaranteed circle primitive.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _canvas_with_circle(self, qapp):
|
||||||
|
from PySide6.QtCore import QPointF
|
||||||
|
|
||||||
|
from fluency.technical_drawing import (
|
||||||
|
DrawingPrimitive,
|
||||||
|
DrawingRenderResult,
|
||||||
|
)
|
||||||
|
from fluency.ui.technical_drawing_widget import DrawingCanvas
|
||||||
|
|
||||||
|
# One r4 circle at model (20, 15), drawn at 10× scale at the
|
||||||
|
# sheet centre: model (20,15) → sheet (200, 150).
|
||||||
|
circle = DrawingPrimitive(
|
||||||
|
kind="circle",
|
||||||
|
points=(),
|
||||||
|
style="visible",
|
||||||
|
center=(200.0, 150.0),
|
||||||
|
radius=40.0,
|
||||||
|
view_id="top",
|
||||||
|
)
|
||||||
|
result = DrawingRenderResult(
|
||||||
|
primitives=(circle,),
|
||||||
|
candidates=(),
|
||||||
|
resolved_annotation_ids=(),
|
||||||
|
unresolved_annotation_ids=(),
|
||||||
|
source_fingerprint="",
|
||||||
|
warnings=(),
|
||||||
|
view_transforms={"top": (10.0, 0.0, 0.0)},
|
||||||
|
)
|
||||||
|
canvas = DrawingCanvas()
|
||||||
|
canvas.resize(840, 600)
|
||||||
|
canvas.set_render_result(result)
|
||||||
|
canvas.set_pick_mode("distance")
|
||||||
|
return canvas, QPointF
|
||||||
|
|
||||||
|
def test_pick_center_mark_returns_point(self, qapp):
|
||||||
|
canvas, QPointF = self._canvas_with_circle(qapp)
|
||||||
|
# Sheet → device position of the circle centre.
|
||||||
|
rect = canvas._sheet_rect()
|
||||||
|
scale = rect.width() / 420.0
|
||||||
|
pos = QPointF(rect.x() + 200.0 * scale, rect.y() + (297.0 - 150.0) * scale)
|
||||||
|
|
||||||
|
hit = canvas._pick_feature(pos)
|
||||||
|
assert hit is not None, "clicking the centre mark must hit something"
|
||||||
|
assert hit["kind"] == "point"
|
||||||
|
assert hit["view_id"] == "top"
|
||||||
|
assert hit["radius"] == pytest.approx(4.0, abs=1e-6)
|
||||||
|
# The picked model point is the inverse-transformed sheet centre:
|
||||||
|
# (200, 150) at 10× scale → (20, 15).
|
||||||
|
assert hit["point"] == pytest.approx((20.0, 15.0), abs=1e-9)
|
||||||
|
|
||||||
|
def test_distance_tool_accepts_center_then_edge(self, qapp, kernel):
|
||||||
|
from fluency.ui.technical_drawing_widget import TechnicalDrawingWidget
|
||||||
|
|
||||||
|
w = TechnicalDrawingWidget()
|
||||||
|
project, comp = _cylinder_project(kernel)
|
||||||
|
w.set_project(project, kernel)
|
||||||
|
w.set_active_component(comp)
|
||||||
|
assert w._drawing is not None
|
||||||
|
|
||||||
|
# First pick: a circle centre at model (10, 12) — the dict a
|
||||||
|
# centre-mark click produces (see test above).
|
||||||
|
w._first_pick = {
|
||||||
|
"kind": "point",
|
||||||
|
"view_id": "top",
|
||||||
|
"point": (10.0, 12.0),
|
||||||
|
"radius": 4.0,
|
||||||
|
}
|
||||||
|
# Second pick: a horizontal edge 6 mm above the centre.
|
||||||
|
second = {
|
||||||
|
"kind": "segment",
|
||||||
|
"view_id": "top",
|
||||||
|
"p1": (0.0, 18.0),
|
||||||
|
"p2": (20.0, 18.0),
|
||||||
|
}
|
||||||
|
w._on_edge_pick(second, "distance")
|
||||||
|
assert w._drawing.annotations, "a manual dimension must be appended"
|
||||||
|
ann = w._drawing.annotations[-1]
|
||||||
|
assert ann.dimension_kind == "length"
|
||||||
|
assert ann.view_id == "top"
|
||||||
|
# First anchor is the picked centre; the second is the closest
|
||||||
|
# point on the edge, straight above it.
|
||||||
|
assert ann.anchors[0] == pytest.approx((10.0, 12.0), abs=1e-9)
|
||||||
|
assert ann.anchors[1] == pytest.approx((10.0, 18.0), abs=1e-6)
|
||||||
|
|
||||||
Reference in New Issue
Block a user