26 Commits

Author SHA1 Message Date
bklronin 2b2afbc479 - Added save file foramt
- Split main.py refactor
2026-07-11 15:39:30 +02:00
bklronin b0aebdc04f - Added save file foramt
- Split main.py refactor
2026-07-11 09:34:38 +02:00
bklronin be22c44a3f - Added save file foramt
- Split main.py refactor
2026-07-07 22:40:40 +02:00
bklronin 80ba3cc70a - Added save file foramt
- Split main.py refactor
2026-07-07 21:51:27 +02:00
bklronin 5269c0897c - Added save file foramt
- Split main.py refactor
2026-07-05 22:16:08 +02:00
bklronin 3a169007f7 - assembly draft 2026-07-05 19:36:27 +02:00
bklronin b595b88e04 - assembly draft 2026-07-05 10:16:49 +02:00
bklronin 9f10a5c5e5 - UI refinement, button position ui file as source no dirty drafting anymore 2026-07-04 16:16:04 +02:00
bklronin 6ba742ddf5 - sketch enhacements 2026-07-04 12:10:58 +02:00
bklronin 01833e4af2 - sketch enhacements 2026-07-03 21:49:05 +02:00
bklronin f860ff3e77 - removed cadquery deoendency 2026-07-01 20:03:00 +02:00
bklronin 9938f4ddd4 - Basic operations 2026-06-29 23:30:02 +02:00
bklronin f6422e0847 - Tons of addtions 2026-06-28 22:51:52 +02:00
bklronin f8f16ea800 - Tons of addtions 2026-06-28 21:12:34 +02:00
bklronin 54ac2c098a - Improved sketching 2026-06-14 10:10:37 +02:00
bklronin ea34e7e29d - Improved sketching 2026-06-14 10:10:33 +02:00
bklronin 7091f530ee fix: use RenderWidget and add animation callback for camera controls
- Change from RenderCanvas to RenderWidget for embedded Qt widget
- Add _animate() callback for canvas.request_draw()
- Update render() to use request_draw() for continuous rendering
- This enables OrbitController to work properly with mouse events
2026-03-14 09:12:12 +01:00
bklronin 75d4820292 fix: register OrbitController events with renderer
Use controller.register_events(renderer) instead of canvas to properly
enable camera rotation/pan/zoom controls in the 3D viewer.
2026-03-14 09:09:32 +01:00
bklronin d52106a48a fix: update pygfx integration for current API
- Use rendercanvas.qt.RenderCanvas instead of wgpu.gui.qt.WgpuCanvas
- Fix camera position API: use camera.local.position instead of camera.position.set()
- Fix light position API: use light.local.position
- Fix PygfxRenderObject to properly inherit from RenderObject
- Change add_mesh/add_wireframe/add_points/add_grid/add_axes to return string ID
- Update base Renderer class to return str instead of RenderObject
- Add custom compute_normals() function for mesh normals
2026-03-14 09:06:38 +01:00
bklronin d7ebbf45d5 feat: add CLI logging for debugging
- Add logging module with DEBUG level
- Log MainWindow initialization
- Log Viewer3DWidget initialization and mesh operations
- Log mouse events in Sketch2DWidget
- Log extrude operations with detailed steps
- Log component and sketch management
2026-03-14 09:01:20 +01:00
bklronin daed79dac6 chore: update .gitignore and remove pycache from tracking 2026-03-14 08:59:25 +01:00
bklronin e13769840b fix: ensure renderer is initialized before operations
- Add _ensure_initialized() method to Viewer3DWidget
- Queue pending meshes until widget is shown
- Add remove_mesh() method to PygfxRenderer
- Fix all viewer methods to check initialization
2026-03-14 08:59:12 +01:00
bklronin 5371bf7c38 fix: implement custom compute_normals for pygfx
pygfx doesn't have a compute_normals function, so we implement
our own vertex normal computation from positions and face indices.
2026-03-14 08:57:52 +01:00
bklronin bf00310889 feat: implement full GUI with all features from old codebase
- Main window with left/center/right panel layout
- 2D sketch widget with drawing tools (line, rectangle, circle)
- Constraint tools (coincident, horizontal, vertical, distance, midpoint)
- Snapping system (point, midpoint, horizontal, vertical, angle, grid)
- 3D viewer widget using pygfx
- Component timeline with buttons
- Sketch and body list management
- Operations (extrude, cut, combine, revolve)
- Workplane tools (origin, face, flip, move)
- Export functionality (STEP, IGES, STL)
- Import STEP/IGES files
- Code tab for CadQuery scripting
2026-03-14 08:56:13 +01:00
bklronin 8c6a413137 fix: correct OCP API usage for mesh, bounding box, and volume
- Fix BRep_Tool.Triangulation_s to use TopoDS.Face_s for face casting
- Fix BRepBndLib.AddClose_s import and usage
- Fix BRepGProp.VolumeProperties_s and SurfaceProperties_s imports
- Fix _get_shape to handle Workplane objects stored in shape attribute
- Fix OCCSketchEntity to properly inherit from SketchEntity
- Update pyproject.toml dependency versions
2026-03-14 08:52:45 +01:00
bklronin fe23ca610c feat: Replace SDF kernel with OpenCASCADE, VTK with pygfx
Major architecture migration:

- Remove SDF-based geometry kernel (sdf/)
- Remove VTK renderer (drawing_modules/)
- Remove old mesh modules (mesh_modules/)

New components:
- geometry/base.py: Abstract geometry kernel interface
- geometry_occ/kernel.py: OpenCASCADE implementation via CadQuery/OCP
- geometry_occ/sketch.py: 2D sketching with constraint solving
- rendering/base.py: Abstract renderer interface
- rendering/pygfx_renderer.py: WebGPU-based renderer
- models/data_model.py: Project, Component, Sketch, Body classes
- main.py: New Qt-based application

Features:
- STEP/IGES import/export
- Exact BRep geometry (vs approximate SDF mesh)
- Parametric sketching with constraints
- Boolean operations (union, difference, intersection)
- Fillet and chamfer operations
- Modern pygfx renderer (~30MB vs VTK ~200MB)

Dependencies:
- cadquery >= 2.4
- ocp >= 7.9.3
- pygfx >= 0.7.0
- wgpu >= 0.19.0
- PySide6 >= 6.9.0
2026-03-14 08:45:07 +01:00
107 changed files with 20102 additions and 14929 deletions
+35
View File
@@ -1,3 +1,38 @@
*.xml
*.iml
.idea
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
# Virtual environments
.venv/
venv/
ENV/
# Lock files
uv.lock
# IDE
.vscode/
*.swp
*.swo
-11
View File
@@ -1,11 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="PYTHON_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<sourceFolder url="file://$MODULE_DIR$/sdfcad" isTestSource="false" />
<excludeFolder url="file://$MODULE_DIR$/.venv" />
</content>
<orderEntry type="jdk" jdkName="Python 3.12 (fluency)" jdkType="Python SDK" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>
+1 -1
View File
@@ -2,7 +2,7 @@
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/fluency.iml" filepath="$PROJECT_DIR$/.idea/fluency.iml" />
<module fileurl="file://$PROJECT_DIR$/.idea/fluency-cad.iml" filepath="$PROJECT_DIR$/.idea/fluency-cad.iml" />
</modules>
</component>
</project>
+156 -31
View File
@@ -4,11 +4,9 @@
<option name="autoReloadType" value="SELECTIVE" />
</component>
<component name="ChangeListManager">
<list default="true" id="8f0bafd6-58a0-4b20-aa2b-ddc3ba278873" name="Changes" comment="- added sdf folder ( doesnt work via pip or git=)">
<list default="true" id="8f0bafd6-58a0-4b20-aa2b-ddc3ba278873" name="Changes" comment="- Added save file foramt&#10;- Split main.py refactor">
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/drawing_modules/draw_widget_solve.py" beforeDir="false" afterPath="$PROJECT_DIR$/drawing_modules/draw_widget_solve.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/main.py" beforeDir="false" afterPath="$PROJECT_DIR$/main.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/mesh_modules/interactor_mesh.py" beforeDir="false" afterPath="$PROJECT_DIR$/mesh_modules/interactor_mesh.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/fluency/rendering/occ_renderer.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/rendering/occ_renderer.py" afterDir="false" />
</list>
<option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" />
@@ -25,7 +23,7 @@
<component name="Git.Settings">
<option name="RECENT_BRANCH_BY_REPOSITORY">
<map>
<entry key="$PROJECT_DIR$" value="structure" />
<entry key="$PROJECT_DIR$" value="single_window" />
</map>
</option>
<option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
@@ -45,30 +43,42 @@
<option name="hideEmptyMiddlePackages" value="true" />
<option name="showLibraryContents" value="true" />
</component>
<component name="PropertiesComponent"><![CDATA[{
"keyToString": {
"Python.2dtest.executor": "Run",
"Python.3d_windows.executor": "Run",
"Python.Unnamed.executor": "Run",
"Python.draw_widget2d.executor": "Run",
"Python.draw_widget_solve.executor": "Run",
"Python.fluency.executor": "Run",
"Python.fluencyb.executor": "Run",
"Python.gl_widget.executor": "Run",
"Python.main.executor": "Run",
"Python.meshtest.executor": "Run",
"Python.side_fluency.executor": "Run",
"Python.simple_mesh.executor": "Run",
"Python.vtk_widget.executor": "Run",
"Python.vulkan.executor": "Run",
"RunOnceActivity.OpenProjectViewOnStart": "true",
"RunOnceActivity.ShowReadmeOnStart": "true",
"RunOnceActivity.git.unshallow": "true",
"git-widget-placeholder": "master",
"last_opened_file_path": "/Volumes/Data_drive/Programming/fluency",
"settings.editor.selected.configurable": "project.propVCSSupport.DirectoryMappings"
<component name="PropertiesComponent">{
&quot;keyToString&quot;: {
&quot;Python.2dtest.executor&quot;: &quot;Run&quot;,
&quot;Python.3d_windows.executor&quot;: &quot;Run&quot;,
&quot;Python.Unnamed.executor&quot;: &quot;Run&quot;,
&quot;Python.base.executor&quot;: &quot;Run&quot;,
&quot;Python.data_model.executor&quot;: &quot;Run&quot;,
&quot;Python.draw_widget2d.executor&quot;: &quot;Run&quot;,
&quot;Python.draw_widget_solve.executor&quot;: &quot;Run&quot;,
&quot;Python.fluency.executor&quot;: &quot;Run&quot;,
&quot;Python.fluencyb.executor&quot;: &quot;Run&quot;,
&quot;Python.gl_widget.executor&quot;: &quot;Run&quot;,
&quot;Python.kernel.executor&quot;: &quot;Run&quot;,
&quot;Python.main.executor&quot;: &quot;Run&quot;,
&quot;Python.meshtest.executor&quot;: &quot;Run&quot;,
&quot;Python.occ_renderer.executor&quot;: &quot;Run&quot;,
&quot;Python.side_fluency.executor&quot;: &quot;Run&quot;,
&quot;Python.simple_mesh.executor&quot;: &quot;Run&quot;,
&quot;Python.sketch.executor&quot;: &quot;Run&quot;,
&quot;Python.vtk_widget.executor&quot;: &quot;Run&quot;,
&quot;Python.vulkan.executor&quot;: &quot;Run&quot;,
&quot;RunOnceActivity.OpenProjectViewOnStart&quot;: &quot;true&quot;,
&quot;RunOnceActivity.ShowReadmeOnStart&quot;: &quot;true&quot;,
&quot;RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252&quot;: &quot;true&quot;,
&quot;RunOnceActivity.git.unshallow&quot;: &quot;true&quot;,
&quot;RunOnceActivity.typescript.service.memoryLimit.init&quot;: &quot;true&quot;,
&quot;codeWithMe.voiceChat.enabledByDefault&quot;: &quot;false&quot;,
&quot;git-widget-placeholder&quot;: &quot;feature/occ-migration&quot;,
&quot;last_opened_file_path&quot;: &quot;/Volumes/Data_drive/Programming/fluency/src/fluency&quot;,
&quot;node.js.detected.package.eslint&quot;: &quot;true&quot;,
&quot;node.js.selected.package.eslint&quot;: &quot;(autodetect)&quot;,
&quot;node.js.selected.package.tslint&quot;: &quot;(autodetect)&quot;,
&quot;nodejs_package_manager_path&quot;: &quot;npm&quot;,
&quot;settings.editor.selected.configurable&quot;: &quot;project.propVCSSupport.DirectoryMappings&quot;
}
}]]></component>
}</component>
<component name="QodanaReportsService">
<option name="descriptions">
<ReportDescription localRun="true" path="/private/var/folders/kg/zm48w_r96yb68mlbzvb9gtq40000gn/T/qodana_output/qodana.sarif.json" reportGuid="5f5b823c-c594-48c5-ae1f-062e30303918" reportId="fluency/qodana/2024-02-04" />
@@ -76,6 +86,7 @@
</component>
<component name="RecentsManager">
<key name="CopyFile.RECENT_KEYS">
<recent name="$PROJECT_DIR$/src/fluency" />
<recent name="$PROJECT_DIR$" />
<recent name="$PROJECT_DIR$/drawing_modules" />
<recent name="$PROJECT_DIR$/modules" />
@@ -87,7 +98,7 @@
<component name="SharedIndexes">
<attachedChunks>
<set>
<option value="bundled-python-sdk-4c141bd692a7-e2d783800521-com.jetbrains.pycharm.community.sharedIndexes.bundled-PC-251.26927.90" />
<option value="bundled-python-sdk-c59985aa861c-c2ffad84badb-com.jetbrains.pycharm.pro.sharedIndexes.bundled-PY-261.24374.152" />
</set>
</attachedChunks>
</component>
@@ -252,9 +263,116 @@
<option name="project" value="LOCAL" />
<updated>1755369224187</updated>
</task>
<option name="localTasksCounter" value="20" />
<task id="LOCAL-00020" summary="- Tons of addtions">
<option name="closed" value="true" />
<created>1782673954850</created>
<option name="number" value="00020" />
<option name="presentableId" value="LOCAL-00020" />
<option name="project" value="LOCAL" />
<updated>1782673954850</updated>
</task>
<task id="LOCAL-00021" summary="- Tons of addtions">
<option name="closed" value="true" />
<created>1782679912834</created>
<option name="number" value="00021" />
<option name="presentableId" value="LOCAL-00021" />
<option name="project" value="LOCAL" />
<updated>1782679912834</updated>
</task>
<task id="LOCAL-00022" summary="- Basic operations">
<option name="closed" value="true" />
<created>1782768610475</created>
<option name="number" value="00022" />
<option name="presentableId" value="LOCAL-00022" />
<option name="project" value="LOCAL" />
<updated>1782768610475</updated>
</task>
<task id="LOCAL-00023" summary="- removed cadquery deoendency">
<option name="closed" value="true" />
<created>1782928990792</created>
<option name="number" value="00023" />
<option name="presentableId" value="LOCAL-00023" />
<option name="project" value="LOCAL" />
<updated>1782928990792</updated>
</task>
<task id="LOCAL-00024" summary="- sketch enhacements">
<option name="closed" value="true" />
<created>1783108151675</created>
<option name="number" value="00024" />
<option name="presentableId" value="LOCAL-00024" />
<option name="project" value="LOCAL" />
<updated>1783108151676</updated>
</task>
<task id="LOCAL-00025" summary="- sketch enhacements">
<option name="closed" value="true" />
<created>1783159860774</created>
<option name="number" value="00025" />
<option name="presentableId" value="LOCAL-00025" />
<option name="project" value="LOCAL" />
<updated>1783159860774</updated>
</task>
<task id="LOCAL-00026" summary="- UI refinement, button position ui file as source no dirty drafting anymore">
<option name="closed" value="true" />
<created>1783174566362</created>
<option name="number" value="00026" />
<option name="presentableId" value="LOCAL-00026" />
<option name="project" value="LOCAL" />
<updated>1783174566362</updated>
</task>
<task id="LOCAL-00027" summary="- assembly draft">
<option name="closed" value="true" />
<created>1783239410744</created>
<option name="number" value="00027" />
<option name="presentableId" value="LOCAL-00027" />
<option name="project" value="LOCAL" />
<updated>1783239410744</updated>
</task>
<task id="LOCAL-00028" summary="- assembly draft">
<option name="closed" value="true" />
<created>1783272988957</created>
<option name="number" value="00028" />
<option name="presentableId" value="LOCAL-00028" />
<option name="project" value="LOCAL" />
<updated>1783272988957</updated>
</task>
<task id="LOCAL-00029" summary="- Added save file foramt&#10;- Split main.py refactor">
<option name="closed" value="true" />
<created>1783282570014</created>
<option name="number" value="00029" />
<option name="presentableId" value="LOCAL-00029" />
<option name="project" value="LOCAL" />
<updated>1783282570014</updated>
</task>
<task id="LOCAL-00030" summary="- Added save file foramt&#10;- Split main.py refactor">
<option name="closed" value="true" />
<created>1783453889199</created>
<option name="number" value="00030" />
<option name="presentableId" value="LOCAL-00030" />
<option name="project" value="LOCAL" />
<updated>1783453889199</updated>
</task>
<task id="LOCAL-00031" summary="- Added save file foramt&#10;- Split main.py refactor">
<option name="closed" value="true" />
<created>1783456842297</created>
<option name="number" value="00031" />
<option name="presentableId" value="LOCAL-00031" />
<option name="project" value="LOCAL" />
<updated>1783456842297</updated>
</task>
<task id="LOCAL-00032" summary="- Added save file foramt&#10;- Split main.py refactor">
<option name="closed" value="true" />
<created>1783755278516</created>
<option name="number" value="00032" />
<option name="presentableId" value="LOCAL-00032" />
<option name="project" value="LOCAL" />
<updated>1783755278516</updated>
</task>
<option name="localTasksCounter" value="33" />
<servers />
</component>
<component name="TypeScriptGeneratedFilesManager">
<option name="version" value="3" />
</component>
<component name="Vcs.Log.Tabs.Properties">
<option name="TAB_STATES">
<map>
@@ -287,6 +405,13 @@
<MESSAGE value="- added MIT license" />
<MESSAGE value="- added screenshot" />
<MESSAGE value="- added sdf folder ( doesnt work via pip or git=)" />
<option name="LAST_COMMIT_MESSAGE" value="- added sdf folder ( doesnt work via pip or git=)" />
<MESSAGE value="- Tons of addtions" />
<MESSAGE value="- Basic operations" />
<MESSAGE value="- removed cadquery deoendency" />
<MESSAGE value="- sketch enhacements" />
<MESSAGE value="- UI refinement, button position ui file as source no dirty drafting anymore" />
<MESSAGE value="- assembly draft" />
<MESSAGE value="- Added save file foramt&#10;- Split main.py refactor" />
<option name="LAST_COMMIT_MESSAGE" value="- Added save file foramt&#10;- Split main.py refactor" />
</component>
</project>
+111
View File
@@ -0,0 +1,111 @@
# Fluency CAD 2.0
A parametric CAD application built on OpenCASCADE Technology (OCCT) with a modern pygfx-based 3D renderer.
## Features
- **OpenCASCADE Geometry Kernel**: Industry-standard BRep geometry with exact precision
- **STEP/IGES Import/Export**: Full support for industry-standard CAD file formats
- **Parametric Sketching**: 2D sketching with constraint solving using SolveSpace
- **Boolean Operations**: Union, difference, and intersection
- **Fillet & Chamfer**: Apply edge treatments to solid bodies
- **Modern Renderer**: WebGPU-based rendering with pygfx (smaller footprint than VTK)
## Architecture
```
fluency/
├── src/fluency/
│ ├── geometry/ # Geometry abstraction layer
│ │ └── base.py # Abstract interfaces
│ ├── geometry_occ/ # OpenCASCADE implementation
│ │ ├── kernel.py # OCGeometryKernel
│ │ └── sketch.py # OCCSketch with constraints
│ ├── rendering/ # Rendering abstraction
│ │ ├── base.py # Abstract renderer
│ │ └── pygfx_renderer.py
│ ├── models/ # Data models
│ │ └── data_model.py # Project, Component, Sketch, Body
│ └── main.py # Application entry point
├── tests/
│ └── test_geometry.py
└── pyproject.toml
```
## Installation
```bash
# Create virtual environment
python -m venv .venv
source .venv/bin/activate # On Windows: .venv\Scripts\activate
# Install dependencies
pip install -e ".[dev]"
```
## Dependencies
| Package | Purpose |
|---------|---------|
| cadquery-ocp | OpenCASCADE Python bindings (OCP) |
| pygfx | WebGPU-based 3D renderer |
| wgpu | WebGPU Python bindings |
| PySide6 | Qt GUI framework |
| numpy | Numerical computing |
| scipy | Scientific computing |
## Usage
```bash
# Run the application
fluency-cad
# Or directly
python -m fluency.main
```
## API Example
```python
from fluency.geometry_occ.kernel import OCGeometryKernel
from fluency.geometry.base import Point2D
# Create kernel
kernel = OCGeometryKernel()
# Create a sketch
points = [
Point2D(0, 0),
Point2D(10, 0),
Point2D(10, 10),
Point2D(0, 10),
]
polygon = kernel.create_polygon(points)
# Extrude to 3D
body = kernel.extrude(polygon, height=20.0)
# Apply fillet
body = kernel.fillet(body, radius=2.0)
# Export to STEP
kernel.export_step(body, "part.step")
# Export to STL
kernel.export_stl(body, "part.stl")
```
## Comparison: Before vs After
| Aspect | Before (SDF + VTK) | After (OCC + pygfx) |
|--------|-------------------|---------------------|
| Geometry Precision | Approximate (mesh) | Exact (BRep) |
| Export Formats | STL only | STEP, IGES, STL, BREP |
| File Size | Large (mesh) | Small (BRep) |
| Fillet/Chamfer | Approximate | Exact |
| Dependency Size | ~200MB (VTK) | ~30MB (pygfx) |
| Constraint Solver | SolveSpace (separate) | SolveSpace (integrated) |
## License
MIT License
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+219
View File
@@ -0,0 +1,219 @@
# Fluency CAD — Agent Guide
## Project Overview
**Fluency CAD 2.0** is a parametric CAD application built on **OpenCASCADE Technology (OCCT)** with a modern **pygfx-based 3D renderer**. It provides 2D sketching with SolveSpace constraint solving, boolean operations, STEP/IGES/STL import/export, and exact BRep geometry.
- Language: **Python 3.10+**
- GUI: **PySide6** (Qt6)
- Geometry Kernel: **OCP** (cadquery-ocp — OpenCASCADE Python bindings)
- Constraint Solver: **python_solvespace**
- Renderer: **pygfx** (WebGPU) + **OCCRenderer** (native OCC AIS display)
---
## Architecture
```
src/fluency/
├── __init__.py # Package entry (version, imports)
├── main.py # Application entry point, MainWindow, Sketch2DWidget (~5000 lines)
├── sketch_solver.py # SolveSpace constraint solver wrapper (legacy)
├── geometry/
│ └── base.py # Abstract interfaces: GeometryKernel, SketchInterface, data classes
├── geometry_occ/
│ ├── kernel.py # OCGeometryKernel (extrude, boolean, fillet, import/export, mesh)
│ └── sketch.py # OCCSketch with SolveSpace integration (face detection, constraints)
├── models/
│ └── data_model.py # Project, Component, Sketch, Body dataclasses
├── rendering/
│ ├── base.py # Abstract Renderer interface
│ ├── occ_renderer.py # OCC AIS renderer (preferred — smooth BRep display)
│ └── pygfx_renderer.py # Legacy pygfx renderer
├── utils/ # Utility modules
└── widgets/ # Custom widgets
tests/
└── test_geometry.py # Comprehensive test suite (52+ tests)
```
### Key Classes & Responsibilities
| Class | File | Purpose |
|-------|------|---------|
| `OCGeometryKernel` | `kernel.py` | OCC shape ops: extrude, boolean, fillet, mesh, import/export |
| `OCCSketch` | `sketch.py` | 2D sketch with SolveSpace solver, face detection, workplane |
| `OCCSketchEntity` | `sketch.py` | Entity (point/line/circle/arc) with solver handle, is_construction, is_external |
| `Sketch2DWidget` | `main.py` | Qt widget for interactive 2D sketching (draw, snap, constrain) |
| `MainWindow` | `main.py` | Main application window, toolbars, 3D viewer, operations |
| `OCCRenderer` | `occ_renderer.py` | Native OCC AIS display (shaded + edges, face pick) |
| `Sketch` | `data_model.py` | Data model: workplane, occ_sketch ref, source_body_id |
| `Body` | `data_model.py` | 3D solid with geometry, visibility, render object |
| `Component` | `data_model.py` | Container for sketches and bodies |
| `Project` | `data_model.py` | Top-level container with kernel |
### Data Flow
```
User draws in Sketch2DWidget
→ OCCSketch entities created in solver
→ Constraint solving (python_solvespace)
→ OCCSketch.get_geometry() → detect_faces() → build_face_geometry()
→ OCGeometryKernel.extrude() → BRepPrimAPI_MakePrism
→ Boolean operations → Body added to Component
→ OCCRenderer.add_shape() → AIS display
```
---
## Development Commands
```bash
# Install editable
pip install -e ".[dev]"
# Run app
python -m fluency.main
# Run tests (52 tests)
python -m pytest tests/test_geometry.py -v
# Run single test
python -m pytest tests/test_geometry.py::TestOCCSketch::test_workplane_extrude_with_hole -xvs
# Quck geometry test (raw OCC, no Qt)
python -c "from fluency.geometry_occ.sketch import OCCSketch; ..."
```
---
## Code Conventions
### General
- Line length: **100 chars** (black/ruff config)
- Target Python: **3.10+** (uses `from __future__ import annotations`, walrus, pattern matching)
- Docstrings: Google/NumPy style preferred
- Logging: `logger = logging.getLogger(__name__)` with `logging.DEBUG` level
### OCC / OCP
- Always use `is not None` for OCP objects — `TopoDS_Shape.__bool__` can be falsy even for valid shapes
- `BRepBuilderAPI_MakeFace.Add(wire)` expects a `TopoDS_Wire`. `wire.Reversed()` returns `TopoDS_Shape` → cast via `_TopoDS.Wire_s(wire.Reversed())`
- Face normal direction: check `face.Orientation()` vs `TopAbs_REVERSED` — a REVERSED face's outward normal is the NEGATION of the surface axis
- `TopoDS_Wire_s(shape)`, `TopoDS_Face_s(shape)` — use `_s` suffix from OCP for downcasts
- Mesh: `BRepMesh_IncrementalMesh(shape, tol, False, 0.15, True)` — default deflection 0.15 rad for smooth curves
### Extrude / Cut Workflow
- Snapshot `list(self._current_component.bodies.items())` **BEFORE** `add_body()` — the new body must not be in the target set
- Cut targets the **source body** (`sketch._source_body_id` from face pick), not `bodies[0]`
- The fix: apply boolean to **target** geometry, then remove tool body
- Plain extrude with holes: inner wires must have **OPPOSITE** geometric winding to the outer wire (see `build_face_geometry` and `_loop_signed_area`)
### Sketch / Solvers
- `python_solvespace` has NO remove API for entities/constraints. Deleting requires: drop from `_points`/`_lines`, prune `_constraint_log`, `_rebuild_solver()` (recreates entire system), `_rebuild_labels()`, re-solve
- `_constraint_log`: each entry is `{"type": str, "ids": tuple[int,...], "params": tuple, "labels": set[str]}`
- Constraint labels: stored on **point** entities for paintEvent rendering; rebuilt via `_rebuild_labels()`
- Line constraints (`horizontal`/`vertical`/`parallel`/`perpendicular`) need the **line's** solver handle, not a point's. Use `_find_line_sketch_entity()` to get the correct handle
- External entities (underlay): `is_external=True`, `is_construction=True`, fixed in solver (always `dragged`). Stored in `_external_entity_ids`, excluded from `_line_segments()`, `get_polygon_points()`, `get_closed_loops()`, `detect_faces()`, `get_geometry()`
### Face Detection
- `get_closed_loops()`: uses snapped-coordinate graph (`_SNAP_TOL = 1e-4`) from line endpoint adjacency. Only accepts simple cycles (all nodes degree 2)
- `detect_faces()`: even-odd nesting rule via `_loop_contains`. Even depth = outer boundary, odd = hole
- `_loop_rep_point`: midpoint between centroid and first vertex. **Fragile** — can land inside a nested shape for certain geometries (e.g., a small hole near the centroid's direction from the first vertex)
- `_loop_signed_area`: shoelace formula for polygons, `πr²` (positive = CCW) for circles
### Rendering
- **OCCRenderer** is the main renderer (not pygfx). Uses `AIS_Shape`, `V3d_Viewer`, `AIS_InteractiveContext`
- Face pick: `pick_planar_face(x, y)``MoveTo``DetectedShape``TopoDS_Face_s``BRepAdaptor_Surface` plane check
- Highlight: `highlight_face(face)` creates a transparent AIS overlay; `clear_face_highlight()` removes it
- Preview: `preview_shape(shape)` for live transparent extrude preview
- Navigation: Left=orbit, Middle=pan, Wheel=zoom. **Right is RESERVED** — check user before reassigning
### Paint-Event Safety
- Every constraint-tag rendering loop wraps each entry in `try/except` so a bad entry (dangling id, corrupted geometry) doesn't crash the entire paint event
- `_point_world()` and `_entity_anchor()` return `None` (not raise) for malformed input
---
## Known Bugs & Fix Patterns
### 1. Hole Orientation in Extrusion (FIXED 2026-07-03)
**Symptom**: Inner shapes (circle/triangle/slot) inside a rectangle become solid islands instead of holes when extruding, depending on drag direction.
**Root Cause**: `wire_loop` in `build_face_geometry` unconditionally reversed hole wires (`w.Reversed()`). When the outer polygon was CW-winding (e.g., dragging from top-left to bottom-right), the reversed inner had the SAME effective direction as the outer, making OCC treat it as solid.
**Fix**: Added `_loop_signed_area()` to compute geometric winding. Hole wires are only reversed when their natural winding matches the outer's (ensuring opposite winding for holes).
**Relevant code**: `sketch.py`, `build_face_geometry()` and `_loop_signed_area()`
### 2. _loop_rep_point Fragility (KNOWN)
**Symptom**: Face detection fails when a nested shape contains the outer loop's representative point (midpoint between centroid and first vertex).
**Would-be fix**: Use a guaranteed-interior point (maximum inscribed circle center or perturbed centroid) instead of the centroid-first-vertex midpoint.
### 3. Extrude Cut / Target Selection (FIXED 2026-06-29)
**Symptom**: Cut created a separate "cavity-shaped" body next to the original instead of modifying the target.
**Fix**: Boolean result stored on TARGET body geometry; tool body removed from component. Auto-target via `sketch._source_body_id`.
### 4. Workplane Preservation (FIXED 2026-06-29)
**Symptom**: Sketch placed on a face lost its workplane after being added to component.
**Fix**: Copy `occ_sketch` workplane fields into `Sketch` dataclass BEFORE `apply_workplane()`.
---
## API Quirks
- **`QPoint(0,0)`**: falsy via `isNull()` in PySide6 → always use `is not None` for `Optional[QPoint]`
- **`QMouseEvent`/`QPainterPath`**: live in `PySide6.QtGui` (NOT `QtCore`)
- **`BRepBuilderAPI_MakeFace.Add()`**: needs `TopoDS_Wire`. `wire.Reversed()` returns `TopoDS_Shape` — cast via `TopoDS_Wire_s()`
- **python_solvespace**: NO entity/constraint remove API — workaround via `_rebuild_solver()`. Parameters read via `solver.params(handle.params)` → returns `(x, y)` tuple
- **OCGeometryKernel.extrude**: unwraps `OCCGeometryObject`, raw `TopoDS_Shape`, or cadquery `Workplane`. Always use `is not None` for the shape (not truthiness)
- **Sketch._source_body_id**: dynamic attribute set on `Sketch` dataclass, set during face-pick flow
- **`_get_shape(obj)`**: returns `obj.shape.wrapped` for `OCCGeometryObject`, `obj.shape` for raw shapes, `None` for empty. Use `is not None` guards everywhere
---
## Memory / Agent Context
This project has extensive Pi memory (hermes-memory) for:
- `project="fluency"` with `target="failure"`: bugs, fixes, corrections, insights
- `project="fluency"` with `target="memory"`: conventions, decisions, workflow patterns
- Available skills: `fix-cad-app-pipeline`, `refactor-from-cadquery-to-ocp`
Key memory queries for debugging:
- "hole orientation" → `_loop_signed_area` / `build_face_geometry` fix
- "extrude cut auto-target" → cut/target body fix
- "workplane preservation" → _add_sketch_to_component fix
- "_loop_rep_point" → face detection fragility
- "paint-event safety" → try/except per entry pattern
- "solver rebuild" → delete workflow via _rebuild_solver
- "face pick origin" → pick_planar_face face bbox centre
---
## Testing Patterns
```python
# Direct OCC test (no Qt)
from OCP.BRepBuilderAPI import BRepBuilderAPI_MakePolygon, BRepBuilderAPI_MakeFace
from OCP.gp import gp_Pnt
from OCP.BRepPrimAPI import BRepPrimAPI_MakePrism
from OCP.GProp import GProp_GProps; from OCP.BRepGProp import BRepGProp
# Build test shape, extrude, verify volume
mp = BRepBuilderAPI_MakePolygon(); ...
g = GProp_GProps(); BRepGProp.VolumeProperties_s(shape, g)
assert abs(g.Mass() - expected) < 0.1
```
```python
# Sketch-based test
from fluency.geometry_occ.sketch import OCCSketch
from fluency.geometry_occ.kernel import OCGeometryKernel
sk = OCCSketch()
# ... add points, lines, circles ...
sk.solve()
geom = sk.get_geometry()
solid = OCGeometryKernel().extrude(geom, 10.0)
```
-849
View File
@@ -1,849 +0,0 @@
# Fluency CAD - Improved Sketcher Technical Documentation
## Table of Contents
1. [Overview](#overview)
2. [Architecture](#architecture)
3. [Core Components](#core-components)
4. [Geometry System](#geometry-system)
5. [Constraint Solving](#constraint-solving)
6. [Coordinate Systems](#coordinate-systems)
7. [Interaction System](#interaction-system)
8. [Rendering System](#rendering-system)
9. [Snapping System](#snapping-system)
10. [Working Plane Integration](#working-plane-integration)
11. [API Reference](#api-reference)
12. [Performance Considerations](#performance-considerations)
13. [Troubleshooting](#troubleshooting)
## Overview
The ImprovedSketchWidget is a parametric 2D sketching system built for Fluency CAD. It provides constraint-based geometric modeling with real-time solving, integrated snapping, and seamless integration with 3D working planes. The system is built on top of the SolverSpace constraint solver and PySide6 for the user interface.
### Key Features
- **Parametric Geometry**: All geometry is constraint-driven and automatically updates
- **Real-time Solving**: Constraints are solved dynamically as geometry is modified
- **Advanced Snapping**: Multi-mode snapping system (points, midpoints, grid, angles)
- **Construction Geometry**: Support for helper/construction geometry
- **Working Plane Integration**: Seamless 2D/3D workflow with projected geometry
- **Interactive Dragging**: Smooth point dragging with constraint preservation
- **Multiple Drawing Modes**: Lines, rectangles, circles, arcs, and points
## Architecture
```
┌─────────────────────────────────────────────────────────┐
│ ImprovedSketchWidget │
│ ┌─────────────────┐ ┌─────────────────────────────┐ │
│ │ User Interface │ │ Rendering System │ │
│ │ - Mouse Events │ │ - Coordinate Transform │ │
│ │ - Keyboard │ │ - Geometry Drawing │ │
│ │ - Mode Control │ │ - UI Overlays │ │
│ └─────────────────┘ └─────────────────────────────┘ │
│ │ │ │
│ └─────────┬───────────────┘ │
│ │ │
│ ┌─────────────────────────────────────────────────┐ │
│ │ Interaction System │ │
│ │ - Snapping Engine │ │
│ │ - Dragging Logic │ │
│ │ - Selection Management │ │
│ └─────────────────────────────────────────────────┘ │
│ │ │
│ ┌─────────────────────────────────────────────────┐ │
│ │ Geometry System │ │
│ │ ┌─────────────┐ ┌─────────────────────────┐ │ │
│ │ │ Point2D │ │ Line2D │ │ │
│ │ │ Circle2D │ │ Arc2D (future) │ │ │
│ │ └─────────────┘ └─────────────────────────┘ │ │
│ └─────────────────────────────────────────────────┘ │
│ │ │
│ ┌─────────────────────────────────────────────────┐ │
│ │ ImprovedSketch │ │
│ │ (Enhanced SolverSystem) │ │
│ │ - Constraint Management │ │
│ │ - Solver Integration │ │
│ │ - Geometry Storage │ │
│ └─────────────────────────────────────────────────┘ │
│ │ │
│ ┌─────────────────────────────────────────────────┐ │
│ │ SolverSpace Library │ │
│ │ - Constraint Solving Engine │ │
│ │ - Geometric Relationships │ │
│ └─────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
```
## Core Components
### 1. ImprovedSketchWidget
The main widget class that handles user interaction and rendering.
**Key Responsibilities:**
- Mouse and keyboard event handling
- Mode management (line, circle, constraint modes, etc.)
- Coordinate system transformations
- Rendering pipeline orchestration
- Integration with external systems (working planes)
### 2. ImprovedSketch
Enhanced wrapper around SolverSpace's SolverSystem.
**Key Responsibilities:**
- Geometry storage and management
- Constraint system integration
- Solver result processing
- Handle management for solver objects
### 3. Geometry Classes
Type-safe geometry representations with validation.
**Classes:**
- `Point2D`: 2D points with solver integration
- `Line2D`: 2D lines with constraint tracking
- `Circle2D`: 2D circles with radius constraints
## Geometry System
### Point2D Class
```python
class Point2D:
def __init__(self, x: float, y: float, is_construction: bool = False):
self.id = uuid.uuid4() # Unique identifier
self.x = float(x) # X coordinate
self.y = float(y) # Y coordinate
self.ui_point = QPoint(int(x), int(y)) # Qt UI point
self.handle = None # SolverSpace handle
self.handle_nr = None # Handle number
self.is_helper = is_construction # Construction geometry flag
```
**Key Features:**
- Automatic coordinate validation
- SolverSpace handle integration
- Construction/normal geometry support
- Distance calculations and equality testing
### Line2D Class
```python
class Line2D:
def __init__(self, start_point: Point2D, end_point: Point2D, is_construction: bool = False):
self.id = uuid.uuid4()
self.start = start_point # Start point reference
self.end = end_point # End point reference
self.handle = None # SolverSpace handle
self.constraints = [] # Applied constraints list
self.is_helper = is_construction
```
**Key Features:**
- Automatic degenerate line detection
- Length, midpoint, and angle calculations
- Point-on-line testing with tolerance
- Constraint tracking and annotation
### Circle2D Class
```python
class Circle2D:
def __init__(self, center: Point2D, radius: float, is_construction: bool = False):
self.id = uuid.uuid4()
self.center = center # Center point reference
self.radius = float(radius) # Radius value
self.handle = None # SolverSpace handle
self.constraints = [] # Applied constraints
self.is_helper = is_construction
```
## Constraint Solving
### SolverSpace Integration
The system uses the `python-solvespace` library for constraint solving. The `ImprovedSketch` class wraps the SolverSpace API and provides:
1. **Automatic Handle Management**: Each geometry object gets a unique handle
2. **Error Handling**: Robust error handling for solver failures
3. **Position Updates**: Automatic geometry position updates after solving
### Constraint Types
#### Geometric Constraints
- **Coincident**: Point-to-point or point-to-line coincidence
- **Horizontal**: Forces lines to be horizontal
- **Vertical**: Forces lines to be vertical
- **Distance**: Fixes distance between points or line length
- **Parallel**: Makes lines parallel (future implementation)
- **Perpendicular**: Makes lines perpendicular (future implementation)
#### Constraint Application Workflow
```python
def _handle_distance_constraint(self, pos: QPoint):
line = self.sketch.get_line_near(pos)
if line and line.handle:
# Get user input for distance
distance, ok = QInputDialog.getDouble(...)
if ok:
# Apply constraint to solver
self.sketch.distance(line.start.handle, line.end.handle, distance, self.sketch.wp)
# Solve system
result = self.sketch.solve_system()
if result == ResultFlag.OKAY:
line.constraints.append(f"L={distance:.2f}")
```
### Solver Workflow
1. **Constraint Addition**: Constraints are added to the solver system
2. **System Solving**: The solver attempts to find a valid solution
3. **Result Processing**: If successful, geometry positions are updated
4. **UI Updates**: The display is refreshed to show new positions
## Coordinate Systems
The sketcher uses multiple coordinate systems that must be properly transformed between:
### 1. Sketch Coordinates (Local)
- Origin at sketch center
- Y-axis points up (mathematical convention)
- Units in millimeters
- Range: typically -1000 to +1000
### 2. Viewport Coordinates (Screen)
- Origin at top-left of widget
- Y-axis points down (computer graphics convention)
- Units in pixels
- Range: 0 to widget dimensions
### 3. Working Plane Coordinates (3D)
- 3D coordinates projected onto 2D working plane
- Transformation handled by external VTK system
- Converted to sketch coordinates for display
### Coordinate Transformations
#### Viewport to Local (Mouse Input)
```python
def _viewport_to_local(self, viewport_pos: QPoint) -> QPoint:
# Step 1: Subtract widget center
center_x = self.width() / 2
center_y = self.height() / 2
# Step 2: Apply pan offset
viewport_x = viewport_pos.x() - center_x - (self.pan_offset.x() * self.zoom_factor)
viewport_y = viewport_pos.y() - center_y - (self.pan_offset.y() * self.zoom_factor)
# Step 3: Apply inverse zoom and Y-flip
local_x = viewport_x / self.zoom_factor
local_y = -viewport_y / self.zoom_factor
return QPoint(int(local_x), int(local_y))
```
#### Rendering Transform Setup
```python
def _setup_coordinate_system(self, painter: QPainter):
transform = QTransform()
# Translate to center and apply pan
center = QPointF(self.width() / 2, self.height() / 2)
transform.translate(center.x() + self.pan_offset.x() * self.zoom_factor,
center.y() + self.pan_offset.y() * self.zoom_factor)
# Apply zoom and flip Y-axis
transform.scale(self.zoom_factor, -self.zoom_factor)
painter.setTransform(transform)
```
## Interaction System
### Mode-Based Interaction
The sketcher supports multiple interaction modes with robust mode management:
#### Drawing Modes
- `SketchMode.LINE`: Two-point line creation
- `SketchMode.RECTANGLE`: Two-corner rectangle creation
- `SketchMode.CIRCLE`: Center-radius circle creation
- `SketchMode.POINT`: Single point creation
#### Constraint Modes
- `SketchMode.COINCIDENT_PT_PT`: Point-to-point coincidence
- `SketchMode.HORIZONTAL`: Horizontal line constraint
- `SketchMode.VERTICAL`: Vertical line constraint
- `SketchMode.DISTANCE`: Distance/length constraint
#### Selection Mode
- `SketchMode.NONE`: Selection and manipulation mode (enables point dragging)
### Selection and Deletion System
The sketcher now includes a comprehensive selection and deletion system that allows users to select and remove elements from the sketch.
#### Selection Methods
1. **Single Element Selection**: Click on individual points or lines to select/deselect them
2. **Rectangle Selection**: Click and drag to create a selection rectangle for multiple elements
3. **Visual Feedback**: Selected elements are highlighted in yellow with increased size
#### Deletion Methods
1. **Keyboard Deletion**: Press Delete or Backspace to remove selected elements
2. **Proper Cleanup**: Elements are removed from both the sketch and constraint solver
3. **Dependency Handling**: Lines are deleted before points to maintain geometric integrity
#### Implementation Details
The selection system is implemented through the following components:
- **Selection Tracking**: `selected_elements` list tracks currently selected elements
- **Rectangle Selection**: `selection_rect_start` and `selection_rect_end` track rectangle selection bounds
- **Visual Feedback**: Modified drawing methods highlight selected elements in yellow
- **Keyboard Support**: `keyPressEvent` handles Delete/Backspace keys
- **Deletion Method**: `delete_selected_elements` handles removal of elements from sketch and solver
#### Selection Workflow
1. **Default Selection Mode**: The sketcher defaults to selection mode when no drawing tool is active
2. **Element Selection**:
- Click on points or lines to select/deselect them (they turn yellow)
- Click and drag to create a rectangle selection for multiple elements
3. **Element Deletion**:
- Press Delete or Backspace to remove all selected elements
- Elements are removed from both the sketch and constraint solver
4. **Visual Feedback**:
- Selected elements are highlighted in yellow
- Rectangle selection is shown with a yellow dashed border
#### Constraints Handling
When elements are deleted:
- Lines are removed first to avoid issues with points being used by lines
- Points are only removed if they are not used by any remaining lines
- The constraint solver is re-run after deletion to update remaining constraints
- Proper error handling ensures the UI remains responsive even if solver operations fail
### Mode Management System
The mode system has been enhanced to provide intuitive selection and deletion functionality:
#### Mode Compatibility
- Python `None` is automatically converted to `SketchMode.NONE` for backward compatibility
- The `set_mode()` method ensures the mode is always a valid `SketchMode` enum value
- Mode changes reset all interaction buffers and state
#### Default Selection Behavior
- `SketchMode.NONE` now serves as the default selection mode
- When no drawing tool is active, the sketcher is in selection mode by default
- Users can click on elements to select/deselect them (they turn yellow)
- Users can click and drag to create rectangle selections
- Pressing Delete or Backspace removes all selected elements
#### Right-Click Behavior
- Right-clicking **always** exits any active mode and returns to `SketchMode.NONE`
- This enables point dragging and prevents unintended geometry creation
- The mode reset happens directly in the sketcher, not through main app signals
#### Point Dragging Safety
- Point dragging is **only** enabled when in `SketchMode.NONE` mode
- Left-clicks in `NONE` mode check for draggable points first
- If no point is found, the click is processed as a selection operation
### Mouse Event Handling
#### Click Processing Flow
```python
def mousePressEvent(self, event):
local_pos = self._viewport_to_local(event.pos())
if event.button() == Qt.LeftButton:
self._handle_left_click(local_pos)
elif event.button() == Qt.RightButton:
self._handle_right_click(local_pos)
elif event.button() == Qt.MiddleButton:
self._start_panning(event.pos())
```
#### Enhanced Left-Click Handler
```python
def _handle_left_click(self, pos: QPoint):
# Safety check for NONE mode (dragging enabled)
if self.current_mode == SketchMode.NONE or self.current_mode is None:
point = self.sketch.get_point_near(pos, self.snap_settings.snap_distance)
if point:
self._start_point_drag(point, pos)
return
else:
# No point found - ignore click to prevent unintended drawing
return
# Handle active drawing/constraint modes
if self.current_mode == SketchMode.LINE:
self._handle_line_creation(pos)
elif self.current_mode == SketchMode.HORIZONTAL:
self._handle_horizontal_constraint(pos)
# ... other modes
```
#### Right-Click Mode Reset
```python
def _handle_right_click(self, pos: QPoint):
# Reset interaction state
self._reset_interaction_state()
# Force mode to NONE to enable dragging
self.current_mode = SketchMode.NONE
# Emit signal to inform main app
self.constraint_applied.emit()
```
### Point Dragging System
The point dragging system is optimized for performance and maintains constraint consistency:
#### Drag Phases
1. **Drag Start** (`_start_point_drag`):
- Identifies dragged point
- Stores initial position
- Sets dragging state
2. **Drag Update** (`_handle_point_drag`):
- Updates point visual position only
- Applies snapping
- No solver execution (for performance)
3. **Drag End** (`_end_point_drag`):
- Updates solver parameters with final position
- Runs constraint solver
- Updates all connected geometry
- Resets drag state
```python
def _end_point_drag(self):
if not self.dragging_point:
return
# Update solver parameters with final position
if self.dragging_point.handle:
new_x = self.dragging_point.x
new_y = self.dragging_point.y
self.sketch.set_params(self.dragging_point.handle.params, [new_x, new_y])
# Run solver to update all connected geometry
result = self.sketch.solve_system()
if result == ResultFlag.OKAY:
self.sketch_modified.emit()
```
## Rendering System
### Rendering Pipeline
The rendering system uses Qt's QPainter with a multi-layer approach:
1. **Coordinate System Setup**: Apply zoom, pan, and Y-flip transforms
2. **Background Rendering**: Grid, axes, and origin marker
3. **Geometry Rendering**: Points, lines, circles with proper styling
4. **Dynamic Elements**: Preview geometry during creation
5. **UI Overlays**: Mode indicators, measurements, snap highlights
### Rendering Layers
#### Layer 1: Background
- Coordinate axes (dashed gray lines)
- Grid (if enabled)
- Origin marker (red circle)
#### Layer 2: Geometry
- Construction geometry (green, dotted)
- Normal geometry (gray, solid)
- Constraint annotations
#### Layer 3: Interactive Elements
- Hover highlights (red)
- Dynamic previews (gray, dashed)
- Measurements during creation
#### Layer 4: UI Overlays
- Snap point indicators
- Mode and zoom information
- Status messages
### Styling System
Rendering appearance is controlled by the `RenderSettings` class:
```python
@dataclass
class RenderSettings:
normal_pen_width: float = 2.0
construction_pen_width: float = 1.0
highlight_pen_width: float = 3.0
normal_color = QColor(128, 128, 128) # Gray
construction_color = QColor(0, 255, 0) # Green
highlight_color = QColor(255, 0, 0) # Red
solver_color = QColor(0, 255, 0) # Green
dynamic_color = QColor(128, 128, 128) # Gray
text_color = QColor(255, 255, 255) # White
```
### Dynamic Previews
During geometry creation, dynamic previews show:
- **Line Creation**: Dashed line from start to cursor with length annotation
- **Rectangle Creation**: Dashed rectangle outline
- **Circle Creation**: Dashed circle with radius line and annotation
## Snapping System
### Snap Modes
The snapping system supports multiple simultaneous snap modes:
#### SnapMode.POINT
- Snaps to existing geometry points
- Priority: Highest
- Visual: Red circle highlight
#### SnapMode.MIDPOINT
- Snaps to line midpoints
- Priority: Medium
- Visual: Red diamond highlight
#### SnapMode.GRID
- Snaps to grid intersections
- Priority: Lowest
- Visual: Green cross highlight
#### SnapMode.HORIZONTAL/VERTICAL
- Angular snapping (future implementation)
- Constrains to horizontal/vertical directions
#### SnapMode.INTERSECTION
- Snaps to line intersections (future implementation)
### Snap Algorithm
```python
def _get_snapped_position(self, pos: QPoint) -> QPoint:
min_distance = float('inf')
snapped_pos = pos
snap_threshold = self.snap_settings.snap_distance
# Point snapping (highest priority)
if SnapMode.POINT in self.snap_settings.enabled_modes:
for point in self.sketch.points:
distance = math.sqrt((pos.x() - point.x)**2 + (pos.y() - point.y)**2)
if distance < snap_threshold and distance < min_distance:
snapped_pos = QPoint(int(point.x), int(point.y))
min_distance = distance
# Midpoint snapping (medium priority)
if SnapMode.MIDPOINT in self.snap_settings.enabled_modes and min_distance > snap_threshold:
for line in self.sketch.lines:
midpoint = line.midpoint
distance = math.sqrt((pos.x() - midpoint.x)**2 + (pos.y() - midpoint.y)**2)
if distance < snap_threshold and distance < min_distance:
snapped_pos = QPoint(int(midpoint.x), int(midpoint.y))
min_distance = distance
return snapped_pos
```
### Snap Settings
```python
@dataclass
class SnapSettings:
snap_distance: float = 20.0 # Snap threshold in pixels
angle_increment: float = 15.0 # Angular snap increment
grid_spacing: float = 50.0 # Grid spacing
enabled_modes: Set[SnapMode] # Active snap modes
```
## Working Plane Integration
### Projected Geometry Workflow
The sketcher integrates with 3D working planes through projected geometry:
1. **3D Geometry Selection**: User selects 3D lines/points in VTK widget
2. **Plane Definition**: System computes working plane from selections
3. **Geometry Projection**: 3D geometry is projected onto 2D working plane
4. **Sketch Import**: Projected geometry is imported as construction geometry
### Projection Import Methods
#### `convert_proj_points(proj_points)`
Imports projected 3D points as 2D construction points:
```python
def convert_proj_points(self, proj_points):
for point_data in proj_points:
if hasattr(point_data, 'x') and hasattr(point_data, 'y'):
point = Point2D(point_data.x, point_data.y, True) # Construction
self.sketch.add_point(point)
```
#### `convert_proj_lines(proj_lines)`
Imports projected 3D lines as 2D construction lines:
```python
def convert_proj_lines(self, proj_lines):
for line_data in proj_lines:
# Handle object format
if hasattr(line_data, 'start') and hasattr(line_data, 'end'):
x1, y1 = line_data.start.x, line_data.start.y
x2, y2 = line_data.end.x, line_data.end.y
# Skip degenerate lines
if abs(x1 - x2) < 1e-6 and abs(y1 - y2) < 1e-6:
continue
start = Point2D(x1, y1, True)
end = Point2D(x2, y2, True)
self.sketch.add_point(start)
self.sketch.add_point(end)
line = Line2D(start, end, True)
self.sketch.add_line(line)
```
### Construction vs Normal Geometry
- **Construction Geometry**:
- Rendered in green with dotted lines
- Used for reference and alignment
- Created from projected 3D geometry
- Flag: `is_construction=True`
- **Normal Geometry**:
- Rendered in gray with solid lines
- Part of the actual sketch design
- Created by user drawing actions
- Flag: `is_construction=False`
## API Reference
### Main Widget Class
#### ImprovedSketchWidget
**Initialization:**
```python
widget = ImprovedSketchWidget()
widget.show()
```
**Mode Control:**
```python
# Set drawing modes
widget.set_mode(SketchMode.LINE)
widget.set_mode(SketchMode.NONE) # Enable selection/dragging
widget.set_mode(None) # Also converted to SketchMode.NONE
# Construction geometry
widget.set_construction_mode(True)
```
**Snapping Control:**
```python
widget.set_snap_mode(SnapMode.POINT, True)
widget.toggle_snap_mode(SnapMode.MIDPOINT, enabled)
```
**View Control:**
```python
widget.zoom_to_fit()
```
**Sketch Access:**
```python
sketch = widget.get_sketch()
widget.set_sketch(imported_sketch)
```
### Sketch Management
#### ImprovedSketch
**Geometry Addition:**
```python
sketch = ImprovedSketch()
point = Point2D(10, 20)
line = Line2D(start_point, end_point)
circle = Circle2D(center_point, radius)
sketch.add_point(point)
sketch.add_line(line)
sketch.add_circle(circle)
```
**Constraint Application:**
```python
# Distance constraint
sketch.distance(point1.handle, point2.handle, 50.0, sketch.wp)
# Coincident constraint
sketch.coincident(point1.handle, point2.handle, sketch.wp)
# Line constraints
sketch.horizontal(line.handle, sketch.wp)
sketch.vertical(line.handle, sketch.wp)
# Solve system
result = sketch.solve_system()
```
### Signals
The widget emits several signals for integration:
```python
# Emitted when constraint is successfully applied
widget.constraint_applied.connect(callback)
# Emitted when new geometry is created
widget.geometry_created.connect(callback) # Parameter: geometry type string
# Emitted when sketch is modified
widget.sketch_modified.connect(callback)
```
## Performance Considerations
### Optimization Strategies
1. **Lazy Solving**: Solver only runs when necessary (after constraints or drag end)
2. **Efficient Rendering**: Uses Qt's optimized drawing primitives
3. **Smart Updates**: Only redraws affected regions when possible
4. **Handle Caching**: SolverSpace handles are cached to avoid recreation
### Memory Management
- Geometry objects use weak references where possible
- SolverSpace handles are properly cleaned up
- Qt objects follow parent-child hierarchy for automatic cleanup
### Scalability Limits
- Recommended maximum: ~1000 geometric entities
- Solver performance degrades with complex constraint networks
- Rendering remains smooth up to ~10,000 entities
## Troubleshooting
### Common Issues
#### Mode Handling Problems
**Symptoms**: Unintended line creation when dragging, tools not deactivating properly
**Causes**: Mode not properly reset to NONE, Python None vs SketchMode.NONE confusion
**Solutions**:
- Always right-click to exit active modes
- Ensure `set_mode(None)` is converted to `SketchMode.NONE`
- Verify mode state after tool deactivation in main app
#### Point Dragging Issues
**Symptoms**: Cannot drag points, dragging creates unwanted lines
**Causes**: Mode not set to NONE, safety checks preventing drag detection
**Solutions**:
- Verify current mode is `SketchMode.NONE` before attempting to drag
- Right-click to ensure proper mode exit from drawing tools
- Check that point detection threshold is appropriate
#### Solver Failures
**Symptoms**: Constraints not applied, geometry not updating
**Causes**: Over-constrained systems, conflicting constraints
**Solutions**:
- Check constraint compatibility
- Verify geometry validity
- Use `ResultFlag` inspection for error details
#### Coordinate Transform Issues
**Symptoms**: Mouse clicks don't match visual geometry
**Causes**: Incorrect transform calculations, zoom/pan state corruption
**Solutions**:
- Verify `_viewport_to_local` and `_setup_coordinate_system` consistency
- Reset view with `zoom_to_fit()`
#### Performance Problems
**Symptoms**: Slow dragging, UI lag
**Causes**: Solver running during drag, excessive redraws
**Solutions**:
- Ensure solver only runs in `_end_point_drag`
- Check render loop efficiency
- Profile with Qt performance tools
#### Snap Behavior Issues
**Symptoms**: Inconsistent snapping, incorrect snap points
**Causes**: Priority conflicts, threshold settings, coordinate errors
**Solutions**:
- Adjust snap threshold in `SnapSettings`
- Verify snap priority order
- Check coordinate conversion in snap calculations
### Debug Logging
Enable detailed logging for troubleshooting:
```python
import logging
logging.basicConfig(level=logging.DEBUG)
logger = logging.getLogger('improved_sketcher')
```
Key log messages include:
- Geometry addition/removal
- Constraint application results
- Solver execution status
- Coordinate transformations
- Snap calculations
### Testing Guidelines
#### Unit Testing
- Test geometry classes with edge cases
- Verify coordinate transformations
- Test constraint application logic
#### Integration Testing
- Test with various sketch sizes
- Verify working plane integration
- Test complex constraint networks
#### Performance Testing
- Measure solver execution time
- Profile rendering performance
- Test with large geometry sets
---
## Recent Improvements (2025-08-16)
### Mode Handling Enhancements
Significant improvements have been made to the mode management system:
#### Fixed Issues
1. **Unintended Line Creation**: Resolved issue where dragging with line tool deactivated would still create lines
2. **Mode Reset Reliability**: Right-click now reliably exits any active mode and returns to NONE
3. **Backward Compatibility**: Python `None` mode values are automatically converted to `SketchMode.NONE`
4. **Safety Checks**: Added comprehensive checks to prevent drawing operations in NONE mode
#### Implementation Details
- Enhanced `_handle_right_click()` to directly set mode to NONE
- Added safety checks in `_handle_left_click()` for NONE mode behavior
- Improved `set_mode()` method to handle None input gracefully
- Added comprehensive debug logging for mode transitions
#### Integration Improvements
- Fixed main app integration where constraint modes were prematurely reset
- Ensured persistent constraint behavior until explicit user cancellation
- Maintained UI button state consistency with actual sketcher mode
These improvements ensure reliable mode transitions and prevent common user frustrations with unintended geometry creation.
## Conclusion
The ImprovedSketchWidget provides a robust, extensible foundation for 2D parametric sketching in Fluency CAD. Its architecture separates concerns effectively, uses proven libraries (SolverSpace, PySide6), and provides rich interaction capabilities while maintaining good performance characteristics.
The system is designed for extensibility - new geometry types, constraint types, and interaction modes can be added following the established patterns. The comprehensive API allows for both direct use and integration with larger CAD systems.
With the recent mode handling improvements, the sketcher now provides a more reliable and intuitive user experience, with proper separation between drawing modes and selection/manipulation operations.
-1
View File
@@ -1 +0,0 @@
pyside6-uic gui.ui > Gui.py -g python
-35
View File
@@ -1,35 +0,0 @@
# Signal Flow
## 2D SketchWidget
- 2D QPoint form custom Qpainter widget in linear space
- 2D QPoint ot cartesian space
- 2D tuple into slvspace dict system and solvespace
- get calced position from Solvespace solver
- add to internal reference dict
- Transform to linear QPainter space for display to show
## 3D custom Widget
- Take Tuple points form solvespace main dict
- Draw Interactor and sdfCAD model
### Select and Project
- Project cartesian flattened mesh into 2D
- Transform to 2D xy
- Transform to linear space for 2D widget to draw.
- Result into 2D cartesian for body interaction extrude etc
### Elements
So far these are the elements:
- Project: Main File
- Timeline : Used to track the steps
- Assembly: Uses Components and Connectors to from Assemblies
- Component: Container for multiple smaller elements "part"
- Connector: Preserves connections between parts even if the part in between is deleted
- Code: A special type that directly builds bodys from sdfCAD code.
- Body: The 3D meshed result from sdfCAD
- Sketch: The base to draw new entities.
- Interactor (edges): A special component mesh that is used to manipulate the bodys in 3d view.
-3
View File
@@ -1,3 +0,0 @@
## Compile ui file
pyside6-uic gui.ui > Gui.py -g python
BIN
View File
Binary file not shown.
Binary file not shown.
-916
View File
@@ -1,916 +0,0 @@
import math
import re
from copy import copy
from typing import Optional
import numpy as np
from PySide6.QtWidgets import QApplication, QWidget, QMessageBox, QInputDialog
from PySide6.QtGui import QPainter, QPen, QColor, QTransform
from PySide6.QtCore import Qt, QPoint, QPointF, Signal, QLine
from python_solvespace import SolverSystem, ResultFlag
class SketchWidget(QWidget):
constrain_done = Signal()
def __init__(self):
super().__init__()
self.line_draw_buffer = [None, None]
self.drag_buffer = [None, None]
self.main_buffer = [None, None]
self.hovered_point = None
self.selected_line = None
self.snapping_range = 20 # Range in pixels for snapping
self.zoom = 1
self.setMouseTracking(True)
self.mouse_mode = False
self.solv = SolverSystem()
self.sketch = None
def set_sketch(self, sketch) -> None:
print(sketch)
self.sketch = sketch
self.create_workplane()
def get_sketch(self):
return self.sketch
def reset_buffers(self):
self.line_draw_buffer = [None, None]
self.drag_buffer = [None, None]
self.main_buffer = [None, None]
def set_points(self, points: list):
self.points = points
#self.update()
def create_workplane(self):
self.sketch.working_plane = self.solv.create_2d_base()
def create_workplane_projected(self):
self.sketch.working_plane = self.solv.create_2d_base()
def convert_proj_points(self):
out_points = []
for point in self.sketch.proj_points:
x, y = point
coord = QPoint(x, y)
out_points.append(coord)
self.sketch.proj_points = out_points
def convert_proj_lines(self):
out_lines = []
for line in self.sketch.proj_lines:
start = QPoint(line[0][0], line[0][1])
end = QPoint(line[1][0], line[1][1])
coord = QLine(start, end)
out_lines.append(coord)
self.sketch.proj_lines = out_lines
def find_duplicate_points_2d(self, edges):
points = []
seen = set()
duplicates = []
for edge in edges:
for point in edge:
# Extract only x and y coordinates
point_2d = (point[0], point[1])
if point_2d in seen:
if point_2d not in duplicates:
duplicates.append(point_2d)
else:
seen.add(point_2d)
points.append(point_2d)
return duplicates
def normal_to_quaternion(self, normal):
normal = np.array(normal)
#normal = normal / np.linalg.norm(normal)
axis = np.cross([0, 0, 1], normal)
if np.allclose(axis, 0):
axis = np.array([1, 0, 0])
else:
axis = axis / np.linalg.norm(axis) # Normalize the axis
angle = np.arccos(np.dot([0, 0, 1], normal))
qw = np.cos(angle / 2)
sin_half_angle = np.sin(angle / 2)
qx, qy, qz = axis * sin_half_angle # This will now work correctly
return qw, qx, qy, qz
def create_workplane_space(self, points, normal):
print("edges", points)
origin = self.find_duplicate_points_2d(points)
print(origin)
x, y = origin[0]
origin = QPoint(x, y)
origin_handle = self.get_handle_from_ui_point(origin)
qw, qx, qy, qz = self.normal_to_quaternion(normal)
slv_normal = self.solv.add_normal_3d(qw, qx, qy, qz)
self.sketch.working_plane = self.solv.add_work_plane(origin_handle, slv_normal)
print(self.sketch.working_plane)
def get_handle_nr(self, input_str: str) -> int:
# Define the regex pattern to extract the handle number
pattern = r"handle=(\d+)"
# Use re.search to find the handle number in the string
match = re.search(pattern, input_str)
if match:
handle_number = int(match.group(1))
print(f"Handle number: {handle_number}")
return int(handle_number)
else:
print("Handle number not found.")
return 0
def get_keys(self, d: dict, target: QPoint) -> list:
result = []
path = []
print(d)
print(target)
for k, v in d.items():
path.append(k)
if isinstance(v, dict):
self.get_keys(v, target)
if v == target:
result.append(copy(path))
path.pop()
return result
def get_handle_from_ui_point(self, ui_point: QPoint):
"""Input QPoint and you shall reveive a slvs entity handle!"""
for point in self.sketch.slv_points:
if ui_point == point['ui_point']:
slv_handle = point['solv_handle']
return slv_handle
def get_line_handle_from_ui_point(self, ui_point: QPoint):
"""Input Qpoint that is on a line and you shall receive the handle of the line!"""
for target_line_con in self.sketch.slv_lines:
if self.is_point_on_line(ui_point, target_line_con['ui_points'][0], target_line_con['ui_points'][1]):
slv_handle = target_line_con['solv_handle']
return slv_handle
def get_point_line_handles_from_ui_point(self, ui_point: QPoint) -> tuple:
"""Input Qpoint that is on a line and you shall receive the handles of the points of the line!"""
for target_line_con in self.sketch.slv_lines:
if self.is_point_on_line(ui_point, target_line_con['ui_points'][0], target_line_con['ui_points'][1]):
lines_to_cons = target_line_con['solv_entity_points']
return lines_to_cons
def distance(self, p1, p2):
return math.sqrt((p1.x() - p2.x())**2 + (p1.y() - p2.y())**2)
def calculate_midpoint(self, point1, point2):
mx = (point1.x() + point2.x()) // 2
my = (point1.y() + point2.y()) // 2
return QPoint(mx, my)
def is_point_on_line(self, p, p1, p2, tolerance=5):
# Calculate the lengths of the sides of the triangle
a = self.distance(p, p1)
b = self.distance(p, p2)
c = self.distance(p1, p2)
# Calculate the semi-perimeter
s = (a + b + c) / 2
# Calculate the area using Heron's formula
area = math.sqrt(s * (s - a) * (s - b) * (s - c))
# Calculate the height (perpendicular distance from the point to the line)
if c > 0:
height = (2 * area) / c
# Check if the height is within the tolerance distance to the line
if height > tolerance:
return False
# Check if the projection of the point onto the line is within the line segment
dot_product = ((p.x() - p1.x()) * (p2.x() - p1.x()) + (p.y() - p1.y()) * (p2.y() - p1.y())) / (c ** 2)
return 0 <= dot_product <= 1
else:
return None
def viewport_to_local_coord(self, qt_pos : QPoint) -> QPoint:
return QPoint(self.to_quadrant_coords(qt_pos))
def check_all_points(self,) -> list:
old_points_ui = []
new_points_ui = []
for old_point_ui in self.sketch.slv_points:
old_points_ui.append(old_point_ui['ui_point'])
for i in range(self.solv.entity_len()):
# Iterate though full length because mixed list from SS
entity = self.solv.entity(i)
if entity.is_point_2d() and self.solv.params(entity.params):
x_tbu, y_tbu = self.solv.params(entity.params)
point_solved = QPoint(x_tbu, y_tbu)
new_points_ui.append(point_solved)
# Now we have old_points_ui and new_points_ui, let's compare them
differences = []
if len(old_points_ui) != len(new_points_ui):
print(f"Length mismatch {len(old_points_ui)} - {len(new_points_ui)}")
for index, (old_point, new_point) in enumerate(zip(old_points_ui, new_points_ui)):
if old_point != new_point:
differences.append((index, old_point, new_point))
return differences
def update_ui_points(self, point_list: list):
# Print initial state of slv_points_main
# print("Initial slv_points_main:", self.slv_points_main)
print("Change list:", point_list)
if len(point_list) > 0:
for tbu_points_idx in point_list:
# Each tbu_points_idx is a tuple: (index, old_point, new_point)
index, old_point, new_point = tbu_points_idx
# Update the point in slv_points_main
self.sketch.slv_points[index]['ui_point'] = new_point
# Print updated state
# print("Updated slv_points_main:", self.slv_points_main)
def check_all_lines_and_update(self,changed_points: list):
for tbu_points_idx in changed_points:
index, old_point, new_point = tbu_points_idx
for line_needs_update in self.sketch.slv_lines:
if old_point == line_needs_update['ui_points'][0]:
line_needs_update['ui_points'][0] = new_point
elif old_point == line_needs_update['ui_points'][1]:
line_needs_update['ui_points'][1] = new_point
def mouseReleaseEvent(self, event):
local_event_pos = self.viewport_to_local_coord(event.pos())
if event.button() == Qt.LeftButton and not self.mouse_mode:
self.drag_buffer[1] = local_event_pos
print("Le main buffer", self.drag_buffer)
if len(self.main_buffer) == 2:
entry = self.drag_buffer[0]
new_params = self.drag_buffer[1].x(), self.drag_buffer[1].y()
self.solv.set_params(entry.params, new_params)
self.solv.solve()
points_need_update = self.check_all_points()
self.update_ui_points(points_need_update)
self.check_all_lines_and_update(points_need_update)
self.update()
self.drag_buffer = [None, None]
def mousePressEvent(self, event):
local_event_pos = self.viewport_to_local_coord(event.pos())
relation_point = {
'handle_nr': None,
'solv_handle': None,
'ui_point': None,
'part_of_entity': None
}
relation_line = {
'handle_nr': None,
'solv_handle': None,
'solv_entity_points': None,
'ui_points': None
}
if event.button() == Qt.LeftButton and not self.mouse_mode:
self.drag_buffer[0] = self.get_handle_from_ui_point(self.hovered_point)
if event.button() == Qt.RightButton and self.mouse_mode:
self.reset_buffers()
if event.button() == Qt.LeftButton and self.mouse_mode == "line":
if self.hovered_point:
clicked_pos = self.hovered_point
else:
clicked_pos = local_event_pos
if not self.line_draw_buffer[0]:
self.line_draw_buffer[0] = clicked_pos
u = clicked_pos.x()
v = clicked_pos.y()
point = self.solv.add_point_2d(u, v, self.sketch.working_plane)
relation_point = {} # Reinitialize the dictionary
handle_nr = self.get_handle_nr(str(point))
relation_point['handle_nr'] = handle_nr
relation_point['solv_handle'] = point
relation_point['ui_point'] = clicked_pos
self.sketch.slv_points.append(relation_point)
print("points", self.sketch.slv_points)
print("lines", self.sketch.slv_lines)
elif self.line_draw_buffer[0]:
self.line_draw_buffer[1] = clicked_pos
u = clicked_pos.x()
v = clicked_pos.y()
point2 = self.solv.add_point_2d(u, v, self.sketch.working_plane)
relation_point = {} # Reinitialize the dictionary
handle_nr = self.get_handle_nr(str(point2))
relation_point['handle_nr'] = handle_nr
relation_point['solv_handle'] = point2
relation_point['ui_point'] = clicked_pos
self.sketch.slv_points.append(relation_point)
print("points", self.sketch.slv_points)
print("lines", self.sketch.slv_lines)
print("Buffer state", self.line_draw_buffer)
if self.line_draw_buffer[0] and self.line_draw_buffer[1]:
point_slv1 = self.get_handle_from_ui_point(self.line_draw_buffer[0])
point_slv2 = self.get_handle_from_ui_point(self.line_draw_buffer[1])
print(point_slv1)
print(point_slv2)
line = self.solv.add_line_2d(point_slv1, point_slv2, self.sketch.working_plane)
relation_line = {} # Reinitialize the dictionary
handle_nr_line = self.get_handle_nr(str(line))
relation_line['handle_nr'] = handle_nr_line
relation_line['solv_handle'] = line
relation_line['solv_entity_points'] = (point_slv1, point_slv2)
relation_line['ui_points'] = [self.line_draw_buffer[0], self.line_draw_buffer[1]]
# Track relationship of point in line
relation_point['part_of_entity'] = handle_nr_line
self.sketch.slv_lines.append(relation_line)
# Reset the buffer for the next line segment
self.line_draw_buffer[0] = self.line_draw_buffer[1]
self.line_draw_buffer[1] = None
# Track Relationship
# Points
if event.button() == Qt.LeftButton and self.mouse_mode == "pt_pt":
if self.hovered_point and not self.main_buffer[0]:
self.main_buffer[0] = self.get_handle_from_ui_point(self.hovered_point)
elif self.main_buffer[0]:
self.main_buffer[1] = self.get_handle_from_ui_point(self.hovered_point)
if self.main_buffer[0] and self.main_buffer[1]:
print("buf", self.main_buffer)
self.solv.coincident(self.main_buffer[0], self.main_buffer[1], self.sketch.working_plane)
if self.solv.solve() == ResultFlag.OKAY:
print("Fuck yeah")
elif self.solv.solve() == ResultFlag.DIDNT_CONVERGE:
print("Solve_failed - Converge")
elif self.solv.solve() == ResultFlag.TOO_MANY_UNKNOWNS:
print("Solve_failed - Unknowns")
elif self.solv.solve() == ResultFlag.INCONSISTENT:
print("Solve_failed - Incons")
self.constrain_done.emit()
self.main_buffer = [None, None]
if event.button() == Qt.LeftButton and self.mouse_mode == "pt_line":
print("ptline")
line_selected = None
if self.hovered_point and not self.main_buffer[1]:
self.main_buffer[0] = self.get_handle_from_ui_point(self.hovered_point)
elif self.main_buffer[0]:
self.main_buffer[1] = self.get_line_handle_from_ui_point(local_event_pos)
# Contrain point to line
if self.main_buffer[1]:
self.solv.coincident(self.main_buffer[0], self.main_buffer[1], self.sketch.working_plane)
if self.solv.solve() == ResultFlag.OKAY:
print("Fuck yeah")
self.constrain_done.emit()
elif self.solv.solve() == ResultFlag.DIDNT_CONVERGE:
print("Solve_failed - Converge")
elif self.solv.solve() == ResultFlag.TOO_MANY_UNKNOWNS:
print("Solve_failed - Unknowns")
elif self.solv.solve() == ResultFlag.INCONSISTENT:
print("Solve_failed - Incons")
self.constrain_done.emit()
# Clear saved_points after solve attempt
self.main_buffer = [None, None]
if event.button() == Qt.LeftButton and self.mouse_mode == "pb_con_mid":
print("ptline")
line_selected = None
if self.hovered_point and not self.main_buffer[1]:
self.main_buffer[0] = self.get_handle_from_ui_point(self.hovered_point)
elif self.main_buffer[0]:
self.main_buffer[1] = self.get_line_handle_from_ui_point(local_event_pos)
# Contrain point to line
if self.main_buffer[1]:
self.solv.midpoint(self.main_buffer[0], self.main_buffer[1], self.sketch.working_plane)
if self.solv.solve() == ResultFlag.OKAY:
print("Fuck yeah")
elif self.solv.solve() == ResultFlag.DIDNT_CONVERGE:
print("Solve_failed - Converge")
elif self.solv.solve() == ResultFlag.TOO_MANY_UNKNOWNS:
print("Solve_failed - Unknowns")
elif self.solv.solve() == ResultFlag.INCONSISTENT:
print("Solve_failed - Incons")
self.constrain_done.emit()
self.main_buffer = [None, None]
if event.button() == Qt.LeftButton and self.mouse_mode == "horiz":
line_selected = self.get_line_handle_from_ui_point(local_event_pos)
if line_selected:
self.solv.horizontal(line_selected, self.sketch.working_plane)
if self.solv.solve() == ResultFlag.OKAY:
print("Fuck yeah")
elif self.solv.solve() == ResultFlag.DIDNT_CONVERGE:
print("Solve_failed - Converge")
elif self.solv.solve() == ResultFlag.TOO_MANY_UNKNOWNS:
print("Solve_failed - Unknowns")
elif self.solv.solve() == ResultFlag.INCONSISTENT:
print("Solve_failed - Incons")
if event.button() == Qt.LeftButton and self.mouse_mode == "vert":
line_selected = self.get_line_handle_from_ui_point(local_event_pos)
if line_selected:
self.solv.vertical(line_selected, self.sketch.working_plane)
if self.solv.solve() == ResultFlag.OKAY:
print("Fuck yeah")
elif self.solv.solve() == ResultFlag.DIDNT_CONVERGE:
print("Solve_failed - Converge")
elif self.solv.solve() == ResultFlag.TOO_MANY_UNKNOWNS:
print("Solve_failed - Unknowns")
elif self.solv.solve() == ResultFlag.INCONSISTENT:
print("Solve_failed - Incons")
if event.button() == Qt.LeftButton and self.mouse_mode == "distance":
# Depending on selected elemnts either point line or line distance
#print("distance")
e1 = None
e2 = None
if self.hovered_point:
print("buf point")
# Get the point as UI point as buffer
self.main_buffer[0] = self.hovered_point
elif self.selected_line:
# Get the point as UI point as buffer
self.main_buffer[1] = local_event_pos
if self.main_buffer[0] and self.main_buffer[1]:
# Define point line combination
e1 = self.get_handle_from_ui_point(self.main_buffer[0])
e2 = self.get_line_handle_from_ui_point(self.main_buffer[1])
elif not self.main_buffer[0]:
# Define only line selection
e1, e2 = self.get_point_line_handles_from_ui_point(local_event_pos)
if e1 and e2:
# Ask fo the dimension and solve if both elements are present
length, ok = QInputDialog.getDouble(self, 'Distance', 'Enter a mm value:', value=100, decimals=2)
self.solv.distance(e1, e2, length, self.sketch.working_plane)
if self.solv.solve() == ResultFlag.OKAY:
print("Fuck yeah")
elif self.solv.solve() == ResultFlag.DIDNT_CONVERGE:
print("Solve_failed - Converge")
elif self.solv.solve() == ResultFlag.TOO_MANY_UNKNOWNS:
print("Solve_failed - Unknowns")
elif self.solv.solve() == ResultFlag.INCONSISTENT:
print("Solve_failed - Incons")
self.constrain_done.emit()
self.main_buffer = [None, None]
# Update the main point list with the new elements and draw them
points_need_update = self.check_all_points()
self.update_ui_points(points_need_update)
self.check_all_lines_and_update(points_need_update)
self.update()
def mouseMoveEvent(self, event):
local_event_pos = self.viewport_to_local_coord(event.pos())
closest_point = None
min_distance = float('inf')
threshold = 10 # Distance threshold for highlighting
if self.sketch:
for point in self.sketch.slv_points:
distance = (local_event_pos - point['ui_point']).manhattanLength()
if distance < threshold and distance < min_distance:
closest_point = point['ui_point']
min_distance = distance
for point in self.sketch.proj_points:
distance = (local_event_pos - point).manhattanLength()
if distance < threshold and distance < min_distance:
closest_point = point
min_distance = distance
if closest_point != self.hovered_point:
self.hovered_point = closest_point
print(self.hovered_point)
for dic in self.sketch.slv_lines:
p1 = dic['ui_points'][0]
p2 = dic['ui_points'][1]
if self.is_point_on_line(local_event_pos, p1, p2):
self.selected_line = p1, p2
break
else:
self.selected_line = None
self.update()
def mouseDoubleClickEvent(self, event):
pass
def drawBackgroundGrid(self, painter):
"""Draw a background grid."""
grid_spacing = 50
pen = QPen(QColor(200, 200, 200), 1, Qt.SolidLine)
painter.setPen(pen)
# Draw vertical grid lines
for x in range(-self.width() // 2, self.width() // 2, grid_spacing):
painter.drawLine(x, -self.height() // 2, x, self.height() // 2)
# Draw horizontal grid lines
for y in range(-self.height() // 2, self.height() // 2, grid_spacing):
painter.drawLine(-self.width() // 2, y, self.width() // 2, y)
def drawAxes(self, painter):
painter.setRenderHint(QPainter.Antialiasing)
# Set up pen for dashed lines
pen = QPen(Qt.gray, 1, Qt.DashLine)
painter.setPen(pen)
middle_x = self.width() // 2
middle_y = self.height() // 2
# Draw X axis as dashed line
painter.drawLine(0, middle_y, self.width(), middle_y)
# Draw Y axis as dashed line
painter.drawLine(middle_x, 0, middle_x, self.height())
# Draw tick marks
tick_length = int(10 * self.zoom)
tick_spacing = int(50 * self.zoom)
pen = QPen(Qt.gray, 1, Qt.SolidLine)
painter.setPen(pen)
# Draw tick marks on the X axis to the right and left from the middle point
for x in range(0, self.width() // 2, tick_spacing):
painter.drawLine(middle_x + x, middle_y - tick_length // 2, middle_x + x, middle_y + tick_length // 2)
painter.drawLine(middle_x - x, middle_y - tick_length // 2, middle_x - x, middle_y + tick_length // 2)
# Draw tick marks on the Y axis upwards and downwards from the middle point
for y in range(0, self.height() // 2, tick_spacing):
painter.drawLine(middle_x - tick_length // 2, middle_y + y, middle_x + tick_length // 2, middle_y + y)
painter.drawLine(middle_x - tick_length // 2, middle_y - y, middle_x + tick_length // 2, middle_y - y)
# Draw the origin point in red
painter.setPen(QPen(Qt.red, 4))
painter.drawPoint(middle_x, middle_y)
def draw_cross(self, painter, pos: QPoint, size=10):
# Set up the pen
pen = QPen(QColor('green')) # You can change the color as needed
pen.setWidth(int(2 / self.zoom)) # Set the line widt)h
painter.setPen(pen)
x = pos.x()
y = pos.y()
# Calculate the endpoints of the cross
half_size = size // 2
# Draw the horizontal line
painter.drawLine(x - half_size, y, x + half_size, y)
# Draw the vertical line
painter.drawLine(x, y - half_size, x, y + half_size)
def to_quadrant_coords(self, point):
"""Translate linear coordinates to quadrant coordinates."""
center_x = self.width() // 2
center_y = self.height() // 2
quadrant_x = point.x() - center_x
quadrant_y = center_y - point.y() # Note the change here
return QPoint(quadrant_x, quadrant_y) / self.zoom
def from_quadrant_coords(self, point: QPoint):
"""Translate quadrant coordinates to linear coordinates."""
center_x = self.width() // 2
center_y = self.height() // 2
widget_x = center_x + point.x() * self.zoom
widget_y = center_y - point.y() * self.zoom # Note the subtraction here
return QPoint(int(widget_x), int(widget_y))
def from_quadrant_coords_no_center(self, point):
"""Invert Y Coordinate for mesh"""
center_x = 0
center_y = 0
widget_x = point.x()
widget_y = -point.y()
return QPoint(int(widget_x), int(widget_y))
def paintEvent(self, event):
painter = QPainter(self)
painter.setRenderHint(QPainter.Antialiasing)
self.drawAxes(painter)
# Create a QTransform object
transform = QTransform()
# Translate the origin to the center of the widget
center = QPointF(self.width() / 2, self.height() / 2)
transform.translate(center.x(), center.y())
# Apply the zoom factor
transform.scale(self.zoom, -self.zoom) # Negative y-scale to invert y-axis
# Set the transform to the painter
painter.setTransform(transform)
pen = QPen(Qt.gray)
pen.setWidthF(2 / self.zoom)
painter.setPen(pen)
# Draw points
if self.sketch:
for point in self.sketch.slv_points:
painter.drawEllipse(point['ui_point'], 3 / self.zoom, 3 / self.zoom)
for dic in self.sketch.slv_lines:
p1 = dic['ui_points'][0]
p2 = dic['ui_points'][1]
painter.drawLine(p1, p2)
dis = self.distance(p1, p2)
mid = self.calculate_midpoint(p1, p2)
painter.drawText(mid, str(round(dis, 2)))
pen = QPen(Qt.green)
pen.setWidthF(2 / self.zoom)
painter.setPen(pen)
if self.solv.entity_len():
for i in range(self.solv.entity_len()):
entity = self.solv.entity(i)
if entity.is_point_2d() and self.solv.params(entity.params):
x, y = self.solv.params(entity.params)
point = QPointF(x, y)
painter.drawEllipse(point, 6 / self.zoom, 6 / self.zoom)
# Highlight point hovered
if self.hovered_point:
highlight_pen = QPen(QColor(255, 0, 0))
highlight_pen.setWidthF(2 / self.zoom)
painter.setPen(highlight_pen)
painter.drawEllipse(self.hovered_point, 5 / self.zoom, 5 / self.zoom)
# Highlight line hovered
if self.selected_line and not self.hovered_point:
p1, p2 = self.selected_line
painter.setPen(QPen(Qt.red, 2 / self.zoom))
painter.drawLine(p1, p2)
for cross in self.sketch.proj_points:
self.draw_cross(painter, cross, 10 / self.zoom)
for selected in self.sketch.proj_lines:
pen = QPen(Qt.white, 1, Qt.DashLine)
painter.setPen(pen)
painter.drawLine(selected)
painter.end()
def wheelEvent(self, event):
delta = event.angleDelta().y()
self.zoom += (delta / 200) * 0.1
self.update()
def aspect_ratio(self):
return self.width() / self.height() * (1.0 / abs(self.zoom))
class Point2D:
"""Improved oop aaproach?"""
def __init__(self):
self.ui_point = None
self.solve_handle_nr = None
self.solve_handle = None
self.part_of_entity = None
def to_quadrant_coords(self, point):
"""Translate linear coordinates to quadrant coordinates."""
center_x = self.width() // 2
center_y = self.height() // 2
quadrant_x = point.x() - center_x
quadrant_y = center_y - point.y() # Note the change here
return QPoint(quadrant_x, quadrant_y) / self.zoom
def from_quadrant_coords(self, point: QPoint):
"""Translate quadrant coordinates to linear coordinates."""
center_x = self.width() // 2
center_y = self.height() // 2
widget_x = center_x + point.x() * self.zoom
widget_y = center_y - point.y() * self.zoom # Note the subtraction here
return QPoint(int(widget_x), int(widget_y))
def from_quadrant_coords_no_center(self, point):
"""Invert Y Coordinate for mesh"""
center_x = 0
center_y = 0
widget_x = point.x()
widget_y = -point.y()
return QPoint(int(widget_x), int(widget_y))
def get_handle_nr(self, input_str: str) -> int:
# Define the regex pattern to extract the handle number
pattern = r"handle=(\d+)"
# Use re.search to find the handle number in the string
match = re.search(pattern, input_str)
if match:
handle_number = int(match.group(1))
print(f"Handle number: {handle_number}")
return int(handle_number)
else:
print("Handle number not found.")
return 0
def get_keys(self, d: dict, target: QPoint) -> list:
result = []
path = []
print(d)
print(target)
for k, v in d.items():
path.append(k)
if isinstance(v, dict):
self.get_keys(v, target)
if v == target:
result.append(copy(path))
path.pop()
return result
def get_handle_from_ui_point(self, ui_point: QPoint):
"""Input QPoint and you shall reveive a slvs entity handle!"""
for point in self.sketch.slv_points:
if ui_point == point['ui_point']:
slv_handle = point['solv_handle']
return slv_handle
def get_line_handle_from_ui_point(self, ui_point: QPoint):
"""Input Qpoint that is on a line and you shall receive the handle of the line!"""
for target_line_con in self.sketch.slv_lines:
if self.is_point_on_line(ui_point, target_line_con['ui_points'][0], target_line_con['ui_points'][1]):
slv_handle = target_line_con['solv_handle']
return slv_handle
def get_point_line_handles_from_ui_point(self, ui_point: QPoint) -> tuple:
"""Input Qpoint that is on a line and you shall receive the handles of the points of the line!"""
for target_line_con in self.sketch.slv_lines:
if self.is_point_on_line(ui_point, target_line_con['ui_points'][0], target_line_con['ui_points'][1]):
lines_to_cons = target_line_con['solv_entity_points']
return lines_to_cons
def distance(self, p1, p2):
return math.sqrt((p1.x() - p2.x())**2 + (p1.y() - p2.y())**2)
def calculate_midpoint(self, point1, point2):
mx = (point1.x() + point2.x()) // 2
my = (point1.y() + point2.y()) // 2
return QPoint(mx, my)
def is_point_on_line(self, p, p1, p2, tolerance=5):
# Calculate the lengths of the sides of the triangle
a = self.distance(p, p1)
b = self.distance(p, p2)
c = self.distance(p1, p2)
# Calculate the semi-perimeter
s = (a + b + c) / 2
# Calculate the area using Heron's formula
area = math.sqrt(s * (s - a) * (s - b) * (s - c))
# Calculate the height (perpendicular distance from the point to the line)
if c > 0:
height = (2 * area) / c
# Check if the height is within the tolerance distance to the line
if height > tolerance:
return False
# Check if the projection of the point onto the line is within the line segment
dot_product = ((p.x() - p1.x()) * (p2.x() - p1.x()) + (p.y() - p1.y()) * (p2.y() - p1.y())) / (c ** 2)
return 0 <= dot_product <= 1
else:
return None
def viewport_to_local_coord(self, qt_pos : QPoint) -> QPoint:
return QPoint(self.to_quadrant_coords(qt_pos))
class Line2D:
pass
class Sketch2d(SolverSystem):
if __name__ == "__main__":
import sys
app = QApplication(sys.argv)
window = SketchWidget()
window.setWindowTitle("Snap Line Widget")
window.resize(800, 600)
window.show()
sys.exit(app.exec())
File diff suppressed because it is too large Load Diff
-504
View File
@@ -1,504 +0,0 @@
import sys
import numpy as np
from PySide6.QtWidgets import QApplication, QMainWindow, QVBoxLayout, QWidget
from PySide6.QtOpenGLWidgets import QOpenGLWidget
from PySide6.QtCore import Qt, QPoint
from OpenGL.GL import *
from OpenGL.GLU import *
##testing
def create_cube(scale=1):
vertices = np.array([
[0, 0, 0],
[2, 0, 0],
[2, 2, 0],
[0, 2, 0],
[0, 0, 2],
[2, 0, 2],
[2, 2, 2],
[0, 2, 2]
]) * scale
faces = np.array([
[0, 1, 2],
[2, 3, 0],
[4, 5, 6],
[6, 7, 4],
[0, 1, 5],
[5, 4, 0],
[2, 3, 7],
[7, 6, 2],
[0, 3, 7],
[7, 4, 0],
[1, 2, 6],
[6, 5, 1]
])
return vertices, faces
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle("OpenGL Cube Viewer")
self.setGeometry(100, 100, 800, 600)
self.opengl_widget = OpenGLWidget()
central_widget = QWidget()
layout = QVBoxLayout()
layout.addWidget(self.opengl_widget)
central_widget.setLayout(layout)
self.setCentralWidget(central_widget)
# Load cube data
vertices, faces = create_cube()
self.opengl_widget.load_interactor_mesh((vertices, faces))
class OpenGLWidget(QOpenGLWidget):
def __init__(self, parent=None):
super().__init__(parent)
self.vertices = None
self.faces = None
self.selected_face = -1
self.scale_factor = 1
self.mesh_loaded = None
self.interactor_loaded = None
self.centroid = None
self.stl_file = "out.stl" # Replace with your STL file path
self.lastPos = QPoint()
self.startPos = None
self.endPos = None
self.xRot = 180
self.yRot = 0
self.zoom = -2
self.sketch = []
self.gl_width = self.width()
self.gl_height = self.height()
def map_value_to_range(self, value, value_min=0, value_max=1920, range_min=-1, range_max=1):
value = max(value_min, min(value_max, value))
mapped_value = ((value - value_min) / (value_max - value_min)) * (range_max - range_min) + range_min
return mapped_value
def load_stl(self, filename: str) -> object:
try:
stl_mesh = mesh.Mesh.from_file(filename)
# Extract vertices
vertices = np.concatenate([stl_mesh.v0, stl_mesh.v1, stl_mesh.v2])
# Calculate bounding box
min_x, min_y, min_z = vertices.min(axis=0)
max_x, max_y, max_z = vertices.max(axis=0)
# Calculate centroid
centroid_x = (min_x + max_x) / 2.0
centroid_y = (min_y + max_y) / 2.0
centroid_z = (min_z + max_z) / 2.0
self.mesh_loaded = stl_mesh.vectors
self.centroid = (centroid_x, centroid_y, centroid_z)
except FileNotFoundError:
print(f"Error: File {filename} not found.")
except Exception as e:
print(f"Error loading {filename}: {e}")
return None, (0, 0, 0)
def load_interactor_mesh(self, simp_mesh):
self.interactor_loaded = simp_mesh
# Calculate centroid based on the average position of vertices
centroid = np.mean(simp_mesh[0], axis=0)
self.centroid = tuple(centroid)
print(f"Centroid: {self.centroid}")
self.update()
def load_mesh_direct(self, mesh):
try:
stl_mesh = mesh
# Extract vertices
vertices = np.array(stl_mesh)
# Calculate centroid based on the average position of vertices
centroid = np.mean(vertices, axis=0)
self.mesh_loaded = vertices
self.centroid = tuple(centroid)
print(f"Centroid: {self.centroid}")
self.update()
except Exception as e:
print(e)
def clear_mesh(self):
self.mesh_loaded = None
def initializeGL(self):
glClearColor(0, 0, 0, 1)
glEnable(GL_DEPTH_TEST)
def resizeGL(self, width, height):
glViewport(0, 0, width, height)
glMatrixMode(GL_PROJECTION)
glLoadIdentity()
aspect = width / float(height)
self.gl_width = self.width()
self.gl_height = self.height()
gluPerspective(45.0, aspect, 0.01, 1000.0)
glMatrixMode(GL_MODELVIEW)
def unproject(self, x, y, z, modelview, projection, viewport):
mvp = np.dot(projection, modelview)
mvp_inv = np.linalg.inv(mvp)
ndc = np.array([(x - viewport[0]) / viewport[2] * 2 - 1,
(y - viewport[1]) / viewport[3] * 2 - 1,
2 * z - 1,
1])
world = np.dot(mvp_inv, ndc)
print("world undproj", world)
return world[:3] / world[3]
def draw_ray(self, ray_start, ray_end):
glColor3f(1.0, 0.0, 0.0) # Set the color of the ray (red)
glBegin(GL_LINES)
glVertex3f(*ray_start)
glVertex3f(*ray_end)
glEnd()
def mousePressEvent(self, event):
if event.buttons() & Qt.RightButton:
self.select_face(event)
def select_face(self, event):
x = event.position().x()
y = event.position().y()
modelview = glGetDoublev(GL_MODELVIEW_MATRIX)
projection = glGetDoublev(GL_PROJECTION_MATRIX)
viewport = glGetIntegerv(GL_VIEWPORT)
# Unproject near and far points in world space
ray_start = gluUnProject(x, y, 0.0, modelview, projection, viewport)
ray_end = gluUnProject(x, y, 1.0, modelview, projection, viewport)
ray_start = np.array(ray_start)
ray_end = np.array(ray_end)
ray_direction = ray_end - ray_start
ray_direction /= np.linalg.norm(ray_direction)
print(f"Ray start: {ray_start}")
print(f"Ray end: {ray_end}")
print(f"Ray direction: {ray_direction}")
self.selected_face = self.check_intersection(ray_start, ray_end)
print(f"Selected face: {self.selected_face}")
self.update()
def ray_box_intersection(self, ray_origin, ray_direction, box_min, box_max):
inv_direction = 1 / (ray_direction + 1e-7) # Add small value to avoid division by zero
t1 = (box_min - ray_origin) * inv_direction
t2 = (box_max - ray_origin) * inv_direction
t_min = np.max(np.minimum(t1, t2))
t_max = np.min(np.maximum(t1, t2))
print(f"min: {t_min}, max: {t_max}" )
return t_max >= t_min and t_max > 0
def check_intersection(self, ray_start, ray_end):
# Get the current modelview matrix
modelview = glGetDoublev(GL_MODELVIEW_MATRIX)
# Transform vertices to camera space
vertices_cam = [np.dot(modelview, np.append(v, 1))[:3] for v in self.interactor_loaded[0]]
ray_direction = ray_end - ray_start
ray_direction /= np.linalg.norm(ray_direction)
print(f"Checking intersection with {len(self.interactor_loaded[1])} faces")
for face_idx, face in enumerate(self.interactor_loaded[1]):
v0, v1, v2 = [vertices_cam[i] for i in face]
intersection = self.moller_trumbore(ray_start, ray_direction, v0, v1, v2)
if intersection is not None:
print(f"Intersection found with face {face_idx}")
return face_idx
print("No intersection found")
return None
def moller_trumbore(self, ray_origin, ray_direction, v0, v1, v2):
epsilon = 1e-6
# Find vectors for two edges sharing v0
edge1 = v1 - v0
edge2 = v2 - v0
pvec = np.cross(ray_direction, edge2)
det = np.dot(edge1, pvec)
print(det)
"""if det < epsilon:
return None"""
inv_det = 1.0 / det
tvec = ray_origin - v0
u = np.dot(tvec, pvec) * inv_det
print("u", u )
if u < 0.0 or u > 1.0:
return None
qvec = np.cross(tvec, edge1)
# Calculate v parameter and test bounds
v = np.dot(ray_direction, qvec) * inv_det
print("v", v)
if v < 0.0 or u + v > 1.0:
return None
# Calculate t, ray intersects triangle
t = np.dot(edge2, qvec) * inv_det
print("t",t)
if t > epsilon:
return ray_origin + t * ray_direction
return None
def ray_triangle_intersection(self, ray_origin, ray_direction, v0, v1, v2):
epsilon = 1e-5
edge1 = v1 - v0
edge2 = v2 - v0
h = np.cross(ray_direction, edge2)
a = np.dot(edge1, h)
print(f"Triangle vertices: {v0}, {v1}, {v2}")
print(f"a: {a}")
if abs(a) < epsilon:
print("Ray is parallel to the triangle")
return None # Ray is parallel to the triangle
f = 1.0 / a
s = ray_origin - v0
u = f * np.dot(s, h)
print(f"u: {u}")
if u < 0.0 or u > 1.0:
print("u is out of range")
return None
q = np.cross(s, edge1)
v = f * np.dot(ray_direction, q)
print(f"v: {v}")
if v < 0.0 or u + v > 1.0:
print("v is out of range")
return None
t = f * np.dot(edge2, q)
print(f"t: {t}")
if t > epsilon:
intersection_point = ray_origin + t * ray_direction
print(f"Intersection point: {intersection_point}")
return intersection_point
print("t is too small")
return None
def paintGL(self):
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT)
glMatrixMode(GL_MODELVIEW)
glLoadIdentity()
# Apply camera transformation
glTranslatef(0, 0, self.zoom)
glRotatef(self.xRot, 1.0, 0.0, 0.0)
glRotatef(self.yRot, 0.0, 1.0, 0.0)
"""# Apply model transformation
glTranslatef(self.tx, self.ty, self.tz)
glScalef(self.scale, self.scale, self.scale)
glRotatef(self.model_xRot, 1.0, 0.0, 0.0)
glRotatef(self.model_yRot, 0.0, 1.0, 0.0)
glRotatef(self.model_zRot, 0.0, 0.0, 1.0)"""
glColor3f(0.9, 0.8, 0.8)
self.draw_area()
if self.mesh_loaded is not None:
# Adjust the camera for the STL mesh
if self.centroid:
glPushMatrix() # Save current transformation matrix
glScalef(self.scale_factor, self.scale_factor, self.scale_factor) # Apply scaling
cx, cy, cz = self.centroid
gluLookAt(cx, cy, cz + 100, cx, cy, cz, 0, 1, 0)
self.draw_mesh_direct(self.mesh_loaded)
glPopMatrix() # Restore transformation matrix
if self.interactor_loaded is not None:
# Draw interactor mesh
glPushMatrix() # Save current transformation matrix
glScalef(self.scale_factor, self.scale_factor, self.scale_factor) # Apply scaling
self.draw_interactor(self.interactor_loaded)
glPopMatrix() # Restore transformation matrix
if self.selected_face is not None:
glColor3f(0.0, 1.0, 0.0) # Red color for selected face
glBegin(GL_TRIANGLES)
for vertex_idx in self.interactor_loaded[1][self.selected_face]:
glVertex3fv(self.interactor_loaded[0][vertex_idx])
glEnd()
# Flush the OpenGL pipeline and swap buffers
if hasattr(self, 'ray_start') and hasattr(self, 'ray_end'):
self.draw_ray(self.ray_start, self.ray_end)
glFlush()
def draw_stl(self, vertices):
glEnable(GL_LIGHTING)
glEnable(GL_LIGHT0)
glEnable(GL_DEPTH_TEST)
glEnable(GL_COLOR_MATERIAL)
glColorMaterial(GL_FRONT_AND_BACK, GL_AMBIENT_AND_DIFFUSE)
glLightfv(GL_LIGHT0, GL_POSITION, (0, 1, 1, 0))
glLightfv(GL_LIGHT0, GL_DIFFUSE, (0.6, 0.6, 0.6, 1.0))
glBegin(GL_TRIANGLES)
for triangle in vertices:
for vertex in triangle:
glVertex3fv(vertex)
glEnd()
self.update()
def draw_interactor(self, simp_mesh: tuple):
vertices, faces = simp_mesh
glEnable(GL_LIGHTING)
glEnable(GL_LIGHT0)
glEnable(GL_DEPTH_TEST)
glEnable(GL_COLOR_MATERIAL)
glColorMaterial(GL_FRONT_AND_BACK, GL_AMBIENT_AND_DIFFUSE)
glLightfv(GL_LIGHT0, GL_POSITION, (0, 0.6, 0.6, 0))
glLightfv(GL_LIGHT0, GL_DIFFUSE, (0.4, 0.4, 0.4, 0.6))
# Draw the faces
glDisable(GL_LIGHTING)
glColor3f(0.2, 0.0, 0.0) # Set face color to red (or any color you prefer)
glBegin(GL_TRIANGLES)
for face in faces:
for vertex_index in face:
glVertex3fv(vertices[vertex_index])
glEnd()
# Draw the lines (edges of the triangles)
glColor3f(0.0, 1.0, 0.0) # Set line color to green (or any color you prefer)
glBegin(GL_LINES)
for face in faces:
for i in range(len(face)):
glVertex3fv(vertices[face[i]])
glVertex3fv(vertices[face[(i + 1) % len(face)]])
glEnd()
glEnable(GL_LIGHTING) # Re-enable lighting if further drawing requires it
def draw_mesh_direct(self, points):
glEnable(GL_LIGHTING)
glEnable(GL_LIGHT0)
glEnable(GL_DEPTH_TEST)
glEnable(GL_COLOR_MATERIAL)
glColorMaterial(GL_FRONT_AND_BACK, GL_AMBIENT_AND_DIFFUSE)
glLightfv(GL_LIGHT0, GL_POSITION, (0, 0.6, 0.6, 0))
glLightfv(GL_LIGHT0, GL_DIFFUSE, (0.4, 0.4, 0.4, 0.6))
glDisable(GL_LIGHTING)
glBegin(GL_TRIANGLES)
for vertex in points:
glVertex3fv(vertex)
glEnd()
# Draw the lines (edges of the triangles)
#glDisable(GL_LIGHTING) # Disable lighting to avoid affecting the line color
glColor3f(0.0, 0.0, 0.0) # Set line color to black (or any color you prefer)
glBegin(GL_LINES)
for i in range(0, len(points), 3):
glVertex3fv(points[i])
glVertex3fv(points[i + 1])
glVertex3fv(points[i + 1])
glVertex3fv(points[i + 2])
glVertex3fv(points[i + 2])
glVertex3fv(points[i])
glEnd()
glEnable(GL_LIGHTING) # Re-enable lighting if further drawing requires it
def draw_area(self):
glColor3f(0.5, 0.5, 0.5) # Gray color
glBegin(GL_LINES)
for x in range(0, self.width(), 1):
x_ndc = self.map_value_to_range(x, 0, value_max=self.width(), range_min=-self.gl_width, range_max=self.gl_width)
glVertex2f(x_ndc, -self.gl_height) # Start from y = -1
glVertex2f(x_ndc, self.gl_height) # End at y = 1
for y in range(0, self.height(), 1):
y_ndc = self.map_value_to_range(y, 0, value_max=self.height(), range_min=-self.gl_height, range_max=self.gl_height)
glVertex2f(-self.gl_width, y_ndc) # Start from x = -1
glVertex2f(self.gl_width, y_ndc) # End at x = 1
glEnd()
def mouseMoveEvent(self, event):
dx = event.x() - self.lastPos.x()
dy = event.y() - self.lastPos.y()
if event.buttons() & Qt.MouseButton.LeftButton :
self.xRot += 0.5 * dy
self.yRot += 0.5 * dx
self.lastPos = event.pos()
self.update()
def wheelEvent(self, event):
delta = event.angleDelta().y()
self.zoom += delta / 200
self.update()
def aspect_ratio(self):
return self.width() / self.height() * (1.0 / abs(self.zoom))
if __name__ == "__main__":
app = QApplication(sys.argv)
window = MainWindow()
window.show()
sys.exit(app.exec())
File diff suppressed because it is too large Load Diff
@@ -1,201 +0,0 @@
"""
Example integration of the improved sketcher with the main Fluency application
This shows how to replace the existing sketcher with the improved version
"""
from PySide6.QtWidgets import QApplication, QMainWindow, QVBoxLayout, QHBoxLayout, QWidget, QPushButton, QButtonGroup
from PySide6.QtCore import Qt
from improved_sketcher import ImprovedSketchWidget, SketchMode, SnapMode
class SketcherIntegrationDemo(QMainWindow):
"""Demo showing how to integrate the improved sketcher with UI controls"""
def __init__(self):
super().__init__()
self.setWindowTitle("Improved Sketcher Integration Demo")
self.resize(1200, 800)
# Create central widget
central_widget = QWidget()
self.setCentralWidget(central_widget)
# Create layout
main_layout = QHBoxLayout(central_widget)
# Create toolbar
self.create_toolbar(main_layout)
# Create sketcher widget
self.sketcher = ImprovedSketchWidget()
main_layout.addWidget(self.sketcher, stretch=1)
# Connect sketcher signals
self.connect_sketcher_signals()
# Set initial mode
self.sketcher.set_mode(SketchMode.LINE)
def create_toolbar(self, parent_layout):
"""Create toolbar with sketching tools"""
toolbar_widget = QWidget()
toolbar_widget.setFixedWidth(200)
toolbar_layout = QVBoxLayout(toolbar_widget)
# Drawing tools group
drawing_group = QWidget()
drawing_layout = QVBoxLayout(drawing_group)
drawing_layout.addWidget(self.create_label("Drawing Tools"))
# Create drawing mode buttons
self.drawing_buttons = QButtonGroup(self)
self.drawing_buttons.setExclusive(True)
drawing_modes = [
("Line", SketchMode.LINE),
("Rectangle", SketchMode.RECTANGLE),
("Circle", SketchMode.CIRCLE),
("Point", SketchMode.POINT),
]
for name, mode in drawing_modes:
button = QPushButton(name)
button.setCheckable(True)
button.clicked.connect(lambda checked, m=mode: self.set_drawing_mode(m))
self.drawing_buttons.addButton(button)
drawing_layout.addWidget(button)
# Set line as default
self.drawing_buttons.buttons()[0].setChecked(True)
# Constraint tools group
constraint_group = QWidget()
constraint_layout = QVBoxLayout(constraint_group)
constraint_layout.addWidget(self.create_label("Constraints"))
# Create constraint buttons
constraint_modes = [
("Coincident", SketchMode.COINCIDENT_PT_PT),
("Horizontal", SketchMode.HORIZONTAL),
("Vertical", SketchMode.VERTICAL),
("Distance", SketchMode.DISTANCE),
]
for name, mode in constraint_modes:
button = QPushButton(name)
button.clicked.connect(lambda checked, m=mode: self.set_constraint_mode(m))
constraint_layout.addWidget(button)
# Settings group
settings_group = QWidget()
settings_layout = QVBoxLayout(settings_group)
settings_layout.addWidget(self.create_label("Settings"))
# Construction mode toggle
self.construction_button = QPushButton("Construction Mode")
self.construction_button.setCheckable(True)
self.construction_button.toggled.connect(self.toggle_construction_mode)
settings_layout.addWidget(self.construction_button)
# Snap settings
snap_buttons = [
("Point Snap", SnapMode.POINT),
("Grid Snap", SnapMode.GRID),
("Midpoint Snap", SnapMode.MIDPOINT),
]
for name, snap_mode in snap_buttons:
button = QPushButton(name)
button.setCheckable(True)
button.toggled.connect(lambda checked, sm=snap_mode: self.toggle_snap_mode(sm, checked))
settings_layout.addWidget(button)
# Set default snaps
settings_layout.itemAt(1).widget().setChecked(True) # Point snap on by default
# View controls
view_group = QWidget()
view_layout = QVBoxLayout(view_group)
view_layout.addWidget(self.create_label("View"))
zoom_fit_button = QPushButton("Zoom to Fit")
zoom_fit_button.clicked.connect(self.sketcher.zoom_to_fit)
view_layout.addWidget(zoom_fit_button)
# Add groups to toolbar
toolbar_layout.addWidget(drawing_group)
toolbar_layout.addWidget(constraint_group)
toolbar_layout.addWidget(settings_group)
toolbar_layout.addWidget(view_group)
toolbar_layout.addStretch()
parent_layout.addWidget(toolbar_widget)
def create_label(self, text):
"""Create a section label"""
from PySide6.QtWidgets import QLabel
from PySide6.QtCore import Qt
label = QLabel(text)
label.setAlignment(Qt.AlignCenter)
label.setStyleSheet("font-weight: bold; padding: 5px; background-color: #333; color: white;")
return label
def set_drawing_mode(self, mode):
"""Set the sketcher to drawing mode"""
self.sketcher.set_mode(mode)
print(f"Drawing mode set to: {mode.name}")
def set_constraint_mode(self, mode):
"""Set the sketcher to constraint mode"""
self.sketcher.set_mode(mode)
# Uncheck all drawing buttons when in constraint mode
for button in self.drawing_buttons.buttons():
button.setChecked(False)
print(f"Constraint mode set to: {mode.name}")
def toggle_construction_mode(self, checked):
"""Toggle construction geometry mode"""
self.sketcher.set_construction_mode(checked)
print(f"Construction mode: {'enabled' if checked else 'disabled'}")
def toggle_snap_mode(self, snap_mode, enabled):
"""Toggle snap mode"""
self.sketcher.toggle_snap_mode(snap_mode, enabled)
print(f"Snap mode {snap_mode.name}: {'enabled' if enabled else 'disabled'}")
def connect_sketcher_signals(self):
"""Connect to sketcher signals for feedback"""
self.sketcher.geometry_created.connect(self.on_geometry_created)
self.sketcher.constraint_applied.connect(self.on_constraint_applied)
self.sketcher.sketch_modified.connect(self.on_sketch_modified)
def on_geometry_created(self, geometry_type):
"""Handle geometry creation"""
print(f"Created: {geometry_type}")
# Update status or trigger other actions
def on_constraint_applied(self):
"""Handle constraint application"""
print("Constraint applied successfully")
# Return to line drawing mode after constraint
self.sketcher.set_mode(SketchMode.LINE)
self.drawing_buttons.buttons()[0].setChecked(True)
def on_sketch_modified(self):
"""Handle sketch modifications"""
print("Sketch modified")
# Could trigger auto-save or update displays
def replace_sketcher_in_main_app():
"""
Example of how to replace the existing sketcher in main.py
In main.py, replace this code:
```python\n from drawing_modules.draw_widget_solve import SketchWidget\n self.sketchWidget = SketchWidget()\n ```\n \n With:\n \n ```python\n from drawing_modules.improved_sketcher import ImprovedSketchWidget, SketchMode\n self.sketchWidget = ImprovedSketchWidget()\n \n # Connect to existing signals (adapt as needed)\n self.sketchWidget.constraint_applied.connect(self.draw_op_complete)\n self.sketchWidget.sketch_modified.connect(self.on_sketch_changed)\n \n # Connect toolbar buttons to new sketcher modes\n self.ui.pb_linetool.clicked.connect(lambda: self.sketchWidget.set_mode(SketchMode.LINE))\n self.ui.pb_rectool.clicked.connect(lambda: self.sketchWidget.set_mode(SketchMode.RECTANGLE))\n # ... etc for other buttons\n ```\n \n The improved sketcher provides these advantages:\n \n 1. **Better Architecture**: Clean separation of concerns, proper error handling\n 2. **Enhanced Features**: Rectangle and circle tools, improved constraints\n 3. **Better Performance**: Optimized rendering and interaction handling\n 4. **Extensibility**: Easy to add new tools and constraints\n 5. **Type Safety**: Proper type hints and validation\n 6. **Logging**: Built-in logging for debugging\n 7. **Settings**: Configurable snap and render settings\n \n Key differences to adapt:\n \n - Use SketchMode enum instead of string modes\n - Connect to new signal names (constraint_applied, geometry_created, sketch_modified)\n - Use set_mode() instead of individual mode methods\n - Access sketch data through self.sketch property\n - Use new geometry classes (Point2D, Line2D, Circle2D)\n """\n pass
if __name__ == "__main__":\n import sys\n \n app = QApplication(sys.argv)\n \n # Create and show the integration demo\n demo = SketcherIntegrationDemo()\n demo.show()\n \n print("Improved Sketcher Integration Demo")\n print("==================================")\n print("Features:")\n print("- Line, Rectangle, Circle, Point drawing")\n print("- Coincident, Horizontal, Vertical, Distance constraints")\n print("- Construction geometry mode")\n print("- Point, Grid, Midpoint snapping")\n print("- Zoom to fit")\n print("- Mouse wheel zoom")\n print("- Right-click to cancel operations")\n print("")\n print("Usage:")\n print("- Select a drawing tool and click in the viewport")\n print("- Right-click to finish multi-point operations")\n print("- Use constraint tools to add relationships")\n print("- Toggle construction mode for helper geometry")\n \n sys.exit(app.exec())
-35
View File
@@ -1,35 +0,0 @@
from python_solvespace import SolverSystem, ResultFlag
def solve_constraint():
solv = SolverSystem()
wp = solv.create_2d_base() # Workplane (Entity)
p0 = solv.add_point_2d(0, 0, wp) # Entity
p1 = solv.add_point_2d(10, 10, wp) # Entity
p2 = solv.add_point_2d(0, 10, wp) # Entity
solv.dragged(p0, wp) # Make a constraint with the entity
line0 = solv.add_line_2d(p0, p1, wp) # Create entity with others
line1 = solv.add_line_2d(p0, p2, wp)
#solv.angle(line0, line1, 45, wp) # Constrain two entities
solv.coincident(p0, p1, wp)
solv.add_constraint(100006, wp, 0, p1,p2, line0, line1)
line1 = solv.entity(-1) # Entity handle can be re-generated and negatively indexed
solv.
if solv.solve() == ResultFlag.OKAY:
# Get the result (unpack from the entity or parameters)
# x and y are actually float type
dof = solv.dof()
x, y = solv.params(p1.params)
print(dof)
print(x)
print(y)
else:
# Error!
# Get the list of all constraints
failures = solv.failures()
print(failures)
...
solve_constraint()
-861
View File
@@ -1,861 +0,0 @@
import sys
import numpy as np
import vtk
from PySide6 import QtCore, QtWidgets
from PySide6.QtCore import Signal
from vtkmodules.qt.QVTKRenderWindowInteractor import QVTKRenderWindowInteractor
from vtkmodules.util.numpy_support import vtk_to_numpy, numpy_to_vtk
class VTKWidget(QtWidgets.QWidget):
face_data = Signal(dict)
def __init__(self, parent=None):
super().__init__(parent)
self.selected_vtk_line = []
self.access_selected_points = []
self.selected_normal = None
self.centroid = None
self.selected_edges = []
self.cell_normals = None
self.local_matrix = None
self.project_tosketch_points = []
self.project_tosketch_lines = []
self.vtk_widget = QVTKRenderWindowInteractor(self)
self.picked_edge_actors = []
self.displayed_normal_actors = []
self.body_actors_orig = []
self.projected_mesh_actors = []
self.interactor_actors = []
self.flip_toggle = False
# Create layout and add VTK widget
layout = QtWidgets.QVBoxLayout()
layout.addWidget(self.vtk_widget)
self.setLayout(layout)
# Create VTK pipeline
self.renderer = vtk.vtkRenderer()
self.renderer_projections = vtk.vtkRenderer()
self.renderer_indicators = vtk.vtkRenderer()
self.renderer.SetViewport(0, 0, 1, 1) # Full viewport
self.renderer_projections.SetViewport(0, 0, 1, 1) # Full viewport, overlays the first
self.renderer_indicators.SetViewport(0, 0, 1, 1) # Full viewport, overlays the first
self.renderer.SetLayer(0)
self.renderer_projections.SetLayer(1)
self.renderer_indicators.SetLayer(2) # This will be on top
# Preserve color and depth buffers for non-zero layers
self.renderer_projections.SetPreserveColorBuffer(True)
self.renderer_projections.SetPreserveDepthBuffer(True)
self.renderer_indicators.SetPreserveColorBuffer(True)
self.renderer_indicators.SetPreserveDepthBuffer(True)
# Add renderers to the render window
render_window = self.vtk_widget.GetRenderWindow()
render_window.SetNumberOfLayers(3)
render_window.AddRenderer(self.renderer)
render_window.AddRenderer(self.renderer_projections)
render_window.AddRenderer(self.renderer_indicators)
self.camera = vtk.vtkCamera()
self.camera.SetPosition(5, 5, 1000)
self.camera.SetFocalPoint(0, 0, 0)
self.camera.SetClippingRange(1, 10000) # Adjusted clipping range
self.renderer.SetActiveCamera(self.camera)
self.renderer_projections.SetActiveCamera(self.camera)
self.renderer_indicators.SetActiveCamera(self.camera)
self.interactor = self.vtk_widget.GetRenderWindow().GetInteractor()
# Light Setup
def add_light(renderer, position, color=(1, 1, 1), intensity=1.0):
light = vtk.vtkLight()
light.SetPosition(position)
light.SetColor(color)
light.SetIntensity(intensity)
renderer.AddLight(light)
# Add lights from multiple directions
add_light(self.renderer, (1000, 0, 0), intensity=1.5)
add_light(self.renderer, (-1000, 0, 0), intensity=1.5)
add_light(self.renderer, (0, 1000, 0), intensity=1.5)
add_light(self.renderer, (0, -1000, 0), intensity=1.5)
add_light(self.renderer, (0, 0, 1000), intensity=1.5)
add_light(self.renderer, (0, 0, -1000), intensity=1.5)
# Set up picking
self.picker = vtk.vtkCellPicker()
self.picker.SetTolerance(0.005)
# Create a mapper and actor for picked cells
self.picked_mapper = vtk.vtkDataSetMapper()
self.picked_actor = vtk.vtkActor()
self.picked_actor.SetMapper(self.picked_mapper)
self.picked_actor.GetProperty().SetColor(1.0, 0.0, 0.0) # Red color for picked faces
self.picked_actor.VisibilityOff() # Initially hide the actor
self.renderer.AddActor(self.picked_actor)
# Create an extract selection filter
self.extract_selection = vtk.vtkExtractSelection()
# Set up interactor style
self.style = vtk.vtkInteractorStyleTrackballCamera()
self.interactor.SetInteractorStyle(self.style)
# Add observer for mouse clicks
self.interactor.AddObserver("RightButtonPressEvent", self.on_click)
# Add axis gizmo (smaller size)
self.axes = vtk.vtkAxesActor()
self.axes.SetTotalLength(0.5, 0.5, 0.5) # Reduced size
self.axes.SetShaftType(0)
self.axes.SetAxisLabels(1)
# Create an orientation marker
self.axes_widget = vtk.vtkOrientationMarkerWidget()
self.axes_widget.SetOrientationMarker(self.axes)
self.axes_widget.SetInteractor(self.interactor)
self.axes_widget.SetViewport(0.0, 0.0, 0.2, 0.2) # Set position and size
self.axes_widget.EnabledOn()
self.axes_widget.InteractiveOff()
# Start the interactor
self.interactor.Initialize()
self.interactor.Start()
# Create the grid
grid = self.create_grid(size=100, spacing=10)
# Setup actor and mapper
mapper = vtk.vtkPolyDataMapper()
mapper.SetInputData(grid)
actor = vtk.vtkActor()
actor.SetPickable(False)
actor.SetMapper(mapper)
actor.GetProperty().SetColor(0.5, 0.5, 0.5) # Set grid color to gray
self.renderer.AddActor(actor)
def reset_camera(self):
self.renderer.ResetCamera()
self.camera.SetClippingRange(1, 100000) # Set your desired range
self.vtk_widget.GetRenderWindow().Render()
def update_render(self):
self.renderer.ResetCameraClippingRange()
self.renderer_projections.ResetCameraClippingRange()
self.renderer_indicators.ResetCameraClippingRange()
self.camera.SetClippingRange(1, 100000)
self.vtk_widget.GetRenderWindow().Render()
def create_grid(self, size=100, spacing=10):
# Create a vtkPoints object and store the points in it
points = vtk.vtkPoints()
# Create lines
lines = vtk.vtkCellArray()
# Create the grid
for i in range(-size, size + 1, spacing):
# X-direction line
points.InsertNextPoint(i, -size, 0)
points.InsertNextPoint(i, size, 0)
line = vtk.vtkLine()
line.GetPointIds().SetId(0, points.GetNumberOfPoints() - 2)
line.GetPointIds().SetId(1, points.GetNumberOfPoints() - 1)
lines.InsertNextCell(line)
# Y-direction line
points.InsertNextPoint(-size, i, 0)
points.InsertNextPoint(size, i, 0)
line = vtk.vtkLine()
line.GetPointIds().SetId(0, points.GetNumberOfPoints() - 2)
line.GetPointIds().SetId(1, points.GetNumberOfPoints() - 1)
lines.InsertNextCell(line)
# Create a polydata to store everything in
grid = vtk.vtkPolyData()
# Add the points to the dataset
grid.SetPoints(points)
# Add the lines to the dataset
grid.SetLines(lines)
return grid
def on_receive_command(self, command):
"""Calls the individual commands pressed in main"""
print("Receive command: ", command)
if command == "flip":
self.clear_actors_projection()
self.flip_toggle = not self.flip_toggle # Toggle the flag
self.on_invert_normal()
@staticmethod
def compute_normal_from_lines(line1, line2):
vec1 = line1[1] - line1[0]
vec2 = line2[1] - line2[0]
normal = np.cross(vec1, vec2)
print(normal)
normal = normal / np.linalg.norm(normal)
return normal
def load_interactor_mesh(self, edges, off_vector):
# Create vtkPoints to store all points
points = vtk.vtkPoints()
# Create vtkCellArray to store the lines
lines = vtk.vtkCellArray()
for edge in edges:
# Add points for this edge
point_id1 = points.InsertNextPoint(edge[0])
point_id2 = points.InsertNextPoint(edge[1])
# Create a line using the point IDs
line = vtk.vtkLine()
line.GetPointIds().SetId(0, point_id1)
line.GetPointIds().SetId(1, point_id2)
# Add the line to the cell array
lines.InsertNextCell(line)
# Create vtkPolyData to store the geometry
polydata = vtk.vtkPolyData()
polydata.SetPoints(points)
polydata.SetLines(lines)
# Create a transform for mirroring across the y-axis
matrix_transform = vtk.vtkTransform()
if self.local_matrix:
print(self.local_matrix)
matrix = vtk.vtkMatrix4x4()
matrix.DeepCopy(self.local_matrix)
matrix.Invert()
matrix_transform.SetMatrix(matrix)
#matrix_transform.Scale(1, 1, 1) # This mirrors across the y-axis
# Apply the matrix transform
transformFilter = vtk.vtkTransformPolyDataFilter()
transformFilter.SetInputData(polydata)
transformFilter.SetTransform(matrix_transform)
transformFilter.Update()
# Create and apply the offset transform
offset_transform = vtk.vtkTransform()
offset_transform.Translate(off_vector[0], off_vector[1], off_vector[2])
offsetFilter = vtk.vtkTransformPolyDataFilter()
offsetFilter.SetInputConnection(transformFilter.GetOutputPort())
offsetFilter.SetTransform(offset_transform)
offsetFilter.Update()
# Create a mapper and actor
mapper = vtk.vtkPolyDataMapper()
mapper.SetInputConnection(offsetFilter.GetOutputPort())
actor = vtk.vtkActor()
actor.SetMapper(mapper)
actor.GetProperty().SetColor(1.0, 1.0, 1.0)
actor.GetProperty().SetLineWidth(4) # Set line width
# Add the actor to the scene
self.renderer.AddActor(actor)
self.interactor_actors.append(actor)
mapper.Update()
self.vtk_widget.GetRenderWindow().Render()
def render_from_points_direct_with_faces(self, vertices, faces, color=(0.1, 0.2, 0.8), line_width=2, point_size=5):
"""Sketch Widget has inverted Y axiis therefore we invert y via scale here until fix"""
# Handle empty vertices or faces
if len(vertices) == 0 or len(faces) == 0:
print("Warning: No vertices or faces to render")
return
points = vtk.vtkPoints()
# Validate vertices shape
if vertices.ndim != 2 or vertices.shape[1] != 3:
print(f"Warning: Invalid vertex shape {vertices.shape}. Expected Nx3.")
return
# Validate faces shape
if faces.ndim != 2 or faces.shape[1] != 3:
print(f"Warning: Invalid face shape {faces.shape}. Expected Nx3.")
return
# Use SetData with numpy array - ensure vertices are float32
try:
vertices_float = np.asarray(vertices, dtype=np.float32)
vtk_array = numpy_to_vtk(vertices_float, deep=True)
points.SetData(vtk_array)
except Exception as e:
print(f"Error converting vertices to VTK array: {e}")
# Fallback: manually insert points
for vertex in vertices:
points.InsertNextPoint(vertex[0], vertex[1], vertex[2])
# Create a vtkCellArray to store the triangles
triangles = vtk.vtkCellArray()
num_vertices = len(vertices)
for i, face in enumerate(faces):
# Validate face indices
if (face[0] >= num_vertices or face[0] < 0 or
face[1] >= num_vertices or face[1] < 0 or
face[2] >= num_vertices or face[2] < 0):
print(f"Warning: Invalid face indices {face} at index {i}. Skipping face.")
continue
triangle = vtk.vtkTriangle()
triangle.GetPointIds().SetId(0, int(face[0]))
triangle.GetPointIds().SetId(1, int(face[1]))
triangle.GetPointIds().SetId(2, int(face[2]))
triangles.InsertNextCell(triangle)
# Check if we have any valid triangles
if triangles.GetNumberOfCells() == 0:
print("Warning: No valid triangles to render")
return
# Create a polydata object
polydata = vtk.vtkPolyData()
polydata.SetPoints(points)
polydata.SetPolys(triangles)
# Calculate normals
normalGenerator = vtk.vtkPolyDataNormals()
normalGenerator.SetInputData(polydata)
normalGenerator.ComputePointNormalsOn()
normalGenerator.ComputeCellNormalsOn()
normalGenerator.Update()
# Safely get cell normals, with fallback if they're not available
cell_normals = normalGenerator.GetOutput().GetCellData().GetNormals()
if cell_normals:
try:
self.cell_normals = vtk_to_numpy(cell_normals)
except Exception as e:
print(f"Warning: Could not convert cell normals to numpy array: {e}")
self.cell_normals = None
else:
print("Warning: No cell normals available")
self.cell_normals = None
# Create a mapper and actor
mapper = vtk.vtkPolyDataMapper()
mapper.SetInputData(polydata)
actor = vtk.vtkActor()
actor.SetMapper(mapper)
actor.GetProperty().SetColor(color)
actor.GetProperty().EdgeVisibilityOff()
actor.GetProperty().SetLineWidth(line_width)
actor.GetProperty().SetMetallic(1)
actor.GetProperty().SetOpacity(0.8)
actor.SetPickable(False)
self.renderer.AddActor(actor)
self.body_actors_orig.append(actor)
self.vtk_widget.GetRenderWindow().Render()
def clear_body_actors(self):
for actor in self.body_actors_orig:
self.renderer.RemoveActor(actor)
def visualize_matrix(self, matrix):
points = vtk.vtkPoints()
for i in range(4):
for j in range(4):
points.InsertNextPoint(matrix.GetElement(0, j),
matrix.GetElement(1, j),
matrix.GetElement(2, j))
polydata = vtk.vtkPolyData()
polydata.SetPoints(points)
mapper = vtk.vtkPolyDataMapper()
mapper.SetInputData(polydata)
actor = vtk.vtkActor()
actor.SetMapper(mapper)
actor.GetProperty().SetPointSize(5)
self.renderer.AddActor(actor)
def numpy_to_vtk(self, array, deep=True):
"""Convert a numpy array to a vtk array."""
vtk_array = vtk.vtkDoubleArray()
vtk_array.SetNumberOfComponents(array.shape[1])
vtk_array.SetNumberOfTuples(array.shape[0])
for i in range(array.shape[0]):
for j in range(array.shape[1]):
vtk_array.SetComponent(i, j, array[i, j])
return vtk_array
def get_points_and_edges_from_polydata(self, polydata) -> list:
# Extract points
points = {}
vtk_points = polydata.GetPoints()
for i in range(vtk_points.GetNumberOfPoints()):
point = vtk_points.GetPoint(i)
points[i] = np.array(point)
# Extract edges
edges = []
for i in range(polydata.GetNumberOfCells()):
cell = polydata.GetCell(i)
if cell.GetCellType() == vtk.VTK_LINE:
point_ids = cell.GetPointIds()
edge = (point_ids.GetId(0), point_ids.GetId(1))
edges.append(edge)
return points, edges
def project_mesh_to_plane(self, input_mesh, normal, origin):
# Create the projector
projector = vtk.vtkProjectPointsToPlane()
projector.SetInputData(input_mesh)
projector.SetProjectionTypeToSpecifiedPlane()
# Set the normal and origin of the plane
projector.SetNormal(normal)
projector.SetOrigin(origin)
# Execute the projection
projector.Update()
# Get the projected mesh
projected_mesh = projector.GetOutput()
return projected_mesh
def compute_2d_coordinates(self, projected_mesh, normal):
# Normalize the normal vector
normal = np.array(normal)
normal = normal / np.linalg.norm(normal)
# Create a vtkTransform
transform = vtk.vtkTransform()
transform.PostMultiply() # This ensures transforms are applied in the order we specify
# Rotate so that the normal aligns with the Z-axis
rotation_axis = np.cross(normal, [0, 0, 1])
angle = np.arccos(np.dot(normal, [0, 0, 1])) * 180 / np.pi # Convert to degrees
if np.linalg.norm(rotation_axis) > 1e-6: # Check if rotation is needed
transform.RotateWXYZ(angle, rotation_axis[0], rotation_axis[1], rotation_axis[2])
# Get the transformation matrix
matrix = transform.GetMatrix()
self.local_matrix = [matrix.GetElement(i, j) for i in range(4) for j in range(4)]
# Apply the transform to the polydata
transformFilter = vtk.vtkTransformPolyDataFilter()
transformFilter.SetInputData(projected_mesh)
transformFilter.SetTransform(transform)
transformFilter.Update()
# Get the transformed points
transformed_polydata = transformFilter.GetOutput()
points = transformed_polydata.GetPoints()
# Extract 2D coordinates
xy_coordinates = []
for i in range(points.GetNumberOfPoints()):
point = points.GetPoint(i)
xy_coordinates.append((point[0], point[1]))
return xy_coordinates
def compute_2d_coordinates_line(self, projected_mesh, normal):
# Normalize the normal vector
normal = np.array(normal)
normal = normal / np.linalg.norm(normal)
# Create a vtkTransform
transform = vtk.vtkTransform()
transform.PostMultiply() # This ensures transforms are applied in the order we specify
# Rotate so that the normal aligns with the Z-axis
rotation_axis = np.cross(normal, [0, 0, 1])
angle = np.arccos(np.dot(normal, [0, 0, 1])) * 180 / np.pi # Convert to degrees
if np.linalg.norm(rotation_axis) > 1e-6: # Check if rotation is needed
transform.RotateWXYZ(angle, rotation_axis[0], rotation_axis[1], rotation_axis[2])
# Get the transformation matrix
matrix = transform.GetMatrix()
self.local_matrix = [matrix.GetElement(i, j) for i in range(4) for j in range(4)]
# Apply the transform to the polydata
transformFilter = vtk.vtkTransformPolyDataFilter()
transformFilter.SetInputData(projected_mesh)
transformFilter.SetTransform(transform)
transformFilter.Update()
# Get the transformed points
transformed_polydata = transformFilter.GetOutput()
points = transformed_polydata.GetPoints()
lines = transformed_polydata.GetLines()
# Extract 2D coordinates
xy_coordinates = []
if points and lines:
points_data = points.GetData()
line_ids = vtk.vtkIdList()
# Loop through all the lines in the vtkCellArray
lines.InitTraversal()
while lines.GetNextCell(line_ids):
line_coordinates = []
for j in range(line_ids.GetNumberOfIds()):
point_id = line_ids.GetId(j)
point = points.GetPoint(point_id)
line_coordinates.append((point[0], point[1])) # Only take x, y
xy_coordinates.append(line_coordinates)
return xy_coordinates
def compute_2d_coordinates_line_bak(self, line_source, normal):
# Ensure the input is a vtkLineSource
print("line", line_source)
if not isinstance(line_source, vtk.vtkLineSource):
raise ValueError("Input must be a vtkLineSource")
# Normalize the normal vector
normal = np.array(normal)
normal = normal / np.linalg.norm(normal)
# Create a vtkTransform
transform = vtk.vtkTransform()
transform.PostMultiply() # This ensures transforms are applied in the order we specify
# Rotate so that the normal aligns with the Z-axis
rotation_axis = np.cross(normal, [0, 0, 1])
angle = np.arccos(np.dot(normal, [0, 0, 1])) * 180 / np.pi # Convert to degrees
if np.linalg.norm(rotation_axis) > 1e-6: # Check if rotation is needed
transform.RotateWXYZ(angle, rotation_axis[0], rotation_axis[1], rotation_axis[2])
# Get the transformation matrix
matrix = transform.GetMatrix()
local_matrix = [matrix.GetElement(i, j) for i in range(4) for j in range(4)]
# Get the polydata from the line source
line_source.Update()
polydata = line_source.GetOutput()
# Apply the transform to the polydata
transform_filter = vtk.vtkTransformPolyDataFilter()
transform_filter.SetInputData(polydata)
transform_filter.SetTransform(transform)
transform_filter.Update()
# Get the transformed points
transformed_polydata = transform_filter.GetOutput()
transformed_points = transformed_polydata.GetPoints()
# Extract 2D coordinates
xy_coordinates = []
for i in range(transformed_points.GetNumberOfPoints()):
point = transformed_points.GetPoint(i)
xy_coordinates.append((point[0], point[1]))
return xy_coordinates
def project_2d_to_3d(self, xy_coordinates, normal):
# Normalize the normal vector
normal = np.array(normal)
normal = normal / np.linalg.norm(normal)
# Create a vtkTransform for the reverse transformation
reverse_transform = vtk.vtkTransform()
reverse_transform.PostMultiply() # This ensures transforms are applied in the order we specify
# Compute the rotation axis and angle (same as in compute_2d_coordinates)
rotation_axis = np.cross(normal, [0, 0, 1])
angle = np.arccos(np.dot(normal, [0, 0, 1])) * 180 / np.pi # Convert to degrees
if np.linalg.norm(rotation_axis) > 1e-6: # Check if rotation is needed
# Apply the inverse rotation
reverse_transform.RotateWXYZ(-angle, rotation_axis[0], rotation_axis[1], rotation_axis[2])
# Create vtkPoints to store the 2D points
points_2d = vtk.vtkPoints()
for x, y in xy_coordinates:
points_2d.InsertNextPoint(x, y, 0) # Z-coordinate is 0 for 2D points
# Create a polydata with the 2D points
polydata_2d = vtk.vtkPolyData()
polydata_2d.SetPoints(points_2d)
# Apply the reverse transform to the polydata
transform_filter = vtk.vtkTransformPolyDataFilter()
transform_filter.SetInputData(polydata_2d)
transform_filter.SetTransform(reverse_transform)
transform_filter.Update()
# Get the transformed points (now in 3D)
transformed_polydata = transform_filter.GetOutput()
transformed_points = transformed_polydata.GetPoints()
# Extract 3D coordinates
xyz_coordinates = []
for i in range(transformed_points.GetNumberOfPoints()):
point = transformed_points.GetPoint(i)
xyz_coordinates.append((point[0], point[1], point[2]))
return xyz_coordinates
def add_normal_line(self, origin, normal, length=10.0, color=(1, 0, 0)):
# Normalize the normal vector
normal = np.array(normal)
normal = normal / np.linalg.norm(normal)
# Calculate the end point
end_point = origin + normal * length
# Create vtkPoints
points = vtk.vtkPoints()
points.InsertNextPoint(origin)
points.InsertNextPoint(end_point)
# Create a line
line = vtk.vtkLine()
line.GetPointIds().SetId(0, 0)
line.GetPointIds().SetId(1, 1)
# Create a cell array to store the line
lines = vtk.vtkCellArray()
lines.InsertNextCell(line)
# Create a polydata to store everything in
polyData = vtk.vtkPolyData()
polyData.SetPoints(points)
polyData.SetLines(lines)
# Create mapper and actor
mapper = vtk.vtkPolyDataMapper()
mapper.SetInputData(polyData)
actor = vtk.vtkActor()
actor.SetMapper(mapper)
actor.GetProperty().SetColor(color)
actor.GetProperty().SetLineWidth(2) # Adjust line width as needed
# Add to renderer
self.renderer.AddActor(actor)
self.vtk_widget.GetRenderWindow().Render()
return actor # Return the actor in case you need to remove or modify it later
def on_invert_normal(self):
# Kippstufe für Normal flip
if self.selected_normal is not None:
self.clear_actors_normals()
self.compute_projection(self.flip_toggle)
def on_click(self, obj, event):
click_pos = self.interactor.GetEventPosition()
# Perform pick
self.picker.Pick(click_pos[0], click_pos[1], 0, self.renderer)
# Get picked cell ID
cell_id = self.picker.GetCellId()
if cell_id != -1:
print(f"Picked cell ID: {cell_id}")
# Get the polydata and the picked cell
polydata = self.picker.GetActor().GetMapper().GetInput()
cell = polydata.GetCell(cell_id)
# Ensure it's a line
if cell.GetCellType() == vtk.VTK_LINE:
# Get the two points of the line
point_id1 = cell.GetPointId(0)
point_id2 = cell.GetPointId(1)
proj_point1 = polydata.GetPoint(point_id1)
proj_point2 = polydata.GetPoint(point_id2)
self.access_selected_points.append((proj_point1, proj_point2))
point1 = np.array(proj_point1)
point2 = np.array(proj_point2)
#print(f"Line starts at: {point1}")
#print(f"Line ends at: {point2}")
# Store this line for later use if needed
self.selected_edges.append((point1, point2))
# Create a new vtkLineSource for the picked edge
line_source = vtk.vtkLineSource()
line_source.SetPoint1(point1)
line_source.SetPoint2(point2)
self.selected_vtk_line.append(line_source)
# Create a mapper and actor for the picked edge
edge_mapper = vtk.vtkPolyDataMapper()
edge_mapper.SetInputConnection(line_source.GetOutputPort())
edge_actor = vtk.vtkActor()
edge_actor.SetMapper(edge_mapper)
edge_actor.GetProperty().SetColor(1.0, 0.0, 0.0) # Red color for picked edges
edge_actor.GetProperty().SetLineWidth(5) # Make the line thicker
# Add the actor to the renderer and store it
self.renderer_indicators.AddActor(edge_actor)
self.picked_edge_actors.append(edge_actor)
if len(self.selected_edges) == 2:
self.compute_projection(False)
if len(self.selected_edges) > 2:
# Clear lists for selection
self.selected_vtk_line.clear()
self.selected_edges.clear()
self.clear_edge_select()
# Clear Actors from view
self.clear_actors_projection()
self.clear_actors_sel_edges()
self.clear_actors_normals()
def find_origin_vertex(self, edge1, edge2):
if edge1[0] == edge2[0]or edge1[0] == edge2[1]:
return edge1[0]
elif edge1[1] == edge2[0] or edge1[1] == edge2[1]:
return edge1[1]
else:
return None # The edges don't share a vertex
def clear_edge_select(self ):
# Clear selection after projection was succesful
self.selected_edges = []
self.selected_normal = []
def clear_actors_projection(self):
"""Removes all actors that were used for projection"""
for flat_mesh in self.projected_mesh_actors:
self.renderer_projections.RemoveActor(flat_mesh)
def clear_actors_normals(self):
for normals in self.displayed_normal_actors:
self.renderer_indicators.RemoveActor(normals)
def clear_actors_sel_edges(self):
for edge_line in self.picked_edge_actors:
self.renderer_indicators.RemoveActor(edge_line)
def clear_actors_interactor(self):
### Clear the outline of the mesh
for interactor in self.interactor_actors:
self.renderer.RemoveActor(interactor)
def compute_projection(self, direction_invert: bool = False):
# Compute the normal from the two selected edges )
edge1 = self.selected_edges[0][1] - self.selected_edges[0][0]
edge2 = self.selected_edges[1][1] - self.selected_edges[1][0]
selected_normal = np.cross(edge1, edge2)
selected_normal = selected_normal / np.linalg.norm(selected_normal)
#print("Computed normal:", self.selected_normal)
# Invert the normal in local z if direction_invert is True
if direction_invert:
self.selected_normal = -selected_normal
else:
self.selected_normal = selected_normal
self.centroid = np.mean([point for edge in self.selected_edges for point in edge], axis=0)
#self.centroid = self.find_origin_vertex(edge1, edge2)
# Draw the normal line
normal_length = 50 # Adjust this value to change the length of the normal line
normal_actor = self.add_normal_line(self.centroid, self.selected_normal, length=normal_length,
color=(1, 0, 0))
polydata = self.picker.GetActor().GetMapper().GetInput()
projected_polydata = self.project_mesh_to_plane(polydata, self.selected_normal, self.centroid)
# Extract 2D coordinates
self.project_tosketch_points = self.compute_2d_coordinates(projected_polydata, self.selected_normal)
# Green indicator mesh needs to be translated to xy point paris start end.
self.project_tosketch_lines = self.compute_2d_coordinates_line(projected_polydata, self.selected_normal)
print("result", self.project_tosketch_lines)
"""# Seperately rotate selected edges for drawing
self.project_tosketch_lines.clear()
for vtk_line in self.selected_vtk_line:
proj_vtk_line = self.compute_2d_coordinates_line(vtk_line, self.selected_normal)
self.project_tosketch_lines.append(proj_vtk_line)
print("outgoing lines", self.project_tosketch_lines)"""
# Create a mapper and actor for the projected data
mapper = vtk.vtkPolyDataMapper()
mapper.SetInputData(projected_polydata)
# Projected mesh in green
actor = vtk.vtkActor()
actor.SetMapper(mapper)
#actor.GetProperty().SetRenderLinesAsTubes(True)
actor.GetProperty().SetColor(0.0, 1.0, 0.0) # Set color to green
actor.GetProperty().SetLineWidth(4) # Set line width
self.renderer_indicators.AddActor(normal_actor)
self.displayed_normal_actors.append(normal_actor)
self.renderer_projections.AddActor(actor)
self.projected_mesh_actors.append(actor)
# Render the scene
self.update_render()
self.vtk_widget.GetRenderWindow().Render()
def start(self):
self.interactor.Initialize()
self.interactor.Start()
class MainWindow(QtWidgets.QMainWindow):
def __init__(self, parent=None):
super().__init__(parent)
self.vtk_widget = VTKWidget()
self.setCentralWidget(self.vtk_widget)
self.setWindowTitle("VTK Mesh Viewer")
self.vtk_widget.create_cube_mesh()
self.show()
self.vtk_widget.start()
if __name__ == "__main__":
app = QtWidgets.QApplication(sys.argv)
window = MainWindow()
sys.exit(app.exec())
-337
View File
@@ -1,337 +0,0 @@
def are_coplanar(self, normal1, normal2, point1, point2, tolerance=1e-6):
# Check if normals are parallel
if np.abs(np.dot(normal1, normal2)) < 1 - tolerance:
return False
# Check if points lie on the same plane
diff = point2 - point1
return np.abs(np.dot(diff, normal1)) < tolerance
def merge_coplanar_triangles(self, polydata):
# Compute normals
normalGenerator = vtk.vtkPolyDataNormals()
normalGenerator.SetInputData(polydata)
normalGenerator.ComputePointNormalsOff()
normalGenerator.ComputeCellNormalsOn()
normalGenerator.Update()
mesh = normalGenerator.GetOutput()
n_cells = mesh.GetNumberOfCells()
# Create a map to store merged triangles
merged = {}
for i in range(n_cells):
if i in merged:
continue
cell = mesh.GetCell(i)
normal = np.array(mesh.GetCellData().GetNormals().GetTuple(i))
point = np.array(cell.GetPoints().GetPoint(0))
merged[i] = [i]
for j in range(i + 1, n_cells):
if j in merged:
continue
cell_j = mesh.GetCell(j)
normal_j = np.array(mesh.GetCellData().GetNormals().GetTuple(j))
point_j = np.array(cell_j.GetPoints().GetPoint(0))
if self.are_coplanar(normal, normal_j, point, point_j):
merged[i].append(j)
# Create new polygons
new_polygons = vtk.vtkCellArray()
for group in merged.values():
if len(group) > 1:
polygon = vtk.vtkPolygon()
points = set()
for idx in group:
cell = mesh.GetCell(idx)
for j in range(3):
point_id = cell.GetPointId(j)
points.add(point_id)
polygon.GetPointIds().SetNumberOfIds(len(points))
for j, point_id in enumerate(points):
polygon.GetPointIds().SetId(j, point_id)
new_polygons.InsertNextCell(polygon)
else:
new_polygons.InsertNextCell(mesh.GetCell(group[0]))
# Create new polydata
new_polydata = vtk.vtkPolyData()
new_polydata.SetPoints(mesh.GetPoints())
new_polydata.SetPolys(new_polygons)
return new_polydata
def create_cube_mesh(self):
# cube_source = vtk.vtkSuperquadricSource()
reader = vtk.vtkSTLReader()
reader.SetFileName("case.stl") # Replace with your mesh file path
reader.Update()
featureEdges = vtk.vtkFeatureEdges()
featureEdges.SetInputConnection(reader.GetOutputPort())
featureEdges.BoundaryEdgesOn()
featureEdges.FeatureEdgesOn()
featureEdges.ManifoldEdgesOff()
featureEdges.NonManifoldEdgesOff()
featureEdges.Update()
# print(cube_source)
mapper = vtk.vtkPolyDataMapper()
mapper.SetInputConnection(reader.GetOutputPort())
actor = vtk.vtkActor()
actor.SetMapper(mapper)
self.renderer.AddActor(actor)
mapper_edge = vtk.vtkPolyDataMapper()
mapper_edge.SetInputConnection(featureEdges.GetOutputPort())
actor = vtk.vtkActor()
actor.SetMapper(mapper_edge)
self.renderer.AddActor(actor)
def simplify_mesh(self, input_mesh, target_reduction):
# Create the quadric decimation filter
decimate = vtk.vtkDecimatePro()
decimate.SetInputData(input_mesh)
# Set the reduction factor (0 to 1, where 1 means maximum reduction)
decimate.SetTargetReduction(target_reduction)
# Optional: Preserve topology (if needed)
decimate.PreserveTopologyOn()
# Perform the decimation
decimate.Update()
return decimate.GetOutput()
def combine_coplanar_faces(self, input_polydata, tolerance=0.001):
# Clean the polydata to merge duplicate points
clean = vtk.vtkCleanPolyData()
clean.SetInputData(input_polydata)
clean.SetTolerance(tolerance)
clean.Update()
# Generate normals and merge coplanar polygons
normals = vtk.vtkPolyDataNormals()
normals.SetInputConnection(clean.GetOutputPort())
normals.SplittingOff() # Disable splitting of sharp edges
normals.ConsistencyOn() # Ensure consistent polygon ordering
normals.AutoOrientNormalsOn() # Automatically orient normals
normals.ComputePointNormalsOff() # We only need face normals
normals.ComputeCellNormalsOn() # Compute cell normals
normals.Update()
return normals.GetOutput()
def poisson_reconstruction(self, points):
# Create a polydata object from points
point_polydata = vtk.vtkPolyData()
point_polydata.SetPoints(points)
# Create a surface reconstruction filter
surf = vtk.vtkSurfaceReconstructionFilter()
surf.SetInputData(point_polydata)
surf.Update()
# Create a contour filter to extract the surface
cf = vtk.vtkContourFilter()
cf.SetInputConnection(surf.GetOutputPort())
cf.SetValue(0, 0.0)
cf.Update()
# Reverse normals
reverse = vtk.vtkReverseSense()
reverse.SetInputConnection(cf.GetOutputPort())
reverse.ReverseCellsOn()
reverse.ReverseNormalsOn()
reverse.Update()
return reverse.GetOutput()
def create_simplified_outline(self, polydata):
featureEdges = vtk.vtkFeatureEdges()
featureEdges.SetInputData(polydata)
featureEdges.BoundaryEdgesOn()
featureEdges.FeatureEdgesOn()
featureEdges.ManifoldEdgesOff()
featureEdges.NonManifoldEdgesOff()
featureEdges.Update()
"""# 3. Clean the edges to merge duplicate points
cleaner = vtk.vtkCleanPolyData()
cleaner.SetInputConnection(feature_edges.GetOutputPort())
cleaner.Update()
# 4. Optional: Smooth the outline
smooth = vtk.vtkSmoothPolyDataFilter()
smooth.SetInputConnection(cleaner.GetOutputPort())
smooth.SetNumberOfIterations(15)
smooth.SetRelaxationFactor(0.1)
smooth.FeatureEdgeSmoothingOff()
smooth.BoundarySmoothingOn()
smooth.Update()"""
return featureEdges
def render_from_points_direct_with_faces(self, vertices, faces):
points = vtk.vtkPoints()
for i in range(vertices.shape[0]):
points.InsertNextPoint(vertices[i])
# Create a vtkCellArray to store the triangles
triangles = vtk.vtkCellArray()
for i in range(faces.shape[0]):
triangle = vtk.vtkTriangle()
triangle.GetPointIds().SetId(0, faces[i, 0])
triangle.GetPointIds().SetId(1, faces[i, 1])
triangle.GetPointIds().SetId(2, faces[i, 2])
triangles.InsertNextCell(triangle)
"""vtk_points = vtk.vtkPoints()
for point in points:
vtk_points.InsertNextPoint(point)
# Create a vtkCellArray to store the triangles
triangles = vtk.vtkCellArray()
# Assuming points are organized as triplets forming triangles
for i in range(0, len(points), 3):
triangle = vtk.vtkTriangle()
triangle.GetPointIds().SetId(0, i)
triangle.GetPointIds().SetId(1, i + 1)
triangle.GetPointIds().SetId(2, i + 2)
triangles.InsertNextCell(triangle)"""
# Create a polydata object
polydata = vtk.vtkPolyData()
polydata.SetPoints(points)
polydata.SetPolys(triangles)
# Calculate normals
normalGenerator = vtk.vtkPolyDataNormals()
normalGenerator.SetInputData(polydata)
normalGenerator.ComputePointNormalsOn()
normalGenerator.ComputeCellNormalsOn()
normalGenerator.Update()
self.cell_normals = vtk_to_numpy(normalGenerator.GetOutput().GetCellData().GetNormals())
# merged_polydata = self.merge_coplanar_triangles(polydata)
# Create a mapper and actor
mapper = vtk.vtkPolyDataMapper()
mapper.SetInputData(polydata)
actor = vtk.vtkActor()
actor.SetMapper(mapper)
actor.GetProperty().SetColor(1, 1, 1) # Set color (white in this case)
actor.GetProperty().EdgeVisibilityOn() # Show edges
actor.GetProperty().SetLineWidth(2) # Set line width
feature_edges = self.create_simplified_outline(polydata)
# Create a mapper for the feature edges
edge_mapper = vtk.vtkPolyDataMapper()
# Already wiht output
edge_mapper.SetInputConnection(feature_edges.GetOutputPort())
# Create an actor for the feature edges
edge_actor = vtk.vtkActor()
edge_actor.SetMapper(edge_mapper)
# Set the properties of the edge actor
edge_actor.GetProperty().SetColor(1, 0, 0) # Set color (red in this case)
edge_actor.GetProperty().SetLineWidth(2) # Set line width
# Optionally, if you want to keep the original mesh visible:
# (assuming you have the original mesh mapper and actor set up)
self.renderer.AddActor(actor) # Add the original mesh actor
# Add the edge actor to the renderer
self.renderer.AddActor(edge_actor)
# Force an update of the pipeline
mapper.Update()
self.vtk_widget.GetRenderWindow().Render()
"""# Print statistics
print(f"Original points: {len(points)}")
print(f"Number of triangles: {triangles.GetNumberOfCells()}")
print(f"Final number of points: {normals.GetOutput().GetNumberOfPoints()}")
print(f"Final number of cells: {normals.GetOutput().GetNumberOfCells()}")"""
def render_from_points_direct(self, points):
### Rendermethod for SDF mesh (output)
# Create a vtkPoints object and store the points in it
vtk_points = vtk.vtkPoints()
for point in points:
vtk_points.InsertNextPoint(point)
# Create a polydata object
point_polydata = vtk.vtkPolyData()
point_polydata.SetPoints(vtk_points)
# Surface reconstruction
surf = vtk.vtkSurfaceReconstructionFilter()
surf.SetInputData(point_polydata)
surf.Update()
# Create a contour filter to extract the surface
cf = vtk.vtkContourFilter()
cf.SetInputConnection(surf.GetOutputPort())
cf.SetValue(0, 0.0)
cf.Update()
# Reverse the normals
reverse = vtk.vtkReverseSense()
reverse.SetInputConnection(cf.GetOutputPort())
reverse.ReverseCellsOn()
reverse.ReverseNormalsOn()
reverse.Update()
# Get the reconstructed mesh
reconstructed_mesh = reverse.GetOutput()
"""# Simplify the mesh
target_reduction = 1 # Adjust this value as needed
simplified_mesh = self.simplify_mesh(reconstructed_mesh, target_reduction)
combinded_faces = self.combine_coplanar_faces(simplified_mesh, 0.001)"""
# Create a mapper and actor for the simplified mesh
mapper = vtk.vtkPolyDataMapper()
mapper.SetInputData(reconstructed_mesh)
actor = vtk.vtkActor()
actor.SetMapper(mapper)
actor.GetProperty().SetColor(1, 1, 1) # Set color (white in this case)
actor.GetProperty().EdgeVisibilityOn() # Show edges
actor.GetProperty().SetLineWidth(2) # Set line width
# Add the actor to the renderer
self.renderer.AddActor(actor)
# Force an update of the pipeline
# mapper.Update()
self.vtk_widget.GetRenderWindow().Render()
# Print statistics
print(f"Original points: {len(points)}")
print(
f"Reconstructed mesh: {reconstructed_mesh.GetNumberOfPoints()} points, {reconstructed_mesh.GetNumberOfCells()} cells")
"""print(
f"Simplified mesh: {simplified_mesh.GetNumberOfPoints()} points, {simplified_mesh.GetNumberOfCells()} cells")"""
-111
View File
@@ -1,111 +0,0 @@
import sys
import numpy as np
import pyvista as pv
from pyvista.plotting.opts import ElementType
from pyvistaqt import QtInteractor
from PySide6.QtWidgets import QApplication, QMainWindow, QVBoxLayout, QWidget
class PyVistaWidget(QWidget):
def __init__(self, parent=None):
super().__init__(parent)
# Create the PyVista plotter
self.plotter = QtInteractor(self)
self.plotter.background_color = "darkgray"
# Create a layout and add the PyVista widget
layout = QVBoxLayout()
layout.addWidget(self.plotter.interactor)
self.setLayout(layout)
# Set up the picker
#self.plotter.enable_cell_picking(callback=self.on_cell_pick, show=True)
self.plotter.enable_element_picking(callback=self.on_cell_pick, show=True, mode="face", left_clicking=True)
def on_cell_pick(self, element):
if element is not None:
mesh = self.plotter.mesh # Get the current mesh
print(mesh)
print(element)
"""# Get the face data
face = mesh.extract_cells(element)
# Compute face normal
face.compute_normals(cell_normals=True, inplace=True)
normal = face.cell_data['Normals'][0]
# Get the points of the face
points = face.points
print(f"Picked face ID: {face_id}")
print(f"Face normal: {normal}")
print("Face points:")
for point in points:
print(point)"""
else:
print("No face was picked or the picked element is not a face.")
def create_simplified_outline(self, mesh, camera):
# Project 3D to 2D
points_2d = self.plotter.map_to_2d(mesh.points)
# Detect silhouette edges (simplified approach)
edges = mesh.extract_feature_edges(feature_angle=90, boundary_edges=False, non_manifold_edges=False)
# Project edges to 2D
edge_points_2d = self.plotter.map_to_2d(edges.points)
# Create 2D outline
self.plotter.add_lines(edge_points_2d, color='black', width=2)
self.plotter.render()
def mesh_from_points(self, points):
# Convert points to numpy array if not already
points = np.array(points)
# Create faces array
num_triangles = len(points) // 3
faces = np.arange(len(points)).reshape(num_triangles, 3)
faces = np.column_stack((np.full(num_triangles, 3), faces)) # Add 3 as first column
# Create PyVista PolyData
mesh = pv.PolyData(points, faces)
# Optional: Merge duplicate points
mesh = mesh.clean()
# Optional: Compute normals
mesh = mesh.compute_normals(point_normals=False, cell_normals=True, consistent_normals=True)
edges = mesh.extract_feature_edges(30, non_manifold_edges=False)
# Clear any existing meshes
self.plotter.clear()
# Add the mesh to the plotter
self.plotter.add_mesh(mesh, pickable=True, color='white', show_edges=True, line_width=2, pbr=True, metallic=0.8, roughness=0.1, diffuse=1)
self.plotter.add_mesh(edges, color="red", line_width=10)
# Reset the camera to fit the new mesh
self.plotter.reset_camera()
# Update the render window
self.plotter.update()
# Print statistics
print(f"Original points: {len(points)}")
print(f"Number of triangles: {num_triangles}")
print(f"Final number of points: {mesh.n_points}")
print(f"Final number of cells: {mesh.n_cells}")
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle("PyVista in PySide6")
self.setGeometry(100, 100, 800, 600)
+783 -566
View File
File diff suppressed because it is too large Load Diff
+656 -531
View File
File diff suppressed because it is too large Load Diff
-1478
View File
File diff suppressed because it is too large Load Diff
-851
View File
@@ -1,851 +0,0 @@
# nuitka-project: --plugin-enable=pyside6
# nuitka-project: --plugin-enable=numpy
# nuitka-project: --standalone
# nuitka-project: --macos-create-app-bundle
import uuid
import names
from PySide6.QtCore import Qt, QPoint, Signal, QSize
from PySide6.QtWidgets import QApplication, QMainWindow, QSizePolicy, QInputDialog, QDialog, QVBoxLayout, QHBoxLayout, QLabel, QDoubleSpinBox, QCheckBox, QPushButton, QButtonGroup
from Gui import Ui_fluencyCAD # Import the generated GUI module
from drawing_modules.vtk_widget import VTKWidget
import numpy as np
from drawing_modules.draw_widget_solve import SketchWidget
from sdf import *
from python_solvespace import SolverSystem, ResultFlag
from mesh_modules import simple_mesh, vesta_mesh, interactor_mesh
from dataclasses import dataclass, field
# main, draw_widget, gl_widget
class ExtrudeDialog(QDialog):
def __init__(self, parent=None):
super().__init__(parent)
self.setWindowTitle('Extrude Options')
def create_hline():
line = QLabel()
line.setStyleSheet("border-top: 1px solid #cccccc;") # Light grey line
line.setFixedHeight(1)
return line
layout = QVBoxLayout()
# Length input
length_layout = QHBoxLayout()
length_label = QLabel('Extrude Length (mm):')
self.length_input = QDoubleSpinBox()
self.length_input.setDecimals(2)
self.length_input.setRange(0, 1000) # Adjust range as needed
length_layout.addWidget(length_label)
length_layout.addWidget(self.length_input)
# Symmetric checkbox
self.symmetric_checkbox = QCheckBox('Symmetric Extrude')
self.invert_checkbox = QCheckBox('Invert Extrusion')
self.cut_checkbox = QCheckBox('Perform Cut')
self.union_checkbox = QCheckBox('Combine')
self.rounded_checkbox = QCheckBox('Round Edges')
self.seperator = create_hline()
# OK and Cancel buttons
button_layout = QHBoxLayout()
ok_button = QPushButton('OK')
cancel_button = QPushButton('Cancel')
ok_button.clicked.connect(self.accept)
cancel_button.clicked.connect(self.reject)
button_layout.addWidget(ok_button)
button_layout.addWidget(cancel_button)
# Add all widgets to main layout
layout.addLayout(length_layout)
layout.addWidget(self.seperator)
layout.addWidget(self.cut_checkbox)
layout.addWidget(self.union_checkbox)
layout.addWidget(self.seperator)
layout.addWidget(self.symmetric_checkbox)
layout.addWidget(self.invert_checkbox)
layout.addWidget(self.seperator)
layout.addWidget(self.rounded_checkbox)
layout.addLayout(button_layout)
self.setLayout(layout)
def get_values(self):
return self.length_input.value(), self.symmetric_checkbox.isChecked() ,self.invert_checkbox.isChecked(), self.cut_checkbox.isChecked(), self.union_checkbox.isChecked(), self.rounded_checkbox.isChecked()
class MainWindow(QMainWindow):
send_command = Signal(str)
def __init__(self):
super().__init__()
# Set up the UI from the generated GUI module
self.ui = Ui_fluencyCAD()
self.ui.setupUi(self)
self.custom_3D_Widget = VTKWidget()
layout = self.ui.gl_box.layout()
layout.addWidget(self.custom_3D_Widget)
size_policy = QSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.MinimumExpanding)
#self.custom_3D_Widget.setSizePolicy(size_policy)
self.sketchWidget = SketchWidget()
layout2 = self.ui.sketch_tab.layout() # Get the layout of self.ui.gl_canvas
layout2.addWidget(self.sketchWidget)
size_policy = QSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.MinimumExpanding)
self.sketchWidget.setSizePolicy(size_policy)
### Main Model -OLD ?
"""self.model = {
'sketches': {},
'operation': {},
}"""
self.list_selected = []
#self.ui.pb_apply_code.pressed.connect(self.check_current_tab)
self.ui.sketch_list.currentItemChanged.connect(self.on_item_changed)
self.ui.sketch_list.itemChanged.connect(self.draw_mesh)
### Sketches
self.ui.pb_origin_wp.pressed.connect(self.add_new_sketch_origin)
self.ui.pb_origin_face.pressed.connect(self.add_new_sketch_wp)
self.ui.pb_nw_sktch.pressed.connect(self.add_sketch_to_compo)
self.ui.pb_del_sketch.pressed.connect(self.del_sketch)
self.ui.pb_edt_sktch.pressed.connect(self.edit_sketch)
self.ui.pb_flip_face.pressed.connect(self.on_flip_face)
###Modes
self.ui.pb_linetool.clicked.connect(self.sketchWidget.act_line_mode)
self.ui.pb_con_ptpt.clicked.connect(self.sketchWidget.act_constrain_pt_pt_mode)
self.ui.pb_con_line.clicked.connect(self.sketchWidget.act_constrain_pt_line_mode)
self.ui.pb_con_horiz.clicked.connect(self.sketchWidget.act_constrain_horiz_line_mode)
self.ui.pb_con_vert.clicked.connect(self.sketchWidget.act_constrain_vert_line_mode)
self.ui.pb_con_dist.clicked.connect(self.sketchWidget.act_constrain_distance_mode)
self.ui.pb_con_mid.clicked.connect(self.sketchWidget.act_constrain_mid_point_mode)
### Operations
self.ui.pb_extrdop.pressed.connect(self.send_extrude)
self.ui.pb_cutop.pressed.connect(self.send_cut)
self.ui.pb_del_body.pressed.connect(self.del_body)
self.sketchWidget.constrain_done.connect(self.draw_op_complete)
self.setFocusPolicy(Qt.StrongFocus)
self.send_command.connect(self.custom_3D_Widget.on_receive_command)
self.ui.actionNew_Project.triggered.connect(self.new_project)
self.ui.pb_enable_construct.clicked.connect(self.sketchWidget.on_construct_change)
self.project = Project()
self.new_project()
### SNAPS
self.ui.pb_snap_midp.toggled.connect(lambda checked: self.sketchWidget.on_snap_mode_change("mpoint", checked))
self.ui.pb_snap_horiz.toggled.connect(lambda checked: self.sketchWidget.on_snap_mode_change("horiz", checked))
self.ui.pb_snap_vert.toggled.connect(lambda checked: self.sketchWidget.on_snap_mode_change("vert", checked))
self.ui.pb_snap_angle.toggled.connect(lambda checked: self.sketchWidget.on_snap_mode_change("angle", checked))
self.ui.pb_enable_snap.toggled.connect(lambda checked: self.sketchWidget.on_snap_mode_change("point", checked))
### COMPOS
### COMPOS
self.ui.new_compo.pressed.connect(self.new_component)
"""Project -> (Timeline) -> Component -> Sketch -> Body / Interactor -> Connector -> Assembly -> PB Render"""
def new_project(self):
print("New project")
timeline = []
self.project.timeline = timeline
self.new_component()
def new_component(self):
print("Creating a new component...")
# Lazily initialize self.compo_layout if it doesn't exist
if not hasattr(self, 'compo_layout'):
print("Initializing compo_layout...")
self.compo_layout = QHBoxLayout()
# Create a button group
self.compo_group = QButtonGroup(self)
self.compo_group.setExclusive(True) # Ensure exclusivity
# Ensure the QGroupBox has a layout
if not self.ui.compo_box.layout():
self.ui.compo_box.setLayout(QVBoxLayout()) # Set a default layout for QGroupBox
# Add the horizontal layout to the QGroupBox's layout
self.ui.compo_box.layout().addLayout(self.compo_layout)
# Align the layout to the left
self.compo_layout.setAlignment(Qt.AlignLeft)
# Create and initialize a new Component
compo = Component()
compo.id = f"Component {len(self.project.timeline)}"
compo.descript = "Initial Component"
compo.sketches = {}
compo.bodies = {}
self.project.timeline.append(compo)
# Create a button for the new component
button = QPushButton()
button.setToolTip(compo.id)
button.setText(str(len(self.project.timeline)))
button.setFixedSize(QSize(40, 40)) # Set button size
button.setCheckable(True)
#button.setAutoExclusive(True)
button.released.connect(self.on_compo_change)
button.setChecked(True)
# Add button to the group
self.compo_group.addButton(button)
# Add the button to the layout
self.compo_layout.addWidget(button)
# We automatically switch to the new compo hence, refresh
self.on_compo_change()
print(f"Added component {compo.id} to the layout.")
def get_activated_compo(self):
# Iterate through all items in the layout
total_elements = self.compo_layout.count()
#print(total_elements)
for i in range(total_elements):
widget = self.compo_layout.itemAt(i).widget() # Get the widget at the index
if widget: # Check if the widget is not None
if isinstance(widget, QPushButton) and widget.isCheckable():
state = widget.isChecked() # Get the checked state
print(f"{widget.text()} is {'checked' if state else 'unchecked'}.")
if state:
return i
def add_new_sketch_origin(self):
name = f"sketches-{str(names.get_first_name())}"
sketch = Sketch()
sketch.id = name
sketch.origin = [0,0,0]
self.sketchWidget.reset_buffers()
self.sketchWidget.create_sketch(sketch)
def add_new_sketch_wp(self):
## Sketch projected from 3d view into 2d
name = f"sketches-{str(names.get_first_name())}"
sketch = Sketch()
sketch.id = name
sketch.origin = self.custom_3D_Widget.centroid
sketch.normal = self.custom_3D_Widget.selected_normal
sketch.slv_points = []
sketch.slv_lines = []
sketch.proj_points = self.custom_3D_Widget.project_tosketch_points
sketch.proj_lines = self.custom_3D_Widget.project_tosketch_lines
self.sketchWidget.reset_buffers()
self.sketchWidget.create_sketch(sketch)
self.sketchWidget.create_workplane_projected()
if not sketch.proj_lines:
self.sketchWidget.convert_proj_points(sketch.proj_points)
self.sketchWidget.convert_proj_lines(sketch.proj_lines)
self.sketchWidget.update()
# CLear all selections after it has been projected
self.custom_3D_Widget.project_tosketch_points.clear()
self.custom_3D_Widget.project_tosketch_lines.clear()
self.custom_3D_Widget.clear_actors_projection()
self.custom_3D_Widget.clear_actors_normals()
def add_sketch_to_compo(self):
"""
Add sketch to component
:return:
"""
sketch = Sketch()
sketch_from_widget = self.sketchWidget.get_sketch()
#Save original for editing later
sketch.original_sketch = sketch_from_widget
#Get parameters
points = [point for point in sketch_from_widget.points if hasattr(point, 'is_helper') and not point.is_helper]
sketch.convert_points_for_sdf(points)
sketch.id = sketch_from_widget.id
sketch.filter_lines_for_interactor(sketch_from_widget.lines)
# Register sketch to timeline
### Add selection compo here
compo_id = self.get_activated_compo()
#print("newsketch_name", sketch.id)
self.project.timeline[compo_id].sketches[sketch.id] = sketch
# Add Item to slection menu
self.ui.sketch_list.addItem(sketch.id)
# Deactivate drawing
self.ui.pb_linetool.setChecked(False)
self.sketchWidget.line_mode = False
items = self.ui.sketch_list.findItems(sketch.id, Qt.MatchExactly)[0]
self.ui.sketch_list.setCurrentItem(items)
def on_compo_change(self):
'''This function redraws the sdf and helper mesh from available bodies and adds the names back to the list entries'''
self.custom_3D_Widget.clear_body_actors()
self.custom_3D_Widget.clear_actors_interactor()
self.custom_3D_Widget.clear_actors_projection()
compo_id = self.get_activated_compo()
if compo_id is not None:
self.ui.sketch_list.clear()
self.ui.body_list.clear()
#print("id", compo_id)
#print("sketch_registry", self.project.timeline[compo_id].sketches)
for sketch in self.project.timeline[compo_id].sketches:
#print(sketch)
self.ui.sketch_list.addItem(sketch)
for body in self.project.timeline[compo_id].bodies:
self.ui.body_list.addItem(body)
if self.project.timeline[compo_id].bodies:
item = self.ui.body_list.findItems(body , Qt.MatchExactly)[0]
self.ui.body_list.setCurrentItem(item)
self.draw_mesh()
selected = self.ui.body_list.currentItem()
name = selected.text()
edges = self.project.timeline[compo_id].bodies[name].interactor.edges
offset_vec = self.project.timeline[compo_id].bodies[name].interactor.offset_vector
self.custom_3D_Widget.load_interactor_mesh(edges, offset_vec)
def edit_sketch(self):
selected = self.ui.sketch_list.currentItem()
name = selected.text()
sel_compo = self.project.timeline[self.get_activated_compo()]
sketch = sel_compo.sketches[name].original_sketch
self.sketchWidget.set_sketch(sketch)
self.sketchWidget.update()
def del_sketch(self):
selected = self.ui.sketch_list.currentItem()
name = selected.text()
sel_compo = self.project.timeline[self.get_activated_compo()]
sketch = sel_compo.sketches[name]
if sketch is not None:
sel_compo.sketches.pop(name)
row = self.ui.sketch_list.row(selected) # Get the row of the current item
self.ui.sketch_list.takeItem(row) # Remove the item from the list widget
self.sketchWidget.sketch = None
print(sketch)
else:
print("No item selected.")
def on_flip_face(self):
self.send_command.emit("flip")
def draw_op_complete(self):
# safely disable the line modes
self.ui.pb_linetool.setChecked(False)
self.ui.pb_con_ptpt.setChecked(False)
self.ui.pb_con_line.setChecked(False)
self.ui.pb_con_dist.setChecked(False)
self.ui.pb_con_mid.setChecked(False)
self.ui.pb_con_perp.setChecked(False)
self.sketchWidget.mouse_mode = None
self.sketchWidget.reset_buffers()
def draw_mesh(self):
name = self.ui.body_list.currentItem().text()
print("selected_for disp", name)
compo_id = self.get_activated_compo()
model = self.project.timeline[compo_id].bodies[name].sdf_body
vesta = vesta_mesh
model_data = vesta.generate_mesh_from_sdf(model, resolution=64, threshold=0)
vertices, faces = model_data
#vesta.save_mesh_as_stl(vertices, faces, 'test.stl')
self.custom_3D_Widget.render_from_points_direct_with_faces(vertices, faces)
def on_item_changed(self, current_item, previous_item):
if current_item:
name = current_item.text()
#self.view_update()
print(f"Selected item: {name}")
def update_body(self):
pass
def del_body(self):
print("Deleting")
name = self.ui.body_list.currentItem() # Get the current item
if name is not None:
item_name = name.text()
print("obj_name", item_name)
# Check if the 'operation' key exists in the model dictionary
if 'operation' in self.model and item_name in self.model['operation']:
if self.model['operation'][item_name]['id'] == item_name:
row = self.ui.body_list.row(name) # Get the row of the current item
self.ui.body_list.takeItem(row) # Remove the item from the list widget
self.model['operation'].pop(item_name) # Remove the item from the operation dictionary
print(f"Removed operation: {item_name}")
self.custom_3D_Widget.clear_mesh()
def send_extrude(self):
# Dialog input
is_symmetric = None
length = None
invert = None
selected = self.ui.sketch_list.currentItem()
name = selected.text()
sel_compo = self.project.timeline[self.get_activated_compo()]
#print(sel_compo)
sketch = sel_compo.sketches[name]
#print(sketch)
points = sketch.sdf_points
# detect loop that causes problems in mesh generation
if points[-1] == points[0]:
print("overlap")
del points[-1]
dialog = ExtrudeDialog(self)
if dialog.exec():
length, is_symmetric, invert, cut, union_with, rounded = dialog.get_values()
#print(f"Extrude length: {length}, Symmetric: {is_symmetric} Invert: {invert}")
else:
length = 0
#print("Extrude cancelled")
normal = self.custom_3D_Widget.selected_normal
#print("Normie enter", normal)
if normal is None:
normal = [0, 0, 1]
centroid = self.custom_3D_Widget.centroid
if centroid is None:
centroid = [0, 0, 0]
"""else:
centroid = list(centroid)"""
#print("This centroid ", centroid)
sketch.origin = centroid
sketch.normal = normal
f = sketch.extrude(length, is_symmetric, invert, 0)
# Create body element and assign known stuff
name_op = f"extrd-{name}"
body = Body()
body.sketch = sketch #we add the sketches for reference here
body.id = name_op
body.sdf_body = f
### Interactor
interactor = Interactor()
interactor.add_lines_for_interactor(sketch.interactor_lines)
interactor.invert = invert
if not invert:
edges = interactor_mesh.generate_mesh(interactor.lines, 0, length)
else:
edges = interactor_mesh.generate_mesh(interactor.lines, 0, -length)
sel_compo.bodies[name_op] = body
offset_vector = interactor.vector_to_centroid(None, centroid, normal)
#print("off_ved", offset_vector)
if len(offset_vector) == 0 :
offset_vector = [0, 0, 0]
interactor.edges = edges
interactor.offset_vector = offset_vector
body.interactor = interactor
self.custom_3D_Widget.load_interactor_mesh(edges, offset_vector)
self.ui.body_list.addItem(name_op)
items = self.ui.body_list.findItems(name_op, Qt.MatchExactly)[0]
self.ui.body_list.setCurrentItem(items)
self.draw_mesh()
def send_cut(self):
"""name = self.ui.body_list.currentItem().text()
points = self.model['operation'][name]['sdf_object']
sel_compo = self.project.timeline[self.get_activated_compo()]
points = sel_compo.bodies[].
self.list_selected.append(points)"""
selected = self.ui.body_list.currentItem()
name = selected.text()
sel_compo = self.project.timeline[self.get_activated_compo()]
# print(sel_compo)
body = sel_compo.bodies[name]
# print(sketch)
self.list_selected.append(body.sdf_body)
if len(self.list_selected) == 2:
f = difference(self.list_selected[0], self.list_selected[1]) # equivalent
element = {
'id': name,
'type': 'cut',
'sdf_object': f,
}
# Create body element and assign known stuff
name_op = f"cut-{name}"
body = Body()
body.id = name_op
body.sdf_body = f
## Add to component
sel_compo.bodies[name_op] = body
self.ui.body_list.addItem(name_op)
items = self.ui.body_list.findItems(name_op, Qt.MatchExactly)
self.ui.body_list.setCurrentItem(items[-1])
self.custom_3D_Widget.clear_body_actors()
self.draw_mesh()
elif len(self.list_selected) > 2:
self.list_selected.clear()
else:
print("mindestens 2!")
def load_and_render(self, file):
self.custom_3D_Widget.load_stl(file)
self.custom_3D_Widget.update()
@dataclass
class Timeline:
"""Timeline """
### Collection of the Components
timeline: list = None
"""add to time,
remove from time, """
class Assembly:
"""Connecting Components in 3D space based on slvs solver"""
@dataclass
class Component:
"""The base container combining all related elements
id : The unique ID
sketches : the base sketches, bodys can contain additonal sketches for features
interactor : A smiplified model used as interactor
body : The body class that contains the actual 3d information
connector : Vector and Nomral information for assembly
descript : a basic description
materil : Speicfy a material for pbr rendering
"""
id = None
sketches: dict = None
bodies: dict = None
connector = None
# Description
descript = None
# PBR
material = None
class Connector:
"""An Element that contains vectors and or normals as connection points.
These connection points can exist independently of bodies and other elements"""
id = None
vector = None
normal = None
class Code:
"""A class that holds all information from the code based approach"""
command_list = None
def generate_mesh_from_code(self, code_text: str):
local_vars = {}
try:
print(code_text)
exec(code_text, globals(), local_vars)
# Retrieve the result from the captured local variables
result = local_vars.get('result')
print("Result:", result)
except Exception as e:
print("Error executing code:", e)
@dataclass
class Sketch:
"""All of the 2D Information of a sketches"""
# Save the incomng sketch from the 2D widget for late redit
original_sketch = None
id = None
# Space Information
origin = None
slv_plane = None
normal = None
# Points in UI form the sketches widget
ui_points: list = None
ui_lines: list = None
# Points cartesian coming as result of the solver
slv_points: list = None
slv_lines: list = None
sdf_points: list = None
interactor_lines: list = None
# Points coming back from the 3D-Widget as projection to draw on
proj_points: list = None
proj_lines: list = None
# Workingplane
working_plane = None
def translate_points_tup(self, point: QPoint):
"""QPoints from Display to mesh data
input: Qpoints
output: Tuple X,Y
"""
if isinstance(point, QPoint):
return point.x(), point.y()
def vector_to_centroid(self, shape_center, centroid, normal):
if not shape_center:
# Calculate the current center of the shape
shape_center = [0, 0, 0]
# Calculate the vector from the shape's center to the centroid
center_to_centroid = np.array(centroid) - np.array(shape_center)
# Project this vector onto the normal to get the required translation along the normal
translation_along_normal = np.dot(center_to_centroid, normal) * normal
return translation_along_normal
def angle_between_normals(self, normal1, normal2):
# Ensure the vectors are normalized
n1 = normal1 / np.linalg.norm(normal1)
n2 = normal2 / np.linalg.norm(normal2)
# Compute the dot product
dot_product = np.dot(n1, n2)
# Clip the dot product to the valid range [-1, 1]
dot_product = np.clip(dot_product, -1.0, 1.0)
# Compute the angle in radians
angle_rad = np.arccos(dot_product)
# Convert to degrees if needed
angle_deg = np.degrees(angle_rad)
print("Angle deg", angle_deg)
return angle_rad
def offset_syn(self, f, length):
f = f.translate((0,0, length / 2))
return f
def distance(self, p1, p2):
"""Calculate the distance between two points."""
print("p1", p1)
print("p2", p2)
return math.sqrt((p1[0] - p2[0]) ** 2 + (p1[1] - p2[1]) ** 2)
def convert_points_for_sdf(self, points):
points_for_sdf = []
for point in points:
if point.is_helper is False:
print("point", point)
points_for_sdf.append(self.translate_points_tup(point.ui_point))
self.sdf_points = points_for_sdf
def filter_lines_for_interactor(self, lines):
### Filter lines that are not meant to be drawn for the interactor like contruction lines
filtered_lines = []
for line in lines:
if not line.is_helper:
filtered_lines.append(line)
self.interactor_lines = filtered_lines
def extrude(self, height: float, symet: bool = True, invert: bool = False, offset_length: float = None):
"""
Extrude a 2D shape into 3D, orient it along the normal, and position it relative to the centroid.
"""
# Normalize the normal vector
normal = np.array(self.normal)
normal = normal / np.linalg.norm(self.normal)
# Create the 2D shape
f = polygon(self.sdf_points)
# Extrude the shape along the Z-axis
f = f.extrude(height)
# Center the shape along its extrusion axis
f = f.translate((0, 0, height / 2))
# Orient the shape along the normal vector
f = f.orient(normal)
offset_vector = self.vector_to_centroid(None, self.origin, normal)
# Adjust the offset vector by subtracting the inset distance along the normal direction
adjusted_offset = offset_vector - (normal * height)
if invert:
# Translate the shape along the adjusted offset vector
f = f.translate(adjusted_offset)
else:
f = f.translate(offset_vector)
# If offset_length is provided, adjust the offset_vector
if offset_length is not None:
# Check if offset_vector is not a zero vector
offset_vector_magnitude = np.linalg.norm(offset_vector)
if offset_vector_magnitude > 1e-10: # Use a small threshold to avoid floating-point issues
# Normalize the offset vector
offset_vector_norm = offset_vector / offset_vector_magnitude
# Scale the normalized vector by the desired length
offset_vector = offset_vector_norm * offset_length
f = f.translate(offset_vector)
else:
print("Warning: Offset vector has zero magnitude. Using original vector.")
# Translate the shape along the adjusted offset vector
return f
@dataclass
class Interactor:
"""Helper mesh consisting of edges for selection"""
lines = None
faces = None
body = None
offset_vector = None
edges = None
def translate_points_tup(self, point: QPoint):
"""QPoints from Display to mesh data
input: Qpoints
output: Tuple X,Y
"""
if isinstance(point, QPoint):
return point.x(), point.y()
def vector_to_centroid(self, shape_center, centroid, normal):
if not shape_center:
# Calculate the current center of the shape
shape_center = [0, 0, 0]
# Calculate the vector from the shape's center to the centroid
center_to_centroid = np.array(centroid) - np.array(shape_center)
# Project this vector onto the normal to get the required translation along the normal
translation_along_normal = np.dot(center_to_centroid, normal) * normal
return translation_along_normal
def add_lines_for_interactor(self, input_lines: list):
"""Takes Line2D objects from the sketch widget and preparesit for interactor mesh.
Translates coordinates."""
points_for_interact = []
for point_to_poly in input_lines:
from_coord_start = window.sketchWidget.from_quadrant_coords_no_center(point_to_poly.crd1.ui_point)
from_coord_end = window.sketchWidget.from_quadrant_coords_no_center(point_to_poly.crd2.ui_point)
start_draw = self.translate_points_tup(from_coord_start)
end_draw = self.translate_points_tup(from_coord_end)
line = start_draw, end_draw
points_for_interact.append(line)
print("packed_lines", points_for_interact)
self.lines = points_for_interact
@dataclass
class Body:
"""The actual body as sdf3 object"""
id = None
sketch = None
height = None
interactor = None
sdf_body = None
def mirror_body(self, sdf_object3d):
f = sdf_object3d.rotate(pi)
return f
class Output:
def export_mesh(self, sdf_object):
"""FINAL EXPORT"""
result_points = sdf_object.generate()
write_binary_stl('out.stl', result_points)
def generate_mesh_from_code(self, code_text: str):
local_vars = {}
try:
print(code_text)
exec(code_text, globals(), local_vars)
# Retrieve the result from the captured local variables
result = local_vars.get('result')
print("Result:", result)
except Exception as e:
print("Error executing code:", e)
class Project:
"""Project -> Timeline -> Component -> Sketch -> Body / Interactor -> Connector -> Assembly -> PB Render"""
timeline: Timeline = None
assembly: Assembly = None
if __name__ == "__main__":
app = QApplication()
window = MainWindow()
window.show()
app.exec()
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
-43
View File
@@ -1,43 +0,0 @@
# Draw simple boundary based on the lines and depth
def generate_mesh(lines: list, z_origin: float, depth: float, invert: bool = False):
origin = create_3D(lines, z_origin)
if invert :
extruded = create_3D(lines, z_origin - depth)
else:
extruded = create_3D(lines, z_origin + depth)
vert_lines = create_vert_lines(origin, extruded)
print(f"Result = {origin} / {extruded} / {vert_lines}")
return origin + vert_lines + extruded
def create_vert_lines(origin, extruded):
vert_lines = []
for d3_point_o, d3point_e in zip(origin, extruded):
for sp3d_1, sp3d_2 in zip(d3_point_o, d3point_e):
new_line = sp3d_1, sp3d_2
vert_lines.append(new_line)
return vert_lines
def create_3D(lines, z_pos):
line_loop = []
for coordinate2d in lines:
start, end = coordinate2d
xs, ys = start
coordinate3d_start_orig = xs, ys, z_pos
xe, ye = end
coordinate3d_end_orig = xe, ye, z_pos
line3d_orig = coordinate3d_start_orig, coordinate3d_end_orig
line_loop.append(line3d_orig)
return line_loop
-213
View File
@@ -1,213 +0,0 @@
import numpy as np
from scipy.spatial import Delaunay, ConvexHull
#from shapely.geometry import Polygon, Point
def alpha_shape(points, alpha):
"""
Compute the alpha shape (concave hull) of a set of points.
"""
def add_edge(edges, edge_points, points, i, j):
"""Add a line between the i-th and j-th points if not in the list already"""
if (i, j) in edges or (j, i) in edges:
return
edges.add((i, j))
edge_points.append(points[[i, j]])
tri = Delaunay(points)
edges = set()
edge_points = []
# Loop over triangles:
for ia, ib, ic in tri.simplices:
pa = points[ia]
pb = points[ib]
pc = points[ic]
# Lengths of sides of triangle
a = np.sqrt((pa[0] - pb[0]) ** 2 + (pa[1] - pb[1]) ** 2)
b = np.sqrt((pb[0] - pc[0]) ** 2 + (pb[1] - pc[1]) ** 2)
c = np.sqrt((pc[0] - pa[0]) ** 2 + (pc[1] - pa[1]) ** 2)
# Semiperimeter of triangle
s = (a + b + c) / 2.0
# Area of triangle by Heron's formula
area = np.sqrt(s * (s - a) * (s - b) * (s - c))
circum_r = a * b * c / (4.0 * area)
# Here's the radius filter.
if circum_r < 1.0 / alpha:
add_edge(edges, edge_points, points, ia, ib)
add_edge(edges, edge_points, points, ib, ic)
add_edge(edges, edge_points, points, ic, ia)
m = np.array(edge_points)
return m
def generate_mesh(points, depth, alpha=0.1):
"""
Generate a mesh by extruding a 2D shape along the Z-axis, automatically detecting holes.
:param points: List of (x, y) tuples representing all points of the 2D shape, including potential holes.
:param depth: Extrusion depth along the Z-axis.
:param alpha: Alpha value for the alpha shape algorithm (controls the "tightness" of the boundary).
:return: Tuple of vertices and faces.
"""
# Convert points to a numpy array
points_2d = np.array(points)
# Compute the alpha shape (outer boundary)
boundary_edges = alpha_shape(points_2d, alpha)
# Create a Polygon from the boundary
boundary_polygon = Polygon(boundary_edges)
# Separate points into boundary and interior
boundary_points = []
interior_points = []
for point in points:
if Point(point).touches(boundary_polygon) or Point(point).within(boundary_polygon):
if Point(point).touches(boundary_polygon):
boundary_points.append(point)
else:
interior_points.append(point)
# Perform Delaunay triangulation on all points
tri = Delaunay(points_2d)
# Generate the top and bottom faces
bottom_face = np.hstack((tri.points, np.zeros((tri.points.shape[0], 1))))
top_face = np.hstack((tri.points, np.ones((tri.points.shape[0], 1)) * depth))
# Combine top and bottom vertices
vertices_array = np.vstack((bottom_face, top_face))
# Create faces
faces = []
# Bottom face triangulation
for simplex in tri.simplices:
faces.append(simplex.tolist())
# Top face triangulation (with an offset)
top_offset = len(tri.points)
for simplex in tri.simplices:
faces.append([i + top_offset for i in simplex])
# Side faces for the outer boundary
for i in range(len(boundary_points)):
next_i = (i + 1) % len(boundary_points)
current = points.index(boundary_points[i])
next_point = points.index(boundary_points[next_i])
faces.append([current, top_offset + current, top_offset + next_point])
faces.append([current, top_offset + next_point, next_point])
# Convert vertices to the desired format: list of tuples
vertices = [tuple(vertex) for vertex in vertices_array]
return vertices, faces
def generate_mesh_wholes(points, holes, depth):
"""
Generate a mesh by extruding a 2D shape along the Z-axis, including holes.
:param points: List of (x, y) tuples representing the outer boundary of the 2D shape.
:param holes: List of lists, where each inner list contains (x, y) tuples representing a hole.
:param depth: Extrusion depth along the Z-axis.
:return: Tuple of vertices and faces.
"""
# Convert points to a numpy array
points_2d = np.array(points)
# Prepare points for triangulation
triangulation_points = points_2d.tolist()
for hole in holes:
triangulation_points.extend(hole)
# Perform Delaunay triangulation
tri = Delaunay(np.array(triangulation_points))
# Generate the top and bottom faces
bottom_face = np.hstack((tri.points, np.zeros((tri.points.shape[0], 1))))
top_face = np.hstack((tri.points, np.ones((tri.points.shape[0], 1)) * depth))
# Combine top and bottom vertices
vertices_array = np.vstack((bottom_face, top_face))
# Create faces
faces = []
# Bottom face triangulation
for simplex in tri.simplices:
faces.append(simplex.tolist())
# Top face triangulation (with an offset)
top_offset = len(tri.points)
for simplex in tri.simplices:
faces.append([i + top_offset for i in simplex])
# Side faces
for i in range(len(points)):
next_i = (i + 1) % len(points)
faces.append([i, top_offset + i, top_offset + next_i])
faces.append([i, top_offset + next_i, next_i])
# Side faces for holes
start_index = len(points)
for hole in holes:
for i in range(len(hole)):
current = start_index + i
next_i = start_index + (i + 1) % len(hole)
faces.append([current, top_offset + next_i, top_offset + current])
faces.append([current, next_i, top_offset + next_i])
start_index += len(hole)
# Convert vertices to the desired format: list of tuples
vertices = [tuple(vertex) for vertex in vertices_array]
return vertices, faces
def generate_mesh_simple(points, depth):
"""
Generate a mesh by extruding a 2D shape along the Z-axis.
:param points: List of (x, y) tuples representing the 2D shape.
:param depth: Extrusion depth along the Z-axis.
:return: Tuple of vertices and faces.
"""
# Convert points to a numpy array
points_2d = np.array(points)
# Get the convex hull of the points to ensure they form a proper polygon
hull = ConvexHull(points_2d)
hull_points = points_2d[hull.vertices]
# Generate the top and bottom faces
bottom_face = np.hstack((hull_points, np.zeros((hull_points.shape[0], 1))))
top_face = np.hstack((hull_points, np.ones((hull_points.shape[0], 1)) * depth))
# Combine top and bottom vertices
vertices_array = np.vstack((bottom_face, top_face))
# Create faces
faces = []
# Bottom face triangulation (counter-clockwise)
for i in range(len(hull_points) - 2):
faces.append([0, i + 2, i + 1])
# Top face triangulation (counter-clockwise, with an offset)
top_offset = len(hull_points)
for i in range(len(hull_points) - 2):
faces.append([top_offset, top_offset + i + 1, top_offset + i + 2])
# Side faces (ensure counter-clockwise order)
for i in range(len(hull_points)):
next_i = (i + 1) % len(hull_points)
faces.append([i, top_offset + i, top_offset + next_i])
faces.append([i, top_offset + next_i, next_i])
# Convert vertices to the desired format: list of tuples
vertices = [tuple(vertex) for vertex in vertices_array]
return vertices, faces
-119
View File
@@ -1,119 +0,0 @@
import numpy as np
from skimage import measure
import multiprocessing
from functools import partial
from multiprocessing.pool import ThreadPool
import itertools
import time
def _cartesian_product(*arrays):
la = len(arrays)
dtype = np.result_type(*arrays)
arr = np.empty([len(a) for a in arrays] + [la], dtype=dtype)
for i, a in enumerate(np.ix_(*arrays)):
arr[..., i] = a
return arr.reshape(-1, la)
class VESTA:
def __init__(self, sdf, bounds=None, resolution=64, threshold=0.0, workers=None):
self.sdf = sdf
self.bounds = bounds
self.resolution = resolution
self.threshold = threshold
self.workers = workers or multiprocessing.cpu_count()
def _estimate_bounds(self):
s = 16
x0 = y0 = z0 = -1e9
x1 = y1 = z1 = 1e9
prev = None
for i in range(32):
X = np.linspace(x0, x1, s)
Y = np.linspace(y0, y1, s)
Z = np.linspace(z0, z1, s)
d = np.array([X[1] - X[0], Y[1] - Y[0], Z[1] - Z[0]])
threshold = np.linalg.norm(d) / 2
if threshold == prev:
break
prev = threshold
P = _cartesian_product(X, Y, Z)
volume = self.sdf(P).reshape((len(X), len(Y), len(Z)))
where = np.argwhere(np.abs(volume) <= threshold)
if where.size == 0:
continue
x1, y1, z1 = (x0, y0, z0) + where.max(axis=0) * d + d / 2
x0, y0, z0 = (x0, y0, z0) + where.min(axis=0) * d - d / 2
if prev is None:
raise ValueError("Failed to estimate bounds. No points found within any threshold.")
return ((x0, y0, z0), (x1, y1, z1))
def _vesta_worker(self, chunk):
x0, x1, y0, y1, z0, z1 = chunk
X = np.linspace(x0, x1, self.resolution)
Y = np.linspace(y0, y1, self.resolution)
Z = np.linspace(z0, z1, self.resolution)
P = _cartesian_product(X, Y, Z)
V = self.sdf(P).reshape((self.resolution, self.resolution, self.resolution))
try:
verts, faces, _, _ = measure.marching_cubes(V, self.threshold)
except RuntimeError:
# Return empty arrays if marching_cubes fails
return np.array([]), np.array([])
# Scale and translate vertices to match the chunk's bounds
verts = verts / (self.resolution - 1)
verts[:, 0] = verts[:, 0] * (x1 - x0) + x0
verts[:, 1] = verts[:, 1] * (y1 - y0) + y0
verts[:, 2] = verts[:, 2] * (z1 - z0) + z0
return verts, faces
def _merge_meshes(self, results):
all_verts = []
all_faces = []
offset = 0
for verts, faces in results:
if len(verts) > 0 and len(faces) > 0:
all_verts.append(verts)
all_faces.append(faces + offset)
offset += len(verts)
if not all_verts or not all_faces:
return np.array([]), np.array([])
return np.vstack(all_verts), np.vstack(all_faces)
def generate_mesh(self):
if self.bounds is None:
self.bounds = self._estimate_bounds()
(x0, y0, z0), (x1, y1, z1) = self.bounds
chunks = [
(x0, x1, y0, y1, z0, z1)
]
with ThreadPool(self.workers) as pool:
results = pool.map(self._vesta_worker, chunks)
verts, faces = self._merge_meshes(results)
return verts, faces
def generate_mesh_from_sdf(sdf, bounds=None, resolution=64, threshold=0.0, workers=None):
vesta = VESTA(sdf, bounds, resolution, threshold, workers)
return vesta.generate_mesh()
# Helper function to save the mesh as an STL file
def save_mesh_as_stl(vertices, faces, filename):
from stl import mesh
# Create the mesh
cube = mesh.Mesh(np.zeros(faces.shape[0], dtype=mesh.Mesh.dtype))
for i, f in enumerate(faces):
for j in range(3):
cube.vectors[i][j] = vertices[f[j], :]
# Write the mesh to file
cube.save(filename)
-5
View File
@@ -1,5 +0,0 @@
from sdf import *
f = box(1).translate((1,1,-0.2))
c = hexagon(1).extrude(1).orient([0,0,-1])
c = f & c
f.save("out.stl")
+72
View File
@@ -0,0 +1,72 @@
[build-system]
requires = ["setuptools>=61.0", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "fluency-cad"
version = "2.0.0"
description = "Parametric CAD application with OpenCASCADE geometry kernel"
readme = "README.md"
license = {text = "MIT"}
requires-python = ">=3.10"
authors = [
{name = "Fluency CAD Team"}
]
keywords = ["cad", "parametric", "opencascade", "3d-modeling"]
classifiers = [
"Development Status :: 4 - Beta",
"Intended Audience :: Developers",
"Intended Audience :: End Users/Desktop",
"License :: OSI Approved :: MIT License",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Topic :: Scientific/Engineering :: CAD",
]
dependencies = [
"pygfx>=0.1.0",
"wgpu>=0.1.0",
"PySide6>=6.4.0",
"numpy>=1.24.0",
"scipy>=1.10.0",
"pillow>=10.0.0",
"python_solvespace>=3.0.0",
]
[project.optional-dependencies]
dev = [
"pytest>=8.0",
"black>=24.0",
"mypy>=1.8",
"ruff>=0.4.0",
]
[project.scripts]
fluency-cad = "fluency.main:main"
[project.urls]
Homepage = "https://github.com/fluency-cad/fluency"
Documentation = "https://github.com/fluency-cad/fluency#readme"
Repository = "https://github.com/fluency-cad/fluency"
[tool.setuptools.packages.find]
where = ["src"]
[tool.setuptools.package-data]
fluency = ["py.typed", "*.pyi"]
[tool.black]
line-length = 100
target-version = ["py310", "py311", "py312"]
[tool.ruff]
line-length = 100
target-version = "py310"
[tool.mypy]
python_version = "3.10"
warn_return_any = true
warn_unused_configs = true
disallow_untyped_defs = true
-61
View File
@@ -1,61 +0,0 @@
asttokens==3.0.0
attrs==25.3.0
black==24.10.0
click==8.2.1
contourpy==1.3.2
cycler==0.12.1
decorator==5.2.1
executing==2.2.0
flexcache==0.3
flexparser==0.4
fonttools==4.58.1
h5py==3.13.0
imageio==2.37.0
ipython==9.3.0
ipython_pygments_lexers==1.1.1
jedi==0.19.2
kiwisolver==1.4.8
lazy_loader==0.4
markdown-it-py==3.0.0
matplotlib==3.10.3
matplotlib-inline==0.1.7
mdurl==0.1.2
meshio==5.3.5
mypy_extensions==1.1.0
names==0.3.0
networkx==3.5
Nuitka==2.7.10
numpy==2.2.6
ordered-set==4.1.0
packaging==25.0
parso==0.8.4
pathspec==0.12.1
pexpect==4.9.0
pillow==11.2.1
Pint==0.24.4
platformdirs==4.3.8
prompt_toolkit==3.0.51
ptyprocess==0.7.0
pure_eval==0.2.3
Pygments==2.19.1
pyparsing==3.2.3
PySide6==6.9.0
PySide6_Addons==6.9.0
PySide6_Essentials==6.9.0
python-dateutil==2.9.0.post0
python_solvespace==3.0.8
rich==13.9.4
scikit-image==0.25.2
scipy==1.15.3
sdfcad @ git+https://gitlab.com/nobodyinperson/sdfCAD@42505b5181c88dda2fd66ac9d387533fbe4145f3
shiboken6==6.9.0
six==1.17.0
stack-data==0.6.3
tifffile==2025.5.26
tokenize_rt==6.2.0
traitlets==5.14.3
typing_extensions==4.13.2
vtk==9.4.2
wcwidth==0.2.13
xlrd==2.0.2
zstandard==0.23.0
-26
View File
@@ -1,26 +0,0 @@
from . import d2, d3, ease
from .util import *
from .units import units
from .d2 import *
from .d3 import *
from .text import (
measure_image,
measure_text,
image,
text,
)
from .mesh import (
generate,
save,
sample_slice,
show_slice,
)
from .stl import (
write_binary_stl,
)
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
-390
View File
@@ -1,390 +0,0 @@
import functools
import numpy as np
import operator
import copy
from . import dn, d3, ease
# Constants
ORIGIN = np.array((0, 0))
X = np.array((1, 0))
Y = np.array((0, 1))
UP = Y
# SDF Class
_ops = {}
class SDF2:
def __init__(self, f):
self.f = f
def __call__(self, p):
return self.f(p).reshape((-1, 1))
def __getattr__(self, name):
if name in _ops:
f = _ops[name]
return functools.partial(f, self)
raise AttributeError
def __or__(self, other):
return union(self, other)
def __and__(self, other):
return intersection(self, other)
def __sub__(self, other):
return difference(self, other)
def fillet(self, r):
newSelf = copy.deepcopy(self)
newSelf._r = r
return newSelf
def radius(self, *args, **kwargs):
return self.fillet(*args, **kwargs)
def k(self, *args, **kwargs):
return self.fillet(*args, **kwargs)
def r(self, *args, **kwargs):
return self.fillet(*args, **kwargs)
def chamfer(self, c):
newSelf = copy.deepcopy(self)
newSelf._c = c
return newSelf
def c(self, *args, **kwargs):
return self.chamfer(*args, **kwargs)
def sdf2(f):
@functools.wraps(f)
def wrapper(*args, **kwargs):
return SDF2(f(*args, **kwargs))
return wrapper
def op2(f):
@functools.wraps(f)
def wrapper(*args, **kwargs):
return SDF2(f(*args, **kwargs))
_ops[f.__name__] = wrapper
return wrapper
def op23(f):
@functools.wraps(f)
def wrapper(*args, **kwargs):
return d3.SDF3(f(*args, **kwargs))
_ops[f.__name__] = wrapper
return wrapper
# Helpers
def _length(a):
return np.linalg.norm(a, axis=1)
def _normalize(a):
return a / np.linalg.norm(a)
def _dot(a, b):
return np.sum(a * b, axis=1)
def _vec(*arrs):
return np.stack(arrs, axis=-1)
_min = np.minimum
_max = np.maximum
# Primitives
@sdf2
def circle(radius=None, diameter=None, center=ORIGIN):
if (radius is not None) == (diameter is not None):
raise ValueError(f"Specify either radius or diameter")
if radius is None:
radius = diameter / 2
def f(p):
return _length(p - center) - radius
return f
@sdf2
def line(normal=UP, point=ORIGIN):
normal = _normalize(normal)
def f(p):
return np.dot(point - p, normal)
return f
@sdf2
def slab(x0=None, y0=None, x1=None, y1=None, r=None):
fs = []
if x0 is not None:
fs.append(line(X, (x0, 0)))
if x1 is not None:
fs.append(line(-X, (x1, 0)))
if y0 is not None:
fs.append(line(Y, (0, y0)))
if y1 is not None:
fs.append(line(-Y, (0, y1)))
return intersection(*fs, r=r)
@sdf2
def rectangle(size=1, center=ORIGIN, a=None, b=None):
if a is not None and b is not None:
a = np.array(a)
b = np.array(b)
size = b - a
center = a + size / 2
return rectangle(size, center)
size = np.array(size)
def f(p):
q = np.abs(p - center) - size / 2
return _length(_max(q, 0)) + _min(np.amax(q, axis=1), 0)
return f
@sdf2
def rounded_rectangle(size, radius, center=ORIGIN):
try:
r0, r1, r2, r3 = radius
except TypeError:
r0 = r1 = r2 = r3 = radius
def f(p):
x = p[:, 0]
y = p[:, 1]
r = np.zeros(len(p)).reshape((-1, 1))
r[np.logical_and(x > 0, y > 0)] = r0
r[np.logical_and(x > 0, y <= 0)] = r1
r[np.logical_and(x <= 0, y <= 0)] = r2
r[np.logical_and(x <= 0, y > 0)] = r3
q = np.abs(p) - size / 2 + r
return (
_min(_max(q[:, 0], q[:, 1]), 0).reshape((-1, 1))
+ _length(_max(q, 0)).reshape((-1, 1))
- r
)
return f
@sdf2
def equilateral_triangle():
def f(p):
k = 3**0.5
p = _vec(np.abs(p[:, 0]) - 1, p[:, 1] + 1 / k)
w = p[:, 0] + k * p[:, 1] > 0
q = _vec(p[:, 0] - k * p[:, 1], -k * p[:, 0] - p[:, 1]) / 2
p = np.where(w.reshape((-1, 1)), q, p)
p = _vec(p[:, 0] - np.clip(p[:, 0], -2, 0), p[:, 1])
return -_length(p) * np.sign(p[:, 1])
return f
@sdf2
def hexagon(radius=None, diameter=None):
if (radius is not None) == (diameter is not None):
raise ValueError(f"Specify either radius or diameter")
if radius is None:
radius = diameter / 2
radius *= 3**0.5 / 2
def f(p):
k = np.array((3**0.5 / -2, 0.5, np.tan(np.pi / 6)))
p = np.abs(p)
p -= 2 * k[:2] * _min(_dot(k[:2], p), 0).reshape((-1, 1))
p -= _vec(
np.clip(p[:, 0], -k[2] * radius, k[2] * radius), np.zeros(len(p)) + radius
)
return _length(p) * np.sign(p[:, 1])
return f
@sdf2
def rounded_x(w, r):
def f(p):
p = np.abs(p)
q = (_min(p[:, 0] + p[:, 1], w) * 0.5).reshape((-1, 1))
return _length(p - q) - r
return f
def RegularPolygon(n, r=1):
ri = r * np.cos(np.pi / n)
return intersection(
*[slab(y0=-ri).rotate(a) for a in np.arange(0, 2 * np.pi, 2 * np.pi / n)]
)
@sdf2
def polygon(points):
points = [np.array(p) for p in points]
def f(p):
n = len(points)
d = _dot(p - points[0], p - points[0])
s = np.ones(len(p))
for i in range(n):
j = (i + n - 1) % n
vi = points[i]
vj = points[j]
e = vj - vi
w = p - vi
b = w - e * np.clip(np.dot(w, e) / np.dot(e, e), 0, 1).reshape((-1, 1))
d = _min(d, _dot(b, b))
c1 = p[:, 1] >= vi[1]
c2 = p[:, 1] < vj[1]
c3 = e[0] * w[:, 1] > e[1] * w[:, 0]
c = _vec(c1, c2, c3)
s = np.where(np.all(c, axis=1) | np.all(~c, axis=1), -s, s)
return s * np.sqrt(d)
return f
# Positioning
@op2
def translate(other, offset):
def f(p):
return other(p - offset)
return f
@op2
def scale(other, factor):
try:
x, y = factor
except TypeError:
x = y = factor
s = (x, y)
m = min(x, y)
def f(p):
return other(p / s) * m
return f
@op2
def rotate(other, angle):
s = np.sin(angle)
c = np.cos(angle)
m = 1 - c
matrix = np.array(
[
[c, -s],
[s, c],
]
).T
def f(p):
return other(np.dot(p, matrix))
return f
@op2
def circular_array(other, count):
angles = [i / count * 2 * np.pi for i in range(count)]
return union(*[other.rotate(a) for a in angles])
# Alterations
@op2
def elongate(other, size):
def f(p):
q = np.abs(p) - size
x = q[:, 0].reshape((-1, 1))
y = q[:, 1].reshape((-1, 1))
w = _min(_max(x, y), 0)
return other(_max(q, 0)) + w
return f
# 2D => 3D Operations
@op23
def extrude(other, h=np.inf):
def f(p):
d = other(p[:, [0, 1]])
w = _vec(d.reshape(-1), np.abs(p[:, 2]) - h / 2)
return _min(_max(w[:, 0], w[:, 1]), 0) + _length(_max(w, 0))
return f
@op23
def extrude_to(a, b, h, e=ease.linear):
def f(p):
d1 = a(p[:, [0, 1]])
d2 = b(p[:, [0, 1]])
t = e(np.clip(p[:, 2] / h, -0.5, 0.5) + 0.5)
d = d1 + (d2 - d1) * t.reshape((-1, 1))
w = _vec(d.reshape(-1), np.abs(p[:, 2]) - h / 2)
return _min(_max(w[:, 0], w[:, 1]), 0) + _length(_max(w, 0))
return f
@op23
def revolve(other, offset=0):
def f(p):
xy = p[:, [0, 1]]
# use horizontal distance to Z axis as X coordinate in 2D shape
# use Z coordinate as Y coordinate in 2D shape
q = _vec(_length(xy) - offset, p[:, 2])
return other(q)
return f
# Common
union = op2(dn.union)
difference = op2(dn.difference)
intersection = op2(dn.intersection)
blend = op2(dn.blend)
negate = op2(dn.negate)
dilate = op2(dn.dilate)
erode = op2(dn.erode)
shell = op2(dn.shell)
repeat = op2(dn.repeat)
mirror = op2(dn.mirror)
modulate_between = op2(dn.modulate_between)
stretch = op2(dn.stretch)
-1666
View File
File diff suppressed because it is too large Load Diff
-366
View File
@@ -1,366 +0,0 @@
import itertools
from functools import reduce, partial
import warnings
from . import ease
import numpy as np
_min = np.minimum
_max = np.maximum
def distance_to_plane(p, origin, normal):
"""
Calculate the distance of a point ``p`` to the plane around ``origin`` with
normal ``normal``. This is dimension-independent, so e.g. the z-coordinate
can be omitted.
Args:
p (array): either [x,y,z] or [[x,y,z],[x,y,z],...]
origin (vector): a point on the plane
normal (vector): normal vector of the plane
Returns:
int: distance to plane
"""
normal = normal / np.linalg.norm(normal)
return abs((p - origin) @ normal)
def minimum(a, b, r=0):
if r:
Δ = b - a
h = np.clip(0.5 + 0.5 * Δ / r, 0, 1)
return b - Δ * h - r * h * (1 - h)
else:
return np.minimum(a, b)
def maximum(a, b, r=0):
if r:
Δ = b - a
h = np.clip(0.5 - 0.5 * Δ / r, 0, 1)
return b - Δ * h + r * h * (1 - h)
else:
return np.maximum(a, b)
def union(*sdfs, chamfer=0, c=0, radius=0, r=0, fillet=0, f=0):
c = max(chamfer, c)
r = max(radius, r, fillet, f)
sqrt05 = np.sqrt(0.5)
def f(p):
sdfs_ = iter(sdfs)
d1 = next(sdfs_)(p)
for sdf in sdfs_:
d2 = sdf(p)
R = r or getattr(sdf, "_r", 0)
C = c or getattr(sdf, "_c", 0)
parts = (d1, d2)
if C:
parts = (minimum(d1, d2), (d1 + d2 - C) * sqrt05)
d1 = minimum(*parts, R)
return d1
return f
def intersection(*sdfs, chamfer=0, c=0, radius=0, r=0, fillet=0, f=0):
c = max(chamfer, c)
r = max(radius, r, fillet, f)
sqrt05 = np.sqrt(0.5)
def f(p):
sdfs_ = iter(sdfs)
d1 = next(sdfs_)(p)
for sdf in sdfs_:
d2 = sdf(p)
R = r or getattr(sdf, "_r", 0)
C = c or getattr(sdf, "_c", 0)
parts = (d1, d2)
if C:
parts = (maximum(d1, d2), (d1 + d2 + C) * sqrt05)
d1 = maximum(*parts, R)
return d1
return f
def difference(*sdfs, chamfer=0, c=0, radius=0, r=0, fillet=0, f=0):
c = max(chamfer, c)
r = max(radius, r, fillet, f)
sqrt05 = np.sqrt(0.5)
def f(p):
sdfs_ = iter(sdfs)
d1 = next(sdfs_)(p)
for sdf in sdfs_:
d2 = sdf(p)
R = r or getattr(sdf, "_r", 0)
C = c or getattr(sdf, "_c", 0)
parts = (d1, -d2)
if C:
parts = (maximum(d1, -d2), (d1 - d2 + C) * sqrt05)
d1 = maximum(*parts, R)
return d1
return f
def union_legacy(a, *bs, r=None):
def f(p):
d1 = a(p)
for b in bs:
d2 = b(p)
K = k or getattr(b, "_r", None)
if K is None:
d1 = _min(d1, d2)
else:
h = np.clip(0.5 + 0.5 * (d2 - d1) / K, 0, 1)
m = d2 + (d1 - d2) * h
d1 = m - K * h * (1 - h)
return d1
return f
def difference_legacy(a, *bs, r=None):
def f(p):
d1 = a(p)
for b in bs:
d2 = b(p)
K = k or getattr(b, "_r", None)
if K is None:
d1 = _max(d1, -d2)
else:
h = np.clip(0.5 - 0.5 * (d2 + d1) / K, 0, 1)
m = d1 + (-d2 - d1) * h
d1 = m + K * h * (1 - h)
return d1
return f
def intersection_legacy(a, *bs, r=None):
def f(p):
d1 = a(p)
for b in bs:
d2 = b(p)
K = k or getattr(b, "_r", None)
if K is None:
d1 = _max(d1, d2)
else:
h = np.clip(0.5 - 0.5 * (d2 - d1) / K, 0, 1)
m = d2 + (d1 - d2) * h
d1 = m + K * h * (1 - h)
return d1
return f
def blend(a, *bs, r=0.5):
def f(p):
d1 = a(p)
for b in bs:
d2 = b(p)
K = k or getattr(b, "_r", None)
d1 = K * d2 + (1 - K) * d1
return d1
return f
def negate(other):
def f(p):
return -other(p)
return f
def dilate(other, r):
def f(p):
return other(p) - r
return f
def erode(other, r):
def f(p):
return other(p) + r
return f
def shell(other, thickness=1, type="center"):
"""
Keep only a margin of a given thickness around the object's boundary.
Args:
thickness (float): the resulting thickness
type (str): what kind of shell to generate.
``"center"`` (default)
shell is spaced symmetrically around boundary
``"outer"``
the resulting shell will be ``thickness`` larger than before
``"inner"``
the resulting shell will be as large as before
"""
return dict(
center=lambda p: np.abs(other(p)) - thickness / 2,
inner=other - other.erode(thickness),
outer=other.dilate(thickness) - other,
)[type]
def modulate_between(sdf, a, b, e=ease.in_out_cubic):
"""
Apply a distance offset transition between two control points
(e.g. make a rod thicker or thinner at some point or add a bump)
Args:
a, b (vectors): the two control points
e (scalar function): the distance offset function, will be called with
values between 0 (at control point ``a``) and 1 (at control point
``b``). Its result will be subtracted from the given SDF, thus
enlarging the object by that value.
"""
# unit vector from control point a to b
ab = (ab := b - a) / (L := np.linalg.norm(ab))
def f(p):
# project current point onto control direction, clip and apply easing
offset = e(np.clip((p - a) @ ab / L, 0, 1))
return (dist := sdf(p)) - offset.reshape(dist.shape)
return f
def stretch(sdf, a, b, symmetric=False, e=ease.linear):
"""
Grab the object at point ``a`` and stretch the entire plane to ``b``.
Args:
a, b (point vectors): the control points
symmetric (bool): also stretch the same into the other direction.
e (Easing): easing to apply
Examples
========
.. code-block:: python
# make a capsule
sphere(5).stretch(ORIGIN, 10*Z).save() # same as capsule(ORIGIN, 10*Z, 5)
# make an egg
sphere(5).stretch(ORIGIN, 10*Z, e=ease.smoothstep[:0.44]).save()
"""
ab = (ab := b - a) / (L := np.linalg.norm(ab))
def f(p):
# s = ”how far are we between a and b as fraction?”
# if symmetric=True this also goes into the negative direction
s = np.clip((p - a) @ ab / L, -1 if symmetric else 0, 1)
# we return the sdf at a point 'behind' (p minus ...)
# the current point, but we go only as far back as the stretch distance
# at max
return sdf(p - (np.sign(s) * e(abs(s)) * L * ab[:, np.newaxis]).T)
return f
def shear(sdf, fix, grab, move, e=ease.linear):
"""
Grab the object at point ``grab`` and shear the entire plane in direction
``move``, keeping point ``fix`` in place. If ``move`` is orthogonal to the
direction ``fix``->``grab``, then this operation is a shear.
Args:
fix, grab (point vectors): the control points
move (point vector): direction to shear to
e (Easing): easing to apply
Examples
========
.. code-block:: python
# make a capsule
box([20,10,50]).shear(fix=-15*Z, grab=15*Z, move=-5*X, e=ease.smoothstep)
"""
ab = (ab := grab - fix) / (L := np.linalg.norm(ab))
def f(p):
# s = ”how far are we between a and b as fraction?”
s = (p - fix) @ ab / L
return sdf(p - move * np.expand_dims(e(np.clip(s, 0, 1)), axis=1))
return f
def mirror(other, direction, at=0):
"""
Mirror around a given plane defined by ``origin`` reference point and
``direction``.
Args:
direction (vector): direction to mirror to (e.g. :any:`X` to mirror along X axis)
at (3D vector): point to mirror at. Default is the origin.
"""
direction = direction / np.linalg.norm(direction)
def f(p):
projdir = np.expand_dims((p - at) @ direction, axis=1) * direction
# mirrored point:
# - project 'p' onto 'direction' (result goes into 'projdir' direction)
# - projected point is at 'at + projdir'
# - remember direction from projected point to the original point (p - (at + projdir))
# - from origin 'at' go backwards the projected direction (at - projdir)
# - from that target, move along the remembered direction (p - (at + projdir))
# - pmirr = at - projdir + (p - (at + projdir))
# - the 'at' cancels out, the projdir is subtracted twice from the point
return other(p - 2 * projdir)
return f
def repeat(other, spacing, count=None, padding=0):
count = np.array(count) if count is not None else None
spacing = np.array(spacing)
def neighbors(dim, padding, spacing):
try:
padding = [padding[i] for i in range(dim)]
except Exception:
padding = [padding] * dim
try:
spacing = [spacing[i] for i in range(dim)]
except Exception:
spacing = [spacing] * dim
for i, s in enumerate(spacing):
if s == 0:
padding[i] = 0
axes = [list(range(-p, p + 1)) for p in padding]
return list(itertools.product(*axes))
def f(p):
q = np.divide(p, spacing, out=np.zeros_like(p), where=spacing != 0)
if count is None:
index = np.round(q)
else:
index = np.clip(np.round(q), -count, count)
indexes = [index + n for n in neighbors(p.shape[-1], padding, spacing)]
A = [other(p - spacing * i) for i in indexes]
a = A[0]
for b in A[1:]:
a = _min(a, b)
return a
return f
-637
View File
@@ -1,637 +0,0 @@
# system modules
from dataclasses import dataclass
from typing import Callable
import itertools
import functools
import warnings
# external modules
import numpy as np
import scipy.optimize
@dataclass
@functools.total_ordering
class Extremum:
"""
Container for min and max in Easing
"""
pos: float
value: float
def __eq__(self, other):
return self.value == other.value
def __lt__(self, other):
return self.value < other.value
@dataclass
@functools.total_ordering
class Easing:
"""
A function defined on the interval [0;1]
"""
f: Callable[float, float]
name: str
def modifier(decorated_fun):
@functools.wraps(decorated_fun)
def wrapper(self, *args, **kwargs):
newfun = decorated_fun(self, *args, **kwargs)
arglist = ",".join(
itertools.chain(map(str, args), (f"{k}={v}" for k, v in kwargs.items()))
)
newfun.__name__ = f"{self.f.__name__}.{decorated_fun.__name__}({arglist})"
return type(self)(f=newfun, name=newfun.__name__)
return wrapper
def __repr__(self):
return self.name
def __str__(self):
return self.name
@functools.cached_property
def is_ascending(self):
return np.all(np.diff(self.f(np.linspace(0, 1, 100))) >= 0)
@functools.cached_property
def is_symmetric(self):
t = np.linspace(0, 0.5, 100)
return np.allclose(self.f(t), self.f(1 - t))
@property
@modifier
def reverse(self):
"""
Revert the function so it goes the other way round (starts at the end)
"""
return lambda t: self.f(1 - t)
@property
@modifier
def symmetric(self):
"""
Mirror and squash function to make it symmetric
"""
return lambda t: self.f(-2 * (np.abs(t - 0.5) - 0.5))
@modifier
def mirror(self, x=None, y=None, copy=False):
"""
Mirror function around an x and/or y value.
Args:
x (float): x value to mirror around
y (float): y value to mirror around
copy (bool): when mirroring around x, do copy-mirror
"""
if (x, y) == (None, None):
x = 0.5
def mirrored(t):
if x is not None:
t = 2 * x - t
if copy:
t = np.abs(-t)
if y is None:
return self.f(t)
else:
return y - self.f(t)
return mirrored
@modifier
def clip(self, min=None, max=None):
"""
Clip function at low and/or high values
"""
if min is None and max is None:
min = 0
max = 1
return lambda t: np.clip(self.f(t), min, max)
@modifier
def clip_input(self, min=None, max=None):
"""
Clip input parameter, i.e. extrapolate constantly outside the interval.
"""
if min is None and max is None:
min = 0
max = 1
return lambda t: self.f(np.clip(t, min, max))
@property
@modifier
def clipped(self):
"""
Clipped parameter and result to [0;1]
"""
return lambda t: np.clip(self(np.clip(t, 0, 1)), 0, 1)
@modifier
def append(self, other, e=None):
"""
Append another easing function and squish both into the [0;1] interval
"""
if e is None:
e = in_out_square
def f(t):
mix = e(t)
return self.f(t * 2) * (1 - mix) + other((t - 0.5) * 2) * mix
return f
@modifier
def prepend(self, other, e=None):
"""
Prepend another easing function and squish both into the [0;1] interval
"""
if e is None:
e = in_out_square
def f(t):
mix = e(t)
return other(t * 2) * (1 - mix) + self.f((t - 0.5) * 2) * mix
return f
@modifier
def shift(self, offset):
"""
Shift function on x-axis into positive direction by ``offset``.
"""
return lambda t: self.f(t - offset)
@modifier
def repeat(self, n=2):
"""
Repeat the function a total of n times in the interval [0;1].
"""
return lambda t: self.f(t % (1 / n) * n)
@modifier
def multiply(self, factor):
"""
Scale function by ``factor``
"""
if isinstance(factor, Easing):
return lambda t: self(t) * factor(t)
else:
return lambda t: factor * self.f(t)
@modifier
def add(self, offset):
"""
Add ``offset`` to function
"""
if isinstance(offset, Easing):
return lambda t: self(t) + offset(t)
else:
return lambda t: self.f(t) + offset
def __add__(self, offset):
return self.add(offset)
def __radd__(self, offset):
return self.add(offset)
def __sub__(self, offset):
return self.add(-offset)
def __rsub__(self, offset):
return self.add(-offset)
def __mul__(self, factor):
return self.multiply(factor)
def __rmul__(self, factor):
return self.multiply(factor)
def __neg__(self):
return self.multiply(-1)
def __truediv__(self, factor):
return self.multiply(1 / factor)
def __or__(self, other):
return self.transition(other)
def __rshift__(self, offset):
return self.shift(offset)
def __lshift__(self, offset):
return self.shift(-offset)
def __getitem__(self, index):
if isinstance(index, Easing):
return self.chain(index)
if isinstance(index, slice):
return self.zoom(
0 if index.start is None else index.start,
1 if index.stop is None else index.stop,
)
else:
raise ValueError(
f"{index = } has to be slice of floats or an easing function"
)
@modifier
def chain(self, f=None):
"""
Feed parameter through the given function before evaluating this function.
"""
if f is None:
f = self.f
return lambda t: self.f(f(t))
@modifier
def zoom(self, left, right=None):
"""
Arrange so that the interval [left;right] is moved into [0;1]
If only one argument is given, zoom in/out by moving edges that far.
"""
if left is not None and right is None:
if left >= 0.5:
raise ValueError(
f"{left = } is > 0.5 which doesn't make sense (bounds would cross)"
)
left = left
right = 1 - left
if left >= right:
raise ValueError(f"{right = } bound must be greater than {left = }")
return self.chain(linear.between(left, right)).f
@modifier
def between(self, left=0, right=1, e=None):
"""
Arrange so ``f(0)==a`` and ``f(1)==b``.
"""
f0, f1 = self.f(np.array([0, 1]))
la = f0 - left
lb = f1 - right
if e is None: # linear is defined later
e = (
self # use ourself as transition when we're ascending within [0;1]
if (self.is_ascending and np.allclose(self.f(np.array([0, 1])), [0, 1]))
else linear
)
def f(t):
t_ = e(t)
return self.f(t_) - (la * (1 - t_)) - lb * t_
return f
@modifier
def transition(self, other, e=None):
"""
Transiton from one easing to another
"""
if e is None:
e = linear
def f(t):
t_ = e(t)
return self.f(t) * (1 - t_) + other(t) * t_
return f
@classmethod
def function(cls, decorated_fun):
return cls(f=decorated_fun, name=decorated_fun.__name__)
def plot(self, *others, xlim=(0, 1), ax=None):
import matplotlib.pyplot as plt # lazy import for speed
from cycler import cycler
if ax is None:
fig, ax_ = plt.subplots()
else:
ax_ = ax
try:
ax_.set_prop_cycle(
cycler(linestyle=["solid", "dashed", "dotted"], linewidth=[1, 1, 2])
* plt.rcParams["axes.prop_cycle"]
)
except ValueError as e:
pass
t = np.linspace(*xlim, 1000)
funs = list(others or [])
if isinstance(self, Easing):
funs.insert(0, self)
for f in funs:
ax_.plot(t, f(t), label=getattr(f, "name", getattr(f, "__name__", str(f))))
ax_.legend(ncol=int(np.ceil(len(ax_.get_lines()) / 10)))
if ax is None:
plt.show()
return ax_
@functools.cached_property
def min(self):
v = self.f(t := np.linspace(0, 1, 1000))
approxmin = Extremum(pos=t[i := np.argmin(v)], value=v[i])
opt = scipy.optimize.minimize(self, x0=[approxmin.pos], bounds=[(0, 1)])
optmin = Extremum(pos=opt.x[0], value=opt.fun)
return min(approxmin, optmin)
@functools.cached_property
def max(self):
"""
Determine the maximum value
"""
v = self.f(t := np.linspace(0, 1, 1000))
approxmax = Extremum(pos=t[i := np.argmax(v)], value=v[i])
opt = scipy.optimize.minimize(-self, x0=[approxmax.pos], bounds=[(0, 1)])
optmax = Extremum(pos=opt.x[0], value=-opt.fun)
return max(approxmax, optmax)
@functools.cached_property
def mean(self):
return np.mean(self.f(np.linspace(0, 1, 1000)))
def __lt__(self, e):
return np.all(self.f(t := np.linspace(0, 1, 50)) < e.f(t))
def __eq__(self, e):
return np.allclose(self.f(t := np.linspace(0, 1, 50)), e.f(t))
def __call__(self, t):
return self.f(t)
@Easing.function
def linear(t):
return t
@Easing.function
def in_quad(t):
return t * t
@Easing.function
def out_quad(t):
return -t * (t - 2)
@Easing.function
def in_out_quad(t):
u = 2 * t - 1
a = 2 * t * t
b = -0.5 * (u * (u - 2) - 1)
return np.where(t < 0.5, a, b)
@Easing.function
def in_cubic(t):
return t * t * t
@Easing.function
def out_cubic(t):
u = t - 1
return u * u * u + 1
@Easing.function
def in_out_cubic(t):
u = t * 2
v = u - 2
a = 0.5 * u * u * u
b = 0.5 * (v * v * v + 2)
return np.where(u < 1, a, b)
@Easing.function
def in_quart(t):
return t * t * t * t
@Easing.function
def out_quart(t):
u = t - 1
return -(u * u * u * u - 1)
@Easing.function
def in_out_quart(t):
u = t * 2
v = u - 2
a = 0.5 * u * u * u * u
b = -0.5 * (v * v * v * v - 2)
return np.where(u < 1, a, b)
@Easing.function
def in_quint(t):
return t * t * t * t * t
@Easing.function
def out_quint(t):
u = t - 1
return u * u * u * u * u + 1
@Easing.function
def in_out_quint(t):
u = t * 2
v = u - 2
a = 0.5 * u * u * u * u * u
b = 0.5 * (v * v * v * v * v + 2)
return np.where(u < 1, a, b)
@Easing.function
def in_sine(t):
return -np.cos(t * np.pi / 2) + 1
@Easing.function
def out_sine(t):
return np.sin(t * np.pi / 2)
@Easing.function
def in_out_sine(t):
return -0.5 * (np.cos(np.pi * t) - 1)
@Easing.function
def in_expo(t):
a = np.zeros(len(t))
b = 2 ** (10 * (t - 1))
return np.where(t == 0, a, b)
@Easing.function
def out_expo(t):
a = np.zeros(len(t)) + 1
b = 1 - 2 ** (-10 * t)
return np.where(t == 1, a, b)
@Easing.function
def in_out_expo(t):
zero = np.zeros(len(t))
one = zero + 1
a = 0.5 * 2 ** (20 * t - 10)
b = 1 - 0.5 * 2 ** (-20 * t + 10)
return np.where(t == 0, zero, np.where(t == 1, one, np.where(t < 0.5, a, b)))
@Easing.function
def in_circ(t):
return -1 * (np.sqrt(1 - t * t) - 1)
@Easing.function
def out_circ(t):
u = t - 1
return np.sqrt(1 - u * u)
@Easing.function
def in_out_circ(t):
u = t * 2
v = u - 2
a = -0.5 * (np.sqrt(1 - u * u) - 1)
b = 0.5 * (np.sqrt(1 - v * v) + 1)
return np.where(u < 1, a, b)
@Easing.function
def in_elastic(t, k=0.5):
u = t - 1
return -1 * (2 ** (10.0 * u) * np.sin((u - k / 4) * (2 * np.pi) / k))
@Easing.function
def out_elastic(t, k=0.5):
return 2 ** (-10.0 * t) * np.sin((t - k / 4) * (2 * np.pi / k)) + 1
@Easing.function
def in_out_elastic(t, k=0.5):
u = t * 2
v = u - 1
a = -0.5 * (2 ** (10 * v) * np.sin((v - k / 4) * 2 * np.pi / k))
b = 2 ** (-10 * v) * np.sin((v - k / 4) * 2 * np.pi / k) * 0.5 + 1
return np.where(u < 1, a, b)
@Easing.function
def in_back(t):
k = 1.70158
return t * t * ((k + 1) * t - k)
@Easing.function
def out_back(t):
k = 1.70158
u = t - 1
return u * u * ((k + 1) * u + k) + 1
@Easing.function
def in_out_back(t):
k = 1.70158 * 1.525
u = t * 2
v = u - 2
a = 0.5 * (u * u * ((k + 1) * u - k))
b = 0.5 * (v * v * ((k + 1) * v + k) + 2)
return np.where(u < 1, a, b)
@Easing.function
def in_bounce(t):
return 1 - out_bounce(1 - t)
@Easing.function
def out_bounce(t):
a = (121 * t * t) / 16
b = (363 / 40 * t * t) - (99 / 10 * t) + 17 / 5
c = (4356 / 361 * t * t) - (35442 / 1805 * t) + 16061 / 1805
d = (54 / 5 * t * t) - (513 / 25 * t) + 268 / 25
return np.where(t < 4 / 11, a, np.where(t < 8 / 11, b, np.where(t < 9 / 10, c, d)))
@Easing.function
def in_out_bounce(t):
a = in_bounce(2 * t) * 0.5
b = out_bounce(2 * t - 1) * 0.5 + 0.5
return np.where(t < 0.5, a, b)
@Easing.function
def in_square(t):
return np.heaviside(t - 1, 0)
@Easing.function
def out_square(t):
return np.heaviside(t + 1, 0)
@Easing.function
def in_out_square(t):
return np.heaviside(t - 0.5, 0)
def constant(x):
return Easing(f=lambda t: np.full_like(t, x), name=f"constant({x})")
zero = constant(0)
one = constant(1)
@Easing.function
def smoothstep(t):
t = np.clip(t, 0, 1)
return 3 * t * t - 2 * t * t * t
def _main():
import matplotlib.pyplot as plt
from cycler import cycler
plt.rcParams["axes.prop_cycle"] *= cycler(
linestyle=["solid", "dashed", "dotted"], linewidth=[1, 2, 3]
)
plt.rcParams["figure.autolayout"] = True
plt.rcParams["axes.grid"] = True
plt.rcParams["axes.axisbelow"] = True
plt.rcParams["legend.fontsize"] = "small"
LOCALS = globals()
print(f"{LOCALS = }")
fig, axes = plt.subplots(nrows=2)
Easing.plot(
*sorted((obj for n, obj in LOCALS.items() if isinstance(obj, Easing)), key=str),
ax=axes[0],
)
Easing.plot(
in_sine.symmetric,
in_out_sine.symmetric.multiply(-0.6),
linear.symmetric.multiply(-0.7),
in_out_sine.multiply(-0.6).symmetric,
out_sine.multiply(-0.6).reverse.symmetric.multiply(2),
out_bounce.add(-0.5),
ax=axes[1],
)
axes[0].set_title("Standard")
axes[1].set_title("Derived")
plt.show()
if __name__ == "__main__":
_main()
-42
View File
@@ -1,42 +0,0 @@
import warnings
import functools
class SDFCADError(Exception):
pass
class SDFCADInfiniteObjectError(Exception):
"""
Error raised when an infinite object is encountered where not suitable.
"""
pass
class SDFCADWarning(Warning):
pass
class SDFCADAlphaQualityWarning(SDFCADWarning):
show = True
def alpha_quality(decorated_fun):
@functools.wraps(decorated_fun)
def wrapper(*args, **kwargs):
if SDFCADAlphaQualityWarning.show:
warnings.warn(
f"{decorated_fun.__name__}() is alpha quality "
f"and might give wrong results. Use with care. "
f"Hide this warning by setting sdf.errors.SDFCADAlphaQualityWarning.show=False.",
SDFCADAlphaQualityWarning,
)
with warnings.catch_warnings():
# Don't reissue nested alpha quality warnings
warnings.simplefilter("ignore", SDFCADAlphaQualityWarning)
return decorated_fun(*args, **kwargs)
else:
return decorated_fun(*args, **kwargs)
return wrapper
-282
View File
@@ -1,282 +0,0 @@
from functools import partial
from multiprocessing.pool import ThreadPool
from skimage import measure
import multiprocessing
import itertools
import numpy as np
import time
from . import progress, stl
WORKERS = multiprocessing.cpu_count()
SAMPLES = 2**18
BATCH_SIZE = 32
def _marching_cubes(volume, level=0):
verts, faces, _, _ = measure.marching_cubes(volume, level)
return verts[faces].reshape((-1, 3))
def _cartesian_product(*arrays):
la = len(arrays)
dtype = np.result_type(*arrays)
arr = np.empty([len(a) for a in arrays] + [la], dtype=dtype)
for i, a in enumerate(np.ix_(*arrays)):
arr[..., i] = a
return arr.reshape(-1, la)
def _skip(sdf, job):
X, Y, Z = job
x0, x1 = X[0], X[-1]
y0, y1 = Y[0], Y[-1]
z0, z1 = Z[0], Z[-1]
x = (x0 + x1) / 2
y = (y0 + y1) / 2
z = (z0 + z1) / 2
r = abs(sdf(np.array([(x, y, z)])).reshape(-1)[0])
d = np.linalg.norm(np.array((x - x0, y - y0, z - z0)))
if r <= d:
return False
corners = np.array(list(itertools.product((x0, x1), (y0, y1), (z0, z1))))
values = sdf(corners).reshape(-1)
same = np.all(values > 0) if values[0] > 0 else np.all(values < 0)
return same
def _worker(sdf, job, sparse):
X, Y, Z = job
if sparse and _skip(sdf, job):
return None
# return _debug_triangles(X, Y, Z)
P = _cartesian_product(X, Y, Z)
volume = sdf(P).reshape((len(X), len(Y), len(Z)))
try:
points = _marching_cubes(volume)
except Exception:
return []
# return _debug_triangles(X, Y, Z)
scale = np.array([X[1] - X[0], Y[1] - Y[0], Z[1] - Z[0]])
offset = np.array([X[0], Y[0], Z[0]])
return points * scale + offset
def _estimate_bounds(sdf):
# TODO: raise exception if bound estimation fails
s = 16
x0 = y0 = z0 = -1e9
x1 = y1 = z1 = 1e9
prev = None
for i in range(32):
X = np.linspace(x0, x1, s)
Y = np.linspace(y0, y1, s)
Z = np.linspace(z0, z1, s)
d = np.array([X[1] - X[0], Y[1] - Y[0], Z[1] - Z[0]])
threshold = np.linalg.norm(d) / 2
if threshold == prev:
break
prev = threshold
P = _cartesian_product(X, Y, Z)
volume = sdf(P).reshape((len(X), len(Y), len(Z)))
where = np.argwhere(np.abs(volume) <= threshold)
x1, y1, z1 = (x0, y0, z0) + where.max(axis=0) * d + d / 2
x0, y0, z0 = (x0, y0, z0) + where.min(axis=0) * d - d / 2
return ((x0, y0, z0), (x1, y1, z1))
def generate(
sdf,
step=None,
bounds=None,
samples=SAMPLES,
workers=WORKERS,
batch_size=BATCH_SIZE,
verbose=True,
sparse=True,
):
start = time.time()
if bounds is None:
bounds = _estimate_bounds(sdf)
(x0, y0, z0), (x1, y1, z1) = bounds
if step is None and samples is not None:
volume = (x1 - x0) * (y1 - y0) * (z1 - z0)
step = (volume / samples) ** (1 / 3)
try:
dx, dy, dz = step
except TypeError:
dx = dy = dz = step
if verbose:
print("min %g, %g, %g" % (x0, y0, z0))
print("max %g, %g, %g" % (x1, y1, z1))
print("step %g, %g, %g" % (dx, dy, dz))
X = np.arange(x0, x1, dx)
Y = np.arange(y0, y1, dy)
Z = np.arange(z0, z1, dz)
s = batch_size
Xs = [X[i : i + s + 1] for i in range(0, len(X), s)]
Ys = [Y[i : i + s + 1] for i in range(0, len(Y), s)]
Zs = [Z[i : i + s + 1] for i in range(0, len(Z), s)]
batches = list(itertools.product(Xs, Ys, Zs))
num_batches = len(batches)
num_samples = sum(len(xs) * len(ys) * len(zs) for xs, ys, zs in batches)
if verbose:
print(
"%d samples in %d batches with %d workers"
% (num_samples, num_batches, workers)
)
points = []
skipped = empty = nonempty = 0
bar = progress.Bar(num_batches, enabled=verbose)
f = partial(_worker, sdf, sparse=sparse)
with ThreadPool(workers) as pool:
for result in pool.imap(f, batches):
bar.increment(1)
if result is None:
skipped += 1
elif len(result) == 0:
empty += 1
else:
nonempty += 1
points.extend(result)
bar.done()
if verbose:
print("%d skipped, %d empty, %d nonempty" % (skipped, empty, nonempty))
triangles = len(points) // 3
seconds = time.time() - start
print("%d triangles in %g seconds" % (triangles, seconds))
return points
def save(path, *args, **kwargs):
points = generate(*args, **kwargs)
if str(path).lower().endswith(".stl"):
stl.write_binary_stl(path, points)
else:
mesh = _mesh(points)
mesh.write(path)
def _mesh(points):
import meshio
points, cells = np.unique(points, axis=0, return_inverse=True)
cells = [("triangle", cells.reshape((-1, 3)))]
return meshio.Mesh(points, cells)
def _debug_triangles(X, Y, Z):
x0, x1 = X[0], X[-1]
y0, y1 = Y[0], Y[-1]
z0, z1 = Z[0], Z[-1]
p = 0.25
x0, x1 = x0 + (x1 - x0) * p, x1 - (x1 - x0) * p
y0, y1 = y0 + (y1 - y0) * p, y1 - (y1 - y0) * p
z0, z1 = z0 + (z1 - z0) * p, z1 - (z1 - z0) * p
v = [
(x0, y0, z0),
(x0, y0, z1),
(x0, y1, z0),
(x0, y1, z1),
(x1, y0, z0),
(x1, y0, z1),
(x1, y1, z0),
(x1, y1, z1),
]
return [
v[3],
v[5],
v[7],
v[5],
v[3],
v[1],
v[0],
v[6],
v[4],
v[6],
v[0],
v[2],
v[0],
v[5],
v[1],
v[5],
v[0],
v[4],
v[5],
v[6],
v[7],
v[6],
v[5],
v[4],
v[6],
v[3],
v[7],
v[3],
v[6],
v[2],
v[0],
v[3],
v[2],
v[3],
v[0],
v[1],
]
def sample_slice(sdf, w=1024, h=1024, x=None, y=None, z=None, bounds=None):
if bounds is None:
bounds = _estimate_bounds(sdf)
(x0, y0, z0), (x1, y1, z1) = bounds
if x is not None:
X = np.array([x])
Y = np.linspace(y0, y1, w)
Z = np.linspace(z0, z1, h)
extent = (Z[0], Z[-1], Y[0], Y[-1])
axes = "ZY"
elif y is not None:
Y = np.array([y])
X = np.linspace(x0, x1, w)
Z = np.linspace(z0, z1, h)
extent = (Z[0], Z[-1], X[0], X[-1])
axes = "ZX"
elif z is not None:
Z = np.array([z])
X = np.linspace(x0, x1, w)
Y = np.linspace(y0, y1, h)
extent = (Y[0], Y[-1], X[0], X[-1])
axes = "YX"
else:
raise Exception("x, y, or z position must be specified")
P = _cartesian_product(X, Y, Z)
return sdf(P).reshape((w, h)), extent, axes
def show_slice(*args, **kwargs):
import matplotlib.pyplot as plt
show_abs = kwargs.pop("abs", False)
a, extent, axes = sample_slice(*args, **kwargs)
if show_abs:
a = np.abs(a)
im = plt.imshow(a, extent=extent, origin="lower")
plt.xlabel(axes[0])
plt.ylabel(axes[1])
plt.colorbar(im)
plt.show()
-83
View File
@@ -1,83 +0,0 @@
import sys
import time
def pretty_time(seconds):
seconds = int(round(seconds))
s = seconds % 60
m = (seconds // 60) % 60
h = seconds // 3600
return "%d:%02d:%02d" % (h, m, s)
class Bar(object):
def __init__(self, max_value=100, min_value=0, enabled=True):
self.min_value = min_value
self.max_value = max_value
self.value = min_value
self.start_time = time.time()
self.enabled = enabled
@property
def percent_complete(self):
t = (self.value - self.min_value) / (self.max_value - self.min_value)
return t * 100
@property
def elapsed_time(self):
return time.time() - self.start_time
@property
def eta(self):
t = self.percent_complete / 100
if t == 0:
return 0
return (1 - t) * self.elapsed_time / t
def increment(self, delta):
self.update(self.value + delta)
def update(self, value):
self.value = value
if self.enabled:
sys.stdout.write(" %s \r" % self.render())
sys.stdout.flush()
def done(self):
self.update(self.max_value)
self.stop()
def stop(self):
if self.enabled:
sys.stdout.write("\n")
sys.stdout.flush()
def render(self):
items = [
self.render_percent_complete(),
self.render_value(),
self.render_bar(),
self.render_elapsed_time(),
self.render_eta(),
]
return " ".join(items)
def render_percent_complete(self):
return "%3.0f%%" % self.percent_complete
def render_value(self):
if self.min_value == 0:
return "(%g of %g)" % (self.value, self.max_value)
else:
return "(%g)" % (self.value)
def render_bar(self, size=30):
a = int(round(self.percent_complete / 100.0 * size))
b = size - a
return "[" + "#" * a + "-" * b + "]"
def render_elapsed_time(self):
return pretty_time(self.elapsed_time)
def render_eta(self):
return pretty_time(self.eta)
-27
View File
@@ -1,27 +0,0 @@
import numpy as np
import struct
def write_binary_stl(path, points):
n = len(points) // 3
points = np.array(points, dtype="float32").reshape((-1, 3, 3))
normals = np.cross(points[:, 1] - points[:, 0], points[:, 2] - points[:, 0])
normals /= np.linalg.norm(normals, axis=1).reshape((-1, 1))
dtype = np.dtype(
[
("normal", ("<f", 3)),
("points", ("<f", (3, 3))),
("attr", "<H"),
]
)
a = np.zeros(n, dtype=dtype)
a["points"] = points
a["normal"] = normals
with open(path, "wb") as fp:
fp.write(b"\x00" * 80)
fp.write(struct.pack("<I", n))
fp.write(a.tobytes())
-160
View File
@@ -1,160 +0,0 @@
from PIL import Image, ImageFont, ImageDraw
import scipy.ndimage as nd
import numpy as np
from . import d2
# TODO: add support for newlines?
PIXELS = 2**22
def _load_image(thing):
if isinstance(thing, str):
return Image.open(thing)
elif isinstance(thing, (np.ndarray, np.generic)):
return Image.fromarray(thing)
return Image.fromarray(np.array(thing))
def measure_text(name, text, width=None, height=None):
font = ImageFont.truetype(name, 96)
x0, y0, x1, y1 = font.getbbox(text)
aspect = (x1 - x0) / (y1 - y0)
if width is None and height is None:
height = 1
if width is None:
width = height * aspect
if height is None:
height = width / aspect
return (width, height)
def measure_image(thing, width=None, height=None):
im = _load_image(thing)
w, h = im.size
aspect = w / h
if width is None and height is None:
height = 1
if width is None:
width = height * aspect
if height is None:
height = width / aspect
return (width, height)
@d2.sdf2
def text(font_name, text, width=None, height=None, pixels=PIXELS, points=512):
# load font file
font = ImageFont.truetype(font_name, points)
# compute texture bounds
p = 0.2
x0, y0, x1, y1 = font.getbbox(text)
px = int((x1 - x0) * p)
py = int((y1 - y0) * p)
tw = x1 - x0 + 1 + px * 2
th = y1 - y0 + 1 + py * 2
# render text to image
im = Image.new("L", (tw, th))
draw = ImageDraw.Draw(im)
draw.text((px - x0, py - y0), text, font=font, fill=255)
return _sdf(width, height, pixels, px, py, im)
@d2.sdf2
def image(thing, width=None, height=None, pixels=PIXELS):
im = _load_image(thing).convert("L")
return _sdf(width, height, pixels, 0, 0, im)
def _sdf(width, height, pixels, px, py, im):
tw, th = im.size
# downscale image if necessary
factor = (pixels / (tw * th)) ** 0.5
if factor < 1:
tw, th = int(round(tw * factor)), int(round(th * factor))
px, py = int(round(px * factor)), int(round(py * factor))
im = im.resize((tw, th))
# convert to numpy array and apply distance transform
im = im.convert("1")
a = np.array(im)
inside = -nd.distance_transform_edt(a)
outside = nd.distance_transform_edt(~a)
texture = np.zeros(a.shape)
texture[a] = inside[a]
texture[~a] = outside[~a]
# save debug image
# a = np.abs(texture)
# lo, hi = a.min(), a.max()
# a = (a - lo) / (hi - lo) * 255
# im = Image.fromarray(a.astype('uint8'))
# im.save('debug.png')
# compute world bounds
pw = tw - px * 2
ph = th - py * 2
aspect = pw / ph
if width is None and height is None:
height = 1
if width is None:
width = height * aspect
if height is None:
height = width / aspect
x0 = -width / 2
y0 = -height / 2
x1 = width / 2
y1 = height / 2
# scale texture distances
scale = width / tw
texture *= scale
# prepare fallback rectangle
# TODO: reduce size based on mesh resolution instead of dividing by 2
rectangle = d2.rectangle((width / 2, height / 2))
def f(p):
x = p[:, 0]
y = p[:, 1]
u = (x - x0) / (x1 - x0)
v = (y - y0) / (y1 - y0)
v = 1 - v
i = u * pw + px
j = v * ph + py
d = _bilinear_interpolate(texture, i, j)
q = rectangle(p).reshape(-1)
outside = (i < 0) | (i >= tw - 1) | (j < 0) | (j >= th - 1)
d[outside] = q[outside]
return d
return f
def _bilinear_interpolate(a, x, y):
x0 = np.floor(x).astype(int)
x1 = x0 + 1
y0 = np.floor(y).astype(int)
y1 = y0 + 1
x0 = np.clip(x0, 0, a.shape[1] - 1)
x1 = np.clip(x1, 0, a.shape[1] - 1)
y0 = np.clip(y0, 0, a.shape[0] - 1)
y1 = np.clip(y1, 0, a.shape[0] - 1)
pa = a[y0, x0]
pb = a[y1, x0]
pc = a[y0, x1]
pd = a[y1, x1]
wa = (x1 - x) * (y1 - y)
wb = (x1 - x) * (y - y0)
wc = (x - x0) * (y1 - y)
wd = (x - x0) * (y - y0)
return wa * pa + wb * pb + wc * pc + wd * pd
-3
View File
@@ -1,3 +0,0 @@
import pint
units = pint.UnitRegistry()
-32
View File
@@ -1,32 +0,0 @@
import math
import functools
import inspect
import numpy as np
pi = math.pi
degrees = math.degrees
radians = math.radians
def n_trailing_ascending_positive(d):
"""
Determine how many elements in a given sequence are positive and ascending.
Args:
d (sequence of numbers): the sequence to check
Returns:
int : the amount of trailing ascending positive elements
"""
d = np.array(d).flatten()
# is the next element larger than previous and positive?
order = (d[1:] > d[:-1]) & (d[:-1] > 0)
# TODO: Not happy at all with this if/else mess. Is there no easier way to find the
# index in a numpy array after which the values are only ascending? 🤔
if np.all(order): # all ascending
return d.size
elif np.all(~order): # none ascending
return 0
else: # count from end how many are ascending
return np.argmin(order[::-1]) + 1
+30
View File
@@ -0,0 +1,30 @@
"""
Fluency CAD - Parametric CAD Application
A modern parametric CAD application built on OpenCASCADE Technology (OCCT)
with a clean Python API using OCP (OpenCASCADE Python bindings).
"""
__version__ = "2.0.0"
__author__ = "Fluency CAD Team"
from fluency.geometry.base import (
Point2D,
Point3D,
GeometryObject,
GeometryKernel,
SketchInterface,
)
from fluency.geometry_occ.kernel import OCGeometryKernel
from fluency.geometry_occ.sketch import OCCSketch
__all__ = [
"Point2D",
"Point3D",
"GeometryObject",
"GeometryKernel",
"SketchInterface",
"OCGeometryKernel",
"OCCSketch",
]
+19
View File
@@ -0,0 +1,19 @@
"""Geometry abstraction layer for Fluency CAD."""
from fluency.geometry.base import (
Point2D,
Point3D,
GeometryObject,
GeometryKernel,
SketchInterface,
SketchEntity,
)
__all__ = [
"Point2D",
"Point3D",
"GeometryObject",
"GeometryKernel",
"SketchInterface",
"SketchEntity",
]
+437
View File
@@ -0,0 +1,437 @@
"""
Geometry abstraction layer for Fluency CAD.
This module defines abstract interfaces for geometry operations,
allowing different geometry kernels to be used interchangeably.
"""
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import List, Tuple, Optional, Any, Dict
import numpy as np
@dataclass
class Point2D:
"""2D point representation."""
x: float
y: float
def to_tuple(self) -> Tuple[float, float]:
return (self.x, self.y)
def to_array(self) -> np.ndarray:
return np.array([self.x, self.y])
def distance_to(self, other: "Point2D") -> float:
return np.sqrt((self.x - other.x) ** 2 + (self.y - other.y) ** 2)
def __eq__(self, other: object) -> bool:
if not isinstance(other, Point2D):
return False
return abs(self.x - other.x) < 1e-6 and abs(self.y - other.y) < 1e-6
@dataclass
class Point3D:
"""3D point representation."""
x: float
y: float
z: float
def to_tuple(self) -> Tuple[float, float, float]:
return (self.x, self.y, self.z)
def to_array(self) -> np.ndarray:
return np.array([self.x, self.y, self.z])
def distance_to(self, other: "Point3D") -> float:
return np.sqrt((self.x - other.x) ** 2 + (self.y - other.y) ** 2 + (self.z - other.z) ** 2)
def __eq__(self, other: object) -> bool:
if not isinstance(other, Point3D):
return False
return (
abs(self.x - other.x) < 1e-6
and abs(self.y - other.y) < 1e-6
and abs(self.z - other.z) < 1e-6
)
class GeometryObject:
"""Base class for geometry objects."""
def __init__(self, shape: Any = None, metadata: Optional[Dict] = None):
self.shape = shape
self.metadata = metadata or {}
self._mesh_cache: Optional[Tuple[np.ndarray, np.ndarray]] = None
def invalidate_cache(self) -> None:
"""Invalidate any cached data."""
self._mesh_cache = None
class SketchEntity:
"""Base class for sketch entities (points, lines, circles)."""
def __init__(self, entity_id: int, entity_type: str):
self.id = entity_id
self.entity_type = entity_type
self.constraints: List[str] = []
self.is_construction: bool = False
def add_constraint(self, constraint_type: str) -> None:
self.constraints.append(constraint_type)
class GeometryKernel(ABC):
"""
Abstract base class for geometry kernels.
A geometry kernel provides primitives, operations, and export capabilities
for CAD geometry.
"""
@abstractmethod
def create_point(self, x: float, y: float) -> GeometryObject:
"""Create a 2D point."""
pass
@abstractmethod
def create_line(self, start: Point2D, end: Point2D) -> GeometryObject:
"""Create a 2D line segment."""
pass
@abstractmethod
def create_circle(self, center: Point2D, radius: float) -> GeometryObject:
"""Create a 2D circle."""
pass
@abstractmethod
def create_arc(
self, center: Point2D, radius: float, start_angle: float, end_angle: float
) -> GeometryObject:
"""Create a 2D arc."""
pass
@abstractmethod
def create_polygon(self, points: List[Point2D]) -> GeometryObject:
"""Create a closed polygon from points."""
pass
@abstractmethod
def create_rectangle(
self, width: float, height: float, center: Optional[Point2D] = None
) -> GeometryObject:
"""Create a rectangle."""
pass
@abstractmethod
def extrude(
self,
sketch: GeometryObject,
height: float,
direction: Tuple[float, float, float] = (0, 0, 1),
symmetric: bool = False,
) -> GeometryObject:
"""Extrude a 2D sketch into a 3D solid."""
pass
@abstractmethod
def revolve(
self,
sketch: GeometryObject,
angle: float = 360.0,
axis: Tuple[float, float, float] = (0, 0, 1),
origin: Tuple[float, float, float] = (0, 0, 0),
) -> GeometryObject:
"""Revolve a 2D sketch around an axis."""
pass
@abstractmethod
def loft(self, profiles: List[GeometryObject], ruled: bool = False) -> GeometryObject:
"""Create a loft between multiple profiles."""
pass
@abstractmethod
def sweep(
self, profile: GeometryObject, path: GeometryObject, is_frenet: bool = False
) -> GeometryObject:
"""Sweep a profile along a path."""
pass
@abstractmethod
def boolean_union(self, *bodies: GeometryObject) -> GeometryObject:
"""Union multiple bodies."""
pass
@abstractmethod
def boolean_difference(self, base: GeometryObject, tool: GeometryObject) -> GeometryObject:
"""Subtract tool from base."""
pass
@abstractmethod
def boolean_intersection(self, body1: GeometryObject, body2: GeometryObject) -> GeometryObject:
"""Intersect two bodies."""
pass
@abstractmethod
def fillet(
self, body: GeometryObject, radius: float, edges: Optional[List[Any]] = None
) -> GeometryObject:
"""Apply fillet to edges."""
pass
@abstractmethod
def chamfer(
self, body: GeometryObject, size: float, edges: Optional[List[Any]] = None
) -> GeometryObject:
"""Apply chamfer to edges."""
pass
@abstractmethod
def shell(
self, body: GeometryObject, thickness: float, faces_to_remove: Optional[List[Any]] = None
) -> GeometryObject:
"""Create a shell (hollow body)."""
pass
@abstractmethod
def offset(self, face: GeometryObject, distance: float) -> GeometryObject:
"""Offset a face or surface."""
pass
@abstractmethod
def translate(self, body: GeometryObject, vector: Tuple[float, float, float]) -> GeometryObject:
"""Translate a body."""
pass
@abstractmethod
def rotate(
self,
body: GeometryObject,
axis: Tuple[float, float, float],
angle: float,
origin: Tuple[float, float, float] = (0, 0, 0),
) -> GeometryObject:
"""Rotate a body around an axis."""
pass
@abstractmethod
def scale(self, body: GeometryObject, factor: float) -> GeometryObject:
"""Scale a body uniformly."""
pass
@abstractmethod
def mirror(
self,
body: GeometryObject,
plane_normal: Tuple[float, float, float],
plane_origin: Tuple[float, float, float] = (0, 0, 0),
) -> GeometryObject:
"""Mirror a body across a plane."""
pass
@abstractmethod
def export_step(self, body: GeometryObject, filepath: str, schema: str = "AP214") -> bool:
"""Export to STEP format."""
pass
@abstractmethod
def export_iges(self, body: GeometryObject, filepath: str) -> bool:
"""Export to IGES format."""
pass
@abstractmethod
def export_stl(
self, body: GeometryObject, filepath: str, tolerance: float = 0.1, ascii_mode: bool = False
) -> bool:
"""Export to STL format."""
pass
@abstractmethod
def import_step(self, filepath: str) -> GeometryObject:
"""Import from STEP format."""
pass
@abstractmethod
def import_iges(self, filepath: str) -> GeometryObject:
"""Import from IGES format."""
pass
@abstractmethod
def get_mesh(
self, body: GeometryObject, tolerance: float = 0.1
) -> Tuple[np.ndarray, np.ndarray]:
"""
Get triangulated mesh for rendering.
Returns:
Tuple of (vertices, faces) where:
- vertices: Nx3 numpy array of vertex positions
- faces: Mx3 numpy array of triangle indices
"""
pass
@abstractmethod
def get_edges(self, body: GeometryObject) -> Tuple[np.ndarray, np.ndarray]:
"""
Get edge wireframe for rendering.
Returns:
Tuple of (vertices, edges) where:
- vertices: Nx3 numpy array of vertex positions
- edges: Mx2 numpy array of edge vertex indices
"""
pass
@abstractmethod
def get_bounding_box(self, body: GeometryObject) -> Tuple[Point3D, Point3D]:
"""Get the bounding box of a body."""
pass
@abstractmethod
def get_volume(self, body: GeometryObject) -> float:
"""Calculate the volume of a solid body."""
pass
@abstractmethod
def get_surface_area(self, body: GeometryObject) -> float:
"""Calculate the surface area of a body."""
pass
@abstractmethod
def get_center_of_mass(self, body: GeometryObject) -> Point3D:
"""Calculate the center of mass of a solid body."""
pass
class SketchInterface(ABC):
"""
Abstract interface for 2D sketching with constraints.
A sketch provides 2D geometry creation and constraint solving
capabilities for parametric CAD.
"""
@abstractmethod
def add_point(self, x: float, y: float) -> SketchEntity:
"""Add a point to the sketch."""
pass
@abstractmethod
def add_line(self, start: SketchEntity, end: SketchEntity) -> SketchEntity:
"""Add a line between two points."""
pass
@abstractmethod
def add_circle(self, center: SketchEntity, radius: float) -> SketchEntity:
"""Add a circle."""
pass
@abstractmethod
def add_arc(
self,
center: SketchEntity,
radius: float,
start_point: SketchEntity,
end_point: SketchEntity,
) -> SketchEntity:
"""Add an arc."""
pass
@abstractmethod
def add_rectangle(
self, corner1: Tuple[float, float], corner2: Tuple[float, float]
) -> List[SketchEntity]:
"""Add a rectangle, returning the created entities."""
pass
@abstractmethod
def constrain_coincident(self, *entities: SketchEntity) -> bool:
"""Make entities coincident."""
pass
@abstractmethod
def constrain_horizontal(self, line: SketchEntity) -> bool:
"""Constrain a line to be horizontal."""
pass
@abstractmethod
def constrain_vertical(self, line: SketchEntity) -> bool:
"""Constrain a line to be vertical."""
pass
@abstractmethod
def constrain_distance(
self, entity1: SketchEntity, entity2: SketchEntity, distance: float
) -> bool:
"""Constrain distance between two entities."""
pass
@abstractmethod
def constrain_angle(self, line1: SketchEntity, line2: SketchEntity, angle: float) -> bool:
"""Constrain angle between two lines."""
pass
@abstractmethod
def constrain_parallel(self, line1: SketchEntity, line2: SketchEntity) -> bool:
"""Constrain two lines to be parallel."""
pass
@abstractmethod
def constrain_perpendicular(self, line1: SketchEntity, line2: SketchEntity) -> bool:
"""Constrain two lines to be perpendicular."""
pass
@abstractmethod
def constrain_midpoint(self, point: SketchEntity, line: SketchEntity) -> bool:
"""Constrain a point to be at the midpoint of a line."""
pass
@abstractmethod
def constrain_tangent(self, entity1: SketchEntity, entity2: SketchEntity) -> bool:
"""Constrain two entities to be tangent."""
pass
@abstractmethod
def constrain_equal_length(self, line1: SketchEntity, line2: SketchEntity) -> bool:
"""Constrain two lines to have equal length."""
pass
@abstractmethod
def constrain_equal_radius(self, circle1: SketchEntity, circle2: SketchEntity) -> bool:
"""Constrain two circles to have equal radius."""
pass
@abstractmethod
def constrain_fixed(self, entity: SketchEntity) -> bool:
"""Fix an entity in place."""
pass
@abstractmethod
def solve(self) -> bool:
"""Solve all constraints."""
pass
@abstractmethod
def get_geometry(self) -> GeometryObject:
"""Get the solved geometry for operations."""
pass
@abstractmethod
def get_points(self) -> List[Point2D]:
"""Get all point positions."""
pass
@abstractmethod
def clear(self) -> None:
"""Clear all geometry and constraints."""
pass
@abstractmethod
def delete_entity(self, entity: SketchEntity) -> bool:
"""Delete an entity and its constraints."""
pass
+11
View File
@@ -0,0 +1,11 @@
"""OpenCASCADE geometry module."""
from fluency.geometry_occ.kernel import OCGeometryKernel, OCCGeometryObject
from fluency.geometry_occ.sketch import OCCSketch, OCCSketchEntity
__all__ = [
"OCGeometryKernel",
"OCCGeometryObject",
"OCCSketch",
"OCCSketchEntity",
]
+743
View File
@@ -0,0 +1,743 @@
"""
OpenCASCADE-based geometry kernel for Fluency CAD.
This module provides a concrete implementation of the geometry kernel
using OCP (OpenCASCADE Python bindings).
"""
from typing import List, Tuple, Optional, Any, Dict
import numpy as np
from fluency.geometry.base import (
GeometryKernel,
GeometryObject,
Point2D,
Point3D,
)
class OCCGeometryObject(GeometryObject):
"""Geometry object wrapper for OpenCASCADE shapes."""
def __init__(self, shape: Any = None, metadata: Optional[Dict] = None):
super().__init__(shape, metadata)
class OCGeometryKernel(GeometryKernel):
"""
OpenCASCADE-based geometry kernel implementation.
This kernel uses OCP (OpenCASCADE Python bindings) for all geometry
operations.
"""
def __init__(self) -> None:
self._tolerance: float = 0.001
self._mesh_tolerance: float = 0.1
def _get_shape(self, obj: GeometryObject) -> Any:
"""Extract the underlying OCC shape from a GeometryObject.
Returns *None* if the object carries no shape (e.g. an empty sketch)
callers should check for None before using the result.
"""
if isinstance(obj, OCCGeometryObject):
if obj.shape is not None and hasattr(obj.shape, "wrapped"):
return obj.shape.wrapped
return obj.shape
# Non-OCCGeometryObject: return its shape if present, else None.
# (Use explicit identity/truth checks — some OCP TopoDS objects have a
# falsy __bool__, so ``obj.shape if obj.shape`` is unsafe.)
shape = getattr(obj, "shape", None)
return shape if shape is not None else None
def create_point(self, x: float, y: float) -> GeometryObject:
"""Create a 2D point."""
from OCP.gp import gp_Pnt
return OCCGeometryObject(gp_Pnt(x, y, 0))
def create_line(self, start: Point2D, end: Point2D) -> GeometryObject:
"""Create a 2D line segment."""
from OCP.gp import gp_Pnt
from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeEdge
edge = BRepBuilderAPI_MakeEdge(
gp_Pnt(start.x, start.y, 0), gp_Pnt(end.x, end.y, 0)
).Edge()
return OCCGeometryObject(edge, {"type": "line"})
def create_circle(self, center: Point2D, radius: float) -> GeometryObject:
"""Create a 2D circle."""
from OCP.gp import gp_Pnt, gp_Dir, gp_Ax2, gp_Circ
from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeEdge
circ = gp_Circ(
gp_Ax2(gp_Pnt(center.x, center.y, 0), gp_Dir(0, 0, 1)), radius
)
edge = BRepBuilderAPI_MakeEdge(circ).Edge()
return OCCGeometryObject(edge, {"type": "circle"})
def create_arc(
self, center: Point2D, radius: float, start_angle: float, end_angle: float
) -> GeometryObject:
"""Create a 2D arc."""
import math
from OCP.gp import gp_Pnt, gp_Dir, gp_Ax2, gp_Circ
from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeEdge
start_rad = math.radians(start_angle)
end_rad = math.radians(end_angle)
circ = gp_Circ(
gp_Ax2(gp_Pnt(center.x, center.y, 0), gp_Dir(0, 0, 1)), radius
)
edge = BRepBuilderAPI_MakeEdge(circ, start_rad, end_rad).Edge()
return OCCGeometryObject(edge, {"type": "arc"})
def create_polygon(self, points: List[Point2D]) -> GeometryObject:
"""Create a closed polygon from points."""
from OCP.gp import gp_Pnt
from OCP.BRepBuilderAPI import BRepBuilderAPI_MakePolygon
if len(points) < 3:
raise ValueError("Polygon requires at least 3 points")
mp = BRepBuilderAPI_MakePolygon()
for pt in points:
mp.Add(gp_Pnt(pt.x, pt.y, 0))
mp.Close()
return OCCGeometryObject(mp.Wire(), {"type": "polygon"})
def create_rectangle(
self, width: float, height: float, center: Optional[Point2D] = None
) -> GeometryObject:
"""Create a rectangle."""
from OCP.gp import gp_Pnt
from OCP.BRepBuilderAPI import BRepBuilderAPI_MakePolygon
cx = center.x if center else 0
cy = center.y if center else 0
hw = width / 2.0
hh = height / 2.0
mp = BRepBuilderAPI_MakePolygon()
mp.Add(gp_Pnt(cx - hw, cy - hh, 0))
mp.Add(gp_Pnt(cx + hw, cy - hh, 0))
mp.Add(gp_Pnt(cx + hw, cy + hh, 0))
mp.Add(gp_Pnt(cx - hw, cy + hh, 0))
mp.Close()
return OCCGeometryObject(mp.Wire(), {"type": "rectangle"})
def extrude(
self,
sketch: GeometryObject,
height: float,
direction: Tuple[float, float, float] = (0, 0, 1),
symmetric: bool = False,
) -> GeometryObject:
"""Extrude a sketch face into a 3D solid along the sketch plane normal.
The sketch's plane normal is read from ``sketch.metadata["normal"]``
(set by ``OCCSketch.build_face_geometry``); it defaults to +Z for
legacy objects that don't carry one. *direction* is accepted for API
compatibility but ignored the plane normal is authoritative. A
negative *height* extrudes against the normal.
"""
from OCP.gp import gp_Vec
from OCP.BRepPrimAPI import BRepPrimAPI_MakePrism
from OCP.BRepAlgoAPI import BRepAlgoAPI_Fuse
from OCP.TopoDS import TopoDS_Shape
# Defensive: figure out the actual shape from whatever the caller
# hands us, and surface a clear error if we can't get one.
if isinstance(sketch, OCCGeometryObject):
face = self._get_shape(sketch)
elif isinstance(sketch, TopoDS_Shape):
face = sketch
else:
face = self._get_shape(sketch)
if face is None:
raise ValueError(
"Cannot extrude: sketch has no geometry. "
"Draw a closed profile before extruding."
)
# If the wrapper class itself leaked through somehow, surface a
# clear error instead of letting BRepPrimAPI_MakePrism raise an
# opaque TypeError.
if isinstance(face, OCCGeometryObject):
raise ValueError(
"Cannot extrude: sketch geometry is a wrapper, not a shape. "
"This is a bug — please report it."
)
# ``face`` may be a TopoDS_Face (new path) or a compound/wire.
# If it's not already a face, build one.
face = self._ensure_face(face)
if face is None:
raise ValueError(
"Cannot extrude: sketch geometry is not a valid face. "
"Ensure the profile is closed (no open ends)."
)
normal = self._sketch_normal(sketch)
nx, ny, nz = normal
def _prism(h: float):
vec = gp_Vec(nx * h, ny * h, nz * h)
maker = BRepPrimAPI_MakePrism(face, vec, False, True)
maker.Build()
return maker.Shape()
if symmetric:
half = height / 2.0
pos = _prism(half)
neg = _prism(-half)
fuse = BRepAlgoAPI_Fuse(pos, neg)
fuse.Build()
solid = fuse.Shape()
else:
solid = _prism(height)
return OCCGeometryObject(solid, {"type": "extrusion", "normal": normal})
@staticmethod
def _sketch_normal(obj: GeometryObject) -> Tuple[float, float, float]:
"""Return the normal stored on a sketch-derived geometry object, else +Z."""
import numpy as np
meta = getattr(obj, "metadata", None) or {}
n = meta.get("normal")
if n is None:
return (0.0, 0.0, 1.0)
arr = np.asarray(n, dtype=float)
norm = float(np.linalg.norm(arr))
if norm < 1e-12:
return (0.0, 0.0, 1.0)
arr = arr / norm
return (float(arr[0]), float(arr[1]), float(arr[2]))
@staticmethod
def _ensure_face(shape: Any) -> Any:
"""Return a ``TopoDS_Face`` from *shape*, or *None* if impossible.
If *shape* is already a face, return it unchanged; otherwise try to
build a planar face from it (wire/edge/compound). Returns *None* for
empty/invalid input so callers can surface a clear error instead of
feeding a non-face to ``BRepPrimAPI_MakePrism``.
"""
from OCP.TopoDS import TopoDS_Face
from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeFace
if shape is None:
return None
if isinstance(shape, TopoDS_Face):
return shape
try:
maker = BRepBuilderAPI_MakeFace(shape, True)
maker.Build()
if maker.IsDone():
return maker.Face()
except Exception:
pass
return None
def revolve(
self,
sketch: GeometryObject,
angle: float = 360.0,
axis: Tuple[float, float, float] = (0, 0, 1),
origin: Tuple[float, float, float] = (0, 0, 0),
) -> GeometryObject:
"""Revolve a sketch face around an axis."""
import math
# Get the OCC shape directly (a TopoDS_Face for new sketch geometry).
shape = self._get_shape(sketch)
face = self._ensure_face(shape)
from OCP.gp import gp_Ax1, gp_Pnt, gp_Dir
from OCP.BRepPrimAPI import BRepPrimAPI_MakeRevol
# Revolve the face around the axis
revolve_axis = gp_Ax1(gp_Pnt(*origin), gp_Dir(*axis))
angle_rad = math.radians(angle)
revolver = BRepPrimAPI_MakeRevol(face, revolve_axis, angle_rad)
revolver.Build()
solid_shape = revolver.Shape()
return OCCGeometryObject(solid_shape, {"type": "revolution"})
def loft(self, profiles: List[GeometryObject], ruled: bool = False) -> GeometryObject:
"""Create a loft between multiple profiles."""
from OCP.BRepOffsetAPI import BRepOffsetAPI_ThruSections
from OCP.TopExp import TopExp_Explorer
from OCP.TopAbs import TopAbs_WIRE
from OCP.TopoDS import TopoDS
if len(profiles) < 2:
raise ValueError("Loft requires at least 2 profiles")
loft_maker = BRepOffsetAPI_ThruSections(True, ruled)
for profile in profiles:
shape = self._get_shape(profile)
explorer = TopExp_Explorer(shape, TopAbs_WIRE)
while explorer.More():
wire = TopoDS.Wire_s(explorer.Current())
loft_maker.AddWire(wire)
explorer.Next()
loft_maker.Build()
solid = loft_maker.Shape()
return OCCGeometryObject(solid, {"type": "loft"})
def sweep(
self, profile: GeometryObject, path: GeometryObject, is_frenet: bool = False
) -> GeometryObject:
"""Sweep a profile along a path."""
from OCP.BRepOffsetAPI import BRepOffsetAPI_MakePipeShell
from OCP.TopExp import TopExp_Explorer
from OCP.TopAbs import TopAbs_WIRE
from OCP.TopoDS import TopoDS
profile_shape = self._get_shape(profile)
path_shape = self._get_shape(path)
def _first_wire(shape):
exp = TopExp_Explorer(shape, TopAbs_WIRE)
if exp.More():
return TopoDS.Wire_s(exp.Current())
raise ValueError("No wire found in shape for sweep")
profile_wire = _first_wire(profile_shape)
path_wire = _first_wire(path_shape)
pipe = BRepOffsetAPI_MakePipeShell(path_wire)
pipe.Add(profile_wire, False, False)
if is_frenet:
pipe.SetMode(True)
pipe.Build()
solid = pipe.Shape()
return OCCGeometryObject(solid, {"type": "sweep"})
def boolean_union(self, *bodies: GeometryObject) -> GeometryObject:
"""Union multiple bodies."""
if len(bodies) < 2:
return bodies[0] if bodies else OCCGeometryObject(None)
result = self._get_shape(bodies[0])
for body in bodies[1:]:
shape = self._get_shape(body)
from OCP.BRepAlgoAPI import BRepAlgoAPI_Fuse
fuse = BRepAlgoAPI_Fuse(result, shape)
fuse.Build()
result = fuse.Shape()
return OCCGeometryObject(result, {"type": "union"})
def boolean_difference(self, base: GeometryObject, tool: GeometryObject) -> GeometryObject:
"""Subtract tool from base."""
base_shape = self._get_shape(base)
tool_shape = self._get_shape(tool)
from OCP.BRepAlgoAPI import BRepAlgoAPI_Cut
cut = BRepAlgoAPI_Cut(base_shape, tool_shape)
cut.Build()
return OCCGeometryObject(cut.Shape(), {"type": "difference"})
def boolean_intersection(self, body1: GeometryObject, body2: GeometryObject) -> GeometryObject:
"""Intersect two bodies."""
shape1 = self._get_shape(body1)
shape2 = self._get_shape(body2)
from OCP.BRepAlgoAPI import BRepAlgoAPI_Common
common = BRepAlgoAPI_Common(shape1, shape2)
common.Build()
return OCCGeometryObject(common.Shape(), {"type": "intersection"})
def fillet(
self, body: GeometryObject, radius: float, edges: Optional[List[Any]] = None
) -> GeometryObject:
"""Apply fillet to edges."""
shape = self._get_shape(body)
from OCP.BRepFilletAPI import BRepFilletAPI_MakeFillet
fillet = BRepFilletAPI_MakeFillet(shape)
if edges:
for edge in edges:
fillet.Add(radius, edge)
else:
from OCP.TopExp import TopExp_Explorer
from OCP.TopAbs import TopAbs_EDGE
explorer = TopExp_Explorer(shape, TopAbs_EDGE)
while explorer.More():
fillet.Add(radius, explorer.Current())
explorer.Next()
fillet.Build()
return OCCGeometryObject(fillet.Shape(), {"type": "fillet"})
def chamfer(
self, body: GeometryObject, size: float, edges: Optional[List[Any]] = None
) -> GeometryObject:
"""Apply chamfer to edges."""
shape = self._get_shape(body)
from OCP.BRepFilletAPI import BRepFilletAPI_MakeChamfer
chamfer = BRepFilletAPI_MakeChamfer(shape)
if edges:
for edge in edges:
chamfer.Add(size, edge)
else:
from OCP.TopExp import TopExp_Explorer
from OCP.TopAbs import TopAbs_EDGE
explorer = TopExp_Explorer(shape, TopAbs_EDGE)
while explorer.More():
chamfer.Add(size, explorer.Current())
explorer.Next()
chamfer.Build()
return OCCGeometryObject(chamfer.Shape(), {"type": "chamfer"})
def shell(
self, body: GeometryObject, thickness: float, faces_to_remove: Optional[List[Any]] = None
) -> GeometryObject:
"""Create a shell (hollow body)."""
shape = self._get_shape(body)
from OCP.BRepOffsetAPI import BRepOffsetAPI_MakeThickSolid
from OCP.TopTools import TopTools_ListOfShape
faces_list = TopTools_ListOfShape()
if faces_to_remove:
for face in faces_to_remove:
faces_list.Append(face)
shell_maker = BRepOffsetAPI_MakeThickSolid()
shell_maker.MakeThickSolidByJoin(shape, faces_list, thickness, 0.001)
shell_maker.Build()
return OCCGeometryObject(shell_maker.Shape(), {"type": "shell"})
def offset(self, face: GeometryObject, distance: float) -> GeometryObject:
"""Offset a face or surface."""
shape = self._get_shape(face)
from OCP.BRepOffsetAPI import BRepOffsetAPI_MakeOffset
offset_maker = BRepOffsetAPI_MakeOffset(shape, False)
offset_maker.Perform(distance)
return OCCGeometryObject(offset_maker.Shape(), {"type": "offset"})
def translate(self, body: GeometryObject, vector: Tuple[float, float, float]) -> GeometryObject:
"""Translate a body."""
shape = self._get_shape(body)
from OCP.BRepBuilderAPI import BRepBuilderAPI_Transform
from OCP.gp import gp_Trsf, gp_Vec
transform = gp_Trsf()
transform.SetTranslation(gp_Vec(*vector))
transformer = BRepBuilderAPI_Transform(shape, transform)
return OCCGeometryObject(transformer.Shape(), {"type": "translated"})
def rotate(
self,
body: GeometryObject,
axis: Tuple[float, float, float],
angle: float,
origin: Tuple[float, float, float] = (0, 0, 0),
) -> GeometryObject:
"""Rotate a body around an axis."""
import math
shape = self._get_shape(body)
from OCP.BRepBuilderAPI import BRepBuilderAPI_Transform
from OCP.gp import gp_Trsf, gp_Ax1, gp_Pnt, gp_Dir
ax1 = gp_Ax1(gp_Pnt(*origin), gp_Dir(*axis))
transform = gp_Trsf()
transform.SetRotation(ax1, angle)
transformer = BRepBuilderAPI_Transform(shape, transform)
return OCCGeometryObject(transformer.Shape(), {"type": "rotated"})
def scale(self, body: GeometryObject, factor: float) -> GeometryObject:
"""Scale a body uniformly."""
shape = self._get_shape(body)
from OCP.BRepBuilderAPI import BRepBuilderAPI_Transform
from OCP.gp import gp_Trsf
transform = gp_Trsf()
transform.SetScale(factor)
transformer = BRepBuilderAPI_Transform(shape, transform)
return OCCGeometryObject(transformer.Shape(), {"type": "scaled"})
def mirror(
self,
body: GeometryObject,
plane_normal: Tuple[float, float, float],
plane_origin: Tuple[float, float, float] = (0, 0, 0),
) -> GeometryObject:
"""Mirror a body across a plane."""
shape = self._get_shape(body)
from OCP.BRepBuilderAPI import BRepBuilderAPI_Transform
from OCP.gp import gp_Trsf, gp_Ax2, gp_Pnt, gp_Dir
ax2 = gp_Ax2(gp_Pnt(*plane_origin), gp_Dir(*plane_normal))
transform = gp_Trsf()
transform.SetMirror(ax2)
transformer = BRepBuilderAPI_Transform(shape, transform)
return OCCGeometryObject(transformer.Shape(), {"type": "mirrored"})
def export_step(self, body: GeometryObject, filepath: str, schema: str = "AP214") -> bool:
"""Export to STEP format."""
try:
shape = self._get_shape(body)
from OCP.STEPControl import STEPControl_Writer, STEPControl_AsIs
from OCP.Interface import Interface_Static
writer = STEPControl_Writer()
if schema == "AP214":
Interface_Static.SetCVal_s("write.step.schema", "AP214")
elif schema == "AP203":
Interface_Static.SetCVal_s("write.step.schema", "AP203")
writer.Transfer(shape, STEPControl_AsIs)
return writer.Write(filepath)
except Exception as e:
print(f"STEP export error: {e}")
return False
def export_iges(self, body: GeometryObject, filepath: str) -> bool:
"""Export to IGES format."""
try:
shape = self._get_shape(body)
from OCP.IGESControl import IGESControl_Writer
from OCP.Interface import Interface_Static
Interface_Static.SetCVal_s("write.iges.schema", "5.3")
writer = IGESControl_Writer()
writer.AddShape(shape)
return writer.Write(filepath)
except Exception as e:
print(f"IGES export error: {e}")
return False
def export_stl(
self, body: GeometryObject, filepath: str, tolerance: float = 0.1, ascii_mode: bool = False
) -> bool:
"""Export to STL format."""
try:
shape = self._get_shape(body)
from OCP.StlAPI import StlAPI_Writer
from OCP.BRepMesh import BRepMesh_IncrementalMesh
mesh = BRepMesh_IncrementalMesh(shape, tolerance)
mesh.Perform()
writer = StlAPI_Writer()
writer.ASCIIMode = ascii_mode
return writer.Write(shape, filepath)
except Exception as e:
print(f"STL export error: {e}")
return False
def import_step(self, filepath: str) -> GeometryObject:
"""Import from STEP format."""
from OCP.STEPControl import STEPControl_Reader
from OCP.IFSelect import IFSelect_RetDone
reader = STEPControl_Reader()
status = reader.ReadFile(filepath)
if status != IFSelect_RetDone:
raise ValueError(f"Failed to read STEP file: {filepath}")
reader.TransferRoots()
shape = reader.OneShape()
return OCCGeometryObject(shape, {"type": "imported_step"})
def import_iges(self, filepath: str) -> GeometryObject:
"""Import from IGES format."""
from OCP.IGESControl import IGESControl_Reader
from OCP.IFSelect import IFSelect_RetDone
reader = IGESControl_Reader()
status = reader.ReadFile(filepath)
if status != IFSelect_RetDone:
raise ValueError(f"Failed to read IGES file: {filepath}")
reader.TransferRoots()
shape = reader.OneShape()
return OCCGeometryObject(shape, {"type": "imported_iges"})
def get_mesh(
self, body: GeometryObject, tolerance: float = 0.1
) -> Tuple[np.ndarray, np.ndarray]:
"""Get triangulated mesh for rendering."""
shape = self._get_shape(body)
from OCP.BRepMesh import BRepMesh_IncrementalMesh
from OCP.TopExp import TopExp_Explorer
from OCP.TopAbs import TopAbs_FACE
from OCP.BRep import BRep_Tool
from OCP.TopLoc import TopLoc_Location
# Use finer angular deflection (0.15 rad ≈ 24 segments/circle) so
# curved surfaces like cylinders render smoothly instead of faceted.
mesh = BRepMesh_IncrementalMesh(shape, tolerance, False, 0.15, True)
mesh.Perform()
vertices_list: List[List[float]] = []
faces_list: List[List[int]] = []
vertex_offset = 0
from OCP.TopoDS import TopoDS
from OCP.TopAbs import TopAbs_Orientation
explorer = TopExp_Explorer(shape, TopAbs_FACE)
while explorer.More():
face = TopoDS.Face_s(explorer.Current())
location = TopLoc_Location()
triangulation = BRep_Tool.Triangulation_s(face, location)
if triangulation is not None:
n_vertices = triangulation.NbNodes()
for i in range(1, n_vertices + 1):
p = triangulation.Node(i)
vertices_list.append([p.X(), p.Y(), p.Z()])
n_triangles = triangulation.NbTriangles()
# REVERSED faces store triangle winding in the natural (surface)
# orientation — we must flip it so the computed normals point
# outward (away from solid interior). TopAbs_REVERSED = 1.
reverse_winding = face.Orientation() == TopAbs_Orientation.TopAbs_REVERSED
for i in range(1, n_triangles + 1):
tri = triangulation.Triangle(i)
v0, v1, v2 = (
tri.Value(1) - 1 + vertex_offset,
tri.Value(2) - 1 + vertex_offset,
tri.Value(3) - 1 + vertex_offset,
)
if reverse_winding:
# Swap last two vertices to flip winding direction.
v1, v2 = v2, v1
faces_list.append([v0, v1, v2])
vertex_offset += n_vertices
explorer.Next()
return np.array(vertices_list, dtype=np.float32), np.array(faces_list, dtype=np.int32)
def get_edges(self, body: GeometryObject) -> Tuple[np.ndarray, np.ndarray]:
"""Get edge wireframe for rendering."""
shape = self._get_shape(body)
from OCP.TopExp import TopExp_Explorer
from OCP.TopAbs import TopAbs_EDGE
from OCP.BRep import BRep_Tool
from OCP.TopLoc import TopLoc_Location
from OCP.BRepAdaptor import BRepAdaptor_Curve
from OCP.GeomAbs import GeomAbs_Line, GeomAbs_Circle, GeomAbs_Ellipse, GeomAbs_BSplineCurve
vertices_list: List[List[float]] = []
edges_list: List[List[int]] = []
vertex_offset = 0
def discretize_edge(edge: Any, num_points: int = 20) -> List[List[float]]:
curve = BRepAdaptor_Curve(edge)
curve_type = curve.GetType()
points = []
if curve_type == GeomAbs_Line:
first = curve.FirstParameter()
last = curve.LastParameter()
p1 = curve.Value(first)
p2 = curve.Value(last)
points = [[p1.X(), p1.Y(), p1.Z()], [p2.X(), p2.Y(), p2.Z()]]
else:
first = curve.FirstParameter()
last = curve.LastParameter()
for i in range(num_points + 1):
t = first + (last - first) * i / num_points
p = curve.Value(t)
points.append([p.X(), p.Y(), p.Z()])
return points
explorer = TopExp_Explorer(shape, TopAbs_EDGE)
while explorer.More():
from OCP.TopoDS import TopoDS
edge = TopoDS.Edge_s(explorer.Current())
edge_points = discretize_edge(edge)
for i, pt in enumerate(edge_points):
vertices_list.append(pt)
if i < len(edge_points) - 1:
edges_list.append([vertex_offset + i, vertex_offset + i + 1])
vertex_offset += len(edge_points)
explorer.Next()
return np.array(vertices_list, dtype=np.float32), np.array(edges_list, dtype=np.int32)
def get_bounding_box(self, body: GeometryObject) -> Tuple[Point3D, Point3D]:
"""Get the bounding box of a body."""
shape = self._get_shape(body)
from OCP.Bnd import Bnd_Box
from OCP.BRepBndLib import BRepBndLib
bbox = Bnd_Box()
BRepBndLib.AddClose_s(shape, bbox)
xmin, ymin, zmin, xmax, ymax, zmax = bbox.Get()
return Point3D(xmin, ymin, zmin), Point3D(xmax, ymax, zmax)
def get_volume(self, body: GeometryObject) -> float:
"""Calculate the volume of a solid body."""
shape = self._get_shape(body)
from OCP.GProp import GProp_GProps
from OCP.BRepGProp import BRepGProp
props = GProp_GProps()
BRepGProp.VolumeProperties_s(shape, props)
return props.Mass()
def get_surface_area(self, body: GeometryObject) -> float:
"""Calculate the surface area of a body."""
shape = self._get_shape(body)
from OCP.GProp import GProp_GProps
from OCP.BRepGProp import BRepGProp
props = GProp_GProps()
BRepGProp.SurfaceProperties_s(shape, props)
return props.Mass()
def get_center_of_mass(self, body: GeometryObject) -> Point3D:
"""Calculate the center of mass of a solid body."""
shape = self._get_shape(body)
from OCP.GProp import GProp_GProps
from OCP.BRepGProp import BRepGProp
props = GProp_GProps()
BRepGProp.VolumeProperties_s(shape, props)
cg = props.CentreOfMass()
return Point3D(cg.X(), cg.Y(), cg.Z())
File diff suppressed because it is too large Load Diff
+5
View File
@@ -0,0 +1,5 @@
"""I/O module: project save/load."""
from fluency.io.project_io import save_project, load_project, project_zip_path
__all__ = ["save_project", "load_project", "project_zip_path"]
+716
View File
@@ -0,0 +1,716 @@
"""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,
"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"),
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),
"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.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
+61
View File
@@ -0,0 +1,61 @@
"""Fluency CAD - Main entry point.
This module is intentionally thin. The actual UI lives in the
``fluency.ui`` package:
ui.dialogs 4 modal dialogs (Extrude, Revolve, Offset, WorkplaneOrientation)
ui.viewer_widget Viewer3DWidget (3D canvas)
ui.sketch_widget Sketch2DWidget (2D sketcher + constraint solver)
ui.main_window MainWindow (application shell)
The public classes are re-exported here so that existing call sites
that do ``from fluency.main import MainWindow`` (notably
``tests/test_geometry.py``) keep working.
"""
from __future__ import annotations
import logging
import sys
from PySide6.QtWidgets import QApplication
from fluency.ui.dialogs import (
ExtrudeDialog,
OffsetDialog,
RevolveDialog,
WorkplaneOrientationDialog,
)
from fluency.ui.main_window import MainWindow
from fluency.ui.sketch_widget import Sketch2DWidget
from fluency.ui.viewer_widget import Viewer3DWidget
__all__ = [
"MainWindow",
"Sketch2DWidget",
"Viewer3DWidget",
"ExtrudeDialog",
"RevolveDialog",
"OffsetDialog",
"WorkplaneOrientationDialog",
"main",
]
def main() -> int:
"""Launch the Fluency CAD application.
Returns the ``QApplication.exec()`` exit code so that the console-script
entry point declared in ``pyproject.toml`` can forward it.
"""
app = QApplication(sys.argv)
app.setStyle("Fusion")
window = MainWindow()
window.show()
return app.exec()
if __name__ == "__main__":
sys.exit(main())
+15
View File
@@ -0,0 +1,15 @@
"""Models module."""
from fluency.models.data_model import (
Project,
Component,
Sketch,
Body,
)
__all__ = [
"Project",
"Component",
"Sketch",
"Body",
]
+772
View File
@@ -0,0 +1,772 @@
"""
Data models for Fluency CAD.
This module defines the core data structures for the CAD application
including projects, components, sketches, and bodies.
"""
from dataclasses import dataclass, field
from typing import Dict, List, Optional, Any, Tuple
from datetime import datetime
import uuid
import numpy as np
from fluency.geometry.base import (
Point2D,
Point3D,
GeometryObject,
SketchInterface,
)
from fluency.geometry_occ.kernel import OCGeometryKernel, OCCGeometryObject
from fluency.geometry_occ.sketch import OCCSketch
@dataclass
class Workplane:
"""
An independent working plane (datum plane) not tied to a face.
Workplanes can be created at any time and serve as the foundation for
sketching and subsequent 3D operations (extrude, cut, revolve, etc.).
They are visible in the 3D view as a semi-transparent reference grid.
"""
id: str = field(default_factory=lambda: str(uuid.uuid4()))
name: str = "Untitled Workplane"
origin: Tuple[float, float, float] = (0.0, 0.0, 0.0)
normal: Tuple[float, float, float] = (0.0, 0.0, 1.0)
x_dir: Tuple[float, float, float] = (1.0, 0.0, 0.0)
# OCC AIS shape (visual plane) object id in the renderer
render_object: Any = None
visible: bool = True
created_at: datetime = field(default_factory=datetime.now)
modified_at: datetime = field(default_factory=datetime.now)
def __post_init__(self):
# Normalise normal and x_dir on construction.
import numpy as np
n = np.asarray(self.normal, dtype=float)
n = n / np.linalg.norm(n)
x = np.asarray(self.x_dir, dtype=float)
# Remove any component of x along n, then renormalise.
x = x - np.dot(x, n) * n
x_norm = np.linalg.norm(x)
if x_norm < 1e-9:
fallback = np.array([1.0, 0.0, 0.0]) if abs(n[0]) < 0.9 else np.array([0.0, 1.0, 0.0])
x = fallback - np.dot(fallback, n) * n
x_norm = np.linalg.norm(x)
x = x / x_norm
y = np.cross(n, x)
y = y / np.linalg.norm(y)
self.normal = tuple(float(v) for v in n)
self.x_dir = tuple(float(v) for v in x)
self._y_dir = tuple(float(v) for v in y)
@property
def y_dir(self) -> Tuple[float, float, float]:
"""Derived in-plane Y axis (normal × x_dir)."""
return self._y_dir
def uv_to_world(self, u: float, v: float) -> Tuple[float, float, float]:
"""Map a UV point to 3D world coordinates on this plane."""
ox, oy, oz = self.origin
xx, xy, xz = self.x_dir
yx, yy, yz = self._y_dir
return (
ox + u * xx + v * yx,
oy + u * xy + v * yy,
oz + u * xz + v * yz,
)
def world_to_uv(self, p: Tuple[float, float, float]) -> Tuple[float, float]:
"""Map a 3D world point to UV coordinates on this plane."""
import numpy as np
ox, oy, oz = self.origin
v = np.array([p[0] - ox, p[1] - oy, p[2] - oz])
xd = np.array(self.x_dir, dtype=float)
yd = np.array(self._y_dir, dtype=float)
return (float(np.dot(v, xd)), float(np.dot(v, yd)))
@dataclass
class Sketch:
"""
2D sketch with constraints.
A sketch contains 2D geometry on a workplane that can be
extruded or revolved to create 3D bodies.
"""
id: str = field(default_factory=lambda: str(uuid.uuid4()))
name: str = "Untitled Sketch"
workplane_origin: np.ndarray = field(default_factory=lambda: np.array([0.0, 0.0, 0.0]))
workplane_normal: np.ndarray = field(default_factory=lambda: np.array([0.0, 0.0, 1.0]))
workplane_x_dir: np.ndarray = field(default_factory=lambda: np.array([1.0, 0.0, 0.0]))
occ_sketch: Optional[OCCSketch] = field(default_factory=OCCSketch)
geometry: Optional[OCCGeometryObject] = None
is_solved: bool = False
is_fully_constrained: bool = False
created_at: datetime = field(default_factory=datetime.now)
modified_at: datetime = field(default_factory=datetime.now)
def set_workplane(
self,
origin: Tuple[float, float, float],
normal: Tuple[float, float, float],
x_dir: Tuple[float, float, float],
) -> None:
"""Set this sketch's 3D workplane and sync it to the OCC sketch.
Call this when the sketch is placed on a face/datum plane. UV
coordinates are unchanged; only their world mapping moves.
"""
self.workplane_origin = np.asarray(origin, dtype=float)
self.workplane_normal = np.asarray(normal, dtype=float)
self.workplane_x_dir = np.asarray(x_dir, dtype=float)
self.apply_workplane()
self.modified_at = datetime.now()
def apply_workplane(self) -> None:
"""Push the stored workplane fields into the underlying OCCSketch."""
if self.occ_sketch is not None:
self.occ_sketch.set_workplane(
tuple(self.workplane_origin.tolist()),
tuple(self.workplane_normal.tolist()),
tuple(self.workplane_x_dir.tolist()),
)
def add_point(self, x: float, y: float) -> Any:
"""Add a point to the sketch."""
self.modified_at = datetime.now()
self.is_solved = False
if self.occ_sketch:
return self.occ_sketch.add_point(x, y)
return None
def add_line(self, start: Any, end: Any) -> Any:
"""Add a line to the sketch."""
self.modified_at = datetime.now()
self.is_solved = False
if self.occ_sketch:
return self.occ_sketch.add_line(start, end)
return None
def add_circle(self, center: Any, radius: float) -> Any:
"""Add a circle to the sketch."""
self.modified_at = datetime.now()
self.is_solved = False
if self.occ_sketch:
return self.occ_sketch.add_circle(center, radius)
return None
def add_rectangle(self, corner1: tuple, corner2: tuple) -> List[Any]:
"""Add a rectangle to the sketch."""
self.modified_at = datetime.now()
self.is_solved = False
if self.occ_sketch:
return self.occ_sketch.add_rectangle(corner1, corner2)
return []
def solve(self) -> bool:
"""Solve all constraints."""
if self.occ_sketch:
result = self.occ_sketch.solve()
self.is_solved = result
self.is_fully_constrained = self.occ_sketch.is_fully_constrained()
self.modified_at = datetime.now()
return result
return False
def get_geometry(self) -> Optional[GeometryObject]:
"""Get the solved geometry."""
if self.occ_sketch:
return self.occ_sketch.get_geometry()
return None
def get_polygon_points(self) -> List[Point2D]:
"""Get ordered polygon points."""
if self.occ_sketch:
return self.occ_sketch.get_polygon_points()
return []
def clear(self) -> None:
"""Clear all geometry."""
if self.occ_sketch:
self.occ_sketch.clear()
self.geometry = None
self.is_solved = False
self.is_fully_constrained = False
self.modified_at = datetime.now()
@dataclass
class Body:
"""
3D solid body.
A body is created from a sketch through operations like
extrude, revolve, loft, or sweep.
"""
id: str = field(default_factory=lambda: str(uuid.uuid4()))
name: str = "Untitled Body"
geometry: Optional[OCCGeometryObject] = None
source_sketch: Optional[Sketch] = None
source_operation: str = "extrude"
position: np.ndarray = field(default_factory=lambda: np.array([0.0, 0.0, 0.0]))
rotation: np.ndarray = field(default_factory=lambda: np.eye(3))
color: tuple = (0.2, 0.4, 0.8)
opacity: float = 1.0
visible: bool = True
render_object: Any = None
created_at: datetime = field(default_factory=datetime.now)
modified_at: datetime = field(default_factory=datetime.now)
def get_mesh(self, kernel: OCGeometryKernel, tolerance: float = 0.1) -> tuple:
"""Get mesh for rendering."""
if self.geometry and kernel:
return kernel.get_mesh(self.geometry, tolerance)
return np.array([]), np.array([])
def get_edges(self, kernel: OCGeometryKernel) -> tuple:
"""Get edges for wireframe rendering."""
if self.geometry and kernel:
return kernel.get_edges(self.geometry)
return np.array([]), np.array([])
@dataclass
class Component:
"""
Component containing sketches and bodies.
A component is a logical grouping of geometry, similar to
a part in a CAD system.
"""
id: str = field(default_factory=lambda: str(uuid.uuid4()))
name: str = "Untitled Component"
description: str = ""
sketches: Dict[str, Sketch] = field(default_factory=dict)
bodies: Dict[str, Body] = field(default_factory=dict)
workplanes: Dict[str, Workplane] = field(default_factory=dict)
active_sketch: Optional[str] = None
active_workplane: Optional[str] = None
created_at: datetime = field(default_factory=datetime.now)
modified_at: datetime = field(default_factory=datetime.now)
def add_workplane(self, workplane: Optional[Workplane] = None) -> Workplane:
"""Add an independent workplane to the component."""
if workplane is None:
workplane = Workplane(name=f"Workplane {len(self.workplanes) + 1}")
self.workplanes[workplane.id] = workplane
if self.active_workplane is None:
self.active_workplane = workplane.id
self.modified_at = datetime.now()
return workplane
def remove_workplane(self, wp_id: str) -> bool:
"""Remove a workplane from the component."""
if wp_id in self.workplanes:
del self.workplanes[wp_id]
if self.active_workplane == wp_id:
self.active_workplane = next(iter(self.workplanes.keys()), None)
self.modified_at = datetime.now()
return True
return False
def add_sketch(self, sketch: Optional[Sketch] = None) -> Sketch:
"""Add a sketch to the component."""
if sketch is None:
sketch = Sketch(name=f"Sketch {len(self.sketches) + 1}")
self.sketches[sketch.id] = sketch
self.modified_at = datetime.now()
return sketch
def add_body(self, body: Optional[Body] = None) -> Body:
"""Add a body to the component."""
if body is None:
body = Body(name=f"Body {len(self.bodies) + 1}")
self.bodies[body.id] = body
self.modified_at = datetime.now()
return body
def remove_sketch(self, sketch_id: str) -> bool:
"""Remove a sketch from the component."""
if sketch_id in self.sketches:
del self.sketches[sketch_id]
if self.active_sketch == sketch_id:
self.active_sketch = None
self.modified_at = datetime.now()
return True
return False
def remove_body(self, body_id: str) -> bool:
"""Remove a body from the component."""
if body_id in self.bodies:
del self.bodies[body_id]
self.modified_at = datetime.now()
return True
return False
def get_active_sketch(self) -> Optional[Sketch]:
"""Get the currently active sketch."""
if self.active_sketch and self.active_sketch in self.sketches:
return self.sketches[self.active_sketch]
return None
def set_active_sketch(self, sketch_id: Optional[str]) -> None:
"""Set the active sketch."""
self.active_sketch = sketch_id
self.modified_at = datetime.now()
@dataclass
class Connector:
"""
A connection point on an assembly component instance.
Stores the position and orientation of a connection point
(e.g. a hole center, face midpoint, or edge point) that will
later be used by the SolveSpace solver to mate components.
The *normal* defines the connection axis direction (e.g. the
hole axis for a screw connection).
"""
id: str = field(default_factory=lambda: str(uuid.uuid4()))
name: str = "Untitled Connector"
# 3D position of the connection point (world coords).
position: Tuple[float, float, float] = (0.0, 0.0, 0.0)
# Normal direction of the connection (e.g. hole axis).
normal: Tuple[float, float, float] = (0.0, 0.0, 1.0)
# In-plane X direction for defining the reference frame.
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
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. Returns the AssemblyConnection for
further bookkeeping (e.g. attaching partner connector ids).
"""
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 add_component_instance(
self, component_id: str, name: Optional[str] = None
) -> AssemblyComponent:
"""Add a component instance to the assembly.
Returns the newly created AssemblyComponent. The same
component can be added multiple times (multiple instances).
"""
ac = AssemblyComponent(
component_id=component_id,
name=name or f"Instance {len(self.components) + 1}",
)
self.components[ac.id] = ac
if self.active_assembly_component is None:
self.active_assembly_component = ac.id
self.modified_at = datetime.now()
return ac
def remove_component_instance(self, assembly_component_id: str) -> bool:
"""Remove a component instance from the assembly."""
if assembly_component_id in self.components:
del self.components[assembly_component_id]
# Also drop any mated-connector links that referenced this
# instance — otherwise stale connection edges would remain in
# the rigid-group graph and point at a missing component.
self.remove_connections_for(assembly_component_id)
if self.active_assembly_component == assembly_component_id:
self.active_assembly_component = next(
iter(self.components.keys()), None
)
self.modified_at = datetime.now()
return True
return False
def get_active_instance(self) -> Optional[AssemblyComponent]:
"""Get the currently active assembly component instance."""
if (
self.active_assembly_component
and self.active_assembly_component in self.components
):
return self.components[self.active_assembly_component]
return None
@dataclass
class Project:
"""
Top-level project container.
A project contains components and provides access to the
geometry kernel for operations.
"""
name: str = "Untitled Project"
description: str = ""
components: Dict[str, Component] = field(default_factory=dict)
active_component: Optional[str] = None
assemblies: Dict[str, Assembly] = field(default_factory=dict)
active_assembly: Optional[str] = None
kernel: OCGeometryKernel = field(default_factory=OCGeometryKernel)
created_at: datetime = field(default_factory=datetime.now)
modified_at: datetime = field(default_factory=datetime.now)
file_path: Optional[str] = None
# ── Component helpers ──
def add_component(self, component: Optional[Component] = None) -> Component:
"""Add a component to the project."""
if component is None:
component = Component(name=f"Component {len(self.components) + 1}")
self.components[component.id] = component
if self.active_component is None:
self.active_component = component.id
self.modified_at = datetime.now()
return component
def remove_component(self, component_id: str) -> bool:
"""Remove a component from the project."""
if component_id in self.components:
del self.components[component_id]
if self.active_component == component_id:
self.active_component = next(iter(self.components.keys()), None)
self.modified_at = datetime.now()
return True
return False
def get_active_component(self) -> Optional[Component]:
"""Get the currently active component."""
if self.active_component and self.active_component in self.components:
return self.components[self.active_component]
return None
def set_active_component(self, component_id: Optional[str]) -> None:
"""Set the active component."""
self.active_component = component_id
self.modified_at = datetime.now()
# ── Assembly helpers ──
def add_assembly(self, assembly: Optional[Assembly] = None) -> Assembly:
"""Add an assembly to the project."""
if assembly is None:
assembly = Assembly(name=f"Assembly {len(self.assemblies) + 1}")
self.assemblies[assembly.id] = assembly
if self.active_assembly is None:
self.active_assembly = assembly.id
self.modified_at = datetime.now()
return assembly
def remove_assembly(self, assembly_id: str) -> bool:
"""Remove an assembly from the project."""
if assembly_id in self.assemblies:
del self.assemblies[assembly_id]
if self.active_assembly == assembly_id:
self.active_assembly = next(iter(self.assemblies.keys()), None)
self.modified_at = datetime.now()
return True
return False
def get_active_assembly(self) -> Optional[Assembly]:
"""Get the currently active assembly."""
if self.active_assembly and self.active_assembly in self.assemblies:
return self.assemblies[self.active_assembly]
return None
def set_active_assembly(self, assembly_id: Optional[str]) -> None:
"""Set the active assembly."""
self.active_assembly = assembly_id
self.modified_at = datetime.now()
def get_component_by_id(self, component_id: str) -> Optional[Component]:
"""Look up a component by id across all project components."""
return self.components.get(component_id)
def add_component(self, component: Optional[Component] = None) -> Component:
"""Add a component to the project."""
if component is None:
component = Component(name=f"Component {len(self.components) + 1}")
self.components[component.id] = component
if self.active_component is None:
self.active_component = component.id
self.modified_at = datetime.now()
return component
def remove_component(self, component_id: str) -> bool:
"""Remove a component from the project."""
if component_id in self.components:
del self.components[component_id]
if self.active_component == component_id:
self.active_component = next(iter(self.components.keys()), None)
self.modified_at = datetime.now()
return True
return False
def get_active_component(self) -> Optional[Component]:
"""Get the currently active component."""
if self.active_component and self.active_component in self.components:
return self.components[self.active_component]
return None
def set_active_component(self, component_id: Optional[str]) -> None:
"""Set the active component."""
self.active_component = component_id
self.modified_at = datetime.now()
def export_step(self, filepath: str) -> bool:
"""Export all visible bodies to STEP."""
all_bodies: List[OCCGeometryObject] = []
for comp in self.components.values():
for body in comp.bodies.values():
if body.visible and body.geometry:
all_bodies.append(body.geometry)
if not all_bodies:
return False
if len(all_bodies) == 1:
return self.kernel.export_step(all_bodies[0], filepath)
result = self.kernel.boolean_union(*all_bodies)
return self.kernel.export_step(result, filepath)
def export_iges(self, filepath: str) -> bool:
"""Export all visible bodies to IGES."""
all_bodies: List[OCCGeometryObject] = []
for comp in self.components.values():
for body in comp.bodies.values():
if body.visible and body.geometry:
all_bodies.append(body.geometry)
if not all_bodies:
return False
if len(all_bodies) == 1:
return self.kernel.export_iges(all_bodies[0], filepath)
result = self.kernel.boolean_union(*all_bodies)
return self.kernel.export_iges(result, filepath)
def export_stl(self, filepath: str, tolerance: float = 0.1) -> bool:
"""Export all visible bodies to STL."""
all_bodies: List[OCCGeometryObject] = []
for comp in self.components.values():
for body in comp.bodies.values():
if body.visible and body.geometry:
all_bodies.append(body.geometry)
if not all_bodies:
return False
if len(all_bodies) == 1:
return self.kernel.export_stl(all_bodies[0], filepath, tolerance)
result = self.kernel.boolean_union(*all_bodies)
return self.kernel.export_stl(result, filepath, tolerance)
def get_all_bodies(self) -> List[Body]:
"""Get all bodies from all components."""
bodies: List[Body] = []
for comp in self.components.values():
bodies.extend(comp.bodies.values())
return bodies
def get_all_sketches(self) -> List[Sketch]:
"""Get all sketches from all components."""
sketches: List[Sketch] = []
for comp in self.components.values():
sketches.extend(comp.sketches.values())
return sketches
+17
View File
@@ -0,0 +1,17 @@
"""Rendering module."""
from fluency.rendering.base import (
Renderer,
RenderObject,
RenderColor,
)
from fluency.rendering.pygfx_renderer import PygfxRenderer, PygfxRenderObject
from fluency.rendering.occ_renderer import OCCRenderer, OCCRenderObject
__all__ = [
"Renderer",
"RenderObject",
"RenderColor",
"PygfxRenderer",
"PygfxRenderObject",
]
+380
View File
@@ -0,0 +1,380 @@
"""
Rendering abstraction layer for Fluency CAD.
This module defines abstract interfaces for 3D rendering,
allowing different rendering backends to be used interchangeably.
"""
from abc import ABC, abstractmethod
from typing import List, Tuple, Optional, Callable, Any
from dataclasses import dataclass
import numpy as np
@dataclass
class RenderColor:
"""RGB color representation."""
r: float
g: float
b: float
a: float = 1.0
def to_tuple(self) -> Tuple[float, float, float, float]:
return (self.r, self.g, self.b, self.a)
def to_tuple_rgb(self) -> Tuple[float, float, float]:
return (self.r, self.g, self.b)
@classmethod
def from_hex(cls, hex_color: str) -> "RenderColor":
"""Create color from hex string (#RRGGBB or #RRGGBBAA)."""
hex_color = hex_color.lstrip("#")
if len(hex_color) == 6:
r = int(hex_color[0:2], 16) / 255.0
g = int(hex_color[2:4], 16) / 255.0
b = int(hex_color[4:6], 16) / 255.0
return cls(r, g, b)
elif len(hex_color) == 8:
r = int(hex_color[0:2], 16) / 255.0
g = int(hex_color[2:4], 16) / 255.0
b = int(hex_color[4:6], 16) / 255.0
a = int(hex_color[6:8], 16) / 255.0
return cls(r, g, b, a)
raise ValueError(f"Invalid hex color: {hex_color}")
class RenderObject:
"""Base class for renderable objects."""
def __init__(self, name: Optional[str] = None):
self.name = name
self.visible: bool = True
self.selected: bool = False
self.color: RenderColor = RenderColor(0.2, 0.4, 0.8)
self._scene_node: Any = None
def set_color(self, color: RenderColor) -> None:
self.color = color
def set_visible(self, visible: bool) -> None:
self.visible = visible
def set_selected(self, selected: bool) -> None:
self.selected = selected
class Renderer(ABC):
"""
Abstract base class for 3D renderers.
A renderer provides 3D visualization capabilities including
mesh display, camera control, and object selection.
"""
@abstractmethod
def initialize(self, parent_widget: Any) -> bool:
"""
Initialize the renderer with a parent widget.
Args:
parent_widget: Qt widget to embed the renderer in
Returns:
True if initialization succeeded
"""
pass
@abstractmethod
def shutdown(self) -> None:
"""Clean up renderer resources."""
pass
@abstractmethod
def add_mesh(
self,
vertices: np.ndarray,
faces: np.ndarray,
color: Tuple[float, float, float] = (0.2, 0.4, 0.8),
name: Optional[str] = None,
) -> str:
"""
Add a mesh to the scene.
Args:
vertices: Nx3 array of vertex positions
faces: Mx3 array of triangle indices
color: RGB color tuple
name: Optional name for the object
Returns:
String ID of the mesh
"""
pass
@abstractmethod
def add_wireframe(
self,
vertices: np.ndarray,
edges: np.ndarray,
color: Tuple[float, float, float] = (1.0, 1.0, 1.0),
line_width: float = 1.0,
name: Optional[str] = None,
) -> str:
"""
Add a wireframe to the scene.
Args:
vertices: Nx3 array of vertex positions
edges: Mx2 array of edge vertex indices
color: RGB color tuple
line_width: Width of lines
name: Optional name for the object
Returns:
String ID of the wireframe
"""
pass
@abstractmethod
def add_points(
self,
points: np.ndarray,
color: Tuple[float, float, float] = (1.0, 0.0, 0.0),
size: float = 5.0,
name: Optional[str] = None,
) -> str:
"""
Add points to the scene.
Args:
points: Nx3 array of point positions
color: RGB color tuple
size: Point size
name: Optional name for the object
Returns:
String ID of the points
"""
pass
@abstractmethod
def add_lines(
self,
start_points: np.ndarray,
end_points: np.ndarray,
color: Tuple[float, float, float] = (1.0, 1.0, 1.0),
line_width: float = 1.0,
name: Optional[str] = None,
) -> str:
"""
Add line segments to the scene.
Args:
start_points: Nx3 array of line start positions
end_points: Nx3 array of line end positions
color: RGB color tuple
line_width: Width of lines
name: Optional name for the object
Returns:
String ID of the lines
"""
pass
@abstractmethod
def remove_object(self, obj: RenderObject) -> bool:
"""
Remove an object from the scene.
Args:
obj: Object to remove
Returns:
True if removal succeeded
"""
pass
@abstractmethod
def clear_scene(self) -> None:
"""Remove all objects from the scene."""
pass
@abstractmethod
def update_mesh(self, obj: RenderObject, vertices: np.ndarray, faces: np.ndarray) -> bool:
"""
Update mesh geometry.
Args:
obj: Object to update
vertices: New Nx3 array of vertex positions
faces: New Mx3 array of triangle indices
Returns:
True if update succeeded
"""
pass
@abstractmethod
def set_object_color(self, obj: RenderObject, color: Tuple[float, float, float]) -> None:
"""Set the color of an object."""
pass
@abstractmethod
def set_object_visible(self, obj: RenderObject, visible: bool) -> None:
"""Set the visibility of an object."""
pass
@abstractmethod
def set_camera_position(
self,
position: Tuple[float, float, float],
target: Tuple[float, float, float] = (0, 0, 0),
up: Tuple[float, float, float] = (0, 0, 1),
) -> None:
"""
Set camera position and orientation.
Args:
position: Camera position
target: Point camera is looking at
up: Up vector
"""
pass
@abstractmethod
def get_camera_position(self) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
"""
Get camera position, target, and up vector.
Returns:
Tuple of (position, target, up) as numpy arrays
"""
pass
@abstractmethod
def fit_camera(self, padding: float = 1.1) -> None:
"""
Fit camera to show all objects.
Args:
padding: Padding factor (1.0 = exact fit)
"""
pass
@abstractmethod
def set_camera_perspective(
self, fov: float = 50.0, near: float = 0.1, far: float = 10000.0
) -> None:
"""Set camera perspective parameters."""
pass
@abstractmethod
def set_camera_orthographic(
self, width: float = 100.0, near: float = 0.1, far: float = 10000.0
) -> None:
"""Set camera orthographic parameters."""
pass
@abstractmethod
def render(self) -> None:
"""Trigger a render."""
pass
@abstractmethod
def on_pick(self, callback: Callable[[Any], None]) -> None:
"""
Register a callback for picking/selection.
Args:
callback: Function called with pick info when object is clicked
"""
pass
@abstractmethod
def on_camera_change(self, callback: Callable[[], None]) -> None:
"""
Register a callback for camera changes.
Args:
callback: Function called when camera moves
"""
pass
@abstractmethod
def set_background_color(self, color: Tuple[float, float, float]) -> None:
"""Set the background color."""
pass
@abstractmethod
def add_grid(
self,
size: float = 100.0,
divisions: int = 10,
color: Tuple[float, float, float] = (0.3, 0.3, 0.3),
) -> str:
"""Add a reference grid. Returns the grid ID."""
pass
@abstractmethod
def add_axes(self, size: float = 10.0, visible: bool = True) -> str:
"""Add coordinate axes. Returns the axes ID."""
pass
@abstractmethod
def get_screen_size(self) -> Tuple[int, int]:
"""Get the screen size in pixels."""
pass
@abstractmethod
def project_to_screen(self, point: Tuple[float, float, float]) -> Tuple[int, int]:
"""
Project a 3D point to screen coordinates.
Args:
point: 3D point to project
Returns:
Screen (x, y) coordinates
"""
pass
@abstractmethod
def unproject_from_screen(
self, screen_x: int, screen_y: int, depth: float = 0.0
) -> Tuple[float, float, float]:
"""
Unproject screen coordinates to 3D.
Args:
screen_x: Screen x coordinate
screen_y: Screen y coordinate
depth: Depth value (0=near, 1=far)
Returns:
3D point coordinates
"""
pass
@abstractmethod
def take_screenshot(self) -> np.ndarray:
"""
Take a screenshot of the current view.
Returns:
RGBA image as numpy array
"""
pass
@abstractmethod
def save_screenshot(self, filepath: str) -> bool:
"""
Save a screenshot to file.
Args:
filepath: Path to save screenshot
Returns:
True if save succeeded
"""
pass
File diff suppressed because it is too large Load Diff
+479
View File
@@ -0,0 +1,479 @@
"""
pygfx-based renderer for Fluency CAD.
This module provides a modern WebGPU-based renderer using pygfx,
offering a smaller dependency footprint than VTK while providing
excellent 3D visualization capabilities.
"""
from typing import List, Tuple, Optional, Callable, Any
import numpy as np
from dataclasses import dataclass
from fluency.rendering.base import (
Renderer,
RenderObject,
RenderColor,
)
def compute_normals(vertices: np.ndarray, faces: np.ndarray) -> np.ndarray:
"""Compute vertex normals from positions and face indices."""
vertices = np.asarray(vertices, dtype=np.float32)
faces = np.asarray(faces, dtype=np.int32)
normals = np.zeros_like(vertices)
for face in faces:
v0, v1, v2 = vertices[face[0]], vertices[face[1]], vertices[face[2]]
edge1 = v1 - v0
edge2 = v2 - v0
face_normal = np.cross(edge1, edge2)
length = np.linalg.norm(face_normal)
if length > 1e-10:
face_normal = face_normal / length
normals[face[0]] += face_normal
normals[face[1]] += face_normal
normals[face[2]] += face_normal
lengths = np.linalg.norm(normals, axis=1, keepdims=True)
lengths = np.where(lengths > 1e-10, lengths, 1)
normals = normals / lengths
return normals.astype(np.float32)
@dataclass
class PygfxRenderObject(RenderObject):
"""pygfx render object wrapper."""
scene_node: Any = None
geometry: Any = None
material: Any = None
def __init__(
self,
scene_node: Any = None,
geometry: Any = None,
material: Any = None,
name: Optional[str] = None,
):
super().__init__(name=name)
self.scene_node = scene_node
self.geometry = geometry
self.material = material
class PygfxRenderer(Renderer):
"""
pygfx-based renderer implementation.
This renderer uses pygfx (WebGPU-based) for 3D visualization,
providing modern rendering with a small dependency footprint.
"""
def __init__(self) -> None:
self._canvas: Any = None
self._renderer: Any = None
self._scene: Any = None
self._camera: Any = None
self._controller: Any = None
self._objects: List[PygfxRenderObject] = []
self._pick_callback: Optional[Callable[[Any], None]] = None
self._camera_change_callback: Optional[Callable[[], None]] = None
self._background_color: Tuple[float, float, float] = (0.1, 0.1, 0.15)
self._initialized: bool = False
def initialize(self, parent_widget: Any) -> bool:
"""Initialize pygfx with Qt widget."""
try:
import pygfx as gfx
from rendercanvas.qt import RenderWidget
from PySide6.QtWidgets import QVBoxLayout
self._canvas = RenderWidget(parent=parent_widget)
self._renderer = gfx.renderers.WgpuRenderer(self._canvas)
self._scene = gfx.Scene()
self._camera = gfx.PerspectiveCamera(50, 16 / 9)
self._camera.local.position = (100, 100, 100)
self._controller = gfx.OrbitController(self._camera)
self._controller.register_events(self._renderer)
self._setup_lighting()
self._add_grid()
layout = QVBoxLayout(parent_widget)
layout.setContentsMargins(0, 0, 0, 0)
layout.addWidget(self._canvas)
self._canvas.request_draw(self._animate)
self._setup_picking()
self._initialized = True
return True
except Exception as e:
print(f"Failed to initialize pygfx: {e}")
import traceback
traceback.print_exc()
return False
def _animate(self):
"""Animation callback for the canvas."""
if self._initialized:
self._renderer.render(self._scene, self._camera)
def _setup_lighting(self) -> None:
"""Setup scene lighting."""
import pygfx as gfx
ambient = gfx.AmbientLight(intensity=0.3)
self._scene.add(ambient)
directional = gfx.DirectionalLight(intensity=1.0)
directional.local.position = (100, 100, 100)
self._scene.add(directional)
fill = gfx.DirectionalLight(intensity=0.5)
fill.local.position = (-100, 50, 50)
self._scene.add(fill)
def _add_grid(self) -> None:
"""Add reference grid."""
import pygfx as gfx
grid = gfx.GridHelper(
size=200, divisions=20, color1=(0.3, 0.3, 0.3, 1), color2=(0.2, 0.2, 0.2, 1)
)
self._scene.add(grid)
def _setup_picking(self) -> None:
"""Setup mesh picking."""
pass
def shutdown(self) -> None:
"""Clean up renderer resources."""
self._objects.clear()
self._initialized = False
def add_mesh(
self,
vertices: np.ndarray,
faces: np.ndarray,
color: Tuple[float, float, float] = (0.2, 0.4, 0.8),
name: Optional[str] = None,
) -> str:
"""Add a mesh to the scene. Returns the mesh ID."""
import pygfx as gfx
import uuid
vertices = np.asarray(vertices, dtype=np.float32)
faces = np.asarray(faces, dtype=np.int32)
normals = compute_normals(vertices, faces)
geometry = gfx.Geometry(positions=vertices, indices=faces, normals=normals)
material = gfx.MeshPhongMaterial(color=color, flat_shading=False)
mesh = gfx.Mesh(geometry, material)
self._scene.add(mesh)
mesh_id = name or f"mesh_{uuid.uuid4().hex[:8]}"
obj = PygfxRenderObject(name=mesh_id, scene_node=mesh, geometry=geometry, material=material)
obj.color = RenderColor(*color)
self._objects.append(obj)
return mesh_id
def add_wireframe(
self,
vertices: np.ndarray,
edges: np.ndarray,
color: Tuple[float, float, float] = (1.0, 1.0, 1.0),
line_width: float = 1.0,
name: Optional[str] = None,
) -> str:
"""Add a wireframe to the scene. Returns the wireframe ID."""
import pygfx as gfx
import uuid
positions: List[List[float]] = []
for edge in edges:
positions.append(vertices[edge[0]])
positions.append(vertices[edge[1]])
positions_arr = np.array(positions, dtype=np.float32)
geometry = gfx.Geometry(positions=positions_arr)
material = gfx.LineMaterial(color=color, thickness=line_width)
lines = gfx.Line(geometry, material)
self._scene.add(lines)
wireframe_id = name or f"wireframe_{uuid.uuid4().hex[:8]}"
obj = PygfxRenderObject(
name=wireframe_id, scene_node=lines, geometry=geometry, material=material
)
obj.color = RenderColor(*color)
self._objects.append(obj)
return wireframe_id
def add_points(
self,
points: np.ndarray,
color: Tuple[float, float, float] = (1.0, 0.0, 0.0),
size: float = 5.0,
name: Optional[str] = None,
) -> str:
"""Add points to the scene. Returns the points ID."""
import pygfx as gfx
import uuid
points_arr = np.asarray(points, dtype=np.float32)
geometry = gfx.Geometry(positions=points_arr)
material = gfx.PointsMaterial(color=color, size=size)
points_obj = gfx.Points(geometry, material)
self._scene.add(points_obj)
points_id = name or f"points_{uuid.uuid4().hex[:8]}"
obj = PygfxRenderObject(
name=points_id, scene_node=points_obj, geometry=geometry, material=material
)
obj.color = RenderColor(*color)
self._objects.append(obj)
return points_id
def add_lines(
self,
start_points: np.ndarray,
end_points: np.ndarray,
color: Tuple[float, float, float] = (1.0, 1.0, 1.0),
line_width: float = 1.0,
name: Optional[str] = None,
) -> str:
"""Add line segments to the scene. Returns the lines ID."""
import pygfx as gfx
import uuid
positions = np.hstack([start_points, end_points]).flatten()
positions = positions.reshape(-1, 3).astype(np.float32)
geometry = gfx.Geometry(positions=positions)
material = gfx.LineMaterial(color=color, thickness=line_width)
lines = gfx.Line(geometry, material)
self._scene.add(lines)
lines_id = name or f"lines_{uuid.uuid4().hex[:8]}"
obj = PygfxRenderObject(
name=lines_id, scene_node=lines, geometry=geometry, material=material
)
obj.color = RenderColor(*color)
self._objects.append(obj)
return lines_id
def remove_object(self, obj: RenderObject) -> bool:
"""Remove an object from the scene."""
if isinstance(obj, PygfxRenderObject) and obj in self._objects:
if obj.scene_node is not None:
self._scene.remove(obj.scene_node)
self._objects.remove(obj)
return True
return False
def remove_mesh(self, mesh_id: str) -> bool:
"""Remove a mesh by its ID."""
for obj in self._objects:
if obj.name == mesh_id:
return self.remove_object(obj)
return False
def clear_scene(self) -> None:
"""Remove all objects from the scene."""
for obj in self._objects[:]:
if obj.scene_node is not None:
self._scene.remove(obj.scene_node)
self._objects.clear()
def update_mesh(self, obj: RenderObject, vertices: np.ndarray, faces: np.ndarray) -> bool:
"""Update mesh geometry."""
if not isinstance(obj, PygfxRenderObject):
return False
import pygfx as gfx
vertices = np.asarray(vertices, dtype=np.float32)
faces = np.asarray(faces, dtype=np.int32)
normals = compute_normals(vertices, faces)
geometry = gfx.Geometry(positions=vertices, indices=faces, normals=normals)
obj.geometry = geometry
if obj.scene_node is not None:
obj.scene_node.geometry = geometry
return True
def set_object_color(self, obj: RenderObject, color: Tuple[float, float, float]) -> None:
"""Set the color of an object."""
if isinstance(obj, PygfxRenderObject):
obj.color = RenderColor(*color)
if obj.material is not None:
obj.material.color = color
def set_object_visible(self, obj: RenderObject, visible: bool) -> None:
"""Set the visibility of an object."""
if isinstance(obj, PygfxRenderObject):
obj.visible = visible
if obj.scene_node is not None:
obj.scene_node.visible = visible
def set_camera_position(
self,
position: Tuple[float, float, float],
target: Tuple[float, float, float] = (0, 0, 0),
up: Tuple[float, float, float] = (0, 0, 1),
) -> None:
"""Set camera position and orientation."""
self._camera.local.position = position
self._camera.look_at(target)
def get_camera_position(self) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
"""Get camera position, target, and up vector."""
pos = np.array(self._camera.local.position)
target = np.array([0, 0, 0])
up = np.array(self._camera.local.up)
return pos, target, up
def fit_camera(self, padding: float = 1.1) -> None:
"""Fit camera to show all objects."""
if not self._objects:
return
all_positions: List[np.ndarray] = []
for obj in self._objects:
if obj.geometry is not None and hasattr(obj.geometry, "positions"):
positions = obj.geometry.positions.data
all_positions.append(positions)
if all_positions:
positions = np.vstack(all_positions)
min_pos = positions.min(axis=0)
max_pos = positions.max(axis=0)
center = (min_pos + max_pos) / 2
size = np.linalg.norm(max_pos - min_pos) * padding
self._camera.local.position = (center[0] + size, center[1] + size, center[2] + size)
self._camera.look_at(tuple(center))
def set_camera_perspective(
self, fov: float = 50.0, near: float = 0.1, far: float = 10000.0
) -> None:
"""Set camera perspective parameters."""
self._camera.fov = fov
self._camera.near = near
self._camera.far = far
def set_camera_orthographic(
self, width: float = 100.0, near: float = 0.1, far: float = 10000.0
) -> None:
"""Set camera orthographic parameters."""
import pygfx as gfx
self._camera = gfx.OrthographicCamera(width=width, near=near, far=far)
self._controller.camera = self._camera
def render(self) -> None:
"""Trigger a render."""
if self._initialized:
self._canvas.request_draw()
def on_pick(self, callback: Callable[[Any], None]) -> None:
"""Register a callback for picking/selection."""
self._pick_callback = callback
def on_camera_change(self, callback: Callable[[], None]) -> None:
"""Register a callback for camera changes."""
self._camera_change_callback = callback
def set_background_color(self, color: Tuple[float, float, float]) -> None:
"""Set the background color."""
self._background_color = color
self._scene.background = color
def add_grid(
self,
size: float = 100.0,
divisions: int = 10,
color: Tuple[float, float, float] = (0.3, 0.3, 0.3),
) -> str:
"""Add a reference grid. Returns the grid ID."""
import pygfx as gfx
grid = gfx.GridHelper(
size=size, divisions=divisions, color1=(*color, 1), color2=(*color, 0.5)
)
self._scene.add(grid)
obj = PygfxRenderObject(name="grid", scene_node=grid)
self._objects.append(obj)
return "grid"
def add_axes(self, size: float = 10.0, visible: bool = True) -> str:
"""Add coordinate axes. Returns the axes ID."""
import pygfx as gfx
axes = gfx.AxesHelper(size=size)
axes.visible = visible
self._scene.add(axes)
obj = PygfxRenderObject(name="axes", scene_node=axes)
self._objects.append(obj)
return "axes"
def get_screen_size(self) -> Tuple[int, int]:
"""Get the screen size in pixels."""
if self._canvas is not None:
return self._canvas.get_physical_size()
return (800, 600)
def project_to_screen(self, point: Tuple[float, float, float]) -> Tuple[int, int]:
"""Project a 3D point to screen coordinates."""
return (0, 0)
def unproject_from_screen(
self, screen_x: int, screen_y: int, depth: float = 0.0
) -> Tuple[float, float, float]:
"""Unproject screen coordinates to 3D."""
return (0.0, 0.0, 0.0)
def take_screenshot(self) -> np.ndarray:
"""Take a screenshot of the current view."""
return np.zeros((100, 100, 4), dtype=np.uint8)
def save_screenshot(self, filepath: str) -> bool:
"""Save a screenshot to file."""
try:
img = self.take_screenshot()
from PIL import Image
Image.fromarray(img).save(filepath)
return True
except Exception as e:
print(f"Screenshot error: {e}")
return False
+326
View File
@@ -0,0 +1,326 @@
"""
SolveSpace-based constraint solver for Fluency CAD.
Provides integration between python-solvespace (SolverSystem) and
the Fluency CAD sketch pipeline (OCCSketch). Drawing operations
add entities to BOTH the OCCSketch (for OCC->render pipeline) and
the SolverSketch (for constraint solving). After constraint solving,
solved positions are synced back to the OCCSketch.
"""
from __future__ import annotations
import math
import re
import uuid
import logging
from dataclasses import dataclass, field
from typing import List, Optional, Tuple, Any, Dict
from python_solvespace import SolverSystem, ResultFlag
logger = logging.getLogger(__name__)
# ── Data classes ──────────────────────────────────────────────────────────
@dataclass
class SolverPoint:
"""A 2D point tracked by the solver system."""
x: float
y: float
handle: Any = None
handle_nr: int = 0
entity_id: int = -1 # Corresponding OCCSketch entity id
is_helper: bool = False
id: str = field(default_factory=lambda: str(uuid.uuid4()))
def to_tuple(self) -> Tuple[float, float]:
return (self.x, self.y)
@dataclass
class SolverLine:
"""A line segment tracked by the solver system."""
start: SolverPoint
end: SolverPoint
handle: Any = None
handle_nr: int = 0
entity_ids: Tuple[int, int] = (-1, -1) # Corresponding OCCSketch entity ids
is_helper: bool = False
constraints: List[str] = field(default_factory=list)
id: str = field(default_factory=lambda: str(uuid.uuid4()))
@property
def length(self) -> float:
return math.sqrt(
(self.end.x - self.start.x) ** 2 + (self.end.y - self.start.y) ** 2
)
def midpoint(self) -> Tuple[float, float]:
return (
(self.start.x + self.end.x) / 2,
(self.start.y + self.end.y) / 2,
)
@dataclass
class SolverCircle:
"""A circle tracked by the solver system."""
center: SolverPoint
radius: float
handle: Any = None
handle_nr: int = 0
entity_id: int = -1 # Corresponding OCCSketch entity id
is_helper: bool = False
id: str = field(default_factory=lambda: str(uuid.uuid4()))
# ── Solver wrapper ────────────────────────────────────────────────────────
class SolverSketch(SolverSystem):
"""
Sketch that uses python-solvespace for parametric constraint solving.
Maintains its own lists of points, lines, and circles with solve-space
handles. Provides methods for creating geometry, applying constraints,
solving, and syncing solved positions back to an OCCSketch.
"""
def __init__(self) -> None:
super().__init__()
self.id = str(uuid.uuid4())
self.wp = self.create_2d_base()
self.points: List[SolverPoint] = []
self.lines: List[SolverLine] = []
self.circles: List[SolverCircle] = []
self._last_solve_result: int = 0
# ── Geometry creation ────────────────────────────────────────────────
def add_solver_point(self, x: float, y: float, is_helper: bool = False) -> SolverPoint:
"""Add a point to the solver system and return a SolverPoint."""
handle = self.add_point_2d(x, y, self.wp)
handle_nr = _extract_handle_nr(str(handle))
point = SolverPoint(
x=x, y=y, handle=handle, handle_nr=handle_nr,
is_helper=is_helper,
)
self.points.append(point)
return point
def add_solver_line(
self, start: SolverPoint, end: SolverPoint, is_helper: bool = False
) -> SolverLine:
"""Add a line to the solver system and return a SolverLine."""
handle = self.add_line_2d(start.handle, end.handle, self.wp)
handle_nr = _extract_handle_nr(str(handle))
line = SolverLine(
start=start, end=end, handle=handle, handle_nr=handle_nr,
is_helper=is_helper,
)
self.lines.append(line)
return line
def add_solver_circle(
self, center: SolverPoint, radius: float, is_helper: bool = False
) -> SolverCircle:
"""Add a circle to the solver system and return a SolverCircle.
Note: python-solvespace handles circles via diameter, so we
store radius but pass 2*radius to the solver if needed.
"""
# For now, circles are tracked for OCC output but the solver
# doesn't have a dedicated add_circle_2d in the standard API.
# We'll handle radius/diameter constraints through the points.
circle = SolverCircle(
center=center, radius=radius,
is_helper=is_helper,
)
self.circles.append(circle)
return circle
# ── Constraint methods ───────────────────────────────────────────────
def constrain_coincident(self, entity_a, entity_b) -> bool:
"""Make two entities coincident (point-point or point-line)."""
try:
if isinstance(entity_a, SolverPoint) and isinstance(entity_b, SolverPoint):
self.coincident(entity_a.handle, entity_b.handle, self.wp)
elif isinstance(entity_a, SolverPoint) and isinstance(entity_b, SolverLine):
self.coincident(entity_a.handle, entity_b.handle, self.wp)
elif isinstance(entity_a, SolverLine) and isinstance(entity_b, SolverPoint):
self.coincident(entity_b.handle, entity_a.handle, self.wp)
else:
logger.warning(f"coincident: unsupported types {type(entity_a)}, {type(entity_b)}")
return False
return True
except Exception as e:
logger.error(f"coincident constraint failed: {e}")
return False
def constrain_horizontal(self, line: SolverLine) -> bool:
"""Constrain a line to be horizontal."""
try:
self.horizontal(line.handle, self.wp)
return True
except Exception as e:
logger.error(f"horizontal constraint failed: {e}")
return False
def constrain_vertical(self, line: SolverLine) -> bool:
"""Constrain a line to be vertical."""
try:
self.vertical(line.handle, self.wp)
return True
except Exception as e:
logger.error(f"vertical constraint failed: {e}")
return False
def constrain_distance(
self, entity_a, entity_b, distance: float
) -> bool:
"""Constrain distance between point-point or point-line."""
try:
handle_a = entity_a.handle if isinstance(entity_a, SolverPoint) else entity_a.handle
handle_b = entity_b.handle if isinstance(entity_b, SolverPoint) else entity_b.handle
if isinstance(entity_a, SolverPoint) and isinstance(entity_b, SolverLine):
self.distance(handle_a, handle_b, distance, self.wp)
elif isinstance(entity_a, SolverLine) and isinstance(entity_b, SolverPoint):
self.distance(handle_b, handle_a, distance, self.wp)
elif isinstance(entity_a, SolverPoint) and isinstance(entity_b, SolverPoint):
self.distance(handle_a, handle_b, distance, self.wp)
elif isinstance(entity_a, SolverLine) and isinstance(entity_b, SolverLine):
self.distance(handle_a, handle_b, distance, self.wp)
else:
logger.warning(f"distance: unsupported types {type(entity_a)}, {type(entity_b)}")
return False
return True
except Exception as e:
logger.error(f"distance constraint failed: {e}")
return False
def constrain_midpoint(self, point: SolverPoint, line: SolverLine) -> bool:
"""Constrain a point to be at the midpoint of a line."""
try:
self.midpoint(point.handle, line.handle, self.wp)
return True
except Exception as e:
logger.error(f"midpoint constraint failed: {e}")
return False
def constrain_parallel(self, line_a: SolverLine, line_b: SolverLine) -> bool:
"""Constrain two lines to be parallel."""
try:
self.parallel(line_a.handle, line_b.handle, self.wp)
return True
except Exception as e:
logger.error(f"parallel constraint failed: {e}")
return False
def constrain_perpendicular(self, line_a: SolverLine, line_b: SolverLine) -> bool:
"""Constrain two lines to be perpendicular."""
try:
self.perpendicular(line_a.handle, line_b.handle, self.wp)
return True
except Exception as e:
logger.error(f"perpendicular constraint failed: {e}")
return False
def constrain_angle(self, line_a: SolverLine, line_b: SolverLine, angle_deg: float) -> bool:
"""Constrain angle between two lines in degrees."""
try:
angle_rad = math.radians(angle_deg)
self.angle(line_a.handle, line_b.handle, angle_rad, self.wp)
return True
except Exception as e:
logger.error(f"angle constraint failed: {e}")
return False
def constrain_equal_length(self, line_a: SolverLine, line_b: SolverLine) -> bool:
"""Constrain two lines to have equal length."""
try:
self.equal(line_a.handle, line_b.handle, self.wp)
return True
except Exception as e:
logger.error(f"equal length constraint failed: {e}")
return False
# ── Solving ──────────────────────────────────────────────────────────
def solve(self) -> int:
"""Solve all constraints. Returns ResultFlag as int."""
result = super().solve()
self._last_solve_result = result
if result == ResultFlag.OKAY:
# Update our stored point positions from solver params
self._sync_solved_positions()
return result
def _sync_solved_positions(self) -> None:
"""Update SolverPoint coordinates from solver's solved params."""
for point in self.points:
if point.handle and self.params(point.handle.params):
x, y = self.params(point.handle.params)
point.x = x
point.y = y
# ── Query ────────────────────────────────────────────────────────────
def get_solved_point_positions(self) -> Dict[int, Tuple[float, float]]:
"""Get map of entity_id -> (x, y) after solving."""
positions: Dict[int, Tuple[float, float]] = {}
for point in self.points:
if point.handle and self.params(point.handle.params):
x, y = self.params(point.handle.params)
positions[point.entity_id] = (x, y)
return positions
def is_point_on_line(
self, px: float, py: float, line: SolverLine, tolerance: float = 5.0
) -> bool:
"""Check if a point lies on a solver line (in world coords)."""
# Vector from start to point
ap_x = px - line.start.x
ap_y = py - line.start.y
# Vector from start to end
ab_x = line.end.x - line.start.x
ab_y = line.end.y - line.start.y
ab_len_sq = ab_x**2 + ab_y**2
if ab_len_sq == 0:
return False
# Project point onto line
t = (ap_x * ab_x + ap_y * ab_y) / ab_len_sq
t = max(0, min(1, t))
closest_x = line.start.x + t * ab_x
closest_y = line.start.y + t * ab_y
dist = math.sqrt((px - closest_x) ** 2 + (py - closest_y) ** 2)
return dist <= tolerance
# ── Clear / reset ────────────────────────────────────────────────────
def clear(self) -> None:
"""Clear all geometry from this solver sketch."""
self.points.clear()
self.lines.clear()
self.circles.clear()
self.wp = self.create_2d_base()
# ── Helpers ───────────────────────────────────────────────────────────────
def _extract_handle_nr(handle_str: str) -> int:
"""Extract numeric handle from string like 'Entity(handle=7, ...)'."""
match = re.search(r"handle=(\d+)", handle_str)
return int(match.group(1)) if match else 0
+240
View File
@@ -0,0 +1,240 @@
"""Smoke test for project_io save/load round-trip.
Builds a small project (a Component with a sketch, an extrude body, a
workplane, plus an assembly with two instances and a connector) and
verifies that saving then loading it preserves the data.
"""
import os
import sys
import tempfile
import unittest
# Allow running this file directly: ``python tests/test_project_io.py``.
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir, "src"))
from fluency.io.project_io import save_project, load_project
from fluency.models.data_model import (
Project,
Component,
Body,
Workplane,
Assembly,
)
class TestProjectIO(unittest.TestCase):
"""Round-trip the same project through save/load and check equivalence."""
def _build_project(self) -> Project:
project = Project(name="Test Project", description="A tiny test")
project.file_path = None # simulate untitled
comp = project.add_component(Component(name="Part1"))
# Add a workplane.
wp = Workplane(
name="Top",
origin=(0.0, 0.0, 0.0),
normal=(0.0, 0.0, 1.0),
x_dir=(1.0, 0.0, 0.0),
)
comp.add_workplane(wp)
# Add a sketch with a square.
sk = comp.add_sketch()
sk.occ_sketch.add_rectangle((0.0, 0.0), (10.0, 10.0))
sk.solve()
# Build a face geometry for the sketch (needed for export / restore).
faces = sk.occ_sketch.detect_faces()
if faces:
sk.geometry = sk.occ_sketch.build_face_geometry(faces[0])
# Extrude into a body.
if sk.geometry:
kernel = project.kernel
body_shape = kernel.extrude(sk.geometry, height=20.0)
body = comp.add_body(
Body(
name="Block",
geometry=body_shape,
source_sketch=sk,
source_operation="extrude",
)
)
body.color = (0.4, 0.2, 0.8)
# Add an assembly with two instances and a mated connector pair.
asm = project.add_assembly(Assembly(name="Asm1"))
ac1 = asm.add_component_instance(comp.id, name="Inst1")
ac2 = asm.add_component_instance(comp.id, name="Inst2")
c1 = ac1.add_connector(
position=(5.0, 5.0, 0.0),
normal=(0.0, 0.0, 1.0),
x_dir=(1.0, 0.0, 0.0),
)
c2 = ac2.add_connector(
position=(10.0, 10.0, 5.0),
normal=(0.0, 0.0, -1.0),
x_dir=(1.0, 0.0, 0.0),
)
# Record a mated pair (UI normally does this on connector-pick).
conn = asm.add_connection(ac1.id, ac2.id)
conn.first_connector_id = c1.id
conn.second_connector_id = c2.id
c1.partner_ac_id = ac2.id
c1.partner_connector_id = c2.id
c2.partner_ac_id = ac1.id
c2.partner_connector_id = c1.id
c1.is_grounded = True
return project
def test_round_trip(self):
original = self._build_project()
with tempfile.TemporaryDirectory() as tmp:
path = os.path.join(tmp, "test.fluency")
saved_path = save_project(original, path)
self.assertTrue(os.path.exists(saved_path))
self.assertGreater(os.path.getsize(saved_path), 100)
loaded, view_state = load_project(saved_path)
# ── Project metadata ──
self.assertEqual(loaded.name, "Test Project")
self.assertEqual(loaded.description, "A tiny test")
self.assertEqual(len(loaded.components), 1)
self.assertEqual(len(loaded.assemblies), 1)
# ── Component / Workplane / Sketch / Body ──
comp = next(iter(loaded.components.values()))
self.assertEqual(comp.name, "Part1")
self.assertEqual(len(comp.workplanes), 1)
self.assertEqual(len(comp.sketches), 1)
self.assertEqual(len(comp.bodies), 1)
sk = next(iter(comp.sketches.values()))
self.assertIsNotNone(sk.occ_sketch)
# OCCSketch replayed the rectangle: 4 points + 4 lines + 1 implicit
# origin anchor (first-point-fix). We only check that the entities
# exist.
self.assertGreaterEqual(sk.occ_sketch.get_entity_count(), 4)
# Solved geometry should round-trip through STEP.
self.assertIsNotNone(sk.geometry)
body = next(iter(comp.bodies.values()))
self.assertIsNotNone(body.geometry)
self.assertEqual(body.name, "Block")
self.assertEqual(tuple(body.color), (0.4, 0.2, 0.8))
# BRep topology should still be valid.
self.assertGreater(body.get_mesh(loaded.kernel, 0.5)[0].size, 0)
# ── Assembly / connector / connection ──
asm = next(iter(loaded.assemblies.values()))
self.assertEqual(len(asm.components), 2)
self.assertEqual(len(asm.connections), 1)
ac1, ac2 = list(asm.components.values())
self.assertEqual(len(ac1.connectors), 1)
conn = next(iter(ac1.connectors.values()))
self.assertEqual(conn.position, (5.0, 5.0, 0.0))
# The grounded-reference flag was re-applied to the first connector.
self.assertTrue(conn.is_grounded)
# Rigid-group BFS should still link the two instances.
self.assertEqual(set(asm.get_rigid_group(ac1.id)), {ac1.id, ac2.id})
class TestProjectIOWithConstraints(unittest.TestCase):
"""Round-trip with parametric constraints on the sketch.
Builds a slightly more interesting sketch (rectangle with horizontal +
vertical constraints + a distance) so the constraint-log replay path
is exercised, not just the entity-construction path.
"""
def _build_project(self) -> Project:
project = Project(name="Constraints Project", description="")
comp = project.add_component(Component(name="Part1"))
# A square with a 25.4mm horizontal distance + a vertical distance,
# both anchored on a single fixed corner. This is the minimum
# number of constraints to fully define a square in 2D.
sk = comp.add_sketch()
sk.occ_sketch.set_workplane(
(0.0, 0.0, 0.0),
(0.0, 0.0, 1.0),
(1.0, 0.0, 0.0),
)
p1 = sk.occ_sketch.add_point(0.0, 0.0) # fixed anchor (auto)
p2 = sk.occ_sketch.add_point(25.4, 0.0)
p3 = sk.occ_sketch.add_point(25.4, 25.4)
p4 = sk.occ_sketch.add_point(0.0, 25.4)
sk.occ_sketch.add_line(p1, p2)
sk.occ_sketch.add_line(p2, p3)
sk.occ_sketch.add_line(p3, p4)
sk.occ_sketch.add_line(p4, p1)
# Constraint the right side to a known length (instead of relying
# on the construction positions, which would just be redundant).
sk.occ_sketch.constrain_distance(p2, p3, 25.4)
sk.solve()
sk.is_fully_constrained = sk.occ_sketch.is_fully_constrained()
faces = sk.occ_sketch.detect_faces()
if faces:
sk.geometry = sk.occ_sketch.build_face_geometry(faces[0])
# View state to persist.
view_state = {
"active_tab": 0,
"active_component_id": comp.id,
"active_sketch_id": sk.id,
"camera_eye": [50.0, 50.0, 50.0],
"camera_at": [0.0, 0.0, 0.0],
"camera_up": [0.0, 0.0, 1.0],
"panel_focus": "sketch",
"assembly_view_active": False,
}
self._view_state = view_state
return project
def test_round_trip_with_view_state(self):
original = self._build_project()
with tempfile.TemporaryDirectory() as tmp:
path = os.path.join(tmp, "constrained.fluency")
save_project(original, path, view_state=self._view_state)
loaded, view_state = load_project(path)
# View state must survive (modulo float-to-list round-tripping).
self.assertEqual(view_state.get("active_component_id"),
self._view_state["active_component_id"])
self.assertEqual(view_state.get("active_sketch_id"),
self._view_state["active_sketch_id"])
self.assertEqual(view_state.get("camera_eye"),
self._view_state["camera_eye"])
self.assertEqual(view_state.get("panel_focus"),
self._view_state["panel_focus"])
# Sketch must have replayed its constraints and remain solvable.
comp = next(iter(loaded.components.values()))
sk = next(iter(comp.sketches.values()))
# Re-solve on the loaded sketch to confirm the post-replay
# configuration is still consistent.
self.assertTrue(sk.occ_sketch.solve())
# The right edge of the square should still be 25.4mm tall.
import math
for lid, line in sk.occ_sketch._lines.items():
sid, eid = line
sx, sy = sk.occ_sketch._points[sid]
ex, ey = sk.occ_sketch._points[eid]
length = math.hypot(ex - sx, ey - sy)
if abs(length) > 1e-3:
self.assertAlmostEqual(
length, 25.4, places=3,
msg=f"Constraint replay broke: line {lid} = {length}",
)
break
if __name__ == "__main__":
unittest.main()
+15
View File
@@ -0,0 +1,15 @@
"""Fluency CAD UI package.
Contains the Qt widgets, dialogs, and the main window:
ui/
dialogs.py All 4 modal dialogs (Extrude, Revolve, Offset, WorkplaneOrientation)
sketch_widget.py Sketch2DWidget (2D sketcher with constraint solver)
viewer_widget.py Viewer3DWidget (3D viewer / OCC canvas)
main_window.py MainWindow (application shell)
The public classes are re-exported from `fluency.main` so existing code that
does `from fluency.main import MainWindow` continues to work.
"""
__all__: list[str] = []
+497
View File
@@ -0,0 +1,497 @@
"""Dialogs for Fluency CAD operations: extrude, revolve, offset, workplane orientation."""
from __future__ import annotations
import logging
import math
from typing import Any, Dict, List, Optional, Tuple
from PySide6.QtCore import Qt, QPoint, QPointF
from PySide6.QtGui import QColor, QFont, QKeySequence
from PySide6.QtWidgets import (
QButtonGroup,
QCheckBox,
QComboBox,
QDialog,
QDialogButtonBox,
QDoubleSpinBox,
QFormLayout,
QFrame,
QGridLayout,
QHBoxLayout,
QLabel,
QLineEdit,
QPushButton,
QRadioButton,
QVBoxLayout,
QWidget,
)
logger = logging.getLogger(__name__)
class ExtrudeDialog(QDialog):
"""Dialog for extrude options.
Carries an optional ``preview_callback`` that is invoked whenever the
user changes any option; the host uses it to render a live transparent
preview of the operation result in the 3D view. Passing *False* (or
*None*) to the callback tells the host to clear the preview.
"""
def __init__(self, parent=None):
super().__init__(parent)
self.setWindowTitle("Extrude Options")
self.setMinimumWidth(320)
self._preview_callback = None
layout = QVBoxLayout(self)
length_layout = QHBoxLayout()
length_layout.addWidget(QLabel("Extrude Length (mm):"))
self.length_input = QDoubleSpinBox()
self.length_input.setDecimals(2)
self.length_input.setRange(-10000, 10000)
self.length_input.setValue(10)
length_layout.addWidget(self.length_input)
layout.addLayout(length_layout)
self.symmetric_checkbox = QCheckBox("Symmetric Extrude")
layout.addWidget(self.symmetric_checkbox)
self.invert_checkbox = QCheckBox("Invert Extrusion")
layout.addWidget(self.invert_checkbox)
self.cut_checkbox = QCheckBox("Perform Cut")
layout.addWidget(self.cut_checkbox)
self.union_checkbox = QCheckBox("Combine (Union)")
layout.addWidget(self.union_checkbox)
self.through_all_checkbox = QCheckBox("Through All (cut/union target)")
self.through_all_checkbox.setToolTip(
"Ignore the typed length and extrude far enough to fully pass "
"through the cut/union target body. Applies when Perform Cut or "
"Combine (Union) is checked."
)
layout.addWidget(self.through_all_checkbox)
self.rounded_checkbox = QCheckBox("Round Edges")
layout.addWidget(self.rounded_checkbox)
line = QFrame()
line.setFrameShape(QFrame.HLine)
line.setFrameShadow(QFrame.Sunken)
layout.addWidget(line)
button_layout = QHBoxLayout()
ok_button = QPushButton("OK")
ok_button.clicked.connect(self.accept)
cancel_button = QPushButton("Cancel")
cancel_button.clicked.connect(self.reject)
button_layout.addWidget(ok_button)
button_layout.addWidget(cancel_button)
layout.addLayout(button_layout)
# Live preview: recompute on every option change. Use a light-
# weight guard so we don't emit before the host has wired up the
# callback.
for w in (
self.length_input,
self.symmetric_checkbox,
self.invert_checkbox,
self.cut_checkbox,
self.union_checkbox,
self.through_all_checkbox,
self.rounded_checkbox,
):
# The spinbox has valueChanged; the checkboxes have stateChanged.
# Each must be wired in its own try/except so that a missing
# signal on one widget type doesn't skip the OTHER signal's
# connection (the prior single-try version accidentally
# left checkboxes un-connected when valueChanged raised first).
try:
w.valueChanged.connect(self._emit_preview)
except AttributeError:
pass
try:
w.stateChanged.connect(self._emit_preview)
except AttributeError:
pass
def set_preview_callback(self, callback) -> None:
"""Install the live-preview callback (or *None* to disable)."""
self._preview_callback = callback
# Emit once so the initial state shows a preview right away.
self._emit_preview()
def _emit_preview(self, *args) -> None:
if self._preview_callback is None:
return
try:
self._preview_callback(self.get_values())
except Exception as exc: # preview must never break the dialog
logger.debug("extrude preview callback raised: %s", exc)
def hideEvent(self, event):
# Tell the host to clear the preview when the dialog goes away
# (accept, reject, or close). The host is responsible for the
# actual viewer cleanup.
if self._preview_callback is not None:
try:
self._preview_callback(None)
except Exception:
pass
super().hideEvent(event)
def get_values(self) -> Tuple[float, bool, bool, bool, bool, bool, bool]:
return (
self.length_input.value(),
self.symmetric_checkbox.isChecked(),
self.invert_checkbox.isChecked(),
self.cut_checkbox.isChecked(),
self.union_checkbox.isChecked(),
self.through_all_checkbox.isChecked(),
self.rounded_checkbox.isChecked(),
)
class RevolveDialog(QDialog):
"""Dialog for revolve options."""
def __init__(self, parent=None):
super().__init__(parent)
self.setWindowTitle("Revolve Options")
self.setMinimumWidth(300)
layout = QVBoxLayout(self)
angle_layout = QHBoxLayout()
angle_layout.addWidget(QLabel("Revolve Angle (°):"))
self.angle_input = QDoubleSpinBox()
self.angle_input.setDecimals(1)
self.angle_input.setRange(1, 360)
self.angle_input.setValue(360)
self.angle_input.setSuffix("°")
angle_layout.addWidget(self.angle_input)
layout.addLayout(angle_layout)
line = QFrame()
line.setFrameShape(QFrame.HLine)
line.setFrameShadow(QFrame.Sunken)
layout.addWidget(line)
button_layout = QHBoxLayout()
ok_button = QPushButton("OK")
ok_button.clicked.connect(self.accept)
cancel_button = QPushButton("Cancel")
cancel_button.clicked.connect(self.reject)
button_layout.addWidget(ok_button)
button_layout.addWidget(cancel_button)
layout.addLayout(button_layout)
class OffsetDialog(QDialog):
"""Dialog for 2D sketch offset options.
Shows a number input for the offset distance with a live preview
callback so the sketch widget can render the offset result in real
time. On accept the caller retrieves ``get_values()`` distance.
"""
def __init__(self, parent=None):
super().__init__(parent)
self.setWindowTitle("Offset Sketch")
self.setMinimumWidth(300)
self._preview_callback = None
layout = QVBoxLayout(self)
dist_layout = QHBoxLayout()
dist_layout.addWidget(QLabel("Offset Distance (mm):"))
self.distance_input = QDoubleSpinBox()
self.distance_input.setDecimals(2)
self.distance_input.setRange(-10000, 10000)
self.distance_input.setValue(10.0)
self.distance_input.setSingleStep(0.5)
dist_layout.addWidget(self.distance_input)
layout.addLayout(dist_layout)
self.inward_checkbox = QCheckBox("Offset Inward (negative)")
self.inward_checkbox.setToolTip("Offset is applied inward instead of outward.")
layout.addWidget(self.inward_checkbox)
line = QFrame()
line.setFrameShape(QFrame.HLine)
line.setFrameShadow(QFrame.Sunken)
layout.addWidget(line)
button_layout = QHBoxLayout()
ok_button = QPushButton("OK")
ok_button.clicked.connect(self.accept)
cancel_button = QPushButton("Cancel")
cancel_button.clicked.connect(self.reject)
button_layout.addWidget(ok_button)
button_layout.addWidget(cancel_button)
layout.addLayout(button_layout)
# Live preview on every value change.
self.distance_input.valueChanged.connect(self._emit_preview)
self.inward_checkbox.stateChanged.connect(self._emit_preview)
def set_preview_callback(self, callback) -> None:
"""Install the live-preview callback (or *None* to disable)."""
self._preview_callback = callback
self._emit_preview()
def _emit_preview(self, *args) -> None:
if self._preview_callback is None:
return
try:
self._preview_callback(self.get_values())
except Exception as exc:
logger.debug("offset preview callback raised: %s", exc)
def hideEvent(self, event):
if self._preview_callback is not None:
try:
self._preview_callback(None)
except Exception:
pass
super().hideEvent(event)
def get_values(self) -> Tuple[float, bool]:
return (self.distance_input.value(), self.inward_checkbox.isChecked())
class WorkplaneOrientationDialog(QDialog):
"""Modal dialog to choose the orientation of a new workplane.
Offers XY, XZ, YZ, and custom angle presets. On accept, the caller
can retrieve the chosen orientation via :meth:`get_orientation`, which
returns (normal, x_dir) pair (both as 3-tuples).
"""
def __init__(self, parent=None):
super().__init__(parent)
self.setWindowTitle("New Workplane Orientation")
self.setMinimumWidth(320)
self._normal: Tuple[float, float, float] = (0.0, 0.0, 1.0)
self._x_dir: Tuple[float, float, float] = (1.0, 0.0, 0.0)
# Optional callback for live 3D preview of the workplane.
# The host installs it via ``set_preview_callback``. The callback
# receives ``(normal, x_dir)`` or *None* to clear.
self._preview_callback = None
layout = QVBoxLayout(self)
# ── Orientation presets ──
lbl = QLabel("Choose orientation:")
layout.addWidget(lbl)
self._preset_group = QButtonGroup(self)
preset_layout = QGridLayout()
presets = [
("XY (Top)", (0, 0, 1), (1, 0, 0)),
("XZ (Front)", (0, 1, 0), (1, 0, 0)),
("YZ (Right)", (1, 0, 0), (0, 1, 0)),
("-XY (Bottom)", (0, 0, -1), (1, 0, 0)),
("-XZ (Back)", (0, -1, 0), (1, 0, 0)),
("-YZ (Left)", (-1, 0, 0), (0, 1, 0)),
]
for idx, (label, normal, x_dir) in enumerate(presets):
btn = QRadioButton(label)
btn.setChecked(idx == 0)
self._preset_group.addButton(btn, idx)
btn.normal = normal
btn.x_dir = x_dir
preset_layout.addWidget(btn, idx // 2, idx % 2)
layout.addLayout(preset_layout)
# ── Custom angle (offset from XY) ──
line = QFrame()
line.setFrameShape(QFrame.HLine)
line.setFrameShadow(QFrame.Sunken)
layout.addWidget(line)
self._custom_radio = QRadioButton("Custom (angle from XY):")
self._custom_radio.setChecked(False)
layout.addWidget(self._custom_radio)
angle_layout = QHBoxLayout()
angle_layout.addWidget(QLabel("Angle X (°):"))
self._angle_x = QDoubleSpinBox()
self._angle_x.setDecimals(1)
self._angle_x.setRange(-360, 360)
self._angle_x.setValue(0.0)
self._angle_x.setSuffix("°")
angle_layout.addWidget(self._angle_x)
angle_layout.addWidget(QLabel("Angle Y (°):"))
self._angle_y = QDoubleSpinBox()
self._angle_y.setDecimals(1)
self._angle_y.setRange(-360, 360)
self._angle_y.setValue(0.0)
self._angle_y.setSuffix("°")
angle_layout.addWidget(self._angle_y)
layout.addLayout(angle_layout)
self._name_label = QLabel("Workplane Name:")
layout.addWidget(self._name_label)
self._name_input = QLineEdit()
self._name_input.setText("Workplane 1")
layout.addWidget(self._name_input)
# ── Buttons ──
line2 = QFrame()
line2.setFrameShape(QFrame.HLine)
line2.setFrameShadow(QFrame.Sunken)
layout.addWidget(line2)
button_layout = QHBoxLayout()
ok_button = QPushButton("Create")
ok_button.clicked.connect(self._on_ok)
cancel_button = QPushButton("Cancel")
cancel_button.clicked.connect(self.reject)
button_layout.addWidget(ok_button)
button_layout.addWidget(cancel_button)
layout.addLayout(button_layout)
# Live preview: update the 3D view whenever the user changes
# the preset, custom radio toggle, or angle values.
self._preset_group.buttonClicked.connect(self._on_preset_changed)
self._custom_radio.toggled.connect(self._emit_preview)
self._angle_x.valueChanged.connect(self._emit_preview)
self._angle_y.valueChanged.connect(self._emit_preview)
def set_preview_callback(self, callback) -> None:
"""Install a callback for live 3D preview of the workplane orientation.
*callback* is called with ``(normal, x_dir)`` whenever the user
changes the selection, or with *None* when the dialog closes.
"""
self._preview_callback = callback
# Emit once so the initial state shows a preview right away.
self._emit_preview()
def _emit_preview(self, *args) -> None:
"""Call the preview callback with the current orientation, if installed."""
if self._preview_callback is None:
return
try:
normal, x_dir, _name = self.get_orientation()
self._preview_callback((normal, x_dir))
except Exception as exc:
logger.debug("workplane preview callback raised: %s", exc)
def hideEvent(self, event):
"""Clear the live preview when the dialog closes."""
if self._preview_callback is not None:
try:
self._preview_callback(None)
except Exception:
pass
super().hideEvent(event)
def _on_preset_changed(self, btn):
"""When a preset is selected, deselect the custom radio and emit preview."""
self._custom_radio.setChecked(False)
self._emit_preview()
def _on_ok(self):
"""Compute the final orientation and accept."""
import numpy as np
import math
if self._custom_radio.isChecked():
# Custom: start from XY normal and rotate by the two angles.
ax = math.radians(self._angle_x.value())
ay = math.radians(self._angle_y.value())
# Start from +Z normal, rotate around X then Y
n = np.array([0.0, 0.0, 1.0])
# Rotate around X
rx = np.array([
[1, 0, 0],
[0, math.cos(ax), -math.sin(ax)],
[0, math.sin(ax), math.cos(ax)],
])
n = rx @ n
# Rotate around Y
ry = np.array([
[math.cos(ay), 0, math.sin(ay)],
[0, 1, 0],
[-math.sin(ay), 0, math.cos(ay)],
])
n = ry @ n
n = n / np.linalg.norm(n)
# x_dir: cross product of normal with world Y, or world Z if normal ~ Y
world_y = np.array([0.0, 1.0, 0.0])
if abs(np.dot(n, world_y)) > 0.99:
world_y = np.array([0.0, 0.0, 1.0])
x = np.cross(world_y, n)
x_norm = np.linalg.norm(x)
if x_norm > 1e-9:
x = x / x_norm
else:
x = np.array([1.0, 0.0, 0.0])
self._normal = tuple(float(v) for v in n)
self._x_dir = tuple(float(v) for v in x)
else:
btn = self._preset_group.checkedButton()
if btn is not None:
self._normal = btn.normal
self._x_dir = btn.x_dir
self.accept()
def get_orientation(self) -> Tuple[Tuple[float, float, float], Tuple[float, float, float], str]:
"""Return (normal, x_dir, name) for the chosen workplane.
Computes the current selection from the UI state so it works
whether called before or after ``_on_ok``.
"""
import numpy as np
import math
if self._custom_radio.isChecked():
ax = math.radians(self._angle_x.value())
ay = math.radians(self._angle_y.value())
n = np.array([0.0, 0.0, 1.0])
rx = np.array([
[1, 0, 0],
[0, math.cos(ax), -math.sin(ax)],
[0, math.sin(ax), math.cos(ax)],
])
n = rx @ n
ry = np.array([
[math.cos(ay), 0, math.sin(ay)],
[0, 1, 0],
[-math.sin(ay), 0, math.cos(ay)],
])
n = ry @ n
n = n / np.linalg.norm(n)
world_y = np.array([0.0, 1.0, 0.0])
if abs(np.dot(n, world_y)) > 0.99:
world_y = np.array([0.0, 0.0, 1.0])
x = np.cross(world_y, n)
x_norm = np.linalg.norm(x)
if x_norm > 1e-9:
x = x / x_norm
else:
x = np.array([1.0, 0.0, 0.0])
return (
tuple(float(v) for v in n),
tuple(float(v) for v in x),
self._name_input.text().strip() or "Workplane",
)
else:
btn = self._preset_group.checkedButton()
if btn is not None:
return (btn.normal, btn.x_dir, self._name_input.text().strip() or "Workplane")
# Fallback: XY default.
return ((0.0, 0.0, 1.0), (1.0, 0.0, 0.0), self._name_input.text().strip() or "Workplane")
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+811
View File
@@ -0,0 +1,811 @@
"""3D viewer widget — wraps OCC's AIS/V3d native display for use inside Qt."""
from __future__ import annotations
import logging
import sys
from typing import Any, Dict, List, Optional, Tuple
from PySide6.QtCore import Qt, Signal, Slot, QPoint, QPointF, QSize, QRect
from PySide6.QtGui import QCursor, QFont, QPainter, QPen, QColor, QBrush, QPolygonF
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)
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
# 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()
from fluency.geometry_occ.sketch import OCCSketch
# 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.
if self._connector_pick_mode:
self._handle_connector_hover(event)
super().mouseMoveEvent(event)
return
# 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)
def wheelEvent(self, event):
self._ensure_initialized()
self._renderer.handle_wheel(event)
super().wheelEvent(event)
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),
)
# ─── 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) -> 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.
"""
self._connector_pick_mode = bool(enabled)
if enabled:
self.setCursor(Qt.CrossCursor)
elif not self._pick_face_mode:
self.unsetCursor()
if not enabled:
self._clear_connector_snap()
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.
Probes a small neighbourhood around the cursor for ALL nearby snap
candidates (vertices, edge midpoints, face centres, hole openings)
and renders a dim marker on each plus a bright primary on the nearest
one the general snap indicator. Clicking then selects the
primary's position.
"""
self._ensure_initialized()
probe = getattr(self._renderer, "probe_snap_candidates", 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 single-pixel pick on renderers without the probe.
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", "")
# 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)
self.connectorHover.emit({
"origin": origin,
"normal": normal,
"type": entity_type,
"owner_obj_id": owner,
})
def _handle_connector_pick(self, event) -> None:
"""Detect an entity under the click and emit connectorPicked.
Uses the multi-pixel ``probe_snap_candidates`` so a click selects the
PRIMARY (nearest) snap target the same one the hover gizmo
emphasised. Falls back to single-pixel ``pick_entity`` then to
``pick_planar_face`` on renderers without the probe.
"""
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", None)
if probe is not None:
candidates = probe(pos.x(), pos.y())
if candidates:
info = candidates[0] # nearest = primary
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
from OCP.V3d import V3d_TypeOfOrientation
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)
+3
View File
@@ -0,0 +1,3 @@
"""Utilities module."""
__all__ = []
+3
View File
@@ -0,0 +1,3 @@
"""Widgets module."""
__all__ = []

Some files were not shown because too many files have changed in this diff Show More