Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 742d06d242 | |||
| c78d0af78c | |||
| dda9db822b | |||
| 9f1387fe68 | |||
| 210e3cfb5d | |||
| b8516fff91 | |||
| d7e5929a13 | |||
| 2b2afbc479 | |||
| b0aebdc04f | |||
| be22c44a3f | |||
| 80ba3cc70a | |||
| 5269c0897c | |||
| 3a169007f7 | |||
| b595b88e04 | |||
| 9f10a5c5e5 | |||
| 6ba742ddf5 | |||
| 01833e4af2 | |||
| f860ff3e77 |
Generated
+169
-10
@@ -4,13 +4,14 @@
|
||||
<option name="autoReloadType" value="SELECTIVE" />
|
||||
</component>
|
||||
<component name="ChangeListManager">
|
||||
<list default="true" id="8f0bafd6-58a0-4b20-aa2b-ddc3ba278873" name="Changes" comment="- Tons of addtions">
|
||||
<list default="true" id="8f0bafd6-58a0-4b20-aa2b-ddc3ba278873" name="Changes" comment="- added renderer - Added undo">
|
||||
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/src/fluency/geometry_occ/kernel.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/geometry_occ/kernel.py" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/src/fluency/geometry_occ/sketch.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/geometry_occ/sketch.py" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/src/fluency/main.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/main.py" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/src/fluency/rendering/mitsuba_backend.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/rendering/mitsuba_backend.py" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/src/fluency/rendering/occ_renderer.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/rendering/occ_renderer.py" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/tests/test_geometry.py" beforeDir="false" afterPath="$PROJECT_DIR$/tests/test_geometry.py" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/src/fluency/rendering/render_backend.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/rendering/render_backend.py" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/src/fluency/ui/main_window.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/ui/main_window.py" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/src/fluency/ui/render_window.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/ui/render_window.py" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/src/fluency/ui/viewer_widget.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/ui/viewer_widget.py" afterDir="false" />
|
||||
</list>
|
||||
<option name="SHOW_DIALOG" value="false" />
|
||||
<option name="HIGHLIGHT_CONFLICTS" value="true" />
|
||||
@@ -52,17 +53,23 @@
|
||||
"Python.2dtest.executor": "Run",
|
||||
"Python.3d_windows.executor": "Run",
|
||||
"Python.Unnamed.executor": "Run",
|
||||
"Python.base.executor": "Run",
|
||||
"Python.data_model.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.gui_ui.executor": "Run",
|
||||
"Python.kernel.executor": "Run",
|
||||
"Python.main.executor": "Run",
|
||||
"Python.meshtest.executor": "Run",
|
||||
"Python.occ_renderer.executor": "Run",
|
||||
"Python.occ_to_mesh.executor": "Run",
|
||||
"Python.render_backend.executor": "Run",
|
||||
"Python.side_fluency.executor": "Run",
|
||||
"Python.simple_mesh.executor": "Run",
|
||||
"Python.sketch.executor": "Run",
|
||||
"Python.vtk_widget.executor": "Run",
|
||||
"Python.vulkan.executor": "Run",
|
||||
"RunOnceActivity.OpenProjectViewOnStart": "true",
|
||||
@@ -72,7 +79,7 @@
|
||||
"RunOnceActivity.typescript.service.memoryLimit.init": "true",
|
||||
"codeWithMe.voiceChat.enabledByDefault": "false",
|
||||
"git-widget-placeholder": "feature/occ-migration",
|
||||
"last_opened_file_path": "/Volumes/Data_drive/Programming/fluency/src/fluency",
|
||||
"last_opened_file_path": "/Volumes/Data_drive/Programming/fluency/src/fluency/rendering",
|
||||
"node.js.detected.package.eslint": "true",
|
||||
"node.js.selected.package.eslint": "(autodetect)",
|
||||
"node.js.selected.package.tslint": "(autodetect)",
|
||||
@@ -87,6 +94,7 @@
|
||||
</component>
|
||||
<component name="RecentsManager">
|
||||
<key name="CopyFile.RECENT_KEYS">
|
||||
<recent name="$PROJECT_DIR$/src/fluency/rendering" />
|
||||
<recent name="$PROJECT_DIR$/src/fluency" />
|
||||
<recent name="$PROJECT_DIR$" />
|
||||
<recent name="$PROJECT_DIR$/drawing_modules" />
|
||||
@@ -280,7 +288,151 @@
|
||||
<option name="project" value="LOCAL" />
|
||||
<updated>1782679912834</updated>
|
||||
</task>
|
||||
<option name="localTasksCounter" value="22" />
|
||||
<task id="LOCAL-00022" summary="- Basic operations">
|
||||
<option name="closed" value="true" />
|
||||
<created>1782768610475</created>
|
||||
<option name="number" value="00022" />
|
||||
<option name="presentableId" value="LOCAL-00022" />
|
||||
<option name="project" value="LOCAL" />
|
||||
<updated>1782768610475</updated>
|
||||
</task>
|
||||
<task id="LOCAL-00023" summary="- removed cadquery deoendency">
|
||||
<option name="closed" value="true" />
|
||||
<created>1782928990792</created>
|
||||
<option name="number" value="00023" />
|
||||
<option name="presentableId" value="LOCAL-00023" />
|
||||
<option name="project" value="LOCAL" />
|
||||
<updated>1782928990792</updated>
|
||||
</task>
|
||||
<task id="LOCAL-00024" summary="- sketch enhacements">
|
||||
<option name="closed" value="true" />
|
||||
<created>1783108151675</created>
|
||||
<option name="number" value="00024" />
|
||||
<option name="presentableId" value="LOCAL-00024" />
|
||||
<option name="project" value="LOCAL" />
|
||||
<updated>1783108151676</updated>
|
||||
</task>
|
||||
<task id="LOCAL-00025" summary="- sketch enhacements">
|
||||
<option name="closed" value="true" />
|
||||
<created>1783159860774</created>
|
||||
<option name="number" value="00025" />
|
||||
<option name="presentableId" value="LOCAL-00025" />
|
||||
<option name="project" value="LOCAL" />
|
||||
<updated>1783159860774</updated>
|
||||
</task>
|
||||
<task id="LOCAL-00026" summary="- UI refinement, button position ui file as source no dirty drafting anymore">
|
||||
<option name="closed" value="true" />
|
||||
<created>1783174566362</created>
|
||||
<option name="number" value="00026" />
|
||||
<option name="presentableId" value="LOCAL-00026" />
|
||||
<option name="project" value="LOCAL" />
|
||||
<updated>1783174566362</updated>
|
||||
</task>
|
||||
<task id="LOCAL-00027" summary="- assembly draft">
|
||||
<option name="closed" value="true" />
|
||||
<created>1783239410744</created>
|
||||
<option name="number" value="00027" />
|
||||
<option name="presentableId" value="LOCAL-00027" />
|
||||
<option name="project" value="LOCAL" />
|
||||
<updated>1783239410744</updated>
|
||||
</task>
|
||||
<task id="LOCAL-00028" summary="- assembly draft">
|
||||
<option name="closed" value="true" />
|
||||
<created>1783272988957</created>
|
||||
<option name="number" value="00028" />
|
||||
<option name="presentableId" value="LOCAL-00028" />
|
||||
<option name="project" value="LOCAL" />
|
||||
<updated>1783272988957</updated>
|
||||
</task>
|
||||
<task id="LOCAL-00029" summary="- Added save file foramt - Split main.py refactor">
|
||||
<option name="closed" value="true" />
|
||||
<created>1783282570014</created>
|
||||
<option name="number" value="00029" />
|
||||
<option name="presentableId" value="LOCAL-00029" />
|
||||
<option name="project" value="LOCAL" />
|
||||
<updated>1783282570014</updated>
|
||||
</task>
|
||||
<task id="LOCAL-00030" summary="- Added save file foramt - Split main.py refactor">
|
||||
<option name="closed" value="true" />
|
||||
<created>1783453889199</created>
|
||||
<option name="number" value="00030" />
|
||||
<option name="presentableId" value="LOCAL-00030" />
|
||||
<option name="project" value="LOCAL" />
|
||||
<updated>1783453889199</updated>
|
||||
</task>
|
||||
<task id="LOCAL-00031" summary="- Added save file foramt - Split main.py refactor">
|
||||
<option name="closed" value="true" />
|
||||
<created>1783456842297</created>
|
||||
<option name="number" value="00031" />
|
||||
<option name="presentableId" value="LOCAL-00031" />
|
||||
<option name="project" value="LOCAL" />
|
||||
<updated>1783456842297</updated>
|
||||
</task>
|
||||
<task id="LOCAL-00032" summary="- Added save file foramt - Split main.py refactor">
|
||||
<option name="closed" value="true" />
|
||||
<created>1783755278516</created>
|
||||
<option name="number" value="00032" />
|
||||
<option name="presentableId" value="LOCAL-00032" />
|
||||
<option name="project" value="LOCAL" />
|
||||
<updated>1783755278516</updated>
|
||||
</task>
|
||||
<task id="LOCAL-00033" summary="- Added save file foramt - Split main.py refactor">
|
||||
<option name="closed" value="true" />
|
||||
<created>1783777171864</created>
|
||||
<option name="number" value="00033" />
|
||||
<option name="presentableId" value="LOCAL-00033" />
|
||||
<option name="project" value="LOCAL" />
|
||||
<updated>1783777171864</updated>
|
||||
</task>
|
||||
<task id="LOCAL-00034" summary="- Working assembly multi :)">
|
||||
<option name="closed" value="true" />
|
||||
<created>1783798201133</created>
|
||||
<option name="number" value="00034" />
|
||||
<option name="presentableId" value="LOCAL-00034" />
|
||||
<option name="project" value="LOCAL" />
|
||||
<updated>1783798201133</updated>
|
||||
</task>
|
||||
<task id="LOCAL-00035" summary="- Working assembly multi :)">
|
||||
<option name="closed" value="true" />
|
||||
<created>1783798929531</created>
|
||||
<option name="number" value="00035" />
|
||||
<option name="presentableId" value="LOCAL-00035" />
|
||||
<option name="project" value="LOCAL" />
|
||||
<updated>1783798929531</updated>
|
||||
</task>
|
||||
<task id="LOCAL-00036" summary="- added renderer">
|
||||
<option name="closed" value="true" />
|
||||
<created>1783887682052</created>
|
||||
<option name="number" value="00036" />
|
||||
<option name="presentableId" value="LOCAL-00036" />
|
||||
<option name="project" value="LOCAL" />
|
||||
<updated>1783887682052</updated>
|
||||
</task>
|
||||
<task id="LOCAL-00037" summary="- added renderer - Added undo">
|
||||
<option name="closed" value="true" />
|
||||
<created>1783887704766</created>
|
||||
<option name="number" value="00037" />
|
||||
<option name="presentableId" value="LOCAL-00037" />
|
||||
<option name="project" value="LOCAL" />
|
||||
<updated>1783887704766</updated>
|
||||
</task>
|
||||
<task id="LOCAL-00038" summary="- added renderer - Added undo">
|
||||
<option name="closed" value="true" />
|
||||
<created>1783891561116</created>
|
||||
<option name="number" value="00038" />
|
||||
<option name="presentableId" value="LOCAL-00038" />
|
||||
<option name="project" value="LOCAL" />
|
||||
<updated>1783891561116</updated>
|
||||
</task>
|
||||
<task id="LOCAL-00039" summary="- added renderer - Added undo">
|
||||
<option name="closed" value="true" />
|
||||
<created>1783918463034</created>
|
||||
<option name="number" value="00039" />
|
||||
<option name="presentableId" value="LOCAL-00039" />
|
||||
<option name="project" value="LOCAL" />
|
||||
<updated>1783918463034</updated>
|
||||
</task>
|
||||
<option name="localTasksCounter" value="40" />
|
||||
<servers />
|
||||
</component>
|
||||
<component name="TypeScriptGeneratedFilesManager">
|
||||
@@ -301,8 +453,6 @@
|
||||
<ignored-roots>
|
||||
<path value="$PROJECT_DIR$/pythonProject" />
|
||||
</ignored-roots>
|
||||
<MESSAGE value="init" />
|
||||
<MESSAGE value="- Basic oop sketch widget implement" />
|
||||
<MESSAGE value="- Renabled extrusion with new object system" />
|
||||
<MESSAGE value="- Sketch projection partly works again :)" />
|
||||
<MESSAGE value="- Added new componnt controls" />
|
||||
@@ -319,6 +469,15 @@
|
||||
<MESSAGE value="- added screenshot" />
|
||||
<MESSAGE value="- added sdf folder ( doesnt work via pip or git=)" />
|
||||
<MESSAGE value="- Tons of addtions" />
|
||||
<option name="LAST_COMMIT_MESSAGE" value="- Tons of addtions" />
|
||||
<MESSAGE value="- Basic operations" />
|
||||
<MESSAGE value="- removed cadquery deoendency" />
|
||||
<MESSAGE value="- sketch enhacements" />
|
||||
<MESSAGE value="- UI refinement, button position ui file as source no dirty drafting anymore" />
|
||||
<MESSAGE value="- assembly draft" />
|
||||
<MESSAGE value="- Added save file foramt - Split main.py refactor" />
|
||||
<MESSAGE value="- Working assembly multi :)" />
|
||||
<MESSAGE value="- added renderer" />
|
||||
<MESSAGE value="- added renderer - Added undo" />
|
||||
<option name="LAST_COMMIT_MESSAGE" value="- added renderer - Added undo" />
|
||||
</component>
|
||||
</project>
|
||||
@@ -6,7 +6,7 @@ A parametric CAD application built on OpenCASCADE Technology (OCCT) with a moder
|
||||
|
||||
- **OpenCASCADE Geometry Kernel**: Industry-standard BRep geometry with exact precision
|
||||
- **STEP/IGES Import/Export**: Full support for industry-standard CAD file formats
|
||||
- **Parametric Sketching**: 2D sketching with constraint solving using CadQuery
|
||||
- **Parametric Sketching**: 2D sketching with constraint solving using SolveSpace
|
||||
- **Boolean Operations**: Union, difference, and intersection
|
||||
- **Fillet & Chamfer**: Apply edge treatments to solid bodies
|
||||
- **Modern Renderer**: WebGPU-based rendering with pygfx (smaller footprint than VTK)
|
||||
@@ -47,8 +47,7 @@ pip install -e ".[dev]"
|
||||
|
||||
| Package | Purpose |
|
||||
|---------|---------|
|
||||
| cadquery | High-level OpenCASCADE Python bindings |
|
||||
| ocp | Low-level OpenCASCADE Python bindings |
|
||||
| cadquery-ocp | OpenCASCADE Python bindings (OCP) |
|
||||
| pygfx | WebGPU-based 3D renderer |
|
||||
| wgpu | WebGPU Python bindings |
|
||||
| PySide6 | Qt GUI framework |
|
||||
@@ -105,7 +104,7 @@ kernel.export_stl(body, "part.stl")
|
||||
| File Size | Large (mesh) | Small (BRep) |
|
||||
| Fillet/Chamfer | Approximate | Exact |
|
||||
| Dependency Size | ~200MB (VTK) | ~30MB (pygfx) |
|
||||
| Constraint Solver | SolveSpace (separate) | CadQuery (integrated) |
|
||||
| Constraint Solver | SolveSpace (separate) | SolveSpace (integrated) |
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
# Fluency CAD — Agent Guide
|
||||
|
||||
## Project Overview
|
||||
|
||||
**Fluency CAD 2.0** is a parametric CAD application built on **OpenCASCADE Technology (OCCT)** with a modern **pygfx-based 3D renderer**. It provides 2D sketching with SolveSpace constraint solving, boolean operations, STEP/IGES/STL import/export, and exact BRep geometry.
|
||||
|
||||
- Language: **Python 3.10+**
|
||||
- GUI: **PySide6** (Qt6)
|
||||
- Geometry Kernel: **OCP** (cadquery-ocp — OpenCASCADE Python bindings)
|
||||
- Constraint Solver: **python_solvespace**
|
||||
- Renderer: **pygfx** (WebGPU) + **OCCRenderer** (native OCC AIS display)
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
src/fluency/
|
||||
├── __init__.py # Package entry (version, imports)
|
||||
├── main.py # Application entry point, MainWindow, Sketch2DWidget (~5000 lines)
|
||||
├── sketch_solver.py # SolveSpace constraint solver wrapper (legacy)
|
||||
├── geometry/
|
||||
│ └── base.py # Abstract interfaces: GeometryKernel, SketchInterface, data classes
|
||||
├── geometry_occ/
|
||||
│ ├── kernel.py # OCGeometryKernel (extrude, boolean, fillet, import/export, mesh)
|
||||
│ └── sketch.py # OCCSketch with SolveSpace integration (face detection, constraints)
|
||||
├── models/
|
||||
│ └── data_model.py # Project, Component, Sketch, Body dataclasses
|
||||
├── rendering/
|
||||
│ ├── base.py # Abstract Renderer interface
|
||||
│ ├── occ_renderer.py # OCC AIS renderer (preferred — smooth BRep display)
|
||||
│ └── pygfx_renderer.py # Legacy pygfx renderer
|
||||
├── utils/ # Utility modules
|
||||
└── widgets/ # Custom widgets
|
||||
tests/
|
||||
└── test_geometry.py # Comprehensive test suite (52+ tests)
|
||||
```
|
||||
|
||||
### Key Classes & Responsibilities
|
||||
|
||||
| Class | File | Purpose |
|
||||
|-------|------|---------|
|
||||
| `OCGeometryKernel` | `kernel.py` | OCC shape ops: extrude, boolean, fillet, mesh, import/export |
|
||||
| `OCCSketch` | `sketch.py` | 2D sketch with SolveSpace solver, face detection, workplane |
|
||||
| `OCCSketchEntity` | `sketch.py` | Entity (point/line/circle/arc) with solver handle, is_construction, is_external |
|
||||
| `Sketch2DWidget` | `main.py` | Qt widget for interactive 2D sketching (draw, snap, constrain) |
|
||||
| `MainWindow` | `main.py` | Main application window, toolbars, 3D viewer, operations |
|
||||
| `OCCRenderer` | `occ_renderer.py` | Native OCC AIS display (shaded + edges, face pick) |
|
||||
| `Sketch` | `data_model.py` | Data model: workplane, occ_sketch ref, source_body_id |
|
||||
| `Body` | `data_model.py` | 3D solid with geometry, visibility, render object |
|
||||
| `Component` | `data_model.py` | Container for sketches and bodies |
|
||||
| `Project` | `data_model.py` | Top-level container with kernel |
|
||||
|
||||
### Data Flow
|
||||
|
||||
```
|
||||
User draws in Sketch2DWidget
|
||||
→ OCCSketch entities created in solver
|
||||
→ Constraint solving (python_solvespace)
|
||||
→ OCCSketch.get_geometry() → detect_faces() → build_face_geometry()
|
||||
→ OCGeometryKernel.extrude() → BRepPrimAPI_MakePrism
|
||||
→ Boolean operations → Body added to Component
|
||||
→ OCCRenderer.add_shape() → AIS display
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Development Commands
|
||||
|
||||
```bash
|
||||
# Install editable
|
||||
pip install -e ".[dev]"
|
||||
|
||||
# Run app
|
||||
python -m fluency.main
|
||||
|
||||
# Run tests (52 tests)
|
||||
python -m pytest tests/test_geometry.py -v
|
||||
|
||||
# Run single test
|
||||
python -m pytest tests/test_geometry.py::TestOCCSketch::test_workplane_extrude_with_hole -xvs
|
||||
|
||||
# Quck geometry test (raw OCC, no Qt)
|
||||
python -c "from fluency.geometry_occ.sketch import OCCSketch; ..."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Code Conventions
|
||||
|
||||
### General
|
||||
- Line length: **100 chars** (black/ruff config)
|
||||
- Target Python: **3.10+** (uses `from __future__ import annotations`, walrus, pattern matching)
|
||||
- Docstrings: Google/NumPy style preferred
|
||||
- Logging: `logger = logging.getLogger(__name__)` with `logging.DEBUG` level
|
||||
|
||||
### OCC / OCP
|
||||
- Always use `is not None` for OCP objects — `TopoDS_Shape.__bool__` can be falsy even for valid shapes
|
||||
- `BRepBuilderAPI_MakeFace.Add(wire)` expects a `TopoDS_Wire`. `wire.Reversed()` returns `TopoDS_Shape` → cast via `_TopoDS.Wire_s(wire.Reversed())`
|
||||
- Face normal direction: check `face.Orientation()` vs `TopAbs_REVERSED` — a REVERSED face's outward normal is the NEGATION of the surface axis
|
||||
- `TopoDS_Wire_s(shape)`, `TopoDS_Face_s(shape)` — use `_s` suffix from OCP for downcasts
|
||||
- Mesh: `BRepMesh_IncrementalMesh(shape, tol, False, 0.15, True)` — default deflection 0.15 rad for smooth curves
|
||||
|
||||
### Extrude / Cut Workflow
|
||||
- Snapshot `list(self._current_component.bodies.items())` **BEFORE** `add_body()` — the new body must not be in the target set
|
||||
- Cut targets the **source body** (`sketch._source_body_id` from face pick), not `bodies[0]`
|
||||
- The fix: apply boolean to **target** geometry, then remove tool body
|
||||
- Plain extrude with holes: inner wires must have **OPPOSITE** geometric winding to the outer wire (see `build_face_geometry` and `_loop_signed_area`)
|
||||
|
||||
### Sketch / Solvers
|
||||
- `python_solvespace` has NO remove API for entities/constraints. Deleting requires: drop from `_points`/`_lines`, prune `_constraint_log`, `_rebuild_solver()` (recreates entire system), `_rebuild_labels()`, re-solve
|
||||
- `_constraint_log`: each entry is `{"type": str, "ids": tuple[int,...], "params": tuple, "labels": set[str]}`
|
||||
- Constraint labels: stored on **point** entities for paintEvent rendering; rebuilt via `_rebuild_labels()`
|
||||
- Line constraints (`horizontal`/`vertical`/`parallel`/`perpendicular`) need the **line's** solver handle, not a point's. Use `_find_line_sketch_entity()` to get the correct handle
|
||||
- External entities (underlay): `is_external=True`, `is_construction=True`, fixed in solver (always `dragged`). Stored in `_external_entity_ids`, excluded from `_line_segments()`, `get_polygon_points()`, `get_closed_loops()`, `detect_faces()`, `get_geometry()`
|
||||
|
||||
### Face Detection
|
||||
- `get_closed_loops()`: uses snapped-coordinate graph (`_SNAP_TOL = 1e-4`) from line endpoint adjacency. Only accepts simple cycles (all nodes degree 2)
|
||||
- `detect_faces()`: even-odd nesting rule via `_loop_contains`. Even depth = outer boundary, odd = hole
|
||||
- `_loop_rep_point`: midpoint between centroid and first vertex. **Fragile** — can land inside a nested shape for certain geometries (e.g., a small hole near the centroid's direction from the first vertex)
|
||||
- `_loop_signed_area`: shoelace formula for polygons, `πr²` (positive = CCW) for circles
|
||||
|
||||
### Rendering
|
||||
- **OCCRenderer** is the main renderer (not pygfx). Uses `AIS_Shape`, `V3d_Viewer`, `AIS_InteractiveContext`
|
||||
- Face pick: `pick_planar_face(x, y)` → `MoveTo` → `DetectedShape` → `TopoDS_Face_s` → `BRepAdaptor_Surface` plane check
|
||||
- Highlight: `highlight_face(face)` creates a transparent AIS overlay; `clear_face_highlight()` removes it
|
||||
- Preview: `preview_shape(shape)` for live transparent extrude preview
|
||||
- Navigation: Left=orbit, Middle=pan, Wheel=zoom. **Right is RESERVED** — check user before reassigning
|
||||
|
||||
### Paint-Event Safety
|
||||
- Every constraint-tag rendering loop wraps each entry in `try/except` so a bad entry (dangling id, corrupted geometry) doesn't crash the entire paint event
|
||||
- `_point_world()` and `_entity_anchor()` return `None` (not raise) for malformed input
|
||||
|
||||
---
|
||||
|
||||
## Known Bugs & Fix Patterns
|
||||
|
||||
### 1. Hole Orientation in Extrusion (FIXED 2026-07-03)
|
||||
**Symptom**: Inner shapes (circle/triangle/slot) inside a rectangle become solid islands instead of holes when extruding, depending on drag direction.
|
||||
|
||||
**Root Cause**: `wire_loop` in `build_face_geometry` unconditionally reversed hole wires (`w.Reversed()`). When the outer polygon was CW-winding (e.g., dragging from top-left to bottom-right), the reversed inner had the SAME effective direction as the outer, making OCC treat it as solid.
|
||||
|
||||
**Fix**: Added `_loop_signed_area()` to compute geometric winding. Hole wires are only reversed when their natural winding matches the outer's (ensuring opposite winding for holes).
|
||||
|
||||
**Relevant code**: `sketch.py`, `build_face_geometry()` and `_loop_signed_area()`
|
||||
|
||||
### 2. _loop_rep_point Fragility (KNOWN)
|
||||
**Symptom**: Face detection fails when a nested shape contains the outer loop's representative point (midpoint between centroid and first vertex).
|
||||
|
||||
**Would-be fix**: Use a guaranteed-interior point (maximum inscribed circle center or perturbed centroid) instead of the centroid-first-vertex midpoint.
|
||||
|
||||
### 3. Extrude Cut / Target Selection (FIXED 2026-06-29)
|
||||
**Symptom**: Cut created a separate "cavity-shaped" body next to the original instead of modifying the target.
|
||||
|
||||
**Fix**: Boolean result stored on TARGET body geometry; tool body removed from component. Auto-target via `sketch._source_body_id`.
|
||||
|
||||
### 4. Workplane Preservation (FIXED 2026-06-29)
|
||||
**Symptom**: Sketch placed on a face lost its workplane after being added to component.
|
||||
|
||||
**Fix**: Copy `occ_sketch` workplane fields into `Sketch` dataclass BEFORE `apply_workplane()`.
|
||||
|
||||
---
|
||||
|
||||
## API Quirks
|
||||
|
||||
- **`QPoint(0,0)`**: falsy via `isNull()` in PySide6 → always use `is not None` for `Optional[QPoint]`
|
||||
- **`QMouseEvent`/`QPainterPath`**: live in `PySide6.QtGui` (NOT `QtCore`)
|
||||
- **`BRepBuilderAPI_MakeFace.Add()`**: needs `TopoDS_Wire`. `wire.Reversed()` returns `TopoDS_Shape` — cast via `TopoDS_Wire_s()`
|
||||
- **python_solvespace**: NO entity/constraint remove API — workaround via `_rebuild_solver()`. Parameters read via `solver.params(handle.params)` → returns `(x, y)` tuple
|
||||
- **OCGeometryKernel.extrude**: unwraps `OCCGeometryObject`, raw `TopoDS_Shape`, or cadquery `Workplane`. Always use `is not None` for the shape (not truthiness)
|
||||
- **Sketch._source_body_id**: dynamic attribute set on `Sketch` dataclass, set during face-pick flow
|
||||
- **`_get_shape(obj)`**: returns `obj.shape.wrapped` for `OCCGeometryObject`, `obj.shape` for raw shapes, `None` for empty. Use `is not None` guards everywhere
|
||||
|
||||
---
|
||||
|
||||
## Memory / Agent Context
|
||||
|
||||
This project has extensive Pi memory (hermes-memory) for:
|
||||
- `project="fluency"` with `target="failure"`: bugs, fixes, corrections, insights
|
||||
- `project="fluency"` with `target="memory"`: conventions, decisions, workflow patterns
|
||||
- Available skills: `fix-cad-app-pipeline`, `refactor-from-cadquery-to-ocp`
|
||||
|
||||
Key memory queries for debugging:
|
||||
- "hole orientation" → `_loop_signed_area` / `build_face_geometry` fix
|
||||
- "extrude cut auto-target" → cut/target body fix
|
||||
- "workplane preservation" → _add_sketch_to_component fix
|
||||
- "_loop_rep_point" → face detection fragility
|
||||
- "paint-event safety" → try/except per entry pattern
|
||||
- "solver rebuild" → delete workflow via _rebuild_solver
|
||||
- "face pick origin" → pick_planar_face face bbox centre
|
||||
|
||||
---
|
||||
|
||||
## Testing Patterns
|
||||
|
||||
```python
|
||||
# Direct OCC test (no Qt)
|
||||
from OCP.BRepBuilderAPI import BRepBuilderAPI_MakePolygon, BRepBuilderAPI_MakeFace
|
||||
from OCP.gp import gp_Pnt
|
||||
from OCP.BRepPrimAPI import BRepPrimAPI_MakePrism
|
||||
from OCP.GProp import GProp_GProps; from OCP.BRepGProp import BRepGProp
|
||||
|
||||
# Build test shape, extrude, verify volume
|
||||
mp = BRepBuilderAPI_MakePolygon(); ...
|
||||
g = GProp_GProps(); BRepGProp.VolumeProperties_s(shape, g)
|
||||
assert abs(g.Mass() - expected) < 0.1
|
||||
```
|
||||
|
||||
```python
|
||||
# Sketch-based test
|
||||
from fluency.geometry_occ.sketch import OCCSketch
|
||||
from fluency.geometry_occ.kernel import OCGeometryKernel
|
||||
|
||||
sk = OCCSketch()
|
||||
# ... add points, lines, circles ...
|
||||
sk.solve()
|
||||
geom = sk.get_geometry()
|
||||
solid = OCGeometryKernel().extrude(geom, 10.0)
|
||||
```
|
||||
@@ -0,0 +1,253 @@
|
||||
# Realistic Render View — Implementation Plan
|
||||
|
||||
## Context
|
||||
|
||||
Add a **"Render"** feature to Fluency CAD that opens a separate window for photorealistic rendering of the selected component or assembly (like KeyShot/Cacles).
|
||||
|
||||
**Constraints:**
|
||||
- Open in a **new window** — don't clutter the workspace
|
||||
- **Keep existing OCCRenderer** for the interactive 3D viewport — untouched
|
||||
- Render backend must be a **separate, swappable module** so we can change the renderer later
|
||||
- Use **Mitsuba 3** as the initial backend (`pip install mitsuba`, ~50MB)
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ Main Fluency Window (existing OCCRenderer — untouched) │
|
||||
│ │
|
||||
│ [Select body/assembly] → [Click "Render"] │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌─────────────────────────────────────┐ │
|
||||
│ │ RenderWindow (separate QMainWindow)│ │
|
||||
│ │ │ │
|
||||
│ │ ┌───────────────────────────────┐ │ │
|
||||
│ │ │ RenderBackend (ABC) │ │ │
|
||||
│ │ │ ├─ MitsubaBackend ← current │ │ │
|
||||
│ │ │ ├─ (future: BlenderBackend) │ │ │
|
||||
│ │ │ └─ (future: CyclesBackend) │ │ │
|
||||
│ │ └───────────────────────────────┘ │ │
|
||||
│ │ │ │\n│ │ [Image preview] [Progress bar] │ │
|
||||
│ │ [Material ▾] [Quality ▾] [Render] │ │
|
||||
│ │ [Export PNG] │ │
|
||||
│ └─────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Swappable Backend Interface
|
||||
|
||||
```python
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
import numpy as np
|
||||
|
||||
@dataclass
|
||||
class RenderMaterial:
|
||||
name: str
|
||||
color: tuple[float, float, float] = (0.7, 0.7, 0.7)
|
||||
metallic: float = 0.0 # 0.0–1.0
|
||||
roughness: float = 0.5 # 0.0–1.0
|
||||
bsdf_type: str = "diffuse" # diffuse | roughconductor | roughdielectric | plastic
|
||||
|
||||
@dataclass
|
||||
class RenderCamera:
|
||||
origin: tuple[float, float, float] = (100, 100, 100)
|
||||
target: tuple[float, float, float] = (0, 0, 0)
|
||||
up: tuple[float, float, float] = (0, 0, 1)
|
||||
fov: float = 45.0
|
||||
|
||||
@dataclass
|
||||
class RenderSettings:
|
||||
width: int = 1920
|
||||
height: int = 1080
|
||||
spp: int = 256 # samples per pixel
|
||||
max_depth: int = 8 # path tracer bounces
|
||||
|
||||
class RenderBackend(ABC):
|
||||
"""Swap this to change the rendering engine."""
|
||||
@abstractmethod
|
||||
def render(self, obj_path: str, material: RenderMaterial,
|
||||
camera: RenderCamera, settings: RenderSettings) -> np.ndarray: ...
|
||||
@abstractmethod
|
||||
def render_preview(self, obj_path: str, material: RenderMaterial,
|
||||
camera: RenderCamera, settings: RenderSettings) -> np.ndarray: ...
|
||||
@abstractmethod
|
||||
def name(self) -> str: ...
|
||||
```
|
||||
|
||||
Switching backends later = write a new class implementing `RenderBackend`. One import change.
|
||||
|
||||
---
|
||||
|
||||
## Mitsuba 3 Backend
|
||||
|
||||
### Why Mitsuba
|
||||
|
||||
| Feature | Status |
|
||||
|---------|--------|
|
||||
| `pip install mitsuba` | Single install, no system deps |
|
||||
| True path tracing | GI, caustics, spectral rendering |
|
||||
| PBR materials | `roughconductor`, `roughdielectric`, `diffuse`, `plastic` |
|
||||
| Python dict API | Build scenes programmatically, no XML |
|
||||
| CPU + GPU backends | `scalar_rgb` (CPU), `cuda_rgb` (NVIDIA) |
|
||||
| Output formats | PNG, EXR (HDR) with tonemapping |
|
||||
|
||||
### OCC → OBJ Conversion Path
|
||||
|
||||
```python
|
||||
from OCP.BRepMesh import BRepMesh_IncrementalMesh
|
||||
from OCP.StlAPI import StlAPI_Writer
|
||||
from OCP.BRep import BRep_Builder
|
||||
import tempfile, os
|
||||
|
||||
def occ_shape_to_obj(shape, obj_path: str, linear_deflection: float = 0.1):
|
||||
"""Tessellate OCC shape and write as OBJ for Mitsuba."""
|
||||
tess = BRepMesh_IncrementalMesh(shape, linear_deflection, False, 0.5, True)
|
||||
tess.Perform()
|
||||
# Write STL (reliable), then convert to OBJ via trimesh or direct
|
||||
writer = StlAPI_Writer()
|
||||
writer.SetASCIIMode(False)
|
||||
stl_path = obj_path.replace(".obj", ".stl")
|
||||
writer.Write(shape, stl_path)
|
||||
# Mitsuba can read STL directly, or we convert to OBJ
|
||||
return stl_path
|
||||
```
|
||||
|
||||
### Mitsuba Scene Construction
|
||||
|
||||
```python
|
||||
import mitsuba as mi
|
||||
mi.set_variant("scalar_rgb")
|
||||
|
||||
def build_scene(mesh_path: str, material: RenderMaterial,
|
||||
camera: RenderCamera, settings: RenderSettings) -> mi.Scene:
|
||||
# Map our material to Mitsuba BSDF
|
||||
bsdf_map = {
|
||||
"diffuse": {"type": "diffuse", "reflectance": {"type": "rgb", "value": material.color}},
|
||||
"roughconductor": {
|
||||
"type": "roughconductor",
|
||||
"material": "copper", # or铝, 钢, etc.
|
||||
"alpha": material.roughness,
|
||||
},
|
||||
"roughdielectric": {
|
||||
"type": "roughdielectric",
|
||||
"int_ior": 1.5,
|
||||
"alpha": material.roughness,
|
||||
},
|
||||
"plastic": {
|
||||
"type": "plastic",
|
||||
"diffuse_reflectance": {"type": "rgb", "value": material.color},
|
||||
"int_ior": 1.5,
|
||||
},
|
||||
}
|
||||
|
||||
return mi.load_dict({
|
||||
"type": "scene",
|
||||
"integrator": {"type": "path", "max_depth": settings.max_depth},
|
||||
"sensor": {
|
||||
"type": "perspective",
|
||||
"fov": camera.fov,
|
||||
"to_world": mi.ScalarTransform4f.look_at(
|
||||
origin=camera.origin, target=camera.target, up=camera.up
|
||||
),
|
||||
"film": {"type": "hdrfilm", "width": settings.width, "height": settings.height},
|
||||
"sampler": {"type": "independent", "sample_count": settings.spp},
|
||||
},
|
||||
"emitter": {"type": "constant"},
|
||||
"shape": {
|
||||
"type": "stl", # or "obj"
|
||||
"filename": mesh_path,
|
||||
"bsdf": bsdf_map.get(material.bsdf_type, bsdf_map["diffuse"]),
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Files to Create/Modify
|
||||
|
||||
| File | Action | Description |
|
||||
|------|--------|-------------|
|
||||
| `src/fluency/rendering/render_backend.py` | **NEW** | Abstract `RenderBackend`, `RenderMaterial`, `RenderCamera`, `RenderSettings` |
|
||||
| `src/fluency/rendering/mitsuba_backend.py` | **NEW** | `MitsubaBackend(RenderBackend)` implementation |
|
||||
| `src/fluency/rendering/occ_to_mesh.py` | **NEW** | OCC `TopoDS_Shape` → STL/OBJ tessellation |
|
||||
| `src/fluency/rendering/material_presets.py` | **NEW** | Preset library: Steel, Aluminum, Brass, Chrome, Plastic, Rubber, Wood |
|
||||
| `src/fluency/ui/render_window.py` | **NEW** | `RenderWindow(QMainWindow)` — image preview, material/quality controls, render/export |
|
||||
| `src/fluency/ui/main_window.py` | MODIFY | Add "Render" button → get selected shapes → open `RenderWindow` |
|
||||
|
||||
---
|
||||
|
||||
## UI: RenderWindow
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────┐
|
||||
│ Render — [Part Name] [─][□][×] │
|
||||
├──────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌──────────────────────────────────┐ │
|
||||
│ │ │ │
|
||||
│ │ Rendered Image Preview │ │
|
||||
│ │ (QLabel with QPixmap) │ │
|
||||
│ │ │ │
|
||||
│ └──────────────────────────────────┘ │
|
||||
│ │
|
||||
│ Material: [Steel ▾] │
|
||||
│ Quality: [256 SPP ▾] │
|
||||
│ Resolution: [1920×1080 ▾] │
|
||||
│ │
|
||||
│ [▶ Render] [⏹ Cancel] [💾 Export PNG] │
|
||||
│ │
|
||||
│ ████████████████░░░░░░ 65% (23s left) │
|
||||
└──────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- **Preview**: progressive refinement (low SPP first, then ramp)
|
||||
- **Cancel**: kill Mitsuba render thread
|
||||
- **Export**: save to PNG/EXR
|
||||
|
||||
---
|
||||
|
||||
## Material Presets
|
||||
|
||||
| Preset | Color | Metallic | Roughness | BSDF |
|
||||
|--------|-------|----------|-----------|------|
|
||||
| Brushed Steel | (0.65, 0.67, 0.72) | 0.9 | 0.35 | roughconductor |
|
||||
| Polished Chrome | (0.8, 0.8, 0.8) | 1.0 | 0.05 | roughconductor |
|
||||
| Brushed Aluminum | (0.75, 0.75, 0.75) | 0.85 | 0.25 | roughconductor |
|
||||
| Copper | (0.95, 0.64, 0.54) | 0.95 | 0.15 | roughconductor |
|
||||
| Gold | (1.0, 0.76, 0.33) | 1.0 | 0.1 | roughconductor |
|
||||
| Blackened Steel | (0.15, 0.15, 0.17) | 0.8 | 0.4 | roughconductor |
|
||||
| Matte Plastic | (0.2, 0.5, 0.8) | 0.0 | 0.6 | plastic |
|
||||
| Glossy Plastic | (0.2, 0.5, 0.8) | 0.0 | 0.1 | plastic |
|
||||
| White Nylon | (0.85, 0.85, 0.83) | 0.0 | 0.45 | plastic |
|
||||
| Black ABS | (0.05, 0.05, 0.05) | 0.0 | 0.35 | plastic |
|
||||
| Red PA12 | (0.75, 0.08, 0.08) | 0.0 | 0.4 | plastic |
|
||||
| Rubber | (0.1, 0.1, 0.1) | 0.0 | 0.9 | diffuse |
|
||||
| Ceramic White | (0.92, 0.91, 0.88) | 0.0 | 0.15 | dielectric |
|
||||
| Glass | (0.95, 0.95, 0.95) | 0.0 | 0.0 | dielectric |
|
||||
| Wood | (0.6, 0.4, 0.2) | 0.0 | 0.7 | diffuse |
|
||||
|
||||
**Note:** Mitsuba pip installs don't include spectral metal data files (iron.spd, copper.spd, etc.), so metal presets use `material="none"` with `specular_reflectance` set to the metal color instead.
|
||||
|
||||
---
|
||||
|
||||
## Risks & Mitigations
|
||||
|
||||
| Risk | Mitigation |
|
||||
|------|-----------|
|
||||
| Mitsuba not installed | Graceful error: "pip install mitsuba" shown in UI |
|
||||
| Slow CPU rendering | Default to low SPP (64) for preview; offer GPU variant if CUDA available |
|
||||
| Large meshes slow to tessellate | Progress indicator; optional mesh decimation |
|
||||
| Mitsuba STL/OCC compatibility | Test tessellation quality; tune `linear_deflection` |
|
||||
|
||||
---
|
||||
|
||||
## Estimated Effort
|
||||
|
||||
- **Phase 1** (abstract backend + OCC→mesh + Mitsuba impl): ~4-6 hours
|
||||
- **Phase 2** (render window UI + material presets): ~3-4 hours
|
||||
- **Phase 3** (polish, export, swap test): ~2-3 hours
|
||||
- **Total**: ~9-13 hours
|
||||
@@ -0,0 +1,906 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
################################################################################
|
||||
## Form generated from reading UI file 'gui.ui'
|
||||
##
|
||||
## Created by: Qt User Interface Compiler version 6.10.2
|
||||
##
|
||||
## WARNING! All changes made in this file will be lost when recompiling UI file!
|
||||
################################################################################
|
||||
|
||||
from PySide6.QtCore import (QCoreApplication, QDate, QDateTime, QLocale,
|
||||
QMetaObject, QObject, QPoint, QRect,
|
||||
QSize, QTime, QUrl, Qt)
|
||||
from PySide6.QtGui import (QAction, QBrush, QColor, QConicalGradient,
|
||||
QCursor, QFont, QFontDatabase, QGradient,
|
||||
QIcon, QImage, QKeySequence, QLinearGradient,
|
||||
QPainter, QPalette, QPixmap, QRadialGradient,
|
||||
QTransform)
|
||||
from PySide6.QtWidgets import (QApplication, QFrame, QGridLayout, QGroupBox,
|
||||
QHBoxLayout, QLabel, QListWidget, QListWidgetItem,
|
||||
QMainWindow, QMenu, QMenuBar, QPushButton,
|
||||
QSizePolicy, QSpinBox, QStatusBar, QTabWidget,
|
||||
QTextEdit, QVBoxLayout, QWidget)
|
||||
|
||||
class Ui_fluencyCAD(object):
|
||||
def setupUi(self, fluencyCAD):
|
||||
if not fluencyCAD.objectName():
|
||||
fluencyCAD.setObjectName(u"fluencyCAD")
|
||||
fluencyCAD.resize(2359, 1285)
|
||||
self.actionNew_Project = QAction(fluencyCAD)
|
||||
self.actionNew_Project.setObjectName(u"actionNew_Project")
|
||||
self.actionOpen_Project = QAction(fluencyCAD)
|
||||
self.actionOpen_Project.setObjectName(u"actionOpen_Project")
|
||||
self.actionSave_Project = QAction(fluencyCAD)
|
||||
self.actionSave_Project.setObjectName(u"actionSave_Project")
|
||||
self.actionSave_Project_As = QAction(fluencyCAD)
|
||||
self.actionSave_Project_As.setObjectName(u"actionSave_Project_As")
|
||||
self.actionImport_File = QAction(fluencyCAD)
|
||||
self.actionImport_File.setObjectName(u"actionImport_File")
|
||||
self.actionExport_Step = QAction(fluencyCAD)
|
||||
self.actionExport_Step.setObjectName(u"actionExport_Step")
|
||||
self.actionExport_Iges = QAction(fluencyCAD)
|
||||
self.actionExport_Iges.setObjectName(u"actionExport_Iges")
|
||||
self.actionExport_Stl = QAction(fluencyCAD)
|
||||
self.actionExport_Stl.setObjectName(u"actionExport_Stl")
|
||||
self.actionExit = QAction(fluencyCAD)
|
||||
self.actionExit.setObjectName(u"actionExit")
|
||||
self.centralwidget = QWidget(fluencyCAD)
|
||||
self.centralwidget.setObjectName(u"centralwidget")
|
||||
self.gridLayout = QGridLayout(self.centralwidget)
|
||||
self.gridLayout.setObjectName(u"gridLayout")
|
||||
self.InputTab = QTabWidget(self.centralwidget)
|
||||
self.InputTab.setObjectName(u"InputTab")
|
||||
sizePolicy = QSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
|
||||
sizePolicy.setHorizontalStretch(0)
|
||||
sizePolicy.setVerticalStretch(0)
|
||||
sizePolicy.setHeightForWidth(self.InputTab.sizePolicy().hasHeightForWidth())
|
||||
self.InputTab.setSizePolicy(sizePolicy)
|
||||
self.sketch_tab = QWidget()
|
||||
self.sketch_tab.setObjectName(u"sketch_tab")
|
||||
self.verticalLayout_4 = QVBoxLayout(self.sketch_tab)
|
||||
self.verticalLayout_4.setObjectName(u"verticalLayout_4")
|
||||
self.InputTab.addTab(self.sketch_tab, "")
|
||||
self.code_tab = QWidget()
|
||||
self.code_tab.setObjectName(u"code_tab")
|
||||
self.verticalLayout = QVBoxLayout(self.code_tab)
|
||||
self.verticalLayout.setObjectName(u"verticalLayout")
|
||||
self.textEdit = QTextEdit(self.code_tab)
|
||||
self.textEdit.setObjectName(u"textEdit")
|
||||
|
||||
self.verticalLayout.addWidget(self.textEdit)
|
||||
|
||||
self.groupBox_7 = QGroupBox(self.code_tab)
|
||||
self.groupBox_7.setObjectName(u"groupBox_7")
|
||||
self.gridLayout_5 = QGridLayout(self.groupBox_7)
|
||||
self.gridLayout_5.setObjectName(u"gridLayout_5")
|
||||
self.pushButton_5 = QPushButton(self.groupBox_7)
|
||||
self.pushButton_5.setObjectName(u"pushButton_5")
|
||||
|
||||
self.gridLayout_5.addWidget(self.pushButton_5, 2, 0, 1, 1)
|
||||
|
||||
self.pushButton_4 = QPushButton(self.groupBox_7)
|
||||
self.pushButton_4.setObjectName(u"pushButton_4")
|
||||
|
||||
self.gridLayout_5.addWidget(self.pushButton_4, 2, 1, 1, 1)
|
||||
|
||||
self.pb_apply_code = QPushButton(self.groupBox_7)
|
||||
self.pb_apply_code.setObjectName(u"pb_apply_code")
|
||||
|
||||
self.gridLayout_5.addWidget(self.pb_apply_code, 1, 0, 1, 1)
|
||||
|
||||
self.pushButton = QPushButton(self.groupBox_7)
|
||||
self.pushButton.setObjectName(u"pushButton")
|
||||
|
||||
self.gridLayout_5.addWidget(self.pushButton, 1, 1, 1, 1)
|
||||
|
||||
|
||||
self.verticalLayout.addWidget(self.groupBox_7)
|
||||
|
||||
self.InputTab.addTab(self.code_tab, "")
|
||||
|
||||
self.gridLayout.addWidget(self.InputTab, 0, 1, 12, 1)
|
||||
|
||||
self.groupBox_9 = QGroupBox(self.centralwidget)
|
||||
self.groupBox_9.setObjectName(u"groupBox_9")
|
||||
self.groupBox_9.setMaximumSize(QSize(200, 16777215))
|
||||
self.gridLayout_7 = QGridLayout(self.groupBox_9)
|
||||
self.gridLayout_7.setObjectName(u"gridLayout_7")
|
||||
self.pb_origin_wp = QPushButton(self.groupBox_9)
|
||||
self.pb_origin_wp.setObjectName(u"pb_origin_wp")
|
||||
|
||||
self.gridLayout_7.addWidget(self.pb_origin_wp, 0, 0, 1, 1)
|
||||
|
||||
self.pb_origin_face = QPushButton(self.groupBox_9)
|
||||
self.pb_origin_face.setObjectName(u"pb_origin_face")
|
||||
self.pb_origin_face.setCheckable(True)
|
||||
|
||||
self.gridLayout_7.addWidget(self.pb_origin_face, 0, 1, 1, 1)
|
||||
|
||||
self.pb_flip_face = QPushButton(self.groupBox_9)
|
||||
self.pb_flip_face.setObjectName(u"pb_flip_face")
|
||||
|
||||
self.gridLayout_7.addWidget(self.pb_flip_face, 1, 0, 1, 1)
|
||||
|
||||
self.pb_underlay = QPushButton(self.groupBox_9)
|
||||
self.pb_underlay.setObjectName(u"pb_underlay")
|
||||
self.pb_underlay.setEnabled(False)
|
||||
self.pb_underlay.setCheckable(True)
|
||||
self.pb_underlay.setChecked(True)
|
||||
|
||||
self.gridLayout_7.addWidget(self.pb_underlay, 3, 0, 1, 1)
|
||||
|
||||
self.pb_clr_face = QPushButton(self.groupBox_9)
|
||||
self.pb_clr_face.setObjectName(u"pb_clr_face")
|
||||
self.pb_clr_face.setEnabled(False)
|
||||
|
||||
self.gridLayout_7.addWidget(self.pb_clr_face, 3, 1, 1, 1)
|
||||
|
||||
self.pb_to_sketch = QPushButton(self.groupBox_9)
|
||||
self.pb_to_sketch.setObjectName(u"pb_to_sketch")
|
||||
self.pb_to_sketch.setEnabled(False)
|
||||
|
||||
self.gridLayout_7.addWidget(self.pb_to_sketch, 4, 0, 1, 2)
|
||||
|
||||
self.pb_wp_new = QPushButton(self.groupBox_9)
|
||||
self.pb_wp_new.setObjectName(u"pb_wp_new")
|
||||
|
||||
self.gridLayout_7.addWidget(self.pb_wp_new, 1, 1, 1, 1)
|
||||
|
||||
|
||||
self.gridLayout.addWidget(self.groupBox_9, 0, 0, 1, 1)
|
||||
|
||||
self.assembly_box = QGroupBox(self.centralwidget)
|
||||
self.assembly_box.setObjectName(u"assembly_box")
|
||||
self.assembly_box.setMinimumSize(QSize(0, 50))
|
||||
|
||||
self.gridLayout.addWidget(self.assembly_box, 13, 1, 1, 2)
|
||||
|
||||
self.joint_tools = QGroupBox(self.centralwidget)
|
||||
self.joint_tools.setObjectName(u"joint_tools")
|
||||
self.joint_tools.setMinimumSize(QSize(0, 50))
|
||||
self.gridLayout_10 = QGridLayout(self.joint_tools)
|
||||
self.gridLayout_10.setObjectName(u"gridLayout_10")
|
||||
self.pb_remove_connector = QPushButton(self.joint_tools)
|
||||
self.pb_remove_connector.setObjectName(u"pb_remove_connector")
|
||||
self.pb_remove_connector.setMinimumSize(QSize(50, 50))
|
||||
self.pb_remove_connector.setMaximumSize(QSize(50, 50))
|
||||
|
||||
self.gridLayout_10.addWidget(self.pb_remove_connector, 0, 2, 1, 1)
|
||||
|
||||
self.pb_add_connector = QPushButton(self.joint_tools)
|
||||
self.pb_add_connector.setObjectName(u"pb_add_connector")
|
||||
self.pb_add_connector.setMinimumSize(QSize(50, 50))
|
||||
self.pb_add_connector.setMaximumSize(QSize(50, 50))
|
||||
|
||||
self.gridLayout_10.addWidget(self.pb_add_connector, 0, 1, 1, 1)
|
||||
|
||||
self.pb_add_connector_2 = QPushButton(self.joint_tools)
|
||||
self.pb_add_connector_2.setObjectName(u"pb_add_connector_2")
|
||||
self.pb_add_connector_2.setMinimumSize(QSize(50, 50))
|
||||
self.pb_add_connector_2.setMaximumSize(QSize(50, 50))
|
||||
|
||||
self.gridLayout_10.addWidget(self.pb_add_connector_2, 1, 1, 1, 1)
|
||||
|
||||
self.pb_add_connector_3 = QPushButton(self.joint_tools)
|
||||
self.pb_add_connector_3.setObjectName(u"pb_add_connector_3")
|
||||
self.pb_add_connector_3.setMinimumSize(QSize(50, 50))
|
||||
self.pb_add_connector_3.setMaximumSize(QSize(50, 50))
|
||||
|
||||
self.gridLayout_10.addWidget(self.pb_add_connector_3, 1, 2, 1, 1)
|
||||
|
||||
|
||||
self.gridLayout.addWidget(self.joint_tools, 12, 3, 2, 1)
|
||||
|
||||
self.gl_box = QGroupBox(self.centralwidget)
|
||||
self.gl_box.setObjectName(u"gl_box")
|
||||
sizePolicy1 = QSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
|
||||
sizePolicy1.setHorizontalStretch(0)
|
||||
sizePolicy1.setVerticalStretch(4)
|
||||
sizePolicy1.setHeightForWidth(self.gl_box.sizePolicy().hasHeightForWidth())
|
||||
self.gl_box.setSizePolicy(sizePolicy1)
|
||||
font = QFont()
|
||||
font.setPointSize(12)
|
||||
self.gl_box.setFont(font)
|
||||
self.horizontalLayout_4 = QHBoxLayout(self.gl_box)
|
||||
#ifndef Q_OS_MAC
|
||||
self.horizontalLayout_4.setSpacing(-1)
|
||||
#endif
|
||||
self.horizontalLayout_4.setObjectName(u"horizontalLayout_4")
|
||||
self.horizontalLayout_4.setContentsMargins(12, -1, -1, -1)
|
||||
|
||||
self.gridLayout.addWidget(self.gl_box, 0, 2, 12, 1)
|
||||
|
||||
self.groupBox_11 = QGroupBox(self.centralwidget)
|
||||
self.groupBox_11.setObjectName(u"groupBox_11")
|
||||
sizePolicy2 = QSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Expanding)
|
||||
sizePolicy2.setHorizontalStretch(0)
|
||||
sizePolicy2.setVerticalStretch(0)
|
||||
sizePolicy2.setHeightForWidth(self.groupBox_11.sizePolicy().hasHeightForWidth())
|
||||
self.groupBox_11.setSizePolicy(sizePolicy2)
|
||||
self.groupBox_11.setMaximumSize(QSize(200, 16777215))
|
||||
self.verticalLayout_7 = QVBoxLayout(self.groupBox_11)
|
||||
self.verticalLayout_7.setObjectName(u"verticalLayout_7")
|
||||
self.verticalLayout_7.setContentsMargins(5, 5, 5, 5)
|
||||
self.sketch_list = QListWidget(self.groupBox_11)
|
||||
self.sketch_list.setObjectName(u"sketch_list")
|
||||
sizePolicy3 = QSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
|
||||
sizePolicy3.setHorizontalStretch(0)
|
||||
sizePolicy3.setVerticalStretch(0)
|
||||
sizePolicy3.setHeightForWidth(self.sketch_list.sizePolicy().hasHeightForWidth())
|
||||
self.sketch_list.setSizePolicy(sizePolicy3)
|
||||
self.sketch_list.setSelectionRectVisible(True)
|
||||
|
||||
self.verticalLayout_7.addWidget(self.sketch_list)
|
||||
|
||||
self.groupBox_6 = QGroupBox(self.groupBox_11)
|
||||
self.groupBox_6.setObjectName(u"groupBox_6")
|
||||
sizePolicy4 = QSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Preferred)
|
||||
sizePolicy4.setHorizontalStretch(0)
|
||||
sizePolicy4.setVerticalStretch(0)
|
||||
sizePolicy4.setHeightForWidth(self.groupBox_6.sizePolicy().hasHeightForWidth())
|
||||
self.groupBox_6.setSizePolicy(sizePolicy4)
|
||||
self.gridLayout_6 = QGridLayout(self.groupBox_6)
|
||||
self.gridLayout_6.setObjectName(u"gridLayout_6")
|
||||
self.gridLayout_6.setContentsMargins(2, 2, 2, 2)
|
||||
self.pb_edt_sktch = QPushButton(self.groupBox_6)
|
||||
self.pb_edt_sktch.setObjectName(u"pb_edt_sktch")
|
||||
|
||||
self.gridLayout_6.addWidget(self.pb_edt_sktch, 1, 1, 1, 1)
|
||||
|
||||
self.pb_nw_sktch = QPushButton(self.groupBox_6)
|
||||
self.pb_nw_sktch.setObjectName(u"pb_nw_sktch")
|
||||
|
||||
self.gridLayout_6.addWidget(self.pb_nw_sktch, 1, 0, 1, 1)
|
||||
|
||||
self.pb_del_sketch = QPushButton(self.groupBox_6)
|
||||
self.pb_del_sketch.setObjectName(u"pb_del_sketch")
|
||||
|
||||
self.gridLayout_6.addWidget(self.pb_del_sketch, 1, 2, 1, 1)
|
||||
|
||||
|
||||
self.verticalLayout_7.addWidget(self.groupBox_6)
|
||||
|
||||
|
||||
self.gridLayout.addWidget(self.groupBox_11, 6, 0, 6, 1)
|
||||
|
||||
self.assembly_tools = QGroupBox(self.centralwidget)
|
||||
self.assembly_tools.setObjectName(u"assembly_tools")
|
||||
self.assembly_tools.setMinimumSize(QSize(0, 50))
|
||||
self.gridLayout_12 = QGridLayout(self.assembly_tools)
|
||||
self.gridLayout_12.setObjectName(u"gridLayout_12")
|
||||
self.pb_compo_to_assembly = QPushButton(self.assembly_tools)
|
||||
self.pb_compo_to_assembly.setObjectName(u"pb_compo_to_assembly")
|
||||
self.pb_compo_to_assembly.setMinimumSize(QSize(50, 50))
|
||||
self.pb_compo_to_assembly.setMaximumSize(QSize(50, 50))
|
||||
|
||||
self.gridLayout_12.addWidget(self.pb_compo_to_assembly, 0, 0, 1, 1)
|
||||
|
||||
self.pb_remove_compo_from_assembly = QPushButton(self.assembly_tools)
|
||||
self.pb_remove_compo_from_assembly.setObjectName(u"pb_remove_compo_from_assembly")
|
||||
self.pb_remove_compo_from_assembly.setEnabled(True)
|
||||
sizePolicy4.setHeightForWidth(self.pb_remove_compo_from_assembly.sizePolicy().hasHeightForWidth())
|
||||
self.pb_remove_compo_from_assembly.setSizePolicy(sizePolicy4)
|
||||
self.pb_remove_compo_from_assembly.setMinimumSize(QSize(50, 50))
|
||||
self.pb_remove_compo_from_assembly.setMaximumSize(QSize(50, 50))
|
||||
self.pb_remove_compo_from_assembly.setLayoutDirection(Qt.LeftToRight)
|
||||
|
||||
self.gridLayout_12.addWidget(self.pb_remove_compo_from_assembly, 0, 1, 1, 1)
|
||||
|
||||
|
||||
self.gridLayout.addWidget(self.assembly_tools, 13, 0, 1, 1)
|
||||
|
||||
self.compo_tool_box = QGroupBox(self.centralwidget)
|
||||
self.compo_tool_box.setObjectName(u"compo_tool_box")
|
||||
self.compo_tool_box.setMinimumSize(QSize(0, 50))
|
||||
self.gridLayout_9 = QGridLayout(self.compo_tool_box)
|
||||
self.gridLayout_9.setObjectName(u"gridLayout_9")
|
||||
self.pb_new_compo = QPushButton(self.compo_tool_box)
|
||||
self.pb_new_compo.setObjectName(u"pb_new_compo")
|
||||
self.pb_new_compo.setMinimumSize(QSize(50, 50))
|
||||
self.pb_new_compo.setMaximumSize(QSize(50, 50))
|
||||
|
||||
self.gridLayout_9.addWidget(self.pb_new_compo, 0, 0, 1, 1)
|
||||
|
||||
self.pb_del_compo = QPushButton(self.compo_tool_box)
|
||||
self.pb_del_compo.setObjectName(u"pb_del_compo")
|
||||
self.pb_del_compo.setEnabled(True)
|
||||
sizePolicy4.setHeightForWidth(self.pb_del_compo.sizePolicy().hasHeightForWidth())
|
||||
self.pb_del_compo.setSizePolicy(sizePolicy4)
|
||||
self.pb_del_compo.setMinimumSize(QSize(50, 50))
|
||||
self.pb_del_compo.setMaximumSize(QSize(50, 50))
|
||||
self.pb_del_compo.setLayoutDirection(Qt.LeftToRight)
|
||||
|
||||
self.gridLayout_9.addWidget(self.pb_del_compo, 0, 1, 1, 1)
|
||||
|
||||
|
||||
self.gridLayout.addWidget(self.compo_tool_box, 12, 0, 1, 1)
|
||||
|
||||
self.compo_box = QGroupBox(self.centralwidget)
|
||||
self.compo_box.setObjectName(u"compo_box")
|
||||
self.compo_box.setMinimumSize(QSize(0, 50))
|
||||
|
||||
self.gridLayout.addWidget(self.compo_box, 12, 1, 1, 2)
|
||||
|
||||
self.groupBox_12 = QGroupBox(self.centralwidget)
|
||||
self.groupBox_12.setObjectName(u"groupBox_12")
|
||||
sizePolicy2.setHeightForWidth(self.groupBox_12.sizePolicy().hasHeightForWidth())
|
||||
self.groupBox_12.setSizePolicy(sizePolicy2)
|
||||
self.groupBox_12.setMaximumSize(QSize(200, 16777215))
|
||||
self.verticalLayout_8 = QVBoxLayout(self.groupBox_12)
|
||||
self.verticalLayout_8.setObjectName(u"verticalLayout_8")
|
||||
self.verticalLayout_8.setContentsMargins(5, 5, 5, 5)
|
||||
self.connection_list = QListWidget(self.groupBox_12)
|
||||
self.connection_list.setObjectName(u"connection_list")
|
||||
self.connection_list.setSelectionRectVisible(True)
|
||||
|
||||
self.verticalLayout_8.addWidget(self.connection_list)
|
||||
|
||||
self.groupBox_13 = QGroupBox(self.groupBox_12)
|
||||
self.groupBox_13.setObjectName(u"groupBox_13")
|
||||
sizePolicy4.setHeightForWidth(self.groupBox_13.sizePolicy().hasHeightForWidth())
|
||||
self.groupBox_13.setSizePolicy(sizePolicy4)
|
||||
self.groupBox_13.setMaximumSize(QSize(200, 16777215))
|
||||
self.gridLayout_13 = QGridLayout(self.groupBox_13)
|
||||
self.gridLayout_13.setObjectName(u"gridLayout_13")
|
||||
self.gridLayout_13.setContentsMargins(2, 2, 2, 2)
|
||||
self.pb_del_connection = QPushButton(self.groupBox_13)
|
||||
self.pb_del_connection.setObjectName(u"pb_del_connection")
|
||||
|
||||
self.gridLayout_13.addWidget(self.pb_del_connection, 0, 2, 1, 1)
|
||||
|
||||
self.pb_update_connection = QPushButton(self.groupBox_13)
|
||||
self.pb_update_connection.setObjectName(u"pb_update_connection")
|
||||
|
||||
self.gridLayout_13.addWidget(self.pb_update_connection, 0, 0, 1, 1)
|
||||
|
||||
self.pb_edt_sktch_4 = QPushButton(self.groupBox_13)
|
||||
self.pb_edt_sktch_4.setObjectName(u"pb_edt_sktch_4")
|
||||
|
||||
self.gridLayout_13.addWidget(self.pb_edt_sktch_4, 0, 1, 1, 1)
|
||||
|
||||
|
||||
self.verticalLayout_8.addWidget(self.groupBox_13)
|
||||
|
||||
|
||||
self.gridLayout.addWidget(self.groupBox_12, 6, 3, 6, 1)
|
||||
|
||||
self.groupBox_10 = QGroupBox(self.centralwidget)
|
||||
self.groupBox_10.setObjectName(u"groupBox_10")
|
||||
sizePolicy2.setHeightForWidth(self.groupBox_10.sizePolicy().hasHeightForWidth())
|
||||
self.groupBox_10.setSizePolicy(sizePolicy2)
|
||||
self.groupBox_10.setMaximumSize(QSize(200, 16777215))
|
||||
self.verticalLayout_6 = QVBoxLayout(self.groupBox_10)
|
||||
self.verticalLayout_6.setObjectName(u"verticalLayout_6")
|
||||
self.verticalLayout_6.setContentsMargins(5, 5, 5, 5)
|
||||
self.body_list = QListWidget(self.groupBox_10)
|
||||
self.body_list.setObjectName(u"body_list")
|
||||
self.body_list.setSelectionRectVisible(True)
|
||||
|
||||
self.verticalLayout_6.addWidget(self.body_list)
|
||||
|
||||
self.groupBox_8 = QGroupBox(self.groupBox_10)
|
||||
self.groupBox_8.setObjectName(u"groupBox_8")
|
||||
sizePolicy4.setHeightForWidth(self.groupBox_8.sizePolicy().hasHeightForWidth())
|
||||
self.groupBox_8.setSizePolicy(sizePolicy4)
|
||||
self.groupBox_8.setMaximumSize(QSize(200, 16777215))
|
||||
self.gridLayout_8 = QGridLayout(self.groupBox_8)
|
||||
self.gridLayout_8.setObjectName(u"gridLayout_8")
|
||||
self.gridLayout_8.setContentsMargins(2, 2, 2, 2)
|
||||
self.pb_del_body = QPushButton(self.groupBox_8)
|
||||
self.pb_del_body.setObjectName(u"pb_del_body")
|
||||
|
||||
self.gridLayout_8.addWidget(self.pb_del_body, 0, 2, 1, 1)
|
||||
|
||||
self.pb_update_body = QPushButton(self.groupBox_8)
|
||||
self.pb_update_body.setObjectName(u"pb_update_body")
|
||||
|
||||
self.gridLayout_8.addWidget(self.pb_update_body, 0, 0, 1, 1)
|
||||
|
||||
self.pb_edt_sktch_3 = QPushButton(self.groupBox_8)
|
||||
self.pb_edt_sktch_3.setObjectName(u"pb_edt_sktch_3")
|
||||
|
||||
self.gridLayout_8.addWidget(self.pb_edt_sktch_3, 0, 1, 1, 1)
|
||||
|
||||
|
||||
self.verticalLayout_6.addWidget(self.groupBox_8)
|
||||
|
||||
|
||||
self.gridLayout.addWidget(self.groupBox_10, 3, 3, 3, 1)
|
||||
|
||||
self.groupBox_2 = QGroupBox(self.centralwidget)
|
||||
self.groupBox_2.setObjectName(u"groupBox_2")
|
||||
sizePolicy4.setHeightForWidth(self.groupBox_2.sizePolicy().hasHeightForWidth())
|
||||
self.groupBox_2.setSizePolicy(sizePolicy4)
|
||||
self.groupBox_2.setMaximumSize(QSize(200, 16777215))
|
||||
self.gridLayout_2 = QGridLayout(self.groupBox_2)
|
||||
self.gridLayout_2.setObjectName(u"gridLayout_2")
|
||||
self.gridLayout_2.setContentsMargins(10, -1, -1, -1)
|
||||
self.pb_arc_tool = QPushButton(self.groupBox_2)
|
||||
self.pb_arc_tool.setObjectName(u"pb_arc_tool")
|
||||
self.pb_arc_tool.setCheckable(True)
|
||||
|
||||
self.gridLayout_2.addWidget(self.pb_arc_tool, 2, 0, 1, 1)
|
||||
|
||||
self.pb_rectool = QPushButton(self.groupBox_2)
|
||||
self.pb_rectool.setObjectName(u"pb_rectool")
|
||||
self.pb_rectool.setCheckable(True)
|
||||
self.pb_rectool.setAutoExclusive(False)
|
||||
|
||||
self.gridLayout_2.addWidget(self.pb_rectool, 0, 1, 1, 1)
|
||||
|
||||
self.pb_circtool = QPushButton(self.groupBox_2)
|
||||
self.pb_circtool.setObjectName(u"pb_circtool")
|
||||
self.pb_circtool.setCheckable(True)
|
||||
self.pb_circtool.setAutoExclusive(False)
|
||||
|
||||
self.gridLayout_2.addWidget(self.pb_circtool, 1, 0, 1, 1, Qt.AlignTop)
|
||||
|
||||
self.pb_enable_construct = QPushButton(self.groupBox_2)
|
||||
self.pb_enable_construct.setObjectName(u"pb_enable_construct")
|
||||
self.pb_enable_construct.setCheckable(True)
|
||||
|
||||
self.gridLayout_2.addWidget(self.pb_enable_construct, 4, 0, 1, 1)
|
||||
|
||||
self.pb_enable_snap = QPushButton(self.groupBox_2)
|
||||
self.pb_enable_snap.setObjectName(u"pb_enable_snap")
|
||||
self.pb_enable_snap.setIconSize(QSize(13, 16))
|
||||
self.pb_enable_snap.setCheckable(True)
|
||||
self.pb_enable_snap.setChecked(True)
|
||||
|
||||
self.gridLayout_2.addWidget(self.pb_enable_snap, 4, 1, 1, 1)
|
||||
|
||||
self.pb_linetool = QPushButton(self.groupBox_2)
|
||||
self.pb_linetool.setObjectName(u"pb_linetool")
|
||||
self.pb_linetool.setCheckable(True)
|
||||
self.pb_linetool.setAutoExclusive(False)
|
||||
|
||||
self.gridLayout_2.addWidget(self.pb_linetool, 0, 0, 1, 1)
|
||||
|
||||
self.pb_slotool = QPushButton(self.groupBox_2)
|
||||
self.pb_slotool.setObjectName(u"pb_slotool")
|
||||
self.pb_slotool.setCheckable(True)
|
||||
self.pb_slotool.setAutoExclusive(False)
|
||||
|
||||
self.gridLayout_2.addWidget(self.pb_slotool, 1, 1, 1, 1, Qt.AlignTop)
|
||||
|
||||
self.line = QFrame(self.groupBox_2)
|
||||
self.line.setObjectName(u"line")
|
||||
self.line.setFrameShape(QFrame.Shape.HLine)
|
||||
self.line.setFrameShadow(QFrame.Shadow.Sunken)
|
||||
|
||||
self.gridLayout_2.addWidget(self.line, 3, 0, 1, 2)
|
||||
|
||||
self.pb_offset_tool = QPushButton(self.groupBox_2)
|
||||
self.pb_offset_tool.setObjectName(u"pb_offset_tool")
|
||||
|
||||
self.gridLayout_2.addWidget(self.pb_offset_tool, 2, 1, 1, 1)
|
||||
|
||||
|
||||
self.gridLayout.addWidget(self.groupBox_2, 1, 0, 1, 1)
|
||||
|
||||
self.groupBox_3 = QGroupBox(self.centralwidget)
|
||||
self.groupBox_3.setObjectName(u"groupBox_3")
|
||||
sizePolicy4.setHeightForWidth(self.groupBox_3.sizePolicy().hasHeightForWidth())
|
||||
self.groupBox_3.setSizePolicy(sizePolicy4)
|
||||
self.groupBox_3.setMaximumSize(QSize(200, 16777213))
|
||||
self.gridLayout_4 = QGridLayout(self.groupBox_3)
|
||||
self.gridLayout_4.setObjectName(u"gridLayout_4")
|
||||
self.pb_con_ptpt = QPushButton(self.groupBox_3)
|
||||
self.pb_con_ptpt.setObjectName(u"pb_con_ptpt")
|
||||
self.pb_con_ptpt.setCheckable(True)
|
||||
self.pb_con_ptpt.setAutoExclusive(False)
|
||||
|
||||
self.gridLayout_4.addWidget(self.pb_con_ptpt, 1, 0, 1, 1)
|
||||
|
||||
self.pb_con_vert = QPushButton(self.groupBox_3)
|
||||
self.pb_con_vert.setObjectName(u"pb_con_vert")
|
||||
self.pb_con_vert.setCheckable(True)
|
||||
self.pb_con_vert.setAutoExclusive(False)
|
||||
|
||||
self.gridLayout_4.addWidget(self.pb_con_vert, 3, 1, 1, 1)
|
||||
|
||||
self.pb_con_sym = QPushButton(self.groupBox_3)
|
||||
self.pb_con_sym.setObjectName(u"pb_con_sym")
|
||||
self.pb_con_sym.setCheckable(True)
|
||||
self.pb_con_sym.setAutoExclusive(False)
|
||||
|
||||
self.gridLayout_4.addWidget(self.pb_con_sym, 4, 1, 1, 1)
|
||||
|
||||
self.pb_con_mid = QPushButton(self.groupBox_3)
|
||||
self.pb_con_mid.setObjectName(u"pb_con_mid")
|
||||
self.pb_con_mid.setCheckable(True)
|
||||
self.pb_con_mid.setAutoExclusive(False)
|
||||
|
||||
self.gridLayout_4.addWidget(self.pb_con_mid, 2, 0, 1, 1)
|
||||
|
||||
self.pb_con_line = QPushButton(self.groupBox_3)
|
||||
self.pb_con_line.setObjectName(u"pb_con_line")
|
||||
self.pb_con_line.setCheckable(True)
|
||||
self.pb_con_line.setAutoExclusive(False)
|
||||
|
||||
self.gridLayout_4.addWidget(self.pb_con_line, 1, 1, 1, 1)
|
||||
|
||||
self.pb_con_horiz = QPushButton(self.groupBox_3)
|
||||
self.pb_con_horiz.setObjectName(u"pb_con_horiz")
|
||||
self.pb_con_horiz.setCheckable(True)
|
||||
self.pb_con_horiz.setAutoExclusive(False)
|
||||
|
||||
self.gridLayout_4.addWidget(self.pb_con_horiz, 3, 0, 1, 1)
|
||||
|
||||
self.pb_con_dist = QPushButton(self.groupBox_3)
|
||||
self.pb_con_dist.setObjectName(u"pb_con_dist")
|
||||
self.pb_con_dist.setCheckable(True)
|
||||
self.pb_con_dist.setAutoExclusive(False)
|
||||
self.pb_con_dist.setAutoRepeatDelay(297)
|
||||
|
||||
self.gridLayout_4.addWidget(self.pb_con_dist, 4, 0, 1, 1)
|
||||
|
||||
self.pb_con_perp = QPushButton(self.groupBox_3)
|
||||
self.pb_con_perp.setObjectName(u"pb_con_perp")
|
||||
self.pb_con_perp.setCheckable(True)
|
||||
self.pb_con_perp.setAutoExclusive(False)
|
||||
|
||||
self.gridLayout_4.addWidget(self.pb_con_perp, 2, 1, 1, 1)
|
||||
|
||||
self.pb_con_diameter = QPushButton(self.groupBox_3)
|
||||
self.pb_con_diameter.setObjectName(u"pb_con_diameter")
|
||||
|
||||
self.gridLayout_4.addWidget(self.pb_con_diameter, 5, 0, 1, 1)
|
||||
|
||||
|
||||
self.gridLayout.addWidget(self.groupBox_3, 2, 0, 1, 1)
|
||||
|
||||
self.groupBox_5 = QGroupBox(self.centralwidget)
|
||||
self.groupBox_5.setObjectName(u"groupBox_5")
|
||||
sizePolicy4.setHeightForWidth(self.groupBox_5.sizePolicy().hasHeightForWidth())
|
||||
self.groupBox_5.setSizePolicy(sizePolicy4)
|
||||
self.gridLayout_11 = QGridLayout(self.groupBox_5)
|
||||
self.gridLayout_11.setObjectName(u"gridLayout_11")
|
||||
self.gridLayout_11.setContentsMargins(12, 12, 12, 12)
|
||||
self.label = QLabel(self.groupBox_5)
|
||||
self.label.setObjectName(u"label")
|
||||
|
||||
self.gridLayout_11.addWidget(self.label, 5, 0, 1, 1)
|
||||
|
||||
self.pb_snap_vert = QPushButton(self.groupBox_5)
|
||||
self.pb_snap_vert.setObjectName(u"pb_snap_vert")
|
||||
self.pb_snap_vert.setCheckable(True)
|
||||
self.pb_snap_vert.setAutoExclusive(False)
|
||||
|
||||
self.gridLayout_11.addWidget(self.pb_snap_vert, 2, 1, 1, 1)
|
||||
|
||||
self.line_2 = QFrame(self.groupBox_5)
|
||||
self.line_2.setObjectName(u"line_2")
|
||||
self.line_2.setFrameShape(QFrame.Shape.HLine)
|
||||
self.line_2.setFrameShadow(QFrame.Shadow.Sunken)
|
||||
|
||||
self.gridLayout_11.addWidget(self.line_2, 4, 0, 1, 2)
|
||||
|
||||
self.label_2 = QLabel(self.groupBox_5)
|
||||
self.label_2.setObjectName(u"label_2")
|
||||
|
||||
self.gridLayout_11.addWidget(self.label_2, 5, 1, 1, 1)
|
||||
|
||||
self.spinbox_snap_distance = QSpinBox(self.groupBox_5)
|
||||
self.spinbox_snap_distance.setObjectName(u"spinbox_snap_distance")
|
||||
self.spinbox_snap_distance.setMaximum(30)
|
||||
self.spinbox_snap_distance.setValue(10)
|
||||
|
||||
self.gridLayout_11.addWidget(self.spinbox_snap_distance, 6, 0, 1, 1)
|
||||
|
||||
self.pushButton_7 = QPushButton(self.groupBox_5)
|
||||
self.pushButton_7.setObjectName(u"pushButton_7")
|
||||
self.pushButton_7.setCheckable(True)
|
||||
self.pushButton_7.setAutoExclusive(False)
|
||||
|
||||
self.gridLayout_11.addWidget(self.pushButton_7, 3, 0, 1, 1)
|
||||
|
||||
self.pb_snap_horiz = QPushButton(self.groupBox_5)
|
||||
self.pb_snap_horiz.setObjectName(u"pb_snap_horiz")
|
||||
self.pb_snap_horiz.setCheckable(True)
|
||||
self.pb_snap_horiz.setAutoExclusive(False)
|
||||
|
||||
self.gridLayout_11.addWidget(self.pb_snap_horiz, 2, 0, 1, 1)
|
||||
|
||||
self.spinbox_angle_steps = QSpinBox(self.groupBox_5)
|
||||
self.spinbox_angle_steps.setObjectName(u"spinbox_angle_steps")
|
||||
self.spinbox_angle_steps.setMaximum(180)
|
||||
self.spinbox_angle_steps.setValue(15)
|
||||
|
||||
self.gridLayout_11.addWidget(self.spinbox_angle_steps, 6, 1, 1, 1)
|
||||
|
||||
self.pushButton_8 = QPushButton(self.groupBox_5)
|
||||
self.pushButton_8.setObjectName(u"pushButton_8")
|
||||
self.pushButton_8.setCheckable(True)
|
||||
self.pushButton_8.setAutoExclusive(False)
|
||||
|
||||
self.gridLayout_11.addWidget(self.pushButton_8, 0, 0, 1, 1)
|
||||
|
||||
self.pb_snap_midp = QPushButton(self.groupBox_5)
|
||||
self.pb_snap_midp.setObjectName(u"pb_snap_midp")
|
||||
self.pb_snap_midp.setCheckable(True)
|
||||
self.pb_snap_midp.setAutoExclusive(False)
|
||||
|
||||
self.gridLayout_11.addWidget(self.pb_snap_midp, 0, 1, 1, 1)
|
||||
|
||||
self.pb_snap_angle = QPushButton(self.groupBox_5)
|
||||
self.pb_snap_angle.setObjectName(u"pb_snap_angle")
|
||||
self.pb_snap_angle.setCheckable(True)
|
||||
self.pb_snap_angle.setAutoExclusive(False)
|
||||
|
||||
self.gridLayout_11.addWidget(self.pb_snap_angle, 3, 1, 1, 1)
|
||||
|
||||
|
||||
self.gridLayout.addWidget(self.groupBox_5, 3, 0, 1, 1)
|
||||
|
||||
self.groupBox = QGroupBox(self.centralwidget)
|
||||
self.groupBox.setObjectName(u"groupBox")
|
||||
self.gridLayout_3 = QGridLayout(self.groupBox)
|
||||
self.gridLayout_3.setObjectName(u"gridLayout_3")
|
||||
self.pb_revop = QPushButton(self.groupBox)
|
||||
self.pb_revop.setObjectName(u"pb_revop")
|
||||
|
||||
self.gridLayout_3.addWidget(self.pb_revop, 2, 1, 1, 1)
|
||||
|
||||
self.pb_extrdop = QPushButton(self.groupBox)
|
||||
self.pb_extrdop.setObjectName(u"pb_extrdop")
|
||||
|
||||
self.gridLayout_3.addWidget(self.pb_extrdop, 0, 0, 1, 1)
|
||||
|
||||
self.pb_arrayop = QPushButton(self.groupBox)
|
||||
self.pb_arrayop.setObjectName(u"pb_arrayop")
|
||||
|
||||
self.gridLayout_3.addWidget(self.pb_arrayop, 2, 0, 1, 1)
|
||||
|
||||
self.pb_cutop = QPushButton(self.groupBox)
|
||||
self.pb_cutop.setObjectName(u"pb_cutop")
|
||||
|
||||
self.gridLayout_3.addWidget(self.pb_cutop, 0, 1, 1, 1)
|
||||
|
||||
self.pb_combop = QPushButton(self.groupBox)
|
||||
self.pb_combop.setObjectName(u"pb_combop")
|
||||
|
||||
self.gridLayout_3.addWidget(self.pb_combop, 1, 0, 1, 1)
|
||||
|
||||
self.pb_moveop = QPushButton(self.groupBox)
|
||||
self.pb_moveop.setObjectName(u"pb_moveop")
|
||||
|
||||
self.gridLayout_3.addWidget(self.pb_moveop, 1, 1, 1, 1)
|
||||
|
||||
|
||||
self.gridLayout.addWidget(self.groupBox, 0, 3, 1, 1)
|
||||
|
||||
self.groupBox_4 = QGroupBox(self.centralwidget)
|
||||
self.groupBox_4.setObjectName(u"groupBox_4")
|
||||
self.verticalLayout_2 = QVBoxLayout(self.groupBox_4)
|
||||
self.verticalLayout_2.setObjectName(u"verticalLayout_2")
|
||||
self.pushButton_2 = QPushButton(self.groupBox_4)
|
||||
self.pushButton_2.setObjectName(u"pushButton_2")
|
||||
|
||||
self.verticalLayout_2.addWidget(self.pushButton_2)
|
||||
|
||||
self.pb_export_step = QPushButton(self.groupBox_4)
|
||||
self.pb_export_step.setObjectName(u"pb_export_step")
|
||||
|
||||
self.verticalLayout_2.addWidget(self.pb_export_step)
|
||||
|
||||
self.pb_export_iges = QPushButton(self.groupBox_4)
|
||||
self.pb_export_iges.setObjectName(u"pb_export_iges")
|
||||
|
||||
self.verticalLayout_2.addWidget(self.pb_export_iges)
|
||||
|
||||
|
||||
self.gridLayout.addWidget(self.groupBox_4, 2, 3, 1, 1)
|
||||
|
||||
fluencyCAD.setCentralWidget(self.centralwidget)
|
||||
self.menubar = QMenuBar(fluencyCAD)
|
||||
self.menubar.setObjectName(u"menubar")
|
||||
self.menubar.setGeometry(QRect(0, 0, 2359, 24))
|
||||
self.menuFile = QMenu(self.menubar)
|
||||
self.menuFile.setObjectName(u"menuFile")
|
||||
self.menuSettings = QMenu(self.menubar)
|
||||
self.menuSettings.setObjectName(u"menuSettings")
|
||||
fluencyCAD.setMenuBar(self.menubar)
|
||||
self.statusbar = QStatusBar(fluencyCAD)
|
||||
self.statusbar.setObjectName(u"statusbar")
|
||||
fluencyCAD.setStatusBar(self.statusbar)
|
||||
|
||||
self.menubar.addAction(self.menuFile.menuAction())
|
||||
self.menubar.addAction(self.menuSettings.menuAction())
|
||||
self.menuFile.addAction(self.actionNew_Project)
|
||||
self.menuFile.addAction(self.actionOpen_Project)
|
||||
self.menuFile.addAction(self.actionSave_Project)
|
||||
self.menuFile.addAction(self.actionSave_Project_As)
|
||||
self.menuFile.addSeparator()
|
||||
self.menuFile.addAction(self.actionImport_File)
|
||||
self.menuFile.addSeparator()
|
||||
self.menuFile.addAction(self.actionExport_Step)
|
||||
self.menuFile.addAction(self.actionExport_Iges)
|
||||
self.menuFile.addAction(self.actionExport_Stl)
|
||||
self.menuFile.addSeparator()
|
||||
self.menuFile.addAction(self.actionExit)
|
||||
|
||||
self.retranslateUi(fluencyCAD)
|
||||
|
||||
self.InputTab.setCurrentIndex(0)
|
||||
|
||||
|
||||
QMetaObject.connectSlotsByName(fluencyCAD)
|
||||
# setupUi
|
||||
|
||||
def retranslateUi(self, fluencyCAD):
|
||||
fluencyCAD.setWindowTitle(QCoreApplication.translate("fluencyCAD", u"fluencyCAD", None))
|
||||
self.actionNew_Project.setText(QCoreApplication.translate("fluencyCAD", u"New Project", None))
|
||||
#if QT_CONFIG(shortcut)
|
||||
self.actionNew_Project.setShortcut(QCoreApplication.translate("fluencyCAD", u"Ctrl+N", None))
|
||||
#endif // QT_CONFIG(shortcut)
|
||||
self.actionOpen_Project.setText(QCoreApplication.translate("fluencyCAD", u"Open Project...", None))
|
||||
#if QT_CONFIG(shortcut)
|
||||
self.actionOpen_Project.setShortcut(QCoreApplication.translate("fluencyCAD", u"Ctrl+O", None))
|
||||
#endif // QT_CONFIG(shortcut)
|
||||
self.actionSave_Project.setText(QCoreApplication.translate("fluencyCAD", u"Save Project", None))
|
||||
#if QT_CONFIG(shortcut)
|
||||
self.actionSave_Project.setShortcut(QCoreApplication.translate("fluencyCAD", u"Ctrl+S", None))
|
||||
#endif // QT_CONFIG(shortcut)
|
||||
self.actionSave_Project_As.setText(QCoreApplication.translate("fluencyCAD", u"Save Project As...", None))
|
||||
#if QT_CONFIG(shortcut)
|
||||
self.actionSave_Project_As.setShortcut(QCoreApplication.translate("fluencyCAD", u"Ctrl+Shift+S", None))
|
||||
#endif // QT_CONFIG(shortcut)
|
||||
self.actionImport_File.setText(QCoreApplication.translate("fluencyCAD", u"Import STEP/IGES...", None))
|
||||
self.actionExport_Step.setText(QCoreApplication.translate("fluencyCAD", u"Export STEP...", None))
|
||||
self.actionExport_Iges.setText(QCoreApplication.translate("fluencyCAD", u"Export IGES...", None))
|
||||
self.actionExport_Stl.setText(QCoreApplication.translate("fluencyCAD", u"Export STL...", None))
|
||||
self.actionExit.setText(QCoreApplication.translate("fluencyCAD", u"Exit", None))
|
||||
#if QT_CONFIG(shortcut)
|
||||
self.actionExit.setShortcut(QCoreApplication.translate("fluencyCAD", u"Ctrl+Q", None))
|
||||
#endif // QT_CONFIG(shortcut)
|
||||
self.InputTab.setTabText(self.InputTab.indexOf(self.sketch_tab), QCoreApplication.translate("fluencyCAD", u"Sketch", None))
|
||||
self.groupBox_7.setTitle(QCoreApplication.translate("fluencyCAD", u"Executive", None))
|
||||
self.pushButton_5.setText(QCoreApplication.translate("fluencyCAD", u"Load Code", None))
|
||||
self.pushButton_4.setText(QCoreApplication.translate("fluencyCAD", u"Save code", None))
|
||||
self.pb_apply_code.setText(QCoreApplication.translate("fluencyCAD", u"Apply Code", None))
|
||||
self.pushButton.setText(QCoreApplication.translate("fluencyCAD", u"Delete Code", None))
|
||||
self.InputTab.setTabText(self.InputTab.indexOf(self.code_tab), QCoreApplication.translate("fluencyCAD", u"Code", None))
|
||||
self.groupBox_9.setTitle(QCoreApplication.translate("fluencyCAD", u"Workplanes", None))
|
||||
#if QT_CONFIG(tooltip)
|
||||
self.pb_origin_wp.setToolTip(QCoreApplication.translate("fluencyCAD", u"<W>orking Plane at 0, 0, 0", None))
|
||||
#endif // QT_CONFIG(tooltip)
|
||||
self.pb_origin_wp.setText(QCoreApplication.translate("fluencyCAD", u"WP Origin", None))
|
||||
#if QT_CONFIG(shortcut)
|
||||
self.pb_origin_wp.setShortcut(QCoreApplication.translate("fluencyCAD", u"W", None))
|
||||
#endif // QT_CONFIG(shortcut)
|
||||
#if QT_CONFIG(tooltip)
|
||||
self.pb_origin_face.setToolTip(QCoreApplication.translate("fluencyCAD", u"Working Plane >P<rojection at selected edges face", None))
|
||||
#endif // QT_CONFIG(tooltip)
|
||||
self.pb_origin_face.setText(QCoreApplication.translate("fluencyCAD", u" WP Face", None))
|
||||
#if QT_CONFIG(shortcut)
|
||||
self.pb_origin_face.setShortcut(QCoreApplication.translate("fluencyCAD", u"P", None))
|
||||
#endif // QT_CONFIG(shortcut)
|
||||
#if QT_CONFIG(tooltip)
|
||||
self.pb_flip_face.setToolTip(QCoreApplication.translate("fluencyCAD", u"Flip >N<ormal of projected mesh.", None))
|
||||
#endif // QT_CONFIG(tooltip)
|
||||
self.pb_flip_face.setText(QCoreApplication.translate("fluencyCAD", u"WP Flip", None))
|
||||
#if QT_CONFIG(shortcut)
|
||||
self.pb_flip_face.setShortcut(QCoreApplication.translate("fluencyCAD", u"N", None))
|
||||
#endif // QT_CONFIG(shortcut)
|
||||
#if QT_CONFIG(tooltip)
|
||||
self.pb_underlay.setToolTip(QCoreApplication.translate("fluencyCAD", u"Show / hide the construction lines projected from the source face", None))
|
||||
#endif // QT_CONFIG(tooltip)
|
||||
self.pb_underlay.setText(QCoreApplication.translate("fluencyCAD", u"Underlay", None))
|
||||
#if QT_CONFIG(tooltip)
|
||||
self.pb_clr_face.setToolTip(QCoreApplication.translate("fluencyCAD", u"Forget the picked source face (keep the workplane)", None))
|
||||
#endif // QT_CONFIG(tooltip)
|
||||
self.pb_clr_face.setText(QCoreApplication.translate("fluencyCAD", u"ClrFace", None))
|
||||
#if QT_CONFIG(tooltip)
|
||||
self.pb_to_sketch.setToolTip(QCoreApplication.translate("fluencyCAD", u"Convert projected construction lines into real sketch geometry", None))
|
||||
#endif // QT_CONFIG(tooltip)
|
||||
self.pb_to_sketch.setText(QCoreApplication.translate("fluencyCAD", u"ToSketch", None))
|
||||
#if QT_CONFIG(tooltip)
|
||||
self.pb_wp_new.setToolTip(QCoreApplication.translate("fluencyCAD", u"Create a new independent workplane (datum plane)", None))
|
||||
#endif // QT_CONFIG(tooltip)
|
||||
self.pb_wp_new.setText(QCoreApplication.translate("fluencyCAD", u"WP New", None))
|
||||
#if QT_CONFIG(shortcut)
|
||||
self.pb_wp_new.setShortcut(QCoreApplication.translate("fluencyCAD", u"Shift+W", None))
|
||||
#endif // QT_CONFIG(shortcut)
|
||||
self.assembly_box.setTitle(QCoreApplication.translate("fluencyCAD", u"Assembly", None))
|
||||
self.joint_tools.setTitle(QCoreApplication.translate("fluencyCAD", u"Joint Tools", None))
|
||||
self.pb_remove_connector.setText(QCoreApplication.translate("fluencyCAD", u"- Cnct", None))
|
||||
self.pb_add_connector.setText(QCoreApplication.translate("fluencyCAD", u"+ Cnct", None))
|
||||
self.pb_add_connector_2.setText(QCoreApplication.translate("fluencyCAD", u"+Jnt", None))
|
||||
self.pb_add_connector_3.setText(QCoreApplication.translate("fluencyCAD", u"-Jnt", None))
|
||||
self.gl_box.setTitle(QCoreApplication.translate("fluencyCAD", u"Model Viewer", None))
|
||||
self.groupBox_11.setTitle(QCoreApplication.translate("fluencyCAD", u"Sketch", None))
|
||||
self.groupBox_6.setTitle(QCoreApplication.translate("fluencyCAD", u"Tools", None))
|
||||
self.pb_edt_sktch.setText(QCoreApplication.translate("fluencyCAD", u"Edt", None))
|
||||
self.pb_nw_sktch.setText(QCoreApplication.translate("fluencyCAD", u"Add", None))
|
||||
self.pb_del_sketch.setText(QCoreApplication.translate("fluencyCAD", u"Del", None))
|
||||
self.assembly_tools.setTitle(QCoreApplication.translate("fluencyCAD", u"Assembly Tools", None))
|
||||
self.pb_compo_to_assembly.setText(QCoreApplication.translate("fluencyCAD", u"Add", None))
|
||||
self.pb_remove_compo_from_assembly.setText(QCoreApplication.translate("fluencyCAD", u"Rem", None))
|
||||
self.compo_tool_box.setTitle(QCoreApplication.translate("fluencyCAD", u"Component Tools", None))
|
||||
self.pb_new_compo.setText(QCoreApplication.translate("fluencyCAD", u"New", None))
|
||||
self.pb_del_compo.setText(QCoreApplication.translate("fluencyCAD", u"Del", None))
|
||||
self.compo_box.setTitle(QCoreApplication.translate("fluencyCAD", u"Components", None))
|
||||
self.groupBox_12.setTitle(QCoreApplication.translate("fluencyCAD", u"Component Connections", None))
|
||||
self.groupBox_13.setTitle(QCoreApplication.translate("fluencyCAD", u"Tools", None))
|
||||
self.pb_del_connection.setText(QCoreApplication.translate("fluencyCAD", u"Del", None))
|
||||
self.pb_update_connection.setText(QCoreApplication.translate("fluencyCAD", u"Upd", None))
|
||||
self.pb_edt_sktch_4.setText(QCoreApplication.translate("fluencyCAD", u"Nothing", None))
|
||||
self.groupBox_10.setTitle(QCoreApplication.translate("fluencyCAD", u"Bodys / Operations", None))
|
||||
self.groupBox_8.setTitle(QCoreApplication.translate("fluencyCAD", u"Tools", None))
|
||||
self.pb_del_body.setText(QCoreApplication.translate("fluencyCAD", u"Del", None))
|
||||
self.pb_update_body.setText(QCoreApplication.translate("fluencyCAD", u"Upd", None))
|
||||
self.pb_edt_sktch_3.setText(QCoreApplication.translate("fluencyCAD", u"Nothing", None))
|
||||
self.groupBox_2.setTitle(QCoreApplication.translate("fluencyCAD", u"Drawing", None))
|
||||
self.pb_arc_tool.setText(QCoreApplication.translate("fluencyCAD", u"Arc", None))
|
||||
self.pb_rectool.setText(QCoreApplication.translate("fluencyCAD", u"Rctgl", None))
|
||||
self.pb_circtool.setText(QCoreApplication.translate("fluencyCAD", u"Circle", None))
|
||||
self.pb_enable_construct.setText(QCoreApplication.translate("fluencyCAD", u"Cstrct", None))
|
||||
self.pb_enable_snap.setText(QCoreApplication.translate("fluencyCAD", u"Snap", None))
|
||||
self.pb_linetool.setText(QCoreApplication.translate("fluencyCAD", u"Line", None))
|
||||
#if QT_CONFIG(shortcut)
|
||||
self.pb_linetool.setShortcut(QCoreApplication.translate("fluencyCAD", u"S", None))
|
||||
#endif // QT_CONFIG(shortcut)
|
||||
self.pb_slotool.setText(QCoreApplication.translate("fluencyCAD", u"Slot", None))
|
||||
#if QT_CONFIG(tooltip)
|
||||
self.pb_offset_tool.setToolTip(QCoreApplication.translate("fluencyCAD", u"Offset selected sketch face (duplicate + offset boundary)", None))
|
||||
#endif // QT_CONFIG(tooltip)
|
||||
self.pb_offset_tool.setText(QCoreApplication.translate("fluencyCAD", u"Offst", None))
|
||||
self.groupBox_3.setTitle(QCoreApplication.translate("fluencyCAD", u"Constrain", None))
|
||||
#if QT_CONFIG(tooltip)
|
||||
self.pb_con_ptpt.setToolTip(QCoreApplication.translate("fluencyCAD", u"Poin to Point Constrain", None))
|
||||
#endif // QT_CONFIG(tooltip)
|
||||
self.pb_con_ptpt.setText(QCoreApplication.translate("fluencyCAD", u"Pt_Pt", None))
|
||||
#if QT_CONFIG(tooltip)
|
||||
self.pb_con_vert.setToolTip(QCoreApplication.translate("fluencyCAD", u"Vertical Constrain", None))
|
||||
#endif // QT_CONFIG(tooltip)
|
||||
self.pb_con_vert.setText(QCoreApplication.translate("fluencyCAD", u"Vert", None))
|
||||
self.pb_con_sym.setText(QCoreApplication.translate("fluencyCAD", u"Symetrc", None))
|
||||
#if QT_CONFIG(tooltip)
|
||||
self.pb_con_mid.setToolTip(QCoreApplication.translate("fluencyCAD", u"Point to Middle Point Constrain", None))
|
||||
#endif // QT_CONFIG(tooltip)
|
||||
self.pb_con_mid.setText(QCoreApplication.translate("fluencyCAD", u"Pt_Mid_L", None))
|
||||
#if QT_CONFIG(tooltip)
|
||||
self.pb_con_line.setToolTip(QCoreApplication.translate("fluencyCAD", u"Point to Line Constrain", None))
|
||||
#endif // QT_CONFIG(tooltip)
|
||||
self.pb_con_line.setText(QCoreApplication.translate("fluencyCAD", u"Pt_Lne", None))
|
||||
#if QT_CONFIG(tooltip)
|
||||
self.pb_con_horiz.setToolTip(QCoreApplication.translate("fluencyCAD", u"Horizontal Constrain ", None))
|
||||
#endif // QT_CONFIG(tooltip)
|
||||
self.pb_con_horiz.setText(QCoreApplication.translate("fluencyCAD", u"Horiz", None))
|
||||
#if QT_CONFIG(tooltip)
|
||||
self.pb_con_dist.setToolTip(QCoreApplication.translate("fluencyCAD", u"Dimension of Line of Distance from Point to Line", None))
|
||||
#endif // QT_CONFIG(tooltip)
|
||||
self.pb_con_dist.setText(QCoreApplication.translate("fluencyCAD", u"Distnce", None))
|
||||
#if QT_CONFIG(tooltip)
|
||||
self.pb_con_perp.setToolTip(QCoreApplication.translate("fluencyCAD", u"Constrain Line perpendicular to another line.", None))
|
||||
#endif // QT_CONFIG(tooltip)
|
||||
self.pb_con_perp.setText(QCoreApplication.translate("fluencyCAD", u"Perp_Lne", None))
|
||||
self.pb_con_diameter.setText(QCoreApplication.translate("fluencyCAD", u"Diameter", None))
|
||||
self.groupBox_5.setTitle(QCoreApplication.translate("fluencyCAD", u"Snapping Points", None))
|
||||
self.label.setText(QCoreApplication.translate("fluencyCAD", u"Snp Dst", None))
|
||||
self.pb_snap_vert.setText(QCoreApplication.translate("fluencyCAD", u"Vert", None))
|
||||
self.label_2.setText(QCoreApplication.translate("fluencyCAD", u"Angl Stps", None))
|
||||
self.spinbox_snap_distance.setSuffix(QCoreApplication.translate("fluencyCAD", u"mm", None))
|
||||
self.pushButton_7.setText(QCoreApplication.translate("fluencyCAD", u"Grid", None))
|
||||
self.pb_snap_horiz.setText(QCoreApplication.translate("fluencyCAD", u"Horiz", None))
|
||||
self.spinbox_angle_steps.setSuffix(QCoreApplication.translate("fluencyCAD", u"\u00b0", None))
|
||||
self.pushButton_8.setText(QCoreApplication.translate("fluencyCAD", u"Pnt", None))
|
||||
self.pb_snap_midp.setText(QCoreApplication.translate("fluencyCAD", u"MidP", None))
|
||||
self.pb_snap_angle.setText(QCoreApplication.translate("fluencyCAD", u"Angles", None))
|
||||
self.groupBox.setTitle(QCoreApplication.translate("fluencyCAD", u"Modify", None))
|
||||
self.pb_revop.setText(QCoreApplication.translate("fluencyCAD", u"Rev", None))
|
||||
self.pb_extrdop.setText(QCoreApplication.translate("fluencyCAD", u"Extrd", None))
|
||||
self.pb_arrayop.setText(QCoreApplication.translate("fluencyCAD", u"Arry", None))
|
||||
self.pb_cutop.setText(QCoreApplication.translate("fluencyCAD", u"Cut", None))
|
||||
self.pb_combop.setText(QCoreApplication.translate("fluencyCAD", u"Comb", None))
|
||||
self.pb_moveop.setText(QCoreApplication.translate("fluencyCAD", u"Mve", None))
|
||||
self.groupBox_4.setTitle(QCoreApplication.translate("fluencyCAD", u"Export", None))
|
||||
self.pushButton_2.setText(QCoreApplication.translate("fluencyCAD", u"STL", None))
|
||||
self.pb_export_step.setText(QCoreApplication.translate("fluencyCAD", u"STEP", None))
|
||||
self.pb_export_iges.setText(QCoreApplication.translate("fluencyCAD", u"IGES", None))
|
||||
self.menuFile.setTitle(QCoreApplication.translate("fluencyCAD", u"File", None))
|
||||
self.menuSettings.setTitle(QCoreApplication.translate("fluencyCAD", u"Settings", None))
|
||||
# retranslateUi
|
||||
|
||||
@@ -26,7 +26,6 @@ classifiers = [
|
||||
]
|
||||
|
||||
dependencies = [
|
||||
"cadquery>=2.4",
|
||||
"pygfx>=0.1.0",
|
||||
"wgpu>=0.1.0",
|
||||
"PySide6>=6.4.0",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
Fluency CAD - Parametric CAD Application
|
||||
|
||||
A modern parametric CAD application built on OpenCASCADE Technology (OCCT)
|
||||
with a clean Python API using CadQuery.
|
||||
with a clean Python API using OCP (OpenCASCADE Python bindings).
|
||||
"""
|
||||
|
||||
__version__ = "2.0.0"
|
||||
|
||||
@@ -406,6 +406,11 @@ class SketchInterface(ABC):
|
||||
"""Constrain two circles to have equal radius."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def constrain_diameter(self, circle: SketchEntity, diameter: float) -> bool:
|
||||
"""Set the diameter of a circle."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def constrain_fixed(self, entity: SketchEntity) -> bool:
|
||||
"""Fix an entity in place."""
|
||||
|
||||
+116
-232
@@ -2,7 +2,7 @@
|
||||
OpenCASCADE-based geometry kernel for Fluency CAD.
|
||||
|
||||
This module provides a concrete implementation of the geometry kernel
|
||||
using CadQuery and OCP (OpenCASCADE Python bindings).
|
||||
using OCP (OpenCASCADE Python bindings).
|
||||
"""
|
||||
|
||||
from typing import List, Tuple, Optional, Any, Dict
|
||||
@@ -21,24 +21,14 @@ class OCCGeometryObject(GeometryObject):
|
||||
|
||||
def __init__(self, shape: Any = None, metadata: Optional[Dict] = None):
|
||||
super().__init__(shape, metadata)
|
||||
self._cadquery_obj: Any = None
|
||||
|
||||
@property
|
||||
def cq_obj(self) -> Any:
|
||||
"""Get the CadQuery object if available."""
|
||||
return self._cadquery_obj
|
||||
|
||||
@cq_obj.setter
|
||||
def cq_obj(self, value: Any) -> None:
|
||||
self._cadquery_obj = value
|
||||
|
||||
|
||||
class OCGeometryKernel(GeometryKernel):
|
||||
"""
|
||||
OpenCASCADE-based geometry kernel implementation.
|
||||
|
||||
This kernel uses CadQuery for high-level operations and
|
||||
OCP for direct OpenCASCADE access when needed.
|
||||
This kernel uses OCP (OpenCASCADE Python bindings) for all geometry
|
||||
operations.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
@@ -51,114 +41,93 @@ class OCGeometryKernel(GeometryKernel):
|
||||
Returns *None* if the object carries no shape (e.g. an empty sketch) —
|
||||
callers should check for None before using the result.
|
||||
"""
|
||||
import cadquery as cq
|
||||
|
||||
if isinstance(obj, OCCGeometryObject):
|
||||
if obj._cadquery_obj is not None:
|
||||
shape = obj._cadquery_obj.val()
|
||||
if hasattr(shape, "wrapped"):
|
||||
return shape.wrapped
|
||||
return shape
|
||||
if obj.shape is not None:
|
||||
if isinstance(obj.shape, cq.Workplane):
|
||||
shape = obj.shape.val()
|
||||
if hasattr(shape, "wrapped"):
|
||||
return shape.wrapped
|
||||
return shape
|
||||
if hasattr(obj.shape, "wrapped"):
|
||||
if obj.shape is not None and hasattr(obj.shape, "wrapped"):
|
||||
return obj.shape.wrapped
|
||||
return obj.shape
|
||||
# No cadquery obj and no raw shape → genuinely empty.
|
||||
return None
|
||||
# Non-OCCGeometryObject: return its shape if present, else None.
|
||||
# (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 _get_cq_obj(self, obj: GeometryObject) -> Any:
|
||||
"""Get CadQuery object from GeometryObject."""
|
||||
if isinstance(obj, OCCGeometryObject) and obj._cadquery_obj is not None:
|
||||
return obj._cadquery_obj
|
||||
return obj.shape
|
||||
|
||||
def create_point(self, x: float, y: float) -> GeometryObject:
|
||||
"""Create a 2D point."""
|
||||
import cadquery as cq
|
||||
|
||||
point = cq.Vector(x, y, 0)
|
||||
return OCCGeometryObject(point)
|
||||
from OCP.gp import gp_Pnt
|
||||
return OCCGeometryObject(gp_Pnt(x, y, 0))
|
||||
|
||||
def create_line(self, start: Point2D, end: Point2D) -> GeometryObject:
|
||||
"""Create a 2D line segment."""
|
||||
import cadquery as cq
|
||||
from OCP.gp import gp_Pnt
|
||||
from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeEdge
|
||||
|
||||
wp = cq.Workplane("XY").moveTo(start.x, start.y).lineTo(end.x, end.y)
|
||||
obj = OCCGeometryObject(wp.val(), {"type": "line"})
|
||||
obj._cadquery_obj = wp
|
||||
return obj
|
||||
edge = BRepBuilderAPI_MakeEdge(
|
||||
gp_Pnt(start.x, start.y, 0), gp_Pnt(end.x, end.y, 0)
|
||||
).Edge()
|
||||
return OCCGeometryObject(edge, {"type": "line"})
|
||||
|
||||
def create_circle(self, center: Point2D, radius: float) -> GeometryObject:
|
||||
"""Create a 2D circle."""
|
||||
import cadquery as cq
|
||||
from OCP.gp import gp_Pnt, gp_Dir, gp_Ax2, gp_Circ
|
||||
from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeEdge
|
||||
|
||||
wp = cq.Workplane("XY").center(center.x, center.y).circle(radius)
|
||||
obj = OCCGeometryObject(wp.val(), {"type": "circle"})
|
||||
obj._cadquery_obj = wp
|
||||
return obj
|
||||
circ = gp_Circ(
|
||||
gp_Ax2(gp_Pnt(center.x, center.y, 0), gp_Dir(0, 0, 1)), radius
|
||||
)
|
||||
edge = BRepBuilderAPI_MakeEdge(circ).Edge()
|
||||
return OCCGeometryObject(edge, {"type": "circle"})
|
||||
|
||||
def create_arc(
|
||||
self, center: Point2D, radius: float, start_angle: float, end_angle: float
|
||||
) -> GeometryObject:
|
||||
"""Create a 2D arc."""
|
||||
import cadquery as cq
|
||||
import math
|
||||
from OCP.gp import gp_Pnt, gp_Dir, gp_Ax2, gp_Circ
|
||||
from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeEdge
|
||||
|
||||
start_rad = math.radians(start_angle)
|
||||
end_rad = math.radians(end_angle)
|
||||
|
||||
start_x = center.x + radius * math.cos(start_rad)
|
||||
start_y = center.y + radius * math.sin(start_rad)
|
||||
|
||||
wire = (
|
||||
cq.Workplane("XY")
|
||||
.moveTo(start_x, start_y)
|
||||
.radiusArc(
|
||||
(center.x + radius * math.cos(end_rad), center.y + radius * math.sin(end_rad)),
|
||||
radius,
|
||||
circ = gp_Circ(
|
||||
gp_Ax2(gp_Pnt(center.x, center.y, 0), gp_Dir(0, 0, 1)), radius
|
||||
)
|
||||
)
|
||||
return OCCGeometryObject(wire.val(), {"type": "arc"})
|
||||
edge = BRepBuilderAPI_MakeEdge(circ, start_rad, end_rad).Edge()
|
||||
return OCCGeometryObject(edge, {"type": "arc"})
|
||||
|
||||
def create_polygon(self, points: List[Point2D]) -> GeometryObject:
|
||||
"""Create a closed polygon from points."""
|
||||
import cadquery as cq
|
||||
from OCP.gp import gp_Pnt
|
||||
from OCP.BRepBuilderAPI import BRepBuilderAPI_MakePolygon
|
||||
|
||||
if len(points) < 3:
|
||||
raise ValueError("Polygon requires at least 3 points")
|
||||
|
||||
wp = cq.Workplane("XY").moveTo(points[0].x, points[0].y)
|
||||
for pt in points[1:]:
|
||||
wp = wp.lineTo(pt.x, pt.y)
|
||||
wp = wp.close()
|
||||
|
||||
obj = OCCGeometryObject(wp.val(), {"type": "polygon"})
|
||||
obj._cadquery_obj = wp
|
||||
return obj
|
||||
mp = BRepBuilderAPI_MakePolygon()
|
||||
for pt in points:
|
||||
mp.Add(gp_Pnt(pt.x, pt.y, 0))
|
||||
mp.Close()
|
||||
return OCCGeometryObject(mp.Wire(), {"type": "polygon"})
|
||||
|
||||
def create_rectangle(
|
||||
self, width: float, height: float, center: Optional[Point2D] = None
|
||||
) -> GeometryObject:
|
||||
"""Create a rectangle."""
|
||||
import cadquery as cq
|
||||
from OCP.gp import gp_Pnt
|
||||
from OCP.BRepBuilderAPI import BRepBuilderAPI_MakePolygon
|
||||
|
||||
cx = center.x if center else 0
|
||||
cy = center.y if center else 0
|
||||
|
||||
wp = cq.Workplane("XY").center(cx, cy).rect(width, height)
|
||||
obj = OCCGeometryObject(wp.val(), {"type": "rectangle"})
|
||||
obj._cadquery_obj = wp
|
||||
return obj
|
||||
hw = width / 2.0
|
||||
hh = height / 2.0
|
||||
|
||||
mp = BRepBuilderAPI_MakePolygon()
|
||||
mp.Add(gp_Pnt(cx - hw, cy - hh, 0))
|
||||
mp.Add(gp_Pnt(cx + hw, cy - hh, 0))
|
||||
mp.Add(gp_Pnt(cx + hw, cy + hh, 0))
|
||||
mp.Add(gp_Pnt(cx - hw, cy + hh, 0))
|
||||
mp.Close()
|
||||
return OCCGeometryObject(mp.Wire(), {"type": "rectangle"})
|
||||
|
||||
def extrude(
|
||||
self,
|
||||
@@ -182,10 +151,6 @@ class OCGeometryKernel(GeometryKernel):
|
||||
|
||||
# Defensive: figure out the actual shape from whatever the caller
|
||||
# hands us, and surface a clear error if we can't get one.
|
||||
# - If it's an OCCGeometryObject wrapper, unwrap via _get_shape.
|
||||
# - If it's already a TopoDS_Shape (raw face/wire/etc.), use it.
|
||||
# - If it's a cadquery Workplane, unwrap that too.
|
||||
# - If it's a cadquery Shape (cq.Shape), unwrap to TopoDS_Shape.
|
||||
if isinstance(sketch, OCCGeometryObject):
|
||||
face = self._get_shape(sketch)
|
||||
elif isinstance(sketch, TopoDS_Shape):
|
||||
@@ -205,8 +170,8 @@ class OCGeometryKernel(GeometryKernel):
|
||||
"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 from
|
||||
# legacy cadquery objects. If it's not already a face, build one.
|
||||
# ``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(
|
||||
@@ -303,48 +268,58 @@ class OCGeometryKernel(GeometryKernel):
|
||||
|
||||
def loft(self, profiles: List[GeometryObject], ruled: bool = False) -> GeometryObject:
|
||||
"""Create a loft between multiple profiles."""
|
||||
import cadquery as cq
|
||||
from OCP.BRepOffsetAPI import BRepOffsetAPI_ThruSections
|
||||
from OCP.TopExp import TopExp_Explorer
|
||||
from OCP.TopAbs import TopAbs_WIRE
|
||||
from OCP.TopoDS import TopoDS
|
||||
|
||||
if len(profiles) < 2:
|
||||
raise ValueError("Loft requires at least 2 profiles")
|
||||
|
||||
wires = []
|
||||
loft_maker = BRepOffsetAPI_ThruSections(True, ruled)
|
||||
for profile in profiles:
|
||||
cq_obj = self._get_cq_obj(profile)
|
||||
if isinstance(cq_obj, cq.Workplane):
|
||||
wires.append(cq_obj.val())
|
||||
else:
|
||||
wires.append(cq_obj)
|
||||
shape = self._get_shape(profile)
|
||||
explorer = TopExp_Explorer(shape, TopAbs_WIRE)
|
||||
while explorer.More():
|
||||
wire = TopoDS.Wire_s(explorer.Current())
|
||||
loft_maker.AddWire(wire)
|
||||
explorer.Next()
|
||||
|
||||
loft = cq.Solid.loft(wires, ruled)
|
||||
return OCCGeometryObject(loft, {"type": "loft"})
|
||||
loft_maker.Build()
|
||||
solid = loft_maker.Shape()
|
||||
return OCCGeometryObject(solid, {"type": "loft"})
|
||||
|
||||
def sweep(
|
||||
self, profile: GeometryObject, path: GeometryObject, is_frenet: bool = False
|
||||
) -> GeometryObject:
|
||||
"""Sweep a profile along a path."""
|
||||
import cadquery as cq
|
||||
from OCP.BRepOffsetAPI import BRepOffsetAPI_MakePipeShell
|
||||
from OCP.TopExp import TopExp_Explorer
|
||||
from OCP.TopAbs import TopAbs_WIRE
|
||||
from OCP.TopoDS import TopoDS
|
||||
|
||||
profile_obj = self._get_cq_obj(profile)
|
||||
path_obj = self._get_cq_obj(path)
|
||||
profile_shape = self._get_shape(profile)
|
||||
path_shape = self._get_shape(path)
|
||||
|
||||
if isinstance(profile_obj, cq.Workplane):
|
||||
profile_wire = profile_obj.val()
|
||||
else:
|
||||
profile_wire = profile_obj
|
||||
def _first_wire(shape):
|
||||
exp = TopExp_Explorer(shape, TopAbs_WIRE)
|
||||
if exp.More():
|
||||
return TopoDS.Wire_s(exp.Current())
|
||||
raise ValueError("No wire found in shape for sweep")
|
||||
|
||||
if isinstance(path_obj, cq.Workplane):
|
||||
path_wire = path_obj.val()
|
||||
else:
|
||||
path_wire = path_obj
|
||||
profile_wire = _first_wire(profile_shape)
|
||||
path_wire = _first_wire(path_shape)
|
||||
|
||||
solid = cq.Solid.sweep(profile_wire, path_wire, is_frenet)
|
||||
pipe = BRepOffsetAPI_MakePipeShell(path_wire)
|
||||
pipe.Add(profile_wire, False, False)
|
||||
if is_frenet:
|
||||
pipe.SetMode(True)
|
||||
pipe.Build()
|
||||
solid = pipe.Shape()
|
||||
return OCCGeometryObject(solid, {"type": "sweep"})
|
||||
|
||||
def boolean_union(self, *bodies: GeometryObject) -> GeometryObject:
|
||||
"""Union multiple bodies."""
|
||||
import cadquery as cq
|
||||
|
||||
if len(bodies) < 2:
|
||||
return bodies[0] if bodies else OCCGeometryObject(None)
|
||||
|
||||
@@ -357,12 +332,10 @@ class OCGeometryKernel(GeometryKernel):
|
||||
fuse.Build()
|
||||
result = fuse.Shape()
|
||||
|
||||
return OCCGeometryObject(cq.Shape(result), {"type": "union"})
|
||||
return OCCGeometryObject(result, {"type": "union"})
|
||||
|
||||
def boolean_difference(self, base: GeometryObject, tool: GeometryObject) -> GeometryObject:
|
||||
"""Subtract tool from base."""
|
||||
import cadquery as cq
|
||||
|
||||
base_shape = self._get_shape(base)
|
||||
tool_shape = self._get_shape(tool)
|
||||
|
||||
@@ -371,12 +344,10 @@ class OCGeometryKernel(GeometryKernel):
|
||||
cut = BRepAlgoAPI_Cut(base_shape, tool_shape)
|
||||
cut.Build()
|
||||
|
||||
return OCCGeometryObject(cq.Shape(cut.Shape()), {"type": "difference"})
|
||||
return OCCGeometryObject(cut.Shape(), {"type": "difference"})
|
||||
|
||||
def boolean_intersection(self, body1: GeometryObject, body2: GeometryObject) -> GeometryObject:
|
||||
"""Intersect two bodies."""
|
||||
import cadquery as cq
|
||||
|
||||
shape1 = self._get_shape(body1)
|
||||
shape2 = self._get_shape(body2)
|
||||
|
||||
@@ -385,27 +356,21 @@ class OCGeometryKernel(GeometryKernel):
|
||||
common = BRepAlgoAPI_Common(shape1, shape2)
|
||||
common.Build()
|
||||
|
||||
return OCCGeometryObject(cq.Shape(common.Shape()), {"type": "intersection"})
|
||||
return OCCGeometryObject(common.Shape(), {"type": "intersection"})
|
||||
|
||||
def fillet(
|
||||
self, body: GeometryObject, radius: float, edges: Optional[List[Any]] = None
|
||||
) -> GeometryObject:
|
||||
"""Apply fillet to edges."""
|
||||
import cadquery as cq
|
||||
|
||||
cq_obj = self._get_cq_obj(body)
|
||||
|
||||
if isinstance(cq_obj, cq.Workplane):
|
||||
if edges:
|
||||
result = cq_obj.edges(edges).fillet(radius)
|
||||
else:
|
||||
result = cq_obj.edges().fillet(radius)
|
||||
else:
|
||||
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
|
||||
|
||||
@@ -414,29 +379,22 @@ class OCGeometryKernel(GeometryKernel):
|
||||
fillet.Add(radius, explorer.Current())
|
||||
explorer.Next()
|
||||
|
||||
result = cq.Shape(fillet.Shape())
|
||||
|
||||
return OCCGeometryObject(result, {"type": "fillet"})
|
||||
fillet.Build()
|
||||
return OCCGeometryObject(fillet.Shape(), {"type": "fillet"})
|
||||
|
||||
def chamfer(
|
||||
self, body: GeometryObject, size: float, edges: Optional[List[Any]] = None
|
||||
) -> GeometryObject:
|
||||
"""Apply chamfer to edges."""
|
||||
import cadquery as cq
|
||||
|
||||
cq_obj = self._get_cq_obj(body)
|
||||
|
||||
if isinstance(cq_obj, cq.Workplane):
|
||||
if edges:
|
||||
result = cq_obj.edges(edges).chamfer(size)
|
||||
else:
|
||||
result = cq_obj.edges().chamfer(size)
|
||||
else:
|
||||
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
|
||||
|
||||
@@ -445,24 +403,13 @@ class OCGeometryKernel(GeometryKernel):
|
||||
chamfer.Add(size, explorer.Current())
|
||||
explorer.Next()
|
||||
|
||||
result = cq.Shape(chamfer.Shape())
|
||||
|
||||
return OCCGeometryObject(result, {"type": "chamfer"})
|
||||
chamfer.Build()
|
||||
return OCCGeometryObject(chamfer.Shape(), {"type": "chamfer"})
|
||||
|
||||
def shell(
|
||||
self, body: GeometryObject, thickness: float, faces_to_remove: Optional[List[Any]] = None
|
||||
) -> GeometryObject:
|
||||
"""Create a shell (hollow body)."""
|
||||
import cadquery as cq
|
||||
|
||||
cq_obj = self._get_cq_obj(body)
|
||||
|
||||
if isinstance(cq_obj, cq.Workplane):
|
||||
if faces_to_remove:
|
||||
result = cq_obj.faces(faces_to_remove).shell(thickness)
|
||||
else:
|
||||
result = cq_obj.shell(thickness)
|
||||
else:
|
||||
shape = self._get_shape(body)
|
||||
from OCP.BRepOffsetAPI import BRepOffsetAPI_MakeThickSolid
|
||||
from OCP.TopTools import TopTools_ListOfShape
|
||||
@@ -475,31 +422,20 @@ class OCGeometryKernel(GeometryKernel):
|
||||
shell_maker = BRepOffsetAPI_MakeThickSolid()
|
||||
shell_maker.MakeThickSolidByJoin(shape, faces_list, thickness, 0.001)
|
||||
shell_maker.Build()
|
||||
result = cq.Shape(shell_maker.Shape())
|
||||
|
||||
return OCCGeometryObject(result, {"type": "shell"})
|
||||
return OCCGeometryObject(shell_maker.Shape(), {"type": "shell"})
|
||||
|
||||
def offset(self, face: GeometryObject, distance: float) -> GeometryObject:
|
||||
"""Offset a face or surface."""
|
||||
import cadquery as cq
|
||||
|
||||
shape = self._get_shape(face)
|
||||
from OCP.BRepOffsetAPI import BRepOffsetAPI_MakeOffset
|
||||
|
||||
offset_maker = BRepOffsetAPI_MakeOffset(shape, False)
|
||||
offset_maker.Perform(distance)
|
||||
|
||||
return OCCGeometryObject(cq.Shape(offset_maker.Shape()), {"type": "offset"})
|
||||
return OCCGeometryObject(offset_maker.Shape(), {"type": "offset"})
|
||||
|
||||
def translate(self, body: GeometryObject, vector: Tuple[float, float, float]) -> GeometryObject:
|
||||
"""Translate a body."""
|
||||
import cadquery as cq
|
||||
|
||||
cq_obj = self._get_cq_obj(body)
|
||||
|
||||
if isinstance(cq_obj, cq.Workplane):
|
||||
result = cq_obj.translate(vector)
|
||||
else:
|
||||
shape = self._get_shape(body)
|
||||
from OCP.BRepBuilderAPI import BRepBuilderAPI_Transform
|
||||
from OCP.gp import gp_Trsf, gp_Vec
|
||||
@@ -507,9 +443,7 @@ class OCGeometryKernel(GeometryKernel):
|
||||
transform = gp_Trsf()
|
||||
transform.SetTranslation(gp_Vec(*vector))
|
||||
transformer = BRepBuilderAPI_Transform(shape, transform)
|
||||
result = cq.Shape(transformer.Shape())
|
||||
|
||||
return OCCGeometryObject(result, {"type": "translated"})
|
||||
return OCCGeometryObject(transformer.Shape(), {"type": "translated"})
|
||||
|
||||
def rotate(
|
||||
self,
|
||||
@@ -519,30 +453,19 @@ class OCGeometryKernel(GeometryKernel):
|
||||
origin: Tuple[float, float, float] = (0, 0, 0),
|
||||
) -> GeometryObject:
|
||||
"""Rotate a body around an axis."""
|
||||
import cadquery as cq
|
||||
import math
|
||||
|
||||
cq_obj = self._get_cq_obj(body)
|
||||
|
||||
if isinstance(cq_obj, cq.Workplane):
|
||||
result = cq_obj.rotate(origin, axis, math.degrees(angle))
|
||||
else:
|
||||
shape = self._get_shape(body)
|
||||
from OCP.BRepBuilderAPI import BRepBuilderAPI_Transform
|
||||
from OCP.gp import gp_Trsf, gp_Ax1, gp_Pnt, gp_Dir, gp_Vec
|
||||
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)
|
||||
result = cq.Shape(transformer.Shape())
|
||||
|
||||
return OCCGeometryObject(result, {"type": "rotated"})
|
||||
return OCCGeometryObject(transformer.Shape(), {"type": "rotated"})
|
||||
|
||||
def scale(self, body: GeometryObject, factor: float) -> GeometryObject:
|
||||
"""Scale a body uniformly."""
|
||||
import cadquery as cq
|
||||
|
||||
shape = self._get_shape(body)
|
||||
from OCP.BRepBuilderAPI import BRepBuilderAPI_Transform
|
||||
from OCP.gp import gp_Trsf
|
||||
@@ -551,7 +474,7 @@ class OCGeometryKernel(GeometryKernel):
|
||||
transform.SetScale(factor)
|
||||
transformer = BRepBuilderAPI_Transform(shape, transform)
|
||||
|
||||
return OCCGeometryObject(cq.Shape(transformer.Shape()), {"type": "scaled"})
|
||||
return OCCGeometryObject(transformer.Shape(), {"type": "scaled"})
|
||||
|
||||
def mirror(
|
||||
self,
|
||||
@@ -560,8 +483,6 @@ class OCGeometryKernel(GeometryKernel):
|
||||
plane_origin: Tuple[float, float, float] = (0, 0, 0),
|
||||
) -> GeometryObject:
|
||||
"""Mirror a body across a plane."""
|
||||
import cadquery as cq
|
||||
|
||||
shape = self._get_shape(body)
|
||||
from OCP.BRepBuilderAPI import BRepBuilderAPI_Transform
|
||||
from OCP.gp import gp_Trsf, gp_Ax2, gp_Pnt, gp_Dir
|
||||
@@ -571,19 +492,12 @@ class OCGeometryKernel(GeometryKernel):
|
||||
transform.SetMirror(ax2)
|
||||
transformer = BRepBuilderAPI_Transform(shape, transform)
|
||||
|
||||
return OCCGeometryObject(cq.Shape(transformer.Shape()), {"type": "mirrored"})
|
||||
return OCCGeometryObject(transformer.Shape(), {"type": "mirrored"})
|
||||
|
||||
def export_step(self, body: GeometryObject, filepath: str, schema: str = "AP214") -> bool:
|
||||
"""Export to STEP format."""
|
||||
try:
|
||||
import cadquery as cq
|
||||
|
||||
shape = self._get_shape(body)
|
||||
|
||||
if hasattr(shape, "exportStep"):
|
||||
shape.exportStep(filepath)
|
||||
return True
|
||||
|
||||
from OCP.STEPControl import STEPControl_Writer, STEPControl_AsIs
|
||||
from OCP.Interface import Interface_Static
|
||||
|
||||
@@ -594,8 +508,7 @@ class OCGeometryKernel(GeometryKernel):
|
||||
Interface_Static.SetCVal_s("write.step.schema", "AP203")
|
||||
|
||||
writer.Transfer(shape, STEPControl_AsIs)
|
||||
writer.Write(filepath)
|
||||
return True
|
||||
return writer.Write(filepath)
|
||||
except Exception as e:
|
||||
print(f"STEP export error: {e}")
|
||||
return False
|
||||
@@ -603,18 +516,14 @@ class OCGeometryKernel(GeometryKernel):
|
||||
def export_iges(self, body: GeometryObject, filepath: str) -> bool:
|
||||
"""Export to IGES format."""
|
||||
try:
|
||||
import cadquery as cq
|
||||
|
||||
shape = self._get_shape(body)
|
||||
|
||||
from OCP.IGESControl import IGESControl_Writer
|
||||
from OCP.Interface import Interface_Static
|
||||
|
||||
Interface_Static.SetCVal_s("write.iges.schema", "5.3")
|
||||
writer = IGESControl_Writer()
|
||||
writer.AddShape(shape)
|
||||
writer.Write(filepath)
|
||||
return True
|
||||
return writer.Write(filepath)
|
||||
except Exception as e:
|
||||
print(f"IGES export error: {e}")
|
||||
return False
|
||||
@@ -624,14 +533,7 @@ class OCGeometryKernel(GeometryKernel):
|
||||
) -> bool:
|
||||
"""Export to STL format."""
|
||||
try:
|
||||
import cadquery as cq
|
||||
|
||||
shape = self._get_shape(body)
|
||||
|
||||
if hasattr(shape, "exportStl"):
|
||||
shape.exportStl(filepath, tolerance)
|
||||
return True
|
||||
|
||||
from OCP.StlAPI import StlAPI_Writer
|
||||
from OCP.BRepMesh import BRepMesh_IncrementalMesh
|
||||
|
||||
@@ -640,23 +542,28 @@ class OCGeometryKernel(GeometryKernel):
|
||||
|
||||
writer = StlAPI_Writer()
|
||||
writer.ASCIIMode = ascii_mode
|
||||
writer.Write(shape, filepath)
|
||||
return True
|
||||
return writer.Write(shape, filepath)
|
||||
except Exception as e:
|
||||
print(f"STL export error: {e}")
|
||||
return False
|
||||
|
||||
def import_step(self, filepath: str) -> GeometryObject:
|
||||
"""Import from STEP format."""
|
||||
import cadquery as cq
|
||||
from OCP.STEPControl import STEPControl_Reader
|
||||
from OCP.IFSelect import IFSelect_RetDone
|
||||
|
||||
result = cq.importers.importStep(filepath)
|
||||
return OCCGeometryObject(result, {"type": "imported_step"})
|
||||
reader = STEPControl_Reader()
|
||||
status = reader.ReadFile(filepath)
|
||||
|
||||
if status != IFSelect_RetDone:
|
||||
raise ValueError(f"Failed to read STEP file: {filepath}")
|
||||
|
||||
reader.TransferRoots()
|
||||
shape = reader.OneShape()
|
||||
return OCCGeometryObject(shape, {"type": "imported_step"})
|
||||
|
||||
def import_iges(self, filepath: str) -> GeometryObject:
|
||||
"""Import from IGES format."""
|
||||
import cadquery as cq
|
||||
|
||||
from OCP.IGESControl import IGESControl_Reader
|
||||
from OCP.IFSelect import IFSelect_RetDone
|
||||
|
||||
@@ -669,27 +576,14 @@ class OCGeometryKernel(GeometryKernel):
|
||||
reader.TransferRoots()
|
||||
shape = reader.OneShape()
|
||||
|
||||
return OCCGeometryObject(cq.Shape(shape), {"type": "imported_iges"})
|
||||
return OCCGeometryObject(shape, {"type": "imported_iges"})
|
||||
|
||||
def get_mesh(
|
||||
self, body: GeometryObject, tolerance: float = 0.1
|
||||
) -> Tuple[np.ndarray, np.ndarray]:
|
||||
"""Get triangulated mesh for rendering."""
|
||||
import cadquery as cq
|
||||
|
||||
cq_obj = self._get_cq_obj(body)
|
||||
|
||||
if isinstance(cq_obj, cq.Workplane):
|
||||
shape = cq_obj.val()
|
||||
if hasattr(shape, "wrapped"):
|
||||
shape = shape.wrapped
|
||||
else:
|
||||
shape = self._get_shape(body)
|
||||
|
||||
if hasattr(shape, "tessellate"):
|
||||
vertices, faces = shape.tessellate(tolerance)
|
||||
return np.array(vertices), np.array(faces)
|
||||
|
||||
from OCP.BRepMesh import BRepMesh_IncrementalMesh
|
||||
from OCP.TopExp import TopExp_Explorer
|
||||
from OCP.TopAbs import TopAbs_FACE
|
||||
@@ -745,8 +639,6 @@ class OCGeometryKernel(GeometryKernel):
|
||||
|
||||
def get_edges(self, body: GeometryObject) -> Tuple[np.ndarray, np.ndarray]:
|
||||
"""Get edge wireframe for rendering."""
|
||||
import cadquery as cq
|
||||
|
||||
shape = self._get_shape(body)
|
||||
|
||||
from OCP.TopExp import TopExp_Explorer
|
||||
@@ -801,8 +693,6 @@ class OCGeometryKernel(GeometryKernel):
|
||||
|
||||
def get_bounding_box(self, body: GeometryObject) -> Tuple[Point3D, Point3D]:
|
||||
"""Get the bounding box of a body."""
|
||||
import cadquery as cq
|
||||
|
||||
shape = self._get_shape(body)
|
||||
|
||||
from OCP.Bnd import Bnd_Box
|
||||
@@ -817,8 +707,6 @@ class OCGeometryKernel(GeometryKernel):
|
||||
|
||||
def get_volume(self, body: GeometryObject) -> float:
|
||||
"""Calculate the volume of a solid body."""
|
||||
import cadquery as cq
|
||||
|
||||
shape = self._get_shape(body)
|
||||
|
||||
from OCP.GProp import GProp_GProps
|
||||
@@ -831,8 +719,6 @@ class OCGeometryKernel(GeometryKernel):
|
||||
|
||||
def get_surface_area(self, body: GeometryObject) -> float:
|
||||
"""Calculate the surface area of a body."""
|
||||
import cadquery as cq
|
||||
|
||||
shape = self._get_shape(body)
|
||||
|
||||
from OCP.GProp import GProp_GProps
|
||||
@@ -845,8 +731,6 @@ class OCGeometryKernel(GeometryKernel):
|
||||
|
||||
def get_center_of_mass(self, body: GeometryObject) -> Point3D:
|
||||
"""Calculate the center of mass of a solid body."""
|
||||
import cadquery as cq
|
||||
|
||||
shape = self._get_shape(body)
|
||||
|
||||
from OCP.GProp import GProp_GProps
|
||||
|
||||
@@ -74,6 +74,14 @@ class OCCSketch(SketchInterface):
|
||||
# • clear them as a group when the source face is removed
|
||||
self._external_entity_ids: set = set()
|
||||
|
||||
# Centerline entity ids (X and Y reference axes through origin).
|
||||
# These are construction lines that span the sketch and are used
|
||||
# as reference axes for constraining geometry. They are:
|
||||
# • fixed in the solver (never move)
|
||||
# • marked is_construction (excluded from profile detection)
|
||||
# • non-deletable
|
||||
self._centerline_ids: set = set()
|
||||
|
||||
# Track first point as dragged/fixed for solver stability
|
||||
self._first_point_id: Optional[int] = None
|
||||
|
||||
@@ -259,8 +267,14 @@ class OCCSketch(SketchInterface):
|
||||
radius: float,
|
||||
start_point: SketchEntity,
|
||||
end_point: SketchEntity,
|
||||
sweep: Optional[float] = None,
|
||||
) -> OCCSketchEntity:
|
||||
"""Add an arc (tracked only)."""
|
||||
"""Add an arc (tracked only).
|
||||
|
||||
*sweep* is the signed angular span in radians (positive = CCW, negative = CW).
|
||||
When *None* the rendering will infer the shortest path between start and end.
|
||||
"""
|
||||
import math
|
||||
entity_id = self._next_id()
|
||||
|
||||
center_entity = self._entities.get(center.id)
|
||||
@@ -274,10 +288,26 @@ class OCCSketch(SketchInterface):
|
||||
sx, sy = start_entity.geometry
|
||||
ex, ey = end_entity.geometry
|
||||
|
||||
# Infer sweep from geometry when not provided.
|
||||
if sweep is None:
|
||||
sa = math.atan2(sy - cy, sx - cx)
|
||||
ea = math.atan2(ey - cy, ex - cx)
|
||||
sweep = ea - sa
|
||||
while sweep > math.pi:
|
||||
sweep -= 2 * math.pi
|
||||
while sweep < -math.pi:
|
||||
sweep += 2 * math.pi
|
||||
|
||||
entity = OCCSketchEntity(
|
||||
entity_id=entity_id,
|
||||
entity_type="arc",
|
||||
geometry={"center": (cx, cy), "radius": radius, "start": (sx, sy), "end": (ex, ey)},
|
||||
geometry={
|
||||
"center": (cx, cy),
|
||||
"radius": radius,
|
||||
"start": (sx, sy),
|
||||
"end": (ex, ey),
|
||||
"sweep": sweep,
|
||||
},
|
||||
)
|
||||
|
||||
self._entities[entity_id] = entity
|
||||
@@ -286,6 +316,7 @@ class OCCSketch(SketchInterface):
|
||||
"start": start_point.id,
|
||||
"end": end_point.id,
|
||||
"radius": radius,
|
||||
"sweep": sweep,
|
||||
}
|
||||
|
||||
return entity
|
||||
@@ -432,6 +463,67 @@ class OCCSketch(SketchInterface):
|
||||
"""Return the set of external (underlay) entity ids currently in the sketch."""
|
||||
return set(self._external_entity_ids)
|
||||
|
||||
# ── Centerlines (X and Y reference axes) ────────────────────────────
|
||||
|
||||
_CENTERLINE_EXTENT: float = 10000.0 # large enough to span any sketch
|
||||
|
||||
def add_centerlines(self) -> None:
|
||||
"""Add X (horizontal) and Y (vertical) centerlines through the origin.
|
||||
|
||||
These construction lines serve as reference axes for constraining
|
||||
sketch geometry. They are:
|
||||
• Fixed in the solver (never move)
|
||||
• Marked is_construction (excluded from profile / face detection)
|
||||
• Non-deletable
|
||||
• Available as constraint targets (snap, coincident, distance,
|
||||
symmetric, horizontal, vertical, parallel, perpendicular)
|
||||
|
||||
The X centerline runs horizontal (left→right) through origin and
|
||||
the Y centerline runs vertical (bottom→top) through origin. Both
|
||||
extend to ``_CENTERLINE_EXTENT`` in both directions so they span
|
||||
any realistic sketch. If centerlines have already been added this
|
||||
is a no-op.
|
||||
"""
|
||||
if self._centerline_ids:
|
||||
return
|
||||
|
||||
HALF = self._CENTERLINE_EXTENT
|
||||
|
||||
# Origin point — auto-fixed as the first point in the solver
|
||||
origin = self.add_point(0.0, 0.0)
|
||||
origin.is_construction = True
|
||||
|
||||
# X centerline (horizontal)
|
||||
xl = self.add_point(-HALF, 0.0)
|
||||
xl.is_construction = True
|
||||
xr = self.add_point(HALF, 0.0)
|
||||
xr.is_construction = True
|
||||
xline = self.add_line(xl, xr)
|
||||
xline.is_construction = True
|
||||
self.constrain_horizontal(xline)
|
||||
self.constrain_fixed(xl)
|
||||
|
||||
# Y centerline (vertical)
|
||||
yb = self.add_point(0.0, -HALF)
|
||||
yb.is_construction = True
|
||||
yt = self.add_point(0.0, HALF)
|
||||
yt.is_construction = True
|
||||
yline = self.add_line(yb, yt)
|
||||
yline.is_construction = True
|
||||
self.constrain_vertical(yline)
|
||||
self.constrain_fixed(yb)
|
||||
|
||||
self._centerline_ids = {
|
||||
origin.id, xl.id, xr.id, xline.id,
|
||||
yb.id, yt.id, yline.id,
|
||||
}
|
||||
|
||||
self.solve()
|
||||
|
||||
def get_centerline_ids(self) -> set:
|
||||
"""Return the set of centerline entity ids currently in the sketch."""
|
||||
return set(self._centerline_ids)
|
||||
|
||||
def add_rectangle(
|
||||
self, corner1: Tuple[float, float], corner2: Tuple[float, float]
|
||||
) -> List[OCCSketchEntity]:
|
||||
@@ -534,6 +626,17 @@ class OCCSketch(SketchInterface):
|
||||
elif ctype == "equal_radius":
|
||||
# tracked only (no solver entity)
|
||||
pass
|
||||
elif ctype == "diameter":
|
||||
# Update circle radius in sketch data
|
||||
circle_id = ids[0]
|
||||
if circle_id in self._circles:
|
||||
center_id, _ = self._circles[circle_id]
|
||||
radius = params[0] / 2.0
|
||||
self._circles[circle_id] = (center_id, radius)
|
||||
ent = self._entities.get(circle_id)
|
||||
if ent is not None and ent.geometry is not None:
|
||||
cx, cy = ent.geometry[0] if isinstance(ent.geometry[0], tuple) else ent.geometry
|
||||
ent.geometry = ((cx, cy), radius)
|
||||
else:
|
||||
return False
|
||||
return True
|
||||
@@ -696,6 +799,21 @@ class OCCSketch(SketchInterface):
|
||||
self._record_constraint("equal_radius", (circle1.id, circle2.id))
|
||||
return True
|
||||
|
||||
def constrain_diameter(self, circle: SketchEntity, diameter: float) -> bool:
|
||||
"""Set the diameter of a circle."""
|
||||
radius = diameter / 2.0
|
||||
# Update the circle's radius in the sketch
|
||||
if circle.id in self._circles:
|
||||
center_id, _ = self._circles[circle.id]
|
||||
self._circles[circle.id] = (center_id, radius)
|
||||
# Update the entity geometry
|
||||
ent = self._entities.get(circle.id)
|
||||
if ent is not None:
|
||||
cx, cy = ent.geometry
|
||||
ent.geometry = ((cx, cy), radius)
|
||||
self._record_constraint("diameter", (circle.id,), (diameter,))
|
||||
return True
|
||||
|
||||
def constrain_fixed(self, entity: SketchEntity) -> bool:
|
||||
"""Fix an entity in place via dragged constraint."""
|
||||
ent = self._entities.get(entity.id)
|
||||
@@ -838,7 +956,11 @@ class OCCSketch(SketchInterface):
|
||||
if entity_id in self._external_entity_ids:
|
||||
continue
|
||||
center_entity = self._entities.get(center_id)
|
||||
circle_ent = self._entities.get(entity_id)
|
||||
if center_entity and center_entity.geometry and not center_entity.is_external:
|
||||
# Skip construction circles — they're reference geometry.
|
||||
if circle_ent is not None and circle_ent.is_construction:
|
||||
continue
|
||||
cx, cy = center_entity.geometry
|
||||
face_dict = {
|
||||
"outer": {"type": "circle", "center": (cx, cy), "radius": radius},
|
||||
@@ -880,13 +1002,13 @@ class OCCSketch(SketchInterface):
|
||||
def get_polygon_points(self) -> List[Point2D]:
|
||||
"""Get ordered polygon points from connected lines (uses solved positions).
|
||||
|
||||
External (underlay) lines are skipped — they are reference geometry
|
||||
only, not part of the sketch profile.
|
||||
External (underlay) and construction lines are skipped — they are
|
||||
reference geometry only, not part of the sketch profile.
|
||||
"""
|
||||
adjacency: Dict[Tuple[float, float], List[Tuple[float, float]]] = {}
|
||||
|
||||
for entity in self._entities.values():
|
||||
if entity.entity_type == "line" and entity.geometry and not entity.is_external:
|
||||
if entity.entity_type == "line" and entity.geometry and not entity.is_external and not entity.is_construction:
|
||||
p1, p2 = entity.geometry
|
||||
if p1 not in adjacency:
|
||||
adjacency[p1] = []
|
||||
@@ -926,21 +1048,63 @@ class OCCSketch(SketchInterface):
|
||||
def _line_segments(self) -> List[Tuple[Tuple[float, float], Tuple[float, float]]]:
|
||||
"""Current line segments as world-coordinate tuples (uses solved positions).
|
||||
|
||||
External (underlay) lines are excluded: they're reference geometry
|
||||
projected from a 3D face, not user-drawn sketch profile, and must
|
||||
not contribute to detect_faces / get_geometry / extrusion.
|
||||
Returns both straight line segments AND tessellated arc segments so
|
||||
that arcs participate in closed-loop / face detection. Construction
|
||||
and external entities are excluded — they're reference geometry and
|
||||
must not affect the sketch profile.
|
||||
|
||||
Tessellation density: roughly 12 segments per π radians of arc sweep,
|
||||
which gives smooth-looking closed loops for face detection.
|
||||
"""
|
||||
segs: List[Tuple[Tuple[float, float], Tuple[float, float]]] = []
|
||||
|
||||
# ── Straight line segments ──
|
||||
for line_id, (sid, eid2) in self._lines.items():
|
||||
# Skip external/underlay lines — they live in the solver for
|
||||
# constraint referencing, but are not part of the sketch profile.
|
||||
if line_id in self._external_entity_ids:
|
||||
continue
|
||||
line_ent = self._entities.get(line_id)
|
||||
if line_ent is not None and line_ent.is_construction:
|
||||
continue
|
||||
s_ent = self._entities.get(sid)
|
||||
e_ent = self._entities.get(eid2)
|
||||
if s_ent and e_ent and s_ent.geometry and e_ent.geometry:
|
||||
segs.append(((float(s_ent.geometry[0]), float(s_ent.geometry[1])),
|
||||
(float(e_ent.geometry[0]), float(e_ent.geometry[1]))))
|
||||
|
||||
# ── Arc segments (tessellated) ──
|
||||
for arc_id, arc_data in self._arcs.items():
|
||||
arc_ent = self._entities.get(arc_id)
|
||||
if arc_ent is not None and arc_ent.is_construction:
|
||||
continue
|
||||
center_id = arc_data.get("center")
|
||||
start_id = arc_data.get("start")
|
||||
end_id = arc_data.get("end")
|
||||
radius = arc_data.get("radius", 0.0)
|
||||
sweep = arc_data.get("sweep")
|
||||
if sweep is None or radius <= 0:
|
||||
continue
|
||||
c_ent = self._entities.get(center_id)
|
||||
s_ent = self._entities.get(start_id)
|
||||
e_ent = self._entities.get(end_id)
|
||||
if not (c_ent and s_ent and e_ent
|
||||
and c_ent.geometry and s_ent.geometry and e_ent.geometry):
|
||||
continue
|
||||
cx, cy = c_ent.geometry
|
||||
sx, sy = s_ent.geometry
|
||||
start_angle = math.atan2(sy - cy, sx - cx)
|
||||
# ~12 segments per π radians
|
||||
n = max(4, int(abs(sweep) / (math.pi / 12)))
|
||||
for i in range(n):
|
||||
t1 = i / n
|
||||
t2 = (i + 1) / n
|
||||
a1 = start_angle + t1 * sweep
|
||||
a2 = start_angle + t2 * sweep
|
||||
p1 = (cx + radius * math.cos(a1),
|
||||
cy + radius * math.sin(a1))
|
||||
p2 = (cx + radius * math.cos(a2),
|
||||
cy + radius * math.sin(a2))
|
||||
segs.append((p1, p2))
|
||||
|
||||
return segs
|
||||
|
||||
def get_closed_loops(self) -> List[Dict[str, Any]]:
|
||||
@@ -1021,16 +1185,24 @@ class OCCSketch(SketchInterface):
|
||||
return loops
|
||||
|
||||
@staticmethod
|
||||
def _point_in_polygon(pt: Tuple[float, float], poly: List[Tuple[float, float]]) -> bool:
|
||||
def _point_in_polygon(
|
||||
pt: Tuple[float, float],
|
||||
poly: List[Tuple[float, float]],
|
||||
margin: float = 0.0,
|
||||
) -> bool:
|
||||
"""Ray-casting point-in-polygon test.
|
||||
|
||||
Returns *True* only for strictly interior points. Points on the
|
||||
boundary (within 1e-9) are considered *outside* so that the outer
|
||||
boundary of a nested shape doesn't falsely contain another loop whose
|
||||
representative point happens to land on that boundary.
|
||||
Returns *True* for points strictly inside the polygon. Points on
|
||||
the boundary (within eps=1e-9) are *outside* by default so the
|
||||
outer boundary of a nested shape doesn't falsely contain a hole's
|
||||
rep point. When *margin* > 0, points that are within that many
|
||||
world-unit of the boundary are also treated as inside — used by
|
||||
``_loop_contains`` to prevent float rounding from breaking
|
||||
thin-wall nesting detection.
|
||||
"""
|
||||
x, y = pt
|
||||
eps = 1e-9
|
||||
eps = 1e-9 # strict boundary rejection
|
||||
margin = float(margin)
|
||||
n = len(poly)
|
||||
inside = False
|
||||
j = n - 1
|
||||
@@ -1039,11 +1211,17 @@ class OCCSketch(SketchInterface):
|
||||
xj, yj = poly[j]
|
||||
# Point-on-segment test — exclude strict boundary hits.
|
||||
# First check bounding box of the segment.
|
||||
if min(xi, xj) - eps <= x <= max(xi, xj) + eps and min(yi, yj) - eps <= y <= max(yi, yj) + eps:
|
||||
bbox_tol = max(eps, margin)
|
||||
if min(xi, xj) - bbox_tol <= x <= max(xi, xj) + bbox_tol and min(yi, yj) - bbox_tol <= y <= max(yi, yj) + bbox_tol:
|
||||
# Check collinearity
|
||||
cross = (x - xi) * (yj - yi) - (y - yi) * (xj - xi)
|
||||
if abs(cross) < eps:
|
||||
return False # on boundary
|
||||
abs_cross = abs(cross)
|
||||
if abs_cross < eps:
|
||||
# Strictly on boundary — return False unless margin says otherwise.
|
||||
if margin > 0 and abs_cross < margin:
|
||||
pass # fall through to ray-cast below
|
||||
else:
|
||||
return False
|
||||
if ((yi > y) != (yj > y)) and (x < (xj - xi) * (y - yi) / (yj - yi + 1e-30) + xi):
|
||||
inside = not inside
|
||||
j = i
|
||||
@@ -1051,34 +1229,78 @@ class OCCSketch(SketchInterface):
|
||||
|
||||
@staticmethod
|
||||
def _loop_contains(inner: Dict[str, Any], outer: Dict[str, Any]) -> bool:
|
||||
"""Does ``outer`` fully enclose ``inner``? Uses a representative point +
|
||||
boundary tests on ``outer`` (only valid when ``outer`` != ``inner``)."""
|
||||
rep = OCCSketch._loop_rep_point(inner)
|
||||
if outer["type"] == "polygon":
|
||||
return OCCSketch._point_in_polygon(rep, outer["points"])
|
||||
else: # circle
|
||||
cx, cy = outer["center"]
|
||||
return math.hypot(rep[0] - cx, rep[1] - cy) < outer["radius"]
|
||||
"""Does ``outer`` fully enclose ``inner``?
|
||||
|
||||
For polygon-polygon: checks that ALL vertices of ``inner`` are strictly
|
||||
inside ``outer`` using ray-casting. This is robust for convex polygons
|
||||
and avoids the representative-point issue where a large nested loop's
|
||||
centroid lands inside an inner loop.
|
||||
|
||||
For circle-in-polygon: checks the circle centre is inside the polygon
|
||||
(vertex check would be too strict for tessellated arc segments).
|
||||
|
||||
For circle-in-circle: checks distance between centres + inner radius
|
||||
< outer radius + margin.
|
||||
|
||||
For polygon-in-circle: checks all polygon vertices are inside the
|
||||
circle.
|
||||
"""
|
||||
eps = 1e-3
|
||||
|
||||
if outer["type"] == "circle":
|
||||
ox, oy = outer["center"]
|
||||
orad = outer["radius"]
|
||||
if inner["type"] == "circle":
|
||||
# Two circles: centre distance + inner radius < outer radius
|
||||
dx = inner["center"][0] - ox
|
||||
dy = inner["center"][1] - oy
|
||||
return math.hypot(dx, dy) + inner["radius"] < orad + eps
|
||||
else:
|
||||
# Polygon in circle: all vertices inside
|
||||
pts = inner["points"]
|
||||
if len(pts) > 1 and pts[0] == pts[-1]:
|
||||
pts = pts[:-1]
|
||||
for pt in pts:
|
||||
if math.hypot(pt[0] - ox, pt[1] - oy) > orad - eps:
|
||||
return False
|
||||
return True
|
||||
else:
|
||||
# outer is polygon
|
||||
if inner["type"] == "circle":
|
||||
# Circle in polygon: centre must be inside with margin
|
||||
cx, cy = inner["center"]
|
||||
return OCCSketch._point_in_polygon(
|
||||
(cx, cy), outer["points"], margin=1e-3
|
||||
)
|
||||
else:
|
||||
# Polygon in polygon: ALL inner vertices inside outer
|
||||
pts = inner["points"]
|
||||
if len(pts) > 1 and pts[0] == pts[-1]:
|
||||
pts = pts[:-1]
|
||||
for pt in pts:
|
||||
if not OCCSketch._point_in_polygon(
|
||||
pt, outer["points"], margin=eps
|
||||
):
|
||||
return False
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def _loop_rep_point(loop: Dict[str, Any]) -> Tuple[float, float]:
|
||||
"""An interior representative point inside a loop.
|
||||
|
||||
For polygons we use the midpoint between the centroid and the first
|
||||
vertex (而不是 centroid 本身): a nested shape centered on the polygon's
|
||||
centroid (e.g. a circle inside a rectangle, both centered on the same
|
||||
point) would otherwise make the polygon's rep point coincide with the
|
||||
hole and break containment tests. This midpoint stays inside convex
|
||||
loops and is unlikely to land on a nested feature's center.
|
||||
Only used for circle-in-polygon containment checks (polygon-in-polygon
|
||||
uses all-vertex containment). Returns the centroid for polygons and
|
||||
the centre for circles.
|
||||
"""
|
||||
if loop["type"] == "polygon":
|
||||
pts = loop["points"][:-1] if len(loop["points"]) > 1 and loop["points"][0] == loop["points"][-1] else loop["points"]
|
||||
n = len(pts)
|
||||
if n < 3:
|
||||
return loop.get("center", (0.0, 0.0))
|
||||
sx = sum(p[0] for p in pts) / n
|
||||
sy = sum(p[1] for p in pts) / n
|
||||
v0 = pts[0]
|
||||
return ((sx + v0[0]) / 2.0, (sy + v0[1]) / 2.0)
|
||||
return loop["center"]
|
||||
return (sx, sy)
|
||||
return loop.get("center", (0.0, 0.0))
|
||||
|
||||
@staticmethod
|
||||
def _loop_area(loop: Dict[str, Any]) -> float:
|
||||
@@ -1165,6 +1387,28 @@ class OCCSketch(SketchInterface):
|
||||
best = face
|
||||
return best
|
||||
|
||||
@staticmethod
|
||||
def _loop_signed_area(loop: Dict[str, Any]) -> float:
|
||||
"""Signed area of a loop. Positive = CCW, negative = CW.
|
||||
|
||||
Circles are treated as CCW (positive area) because
|
||||
``gp_Circ`` / ``gp_Ax2`` creates edges with CCW parametric
|
||||
direction when looking against the normal.
|
||||
"""
|
||||
if loop["type"] == "circle":
|
||||
r = loop.get("radius", 0.0)
|
||||
return math.pi * r * r # always positive (CCW)
|
||||
pts = loop["points"]
|
||||
if len(pts) < 3:
|
||||
return 0.0
|
||||
area = 0.0
|
||||
n = len(pts) - 1 # last point == first for closed loops
|
||||
for i in range(n):
|
||||
x1, y1 = pts[i]
|
||||
x2, y2 = pts[i + 1]
|
||||
area += x1 * y2 - x2 * y1
|
||||
return area / 2.0
|
||||
|
||||
def build_face_geometry(self, face: Dict[str, Any]) -> OCCGeometryObject:
|
||||
"""Build an OCC face (outer boundary + inner holes) on the workplane.
|
||||
|
||||
@@ -1173,6 +1417,13 @@ class OCCSketch(SketchInterface):
|
||||
sketch's 3D plane (not necessarily XY). The returned object stores
|
||||
the raw OCC face in ``.shape`` and the plane normal in
|
||||
``metadata["normal"]`` for the extrude kernel.
|
||||
|
||||
Hole wires are oriented to have OPPOSITE geometric winding relative
|
||||
to the outer wire, which is what OCC's face builder expects for
|
||||
proper hole treatment. Previous code unconditionally reversed ALL
|
||||
hole wires, which produced solid islands (not holes) whenever the
|
||||
outer loop had clockwise winding — e.g. after dragging a rectangle
|
||||
from top-left to bottom-right.
|
||||
"""
|
||||
from OCP.BRepBuilderAPI import (
|
||||
BRepBuilderAPI_MakePolygon, BRepBuilderAPI_MakeFace,
|
||||
@@ -1181,15 +1432,15 @@ class OCCSketch(SketchInterface):
|
||||
from OCP.gp import gp_Pnt, gp_Circ, gp_Ax2, gp_Dir
|
||||
from OCP.TopoDS import TopoDS as _TopoDS
|
||||
|
||||
def wire_loop(loop: Dict[str, Any], is_hole: bool = False):
|
||||
def _wire_from_loop(loop: Dict[str, Any]):
|
||||
"""Build a wire from a loop dict. No orientation adjustment."""
|
||||
if loop["type"] == "polygon":
|
||||
mp = BRepBuilderAPI_MakePolygon()
|
||||
for (pu, pv) in loop["points"]:
|
||||
mp.Add(self._uv_to_world(pu, pv))
|
||||
mp.Close()
|
||||
mp.Build()
|
||||
w = mp.Wire()
|
||||
else:
|
||||
return mp.Wire()
|
||||
cu, cv = loop["center"]
|
||||
r = loop["radius"]
|
||||
circ = gp_Circ(self._circle_axis(cu, cv), r)
|
||||
@@ -1198,15 +1449,23 @@ class OCCSketch(SketchInterface):
|
||||
mw = BRepBuilderAPI_MakeWire()
|
||||
mw.Add(me.Edge())
|
||||
mw.Build()
|
||||
w = mw.Wire()
|
||||
if is_hole:
|
||||
w = _TopoDS.Wire_s(w.Reversed()) # reverse so OCC treats it as a hole
|
||||
return w
|
||||
return mw.Wire()
|
||||
|
||||
outer_loop = face["outer"]
|
||||
outer_wire = _wire_from_loop(outer_loop)
|
||||
outer_winding = self._loop_signed_area(outer_loop)
|
||||
|
||||
outer_wire = wire_loop(face["outer"], is_hole=False)
|
||||
face_maker = BRepBuilderAPI_MakeFace(outer_wire, True)
|
||||
for h in face["holes"]:
|
||||
face_maker.Add(wire_loop(h, is_hole=True))
|
||||
hole_wire = _wire_from_loop(h)
|
||||
hole_winding = self._loop_signed_area(h)
|
||||
# OCC expects hole wires to have OPPOSITE winding to the outer
|
||||
# wire (material on the other side). We reverse the hole wire
|
||||
# only when its natural winding matches the outer's; if they
|
||||
# already differ the wire is left as-is.
|
||||
if (hole_winding >= 0 and outer_winding >= 0) or (hole_winding < 0 and outer_winding < 0):
|
||||
hole_wire = _TopoDS.Wire_s(hole_wire.Reversed())
|
||||
face_maker.Add(hole_wire)
|
||||
face_maker.Build()
|
||||
occ_face = face_maker.Face()
|
||||
|
||||
@@ -1240,6 +1499,7 @@ class OCCSketch(SketchInterface):
|
||||
self._constraint_count = 0
|
||||
self._constraint_log.clear()
|
||||
self._external_entity_ids.clear()
|
||||
self._centerline_ids.clear()
|
||||
self._first_point_id = None
|
||||
|
||||
def _prune_log_for(self, removed_ids: set) -> None:
|
||||
@@ -1259,10 +1519,17 @@ class OCCSketch(SketchInterface):
|
||||
constraint that referenced it, rebuilds the whole solver system from
|
||||
the surviving points/lines + pruned log, and re-solves. The line's
|
||||
endpoint points are NOT removed — only the line segment.
|
||||
|
||||
Note: centerlines (reference axes through origin) cannot be deleted.
|
||||
"""
|
||||
if line.id not in self._lines or line.id not in self._entities:
|
||||
return False
|
||||
|
||||
# Centerlines are permanent reference axes — refuse deletion.
|
||||
if line.id in self._centerline_ids:
|
||||
logger.debug("Refusing to delete centerline")
|
||||
return False
|
||||
|
||||
del self._lines[line.id]
|
||||
if line.id in self._entities:
|
||||
del self._entities[line.id]
|
||||
@@ -1298,10 +1565,17 @@ class OCCSketch(SketchInterface):
|
||||
All constraints that reference the point OR the removed lines are
|
||||
pruned from the log, the solver is rebuilt from survivors, labels are
|
||||
re-derived, and the system is re-solved.
|
||||
|
||||
Note: centerlines (reference axes through origin) cannot be deleted.
|
||||
"""
|
||||
if point.id not in self._entities or point.id not in self._points:
|
||||
return False
|
||||
|
||||
# Centerline points are permanent reference anchors — refuse deletion.
|
||||
if point.id in self._centerline_ids:
|
||||
logger.debug("Refusing to delete centerline point")
|
||||
return False
|
||||
|
||||
removed_ids: set = {point.id}
|
||||
# Remove lines that use this point as an endpoint.
|
||||
removed_line_keys: List[int] = [
|
||||
@@ -1327,6 +1601,18 @@ class OCCSketch(SketchInterface):
|
||||
del self._circles[cid]
|
||||
if cid in self._entities:
|
||||
del self._entities[cid]
|
||||
# Arcs referencing this point (as centre, start, or end) are invalid.
|
||||
removed_arc_keys: List[int] = [
|
||||
aid for aid, adata in list(self._arcs.items())
|
||||
if adata.get("center") == point.id
|
||||
or adata.get("start") == point.id
|
||||
or adata.get("end") == point.id
|
||||
]
|
||||
for aid in removed_arc_keys:
|
||||
removed_ids.add(aid)
|
||||
del self._arcs[aid]
|
||||
if aid in self._entities:
|
||||
del self._entities[aid]
|
||||
|
||||
self._prune_log_for(removed_ids)
|
||||
self._rebuild_solver()
|
||||
@@ -1413,3 +1699,205 @@ class OCCSketch(SketchInterface):
|
||||
return self._solver.dof() == 0
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
# ─── Serialization (used by fluency.io.project_io) ─────────────────────
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Serialize the sketch to a plain-dict for JSON storage.
|
||||
|
||||
Captures: the workplane, every entity (with its current geometry and
|
||||
flags), the constraint log, and the entity counter. Live solver
|
||||
handles are intentionally NOT saved — the consumer must call
|
||||
:meth:`from_dict` (or :meth:`rebuild_from_dict`) to rebuild the
|
||||
SolveSpace system before solving again.
|
||||
"""
|
||||
# Sort entities by id so replay order is deterministic and matches
|
||||
# creation order (ids are assigned monotonically by ``_next_id``).
|
||||
entities_payload: List[Dict[str, Any]] = []
|
||||
for eid in sorted(self._entities.keys()):
|
||||
ent = self._entities[eid]
|
||||
entities_payload.append(
|
||||
{
|
||||
"id": eid,
|
||||
"type": ent.entity_type,
|
||||
# geometry shape varies: point→(x,y), line→((x1,y1),(x2,y2)),
|
||||
# circle→((cx,cy),r), arc→dict. All JSON-friendly.
|
||||
"geometry": ent.geometry,
|
||||
"is_construction": bool(ent.is_construction),
|
||||
"is_external": bool(ent.is_external),
|
||||
"constraints": list(ent.constraints),
|
||||
}
|
||||
)
|
||||
|
||||
# Sets become sorted lists for JSON. ``labels`` inside constraint_log
|
||||
# is a set on the wire; convert to sorted list for JSON round-trip.
|
||||
constraint_log_payload: List[Dict[str, Any]] = []
|
||||
for entry in self._constraint_log:
|
||||
constraint_log_payload.append(
|
||||
{
|
||||
"type": entry["type"],
|
||||
"ids": list(entry["ids"]),
|
||||
"params": list(entry["params"]),
|
||||
"labels": sorted(entry["labels"]),
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"wp_origin": list(self._wp_origin),
|
||||
"wp_normal": list(self._wp_normal),
|
||||
"wp_x_dir": list(self._wp_x_dir),
|
||||
"wp_y_dir": list(self._wp_y_dir),
|
||||
"entity_counter": self._entity_counter,
|
||||
"first_point_id": self._first_point_id,
|
||||
"external_entity_ids": sorted(self._external_entity_ids),
|
||||
"centerline_ids": sorted(self._centerline_ids),
|
||||
"constraint_count": self._constraint_count,
|
||||
"entities": entities_payload,
|
||||
"constraint_log": constraint_log_payload,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> "OCCSketch":
|
||||
"""Build a fresh OCCSketch that reproduces the saved state.
|
||||
|
||||
Replays the construction sequence (points → lines → circles → arcs,
|
||||
respecting external/centerline flags) and re-applies every constraint
|
||||
in the saved log. The SolveSpace solver is deterministic for a given
|
||||
input, so the post-solve state matches the saved one.
|
||||
"""
|
||||
sk = cls()
|
||||
sk.rebuild_from_dict(data)
|
||||
return sk
|
||||
|
||||
def rebuild_from_dict(self, data: Dict[str, Any]) -> None:
|
||||
"""In-place restore from a dict produced by :meth:`to_dict`.
|
||||
|
||||
Wipes the current solver/state and re-creates every entity in id
|
||||
order so :attr:`_first_point_id` is anchored correctly. Existing
|
||||
callers (notably the solver-rebuild path on entity delete) don't use
|
||||
this; only the project load path does.
|
||||
"""
|
||||
# Wipe solver + trackers (don't lose the workplane yet — we set it
|
||||
# explicitly below).
|
||||
self.clear()
|
||||
self._external_entity_ids.clear()
|
||||
self._centerline_ids.clear()
|
||||
|
||||
# 1. Workplane.
|
||||
self.set_workplane(
|
||||
tuple(data["wp_origin"]),
|
||||
tuple(data["wp_normal"]),
|
||||
tuple(data["wp_x_dir"]),
|
||||
)
|
||||
|
||||
# 2. Force the entity counter so the replay assigns the same ids as
|
||||
# the saved sketch — the constraint log references those ids.
|
||||
self._entity_counter = int(data.get("entity_counter", 0))
|
||||
|
||||
# 3. Replay entities in id order. We need the OCCSketchEntity
|
||||
# objects back (for arc center/start/end lookups), so we
|
||||
# reconstruct by id and let ``_next_id`` advance the counter.
|
||||
entities_by_id: Dict[int, OCCSketchEntity] = {}
|
||||
for entry in data.get("entities", []):
|
||||
eid = int(entry["id"])
|
||||
# Ensure the next _next_id() call returns eid.
|
||||
self._entity_counter = eid - 1
|
||||
etype = entry["type"]
|
||||
geom = entry.get("geometry")
|
||||
is_external = bool(entry.get("is_external", False))
|
||||
|
||||
if etype == "point":
|
||||
x, y = float(geom[0]), float(geom[1])
|
||||
if is_external:
|
||||
ent = self.add_external_point(x, y)
|
||||
else:
|
||||
ent = self.add_point(x, y)
|
||||
elif etype == "line":
|
||||
# line geometry is ((x1,y1),(x2,y2)); we already know the
|
||||
# endpoints exist as point entities. Look them up by saved
|
||||
# position via _points (which was just populated above).
|
||||
(x1, y1), (x2, y2) = geom
|
||||
s_id = self._find_point_at(x1, y1)
|
||||
e_id = self._find_point_at(x2, y2)
|
||||
if s_id is None or e_id is None:
|
||||
logger.warning(
|
||||
"Skipping line %s during load: endpoints not found", eid
|
||||
)
|
||||
continue
|
||||
if is_external:
|
||||
ent = self.add_external_line(entities_by_id[s_id], entities_by_id[e_id])
|
||||
else:
|
||||
ent = self.add_line(entities_by_id[s_id], entities_by_id[e_id])
|
||||
elif etype == "circle":
|
||||
(cx, cy), radius = geom
|
||||
c_id = self._find_point_at(cx, cy)
|
||||
if c_id is None:
|
||||
logger.warning(
|
||||
"Skipping circle %s during load: center not found", eid
|
||||
)
|
||||
continue
|
||||
ent = self.add_circle(entities_by_id[c_id], float(radius))
|
||||
elif etype == "arc":
|
||||
center_pos = tuple(geom["center"])
|
||||
start_pos = tuple(geom["start"])
|
||||
end_pos = tuple(geom["end"])
|
||||
radius = float(geom["radius"])
|
||||
sweep = float(geom.get("sweep", 0.0))
|
||||
c_id = self._find_point_at(*center_pos)
|
||||
s_id = self._find_point_at(*start_pos)
|
||||
e_id = self._find_point_at(*end_pos)
|
||||
if c_id is None or s_id is None or e_id is None:
|
||||
logger.warning(
|
||||
"Skipping arc %s during load: endpoints not found", eid
|
||||
)
|
||||
continue
|
||||
ent = self.add_arc(
|
||||
entities_by_id[c_id],
|
||||
radius,
|
||||
entities_by_id[s_id],
|
||||
entities_by_id[e_id],
|
||||
sweep=sweep,
|
||||
)
|
||||
else:
|
||||
logger.warning("Unknown sketch entity type %r; skipping", etype)
|
||||
continue
|
||||
|
||||
# Restore the per-entity UI flags / labels that aren't carried
|
||||
# by the add_* methods themselves.
|
||||
ent.is_construction = bool(entry.get("is_construction", False))
|
||||
ent.constraints = list(entry.get("constraints", []))
|
||||
entities_by_id[eid] = ent
|
||||
|
||||
# 4. Replay constraint log. ``_apply_constraint_log`` re-issues the
|
||||
# solver call and pushes back into the entity tracker via
|
||||
# ``entity.constraints``. We don't double-record into the log
|
||||
# itself (the log was already cleared by ``clear()`` and we
|
||||
# re-populate it here, so the live ``_constraint_count`` will
|
||||
# reflect the saved state at the end).
|
||||
for entry in data.get("constraint_log", []):
|
||||
self._record_constraint(
|
||||
entry["type"],
|
||||
tuple(entry["ids"]),
|
||||
tuple(entry.get("params", ())),
|
||||
tuple(entry.get("labels", ())),
|
||||
)
|
||||
# Re-apply the live solver calls AFTER the log is restored, so that
|
||||
# the constraint tracker matches the solver state on a fresh solve.
|
||||
for entry in self._constraint_log:
|
||||
self._apply_constraint_log(entry)
|
||||
|
||||
# 5. Restore external / centerline id sets. ``add_external_*`` adds
|
||||
# to the set internally; if the entity's id was a regular entity
|
||||
# for some reason (legacy / hand-edited file), fold it in too so
|
||||
# the saved flag is authoritative.
|
||||
for eid in data.get("external_entity_ids", []):
|
||||
self._external_entity_ids.add(int(eid))
|
||||
for eid in data.get("centerline_ids", []):
|
||||
self._centerline_ids.add(int(eid))
|
||||
|
||||
def _find_point_at(self, x: float, y: float, tol: float = 1e-6) -> Optional[int]:
|
||||
"""Return the entity id of a point sitting at UV ``(x, y)`` (within tol)."""
|
||||
for pid, pos in self._points.items():
|
||||
if abs(pos[0] - x) < tol and abs(pos[1] - y) < tol:
|
||||
return pid
|
||||
return None
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
"""I/O module: project save/load."""
|
||||
|
||||
from fluency.io.project_io import save_project, load_project, project_zip_path
|
||||
|
||||
__all__ = ["save_project", "load_project", "project_zip_path"]
|
||||
@@ -0,0 +1,734 @@
|
||||
"""Project save/load — ``.fluency`` ZIP files.
|
||||
|
||||
The on-disk format is a single ZIP archive:
|
||||
|
||||
project.json # project tree: components, sketches, bodies,
|
||||
# workplanes, assemblies, connectors, view state
|
||||
bodies/<id>.step # one STEP file per Body (BRep geometry)
|
||||
sketches/<id>/meta.json # sketch entities + constraints (kept separately
|
||||
# so a single huge sketch doesn't bloat the
|
||||
# main project.json)
|
||||
sketches/<id>/solved.step # the sketch's solved face geometry
|
||||
|
||||
Sketch constraint solving and 3D body geometry are both preserved by using
|
||||
OpenCASCADE's native STEP exporter (which is lossless for BRep). Everything
|
||||
else is JSON.
|
||||
|
||||
The :func:`save_project` function is the entry point used by the File menu.
|
||||
The :func:`load_project` function returns a fully populated
|
||||
:class:`fluency.models.Project` (with a fresh ``OCGeometryKernel``) and an
|
||||
optional view-state dict that the main window can hand back to the renderer
|
||||
to restore the camera.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
import zipfile
|
||||
from dataclasses import asdict, is_dataclass
|
||||
from datetime import datetime
|
||||
from typing import Any, Callable, Dict, List, Optional, Tuple
|
||||
|
||||
import numpy as np
|
||||
|
||||
from fluency.models.data_model import (
|
||||
Assembly,
|
||||
AssemblyComponent,
|
||||
AssemblyConnection,
|
||||
Body,
|
||||
Component,
|
||||
Connector,
|
||||
Project,
|
||||
Sketch,
|
||||
Workplane,
|
||||
)
|
||||
from fluency.geometry_occ.kernel import OCCGeometryObject, OCGeometryKernel
|
||||
from fluency.geometry_occ.sketch import OCCSketch
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ── JSON-friendly type coercion ─────────────────────────────────────────────
|
||||
|
||||
|
||||
def _json_default(obj: Any) -> Any:
|
||||
"""Default JSON encoder for numpy / dataclass / datetime values."""
|
||||
if isinstance(obj, np.ndarray):
|
||||
return obj.tolist()
|
||||
if isinstance(obj, (datetime,)):
|
||||
return obj.isoformat()
|
||||
if isinstance(obj, (set, frozenset)):
|
||||
return sorted(obj)
|
||||
if isinstance(obj, tuple):
|
||||
return list(obj)
|
||||
if is_dataclass(obj):
|
||||
return asdict(obj)
|
||||
raise TypeError(f"Object of type {type(obj).__name__} is not JSON serializable")
|
||||
|
||||
|
||||
def _to_json(data: Any) -> str:
|
||||
return json.dumps(data, default=_json_default, indent=2, sort_keys=False)
|
||||
|
||||
|
||||
def _coerce_listlike(value: Any) -> List[Any]:
|
||||
"""Cast arrays / tuples / numpy arrays to plain lists for JSON friendliness."""
|
||||
if value is None:
|
||||
return []
|
||||
if isinstance(value, np.ndarray):
|
||||
return value.tolist()
|
||||
if isinstance(value, (list, tuple)):
|
||||
return [v for v in value]
|
||||
return list(value)
|
||||
|
||||
|
||||
def _to_3tuple(value: Any) -> Tuple[float, float, float]:
|
||||
"""Coerce a saved 3-vector to a tuple of floats (for OCC)."""
|
||||
if value is None:
|
||||
return (0.0, 0.0, 0.0)
|
||||
if isinstance(value, np.ndarray):
|
||||
seq = value.tolist()
|
||||
else:
|
||||
seq = list(value)
|
||||
if len(seq) < 3:
|
||||
seq = list(seq) + [0.0] * (3 - len(seq))
|
||||
return (float(seq[0]), float(seq[1]), float(seq[2]))
|
||||
|
||||
|
||||
def _to_3vec(value: Any) -> np.ndarray:
|
||||
"""Coerce a saved 3-vector to a 3-element numpy array."""
|
||||
if isinstance(value, np.ndarray):
|
||||
return value.astype(float).reshape(3)
|
||||
if value is None:
|
||||
return np.zeros(3, dtype=float)
|
||||
seq = list(value)
|
||||
if len(seq) < 3:
|
||||
seq = list(seq) + [0.0] * (3 - len(seq))
|
||||
return np.array([float(seq[0]), float(seq[1]), float(seq[2])], dtype=float)
|
||||
|
||||
|
||||
def _to_mat3(value: Any) -> np.ndarray:
|
||||
"""Coerce a saved 3×3 matrix (flat 9-list or nested) to np.ndarray."""
|
||||
if isinstance(value, np.ndarray):
|
||||
arr = value.astype(float)
|
||||
return arr.reshape(3, 3)
|
||||
if value is None:
|
||||
return np.eye(3, dtype=float)
|
||||
flat = list(np.asarray(value, dtype=float).flatten())
|
||||
if len(flat) < 9:
|
||||
flat = flat + [0.0] * (9 - len(flat))
|
||||
return np.array(flat[:9], dtype=float).reshape(3, 3)
|
||||
|
||||
|
||||
def _parse_iso(value: Optional[str]) -> datetime:
|
||||
"""Parse an ISO-8601 timestamp, falling back to ``now`` on failure."""
|
||||
if not value:
|
||||
return datetime.now()
|
||||
try:
|
||||
return datetime.fromisoformat(value)
|
||||
except (TypeError, ValueError):
|
||||
return datetime.now()
|
||||
|
||||
|
||||
# ── Model serialization (to_dict) ──────────────────────────────────────────
|
||||
|
||||
|
||||
def _workplane_to_dict(wp: Workplane) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": wp.id,
|
||||
"name": wp.name,
|
||||
"origin": list(wp.origin),
|
||||
"normal": list(wp.normal),
|
||||
"x_dir": list(wp.x_dir),
|
||||
"visible": bool(wp.visible),
|
||||
"created_at": wp.created_at.isoformat() if wp.created_at else None,
|
||||
"modified_at": wp.modified_at.isoformat() if wp.modified_at else None,
|
||||
}
|
||||
|
||||
|
||||
def _workplane_from_dict(data: Dict[str, Any]) -> Workplane:
|
||||
wp = Workplane(
|
||||
id=data.get("id") or None, # Workplane generates uuid if None
|
||||
name=data.get("name", "Untitled Workplane"),
|
||||
origin=tuple(data.get("origin", (0.0, 0.0, 0.0))),
|
||||
normal=tuple(data.get("normal", (0.0, 0.0, 1.0))),
|
||||
x_dir=tuple(data.get("x_dir", (1.0, 0.0, 0.0))),
|
||||
visible=bool(data.get("visible", True)),
|
||||
)
|
||||
wp.created_at = _parse_iso(data.get("created_at"))
|
||||
wp.modified_at = _parse_iso(data.get("modified_at"))
|
||||
return wp
|
||||
|
||||
|
||||
def _body_to_dict(body: Body) -> Dict[str, Any]:
|
||||
"""Body serialization. ``geometry_ref`` is set later by the ZIP writer
|
||||
once the STEP file is written."""
|
||||
return {
|
||||
"id": body.id,
|
||||
"name": body.name,
|
||||
"source_sketch_id": body.source_sketch.id if body.source_sketch else None,
|
||||
"source_operation": body.source_operation,
|
||||
"extrude_length": body.extrude_length,
|
||||
"extrude_symmetric": body.extrude_symmetric,
|
||||
"extrude_invert": body.extrude_invert,
|
||||
"extrude_cut": body.extrude_cut,
|
||||
"extrude_union": body.extrude_union,
|
||||
"extrude_through_all": body.extrude_through_all,
|
||||
"extrude_face_index": body.extrude_face_index,
|
||||
"extrude_target_body_id": body.extrude_target_body_id,
|
||||
"position": _coerce_listlike(body.position),
|
||||
"rotation": _coerce_listlike(body.rotation),
|
||||
"color": list(body.color) if body.color else [0.2, 0.4, 0.8],
|
||||
"opacity": float(body.opacity),
|
||||
"visible": bool(body.visible),
|
||||
"has_geometry": body.geometry is not None,
|
||||
"geometry_ref": None, # filled in by save_project
|
||||
"created_at": body.created_at.isoformat() if body.created_at else None,
|
||||
"modified_at": body.modified_at.isoformat() if body.modified_at else None,
|
||||
}
|
||||
|
||||
|
||||
def _body_from_dict(
|
||||
data: Dict[str, Any],
|
||||
geometry_loader: Optional[Callable[[str], Optional[OCCGeometryObject]]] = None,
|
||||
source_sketch: Optional[Sketch] = None,
|
||||
) -> Body:
|
||||
geometry: Optional[OCCGeometryObject] = None
|
||||
if geometry_loader is not None and data.get("geometry_ref"):
|
||||
geometry = geometry_loader(data["geometry_ref"]) if data.get("has_geometry") else None
|
||||
|
||||
body = Body(
|
||||
id=data.get("id") or None,
|
||||
name=data.get("name", "Untitled Body"),
|
||||
geometry=geometry,
|
||||
source_sketch=source_sketch,
|
||||
source_operation=data.get("source_operation", "extrude"),
|
||||
extrude_length=data.get("extrude_length"),
|
||||
extrude_symmetric=bool(data.get("extrude_symmetric", False)),
|
||||
extrude_invert=bool(data.get("extrude_invert", False)),
|
||||
extrude_cut=bool(data.get("extrude_cut", False)),
|
||||
extrude_union=bool(data.get("extrude_union", False)),
|
||||
extrude_through_all=bool(data.get("extrude_through_all", False)),
|
||||
extrude_face_index=data.get("extrude_face_index"),
|
||||
extrude_target_body_id=data.get("extrude_target_body_id"),
|
||||
position=_to_3vec(data.get("position")),
|
||||
rotation=_to_mat3(data.get("rotation")),
|
||||
color=tuple(data.get("color", [0.2, 0.4, 0.8])),
|
||||
opacity=float(data.get("opacity", 1.0)),
|
||||
visible=bool(data.get("visible", True)),
|
||||
)
|
||||
body.created_at = _parse_iso(data.get("created_at"))
|
||||
body.modified_at = _parse_iso(data.get("modified_at"))
|
||||
return body
|
||||
|
||||
|
||||
def _sketch_to_dict(sketch: Sketch) -> Dict[str, Any]:
|
||||
occ_dict: Optional[Dict[str, Any]] = None
|
||||
if sketch.occ_sketch is not None and isinstance(sketch.occ_sketch, OCCSketch):
|
||||
try:
|
||||
occ_dict = sketch.occ_sketch.to_dict()
|
||||
except Exception as exc:
|
||||
logger.warning("Sketch %s occ_sketch.to_dict() failed: %s", sketch.id, exc)
|
||||
|
||||
return {
|
||||
"id": sketch.id,
|
||||
"name": sketch.name,
|
||||
"workplane_origin": _coerce_listlike(sketch.workplane_origin),
|
||||
"workplane_normal": _coerce_listlike(sketch.workplane_normal),
|
||||
"workplane_x_dir": _coerce_listlike(sketch.workplane_x_dir),
|
||||
"is_solved": bool(sketch.is_solved),
|
||||
"is_fully_constrained": bool(sketch.is_fully_constrained),
|
||||
"occ_sketch": occ_dict,
|
||||
"has_geometry": sketch.geometry is not None,
|
||||
"geometry_ref": None, # filled in by save_project
|
||||
"created_at": sketch.created_at.isoformat() if sketch.created_at else None,
|
||||
"modified_at": sketch.modified_at.isoformat() if sketch.modified_at else None,
|
||||
}
|
||||
|
||||
|
||||
def _sketch_from_dict(
|
||||
data: Dict[str, Any],
|
||||
geometry_loader: Optional[Callable[[str], Optional[OCCGeometryObject]]] = None,
|
||||
) -> Sketch:
|
||||
occ_dict = data.get("occ_sketch")
|
||||
occ_sketch: Optional[OCCSketch] = None
|
||||
if occ_dict is not None:
|
||||
try:
|
||||
occ_sketch = OCCSketch.from_dict(occ_dict)
|
||||
except Exception as exc:
|
||||
logger.warning("Sketch %s OCCSketch.from_dict() failed: %s", data.get("id"), exc)
|
||||
occ_sketch = OCCSketch()
|
||||
else:
|
||||
occ_sketch = OCCSketch()
|
||||
|
||||
# Re-apply the workplane (from_dict already does this internally, but be
|
||||
# defensive in case the saved dict didn't carry the workplane fields).
|
||||
occ_sketch.set_workplane(
|
||||
tuple(data.get("workplane_origin", (0.0, 0.0, 0.0))),
|
||||
tuple(data.get("workplane_normal", (0.0, 0.0, 1.0))),
|
||||
tuple(data.get("workplane_x_dir", (1.0, 0.0, 0.0))),
|
||||
)
|
||||
|
||||
geometry: Optional[OCCGeometryObject] = None
|
||||
if geometry_loader is not None and data.get("geometry_ref"):
|
||||
geometry = geometry_loader(data["geometry_ref"]) if data.get("has_geometry") else None
|
||||
|
||||
sk = Sketch(
|
||||
id=data.get("id") or None,
|
||||
name=data.get("name", "Untitled Sketch"),
|
||||
occ_sketch=occ_sketch,
|
||||
geometry=geometry,
|
||||
is_solved=bool(data.get("is_solved", False)),
|
||||
is_fully_constrained=bool(data.get("is_fully_constrained", False)),
|
||||
)
|
||||
sk.workplane_origin = _to_3vec(data.get("workplane_origin"))
|
||||
sk.workplane_normal = _to_3vec(data.get("workplane_normal"))
|
||||
sk.workplane_x_dir = _to_3vec(data.get("workplane_x_dir"))
|
||||
sk.apply_workplane()
|
||||
sk.created_at = _parse_iso(data.get("created_at"))
|
||||
sk.modified_at = _parse_iso(data.get("modified_at"))
|
||||
return sk
|
||||
|
||||
|
||||
def _component_to_dict(comp: Component) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": comp.id,
|
||||
"name": comp.name,
|
||||
"description": comp.description,
|
||||
"active_sketch": comp.active_sketch,
|
||||
"active_workplane": comp.active_workplane,
|
||||
"sketches": {sid: _sketch_to_dict(s) for sid, s in comp.sketches.items()},
|
||||
"bodies": {bid: _body_to_dict(b) for bid, b in comp.bodies.items()},
|
||||
"workplanes": {wid: _workplane_to_dict(w) for wid, w in comp.workplanes.items()},
|
||||
"created_at": comp.created_at.isoformat() if comp.created_at else None,
|
||||
"modified_at": comp.modified_at.isoformat() if comp.modified_at else None,
|
||||
}
|
||||
|
||||
|
||||
def _component_from_dict(
|
||||
data: Dict[str, Any],
|
||||
body_geometry_loader: Optional[Callable[[str], Optional[OCCGeometryObject]]] = None,
|
||||
sketch_geometry_loader: Optional[Callable[[str], Optional[OCCGeometryObject]]] = None,
|
||||
) -> Component:
|
||||
comp = Component(
|
||||
id=data.get("id") or None,
|
||||
name=data.get("name", "Untitled Component"),
|
||||
description=data.get("description", ""),
|
||||
active_sketch=data.get("active_sketch"),
|
||||
active_workplane=data.get("active_workplane"),
|
||||
)
|
||||
comp.created_at = _parse_iso(data.get("created_at"))
|
||||
comp.modified_at = _parse_iso(data.get("modified_at"))
|
||||
|
||||
for wid, wp_data in (data.get("workplanes") or {}).items():
|
||||
comp.workplanes[wid] = _workplane_from_dict(wp_data)
|
||||
|
||||
# Sketches first so bodies can reference them.
|
||||
for sid, sk_data in (data.get("sketches") or {}).items():
|
||||
comp.sketches[sid] = _sketch_from_dict(sk_data, sketch_geometry_loader)
|
||||
|
||||
for bid, body_data in (data.get("bodies") or {}).items():
|
||||
src_sketch = None
|
||||
src_id = body_data.get("source_sketch_id")
|
||||
if src_id and src_id in comp.sketches:
|
||||
src_sketch = comp.sketches[src_id]
|
||||
comp.bodies[bid] = _body_from_dict(body_data, body_geometry_loader, src_sketch)
|
||||
|
||||
return comp
|
||||
|
||||
|
||||
def _connector_to_dict(conn: Connector) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": conn.id,
|
||||
"name": conn.name,
|
||||
"position": list(conn.position),
|
||||
"normal": list(conn.normal),
|
||||
"x_dir": list(conn.x_dir),
|
||||
"axis_rotation": float(conn.axis_rotation),
|
||||
"offset": float(conn.offset),
|
||||
"assembly_component_id": conn.assembly_component_id,
|
||||
"source_obj_id": conn.source_obj_id,
|
||||
"partner_ac_id": conn.partner_ac_id,
|
||||
"partner_connector_id": conn.partner_connector_id,
|
||||
"is_grounded": bool(conn.is_grounded),
|
||||
"is_invalid": bool(conn.is_invalid),
|
||||
"created_at": conn.created_at.isoformat() if conn.created_at else None,
|
||||
"modified_at": conn.modified_at.isoformat() if conn.modified_at else None,
|
||||
}
|
||||
|
||||
|
||||
def _connector_from_dict(data: Dict[str, Any]) -> Connector:
|
||||
conn = Connector(
|
||||
id=data.get("id") or None,
|
||||
name=data.get("name", "Untitled Connector"),
|
||||
position=_to_3tuple(data.get("position")),
|
||||
normal=_to_3tuple(data.get("normal")),
|
||||
x_dir=_to_3tuple(data.get("x_dir")),
|
||||
axis_rotation=float(data.get("axis_rotation", 0.0)),
|
||||
offset=float(data.get("offset", 0.0)),
|
||||
assembly_component_id=data.get("assembly_component_id", ""),
|
||||
source_obj_id=data.get("source_obj_id", ""),
|
||||
)
|
||||
conn.partner_ac_id = data.get("partner_ac_id")
|
||||
conn.partner_connector_id = data.get("partner_connector_id")
|
||||
conn.is_grounded = bool(data.get("is_grounded", False))
|
||||
conn.is_invalid = bool(data.get("is_invalid", False))
|
||||
conn.created_at = _parse_iso(data.get("created_at"))
|
||||
conn.modified_at = _parse_iso(data.get("modified_at"))
|
||||
return conn
|
||||
|
||||
|
||||
def _assembly_component_to_dict(ac: AssemblyComponent) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": ac.id,
|
||||
"component_id": ac.component_id,
|
||||
"name": ac.name,
|
||||
"position": _coerce_listlike(ac.position),
|
||||
"rotation": _coerce_listlike(ac.rotation),
|
||||
"connectors": {cid: _connector_to_dict(c) for cid, c in ac.connectors.items()},
|
||||
"created_at": ac.created_at.isoformat() if ac.created_at else None,
|
||||
"modified_at": ac.modified_at.isoformat() if ac.modified_at else None,
|
||||
}
|
||||
|
||||
|
||||
def _assembly_component_from_dict(data: Dict[str, Any]) -> AssemblyComponent:
|
||||
ac = AssemblyComponent(
|
||||
id=data.get("id") or None,
|
||||
component_id=data.get("component_id", ""),
|
||||
name=data.get("name", "Untitled Instance"),
|
||||
position=_to_3vec(data.get("position")),
|
||||
rotation=_to_mat3(data.get("rotation")),
|
||||
)
|
||||
ac.created_at = _parse_iso(data.get("created_at"))
|
||||
ac.modified_at = _parse_iso(data.get("modified_at"))
|
||||
for cid, c_data in (data.get("connectors") or {}).items():
|
||||
ac.connectors[cid] = _connector_from_dict(c_data)
|
||||
return ac
|
||||
|
||||
|
||||
def _assembly_connection_to_dict(c: AssemblyConnection) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": c.id,
|
||||
"first_ac_id": c.first_ac_id,
|
||||
"second_ac_id": c.second_ac_id,
|
||||
"first_connector_id": c.first_connector_id,
|
||||
"second_connector_id": c.second_connector_id,
|
||||
"created_at": c.created_at.isoformat() if c.created_at else None,
|
||||
}
|
||||
|
||||
|
||||
def _assembly_connection_from_dict(data: Dict[str, Any]) -> AssemblyConnection:
|
||||
conn = AssemblyConnection(
|
||||
id=data.get("id") or None,
|
||||
first_ac_id=data.get("first_ac_id", ""),
|
||||
second_ac_id=data.get("second_ac_id", ""),
|
||||
first_connector_id=data.get("first_connector_id"),
|
||||
second_connector_id=data.get("second_connector_id"),
|
||||
)
|
||||
conn.created_at = _parse_iso(data.get("created_at"))
|
||||
return conn
|
||||
|
||||
|
||||
def _assembly_to_dict(asm: Assembly) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": asm.id,
|
||||
"name": asm.name,
|
||||
"active_assembly_component": asm.active_assembly_component,
|
||||
"components": {cid: _assembly_component_to_dict(ac) for cid, ac in asm.components.items()},
|
||||
"connections": [_assembly_connection_to_dict(c) for c in asm.connections],
|
||||
"created_at": asm.created_at.isoformat() if asm.created_at else None,
|
||||
"modified_at": asm.modified_at.isoformat() if asm.modified_at else None,
|
||||
}
|
||||
|
||||
|
||||
def _assembly_from_dict(data: Dict[str, Any]) -> Assembly:
|
||||
asm = Assembly(
|
||||
id=data.get("id") or None,
|
||||
name=data.get("name", "Untitled Assembly"),
|
||||
active_assembly_component=data.get("active_assembly_component"),
|
||||
)
|
||||
asm.created_at = _parse_iso(data.get("created_at"))
|
||||
asm.modified_at = _parse_iso(data.get("modified_at"))
|
||||
for cid, ac_data in (data.get("components") or {}).items():
|
||||
asm.components[cid] = _assembly_component_from_dict(ac_data)
|
||||
for c_data in (data.get("connections") or []):
|
||||
asm.connections.append(_assembly_connection_from_dict(c_data))
|
||||
return asm
|
||||
|
||||
|
||||
def _project_to_dict(
|
||||
project: Project,
|
||||
view_state: Optional[Dict[str, Any]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
return {
|
||||
"format_version": 1,
|
||||
"name": project.name,
|
||||
"description": project.description,
|
||||
"active_component": project.active_component,
|
||||
"active_assembly": project.active_assembly,
|
||||
"components": {cid: _component_to_dict(c) for cid, c in project.components.items()},
|
||||
"assemblies": {aid: _assembly_to_dict(a) for aid, a in project.assemblies.items()},
|
||||
"created_at": project.created_at.isoformat() if project.created_at else None,
|
||||
"modified_at": project.modified_at.isoformat() if project.modified_at else None,
|
||||
"view_state": view_state or {},
|
||||
}
|
||||
|
||||
|
||||
# ── Geometry (STEP) write/read helpers ─────────────────────────────────────
|
||||
|
||||
|
||||
def _write_step_for_body(
|
||||
kernel: OCGeometryKernel,
|
||||
geometry: OCCGeometryObject,
|
||||
) -> Optional[bytes]:
|
||||
"""Serialize a single body geometry to a STEP byte string.
|
||||
|
||||
Returns *None* if OCC reports the shape is empty (so the ZIP can omit
|
||||
the file and the body is restored as geometry-less). The temporary
|
||||
file is created and immediately deleted; we never touch the user's
|
||||
filesystem outside of ``tempfile``.
|
||||
"""
|
||||
fd, tmp_path = tempfile.mkstemp(suffix=".step")
|
||||
os.close(fd)
|
||||
try:
|
||||
ok = kernel.export_step(geometry, tmp_path)
|
||||
if not ok:
|
||||
return None
|
||||
with open(tmp_path, "rb") as f:
|
||||
return f.read()
|
||||
finally:
|
||||
try:
|
||||
os.unlink(tmp_path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _read_step_bytes(
|
||||
kernel: OCGeometryKernel,
|
||||
data: bytes,
|
||||
) -> Optional[OCCGeometryObject]:
|
||||
"""Parse a STEP byte string back into an OCCGeometryObject."""
|
||||
fd, tmp_path = tempfile.mkstemp(suffix=".step")
|
||||
os.close(fd)
|
||||
try:
|
||||
with open(tmp_path, "wb") as f:
|
||||
f.write(data)
|
||||
geom = kernel.import_step(tmp_path)
|
||||
return geom
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to read STEP: %s", exc)
|
||||
return None
|
||||
finally:
|
||||
try:
|
||||
os.unlink(tmp_path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
# ── Save / Load entry points ───────────────────────────────────────────────
|
||||
|
||||
|
||||
def project_zip_path(path: str) -> str:
|
||||
"""Return *path* with the ``.fluency`` extension added if missing."""
|
||||
base, ext = os.path.splitext(path)
|
||||
if ext.lower() == ".fluency":
|
||||
return path
|
||||
return base + ".fluency"
|
||||
|
||||
|
||||
def save_project(
|
||||
project: Project,
|
||||
filepath: str,
|
||||
view_state: Optional[Dict[str, Any]] = None,
|
||||
kernel: Optional[OCGeometryKernel] = None,
|
||||
) -> str:
|
||||
"""Save *project* to a ``.fluency`` ZIP at *filepath*.
|
||||
|
||||
*view_state* (optional) is a free-form dict that the main window uses to
|
||||
record camera position, active tab, etc. It is stored verbatim inside
|
||||
``project.json`` under the ``view_state`` key.
|
||||
|
||||
*kernel* is the OCGeometryKernel to use for STEP export. A new one is
|
||||
created if not provided (slightly slower startup, but never holds stale
|
||||
state). Pass the app's kernel to keep one canonical instance.
|
||||
|
||||
Returns the actual file path that was written.
|
||||
"""
|
||||
filepath = project_zip_path(filepath)
|
||||
kernel = kernel or OCGeometryKernel()
|
||||
|
||||
# Build the manifest in two passes:
|
||||
# pass 1: serialize all metadata + collect body/sketches that need
|
||||
# STEP files written alongside. We track the in-zip path of
|
||||
# each STEP file in the body/sketches' ``geometry_ref`` slot.
|
||||
# pass 2: write the ZIP, streaming each body/sketches's STEP data
|
||||
# into its own archive member.
|
||||
manifest = _project_to_dict(project, view_state)
|
||||
|
||||
# Per-body STEP files. Skipped if the body has no geometry.
|
||||
body_files: List[Tuple[str, bytes]] = []
|
||||
for comp_id, comp in project.components.items():
|
||||
for body_id, body in comp.bodies.items():
|
||||
if body.geometry is None:
|
||||
continue
|
||||
step_bytes = _write_step_for_body(kernel, body.geometry)
|
||||
if step_bytes is None:
|
||||
continue
|
||||
arcname = f"bodies/{body_id}.step"
|
||||
body_files.append((arcname, step_bytes))
|
||||
manifest["components"][comp_id]["bodies"][body_id]["geometry_ref"] = arcname
|
||||
|
||||
# Per-sketch STEP files (solved face geometry).
|
||||
sketch_files: List[Tuple[str, bytes]] = []
|
||||
sketch_meta_files: List[Tuple[str, bytes]] = []
|
||||
for comp_id, comp in project.components.items():
|
||||
for sketch_id, sketch in comp.sketches.items():
|
||||
# Save the OCCSketch state to its own JSON file so the
|
||||
# main project.json stays compact.
|
||||
occ = sketch.occ_sketch.to_dict() if sketch.occ_sketch is not None else None
|
||||
meta = {
|
||||
"id": sketch.id,
|
||||
"name": sketch.name,
|
||||
"workplane_origin": _coerce_listlike(sketch.workplane_origin),
|
||||
"workplane_normal": _coerce_listlike(sketch.workplane_normal),
|
||||
"workplane_x_dir": _coerce_listlike(sketch.workplane_x_dir),
|
||||
"is_solved": bool(sketch.is_solved),
|
||||
"is_fully_constrained": bool(sketch.is_fully_constrained),
|
||||
"occ_sketch": occ,
|
||||
}
|
||||
meta_arc = f"sketches/{sketch_id}/meta.json"
|
||||
sketch_meta_files.append((meta_arc, _to_json(meta).encode("utf-8")))
|
||||
# Drop the heavy occ_sketch payload from the main manifest so
|
||||
# the file is smaller and edits are localised.
|
||||
manifest["components"][comp_id]["sketches"][sketch_id]["occ_sketch"] = None
|
||||
manifest["components"][comp_id]["sketches"][sketch_id]["occ_sketch_ref"] = meta_arc
|
||||
|
||||
if sketch.geometry is None:
|
||||
continue
|
||||
step_bytes = _write_step_for_body(kernel, sketch.geometry)
|
||||
if step_bytes is None:
|
||||
continue
|
||||
arcname = f"sketches/{sketch_id}/solved.step"
|
||||
sketch_files.append((arcname, step_bytes))
|
||||
manifest["components"][comp_id]["sketches"][sketch_id]["geometry_ref"] = arcname
|
||||
|
||||
# Write the ZIP. Use a temp file + rename so a partial write can't
|
||||
# clobber an existing good file.
|
||||
tmp_fd, tmp_path = tempfile.mkstemp(suffix=".fluency")
|
||||
os.close(tmp_fd)
|
||||
try:
|
||||
with zipfile.ZipFile(tmp_path, "w", compression=zipfile.ZIP_DEFLATED) as zf:
|
||||
zf.writestr("project.json", _to_json(manifest))
|
||||
for arcname, data in body_files + sketch_files + sketch_meta_files:
|
||||
zf.writestr(arcname, data)
|
||||
# Atomic-ish replace.
|
||||
shutil.move(tmp_path, filepath)
|
||||
except Exception:
|
||||
try:
|
||||
os.unlink(tmp_path)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
|
||||
return filepath
|
||||
|
||||
|
||||
def load_project(filepath: str) -> Tuple[Project, Dict[str, Any]]:
|
||||
"""Load a project from a ``.fluency`` ZIP.
|
||||
|
||||
Returns ``(project, view_state)``. The caller is responsible for
|
||||
handing *view_state* to the renderer (camera, etc.) and for re-rendering
|
||||
the scene with the freshly-loaded bodies.
|
||||
"""
|
||||
if not os.path.exists(filepath):
|
||||
raise FileNotFoundError(filepath)
|
||||
|
||||
kernel = OCGeometryKernel()
|
||||
body_cache: Dict[str, Optional[OCCGeometryObject]] = {}
|
||||
|
||||
# Body geometry can be reused across bodies if the same STEP appears
|
||||
# under multiple names (rare, but cheap to handle). We cache by zip
|
||||
# member name.
|
||||
def body_geometry_loader(member_name: str) -> Optional[OCCGeometryObject]:
|
||||
if member_name in body_cache:
|
||||
return body_cache[member_name]
|
||||
try:
|
||||
data = zipf.read(member_name)
|
||||
except KeyError:
|
||||
logger.warning("Body STEP missing in archive: %s", member_name)
|
||||
body_cache[member_name] = None
|
||||
return None
|
||||
geom = _read_step_bytes(kernel, data)
|
||||
body_cache[member_name] = geom
|
||||
return geom
|
||||
|
||||
# Sketch geometry loader shares the same byte path. Sketches that have
|
||||
# solved faces point at sketches/<id>/solved.step.
|
||||
def sketch_geometry_loader(member_name: str) -> Optional[OCCGeometryObject]:
|
||||
return body_geometry_loader(member_name)
|
||||
|
||||
with zipfile.ZipFile(filepath, "r") as zipf:
|
||||
manifest_raw = zipf.read("project.json")
|
||||
manifest = json.loads(manifest_raw.decode("utf-8"))
|
||||
view_state: Dict[str, Any] = manifest.get("view_state") or {}
|
||||
|
||||
# If a sketch's occ_sketch is referenced as a separate file, read
|
||||
# it in now and patch the manifest so _sketch_from_dict sees it.
|
||||
for comp_id, comp_data in (manifest.get("components") or {}).items():
|
||||
for sk_id, sk_data in (comp_data.get("sketches") or {}).items():
|
||||
ref = sk_data.get("occ_sketch_ref")
|
||||
if not ref:
|
||||
continue
|
||||
try:
|
||||
meta_bytes = zipf.read(ref)
|
||||
except KeyError:
|
||||
logger.warning("Sketch meta missing in archive: %s", ref)
|
||||
continue
|
||||
meta = json.loads(meta_bytes.decode("utf-8"))
|
||||
sk_data["occ_sketch"] = meta.get("occ_sketch")
|
||||
# Workplane fields on the sketch-level file override the
|
||||
# embedded ones (source of truth lives in the sidecar).
|
||||
for k in ("workplane_origin", "workplane_normal", "workplane_x_dir",
|
||||
"is_solved", "is_fully_constrained"):
|
||||
if k in meta:
|
||||
sk_data[k] = meta[k]
|
||||
|
||||
project = Project(
|
||||
name=manifest.get("name", "Untitled Project"),
|
||||
description=manifest.get("description", ""),
|
||||
active_component=manifest.get("active_component"),
|
||||
active_assembly=manifest.get("active_assembly"),
|
||||
kernel=kernel,
|
||||
)
|
||||
project.file_path = filepath
|
||||
project.created_at = _parse_iso(manifest.get("created_at"))
|
||||
project.modified_at = _parse_iso(manifest.get("modified_at"))
|
||||
|
||||
for cid, c_data in (manifest.get("components") or {}).items():
|
||||
project.components[cid] = _component_from_dict(
|
||||
c_data,
|
||||
body_geometry_loader=body_geometry_loader,
|
||||
sketch_geometry_loader=sketch_geometry_loader,
|
||||
)
|
||||
|
||||
for aid, a_data in (manifest.get("assemblies") or {}).items():
|
||||
project.assemblies[aid] = _assembly_from_dict(a_data)
|
||||
|
||||
# After all components are loaded, re-wire connector partner ids so
|
||||
# they point to the freshly-loaded AssemblyComponents. (The dict
|
||||
# round-trip preserves the raw strings; we just make sure the partner
|
||||
# ids are still present in the project so the assembly-move handler
|
||||
# can follow the rigid-group graph.)
|
||||
for asm in project.assemblies.values():
|
||||
for conn in asm.connections:
|
||||
if conn.first_connector_id and conn.first_ac_id in asm.components:
|
||||
first_ac = asm.components[conn.first_ac_id]
|
||||
if conn.first_connector_id in first_ac.connectors:
|
||||
first_ac.connectors[conn.first_connector_id].is_grounded = True
|
||||
if conn.second_connector_id and conn.second_ac_id in asm.components:
|
||||
pass # already set in the connector itself
|
||||
|
||||
return project, view_state
|
||||
+40
-4228
File diff suppressed because it is too large
Load Diff
@@ -21,6 +21,76 @@ from fluency.geometry_occ.kernel import OCGeometryKernel, OCCGeometryObject
|
||||
from fluency.geometry_occ.sketch import OCCSketch
|
||||
|
||||
|
||||
@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:
|
||||
"""
|
||||
@@ -152,6 +222,20 @@ class Body:
|
||||
source_sketch: Optional[Sketch] = None
|
||||
source_operation: str = "extrude"
|
||||
|
||||
# Re-extrusion parameters — stored so the body can be rebuilt from
|
||||
# its source sketch when the sketch is edited. None means the body
|
||||
# was not created by an extrude-type operation and cannot be auto-
|
||||
# rebuilt.
|
||||
extrude_length: Optional[float] = None
|
||||
extrude_symmetric: bool = False
|
||||
extrude_invert: bool = False
|
||||
extrude_cut: bool = False
|
||||
extrude_union: bool = False
|
||||
extrude_through_all: bool = False
|
||||
extrude_face_index: Optional[int] = None # which sketch face was selected
|
||||
extrude_target_body_id: Optional[str] = None # for cut/union: target body id
|
||||
needs_update: bool = False # True when source sketch changed since last extrude
|
||||
|
||||
position: np.ndarray = field(default_factory=lambda: np.array([0.0, 0.0, 0.0]))
|
||||
rotation: np.ndarray = field(default_factory=lambda: np.eye(3))
|
||||
|
||||
@@ -192,12 +276,34 @@ class Component:
|
||||
|
||||
sketches: Dict[str, Sketch] = field(default_factory=dict)
|
||||
bodies: Dict[str, Body] = field(default_factory=dict)
|
||||
workplanes: Dict[str, Workplane] = field(default_factory=dict)
|
||||
|
||||
active_sketch: Optional[str] = None
|
||||
active_workplane: Optional[str] = None
|
||||
|
||||
created_at: datetime = field(default_factory=datetime.now)
|
||||
modified_at: datetime = field(default_factory=datetime.now)
|
||||
|
||||
def add_workplane(self, workplane: Optional[Workplane] = None) -> Workplane:
|
||||
"""Add an independent workplane to the component."""
|
||||
if workplane is None:
|
||||
workplane = Workplane(name=f"Workplane {len(self.workplanes) + 1}")
|
||||
self.workplanes[workplane.id] = workplane
|
||||
if self.active_workplane is None:
|
||||
self.active_workplane = workplane.id
|
||||
self.modified_at = datetime.now()
|
||||
return workplane
|
||||
|
||||
def remove_workplane(self, wp_id: str) -> bool:
|
||||
"""Remove a workplane from the component."""
|
||||
if wp_id in self.workplanes:
|
||||
del self.workplanes[wp_id]
|
||||
if self.active_workplane == wp_id:
|
||||
self.active_workplane = next(iter(self.workplanes.keys()), None)
|
||||
self.modified_at = datetime.now()
|
||||
return True
|
||||
return False
|
||||
|
||||
def add_sketch(self, sketch: Optional[Sketch] = None) -> Sketch:
|
||||
"""Add a sketch to the component."""
|
||||
if sketch is None:
|
||||
@@ -244,6 +350,264 @@ class Component:
|
||||
self.modified_at = datetime.now()
|
||||
|
||||
|
||||
@dataclass
|
||||
class Connector:
|
||||
"""
|
||||
A connection point on an assembly component instance.
|
||||
|
||||
Stores the position and orientation of a connection point
|
||||
(e.g. a hole center, face midpoint, or edge point) that will
|
||||
later be used by the SolveSpace solver to mate components.
|
||||
The *normal* defines the connection axis direction (e.g. the
|
||||
hole axis for a screw connection).
|
||||
"""
|
||||
|
||||
id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
||||
name: str = "Untitled Connector"
|
||||
|
||||
# 3D position of the connection point (component-local coords).
|
||||
# Transformed to world coords at render time using the parent
|
||||
# AssemblyComponent's position/rotation, so connectors move with
|
||||
# their component automatically.
|
||||
position: Tuple[float, float, float] = (0.0, 0.0, 0.0)
|
||||
# Normal direction of the connection (e.g. hole axis) in local coords.
|
||||
normal: Tuple[float, float, float] = (0.0, 0.0, 1.0)
|
||||
# In-plane X direction for defining the reference frame (local coords).
|
||||
x_dir: Tuple[float, float, float] = (1.0, 0.0, 0.0)
|
||||
|
||||
# Rotation around the normal axis (degrees).
|
||||
axis_rotation: float = 0.0
|
||||
# Offset distance along the normal.
|
||||
offset: float = 0.0
|
||||
|
||||
# Which AssemblyComponent this connector belongs to.
|
||||
assembly_component_id: str = ""
|
||||
# Which body/face this connector was placed on (renderer obj_id).
|
||||
source_obj_id: str = ""
|
||||
|
||||
# --- Rigid-group pairing (set when two connectors are mated) ---
|
||||
# The id of the partner AssemblyComponent this connector is mated to.
|
||||
# The FIRST-picked component is the grounded reference of the pair;
|
||||
# 'is_grounded' marks that side so the move handler knows which half
|
||||
# is the fixed frame of the rigid group.
|
||||
partner_ac_id: Optional[str] = None
|
||||
# The id of the partner Connector on the partner component.
|
||||
partner_connector_id: Optional[str] = None
|
||||
# True on the first-picked (grounded) connector of a mated pair.
|
||||
is_grounded: bool = False
|
||||
|
||||
# True when the source geometry this connector was placed on has
|
||||
# changed or disappeared — the connector needs re-placement.
|
||||
is_invalid: bool = False
|
||||
|
||||
created_at: datetime = field(default_factory=datetime.now)
|
||||
modified_at: datetime = field(default_factory=datetime.now)
|
||||
|
||||
|
||||
@dataclass
|
||||
class AssemblyComponent:
|
||||
"""
|
||||
An instance of a component within an assembly.
|
||||
|
||||
References a component in the project and stores its relative
|
||||
position and rotation for placement within the assembly.
|
||||
Holds connectors that define connection points for the
|
||||
SolveSpace solver.
|
||||
"""
|
||||
|
||||
id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
||||
component_id: str = ""
|
||||
name: str = "Untitled Instance"
|
||||
|
||||
# Position and orientation relative to the assembly origin.
|
||||
position: np.ndarray = field(default_factory=lambda: np.array([0.0, 0.0, 0.0]))
|
||||
rotation: np.ndarray = field(default_factory=lambda: np.eye(3))
|
||||
|
||||
# Connectors defined on this component instance.
|
||||
connectors: Dict[str, Connector] = field(default_factory=dict)
|
||||
|
||||
created_at: datetime = field(default_factory=datetime.now)
|
||||
modified_at: datetime = field(default_factory=datetime.now)
|
||||
|
||||
def add_connector(
|
||||
self,
|
||||
position: Tuple[float, float, float],
|
||||
normal: Tuple[float, float, float],
|
||||
x_dir: Tuple[float, float, float],
|
||||
source_obj_id: str = "",
|
||||
name: Optional[str] = None,
|
||||
) -> Connector:
|
||||
"""Add a connector to this component instance."""
|
||||
conn = Connector(
|
||||
name=name or f"Connector {len(self.connectors) + 1}",
|
||||
position=position,
|
||||
normal=normal,
|
||||
x_dir=x_dir,
|
||||
assembly_component_id=self.id,
|
||||
source_obj_id=source_obj_id,
|
||||
)
|
||||
self.connectors[conn.id] = conn
|
||||
self.modified_at = datetime.now()
|
||||
return conn
|
||||
|
||||
def remove_connector(self, connector_id: str) -> bool:
|
||||
"""Remove a connector from this component instance."""
|
||||
if connector_id in self.connectors:
|
||||
del self.connectors[connector_id]
|
||||
self.modified_at = datetime.now()
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
@dataclass
|
||||
class AssemblyConnection:
|
||||
"""A mated connector pair linking two AssemblyComponents.
|
||||
|
||||
Records which component is the grounded reference (``first_ac_id``) and
|
||||
which was solved against it (``second_ac_id``), plus the partner
|
||||
connector ids so the linkage can be followed / removed symmetrically.
|
||||
Used by the assembly-move handler to propagate translations across the
|
||||
rigid group.
|
||||
"""
|
||||
|
||||
id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
||||
first_ac_id: str = "" # grounded reference side
|
||||
second_ac_id: str = "" # solved side
|
||||
first_connector_id: Optional[str] = None
|
||||
second_connector_id: Optional[str] = None
|
||||
created_at: datetime = field(default_factory=datetime.now)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Assembly:
|
||||
"""
|
||||
An assembly of multiple component instances.
|
||||
|
||||
An assembly groups component instances with relative positions,
|
||||
ready for constraint solving and joint definition between them.
|
||||
Components can be instanced multiple times, each at a different
|
||||
position and rotation.
|
||||
"""
|
||||
|
||||
id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
||||
name: str = "Untitled Assembly"
|
||||
|
||||
components: Dict[str, AssemblyComponent] = field(default_factory=dict)
|
||||
active_assembly_component: Optional[str] = None
|
||||
|
||||
# Mated connector pairs — each entry links two AssemblyComponents so the
|
||||
# assembly-move handler can propagate rigid-group translations. The
|
||||
# 'first_ac_id' side is the grounded reference of the pair.
|
||||
connections: List["AssemblyConnection"] = field(default_factory=list)
|
||||
|
||||
created_at: datetime = field(default_factory=datetime.now)
|
||||
modified_at: datetime = field(default_factory=datetime.now)
|
||||
|
||||
def add_connection(self, first_ac_id: str, second_ac_id: str) -> "AssemblyConnection":
|
||||
"""Record a mated connector pair between two component instances.
|
||||
|
||||
The first-picked component (``first_ac_id``) is treated as the
|
||||
grounded reference of the pair. Guards against duplicate entries.
|
||||
Returns the AssemblyConnection for further bookkeeping.
|
||||
"""
|
||||
# Guard: deduplicate — same pair in either order
|
||||
for c in self.connections:
|
||||
if (c.first_ac_id == first_ac_id and c.second_ac_id == second_ac_id) or \
|
||||
(c.first_ac_id == second_ac_id and c.second_ac_id == first_ac_id):
|
||||
return c
|
||||
conn = AssemblyConnection(
|
||||
first_ac_id=first_ac_id,
|
||||
second_ac_id=second_ac_id,
|
||||
)
|
||||
self.connections.append(conn)
|
||||
self.modified_at = datetime.now()
|
||||
return conn
|
||||
|
||||
def remove_connections_for(self, ac_id: str) -> None:
|
||||
"""Drop every connection that involves *ac_id* (e.g. on removal)."""
|
||||
self.connections = [
|
||||
c for c in self.connections
|
||||
if c.first_ac_id != ac_id and c.second_ac_id != ac_id
|
||||
]
|
||||
|
||||
def get_rigid_group(self, ac_id: str) -> List[str]:
|
||||
"""Return ids of all components rigidly linked to *ac_id* (BFS).
|
||||
|
||||
Includes *ac_id* itself. Two components are linked when a mated
|
||||
connector pair (in ``connections``) joins them; linkage is
|
||||
transitive, so the whole connected subgraph forms one rigid group.
|
||||
"""
|
||||
if ac_id not in self.components:
|
||||
return []
|
||||
# Build adjacency from the connection list.
|
||||
adj: Dict[str, List[str]] = {}
|
||||
for c in self.connections:
|
||||
adj.setdefault(c.first_ac_id, []).append(c.second_ac_id)
|
||||
adj.setdefault(c.second_ac_id, []).append(c.first_ac_id)
|
||||
seen: List[str] = []
|
||||
queue: List[str] = [ac_id]
|
||||
while queue:
|
||||
cur = queue.pop(0)
|
||||
if cur in seen:
|
||||
continue
|
||||
seen.append(cur)
|
||||
for nb in adj.get(cur, []):
|
||||
if nb not in seen:
|
||||
queue.append(nb)
|
||||
return seen
|
||||
|
||||
def is_grounded_reference(self, ac_id: str) -> bool:
|
||||
"""True if *ac_id* is the grounded (first-picked) side of any pair."""
|
||||
return any(c.first_ac_id == ac_id for c in self.connections)
|
||||
|
||||
def get_group_size(self, ac_id: str) -> int:
|
||||
"""Number of components rigidly linked to *ac_id* (including itself)."""
|
||||
return len(self.get_rigid_group(ac_id))
|
||||
|
||||
def add_component_instance(
|
||||
self, component_id: str, name: Optional[str] = None
|
||||
) -> AssemblyComponent:
|
||||
"""Add a component instance to the assembly.
|
||||
|
||||
Returns the newly created AssemblyComponent. The same
|
||||
component can be added multiple times (multiple instances).
|
||||
"""
|
||||
ac = AssemblyComponent(
|
||||
component_id=component_id,
|
||||
name=name or f"Instance {len(self.components) + 1}",
|
||||
)
|
||||
self.components[ac.id] = ac
|
||||
if self.active_assembly_component is None:
|
||||
self.active_assembly_component = ac.id
|
||||
self.modified_at = datetime.now()
|
||||
return ac
|
||||
|
||||
def remove_component_instance(self, assembly_component_id: str) -> bool:
|
||||
"""Remove a component instance from the assembly."""
|
||||
if assembly_component_id in self.components:
|
||||
del self.components[assembly_component_id]
|
||||
# Also drop any mated-connector links that referenced this
|
||||
# instance — otherwise stale connection edges would remain in
|
||||
# the rigid-group graph and point at a missing component.
|
||||
self.remove_connections_for(assembly_component_id)
|
||||
if self.active_assembly_component == assembly_component_id:
|
||||
self.active_assembly_component = next(
|
||||
iter(self.components.keys()), None
|
||||
)
|
||||
self.modified_at = datetime.now()
|
||||
return True
|
||||
return False
|
||||
|
||||
def get_active_instance(self) -> Optional[AssemblyComponent]:
|
||||
"""Get the currently active assembly component instance."""
|
||||
if (
|
||||
self.active_assembly_component
|
||||
and self.active_assembly_component in self.components
|
||||
):
|
||||
return self.components[self.active_assembly_component]
|
||||
return None
|
||||
|
||||
|
||||
@dataclass
|
||||
class Project:
|
||||
"""
|
||||
@@ -259,12 +623,85 @@ class Project:
|
||||
components: Dict[str, Component] = field(default_factory=dict)
|
||||
active_component: Optional[str] = None
|
||||
|
||||
assemblies: Dict[str, Assembly] = field(default_factory=dict)
|
||||
active_assembly: Optional[str] = None
|
||||
|
||||
kernel: OCGeometryKernel = field(default_factory=OCGeometryKernel)
|
||||
|
||||
created_at: datetime = field(default_factory=datetime.now)
|
||||
modified_at: datetime = field(default_factory=datetime.now)
|
||||
file_path: Optional[str] = None
|
||||
|
||||
# ── Component helpers ──
|
||||
|
||||
def add_component(self, component: Optional[Component] = None) -> Component:
|
||||
"""Add a component to the project."""
|
||||
if component is None:
|
||||
component = Component(name=f"Component {len(self.components) + 1}")
|
||||
self.components[component.id] = component
|
||||
if self.active_component is None:
|
||||
self.active_component = component.id
|
||||
self.modified_at = datetime.now()
|
||||
return component
|
||||
|
||||
def remove_component(self, component_id: str) -> bool:
|
||||
"""Remove a component from the project."""
|
||||
if component_id in self.components:
|
||||
del self.components[component_id]
|
||||
if self.active_component == component_id:
|
||||
self.active_component = next(iter(self.components.keys()), None)
|
||||
self.modified_at = datetime.now()
|
||||
return True
|
||||
return False
|
||||
|
||||
def get_active_component(self) -> Optional[Component]:
|
||||
"""Get the currently active component."""
|
||||
if self.active_component and self.active_component in self.components:
|
||||
return self.components[self.active_component]
|
||||
return None
|
||||
|
||||
def set_active_component(self, component_id: Optional[str]) -> None:
|
||||
"""Set the active component."""
|
||||
self.active_component = component_id
|
||||
self.modified_at = datetime.now()
|
||||
|
||||
# ── Assembly helpers ──
|
||||
|
||||
def add_assembly(self, assembly: Optional[Assembly] = None) -> Assembly:
|
||||
"""Add an assembly to the project."""
|
||||
if assembly is None:
|
||||
assembly = Assembly(name=f"Assembly {len(self.assemblies) + 1}")
|
||||
self.assemblies[assembly.id] = assembly
|
||||
if self.active_assembly is None:
|
||||
self.active_assembly = assembly.id
|
||||
self.modified_at = datetime.now()
|
||||
return assembly
|
||||
|
||||
def remove_assembly(self, assembly_id: str) -> bool:
|
||||
"""Remove an assembly from the project."""
|
||||
if assembly_id in self.assemblies:
|
||||
del self.assemblies[assembly_id]
|
||||
if self.active_assembly == assembly_id:
|
||||
self.active_assembly = next(iter(self.assemblies.keys()), None)
|
||||
self.modified_at = datetime.now()
|
||||
return True
|
||||
return False
|
||||
|
||||
def get_active_assembly(self) -> Optional[Assembly]:
|
||||
"""Get the currently active assembly."""
|
||||
if self.active_assembly and self.active_assembly in self.assemblies:
|
||||
return self.assemblies[self.active_assembly]
|
||||
return None
|
||||
|
||||
def set_active_assembly(self, assembly_id: Optional[str]) -> None:
|
||||
"""Set the active assembly."""
|
||||
self.active_assembly = assembly_id
|
||||
self.modified_at = datetime.now()
|
||||
|
||||
def get_component_by_id(self, component_id: str) -> Optional[Component]:
|
||||
"""Look up a component by id across all project components."""
|
||||
return self.components.get(component_id)
|
||||
|
||||
def add_component(self, component: Optional[Component] = None) -> Component:
|
||||
"""Add a component to the project."""
|
||||
if component is None:
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Rendering module."""
|
||||
|
||||
# OCC/OpenGL viewport renderers
|
||||
from fluency.rendering.base import (
|
||||
Renderer,
|
||||
RenderObject,
|
||||
@@ -8,10 +9,22 @@ from fluency.rendering.base import (
|
||||
from fluency.rendering.pygfx_renderer import PygfxRenderer, PygfxRenderObject
|
||||
from fluency.rendering.occ_renderer import OCCRenderer, OCCRenderObject
|
||||
|
||||
# Photorealistic render backends
|
||||
from fluency.rendering.render_backend import (
|
||||
RenderBackend,
|
||||
RenderMaterial,
|
||||
RenderCamera,
|
||||
RenderSettings,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"Renderer",
|
||||
"RenderObject",
|
||||
"RenderColor",
|
||||
"PygfxRenderer",
|
||||
"PygfxRenderObject",
|
||||
"RenderBackend",
|
||||
"RenderMaterial",
|
||||
"RenderCamera",
|
||||
"RenderSettings",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
"""Material presets for the render backend.
|
||||
|
||||
Each preset is a RenderMaterial with physically-plausible values.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Dict, List
|
||||
|
||||
from .render_backend import RenderMaterial
|
||||
|
||||
# ── Preset library ──────────────────────────────────────────────────────
|
||||
# Note: Mitsuba pip installs don't include spectral metal data files,
|
||||
# so metal_preset is not used. Instead, metals use material="none" with
|
||||
# specular_reflectance set to the metal color.
|
||||
|
||||
PRESETS: Dict[str, RenderMaterial] = {
|
||||
# ── Metals ──────────────────────────────────────────────────────
|
||||
"Brushed Steel": RenderMaterial(
|
||||
name="Brushed Steel",
|
||||
color=(0.65, 0.67, 0.72),
|
||||
metallic=0.9,
|
||||
roughness=0.35,
|
||||
bsdf_type="roughconductor",
|
||||
),
|
||||
"Polished Chrome": RenderMaterial(
|
||||
name="Polished Chrome",
|
||||
color=(0.8, 0.8, 0.8),
|
||||
metallic=1.0,
|
||||
roughness=0.05,
|
||||
bsdf_type="roughconductor",
|
||||
),
|
||||
"Brushed Aluminum": RenderMaterial(
|
||||
name="Brushed Aluminum",
|
||||
color=(0.75, 0.75, 0.75),
|
||||
metallic=0.85,
|
||||
roughness=0.25,
|
||||
bsdf_type="roughconductor",
|
||||
),
|
||||
"Copper": RenderMaterial(
|
||||
name="Copper",
|
||||
color=(0.95, 0.64, 0.54),
|
||||
metallic=0.95,
|
||||
roughness=0.15,
|
||||
bsdf_type="roughconductor",
|
||||
),
|
||||
"Gold": RenderMaterial(
|
||||
name="Gold",
|
||||
color=(1.0, 0.76, 0.33),
|
||||
metallic=1.0,
|
||||
roughness=0.1,
|
||||
bsdf_type="roughconductor",
|
||||
),
|
||||
"Blackened Steel": RenderMaterial(
|
||||
name="Blackened Steel",
|
||||
color=(0.15, 0.15, 0.17),
|
||||
metallic=0.8,
|
||||
roughness=0.4,
|
||||
bsdf_type="roughconductor",
|
||||
),
|
||||
# ── Plastics ────────────────────────────────────────────────────
|
||||
"Matte Plastic": RenderMaterial(
|
||||
name="Matte Plastic",
|
||||
color=(0.2, 0.5, 0.8),
|
||||
metallic=0.0,
|
||||
roughness=0.6,
|
||||
bsdf_type="plastic",
|
||||
int_ior=1.5,
|
||||
),
|
||||
"Glossy Plastic": RenderMaterial(
|
||||
name="Glossy Plastic",
|
||||
color=(0.2, 0.5, 0.8),
|
||||
metallic=0.0,
|
||||
roughness=0.1,
|
||||
bsdf_type="plastic",
|
||||
int_ior=1.5,
|
||||
),
|
||||
"White Nylon": RenderMaterial(
|
||||
name="White Nylon",
|
||||
color=(0.85, 0.85, 0.83),
|
||||
metallic=0.0,
|
||||
roughness=0.45,
|
||||
bsdf_type="plastic",
|
||||
int_ior=1.53,
|
||||
),
|
||||
"Black ABS": RenderMaterial(
|
||||
name="Black ABS",
|
||||
color=(0.05, 0.05, 0.05),
|
||||
metallic=0.0,
|
||||
roughness=0.35,
|
||||
bsdf_type="plastic",
|
||||
int_ior=1.54,
|
||||
),
|
||||
"Red PA12": RenderMaterial(
|
||||
name="Red PA12",
|
||||
color=(0.75, 0.08, 0.08),
|
||||
metallic=0.0,
|
||||
roughness=0.4,
|
||||
bsdf_type="plastic",
|
||||
int_ior=1.53,
|
||||
),
|
||||
# ── Other ───────────────────────────────────────────────────────
|
||||
"Rubber": RenderMaterial(
|
||||
name="Rubber",
|
||||
color=(0.1, 0.1, 0.1),
|
||||
metallic=0.0,
|
||||
roughness=0.9,
|
||||
bsdf_type="diffuse",
|
||||
),
|
||||
"Ceramic White": RenderMaterial(
|
||||
name="Ceramic White",
|
||||
color=(0.92, 0.91, 0.88),
|
||||
metallic=0.0,
|
||||
roughness=0.15,
|
||||
bsdf_type="dielectric",
|
||||
int_ior=1.55,
|
||||
),
|
||||
"Glass": RenderMaterial(
|
||||
name="Glass",
|
||||
color=(0.95, 0.95, 0.95),
|
||||
metallic=0.0,
|
||||
roughness=0.0,
|
||||
bsdf_type="dielectric",
|
||||
int_ior=1.52,
|
||||
),
|
||||
"Wood": RenderMaterial(
|
||||
name="Wood",
|
||||
color=(0.6, 0.4, 0.2),
|
||||
metallic=0.0,
|
||||
roughness=0.7,
|
||||
bsdf_type="diffuse",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def get_preset(name: str) -> RenderMaterial:
|
||||
"""Get a material preset by name. Falls back to default if not found."""
|
||||
if name in PRESETS:
|
||||
return PRESETS[name]
|
||||
return default_material()
|
||||
|
||||
|
||||
def default_material() -> RenderMaterial:
|
||||
"""Return the default grey material."""
|
||||
return RenderMaterial(
|
||||
name="Default",
|
||||
color=(0.7, 0.7, 0.7),
|
||||
metallic=0.0,
|
||||
roughness=0.5,
|
||||
bsdf_type="diffuse",
|
||||
)
|
||||
|
||||
|
||||
def preset_names() -> List[str]:
|
||||
"""Return sorted list of available preset names."""
|
||||
return sorted(PRESETS.keys())
|
||||
@@ -0,0 +1,382 @@
|
||||
"""Mitsuba 3 photorealistic render backend.
|
||||
|
||||
Requires: ``pip install mitsuba``
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from typing import Callable, Optional
|
||||
|
||||
import numpy as np
|
||||
|
||||
from .render_backend import RenderBackend, RenderCamera, RenderMaterial, RenderSettings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MitsubaBackend(RenderBackend):
|
||||
"""Render backend using Mitsuba 3 path tracer."""
|
||||
|
||||
def name(self) -> str:
|
||||
return "Mitsuba 3"
|
||||
|
||||
def is_available(self) -> bool:
|
||||
import sys
|
||||
import io
|
||||
|
||||
old_stderr = sys.stderr
|
||||
sys.stderr = io.StringIO()
|
||||
try:
|
||||
import mitsuba # noqa: F401
|
||||
|
||||
return True
|
||||
except ImportError:
|
||||
return False
|
||||
finally:
|
||||
sys.stderr = old_stderr
|
||||
|
||||
# ── Scene construction ──────────────────────────────────────────
|
||||
|
||||
def _build_scene_dict(
|
||||
self,
|
||||
mesh_path: str,
|
||||
material: RenderMaterial,
|
||||
camera: RenderCamera,
|
||||
settings: RenderSettings,
|
||||
) -> dict:
|
||||
"""Build a Mitsuba scene dictionary from our data classes.
|
||||
|
||||
Uses a 3-point lighting setup (key + fill + rim) plus an ambient
|
||||
environment emitter for soft fill, giving well-balanced shading on
|
||||
all faces of the model. Lighting intensities and colors come from
|
||||
``settings.lighting``; ground plane comes from ``settings.ground_plane``.
|
||||
"""
|
||||
import mitsuba as mi
|
||||
|
||||
lighting = settings.lighting
|
||||
ground = settings.ground_plane
|
||||
|
||||
# Map our BSDF types to Mitsuba BSDF dicts
|
||||
bsdf = self._make_bsdf(material)
|
||||
|
||||
# Determine mesh file type from extension
|
||||
ext = os.path.splitext(mesh_path)[1].lower()
|
||||
shape_type = "ply" if ext == ".ply" else "obj"
|
||||
|
||||
# Build camera-to-world transform using the Python API
|
||||
cam_to_world = mi.ScalarTransform4f.look_at(
|
||||
origin=list(camera.origin),
|
||||
target=list(camera.target),
|
||||
up=list(camera.up),
|
||||
)
|
||||
|
||||
scene = {
|
||||
"type": "scene",
|
||||
# Integrator
|
||||
"integrator": {
|
||||
"type": "path",
|
||||
"max_depth": settings.max_depth,
|
||||
},
|
||||
# Camera
|
||||
"sensor": {
|
||||
"type": "perspective",
|
||||
"fov": camera.fov,
|
||||
"to_world": cam_to_world,
|
||||
"film": {
|
||||
"type": "hdrfilm",
|
||||
"width": settings.width,
|
||||
"height": settings.height,
|
||||
"rfilter": {"type": "gaussian"},
|
||||
},
|
||||
"sampler": {
|
||||
"type": "independent",
|
||||
"sample_count": settings.spp,
|
||||
},
|
||||
},
|
||||
# Ambient environment fill
|
||||
"emitter": {
|
||||
"type": "constant",
|
||||
"radiance": {
|
||||
"type": "rgb",
|
||||
"value": [
|
||||
lighting.ambient_intensity,
|
||||
lighting.ambient_intensity * 0.97,
|
||||
lighting.ambient_intensity * 0.94,
|
||||
],
|
||||
},
|
||||
},
|
||||
# Shape
|
||||
"shape": {
|
||||
"type": shape_type,
|
||||
"filename": mesh_path,
|
||||
"bsdf": bsdf,
|
||||
},
|
||||
}
|
||||
|
||||
# ── 3-point lighting (colors and intensities from config) ───
|
||||
key_rgb = [c * lighting.key_intensity for c in lighting.key_color]
|
||||
key_to_world = mi.ScalarTransform4f.look_at(
|
||||
origin=[1.0, -0.8, 1.2],
|
||||
target=[0.0, 0.0, 0.0],
|
||||
up=[0.0, 0.0, 1.0],
|
||||
)
|
||||
scene["key_light"] = {
|
||||
"type": "directional",
|
||||
"to_world": key_to_world,
|
||||
"irradiance": {"type": "rgb", "value": key_rgb},
|
||||
}
|
||||
|
||||
fill_rgb = [c * lighting.fill_intensity for c in lighting.fill_color]
|
||||
fill_to_world = mi.ScalarTransform4f.look_at(
|
||||
origin=[-1.0, 0.6, 0.8],
|
||||
target=[0.0, 0.0, 0.0],
|
||||
up=[0.0, 0.0, 1.0],
|
||||
)
|
||||
scene["fill_light"] = {
|
||||
"type": "directional",
|
||||
"to_world": fill_to_world,
|
||||
"irradiance": {"type": "rgb", "value": fill_rgb},
|
||||
}
|
||||
|
||||
rim_rgb = [c * lighting.rim_intensity for c in lighting.rim_color]
|
||||
rim_to_world = mi.ScalarTransform4f.look_at(
|
||||
origin=[-0.3, 1.2, -0.8],
|
||||
target=[0.0, 0.0, 0.0],
|
||||
up=[0.0, 0.0, 1.0],
|
||||
)
|
||||
scene["rim_light"] = {
|
||||
"type": "directional",
|
||||
"to_world": rim_to_world,
|
||||
"irradiance": {"type": "rgb", "value": rim_rgb},
|
||||
}
|
||||
|
||||
# ── Ground plane / backdrop (optional) ─────────────────────
|
||||
if ground.enabled:
|
||||
try:
|
||||
# Load mesh to compute bounds for ground/backdrop placement
|
||||
mesh_shape = mi.load_dict({"type": shape_type, "filename": mesh_path})
|
||||
bbox = mesh_shape.bbox()
|
||||
bbox_min, bbox_max = bbox[0], bbox[1]
|
||||
# Ground at model's lowest Z with 0.1% offset
|
||||
model_height = bbox_max[2] - bbox_min[2]
|
||||
ground_z = bbox_min[2] - 0.001 * model_height
|
||||
dx = bbox_max[0] - bbox_min[0]
|
||||
dy = bbox_max[1] - bbox_min[1]
|
||||
dz = bbox_max[2] - bbox_min[2]
|
||||
diag = float((dx * dx + dy * dy + dz * dz) ** 0.5)
|
||||
except Exception:
|
||||
# Fallback: place at origin with large default size
|
||||
ground_z = -ground.distance_below
|
||||
diag = 1000.0
|
||||
|
||||
bsdf_ground = {
|
||||
"type": "diffuse",
|
||||
"reflectance": {"type": "rgb", "value": list(ground.color)},
|
||||
}
|
||||
|
||||
if ground.curved_backdrop:
|
||||
# Photo booth style curved leinwand:
|
||||
# - Flat floor section in front of the model
|
||||
# - Curved cylinder behind that sweeps up and over
|
||||
half_size = diag * 50.0 # huge floor
|
||||
radius = diag * 3.0 # curvature radius
|
||||
cyl_height = diag * 20.0 # width of the cylinder (along its axis)
|
||||
scene["ground_floor"] = {
|
||||
"type": "rectangle",
|
||||
"to_world": mi.ScalarTransform4f.translate([0.0, 0.0, ground_z])
|
||||
@ mi.ScalarTransform4f.scale([half_size, half_size, 1.0]),
|
||||
"bsdf": bsdf_ground,
|
||||
}
|
||||
# Cylinder: axis along Y, positioned behind model, radius sweeps up
|
||||
scene["ground_backdrop"] = {
|
||||
"type": "cylinder",
|
||||
"radius": radius,
|
||||
"p0": [-cyl_height / 2, -radius, ground_z],
|
||||
"p1": [cyl_height / 2, -radius, ground_z],
|
||||
"to_world": mi.ScalarTransform4f.rotate([1, 0, 0], 90)
|
||||
@ mi.ScalarTransform4f.translate([0.0, 0.0, -radius]),
|
||||
"bsdf": bsdf_ground,
|
||||
}
|
||||
else:
|
||||
# Simple flat ground plane — very large so edges aren't visible
|
||||
half_size = diag * 50.0
|
||||
scene["ground_plane"] = {
|
||||
"type": "rectangle",
|
||||
"to_world": mi.ScalarTransform4f.translate([0.0, 0.0, ground_z])
|
||||
@ mi.ScalarTransform4f.scale([half_size, half_size, 1.0]),
|
||||
"bsdf": bsdf_ground,
|
||||
}
|
||||
|
||||
return scene
|
||||
|
||||
def _make_bsdf(self, material: RenderMaterial) -> dict:
|
||||
"""Convert a RenderMaterial to a Mitsuba BSDF dict."""
|
||||
mt = material.bsdf_type
|
||||
|
||||
if mt == "roughconductor":
|
||||
# Use material="none" with specular_reflectance set to the
|
||||
# metal color. The pip-installed Mitsuba doesn't include
|
||||
# spectral metal data files (iron.spd, copper.spd, etc.).
|
||||
return {
|
||||
"type": "roughconductor",
|
||||
"material": "none",
|
||||
"alpha": max(material.roughness, 0.01),
|
||||
"specular_reflectance": {
|
||||
"type": "rgb",
|
||||
"value": list(material.color),
|
||||
},
|
||||
}
|
||||
|
||||
if mt == "roughdielectric":
|
||||
return {
|
||||
"type": "roughdielectric",
|
||||
"int_ior": material.int_ior,
|
||||
"ext_ior": 1.0,
|
||||
"alpha": max(material.roughness, 0.01),
|
||||
}
|
||||
|
||||
if mt == "dielectric":
|
||||
return {
|
||||
"type": "dielectric",
|
||||
"int_ior": material.int_ior,
|
||||
"ext_ior": 1.0,
|
||||
}
|
||||
|
||||
if mt == "plastic":
|
||||
return {
|
||||
"type": "plastic",
|
||||
"diffuse_reflectance": {
|
||||
"type": "rgb",
|
||||
"value": list(material.color),
|
||||
},
|
||||
"int_ior": material.int_ior,
|
||||
}
|
||||
|
||||
# Default: diffuse
|
||||
return {
|
||||
"type": "diffuse",
|
||||
"reflectance": {
|
||||
"type": "rgb",
|
||||
"value": list(material.color),
|
||||
},
|
||||
}
|
||||
|
||||
# ── Rendering ───────────────────────────────────────────────────
|
||||
|
||||
def render(
|
||||
self,
|
||||
mesh_path: str,
|
||||
material: RenderMaterial,
|
||||
camera: RenderCamera,
|
||||
settings: RenderSettings,
|
||||
progress_callback: Optional[Callable[[float], None]] = None,
|
||||
) -> np.ndarray:
|
||||
"""Render a mesh file and return (H, W, 3) float32 RGB array."""
|
||||
self._set_variant()
|
||||
import mitsuba as mi
|
||||
|
||||
scene_dict = self._build_scene_dict(mesh_path, material, camera, settings)
|
||||
scene = mi.load_dict(scene_dict)
|
||||
|
||||
logger.info(f"Rendering {settings.width}x{settings.height} @ {settings.spp} spp")
|
||||
|
||||
# Render
|
||||
try:
|
||||
image = mi.render(scene, spp=settings.spp, seed=int(settings.seed or 0)) # type: ignore[arg-type] # Mitsuba accepts int at runtime
|
||||
except Exception as e:
|
||||
logger.error(f"Mitsuba render failed: {e}")
|
||||
raise
|
||||
|
||||
if progress_callback:
|
||||
progress_callback(1.0)
|
||||
|
||||
# Convert to numpy (H, W, 3)
|
||||
arr = np.array(image, dtype=np.float32)
|
||||
|
||||
# Apply approximate sRGB tonemapping
|
||||
arr = np.clip(arr, 0.0, None)
|
||||
arr = np.power(arr, 1.0 / 2.2) # gamma
|
||||
arr = np.clip(arr, 0.0, 1.0)
|
||||
|
||||
return arr
|
||||
|
||||
def render_preview(
|
||||
self,
|
||||
mesh_path: str,
|
||||
material: RenderMaterial,
|
||||
camera: RenderCamera,
|
||||
settings: RenderSettings,
|
||||
) -> np.ndarray:
|
||||
"""Quick low-quality preview (4x fewer spp)."""
|
||||
preview_settings = RenderSettings(
|
||||
width=settings.width // 2,
|
||||
height=settings.height // 2,
|
||||
spp=max(settings.spp // 4, 16),
|
||||
max_depth=min(settings.max_depth, 4),
|
||||
seed=settings.seed,
|
||||
lighting=settings.lighting,
|
||||
ground_plane=settings.ground_plane,
|
||||
)
|
||||
return self.render(mesh_path, material, camera, preview_settings)
|
||||
|
||||
# ── Export ──────────────────────────────────────────────────────
|
||||
|
||||
def export_image(self, image: np.ndarray, path: str) -> None:
|
||||
"""Save a rendered image to PNG or EXR."""
|
||||
from PIL import Image
|
||||
|
||||
ext = os.path.splitext(path)[1].lower()
|
||||
|
||||
if ext == ".exr":
|
||||
# Save as EXR (HDR) — no tonemapping
|
||||
try:
|
||||
import OpenEXR # type: ignore[import-not-found]
|
||||
import Imath # type: ignore[import-not-found]
|
||||
|
||||
h, w = image.shape[:2]
|
||||
header = OpenEXR.Header(w, h)
|
||||
header["channels"] = {
|
||||
"R": Imath.PixelType(Imath.PixelType.FLOAT),
|
||||
"G": Imath.PixelType(Imath.PixelType.FLOAT),
|
||||
"B": Imath.PixelType(Imath.PixelType.FLOAT),
|
||||
}
|
||||
exr = OpenEXR.OutputFile(path, header)
|
||||
exr.write(
|
||||
{
|
||||
"R": image[:, :, 0].tobytes(),
|
||||
"G": image[:, :, 1].tobytes(),
|
||||
"B": image[:, :, 2].tobytes(),
|
||||
}
|
||||
)
|
||||
exr.close()
|
||||
except ImportError:
|
||||
# Fallback: save as 16-bit PNG
|
||||
logger.warning("OpenEXR not available, saving as 16-bit PNG")
|
||||
img = Image.fromarray((image * 65535).astype(np.uint16), "RGB")
|
||||
img.save(path)
|
||||
else:
|
||||
# PNG / JPEG — already tonemapped
|
||||
img = Image.fromarray((image * 255).astype(np.uint8), "RGB")
|
||||
img.save(path)
|
||||
|
||||
logger.info(f"Exported render to {path}")
|
||||
|
||||
# ── Helpers ─────────────────────────────────────────────────────
|
||||
|
||||
def _set_variant(self) -> None:
|
||||
"""Set the Mitsuba variant (called once)."""
|
||||
import sys
|
||||
import io
|
||||
|
||||
# Suppress the harmless "LLVM API initialization failed" warning
|
||||
# that drjit emits on macOS ARM when scalar variant is used.
|
||||
old_stderr = sys.stderr
|
||||
sys.stderr = io.StringIO()
|
||||
try:
|
||||
import mitsuba as mi
|
||||
|
||||
mi.set_variant("scalar_rgb")
|
||||
finally:
|
||||
sys.stderr = old_stderr
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,252 @@
|
||||
"""Convert OCC BRep shapes to mesh files for render backends.
|
||||
|
||||
Outputs PLY files (preferred by Mitsuba) or STL files.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
import numpy as np
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def occ_shape_to_ply(
|
||||
shape,
|
||||
output_path: Optional[str] = None,
|
||||
linear_deflection: float = 0.1,
|
||||
angular_deflection: float = 0.15,
|
||||
) -> str:
|
||||
"""Tessellate an OCC TopoDS_Shape and write as PLY.
|
||||
|
||||
Returns the path to the written PLY file.
|
||||
"""
|
||||
from OCP.BRepMesh import BRepMesh_IncrementalMesh
|
||||
from OCP.TopExp import TopExp_Explorer
|
||||
from OCP.TopAbs import TopAbs_FACE
|
||||
from OCP.TopoDS import TopoDS
|
||||
from OCP.BRep import BRep_Tool
|
||||
from OCP.TopLoc import TopLoc_Location
|
||||
|
||||
# Tessellate
|
||||
tess = BRepMesh_IncrementalMesh(
|
||||
shape, linear_deflection, False, angular_deflection, True
|
||||
)
|
||||
tess.Perform()
|
||||
|
||||
# Extract triangulation from all faces
|
||||
all_vertices: List[List[float]] = []
|
||||
all_faces: List[List[int]] = []
|
||||
vertex_offset = 0
|
||||
|
||||
from OCP.TopAbs import TopAbs_FORWARD
|
||||
|
||||
explorer = TopExp_Explorer(shape, TopAbs_FACE)
|
||||
while explorer.More():
|
||||
face = TopoDS.Face_s(explorer.Current())
|
||||
location = TopLoc_Location()
|
||||
triangulation = BRep_Tool.Triangulation_s(face, location)
|
||||
|
||||
if triangulation is None:
|
||||
explorer.Next()
|
||||
continue
|
||||
|
||||
# Transform
|
||||
trsf = location.Transformation()
|
||||
|
||||
# Check face orientation: FORWARD means the surface normal points
|
||||
# outward from the solid; REVERSED means it points inward.
|
||||
is_forward = (face.Orientation() == TopAbs_FORWARD)
|
||||
|
||||
# Extract vertices (apply location transform to positions)
|
||||
nb_nodes = triangulation.NbNodes()
|
||||
for i in range(1, nb_nodes + 1):
|
||||
node = triangulation.Node(i)
|
||||
pnt = node.Transformed(trsf)
|
||||
all_vertices.append([pnt.X(), pnt.Y(), pnt.Z()])
|
||||
|
||||
# Extract triangles
|
||||
# For REVERSED faces, swap winding order (n1, n3, n2) so that
|
||||
# the computed normal points outward consistently.
|
||||
nb_triangles = triangulation.NbTriangles()
|
||||
for i in range(1, nb_triangles + 1):
|
||||
tri = triangulation.Triangle(i)
|
||||
n1, n2, n3 = tri.Get()
|
||||
if is_forward:
|
||||
all_faces.append([
|
||||
n1 - 1 + vertex_offset,
|
||||
n2 - 1 + vertex_offset,
|
||||
n3 - 1 + vertex_offset,
|
||||
])
|
||||
else:
|
||||
# Swap winding for REVERSED faces
|
||||
all_faces.append([
|
||||
n1 - 1 + vertex_offset,
|
||||
n3 - 1 + vertex_offset,
|
||||
n2 - 1 + vertex_offset,
|
||||
])
|
||||
|
||||
vertex_offset += nb_nodes
|
||||
explorer.Next()
|
||||
|
||||
if not all_vertices:
|
||||
raise ValueError("Tessellation produced no vertices")
|
||||
|
||||
vertices = np.array(all_vertices, dtype=np.float32)
|
||||
faces = np.array(all_faces, dtype=np.uint32)
|
||||
|
||||
logger.info(
|
||||
f"Tessellation: {len(vertices)} vertices, {len(faces)} triangles"
|
||||
)
|
||||
|
||||
# Compute smooth vertex normals from face normals.
|
||||
# Winding is already corrected during tessellation using OCC face orientation.
|
||||
normals, corrected_faces = _compute_outward_normals(vertices, faces, shape)
|
||||
|
||||
# Write PLY with corrected faces and normals
|
||||
if output_path is None:
|
||||
fd, output_path = tempfile.mkstemp(suffix=".ply", prefix="fluency_render_")
|
||||
os.close(fd)
|
||||
|
||||
_write_ply(output_path, vertices, corrected_faces, normals)
|
||||
logger.info(f"Wrote PLY: {output_path}")
|
||||
return output_path
|
||||
|
||||
|
||||
def _compute_outward_normals(
|
||||
vertices: np.ndarray,
|
||||
faces: np.ndarray,
|
||||
shape,
|
||||
) -> Tuple[np.ndarray, np.ndarray]:
|
||||
"""Compute outward-facing vertex normals and correct face winding.
|
||||
|
||||
The winding is already corrected during tessellation using OCC's face
|
||||
orientation (TopAbs_FORWARD/REVERSED). This function computes smooth
|
||||
vertex normals by averaging face normals at shared vertices.
|
||||
|
||||
Returns (normals, corrected_faces) for PLY export.
|
||||
"""
|
||||
n_verts = len(vertices)
|
||||
v_normals = np.zeros((n_verts, 3), dtype=np.float64)
|
||||
|
||||
# Ensure faces is 2D (numpy creates (3,) for single-face meshes)
|
||||
if faces.ndim == 1:
|
||||
faces = faces.reshape(1, -1)
|
||||
|
||||
# Winding is already correct from tessellation (face orientation check).
|
||||
# Just compute face normals and accumulate to vertices.
|
||||
v0 = vertices[faces[:, 0]]
|
||||
v1 = vertices[faces[:, 1]]
|
||||
v2 = vertices[faces[:, 2]]
|
||||
|
||||
edge1 = v1 - v0
|
||||
edge2 = v2 - v0
|
||||
fn = np.cross(edge1, edge2)
|
||||
|
||||
# Normalize face normals
|
||||
lengths = np.linalg.norm(fn, axis=1, keepdims=True)
|
||||
lengths[lengths < 1e-10] = 1.0
|
||||
fn /= lengths
|
||||
|
||||
# Accumulate to vertices
|
||||
for i in range(len(faces)):
|
||||
idx = faces[i]
|
||||
v_normals[idx[0]] += fn[i]
|
||||
v_normals[idx[1]] += fn[i]
|
||||
v_normals[idx[2]] += fn[i]
|
||||
|
||||
# Normalize vertex normals
|
||||
v_lengths = np.linalg.norm(v_normals, axis=1, keepdims=True)
|
||||
v_lengths[v_lengths < 1e-10] = 1.0
|
||||
v_normals /= v_lengths
|
||||
|
||||
return v_normals.astype(np.float32), faces.astype(np.uint32)
|
||||
|
||||
|
||||
def occ_shape_to_stl(
|
||||
shape,
|
||||
output_path: Optional[str] = None,
|
||||
linear_deflection: float = 0.1,
|
||||
) -> str:
|
||||
"""Tessellate an OCC TopoDS_Shape and write as binary STL.
|
||||
|
||||
Returns the path to the written STL file.
|
||||
"""
|
||||
from OCP.BRepMesh import BRepMesh_IncrementalMesh
|
||||
from OCP.StlAPI import StlAPI_Writer
|
||||
|
||||
# Tessellate
|
||||
tess = BRepMesh_IncrementalMesh(shape, linear_deflection, False, 0.5, True)
|
||||
tess.Perform()
|
||||
|
||||
if output_path is None:
|
||||
fd, output_path = tempfile.mkstemp(suffix=".stl", prefix="fluency_render_")
|
||||
os.close(fd)
|
||||
|
||||
writer = StlAPI_Writer()
|
||||
writer.SetASCIIMode(False)
|
||||
writer.Write(shape, output_path)
|
||||
logger.info(f"Wrote STL: {output_path}")
|
||||
return output_path
|
||||
|
||||
|
||||
def occ_shape_bounds(shape) -> Tuple[Tuple[float, float, float], Tuple[float, float, float]]:
|
||||
"""Return (min_xyz, max_xyz) bounding box of an OCC shape."""
|
||||
from OCP.Bnd import Bnd_Box
|
||||
from OCP.BRepBndLib import BRepBndLib
|
||||
|
||||
bbox = Bnd_Box()
|
||||
BRepBndLib.Add_s(shape, bbox)
|
||||
xmin, ymin, zmin, xmax, ymax, zmax = bbox.Get()
|
||||
return (xmin, ymin, zmin), (xmax, ymax, zmax)
|
||||
|
||||
|
||||
def _write_ply(
|
||||
path: str,
|
||||
vertices: np.ndarray,
|
||||
faces: np.ndarray,
|
||||
normals: Optional[np.ndarray] = None,
|
||||
) -> None:
|
||||
"""Write a binary PLY file (little-endian) with optional vertex normals."""
|
||||
import struct
|
||||
|
||||
n_verts = len(vertices)
|
||||
n_faces = len(faces)
|
||||
has_normals = normals is not None and len(normals) == n_verts
|
||||
|
||||
with open(path, "wb") as f:
|
||||
# Header
|
||||
header_lines = [
|
||||
"ply",
|
||||
"format binary_little_endian 1.0",
|
||||
f"element vertex {n_verts}",
|
||||
"property float x",
|
||||
"property float y",
|
||||
"property float z",
|
||||
]
|
||||
if has_normals:
|
||||
header_lines.extend([
|
||||
"property float nx",
|
||||
"property float ny",
|
||||
"property float nz",
|
||||
])
|
||||
header_lines.append(f"element face {n_faces}")
|
||||
header_lines.append("property list uchar int vertex_indices")
|
||||
header_lines.append("end_header")
|
||||
|
||||
f.write(("\n".join(header_lines) + "\n").encode("ascii"))
|
||||
|
||||
# Vertex positions (+ normals if available)
|
||||
for i in range(n_verts):
|
||||
f.write(struct.pack("<fff", vertices[i, 0], vertices[i, 1], vertices[i, 2]))
|
||||
if has_normals:
|
||||
f.write(struct.pack("<fff", normals[i, 0], normals[i, 1], normals[i, 2]))
|
||||
|
||||
# Faces
|
||||
for face in faces:
|
||||
f.write(struct.pack("<B", 3))
|
||||
f.write(struct.pack("<iii", int(face[0]), int(face[1]), int(face[2])))
|
||||
@@ -0,0 +1,142 @@
|
||||
"""Abstract render backend interface.
|
||||
|
||||
Any photorealistic renderer (Mitsuba, Blender, Cycles, ...) implements
|
||||
:class:`RenderBackend`. The UI only talks to this ABC so backends can be
|
||||
swapped by changing one import.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from typing import Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class RenderMaterial:
|
||||
"""PBR material description for the render backend."""
|
||||
|
||||
name: str = "Default"
|
||||
color: tuple[float, float, float] = (0.7, 0.7, 0.7)
|
||||
metallic: float = 0.0 # 0.0 = dielectric, 1.0 = metal
|
||||
roughness: float = 0.5 # 0.0 = mirror, 1.0 = fully rough
|
||||
bsdf_type: str = "diffuse" # diffuse | roughconductor | roughdielectric | plastic
|
||||
# Optional: named metal preset (copper, aluminium, gold, chrome, steel)
|
||||
metal_preset: Optional[str] = None
|
||||
# For dielectric / plastic
|
||||
int_ior: float = 1.5
|
||||
|
||||
|
||||
@dataclass
|
||||
class RenderCamera:
|
||||
"""Camera parameters for the render."""
|
||||
|
||||
origin: tuple[float, float, float] = (100.0, 100.0, 100.0)
|
||||
target: tuple[float, float, float] = (0.0, 0.0, 0.0)
|
||||
up: tuple[float, float, float] = (0.0, 0.0, 1.0)
|
||||
fov: float = 60.0 # vertical field of view in degrees
|
||||
|
||||
|
||||
@dataclass
|
||||
class LightingConfig:
|
||||
"""Lighting configuration for the render scene."""
|
||||
|
||||
ambient_intensity: float = 0.3 # constant environment fill [0..1]
|
||||
key_color: tuple[float, float, float] = (1.0, 0.98, 0.95) # RGB key light color
|
||||
key_intensity: float = 3.5 # key light irradiance multiplier
|
||||
fill_color: tuple[float, float, float] = (0.92, 0.94, 1.0) # RGB fill light color
|
||||
fill_intensity: float = 1.5 # fill light irradiance multiplier
|
||||
rim_color: tuple[float, float, float] = (1.0, 0.98, 0.96) # RGB rim light color
|
||||
rim_intensity: float = 1.2 # rim light irradiance multiplier
|
||||
|
||||
|
||||
@dataclass
|
||||
class GroundPlaneConfig:
|
||||
"""Ground plane configuration for the render scene."""
|
||||
|
||||
enabled: bool = False
|
||||
color: tuple[float, float, float] = (0.5, 0.5, 0.5) # RGB diffuse color
|
||||
roughness: float = 0.8 # surface roughness [0..1]
|
||||
distance_below: float = 0.0 # mm below origin (positive = below)
|
||||
curved_backdrop: bool = False # photo booth style curved leinwand
|
||||
|
||||
|
||||
@dataclass
|
||||
class RenderSettings:
|
||||
"""Quality / resolution settings."""
|
||||
|
||||
width: int = 1920
|
||||
height: int = 1080
|
||||
spp: int = 256 # samples per pixel
|
||||
max_depth: int = 8 # max bounces for path tracer
|
||||
seed: int = 0 # random seed (0 = auto)
|
||||
lighting: LightingConfig = field(default_factory=LightingConfig)
|
||||
ground_plane: GroundPlaneConfig = field(default_factory=GroundPlaneConfig)
|
||||
|
||||
|
||||
class RenderBackend(ABC):
|
||||
"""Abstract photorealistic renderer.
|
||||
|
||||
Implementations live in separate modules so backends can be swapped
|
||||
without touching the UI. Typical call::
|
||||
|
||||
backend = MitsubaBackend()
|
||||
image = backend.render(obj_path, material, camera, settings)
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def name(self) -> str:
|
||||
"""Human-readable backend name (shown in UI)."""
|
||||
|
||||
@abstractmethod
|
||||
def is_available(self) -> bool:
|
||||
"""Return True if this backend's dependencies are installed."""
|
||||
|
||||
@abstractmethod
|
||||
def render(
|
||||
self,
|
||||
mesh_path: str,
|
||||
material: RenderMaterial,
|
||||
camera: RenderCamera,
|
||||
settings: RenderSettings,
|
||||
progress_callback=None,
|
||||
) -> "np.ndarray":
|
||||
"""Render a mesh file and return an (H, W, 3) float32 RGB array.
|
||||
|
||||
*mesh_path* is an STL or OBJ file on disk.
|
||||
*progress_callback(fraction)* is called with 0.0–1.0 progress.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def render_preview(
|
||||
self,
|
||||
mesh_path: str,
|
||||
material: RenderMaterial,
|
||||
camera: RenderCamera,
|
||||
settings: RenderSettings,
|
||||
) -> "np.ndarray":
|
||||
"""Quick low-quality preview (fewer spp)."""
|
||||
|
||||
@abstractmethod
|
||||
def export_image(self, image: "np.ndarray", path: str) -> None:
|
||||
"""Save a rendered image to PNG / EXR."""
|
||||
|
||||
def default_camera_from_bounds(
|
||||
self, bounds_min: tuple[float, float, float], bounds_max: tuple[float, float, float]
|
||||
) -> RenderCamera:
|
||||
"""Compute a sensible default camera looking at the bbox centre."""
|
||||
import numpy as np
|
||||
|
||||
mn = np.asarray(bounds_min, dtype=float)
|
||||
mx = np.asarray(bounds_max, dtype=float)
|
||||
centre = (mn + mx) / 2.0
|
||||
diag = float(np.linalg.norm(mx - mn))
|
||||
# Place camera at iso-ish position, far enough to see everything.
|
||||
eye = centre + np.array([0.7, -0.7, 0.5]) * diag * 0.8
|
||||
return RenderCamera(
|
||||
origin=tuple(eye.tolist()),
|
||||
target=tuple(centre.tolist()),
|
||||
up=(0.0, 0.0, 1.0),
|
||||
fov=45.0,
|
||||
)
|
||||
@@ -0,0 +1,372 @@
|
||||
"""
|
||||
Undo/Redo manager for OCCSketch using snapshot-based approach.
|
||||
|
||||
python_solvespace has no per-entity delete API — the solver is rebuilt from
|
||||
scratch after every modification. This makes snapshot-based undo natural:
|
||||
we capture the complete sketch state (entities, geometry, constraints) and
|
||||
restore it by rebuilding the solver from the snapshot data.
|
||||
|
||||
Each snapshot is ~10-50 KB depending on sketch complexity, and we cap the
|
||||
stack at a configurable depth (default 50).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Optional, Set, Tuple
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SketchSnapshot:
|
||||
"""Immutable snapshot of sketch state for undo/redo.
|
||||
|
||||
Captures everything needed to fully reconstruct an OCCSketch:
|
||||
entities, points, lines, circles, arcs, counter, constraint log,
|
||||
and the special entity id sets (centerlines, external/underlay).
|
||||
"""
|
||||
|
||||
# Entity registry: id → (entity_type, geometry, is_construction, is_external, constraints_list)
|
||||
entities: Dict[int, Tuple[str, Any, bool, bool, List[str]]] = field(default_factory=dict)
|
||||
|
||||
# Geometry sub-indices
|
||||
points: Dict[int, Tuple[float, float]] = field(default_factory=dict)
|
||||
lines: Dict[int, Tuple[int, int]] = field(default_factory=dict)
|
||||
circles: Dict[int, Tuple[int, float]] = field(default_factory=dict)
|
||||
arcs: Dict[int, Dict[str, Any]] = field(default_factory=dict)
|
||||
|
||||
# Counters and flags
|
||||
entity_counter: int = 0
|
||||
constraint_count: int = 0
|
||||
first_point_id: Optional[int] = None
|
||||
|
||||
# Constraint replay log
|
||||
constraint_log: List[Dict[str, Any]] = field(default_factory=list)
|
||||
|
||||
# Special entity sets
|
||||
centerline_ids: Set[int] = field(default_factory=set)
|
||||
external_entity_ids: Set[int] = field(default_factory=set)
|
||||
|
||||
# Workplane (so undo doesn't lose the placement plane)
|
||||
wp_origin: Tuple[float, float, float] = (0.0, 0.0, 0.0)
|
||||
wp_normal: Tuple[float, float, float] = (0.0, 0.0, 1.0)
|
||||
wp_x_dir: Tuple[float, float, float] = (1.0, 0.0, 0.0)
|
||||
wp_y_dir: Tuple[float, float, float] = (0.0, 1.0, 0.0)
|
||||
|
||||
|
||||
class SketchUndoManager:
|
||||
"""Manages undo/redo stacks of SketchSnapshot for an OCCSketch.
|
||||
|
||||
Usage::
|
||||
|
||||
undo_mgr = SketchUndoManager(sketch)
|
||||
|
||||
# Before any modification:
|
||||
undo_mgr.save_state()
|
||||
|
||||
# ... perform modification ...
|
||||
|
||||
# Ctrl+Z:
|
||||
undo_mgr.undo()
|
||||
|
||||
# Ctrl+Y / Ctrl+Shift+Z:
|
||||
undo_mgr.redo()
|
||||
"""
|
||||
|
||||
def __init__(self, sketch: Any, max_stack_size: int = 50) -> None:
|
||||
from fluency.geometry_occ.sketch import OCCSketch
|
||||
|
||||
self._sketch: OCCSketch = sketch
|
||||
self._max_stack_size = max_stack_size
|
||||
self._undo_stack: List[SketchSnapshot] = []
|
||||
self._redo_stack: List[SketchSnapshot] = []
|
||||
|
||||
# ─── Public API ────────────────────────────────────────────────────────
|
||||
|
||||
@property
|
||||
def can_undo(self) -> bool:
|
||||
"""True if there is a state to undo to."""
|
||||
return len(self._undo_stack) > 0
|
||||
|
||||
@property
|
||||
def can_redo(self) -> bool:
|
||||
"""True if there is a state to redo to."""
|
||||
return len(self._redo_stack) > 0
|
||||
|
||||
@property
|
||||
def undo_depth(self) -> int:
|
||||
"""Number of undo levels available."""
|
||||
return len(self._undo_stack)
|
||||
|
||||
@property
|
||||
def redo_depth(self) -> int:
|
||||
"""Number of redo levels available."""
|
||||
return len(self._redo_stack)
|
||||
|
||||
def save_state(self) -> None:
|
||||
"""Capture the current sketch state and push it onto the undo stack.
|
||||
|
||||
Call this **before** any modifying operation (draw, delete, move,
|
||||
constraint add/remove, construction toggle, etc.).
|
||||
|
||||
The redo stack is cleared whenever a new state is saved (i.e. when
|
||||
the user makes a new change after undoing).
|
||||
"""
|
||||
snapshot = self._capture()
|
||||
self._undo_stack.append(snapshot)
|
||||
|
||||
# Cap the stack size.
|
||||
if len(self._undo_stack) > self._max_stack_size:
|
||||
self._undo_stack.pop(0)
|
||||
|
||||
# New mutation invalidates the redo history.
|
||||
self._redo_stack.clear()
|
||||
|
||||
logger.debug(
|
||||
f"save_state: undo_depth={len(self._undo_stack)} "
|
||||
f"entities={len(snapshot.entities)}"
|
||||
)
|
||||
|
||||
def undo(self) -> bool:
|
||||
"""Restore the previous sketch state.
|
||||
|
||||
Returns True if a state was restored, False if the undo stack is empty.
|
||||
"""
|
||||
if not self._undo_stack:
|
||||
logger.debug("undo: stack empty")
|
||||
return False
|
||||
|
||||
# Save current state to redo stack before restoring.
|
||||
current_snapshot = self._capture()
|
||||
self._redo_stack.append(current_snapshot)
|
||||
|
||||
# Pop and restore.
|
||||
snapshot = self._undo_stack.pop()
|
||||
self._restore(snapshot)
|
||||
|
||||
logger.debug(
|
||||
f"undo: restored state with {len(snapshot.entities)} entities, "
|
||||
f"undo_depth={len(self._undo_stack)} redo_depth={len(self._redo_stack)}"
|
||||
)
|
||||
return True
|
||||
|
||||
def redo(self) -> bool:
|
||||
"""Re-apply the most recently undone state.
|
||||
|
||||
Returns True if a state was restored, False if the redo stack is empty.
|
||||
"""
|
||||
if not self._redo_stack:
|
||||
logger.debug("redo: stack empty")
|
||||
return False
|
||||
|
||||
# Save current state to undo stack before restoring.
|
||||
current_snapshot = self._capture()
|
||||
self._undo_stack.append(current_snapshot)
|
||||
|
||||
# Pop and restore.
|
||||
snapshot = self._redo_stack.pop()
|
||||
self._restore(snapshot)
|
||||
|
||||
logger.debug(
|
||||
f"redo: restored state with {len(snapshot.entities)} entities, "
|
||||
f"undo_depth={len(self._undo_stack)} redo_depth={len(self._redo_stack)}"
|
||||
)
|
||||
return True
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Clear both stacks (e.g. when loading a new sketch)."""
|
||||
self._undo_stack.clear()
|
||||
self._redo_stack.clear()
|
||||
logger.debug("undo stacks cleared")
|
||||
|
||||
# ─── Snapshot Capture ──────────────────────────────────────────────────
|
||||
|
||||
def _capture(self) -> SketchSnapshot:
|
||||
"""Capture the current sketch state into a snapshot."""
|
||||
sketch = self._sketch
|
||||
|
||||
# Capture entities: id → (type, geometry, is_construction, is_external, constraints)
|
||||
entities: Dict[int, Tuple[str, Any, bool, bool, List[str]]] = {}
|
||||
for eid, ent in sketch._entities.items():
|
||||
entities[eid] = (
|
||||
ent.entity_type,
|
||||
ent.geometry,
|
||||
ent.is_construction,
|
||||
ent.is_external,
|
||||
list(ent.constraints), # copy the constraints list
|
||||
)
|
||||
|
||||
# Deep copy the mutable dicts (points coords are tuples, so shallow is fine,
|
||||
# but arcs contain dicts so we deep-copy those).
|
||||
points = dict(sketch._points)
|
||||
lines = dict(sketch._lines)
|
||||
circles = dict(sketch._circles)
|
||||
arcs = {k: copy.deepcopy(v) for k, v in sketch._arcs.items()}
|
||||
|
||||
# Constraint log entries contain tuples and sets — need careful copy.
|
||||
constraint_log = []
|
||||
for entry in sketch._constraint_log:
|
||||
copied = {
|
||||
"type": entry["type"],
|
||||
"ids": tuple(entry["ids"]),
|
||||
"params": tuple(entry["params"]) if entry.get("params") else (),
|
||||
"labels": set(entry["labels"]) if entry.get("labels") else set(),
|
||||
}
|
||||
constraint_log.append(copied)
|
||||
|
||||
return SketchSnapshot(
|
||||
entities=entities,
|
||||
points=points,
|
||||
lines=lines,
|
||||
circles=circles,
|
||||
arcs=arcs,
|
||||
entity_counter=sketch._entity_counter,
|
||||
constraint_count=sketch._constraint_count,
|
||||
first_point_id=sketch._first_point_id,
|
||||
constraint_log=constraint_log,
|
||||
centerline_ids=set(sketch._centerline_ids),
|
||||
external_entity_ids=set(sketch._external_entity_ids),
|
||||
wp_origin=sketch._wp_origin,
|
||||
wp_normal=sketch._wp_normal,
|
||||
wp_x_dir=sketch._wp_x_dir,
|
||||
wp_y_dir=sketch._wp_y_dir,
|
||||
)
|
||||
|
||||
# ─── Snapshot Restore ──────────────────────────────────────────────────
|
||||
|
||||
def _restore(self, snapshot: SketchSnapshot) -> None:
|
||||
"""Restore the sketch to a previously captured snapshot state."""
|
||||
from fluency.geometry_occ.sketch import OCCSketch, OCCSketchEntity
|
||||
|
||||
sketch = self._sketch
|
||||
|
||||
# Clear the current solver and rebuild from scratch.
|
||||
sketch._solver = sketch._solver.__class__() # SolverSystem()
|
||||
sketch._wp = sketch._solver.create_2d_base()
|
||||
sketch._first_point_id = None
|
||||
|
||||
# Restore counters and flags.
|
||||
sketch._entity_counter = snapshot.entity_counter
|
||||
sketch._constraint_count = snapshot.constraint_count
|
||||
sketch._centerline_ids = set(snapshot.centerline_ids)
|
||||
sketch._external_entity_ids = set(snapshot.external_entity_ids)
|
||||
|
||||
# Restore workplane.
|
||||
sketch._wp_origin = snapshot.wp_origin
|
||||
sketch._wp_normal = snapshot.wp_normal
|
||||
sketch._wp_x_dir = snapshot.wp_x_dir
|
||||
sketch._wp_y_dir = snapshot.wp_y_dir
|
||||
|
||||
# Rebuild entity objects from the snapshot.
|
||||
sketch._entities.clear()
|
||||
sketch._points.clear()
|
||||
sketch._lines.clear()
|
||||
sketch._circles.clear()
|
||||
sketch._arcs.clear()
|
||||
|
||||
# First pass: re-add all points to the solver.
|
||||
for eid in sorted(snapshot.entities.keys()):
|
||||
etype, geometry, is_constr, is_ext, constraints = snapshot.entities[eid]
|
||||
if etype == "point" and eid in snapshot.points:
|
||||
x, y = snapshot.points[eid]
|
||||
solver_handle = sketch._solver.add_point_2d(x, y, sketch._wp)
|
||||
ent = OCCSketchEntity(
|
||||
entity_id=eid,
|
||||
entity_type="point",
|
||||
geometry=(x, y),
|
||||
handle=solver_handle,
|
||||
)
|
||||
ent.is_construction = is_constr
|
||||
ent.is_external = is_ext
|
||||
ent.constraints = list(constraints)
|
||||
sketch._entities[eid] = ent
|
||||
sketch._points[eid] = (x, y)
|
||||
|
||||
# Anchor the first point (or first external point) for solver stability.
|
||||
if sketch._first_point_id is None and not is_ext:
|
||||
sketch._first_point_id = eid
|
||||
sketch._solver.dragged(solver_handle, sketch._wp)
|
||||
elif sketch._first_point_id is None and is_ext:
|
||||
sketch._first_point_id = eid
|
||||
sketch._solver.dragged(solver_handle, sketch._wp)
|
||||
|
||||
# Second pass: re-add all lines.
|
||||
for lid in sorted(snapshot.lines.keys()):
|
||||
sid, eid2 = snapshot.lines[lid]
|
||||
s_ent = sketch._entities.get(sid)
|
||||
e_ent = sketch._entities.get(eid2)
|
||||
if s_ent is None or e_ent is None or s_ent.handle is None or e_ent.handle is None:
|
||||
continue
|
||||
|
||||
solver_handle = sketch._solver.add_line_2d(s_ent.handle, e_ent.handle, sketch._wp)
|
||||
|
||||
etype, geometry, is_constr, is_ext, constraints = snapshot.entities[lid]
|
||||
ent = OCCSketchEntity(
|
||||
entity_id=lid,
|
||||
entity_type="line",
|
||||
geometry=geometry,
|
||||
handle=solver_handle,
|
||||
)
|
||||
ent.is_construction = is_constr
|
||||
ent.is_external = is_ext
|
||||
ent.constraints = list(constraints)
|
||||
sketch._entities[lid] = ent
|
||||
sketch._lines[lid] = (sid, eid2)
|
||||
|
||||
# Restore circles (not in solver, just tracked).
|
||||
for cid, (center_id, radius) in snapshot.circles.items():
|
||||
if cid in snapshot.entities:
|
||||
etype, geometry, is_constr, is_ext, constraints = snapshot.entities[cid]
|
||||
center_ent = sketch._entities.get(center_id)
|
||||
ent = OCCSketchEntity(
|
||||
entity_id=cid,
|
||||
entity_type="circle",
|
||||
geometry=geometry,
|
||||
handle=None,
|
||||
)
|
||||
ent.is_construction = is_constr
|
||||
ent.is_external = is_ext
|
||||
ent.constraints = list(constraints)
|
||||
sketch._entities[cid] = ent
|
||||
sketch._circles[cid] = (center_id, radius)
|
||||
|
||||
# Restore arcs (not in solver, just tracked).
|
||||
for aid, arc_data in snapshot.arcs.items():
|
||||
if aid in snapshot.entities:
|
||||
etype, geometry, is_constr, is_ext, constraints = snapshot.entities[aid]
|
||||
ent = OCCSketchEntity(
|
||||
entity_id=aid,
|
||||
entity_type="arc",
|
||||
geometry=geometry,
|
||||
handle=None,
|
||||
)
|
||||
ent.is_construction = is_constr
|
||||
ent.is_external = is_ext
|
||||
ent.constraints = list(constraints)
|
||||
sketch._entities[aid] = ent
|
||||
sketch._arcs[aid] = copy.deepcopy(arc_data)
|
||||
|
||||
# Rebuild the constraint log (entries were deep-copied on capture).
|
||||
sketch._constraint_log = []
|
||||
for entry in snapshot.constraint_log:
|
||||
sketch._constraint_log.append({
|
||||
"type": entry["type"],
|
||||
"ids": tuple(entry["ids"]),
|
||||
"params": tuple(entry["params"]) if entry.get("params") else (),
|
||||
"labels": set(entry["labels"]) if entry.get("labels") else set(),
|
||||
})
|
||||
|
||||
# Re-apply all constraints to the solver.
|
||||
for entry in sketch._constraint_log:
|
||||
sketch._apply_constraint_log(entry)
|
||||
|
||||
# Solve to update geometry positions.
|
||||
sketch.solve()
|
||||
|
||||
logger.debug(
|
||||
f"Restored snapshot: {len(sketch._entities)} entities, "
|
||||
f"{len(sketch._constraint_log)} constraints"
|
||||
)
|
||||
@@ -0,0 +1,240 @@
|
||||
"""Smoke test for project_io save/load round-trip.
|
||||
|
||||
Builds a small project (a Component with a sketch, an extrude body, a
|
||||
workplane, plus an assembly with two instances and a connector) and
|
||||
verifies that saving then loading it preserves the data.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
# Allow running this file directly: ``python tests/test_project_io.py``.
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir, "src"))
|
||||
|
||||
from fluency.io.project_io import save_project, load_project
|
||||
from fluency.models.data_model import (
|
||||
Project,
|
||||
Component,
|
||||
Body,
|
||||
Workplane,
|
||||
Assembly,
|
||||
)
|
||||
|
||||
|
||||
class TestProjectIO(unittest.TestCase):
|
||||
"""Round-trip the same project through save/load and check equivalence."""
|
||||
|
||||
def _build_project(self) -> Project:
|
||||
project = Project(name="Test Project", description="A tiny test")
|
||||
project.file_path = None # simulate untitled
|
||||
|
||||
comp = project.add_component(Component(name="Part1"))
|
||||
|
||||
# Add a workplane.
|
||||
wp = Workplane(
|
||||
name="Top",
|
||||
origin=(0.0, 0.0, 0.0),
|
||||
normal=(0.0, 0.0, 1.0),
|
||||
x_dir=(1.0, 0.0, 0.0),
|
||||
)
|
||||
comp.add_workplane(wp)
|
||||
|
||||
# Add a sketch with a square.
|
||||
sk = comp.add_sketch()
|
||||
sk.occ_sketch.add_rectangle((0.0, 0.0), (10.0, 10.0))
|
||||
sk.solve()
|
||||
# Build a face geometry for the sketch (needed for export / restore).
|
||||
faces = sk.occ_sketch.detect_faces()
|
||||
if faces:
|
||||
sk.geometry = sk.occ_sketch.build_face_geometry(faces[0])
|
||||
|
||||
# Extrude into a body.
|
||||
if sk.geometry:
|
||||
kernel = project.kernel
|
||||
body_shape = kernel.extrude(sk.geometry, height=20.0)
|
||||
body = comp.add_body(
|
||||
Body(
|
||||
name="Block",
|
||||
geometry=body_shape,
|
||||
source_sketch=sk,
|
||||
source_operation="extrude",
|
||||
)
|
||||
)
|
||||
body.color = (0.4, 0.2, 0.8)
|
||||
|
||||
# Add an assembly with two instances and a mated connector pair.
|
||||
asm = project.add_assembly(Assembly(name="Asm1"))
|
||||
ac1 = asm.add_component_instance(comp.id, name="Inst1")
|
||||
ac2 = asm.add_component_instance(comp.id, name="Inst2")
|
||||
c1 = ac1.add_connector(
|
||||
position=(5.0, 5.0, 0.0),
|
||||
normal=(0.0, 0.0, 1.0),
|
||||
x_dir=(1.0, 0.0, 0.0),
|
||||
)
|
||||
c2 = ac2.add_connector(
|
||||
position=(10.0, 10.0, 5.0),
|
||||
normal=(0.0, 0.0, -1.0),
|
||||
x_dir=(1.0, 0.0, 0.0),
|
||||
)
|
||||
# Record a mated pair (UI normally does this on connector-pick).
|
||||
conn = asm.add_connection(ac1.id, ac2.id)
|
||||
conn.first_connector_id = c1.id
|
||||
conn.second_connector_id = c2.id
|
||||
c1.partner_ac_id = ac2.id
|
||||
c1.partner_connector_id = c2.id
|
||||
c2.partner_ac_id = ac1.id
|
||||
c2.partner_connector_id = c1.id
|
||||
c1.is_grounded = True
|
||||
|
||||
return project
|
||||
|
||||
def test_round_trip(self):
|
||||
original = self._build_project()
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = os.path.join(tmp, "test.fluency")
|
||||
saved_path = save_project(original, path)
|
||||
self.assertTrue(os.path.exists(saved_path))
|
||||
self.assertGreater(os.path.getsize(saved_path), 100)
|
||||
|
||||
loaded, view_state = load_project(saved_path)
|
||||
|
||||
# ── Project metadata ──
|
||||
self.assertEqual(loaded.name, "Test Project")
|
||||
self.assertEqual(loaded.description, "A tiny test")
|
||||
self.assertEqual(len(loaded.components), 1)
|
||||
self.assertEqual(len(loaded.assemblies), 1)
|
||||
|
||||
# ── Component / Workplane / Sketch / Body ──
|
||||
comp = next(iter(loaded.components.values()))
|
||||
self.assertEqual(comp.name, "Part1")
|
||||
self.assertEqual(len(comp.workplanes), 1)
|
||||
self.assertEqual(len(comp.sketches), 1)
|
||||
self.assertEqual(len(comp.bodies), 1)
|
||||
|
||||
sk = next(iter(comp.sketches.values()))
|
||||
self.assertIsNotNone(sk.occ_sketch)
|
||||
# OCCSketch replayed the rectangle: 4 points + 4 lines + 1 implicit
|
||||
# origin anchor (first-point-fix). We only check that the entities
|
||||
# exist.
|
||||
self.assertGreaterEqual(sk.occ_sketch.get_entity_count(), 4)
|
||||
# Solved geometry should round-trip through STEP.
|
||||
self.assertIsNotNone(sk.geometry)
|
||||
|
||||
body = next(iter(comp.bodies.values()))
|
||||
self.assertIsNotNone(body.geometry)
|
||||
self.assertEqual(body.name, "Block")
|
||||
self.assertEqual(tuple(body.color), (0.4, 0.2, 0.8))
|
||||
# BRep topology should still be valid.
|
||||
self.assertGreater(body.get_mesh(loaded.kernel, 0.5)[0].size, 0)
|
||||
|
||||
# ── Assembly / connector / connection ──
|
||||
asm = next(iter(loaded.assemblies.values()))
|
||||
self.assertEqual(len(asm.components), 2)
|
||||
self.assertEqual(len(asm.connections), 1)
|
||||
ac1, ac2 = list(asm.components.values())
|
||||
self.assertEqual(len(ac1.connectors), 1)
|
||||
conn = next(iter(ac1.connectors.values()))
|
||||
self.assertEqual(conn.position, (5.0, 5.0, 0.0))
|
||||
# The grounded-reference flag was re-applied to the first connector.
|
||||
self.assertTrue(conn.is_grounded)
|
||||
# Rigid-group BFS should still link the two instances.
|
||||
self.assertEqual(set(asm.get_rigid_group(ac1.id)), {ac1.id, ac2.id})
|
||||
|
||||
|
||||
class TestProjectIOWithConstraints(unittest.TestCase):
|
||||
"""Round-trip with parametric constraints on the sketch.
|
||||
|
||||
Builds a slightly more interesting sketch (rectangle with horizontal +
|
||||
vertical constraints + a distance) so the constraint-log replay path
|
||||
is exercised, not just the entity-construction path.
|
||||
"""
|
||||
|
||||
def _build_project(self) -> Project:
|
||||
project = Project(name="Constraints Project", description="")
|
||||
|
||||
comp = project.add_component(Component(name="Part1"))
|
||||
|
||||
# A square with a 25.4mm horizontal distance + a vertical distance,
|
||||
# both anchored on a single fixed corner. This is the minimum
|
||||
# number of constraints to fully define a square in 2D.
|
||||
sk = comp.add_sketch()
|
||||
sk.occ_sketch.set_workplane(
|
||||
(0.0, 0.0, 0.0),
|
||||
(0.0, 0.0, 1.0),
|
||||
(1.0, 0.0, 0.0),
|
||||
)
|
||||
p1 = sk.occ_sketch.add_point(0.0, 0.0) # fixed anchor (auto)
|
||||
p2 = sk.occ_sketch.add_point(25.4, 0.0)
|
||||
p3 = sk.occ_sketch.add_point(25.4, 25.4)
|
||||
p4 = sk.occ_sketch.add_point(0.0, 25.4)
|
||||
sk.occ_sketch.add_line(p1, p2)
|
||||
sk.occ_sketch.add_line(p2, p3)
|
||||
sk.occ_sketch.add_line(p3, p4)
|
||||
sk.occ_sketch.add_line(p4, p1)
|
||||
# Constraint the right side to a known length (instead of relying
|
||||
# on the construction positions, which would just be redundant).
|
||||
sk.occ_sketch.constrain_distance(p2, p3, 25.4)
|
||||
sk.solve()
|
||||
sk.is_fully_constrained = sk.occ_sketch.is_fully_constrained()
|
||||
faces = sk.occ_sketch.detect_faces()
|
||||
if faces:
|
||||
sk.geometry = sk.occ_sketch.build_face_geometry(faces[0])
|
||||
|
||||
# View state to persist.
|
||||
view_state = {
|
||||
"active_tab": 0,
|
||||
"active_component_id": comp.id,
|
||||
"active_sketch_id": sk.id,
|
||||
"camera_eye": [50.0, 50.0, 50.0],
|
||||
"camera_at": [0.0, 0.0, 0.0],
|
||||
"camera_up": [0.0, 0.0, 1.0],
|
||||
"panel_focus": "sketch",
|
||||
"assembly_view_active": False,
|
||||
}
|
||||
self._view_state = view_state
|
||||
return project
|
||||
|
||||
def test_round_trip_with_view_state(self):
|
||||
original = self._build_project()
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = os.path.join(tmp, "constrained.fluency")
|
||||
save_project(original, path, view_state=self._view_state)
|
||||
loaded, view_state = load_project(path)
|
||||
|
||||
# View state must survive (modulo float-to-list round-tripping).
|
||||
self.assertEqual(view_state.get("active_component_id"),
|
||||
self._view_state["active_component_id"])
|
||||
self.assertEqual(view_state.get("active_sketch_id"),
|
||||
self._view_state["active_sketch_id"])
|
||||
self.assertEqual(view_state.get("camera_eye"),
|
||||
self._view_state["camera_eye"])
|
||||
self.assertEqual(view_state.get("panel_focus"),
|
||||
self._view_state["panel_focus"])
|
||||
|
||||
# Sketch must have replayed its constraints and remain solvable.
|
||||
comp = next(iter(loaded.components.values()))
|
||||
sk = next(iter(comp.sketches.values()))
|
||||
# Re-solve on the loaded sketch to confirm the post-replay
|
||||
# configuration is still consistent.
|
||||
self.assertTrue(sk.occ_sketch.solve())
|
||||
# The right edge of the square should still be 25.4mm tall.
|
||||
import math
|
||||
for lid, line in sk.occ_sketch._lines.items():
|
||||
sid, eid = line
|
||||
sx, sy = sk.occ_sketch._points[sid]
|
||||
ex, ey = sk.occ_sketch._points[eid]
|
||||
length = math.hypot(ex - sx, ey - sy)
|
||||
if abs(length) > 1e-3:
|
||||
self.assertAlmostEqual(
|
||||
length, 25.4, places=3,
|
||||
msg=f"Constraint replay broke: line {lid} = {length}",
|
||||
)
|
||||
break
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,15 @@
|
||||
"""Fluency CAD UI package.
|
||||
|
||||
Contains the Qt widgets, dialogs, and the main window:
|
||||
|
||||
ui/
|
||||
dialogs.py – All 4 modal dialogs (Extrude, Revolve, Offset, WorkplaneOrientation)
|
||||
sketch_widget.py – Sketch2DWidget (2D sketcher with constraint solver)
|
||||
viewer_widget.py – Viewer3DWidget (3D viewer / OCC canvas)
|
||||
main_window.py – MainWindow (application shell)
|
||||
|
||||
The public classes are re-exported from `fluency.main` so existing code that
|
||||
does `from fluency.main import MainWindow` continues to work.
|
||||
"""
|
||||
|
||||
__all__: list[str] = []
|
||||
@@ -0,0 +1,497 @@
|
||||
"""Dialogs for Fluency CAD operations: extrude, revolve, offset, workplane orientation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import math
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from PySide6.QtCore import Qt, QPoint, QPointF
|
||||
from PySide6.QtGui import QColor, QFont, QKeySequence
|
||||
from PySide6.QtWidgets import (
|
||||
QButtonGroup,
|
||||
QCheckBox,
|
||||
QComboBox,
|
||||
QDialog,
|
||||
QDialogButtonBox,
|
||||
QDoubleSpinBox,
|
||||
QFormLayout,
|
||||
QFrame,
|
||||
QGridLayout,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QPushButton,
|
||||
QRadioButton,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class ExtrudeDialog(QDialog):
|
||||
"""Dialog for extrude options.
|
||||
|
||||
Carries an optional ``preview_callback`` that is invoked whenever the
|
||||
user changes any option; the host uses it to render a live transparent
|
||||
preview of the operation result in the 3D view. Passing *False* (or
|
||||
*None*) to the callback tells the host to clear the preview.
|
||||
"""
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Extrude Options")
|
||||
self.setMinimumWidth(320)
|
||||
|
||||
self._preview_callback = None
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
|
||||
length_layout = QHBoxLayout()
|
||||
length_layout.addWidget(QLabel("Extrude Length (mm):"))
|
||||
self.length_input = QDoubleSpinBox()
|
||||
self.length_input.setDecimals(2)
|
||||
self.length_input.setRange(-10000, 10000)
|
||||
self.length_input.setValue(10)
|
||||
length_layout.addWidget(self.length_input)
|
||||
layout.addLayout(length_layout)
|
||||
|
||||
self.symmetric_checkbox = QCheckBox("Symmetric Extrude")
|
||||
layout.addWidget(self.symmetric_checkbox)
|
||||
|
||||
self.invert_checkbox = QCheckBox("Invert Extrusion")
|
||||
layout.addWidget(self.invert_checkbox)
|
||||
|
||||
self.cut_checkbox = QCheckBox("Perform Cut")
|
||||
layout.addWidget(self.cut_checkbox)
|
||||
|
||||
self.union_checkbox = QCheckBox("Combine (Union)")
|
||||
layout.addWidget(self.union_checkbox)
|
||||
|
||||
self.through_all_checkbox = QCheckBox("Through All (cut/union target)")
|
||||
self.through_all_checkbox.setToolTip(
|
||||
"Ignore the typed length and extrude far enough to fully pass "
|
||||
"through the cut/union target body. Applies when Perform Cut or "
|
||||
"Combine (Union) is checked."
|
||||
)
|
||||
layout.addWidget(self.through_all_checkbox)
|
||||
|
||||
self.rounded_checkbox = QCheckBox("Round Edges")
|
||||
layout.addWidget(self.rounded_checkbox)
|
||||
|
||||
line = QFrame()
|
||||
line.setFrameShape(QFrame.HLine)
|
||||
line.setFrameShadow(QFrame.Sunken)
|
||||
layout.addWidget(line)
|
||||
|
||||
button_layout = QHBoxLayout()
|
||||
ok_button = QPushButton("OK")
|
||||
ok_button.clicked.connect(self.accept)
|
||||
cancel_button = QPushButton("Cancel")
|
||||
cancel_button.clicked.connect(self.reject)
|
||||
button_layout.addWidget(ok_button)
|
||||
button_layout.addWidget(cancel_button)
|
||||
layout.addLayout(button_layout)
|
||||
|
||||
# Live preview: recompute on every option change. Use a light-
|
||||
# weight guard so we don't emit before the host has wired up the
|
||||
# callback.
|
||||
for w in (
|
||||
self.length_input,
|
||||
self.symmetric_checkbox,
|
||||
self.invert_checkbox,
|
||||
self.cut_checkbox,
|
||||
self.union_checkbox,
|
||||
self.through_all_checkbox,
|
||||
self.rounded_checkbox,
|
||||
):
|
||||
# The spinbox has valueChanged; the checkboxes have stateChanged.
|
||||
# Each must be wired in its own try/except so that a missing
|
||||
# signal on one widget type doesn't skip the OTHER signal's
|
||||
# connection (the prior single-try version accidentally
|
||||
# left checkboxes un-connected when valueChanged raised first).
|
||||
try:
|
||||
w.valueChanged.connect(self._emit_preview)
|
||||
except AttributeError:
|
||||
pass
|
||||
try:
|
||||
w.stateChanged.connect(self._emit_preview)
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
def set_preview_callback(self, callback) -> None:
|
||||
"""Install the live-preview callback (or *None* to disable)."""
|
||||
self._preview_callback = callback
|
||||
# Emit once so the initial state shows a preview right away.
|
||||
self._emit_preview()
|
||||
|
||||
def _emit_preview(self, *args) -> None:
|
||||
if self._preview_callback is None:
|
||||
return
|
||||
try:
|
||||
self._preview_callback(self.get_values())
|
||||
except Exception as exc: # preview must never break the dialog
|
||||
logger.debug("extrude preview callback raised: %s", exc)
|
||||
|
||||
def hideEvent(self, event):
|
||||
# Tell the host to clear the preview when the dialog goes away
|
||||
# (accept, reject, or close). The host is responsible for the
|
||||
# actual viewer cleanup.
|
||||
if self._preview_callback is not None:
|
||||
try:
|
||||
self._preview_callback(None)
|
||||
except Exception:
|
||||
pass
|
||||
super().hideEvent(event)
|
||||
|
||||
def get_values(self) -> Tuple[float, bool, bool, bool, bool, bool, bool]:
|
||||
return (
|
||||
self.length_input.value(),
|
||||
self.symmetric_checkbox.isChecked(),
|
||||
self.invert_checkbox.isChecked(),
|
||||
self.cut_checkbox.isChecked(),
|
||||
self.union_checkbox.isChecked(),
|
||||
self.through_all_checkbox.isChecked(),
|
||||
self.rounded_checkbox.isChecked(),
|
||||
)
|
||||
|
||||
|
||||
class RevolveDialog(QDialog):
|
||||
"""Dialog for revolve options."""
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Revolve Options")
|
||||
self.setMinimumWidth(300)
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
|
||||
angle_layout = QHBoxLayout()
|
||||
angle_layout.addWidget(QLabel("Revolve Angle (°):"))
|
||||
self.angle_input = QDoubleSpinBox()
|
||||
self.angle_input.setDecimals(1)
|
||||
self.angle_input.setRange(1, 360)
|
||||
self.angle_input.setValue(360)
|
||||
self.angle_input.setSuffix("°")
|
||||
angle_layout.addWidget(self.angle_input)
|
||||
layout.addLayout(angle_layout)
|
||||
|
||||
line = QFrame()
|
||||
line.setFrameShape(QFrame.HLine)
|
||||
line.setFrameShadow(QFrame.Sunken)
|
||||
layout.addWidget(line)
|
||||
|
||||
button_layout = QHBoxLayout()
|
||||
ok_button = QPushButton("OK")
|
||||
ok_button.clicked.connect(self.accept)
|
||||
cancel_button = QPushButton("Cancel")
|
||||
cancel_button.clicked.connect(self.reject)
|
||||
button_layout.addWidget(ok_button)
|
||||
button_layout.addWidget(cancel_button)
|
||||
layout.addLayout(button_layout)
|
||||
|
||||
|
||||
class OffsetDialog(QDialog):
|
||||
"""Dialog for 2D sketch offset options.
|
||||
|
||||
Shows a number input for the offset distance with a live preview
|
||||
callback so the sketch widget can render the offset result in real
|
||||
time. On accept the caller retrieves ``get_values()`` → distance.
|
||||
"""
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Offset Sketch")
|
||||
self.setMinimumWidth(300)
|
||||
|
||||
self._preview_callback = None
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
|
||||
dist_layout = QHBoxLayout()
|
||||
dist_layout.addWidget(QLabel("Offset Distance (mm):"))
|
||||
self.distance_input = QDoubleSpinBox()
|
||||
self.distance_input.setDecimals(2)
|
||||
self.distance_input.setRange(-10000, 10000)
|
||||
self.distance_input.setValue(10.0)
|
||||
self.distance_input.setSingleStep(0.5)
|
||||
dist_layout.addWidget(self.distance_input)
|
||||
layout.addLayout(dist_layout)
|
||||
|
||||
self.inward_checkbox = QCheckBox("Offset Inward (negative)")
|
||||
self.inward_checkbox.setToolTip("Offset is applied inward instead of outward.")
|
||||
layout.addWidget(self.inward_checkbox)
|
||||
|
||||
line = QFrame()
|
||||
line.setFrameShape(QFrame.HLine)
|
||||
line.setFrameShadow(QFrame.Sunken)
|
||||
layout.addWidget(line)
|
||||
|
||||
button_layout = QHBoxLayout()
|
||||
ok_button = QPushButton("OK")
|
||||
ok_button.clicked.connect(self.accept)
|
||||
cancel_button = QPushButton("Cancel")
|
||||
cancel_button.clicked.connect(self.reject)
|
||||
button_layout.addWidget(ok_button)
|
||||
button_layout.addWidget(cancel_button)
|
||||
layout.addLayout(button_layout)
|
||||
|
||||
# Live preview on every value change.
|
||||
self.distance_input.valueChanged.connect(self._emit_preview)
|
||||
self.inward_checkbox.stateChanged.connect(self._emit_preview)
|
||||
|
||||
def set_preview_callback(self, callback) -> None:
|
||||
"""Install the live-preview callback (or *None* to disable)."""
|
||||
self._preview_callback = callback
|
||||
self._emit_preview()
|
||||
|
||||
def _emit_preview(self, *args) -> None:
|
||||
if self._preview_callback is None:
|
||||
return
|
||||
try:
|
||||
self._preview_callback(self.get_values())
|
||||
except Exception as exc:
|
||||
logger.debug("offset preview callback raised: %s", exc)
|
||||
|
||||
def hideEvent(self, event):
|
||||
if self._preview_callback is not None:
|
||||
try:
|
||||
self._preview_callback(None)
|
||||
except Exception:
|
||||
pass
|
||||
super().hideEvent(event)
|
||||
|
||||
def get_values(self) -> Tuple[float, bool]:
|
||||
return (self.distance_input.value(), self.inward_checkbox.isChecked())
|
||||
|
||||
|
||||
class WorkplaneOrientationDialog(QDialog):
|
||||
"""Modal dialog to choose the orientation of a new workplane.
|
||||
|
||||
Offers XY, XZ, YZ, and custom angle presets. On accept, the caller
|
||||
can retrieve the chosen orientation via :meth:`get_orientation`, which
|
||||
returns (normal, x_dir) pair (both as 3-tuples).
|
||||
"""
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("New Workplane Orientation")
|
||||
self.setMinimumWidth(320)
|
||||
|
||||
self._normal: Tuple[float, float, float] = (0.0, 0.0, 1.0)
|
||||
self._x_dir: Tuple[float, float, float] = (1.0, 0.0, 0.0)
|
||||
|
||||
# Optional callback for live 3D preview of the workplane.
|
||||
# The host installs it via ``set_preview_callback``. The callback
|
||||
# receives ``(normal, x_dir)`` or *None* to clear.
|
||||
self._preview_callback = None
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
|
||||
# ── Orientation presets ──
|
||||
lbl = QLabel("Choose orientation:")
|
||||
layout.addWidget(lbl)
|
||||
|
||||
self._preset_group = QButtonGroup(self)
|
||||
preset_layout = QGridLayout()
|
||||
presets = [
|
||||
("XY (Top)", (0, 0, 1), (1, 0, 0)),
|
||||
("XZ (Front)", (0, 1, 0), (1, 0, 0)),
|
||||
("YZ (Right)", (1, 0, 0), (0, 1, 0)),
|
||||
("-XY (Bottom)", (0, 0, -1), (1, 0, 0)),
|
||||
("-XZ (Back)", (0, -1, 0), (1, 0, 0)),
|
||||
("-YZ (Left)", (-1, 0, 0), (0, 1, 0)),
|
||||
]
|
||||
for idx, (label, normal, x_dir) in enumerate(presets):
|
||||
btn = QRadioButton(label)
|
||||
btn.setChecked(idx == 0)
|
||||
self._preset_group.addButton(btn, idx)
|
||||
btn.normal = normal
|
||||
btn.x_dir = x_dir
|
||||
preset_layout.addWidget(btn, idx // 2, idx % 2)
|
||||
layout.addLayout(preset_layout)
|
||||
|
||||
# ── Custom angle (offset from XY) ──
|
||||
line = QFrame()
|
||||
line.setFrameShape(QFrame.HLine)
|
||||
line.setFrameShadow(QFrame.Sunken)
|
||||
layout.addWidget(line)
|
||||
|
||||
self._custom_radio = QRadioButton("Custom (angle from XY):")
|
||||
self._custom_radio.setChecked(False)
|
||||
layout.addWidget(self._custom_radio)
|
||||
|
||||
angle_layout = QHBoxLayout()
|
||||
angle_layout.addWidget(QLabel("Angle X (°):"))
|
||||
self._angle_x = QDoubleSpinBox()
|
||||
self._angle_x.setDecimals(1)
|
||||
self._angle_x.setRange(-360, 360)
|
||||
self._angle_x.setValue(0.0)
|
||||
self._angle_x.setSuffix("°")
|
||||
angle_layout.addWidget(self._angle_x)
|
||||
angle_layout.addWidget(QLabel("Angle Y (°):"))
|
||||
self._angle_y = QDoubleSpinBox()
|
||||
self._angle_y.setDecimals(1)
|
||||
self._angle_y.setRange(-360, 360)
|
||||
self._angle_y.setValue(0.0)
|
||||
self._angle_y.setSuffix("°")
|
||||
angle_layout.addWidget(self._angle_y)
|
||||
layout.addLayout(angle_layout)
|
||||
|
||||
self._name_label = QLabel("Workplane Name:")
|
||||
layout.addWidget(self._name_label)
|
||||
|
||||
self._name_input = QLineEdit()
|
||||
self._name_input.setText("Workplane 1")
|
||||
layout.addWidget(self._name_input)
|
||||
|
||||
# ── Buttons ──
|
||||
line2 = QFrame()
|
||||
line2.setFrameShape(QFrame.HLine)
|
||||
line2.setFrameShadow(QFrame.Sunken)
|
||||
layout.addWidget(line2)
|
||||
|
||||
button_layout = QHBoxLayout()
|
||||
ok_button = QPushButton("Create")
|
||||
ok_button.clicked.connect(self._on_ok)
|
||||
cancel_button = QPushButton("Cancel")
|
||||
cancel_button.clicked.connect(self.reject)
|
||||
button_layout.addWidget(ok_button)
|
||||
button_layout.addWidget(cancel_button)
|
||||
layout.addLayout(button_layout)
|
||||
|
||||
# Live preview: update the 3D view whenever the user changes
|
||||
# the preset, custom radio toggle, or angle values.
|
||||
self._preset_group.buttonClicked.connect(self._on_preset_changed)
|
||||
self._custom_radio.toggled.connect(self._emit_preview)
|
||||
self._angle_x.valueChanged.connect(self._emit_preview)
|
||||
self._angle_y.valueChanged.connect(self._emit_preview)
|
||||
|
||||
def set_preview_callback(self, callback) -> None:
|
||||
"""Install a callback for live 3D preview of the workplane orientation.
|
||||
|
||||
*callback* is called with ``(normal, x_dir)`` whenever the user
|
||||
changes the selection, or with *None* when the dialog closes.
|
||||
"""
|
||||
self._preview_callback = callback
|
||||
# Emit once so the initial state shows a preview right away.
|
||||
self._emit_preview()
|
||||
|
||||
def _emit_preview(self, *args) -> None:
|
||||
"""Call the preview callback with the current orientation, if installed."""
|
||||
if self._preview_callback is None:
|
||||
return
|
||||
try:
|
||||
normal, x_dir, _name = self.get_orientation()
|
||||
self._preview_callback((normal, x_dir))
|
||||
except Exception as exc:
|
||||
logger.debug("workplane preview callback raised: %s", exc)
|
||||
|
||||
def hideEvent(self, event):
|
||||
"""Clear the live preview when the dialog closes."""
|
||||
if self._preview_callback is not None:
|
||||
try:
|
||||
self._preview_callback(None)
|
||||
except Exception:
|
||||
pass
|
||||
super().hideEvent(event)
|
||||
|
||||
def _on_preset_changed(self, btn):
|
||||
"""When a preset is selected, deselect the custom radio and emit preview."""
|
||||
self._custom_radio.setChecked(False)
|
||||
self._emit_preview()
|
||||
|
||||
def _on_ok(self):
|
||||
"""Compute the final orientation and accept."""
|
||||
import numpy as np
|
||||
import math
|
||||
|
||||
if self._custom_radio.isChecked():
|
||||
# Custom: start from XY normal and rotate by the two angles.
|
||||
ax = math.radians(self._angle_x.value())
|
||||
ay = math.radians(self._angle_y.value())
|
||||
# Start from +Z normal, rotate around X then Y
|
||||
n = np.array([0.0, 0.0, 1.0])
|
||||
# Rotate around X
|
||||
rx = np.array([
|
||||
[1, 0, 0],
|
||||
[0, math.cos(ax), -math.sin(ax)],
|
||||
[0, math.sin(ax), math.cos(ax)],
|
||||
])
|
||||
n = rx @ n
|
||||
# Rotate around Y
|
||||
ry = np.array([
|
||||
[math.cos(ay), 0, math.sin(ay)],
|
||||
[0, 1, 0],
|
||||
[-math.sin(ay), 0, math.cos(ay)],
|
||||
])
|
||||
n = ry @ n
|
||||
n = n / np.linalg.norm(n)
|
||||
# x_dir: cross product of normal with world Y, or world Z if normal ~ Y
|
||||
world_y = np.array([0.0, 1.0, 0.0])
|
||||
if abs(np.dot(n, world_y)) > 0.99:
|
||||
world_y = np.array([0.0, 0.0, 1.0])
|
||||
x = np.cross(world_y, n)
|
||||
x_norm = np.linalg.norm(x)
|
||||
if x_norm > 1e-9:
|
||||
x = x / x_norm
|
||||
else:
|
||||
x = np.array([1.0, 0.0, 0.0])
|
||||
self._normal = tuple(float(v) for v in n)
|
||||
self._x_dir = tuple(float(v) for v in x)
|
||||
|
||||
else:
|
||||
btn = self._preset_group.checkedButton()
|
||||
if btn is not None:
|
||||
self._normal = btn.normal
|
||||
self._x_dir = btn.x_dir
|
||||
self.accept()
|
||||
|
||||
def get_orientation(self) -> Tuple[Tuple[float, float, float], Tuple[float, float, float], str]:
|
||||
"""Return (normal, x_dir, name) for the chosen workplane.
|
||||
|
||||
Computes the current selection from the UI state so it works
|
||||
whether called before or after ``_on_ok``.
|
||||
"""
|
||||
import numpy as np
|
||||
import math
|
||||
|
||||
if self._custom_radio.isChecked():
|
||||
ax = math.radians(self._angle_x.value())
|
||||
ay = math.radians(self._angle_y.value())
|
||||
n = np.array([0.0, 0.0, 1.0])
|
||||
rx = np.array([
|
||||
[1, 0, 0],
|
||||
[0, math.cos(ax), -math.sin(ax)],
|
||||
[0, math.sin(ax), math.cos(ax)],
|
||||
])
|
||||
n = rx @ n
|
||||
ry = np.array([
|
||||
[math.cos(ay), 0, math.sin(ay)],
|
||||
[0, 1, 0],
|
||||
[-math.sin(ay), 0, math.cos(ay)],
|
||||
])
|
||||
n = ry @ n
|
||||
n = n / np.linalg.norm(n)
|
||||
world_y = np.array([0.0, 1.0, 0.0])
|
||||
if abs(np.dot(n, world_y)) > 0.99:
|
||||
world_y = np.array([0.0, 0.0, 1.0])
|
||||
x = np.cross(world_y, n)
|
||||
x_norm = np.linalg.norm(x)
|
||||
if x_norm > 1e-9:
|
||||
x = x / x_norm
|
||||
else:
|
||||
x = np.array([1.0, 0.0, 0.0])
|
||||
return (
|
||||
tuple(float(v) for v in n),
|
||||
tuple(float(v) for v in x),
|
||||
self._name_input.text().strip() or "Workplane",
|
||||
)
|
||||
else:
|
||||
btn = self._preset_group.checkedButton()
|
||||
if btn is not None:
|
||||
return (btn.normal, btn.x_dir, self._name_input.text().strip() or "Workplane")
|
||||
# Fallback: XY default.
|
||||
return ((0.0, 0.0, 1.0), (1.0, 0.0, 0.0), self._name_input.text().strip() or "Workplane")
|
||||
|
||||
|
||||
@@ -0,0 +1,906 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
################################################################################
|
||||
## Form generated from reading UI file 'gui.ui'
|
||||
##
|
||||
## Created by: Qt User Interface Compiler version 6.10.2
|
||||
##
|
||||
## WARNING! All changes made in this file will be lost when recompiling UI file!
|
||||
################################################################################
|
||||
|
||||
from PySide6.QtCore import (QCoreApplication, QDate, QDateTime, QLocale,
|
||||
QMetaObject, QObject, QPoint, QRect,
|
||||
QSize, QTime, QUrl, Qt)
|
||||
from PySide6.QtGui import (QAction, QBrush, QColor, QConicalGradient,
|
||||
QCursor, QFont, QFontDatabase, QGradient,
|
||||
QIcon, QImage, QKeySequence, QLinearGradient,
|
||||
QPainter, QPalette, QPixmap, QRadialGradient,
|
||||
QTransform)
|
||||
from PySide6.QtWidgets import (QApplication, QFrame, QGridLayout, QGroupBox,
|
||||
QHBoxLayout, QLabel, QListWidget, QListWidgetItem,
|
||||
QMainWindow, QMenu, QMenuBar, QPushButton,
|
||||
QSizePolicy, QSpinBox, QStatusBar, QTabWidget,
|
||||
QTextEdit, QVBoxLayout, QWidget)
|
||||
|
||||
class Ui_fluencyCAD(object):
|
||||
def setupUi(self, fluencyCAD):
|
||||
if not fluencyCAD.objectName():
|
||||
fluencyCAD.setObjectName(u"fluencyCAD")
|
||||
fluencyCAD.resize(2359, 1285)
|
||||
self.actionNew_Project = QAction(fluencyCAD)
|
||||
self.actionNew_Project.setObjectName(u"actionNew_Project")
|
||||
self.actionOpen_Project = QAction(fluencyCAD)
|
||||
self.actionOpen_Project.setObjectName(u"actionOpen_Project")
|
||||
self.actionSave_Project = QAction(fluencyCAD)
|
||||
self.actionSave_Project.setObjectName(u"actionSave_Project")
|
||||
self.actionSave_Project_As = QAction(fluencyCAD)
|
||||
self.actionSave_Project_As.setObjectName(u"actionSave_Project_As")
|
||||
self.actionImport_File = QAction(fluencyCAD)
|
||||
self.actionImport_File.setObjectName(u"actionImport_File")
|
||||
self.actionExport_Step = QAction(fluencyCAD)
|
||||
self.actionExport_Step.setObjectName(u"actionExport_Step")
|
||||
self.actionExport_Iges = QAction(fluencyCAD)
|
||||
self.actionExport_Iges.setObjectName(u"actionExport_Iges")
|
||||
self.actionExport_Stl = QAction(fluencyCAD)
|
||||
self.actionExport_Stl.setObjectName(u"actionExport_Stl")
|
||||
self.actionExit = QAction(fluencyCAD)
|
||||
self.actionExit.setObjectName(u"actionExit")
|
||||
self.centralwidget = QWidget(fluencyCAD)
|
||||
self.centralwidget.setObjectName(u"centralwidget")
|
||||
self.gridLayout = QGridLayout(self.centralwidget)
|
||||
self.gridLayout.setObjectName(u"gridLayout")
|
||||
self.InputTab = QTabWidget(self.centralwidget)
|
||||
self.InputTab.setObjectName(u"InputTab")
|
||||
sizePolicy = QSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
|
||||
sizePolicy.setHorizontalStretch(0)
|
||||
sizePolicy.setVerticalStretch(0)
|
||||
sizePolicy.setHeightForWidth(self.InputTab.sizePolicy().hasHeightForWidth())
|
||||
self.InputTab.setSizePolicy(sizePolicy)
|
||||
self.sketch_tab = QWidget()
|
||||
self.sketch_tab.setObjectName(u"sketch_tab")
|
||||
self.verticalLayout_4 = QVBoxLayout(self.sketch_tab)
|
||||
self.verticalLayout_4.setObjectName(u"verticalLayout_4")
|
||||
self.InputTab.addTab(self.sketch_tab, "")
|
||||
self.code_tab = QWidget()
|
||||
self.code_tab.setObjectName(u"code_tab")
|
||||
self.verticalLayout = QVBoxLayout(self.code_tab)
|
||||
self.verticalLayout.setObjectName(u"verticalLayout")
|
||||
self.textEdit = QTextEdit(self.code_tab)
|
||||
self.textEdit.setObjectName(u"textEdit")
|
||||
|
||||
self.verticalLayout.addWidget(self.textEdit)
|
||||
|
||||
self.groupBox_7 = QGroupBox(self.code_tab)
|
||||
self.groupBox_7.setObjectName(u"groupBox_7")
|
||||
self.gridLayout_5 = QGridLayout(self.groupBox_7)
|
||||
self.gridLayout_5.setObjectName(u"gridLayout_5")
|
||||
self.pushButton_5 = QPushButton(self.groupBox_7)
|
||||
self.pushButton_5.setObjectName(u"pushButton_5")
|
||||
|
||||
self.gridLayout_5.addWidget(self.pushButton_5, 2, 0, 1, 1)
|
||||
|
||||
self.pushButton_4 = QPushButton(self.groupBox_7)
|
||||
self.pushButton_4.setObjectName(u"pushButton_4")
|
||||
|
||||
self.gridLayout_5.addWidget(self.pushButton_4, 2, 1, 1, 1)
|
||||
|
||||
self.pb_apply_code = QPushButton(self.groupBox_7)
|
||||
self.pb_apply_code.setObjectName(u"pb_apply_code")
|
||||
|
||||
self.gridLayout_5.addWidget(self.pb_apply_code, 1, 0, 1, 1)
|
||||
|
||||
self.pushButton = QPushButton(self.groupBox_7)
|
||||
self.pushButton.setObjectName(u"pushButton")
|
||||
|
||||
self.gridLayout_5.addWidget(self.pushButton, 1, 1, 1, 1)
|
||||
|
||||
|
||||
self.verticalLayout.addWidget(self.groupBox_7)
|
||||
|
||||
self.InputTab.addTab(self.code_tab, "")
|
||||
|
||||
self.gridLayout.addWidget(self.InputTab, 0, 1, 12, 1)
|
||||
|
||||
self.groupBox_9 = QGroupBox(self.centralwidget)
|
||||
self.groupBox_9.setObjectName(u"groupBox_9")
|
||||
self.groupBox_9.setMaximumSize(QSize(200, 16777215))
|
||||
self.gridLayout_7 = QGridLayout(self.groupBox_9)
|
||||
self.gridLayout_7.setObjectName(u"gridLayout_7")
|
||||
self.pb_origin_wp = QPushButton(self.groupBox_9)
|
||||
self.pb_origin_wp.setObjectName(u"pb_origin_wp")
|
||||
|
||||
self.gridLayout_7.addWidget(self.pb_origin_wp, 0, 0, 1, 1)
|
||||
|
||||
self.pb_origin_face = QPushButton(self.groupBox_9)
|
||||
self.pb_origin_face.setObjectName(u"pb_origin_face")
|
||||
self.pb_origin_face.setCheckable(True)
|
||||
|
||||
self.gridLayout_7.addWidget(self.pb_origin_face, 0, 1, 1, 1)
|
||||
|
||||
self.pb_flip_face = QPushButton(self.groupBox_9)
|
||||
self.pb_flip_face.setObjectName(u"pb_flip_face")
|
||||
|
||||
self.gridLayout_7.addWidget(self.pb_flip_face, 1, 0, 1, 1)
|
||||
|
||||
self.pb_underlay = QPushButton(self.groupBox_9)
|
||||
self.pb_underlay.setObjectName(u"pb_underlay")
|
||||
self.pb_underlay.setEnabled(False)
|
||||
self.pb_underlay.setCheckable(True)
|
||||
self.pb_underlay.setChecked(True)
|
||||
|
||||
self.gridLayout_7.addWidget(self.pb_underlay, 3, 0, 1, 1)
|
||||
|
||||
self.pb_clr_face = QPushButton(self.groupBox_9)
|
||||
self.pb_clr_face.setObjectName(u"pb_clr_face")
|
||||
self.pb_clr_face.setEnabled(False)
|
||||
|
||||
self.gridLayout_7.addWidget(self.pb_clr_face, 3, 1, 1, 1)
|
||||
|
||||
self.pb_to_sketch = QPushButton(self.groupBox_9)
|
||||
self.pb_to_sketch.setObjectName(u"pb_to_sketch")
|
||||
self.pb_to_sketch.setEnabled(False)
|
||||
|
||||
self.gridLayout_7.addWidget(self.pb_to_sketch, 4, 0, 1, 2)
|
||||
|
||||
self.pb_wp_new = QPushButton(self.groupBox_9)
|
||||
self.pb_wp_new.setObjectName(u"pb_wp_new")
|
||||
|
||||
self.gridLayout_7.addWidget(self.pb_wp_new, 1, 1, 1, 1)
|
||||
|
||||
|
||||
self.gridLayout.addWidget(self.groupBox_9, 0, 0, 1, 1)
|
||||
|
||||
self.assembly_box = QGroupBox(self.centralwidget)
|
||||
self.assembly_box.setObjectName(u"assembly_box")
|
||||
self.assembly_box.setMinimumSize(QSize(0, 50))
|
||||
|
||||
self.gridLayout.addWidget(self.assembly_box, 13, 1, 1, 2)
|
||||
|
||||
self.joint_tools = QGroupBox(self.centralwidget)
|
||||
self.joint_tools.setObjectName(u"joint_tools")
|
||||
self.joint_tools.setMinimumSize(QSize(0, 50))
|
||||
self.gridLayout_10 = QGridLayout(self.joint_tools)
|
||||
self.gridLayout_10.setObjectName(u"gridLayout_10")
|
||||
self.pb_remove_connector = QPushButton(self.joint_tools)
|
||||
self.pb_remove_connector.setObjectName(u"pb_remove_connector")
|
||||
self.pb_remove_connector.setMinimumSize(QSize(50, 50))
|
||||
self.pb_remove_connector.setMaximumSize(QSize(50, 50))
|
||||
|
||||
self.gridLayout_10.addWidget(self.pb_remove_connector, 0, 2, 1, 1)
|
||||
|
||||
self.pb_add_connector = QPushButton(self.joint_tools)
|
||||
self.pb_add_connector.setObjectName(u"pb_add_connector")
|
||||
self.pb_add_connector.setMinimumSize(QSize(50, 50))
|
||||
self.pb_add_connector.setMaximumSize(QSize(50, 50))
|
||||
|
||||
self.gridLayout_10.addWidget(self.pb_add_connector, 0, 1, 1, 1)
|
||||
|
||||
self.pb_add_connector_2 = QPushButton(self.joint_tools)
|
||||
self.pb_add_connector_2.setObjectName(u"pb_add_connector_2")
|
||||
self.pb_add_connector_2.setMinimumSize(QSize(50, 50))
|
||||
self.pb_add_connector_2.setMaximumSize(QSize(50, 50))
|
||||
|
||||
self.gridLayout_10.addWidget(self.pb_add_connector_2, 1, 1, 1, 1)
|
||||
|
||||
self.pb_add_connector_3 = QPushButton(self.joint_tools)
|
||||
self.pb_add_connector_3.setObjectName(u"pb_add_connector_3")
|
||||
self.pb_add_connector_3.setMinimumSize(QSize(50, 50))
|
||||
self.pb_add_connector_3.setMaximumSize(QSize(50, 50))
|
||||
|
||||
self.gridLayout_10.addWidget(self.pb_add_connector_3, 1, 2, 1, 1)
|
||||
|
||||
|
||||
self.gridLayout.addWidget(self.joint_tools, 12, 3, 2, 1)
|
||||
|
||||
self.gl_box = QGroupBox(self.centralwidget)
|
||||
self.gl_box.setObjectName(u"gl_box")
|
||||
sizePolicy1 = QSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
|
||||
sizePolicy1.setHorizontalStretch(0)
|
||||
sizePolicy1.setVerticalStretch(4)
|
||||
sizePolicy1.setHeightForWidth(self.gl_box.sizePolicy().hasHeightForWidth())
|
||||
self.gl_box.setSizePolicy(sizePolicy1)
|
||||
font = QFont()
|
||||
font.setPointSize(12)
|
||||
self.gl_box.setFont(font)
|
||||
self.horizontalLayout_4 = QHBoxLayout(self.gl_box)
|
||||
#ifndef Q_OS_MAC
|
||||
self.horizontalLayout_4.setSpacing(-1)
|
||||
#endif
|
||||
self.horizontalLayout_4.setObjectName(u"horizontalLayout_4")
|
||||
self.horizontalLayout_4.setContentsMargins(12, -1, -1, -1)
|
||||
|
||||
self.gridLayout.addWidget(self.gl_box, 0, 2, 12, 1)
|
||||
|
||||
self.groupBox_11 = QGroupBox(self.centralwidget)
|
||||
self.groupBox_11.setObjectName(u"groupBox_11")
|
||||
sizePolicy2 = QSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Expanding)
|
||||
sizePolicy2.setHorizontalStretch(0)
|
||||
sizePolicy2.setVerticalStretch(0)
|
||||
sizePolicy2.setHeightForWidth(self.groupBox_11.sizePolicy().hasHeightForWidth())
|
||||
self.groupBox_11.setSizePolicy(sizePolicy2)
|
||||
self.groupBox_11.setMaximumSize(QSize(200, 16777215))
|
||||
self.verticalLayout_7 = QVBoxLayout(self.groupBox_11)
|
||||
self.verticalLayout_7.setObjectName(u"verticalLayout_7")
|
||||
self.verticalLayout_7.setContentsMargins(5, 5, 5, 5)
|
||||
self.sketch_list = QListWidget(self.groupBox_11)
|
||||
self.sketch_list.setObjectName(u"sketch_list")
|
||||
sizePolicy3 = QSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
|
||||
sizePolicy3.setHorizontalStretch(0)
|
||||
sizePolicy3.setVerticalStretch(0)
|
||||
sizePolicy3.setHeightForWidth(self.sketch_list.sizePolicy().hasHeightForWidth())
|
||||
self.sketch_list.setSizePolicy(sizePolicy3)
|
||||
self.sketch_list.setSelectionRectVisible(True)
|
||||
|
||||
self.verticalLayout_7.addWidget(self.sketch_list)
|
||||
|
||||
self.groupBox_6 = QGroupBox(self.groupBox_11)
|
||||
self.groupBox_6.setObjectName(u"groupBox_6")
|
||||
sizePolicy4 = QSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Preferred)
|
||||
sizePolicy4.setHorizontalStretch(0)
|
||||
sizePolicy4.setVerticalStretch(0)
|
||||
sizePolicy4.setHeightForWidth(self.groupBox_6.sizePolicy().hasHeightForWidth())
|
||||
self.groupBox_6.setSizePolicy(sizePolicy4)
|
||||
self.gridLayout_6 = QGridLayout(self.groupBox_6)
|
||||
self.gridLayout_6.setObjectName(u"gridLayout_6")
|
||||
self.gridLayout_6.setContentsMargins(2, 2, 2, 2)
|
||||
self.pb_edt_sktch = QPushButton(self.groupBox_6)
|
||||
self.pb_edt_sktch.setObjectName(u"pb_edt_sktch")
|
||||
|
||||
self.gridLayout_6.addWidget(self.pb_edt_sktch, 1, 1, 1, 1)
|
||||
|
||||
self.pb_nw_sktch = QPushButton(self.groupBox_6)
|
||||
self.pb_nw_sktch.setObjectName(u"pb_nw_sktch")
|
||||
|
||||
self.gridLayout_6.addWidget(self.pb_nw_sktch, 1, 0, 1, 1)
|
||||
|
||||
self.pb_del_sketch = QPushButton(self.groupBox_6)
|
||||
self.pb_del_sketch.setObjectName(u"pb_del_sketch")
|
||||
|
||||
self.gridLayout_6.addWidget(self.pb_del_sketch, 1, 2, 1, 1)
|
||||
|
||||
|
||||
self.verticalLayout_7.addWidget(self.groupBox_6)
|
||||
|
||||
|
||||
self.gridLayout.addWidget(self.groupBox_11, 6, 0, 6, 1)
|
||||
|
||||
self.assembly_tools = QGroupBox(self.centralwidget)
|
||||
self.assembly_tools.setObjectName(u"assembly_tools")
|
||||
self.assembly_tools.setMinimumSize(QSize(0, 50))
|
||||
self.gridLayout_12 = QGridLayout(self.assembly_tools)
|
||||
self.gridLayout_12.setObjectName(u"gridLayout_12")
|
||||
self.pb_compo_to_assembly = QPushButton(self.assembly_tools)
|
||||
self.pb_compo_to_assembly.setObjectName(u"pb_compo_to_assembly")
|
||||
self.pb_compo_to_assembly.setMinimumSize(QSize(50, 50))
|
||||
self.pb_compo_to_assembly.setMaximumSize(QSize(50, 50))
|
||||
|
||||
self.gridLayout_12.addWidget(self.pb_compo_to_assembly, 0, 0, 1, 1)
|
||||
|
||||
self.pb_remove_compo_from_assembly = QPushButton(self.assembly_tools)
|
||||
self.pb_remove_compo_from_assembly.setObjectName(u"pb_remove_compo_from_assembly")
|
||||
self.pb_remove_compo_from_assembly.setEnabled(True)
|
||||
sizePolicy4.setHeightForWidth(self.pb_remove_compo_from_assembly.sizePolicy().hasHeightForWidth())
|
||||
self.pb_remove_compo_from_assembly.setSizePolicy(sizePolicy4)
|
||||
self.pb_remove_compo_from_assembly.setMinimumSize(QSize(50, 50))
|
||||
self.pb_remove_compo_from_assembly.setMaximumSize(QSize(50, 50))
|
||||
self.pb_remove_compo_from_assembly.setLayoutDirection(Qt.LeftToRight)
|
||||
|
||||
self.gridLayout_12.addWidget(self.pb_remove_compo_from_assembly, 0, 1, 1, 1)
|
||||
|
||||
|
||||
self.gridLayout.addWidget(self.assembly_tools, 13, 0, 1, 1)
|
||||
|
||||
self.compo_tool_box = QGroupBox(self.centralwidget)
|
||||
self.compo_tool_box.setObjectName(u"compo_tool_box")
|
||||
self.compo_tool_box.setMinimumSize(QSize(0, 50))
|
||||
self.gridLayout_9 = QGridLayout(self.compo_tool_box)
|
||||
self.gridLayout_9.setObjectName(u"gridLayout_9")
|
||||
self.pb_new_compo = QPushButton(self.compo_tool_box)
|
||||
self.pb_new_compo.setObjectName(u"pb_new_compo")
|
||||
self.pb_new_compo.setMinimumSize(QSize(50, 50))
|
||||
self.pb_new_compo.setMaximumSize(QSize(50, 50))
|
||||
|
||||
self.gridLayout_9.addWidget(self.pb_new_compo, 0, 0, 1, 1)
|
||||
|
||||
self.pb_del_compo = QPushButton(self.compo_tool_box)
|
||||
self.pb_del_compo.setObjectName(u"pb_del_compo")
|
||||
self.pb_del_compo.setEnabled(True)
|
||||
sizePolicy4.setHeightForWidth(self.pb_del_compo.sizePolicy().hasHeightForWidth())
|
||||
self.pb_del_compo.setSizePolicy(sizePolicy4)
|
||||
self.pb_del_compo.setMinimumSize(QSize(50, 50))
|
||||
self.pb_del_compo.setMaximumSize(QSize(50, 50))
|
||||
self.pb_del_compo.setLayoutDirection(Qt.LeftToRight)
|
||||
|
||||
self.gridLayout_9.addWidget(self.pb_del_compo, 0, 1, 1, 1)
|
||||
|
||||
|
||||
self.gridLayout.addWidget(self.compo_tool_box, 12, 0, 1, 1)
|
||||
|
||||
self.compo_box = QGroupBox(self.centralwidget)
|
||||
self.compo_box.setObjectName(u"compo_box")
|
||||
self.compo_box.setMinimumSize(QSize(0, 50))
|
||||
|
||||
self.gridLayout.addWidget(self.compo_box, 12, 1, 1, 2)
|
||||
|
||||
self.groupBox_12 = QGroupBox(self.centralwidget)
|
||||
self.groupBox_12.setObjectName(u"groupBox_12")
|
||||
sizePolicy2.setHeightForWidth(self.groupBox_12.sizePolicy().hasHeightForWidth())
|
||||
self.groupBox_12.setSizePolicy(sizePolicy2)
|
||||
self.groupBox_12.setMaximumSize(QSize(200, 16777215))
|
||||
self.verticalLayout_8 = QVBoxLayout(self.groupBox_12)
|
||||
self.verticalLayout_8.setObjectName(u"verticalLayout_8")
|
||||
self.verticalLayout_8.setContentsMargins(5, 5, 5, 5)
|
||||
self.connection_list = QListWidget(self.groupBox_12)
|
||||
self.connection_list.setObjectName(u"connection_list")
|
||||
self.connection_list.setSelectionRectVisible(True)
|
||||
|
||||
self.verticalLayout_8.addWidget(self.connection_list)
|
||||
|
||||
self.groupBox_13 = QGroupBox(self.groupBox_12)
|
||||
self.groupBox_13.setObjectName(u"groupBox_13")
|
||||
sizePolicy4.setHeightForWidth(self.groupBox_13.sizePolicy().hasHeightForWidth())
|
||||
self.groupBox_13.setSizePolicy(sizePolicy4)
|
||||
self.groupBox_13.setMaximumSize(QSize(200, 16777215))
|
||||
self.gridLayout_13 = QGridLayout(self.groupBox_13)
|
||||
self.gridLayout_13.setObjectName(u"gridLayout_13")
|
||||
self.gridLayout_13.setContentsMargins(2, 2, 2, 2)
|
||||
self.pb_del_connection = QPushButton(self.groupBox_13)
|
||||
self.pb_del_connection.setObjectName(u"pb_del_connection")
|
||||
|
||||
self.gridLayout_13.addWidget(self.pb_del_connection, 0, 2, 1, 1)
|
||||
|
||||
self.pb_update_connection = QPushButton(self.groupBox_13)
|
||||
self.pb_update_connection.setObjectName(u"pb_update_connection")
|
||||
|
||||
self.gridLayout_13.addWidget(self.pb_update_connection, 0, 0, 1, 1)
|
||||
|
||||
self.pb_edt_sktch_4 = QPushButton(self.groupBox_13)
|
||||
self.pb_edt_sktch_4.setObjectName(u"pb_edt_sktch_4")
|
||||
|
||||
self.gridLayout_13.addWidget(self.pb_edt_sktch_4, 0, 1, 1, 1)
|
||||
|
||||
|
||||
self.verticalLayout_8.addWidget(self.groupBox_13)
|
||||
|
||||
|
||||
self.gridLayout.addWidget(self.groupBox_12, 6, 3, 6, 1)
|
||||
|
||||
self.groupBox_10 = QGroupBox(self.centralwidget)
|
||||
self.groupBox_10.setObjectName(u"groupBox_10")
|
||||
sizePolicy2.setHeightForWidth(self.groupBox_10.sizePolicy().hasHeightForWidth())
|
||||
self.groupBox_10.setSizePolicy(sizePolicy2)
|
||||
self.groupBox_10.setMaximumSize(QSize(200, 16777215))
|
||||
self.verticalLayout_6 = QVBoxLayout(self.groupBox_10)
|
||||
self.verticalLayout_6.setObjectName(u"verticalLayout_6")
|
||||
self.verticalLayout_6.setContentsMargins(5, 5, 5, 5)
|
||||
self.body_list = QListWidget(self.groupBox_10)
|
||||
self.body_list.setObjectName(u"body_list")
|
||||
self.body_list.setSelectionRectVisible(True)
|
||||
|
||||
self.verticalLayout_6.addWidget(self.body_list)
|
||||
|
||||
self.groupBox_8 = QGroupBox(self.groupBox_10)
|
||||
self.groupBox_8.setObjectName(u"groupBox_8")
|
||||
sizePolicy4.setHeightForWidth(self.groupBox_8.sizePolicy().hasHeightForWidth())
|
||||
self.groupBox_8.setSizePolicy(sizePolicy4)
|
||||
self.groupBox_8.setMaximumSize(QSize(200, 16777215))
|
||||
self.gridLayout_8 = QGridLayout(self.groupBox_8)
|
||||
self.gridLayout_8.setObjectName(u"gridLayout_8")
|
||||
self.gridLayout_8.setContentsMargins(2, 2, 2, 2)
|
||||
self.pb_del_body = QPushButton(self.groupBox_8)
|
||||
self.pb_del_body.setObjectName(u"pb_del_body")
|
||||
|
||||
self.gridLayout_8.addWidget(self.pb_del_body, 0, 2, 1, 1)
|
||||
|
||||
self.pb_update_body = QPushButton(self.groupBox_8)
|
||||
self.pb_update_body.setObjectName(u"pb_update_body")
|
||||
|
||||
self.gridLayout_8.addWidget(self.pb_update_body, 0, 0, 1, 1)
|
||||
|
||||
self.pb_edt_sktch_3 = QPushButton(self.groupBox_8)
|
||||
self.pb_edt_sktch_3.setObjectName(u"pb_edt_sktch_3")
|
||||
|
||||
self.gridLayout_8.addWidget(self.pb_edt_sktch_3, 0, 1, 1, 1)
|
||||
|
||||
|
||||
self.verticalLayout_6.addWidget(self.groupBox_8)
|
||||
|
||||
|
||||
self.gridLayout.addWidget(self.groupBox_10, 3, 3, 3, 1)
|
||||
|
||||
self.groupBox_2 = QGroupBox(self.centralwidget)
|
||||
self.groupBox_2.setObjectName(u"groupBox_2")
|
||||
sizePolicy4.setHeightForWidth(self.groupBox_2.sizePolicy().hasHeightForWidth())
|
||||
self.groupBox_2.setSizePolicy(sizePolicy4)
|
||||
self.groupBox_2.setMaximumSize(QSize(200, 16777215))
|
||||
self.gridLayout_2 = QGridLayout(self.groupBox_2)
|
||||
self.gridLayout_2.setObjectName(u"gridLayout_2")
|
||||
self.gridLayout_2.setContentsMargins(10, -1, -1, -1)
|
||||
self.pb_arc_tool = QPushButton(self.groupBox_2)
|
||||
self.pb_arc_tool.setObjectName(u"pb_arc_tool")
|
||||
self.pb_arc_tool.setCheckable(True)
|
||||
|
||||
self.gridLayout_2.addWidget(self.pb_arc_tool, 2, 0, 1, 1)
|
||||
|
||||
self.pb_rectool = QPushButton(self.groupBox_2)
|
||||
self.pb_rectool.setObjectName(u"pb_rectool")
|
||||
self.pb_rectool.setCheckable(True)
|
||||
self.pb_rectool.setAutoExclusive(False)
|
||||
|
||||
self.gridLayout_2.addWidget(self.pb_rectool, 0, 1, 1, 1)
|
||||
|
||||
self.pb_circtool = QPushButton(self.groupBox_2)
|
||||
self.pb_circtool.setObjectName(u"pb_circtool")
|
||||
self.pb_circtool.setCheckable(True)
|
||||
self.pb_circtool.setAutoExclusive(False)
|
||||
|
||||
self.gridLayout_2.addWidget(self.pb_circtool, 1, 0, 1, 1, Qt.AlignTop)
|
||||
|
||||
self.pb_enable_construct = QPushButton(self.groupBox_2)
|
||||
self.pb_enable_construct.setObjectName(u"pb_enable_construct")
|
||||
self.pb_enable_construct.setCheckable(True)
|
||||
|
||||
self.gridLayout_2.addWidget(self.pb_enable_construct, 4, 0, 1, 1)
|
||||
|
||||
self.pb_enable_snap = QPushButton(self.groupBox_2)
|
||||
self.pb_enable_snap.setObjectName(u"pb_enable_snap")
|
||||
self.pb_enable_snap.setIconSize(QSize(13, 16))
|
||||
self.pb_enable_snap.setCheckable(True)
|
||||
self.pb_enable_snap.setChecked(True)
|
||||
|
||||
self.gridLayout_2.addWidget(self.pb_enable_snap, 4, 1, 1, 1)
|
||||
|
||||
self.pb_linetool = QPushButton(self.groupBox_2)
|
||||
self.pb_linetool.setObjectName(u"pb_linetool")
|
||||
self.pb_linetool.setCheckable(True)
|
||||
self.pb_linetool.setAutoExclusive(False)
|
||||
|
||||
self.gridLayout_2.addWidget(self.pb_linetool, 0, 0, 1, 1)
|
||||
|
||||
self.pb_slotool = QPushButton(self.groupBox_2)
|
||||
self.pb_slotool.setObjectName(u"pb_slotool")
|
||||
self.pb_slotool.setCheckable(True)
|
||||
self.pb_slotool.setAutoExclusive(False)
|
||||
|
||||
self.gridLayout_2.addWidget(self.pb_slotool, 1, 1, 1, 1, Qt.AlignTop)
|
||||
|
||||
self.line = QFrame(self.groupBox_2)
|
||||
self.line.setObjectName(u"line")
|
||||
self.line.setFrameShape(QFrame.Shape.HLine)
|
||||
self.line.setFrameShadow(QFrame.Shadow.Sunken)
|
||||
|
||||
self.gridLayout_2.addWidget(self.line, 3, 0, 1, 2)
|
||||
|
||||
self.pb_offset_tool = QPushButton(self.groupBox_2)
|
||||
self.pb_offset_tool.setObjectName(u"pb_offset_tool")
|
||||
|
||||
self.gridLayout_2.addWidget(self.pb_offset_tool, 2, 1, 1, 1)
|
||||
|
||||
|
||||
self.gridLayout.addWidget(self.groupBox_2, 1, 0, 1, 1)
|
||||
|
||||
self.groupBox_3 = QGroupBox(self.centralwidget)
|
||||
self.groupBox_3.setObjectName(u"groupBox_3")
|
||||
sizePolicy4.setHeightForWidth(self.groupBox_3.sizePolicy().hasHeightForWidth())
|
||||
self.groupBox_3.setSizePolicy(sizePolicy4)
|
||||
self.groupBox_3.setMaximumSize(QSize(200, 16777213))
|
||||
self.gridLayout_4 = QGridLayout(self.groupBox_3)
|
||||
self.gridLayout_4.setObjectName(u"gridLayout_4")
|
||||
self.pb_con_ptpt = QPushButton(self.groupBox_3)
|
||||
self.pb_con_ptpt.setObjectName(u"pb_con_ptpt")
|
||||
self.pb_con_ptpt.setCheckable(True)
|
||||
self.pb_con_ptpt.setAutoExclusive(False)
|
||||
|
||||
self.gridLayout_4.addWidget(self.pb_con_ptpt, 1, 0, 1, 1)
|
||||
|
||||
self.pb_con_vert = QPushButton(self.groupBox_3)
|
||||
self.pb_con_vert.setObjectName(u"pb_con_vert")
|
||||
self.pb_con_vert.setCheckable(True)
|
||||
self.pb_con_vert.setAutoExclusive(False)
|
||||
|
||||
self.gridLayout_4.addWidget(self.pb_con_vert, 3, 1, 1, 1)
|
||||
|
||||
self.pb_con_sym = QPushButton(self.groupBox_3)
|
||||
self.pb_con_sym.setObjectName(u"pb_con_sym")
|
||||
self.pb_con_sym.setCheckable(True)
|
||||
self.pb_con_sym.setAutoExclusive(False)
|
||||
|
||||
self.gridLayout_4.addWidget(self.pb_con_sym, 4, 1, 1, 1)
|
||||
|
||||
self.pb_con_mid = QPushButton(self.groupBox_3)
|
||||
self.pb_con_mid.setObjectName(u"pb_con_mid")
|
||||
self.pb_con_mid.setCheckable(True)
|
||||
self.pb_con_mid.setAutoExclusive(False)
|
||||
|
||||
self.gridLayout_4.addWidget(self.pb_con_mid, 2, 0, 1, 1)
|
||||
|
||||
self.pb_con_line = QPushButton(self.groupBox_3)
|
||||
self.pb_con_line.setObjectName(u"pb_con_line")
|
||||
self.pb_con_line.setCheckable(True)
|
||||
self.pb_con_line.setAutoExclusive(False)
|
||||
|
||||
self.gridLayout_4.addWidget(self.pb_con_line, 1, 1, 1, 1)
|
||||
|
||||
self.pb_con_horiz = QPushButton(self.groupBox_3)
|
||||
self.pb_con_horiz.setObjectName(u"pb_con_horiz")
|
||||
self.pb_con_horiz.setCheckable(True)
|
||||
self.pb_con_horiz.setAutoExclusive(False)
|
||||
|
||||
self.gridLayout_4.addWidget(self.pb_con_horiz, 3, 0, 1, 1)
|
||||
|
||||
self.pb_con_dist = QPushButton(self.groupBox_3)
|
||||
self.pb_con_dist.setObjectName(u"pb_con_dist")
|
||||
self.pb_con_dist.setCheckable(True)
|
||||
self.pb_con_dist.setAutoExclusive(False)
|
||||
self.pb_con_dist.setAutoRepeatDelay(297)
|
||||
|
||||
self.gridLayout_4.addWidget(self.pb_con_dist, 4, 0, 1, 1)
|
||||
|
||||
self.pb_con_perp = QPushButton(self.groupBox_3)
|
||||
self.pb_con_perp.setObjectName(u"pb_con_perp")
|
||||
self.pb_con_perp.setCheckable(True)
|
||||
self.pb_con_perp.setAutoExclusive(False)
|
||||
|
||||
self.gridLayout_4.addWidget(self.pb_con_perp, 2, 1, 1, 1)
|
||||
|
||||
self.pb_con_diameter = QPushButton(self.groupBox_3)
|
||||
self.pb_con_diameter.setObjectName(u"pb_con_diameter")
|
||||
|
||||
self.gridLayout_4.addWidget(self.pb_con_diameter, 5, 0, 1, 1)
|
||||
|
||||
|
||||
self.gridLayout.addWidget(self.groupBox_3, 2, 0, 1, 1)
|
||||
|
||||
self.groupBox_5 = QGroupBox(self.centralwidget)
|
||||
self.groupBox_5.setObjectName(u"groupBox_5")
|
||||
sizePolicy4.setHeightForWidth(self.groupBox_5.sizePolicy().hasHeightForWidth())
|
||||
self.groupBox_5.setSizePolicy(sizePolicy4)
|
||||
self.gridLayout_11 = QGridLayout(self.groupBox_5)
|
||||
self.gridLayout_11.setObjectName(u"gridLayout_11")
|
||||
self.gridLayout_11.setContentsMargins(12, 12, 12, 12)
|
||||
self.label = QLabel(self.groupBox_5)
|
||||
self.label.setObjectName(u"label")
|
||||
|
||||
self.gridLayout_11.addWidget(self.label, 5, 0, 1, 1)
|
||||
|
||||
self.pb_snap_vert = QPushButton(self.groupBox_5)
|
||||
self.pb_snap_vert.setObjectName(u"pb_snap_vert")
|
||||
self.pb_snap_vert.setCheckable(True)
|
||||
self.pb_snap_vert.setAutoExclusive(False)
|
||||
|
||||
self.gridLayout_11.addWidget(self.pb_snap_vert, 2, 1, 1, 1)
|
||||
|
||||
self.line_2 = QFrame(self.groupBox_5)
|
||||
self.line_2.setObjectName(u"line_2")
|
||||
self.line_2.setFrameShape(QFrame.Shape.HLine)
|
||||
self.line_2.setFrameShadow(QFrame.Shadow.Sunken)
|
||||
|
||||
self.gridLayout_11.addWidget(self.line_2, 4, 0, 1, 2)
|
||||
|
||||
self.label_2 = QLabel(self.groupBox_5)
|
||||
self.label_2.setObjectName(u"label_2")
|
||||
|
||||
self.gridLayout_11.addWidget(self.label_2, 5, 1, 1, 1)
|
||||
|
||||
self.spinbox_snap_distance = QSpinBox(self.groupBox_5)
|
||||
self.spinbox_snap_distance.setObjectName(u"spinbox_snap_distance")
|
||||
self.spinbox_snap_distance.setMaximum(30)
|
||||
self.spinbox_snap_distance.setValue(10)
|
||||
|
||||
self.gridLayout_11.addWidget(self.spinbox_snap_distance, 6, 0, 1, 1)
|
||||
|
||||
self.pushButton_7 = QPushButton(self.groupBox_5)
|
||||
self.pushButton_7.setObjectName(u"pushButton_7")
|
||||
self.pushButton_7.setCheckable(True)
|
||||
self.pushButton_7.setAutoExclusive(False)
|
||||
|
||||
self.gridLayout_11.addWidget(self.pushButton_7, 3, 0, 1, 1)
|
||||
|
||||
self.pb_snap_horiz = QPushButton(self.groupBox_5)
|
||||
self.pb_snap_horiz.setObjectName(u"pb_snap_horiz")
|
||||
self.pb_snap_horiz.setCheckable(True)
|
||||
self.pb_snap_horiz.setAutoExclusive(False)
|
||||
|
||||
self.gridLayout_11.addWidget(self.pb_snap_horiz, 2, 0, 1, 1)
|
||||
|
||||
self.spinbox_angle_steps = QSpinBox(self.groupBox_5)
|
||||
self.spinbox_angle_steps.setObjectName(u"spinbox_angle_steps")
|
||||
self.spinbox_angle_steps.setMaximum(180)
|
||||
self.spinbox_angle_steps.setValue(15)
|
||||
|
||||
self.gridLayout_11.addWidget(self.spinbox_angle_steps, 6, 1, 1, 1)
|
||||
|
||||
self.pushButton_8 = QPushButton(self.groupBox_5)
|
||||
self.pushButton_8.setObjectName(u"pushButton_8")
|
||||
self.pushButton_8.setCheckable(True)
|
||||
self.pushButton_8.setAutoExclusive(False)
|
||||
|
||||
self.gridLayout_11.addWidget(self.pushButton_8, 0, 0, 1, 1)
|
||||
|
||||
self.pb_snap_midp = QPushButton(self.groupBox_5)
|
||||
self.pb_snap_midp.setObjectName(u"pb_snap_midp")
|
||||
self.pb_snap_midp.setCheckable(True)
|
||||
self.pb_snap_midp.setAutoExclusive(False)
|
||||
|
||||
self.gridLayout_11.addWidget(self.pb_snap_midp, 0, 1, 1, 1)
|
||||
|
||||
self.pb_snap_angle = QPushButton(self.groupBox_5)
|
||||
self.pb_snap_angle.setObjectName(u"pb_snap_angle")
|
||||
self.pb_snap_angle.setCheckable(True)
|
||||
self.pb_snap_angle.setAutoExclusive(False)
|
||||
|
||||
self.gridLayout_11.addWidget(self.pb_snap_angle, 3, 1, 1, 1)
|
||||
|
||||
|
||||
self.gridLayout.addWidget(self.groupBox_5, 3, 0, 1, 1)
|
||||
|
||||
self.groupBox = QGroupBox(self.centralwidget)
|
||||
self.groupBox.setObjectName(u"groupBox")
|
||||
self.gridLayout_3 = QGridLayout(self.groupBox)
|
||||
self.gridLayout_3.setObjectName(u"gridLayout_3")
|
||||
self.pb_revop = QPushButton(self.groupBox)
|
||||
self.pb_revop.setObjectName(u"pb_revop")
|
||||
|
||||
self.gridLayout_3.addWidget(self.pb_revop, 2, 1, 1, 1)
|
||||
|
||||
self.pb_extrdop = QPushButton(self.groupBox)
|
||||
self.pb_extrdop.setObjectName(u"pb_extrdop")
|
||||
|
||||
self.gridLayout_3.addWidget(self.pb_extrdop, 0, 0, 1, 1)
|
||||
|
||||
self.pb_arrayop = QPushButton(self.groupBox)
|
||||
self.pb_arrayop.setObjectName(u"pb_arrayop")
|
||||
|
||||
self.gridLayout_3.addWidget(self.pb_arrayop, 2, 0, 1, 1)
|
||||
|
||||
self.pb_cutop = QPushButton(self.groupBox)
|
||||
self.pb_cutop.setObjectName(u"pb_cutop")
|
||||
|
||||
self.gridLayout_3.addWidget(self.pb_cutop, 0, 1, 1, 1)
|
||||
|
||||
self.pb_combop = QPushButton(self.groupBox)
|
||||
self.pb_combop.setObjectName(u"pb_combop")
|
||||
|
||||
self.gridLayout_3.addWidget(self.pb_combop, 1, 0, 1, 1)
|
||||
|
||||
self.pb_moveop = QPushButton(self.groupBox)
|
||||
self.pb_moveop.setObjectName(u"pb_moveop")
|
||||
|
||||
self.gridLayout_3.addWidget(self.pb_moveop, 1, 1, 1, 1)
|
||||
|
||||
|
||||
self.gridLayout.addWidget(self.groupBox, 0, 3, 1, 1)
|
||||
|
||||
self.groupBox_4 = QGroupBox(self.centralwidget)
|
||||
self.groupBox_4.setObjectName(u"groupBox_4")
|
||||
self.verticalLayout_2 = QVBoxLayout(self.groupBox_4)
|
||||
self.verticalLayout_2.setObjectName(u"verticalLayout_2")
|
||||
self.pushButton_2 = QPushButton(self.groupBox_4)
|
||||
self.pushButton_2.setObjectName(u"pushButton_2")
|
||||
|
||||
self.verticalLayout_2.addWidget(self.pushButton_2)
|
||||
|
||||
self.pb_export_step = QPushButton(self.groupBox_4)
|
||||
self.pb_export_step.setObjectName(u"pb_export_step")
|
||||
|
||||
self.verticalLayout_2.addWidget(self.pb_export_step)
|
||||
|
||||
self.pb_export_iges = QPushButton(self.groupBox_4)
|
||||
self.pb_export_iges.setObjectName(u"pb_export_iges")
|
||||
|
||||
self.verticalLayout_2.addWidget(self.pb_export_iges)
|
||||
|
||||
|
||||
self.gridLayout.addWidget(self.groupBox_4, 2, 3, 1, 1)
|
||||
|
||||
fluencyCAD.setCentralWidget(self.centralwidget)
|
||||
self.menubar = QMenuBar(fluencyCAD)
|
||||
self.menubar.setObjectName(u"menubar")
|
||||
self.menubar.setGeometry(QRect(0, 0, 2359, 24))
|
||||
self.menuFile = QMenu(self.menubar)
|
||||
self.menuFile.setObjectName(u"menuFile")
|
||||
self.menuSettings = QMenu(self.menubar)
|
||||
self.menuSettings.setObjectName(u"menuSettings")
|
||||
fluencyCAD.setMenuBar(self.menubar)
|
||||
self.statusbar = QStatusBar(fluencyCAD)
|
||||
self.statusbar.setObjectName(u"statusbar")
|
||||
fluencyCAD.setStatusBar(self.statusbar)
|
||||
|
||||
self.menubar.addAction(self.menuFile.menuAction())
|
||||
self.menubar.addAction(self.menuSettings.menuAction())
|
||||
self.menuFile.addAction(self.actionNew_Project)
|
||||
self.menuFile.addAction(self.actionOpen_Project)
|
||||
self.menuFile.addAction(self.actionSave_Project)
|
||||
self.menuFile.addAction(self.actionSave_Project_As)
|
||||
self.menuFile.addSeparator()
|
||||
self.menuFile.addAction(self.actionImport_File)
|
||||
self.menuFile.addSeparator()
|
||||
self.menuFile.addAction(self.actionExport_Step)
|
||||
self.menuFile.addAction(self.actionExport_Iges)
|
||||
self.menuFile.addAction(self.actionExport_Stl)
|
||||
self.menuFile.addSeparator()
|
||||
self.menuFile.addAction(self.actionExit)
|
||||
|
||||
self.retranslateUi(fluencyCAD)
|
||||
|
||||
self.InputTab.setCurrentIndex(0)
|
||||
|
||||
|
||||
QMetaObject.connectSlotsByName(fluencyCAD)
|
||||
# setupUi
|
||||
|
||||
def retranslateUi(self, fluencyCAD):
|
||||
fluencyCAD.setWindowTitle(QCoreApplication.translate("fluencyCAD", u"fluencyCAD", None))
|
||||
self.actionNew_Project.setText(QCoreApplication.translate("fluencyCAD", u"New Project", None))
|
||||
#if QT_CONFIG(shortcut)
|
||||
self.actionNew_Project.setShortcut(QCoreApplication.translate("fluencyCAD", u"Ctrl+N", None))
|
||||
#endif // QT_CONFIG(shortcut)
|
||||
self.actionOpen_Project.setText(QCoreApplication.translate("fluencyCAD", u"Open Project...", None))
|
||||
#if QT_CONFIG(shortcut)
|
||||
self.actionOpen_Project.setShortcut(QCoreApplication.translate("fluencyCAD", u"Ctrl+O", None))
|
||||
#endif // QT_CONFIG(shortcut)
|
||||
self.actionSave_Project.setText(QCoreApplication.translate("fluencyCAD", u"Save Project", None))
|
||||
#if QT_CONFIG(shortcut)
|
||||
self.actionSave_Project.setShortcut(QCoreApplication.translate("fluencyCAD", u"Ctrl+S", None))
|
||||
#endif // QT_CONFIG(shortcut)
|
||||
self.actionSave_Project_As.setText(QCoreApplication.translate("fluencyCAD", u"Save Project As...", None))
|
||||
#if QT_CONFIG(shortcut)
|
||||
self.actionSave_Project_As.setShortcut(QCoreApplication.translate("fluencyCAD", u"Ctrl+Shift+S", None))
|
||||
#endif // QT_CONFIG(shortcut)
|
||||
self.actionImport_File.setText(QCoreApplication.translate("fluencyCAD", u"Import STEP/IGES...", None))
|
||||
self.actionExport_Step.setText(QCoreApplication.translate("fluencyCAD", u"Export STEP...", None))
|
||||
self.actionExport_Iges.setText(QCoreApplication.translate("fluencyCAD", u"Export IGES...", None))
|
||||
self.actionExport_Stl.setText(QCoreApplication.translate("fluencyCAD", u"Export STL...", None))
|
||||
self.actionExit.setText(QCoreApplication.translate("fluencyCAD", u"Exit", None))
|
||||
#if QT_CONFIG(shortcut)
|
||||
self.actionExit.setShortcut(QCoreApplication.translate("fluencyCAD", u"Ctrl+Q", None))
|
||||
#endif // QT_CONFIG(shortcut)
|
||||
self.InputTab.setTabText(self.InputTab.indexOf(self.sketch_tab), QCoreApplication.translate("fluencyCAD", u"Sketch", None))
|
||||
self.groupBox_7.setTitle(QCoreApplication.translate("fluencyCAD", u"Executive", None))
|
||||
self.pushButton_5.setText(QCoreApplication.translate("fluencyCAD", u"Load Code", None))
|
||||
self.pushButton_4.setText(QCoreApplication.translate("fluencyCAD", u"Save code", None))
|
||||
self.pb_apply_code.setText(QCoreApplication.translate("fluencyCAD", u"Apply Code", None))
|
||||
self.pushButton.setText(QCoreApplication.translate("fluencyCAD", u"Delete Code", None))
|
||||
self.InputTab.setTabText(self.InputTab.indexOf(self.code_tab), QCoreApplication.translate("fluencyCAD", u"Code", None))
|
||||
self.groupBox_9.setTitle(QCoreApplication.translate("fluencyCAD", u"Workplanes", None))
|
||||
#if QT_CONFIG(tooltip)
|
||||
self.pb_origin_wp.setToolTip(QCoreApplication.translate("fluencyCAD", u"<W>orking Plane at 0, 0, 0", None))
|
||||
#endif // QT_CONFIG(tooltip)
|
||||
self.pb_origin_wp.setText(QCoreApplication.translate("fluencyCAD", u"WP Origin", None))
|
||||
#if QT_CONFIG(shortcut)
|
||||
self.pb_origin_wp.setShortcut(QCoreApplication.translate("fluencyCAD", u"W", None))
|
||||
#endif // QT_CONFIG(shortcut)
|
||||
#if QT_CONFIG(tooltip)
|
||||
self.pb_origin_face.setToolTip(QCoreApplication.translate("fluencyCAD", u"Working Plane >P<rojection at selected edges face", None))
|
||||
#endif // QT_CONFIG(tooltip)
|
||||
self.pb_origin_face.setText(QCoreApplication.translate("fluencyCAD", u" WP Face", None))
|
||||
#if QT_CONFIG(shortcut)
|
||||
self.pb_origin_face.setShortcut(QCoreApplication.translate("fluencyCAD", u"P", None))
|
||||
#endif // QT_CONFIG(shortcut)
|
||||
#if QT_CONFIG(tooltip)
|
||||
self.pb_flip_face.setToolTip(QCoreApplication.translate("fluencyCAD", u"Flip >N<ormal of projected mesh.", None))
|
||||
#endif // QT_CONFIG(tooltip)
|
||||
self.pb_flip_face.setText(QCoreApplication.translate("fluencyCAD", u"WP Flip", None))
|
||||
#if QT_CONFIG(shortcut)
|
||||
self.pb_flip_face.setShortcut(QCoreApplication.translate("fluencyCAD", u"N", None))
|
||||
#endif // QT_CONFIG(shortcut)
|
||||
#if QT_CONFIG(tooltip)
|
||||
self.pb_underlay.setToolTip(QCoreApplication.translate("fluencyCAD", u"Show / hide the construction lines projected from the source face", None))
|
||||
#endif // QT_CONFIG(tooltip)
|
||||
self.pb_underlay.setText(QCoreApplication.translate("fluencyCAD", u"Underlay", None))
|
||||
#if QT_CONFIG(tooltip)
|
||||
self.pb_clr_face.setToolTip(QCoreApplication.translate("fluencyCAD", u"Forget the picked source face (keep the workplane)", None))
|
||||
#endif // QT_CONFIG(tooltip)
|
||||
self.pb_clr_face.setText(QCoreApplication.translate("fluencyCAD", u"ClrFace", None))
|
||||
#if QT_CONFIG(tooltip)
|
||||
self.pb_to_sketch.setToolTip(QCoreApplication.translate("fluencyCAD", u"Convert projected construction lines into real sketch geometry", None))
|
||||
#endif // QT_CONFIG(tooltip)
|
||||
self.pb_to_sketch.setText(QCoreApplication.translate("fluencyCAD", u"ToSketch", None))
|
||||
#if QT_CONFIG(tooltip)
|
||||
self.pb_wp_new.setToolTip(QCoreApplication.translate("fluencyCAD", u"Create a new independent workplane (datum plane)", None))
|
||||
#endif // QT_CONFIG(tooltip)
|
||||
self.pb_wp_new.setText(QCoreApplication.translate("fluencyCAD", u"WP New", None))
|
||||
#if QT_CONFIG(shortcut)
|
||||
self.pb_wp_new.setShortcut(QCoreApplication.translate("fluencyCAD", u"Shift+W", None))
|
||||
#endif // QT_CONFIG(shortcut)
|
||||
self.assembly_box.setTitle(QCoreApplication.translate("fluencyCAD", u"Assembly", None))
|
||||
self.joint_tools.setTitle(QCoreApplication.translate("fluencyCAD", u"Joint Tools", None))
|
||||
self.pb_remove_connector.setText(QCoreApplication.translate("fluencyCAD", u"- Cnct", None))
|
||||
self.pb_add_connector.setText(QCoreApplication.translate("fluencyCAD", u"+ Cnct", None))
|
||||
self.pb_add_connector_2.setText(QCoreApplication.translate("fluencyCAD", u"+Jnt", None))
|
||||
self.pb_add_connector_3.setText(QCoreApplication.translate("fluencyCAD", u"-Jnt", None))
|
||||
self.gl_box.setTitle(QCoreApplication.translate("fluencyCAD", u"Model Viewer", None))
|
||||
self.groupBox_11.setTitle(QCoreApplication.translate("fluencyCAD", u"Sketch", None))
|
||||
self.groupBox_6.setTitle(QCoreApplication.translate("fluencyCAD", u"Tools", None))
|
||||
self.pb_edt_sktch.setText(QCoreApplication.translate("fluencyCAD", u"Edt", None))
|
||||
self.pb_nw_sktch.setText(QCoreApplication.translate("fluencyCAD", u"Add", None))
|
||||
self.pb_del_sketch.setText(QCoreApplication.translate("fluencyCAD", u"Del", None))
|
||||
self.assembly_tools.setTitle(QCoreApplication.translate("fluencyCAD", u"Assembly Tools", None))
|
||||
self.pb_compo_to_assembly.setText(QCoreApplication.translate("fluencyCAD", u"Add", None))
|
||||
self.pb_remove_compo_from_assembly.setText(QCoreApplication.translate("fluencyCAD", u"Rem", None))
|
||||
self.compo_tool_box.setTitle(QCoreApplication.translate("fluencyCAD", u"Component Tools", None))
|
||||
self.pb_new_compo.setText(QCoreApplication.translate("fluencyCAD", u"New", None))
|
||||
self.pb_del_compo.setText(QCoreApplication.translate("fluencyCAD", u"Del", None))
|
||||
self.compo_box.setTitle(QCoreApplication.translate("fluencyCAD", u"Components", None))
|
||||
self.groupBox_12.setTitle(QCoreApplication.translate("fluencyCAD", u"Component Connections", None))
|
||||
self.groupBox_13.setTitle(QCoreApplication.translate("fluencyCAD", u"Tools", None))
|
||||
self.pb_del_connection.setText(QCoreApplication.translate("fluencyCAD", u"Del", None))
|
||||
self.pb_update_connection.setText(QCoreApplication.translate("fluencyCAD", u"Upd", None))
|
||||
self.pb_edt_sktch_4.setText(QCoreApplication.translate("fluencyCAD", u"Nothing", None))
|
||||
self.groupBox_10.setTitle(QCoreApplication.translate("fluencyCAD", u"Bodys / Operations", None))
|
||||
self.groupBox_8.setTitle(QCoreApplication.translate("fluencyCAD", u"Tools", None))
|
||||
self.pb_del_body.setText(QCoreApplication.translate("fluencyCAD", u"Del", None))
|
||||
self.pb_update_body.setText(QCoreApplication.translate("fluencyCAD", u"Upd", None))
|
||||
self.pb_edt_sktch_3.setText(QCoreApplication.translate("fluencyCAD", u"Nothing", None))
|
||||
self.groupBox_2.setTitle(QCoreApplication.translate("fluencyCAD", u"Drawing", None))
|
||||
self.pb_arc_tool.setText(QCoreApplication.translate("fluencyCAD", u"Arc", None))
|
||||
self.pb_rectool.setText(QCoreApplication.translate("fluencyCAD", u"Rctgl", None))
|
||||
self.pb_circtool.setText(QCoreApplication.translate("fluencyCAD", u"Circle", None))
|
||||
self.pb_enable_construct.setText(QCoreApplication.translate("fluencyCAD", u"Cstrct", None))
|
||||
self.pb_enable_snap.setText(QCoreApplication.translate("fluencyCAD", u"Snap", None))
|
||||
self.pb_linetool.setText(QCoreApplication.translate("fluencyCAD", u"Line", None))
|
||||
#if QT_CONFIG(shortcut)
|
||||
self.pb_linetool.setShortcut(QCoreApplication.translate("fluencyCAD", u"S", None))
|
||||
#endif // QT_CONFIG(shortcut)
|
||||
self.pb_slotool.setText(QCoreApplication.translate("fluencyCAD", u"Slot", None))
|
||||
#if QT_CONFIG(tooltip)
|
||||
self.pb_offset_tool.setToolTip(QCoreApplication.translate("fluencyCAD", u"Offset selected sketch face (duplicate + offset boundary)", None))
|
||||
#endif // QT_CONFIG(tooltip)
|
||||
self.pb_offset_tool.setText(QCoreApplication.translate("fluencyCAD", u"Offst", None))
|
||||
self.groupBox_3.setTitle(QCoreApplication.translate("fluencyCAD", u"Constrain", None))
|
||||
#if QT_CONFIG(tooltip)
|
||||
self.pb_con_ptpt.setToolTip(QCoreApplication.translate("fluencyCAD", u"Poin to Point Constrain", None))
|
||||
#endif // QT_CONFIG(tooltip)
|
||||
self.pb_con_ptpt.setText(QCoreApplication.translate("fluencyCAD", u"Pt_Pt", None))
|
||||
#if QT_CONFIG(tooltip)
|
||||
self.pb_con_vert.setToolTip(QCoreApplication.translate("fluencyCAD", u"Vertical Constrain", None))
|
||||
#endif // QT_CONFIG(tooltip)
|
||||
self.pb_con_vert.setText(QCoreApplication.translate("fluencyCAD", u"Vert", None))
|
||||
self.pb_con_sym.setText(QCoreApplication.translate("fluencyCAD", u"Symetrc", None))
|
||||
#if QT_CONFIG(tooltip)
|
||||
self.pb_con_mid.setToolTip(QCoreApplication.translate("fluencyCAD", u"Point to Middle Point Constrain", None))
|
||||
#endif // QT_CONFIG(tooltip)
|
||||
self.pb_con_mid.setText(QCoreApplication.translate("fluencyCAD", u"Pt_Mid_L", None))
|
||||
#if QT_CONFIG(tooltip)
|
||||
self.pb_con_line.setToolTip(QCoreApplication.translate("fluencyCAD", u"Point to Line Constrain", None))
|
||||
#endif // QT_CONFIG(tooltip)
|
||||
self.pb_con_line.setText(QCoreApplication.translate("fluencyCAD", u"Pt_Lne", None))
|
||||
#if QT_CONFIG(tooltip)
|
||||
self.pb_con_horiz.setToolTip(QCoreApplication.translate("fluencyCAD", u"Horizontal Constrain ", None))
|
||||
#endif // QT_CONFIG(tooltip)
|
||||
self.pb_con_horiz.setText(QCoreApplication.translate("fluencyCAD", u"Horiz", None))
|
||||
#if QT_CONFIG(tooltip)
|
||||
self.pb_con_dist.setToolTip(QCoreApplication.translate("fluencyCAD", u"Dimension of Line of Distance from Point to Line", None))
|
||||
#endif // QT_CONFIG(tooltip)
|
||||
self.pb_con_dist.setText(QCoreApplication.translate("fluencyCAD", u"Distnce", None))
|
||||
#if QT_CONFIG(tooltip)
|
||||
self.pb_con_perp.setToolTip(QCoreApplication.translate("fluencyCAD", u"Constrain Line perpendicular to another line.", None))
|
||||
#endif // QT_CONFIG(tooltip)
|
||||
self.pb_con_perp.setText(QCoreApplication.translate("fluencyCAD", u"Perp_Lne", None))
|
||||
self.pb_con_diameter.setText(QCoreApplication.translate("fluencyCAD", u"Diameter", None))
|
||||
self.groupBox_5.setTitle(QCoreApplication.translate("fluencyCAD", u"Snapping Points", None))
|
||||
self.label.setText(QCoreApplication.translate("fluencyCAD", u"Snp Dst", None))
|
||||
self.pb_snap_vert.setText(QCoreApplication.translate("fluencyCAD", u"Vert", None))
|
||||
self.label_2.setText(QCoreApplication.translate("fluencyCAD", u"Angl Stps", None))
|
||||
self.spinbox_snap_distance.setSuffix(QCoreApplication.translate("fluencyCAD", u"mm", None))
|
||||
self.pushButton_7.setText(QCoreApplication.translate("fluencyCAD", u"Grid", None))
|
||||
self.pb_snap_horiz.setText(QCoreApplication.translate("fluencyCAD", u"Horiz", None))
|
||||
self.spinbox_angle_steps.setSuffix(QCoreApplication.translate("fluencyCAD", u"\u00b0", None))
|
||||
self.pushButton_8.setText(QCoreApplication.translate("fluencyCAD", u"Pnt", None))
|
||||
self.pb_snap_midp.setText(QCoreApplication.translate("fluencyCAD", u"MidP", None))
|
||||
self.pb_snap_angle.setText(QCoreApplication.translate("fluencyCAD", u"Angles", None))
|
||||
self.groupBox.setTitle(QCoreApplication.translate("fluencyCAD", u"Modify", None))
|
||||
self.pb_revop.setText(QCoreApplication.translate("fluencyCAD", u"Rev", None))
|
||||
self.pb_extrdop.setText(QCoreApplication.translate("fluencyCAD", u"Extrd", None))
|
||||
self.pb_arrayop.setText(QCoreApplication.translate("fluencyCAD", u"Arry", None))
|
||||
self.pb_cutop.setText(QCoreApplication.translate("fluencyCAD", u"Cut", None))
|
||||
self.pb_combop.setText(QCoreApplication.translate("fluencyCAD", u"Comb", None))
|
||||
self.pb_moveop.setText(QCoreApplication.translate("fluencyCAD", u"Mve", None))
|
||||
self.groupBox_4.setTitle(QCoreApplication.translate("fluencyCAD", u"Export", None))
|
||||
self.pushButton_2.setText(QCoreApplication.translate("fluencyCAD", u"STL", None))
|
||||
self.pb_export_step.setText(QCoreApplication.translate("fluencyCAD", u"STEP", None))
|
||||
self.pb_export_iges.setText(QCoreApplication.translate("fluencyCAD", u"IGES", None))
|
||||
self.menuFile.setTitle(QCoreApplication.translate("fluencyCAD", u"File", None))
|
||||
self.menuSettings.setTitle(QCoreApplication.translate("fluencyCAD", u"Settings", None))
|
||||
# retranslateUi
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,918 @@
|
||||
"""3D viewer widget — wraps OCC's AIS/V3d native display for use inside Qt."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
from PySide6.QtWidgets import QWidget
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Viewer3DWidget(QWidget):
|
||||
"""3D viewer widget using OCC's native AIS display."""
|
||||
|
||||
# Emitted when the user picks a planar face to sketch on.
|
||||
# Payload: (origin, normal, x_dir, face_shape) — all tuples are (x,y,z).
|
||||
facePicked = Signal(tuple, tuple, tuple, object)
|
||||
# Emitted when face-pick mode is cancelled (Esc) so the host can uncheck.
|
||||
pickFaceCancelled = Signal()
|
||||
|
||||
# Emitted when the user picks an entity for a connector point (assembly).
|
||||
# Payload: (origin, normal, x_dir, entity_type, face_or_edge_or_vertex, owner_obj_id).
|
||||
connectorPicked = Signal(tuple, tuple, tuple, str, object, str)
|
||||
# Emitted when connector pick mode is cancelled.
|
||||
connectorPickCancelled = Signal()
|
||||
# Emitted on mouse move in connector mode to show snap preview.
|
||||
# Payload: (origin, normal, entity_type, owner_obj_id) or None if nothing.
|
||||
connectorHover = Signal(object)
|
||||
|
||||
# Emitted when a body is clicked in assembly move mode.
|
||||
# Payload: owner_obj_id.
|
||||
assemblyComponentActivated = Signal(str)
|
||||
# Emitted during a drag move: owner_obj_id, world dx, dy, dz.
|
||||
assemblyComponentDragged = Signal(str, float, float, float)
|
||||
# Emitted when a drag move finishes.
|
||||
assemblyMoveFinished = Signal(str)
|
||||
|
||||
# Emitted whenever the camera changes (orbit, pan, zoom).
|
||||
# Payload: (eye, at, up) — each is a tuple of 3 floats.
|
||||
cameraChanged = Signal(tuple, tuple, tuple)
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
# For OCC's direct OpenGL rendering we need Qt to not paint over it.
|
||||
self.setAttribute(Qt.WA_PaintOnScreen)
|
||||
self.setAttribute(Qt.WA_OpaquePaintEvent)
|
||||
self.setAutoFillBackground(False)
|
||||
# Accept keyboard focus so navigation shortcuts (F, R, 1-7, P, O) work.
|
||||
self.setFocusPolicy(Qt.StrongFocus)
|
||||
# Enable mouse tracking so ``mouseMoveEvent`` fires even without a
|
||||
# button held — required for the connector-pick hover gizmo (and any
|
||||
# status-bar hover feedback) to show under the cursor as the user
|
||||
# moves the mouse over candidate snap entities before clicking.
|
||||
self.setMouseTracking(True)
|
||||
# Try OCC renderer first; fall back to pygfx if unavailable.
|
||||
self._renderer: Any = None
|
||||
self._initialized = False
|
||||
self._meshes: Dict[str, Any] = {}
|
||||
self._selected_normal: Optional[Tuple[float, float, float]] = None
|
||||
self._centroid: Optional[Tuple[float, float, float]] = None
|
||||
self._pending_meshes: List[Tuple] = []
|
||||
# When True, a left-click picks a planar face (for sketch-on-surface)
|
||||
# instead of orbiting the camera. Set via set_pick_face_mode().
|
||||
self._pick_face_mode: bool = False
|
||||
# When True, a left-click picks an entity for a connector point
|
||||
# (assembly component connection).
|
||||
self._connector_pick_mode: bool = False
|
||||
# Current snap highlight object id (for hover during connector mode).
|
||||
self._connector_snap_id: Optional[str] = None
|
||||
# Throttle connector hover probes to avoid UI lag on fast mouse moves.
|
||||
self._connector_last_hover_time: float = 0.0
|
||||
self._connector_hover_interval: float = 0.05 # 50 ms between probes
|
||||
# When True, left-click on a body activates assembly drag-to-move.
|
||||
self._assembly_move_mode: bool = False
|
||||
# State for ongoing assembly drag.
|
||||
self._move_drag_active: bool = False
|
||||
self._move_owner_obj_id: str = ""
|
||||
self._move_click_3d: Optional[Tuple[float, float, float]] = None
|
||||
self._move_click_screen: Optional[Any] = None
|
||||
self._move_plane_normal: Optional[Tuple[float, float, float]] = None
|
||||
self._move_initial_position: Optional[Tuple[float, float, float]] = None
|
||||
# Most recently recorded owning obj_id for the face returned by
|
||||
# ``pick_planar_face``. Stashed on each pick pass so the host can
|
||||
# pair the picked face with the body it belongs to (used to auto-
|
||||
# target a cut/union extrude against the body the sketch was
|
||||
# projected onto).
|
||||
self._last_pick_owner_obj_id: Optional[str] = None
|
||||
|
||||
def _init_renderer(self) -> None:
|
||||
"""Create the best available renderer."""
|
||||
if self._renderer is not None:
|
||||
return
|
||||
import sys as _sys
|
||||
|
||||
_sys.stdout.flush()
|
||||
logger.info("Renderer: starting import...")
|
||||
from fluency.rendering.occ_renderer import OCCRenderer
|
||||
from fluency.rendering.pygfx_renderer import PygfxRenderer
|
||||
|
||||
logger.info("Renderer: imports done, creating OCCRenderer...")
|
||||
occ = OCCRenderer()
|
||||
logger.info("Renderer: calling occ.initialize...")
|
||||
try:
|
||||
ok = occ.initialize(self)
|
||||
except Exception as exc:
|
||||
logger.warning(f"OCCRenderer init raised: {exc}")
|
||||
ok = False
|
||||
logger.info(f"Renderer: OCC result={ok}")
|
||||
if ok:
|
||||
self._renderer = occ
|
||||
logger.info("Using OCCRenderer (native BRep display)")
|
||||
else:
|
||||
logger.info("Falling back to PygfxRenderer")
|
||||
self._renderer = PygfxRenderer()
|
||||
logger.info("Renderer: calling pygfx initialize...")
|
||||
self._renderer.initialize(self)
|
||||
logger.info("Renderer: pygfx init done")
|
||||
self._initialized = True
|
||||
logger.info("Renderer: initialization complete")
|
||||
|
||||
def showEvent(self, event):
|
||||
logger.info("Viewer3DWidget showEvent - initializing renderer")
|
||||
if not self._initialized:
|
||||
self._init_renderer()
|
||||
logger.info(f"Renderer initialized, pending meshes: {len(self._pending_meshes)}")
|
||||
for args in self._pending_meshes:
|
||||
self.add_mesh(*args)
|
||||
self._pending_meshes.clear()
|
||||
self._renderer.render()
|
||||
|
||||
def _ensure_initialized(self):
|
||||
if not self._initialized:
|
||||
logger.debug("Ensuring renderer is initialized")
|
||||
self._init_renderer()
|
||||
|
||||
def get_renderer(self):
|
||||
self._ensure_initialized()
|
||||
return self._renderer
|
||||
|
||||
def show_shape(self, shape: Any, color=None, name=None) -> str:
|
||||
"""Display an OCC TopoDS_Shape.
|
||||
|
||||
Uses OCCRenderer.add_shape for native AIS display, or falls back to
|
||||
triangulation + add_mesh for the PygfxRenderer.
|
||||
"""
|
||||
self._ensure_initialized()
|
||||
from fluency.rendering.occ_renderer import OCCRenderer
|
||||
|
||||
if isinstance(self._renderer, OCCRenderer):
|
||||
oid = self._renderer.add_shape(shape, color, name)
|
||||
self._renderer.render()
|
||||
return oid
|
||||
# Fallback: tessellate and use the mesh pipeline.
|
||||
from fluency.geometry_occ.kernel import OCGeometryKernel
|
||||
|
||||
k = OCGeometryKernel()
|
||||
# Build a temporary OCCGeometryObject to use the kernel's mesh helpers.
|
||||
from fluency.geometry_occ.kernel import OCCGeometryObject
|
||||
|
||||
obj = OCCGeometryObject(shape)
|
||||
verts, faces = k.get_mesh(obj)
|
||||
oid = self._renderer.add_mesh(verts, faces, color, name)
|
||||
# Edges
|
||||
try:
|
||||
e_verts, e_edges = k.get_edges(obj)
|
||||
if len(e_verts) > 0:
|
||||
self._renderer.add_wireframe(
|
||||
e_verts, e_edges, (0.9, 0.9, 0.9), line_width=1.5, name=f"{name}_edges"
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
self._renderer.render()
|
||||
return oid
|
||||
|
||||
def add_mesh(self, vertices, faces, color=None, name=None) -> str:
|
||||
logger.debug(
|
||||
f"add_mesh called: initialized={self._initialized}, vertices={len(vertices)}, faces={len(faces)}, name={name}"
|
||||
)
|
||||
if not self._initialized:
|
||||
self._pending_meshes.append((vertices, faces, color, name))
|
||||
logger.info(f"Queued pending mesh, total pending: {len(self._pending_meshes)}")
|
||||
return f"pending_{len(self._pending_meshes)}"
|
||||
|
||||
self._ensure_initialized()
|
||||
mesh_id = self._renderer.add_mesh(vertices, faces, color, name)
|
||||
self._meshes[mesh_id] = {"vertices": vertices, "faces": faces, "name": name}
|
||||
self._renderer.render()
|
||||
logger.info(f"Added mesh: {mesh_id}, name={name}")
|
||||
return mesh_id
|
||||
|
||||
def update_mesh(self, mesh_id: str, vertices, faces):
|
||||
self._ensure_initialized()
|
||||
self._renderer.update_mesh(mesh_id, vertices, faces)
|
||||
self._meshes[mesh_id] = {"vertices": vertices, "faces": faces}
|
||||
self._renderer.render()
|
||||
|
||||
def add_wireframe(self, vertices, edges, color=None, line_width=1.0, name=None) -> str:
|
||||
self._ensure_initialized()
|
||||
wid = self._renderer.add_wireframe(
|
||||
vertices, edges, color or (0.9, 0.9, 0.9), line_width, name
|
||||
)
|
||||
self._renderer.render()
|
||||
return wid
|
||||
|
||||
def remove_mesh(self, mesh_id: str):
|
||||
self._ensure_initialized()
|
||||
self._renderer.remove_mesh(mesh_id)
|
||||
if mesh_id in self._meshes:
|
||||
del self._meshes[mesh_id]
|
||||
self._renderer.render()
|
||||
|
||||
def set_visibility(self, mesh_id: str, visible: bool) -> bool:
|
||||
"""Show or hide a previously-added mesh without removing it.
|
||||
|
||||
Used by the per-body visibility toggle on the body list so the
|
||||
user can quickly hide a body (e.g. to verify a cut worked on
|
||||
another body). Returns True on success, False if the mesh is
|
||||
unknown to the renderer or the renderer doesn't support it
|
||||
(e.g. the Pygfx fallback ABI).
|
||||
"""
|
||||
self._ensure_initialized()
|
||||
fn = getattr(self._renderer, "set_visibility", None)
|
||||
if fn is None:
|
||||
return False
|
||||
ok = fn(mesh_id, visible)
|
||||
if ok:
|
||||
self._renderer.render()
|
||||
return ok
|
||||
|
||||
def set_transparency(self, mesh_id: str, transparency: float) -> bool:
|
||||
"""Set a previously-added mesh's transparency (0..1).
|
||||
|
||||
Used by the live extrude preview to dim the target body so the
|
||||
previewed result reads on top of it.
|
||||
"""
|
||||
self._ensure_initialized()
|
||||
fn = getattr(self._renderer, "set_object_transparency", None)
|
||||
if fn is None:
|
||||
return False
|
||||
return fn(mesh_id, transparency)
|
||||
|
||||
def show_preview(self, shape: Any, color=None, transparency: float = 0.60) -> None:
|
||||
"""Display a temporary transparent preview of *shape* in the 3D view.
|
||||
|
||||
Used by the ExtrudeDialog live preview: as the user drags the
|
||||
length spinner or toggles Cut/Through-All, the host recomputes
|
||||
the operation result and shows it here. Call clear_preview()
|
||||
when the dialog closes.
|
||||
"""
|
||||
self._ensure_initialized()
|
||||
fn = getattr(self._renderer, "preview_shape", None)
|
||||
if fn is None:
|
||||
return
|
||||
fn(shape, color, transparency)
|
||||
|
||||
def clear_preview(self) -> None:
|
||||
"""Remove the live extrude preview shape, if any."""
|
||||
if not self._initialized or self._renderer is None:
|
||||
return
|
||||
fn = getattr(self._renderer, "clear_preview", None)
|
||||
if fn is None:
|
||||
return
|
||||
fn()
|
||||
|
||||
def clear_scene(self):
|
||||
self._ensure_initialized()
|
||||
self._renderer.clear_scene()
|
||||
self._meshes.clear()
|
||||
self._renderer.render()
|
||||
|
||||
def fit_camera(self):
|
||||
self._ensure_initialized()
|
||||
self._renderer.fit_camera()
|
||||
self._renderer.render()
|
||||
|
||||
# ─── Workplane visualization ───────────────────────────────────────────
|
||||
|
||||
def show_workplane(
|
||||
self,
|
||||
origin: Tuple[float, float, float] = (0, 0, 0),
|
||||
normal: Tuple[float, float, float] = (0, 0, 1),
|
||||
x_dir: Tuple[float, float, float] = (1, 0, 0),
|
||||
size: float = 200.0,
|
||||
name: Optional[str] = None,
|
||||
) -> Optional[str]:
|
||||
"""Display a semi-transparent workplane plane in the 3D view.
|
||||
|
||||
Returns the object ID (for later removal) or None if the renderer
|
||||
doesn't support workplane planes.
|
||||
"""
|
||||
self._ensure_initialized()
|
||||
fn = getattr(self._renderer, "show_workplane_plane", None)
|
||||
if fn is None:
|
||||
return None
|
||||
oid = fn(origin, normal, x_dir, size, name)
|
||||
self._renderer.render()
|
||||
return oid
|
||||
|
||||
def remove_workplane(self, obj_id: str) -> bool:
|
||||
"""Remove a workplane plane visual by its ID."""
|
||||
self._ensure_initialized()
|
||||
fn = getattr(self._renderer, "remove_workplane_plane", None)
|
||||
if fn is None:
|
||||
return False
|
||||
ok = fn(obj_id)
|
||||
if ok:
|
||||
self._renderer.render()
|
||||
return ok
|
||||
|
||||
def mousePressEvent(self, event):
|
||||
self._ensure_initialized()
|
||||
# Face-pick mode: a left-click selects a planar face to sketch on.
|
||||
if self._pick_face_mode and event.button() == Qt.LeftButton:
|
||||
self._handle_face_pick(event)
|
||||
return
|
||||
# Connector pick mode: a left-click selects a face for a connection point.
|
||||
if self._connector_pick_mode and event.button() == Qt.LeftButton:
|
||||
self._handle_connector_pick(event)
|
||||
return
|
||||
# Assembly move mode: start dragging the clicked body.
|
||||
if self._assembly_move_mode and event.button() == Qt.LeftButton:
|
||||
self._handle_assembly_move_press(event)
|
||||
return
|
||||
self._renderer.handle_mouse_press(event)
|
||||
super().mousePressEvent(event)
|
||||
|
||||
def mouseMoveEvent(self, event):
|
||||
self._ensure_initialized()
|
||||
# In connector mode, show snap hover.
|
||||
# Selection modes are deactivated so we skip the idle MoveTo
|
||||
# (dynamic highlighting) — only the gizmo hover handler runs.
|
||||
if self._connector_pick_mode:
|
||||
self._handle_connector_hover(event)
|
||||
super().mouseMoveEvent(event)
|
||||
return
|
||||
# If connector mode was just exited (gizmo persists after pick),
|
||||
# clear any lingering gizmo on first mouse move.
|
||||
gizmo_objs = getattr(self._renderer, "_gizmo_objects", None)
|
||||
if self._connector_snap_id is not None or (gizmo_objs and len(gizmo_objs) > 0):
|
||||
self._clear_connector_snap()
|
||||
# In face-pick mode, keep dynamic highlighting.
|
||||
if self._pick_face_mode:
|
||||
if hasattr(self._renderer, "handle_mouse_move"):
|
||||
self._renderer.handle_mouse_move(event)
|
||||
super().mouseMoveEvent(event)
|
||||
return
|
||||
# Active drag in assembly move mode.
|
||||
if self._move_drag_active:
|
||||
self._handle_assembly_move_move(event)
|
||||
super().mouseMoveEvent(event)
|
||||
return
|
||||
self._renderer.handle_mouse_move(event)
|
||||
super().mouseMoveEvent(event)
|
||||
|
||||
def paintEngine(self):
|
||||
"""Return None to prevent Qt from painting over OCC's direct OpenGL."""
|
||||
return None
|
||||
|
||||
def paintEvent(self, event):
|
||||
"""Empty paintEvent — OCC draws directly via OpenGL."""
|
||||
pass
|
||||
|
||||
def mouseReleaseEvent(self, event):
|
||||
self._ensure_initialized()
|
||||
# Finish assembly drag.
|
||||
if self._move_drag_active:
|
||||
self._handle_assembly_move_release(event)
|
||||
return
|
||||
self._renderer.handle_mouse_release(event)
|
||||
super().mouseReleaseEvent(event)
|
||||
# Camera may have changed during orbit/pan — emit signal.
|
||||
try:
|
||||
eye, at_, up = self.get_camera_position()
|
||||
self.cameraChanged.emit(
|
||||
tuple(float(v) for v in eye),
|
||||
tuple(float(v) for v in at_),
|
||||
tuple(float(v) for v in up),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def wheelEvent(self, event):
|
||||
self._ensure_initialized()
|
||||
self._renderer.handle_wheel(event)
|
||||
super().wheelEvent(event)
|
||||
# Zoom changed the camera — emit signal.
|
||||
try:
|
||||
eye, at_, up = self.get_camera_position()
|
||||
self.cameraChanged.emit(
|
||||
tuple(float(v) for v in eye),
|
||||
tuple(float(v) for v in at_),
|
||||
tuple(float(v) for v in up),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def resizeEvent(self, event):
|
||||
super().resizeEvent(event)
|
||||
self._ensure_initialized()
|
||||
self._renderer.handle_resize(event.size().width(), event.size().height())
|
||||
|
||||
def set_camera_position(self, position, target):
|
||||
self._ensure_initialized()
|
||||
self._renderer.set_camera_position(position, target)
|
||||
self._renderer.render()
|
||||
|
||||
def get_camera_position(self):
|
||||
"""Return the current camera ``(eye, at, up)`` triple.
|
||||
|
||||
The underlying renderer's ``get_camera_position`` returns three
|
||||
``np.ndarray``s. We forward the call so callers (notably
|
||||
:meth:`MainWindow._collect_view_state`) can persist the camera.
|
||||
Returns a tuple of ``(np.zeros(3), np.zeros(3), (0,0,1))`` if the
|
||||
renderer hasn't been initialised yet (e.g. when the window is
|
||||
being constructed).
|
||||
"""
|
||||
self._ensure_initialized()
|
||||
if hasattr(self._renderer, "get_camera_position"):
|
||||
return self._renderer.get_camera_position()
|
||||
import numpy as np
|
||||
|
||||
return (
|
||||
np.zeros(3, dtype=float),
|
||||
np.zeros(3, dtype=float),
|
||||
np.array([0.0, 0.0, 1.0], dtype=float),
|
||||
)
|
||||
|
||||
def get_camera_fov(self) -> float:
|
||||
"""Return the current vertical FOV in degrees from the renderer.
|
||||
|
||||
Falls back to 45.0 if the renderer doesn't support it.
|
||||
"""
|
||||
self._ensure_initialized()
|
||||
if hasattr(self._renderer, "get_camera_fov"):
|
||||
return self._renderer.get_camera_fov()
|
||||
return 45.0
|
||||
|
||||
# ─── Face-pick mode (sketch-on-surface) ────────────────────────────────
|
||||
|
||||
def set_pick_face_mode(self, enabled: bool) -> None:
|
||||
"""Toggle face-pick mode.
|
||||
|
||||
When enabled, the cursor selects planar faces for sketch placement
|
||||
instead of orbiting the camera. Middle button still pans; wheel zooms.
|
||||
"""
|
||||
self._pick_face_mode = bool(enabled)
|
||||
if enabled:
|
||||
self.setCursor(Qt.CrossCursor)
|
||||
else:
|
||||
self.unsetCursor()
|
||||
|
||||
def is_pick_face_mode(self) -> bool:
|
||||
return self._pick_face_mode
|
||||
|
||||
def highlight_face(self, face: Any) -> None:
|
||||
"""Tint the picked face light-blue/transparent in the 3D viewer."""
|
||||
self._ensure_initialized()
|
||||
fn = getattr(self._renderer, "highlight_face", None)
|
||||
if fn is not None:
|
||||
fn(face)
|
||||
self._renderer.render()
|
||||
|
||||
def clear_face_highlight(self) -> None:
|
||||
"""Remove the persistent face-selection tint."""
|
||||
self._ensure_initialized()
|
||||
fn = getattr(self._renderer, "clear_face_highlight", None)
|
||||
if fn is not None:
|
||||
fn()
|
||||
self._renderer.render()
|
||||
|
||||
# ─── Connector pick mode (assembly) ────────────────────────────────────
|
||||
|
||||
def set_connector_pick_mode(self, enabled: bool, clear_gizmo: bool = True) -> None:
|
||||
"""Toggle connector pick mode for placing connection points.
|
||||
|
||||
When enabled, clicking an entity (face, edge, vertex, hole)
|
||||
on a body in the assembly view captures its position and
|
||||
direction as a connection point for the SolveSpace solver.
|
||||
|
||||
Entering connector mode deactivates standard OCC face/edge/vertex
|
||||
selection so dynamic highlighting does not clash with the gizmo
|
||||
visuals. Selection is re-activated on exit.
|
||||
|
||||
*clear_gizmo*: if False the gizmo marker is not cleared on exit,
|
||||
allowing it to persist until the next hover event (used after a
|
||||
successful pick so the user sees what was selected).
|
||||
"""
|
||||
self._connector_pick_mode = bool(enabled)
|
||||
if enabled:
|
||||
self.setCursor(Qt.CrossCursor)
|
||||
# Disable standard OCC selection so gizmo visuals are not
|
||||
# interfered with by dynamic face highlighting.
|
||||
fn = getattr(self._renderer, "deactivate_selection_modes", None)
|
||||
if fn is not None:
|
||||
fn()
|
||||
else:
|
||||
if clear_gizmo:
|
||||
self._clear_connector_snap()
|
||||
# Restore standard OCC selection for face-pick / normal modes.
|
||||
fn = getattr(self._renderer, "activate_selection_modes", None)
|
||||
if fn is not None:
|
||||
fn()
|
||||
if not self._pick_face_mode:
|
||||
self.unsetCursor()
|
||||
|
||||
def is_connector_pick_mode(self) -> bool:
|
||||
return self._connector_pick_mode
|
||||
|
||||
def _clear_connector_snap(self) -> None:
|
||||
"""Remove the hover gizmo."""
|
||||
fn = getattr(self._renderer, "clear_entity_gizmo", None)
|
||||
if fn is not None:
|
||||
fn()
|
||||
# Backwards compat: also try the old method.
|
||||
if self._connector_snap_id is not None:
|
||||
fn2 = getattr(self._renderer, "remove_highlight_snap", None)
|
||||
if fn2 is not None:
|
||||
fn2(self._connector_snap_id)
|
||||
self._connector_snap_id = None
|
||||
|
||||
def _handle_connector_hover(self, event) -> None:
|
||||
"""Update the hover snap gizmo during connector pick mode.
|
||||
|
||||
Uses geometric probing (direct topology walk) which does not depend
|
||||
on OCC's selection system — this avoids clashing with the gizmo
|
||||
visuals since selection modes are deactivated in connector mode.
|
||||
Probes are throttled to at most once every 50 ms to avoid UI lag
|
||||
on fast mouse moves.
|
||||
"""
|
||||
import time
|
||||
|
||||
now = time.monotonic()
|
||||
if now - self._connector_last_hover_time < self._connector_hover_interval:
|
||||
return # throttled — skip this mouse move
|
||||
self._connector_last_hover_time = now
|
||||
|
||||
self._ensure_initialized()
|
||||
probe = getattr(self._renderer, "probe_snap_candidates_geometric", None)
|
||||
pos = event.position().toPoint() if hasattr(event, "position") else event.pos()
|
||||
|
||||
if probe is not None:
|
||||
candidates = probe(pos.x(), pos.y())
|
||||
if not candidates:
|
||||
self._clear_connector_snap()
|
||||
self.connectorHover.emit(None)
|
||||
return
|
||||
# Primary = the nearest candidate (probe sorts nearest-first).
|
||||
info = candidates[0]
|
||||
else:
|
||||
# Fall back to the selection-system-based probe.
|
||||
probe2 = getattr(self._renderer, "probe_snap_candidates", None)
|
||||
if probe2 is not None:
|
||||
candidates = probe2(pos.x(), pos.y())
|
||||
if not candidates:
|
||||
self._clear_connector_snap()
|
||||
self.connectorHover.emit(None)
|
||||
return
|
||||
info = candidates[0]
|
||||
else:
|
||||
# Last resort: single-pixel pick.
|
||||
picker = getattr(self._renderer, "pick_entity", None)
|
||||
if picker is None:
|
||||
return
|
||||
info = picker(pos.x(), pos.y())
|
||||
candidates = [info] if info else []
|
||||
if info is None or info.get("owner_obj_id") is None:
|
||||
self._clear_connector_snap()
|
||||
self.connectorHover.emit(None)
|
||||
return
|
||||
|
||||
origin = info["position"]
|
||||
normal = info.get("normal")
|
||||
entity_type = info["type"]
|
||||
owner = info.get("owner_obj_id", "")
|
||||
|
||||
# ── Feature recognition ──
|
||||
# Enhance candidates with composite feature info (holes, edge loops, etc.)
|
||||
recognize = getattr(self._renderer, "recognize_composite_features", None)
|
||||
if recognize is not None and candidates:
|
||||
candidates = recognize(candidates, pos.x(), pos.y())
|
||||
info = candidates[0] # re-read primary after enhancement
|
||||
origin = info["position"]
|
||||
normal = info.get("normal")
|
||||
entity_type = info.get("type", info.get("feature_type", entity_type))
|
||||
|
||||
# Show smart entity gizmo — dim candidate markers + bright primary.
|
||||
self._clear_connector_snap()
|
||||
gizmo_fn = getattr(self._renderer, "show_entity_gizmo", None)
|
||||
if gizmo_fn is not None:
|
||||
gizmo_fn(
|
||||
entity_type=entity_type,
|
||||
position=origin,
|
||||
normal=normal,
|
||||
x_dir=info.get("x_dir"),
|
||||
radius=info.get("radius"),
|
||||
candidates=candidates,
|
||||
)
|
||||
else:
|
||||
# Fallback to old highlight_snap.
|
||||
fn = getattr(self._renderer, "highlight_snap", None)
|
||||
if fn is not None:
|
||||
colors = {
|
||||
"planar_face": (0.0, 0.8, 1.0), # cyan
|
||||
"cylindrical_face": (1.0, 0.4, 0.0), # orange (hole)
|
||||
"edge": (0.0, 1.0, 0.4), # green
|
||||
"vertex": (1.0, 1.0, 0.0), # yellow
|
||||
}
|
||||
c = colors.get(entity_type, (1.0, 0.6, 0.0))
|
||||
self._connector_snap_id = fn(origin, color=c, size=3.0)
|
||||
|
||||
# Build payload with feature recognition info.
|
||||
payload = {
|
||||
"origin": origin,
|
||||
"normal": normal,
|
||||
"type": entity_type,
|
||||
"owner_obj_id": owner,
|
||||
}
|
||||
# Attach feature info if available.
|
||||
if "feature_type" in info:
|
||||
payload["feature_type"] = info["feature_type"]
|
||||
if "suggestion" in info:
|
||||
payload["suggestion"] = info["suggestion"]
|
||||
if "feature_data" in info:
|
||||
payload["feature_data"] = info["feature_data"]
|
||||
|
||||
self.connectorHover.emit(payload)
|
||||
|
||||
def _handle_connector_pick(self, event) -> None:
|
||||
"""Detect an entity under the click and emit connectorPicked.
|
||||
|
||||
Uses geometric probing (direct topology walk) so a click selects the
|
||||
PRIMARY (nearest) snap target — the same one the hover gizmo
|
||||
emphasised. Falls back to selection-system probe, then single-pixel
|
||||
``pick_entity``, then ``pick_planar_face``.
|
||||
"""
|
||||
self._ensure_initialized()
|
||||
pos = event.position().toPoint() if hasattr(event, "position") else event.pos()
|
||||
info: Optional[Dict[str, Any]] = None
|
||||
|
||||
probe = getattr(self._renderer, "probe_snap_candidates_geometric", None)
|
||||
if probe is not None:
|
||||
candidates = probe(pos.x(), pos.y())
|
||||
if candidates:
|
||||
info = candidates[0] # nearest = primary
|
||||
|
||||
if info is None:
|
||||
probe2 = getattr(self._renderer, "probe_snap_candidates", None)
|
||||
if probe2 is not None:
|
||||
candidates = probe2(pos.x(), pos.y())
|
||||
if candidates:
|
||||
info = candidates[0]
|
||||
|
||||
if info is None:
|
||||
picker = getattr(self._renderer, "pick_entity", None)
|
||||
if picker is None:
|
||||
# Fallback to planar face only.
|
||||
picker = getattr(self._renderer, "pick_planar_face", None)
|
||||
if picker is None:
|
||||
logger.warning("Renderer has no entity picking support")
|
||||
return
|
||||
pinfo = picker(pos.x(), pos.y())
|
||||
if pinfo is None:
|
||||
logger.info("Connector pick: no planar face under cursor")
|
||||
return
|
||||
owner_obj_id = pinfo.get("owner_obj_id", "")
|
||||
self.connectorPicked.emit(
|
||||
tuple(pinfo["origin"]),
|
||||
tuple(pinfo["normal"]),
|
||||
tuple(pinfo["x_dir"]),
|
||||
"planar_face",
|
||||
pinfo["face"],
|
||||
owner_obj_id,
|
||||
)
|
||||
return
|
||||
info = picker(pos.x(), pos.y())
|
||||
|
||||
if info is None:
|
||||
logger.info("Connector pick: no entity under cursor")
|
||||
return
|
||||
owner_obj_id = info.get("owner_obj_id", "")
|
||||
if not owner_obj_id:
|
||||
return
|
||||
|
||||
entity_type = info["type"]
|
||||
origin = info["position"]
|
||||
normal = info.get("normal") or (0.0, 0.0, 1.0)
|
||||
x_dir = info.get("x_dir") or (1.0, 0.0, 0.0)
|
||||
|
||||
# For vertices, pick a sensible normal from the parent face if possible.
|
||||
if entity_type == "vertex" and normal is None:
|
||||
normal = (0.0, 0.0, 1.0)
|
||||
|
||||
# Package the raw shape appropriately.
|
||||
raw_shape = info.get("face") or info.get("edge") or info.get("vertex")
|
||||
|
||||
self.connectorPicked.emit(
|
||||
tuple(origin),
|
||||
tuple(normal),
|
||||
tuple(x_dir) if x_dir else (1.0, 0.0, 0.0),
|
||||
entity_type,
|
||||
raw_shape,
|
||||
owner_obj_id,
|
||||
)
|
||||
|
||||
# ─── Assembly move mode (3D drag) ─────────────────────────────────────
|
||||
|
||||
def set_assembly_move_mode(self, enabled: bool) -> None:
|
||||
"""Toggle assembly move mode.
|
||||
|
||||
When enabled, clicking on a body and dragging moves its
|
||||
assembly component in the view plane. Shift+drag moves in Z.
|
||||
"""
|
||||
self._assembly_move_mode = bool(enabled)
|
||||
if enabled:
|
||||
self.setCursor(Qt.SizeAllCursor)
|
||||
elif not self._pick_face_mode and not self._connector_pick_mode:
|
||||
self.unsetCursor()
|
||||
if not enabled:
|
||||
self._move_drag_active = False
|
||||
self._move_owner_obj_id = ""
|
||||
self._move_click_3d = None
|
||||
self._move_click_screen = None
|
||||
self._move_plane_normal = None
|
||||
self._move_initial_position = None
|
||||
|
||||
def _handle_assembly_move_press(self, event) -> None:
|
||||
"""Start a drag-to-move for the body under the cursor."""
|
||||
self._ensure_initialized()
|
||||
picker = getattr(self._renderer, "pick_planar_face", None)
|
||||
if picker is None:
|
||||
return
|
||||
pos = event.position().toPoint() if hasattr(event, "position") else event.pos()
|
||||
info = picker(pos.x(), pos.y())
|
||||
if info is None:
|
||||
return
|
||||
|
||||
owner_obj_id = info.get("owner_obj_id", "")
|
||||
if not owner_obj_id or not owner_obj_id.startswith("asm_"):
|
||||
return
|
||||
|
||||
# Store drag state.
|
||||
self._move_drag_active = True
|
||||
self._move_owner_obj_id = owner_obj_id
|
||||
self._move_click_3d = tuple(info["origin"])
|
||||
self._move_click_screen = pos
|
||||
self._move_plane_normal = tuple(info["normal"])
|
||||
|
||||
# Emit activation signal so MainWindow stores initial position.
|
||||
self.assemblyComponentActivated.emit(owner_obj_id)
|
||||
|
||||
def _handle_assembly_move_move(self, event) -> None:
|
||||
"""Continue the drag: project mouse delta to world-space and emit."""
|
||||
if not self._move_drag_active or self._move_click_screen is None:
|
||||
return
|
||||
|
||||
pos = event.position().toPoint() if hasattr(event, "position") else event.pos()
|
||||
|
||||
# Screen delta (Qt Y is inverted vs OCC).
|
||||
dx = pos.x() - self._move_click_screen.x()
|
||||
dy = -(pos.y() - self._move_click_screen.y()) # invert Y
|
||||
|
||||
# Convert screen delta to world units using the view scale.
|
||||
# view.Scale() returns a scale factor — the smaller the value the
|
||||
# more world distance per pixel. We use an empirical conversion:
|
||||
# at scale=1.0, ~1 pixel ≈ 0.3 world units at typical depth.
|
||||
scale = self._renderer._view.Scale() if hasattr(self._renderer, "_view") else 1.0
|
||||
world_per_pixel = 2.0 / max(scale, 0.001)
|
||||
|
||||
# Get camera vectors for proper view-plane projection.
|
||||
import numpy as np
|
||||
|
||||
try:
|
||||
# Get camera direction and up from the OCC view.
|
||||
camera = self._renderer._view.Camera()
|
||||
dir_ = camera.Direction()
|
||||
up_ = camera.Up()
|
||||
cam_dir = np.array([dir_.X(), dir_.Y(), dir_.Z()])
|
||||
cam_up = np.array([up_.X(), up_.Y(), up_.Z()])
|
||||
cam_right = np.cross(cam_dir, cam_up)
|
||||
cam_right = cam_right / np.linalg.norm(cam_right)
|
||||
cam_up = cam_up / np.linalg.norm(cam_up)
|
||||
except Exception:
|
||||
# Fallback: assume XY plane.
|
||||
cam_right = np.array([1.0, 0.0, 0.0])
|
||||
cam_up = np.array([0.0, 0.0, 1.0])
|
||||
|
||||
# Compute world-space delta.
|
||||
modifiers = event.modifiers()
|
||||
if modifiers & Qt.ShiftModifier:
|
||||
# Shift+drag: move along camera direction (Z-depth).
|
||||
dz_world = dx * world_per_pixel
|
||||
dx_world = 0.0
|
||||
dy_world = 0.0
|
||||
else:
|
||||
# Normal drag: move in view plane.
|
||||
dx_world = float(cam_right[0] * dx * world_per_pixel + cam_up[0] * dy * world_per_pixel)
|
||||
dy_world = float(cam_right[1] * dx * world_per_pixel + cam_up[1] * dy * world_per_pixel)
|
||||
dz_world = float(cam_right[2] * dx * world_per_pixel + cam_up[2] * dy * world_per_pixel)
|
||||
|
||||
self.assemblyComponentDragged.emit(self._move_owner_obj_id, dx_world, dy_world, dz_world)
|
||||
|
||||
def _handle_assembly_move_release(self, event) -> None:
|
||||
"""Finish the drag, emit final position."""
|
||||
self.assemblyMoveFinished.emit(self._move_owner_obj_id)
|
||||
self._move_drag_active = False
|
||||
self._move_owner_obj_id = ""
|
||||
self._move_click_3d = None
|
||||
self._move_click_screen = None
|
||||
self._move_plane_normal = None
|
||||
self._move_initial_position = None
|
||||
|
||||
def _handle_face_pick(self, event) -> None:
|
||||
"""Detect a planar face under the click and emit facePicked."""
|
||||
self._ensure_initialized()
|
||||
picker = getattr(self._renderer, "pick_planar_face", None)
|
||||
if picker is None:
|
||||
logger.warning("Renderer has no pick_planar_face support")
|
||||
return
|
||||
# Qt6: prefer position().toPoint() over deprecated pos().
|
||||
pos = event.position().toPoint() if hasattr(event, "position") else event.pos()
|
||||
info = picker(pos.x(), pos.y())
|
||||
if info is None:
|
||||
logger.info("Face pick: no planar face under cursor")
|
||||
return
|
||||
# Stash the owning obj_id so MainWindow._on_face_picked can pair the
|
||||
# picked face with the body it belongs to (for auto-targeted cut).
|
||||
self._last_pick_owner_obj_id = info.get("owner_obj_id")
|
||||
self.facePicked.emit(
|
||||
tuple(info["origin"]),
|
||||
tuple(info["normal"]),
|
||||
tuple(info["x_dir"]),
|
||||
info["face"],
|
||||
)
|
||||
|
||||
def set_view(self, view: str):
|
||||
# Prefer the renderer's native orientation snap (preserves target,
|
||||
# refits the scene). Falls back to absolute eye positions for
|
||||
# renderers that don't implement set_view_orientation.
|
||||
self._ensure_initialized()
|
||||
if hasattr(self._renderer, "set_view_orientation"):
|
||||
self._renderer.set_view_orientation(view)
|
||||
self._renderer.render()
|
||||
return
|
||||
positions = {
|
||||
"iso": ((100, 100, 100), (0, 0, 0)),
|
||||
"top": ((0, 0, 200), (0, 0, 0)),
|
||||
"front": ((0, -200, 0), (0, 0, 0)),
|
||||
"right": ((200, 0, 0), (0, 0, 0)),
|
||||
"back": ((0, 200, 0), (0, 0, 0)),
|
||||
"left": ((-200, 0, 0), (0, 0, 0)),
|
||||
"bottom": ((0, 0, -200), (0, 0, 0)),
|
||||
}
|
||||
if view in positions:
|
||||
pos, target = positions[view]
|
||||
self.set_camera_position(pos, target)
|
||||
|
||||
def mouseDoubleClickEvent(self, event):
|
||||
# Double-click → fit all (common CAD convention).
|
||||
self._ensure_initialized()
|
||||
if event.button() == Qt.LeftButton:
|
||||
self.fit_camera()
|
||||
super().mouseDoubleClickEvent(event)
|
||||
|
||||
def keyPressEvent(self, event):
|
||||
# Esc cancels face-pick mode.
|
||||
if self._pick_face_mode and event.key() == Qt.Key_Escape:
|
||||
self.set_pick_face_mode(False)
|
||||
self.pickFaceCancelled.emit()
|
||||
return
|
||||
# Esc cancels connector pick mode.
|
||||
if self._connector_pick_mode and event.key() == Qt.Key_Escape:
|
||||
self.set_connector_pick_mode(False)
|
||||
self.connectorPickCancelled.emit()
|
||||
return
|
||||
# Esc cancels assembly move mode.
|
||||
if self._assembly_move_mode and event.key() == Qt.Key_Escape:
|
||||
self.set_assembly_move_mode(False)
|
||||
return
|
||||
# Navigation shortcuts (lowercase = view presets, F = fit,
|
||||
# P/O = perspective/orthographic, R = reset).
|
||||
self._ensure_initialized()
|
||||
key = event.text().lower()
|
||||
mapping = {
|
||||
"f": "fit",
|
||||
"r": "reset",
|
||||
"1": "front",
|
||||
"2": "back",
|
||||
"3": "top",
|
||||
"4": "bottom",
|
||||
"5": "left",
|
||||
"6": "right",
|
||||
"7": "iso",
|
||||
}
|
||||
action = mapping.get(key)
|
||||
if action == "fit":
|
||||
self.fit_camera()
|
||||
return
|
||||
if action == "reset":
|
||||
if hasattr(self._renderer, "reset_camera"):
|
||||
self._renderer.reset_camera()
|
||||
self._renderer.render()
|
||||
else:
|
||||
self.set_view("iso")
|
||||
return
|
||||
if action in ("front", "back", "top", "bottom", "left", "right", "iso"):
|
||||
self.set_view(action)
|
||||
return
|
||||
if key == "p" and hasattr(self._renderer, "set_camera_perspective"):
|
||||
self._renderer.set_camera_perspective()
|
||||
self._renderer.render()
|
||||
return
|
||||
if key == "o" and hasattr(self._renderer, "set_camera_orthographic"):
|
||||
self._renderer.set_camera_orthographic()
|
||||
self._renderer.render()
|
||||
return
|
||||
super().keyPressEvent(event)
|
||||
Reference in New Issue
Block a user