Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 742d06d242 | |||
| c78d0af78c | |||
| dda9db822b | |||
| 9f1387fe68 | |||
| 210e3cfb5d | |||
| b8516fff91 | |||
| d7e5929a13 | |||
| 2b2afbc479 | |||
| b0aebdc04f | |||
| be22c44a3f | |||
| 80ba3cc70a | |||
| 5269c0897c | |||
| 3a169007f7 | |||
| b595b88e04 | |||
| 9f10a5c5e5 | |||
| 6ba742ddf5 | |||
| 01833e4af2 | |||
| f860ff3e77 |
Generated
+169
-10
@@ -4,13 +4,14 @@
|
|||||||
<option name="autoReloadType" value="SELECTIVE" />
|
<option name="autoReloadType" value="SELECTIVE" />
|
||||||
</component>
|
</component>
|
||||||
<component name="ChangeListManager">
|
<component name="ChangeListManager">
|
||||||
<list default="true" id="8f0bafd6-58a0-4b20-aa2b-ddc3ba278873" name="Changes" comment="- Tons of addtions">
|
<list default="true" id="8f0bafd6-58a0-4b20-aa2b-ddc3ba278873" name="Changes" comment="- added renderer - Added undo">
|
||||||
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
|
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
|
||||||
<change beforePath="$PROJECT_DIR$/src/fluency/geometry_occ/kernel.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/geometry_occ/kernel.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/geometry_occ/sketch.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/geometry_occ/sketch.py" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/src/fluency/main.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/main.py" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/src/fluency/rendering/occ_renderer.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/rendering/occ_renderer.py" afterDir="false" />
|
<change beforePath="$PROJECT_DIR$/src/fluency/rendering/occ_renderer.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/rendering/occ_renderer.py" afterDir="false" />
|
||||||
<change beforePath="$PROJECT_DIR$/tests/test_geometry.py" beforeDir="false" afterPath="$PROJECT_DIR$/tests/test_geometry.py" afterDir="false" />
|
<change beforePath="$PROJECT_DIR$/src/fluency/rendering/render_backend.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/rendering/render_backend.py" afterDir="false" />
|
||||||
|
<change beforePath="$PROJECT_DIR$/src/fluency/ui/main_window.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/ui/main_window.py" afterDir="false" />
|
||||||
|
<change beforePath="$PROJECT_DIR$/src/fluency/ui/render_window.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/ui/render_window.py" afterDir="false" />
|
||||||
|
<change beforePath="$PROJECT_DIR$/src/fluency/ui/viewer_widget.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/ui/viewer_widget.py" afterDir="false" />
|
||||||
</list>
|
</list>
|
||||||
<option name="SHOW_DIALOG" value="false" />
|
<option name="SHOW_DIALOG" value="false" />
|
||||||
<option name="HIGHLIGHT_CONFLICTS" value="true" />
|
<option name="HIGHLIGHT_CONFLICTS" value="true" />
|
||||||
@@ -52,17 +53,23 @@
|
|||||||
"Python.2dtest.executor": "Run",
|
"Python.2dtest.executor": "Run",
|
||||||
"Python.3d_windows.executor": "Run",
|
"Python.3d_windows.executor": "Run",
|
||||||
"Python.Unnamed.executor": "Run",
|
"Python.Unnamed.executor": "Run",
|
||||||
|
"Python.base.executor": "Run",
|
||||||
|
"Python.data_model.executor": "Run",
|
||||||
"Python.draw_widget2d.executor": "Run",
|
"Python.draw_widget2d.executor": "Run",
|
||||||
"Python.draw_widget_solve.executor": "Run",
|
"Python.draw_widget_solve.executor": "Run",
|
||||||
"Python.fluency.executor": "Run",
|
"Python.fluency.executor": "Run",
|
||||||
"Python.fluencyb.executor": "Run",
|
"Python.fluencyb.executor": "Run",
|
||||||
"Python.gl_widget.executor": "Run",
|
"Python.gl_widget.executor": "Run",
|
||||||
|
"Python.gui_ui.executor": "Run",
|
||||||
"Python.kernel.executor": "Run",
|
"Python.kernel.executor": "Run",
|
||||||
"Python.main.executor": "Run",
|
"Python.main.executor": "Run",
|
||||||
"Python.meshtest.executor": "Run",
|
"Python.meshtest.executor": "Run",
|
||||||
"Python.occ_renderer.executor": "Run",
|
"Python.occ_renderer.executor": "Run",
|
||||||
|
"Python.occ_to_mesh.executor": "Run",
|
||||||
|
"Python.render_backend.executor": "Run",
|
||||||
"Python.side_fluency.executor": "Run",
|
"Python.side_fluency.executor": "Run",
|
||||||
"Python.simple_mesh.executor": "Run",
|
"Python.simple_mesh.executor": "Run",
|
||||||
|
"Python.sketch.executor": "Run",
|
||||||
"Python.vtk_widget.executor": "Run",
|
"Python.vtk_widget.executor": "Run",
|
||||||
"Python.vulkan.executor": "Run",
|
"Python.vulkan.executor": "Run",
|
||||||
"RunOnceActivity.OpenProjectViewOnStart": "true",
|
"RunOnceActivity.OpenProjectViewOnStart": "true",
|
||||||
@@ -72,7 +79,7 @@
|
|||||||
"RunOnceActivity.typescript.service.memoryLimit.init": "true",
|
"RunOnceActivity.typescript.service.memoryLimit.init": "true",
|
||||||
"codeWithMe.voiceChat.enabledByDefault": "false",
|
"codeWithMe.voiceChat.enabledByDefault": "false",
|
||||||
"git-widget-placeholder": "feature/occ-migration",
|
"git-widget-placeholder": "feature/occ-migration",
|
||||||
"last_opened_file_path": "/Volumes/Data_drive/Programming/fluency/src/fluency",
|
"last_opened_file_path": "/Volumes/Data_drive/Programming/fluency/src/fluency/rendering",
|
||||||
"node.js.detected.package.eslint": "true",
|
"node.js.detected.package.eslint": "true",
|
||||||
"node.js.selected.package.eslint": "(autodetect)",
|
"node.js.selected.package.eslint": "(autodetect)",
|
||||||
"node.js.selected.package.tslint": "(autodetect)",
|
"node.js.selected.package.tslint": "(autodetect)",
|
||||||
@@ -87,6 +94,7 @@
|
|||||||
</component>
|
</component>
|
||||||
<component name="RecentsManager">
|
<component name="RecentsManager">
|
||||||
<key name="CopyFile.RECENT_KEYS">
|
<key name="CopyFile.RECENT_KEYS">
|
||||||
|
<recent name="$PROJECT_DIR$/src/fluency/rendering" />
|
||||||
<recent name="$PROJECT_DIR$/src/fluency" />
|
<recent name="$PROJECT_DIR$/src/fluency" />
|
||||||
<recent name="$PROJECT_DIR$" />
|
<recent name="$PROJECT_DIR$" />
|
||||||
<recent name="$PROJECT_DIR$/drawing_modules" />
|
<recent name="$PROJECT_DIR$/drawing_modules" />
|
||||||
@@ -280,7 +288,151 @@
|
|||||||
<option name="project" value="LOCAL" />
|
<option name="project" value="LOCAL" />
|
||||||
<updated>1782679912834</updated>
|
<updated>1782679912834</updated>
|
||||||
</task>
|
</task>
|
||||||
<option name="localTasksCounter" value="22" />
|
<task id="LOCAL-00022" summary="- Basic operations">
|
||||||
|
<option name="closed" value="true" />
|
||||||
|
<created>1782768610475</created>
|
||||||
|
<option name="number" value="00022" />
|
||||||
|
<option name="presentableId" value="LOCAL-00022" />
|
||||||
|
<option name="project" value="LOCAL" />
|
||||||
|
<updated>1782768610475</updated>
|
||||||
|
</task>
|
||||||
|
<task id="LOCAL-00023" summary="- removed cadquery deoendency">
|
||||||
|
<option name="closed" value="true" />
|
||||||
|
<created>1782928990792</created>
|
||||||
|
<option name="number" value="00023" />
|
||||||
|
<option name="presentableId" value="LOCAL-00023" />
|
||||||
|
<option name="project" value="LOCAL" />
|
||||||
|
<updated>1782928990792</updated>
|
||||||
|
</task>
|
||||||
|
<task id="LOCAL-00024" summary="- sketch enhacements">
|
||||||
|
<option name="closed" value="true" />
|
||||||
|
<created>1783108151675</created>
|
||||||
|
<option name="number" value="00024" />
|
||||||
|
<option name="presentableId" value="LOCAL-00024" />
|
||||||
|
<option name="project" value="LOCAL" />
|
||||||
|
<updated>1783108151676</updated>
|
||||||
|
</task>
|
||||||
|
<task id="LOCAL-00025" summary="- sketch enhacements">
|
||||||
|
<option name="closed" value="true" />
|
||||||
|
<created>1783159860774</created>
|
||||||
|
<option name="number" value="00025" />
|
||||||
|
<option name="presentableId" value="LOCAL-00025" />
|
||||||
|
<option name="project" value="LOCAL" />
|
||||||
|
<updated>1783159860774</updated>
|
||||||
|
</task>
|
||||||
|
<task id="LOCAL-00026" summary="- UI refinement, button position ui file as source no dirty drafting anymore">
|
||||||
|
<option name="closed" value="true" />
|
||||||
|
<created>1783174566362</created>
|
||||||
|
<option name="number" value="00026" />
|
||||||
|
<option name="presentableId" value="LOCAL-00026" />
|
||||||
|
<option name="project" value="LOCAL" />
|
||||||
|
<updated>1783174566362</updated>
|
||||||
|
</task>
|
||||||
|
<task id="LOCAL-00027" summary="- assembly draft">
|
||||||
|
<option name="closed" value="true" />
|
||||||
|
<created>1783239410744</created>
|
||||||
|
<option name="number" value="00027" />
|
||||||
|
<option name="presentableId" value="LOCAL-00027" />
|
||||||
|
<option name="project" value="LOCAL" />
|
||||||
|
<updated>1783239410744</updated>
|
||||||
|
</task>
|
||||||
|
<task id="LOCAL-00028" summary="- assembly draft">
|
||||||
|
<option name="closed" value="true" />
|
||||||
|
<created>1783272988957</created>
|
||||||
|
<option name="number" value="00028" />
|
||||||
|
<option name="presentableId" value="LOCAL-00028" />
|
||||||
|
<option name="project" value="LOCAL" />
|
||||||
|
<updated>1783272988957</updated>
|
||||||
|
</task>
|
||||||
|
<task id="LOCAL-00029" summary="- Added save file foramt - Split main.py refactor">
|
||||||
|
<option name="closed" value="true" />
|
||||||
|
<created>1783282570014</created>
|
||||||
|
<option name="number" value="00029" />
|
||||||
|
<option name="presentableId" value="LOCAL-00029" />
|
||||||
|
<option name="project" value="LOCAL" />
|
||||||
|
<updated>1783282570014</updated>
|
||||||
|
</task>
|
||||||
|
<task id="LOCAL-00030" summary="- Added save file foramt - Split main.py refactor">
|
||||||
|
<option name="closed" value="true" />
|
||||||
|
<created>1783453889199</created>
|
||||||
|
<option name="number" value="00030" />
|
||||||
|
<option name="presentableId" value="LOCAL-00030" />
|
||||||
|
<option name="project" value="LOCAL" />
|
||||||
|
<updated>1783453889199</updated>
|
||||||
|
</task>
|
||||||
|
<task id="LOCAL-00031" summary="- Added save file foramt - Split main.py refactor">
|
||||||
|
<option name="closed" value="true" />
|
||||||
|
<created>1783456842297</created>
|
||||||
|
<option name="number" value="00031" />
|
||||||
|
<option name="presentableId" value="LOCAL-00031" />
|
||||||
|
<option name="project" value="LOCAL" />
|
||||||
|
<updated>1783456842297</updated>
|
||||||
|
</task>
|
||||||
|
<task id="LOCAL-00032" summary="- Added save file foramt - Split main.py refactor">
|
||||||
|
<option name="closed" value="true" />
|
||||||
|
<created>1783755278516</created>
|
||||||
|
<option name="number" value="00032" />
|
||||||
|
<option name="presentableId" value="LOCAL-00032" />
|
||||||
|
<option name="project" value="LOCAL" />
|
||||||
|
<updated>1783755278516</updated>
|
||||||
|
</task>
|
||||||
|
<task id="LOCAL-00033" summary="- Added save file foramt - Split main.py refactor">
|
||||||
|
<option name="closed" value="true" />
|
||||||
|
<created>1783777171864</created>
|
||||||
|
<option name="number" value="00033" />
|
||||||
|
<option name="presentableId" value="LOCAL-00033" />
|
||||||
|
<option name="project" value="LOCAL" />
|
||||||
|
<updated>1783777171864</updated>
|
||||||
|
</task>
|
||||||
|
<task id="LOCAL-00034" summary="- Working assembly multi :)">
|
||||||
|
<option name="closed" value="true" />
|
||||||
|
<created>1783798201133</created>
|
||||||
|
<option name="number" value="00034" />
|
||||||
|
<option name="presentableId" value="LOCAL-00034" />
|
||||||
|
<option name="project" value="LOCAL" />
|
||||||
|
<updated>1783798201133</updated>
|
||||||
|
</task>
|
||||||
|
<task id="LOCAL-00035" summary="- Working assembly multi :)">
|
||||||
|
<option name="closed" value="true" />
|
||||||
|
<created>1783798929531</created>
|
||||||
|
<option name="number" value="00035" />
|
||||||
|
<option name="presentableId" value="LOCAL-00035" />
|
||||||
|
<option name="project" value="LOCAL" />
|
||||||
|
<updated>1783798929531</updated>
|
||||||
|
</task>
|
||||||
|
<task id="LOCAL-00036" summary="- added renderer">
|
||||||
|
<option name="closed" value="true" />
|
||||||
|
<created>1783887682052</created>
|
||||||
|
<option name="number" value="00036" />
|
||||||
|
<option name="presentableId" value="LOCAL-00036" />
|
||||||
|
<option name="project" value="LOCAL" />
|
||||||
|
<updated>1783887682052</updated>
|
||||||
|
</task>
|
||||||
|
<task id="LOCAL-00037" summary="- added renderer - Added undo">
|
||||||
|
<option name="closed" value="true" />
|
||||||
|
<created>1783887704766</created>
|
||||||
|
<option name="number" value="00037" />
|
||||||
|
<option name="presentableId" value="LOCAL-00037" />
|
||||||
|
<option name="project" value="LOCAL" />
|
||||||
|
<updated>1783887704766</updated>
|
||||||
|
</task>
|
||||||
|
<task id="LOCAL-00038" summary="- added renderer - Added undo">
|
||||||
|
<option name="closed" value="true" />
|
||||||
|
<created>1783891561116</created>
|
||||||
|
<option name="number" value="00038" />
|
||||||
|
<option name="presentableId" value="LOCAL-00038" />
|
||||||
|
<option name="project" value="LOCAL" />
|
||||||
|
<updated>1783891561116</updated>
|
||||||
|
</task>
|
||||||
|
<task id="LOCAL-00039" summary="- added renderer - Added undo">
|
||||||
|
<option name="closed" value="true" />
|
||||||
|
<created>1783918463034</created>
|
||||||
|
<option name="number" value="00039" />
|
||||||
|
<option name="presentableId" value="LOCAL-00039" />
|
||||||
|
<option name="project" value="LOCAL" />
|
||||||
|
<updated>1783918463034</updated>
|
||||||
|
</task>
|
||||||
|
<option name="localTasksCounter" value="40" />
|
||||||
<servers />
|
<servers />
|
||||||
</component>
|
</component>
|
||||||
<component name="TypeScriptGeneratedFilesManager">
|
<component name="TypeScriptGeneratedFilesManager">
|
||||||
@@ -301,8 +453,6 @@
|
|||||||
<ignored-roots>
|
<ignored-roots>
|
||||||
<path value="$PROJECT_DIR$/pythonProject" />
|
<path value="$PROJECT_DIR$/pythonProject" />
|
||||||
</ignored-roots>
|
</ignored-roots>
|
||||||
<MESSAGE value="init" />
|
|
||||||
<MESSAGE value="- Basic oop sketch widget implement" />
|
|
||||||
<MESSAGE value="- Renabled extrusion with new object system" />
|
<MESSAGE value="- Renabled extrusion with new object system" />
|
||||||
<MESSAGE value="- Sketch projection partly works again :)" />
|
<MESSAGE value="- Sketch projection partly works again :)" />
|
||||||
<MESSAGE value="- Added new componnt controls" />
|
<MESSAGE value="- Added new componnt controls" />
|
||||||
@@ -319,6 +469,15 @@
|
|||||||
<MESSAGE value="- added screenshot" />
|
<MESSAGE value="- added screenshot" />
|
||||||
<MESSAGE value="- added sdf folder ( doesnt work via pip or git=)" />
|
<MESSAGE value="- added sdf folder ( doesnt work via pip or git=)" />
|
||||||
<MESSAGE value="- Tons of addtions" />
|
<MESSAGE value="- Tons of addtions" />
|
||||||
<option name="LAST_COMMIT_MESSAGE" value="- Tons of addtions" />
|
<MESSAGE value="- Basic operations" />
|
||||||
|
<MESSAGE value="- removed cadquery deoendency" />
|
||||||
|
<MESSAGE value="- sketch enhacements" />
|
||||||
|
<MESSAGE value="- UI refinement, button position ui file as source no dirty drafting anymore" />
|
||||||
|
<MESSAGE value="- assembly draft" />
|
||||||
|
<MESSAGE value="- Added save file foramt - Split main.py refactor" />
|
||||||
|
<MESSAGE value="- Working assembly multi :)" />
|
||||||
|
<MESSAGE value="- added renderer" />
|
||||||
|
<MESSAGE value="- added renderer - Added undo" />
|
||||||
|
<option name="LAST_COMMIT_MESSAGE" value="- added renderer - Added undo" />
|
||||||
</component>
|
</component>
|
||||||
</project>
|
</project>
|
||||||
@@ -6,7 +6,7 @@ A parametric CAD application built on OpenCASCADE Technology (OCCT) with a moder
|
|||||||
|
|
||||||
- **OpenCASCADE Geometry Kernel**: Industry-standard BRep geometry with exact precision
|
- **OpenCASCADE Geometry Kernel**: Industry-standard BRep geometry with exact precision
|
||||||
- **STEP/IGES Import/Export**: Full support for industry-standard CAD file formats
|
- **STEP/IGES Import/Export**: Full support for industry-standard CAD file formats
|
||||||
- **Parametric Sketching**: 2D sketching with constraint solving using CadQuery
|
- **Parametric Sketching**: 2D sketching with constraint solving using SolveSpace
|
||||||
- **Boolean Operations**: Union, difference, and intersection
|
- **Boolean Operations**: Union, difference, and intersection
|
||||||
- **Fillet & Chamfer**: Apply edge treatments to solid bodies
|
- **Fillet & Chamfer**: Apply edge treatments to solid bodies
|
||||||
- **Modern Renderer**: WebGPU-based rendering with pygfx (smaller footprint than VTK)
|
- **Modern Renderer**: WebGPU-based rendering with pygfx (smaller footprint than VTK)
|
||||||
@@ -47,8 +47,7 @@ pip install -e ".[dev]"
|
|||||||
|
|
||||||
| Package | Purpose |
|
| Package | Purpose |
|
||||||
|---------|---------|
|
|---------|---------|
|
||||||
| cadquery | High-level OpenCASCADE Python bindings |
|
| cadquery-ocp | OpenCASCADE Python bindings (OCP) |
|
||||||
| ocp | Low-level OpenCASCADE Python bindings |
|
|
||||||
| pygfx | WebGPU-based 3D renderer |
|
| pygfx | WebGPU-based 3D renderer |
|
||||||
| wgpu | WebGPU Python bindings |
|
| wgpu | WebGPU Python bindings |
|
||||||
| PySide6 | Qt GUI framework |
|
| PySide6 | Qt GUI framework |
|
||||||
@@ -105,7 +104,7 @@ kernel.export_stl(body, "part.stl")
|
|||||||
| File Size | Large (mesh) | Small (BRep) |
|
| File Size | Large (mesh) | Small (BRep) |
|
||||||
| Fillet/Chamfer | Approximate | Exact |
|
| Fillet/Chamfer | Approximate | Exact |
|
||||||
| Dependency Size | ~200MB (VTK) | ~30MB (pygfx) |
|
| Dependency Size | ~200MB (VTK) | ~30MB (pygfx) |
|
||||||
| Constraint Solver | SolveSpace (separate) | CadQuery (integrated) |
|
| Constraint Solver | SolveSpace (separate) | SolveSpace (integrated) |
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,219 @@
|
|||||||
|
# Fluency CAD — Agent Guide
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
**Fluency CAD 2.0** is a parametric CAD application built on **OpenCASCADE Technology (OCCT)** with a modern **pygfx-based 3D renderer**. It provides 2D sketching with SolveSpace constraint solving, boolean operations, STEP/IGES/STL import/export, and exact BRep geometry.
|
||||||
|
|
||||||
|
- Language: **Python 3.10+**
|
||||||
|
- GUI: **PySide6** (Qt6)
|
||||||
|
- Geometry Kernel: **OCP** (cadquery-ocp — OpenCASCADE Python bindings)
|
||||||
|
- Constraint Solver: **python_solvespace**
|
||||||
|
- Renderer: **pygfx** (WebGPU) + **OCCRenderer** (native OCC AIS display)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
src/fluency/
|
||||||
|
├── __init__.py # Package entry (version, imports)
|
||||||
|
├── main.py # Application entry point, MainWindow, Sketch2DWidget (~5000 lines)
|
||||||
|
├── sketch_solver.py # SolveSpace constraint solver wrapper (legacy)
|
||||||
|
├── geometry/
|
||||||
|
│ └── base.py # Abstract interfaces: GeometryKernel, SketchInterface, data classes
|
||||||
|
├── geometry_occ/
|
||||||
|
│ ├── kernel.py # OCGeometryKernel (extrude, boolean, fillet, import/export, mesh)
|
||||||
|
│ └── sketch.py # OCCSketch with SolveSpace integration (face detection, constraints)
|
||||||
|
├── models/
|
||||||
|
│ └── data_model.py # Project, Component, Sketch, Body dataclasses
|
||||||
|
├── rendering/
|
||||||
|
│ ├── base.py # Abstract Renderer interface
|
||||||
|
│ ├── occ_renderer.py # OCC AIS renderer (preferred — smooth BRep display)
|
||||||
|
│ └── pygfx_renderer.py # Legacy pygfx renderer
|
||||||
|
├── utils/ # Utility modules
|
||||||
|
└── widgets/ # Custom widgets
|
||||||
|
tests/
|
||||||
|
└── test_geometry.py # Comprehensive test suite (52+ tests)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Key Classes & Responsibilities
|
||||||
|
|
||||||
|
| Class | File | Purpose |
|
||||||
|
|-------|------|---------|
|
||||||
|
| `OCGeometryKernel` | `kernel.py` | OCC shape ops: extrude, boolean, fillet, mesh, import/export |
|
||||||
|
| `OCCSketch` | `sketch.py` | 2D sketch with SolveSpace solver, face detection, workplane |
|
||||||
|
| `OCCSketchEntity` | `sketch.py` | Entity (point/line/circle/arc) with solver handle, is_construction, is_external |
|
||||||
|
| `Sketch2DWidget` | `main.py` | Qt widget for interactive 2D sketching (draw, snap, constrain) |
|
||||||
|
| `MainWindow` | `main.py` | Main application window, toolbars, 3D viewer, operations |
|
||||||
|
| `OCCRenderer` | `occ_renderer.py` | Native OCC AIS display (shaded + edges, face pick) |
|
||||||
|
| `Sketch` | `data_model.py` | Data model: workplane, occ_sketch ref, source_body_id |
|
||||||
|
| `Body` | `data_model.py` | 3D solid with geometry, visibility, render object |
|
||||||
|
| `Component` | `data_model.py` | Container for sketches and bodies |
|
||||||
|
| `Project` | `data_model.py` | Top-level container with kernel |
|
||||||
|
|
||||||
|
### Data Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
User draws in Sketch2DWidget
|
||||||
|
→ OCCSketch entities created in solver
|
||||||
|
→ Constraint solving (python_solvespace)
|
||||||
|
→ OCCSketch.get_geometry() → detect_faces() → build_face_geometry()
|
||||||
|
→ OCGeometryKernel.extrude() → BRepPrimAPI_MakePrism
|
||||||
|
→ Boolean operations → Body added to Component
|
||||||
|
→ OCCRenderer.add_shape() → AIS display
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Development Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install editable
|
||||||
|
pip install -e ".[dev]"
|
||||||
|
|
||||||
|
# Run app
|
||||||
|
python -m fluency.main
|
||||||
|
|
||||||
|
# Run tests (52 tests)
|
||||||
|
python -m pytest tests/test_geometry.py -v
|
||||||
|
|
||||||
|
# Run single test
|
||||||
|
python -m pytest tests/test_geometry.py::TestOCCSketch::test_workplane_extrude_with_hole -xvs
|
||||||
|
|
||||||
|
# Quck geometry test (raw OCC, no Qt)
|
||||||
|
python -c "from fluency.geometry_occ.sketch import OCCSketch; ..."
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Code Conventions
|
||||||
|
|
||||||
|
### General
|
||||||
|
- Line length: **100 chars** (black/ruff config)
|
||||||
|
- Target Python: **3.10+** (uses `from __future__ import annotations`, walrus, pattern matching)
|
||||||
|
- Docstrings: Google/NumPy style preferred
|
||||||
|
- Logging: `logger = logging.getLogger(__name__)` with `logging.DEBUG` level
|
||||||
|
|
||||||
|
### OCC / OCP
|
||||||
|
- Always use `is not None` for OCP objects — `TopoDS_Shape.__bool__` can be falsy even for valid shapes
|
||||||
|
- `BRepBuilderAPI_MakeFace.Add(wire)` expects a `TopoDS_Wire`. `wire.Reversed()` returns `TopoDS_Shape` → cast via `_TopoDS.Wire_s(wire.Reversed())`
|
||||||
|
- Face normal direction: check `face.Orientation()` vs `TopAbs_REVERSED` — a REVERSED face's outward normal is the NEGATION of the surface axis
|
||||||
|
- `TopoDS_Wire_s(shape)`, `TopoDS_Face_s(shape)` — use `_s` suffix from OCP for downcasts
|
||||||
|
- Mesh: `BRepMesh_IncrementalMesh(shape, tol, False, 0.15, True)` — default deflection 0.15 rad for smooth curves
|
||||||
|
|
||||||
|
### Extrude / Cut Workflow
|
||||||
|
- Snapshot `list(self._current_component.bodies.items())` **BEFORE** `add_body()` — the new body must not be in the target set
|
||||||
|
- Cut targets the **source body** (`sketch._source_body_id` from face pick), not `bodies[0]`
|
||||||
|
- The fix: apply boolean to **target** geometry, then remove tool body
|
||||||
|
- Plain extrude with holes: inner wires must have **OPPOSITE** geometric winding to the outer wire (see `build_face_geometry` and `_loop_signed_area`)
|
||||||
|
|
||||||
|
### Sketch / Solvers
|
||||||
|
- `python_solvespace` has NO remove API for entities/constraints. Deleting requires: drop from `_points`/`_lines`, prune `_constraint_log`, `_rebuild_solver()` (recreates entire system), `_rebuild_labels()`, re-solve
|
||||||
|
- `_constraint_log`: each entry is `{"type": str, "ids": tuple[int,...], "params": tuple, "labels": set[str]}`
|
||||||
|
- Constraint labels: stored on **point** entities for paintEvent rendering; rebuilt via `_rebuild_labels()`
|
||||||
|
- Line constraints (`horizontal`/`vertical`/`parallel`/`perpendicular`) need the **line's** solver handle, not a point's. Use `_find_line_sketch_entity()` to get the correct handle
|
||||||
|
- External entities (underlay): `is_external=True`, `is_construction=True`, fixed in solver (always `dragged`). Stored in `_external_entity_ids`, excluded from `_line_segments()`, `get_polygon_points()`, `get_closed_loops()`, `detect_faces()`, `get_geometry()`
|
||||||
|
|
||||||
|
### Face Detection
|
||||||
|
- `get_closed_loops()`: uses snapped-coordinate graph (`_SNAP_TOL = 1e-4`) from line endpoint adjacency. Only accepts simple cycles (all nodes degree 2)
|
||||||
|
- `detect_faces()`: even-odd nesting rule via `_loop_contains`. Even depth = outer boundary, odd = hole
|
||||||
|
- `_loop_rep_point`: midpoint between centroid and first vertex. **Fragile** — can land inside a nested shape for certain geometries (e.g., a small hole near the centroid's direction from the first vertex)
|
||||||
|
- `_loop_signed_area`: shoelace formula for polygons, `πr²` (positive = CCW) for circles
|
||||||
|
|
||||||
|
### Rendering
|
||||||
|
- **OCCRenderer** is the main renderer (not pygfx). Uses `AIS_Shape`, `V3d_Viewer`, `AIS_InteractiveContext`
|
||||||
|
- Face pick: `pick_planar_face(x, y)` → `MoveTo` → `DetectedShape` → `TopoDS_Face_s` → `BRepAdaptor_Surface` plane check
|
||||||
|
- Highlight: `highlight_face(face)` creates a transparent AIS overlay; `clear_face_highlight()` removes it
|
||||||
|
- Preview: `preview_shape(shape)` for live transparent extrude preview
|
||||||
|
- Navigation: Left=orbit, Middle=pan, Wheel=zoom. **Right is RESERVED** — check user before reassigning
|
||||||
|
|
||||||
|
### Paint-Event Safety
|
||||||
|
- Every constraint-tag rendering loop wraps each entry in `try/except` so a bad entry (dangling id, corrupted geometry) doesn't crash the entire paint event
|
||||||
|
- `_point_world()` and `_entity_anchor()` return `None` (not raise) for malformed input
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Known Bugs & Fix Patterns
|
||||||
|
|
||||||
|
### 1. Hole Orientation in Extrusion (FIXED 2026-07-03)
|
||||||
|
**Symptom**: Inner shapes (circle/triangle/slot) inside a rectangle become solid islands instead of holes when extruding, depending on drag direction.
|
||||||
|
|
||||||
|
**Root Cause**: `wire_loop` in `build_face_geometry` unconditionally reversed hole wires (`w.Reversed()`). When the outer polygon was CW-winding (e.g., dragging from top-left to bottom-right), the reversed inner had the SAME effective direction as the outer, making OCC treat it as solid.
|
||||||
|
|
||||||
|
**Fix**: Added `_loop_signed_area()` to compute geometric winding. Hole wires are only reversed when their natural winding matches the outer's (ensuring opposite winding for holes).
|
||||||
|
|
||||||
|
**Relevant code**: `sketch.py`, `build_face_geometry()` and `_loop_signed_area()`
|
||||||
|
|
||||||
|
### 2. _loop_rep_point Fragility (KNOWN)
|
||||||
|
**Symptom**: Face detection fails when a nested shape contains the outer loop's representative point (midpoint between centroid and first vertex).
|
||||||
|
|
||||||
|
**Would-be fix**: Use a guaranteed-interior point (maximum inscribed circle center or perturbed centroid) instead of the centroid-first-vertex midpoint.
|
||||||
|
|
||||||
|
### 3. Extrude Cut / Target Selection (FIXED 2026-06-29)
|
||||||
|
**Symptom**: Cut created a separate "cavity-shaped" body next to the original instead of modifying the target.
|
||||||
|
|
||||||
|
**Fix**: Boolean result stored on TARGET body geometry; tool body removed from component. Auto-target via `sketch._source_body_id`.
|
||||||
|
|
||||||
|
### 4. Workplane Preservation (FIXED 2026-06-29)
|
||||||
|
**Symptom**: Sketch placed on a face lost its workplane after being added to component.
|
||||||
|
|
||||||
|
**Fix**: Copy `occ_sketch` workplane fields into `Sketch` dataclass BEFORE `apply_workplane()`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API Quirks
|
||||||
|
|
||||||
|
- **`QPoint(0,0)`**: falsy via `isNull()` in PySide6 → always use `is not None` for `Optional[QPoint]`
|
||||||
|
- **`QMouseEvent`/`QPainterPath`**: live in `PySide6.QtGui` (NOT `QtCore`)
|
||||||
|
- **`BRepBuilderAPI_MakeFace.Add()`**: needs `TopoDS_Wire`. `wire.Reversed()` returns `TopoDS_Shape` — cast via `TopoDS_Wire_s()`
|
||||||
|
- **python_solvespace**: NO entity/constraint remove API — workaround via `_rebuild_solver()`. Parameters read via `solver.params(handle.params)` → returns `(x, y)` tuple
|
||||||
|
- **OCGeometryKernel.extrude**: unwraps `OCCGeometryObject`, raw `TopoDS_Shape`, or cadquery `Workplane`. Always use `is not None` for the shape (not truthiness)
|
||||||
|
- **Sketch._source_body_id**: dynamic attribute set on `Sketch` dataclass, set during face-pick flow
|
||||||
|
- **`_get_shape(obj)`**: returns `obj.shape.wrapped` for `OCCGeometryObject`, `obj.shape` for raw shapes, `None` for empty. Use `is not None` guards everywhere
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Memory / Agent Context
|
||||||
|
|
||||||
|
This project has extensive Pi memory (hermes-memory) for:
|
||||||
|
- `project="fluency"` with `target="failure"`: bugs, fixes, corrections, insights
|
||||||
|
- `project="fluency"` with `target="memory"`: conventions, decisions, workflow patterns
|
||||||
|
- Available skills: `fix-cad-app-pipeline`, `refactor-from-cadquery-to-ocp`
|
||||||
|
|
||||||
|
Key memory queries for debugging:
|
||||||
|
- "hole orientation" → `_loop_signed_area` / `build_face_geometry` fix
|
||||||
|
- "extrude cut auto-target" → cut/target body fix
|
||||||
|
- "workplane preservation" → _add_sketch_to_component fix
|
||||||
|
- "_loop_rep_point" → face detection fragility
|
||||||
|
- "paint-event safety" → try/except per entry pattern
|
||||||
|
- "solver rebuild" → delete workflow via _rebuild_solver
|
||||||
|
- "face pick origin" → pick_planar_face face bbox centre
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing Patterns
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Direct OCC test (no Qt)
|
||||||
|
from OCP.BRepBuilderAPI import BRepBuilderAPI_MakePolygon, BRepBuilderAPI_MakeFace
|
||||||
|
from OCP.gp import gp_Pnt
|
||||||
|
from OCP.BRepPrimAPI import BRepPrimAPI_MakePrism
|
||||||
|
from OCP.GProp import GProp_GProps; from OCP.BRepGProp import BRepGProp
|
||||||
|
|
||||||
|
# Build test shape, extrude, verify volume
|
||||||
|
mp = BRepBuilderAPI_MakePolygon(); ...
|
||||||
|
g = GProp_GProps(); BRepGProp.VolumeProperties_s(shape, g)
|
||||||
|
assert abs(g.Mass() - expected) < 0.1
|
||||||
|
```
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Sketch-based test
|
||||||
|
from fluency.geometry_occ.sketch import OCCSketch
|
||||||
|
from fluency.geometry_occ.kernel import OCGeometryKernel
|
||||||
|
|
||||||
|
sk = OCCSketch()
|
||||||
|
# ... add points, lines, circles ...
|
||||||
|
sk.solve()
|
||||||
|
geom = sk.get_geometry()
|
||||||
|
solid = OCGeometryKernel().extrude(geom, 10.0)
|
||||||
|
```
|
||||||
@@ -0,0 +1,253 @@
|
|||||||
|
# Realistic Render View — Implementation Plan
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Add a **"Render"** feature to Fluency CAD that opens a separate window for photorealistic rendering of the selected component or assembly (like KeyShot/Cacles).
|
||||||
|
|
||||||
|
**Constraints:**
|
||||||
|
- Open in a **new window** — don't clutter the workspace
|
||||||
|
- **Keep existing OCCRenderer** for the interactive 3D viewport — untouched
|
||||||
|
- Render backend must be a **separate, swappable module** so we can change the renderer later
|
||||||
|
- Use **Mitsuba 3** as the initial backend (`pip install mitsuba`, ~50MB)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────┐
|
||||||
|
│ Main Fluency Window (existing OCCRenderer — untouched) │
|
||||||
|
│ │
|
||||||
|
│ [Select body/assembly] → [Click "Render"] │
|
||||||
|
│ │ │
|
||||||
|
│ ▼ │
|
||||||
|
│ ┌─────────────────────────────────────┐ │
|
||||||
|
│ │ RenderWindow (separate QMainWindow)│ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ ┌───────────────────────────────┐ │ │
|
||||||
|
│ │ │ RenderBackend (ABC) │ │ │
|
||||||
|
│ │ │ ├─ MitsubaBackend ← current │ │ │
|
||||||
|
│ │ │ ├─ (future: BlenderBackend) │ │ │
|
||||||
|
│ │ │ └─ (future: CyclesBackend) │ │ │
|
||||||
|
│ │ └───────────────────────────────┘ │ │
|
||||||
|
│ │ │ │\n│ │ [Image preview] [Progress bar] │ │
|
||||||
|
│ │ [Material ▾] [Quality ▾] [Render] │ │
|
||||||
|
│ │ [Export PNG] │ │
|
||||||
|
│ └─────────────────────────────────────┘ │
|
||||||
|
└─────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Swappable Backend Interface
|
||||||
|
|
||||||
|
```python
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from dataclasses import dataclass
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class RenderMaterial:
|
||||||
|
name: str
|
||||||
|
color: tuple[float, float, float] = (0.7, 0.7, 0.7)
|
||||||
|
metallic: float = 0.0 # 0.0–1.0
|
||||||
|
roughness: float = 0.5 # 0.0–1.0
|
||||||
|
bsdf_type: str = "diffuse" # diffuse | roughconductor | roughdielectric | plastic
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class RenderCamera:
|
||||||
|
origin: tuple[float, float, float] = (100, 100, 100)
|
||||||
|
target: tuple[float, float, float] = (0, 0, 0)
|
||||||
|
up: tuple[float, float, float] = (0, 0, 1)
|
||||||
|
fov: float = 45.0
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class RenderSettings:
|
||||||
|
width: int = 1920
|
||||||
|
height: int = 1080
|
||||||
|
spp: int = 256 # samples per pixel
|
||||||
|
max_depth: int = 8 # path tracer bounces
|
||||||
|
|
||||||
|
class RenderBackend(ABC):
|
||||||
|
"""Swap this to change the rendering engine."""
|
||||||
|
@abstractmethod
|
||||||
|
def render(self, obj_path: str, material: RenderMaterial,
|
||||||
|
camera: RenderCamera, settings: RenderSettings) -> np.ndarray: ...
|
||||||
|
@abstractmethod
|
||||||
|
def render_preview(self, obj_path: str, material: RenderMaterial,
|
||||||
|
camera: RenderCamera, settings: RenderSettings) -> np.ndarray: ...
|
||||||
|
@abstractmethod
|
||||||
|
def name(self) -> str: ...
|
||||||
|
```
|
||||||
|
|
||||||
|
Switching backends later = write a new class implementing `RenderBackend`. One import change.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Mitsuba 3 Backend
|
||||||
|
|
||||||
|
### Why Mitsuba
|
||||||
|
|
||||||
|
| Feature | Status |
|
||||||
|
|---------|--------|
|
||||||
|
| `pip install mitsuba` | Single install, no system deps |
|
||||||
|
| True path tracing | GI, caustics, spectral rendering |
|
||||||
|
| PBR materials | `roughconductor`, `roughdielectric`, `diffuse`, `plastic` |
|
||||||
|
| Python dict API | Build scenes programmatically, no XML |
|
||||||
|
| CPU + GPU backends | `scalar_rgb` (CPU), `cuda_rgb` (NVIDIA) |
|
||||||
|
| Output formats | PNG, EXR (HDR) with tonemapping |
|
||||||
|
|
||||||
|
### OCC → OBJ Conversion Path
|
||||||
|
|
||||||
|
```python
|
||||||
|
from OCP.BRepMesh import BRepMesh_IncrementalMesh
|
||||||
|
from OCP.StlAPI import StlAPI_Writer
|
||||||
|
from OCP.BRep import BRep_Builder
|
||||||
|
import tempfile, os
|
||||||
|
|
||||||
|
def occ_shape_to_obj(shape, obj_path: str, linear_deflection: float = 0.1):
|
||||||
|
"""Tessellate OCC shape and write as OBJ for Mitsuba."""
|
||||||
|
tess = BRepMesh_IncrementalMesh(shape, linear_deflection, False, 0.5, True)
|
||||||
|
tess.Perform()
|
||||||
|
# Write STL (reliable), then convert to OBJ via trimesh or direct
|
||||||
|
writer = StlAPI_Writer()
|
||||||
|
writer.SetASCIIMode(False)
|
||||||
|
stl_path = obj_path.replace(".obj", ".stl")
|
||||||
|
writer.Write(shape, stl_path)
|
||||||
|
# Mitsuba can read STL directly, or we convert to OBJ
|
||||||
|
return stl_path
|
||||||
|
```
|
||||||
|
|
||||||
|
### Mitsuba Scene Construction
|
||||||
|
|
||||||
|
```python
|
||||||
|
import mitsuba as mi
|
||||||
|
mi.set_variant("scalar_rgb")
|
||||||
|
|
||||||
|
def build_scene(mesh_path: str, material: RenderMaterial,
|
||||||
|
camera: RenderCamera, settings: RenderSettings) -> mi.Scene:
|
||||||
|
# Map our material to Mitsuba BSDF
|
||||||
|
bsdf_map = {
|
||||||
|
"diffuse": {"type": "diffuse", "reflectance": {"type": "rgb", "value": material.color}},
|
||||||
|
"roughconductor": {
|
||||||
|
"type": "roughconductor",
|
||||||
|
"material": "copper", # or铝, 钢, etc.
|
||||||
|
"alpha": material.roughness,
|
||||||
|
},
|
||||||
|
"roughdielectric": {
|
||||||
|
"type": "roughdielectric",
|
||||||
|
"int_ior": 1.5,
|
||||||
|
"alpha": material.roughness,
|
||||||
|
},
|
||||||
|
"plastic": {
|
||||||
|
"type": "plastic",
|
||||||
|
"diffuse_reflectance": {"type": "rgb", "value": material.color},
|
||||||
|
"int_ior": 1.5,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return mi.load_dict({
|
||||||
|
"type": "scene",
|
||||||
|
"integrator": {"type": "path", "max_depth": settings.max_depth},
|
||||||
|
"sensor": {
|
||||||
|
"type": "perspective",
|
||||||
|
"fov": camera.fov,
|
||||||
|
"to_world": mi.ScalarTransform4f.look_at(
|
||||||
|
origin=camera.origin, target=camera.target, up=camera.up
|
||||||
|
),
|
||||||
|
"film": {"type": "hdrfilm", "width": settings.width, "height": settings.height},
|
||||||
|
"sampler": {"type": "independent", "sample_count": settings.spp},
|
||||||
|
},
|
||||||
|
"emitter": {"type": "constant"},
|
||||||
|
"shape": {
|
||||||
|
"type": "stl", # or "obj"
|
||||||
|
"filename": mesh_path,
|
||||||
|
"bsdf": bsdf_map.get(material.bsdf_type, bsdf_map["diffuse"]),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Files to Create/Modify
|
||||||
|
|
||||||
|
| File | Action | Description |
|
||||||
|
|------|--------|-------------|
|
||||||
|
| `src/fluency/rendering/render_backend.py` | **NEW** | Abstract `RenderBackend`, `RenderMaterial`, `RenderCamera`, `RenderSettings` |
|
||||||
|
| `src/fluency/rendering/mitsuba_backend.py` | **NEW** | `MitsubaBackend(RenderBackend)` implementation |
|
||||||
|
| `src/fluency/rendering/occ_to_mesh.py` | **NEW** | OCC `TopoDS_Shape` → STL/OBJ tessellation |
|
||||||
|
| `src/fluency/rendering/material_presets.py` | **NEW** | Preset library: Steel, Aluminum, Brass, Chrome, Plastic, Rubber, Wood |
|
||||||
|
| `src/fluency/ui/render_window.py` | **NEW** | `RenderWindow(QMainWindow)` — image preview, material/quality controls, render/export |
|
||||||
|
| `src/fluency/ui/main_window.py` | MODIFY | Add "Render" button → get selected shapes → open `RenderWindow` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## UI: RenderWindow
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────┐
|
||||||
|
│ Render — [Part Name] [─][□][×] │
|
||||||
|
├──────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ ┌──────────────────────────────────┐ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ Rendered Image Preview │ │
|
||||||
|
│ │ (QLabel with QPixmap) │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ └──────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ Material: [Steel ▾] │
|
||||||
|
│ Quality: [256 SPP ▾] │
|
||||||
|
│ Resolution: [1920×1080 ▾] │
|
||||||
|
│ │
|
||||||
|
│ [▶ Render] [⏹ Cancel] [💾 Export PNG] │
|
||||||
|
│ │
|
||||||
|
│ ████████████████░░░░░░ 65% (23s left) │
|
||||||
|
└──────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Preview**: progressive refinement (low SPP first, then ramp)
|
||||||
|
- **Cancel**: kill Mitsuba render thread
|
||||||
|
- **Export**: save to PNG/EXR
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Material Presets
|
||||||
|
|
||||||
|
| Preset | Color | Metallic | Roughness | BSDF |
|
||||||
|
|--------|-------|----------|-----------|------|
|
||||||
|
| Brushed Steel | (0.65, 0.67, 0.72) | 0.9 | 0.35 | roughconductor |
|
||||||
|
| Polished Chrome | (0.8, 0.8, 0.8) | 1.0 | 0.05 | roughconductor |
|
||||||
|
| Brushed Aluminum | (0.75, 0.75, 0.75) | 0.85 | 0.25 | roughconductor |
|
||||||
|
| Copper | (0.95, 0.64, 0.54) | 0.95 | 0.15 | roughconductor |
|
||||||
|
| Gold | (1.0, 0.76, 0.33) | 1.0 | 0.1 | roughconductor |
|
||||||
|
| Blackened Steel | (0.15, 0.15, 0.17) | 0.8 | 0.4 | roughconductor |
|
||||||
|
| Matte Plastic | (0.2, 0.5, 0.8) | 0.0 | 0.6 | plastic |
|
||||||
|
| Glossy Plastic | (0.2, 0.5, 0.8) | 0.0 | 0.1 | plastic |
|
||||||
|
| White Nylon | (0.85, 0.85, 0.83) | 0.0 | 0.45 | plastic |
|
||||||
|
| Black ABS | (0.05, 0.05, 0.05) | 0.0 | 0.35 | plastic |
|
||||||
|
| Red PA12 | (0.75, 0.08, 0.08) | 0.0 | 0.4 | plastic |
|
||||||
|
| Rubber | (0.1, 0.1, 0.1) | 0.0 | 0.9 | diffuse |
|
||||||
|
| Ceramic White | (0.92, 0.91, 0.88) | 0.0 | 0.15 | dielectric |
|
||||||
|
| Glass | (0.95, 0.95, 0.95) | 0.0 | 0.0 | dielectric |
|
||||||
|
| Wood | (0.6, 0.4, 0.2) | 0.0 | 0.7 | diffuse |
|
||||||
|
|
||||||
|
**Note:** Mitsuba pip installs don't include spectral metal data files (iron.spd, copper.spd, etc.), so metal presets use `material="none"` with `specular_reflectance` set to the metal color instead.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Risks & Mitigations
|
||||||
|
|
||||||
|
| Risk | Mitigation |
|
||||||
|
|------|-----------|
|
||||||
|
| Mitsuba not installed | Graceful error: "pip install mitsuba" shown in UI |
|
||||||
|
| Slow CPU rendering | Default to low SPP (64) for preview; offer GPU variant if CUDA available |
|
||||||
|
| Large meshes slow to tessellate | Progress indicator; optional mesh decimation |
|
||||||
|
| Mitsuba STL/OCC compatibility | Test tessellation quality; tune `linear_deflection` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Estimated Effort
|
||||||
|
|
||||||
|
- **Phase 1** (abstract backend + OCC→mesh + Mitsuba impl): ~4-6 hours
|
||||||
|
- **Phase 2** (render window UI + material presets): ~3-4 hours
|
||||||
|
- **Phase 3** (polish, export, swap test): ~2-3 hours
|
||||||
|
- **Total**: ~9-13 hours
|
||||||
@@ -0,0 +1,906 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
################################################################################
|
||||||
|
## Form generated from reading UI file 'gui.ui'
|
||||||
|
##
|
||||||
|
## Created by: Qt User Interface Compiler version 6.10.2
|
||||||
|
##
|
||||||
|
## WARNING! All changes made in this file will be lost when recompiling UI file!
|
||||||
|
################################################################################
|
||||||
|
|
||||||
|
from PySide6.QtCore import (QCoreApplication, QDate, QDateTime, QLocale,
|
||||||
|
QMetaObject, QObject, QPoint, QRect,
|
||||||
|
QSize, QTime, QUrl, Qt)
|
||||||
|
from PySide6.QtGui import (QAction, QBrush, QColor, QConicalGradient,
|
||||||
|
QCursor, QFont, QFontDatabase, QGradient,
|
||||||
|
QIcon, QImage, QKeySequence, QLinearGradient,
|
||||||
|
QPainter, QPalette, QPixmap, QRadialGradient,
|
||||||
|
QTransform)
|
||||||
|
from PySide6.QtWidgets import (QApplication, QFrame, QGridLayout, QGroupBox,
|
||||||
|
QHBoxLayout, QLabel, QListWidget, QListWidgetItem,
|
||||||
|
QMainWindow, QMenu, QMenuBar, QPushButton,
|
||||||
|
QSizePolicy, QSpinBox, QStatusBar, QTabWidget,
|
||||||
|
QTextEdit, QVBoxLayout, QWidget)
|
||||||
|
|
||||||
|
class Ui_fluencyCAD(object):
|
||||||
|
def setupUi(self, fluencyCAD):
|
||||||
|
if not fluencyCAD.objectName():
|
||||||
|
fluencyCAD.setObjectName(u"fluencyCAD")
|
||||||
|
fluencyCAD.resize(2359, 1285)
|
||||||
|
self.actionNew_Project = QAction(fluencyCAD)
|
||||||
|
self.actionNew_Project.setObjectName(u"actionNew_Project")
|
||||||
|
self.actionOpen_Project = QAction(fluencyCAD)
|
||||||
|
self.actionOpen_Project.setObjectName(u"actionOpen_Project")
|
||||||
|
self.actionSave_Project = QAction(fluencyCAD)
|
||||||
|
self.actionSave_Project.setObjectName(u"actionSave_Project")
|
||||||
|
self.actionSave_Project_As = QAction(fluencyCAD)
|
||||||
|
self.actionSave_Project_As.setObjectName(u"actionSave_Project_As")
|
||||||
|
self.actionImport_File = QAction(fluencyCAD)
|
||||||
|
self.actionImport_File.setObjectName(u"actionImport_File")
|
||||||
|
self.actionExport_Step = QAction(fluencyCAD)
|
||||||
|
self.actionExport_Step.setObjectName(u"actionExport_Step")
|
||||||
|
self.actionExport_Iges = QAction(fluencyCAD)
|
||||||
|
self.actionExport_Iges.setObjectName(u"actionExport_Iges")
|
||||||
|
self.actionExport_Stl = QAction(fluencyCAD)
|
||||||
|
self.actionExport_Stl.setObjectName(u"actionExport_Stl")
|
||||||
|
self.actionExit = QAction(fluencyCAD)
|
||||||
|
self.actionExit.setObjectName(u"actionExit")
|
||||||
|
self.centralwidget = QWidget(fluencyCAD)
|
||||||
|
self.centralwidget.setObjectName(u"centralwidget")
|
||||||
|
self.gridLayout = QGridLayout(self.centralwidget)
|
||||||
|
self.gridLayout.setObjectName(u"gridLayout")
|
||||||
|
self.InputTab = QTabWidget(self.centralwidget)
|
||||||
|
self.InputTab.setObjectName(u"InputTab")
|
||||||
|
sizePolicy = QSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
|
||||||
|
sizePolicy.setHorizontalStretch(0)
|
||||||
|
sizePolicy.setVerticalStretch(0)
|
||||||
|
sizePolicy.setHeightForWidth(self.InputTab.sizePolicy().hasHeightForWidth())
|
||||||
|
self.InputTab.setSizePolicy(sizePolicy)
|
||||||
|
self.sketch_tab = QWidget()
|
||||||
|
self.sketch_tab.setObjectName(u"sketch_tab")
|
||||||
|
self.verticalLayout_4 = QVBoxLayout(self.sketch_tab)
|
||||||
|
self.verticalLayout_4.setObjectName(u"verticalLayout_4")
|
||||||
|
self.InputTab.addTab(self.sketch_tab, "")
|
||||||
|
self.code_tab = QWidget()
|
||||||
|
self.code_tab.setObjectName(u"code_tab")
|
||||||
|
self.verticalLayout = QVBoxLayout(self.code_tab)
|
||||||
|
self.verticalLayout.setObjectName(u"verticalLayout")
|
||||||
|
self.textEdit = QTextEdit(self.code_tab)
|
||||||
|
self.textEdit.setObjectName(u"textEdit")
|
||||||
|
|
||||||
|
self.verticalLayout.addWidget(self.textEdit)
|
||||||
|
|
||||||
|
self.groupBox_7 = QGroupBox(self.code_tab)
|
||||||
|
self.groupBox_7.setObjectName(u"groupBox_7")
|
||||||
|
self.gridLayout_5 = QGridLayout(self.groupBox_7)
|
||||||
|
self.gridLayout_5.setObjectName(u"gridLayout_5")
|
||||||
|
self.pushButton_5 = QPushButton(self.groupBox_7)
|
||||||
|
self.pushButton_5.setObjectName(u"pushButton_5")
|
||||||
|
|
||||||
|
self.gridLayout_5.addWidget(self.pushButton_5, 2, 0, 1, 1)
|
||||||
|
|
||||||
|
self.pushButton_4 = QPushButton(self.groupBox_7)
|
||||||
|
self.pushButton_4.setObjectName(u"pushButton_4")
|
||||||
|
|
||||||
|
self.gridLayout_5.addWidget(self.pushButton_4, 2, 1, 1, 1)
|
||||||
|
|
||||||
|
self.pb_apply_code = QPushButton(self.groupBox_7)
|
||||||
|
self.pb_apply_code.setObjectName(u"pb_apply_code")
|
||||||
|
|
||||||
|
self.gridLayout_5.addWidget(self.pb_apply_code, 1, 0, 1, 1)
|
||||||
|
|
||||||
|
self.pushButton = QPushButton(self.groupBox_7)
|
||||||
|
self.pushButton.setObjectName(u"pushButton")
|
||||||
|
|
||||||
|
self.gridLayout_5.addWidget(self.pushButton, 1, 1, 1, 1)
|
||||||
|
|
||||||
|
|
||||||
|
self.verticalLayout.addWidget(self.groupBox_7)
|
||||||
|
|
||||||
|
self.InputTab.addTab(self.code_tab, "")
|
||||||
|
|
||||||
|
self.gridLayout.addWidget(self.InputTab, 0, 1, 12, 1)
|
||||||
|
|
||||||
|
self.groupBox_9 = QGroupBox(self.centralwidget)
|
||||||
|
self.groupBox_9.setObjectName(u"groupBox_9")
|
||||||
|
self.groupBox_9.setMaximumSize(QSize(200, 16777215))
|
||||||
|
self.gridLayout_7 = QGridLayout(self.groupBox_9)
|
||||||
|
self.gridLayout_7.setObjectName(u"gridLayout_7")
|
||||||
|
self.pb_origin_wp = QPushButton(self.groupBox_9)
|
||||||
|
self.pb_origin_wp.setObjectName(u"pb_origin_wp")
|
||||||
|
|
||||||
|
self.gridLayout_7.addWidget(self.pb_origin_wp, 0, 0, 1, 1)
|
||||||
|
|
||||||
|
self.pb_origin_face = QPushButton(self.groupBox_9)
|
||||||
|
self.pb_origin_face.setObjectName(u"pb_origin_face")
|
||||||
|
self.pb_origin_face.setCheckable(True)
|
||||||
|
|
||||||
|
self.gridLayout_7.addWidget(self.pb_origin_face, 0, 1, 1, 1)
|
||||||
|
|
||||||
|
self.pb_flip_face = QPushButton(self.groupBox_9)
|
||||||
|
self.pb_flip_face.setObjectName(u"pb_flip_face")
|
||||||
|
|
||||||
|
self.gridLayout_7.addWidget(self.pb_flip_face, 1, 0, 1, 1)
|
||||||
|
|
||||||
|
self.pb_underlay = QPushButton(self.groupBox_9)
|
||||||
|
self.pb_underlay.setObjectName(u"pb_underlay")
|
||||||
|
self.pb_underlay.setEnabled(False)
|
||||||
|
self.pb_underlay.setCheckable(True)
|
||||||
|
self.pb_underlay.setChecked(True)
|
||||||
|
|
||||||
|
self.gridLayout_7.addWidget(self.pb_underlay, 3, 0, 1, 1)
|
||||||
|
|
||||||
|
self.pb_clr_face = QPushButton(self.groupBox_9)
|
||||||
|
self.pb_clr_face.setObjectName(u"pb_clr_face")
|
||||||
|
self.pb_clr_face.setEnabled(False)
|
||||||
|
|
||||||
|
self.gridLayout_7.addWidget(self.pb_clr_face, 3, 1, 1, 1)
|
||||||
|
|
||||||
|
self.pb_to_sketch = QPushButton(self.groupBox_9)
|
||||||
|
self.pb_to_sketch.setObjectName(u"pb_to_sketch")
|
||||||
|
self.pb_to_sketch.setEnabled(False)
|
||||||
|
|
||||||
|
self.gridLayout_7.addWidget(self.pb_to_sketch, 4, 0, 1, 2)
|
||||||
|
|
||||||
|
self.pb_wp_new = QPushButton(self.groupBox_9)
|
||||||
|
self.pb_wp_new.setObjectName(u"pb_wp_new")
|
||||||
|
|
||||||
|
self.gridLayout_7.addWidget(self.pb_wp_new, 1, 1, 1, 1)
|
||||||
|
|
||||||
|
|
||||||
|
self.gridLayout.addWidget(self.groupBox_9, 0, 0, 1, 1)
|
||||||
|
|
||||||
|
self.assembly_box = QGroupBox(self.centralwidget)
|
||||||
|
self.assembly_box.setObjectName(u"assembly_box")
|
||||||
|
self.assembly_box.setMinimumSize(QSize(0, 50))
|
||||||
|
|
||||||
|
self.gridLayout.addWidget(self.assembly_box, 13, 1, 1, 2)
|
||||||
|
|
||||||
|
self.joint_tools = QGroupBox(self.centralwidget)
|
||||||
|
self.joint_tools.setObjectName(u"joint_tools")
|
||||||
|
self.joint_tools.setMinimumSize(QSize(0, 50))
|
||||||
|
self.gridLayout_10 = QGridLayout(self.joint_tools)
|
||||||
|
self.gridLayout_10.setObjectName(u"gridLayout_10")
|
||||||
|
self.pb_remove_connector = QPushButton(self.joint_tools)
|
||||||
|
self.pb_remove_connector.setObjectName(u"pb_remove_connector")
|
||||||
|
self.pb_remove_connector.setMinimumSize(QSize(50, 50))
|
||||||
|
self.pb_remove_connector.setMaximumSize(QSize(50, 50))
|
||||||
|
|
||||||
|
self.gridLayout_10.addWidget(self.pb_remove_connector, 0, 2, 1, 1)
|
||||||
|
|
||||||
|
self.pb_add_connector = QPushButton(self.joint_tools)
|
||||||
|
self.pb_add_connector.setObjectName(u"pb_add_connector")
|
||||||
|
self.pb_add_connector.setMinimumSize(QSize(50, 50))
|
||||||
|
self.pb_add_connector.setMaximumSize(QSize(50, 50))
|
||||||
|
|
||||||
|
self.gridLayout_10.addWidget(self.pb_add_connector, 0, 1, 1, 1)
|
||||||
|
|
||||||
|
self.pb_add_connector_2 = QPushButton(self.joint_tools)
|
||||||
|
self.pb_add_connector_2.setObjectName(u"pb_add_connector_2")
|
||||||
|
self.pb_add_connector_2.setMinimumSize(QSize(50, 50))
|
||||||
|
self.pb_add_connector_2.setMaximumSize(QSize(50, 50))
|
||||||
|
|
||||||
|
self.gridLayout_10.addWidget(self.pb_add_connector_2, 1, 1, 1, 1)
|
||||||
|
|
||||||
|
self.pb_add_connector_3 = QPushButton(self.joint_tools)
|
||||||
|
self.pb_add_connector_3.setObjectName(u"pb_add_connector_3")
|
||||||
|
self.pb_add_connector_3.setMinimumSize(QSize(50, 50))
|
||||||
|
self.pb_add_connector_3.setMaximumSize(QSize(50, 50))
|
||||||
|
|
||||||
|
self.gridLayout_10.addWidget(self.pb_add_connector_3, 1, 2, 1, 1)
|
||||||
|
|
||||||
|
|
||||||
|
self.gridLayout.addWidget(self.joint_tools, 12, 3, 2, 1)
|
||||||
|
|
||||||
|
self.gl_box = QGroupBox(self.centralwidget)
|
||||||
|
self.gl_box.setObjectName(u"gl_box")
|
||||||
|
sizePolicy1 = QSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
|
||||||
|
sizePolicy1.setHorizontalStretch(0)
|
||||||
|
sizePolicy1.setVerticalStretch(4)
|
||||||
|
sizePolicy1.setHeightForWidth(self.gl_box.sizePolicy().hasHeightForWidth())
|
||||||
|
self.gl_box.setSizePolicy(sizePolicy1)
|
||||||
|
font = QFont()
|
||||||
|
font.setPointSize(12)
|
||||||
|
self.gl_box.setFont(font)
|
||||||
|
self.horizontalLayout_4 = QHBoxLayout(self.gl_box)
|
||||||
|
#ifndef Q_OS_MAC
|
||||||
|
self.horizontalLayout_4.setSpacing(-1)
|
||||||
|
#endif
|
||||||
|
self.horizontalLayout_4.setObjectName(u"horizontalLayout_4")
|
||||||
|
self.horizontalLayout_4.setContentsMargins(12, -1, -1, -1)
|
||||||
|
|
||||||
|
self.gridLayout.addWidget(self.gl_box, 0, 2, 12, 1)
|
||||||
|
|
||||||
|
self.groupBox_11 = QGroupBox(self.centralwidget)
|
||||||
|
self.groupBox_11.setObjectName(u"groupBox_11")
|
||||||
|
sizePolicy2 = QSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Expanding)
|
||||||
|
sizePolicy2.setHorizontalStretch(0)
|
||||||
|
sizePolicy2.setVerticalStretch(0)
|
||||||
|
sizePolicy2.setHeightForWidth(self.groupBox_11.sizePolicy().hasHeightForWidth())
|
||||||
|
self.groupBox_11.setSizePolicy(sizePolicy2)
|
||||||
|
self.groupBox_11.setMaximumSize(QSize(200, 16777215))
|
||||||
|
self.verticalLayout_7 = QVBoxLayout(self.groupBox_11)
|
||||||
|
self.verticalLayout_7.setObjectName(u"verticalLayout_7")
|
||||||
|
self.verticalLayout_7.setContentsMargins(5, 5, 5, 5)
|
||||||
|
self.sketch_list = QListWidget(self.groupBox_11)
|
||||||
|
self.sketch_list.setObjectName(u"sketch_list")
|
||||||
|
sizePolicy3 = QSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
|
||||||
|
sizePolicy3.setHorizontalStretch(0)
|
||||||
|
sizePolicy3.setVerticalStretch(0)
|
||||||
|
sizePolicy3.setHeightForWidth(self.sketch_list.sizePolicy().hasHeightForWidth())
|
||||||
|
self.sketch_list.setSizePolicy(sizePolicy3)
|
||||||
|
self.sketch_list.setSelectionRectVisible(True)
|
||||||
|
|
||||||
|
self.verticalLayout_7.addWidget(self.sketch_list)
|
||||||
|
|
||||||
|
self.groupBox_6 = QGroupBox(self.groupBox_11)
|
||||||
|
self.groupBox_6.setObjectName(u"groupBox_6")
|
||||||
|
sizePolicy4 = QSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Preferred)
|
||||||
|
sizePolicy4.setHorizontalStretch(0)
|
||||||
|
sizePolicy4.setVerticalStretch(0)
|
||||||
|
sizePolicy4.setHeightForWidth(self.groupBox_6.sizePolicy().hasHeightForWidth())
|
||||||
|
self.groupBox_6.setSizePolicy(sizePolicy4)
|
||||||
|
self.gridLayout_6 = QGridLayout(self.groupBox_6)
|
||||||
|
self.gridLayout_6.setObjectName(u"gridLayout_6")
|
||||||
|
self.gridLayout_6.setContentsMargins(2, 2, 2, 2)
|
||||||
|
self.pb_edt_sktch = QPushButton(self.groupBox_6)
|
||||||
|
self.pb_edt_sktch.setObjectName(u"pb_edt_sktch")
|
||||||
|
|
||||||
|
self.gridLayout_6.addWidget(self.pb_edt_sktch, 1, 1, 1, 1)
|
||||||
|
|
||||||
|
self.pb_nw_sktch = QPushButton(self.groupBox_6)
|
||||||
|
self.pb_nw_sktch.setObjectName(u"pb_nw_sktch")
|
||||||
|
|
||||||
|
self.gridLayout_6.addWidget(self.pb_nw_sktch, 1, 0, 1, 1)
|
||||||
|
|
||||||
|
self.pb_del_sketch = QPushButton(self.groupBox_6)
|
||||||
|
self.pb_del_sketch.setObjectName(u"pb_del_sketch")
|
||||||
|
|
||||||
|
self.gridLayout_6.addWidget(self.pb_del_sketch, 1, 2, 1, 1)
|
||||||
|
|
||||||
|
|
||||||
|
self.verticalLayout_7.addWidget(self.groupBox_6)
|
||||||
|
|
||||||
|
|
||||||
|
self.gridLayout.addWidget(self.groupBox_11, 6, 0, 6, 1)
|
||||||
|
|
||||||
|
self.assembly_tools = QGroupBox(self.centralwidget)
|
||||||
|
self.assembly_tools.setObjectName(u"assembly_tools")
|
||||||
|
self.assembly_tools.setMinimumSize(QSize(0, 50))
|
||||||
|
self.gridLayout_12 = QGridLayout(self.assembly_tools)
|
||||||
|
self.gridLayout_12.setObjectName(u"gridLayout_12")
|
||||||
|
self.pb_compo_to_assembly = QPushButton(self.assembly_tools)
|
||||||
|
self.pb_compo_to_assembly.setObjectName(u"pb_compo_to_assembly")
|
||||||
|
self.pb_compo_to_assembly.setMinimumSize(QSize(50, 50))
|
||||||
|
self.pb_compo_to_assembly.setMaximumSize(QSize(50, 50))
|
||||||
|
|
||||||
|
self.gridLayout_12.addWidget(self.pb_compo_to_assembly, 0, 0, 1, 1)
|
||||||
|
|
||||||
|
self.pb_remove_compo_from_assembly = QPushButton(self.assembly_tools)
|
||||||
|
self.pb_remove_compo_from_assembly.setObjectName(u"pb_remove_compo_from_assembly")
|
||||||
|
self.pb_remove_compo_from_assembly.setEnabled(True)
|
||||||
|
sizePolicy4.setHeightForWidth(self.pb_remove_compo_from_assembly.sizePolicy().hasHeightForWidth())
|
||||||
|
self.pb_remove_compo_from_assembly.setSizePolicy(sizePolicy4)
|
||||||
|
self.pb_remove_compo_from_assembly.setMinimumSize(QSize(50, 50))
|
||||||
|
self.pb_remove_compo_from_assembly.setMaximumSize(QSize(50, 50))
|
||||||
|
self.pb_remove_compo_from_assembly.setLayoutDirection(Qt.LeftToRight)
|
||||||
|
|
||||||
|
self.gridLayout_12.addWidget(self.pb_remove_compo_from_assembly, 0, 1, 1, 1)
|
||||||
|
|
||||||
|
|
||||||
|
self.gridLayout.addWidget(self.assembly_tools, 13, 0, 1, 1)
|
||||||
|
|
||||||
|
self.compo_tool_box = QGroupBox(self.centralwidget)
|
||||||
|
self.compo_tool_box.setObjectName(u"compo_tool_box")
|
||||||
|
self.compo_tool_box.setMinimumSize(QSize(0, 50))
|
||||||
|
self.gridLayout_9 = QGridLayout(self.compo_tool_box)
|
||||||
|
self.gridLayout_9.setObjectName(u"gridLayout_9")
|
||||||
|
self.pb_new_compo = QPushButton(self.compo_tool_box)
|
||||||
|
self.pb_new_compo.setObjectName(u"pb_new_compo")
|
||||||
|
self.pb_new_compo.setMinimumSize(QSize(50, 50))
|
||||||
|
self.pb_new_compo.setMaximumSize(QSize(50, 50))
|
||||||
|
|
||||||
|
self.gridLayout_9.addWidget(self.pb_new_compo, 0, 0, 1, 1)
|
||||||
|
|
||||||
|
self.pb_del_compo = QPushButton(self.compo_tool_box)
|
||||||
|
self.pb_del_compo.setObjectName(u"pb_del_compo")
|
||||||
|
self.pb_del_compo.setEnabled(True)
|
||||||
|
sizePolicy4.setHeightForWidth(self.pb_del_compo.sizePolicy().hasHeightForWidth())
|
||||||
|
self.pb_del_compo.setSizePolicy(sizePolicy4)
|
||||||
|
self.pb_del_compo.setMinimumSize(QSize(50, 50))
|
||||||
|
self.pb_del_compo.setMaximumSize(QSize(50, 50))
|
||||||
|
self.pb_del_compo.setLayoutDirection(Qt.LeftToRight)
|
||||||
|
|
||||||
|
self.gridLayout_9.addWidget(self.pb_del_compo, 0, 1, 1, 1)
|
||||||
|
|
||||||
|
|
||||||
|
self.gridLayout.addWidget(self.compo_tool_box, 12, 0, 1, 1)
|
||||||
|
|
||||||
|
self.compo_box = QGroupBox(self.centralwidget)
|
||||||
|
self.compo_box.setObjectName(u"compo_box")
|
||||||
|
self.compo_box.setMinimumSize(QSize(0, 50))
|
||||||
|
|
||||||
|
self.gridLayout.addWidget(self.compo_box, 12, 1, 1, 2)
|
||||||
|
|
||||||
|
self.groupBox_12 = QGroupBox(self.centralwidget)
|
||||||
|
self.groupBox_12.setObjectName(u"groupBox_12")
|
||||||
|
sizePolicy2.setHeightForWidth(self.groupBox_12.sizePolicy().hasHeightForWidth())
|
||||||
|
self.groupBox_12.setSizePolicy(sizePolicy2)
|
||||||
|
self.groupBox_12.setMaximumSize(QSize(200, 16777215))
|
||||||
|
self.verticalLayout_8 = QVBoxLayout(self.groupBox_12)
|
||||||
|
self.verticalLayout_8.setObjectName(u"verticalLayout_8")
|
||||||
|
self.verticalLayout_8.setContentsMargins(5, 5, 5, 5)
|
||||||
|
self.connection_list = QListWidget(self.groupBox_12)
|
||||||
|
self.connection_list.setObjectName(u"connection_list")
|
||||||
|
self.connection_list.setSelectionRectVisible(True)
|
||||||
|
|
||||||
|
self.verticalLayout_8.addWidget(self.connection_list)
|
||||||
|
|
||||||
|
self.groupBox_13 = QGroupBox(self.groupBox_12)
|
||||||
|
self.groupBox_13.setObjectName(u"groupBox_13")
|
||||||
|
sizePolicy4.setHeightForWidth(self.groupBox_13.sizePolicy().hasHeightForWidth())
|
||||||
|
self.groupBox_13.setSizePolicy(sizePolicy4)
|
||||||
|
self.groupBox_13.setMaximumSize(QSize(200, 16777215))
|
||||||
|
self.gridLayout_13 = QGridLayout(self.groupBox_13)
|
||||||
|
self.gridLayout_13.setObjectName(u"gridLayout_13")
|
||||||
|
self.gridLayout_13.setContentsMargins(2, 2, 2, 2)
|
||||||
|
self.pb_del_connection = QPushButton(self.groupBox_13)
|
||||||
|
self.pb_del_connection.setObjectName(u"pb_del_connection")
|
||||||
|
|
||||||
|
self.gridLayout_13.addWidget(self.pb_del_connection, 0, 2, 1, 1)
|
||||||
|
|
||||||
|
self.pb_update_connection = QPushButton(self.groupBox_13)
|
||||||
|
self.pb_update_connection.setObjectName(u"pb_update_connection")
|
||||||
|
|
||||||
|
self.gridLayout_13.addWidget(self.pb_update_connection, 0, 0, 1, 1)
|
||||||
|
|
||||||
|
self.pb_edt_sktch_4 = QPushButton(self.groupBox_13)
|
||||||
|
self.pb_edt_sktch_4.setObjectName(u"pb_edt_sktch_4")
|
||||||
|
|
||||||
|
self.gridLayout_13.addWidget(self.pb_edt_sktch_4, 0, 1, 1, 1)
|
||||||
|
|
||||||
|
|
||||||
|
self.verticalLayout_8.addWidget(self.groupBox_13)
|
||||||
|
|
||||||
|
|
||||||
|
self.gridLayout.addWidget(self.groupBox_12, 6, 3, 6, 1)
|
||||||
|
|
||||||
|
self.groupBox_10 = QGroupBox(self.centralwidget)
|
||||||
|
self.groupBox_10.setObjectName(u"groupBox_10")
|
||||||
|
sizePolicy2.setHeightForWidth(self.groupBox_10.sizePolicy().hasHeightForWidth())
|
||||||
|
self.groupBox_10.setSizePolicy(sizePolicy2)
|
||||||
|
self.groupBox_10.setMaximumSize(QSize(200, 16777215))
|
||||||
|
self.verticalLayout_6 = QVBoxLayout(self.groupBox_10)
|
||||||
|
self.verticalLayout_6.setObjectName(u"verticalLayout_6")
|
||||||
|
self.verticalLayout_6.setContentsMargins(5, 5, 5, 5)
|
||||||
|
self.body_list = QListWidget(self.groupBox_10)
|
||||||
|
self.body_list.setObjectName(u"body_list")
|
||||||
|
self.body_list.setSelectionRectVisible(True)
|
||||||
|
|
||||||
|
self.verticalLayout_6.addWidget(self.body_list)
|
||||||
|
|
||||||
|
self.groupBox_8 = QGroupBox(self.groupBox_10)
|
||||||
|
self.groupBox_8.setObjectName(u"groupBox_8")
|
||||||
|
sizePolicy4.setHeightForWidth(self.groupBox_8.sizePolicy().hasHeightForWidth())
|
||||||
|
self.groupBox_8.setSizePolicy(sizePolicy4)
|
||||||
|
self.groupBox_8.setMaximumSize(QSize(200, 16777215))
|
||||||
|
self.gridLayout_8 = QGridLayout(self.groupBox_8)
|
||||||
|
self.gridLayout_8.setObjectName(u"gridLayout_8")
|
||||||
|
self.gridLayout_8.setContentsMargins(2, 2, 2, 2)
|
||||||
|
self.pb_del_body = QPushButton(self.groupBox_8)
|
||||||
|
self.pb_del_body.setObjectName(u"pb_del_body")
|
||||||
|
|
||||||
|
self.gridLayout_8.addWidget(self.pb_del_body, 0, 2, 1, 1)
|
||||||
|
|
||||||
|
self.pb_update_body = QPushButton(self.groupBox_8)
|
||||||
|
self.pb_update_body.setObjectName(u"pb_update_body")
|
||||||
|
|
||||||
|
self.gridLayout_8.addWidget(self.pb_update_body, 0, 0, 1, 1)
|
||||||
|
|
||||||
|
self.pb_edt_sktch_3 = QPushButton(self.groupBox_8)
|
||||||
|
self.pb_edt_sktch_3.setObjectName(u"pb_edt_sktch_3")
|
||||||
|
|
||||||
|
self.gridLayout_8.addWidget(self.pb_edt_sktch_3, 0, 1, 1, 1)
|
||||||
|
|
||||||
|
|
||||||
|
self.verticalLayout_6.addWidget(self.groupBox_8)
|
||||||
|
|
||||||
|
|
||||||
|
self.gridLayout.addWidget(self.groupBox_10, 3, 3, 3, 1)
|
||||||
|
|
||||||
|
self.groupBox_2 = QGroupBox(self.centralwidget)
|
||||||
|
self.groupBox_2.setObjectName(u"groupBox_2")
|
||||||
|
sizePolicy4.setHeightForWidth(self.groupBox_2.sizePolicy().hasHeightForWidth())
|
||||||
|
self.groupBox_2.setSizePolicy(sizePolicy4)
|
||||||
|
self.groupBox_2.setMaximumSize(QSize(200, 16777215))
|
||||||
|
self.gridLayout_2 = QGridLayout(self.groupBox_2)
|
||||||
|
self.gridLayout_2.setObjectName(u"gridLayout_2")
|
||||||
|
self.gridLayout_2.setContentsMargins(10, -1, -1, -1)
|
||||||
|
self.pb_arc_tool = QPushButton(self.groupBox_2)
|
||||||
|
self.pb_arc_tool.setObjectName(u"pb_arc_tool")
|
||||||
|
self.pb_arc_tool.setCheckable(True)
|
||||||
|
|
||||||
|
self.gridLayout_2.addWidget(self.pb_arc_tool, 2, 0, 1, 1)
|
||||||
|
|
||||||
|
self.pb_rectool = QPushButton(self.groupBox_2)
|
||||||
|
self.pb_rectool.setObjectName(u"pb_rectool")
|
||||||
|
self.pb_rectool.setCheckable(True)
|
||||||
|
self.pb_rectool.setAutoExclusive(False)
|
||||||
|
|
||||||
|
self.gridLayout_2.addWidget(self.pb_rectool, 0, 1, 1, 1)
|
||||||
|
|
||||||
|
self.pb_circtool = QPushButton(self.groupBox_2)
|
||||||
|
self.pb_circtool.setObjectName(u"pb_circtool")
|
||||||
|
self.pb_circtool.setCheckable(True)
|
||||||
|
self.pb_circtool.setAutoExclusive(False)
|
||||||
|
|
||||||
|
self.gridLayout_2.addWidget(self.pb_circtool, 1, 0, 1, 1, Qt.AlignTop)
|
||||||
|
|
||||||
|
self.pb_enable_construct = QPushButton(self.groupBox_2)
|
||||||
|
self.pb_enable_construct.setObjectName(u"pb_enable_construct")
|
||||||
|
self.pb_enable_construct.setCheckable(True)
|
||||||
|
|
||||||
|
self.gridLayout_2.addWidget(self.pb_enable_construct, 4, 0, 1, 1)
|
||||||
|
|
||||||
|
self.pb_enable_snap = QPushButton(self.groupBox_2)
|
||||||
|
self.pb_enable_snap.setObjectName(u"pb_enable_snap")
|
||||||
|
self.pb_enable_snap.setIconSize(QSize(13, 16))
|
||||||
|
self.pb_enable_snap.setCheckable(True)
|
||||||
|
self.pb_enable_snap.setChecked(True)
|
||||||
|
|
||||||
|
self.gridLayout_2.addWidget(self.pb_enable_snap, 4, 1, 1, 1)
|
||||||
|
|
||||||
|
self.pb_linetool = QPushButton(self.groupBox_2)
|
||||||
|
self.pb_linetool.setObjectName(u"pb_linetool")
|
||||||
|
self.pb_linetool.setCheckable(True)
|
||||||
|
self.pb_linetool.setAutoExclusive(False)
|
||||||
|
|
||||||
|
self.gridLayout_2.addWidget(self.pb_linetool, 0, 0, 1, 1)
|
||||||
|
|
||||||
|
self.pb_slotool = QPushButton(self.groupBox_2)
|
||||||
|
self.pb_slotool.setObjectName(u"pb_slotool")
|
||||||
|
self.pb_slotool.setCheckable(True)
|
||||||
|
self.pb_slotool.setAutoExclusive(False)
|
||||||
|
|
||||||
|
self.gridLayout_2.addWidget(self.pb_slotool, 1, 1, 1, 1, Qt.AlignTop)
|
||||||
|
|
||||||
|
self.line = QFrame(self.groupBox_2)
|
||||||
|
self.line.setObjectName(u"line")
|
||||||
|
self.line.setFrameShape(QFrame.Shape.HLine)
|
||||||
|
self.line.setFrameShadow(QFrame.Shadow.Sunken)
|
||||||
|
|
||||||
|
self.gridLayout_2.addWidget(self.line, 3, 0, 1, 2)
|
||||||
|
|
||||||
|
self.pb_offset_tool = QPushButton(self.groupBox_2)
|
||||||
|
self.pb_offset_tool.setObjectName(u"pb_offset_tool")
|
||||||
|
|
||||||
|
self.gridLayout_2.addWidget(self.pb_offset_tool, 2, 1, 1, 1)
|
||||||
|
|
||||||
|
|
||||||
|
self.gridLayout.addWidget(self.groupBox_2, 1, 0, 1, 1)
|
||||||
|
|
||||||
|
self.groupBox_3 = QGroupBox(self.centralwidget)
|
||||||
|
self.groupBox_3.setObjectName(u"groupBox_3")
|
||||||
|
sizePolicy4.setHeightForWidth(self.groupBox_3.sizePolicy().hasHeightForWidth())
|
||||||
|
self.groupBox_3.setSizePolicy(sizePolicy4)
|
||||||
|
self.groupBox_3.setMaximumSize(QSize(200, 16777213))
|
||||||
|
self.gridLayout_4 = QGridLayout(self.groupBox_3)
|
||||||
|
self.gridLayout_4.setObjectName(u"gridLayout_4")
|
||||||
|
self.pb_con_ptpt = QPushButton(self.groupBox_3)
|
||||||
|
self.pb_con_ptpt.setObjectName(u"pb_con_ptpt")
|
||||||
|
self.pb_con_ptpt.setCheckable(True)
|
||||||
|
self.pb_con_ptpt.setAutoExclusive(False)
|
||||||
|
|
||||||
|
self.gridLayout_4.addWidget(self.pb_con_ptpt, 1, 0, 1, 1)
|
||||||
|
|
||||||
|
self.pb_con_vert = QPushButton(self.groupBox_3)
|
||||||
|
self.pb_con_vert.setObjectName(u"pb_con_vert")
|
||||||
|
self.pb_con_vert.setCheckable(True)
|
||||||
|
self.pb_con_vert.setAutoExclusive(False)
|
||||||
|
|
||||||
|
self.gridLayout_4.addWidget(self.pb_con_vert, 3, 1, 1, 1)
|
||||||
|
|
||||||
|
self.pb_con_sym = QPushButton(self.groupBox_3)
|
||||||
|
self.pb_con_sym.setObjectName(u"pb_con_sym")
|
||||||
|
self.pb_con_sym.setCheckable(True)
|
||||||
|
self.pb_con_sym.setAutoExclusive(False)
|
||||||
|
|
||||||
|
self.gridLayout_4.addWidget(self.pb_con_sym, 4, 1, 1, 1)
|
||||||
|
|
||||||
|
self.pb_con_mid = QPushButton(self.groupBox_3)
|
||||||
|
self.pb_con_mid.setObjectName(u"pb_con_mid")
|
||||||
|
self.pb_con_mid.setCheckable(True)
|
||||||
|
self.pb_con_mid.setAutoExclusive(False)
|
||||||
|
|
||||||
|
self.gridLayout_4.addWidget(self.pb_con_mid, 2, 0, 1, 1)
|
||||||
|
|
||||||
|
self.pb_con_line = QPushButton(self.groupBox_3)
|
||||||
|
self.pb_con_line.setObjectName(u"pb_con_line")
|
||||||
|
self.pb_con_line.setCheckable(True)
|
||||||
|
self.pb_con_line.setAutoExclusive(False)
|
||||||
|
|
||||||
|
self.gridLayout_4.addWidget(self.pb_con_line, 1, 1, 1, 1)
|
||||||
|
|
||||||
|
self.pb_con_horiz = QPushButton(self.groupBox_3)
|
||||||
|
self.pb_con_horiz.setObjectName(u"pb_con_horiz")
|
||||||
|
self.pb_con_horiz.setCheckable(True)
|
||||||
|
self.pb_con_horiz.setAutoExclusive(False)
|
||||||
|
|
||||||
|
self.gridLayout_4.addWidget(self.pb_con_horiz, 3, 0, 1, 1)
|
||||||
|
|
||||||
|
self.pb_con_dist = QPushButton(self.groupBox_3)
|
||||||
|
self.pb_con_dist.setObjectName(u"pb_con_dist")
|
||||||
|
self.pb_con_dist.setCheckable(True)
|
||||||
|
self.pb_con_dist.setAutoExclusive(False)
|
||||||
|
self.pb_con_dist.setAutoRepeatDelay(297)
|
||||||
|
|
||||||
|
self.gridLayout_4.addWidget(self.pb_con_dist, 4, 0, 1, 1)
|
||||||
|
|
||||||
|
self.pb_con_perp = QPushButton(self.groupBox_3)
|
||||||
|
self.pb_con_perp.setObjectName(u"pb_con_perp")
|
||||||
|
self.pb_con_perp.setCheckable(True)
|
||||||
|
self.pb_con_perp.setAutoExclusive(False)
|
||||||
|
|
||||||
|
self.gridLayout_4.addWidget(self.pb_con_perp, 2, 1, 1, 1)
|
||||||
|
|
||||||
|
self.pb_con_diameter = QPushButton(self.groupBox_3)
|
||||||
|
self.pb_con_diameter.setObjectName(u"pb_con_diameter")
|
||||||
|
|
||||||
|
self.gridLayout_4.addWidget(self.pb_con_diameter, 5, 0, 1, 1)
|
||||||
|
|
||||||
|
|
||||||
|
self.gridLayout.addWidget(self.groupBox_3, 2, 0, 1, 1)
|
||||||
|
|
||||||
|
self.groupBox_5 = QGroupBox(self.centralwidget)
|
||||||
|
self.groupBox_5.setObjectName(u"groupBox_5")
|
||||||
|
sizePolicy4.setHeightForWidth(self.groupBox_5.sizePolicy().hasHeightForWidth())
|
||||||
|
self.groupBox_5.setSizePolicy(sizePolicy4)
|
||||||
|
self.gridLayout_11 = QGridLayout(self.groupBox_5)
|
||||||
|
self.gridLayout_11.setObjectName(u"gridLayout_11")
|
||||||
|
self.gridLayout_11.setContentsMargins(12, 12, 12, 12)
|
||||||
|
self.label = QLabel(self.groupBox_5)
|
||||||
|
self.label.setObjectName(u"label")
|
||||||
|
|
||||||
|
self.gridLayout_11.addWidget(self.label, 5, 0, 1, 1)
|
||||||
|
|
||||||
|
self.pb_snap_vert = QPushButton(self.groupBox_5)
|
||||||
|
self.pb_snap_vert.setObjectName(u"pb_snap_vert")
|
||||||
|
self.pb_snap_vert.setCheckable(True)
|
||||||
|
self.pb_snap_vert.setAutoExclusive(False)
|
||||||
|
|
||||||
|
self.gridLayout_11.addWidget(self.pb_snap_vert, 2, 1, 1, 1)
|
||||||
|
|
||||||
|
self.line_2 = QFrame(self.groupBox_5)
|
||||||
|
self.line_2.setObjectName(u"line_2")
|
||||||
|
self.line_2.setFrameShape(QFrame.Shape.HLine)
|
||||||
|
self.line_2.setFrameShadow(QFrame.Shadow.Sunken)
|
||||||
|
|
||||||
|
self.gridLayout_11.addWidget(self.line_2, 4, 0, 1, 2)
|
||||||
|
|
||||||
|
self.label_2 = QLabel(self.groupBox_5)
|
||||||
|
self.label_2.setObjectName(u"label_2")
|
||||||
|
|
||||||
|
self.gridLayout_11.addWidget(self.label_2, 5, 1, 1, 1)
|
||||||
|
|
||||||
|
self.spinbox_snap_distance = QSpinBox(self.groupBox_5)
|
||||||
|
self.spinbox_snap_distance.setObjectName(u"spinbox_snap_distance")
|
||||||
|
self.spinbox_snap_distance.setMaximum(30)
|
||||||
|
self.spinbox_snap_distance.setValue(10)
|
||||||
|
|
||||||
|
self.gridLayout_11.addWidget(self.spinbox_snap_distance, 6, 0, 1, 1)
|
||||||
|
|
||||||
|
self.pushButton_7 = QPushButton(self.groupBox_5)
|
||||||
|
self.pushButton_7.setObjectName(u"pushButton_7")
|
||||||
|
self.pushButton_7.setCheckable(True)
|
||||||
|
self.pushButton_7.setAutoExclusive(False)
|
||||||
|
|
||||||
|
self.gridLayout_11.addWidget(self.pushButton_7, 3, 0, 1, 1)
|
||||||
|
|
||||||
|
self.pb_snap_horiz = QPushButton(self.groupBox_5)
|
||||||
|
self.pb_snap_horiz.setObjectName(u"pb_snap_horiz")
|
||||||
|
self.pb_snap_horiz.setCheckable(True)
|
||||||
|
self.pb_snap_horiz.setAutoExclusive(False)
|
||||||
|
|
||||||
|
self.gridLayout_11.addWidget(self.pb_snap_horiz, 2, 0, 1, 1)
|
||||||
|
|
||||||
|
self.spinbox_angle_steps = QSpinBox(self.groupBox_5)
|
||||||
|
self.spinbox_angle_steps.setObjectName(u"spinbox_angle_steps")
|
||||||
|
self.spinbox_angle_steps.setMaximum(180)
|
||||||
|
self.spinbox_angle_steps.setValue(15)
|
||||||
|
|
||||||
|
self.gridLayout_11.addWidget(self.spinbox_angle_steps, 6, 1, 1, 1)
|
||||||
|
|
||||||
|
self.pushButton_8 = QPushButton(self.groupBox_5)
|
||||||
|
self.pushButton_8.setObjectName(u"pushButton_8")
|
||||||
|
self.pushButton_8.setCheckable(True)
|
||||||
|
self.pushButton_8.setAutoExclusive(False)
|
||||||
|
|
||||||
|
self.gridLayout_11.addWidget(self.pushButton_8, 0, 0, 1, 1)
|
||||||
|
|
||||||
|
self.pb_snap_midp = QPushButton(self.groupBox_5)
|
||||||
|
self.pb_snap_midp.setObjectName(u"pb_snap_midp")
|
||||||
|
self.pb_snap_midp.setCheckable(True)
|
||||||
|
self.pb_snap_midp.setAutoExclusive(False)
|
||||||
|
|
||||||
|
self.gridLayout_11.addWidget(self.pb_snap_midp, 0, 1, 1, 1)
|
||||||
|
|
||||||
|
self.pb_snap_angle = QPushButton(self.groupBox_5)
|
||||||
|
self.pb_snap_angle.setObjectName(u"pb_snap_angle")
|
||||||
|
self.pb_snap_angle.setCheckable(True)
|
||||||
|
self.pb_snap_angle.setAutoExclusive(False)
|
||||||
|
|
||||||
|
self.gridLayout_11.addWidget(self.pb_snap_angle, 3, 1, 1, 1)
|
||||||
|
|
||||||
|
|
||||||
|
self.gridLayout.addWidget(self.groupBox_5, 3, 0, 1, 1)
|
||||||
|
|
||||||
|
self.groupBox = QGroupBox(self.centralwidget)
|
||||||
|
self.groupBox.setObjectName(u"groupBox")
|
||||||
|
self.gridLayout_3 = QGridLayout(self.groupBox)
|
||||||
|
self.gridLayout_3.setObjectName(u"gridLayout_3")
|
||||||
|
self.pb_revop = QPushButton(self.groupBox)
|
||||||
|
self.pb_revop.setObjectName(u"pb_revop")
|
||||||
|
|
||||||
|
self.gridLayout_3.addWidget(self.pb_revop, 2, 1, 1, 1)
|
||||||
|
|
||||||
|
self.pb_extrdop = QPushButton(self.groupBox)
|
||||||
|
self.pb_extrdop.setObjectName(u"pb_extrdop")
|
||||||
|
|
||||||
|
self.gridLayout_3.addWidget(self.pb_extrdop, 0, 0, 1, 1)
|
||||||
|
|
||||||
|
self.pb_arrayop = QPushButton(self.groupBox)
|
||||||
|
self.pb_arrayop.setObjectName(u"pb_arrayop")
|
||||||
|
|
||||||
|
self.gridLayout_3.addWidget(self.pb_arrayop, 2, 0, 1, 1)
|
||||||
|
|
||||||
|
self.pb_cutop = QPushButton(self.groupBox)
|
||||||
|
self.pb_cutop.setObjectName(u"pb_cutop")
|
||||||
|
|
||||||
|
self.gridLayout_3.addWidget(self.pb_cutop, 0, 1, 1, 1)
|
||||||
|
|
||||||
|
self.pb_combop = QPushButton(self.groupBox)
|
||||||
|
self.pb_combop.setObjectName(u"pb_combop")
|
||||||
|
|
||||||
|
self.gridLayout_3.addWidget(self.pb_combop, 1, 0, 1, 1)
|
||||||
|
|
||||||
|
self.pb_moveop = QPushButton(self.groupBox)
|
||||||
|
self.pb_moveop.setObjectName(u"pb_moveop")
|
||||||
|
|
||||||
|
self.gridLayout_3.addWidget(self.pb_moveop, 1, 1, 1, 1)
|
||||||
|
|
||||||
|
|
||||||
|
self.gridLayout.addWidget(self.groupBox, 0, 3, 1, 1)
|
||||||
|
|
||||||
|
self.groupBox_4 = QGroupBox(self.centralwidget)
|
||||||
|
self.groupBox_4.setObjectName(u"groupBox_4")
|
||||||
|
self.verticalLayout_2 = QVBoxLayout(self.groupBox_4)
|
||||||
|
self.verticalLayout_2.setObjectName(u"verticalLayout_2")
|
||||||
|
self.pushButton_2 = QPushButton(self.groupBox_4)
|
||||||
|
self.pushButton_2.setObjectName(u"pushButton_2")
|
||||||
|
|
||||||
|
self.verticalLayout_2.addWidget(self.pushButton_2)
|
||||||
|
|
||||||
|
self.pb_export_step = QPushButton(self.groupBox_4)
|
||||||
|
self.pb_export_step.setObjectName(u"pb_export_step")
|
||||||
|
|
||||||
|
self.verticalLayout_2.addWidget(self.pb_export_step)
|
||||||
|
|
||||||
|
self.pb_export_iges = QPushButton(self.groupBox_4)
|
||||||
|
self.pb_export_iges.setObjectName(u"pb_export_iges")
|
||||||
|
|
||||||
|
self.verticalLayout_2.addWidget(self.pb_export_iges)
|
||||||
|
|
||||||
|
|
||||||
|
self.gridLayout.addWidget(self.groupBox_4, 2, 3, 1, 1)
|
||||||
|
|
||||||
|
fluencyCAD.setCentralWidget(self.centralwidget)
|
||||||
|
self.menubar = QMenuBar(fluencyCAD)
|
||||||
|
self.menubar.setObjectName(u"menubar")
|
||||||
|
self.menubar.setGeometry(QRect(0, 0, 2359, 24))
|
||||||
|
self.menuFile = QMenu(self.menubar)
|
||||||
|
self.menuFile.setObjectName(u"menuFile")
|
||||||
|
self.menuSettings = QMenu(self.menubar)
|
||||||
|
self.menuSettings.setObjectName(u"menuSettings")
|
||||||
|
fluencyCAD.setMenuBar(self.menubar)
|
||||||
|
self.statusbar = QStatusBar(fluencyCAD)
|
||||||
|
self.statusbar.setObjectName(u"statusbar")
|
||||||
|
fluencyCAD.setStatusBar(self.statusbar)
|
||||||
|
|
||||||
|
self.menubar.addAction(self.menuFile.menuAction())
|
||||||
|
self.menubar.addAction(self.menuSettings.menuAction())
|
||||||
|
self.menuFile.addAction(self.actionNew_Project)
|
||||||
|
self.menuFile.addAction(self.actionOpen_Project)
|
||||||
|
self.menuFile.addAction(self.actionSave_Project)
|
||||||
|
self.menuFile.addAction(self.actionSave_Project_As)
|
||||||
|
self.menuFile.addSeparator()
|
||||||
|
self.menuFile.addAction(self.actionImport_File)
|
||||||
|
self.menuFile.addSeparator()
|
||||||
|
self.menuFile.addAction(self.actionExport_Step)
|
||||||
|
self.menuFile.addAction(self.actionExport_Iges)
|
||||||
|
self.menuFile.addAction(self.actionExport_Stl)
|
||||||
|
self.menuFile.addSeparator()
|
||||||
|
self.menuFile.addAction(self.actionExit)
|
||||||
|
|
||||||
|
self.retranslateUi(fluencyCAD)
|
||||||
|
|
||||||
|
self.InputTab.setCurrentIndex(0)
|
||||||
|
|
||||||
|
|
||||||
|
QMetaObject.connectSlotsByName(fluencyCAD)
|
||||||
|
# setupUi
|
||||||
|
|
||||||
|
def retranslateUi(self, fluencyCAD):
|
||||||
|
fluencyCAD.setWindowTitle(QCoreApplication.translate("fluencyCAD", u"fluencyCAD", None))
|
||||||
|
self.actionNew_Project.setText(QCoreApplication.translate("fluencyCAD", u"New Project", None))
|
||||||
|
#if QT_CONFIG(shortcut)
|
||||||
|
self.actionNew_Project.setShortcut(QCoreApplication.translate("fluencyCAD", u"Ctrl+N", None))
|
||||||
|
#endif // QT_CONFIG(shortcut)
|
||||||
|
self.actionOpen_Project.setText(QCoreApplication.translate("fluencyCAD", u"Open Project...", None))
|
||||||
|
#if QT_CONFIG(shortcut)
|
||||||
|
self.actionOpen_Project.setShortcut(QCoreApplication.translate("fluencyCAD", u"Ctrl+O", None))
|
||||||
|
#endif // QT_CONFIG(shortcut)
|
||||||
|
self.actionSave_Project.setText(QCoreApplication.translate("fluencyCAD", u"Save Project", None))
|
||||||
|
#if QT_CONFIG(shortcut)
|
||||||
|
self.actionSave_Project.setShortcut(QCoreApplication.translate("fluencyCAD", u"Ctrl+S", None))
|
||||||
|
#endif // QT_CONFIG(shortcut)
|
||||||
|
self.actionSave_Project_As.setText(QCoreApplication.translate("fluencyCAD", u"Save Project As...", None))
|
||||||
|
#if QT_CONFIG(shortcut)
|
||||||
|
self.actionSave_Project_As.setShortcut(QCoreApplication.translate("fluencyCAD", u"Ctrl+Shift+S", None))
|
||||||
|
#endif // QT_CONFIG(shortcut)
|
||||||
|
self.actionImport_File.setText(QCoreApplication.translate("fluencyCAD", u"Import STEP/IGES...", None))
|
||||||
|
self.actionExport_Step.setText(QCoreApplication.translate("fluencyCAD", u"Export STEP...", None))
|
||||||
|
self.actionExport_Iges.setText(QCoreApplication.translate("fluencyCAD", u"Export IGES...", None))
|
||||||
|
self.actionExport_Stl.setText(QCoreApplication.translate("fluencyCAD", u"Export STL...", None))
|
||||||
|
self.actionExit.setText(QCoreApplication.translate("fluencyCAD", u"Exit", None))
|
||||||
|
#if QT_CONFIG(shortcut)
|
||||||
|
self.actionExit.setShortcut(QCoreApplication.translate("fluencyCAD", u"Ctrl+Q", None))
|
||||||
|
#endif // QT_CONFIG(shortcut)
|
||||||
|
self.InputTab.setTabText(self.InputTab.indexOf(self.sketch_tab), QCoreApplication.translate("fluencyCAD", u"Sketch", None))
|
||||||
|
self.groupBox_7.setTitle(QCoreApplication.translate("fluencyCAD", u"Executive", None))
|
||||||
|
self.pushButton_5.setText(QCoreApplication.translate("fluencyCAD", u"Load Code", None))
|
||||||
|
self.pushButton_4.setText(QCoreApplication.translate("fluencyCAD", u"Save code", None))
|
||||||
|
self.pb_apply_code.setText(QCoreApplication.translate("fluencyCAD", u"Apply Code", None))
|
||||||
|
self.pushButton.setText(QCoreApplication.translate("fluencyCAD", u"Delete Code", None))
|
||||||
|
self.InputTab.setTabText(self.InputTab.indexOf(self.code_tab), QCoreApplication.translate("fluencyCAD", u"Code", None))
|
||||||
|
self.groupBox_9.setTitle(QCoreApplication.translate("fluencyCAD", u"Workplanes", None))
|
||||||
|
#if QT_CONFIG(tooltip)
|
||||||
|
self.pb_origin_wp.setToolTip(QCoreApplication.translate("fluencyCAD", u"<W>orking Plane at 0, 0, 0", None))
|
||||||
|
#endif // QT_CONFIG(tooltip)
|
||||||
|
self.pb_origin_wp.setText(QCoreApplication.translate("fluencyCAD", u"WP Origin", None))
|
||||||
|
#if QT_CONFIG(shortcut)
|
||||||
|
self.pb_origin_wp.setShortcut(QCoreApplication.translate("fluencyCAD", u"W", None))
|
||||||
|
#endif // QT_CONFIG(shortcut)
|
||||||
|
#if QT_CONFIG(tooltip)
|
||||||
|
self.pb_origin_face.setToolTip(QCoreApplication.translate("fluencyCAD", u"Working Plane >P<rojection at selected edges face", None))
|
||||||
|
#endif // QT_CONFIG(tooltip)
|
||||||
|
self.pb_origin_face.setText(QCoreApplication.translate("fluencyCAD", u" WP Face", None))
|
||||||
|
#if QT_CONFIG(shortcut)
|
||||||
|
self.pb_origin_face.setShortcut(QCoreApplication.translate("fluencyCAD", u"P", None))
|
||||||
|
#endif // QT_CONFIG(shortcut)
|
||||||
|
#if QT_CONFIG(tooltip)
|
||||||
|
self.pb_flip_face.setToolTip(QCoreApplication.translate("fluencyCAD", u"Flip >N<ormal of projected mesh.", None))
|
||||||
|
#endif // QT_CONFIG(tooltip)
|
||||||
|
self.pb_flip_face.setText(QCoreApplication.translate("fluencyCAD", u"WP Flip", None))
|
||||||
|
#if QT_CONFIG(shortcut)
|
||||||
|
self.pb_flip_face.setShortcut(QCoreApplication.translate("fluencyCAD", u"N", None))
|
||||||
|
#endif // QT_CONFIG(shortcut)
|
||||||
|
#if QT_CONFIG(tooltip)
|
||||||
|
self.pb_underlay.setToolTip(QCoreApplication.translate("fluencyCAD", u"Show / hide the construction lines projected from the source face", None))
|
||||||
|
#endif // QT_CONFIG(tooltip)
|
||||||
|
self.pb_underlay.setText(QCoreApplication.translate("fluencyCAD", u"Underlay", None))
|
||||||
|
#if QT_CONFIG(tooltip)
|
||||||
|
self.pb_clr_face.setToolTip(QCoreApplication.translate("fluencyCAD", u"Forget the picked source face (keep the workplane)", None))
|
||||||
|
#endif // QT_CONFIG(tooltip)
|
||||||
|
self.pb_clr_face.setText(QCoreApplication.translate("fluencyCAD", u"ClrFace", None))
|
||||||
|
#if QT_CONFIG(tooltip)
|
||||||
|
self.pb_to_sketch.setToolTip(QCoreApplication.translate("fluencyCAD", u"Convert projected construction lines into real sketch geometry", None))
|
||||||
|
#endif // QT_CONFIG(tooltip)
|
||||||
|
self.pb_to_sketch.setText(QCoreApplication.translate("fluencyCAD", u"ToSketch", None))
|
||||||
|
#if QT_CONFIG(tooltip)
|
||||||
|
self.pb_wp_new.setToolTip(QCoreApplication.translate("fluencyCAD", u"Create a new independent workplane (datum plane)", None))
|
||||||
|
#endif // QT_CONFIG(tooltip)
|
||||||
|
self.pb_wp_new.setText(QCoreApplication.translate("fluencyCAD", u"WP New", None))
|
||||||
|
#if QT_CONFIG(shortcut)
|
||||||
|
self.pb_wp_new.setShortcut(QCoreApplication.translate("fluencyCAD", u"Shift+W", None))
|
||||||
|
#endif // QT_CONFIG(shortcut)
|
||||||
|
self.assembly_box.setTitle(QCoreApplication.translate("fluencyCAD", u"Assembly", None))
|
||||||
|
self.joint_tools.setTitle(QCoreApplication.translate("fluencyCAD", u"Joint Tools", None))
|
||||||
|
self.pb_remove_connector.setText(QCoreApplication.translate("fluencyCAD", u"- Cnct", None))
|
||||||
|
self.pb_add_connector.setText(QCoreApplication.translate("fluencyCAD", u"+ Cnct", None))
|
||||||
|
self.pb_add_connector_2.setText(QCoreApplication.translate("fluencyCAD", u"+Jnt", None))
|
||||||
|
self.pb_add_connector_3.setText(QCoreApplication.translate("fluencyCAD", u"-Jnt", None))
|
||||||
|
self.gl_box.setTitle(QCoreApplication.translate("fluencyCAD", u"Model Viewer", None))
|
||||||
|
self.groupBox_11.setTitle(QCoreApplication.translate("fluencyCAD", u"Sketch", None))
|
||||||
|
self.groupBox_6.setTitle(QCoreApplication.translate("fluencyCAD", u"Tools", None))
|
||||||
|
self.pb_edt_sktch.setText(QCoreApplication.translate("fluencyCAD", u"Edt", None))
|
||||||
|
self.pb_nw_sktch.setText(QCoreApplication.translate("fluencyCAD", u"Add", None))
|
||||||
|
self.pb_del_sketch.setText(QCoreApplication.translate("fluencyCAD", u"Del", None))
|
||||||
|
self.assembly_tools.setTitle(QCoreApplication.translate("fluencyCAD", u"Assembly Tools", None))
|
||||||
|
self.pb_compo_to_assembly.setText(QCoreApplication.translate("fluencyCAD", u"Add", None))
|
||||||
|
self.pb_remove_compo_from_assembly.setText(QCoreApplication.translate("fluencyCAD", u"Rem", None))
|
||||||
|
self.compo_tool_box.setTitle(QCoreApplication.translate("fluencyCAD", u"Component Tools", None))
|
||||||
|
self.pb_new_compo.setText(QCoreApplication.translate("fluencyCAD", u"New", None))
|
||||||
|
self.pb_del_compo.setText(QCoreApplication.translate("fluencyCAD", u"Del", None))
|
||||||
|
self.compo_box.setTitle(QCoreApplication.translate("fluencyCAD", u"Components", None))
|
||||||
|
self.groupBox_12.setTitle(QCoreApplication.translate("fluencyCAD", u"Component Connections", None))
|
||||||
|
self.groupBox_13.setTitle(QCoreApplication.translate("fluencyCAD", u"Tools", None))
|
||||||
|
self.pb_del_connection.setText(QCoreApplication.translate("fluencyCAD", u"Del", None))
|
||||||
|
self.pb_update_connection.setText(QCoreApplication.translate("fluencyCAD", u"Upd", None))
|
||||||
|
self.pb_edt_sktch_4.setText(QCoreApplication.translate("fluencyCAD", u"Nothing", None))
|
||||||
|
self.groupBox_10.setTitle(QCoreApplication.translate("fluencyCAD", u"Bodys / Operations", None))
|
||||||
|
self.groupBox_8.setTitle(QCoreApplication.translate("fluencyCAD", u"Tools", None))
|
||||||
|
self.pb_del_body.setText(QCoreApplication.translate("fluencyCAD", u"Del", None))
|
||||||
|
self.pb_update_body.setText(QCoreApplication.translate("fluencyCAD", u"Upd", None))
|
||||||
|
self.pb_edt_sktch_3.setText(QCoreApplication.translate("fluencyCAD", u"Nothing", None))
|
||||||
|
self.groupBox_2.setTitle(QCoreApplication.translate("fluencyCAD", u"Drawing", None))
|
||||||
|
self.pb_arc_tool.setText(QCoreApplication.translate("fluencyCAD", u"Arc", None))
|
||||||
|
self.pb_rectool.setText(QCoreApplication.translate("fluencyCAD", u"Rctgl", None))
|
||||||
|
self.pb_circtool.setText(QCoreApplication.translate("fluencyCAD", u"Circle", None))
|
||||||
|
self.pb_enable_construct.setText(QCoreApplication.translate("fluencyCAD", u"Cstrct", None))
|
||||||
|
self.pb_enable_snap.setText(QCoreApplication.translate("fluencyCAD", u"Snap", None))
|
||||||
|
self.pb_linetool.setText(QCoreApplication.translate("fluencyCAD", u"Line", None))
|
||||||
|
#if QT_CONFIG(shortcut)
|
||||||
|
self.pb_linetool.setShortcut(QCoreApplication.translate("fluencyCAD", u"S", None))
|
||||||
|
#endif // QT_CONFIG(shortcut)
|
||||||
|
self.pb_slotool.setText(QCoreApplication.translate("fluencyCAD", u"Slot", None))
|
||||||
|
#if QT_CONFIG(tooltip)
|
||||||
|
self.pb_offset_tool.setToolTip(QCoreApplication.translate("fluencyCAD", u"Offset selected sketch face (duplicate + offset boundary)", None))
|
||||||
|
#endif // QT_CONFIG(tooltip)
|
||||||
|
self.pb_offset_tool.setText(QCoreApplication.translate("fluencyCAD", u"Offst", None))
|
||||||
|
self.groupBox_3.setTitle(QCoreApplication.translate("fluencyCAD", u"Constrain", None))
|
||||||
|
#if QT_CONFIG(tooltip)
|
||||||
|
self.pb_con_ptpt.setToolTip(QCoreApplication.translate("fluencyCAD", u"Poin to Point Constrain", None))
|
||||||
|
#endif // QT_CONFIG(tooltip)
|
||||||
|
self.pb_con_ptpt.setText(QCoreApplication.translate("fluencyCAD", u"Pt_Pt", None))
|
||||||
|
#if QT_CONFIG(tooltip)
|
||||||
|
self.pb_con_vert.setToolTip(QCoreApplication.translate("fluencyCAD", u"Vertical Constrain", None))
|
||||||
|
#endif // QT_CONFIG(tooltip)
|
||||||
|
self.pb_con_vert.setText(QCoreApplication.translate("fluencyCAD", u"Vert", None))
|
||||||
|
self.pb_con_sym.setText(QCoreApplication.translate("fluencyCAD", u"Symetrc", None))
|
||||||
|
#if QT_CONFIG(tooltip)
|
||||||
|
self.pb_con_mid.setToolTip(QCoreApplication.translate("fluencyCAD", u"Point to Middle Point Constrain", None))
|
||||||
|
#endif // QT_CONFIG(tooltip)
|
||||||
|
self.pb_con_mid.setText(QCoreApplication.translate("fluencyCAD", u"Pt_Mid_L", None))
|
||||||
|
#if QT_CONFIG(tooltip)
|
||||||
|
self.pb_con_line.setToolTip(QCoreApplication.translate("fluencyCAD", u"Point to Line Constrain", None))
|
||||||
|
#endif // QT_CONFIG(tooltip)
|
||||||
|
self.pb_con_line.setText(QCoreApplication.translate("fluencyCAD", u"Pt_Lne", None))
|
||||||
|
#if QT_CONFIG(tooltip)
|
||||||
|
self.pb_con_horiz.setToolTip(QCoreApplication.translate("fluencyCAD", u"Horizontal Constrain ", None))
|
||||||
|
#endif // QT_CONFIG(tooltip)
|
||||||
|
self.pb_con_horiz.setText(QCoreApplication.translate("fluencyCAD", u"Horiz", None))
|
||||||
|
#if QT_CONFIG(tooltip)
|
||||||
|
self.pb_con_dist.setToolTip(QCoreApplication.translate("fluencyCAD", u"Dimension of Line of Distance from Point to Line", None))
|
||||||
|
#endif // QT_CONFIG(tooltip)
|
||||||
|
self.pb_con_dist.setText(QCoreApplication.translate("fluencyCAD", u"Distnce", None))
|
||||||
|
#if QT_CONFIG(tooltip)
|
||||||
|
self.pb_con_perp.setToolTip(QCoreApplication.translate("fluencyCAD", u"Constrain Line perpendicular to another line.", None))
|
||||||
|
#endif // QT_CONFIG(tooltip)
|
||||||
|
self.pb_con_perp.setText(QCoreApplication.translate("fluencyCAD", u"Perp_Lne", None))
|
||||||
|
self.pb_con_diameter.setText(QCoreApplication.translate("fluencyCAD", u"Diameter", None))
|
||||||
|
self.groupBox_5.setTitle(QCoreApplication.translate("fluencyCAD", u"Snapping Points", None))
|
||||||
|
self.label.setText(QCoreApplication.translate("fluencyCAD", u"Snp Dst", None))
|
||||||
|
self.pb_snap_vert.setText(QCoreApplication.translate("fluencyCAD", u"Vert", None))
|
||||||
|
self.label_2.setText(QCoreApplication.translate("fluencyCAD", u"Angl Stps", None))
|
||||||
|
self.spinbox_snap_distance.setSuffix(QCoreApplication.translate("fluencyCAD", u"mm", None))
|
||||||
|
self.pushButton_7.setText(QCoreApplication.translate("fluencyCAD", u"Grid", None))
|
||||||
|
self.pb_snap_horiz.setText(QCoreApplication.translate("fluencyCAD", u"Horiz", None))
|
||||||
|
self.spinbox_angle_steps.setSuffix(QCoreApplication.translate("fluencyCAD", u"\u00b0", None))
|
||||||
|
self.pushButton_8.setText(QCoreApplication.translate("fluencyCAD", u"Pnt", None))
|
||||||
|
self.pb_snap_midp.setText(QCoreApplication.translate("fluencyCAD", u"MidP", None))
|
||||||
|
self.pb_snap_angle.setText(QCoreApplication.translate("fluencyCAD", u"Angles", None))
|
||||||
|
self.groupBox.setTitle(QCoreApplication.translate("fluencyCAD", u"Modify", None))
|
||||||
|
self.pb_revop.setText(QCoreApplication.translate("fluencyCAD", u"Rev", None))
|
||||||
|
self.pb_extrdop.setText(QCoreApplication.translate("fluencyCAD", u"Extrd", None))
|
||||||
|
self.pb_arrayop.setText(QCoreApplication.translate("fluencyCAD", u"Arry", None))
|
||||||
|
self.pb_cutop.setText(QCoreApplication.translate("fluencyCAD", u"Cut", None))
|
||||||
|
self.pb_combop.setText(QCoreApplication.translate("fluencyCAD", u"Comb", None))
|
||||||
|
self.pb_moveop.setText(QCoreApplication.translate("fluencyCAD", u"Mve", None))
|
||||||
|
self.groupBox_4.setTitle(QCoreApplication.translate("fluencyCAD", u"Export", None))
|
||||||
|
self.pushButton_2.setText(QCoreApplication.translate("fluencyCAD", u"STL", None))
|
||||||
|
self.pb_export_step.setText(QCoreApplication.translate("fluencyCAD", u"STEP", None))
|
||||||
|
self.pb_export_iges.setText(QCoreApplication.translate("fluencyCAD", u"IGES", None))
|
||||||
|
self.menuFile.setTitle(QCoreApplication.translate("fluencyCAD", u"File", None))
|
||||||
|
self.menuSettings.setTitle(QCoreApplication.translate("fluencyCAD", u"Settings", None))
|
||||||
|
# retranslateUi
|
||||||
|
|
||||||
@@ -26,7 +26,6 @@ classifiers = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cadquery>=2.4",
|
|
||||||
"pygfx>=0.1.0",
|
"pygfx>=0.1.0",
|
||||||
"wgpu>=0.1.0",
|
"wgpu>=0.1.0",
|
||||||
"PySide6>=6.4.0",
|
"PySide6>=6.4.0",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
Fluency CAD - Parametric CAD Application
|
Fluency CAD - Parametric CAD Application
|
||||||
|
|
||||||
A modern parametric CAD application built on OpenCASCADE Technology (OCCT)
|
A modern parametric CAD application built on OpenCASCADE Technology (OCCT)
|
||||||
with a clean Python API using CadQuery.
|
with a clean Python API using OCP (OpenCASCADE Python bindings).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__version__ = "2.0.0"
|
__version__ = "2.0.0"
|
||||||
|
|||||||
@@ -406,6 +406,11 @@ class SketchInterface(ABC):
|
|||||||
"""Constrain two circles to have equal radius."""
|
"""Constrain two circles to have equal radius."""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def constrain_diameter(self, circle: SketchEntity, diameter: float) -> bool:
|
||||||
|
"""Set the diameter of a circle."""
|
||||||
|
pass
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def constrain_fixed(self, entity: SketchEntity) -> bool:
|
def constrain_fixed(self, entity: SketchEntity) -> bool:
|
||||||
"""Fix an entity in place."""
|
"""Fix an entity in place."""
|
||||||
|
|||||||
+145
-261
@@ -2,7 +2,7 @@
|
|||||||
OpenCASCADE-based geometry kernel for Fluency CAD.
|
OpenCASCADE-based geometry kernel for Fluency CAD.
|
||||||
|
|
||||||
This module provides a concrete implementation of the geometry kernel
|
This module provides a concrete implementation of the geometry kernel
|
||||||
using CadQuery and OCP (OpenCASCADE Python bindings).
|
using OCP (OpenCASCADE Python bindings).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import List, Tuple, Optional, Any, Dict
|
from typing import List, Tuple, Optional, Any, Dict
|
||||||
@@ -21,24 +21,14 @@ class OCCGeometryObject(GeometryObject):
|
|||||||
|
|
||||||
def __init__(self, shape: Any = None, metadata: Optional[Dict] = None):
|
def __init__(self, shape: Any = None, metadata: Optional[Dict] = None):
|
||||||
super().__init__(shape, metadata)
|
super().__init__(shape, metadata)
|
||||||
self._cadquery_obj: Any = None
|
|
||||||
|
|
||||||
@property
|
|
||||||
def cq_obj(self) -> Any:
|
|
||||||
"""Get the CadQuery object if available."""
|
|
||||||
return self._cadquery_obj
|
|
||||||
|
|
||||||
@cq_obj.setter
|
|
||||||
def cq_obj(self, value: Any) -> None:
|
|
||||||
self._cadquery_obj = value
|
|
||||||
|
|
||||||
|
|
||||||
class OCGeometryKernel(GeometryKernel):
|
class OCGeometryKernel(GeometryKernel):
|
||||||
"""
|
"""
|
||||||
OpenCASCADE-based geometry kernel implementation.
|
OpenCASCADE-based geometry kernel implementation.
|
||||||
|
|
||||||
This kernel uses CadQuery for high-level operations and
|
This kernel uses OCP (OpenCASCADE Python bindings) for all geometry
|
||||||
OCP for direct OpenCASCADE access when needed.
|
operations.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
@@ -51,114 +41,93 @@ class OCGeometryKernel(GeometryKernel):
|
|||||||
Returns *None* if the object carries no shape (e.g. an empty sketch) —
|
Returns *None* if the object carries no shape (e.g. an empty sketch) —
|
||||||
callers should check for None before using the result.
|
callers should check for None before using the result.
|
||||||
"""
|
"""
|
||||||
import cadquery as cq
|
|
||||||
|
|
||||||
if isinstance(obj, OCCGeometryObject):
|
if isinstance(obj, OCCGeometryObject):
|
||||||
if obj._cadquery_obj is not None:
|
if obj.shape is not None and hasattr(obj.shape, "wrapped"):
|
||||||
shape = obj._cadquery_obj.val()
|
return obj.shape.wrapped
|
||||||
if hasattr(shape, "wrapped"):
|
return obj.shape
|
||||||
return shape.wrapped
|
|
||||||
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
|
|
||||||
# No cadquery obj and no raw shape → genuinely empty.
|
|
||||||
return None
|
|
||||||
# Non-OCCGeometryObject: return its shape if present, else None.
|
# Non-OCCGeometryObject: return its shape if present, else None.
|
||||||
# (Use explicit identity/truth checks — some OCP TopoDS objects have a
|
# (Use explicit identity/truth checks — some OCP TopoDS objects have a
|
||||||
# falsy __bool__, so ``obj.shape if obj.shape`` is unsafe.)
|
# falsy __bool__, so ``obj.shape if obj.shape`` is unsafe.)
|
||||||
shape = getattr(obj, "shape", None)
|
shape = getattr(obj, "shape", None)
|
||||||
return shape if shape is not None else None
|
return shape if shape is not None else None
|
||||||
|
|
||||||
def _get_cq_obj(self, obj: GeometryObject) -> Any:
|
|
||||||
"""Get CadQuery object from GeometryObject."""
|
|
||||||
if isinstance(obj, OCCGeometryObject) and obj._cadquery_obj is not None:
|
|
||||||
return obj._cadquery_obj
|
|
||||||
return obj.shape
|
|
||||||
|
|
||||||
def create_point(self, x: float, y: float) -> GeometryObject:
|
def create_point(self, x: float, y: float) -> GeometryObject:
|
||||||
"""Create a 2D point."""
|
"""Create a 2D point."""
|
||||||
import cadquery as cq
|
from OCP.gp import gp_Pnt
|
||||||
|
return OCCGeometryObject(gp_Pnt(x, y, 0))
|
||||||
point = cq.Vector(x, y, 0)
|
|
||||||
return OCCGeometryObject(point)
|
|
||||||
|
|
||||||
def create_line(self, start: Point2D, end: Point2D) -> GeometryObject:
|
def create_line(self, start: Point2D, end: Point2D) -> GeometryObject:
|
||||||
"""Create a 2D line segment."""
|
"""Create a 2D line segment."""
|
||||||
import cadquery as cq
|
from OCP.gp import gp_Pnt
|
||||||
|
from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeEdge
|
||||||
|
|
||||||
wp = cq.Workplane("XY").moveTo(start.x, start.y).lineTo(end.x, end.y)
|
edge = BRepBuilderAPI_MakeEdge(
|
||||||
obj = OCCGeometryObject(wp.val(), {"type": "line"})
|
gp_Pnt(start.x, start.y, 0), gp_Pnt(end.x, end.y, 0)
|
||||||
obj._cadquery_obj = wp
|
).Edge()
|
||||||
return obj
|
return OCCGeometryObject(edge, {"type": "line"})
|
||||||
|
|
||||||
def create_circle(self, center: Point2D, radius: float) -> GeometryObject:
|
def create_circle(self, center: Point2D, radius: float) -> GeometryObject:
|
||||||
"""Create a 2D circle."""
|
"""Create a 2D circle."""
|
||||||
import cadquery as cq
|
from OCP.gp import gp_Pnt, gp_Dir, gp_Ax2, gp_Circ
|
||||||
|
from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeEdge
|
||||||
|
|
||||||
wp = cq.Workplane("XY").center(center.x, center.y).circle(radius)
|
circ = gp_Circ(
|
||||||
obj = OCCGeometryObject(wp.val(), {"type": "circle"})
|
gp_Ax2(gp_Pnt(center.x, center.y, 0), gp_Dir(0, 0, 1)), radius
|
||||||
obj._cadquery_obj = wp
|
)
|
||||||
return obj
|
edge = BRepBuilderAPI_MakeEdge(circ).Edge()
|
||||||
|
return OCCGeometryObject(edge, {"type": "circle"})
|
||||||
|
|
||||||
def create_arc(
|
def create_arc(
|
||||||
self, center: Point2D, radius: float, start_angle: float, end_angle: float
|
self, center: Point2D, radius: float, start_angle: float, end_angle: float
|
||||||
) -> GeometryObject:
|
) -> GeometryObject:
|
||||||
"""Create a 2D arc."""
|
"""Create a 2D arc."""
|
||||||
import cadquery as cq
|
|
||||||
import math
|
import math
|
||||||
|
from OCP.gp import gp_Pnt, gp_Dir, gp_Ax2, gp_Circ
|
||||||
|
from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeEdge
|
||||||
|
|
||||||
start_rad = math.radians(start_angle)
|
start_rad = math.radians(start_angle)
|
||||||
end_rad = math.radians(end_angle)
|
end_rad = math.radians(end_angle)
|
||||||
|
|
||||||
start_x = center.x + radius * math.cos(start_rad)
|
circ = gp_Circ(
|
||||||
start_y = center.y + radius * math.sin(start_rad)
|
gp_Ax2(gp_Pnt(center.x, center.y, 0), gp_Dir(0, 0, 1)), radius
|
||||||
|
|
||||||
wire = (
|
|
||||||
cq.Workplane("XY")
|
|
||||||
.moveTo(start_x, start_y)
|
|
||||||
.radiusArc(
|
|
||||||
(center.x + radius * math.cos(end_rad), center.y + radius * math.sin(end_rad)),
|
|
||||||
radius,
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
return OCCGeometryObject(wire.val(), {"type": "arc"})
|
edge = BRepBuilderAPI_MakeEdge(circ, start_rad, end_rad).Edge()
|
||||||
|
return OCCGeometryObject(edge, {"type": "arc"})
|
||||||
|
|
||||||
def create_polygon(self, points: List[Point2D]) -> GeometryObject:
|
def create_polygon(self, points: List[Point2D]) -> GeometryObject:
|
||||||
"""Create a closed polygon from points."""
|
"""Create a closed polygon from points."""
|
||||||
import cadquery as cq
|
from OCP.gp import gp_Pnt
|
||||||
|
from OCP.BRepBuilderAPI import BRepBuilderAPI_MakePolygon
|
||||||
|
|
||||||
if len(points) < 3:
|
if len(points) < 3:
|
||||||
raise ValueError("Polygon requires at least 3 points")
|
raise ValueError("Polygon requires at least 3 points")
|
||||||
|
|
||||||
wp = cq.Workplane("XY").moveTo(points[0].x, points[0].y)
|
mp = BRepBuilderAPI_MakePolygon()
|
||||||
for pt in points[1:]:
|
for pt in points:
|
||||||
wp = wp.lineTo(pt.x, pt.y)
|
mp.Add(gp_Pnt(pt.x, pt.y, 0))
|
||||||
wp = wp.close()
|
mp.Close()
|
||||||
|
return OCCGeometryObject(mp.Wire(), {"type": "polygon"})
|
||||||
obj = OCCGeometryObject(wp.val(), {"type": "polygon"})
|
|
||||||
obj._cadquery_obj = wp
|
|
||||||
return obj
|
|
||||||
|
|
||||||
def create_rectangle(
|
def create_rectangle(
|
||||||
self, width: float, height: float, center: Optional[Point2D] = None
|
self, width: float, height: float, center: Optional[Point2D] = None
|
||||||
) -> GeometryObject:
|
) -> GeometryObject:
|
||||||
"""Create a rectangle."""
|
"""Create a rectangle."""
|
||||||
import cadquery as cq
|
from OCP.gp import gp_Pnt
|
||||||
|
from OCP.BRepBuilderAPI import BRepBuilderAPI_MakePolygon
|
||||||
|
|
||||||
cx = center.x if center else 0
|
cx = center.x if center else 0
|
||||||
cy = center.y if center else 0
|
cy = center.y if center else 0
|
||||||
|
|
||||||
wp = cq.Workplane("XY").center(cx, cy).rect(width, height)
|
hw = width / 2.0
|
||||||
obj = OCCGeometryObject(wp.val(), {"type": "rectangle"})
|
hh = height / 2.0
|
||||||
obj._cadquery_obj = wp
|
|
||||||
return obj
|
mp = BRepBuilderAPI_MakePolygon()
|
||||||
|
mp.Add(gp_Pnt(cx - hw, cy - hh, 0))
|
||||||
|
mp.Add(gp_Pnt(cx + hw, cy - hh, 0))
|
||||||
|
mp.Add(gp_Pnt(cx + hw, cy + hh, 0))
|
||||||
|
mp.Add(gp_Pnt(cx - hw, cy + hh, 0))
|
||||||
|
mp.Close()
|
||||||
|
return OCCGeometryObject(mp.Wire(), {"type": "rectangle"})
|
||||||
|
|
||||||
def extrude(
|
def extrude(
|
||||||
self,
|
self,
|
||||||
@@ -182,10 +151,6 @@ class OCGeometryKernel(GeometryKernel):
|
|||||||
|
|
||||||
# Defensive: figure out the actual shape from whatever the caller
|
# Defensive: figure out the actual shape from whatever the caller
|
||||||
# hands us, and surface a clear error if we can't get one.
|
# hands us, and surface a clear error if we can't get one.
|
||||||
# - If it's an OCCGeometryObject wrapper, unwrap via _get_shape.
|
|
||||||
# - If it's already a TopoDS_Shape (raw face/wire/etc.), use it.
|
|
||||||
# - If it's a cadquery Workplane, unwrap that too.
|
|
||||||
# - If it's a cadquery Shape (cq.Shape), unwrap to TopoDS_Shape.
|
|
||||||
if isinstance(sketch, OCCGeometryObject):
|
if isinstance(sketch, OCCGeometryObject):
|
||||||
face = self._get_shape(sketch)
|
face = self._get_shape(sketch)
|
||||||
elif isinstance(sketch, TopoDS_Shape):
|
elif isinstance(sketch, TopoDS_Shape):
|
||||||
@@ -205,8 +170,8 @@ class OCGeometryKernel(GeometryKernel):
|
|||||||
"Cannot extrude: sketch geometry is a wrapper, not a shape. "
|
"Cannot extrude: sketch geometry is a wrapper, not a shape. "
|
||||||
"This is a bug — please report it."
|
"This is a bug — please report it."
|
||||||
)
|
)
|
||||||
# ``face`` may be a TopoDS_Face (new path) or a compound/wire from
|
# ``face`` may be a TopoDS_Face (new path) or a compound/wire.
|
||||||
# legacy cadquery objects. If it's not already a face, build one.
|
# If it's not already a face, build one.
|
||||||
face = self._ensure_face(face)
|
face = self._ensure_face(face)
|
||||||
if face is None:
|
if face is None:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
@@ -303,48 +268,58 @@ class OCGeometryKernel(GeometryKernel):
|
|||||||
|
|
||||||
def loft(self, profiles: List[GeometryObject], ruled: bool = False) -> GeometryObject:
|
def loft(self, profiles: List[GeometryObject], ruled: bool = False) -> GeometryObject:
|
||||||
"""Create a loft between multiple profiles."""
|
"""Create a loft between multiple profiles."""
|
||||||
import cadquery as cq
|
from OCP.BRepOffsetAPI import BRepOffsetAPI_ThruSections
|
||||||
|
from OCP.TopExp import TopExp_Explorer
|
||||||
|
from OCP.TopAbs import TopAbs_WIRE
|
||||||
|
from OCP.TopoDS import TopoDS
|
||||||
|
|
||||||
if len(profiles) < 2:
|
if len(profiles) < 2:
|
||||||
raise ValueError("Loft requires at least 2 profiles")
|
raise ValueError("Loft requires at least 2 profiles")
|
||||||
|
|
||||||
wires = []
|
loft_maker = BRepOffsetAPI_ThruSections(True, ruled)
|
||||||
for profile in profiles:
|
for profile in profiles:
|
||||||
cq_obj = self._get_cq_obj(profile)
|
shape = self._get_shape(profile)
|
||||||
if isinstance(cq_obj, cq.Workplane):
|
explorer = TopExp_Explorer(shape, TopAbs_WIRE)
|
||||||
wires.append(cq_obj.val())
|
while explorer.More():
|
||||||
else:
|
wire = TopoDS.Wire_s(explorer.Current())
|
||||||
wires.append(cq_obj)
|
loft_maker.AddWire(wire)
|
||||||
|
explorer.Next()
|
||||||
|
|
||||||
loft = cq.Solid.loft(wires, ruled)
|
loft_maker.Build()
|
||||||
return OCCGeometryObject(loft, {"type": "loft"})
|
solid = loft_maker.Shape()
|
||||||
|
return OCCGeometryObject(solid, {"type": "loft"})
|
||||||
|
|
||||||
def sweep(
|
def sweep(
|
||||||
self, profile: GeometryObject, path: GeometryObject, is_frenet: bool = False
|
self, profile: GeometryObject, path: GeometryObject, is_frenet: bool = False
|
||||||
) -> GeometryObject:
|
) -> GeometryObject:
|
||||||
"""Sweep a profile along a path."""
|
"""Sweep a profile along a path."""
|
||||||
import cadquery as cq
|
from OCP.BRepOffsetAPI import BRepOffsetAPI_MakePipeShell
|
||||||
|
from OCP.TopExp import TopExp_Explorer
|
||||||
|
from OCP.TopAbs import TopAbs_WIRE
|
||||||
|
from OCP.TopoDS import TopoDS
|
||||||
|
|
||||||
profile_obj = self._get_cq_obj(profile)
|
profile_shape = self._get_shape(profile)
|
||||||
path_obj = self._get_cq_obj(path)
|
path_shape = self._get_shape(path)
|
||||||
|
|
||||||
if isinstance(profile_obj, cq.Workplane):
|
def _first_wire(shape):
|
||||||
profile_wire = profile_obj.val()
|
exp = TopExp_Explorer(shape, TopAbs_WIRE)
|
||||||
else:
|
if exp.More():
|
||||||
profile_wire = profile_obj
|
return TopoDS.Wire_s(exp.Current())
|
||||||
|
raise ValueError("No wire found in shape for sweep")
|
||||||
|
|
||||||
if isinstance(path_obj, cq.Workplane):
|
profile_wire = _first_wire(profile_shape)
|
||||||
path_wire = path_obj.val()
|
path_wire = _first_wire(path_shape)
|
||||||
else:
|
|
||||||
path_wire = path_obj
|
|
||||||
|
|
||||||
solid = cq.Solid.sweep(profile_wire, path_wire, is_frenet)
|
pipe = BRepOffsetAPI_MakePipeShell(path_wire)
|
||||||
|
pipe.Add(profile_wire, False, False)
|
||||||
|
if is_frenet:
|
||||||
|
pipe.SetMode(True)
|
||||||
|
pipe.Build()
|
||||||
|
solid = pipe.Shape()
|
||||||
return OCCGeometryObject(solid, {"type": "sweep"})
|
return OCCGeometryObject(solid, {"type": "sweep"})
|
||||||
|
|
||||||
def boolean_union(self, *bodies: GeometryObject) -> GeometryObject:
|
def boolean_union(self, *bodies: GeometryObject) -> GeometryObject:
|
||||||
"""Union multiple bodies."""
|
"""Union multiple bodies."""
|
||||||
import cadquery as cq
|
|
||||||
|
|
||||||
if len(bodies) < 2:
|
if len(bodies) < 2:
|
||||||
return bodies[0] if bodies else OCCGeometryObject(None)
|
return bodies[0] if bodies else OCCGeometryObject(None)
|
||||||
|
|
||||||
@@ -357,12 +332,10 @@ class OCGeometryKernel(GeometryKernel):
|
|||||||
fuse.Build()
|
fuse.Build()
|
||||||
result = fuse.Shape()
|
result = fuse.Shape()
|
||||||
|
|
||||||
return OCCGeometryObject(cq.Shape(result), {"type": "union"})
|
return OCCGeometryObject(result, {"type": "union"})
|
||||||
|
|
||||||
def boolean_difference(self, base: GeometryObject, tool: GeometryObject) -> GeometryObject:
|
def boolean_difference(self, base: GeometryObject, tool: GeometryObject) -> GeometryObject:
|
||||||
"""Subtract tool from base."""
|
"""Subtract tool from base."""
|
||||||
import cadquery as cq
|
|
||||||
|
|
||||||
base_shape = self._get_shape(base)
|
base_shape = self._get_shape(base)
|
||||||
tool_shape = self._get_shape(tool)
|
tool_shape = self._get_shape(tool)
|
||||||
|
|
||||||
@@ -371,12 +344,10 @@ class OCGeometryKernel(GeometryKernel):
|
|||||||
cut = BRepAlgoAPI_Cut(base_shape, tool_shape)
|
cut = BRepAlgoAPI_Cut(base_shape, tool_shape)
|
||||||
cut.Build()
|
cut.Build()
|
||||||
|
|
||||||
return OCCGeometryObject(cq.Shape(cut.Shape()), {"type": "difference"})
|
return OCCGeometryObject(cut.Shape(), {"type": "difference"})
|
||||||
|
|
||||||
def boolean_intersection(self, body1: GeometryObject, body2: GeometryObject) -> GeometryObject:
|
def boolean_intersection(self, body1: GeometryObject, body2: GeometryObject) -> GeometryObject:
|
||||||
"""Intersect two bodies."""
|
"""Intersect two bodies."""
|
||||||
import cadquery as cq
|
|
||||||
|
|
||||||
shape1 = self._get_shape(body1)
|
shape1 = self._get_shape(body1)
|
||||||
shape2 = self._get_shape(body2)
|
shape2 = self._get_shape(body2)
|
||||||
|
|
||||||
@@ -385,27 +356,21 @@ class OCGeometryKernel(GeometryKernel):
|
|||||||
common = BRepAlgoAPI_Common(shape1, shape2)
|
common = BRepAlgoAPI_Common(shape1, shape2)
|
||||||
common.Build()
|
common.Build()
|
||||||
|
|
||||||
return OCCGeometryObject(cq.Shape(common.Shape()), {"type": "intersection"})
|
return OCCGeometryObject(common.Shape(), {"type": "intersection"})
|
||||||
|
|
||||||
def fillet(
|
def fillet(
|
||||||
self, body: GeometryObject, radius: float, edges: Optional[List[Any]] = None
|
self, body: GeometryObject, radius: float, edges: Optional[List[Any]] = None
|
||||||
) -> GeometryObject:
|
) -> GeometryObject:
|
||||||
"""Apply fillet to edges."""
|
"""Apply fillet to edges."""
|
||||||
import cadquery as cq
|
shape = self._get_shape(body)
|
||||||
|
from OCP.BRepFilletAPI import BRepFilletAPI_MakeFillet
|
||||||
|
|
||||||
cq_obj = self._get_cq_obj(body)
|
fillet = BRepFilletAPI_MakeFillet(shape)
|
||||||
|
|
||||||
if isinstance(cq_obj, cq.Workplane):
|
if edges:
|
||||||
if edges:
|
for edge in edges:
|
||||||
result = cq_obj.edges(edges).fillet(radius)
|
fillet.Add(radius, edge)
|
||||||
else:
|
|
||||||
result = cq_obj.edges().fillet(radius)
|
|
||||||
else:
|
else:
|
||||||
shape = self._get_shape(body)
|
|
||||||
from OCP.BRepFilletAPI import BRepFilletAPI_MakeFillet
|
|
||||||
|
|
||||||
fillet = BRepFilletAPI_MakeFillet(shape)
|
|
||||||
|
|
||||||
from OCP.TopExp import TopExp_Explorer
|
from OCP.TopExp import TopExp_Explorer
|
||||||
from OCP.TopAbs import TopAbs_EDGE
|
from OCP.TopAbs import TopAbs_EDGE
|
||||||
|
|
||||||
@@ -414,29 +379,22 @@ class OCGeometryKernel(GeometryKernel):
|
|||||||
fillet.Add(radius, explorer.Current())
|
fillet.Add(radius, explorer.Current())
|
||||||
explorer.Next()
|
explorer.Next()
|
||||||
|
|
||||||
result = cq.Shape(fillet.Shape())
|
fillet.Build()
|
||||||
|
return OCCGeometryObject(fillet.Shape(), {"type": "fillet"})
|
||||||
return OCCGeometryObject(result, {"type": "fillet"})
|
|
||||||
|
|
||||||
def chamfer(
|
def chamfer(
|
||||||
self, body: GeometryObject, size: float, edges: Optional[List[Any]] = None
|
self, body: GeometryObject, size: float, edges: Optional[List[Any]] = None
|
||||||
) -> GeometryObject:
|
) -> GeometryObject:
|
||||||
"""Apply chamfer to edges."""
|
"""Apply chamfer to edges."""
|
||||||
import cadquery as cq
|
shape = self._get_shape(body)
|
||||||
|
from OCP.BRepFilletAPI import BRepFilletAPI_MakeChamfer
|
||||||
|
|
||||||
cq_obj = self._get_cq_obj(body)
|
chamfer = BRepFilletAPI_MakeChamfer(shape)
|
||||||
|
|
||||||
if isinstance(cq_obj, cq.Workplane):
|
if edges:
|
||||||
if edges:
|
for edge in edges:
|
||||||
result = cq_obj.edges(edges).chamfer(size)
|
chamfer.Add(size, edge)
|
||||||
else:
|
|
||||||
result = cq_obj.edges().chamfer(size)
|
|
||||||
else:
|
else:
|
||||||
shape = self._get_shape(body)
|
|
||||||
from OCP.BRepFilletAPI import BRepFilletAPI_MakeChamfer
|
|
||||||
|
|
||||||
chamfer = BRepFilletAPI_MakeChamfer(shape)
|
|
||||||
|
|
||||||
from OCP.TopExp import TopExp_Explorer
|
from OCP.TopExp import TopExp_Explorer
|
||||||
from OCP.TopAbs import TopAbs_EDGE
|
from OCP.TopAbs import TopAbs_EDGE
|
||||||
|
|
||||||
@@ -445,71 +403,47 @@ class OCGeometryKernel(GeometryKernel):
|
|||||||
chamfer.Add(size, explorer.Current())
|
chamfer.Add(size, explorer.Current())
|
||||||
explorer.Next()
|
explorer.Next()
|
||||||
|
|
||||||
result = cq.Shape(chamfer.Shape())
|
chamfer.Build()
|
||||||
|
return OCCGeometryObject(chamfer.Shape(), {"type": "chamfer"})
|
||||||
return OCCGeometryObject(result, {"type": "chamfer"})
|
|
||||||
|
|
||||||
def shell(
|
def shell(
|
||||||
self, body: GeometryObject, thickness: float, faces_to_remove: Optional[List[Any]] = None
|
self, body: GeometryObject, thickness: float, faces_to_remove: Optional[List[Any]] = None
|
||||||
) -> GeometryObject:
|
) -> GeometryObject:
|
||||||
"""Create a shell (hollow body)."""
|
"""Create a shell (hollow body)."""
|
||||||
import cadquery as cq
|
shape = self._get_shape(body)
|
||||||
|
from OCP.BRepOffsetAPI import BRepOffsetAPI_MakeThickSolid
|
||||||
|
from OCP.TopTools import TopTools_ListOfShape
|
||||||
|
|
||||||
cq_obj = self._get_cq_obj(body)
|
faces_list = TopTools_ListOfShape()
|
||||||
|
if faces_to_remove:
|
||||||
|
for face in faces_to_remove:
|
||||||
|
faces_list.Append(face)
|
||||||
|
|
||||||
if isinstance(cq_obj, cq.Workplane):
|
shell_maker = BRepOffsetAPI_MakeThickSolid()
|
||||||
if faces_to_remove:
|
shell_maker.MakeThickSolidByJoin(shape, faces_list, thickness, 0.001)
|
||||||
result = cq_obj.faces(faces_to_remove).shell(thickness)
|
shell_maker.Build()
|
||||||
else:
|
return OCCGeometryObject(shell_maker.Shape(), {"type": "shell"})
|
||||||
result = cq_obj.shell(thickness)
|
|
||||||
else:
|
|
||||||
shape = self._get_shape(body)
|
|
||||||
from OCP.BRepOffsetAPI import BRepOffsetAPI_MakeThickSolid
|
|
||||||
from OCP.TopTools import TopTools_ListOfShape
|
|
||||||
|
|
||||||
faces_list = TopTools_ListOfShape()
|
|
||||||
if faces_to_remove:
|
|
||||||
for face in faces_to_remove:
|
|
||||||
faces_list.Append(face)
|
|
||||||
|
|
||||||
shell_maker = BRepOffsetAPI_MakeThickSolid()
|
|
||||||
shell_maker.MakeThickSolidByJoin(shape, faces_list, thickness, 0.001)
|
|
||||||
shell_maker.Build()
|
|
||||||
result = cq.Shape(shell_maker.Shape())
|
|
||||||
|
|
||||||
return OCCGeometryObject(result, {"type": "shell"})
|
|
||||||
|
|
||||||
def offset(self, face: GeometryObject, distance: float) -> GeometryObject:
|
def offset(self, face: GeometryObject, distance: float) -> GeometryObject:
|
||||||
"""Offset a face or surface."""
|
"""Offset a face or surface."""
|
||||||
import cadquery as cq
|
|
||||||
|
|
||||||
shape = self._get_shape(face)
|
shape = self._get_shape(face)
|
||||||
from OCP.BRepOffsetAPI import BRepOffsetAPI_MakeOffset
|
from OCP.BRepOffsetAPI import BRepOffsetAPI_MakeOffset
|
||||||
|
|
||||||
offset_maker = BRepOffsetAPI_MakeOffset(shape, False)
|
offset_maker = BRepOffsetAPI_MakeOffset(shape, False)
|
||||||
offset_maker.Perform(distance)
|
offset_maker.Perform(distance)
|
||||||
|
|
||||||
return OCCGeometryObject(cq.Shape(offset_maker.Shape()), {"type": "offset"})
|
return OCCGeometryObject(offset_maker.Shape(), {"type": "offset"})
|
||||||
|
|
||||||
def translate(self, body: GeometryObject, vector: Tuple[float, float, float]) -> GeometryObject:
|
def translate(self, body: GeometryObject, vector: Tuple[float, float, float]) -> GeometryObject:
|
||||||
"""Translate a body."""
|
"""Translate a body."""
|
||||||
import cadquery as cq
|
shape = self._get_shape(body)
|
||||||
|
from OCP.BRepBuilderAPI import BRepBuilderAPI_Transform
|
||||||
|
from OCP.gp import gp_Trsf, gp_Vec
|
||||||
|
|
||||||
cq_obj = self._get_cq_obj(body)
|
transform = gp_Trsf()
|
||||||
|
transform.SetTranslation(gp_Vec(*vector))
|
||||||
if isinstance(cq_obj, cq.Workplane):
|
transformer = BRepBuilderAPI_Transform(shape, transform)
|
||||||
result = cq_obj.translate(vector)
|
return OCCGeometryObject(transformer.Shape(), {"type": "translated"})
|
||||||
else:
|
|
||||||
shape = self._get_shape(body)
|
|
||||||
from OCP.BRepBuilderAPI import BRepBuilderAPI_Transform
|
|
||||||
from OCP.gp import gp_Trsf, gp_Vec
|
|
||||||
|
|
||||||
transform = gp_Trsf()
|
|
||||||
transform.SetTranslation(gp_Vec(*vector))
|
|
||||||
transformer = BRepBuilderAPI_Transform(shape, transform)
|
|
||||||
result = cq.Shape(transformer.Shape())
|
|
||||||
|
|
||||||
return OCCGeometryObject(result, {"type": "translated"})
|
|
||||||
|
|
||||||
def rotate(
|
def rotate(
|
||||||
self,
|
self,
|
||||||
@@ -519,30 +453,19 @@ class OCGeometryKernel(GeometryKernel):
|
|||||||
origin: Tuple[float, float, float] = (0, 0, 0),
|
origin: Tuple[float, float, float] = (0, 0, 0),
|
||||||
) -> GeometryObject:
|
) -> GeometryObject:
|
||||||
"""Rotate a body around an axis."""
|
"""Rotate a body around an axis."""
|
||||||
import cadquery as cq
|
|
||||||
import math
|
import math
|
||||||
|
shape = self._get_shape(body)
|
||||||
|
from OCP.BRepBuilderAPI import BRepBuilderAPI_Transform
|
||||||
|
from OCP.gp import gp_Trsf, gp_Ax1, gp_Pnt, gp_Dir
|
||||||
|
|
||||||
cq_obj = self._get_cq_obj(body)
|
ax1 = gp_Ax1(gp_Pnt(*origin), gp_Dir(*axis))
|
||||||
|
transform = gp_Trsf()
|
||||||
if isinstance(cq_obj, cq.Workplane):
|
transform.SetRotation(ax1, angle)
|
||||||
result = cq_obj.rotate(origin, axis, math.degrees(angle))
|
transformer = BRepBuilderAPI_Transform(shape, transform)
|
||||||
else:
|
return OCCGeometryObject(transformer.Shape(), {"type": "rotated"})
|
||||||
shape = self._get_shape(body)
|
|
||||||
from OCP.BRepBuilderAPI import BRepBuilderAPI_Transform
|
|
||||||
from OCP.gp import gp_Trsf, gp_Ax1, gp_Pnt, gp_Dir, gp_Vec
|
|
||||||
|
|
||||||
ax1 = gp_Ax1(gp_Pnt(*origin), gp_Dir(*axis))
|
|
||||||
transform = gp_Trsf()
|
|
||||||
transform.SetRotation(ax1, angle)
|
|
||||||
transformer = BRepBuilderAPI_Transform(shape, transform)
|
|
||||||
result = cq.Shape(transformer.Shape())
|
|
||||||
|
|
||||||
return OCCGeometryObject(result, {"type": "rotated"})
|
|
||||||
|
|
||||||
def scale(self, body: GeometryObject, factor: float) -> GeometryObject:
|
def scale(self, body: GeometryObject, factor: float) -> GeometryObject:
|
||||||
"""Scale a body uniformly."""
|
"""Scale a body uniformly."""
|
||||||
import cadquery as cq
|
|
||||||
|
|
||||||
shape = self._get_shape(body)
|
shape = self._get_shape(body)
|
||||||
from OCP.BRepBuilderAPI import BRepBuilderAPI_Transform
|
from OCP.BRepBuilderAPI import BRepBuilderAPI_Transform
|
||||||
from OCP.gp import gp_Trsf
|
from OCP.gp import gp_Trsf
|
||||||
@@ -551,7 +474,7 @@ class OCGeometryKernel(GeometryKernel):
|
|||||||
transform.SetScale(factor)
|
transform.SetScale(factor)
|
||||||
transformer = BRepBuilderAPI_Transform(shape, transform)
|
transformer = BRepBuilderAPI_Transform(shape, transform)
|
||||||
|
|
||||||
return OCCGeometryObject(cq.Shape(transformer.Shape()), {"type": "scaled"})
|
return OCCGeometryObject(transformer.Shape(), {"type": "scaled"})
|
||||||
|
|
||||||
def mirror(
|
def mirror(
|
||||||
self,
|
self,
|
||||||
@@ -560,8 +483,6 @@ class OCGeometryKernel(GeometryKernel):
|
|||||||
plane_origin: Tuple[float, float, float] = (0, 0, 0),
|
plane_origin: Tuple[float, float, float] = (0, 0, 0),
|
||||||
) -> GeometryObject:
|
) -> GeometryObject:
|
||||||
"""Mirror a body across a plane."""
|
"""Mirror a body across a plane."""
|
||||||
import cadquery as cq
|
|
||||||
|
|
||||||
shape = self._get_shape(body)
|
shape = self._get_shape(body)
|
||||||
from OCP.BRepBuilderAPI import BRepBuilderAPI_Transform
|
from OCP.BRepBuilderAPI import BRepBuilderAPI_Transform
|
||||||
from OCP.gp import gp_Trsf, gp_Ax2, gp_Pnt, gp_Dir
|
from OCP.gp import gp_Trsf, gp_Ax2, gp_Pnt, gp_Dir
|
||||||
@@ -571,19 +492,12 @@ class OCGeometryKernel(GeometryKernel):
|
|||||||
transform.SetMirror(ax2)
|
transform.SetMirror(ax2)
|
||||||
transformer = BRepBuilderAPI_Transform(shape, transform)
|
transformer = BRepBuilderAPI_Transform(shape, transform)
|
||||||
|
|
||||||
return OCCGeometryObject(cq.Shape(transformer.Shape()), {"type": "mirrored"})
|
return OCCGeometryObject(transformer.Shape(), {"type": "mirrored"})
|
||||||
|
|
||||||
def export_step(self, body: GeometryObject, filepath: str, schema: str = "AP214") -> bool:
|
def export_step(self, body: GeometryObject, filepath: str, schema: str = "AP214") -> bool:
|
||||||
"""Export to STEP format."""
|
"""Export to STEP format."""
|
||||||
try:
|
try:
|
||||||
import cadquery as cq
|
|
||||||
|
|
||||||
shape = self._get_shape(body)
|
shape = self._get_shape(body)
|
||||||
|
|
||||||
if hasattr(shape, "exportStep"):
|
|
||||||
shape.exportStep(filepath)
|
|
||||||
return True
|
|
||||||
|
|
||||||
from OCP.STEPControl import STEPControl_Writer, STEPControl_AsIs
|
from OCP.STEPControl import STEPControl_Writer, STEPControl_AsIs
|
||||||
from OCP.Interface import Interface_Static
|
from OCP.Interface import Interface_Static
|
||||||
|
|
||||||
@@ -594,8 +508,7 @@ class OCGeometryKernel(GeometryKernel):
|
|||||||
Interface_Static.SetCVal_s("write.step.schema", "AP203")
|
Interface_Static.SetCVal_s("write.step.schema", "AP203")
|
||||||
|
|
||||||
writer.Transfer(shape, STEPControl_AsIs)
|
writer.Transfer(shape, STEPControl_AsIs)
|
||||||
writer.Write(filepath)
|
return writer.Write(filepath)
|
||||||
return True
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"STEP export error: {e}")
|
print(f"STEP export error: {e}")
|
||||||
return False
|
return False
|
||||||
@@ -603,18 +516,14 @@ class OCGeometryKernel(GeometryKernel):
|
|||||||
def export_iges(self, body: GeometryObject, filepath: str) -> bool:
|
def export_iges(self, body: GeometryObject, filepath: str) -> bool:
|
||||||
"""Export to IGES format."""
|
"""Export to IGES format."""
|
||||||
try:
|
try:
|
||||||
import cadquery as cq
|
|
||||||
|
|
||||||
shape = self._get_shape(body)
|
shape = self._get_shape(body)
|
||||||
|
|
||||||
from OCP.IGESControl import IGESControl_Writer
|
from OCP.IGESControl import IGESControl_Writer
|
||||||
from OCP.Interface import Interface_Static
|
from OCP.Interface import Interface_Static
|
||||||
|
|
||||||
Interface_Static.SetCVal_s("write.iges.schema", "5.3")
|
Interface_Static.SetCVal_s("write.iges.schema", "5.3")
|
||||||
writer = IGESControl_Writer()
|
writer = IGESControl_Writer()
|
||||||
writer.AddShape(shape)
|
writer.AddShape(shape)
|
||||||
writer.Write(filepath)
|
return writer.Write(filepath)
|
||||||
return True
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"IGES export error: {e}")
|
print(f"IGES export error: {e}")
|
||||||
return False
|
return False
|
||||||
@@ -624,14 +533,7 @@ class OCGeometryKernel(GeometryKernel):
|
|||||||
) -> bool:
|
) -> bool:
|
||||||
"""Export to STL format."""
|
"""Export to STL format."""
|
||||||
try:
|
try:
|
||||||
import cadquery as cq
|
|
||||||
|
|
||||||
shape = self._get_shape(body)
|
shape = self._get_shape(body)
|
||||||
|
|
||||||
if hasattr(shape, "exportStl"):
|
|
||||||
shape.exportStl(filepath, tolerance)
|
|
||||||
return True
|
|
||||||
|
|
||||||
from OCP.StlAPI import StlAPI_Writer
|
from OCP.StlAPI import StlAPI_Writer
|
||||||
from OCP.BRepMesh import BRepMesh_IncrementalMesh
|
from OCP.BRepMesh import BRepMesh_IncrementalMesh
|
||||||
|
|
||||||
@@ -640,23 +542,28 @@ class OCGeometryKernel(GeometryKernel):
|
|||||||
|
|
||||||
writer = StlAPI_Writer()
|
writer = StlAPI_Writer()
|
||||||
writer.ASCIIMode = ascii_mode
|
writer.ASCIIMode = ascii_mode
|
||||||
writer.Write(shape, filepath)
|
return writer.Write(shape, filepath)
|
||||||
return True
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"STL export error: {e}")
|
print(f"STL export error: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def import_step(self, filepath: str) -> GeometryObject:
|
def import_step(self, filepath: str) -> GeometryObject:
|
||||||
"""Import from STEP format."""
|
"""Import from STEP format."""
|
||||||
import cadquery as cq
|
from OCP.STEPControl import STEPControl_Reader
|
||||||
|
from OCP.IFSelect import IFSelect_RetDone
|
||||||
|
|
||||||
result = cq.importers.importStep(filepath)
|
reader = STEPControl_Reader()
|
||||||
return OCCGeometryObject(result, {"type": "imported_step"})
|
status = reader.ReadFile(filepath)
|
||||||
|
|
||||||
|
if status != IFSelect_RetDone:
|
||||||
|
raise ValueError(f"Failed to read STEP file: {filepath}")
|
||||||
|
|
||||||
|
reader.TransferRoots()
|
||||||
|
shape = reader.OneShape()
|
||||||
|
return OCCGeometryObject(shape, {"type": "imported_step"})
|
||||||
|
|
||||||
def import_iges(self, filepath: str) -> GeometryObject:
|
def import_iges(self, filepath: str) -> GeometryObject:
|
||||||
"""Import from IGES format."""
|
"""Import from IGES format."""
|
||||||
import cadquery as cq
|
|
||||||
|
|
||||||
from OCP.IGESControl import IGESControl_Reader
|
from OCP.IGESControl import IGESControl_Reader
|
||||||
from OCP.IFSelect import IFSelect_RetDone
|
from OCP.IFSelect import IFSelect_RetDone
|
||||||
|
|
||||||
@@ -669,26 +576,13 @@ class OCGeometryKernel(GeometryKernel):
|
|||||||
reader.TransferRoots()
|
reader.TransferRoots()
|
||||||
shape = reader.OneShape()
|
shape = reader.OneShape()
|
||||||
|
|
||||||
return OCCGeometryObject(cq.Shape(shape), {"type": "imported_iges"})
|
return OCCGeometryObject(shape, {"type": "imported_iges"})
|
||||||
|
|
||||||
def get_mesh(
|
def get_mesh(
|
||||||
self, body: GeometryObject, tolerance: float = 0.1
|
self, body: GeometryObject, tolerance: float = 0.1
|
||||||
) -> Tuple[np.ndarray, np.ndarray]:
|
) -> Tuple[np.ndarray, np.ndarray]:
|
||||||
"""Get triangulated mesh for rendering."""
|
"""Get triangulated mesh for rendering."""
|
||||||
import cadquery as cq
|
shape = self._get_shape(body)
|
||||||
|
|
||||||
cq_obj = self._get_cq_obj(body)
|
|
||||||
|
|
||||||
if isinstance(cq_obj, cq.Workplane):
|
|
||||||
shape = cq_obj.val()
|
|
||||||
if hasattr(shape, "wrapped"):
|
|
||||||
shape = shape.wrapped
|
|
||||||
else:
|
|
||||||
shape = self._get_shape(body)
|
|
||||||
|
|
||||||
if hasattr(shape, "tessellate"):
|
|
||||||
vertices, faces = shape.tessellate(tolerance)
|
|
||||||
return np.array(vertices), np.array(faces)
|
|
||||||
|
|
||||||
from OCP.BRepMesh import BRepMesh_IncrementalMesh
|
from OCP.BRepMesh import BRepMesh_IncrementalMesh
|
||||||
from OCP.TopExp import TopExp_Explorer
|
from OCP.TopExp import TopExp_Explorer
|
||||||
@@ -745,8 +639,6 @@ class OCGeometryKernel(GeometryKernel):
|
|||||||
|
|
||||||
def get_edges(self, body: GeometryObject) -> Tuple[np.ndarray, np.ndarray]:
|
def get_edges(self, body: GeometryObject) -> Tuple[np.ndarray, np.ndarray]:
|
||||||
"""Get edge wireframe for rendering."""
|
"""Get edge wireframe for rendering."""
|
||||||
import cadquery as cq
|
|
||||||
|
|
||||||
shape = self._get_shape(body)
|
shape = self._get_shape(body)
|
||||||
|
|
||||||
from OCP.TopExp import TopExp_Explorer
|
from OCP.TopExp import TopExp_Explorer
|
||||||
@@ -801,8 +693,6 @@ class OCGeometryKernel(GeometryKernel):
|
|||||||
|
|
||||||
def get_bounding_box(self, body: GeometryObject) -> Tuple[Point3D, Point3D]:
|
def get_bounding_box(self, body: GeometryObject) -> Tuple[Point3D, Point3D]:
|
||||||
"""Get the bounding box of a body."""
|
"""Get the bounding box of a body."""
|
||||||
import cadquery as cq
|
|
||||||
|
|
||||||
shape = self._get_shape(body)
|
shape = self._get_shape(body)
|
||||||
|
|
||||||
from OCP.Bnd import Bnd_Box
|
from OCP.Bnd import Bnd_Box
|
||||||
@@ -817,8 +707,6 @@ class OCGeometryKernel(GeometryKernel):
|
|||||||
|
|
||||||
def get_volume(self, body: GeometryObject) -> float:
|
def get_volume(self, body: GeometryObject) -> float:
|
||||||
"""Calculate the volume of a solid body."""
|
"""Calculate the volume of a solid body."""
|
||||||
import cadquery as cq
|
|
||||||
|
|
||||||
shape = self._get_shape(body)
|
shape = self._get_shape(body)
|
||||||
|
|
||||||
from OCP.GProp import GProp_GProps
|
from OCP.GProp import GProp_GProps
|
||||||
@@ -831,8 +719,6 @@ class OCGeometryKernel(GeometryKernel):
|
|||||||
|
|
||||||
def get_surface_area(self, body: GeometryObject) -> float:
|
def get_surface_area(self, body: GeometryObject) -> float:
|
||||||
"""Calculate the surface area of a body."""
|
"""Calculate the surface area of a body."""
|
||||||
import cadquery as cq
|
|
||||||
|
|
||||||
shape = self._get_shape(body)
|
shape = self._get_shape(body)
|
||||||
|
|
||||||
from OCP.GProp import GProp_GProps
|
from OCP.GProp import GProp_GProps
|
||||||
@@ -845,8 +731,6 @@ class OCGeometryKernel(GeometryKernel):
|
|||||||
|
|
||||||
def get_center_of_mass(self, body: GeometryObject) -> Point3D:
|
def get_center_of_mass(self, body: GeometryObject) -> Point3D:
|
||||||
"""Calculate the center of mass of a solid body."""
|
"""Calculate the center of mass of a solid body."""
|
||||||
import cadquery as cq
|
|
||||||
|
|
||||||
shape = self._get_shape(body)
|
shape = self._get_shape(body)
|
||||||
|
|
||||||
from OCP.GProp import GProp_GProps
|
from OCP.GProp import GProp_GProps
|
||||||
|
|||||||
@@ -74,6 +74,14 @@ class OCCSketch(SketchInterface):
|
|||||||
# • clear them as a group when the source face is removed
|
# • clear them as a group when the source face is removed
|
||||||
self._external_entity_ids: set = set()
|
self._external_entity_ids: set = set()
|
||||||
|
|
||||||
|
# Centerline entity ids (X and Y reference axes through origin).
|
||||||
|
# These are construction lines that span the sketch and are used
|
||||||
|
# as reference axes for constraining geometry. They are:
|
||||||
|
# • fixed in the solver (never move)
|
||||||
|
# • marked is_construction (excluded from profile detection)
|
||||||
|
# • non-deletable
|
||||||
|
self._centerline_ids: set = set()
|
||||||
|
|
||||||
# Track first point as dragged/fixed for solver stability
|
# Track first point as dragged/fixed for solver stability
|
||||||
self._first_point_id: Optional[int] = None
|
self._first_point_id: Optional[int] = None
|
||||||
|
|
||||||
@@ -259,8 +267,14 @@ class OCCSketch(SketchInterface):
|
|||||||
radius: float,
|
radius: float,
|
||||||
start_point: SketchEntity,
|
start_point: SketchEntity,
|
||||||
end_point: SketchEntity,
|
end_point: SketchEntity,
|
||||||
|
sweep: Optional[float] = None,
|
||||||
) -> OCCSketchEntity:
|
) -> OCCSketchEntity:
|
||||||
"""Add an arc (tracked only)."""
|
"""Add an arc (tracked only).
|
||||||
|
|
||||||
|
*sweep* is the signed angular span in radians (positive = CCW, negative = CW).
|
||||||
|
When *None* the rendering will infer the shortest path between start and end.
|
||||||
|
"""
|
||||||
|
import math
|
||||||
entity_id = self._next_id()
|
entity_id = self._next_id()
|
||||||
|
|
||||||
center_entity = self._entities.get(center.id)
|
center_entity = self._entities.get(center.id)
|
||||||
@@ -274,10 +288,26 @@ class OCCSketch(SketchInterface):
|
|||||||
sx, sy = start_entity.geometry
|
sx, sy = start_entity.geometry
|
||||||
ex, ey = end_entity.geometry
|
ex, ey = end_entity.geometry
|
||||||
|
|
||||||
|
# Infer sweep from geometry when not provided.
|
||||||
|
if sweep is None:
|
||||||
|
sa = math.atan2(sy - cy, sx - cx)
|
||||||
|
ea = math.atan2(ey - cy, ex - cx)
|
||||||
|
sweep = ea - sa
|
||||||
|
while sweep > math.pi:
|
||||||
|
sweep -= 2 * math.pi
|
||||||
|
while sweep < -math.pi:
|
||||||
|
sweep += 2 * math.pi
|
||||||
|
|
||||||
entity = OCCSketchEntity(
|
entity = OCCSketchEntity(
|
||||||
entity_id=entity_id,
|
entity_id=entity_id,
|
||||||
entity_type="arc",
|
entity_type="arc",
|
||||||
geometry={"center": (cx, cy), "radius": radius, "start": (sx, sy), "end": (ex, ey)},
|
geometry={
|
||||||
|
"center": (cx, cy),
|
||||||
|
"radius": radius,
|
||||||
|
"start": (sx, sy),
|
||||||
|
"end": (ex, ey),
|
||||||
|
"sweep": sweep,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
self._entities[entity_id] = entity
|
self._entities[entity_id] = entity
|
||||||
@@ -286,6 +316,7 @@ class OCCSketch(SketchInterface):
|
|||||||
"start": start_point.id,
|
"start": start_point.id,
|
||||||
"end": end_point.id,
|
"end": end_point.id,
|
||||||
"radius": radius,
|
"radius": radius,
|
||||||
|
"sweep": sweep,
|
||||||
}
|
}
|
||||||
|
|
||||||
return entity
|
return entity
|
||||||
@@ -432,6 +463,67 @@ class OCCSketch(SketchInterface):
|
|||||||
"""Return the set of external (underlay) entity ids currently in the sketch."""
|
"""Return the set of external (underlay) entity ids currently in the sketch."""
|
||||||
return set(self._external_entity_ids)
|
return set(self._external_entity_ids)
|
||||||
|
|
||||||
|
# ── Centerlines (X and Y reference axes) ────────────────────────────
|
||||||
|
|
||||||
|
_CENTERLINE_EXTENT: float = 10000.0 # large enough to span any sketch
|
||||||
|
|
||||||
|
def add_centerlines(self) -> None:
|
||||||
|
"""Add X (horizontal) and Y (vertical) centerlines through the origin.
|
||||||
|
|
||||||
|
These construction lines serve as reference axes for constraining
|
||||||
|
sketch geometry. They are:
|
||||||
|
• Fixed in the solver (never move)
|
||||||
|
• Marked is_construction (excluded from profile / face detection)
|
||||||
|
• Non-deletable
|
||||||
|
• Available as constraint targets (snap, coincident, distance,
|
||||||
|
symmetric, horizontal, vertical, parallel, perpendicular)
|
||||||
|
|
||||||
|
The X centerline runs horizontal (left→right) through origin and
|
||||||
|
the Y centerline runs vertical (bottom→top) through origin. Both
|
||||||
|
extend to ``_CENTERLINE_EXTENT`` in both directions so they span
|
||||||
|
any realistic sketch. If centerlines have already been added this
|
||||||
|
is a no-op.
|
||||||
|
"""
|
||||||
|
if self._centerline_ids:
|
||||||
|
return
|
||||||
|
|
||||||
|
HALF = self._CENTERLINE_EXTENT
|
||||||
|
|
||||||
|
# Origin point — auto-fixed as the first point in the solver
|
||||||
|
origin = self.add_point(0.0, 0.0)
|
||||||
|
origin.is_construction = True
|
||||||
|
|
||||||
|
# X centerline (horizontal)
|
||||||
|
xl = self.add_point(-HALF, 0.0)
|
||||||
|
xl.is_construction = True
|
||||||
|
xr = self.add_point(HALF, 0.0)
|
||||||
|
xr.is_construction = True
|
||||||
|
xline = self.add_line(xl, xr)
|
||||||
|
xline.is_construction = True
|
||||||
|
self.constrain_horizontal(xline)
|
||||||
|
self.constrain_fixed(xl)
|
||||||
|
|
||||||
|
# Y centerline (vertical)
|
||||||
|
yb = self.add_point(0.0, -HALF)
|
||||||
|
yb.is_construction = True
|
||||||
|
yt = self.add_point(0.0, HALF)
|
||||||
|
yt.is_construction = True
|
||||||
|
yline = self.add_line(yb, yt)
|
||||||
|
yline.is_construction = True
|
||||||
|
self.constrain_vertical(yline)
|
||||||
|
self.constrain_fixed(yb)
|
||||||
|
|
||||||
|
self._centerline_ids = {
|
||||||
|
origin.id, xl.id, xr.id, xline.id,
|
||||||
|
yb.id, yt.id, yline.id,
|
||||||
|
}
|
||||||
|
|
||||||
|
self.solve()
|
||||||
|
|
||||||
|
def get_centerline_ids(self) -> set:
|
||||||
|
"""Return the set of centerline entity ids currently in the sketch."""
|
||||||
|
return set(self._centerline_ids)
|
||||||
|
|
||||||
def add_rectangle(
|
def add_rectangle(
|
||||||
self, corner1: Tuple[float, float], corner2: Tuple[float, float]
|
self, corner1: Tuple[float, float], corner2: Tuple[float, float]
|
||||||
) -> List[OCCSketchEntity]:
|
) -> List[OCCSketchEntity]:
|
||||||
@@ -534,6 +626,17 @@ class OCCSketch(SketchInterface):
|
|||||||
elif ctype == "equal_radius":
|
elif ctype == "equal_radius":
|
||||||
# tracked only (no solver entity)
|
# tracked only (no solver entity)
|
||||||
pass
|
pass
|
||||||
|
elif ctype == "diameter":
|
||||||
|
# Update circle radius in sketch data
|
||||||
|
circle_id = ids[0]
|
||||||
|
if circle_id in self._circles:
|
||||||
|
center_id, _ = self._circles[circle_id]
|
||||||
|
radius = params[0] / 2.0
|
||||||
|
self._circles[circle_id] = (center_id, radius)
|
||||||
|
ent = self._entities.get(circle_id)
|
||||||
|
if ent is not None and ent.geometry is not None:
|
||||||
|
cx, cy = ent.geometry[0] if isinstance(ent.geometry[0], tuple) else ent.geometry
|
||||||
|
ent.geometry = ((cx, cy), radius)
|
||||||
else:
|
else:
|
||||||
return False
|
return False
|
||||||
return True
|
return True
|
||||||
@@ -696,6 +799,21 @@ class OCCSketch(SketchInterface):
|
|||||||
self._record_constraint("equal_radius", (circle1.id, circle2.id))
|
self._record_constraint("equal_radius", (circle1.id, circle2.id))
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
def constrain_diameter(self, circle: SketchEntity, diameter: float) -> bool:
|
||||||
|
"""Set the diameter of a circle."""
|
||||||
|
radius = diameter / 2.0
|
||||||
|
# Update the circle's radius in the sketch
|
||||||
|
if circle.id in self._circles:
|
||||||
|
center_id, _ = self._circles[circle.id]
|
||||||
|
self._circles[circle.id] = (center_id, radius)
|
||||||
|
# Update the entity geometry
|
||||||
|
ent = self._entities.get(circle.id)
|
||||||
|
if ent is not None:
|
||||||
|
cx, cy = ent.geometry
|
||||||
|
ent.geometry = ((cx, cy), radius)
|
||||||
|
self._record_constraint("diameter", (circle.id,), (diameter,))
|
||||||
|
return True
|
||||||
|
|
||||||
def constrain_fixed(self, entity: SketchEntity) -> bool:
|
def constrain_fixed(self, entity: SketchEntity) -> bool:
|
||||||
"""Fix an entity in place via dragged constraint."""
|
"""Fix an entity in place via dragged constraint."""
|
||||||
ent = self._entities.get(entity.id)
|
ent = self._entities.get(entity.id)
|
||||||
@@ -838,7 +956,11 @@ class OCCSketch(SketchInterface):
|
|||||||
if entity_id in self._external_entity_ids:
|
if entity_id in self._external_entity_ids:
|
||||||
continue
|
continue
|
||||||
center_entity = self._entities.get(center_id)
|
center_entity = self._entities.get(center_id)
|
||||||
|
circle_ent = self._entities.get(entity_id)
|
||||||
if center_entity and center_entity.geometry and not center_entity.is_external:
|
if center_entity and center_entity.geometry and not center_entity.is_external:
|
||||||
|
# Skip construction circles — they're reference geometry.
|
||||||
|
if circle_ent is not None and circle_ent.is_construction:
|
||||||
|
continue
|
||||||
cx, cy = center_entity.geometry
|
cx, cy = center_entity.geometry
|
||||||
face_dict = {
|
face_dict = {
|
||||||
"outer": {"type": "circle", "center": (cx, cy), "radius": radius},
|
"outer": {"type": "circle", "center": (cx, cy), "radius": radius},
|
||||||
@@ -880,13 +1002,13 @@ class OCCSketch(SketchInterface):
|
|||||||
def get_polygon_points(self) -> List[Point2D]:
|
def get_polygon_points(self) -> List[Point2D]:
|
||||||
"""Get ordered polygon points from connected lines (uses solved positions).
|
"""Get ordered polygon points from connected lines (uses solved positions).
|
||||||
|
|
||||||
External (underlay) lines are skipped — they are reference geometry
|
External (underlay) and construction lines are skipped — they are
|
||||||
only, not part of the sketch profile.
|
reference geometry only, not part of the sketch profile.
|
||||||
"""
|
"""
|
||||||
adjacency: Dict[Tuple[float, float], List[Tuple[float, float]]] = {}
|
adjacency: Dict[Tuple[float, float], List[Tuple[float, float]]] = {}
|
||||||
|
|
||||||
for entity in self._entities.values():
|
for entity in self._entities.values():
|
||||||
if entity.entity_type == "line" and entity.geometry and not entity.is_external:
|
if entity.entity_type == "line" and entity.geometry and not entity.is_external and not entity.is_construction:
|
||||||
p1, p2 = entity.geometry
|
p1, p2 = entity.geometry
|
||||||
if p1 not in adjacency:
|
if p1 not in adjacency:
|
||||||
adjacency[p1] = []
|
adjacency[p1] = []
|
||||||
@@ -926,21 +1048,63 @@ class OCCSketch(SketchInterface):
|
|||||||
def _line_segments(self) -> List[Tuple[Tuple[float, float], Tuple[float, float]]]:
|
def _line_segments(self) -> List[Tuple[Tuple[float, float], Tuple[float, float]]]:
|
||||||
"""Current line segments as world-coordinate tuples (uses solved positions).
|
"""Current line segments as world-coordinate tuples (uses solved positions).
|
||||||
|
|
||||||
External (underlay) lines are excluded: they're reference geometry
|
Returns both straight line segments AND tessellated arc segments so
|
||||||
projected from a 3D face, not user-drawn sketch profile, and must
|
that arcs participate in closed-loop / face detection. Construction
|
||||||
not contribute to detect_faces / get_geometry / extrusion.
|
and external entities are excluded — they're reference geometry and
|
||||||
|
must not affect the sketch profile.
|
||||||
|
|
||||||
|
Tessellation density: roughly 12 segments per π radians of arc sweep,
|
||||||
|
which gives smooth-looking closed loops for face detection.
|
||||||
"""
|
"""
|
||||||
segs: List[Tuple[Tuple[float, float], Tuple[float, float]]] = []
|
segs: List[Tuple[Tuple[float, float], Tuple[float, float]]] = []
|
||||||
|
|
||||||
|
# ── Straight line segments ──
|
||||||
for line_id, (sid, eid2) in self._lines.items():
|
for line_id, (sid, eid2) in self._lines.items():
|
||||||
# Skip external/underlay lines — they live in the solver for
|
|
||||||
# constraint referencing, but are not part of the sketch profile.
|
|
||||||
if line_id in self._external_entity_ids:
|
if line_id in self._external_entity_ids:
|
||||||
continue
|
continue
|
||||||
|
line_ent = self._entities.get(line_id)
|
||||||
|
if line_ent is not None and line_ent.is_construction:
|
||||||
|
continue
|
||||||
s_ent = self._entities.get(sid)
|
s_ent = self._entities.get(sid)
|
||||||
e_ent = self._entities.get(eid2)
|
e_ent = self._entities.get(eid2)
|
||||||
if s_ent and e_ent and s_ent.geometry and e_ent.geometry:
|
if s_ent and e_ent and s_ent.geometry and e_ent.geometry:
|
||||||
segs.append(((float(s_ent.geometry[0]), float(s_ent.geometry[1])),
|
segs.append(((float(s_ent.geometry[0]), float(s_ent.geometry[1])),
|
||||||
(float(e_ent.geometry[0]), float(e_ent.geometry[1]))))
|
(float(e_ent.geometry[0]), float(e_ent.geometry[1]))))
|
||||||
|
|
||||||
|
# ── Arc segments (tessellated) ──
|
||||||
|
for arc_id, arc_data in self._arcs.items():
|
||||||
|
arc_ent = self._entities.get(arc_id)
|
||||||
|
if arc_ent is not None and arc_ent.is_construction:
|
||||||
|
continue
|
||||||
|
center_id = arc_data.get("center")
|
||||||
|
start_id = arc_data.get("start")
|
||||||
|
end_id = arc_data.get("end")
|
||||||
|
radius = arc_data.get("radius", 0.0)
|
||||||
|
sweep = arc_data.get("sweep")
|
||||||
|
if sweep is None or radius <= 0:
|
||||||
|
continue
|
||||||
|
c_ent = self._entities.get(center_id)
|
||||||
|
s_ent = self._entities.get(start_id)
|
||||||
|
e_ent = self._entities.get(end_id)
|
||||||
|
if not (c_ent and s_ent and e_ent
|
||||||
|
and c_ent.geometry and s_ent.geometry and e_ent.geometry):
|
||||||
|
continue
|
||||||
|
cx, cy = c_ent.geometry
|
||||||
|
sx, sy = s_ent.geometry
|
||||||
|
start_angle = math.atan2(sy - cy, sx - cx)
|
||||||
|
# ~12 segments per π radians
|
||||||
|
n = max(4, int(abs(sweep) / (math.pi / 12)))
|
||||||
|
for i in range(n):
|
||||||
|
t1 = i / n
|
||||||
|
t2 = (i + 1) / n
|
||||||
|
a1 = start_angle + t1 * sweep
|
||||||
|
a2 = start_angle + t2 * sweep
|
||||||
|
p1 = (cx + radius * math.cos(a1),
|
||||||
|
cy + radius * math.sin(a1))
|
||||||
|
p2 = (cx + radius * math.cos(a2),
|
||||||
|
cy + radius * math.sin(a2))
|
||||||
|
segs.append((p1, p2))
|
||||||
|
|
||||||
return segs
|
return segs
|
||||||
|
|
||||||
def get_closed_loops(self) -> List[Dict[str, Any]]:
|
def get_closed_loops(self) -> List[Dict[str, Any]]:
|
||||||
@@ -1021,16 +1185,24 @@ class OCCSketch(SketchInterface):
|
|||||||
return loops
|
return loops
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _point_in_polygon(pt: Tuple[float, float], poly: List[Tuple[float, float]]) -> bool:
|
def _point_in_polygon(
|
||||||
|
pt: Tuple[float, float],
|
||||||
|
poly: List[Tuple[float, float]],
|
||||||
|
margin: float = 0.0,
|
||||||
|
) -> bool:
|
||||||
"""Ray-casting point-in-polygon test.
|
"""Ray-casting point-in-polygon test.
|
||||||
|
|
||||||
Returns *True* only for strictly interior points. Points on the
|
Returns *True* for points strictly inside the polygon. Points on
|
||||||
boundary (within 1e-9) are considered *outside* so that the outer
|
the boundary (within eps=1e-9) are *outside* by default so the
|
||||||
boundary of a nested shape doesn't falsely contain another loop whose
|
outer boundary of a nested shape doesn't falsely contain a hole's
|
||||||
representative point happens to land on that boundary.
|
rep point. When *margin* > 0, points that are within that many
|
||||||
|
world-unit of the boundary are also treated as inside — used by
|
||||||
|
``_loop_contains`` to prevent float rounding from breaking
|
||||||
|
thin-wall nesting detection.
|
||||||
"""
|
"""
|
||||||
x, y = pt
|
x, y = pt
|
||||||
eps = 1e-9
|
eps = 1e-9 # strict boundary rejection
|
||||||
|
margin = float(margin)
|
||||||
n = len(poly)
|
n = len(poly)
|
||||||
inside = False
|
inside = False
|
||||||
j = n - 1
|
j = n - 1
|
||||||
@@ -1039,11 +1211,17 @@ class OCCSketch(SketchInterface):
|
|||||||
xj, yj = poly[j]
|
xj, yj = poly[j]
|
||||||
# Point-on-segment test — exclude strict boundary hits.
|
# Point-on-segment test — exclude strict boundary hits.
|
||||||
# First check bounding box of the segment.
|
# First check bounding box of the segment.
|
||||||
if min(xi, xj) - eps <= x <= max(xi, xj) + eps and min(yi, yj) - eps <= y <= max(yi, yj) + eps:
|
bbox_tol = max(eps, margin)
|
||||||
|
if min(xi, xj) - bbox_tol <= x <= max(xi, xj) + bbox_tol and min(yi, yj) - bbox_tol <= y <= max(yi, yj) + bbox_tol:
|
||||||
# Check collinearity
|
# Check collinearity
|
||||||
cross = (x - xi) * (yj - yi) - (y - yi) * (xj - xi)
|
cross = (x - xi) * (yj - yi) - (y - yi) * (xj - xi)
|
||||||
if abs(cross) < eps:
|
abs_cross = abs(cross)
|
||||||
return False # on boundary
|
if abs_cross < eps:
|
||||||
|
# Strictly on boundary — return False unless margin says otherwise.
|
||||||
|
if margin > 0 and abs_cross < margin:
|
||||||
|
pass # fall through to ray-cast below
|
||||||
|
else:
|
||||||
|
return False
|
||||||
if ((yi > y) != (yj > y)) and (x < (xj - xi) * (y - yi) / (yj - yi + 1e-30) + xi):
|
if ((yi > y) != (yj > y)) and (x < (xj - xi) * (y - yi) / (yj - yi + 1e-30) + xi):
|
||||||
inside = not inside
|
inside = not inside
|
||||||
j = i
|
j = i
|
||||||
@@ -1051,34 +1229,78 @@ class OCCSketch(SketchInterface):
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _loop_contains(inner: Dict[str, Any], outer: Dict[str, Any]) -> bool:
|
def _loop_contains(inner: Dict[str, Any], outer: Dict[str, Any]) -> bool:
|
||||||
"""Does ``outer`` fully enclose ``inner``? Uses a representative point +
|
"""Does ``outer`` fully enclose ``inner``?
|
||||||
boundary tests on ``outer`` (only valid when ``outer`` != ``inner``)."""
|
|
||||||
rep = OCCSketch._loop_rep_point(inner)
|
For polygon-polygon: checks that ALL vertices of ``inner`` are strictly
|
||||||
if outer["type"] == "polygon":
|
inside ``outer`` using ray-casting. This is robust for convex polygons
|
||||||
return OCCSketch._point_in_polygon(rep, outer["points"])
|
and avoids the representative-point issue where a large nested loop's
|
||||||
else: # circle
|
centroid lands inside an inner loop.
|
||||||
cx, cy = outer["center"]
|
|
||||||
return math.hypot(rep[0] - cx, rep[1] - cy) < outer["radius"]
|
For circle-in-polygon: checks the circle centre is inside the polygon
|
||||||
|
(vertex check would be too strict for tessellated arc segments).
|
||||||
|
|
||||||
|
For circle-in-circle: checks distance between centres + inner radius
|
||||||
|
< outer radius + margin.
|
||||||
|
|
||||||
|
For polygon-in-circle: checks all polygon vertices are inside the
|
||||||
|
circle.
|
||||||
|
"""
|
||||||
|
eps = 1e-3
|
||||||
|
|
||||||
|
if outer["type"] == "circle":
|
||||||
|
ox, oy = outer["center"]
|
||||||
|
orad = outer["radius"]
|
||||||
|
if inner["type"] == "circle":
|
||||||
|
# Two circles: centre distance + inner radius < outer radius
|
||||||
|
dx = inner["center"][0] - ox
|
||||||
|
dy = inner["center"][1] - oy
|
||||||
|
return math.hypot(dx, dy) + inner["radius"] < orad + eps
|
||||||
|
else:
|
||||||
|
# Polygon in circle: all vertices inside
|
||||||
|
pts = inner["points"]
|
||||||
|
if len(pts) > 1 and pts[0] == pts[-1]:
|
||||||
|
pts = pts[:-1]
|
||||||
|
for pt in pts:
|
||||||
|
if math.hypot(pt[0] - ox, pt[1] - oy) > orad - eps:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
# outer is polygon
|
||||||
|
if inner["type"] == "circle":
|
||||||
|
# Circle in polygon: centre must be inside with margin
|
||||||
|
cx, cy = inner["center"]
|
||||||
|
return OCCSketch._point_in_polygon(
|
||||||
|
(cx, cy), outer["points"], margin=1e-3
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Polygon in polygon: ALL inner vertices inside outer
|
||||||
|
pts = inner["points"]
|
||||||
|
if len(pts) > 1 and pts[0] == pts[-1]:
|
||||||
|
pts = pts[:-1]
|
||||||
|
for pt in pts:
|
||||||
|
if not OCCSketch._point_in_polygon(
|
||||||
|
pt, outer["points"], margin=eps
|
||||||
|
):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _loop_rep_point(loop: Dict[str, Any]) -> Tuple[float, float]:
|
def _loop_rep_point(loop: Dict[str, Any]) -> Tuple[float, float]:
|
||||||
"""An interior representative point inside a loop.
|
"""An interior representative point inside a loop.
|
||||||
|
|
||||||
For polygons we use the midpoint between the centroid and the first
|
Only used for circle-in-polygon containment checks (polygon-in-polygon
|
||||||
vertex (而不是 centroid 本身): a nested shape centered on the polygon's
|
uses all-vertex containment). Returns the centroid for polygons and
|
||||||
centroid (e.g. a circle inside a rectangle, both centered on the same
|
the centre for circles.
|
||||||
point) would otherwise make the polygon's rep point coincide with the
|
|
||||||
hole and break containment tests. This midpoint stays inside convex
|
|
||||||
loops and is unlikely to land on a nested feature's center.
|
|
||||||
"""
|
"""
|
||||||
if loop["type"] == "polygon":
|
if loop["type"] == "polygon":
|
||||||
pts = loop["points"][:-1] if len(loop["points"]) > 1 and loop["points"][0] == loop["points"][-1] else loop["points"]
|
pts = loop["points"][:-1] if len(loop["points"]) > 1 and loop["points"][0] == loop["points"][-1] else loop["points"]
|
||||||
n = len(pts)
|
n = len(pts)
|
||||||
|
if n < 3:
|
||||||
|
return loop.get("center", (0.0, 0.0))
|
||||||
sx = sum(p[0] for p in pts) / n
|
sx = sum(p[0] for p in pts) / n
|
||||||
sy = sum(p[1] for p in pts) / n
|
sy = sum(p[1] for p in pts) / n
|
||||||
v0 = pts[0]
|
return (sx, sy)
|
||||||
return ((sx + v0[0]) / 2.0, (sy + v0[1]) / 2.0)
|
return loop.get("center", (0.0, 0.0))
|
||||||
return loop["center"]
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _loop_area(loop: Dict[str, Any]) -> float:
|
def _loop_area(loop: Dict[str, Any]) -> float:
|
||||||
@@ -1165,6 +1387,28 @@ class OCCSketch(SketchInterface):
|
|||||||
best = face
|
best = face
|
||||||
return best
|
return best
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _loop_signed_area(loop: Dict[str, Any]) -> float:
|
||||||
|
"""Signed area of a loop. Positive = CCW, negative = CW.
|
||||||
|
|
||||||
|
Circles are treated as CCW (positive area) because
|
||||||
|
``gp_Circ`` / ``gp_Ax2`` creates edges with CCW parametric
|
||||||
|
direction when looking against the normal.
|
||||||
|
"""
|
||||||
|
if loop["type"] == "circle":
|
||||||
|
r = loop.get("radius", 0.0)
|
||||||
|
return math.pi * r * r # always positive (CCW)
|
||||||
|
pts = loop["points"]
|
||||||
|
if len(pts) < 3:
|
||||||
|
return 0.0
|
||||||
|
area = 0.0
|
||||||
|
n = len(pts) - 1 # last point == first for closed loops
|
||||||
|
for i in range(n):
|
||||||
|
x1, y1 = pts[i]
|
||||||
|
x2, y2 = pts[i + 1]
|
||||||
|
area += x1 * y2 - x2 * y1
|
||||||
|
return area / 2.0
|
||||||
|
|
||||||
def build_face_geometry(self, face: Dict[str, Any]) -> OCCGeometryObject:
|
def build_face_geometry(self, face: Dict[str, Any]) -> OCCGeometryObject:
|
||||||
"""Build an OCC face (outer boundary + inner holes) on the workplane.
|
"""Build an OCC face (outer boundary + inner holes) on the workplane.
|
||||||
|
|
||||||
@@ -1173,6 +1417,13 @@ class OCCSketch(SketchInterface):
|
|||||||
sketch's 3D plane (not necessarily XY). The returned object stores
|
sketch's 3D plane (not necessarily XY). The returned object stores
|
||||||
the raw OCC face in ``.shape`` and the plane normal in
|
the raw OCC face in ``.shape`` and the plane normal in
|
||||||
``metadata["normal"]`` for the extrude kernel.
|
``metadata["normal"]`` for the extrude kernel.
|
||||||
|
|
||||||
|
Hole wires are oriented to have OPPOSITE geometric winding relative
|
||||||
|
to the outer wire, which is what OCC's face builder expects for
|
||||||
|
proper hole treatment. Previous code unconditionally reversed ALL
|
||||||
|
hole wires, which produced solid islands (not holes) whenever the
|
||||||
|
outer loop had clockwise winding — e.g. after dragging a rectangle
|
||||||
|
from top-left to bottom-right.
|
||||||
"""
|
"""
|
||||||
from OCP.BRepBuilderAPI import (
|
from OCP.BRepBuilderAPI import (
|
||||||
BRepBuilderAPI_MakePolygon, BRepBuilderAPI_MakeFace,
|
BRepBuilderAPI_MakePolygon, BRepBuilderAPI_MakeFace,
|
||||||
@@ -1181,32 +1432,40 @@ class OCCSketch(SketchInterface):
|
|||||||
from OCP.gp import gp_Pnt, gp_Circ, gp_Ax2, gp_Dir
|
from OCP.gp import gp_Pnt, gp_Circ, gp_Ax2, gp_Dir
|
||||||
from OCP.TopoDS import TopoDS as _TopoDS
|
from OCP.TopoDS import TopoDS as _TopoDS
|
||||||
|
|
||||||
def wire_loop(loop: Dict[str, Any], is_hole: bool = False):
|
def _wire_from_loop(loop: Dict[str, Any]):
|
||||||
|
"""Build a wire from a loop dict. No orientation adjustment."""
|
||||||
if loop["type"] == "polygon":
|
if loop["type"] == "polygon":
|
||||||
mp = BRepBuilderAPI_MakePolygon()
|
mp = BRepBuilderAPI_MakePolygon()
|
||||||
for (pu, pv) in loop["points"]:
|
for (pu, pv) in loop["points"]:
|
||||||
mp.Add(self._uv_to_world(pu, pv))
|
mp.Add(self._uv_to_world(pu, pv))
|
||||||
mp.Close()
|
mp.Close()
|
||||||
mp.Build()
|
mp.Build()
|
||||||
w = mp.Wire()
|
return mp.Wire()
|
||||||
else:
|
cu, cv = loop["center"]
|
||||||
cu, cv = loop["center"]
|
r = loop["radius"]
|
||||||
r = loop["radius"]
|
circ = gp_Circ(self._circle_axis(cu, cv), r)
|
||||||
circ = gp_Circ(self._circle_axis(cu, cv), r)
|
me = BRepBuilderAPI_MakeEdge(circ)
|
||||||
me = BRepBuilderAPI_MakeEdge(circ)
|
me.Build()
|
||||||
me.Build()
|
mw = BRepBuilderAPI_MakeWire()
|
||||||
mw = BRepBuilderAPI_MakeWire()
|
mw.Add(me.Edge())
|
||||||
mw.Add(me.Edge())
|
mw.Build()
|
||||||
mw.Build()
|
return mw.Wire()
|
||||||
w = mw.Wire()
|
|
||||||
if is_hole:
|
outer_loop = face["outer"]
|
||||||
w = _TopoDS.Wire_s(w.Reversed()) # reverse so OCC treats it as a hole
|
outer_wire = _wire_from_loop(outer_loop)
|
||||||
return w
|
outer_winding = self._loop_signed_area(outer_loop)
|
||||||
|
|
||||||
outer_wire = wire_loop(face["outer"], is_hole=False)
|
|
||||||
face_maker = BRepBuilderAPI_MakeFace(outer_wire, True)
|
face_maker = BRepBuilderAPI_MakeFace(outer_wire, True)
|
||||||
for h in face["holes"]:
|
for h in face["holes"]:
|
||||||
face_maker.Add(wire_loop(h, is_hole=True))
|
hole_wire = _wire_from_loop(h)
|
||||||
|
hole_winding = self._loop_signed_area(h)
|
||||||
|
# OCC expects hole wires to have OPPOSITE winding to the outer
|
||||||
|
# wire (material on the other side). We reverse the hole wire
|
||||||
|
# only when its natural winding matches the outer's; if they
|
||||||
|
# already differ the wire is left as-is.
|
||||||
|
if (hole_winding >= 0 and outer_winding >= 0) or (hole_winding < 0 and outer_winding < 0):
|
||||||
|
hole_wire = _TopoDS.Wire_s(hole_wire.Reversed())
|
||||||
|
face_maker.Add(hole_wire)
|
||||||
face_maker.Build()
|
face_maker.Build()
|
||||||
occ_face = face_maker.Face()
|
occ_face = face_maker.Face()
|
||||||
|
|
||||||
@@ -1240,6 +1499,7 @@ class OCCSketch(SketchInterface):
|
|||||||
self._constraint_count = 0
|
self._constraint_count = 0
|
||||||
self._constraint_log.clear()
|
self._constraint_log.clear()
|
||||||
self._external_entity_ids.clear()
|
self._external_entity_ids.clear()
|
||||||
|
self._centerline_ids.clear()
|
||||||
self._first_point_id = None
|
self._first_point_id = None
|
||||||
|
|
||||||
def _prune_log_for(self, removed_ids: set) -> None:
|
def _prune_log_for(self, removed_ids: set) -> None:
|
||||||
@@ -1259,10 +1519,17 @@ class OCCSketch(SketchInterface):
|
|||||||
constraint that referenced it, rebuilds the whole solver system from
|
constraint that referenced it, rebuilds the whole solver system from
|
||||||
the surviving points/lines + pruned log, and re-solves. The line's
|
the surviving points/lines + pruned log, and re-solves. The line's
|
||||||
endpoint points are NOT removed — only the line segment.
|
endpoint points are NOT removed — only the line segment.
|
||||||
|
|
||||||
|
Note: centerlines (reference axes through origin) cannot be deleted.
|
||||||
"""
|
"""
|
||||||
if line.id not in self._lines or line.id not in self._entities:
|
if line.id not in self._lines or line.id not in self._entities:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
# Centerlines are permanent reference axes — refuse deletion.
|
||||||
|
if line.id in self._centerline_ids:
|
||||||
|
logger.debug("Refusing to delete centerline")
|
||||||
|
return False
|
||||||
|
|
||||||
del self._lines[line.id]
|
del self._lines[line.id]
|
||||||
if line.id in self._entities:
|
if line.id in self._entities:
|
||||||
del self._entities[line.id]
|
del self._entities[line.id]
|
||||||
@@ -1298,10 +1565,17 @@ class OCCSketch(SketchInterface):
|
|||||||
All constraints that reference the point OR the removed lines are
|
All constraints that reference the point OR the removed lines are
|
||||||
pruned from the log, the solver is rebuilt from survivors, labels are
|
pruned from the log, the solver is rebuilt from survivors, labels are
|
||||||
re-derived, and the system is re-solved.
|
re-derived, and the system is re-solved.
|
||||||
|
|
||||||
|
Note: centerlines (reference axes through origin) cannot be deleted.
|
||||||
"""
|
"""
|
||||||
if point.id not in self._entities or point.id not in self._points:
|
if point.id not in self._entities or point.id not in self._points:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
# Centerline points are permanent reference anchors — refuse deletion.
|
||||||
|
if point.id in self._centerline_ids:
|
||||||
|
logger.debug("Refusing to delete centerline point")
|
||||||
|
return False
|
||||||
|
|
||||||
removed_ids: set = {point.id}
|
removed_ids: set = {point.id}
|
||||||
# Remove lines that use this point as an endpoint.
|
# Remove lines that use this point as an endpoint.
|
||||||
removed_line_keys: List[int] = [
|
removed_line_keys: List[int] = [
|
||||||
@@ -1327,6 +1601,18 @@ class OCCSketch(SketchInterface):
|
|||||||
del self._circles[cid]
|
del self._circles[cid]
|
||||||
if cid in self._entities:
|
if cid in self._entities:
|
||||||
del self._entities[cid]
|
del self._entities[cid]
|
||||||
|
# Arcs referencing this point (as centre, start, or end) are invalid.
|
||||||
|
removed_arc_keys: List[int] = [
|
||||||
|
aid for aid, adata in list(self._arcs.items())
|
||||||
|
if adata.get("center") == point.id
|
||||||
|
or adata.get("start") == point.id
|
||||||
|
or adata.get("end") == point.id
|
||||||
|
]
|
||||||
|
for aid in removed_arc_keys:
|
||||||
|
removed_ids.add(aid)
|
||||||
|
del self._arcs[aid]
|
||||||
|
if aid in self._entities:
|
||||||
|
del self._entities[aid]
|
||||||
|
|
||||||
self._prune_log_for(removed_ids)
|
self._prune_log_for(removed_ids)
|
||||||
self._rebuild_solver()
|
self._rebuild_solver()
|
||||||
@@ -1413,3 +1699,205 @@ class OCCSketch(SketchInterface):
|
|||||||
return self._solver.dof() == 0
|
return self._solver.dof() == 0
|
||||||
except Exception:
|
except Exception:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
# ─── Serialization (used by fluency.io.project_io) ─────────────────────
|
||||||
|
|
||||||
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
|
"""Serialize the sketch to a plain-dict for JSON storage.
|
||||||
|
|
||||||
|
Captures: the workplane, every entity (with its current geometry and
|
||||||
|
flags), the constraint log, and the entity counter. Live solver
|
||||||
|
handles are intentionally NOT saved — the consumer must call
|
||||||
|
:meth:`from_dict` (or :meth:`rebuild_from_dict`) to rebuild the
|
||||||
|
SolveSpace system before solving again.
|
||||||
|
"""
|
||||||
|
# Sort entities by id so replay order is deterministic and matches
|
||||||
|
# creation order (ids are assigned monotonically by ``_next_id``).
|
||||||
|
entities_payload: List[Dict[str, Any]] = []
|
||||||
|
for eid in sorted(self._entities.keys()):
|
||||||
|
ent = self._entities[eid]
|
||||||
|
entities_payload.append(
|
||||||
|
{
|
||||||
|
"id": eid,
|
||||||
|
"type": ent.entity_type,
|
||||||
|
# geometry shape varies: point→(x,y), line→((x1,y1),(x2,y2)),
|
||||||
|
# circle→((cx,cy),r), arc→dict. All JSON-friendly.
|
||||||
|
"geometry": ent.geometry,
|
||||||
|
"is_construction": bool(ent.is_construction),
|
||||||
|
"is_external": bool(ent.is_external),
|
||||||
|
"constraints": list(ent.constraints),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Sets become sorted lists for JSON. ``labels`` inside constraint_log
|
||||||
|
# is a set on the wire; convert to sorted list for JSON round-trip.
|
||||||
|
constraint_log_payload: List[Dict[str, Any]] = []
|
||||||
|
for entry in self._constraint_log:
|
||||||
|
constraint_log_payload.append(
|
||||||
|
{
|
||||||
|
"type": entry["type"],
|
||||||
|
"ids": list(entry["ids"]),
|
||||||
|
"params": list(entry["params"]),
|
||||||
|
"labels": sorted(entry["labels"]),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"wp_origin": list(self._wp_origin),
|
||||||
|
"wp_normal": list(self._wp_normal),
|
||||||
|
"wp_x_dir": list(self._wp_x_dir),
|
||||||
|
"wp_y_dir": list(self._wp_y_dir),
|
||||||
|
"entity_counter": self._entity_counter,
|
||||||
|
"first_point_id": self._first_point_id,
|
||||||
|
"external_entity_ids": sorted(self._external_entity_ids),
|
||||||
|
"centerline_ids": sorted(self._centerline_ids),
|
||||||
|
"constraint_count": self._constraint_count,
|
||||||
|
"entities": entities_payload,
|
||||||
|
"constraint_log": constraint_log_payload,
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: Dict[str, Any]) -> "OCCSketch":
|
||||||
|
"""Build a fresh OCCSketch that reproduces the saved state.
|
||||||
|
|
||||||
|
Replays the construction sequence (points → lines → circles → arcs,
|
||||||
|
respecting external/centerline flags) and re-applies every constraint
|
||||||
|
in the saved log. The SolveSpace solver is deterministic for a given
|
||||||
|
input, so the post-solve state matches the saved one.
|
||||||
|
"""
|
||||||
|
sk = cls()
|
||||||
|
sk.rebuild_from_dict(data)
|
||||||
|
return sk
|
||||||
|
|
||||||
|
def rebuild_from_dict(self, data: Dict[str, Any]) -> None:
|
||||||
|
"""In-place restore from a dict produced by :meth:`to_dict`.
|
||||||
|
|
||||||
|
Wipes the current solver/state and re-creates every entity in id
|
||||||
|
order so :attr:`_first_point_id` is anchored correctly. Existing
|
||||||
|
callers (notably the solver-rebuild path on entity delete) don't use
|
||||||
|
this; only the project load path does.
|
||||||
|
"""
|
||||||
|
# Wipe solver + trackers (don't lose the workplane yet — we set it
|
||||||
|
# explicitly below).
|
||||||
|
self.clear()
|
||||||
|
self._external_entity_ids.clear()
|
||||||
|
self._centerline_ids.clear()
|
||||||
|
|
||||||
|
# 1. Workplane.
|
||||||
|
self.set_workplane(
|
||||||
|
tuple(data["wp_origin"]),
|
||||||
|
tuple(data["wp_normal"]),
|
||||||
|
tuple(data["wp_x_dir"]),
|
||||||
|
)
|
||||||
|
|
||||||
|
# 2. Force the entity counter so the replay assigns the same ids as
|
||||||
|
# the saved sketch — the constraint log references those ids.
|
||||||
|
self._entity_counter = int(data.get("entity_counter", 0))
|
||||||
|
|
||||||
|
# 3. Replay entities in id order. We need the OCCSketchEntity
|
||||||
|
# objects back (for arc center/start/end lookups), so we
|
||||||
|
# reconstruct by id and let ``_next_id`` advance the counter.
|
||||||
|
entities_by_id: Dict[int, OCCSketchEntity] = {}
|
||||||
|
for entry in data.get("entities", []):
|
||||||
|
eid = int(entry["id"])
|
||||||
|
# Ensure the next _next_id() call returns eid.
|
||||||
|
self._entity_counter = eid - 1
|
||||||
|
etype = entry["type"]
|
||||||
|
geom = entry.get("geometry")
|
||||||
|
is_external = bool(entry.get("is_external", False))
|
||||||
|
|
||||||
|
if etype == "point":
|
||||||
|
x, y = float(geom[0]), float(geom[1])
|
||||||
|
if is_external:
|
||||||
|
ent = self.add_external_point(x, y)
|
||||||
|
else:
|
||||||
|
ent = self.add_point(x, y)
|
||||||
|
elif etype == "line":
|
||||||
|
# line geometry is ((x1,y1),(x2,y2)); we already know the
|
||||||
|
# endpoints exist as point entities. Look them up by saved
|
||||||
|
# position via _points (which was just populated above).
|
||||||
|
(x1, y1), (x2, y2) = geom
|
||||||
|
s_id = self._find_point_at(x1, y1)
|
||||||
|
e_id = self._find_point_at(x2, y2)
|
||||||
|
if s_id is None or e_id is None:
|
||||||
|
logger.warning(
|
||||||
|
"Skipping line %s during load: endpoints not found", eid
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
if is_external:
|
||||||
|
ent = self.add_external_line(entities_by_id[s_id], entities_by_id[e_id])
|
||||||
|
else:
|
||||||
|
ent = self.add_line(entities_by_id[s_id], entities_by_id[e_id])
|
||||||
|
elif etype == "circle":
|
||||||
|
(cx, cy), radius = geom
|
||||||
|
c_id = self._find_point_at(cx, cy)
|
||||||
|
if c_id is None:
|
||||||
|
logger.warning(
|
||||||
|
"Skipping circle %s during load: center not found", eid
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
ent = self.add_circle(entities_by_id[c_id], float(radius))
|
||||||
|
elif etype == "arc":
|
||||||
|
center_pos = tuple(geom["center"])
|
||||||
|
start_pos = tuple(geom["start"])
|
||||||
|
end_pos = tuple(geom["end"])
|
||||||
|
radius = float(geom["radius"])
|
||||||
|
sweep = float(geom.get("sweep", 0.0))
|
||||||
|
c_id = self._find_point_at(*center_pos)
|
||||||
|
s_id = self._find_point_at(*start_pos)
|
||||||
|
e_id = self._find_point_at(*end_pos)
|
||||||
|
if c_id is None or s_id is None or e_id is None:
|
||||||
|
logger.warning(
|
||||||
|
"Skipping arc %s during load: endpoints not found", eid
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
ent = self.add_arc(
|
||||||
|
entities_by_id[c_id],
|
||||||
|
radius,
|
||||||
|
entities_by_id[s_id],
|
||||||
|
entities_by_id[e_id],
|
||||||
|
sweep=sweep,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.warning("Unknown sketch entity type %r; skipping", etype)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Restore the per-entity UI flags / labels that aren't carried
|
||||||
|
# by the add_* methods themselves.
|
||||||
|
ent.is_construction = bool(entry.get("is_construction", False))
|
||||||
|
ent.constraints = list(entry.get("constraints", []))
|
||||||
|
entities_by_id[eid] = ent
|
||||||
|
|
||||||
|
# 4. Replay constraint log. ``_apply_constraint_log`` re-issues the
|
||||||
|
# solver call and pushes back into the entity tracker via
|
||||||
|
# ``entity.constraints``. We don't double-record into the log
|
||||||
|
# itself (the log was already cleared by ``clear()`` and we
|
||||||
|
# re-populate it here, so the live ``_constraint_count`` will
|
||||||
|
# reflect the saved state at the end).
|
||||||
|
for entry in data.get("constraint_log", []):
|
||||||
|
self._record_constraint(
|
||||||
|
entry["type"],
|
||||||
|
tuple(entry["ids"]),
|
||||||
|
tuple(entry.get("params", ())),
|
||||||
|
tuple(entry.get("labels", ())),
|
||||||
|
)
|
||||||
|
# Re-apply the live solver calls AFTER the log is restored, so that
|
||||||
|
# the constraint tracker matches the solver state on a fresh solve.
|
||||||
|
for entry in self._constraint_log:
|
||||||
|
self._apply_constraint_log(entry)
|
||||||
|
|
||||||
|
# 5. Restore external / centerline id sets. ``add_external_*`` adds
|
||||||
|
# to the set internally; if the entity's id was a regular entity
|
||||||
|
# for some reason (legacy / hand-edited file), fold it in too so
|
||||||
|
# the saved flag is authoritative.
|
||||||
|
for eid in data.get("external_entity_ids", []):
|
||||||
|
self._external_entity_ids.add(int(eid))
|
||||||
|
for eid in data.get("centerline_ids", []):
|
||||||
|
self._centerline_ids.add(int(eid))
|
||||||
|
|
||||||
|
def _find_point_at(self, x: float, y: float, tol: float = 1e-6) -> Optional[int]:
|
||||||
|
"""Return the entity id of a point sitting at UV ``(x, y)`` (within tol)."""
|
||||||
|
for pid, pos in self._points.items():
|
||||||
|
if abs(pos[0] - x) < tol and abs(pos[1] - y) < tol:
|
||||||
|
return pid
|
||||||
|
return None
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
"""I/O module: project save/load."""
|
||||||
|
|
||||||
|
from fluency.io.project_io import save_project, load_project, project_zip_path
|
||||||
|
|
||||||
|
__all__ = ["save_project", "load_project", "project_zip_path"]
|
||||||
@@ -0,0 +1,734 @@
|
|||||||
|
"""Project save/load — ``.fluency`` ZIP files.
|
||||||
|
|
||||||
|
The on-disk format is a single ZIP archive:
|
||||||
|
|
||||||
|
project.json # project tree: components, sketches, bodies,
|
||||||
|
# workplanes, assemblies, connectors, view state
|
||||||
|
bodies/<id>.step # one STEP file per Body (BRep geometry)
|
||||||
|
sketches/<id>/meta.json # sketch entities + constraints (kept separately
|
||||||
|
# so a single huge sketch doesn't bloat the
|
||||||
|
# main project.json)
|
||||||
|
sketches/<id>/solved.step # the sketch's solved face geometry
|
||||||
|
|
||||||
|
Sketch constraint solving and 3D body geometry are both preserved by using
|
||||||
|
OpenCASCADE's native STEP exporter (which is lossless for BRep). Everything
|
||||||
|
else is JSON.
|
||||||
|
|
||||||
|
The :func:`save_project` function is the entry point used by the File menu.
|
||||||
|
The :func:`load_project` function returns a fully populated
|
||||||
|
:class:`fluency.models.Project` (with a fresh ``OCGeometryKernel``) and an
|
||||||
|
optional view-state dict that the main window can hand back to the renderer
|
||||||
|
to restore the camera.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import tempfile
|
||||||
|
import zipfile
|
||||||
|
from dataclasses import asdict, is_dataclass
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any, Callable, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from fluency.models.data_model import (
|
||||||
|
Assembly,
|
||||||
|
AssemblyComponent,
|
||||||
|
AssemblyConnection,
|
||||||
|
Body,
|
||||||
|
Component,
|
||||||
|
Connector,
|
||||||
|
Project,
|
||||||
|
Sketch,
|
||||||
|
Workplane,
|
||||||
|
)
|
||||||
|
from fluency.geometry_occ.kernel import OCCGeometryObject, OCGeometryKernel
|
||||||
|
from fluency.geometry_occ.sketch import OCCSketch
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# ── JSON-friendly type coercion ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _json_default(obj: Any) -> Any:
|
||||||
|
"""Default JSON encoder for numpy / dataclass / datetime values."""
|
||||||
|
if isinstance(obj, np.ndarray):
|
||||||
|
return obj.tolist()
|
||||||
|
if isinstance(obj, (datetime,)):
|
||||||
|
return obj.isoformat()
|
||||||
|
if isinstance(obj, (set, frozenset)):
|
||||||
|
return sorted(obj)
|
||||||
|
if isinstance(obj, tuple):
|
||||||
|
return list(obj)
|
||||||
|
if is_dataclass(obj):
|
||||||
|
return asdict(obj)
|
||||||
|
raise TypeError(f"Object of type {type(obj).__name__} is not JSON serializable")
|
||||||
|
|
||||||
|
|
||||||
|
def _to_json(data: Any) -> str:
|
||||||
|
return json.dumps(data, default=_json_default, indent=2, sort_keys=False)
|
||||||
|
|
||||||
|
|
||||||
|
def _coerce_listlike(value: Any) -> List[Any]:
|
||||||
|
"""Cast arrays / tuples / numpy arrays to plain lists for JSON friendliness."""
|
||||||
|
if value is None:
|
||||||
|
return []
|
||||||
|
if isinstance(value, np.ndarray):
|
||||||
|
return value.tolist()
|
||||||
|
if isinstance(value, (list, tuple)):
|
||||||
|
return [v for v in value]
|
||||||
|
return list(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _to_3tuple(value: Any) -> Tuple[float, float, float]:
|
||||||
|
"""Coerce a saved 3-vector to a tuple of floats (for OCC)."""
|
||||||
|
if value is None:
|
||||||
|
return (0.0, 0.0, 0.0)
|
||||||
|
if isinstance(value, np.ndarray):
|
||||||
|
seq = value.tolist()
|
||||||
|
else:
|
||||||
|
seq = list(value)
|
||||||
|
if len(seq) < 3:
|
||||||
|
seq = list(seq) + [0.0] * (3 - len(seq))
|
||||||
|
return (float(seq[0]), float(seq[1]), float(seq[2]))
|
||||||
|
|
||||||
|
|
||||||
|
def _to_3vec(value: Any) -> np.ndarray:
|
||||||
|
"""Coerce a saved 3-vector to a 3-element numpy array."""
|
||||||
|
if isinstance(value, np.ndarray):
|
||||||
|
return value.astype(float).reshape(3)
|
||||||
|
if value is None:
|
||||||
|
return np.zeros(3, dtype=float)
|
||||||
|
seq = list(value)
|
||||||
|
if len(seq) < 3:
|
||||||
|
seq = list(seq) + [0.0] * (3 - len(seq))
|
||||||
|
return np.array([float(seq[0]), float(seq[1]), float(seq[2])], dtype=float)
|
||||||
|
|
||||||
|
|
||||||
|
def _to_mat3(value: Any) -> np.ndarray:
|
||||||
|
"""Coerce a saved 3×3 matrix (flat 9-list or nested) to np.ndarray."""
|
||||||
|
if isinstance(value, np.ndarray):
|
||||||
|
arr = value.astype(float)
|
||||||
|
return arr.reshape(3, 3)
|
||||||
|
if value is None:
|
||||||
|
return np.eye(3, dtype=float)
|
||||||
|
flat = list(np.asarray(value, dtype=float).flatten())
|
||||||
|
if len(flat) < 9:
|
||||||
|
flat = flat + [0.0] * (9 - len(flat))
|
||||||
|
return np.array(flat[:9], dtype=float).reshape(3, 3)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_iso(value: Optional[str]) -> datetime:
|
||||||
|
"""Parse an ISO-8601 timestamp, falling back to ``now`` on failure."""
|
||||||
|
if not value:
|
||||||
|
return datetime.now()
|
||||||
|
try:
|
||||||
|
return datetime.fromisoformat(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return datetime.now()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Model serialization (to_dict) ──────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _workplane_to_dict(wp: Workplane) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": wp.id,
|
||||||
|
"name": wp.name,
|
||||||
|
"origin": list(wp.origin),
|
||||||
|
"normal": list(wp.normal),
|
||||||
|
"x_dir": list(wp.x_dir),
|
||||||
|
"visible": bool(wp.visible),
|
||||||
|
"created_at": wp.created_at.isoformat() if wp.created_at else None,
|
||||||
|
"modified_at": wp.modified_at.isoformat() if wp.modified_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _workplane_from_dict(data: Dict[str, Any]) -> Workplane:
|
||||||
|
wp = Workplane(
|
||||||
|
id=data.get("id") or None, # Workplane generates uuid if None
|
||||||
|
name=data.get("name", "Untitled Workplane"),
|
||||||
|
origin=tuple(data.get("origin", (0.0, 0.0, 0.0))),
|
||||||
|
normal=tuple(data.get("normal", (0.0, 0.0, 1.0))),
|
||||||
|
x_dir=tuple(data.get("x_dir", (1.0, 0.0, 0.0))),
|
||||||
|
visible=bool(data.get("visible", True)),
|
||||||
|
)
|
||||||
|
wp.created_at = _parse_iso(data.get("created_at"))
|
||||||
|
wp.modified_at = _parse_iso(data.get("modified_at"))
|
||||||
|
return wp
|
||||||
|
|
||||||
|
|
||||||
|
def _body_to_dict(body: Body) -> Dict[str, Any]:
|
||||||
|
"""Body serialization. ``geometry_ref`` is set later by the ZIP writer
|
||||||
|
once the STEP file is written."""
|
||||||
|
return {
|
||||||
|
"id": body.id,
|
||||||
|
"name": body.name,
|
||||||
|
"source_sketch_id": body.source_sketch.id if body.source_sketch else None,
|
||||||
|
"source_operation": body.source_operation,
|
||||||
|
"extrude_length": body.extrude_length,
|
||||||
|
"extrude_symmetric": body.extrude_symmetric,
|
||||||
|
"extrude_invert": body.extrude_invert,
|
||||||
|
"extrude_cut": body.extrude_cut,
|
||||||
|
"extrude_union": body.extrude_union,
|
||||||
|
"extrude_through_all": body.extrude_through_all,
|
||||||
|
"extrude_face_index": body.extrude_face_index,
|
||||||
|
"extrude_target_body_id": body.extrude_target_body_id,
|
||||||
|
"position": _coerce_listlike(body.position),
|
||||||
|
"rotation": _coerce_listlike(body.rotation),
|
||||||
|
"color": list(body.color) if body.color else [0.2, 0.4, 0.8],
|
||||||
|
"opacity": float(body.opacity),
|
||||||
|
"visible": bool(body.visible),
|
||||||
|
"has_geometry": body.geometry is not None,
|
||||||
|
"geometry_ref": None, # filled in by save_project
|
||||||
|
"created_at": body.created_at.isoformat() if body.created_at else None,
|
||||||
|
"modified_at": body.modified_at.isoformat() if body.modified_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _body_from_dict(
|
||||||
|
data: Dict[str, Any],
|
||||||
|
geometry_loader: Optional[Callable[[str], Optional[OCCGeometryObject]]] = None,
|
||||||
|
source_sketch: Optional[Sketch] = None,
|
||||||
|
) -> Body:
|
||||||
|
geometry: Optional[OCCGeometryObject] = None
|
||||||
|
if geometry_loader is not None and data.get("geometry_ref"):
|
||||||
|
geometry = geometry_loader(data["geometry_ref"]) if data.get("has_geometry") else None
|
||||||
|
|
||||||
|
body = Body(
|
||||||
|
id=data.get("id") or None,
|
||||||
|
name=data.get("name", "Untitled Body"),
|
||||||
|
geometry=geometry,
|
||||||
|
source_sketch=source_sketch,
|
||||||
|
source_operation=data.get("source_operation", "extrude"),
|
||||||
|
extrude_length=data.get("extrude_length"),
|
||||||
|
extrude_symmetric=bool(data.get("extrude_symmetric", False)),
|
||||||
|
extrude_invert=bool(data.get("extrude_invert", False)),
|
||||||
|
extrude_cut=bool(data.get("extrude_cut", False)),
|
||||||
|
extrude_union=bool(data.get("extrude_union", False)),
|
||||||
|
extrude_through_all=bool(data.get("extrude_through_all", False)),
|
||||||
|
extrude_face_index=data.get("extrude_face_index"),
|
||||||
|
extrude_target_body_id=data.get("extrude_target_body_id"),
|
||||||
|
position=_to_3vec(data.get("position")),
|
||||||
|
rotation=_to_mat3(data.get("rotation")),
|
||||||
|
color=tuple(data.get("color", [0.2, 0.4, 0.8])),
|
||||||
|
opacity=float(data.get("opacity", 1.0)),
|
||||||
|
visible=bool(data.get("visible", True)),
|
||||||
|
)
|
||||||
|
body.created_at = _parse_iso(data.get("created_at"))
|
||||||
|
body.modified_at = _parse_iso(data.get("modified_at"))
|
||||||
|
return body
|
||||||
|
|
||||||
|
|
||||||
|
def _sketch_to_dict(sketch: Sketch) -> Dict[str, Any]:
|
||||||
|
occ_dict: Optional[Dict[str, Any]] = None
|
||||||
|
if sketch.occ_sketch is not None and isinstance(sketch.occ_sketch, OCCSketch):
|
||||||
|
try:
|
||||||
|
occ_dict = sketch.occ_sketch.to_dict()
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Sketch %s occ_sketch.to_dict() failed: %s", sketch.id, exc)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": sketch.id,
|
||||||
|
"name": sketch.name,
|
||||||
|
"workplane_origin": _coerce_listlike(sketch.workplane_origin),
|
||||||
|
"workplane_normal": _coerce_listlike(sketch.workplane_normal),
|
||||||
|
"workplane_x_dir": _coerce_listlike(sketch.workplane_x_dir),
|
||||||
|
"is_solved": bool(sketch.is_solved),
|
||||||
|
"is_fully_constrained": bool(sketch.is_fully_constrained),
|
||||||
|
"occ_sketch": occ_dict,
|
||||||
|
"has_geometry": sketch.geometry is not None,
|
||||||
|
"geometry_ref": None, # filled in by save_project
|
||||||
|
"created_at": sketch.created_at.isoformat() if sketch.created_at else None,
|
||||||
|
"modified_at": sketch.modified_at.isoformat() if sketch.modified_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _sketch_from_dict(
|
||||||
|
data: Dict[str, Any],
|
||||||
|
geometry_loader: Optional[Callable[[str], Optional[OCCGeometryObject]]] = None,
|
||||||
|
) -> Sketch:
|
||||||
|
occ_dict = data.get("occ_sketch")
|
||||||
|
occ_sketch: Optional[OCCSketch] = None
|
||||||
|
if occ_dict is not None:
|
||||||
|
try:
|
||||||
|
occ_sketch = OCCSketch.from_dict(occ_dict)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Sketch %s OCCSketch.from_dict() failed: %s", data.get("id"), exc)
|
||||||
|
occ_sketch = OCCSketch()
|
||||||
|
else:
|
||||||
|
occ_sketch = OCCSketch()
|
||||||
|
|
||||||
|
# Re-apply the workplane (from_dict already does this internally, but be
|
||||||
|
# defensive in case the saved dict didn't carry the workplane fields).
|
||||||
|
occ_sketch.set_workplane(
|
||||||
|
tuple(data.get("workplane_origin", (0.0, 0.0, 0.0))),
|
||||||
|
tuple(data.get("workplane_normal", (0.0, 0.0, 1.0))),
|
||||||
|
tuple(data.get("workplane_x_dir", (1.0, 0.0, 0.0))),
|
||||||
|
)
|
||||||
|
|
||||||
|
geometry: Optional[OCCGeometryObject] = None
|
||||||
|
if geometry_loader is not None and data.get("geometry_ref"):
|
||||||
|
geometry = geometry_loader(data["geometry_ref"]) if data.get("has_geometry") else None
|
||||||
|
|
||||||
|
sk = Sketch(
|
||||||
|
id=data.get("id") or None,
|
||||||
|
name=data.get("name", "Untitled Sketch"),
|
||||||
|
occ_sketch=occ_sketch,
|
||||||
|
geometry=geometry,
|
||||||
|
is_solved=bool(data.get("is_solved", False)),
|
||||||
|
is_fully_constrained=bool(data.get("is_fully_constrained", False)),
|
||||||
|
)
|
||||||
|
sk.workplane_origin = _to_3vec(data.get("workplane_origin"))
|
||||||
|
sk.workplane_normal = _to_3vec(data.get("workplane_normal"))
|
||||||
|
sk.workplane_x_dir = _to_3vec(data.get("workplane_x_dir"))
|
||||||
|
sk.apply_workplane()
|
||||||
|
sk.created_at = _parse_iso(data.get("created_at"))
|
||||||
|
sk.modified_at = _parse_iso(data.get("modified_at"))
|
||||||
|
return sk
|
||||||
|
|
||||||
|
|
||||||
|
def _component_to_dict(comp: Component) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": comp.id,
|
||||||
|
"name": comp.name,
|
||||||
|
"description": comp.description,
|
||||||
|
"active_sketch": comp.active_sketch,
|
||||||
|
"active_workplane": comp.active_workplane,
|
||||||
|
"sketches": {sid: _sketch_to_dict(s) for sid, s in comp.sketches.items()},
|
||||||
|
"bodies": {bid: _body_to_dict(b) for bid, b in comp.bodies.items()},
|
||||||
|
"workplanes": {wid: _workplane_to_dict(w) for wid, w in comp.workplanes.items()},
|
||||||
|
"created_at": comp.created_at.isoformat() if comp.created_at else None,
|
||||||
|
"modified_at": comp.modified_at.isoformat() if comp.modified_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _component_from_dict(
|
||||||
|
data: Dict[str, Any],
|
||||||
|
body_geometry_loader: Optional[Callable[[str], Optional[OCCGeometryObject]]] = None,
|
||||||
|
sketch_geometry_loader: Optional[Callable[[str], Optional[OCCGeometryObject]]] = None,
|
||||||
|
) -> Component:
|
||||||
|
comp = Component(
|
||||||
|
id=data.get("id") or None,
|
||||||
|
name=data.get("name", "Untitled Component"),
|
||||||
|
description=data.get("description", ""),
|
||||||
|
active_sketch=data.get("active_sketch"),
|
||||||
|
active_workplane=data.get("active_workplane"),
|
||||||
|
)
|
||||||
|
comp.created_at = _parse_iso(data.get("created_at"))
|
||||||
|
comp.modified_at = _parse_iso(data.get("modified_at"))
|
||||||
|
|
||||||
|
for wid, wp_data in (data.get("workplanes") or {}).items():
|
||||||
|
comp.workplanes[wid] = _workplane_from_dict(wp_data)
|
||||||
|
|
||||||
|
# Sketches first so bodies can reference them.
|
||||||
|
for sid, sk_data in (data.get("sketches") or {}).items():
|
||||||
|
comp.sketches[sid] = _sketch_from_dict(sk_data, sketch_geometry_loader)
|
||||||
|
|
||||||
|
for bid, body_data in (data.get("bodies") or {}).items():
|
||||||
|
src_sketch = None
|
||||||
|
src_id = body_data.get("source_sketch_id")
|
||||||
|
if src_id and src_id in comp.sketches:
|
||||||
|
src_sketch = comp.sketches[src_id]
|
||||||
|
comp.bodies[bid] = _body_from_dict(body_data, body_geometry_loader, src_sketch)
|
||||||
|
|
||||||
|
return comp
|
||||||
|
|
||||||
|
|
||||||
|
def _connector_to_dict(conn: Connector) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": conn.id,
|
||||||
|
"name": conn.name,
|
||||||
|
"position": list(conn.position),
|
||||||
|
"normal": list(conn.normal),
|
||||||
|
"x_dir": list(conn.x_dir),
|
||||||
|
"axis_rotation": float(conn.axis_rotation),
|
||||||
|
"offset": float(conn.offset),
|
||||||
|
"assembly_component_id": conn.assembly_component_id,
|
||||||
|
"source_obj_id": conn.source_obj_id,
|
||||||
|
"partner_ac_id": conn.partner_ac_id,
|
||||||
|
"partner_connector_id": conn.partner_connector_id,
|
||||||
|
"is_grounded": bool(conn.is_grounded),
|
||||||
|
"is_invalid": bool(conn.is_invalid),
|
||||||
|
"created_at": conn.created_at.isoformat() if conn.created_at else None,
|
||||||
|
"modified_at": conn.modified_at.isoformat() if conn.modified_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _connector_from_dict(data: Dict[str, Any]) -> Connector:
|
||||||
|
conn = Connector(
|
||||||
|
id=data.get("id") or None,
|
||||||
|
name=data.get("name", "Untitled Connector"),
|
||||||
|
position=_to_3tuple(data.get("position")),
|
||||||
|
normal=_to_3tuple(data.get("normal")),
|
||||||
|
x_dir=_to_3tuple(data.get("x_dir")),
|
||||||
|
axis_rotation=float(data.get("axis_rotation", 0.0)),
|
||||||
|
offset=float(data.get("offset", 0.0)),
|
||||||
|
assembly_component_id=data.get("assembly_component_id", ""),
|
||||||
|
source_obj_id=data.get("source_obj_id", ""),
|
||||||
|
)
|
||||||
|
conn.partner_ac_id = data.get("partner_ac_id")
|
||||||
|
conn.partner_connector_id = data.get("partner_connector_id")
|
||||||
|
conn.is_grounded = bool(data.get("is_grounded", False))
|
||||||
|
conn.is_invalid = bool(data.get("is_invalid", False))
|
||||||
|
conn.created_at = _parse_iso(data.get("created_at"))
|
||||||
|
conn.modified_at = _parse_iso(data.get("modified_at"))
|
||||||
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
def _assembly_component_to_dict(ac: AssemblyComponent) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": ac.id,
|
||||||
|
"component_id": ac.component_id,
|
||||||
|
"name": ac.name,
|
||||||
|
"position": _coerce_listlike(ac.position),
|
||||||
|
"rotation": _coerce_listlike(ac.rotation),
|
||||||
|
"connectors": {cid: _connector_to_dict(c) for cid, c in ac.connectors.items()},
|
||||||
|
"created_at": ac.created_at.isoformat() if ac.created_at else None,
|
||||||
|
"modified_at": ac.modified_at.isoformat() if ac.modified_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _assembly_component_from_dict(data: Dict[str, Any]) -> AssemblyComponent:
|
||||||
|
ac = AssemblyComponent(
|
||||||
|
id=data.get("id") or None,
|
||||||
|
component_id=data.get("component_id", ""),
|
||||||
|
name=data.get("name", "Untitled Instance"),
|
||||||
|
position=_to_3vec(data.get("position")),
|
||||||
|
rotation=_to_mat3(data.get("rotation")),
|
||||||
|
)
|
||||||
|
ac.created_at = _parse_iso(data.get("created_at"))
|
||||||
|
ac.modified_at = _parse_iso(data.get("modified_at"))
|
||||||
|
for cid, c_data in (data.get("connectors") or {}).items():
|
||||||
|
ac.connectors[cid] = _connector_from_dict(c_data)
|
||||||
|
return ac
|
||||||
|
|
||||||
|
|
||||||
|
def _assembly_connection_to_dict(c: AssemblyConnection) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": c.id,
|
||||||
|
"first_ac_id": c.first_ac_id,
|
||||||
|
"second_ac_id": c.second_ac_id,
|
||||||
|
"first_connector_id": c.first_connector_id,
|
||||||
|
"second_connector_id": c.second_connector_id,
|
||||||
|
"created_at": c.created_at.isoformat() if c.created_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _assembly_connection_from_dict(data: Dict[str, Any]) -> AssemblyConnection:
|
||||||
|
conn = AssemblyConnection(
|
||||||
|
id=data.get("id") or None,
|
||||||
|
first_ac_id=data.get("first_ac_id", ""),
|
||||||
|
second_ac_id=data.get("second_ac_id", ""),
|
||||||
|
first_connector_id=data.get("first_connector_id"),
|
||||||
|
second_connector_id=data.get("second_connector_id"),
|
||||||
|
)
|
||||||
|
conn.created_at = _parse_iso(data.get("created_at"))
|
||||||
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
def _assembly_to_dict(asm: Assembly) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": asm.id,
|
||||||
|
"name": asm.name,
|
||||||
|
"active_assembly_component": asm.active_assembly_component,
|
||||||
|
"components": {cid: _assembly_component_to_dict(ac) for cid, ac in asm.components.items()},
|
||||||
|
"connections": [_assembly_connection_to_dict(c) for c in asm.connections],
|
||||||
|
"created_at": asm.created_at.isoformat() if asm.created_at else None,
|
||||||
|
"modified_at": asm.modified_at.isoformat() if asm.modified_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _assembly_from_dict(data: Dict[str, Any]) -> Assembly:
|
||||||
|
asm = Assembly(
|
||||||
|
id=data.get("id") or None,
|
||||||
|
name=data.get("name", "Untitled Assembly"),
|
||||||
|
active_assembly_component=data.get("active_assembly_component"),
|
||||||
|
)
|
||||||
|
asm.created_at = _parse_iso(data.get("created_at"))
|
||||||
|
asm.modified_at = _parse_iso(data.get("modified_at"))
|
||||||
|
for cid, ac_data in (data.get("components") or {}).items():
|
||||||
|
asm.components[cid] = _assembly_component_from_dict(ac_data)
|
||||||
|
for c_data in (data.get("connections") or []):
|
||||||
|
asm.connections.append(_assembly_connection_from_dict(c_data))
|
||||||
|
return asm
|
||||||
|
|
||||||
|
|
||||||
|
def _project_to_dict(
|
||||||
|
project: Project,
|
||||||
|
view_state: Optional[Dict[str, Any]] = None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"format_version": 1,
|
||||||
|
"name": project.name,
|
||||||
|
"description": project.description,
|
||||||
|
"active_component": project.active_component,
|
||||||
|
"active_assembly": project.active_assembly,
|
||||||
|
"components": {cid: _component_to_dict(c) for cid, c in project.components.items()},
|
||||||
|
"assemblies": {aid: _assembly_to_dict(a) for aid, a in project.assemblies.items()},
|
||||||
|
"created_at": project.created_at.isoformat() if project.created_at else None,
|
||||||
|
"modified_at": project.modified_at.isoformat() if project.modified_at else None,
|
||||||
|
"view_state": view_state or {},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Geometry (STEP) write/read helpers ─────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _write_step_for_body(
|
||||||
|
kernel: OCGeometryKernel,
|
||||||
|
geometry: OCCGeometryObject,
|
||||||
|
) -> Optional[bytes]:
|
||||||
|
"""Serialize a single body geometry to a STEP byte string.
|
||||||
|
|
||||||
|
Returns *None* if OCC reports the shape is empty (so the ZIP can omit
|
||||||
|
the file and the body is restored as geometry-less). The temporary
|
||||||
|
file is created and immediately deleted; we never touch the user's
|
||||||
|
filesystem outside of ``tempfile``.
|
||||||
|
"""
|
||||||
|
fd, tmp_path = tempfile.mkstemp(suffix=".step")
|
||||||
|
os.close(fd)
|
||||||
|
try:
|
||||||
|
ok = kernel.export_step(geometry, tmp_path)
|
||||||
|
if not ok:
|
||||||
|
return None
|
||||||
|
with open(tmp_path, "rb") as f:
|
||||||
|
return f.read()
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
os.unlink(tmp_path)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _read_step_bytes(
|
||||||
|
kernel: OCGeometryKernel,
|
||||||
|
data: bytes,
|
||||||
|
) -> Optional[OCCGeometryObject]:
|
||||||
|
"""Parse a STEP byte string back into an OCCGeometryObject."""
|
||||||
|
fd, tmp_path = tempfile.mkstemp(suffix=".step")
|
||||||
|
os.close(fd)
|
||||||
|
try:
|
||||||
|
with open(tmp_path, "wb") as f:
|
||||||
|
f.write(data)
|
||||||
|
geom = kernel.import_step(tmp_path)
|
||||||
|
return geom
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Failed to read STEP: %s", exc)
|
||||||
|
return None
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
os.unlink(tmp_path)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# ── Save / Load entry points ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def project_zip_path(path: str) -> str:
|
||||||
|
"""Return *path* with the ``.fluency`` extension added if missing."""
|
||||||
|
base, ext = os.path.splitext(path)
|
||||||
|
if ext.lower() == ".fluency":
|
||||||
|
return path
|
||||||
|
return base + ".fluency"
|
||||||
|
|
||||||
|
|
||||||
|
def save_project(
|
||||||
|
project: Project,
|
||||||
|
filepath: str,
|
||||||
|
view_state: Optional[Dict[str, Any]] = None,
|
||||||
|
kernel: Optional[OCGeometryKernel] = None,
|
||||||
|
) -> str:
|
||||||
|
"""Save *project* to a ``.fluency`` ZIP at *filepath*.
|
||||||
|
|
||||||
|
*view_state* (optional) is a free-form dict that the main window uses to
|
||||||
|
record camera position, active tab, etc. It is stored verbatim inside
|
||||||
|
``project.json`` under the ``view_state`` key.
|
||||||
|
|
||||||
|
*kernel* is the OCGeometryKernel to use for STEP export. A new one is
|
||||||
|
created if not provided (slightly slower startup, but never holds stale
|
||||||
|
state). Pass the app's kernel to keep one canonical instance.
|
||||||
|
|
||||||
|
Returns the actual file path that was written.
|
||||||
|
"""
|
||||||
|
filepath = project_zip_path(filepath)
|
||||||
|
kernel = kernel or OCGeometryKernel()
|
||||||
|
|
||||||
|
# Build the manifest in two passes:
|
||||||
|
# pass 1: serialize all metadata + collect body/sketches that need
|
||||||
|
# STEP files written alongside. We track the in-zip path of
|
||||||
|
# each STEP file in the body/sketches' ``geometry_ref`` slot.
|
||||||
|
# pass 2: write the ZIP, streaming each body/sketches's STEP data
|
||||||
|
# into its own archive member.
|
||||||
|
manifest = _project_to_dict(project, view_state)
|
||||||
|
|
||||||
|
# Per-body STEP files. Skipped if the body has no geometry.
|
||||||
|
body_files: List[Tuple[str, bytes]] = []
|
||||||
|
for comp_id, comp in project.components.items():
|
||||||
|
for body_id, body in comp.bodies.items():
|
||||||
|
if body.geometry is None:
|
||||||
|
continue
|
||||||
|
step_bytes = _write_step_for_body(kernel, body.geometry)
|
||||||
|
if step_bytes is None:
|
||||||
|
continue
|
||||||
|
arcname = f"bodies/{body_id}.step"
|
||||||
|
body_files.append((arcname, step_bytes))
|
||||||
|
manifest["components"][comp_id]["bodies"][body_id]["geometry_ref"] = arcname
|
||||||
|
|
||||||
|
# Per-sketch STEP files (solved face geometry).
|
||||||
|
sketch_files: List[Tuple[str, bytes]] = []
|
||||||
|
sketch_meta_files: List[Tuple[str, bytes]] = []
|
||||||
|
for comp_id, comp in project.components.items():
|
||||||
|
for sketch_id, sketch in comp.sketches.items():
|
||||||
|
# Save the OCCSketch state to its own JSON file so the
|
||||||
|
# main project.json stays compact.
|
||||||
|
occ = sketch.occ_sketch.to_dict() if sketch.occ_sketch is not None else None
|
||||||
|
meta = {
|
||||||
|
"id": sketch.id,
|
||||||
|
"name": sketch.name,
|
||||||
|
"workplane_origin": _coerce_listlike(sketch.workplane_origin),
|
||||||
|
"workplane_normal": _coerce_listlike(sketch.workplane_normal),
|
||||||
|
"workplane_x_dir": _coerce_listlike(sketch.workplane_x_dir),
|
||||||
|
"is_solved": bool(sketch.is_solved),
|
||||||
|
"is_fully_constrained": bool(sketch.is_fully_constrained),
|
||||||
|
"occ_sketch": occ,
|
||||||
|
}
|
||||||
|
meta_arc = f"sketches/{sketch_id}/meta.json"
|
||||||
|
sketch_meta_files.append((meta_arc, _to_json(meta).encode("utf-8")))
|
||||||
|
# Drop the heavy occ_sketch payload from the main manifest so
|
||||||
|
# the file is smaller and edits are localised.
|
||||||
|
manifest["components"][comp_id]["sketches"][sketch_id]["occ_sketch"] = None
|
||||||
|
manifest["components"][comp_id]["sketches"][sketch_id]["occ_sketch_ref"] = meta_arc
|
||||||
|
|
||||||
|
if sketch.geometry is None:
|
||||||
|
continue
|
||||||
|
step_bytes = _write_step_for_body(kernel, sketch.geometry)
|
||||||
|
if step_bytes is None:
|
||||||
|
continue
|
||||||
|
arcname = f"sketches/{sketch_id}/solved.step"
|
||||||
|
sketch_files.append((arcname, step_bytes))
|
||||||
|
manifest["components"][comp_id]["sketches"][sketch_id]["geometry_ref"] = arcname
|
||||||
|
|
||||||
|
# Write the ZIP. Use a temp file + rename so a partial write can't
|
||||||
|
# clobber an existing good file.
|
||||||
|
tmp_fd, tmp_path = tempfile.mkstemp(suffix=".fluency")
|
||||||
|
os.close(tmp_fd)
|
||||||
|
try:
|
||||||
|
with zipfile.ZipFile(tmp_path, "w", compression=zipfile.ZIP_DEFLATED) as zf:
|
||||||
|
zf.writestr("project.json", _to_json(manifest))
|
||||||
|
for arcname, data in body_files + sketch_files + sketch_meta_files:
|
||||||
|
zf.writestr(arcname, data)
|
||||||
|
# Atomic-ish replace.
|
||||||
|
shutil.move(tmp_path, filepath)
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
os.unlink(tmp_path)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
raise
|
||||||
|
|
||||||
|
return filepath
|
||||||
|
|
||||||
|
|
||||||
|
def load_project(filepath: str) -> Tuple[Project, Dict[str, Any]]:
|
||||||
|
"""Load a project from a ``.fluency`` ZIP.
|
||||||
|
|
||||||
|
Returns ``(project, view_state)``. The caller is responsible for
|
||||||
|
handing *view_state* to the renderer (camera, etc.) and for re-rendering
|
||||||
|
the scene with the freshly-loaded bodies.
|
||||||
|
"""
|
||||||
|
if not os.path.exists(filepath):
|
||||||
|
raise FileNotFoundError(filepath)
|
||||||
|
|
||||||
|
kernel = OCGeometryKernel()
|
||||||
|
body_cache: Dict[str, Optional[OCCGeometryObject]] = {}
|
||||||
|
|
||||||
|
# Body geometry can be reused across bodies if the same STEP appears
|
||||||
|
# under multiple names (rare, but cheap to handle). We cache by zip
|
||||||
|
# member name.
|
||||||
|
def body_geometry_loader(member_name: str) -> Optional[OCCGeometryObject]:
|
||||||
|
if member_name in body_cache:
|
||||||
|
return body_cache[member_name]
|
||||||
|
try:
|
||||||
|
data = zipf.read(member_name)
|
||||||
|
except KeyError:
|
||||||
|
logger.warning("Body STEP missing in archive: %s", member_name)
|
||||||
|
body_cache[member_name] = None
|
||||||
|
return None
|
||||||
|
geom = _read_step_bytes(kernel, data)
|
||||||
|
body_cache[member_name] = geom
|
||||||
|
return geom
|
||||||
|
|
||||||
|
# Sketch geometry loader shares the same byte path. Sketches that have
|
||||||
|
# solved faces point at sketches/<id>/solved.step.
|
||||||
|
def sketch_geometry_loader(member_name: str) -> Optional[OCCGeometryObject]:
|
||||||
|
return body_geometry_loader(member_name)
|
||||||
|
|
||||||
|
with zipfile.ZipFile(filepath, "r") as zipf:
|
||||||
|
manifest_raw = zipf.read("project.json")
|
||||||
|
manifest = json.loads(manifest_raw.decode("utf-8"))
|
||||||
|
view_state: Dict[str, Any] = manifest.get("view_state") or {}
|
||||||
|
|
||||||
|
# If a sketch's occ_sketch is referenced as a separate file, read
|
||||||
|
# it in now and patch the manifest so _sketch_from_dict sees it.
|
||||||
|
for comp_id, comp_data in (manifest.get("components") or {}).items():
|
||||||
|
for sk_id, sk_data in (comp_data.get("sketches") or {}).items():
|
||||||
|
ref = sk_data.get("occ_sketch_ref")
|
||||||
|
if not ref:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
meta_bytes = zipf.read(ref)
|
||||||
|
except KeyError:
|
||||||
|
logger.warning("Sketch meta missing in archive: %s", ref)
|
||||||
|
continue
|
||||||
|
meta = json.loads(meta_bytes.decode("utf-8"))
|
||||||
|
sk_data["occ_sketch"] = meta.get("occ_sketch")
|
||||||
|
# Workplane fields on the sketch-level file override the
|
||||||
|
# embedded ones (source of truth lives in the sidecar).
|
||||||
|
for k in ("workplane_origin", "workplane_normal", "workplane_x_dir",
|
||||||
|
"is_solved", "is_fully_constrained"):
|
||||||
|
if k in meta:
|
||||||
|
sk_data[k] = meta[k]
|
||||||
|
|
||||||
|
project = Project(
|
||||||
|
name=manifest.get("name", "Untitled Project"),
|
||||||
|
description=manifest.get("description", ""),
|
||||||
|
active_component=manifest.get("active_component"),
|
||||||
|
active_assembly=manifest.get("active_assembly"),
|
||||||
|
kernel=kernel,
|
||||||
|
)
|
||||||
|
project.file_path = filepath
|
||||||
|
project.created_at = _parse_iso(manifest.get("created_at"))
|
||||||
|
project.modified_at = _parse_iso(manifest.get("modified_at"))
|
||||||
|
|
||||||
|
for cid, c_data in (manifest.get("components") or {}).items():
|
||||||
|
project.components[cid] = _component_from_dict(
|
||||||
|
c_data,
|
||||||
|
body_geometry_loader=body_geometry_loader,
|
||||||
|
sketch_geometry_loader=sketch_geometry_loader,
|
||||||
|
)
|
||||||
|
|
||||||
|
for aid, a_data in (manifest.get("assemblies") or {}).items():
|
||||||
|
project.assemblies[aid] = _assembly_from_dict(a_data)
|
||||||
|
|
||||||
|
# After all components are loaded, re-wire connector partner ids so
|
||||||
|
# they point to the freshly-loaded AssemblyComponents. (The dict
|
||||||
|
# round-trip preserves the raw strings; we just make sure the partner
|
||||||
|
# ids are still present in the project so the assembly-move handler
|
||||||
|
# can follow the rigid-group graph.)
|
||||||
|
for asm in project.assemblies.values():
|
||||||
|
for conn in asm.connections:
|
||||||
|
if conn.first_connector_id and conn.first_ac_id in asm.components:
|
||||||
|
first_ac = asm.components[conn.first_ac_id]
|
||||||
|
if conn.first_connector_id in first_ac.connectors:
|
||||||
|
first_ac.connectors[conn.first_connector_id].is_grounded = True
|
||||||
|
if conn.second_connector_id and conn.second_ac_id in asm.components:
|
||||||
|
pass # already set in the connector itself
|
||||||
|
|
||||||
|
return project, view_state
|
||||||
+40
-4228
File diff suppressed because it is too large
Load Diff
@@ -21,6 +21,76 @@ from fluency.geometry_occ.kernel import OCGeometryKernel, OCCGeometryObject
|
|||||||
from fluency.geometry_occ.sketch import OCCSketch
|
from fluency.geometry_occ.sketch import OCCSketch
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Workplane:
|
||||||
|
"""
|
||||||
|
An independent working plane (datum plane) not tied to a face.
|
||||||
|
|
||||||
|
Workplanes can be created at any time and serve as the foundation for
|
||||||
|
sketching and subsequent 3D operations (extrude, cut, revolve, etc.).
|
||||||
|
They are visible in the 3D view as a semi-transparent reference grid.
|
||||||
|
"""
|
||||||
|
|
||||||
|
id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
||||||
|
name: str = "Untitled Workplane"
|
||||||
|
|
||||||
|
origin: Tuple[float, float, float] = (0.0, 0.0, 0.0)
|
||||||
|
normal: Tuple[float, float, float] = (0.0, 0.0, 1.0)
|
||||||
|
x_dir: Tuple[float, float, float] = (1.0, 0.0, 0.0)
|
||||||
|
|
||||||
|
# OCC AIS shape (visual plane) object id in the renderer
|
||||||
|
render_object: Any = None
|
||||||
|
visible: bool = True
|
||||||
|
|
||||||
|
created_at: datetime = field(default_factory=datetime.now)
|
||||||
|
modified_at: datetime = field(default_factory=datetime.now)
|
||||||
|
|
||||||
|
def __post_init__(self):
|
||||||
|
# Normalise normal and x_dir on construction.
|
||||||
|
import numpy as np
|
||||||
|
n = np.asarray(self.normal, dtype=float)
|
||||||
|
n = n / np.linalg.norm(n)
|
||||||
|
x = np.asarray(self.x_dir, dtype=float)
|
||||||
|
# Remove any component of x along n, then renormalise.
|
||||||
|
x = x - np.dot(x, n) * n
|
||||||
|
x_norm = np.linalg.norm(x)
|
||||||
|
if x_norm < 1e-9:
|
||||||
|
fallback = np.array([1.0, 0.0, 0.0]) if abs(n[0]) < 0.9 else np.array([0.0, 1.0, 0.0])
|
||||||
|
x = fallback - np.dot(fallback, n) * n
|
||||||
|
x_norm = np.linalg.norm(x)
|
||||||
|
x = x / x_norm
|
||||||
|
y = np.cross(n, x)
|
||||||
|
y = y / np.linalg.norm(y)
|
||||||
|
self.normal = tuple(float(v) for v in n)
|
||||||
|
self.x_dir = tuple(float(v) for v in x)
|
||||||
|
self._y_dir = tuple(float(v) for v in y)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def y_dir(self) -> Tuple[float, float, float]:
|
||||||
|
"""Derived in-plane Y axis (normal × x_dir)."""
|
||||||
|
return self._y_dir
|
||||||
|
|
||||||
|
def uv_to_world(self, u: float, v: float) -> Tuple[float, float, float]:
|
||||||
|
"""Map a UV point to 3D world coordinates on this plane."""
|
||||||
|
ox, oy, oz = self.origin
|
||||||
|
xx, xy, xz = self.x_dir
|
||||||
|
yx, yy, yz = self._y_dir
|
||||||
|
return (
|
||||||
|
ox + u * xx + v * yx,
|
||||||
|
oy + u * xy + v * yy,
|
||||||
|
oz + u * xz + v * yz,
|
||||||
|
)
|
||||||
|
|
||||||
|
def world_to_uv(self, p: Tuple[float, float, float]) -> Tuple[float, float]:
|
||||||
|
"""Map a 3D world point to UV coordinates on this plane."""
|
||||||
|
import numpy as np
|
||||||
|
ox, oy, oz = self.origin
|
||||||
|
v = np.array([p[0] - ox, p[1] - oy, p[2] - oz])
|
||||||
|
xd = np.array(self.x_dir, dtype=float)
|
||||||
|
yd = np.array(self._y_dir, dtype=float)
|
||||||
|
return (float(np.dot(v, xd)), float(np.dot(v, yd)))
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class Sketch:
|
class Sketch:
|
||||||
"""
|
"""
|
||||||
@@ -152,6 +222,20 @@ class Body:
|
|||||||
source_sketch: Optional[Sketch] = None
|
source_sketch: Optional[Sketch] = None
|
||||||
source_operation: str = "extrude"
|
source_operation: str = "extrude"
|
||||||
|
|
||||||
|
# Re-extrusion parameters — stored so the body can be rebuilt from
|
||||||
|
# its source sketch when the sketch is edited. None means the body
|
||||||
|
# was not created by an extrude-type operation and cannot be auto-
|
||||||
|
# rebuilt.
|
||||||
|
extrude_length: Optional[float] = None
|
||||||
|
extrude_symmetric: bool = False
|
||||||
|
extrude_invert: bool = False
|
||||||
|
extrude_cut: bool = False
|
||||||
|
extrude_union: bool = False
|
||||||
|
extrude_through_all: bool = False
|
||||||
|
extrude_face_index: Optional[int] = None # which sketch face was selected
|
||||||
|
extrude_target_body_id: Optional[str] = None # for cut/union: target body id
|
||||||
|
needs_update: bool = False # True when source sketch changed since last extrude
|
||||||
|
|
||||||
position: np.ndarray = field(default_factory=lambda: np.array([0.0, 0.0, 0.0]))
|
position: np.ndarray = field(default_factory=lambda: np.array([0.0, 0.0, 0.0]))
|
||||||
rotation: np.ndarray = field(default_factory=lambda: np.eye(3))
|
rotation: np.ndarray = field(default_factory=lambda: np.eye(3))
|
||||||
|
|
||||||
@@ -192,12 +276,34 @@ class Component:
|
|||||||
|
|
||||||
sketches: Dict[str, Sketch] = field(default_factory=dict)
|
sketches: Dict[str, Sketch] = field(default_factory=dict)
|
||||||
bodies: Dict[str, Body] = field(default_factory=dict)
|
bodies: Dict[str, Body] = field(default_factory=dict)
|
||||||
|
workplanes: Dict[str, Workplane] = field(default_factory=dict)
|
||||||
|
|
||||||
active_sketch: Optional[str] = None
|
active_sketch: Optional[str] = None
|
||||||
|
active_workplane: Optional[str] = None
|
||||||
|
|
||||||
created_at: datetime = field(default_factory=datetime.now)
|
created_at: datetime = field(default_factory=datetime.now)
|
||||||
modified_at: datetime = field(default_factory=datetime.now)
|
modified_at: datetime = field(default_factory=datetime.now)
|
||||||
|
|
||||||
|
def add_workplane(self, workplane: Optional[Workplane] = None) -> Workplane:
|
||||||
|
"""Add an independent workplane to the component."""
|
||||||
|
if workplane is None:
|
||||||
|
workplane = Workplane(name=f"Workplane {len(self.workplanes) + 1}")
|
||||||
|
self.workplanes[workplane.id] = workplane
|
||||||
|
if self.active_workplane is None:
|
||||||
|
self.active_workplane = workplane.id
|
||||||
|
self.modified_at = datetime.now()
|
||||||
|
return workplane
|
||||||
|
|
||||||
|
def remove_workplane(self, wp_id: str) -> bool:
|
||||||
|
"""Remove a workplane from the component."""
|
||||||
|
if wp_id in self.workplanes:
|
||||||
|
del self.workplanes[wp_id]
|
||||||
|
if self.active_workplane == wp_id:
|
||||||
|
self.active_workplane = next(iter(self.workplanes.keys()), None)
|
||||||
|
self.modified_at = datetime.now()
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
def add_sketch(self, sketch: Optional[Sketch] = None) -> Sketch:
|
def add_sketch(self, sketch: Optional[Sketch] = None) -> Sketch:
|
||||||
"""Add a sketch to the component."""
|
"""Add a sketch to the component."""
|
||||||
if sketch is None:
|
if sketch is None:
|
||||||
@@ -244,6 +350,264 @@ class Component:
|
|||||||
self.modified_at = datetime.now()
|
self.modified_at = datetime.now()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Connector:
|
||||||
|
"""
|
||||||
|
A connection point on an assembly component instance.
|
||||||
|
|
||||||
|
Stores the position and orientation of a connection point
|
||||||
|
(e.g. a hole center, face midpoint, or edge point) that will
|
||||||
|
later be used by the SolveSpace solver to mate components.
|
||||||
|
The *normal* defines the connection axis direction (e.g. the
|
||||||
|
hole axis for a screw connection).
|
||||||
|
"""
|
||||||
|
|
||||||
|
id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
||||||
|
name: str = "Untitled Connector"
|
||||||
|
|
||||||
|
# 3D position of the connection point (component-local coords).
|
||||||
|
# Transformed to world coords at render time using the parent
|
||||||
|
# AssemblyComponent's position/rotation, so connectors move with
|
||||||
|
# their component automatically.
|
||||||
|
position: Tuple[float, float, float] = (0.0, 0.0, 0.0)
|
||||||
|
# Normal direction of the connection (e.g. hole axis) in local coords.
|
||||||
|
normal: Tuple[float, float, float] = (0.0, 0.0, 1.0)
|
||||||
|
# In-plane X direction for defining the reference frame (local coords).
|
||||||
|
x_dir: Tuple[float, float, float] = (1.0, 0.0, 0.0)
|
||||||
|
|
||||||
|
# Rotation around the normal axis (degrees).
|
||||||
|
axis_rotation: float = 0.0
|
||||||
|
# Offset distance along the normal.
|
||||||
|
offset: float = 0.0
|
||||||
|
|
||||||
|
# Which AssemblyComponent this connector belongs to.
|
||||||
|
assembly_component_id: str = ""
|
||||||
|
# Which body/face this connector was placed on (renderer obj_id).
|
||||||
|
source_obj_id: str = ""
|
||||||
|
|
||||||
|
# --- Rigid-group pairing (set when two connectors are mated) ---
|
||||||
|
# The id of the partner AssemblyComponent this connector is mated to.
|
||||||
|
# The FIRST-picked component is the grounded reference of the pair;
|
||||||
|
# 'is_grounded' marks that side so the move handler knows which half
|
||||||
|
# is the fixed frame of the rigid group.
|
||||||
|
partner_ac_id: Optional[str] = None
|
||||||
|
# The id of the partner Connector on the partner component.
|
||||||
|
partner_connector_id: Optional[str] = None
|
||||||
|
# True on the first-picked (grounded) connector of a mated pair.
|
||||||
|
is_grounded: bool = False
|
||||||
|
|
||||||
|
# True when the source geometry this connector was placed on has
|
||||||
|
# changed or disappeared — the connector needs re-placement.
|
||||||
|
is_invalid: bool = False
|
||||||
|
|
||||||
|
created_at: datetime = field(default_factory=datetime.now)
|
||||||
|
modified_at: datetime = field(default_factory=datetime.now)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AssemblyComponent:
|
||||||
|
"""
|
||||||
|
An instance of a component within an assembly.
|
||||||
|
|
||||||
|
References a component in the project and stores its relative
|
||||||
|
position and rotation for placement within the assembly.
|
||||||
|
Holds connectors that define connection points for the
|
||||||
|
SolveSpace solver.
|
||||||
|
"""
|
||||||
|
|
||||||
|
id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
||||||
|
component_id: str = ""
|
||||||
|
name: str = "Untitled Instance"
|
||||||
|
|
||||||
|
# Position and orientation relative to the assembly origin.
|
||||||
|
position: np.ndarray = field(default_factory=lambda: np.array([0.0, 0.0, 0.0]))
|
||||||
|
rotation: np.ndarray = field(default_factory=lambda: np.eye(3))
|
||||||
|
|
||||||
|
# Connectors defined on this component instance.
|
||||||
|
connectors: Dict[str, Connector] = field(default_factory=dict)
|
||||||
|
|
||||||
|
created_at: datetime = field(default_factory=datetime.now)
|
||||||
|
modified_at: datetime = field(default_factory=datetime.now)
|
||||||
|
|
||||||
|
def add_connector(
|
||||||
|
self,
|
||||||
|
position: Tuple[float, float, float],
|
||||||
|
normal: Tuple[float, float, float],
|
||||||
|
x_dir: Tuple[float, float, float],
|
||||||
|
source_obj_id: str = "",
|
||||||
|
name: Optional[str] = None,
|
||||||
|
) -> Connector:
|
||||||
|
"""Add a connector to this component instance."""
|
||||||
|
conn = Connector(
|
||||||
|
name=name or f"Connector {len(self.connectors) + 1}",
|
||||||
|
position=position,
|
||||||
|
normal=normal,
|
||||||
|
x_dir=x_dir,
|
||||||
|
assembly_component_id=self.id,
|
||||||
|
source_obj_id=source_obj_id,
|
||||||
|
)
|
||||||
|
self.connectors[conn.id] = conn
|
||||||
|
self.modified_at = datetime.now()
|
||||||
|
return conn
|
||||||
|
|
||||||
|
def remove_connector(self, connector_id: str) -> bool:
|
||||||
|
"""Remove a connector from this component instance."""
|
||||||
|
if connector_id in self.connectors:
|
||||||
|
del self.connectors[connector_id]
|
||||||
|
self.modified_at = datetime.now()
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AssemblyConnection:
|
||||||
|
"""A mated connector pair linking two AssemblyComponents.
|
||||||
|
|
||||||
|
Records which component is the grounded reference (``first_ac_id``) and
|
||||||
|
which was solved against it (``second_ac_id``), plus the partner
|
||||||
|
connector ids so the linkage can be followed / removed symmetrically.
|
||||||
|
Used by the assembly-move handler to propagate translations across the
|
||||||
|
rigid group.
|
||||||
|
"""
|
||||||
|
|
||||||
|
id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
||||||
|
first_ac_id: str = "" # grounded reference side
|
||||||
|
second_ac_id: str = "" # solved side
|
||||||
|
first_connector_id: Optional[str] = None
|
||||||
|
second_connector_id: Optional[str] = None
|
||||||
|
created_at: datetime = field(default_factory=datetime.now)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Assembly:
|
||||||
|
"""
|
||||||
|
An assembly of multiple component instances.
|
||||||
|
|
||||||
|
An assembly groups component instances with relative positions,
|
||||||
|
ready for constraint solving and joint definition between them.
|
||||||
|
Components can be instanced multiple times, each at a different
|
||||||
|
position and rotation.
|
||||||
|
"""
|
||||||
|
|
||||||
|
id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
||||||
|
name: str = "Untitled Assembly"
|
||||||
|
|
||||||
|
components: Dict[str, AssemblyComponent] = field(default_factory=dict)
|
||||||
|
active_assembly_component: Optional[str] = None
|
||||||
|
|
||||||
|
# Mated connector pairs — each entry links two AssemblyComponents so the
|
||||||
|
# assembly-move handler can propagate rigid-group translations. The
|
||||||
|
# 'first_ac_id' side is the grounded reference of the pair.
|
||||||
|
connections: List["AssemblyConnection"] = field(default_factory=list)
|
||||||
|
|
||||||
|
created_at: datetime = field(default_factory=datetime.now)
|
||||||
|
modified_at: datetime = field(default_factory=datetime.now)
|
||||||
|
|
||||||
|
def add_connection(self, first_ac_id: str, second_ac_id: str) -> "AssemblyConnection":
|
||||||
|
"""Record a mated connector pair between two component instances.
|
||||||
|
|
||||||
|
The first-picked component (``first_ac_id``) is treated as the
|
||||||
|
grounded reference of the pair. Guards against duplicate entries.
|
||||||
|
Returns the AssemblyConnection for further bookkeeping.
|
||||||
|
"""
|
||||||
|
# Guard: deduplicate — same pair in either order
|
||||||
|
for c in self.connections:
|
||||||
|
if (c.first_ac_id == first_ac_id and c.second_ac_id == second_ac_id) or \
|
||||||
|
(c.first_ac_id == second_ac_id and c.second_ac_id == first_ac_id):
|
||||||
|
return c
|
||||||
|
conn = AssemblyConnection(
|
||||||
|
first_ac_id=first_ac_id,
|
||||||
|
second_ac_id=second_ac_id,
|
||||||
|
)
|
||||||
|
self.connections.append(conn)
|
||||||
|
self.modified_at = datetime.now()
|
||||||
|
return conn
|
||||||
|
|
||||||
|
def remove_connections_for(self, ac_id: str) -> None:
|
||||||
|
"""Drop every connection that involves *ac_id* (e.g. on removal)."""
|
||||||
|
self.connections = [
|
||||||
|
c for c in self.connections
|
||||||
|
if c.first_ac_id != ac_id and c.second_ac_id != ac_id
|
||||||
|
]
|
||||||
|
|
||||||
|
def get_rigid_group(self, ac_id: str) -> List[str]:
|
||||||
|
"""Return ids of all components rigidly linked to *ac_id* (BFS).
|
||||||
|
|
||||||
|
Includes *ac_id* itself. Two components are linked when a mated
|
||||||
|
connector pair (in ``connections``) joins them; linkage is
|
||||||
|
transitive, so the whole connected subgraph forms one rigid group.
|
||||||
|
"""
|
||||||
|
if ac_id not in self.components:
|
||||||
|
return []
|
||||||
|
# Build adjacency from the connection list.
|
||||||
|
adj: Dict[str, List[str]] = {}
|
||||||
|
for c in self.connections:
|
||||||
|
adj.setdefault(c.first_ac_id, []).append(c.second_ac_id)
|
||||||
|
adj.setdefault(c.second_ac_id, []).append(c.first_ac_id)
|
||||||
|
seen: List[str] = []
|
||||||
|
queue: List[str] = [ac_id]
|
||||||
|
while queue:
|
||||||
|
cur = queue.pop(0)
|
||||||
|
if cur in seen:
|
||||||
|
continue
|
||||||
|
seen.append(cur)
|
||||||
|
for nb in adj.get(cur, []):
|
||||||
|
if nb not in seen:
|
||||||
|
queue.append(nb)
|
||||||
|
return seen
|
||||||
|
|
||||||
|
def is_grounded_reference(self, ac_id: str) -> bool:
|
||||||
|
"""True if *ac_id* is the grounded (first-picked) side of any pair."""
|
||||||
|
return any(c.first_ac_id == ac_id for c in self.connections)
|
||||||
|
|
||||||
|
def get_group_size(self, ac_id: str) -> int:
|
||||||
|
"""Number of components rigidly linked to *ac_id* (including itself)."""
|
||||||
|
return len(self.get_rigid_group(ac_id))
|
||||||
|
|
||||||
|
def add_component_instance(
|
||||||
|
self, component_id: str, name: Optional[str] = None
|
||||||
|
) -> AssemblyComponent:
|
||||||
|
"""Add a component instance to the assembly.
|
||||||
|
|
||||||
|
Returns the newly created AssemblyComponent. The same
|
||||||
|
component can be added multiple times (multiple instances).
|
||||||
|
"""
|
||||||
|
ac = AssemblyComponent(
|
||||||
|
component_id=component_id,
|
||||||
|
name=name or f"Instance {len(self.components) + 1}",
|
||||||
|
)
|
||||||
|
self.components[ac.id] = ac
|
||||||
|
if self.active_assembly_component is None:
|
||||||
|
self.active_assembly_component = ac.id
|
||||||
|
self.modified_at = datetime.now()
|
||||||
|
return ac
|
||||||
|
|
||||||
|
def remove_component_instance(self, assembly_component_id: str) -> bool:
|
||||||
|
"""Remove a component instance from the assembly."""
|
||||||
|
if assembly_component_id in self.components:
|
||||||
|
del self.components[assembly_component_id]
|
||||||
|
# Also drop any mated-connector links that referenced this
|
||||||
|
# instance — otherwise stale connection edges would remain in
|
||||||
|
# the rigid-group graph and point at a missing component.
|
||||||
|
self.remove_connections_for(assembly_component_id)
|
||||||
|
if self.active_assembly_component == assembly_component_id:
|
||||||
|
self.active_assembly_component = next(
|
||||||
|
iter(self.components.keys()), None
|
||||||
|
)
|
||||||
|
self.modified_at = datetime.now()
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def get_active_instance(self) -> Optional[AssemblyComponent]:
|
||||||
|
"""Get the currently active assembly component instance."""
|
||||||
|
if (
|
||||||
|
self.active_assembly_component
|
||||||
|
and self.active_assembly_component in self.components
|
||||||
|
):
|
||||||
|
return self.components[self.active_assembly_component]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class Project:
|
class Project:
|
||||||
"""
|
"""
|
||||||
@@ -259,12 +623,85 @@ class Project:
|
|||||||
components: Dict[str, Component] = field(default_factory=dict)
|
components: Dict[str, Component] = field(default_factory=dict)
|
||||||
active_component: Optional[str] = None
|
active_component: Optional[str] = None
|
||||||
|
|
||||||
|
assemblies: Dict[str, Assembly] = field(default_factory=dict)
|
||||||
|
active_assembly: Optional[str] = None
|
||||||
|
|
||||||
kernel: OCGeometryKernel = field(default_factory=OCGeometryKernel)
|
kernel: OCGeometryKernel = field(default_factory=OCGeometryKernel)
|
||||||
|
|
||||||
created_at: datetime = field(default_factory=datetime.now)
|
created_at: datetime = field(default_factory=datetime.now)
|
||||||
modified_at: datetime = field(default_factory=datetime.now)
|
modified_at: datetime = field(default_factory=datetime.now)
|
||||||
file_path: Optional[str] = None
|
file_path: Optional[str] = None
|
||||||
|
|
||||||
|
# ── Component helpers ──
|
||||||
|
|
||||||
|
def add_component(self, component: Optional[Component] = None) -> Component:
|
||||||
|
"""Add a component to the project."""
|
||||||
|
if component is None:
|
||||||
|
component = Component(name=f"Component {len(self.components) + 1}")
|
||||||
|
self.components[component.id] = component
|
||||||
|
if self.active_component is None:
|
||||||
|
self.active_component = component.id
|
||||||
|
self.modified_at = datetime.now()
|
||||||
|
return component
|
||||||
|
|
||||||
|
def remove_component(self, component_id: str) -> bool:
|
||||||
|
"""Remove a component from the project."""
|
||||||
|
if component_id in self.components:
|
||||||
|
del self.components[component_id]
|
||||||
|
if self.active_component == component_id:
|
||||||
|
self.active_component = next(iter(self.components.keys()), None)
|
||||||
|
self.modified_at = datetime.now()
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def get_active_component(self) -> Optional[Component]:
|
||||||
|
"""Get the currently active component."""
|
||||||
|
if self.active_component and self.active_component in self.components:
|
||||||
|
return self.components[self.active_component]
|
||||||
|
return None
|
||||||
|
|
||||||
|
def set_active_component(self, component_id: Optional[str]) -> None:
|
||||||
|
"""Set the active component."""
|
||||||
|
self.active_component = component_id
|
||||||
|
self.modified_at = datetime.now()
|
||||||
|
|
||||||
|
# ── Assembly helpers ──
|
||||||
|
|
||||||
|
def add_assembly(self, assembly: Optional[Assembly] = None) -> Assembly:
|
||||||
|
"""Add an assembly to the project."""
|
||||||
|
if assembly is None:
|
||||||
|
assembly = Assembly(name=f"Assembly {len(self.assemblies) + 1}")
|
||||||
|
self.assemblies[assembly.id] = assembly
|
||||||
|
if self.active_assembly is None:
|
||||||
|
self.active_assembly = assembly.id
|
||||||
|
self.modified_at = datetime.now()
|
||||||
|
return assembly
|
||||||
|
|
||||||
|
def remove_assembly(self, assembly_id: str) -> bool:
|
||||||
|
"""Remove an assembly from the project."""
|
||||||
|
if assembly_id in self.assemblies:
|
||||||
|
del self.assemblies[assembly_id]
|
||||||
|
if self.active_assembly == assembly_id:
|
||||||
|
self.active_assembly = next(iter(self.assemblies.keys()), None)
|
||||||
|
self.modified_at = datetime.now()
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def get_active_assembly(self) -> Optional[Assembly]:
|
||||||
|
"""Get the currently active assembly."""
|
||||||
|
if self.active_assembly and self.active_assembly in self.assemblies:
|
||||||
|
return self.assemblies[self.active_assembly]
|
||||||
|
return None
|
||||||
|
|
||||||
|
def set_active_assembly(self, assembly_id: Optional[str]) -> None:
|
||||||
|
"""Set the active assembly."""
|
||||||
|
self.active_assembly = assembly_id
|
||||||
|
self.modified_at = datetime.now()
|
||||||
|
|
||||||
|
def get_component_by_id(self, component_id: str) -> Optional[Component]:
|
||||||
|
"""Look up a component by id across all project components."""
|
||||||
|
return self.components.get(component_id)
|
||||||
|
|
||||||
def add_component(self, component: Optional[Component] = None) -> Component:
|
def add_component(self, component: Optional[Component] = None) -> Component:
|
||||||
"""Add a component to the project."""
|
"""Add a component to the project."""
|
||||||
if component is None:
|
if component is None:
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"""Rendering module."""
|
"""Rendering module."""
|
||||||
|
|
||||||
|
# OCC/OpenGL viewport renderers
|
||||||
from fluency.rendering.base import (
|
from fluency.rendering.base import (
|
||||||
Renderer,
|
Renderer,
|
||||||
RenderObject,
|
RenderObject,
|
||||||
@@ -8,10 +9,22 @@ from fluency.rendering.base import (
|
|||||||
from fluency.rendering.pygfx_renderer import PygfxRenderer, PygfxRenderObject
|
from fluency.rendering.pygfx_renderer import PygfxRenderer, PygfxRenderObject
|
||||||
from fluency.rendering.occ_renderer import OCCRenderer, OCCRenderObject
|
from fluency.rendering.occ_renderer import OCCRenderer, OCCRenderObject
|
||||||
|
|
||||||
|
# Photorealistic render backends
|
||||||
|
from fluency.rendering.render_backend import (
|
||||||
|
RenderBackend,
|
||||||
|
RenderMaterial,
|
||||||
|
RenderCamera,
|
||||||
|
RenderSettings,
|
||||||
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"Renderer",
|
"Renderer",
|
||||||
"RenderObject",
|
"RenderObject",
|
||||||
"RenderColor",
|
"RenderColor",
|
||||||
"PygfxRenderer",
|
"PygfxRenderer",
|
||||||
"PygfxRenderObject",
|
"PygfxRenderObject",
|
||||||
|
"RenderBackend",
|
||||||
|
"RenderMaterial",
|
||||||
|
"RenderCamera",
|
||||||
|
"RenderSettings",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
"""Material presets for the render backend.
|
||||||
|
|
||||||
|
Each preset is a RenderMaterial with physically-plausible values.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Dict, List
|
||||||
|
|
||||||
|
from .render_backend import RenderMaterial
|
||||||
|
|
||||||
|
# ── Preset library ──────────────────────────────────────────────────────
|
||||||
|
# Note: Mitsuba pip installs don't include spectral metal data files,
|
||||||
|
# so metal_preset is not used. Instead, metals use material="none" with
|
||||||
|
# specular_reflectance set to the metal color.
|
||||||
|
|
||||||
|
PRESETS: Dict[str, RenderMaterial] = {
|
||||||
|
# ── Metals ──────────────────────────────────────────────────────
|
||||||
|
"Brushed Steel": RenderMaterial(
|
||||||
|
name="Brushed Steel",
|
||||||
|
color=(0.65, 0.67, 0.72),
|
||||||
|
metallic=0.9,
|
||||||
|
roughness=0.35,
|
||||||
|
bsdf_type="roughconductor",
|
||||||
|
),
|
||||||
|
"Polished Chrome": RenderMaterial(
|
||||||
|
name="Polished Chrome",
|
||||||
|
color=(0.8, 0.8, 0.8),
|
||||||
|
metallic=1.0,
|
||||||
|
roughness=0.05,
|
||||||
|
bsdf_type="roughconductor",
|
||||||
|
),
|
||||||
|
"Brushed Aluminum": RenderMaterial(
|
||||||
|
name="Brushed Aluminum",
|
||||||
|
color=(0.75, 0.75, 0.75),
|
||||||
|
metallic=0.85,
|
||||||
|
roughness=0.25,
|
||||||
|
bsdf_type="roughconductor",
|
||||||
|
),
|
||||||
|
"Copper": RenderMaterial(
|
||||||
|
name="Copper",
|
||||||
|
color=(0.95, 0.64, 0.54),
|
||||||
|
metallic=0.95,
|
||||||
|
roughness=0.15,
|
||||||
|
bsdf_type="roughconductor",
|
||||||
|
),
|
||||||
|
"Gold": RenderMaterial(
|
||||||
|
name="Gold",
|
||||||
|
color=(1.0, 0.76, 0.33),
|
||||||
|
metallic=1.0,
|
||||||
|
roughness=0.1,
|
||||||
|
bsdf_type="roughconductor",
|
||||||
|
),
|
||||||
|
"Blackened Steel": RenderMaterial(
|
||||||
|
name="Blackened Steel",
|
||||||
|
color=(0.15, 0.15, 0.17),
|
||||||
|
metallic=0.8,
|
||||||
|
roughness=0.4,
|
||||||
|
bsdf_type="roughconductor",
|
||||||
|
),
|
||||||
|
# ── Plastics ────────────────────────────────────────────────────
|
||||||
|
"Matte Plastic": RenderMaterial(
|
||||||
|
name="Matte Plastic",
|
||||||
|
color=(0.2, 0.5, 0.8),
|
||||||
|
metallic=0.0,
|
||||||
|
roughness=0.6,
|
||||||
|
bsdf_type="plastic",
|
||||||
|
int_ior=1.5,
|
||||||
|
),
|
||||||
|
"Glossy Plastic": RenderMaterial(
|
||||||
|
name="Glossy Plastic",
|
||||||
|
color=(0.2, 0.5, 0.8),
|
||||||
|
metallic=0.0,
|
||||||
|
roughness=0.1,
|
||||||
|
bsdf_type="plastic",
|
||||||
|
int_ior=1.5,
|
||||||
|
),
|
||||||
|
"White Nylon": RenderMaterial(
|
||||||
|
name="White Nylon",
|
||||||
|
color=(0.85, 0.85, 0.83),
|
||||||
|
metallic=0.0,
|
||||||
|
roughness=0.45,
|
||||||
|
bsdf_type="plastic",
|
||||||
|
int_ior=1.53,
|
||||||
|
),
|
||||||
|
"Black ABS": RenderMaterial(
|
||||||
|
name="Black ABS",
|
||||||
|
color=(0.05, 0.05, 0.05),
|
||||||
|
metallic=0.0,
|
||||||
|
roughness=0.35,
|
||||||
|
bsdf_type="plastic",
|
||||||
|
int_ior=1.54,
|
||||||
|
),
|
||||||
|
"Red PA12": RenderMaterial(
|
||||||
|
name="Red PA12",
|
||||||
|
color=(0.75, 0.08, 0.08),
|
||||||
|
metallic=0.0,
|
||||||
|
roughness=0.4,
|
||||||
|
bsdf_type="plastic",
|
||||||
|
int_ior=1.53,
|
||||||
|
),
|
||||||
|
# ── Other ───────────────────────────────────────────────────────
|
||||||
|
"Rubber": RenderMaterial(
|
||||||
|
name="Rubber",
|
||||||
|
color=(0.1, 0.1, 0.1),
|
||||||
|
metallic=0.0,
|
||||||
|
roughness=0.9,
|
||||||
|
bsdf_type="diffuse",
|
||||||
|
),
|
||||||
|
"Ceramic White": RenderMaterial(
|
||||||
|
name="Ceramic White",
|
||||||
|
color=(0.92, 0.91, 0.88),
|
||||||
|
metallic=0.0,
|
||||||
|
roughness=0.15,
|
||||||
|
bsdf_type="dielectric",
|
||||||
|
int_ior=1.55,
|
||||||
|
),
|
||||||
|
"Glass": RenderMaterial(
|
||||||
|
name="Glass",
|
||||||
|
color=(0.95, 0.95, 0.95),
|
||||||
|
metallic=0.0,
|
||||||
|
roughness=0.0,
|
||||||
|
bsdf_type="dielectric",
|
||||||
|
int_ior=1.52,
|
||||||
|
),
|
||||||
|
"Wood": RenderMaterial(
|
||||||
|
name="Wood",
|
||||||
|
color=(0.6, 0.4, 0.2),
|
||||||
|
metallic=0.0,
|
||||||
|
roughness=0.7,
|
||||||
|
bsdf_type="diffuse",
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_preset(name: str) -> RenderMaterial:
|
||||||
|
"""Get a material preset by name. Falls back to default if not found."""
|
||||||
|
if name in PRESETS:
|
||||||
|
return PRESETS[name]
|
||||||
|
return default_material()
|
||||||
|
|
||||||
|
|
||||||
|
def default_material() -> RenderMaterial:
|
||||||
|
"""Return the default grey material."""
|
||||||
|
return RenderMaterial(
|
||||||
|
name="Default",
|
||||||
|
color=(0.7, 0.7, 0.7),
|
||||||
|
metallic=0.0,
|
||||||
|
roughness=0.5,
|
||||||
|
bsdf_type="diffuse",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def preset_names() -> List[str]:
|
||||||
|
"""Return sorted list of available preset names."""
|
||||||
|
return sorted(PRESETS.keys())
|
||||||
@@ -0,0 +1,382 @@
|
|||||||
|
"""Mitsuba 3 photorealistic render backend.
|
||||||
|
|
||||||
|
Requires: ``pip install mitsuba``
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from typing import Callable, Optional
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from .render_backend import RenderBackend, RenderCamera, RenderMaterial, RenderSettings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class MitsubaBackend(RenderBackend):
|
||||||
|
"""Render backend using Mitsuba 3 path tracer."""
|
||||||
|
|
||||||
|
def name(self) -> str:
|
||||||
|
return "Mitsuba 3"
|
||||||
|
|
||||||
|
def is_available(self) -> bool:
|
||||||
|
import sys
|
||||||
|
import io
|
||||||
|
|
||||||
|
old_stderr = sys.stderr
|
||||||
|
sys.stderr = io.StringIO()
|
||||||
|
try:
|
||||||
|
import mitsuba # noqa: F401
|
||||||
|
|
||||||
|
return True
|
||||||
|
except ImportError:
|
||||||
|
return False
|
||||||
|
finally:
|
||||||
|
sys.stderr = old_stderr
|
||||||
|
|
||||||
|
# ── Scene construction ──────────────────────────────────────────
|
||||||
|
|
||||||
|
def _build_scene_dict(
|
||||||
|
self,
|
||||||
|
mesh_path: str,
|
||||||
|
material: RenderMaterial,
|
||||||
|
camera: RenderCamera,
|
||||||
|
settings: RenderSettings,
|
||||||
|
) -> dict:
|
||||||
|
"""Build a Mitsuba scene dictionary from our data classes.
|
||||||
|
|
||||||
|
Uses a 3-point lighting setup (key + fill + rim) plus an ambient
|
||||||
|
environment emitter for soft fill, giving well-balanced shading on
|
||||||
|
all faces of the model. Lighting intensities and colors come from
|
||||||
|
``settings.lighting``; ground plane comes from ``settings.ground_plane``.
|
||||||
|
"""
|
||||||
|
import mitsuba as mi
|
||||||
|
|
||||||
|
lighting = settings.lighting
|
||||||
|
ground = settings.ground_plane
|
||||||
|
|
||||||
|
# Map our BSDF types to Mitsuba BSDF dicts
|
||||||
|
bsdf = self._make_bsdf(material)
|
||||||
|
|
||||||
|
# Determine mesh file type from extension
|
||||||
|
ext = os.path.splitext(mesh_path)[1].lower()
|
||||||
|
shape_type = "ply" if ext == ".ply" else "obj"
|
||||||
|
|
||||||
|
# Build camera-to-world transform using the Python API
|
||||||
|
cam_to_world = mi.ScalarTransform4f.look_at(
|
||||||
|
origin=list(camera.origin),
|
||||||
|
target=list(camera.target),
|
||||||
|
up=list(camera.up),
|
||||||
|
)
|
||||||
|
|
||||||
|
scene = {
|
||||||
|
"type": "scene",
|
||||||
|
# Integrator
|
||||||
|
"integrator": {
|
||||||
|
"type": "path",
|
||||||
|
"max_depth": settings.max_depth,
|
||||||
|
},
|
||||||
|
# Camera
|
||||||
|
"sensor": {
|
||||||
|
"type": "perspective",
|
||||||
|
"fov": camera.fov,
|
||||||
|
"to_world": cam_to_world,
|
||||||
|
"film": {
|
||||||
|
"type": "hdrfilm",
|
||||||
|
"width": settings.width,
|
||||||
|
"height": settings.height,
|
||||||
|
"rfilter": {"type": "gaussian"},
|
||||||
|
},
|
||||||
|
"sampler": {
|
||||||
|
"type": "independent",
|
||||||
|
"sample_count": settings.spp,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
# Ambient environment fill
|
||||||
|
"emitter": {
|
||||||
|
"type": "constant",
|
||||||
|
"radiance": {
|
||||||
|
"type": "rgb",
|
||||||
|
"value": [
|
||||||
|
lighting.ambient_intensity,
|
||||||
|
lighting.ambient_intensity * 0.97,
|
||||||
|
lighting.ambient_intensity * 0.94,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
# Shape
|
||||||
|
"shape": {
|
||||||
|
"type": shape_type,
|
||||||
|
"filename": mesh_path,
|
||||||
|
"bsdf": bsdf,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── 3-point lighting (colors and intensities from config) ───
|
||||||
|
key_rgb = [c * lighting.key_intensity for c in lighting.key_color]
|
||||||
|
key_to_world = mi.ScalarTransform4f.look_at(
|
||||||
|
origin=[1.0, -0.8, 1.2],
|
||||||
|
target=[0.0, 0.0, 0.0],
|
||||||
|
up=[0.0, 0.0, 1.0],
|
||||||
|
)
|
||||||
|
scene["key_light"] = {
|
||||||
|
"type": "directional",
|
||||||
|
"to_world": key_to_world,
|
||||||
|
"irradiance": {"type": "rgb", "value": key_rgb},
|
||||||
|
}
|
||||||
|
|
||||||
|
fill_rgb = [c * lighting.fill_intensity for c in lighting.fill_color]
|
||||||
|
fill_to_world = mi.ScalarTransform4f.look_at(
|
||||||
|
origin=[-1.0, 0.6, 0.8],
|
||||||
|
target=[0.0, 0.0, 0.0],
|
||||||
|
up=[0.0, 0.0, 1.0],
|
||||||
|
)
|
||||||
|
scene["fill_light"] = {
|
||||||
|
"type": "directional",
|
||||||
|
"to_world": fill_to_world,
|
||||||
|
"irradiance": {"type": "rgb", "value": fill_rgb},
|
||||||
|
}
|
||||||
|
|
||||||
|
rim_rgb = [c * lighting.rim_intensity for c in lighting.rim_color]
|
||||||
|
rim_to_world = mi.ScalarTransform4f.look_at(
|
||||||
|
origin=[-0.3, 1.2, -0.8],
|
||||||
|
target=[0.0, 0.0, 0.0],
|
||||||
|
up=[0.0, 0.0, 1.0],
|
||||||
|
)
|
||||||
|
scene["rim_light"] = {
|
||||||
|
"type": "directional",
|
||||||
|
"to_world": rim_to_world,
|
||||||
|
"irradiance": {"type": "rgb", "value": rim_rgb},
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Ground plane / backdrop (optional) ─────────────────────
|
||||||
|
if ground.enabled:
|
||||||
|
try:
|
||||||
|
# Load mesh to compute bounds for ground/backdrop placement
|
||||||
|
mesh_shape = mi.load_dict({"type": shape_type, "filename": mesh_path})
|
||||||
|
bbox = mesh_shape.bbox()
|
||||||
|
bbox_min, bbox_max = bbox[0], bbox[1]
|
||||||
|
# Ground at model's lowest Z with 0.1% offset
|
||||||
|
model_height = bbox_max[2] - bbox_min[2]
|
||||||
|
ground_z = bbox_min[2] - 0.001 * model_height
|
||||||
|
dx = bbox_max[0] - bbox_min[0]
|
||||||
|
dy = bbox_max[1] - bbox_min[1]
|
||||||
|
dz = bbox_max[2] - bbox_min[2]
|
||||||
|
diag = float((dx * dx + dy * dy + dz * dz) ** 0.5)
|
||||||
|
except Exception:
|
||||||
|
# Fallback: place at origin with large default size
|
||||||
|
ground_z = -ground.distance_below
|
||||||
|
diag = 1000.0
|
||||||
|
|
||||||
|
bsdf_ground = {
|
||||||
|
"type": "diffuse",
|
||||||
|
"reflectance": {"type": "rgb", "value": list(ground.color)},
|
||||||
|
}
|
||||||
|
|
||||||
|
if ground.curved_backdrop:
|
||||||
|
# Photo booth style curved leinwand:
|
||||||
|
# - Flat floor section in front of the model
|
||||||
|
# - Curved cylinder behind that sweeps up and over
|
||||||
|
half_size = diag * 50.0 # huge floor
|
||||||
|
radius = diag * 3.0 # curvature radius
|
||||||
|
cyl_height = diag * 20.0 # width of the cylinder (along its axis)
|
||||||
|
scene["ground_floor"] = {
|
||||||
|
"type": "rectangle",
|
||||||
|
"to_world": mi.ScalarTransform4f.translate([0.0, 0.0, ground_z])
|
||||||
|
@ mi.ScalarTransform4f.scale([half_size, half_size, 1.0]),
|
||||||
|
"bsdf": bsdf_ground,
|
||||||
|
}
|
||||||
|
# Cylinder: axis along Y, positioned behind model, radius sweeps up
|
||||||
|
scene["ground_backdrop"] = {
|
||||||
|
"type": "cylinder",
|
||||||
|
"radius": radius,
|
||||||
|
"p0": [-cyl_height / 2, -radius, ground_z],
|
||||||
|
"p1": [cyl_height / 2, -radius, ground_z],
|
||||||
|
"to_world": mi.ScalarTransform4f.rotate([1, 0, 0], 90)
|
||||||
|
@ mi.ScalarTransform4f.translate([0.0, 0.0, -radius]),
|
||||||
|
"bsdf": bsdf_ground,
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
# Simple flat ground plane — very large so edges aren't visible
|
||||||
|
half_size = diag * 50.0
|
||||||
|
scene["ground_plane"] = {
|
||||||
|
"type": "rectangle",
|
||||||
|
"to_world": mi.ScalarTransform4f.translate([0.0, 0.0, ground_z])
|
||||||
|
@ mi.ScalarTransform4f.scale([half_size, half_size, 1.0]),
|
||||||
|
"bsdf": bsdf_ground,
|
||||||
|
}
|
||||||
|
|
||||||
|
return scene
|
||||||
|
|
||||||
|
def _make_bsdf(self, material: RenderMaterial) -> dict:
|
||||||
|
"""Convert a RenderMaterial to a Mitsuba BSDF dict."""
|
||||||
|
mt = material.bsdf_type
|
||||||
|
|
||||||
|
if mt == "roughconductor":
|
||||||
|
# Use material="none" with specular_reflectance set to the
|
||||||
|
# metal color. The pip-installed Mitsuba doesn't include
|
||||||
|
# spectral metal data files (iron.spd, copper.spd, etc.).
|
||||||
|
return {
|
||||||
|
"type": "roughconductor",
|
||||||
|
"material": "none",
|
||||||
|
"alpha": max(material.roughness, 0.01),
|
||||||
|
"specular_reflectance": {
|
||||||
|
"type": "rgb",
|
||||||
|
"value": list(material.color),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if mt == "roughdielectric":
|
||||||
|
return {
|
||||||
|
"type": "roughdielectric",
|
||||||
|
"int_ior": material.int_ior,
|
||||||
|
"ext_ior": 1.0,
|
||||||
|
"alpha": max(material.roughness, 0.01),
|
||||||
|
}
|
||||||
|
|
||||||
|
if mt == "dielectric":
|
||||||
|
return {
|
||||||
|
"type": "dielectric",
|
||||||
|
"int_ior": material.int_ior,
|
||||||
|
"ext_ior": 1.0,
|
||||||
|
}
|
||||||
|
|
||||||
|
if mt == "plastic":
|
||||||
|
return {
|
||||||
|
"type": "plastic",
|
||||||
|
"diffuse_reflectance": {
|
||||||
|
"type": "rgb",
|
||||||
|
"value": list(material.color),
|
||||||
|
},
|
||||||
|
"int_ior": material.int_ior,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Default: diffuse
|
||||||
|
return {
|
||||||
|
"type": "diffuse",
|
||||||
|
"reflectance": {
|
||||||
|
"type": "rgb",
|
||||||
|
"value": list(material.color),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Rendering ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def render(
|
||||||
|
self,
|
||||||
|
mesh_path: str,
|
||||||
|
material: RenderMaterial,
|
||||||
|
camera: RenderCamera,
|
||||||
|
settings: RenderSettings,
|
||||||
|
progress_callback: Optional[Callable[[float], None]] = None,
|
||||||
|
) -> np.ndarray:
|
||||||
|
"""Render a mesh file and return (H, W, 3) float32 RGB array."""
|
||||||
|
self._set_variant()
|
||||||
|
import mitsuba as mi
|
||||||
|
|
||||||
|
scene_dict = self._build_scene_dict(mesh_path, material, camera, settings)
|
||||||
|
scene = mi.load_dict(scene_dict)
|
||||||
|
|
||||||
|
logger.info(f"Rendering {settings.width}x{settings.height} @ {settings.spp} spp")
|
||||||
|
|
||||||
|
# Render
|
||||||
|
try:
|
||||||
|
image = mi.render(scene, spp=settings.spp, seed=int(settings.seed or 0)) # type: ignore[arg-type] # Mitsuba accepts int at runtime
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Mitsuba render failed: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
if progress_callback:
|
||||||
|
progress_callback(1.0)
|
||||||
|
|
||||||
|
# Convert to numpy (H, W, 3)
|
||||||
|
arr = np.array(image, dtype=np.float32)
|
||||||
|
|
||||||
|
# Apply approximate sRGB tonemapping
|
||||||
|
arr = np.clip(arr, 0.0, None)
|
||||||
|
arr = np.power(arr, 1.0 / 2.2) # gamma
|
||||||
|
arr = np.clip(arr, 0.0, 1.0)
|
||||||
|
|
||||||
|
return arr
|
||||||
|
|
||||||
|
def render_preview(
|
||||||
|
self,
|
||||||
|
mesh_path: str,
|
||||||
|
material: RenderMaterial,
|
||||||
|
camera: RenderCamera,
|
||||||
|
settings: RenderSettings,
|
||||||
|
) -> np.ndarray:
|
||||||
|
"""Quick low-quality preview (4x fewer spp)."""
|
||||||
|
preview_settings = RenderSettings(
|
||||||
|
width=settings.width // 2,
|
||||||
|
height=settings.height // 2,
|
||||||
|
spp=max(settings.spp // 4, 16),
|
||||||
|
max_depth=min(settings.max_depth, 4),
|
||||||
|
seed=settings.seed,
|
||||||
|
lighting=settings.lighting,
|
||||||
|
ground_plane=settings.ground_plane,
|
||||||
|
)
|
||||||
|
return self.render(mesh_path, material, camera, preview_settings)
|
||||||
|
|
||||||
|
# ── Export ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def export_image(self, image: np.ndarray, path: str) -> None:
|
||||||
|
"""Save a rendered image to PNG or EXR."""
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
ext = os.path.splitext(path)[1].lower()
|
||||||
|
|
||||||
|
if ext == ".exr":
|
||||||
|
# Save as EXR (HDR) — no tonemapping
|
||||||
|
try:
|
||||||
|
import OpenEXR # type: ignore[import-not-found]
|
||||||
|
import Imath # type: ignore[import-not-found]
|
||||||
|
|
||||||
|
h, w = image.shape[:2]
|
||||||
|
header = OpenEXR.Header(w, h)
|
||||||
|
header["channels"] = {
|
||||||
|
"R": Imath.PixelType(Imath.PixelType.FLOAT),
|
||||||
|
"G": Imath.PixelType(Imath.PixelType.FLOAT),
|
||||||
|
"B": Imath.PixelType(Imath.PixelType.FLOAT),
|
||||||
|
}
|
||||||
|
exr = OpenEXR.OutputFile(path, header)
|
||||||
|
exr.write(
|
||||||
|
{
|
||||||
|
"R": image[:, :, 0].tobytes(),
|
||||||
|
"G": image[:, :, 1].tobytes(),
|
||||||
|
"B": image[:, :, 2].tobytes(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
exr.close()
|
||||||
|
except ImportError:
|
||||||
|
# Fallback: save as 16-bit PNG
|
||||||
|
logger.warning("OpenEXR not available, saving as 16-bit PNG")
|
||||||
|
img = Image.fromarray((image * 65535).astype(np.uint16), "RGB")
|
||||||
|
img.save(path)
|
||||||
|
else:
|
||||||
|
# PNG / JPEG — already tonemapped
|
||||||
|
img = Image.fromarray((image * 255).astype(np.uint8), "RGB")
|
||||||
|
img.save(path)
|
||||||
|
|
||||||
|
logger.info(f"Exported render to {path}")
|
||||||
|
|
||||||
|
# ── Helpers ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _set_variant(self) -> None:
|
||||||
|
"""Set the Mitsuba variant (called once)."""
|
||||||
|
import sys
|
||||||
|
import io
|
||||||
|
|
||||||
|
# Suppress the harmless "LLVM API initialization failed" warning
|
||||||
|
# that drjit emits on macOS ARM when scalar variant is used.
|
||||||
|
old_stderr = sys.stderr
|
||||||
|
sys.stderr = io.StringIO()
|
||||||
|
try:
|
||||||
|
import mitsuba as mi
|
||||||
|
|
||||||
|
mi.set_variant("scalar_rgb")
|
||||||
|
finally:
|
||||||
|
sys.stderr = old_stderr
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,252 @@
|
|||||||
|
"""Convert OCC BRep shapes to mesh files for render backends.
|
||||||
|
|
||||||
|
Outputs PLY files (preferred by Mitsuba) or STL files.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
from typing import List, Optional, Tuple
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def occ_shape_to_ply(
|
||||||
|
shape,
|
||||||
|
output_path: Optional[str] = None,
|
||||||
|
linear_deflection: float = 0.1,
|
||||||
|
angular_deflection: float = 0.15,
|
||||||
|
) -> str:
|
||||||
|
"""Tessellate an OCC TopoDS_Shape and write as PLY.
|
||||||
|
|
||||||
|
Returns the path to the written PLY file.
|
||||||
|
"""
|
||||||
|
from OCP.BRepMesh import BRepMesh_IncrementalMesh
|
||||||
|
from OCP.TopExp import TopExp_Explorer
|
||||||
|
from OCP.TopAbs import TopAbs_FACE
|
||||||
|
from OCP.TopoDS import TopoDS
|
||||||
|
from OCP.BRep import BRep_Tool
|
||||||
|
from OCP.TopLoc import TopLoc_Location
|
||||||
|
|
||||||
|
# Tessellate
|
||||||
|
tess = BRepMesh_IncrementalMesh(
|
||||||
|
shape, linear_deflection, False, angular_deflection, True
|
||||||
|
)
|
||||||
|
tess.Perform()
|
||||||
|
|
||||||
|
# Extract triangulation from all faces
|
||||||
|
all_vertices: List[List[float]] = []
|
||||||
|
all_faces: List[List[int]] = []
|
||||||
|
vertex_offset = 0
|
||||||
|
|
||||||
|
from OCP.TopAbs import TopAbs_FORWARD
|
||||||
|
|
||||||
|
explorer = TopExp_Explorer(shape, TopAbs_FACE)
|
||||||
|
while explorer.More():
|
||||||
|
face = TopoDS.Face_s(explorer.Current())
|
||||||
|
location = TopLoc_Location()
|
||||||
|
triangulation = BRep_Tool.Triangulation_s(face, location)
|
||||||
|
|
||||||
|
if triangulation is None:
|
||||||
|
explorer.Next()
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Transform
|
||||||
|
trsf = location.Transformation()
|
||||||
|
|
||||||
|
# Check face orientation: FORWARD means the surface normal points
|
||||||
|
# outward from the solid; REVERSED means it points inward.
|
||||||
|
is_forward = (face.Orientation() == TopAbs_FORWARD)
|
||||||
|
|
||||||
|
# Extract vertices (apply location transform to positions)
|
||||||
|
nb_nodes = triangulation.NbNodes()
|
||||||
|
for i in range(1, nb_nodes + 1):
|
||||||
|
node = triangulation.Node(i)
|
||||||
|
pnt = node.Transformed(trsf)
|
||||||
|
all_vertices.append([pnt.X(), pnt.Y(), pnt.Z()])
|
||||||
|
|
||||||
|
# Extract triangles
|
||||||
|
# For REVERSED faces, swap winding order (n1, n3, n2) so that
|
||||||
|
# the computed normal points outward consistently.
|
||||||
|
nb_triangles = triangulation.NbTriangles()
|
||||||
|
for i in range(1, nb_triangles + 1):
|
||||||
|
tri = triangulation.Triangle(i)
|
||||||
|
n1, n2, n3 = tri.Get()
|
||||||
|
if is_forward:
|
||||||
|
all_faces.append([
|
||||||
|
n1 - 1 + vertex_offset,
|
||||||
|
n2 - 1 + vertex_offset,
|
||||||
|
n3 - 1 + vertex_offset,
|
||||||
|
])
|
||||||
|
else:
|
||||||
|
# Swap winding for REVERSED faces
|
||||||
|
all_faces.append([
|
||||||
|
n1 - 1 + vertex_offset,
|
||||||
|
n3 - 1 + vertex_offset,
|
||||||
|
n2 - 1 + vertex_offset,
|
||||||
|
])
|
||||||
|
|
||||||
|
vertex_offset += nb_nodes
|
||||||
|
explorer.Next()
|
||||||
|
|
||||||
|
if not all_vertices:
|
||||||
|
raise ValueError("Tessellation produced no vertices")
|
||||||
|
|
||||||
|
vertices = np.array(all_vertices, dtype=np.float32)
|
||||||
|
faces = np.array(all_faces, dtype=np.uint32)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Tessellation: {len(vertices)} vertices, {len(faces)} triangles"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Compute smooth vertex normals from face normals.
|
||||||
|
# Winding is already corrected during tessellation using OCC face orientation.
|
||||||
|
normals, corrected_faces = _compute_outward_normals(vertices, faces, shape)
|
||||||
|
|
||||||
|
# Write PLY with corrected faces and normals
|
||||||
|
if output_path is None:
|
||||||
|
fd, output_path = tempfile.mkstemp(suffix=".ply", prefix="fluency_render_")
|
||||||
|
os.close(fd)
|
||||||
|
|
||||||
|
_write_ply(output_path, vertices, corrected_faces, normals)
|
||||||
|
logger.info(f"Wrote PLY: {output_path}")
|
||||||
|
return output_path
|
||||||
|
|
||||||
|
|
||||||
|
def _compute_outward_normals(
|
||||||
|
vertices: np.ndarray,
|
||||||
|
faces: np.ndarray,
|
||||||
|
shape,
|
||||||
|
) -> Tuple[np.ndarray, np.ndarray]:
|
||||||
|
"""Compute outward-facing vertex normals and correct face winding.
|
||||||
|
|
||||||
|
The winding is already corrected during tessellation using OCC's face
|
||||||
|
orientation (TopAbs_FORWARD/REVERSED). This function computes smooth
|
||||||
|
vertex normals by averaging face normals at shared vertices.
|
||||||
|
|
||||||
|
Returns (normals, corrected_faces) for PLY export.
|
||||||
|
"""
|
||||||
|
n_verts = len(vertices)
|
||||||
|
v_normals = np.zeros((n_verts, 3), dtype=np.float64)
|
||||||
|
|
||||||
|
# Ensure faces is 2D (numpy creates (3,) for single-face meshes)
|
||||||
|
if faces.ndim == 1:
|
||||||
|
faces = faces.reshape(1, -1)
|
||||||
|
|
||||||
|
# Winding is already correct from tessellation (face orientation check).
|
||||||
|
# Just compute face normals and accumulate to vertices.
|
||||||
|
v0 = vertices[faces[:, 0]]
|
||||||
|
v1 = vertices[faces[:, 1]]
|
||||||
|
v2 = vertices[faces[:, 2]]
|
||||||
|
|
||||||
|
edge1 = v1 - v0
|
||||||
|
edge2 = v2 - v0
|
||||||
|
fn = np.cross(edge1, edge2)
|
||||||
|
|
||||||
|
# Normalize face normals
|
||||||
|
lengths = np.linalg.norm(fn, axis=1, keepdims=True)
|
||||||
|
lengths[lengths < 1e-10] = 1.0
|
||||||
|
fn /= lengths
|
||||||
|
|
||||||
|
# Accumulate to vertices
|
||||||
|
for i in range(len(faces)):
|
||||||
|
idx = faces[i]
|
||||||
|
v_normals[idx[0]] += fn[i]
|
||||||
|
v_normals[idx[1]] += fn[i]
|
||||||
|
v_normals[idx[2]] += fn[i]
|
||||||
|
|
||||||
|
# Normalize vertex normals
|
||||||
|
v_lengths = np.linalg.norm(v_normals, axis=1, keepdims=True)
|
||||||
|
v_lengths[v_lengths < 1e-10] = 1.0
|
||||||
|
v_normals /= v_lengths
|
||||||
|
|
||||||
|
return v_normals.astype(np.float32), faces.astype(np.uint32)
|
||||||
|
|
||||||
|
|
||||||
|
def occ_shape_to_stl(
|
||||||
|
shape,
|
||||||
|
output_path: Optional[str] = None,
|
||||||
|
linear_deflection: float = 0.1,
|
||||||
|
) -> str:
|
||||||
|
"""Tessellate an OCC TopoDS_Shape and write as binary STL.
|
||||||
|
|
||||||
|
Returns the path to the written STL file.
|
||||||
|
"""
|
||||||
|
from OCP.BRepMesh import BRepMesh_IncrementalMesh
|
||||||
|
from OCP.StlAPI import StlAPI_Writer
|
||||||
|
|
||||||
|
# Tessellate
|
||||||
|
tess = BRepMesh_IncrementalMesh(shape, linear_deflection, False, 0.5, True)
|
||||||
|
tess.Perform()
|
||||||
|
|
||||||
|
if output_path is None:
|
||||||
|
fd, output_path = tempfile.mkstemp(suffix=".stl", prefix="fluency_render_")
|
||||||
|
os.close(fd)
|
||||||
|
|
||||||
|
writer = StlAPI_Writer()
|
||||||
|
writer.SetASCIIMode(False)
|
||||||
|
writer.Write(shape, output_path)
|
||||||
|
logger.info(f"Wrote STL: {output_path}")
|
||||||
|
return output_path
|
||||||
|
|
||||||
|
|
||||||
|
def occ_shape_bounds(shape) -> Tuple[Tuple[float, float, float], Tuple[float, float, float]]:
|
||||||
|
"""Return (min_xyz, max_xyz) bounding box of an OCC shape."""
|
||||||
|
from OCP.Bnd import Bnd_Box
|
||||||
|
from OCP.BRepBndLib import BRepBndLib
|
||||||
|
|
||||||
|
bbox = Bnd_Box()
|
||||||
|
BRepBndLib.Add_s(shape, bbox)
|
||||||
|
xmin, ymin, zmin, xmax, ymax, zmax = bbox.Get()
|
||||||
|
return (xmin, ymin, zmin), (xmax, ymax, zmax)
|
||||||
|
|
||||||
|
|
||||||
|
def _write_ply(
|
||||||
|
path: str,
|
||||||
|
vertices: np.ndarray,
|
||||||
|
faces: np.ndarray,
|
||||||
|
normals: Optional[np.ndarray] = None,
|
||||||
|
) -> None:
|
||||||
|
"""Write a binary PLY file (little-endian) with optional vertex normals."""
|
||||||
|
import struct
|
||||||
|
|
||||||
|
n_verts = len(vertices)
|
||||||
|
n_faces = len(faces)
|
||||||
|
has_normals = normals is not None and len(normals) == n_verts
|
||||||
|
|
||||||
|
with open(path, "wb") as f:
|
||||||
|
# Header
|
||||||
|
header_lines = [
|
||||||
|
"ply",
|
||||||
|
"format binary_little_endian 1.0",
|
||||||
|
f"element vertex {n_verts}",
|
||||||
|
"property float x",
|
||||||
|
"property float y",
|
||||||
|
"property float z",
|
||||||
|
]
|
||||||
|
if has_normals:
|
||||||
|
header_lines.extend([
|
||||||
|
"property float nx",
|
||||||
|
"property float ny",
|
||||||
|
"property float nz",
|
||||||
|
])
|
||||||
|
header_lines.append(f"element face {n_faces}")
|
||||||
|
header_lines.append("property list uchar int vertex_indices")
|
||||||
|
header_lines.append("end_header")
|
||||||
|
|
||||||
|
f.write(("\n".join(header_lines) + "\n").encode("ascii"))
|
||||||
|
|
||||||
|
# Vertex positions (+ normals if available)
|
||||||
|
for i in range(n_verts):
|
||||||
|
f.write(struct.pack("<fff", vertices[i, 0], vertices[i, 1], vertices[i, 2]))
|
||||||
|
if has_normals:
|
||||||
|
f.write(struct.pack("<fff", normals[i, 0], normals[i, 1], normals[i, 2]))
|
||||||
|
|
||||||
|
# Faces
|
||||||
|
for face in faces:
|
||||||
|
f.write(struct.pack("<B", 3))
|
||||||
|
f.write(struct.pack("<iii", int(face[0]), int(face[1]), int(face[2])))
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
"""Abstract render backend interface.
|
||||||
|
|
||||||
|
Any photorealistic renderer (Mitsuba, Blender, Cycles, ...) implements
|
||||||
|
:class:`RenderBackend`. The UI only talks to this ABC so backends can be
|
||||||
|
swapped by changing one import.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class RenderMaterial:
|
||||||
|
"""PBR material description for the render backend."""
|
||||||
|
|
||||||
|
name: str = "Default"
|
||||||
|
color: tuple[float, float, float] = (0.7, 0.7, 0.7)
|
||||||
|
metallic: float = 0.0 # 0.0 = dielectric, 1.0 = metal
|
||||||
|
roughness: float = 0.5 # 0.0 = mirror, 1.0 = fully rough
|
||||||
|
bsdf_type: str = "diffuse" # diffuse | roughconductor | roughdielectric | plastic
|
||||||
|
# Optional: named metal preset (copper, aluminium, gold, chrome, steel)
|
||||||
|
metal_preset: Optional[str] = None
|
||||||
|
# For dielectric / plastic
|
||||||
|
int_ior: float = 1.5
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class RenderCamera:
|
||||||
|
"""Camera parameters for the render."""
|
||||||
|
|
||||||
|
origin: tuple[float, float, float] = (100.0, 100.0, 100.0)
|
||||||
|
target: tuple[float, float, float] = (0.0, 0.0, 0.0)
|
||||||
|
up: tuple[float, float, float] = (0.0, 0.0, 1.0)
|
||||||
|
fov: float = 60.0 # vertical field of view in degrees
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class LightingConfig:
|
||||||
|
"""Lighting configuration for the render scene."""
|
||||||
|
|
||||||
|
ambient_intensity: float = 0.3 # constant environment fill [0..1]
|
||||||
|
key_color: tuple[float, float, float] = (1.0, 0.98, 0.95) # RGB key light color
|
||||||
|
key_intensity: float = 3.5 # key light irradiance multiplier
|
||||||
|
fill_color: tuple[float, float, float] = (0.92, 0.94, 1.0) # RGB fill light color
|
||||||
|
fill_intensity: float = 1.5 # fill light irradiance multiplier
|
||||||
|
rim_color: tuple[float, float, float] = (1.0, 0.98, 0.96) # RGB rim light color
|
||||||
|
rim_intensity: float = 1.2 # rim light irradiance multiplier
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class GroundPlaneConfig:
|
||||||
|
"""Ground plane configuration for the render scene."""
|
||||||
|
|
||||||
|
enabled: bool = False
|
||||||
|
color: tuple[float, float, float] = (0.5, 0.5, 0.5) # RGB diffuse color
|
||||||
|
roughness: float = 0.8 # surface roughness [0..1]
|
||||||
|
distance_below: float = 0.0 # mm below origin (positive = below)
|
||||||
|
curved_backdrop: bool = False # photo booth style curved leinwand
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class RenderSettings:
|
||||||
|
"""Quality / resolution settings."""
|
||||||
|
|
||||||
|
width: int = 1920
|
||||||
|
height: int = 1080
|
||||||
|
spp: int = 256 # samples per pixel
|
||||||
|
max_depth: int = 8 # max bounces for path tracer
|
||||||
|
seed: int = 0 # random seed (0 = auto)
|
||||||
|
lighting: LightingConfig = field(default_factory=LightingConfig)
|
||||||
|
ground_plane: GroundPlaneConfig = field(default_factory=GroundPlaneConfig)
|
||||||
|
|
||||||
|
|
||||||
|
class RenderBackend(ABC):
|
||||||
|
"""Abstract photorealistic renderer.
|
||||||
|
|
||||||
|
Implementations live in separate modules so backends can be swapped
|
||||||
|
without touching the UI. Typical call::
|
||||||
|
|
||||||
|
backend = MitsubaBackend()
|
||||||
|
image = backend.render(obj_path, material, camera, settings)
|
||||||
|
"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def name(self) -> str:
|
||||||
|
"""Human-readable backend name (shown in UI)."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def is_available(self) -> bool:
|
||||||
|
"""Return True if this backend's dependencies are installed."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def render(
|
||||||
|
self,
|
||||||
|
mesh_path: str,
|
||||||
|
material: RenderMaterial,
|
||||||
|
camera: RenderCamera,
|
||||||
|
settings: RenderSettings,
|
||||||
|
progress_callback=None,
|
||||||
|
) -> "np.ndarray":
|
||||||
|
"""Render a mesh file and return an (H, W, 3) float32 RGB array.
|
||||||
|
|
||||||
|
*mesh_path* is an STL or OBJ file on disk.
|
||||||
|
*progress_callback(fraction)* is called with 0.0–1.0 progress.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def render_preview(
|
||||||
|
self,
|
||||||
|
mesh_path: str,
|
||||||
|
material: RenderMaterial,
|
||||||
|
camera: RenderCamera,
|
||||||
|
settings: RenderSettings,
|
||||||
|
) -> "np.ndarray":
|
||||||
|
"""Quick low-quality preview (fewer spp)."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def export_image(self, image: "np.ndarray", path: str) -> None:
|
||||||
|
"""Save a rendered image to PNG / EXR."""
|
||||||
|
|
||||||
|
def default_camera_from_bounds(
|
||||||
|
self, bounds_min: tuple[float, float, float], bounds_max: tuple[float, float, float]
|
||||||
|
) -> RenderCamera:
|
||||||
|
"""Compute a sensible default camera looking at the bbox centre."""
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
mn = np.asarray(bounds_min, dtype=float)
|
||||||
|
mx = np.asarray(bounds_max, dtype=float)
|
||||||
|
centre = (mn + mx) / 2.0
|
||||||
|
diag = float(np.linalg.norm(mx - mn))
|
||||||
|
# Place camera at iso-ish position, far enough to see everything.
|
||||||
|
eye = centre + np.array([0.7, -0.7, 0.5]) * diag * 0.8
|
||||||
|
return RenderCamera(
|
||||||
|
origin=tuple(eye.tolist()),
|
||||||
|
target=tuple(centre.tolist()),
|
||||||
|
up=(0.0, 0.0, 1.0),
|
||||||
|
fov=45.0,
|
||||||
|
)
|
||||||
@@ -0,0 +1,372 @@
|
|||||||
|
"""
|
||||||
|
Undo/Redo manager for OCCSketch using snapshot-based approach.
|
||||||
|
|
||||||
|
python_solvespace has no per-entity delete API — the solver is rebuilt from
|
||||||
|
scratch after every modification. This makes snapshot-based undo natural:
|
||||||
|
we capture the complete sketch state (entities, geometry, constraints) and
|
||||||
|
restore it by rebuilding the solver from the snapshot data.
|
||||||
|
|
||||||
|
Each snapshot is ~10-50 KB depending on sketch complexity, and we cap the
|
||||||
|
stack at a configurable depth (default 50).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import copy
|
||||||
|
import logging
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any, Dict, List, Optional, Set, Tuple
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SketchSnapshot:
|
||||||
|
"""Immutable snapshot of sketch state for undo/redo.
|
||||||
|
|
||||||
|
Captures everything needed to fully reconstruct an OCCSketch:
|
||||||
|
entities, points, lines, circles, arcs, counter, constraint log,
|
||||||
|
and the special entity id sets (centerlines, external/underlay).
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Entity registry: id → (entity_type, geometry, is_construction, is_external, constraints_list)
|
||||||
|
entities: Dict[int, Tuple[str, Any, bool, bool, List[str]]] = field(default_factory=dict)
|
||||||
|
|
||||||
|
# Geometry sub-indices
|
||||||
|
points: Dict[int, Tuple[float, float]] = field(default_factory=dict)
|
||||||
|
lines: Dict[int, Tuple[int, int]] = field(default_factory=dict)
|
||||||
|
circles: Dict[int, Tuple[int, float]] = field(default_factory=dict)
|
||||||
|
arcs: Dict[int, Dict[str, Any]] = field(default_factory=dict)
|
||||||
|
|
||||||
|
# Counters and flags
|
||||||
|
entity_counter: int = 0
|
||||||
|
constraint_count: int = 0
|
||||||
|
first_point_id: Optional[int] = None
|
||||||
|
|
||||||
|
# Constraint replay log
|
||||||
|
constraint_log: List[Dict[str, Any]] = field(default_factory=list)
|
||||||
|
|
||||||
|
# Special entity sets
|
||||||
|
centerline_ids: Set[int] = field(default_factory=set)
|
||||||
|
external_entity_ids: Set[int] = field(default_factory=set)
|
||||||
|
|
||||||
|
# Workplane (so undo doesn't lose the placement plane)
|
||||||
|
wp_origin: Tuple[float, float, float] = (0.0, 0.0, 0.0)
|
||||||
|
wp_normal: Tuple[float, float, float] = (0.0, 0.0, 1.0)
|
||||||
|
wp_x_dir: Tuple[float, float, float] = (1.0, 0.0, 0.0)
|
||||||
|
wp_y_dir: Tuple[float, float, float] = (0.0, 1.0, 0.0)
|
||||||
|
|
||||||
|
|
||||||
|
class SketchUndoManager:
|
||||||
|
"""Manages undo/redo stacks of SketchSnapshot for an OCCSketch.
|
||||||
|
|
||||||
|
Usage::
|
||||||
|
|
||||||
|
undo_mgr = SketchUndoManager(sketch)
|
||||||
|
|
||||||
|
# Before any modification:
|
||||||
|
undo_mgr.save_state()
|
||||||
|
|
||||||
|
# ... perform modification ...
|
||||||
|
|
||||||
|
# Ctrl+Z:
|
||||||
|
undo_mgr.undo()
|
||||||
|
|
||||||
|
# Ctrl+Y / Ctrl+Shift+Z:
|
||||||
|
undo_mgr.redo()
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, sketch: Any, max_stack_size: int = 50) -> None:
|
||||||
|
from fluency.geometry_occ.sketch import OCCSketch
|
||||||
|
|
||||||
|
self._sketch: OCCSketch = sketch
|
||||||
|
self._max_stack_size = max_stack_size
|
||||||
|
self._undo_stack: List[SketchSnapshot] = []
|
||||||
|
self._redo_stack: List[SketchSnapshot] = []
|
||||||
|
|
||||||
|
# ─── Public API ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@property
|
||||||
|
def can_undo(self) -> bool:
|
||||||
|
"""True if there is a state to undo to."""
|
||||||
|
return len(self._undo_stack) > 0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def can_redo(self) -> bool:
|
||||||
|
"""True if there is a state to redo to."""
|
||||||
|
return len(self._redo_stack) > 0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def undo_depth(self) -> int:
|
||||||
|
"""Number of undo levels available."""
|
||||||
|
return len(self._undo_stack)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def redo_depth(self) -> int:
|
||||||
|
"""Number of redo levels available."""
|
||||||
|
return len(self._redo_stack)
|
||||||
|
|
||||||
|
def save_state(self) -> None:
|
||||||
|
"""Capture the current sketch state and push it onto the undo stack.
|
||||||
|
|
||||||
|
Call this **before** any modifying operation (draw, delete, move,
|
||||||
|
constraint add/remove, construction toggle, etc.).
|
||||||
|
|
||||||
|
The redo stack is cleared whenever a new state is saved (i.e. when
|
||||||
|
the user makes a new change after undoing).
|
||||||
|
"""
|
||||||
|
snapshot = self._capture()
|
||||||
|
self._undo_stack.append(snapshot)
|
||||||
|
|
||||||
|
# Cap the stack size.
|
||||||
|
if len(self._undo_stack) > self._max_stack_size:
|
||||||
|
self._undo_stack.pop(0)
|
||||||
|
|
||||||
|
# New mutation invalidates the redo history.
|
||||||
|
self._redo_stack.clear()
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
f"save_state: undo_depth={len(self._undo_stack)} "
|
||||||
|
f"entities={len(snapshot.entities)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def undo(self) -> bool:
|
||||||
|
"""Restore the previous sketch state.
|
||||||
|
|
||||||
|
Returns True if a state was restored, False if the undo stack is empty.
|
||||||
|
"""
|
||||||
|
if not self._undo_stack:
|
||||||
|
logger.debug("undo: stack empty")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Save current state to redo stack before restoring.
|
||||||
|
current_snapshot = self._capture()
|
||||||
|
self._redo_stack.append(current_snapshot)
|
||||||
|
|
||||||
|
# Pop and restore.
|
||||||
|
snapshot = self._undo_stack.pop()
|
||||||
|
self._restore(snapshot)
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
f"undo: restored state with {len(snapshot.entities)} entities, "
|
||||||
|
f"undo_depth={len(self._undo_stack)} redo_depth={len(self._redo_stack)}"
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def redo(self) -> bool:
|
||||||
|
"""Re-apply the most recently undone state.
|
||||||
|
|
||||||
|
Returns True if a state was restored, False if the redo stack is empty.
|
||||||
|
"""
|
||||||
|
if not self._redo_stack:
|
||||||
|
logger.debug("redo: stack empty")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Save current state to undo stack before restoring.
|
||||||
|
current_snapshot = self._capture()
|
||||||
|
self._undo_stack.append(current_snapshot)
|
||||||
|
|
||||||
|
# Pop and restore.
|
||||||
|
snapshot = self._redo_stack.pop()
|
||||||
|
self._restore(snapshot)
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
f"redo: restored state with {len(snapshot.entities)} entities, "
|
||||||
|
f"undo_depth={len(self._undo_stack)} redo_depth={len(self._redo_stack)}"
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def clear(self) -> None:
|
||||||
|
"""Clear both stacks (e.g. when loading a new sketch)."""
|
||||||
|
self._undo_stack.clear()
|
||||||
|
self._redo_stack.clear()
|
||||||
|
logger.debug("undo stacks cleared")
|
||||||
|
|
||||||
|
# ─── Snapshot Capture ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _capture(self) -> SketchSnapshot:
|
||||||
|
"""Capture the current sketch state into a snapshot."""
|
||||||
|
sketch = self._sketch
|
||||||
|
|
||||||
|
# Capture entities: id → (type, geometry, is_construction, is_external, constraints)
|
||||||
|
entities: Dict[int, Tuple[str, Any, bool, bool, List[str]]] = {}
|
||||||
|
for eid, ent in sketch._entities.items():
|
||||||
|
entities[eid] = (
|
||||||
|
ent.entity_type,
|
||||||
|
ent.geometry,
|
||||||
|
ent.is_construction,
|
||||||
|
ent.is_external,
|
||||||
|
list(ent.constraints), # copy the constraints list
|
||||||
|
)
|
||||||
|
|
||||||
|
# Deep copy the mutable dicts (points coords are tuples, so shallow is fine,
|
||||||
|
# but arcs contain dicts so we deep-copy those).
|
||||||
|
points = dict(sketch._points)
|
||||||
|
lines = dict(sketch._lines)
|
||||||
|
circles = dict(sketch._circles)
|
||||||
|
arcs = {k: copy.deepcopy(v) for k, v in sketch._arcs.items()}
|
||||||
|
|
||||||
|
# Constraint log entries contain tuples and sets — need careful copy.
|
||||||
|
constraint_log = []
|
||||||
|
for entry in sketch._constraint_log:
|
||||||
|
copied = {
|
||||||
|
"type": entry["type"],
|
||||||
|
"ids": tuple(entry["ids"]),
|
||||||
|
"params": tuple(entry["params"]) if entry.get("params") else (),
|
||||||
|
"labels": set(entry["labels"]) if entry.get("labels") else set(),
|
||||||
|
}
|
||||||
|
constraint_log.append(copied)
|
||||||
|
|
||||||
|
return SketchSnapshot(
|
||||||
|
entities=entities,
|
||||||
|
points=points,
|
||||||
|
lines=lines,
|
||||||
|
circles=circles,
|
||||||
|
arcs=arcs,
|
||||||
|
entity_counter=sketch._entity_counter,
|
||||||
|
constraint_count=sketch._constraint_count,
|
||||||
|
first_point_id=sketch._first_point_id,
|
||||||
|
constraint_log=constraint_log,
|
||||||
|
centerline_ids=set(sketch._centerline_ids),
|
||||||
|
external_entity_ids=set(sketch._external_entity_ids),
|
||||||
|
wp_origin=sketch._wp_origin,
|
||||||
|
wp_normal=sketch._wp_normal,
|
||||||
|
wp_x_dir=sketch._wp_x_dir,
|
||||||
|
wp_y_dir=sketch._wp_y_dir,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ─── Snapshot Restore ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _restore(self, snapshot: SketchSnapshot) -> None:
|
||||||
|
"""Restore the sketch to a previously captured snapshot state."""
|
||||||
|
from fluency.geometry_occ.sketch import OCCSketch, OCCSketchEntity
|
||||||
|
|
||||||
|
sketch = self._sketch
|
||||||
|
|
||||||
|
# Clear the current solver and rebuild from scratch.
|
||||||
|
sketch._solver = sketch._solver.__class__() # SolverSystem()
|
||||||
|
sketch._wp = sketch._solver.create_2d_base()
|
||||||
|
sketch._first_point_id = None
|
||||||
|
|
||||||
|
# Restore counters and flags.
|
||||||
|
sketch._entity_counter = snapshot.entity_counter
|
||||||
|
sketch._constraint_count = snapshot.constraint_count
|
||||||
|
sketch._centerline_ids = set(snapshot.centerline_ids)
|
||||||
|
sketch._external_entity_ids = set(snapshot.external_entity_ids)
|
||||||
|
|
||||||
|
# Restore workplane.
|
||||||
|
sketch._wp_origin = snapshot.wp_origin
|
||||||
|
sketch._wp_normal = snapshot.wp_normal
|
||||||
|
sketch._wp_x_dir = snapshot.wp_x_dir
|
||||||
|
sketch._wp_y_dir = snapshot.wp_y_dir
|
||||||
|
|
||||||
|
# Rebuild entity objects from the snapshot.
|
||||||
|
sketch._entities.clear()
|
||||||
|
sketch._points.clear()
|
||||||
|
sketch._lines.clear()
|
||||||
|
sketch._circles.clear()
|
||||||
|
sketch._arcs.clear()
|
||||||
|
|
||||||
|
# First pass: re-add all points to the solver.
|
||||||
|
for eid in sorted(snapshot.entities.keys()):
|
||||||
|
etype, geometry, is_constr, is_ext, constraints = snapshot.entities[eid]
|
||||||
|
if etype == "point" and eid in snapshot.points:
|
||||||
|
x, y = snapshot.points[eid]
|
||||||
|
solver_handle = sketch._solver.add_point_2d(x, y, sketch._wp)
|
||||||
|
ent = OCCSketchEntity(
|
||||||
|
entity_id=eid,
|
||||||
|
entity_type="point",
|
||||||
|
geometry=(x, y),
|
||||||
|
handle=solver_handle,
|
||||||
|
)
|
||||||
|
ent.is_construction = is_constr
|
||||||
|
ent.is_external = is_ext
|
||||||
|
ent.constraints = list(constraints)
|
||||||
|
sketch._entities[eid] = ent
|
||||||
|
sketch._points[eid] = (x, y)
|
||||||
|
|
||||||
|
# Anchor the first point (or first external point) for solver stability.
|
||||||
|
if sketch._first_point_id is None and not is_ext:
|
||||||
|
sketch._first_point_id = eid
|
||||||
|
sketch._solver.dragged(solver_handle, sketch._wp)
|
||||||
|
elif sketch._first_point_id is None and is_ext:
|
||||||
|
sketch._first_point_id = eid
|
||||||
|
sketch._solver.dragged(solver_handle, sketch._wp)
|
||||||
|
|
||||||
|
# Second pass: re-add all lines.
|
||||||
|
for lid in sorted(snapshot.lines.keys()):
|
||||||
|
sid, eid2 = snapshot.lines[lid]
|
||||||
|
s_ent = sketch._entities.get(sid)
|
||||||
|
e_ent = sketch._entities.get(eid2)
|
||||||
|
if s_ent is None or e_ent is None or s_ent.handle is None or e_ent.handle is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
solver_handle = sketch._solver.add_line_2d(s_ent.handle, e_ent.handle, sketch._wp)
|
||||||
|
|
||||||
|
etype, geometry, is_constr, is_ext, constraints = snapshot.entities[lid]
|
||||||
|
ent = OCCSketchEntity(
|
||||||
|
entity_id=lid,
|
||||||
|
entity_type="line",
|
||||||
|
geometry=geometry,
|
||||||
|
handle=solver_handle,
|
||||||
|
)
|
||||||
|
ent.is_construction = is_constr
|
||||||
|
ent.is_external = is_ext
|
||||||
|
ent.constraints = list(constraints)
|
||||||
|
sketch._entities[lid] = ent
|
||||||
|
sketch._lines[lid] = (sid, eid2)
|
||||||
|
|
||||||
|
# Restore circles (not in solver, just tracked).
|
||||||
|
for cid, (center_id, radius) in snapshot.circles.items():
|
||||||
|
if cid in snapshot.entities:
|
||||||
|
etype, geometry, is_constr, is_ext, constraints = snapshot.entities[cid]
|
||||||
|
center_ent = sketch._entities.get(center_id)
|
||||||
|
ent = OCCSketchEntity(
|
||||||
|
entity_id=cid,
|
||||||
|
entity_type="circle",
|
||||||
|
geometry=geometry,
|
||||||
|
handle=None,
|
||||||
|
)
|
||||||
|
ent.is_construction = is_constr
|
||||||
|
ent.is_external = is_ext
|
||||||
|
ent.constraints = list(constraints)
|
||||||
|
sketch._entities[cid] = ent
|
||||||
|
sketch._circles[cid] = (center_id, radius)
|
||||||
|
|
||||||
|
# Restore arcs (not in solver, just tracked).
|
||||||
|
for aid, arc_data in snapshot.arcs.items():
|
||||||
|
if aid in snapshot.entities:
|
||||||
|
etype, geometry, is_constr, is_ext, constraints = snapshot.entities[aid]
|
||||||
|
ent = OCCSketchEntity(
|
||||||
|
entity_id=aid,
|
||||||
|
entity_type="arc",
|
||||||
|
geometry=geometry,
|
||||||
|
handle=None,
|
||||||
|
)
|
||||||
|
ent.is_construction = is_constr
|
||||||
|
ent.is_external = is_ext
|
||||||
|
ent.constraints = list(constraints)
|
||||||
|
sketch._entities[aid] = ent
|
||||||
|
sketch._arcs[aid] = copy.deepcopy(arc_data)
|
||||||
|
|
||||||
|
# Rebuild the constraint log (entries were deep-copied on capture).
|
||||||
|
sketch._constraint_log = []
|
||||||
|
for entry in snapshot.constraint_log:
|
||||||
|
sketch._constraint_log.append({
|
||||||
|
"type": entry["type"],
|
||||||
|
"ids": tuple(entry["ids"]),
|
||||||
|
"params": tuple(entry["params"]) if entry.get("params") else (),
|
||||||
|
"labels": set(entry["labels"]) if entry.get("labels") else set(),
|
||||||
|
})
|
||||||
|
|
||||||
|
# Re-apply all constraints to the solver.
|
||||||
|
for entry in sketch._constraint_log:
|
||||||
|
sketch._apply_constraint_log(entry)
|
||||||
|
|
||||||
|
# Solve to update geometry positions.
|
||||||
|
sketch.solve()
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
f"Restored snapshot: {len(sketch._entities)} entities, "
|
||||||
|
f"{len(sketch._constraint_log)} constraints"
|
||||||
|
)
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
"""Smoke test for project_io save/load round-trip.
|
||||||
|
|
||||||
|
Builds a small project (a Component with a sketch, an extrude body, a
|
||||||
|
workplane, plus an assembly with two instances and a connector) and
|
||||||
|
verifies that saving then loading it preserves the data.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
# Allow running this file directly: ``python tests/test_project_io.py``.
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir, "src"))
|
||||||
|
|
||||||
|
from fluency.io.project_io import save_project, load_project
|
||||||
|
from fluency.models.data_model import (
|
||||||
|
Project,
|
||||||
|
Component,
|
||||||
|
Body,
|
||||||
|
Workplane,
|
||||||
|
Assembly,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestProjectIO(unittest.TestCase):
|
||||||
|
"""Round-trip the same project through save/load and check equivalence."""
|
||||||
|
|
||||||
|
def _build_project(self) -> Project:
|
||||||
|
project = Project(name="Test Project", description="A tiny test")
|
||||||
|
project.file_path = None # simulate untitled
|
||||||
|
|
||||||
|
comp = project.add_component(Component(name="Part1"))
|
||||||
|
|
||||||
|
# Add a workplane.
|
||||||
|
wp = Workplane(
|
||||||
|
name="Top",
|
||||||
|
origin=(0.0, 0.0, 0.0),
|
||||||
|
normal=(0.0, 0.0, 1.0),
|
||||||
|
x_dir=(1.0, 0.0, 0.0),
|
||||||
|
)
|
||||||
|
comp.add_workplane(wp)
|
||||||
|
|
||||||
|
# Add a sketch with a square.
|
||||||
|
sk = comp.add_sketch()
|
||||||
|
sk.occ_sketch.add_rectangle((0.0, 0.0), (10.0, 10.0))
|
||||||
|
sk.solve()
|
||||||
|
# Build a face geometry for the sketch (needed for export / restore).
|
||||||
|
faces = sk.occ_sketch.detect_faces()
|
||||||
|
if faces:
|
||||||
|
sk.geometry = sk.occ_sketch.build_face_geometry(faces[0])
|
||||||
|
|
||||||
|
# Extrude into a body.
|
||||||
|
if sk.geometry:
|
||||||
|
kernel = project.kernel
|
||||||
|
body_shape = kernel.extrude(sk.geometry, height=20.0)
|
||||||
|
body = comp.add_body(
|
||||||
|
Body(
|
||||||
|
name="Block",
|
||||||
|
geometry=body_shape,
|
||||||
|
source_sketch=sk,
|
||||||
|
source_operation="extrude",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
body.color = (0.4, 0.2, 0.8)
|
||||||
|
|
||||||
|
# Add an assembly with two instances and a mated connector pair.
|
||||||
|
asm = project.add_assembly(Assembly(name="Asm1"))
|
||||||
|
ac1 = asm.add_component_instance(comp.id, name="Inst1")
|
||||||
|
ac2 = asm.add_component_instance(comp.id, name="Inst2")
|
||||||
|
c1 = ac1.add_connector(
|
||||||
|
position=(5.0, 5.0, 0.0),
|
||||||
|
normal=(0.0, 0.0, 1.0),
|
||||||
|
x_dir=(1.0, 0.0, 0.0),
|
||||||
|
)
|
||||||
|
c2 = ac2.add_connector(
|
||||||
|
position=(10.0, 10.0, 5.0),
|
||||||
|
normal=(0.0, 0.0, -1.0),
|
||||||
|
x_dir=(1.0, 0.0, 0.0),
|
||||||
|
)
|
||||||
|
# Record a mated pair (UI normally does this on connector-pick).
|
||||||
|
conn = asm.add_connection(ac1.id, ac2.id)
|
||||||
|
conn.first_connector_id = c1.id
|
||||||
|
conn.second_connector_id = c2.id
|
||||||
|
c1.partner_ac_id = ac2.id
|
||||||
|
c1.partner_connector_id = c2.id
|
||||||
|
c2.partner_ac_id = ac1.id
|
||||||
|
c2.partner_connector_id = c1.id
|
||||||
|
c1.is_grounded = True
|
||||||
|
|
||||||
|
return project
|
||||||
|
|
||||||
|
def test_round_trip(self):
|
||||||
|
original = self._build_project()
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
path = os.path.join(tmp, "test.fluency")
|
||||||
|
saved_path = save_project(original, path)
|
||||||
|
self.assertTrue(os.path.exists(saved_path))
|
||||||
|
self.assertGreater(os.path.getsize(saved_path), 100)
|
||||||
|
|
||||||
|
loaded, view_state = load_project(saved_path)
|
||||||
|
|
||||||
|
# ── Project metadata ──
|
||||||
|
self.assertEqual(loaded.name, "Test Project")
|
||||||
|
self.assertEqual(loaded.description, "A tiny test")
|
||||||
|
self.assertEqual(len(loaded.components), 1)
|
||||||
|
self.assertEqual(len(loaded.assemblies), 1)
|
||||||
|
|
||||||
|
# ── Component / Workplane / Sketch / Body ──
|
||||||
|
comp = next(iter(loaded.components.values()))
|
||||||
|
self.assertEqual(comp.name, "Part1")
|
||||||
|
self.assertEqual(len(comp.workplanes), 1)
|
||||||
|
self.assertEqual(len(comp.sketches), 1)
|
||||||
|
self.assertEqual(len(comp.bodies), 1)
|
||||||
|
|
||||||
|
sk = next(iter(comp.sketches.values()))
|
||||||
|
self.assertIsNotNone(sk.occ_sketch)
|
||||||
|
# OCCSketch replayed the rectangle: 4 points + 4 lines + 1 implicit
|
||||||
|
# origin anchor (first-point-fix). We only check that the entities
|
||||||
|
# exist.
|
||||||
|
self.assertGreaterEqual(sk.occ_sketch.get_entity_count(), 4)
|
||||||
|
# Solved geometry should round-trip through STEP.
|
||||||
|
self.assertIsNotNone(sk.geometry)
|
||||||
|
|
||||||
|
body = next(iter(comp.bodies.values()))
|
||||||
|
self.assertIsNotNone(body.geometry)
|
||||||
|
self.assertEqual(body.name, "Block")
|
||||||
|
self.assertEqual(tuple(body.color), (0.4, 0.2, 0.8))
|
||||||
|
# BRep topology should still be valid.
|
||||||
|
self.assertGreater(body.get_mesh(loaded.kernel, 0.5)[0].size, 0)
|
||||||
|
|
||||||
|
# ── Assembly / connector / connection ──
|
||||||
|
asm = next(iter(loaded.assemblies.values()))
|
||||||
|
self.assertEqual(len(asm.components), 2)
|
||||||
|
self.assertEqual(len(asm.connections), 1)
|
||||||
|
ac1, ac2 = list(asm.components.values())
|
||||||
|
self.assertEqual(len(ac1.connectors), 1)
|
||||||
|
conn = next(iter(ac1.connectors.values()))
|
||||||
|
self.assertEqual(conn.position, (5.0, 5.0, 0.0))
|
||||||
|
# The grounded-reference flag was re-applied to the first connector.
|
||||||
|
self.assertTrue(conn.is_grounded)
|
||||||
|
# Rigid-group BFS should still link the two instances.
|
||||||
|
self.assertEqual(set(asm.get_rigid_group(ac1.id)), {ac1.id, ac2.id})
|
||||||
|
|
||||||
|
|
||||||
|
class TestProjectIOWithConstraints(unittest.TestCase):
|
||||||
|
"""Round-trip with parametric constraints on the sketch.
|
||||||
|
|
||||||
|
Builds a slightly more interesting sketch (rectangle with horizontal +
|
||||||
|
vertical constraints + a distance) so the constraint-log replay path
|
||||||
|
is exercised, not just the entity-construction path.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _build_project(self) -> Project:
|
||||||
|
project = Project(name="Constraints Project", description="")
|
||||||
|
|
||||||
|
comp = project.add_component(Component(name="Part1"))
|
||||||
|
|
||||||
|
# A square with a 25.4mm horizontal distance + a vertical distance,
|
||||||
|
# both anchored on a single fixed corner. This is the minimum
|
||||||
|
# number of constraints to fully define a square in 2D.
|
||||||
|
sk = comp.add_sketch()
|
||||||
|
sk.occ_sketch.set_workplane(
|
||||||
|
(0.0, 0.0, 0.0),
|
||||||
|
(0.0, 0.0, 1.0),
|
||||||
|
(1.0, 0.0, 0.0),
|
||||||
|
)
|
||||||
|
p1 = sk.occ_sketch.add_point(0.0, 0.0) # fixed anchor (auto)
|
||||||
|
p2 = sk.occ_sketch.add_point(25.4, 0.0)
|
||||||
|
p3 = sk.occ_sketch.add_point(25.4, 25.4)
|
||||||
|
p4 = sk.occ_sketch.add_point(0.0, 25.4)
|
||||||
|
sk.occ_sketch.add_line(p1, p2)
|
||||||
|
sk.occ_sketch.add_line(p2, p3)
|
||||||
|
sk.occ_sketch.add_line(p3, p4)
|
||||||
|
sk.occ_sketch.add_line(p4, p1)
|
||||||
|
# Constraint the right side to a known length (instead of relying
|
||||||
|
# on the construction positions, which would just be redundant).
|
||||||
|
sk.occ_sketch.constrain_distance(p2, p3, 25.4)
|
||||||
|
sk.solve()
|
||||||
|
sk.is_fully_constrained = sk.occ_sketch.is_fully_constrained()
|
||||||
|
faces = sk.occ_sketch.detect_faces()
|
||||||
|
if faces:
|
||||||
|
sk.geometry = sk.occ_sketch.build_face_geometry(faces[0])
|
||||||
|
|
||||||
|
# View state to persist.
|
||||||
|
view_state = {
|
||||||
|
"active_tab": 0,
|
||||||
|
"active_component_id": comp.id,
|
||||||
|
"active_sketch_id": sk.id,
|
||||||
|
"camera_eye": [50.0, 50.0, 50.0],
|
||||||
|
"camera_at": [0.0, 0.0, 0.0],
|
||||||
|
"camera_up": [0.0, 0.0, 1.0],
|
||||||
|
"panel_focus": "sketch",
|
||||||
|
"assembly_view_active": False,
|
||||||
|
}
|
||||||
|
self._view_state = view_state
|
||||||
|
return project
|
||||||
|
|
||||||
|
def test_round_trip_with_view_state(self):
|
||||||
|
original = self._build_project()
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
path = os.path.join(tmp, "constrained.fluency")
|
||||||
|
save_project(original, path, view_state=self._view_state)
|
||||||
|
loaded, view_state = load_project(path)
|
||||||
|
|
||||||
|
# View state must survive (modulo float-to-list round-tripping).
|
||||||
|
self.assertEqual(view_state.get("active_component_id"),
|
||||||
|
self._view_state["active_component_id"])
|
||||||
|
self.assertEqual(view_state.get("active_sketch_id"),
|
||||||
|
self._view_state["active_sketch_id"])
|
||||||
|
self.assertEqual(view_state.get("camera_eye"),
|
||||||
|
self._view_state["camera_eye"])
|
||||||
|
self.assertEqual(view_state.get("panel_focus"),
|
||||||
|
self._view_state["panel_focus"])
|
||||||
|
|
||||||
|
# Sketch must have replayed its constraints and remain solvable.
|
||||||
|
comp = next(iter(loaded.components.values()))
|
||||||
|
sk = next(iter(comp.sketches.values()))
|
||||||
|
# Re-solve on the loaded sketch to confirm the post-replay
|
||||||
|
# configuration is still consistent.
|
||||||
|
self.assertTrue(sk.occ_sketch.solve())
|
||||||
|
# The right edge of the square should still be 25.4mm tall.
|
||||||
|
import math
|
||||||
|
for lid, line in sk.occ_sketch._lines.items():
|
||||||
|
sid, eid = line
|
||||||
|
sx, sy = sk.occ_sketch._points[sid]
|
||||||
|
ex, ey = sk.occ_sketch._points[eid]
|
||||||
|
length = math.hypot(ex - sx, ey - sy)
|
||||||
|
if abs(length) > 1e-3:
|
||||||
|
self.assertAlmostEqual(
|
||||||
|
length, 25.4, places=3,
|
||||||
|
msg=f"Constraint replay broke: line {lid} = {length}",
|
||||||
|
)
|
||||||
|
break
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
"""Fluency CAD UI package.
|
||||||
|
|
||||||
|
Contains the Qt widgets, dialogs, and the main window:
|
||||||
|
|
||||||
|
ui/
|
||||||
|
dialogs.py – All 4 modal dialogs (Extrude, Revolve, Offset, WorkplaneOrientation)
|
||||||
|
sketch_widget.py – Sketch2DWidget (2D sketcher with constraint solver)
|
||||||
|
viewer_widget.py – Viewer3DWidget (3D viewer / OCC canvas)
|
||||||
|
main_window.py – MainWindow (application shell)
|
||||||
|
|
||||||
|
The public classes are re-exported from `fluency.main` so existing code that
|
||||||
|
does `from fluency.main import MainWindow` continues to work.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__all__: list[str] = []
|
||||||
@@ -0,0 +1,497 @@
|
|||||||
|
"""Dialogs for Fluency CAD operations: extrude, revolve, offset, workplane orientation."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import math
|
||||||
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
|
from PySide6.QtCore import Qt, QPoint, QPointF
|
||||||
|
from PySide6.QtGui import QColor, QFont, QKeySequence
|
||||||
|
from PySide6.QtWidgets import (
|
||||||
|
QButtonGroup,
|
||||||
|
QCheckBox,
|
||||||
|
QComboBox,
|
||||||
|
QDialog,
|
||||||
|
QDialogButtonBox,
|
||||||
|
QDoubleSpinBox,
|
||||||
|
QFormLayout,
|
||||||
|
QFrame,
|
||||||
|
QGridLayout,
|
||||||
|
QHBoxLayout,
|
||||||
|
QLabel,
|
||||||
|
QLineEdit,
|
||||||
|
QPushButton,
|
||||||
|
QRadioButton,
|
||||||
|
QVBoxLayout,
|
||||||
|
QWidget,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
class ExtrudeDialog(QDialog):
|
||||||
|
"""Dialog for extrude options.
|
||||||
|
|
||||||
|
Carries an optional ``preview_callback`` that is invoked whenever the
|
||||||
|
user changes any option; the host uses it to render a live transparent
|
||||||
|
preview of the operation result in the 3D view. Passing *False* (or
|
||||||
|
*None*) to the callback tells the host to clear the preview.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.setWindowTitle("Extrude Options")
|
||||||
|
self.setMinimumWidth(320)
|
||||||
|
|
||||||
|
self._preview_callback = None
|
||||||
|
|
||||||
|
layout = QVBoxLayout(self)
|
||||||
|
|
||||||
|
length_layout = QHBoxLayout()
|
||||||
|
length_layout.addWidget(QLabel("Extrude Length (mm):"))
|
||||||
|
self.length_input = QDoubleSpinBox()
|
||||||
|
self.length_input.setDecimals(2)
|
||||||
|
self.length_input.setRange(-10000, 10000)
|
||||||
|
self.length_input.setValue(10)
|
||||||
|
length_layout.addWidget(self.length_input)
|
||||||
|
layout.addLayout(length_layout)
|
||||||
|
|
||||||
|
self.symmetric_checkbox = QCheckBox("Symmetric Extrude")
|
||||||
|
layout.addWidget(self.symmetric_checkbox)
|
||||||
|
|
||||||
|
self.invert_checkbox = QCheckBox("Invert Extrusion")
|
||||||
|
layout.addWidget(self.invert_checkbox)
|
||||||
|
|
||||||
|
self.cut_checkbox = QCheckBox("Perform Cut")
|
||||||
|
layout.addWidget(self.cut_checkbox)
|
||||||
|
|
||||||
|
self.union_checkbox = QCheckBox("Combine (Union)")
|
||||||
|
layout.addWidget(self.union_checkbox)
|
||||||
|
|
||||||
|
self.through_all_checkbox = QCheckBox("Through All (cut/union target)")
|
||||||
|
self.through_all_checkbox.setToolTip(
|
||||||
|
"Ignore the typed length and extrude far enough to fully pass "
|
||||||
|
"through the cut/union target body. Applies when Perform Cut or "
|
||||||
|
"Combine (Union) is checked."
|
||||||
|
)
|
||||||
|
layout.addWidget(self.through_all_checkbox)
|
||||||
|
|
||||||
|
self.rounded_checkbox = QCheckBox("Round Edges")
|
||||||
|
layout.addWidget(self.rounded_checkbox)
|
||||||
|
|
||||||
|
line = QFrame()
|
||||||
|
line.setFrameShape(QFrame.HLine)
|
||||||
|
line.setFrameShadow(QFrame.Sunken)
|
||||||
|
layout.addWidget(line)
|
||||||
|
|
||||||
|
button_layout = QHBoxLayout()
|
||||||
|
ok_button = QPushButton("OK")
|
||||||
|
ok_button.clicked.connect(self.accept)
|
||||||
|
cancel_button = QPushButton("Cancel")
|
||||||
|
cancel_button.clicked.connect(self.reject)
|
||||||
|
button_layout.addWidget(ok_button)
|
||||||
|
button_layout.addWidget(cancel_button)
|
||||||
|
layout.addLayout(button_layout)
|
||||||
|
|
||||||
|
# Live preview: recompute on every option change. Use a light-
|
||||||
|
# weight guard so we don't emit before the host has wired up the
|
||||||
|
# callback.
|
||||||
|
for w in (
|
||||||
|
self.length_input,
|
||||||
|
self.symmetric_checkbox,
|
||||||
|
self.invert_checkbox,
|
||||||
|
self.cut_checkbox,
|
||||||
|
self.union_checkbox,
|
||||||
|
self.through_all_checkbox,
|
||||||
|
self.rounded_checkbox,
|
||||||
|
):
|
||||||
|
# The spinbox has valueChanged; the checkboxes have stateChanged.
|
||||||
|
# Each must be wired in its own try/except so that a missing
|
||||||
|
# signal on one widget type doesn't skip the OTHER signal's
|
||||||
|
# connection (the prior single-try version accidentally
|
||||||
|
# left checkboxes un-connected when valueChanged raised first).
|
||||||
|
try:
|
||||||
|
w.valueChanged.connect(self._emit_preview)
|
||||||
|
except AttributeError:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
w.stateChanged.connect(self._emit_preview)
|
||||||
|
except AttributeError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def set_preview_callback(self, callback) -> None:
|
||||||
|
"""Install the live-preview callback (or *None* to disable)."""
|
||||||
|
self._preview_callback = callback
|
||||||
|
# Emit once so the initial state shows a preview right away.
|
||||||
|
self._emit_preview()
|
||||||
|
|
||||||
|
def _emit_preview(self, *args) -> None:
|
||||||
|
if self._preview_callback is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
self._preview_callback(self.get_values())
|
||||||
|
except Exception as exc: # preview must never break the dialog
|
||||||
|
logger.debug("extrude preview callback raised: %s", exc)
|
||||||
|
|
||||||
|
def hideEvent(self, event):
|
||||||
|
# Tell the host to clear the preview when the dialog goes away
|
||||||
|
# (accept, reject, or close). The host is responsible for the
|
||||||
|
# actual viewer cleanup.
|
||||||
|
if self._preview_callback is not None:
|
||||||
|
try:
|
||||||
|
self._preview_callback(None)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
super().hideEvent(event)
|
||||||
|
|
||||||
|
def get_values(self) -> Tuple[float, bool, bool, bool, bool, bool, bool]:
|
||||||
|
return (
|
||||||
|
self.length_input.value(),
|
||||||
|
self.symmetric_checkbox.isChecked(),
|
||||||
|
self.invert_checkbox.isChecked(),
|
||||||
|
self.cut_checkbox.isChecked(),
|
||||||
|
self.union_checkbox.isChecked(),
|
||||||
|
self.through_all_checkbox.isChecked(),
|
||||||
|
self.rounded_checkbox.isChecked(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class RevolveDialog(QDialog):
|
||||||
|
"""Dialog for revolve options."""
|
||||||
|
|
||||||
|
def __init__(self, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.setWindowTitle("Revolve Options")
|
||||||
|
self.setMinimumWidth(300)
|
||||||
|
|
||||||
|
layout = QVBoxLayout(self)
|
||||||
|
|
||||||
|
angle_layout = QHBoxLayout()
|
||||||
|
angle_layout.addWidget(QLabel("Revolve Angle (°):"))
|
||||||
|
self.angle_input = QDoubleSpinBox()
|
||||||
|
self.angle_input.setDecimals(1)
|
||||||
|
self.angle_input.setRange(1, 360)
|
||||||
|
self.angle_input.setValue(360)
|
||||||
|
self.angle_input.setSuffix("°")
|
||||||
|
angle_layout.addWidget(self.angle_input)
|
||||||
|
layout.addLayout(angle_layout)
|
||||||
|
|
||||||
|
line = QFrame()
|
||||||
|
line.setFrameShape(QFrame.HLine)
|
||||||
|
line.setFrameShadow(QFrame.Sunken)
|
||||||
|
layout.addWidget(line)
|
||||||
|
|
||||||
|
button_layout = QHBoxLayout()
|
||||||
|
ok_button = QPushButton("OK")
|
||||||
|
ok_button.clicked.connect(self.accept)
|
||||||
|
cancel_button = QPushButton("Cancel")
|
||||||
|
cancel_button.clicked.connect(self.reject)
|
||||||
|
button_layout.addWidget(ok_button)
|
||||||
|
button_layout.addWidget(cancel_button)
|
||||||
|
layout.addLayout(button_layout)
|
||||||
|
|
||||||
|
|
||||||
|
class OffsetDialog(QDialog):
|
||||||
|
"""Dialog for 2D sketch offset options.
|
||||||
|
|
||||||
|
Shows a number input for the offset distance with a live preview
|
||||||
|
callback so the sketch widget can render the offset result in real
|
||||||
|
time. On accept the caller retrieves ``get_values()`` → distance.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.setWindowTitle("Offset Sketch")
|
||||||
|
self.setMinimumWidth(300)
|
||||||
|
|
||||||
|
self._preview_callback = None
|
||||||
|
|
||||||
|
layout = QVBoxLayout(self)
|
||||||
|
|
||||||
|
dist_layout = QHBoxLayout()
|
||||||
|
dist_layout.addWidget(QLabel("Offset Distance (mm):"))
|
||||||
|
self.distance_input = QDoubleSpinBox()
|
||||||
|
self.distance_input.setDecimals(2)
|
||||||
|
self.distance_input.setRange(-10000, 10000)
|
||||||
|
self.distance_input.setValue(10.0)
|
||||||
|
self.distance_input.setSingleStep(0.5)
|
||||||
|
dist_layout.addWidget(self.distance_input)
|
||||||
|
layout.addLayout(dist_layout)
|
||||||
|
|
||||||
|
self.inward_checkbox = QCheckBox("Offset Inward (negative)")
|
||||||
|
self.inward_checkbox.setToolTip("Offset is applied inward instead of outward.")
|
||||||
|
layout.addWidget(self.inward_checkbox)
|
||||||
|
|
||||||
|
line = QFrame()
|
||||||
|
line.setFrameShape(QFrame.HLine)
|
||||||
|
line.setFrameShadow(QFrame.Sunken)
|
||||||
|
layout.addWidget(line)
|
||||||
|
|
||||||
|
button_layout = QHBoxLayout()
|
||||||
|
ok_button = QPushButton("OK")
|
||||||
|
ok_button.clicked.connect(self.accept)
|
||||||
|
cancel_button = QPushButton("Cancel")
|
||||||
|
cancel_button.clicked.connect(self.reject)
|
||||||
|
button_layout.addWidget(ok_button)
|
||||||
|
button_layout.addWidget(cancel_button)
|
||||||
|
layout.addLayout(button_layout)
|
||||||
|
|
||||||
|
# Live preview on every value change.
|
||||||
|
self.distance_input.valueChanged.connect(self._emit_preview)
|
||||||
|
self.inward_checkbox.stateChanged.connect(self._emit_preview)
|
||||||
|
|
||||||
|
def set_preview_callback(self, callback) -> None:
|
||||||
|
"""Install the live-preview callback (or *None* to disable)."""
|
||||||
|
self._preview_callback = callback
|
||||||
|
self._emit_preview()
|
||||||
|
|
||||||
|
def _emit_preview(self, *args) -> None:
|
||||||
|
if self._preview_callback is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
self._preview_callback(self.get_values())
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug("offset preview callback raised: %s", exc)
|
||||||
|
|
||||||
|
def hideEvent(self, event):
|
||||||
|
if self._preview_callback is not None:
|
||||||
|
try:
|
||||||
|
self._preview_callback(None)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
super().hideEvent(event)
|
||||||
|
|
||||||
|
def get_values(self) -> Tuple[float, bool]:
|
||||||
|
return (self.distance_input.value(), self.inward_checkbox.isChecked())
|
||||||
|
|
||||||
|
|
||||||
|
class WorkplaneOrientationDialog(QDialog):
|
||||||
|
"""Modal dialog to choose the orientation of a new workplane.
|
||||||
|
|
||||||
|
Offers XY, XZ, YZ, and custom angle presets. On accept, the caller
|
||||||
|
can retrieve the chosen orientation via :meth:`get_orientation`, which
|
||||||
|
returns (normal, x_dir) pair (both as 3-tuples).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.setWindowTitle("New Workplane Orientation")
|
||||||
|
self.setMinimumWidth(320)
|
||||||
|
|
||||||
|
self._normal: Tuple[float, float, float] = (0.0, 0.0, 1.0)
|
||||||
|
self._x_dir: Tuple[float, float, float] = (1.0, 0.0, 0.0)
|
||||||
|
|
||||||
|
# Optional callback for live 3D preview of the workplane.
|
||||||
|
# The host installs it via ``set_preview_callback``. The callback
|
||||||
|
# receives ``(normal, x_dir)`` or *None* to clear.
|
||||||
|
self._preview_callback = None
|
||||||
|
|
||||||
|
layout = QVBoxLayout(self)
|
||||||
|
|
||||||
|
# ── Orientation presets ──
|
||||||
|
lbl = QLabel("Choose orientation:")
|
||||||
|
layout.addWidget(lbl)
|
||||||
|
|
||||||
|
self._preset_group = QButtonGroup(self)
|
||||||
|
preset_layout = QGridLayout()
|
||||||
|
presets = [
|
||||||
|
("XY (Top)", (0, 0, 1), (1, 0, 0)),
|
||||||
|
("XZ (Front)", (0, 1, 0), (1, 0, 0)),
|
||||||
|
("YZ (Right)", (1, 0, 0), (0, 1, 0)),
|
||||||
|
("-XY (Bottom)", (0, 0, -1), (1, 0, 0)),
|
||||||
|
("-XZ (Back)", (0, -1, 0), (1, 0, 0)),
|
||||||
|
("-YZ (Left)", (-1, 0, 0), (0, 1, 0)),
|
||||||
|
]
|
||||||
|
for idx, (label, normal, x_dir) in enumerate(presets):
|
||||||
|
btn = QRadioButton(label)
|
||||||
|
btn.setChecked(idx == 0)
|
||||||
|
self._preset_group.addButton(btn, idx)
|
||||||
|
btn.normal = normal
|
||||||
|
btn.x_dir = x_dir
|
||||||
|
preset_layout.addWidget(btn, idx // 2, idx % 2)
|
||||||
|
layout.addLayout(preset_layout)
|
||||||
|
|
||||||
|
# ── Custom angle (offset from XY) ──
|
||||||
|
line = QFrame()
|
||||||
|
line.setFrameShape(QFrame.HLine)
|
||||||
|
line.setFrameShadow(QFrame.Sunken)
|
||||||
|
layout.addWidget(line)
|
||||||
|
|
||||||
|
self._custom_radio = QRadioButton("Custom (angle from XY):")
|
||||||
|
self._custom_radio.setChecked(False)
|
||||||
|
layout.addWidget(self._custom_radio)
|
||||||
|
|
||||||
|
angle_layout = QHBoxLayout()
|
||||||
|
angle_layout.addWidget(QLabel("Angle X (°):"))
|
||||||
|
self._angle_x = QDoubleSpinBox()
|
||||||
|
self._angle_x.setDecimals(1)
|
||||||
|
self._angle_x.setRange(-360, 360)
|
||||||
|
self._angle_x.setValue(0.0)
|
||||||
|
self._angle_x.setSuffix("°")
|
||||||
|
angle_layout.addWidget(self._angle_x)
|
||||||
|
angle_layout.addWidget(QLabel("Angle Y (°):"))
|
||||||
|
self._angle_y = QDoubleSpinBox()
|
||||||
|
self._angle_y.setDecimals(1)
|
||||||
|
self._angle_y.setRange(-360, 360)
|
||||||
|
self._angle_y.setValue(0.0)
|
||||||
|
self._angle_y.setSuffix("°")
|
||||||
|
angle_layout.addWidget(self._angle_y)
|
||||||
|
layout.addLayout(angle_layout)
|
||||||
|
|
||||||
|
self._name_label = QLabel("Workplane Name:")
|
||||||
|
layout.addWidget(self._name_label)
|
||||||
|
|
||||||
|
self._name_input = QLineEdit()
|
||||||
|
self._name_input.setText("Workplane 1")
|
||||||
|
layout.addWidget(self._name_input)
|
||||||
|
|
||||||
|
# ── Buttons ──
|
||||||
|
line2 = QFrame()
|
||||||
|
line2.setFrameShape(QFrame.HLine)
|
||||||
|
line2.setFrameShadow(QFrame.Sunken)
|
||||||
|
layout.addWidget(line2)
|
||||||
|
|
||||||
|
button_layout = QHBoxLayout()
|
||||||
|
ok_button = QPushButton("Create")
|
||||||
|
ok_button.clicked.connect(self._on_ok)
|
||||||
|
cancel_button = QPushButton("Cancel")
|
||||||
|
cancel_button.clicked.connect(self.reject)
|
||||||
|
button_layout.addWidget(ok_button)
|
||||||
|
button_layout.addWidget(cancel_button)
|
||||||
|
layout.addLayout(button_layout)
|
||||||
|
|
||||||
|
# Live preview: update the 3D view whenever the user changes
|
||||||
|
# the preset, custom radio toggle, or angle values.
|
||||||
|
self._preset_group.buttonClicked.connect(self._on_preset_changed)
|
||||||
|
self._custom_radio.toggled.connect(self._emit_preview)
|
||||||
|
self._angle_x.valueChanged.connect(self._emit_preview)
|
||||||
|
self._angle_y.valueChanged.connect(self._emit_preview)
|
||||||
|
|
||||||
|
def set_preview_callback(self, callback) -> None:
|
||||||
|
"""Install a callback for live 3D preview of the workplane orientation.
|
||||||
|
|
||||||
|
*callback* is called with ``(normal, x_dir)`` whenever the user
|
||||||
|
changes the selection, or with *None* when the dialog closes.
|
||||||
|
"""
|
||||||
|
self._preview_callback = callback
|
||||||
|
# Emit once so the initial state shows a preview right away.
|
||||||
|
self._emit_preview()
|
||||||
|
|
||||||
|
def _emit_preview(self, *args) -> None:
|
||||||
|
"""Call the preview callback with the current orientation, if installed."""
|
||||||
|
if self._preview_callback is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
normal, x_dir, _name = self.get_orientation()
|
||||||
|
self._preview_callback((normal, x_dir))
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug("workplane preview callback raised: %s", exc)
|
||||||
|
|
||||||
|
def hideEvent(self, event):
|
||||||
|
"""Clear the live preview when the dialog closes."""
|
||||||
|
if self._preview_callback is not None:
|
||||||
|
try:
|
||||||
|
self._preview_callback(None)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
super().hideEvent(event)
|
||||||
|
|
||||||
|
def _on_preset_changed(self, btn):
|
||||||
|
"""When a preset is selected, deselect the custom radio and emit preview."""
|
||||||
|
self._custom_radio.setChecked(False)
|
||||||
|
self._emit_preview()
|
||||||
|
|
||||||
|
def _on_ok(self):
|
||||||
|
"""Compute the final orientation and accept."""
|
||||||
|
import numpy as np
|
||||||
|
import math
|
||||||
|
|
||||||
|
if self._custom_radio.isChecked():
|
||||||
|
# Custom: start from XY normal and rotate by the two angles.
|
||||||
|
ax = math.radians(self._angle_x.value())
|
||||||
|
ay = math.radians(self._angle_y.value())
|
||||||
|
# Start from +Z normal, rotate around X then Y
|
||||||
|
n = np.array([0.0, 0.0, 1.0])
|
||||||
|
# Rotate around X
|
||||||
|
rx = np.array([
|
||||||
|
[1, 0, 0],
|
||||||
|
[0, math.cos(ax), -math.sin(ax)],
|
||||||
|
[0, math.sin(ax), math.cos(ax)],
|
||||||
|
])
|
||||||
|
n = rx @ n
|
||||||
|
# Rotate around Y
|
||||||
|
ry = np.array([
|
||||||
|
[math.cos(ay), 0, math.sin(ay)],
|
||||||
|
[0, 1, 0],
|
||||||
|
[-math.sin(ay), 0, math.cos(ay)],
|
||||||
|
])
|
||||||
|
n = ry @ n
|
||||||
|
n = n / np.linalg.norm(n)
|
||||||
|
# x_dir: cross product of normal with world Y, or world Z if normal ~ Y
|
||||||
|
world_y = np.array([0.0, 1.0, 0.0])
|
||||||
|
if abs(np.dot(n, world_y)) > 0.99:
|
||||||
|
world_y = np.array([0.0, 0.0, 1.0])
|
||||||
|
x = np.cross(world_y, n)
|
||||||
|
x_norm = np.linalg.norm(x)
|
||||||
|
if x_norm > 1e-9:
|
||||||
|
x = x / x_norm
|
||||||
|
else:
|
||||||
|
x = np.array([1.0, 0.0, 0.0])
|
||||||
|
self._normal = tuple(float(v) for v in n)
|
||||||
|
self._x_dir = tuple(float(v) for v in x)
|
||||||
|
|
||||||
|
else:
|
||||||
|
btn = self._preset_group.checkedButton()
|
||||||
|
if btn is not None:
|
||||||
|
self._normal = btn.normal
|
||||||
|
self._x_dir = btn.x_dir
|
||||||
|
self.accept()
|
||||||
|
|
||||||
|
def get_orientation(self) -> Tuple[Tuple[float, float, float], Tuple[float, float, float], str]:
|
||||||
|
"""Return (normal, x_dir, name) for the chosen workplane.
|
||||||
|
|
||||||
|
Computes the current selection from the UI state so it works
|
||||||
|
whether called before or after ``_on_ok``.
|
||||||
|
"""
|
||||||
|
import numpy as np
|
||||||
|
import math
|
||||||
|
|
||||||
|
if self._custom_radio.isChecked():
|
||||||
|
ax = math.radians(self._angle_x.value())
|
||||||
|
ay = math.radians(self._angle_y.value())
|
||||||
|
n = np.array([0.0, 0.0, 1.0])
|
||||||
|
rx = np.array([
|
||||||
|
[1, 0, 0],
|
||||||
|
[0, math.cos(ax), -math.sin(ax)],
|
||||||
|
[0, math.sin(ax), math.cos(ax)],
|
||||||
|
])
|
||||||
|
n = rx @ n
|
||||||
|
ry = np.array([
|
||||||
|
[math.cos(ay), 0, math.sin(ay)],
|
||||||
|
[0, 1, 0],
|
||||||
|
[-math.sin(ay), 0, math.cos(ay)],
|
||||||
|
])
|
||||||
|
n = ry @ n
|
||||||
|
n = n / np.linalg.norm(n)
|
||||||
|
world_y = np.array([0.0, 1.0, 0.0])
|
||||||
|
if abs(np.dot(n, world_y)) > 0.99:
|
||||||
|
world_y = np.array([0.0, 0.0, 1.0])
|
||||||
|
x = np.cross(world_y, n)
|
||||||
|
x_norm = np.linalg.norm(x)
|
||||||
|
if x_norm > 1e-9:
|
||||||
|
x = x / x_norm
|
||||||
|
else:
|
||||||
|
x = np.array([1.0, 0.0, 0.0])
|
||||||
|
return (
|
||||||
|
tuple(float(v) for v in n),
|
||||||
|
tuple(float(v) for v in x),
|
||||||
|
self._name_input.text().strip() or "Workplane",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
btn = self._preset_group.checkedButton()
|
||||||
|
if btn is not None:
|
||||||
|
return (btn.normal, btn.x_dir, self._name_input.text().strip() or "Workplane")
|
||||||
|
# Fallback: XY default.
|
||||||
|
return ((0.0, 0.0, 1.0), (1.0, 0.0, 0.0), self._name_input.text().strip() or "Workplane")
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,906 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
################################################################################
|
||||||
|
## Form generated from reading UI file 'gui.ui'
|
||||||
|
##
|
||||||
|
## Created by: Qt User Interface Compiler version 6.10.2
|
||||||
|
##
|
||||||
|
## WARNING! All changes made in this file will be lost when recompiling UI file!
|
||||||
|
################################################################################
|
||||||
|
|
||||||
|
from PySide6.QtCore import (QCoreApplication, QDate, QDateTime, QLocale,
|
||||||
|
QMetaObject, QObject, QPoint, QRect,
|
||||||
|
QSize, QTime, QUrl, Qt)
|
||||||
|
from PySide6.QtGui import (QAction, QBrush, QColor, QConicalGradient,
|
||||||
|
QCursor, QFont, QFontDatabase, QGradient,
|
||||||
|
QIcon, QImage, QKeySequence, QLinearGradient,
|
||||||
|
QPainter, QPalette, QPixmap, QRadialGradient,
|
||||||
|
QTransform)
|
||||||
|
from PySide6.QtWidgets import (QApplication, QFrame, QGridLayout, QGroupBox,
|
||||||
|
QHBoxLayout, QLabel, QListWidget, QListWidgetItem,
|
||||||
|
QMainWindow, QMenu, QMenuBar, QPushButton,
|
||||||
|
QSizePolicy, QSpinBox, QStatusBar, QTabWidget,
|
||||||
|
QTextEdit, QVBoxLayout, QWidget)
|
||||||
|
|
||||||
|
class Ui_fluencyCAD(object):
|
||||||
|
def setupUi(self, fluencyCAD):
|
||||||
|
if not fluencyCAD.objectName():
|
||||||
|
fluencyCAD.setObjectName(u"fluencyCAD")
|
||||||
|
fluencyCAD.resize(2359, 1285)
|
||||||
|
self.actionNew_Project = QAction(fluencyCAD)
|
||||||
|
self.actionNew_Project.setObjectName(u"actionNew_Project")
|
||||||
|
self.actionOpen_Project = QAction(fluencyCAD)
|
||||||
|
self.actionOpen_Project.setObjectName(u"actionOpen_Project")
|
||||||
|
self.actionSave_Project = QAction(fluencyCAD)
|
||||||
|
self.actionSave_Project.setObjectName(u"actionSave_Project")
|
||||||
|
self.actionSave_Project_As = QAction(fluencyCAD)
|
||||||
|
self.actionSave_Project_As.setObjectName(u"actionSave_Project_As")
|
||||||
|
self.actionImport_File = QAction(fluencyCAD)
|
||||||
|
self.actionImport_File.setObjectName(u"actionImport_File")
|
||||||
|
self.actionExport_Step = QAction(fluencyCAD)
|
||||||
|
self.actionExport_Step.setObjectName(u"actionExport_Step")
|
||||||
|
self.actionExport_Iges = QAction(fluencyCAD)
|
||||||
|
self.actionExport_Iges.setObjectName(u"actionExport_Iges")
|
||||||
|
self.actionExport_Stl = QAction(fluencyCAD)
|
||||||
|
self.actionExport_Stl.setObjectName(u"actionExport_Stl")
|
||||||
|
self.actionExit = QAction(fluencyCAD)
|
||||||
|
self.actionExit.setObjectName(u"actionExit")
|
||||||
|
self.centralwidget = QWidget(fluencyCAD)
|
||||||
|
self.centralwidget.setObjectName(u"centralwidget")
|
||||||
|
self.gridLayout = QGridLayout(self.centralwidget)
|
||||||
|
self.gridLayout.setObjectName(u"gridLayout")
|
||||||
|
self.InputTab = QTabWidget(self.centralwidget)
|
||||||
|
self.InputTab.setObjectName(u"InputTab")
|
||||||
|
sizePolicy = QSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
|
||||||
|
sizePolicy.setHorizontalStretch(0)
|
||||||
|
sizePolicy.setVerticalStretch(0)
|
||||||
|
sizePolicy.setHeightForWidth(self.InputTab.sizePolicy().hasHeightForWidth())
|
||||||
|
self.InputTab.setSizePolicy(sizePolicy)
|
||||||
|
self.sketch_tab = QWidget()
|
||||||
|
self.sketch_tab.setObjectName(u"sketch_tab")
|
||||||
|
self.verticalLayout_4 = QVBoxLayout(self.sketch_tab)
|
||||||
|
self.verticalLayout_4.setObjectName(u"verticalLayout_4")
|
||||||
|
self.InputTab.addTab(self.sketch_tab, "")
|
||||||
|
self.code_tab = QWidget()
|
||||||
|
self.code_tab.setObjectName(u"code_tab")
|
||||||
|
self.verticalLayout = QVBoxLayout(self.code_tab)
|
||||||
|
self.verticalLayout.setObjectName(u"verticalLayout")
|
||||||
|
self.textEdit = QTextEdit(self.code_tab)
|
||||||
|
self.textEdit.setObjectName(u"textEdit")
|
||||||
|
|
||||||
|
self.verticalLayout.addWidget(self.textEdit)
|
||||||
|
|
||||||
|
self.groupBox_7 = QGroupBox(self.code_tab)
|
||||||
|
self.groupBox_7.setObjectName(u"groupBox_7")
|
||||||
|
self.gridLayout_5 = QGridLayout(self.groupBox_7)
|
||||||
|
self.gridLayout_5.setObjectName(u"gridLayout_5")
|
||||||
|
self.pushButton_5 = QPushButton(self.groupBox_7)
|
||||||
|
self.pushButton_5.setObjectName(u"pushButton_5")
|
||||||
|
|
||||||
|
self.gridLayout_5.addWidget(self.pushButton_5, 2, 0, 1, 1)
|
||||||
|
|
||||||
|
self.pushButton_4 = QPushButton(self.groupBox_7)
|
||||||
|
self.pushButton_4.setObjectName(u"pushButton_4")
|
||||||
|
|
||||||
|
self.gridLayout_5.addWidget(self.pushButton_4, 2, 1, 1, 1)
|
||||||
|
|
||||||
|
self.pb_apply_code = QPushButton(self.groupBox_7)
|
||||||
|
self.pb_apply_code.setObjectName(u"pb_apply_code")
|
||||||
|
|
||||||
|
self.gridLayout_5.addWidget(self.pb_apply_code, 1, 0, 1, 1)
|
||||||
|
|
||||||
|
self.pushButton = QPushButton(self.groupBox_7)
|
||||||
|
self.pushButton.setObjectName(u"pushButton")
|
||||||
|
|
||||||
|
self.gridLayout_5.addWidget(self.pushButton, 1, 1, 1, 1)
|
||||||
|
|
||||||
|
|
||||||
|
self.verticalLayout.addWidget(self.groupBox_7)
|
||||||
|
|
||||||
|
self.InputTab.addTab(self.code_tab, "")
|
||||||
|
|
||||||
|
self.gridLayout.addWidget(self.InputTab, 0, 1, 12, 1)
|
||||||
|
|
||||||
|
self.groupBox_9 = QGroupBox(self.centralwidget)
|
||||||
|
self.groupBox_9.setObjectName(u"groupBox_9")
|
||||||
|
self.groupBox_9.setMaximumSize(QSize(200, 16777215))
|
||||||
|
self.gridLayout_7 = QGridLayout(self.groupBox_9)
|
||||||
|
self.gridLayout_7.setObjectName(u"gridLayout_7")
|
||||||
|
self.pb_origin_wp = QPushButton(self.groupBox_9)
|
||||||
|
self.pb_origin_wp.setObjectName(u"pb_origin_wp")
|
||||||
|
|
||||||
|
self.gridLayout_7.addWidget(self.pb_origin_wp, 0, 0, 1, 1)
|
||||||
|
|
||||||
|
self.pb_origin_face = QPushButton(self.groupBox_9)
|
||||||
|
self.pb_origin_face.setObjectName(u"pb_origin_face")
|
||||||
|
self.pb_origin_face.setCheckable(True)
|
||||||
|
|
||||||
|
self.gridLayout_7.addWidget(self.pb_origin_face, 0, 1, 1, 1)
|
||||||
|
|
||||||
|
self.pb_flip_face = QPushButton(self.groupBox_9)
|
||||||
|
self.pb_flip_face.setObjectName(u"pb_flip_face")
|
||||||
|
|
||||||
|
self.gridLayout_7.addWidget(self.pb_flip_face, 1, 0, 1, 1)
|
||||||
|
|
||||||
|
self.pb_underlay = QPushButton(self.groupBox_9)
|
||||||
|
self.pb_underlay.setObjectName(u"pb_underlay")
|
||||||
|
self.pb_underlay.setEnabled(False)
|
||||||
|
self.pb_underlay.setCheckable(True)
|
||||||
|
self.pb_underlay.setChecked(True)
|
||||||
|
|
||||||
|
self.gridLayout_7.addWidget(self.pb_underlay, 3, 0, 1, 1)
|
||||||
|
|
||||||
|
self.pb_clr_face = QPushButton(self.groupBox_9)
|
||||||
|
self.pb_clr_face.setObjectName(u"pb_clr_face")
|
||||||
|
self.pb_clr_face.setEnabled(False)
|
||||||
|
|
||||||
|
self.gridLayout_7.addWidget(self.pb_clr_face, 3, 1, 1, 1)
|
||||||
|
|
||||||
|
self.pb_to_sketch = QPushButton(self.groupBox_9)
|
||||||
|
self.pb_to_sketch.setObjectName(u"pb_to_sketch")
|
||||||
|
self.pb_to_sketch.setEnabled(False)
|
||||||
|
|
||||||
|
self.gridLayout_7.addWidget(self.pb_to_sketch, 4, 0, 1, 2)
|
||||||
|
|
||||||
|
self.pb_wp_new = QPushButton(self.groupBox_9)
|
||||||
|
self.pb_wp_new.setObjectName(u"pb_wp_new")
|
||||||
|
|
||||||
|
self.gridLayout_7.addWidget(self.pb_wp_new, 1, 1, 1, 1)
|
||||||
|
|
||||||
|
|
||||||
|
self.gridLayout.addWidget(self.groupBox_9, 0, 0, 1, 1)
|
||||||
|
|
||||||
|
self.assembly_box = QGroupBox(self.centralwidget)
|
||||||
|
self.assembly_box.setObjectName(u"assembly_box")
|
||||||
|
self.assembly_box.setMinimumSize(QSize(0, 50))
|
||||||
|
|
||||||
|
self.gridLayout.addWidget(self.assembly_box, 13, 1, 1, 2)
|
||||||
|
|
||||||
|
self.joint_tools = QGroupBox(self.centralwidget)
|
||||||
|
self.joint_tools.setObjectName(u"joint_tools")
|
||||||
|
self.joint_tools.setMinimumSize(QSize(0, 50))
|
||||||
|
self.gridLayout_10 = QGridLayout(self.joint_tools)
|
||||||
|
self.gridLayout_10.setObjectName(u"gridLayout_10")
|
||||||
|
self.pb_remove_connector = QPushButton(self.joint_tools)
|
||||||
|
self.pb_remove_connector.setObjectName(u"pb_remove_connector")
|
||||||
|
self.pb_remove_connector.setMinimumSize(QSize(50, 50))
|
||||||
|
self.pb_remove_connector.setMaximumSize(QSize(50, 50))
|
||||||
|
|
||||||
|
self.gridLayout_10.addWidget(self.pb_remove_connector, 0, 2, 1, 1)
|
||||||
|
|
||||||
|
self.pb_add_connector = QPushButton(self.joint_tools)
|
||||||
|
self.pb_add_connector.setObjectName(u"pb_add_connector")
|
||||||
|
self.pb_add_connector.setMinimumSize(QSize(50, 50))
|
||||||
|
self.pb_add_connector.setMaximumSize(QSize(50, 50))
|
||||||
|
|
||||||
|
self.gridLayout_10.addWidget(self.pb_add_connector, 0, 1, 1, 1)
|
||||||
|
|
||||||
|
self.pb_add_connector_2 = QPushButton(self.joint_tools)
|
||||||
|
self.pb_add_connector_2.setObjectName(u"pb_add_connector_2")
|
||||||
|
self.pb_add_connector_2.setMinimumSize(QSize(50, 50))
|
||||||
|
self.pb_add_connector_2.setMaximumSize(QSize(50, 50))
|
||||||
|
|
||||||
|
self.gridLayout_10.addWidget(self.pb_add_connector_2, 1, 1, 1, 1)
|
||||||
|
|
||||||
|
self.pb_add_connector_3 = QPushButton(self.joint_tools)
|
||||||
|
self.pb_add_connector_3.setObjectName(u"pb_add_connector_3")
|
||||||
|
self.pb_add_connector_3.setMinimumSize(QSize(50, 50))
|
||||||
|
self.pb_add_connector_3.setMaximumSize(QSize(50, 50))
|
||||||
|
|
||||||
|
self.gridLayout_10.addWidget(self.pb_add_connector_3, 1, 2, 1, 1)
|
||||||
|
|
||||||
|
|
||||||
|
self.gridLayout.addWidget(self.joint_tools, 12, 3, 2, 1)
|
||||||
|
|
||||||
|
self.gl_box = QGroupBox(self.centralwidget)
|
||||||
|
self.gl_box.setObjectName(u"gl_box")
|
||||||
|
sizePolicy1 = QSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
|
||||||
|
sizePolicy1.setHorizontalStretch(0)
|
||||||
|
sizePolicy1.setVerticalStretch(4)
|
||||||
|
sizePolicy1.setHeightForWidth(self.gl_box.sizePolicy().hasHeightForWidth())
|
||||||
|
self.gl_box.setSizePolicy(sizePolicy1)
|
||||||
|
font = QFont()
|
||||||
|
font.setPointSize(12)
|
||||||
|
self.gl_box.setFont(font)
|
||||||
|
self.horizontalLayout_4 = QHBoxLayout(self.gl_box)
|
||||||
|
#ifndef Q_OS_MAC
|
||||||
|
self.horizontalLayout_4.setSpacing(-1)
|
||||||
|
#endif
|
||||||
|
self.horizontalLayout_4.setObjectName(u"horizontalLayout_4")
|
||||||
|
self.horizontalLayout_4.setContentsMargins(12, -1, -1, -1)
|
||||||
|
|
||||||
|
self.gridLayout.addWidget(self.gl_box, 0, 2, 12, 1)
|
||||||
|
|
||||||
|
self.groupBox_11 = QGroupBox(self.centralwidget)
|
||||||
|
self.groupBox_11.setObjectName(u"groupBox_11")
|
||||||
|
sizePolicy2 = QSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Expanding)
|
||||||
|
sizePolicy2.setHorizontalStretch(0)
|
||||||
|
sizePolicy2.setVerticalStretch(0)
|
||||||
|
sizePolicy2.setHeightForWidth(self.groupBox_11.sizePolicy().hasHeightForWidth())
|
||||||
|
self.groupBox_11.setSizePolicy(sizePolicy2)
|
||||||
|
self.groupBox_11.setMaximumSize(QSize(200, 16777215))
|
||||||
|
self.verticalLayout_7 = QVBoxLayout(self.groupBox_11)
|
||||||
|
self.verticalLayout_7.setObjectName(u"verticalLayout_7")
|
||||||
|
self.verticalLayout_7.setContentsMargins(5, 5, 5, 5)
|
||||||
|
self.sketch_list = QListWidget(self.groupBox_11)
|
||||||
|
self.sketch_list.setObjectName(u"sketch_list")
|
||||||
|
sizePolicy3 = QSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
|
||||||
|
sizePolicy3.setHorizontalStretch(0)
|
||||||
|
sizePolicy3.setVerticalStretch(0)
|
||||||
|
sizePolicy3.setHeightForWidth(self.sketch_list.sizePolicy().hasHeightForWidth())
|
||||||
|
self.sketch_list.setSizePolicy(sizePolicy3)
|
||||||
|
self.sketch_list.setSelectionRectVisible(True)
|
||||||
|
|
||||||
|
self.verticalLayout_7.addWidget(self.sketch_list)
|
||||||
|
|
||||||
|
self.groupBox_6 = QGroupBox(self.groupBox_11)
|
||||||
|
self.groupBox_6.setObjectName(u"groupBox_6")
|
||||||
|
sizePolicy4 = QSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Preferred)
|
||||||
|
sizePolicy4.setHorizontalStretch(0)
|
||||||
|
sizePolicy4.setVerticalStretch(0)
|
||||||
|
sizePolicy4.setHeightForWidth(self.groupBox_6.sizePolicy().hasHeightForWidth())
|
||||||
|
self.groupBox_6.setSizePolicy(sizePolicy4)
|
||||||
|
self.gridLayout_6 = QGridLayout(self.groupBox_6)
|
||||||
|
self.gridLayout_6.setObjectName(u"gridLayout_6")
|
||||||
|
self.gridLayout_6.setContentsMargins(2, 2, 2, 2)
|
||||||
|
self.pb_edt_sktch = QPushButton(self.groupBox_6)
|
||||||
|
self.pb_edt_sktch.setObjectName(u"pb_edt_sktch")
|
||||||
|
|
||||||
|
self.gridLayout_6.addWidget(self.pb_edt_sktch, 1, 1, 1, 1)
|
||||||
|
|
||||||
|
self.pb_nw_sktch = QPushButton(self.groupBox_6)
|
||||||
|
self.pb_nw_sktch.setObjectName(u"pb_nw_sktch")
|
||||||
|
|
||||||
|
self.gridLayout_6.addWidget(self.pb_nw_sktch, 1, 0, 1, 1)
|
||||||
|
|
||||||
|
self.pb_del_sketch = QPushButton(self.groupBox_6)
|
||||||
|
self.pb_del_sketch.setObjectName(u"pb_del_sketch")
|
||||||
|
|
||||||
|
self.gridLayout_6.addWidget(self.pb_del_sketch, 1, 2, 1, 1)
|
||||||
|
|
||||||
|
|
||||||
|
self.verticalLayout_7.addWidget(self.groupBox_6)
|
||||||
|
|
||||||
|
|
||||||
|
self.gridLayout.addWidget(self.groupBox_11, 6, 0, 6, 1)
|
||||||
|
|
||||||
|
self.assembly_tools = QGroupBox(self.centralwidget)
|
||||||
|
self.assembly_tools.setObjectName(u"assembly_tools")
|
||||||
|
self.assembly_tools.setMinimumSize(QSize(0, 50))
|
||||||
|
self.gridLayout_12 = QGridLayout(self.assembly_tools)
|
||||||
|
self.gridLayout_12.setObjectName(u"gridLayout_12")
|
||||||
|
self.pb_compo_to_assembly = QPushButton(self.assembly_tools)
|
||||||
|
self.pb_compo_to_assembly.setObjectName(u"pb_compo_to_assembly")
|
||||||
|
self.pb_compo_to_assembly.setMinimumSize(QSize(50, 50))
|
||||||
|
self.pb_compo_to_assembly.setMaximumSize(QSize(50, 50))
|
||||||
|
|
||||||
|
self.gridLayout_12.addWidget(self.pb_compo_to_assembly, 0, 0, 1, 1)
|
||||||
|
|
||||||
|
self.pb_remove_compo_from_assembly = QPushButton(self.assembly_tools)
|
||||||
|
self.pb_remove_compo_from_assembly.setObjectName(u"pb_remove_compo_from_assembly")
|
||||||
|
self.pb_remove_compo_from_assembly.setEnabled(True)
|
||||||
|
sizePolicy4.setHeightForWidth(self.pb_remove_compo_from_assembly.sizePolicy().hasHeightForWidth())
|
||||||
|
self.pb_remove_compo_from_assembly.setSizePolicy(sizePolicy4)
|
||||||
|
self.pb_remove_compo_from_assembly.setMinimumSize(QSize(50, 50))
|
||||||
|
self.pb_remove_compo_from_assembly.setMaximumSize(QSize(50, 50))
|
||||||
|
self.pb_remove_compo_from_assembly.setLayoutDirection(Qt.LeftToRight)
|
||||||
|
|
||||||
|
self.gridLayout_12.addWidget(self.pb_remove_compo_from_assembly, 0, 1, 1, 1)
|
||||||
|
|
||||||
|
|
||||||
|
self.gridLayout.addWidget(self.assembly_tools, 13, 0, 1, 1)
|
||||||
|
|
||||||
|
self.compo_tool_box = QGroupBox(self.centralwidget)
|
||||||
|
self.compo_tool_box.setObjectName(u"compo_tool_box")
|
||||||
|
self.compo_tool_box.setMinimumSize(QSize(0, 50))
|
||||||
|
self.gridLayout_9 = QGridLayout(self.compo_tool_box)
|
||||||
|
self.gridLayout_9.setObjectName(u"gridLayout_9")
|
||||||
|
self.pb_new_compo = QPushButton(self.compo_tool_box)
|
||||||
|
self.pb_new_compo.setObjectName(u"pb_new_compo")
|
||||||
|
self.pb_new_compo.setMinimumSize(QSize(50, 50))
|
||||||
|
self.pb_new_compo.setMaximumSize(QSize(50, 50))
|
||||||
|
|
||||||
|
self.gridLayout_9.addWidget(self.pb_new_compo, 0, 0, 1, 1)
|
||||||
|
|
||||||
|
self.pb_del_compo = QPushButton(self.compo_tool_box)
|
||||||
|
self.pb_del_compo.setObjectName(u"pb_del_compo")
|
||||||
|
self.pb_del_compo.setEnabled(True)
|
||||||
|
sizePolicy4.setHeightForWidth(self.pb_del_compo.sizePolicy().hasHeightForWidth())
|
||||||
|
self.pb_del_compo.setSizePolicy(sizePolicy4)
|
||||||
|
self.pb_del_compo.setMinimumSize(QSize(50, 50))
|
||||||
|
self.pb_del_compo.setMaximumSize(QSize(50, 50))
|
||||||
|
self.pb_del_compo.setLayoutDirection(Qt.LeftToRight)
|
||||||
|
|
||||||
|
self.gridLayout_9.addWidget(self.pb_del_compo, 0, 1, 1, 1)
|
||||||
|
|
||||||
|
|
||||||
|
self.gridLayout.addWidget(self.compo_tool_box, 12, 0, 1, 1)
|
||||||
|
|
||||||
|
self.compo_box = QGroupBox(self.centralwidget)
|
||||||
|
self.compo_box.setObjectName(u"compo_box")
|
||||||
|
self.compo_box.setMinimumSize(QSize(0, 50))
|
||||||
|
|
||||||
|
self.gridLayout.addWidget(self.compo_box, 12, 1, 1, 2)
|
||||||
|
|
||||||
|
self.groupBox_12 = QGroupBox(self.centralwidget)
|
||||||
|
self.groupBox_12.setObjectName(u"groupBox_12")
|
||||||
|
sizePolicy2.setHeightForWidth(self.groupBox_12.sizePolicy().hasHeightForWidth())
|
||||||
|
self.groupBox_12.setSizePolicy(sizePolicy2)
|
||||||
|
self.groupBox_12.setMaximumSize(QSize(200, 16777215))
|
||||||
|
self.verticalLayout_8 = QVBoxLayout(self.groupBox_12)
|
||||||
|
self.verticalLayout_8.setObjectName(u"verticalLayout_8")
|
||||||
|
self.verticalLayout_8.setContentsMargins(5, 5, 5, 5)
|
||||||
|
self.connection_list = QListWidget(self.groupBox_12)
|
||||||
|
self.connection_list.setObjectName(u"connection_list")
|
||||||
|
self.connection_list.setSelectionRectVisible(True)
|
||||||
|
|
||||||
|
self.verticalLayout_8.addWidget(self.connection_list)
|
||||||
|
|
||||||
|
self.groupBox_13 = QGroupBox(self.groupBox_12)
|
||||||
|
self.groupBox_13.setObjectName(u"groupBox_13")
|
||||||
|
sizePolicy4.setHeightForWidth(self.groupBox_13.sizePolicy().hasHeightForWidth())
|
||||||
|
self.groupBox_13.setSizePolicy(sizePolicy4)
|
||||||
|
self.groupBox_13.setMaximumSize(QSize(200, 16777215))
|
||||||
|
self.gridLayout_13 = QGridLayout(self.groupBox_13)
|
||||||
|
self.gridLayout_13.setObjectName(u"gridLayout_13")
|
||||||
|
self.gridLayout_13.setContentsMargins(2, 2, 2, 2)
|
||||||
|
self.pb_del_connection = QPushButton(self.groupBox_13)
|
||||||
|
self.pb_del_connection.setObjectName(u"pb_del_connection")
|
||||||
|
|
||||||
|
self.gridLayout_13.addWidget(self.pb_del_connection, 0, 2, 1, 1)
|
||||||
|
|
||||||
|
self.pb_update_connection = QPushButton(self.groupBox_13)
|
||||||
|
self.pb_update_connection.setObjectName(u"pb_update_connection")
|
||||||
|
|
||||||
|
self.gridLayout_13.addWidget(self.pb_update_connection, 0, 0, 1, 1)
|
||||||
|
|
||||||
|
self.pb_edt_sktch_4 = QPushButton(self.groupBox_13)
|
||||||
|
self.pb_edt_sktch_4.setObjectName(u"pb_edt_sktch_4")
|
||||||
|
|
||||||
|
self.gridLayout_13.addWidget(self.pb_edt_sktch_4, 0, 1, 1, 1)
|
||||||
|
|
||||||
|
|
||||||
|
self.verticalLayout_8.addWidget(self.groupBox_13)
|
||||||
|
|
||||||
|
|
||||||
|
self.gridLayout.addWidget(self.groupBox_12, 6, 3, 6, 1)
|
||||||
|
|
||||||
|
self.groupBox_10 = QGroupBox(self.centralwidget)
|
||||||
|
self.groupBox_10.setObjectName(u"groupBox_10")
|
||||||
|
sizePolicy2.setHeightForWidth(self.groupBox_10.sizePolicy().hasHeightForWidth())
|
||||||
|
self.groupBox_10.setSizePolicy(sizePolicy2)
|
||||||
|
self.groupBox_10.setMaximumSize(QSize(200, 16777215))
|
||||||
|
self.verticalLayout_6 = QVBoxLayout(self.groupBox_10)
|
||||||
|
self.verticalLayout_6.setObjectName(u"verticalLayout_6")
|
||||||
|
self.verticalLayout_6.setContentsMargins(5, 5, 5, 5)
|
||||||
|
self.body_list = QListWidget(self.groupBox_10)
|
||||||
|
self.body_list.setObjectName(u"body_list")
|
||||||
|
self.body_list.setSelectionRectVisible(True)
|
||||||
|
|
||||||
|
self.verticalLayout_6.addWidget(self.body_list)
|
||||||
|
|
||||||
|
self.groupBox_8 = QGroupBox(self.groupBox_10)
|
||||||
|
self.groupBox_8.setObjectName(u"groupBox_8")
|
||||||
|
sizePolicy4.setHeightForWidth(self.groupBox_8.sizePolicy().hasHeightForWidth())
|
||||||
|
self.groupBox_8.setSizePolicy(sizePolicy4)
|
||||||
|
self.groupBox_8.setMaximumSize(QSize(200, 16777215))
|
||||||
|
self.gridLayout_8 = QGridLayout(self.groupBox_8)
|
||||||
|
self.gridLayout_8.setObjectName(u"gridLayout_8")
|
||||||
|
self.gridLayout_8.setContentsMargins(2, 2, 2, 2)
|
||||||
|
self.pb_del_body = QPushButton(self.groupBox_8)
|
||||||
|
self.pb_del_body.setObjectName(u"pb_del_body")
|
||||||
|
|
||||||
|
self.gridLayout_8.addWidget(self.pb_del_body, 0, 2, 1, 1)
|
||||||
|
|
||||||
|
self.pb_update_body = QPushButton(self.groupBox_8)
|
||||||
|
self.pb_update_body.setObjectName(u"pb_update_body")
|
||||||
|
|
||||||
|
self.gridLayout_8.addWidget(self.pb_update_body, 0, 0, 1, 1)
|
||||||
|
|
||||||
|
self.pb_edt_sktch_3 = QPushButton(self.groupBox_8)
|
||||||
|
self.pb_edt_sktch_3.setObjectName(u"pb_edt_sktch_3")
|
||||||
|
|
||||||
|
self.gridLayout_8.addWidget(self.pb_edt_sktch_3, 0, 1, 1, 1)
|
||||||
|
|
||||||
|
|
||||||
|
self.verticalLayout_6.addWidget(self.groupBox_8)
|
||||||
|
|
||||||
|
|
||||||
|
self.gridLayout.addWidget(self.groupBox_10, 3, 3, 3, 1)
|
||||||
|
|
||||||
|
self.groupBox_2 = QGroupBox(self.centralwidget)
|
||||||
|
self.groupBox_2.setObjectName(u"groupBox_2")
|
||||||
|
sizePolicy4.setHeightForWidth(self.groupBox_2.sizePolicy().hasHeightForWidth())
|
||||||
|
self.groupBox_2.setSizePolicy(sizePolicy4)
|
||||||
|
self.groupBox_2.setMaximumSize(QSize(200, 16777215))
|
||||||
|
self.gridLayout_2 = QGridLayout(self.groupBox_2)
|
||||||
|
self.gridLayout_2.setObjectName(u"gridLayout_2")
|
||||||
|
self.gridLayout_2.setContentsMargins(10, -1, -1, -1)
|
||||||
|
self.pb_arc_tool = QPushButton(self.groupBox_2)
|
||||||
|
self.pb_arc_tool.setObjectName(u"pb_arc_tool")
|
||||||
|
self.pb_arc_tool.setCheckable(True)
|
||||||
|
|
||||||
|
self.gridLayout_2.addWidget(self.pb_arc_tool, 2, 0, 1, 1)
|
||||||
|
|
||||||
|
self.pb_rectool = QPushButton(self.groupBox_2)
|
||||||
|
self.pb_rectool.setObjectName(u"pb_rectool")
|
||||||
|
self.pb_rectool.setCheckable(True)
|
||||||
|
self.pb_rectool.setAutoExclusive(False)
|
||||||
|
|
||||||
|
self.gridLayout_2.addWidget(self.pb_rectool, 0, 1, 1, 1)
|
||||||
|
|
||||||
|
self.pb_circtool = QPushButton(self.groupBox_2)
|
||||||
|
self.pb_circtool.setObjectName(u"pb_circtool")
|
||||||
|
self.pb_circtool.setCheckable(True)
|
||||||
|
self.pb_circtool.setAutoExclusive(False)
|
||||||
|
|
||||||
|
self.gridLayout_2.addWidget(self.pb_circtool, 1, 0, 1, 1, Qt.AlignTop)
|
||||||
|
|
||||||
|
self.pb_enable_construct = QPushButton(self.groupBox_2)
|
||||||
|
self.pb_enable_construct.setObjectName(u"pb_enable_construct")
|
||||||
|
self.pb_enable_construct.setCheckable(True)
|
||||||
|
|
||||||
|
self.gridLayout_2.addWidget(self.pb_enable_construct, 4, 0, 1, 1)
|
||||||
|
|
||||||
|
self.pb_enable_snap = QPushButton(self.groupBox_2)
|
||||||
|
self.pb_enable_snap.setObjectName(u"pb_enable_snap")
|
||||||
|
self.pb_enable_snap.setIconSize(QSize(13, 16))
|
||||||
|
self.pb_enable_snap.setCheckable(True)
|
||||||
|
self.pb_enable_snap.setChecked(True)
|
||||||
|
|
||||||
|
self.gridLayout_2.addWidget(self.pb_enable_snap, 4, 1, 1, 1)
|
||||||
|
|
||||||
|
self.pb_linetool = QPushButton(self.groupBox_2)
|
||||||
|
self.pb_linetool.setObjectName(u"pb_linetool")
|
||||||
|
self.pb_linetool.setCheckable(True)
|
||||||
|
self.pb_linetool.setAutoExclusive(False)
|
||||||
|
|
||||||
|
self.gridLayout_2.addWidget(self.pb_linetool, 0, 0, 1, 1)
|
||||||
|
|
||||||
|
self.pb_slotool = QPushButton(self.groupBox_2)
|
||||||
|
self.pb_slotool.setObjectName(u"pb_slotool")
|
||||||
|
self.pb_slotool.setCheckable(True)
|
||||||
|
self.pb_slotool.setAutoExclusive(False)
|
||||||
|
|
||||||
|
self.gridLayout_2.addWidget(self.pb_slotool, 1, 1, 1, 1, Qt.AlignTop)
|
||||||
|
|
||||||
|
self.line = QFrame(self.groupBox_2)
|
||||||
|
self.line.setObjectName(u"line")
|
||||||
|
self.line.setFrameShape(QFrame.Shape.HLine)
|
||||||
|
self.line.setFrameShadow(QFrame.Shadow.Sunken)
|
||||||
|
|
||||||
|
self.gridLayout_2.addWidget(self.line, 3, 0, 1, 2)
|
||||||
|
|
||||||
|
self.pb_offset_tool = QPushButton(self.groupBox_2)
|
||||||
|
self.pb_offset_tool.setObjectName(u"pb_offset_tool")
|
||||||
|
|
||||||
|
self.gridLayout_2.addWidget(self.pb_offset_tool, 2, 1, 1, 1)
|
||||||
|
|
||||||
|
|
||||||
|
self.gridLayout.addWidget(self.groupBox_2, 1, 0, 1, 1)
|
||||||
|
|
||||||
|
self.groupBox_3 = QGroupBox(self.centralwidget)
|
||||||
|
self.groupBox_3.setObjectName(u"groupBox_3")
|
||||||
|
sizePolicy4.setHeightForWidth(self.groupBox_3.sizePolicy().hasHeightForWidth())
|
||||||
|
self.groupBox_3.setSizePolicy(sizePolicy4)
|
||||||
|
self.groupBox_3.setMaximumSize(QSize(200, 16777213))
|
||||||
|
self.gridLayout_4 = QGridLayout(self.groupBox_3)
|
||||||
|
self.gridLayout_4.setObjectName(u"gridLayout_4")
|
||||||
|
self.pb_con_ptpt = QPushButton(self.groupBox_3)
|
||||||
|
self.pb_con_ptpt.setObjectName(u"pb_con_ptpt")
|
||||||
|
self.pb_con_ptpt.setCheckable(True)
|
||||||
|
self.pb_con_ptpt.setAutoExclusive(False)
|
||||||
|
|
||||||
|
self.gridLayout_4.addWidget(self.pb_con_ptpt, 1, 0, 1, 1)
|
||||||
|
|
||||||
|
self.pb_con_vert = QPushButton(self.groupBox_3)
|
||||||
|
self.pb_con_vert.setObjectName(u"pb_con_vert")
|
||||||
|
self.pb_con_vert.setCheckable(True)
|
||||||
|
self.pb_con_vert.setAutoExclusive(False)
|
||||||
|
|
||||||
|
self.gridLayout_4.addWidget(self.pb_con_vert, 3, 1, 1, 1)
|
||||||
|
|
||||||
|
self.pb_con_sym = QPushButton(self.groupBox_3)
|
||||||
|
self.pb_con_sym.setObjectName(u"pb_con_sym")
|
||||||
|
self.pb_con_sym.setCheckable(True)
|
||||||
|
self.pb_con_sym.setAutoExclusive(False)
|
||||||
|
|
||||||
|
self.gridLayout_4.addWidget(self.pb_con_sym, 4, 1, 1, 1)
|
||||||
|
|
||||||
|
self.pb_con_mid = QPushButton(self.groupBox_3)
|
||||||
|
self.pb_con_mid.setObjectName(u"pb_con_mid")
|
||||||
|
self.pb_con_mid.setCheckable(True)
|
||||||
|
self.pb_con_mid.setAutoExclusive(False)
|
||||||
|
|
||||||
|
self.gridLayout_4.addWidget(self.pb_con_mid, 2, 0, 1, 1)
|
||||||
|
|
||||||
|
self.pb_con_line = QPushButton(self.groupBox_3)
|
||||||
|
self.pb_con_line.setObjectName(u"pb_con_line")
|
||||||
|
self.pb_con_line.setCheckable(True)
|
||||||
|
self.pb_con_line.setAutoExclusive(False)
|
||||||
|
|
||||||
|
self.gridLayout_4.addWidget(self.pb_con_line, 1, 1, 1, 1)
|
||||||
|
|
||||||
|
self.pb_con_horiz = QPushButton(self.groupBox_3)
|
||||||
|
self.pb_con_horiz.setObjectName(u"pb_con_horiz")
|
||||||
|
self.pb_con_horiz.setCheckable(True)
|
||||||
|
self.pb_con_horiz.setAutoExclusive(False)
|
||||||
|
|
||||||
|
self.gridLayout_4.addWidget(self.pb_con_horiz, 3, 0, 1, 1)
|
||||||
|
|
||||||
|
self.pb_con_dist = QPushButton(self.groupBox_3)
|
||||||
|
self.pb_con_dist.setObjectName(u"pb_con_dist")
|
||||||
|
self.pb_con_dist.setCheckable(True)
|
||||||
|
self.pb_con_dist.setAutoExclusive(False)
|
||||||
|
self.pb_con_dist.setAutoRepeatDelay(297)
|
||||||
|
|
||||||
|
self.gridLayout_4.addWidget(self.pb_con_dist, 4, 0, 1, 1)
|
||||||
|
|
||||||
|
self.pb_con_perp = QPushButton(self.groupBox_3)
|
||||||
|
self.pb_con_perp.setObjectName(u"pb_con_perp")
|
||||||
|
self.pb_con_perp.setCheckable(True)
|
||||||
|
self.pb_con_perp.setAutoExclusive(False)
|
||||||
|
|
||||||
|
self.gridLayout_4.addWidget(self.pb_con_perp, 2, 1, 1, 1)
|
||||||
|
|
||||||
|
self.pb_con_diameter = QPushButton(self.groupBox_3)
|
||||||
|
self.pb_con_diameter.setObjectName(u"pb_con_diameter")
|
||||||
|
|
||||||
|
self.gridLayout_4.addWidget(self.pb_con_diameter, 5, 0, 1, 1)
|
||||||
|
|
||||||
|
|
||||||
|
self.gridLayout.addWidget(self.groupBox_3, 2, 0, 1, 1)
|
||||||
|
|
||||||
|
self.groupBox_5 = QGroupBox(self.centralwidget)
|
||||||
|
self.groupBox_5.setObjectName(u"groupBox_5")
|
||||||
|
sizePolicy4.setHeightForWidth(self.groupBox_5.sizePolicy().hasHeightForWidth())
|
||||||
|
self.groupBox_5.setSizePolicy(sizePolicy4)
|
||||||
|
self.gridLayout_11 = QGridLayout(self.groupBox_5)
|
||||||
|
self.gridLayout_11.setObjectName(u"gridLayout_11")
|
||||||
|
self.gridLayout_11.setContentsMargins(12, 12, 12, 12)
|
||||||
|
self.label = QLabel(self.groupBox_5)
|
||||||
|
self.label.setObjectName(u"label")
|
||||||
|
|
||||||
|
self.gridLayout_11.addWidget(self.label, 5, 0, 1, 1)
|
||||||
|
|
||||||
|
self.pb_snap_vert = QPushButton(self.groupBox_5)
|
||||||
|
self.pb_snap_vert.setObjectName(u"pb_snap_vert")
|
||||||
|
self.pb_snap_vert.setCheckable(True)
|
||||||
|
self.pb_snap_vert.setAutoExclusive(False)
|
||||||
|
|
||||||
|
self.gridLayout_11.addWidget(self.pb_snap_vert, 2, 1, 1, 1)
|
||||||
|
|
||||||
|
self.line_2 = QFrame(self.groupBox_5)
|
||||||
|
self.line_2.setObjectName(u"line_2")
|
||||||
|
self.line_2.setFrameShape(QFrame.Shape.HLine)
|
||||||
|
self.line_2.setFrameShadow(QFrame.Shadow.Sunken)
|
||||||
|
|
||||||
|
self.gridLayout_11.addWidget(self.line_2, 4, 0, 1, 2)
|
||||||
|
|
||||||
|
self.label_2 = QLabel(self.groupBox_5)
|
||||||
|
self.label_2.setObjectName(u"label_2")
|
||||||
|
|
||||||
|
self.gridLayout_11.addWidget(self.label_2, 5, 1, 1, 1)
|
||||||
|
|
||||||
|
self.spinbox_snap_distance = QSpinBox(self.groupBox_5)
|
||||||
|
self.spinbox_snap_distance.setObjectName(u"spinbox_snap_distance")
|
||||||
|
self.spinbox_snap_distance.setMaximum(30)
|
||||||
|
self.spinbox_snap_distance.setValue(10)
|
||||||
|
|
||||||
|
self.gridLayout_11.addWidget(self.spinbox_snap_distance, 6, 0, 1, 1)
|
||||||
|
|
||||||
|
self.pushButton_7 = QPushButton(self.groupBox_5)
|
||||||
|
self.pushButton_7.setObjectName(u"pushButton_7")
|
||||||
|
self.pushButton_7.setCheckable(True)
|
||||||
|
self.pushButton_7.setAutoExclusive(False)
|
||||||
|
|
||||||
|
self.gridLayout_11.addWidget(self.pushButton_7, 3, 0, 1, 1)
|
||||||
|
|
||||||
|
self.pb_snap_horiz = QPushButton(self.groupBox_5)
|
||||||
|
self.pb_snap_horiz.setObjectName(u"pb_snap_horiz")
|
||||||
|
self.pb_snap_horiz.setCheckable(True)
|
||||||
|
self.pb_snap_horiz.setAutoExclusive(False)
|
||||||
|
|
||||||
|
self.gridLayout_11.addWidget(self.pb_snap_horiz, 2, 0, 1, 1)
|
||||||
|
|
||||||
|
self.spinbox_angle_steps = QSpinBox(self.groupBox_5)
|
||||||
|
self.spinbox_angle_steps.setObjectName(u"spinbox_angle_steps")
|
||||||
|
self.spinbox_angle_steps.setMaximum(180)
|
||||||
|
self.spinbox_angle_steps.setValue(15)
|
||||||
|
|
||||||
|
self.gridLayout_11.addWidget(self.spinbox_angle_steps, 6, 1, 1, 1)
|
||||||
|
|
||||||
|
self.pushButton_8 = QPushButton(self.groupBox_5)
|
||||||
|
self.pushButton_8.setObjectName(u"pushButton_8")
|
||||||
|
self.pushButton_8.setCheckable(True)
|
||||||
|
self.pushButton_8.setAutoExclusive(False)
|
||||||
|
|
||||||
|
self.gridLayout_11.addWidget(self.pushButton_8, 0, 0, 1, 1)
|
||||||
|
|
||||||
|
self.pb_snap_midp = QPushButton(self.groupBox_5)
|
||||||
|
self.pb_snap_midp.setObjectName(u"pb_snap_midp")
|
||||||
|
self.pb_snap_midp.setCheckable(True)
|
||||||
|
self.pb_snap_midp.setAutoExclusive(False)
|
||||||
|
|
||||||
|
self.gridLayout_11.addWidget(self.pb_snap_midp, 0, 1, 1, 1)
|
||||||
|
|
||||||
|
self.pb_snap_angle = QPushButton(self.groupBox_5)
|
||||||
|
self.pb_snap_angle.setObjectName(u"pb_snap_angle")
|
||||||
|
self.pb_snap_angle.setCheckable(True)
|
||||||
|
self.pb_snap_angle.setAutoExclusive(False)
|
||||||
|
|
||||||
|
self.gridLayout_11.addWidget(self.pb_snap_angle, 3, 1, 1, 1)
|
||||||
|
|
||||||
|
|
||||||
|
self.gridLayout.addWidget(self.groupBox_5, 3, 0, 1, 1)
|
||||||
|
|
||||||
|
self.groupBox = QGroupBox(self.centralwidget)
|
||||||
|
self.groupBox.setObjectName(u"groupBox")
|
||||||
|
self.gridLayout_3 = QGridLayout(self.groupBox)
|
||||||
|
self.gridLayout_3.setObjectName(u"gridLayout_3")
|
||||||
|
self.pb_revop = QPushButton(self.groupBox)
|
||||||
|
self.pb_revop.setObjectName(u"pb_revop")
|
||||||
|
|
||||||
|
self.gridLayout_3.addWidget(self.pb_revop, 2, 1, 1, 1)
|
||||||
|
|
||||||
|
self.pb_extrdop = QPushButton(self.groupBox)
|
||||||
|
self.pb_extrdop.setObjectName(u"pb_extrdop")
|
||||||
|
|
||||||
|
self.gridLayout_3.addWidget(self.pb_extrdop, 0, 0, 1, 1)
|
||||||
|
|
||||||
|
self.pb_arrayop = QPushButton(self.groupBox)
|
||||||
|
self.pb_arrayop.setObjectName(u"pb_arrayop")
|
||||||
|
|
||||||
|
self.gridLayout_3.addWidget(self.pb_arrayop, 2, 0, 1, 1)
|
||||||
|
|
||||||
|
self.pb_cutop = QPushButton(self.groupBox)
|
||||||
|
self.pb_cutop.setObjectName(u"pb_cutop")
|
||||||
|
|
||||||
|
self.gridLayout_3.addWidget(self.pb_cutop, 0, 1, 1, 1)
|
||||||
|
|
||||||
|
self.pb_combop = QPushButton(self.groupBox)
|
||||||
|
self.pb_combop.setObjectName(u"pb_combop")
|
||||||
|
|
||||||
|
self.gridLayout_3.addWidget(self.pb_combop, 1, 0, 1, 1)
|
||||||
|
|
||||||
|
self.pb_moveop = QPushButton(self.groupBox)
|
||||||
|
self.pb_moveop.setObjectName(u"pb_moveop")
|
||||||
|
|
||||||
|
self.gridLayout_3.addWidget(self.pb_moveop, 1, 1, 1, 1)
|
||||||
|
|
||||||
|
|
||||||
|
self.gridLayout.addWidget(self.groupBox, 0, 3, 1, 1)
|
||||||
|
|
||||||
|
self.groupBox_4 = QGroupBox(self.centralwidget)
|
||||||
|
self.groupBox_4.setObjectName(u"groupBox_4")
|
||||||
|
self.verticalLayout_2 = QVBoxLayout(self.groupBox_4)
|
||||||
|
self.verticalLayout_2.setObjectName(u"verticalLayout_2")
|
||||||
|
self.pushButton_2 = QPushButton(self.groupBox_4)
|
||||||
|
self.pushButton_2.setObjectName(u"pushButton_2")
|
||||||
|
|
||||||
|
self.verticalLayout_2.addWidget(self.pushButton_2)
|
||||||
|
|
||||||
|
self.pb_export_step = QPushButton(self.groupBox_4)
|
||||||
|
self.pb_export_step.setObjectName(u"pb_export_step")
|
||||||
|
|
||||||
|
self.verticalLayout_2.addWidget(self.pb_export_step)
|
||||||
|
|
||||||
|
self.pb_export_iges = QPushButton(self.groupBox_4)
|
||||||
|
self.pb_export_iges.setObjectName(u"pb_export_iges")
|
||||||
|
|
||||||
|
self.verticalLayout_2.addWidget(self.pb_export_iges)
|
||||||
|
|
||||||
|
|
||||||
|
self.gridLayout.addWidget(self.groupBox_4, 2, 3, 1, 1)
|
||||||
|
|
||||||
|
fluencyCAD.setCentralWidget(self.centralwidget)
|
||||||
|
self.menubar = QMenuBar(fluencyCAD)
|
||||||
|
self.menubar.setObjectName(u"menubar")
|
||||||
|
self.menubar.setGeometry(QRect(0, 0, 2359, 24))
|
||||||
|
self.menuFile = QMenu(self.menubar)
|
||||||
|
self.menuFile.setObjectName(u"menuFile")
|
||||||
|
self.menuSettings = QMenu(self.menubar)
|
||||||
|
self.menuSettings.setObjectName(u"menuSettings")
|
||||||
|
fluencyCAD.setMenuBar(self.menubar)
|
||||||
|
self.statusbar = QStatusBar(fluencyCAD)
|
||||||
|
self.statusbar.setObjectName(u"statusbar")
|
||||||
|
fluencyCAD.setStatusBar(self.statusbar)
|
||||||
|
|
||||||
|
self.menubar.addAction(self.menuFile.menuAction())
|
||||||
|
self.menubar.addAction(self.menuSettings.menuAction())
|
||||||
|
self.menuFile.addAction(self.actionNew_Project)
|
||||||
|
self.menuFile.addAction(self.actionOpen_Project)
|
||||||
|
self.menuFile.addAction(self.actionSave_Project)
|
||||||
|
self.menuFile.addAction(self.actionSave_Project_As)
|
||||||
|
self.menuFile.addSeparator()
|
||||||
|
self.menuFile.addAction(self.actionImport_File)
|
||||||
|
self.menuFile.addSeparator()
|
||||||
|
self.menuFile.addAction(self.actionExport_Step)
|
||||||
|
self.menuFile.addAction(self.actionExport_Iges)
|
||||||
|
self.menuFile.addAction(self.actionExport_Stl)
|
||||||
|
self.menuFile.addSeparator()
|
||||||
|
self.menuFile.addAction(self.actionExit)
|
||||||
|
|
||||||
|
self.retranslateUi(fluencyCAD)
|
||||||
|
|
||||||
|
self.InputTab.setCurrentIndex(0)
|
||||||
|
|
||||||
|
|
||||||
|
QMetaObject.connectSlotsByName(fluencyCAD)
|
||||||
|
# setupUi
|
||||||
|
|
||||||
|
def retranslateUi(self, fluencyCAD):
|
||||||
|
fluencyCAD.setWindowTitle(QCoreApplication.translate("fluencyCAD", u"fluencyCAD", None))
|
||||||
|
self.actionNew_Project.setText(QCoreApplication.translate("fluencyCAD", u"New Project", None))
|
||||||
|
#if QT_CONFIG(shortcut)
|
||||||
|
self.actionNew_Project.setShortcut(QCoreApplication.translate("fluencyCAD", u"Ctrl+N", None))
|
||||||
|
#endif // QT_CONFIG(shortcut)
|
||||||
|
self.actionOpen_Project.setText(QCoreApplication.translate("fluencyCAD", u"Open Project...", None))
|
||||||
|
#if QT_CONFIG(shortcut)
|
||||||
|
self.actionOpen_Project.setShortcut(QCoreApplication.translate("fluencyCAD", u"Ctrl+O", None))
|
||||||
|
#endif // QT_CONFIG(shortcut)
|
||||||
|
self.actionSave_Project.setText(QCoreApplication.translate("fluencyCAD", u"Save Project", None))
|
||||||
|
#if QT_CONFIG(shortcut)
|
||||||
|
self.actionSave_Project.setShortcut(QCoreApplication.translate("fluencyCAD", u"Ctrl+S", None))
|
||||||
|
#endif // QT_CONFIG(shortcut)
|
||||||
|
self.actionSave_Project_As.setText(QCoreApplication.translate("fluencyCAD", u"Save Project As...", None))
|
||||||
|
#if QT_CONFIG(shortcut)
|
||||||
|
self.actionSave_Project_As.setShortcut(QCoreApplication.translate("fluencyCAD", u"Ctrl+Shift+S", None))
|
||||||
|
#endif // QT_CONFIG(shortcut)
|
||||||
|
self.actionImport_File.setText(QCoreApplication.translate("fluencyCAD", u"Import STEP/IGES...", None))
|
||||||
|
self.actionExport_Step.setText(QCoreApplication.translate("fluencyCAD", u"Export STEP...", None))
|
||||||
|
self.actionExport_Iges.setText(QCoreApplication.translate("fluencyCAD", u"Export IGES...", None))
|
||||||
|
self.actionExport_Stl.setText(QCoreApplication.translate("fluencyCAD", u"Export STL...", None))
|
||||||
|
self.actionExit.setText(QCoreApplication.translate("fluencyCAD", u"Exit", None))
|
||||||
|
#if QT_CONFIG(shortcut)
|
||||||
|
self.actionExit.setShortcut(QCoreApplication.translate("fluencyCAD", u"Ctrl+Q", None))
|
||||||
|
#endif // QT_CONFIG(shortcut)
|
||||||
|
self.InputTab.setTabText(self.InputTab.indexOf(self.sketch_tab), QCoreApplication.translate("fluencyCAD", u"Sketch", None))
|
||||||
|
self.groupBox_7.setTitle(QCoreApplication.translate("fluencyCAD", u"Executive", None))
|
||||||
|
self.pushButton_5.setText(QCoreApplication.translate("fluencyCAD", u"Load Code", None))
|
||||||
|
self.pushButton_4.setText(QCoreApplication.translate("fluencyCAD", u"Save code", None))
|
||||||
|
self.pb_apply_code.setText(QCoreApplication.translate("fluencyCAD", u"Apply Code", None))
|
||||||
|
self.pushButton.setText(QCoreApplication.translate("fluencyCAD", u"Delete Code", None))
|
||||||
|
self.InputTab.setTabText(self.InputTab.indexOf(self.code_tab), QCoreApplication.translate("fluencyCAD", u"Code", None))
|
||||||
|
self.groupBox_9.setTitle(QCoreApplication.translate("fluencyCAD", u"Workplanes", None))
|
||||||
|
#if QT_CONFIG(tooltip)
|
||||||
|
self.pb_origin_wp.setToolTip(QCoreApplication.translate("fluencyCAD", u"<W>orking Plane at 0, 0, 0", None))
|
||||||
|
#endif // QT_CONFIG(tooltip)
|
||||||
|
self.pb_origin_wp.setText(QCoreApplication.translate("fluencyCAD", u"WP Origin", None))
|
||||||
|
#if QT_CONFIG(shortcut)
|
||||||
|
self.pb_origin_wp.setShortcut(QCoreApplication.translate("fluencyCAD", u"W", None))
|
||||||
|
#endif // QT_CONFIG(shortcut)
|
||||||
|
#if QT_CONFIG(tooltip)
|
||||||
|
self.pb_origin_face.setToolTip(QCoreApplication.translate("fluencyCAD", u"Working Plane >P<rojection at selected edges face", None))
|
||||||
|
#endif // QT_CONFIG(tooltip)
|
||||||
|
self.pb_origin_face.setText(QCoreApplication.translate("fluencyCAD", u" WP Face", None))
|
||||||
|
#if QT_CONFIG(shortcut)
|
||||||
|
self.pb_origin_face.setShortcut(QCoreApplication.translate("fluencyCAD", u"P", None))
|
||||||
|
#endif // QT_CONFIG(shortcut)
|
||||||
|
#if QT_CONFIG(tooltip)
|
||||||
|
self.pb_flip_face.setToolTip(QCoreApplication.translate("fluencyCAD", u"Flip >N<ormal of projected mesh.", None))
|
||||||
|
#endif // QT_CONFIG(tooltip)
|
||||||
|
self.pb_flip_face.setText(QCoreApplication.translate("fluencyCAD", u"WP Flip", None))
|
||||||
|
#if QT_CONFIG(shortcut)
|
||||||
|
self.pb_flip_face.setShortcut(QCoreApplication.translate("fluencyCAD", u"N", None))
|
||||||
|
#endif // QT_CONFIG(shortcut)
|
||||||
|
#if QT_CONFIG(tooltip)
|
||||||
|
self.pb_underlay.setToolTip(QCoreApplication.translate("fluencyCAD", u"Show / hide the construction lines projected from the source face", None))
|
||||||
|
#endif // QT_CONFIG(tooltip)
|
||||||
|
self.pb_underlay.setText(QCoreApplication.translate("fluencyCAD", u"Underlay", None))
|
||||||
|
#if QT_CONFIG(tooltip)
|
||||||
|
self.pb_clr_face.setToolTip(QCoreApplication.translate("fluencyCAD", u"Forget the picked source face (keep the workplane)", None))
|
||||||
|
#endif // QT_CONFIG(tooltip)
|
||||||
|
self.pb_clr_face.setText(QCoreApplication.translate("fluencyCAD", u"ClrFace", None))
|
||||||
|
#if QT_CONFIG(tooltip)
|
||||||
|
self.pb_to_sketch.setToolTip(QCoreApplication.translate("fluencyCAD", u"Convert projected construction lines into real sketch geometry", None))
|
||||||
|
#endif // QT_CONFIG(tooltip)
|
||||||
|
self.pb_to_sketch.setText(QCoreApplication.translate("fluencyCAD", u"ToSketch", None))
|
||||||
|
#if QT_CONFIG(tooltip)
|
||||||
|
self.pb_wp_new.setToolTip(QCoreApplication.translate("fluencyCAD", u"Create a new independent workplane (datum plane)", None))
|
||||||
|
#endif // QT_CONFIG(tooltip)
|
||||||
|
self.pb_wp_new.setText(QCoreApplication.translate("fluencyCAD", u"WP New", None))
|
||||||
|
#if QT_CONFIG(shortcut)
|
||||||
|
self.pb_wp_new.setShortcut(QCoreApplication.translate("fluencyCAD", u"Shift+W", None))
|
||||||
|
#endif // QT_CONFIG(shortcut)
|
||||||
|
self.assembly_box.setTitle(QCoreApplication.translate("fluencyCAD", u"Assembly", None))
|
||||||
|
self.joint_tools.setTitle(QCoreApplication.translate("fluencyCAD", u"Joint Tools", None))
|
||||||
|
self.pb_remove_connector.setText(QCoreApplication.translate("fluencyCAD", u"- Cnct", None))
|
||||||
|
self.pb_add_connector.setText(QCoreApplication.translate("fluencyCAD", u"+ Cnct", None))
|
||||||
|
self.pb_add_connector_2.setText(QCoreApplication.translate("fluencyCAD", u"+Jnt", None))
|
||||||
|
self.pb_add_connector_3.setText(QCoreApplication.translate("fluencyCAD", u"-Jnt", None))
|
||||||
|
self.gl_box.setTitle(QCoreApplication.translate("fluencyCAD", u"Model Viewer", None))
|
||||||
|
self.groupBox_11.setTitle(QCoreApplication.translate("fluencyCAD", u"Sketch", None))
|
||||||
|
self.groupBox_6.setTitle(QCoreApplication.translate("fluencyCAD", u"Tools", None))
|
||||||
|
self.pb_edt_sktch.setText(QCoreApplication.translate("fluencyCAD", u"Edt", None))
|
||||||
|
self.pb_nw_sktch.setText(QCoreApplication.translate("fluencyCAD", u"Add", None))
|
||||||
|
self.pb_del_sketch.setText(QCoreApplication.translate("fluencyCAD", u"Del", None))
|
||||||
|
self.assembly_tools.setTitle(QCoreApplication.translate("fluencyCAD", u"Assembly Tools", None))
|
||||||
|
self.pb_compo_to_assembly.setText(QCoreApplication.translate("fluencyCAD", u"Add", None))
|
||||||
|
self.pb_remove_compo_from_assembly.setText(QCoreApplication.translate("fluencyCAD", u"Rem", None))
|
||||||
|
self.compo_tool_box.setTitle(QCoreApplication.translate("fluencyCAD", u"Component Tools", None))
|
||||||
|
self.pb_new_compo.setText(QCoreApplication.translate("fluencyCAD", u"New", None))
|
||||||
|
self.pb_del_compo.setText(QCoreApplication.translate("fluencyCAD", u"Del", None))
|
||||||
|
self.compo_box.setTitle(QCoreApplication.translate("fluencyCAD", u"Components", None))
|
||||||
|
self.groupBox_12.setTitle(QCoreApplication.translate("fluencyCAD", u"Component Connections", None))
|
||||||
|
self.groupBox_13.setTitle(QCoreApplication.translate("fluencyCAD", u"Tools", None))
|
||||||
|
self.pb_del_connection.setText(QCoreApplication.translate("fluencyCAD", u"Del", None))
|
||||||
|
self.pb_update_connection.setText(QCoreApplication.translate("fluencyCAD", u"Upd", None))
|
||||||
|
self.pb_edt_sktch_4.setText(QCoreApplication.translate("fluencyCAD", u"Nothing", None))
|
||||||
|
self.groupBox_10.setTitle(QCoreApplication.translate("fluencyCAD", u"Bodys / Operations", None))
|
||||||
|
self.groupBox_8.setTitle(QCoreApplication.translate("fluencyCAD", u"Tools", None))
|
||||||
|
self.pb_del_body.setText(QCoreApplication.translate("fluencyCAD", u"Del", None))
|
||||||
|
self.pb_update_body.setText(QCoreApplication.translate("fluencyCAD", u"Upd", None))
|
||||||
|
self.pb_edt_sktch_3.setText(QCoreApplication.translate("fluencyCAD", u"Nothing", None))
|
||||||
|
self.groupBox_2.setTitle(QCoreApplication.translate("fluencyCAD", u"Drawing", None))
|
||||||
|
self.pb_arc_tool.setText(QCoreApplication.translate("fluencyCAD", u"Arc", None))
|
||||||
|
self.pb_rectool.setText(QCoreApplication.translate("fluencyCAD", u"Rctgl", None))
|
||||||
|
self.pb_circtool.setText(QCoreApplication.translate("fluencyCAD", u"Circle", None))
|
||||||
|
self.pb_enable_construct.setText(QCoreApplication.translate("fluencyCAD", u"Cstrct", None))
|
||||||
|
self.pb_enable_snap.setText(QCoreApplication.translate("fluencyCAD", u"Snap", None))
|
||||||
|
self.pb_linetool.setText(QCoreApplication.translate("fluencyCAD", u"Line", None))
|
||||||
|
#if QT_CONFIG(shortcut)
|
||||||
|
self.pb_linetool.setShortcut(QCoreApplication.translate("fluencyCAD", u"S", None))
|
||||||
|
#endif // QT_CONFIG(shortcut)
|
||||||
|
self.pb_slotool.setText(QCoreApplication.translate("fluencyCAD", u"Slot", None))
|
||||||
|
#if QT_CONFIG(tooltip)
|
||||||
|
self.pb_offset_tool.setToolTip(QCoreApplication.translate("fluencyCAD", u"Offset selected sketch face (duplicate + offset boundary)", None))
|
||||||
|
#endif // QT_CONFIG(tooltip)
|
||||||
|
self.pb_offset_tool.setText(QCoreApplication.translate("fluencyCAD", u"Offst", None))
|
||||||
|
self.groupBox_3.setTitle(QCoreApplication.translate("fluencyCAD", u"Constrain", None))
|
||||||
|
#if QT_CONFIG(tooltip)
|
||||||
|
self.pb_con_ptpt.setToolTip(QCoreApplication.translate("fluencyCAD", u"Poin to Point Constrain", None))
|
||||||
|
#endif // QT_CONFIG(tooltip)
|
||||||
|
self.pb_con_ptpt.setText(QCoreApplication.translate("fluencyCAD", u"Pt_Pt", None))
|
||||||
|
#if QT_CONFIG(tooltip)
|
||||||
|
self.pb_con_vert.setToolTip(QCoreApplication.translate("fluencyCAD", u"Vertical Constrain", None))
|
||||||
|
#endif // QT_CONFIG(tooltip)
|
||||||
|
self.pb_con_vert.setText(QCoreApplication.translate("fluencyCAD", u"Vert", None))
|
||||||
|
self.pb_con_sym.setText(QCoreApplication.translate("fluencyCAD", u"Symetrc", None))
|
||||||
|
#if QT_CONFIG(tooltip)
|
||||||
|
self.pb_con_mid.setToolTip(QCoreApplication.translate("fluencyCAD", u"Point to Middle Point Constrain", None))
|
||||||
|
#endif // QT_CONFIG(tooltip)
|
||||||
|
self.pb_con_mid.setText(QCoreApplication.translate("fluencyCAD", u"Pt_Mid_L", None))
|
||||||
|
#if QT_CONFIG(tooltip)
|
||||||
|
self.pb_con_line.setToolTip(QCoreApplication.translate("fluencyCAD", u"Point to Line Constrain", None))
|
||||||
|
#endif // QT_CONFIG(tooltip)
|
||||||
|
self.pb_con_line.setText(QCoreApplication.translate("fluencyCAD", u"Pt_Lne", None))
|
||||||
|
#if QT_CONFIG(tooltip)
|
||||||
|
self.pb_con_horiz.setToolTip(QCoreApplication.translate("fluencyCAD", u"Horizontal Constrain ", None))
|
||||||
|
#endif // QT_CONFIG(tooltip)
|
||||||
|
self.pb_con_horiz.setText(QCoreApplication.translate("fluencyCAD", u"Horiz", None))
|
||||||
|
#if QT_CONFIG(tooltip)
|
||||||
|
self.pb_con_dist.setToolTip(QCoreApplication.translate("fluencyCAD", u"Dimension of Line of Distance from Point to Line", None))
|
||||||
|
#endif // QT_CONFIG(tooltip)
|
||||||
|
self.pb_con_dist.setText(QCoreApplication.translate("fluencyCAD", u"Distnce", None))
|
||||||
|
#if QT_CONFIG(tooltip)
|
||||||
|
self.pb_con_perp.setToolTip(QCoreApplication.translate("fluencyCAD", u"Constrain Line perpendicular to another line.", None))
|
||||||
|
#endif // QT_CONFIG(tooltip)
|
||||||
|
self.pb_con_perp.setText(QCoreApplication.translate("fluencyCAD", u"Perp_Lne", None))
|
||||||
|
self.pb_con_diameter.setText(QCoreApplication.translate("fluencyCAD", u"Diameter", None))
|
||||||
|
self.groupBox_5.setTitle(QCoreApplication.translate("fluencyCAD", u"Snapping Points", None))
|
||||||
|
self.label.setText(QCoreApplication.translate("fluencyCAD", u"Snp Dst", None))
|
||||||
|
self.pb_snap_vert.setText(QCoreApplication.translate("fluencyCAD", u"Vert", None))
|
||||||
|
self.label_2.setText(QCoreApplication.translate("fluencyCAD", u"Angl Stps", None))
|
||||||
|
self.spinbox_snap_distance.setSuffix(QCoreApplication.translate("fluencyCAD", u"mm", None))
|
||||||
|
self.pushButton_7.setText(QCoreApplication.translate("fluencyCAD", u"Grid", None))
|
||||||
|
self.pb_snap_horiz.setText(QCoreApplication.translate("fluencyCAD", u"Horiz", None))
|
||||||
|
self.spinbox_angle_steps.setSuffix(QCoreApplication.translate("fluencyCAD", u"\u00b0", None))
|
||||||
|
self.pushButton_8.setText(QCoreApplication.translate("fluencyCAD", u"Pnt", None))
|
||||||
|
self.pb_snap_midp.setText(QCoreApplication.translate("fluencyCAD", u"MidP", None))
|
||||||
|
self.pb_snap_angle.setText(QCoreApplication.translate("fluencyCAD", u"Angles", None))
|
||||||
|
self.groupBox.setTitle(QCoreApplication.translate("fluencyCAD", u"Modify", None))
|
||||||
|
self.pb_revop.setText(QCoreApplication.translate("fluencyCAD", u"Rev", None))
|
||||||
|
self.pb_extrdop.setText(QCoreApplication.translate("fluencyCAD", u"Extrd", None))
|
||||||
|
self.pb_arrayop.setText(QCoreApplication.translate("fluencyCAD", u"Arry", None))
|
||||||
|
self.pb_cutop.setText(QCoreApplication.translate("fluencyCAD", u"Cut", None))
|
||||||
|
self.pb_combop.setText(QCoreApplication.translate("fluencyCAD", u"Comb", None))
|
||||||
|
self.pb_moveop.setText(QCoreApplication.translate("fluencyCAD", u"Mve", None))
|
||||||
|
self.groupBox_4.setTitle(QCoreApplication.translate("fluencyCAD", u"Export", None))
|
||||||
|
self.pushButton_2.setText(QCoreApplication.translate("fluencyCAD", u"STL", None))
|
||||||
|
self.pb_export_step.setText(QCoreApplication.translate("fluencyCAD", u"STEP", None))
|
||||||
|
self.pb_export_iges.setText(QCoreApplication.translate("fluencyCAD", u"IGES", None))
|
||||||
|
self.menuFile.setTitle(QCoreApplication.translate("fluencyCAD", u"File", None))
|
||||||
|
self.menuSettings.setTitle(QCoreApplication.translate("fluencyCAD", u"Settings", None))
|
||||||
|
# retranslateUi
|
||||||
|
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,918 @@
|
|||||||
|
"""3D viewer widget — wraps OCC's AIS/V3d native display for use inside Qt."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
|
from PySide6.QtCore import Qt, Signal
|
||||||
|
from PySide6.QtWidgets import QWidget
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class Viewer3DWidget(QWidget):
|
||||||
|
"""3D viewer widget using OCC's native AIS display."""
|
||||||
|
|
||||||
|
# Emitted when the user picks a planar face to sketch on.
|
||||||
|
# Payload: (origin, normal, x_dir, face_shape) — all tuples are (x,y,z).
|
||||||
|
facePicked = Signal(tuple, tuple, tuple, object)
|
||||||
|
# Emitted when face-pick mode is cancelled (Esc) so the host can uncheck.
|
||||||
|
pickFaceCancelled = Signal()
|
||||||
|
|
||||||
|
# Emitted when the user picks an entity for a connector point (assembly).
|
||||||
|
# Payload: (origin, normal, x_dir, entity_type, face_or_edge_or_vertex, owner_obj_id).
|
||||||
|
connectorPicked = Signal(tuple, tuple, tuple, str, object, str)
|
||||||
|
# Emitted when connector pick mode is cancelled.
|
||||||
|
connectorPickCancelled = Signal()
|
||||||
|
# Emitted on mouse move in connector mode to show snap preview.
|
||||||
|
# Payload: (origin, normal, entity_type, owner_obj_id) or None if nothing.
|
||||||
|
connectorHover = Signal(object)
|
||||||
|
|
||||||
|
# Emitted when a body is clicked in assembly move mode.
|
||||||
|
# Payload: owner_obj_id.
|
||||||
|
assemblyComponentActivated = Signal(str)
|
||||||
|
# Emitted during a drag move: owner_obj_id, world dx, dy, dz.
|
||||||
|
assemblyComponentDragged = Signal(str, float, float, float)
|
||||||
|
# Emitted when a drag move finishes.
|
||||||
|
assemblyMoveFinished = Signal(str)
|
||||||
|
|
||||||
|
# Emitted whenever the camera changes (orbit, pan, zoom).
|
||||||
|
# Payload: (eye, at, up) — each is a tuple of 3 floats.
|
||||||
|
cameraChanged = Signal(tuple, tuple, tuple)
|
||||||
|
|
||||||
|
def __init__(self, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
# For OCC's direct OpenGL rendering we need Qt to not paint over it.
|
||||||
|
self.setAttribute(Qt.WA_PaintOnScreen)
|
||||||
|
self.setAttribute(Qt.WA_OpaquePaintEvent)
|
||||||
|
self.setAutoFillBackground(False)
|
||||||
|
# Accept keyboard focus so navigation shortcuts (F, R, 1-7, P, O) work.
|
||||||
|
self.setFocusPolicy(Qt.StrongFocus)
|
||||||
|
# Enable mouse tracking so ``mouseMoveEvent`` fires even without a
|
||||||
|
# button held — required for the connector-pick hover gizmo (and any
|
||||||
|
# status-bar hover feedback) to show under the cursor as the user
|
||||||
|
# moves the mouse over candidate snap entities before clicking.
|
||||||
|
self.setMouseTracking(True)
|
||||||
|
# Try OCC renderer first; fall back to pygfx if unavailable.
|
||||||
|
self._renderer: Any = None
|
||||||
|
self._initialized = False
|
||||||
|
self._meshes: Dict[str, Any] = {}
|
||||||
|
self._selected_normal: Optional[Tuple[float, float, float]] = None
|
||||||
|
self._centroid: Optional[Tuple[float, float, float]] = None
|
||||||
|
self._pending_meshes: List[Tuple] = []
|
||||||
|
# When True, a left-click picks a planar face (for sketch-on-surface)
|
||||||
|
# instead of orbiting the camera. Set via set_pick_face_mode().
|
||||||
|
self._pick_face_mode: bool = False
|
||||||
|
# When True, a left-click picks an entity for a connector point
|
||||||
|
# (assembly component connection).
|
||||||
|
self._connector_pick_mode: bool = False
|
||||||
|
# Current snap highlight object id (for hover during connector mode).
|
||||||
|
self._connector_snap_id: Optional[str] = None
|
||||||
|
# Throttle connector hover probes to avoid UI lag on fast mouse moves.
|
||||||
|
self._connector_last_hover_time: float = 0.0
|
||||||
|
self._connector_hover_interval: float = 0.05 # 50 ms between probes
|
||||||
|
# When True, left-click on a body activates assembly drag-to-move.
|
||||||
|
self._assembly_move_mode: bool = False
|
||||||
|
# State for ongoing assembly drag.
|
||||||
|
self._move_drag_active: bool = False
|
||||||
|
self._move_owner_obj_id: str = ""
|
||||||
|
self._move_click_3d: Optional[Tuple[float, float, float]] = None
|
||||||
|
self._move_click_screen: Optional[Any] = None
|
||||||
|
self._move_plane_normal: Optional[Tuple[float, float, float]] = None
|
||||||
|
self._move_initial_position: Optional[Tuple[float, float, float]] = None
|
||||||
|
# Most recently recorded owning obj_id for the face returned by
|
||||||
|
# ``pick_planar_face``. Stashed on each pick pass so the host can
|
||||||
|
# pair the picked face with the body it belongs to (used to auto-
|
||||||
|
# target a cut/union extrude against the body the sketch was
|
||||||
|
# projected onto).
|
||||||
|
self._last_pick_owner_obj_id: Optional[str] = None
|
||||||
|
|
||||||
|
def _init_renderer(self) -> None:
|
||||||
|
"""Create the best available renderer."""
|
||||||
|
if self._renderer is not None:
|
||||||
|
return
|
||||||
|
import sys as _sys
|
||||||
|
|
||||||
|
_sys.stdout.flush()
|
||||||
|
logger.info("Renderer: starting import...")
|
||||||
|
from fluency.rendering.occ_renderer import OCCRenderer
|
||||||
|
from fluency.rendering.pygfx_renderer import PygfxRenderer
|
||||||
|
|
||||||
|
logger.info("Renderer: imports done, creating OCCRenderer...")
|
||||||
|
occ = OCCRenderer()
|
||||||
|
logger.info("Renderer: calling occ.initialize...")
|
||||||
|
try:
|
||||||
|
ok = occ.initialize(self)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(f"OCCRenderer init raised: {exc}")
|
||||||
|
ok = False
|
||||||
|
logger.info(f"Renderer: OCC result={ok}")
|
||||||
|
if ok:
|
||||||
|
self._renderer = occ
|
||||||
|
logger.info("Using OCCRenderer (native BRep display)")
|
||||||
|
else:
|
||||||
|
logger.info("Falling back to PygfxRenderer")
|
||||||
|
self._renderer = PygfxRenderer()
|
||||||
|
logger.info("Renderer: calling pygfx initialize...")
|
||||||
|
self._renderer.initialize(self)
|
||||||
|
logger.info("Renderer: pygfx init done")
|
||||||
|
self._initialized = True
|
||||||
|
logger.info("Renderer: initialization complete")
|
||||||
|
|
||||||
|
def showEvent(self, event):
|
||||||
|
logger.info("Viewer3DWidget showEvent - initializing renderer")
|
||||||
|
if not self._initialized:
|
||||||
|
self._init_renderer()
|
||||||
|
logger.info(f"Renderer initialized, pending meshes: {len(self._pending_meshes)}")
|
||||||
|
for args in self._pending_meshes:
|
||||||
|
self.add_mesh(*args)
|
||||||
|
self._pending_meshes.clear()
|
||||||
|
self._renderer.render()
|
||||||
|
|
||||||
|
def _ensure_initialized(self):
|
||||||
|
if not self._initialized:
|
||||||
|
logger.debug("Ensuring renderer is initialized")
|
||||||
|
self._init_renderer()
|
||||||
|
|
||||||
|
def get_renderer(self):
|
||||||
|
self._ensure_initialized()
|
||||||
|
return self._renderer
|
||||||
|
|
||||||
|
def show_shape(self, shape: Any, color=None, name=None) -> str:
|
||||||
|
"""Display an OCC TopoDS_Shape.
|
||||||
|
|
||||||
|
Uses OCCRenderer.add_shape for native AIS display, or falls back to
|
||||||
|
triangulation + add_mesh for the PygfxRenderer.
|
||||||
|
"""
|
||||||
|
self._ensure_initialized()
|
||||||
|
from fluency.rendering.occ_renderer import OCCRenderer
|
||||||
|
|
||||||
|
if isinstance(self._renderer, OCCRenderer):
|
||||||
|
oid = self._renderer.add_shape(shape, color, name)
|
||||||
|
self._renderer.render()
|
||||||
|
return oid
|
||||||
|
# Fallback: tessellate and use the mesh pipeline.
|
||||||
|
from fluency.geometry_occ.kernel import OCGeometryKernel
|
||||||
|
|
||||||
|
k = OCGeometryKernel()
|
||||||
|
# Build a temporary OCCGeometryObject to use the kernel's mesh helpers.
|
||||||
|
from fluency.geometry_occ.kernel import OCCGeometryObject
|
||||||
|
|
||||||
|
obj = OCCGeometryObject(shape)
|
||||||
|
verts, faces = k.get_mesh(obj)
|
||||||
|
oid = self._renderer.add_mesh(verts, faces, color, name)
|
||||||
|
# Edges
|
||||||
|
try:
|
||||||
|
e_verts, e_edges = k.get_edges(obj)
|
||||||
|
if len(e_verts) > 0:
|
||||||
|
self._renderer.add_wireframe(
|
||||||
|
e_verts, e_edges, (0.9, 0.9, 0.9), line_width=1.5, name=f"{name}_edges"
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self._renderer.render()
|
||||||
|
return oid
|
||||||
|
|
||||||
|
def add_mesh(self, vertices, faces, color=None, name=None) -> str:
|
||||||
|
logger.debug(
|
||||||
|
f"add_mesh called: initialized={self._initialized}, vertices={len(vertices)}, faces={len(faces)}, name={name}"
|
||||||
|
)
|
||||||
|
if not self._initialized:
|
||||||
|
self._pending_meshes.append((vertices, faces, color, name))
|
||||||
|
logger.info(f"Queued pending mesh, total pending: {len(self._pending_meshes)}")
|
||||||
|
return f"pending_{len(self._pending_meshes)}"
|
||||||
|
|
||||||
|
self._ensure_initialized()
|
||||||
|
mesh_id = self._renderer.add_mesh(vertices, faces, color, name)
|
||||||
|
self._meshes[mesh_id] = {"vertices": vertices, "faces": faces, "name": name}
|
||||||
|
self._renderer.render()
|
||||||
|
logger.info(f"Added mesh: {mesh_id}, name={name}")
|
||||||
|
return mesh_id
|
||||||
|
|
||||||
|
def update_mesh(self, mesh_id: str, vertices, faces):
|
||||||
|
self._ensure_initialized()
|
||||||
|
self._renderer.update_mesh(mesh_id, vertices, faces)
|
||||||
|
self._meshes[mesh_id] = {"vertices": vertices, "faces": faces}
|
||||||
|
self._renderer.render()
|
||||||
|
|
||||||
|
def add_wireframe(self, vertices, edges, color=None, line_width=1.0, name=None) -> str:
|
||||||
|
self._ensure_initialized()
|
||||||
|
wid = self._renderer.add_wireframe(
|
||||||
|
vertices, edges, color or (0.9, 0.9, 0.9), line_width, name
|
||||||
|
)
|
||||||
|
self._renderer.render()
|
||||||
|
return wid
|
||||||
|
|
||||||
|
def remove_mesh(self, mesh_id: str):
|
||||||
|
self._ensure_initialized()
|
||||||
|
self._renderer.remove_mesh(mesh_id)
|
||||||
|
if mesh_id in self._meshes:
|
||||||
|
del self._meshes[mesh_id]
|
||||||
|
self._renderer.render()
|
||||||
|
|
||||||
|
def set_visibility(self, mesh_id: str, visible: bool) -> bool:
|
||||||
|
"""Show or hide a previously-added mesh without removing it.
|
||||||
|
|
||||||
|
Used by the per-body visibility toggle on the body list so the
|
||||||
|
user can quickly hide a body (e.g. to verify a cut worked on
|
||||||
|
another body). Returns True on success, False if the mesh is
|
||||||
|
unknown to the renderer or the renderer doesn't support it
|
||||||
|
(e.g. the Pygfx fallback ABI).
|
||||||
|
"""
|
||||||
|
self._ensure_initialized()
|
||||||
|
fn = getattr(self._renderer, "set_visibility", None)
|
||||||
|
if fn is None:
|
||||||
|
return False
|
||||||
|
ok = fn(mesh_id, visible)
|
||||||
|
if ok:
|
||||||
|
self._renderer.render()
|
||||||
|
return ok
|
||||||
|
|
||||||
|
def set_transparency(self, mesh_id: str, transparency: float) -> bool:
|
||||||
|
"""Set a previously-added mesh's transparency (0..1).
|
||||||
|
|
||||||
|
Used by the live extrude preview to dim the target body so the
|
||||||
|
previewed result reads on top of it.
|
||||||
|
"""
|
||||||
|
self._ensure_initialized()
|
||||||
|
fn = getattr(self._renderer, "set_object_transparency", None)
|
||||||
|
if fn is None:
|
||||||
|
return False
|
||||||
|
return fn(mesh_id, transparency)
|
||||||
|
|
||||||
|
def show_preview(self, shape: Any, color=None, transparency: float = 0.60) -> None:
|
||||||
|
"""Display a temporary transparent preview of *shape* in the 3D view.
|
||||||
|
|
||||||
|
Used by the ExtrudeDialog live preview: as the user drags the
|
||||||
|
length spinner or toggles Cut/Through-All, the host recomputes
|
||||||
|
the operation result and shows it here. Call clear_preview()
|
||||||
|
when the dialog closes.
|
||||||
|
"""
|
||||||
|
self._ensure_initialized()
|
||||||
|
fn = getattr(self._renderer, "preview_shape", None)
|
||||||
|
if fn is None:
|
||||||
|
return
|
||||||
|
fn(shape, color, transparency)
|
||||||
|
|
||||||
|
def clear_preview(self) -> None:
|
||||||
|
"""Remove the live extrude preview shape, if any."""
|
||||||
|
if not self._initialized or self._renderer is None:
|
||||||
|
return
|
||||||
|
fn = getattr(self._renderer, "clear_preview", None)
|
||||||
|
if fn is None:
|
||||||
|
return
|
||||||
|
fn()
|
||||||
|
|
||||||
|
def clear_scene(self):
|
||||||
|
self._ensure_initialized()
|
||||||
|
self._renderer.clear_scene()
|
||||||
|
self._meshes.clear()
|
||||||
|
self._renderer.render()
|
||||||
|
|
||||||
|
def fit_camera(self):
|
||||||
|
self._ensure_initialized()
|
||||||
|
self._renderer.fit_camera()
|
||||||
|
self._renderer.render()
|
||||||
|
|
||||||
|
# ─── Workplane visualization ───────────────────────────────────────────
|
||||||
|
|
||||||
|
def show_workplane(
|
||||||
|
self,
|
||||||
|
origin: Tuple[float, float, float] = (0, 0, 0),
|
||||||
|
normal: Tuple[float, float, float] = (0, 0, 1),
|
||||||
|
x_dir: Tuple[float, float, float] = (1, 0, 0),
|
||||||
|
size: float = 200.0,
|
||||||
|
name: Optional[str] = None,
|
||||||
|
) -> Optional[str]:
|
||||||
|
"""Display a semi-transparent workplane plane in the 3D view.
|
||||||
|
|
||||||
|
Returns the object ID (for later removal) or None if the renderer
|
||||||
|
doesn't support workplane planes.
|
||||||
|
"""
|
||||||
|
self._ensure_initialized()
|
||||||
|
fn = getattr(self._renderer, "show_workplane_plane", None)
|
||||||
|
if fn is None:
|
||||||
|
return None
|
||||||
|
oid = fn(origin, normal, x_dir, size, name)
|
||||||
|
self._renderer.render()
|
||||||
|
return oid
|
||||||
|
|
||||||
|
def remove_workplane(self, obj_id: str) -> bool:
|
||||||
|
"""Remove a workplane plane visual by its ID."""
|
||||||
|
self._ensure_initialized()
|
||||||
|
fn = getattr(self._renderer, "remove_workplane_plane", None)
|
||||||
|
if fn is None:
|
||||||
|
return False
|
||||||
|
ok = fn(obj_id)
|
||||||
|
if ok:
|
||||||
|
self._renderer.render()
|
||||||
|
return ok
|
||||||
|
|
||||||
|
def mousePressEvent(self, event):
|
||||||
|
self._ensure_initialized()
|
||||||
|
# Face-pick mode: a left-click selects a planar face to sketch on.
|
||||||
|
if self._pick_face_mode and event.button() == Qt.LeftButton:
|
||||||
|
self._handle_face_pick(event)
|
||||||
|
return
|
||||||
|
# Connector pick mode: a left-click selects a face for a connection point.
|
||||||
|
if self._connector_pick_mode and event.button() == Qt.LeftButton:
|
||||||
|
self._handle_connector_pick(event)
|
||||||
|
return
|
||||||
|
# Assembly move mode: start dragging the clicked body.
|
||||||
|
if self._assembly_move_mode and event.button() == Qt.LeftButton:
|
||||||
|
self._handle_assembly_move_press(event)
|
||||||
|
return
|
||||||
|
self._renderer.handle_mouse_press(event)
|
||||||
|
super().mousePressEvent(event)
|
||||||
|
|
||||||
|
def mouseMoveEvent(self, event):
|
||||||
|
self._ensure_initialized()
|
||||||
|
# In connector mode, show snap hover.
|
||||||
|
# Selection modes are deactivated so we skip the idle MoveTo
|
||||||
|
# (dynamic highlighting) — only the gizmo hover handler runs.
|
||||||
|
if self._connector_pick_mode:
|
||||||
|
self._handle_connector_hover(event)
|
||||||
|
super().mouseMoveEvent(event)
|
||||||
|
return
|
||||||
|
# If connector mode was just exited (gizmo persists after pick),
|
||||||
|
# clear any lingering gizmo on first mouse move.
|
||||||
|
gizmo_objs = getattr(self._renderer, "_gizmo_objects", None)
|
||||||
|
if self._connector_snap_id is not None or (gizmo_objs and len(gizmo_objs) > 0):
|
||||||
|
self._clear_connector_snap()
|
||||||
|
# In face-pick mode, keep dynamic highlighting.
|
||||||
|
if self._pick_face_mode:
|
||||||
|
if hasattr(self._renderer, "handle_mouse_move"):
|
||||||
|
self._renderer.handle_mouse_move(event)
|
||||||
|
super().mouseMoveEvent(event)
|
||||||
|
return
|
||||||
|
# Active drag in assembly move mode.
|
||||||
|
if self._move_drag_active:
|
||||||
|
self._handle_assembly_move_move(event)
|
||||||
|
super().mouseMoveEvent(event)
|
||||||
|
return
|
||||||
|
self._renderer.handle_mouse_move(event)
|
||||||
|
super().mouseMoveEvent(event)
|
||||||
|
|
||||||
|
def paintEngine(self):
|
||||||
|
"""Return None to prevent Qt from painting over OCC's direct OpenGL."""
|
||||||
|
return None
|
||||||
|
|
||||||
|
def paintEvent(self, event):
|
||||||
|
"""Empty paintEvent — OCC draws directly via OpenGL."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
def mouseReleaseEvent(self, event):
|
||||||
|
self._ensure_initialized()
|
||||||
|
# Finish assembly drag.
|
||||||
|
if self._move_drag_active:
|
||||||
|
self._handle_assembly_move_release(event)
|
||||||
|
return
|
||||||
|
self._renderer.handle_mouse_release(event)
|
||||||
|
super().mouseReleaseEvent(event)
|
||||||
|
# Camera may have changed during orbit/pan — emit signal.
|
||||||
|
try:
|
||||||
|
eye, at_, up = self.get_camera_position()
|
||||||
|
self.cameraChanged.emit(
|
||||||
|
tuple(float(v) for v in eye),
|
||||||
|
tuple(float(v) for v in at_),
|
||||||
|
tuple(float(v) for v in up),
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def wheelEvent(self, event):
|
||||||
|
self._ensure_initialized()
|
||||||
|
self._renderer.handle_wheel(event)
|
||||||
|
super().wheelEvent(event)
|
||||||
|
# Zoom changed the camera — emit signal.
|
||||||
|
try:
|
||||||
|
eye, at_, up = self.get_camera_position()
|
||||||
|
self.cameraChanged.emit(
|
||||||
|
tuple(float(v) for v in eye),
|
||||||
|
tuple(float(v) for v in at_),
|
||||||
|
tuple(float(v) for v in up),
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def resizeEvent(self, event):
|
||||||
|
super().resizeEvent(event)
|
||||||
|
self._ensure_initialized()
|
||||||
|
self._renderer.handle_resize(event.size().width(), event.size().height())
|
||||||
|
|
||||||
|
def set_camera_position(self, position, target):
|
||||||
|
self._ensure_initialized()
|
||||||
|
self._renderer.set_camera_position(position, target)
|
||||||
|
self._renderer.render()
|
||||||
|
|
||||||
|
def get_camera_position(self):
|
||||||
|
"""Return the current camera ``(eye, at, up)`` triple.
|
||||||
|
|
||||||
|
The underlying renderer's ``get_camera_position`` returns three
|
||||||
|
``np.ndarray``s. We forward the call so callers (notably
|
||||||
|
:meth:`MainWindow._collect_view_state`) can persist the camera.
|
||||||
|
Returns a tuple of ``(np.zeros(3), np.zeros(3), (0,0,1))`` if the
|
||||||
|
renderer hasn't been initialised yet (e.g. when the window is
|
||||||
|
being constructed).
|
||||||
|
"""
|
||||||
|
self._ensure_initialized()
|
||||||
|
if hasattr(self._renderer, "get_camera_position"):
|
||||||
|
return self._renderer.get_camera_position()
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
return (
|
||||||
|
np.zeros(3, dtype=float),
|
||||||
|
np.zeros(3, dtype=float),
|
||||||
|
np.array([0.0, 0.0, 1.0], dtype=float),
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_camera_fov(self) -> float:
|
||||||
|
"""Return the current vertical FOV in degrees from the renderer.
|
||||||
|
|
||||||
|
Falls back to 45.0 if the renderer doesn't support it.
|
||||||
|
"""
|
||||||
|
self._ensure_initialized()
|
||||||
|
if hasattr(self._renderer, "get_camera_fov"):
|
||||||
|
return self._renderer.get_camera_fov()
|
||||||
|
return 45.0
|
||||||
|
|
||||||
|
# ─── Face-pick mode (sketch-on-surface) ────────────────────────────────
|
||||||
|
|
||||||
|
def set_pick_face_mode(self, enabled: bool) -> None:
|
||||||
|
"""Toggle face-pick mode.
|
||||||
|
|
||||||
|
When enabled, the cursor selects planar faces for sketch placement
|
||||||
|
instead of orbiting the camera. Middle button still pans; wheel zooms.
|
||||||
|
"""
|
||||||
|
self._pick_face_mode = bool(enabled)
|
||||||
|
if enabled:
|
||||||
|
self.setCursor(Qt.CrossCursor)
|
||||||
|
else:
|
||||||
|
self.unsetCursor()
|
||||||
|
|
||||||
|
def is_pick_face_mode(self) -> bool:
|
||||||
|
return self._pick_face_mode
|
||||||
|
|
||||||
|
def highlight_face(self, face: Any) -> None:
|
||||||
|
"""Tint the picked face light-blue/transparent in the 3D viewer."""
|
||||||
|
self._ensure_initialized()
|
||||||
|
fn = getattr(self._renderer, "highlight_face", None)
|
||||||
|
if fn is not None:
|
||||||
|
fn(face)
|
||||||
|
self._renderer.render()
|
||||||
|
|
||||||
|
def clear_face_highlight(self) -> None:
|
||||||
|
"""Remove the persistent face-selection tint."""
|
||||||
|
self._ensure_initialized()
|
||||||
|
fn = getattr(self._renderer, "clear_face_highlight", None)
|
||||||
|
if fn is not None:
|
||||||
|
fn()
|
||||||
|
self._renderer.render()
|
||||||
|
|
||||||
|
# ─── Connector pick mode (assembly) ────────────────────────────────────
|
||||||
|
|
||||||
|
def set_connector_pick_mode(self, enabled: bool, clear_gizmo: bool = True) -> None:
|
||||||
|
"""Toggle connector pick mode for placing connection points.
|
||||||
|
|
||||||
|
When enabled, clicking an entity (face, edge, vertex, hole)
|
||||||
|
on a body in the assembly view captures its position and
|
||||||
|
direction as a connection point for the SolveSpace solver.
|
||||||
|
|
||||||
|
Entering connector mode deactivates standard OCC face/edge/vertex
|
||||||
|
selection so dynamic highlighting does not clash with the gizmo
|
||||||
|
visuals. Selection is re-activated on exit.
|
||||||
|
|
||||||
|
*clear_gizmo*: if False the gizmo marker is not cleared on exit,
|
||||||
|
allowing it to persist until the next hover event (used after a
|
||||||
|
successful pick so the user sees what was selected).
|
||||||
|
"""
|
||||||
|
self._connector_pick_mode = bool(enabled)
|
||||||
|
if enabled:
|
||||||
|
self.setCursor(Qt.CrossCursor)
|
||||||
|
# Disable standard OCC selection so gizmo visuals are not
|
||||||
|
# interfered with by dynamic face highlighting.
|
||||||
|
fn = getattr(self._renderer, "deactivate_selection_modes", None)
|
||||||
|
if fn is not None:
|
||||||
|
fn()
|
||||||
|
else:
|
||||||
|
if clear_gizmo:
|
||||||
|
self._clear_connector_snap()
|
||||||
|
# Restore standard OCC selection for face-pick / normal modes.
|
||||||
|
fn = getattr(self._renderer, "activate_selection_modes", None)
|
||||||
|
if fn is not None:
|
||||||
|
fn()
|
||||||
|
if not self._pick_face_mode:
|
||||||
|
self.unsetCursor()
|
||||||
|
|
||||||
|
def is_connector_pick_mode(self) -> bool:
|
||||||
|
return self._connector_pick_mode
|
||||||
|
|
||||||
|
def _clear_connector_snap(self) -> None:
|
||||||
|
"""Remove the hover gizmo."""
|
||||||
|
fn = getattr(self._renderer, "clear_entity_gizmo", None)
|
||||||
|
if fn is not None:
|
||||||
|
fn()
|
||||||
|
# Backwards compat: also try the old method.
|
||||||
|
if self._connector_snap_id is not None:
|
||||||
|
fn2 = getattr(self._renderer, "remove_highlight_snap", None)
|
||||||
|
if fn2 is not None:
|
||||||
|
fn2(self._connector_snap_id)
|
||||||
|
self._connector_snap_id = None
|
||||||
|
|
||||||
|
def _handle_connector_hover(self, event) -> None:
|
||||||
|
"""Update the hover snap gizmo during connector pick mode.
|
||||||
|
|
||||||
|
Uses geometric probing (direct topology walk) which does not depend
|
||||||
|
on OCC's selection system — this avoids clashing with the gizmo
|
||||||
|
visuals since selection modes are deactivated in connector mode.
|
||||||
|
Probes are throttled to at most once every 50 ms to avoid UI lag
|
||||||
|
on fast mouse moves.
|
||||||
|
"""
|
||||||
|
import time
|
||||||
|
|
||||||
|
now = time.monotonic()
|
||||||
|
if now - self._connector_last_hover_time < self._connector_hover_interval:
|
||||||
|
return # throttled — skip this mouse move
|
||||||
|
self._connector_last_hover_time = now
|
||||||
|
|
||||||
|
self._ensure_initialized()
|
||||||
|
probe = getattr(self._renderer, "probe_snap_candidates_geometric", None)
|
||||||
|
pos = event.position().toPoint() if hasattr(event, "position") else event.pos()
|
||||||
|
|
||||||
|
if probe is not None:
|
||||||
|
candidates = probe(pos.x(), pos.y())
|
||||||
|
if not candidates:
|
||||||
|
self._clear_connector_snap()
|
||||||
|
self.connectorHover.emit(None)
|
||||||
|
return
|
||||||
|
# Primary = the nearest candidate (probe sorts nearest-first).
|
||||||
|
info = candidates[0]
|
||||||
|
else:
|
||||||
|
# Fall back to the selection-system-based probe.
|
||||||
|
probe2 = getattr(self._renderer, "probe_snap_candidates", None)
|
||||||
|
if probe2 is not None:
|
||||||
|
candidates = probe2(pos.x(), pos.y())
|
||||||
|
if not candidates:
|
||||||
|
self._clear_connector_snap()
|
||||||
|
self.connectorHover.emit(None)
|
||||||
|
return
|
||||||
|
info = candidates[0]
|
||||||
|
else:
|
||||||
|
# Last resort: single-pixel pick.
|
||||||
|
picker = getattr(self._renderer, "pick_entity", None)
|
||||||
|
if picker is None:
|
||||||
|
return
|
||||||
|
info = picker(pos.x(), pos.y())
|
||||||
|
candidates = [info] if info else []
|
||||||
|
if info is None or info.get("owner_obj_id") is None:
|
||||||
|
self._clear_connector_snap()
|
||||||
|
self.connectorHover.emit(None)
|
||||||
|
return
|
||||||
|
|
||||||
|
origin = info["position"]
|
||||||
|
normal = info.get("normal")
|
||||||
|
entity_type = info["type"]
|
||||||
|
owner = info.get("owner_obj_id", "")
|
||||||
|
|
||||||
|
# ── Feature recognition ──
|
||||||
|
# Enhance candidates with composite feature info (holes, edge loops, etc.)
|
||||||
|
recognize = getattr(self._renderer, "recognize_composite_features", None)
|
||||||
|
if recognize is not None and candidates:
|
||||||
|
candidates = recognize(candidates, pos.x(), pos.y())
|
||||||
|
info = candidates[0] # re-read primary after enhancement
|
||||||
|
origin = info["position"]
|
||||||
|
normal = info.get("normal")
|
||||||
|
entity_type = info.get("type", info.get("feature_type", entity_type))
|
||||||
|
|
||||||
|
# Show smart entity gizmo — dim candidate markers + bright primary.
|
||||||
|
self._clear_connector_snap()
|
||||||
|
gizmo_fn = getattr(self._renderer, "show_entity_gizmo", None)
|
||||||
|
if gizmo_fn is not None:
|
||||||
|
gizmo_fn(
|
||||||
|
entity_type=entity_type,
|
||||||
|
position=origin,
|
||||||
|
normal=normal,
|
||||||
|
x_dir=info.get("x_dir"),
|
||||||
|
radius=info.get("radius"),
|
||||||
|
candidates=candidates,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Fallback to old highlight_snap.
|
||||||
|
fn = getattr(self._renderer, "highlight_snap", None)
|
||||||
|
if fn is not None:
|
||||||
|
colors = {
|
||||||
|
"planar_face": (0.0, 0.8, 1.0), # cyan
|
||||||
|
"cylindrical_face": (1.0, 0.4, 0.0), # orange (hole)
|
||||||
|
"edge": (0.0, 1.0, 0.4), # green
|
||||||
|
"vertex": (1.0, 1.0, 0.0), # yellow
|
||||||
|
}
|
||||||
|
c = colors.get(entity_type, (1.0, 0.6, 0.0))
|
||||||
|
self._connector_snap_id = fn(origin, color=c, size=3.0)
|
||||||
|
|
||||||
|
# Build payload with feature recognition info.
|
||||||
|
payload = {
|
||||||
|
"origin": origin,
|
||||||
|
"normal": normal,
|
||||||
|
"type": entity_type,
|
||||||
|
"owner_obj_id": owner,
|
||||||
|
}
|
||||||
|
# Attach feature info if available.
|
||||||
|
if "feature_type" in info:
|
||||||
|
payload["feature_type"] = info["feature_type"]
|
||||||
|
if "suggestion" in info:
|
||||||
|
payload["suggestion"] = info["suggestion"]
|
||||||
|
if "feature_data" in info:
|
||||||
|
payload["feature_data"] = info["feature_data"]
|
||||||
|
|
||||||
|
self.connectorHover.emit(payload)
|
||||||
|
|
||||||
|
def _handle_connector_pick(self, event) -> None:
|
||||||
|
"""Detect an entity under the click and emit connectorPicked.
|
||||||
|
|
||||||
|
Uses geometric probing (direct topology walk) so a click selects the
|
||||||
|
PRIMARY (nearest) snap target — the same one the hover gizmo
|
||||||
|
emphasised. Falls back to selection-system probe, then single-pixel
|
||||||
|
``pick_entity``, then ``pick_planar_face``.
|
||||||
|
"""
|
||||||
|
self._ensure_initialized()
|
||||||
|
pos = event.position().toPoint() if hasattr(event, "position") else event.pos()
|
||||||
|
info: Optional[Dict[str, Any]] = None
|
||||||
|
|
||||||
|
probe = getattr(self._renderer, "probe_snap_candidates_geometric", None)
|
||||||
|
if probe is not None:
|
||||||
|
candidates = probe(pos.x(), pos.y())
|
||||||
|
if candidates:
|
||||||
|
info = candidates[0] # nearest = primary
|
||||||
|
|
||||||
|
if info is None:
|
||||||
|
probe2 = getattr(self._renderer, "probe_snap_candidates", None)
|
||||||
|
if probe2 is not None:
|
||||||
|
candidates = probe2(pos.x(), pos.y())
|
||||||
|
if candidates:
|
||||||
|
info = candidates[0]
|
||||||
|
|
||||||
|
if info is None:
|
||||||
|
picker = getattr(self._renderer, "pick_entity", None)
|
||||||
|
if picker is None:
|
||||||
|
# Fallback to planar face only.
|
||||||
|
picker = getattr(self._renderer, "pick_planar_face", None)
|
||||||
|
if picker is None:
|
||||||
|
logger.warning("Renderer has no entity picking support")
|
||||||
|
return
|
||||||
|
pinfo = picker(pos.x(), pos.y())
|
||||||
|
if pinfo is None:
|
||||||
|
logger.info("Connector pick: no planar face under cursor")
|
||||||
|
return
|
||||||
|
owner_obj_id = pinfo.get("owner_obj_id", "")
|
||||||
|
self.connectorPicked.emit(
|
||||||
|
tuple(pinfo["origin"]),
|
||||||
|
tuple(pinfo["normal"]),
|
||||||
|
tuple(pinfo["x_dir"]),
|
||||||
|
"planar_face",
|
||||||
|
pinfo["face"],
|
||||||
|
owner_obj_id,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
info = picker(pos.x(), pos.y())
|
||||||
|
|
||||||
|
if info is None:
|
||||||
|
logger.info("Connector pick: no entity under cursor")
|
||||||
|
return
|
||||||
|
owner_obj_id = info.get("owner_obj_id", "")
|
||||||
|
if not owner_obj_id:
|
||||||
|
return
|
||||||
|
|
||||||
|
entity_type = info["type"]
|
||||||
|
origin = info["position"]
|
||||||
|
normal = info.get("normal") or (0.0, 0.0, 1.0)
|
||||||
|
x_dir = info.get("x_dir") or (1.0, 0.0, 0.0)
|
||||||
|
|
||||||
|
# For vertices, pick a sensible normal from the parent face if possible.
|
||||||
|
if entity_type == "vertex" and normal is None:
|
||||||
|
normal = (0.0, 0.0, 1.0)
|
||||||
|
|
||||||
|
# Package the raw shape appropriately.
|
||||||
|
raw_shape = info.get("face") or info.get("edge") or info.get("vertex")
|
||||||
|
|
||||||
|
self.connectorPicked.emit(
|
||||||
|
tuple(origin),
|
||||||
|
tuple(normal),
|
||||||
|
tuple(x_dir) if x_dir else (1.0, 0.0, 0.0),
|
||||||
|
entity_type,
|
||||||
|
raw_shape,
|
||||||
|
owner_obj_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ─── Assembly move mode (3D drag) ─────────────────────────────────────
|
||||||
|
|
||||||
|
def set_assembly_move_mode(self, enabled: bool) -> None:
|
||||||
|
"""Toggle assembly move mode.
|
||||||
|
|
||||||
|
When enabled, clicking on a body and dragging moves its
|
||||||
|
assembly component in the view plane. Shift+drag moves in Z.
|
||||||
|
"""
|
||||||
|
self._assembly_move_mode = bool(enabled)
|
||||||
|
if enabled:
|
||||||
|
self.setCursor(Qt.SizeAllCursor)
|
||||||
|
elif not self._pick_face_mode and not self._connector_pick_mode:
|
||||||
|
self.unsetCursor()
|
||||||
|
if not enabled:
|
||||||
|
self._move_drag_active = False
|
||||||
|
self._move_owner_obj_id = ""
|
||||||
|
self._move_click_3d = None
|
||||||
|
self._move_click_screen = None
|
||||||
|
self._move_plane_normal = None
|
||||||
|
self._move_initial_position = None
|
||||||
|
|
||||||
|
def _handle_assembly_move_press(self, event) -> None:
|
||||||
|
"""Start a drag-to-move for the body under the cursor."""
|
||||||
|
self._ensure_initialized()
|
||||||
|
picker = getattr(self._renderer, "pick_planar_face", None)
|
||||||
|
if picker is None:
|
||||||
|
return
|
||||||
|
pos = event.position().toPoint() if hasattr(event, "position") else event.pos()
|
||||||
|
info = picker(pos.x(), pos.y())
|
||||||
|
if info is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
owner_obj_id = info.get("owner_obj_id", "")
|
||||||
|
if not owner_obj_id or not owner_obj_id.startswith("asm_"):
|
||||||
|
return
|
||||||
|
|
||||||
|
# Store drag state.
|
||||||
|
self._move_drag_active = True
|
||||||
|
self._move_owner_obj_id = owner_obj_id
|
||||||
|
self._move_click_3d = tuple(info["origin"])
|
||||||
|
self._move_click_screen = pos
|
||||||
|
self._move_plane_normal = tuple(info["normal"])
|
||||||
|
|
||||||
|
# Emit activation signal so MainWindow stores initial position.
|
||||||
|
self.assemblyComponentActivated.emit(owner_obj_id)
|
||||||
|
|
||||||
|
def _handle_assembly_move_move(self, event) -> None:
|
||||||
|
"""Continue the drag: project mouse delta to world-space and emit."""
|
||||||
|
if not self._move_drag_active or self._move_click_screen is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
pos = event.position().toPoint() if hasattr(event, "position") else event.pos()
|
||||||
|
|
||||||
|
# Screen delta (Qt Y is inverted vs OCC).
|
||||||
|
dx = pos.x() - self._move_click_screen.x()
|
||||||
|
dy = -(pos.y() - self._move_click_screen.y()) # invert Y
|
||||||
|
|
||||||
|
# Convert screen delta to world units using the view scale.
|
||||||
|
# view.Scale() returns a scale factor — the smaller the value the
|
||||||
|
# more world distance per pixel. We use an empirical conversion:
|
||||||
|
# at scale=1.0, ~1 pixel ≈ 0.3 world units at typical depth.
|
||||||
|
scale = self._renderer._view.Scale() if hasattr(self._renderer, "_view") else 1.0
|
||||||
|
world_per_pixel = 2.0 / max(scale, 0.001)
|
||||||
|
|
||||||
|
# Get camera vectors for proper view-plane projection.
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Get camera direction and up from the OCC view.
|
||||||
|
camera = self._renderer._view.Camera()
|
||||||
|
dir_ = camera.Direction()
|
||||||
|
up_ = camera.Up()
|
||||||
|
cam_dir = np.array([dir_.X(), dir_.Y(), dir_.Z()])
|
||||||
|
cam_up = np.array([up_.X(), up_.Y(), up_.Z()])
|
||||||
|
cam_right = np.cross(cam_dir, cam_up)
|
||||||
|
cam_right = cam_right / np.linalg.norm(cam_right)
|
||||||
|
cam_up = cam_up / np.linalg.norm(cam_up)
|
||||||
|
except Exception:
|
||||||
|
# Fallback: assume XY plane.
|
||||||
|
cam_right = np.array([1.0, 0.0, 0.0])
|
||||||
|
cam_up = np.array([0.0, 0.0, 1.0])
|
||||||
|
|
||||||
|
# Compute world-space delta.
|
||||||
|
modifiers = event.modifiers()
|
||||||
|
if modifiers & Qt.ShiftModifier:
|
||||||
|
# Shift+drag: move along camera direction (Z-depth).
|
||||||
|
dz_world = dx * world_per_pixel
|
||||||
|
dx_world = 0.0
|
||||||
|
dy_world = 0.0
|
||||||
|
else:
|
||||||
|
# Normal drag: move in view plane.
|
||||||
|
dx_world = float(cam_right[0] * dx * world_per_pixel + cam_up[0] * dy * world_per_pixel)
|
||||||
|
dy_world = float(cam_right[1] * dx * world_per_pixel + cam_up[1] * dy * world_per_pixel)
|
||||||
|
dz_world = float(cam_right[2] * dx * world_per_pixel + cam_up[2] * dy * world_per_pixel)
|
||||||
|
|
||||||
|
self.assemblyComponentDragged.emit(self._move_owner_obj_id, dx_world, dy_world, dz_world)
|
||||||
|
|
||||||
|
def _handle_assembly_move_release(self, event) -> None:
|
||||||
|
"""Finish the drag, emit final position."""
|
||||||
|
self.assemblyMoveFinished.emit(self._move_owner_obj_id)
|
||||||
|
self._move_drag_active = False
|
||||||
|
self._move_owner_obj_id = ""
|
||||||
|
self._move_click_3d = None
|
||||||
|
self._move_click_screen = None
|
||||||
|
self._move_plane_normal = None
|
||||||
|
self._move_initial_position = None
|
||||||
|
|
||||||
|
def _handle_face_pick(self, event) -> None:
|
||||||
|
"""Detect a planar face under the click and emit facePicked."""
|
||||||
|
self._ensure_initialized()
|
||||||
|
picker = getattr(self._renderer, "pick_planar_face", None)
|
||||||
|
if picker is None:
|
||||||
|
logger.warning("Renderer has no pick_planar_face support")
|
||||||
|
return
|
||||||
|
# Qt6: prefer position().toPoint() over deprecated pos().
|
||||||
|
pos = event.position().toPoint() if hasattr(event, "position") else event.pos()
|
||||||
|
info = picker(pos.x(), pos.y())
|
||||||
|
if info is None:
|
||||||
|
logger.info("Face pick: no planar face under cursor")
|
||||||
|
return
|
||||||
|
# Stash the owning obj_id so MainWindow._on_face_picked can pair the
|
||||||
|
# picked face with the body it belongs to (for auto-targeted cut).
|
||||||
|
self._last_pick_owner_obj_id = info.get("owner_obj_id")
|
||||||
|
self.facePicked.emit(
|
||||||
|
tuple(info["origin"]),
|
||||||
|
tuple(info["normal"]),
|
||||||
|
tuple(info["x_dir"]),
|
||||||
|
info["face"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def set_view(self, view: str):
|
||||||
|
# Prefer the renderer's native orientation snap (preserves target,
|
||||||
|
# refits the scene). Falls back to absolute eye positions for
|
||||||
|
# renderers that don't implement set_view_orientation.
|
||||||
|
self._ensure_initialized()
|
||||||
|
if hasattr(self._renderer, "set_view_orientation"):
|
||||||
|
self._renderer.set_view_orientation(view)
|
||||||
|
self._renderer.render()
|
||||||
|
return
|
||||||
|
positions = {
|
||||||
|
"iso": ((100, 100, 100), (0, 0, 0)),
|
||||||
|
"top": ((0, 0, 200), (0, 0, 0)),
|
||||||
|
"front": ((0, -200, 0), (0, 0, 0)),
|
||||||
|
"right": ((200, 0, 0), (0, 0, 0)),
|
||||||
|
"back": ((0, 200, 0), (0, 0, 0)),
|
||||||
|
"left": ((-200, 0, 0), (0, 0, 0)),
|
||||||
|
"bottom": ((0, 0, -200), (0, 0, 0)),
|
||||||
|
}
|
||||||
|
if view in positions:
|
||||||
|
pos, target = positions[view]
|
||||||
|
self.set_camera_position(pos, target)
|
||||||
|
|
||||||
|
def mouseDoubleClickEvent(self, event):
|
||||||
|
# Double-click → fit all (common CAD convention).
|
||||||
|
self._ensure_initialized()
|
||||||
|
if event.button() == Qt.LeftButton:
|
||||||
|
self.fit_camera()
|
||||||
|
super().mouseDoubleClickEvent(event)
|
||||||
|
|
||||||
|
def keyPressEvent(self, event):
|
||||||
|
# Esc cancels face-pick mode.
|
||||||
|
if self._pick_face_mode and event.key() == Qt.Key_Escape:
|
||||||
|
self.set_pick_face_mode(False)
|
||||||
|
self.pickFaceCancelled.emit()
|
||||||
|
return
|
||||||
|
# Esc cancels connector pick mode.
|
||||||
|
if self._connector_pick_mode and event.key() == Qt.Key_Escape:
|
||||||
|
self.set_connector_pick_mode(False)
|
||||||
|
self.connectorPickCancelled.emit()
|
||||||
|
return
|
||||||
|
# Esc cancels assembly move mode.
|
||||||
|
if self._assembly_move_mode and event.key() == Qt.Key_Escape:
|
||||||
|
self.set_assembly_move_mode(False)
|
||||||
|
return
|
||||||
|
# Navigation shortcuts (lowercase = view presets, F = fit,
|
||||||
|
# P/O = perspective/orthographic, R = reset).
|
||||||
|
self._ensure_initialized()
|
||||||
|
key = event.text().lower()
|
||||||
|
mapping = {
|
||||||
|
"f": "fit",
|
||||||
|
"r": "reset",
|
||||||
|
"1": "front",
|
||||||
|
"2": "back",
|
||||||
|
"3": "top",
|
||||||
|
"4": "bottom",
|
||||||
|
"5": "left",
|
||||||
|
"6": "right",
|
||||||
|
"7": "iso",
|
||||||
|
}
|
||||||
|
action = mapping.get(key)
|
||||||
|
if action == "fit":
|
||||||
|
self.fit_camera()
|
||||||
|
return
|
||||||
|
if action == "reset":
|
||||||
|
if hasattr(self._renderer, "reset_camera"):
|
||||||
|
self._renderer.reset_camera()
|
||||||
|
self._renderer.render()
|
||||||
|
else:
|
||||||
|
self.set_view("iso")
|
||||||
|
return
|
||||||
|
if action in ("front", "back", "top", "bottom", "left", "right", "iso"):
|
||||||
|
self.set_view(action)
|
||||||
|
return
|
||||||
|
if key == "p" and hasattr(self._renderer, "set_camera_perspective"):
|
||||||
|
self._renderer.set_camera_perspective()
|
||||||
|
self._renderer.render()
|
||||||
|
return
|
||||||
|
if key == "o" and hasattr(self._renderer, "set_camera_orthographic"):
|
||||||
|
self._renderer.set_camera_orthographic()
|
||||||
|
self._renderer.render()
|
||||||
|
return
|
||||||
|
super().keyPressEvent(event)
|
||||||
Reference in New Issue
Block a user