Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 742d06d242 | |||
| c78d0af78c | |||
| dda9db822b | |||
| 9f1387fe68 | |||
| 210e3cfb5d | |||
| b8516fff91 | |||
| d7e5929a13 |
Generated
+72
-6
@@ -4,9 +4,14 @@
|
||||
<option name="autoReloadType" value="SELECTIVE" />
|
||||
</component>
|
||||
<component name="ChangeListManager">
|
||||
<list default="true" id="8f0bafd6-58a0-4b20-aa2b-ddc3ba278873" name="Changes" comment="- Added save file foramt - Split main.py refactor">
|
||||
<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/rendering/mitsuba_backend.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/rendering/mitsuba_backend.py" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/src/fluency/rendering/occ_renderer.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/rendering/occ_renderer.py" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/src/fluency/rendering/render_backend.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/rendering/render_backend.py" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/src/fluency/ui/main_window.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/ui/main_window.py" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/src/fluency/ui/render_window.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/ui/render_window.py" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/src/fluency/ui/viewer_widget.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/ui/viewer_widget.py" afterDir="false" />
|
||||
</list>
|
||||
<option name="SHOW_DIALOG" value="false" />
|
||||
<option name="HIGHLIGHT_CONFLICTS" value="true" />
|
||||
@@ -55,10 +60,13 @@
|
||||
"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",
|
||||
@@ -71,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)",
|
||||
@@ -86,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" />
|
||||
@@ -367,7 +376,63 @@
|
||||
<option name="project" value="LOCAL" />
|
||||
<updated>1783755278516</updated>
|
||||
</task>
|
||||
<option name="localTasksCounter" value="33" />
|
||||
<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">
|
||||
@@ -388,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" />
|
||||
@@ -412,6 +475,9 @@
|
||||
<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" />
|
||||
<option name="LAST_COMMIT_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>
|
||||
@@ -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
|
||||
@@ -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."""
|
||||
|
||||
@@ -626,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
|
||||
@@ -788,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)
|
||||
|
||||
@@ -171,6 +171,14 @@ def _body_to_dict(body: Body) -> Dict[str, Any]:
|
||||
"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],
|
||||
@@ -198,6 +206,14 @@ def _body_from_dict(
|
||||
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])),
|
||||
@@ -338,6 +354,7 @@ def _connector_to_dict(conn: Connector) -> Dict[str, Any]:
|
||||
"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,
|
||||
}
|
||||
@@ -358,6 +375,7 @@ def _connector_from_dict(data: Dict[str, Any]) -> Connector:
|
||||
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
|
||||
|
||||
@@ -222,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))
|
||||
|
||||
@@ -351,11 +365,14 @@ class Connector:
|
||||
id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
||||
name: str = "Untitled Connector"
|
||||
|
||||
# 3D position of the connection point (world coords).
|
||||
# 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).
|
||||
# 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.
|
||||
# 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).
|
||||
@@ -379,6 +396,10 @@ class Connector:
|
||||
# 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)
|
||||
|
||||
@@ -486,9 +507,14 @@ class Assembly:
|
||||
"""Record a mated connector pair between two component instances.
|
||||
|
||||
The first-picked component (``first_ac_id``) is treated as the
|
||||
grounded reference of the pair. Returns the AssemblyConnection for
|
||||
further bookkeeping (e.g. attaching partner connector ids).
|
||||
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,
|
||||
@@ -534,6 +560,10 @@ class Assembly:
|
||||
"""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:
|
||||
|
||||
@@ -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
|
||||
@@ -577,6 +577,128 @@ class OCCRenderer(Renderer):
|
||||
|
||||
return (_xyz(eye), _xyz(at), _xyz(up))
|
||||
|
||||
def get_camera_fov(self) -> float:
|
||||
"""Return the current vertical FOV in degrees from the OCC camera."""
|
||||
if self._view is None:
|
||||
return 45.0
|
||||
cam = self._view.Camera()
|
||||
fov_y = cam.FOVy()
|
||||
# Clamp to a reasonable range for Mitsuba (10–120 deg)
|
||||
return max(10.0, min(120.0, fov_y))
|
||||
|
||||
def get_render_camera(self) -> "RenderCamera":
|
||||
"""Compute a :class:`RenderCamera` from the current viewport state.
|
||||
|
||||
Handles both perspective and orthographic projection modes:
|
||||
|
||||
* **Perspective** — uses the actual eye/target/up/fov directly.
|
||||
* **Orthographic** — translates the view's scale factor (which encodes
|
||||
mouse-wheel zoom) into a camera distance so the render framing
|
||||
matches what the user sees in the viewport. In orthographic mode,
|
||||
zooming changes ``view.Scale()`` rather than moving the eye position,
|
||||
so simply reading ``Eye()`` produces incorrect framing.
|
||||
|
||||
Returns *None* if the view is not initialised.
|
||||
"""
|
||||
from .render_backend import RenderCamera
|
||||
|
||||
if self._view is None:
|
||||
return None
|
||||
|
||||
cam = self._view.Camera()
|
||||
eye_obj = self._view.Eye()
|
||||
at_obj = self._view.At()
|
||||
up_obj = self._view.Up()
|
||||
|
||||
def _xyz(v):
|
||||
if isinstance(v, (tuple, list)):
|
||||
return np.array([float(v[0]), float(v[1]), float(v[2])])
|
||||
return np.array([v.X(), v.Y(), v.Z()])
|
||||
|
||||
eye = _xyz(eye_obj)
|
||||
at = _xyz(at_obj)
|
||||
up = _xyz(up_obj)
|
||||
fov_y = max(10.0, min(120.0, cam.FOVy()))
|
||||
|
||||
# Check projection type.
|
||||
from OCP.Graphic3d import Graphic3d_Camera
|
||||
proj_type = cam.ProjectionType()
|
||||
is_orthographic = (
|
||||
proj_type == Graphic3d_Camera.Projection_Orthographic
|
||||
)
|
||||
|
||||
if not is_orthographic:
|
||||
# Perspective mode: use the actual eye position directly.
|
||||
return RenderCamera(
|
||||
origin=tuple(float(v) for v in eye),
|
||||
target=tuple(float(v) for v in at),
|
||||
up=tuple(float(v) for v in up),
|
||||
fov=fov_y,
|
||||
)
|
||||
|
||||
# ── Orthographic mode: translate scale → camera distance ───────
|
||||
# In OCC orthographic projection, mouse-wheel zoom changes
|
||||
# ``view.Scale()`` rather than moving the eye. A smaller scale
|
||||
# means "zoomed in" (more world units per pixel), so the render
|
||||
# camera should move closer to match.
|
||||
view_scale = self._view.Scale()
|
||||
|
||||
# Compute scene bounding box diagonal from displayed objects.
|
||||
from OCP.Bnd import Bnd_Box
|
||||
from OCP.BRepBndLib import BRepBndLib
|
||||
bbox = Bnd_Box()
|
||||
try:
|
||||
for robj in self._objects.values():
|
||||
if robj.ais_shape is not None and robj.ais_type != "workplane":
|
||||
shape = getattr(robj.ais_shape, "Shape", lambda: None)()
|
||||
if shape is not None:
|
||||
BRepBndLib.Add_s(shape, bbox)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
xmin, ymin, zmin, xmax, ymax, zmax = bbox.Get()
|
||||
diag = float(
|
||||
np.sqrt(
|
||||
(xmax - xmin) ** 2
|
||||
+ (ymax - ymin) ** 2
|
||||
+ (zmax - zmin) ** 2
|
||||
)
|
||||
)
|
||||
|
||||
# Fallback: if bbox is empty (no objects or all shapes failed),
|
||||
# use the eye-to-at distance as a reasonable estimate.
|
||||
if diag < 1e-9:
|
||||
diag = float(np.linalg.norm(eye - at))
|
||||
|
||||
# Base distance: how far the camera must be for the bbox diagonal
|
||||
# to fill the frame at the given vertical FOV.
|
||||
import math
|
||||
base_distance = diag / (
|
||||
2.0 * math.tan(math.radians(fov_y / 2.0))
|
||||
)
|
||||
|
||||
# Scale factor maps inversely: larger scale (zoomed in) → closer camera.
|
||||
# Dividing by view_scale ensures that when the user zooms in (scale increases)
|
||||
# the render camera moves closer, matching the viewport.
|
||||
adjusted_distance = base_distance / view_scale
|
||||
|
||||
# Direction from target toward the original eye position.
|
||||
direction = eye - at
|
||||
norm = np.linalg.norm(direction)
|
||||
if norm < 1e-9:
|
||||
direction = np.array([0.0, 0.0, 1.0])
|
||||
else:
|
||||
direction /= norm
|
||||
|
||||
new_eye = at + direction * adjusted_distance
|
||||
|
||||
return RenderCamera(
|
||||
origin=tuple(float(v) for v in new_eye),
|
||||
target=tuple(float(v) for v in at),
|
||||
up=tuple(float(v) for v in up),
|
||||
fov=fov_y,
|
||||
)
|
||||
|
||||
def fit_camera(self, padding: float = 0.05) -> None:
|
||||
"""Fit camera to show all displayed objects.
|
||||
|
||||
@@ -1334,25 +1456,6 @@ class OCCRenderer(Renderer):
|
||||
|
||||
return []
|
||||
|
||||
def _project_to_screen(self, p3d: Tuple[float, float, float]) -> Optional[Tuple[int, int]]:
|
||||
"""Project a 3D world point to (x, y) screen pixel.
|
||||
|
||||
Uses OCC's ``V3d_View.Convert`` (world → view coords). Returns None
|
||||
if the projection fails (e.g. behind the camera).
|
||||
"""
|
||||
if self._view is None:
|
||||
return None
|
||||
try:
|
||||
# OCC's Convert returns the window pixel coordinates.
|
||||
xpix = self._view.Convert(float(p3d[0]), float(p3d[1]), float(p3d[2]))
|
||||
# Some OCP builds return a tuple (x, y); others return two values.
|
||||
if isinstance(xpix, (tuple, list)) and len(xpix) == 2:
|
||||
return (int(xpix[0]), int(xpix[1]))
|
||||
return None
|
||||
except Exception:
|
||||
# Fall back to ConvertWithProj or ProjTexte if Convert is unavailable.
|
||||
return None
|
||||
|
||||
def probe_snap_candidates(
|
||||
self, x: int, y: int, radius: int = 30,
|
||||
) -> List[Dict[str, Any]]:
|
||||
@@ -1429,8 +1532,8 @@ class OCCRenderer(Renderer):
|
||||
results.sort(key=lambda c: (c.get("screen", (x, y))[0] - x) ** 2 + (c.get("screen", (x, y))[1] - y) ** 2)
|
||||
return results
|
||||
|
||||
def highlight_snap(self, position, color=None, size=3.0) -> Optional[str]:
|
||||
"""Show a small marker sphere at *position* as a snap indicator.
|
||||
def highlight_snap(self, position, color=None, size=6.0) -> Optional[str]:
|
||||
"""Show a marker sphere at *position* as a snap indicator.
|
||||
|
||||
Returns an object id that can be removed later.
|
||||
The *size* is auto-scaled by camera distance so the marker stays
|
||||
@@ -1454,7 +1557,7 @@ class OCCRenderer(Renderer):
|
||||
self._view.Update()
|
||||
# Track this as a temporary object; use a synthetic id.
|
||||
oid = f"__snap_{id(ais)}"
|
||||
self._objects[oid] = _RenderObject(oid, ais, None, None)
|
||||
self._objects[oid] = OCCRenderObject(obj_id=oid, ais_shape=ais, ais_type="snap")
|
||||
return oid
|
||||
except Exception as exc:
|
||||
logger.debug(f"highlight_snap failed: {exc}")
|
||||
@@ -1596,13 +1699,13 @@ class OCCRenderer(Renderer):
|
||||
):
|
||||
continue
|
||||
cc = default_colors.get(cand.get("type", ""), (0.7, 0.7, 0.7))
|
||||
_make_sphere(cpos, cc, 1.4 * gizmo_scale) # dim, small
|
||||
_make_sphere(cpos, cc, 2.8 * gizmo_scale) # dim, small
|
||||
|
||||
# ── 1. Bright primary marker (sphere) ──
|
||||
_make_sphere(position, gizmo_color, 2.8 * gizmo_scale)
|
||||
_make_sphere(position, gizmo_color, 5.6 * gizmo_scale)
|
||||
|
||||
# ── 2. Axis indicator lines (primary only) ──
|
||||
axis_length = 15.0 * gizmo_scale
|
||||
axis_length = 30.0 * gizmo_scale
|
||||
|
||||
def _make_axis_line(
|
||||
origin: Tuple[float, float, float],
|
||||
@@ -1690,6 +1793,334 @@ class OCCRenderer(Renderer):
|
||||
if self._view is not None:
|
||||
self._view.Update()
|
||||
|
||||
# ─── Selection mode control ───────────────────────────────────────────
|
||||
#
|
||||
# When connector gizmo mode is active, standard OCC face/edge/vertex
|
||||
# selection is deactivated so dynamic highlighting does not interfere
|
||||
# with the gizmo visuals. The geometric probing method below replaces
|
||||
# the selection-system-based probe.
|
||||
|
||||
def deactivate_selection_modes(self) -> None:
|
||||
"""Deactivate OCC face/edge/vertex selection on every tracked AIS shape.
|
||||
|
||||
Used when entering connector gizmo mode so that standard dynamic
|
||||
highlighting (MoveTo) does not interfere with the gizmo visuals.
|
||||
Call :meth:`activate_selection_modes` to restore.
|
||||
"""
|
||||
if self._context is None:
|
||||
return
|
||||
from OCP.TopAbs import TopAbs_FACE, TopAbs_EDGE, TopAbs_VERTEX
|
||||
from OCP.AIS import AIS_Shape
|
||||
for robj in self._objects.values():
|
||||
if robj.ais_shape is not None:
|
||||
for topo in (TopAbs_VERTEX, TopAbs_EDGE, TopAbs_FACE):
|
||||
mode = AIS_Shape.SelectionMode_s(topo)
|
||||
try:
|
||||
self._context.Deactivate(robj.ais_shape, mode)
|
||||
except Exception:
|
||||
pass
|
||||
logger.debug("Selection modes deactivated for all AIS shapes")
|
||||
|
||||
def activate_selection_modes(self) -> None:
|
||||
"""Re-activate OCC face/edge/vertex selection on every tracked AIS shape.
|
||||
|
||||
Called when exiting connector gizmo mode to restore normal
|
||||
dynamic highlighting and face-pick behaviour.
|
||||
"""
|
||||
if self._context is None:
|
||||
return
|
||||
from OCP.TopAbs import TopAbs_FACE, TopAbs_EDGE, TopAbs_VERTEX
|
||||
from OCP.AIS import AIS_Shape
|
||||
for robj in self._objects.values():
|
||||
if robj.ais_shape is not None:
|
||||
for topo in (TopAbs_VERTEX, TopAbs_EDGE, TopAbs_FACE):
|
||||
mode = AIS_Shape.SelectionMode_s(topo)
|
||||
try:
|
||||
self._context.Activate(robj.ais_shape, mode)
|
||||
except Exception:
|
||||
pass
|
||||
logger.debug("Selection modes re-activated for all AIS shapes")
|
||||
|
||||
# ─── Geometric snap probing (selection-system-independent) ────────────
|
||||
#
|
||||
# Walks every AIS shape's topology directly, projects each feature to
|
||||
# screen, and returns candidates within *radius* pixels of the cursor.
|
||||
# This replaces the MoveTo-based probe when selection modes are
|
||||
# deactivated (connector gizmo mode).
|
||||
|
||||
def _project_to_screen(self, p3d: Tuple[float, float, float]) -> Optional[Tuple[int, int]]:
|
||||
"""Project a 3D world point to (x, y) screen pixel.
|
||||
|
||||
Uses OCC's ``V3d_View.Convert`` (world → view coords). Returns None
|
||||
if the projection fails (e.g. behind the camera).
|
||||
"""
|
||||
if self._view is None:
|
||||
return None
|
||||
try:
|
||||
# OCC's Convert returns the window pixel coordinates.
|
||||
xpix = self._view.Convert(float(p3d[0]), float(p3d[1]), float(p3d[2]))
|
||||
# Some OCP builds return a tuple (x, y); others return two values.
|
||||
if isinstance(xpix, (tuple, list)) and len(xpix) == 2:
|
||||
return (int(xpix[0]), int(xpix[1]))
|
||||
return None
|
||||
except Exception:
|
||||
# Fall back to ConvertWithProj or ProjTexte if Convert is unavailable.
|
||||
return None
|
||||
|
||||
def probe_snap_candidates_geometric(
|
||||
self, x: int, y: int, radius: int = 30,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Probe snap candidates by iterating geometry directly (no selection system).
|
||||
|
||||
Uses a two-pass approach for performance:
|
||||
1. **Bounding-box pre-filter**: projects each shape's 3D bbox to screen;
|
||||
skips shapes whose screen bbox is far from the cursor.
|
||||
2. **Feature iteration**: for nearby shapes only, walks faces/edges/vertices,
|
||||
projects each feature to screen, and collects candidates within
|
||||
*radius* pixels.
|
||||
|
||||
This replaces ``probe_snap_candidates`` when selection modes are
|
||||
deactivated (connector gizmo mode).
|
||||
"""
|
||||
if self._view is None or self._context is None:
|
||||
return []
|
||||
|
||||
from OCP.TopExp import TopExp_Explorer
|
||||
from OCP.TopAbs import TopAbs_FACE, TopAbs_EDGE, TopAbs_VERTEX
|
||||
from OCP.TopoDS import TopoDS
|
||||
from OCP.Bnd import Bnd_Box
|
||||
from OCP.BRepBndLib import BRepBndLib
|
||||
import numpy as np
|
||||
|
||||
candidates: Dict[Tuple[str, str, Tuple[int, int, int]], Dict[str, Any]] = {}
|
||||
# Expand the search radius for the bbox pre-filter so features near
|
||||
# the screen edge of a shape are not missed.
|
||||
margin = radius + 40
|
||||
|
||||
for robj in self._objects.values():
|
||||
if robj.ais_shape is None:
|
||||
continue
|
||||
try:
|
||||
shape = robj.ais_shape.Shape()
|
||||
except Exception:
|
||||
continue
|
||||
if shape is None:
|
||||
continue
|
||||
|
||||
# ── Pass 0: bounding-box pre-filter ──
|
||||
# Project the shape's 3D AABB to screen. If the cursor is
|
||||
# outside the screen bbox (with margin), skip this shape entirely.
|
||||
try:
|
||||
bbox = Bnd_Box()
|
||||
BRepBndLib.Add_s(shape, bbox)
|
||||
if bbox.IsVoid():
|
||||
continue
|
||||
bx0, by0, bz0, bx1, by1, bz1 = bbox.Get()
|
||||
# Project the 8 AABB corners to screen.
|
||||
corners = [
|
||||
(bx0, by0, bz0), (bx1, by0, bz0),
|
||||
(bx0, by1, bz0), (bx1, by1, bz0),
|
||||
(bx0, by0, bz1), (bx1, by0, bz1),
|
||||
(bx0, by1, bz1), (bx1, by1, bz1),
|
||||
]
|
||||
sx_min, sy_min = 99999, 99999
|
||||
sx_max, sy_max = -99999, -99999
|
||||
all_behind = True
|
||||
for c in corners:
|
||||
sp = self._project_to_screen(c)
|
||||
if sp is not None:
|
||||
all_behind = False
|
||||
sx_min = min(sx_min, sp[0])
|
||||
sy_min = min(sy_min, sp[1])
|
||||
sx_max = max(sx_max, sp[0])
|
||||
sy_max = max(sy_max, sp[1])
|
||||
if all_behind:
|
||||
continue
|
||||
# Check if cursor is within margin of the screen bbox.
|
||||
if (x < sx_min - margin or x > sx_max + margin or
|
||||
y < sy_min - margin or y > sy_max + margin):
|
||||
continue
|
||||
except Exception:
|
||||
pass # If bbox fails, fall through and try features.
|
||||
|
||||
# ── Pass 1: iterate only nearby shapes ──
|
||||
# --- Faces ---
|
||||
face_expl = TopExp_Explorer(shape, TopAbs_FACE)
|
||||
while face_expl.More():
|
||||
face = TopoDS.Face_s(face_expl.Current())
|
||||
infos = self._classify_detected_shape(face, robj.obj_id)
|
||||
for info in infos:
|
||||
pos = info.get("position") or (0.0, 0.0, 0.0)
|
||||
sp = self._project_to_screen(pos)
|
||||
if sp is None:
|
||||
continue
|
||||
dist2 = (sp[0] - x) ** 2 + (sp[1] - y) ** 2
|
||||
if dist2 <= radius * radius:
|
||||
key = (
|
||||
info.get("owner_obj_id", ""),
|
||||
info.get("type", ""),
|
||||
(round(pos[0], 1), round(pos[1], 1), round(pos[2], 1)),
|
||||
)
|
||||
if key not in candidates:
|
||||
info["screen"] = sp
|
||||
candidates[key] = info
|
||||
face_expl.Next()
|
||||
|
||||
# --- Edges ---
|
||||
edge_expl = TopExp_Explorer(shape, TopAbs_EDGE)
|
||||
while edge_expl.More():
|
||||
edge = TopoDS.Edge_s(edge_expl.Current())
|
||||
infos = self._classify_detected_shape(edge, robj.obj_id)
|
||||
for info in infos:
|
||||
pos = info.get("position") or (0.0, 0.0, 0.0)
|
||||
sp = self._project_to_screen(pos)
|
||||
if sp is None:
|
||||
continue
|
||||
dist2 = (sp[0] - x) ** 2 + (sp[1] - y) ** 2
|
||||
if dist2 <= radius * radius:
|
||||
key = (
|
||||
info.get("owner_obj_id", ""),
|
||||
info.get("type", ""),
|
||||
(round(pos[0], 1), round(pos[1], 1), round(pos[2], 1)),
|
||||
)
|
||||
if key not in candidates:
|
||||
info["screen"] = sp
|
||||
candidates[key] = info
|
||||
edge_expl.Next()
|
||||
|
||||
# --- Vertices ---
|
||||
vert_expl = TopExp_Explorer(shape, TopAbs_VERTEX)
|
||||
while vert_expl.More():
|
||||
vertex = TopoDS.Vertex_s(vert_expl.Current())
|
||||
infos = self._classify_detected_shape(vertex, robj.obj_id)
|
||||
for info in infos:
|
||||
pos = info.get("position") or (0.0, 0.0, 0.0)
|
||||
sp = self._project_to_screen(pos)
|
||||
if sp is None:
|
||||
continue
|
||||
dist2 = (sp[0] - x) ** 2 + (sp[1] - y) ** 2
|
||||
if dist2 <= radius * radius:
|
||||
key = (
|
||||
info.get("owner_obj_id", ""),
|
||||
info.get("type", ""),
|
||||
(round(pos[0], 1), round(pos[1], 1), round(pos[2], 1)),
|
||||
)
|
||||
if key not in candidates:
|
||||
info["screen"] = sp
|
||||
candidates[key] = info
|
||||
vert_expl.Next()
|
||||
|
||||
# Sort by screen-space distance to cursor, nearest first.
|
||||
results = list(candidates.values())
|
||||
results.sort(
|
||||
key=lambda c: (c.get("screen", (x, y))[0] - x) ** 2
|
||||
+ (c.get("screen", (x, y))[1] - y) ** 2
|
||||
)
|
||||
return results
|
||||
|
||||
def recognize_composite_features(
|
||||
self, candidates: List[Dict[str, Any]], x: int, y: int, radius: int = 30
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Enhance raw entity candidates with composite feature recognition.
|
||||
|
||||
Groups nearby entities and recognizes composite features like:
|
||||
* **hole** — cylindrical face (bolt/shaft insertion point)
|
||||
* **edge_loop** — circular edge loop (alignment target)
|
||||
* **meeting_edges** — vertex shared by two edges (corner constraint)
|
||||
* **mating_surface** — large planar face (assembly plane)
|
||||
|
||||
Each candidate gets additional fields:
|
||||
* ``feature_type`` — composite feature name (e.g. "hole", "edge_loop")
|
||||
* ``suggestion`` — human-readable snap suggestion
|
||||
* ``feature_data`` — dict with feature-specific info (radius, axis, etc.)
|
||||
"""
|
||||
import numpy as np
|
||||
from collections import defaultdict
|
||||
|
||||
# Group candidates by owner_obj_id.
|
||||
by_owner: Dict[str, List[Dict[str, Any]]] = defaultdict(list)
|
||||
for c in candidates:
|
||||
owner = c.get("owner_obj_id", "")
|
||||
if owner:
|
||||
by_owner[owner].append(c)
|
||||
|
||||
enhanced: List[Dict[str, Any]] = []
|
||||
|
||||
for c in candidates:
|
||||
ec = dict(c) # copy
|
||||
etype = c.get("type", "")
|
||||
pos = c.get("position", (0, 0, 0))
|
||||
owner = c.get("owner_obj_id", "")
|
||||
|
||||
# ── Cylindrical face → hole / bolt insertion ──
|
||||
if etype == "cylindrical_face":
|
||||
ec["feature_type"] = "hole"
|
||||
ec["suggestion"] = "Bolt / shaft insertion point"
|
||||
ec["feature_data"] = {
|
||||
"axis": c.get("normal"),
|
||||
"radius": c.get("radius"),
|
||||
"center": pos,
|
||||
}
|
||||
enhanced.append(ec)
|
||||
continue
|
||||
|
||||
# ── Planar face → mating surface ──
|
||||
if etype == "planar_face":
|
||||
ec["feature_type"] = "mating_surface"
|
||||
ec["suggestion"] = "Assembly mating plane"
|
||||
ec["feature_data"] = {
|
||||
"normal": c.get("normal"),
|
||||
"center": pos,
|
||||
}
|
||||
enhanced.append(ec)
|
||||
continue
|
||||
|
||||
# ── Edge → check for circular edge loop ──
|
||||
if etype == "edge":
|
||||
# Look for other edges nearby that might form a loop.
|
||||
nearby_edges = [
|
||||
n for n in candidates
|
||||
if n.get("type") == "edge"
|
||||
and n.get("owner_obj_id") == owner
|
||||
and n is not c
|
||||
]
|
||||
# For now, mark as edge — loop detection is complex.
|
||||
ec["feature_type"] = "edge"
|
||||
ec["suggestion"] = "Edge midpoint snap"
|
||||
ec["feature_data"] = {
|
||||
"tangent": c.get("normal"),
|
||||
"midpoint": pos,
|
||||
}
|
||||
enhanced.append(ec)
|
||||
continue
|
||||
|
||||
# ── Vertex → check for meeting edges ──
|
||||
if etype == "vertex":
|
||||
# Look for edges that share this vertex (nearby edges).
|
||||
nearby_edges = [
|
||||
n for n in candidates
|
||||
if n.get("type") == "edge"
|
||||
and n.get("owner_obj_id") == owner
|
||||
]
|
||||
if len(nearby_edges) >= 2:
|
||||
ec["feature_type"] = "meeting_edges"
|
||||
ec["suggestion"] = "Corner constraint (vertex)"
|
||||
ec["feature_data"] = {
|
||||
"vertex": pos,
|
||||
"edge_count": len(nearby_edges),
|
||||
}
|
||||
else:
|
||||
ec["feature_type"] = "vertex"
|
||||
ec["suggestion"] = "Vertex snap"
|
||||
ec["feature_data"] = {"vertex": pos}
|
||||
enhanced.append(ec)
|
||||
continue
|
||||
|
||||
# Fallback: pass through unchanged.
|
||||
enhanced.append(ec)
|
||||
|
||||
return enhanced
|
||||
|
||||
# ─── Mouse / keyboard event forwarding ──────────────────────────────
|
||||
#
|
||||
# CAD-style navigation:
|
||||
|
||||
@@ -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,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
|
||||
|
||||
+1181
-325
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+238
-71
@@ -24,6 +24,7 @@ from PySide6.QtWidgets import (
|
||||
)
|
||||
|
||||
from fluency.geometry_occ.sketch import OCCSketch, OCCSketchEntity
|
||||
from fluency.sketch_undo import SketchUndoManager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -178,6 +179,9 @@ class Sketch2DWidget(QWidget):
|
||||
self._offset_preview_points: List[Tuple[float, float]] = []
|
||||
self._offset_preview_active: bool = False
|
||||
|
||||
# Undo/redo manager (created when a sketch is set)
|
||||
self._undo_manager: Optional[SketchUndoManager] = None
|
||||
|
||||
self.setFocusPolicy(Qt.StrongFocus)
|
||||
self._setup_ui()
|
||||
|
||||
@@ -197,6 +201,11 @@ class Sketch2DWidget(QWidget):
|
||||
# them if the new sketch is on a face too.)
|
||||
if self._sketch is not None and self._sketch is not sketch:
|
||||
self._sketch.remove_external_entities()
|
||||
# Reset undo manager for the new sketch
|
||||
if self._sketch is not None:
|
||||
self._undo_manager = SketchUndoManager(self._sketch)
|
||||
else:
|
||||
self._undo_manager = None
|
||||
self.update()
|
||||
|
||||
def clear_source_face(self) -> None:
|
||||
@@ -412,6 +421,16 @@ class Sketch2DWidget(QWidget):
|
||||
return self._sketch.build_face_geometry(self._selected_face)
|
||||
return None
|
||||
|
||||
def get_selected_face_index(self) -> Optional[int]:
|
||||
"""Return the index of the selected face in detect_faces(), or None."""
|
||||
if self._selected_face is None or self._sketch is None:
|
||||
return None
|
||||
faces = self._sketch.detect_faces()
|
||||
for i, face in enumerate(faces):
|
||||
if self._faces_match(face, self._selected_face):
|
||||
return i
|
||||
return None
|
||||
|
||||
def clear_selected_face(self):
|
||||
self._selected_face = None
|
||||
self._hovered_face = None
|
||||
@@ -499,12 +518,18 @@ class Sketch2DWidget(QWidget):
|
||||
def get_sketch(self) -> Optional[OCCSketch]:
|
||||
return self._sketch
|
||||
|
||||
def get_undo_manager(self) -> Optional[SketchUndoManager]:
|
||||
"""Return the undo manager for this sketch widget."""
|
||||
return self._undo_manager
|
||||
|
||||
def create_sketch(self) -> OCCSketch:
|
||||
self._sketch = OCCSketch()
|
||||
self._sketch.add_centerlines()
|
||||
# Sync widget tracking lists from sketch so centerlines (and any
|
||||
# future auto-created entities) are immediately pickable.
|
||||
self._rebuild_from_sketch()
|
||||
# Initialize undo manager for the new sketch
|
||||
self._undo_manager = SketchUndoManager(self._sketch)
|
||||
self.update()
|
||||
return self._sketch
|
||||
|
||||
@@ -577,6 +602,8 @@ class Sketch2DWidget(QWidget):
|
||||
self._selected_entities = []
|
||||
self._hovered_constraint_idx = -1
|
||||
self._hovered_face = None
|
||||
self._hovered_circle = None
|
||||
self._hovered_circle_entity = None
|
||||
self._arc_accum_sweep = 0.0
|
||||
self._arc_prev_angle = None
|
||||
self.clear_offset_preview()
|
||||
@@ -1267,7 +1294,7 @@ class Sketch2DWidget(QWidget):
|
||||
return
|
||||
|
||||
if event.button() == Qt.LeftButton:
|
||||
# Priority order: ① tight point-grab → ② face selection → ③ element move
|
||||
# Priority order: ① tight point-grab → ② element move → ③ face selection
|
||||
if self._mode in ("select", None) and self._sketch:
|
||||
# ① Tight point check (4 px) — a deliberate grab on a point.
|
||||
tight_ent = self._find_nearest_point_entity(event.pos(), max_distance=4)
|
||||
@@ -1284,30 +1311,18 @@ class Sketch2DWidget(QWidget):
|
||||
self._move_anchor = tight_ent
|
||||
self._move_anchor_orig = QPoint(int(round(x)), int(round(y)))
|
||||
self._move_orig_positions = orig
|
||||
# Save state before starting move
|
||||
if self._undo_manager:
|
||||
self._undo_manager.save_state()
|
||||
self._move_active = True
|
||||
self.setCursor(Qt.ClosedHandCursor)
|
||||
return
|
||||
|
||||
# ② Face region — click inside a closed face to select it.
|
||||
# Use float-precision world coords so small shapes (sub-integer
|
||||
# at the current zoom) are still pickable.
|
||||
fwx, fwy = self._screen_to_world_f(event.pos())
|
||||
face = self._sketch.find_face_at(fwx, fwy)
|
||||
if face is not None:
|
||||
if self._faces_match(face, self._selected_face):
|
||||
# Clicking the same face again toggles it off.
|
||||
self._selected_face = None
|
||||
else:
|
||||
self._selected_face = face
|
||||
self._hovered_face = None
|
||||
self.update()
|
||||
return
|
||||
|
||||
# ③ Wider element-move check (lines and circles). External
|
||||
# (underlay) entities are fixed references and can't be
|
||||
# dragged — fall through to the constraint / draw handlers
|
||||
# so a click on an underlay edge is treated as a constraint
|
||||
# pick (the desired behavior) rather than a no-op.
|
||||
# ② Wider element-move check (lines, circles, arcs).
|
||||
# Lines/circles/arcs have small hit targets — check them
|
||||
# BEFORE face so they aren't blocked by the large interior
|
||||
# selection zone. External (underlay) entities are fixed
|
||||
# references and can't be dragged.
|
||||
target = self._find_move_target(event.pos())
|
||||
if (target is not None
|
||||
and not self._is_external(target[0])
|
||||
@@ -1322,10 +1337,30 @@ class Sketch2DWidget(QWidget):
|
||||
self._move_anchor = anchor_ent
|
||||
self._move_anchor_orig = anchor_world
|
||||
self._move_orig_positions = orig
|
||||
# Save state before starting move
|
||||
if self._undo_manager:
|
||||
self._undo_manager.save_state()
|
||||
self._move_active = True
|
||||
self.setCursor(Qt.ClosedHandCursor)
|
||||
return
|
||||
|
||||
# ③ Face region — click inside a closed face to select it.
|
||||
# Checked LAST because faces have huge selection zones that
|
||||
# would otherwise block picking lines/points/arcs/circles.
|
||||
# Use float-precision world coords so small shapes (sub-integer
|
||||
# at the current zoom) are still pickable.
|
||||
fwx, fwy = self._screen_to_world_f(event.pos())
|
||||
face = self._sketch.find_face_at(fwx, fwy)
|
||||
if face is not None:
|
||||
if self._faces_match(face, self._selected_face):
|
||||
# Clicking the same face again toggles it off.
|
||||
self._selected_face = None
|
||||
else:
|
||||
self._selected_face = face
|
||||
self._hovered_face = None
|
||||
self.update()
|
||||
return
|
||||
|
||||
snapped_pos = self._apply_all_snaps(
|
||||
event.pos(), self._world_to_screen(self._draw_buffer[0]) if self._draw_buffer else None
|
||||
)
|
||||
@@ -1363,6 +1398,8 @@ class Sketch2DWidget(QWidget):
|
||||
self._handle_constraint_ptline(world_snapped)
|
||||
elif self._mode == "constrain_symmetric":
|
||||
self._handle_constraint_symmetric(world_snapped)
|
||||
elif self._mode == "constrain_diameter":
|
||||
self._handle_constraint_diameter(world_snapped)
|
||||
|
||||
def mouseMoveEvent(self, event):
|
||||
if self._panning and self._pan_start:
|
||||
@@ -1455,10 +1492,10 @@ class Sketch2DWidget(QWidget):
|
||||
if self._hovered_constraint_idx != -1:
|
||||
self._hovered_constraint_idx = -1
|
||||
|
||||
# Priority for select/move mode: point > face > line > circle.
|
||||
# Face is checked before line so the user can see and select the
|
||||
# wall region between two concentric boundaries (e.g. after offset).
|
||||
# In drawing modes, lines take priority so the user can snap to them.
|
||||
# Priority for select/move mode: point > line > circle > face.
|
||||
# Lines/circles have small hit targets — check them before face so
|
||||
# the large interior face zone doesn't block picking. In drawing
|
||||
# modes, lines take priority so the user can snap to them.
|
||||
hover_cursor = Qt.OpenHandCursor if self._mode in ("select", None) else Qt.CrossCursor
|
||||
|
||||
# ── ① Point hover (tightest, always first) ──
|
||||
@@ -1474,55 +1511,64 @@ class Sketch2DWidget(QWidget):
|
||||
self._hovered_point = None
|
||||
self._hovered_point_entity = None
|
||||
|
||||
# ── ② Face hover (checked before line in select mode) ──
|
||||
# In select/move mode the face is the primary interaction target
|
||||
# (select to extrude etc.). Lines remain selectable via click
|
||||
# (element move) but the visual highlight shows the face region.
|
||||
face_found = False
|
||||
if self._mode in ("select", None) and self._sketch is not None:
|
||||
fwx, fwy = self._screen_to_world_f(event.pos())
|
||||
face = self._sketch.find_face_at(fwx, fwy)
|
||||
if face is not None:
|
||||
self._hovered_face = face
|
||||
self._hovered_line = None
|
||||
self._hovered_line_entity = None
|
||||
self.setCursor(Qt.PointingHandCursor)
|
||||
face_found = True
|
||||
|
||||
if not face_found:
|
||||
self._hovered_face = None
|
||||
|
||||
# ── ③ Line hover (when no face under cursor) ──
|
||||
line_hit = self._get_line_entity_at(world_pos)
|
||||
if line_hit:
|
||||
p1_ent, p2_ent = line_hit
|
||||
if p1_ent.geometry and p2_ent.geometry:
|
||||
self._hovered_line = (
|
||||
QPoint(int(round(p1_ent.geometry[0])), int(round(p1_ent.geometry[1]))),
|
||||
QPoint(int(round(p2_ent.geometry[0])), int(round(p2_ent.geometry[1]))),
|
||||
)
|
||||
self._hovered_line_entity = self._find_line_sketch_entity(p1_ent, p2_ent)
|
||||
self.setCursor(hover_cursor)
|
||||
else:
|
||||
self._hovered_line = None
|
||||
self._hovered_line_entity = None
|
||||
self.setCursor(Qt.ArrowCursor)
|
||||
# ── ② Line hover ──
|
||||
# Lines have small hit targets — check them BEFORE face so
|
||||
# the large interior face zone doesn't block line picking.
|
||||
line_hit = self._get_line_entity_at(world_pos)
|
||||
if line_hit:
|
||||
p1_ent, p2_ent = line_hit
|
||||
if p1_ent.geometry and p2_ent.geometry:
|
||||
self._hovered_line = (
|
||||
QPoint(int(round(p1_ent.geometry[0])), int(round(p1_ent.geometry[1]))),
|
||||
QPoint(int(round(p2_ent.geometry[0])), int(round(p2_ent.geometry[1]))),
|
||||
)
|
||||
self._hovered_line_entity = self._find_line_sketch_entity(p1_ent, p2_ent)
|
||||
self._hovered_face = None
|
||||
self.setCursor(hover_cursor)
|
||||
else:
|
||||
self._hovered_line = None
|
||||
self._hovered_line_entity = None
|
||||
# ── ④ Circle hover ──
|
||||
if self._mode in ("select", None):
|
||||
over_circle = False
|
||||
for c_ent, r in self._circles:
|
||||
if c_ent.geometry and r > 0:
|
||||
cx, cy = c_ent.geometry
|
||||
d = math.sqrt((world_pos.x() - cx) ** 2 + (world_pos.y() - cy) ** 2)
|
||||
if abs(d - r) < 8:
|
||||
over_circle = True
|
||||
break
|
||||
self.setCursor(Qt.OpenHandCursor if over_circle else Qt.ArrowCursor)
|
||||
self._hovered_face = None
|
||||
self.setCursor(Qt.ArrowCursor)
|
||||
else:
|
||||
self._hovered_line = None
|
||||
self._hovered_line_entity = None
|
||||
|
||||
# ── ③ Circle / arc hover ──
|
||||
if self._mode in ("select", None):
|
||||
over_circle = False
|
||||
for c_ent, r in self._circles:
|
||||
if c_ent.geometry and r > 0:
|
||||
cx, cy = c_ent.geometry
|
||||
d = math.sqrt((world_pos.x() - cx) ** 2 + (world_pos.y() - cy) ** 2)
|
||||
if abs(d - r) < 8:
|
||||
over_circle = True
|
||||
break
|
||||
self._hovered_face = None
|
||||
if over_circle:
|
||||
self._hovered_circle = (QPoint(int(round(cx)), int(round(cy))), r)
|
||||
self._hovered_circle_entity = c_ent
|
||||
self.setCursor(Qt.OpenHandCursor)
|
||||
else:
|
||||
self._hovered_circle = None
|
||||
self._hovered_circle_entity = None
|
||||
self.setCursor(Qt.ArrowCursor)
|
||||
else:
|
||||
self._hovered_circle = None
|
||||
self._hovered_circle_entity = None
|
||||
self._hovered_face = None
|
||||
self.setCursor(Qt.ArrowCursor)
|
||||
|
||||
# ── ④ Face hover (checked LAST — huge interior zones) ──
|
||||
# Face is the lowest priority so it doesn't block picking
|
||||
# lines, circles, arcs, or points. Only highlighted when
|
||||
# nothing else is under the cursor.
|
||||
if self._mode in ("select", None) and self._sketch is not None:
|
||||
fwx, fwy = self._screen_to_world_f(event.pos())
|
||||
face = self._sketch.find_face_at(fwx, fwy)
|
||||
if face is not None:
|
||||
self._hovered_face = face
|
||||
self.setCursor(Qt.PointingHandCursor)
|
||||
|
||||
self.update()
|
||||
|
||||
@@ -1572,6 +1618,31 @@ class Sketch2DWidget(QWidget):
|
||||
self.update()
|
||||
|
||||
def keyPressEvent(self, event):
|
||||
# Undo: Ctrl+Z
|
||||
if event.key() == Qt.Key_Z and event.modifiers() & Qt.ControlModifier:
|
||||
if not (event.modifiers() & Qt.ShiftModifier):
|
||||
# Ctrl+Z: Undo
|
||||
if self._undo_manager and self._undo_manager.can_undo:
|
||||
self._undo_manager.undo()
|
||||
self._rebuild_from_sketch()
|
||||
self._solve_and_sync()
|
||||
self.sketch_updated.emit()
|
||||
self.update()
|
||||
event.accept()
|
||||
return
|
||||
|
||||
# Redo: Ctrl+Shift+Z or Ctrl+Y
|
||||
if (event.key() == Qt.Key_Z and event.modifiers() & (Qt.ControlModifier | Qt.ShiftModifier)) or \
|
||||
(event.key() == Qt.Key_Y and event.modifiers() & Qt.ControlModifier):
|
||||
if self._undo_manager and self._undo_manager.can_redo:
|
||||
self._undo_manager.redo()
|
||||
self._rebuild_from_sketch()
|
||||
self._solve_and_sync()
|
||||
self.sketch_updated.emit()
|
||||
self.update()
|
||||
event.accept()
|
||||
return
|
||||
|
||||
# Delete / Backspace removes the entity currently under the cursor
|
||||
# and recomputes the surviving constraints. Priority: constraint tag >
|
||||
# line > point. Works in Move mode or when no tool is selected; ignored
|
||||
@@ -1606,6 +1677,9 @@ class Sketch2DWidget(QWidget):
|
||||
idx = self._hovered_constraint_idx
|
||||
if idx < 0 or self._sketch is None:
|
||||
return
|
||||
# Save state before deleting
|
||||
if self._undo_manager:
|
||||
self._undo_manager.save_state()
|
||||
ok = self._sketch.remove_constraint_at(idx)
|
||||
logger.info(f"Deleted constraint #{idx}; recompute solved={ok}")
|
||||
self._hovered_constraint_idx = -1
|
||||
@@ -1630,6 +1704,9 @@ class Sketch2DWidget(QWidget):
|
||||
if self._is_centerline(line_ent):
|
||||
logger.debug("Refusing to delete centerline")
|
||||
return
|
||||
# Save state before deleting
|
||||
if self._undo_manager:
|
||||
self._undo_manager.save_state()
|
||||
ok = self._sketch.delete_line(line_ent)
|
||||
logger.info(f"Deleted line {line_ent.id}; recompute solved={ok}")
|
||||
# Refresh widget tracking from the pruned sketch and sync solved positions.
|
||||
@@ -1660,6 +1737,9 @@ class Sketch2DWidget(QWidget):
|
||||
if self._is_centerline(point_ent):
|
||||
logger.debug("Refusing to delete centerline point")
|
||||
return
|
||||
# Save state before deleting
|
||||
if self._undo_manager:
|
||||
self._undo_manager.save_state()
|
||||
ok = self._sketch.delete_point(point_ent)
|
||||
logger.info(f"Deleted point {point_ent.id}; recompute solved={ok}")
|
||||
self._rebuild_from_sketch()
|
||||
@@ -1693,6 +1773,9 @@ class Sketch2DWidget(QWidget):
|
||||
if self._is_centerline(line_ent):
|
||||
logger.debug("Refusing to toggle centerline")
|
||||
return
|
||||
# Save state before toggling
|
||||
if self._undo_manager:
|
||||
self._undo_manager.save_state()
|
||||
# Flip the construction flag on the line entity itself.
|
||||
new_val = not line_ent.is_construction
|
||||
line_ent.is_construction = new_val
|
||||
@@ -1711,6 +1794,9 @@ class Sketch2DWidget(QWidget):
|
||||
self._ensure_sketch_with_centerlines()
|
||||
|
||||
if not self._draw_buffer:
|
||||
# Save state before starting a new line
|
||||
if self._undo_manager:
|
||||
self._undo_manager.save_state()
|
||||
point = self._sketch.add_point(pos.x(), pos.y())
|
||||
point.is_construction = self._is_construct
|
||||
self._points.append(point)
|
||||
@@ -1756,6 +1842,9 @@ class Sketch2DWidget(QWidget):
|
||||
self._ensure_sketch_with_centerlines()
|
||||
|
||||
if not self._draw_buffer:
|
||||
# Save state before starting a new rectangle
|
||||
if self._undo_manager:
|
||||
self._undo_manager.save_state()
|
||||
self._draw_buffer.append(pos)
|
||||
self._rect_first_snap_target = self._snap_point_target
|
||||
else:
|
||||
@@ -1818,6 +1907,9 @@ class Sketch2DWidget(QWidget):
|
||||
self._ensure_sketch_with_centerlines()
|
||||
|
||||
if not self._draw_buffer:
|
||||
# Save state before starting a new circle
|
||||
if self._undo_manager:
|
||||
self._undo_manager.save_state()
|
||||
center = self._sketch.add_point(pos.x(), pos.y())
|
||||
center.is_construction = self._is_construct
|
||||
self._points.append(center)
|
||||
@@ -1846,6 +1938,9 @@ class Sketch2DWidget(QWidget):
|
||||
self._ensure_sketch_with_centerlines()
|
||||
|
||||
if not self._draw_buffer:
|
||||
# Save state before starting a new arc
|
||||
if self._undo_manager:
|
||||
self._undo_manager.save_state()
|
||||
# Click 1: place center point
|
||||
center = self._sketch.add_point(pos.x(), pos.y())
|
||||
center.is_construction = self._is_construct
|
||||
@@ -1938,6 +2033,9 @@ class Sketch2DWidget(QWidget):
|
||||
self._ensure_sketch_with_centerlines()
|
||||
|
||||
if not self._draw_buffer:
|
||||
# Save state before starting a new slot
|
||||
if self._undo_manager:
|
||||
self._undo_manager.save_state()
|
||||
# Click 1: place first arc center C1.
|
||||
c1 = self._sketch.add_point(pos.x(), pos.y())
|
||||
c1.is_construction = self._is_construct
|
||||
@@ -2146,6 +2244,9 @@ class Sketch2DWidget(QWidget):
|
||||
self._selected_entities.append(ent)
|
||||
if len(self._selected_entities) >= 2:
|
||||
e1, e2 = self._selected_entities[:2]
|
||||
# Save state before adding constraint
|
||||
if self._undo_manager:
|
||||
self._undo_manager.save_state()
|
||||
if self._sketch:
|
||||
self._sketch.constrain_coincident(e1, e2)
|
||||
ok = self._solve_and_sync()
|
||||
@@ -2166,6 +2267,9 @@ class Sketch2DWidget(QWidget):
|
||||
line_ent = self._find_line_sketch_entity(p1_ent, p2_ent)
|
||||
if line_ent is None:
|
||||
return
|
||||
# Save state before adding constraint
|
||||
if self._undo_manager:
|
||||
self._undo_manager.save_state()
|
||||
if self._sketch:
|
||||
self._sketch.constrain_horizontal(line_ent)
|
||||
# Tag endpoints so paintEvent renders the "> hrz <" label.
|
||||
@@ -2189,6 +2293,9 @@ class Sketch2DWidget(QWidget):
|
||||
line_ent = self._find_line_sketch_entity(p1_ent, p2_ent)
|
||||
if line_ent is None:
|
||||
return
|
||||
# Save state before adding constraint
|
||||
if self._undo_manager:
|
||||
self._undo_manager.save_state()
|
||||
if self._sketch:
|
||||
self._sketch.constrain_vertical(line_ent)
|
||||
for ent in (p1_ent, p2_ent):
|
||||
@@ -2218,6 +2325,9 @@ class Sketch2DWidget(QWidget):
|
||||
dist, ok = QInputDialog.getDouble(self, "Distance", "Distance (mm):",
|
||||
self._constraint_distance_value, 0, 10000, 2)
|
||||
if ok and self._sketch:
|
||||
# Save state before adding constraint
|
||||
if self._undo_manager:
|
||||
self._undo_manager.save_state()
|
||||
self._sketch.constrain_distance(p1_ent, p2_ent, dist)
|
||||
self._solve_and_sync()
|
||||
logger.info(f"Line distance {dist:.2f}mm")
|
||||
@@ -2232,6 +2342,9 @@ class Sketch2DWidget(QWidget):
|
||||
dist, ok = QInputDialog.getDouble(self, "Distance", "Distance (mm):",
|
||||
self._constraint_distance_value, 0, 10000, 2)
|
||||
if ok and self._sketch:
|
||||
# Save state before adding constraint
|
||||
if self._undo_manager:
|
||||
self._undo_manager.save_state()
|
||||
self._sketch.constrain_distance(e1, e2, dist)
|
||||
self._solve_and_sync()
|
||||
logger.info(f"Distance {dist:.2f}mm")
|
||||
@@ -2250,6 +2363,9 @@ class Sketch2DWidget(QWidget):
|
||||
p1_ent, p2_ent = line_hit
|
||||
line_ent = self._find_line_sketch_entity(p1_ent, p2_ent)
|
||||
if line_ent is not None and self._sketch and self._selected_entities:
|
||||
# Save state before adding constraint
|
||||
if self._undo_manager:
|
||||
self._undo_manager.save_state()
|
||||
self._sketch.constrain_midpoint(self._selected_entities[0], line_ent)
|
||||
for ent in (p1_ent, p2_ent):
|
||||
if "mid" not in ent.constraints:
|
||||
@@ -2276,6 +2392,9 @@ class Sketch2DWidget(QWidget):
|
||||
else:
|
||||
# Second click: apply perpendicular constraint between two LINE entities
|
||||
prev_ent = self._selected_entities[0]
|
||||
# Save state before adding constraint
|
||||
if self._undo_manager:
|
||||
self._undo_manager.save_state()
|
||||
if self._sketch:
|
||||
self._sketch.constrain_perpendicular(prev_ent, target_ent)
|
||||
self._solve_and_sync()
|
||||
@@ -2298,6 +2417,9 @@ class Sketch2DWidget(QWidget):
|
||||
self._selected_entities.append(target_ent)
|
||||
else:
|
||||
prev_ent = self._selected_entities[0]
|
||||
# Save state before adding constraint
|
||||
if self._undo_manager:
|
||||
self._undo_manager.save_state()
|
||||
if self._sketch:
|
||||
self._sketch.constrain_parallel(prev_ent, target_ent)
|
||||
self._solve_and_sync()
|
||||
@@ -2318,6 +2440,9 @@ class Sketch2DWidget(QWidget):
|
||||
p1_ent, p2_ent = line_hit
|
||||
line_ent = self._find_line_sketch_entity(p1_ent, p2_ent)
|
||||
if line_ent is not None and self._sketch and self._selected_entities:
|
||||
# Save state before adding constraint
|
||||
if self._undo_manager:
|
||||
self._undo_manager.save_state()
|
||||
# coincident(point, line) = point-on-line; needs the line handle
|
||||
self._sketch.constrain_coincident(self._selected_entities[0], line_ent)
|
||||
self._solve_and_sync()
|
||||
@@ -2336,6 +2461,9 @@ class Sketch2DWidget(QWidget):
|
||||
p1_ent, p2_ent = line_hit
|
||||
mirror_line = self._find_line_sketch_entity(p1_ent, p2_ent)
|
||||
if mirror_line is not None and self._sketch:
|
||||
# Save state before adding constraint
|
||||
if self._undo_manager:
|
||||
self._undo_manager.save_state()
|
||||
self._sketch.constrain_symmetric(
|
||||
self._selected_entities[0], self._selected_entities[1], mirror_line
|
||||
)
|
||||
@@ -2360,6 +2488,46 @@ class Sketch2DWidget(QWidget):
|
||||
logger.info("Click on the mirror line")
|
||||
self.update()
|
||||
|
||||
def _handle_constraint_diameter(self, world_pos: QPoint):
|
||||
"""Diameter constraint: click on a circle to set its diameter."""
|
||||
# Check if we clicked on a circle
|
||||
for c_ent, r in self._circles:
|
||||
if c_ent.geometry and r > 0:
|
||||
cx, cy = c_ent.geometry
|
||||
d = math.sqrt((world_pos.x() - cx) ** 2 + (world_pos.y() - cy) ** 2)
|
||||
if abs(d - r) < 8: # Clicked on circle circumference
|
||||
# Prompt for diameter value
|
||||
current_diameter = r * 2.0
|
||||
diameter, ok = QInputDialog.getDouble(
|
||||
self, "Diameter", "Diameter (mm):",
|
||||
current_diameter, 0, 10000, 2
|
||||
)
|
||||
if ok and self._sketch:
|
||||
# Save state before adding constraint
|
||||
if self._undo_manager:
|
||||
self._undo_manager.save_state()
|
||||
new_radius = diameter / 2.0
|
||||
# Update the circle's radius in the sketch
|
||||
self._sketch._circles[c_ent.id] = (c_ent.id, new_radius)
|
||||
# Update the local cache
|
||||
for i, (ent, rad) in enumerate(self._circles):
|
||||
if ent.id == c_ent.id:
|
||||
self._circles[i] = (ent, new_radius)
|
||||
break
|
||||
# Record constraint for undo/redo
|
||||
self._sketch._record_constraint(
|
||||
"diameter", (c_ent.id,), (diameter,)
|
||||
)
|
||||
self._solve_and_sync()
|
||||
logger.info(f"Diameter constraint: {diameter:.2f}mm")
|
||||
self._selected_entities = []
|
||||
self._mode = None
|
||||
self.constrain_done.emit()
|
||||
self.update()
|
||||
return
|
||||
# Clicked somewhere else - do nothing
|
||||
self.update()
|
||||
|
||||
# ─── Painting ─────────────────────────────────────────────────────────
|
||||
|
||||
def _calculate_midpoint(self, p1: QPoint, p2: QPoint) -> QPointF:
|
||||
@@ -2780,14 +2948,13 @@ class Sketch2DWidget(QWidget):
|
||||
painter.setBrush(Qt.NoBrush)
|
||||
painter.drawEllipse(screen_pos, 10, 10)
|
||||
|
||||
# ── Hovered line distance measurement ──
|
||||
# ── Hovered line highlight ──
|
||||
if self._hovered_line and not self._hovered_point:
|
||||
p1, p2 = self._hovered_line
|
||||
sp1 = self._world_to_screen(p1)
|
||||
sp2 = self._world_to_screen(p2)
|
||||
painter.setPen(QPen(QColor("#a6e3a1"), 2))
|
||||
painter.drawLine(sp1, sp2)
|
||||
self._draw_distance_measurement(painter, p1, p2)
|
||||
|
||||
# ── Moved-element highlight ──
|
||||
if self._move_active and self._moving_points:
|
||||
|
||||
+155
-48
@@ -3,15 +3,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import sys
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from PySide6.QtCore import Qt, Signal, Slot, QPoint, QPointF, QSize, QRect
|
||||
from PySide6.QtGui import QCursor, QFont, QPainter, QPen, QColor, QBrush, QPolygonF
|
||||
from PySide6.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."""
|
||||
|
||||
@@ -38,6 +37,10 @@ class Viewer3DWidget(QWidget):
|
||||
# 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.
|
||||
@@ -66,6 +69,9 @@ class Viewer3DWidget(QWidget):
|
||||
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.
|
||||
@@ -87,10 +93,12 @@ class Viewer3DWidget(QWidget):
|
||||
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...")
|
||||
@@ -139,16 +147,18 @@ class Viewer3DWidget(QWidget):
|
||||
"""
|
||||
self._ensure_initialized()
|
||||
from fluency.rendering.occ_renderer import OCCRenderer
|
||||
|
||||
if isinstance(self._renderer, OCCRenderer):
|
||||
oid = self._renderer.add_shape(shape, color, name)
|
||||
self._renderer.render()
|
||||
return oid
|
||||
# Fallback: tessellate and use the mesh pipeline.
|
||||
from fluency.geometry_occ.kernel import OCGeometryKernel
|
||||
|
||||
k = OCGeometryKernel()
|
||||
from fluency.geometry_occ.sketch import OCCSketch
|
||||
# Build a temporary OCCGeometryObject to use the kernel's mesh helpers.
|
||||
from fluency.geometry_occ.kernel import OCCGeometryObject
|
||||
|
||||
obj = OCCGeometryObject(shape)
|
||||
verts, faces = k.get_mesh(obj)
|
||||
oid = self._renderer.add_mesh(verts, faces, color, name)
|
||||
@@ -156,7 +166,9 @@ class Viewer3DWidget(QWidget):
|
||||
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")
|
||||
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()
|
||||
@@ -186,7 +198,9 @@ class Viewer3DWidget(QWidget):
|
||||
|
||||
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)
|
||||
wid = self._renderer.add_wireframe(
|
||||
vertices, edges, color or (0.9, 0.9, 0.9), line_width, name
|
||||
)
|
||||
self._renderer.render()
|
||||
return wid
|
||||
|
||||
@@ -315,10 +329,17 @@ class Viewer3DWidget(QWidget):
|
||||
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"):
|
||||
@@ -349,11 +370,31 @@ class Viewer3DWidget(QWidget):
|
||||
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)
|
||||
@@ -379,12 +420,23 @@ class Viewer3DWidget(QWidget):
|
||||
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:
|
||||
@@ -420,20 +472,38 @@ class Viewer3DWidget(QWidget):
|
||||
|
||||
# ─── Connector pick mode (assembly) ────────────────────────────────────
|
||||
|
||||
def set_connector_pick_mode(self, enabled: bool) -> None:
|
||||
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)
|
||||
elif not self._pick_face_mode:
|
||||
self.unsetCursor()
|
||||
if not enabled:
|
||||
self._clear_connector_snap()
|
||||
# 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
|
||||
@@ -453,14 +523,21 @@ class Viewer3DWidget(QWidget):
|
||||
def _handle_connector_hover(self, event) -> None:
|
||||
"""Update the hover snap gizmo during connector pick mode.
|
||||
|
||||
Probes a small neighbourhood around the cursor for ALL nearby snap
|
||||
candidates (vertices, edge midpoints, face centres, hole openings)
|
||||
and renders a dim marker on each plus a bright primary on the nearest
|
||||
one — the general snap indicator. Clicking then selects the
|
||||
primary's position.
|
||||
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", None)
|
||||
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:
|
||||
@@ -472,22 +549,42 @@ class Viewer3DWidget(QWidget):
|
||||
# Primary = the nearest candidate (probe sorts nearest-first).
|
||||
info = candidates[0]
|
||||
else:
|
||||
# Fall back to single-pixel pick on renderers without the probe.
|
||||
picker = getattr(self._renderer, "pick_entity", None)
|
||||
if picker is None:
|
||||
return
|
||||
info = picker(pos.x(), pos.y())
|
||||
candidates = [info] if info else []
|
||||
if info is None or info.get("owner_obj_id") is None:
|
||||
self._clear_connector_snap()
|
||||
self.connectorHover.emit(None)
|
||||
return
|
||||
# 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)
|
||||
@@ -505,39 +602,56 @@ class Viewer3DWidget(QWidget):
|
||||
fn = getattr(self._renderer, "highlight_snap", None)
|
||||
if fn is not None:
|
||||
colors = {
|
||||
"planar_face": (0.0, 0.8, 1.0), # cyan
|
||||
"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
|
||||
"edge": (0.0, 1.0, 0.4), # green
|
||||
"vertex": (1.0, 1.0, 0.0), # yellow
|
||||
}
|
||||
c = colors.get(entity_type, (1.0, 0.6, 0.0))
|
||||
self._connector_snap_id = fn(origin, color=c, size=3.0)
|
||||
|
||||
self.connectorHover.emit({
|
||||
# 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 the multi-pixel ``probe_snap_candidates`` so a click selects the
|
||||
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 single-pixel ``pick_entity`` then to
|
||||
``pick_planar_face`` on renderers without the probe.
|
||||
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", 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:
|
||||
@@ -656,7 +770,7 @@ class Viewer3DWidget(QWidget):
|
||||
|
||||
# Get camera vectors for proper view-plane projection.
|
||||
import numpy as np
|
||||
from OCP.V3d import V3d_TypeOfOrientation
|
||||
|
||||
try:
|
||||
# Get camera direction and up from the OCC view.
|
||||
camera = self._renderer._view.Camera()
|
||||
@@ -681,16 +795,11 @@ class Viewer3DWidget(QWidget):
|
||||
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)
|
||||
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
|
||||
)
|
||||
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."""
|
||||
@@ -807,5 +916,3 @@ class Viewer3DWidget(QWidget):
|
||||
self._renderer.render()
|
||||
return
|
||||
super().keyPressEvent(event)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user