28 Commits

Author SHA1 Message Date
bklronin 742d06d242 - Render improvements, camera plane, update 2026-07-13 23:01:35 +02:00
bklronin c78d0af78c - added renderer
- Added undo
2026-07-13 06:54:21 +02:00
bklronin dda9db822b - added renderer
- Added undo
2026-07-12 23:25:59 +02:00
bklronin 9f1387fe68 - added renderer
- Added undo
2026-07-12 22:21:43 +02:00
bklronin 210e3cfb5d - added renderer 2026-07-12 22:21:20 +02:00
bklronin b8516fff91 - Working assembly multi :) 2026-07-11 21:42:08 +02:00
bklronin d7e5929a13 - Working assembly multi :) 2026-07-11 21:29:58 +02:00
bklronin 2b2afbc479 - Added save file foramt
- Split main.py refactor
2026-07-11 15:39:30 +02:00
bklronin b0aebdc04f - Added save file foramt
- Split main.py refactor
2026-07-11 09:34:38 +02:00
bklronin be22c44a3f - Added save file foramt
- Split main.py refactor
2026-07-07 22:40:40 +02:00
bklronin 80ba3cc70a - Added save file foramt
- Split main.py refactor
2026-07-07 21:51:27 +02:00
bklronin 5269c0897c - Added save file foramt
- Split main.py refactor
2026-07-05 22:16:08 +02:00
bklronin 3a169007f7 - assembly draft 2026-07-05 19:36:27 +02:00
bklronin b595b88e04 - assembly draft 2026-07-05 10:16:49 +02:00
bklronin 9f10a5c5e5 - UI refinement, button position ui file as source no dirty drafting anymore 2026-07-04 16:16:04 +02:00
bklronin 6ba742ddf5 - sketch enhacements 2026-07-04 12:10:58 +02:00
bklronin 01833e4af2 - sketch enhacements 2026-07-03 21:49:05 +02:00
bklronin f860ff3e77 - removed cadquery deoendency 2026-07-01 20:03:00 +02:00
bklronin 9938f4ddd4 - Basic operations 2026-06-29 23:30:02 +02:00
bklronin f6422e0847 - Tons of addtions 2026-06-28 22:51:52 +02:00
bklronin f8f16ea800 - Tons of addtions 2026-06-28 21:12:34 +02:00
bklronin 54ac2c098a - Improved sketching 2026-06-14 10:10:37 +02:00
bklronin ea34e7e29d - Improved sketching 2026-06-14 10:10:33 +02:00
bklronin 7091f530ee fix: use RenderWidget and add animation callback for camera controls
- Change from RenderCanvas to RenderWidget for embedded Qt widget
- Add _animate() callback for canvas.request_draw()
- Update render() to use request_draw() for continuous rendering
- This enables OrbitController to work properly with mouse events
2026-03-14 09:12:12 +01:00
bklronin 75d4820292 fix: register OrbitController events with renderer
Use controller.register_events(renderer) instead of canvas to properly
enable camera rotation/pan/zoom controls in the 3D viewer.
2026-03-14 09:09:32 +01:00
bklronin d52106a48a fix: update pygfx integration for current API
- Use rendercanvas.qt.RenderCanvas instead of wgpu.gui.qt.WgpuCanvas
- Fix camera position API: use camera.local.position instead of camera.position.set()
- Fix light position API: use light.local.position
- Fix PygfxRenderObject to properly inherit from RenderObject
- Change add_mesh/add_wireframe/add_points/add_grid/add_axes to return string ID
- Update base Renderer class to return str instead of RenderObject
- Add custom compute_normals() function for mesh normals
2026-03-14 09:06:38 +01:00
bklronin d7ebbf45d5 feat: add CLI logging for debugging
- Add logging module with DEBUG level
- Log MainWindow initialization
- Log Viewer3DWidget initialization and mesh operations
- Log mouse events in Sketch2DWidget
- Log extrude operations with detailed steps
- Log component and sketch management
2026-03-14 09:01:20 +01:00
bklronin daed79dac6 chore: update .gitignore and remove pycache from tracking 2026-03-14 08:59:25 +01:00
58 changed files with 22240 additions and 4378 deletions
+36 -1
View File
@@ -1,3 +1,38 @@
*.xml
*.iml
.idea
.idea
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
# Virtual environments
.venv/
venv/
ENV/
# Lock files
uv.lock
# IDE
.vscode/
*.swp
*.swo
-11
View File
@@ -1,11 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="PYTHON_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<sourceFolder url="file://$MODULE_DIR$/sdfcad" isTestSource="false" />
<excludeFolder url="file://$MODULE_DIR$/.venv" />
</content>
<orderEntry type="jdk" jdkName="Python 3.12 (fluency)" jdkType="Python SDK" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>
+1 -1
View File
@@ -2,7 +2,7 @@
<project version="4">
<component name="ProjectModuleManager">
<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>
</component>
</project>
+219 -60
View File
@@ -4,31 +4,14 @@
<option name="autoReloadType" value="SELECTIVE" />
</component>
<component name="ChangeListManager">
<list default="true" id="8f0bafd6-58a0-4b20-aa2b-ddc3ba278873" name="Changes" comment="- Improved sketching">
<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" />
<list default="true" id="8f0bafd6-58a0-4b20-aa2b-ddc3ba278873" name="Changes" comment="- added renderer&#10;- Added undo">
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/main.py" beforeDir="false" afterPath="$PROJECT_DIR$/main.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/fluency/rendering/mitsuba_backend.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/rendering/mitsuba_backend.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/fluency/rendering/occ_renderer.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/rendering/occ_renderer.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/fluency/rendering/render_backend.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/rendering/render_backend.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/fluency/ui/main_window.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/ui/main_window.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/fluency/ui/render_window.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/ui/render_window.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/fluency/ui/viewer_widget.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/ui/viewer_widget.py" afterDir="false" />
</list>
<option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" />
@@ -45,7 +28,7 @@
<component name="Git.Settings">
<option name="RECENT_BRANCH_BY_REPOSITORY">
<map>
<entry key="$PROJECT_DIR$" value="master" />
<entry key="$PROJECT_DIR$" value="single_window" />
</map>
</option>
<option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
@@ -65,32 +48,45 @@
<option name="hideEmptyMiddlePackages" value="true" />
<option name="showLibraryContents" value="true" />
</component>
<component name="PropertiesComponent"><![CDATA[{
"keyToString": {
"Python.2dtest.executor": "Run",
"Python.3d_windows.executor": "Run",
"Python.Unnamed.executor": "Run",
"Python.draw_widget2d.executor": "Run",
"Python.draw_widget_solve.executor": "Run",
"Python.fluency.executor": "Run",
"Python.fluencyb.executor": "Run",
"Python.gl_widget.executor": "Run",
"Python.main.executor": "Run",
"Python.meshtest.executor": "Run",
"Python.side_fluency.executor": "Run",
"Python.simple_mesh.executor": "Run",
"Python.test_vtk_sketch_widget.executor": "Run",
"Python.vtk_sketch_widget.executor": "Run",
"Python.vtk_widget.executor": "Run",
"Python.vulkan.executor": "Run",
"RunOnceActivity.OpenProjectViewOnStart": "true",
"RunOnceActivity.ShowReadmeOnStart": "true",
"RunOnceActivity.git.unshallow": "true",
"git-widget-placeholder": "single__window",
"last_opened_file_path": "/Volumes/Data_drive/Programming/fluency",
"settings.editor.selected.configurable": "project.propVCSSupport.DirectoryMappings"
<component name="PropertiesComponent">{
&quot;keyToString&quot;: {
&quot;Python.2dtest.executor&quot;: &quot;Run&quot;,
&quot;Python.3d_windows.executor&quot;: &quot;Run&quot;,
&quot;Python.Unnamed.executor&quot;: &quot;Run&quot;,
&quot;Python.base.executor&quot;: &quot;Run&quot;,
&quot;Python.data_model.executor&quot;: &quot;Run&quot;,
&quot;Python.draw_widget2d.executor&quot;: &quot;Run&quot;,
&quot;Python.draw_widget_solve.executor&quot;: &quot;Run&quot;,
&quot;Python.fluency.executor&quot;: &quot;Run&quot;,
&quot;Python.fluencyb.executor&quot;: &quot;Run&quot;,
&quot;Python.gl_widget.executor&quot;: &quot;Run&quot;,
&quot;Python.gui_ui.executor&quot;: &quot;Run&quot;,
&quot;Python.kernel.executor&quot;: &quot;Run&quot;,
&quot;Python.main.executor&quot;: &quot;Run&quot;,
&quot;Python.meshtest.executor&quot;: &quot;Run&quot;,
&quot;Python.occ_renderer.executor&quot;: &quot;Run&quot;,
&quot;Python.occ_to_mesh.executor&quot;: &quot;Run&quot;,
&quot;Python.render_backend.executor&quot;: &quot;Run&quot;,
&quot;Python.side_fluency.executor&quot;: &quot;Run&quot;,
&quot;Python.simple_mesh.executor&quot;: &quot;Run&quot;,
&quot;Python.sketch.executor&quot;: &quot;Run&quot;,
&quot;Python.vtk_widget.executor&quot;: &quot;Run&quot;,
&quot;Python.vulkan.executor&quot;: &quot;Run&quot;,
&quot;RunOnceActivity.OpenProjectViewOnStart&quot;: &quot;true&quot;,
&quot;RunOnceActivity.ShowReadmeOnStart&quot;: &quot;true&quot;,
&quot;RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252&quot;: &quot;true&quot;,
&quot;RunOnceActivity.git.unshallow&quot;: &quot;true&quot;,
&quot;RunOnceActivity.typescript.service.memoryLimit.init&quot;: &quot;true&quot;,
&quot;codeWithMe.voiceChat.enabledByDefault&quot;: &quot;false&quot;,
&quot;git-widget-placeholder&quot;: &quot;feature/occ-migration&quot;,
&quot;last_opened_file_path&quot;: &quot;/Volumes/Data_drive/Programming/fluency/src/fluency/rendering&quot;,
&quot;node.js.detected.package.eslint&quot;: &quot;true&quot;,
&quot;node.js.selected.package.eslint&quot;: &quot;(autodetect)&quot;,
&quot;node.js.selected.package.tslint&quot;: &quot;(autodetect)&quot;,
&quot;nodejs_package_manager_path&quot;: &quot;npm&quot;,
&quot;settings.editor.selected.configurable&quot;: &quot;project.propVCSSupport.DirectoryMappings&quot;
}
}]]></component>
}</component>
<component name="QodanaReportsService">
<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" />
@@ -98,19 +94,20 @@
</component>
<component name="RecentsManager">
<key name="CopyFile.RECENT_KEYS">
<recent name="$PROJECT_DIR$/src/fluency/rendering" />
<recent name="$PROJECT_DIR$/src/fluency" />
<recent name="$PROJECT_DIR$" />
<recent name="$PROJECT_DIR$/drawing_modules" />
<recent name="$PROJECT_DIR$/modules" />
</key>
<key name="MoveFile.RECENT_KEYS">
<recent name="$PROJECT_DIR$/doc" />
<recent name="$PROJECT_DIR$" />
</key>
</component>
<component name="SharedIndexes">
<attachedChunks>
<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>
</attachedChunks>
</component>
@@ -275,17 +272,172 @@
<option name="project" value="LOCAL" />
<updated>1755369224187</updated>
</task>
<task id="LOCAL-00020" summary="- Improved sketching">
<task id="LOCAL-00020" summary="- Tons of addtions">
<option name="closed" value="true" />
<created>1763311700335</created>
<created>1782673954850</created>
<option name="number" value="00020" />
<option name="presentableId" value="LOCAL-00020" />
<option name="project" value="LOCAL" />
<updated>1763311700335</updated>
<updated>1782673954850</updated>
</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&#10;- Split main.py refactor">
<option name="closed" value="true" />
<created>1783282570014</created>
<option name="number" value="00029" />
<option name="presentableId" value="LOCAL-00029" />
<option name="project" value="LOCAL" />
<updated>1783282570014</updated>
</task>
<task id="LOCAL-00030" summary="- Added save file foramt&#10;- Split main.py refactor">
<option name="closed" value="true" />
<created>1783453889199</created>
<option name="number" value="00030" />
<option name="presentableId" value="LOCAL-00030" />
<option name="project" value="LOCAL" />
<updated>1783453889199</updated>
</task>
<task id="LOCAL-00031" summary="- Added save file foramt&#10;- Split main.py refactor">
<option name="closed" value="true" />
<created>1783456842297</created>
<option name="number" value="00031" />
<option name="presentableId" value="LOCAL-00031" />
<option name="project" value="LOCAL" />
<updated>1783456842297</updated>
</task>
<task id="LOCAL-00032" summary="- Added save file foramt&#10;- Split main.py refactor">
<option name="closed" value="true" />
<created>1783755278516</created>
<option name="number" value="00032" />
<option name="presentableId" value="LOCAL-00032" />
<option name="project" value="LOCAL" />
<updated>1783755278516</updated>
</task>
<task id="LOCAL-00033" summary="- Added save file foramt&#10;- Split main.py refactor">
<option name="closed" value="true" />
<created>1783777171864</created>
<option name="number" value="00033" />
<option name="presentableId" value="LOCAL-00033" />
<option name="project" value="LOCAL" />
<updated>1783777171864</updated>
</task>
<task id="LOCAL-00034" summary="- Working assembly multi :)">
<option name="closed" value="true" />
<created>1783798201133</created>
<option name="number" value="00034" />
<option name="presentableId" value="LOCAL-00034" />
<option name="project" value="LOCAL" />
<updated>1783798201133</updated>
</task>
<task id="LOCAL-00035" summary="- Working assembly multi :)">
<option name="closed" value="true" />
<created>1783798929531</created>
<option name="number" value="00035" />
<option name="presentableId" value="LOCAL-00035" />
<option name="project" value="LOCAL" />
<updated>1783798929531</updated>
</task>
<task id="LOCAL-00036" summary="- added renderer">
<option name="closed" value="true" />
<created>1783887682052</created>
<option name="number" value="00036" />
<option name="presentableId" value="LOCAL-00036" />
<option name="project" value="LOCAL" />
<updated>1783887682052</updated>
</task>
<task id="LOCAL-00037" summary="- added renderer&#10;- Added undo">
<option name="closed" value="true" />
<created>1783887704766</created>
<option name="number" value="00037" />
<option name="presentableId" value="LOCAL-00037" />
<option name="project" value="LOCAL" />
<updated>1783887704766</updated>
</task>
<task id="LOCAL-00038" summary="- added renderer&#10;- Added undo">
<option name="closed" value="true" />
<created>1783891561116</created>
<option name="number" value="00038" />
<option name="presentableId" value="LOCAL-00038" />
<option name="project" value="LOCAL" />
<updated>1783891561116</updated>
</task>
<task id="LOCAL-00039" summary="- added renderer&#10;- Added undo">
<option name="closed" value="true" />
<created>1783918463034</created>
<option name="number" value="00039" />
<option name="presentableId" value="LOCAL-00039" />
<option name="project" value="LOCAL" />
<updated>1783918463034</updated>
</task>
<option name="localTasksCounter" value="40" />
<servers />
</component>
<component name="TypeScriptGeneratedFilesManager">
<option name="version" value="3" />
</component>
<component name="Vcs.Log.Tabs.Properties">
<option name="TAB_STATES">
<map>
@@ -301,8 +453,6 @@
<ignored-roots>
<path value="$PROJECT_DIR$/pythonProject" />
</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" />
@@ -318,7 +468,16 @@
<MESSAGE value="- added MIT license" />
<MESSAGE value="- added screenshot" />
<MESSAGE value="- added sdf folder ( doesnt work via pip or git=)" />
<MESSAGE value="- Improved sketching" />
<option name="LAST_COMMIT_MESSAGE" value="- Improved sketching" />
<MESSAGE value="- Tons of addtions" />
<MESSAGE value="- Basic operations" />
<MESSAGE value="- removed cadquery deoendency" />
<MESSAGE value="- sketch enhacements" />
<MESSAGE value="- UI refinement, button position ui file as source no dirty drafting anymore" />
<MESSAGE value="- assembly draft" />
<MESSAGE value="- Added save file foramt&#10;- Split main.py refactor" />
<MESSAGE value="- Working assembly multi :)" />
<MESSAGE value="- added renderer" />
<MESSAGE value="- added renderer&#10;- Added undo" />
<option name="LAST_COMMIT_MESSAGE" value="- added renderer&#10;- Added undo" />
</component>
</project>
+3 -4
View File
@@ -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
- **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
- **Fillet & Chamfer**: Apply edge treatments to solid bodies
- **Modern Renderer**: WebGPU-based rendering with pygfx (smaller footprint than VTK)
@@ -47,8 +47,7 @@ pip install -e ".[dev]"
| Package | Purpose |
|---------|---------|
| cadquery | High-level OpenCASCADE Python bindings |
| ocp | Low-level OpenCASCADE Python bindings |
| cadquery-ocp | OpenCASCADE Python bindings (OCP) |
| pygfx | WebGPU-based 3D renderer |
| wgpu | WebGPU Python bindings |
| PySide6 | Qt GUI framework |
@@ -105,7 +104,7 @@ kernel.export_stl(body, "part.stl")
| File Size | Large (mesh) | Small (BRep) |
| Fillet/Chamfer | Approximate | Exact |
| Dependency Size | ~200MB (VTK) | ~30MB (pygfx) |
| Constraint Solver | SolveSpace (separate) | CadQuery (integrated) |
| Constraint Solver | SolveSpace (separate) | SolveSpace (integrated) |
## License
+219
View File
@@ -0,0 +1,219 @@
# Fluency CAD — Agent Guide
## Project Overview
**Fluency CAD 2.0** is a parametric CAD application built on **OpenCASCADE Technology (OCCT)** with a modern **pygfx-based 3D renderer**. It provides 2D sketching with SolveSpace constraint solving, boolean operations, STEP/IGES/STL import/export, and exact BRep geometry.
- Language: **Python 3.10+**
- GUI: **PySide6** (Qt6)
- Geometry Kernel: **OCP** (cadquery-ocp — OpenCASCADE Python bindings)
- Constraint Solver: **python_solvespace**
- Renderer: **pygfx** (WebGPU) + **OCCRenderer** (native OCC AIS display)
---
## Architecture
```
src/fluency/
├── __init__.py # Package entry (version, imports)
├── main.py # Application entry point, MainWindow, Sketch2DWidget (~5000 lines)
├── sketch_solver.py # SolveSpace constraint solver wrapper (legacy)
├── geometry/
│ └── base.py # Abstract interfaces: GeometryKernel, SketchInterface, data classes
├── geometry_occ/
│ ├── kernel.py # OCGeometryKernel (extrude, boolean, fillet, import/export, mesh)
│ └── sketch.py # OCCSketch with SolveSpace integration (face detection, constraints)
├── models/
│ └── data_model.py # Project, Component, Sketch, Body dataclasses
├── rendering/
│ ├── base.py # Abstract Renderer interface
│ ├── occ_renderer.py # OCC AIS renderer (preferred — smooth BRep display)
│ └── pygfx_renderer.py # Legacy pygfx renderer
├── utils/ # Utility modules
└── widgets/ # Custom widgets
tests/
└── test_geometry.py # Comprehensive test suite (52+ tests)
```
### Key Classes & Responsibilities
| Class | File | Purpose |
|-------|------|---------|
| `OCGeometryKernel` | `kernel.py` | OCC shape ops: extrude, boolean, fillet, mesh, import/export |
| `OCCSketch` | `sketch.py` | 2D sketch with SolveSpace solver, face detection, workplane |
| `OCCSketchEntity` | `sketch.py` | Entity (point/line/circle/arc) with solver handle, is_construction, is_external |
| `Sketch2DWidget` | `main.py` | Qt widget for interactive 2D sketching (draw, snap, constrain) |
| `MainWindow` | `main.py` | Main application window, toolbars, 3D viewer, operations |
| `OCCRenderer` | `occ_renderer.py` | Native OCC AIS display (shaded + edges, face pick) |
| `Sketch` | `data_model.py` | Data model: workplane, occ_sketch ref, source_body_id |
| `Body` | `data_model.py` | 3D solid with geometry, visibility, render object |
| `Component` | `data_model.py` | Container for sketches and bodies |
| `Project` | `data_model.py` | Top-level container with kernel |
### Data Flow
```
User draws in Sketch2DWidget
→ OCCSketch entities created in solver
→ Constraint solving (python_solvespace)
→ OCCSketch.get_geometry() → detect_faces() → build_face_geometry()
→ OCGeometryKernel.extrude() → BRepPrimAPI_MakePrism
→ Boolean operations → Body added to Component
→ OCCRenderer.add_shape() → AIS display
```
---
## Development Commands
```bash
# Install editable
pip install -e ".[dev]"
# Run app
python -m fluency.main
# Run tests (52 tests)
python -m pytest tests/test_geometry.py -v
# Run single test
python -m pytest tests/test_geometry.py::TestOCCSketch::test_workplane_extrude_with_hole -xvs
# Quck geometry test (raw OCC, no Qt)
python -c "from fluency.geometry_occ.sketch import OCCSketch; ..."
```
---
## Code Conventions
### General
- Line length: **100 chars** (black/ruff config)
- Target Python: **3.10+** (uses `from __future__ import annotations`, walrus, pattern matching)
- Docstrings: Google/NumPy style preferred
- Logging: `logger = logging.getLogger(__name__)` with `logging.DEBUG` level
### OCC / OCP
- Always use `is not None` for OCP objects — `TopoDS_Shape.__bool__` can be falsy even for valid shapes
- `BRepBuilderAPI_MakeFace.Add(wire)` expects a `TopoDS_Wire`. `wire.Reversed()` returns `TopoDS_Shape` → cast via `_TopoDS.Wire_s(wire.Reversed())`
- Face normal direction: check `face.Orientation()` vs `TopAbs_REVERSED` — a REVERSED face's outward normal is the NEGATION of the surface axis
- `TopoDS_Wire_s(shape)`, `TopoDS_Face_s(shape)` — use `_s` suffix from OCP for downcasts
- Mesh: `BRepMesh_IncrementalMesh(shape, tol, False, 0.15, True)` — default deflection 0.15 rad for smooth curves
### Extrude / Cut Workflow
- Snapshot `list(self._current_component.bodies.items())` **BEFORE** `add_body()` — the new body must not be in the target set
- Cut targets the **source body** (`sketch._source_body_id` from face pick), not `bodies[0]`
- The fix: apply boolean to **target** geometry, then remove tool body
- Plain extrude with holes: inner wires must have **OPPOSITE** geometric winding to the outer wire (see `build_face_geometry` and `_loop_signed_area`)
### Sketch / Solvers
- `python_solvespace` has NO remove API for entities/constraints. Deleting requires: drop from `_points`/`_lines`, prune `_constraint_log`, `_rebuild_solver()` (recreates entire system), `_rebuild_labels()`, re-solve
- `_constraint_log`: each entry is `{"type": str, "ids": tuple[int,...], "params": tuple, "labels": set[str]}`
- Constraint labels: stored on **point** entities for paintEvent rendering; rebuilt via `_rebuild_labels()`
- Line constraints (`horizontal`/`vertical`/`parallel`/`perpendicular`) need the **line's** solver handle, not a point's. Use `_find_line_sketch_entity()` to get the correct handle
- External entities (underlay): `is_external=True`, `is_construction=True`, fixed in solver (always `dragged`). Stored in `_external_entity_ids`, excluded from `_line_segments()`, `get_polygon_points()`, `get_closed_loops()`, `detect_faces()`, `get_geometry()`
### Face Detection
- `get_closed_loops()`: uses snapped-coordinate graph (`_SNAP_TOL = 1e-4`) from line endpoint adjacency. Only accepts simple cycles (all nodes degree 2)
- `detect_faces()`: even-odd nesting rule via `_loop_contains`. Even depth = outer boundary, odd = hole
- `_loop_rep_point`: midpoint between centroid and first vertex. **Fragile** — can land inside a nested shape for certain geometries (e.g., a small hole near the centroid's direction from the first vertex)
- `_loop_signed_area`: shoelace formula for polygons, `πr²` (positive = CCW) for circles
### Rendering
- **OCCRenderer** is the main renderer (not pygfx). Uses `AIS_Shape`, `V3d_Viewer`, `AIS_InteractiveContext`
- Face pick: `pick_planar_face(x, y)``MoveTo``DetectedShape``TopoDS_Face_s``BRepAdaptor_Surface` plane check
- Highlight: `highlight_face(face)` creates a transparent AIS overlay; `clear_face_highlight()` removes it
- Preview: `preview_shape(shape)` for live transparent extrude preview
- Navigation: Left=orbit, Middle=pan, Wheel=zoom. **Right is RESERVED** — check user before reassigning
### Paint-Event Safety
- Every constraint-tag rendering loop wraps each entry in `try/except` so a bad entry (dangling id, corrupted geometry) doesn't crash the entire paint event
- `_point_world()` and `_entity_anchor()` return `None` (not raise) for malformed input
---
## Known Bugs & Fix Patterns
### 1. Hole Orientation in Extrusion (FIXED 2026-07-03)
**Symptom**: Inner shapes (circle/triangle/slot) inside a rectangle become solid islands instead of holes when extruding, depending on drag direction.
**Root Cause**: `wire_loop` in `build_face_geometry` unconditionally reversed hole wires (`w.Reversed()`). When the outer polygon was CW-winding (e.g., dragging from top-left to bottom-right), the reversed inner had the SAME effective direction as the outer, making OCC treat it as solid.
**Fix**: Added `_loop_signed_area()` to compute geometric winding. Hole wires are only reversed when their natural winding matches the outer's (ensuring opposite winding for holes).
**Relevant code**: `sketch.py`, `build_face_geometry()` and `_loop_signed_area()`
### 2. _loop_rep_point Fragility (KNOWN)
**Symptom**: Face detection fails when a nested shape contains the outer loop's representative point (midpoint between centroid and first vertex).
**Would-be fix**: Use a guaranteed-interior point (maximum inscribed circle center or perturbed centroid) instead of the centroid-first-vertex midpoint.
### 3. Extrude Cut / Target Selection (FIXED 2026-06-29)
**Symptom**: Cut created a separate "cavity-shaped" body next to the original instead of modifying the target.
**Fix**: Boolean result stored on TARGET body geometry; tool body removed from component. Auto-target via `sketch._source_body_id`.
### 4. Workplane Preservation (FIXED 2026-06-29)
**Symptom**: Sketch placed on a face lost its workplane after being added to component.
**Fix**: Copy `occ_sketch` workplane fields into `Sketch` dataclass BEFORE `apply_workplane()`.
---
## API Quirks
- **`QPoint(0,0)`**: falsy via `isNull()` in PySide6 → always use `is not None` for `Optional[QPoint]`
- **`QMouseEvent`/`QPainterPath`**: live in `PySide6.QtGui` (NOT `QtCore`)
- **`BRepBuilderAPI_MakeFace.Add()`**: needs `TopoDS_Wire`. `wire.Reversed()` returns `TopoDS_Shape` — cast via `TopoDS_Wire_s()`
- **python_solvespace**: NO entity/constraint remove API — workaround via `_rebuild_solver()`. Parameters read via `solver.params(handle.params)` → returns `(x, y)` tuple
- **OCGeometryKernel.extrude**: unwraps `OCCGeometryObject`, raw `TopoDS_Shape`, or cadquery `Workplane`. Always use `is not None` for the shape (not truthiness)
- **Sketch._source_body_id**: dynamic attribute set on `Sketch` dataclass, set during face-pick flow
- **`_get_shape(obj)`**: returns `obj.shape.wrapped` for `OCCGeometryObject`, `obj.shape` for raw shapes, `None` for empty. Use `is not None` guards everywhere
---
## Memory / Agent Context
This project has extensive Pi memory (hermes-memory) for:
- `project="fluency"` with `target="failure"`: bugs, fixes, corrections, insights
- `project="fluency"` with `target="memory"`: conventions, decisions, workflow patterns
- Available skills: `fix-cad-app-pipeline`, `refactor-from-cadquery-to-ocp`
Key memory queries for debugging:
- "hole orientation" → `_loop_signed_area` / `build_face_geometry` fix
- "extrude cut auto-target" → cut/target body fix
- "workplane preservation" → _add_sketch_to_component fix
- "_loop_rep_point" → face detection fragility
- "paint-event safety" → try/except per entry pattern
- "solver rebuild" → delete workflow via _rebuild_solver
- "face pick origin" → pick_planar_face face bbox centre
---
## Testing Patterns
```python
# Direct OCC test (no Qt)
from OCP.BRepBuilderAPI import BRepBuilderAPI_MakePolygon, BRepBuilderAPI_MakeFace
from OCP.gp import gp_Pnt
from OCP.BRepPrimAPI import BRepPrimAPI_MakePrism
from OCP.GProp import GProp_GProps; from OCP.BRepGProp import BRepGProp
# Build test shape, extrude, verify volume
mp = BRepBuilderAPI_MakePolygon(); ...
g = GProp_GProps(); BRepGProp.VolumeProperties_s(shape, g)
assert abs(g.Mass() - expected) < 0.1
```
```python
# Sketch-based test
from fluency.geometry_occ.sketch import OCCSketch
from fluency.geometry_occ.kernel import OCGeometryKernel
sk = OCCSketch()
# ... add points, lines, circles ...
sk.solve()
geom = sk.get_geometry()
solid = OCGeometryKernel().extrude(geom, 10.0)
```
+253
View File
@@ -0,0 +1,253 @@
# Realistic Render View — Implementation Plan
## Context
Add a **"Render"** feature to Fluency CAD that opens a separate window for photorealistic rendering of the selected component or assembly (like KeyShot/Cacles).
**Constraints:**
- Open in a **new window** — don't clutter the workspace
- **Keep existing OCCRenderer** for the interactive 3D viewport — untouched
- Render backend must be a **separate, swappable module** so we can change the renderer later
- Use **Mitsuba 3** as the initial backend (`pip install mitsuba`, ~50MB)
---
## Architecture
```
┌─────────────────────────────────────────────────────────┐
│ Main Fluency Window (existing OCCRenderer — untouched) │
│ │
│ [Select body/assembly] → [Click "Render"] │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────┐ │
│ │ RenderWindow (separate QMainWindow)│ │
│ │ │ │
│ │ ┌───────────────────────────────┐ │ │
│ │ │ RenderBackend (ABC) │ │ │
│ │ │ ├─ MitsubaBackend ← current │ │ │
│ │ │ ├─ (future: BlenderBackend) │ │ │
│ │ │ └─ (future: CyclesBackend) │ │ │
│ │ └───────────────────────────────┘ │ │
│ │ │ │\n│ │ [Image preview] [Progress bar] │ │
│ │ [Material ▾] [Quality ▾] [Render] │ │
│ │ [Export PNG] │ │
│ └─────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
```
### Swappable Backend Interface
```python
from abc import ABC, abstractmethod
from dataclasses import dataclass
import numpy as np
@dataclass
class RenderMaterial:
name: str
color: tuple[float, float, float] = (0.7, 0.7, 0.7)
metallic: float = 0.0 # 0.01.0
roughness: float = 0.5 # 0.01.0
bsdf_type: str = "diffuse" # diffuse | roughconductor | roughdielectric | plastic
@dataclass
class RenderCamera:
origin: tuple[float, float, float] = (100, 100, 100)
target: tuple[float, float, float] = (0, 0, 0)
up: tuple[float, float, float] = (0, 0, 1)
fov: float = 45.0
@dataclass
class RenderSettings:
width: int = 1920
height: int = 1080
spp: int = 256 # samples per pixel
max_depth: int = 8 # path tracer bounces
class RenderBackend(ABC):
"""Swap this to change the rendering engine."""
@abstractmethod
def render(self, obj_path: str, material: RenderMaterial,
camera: RenderCamera, settings: RenderSettings) -> np.ndarray: ...
@abstractmethod
def render_preview(self, obj_path: str, material: RenderMaterial,
camera: RenderCamera, settings: RenderSettings) -> np.ndarray: ...
@abstractmethod
def name(self) -> str: ...
```
Switching backends later = write a new class implementing `RenderBackend`. One import change.
---
## Mitsuba 3 Backend
### Why Mitsuba
| Feature | Status |
|---------|--------|
| `pip install mitsuba` | Single install, no system deps |
| True path tracing | GI, caustics, spectral rendering |
| PBR materials | `roughconductor`, `roughdielectric`, `diffuse`, `plastic` |
| Python dict API | Build scenes programmatically, no XML |
| CPU + GPU backends | `scalar_rgb` (CPU), `cuda_rgb` (NVIDIA) |
| Output formats | PNG, EXR (HDR) with tonemapping |
### OCC → OBJ Conversion Path
```python
from OCP.BRepMesh import BRepMesh_IncrementalMesh
from OCP.StlAPI import StlAPI_Writer
from OCP.BRep import BRep_Builder
import tempfile, os
def occ_shape_to_obj(shape, obj_path: str, linear_deflection: float = 0.1):
"""Tessellate OCC shape and write as OBJ for Mitsuba."""
tess = BRepMesh_IncrementalMesh(shape, linear_deflection, False, 0.5, True)
tess.Perform()
# Write STL (reliable), then convert to OBJ via trimesh or direct
writer = StlAPI_Writer()
writer.SetASCIIMode(False)
stl_path = obj_path.replace(".obj", ".stl")
writer.Write(shape, stl_path)
# Mitsuba can read STL directly, or we convert to OBJ
return stl_path
```
### Mitsuba Scene Construction
```python
import mitsuba as mi
mi.set_variant("scalar_rgb")
def build_scene(mesh_path: str, material: RenderMaterial,
camera: RenderCamera, settings: RenderSettings) -> mi.Scene:
# Map our material to Mitsuba BSDF
bsdf_map = {
"diffuse": {"type": "diffuse", "reflectance": {"type": "rgb", "value": material.color}},
"roughconductor": {
"type": "roughconductor",
"material": "copper", # or铝, 钢, etc.
"alpha": material.roughness,
},
"roughdielectric": {
"type": "roughdielectric",
"int_ior": 1.5,
"alpha": material.roughness,
},
"plastic": {
"type": "plastic",
"diffuse_reflectance": {"type": "rgb", "value": material.color},
"int_ior": 1.5,
},
}
return mi.load_dict({
"type": "scene",
"integrator": {"type": "path", "max_depth": settings.max_depth},
"sensor": {
"type": "perspective",
"fov": camera.fov,
"to_world": mi.ScalarTransform4f.look_at(
origin=camera.origin, target=camera.target, up=camera.up
),
"film": {"type": "hdrfilm", "width": settings.width, "height": settings.height},
"sampler": {"type": "independent", "sample_count": settings.spp},
},
"emitter": {"type": "constant"},
"shape": {
"type": "stl", # or "obj"
"filename": mesh_path,
"bsdf": bsdf_map.get(material.bsdf_type, bsdf_map["diffuse"]),
},
})
```
---
## Files to Create/Modify
| File | Action | Description |
|------|--------|-------------|
| `src/fluency/rendering/render_backend.py` | **NEW** | Abstract `RenderBackend`, `RenderMaterial`, `RenderCamera`, `RenderSettings` |
| `src/fluency/rendering/mitsuba_backend.py` | **NEW** | `MitsubaBackend(RenderBackend)` implementation |
| `src/fluency/rendering/occ_to_mesh.py` | **NEW** | OCC `TopoDS_Shape` → STL/OBJ tessellation |
| `src/fluency/rendering/material_presets.py` | **NEW** | Preset library: Steel, Aluminum, Brass, Chrome, Plastic, Rubber, Wood |
| `src/fluency/ui/render_window.py` | **NEW** | `RenderWindow(QMainWindow)` — image preview, material/quality controls, render/export |
| `src/fluency/ui/main_window.py` | MODIFY | Add "Render" button → get selected shapes → open `RenderWindow` |
---
## UI: RenderWindow
```
┌──────────────────────────────────────────┐
│ Render — [Part Name] [─][□][×] │
├──────────────────────────────────────────┤
│ │
│ ┌──────────────────────────────────┐ │
│ │ │ │
│ │ Rendered Image Preview │ │
│ │ (QLabel with QPixmap) │ │
│ │ │ │
│ └──────────────────────────────────┘ │
│ │
│ Material: [Steel ▾] │
│ Quality: [256 SPP ▾] │
│ Resolution: [1920×1080 ▾] │
│ │
│ [▶ Render] [⏹ Cancel] [💾 Export PNG] │
│ │
│ ████████████████░░░░░░ 65% (23s left) │
└──────────────────────────────────────────┘
```
- **Preview**: progressive refinement (low SPP first, then ramp)
- **Cancel**: kill Mitsuba render thread
- **Export**: save to PNG/EXR
---
## Material Presets
| Preset | Color | Metallic | Roughness | BSDF |
|--------|-------|----------|-----------|------|
| Brushed Steel | (0.65, 0.67, 0.72) | 0.9 | 0.35 | roughconductor |
| Polished Chrome | (0.8, 0.8, 0.8) | 1.0 | 0.05 | roughconductor |
| Brushed Aluminum | (0.75, 0.75, 0.75) | 0.85 | 0.25 | roughconductor |
| Copper | (0.95, 0.64, 0.54) | 0.95 | 0.15 | roughconductor |
| Gold | (1.0, 0.76, 0.33) | 1.0 | 0.1 | roughconductor |
| Blackened Steel | (0.15, 0.15, 0.17) | 0.8 | 0.4 | roughconductor |
| Matte Plastic | (0.2, 0.5, 0.8) | 0.0 | 0.6 | plastic |
| Glossy Plastic | (0.2, 0.5, 0.8) | 0.0 | 0.1 | plastic |
| White Nylon | (0.85, 0.85, 0.83) | 0.0 | 0.45 | plastic |
| Black ABS | (0.05, 0.05, 0.05) | 0.0 | 0.35 | plastic |
| Red PA12 | (0.75, 0.08, 0.08) | 0.0 | 0.4 | plastic |
| Rubber | (0.1, 0.1, 0.1) | 0.0 | 0.9 | diffuse |
| Ceramic White | (0.92, 0.91, 0.88) | 0.0 | 0.15 | dielectric |
| Glass | (0.95, 0.95, 0.95) | 0.0 | 0.0 | dielectric |
| Wood | (0.6, 0.4, 0.2) | 0.0 | 0.7 | diffuse |
**Note:** Mitsuba pip installs don't include spectral metal data files (iron.spd, copper.spd, etc.), so metal presets use `material="none"` with `specular_reflectance` set to the metal color instead.
---
## Risks & Mitigations
| Risk | Mitigation |
|------|-----------|
| Mitsuba not installed | Graceful error: "pip install mitsuba" shown in UI |
| Slow CPU rendering | Default to low SPP (64) for preview; offer GPU variant if CUDA available |
| Large meshes slow to tessellate | Progress indicator; optional mesh decimation |
| Mitsuba STL/OCC compatibility | Test tessellation quality; tune `linear_deflection` |
---
## Estimated Effort
- **Phase 1** (abstract backend + OCC→mesh + Mitsuba impl): ~4-6 hours
- **Phase 2** (render window UI + material presets): ~3-4 hours
- **Phase 3** (polish, export, swap test): ~2-3 hours
- **Total**: ~9-13 hours
+843 -525
View File
File diff suppressed because it is too large Load Diff
+906
View File
@@ -0,0 +1,906 @@
# -*- coding: utf-8 -*-
################################################################################
## Form generated from reading UI file 'gui.ui'
##
## Created by: Qt User Interface Compiler version 6.10.2
##
## WARNING! All changes made in this file will be lost when recompiling UI file!
################################################################################
from PySide6.QtCore import (QCoreApplication, QDate, QDateTime, QLocale,
QMetaObject, QObject, QPoint, QRect,
QSize, QTime, QUrl, Qt)
from PySide6.QtGui import (QAction, QBrush, QColor, QConicalGradient,
QCursor, QFont, QFontDatabase, QGradient,
QIcon, QImage, QKeySequence, QLinearGradient,
QPainter, QPalette, QPixmap, QRadialGradient,
QTransform)
from PySide6.QtWidgets import (QApplication, QFrame, QGridLayout, QGroupBox,
QHBoxLayout, QLabel, QListWidget, QListWidgetItem,
QMainWindow, QMenu, QMenuBar, QPushButton,
QSizePolicy, QSpinBox, QStatusBar, QTabWidget,
QTextEdit, QVBoxLayout, QWidget)
class Ui_fluencyCAD(object):
def setupUi(self, fluencyCAD):
if not fluencyCAD.objectName():
fluencyCAD.setObjectName(u"fluencyCAD")
fluencyCAD.resize(2359, 1285)
self.actionNew_Project = QAction(fluencyCAD)
self.actionNew_Project.setObjectName(u"actionNew_Project")
self.actionOpen_Project = QAction(fluencyCAD)
self.actionOpen_Project.setObjectName(u"actionOpen_Project")
self.actionSave_Project = QAction(fluencyCAD)
self.actionSave_Project.setObjectName(u"actionSave_Project")
self.actionSave_Project_As = QAction(fluencyCAD)
self.actionSave_Project_As.setObjectName(u"actionSave_Project_As")
self.actionImport_File = QAction(fluencyCAD)
self.actionImport_File.setObjectName(u"actionImport_File")
self.actionExport_Step = QAction(fluencyCAD)
self.actionExport_Step.setObjectName(u"actionExport_Step")
self.actionExport_Iges = QAction(fluencyCAD)
self.actionExport_Iges.setObjectName(u"actionExport_Iges")
self.actionExport_Stl = QAction(fluencyCAD)
self.actionExport_Stl.setObjectName(u"actionExport_Stl")
self.actionExit = QAction(fluencyCAD)
self.actionExit.setObjectName(u"actionExit")
self.centralwidget = QWidget(fluencyCAD)
self.centralwidget.setObjectName(u"centralwidget")
self.gridLayout = QGridLayout(self.centralwidget)
self.gridLayout.setObjectName(u"gridLayout")
self.InputTab = QTabWidget(self.centralwidget)
self.InputTab.setObjectName(u"InputTab")
sizePolicy = QSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
sizePolicy.setHorizontalStretch(0)
sizePolicy.setVerticalStretch(0)
sizePolicy.setHeightForWidth(self.InputTab.sizePolicy().hasHeightForWidth())
self.InputTab.setSizePolicy(sizePolicy)
self.sketch_tab = QWidget()
self.sketch_tab.setObjectName(u"sketch_tab")
self.verticalLayout_4 = QVBoxLayout(self.sketch_tab)
self.verticalLayout_4.setObjectName(u"verticalLayout_4")
self.InputTab.addTab(self.sketch_tab, "")
self.code_tab = QWidget()
self.code_tab.setObjectName(u"code_tab")
self.verticalLayout = QVBoxLayout(self.code_tab)
self.verticalLayout.setObjectName(u"verticalLayout")
self.textEdit = QTextEdit(self.code_tab)
self.textEdit.setObjectName(u"textEdit")
self.verticalLayout.addWidget(self.textEdit)
self.groupBox_7 = QGroupBox(self.code_tab)
self.groupBox_7.setObjectName(u"groupBox_7")
self.gridLayout_5 = QGridLayout(self.groupBox_7)
self.gridLayout_5.setObjectName(u"gridLayout_5")
self.pushButton_5 = QPushButton(self.groupBox_7)
self.pushButton_5.setObjectName(u"pushButton_5")
self.gridLayout_5.addWidget(self.pushButton_5, 2, 0, 1, 1)
self.pushButton_4 = QPushButton(self.groupBox_7)
self.pushButton_4.setObjectName(u"pushButton_4")
self.gridLayout_5.addWidget(self.pushButton_4, 2, 1, 1, 1)
self.pb_apply_code = QPushButton(self.groupBox_7)
self.pb_apply_code.setObjectName(u"pb_apply_code")
self.gridLayout_5.addWidget(self.pb_apply_code, 1, 0, 1, 1)
self.pushButton = QPushButton(self.groupBox_7)
self.pushButton.setObjectName(u"pushButton")
self.gridLayout_5.addWidget(self.pushButton, 1, 1, 1, 1)
self.verticalLayout.addWidget(self.groupBox_7)
self.InputTab.addTab(self.code_tab, "")
self.gridLayout.addWidget(self.InputTab, 0, 1, 12, 1)
self.groupBox_9 = QGroupBox(self.centralwidget)
self.groupBox_9.setObjectName(u"groupBox_9")
self.groupBox_9.setMaximumSize(QSize(200, 16777215))
self.gridLayout_7 = QGridLayout(self.groupBox_9)
self.gridLayout_7.setObjectName(u"gridLayout_7")
self.pb_origin_wp = QPushButton(self.groupBox_9)
self.pb_origin_wp.setObjectName(u"pb_origin_wp")
self.gridLayout_7.addWidget(self.pb_origin_wp, 0, 0, 1, 1)
self.pb_origin_face = QPushButton(self.groupBox_9)
self.pb_origin_face.setObjectName(u"pb_origin_face")
self.pb_origin_face.setCheckable(True)
self.gridLayout_7.addWidget(self.pb_origin_face, 0, 1, 1, 1)
self.pb_flip_face = QPushButton(self.groupBox_9)
self.pb_flip_face.setObjectName(u"pb_flip_face")
self.gridLayout_7.addWidget(self.pb_flip_face, 1, 0, 1, 1)
self.pb_underlay = QPushButton(self.groupBox_9)
self.pb_underlay.setObjectName(u"pb_underlay")
self.pb_underlay.setEnabled(False)
self.pb_underlay.setCheckable(True)
self.pb_underlay.setChecked(True)
self.gridLayout_7.addWidget(self.pb_underlay, 3, 0, 1, 1)
self.pb_clr_face = QPushButton(self.groupBox_9)
self.pb_clr_face.setObjectName(u"pb_clr_face")
self.pb_clr_face.setEnabled(False)
self.gridLayout_7.addWidget(self.pb_clr_face, 3, 1, 1, 1)
self.pb_to_sketch = QPushButton(self.groupBox_9)
self.pb_to_sketch.setObjectName(u"pb_to_sketch")
self.pb_to_sketch.setEnabled(False)
self.gridLayout_7.addWidget(self.pb_to_sketch, 4, 0, 1, 2)
self.pb_wp_new = QPushButton(self.groupBox_9)
self.pb_wp_new.setObjectName(u"pb_wp_new")
self.gridLayout_7.addWidget(self.pb_wp_new, 1, 1, 1, 1)
self.gridLayout.addWidget(self.groupBox_9, 0, 0, 1, 1)
self.assembly_box = QGroupBox(self.centralwidget)
self.assembly_box.setObjectName(u"assembly_box")
self.assembly_box.setMinimumSize(QSize(0, 50))
self.gridLayout.addWidget(self.assembly_box, 13, 1, 1, 2)
self.joint_tools = QGroupBox(self.centralwidget)
self.joint_tools.setObjectName(u"joint_tools")
self.joint_tools.setMinimumSize(QSize(0, 50))
self.gridLayout_10 = QGridLayout(self.joint_tools)
self.gridLayout_10.setObjectName(u"gridLayout_10")
self.pb_remove_connector = QPushButton(self.joint_tools)
self.pb_remove_connector.setObjectName(u"pb_remove_connector")
self.pb_remove_connector.setMinimumSize(QSize(50, 50))
self.pb_remove_connector.setMaximumSize(QSize(50, 50))
self.gridLayout_10.addWidget(self.pb_remove_connector, 0, 2, 1, 1)
self.pb_add_connector = QPushButton(self.joint_tools)
self.pb_add_connector.setObjectName(u"pb_add_connector")
self.pb_add_connector.setMinimumSize(QSize(50, 50))
self.pb_add_connector.setMaximumSize(QSize(50, 50))
self.gridLayout_10.addWidget(self.pb_add_connector, 0, 1, 1, 1)
self.pb_add_connector_2 = QPushButton(self.joint_tools)
self.pb_add_connector_2.setObjectName(u"pb_add_connector_2")
self.pb_add_connector_2.setMinimumSize(QSize(50, 50))
self.pb_add_connector_2.setMaximumSize(QSize(50, 50))
self.gridLayout_10.addWidget(self.pb_add_connector_2, 1, 1, 1, 1)
self.pb_add_connector_3 = QPushButton(self.joint_tools)
self.pb_add_connector_3.setObjectName(u"pb_add_connector_3")
self.pb_add_connector_3.setMinimumSize(QSize(50, 50))
self.pb_add_connector_3.setMaximumSize(QSize(50, 50))
self.gridLayout_10.addWidget(self.pb_add_connector_3, 1, 2, 1, 1)
self.gridLayout.addWidget(self.joint_tools, 12, 3, 2, 1)
self.gl_box = QGroupBox(self.centralwidget)
self.gl_box.setObjectName(u"gl_box")
sizePolicy1 = QSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
sizePolicy1.setHorizontalStretch(0)
sizePolicy1.setVerticalStretch(4)
sizePolicy1.setHeightForWidth(self.gl_box.sizePolicy().hasHeightForWidth())
self.gl_box.setSizePolicy(sizePolicy1)
font = QFont()
font.setPointSize(12)
self.gl_box.setFont(font)
self.horizontalLayout_4 = QHBoxLayout(self.gl_box)
#ifndef Q_OS_MAC
self.horizontalLayout_4.setSpacing(-1)
#endif
self.horizontalLayout_4.setObjectName(u"horizontalLayout_4")
self.horizontalLayout_4.setContentsMargins(12, -1, -1, -1)
self.gridLayout.addWidget(self.gl_box, 0, 2, 12, 1)
self.groupBox_11 = QGroupBox(self.centralwidget)
self.groupBox_11.setObjectName(u"groupBox_11")
sizePolicy2 = QSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Expanding)
sizePolicy2.setHorizontalStretch(0)
sizePolicy2.setVerticalStretch(0)
sizePolicy2.setHeightForWidth(self.groupBox_11.sizePolicy().hasHeightForWidth())
self.groupBox_11.setSizePolicy(sizePolicy2)
self.groupBox_11.setMaximumSize(QSize(200, 16777215))
self.verticalLayout_7 = QVBoxLayout(self.groupBox_11)
self.verticalLayout_7.setObjectName(u"verticalLayout_7")
self.verticalLayout_7.setContentsMargins(5, 5, 5, 5)
self.sketch_list = QListWidget(self.groupBox_11)
self.sketch_list.setObjectName(u"sketch_list")
sizePolicy3 = QSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
sizePolicy3.setHorizontalStretch(0)
sizePolicy3.setVerticalStretch(0)
sizePolicy3.setHeightForWidth(self.sketch_list.sizePolicy().hasHeightForWidth())
self.sketch_list.setSizePolicy(sizePolicy3)
self.sketch_list.setSelectionRectVisible(True)
self.verticalLayout_7.addWidget(self.sketch_list)
self.groupBox_6 = QGroupBox(self.groupBox_11)
self.groupBox_6.setObjectName(u"groupBox_6")
sizePolicy4 = QSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Preferred)
sizePolicy4.setHorizontalStretch(0)
sizePolicy4.setVerticalStretch(0)
sizePolicy4.setHeightForWidth(self.groupBox_6.sizePolicy().hasHeightForWidth())
self.groupBox_6.setSizePolicy(sizePolicy4)
self.gridLayout_6 = QGridLayout(self.groupBox_6)
self.gridLayout_6.setObjectName(u"gridLayout_6")
self.gridLayout_6.setContentsMargins(2, 2, 2, 2)
self.pb_edt_sktch = QPushButton(self.groupBox_6)
self.pb_edt_sktch.setObjectName(u"pb_edt_sktch")
self.gridLayout_6.addWidget(self.pb_edt_sktch, 1, 1, 1, 1)
self.pb_nw_sktch = QPushButton(self.groupBox_6)
self.pb_nw_sktch.setObjectName(u"pb_nw_sktch")
self.gridLayout_6.addWidget(self.pb_nw_sktch, 1, 0, 1, 1)
self.pb_del_sketch = QPushButton(self.groupBox_6)
self.pb_del_sketch.setObjectName(u"pb_del_sketch")
self.gridLayout_6.addWidget(self.pb_del_sketch, 1, 2, 1, 1)
self.verticalLayout_7.addWidget(self.groupBox_6)
self.gridLayout.addWidget(self.groupBox_11, 6, 0, 6, 1)
self.assembly_tools = QGroupBox(self.centralwidget)
self.assembly_tools.setObjectName(u"assembly_tools")
self.assembly_tools.setMinimumSize(QSize(0, 50))
self.gridLayout_12 = QGridLayout(self.assembly_tools)
self.gridLayout_12.setObjectName(u"gridLayout_12")
self.pb_compo_to_assembly = QPushButton(self.assembly_tools)
self.pb_compo_to_assembly.setObjectName(u"pb_compo_to_assembly")
self.pb_compo_to_assembly.setMinimumSize(QSize(50, 50))
self.pb_compo_to_assembly.setMaximumSize(QSize(50, 50))
self.gridLayout_12.addWidget(self.pb_compo_to_assembly, 0, 0, 1, 1)
self.pb_remove_compo_from_assembly = QPushButton(self.assembly_tools)
self.pb_remove_compo_from_assembly.setObjectName(u"pb_remove_compo_from_assembly")
self.pb_remove_compo_from_assembly.setEnabled(True)
sizePolicy4.setHeightForWidth(self.pb_remove_compo_from_assembly.sizePolicy().hasHeightForWidth())
self.pb_remove_compo_from_assembly.setSizePolicy(sizePolicy4)
self.pb_remove_compo_from_assembly.setMinimumSize(QSize(50, 50))
self.pb_remove_compo_from_assembly.setMaximumSize(QSize(50, 50))
self.pb_remove_compo_from_assembly.setLayoutDirection(Qt.LeftToRight)
self.gridLayout_12.addWidget(self.pb_remove_compo_from_assembly, 0, 1, 1, 1)
self.gridLayout.addWidget(self.assembly_tools, 13, 0, 1, 1)
self.compo_tool_box = QGroupBox(self.centralwidget)
self.compo_tool_box.setObjectName(u"compo_tool_box")
self.compo_tool_box.setMinimumSize(QSize(0, 50))
self.gridLayout_9 = QGridLayout(self.compo_tool_box)
self.gridLayout_9.setObjectName(u"gridLayout_9")
self.pb_new_compo = QPushButton(self.compo_tool_box)
self.pb_new_compo.setObjectName(u"pb_new_compo")
self.pb_new_compo.setMinimumSize(QSize(50, 50))
self.pb_new_compo.setMaximumSize(QSize(50, 50))
self.gridLayout_9.addWidget(self.pb_new_compo, 0, 0, 1, 1)
self.pb_del_compo = QPushButton(self.compo_tool_box)
self.pb_del_compo.setObjectName(u"pb_del_compo")
self.pb_del_compo.setEnabled(True)
sizePolicy4.setHeightForWidth(self.pb_del_compo.sizePolicy().hasHeightForWidth())
self.pb_del_compo.setSizePolicy(sizePolicy4)
self.pb_del_compo.setMinimumSize(QSize(50, 50))
self.pb_del_compo.setMaximumSize(QSize(50, 50))
self.pb_del_compo.setLayoutDirection(Qt.LeftToRight)
self.gridLayout_9.addWidget(self.pb_del_compo, 0, 1, 1, 1)
self.gridLayout.addWidget(self.compo_tool_box, 12, 0, 1, 1)
self.compo_box = QGroupBox(self.centralwidget)
self.compo_box.setObjectName(u"compo_box")
self.compo_box.setMinimumSize(QSize(0, 50))
self.gridLayout.addWidget(self.compo_box, 12, 1, 1, 2)
self.groupBox_12 = QGroupBox(self.centralwidget)
self.groupBox_12.setObjectName(u"groupBox_12")
sizePolicy2.setHeightForWidth(self.groupBox_12.sizePolicy().hasHeightForWidth())
self.groupBox_12.setSizePolicy(sizePolicy2)
self.groupBox_12.setMaximumSize(QSize(200, 16777215))
self.verticalLayout_8 = QVBoxLayout(self.groupBox_12)
self.verticalLayout_8.setObjectName(u"verticalLayout_8")
self.verticalLayout_8.setContentsMargins(5, 5, 5, 5)
self.connection_list = QListWidget(self.groupBox_12)
self.connection_list.setObjectName(u"connection_list")
self.connection_list.setSelectionRectVisible(True)
self.verticalLayout_8.addWidget(self.connection_list)
self.groupBox_13 = QGroupBox(self.groupBox_12)
self.groupBox_13.setObjectName(u"groupBox_13")
sizePolicy4.setHeightForWidth(self.groupBox_13.sizePolicy().hasHeightForWidth())
self.groupBox_13.setSizePolicy(sizePolicy4)
self.groupBox_13.setMaximumSize(QSize(200, 16777215))
self.gridLayout_13 = QGridLayout(self.groupBox_13)
self.gridLayout_13.setObjectName(u"gridLayout_13")
self.gridLayout_13.setContentsMargins(2, 2, 2, 2)
self.pb_del_connection = QPushButton(self.groupBox_13)
self.pb_del_connection.setObjectName(u"pb_del_connection")
self.gridLayout_13.addWidget(self.pb_del_connection, 0, 2, 1, 1)
self.pb_update_connection = QPushButton(self.groupBox_13)
self.pb_update_connection.setObjectName(u"pb_update_connection")
self.gridLayout_13.addWidget(self.pb_update_connection, 0, 0, 1, 1)
self.pb_edt_sktch_4 = QPushButton(self.groupBox_13)
self.pb_edt_sktch_4.setObjectName(u"pb_edt_sktch_4")
self.gridLayout_13.addWidget(self.pb_edt_sktch_4, 0, 1, 1, 1)
self.verticalLayout_8.addWidget(self.groupBox_13)
self.gridLayout.addWidget(self.groupBox_12, 6, 3, 6, 1)
self.groupBox_10 = QGroupBox(self.centralwidget)
self.groupBox_10.setObjectName(u"groupBox_10")
sizePolicy2.setHeightForWidth(self.groupBox_10.sizePolicy().hasHeightForWidth())
self.groupBox_10.setSizePolicy(sizePolicy2)
self.groupBox_10.setMaximumSize(QSize(200, 16777215))
self.verticalLayout_6 = QVBoxLayout(self.groupBox_10)
self.verticalLayout_6.setObjectName(u"verticalLayout_6")
self.verticalLayout_6.setContentsMargins(5, 5, 5, 5)
self.body_list = QListWidget(self.groupBox_10)
self.body_list.setObjectName(u"body_list")
self.body_list.setSelectionRectVisible(True)
self.verticalLayout_6.addWidget(self.body_list)
self.groupBox_8 = QGroupBox(self.groupBox_10)
self.groupBox_8.setObjectName(u"groupBox_8")
sizePolicy4.setHeightForWidth(self.groupBox_8.sizePolicy().hasHeightForWidth())
self.groupBox_8.setSizePolicy(sizePolicy4)
self.groupBox_8.setMaximumSize(QSize(200, 16777215))
self.gridLayout_8 = QGridLayout(self.groupBox_8)
self.gridLayout_8.setObjectName(u"gridLayout_8")
self.gridLayout_8.setContentsMargins(2, 2, 2, 2)
self.pb_del_body = QPushButton(self.groupBox_8)
self.pb_del_body.setObjectName(u"pb_del_body")
self.gridLayout_8.addWidget(self.pb_del_body, 0, 2, 1, 1)
self.pb_update_body = QPushButton(self.groupBox_8)
self.pb_update_body.setObjectName(u"pb_update_body")
self.gridLayout_8.addWidget(self.pb_update_body, 0, 0, 1, 1)
self.pb_edt_sktch_3 = QPushButton(self.groupBox_8)
self.pb_edt_sktch_3.setObjectName(u"pb_edt_sktch_3")
self.gridLayout_8.addWidget(self.pb_edt_sktch_3, 0, 1, 1, 1)
self.verticalLayout_6.addWidget(self.groupBox_8)
self.gridLayout.addWidget(self.groupBox_10, 3, 3, 3, 1)
self.groupBox_2 = QGroupBox(self.centralwidget)
self.groupBox_2.setObjectName(u"groupBox_2")
sizePolicy4.setHeightForWidth(self.groupBox_2.sizePolicy().hasHeightForWidth())
self.groupBox_2.setSizePolicy(sizePolicy4)
self.groupBox_2.setMaximumSize(QSize(200, 16777215))
self.gridLayout_2 = QGridLayout(self.groupBox_2)
self.gridLayout_2.setObjectName(u"gridLayout_2")
self.gridLayout_2.setContentsMargins(10, -1, -1, -1)
self.pb_arc_tool = QPushButton(self.groupBox_2)
self.pb_arc_tool.setObjectName(u"pb_arc_tool")
self.pb_arc_tool.setCheckable(True)
self.gridLayout_2.addWidget(self.pb_arc_tool, 2, 0, 1, 1)
self.pb_rectool = QPushButton(self.groupBox_2)
self.pb_rectool.setObjectName(u"pb_rectool")
self.pb_rectool.setCheckable(True)
self.pb_rectool.setAutoExclusive(False)
self.gridLayout_2.addWidget(self.pb_rectool, 0, 1, 1, 1)
self.pb_circtool = QPushButton(self.groupBox_2)
self.pb_circtool.setObjectName(u"pb_circtool")
self.pb_circtool.setCheckable(True)
self.pb_circtool.setAutoExclusive(False)
self.gridLayout_2.addWidget(self.pb_circtool, 1, 0, 1, 1, Qt.AlignTop)
self.pb_enable_construct = QPushButton(self.groupBox_2)
self.pb_enable_construct.setObjectName(u"pb_enable_construct")
self.pb_enable_construct.setCheckable(True)
self.gridLayout_2.addWidget(self.pb_enable_construct, 4, 0, 1, 1)
self.pb_enable_snap = QPushButton(self.groupBox_2)
self.pb_enable_snap.setObjectName(u"pb_enable_snap")
self.pb_enable_snap.setIconSize(QSize(13, 16))
self.pb_enable_snap.setCheckable(True)
self.pb_enable_snap.setChecked(True)
self.gridLayout_2.addWidget(self.pb_enable_snap, 4, 1, 1, 1)
self.pb_linetool = QPushButton(self.groupBox_2)
self.pb_linetool.setObjectName(u"pb_linetool")
self.pb_linetool.setCheckable(True)
self.pb_linetool.setAutoExclusive(False)
self.gridLayout_2.addWidget(self.pb_linetool, 0, 0, 1, 1)
self.pb_slotool = QPushButton(self.groupBox_2)
self.pb_slotool.setObjectName(u"pb_slotool")
self.pb_slotool.setCheckable(True)
self.pb_slotool.setAutoExclusive(False)
self.gridLayout_2.addWidget(self.pb_slotool, 1, 1, 1, 1, Qt.AlignTop)
self.line = QFrame(self.groupBox_2)
self.line.setObjectName(u"line")
self.line.setFrameShape(QFrame.Shape.HLine)
self.line.setFrameShadow(QFrame.Shadow.Sunken)
self.gridLayout_2.addWidget(self.line, 3, 0, 1, 2)
self.pb_offset_tool = QPushButton(self.groupBox_2)
self.pb_offset_tool.setObjectName(u"pb_offset_tool")
self.gridLayout_2.addWidget(self.pb_offset_tool, 2, 1, 1, 1)
self.gridLayout.addWidget(self.groupBox_2, 1, 0, 1, 1)
self.groupBox_3 = QGroupBox(self.centralwidget)
self.groupBox_3.setObjectName(u"groupBox_3")
sizePolicy4.setHeightForWidth(self.groupBox_3.sizePolicy().hasHeightForWidth())
self.groupBox_3.setSizePolicy(sizePolicy4)
self.groupBox_3.setMaximumSize(QSize(200, 16777213))
self.gridLayout_4 = QGridLayout(self.groupBox_3)
self.gridLayout_4.setObjectName(u"gridLayout_4")
self.pb_con_ptpt = QPushButton(self.groupBox_3)
self.pb_con_ptpt.setObjectName(u"pb_con_ptpt")
self.pb_con_ptpt.setCheckable(True)
self.pb_con_ptpt.setAutoExclusive(False)
self.gridLayout_4.addWidget(self.pb_con_ptpt, 1, 0, 1, 1)
self.pb_con_vert = QPushButton(self.groupBox_3)
self.pb_con_vert.setObjectName(u"pb_con_vert")
self.pb_con_vert.setCheckable(True)
self.pb_con_vert.setAutoExclusive(False)
self.gridLayout_4.addWidget(self.pb_con_vert, 3, 1, 1, 1)
self.pb_con_sym = QPushButton(self.groupBox_3)
self.pb_con_sym.setObjectName(u"pb_con_sym")
self.pb_con_sym.setCheckable(True)
self.pb_con_sym.setAutoExclusive(False)
self.gridLayout_4.addWidget(self.pb_con_sym, 4, 1, 1, 1)
self.pb_con_mid = QPushButton(self.groupBox_3)
self.pb_con_mid.setObjectName(u"pb_con_mid")
self.pb_con_mid.setCheckable(True)
self.pb_con_mid.setAutoExclusive(False)
self.gridLayout_4.addWidget(self.pb_con_mid, 2, 0, 1, 1)
self.pb_con_line = QPushButton(self.groupBox_3)
self.pb_con_line.setObjectName(u"pb_con_line")
self.pb_con_line.setCheckable(True)
self.pb_con_line.setAutoExclusive(False)
self.gridLayout_4.addWidget(self.pb_con_line, 1, 1, 1, 1)
self.pb_con_horiz = QPushButton(self.groupBox_3)
self.pb_con_horiz.setObjectName(u"pb_con_horiz")
self.pb_con_horiz.setCheckable(True)
self.pb_con_horiz.setAutoExclusive(False)
self.gridLayout_4.addWidget(self.pb_con_horiz, 3, 0, 1, 1)
self.pb_con_dist = QPushButton(self.groupBox_3)
self.pb_con_dist.setObjectName(u"pb_con_dist")
self.pb_con_dist.setCheckable(True)
self.pb_con_dist.setAutoExclusive(False)
self.pb_con_dist.setAutoRepeatDelay(297)
self.gridLayout_4.addWidget(self.pb_con_dist, 4, 0, 1, 1)
self.pb_con_perp = QPushButton(self.groupBox_3)
self.pb_con_perp.setObjectName(u"pb_con_perp")
self.pb_con_perp.setCheckable(True)
self.pb_con_perp.setAutoExclusive(False)
self.gridLayout_4.addWidget(self.pb_con_perp, 2, 1, 1, 1)
self.pb_con_diameter = QPushButton(self.groupBox_3)
self.pb_con_diameter.setObjectName(u"pb_con_diameter")
self.gridLayout_4.addWidget(self.pb_con_diameter, 5, 0, 1, 1)
self.gridLayout.addWidget(self.groupBox_3, 2, 0, 1, 1)
self.groupBox_5 = QGroupBox(self.centralwidget)
self.groupBox_5.setObjectName(u"groupBox_5")
sizePolicy4.setHeightForWidth(self.groupBox_5.sizePolicy().hasHeightForWidth())
self.groupBox_5.setSizePolicy(sizePolicy4)
self.gridLayout_11 = QGridLayout(self.groupBox_5)
self.gridLayout_11.setObjectName(u"gridLayout_11")
self.gridLayout_11.setContentsMargins(12, 12, 12, 12)
self.label = QLabel(self.groupBox_5)
self.label.setObjectName(u"label")
self.gridLayout_11.addWidget(self.label, 5, 0, 1, 1)
self.pb_snap_vert = QPushButton(self.groupBox_5)
self.pb_snap_vert.setObjectName(u"pb_snap_vert")
self.pb_snap_vert.setCheckable(True)
self.pb_snap_vert.setAutoExclusive(False)
self.gridLayout_11.addWidget(self.pb_snap_vert, 2, 1, 1, 1)
self.line_2 = QFrame(self.groupBox_5)
self.line_2.setObjectName(u"line_2")
self.line_2.setFrameShape(QFrame.Shape.HLine)
self.line_2.setFrameShadow(QFrame.Shadow.Sunken)
self.gridLayout_11.addWidget(self.line_2, 4, 0, 1, 2)
self.label_2 = QLabel(self.groupBox_5)
self.label_2.setObjectName(u"label_2")
self.gridLayout_11.addWidget(self.label_2, 5, 1, 1, 1)
self.spinbox_snap_distance = QSpinBox(self.groupBox_5)
self.spinbox_snap_distance.setObjectName(u"spinbox_snap_distance")
self.spinbox_snap_distance.setMaximum(30)
self.spinbox_snap_distance.setValue(10)
self.gridLayout_11.addWidget(self.spinbox_snap_distance, 6, 0, 1, 1)
self.pushButton_7 = QPushButton(self.groupBox_5)
self.pushButton_7.setObjectName(u"pushButton_7")
self.pushButton_7.setCheckable(True)
self.pushButton_7.setAutoExclusive(False)
self.gridLayout_11.addWidget(self.pushButton_7, 3, 0, 1, 1)
self.pb_snap_horiz = QPushButton(self.groupBox_5)
self.pb_snap_horiz.setObjectName(u"pb_snap_horiz")
self.pb_snap_horiz.setCheckable(True)
self.pb_snap_horiz.setAutoExclusive(False)
self.gridLayout_11.addWidget(self.pb_snap_horiz, 2, 0, 1, 1)
self.spinbox_angle_steps = QSpinBox(self.groupBox_5)
self.spinbox_angle_steps.setObjectName(u"spinbox_angle_steps")
self.spinbox_angle_steps.setMaximum(180)
self.spinbox_angle_steps.setValue(15)
self.gridLayout_11.addWidget(self.spinbox_angle_steps, 6, 1, 1, 1)
self.pushButton_8 = QPushButton(self.groupBox_5)
self.pushButton_8.setObjectName(u"pushButton_8")
self.pushButton_8.setCheckable(True)
self.pushButton_8.setAutoExclusive(False)
self.gridLayout_11.addWidget(self.pushButton_8, 0, 0, 1, 1)
self.pb_snap_midp = QPushButton(self.groupBox_5)
self.pb_snap_midp.setObjectName(u"pb_snap_midp")
self.pb_snap_midp.setCheckable(True)
self.pb_snap_midp.setAutoExclusive(False)
self.gridLayout_11.addWidget(self.pb_snap_midp, 0, 1, 1, 1)
self.pb_snap_angle = QPushButton(self.groupBox_5)
self.pb_snap_angle.setObjectName(u"pb_snap_angle")
self.pb_snap_angle.setCheckable(True)
self.pb_snap_angle.setAutoExclusive(False)
self.gridLayout_11.addWidget(self.pb_snap_angle, 3, 1, 1, 1)
self.gridLayout.addWidget(self.groupBox_5, 3, 0, 1, 1)
self.groupBox = QGroupBox(self.centralwidget)
self.groupBox.setObjectName(u"groupBox")
self.gridLayout_3 = QGridLayout(self.groupBox)
self.gridLayout_3.setObjectName(u"gridLayout_3")
self.pb_revop = QPushButton(self.groupBox)
self.pb_revop.setObjectName(u"pb_revop")
self.gridLayout_3.addWidget(self.pb_revop, 2, 1, 1, 1)
self.pb_extrdop = QPushButton(self.groupBox)
self.pb_extrdop.setObjectName(u"pb_extrdop")
self.gridLayout_3.addWidget(self.pb_extrdop, 0, 0, 1, 1)
self.pb_arrayop = QPushButton(self.groupBox)
self.pb_arrayop.setObjectName(u"pb_arrayop")
self.gridLayout_3.addWidget(self.pb_arrayop, 2, 0, 1, 1)
self.pb_cutop = QPushButton(self.groupBox)
self.pb_cutop.setObjectName(u"pb_cutop")
self.gridLayout_3.addWidget(self.pb_cutop, 0, 1, 1, 1)
self.pb_combop = QPushButton(self.groupBox)
self.pb_combop.setObjectName(u"pb_combop")
self.gridLayout_3.addWidget(self.pb_combop, 1, 0, 1, 1)
self.pb_moveop = QPushButton(self.groupBox)
self.pb_moveop.setObjectName(u"pb_moveop")
self.gridLayout_3.addWidget(self.pb_moveop, 1, 1, 1, 1)
self.gridLayout.addWidget(self.groupBox, 0, 3, 1, 1)
self.groupBox_4 = QGroupBox(self.centralwidget)
self.groupBox_4.setObjectName(u"groupBox_4")
self.verticalLayout_2 = QVBoxLayout(self.groupBox_4)
self.verticalLayout_2.setObjectName(u"verticalLayout_2")
self.pushButton_2 = QPushButton(self.groupBox_4)
self.pushButton_2.setObjectName(u"pushButton_2")
self.verticalLayout_2.addWidget(self.pushButton_2)
self.pb_export_step = QPushButton(self.groupBox_4)
self.pb_export_step.setObjectName(u"pb_export_step")
self.verticalLayout_2.addWidget(self.pb_export_step)
self.pb_export_iges = QPushButton(self.groupBox_4)
self.pb_export_iges.setObjectName(u"pb_export_iges")
self.verticalLayout_2.addWidget(self.pb_export_iges)
self.gridLayout.addWidget(self.groupBox_4, 2, 3, 1, 1)
fluencyCAD.setCentralWidget(self.centralwidget)
self.menubar = QMenuBar(fluencyCAD)
self.menubar.setObjectName(u"menubar")
self.menubar.setGeometry(QRect(0, 0, 2359, 24))
self.menuFile = QMenu(self.menubar)
self.menuFile.setObjectName(u"menuFile")
self.menuSettings = QMenu(self.menubar)
self.menuSettings.setObjectName(u"menuSettings")
fluencyCAD.setMenuBar(self.menubar)
self.statusbar = QStatusBar(fluencyCAD)
self.statusbar.setObjectName(u"statusbar")
fluencyCAD.setStatusBar(self.statusbar)
self.menubar.addAction(self.menuFile.menuAction())
self.menubar.addAction(self.menuSettings.menuAction())
self.menuFile.addAction(self.actionNew_Project)
self.menuFile.addAction(self.actionOpen_Project)
self.menuFile.addAction(self.actionSave_Project)
self.menuFile.addAction(self.actionSave_Project_As)
self.menuFile.addSeparator()
self.menuFile.addAction(self.actionImport_File)
self.menuFile.addSeparator()
self.menuFile.addAction(self.actionExport_Step)
self.menuFile.addAction(self.actionExport_Iges)
self.menuFile.addAction(self.actionExport_Stl)
self.menuFile.addSeparator()
self.menuFile.addAction(self.actionExit)
self.retranslateUi(fluencyCAD)
self.InputTab.setCurrentIndex(0)
QMetaObject.connectSlotsByName(fluencyCAD)
# setupUi
def retranslateUi(self, fluencyCAD):
fluencyCAD.setWindowTitle(QCoreApplication.translate("fluencyCAD", u"fluencyCAD", None))
self.actionNew_Project.setText(QCoreApplication.translate("fluencyCAD", u"New Project", None))
#if QT_CONFIG(shortcut)
self.actionNew_Project.setShortcut(QCoreApplication.translate("fluencyCAD", u"Ctrl+N", None))
#endif // QT_CONFIG(shortcut)
self.actionOpen_Project.setText(QCoreApplication.translate("fluencyCAD", u"Open Project...", None))
#if QT_CONFIG(shortcut)
self.actionOpen_Project.setShortcut(QCoreApplication.translate("fluencyCAD", u"Ctrl+O", None))
#endif // QT_CONFIG(shortcut)
self.actionSave_Project.setText(QCoreApplication.translate("fluencyCAD", u"Save Project", None))
#if QT_CONFIG(shortcut)
self.actionSave_Project.setShortcut(QCoreApplication.translate("fluencyCAD", u"Ctrl+S", None))
#endif // QT_CONFIG(shortcut)
self.actionSave_Project_As.setText(QCoreApplication.translate("fluencyCAD", u"Save Project As...", None))
#if QT_CONFIG(shortcut)
self.actionSave_Project_As.setShortcut(QCoreApplication.translate("fluencyCAD", u"Ctrl+Shift+S", None))
#endif // QT_CONFIG(shortcut)
self.actionImport_File.setText(QCoreApplication.translate("fluencyCAD", u"Import STEP/IGES...", None))
self.actionExport_Step.setText(QCoreApplication.translate("fluencyCAD", u"Export STEP...", None))
self.actionExport_Iges.setText(QCoreApplication.translate("fluencyCAD", u"Export IGES...", None))
self.actionExport_Stl.setText(QCoreApplication.translate("fluencyCAD", u"Export STL...", None))
self.actionExit.setText(QCoreApplication.translate("fluencyCAD", u"Exit", None))
#if QT_CONFIG(shortcut)
self.actionExit.setShortcut(QCoreApplication.translate("fluencyCAD", u"Ctrl+Q", None))
#endif // QT_CONFIG(shortcut)
self.InputTab.setTabText(self.InputTab.indexOf(self.sketch_tab), QCoreApplication.translate("fluencyCAD", u"Sketch", None))
self.groupBox_7.setTitle(QCoreApplication.translate("fluencyCAD", u"Executive", None))
self.pushButton_5.setText(QCoreApplication.translate("fluencyCAD", u"Load Code", None))
self.pushButton_4.setText(QCoreApplication.translate("fluencyCAD", u"Save code", None))
self.pb_apply_code.setText(QCoreApplication.translate("fluencyCAD", u"Apply Code", None))
self.pushButton.setText(QCoreApplication.translate("fluencyCAD", u"Delete Code", None))
self.InputTab.setTabText(self.InputTab.indexOf(self.code_tab), QCoreApplication.translate("fluencyCAD", u"Code", None))
self.groupBox_9.setTitle(QCoreApplication.translate("fluencyCAD", u"Workplanes", None))
#if QT_CONFIG(tooltip)
self.pb_origin_wp.setToolTip(QCoreApplication.translate("fluencyCAD", u"<W>orking Plane at 0, 0, 0", None))
#endif // QT_CONFIG(tooltip)
self.pb_origin_wp.setText(QCoreApplication.translate("fluencyCAD", u"WP Origin", None))
#if QT_CONFIG(shortcut)
self.pb_origin_wp.setShortcut(QCoreApplication.translate("fluencyCAD", u"W", None))
#endif // QT_CONFIG(shortcut)
#if QT_CONFIG(tooltip)
self.pb_origin_face.setToolTip(QCoreApplication.translate("fluencyCAD", u"Working Plane >P<rojection at selected edges face", None))
#endif // QT_CONFIG(tooltip)
self.pb_origin_face.setText(QCoreApplication.translate("fluencyCAD", u" WP Face", None))
#if QT_CONFIG(shortcut)
self.pb_origin_face.setShortcut(QCoreApplication.translate("fluencyCAD", u"P", None))
#endif // QT_CONFIG(shortcut)
#if QT_CONFIG(tooltip)
self.pb_flip_face.setToolTip(QCoreApplication.translate("fluencyCAD", u"Flip >N<ormal of projected mesh.", None))
#endif // QT_CONFIG(tooltip)
self.pb_flip_face.setText(QCoreApplication.translate("fluencyCAD", u"WP Flip", None))
#if QT_CONFIG(shortcut)
self.pb_flip_face.setShortcut(QCoreApplication.translate("fluencyCAD", u"N", None))
#endif // QT_CONFIG(shortcut)
#if QT_CONFIG(tooltip)
self.pb_underlay.setToolTip(QCoreApplication.translate("fluencyCAD", u"Show / hide the construction lines projected from the source face", None))
#endif // QT_CONFIG(tooltip)
self.pb_underlay.setText(QCoreApplication.translate("fluencyCAD", u"Underlay", None))
#if QT_CONFIG(tooltip)
self.pb_clr_face.setToolTip(QCoreApplication.translate("fluencyCAD", u"Forget the picked source face (keep the workplane)", None))
#endif // QT_CONFIG(tooltip)
self.pb_clr_face.setText(QCoreApplication.translate("fluencyCAD", u"ClrFace", None))
#if QT_CONFIG(tooltip)
self.pb_to_sketch.setToolTip(QCoreApplication.translate("fluencyCAD", u"Convert projected construction lines into real sketch geometry", None))
#endif // QT_CONFIG(tooltip)
self.pb_to_sketch.setText(QCoreApplication.translate("fluencyCAD", u"ToSketch", None))
#if QT_CONFIG(tooltip)
self.pb_wp_new.setToolTip(QCoreApplication.translate("fluencyCAD", u"Create a new independent workplane (datum plane)", None))
#endif // QT_CONFIG(tooltip)
self.pb_wp_new.setText(QCoreApplication.translate("fluencyCAD", u"WP New", None))
#if QT_CONFIG(shortcut)
self.pb_wp_new.setShortcut(QCoreApplication.translate("fluencyCAD", u"Shift+W", None))
#endif // QT_CONFIG(shortcut)
self.assembly_box.setTitle(QCoreApplication.translate("fluencyCAD", u"Assembly", None))
self.joint_tools.setTitle(QCoreApplication.translate("fluencyCAD", u"Joint Tools", None))
self.pb_remove_connector.setText(QCoreApplication.translate("fluencyCAD", u"- Cnct", None))
self.pb_add_connector.setText(QCoreApplication.translate("fluencyCAD", u"+ Cnct", None))
self.pb_add_connector_2.setText(QCoreApplication.translate("fluencyCAD", u"+Jnt", None))
self.pb_add_connector_3.setText(QCoreApplication.translate("fluencyCAD", u"-Jnt", None))
self.gl_box.setTitle(QCoreApplication.translate("fluencyCAD", u"Model Viewer", None))
self.groupBox_11.setTitle(QCoreApplication.translate("fluencyCAD", u"Sketch", None))
self.groupBox_6.setTitle(QCoreApplication.translate("fluencyCAD", u"Tools", None))
self.pb_edt_sktch.setText(QCoreApplication.translate("fluencyCAD", u"Edt", None))
self.pb_nw_sktch.setText(QCoreApplication.translate("fluencyCAD", u"Add", None))
self.pb_del_sketch.setText(QCoreApplication.translate("fluencyCAD", u"Del", None))
self.assembly_tools.setTitle(QCoreApplication.translate("fluencyCAD", u"Assembly Tools", None))
self.pb_compo_to_assembly.setText(QCoreApplication.translate("fluencyCAD", u"Add", None))
self.pb_remove_compo_from_assembly.setText(QCoreApplication.translate("fluencyCAD", u"Rem", None))
self.compo_tool_box.setTitle(QCoreApplication.translate("fluencyCAD", u"Component Tools", None))
self.pb_new_compo.setText(QCoreApplication.translate("fluencyCAD", u"New", None))
self.pb_del_compo.setText(QCoreApplication.translate("fluencyCAD", u"Del", None))
self.compo_box.setTitle(QCoreApplication.translate("fluencyCAD", u"Components", None))
self.groupBox_12.setTitle(QCoreApplication.translate("fluencyCAD", u"Component Connections", None))
self.groupBox_13.setTitle(QCoreApplication.translate("fluencyCAD", u"Tools", None))
self.pb_del_connection.setText(QCoreApplication.translate("fluencyCAD", u"Del", None))
self.pb_update_connection.setText(QCoreApplication.translate("fluencyCAD", u"Upd", None))
self.pb_edt_sktch_4.setText(QCoreApplication.translate("fluencyCAD", u"Nothing", None))
self.groupBox_10.setTitle(QCoreApplication.translate("fluencyCAD", u"Bodys / Operations", None))
self.groupBox_8.setTitle(QCoreApplication.translate("fluencyCAD", u"Tools", None))
self.pb_del_body.setText(QCoreApplication.translate("fluencyCAD", u"Del", None))
self.pb_update_body.setText(QCoreApplication.translate("fluencyCAD", u"Upd", None))
self.pb_edt_sktch_3.setText(QCoreApplication.translate("fluencyCAD", u"Nothing", None))
self.groupBox_2.setTitle(QCoreApplication.translate("fluencyCAD", u"Drawing", None))
self.pb_arc_tool.setText(QCoreApplication.translate("fluencyCAD", u"Arc", None))
self.pb_rectool.setText(QCoreApplication.translate("fluencyCAD", u"Rctgl", None))
self.pb_circtool.setText(QCoreApplication.translate("fluencyCAD", u"Circle", None))
self.pb_enable_construct.setText(QCoreApplication.translate("fluencyCAD", u"Cstrct", None))
self.pb_enable_snap.setText(QCoreApplication.translate("fluencyCAD", u"Snap", None))
self.pb_linetool.setText(QCoreApplication.translate("fluencyCAD", u"Line", None))
#if QT_CONFIG(shortcut)
self.pb_linetool.setShortcut(QCoreApplication.translate("fluencyCAD", u"S", None))
#endif // QT_CONFIG(shortcut)
self.pb_slotool.setText(QCoreApplication.translate("fluencyCAD", u"Slot", None))
#if QT_CONFIG(tooltip)
self.pb_offset_tool.setToolTip(QCoreApplication.translate("fluencyCAD", u"Offset selected sketch face (duplicate + offset boundary)", None))
#endif // QT_CONFIG(tooltip)
self.pb_offset_tool.setText(QCoreApplication.translate("fluencyCAD", u"Offst", None))
self.groupBox_3.setTitle(QCoreApplication.translate("fluencyCAD", u"Constrain", None))
#if QT_CONFIG(tooltip)
self.pb_con_ptpt.setToolTip(QCoreApplication.translate("fluencyCAD", u"Poin to Point Constrain", None))
#endif // QT_CONFIG(tooltip)
self.pb_con_ptpt.setText(QCoreApplication.translate("fluencyCAD", u"Pt_Pt", None))
#if QT_CONFIG(tooltip)
self.pb_con_vert.setToolTip(QCoreApplication.translate("fluencyCAD", u"Vertical Constrain", None))
#endif // QT_CONFIG(tooltip)
self.pb_con_vert.setText(QCoreApplication.translate("fluencyCAD", u"Vert", None))
self.pb_con_sym.setText(QCoreApplication.translate("fluencyCAD", u"Symetrc", None))
#if QT_CONFIG(tooltip)
self.pb_con_mid.setToolTip(QCoreApplication.translate("fluencyCAD", u"Point to Middle Point Constrain", None))
#endif // QT_CONFIG(tooltip)
self.pb_con_mid.setText(QCoreApplication.translate("fluencyCAD", u"Pt_Mid_L", None))
#if QT_CONFIG(tooltip)
self.pb_con_line.setToolTip(QCoreApplication.translate("fluencyCAD", u"Point to Line Constrain", None))
#endif // QT_CONFIG(tooltip)
self.pb_con_line.setText(QCoreApplication.translate("fluencyCAD", u"Pt_Lne", None))
#if QT_CONFIG(tooltip)
self.pb_con_horiz.setToolTip(QCoreApplication.translate("fluencyCAD", u"Horizontal Constrain ", None))
#endif // QT_CONFIG(tooltip)
self.pb_con_horiz.setText(QCoreApplication.translate("fluencyCAD", u"Horiz", None))
#if QT_CONFIG(tooltip)
self.pb_con_dist.setToolTip(QCoreApplication.translate("fluencyCAD", u"Dimension of Line of Distance from Point to Line", None))
#endif // QT_CONFIG(tooltip)
self.pb_con_dist.setText(QCoreApplication.translate("fluencyCAD", u"Distnce", None))
#if QT_CONFIG(tooltip)
self.pb_con_perp.setToolTip(QCoreApplication.translate("fluencyCAD", u"Constrain Line perpendicular to another line.", None))
#endif // QT_CONFIG(tooltip)
self.pb_con_perp.setText(QCoreApplication.translate("fluencyCAD", u"Perp_Lne", None))
self.pb_con_diameter.setText(QCoreApplication.translate("fluencyCAD", u"Diameter", None))
self.groupBox_5.setTitle(QCoreApplication.translate("fluencyCAD", u"Snapping Points", None))
self.label.setText(QCoreApplication.translate("fluencyCAD", u"Snp Dst", None))
self.pb_snap_vert.setText(QCoreApplication.translate("fluencyCAD", u"Vert", None))
self.label_2.setText(QCoreApplication.translate("fluencyCAD", u"Angl Stps", None))
self.spinbox_snap_distance.setSuffix(QCoreApplication.translate("fluencyCAD", u"mm", None))
self.pushButton_7.setText(QCoreApplication.translate("fluencyCAD", u"Grid", None))
self.pb_snap_horiz.setText(QCoreApplication.translate("fluencyCAD", u"Horiz", None))
self.spinbox_angle_steps.setSuffix(QCoreApplication.translate("fluencyCAD", u"\u00b0", None))
self.pushButton_8.setText(QCoreApplication.translate("fluencyCAD", u"Pnt", None))
self.pb_snap_midp.setText(QCoreApplication.translate("fluencyCAD", u"MidP", None))
self.pb_snap_angle.setText(QCoreApplication.translate("fluencyCAD", u"Angles", None))
self.groupBox.setTitle(QCoreApplication.translate("fluencyCAD", u"Modify", None))
self.pb_revop.setText(QCoreApplication.translate("fluencyCAD", u"Rev", None))
self.pb_extrdop.setText(QCoreApplication.translate("fluencyCAD", u"Extrd", None))
self.pb_arrayop.setText(QCoreApplication.translate("fluencyCAD", u"Arry", None))
self.pb_cutop.setText(QCoreApplication.translate("fluencyCAD", u"Cut", None))
self.pb_combop.setText(QCoreApplication.translate("fluencyCAD", u"Comb", None))
self.pb_moveop.setText(QCoreApplication.translate("fluencyCAD", u"Mve", None))
self.groupBox_4.setTitle(QCoreApplication.translate("fluencyCAD", u"Export", None))
self.pushButton_2.setText(QCoreApplication.translate("fluencyCAD", u"STL", None))
self.pb_export_step.setText(QCoreApplication.translate("fluencyCAD", u"STEP", None))
self.pb_export_iges.setText(QCoreApplication.translate("fluencyCAD", u"IGES", None))
self.menuFile.setTitle(QCoreApplication.translate("fluencyCAD", u"File", None))
self.menuSettings.setTitle(QCoreApplication.translate("fluencyCAD", u"Settings", None))
# retranslateUi
View File
+1 -1
View File
@@ -26,13 +26,13 @@ classifiers = [
]
dependencies = [
"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",
"python_solvespace>=3.0.0",
]
[project.optional-dependencies]
+1 -1
View File
@@ -2,7 +2,7 @@
Fluency CAD - Parametric CAD Application
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"
Binary file not shown.
Binary file not shown.
+5
View File
@@ -406,6 +406,11 @@ class SketchInterface(ABC):
"""Constrain two circles to have equal radius."""
pass
@abstractmethod
def constrain_diameter(self, circle: SketchEntity, diameter: float) -> bool:
"""Set the diameter of a circle."""
pass
@abstractmethod
def constrain_fixed(self, entity: SketchEntity) -> bool:
"""Fix an entity in place."""
+284 -287
View File
@@ -2,7 +2,7 @@
OpenCASCADE-based geometry kernel for Fluency CAD.
This module provides a concrete implementation of the geometry kernel
using CadQuery and OCP (OpenCASCADE Python bindings).
using OCP (OpenCASCADE Python bindings).
"""
from typing import List, Tuple, Optional, Any, Dict
@@ -21,24 +21,14 @@ class OCCGeometryObject(GeometryObject):
def __init__(self, shape: Any = None, metadata: Optional[Dict] = None):
super().__init__(shape, metadata)
self._cadquery_obj: Any = None
@property
def cq_obj(self) -> Any:
"""Get the CadQuery object if available."""
return self._cadquery_obj
@cq_obj.setter
def cq_obj(self, value: Any) -> None:
self._cadquery_obj = value
class OCGeometryKernel(GeometryKernel):
"""
OpenCASCADE-based geometry kernel implementation.
This kernel uses CadQuery for high-level operations and
OCP for direct OpenCASCADE access when needed.
This kernel uses OCP (OpenCASCADE Python bindings) for all geometry
operations.
"""
def __init__(self) -> None:
@@ -46,109 +36,98 @@ class OCGeometryKernel(GeometryKernel):
self._mesh_tolerance: float = 0.1
def _get_shape(self, obj: GeometryObject) -> Any:
"""Extract the underlying OCC shape from a GeometryObject."""
import cadquery as cq
"""Extract the underlying OCC shape from a GeometryObject.
Returns *None* if the object carries no shape (e.g. an empty sketch) —
callers should check for None before using the result.
"""
if isinstance(obj, OCCGeometryObject):
if obj._cadquery_obj is not None:
shape = obj._cadquery_obj.val()
if hasattr(shape, "wrapped"):
return shape.wrapped
return shape
if obj.shape is not None:
if isinstance(obj.shape, cq.Workplane):
shape = obj.shape.val()
if hasattr(shape, "wrapped"):
return shape.wrapped
return shape
if hasattr(obj.shape, "wrapped"):
return obj.shape.wrapped
return obj.shape
return obj.shape if obj.shape else obj
def _get_cq_obj(self, obj: GeometryObject) -> Any:
"""Get CadQuery object from GeometryObject."""
if isinstance(obj, OCCGeometryObject) and obj._cadquery_obj is not None:
return obj._cadquery_obj
return obj.shape
if obj.shape is not None and hasattr(obj.shape, "wrapped"):
return obj.shape.wrapped
return obj.shape
# Non-OCCGeometryObject: return its shape if present, else None.
# (Use explicit identity/truth checks — some OCP TopoDS objects have a
# falsy __bool__, so ``obj.shape if obj.shape`` is unsafe.)
shape = getattr(obj, "shape", None)
return shape if shape is not None else None
def create_point(self, x: float, y: float) -> GeometryObject:
"""Create a 2D point."""
import cadquery as cq
point = cq.Vector(x, y, 0)
return OCCGeometryObject(point)
from OCP.gp import gp_Pnt
return OCCGeometryObject(gp_Pnt(x, y, 0))
def create_line(self, start: Point2D, end: Point2D) -> GeometryObject:
"""Create a 2D line segment."""
import cadquery as cq
from OCP.gp import gp_Pnt
from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeEdge
wp = cq.Workplane("XY").moveTo(start.x, start.y).lineTo(end.x, end.y)
obj = OCCGeometryObject(wp.val(), {"type": "line"})
obj._cadquery_obj = wp
return obj
edge = BRepBuilderAPI_MakeEdge(
gp_Pnt(start.x, start.y, 0), gp_Pnt(end.x, end.y, 0)
).Edge()
return OCCGeometryObject(edge, {"type": "line"})
def create_circle(self, center: Point2D, radius: float) -> GeometryObject:
"""Create a 2D circle."""
import cadquery as cq
from OCP.gp import gp_Pnt, gp_Dir, gp_Ax2, gp_Circ
from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeEdge
wp = cq.Workplane("XY").center(center.x, center.y).circle(radius)
obj = OCCGeometryObject(wp.val(), {"type": "circle"})
obj._cadquery_obj = wp
return obj
circ = gp_Circ(
gp_Ax2(gp_Pnt(center.x, center.y, 0), gp_Dir(0, 0, 1)), radius
)
edge = BRepBuilderAPI_MakeEdge(circ).Edge()
return OCCGeometryObject(edge, {"type": "circle"})
def create_arc(
self, center: Point2D, radius: float, start_angle: float, end_angle: float
) -> GeometryObject:
"""Create a 2D arc."""
import cadquery as cq
import math
from OCP.gp import gp_Pnt, gp_Dir, gp_Ax2, gp_Circ
from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeEdge
start_rad = math.radians(start_angle)
end_rad = math.radians(end_angle)
start_x = center.x + radius * math.cos(start_rad)
start_y = center.y + radius * math.sin(start_rad)
wire = (
cq.Workplane("XY")
.moveTo(start_x, start_y)
.radiusArc(
(center.x + radius * math.cos(end_rad), center.y + radius * math.sin(end_rad)),
radius,
)
circ = gp_Circ(
gp_Ax2(gp_Pnt(center.x, center.y, 0), gp_Dir(0, 0, 1)), radius
)
return OCCGeometryObject(wire.val(), {"type": "arc"})
edge = BRepBuilderAPI_MakeEdge(circ, start_rad, end_rad).Edge()
return OCCGeometryObject(edge, {"type": "arc"})
def create_polygon(self, points: List[Point2D]) -> GeometryObject:
"""Create a closed polygon from points."""
import cadquery as cq
from OCP.gp import gp_Pnt
from OCP.BRepBuilderAPI import BRepBuilderAPI_MakePolygon
if len(points) < 3:
raise ValueError("Polygon requires at least 3 points")
wp = cq.Workplane("XY").moveTo(points[0].x, points[0].y)
for pt in points[1:]:
wp = wp.lineTo(pt.x, pt.y)
wp = wp.close()
obj = OCCGeometryObject(wp.val(), {"type": "polygon"})
obj._cadquery_obj = wp
return obj
mp = BRepBuilderAPI_MakePolygon()
for pt in points:
mp.Add(gp_Pnt(pt.x, pt.y, 0))
mp.Close()
return OCCGeometryObject(mp.Wire(), {"type": "polygon"})
def create_rectangle(
self, width: float, height: float, center: Optional[Point2D] = None
) -> GeometryObject:
"""Create a rectangle."""
import cadquery as cq
from OCP.gp import gp_Pnt
from OCP.BRepBuilderAPI import BRepBuilderAPI_MakePolygon
cx = center.x if center else 0
cy = center.y if center else 0
wp = cq.Workplane("XY").center(cx, cy).rect(width, height)
obj = OCCGeometryObject(wp.val(), {"type": "rectangle"})
obj._cadquery_obj = wp
return obj
hw = width / 2.0
hh = height / 2.0
mp = BRepBuilderAPI_MakePolygon()
mp.Add(gp_Pnt(cx - hw, cy - hh, 0))
mp.Add(gp_Pnt(cx + hw, cy - hh, 0))
mp.Add(gp_Pnt(cx + hw, cy + hh, 0))
mp.Add(gp_Pnt(cx - hw, cy + hh, 0))
mp.Close()
return OCCGeometryObject(mp.Wire(), {"type": "rectangle"})
def extrude(
self,
@@ -157,24 +136,109 @@ class OCGeometryKernel(GeometryKernel):
direction: Tuple[float, float, float] = (0, 0, 1),
symmetric: bool = False,
) -> GeometryObject:
"""Extrude a 2D sketch into a 3D solid."""
import cadquery as cq
"""Extrude a sketch face into a 3D solid along the sketch plane normal.
cq_obj = self._get_cq_obj(sketch)
The sketch's plane normal is read from ``sketch.metadata["normal"]``
(set by ``OCCSketch.build_face_geometry``); it defaults to +Z for
legacy objects that don't carry one. *direction* is accepted for API
compatibility but ignored — the plane normal is authoritative. A
negative *height* extrudes against the normal.
"""
from OCP.gp import gp_Vec
from OCP.BRepPrimAPI import BRepPrimAPI_MakePrism
from OCP.BRepAlgoAPI import BRepAlgoAPI_Fuse
from OCP.TopoDS import TopoDS_Shape
if isinstance(cq_obj, cq.Workplane):
if symmetric:
solid = cq_obj.extrude(height / 2, both=True)
else:
solid = cq_obj.extrude(height)
# Defensive: figure out the actual shape from whatever the caller
# hands us, and surface a clear error if we can't get one.
if isinstance(sketch, OCCGeometryObject):
face = self._get_shape(sketch)
elif isinstance(sketch, TopoDS_Shape):
face = sketch
else:
wp = cq.Workplane("XY").add(cq_obj)
if symmetric:
solid = wp.extrude(height / 2, both=True)
else:
solid = wp.extrude(height)
face = self._get_shape(sketch)
if face is None:
raise ValueError(
"Cannot extrude: sketch has no geometry. "
"Draw a closed profile before extruding."
)
# If the wrapper class itself leaked through somehow, surface a
# clear error instead of letting BRepPrimAPI_MakePrism raise an
# opaque TypeError.
if isinstance(face, OCCGeometryObject):
raise ValueError(
"Cannot extrude: sketch geometry is a wrapper, not a shape. "
"This is a bug — please report it."
)
# ``face`` may be a TopoDS_Face (new path) or a compound/wire.
# If it's not already a face, build one.
face = self._ensure_face(face)
if face is None:
raise ValueError(
"Cannot extrude: sketch geometry is not a valid face. "
"Ensure the profile is closed (no open ends)."
)
return OCCGeometryObject(solid, {"type": "extrusion"})
normal = self._sketch_normal(sketch)
nx, ny, nz = normal
def _prism(h: float):
vec = gp_Vec(nx * h, ny * h, nz * h)
maker = BRepPrimAPI_MakePrism(face, vec, False, True)
maker.Build()
return maker.Shape()
if symmetric:
half = height / 2.0
pos = _prism(half)
neg = _prism(-half)
fuse = BRepAlgoAPI_Fuse(pos, neg)
fuse.Build()
solid = fuse.Shape()
else:
solid = _prism(height)
return OCCGeometryObject(solid, {"type": "extrusion", "normal": normal})
@staticmethod
def _sketch_normal(obj: GeometryObject) -> Tuple[float, float, float]:
"""Return the normal stored on a sketch-derived geometry object, else +Z."""
import numpy as np
meta = getattr(obj, "metadata", None) or {}
n = meta.get("normal")
if n is None:
return (0.0, 0.0, 1.0)
arr = np.asarray(n, dtype=float)
norm = float(np.linalg.norm(arr))
if norm < 1e-12:
return (0.0, 0.0, 1.0)
arr = arr / norm
return (float(arr[0]), float(arr[1]), float(arr[2]))
@staticmethod
def _ensure_face(shape: Any) -> Any:
"""Return a ``TopoDS_Face`` from *shape*, or *None* if impossible.
If *shape* is already a face, return it unchanged; otherwise try to
build a planar face from it (wire/edge/compound). Returns *None* for
empty/invalid input so callers can surface a clear error instead of
feeding a non-face to ``BRepPrimAPI_MakePrism``.
"""
from OCP.TopoDS import TopoDS_Face
from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeFace
if shape is None:
return None
if isinstance(shape, TopoDS_Face):
return shape
try:
maker = BRepBuilderAPI_MakeFace(shape, True)
maker.Build()
if maker.IsDone():
return maker.Face()
except Exception:
pass
return None
def revolve(
self,
@@ -183,65 +247,79 @@ class OCGeometryKernel(GeometryKernel):
axis: Tuple[float, float, float] = (0, 0, 1),
origin: Tuple[float, float, float] = (0, 0, 0),
) -> GeometryObject:
"""Revolve a 2D sketch around an axis."""
import cadquery as cq
"""Revolve a sketch face around an axis."""
import math
cq_obj = self._get_cq_obj(sketch)
# Get the OCC shape directly (a TopoDS_Face for new sketch geometry).
shape = self._get_shape(sketch)
face = self._ensure_face(shape)
if isinstance(cq_obj, cq.Workplane):
solid = cq_obj.revolve(angle)
else:
face = cq.Face.makeFromWires(cq_obj)
axis_vec = cq.Vector(*axis)
origin_vec = cq.Vector(*origin)
solid = face.revolve(axis_vec, origin_vec, angle)
from OCP.gp import gp_Ax1, gp_Pnt, gp_Dir
from OCP.BRepPrimAPI import BRepPrimAPI_MakeRevol
return OCCGeometryObject(solid, {"type": "revolution"})
# Revolve the face around the axis
revolve_axis = gp_Ax1(gp_Pnt(*origin), gp_Dir(*axis))
angle_rad = math.radians(angle)
revolver = BRepPrimAPI_MakeRevol(face, revolve_axis, angle_rad)
revolver.Build()
solid_shape = revolver.Shape()
return OCCGeometryObject(solid_shape, {"type": "revolution"})
def loft(self, profiles: List[GeometryObject], ruled: bool = False) -> GeometryObject:
"""Create a loft between multiple profiles."""
import cadquery as cq
from OCP.BRepOffsetAPI import BRepOffsetAPI_ThruSections
from OCP.TopExp import TopExp_Explorer
from OCP.TopAbs import TopAbs_WIRE
from OCP.TopoDS import TopoDS
if len(profiles) < 2:
raise ValueError("Loft requires at least 2 profiles")
wires = []
loft_maker = BRepOffsetAPI_ThruSections(True, ruled)
for profile in profiles:
cq_obj = self._get_cq_obj(profile)
if isinstance(cq_obj, cq.Workplane):
wires.append(cq_obj.val())
else:
wires.append(cq_obj)
shape = self._get_shape(profile)
explorer = TopExp_Explorer(shape, TopAbs_WIRE)
while explorer.More():
wire = TopoDS.Wire_s(explorer.Current())
loft_maker.AddWire(wire)
explorer.Next()
loft = cq.Solid.loft(wires, ruled)
return OCCGeometryObject(loft, {"type": "loft"})
loft_maker.Build()
solid = loft_maker.Shape()
return OCCGeometryObject(solid, {"type": "loft"})
def sweep(
self, profile: GeometryObject, path: GeometryObject, is_frenet: bool = False
) -> GeometryObject:
"""Sweep a profile along a path."""
import cadquery as cq
from OCP.BRepOffsetAPI import BRepOffsetAPI_MakePipeShell
from OCP.TopExp import TopExp_Explorer
from OCP.TopAbs import TopAbs_WIRE
from OCP.TopoDS import TopoDS
profile_obj = self._get_cq_obj(profile)
path_obj = self._get_cq_obj(path)
profile_shape = self._get_shape(profile)
path_shape = self._get_shape(path)
if isinstance(profile_obj, cq.Workplane):
profile_wire = profile_obj.val()
else:
profile_wire = profile_obj
def _first_wire(shape):
exp = TopExp_Explorer(shape, TopAbs_WIRE)
if exp.More():
return TopoDS.Wire_s(exp.Current())
raise ValueError("No wire found in shape for sweep")
if isinstance(path_obj, cq.Workplane):
path_wire = path_obj.val()
else:
path_wire = path_obj
profile_wire = _first_wire(profile_shape)
path_wire = _first_wire(path_shape)
solid = cq.Solid.sweep(profile_wire, path_wire, is_frenet)
pipe = BRepOffsetAPI_MakePipeShell(path_wire)
pipe.Add(profile_wire, False, False)
if is_frenet:
pipe.SetMode(True)
pipe.Build()
solid = pipe.Shape()
return OCCGeometryObject(solid, {"type": "sweep"})
def boolean_union(self, *bodies: GeometryObject) -> GeometryObject:
"""Union multiple bodies."""
import cadquery as cq
if len(bodies) < 2:
return bodies[0] if bodies else OCCGeometryObject(None)
@@ -254,12 +332,10 @@ class OCGeometryKernel(GeometryKernel):
fuse.Build()
result = fuse.Shape()
return OCCGeometryObject(cq.Shape(result), {"type": "union"})
return OCCGeometryObject(result, {"type": "union"})
def boolean_difference(self, base: GeometryObject, tool: GeometryObject) -> GeometryObject:
"""Subtract tool from base."""
import cadquery as cq
base_shape = self._get_shape(base)
tool_shape = self._get_shape(tool)
@@ -268,12 +344,10 @@ class OCGeometryKernel(GeometryKernel):
cut = BRepAlgoAPI_Cut(base_shape, tool_shape)
cut.Build()
return OCCGeometryObject(cq.Shape(cut.Shape()), {"type": "difference"})
return OCCGeometryObject(cut.Shape(), {"type": "difference"})
def boolean_intersection(self, body1: GeometryObject, body2: GeometryObject) -> GeometryObject:
"""Intersect two bodies."""
import cadquery as cq
shape1 = self._get_shape(body1)
shape2 = self._get_shape(body2)
@@ -282,27 +356,21 @@ class OCGeometryKernel(GeometryKernel):
common = BRepAlgoAPI_Common(shape1, shape2)
common.Build()
return OCCGeometryObject(cq.Shape(common.Shape()), {"type": "intersection"})
return OCCGeometryObject(common.Shape(), {"type": "intersection"})
def fillet(
self, body: GeometryObject, radius: float, edges: Optional[List[Any]] = None
) -> GeometryObject:
"""Apply fillet to edges."""
import cadquery as cq
shape = self._get_shape(body)
from OCP.BRepFilletAPI import BRepFilletAPI_MakeFillet
cq_obj = self._get_cq_obj(body)
fillet = BRepFilletAPI_MakeFillet(shape)
if isinstance(cq_obj, cq.Workplane):
if edges:
result = cq_obj.edges(edges).fillet(radius)
else:
result = cq_obj.edges().fillet(radius)
if edges:
for edge in edges:
fillet.Add(radius, edge)
else:
shape = self._get_shape(body)
from OCP.BRepFilletAPI import BRepFilletAPI_MakeFillet
fillet = BRepFilletAPI_MakeFillet(shape)
from OCP.TopExp import TopExp_Explorer
from OCP.TopAbs import TopAbs_EDGE
@@ -311,29 +379,22 @@ class OCGeometryKernel(GeometryKernel):
fillet.Add(radius, explorer.Current())
explorer.Next()
result = cq.Shape(fillet.Shape())
return OCCGeometryObject(result, {"type": "fillet"})
fillet.Build()
return OCCGeometryObject(fillet.Shape(), {"type": "fillet"})
def chamfer(
self, body: GeometryObject, size: float, edges: Optional[List[Any]] = None
) -> GeometryObject:
"""Apply chamfer to edges."""
import cadquery as cq
shape = self._get_shape(body)
from OCP.BRepFilletAPI import BRepFilletAPI_MakeChamfer
cq_obj = self._get_cq_obj(body)
chamfer = BRepFilletAPI_MakeChamfer(shape)
if isinstance(cq_obj, cq.Workplane):
if edges:
result = cq_obj.edges(edges).chamfer(size)
else:
result = cq_obj.edges().chamfer(size)
if edges:
for edge in edges:
chamfer.Add(size, edge)
else:
shape = self._get_shape(body)
from OCP.BRepFilletAPI import BRepFilletAPI_MakeChamfer
chamfer = BRepFilletAPI_MakeChamfer(shape)
from OCP.TopExp import TopExp_Explorer
from OCP.TopAbs import TopAbs_EDGE
@@ -342,71 +403,47 @@ class OCGeometryKernel(GeometryKernel):
chamfer.Add(size, explorer.Current())
explorer.Next()
result = cq.Shape(chamfer.Shape())
return OCCGeometryObject(result, {"type": "chamfer"})
chamfer.Build()
return OCCGeometryObject(chamfer.Shape(), {"type": "chamfer"})
def shell(
self, body: GeometryObject, thickness: float, faces_to_remove: Optional[List[Any]] = None
) -> GeometryObject:
"""Create a shell (hollow body)."""
import cadquery as cq
shape = self._get_shape(body)
from OCP.BRepOffsetAPI import BRepOffsetAPI_MakeThickSolid
from OCP.TopTools import TopTools_ListOfShape
cq_obj = self._get_cq_obj(body)
faces_list = TopTools_ListOfShape()
if faces_to_remove:
for face in faces_to_remove:
faces_list.Append(face)
if isinstance(cq_obj, cq.Workplane):
if faces_to_remove:
result = cq_obj.faces(faces_to_remove).shell(thickness)
else:
result = cq_obj.shell(thickness)
else:
shape = self._get_shape(body)
from OCP.BRepOffsetAPI import BRepOffsetAPI_MakeThickSolid
from OCP.TopTools import TopTools_ListOfShape
faces_list = TopTools_ListOfShape()
if faces_to_remove:
for face in faces_to_remove:
faces_list.Append(face)
shell_maker = BRepOffsetAPI_MakeThickSolid()
shell_maker.MakeThickSolidByJoin(shape, faces_list, thickness, 0.001)
shell_maker.Build()
result = cq.Shape(shell_maker.Shape())
return OCCGeometryObject(result, {"type": "shell"})
shell_maker = BRepOffsetAPI_MakeThickSolid()
shell_maker.MakeThickSolidByJoin(shape, faces_list, thickness, 0.001)
shell_maker.Build()
return OCCGeometryObject(shell_maker.Shape(), {"type": "shell"})
def offset(self, face: GeometryObject, distance: float) -> GeometryObject:
"""Offset a face or surface."""
import cadquery as cq
shape = self._get_shape(face)
from OCP.BRepOffsetAPI import BRepOffsetAPI_MakeOffset
offset_maker = BRepOffsetAPI_MakeOffset(shape, False)
offset_maker.Perform(distance)
return OCCGeometryObject(cq.Shape(offset_maker.Shape()), {"type": "offset"})
return OCCGeometryObject(offset_maker.Shape(), {"type": "offset"})
def translate(self, body: GeometryObject, vector: Tuple[float, float, float]) -> GeometryObject:
"""Translate a body."""
import cadquery as cq
shape = self._get_shape(body)
from OCP.BRepBuilderAPI import BRepBuilderAPI_Transform
from OCP.gp import gp_Trsf, gp_Vec
cq_obj = self._get_cq_obj(body)
if isinstance(cq_obj, cq.Workplane):
result = cq_obj.translate(vector)
else:
shape = self._get_shape(body)
from OCP.BRepBuilderAPI import BRepBuilderAPI_Transform
from OCP.gp import gp_Trsf, gp_Vec
transform = gp_Trsf()
transform.SetTranslation(gp_Vec(*vector))
transformer = BRepBuilderAPI_Transform(shape, transform)
result = cq.Shape(transformer.Shape())
return OCCGeometryObject(result, {"type": "translated"})
transform = gp_Trsf()
transform.SetTranslation(gp_Vec(*vector))
transformer = BRepBuilderAPI_Transform(shape, transform)
return OCCGeometryObject(transformer.Shape(), {"type": "translated"})
def rotate(
self,
@@ -416,30 +453,19 @@ class OCGeometryKernel(GeometryKernel):
origin: Tuple[float, float, float] = (0, 0, 0),
) -> GeometryObject:
"""Rotate a body around an axis."""
import cadquery as cq
import math
shape = self._get_shape(body)
from OCP.BRepBuilderAPI import BRepBuilderAPI_Transform
from OCP.gp import gp_Trsf, gp_Ax1, gp_Pnt, gp_Dir
cq_obj = self._get_cq_obj(body)
if isinstance(cq_obj, cq.Workplane):
result = cq_obj.rotate(origin, axis, math.degrees(angle))
else:
shape = self._get_shape(body)
from OCP.BRepBuilderAPI import BRepBuilderAPI_Transform
from OCP.gp import gp_Trsf, gp_Ax1, gp_Pnt, gp_Dir, gp_Vec
ax1 = gp_Ax1(gp_Pnt(*origin), gp_Dir(*axis))
transform = gp_Trsf()
transform.SetRotation(ax1, angle)
transformer = BRepBuilderAPI_Transform(shape, transform)
result = cq.Shape(transformer.Shape())
return OCCGeometryObject(result, {"type": "rotated"})
ax1 = gp_Ax1(gp_Pnt(*origin), gp_Dir(*axis))
transform = gp_Trsf()
transform.SetRotation(ax1, angle)
transformer = BRepBuilderAPI_Transform(shape, transform)
return OCCGeometryObject(transformer.Shape(), {"type": "rotated"})
def scale(self, body: GeometryObject, factor: float) -> GeometryObject:
"""Scale a body uniformly."""
import cadquery as cq
shape = self._get_shape(body)
from OCP.BRepBuilderAPI import BRepBuilderAPI_Transform
from OCP.gp import gp_Trsf
@@ -448,7 +474,7 @@ class OCGeometryKernel(GeometryKernel):
transform.SetScale(factor)
transformer = BRepBuilderAPI_Transform(shape, transform)
return OCCGeometryObject(cq.Shape(transformer.Shape()), {"type": "scaled"})
return OCCGeometryObject(transformer.Shape(), {"type": "scaled"})
def mirror(
self,
@@ -457,8 +483,6 @@ class OCGeometryKernel(GeometryKernel):
plane_origin: Tuple[float, float, float] = (0, 0, 0),
) -> GeometryObject:
"""Mirror a body across a plane."""
import cadquery as cq
shape = self._get_shape(body)
from OCP.BRepBuilderAPI import BRepBuilderAPI_Transform
from OCP.gp import gp_Trsf, gp_Ax2, gp_Pnt, gp_Dir
@@ -468,19 +492,12 @@ class OCGeometryKernel(GeometryKernel):
transform.SetMirror(ax2)
transformer = BRepBuilderAPI_Transform(shape, transform)
return OCCGeometryObject(cq.Shape(transformer.Shape()), {"type": "mirrored"})
return OCCGeometryObject(transformer.Shape(), {"type": "mirrored"})
def export_step(self, body: GeometryObject, filepath: str, schema: str = "AP214") -> bool:
"""Export to STEP format."""
try:
import cadquery as cq
shape = self._get_shape(body)
if hasattr(shape, "exportStep"):
shape.exportStep(filepath)
return True
from OCP.STEPControl import STEPControl_Writer, STEPControl_AsIs
from OCP.Interface import Interface_Static
@@ -491,8 +508,7 @@ class OCGeometryKernel(GeometryKernel):
Interface_Static.SetCVal_s("write.step.schema", "AP203")
writer.Transfer(shape, STEPControl_AsIs)
writer.Write(filepath)
return True
return writer.Write(filepath)
except Exception as e:
print(f"STEP export error: {e}")
return False
@@ -500,18 +516,14 @@ class OCGeometryKernel(GeometryKernel):
def export_iges(self, body: GeometryObject, filepath: str) -> bool:
"""Export to IGES format."""
try:
import cadquery as cq
shape = self._get_shape(body)
from OCP.IGESControl import IGESControl_Writer
from OCP.Interface import Interface_Static
Interface_Static.SetCVal_s("write.iges.schema", "5.3")
writer = IGESControl_Writer()
writer.AddShape(shape)
writer.Write(filepath)
return True
return writer.Write(filepath)
except Exception as e:
print(f"IGES export error: {e}")
return False
@@ -521,14 +533,7 @@ class OCGeometryKernel(GeometryKernel):
) -> bool:
"""Export to STL format."""
try:
import cadquery as cq
shape = self._get_shape(body)
if hasattr(shape, "exportStl"):
shape.exportStl(filepath, tolerance)
return True
from OCP.StlAPI import StlAPI_Writer
from OCP.BRepMesh import BRepMesh_IncrementalMesh
@@ -537,23 +542,28 @@ class OCGeometryKernel(GeometryKernel):
writer = StlAPI_Writer()
writer.ASCIIMode = ascii_mode
writer.Write(shape, filepath)
return True
return writer.Write(shape, filepath)
except Exception as e:
print(f"STL export error: {e}")
return False
def import_step(self, filepath: str) -> GeometryObject:
"""Import from STEP format."""
import cadquery as cq
from OCP.STEPControl import STEPControl_Reader
from OCP.IFSelect import IFSelect_RetDone
result = cq.importers.importStep(filepath)
return OCCGeometryObject(result, {"type": "imported_step"})
reader = STEPControl_Reader()
status = reader.ReadFile(filepath)
if status != IFSelect_RetDone:
raise ValueError(f"Failed to read STEP file: {filepath}")
reader.TransferRoots()
shape = reader.OneShape()
return OCCGeometryObject(shape, {"type": "imported_step"})
def import_iges(self, filepath: str) -> GeometryObject:
"""Import from IGES format."""
import cadquery as cq
from OCP.IGESControl import IGESControl_Reader
from OCP.IFSelect import IFSelect_RetDone
@@ -566,26 +576,13 @@ class OCGeometryKernel(GeometryKernel):
reader.TransferRoots()
shape = reader.OneShape()
return OCCGeometryObject(cq.Shape(shape), {"type": "imported_iges"})
return OCCGeometryObject(shape, {"type": "imported_iges"})
def get_mesh(
self, body: GeometryObject, tolerance: float = 0.1
) -> Tuple[np.ndarray, np.ndarray]:
"""Get triangulated mesh for rendering."""
import cadquery as cq
cq_obj = self._get_cq_obj(body)
if isinstance(cq_obj, cq.Workplane):
shape = cq_obj.val()
if hasattr(shape, "wrapped"):
shape = shape.wrapped
else:
shape = self._get_shape(body)
if hasattr(shape, "tessellate"):
vertices, faces = shape.tessellate(tolerance)
return np.array(vertices), np.array(faces)
shape = self._get_shape(body)
from OCP.BRepMesh import BRepMesh_IncrementalMesh
from OCP.TopExp import TopExp_Explorer
@@ -593,7 +590,9 @@ class OCGeometryKernel(GeometryKernel):
from OCP.BRep import BRep_Tool
from OCP.TopLoc import TopLoc_Location
mesh = BRepMesh_IncrementalMesh(shape, tolerance, False, 0.5, False)
# Use finer angular deflection (0.15 rad ≈ 24 segments/circle) so
# curved surfaces like cylinders render smoothly instead of faceted.
mesh = BRepMesh_IncrementalMesh(shape, tolerance, False, 0.15, True)
mesh.Perform()
vertices_list: List[List[float]] = []
@@ -601,6 +600,7 @@ class OCGeometryKernel(GeometryKernel):
vertex_offset = 0
from OCP.TopoDS import TopoDS
from OCP.TopAbs import TopAbs_Orientation
explorer = TopExp_Explorer(shape, TopAbs_FACE)
while explorer.More():
@@ -615,15 +615,21 @@ class OCGeometryKernel(GeometryKernel):
vertices_list.append([p.X(), p.Y(), p.Z()])
n_triangles = triangulation.NbTriangles()
# REVERSED faces store triangle winding in the natural (surface)
# orientation — we must flip it so the computed normals point
# outward (away from solid interior). TopAbs_REVERSED = 1.
reverse_winding = face.Orientation() == TopAbs_Orientation.TopAbs_REVERSED
for i in range(1, n_triangles + 1):
tri = triangulation.Triangle(i)
faces_list.append(
[
tri.Value(1) - 1 + vertex_offset,
tri.Value(2) - 1 + vertex_offset,
tri.Value(3) - 1 + vertex_offset,
]
v0, v1, v2 = (
tri.Value(1) - 1 + vertex_offset,
tri.Value(2) - 1 + vertex_offset,
tri.Value(3) - 1 + vertex_offset,
)
if reverse_winding:
# Swap last two vertices to flip winding direction.
v1, v2 = v2, v1
faces_list.append([v0, v1, v2])
vertex_offset += n_vertices
@@ -633,8 +639,6 @@ class OCGeometryKernel(GeometryKernel):
def get_edges(self, body: GeometryObject) -> Tuple[np.ndarray, np.ndarray]:
"""Get edge wireframe for rendering."""
import cadquery as cq
shape = self._get_shape(body)
from OCP.TopExp import TopExp_Explorer
@@ -673,7 +677,8 @@ class OCGeometryKernel(GeometryKernel):
explorer = TopExp_Explorer(shape, TopAbs_EDGE)
while explorer.More():
edge = explorer.Current()
from OCP.TopoDS import TopoDS
edge = TopoDS.Edge_s(explorer.Current())
edge_points = discretize_edge(edge)
for i, pt in enumerate(edge_points):
@@ -688,8 +693,6 @@ class OCGeometryKernel(GeometryKernel):
def get_bounding_box(self, body: GeometryObject) -> Tuple[Point3D, Point3D]:
"""Get the bounding box of a body."""
import cadquery as cq
shape = self._get_shape(body)
from OCP.Bnd import Bnd_Box
@@ -704,8 +707,6 @@ class OCGeometryKernel(GeometryKernel):
def get_volume(self, body: GeometryObject) -> float:
"""Calculate the volume of a solid body."""
import cadquery as cq
shape = self._get_shape(body)
from OCP.GProp import GProp_GProps
@@ -718,8 +719,6 @@ class OCGeometryKernel(GeometryKernel):
def get_surface_area(self, body: GeometryObject) -> float:
"""Calculate the surface area of a body."""
import cadquery as cq
shape = self._get_shape(body)
from OCP.GProp import GProp_GProps
@@ -732,8 +731,6 @@ class OCGeometryKernel(GeometryKernel):
def get_center_of_mass(self, body: GeometryObject) -> Point3D:
"""Calculate the center of mass of a solid body."""
import cadquery as cq
shape = self._get_shape(body)
from OCP.GProp import GProp_GProps
File diff suppressed because it is too large Load Diff
+5
View File
@@ -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"]
+734
View File
@@ -0,0 +1,734 @@
"""Project save/load — ``.fluency`` ZIP files.
The on-disk format is a single ZIP archive:
project.json # project tree: components, sketches, bodies,
# workplanes, assemblies, connectors, view state
bodies/<id>.step # one STEP file per Body (BRep geometry)
sketches/<id>/meta.json # sketch entities + constraints (kept separately
# so a single huge sketch doesn't bloat the
# main project.json)
sketches/<id>/solved.step # the sketch's solved face geometry
Sketch constraint solving and 3D body geometry are both preserved by using
OpenCASCADE's native STEP exporter (which is lossless for BRep). Everything
else is JSON.
The :func:`save_project` function is the entry point used by the File menu.
The :func:`load_project` function returns a fully populated
:class:`fluency.models.Project` (with a fresh ``OCGeometryKernel``) and an
optional view-state dict that the main window can hand back to the renderer
to restore the camera.
"""
from __future__ import annotations
import json
import logging
import os
import shutil
import tempfile
import zipfile
from dataclasses import asdict, is_dataclass
from datetime import datetime
from typing import Any, Callable, Dict, List, Optional, Tuple
import numpy as np
from fluency.models.data_model import (
Assembly,
AssemblyComponent,
AssemblyConnection,
Body,
Component,
Connector,
Project,
Sketch,
Workplane,
)
from fluency.geometry_occ.kernel import OCCGeometryObject, OCGeometryKernel
from fluency.geometry_occ.sketch import OCCSketch
logger = logging.getLogger(__name__)
# ── JSON-friendly type coercion ─────────────────────────────────────────────
def _json_default(obj: Any) -> Any:
"""Default JSON encoder for numpy / dataclass / datetime values."""
if isinstance(obj, np.ndarray):
return obj.tolist()
if isinstance(obj, (datetime,)):
return obj.isoformat()
if isinstance(obj, (set, frozenset)):
return sorted(obj)
if isinstance(obj, tuple):
return list(obj)
if is_dataclass(obj):
return asdict(obj)
raise TypeError(f"Object of type {type(obj).__name__} is not JSON serializable")
def _to_json(data: Any) -> str:
return json.dumps(data, default=_json_default, indent=2, sort_keys=False)
def _coerce_listlike(value: Any) -> List[Any]:
"""Cast arrays / tuples / numpy arrays to plain lists for JSON friendliness."""
if value is None:
return []
if isinstance(value, np.ndarray):
return value.tolist()
if isinstance(value, (list, tuple)):
return [v for v in value]
return list(value)
def _to_3tuple(value: Any) -> Tuple[float, float, float]:
"""Coerce a saved 3-vector to a tuple of floats (for OCC)."""
if value is None:
return (0.0, 0.0, 0.0)
if isinstance(value, np.ndarray):
seq = value.tolist()
else:
seq = list(value)
if len(seq) < 3:
seq = list(seq) + [0.0] * (3 - len(seq))
return (float(seq[0]), float(seq[1]), float(seq[2]))
def _to_3vec(value: Any) -> np.ndarray:
"""Coerce a saved 3-vector to a 3-element numpy array."""
if isinstance(value, np.ndarray):
return value.astype(float).reshape(3)
if value is None:
return np.zeros(3, dtype=float)
seq = list(value)
if len(seq) < 3:
seq = list(seq) + [0.0] * (3 - len(seq))
return np.array([float(seq[0]), float(seq[1]), float(seq[2])], dtype=float)
def _to_mat3(value: Any) -> np.ndarray:
"""Coerce a saved 3×3 matrix (flat 9-list or nested) to np.ndarray."""
if isinstance(value, np.ndarray):
arr = value.astype(float)
return arr.reshape(3, 3)
if value is None:
return np.eye(3, dtype=float)
flat = list(np.asarray(value, dtype=float).flatten())
if len(flat) < 9:
flat = flat + [0.0] * (9 - len(flat))
return np.array(flat[:9], dtype=float).reshape(3, 3)
def _parse_iso(value: Optional[str]) -> datetime:
"""Parse an ISO-8601 timestamp, falling back to ``now`` on failure."""
if not value:
return datetime.now()
try:
return datetime.fromisoformat(value)
except (TypeError, ValueError):
return datetime.now()
# ── Model serialization (to_dict) ──────────────────────────────────────────
def _workplane_to_dict(wp: Workplane) -> Dict[str, Any]:
return {
"id": wp.id,
"name": wp.name,
"origin": list(wp.origin),
"normal": list(wp.normal),
"x_dir": list(wp.x_dir),
"visible": bool(wp.visible),
"created_at": wp.created_at.isoformat() if wp.created_at else None,
"modified_at": wp.modified_at.isoformat() if wp.modified_at else None,
}
def _workplane_from_dict(data: Dict[str, Any]) -> Workplane:
wp = Workplane(
id=data.get("id") or None, # Workplane generates uuid if None
name=data.get("name", "Untitled Workplane"),
origin=tuple(data.get("origin", (0.0, 0.0, 0.0))),
normal=tuple(data.get("normal", (0.0, 0.0, 1.0))),
x_dir=tuple(data.get("x_dir", (1.0, 0.0, 0.0))),
visible=bool(data.get("visible", True)),
)
wp.created_at = _parse_iso(data.get("created_at"))
wp.modified_at = _parse_iso(data.get("modified_at"))
return wp
def _body_to_dict(body: Body) -> Dict[str, Any]:
"""Body serialization. ``geometry_ref`` is set later by the ZIP writer
once the STEP file is written."""
return {
"id": body.id,
"name": body.name,
"source_sketch_id": body.source_sketch.id if body.source_sketch else None,
"source_operation": body.source_operation,
"extrude_length": body.extrude_length,
"extrude_symmetric": body.extrude_symmetric,
"extrude_invert": body.extrude_invert,
"extrude_cut": body.extrude_cut,
"extrude_union": body.extrude_union,
"extrude_through_all": body.extrude_through_all,
"extrude_face_index": body.extrude_face_index,
"extrude_target_body_id": body.extrude_target_body_id,
"position": _coerce_listlike(body.position),
"rotation": _coerce_listlike(body.rotation),
"color": list(body.color) if body.color else [0.2, 0.4, 0.8],
"opacity": float(body.opacity),
"visible": bool(body.visible),
"has_geometry": body.geometry is not None,
"geometry_ref": None, # filled in by save_project
"created_at": body.created_at.isoformat() if body.created_at else None,
"modified_at": body.modified_at.isoformat() if body.modified_at else None,
}
def _body_from_dict(
data: Dict[str, Any],
geometry_loader: Optional[Callable[[str], Optional[OCCGeometryObject]]] = None,
source_sketch: Optional[Sketch] = None,
) -> Body:
geometry: Optional[OCCGeometryObject] = None
if geometry_loader is not None and data.get("geometry_ref"):
geometry = geometry_loader(data["geometry_ref"]) if data.get("has_geometry") else None
body = Body(
id=data.get("id") or None,
name=data.get("name", "Untitled Body"),
geometry=geometry,
source_sketch=source_sketch,
source_operation=data.get("source_operation", "extrude"),
extrude_length=data.get("extrude_length"),
extrude_symmetric=bool(data.get("extrude_symmetric", False)),
extrude_invert=bool(data.get("extrude_invert", False)),
extrude_cut=bool(data.get("extrude_cut", False)),
extrude_union=bool(data.get("extrude_union", False)),
extrude_through_all=bool(data.get("extrude_through_all", False)),
extrude_face_index=data.get("extrude_face_index"),
extrude_target_body_id=data.get("extrude_target_body_id"),
position=_to_3vec(data.get("position")),
rotation=_to_mat3(data.get("rotation")),
color=tuple(data.get("color", [0.2, 0.4, 0.8])),
opacity=float(data.get("opacity", 1.0)),
visible=bool(data.get("visible", True)),
)
body.created_at = _parse_iso(data.get("created_at"))
body.modified_at = _parse_iso(data.get("modified_at"))
return body
def _sketch_to_dict(sketch: Sketch) -> Dict[str, Any]:
occ_dict: Optional[Dict[str, Any]] = None
if sketch.occ_sketch is not None and isinstance(sketch.occ_sketch, OCCSketch):
try:
occ_dict = sketch.occ_sketch.to_dict()
except Exception as exc:
logger.warning("Sketch %s occ_sketch.to_dict() failed: %s", sketch.id, exc)
return {
"id": sketch.id,
"name": sketch.name,
"workplane_origin": _coerce_listlike(sketch.workplane_origin),
"workplane_normal": _coerce_listlike(sketch.workplane_normal),
"workplane_x_dir": _coerce_listlike(sketch.workplane_x_dir),
"is_solved": bool(sketch.is_solved),
"is_fully_constrained": bool(sketch.is_fully_constrained),
"occ_sketch": occ_dict,
"has_geometry": sketch.geometry is not None,
"geometry_ref": None, # filled in by save_project
"created_at": sketch.created_at.isoformat() if sketch.created_at else None,
"modified_at": sketch.modified_at.isoformat() if sketch.modified_at else None,
}
def _sketch_from_dict(
data: Dict[str, Any],
geometry_loader: Optional[Callable[[str], Optional[OCCGeometryObject]]] = None,
) -> Sketch:
occ_dict = data.get("occ_sketch")
occ_sketch: Optional[OCCSketch] = None
if occ_dict is not None:
try:
occ_sketch = OCCSketch.from_dict(occ_dict)
except Exception as exc:
logger.warning("Sketch %s OCCSketch.from_dict() failed: %s", data.get("id"), exc)
occ_sketch = OCCSketch()
else:
occ_sketch = OCCSketch()
# Re-apply the workplane (from_dict already does this internally, but be
# defensive in case the saved dict didn't carry the workplane fields).
occ_sketch.set_workplane(
tuple(data.get("workplane_origin", (0.0, 0.0, 0.0))),
tuple(data.get("workplane_normal", (0.0, 0.0, 1.0))),
tuple(data.get("workplane_x_dir", (1.0, 0.0, 0.0))),
)
geometry: Optional[OCCGeometryObject] = None
if geometry_loader is not None and data.get("geometry_ref"):
geometry = geometry_loader(data["geometry_ref"]) if data.get("has_geometry") else None
sk = Sketch(
id=data.get("id") or None,
name=data.get("name", "Untitled Sketch"),
occ_sketch=occ_sketch,
geometry=geometry,
is_solved=bool(data.get("is_solved", False)),
is_fully_constrained=bool(data.get("is_fully_constrained", False)),
)
sk.workplane_origin = _to_3vec(data.get("workplane_origin"))
sk.workplane_normal = _to_3vec(data.get("workplane_normal"))
sk.workplane_x_dir = _to_3vec(data.get("workplane_x_dir"))
sk.apply_workplane()
sk.created_at = _parse_iso(data.get("created_at"))
sk.modified_at = _parse_iso(data.get("modified_at"))
return sk
def _component_to_dict(comp: Component) -> Dict[str, Any]:
return {
"id": comp.id,
"name": comp.name,
"description": comp.description,
"active_sketch": comp.active_sketch,
"active_workplane": comp.active_workplane,
"sketches": {sid: _sketch_to_dict(s) for sid, s in comp.sketches.items()},
"bodies": {bid: _body_to_dict(b) for bid, b in comp.bodies.items()},
"workplanes": {wid: _workplane_to_dict(w) for wid, w in comp.workplanes.items()},
"created_at": comp.created_at.isoformat() if comp.created_at else None,
"modified_at": comp.modified_at.isoformat() if comp.modified_at else None,
}
def _component_from_dict(
data: Dict[str, Any],
body_geometry_loader: Optional[Callable[[str], Optional[OCCGeometryObject]]] = None,
sketch_geometry_loader: Optional[Callable[[str], Optional[OCCGeometryObject]]] = None,
) -> Component:
comp = Component(
id=data.get("id") or None,
name=data.get("name", "Untitled Component"),
description=data.get("description", ""),
active_sketch=data.get("active_sketch"),
active_workplane=data.get("active_workplane"),
)
comp.created_at = _parse_iso(data.get("created_at"))
comp.modified_at = _parse_iso(data.get("modified_at"))
for wid, wp_data in (data.get("workplanes") or {}).items():
comp.workplanes[wid] = _workplane_from_dict(wp_data)
# Sketches first so bodies can reference them.
for sid, sk_data in (data.get("sketches") or {}).items():
comp.sketches[sid] = _sketch_from_dict(sk_data, sketch_geometry_loader)
for bid, body_data in (data.get("bodies") or {}).items():
src_sketch = None
src_id = body_data.get("source_sketch_id")
if src_id and src_id in comp.sketches:
src_sketch = comp.sketches[src_id]
comp.bodies[bid] = _body_from_dict(body_data, body_geometry_loader, src_sketch)
return comp
def _connector_to_dict(conn: Connector) -> Dict[str, Any]:
return {
"id": conn.id,
"name": conn.name,
"position": list(conn.position),
"normal": list(conn.normal),
"x_dir": list(conn.x_dir),
"axis_rotation": float(conn.axis_rotation),
"offset": float(conn.offset),
"assembly_component_id": conn.assembly_component_id,
"source_obj_id": conn.source_obj_id,
"partner_ac_id": conn.partner_ac_id,
"partner_connector_id": conn.partner_connector_id,
"is_grounded": bool(conn.is_grounded),
"is_invalid": bool(conn.is_invalid),
"created_at": conn.created_at.isoformat() if conn.created_at else None,
"modified_at": conn.modified_at.isoformat() if conn.modified_at else None,
}
def _connector_from_dict(data: Dict[str, Any]) -> Connector:
conn = Connector(
id=data.get("id") or None,
name=data.get("name", "Untitled Connector"),
position=_to_3tuple(data.get("position")),
normal=_to_3tuple(data.get("normal")),
x_dir=_to_3tuple(data.get("x_dir")),
axis_rotation=float(data.get("axis_rotation", 0.0)),
offset=float(data.get("offset", 0.0)),
assembly_component_id=data.get("assembly_component_id", ""),
source_obj_id=data.get("source_obj_id", ""),
)
conn.partner_ac_id = data.get("partner_ac_id")
conn.partner_connector_id = data.get("partner_connector_id")
conn.is_grounded = bool(data.get("is_grounded", False))
conn.is_invalid = bool(data.get("is_invalid", False))
conn.created_at = _parse_iso(data.get("created_at"))
conn.modified_at = _parse_iso(data.get("modified_at"))
return conn
def _assembly_component_to_dict(ac: AssemblyComponent) -> Dict[str, Any]:
return {
"id": ac.id,
"component_id": ac.component_id,
"name": ac.name,
"position": _coerce_listlike(ac.position),
"rotation": _coerce_listlike(ac.rotation),
"connectors": {cid: _connector_to_dict(c) for cid, c in ac.connectors.items()},
"created_at": ac.created_at.isoformat() if ac.created_at else None,
"modified_at": ac.modified_at.isoformat() if ac.modified_at else None,
}
def _assembly_component_from_dict(data: Dict[str, Any]) -> AssemblyComponent:
ac = AssemblyComponent(
id=data.get("id") or None,
component_id=data.get("component_id", ""),
name=data.get("name", "Untitled Instance"),
position=_to_3vec(data.get("position")),
rotation=_to_mat3(data.get("rotation")),
)
ac.created_at = _parse_iso(data.get("created_at"))
ac.modified_at = _parse_iso(data.get("modified_at"))
for cid, c_data in (data.get("connectors") or {}).items():
ac.connectors[cid] = _connector_from_dict(c_data)
return ac
def _assembly_connection_to_dict(c: AssemblyConnection) -> Dict[str, Any]:
return {
"id": c.id,
"first_ac_id": c.first_ac_id,
"second_ac_id": c.second_ac_id,
"first_connector_id": c.first_connector_id,
"second_connector_id": c.second_connector_id,
"created_at": c.created_at.isoformat() if c.created_at else None,
}
def _assembly_connection_from_dict(data: Dict[str, Any]) -> AssemblyConnection:
conn = AssemblyConnection(
id=data.get("id") or None,
first_ac_id=data.get("first_ac_id", ""),
second_ac_id=data.get("second_ac_id", ""),
first_connector_id=data.get("first_connector_id"),
second_connector_id=data.get("second_connector_id"),
)
conn.created_at = _parse_iso(data.get("created_at"))
return conn
def _assembly_to_dict(asm: Assembly) -> Dict[str, Any]:
return {
"id": asm.id,
"name": asm.name,
"active_assembly_component": asm.active_assembly_component,
"components": {cid: _assembly_component_to_dict(ac) for cid, ac in asm.components.items()},
"connections": [_assembly_connection_to_dict(c) for c in asm.connections],
"created_at": asm.created_at.isoformat() if asm.created_at else None,
"modified_at": asm.modified_at.isoformat() if asm.modified_at else None,
}
def _assembly_from_dict(data: Dict[str, Any]) -> Assembly:
asm = Assembly(
id=data.get("id") or None,
name=data.get("name", "Untitled Assembly"),
active_assembly_component=data.get("active_assembly_component"),
)
asm.created_at = _parse_iso(data.get("created_at"))
asm.modified_at = _parse_iso(data.get("modified_at"))
for cid, ac_data in (data.get("components") or {}).items():
asm.components[cid] = _assembly_component_from_dict(ac_data)
for c_data in (data.get("connections") or []):
asm.connections.append(_assembly_connection_from_dict(c_data))
return asm
def _project_to_dict(
project: Project,
view_state: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
return {
"format_version": 1,
"name": project.name,
"description": project.description,
"active_component": project.active_component,
"active_assembly": project.active_assembly,
"components": {cid: _component_to_dict(c) for cid, c in project.components.items()},
"assemblies": {aid: _assembly_to_dict(a) for aid, a in project.assemblies.items()},
"created_at": project.created_at.isoformat() if project.created_at else None,
"modified_at": project.modified_at.isoformat() if project.modified_at else None,
"view_state": view_state or {},
}
# ── Geometry (STEP) write/read helpers ─────────────────────────────────────
def _write_step_for_body(
kernel: OCGeometryKernel,
geometry: OCCGeometryObject,
) -> Optional[bytes]:
"""Serialize a single body geometry to a STEP byte string.
Returns *None* if OCC reports the shape is empty (so the ZIP can omit
the file and the body is restored as geometry-less). The temporary
file is created and immediately deleted; we never touch the user's
filesystem outside of ``tempfile``.
"""
fd, tmp_path = tempfile.mkstemp(suffix=".step")
os.close(fd)
try:
ok = kernel.export_step(geometry, tmp_path)
if not ok:
return None
with open(tmp_path, "rb") as f:
return f.read()
finally:
try:
os.unlink(tmp_path)
except OSError:
pass
def _read_step_bytes(
kernel: OCGeometryKernel,
data: bytes,
) -> Optional[OCCGeometryObject]:
"""Parse a STEP byte string back into an OCCGeometryObject."""
fd, tmp_path = tempfile.mkstemp(suffix=".step")
os.close(fd)
try:
with open(tmp_path, "wb") as f:
f.write(data)
geom = kernel.import_step(tmp_path)
return geom
except Exception as exc:
logger.warning("Failed to read STEP: %s", exc)
return None
finally:
try:
os.unlink(tmp_path)
except OSError:
pass
# ── Save / Load entry points ───────────────────────────────────────────────
def project_zip_path(path: str) -> str:
"""Return *path* with the ``.fluency`` extension added if missing."""
base, ext = os.path.splitext(path)
if ext.lower() == ".fluency":
return path
return base + ".fluency"
def save_project(
project: Project,
filepath: str,
view_state: Optional[Dict[str, Any]] = None,
kernel: Optional[OCGeometryKernel] = None,
) -> str:
"""Save *project* to a ``.fluency`` ZIP at *filepath*.
*view_state* (optional) is a free-form dict that the main window uses to
record camera position, active tab, etc. It is stored verbatim inside
``project.json`` under the ``view_state`` key.
*kernel* is the OCGeometryKernel to use for STEP export. A new one is
created if not provided (slightly slower startup, but never holds stale
state). Pass the app's kernel to keep one canonical instance.
Returns the actual file path that was written.
"""
filepath = project_zip_path(filepath)
kernel = kernel or OCGeometryKernel()
# Build the manifest in two passes:
# pass 1: serialize all metadata + collect body/sketches that need
# STEP files written alongside. We track the in-zip path of
# each STEP file in the body/sketches' ``geometry_ref`` slot.
# pass 2: write the ZIP, streaming each body/sketches's STEP data
# into its own archive member.
manifest = _project_to_dict(project, view_state)
# Per-body STEP files. Skipped if the body has no geometry.
body_files: List[Tuple[str, bytes]] = []
for comp_id, comp in project.components.items():
for body_id, body in comp.bodies.items():
if body.geometry is None:
continue
step_bytes = _write_step_for_body(kernel, body.geometry)
if step_bytes is None:
continue
arcname = f"bodies/{body_id}.step"
body_files.append((arcname, step_bytes))
manifest["components"][comp_id]["bodies"][body_id]["geometry_ref"] = arcname
# Per-sketch STEP files (solved face geometry).
sketch_files: List[Tuple[str, bytes]] = []
sketch_meta_files: List[Tuple[str, bytes]] = []
for comp_id, comp in project.components.items():
for sketch_id, sketch in comp.sketches.items():
# Save the OCCSketch state to its own JSON file so the
# main project.json stays compact.
occ = sketch.occ_sketch.to_dict() if sketch.occ_sketch is not None else None
meta = {
"id": sketch.id,
"name": sketch.name,
"workplane_origin": _coerce_listlike(sketch.workplane_origin),
"workplane_normal": _coerce_listlike(sketch.workplane_normal),
"workplane_x_dir": _coerce_listlike(sketch.workplane_x_dir),
"is_solved": bool(sketch.is_solved),
"is_fully_constrained": bool(sketch.is_fully_constrained),
"occ_sketch": occ,
}
meta_arc = f"sketches/{sketch_id}/meta.json"
sketch_meta_files.append((meta_arc, _to_json(meta).encode("utf-8")))
# Drop the heavy occ_sketch payload from the main manifest so
# the file is smaller and edits are localised.
manifest["components"][comp_id]["sketches"][sketch_id]["occ_sketch"] = None
manifest["components"][comp_id]["sketches"][sketch_id]["occ_sketch_ref"] = meta_arc
if sketch.geometry is None:
continue
step_bytes = _write_step_for_body(kernel, sketch.geometry)
if step_bytes is None:
continue
arcname = f"sketches/{sketch_id}/solved.step"
sketch_files.append((arcname, step_bytes))
manifest["components"][comp_id]["sketches"][sketch_id]["geometry_ref"] = arcname
# Write the ZIP. Use a temp file + rename so a partial write can't
# clobber an existing good file.
tmp_fd, tmp_path = tempfile.mkstemp(suffix=".fluency")
os.close(tmp_fd)
try:
with zipfile.ZipFile(tmp_path, "w", compression=zipfile.ZIP_DEFLATED) as zf:
zf.writestr("project.json", _to_json(manifest))
for arcname, data in body_files + sketch_files + sketch_meta_files:
zf.writestr(arcname, data)
# Atomic-ish replace.
shutil.move(tmp_path, filepath)
except Exception:
try:
os.unlink(tmp_path)
except OSError:
pass
raise
return filepath
def load_project(filepath: str) -> Tuple[Project, Dict[str, Any]]:
"""Load a project from a ``.fluency`` ZIP.
Returns ``(project, view_state)``. The caller is responsible for
handing *view_state* to the renderer (camera, etc.) and for re-rendering
the scene with the freshly-loaded bodies.
"""
if not os.path.exists(filepath):
raise FileNotFoundError(filepath)
kernel = OCGeometryKernel()
body_cache: Dict[str, Optional[OCCGeometryObject]] = {}
# Body geometry can be reused across bodies if the same STEP appears
# under multiple names (rare, but cheap to handle). We cache by zip
# member name.
def body_geometry_loader(member_name: str) -> Optional[OCCGeometryObject]:
if member_name in body_cache:
return body_cache[member_name]
try:
data = zipf.read(member_name)
except KeyError:
logger.warning("Body STEP missing in archive: %s", member_name)
body_cache[member_name] = None
return None
geom = _read_step_bytes(kernel, data)
body_cache[member_name] = geom
return geom
# Sketch geometry loader shares the same byte path. Sketches that have
# solved faces point at sketches/<id>/solved.step.
def sketch_geometry_loader(member_name: str) -> Optional[OCCGeometryObject]:
return body_geometry_loader(member_name)
with zipfile.ZipFile(filepath, "r") as zipf:
manifest_raw = zipf.read("project.json")
manifest = json.loads(manifest_raw.decode("utf-8"))
view_state: Dict[str, Any] = manifest.get("view_state") or {}
# If a sketch's occ_sketch is referenced as a separate file, read
# it in now and patch the manifest so _sketch_from_dict sees it.
for comp_id, comp_data in (manifest.get("components") or {}).items():
for sk_id, sk_data in (comp_data.get("sketches") or {}).items():
ref = sk_data.get("occ_sketch_ref")
if not ref:
continue
try:
meta_bytes = zipf.read(ref)
except KeyError:
logger.warning("Sketch meta missing in archive: %s", ref)
continue
meta = json.loads(meta_bytes.decode("utf-8"))
sk_data["occ_sketch"] = meta.get("occ_sketch")
# Workplane fields on the sketch-level file override the
# embedded ones (source of truth lives in the sidecar).
for k in ("workplane_origin", "workplane_normal", "workplane_x_dir",
"is_solved", "is_fully_constrained"):
if k in meta:
sk_data[k] = meta[k]
project = Project(
name=manifest.get("name", "Untitled Project"),
description=manifest.get("description", ""),
active_component=manifest.get("active_component"),
active_assembly=manifest.get("active_assembly"),
kernel=kernel,
)
project.file_path = filepath
project.created_at = _parse_iso(manifest.get("created_at"))
project.modified_at = _parse_iso(manifest.get("modified_at"))
for cid, c_data in (manifest.get("components") or {}).items():
project.components[cid] = _component_from_dict(
c_data,
body_geometry_loader=body_geometry_loader,
sketch_geometry_loader=sketch_geometry_loader,
)
for aid, a_data in (manifest.get("assemblies") or {}).items():
project.assemblies[aid] = _assembly_from_dict(a_data)
# After all components are loaded, re-wire connector partner ids so
# they point to the freshly-loaded AssemblyComponents. (The dict
# round-trip preserves the raw strings; we just make sure the partner
# ids are still present in the project so the assembly-move handler
# can follow the rigid-group graph.)
for asm in project.assemblies.values():
for conn in asm.connections:
if conn.first_connector_id and conn.first_ac_id in asm.components:
first_ac = asm.components[conn.first_ac_id]
if conn.first_connector_id in first_ac.connectors:
first_ac.connectors[conn.first_connector_id].is_grounded = True
if conn.second_connector_id and conn.second_ac_id in asm.components:
pass # already set in the connector itself
return project, view_state
+42 -1608
View File
File diff suppressed because it is too large Load Diff
+464 -1
View File
@@ -6,7 +6,7 @@ including projects, components, sketches, and bodies.
"""
from dataclasses import dataclass, field
from typing import Dict, List, Optional, Any
from typing import Dict, List, Optional, Any, Tuple
from datetime import datetime
import uuid
import numpy as np
@@ -21,6 +21,76 @@ from fluency.geometry_occ.kernel import OCGeometryKernel, OCCGeometryObject
from fluency.geometry_occ.sketch import OCCSketch
@dataclass
class Workplane:
"""
An independent working plane (datum plane) not tied to a face.
Workplanes can be created at any time and serve as the foundation for
sketching and subsequent 3D operations (extrude, cut, revolve, etc.).
They are visible in the 3D view as a semi-transparent reference grid.
"""
id: str = field(default_factory=lambda: str(uuid.uuid4()))
name: str = "Untitled Workplane"
origin: Tuple[float, float, float] = (0.0, 0.0, 0.0)
normal: Tuple[float, float, float] = (0.0, 0.0, 1.0)
x_dir: Tuple[float, float, float] = (1.0, 0.0, 0.0)
# OCC AIS shape (visual plane) object id in the renderer
render_object: Any = None
visible: bool = True
created_at: datetime = field(default_factory=datetime.now)
modified_at: datetime = field(default_factory=datetime.now)
def __post_init__(self):
# Normalise normal and x_dir on construction.
import numpy as np
n = np.asarray(self.normal, dtype=float)
n = n / np.linalg.norm(n)
x = np.asarray(self.x_dir, dtype=float)
# Remove any component of x along n, then renormalise.
x = x - np.dot(x, n) * n
x_norm = np.linalg.norm(x)
if x_norm < 1e-9:
fallback = np.array([1.0, 0.0, 0.0]) if abs(n[0]) < 0.9 else np.array([0.0, 1.0, 0.0])
x = fallback - np.dot(fallback, n) * n
x_norm = np.linalg.norm(x)
x = x / x_norm
y = np.cross(n, x)
y = y / np.linalg.norm(y)
self.normal = tuple(float(v) for v in n)
self.x_dir = tuple(float(v) for v in x)
self._y_dir = tuple(float(v) for v in y)
@property
def y_dir(self) -> Tuple[float, float, float]:
"""Derived in-plane Y axis (normal × x_dir)."""
return self._y_dir
def uv_to_world(self, u: float, v: float) -> Tuple[float, float, float]:
"""Map a UV point to 3D world coordinates on this plane."""
ox, oy, oz = self.origin
xx, xy, xz = self.x_dir
yx, yy, yz = self._y_dir
return (
ox + u * xx + v * yx,
oy + u * xy + v * yy,
oz + u * xz + v * yz,
)
def world_to_uv(self, p: Tuple[float, float, float]) -> Tuple[float, float]:
"""Map a 3D world point to UV coordinates on this plane."""
import numpy as np
ox, oy, oz = self.origin
v = np.array([p[0] - ox, p[1] - oy, p[2] - oz])
xd = np.array(self.x_dir, dtype=float)
yd = np.array(self._y_dir, dtype=float)
return (float(np.dot(v, xd)), float(np.dot(v, yd)))
@dataclass
class Sketch:
"""
@@ -46,6 +116,32 @@ class Sketch:
created_at: datetime = field(default_factory=datetime.now)
modified_at: datetime = field(default_factory=datetime.now)
def set_workplane(
self,
origin: Tuple[float, float, float],
normal: Tuple[float, float, float],
x_dir: Tuple[float, float, float],
) -> None:
"""Set this sketch's 3D workplane and sync it to the OCC sketch.
Call this when the sketch is placed on a face/datum plane. UV
coordinates are unchanged; only their world mapping moves.
"""
self.workplane_origin = np.asarray(origin, dtype=float)
self.workplane_normal = np.asarray(normal, dtype=float)
self.workplane_x_dir = np.asarray(x_dir, dtype=float)
self.apply_workplane()
self.modified_at = datetime.now()
def apply_workplane(self) -> None:
"""Push the stored workplane fields into the underlying OCCSketch."""
if self.occ_sketch is not None:
self.occ_sketch.set_workplane(
tuple(self.workplane_origin.tolist()),
tuple(self.workplane_normal.tolist()),
tuple(self.workplane_x_dir.tolist()),
)
def add_point(self, x: float, y: float) -> Any:
"""Add a point to the sketch."""
self.modified_at = datetime.now()
@@ -126,6 +222,20 @@ class Body:
source_sketch: Optional[Sketch] = None
source_operation: str = "extrude"
# Re-extrusion parameters — stored so the body can be rebuilt from
# its source sketch when the sketch is edited. None means the body
# was not created by an extrude-type operation and cannot be auto-
# rebuilt.
extrude_length: Optional[float] = None
extrude_symmetric: bool = False
extrude_invert: bool = False
extrude_cut: bool = False
extrude_union: bool = False
extrude_through_all: bool = False
extrude_face_index: Optional[int] = None # which sketch face was selected
extrude_target_body_id: Optional[str] = None # for cut/union: target body id
needs_update: bool = False # True when source sketch changed since last extrude
position: np.ndarray = field(default_factory=lambda: np.array([0.0, 0.0, 0.0]))
rotation: np.ndarray = field(default_factory=lambda: np.eye(3))
@@ -166,12 +276,34 @@ class Component:
sketches: Dict[str, Sketch] = field(default_factory=dict)
bodies: Dict[str, Body] = field(default_factory=dict)
workplanes: Dict[str, Workplane] = field(default_factory=dict)
active_sketch: Optional[str] = None
active_workplane: Optional[str] = None
created_at: datetime = field(default_factory=datetime.now)
modified_at: datetime = field(default_factory=datetime.now)
def add_workplane(self, workplane: Optional[Workplane] = None) -> Workplane:
"""Add an independent workplane to the component."""
if workplane is None:
workplane = Workplane(name=f"Workplane {len(self.workplanes) + 1}")
self.workplanes[workplane.id] = workplane
if self.active_workplane is None:
self.active_workplane = workplane.id
self.modified_at = datetime.now()
return workplane
def remove_workplane(self, wp_id: str) -> bool:
"""Remove a workplane from the component."""
if wp_id in self.workplanes:
del self.workplanes[wp_id]
if self.active_workplane == wp_id:
self.active_workplane = next(iter(self.workplanes.keys()), None)
self.modified_at = datetime.now()
return True
return False
def add_sketch(self, sketch: Optional[Sketch] = None) -> Sketch:
"""Add a sketch to the component."""
if sketch is None:
@@ -218,6 +350,264 @@ class Component:
self.modified_at = datetime.now()
@dataclass
class Connector:
"""
A connection point on an assembly component instance.
Stores the position and orientation of a connection point
(e.g. a hole center, face midpoint, or edge point) that will
later be used by the SolveSpace solver to mate components.
The *normal* defines the connection axis direction (e.g. the
hole axis for a screw connection).
"""
id: str = field(default_factory=lambda: str(uuid.uuid4()))
name: str = "Untitled Connector"
# 3D position of the connection point (component-local coords).
# Transformed to world coords at render time using the parent
# AssemblyComponent's position/rotation, so connectors move with
# their component automatically.
position: Tuple[float, float, float] = (0.0, 0.0, 0.0)
# Normal direction of the connection (e.g. hole axis) in local coords.
normal: Tuple[float, float, float] = (0.0, 0.0, 1.0)
# In-plane X direction for defining the reference frame (local coords).
x_dir: Tuple[float, float, float] = (1.0, 0.0, 0.0)
# Rotation around the normal axis (degrees).
axis_rotation: float = 0.0
# Offset distance along the normal.
offset: float = 0.0
# Which AssemblyComponent this connector belongs to.
assembly_component_id: str = ""
# Which body/face this connector was placed on (renderer obj_id).
source_obj_id: str = ""
# --- Rigid-group pairing (set when two connectors are mated) ---
# The id of the partner AssemblyComponent this connector is mated to.
# The FIRST-picked component is the grounded reference of the pair;
# 'is_grounded' marks that side so the move handler knows which half
# is the fixed frame of the rigid group.
partner_ac_id: Optional[str] = None
# The id of the partner Connector on the partner component.
partner_connector_id: Optional[str] = None
# True on the first-picked (grounded) connector of a mated pair.
is_grounded: bool = False
# True when the source geometry this connector was placed on has
# changed or disappeared — the connector needs re-placement.
is_invalid: bool = False
created_at: datetime = field(default_factory=datetime.now)
modified_at: datetime = field(default_factory=datetime.now)
@dataclass
class AssemblyComponent:
"""
An instance of a component within an assembly.
References a component in the project and stores its relative
position and rotation for placement within the assembly.
Holds connectors that define connection points for the
SolveSpace solver.
"""
id: str = field(default_factory=lambda: str(uuid.uuid4()))
component_id: str = ""
name: str = "Untitled Instance"
# Position and orientation relative to the assembly origin.
position: np.ndarray = field(default_factory=lambda: np.array([0.0, 0.0, 0.0]))
rotation: np.ndarray = field(default_factory=lambda: np.eye(3))
# Connectors defined on this component instance.
connectors: Dict[str, Connector] = field(default_factory=dict)
created_at: datetime = field(default_factory=datetime.now)
modified_at: datetime = field(default_factory=datetime.now)
def add_connector(
self,
position: Tuple[float, float, float],
normal: Tuple[float, float, float],
x_dir: Tuple[float, float, float],
source_obj_id: str = "",
name: Optional[str] = None,
) -> Connector:
"""Add a connector to this component instance."""
conn = Connector(
name=name or f"Connector {len(self.connectors) + 1}",
position=position,
normal=normal,
x_dir=x_dir,
assembly_component_id=self.id,
source_obj_id=source_obj_id,
)
self.connectors[conn.id] = conn
self.modified_at = datetime.now()
return conn
def remove_connector(self, connector_id: str) -> bool:
"""Remove a connector from this component instance."""
if connector_id in self.connectors:
del self.connectors[connector_id]
self.modified_at = datetime.now()
return True
return False
@dataclass
class AssemblyConnection:
"""A mated connector pair linking two AssemblyComponents.
Records which component is the grounded reference (``first_ac_id``) and
which was solved against it (``second_ac_id``), plus the partner
connector ids so the linkage can be followed / removed symmetrically.
Used by the assembly-move handler to propagate translations across the
rigid group.
"""
id: str = field(default_factory=lambda: str(uuid.uuid4()))
first_ac_id: str = "" # grounded reference side
second_ac_id: str = "" # solved side
first_connector_id: Optional[str] = None
second_connector_id: Optional[str] = None
created_at: datetime = field(default_factory=datetime.now)
@dataclass
class Assembly:
"""
An assembly of multiple component instances.
An assembly groups component instances with relative positions,
ready for constraint solving and joint definition between them.
Components can be instanced multiple times, each at a different
position and rotation.
"""
id: str = field(default_factory=lambda: str(uuid.uuid4()))
name: str = "Untitled Assembly"
components: Dict[str, AssemblyComponent] = field(default_factory=dict)
active_assembly_component: Optional[str] = None
# Mated connector pairs — each entry links two AssemblyComponents so the
# assembly-move handler can propagate rigid-group translations. The
# 'first_ac_id' side is the grounded reference of the pair.
connections: List["AssemblyConnection"] = field(default_factory=list)
created_at: datetime = field(default_factory=datetime.now)
modified_at: datetime = field(default_factory=datetime.now)
def add_connection(self, first_ac_id: str, second_ac_id: str) -> "AssemblyConnection":
"""Record a mated connector pair between two component instances.
The first-picked component (``first_ac_id``) is treated as the
grounded reference of the pair. Guards against duplicate entries.
Returns the AssemblyConnection for further bookkeeping.
"""
# Guard: deduplicate — same pair in either order
for c in self.connections:
if (c.first_ac_id == first_ac_id and c.second_ac_id == second_ac_id) or \
(c.first_ac_id == second_ac_id and c.second_ac_id == first_ac_id):
return c
conn = AssemblyConnection(
first_ac_id=first_ac_id,
second_ac_id=second_ac_id,
)
self.connections.append(conn)
self.modified_at = datetime.now()
return conn
def remove_connections_for(self, ac_id: str) -> None:
"""Drop every connection that involves *ac_id* (e.g. on removal)."""
self.connections = [
c for c in self.connections
if c.first_ac_id != ac_id and c.second_ac_id != ac_id
]
def get_rigid_group(self, ac_id: str) -> List[str]:
"""Return ids of all components rigidly linked to *ac_id* (BFS).
Includes *ac_id* itself. Two components are linked when a mated
connector pair (in ``connections``) joins them; linkage is
transitive, so the whole connected subgraph forms one rigid group.
"""
if ac_id not in self.components:
return []
# Build adjacency from the connection list.
adj: Dict[str, List[str]] = {}
for c in self.connections:
adj.setdefault(c.first_ac_id, []).append(c.second_ac_id)
adj.setdefault(c.second_ac_id, []).append(c.first_ac_id)
seen: List[str] = []
queue: List[str] = [ac_id]
while queue:
cur = queue.pop(0)
if cur in seen:
continue
seen.append(cur)
for nb in adj.get(cur, []):
if nb not in seen:
queue.append(nb)
return seen
def is_grounded_reference(self, ac_id: str) -> bool:
"""True if *ac_id* is the grounded (first-picked) side of any pair."""
return any(c.first_ac_id == ac_id for c in self.connections)
def get_group_size(self, ac_id: str) -> int:
"""Number of components rigidly linked to *ac_id* (including itself)."""
return len(self.get_rigid_group(ac_id))
def add_component_instance(
self, component_id: str, name: Optional[str] = None
) -> AssemblyComponent:
"""Add a component instance to the assembly.
Returns the newly created AssemblyComponent. The same
component can be added multiple times (multiple instances).
"""
ac = AssemblyComponent(
component_id=component_id,
name=name or f"Instance {len(self.components) + 1}",
)
self.components[ac.id] = ac
if self.active_assembly_component is None:
self.active_assembly_component = ac.id
self.modified_at = datetime.now()
return ac
def remove_component_instance(self, assembly_component_id: str) -> bool:
"""Remove a component instance from the assembly."""
if assembly_component_id in self.components:
del self.components[assembly_component_id]
# Also drop any mated-connector links that referenced this
# instance — otherwise stale connection edges would remain in
# the rigid-group graph and point at a missing component.
self.remove_connections_for(assembly_component_id)
if self.active_assembly_component == assembly_component_id:
self.active_assembly_component = next(
iter(self.components.keys()), None
)
self.modified_at = datetime.now()
return True
return False
def get_active_instance(self) -> Optional[AssemblyComponent]:
"""Get the currently active assembly component instance."""
if (
self.active_assembly_component
and self.active_assembly_component in self.components
):
return self.components[self.active_assembly_component]
return None
@dataclass
class Project:
"""
@@ -233,12 +623,85 @@ class Project:
components: Dict[str, Component] = field(default_factory=dict)
active_component: Optional[str] = None
assemblies: Dict[str, Assembly] = field(default_factory=dict)
active_assembly: Optional[str] = None
kernel: OCGeometryKernel = field(default_factory=OCGeometryKernel)
created_at: datetime = field(default_factory=datetime.now)
modified_at: datetime = field(default_factory=datetime.now)
file_path: Optional[str] = None
# ── Component helpers ──
def add_component(self, component: Optional[Component] = None) -> Component:
"""Add a component to the project."""
if component is None:
component = Component(name=f"Component {len(self.components) + 1}")
self.components[component.id] = component
if self.active_component is None:
self.active_component = component.id
self.modified_at = datetime.now()
return component
def remove_component(self, component_id: str) -> bool:
"""Remove a component from the project."""
if component_id in self.components:
del self.components[component_id]
if self.active_component == component_id:
self.active_component = next(iter(self.components.keys()), None)
self.modified_at = datetime.now()
return True
return False
def get_active_component(self) -> Optional[Component]:
"""Get the currently active component."""
if self.active_component and self.active_component in self.components:
return self.components[self.active_component]
return None
def set_active_component(self, component_id: Optional[str]) -> None:
"""Set the active component."""
self.active_component = component_id
self.modified_at = datetime.now()
# ── Assembly helpers ──
def add_assembly(self, assembly: Optional[Assembly] = None) -> Assembly:
"""Add an assembly to the project."""
if assembly is None:
assembly = Assembly(name=f"Assembly {len(self.assemblies) + 1}")
self.assemblies[assembly.id] = assembly
if self.active_assembly is None:
self.active_assembly = assembly.id
self.modified_at = datetime.now()
return assembly
def remove_assembly(self, assembly_id: str) -> bool:
"""Remove an assembly from the project."""
if assembly_id in self.assemblies:
del self.assemblies[assembly_id]
if self.active_assembly == assembly_id:
self.active_assembly = next(iter(self.assemblies.keys()), None)
self.modified_at = datetime.now()
return True
return False
def get_active_assembly(self) -> Optional[Assembly]:
"""Get the currently active assembly."""
if self.active_assembly and self.active_assembly in self.assemblies:
return self.assemblies[self.active_assembly]
return None
def set_active_assembly(self, assembly_id: Optional[str]) -> None:
"""Set the active assembly."""
self.active_assembly = assembly_id
self.modified_at = datetime.now()
def get_component_by_id(self, component_id: str) -> Optional[Component]:
"""Look up a component by id across all project components."""
return self.components.get(component_id)
def add_component(self, component: Optional[Component] = None) -> Component:
"""Add a component to the project."""
if component is None:
+14
View File
@@ -1,11 +1,21 @@
"""Rendering module."""
# OCC/OpenGL viewport renderers
from fluency.rendering.base import (
Renderer,
RenderObject,
RenderColor,
)
from fluency.rendering.pygfx_renderer import PygfxRenderer, PygfxRenderObject
from fluency.rendering.occ_renderer import OCCRenderer, OCCRenderObject
# Photorealistic render backends
from fluency.rendering.render_backend import (
RenderBackend,
RenderMaterial,
RenderCamera,
RenderSettings,
)
__all__ = [
"Renderer",
@@ -13,4 +23,8 @@ __all__ = [
"RenderColor",
"PygfxRenderer",
"PygfxRenderObject",
"RenderBackend",
"RenderMaterial",
"RenderCamera",
"RenderSettings",
]
+12 -12
View File
@@ -97,7 +97,7 @@ class Renderer(ABC):
faces: np.ndarray,
color: Tuple[float, float, float] = (0.2, 0.4, 0.8),
name: Optional[str] = None,
) -> RenderObject:
) -> str:
"""
Add a mesh to the scene.
@@ -108,7 +108,7 @@ class Renderer(ABC):
name: Optional name for the object
Returns:
RenderObject representing the mesh
String ID of the mesh
"""
pass
@@ -120,7 +120,7 @@ class Renderer(ABC):
color: Tuple[float, float, float] = (1.0, 1.0, 1.0),
line_width: float = 1.0,
name: Optional[str] = None,
) -> RenderObject:
) -> str:
"""
Add a wireframe to the scene.
@@ -132,7 +132,7 @@ class Renderer(ABC):
name: Optional name for the object
Returns:
RenderObject representing the wireframe
String ID of the wireframe
"""
pass
@@ -143,7 +143,7 @@ class Renderer(ABC):
color: Tuple[float, float, float] = (1.0, 0.0, 0.0),
size: float = 5.0,
name: Optional[str] = None,
) -> RenderObject:
) -> str:
"""
Add points to the scene.
@@ -154,7 +154,7 @@ class Renderer(ABC):
name: Optional name for the object
Returns:
RenderObject representing the points
String ID of the points
"""
pass
@@ -166,7 +166,7 @@ class Renderer(ABC):
color: Tuple[float, float, float] = (1.0, 1.0, 1.0),
line_width: float = 1.0,
name: Optional[str] = None,
) -> RenderObject:
) -> str:
"""
Add line segments to the scene.
@@ -178,7 +178,7 @@ class Renderer(ABC):
name: Optional name for the object
Returns:
RenderObject representing the lines
String ID of the lines
"""
pass
@@ -312,13 +312,13 @@ class Renderer(ABC):
size: float = 100.0,
divisions: int = 10,
color: Tuple[float, float, float] = (0.3, 0.3, 0.3),
) -> RenderObject:
"""Add a reference grid."""
) -> str:
"""Add a reference grid. Returns the grid ID."""
pass
@abstractmethod
def add_axes(self, size: float = 10.0, visible: bool = True) -> RenderObject:
"""Add coordinate axes."""
def add_axes(self, size: float = 10.0, visible: bool = True) -> str:
"""Add coordinate axes. Returns the axes ID."""
pass
@abstractmethod
+156
View File
@@ -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())
+382
View File
@@ -0,0 +1,382 @@
"""Mitsuba 3 photorealistic render backend.
Requires: ``pip install mitsuba``
"""
from __future__ import annotations
import logging
import os
from typing import Callable, Optional
import numpy as np
from .render_backend import RenderBackend, RenderCamera, RenderMaterial, RenderSettings
logger = logging.getLogger(__name__)
class MitsubaBackend(RenderBackend):
"""Render backend using Mitsuba 3 path tracer."""
def name(self) -> str:
return "Mitsuba 3"
def is_available(self) -> bool:
import sys
import io
old_stderr = sys.stderr
sys.stderr = io.StringIO()
try:
import mitsuba # noqa: F401
return True
except ImportError:
return False
finally:
sys.stderr = old_stderr
# ── Scene construction ──────────────────────────────────────────
def _build_scene_dict(
self,
mesh_path: str,
material: RenderMaterial,
camera: RenderCamera,
settings: RenderSettings,
) -> dict:
"""Build a Mitsuba scene dictionary from our data classes.
Uses a 3-point lighting setup (key + fill + rim) plus an ambient
environment emitter for soft fill, giving well-balanced shading on
all faces of the model. Lighting intensities and colors come from
``settings.lighting``; ground plane comes from ``settings.ground_plane``.
"""
import mitsuba as mi
lighting = settings.lighting
ground = settings.ground_plane
# Map our BSDF types to Mitsuba BSDF dicts
bsdf = self._make_bsdf(material)
# Determine mesh file type from extension
ext = os.path.splitext(mesh_path)[1].lower()
shape_type = "ply" if ext == ".ply" else "obj"
# Build camera-to-world transform using the Python API
cam_to_world = mi.ScalarTransform4f.look_at(
origin=list(camera.origin),
target=list(camera.target),
up=list(camera.up),
)
scene = {
"type": "scene",
# Integrator
"integrator": {
"type": "path",
"max_depth": settings.max_depth,
},
# Camera
"sensor": {
"type": "perspective",
"fov": camera.fov,
"to_world": cam_to_world,
"film": {
"type": "hdrfilm",
"width": settings.width,
"height": settings.height,
"rfilter": {"type": "gaussian"},
},
"sampler": {
"type": "independent",
"sample_count": settings.spp,
},
},
# Ambient environment fill
"emitter": {
"type": "constant",
"radiance": {
"type": "rgb",
"value": [
lighting.ambient_intensity,
lighting.ambient_intensity * 0.97,
lighting.ambient_intensity * 0.94,
],
},
},
# Shape
"shape": {
"type": shape_type,
"filename": mesh_path,
"bsdf": bsdf,
},
}
# ── 3-point lighting (colors and intensities from config) ───
key_rgb = [c * lighting.key_intensity for c in lighting.key_color]
key_to_world = mi.ScalarTransform4f.look_at(
origin=[1.0, -0.8, 1.2],
target=[0.0, 0.0, 0.0],
up=[0.0, 0.0, 1.0],
)
scene["key_light"] = {
"type": "directional",
"to_world": key_to_world,
"irradiance": {"type": "rgb", "value": key_rgb},
}
fill_rgb = [c * lighting.fill_intensity for c in lighting.fill_color]
fill_to_world = mi.ScalarTransform4f.look_at(
origin=[-1.0, 0.6, 0.8],
target=[0.0, 0.0, 0.0],
up=[0.0, 0.0, 1.0],
)
scene["fill_light"] = {
"type": "directional",
"to_world": fill_to_world,
"irradiance": {"type": "rgb", "value": fill_rgb},
}
rim_rgb = [c * lighting.rim_intensity for c in lighting.rim_color]
rim_to_world = mi.ScalarTransform4f.look_at(
origin=[-0.3, 1.2, -0.8],
target=[0.0, 0.0, 0.0],
up=[0.0, 0.0, 1.0],
)
scene["rim_light"] = {
"type": "directional",
"to_world": rim_to_world,
"irradiance": {"type": "rgb", "value": rim_rgb},
}
# ── Ground plane / backdrop (optional) ─────────────────────
if ground.enabled:
try:
# Load mesh to compute bounds for ground/backdrop placement
mesh_shape = mi.load_dict({"type": shape_type, "filename": mesh_path})
bbox = mesh_shape.bbox()
bbox_min, bbox_max = bbox[0], bbox[1]
# Ground at model's lowest Z with 0.1% offset
model_height = bbox_max[2] - bbox_min[2]
ground_z = bbox_min[2] - 0.001 * model_height
dx = bbox_max[0] - bbox_min[0]
dy = bbox_max[1] - bbox_min[1]
dz = bbox_max[2] - bbox_min[2]
diag = float((dx * dx + dy * dy + dz * dz) ** 0.5)
except Exception:
# Fallback: place at origin with large default size
ground_z = -ground.distance_below
diag = 1000.0
bsdf_ground = {
"type": "diffuse",
"reflectance": {"type": "rgb", "value": list(ground.color)},
}
if ground.curved_backdrop:
# Photo booth style curved leinwand:
# - Flat floor section in front of the model
# - Curved cylinder behind that sweeps up and over
half_size = diag * 50.0 # huge floor
radius = diag * 3.0 # curvature radius
cyl_height = diag * 20.0 # width of the cylinder (along its axis)
scene["ground_floor"] = {
"type": "rectangle",
"to_world": mi.ScalarTransform4f.translate([0.0, 0.0, ground_z])
@ mi.ScalarTransform4f.scale([half_size, half_size, 1.0]),
"bsdf": bsdf_ground,
}
# Cylinder: axis along Y, positioned behind model, radius sweeps up
scene["ground_backdrop"] = {
"type": "cylinder",
"radius": radius,
"p0": [-cyl_height / 2, -radius, ground_z],
"p1": [cyl_height / 2, -radius, ground_z],
"to_world": mi.ScalarTransform4f.rotate([1, 0, 0], 90)
@ mi.ScalarTransform4f.translate([0.0, 0.0, -radius]),
"bsdf": bsdf_ground,
}
else:
# Simple flat ground plane — very large so edges aren't visible
half_size = diag * 50.0
scene["ground_plane"] = {
"type": "rectangle",
"to_world": mi.ScalarTransform4f.translate([0.0, 0.0, ground_z])
@ mi.ScalarTransform4f.scale([half_size, half_size, 1.0]),
"bsdf": bsdf_ground,
}
return scene
def _make_bsdf(self, material: RenderMaterial) -> dict:
"""Convert a RenderMaterial to a Mitsuba BSDF dict."""
mt = material.bsdf_type
if mt == "roughconductor":
# Use material="none" with specular_reflectance set to the
# metal color. The pip-installed Mitsuba doesn't include
# spectral metal data files (iron.spd, copper.spd, etc.).
return {
"type": "roughconductor",
"material": "none",
"alpha": max(material.roughness, 0.01),
"specular_reflectance": {
"type": "rgb",
"value": list(material.color),
},
}
if mt == "roughdielectric":
return {
"type": "roughdielectric",
"int_ior": material.int_ior,
"ext_ior": 1.0,
"alpha": max(material.roughness, 0.01),
}
if mt == "dielectric":
return {
"type": "dielectric",
"int_ior": material.int_ior,
"ext_ior": 1.0,
}
if mt == "plastic":
return {
"type": "plastic",
"diffuse_reflectance": {
"type": "rgb",
"value": list(material.color),
},
"int_ior": material.int_ior,
}
# Default: diffuse
return {
"type": "diffuse",
"reflectance": {
"type": "rgb",
"value": list(material.color),
},
}
# ── Rendering ───────────────────────────────────────────────────
def render(
self,
mesh_path: str,
material: RenderMaterial,
camera: RenderCamera,
settings: RenderSettings,
progress_callback: Optional[Callable[[float], None]] = None,
) -> np.ndarray:
"""Render a mesh file and return (H, W, 3) float32 RGB array."""
self._set_variant()
import mitsuba as mi
scene_dict = self._build_scene_dict(mesh_path, material, camera, settings)
scene = mi.load_dict(scene_dict)
logger.info(f"Rendering {settings.width}x{settings.height} @ {settings.spp} spp")
# Render
try:
image = mi.render(scene, spp=settings.spp, seed=int(settings.seed or 0)) # type: ignore[arg-type] # Mitsuba accepts int at runtime
except Exception as e:
logger.error(f"Mitsuba render failed: {e}")
raise
if progress_callback:
progress_callback(1.0)
# Convert to numpy (H, W, 3)
arr = np.array(image, dtype=np.float32)
# Apply approximate sRGB tonemapping
arr = np.clip(arr, 0.0, None)
arr = np.power(arr, 1.0 / 2.2) # gamma
arr = np.clip(arr, 0.0, 1.0)
return arr
def render_preview(
self,
mesh_path: str,
material: RenderMaterial,
camera: RenderCamera,
settings: RenderSettings,
) -> np.ndarray:
"""Quick low-quality preview (4x fewer spp)."""
preview_settings = RenderSettings(
width=settings.width // 2,
height=settings.height // 2,
spp=max(settings.spp // 4, 16),
max_depth=min(settings.max_depth, 4),
seed=settings.seed,
lighting=settings.lighting,
ground_plane=settings.ground_plane,
)
return self.render(mesh_path, material, camera, preview_settings)
# ── Export ──────────────────────────────────────────────────────
def export_image(self, image: np.ndarray, path: str) -> None:
"""Save a rendered image to PNG or EXR."""
from PIL import Image
ext = os.path.splitext(path)[1].lower()
if ext == ".exr":
# Save as EXR (HDR) — no tonemapping
try:
import OpenEXR # type: ignore[import-not-found]
import Imath # type: ignore[import-not-found]
h, w = image.shape[:2]
header = OpenEXR.Header(w, h)
header["channels"] = {
"R": Imath.PixelType(Imath.PixelType.FLOAT),
"G": Imath.PixelType(Imath.PixelType.FLOAT),
"B": Imath.PixelType(Imath.PixelType.FLOAT),
}
exr = OpenEXR.OutputFile(path, header)
exr.write(
{
"R": image[:, :, 0].tobytes(),
"G": image[:, :, 1].tobytes(),
"B": image[:, :, 2].tobytes(),
}
)
exr.close()
except ImportError:
# Fallback: save as 16-bit PNG
logger.warning("OpenEXR not available, saving as 16-bit PNG")
img = Image.fromarray((image * 65535).astype(np.uint16), "RGB")
img.save(path)
else:
# PNG / JPEG — already tonemapped
img = Image.fromarray((image * 255).astype(np.uint8), "RGB")
img.save(path)
logger.info(f"Exported render to {path}")
# ── Helpers ─────────────────────────────────────────────────────
def _set_variant(self) -> None:
"""Set the Mitsuba variant (called once)."""
import sys
import io
# Suppress the harmless "LLVM API initialization failed" warning
# that drjit emits on macOS ARM when scalar variant is used.
old_stderr = sys.stderr
sys.stderr = io.StringIO()
try:
import mitsuba as mi
mi.set_variant("scalar_rgb")
finally:
sys.stderr = old_stderr
File diff suppressed because it is too large Load Diff
+252
View File
@@ -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])))
+70 -39
View File
@@ -55,9 +55,17 @@ class PygfxRenderObject(RenderObject):
geometry: Any = None
material: Any = None
def __post_init__(self) -> None:
if self.scene_node is not None:
self._scene_node = self.scene_node
def __init__(
self,
scene_node: Any = None,
geometry: Any = None,
material: Any = None,
name: Optional[str] = None,
):
super().__init__(name=name)
self.scene_node = scene_node
self.geometry = geometry
self.material = material
class PygfxRenderer(Renderer):
@@ -84,17 +92,18 @@ class PygfxRenderer(Renderer):
"""Initialize pygfx with Qt widget."""
try:
import pygfx as gfx
from wgpu.gui.qt import WgpuCanvas
from rendercanvas.qt import RenderWidget
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._scene = gfx.Scene()
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._add_grid()
@@ -103,6 +112,8 @@ class PygfxRenderer(Renderer):
layout.setContentsMargins(0, 0, 0, 0)
layout.addWidget(self._canvas)
self._canvas.request_draw(self._animate)
self._setup_picking()
self._initialized = True
@@ -110,8 +121,16 @@ class PygfxRenderer(Renderer):
except Exception as e:
print(f"Failed to initialize pygfx: {e}")
import traceback
traceback.print_exc()
return False
def _animate(self):
"""Animation callback for the canvas."""
if self._initialized:
self._renderer.render(self._scene, self._camera)
def _setup_lighting(self) -> None:
"""Setup scene lighting."""
import pygfx as gfx
@@ -120,11 +139,11 @@ class PygfxRenderer(Renderer):
self._scene.add(ambient)
directional = gfx.DirectionalLight(intensity=1.0)
directional.position.set(100, 100, 100)
directional.local.position = (100, 100, 100)
self._scene.add(directional)
fill = gfx.DirectionalLight(intensity=0.5)
fill.position.set(-100, 50, 50)
fill.local.position = (-100, 50, 50)
self._scene.add(fill)
def _add_grid(self) -> None:
@@ -151,9 +170,10 @@ class PygfxRenderer(Renderer):
faces: np.ndarray,
color: Tuple[float, float, float] = (0.2, 0.4, 0.8),
name: Optional[str] = None,
) -> PygfxRenderObject:
"""Add a mesh to the scene."""
) -> str:
"""Add a mesh to the scene. Returns the mesh ID."""
import pygfx as gfx
import uuid
vertices = np.asarray(vertices, dtype=np.float32)
faces = np.asarray(faces, dtype=np.int32)
@@ -167,11 +187,12 @@ class PygfxRenderer(Renderer):
mesh = gfx.Mesh(geometry, material)
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)
self._objects.append(obj)
return obj
return mesh_id
def add_wireframe(
self,
@@ -180,9 +201,10 @@ class PygfxRenderer(Renderer):
color: Tuple[float, float, float] = (1.0, 1.0, 1.0),
line_width: float = 1.0,
name: Optional[str] = None,
) -> PygfxRenderObject:
"""Add a wireframe to the scene."""
) -> str:
"""Add a wireframe to the scene. Returns the wireframe ID."""
import pygfx as gfx
import uuid
positions: List[List[float]] = []
for edge in edges:
@@ -197,11 +219,14 @@ class PygfxRenderer(Renderer):
lines = gfx.Line(geometry, material)
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)
self._objects.append(obj)
return obj
return wireframe_id
def add_points(
self,
@@ -209,9 +234,10 @@ class PygfxRenderer(Renderer):
color: Tuple[float, float, float] = (1.0, 0.0, 0.0),
size: float = 5.0,
name: Optional[str] = None,
) -> PygfxRenderObject:
"""Add points to the scene."""
) -> str:
"""Add points to the scene. Returns the points ID."""
import pygfx as gfx
import uuid
points_arr = np.asarray(points, dtype=np.float32)
@@ -221,13 +247,14 @@ class PygfxRenderer(Renderer):
points_obj = gfx.Points(geometry, material)
self._scene.add(points_obj)
points_id = name or f"points_{uuid.uuid4().hex[:8]}"
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)
self._objects.append(obj)
return obj
return points_id
def add_lines(
self,
@@ -236,9 +263,10 @@ class PygfxRenderer(Renderer):
color: Tuple[float, float, float] = (1.0, 1.0, 1.0),
line_width: float = 1.0,
name: Optional[str] = None,
) -> PygfxRenderObject:
"""Add line segments to the scene."""
) -> str:
"""Add line segments to the scene. Returns the lines ID."""
import pygfx as gfx
import uuid
positions = np.hstack([start_points, end_points]).flatten()
positions = positions.reshape(-1, 3).astype(np.float32)
@@ -249,11 +277,14 @@ class PygfxRenderer(Renderer):
lines = gfx.Line(geometry, material)
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)
self._objects.append(obj)
return obj
return lines_id
def remove_object(self, obj: RenderObject) -> bool:
"""Remove an object from the scene."""
@@ -319,15 +350,14 @@ class PygfxRenderer(Renderer):
up: Tuple[float, float, float] = (0, 0, 1),
) -> None:
"""Set camera position and orientation."""
self._camera.position.set(*position)
self._camera.look_at(*target)
self._camera.up.set(*up)
self._camera.local.position = position
self._camera.look_at(target)
def get_camera_position(self) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
"""Get camera position, target, and up vector."""
pos = np.array(self._camera.position.to_array())
pos = np.array(self._camera.local.position)
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
def fit_camera(self, padding: float = 1.1) -> None:
@@ -348,8 +378,8 @@ class PygfxRenderer(Renderer):
center = (min_pos + max_pos) / 2
size = np.linalg.norm(max_pos - min_pos) * padding
self._camera.position.set(center[0] + size, center[1] + size, center[2] + size)
self._camera.look_at(*center)
self._camera.local.position = (center[0] + size, center[1] + size, center[2] + size)
self._camera.look_at(tuple(center))
def set_camera_perspective(
self, fov: float = 50.0, near: float = 0.1, far: float = 10000.0
@@ -371,7 +401,6 @@ class PygfxRenderer(Renderer):
def render(self) -> None:
"""Trigger a render."""
if self._initialized:
self._renderer.render(self._scene, self._camera)
self._canvas.request_draw()
def on_pick(self, callback: Callable[[Any], None]) -> None:
@@ -392,8 +421,8 @@ class PygfxRenderer(Renderer):
size: float = 100.0,
divisions: int = 10,
color: Tuple[float, float, float] = (0.3, 0.3, 0.3),
) -> PygfxRenderObject:
"""Add a reference grid."""
) -> str:
"""Add a reference grid. Returns the grid ID."""
import pygfx as gfx
grid = gfx.GridHelper(
@@ -402,10 +431,11 @@ class PygfxRenderer(Renderer):
self._scene.add(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:
"""Add coordinate axes."""
def add_axes(self, size: float = 10.0, visible: bool = True) -> str:
"""Add coordinate axes. Returns the axes ID."""
import pygfx as gfx
axes = gfx.AxesHelper(size=size)
@@ -413,7 +443,8 @@ class PygfxRenderer(Renderer):
self._scene.add(axes)
obj = PygfxRenderObject(name="axes", scene_node=axes)
return obj
self._objects.append(obj)
return "axes"
def get_screen_size(self) -> Tuple[int, int]:
"""Get the screen size in pixels."""
+142
View File
@@ -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.01.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,
)
+326
View File
@@ -0,0 +1,326 @@
"""
SolveSpace-based constraint solver for Fluency CAD.
Provides integration between python-solvespace (SolverSystem) and
the Fluency CAD sketch pipeline (OCCSketch). Drawing operations
add entities to BOTH the OCCSketch (for OCC->render pipeline) and
the SolverSketch (for constraint solving). After constraint solving,
solved positions are synced back to the OCCSketch.
"""
from __future__ import annotations
import math
import re
import uuid
import logging
from dataclasses import dataclass, field
from typing import List, Optional, Tuple, Any, Dict
from python_solvespace import SolverSystem, ResultFlag
logger = logging.getLogger(__name__)
# ── Data classes ──────────────────────────────────────────────────────────
@dataclass
class SolverPoint:
"""A 2D point tracked by the solver system."""
x: float
y: float
handle: Any = None
handle_nr: int = 0
entity_id: int = -1 # Corresponding OCCSketch entity id
is_helper: bool = False
id: str = field(default_factory=lambda: str(uuid.uuid4()))
def to_tuple(self) -> Tuple[float, float]:
return (self.x, self.y)
@dataclass
class SolverLine:
"""A line segment tracked by the solver system."""
start: SolverPoint
end: SolverPoint
handle: Any = None
handle_nr: int = 0
entity_ids: Tuple[int, int] = (-1, -1) # Corresponding OCCSketch entity ids
is_helper: bool = False
constraints: List[str] = field(default_factory=list)
id: str = field(default_factory=lambda: str(uuid.uuid4()))
@property
def length(self) -> float:
return math.sqrt(
(self.end.x - self.start.x) ** 2 + (self.end.y - self.start.y) ** 2
)
def midpoint(self) -> Tuple[float, float]:
return (
(self.start.x + self.end.x) / 2,
(self.start.y + self.end.y) / 2,
)
@dataclass
class SolverCircle:
"""A circle tracked by the solver system."""
center: SolverPoint
radius: float
handle: Any = None
handle_nr: int = 0
entity_id: int = -1 # Corresponding OCCSketch entity id
is_helper: bool = False
id: str = field(default_factory=lambda: str(uuid.uuid4()))
# ── Solver wrapper ────────────────────────────────────────────────────────
class SolverSketch(SolverSystem):
"""
Sketch that uses python-solvespace for parametric constraint solving.
Maintains its own lists of points, lines, and circles with solve-space
handles. Provides methods for creating geometry, applying constraints,
solving, and syncing solved positions back to an OCCSketch.
"""
def __init__(self) -> None:
super().__init__()
self.id = str(uuid.uuid4())
self.wp = self.create_2d_base()
self.points: List[SolverPoint] = []
self.lines: List[SolverLine] = []
self.circles: List[SolverCircle] = []
self._last_solve_result: int = 0
# ── Geometry creation ────────────────────────────────────────────────
def add_solver_point(self, x: float, y: float, is_helper: bool = False) -> SolverPoint:
"""Add a point to the solver system and return a SolverPoint."""
handle = self.add_point_2d(x, y, self.wp)
handle_nr = _extract_handle_nr(str(handle))
point = SolverPoint(
x=x, y=y, handle=handle, handle_nr=handle_nr,
is_helper=is_helper,
)
self.points.append(point)
return point
def add_solver_line(
self, start: SolverPoint, end: SolverPoint, is_helper: bool = False
) -> SolverLine:
"""Add a line to the solver system and return a SolverLine."""
handle = self.add_line_2d(start.handle, end.handle, self.wp)
handle_nr = _extract_handle_nr(str(handle))
line = SolverLine(
start=start, end=end, handle=handle, handle_nr=handle_nr,
is_helper=is_helper,
)
self.lines.append(line)
return line
def add_solver_circle(
self, center: SolverPoint, radius: float, is_helper: bool = False
) -> SolverCircle:
"""Add a circle to the solver system and return a SolverCircle.
Note: python-solvespace handles circles via diameter, so we
store radius but pass 2*radius to the solver if needed.
"""
# For now, circles are tracked for OCC output but the solver
# doesn't have a dedicated add_circle_2d in the standard API.
# We'll handle radius/diameter constraints through the points.
circle = SolverCircle(
center=center, radius=radius,
is_helper=is_helper,
)
self.circles.append(circle)
return circle
# ── Constraint methods ───────────────────────────────────────────────
def constrain_coincident(self, entity_a, entity_b) -> bool:
"""Make two entities coincident (point-point or point-line)."""
try:
if isinstance(entity_a, SolverPoint) and isinstance(entity_b, SolverPoint):
self.coincident(entity_a.handle, entity_b.handle, self.wp)
elif isinstance(entity_a, SolverPoint) and isinstance(entity_b, SolverLine):
self.coincident(entity_a.handle, entity_b.handle, self.wp)
elif isinstance(entity_a, SolverLine) and isinstance(entity_b, SolverPoint):
self.coincident(entity_b.handle, entity_a.handle, self.wp)
else:
logger.warning(f"coincident: unsupported types {type(entity_a)}, {type(entity_b)}")
return False
return True
except Exception as e:
logger.error(f"coincident constraint failed: {e}")
return False
def constrain_horizontal(self, line: SolverLine) -> bool:
"""Constrain a line to be horizontal."""
try:
self.horizontal(line.handle, self.wp)
return True
except Exception as e:
logger.error(f"horizontal constraint failed: {e}")
return False
def constrain_vertical(self, line: SolverLine) -> bool:
"""Constrain a line to be vertical."""
try:
self.vertical(line.handle, self.wp)
return True
except Exception as e:
logger.error(f"vertical constraint failed: {e}")
return False
def constrain_distance(
self, entity_a, entity_b, distance: float
) -> bool:
"""Constrain distance between point-point or point-line."""
try:
handle_a = entity_a.handle if isinstance(entity_a, SolverPoint) else entity_a.handle
handle_b = entity_b.handle if isinstance(entity_b, SolverPoint) else entity_b.handle
if isinstance(entity_a, SolverPoint) and isinstance(entity_b, SolverLine):
self.distance(handle_a, handle_b, distance, self.wp)
elif isinstance(entity_a, SolverLine) and isinstance(entity_b, SolverPoint):
self.distance(handle_b, handle_a, distance, self.wp)
elif isinstance(entity_a, SolverPoint) and isinstance(entity_b, SolverPoint):
self.distance(handle_a, handle_b, distance, self.wp)
elif isinstance(entity_a, SolverLine) and isinstance(entity_b, SolverLine):
self.distance(handle_a, handle_b, distance, self.wp)
else:
logger.warning(f"distance: unsupported types {type(entity_a)}, {type(entity_b)}")
return False
return True
except Exception as e:
logger.error(f"distance constraint failed: {e}")
return False
def constrain_midpoint(self, point: SolverPoint, line: SolverLine) -> bool:
"""Constrain a point to be at the midpoint of a line."""
try:
self.midpoint(point.handle, line.handle, self.wp)
return True
except Exception as e:
logger.error(f"midpoint constraint failed: {e}")
return False
def constrain_parallel(self, line_a: SolverLine, line_b: SolverLine) -> bool:
"""Constrain two lines to be parallel."""
try:
self.parallel(line_a.handle, line_b.handle, self.wp)
return True
except Exception as e:
logger.error(f"parallel constraint failed: {e}")
return False
def constrain_perpendicular(self, line_a: SolverLine, line_b: SolverLine) -> bool:
"""Constrain two lines to be perpendicular."""
try:
self.perpendicular(line_a.handle, line_b.handle, self.wp)
return True
except Exception as e:
logger.error(f"perpendicular constraint failed: {e}")
return False
def constrain_angle(self, line_a: SolverLine, line_b: SolverLine, angle_deg: float) -> bool:
"""Constrain angle between two lines in degrees."""
try:
angle_rad = math.radians(angle_deg)
self.angle(line_a.handle, line_b.handle, angle_rad, self.wp)
return True
except Exception as e:
logger.error(f"angle constraint failed: {e}")
return False
def constrain_equal_length(self, line_a: SolverLine, line_b: SolverLine) -> bool:
"""Constrain two lines to have equal length."""
try:
self.equal(line_a.handle, line_b.handle, self.wp)
return True
except Exception as e:
logger.error(f"equal length constraint failed: {e}")
return False
# ── Solving ──────────────────────────────────────────────────────────
def solve(self) -> int:
"""Solve all constraints. Returns ResultFlag as int."""
result = super().solve()
self._last_solve_result = result
if result == ResultFlag.OKAY:
# Update our stored point positions from solver params
self._sync_solved_positions()
return result
def _sync_solved_positions(self) -> None:
"""Update SolverPoint coordinates from solver's solved params."""
for point in self.points:
if point.handle and self.params(point.handle.params):
x, y = self.params(point.handle.params)
point.x = x
point.y = y
# ── Query ────────────────────────────────────────────────────────────
def get_solved_point_positions(self) -> Dict[int, Tuple[float, float]]:
"""Get map of entity_id -> (x, y) after solving."""
positions: Dict[int, Tuple[float, float]] = {}
for point in self.points:
if point.handle and self.params(point.handle.params):
x, y = self.params(point.handle.params)
positions[point.entity_id] = (x, y)
return positions
def is_point_on_line(
self, px: float, py: float, line: SolverLine, tolerance: float = 5.0
) -> bool:
"""Check if a point lies on a solver line (in world coords)."""
# Vector from start to point
ap_x = px - line.start.x
ap_y = py - line.start.y
# Vector from start to end
ab_x = line.end.x - line.start.x
ab_y = line.end.y - line.start.y
ab_len_sq = ab_x**2 + ab_y**2
if ab_len_sq == 0:
return False
# Project point onto line
t = (ap_x * ab_x + ap_y * ab_y) / ab_len_sq
t = max(0, min(1, t))
closest_x = line.start.x + t * ab_x
closest_y = line.start.y + t * ab_y
dist = math.sqrt((px - closest_x) ** 2 + (py - closest_y) ** 2)
return dist <= tolerance
# ── Clear / reset ────────────────────────────────────────────────────
def clear(self) -> None:
"""Clear all geometry from this solver sketch."""
self.points.clear()
self.lines.clear()
self.circles.clear()
self.wp = self.create_2d_base()
# ── Helpers ───────────────────────────────────────────────────────────────
def _extract_handle_nr(handle_str: str) -> int:
"""Extract numeric handle from string like 'Entity(handle=7, ...)'."""
match = re.search(r"handle=(\d+)", handle_str)
return int(match.group(1)) if match else 0
+372
View File
@@ -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"
)
+240
View File
@@ -0,0 +1,240 @@
"""Smoke test for project_io save/load round-trip.
Builds a small project (a Component with a sketch, an extrude body, a
workplane, plus an assembly with two instances and a connector) and
verifies that saving then loading it preserves the data.
"""
import os
import sys
import tempfile
import unittest
# Allow running this file directly: ``python tests/test_project_io.py``.
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir, "src"))
from fluency.io.project_io import save_project, load_project
from fluency.models.data_model import (
Project,
Component,
Body,
Workplane,
Assembly,
)
class TestProjectIO(unittest.TestCase):
"""Round-trip the same project through save/load and check equivalence."""
def _build_project(self) -> Project:
project = Project(name="Test Project", description="A tiny test")
project.file_path = None # simulate untitled
comp = project.add_component(Component(name="Part1"))
# Add a workplane.
wp = Workplane(
name="Top",
origin=(0.0, 0.0, 0.0),
normal=(0.0, 0.0, 1.0),
x_dir=(1.0, 0.0, 0.0),
)
comp.add_workplane(wp)
# Add a sketch with a square.
sk = comp.add_sketch()
sk.occ_sketch.add_rectangle((0.0, 0.0), (10.0, 10.0))
sk.solve()
# Build a face geometry for the sketch (needed for export / restore).
faces = sk.occ_sketch.detect_faces()
if faces:
sk.geometry = sk.occ_sketch.build_face_geometry(faces[0])
# Extrude into a body.
if sk.geometry:
kernel = project.kernel
body_shape = kernel.extrude(sk.geometry, height=20.0)
body = comp.add_body(
Body(
name="Block",
geometry=body_shape,
source_sketch=sk,
source_operation="extrude",
)
)
body.color = (0.4, 0.2, 0.8)
# Add an assembly with two instances and a mated connector pair.
asm = project.add_assembly(Assembly(name="Asm1"))
ac1 = asm.add_component_instance(comp.id, name="Inst1")
ac2 = asm.add_component_instance(comp.id, name="Inst2")
c1 = ac1.add_connector(
position=(5.0, 5.0, 0.0),
normal=(0.0, 0.0, 1.0),
x_dir=(1.0, 0.0, 0.0),
)
c2 = ac2.add_connector(
position=(10.0, 10.0, 5.0),
normal=(0.0, 0.0, -1.0),
x_dir=(1.0, 0.0, 0.0),
)
# Record a mated pair (UI normally does this on connector-pick).
conn = asm.add_connection(ac1.id, ac2.id)
conn.first_connector_id = c1.id
conn.second_connector_id = c2.id
c1.partner_ac_id = ac2.id
c1.partner_connector_id = c2.id
c2.partner_ac_id = ac1.id
c2.partner_connector_id = c1.id
c1.is_grounded = True
return project
def test_round_trip(self):
original = self._build_project()
with tempfile.TemporaryDirectory() as tmp:
path = os.path.join(tmp, "test.fluency")
saved_path = save_project(original, path)
self.assertTrue(os.path.exists(saved_path))
self.assertGreater(os.path.getsize(saved_path), 100)
loaded, view_state = load_project(saved_path)
# ── Project metadata ──
self.assertEqual(loaded.name, "Test Project")
self.assertEqual(loaded.description, "A tiny test")
self.assertEqual(len(loaded.components), 1)
self.assertEqual(len(loaded.assemblies), 1)
# ── Component / Workplane / Sketch / Body ──
comp = next(iter(loaded.components.values()))
self.assertEqual(comp.name, "Part1")
self.assertEqual(len(comp.workplanes), 1)
self.assertEqual(len(comp.sketches), 1)
self.assertEqual(len(comp.bodies), 1)
sk = next(iter(comp.sketches.values()))
self.assertIsNotNone(sk.occ_sketch)
# OCCSketch replayed the rectangle: 4 points + 4 lines + 1 implicit
# origin anchor (first-point-fix). We only check that the entities
# exist.
self.assertGreaterEqual(sk.occ_sketch.get_entity_count(), 4)
# Solved geometry should round-trip through STEP.
self.assertIsNotNone(sk.geometry)
body = next(iter(comp.bodies.values()))
self.assertIsNotNone(body.geometry)
self.assertEqual(body.name, "Block")
self.assertEqual(tuple(body.color), (0.4, 0.2, 0.8))
# BRep topology should still be valid.
self.assertGreater(body.get_mesh(loaded.kernel, 0.5)[0].size, 0)
# ── Assembly / connector / connection ──
asm = next(iter(loaded.assemblies.values()))
self.assertEqual(len(asm.components), 2)
self.assertEqual(len(asm.connections), 1)
ac1, ac2 = list(asm.components.values())
self.assertEqual(len(ac1.connectors), 1)
conn = next(iter(ac1.connectors.values()))
self.assertEqual(conn.position, (5.0, 5.0, 0.0))
# The grounded-reference flag was re-applied to the first connector.
self.assertTrue(conn.is_grounded)
# Rigid-group BFS should still link the two instances.
self.assertEqual(set(asm.get_rigid_group(ac1.id)), {ac1.id, ac2.id})
class TestProjectIOWithConstraints(unittest.TestCase):
"""Round-trip with parametric constraints on the sketch.
Builds a slightly more interesting sketch (rectangle with horizontal +
vertical constraints + a distance) so the constraint-log replay path
is exercised, not just the entity-construction path.
"""
def _build_project(self) -> Project:
project = Project(name="Constraints Project", description="")
comp = project.add_component(Component(name="Part1"))
# A square with a 25.4mm horizontal distance + a vertical distance,
# both anchored on a single fixed corner. This is the minimum
# number of constraints to fully define a square in 2D.
sk = comp.add_sketch()
sk.occ_sketch.set_workplane(
(0.0, 0.0, 0.0),
(0.0, 0.0, 1.0),
(1.0, 0.0, 0.0),
)
p1 = sk.occ_sketch.add_point(0.0, 0.0) # fixed anchor (auto)
p2 = sk.occ_sketch.add_point(25.4, 0.0)
p3 = sk.occ_sketch.add_point(25.4, 25.4)
p4 = sk.occ_sketch.add_point(0.0, 25.4)
sk.occ_sketch.add_line(p1, p2)
sk.occ_sketch.add_line(p2, p3)
sk.occ_sketch.add_line(p3, p4)
sk.occ_sketch.add_line(p4, p1)
# Constraint the right side to a known length (instead of relying
# on the construction positions, which would just be redundant).
sk.occ_sketch.constrain_distance(p2, p3, 25.4)
sk.solve()
sk.is_fully_constrained = sk.occ_sketch.is_fully_constrained()
faces = sk.occ_sketch.detect_faces()
if faces:
sk.geometry = sk.occ_sketch.build_face_geometry(faces[0])
# View state to persist.
view_state = {
"active_tab": 0,
"active_component_id": comp.id,
"active_sketch_id": sk.id,
"camera_eye": [50.0, 50.0, 50.0],
"camera_at": [0.0, 0.0, 0.0],
"camera_up": [0.0, 0.0, 1.0],
"panel_focus": "sketch",
"assembly_view_active": False,
}
self._view_state = view_state
return project
def test_round_trip_with_view_state(self):
original = self._build_project()
with tempfile.TemporaryDirectory() as tmp:
path = os.path.join(tmp, "constrained.fluency")
save_project(original, path, view_state=self._view_state)
loaded, view_state = load_project(path)
# View state must survive (modulo float-to-list round-tripping).
self.assertEqual(view_state.get("active_component_id"),
self._view_state["active_component_id"])
self.assertEqual(view_state.get("active_sketch_id"),
self._view_state["active_sketch_id"])
self.assertEqual(view_state.get("camera_eye"),
self._view_state["camera_eye"])
self.assertEqual(view_state.get("panel_focus"),
self._view_state["panel_focus"])
# Sketch must have replayed its constraints and remain solvable.
comp = next(iter(loaded.components.values()))
sk = next(iter(comp.sketches.values()))
# Re-solve on the loaded sketch to confirm the post-replay
# configuration is still consistent.
self.assertTrue(sk.occ_sketch.solve())
# The right edge of the square should still be 25.4mm tall.
import math
for lid, line in sk.occ_sketch._lines.items():
sid, eid = line
sx, sy = sk.occ_sketch._points[sid]
ex, ey = sk.occ_sketch._points[eid]
length = math.hypot(ex - sx, ey - sy)
if abs(length) > 1e-3:
self.assertAlmostEqual(
length, 25.4, places=3,
msg=f"Constraint replay broke: line {lid} = {length}",
)
break
if __name__ == "__main__":
unittest.main()
+15
View File
@@ -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] = []
+497
View File
@@ -0,0 +1,497 @@
"""Dialogs for Fluency CAD operations: extrude, revolve, offset, workplane orientation."""
from __future__ import annotations
import logging
import math
from typing import Any, Dict, List, Optional, Tuple
from PySide6.QtCore import Qt, QPoint, QPointF
from PySide6.QtGui import QColor, QFont, QKeySequence
from PySide6.QtWidgets import (
QButtonGroup,
QCheckBox,
QComboBox,
QDialog,
QDialogButtonBox,
QDoubleSpinBox,
QFormLayout,
QFrame,
QGridLayout,
QHBoxLayout,
QLabel,
QLineEdit,
QPushButton,
QRadioButton,
QVBoxLayout,
QWidget,
)
logger = logging.getLogger(__name__)
class ExtrudeDialog(QDialog):
"""Dialog for extrude options.
Carries an optional ``preview_callback`` that is invoked whenever the
user changes any option; the host uses it to render a live transparent
preview of the operation result in the 3D view. Passing *False* (or
*None*) to the callback tells the host to clear the preview.
"""
def __init__(self, parent=None):
super().__init__(parent)
self.setWindowTitle("Extrude Options")
self.setMinimumWidth(320)
self._preview_callback = None
layout = QVBoxLayout(self)
length_layout = QHBoxLayout()
length_layout.addWidget(QLabel("Extrude Length (mm):"))
self.length_input = QDoubleSpinBox()
self.length_input.setDecimals(2)
self.length_input.setRange(-10000, 10000)
self.length_input.setValue(10)
length_layout.addWidget(self.length_input)
layout.addLayout(length_layout)
self.symmetric_checkbox = QCheckBox("Symmetric Extrude")
layout.addWidget(self.symmetric_checkbox)
self.invert_checkbox = QCheckBox("Invert Extrusion")
layout.addWidget(self.invert_checkbox)
self.cut_checkbox = QCheckBox("Perform Cut")
layout.addWidget(self.cut_checkbox)
self.union_checkbox = QCheckBox("Combine (Union)")
layout.addWidget(self.union_checkbox)
self.through_all_checkbox = QCheckBox("Through All (cut/union target)")
self.through_all_checkbox.setToolTip(
"Ignore the typed length and extrude far enough to fully pass "
"through the cut/union target body. Applies when Perform Cut or "
"Combine (Union) is checked."
)
layout.addWidget(self.through_all_checkbox)
self.rounded_checkbox = QCheckBox("Round Edges")
layout.addWidget(self.rounded_checkbox)
line = QFrame()
line.setFrameShape(QFrame.HLine)
line.setFrameShadow(QFrame.Sunken)
layout.addWidget(line)
button_layout = QHBoxLayout()
ok_button = QPushButton("OK")
ok_button.clicked.connect(self.accept)
cancel_button = QPushButton("Cancel")
cancel_button.clicked.connect(self.reject)
button_layout.addWidget(ok_button)
button_layout.addWidget(cancel_button)
layout.addLayout(button_layout)
# Live preview: recompute on every option change. Use a light-
# weight guard so we don't emit before the host has wired up the
# callback.
for w in (
self.length_input,
self.symmetric_checkbox,
self.invert_checkbox,
self.cut_checkbox,
self.union_checkbox,
self.through_all_checkbox,
self.rounded_checkbox,
):
# The spinbox has valueChanged; the checkboxes have stateChanged.
# Each must be wired in its own try/except so that a missing
# signal on one widget type doesn't skip the OTHER signal's
# connection (the prior single-try version accidentally
# left checkboxes un-connected when valueChanged raised first).
try:
w.valueChanged.connect(self._emit_preview)
except AttributeError:
pass
try:
w.stateChanged.connect(self._emit_preview)
except AttributeError:
pass
def set_preview_callback(self, callback) -> None:
"""Install the live-preview callback (or *None* to disable)."""
self._preview_callback = callback
# Emit once so the initial state shows a preview right away.
self._emit_preview()
def _emit_preview(self, *args) -> None:
if self._preview_callback is None:
return
try:
self._preview_callback(self.get_values())
except Exception as exc: # preview must never break the dialog
logger.debug("extrude preview callback raised: %s", exc)
def hideEvent(self, event):
# Tell the host to clear the preview when the dialog goes away
# (accept, reject, or close). The host is responsible for the
# actual viewer cleanup.
if self._preview_callback is not None:
try:
self._preview_callback(None)
except Exception:
pass
super().hideEvent(event)
def get_values(self) -> Tuple[float, bool, bool, bool, bool, bool, bool]:
return (
self.length_input.value(),
self.symmetric_checkbox.isChecked(),
self.invert_checkbox.isChecked(),
self.cut_checkbox.isChecked(),
self.union_checkbox.isChecked(),
self.through_all_checkbox.isChecked(),
self.rounded_checkbox.isChecked(),
)
class RevolveDialog(QDialog):
"""Dialog for revolve options."""
def __init__(self, parent=None):
super().__init__(parent)
self.setWindowTitle("Revolve Options")
self.setMinimumWidth(300)
layout = QVBoxLayout(self)
angle_layout = QHBoxLayout()
angle_layout.addWidget(QLabel("Revolve Angle (°):"))
self.angle_input = QDoubleSpinBox()
self.angle_input.setDecimals(1)
self.angle_input.setRange(1, 360)
self.angle_input.setValue(360)
self.angle_input.setSuffix("°")
angle_layout.addWidget(self.angle_input)
layout.addLayout(angle_layout)
line = QFrame()
line.setFrameShape(QFrame.HLine)
line.setFrameShadow(QFrame.Sunken)
layout.addWidget(line)
button_layout = QHBoxLayout()
ok_button = QPushButton("OK")
ok_button.clicked.connect(self.accept)
cancel_button = QPushButton("Cancel")
cancel_button.clicked.connect(self.reject)
button_layout.addWidget(ok_button)
button_layout.addWidget(cancel_button)
layout.addLayout(button_layout)
class OffsetDialog(QDialog):
"""Dialog for 2D sketch offset options.
Shows a number input for the offset distance with a live preview
callback so the sketch widget can render the offset result in real
time. On accept the caller retrieves ``get_values()`` distance.
"""
def __init__(self, parent=None):
super().__init__(parent)
self.setWindowTitle("Offset Sketch")
self.setMinimumWidth(300)
self._preview_callback = None
layout = QVBoxLayout(self)
dist_layout = QHBoxLayout()
dist_layout.addWidget(QLabel("Offset Distance (mm):"))
self.distance_input = QDoubleSpinBox()
self.distance_input.setDecimals(2)
self.distance_input.setRange(-10000, 10000)
self.distance_input.setValue(10.0)
self.distance_input.setSingleStep(0.5)
dist_layout.addWidget(self.distance_input)
layout.addLayout(dist_layout)
self.inward_checkbox = QCheckBox("Offset Inward (negative)")
self.inward_checkbox.setToolTip("Offset is applied inward instead of outward.")
layout.addWidget(self.inward_checkbox)
line = QFrame()
line.setFrameShape(QFrame.HLine)
line.setFrameShadow(QFrame.Sunken)
layout.addWidget(line)
button_layout = QHBoxLayout()
ok_button = QPushButton("OK")
ok_button.clicked.connect(self.accept)
cancel_button = QPushButton("Cancel")
cancel_button.clicked.connect(self.reject)
button_layout.addWidget(ok_button)
button_layout.addWidget(cancel_button)
layout.addLayout(button_layout)
# Live preview on every value change.
self.distance_input.valueChanged.connect(self._emit_preview)
self.inward_checkbox.stateChanged.connect(self._emit_preview)
def set_preview_callback(self, callback) -> None:
"""Install the live-preview callback (or *None* to disable)."""
self._preview_callback = callback
self._emit_preview()
def _emit_preview(self, *args) -> None:
if self._preview_callback is None:
return
try:
self._preview_callback(self.get_values())
except Exception as exc:
logger.debug("offset preview callback raised: %s", exc)
def hideEvent(self, event):
if self._preview_callback is not None:
try:
self._preview_callback(None)
except Exception:
pass
super().hideEvent(event)
def get_values(self) -> Tuple[float, bool]:
return (self.distance_input.value(), self.inward_checkbox.isChecked())
class WorkplaneOrientationDialog(QDialog):
"""Modal dialog to choose the orientation of a new workplane.
Offers XY, XZ, YZ, and custom angle presets. On accept, the caller
can retrieve the chosen orientation via :meth:`get_orientation`, which
returns (normal, x_dir) pair (both as 3-tuples).
"""
def __init__(self, parent=None):
super().__init__(parent)
self.setWindowTitle("New Workplane Orientation")
self.setMinimumWidth(320)
self._normal: Tuple[float, float, float] = (0.0, 0.0, 1.0)
self._x_dir: Tuple[float, float, float] = (1.0, 0.0, 0.0)
# Optional callback for live 3D preview of the workplane.
# The host installs it via ``set_preview_callback``. The callback
# receives ``(normal, x_dir)`` or *None* to clear.
self._preview_callback = None
layout = QVBoxLayout(self)
# ── Orientation presets ──
lbl = QLabel("Choose orientation:")
layout.addWidget(lbl)
self._preset_group = QButtonGroup(self)
preset_layout = QGridLayout()
presets = [
("XY (Top)", (0, 0, 1), (1, 0, 0)),
("XZ (Front)", (0, 1, 0), (1, 0, 0)),
("YZ (Right)", (1, 0, 0), (0, 1, 0)),
("-XY (Bottom)", (0, 0, -1), (1, 0, 0)),
("-XZ (Back)", (0, -1, 0), (1, 0, 0)),
("-YZ (Left)", (-1, 0, 0), (0, 1, 0)),
]
for idx, (label, normal, x_dir) in enumerate(presets):
btn = QRadioButton(label)
btn.setChecked(idx == 0)
self._preset_group.addButton(btn, idx)
btn.normal = normal
btn.x_dir = x_dir
preset_layout.addWidget(btn, idx // 2, idx % 2)
layout.addLayout(preset_layout)
# ── Custom angle (offset from XY) ──
line = QFrame()
line.setFrameShape(QFrame.HLine)
line.setFrameShadow(QFrame.Sunken)
layout.addWidget(line)
self._custom_radio = QRadioButton("Custom (angle from XY):")
self._custom_radio.setChecked(False)
layout.addWidget(self._custom_radio)
angle_layout = QHBoxLayout()
angle_layout.addWidget(QLabel("Angle X (°):"))
self._angle_x = QDoubleSpinBox()
self._angle_x.setDecimals(1)
self._angle_x.setRange(-360, 360)
self._angle_x.setValue(0.0)
self._angle_x.setSuffix("°")
angle_layout.addWidget(self._angle_x)
angle_layout.addWidget(QLabel("Angle Y (°):"))
self._angle_y = QDoubleSpinBox()
self._angle_y.setDecimals(1)
self._angle_y.setRange(-360, 360)
self._angle_y.setValue(0.0)
self._angle_y.setSuffix("°")
angle_layout.addWidget(self._angle_y)
layout.addLayout(angle_layout)
self._name_label = QLabel("Workplane Name:")
layout.addWidget(self._name_label)
self._name_input = QLineEdit()
self._name_input.setText("Workplane 1")
layout.addWidget(self._name_input)
# ── Buttons ──
line2 = QFrame()
line2.setFrameShape(QFrame.HLine)
line2.setFrameShadow(QFrame.Sunken)
layout.addWidget(line2)
button_layout = QHBoxLayout()
ok_button = QPushButton("Create")
ok_button.clicked.connect(self._on_ok)
cancel_button = QPushButton("Cancel")
cancel_button.clicked.connect(self.reject)
button_layout.addWidget(ok_button)
button_layout.addWidget(cancel_button)
layout.addLayout(button_layout)
# Live preview: update the 3D view whenever the user changes
# the preset, custom radio toggle, or angle values.
self._preset_group.buttonClicked.connect(self._on_preset_changed)
self._custom_radio.toggled.connect(self._emit_preview)
self._angle_x.valueChanged.connect(self._emit_preview)
self._angle_y.valueChanged.connect(self._emit_preview)
def set_preview_callback(self, callback) -> None:
"""Install a callback for live 3D preview of the workplane orientation.
*callback* is called with ``(normal, x_dir)`` whenever the user
changes the selection, or with *None* when the dialog closes.
"""
self._preview_callback = callback
# Emit once so the initial state shows a preview right away.
self._emit_preview()
def _emit_preview(self, *args) -> None:
"""Call the preview callback with the current orientation, if installed."""
if self._preview_callback is None:
return
try:
normal, x_dir, _name = self.get_orientation()
self._preview_callback((normal, x_dir))
except Exception as exc:
logger.debug("workplane preview callback raised: %s", exc)
def hideEvent(self, event):
"""Clear the live preview when the dialog closes."""
if self._preview_callback is not None:
try:
self._preview_callback(None)
except Exception:
pass
super().hideEvent(event)
def _on_preset_changed(self, btn):
"""When a preset is selected, deselect the custom radio and emit preview."""
self._custom_radio.setChecked(False)
self._emit_preview()
def _on_ok(self):
"""Compute the final orientation and accept."""
import numpy as np
import math
if self._custom_radio.isChecked():
# Custom: start from XY normal and rotate by the two angles.
ax = math.radians(self._angle_x.value())
ay = math.radians(self._angle_y.value())
# Start from +Z normal, rotate around X then Y
n = np.array([0.0, 0.0, 1.0])
# Rotate around X
rx = np.array([
[1, 0, 0],
[0, math.cos(ax), -math.sin(ax)],
[0, math.sin(ax), math.cos(ax)],
])
n = rx @ n
# Rotate around Y
ry = np.array([
[math.cos(ay), 0, math.sin(ay)],
[0, 1, 0],
[-math.sin(ay), 0, math.cos(ay)],
])
n = ry @ n
n = n / np.linalg.norm(n)
# x_dir: cross product of normal with world Y, or world Z if normal ~ Y
world_y = np.array([0.0, 1.0, 0.0])
if abs(np.dot(n, world_y)) > 0.99:
world_y = np.array([0.0, 0.0, 1.0])
x = np.cross(world_y, n)
x_norm = np.linalg.norm(x)
if x_norm > 1e-9:
x = x / x_norm
else:
x = np.array([1.0, 0.0, 0.0])
self._normal = tuple(float(v) for v in n)
self._x_dir = tuple(float(v) for v in x)
else:
btn = self._preset_group.checkedButton()
if btn is not None:
self._normal = btn.normal
self._x_dir = btn.x_dir
self.accept()
def get_orientation(self) -> Tuple[Tuple[float, float, float], Tuple[float, float, float], str]:
"""Return (normal, x_dir, name) for the chosen workplane.
Computes the current selection from the UI state so it works
whether called before or after ``_on_ok``.
"""
import numpy as np
import math
if self._custom_radio.isChecked():
ax = math.radians(self._angle_x.value())
ay = math.radians(self._angle_y.value())
n = np.array([0.0, 0.0, 1.0])
rx = np.array([
[1, 0, 0],
[0, math.cos(ax), -math.sin(ax)],
[0, math.sin(ax), math.cos(ax)],
])
n = rx @ n
ry = np.array([
[math.cos(ay), 0, math.sin(ay)],
[0, 1, 0],
[-math.sin(ay), 0, math.cos(ay)],
])
n = ry @ n
n = n / np.linalg.norm(n)
world_y = np.array([0.0, 1.0, 0.0])
if abs(np.dot(n, world_y)) > 0.99:
world_y = np.array([0.0, 0.0, 1.0])
x = np.cross(world_y, n)
x_norm = np.linalg.norm(x)
if x_norm > 1e-9:
x = x / x_norm
else:
x = np.array([1.0, 0.0, 0.0])
return (
tuple(float(v) for v in n),
tuple(float(v) for v in x),
self._name_input.text().strip() or "Workplane",
)
else:
btn = self._preset_group.checkedButton()
if btn is not None:
return (btn.normal, btn.x_dir, self._name_input.text().strip() or "Workplane")
# Fallback: XY default.
return ((0.0, 0.0, 1.0), (1.0, 0.0, 0.0), self._name_input.text().strip() or "Workplane")
+906
View File
@@ -0,0 +1,906 @@
# -*- coding: utf-8 -*-
################################################################################
## Form generated from reading UI file 'gui.ui'
##
## Created by: Qt User Interface Compiler version 6.10.2
##
## WARNING! All changes made in this file will be lost when recompiling UI file!
################################################################################
from PySide6.QtCore import (QCoreApplication, QDate, QDateTime, QLocale,
QMetaObject, QObject, QPoint, QRect,
QSize, QTime, QUrl, Qt)
from PySide6.QtGui import (QAction, QBrush, QColor, QConicalGradient,
QCursor, QFont, QFontDatabase, QGradient,
QIcon, QImage, QKeySequence, QLinearGradient,
QPainter, QPalette, QPixmap, QRadialGradient,
QTransform)
from PySide6.QtWidgets import (QApplication, QFrame, QGridLayout, QGroupBox,
QHBoxLayout, QLabel, QListWidget, QListWidgetItem,
QMainWindow, QMenu, QMenuBar, QPushButton,
QSizePolicy, QSpinBox, QStatusBar, QTabWidget,
QTextEdit, QVBoxLayout, QWidget)
class Ui_fluencyCAD(object):
def setupUi(self, fluencyCAD):
if not fluencyCAD.objectName():
fluencyCAD.setObjectName(u"fluencyCAD")
fluencyCAD.resize(2359, 1285)
self.actionNew_Project = QAction(fluencyCAD)
self.actionNew_Project.setObjectName(u"actionNew_Project")
self.actionOpen_Project = QAction(fluencyCAD)
self.actionOpen_Project.setObjectName(u"actionOpen_Project")
self.actionSave_Project = QAction(fluencyCAD)
self.actionSave_Project.setObjectName(u"actionSave_Project")
self.actionSave_Project_As = QAction(fluencyCAD)
self.actionSave_Project_As.setObjectName(u"actionSave_Project_As")
self.actionImport_File = QAction(fluencyCAD)
self.actionImport_File.setObjectName(u"actionImport_File")
self.actionExport_Step = QAction(fluencyCAD)
self.actionExport_Step.setObjectName(u"actionExport_Step")
self.actionExport_Iges = QAction(fluencyCAD)
self.actionExport_Iges.setObjectName(u"actionExport_Iges")
self.actionExport_Stl = QAction(fluencyCAD)
self.actionExport_Stl.setObjectName(u"actionExport_Stl")
self.actionExit = QAction(fluencyCAD)
self.actionExit.setObjectName(u"actionExit")
self.centralwidget = QWidget(fluencyCAD)
self.centralwidget.setObjectName(u"centralwidget")
self.gridLayout = QGridLayout(self.centralwidget)
self.gridLayout.setObjectName(u"gridLayout")
self.InputTab = QTabWidget(self.centralwidget)
self.InputTab.setObjectName(u"InputTab")
sizePolicy = QSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
sizePolicy.setHorizontalStretch(0)
sizePolicy.setVerticalStretch(0)
sizePolicy.setHeightForWidth(self.InputTab.sizePolicy().hasHeightForWidth())
self.InputTab.setSizePolicy(sizePolicy)
self.sketch_tab = QWidget()
self.sketch_tab.setObjectName(u"sketch_tab")
self.verticalLayout_4 = QVBoxLayout(self.sketch_tab)
self.verticalLayout_4.setObjectName(u"verticalLayout_4")
self.InputTab.addTab(self.sketch_tab, "")
self.code_tab = QWidget()
self.code_tab.setObjectName(u"code_tab")
self.verticalLayout = QVBoxLayout(self.code_tab)
self.verticalLayout.setObjectName(u"verticalLayout")
self.textEdit = QTextEdit(self.code_tab)
self.textEdit.setObjectName(u"textEdit")
self.verticalLayout.addWidget(self.textEdit)
self.groupBox_7 = QGroupBox(self.code_tab)
self.groupBox_7.setObjectName(u"groupBox_7")
self.gridLayout_5 = QGridLayout(self.groupBox_7)
self.gridLayout_5.setObjectName(u"gridLayout_5")
self.pushButton_5 = QPushButton(self.groupBox_7)
self.pushButton_5.setObjectName(u"pushButton_5")
self.gridLayout_5.addWidget(self.pushButton_5, 2, 0, 1, 1)
self.pushButton_4 = QPushButton(self.groupBox_7)
self.pushButton_4.setObjectName(u"pushButton_4")
self.gridLayout_5.addWidget(self.pushButton_4, 2, 1, 1, 1)
self.pb_apply_code = QPushButton(self.groupBox_7)
self.pb_apply_code.setObjectName(u"pb_apply_code")
self.gridLayout_5.addWidget(self.pb_apply_code, 1, 0, 1, 1)
self.pushButton = QPushButton(self.groupBox_7)
self.pushButton.setObjectName(u"pushButton")
self.gridLayout_5.addWidget(self.pushButton, 1, 1, 1, 1)
self.verticalLayout.addWidget(self.groupBox_7)
self.InputTab.addTab(self.code_tab, "")
self.gridLayout.addWidget(self.InputTab, 0, 1, 12, 1)
self.groupBox_9 = QGroupBox(self.centralwidget)
self.groupBox_9.setObjectName(u"groupBox_9")
self.groupBox_9.setMaximumSize(QSize(200, 16777215))
self.gridLayout_7 = QGridLayout(self.groupBox_9)
self.gridLayout_7.setObjectName(u"gridLayout_7")
self.pb_origin_wp = QPushButton(self.groupBox_9)
self.pb_origin_wp.setObjectName(u"pb_origin_wp")
self.gridLayout_7.addWidget(self.pb_origin_wp, 0, 0, 1, 1)
self.pb_origin_face = QPushButton(self.groupBox_9)
self.pb_origin_face.setObjectName(u"pb_origin_face")
self.pb_origin_face.setCheckable(True)
self.gridLayout_7.addWidget(self.pb_origin_face, 0, 1, 1, 1)
self.pb_flip_face = QPushButton(self.groupBox_9)
self.pb_flip_face.setObjectName(u"pb_flip_face")
self.gridLayout_7.addWidget(self.pb_flip_face, 1, 0, 1, 1)
self.pb_underlay = QPushButton(self.groupBox_9)
self.pb_underlay.setObjectName(u"pb_underlay")
self.pb_underlay.setEnabled(False)
self.pb_underlay.setCheckable(True)
self.pb_underlay.setChecked(True)
self.gridLayout_7.addWidget(self.pb_underlay, 3, 0, 1, 1)
self.pb_clr_face = QPushButton(self.groupBox_9)
self.pb_clr_face.setObjectName(u"pb_clr_face")
self.pb_clr_face.setEnabled(False)
self.gridLayout_7.addWidget(self.pb_clr_face, 3, 1, 1, 1)
self.pb_to_sketch = QPushButton(self.groupBox_9)
self.pb_to_sketch.setObjectName(u"pb_to_sketch")
self.pb_to_sketch.setEnabled(False)
self.gridLayout_7.addWidget(self.pb_to_sketch, 4, 0, 1, 2)
self.pb_wp_new = QPushButton(self.groupBox_9)
self.pb_wp_new.setObjectName(u"pb_wp_new")
self.gridLayout_7.addWidget(self.pb_wp_new, 1, 1, 1, 1)
self.gridLayout.addWidget(self.groupBox_9, 0, 0, 1, 1)
self.assembly_box = QGroupBox(self.centralwidget)
self.assembly_box.setObjectName(u"assembly_box")
self.assembly_box.setMinimumSize(QSize(0, 50))
self.gridLayout.addWidget(self.assembly_box, 13, 1, 1, 2)
self.joint_tools = QGroupBox(self.centralwidget)
self.joint_tools.setObjectName(u"joint_tools")
self.joint_tools.setMinimumSize(QSize(0, 50))
self.gridLayout_10 = QGridLayout(self.joint_tools)
self.gridLayout_10.setObjectName(u"gridLayout_10")
self.pb_remove_connector = QPushButton(self.joint_tools)
self.pb_remove_connector.setObjectName(u"pb_remove_connector")
self.pb_remove_connector.setMinimumSize(QSize(50, 50))
self.pb_remove_connector.setMaximumSize(QSize(50, 50))
self.gridLayout_10.addWidget(self.pb_remove_connector, 0, 2, 1, 1)
self.pb_add_connector = QPushButton(self.joint_tools)
self.pb_add_connector.setObjectName(u"pb_add_connector")
self.pb_add_connector.setMinimumSize(QSize(50, 50))
self.pb_add_connector.setMaximumSize(QSize(50, 50))
self.gridLayout_10.addWidget(self.pb_add_connector, 0, 1, 1, 1)
self.pb_add_connector_2 = QPushButton(self.joint_tools)
self.pb_add_connector_2.setObjectName(u"pb_add_connector_2")
self.pb_add_connector_2.setMinimumSize(QSize(50, 50))
self.pb_add_connector_2.setMaximumSize(QSize(50, 50))
self.gridLayout_10.addWidget(self.pb_add_connector_2, 1, 1, 1, 1)
self.pb_add_connector_3 = QPushButton(self.joint_tools)
self.pb_add_connector_3.setObjectName(u"pb_add_connector_3")
self.pb_add_connector_3.setMinimumSize(QSize(50, 50))
self.pb_add_connector_3.setMaximumSize(QSize(50, 50))
self.gridLayout_10.addWidget(self.pb_add_connector_3, 1, 2, 1, 1)
self.gridLayout.addWidget(self.joint_tools, 12, 3, 2, 1)
self.gl_box = QGroupBox(self.centralwidget)
self.gl_box.setObjectName(u"gl_box")
sizePolicy1 = QSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
sizePolicy1.setHorizontalStretch(0)
sizePolicy1.setVerticalStretch(4)
sizePolicy1.setHeightForWidth(self.gl_box.sizePolicy().hasHeightForWidth())
self.gl_box.setSizePolicy(sizePolicy1)
font = QFont()
font.setPointSize(12)
self.gl_box.setFont(font)
self.horizontalLayout_4 = QHBoxLayout(self.gl_box)
#ifndef Q_OS_MAC
self.horizontalLayout_4.setSpacing(-1)
#endif
self.horizontalLayout_4.setObjectName(u"horizontalLayout_4")
self.horizontalLayout_4.setContentsMargins(12, -1, -1, -1)
self.gridLayout.addWidget(self.gl_box, 0, 2, 12, 1)
self.groupBox_11 = QGroupBox(self.centralwidget)
self.groupBox_11.setObjectName(u"groupBox_11")
sizePolicy2 = QSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Expanding)
sizePolicy2.setHorizontalStretch(0)
sizePolicy2.setVerticalStretch(0)
sizePolicy2.setHeightForWidth(self.groupBox_11.sizePolicy().hasHeightForWidth())
self.groupBox_11.setSizePolicy(sizePolicy2)
self.groupBox_11.setMaximumSize(QSize(200, 16777215))
self.verticalLayout_7 = QVBoxLayout(self.groupBox_11)
self.verticalLayout_7.setObjectName(u"verticalLayout_7")
self.verticalLayout_7.setContentsMargins(5, 5, 5, 5)
self.sketch_list = QListWidget(self.groupBox_11)
self.sketch_list.setObjectName(u"sketch_list")
sizePolicy3 = QSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
sizePolicy3.setHorizontalStretch(0)
sizePolicy3.setVerticalStretch(0)
sizePolicy3.setHeightForWidth(self.sketch_list.sizePolicy().hasHeightForWidth())
self.sketch_list.setSizePolicy(sizePolicy3)
self.sketch_list.setSelectionRectVisible(True)
self.verticalLayout_7.addWidget(self.sketch_list)
self.groupBox_6 = QGroupBox(self.groupBox_11)
self.groupBox_6.setObjectName(u"groupBox_6")
sizePolicy4 = QSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Preferred)
sizePolicy4.setHorizontalStretch(0)
sizePolicy4.setVerticalStretch(0)
sizePolicy4.setHeightForWidth(self.groupBox_6.sizePolicy().hasHeightForWidth())
self.groupBox_6.setSizePolicy(sizePolicy4)
self.gridLayout_6 = QGridLayout(self.groupBox_6)
self.gridLayout_6.setObjectName(u"gridLayout_6")
self.gridLayout_6.setContentsMargins(2, 2, 2, 2)
self.pb_edt_sktch = QPushButton(self.groupBox_6)
self.pb_edt_sktch.setObjectName(u"pb_edt_sktch")
self.gridLayout_6.addWidget(self.pb_edt_sktch, 1, 1, 1, 1)
self.pb_nw_sktch = QPushButton(self.groupBox_6)
self.pb_nw_sktch.setObjectName(u"pb_nw_sktch")
self.gridLayout_6.addWidget(self.pb_nw_sktch, 1, 0, 1, 1)
self.pb_del_sketch = QPushButton(self.groupBox_6)
self.pb_del_sketch.setObjectName(u"pb_del_sketch")
self.gridLayout_6.addWidget(self.pb_del_sketch, 1, 2, 1, 1)
self.verticalLayout_7.addWidget(self.groupBox_6)
self.gridLayout.addWidget(self.groupBox_11, 6, 0, 6, 1)
self.assembly_tools = QGroupBox(self.centralwidget)
self.assembly_tools.setObjectName(u"assembly_tools")
self.assembly_tools.setMinimumSize(QSize(0, 50))
self.gridLayout_12 = QGridLayout(self.assembly_tools)
self.gridLayout_12.setObjectName(u"gridLayout_12")
self.pb_compo_to_assembly = QPushButton(self.assembly_tools)
self.pb_compo_to_assembly.setObjectName(u"pb_compo_to_assembly")
self.pb_compo_to_assembly.setMinimumSize(QSize(50, 50))
self.pb_compo_to_assembly.setMaximumSize(QSize(50, 50))
self.gridLayout_12.addWidget(self.pb_compo_to_assembly, 0, 0, 1, 1)
self.pb_remove_compo_from_assembly = QPushButton(self.assembly_tools)
self.pb_remove_compo_from_assembly.setObjectName(u"pb_remove_compo_from_assembly")
self.pb_remove_compo_from_assembly.setEnabled(True)
sizePolicy4.setHeightForWidth(self.pb_remove_compo_from_assembly.sizePolicy().hasHeightForWidth())
self.pb_remove_compo_from_assembly.setSizePolicy(sizePolicy4)
self.pb_remove_compo_from_assembly.setMinimumSize(QSize(50, 50))
self.pb_remove_compo_from_assembly.setMaximumSize(QSize(50, 50))
self.pb_remove_compo_from_assembly.setLayoutDirection(Qt.LeftToRight)
self.gridLayout_12.addWidget(self.pb_remove_compo_from_assembly, 0, 1, 1, 1)
self.gridLayout.addWidget(self.assembly_tools, 13, 0, 1, 1)
self.compo_tool_box = QGroupBox(self.centralwidget)
self.compo_tool_box.setObjectName(u"compo_tool_box")
self.compo_tool_box.setMinimumSize(QSize(0, 50))
self.gridLayout_9 = QGridLayout(self.compo_tool_box)
self.gridLayout_9.setObjectName(u"gridLayout_9")
self.pb_new_compo = QPushButton(self.compo_tool_box)
self.pb_new_compo.setObjectName(u"pb_new_compo")
self.pb_new_compo.setMinimumSize(QSize(50, 50))
self.pb_new_compo.setMaximumSize(QSize(50, 50))
self.gridLayout_9.addWidget(self.pb_new_compo, 0, 0, 1, 1)
self.pb_del_compo = QPushButton(self.compo_tool_box)
self.pb_del_compo.setObjectName(u"pb_del_compo")
self.pb_del_compo.setEnabled(True)
sizePolicy4.setHeightForWidth(self.pb_del_compo.sizePolicy().hasHeightForWidth())
self.pb_del_compo.setSizePolicy(sizePolicy4)
self.pb_del_compo.setMinimumSize(QSize(50, 50))
self.pb_del_compo.setMaximumSize(QSize(50, 50))
self.pb_del_compo.setLayoutDirection(Qt.LeftToRight)
self.gridLayout_9.addWidget(self.pb_del_compo, 0, 1, 1, 1)
self.gridLayout.addWidget(self.compo_tool_box, 12, 0, 1, 1)
self.compo_box = QGroupBox(self.centralwidget)
self.compo_box.setObjectName(u"compo_box")
self.compo_box.setMinimumSize(QSize(0, 50))
self.gridLayout.addWidget(self.compo_box, 12, 1, 1, 2)
self.groupBox_12 = QGroupBox(self.centralwidget)
self.groupBox_12.setObjectName(u"groupBox_12")
sizePolicy2.setHeightForWidth(self.groupBox_12.sizePolicy().hasHeightForWidth())
self.groupBox_12.setSizePolicy(sizePolicy2)
self.groupBox_12.setMaximumSize(QSize(200, 16777215))
self.verticalLayout_8 = QVBoxLayout(self.groupBox_12)
self.verticalLayout_8.setObjectName(u"verticalLayout_8")
self.verticalLayout_8.setContentsMargins(5, 5, 5, 5)
self.connection_list = QListWidget(self.groupBox_12)
self.connection_list.setObjectName(u"connection_list")
self.connection_list.setSelectionRectVisible(True)
self.verticalLayout_8.addWidget(self.connection_list)
self.groupBox_13 = QGroupBox(self.groupBox_12)
self.groupBox_13.setObjectName(u"groupBox_13")
sizePolicy4.setHeightForWidth(self.groupBox_13.sizePolicy().hasHeightForWidth())
self.groupBox_13.setSizePolicy(sizePolicy4)
self.groupBox_13.setMaximumSize(QSize(200, 16777215))
self.gridLayout_13 = QGridLayout(self.groupBox_13)
self.gridLayout_13.setObjectName(u"gridLayout_13")
self.gridLayout_13.setContentsMargins(2, 2, 2, 2)
self.pb_del_connection = QPushButton(self.groupBox_13)
self.pb_del_connection.setObjectName(u"pb_del_connection")
self.gridLayout_13.addWidget(self.pb_del_connection, 0, 2, 1, 1)
self.pb_update_connection = QPushButton(self.groupBox_13)
self.pb_update_connection.setObjectName(u"pb_update_connection")
self.gridLayout_13.addWidget(self.pb_update_connection, 0, 0, 1, 1)
self.pb_edt_sktch_4 = QPushButton(self.groupBox_13)
self.pb_edt_sktch_4.setObjectName(u"pb_edt_sktch_4")
self.gridLayout_13.addWidget(self.pb_edt_sktch_4, 0, 1, 1, 1)
self.verticalLayout_8.addWidget(self.groupBox_13)
self.gridLayout.addWidget(self.groupBox_12, 6, 3, 6, 1)
self.groupBox_10 = QGroupBox(self.centralwidget)
self.groupBox_10.setObjectName(u"groupBox_10")
sizePolicy2.setHeightForWidth(self.groupBox_10.sizePolicy().hasHeightForWidth())
self.groupBox_10.setSizePolicy(sizePolicy2)
self.groupBox_10.setMaximumSize(QSize(200, 16777215))
self.verticalLayout_6 = QVBoxLayout(self.groupBox_10)
self.verticalLayout_6.setObjectName(u"verticalLayout_6")
self.verticalLayout_6.setContentsMargins(5, 5, 5, 5)
self.body_list = QListWidget(self.groupBox_10)
self.body_list.setObjectName(u"body_list")
self.body_list.setSelectionRectVisible(True)
self.verticalLayout_6.addWidget(self.body_list)
self.groupBox_8 = QGroupBox(self.groupBox_10)
self.groupBox_8.setObjectName(u"groupBox_8")
sizePolicy4.setHeightForWidth(self.groupBox_8.sizePolicy().hasHeightForWidth())
self.groupBox_8.setSizePolicy(sizePolicy4)
self.groupBox_8.setMaximumSize(QSize(200, 16777215))
self.gridLayout_8 = QGridLayout(self.groupBox_8)
self.gridLayout_8.setObjectName(u"gridLayout_8")
self.gridLayout_8.setContentsMargins(2, 2, 2, 2)
self.pb_del_body = QPushButton(self.groupBox_8)
self.pb_del_body.setObjectName(u"pb_del_body")
self.gridLayout_8.addWidget(self.pb_del_body, 0, 2, 1, 1)
self.pb_update_body = QPushButton(self.groupBox_8)
self.pb_update_body.setObjectName(u"pb_update_body")
self.gridLayout_8.addWidget(self.pb_update_body, 0, 0, 1, 1)
self.pb_edt_sktch_3 = QPushButton(self.groupBox_8)
self.pb_edt_sktch_3.setObjectName(u"pb_edt_sktch_3")
self.gridLayout_8.addWidget(self.pb_edt_sktch_3, 0, 1, 1, 1)
self.verticalLayout_6.addWidget(self.groupBox_8)
self.gridLayout.addWidget(self.groupBox_10, 3, 3, 3, 1)
self.groupBox_2 = QGroupBox(self.centralwidget)
self.groupBox_2.setObjectName(u"groupBox_2")
sizePolicy4.setHeightForWidth(self.groupBox_2.sizePolicy().hasHeightForWidth())
self.groupBox_2.setSizePolicy(sizePolicy4)
self.groupBox_2.setMaximumSize(QSize(200, 16777215))
self.gridLayout_2 = QGridLayout(self.groupBox_2)
self.gridLayout_2.setObjectName(u"gridLayout_2")
self.gridLayout_2.setContentsMargins(10, -1, -1, -1)
self.pb_arc_tool = QPushButton(self.groupBox_2)
self.pb_arc_tool.setObjectName(u"pb_arc_tool")
self.pb_arc_tool.setCheckable(True)
self.gridLayout_2.addWidget(self.pb_arc_tool, 2, 0, 1, 1)
self.pb_rectool = QPushButton(self.groupBox_2)
self.pb_rectool.setObjectName(u"pb_rectool")
self.pb_rectool.setCheckable(True)
self.pb_rectool.setAutoExclusive(False)
self.gridLayout_2.addWidget(self.pb_rectool, 0, 1, 1, 1)
self.pb_circtool = QPushButton(self.groupBox_2)
self.pb_circtool.setObjectName(u"pb_circtool")
self.pb_circtool.setCheckable(True)
self.pb_circtool.setAutoExclusive(False)
self.gridLayout_2.addWidget(self.pb_circtool, 1, 0, 1, 1, Qt.AlignTop)
self.pb_enable_construct = QPushButton(self.groupBox_2)
self.pb_enable_construct.setObjectName(u"pb_enable_construct")
self.pb_enable_construct.setCheckable(True)
self.gridLayout_2.addWidget(self.pb_enable_construct, 4, 0, 1, 1)
self.pb_enable_snap = QPushButton(self.groupBox_2)
self.pb_enable_snap.setObjectName(u"pb_enable_snap")
self.pb_enable_snap.setIconSize(QSize(13, 16))
self.pb_enable_snap.setCheckable(True)
self.pb_enable_snap.setChecked(True)
self.gridLayout_2.addWidget(self.pb_enable_snap, 4, 1, 1, 1)
self.pb_linetool = QPushButton(self.groupBox_2)
self.pb_linetool.setObjectName(u"pb_linetool")
self.pb_linetool.setCheckable(True)
self.pb_linetool.setAutoExclusive(False)
self.gridLayout_2.addWidget(self.pb_linetool, 0, 0, 1, 1)
self.pb_slotool = QPushButton(self.groupBox_2)
self.pb_slotool.setObjectName(u"pb_slotool")
self.pb_slotool.setCheckable(True)
self.pb_slotool.setAutoExclusive(False)
self.gridLayout_2.addWidget(self.pb_slotool, 1, 1, 1, 1, Qt.AlignTop)
self.line = QFrame(self.groupBox_2)
self.line.setObjectName(u"line")
self.line.setFrameShape(QFrame.Shape.HLine)
self.line.setFrameShadow(QFrame.Shadow.Sunken)
self.gridLayout_2.addWidget(self.line, 3, 0, 1, 2)
self.pb_offset_tool = QPushButton(self.groupBox_2)
self.pb_offset_tool.setObjectName(u"pb_offset_tool")
self.gridLayout_2.addWidget(self.pb_offset_tool, 2, 1, 1, 1)
self.gridLayout.addWidget(self.groupBox_2, 1, 0, 1, 1)
self.groupBox_3 = QGroupBox(self.centralwidget)
self.groupBox_3.setObjectName(u"groupBox_3")
sizePolicy4.setHeightForWidth(self.groupBox_3.sizePolicy().hasHeightForWidth())
self.groupBox_3.setSizePolicy(sizePolicy4)
self.groupBox_3.setMaximumSize(QSize(200, 16777213))
self.gridLayout_4 = QGridLayout(self.groupBox_3)
self.gridLayout_4.setObjectName(u"gridLayout_4")
self.pb_con_ptpt = QPushButton(self.groupBox_3)
self.pb_con_ptpt.setObjectName(u"pb_con_ptpt")
self.pb_con_ptpt.setCheckable(True)
self.pb_con_ptpt.setAutoExclusive(False)
self.gridLayout_4.addWidget(self.pb_con_ptpt, 1, 0, 1, 1)
self.pb_con_vert = QPushButton(self.groupBox_3)
self.pb_con_vert.setObjectName(u"pb_con_vert")
self.pb_con_vert.setCheckable(True)
self.pb_con_vert.setAutoExclusive(False)
self.gridLayout_4.addWidget(self.pb_con_vert, 3, 1, 1, 1)
self.pb_con_sym = QPushButton(self.groupBox_3)
self.pb_con_sym.setObjectName(u"pb_con_sym")
self.pb_con_sym.setCheckable(True)
self.pb_con_sym.setAutoExclusive(False)
self.gridLayout_4.addWidget(self.pb_con_sym, 4, 1, 1, 1)
self.pb_con_mid = QPushButton(self.groupBox_3)
self.pb_con_mid.setObjectName(u"pb_con_mid")
self.pb_con_mid.setCheckable(True)
self.pb_con_mid.setAutoExclusive(False)
self.gridLayout_4.addWidget(self.pb_con_mid, 2, 0, 1, 1)
self.pb_con_line = QPushButton(self.groupBox_3)
self.pb_con_line.setObjectName(u"pb_con_line")
self.pb_con_line.setCheckable(True)
self.pb_con_line.setAutoExclusive(False)
self.gridLayout_4.addWidget(self.pb_con_line, 1, 1, 1, 1)
self.pb_con_horiz = QPushButton(self.groupBox_3)
self.pb_con_horiz.setObjectName(u"pb_con_horiz")
self.pb_con_horiz.setCheckable(True)
self.pb_con_horiz.setAutoExclusive(False)
self.gridLayout_4.addWidget(self.pb_con_horiz, 3, 0, 1, 1)
self.pb_con_dist = QPushButton(self.groupBox_3)
self.pb_con_dist.setObjectName(u"pb_con_dist")
self.pb_con_dist.setCheckable(True)
self.pb_con_dist.setAutoExclusive(False)
self.pb_con_dist.setAutoRepeatDelay(297)
self.gridLayout_4.addWidget(self.pb_con_dist, 4, 0, 1, 1)
self.pb_con_perp = QPushButton(self.groupBox_3)
self.pb_con_perp.setObjectName(u"pb_con_perp")
self.pb_con_perp.setCheckable(True)
self.pb_con_perp.setAutoExclusive(False)
self.gridLayout_4.addWidget(self.pb_con_perp, 2, 1, 1, 1)
self.pb_con_diameter = QPushButton(self.groupBox_3)
self.pb_con_diameter.setObjectName(u"pb_con_diameter")
self.gridLayout_4.addWidget(self.pb_con_diameter, 5, 0, 1, 1)
self.gridLayout.addWidget(self.groupBox_3, 2, 0, 1, 1)
self.groupBox_5 = QGroupBox(self.centralwidget)
self.groupBox_5.setObjectName(u"groupBox_5")
sizePolicy4.setHeightForWidth(self.groupBox_5.sizePolicy().hasHeightForWidth())
self.groupBox_5.setSizePolicy(sizePolicy4)
self.gridLayout_11 = QGridLayout(self.groupBox_5)
self.gridLayout_11.setObjectName(u"gridLayout_11")
self.gridLayout_11.setContentsMargins(12, 12, 12, 12)
self.label = QLabel(self.groupBox_5)
self.label.setObjectName(u"label")
self.gridLayout_11.addWidget(self.label, 5, 0, 1, 1)
self.pb_snap_vert = QPushButton(self.groupBox_5)
self.pb_snap_vert.setObjectName(u"pb_snap_vert")
self.pb_snap_vert.setCheckable(True)
self.pb_snap_vert.setAutoExclusive(False)
self.gridLayout_11.addWidget(self.pb_snap_vert, 2, 1, 1, 1)
self.line_2 = QFrame(self.groupBox_5)
self.line_2.setObjectName(u"line_2")
self.line_2.setFrameShape(QFrame.Shape.HLine)
self.line_2.setFrameShadow(QFrame.Shadow.Sunken)
self.gridLayout_11.addWidget(self.line_2, 4, 0, 1, 2)
self.label_2 = QLabel(self.groupBox_5)
self.label_2.setObjectName(u"label_2")
self.gridLayout_11.addWidget(self.label_2, 5, 1, 1, 1)
self.spinbox_snap_distance = QSpinBox(self.groupBox_5)
self.spinbox_snap_distance.setObjectName(u"spinbox_snap_distance")
self.spinbox_snap_distance.setMaximum(30)
self.spinbox_snap_distance.setValue(10)
self.gridLayout_11.addWidget(self.spinbox_snap_distance, 6, 0, 1, 1)
self.pushButton_7 = QPushButton(self.groupBox_5)
self.pushButton_7.setObjectName(u"pushButton_7")
self.pushButton_7.setCheckable(True)
self.pushButton_7.setAutoExclusive(False)
self.gridLayout_11.addWidget(self.pushButton_7, 3, 0, 1, 1)
self.pb_snap_horiz = QPushButton(self.groupBox_5)
self.pb_snap_horiz.setObjectName(u"pb_snap_horiz")
self.pb_snap_horiz.setCheckable(True)
self.pb_snap_horiz.setAutoExclusive(False)
self.gridLayout_11.addWidget(self.pb_snap_horiz, 2, 0, 1, 1)
self.spinbox_angle_steps = QSpinBox(self.groupBox_5)
self.spinbox_angle_steps.setObjectName(u"spinbox_angle_steps")
self.spinbox_angle_steps.setMaximum(180)
self.spinbox_angle_steps.setValue(15)
self.gridLayout_11.addWidget(self.spinbox_angle_steps, 6, 1, 1, 1)
self.pushButton_8 = QPushButton(self.groupBox_5)
self.pushButton_8.setObjectName(u"pushButton_8")
self.pushButton_8.setCheckable(True)
self.pushButton_8.setAutoExclusive(False)
self.gridLayout_11.addWidget(self.pushButton_8, 0, 0, 1, 1)
self.pb_snap_midp = QPushButton(self.groupBox_5)
self.pb_snap_midp.setObjectName(u"pb_snap_midp")
self.pb_snap_midp.setCheckable(True)
self.pb_snap_midp.setAutoExclusive(False)
self.gridLayout_11.addWidget(self.pb_snap_midp, 0, 1, 1, 1)
self.pb_snap_angle = QPushButton(self.groupBox_5)
self.pb_snap_angle.setObjectName(u"pb_snap_angle")
self.pb_snap_angle.setCheckable(True)
self.pb_snap_angle.setAutoExclusive(False)
self.gridLayout_11.addWidget(self.pb_snap_angle, 3, 1, 1, 1)
self.gridLayout.addWidget(self.groupBox_5, 3, 0, 1, 1)
self.groupBox = QGroupBox(self.centralwidget)
self.groupBox.setObjectName(u"groupBox")
self.gridLayout_3 = QGridLayout(self.groupBox)
self.gridLayout_3.setObjectName(u"gridLayout_3")
self.pb_revop = QPushButton(self.groupBox)
self.pb_revop.setObjectName(u"pb_revop")
self.gridLayout_3.addWidget(self.pb_revop, 2, 1, 1, 1)
self.pb_extrdop = QPushButton(self.groupBox)
self.pb_extrdop.setObjectName(u"pb_extrdop")
self.gridLayout_3.addWidget(self.pb_extrdop, 0, 0, 1, 1)
self.pb_arrayop = QPushButton(self.groupBox)
self.pb_arrayop.setObjectName(u"pb_arrayop")
self.gridLayout_3.addWidget(self.pb_arrayop, 2, 0, 1, 1)
self.pb_cutop = QPushButton(self.groupBox)
self.pb_cutop.setObjectName(u"pb_cutop")
self.gridLayout_3.addWidget(self.pb_cutop, 0, 1, 1, 1)
self.pb_combop = QPushButton(self.groupBox)
self.pb_combop.setObjectName(u"pb_combop")
self.gridLayout_3.addWidget(self.pb_combop, 1, 0, 1, 1)
self.pb_moveop = QPushButton(self.groupBox)
self.pb_moveop.setObjectName(u"pb_moveop")
self.gridLayout_3.addWidget(self.pb_moveop, 1, 1, 1, 1)
self.gridLayout.addWidget(self.groupBox, 0, 3, 1, 1)
self.groupBox_4 = QGroupBox(self.centralwidget)
self.groupBox_4.setObjectName(u"groupBox_4")
self.verticalLayout_2 = QVBoxLayout(self.groupBox_4)
self.verticalLayout_2.setObjectName(u"verticalLayout_2")
self.pushButton_2 = QPushButton(self.groupBox_4)
self.pushButton_2.setObjectName(u"pushButton_2")
self.verticalLayout_2.addWidget(self.pushButton_2)
self.pb_export_step = QPushButton(self.groupBox_4)
self.pb_export_step.setObjectName(u"pb_export_step")
self.verticalLayout_2.addWidget(self.pb_export_step)
self.pb_export_iges = QPushButton(self.groupBox_4)
self.pb_export_iges.setObjectName(u"pb_export_iges")
self.verticalLayout_2.addWidget(self.pb_export_iges)
self.gridLayout.addWidget(self.groupBox_4, 2, 3, 1, 1)
fluencyCAD.setCentralWidget(self.centralwidget)
self.menubar = QMenuBar(fluencyCAD)
self.menubar.setObjectName(u"menubar")
self.menubar.setGeometry(QRect(0, 0, 2359, 24))
self.menuFile = QMenu(self.menubar)
self.menuFile.setObjectName(u"menuFile")
self.menuSettings = QMenu(self.menubar)
self.menuSettings.setObjectName(u"menuSettings")
fluencyCAD.setMenuBar(self.menubar)
self.statusbar = QStatusBar(fluencyCAD)
self.statusbar.setObjectName(u"statusbar")
fluencyCAD.setStatusBar(self.statusbar)
self.menubar.addAction(self.menuFile.menuAction())
self.menubar.addAction(self.menuSettings.menuAction())
self.menuFile.addAction(self.actionNew_Project)
self.menuFile.addAction(self.actionOpen_Project)
self.menuFile.addAction(self.actionSave_Project)
self.menuFile.addAction(self.actionSave_Project_As)
self.menuFile.addSeparator()
self.menuFile.addAction(self.actionImport_File)
self.menuFile.addSeparator()
self.menuFile.addAction(self.actionExport_Step)
self.menuFile.addAction(self.actionExport_Iges)
self.menuFile.addAction(self.actionExport_Stl)
self.menuFile.addSeparator()
self.menuFile.addAction(self.actionExit)
self.retranslateUi(fluencyCAD)
self.InputTab.setCurrentIndex(0)
QMetaObject.connectSlotsByName(fluencyCAD)
# setupUi
def retranslateUi(self, fluencyCAD):
fluencyCAD.setWindowTitle(QCoreApplication.translate("fluencyCAD", u"fluencyCAD", None))
self.actionNew_Project.setText(QCoreApplication.translate("fluencyCAD", u"New Project", None))
#if QT_CONFIG(shortcut)
self.actionNew_Project.setShortcut(QCoreApplication.translate("fluencyCAD", u"Ctrl+N", None))
#endif // QT_CONFIG(shortcut)
self.actionOpen_Project.setText(QCoreApplication.translate("fluencyCAD", u"Open Project...", None))
#if QT_CONFIG(shortcut)
self.actionOpen_Project.setShortcut(QCoreApplication.translate("fluencyCAD", u"Ctrl+O", None))
#endif // QT_CONFIG(shortcut)
self.actionSave_Project.setText(QCoreApplication.translate("fluencyCAD", u"Save Project", None))
#if QT_CONFIG(shortcut)
self.actionSave_Project.setShortcut(QCoreApplication.translate("fluencyCAD", u"Ctrl+S", None))
#endif // QT_CONFIG(shortcut)
self.actionSave_Project_As.setText(QCoreApplication.translate("fluencyCAD", u"Save Project As...", None))
#if QT_CONFIG(shortcut)
self.actionSave_Project_As.setShortcut(QCoreApplication.translate("fluencyCAD", u"Ctrl+Shift+S", None))
#endif // QT_CONFIG(shortcut)
self.actionImport_File.setText(QCoreApplication.translate("fluencyCAD", u"Import STEP/IGES...", None))
self.actionExport_Step.setText(QCoreApplication.translate("fluencyCAD", u"Export STEP...", None))
self.actionExport_Iges.setText(QCoreApplication.translate("fluencyCAD", u"Export IGES...", None))
self.actionExport_Stl.setText(QCoreApplication.translate("fluencyCAD", u"Export STL...", None))
self.actionExit.setText(QCoreApplication.translate("fluencyCAD", u"Exit", None))
#if QT_CONFIG(shortcut)
self.actionExit.setShortcut(QCoreApplication.translate("fluencyCAD", u"Ctrl+Q", None))
#endif // QT_CONFIG(shortcut)
self.InputTab.setTabText(self.InputTab.indexOf(self.sketch_tab), QCoreApplication.translate("fluencyCAD", u"Sketch", None))
self.groupBox_7.setTitle(QCoreApplication.translate("fluencyCAD", u"Executive", None))
self.pushButton_5.setText(QCoreApplication.translate("fluencyCAD", u"Load Code", None))
self.pushButton_4.setText(QCoreApplication.translate("fluencyCAD", u"Save code", None))
self.pb_apply_code.setText(QCoreApplication.translate("fluencyCAD", u"Apply Code", None))
self.pushButton.setText(QCoreApplication.translate("fluencyCAD", u"Delete Code", None))
self.InputTab.setTabText(self.InputTab.indexOf(self.code_tab), QCoreApplication.translate("fluencyCAD", u"Code", None))
self.groupBox_9.setTitle(QCoreApplication.translate("fluencyCAD", u"Workplanes", None))
#if QT_CONFIG(tooltip)
self.pb_origin_wp.setToolTip(QCoreApplication.translate("fluencyCAD", u"<W>orking Plane at 0, 0, 0", None))
#endif // QT_CONFIG(tooltip)
self.pb_origin_wp.setText(QCoreApplication.translate("fluencyCAD", u"WP Origin", None))
#if QT_CONFIG(shortcut)
self.pb_origin_wp.setShortcut(QCoreApplication.translate("fluencyCAD", u"W", None))
#endif // QT_CONFIG(shortcut)
#if QT_CONFIG(tooltip)
self.pb_origin_face.setToolTip(QCoreApplication.translate("fluencyCAD", u"Working Plane >P<rojection at selected edges face", None))
#endif // QT_CONFIG(tooltip)
self.pb_origin_face.setText(QCoreApplication.translate("fluencyCAD", u" WP Face", None))
#if QT_CONFIG(shortcut)
self.pb_origin_face.setShortcut(QCoreApplication.translate("fluencyCAD", u"P", None))
#endif // QT_CONFIG(shortcut)
#if QT_CONFIG(tooltip)
self.pb_flip_face.setToolTip(QCoreApplication.translate("fluencyCAD", u"Flip >N<ormal of projected mesh.", None))
#endif // QT_CONFIG(tooltip)
self.pb_flip_face.setText(QCoreApplication.translate("fluencyCAD", u"WP Flip", None))
#if QT_CONFIG(shortcut)
self.pb_flip_face.setShortcut(QCoreApplication.translate("fluencyCAD", u"N", None))
#endif // QT_CONFIG(shortcut)
#if QT_CONFIG(tooltip)
self.pb_underlay.setToolTip(QCoreApplication.translate("fluencyCAD", u"Show / hide the construction lines projected from the source face", None))
#endif // QT_CONFIG(tooltip)
self.pb_underlay.setText(QCoreApplication.translate("fluencyCAD", u"Underlay", None))
#if QT_CONFIG(tooltip)
self.pb_clr_face.setToolTip(QCoreApplication.translate("fluencyCAD", u"Forget the picked source face (keep the workplane)", None))
#endif // QT_CONFIG(tooltip)
self.pb_clr_face.setText(QCoreApplication.translate("fluencyCAD", u"ClrFace", None))
#if QT_CONFIG(tooltip)
self.pb_to_sketch.setToolTip(QCoreApplication.translate("fluencyCAD", u"Convert projected construction lines into real sketch geometry", None))
#endif // QT_CONFIG(tooltip)
self.pb_to_sketch.setText(QCoreApplication.translate("fluencyCAD", u"ToSketch", None))
#if QT_CONFIG(tooltip)
self.pb_wp_new.setToolTip(QCoreApplication.translate("fluencyCAD", u"Create a new independent workplane (datum plane)", None))
#endif // QT_CONFIG(tooltip)
self.pb_wp_new.setText(QCoreApplication.translate("fluencyCAD", u"WP New", None))
#if QT_CONFIG(shortcut)
self.pb_wp_new.setShortcut(QCoreApplication.translate("fluencyCAD", u"Shift+W", None))
#endif // QT_CONFIG(shortcut)
self.assembly_box.setTitle(QCoreApplication.translate("fluencyCAD", u"Assembly", None))
self.joint_tools.setTitle(QCoreApplication.translate("fluencyCAD", u"Joint Tools", None))
self.pb_remove_connector.setText(QCoreApplication.translate("fluencyCAD", u"- Cnct", None))
self.pb_add_connector.setText(QCoreApplication.translate("fluencyCAD", u"+ Cnct", None))
self.pb_add_connector_2.setText(QCoreApplication.translate("fluencyCAD", u"+Jnt", None))
self.pb_add_connector_3.setText(QCoreApplication.translate("fluencyCAD", u"-Jnt", None))
self.gl_box.setTitle(QCoreApplication.translate("fluencyCAD", u"Model Viewer", None))
self.groupBox_11.setTitle(QCoreApplication.translate("fluencyCAD", u"Sketch", None))
self.groupBox_6.setTitle(QCoreApplication.translate("fluencyCAD", u"Tools", None))
self.pb_edt_sktch.setText(QCoreApplication.translate("fluencyCAD", u"Edt", None))
self.pb_nw_sktch.setText(QCoreApplication.translate("fluencyCAD", u"Add", None))
self.pb_del_sketch.setText(QCoreApplication.translate("fluencyCAD", u"Del", None))
self.assembly_tools.setTitle(QCoreApplication.translate("fluencyCAD", u"Assembly Tools", None))
self.pb_compo_to_assembly.setText(QCoreApplication.translate("fluencyCAD", u"Add", None))
self.pb_remove_compo_from_assembly.setText(QCoreApplication.translate("fluencyCAD", u"Rem", None))
self.compo_tool_box.setTitle(QCoreApplication.translate("fluencyCAD", u"Component Tools", None))
self.pb_new_compo.setText(QCoreApplication.translate("fluencyCAD", u"New", None))
self.pb_del_compo.setText(QCoreApplication.translate("fluencyCAD", u"Del", None))
self.compo_box.setTitle(QCoreApplication.translate("fluencyCAD", u"Components", None))
self.groupBox_12.setTitle(QCoreApplication.translate("fluencyCAD", u"Component Connections", None))
self.groupBox_13.setTitle(QCoreApplication.translate("fluencyCAD", u"Tools", None))
self.pb_del_connection.setText(QCoreApplication.translate("fluencyCAD", u"Del", None))
self.pb_update_connection.setText(QCoreApplication.translate("fluencyCAD", u"Upd", None))
self.pb_edt_sktch_4.setText(QCoreApplication.translate("fluencyCAD", u"Nothing", None))
self.groupBox_10.setTitle(QCoreApplication.translate("fluencyCAD", u"Bodys / Operations", None))
self.groupBox_8.setTitle(QCoreApplication.translate("fluencyCAD", u"Tools", None))
self.pb_del_body.setText(QCoreApplication.translate("fluencyCAD", u"Del", None))
self.pb_update_body.setText(QCoreApplication.translate("fluencyCAD", u"Upd", None))
self.pb_edt_sktch_3.setText(QCoreApplication.translate("fluencyCAD", u"Nothing", None))
self.groupBox_2.setTitle(QCoreApplication.translate("fluencyCAD", u"Drawing", None))
self.pb_arc_tool.setText(QCoreApplication.translate("fluencyCAD", u"Arc", None))
self.pb_rectool.setText(QCoreApplication.translate("fluencyCAD", u"Rctgl", None))
self.pb_circtool.setText(QCoreApplication.translate("fluencyCAD", u"Circle", None))
self.pb_enable_construct.setText(QCoreApplication.translate("fluencyCAD", u"Cstrct", None))
self.pb_enable_snap.setText(QCoreApplication.translate("fluencyCAD", u"Snap", None))
self.pb_linetool.setText(QCoreApplication.translate("fluencyCAD", u"Line", None))
#if QT_CONFIG(shortcut)
self.pb_linetool.setShortcut(QCoreApplication.translate("fluencyCAD", u"S", None))
#endif // QT_CONFIG(shortcut)
self.pb_slotool.setText(QCoreApplication.translate("fluencyCAD", u"Slot", None))
#if QT_CONFIG(tooltip)
self.pb_offset_tool.setToolTip(QCoreApplication.translate("fluencyCAD", u"Offset selected sketch face (duplicate + offset boundary)", None))
#endif // QT_CONFIG(tooltip)
self.pb_offset_tool.setText(QCoreApplication.translate("fluencyCAD", u"Offst", None))
self.groupBox_3.setTitle(QCoreApplication.translate("fluencyCAD", u"Constrain", None))
#if QT_CONFIG(tooltip)
self.pb_con_ptpt.setToolTip(QCoreApplication.translate("fluencyCAD", u"Poin to Point Constrain", None))
#endif // QT_CONFIG(tooltip)
self.pb_con_ptpt.setText(QCoreApplication.translate("fluencyCAD", u"Pt_Pt", None))
#if QT_CONFIG(tooltip)
self.pb_con_vert.setToolTip(QCoreApplication.translate("fluencyCAD", u"Vertical Constrain", None))
#endif // QT_CONFIG(tooltip)
self.pb_con_vert.setText(QCoreApplication.translate("fluencyCAD", u"Vert", None))
self.pb_con_sym.setText(QCoreApplication.translate("fluencyCAD", u"Symetrc", None))
#if QT_CONFIG(tooltip)
self.pb_con_mid.setToolTip(QCoreApplication.translate("fluencyCAD", u"Point to Middle Point Constrain", None))
#endif // QT_CONFIG(tooltip)
self.pb_con_mid.setText(QCoreApplication.translate("fluencyCAD", u"Pt_Mid_L", None))
#if QT_CONFIG(tooltip)
self.pb_con_line.setToolTip(QCoreApplication.translate("fluencyCAD", u"Point to Line Constrain", None))
#endif // QT_CONFIG(tooltip)
self.pb_con_line.setText(QCoreApplication.translate("fluencyCAD", u"Pt_Lne", None))
#if QT_CONFIG(tooltip)
self.pb_con_horiz.setToolTip(QCoreApplication.translate("fluencyCAD", u"Horizontal Constrain ", None))
#endif // QT_CONFIG(tooltip)
self.pb_con_horiz.setText(QCoreApplication.translate("fluencyCAD", u"Horiz", None))
#if QT_CONFIG(tooltip)
self.pb_con_dist.setToolTip(QCoreApplication.translate("fluencyCAD", u"Dimension of Line of Distance from Point to Line", None))
#endif // QT_CONFIG(tooltip)
self.pb_con_dist.setText(QCoreApplication.translate("fluencyCAD", u"Distnce", None))
#if QT_CONFIG(tooltip)
self.pb_con_perp.setToolTip(QCoreApplication.translate("fluencyCAD", u"Constrain Line perpendicular to another line.", None))
#endif // QT_CONFIG(tooltip)
self.pb_con_perp.setText(QCoreApplication.translate("fluencyCAD", u"Perp_Lne", None))
self.pb_con_diameter.setText(QCoreApplication.translate("fluencyCAD", u"Diameter", None))
self.groupBox_5.setTitle(QCoreApplication.translate("fluencyCAD", u"Snapping Points", None))
self.label.setText(QCoreApplication.translate("fluencyCAD", u"Snp Dst", None))
self.pb_snap_vert.setText(QCoreApplication.translate("fluencyCAD", u"Vert", None))
self.label_2.setText(QCoreApplication.translate("fluencyCAD", u"Angl Stps", None))
self.spinbox_snap_distance.setSuffix(QCoreApplication.translate("fluencyCAD", u"mm", None))
self.pushButton_7.setText(QCoreApplication.translate("fluencyCAD", u"Grid", None))
self.pb_snap_horiz.setText(QCoreApplication.translate("fluencyCAD", u"Horiz", None))
self.spinbox_angle_steps.setSuffix(QCoreApplication.translate("fluencyCAD", u"\u00b0", None))
self.pushButton_8.setText(QCoreApplication.translate("fluencyCAD", u"Pnt", None))
self.pb_snap_midp.setText(QCoreApplication.translate("fluencyCAD", u"MidP", None))
self.pb_snap_angle.setText(QCoreApplication.translate("fluencyCAD", u"Angles", None))
self.groupBox.setTitle(QCoreApplication.translate("fluencyCAD", u"Modify", None))
self.pb_revop.setText(QCoreApplication.translate("fluencyCAD", u"Rev", None))
self.pb_extrdop.setText(QCoreApplication.translate("fluencyCAD", u"Extrd", None))
self.pb_arrayop.setText(QCoreApplication.translate("fluencyCAD", u"Arry", None))
self.pb_cutop.setText(QCoreApplication.translate("fluencyCAD", u"Cut", None))
self.pb_combop.setText(QCoreApplication.translate("fluencyCAD", u"Comb", None))
self.pb_moveop.setText(QCoreApplication.translate("fluencyCAD", u"Mve", None))
self.groupBox_4.setTitle(QCoreApplication.translate("fluencyCAD", u"Export", None))
self.pushButton_2.setText(QCoreApplication.translate("fluencyCAD", u"STL", None))
self.pb_export_step.setText(QCoreApplication.translate("fluencyCAD", u"STEP", None))
self.pb_export_iges.setText(QCoreApplication.translate("fluencyCAD", u"IGES", None))
self.menuFile.setTitle(QCoreApplication.translate("fluencyCAD", u"File", None))
self.menuSettings.setTitle(QCoreApplication.translate("fluencyCAD", u"Settings", None))
# retranslateUi
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+918
View File
@@ -0,0 +1,918 @@
"""3D viewer widget — wraps OCC's AIS/V3d native display for use inside Qt."""
from __future__ import annotations
import logging
from typing import Any, Dict, List, Optional, Tuple
from PySide6.QtCore import Qt, Signal
from PySide6.QtWidgets import QWidget
logger = logging.getLogger(__name__)
class Viewer3DWidget(QWidget):
"""3D viewer widget using OCC's native AIS display."""
# Emitted when the user picks a planar face to sketch on.
# Payload: (origin, normal, x_dir, face_shape) — all tuples are (x,y,z).
facePicked = Signal(tuple, tuple, tuple, object)
# Emitted when face-pick mode is cancelled (Esc) so the host can uncheck.
pickFaceCancelled = Signal()
# Emitted when the user picks an entity for a connector point (assembly).
# Payload: (origin, normal, x_dir, entity_type, face_or_edge_or_vertex, owner_obj_id).
connectorPicked = Signal(tuple, tuple, tuple, str, object, str)
# Emitted when connector pick mode is cancelled.
connectorPickCancelled = Signal()
# Emitted on mouse move in connector mode to show snap preview.
# Payload: (origin, normal, entity_type, owner_obj_id) or None if nothing.
connectorHover = Signal(object)
# Emitted when a body is clicked in assembly move mode.
# Payload: owner_obj_id.
assemblyComponentActivated = Signal(str)
# Emitted during a drag move: owner_obj_id, world dx, dy, dz.
assemblyComponentDragged = Signal(str, float, float, float)
# Emitted when a drag move finishes.
assemblyMoveFinished = Signal(str)
# Emitted whenever the camera changes (orbit, pan, zoom).
# Payload: (eye, at, up) — each is a tuple of 3 floats.
cameraChanged = Signal(tuple, tuple, tuple)
def __init__(self, parent=None):
super().__init__(parent)
# For OCC's direct OpenGL rendering we need Qt to not paint over it.
self.setAttribute(Qt.WA_PaintOnScreen)
self.setAttribute(Qt.WA_OpaquePaintEvent)
self.setAutoFillBackground(False)
# Accept keyboard focus so navigation shortcuts (F, R, 1-7, P, O) work.
self.setFocusPolicy(Qt.StrongFocus)
# Enable mouse tracking so ``mouseMoveEvent`` fires even without a
# button held — required for the connector-pick hover gizmo (and any
# status-bar hover feedback) to show under the cursor as the user
# moves the mouse over candidate snap entities before clicking.
self.setMouseTracking(True)
# Try OCC renderer first; fall back to pygfx if unavailable.
self._renderer: Any = None
self._initialized = False
self._meshes: Dict[str, Any] = {}
self._selected_normal: Optional[Tuple[float, float, float]] = None
self._centroid: Optional[Tuple[float, float, float]] = None
self._pending_meshes: List[Tuple] = []
# When True, a left-click picks a planar face (for sketch-on-surface)
# instead of orbiting the camera. Set via set_pick_face_mode().
self._pick_face_mode: bool = False
# When True, a left-click picks an entity for a connector point
# (assembly component connection).
self._connector_pick_mode: bool = False
# Current snap highlight object id (for hover during connector mode).
self._connector_snap_id: Optional[str] = None
# Throttle connector hover probes to avoid UI lag on fast mouse moves.
self._connector_last_hover_time: float = 0.0
self._connector_hover_interval: float = 0.05 # 50 ms between probes
# When True, left-click on a body activates assembly drag-to-move.
self._assembly_move_mode: bool = False
# State for ongoing assembly drag.
self._move_drag_active: bool = False
self._move_owner_obj_id: str = ""
self._move_click_3d: Optional[Tuple[float, float, float]] = None
self._move_click_screen: Optional[Any] = None
self._move_plane_normal: Optional[Tuple[float, float, float]] = None
self._move_initial_position: Optional[Tuple[float, float, float]] = None
# Most recently recorded owning obj_id for the face returned by
# ``pick_planar_face``. Stashed on each pick pass so the host can
# pair the picked face with the body it belongs to (used to auto-
# target a cut/union extrude against the body the sketch was
# projected onto).
self._last_pick_owner_obj_id: Optional[str] = None
def _init_renderer(self) -> None:
"""Create the best available renderer."""
if self._renderer is not None:
return
import sys as _sys
_sys.stdout.flush()
logger.info("Renderer: starting import...")
from fluency.rendering.occ_renderer import OCCRenderer
from fluency.rendering.pygfx_renderer import PygfxRenderer
logger.info("Renderer: imports done, creating OCCRenderer...")
occ = OCCRenderer()
logger.info("Renderer: calling occ.initialize...")
try:
ok = occ.initialize(self)
except Exception as exc:
logger.warning(f"OCCRenderer init raised: {exc}")
ok = False
logger.info(f"Renderer: OCC result={ok}")
if ok:
self._renderer = occ
logger.info("Using OCCRenderer (native BRep display)")
else:
logger.info("Falling back to PygfxRenderer")
self._renderer = PygfxRenderer()
logger.info("Renderer: calling pygfx initialize...")
self._renderer.initialize(self)
logger.info("Renderer: pygfx init done")
self._initialized = True
logger.info("Renderer: initialization complete")
def showEvent(self, event):
logger.info("Viewer3DWidget showEvent - initializing renderer")
if not self._initialized:
self._init_renderer()
logger.info(f"Renderer initialized, pending meshes: {len(self._pending_meshes)}")
for args in self._pending_meshes:
self.add_mesh(*args)
self._pending_meshes.clear()
self._renderer.render()
def _ensure_initialized(self):
if not self._initialized:
logger.debug("Ensuring renderer is initialized")
self._init_renderer()
def get_renderer(self):
self._ensure_initialized()
return self._renderer
def show_shape(self, shape: Any, color=None, name=None) -> str:
"""Display an OCC TopoDS_Shape.
Uses OCCRenderer.add_shape for native AIS display, or falls back to
triangulation + add_mesh for the PygfxRenderer.
"""
self._ensure_initialized()
from fluency.rendering.occ_renderer import OCCRenderer
if isinstance(self._renderer, OCCRenderer):
oid = self._renderer.add_shape(shape, color, name)
self._renderer.render()
return oid
# Fallback: tessellate and use the mesh pipeline.
from fluency.geometry_occ.kernel import OCGeometryKernel
k = OCGeometryKernel()
# Build a temporary OCCGeometryObject to use the kernel's mesh helpers.
from fluency.geometry_occ.kernel import OCCGeometryObject
obj = OCCGeometryObject(shape)
verts, faces = k.get_mesh(obj)
oid = self._renderer.add_mesh(verts, faces, color, name)
# Edges
try:
e_verts, e_edges = k.get_edges(obj)
if len(e_verts) > 0:
self._renderer.add_wireframe(
e_verts, e_edges, (0.9, 0.9, 0.9), line_width=1.5, name=f"{name}_edges"
)
except Exception:
pass
self._renderer.render()
return oid
def add_mesh(self, vertices, faces, color=None, name=None) -> str:
logger.debug(
f"add_mesh called: initialized={self._initialized}, vertices={len(vertices)}, faces={len(faces)}, name={name}"
)
if not self._initialized:
self._pending_meshes.append((vertices, faces, color, name))
logger.info(f"Queued pending mesh, total pending: {len(self._pending_meshes)}")
return f"pending_{len(self._pending_meshes)}"
self._ensure_initialized()
mesh_id = self._renderer.add_mesh(vertices, faces, color, name)
self._meshes[mesh_id] = {"vertices": vertices, "faces": faces, "name": name}
self._renderer.render()
logger.info(f"Added mesh: {mesh_id}, name={name}")
return mesh_id
def update_mesh(self, mesh_id: str, vertices, faces):
self._ensure_initialized()
self._renderer.update_mesh(mesh_id, vertices, faces)
self._meshes[mesh_id] = {"vertices": vertices, "faces": faces}
self._renderer.render()
def add_wireframe(self, vertices, edges, color=None, line_width=1.0, name=None) -> str:
self._ensure_initialized()
wid = self._renderer.add_wireframe(
vertices, edges, color or (0.9, 0.9, 0.9), line_width, name
)
self._renderer.render()
return wid
def remove_mesh(self, mesh_id: str):
self._ensure_initialized()
self._renderer.remove_mesh(mesh_id)
if mesh_id in self._meshes:
del self._meshes[mesh_id]
self._renderer.render()
def set_visibility(self, mesh_id: str, visible: bool) -> bool:
"""Show or hide a previously-added mesh without removing it.
Used by the per-body visibility toggle on the body list so the
user can quickly hide a body (e.g. to verify a cut worked on
another body). Returns True on success, False if the mesh is
unknown to the renderer or the renderer doesn't support it
(e.g. the Pygfx fallback ABI).
"""
self._ensure_initialized()
fn = getattr(self._renderer, "set_visibility", None)
if fn is None:
return False
ok = fn(mesh_id, visible)
if ok:
self._renderer.render()
return ok
def set_transparency(self, mesh_id: str, transparency: float) -> bool:
"""Set a previously-added mesh's transparency (0..1).
Used by the live extrude preview to dim the target body so the
previewed result reads on top of it.
"""
self._ensure_initialized()
fn = getattr(self._renderer, "set_object_transparency", None)
if fn is None:
return False
return fn(mesh_id, transparency)
def show_preview(self, shape: Any, color=None, transparency: float = 0.60) -> None:
"""Display a temporary transparent preview of *shape* in the 3D view.
Used by the ExtrudeDialog live preview: as the user drags the
length spinner or toggles Cut/Through-All, the host recomputes
the operation result and shows it here. Call clear_preview()
when the dialog closes.
"""
self._ensure_initialized()
fn = getattr(self._renderer, "preview_shape", None)
if fn is None:
return
fn(shape, color, transparency)
def clear_preview(self) -> None:
"""Remove the live extrude preview shape, if any."""
if not self._initialized or self._renderer is None:
return
fn = getattr(self._renderer, "clear_preview", None)
if fn is None:
return
fn()
def clear_scene(self):
self._ensure_initialized()
self._renderer.clear_scene()
self._meshes.clear()
self._renderer.render()
def fit_camera(self):
self._ensure_initialized()
self._renderer.fit_camera()
self._renderer.render()
# ─── Workplane visualization ───────────────────────────────────────────
def show_workplane(
self,
origin: Tuple[float, float, float] = (0, 0, 0),
normal: Tuple[float, float, float] = (0, 0, 1),
x_dir: Tuple[float, float, float] = (1, 0, 0),
size: float = 200.0,
name: Optional[str] = None,
) -> Optional[str]:
"""Display a semi-transparent workplane plane in the 3D view.
Returns the object ID (for later removal) or None if the renderer
doesn't support workplane planes.
"""
self._ensure_initialized()
fn = getattr(self._renderer, "show_workplane_plane", None)
if fn is None:
return None
oid = fn(origin, normal, x_dir, size, name)
self._renderer.render()
return oid
def remove_workplane(self, obj_id: str) -> bool:
"""Remove a workplane plane visual by its ID."""
self._ensure_initialized()
fn = getattr(self._renderer, "remove_workplane_plane", None)
if fn is None:
return False
ok = fn(obj_id)
if ok:
self._renderer.render()
return ok
def mousePressEvent(self, event):
self._ensure_initialized()
# Face-pick mode: a left-click selects a planar face to sketch on.
if self._pick_face_mode and event.button() == Qt.LeftButton:
self._handle_face_pick(event)
return
# Connector pick mode: a left-click selects a face for a connection point.
if self._connector_pick_mode and event.button() == Qt.LeftButton:
self._handle_connector_pick(event)
return
# Assembly move mode: start dragging the clicked body.
if self._assembly_move_mode and event.button() == Qt.LeftButton:
self._handle_assembly_move_press(event)
return
self._renderer.handle_mouse_press(event)
super().mousePressEvent(event)
def mouseMoveEvent(self, event):
self._ensure_initialized()
# In connector mode, show snap hover.
# Selection modes are deactivated so we skip the idle MoveTo
# (dynamic highlighting) — only the gizmo hover handler runs.
if self._connector_pick_mode:
self._handle_connector_hover(event)
super().mouseMoveEvent(event)
return
# If connector mode was just exited (gizmo persists after pick),
# clear any lingering gizmo on first mouse move.
gizmo_objs = getattr(self._renderer, "_gizmo_objects", None)
if self._connector_snap_id is not None or (gizmo_objs and len(gizmo_objs) > 0):
self._clear_connector_snap()
# In face-pick mode, keep dynamic highlighting.
if self._pick_face_mode:
if hasattr(self._renderer, "handle_mouse_move"):
self._renderer.handle_mouse_move(event)
super().mouseMoveEvent(event)
return
# Active drag in assembly move mode.
if self._move_drag_active:
self._handle_assembly_move_move(event)
super().mouseMoveEvent(event)
return
self._renderer.handle_mouse_move(event)
super().mouseMoveEvent(event)
def paintEngine(self):
"""Return None to prevent Qt from painting over OCC's direct OpenGL."""
return None
def paintEvent(self, event):
"""Empty paintEvent — OCC draws directly via OpenGL."""
pass
def mouseReleaseEvent(self, event):
self._ensure_initialized()
# Finish assembly drag.
if self._move_drag_active:
self._handle_assembly_move_release(event)
return
self._renderer.handle_mouse_release(event)
super().mouseReleaseEvent(event)
# Camera may have changed during orbit/pan — emit signal.
try:
eye, at_, up = self.get_camera_position()
self.cameraChanged.emit(
tuple(float(v) for v in eye),
tuple(float(v) for v in at_),
tuple(float(v) for v in up),
)
except Exception:
pass
def wheelEvent(self, event):
self._ensure_initialized()
self._renderer.handle_wheel(event)
super().wheelEvent(event)
# Zoom changed the camera — emit signal.
try:
eye, at_, up = self.get_camera_position()
self.cameraChanged.emit(
tuple(float(v) for v in eye),
tuple(float(v) for v in at_),
tuple(float(v) for v in up),
)
except Exception:
pass
def resizeEvent(self, event):
super().resizeEvent(event)
self._ensure_initialized()
self._renderer.handle_resize(event.size().width(), event.size().height())
def set_camera_position(self, position, target):
self._ensure_initialized()
self._renderer.set_camera_position(position, target)
self._renderer.render()
def get_camera_position(self):
"""Return the current camera ``(eye, at, up)`` triple.
The underlying renderer's ``get_camera_position`` returns three
``np.ndarray``s. We forward the call so callers (notably
:meth:`MainWindow._collect_view_state`) can persist the camera.
Returns a tuple of ``(np.zeros(3), np.zeros(3), (0,0,1))`` if the
renderer hasn't been initialised yet (e.g. when the window is
being constructed).
"""
self._ensure_initialized()
if hasattr(self._renderer, "get_camera_position"):
return self._renderer.get_camera_position()
import numpy as np
return (
np.zeros(3, dtype=float),
np.zeros(3, dtype=float),
np.array([0.0, 0.0, 1.0], dtype=float),
)
def get_camera_fov(self) -> float:
"""Return the current vertical FOV in degrees from the renderer.
Falls back to 45.0 if the renderer doesn't support it.
"""
self._ensure_initialized()
if hasattr(self._renderer, "get_camera_fov"):
return self._renderer.get_camera_fov()
return 45.0
# ─── Face-pick mode (sketch-on-surface) ────────────────────────────────
def set_pick_face_mode(self, enabled: bool) -> None:
"""Toggle face-pick mode.
When enabled, the cursor selects planar faces for sketch placement
instead of orbiting the camera. Middle button still pans; wheel zooms.
"""
self._pick_face_mode = bool(enabled)
if enabled:
self.setCursor(Qt.CrossCursor)
else:
self.unsetCursor()
def is_pick_face_mode(self) -> bool:
return self._pick_face_mode
def highlight_face(self, face: Any) -> None:
"""Tint the picked face light-blue/transparent in the 3D viewer."""
self._ensure_initialized()
fn = getattr(self._renderer, "highlight_face", None)
if fn is not None:
fn(face)
self._renderer.render()
def clear_face_highlight(self) -> None:
"""Remove the persistent face-selection tint."""
self._ensure_initialized()
fn = getattr(self._renderer, "clear_face_highlight", None)
if fn is not None:
fn()
self._renderer.render()
# ─── Connector pick mode (assembly) ────────────────────────────────────
def set_connector_pick_mode(self, enabled: bool, clear_gizmo: bool = True) -> None:
"""Toggle connector pick mode for placing connection points.
When enabled, clicking an entity (face, edge, vertex, hole)
on a body in the assembly view captures its position and
direction as a connection point for the SolveSpace solver.
Entering connector mode deactivates standard OCC face/edge/vertex
selection so dynamic highlighting does not clash with the gizmo
visuals. Selection is re-activated on exit.
*clear_gizmo*: if False the gizmo marker is not cleared on exit,
allowing it to persist until the next hover event (used after a
successful pick so the user sees what was selected).
"""
self._connector_pick_mode = bool(enabled)
if enabled:
self.setCursor(Qt.CrossCursor)
# Disable standard OCC selection so gizmo visuals are not
# interfered with by dynamic face highlighting.
fn = getattr(self._renderer, "deactivate_selection_modes", None)
if fn is not None:
fn()
else:
if clear_gizmo:
self._clear_connector_snap()
# Restore standard OCC selection for face-pick / normal modes.
fn = getattr(self._renderer, "activate_selection_modes", None)
if fn is not None:
fn()
if not self._pick_face_mode:
self.unsetCursor()
def is_connector_pick_mode(self) -> bool:
return self._connector_pick_mode
def _clear_connector_snap(self) -> None:
"""Remove the hover gizmo."""
fn = getattr(self._renderer, "clear_entity_gizmo", None)
if fn is not None:
fn()
# Backwards compat: also try the old method.
if self._connector_snap_id is not None:
fn2 = getattr(self._renderer, "remove_highlight_snap", None)
if fn2 is not None:
fn2(self._connector_snap_id)
self._connector_snap_id = None
def _handle_connector_hover(self, event) -> None:
"""Update the hover snap gizmo during connector pick mode.
Uses geometric probing (direct topology walk) which does not depend
on OCC's selection system — this avoids clashing with the gizmo
visuals since selection modes are deactivated in connector mode.
Probes are throttled to at most once every 50 ms to avoid UI lag
on fast mouse moves.
"""
import time
now = time.monotonic()
if now - self._connector_last_hover_time < self._connector_hover_interval:
return # throttled — skip this mouse move
self._connector_last_hover_time = now
self._ensure_initialized()
probe = getattr(self._renderer, "probe_snap_candidates_geometric", None)
pos = event.position().toPoint() if hasattr(event, "position") else event.pos()
if probe is not None:
candidates = probe(pos.x(), pos.y())
if not candidates:
self._clear_connector_snap()
self.connectorHover.emit(None)
return
# Primary = the nearest candidate (probe sorts nearest-first).
info = candidates[0]
else:
# Fall back to the selection-system-based probe.
probe2 = getattr(self._renderer, "probe_snap_candidates", None)
if probe2 is not None:
candidates = probe2(pos.x(), pos.y())
if not candidates:
self._clear_connector_snap()
self.connectorHover.emit(None)
return
info = candidates[0]
else:
# Last resort: single-pixel pick.
picker = getattr(self._renderer, "pick_entity", None)
if picker is None:
return
info = picker(pos.x(), pos.y())
candidates = [info] if info else []
if info is None or info.get("owner_obj_id") is None:
self._clear_connector_snap()
self.connectorHover.emit(None)
return
origin = info["position"]
normal = info.get("normal")
entity_type = info["type"]
owner = info.get("owner_obj_id", "")
# ── Feature recognition ──
# Enhance candidates with composite feature info (holes, edge loops, etc.)
recognize = getattr(self._renderer, "recognize_composite_features", None)
if recognize is not None and candidates:
candidates = recognize(candidates, pos.x(), pos.y())
info = candidates[0] # re-read primary after enhancement
origin = info["position"]
normal = info.get("normal")
entity_type = info.get("type", info.get("feature_type", entity_type))
# Show smart entity gizmo — dim candidate markers + bright primary.
self._clear_connector_snap()
gizmo_fn = getattr(self._renderer, "show_entity_gizmo", None)
if gizmo_fn is not None:
gizmo_fn(
entity_type=entity_type,
position=origin,
normal=normal,
x_dir=info.get("x_dir"),
radius=info.get("radius"),
candidates=candidates,
)
else:
# Fallback to old highlight_snap.
fn = getattr(self._renderer, "highlight_snap", None)
if fn is not None:
colors = {
"planar_face": (0.0, 0.8, 1.0), # cyan
"cylindrical_face": (1.0, 0.4, 0.0), # orange (hole)
"edge": (0.0, 1.0, 0.4), # green
"vertex": (1.0, 1.0, 0.0), # yellow
}
c = colors.get(entity_type, (1.0, 0.6, 0.0))
self._connector_snap_id = fn(origin, color=c, size=3.0)
# Build payload with feature recognition info.
payload = {
"origin": origin,
"normal": normal,
"type": entity_type,
"owner_obj_id": owner,
}
# Attach feature info if available.
if "feature_type" in info:
payload["feature_type"] = info["feature_type"]
if "suggestion" in info:
payload["suggestion"] = info["suggestion"]
if "feature_data" in info:
payload["feature_data"] = info["feature_data"]
self.connectorHover.emit(payload)
def _handle_connector_pick(self, event) -> None:
"""Detect an entity under the click and emit connectorPicked.
Uses geometric probing (direct topology walk) so a click selects the
PRIMARY (nearest) snap target the same one the hover gizmo
emphasised. Falls back to selection-system probe, then single-pixel
``pick_entity``, then ``pick_planar_face``.
"""
self._ensure_initialized()
pos = event.position().toPoint() if hasattr(event, "position") else event.pos()
info: Optional[Dict[str, Any]] = None
probe = getattr(self._renderer, "probe_snap_candidates_geometric", None)
if probe is not None:
candidates = probe(pos.x(), pos.y())
if candidates:
info = candidates[0] # nearest = primary
if info is None:
probe2 = getattr(self._renderer, "probe_snap_candidates", None)
if probe2 is not None:
candidates = probe2(pos.x(), pos.y())
if candidates:
info = candidates[0]
if info is None:
picker = getattr(self._renderer, "pick_entity", None)
if picker is None:
# Fallback to planar face only.
picker = getattr(self._renderer, "pick_planar_face", None)
if picker is None:
logger.warning("Renderer has no entity picking support")
return
pinfo = picker(pos.x(), pos.y())
if pinfo is None:
logger.info("Connector pick: no planar face under cursor")
return
owner_obj_id = pinfo.get("owner_obj_id", "")
self.connectorPicked.emit(
tuple(pinfo["origin"]),
tuple(pinfo["normal"]),
tuple(pinfo["x_dir"]),
"planar_face",
pinfo["face"],
owner_obj_id,
)
return
info = picker(pos.x(), pos.y())
if info is None:
logger.info("Connector pick: no entity under cursor")
return
owner_obj_id = info.get("owner_obj_id", "")
if not owner_obj_id:
return
entity_type = info["type"]
origin = info["position"]
normal = info.get("normal") or (0.0, 0.0, 1.0)
x_dir = info.get("x_dir") or (1.0, 0.0, 0.0)
# For vertices, pick a sensible normal from the parent face if possible.
if entity_type == "vertex" and normal is None:
normal = (0.0, 0.0, 1.0)
# Package the raw shape appropriately.
raw_shape = info.get("face") or info.get("edge") or info.get("vertex")
self.connectorPicked.emit(
tuple(origin),
tuple(normal),
tuple(x_dir) if x_dir else (1.0, 0.0, 0.0),
entity_type,
raw_shape,
owner_obj_id,
)
# ─── Assembly move mode (3D drag) ─────────────────────────────────────
def set_assembly_move_mode(self, enabled: bool) -> None:
"""Toggle assembly move mode.
When enabled, clicking on a body and dragging moves its
assembly component in the view plane. Shift+drag moves in Z.
"""
self._assembly_move_mode = bool(enabled)
if enabled:
self.setCursor(Qt.SizeAllCursor)
elif not self._pick_face_mode and not self._connector_pick_mode:
self.unsetCursor()
if not enabled:
self._move_drag_active = False
self._move_owner_obj_id = ""
self._move_click_3d = None
self._move_click_screen = None
self._move_plane_normal = None
self._move_initial_position = None
def _handle_assembly_move_press(self, event) -> None:
"""Start a drag-to-move for the body under the cursor."""
self._ensure_initialized()
picker = getattr(self._renderer, "pick_planar_face", None)
if picker is None:
return
pos = event.position().toPoint() if hasattr(event, "position") else event.pos()
info = picker(pos.x(), pos.y())
if info is None:
return
owner_obj_id = info.get("owner_obj_id", "")
if not owner_obj_id or not owner_obj_id.startswith("asm_"):
return
# Store drag state.
self._move_drag_active = True
self._move_owner_obj_id = owner_obj_id
self._move_click_3d = tuple(info["origin"])
self._move_click_screen = pos
self._move_plane_normal = tuple(info["normal"])
# Emit activation signal so MainWindow stores initial position.
self.assemblyComponentActivated.emit(owner_obj_id)
def _handle_assembly_move_move(self, event) -> None:
"""Continue the drag: project mouse delta to world-space and emit."""
if not self._move_drag_active or self._move_click_screen is None:
return
pos = event.position().toPoint() if hasattr(event, "position") else event.pos()
# Screen delta (Qt Y is inverted vs OCC).
dx = pos.x() - self._move_click_screen.x()
dy = -(pos.y() - self._move_click_screen.y()) # invert Y
# Convert screen delta to world units using the view scale.
# view.Scale() returns a scale factor — the smaller the value the
# more world distance per pixel. We use an empirical conversion:
# at scale=1.0, ~1 pixel ≈ 0.3 world units at typical depth.
scale = self._renderer._view.Scale() if hasattr(self._renderer, "_view") else 1.0
world_per_pixel = 2.0 / max(scale, 0.001)
# Get camera vectors for proper view-plane projection.
import numpy as np
try:
# Get camera direction and up from the OCC view.
camera = self._renderer._view.Camera()
dir_ = camera.Direction()
up_ = camera.Up()
cam_dir = np.array([dir_.X(), dir_.Y(), dir_.Z()])
cam_up = np.array([up_.X(), up_.Y(), up_.Z()])
cam_right = np.cross(cam_dir, cam_up)
cam_right = cam_right / np.linalg.norm(cam_right)
cam_up = cam_up / np.linalg.norm(cam_up)
except Exception:
# Fallback: assume XY plane.
cam_right = np.array([1.0, 0.0, 0.0])
cam_up = np.array([0.0, 0.0, 1.0])
# Compute world-space delta.
modifiers = event.modifiers()
if modifiers & Qt.ShiftModifier:
# Shift+drag: move along camera direction (Z-depth).
dz_world = dx * world_per_pixel
dx_world = 0.0
dy_world = 0.0
else:
# Normal drag: move in view plane.
dx_world = float(cam_right[0] * dx * world_per_pixel + cam_up[0] * dy * world_per_pixel)
dy_world = float(cam_right[1] * dx * world_per_pixel + cam_up[1] * dy * world_per_pixel)
dz_world = float(cam_right[2] * dx * world_per_pixel + cam_up[2] * dy * world_per_pixel)
self.assemblyComponentDragged.emit(self._move_owner_obj_id, dx_world, dy_world, dz_world)
def _handle_assembly_move_release(self, event) -> None:
"""Finish the drag, emit final position."""
self.assemblyMoveFinished.emit(self._move_owner_obj_id)
self._move_drag_active = False
self._move_owner_obj_id = ""
self._move_click_3d = None
self._move_click_screen = None
self._move_plane_normal = None
self._move_initial_position = None
def _handle_face_pick(self, event) -> None:
"""Detect a planar face under the click and emit facePicked."""
self._ensure_initialized()
picker = getattr(self._renderer, "pick_planar_face", None)
if picker is None:
logger.warning("Renderer has no pick_planar_face support")
return
# Qt6: prefer position().toPoint() over deprecated pos().
pos = event.position().toPoint() if hasattr(event, "position") else event.pos()
info = picker(pos.x(), pos.y())
if info is None:
logger.info("Face pick: no planar face under cursor")
return
# Stash the owning obj_id so MainWindow._on_face_picked can pair the
# picked face with the body it belongs to (for auto-targeted cut).
self._last_pick_owner_obj_id = info.get("owner_obj_id")
self.facePicked.emit(
tuple(info["origin"]),
tuple(info["normal"]),
tuple(info["x_dir"]),
info["face"],
)
def set_view(self, view: str):
# Prefer the renderer's native orientation snap (preserves target,
# refits the scene). Falls back to absolute eye positions for
# renderers that don't implement set_view_orientation.
self._ensure_initialized()
if hasattr(self._renderer, "set_view_orientation"):
self._renderer.set_view_orientation(view)
self._renderer.render()
return
positions = {
"iso": ((100, 100, 100), (0, 0, 0)),
"top": ((0, 0, 200), (0, 0, 0)),
"front": ((0, -200, 0), (0, 0, 0)),
"right": ((200, 0, 0), (0, 0, 0)),
"back": ((0, 200, 0), (0, 0, 0)),
"left": ((-200, 0, 0), (0, 0, 0)),
"bottom": ((0, 0, -200), (0, 0, 0)),
}
if view in positions:
pos, target = positions[view]
self.set_camera_position(pos, target)
def mouseDoubleClickEvent(self, event):
# Double-click → fit all (common CAD convention).
self._ensure_initialized()
if event.button() == Qt.LeftButton:
self.fit_camera()
super().mouseDoubleClickEvent(event)
def keyPressEvent(self, event):
# Esc cancels face-pick mode.
if self._pick_face_mode and event.key() == Qt.Key_Escape:
self.set_pick_face_mode(False)
self.pickFaceCancelled.emit()
return
# Esc cancels connector pick mode.
if self._connector_pick_mode and event.key() == Qt.Key_Escape:
self.set_connector_pick_mode(False)
self.connectorPickCancelled.emit()
return
# Esc cancels assembly move mode.
if self._assembly_move_mode and event.key() == Qt.Key_Escape:
self.set_assembly_move_mode(False)
return
# Navigation shortcuts (lowercase = view presets, F = fit,
# P/O = perspective/orthographic, R = reset).
self._ensure_initialized()
key = event.text().lower()
mapping = {
"f": "fit",
"r": "reset",
"1": "front",
"2": "back",
"3": "top",
"4": "bottom",
"5": "left",
"6": "right",
"7": "iso",
}
action = mapping.get(key)
if action == "fit":
self.fit_camera()
return
if action == "reset":
if hasattr(self._renderer, "reset_camera"):
self._renderer.reset_camera()
self._renderer.render()
else:
self.set_view("iso")
return
if action in ("front", "back", "top", "bottom", "left", "right", "iso"):
self.set_view(action)
return
if key == "p" and hasattr(self._renderer, "set_camera_perspective"):
self._renderer.set_camera_perspective()
self._renderer.render()
return
if key == "o" and hasattr(self._renderer, "set_camera_orthographic"):
self._renderer.set_camera_orthographic()
self._renderer.render()
return
super().keyPressEvent(event)
-146
View File
@@ -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
-23
View File
@@ -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
-13
View File
@@ -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
View File
@@ -1 +0,0 @@
fluency
+945
View File
@@ -182,6 +182,951 @@ class TestOCCSketch:
points = sketch.get_points()
assert len(points) == 0
def test_workplane_extrude_along_normal(self):
"""A sketch on a tilted plane extrudes along that plane's normal."""
import math
from OCP.GProp import GProp_GProps
from OCP.BRepGProp import BRepGProp
ang = math.radians(35)
normal = (math.sin(ang), 0.0, math.cos(ang))
x_dir = (math.cos(ang), 0.0, -math.sin(ang))
sk = OCCSketch()
sk.set_workplane((10.0, 0.0, 5.0), normal, x_dir)
# 20x20 square in UV
p0 = sk.add_point(-10, -10); p1 = sk.add_point(10, -10)
p2 = sk.add_point(10, 10); p3 = sk.add_point(-10, 10)
sk.add_line(p0, p1); sk.add_line(p1, p2)
sk.add_line(p2, p3); sk.add_line(p3, p0)
geom = sk.get_geometry()
# The face must carry the plane normal for the kernel.
assert geom.metadata.get("normal") is not None
kernel = OCGeometryKernel()
solid = kernel.extrude(geom, 15.0)
s = kernel._get_shape(solid)
g = GProp_GProps()
BRepGProp.VolumeProperties_s(s, g)
# 20 * 20 * 15 = 6000 regardless of plane orientation.
assert abs(g.Mass() - 6000.0) < 0.1
def test_workplane_extrude_with_hole(self):
"""A square with a circular hole on a custom plane extrudes correctly."""
import math
from OCP.GProp import GProp_GProps
from OCP.BRepGProp import BRepGProp
sk = OCCSketch()
sk.set_workplane((0, 0, 0), (0, 0, 1), (1, 0, 0))
a = sk.add_point(-10, -10); b = sk.add_point(10, -10)
c = sk.add_point(10, 10); d = sk.add_point(-10, 10)
sk.add_line(a, b); sk.add_line(b, c)
sk.add_line(c, d); sk.add_line(d, a)
ctr = sk.add_point(0, 0)
sk.add_circle(ctr, 3.0)
geom = sk.get_geometry()
kernel = OCGeometryKernel()
solid = kernel.extrude(geom, 5.0)
s = kernel._get_shape(solid)
g = GProp_GProps()
BRepGProp.VolumeProperties_s(s, g)
expected = 20 * 20 * 5 - math.pi * 3 * 3 * 5
assert abs(g.Mass() - expected) < 0.1
class TestExternalEntities:
"""Tests for the underlay / face-projected reference entity API.
External entities live in the solver so user constraints can reference
them (e.g. "hole center 50 mm from the body's top edge"), but they
are *not* part of the sketch profile and must be excluded from
detect_faces / get_geometry.
"""
def test_add_external_point_flags_and_fixes(self):
sk = OCCSketch()
ep = sk.add_external_point(5.0, 7.0)
assert ep is not None
assert ep.is_external is True
assert ep.is_construction is True
# External point is in the solver, with a non-None handle.
assert ep.handle is not None
# The point is in the entities / points dicts.
assert ep.id in sk._entities
assert ep.id in sk._points
# It's tracked as external.
assert ep.id in sk.get_external_entity_ids()
def test_add_external_line_requires_external_endpoints(self):
sk = OCCSketch()
a = sk.add_external_point(0, 0)
b = sk.add_external_point(10, 0)
line = sk.add_external_line(a, b)
assert line is not None
assert line.is_external is True
assert line.is_construction is True
assert line.handle is not None
assert line.id in sk._lines
assert line.id in sk.get_external_entity_ids()
def test_add_external_polyline_shares_corners(self):
sk = OCCSketch()
# Closed rectangle: 4 unique corners reused at the joints.
pts = [(0, 0), (10, 0), (10, 10), (0, 10), (0, 0)]
points, lines = sk.add_external_polyline(pts)
# 4 segments, 5 UV samples but the 1st and last are the same corner.
assert len(lines) == 4
# The 5 samples share the rectangle's 4 corners → 4 unique point entities.
assert len(set(p.id for p in points)) == 4
# All are external.
assert all(p.is_external for p in points)
assert all(ln.is_external for ln in lines)
def test_external_entities_excluded_from_detect_faces(self):
sk = OCCSketch()
# Underlay: a 20x20 square projected from a face (closed polyline).
sk.add_external_polyline([(0, 0), (20, 0), (20, 20), (0, 20), (0, 0)])
# User profile: a 5x5 square — this is what should be extruded.
a = sk.add_point(2, 2); b = sk.add_point(8, 2)
c = sk.add_point(8, 8); d = sk.add_point(2, 8)
sk.add_line(a, b); sk.add_line(b, c)
sk.add_line(c, d); sk.add_line(d, a)
faces = sk.detect_faces()
# Only the user-drawn face (5x5 square) should be detected.
assert len(faces) == 1
outer = faces[0]["outer"]
assert outer["type"] == "polygon"
# 5 vertices on the outer loop (4 corners + closing point).
assert len(outer["points"]) == 5
# It must be the user square, not the underlay.
xs = [p[0] for p in outer["points"][:4]]
ys = [p[1] for p in outer["points"][:4]]
assert min(xs) >= 2 and max(xs) <= 8
assert min(ys) >= 2 and max(ys) <= 8
def test_external_entities_excluded_from_get_polygon_points(self):
sk = OCCSketch()
sk.add_external_polyline([(0, 0), (100, 0), (100, 100), (0, 100), (0, 0)])
a = sk.add_point(1, 1); b = sk.add_point(2, 1)
c = sk.add_point(2, 2); d = sk.add_point(1, 2)
sk.add_line(a, b); sk.add_line(b, c)
sk.add_line(c, d); sk.add_line(d, a)
poly = sk.get_polygon_points()
# The user square (1..2 range) should appear, not the 0..100 underlay.
assert all(1.0 <= p.x <= 2.0 for p in poly)
assert all(1.0 <= p.y <= 2.0 for p in poly)
def test_external_entities_excluded_from_get_geometry(self):
"""Underlay must never appear in the extruded face."""
from OCP.GProp import GProp_GProps
from OCP.BRepGProp import BRepGProp
sk = OCCSketch()
# Underlay (NOT to be extruded).
sk.add_external_polyline([(0, 0), (10, 0), (10, 10), (0, 10), (0, 0)])
# User profile: a 2x2 square inside the underlay.
a = sk.add_point(1, 1); b = sk.add_point(3, 1)
c = sk.add_point(3, 3); d = sk.add_point(1, 3)
sk.add_line(a, b); sk.add_line(b, c)
sk.add_line(c, d); sk.add_line(d, a)
geom = sk.get_geometry()
# Volume = 2 * 2 * 4 = 16, NOT 10 * 10 * 4 = 400.
kernel = OCGeometryKernel()
solid = kernel.extrude(geom, 4.0)
s = kernel._get_shape(solid)
g = GProp_GProps()
BRepGProp.VolumeProperties_s(s, g)
assert abs(g.Mass() - 16.0) < 0.5
def test_distance_to_external_point_constraint(self):
"""The headline use case: hole position fixed relative to a face edge.
User draws a circle (the hole) and a distance from its centre to
a face-projected point. After solve, the circle centre should be
exactly the requested distance from the external point.
"""
sk = OCCSketch()
# Underlay corner: pick a known anchor on the projected face.
anchor = sk.add_external_point(0.0, 0.0)
# User geometry: a 1mm circle for the hole.
hole_centre = sk.add_point(7.0, 4.0) # start position: 7 from anchor
sk.add_circle(hole_centre, 1.0)
# Constrain the hole centre 50 mm from the underlay corner.
ok = sk.constrain_distance(anchor, hole_centre, 50.0)
assert ok
assert sk.solve()
solved = sk.get_solved_point(hole_centre.id)
assert solved is not None
# The starting (7, 4) is well short of 50, so the constraint
# forces the centre out to a point on the 50mm circle around (0,0).
x, y = solved
assert abs(math_hypot(x, y) - 50.0) < 0.01
def test_remove_external_entities_clears_them(self):
sk = OCCSketch()
sk.add_external_polyline([(0, 0), (10, 0), (10, 10), (0, 10), (0, 0)])
assert len(sk.get_external_entity_ids()) > 0
sk.remove_external_entities()
assert len(sk.get_external_entity_ids()) == 0
# No external points/lines left in the tracking dicts.
for eid in sk._entities:
assert not getattr(sk._entities[eid], "is_external", False)
def test_remove_external_entities_prunes_related_constraints(self):
"""Constraints referencing external entities are pruned on removal.
A distance to an external point is recorded in the constraint log
on the ids of both endpoints. After remove_external_entities(),
those entries are gone and the solver rebuilds without them.
"""
sk = OCCSketch()
anchor = sk.add_external_point(0.0, 0.0)
user = sk.add_point(20.0, 0.0)
sk.constrain_distance(anchor, user, 5.0)
sk.solve()
# At least one log entry references the external anchor.
assert any(anchor.id in entry["ids"] for entry in sk._constraint_log)
# Now wipe the underlay.
sk.remove_external_entities()
# The distance constraint is gone, and the user point is free.
assert not any(anchor.id in entry["ids"] for entry in sk._constraint_log)
assert sk.solve()
def test_external_polyline_dedupes_close_points(self):
"""Co-located UV samples share a single point entity (closed loops)."""
sk = OCCSketch()
# Closed rectangle (closing point == start point).
pts = [(1.0, 1.0), (9.0, 1.0), (9.0, 9.0), (1.0, 9.0), (1.0, 1.0)]
points, lines = sk.add_external_polyline(pts)
# 5 samples → 4 unique points (start/end collapse).
assert len(set(p.id for p in points)) == 4
# 4 segments connect them.
assert len(lines) == 4
# Every line's endpoints are among the 4 points.
point_ids = {p.id for p in points}
for line_id, (sid, eid2) in sk._lines.items():
if line_id in sk.get_external_entity_ids():
assert sid in point_ids and eid2 in point_ids
def test_external_point_is_solver_fixed(self):
"""An external point's solver parameters must not change on re-solve.
python_solvespace drags the first user point; external points use
``dragged`` directly so dragging a user point near an external
reference doesn't shift the reference.
"""
sk = OCCSketch()
ep = sk.add_external_point(3.0, 4.0)
# Add a user point; solve; record the external point's solved
# params. Then delete the user point and add another one; the
# external point's params must not have moved.
sk.add_point(100.0, 0.0)
sk.solve()
x0, y0 = sk.solver.params(ep.handle.params)
for dx in range(-5, 6):
sk.add_point(100.0 + dx, 0.0)
sk.solve()
x1, y1 = sk.solver.params(ep.handle.params)
assert abs(x1 - x0) < 1e-6
assert abs(y1 - y0) < 1e-6
def test_horizontal_constraint_on_external_line(self):
"""Horizontal constraint involving a partly-external line is solvable.
Both external endpoints are dragged (fixed), so a horizontal
constraint between them is over-determined when their y values
differ. To make the system solvable we add a free user point
connected to one external point via a line, then constrain that
line horizontal the user endpoint is dragged to a y that
matches the external one, satisfying the constraint.
"""
sk = OCCSketch()
a = sk.add_external_point(0.0, 0.0)
# Add a free user point first (skipped auto-drag because external
# points exist, so this one is free).
free = sk.add_point(7.0, 5.0)
# And an external endpoint to pair with the free point in a line.
b = sk.add_external_point(0.0, 0.0)
line = sk.add_external_line(b, free)
# Constrain it horizontal; the free point should drop to y=0.
sk.constrain_horizontal(line)
assert sk.solve()
sa = sk.get_solved_point(b.id)
sf = sk.get_solved_point(free.id)
assert sa is not None and sf is not None
assert abs(sa[1] - sf[1]) < 1e-6
def test_cleared_sketch_drops_external_entities(self):
sk = OCCSketch()
sk.add_external_polyline([(0, 0), (1, 0), (1, 1), (0, 1), (0, 0)])
sk.add_point(5, 5)
assert len(sk.get_external_entity_ids()) > 0
sk.clear()
assert len(sk.get_external_entity_ids()) == 0
assert sk.get_entity_count() == 0
class TestExtrudeCutFix:
"""Tests for the cut/union logic in MainWindow._extrude_sketch.
The old code stored the boolean result in the *tool* (newly extruded)
body, leaving the *target* body untouched so the user would see a
separate "cavity-shaped" body next to the original instead of a
cavity in the original. After deleting that extra body, the next
extrude-cutter saw ``len(existing) <= 1`` and silently skipped the
cut, producing an unconstrained new body that looked "added without
cut". The fix:
1. Apply the boolean to the *target* (existing[0]) body.
2. Remove the tool body from the component.
3. Re-render the target in place.
These tests verify the boolean operation produces the right solid and
that the post-extrude bookkeeping leaves exactly the right bodies
in the component.
"""
def test_boolean_difference_modifies_target_not_tool(self):
"""The fix: cut goes into the target, tool is removed.
Reproduces the cut/merge flow from ``_extrude_sketch`` without
spinning up the full MainWindow: build a target + tool body,
run boolean_difference, then verify the target's volume dropped
and the tool is no longer needed.
"""
from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox
from fluency.geometry_occ.kernel import OCGeometryKernel, OCCGeometryObject
from OCP.GProp import GProp_GProps
from OCP.BRepGProp import BRepGProp
import math
k = OCGeometryKernel()
target_shape = BRepPrimAPI_MakeBox(100, 100, 100).Shape()
target_obj = OCCGeometryObject(target_shape, {"type": "box"})
# Tool: a 20x20x200 cuboid at the corner of the box, to make the
# expected volume easy to compute.
from OCP.BRepPrimAPI import BRepPrimAPI_MakePrism
from OCP.gp import gp_Pnt, gp_Vec
# 20x20 square at (0,0,0), extruded along +Z by 200.
from OCP.BRepBuilderAPI import BRepBuilderAPI_MakePolygon
mp = BRepBuilderAPI_MakePolygon()
for (x, y) in [(0, 0), (20, 0), (20, 20), (0, 20)]:
mp.Add(gp_Pnt(x, y, 0))
mp.Close()
from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeFace
face = BRepBuilderAPI_MakeFace(mp.Wire()).Face()
tool_shape = BRepPrimAPI_MakePrism(
face, gp_Vec(0, 0, 200)
).Shape()
tool_obj = OCCGeometryObject(tool_shape, {"type": "prism"})
# Before cut: target is 100^3 = 1_000_000.
g0 = GProp_GProps()
BRepGProp.VolumeProperties_s(k._get_shape(target_obj), g0)
assert abs(g0.Mass() - 1_000_000.0) < 1.0
# Apply the fix: result goes to the target, not the tool.
result = k.boolean_difference(target_obj, tool_obj)
target_obj_geometry = result
# After cut: target is 1_000_000 - 20*20*100 = 960_000
# (the prism only intersects the box in z=[0,100], i.e. 100 deep).
g1 = GProp_GProps()
BRepGProp.VolumeProperties_s(
k._get_shape(target_obj_geometry), g1
)
assert abs(g1.Mass() - 960_000.0) < 1.0
def test_boolean_difference_does_not_leave_separate_cavity_body(self):
"""Sanity: the cut result is a single body (not two).
The OLD bug stored the cut result in a SECOND body, so after a
cut the user would see the original body PLUS a "cavity-shaped"
body the user thought the cut worked but it was just two
separate solids. With the fix the cut is folded into the
target, so a single body remains.
"""
from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox
from OCP.BRepAlgoAPI import BRepAlgoAPI_Cut
from OCP.TopExp import TopExp_Explorer
from OCP.TopAbs import TopAbs_SOLID
from fluency.geometry_occ.kernel import OCGeometryKernel, OCCGeometryObject
k = OCGeometryKernel()
target_shape = BRepPrimAPI_MakeBox(100, 100, 100).Shape()
target_obj = OCCGeometryObject(target_shape, {})
# Tool: small box at the centre, fully inside the target.
from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox as BBox
tool_shape = BBox(20, 20, 20).Shape()
tool_obj = OCCGeometryObject(tool_shape, {})
# The fixed cut flow:
# 1. Apply boolean to target.
# 2. Remove the tool from the component dict.
result = k.boolean_difference(target_obj, tool_obj)
target_obj.geometry = result # the fix: result goes in target
# (the tool_obj is then discarded; the simulated flow above
# keeps it locally but doesn't use it for display).
# Count solids in the cut result. It should be exactly 1 (the
# target with a cavity), not 2 (target + cavity-shaped tool).
shape = k._get_shape(target_obj)
explorer = TopExp_Explorer(shape, TopAbs_SOLID)
n_solids = 0
while explorer.More():
n_solids += 1
explorer.Next()
assert n_solids == 1, f"Cut result has {n_solids} solids, expected 1"
class TestBodyVisibilityToggle:
"""Tests for the per-body visibility toggle on the right-hand body list.
The user asked for a visibility checkbox per body so they could
easily verify whether an operation (e.g. cut) had actually modified
a body. Hiding the second body and seeing whether the first still
has the cut shape is the intended workflow.
"""
def _make_window(self):
import os
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PySide6.QtWidgets import QApplication
app = QApplication.instance() or QApplication([])
from fluency.main import MainWindow
return MainWindow()
def test_body_list_uses_checkable_items(self):
"""Each body list item must be a checkable QListWidgetItem."""
from PySide6.QtCore import Qt
win = self._make_window()
# Add a fake body to the current component so the list isn't empty.
from fluency.models.data_model import Body
from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox
from fluency.geometry_occ.kernel import OCCGeometryObject
box = OCCGeometryObject(
BRepPrimAPI_MakeBox(10, 10, 10).Shape(), {}
)
win._current_component.bodies["a"] = Body(name="A", geometry=box)
win._refresh_lists()
items = win._body_list.findItems("A", Qt.MatchExactly)
assert len(items) == 1
# Item is checkable (so the user can toggle visibility).
assert items[0].flags() & Qt.ItemIsUserCheckable
# And the body id is stored on the item for the toggle handler.
assert items[0].data(Qt.UserRole) == "a"
# Default state is checked (= visible).
assert items[0].checkState() == Qt.Checked
def test_toggling_visibility_updates_body_model(self):
"""Flipping the checkbox should set body.visible accordingly."""
from PySide6.QtCore import Qt
win = self._make_window()
from fluency.models.data_model import Body
from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox
from fluency.geometry_occ.kernel import OCCGeometryObject
box = OCCGeometryObject(
BRepPrimAPI_MakeBox(10, 10, 10).Shape(), {}
)
win._current_component.bodies["a"] = Body(name="A", geometry=box)
win._refresh_lists()
item = win._body_list.findItems("A", Qt.MatchExactly)[0]
# Toggle off.
item.setCheckState(Qt.Unchecked)
win._on_body_visibility_changed(item)
assert win._current_component.bodies["a"].visible is False
# Toggle back on.
item.setCheckState(Qt.Checked)
win._on_body_visibility_changed(item)
assert win._current_component.bodies["a"].visible is True
def test_visibility_no_op_when_unchanged(self):
"""Re-emitting the same state must not trigger a viewer call.
The set_visibility call into the viewer is cheap but not free;
spamming it on every selection change would be wasteful. The
handler short-circuits when the new state matches the model's.
"""
from PySide6.QtCore import Qt
win = self._make_window()
from fluency.models.data_model import Body
from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox
from fluency.geometry_occ.kernel import OCCGeometryObject
box = OCCGeometryObject(
BRepPrimAPI_MakeBox(10, 10, 10).Shape(), {}
)
win._current_component.bodies["a"] = Body(name="A", geometry=box)
win._refresh_lists()
item = win._body_list.findItems("A", Qt.MatchExactly)[0]
# Force the model's visibility to False to mimic a desync.
win._current_component.bodies["a"].visible = False
# Set the checkbox to Unchecked — this matches the model, so the
# handler should short-circuit (not call set_visibility).
item.setCheckState(Qt.Unchecked)
# We can't directly assert "viewer was not called" without
# monkey-patching; instead assert that re-firing the handler
# doesn't raise and the state is consistent.
win._on_body_visibility_changed(item)
assert win._current_component.bodies["a"].visible is False
def math_hypot(x, y):
import math
return math.hypot(x, y)
class TestConstraintTagRendering:
"""Tests for constraint tag rendering when a tag references a line id.
The constraint log stores entity ids. A constraint that targets a
line (e.g. point-on-line coincident) puts a *line* id in the log,
and the tag rendering code used to naively unpack that line's
geometry ``((x1,y1), (x2,y2))`` as if it were a point's ``(x, y)``,
calling ``round()`` on a tuple and raising
``TypeError: type tuple doesn't define __round__ method``.
These tests pin the fix in ``Sketch2DWidget._compute_constraint_tags``.
"""
def _make_widget_with_sketch(self, sk):
"""Build a Sketch2DWidget in offscreen mode and attach *sk* to it."""
import os
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PySide6.QtWidgets import QApplication
app = QApplication.instance() or QApplication([])
from fluency.main import Sketch2DWidget
w = Sketch2DWidget()
w.set_sketch(sk)
return w
def test_point_on_line_coincident_tag_renders(self):
"""A coincident between a point and a line must not crash the paint event.
Reproduces the user-reported error: ids[1] is a line id, the old
code unpacked the line's geometry ``((x1,y1), (x2,y2))`` as a
point and called ``round()`` on the inner tuple.
"""
sk = OCCSketch()
a = sk.add_point(0, 0)
b = sk.add_point(10, 0)
line = sk.add_line(a, b) # 3rd entity: the line itself
# Point-on-line: the line id is in the constraint log.
p3 = sk.add_point(5, 5)
sk.constrain_coincident(p3, line)
sk.solve()
w = self._make_widget_with_sketch(sk)
# Must not raise.
tags = w._compute_constraint_tags()
# One tag for the coincident.
coin_tags = [t for t in tags if "coin" in t["label"]]
assert len(coin_tags) == 1
# The tag was anchored (non-None center) and renders successfully.
assert coin_tags[0]["center"] is not None
def test_point_world_rejects_line_geometry(self):
"""_point_world must return None (not crash) when given a line id."""
sk = OCCSketch()
a = sk.add_point(0, 0)
b = sk.add_point(10, 0)
line = sk.add_line(a, b)
w = self._make_widget_with_sketch(sk)
# Old behaviour: round(<tuple>) raised TypeError.
# New behaviour: _point_world returns None for non-point entities.
result = w._point_world(line.id)
assert result is None
def test_point_world_rejects_circle_geometry(self):
"""_point_world must return None for circle entities too.
A circle's geometry is ``((cx, cy), radius)`` — also not a flat
2-tuple of numbers. Same shape check rejects it.
"""
sk = OCCSketch()
c = sk.add_point(0, 0)
circle = sk.add_circle(c, 5.0)
w = self._make_widget_with_sketch(sk)
result = w._point_world(circle.id)
assert result is None
def test_entity_anchor_routes_to_line_midpoint(self):
"""_entity_anchor returns the line midpoint for line ids."""
sk = OCCSketch()
a = sk.add_point(0, 0)
b = sk.add_point(10, 0)
line = sk.add_line(a, b)
w = self._make_widget_with_sketch(sk)
anchor = w._entity_anchor(line.id)
assert anchor is not None
# Midpoint of (0,0) and (10,0) is (5, 0).
assert anchor.x() == 5
assert anchor.y() == 0
def test_distance_constraint_with_line_id(self):
"""A distance constraint involving a line id must not crash.
Future enhancements might add a point-to-line distance; even
without that, the defensive routing through _entity_anchor
ensures the tag renders cleanly when such an entry is logged.
"""
sk = OCCSketch()
a = sk.add_point(0, 0)
b = sk.add_point(10, 0)
line = sk.add_line(a, b)
p3 = sk.add_point(15, 5)
# Simulate a point-to-line distance by directly appending a log
# entry — this matches the solver's surface (it would call
# _record_constraint with these ids once a point-to-line
# distance is added to the solver wrapper).
sk._record_constraint("distance", (p3.id, line.id), (12.0,))
w = self._make_widget_with_sketch(sk)
tags = w._compute_constraint_tags()
dst_tags = [t for t in tags if "dst" in t["label"]]
assert len(dst_tags) == 1
assert dst_tags[0]["center"] is not None
def test_paint_tolerates_corrupted_entity_geometry(self):
"""Paint must not crash if an entity's geometry is weird.
Simulates the user-reported case: after constraining many
points, the solver log still references an entity whose
geometry was corrupted (e.g. line-shape ``((x,y), r)`` on a
point entity, a 3-element list, or a value with a __round__
that raises). _compute_constraint_tags should drop the bad
tag and keep rendering the rest.
"""
sk = OCCSketch()
a = sk.add_point(0, 0)
b = sk.add_point(10, 0)
c = sk.add_point(5, 5)
sk.constrain_coincident(c, a)
sk.solve()
w = self._make_widget_with_sketch(sk)
# Case 1: point entity has line-shape geometry.
sk._entities[c.id].geometry = ((1.0, 2.0), 3.0)
tags = w._compute_constraint_tags()
# Bad entry is dropped; the good one still renders.
assert all(t["center"] is not None for t in tags)
# Case 2: wrong-shape geometry (3-element list).
sk._entities[c.id].geometry = [1.0, 2.0, 3.0]
tags = w._compute_constraint_tags()
assert all(t["center"] is not None for t in tags)
# Case 3: exotic type whose __round__ raises.
class _BadRound:
def __round__(self, ndigits=0):
raise TypeError("cannot round")
sk._entities[c.id].geometry = (_BadRound(), _BadRound())
tags = w._compute_constraint_tags()
assert all(t["center"] is not None for t in tags)
def test_paint_tolerates_dangling_constraint_ids(self):
"""Paint must not crash if the log references an entity that was deleted.
The log can briefly reference a stale id after a delete (e.g.
if a constraint handler logs first and deletes second). The
render path must skip such entries, not raise KeyError or
TypeError.
"""
sk = OCCSketch()
a = sk.add_point(0, 0)
sk.constrain_fixed(a)
sk.solve()
# Simulate the entity being removed without pruning the log.
sk._entities.pop(a.id)
w = self._make_widget_with_sketch(sk)
tags = w._compute_constraint_tags()
# No crash; the dangling tag is dropped.
assert isinstance(tags, list)
class TestExtrudeRedesign:
"""Tests for the cut-through / source-body auto-target / live-preview
redesign (2026-06-29).
Headline workflow: a sketch projected on a face of a body, plus "Perform
Cut"
1. auto-targets the body it was projected onto,
2. auto-directs the cut INTO the body (the picked face's outward normal
points away, so a plain cut would carve nothing),
3. with "Through All" ticked, fully passes through the body.
A live transparent preview is computed from the same shared helper, and
a freshly-projected sketch is auto-selected in the row-left list so the
user can Extrude/Cut without hunting for the row.
"""
def _make_window_with_box(self, box_side=100.0):
import os
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PySide6.QtWidgets import QApplication
app = QApplication.instance() or QApplication([])
from fluency.main import MainWindow
from fluency.models.data_model import Sketch, Body
from fluency.geometry_occ.kernel import OCCGeometryObject
from fluency.geometry_occ.sketch import OCCSketch
from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox
win = MainWindow()
k = win._kernel
box_shape = BRepPrimAPI_MakeBox(box_side, box_side, box_side).Shape()
box_obj = OCCGeometryObject(box_shape, {"type": "box"})
win._current_component.bodies["b1"] = Body(name="Box1", geometry=box_obj)
# Sketch on the TOP face of the box (normal +Z points outward).
sk = OCCSketch()
origin = (box_side / 2.0, box_side / 2.0, box_side)
normal = (0.0, 0.0, 1.0)
x_dir = (1.0, 0.0, 0.0)
sk.set_workplane(origin, normal, x_dir)
sketch = Sketch(name="S on top")
sketch.occ_sketch = sk
sketch.set_workplane(origin, normal, x_dir)
sketch._source_body_id = "b1"
win._current_component.sketches[sketch.id] = sketch
win._current_sketch = sketch
# Return all the fixtures.
return win, sketch, sk, box_obj
def _add_circle(self, sk, r=10.0):
from fluency.geometry_occ.sketch import OCCSketch
c = sk.add_point(0, 0)
sk.add_circle(c, r)
sk.solve()
return sk.get_geometry()
def _geometry_volume(self, win, geom):
from OCP.GProp import GProp_GProps
from OCP.BRepGProp import BRepGProp
sh = win._kernel._get_shape(geom)
g = GProp_GProps()
BRepGProp.VolumeProperties_s(sh, g)
return g.Mass()
def test_cut_auto_directs_into_body(self):
"""A plain "Perform Cut" on a sketch-on-top-of-body carves a pocket.
Without the redesign a non-inverted extrude goes *outward* (up),
missing the box and carving nothing. The redesign auto-flips the
extrusion to go *into* the body regardless of the Invert checkbox,
so a 5 mm cut makes a real 5 mm-deep pocket.
"""
import math
win, sketch, sk, box_obj = self._make_window_with_box(100.0)
face_geom = self._add_circle(sk, r=10.0)
# Plain cut, length=5, NOT inverted. Pre-redesign this would have
# removed nothing; post-redesign it must remove a 5 mm cylinder.
result = win._compute_extrude_result(
sketch, face_geom,
length=5.0, symmetric=False, invert=False,
cut=True, union=False, through_all=False,
)
assert result is not None
assert result["target_body"] is not None
assert result["target_body"].name == "Box1"
vol = self._geometry_volume(win, result["result_geom"])
expected = 100.0 ** 3 - math.pi * (10.0 ** 2) * 5.0
assert abs(vol - expected) < 1.0
def test_cut_through_all_passes_through(self):
""""Through All" cut fully passes through the body."""
import math
win, sketch, sk, box_obj = self._make_window_with_box(100.0)
face_geom = self._add_circle(sk, r=10.0)
result = win._compute_extrude_result(
sketch, face_geom,
length=5.0, # ignored when through_all
symmetric=False, invert=False,
cut=True, union=False, through_all=True,
)
assert result is not None
vol = self._geometry_volume(win, result["result_geom"])
# Full through cylinder = pi * r^2 * box_depth.
expected = 100.0 ** 3 - math.pi * (10.0 ** 2) * 100.0
assert abs(vol - expected) < 1.0
def test_cut_auto_targets_source_body_not_existing_zero(self):
"""Cut should target the source body, not the dict's first body.
Constructs a 2-body scenario where the first body in the dict is NOT
the source, and verifies the cut goes into the source.
"""
import math
import os
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PySide6.QtWidgets import QApplication
app = QApplication.instance() or QApplication([])
from fluency.main import MainWindow
from fluency.models.data_model import Sketch, Body
from fluency.geometry_occ.kernel import OCCGeometryObject
from fluency.geometry_occ.sketch import OCCSketch
from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox
win = MainWindow()
# First body in the dict: a 50-millimetre box ALSO.
first = OCCGeometryObject(
BRepPrimAPI_MakeBox(50, 50, 50).Shape(), {}
)
win._current_component.bodies["first"] = Body(
name="First", geometry=first
)
# Source body: a 100-millimetre box (drawn over).
src = OCCGeometryObject(
BRepPrimAPI_MakeBox(100, 100, 100).Shape(), {}
)
win._current_component.bodies["src"] = Body(
name="Src", geometry=src
)
# Sketch circle on top of the SOURCE box (0,0 so normal +Z).
sk = OCCSketch()
sk.set_workplane((50, 50, 100), (0, 0, 1), (1, 0, 0))
centre = sk.add_point(0, 0)
sk.add_circle(centre, 10.0)
sk.solve()
sketch = Sketch(name="S")
sketch.occ_sketch = sk
sketch.set_workplane((50, 50, 100), (0, 0, 1), (1, 0, 0))
sketch._source_body_id = "src" # explicitly the source box.
win._current_component.sketches[sketch.id] = sketch
win._current_sketch = sketch
face_geom = sk.get_geometry()
result = win._compute_extrude_result(
sketch, face_geom,
length=5.0, symmetric=False, invert=False,
cut=True, union=False, through_all=True,
)
assert result is not None
# Target is the source box, NOT the dict's first body.
assert result["target_body"].name == "Src"
vol = self._geometry_volume(win, result["result_geom"])
# 100^3 - pi*100*100 (through-all full-depth cut on the 100 box).
expected = 100.0 ** 3 - math.pi * (10.0 ** 2) * 100.0
assert abs(vol - expected) < 1.0
def test_union_default_builds_outward(self):
"""Combine (Union) builds a boss OUTWARD (no auto-into-body flip).
Union semantics: the new material adds on TOP of the face, not
into the body. So a 10 mm union adds a 10 mm cylinder of material
rather than "subtracting" from the existing box.
"""
import math
win, sketch, sk, box_obj = self._make_window_with_box(100.0)
face_geom = self._add_circle(sk, r=10.0)
result = win._compute_extrude_result(
sketch, face_geom,
length=10.0, symmetric=False, invert=False,
cut=False, union=True, through_all=False,
)
assert result is not None
vol = self._geometry_volume(win, result["result_geom"])
# 100^3 + pi*100*10 — material added on top.
expected = 100.0 ** 3 + math.pi * (10.0 ** 2) * 10.0
assert abs(vol - expected) < 1.0
def test_plain_extrude_untouched_by_source_body(self):
"""Without cut/union, the extrusion is a standalone new body."""
win, sketch, sk, box_obj = self._make_window_with_box(100.0)
face_geom = self._add_circle(sk, r=10.0)
result = win._compute_extrude_result(
sketch, face_geom,
length=10.0, symmetric=False, invert=False,
cut=False, union=False, through_all=False,
)
assert result is not None
# No boolean target; result is the standalone tool extrusion.
assert result["target_body"] is None
vol = self._geometry_volume(win, result["result_geom"])
# Standalone cylinder 10 mm tall.
import math
assert abs(vol - math.pi * (10.0 ** 2) * 10.0) < 1.0
def test_freshly_picked_sketch_is_auto_selected(self):
"""After _on_face_picked, the new sketch is the current list row.
The user should be able to click Extrude/Cut immediately without
first hunting for the new sketch in the left list.
"""
from fluency.geometry_occ.kernel import OCCGeometryObject
win, _, sk, box_obj = self._make_window_with_box(100.0)
# Simulate _on_face_picked by calling it through a fake face
# shape — but the simplest behavioural check is to call the
# bookkeeping directly: a new sketch matching src exists and is
# set as _current_sketch, and it appears (and is selected) in
# the list after _refresh_lists + setCurrentRow.
from fluency.models.data_model import Sketch
sketch = Sketch(name="Sketch on face 99")
sketch._source_body_id = "b1"
sketch.set_workplane((50, 50, 100), (0, 0, 1), (1, 0, 0))
win._current_component.sketches[sketch.id] = sketch
win._current_sketch = sketch
win._refresh_lists()
# The auto-select block from _on_face_picked — re-derive it
# here since we can't run the full pick path offscreen.
target_row = None
for row in range(win._sketch_list.count()):
if win._sketch_list.item(row).text() == sketch.name:
target_row = row
break
assert target_row is not None
win._sketch_list.setCurrentRow(target_row)
assert win._sketch_list.currentRow() == target_row
assert win._sketch_list.currentItem().text() == sketch.name
def test_preview_callback_invoked_on_value_change(self):
"""The live preview callback fires on spinbox/checkbox changes."""
import os
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PySide6.QtWidgets import QApplication
app = QApplication.instance() or QApplication([])
from fluency.main import ExtrudeDialog
calls = []
dialog = ExtrudeDialog()
dialog.set_preview_callback(lambda v: calls.append(v))
# set_preview_callback emits once for the initial state.
assert len(calls) == 1
# Changing the length should emit a new values tuple.
dialog.length_input.setValue(42.0)
assert len(calls) == 2
# Toggling "Through All" should emit again.
dialog.through_all_checkbox.setChecked(True)
assert len(calls) >= 3
# Passing None clears the preview (as the host does on close).
dialog.set_preview_callback(None)
# New callback None → no further emissions.
before = len(calls)
dialog.length_input.setValue(99.0)
assert len(calls) == before # callback gone → no emit
def test_preview_hidden_event_sends_none(self):
"""hideEvent should deliver None to the callback so the host clears."""
import os
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PySide6.QtWidgets import QApplication
app = QApplication.instance() or QApplication([])
from fluency.main import ExtrudeDialog
seen = []
dialog = ExtrudeDialog()
dialog.set_preview_callback(lambda v: seen.append(v))
# hideEvent only fires when the dialog was previously visible, so
# show it first (window system / offscreen both honour this) and
# then hide it — which is exactly what dialog.exec() does when the
# user accepts or cancels.
dialog.show()
dialog.hide()
# The last value emitted to the callback must be None (clear).
assert seen and seen[-1] is None
if __name__ == "__main__":
pytest.main([__file__, "-v"])
Generated
+9 -1575
View File
File diff suppressed because it is too large Load Diff