9 Commits

Author SHA1 Message Date
bklronin 742d06d242 - Render improvements, camera plane, update 2026-07-13 23:01:35 +02:00
bklronin c78d0af78c - added renderer
- Added undo
2026-07-13 06:54:21 +02:00
bklronin dda9db822b - added renderer
- Added undo
2026-07-12 23:25:59 +02:00
bklronin 9f1387fe68 - added renderer
- Added undo
2026-07-12 22:21:43 +02:00
bklronin 210e3cfb5d - added renderer 2026-07-12 22:21:20 +02:00
bklronin b8516fff91 - Working assembly multi :) 2026-07-11 21:42:08 +02:00
bklronin d7e5929a13 - Working assembly multi :) 2026-07-11 21:29:58 +02:00
bklronin 2b2afbc479 - Added save file foramt
- Split main.py refactor
2026-07-11 15:39:30 +02:00
bklronin b0aebdc04f - Added save file foramt
- Split main.py refactor
2026-07-11 09:34:38 +02:00
21 changed files with 8066 additions and 2035 deletions
+96 -7
View File
@@ -4,10 +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&#10;- Split main.py refactor">
<list default="true" id="8f0bafd6-58a0-4b20-aa2b-ddc3ba278873" name="Changes" comment="- added renderer&#10;- Added undo">
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/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/sketch_widget.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/ui/sketch_widget.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" />
@@ -56,10 +60,13 @@
&quot;Python.fluency.executor&quot;: &quot;Run&quot;,
&quot;Python.fluencyb.executor&quot;: &quot;Run&quot;,
&quot;Python.gl_widget.executor&quot;: &quot;Run&quot;,
&quot;Python.gui_ui.executor&quot;: &quot;Run&quot;,
&quot;Python.kernel.executor&quot;: &quot;Run&quot;,
&quot;Python.main.executor&quot;: &quot;Run&quot;,
&quot;Python.meshtest.executor&quot;: &quot;Run&quot;,
&quot;Python.occ_renderer.executor&quot;: &quot;Run&quot;,
&quot;Python.occ_to_mesh.executor&quot;: &quot;Run&quot;,
&quot;Python.render_backend.executor&quot;: &quot;Run&quot;,
&quot;Python.side_fluency.executor&quot;: &quot;Run&quot;,
&quot;Python.simple_mesh.executor&quot;: &quot;Run&quot;,
&quot;Python.sketch.executor&quot;: &quot;Run&quot;,
@@ -72,7 +79,7 @@
&quot;RunOnceActivity.typescript.service.memoryLimit.init&quot;: &quot;true&quot;,
&quot;codeWithMe.voiceChat.enabledByDefault&quot;: &quot;false&quot;,
&quot;git-widget-placeholder&quot;: &quot;feature/occ-migration&quot;,
&quot;last_opened_file_path&quot;: &quot;/Volumes/Data_drive/Programming/fluency/src/fluency&quot;,
&quot;last_opened_file_path&quot;: &quot;/Volumes/Data_drive/Programming/fluency/src/fluency/rendering&quot;,
&quot;node.js.detected.package.eslint&quot;: &quot;true&quot;,
&quot;node.js.selected.package.eslint&quot;: &quot;(autodetect)&quot;,
&quot;node.js.selected.package.tslint&quot;: &quot;(autodetect)&quot;,
@@ -87,6 +94,7 @@
</component>
<component name="RecentsManager">
<key name="CopyFile.RECENT_KEYS">
<recent name="$PROJECT_DIR$/src/fluency/rendering" />
<recent name="$PROJECT_DIR$/src/fluency" />
<recent name="$PROJECT_DIR$" />
<recent name="$PROJECT_DIR$/drawing_modules" />
@@ -344,7 +352,87 @@
<option name="project" value="LOCAL" />
<updated>1783282570014</updated>
</task>
<option name="localTasksCounter" value="30" />
<task id="LOCAL-00030" summary="- Added save file foramt&#10;- Split main.py refactor">
<option name="closed" value="true" />
<created>1783453889199</created>
<option name="number" value="00030" />
<option name="presentableId" value="LOCAL-00030" />
<option name="project" value="LOCAL" />
<updated>1783453889199</updated>
</task>
<task id="LOCAL-00031" summary="- Added save file foramt&#10;- Split main.py refactor">
<option name="closed" value="true" />
<created>1783456842297</created>
<option name="number" value="00031" />
<option name="presentableId" value="LOCAL-00031" />
<option name="project" value="LOCAL" />
<updated>1783456842297</updated>
</task>
<task id="LOCAL-00032" summary="- Added save file foramt&#10;- Split main.py refactor">
<option name="closed" value="true" />
<created>1783755278516</created>
<option name="number" value="00032" />
<option name="presentableId" value="LOCAL-00032" />
<option name="project" value="LOCAL" />
<updated>1783755278516</updated>
</task>
<task id="LOCAL-00033" summary="- Added save file foramt&#10;- Split main.py refactor">
<option name="closed" value="true" />
<created>1783777171864</created>
<option name="number" value="00033" />
<option name="presentableId" value="LOCAL-00033" />
<option name="project" value="LOCAL" />
<updated>1783777171864</updated>
</task>
<task id="LOCAL-00034" summary="- Working assembly multi :)">
<option name="closed" value="true" />
<created>1783798201133</created>
<option name="number" value="00034" />
<option name="presentableId" value="LOCAL-00034" />
<option name="project" value="LOCAL" />
<updated>1783798201133</updated>
</task>
<task id="LOCAL-00035" summary="- Working assembly multi :)">
<option name="closed" value="true" />
<created>1783798929531</created>
<option name="number" value="00035" />
<option name="presentableId" value="LOCAL-00035" />
<option name="project" value="LOCAL" />
<updated>1783798929531</updated>
</task>
<task id="LOCAL-00036" summary="- added renderer">
<option name="closed" value="true" />
<created>1783887682052</created>
<option name="number" value="00036" />
<option name="presentableId" value="LOCAL-00036" />
<option name="project" value="LOCAL" />
<updated>1783887682052</updated>
</task>
<task id="LOCAL-00037" summary="- added renderer&#10;- Added undo">
<option name="closed" value="true" />
<created>1783887704766</created>
<option name="number" value="00037" />
<option name="presentableId" value="LOCAL-00037" />
<option name="project" value="LOCAL" />
<updated>1783887704766</updated>
</task>
<task id="LOCAL-00038" summary="- added renderer&#10;- Added undo">
<option name="closed" value="true" />
<created>1783891561116</created>
<option name="number" value="00038" />
<option name="presentableId" value="LOCAL-00038" />
<option name="project" value="LOCAL" />
<updated>1783891561116</updated>
</task>
<task id="LOCAL-00039" summary="- added renderer&#10;- Added undo">
<option name="closed" value="true" />
<created>1783918463034</created>
<option name="number" value="00039" />
<option name="presentableId" value="LOCAL-00039" />
<option name="project" value="LOCAL" />
<updated>1783918463034</updated>
</task>
<option name="localTasksCounter" value="40" />
<servers />
</component>
<component name="TypeScriptGeneratedFilesManager">
@@ -365,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" />
@@ -389,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&#10;- Split main.py refactor" />
<option name="LAST_COMMIT_MESSAGE" value="- Added save file foramt&#10;- Split main.py refactor" />
<MESSAGE value="- Working assembly multi :)" />
<MESSAGE value="- added renderer" />
<MESSAGE value="- added renderer&#10;- Added undo" />
<option name="LAST_COMMIT_MESSAGE" value="- added renderer&#10;- Added undo" />
</component>
</project>
+253
View File
@@ -0,0 +1,253 @@
# Realistic Render View — Implementation Plan
## Context
Add a **"Render"** feature to Fluency CAD that opens a separate window for photorealistic rendering of the selected component or assembly (like KeyShot/Cacles).
**Constraints:**
- Open in a **new window** — don't clutter the workspace
- **Keep existing OCCRenderer** for the interactive 3D viewport — untouched
- Render backend must be a **separate, swappable module** so we can change the renderer later
- Use **Mitsuba 3** as the initial backend (`pip install mitsuba`, ~50MB)
---
## Architecture
```
┌─────────────────────────────────────────────────────────┐
│ Main Fluency Window (existing OCCRenderer — untouched) │
│ │
│ [Select body/assembly] → [Click "Render"] │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────┐ │
│ │ RenderWindow (separate QMainWindow)│ │
│ │ │ │
│ │ ┌───────────────────────────────┐ │ │
│ │ │ RenderBackend (ABC) │ │ │
│ │ │ ├─ MitsubaBackend ← current │ │ │
│ │ │ ├─ (future: BlenderBackend) │ │ │
│ │ │ └─ (future: CyclesBackend) │ │ │
│ │ └───────────────────────────────┘ │ │
│ │ │ │\n│ │ [Image preview] [Progress bar] │ │
│ │ [Material ▾] [Quality ▾] [Render] │ │
│ │ [Export PNG] │ │
│ └─────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
```
### Swappable Backend Interface
```python
from abc import ABC, abstractmethod
from dataclasses import dataclass
import numpy as np
@dataclass
class RenderMaterial:
name: str
color: tuple[float, float, float] = (0.7, 0.7, 0.7)
metallic: float = 0.0 # 0.01.0
roughness: float = 0.5 # 0.01.0
bsdf_type: str = "diffuse" # diffuse | roughconductor | roughdielectric | plastic
@dataclass
class RenderCamera:
origin: tuple[float, float, float] = (100, 100, 100)
target: tuple[float, float, float] = (0, 0, 0)
up: tuple[float, float, float] = (0, 0, 1)
fov: float = 45.0
@dataclass
class RenderSettings:
width: int = 1920
height: int = 1080
spp: int = 256 # samples per pixel
max_depth: int = 8 # path tracer bounces
class RenderBackend(ABC):
"""Swap this to change the rendering engine."""
@abstractmethod
def render(self, obj_path: str, material: RenderMaterial,
camera: RenderCamera, settings: RenderSettings) -> np.ndarray: ...
@abstractmethod
def render_preview(self, obj_path: str, material: RenderMaterial,
camera: RenderCamera, settings: RenderSettings) -> np.ndarray: ...
@abstractmethod
def name(self) -> str: ...
```
Switching backends later = write a new class implementing `RenderBackend`. One import change.
---
## Mitsuba 3 Backend
### Why Mitsuba
| Feature | Status |
|---------|--------|
| `pip install mitsuba` | Single install, no system deps |
| True path tracing | GI, caustics, spectral rendering |
| PBR materials | `roughconductor`, `roughdielectric`, `diffuse`, `plastic` |
| Python dict API | Build scenes programmatically, no XML |
| CPU + GPU backends | `scalar_rgb` (CPU), `cuda_rgb` (NVIDIA) |
| Output formats | PNG, EXR (HDR) with tonemapping |
### OCC → OBJ Conversion Path
```python
from OCP.BRepMesh import BRepMesh_IncrementalMesh
from OCP.StlAPI import StlAPI_Writer
from OCP.BRep import BRep_Builder
import tempfile, os
def occ_shape_to_obj(shape, obj_path: str, linear_deflection: float = 0.1):
"""Tessellate OCC shape and write as OBJ for Mitsuba."""
tess = BRepMesh_IncrementalMesh(shape, linear_deflection, False, 0.5, True)
tess.Perform()
# Write STL (reliable), then convert to OBJ via trimesh or direct
writer = StlAPI_Writer()
writer.SetASCIIMode(False)
stl_path = obj_path.replace(".obj", ".stl")
writer.Write(shape, stl_path)
# Mitsuba can read STL directly, or we convert to OBJ
return stl_path
```
### Mitsuba Scene Construction
```python
import mitsuba as mi
mi.set_variant("scalar_rgb")
def build_scene(mesh_path: str, material: RenderMaterial,
camera: RenderCamera, settings: RenderSettings) -> mi.Scene:
# Map our material to Mitsuba BSDF
bsdf_map = {
"diffuse": {"type": "diffuse", "reflectance": {"type": "rgb", "value": material.color}},
"roughconductor": {
"type": "roughconductor",
"material": "copper", # or铝, 钢, etc.
"alpha": material.roughness,
},
"roughdielectric": {
"type": "roughdielectric",
"int_ior": 1.5,
"alpha": material.roughness,
},
"plastic": {
"type": "plastic",
"diffuse_reflectance": {"type": "rgb", "value": material.color},
"int_ior": 1.5,
},
}
return mi.load_dict({
"type": "scene",
"integrator": {"type": "path", "max_depth": settings.max_depth},
"sensor": {
"type": "perspective",
"fov": camera.fov,
"to_world": mi.ScalarTransform4f.look_at(
origin=camera.origin, target=camera.target, up=camera.up
),
"film": {"type": "hdrfilm", "width": settings.width, "height": settings.height},
"sampler": {"type": "independent", "sample_count": settings.spp},
},
"emitter": {"type": "constant"},
"shape": {
"type": "stl", # or "obj"
"filename": mesh_path,
"bsdf": bsdf_map.get(material.bsdf_type, bsdf_map["diffuse"]),
},
})
```
---
## Files to Create/Modify
| File | Action | Description |
|------|--------|-------------|
| `src/fluency/rendering/render_backend.py` | **NEW** | Abstract `RenderBackend`, `RenderMaterial`, `RenderCamera`, `RenderSettings` |
| `src/fluency/rendering/mitsuba_backend.py` | **NEW** | `MitsubaBackend(RenderBackend)` implementation |
| `src/fluency/rendering/occ_to_mesh.py` | **NEW** | OCC `TopoDS_Shape` → STL/OBJ tessellation |
| `src/fluency/rendering/material_presets.py` | **NEW** | Preset library: Steel, Aluminum, Brass, Chrome, Plastic, Rubber, Wood |
| `src/fluency/ui/render_window.py` | **NEW** | `RenderWindow(QMainWindow)` — image preview, material/quality controls, render/export |
| `src/fluency/ui/main_window.py` | MODIFY | Add "Render" button → get selected shapes → open `RenderWindow` |
---
## UI: RenderWindow
```
┌──────────────────────────────────────────┐
│ Render — [Part Name] [─][□][×] │
├──────────────────────────────────────────┤
│ │
│ ┌──────────────────────────────────┐ │
│ │ │ │
│ │ Rendered Image Preview │ │
│ │ (QLabel with QPixmap) │ │
│ │ │ │
│ └──────────────────────────────────┘ │
│ │
│ Material: [Steel ▾] │
│ Quality: [256 SPP ▾] │
│ Resolution: [1920×1080 ▾] │
│ │
│ [▶ Render] [⏹ Cancel] [💾 Export PNG] │
│ │
│ ████████████████░░░░░░ 65% (23s left) │
└──────────────────────────────────────────┘
```
- **Preview**: progressive refinement (low SPP first, then ramp)
- **Cancel**: kill Mitsuba render thread
- **Export**: save to PNG/EXR
---
## Material Presets
| Preset | Color | Metallic | Roughness | BSDF |
|--------|-------|----------|-----------|------|
| Brushed Steel | (0.65, 0.67, 0.72) | 0.9 | 0.35 | roughconductor |
| Polished Chrome | (0.8, 0.8, 0.8) | 1.0 | 0.05 | roughconductor |
| Brushed Aluminum | (0.75, 0.75, 0.75) | 0.85 | 0.25 | roughconductor |
| Copper | (0.95, 0.64, 0.54) | 0.95 | 0.15 | roughconductor |
| Gold | (1.0, 0.76, 0.33) | 1.0 | 0.1 | roughconductor |
| Blackened Steel | (0.15, 0.15, 0.17) | 0.8 | 0.4 | roughconductor |
| Matte Plastic | (0.2, 0.5, 0.8) | 0.0 | 0.6 | plastic |
| Glossy Plastic | (0.2, 0.5, 0.8) | 0.0 | 0.1 | plastic |
| White Nylon | (0.85, 0.85, 0.83) | 0.0 | 0.45 | plastic |
| Black ABS | (0.05, 0.05, 0.05) | 0.0 | 0.35 | plastic |
| Red PA12 | (0.75, 0.08, 0.08) | 0.0 | 0.4 | plastic |
| Rubber | (0.1, 0.1, 0.1) | 0.0 | 0.9 | diffuse |
| Ceramic White | (0.92, 0.91, 0.88) | 0.0 | 0.15 | dielectric |
| Glass | (0.95, 0.95, 0.95) | 0.0 | 0.0 | dielectric |
| Wood | (0.6, 0.4, 0.2) | 0.0 | 0.7 | diffuse |
**Note:** Mitsuba pip installs don't include spectral metal data files (iron.spd, copper.spd, etc.), so metal presets use `material="none"` with `specular_reflectance` set to the metal color instead.
---
## Risks & Mitigations
| Risk | Mitigation |
|------|-----------|
| Mitsuba not installed | Graceful error: "pip install mitsuba" shown in UI |
| Slow CPU rendering | Default to low SPP (64) for preview; offer GPU variant if CUDA available |
| Large meshes slow to tessellate | Progress indicator; optional mesh decimation |
| Mitsuba STL/OCC compatibility | Test tessellation quality; tune `linear_deflection` |
---
## Estimated Effort
- **Phase 1** (abstract backend + OCC→mesh + Mitsuba impl): ~4-6 hours
- **Phase 2** (render window UI + material presets): ~3-4 hours
- **Phase 3** (polish, export, swap test): ~2-3 hours
- **Total**: ~9-13 hours
+1031 -930
View File
File diff suppressed because it is too large Load Diff
+588 -534
View File
File diff suppressed because it is too large Load Diff
+5
View File
@@ -406,6 +406,11 @@ class SketchInterface(ABC):
"""Constrain two circles to have equal radius."""
pass
@abstractmethod
def constrain_diameter(self, circle: SketchEntity, diameter: float) -> bool:
"""Set the diameter of a circle."""
pass
@abstractmethod
def constrain_fixed(self, entity: SketchEntity) -> bool:
"""Fix an entity in place."""
+26
View File
@@ -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)
+18
View File
@@ -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
+35 -5
View File
@@ -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:
+13
View File
@@ -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",
]
+156
View File
@@ -0,0 +1,156 @@
"""Material presets for the render backend.
Each preset is a RenderMaterial with physically-plausible values.
"""
from __future__ import annotations
from typing import Dict, List
from .render_backend import RenderMaterial
# ── Preset library ──────────────────────────────────────────────────────
# Note: Mitsuba pip installs don't include spectral metal data files,
# so metal_preset is not used. Instead, metals use material="none" with
# specular_reflectance set to the metal color.
PRESETS: Dict[str, RenderMaterial] = {
# ── Metals ──────────────────────────────────────────────────────
"Brushed Steel": RenderMaterial(
name="Brushed Steel",
color=(0.65, 0.67, 0.72),
metallic=0.9,
roughness=0.35,
bsdf_type="roughconductor",
),
"Polished Chrome": RenderMaterial(
name="Polished Chrome",
color=(0.8, 0.8, 0.8),
metallic=1.0,
roughness=0.05,
bsdf_type="roughconductor",
),
"Brushed Aluminum": RenderMaterial(
name="Brushed Aluminum",
color=(0.75, 0.75, 0.75),
metallic=0.85,
roughness=0.25,
bsdf_type="roughconductor",
),
"Copper": RenderMaterial(
name="Copper",
color=(0.95, 0.64, 0.54),
metallic=0.95,
roughness=0.15,
bsdf_type="roughconductor",
),
"Gold": RenderMaterial(
name="Gold",
color=(1.0, 0.76, 0.33),
metallic=1.0,
roughness=0.1,
bsdf_type="roughconductor",
),
"Blackened Steel": RenderMaterial(
name="Blackened Steel",
color=(0.15, 0.15, 0.17),
metallic=0.8,
roughness=0.4,
bsdf_type="roughconductor",
),
# ── Plastics ────────────────────────────────────────────────────
"Matte Plastic": RenderMaterial(
name="Matte Plastic",
color=(0.2, 0.5, 0.8),
metallic=0.0,
roughness=0.6,
bsdf_type="plastic",
int_ior=1.5,
),
"Glossy Plastic": RenderMaterial(
name="Glossy Plastic",
color=(0.2, 0.5, 0.8),
metallic=0.0,
roughness=0.1,
bsdf_type="plastic",
int_ior=1.5,
),
"White Nylon": RenderMaterial(
name="White Nylon",
color=(0.85, 0.85, 0.83),
metallic=0.0,
roughness=0.45,
bsdf_type="plastic",
int_ior=1.53,
),
"Black ABS": RenderMaterial(
name="Black ABS",
color=(0.05, 0.05, 0.05),
metallic=0.0,
roughness=0.35,
bsdf_type="plastic",
int_ior=1.54,
),
"Red PA12": RenderMaterial(
name="Red PA12",
color=(0.75, 0.08, 0.08),
metallic=0.0,
roughness=0.4,
bsdf_type="plastic",
int_ior=1.53,
),
# ── Other ───────────────────────────────────────────────────────
"Rubber": RenderMaterial(
name="Rubber",
color=(0.1, 0.1, 0.1),
metallic=0.0,
roughness=0.9,
bsdf_type="diffuse",
),
"Ceramic White": RenderMaterial(
name="Ceramic White",
color=(0.92, 0.91, 0.88),
metallic=0.0,
roughness=0.15,
bsdf_type="dielectric",
int_ior=1.55,
),
"Glass": RenderMaterial(
name="Glass",
color=(0.95, 0.95, 0.95),
metallic=0.0,
roughness=0.0,
bsdf_type="dielectric",
int_ior=1.52,
),
"Wood": RenderMaterial(
name="Wood",
color=(0.6, 0.4, 0.2),
metallic=0.0,
roughness=0.7,
bsdf_type="diffuse",
),
}
def get_preset(name: str) -> RenderMaterial:
"""Get a material preset by name. Falls back to default if not found."""
if name in PRESETS:
return PRESETS[name]
return default_material()
def default_material() -> RenderMaterial:
"""Return the default grey material."""
return RenderMaterial(
name="Default",
color=(0.7, 0.7, 0.7),
metallic=0.0,
roughness=0.5,
bsdf_type="diffuse",
)
def preset_names() -> List[str]:
"""Return sorted list of available preset names."""
return sorted(PRESETS.keys())
+382
View File
@@ -0,0 +1,382 @@
"""Mitsuba 3 photorealistic render backend.
Requires: ``pip install mitsuba``
"""
from __future__ import annotations
import logging
import os
from typing import Callable, Optional
import numpy as np
from .render_backend import RenderBackend, RenderCamera, RenderMaterial, RenderSettings
logger = logging.getLogger(__name__)
class MitsubaBackend(RenderBackend):
"""Render backend using Mitsuba 3 path tracer."""
def name(self) -> str:
return "Mitsuba 3"
def is_available(self) -> bool:
import sys
import io
old_stderr = sys.stderr
sys.stderr = io.StringIO()
try:
import mitsuba # noqa: F401
return True
except ImportError:
return False
finally:
sys.stderr = old_stderr
# ── Scene construction ──────────────────────────────────────────
def _build_scene_dict(
self,
mesh_path: str,
material: RenderMaterial,
camera: RenderCamera,
settings: RenderSettings,
) -> dict:
"""Build a Mitsuba scene dictionary from our data classes.
Uses a 3-point lighting setup (key + fill + rim) plus an ambient
environment emitter for soft fill, giving well-balanced shading on
all faces of the model. Lighting intensities and colors come from
``settings.lighting``; ground plane comes from ``settings.ground_plane``.
"""
import mitsuba as mi
lighting = settings.lighting
ground = settings.ground_plane
# Map our BSDF types to Mitsuba BSDF dicts
bsdf = self._make_bsdf(material)
# Determine mesh file type from extension
ext = os.path.splitext(mesh_path)[1].lower()
shape_type = "ply" if ext == ".ply" else "obj"
# Build camera-to-world transform using the Python API
cam_to_world = mi.ScalarTransform4f.look_at(
origin=list(camera.origin),
target=list(camera.target),
up=list(camera.up),
)
scene = {
"type": "scene",
# Integrator
"integrator": {
"type": "path",
"max_depth": settings.max_depth,
},
# Camera
"sensor": {
"type": "perspective",
"fov": camera.fov,
"to_world": cam_to_world,
"film": {
"type": "hdrfilm",
"width": settings.width,
"height": settings.height,
"rfilter": {"type": "gaussian"},
},
"sampler": {
"type": "independent",
"sample_count": settings.spp,
},
},
# Ambient environment fill
"emitter": {
"type": "constant",
"radiance": {
"type": "rgb",
"value": [
lighting.ambient_intensity,
lighting.ambient_intensity * 0.97,
lighting.ambient_intensity * 0.94,
],
},
},
# Shape
"shape": {
"type": shape_type,
"filename": mesh_path,
"bsdf": bsdf,
},
}
# ── 3-point lighting (colors and intensities from config) ───
key_rgb = [c * lighting.key_intensity for c in lighting.key_color]
key_to_world = mi.ScalarTransform4f.look_at(
origin=[1.0, -0.8, 1.2],
target=[0.0, 0.0, 0.0],
up=[0.0, 0.0, 1.0],
)
scene["key_light"] = {
"type": "directional",
"to_world": key_to_world,
"irradiance": {"type": "rgb", "value": key_rgb},
}
fill_rgb = [c * lighting.fill_intensity for c in lighting.fill_color]
fill_to_world = mi.ScalarTransform4f.look_at(
origin=[-1.0, 0.6, 0.8],
target=[0.0, 0.0, 0.0],
up=[0.0, 0.0, 1.0],
)
scene["fill_light"] = {
"type": "directional",
"to_world": fill_to_world,
"irradiance": {"type": "rgb", "value": fill_rgb},
}
rim_rgb = [c * lighting.rim_intensity for c in lighting.rim_color]
rim_to_world = mi.ScalarTransform4f.look_at(
origin=[-0.3, 1.2, -0.8],
target=[0.0, 0.0, 0.0],
up=[0.0, 0.0, 1.0],
)
scene["rim_light"] = {
"type": "directional",
"to_world": rim_to_world,
"irradiance": {"type": "rgb", "value": rim_rgb},
}
# ── Ground plane / backdrop (optional) ─────────────────────
if ground.enabled:
try:
# Load mesh to compute bounds for ground/backdrop placement
mesh_shape = mi.load_dict({"type": shape_type, "filename": mesh_path})
bbox = mesh_shape.bbox()
bbox_min, bbox_max = bbox[0], bbox[1]
# Ground at model's lowest Z with 0.1% offset
model_height = bbox_max[2] - bbox_min[2]
ground_z = bbox_min[2] - 0.001 * model_height
dx = bbox_max[0] - bbox_min[0]
dy = bbox_max[1] - bbox_min[1]
dz = bbox_max[2] - bbox_min[2]
diag = float((dx * dx + dy * dy + dz * dz) ** 0.5)
except Exception:
# Fallback: place at origin with large default size
ground_z = -ground.distance_below
diag = 1000.0
bsdf_ground = {
"type": "diffuse",
"reflectance": {"type": "rgb", "value": list(ground.color)},
}
if ground.curved_backdrop:
# Photo booth style curved leinwand:
# - Flat floor section in front of the model
# - Curved cylinder behind that sweeps up and over
half_size = diag * 50.0 # huge floor
radius = diag * 3.0 # curvature radius
cyl_height = diag * 20.0 # width of the cylinder (along its axis)
scene["ground_floor"] = {
"type": "rectangle",
"to_world": mi.ScalarTransform4f.translate([0.0, 0.0, ground_z])
@ mi.ScalarTransform4f.scale([half_size, half_size, 1.0]),
"bsdf": bsdf_ground,
}
# Cylinder: axis along Y, positioned behind model, radius sweeps up
scene["ground_backdrop"] = {
"type": "cylinder",
"radius": radius,
"p0": [-cyl_height / 2, -radius, ground_z],
"p1": [cyl_height / 2, -radius, ground_z],
"to_world": mi.ScalarTransform4f.rotate([1, 0, 0], 90)
@ mi.ScalarTransform4f.translate([0.0, 0.0, -radius]),
"bsdf": bsdf_ground,
}
else:
# Simple flat ground plane — very large so edges aren't visible
half_size = diag * 50.0
scene["ground_plane"] = {
"type": "rectangle",
"to_world": mi.ScalarTransform4f.translate([0.0, 0.0, ground_z])
@ mi.ScalarTransform4f.scale([half_size, half_size, 1.0]),
"bsdf": bsdf_ground,
}
return scene
def _make_bsdf(self, material: RenderMaterial) -> dict:
"""Convert a RenderMaterial to a Mitsuba BSDF dict."""
mt = material.bsdf_type
if mt == "roughconductor":
# Use material="none" with specular_reflectance set to the
# metal color. The pip-installed Mitsuba doesn't include
# spectral metal data files (iron.spd, copper.spd, etc.).
return {
"type": "roughconductor",
"material": "none",
"alpha": max(material.roughness, 0.01),
"specular_reflectance": {
"type": "rgb",
"value": list(material.color),
},
}
if mt == "roughdielectric":
return {
"type": "roughdielectric",
"int_ior": material.int_ior,
"ext_ior": 1.0,
"alpha": max(material.roughness, 0.01),
}
if mt == "dielectric":
return {
"type": "dielectric",
"int_ior": material.int_ior,
"ext_ior": 1.0,
}
if mt == "plastic":
return {
"type": "plastic",
"diffuse_reflectance": {
"type": "rgb",
"value": list(material.color),
},
"int_ior": material.int_ior,
}
# Default: diffuse
return {
"type": "diffuse",
"reflectance": {
"type": "rgb",
"value": list(material.color),
},
}
# ── Rendering ───────────────────────────────────────────────────
def render(
self,
mesh_path: str,
material: RenderMaterial,
camera: RenderCamera,
settings: RenderSettings,
progress_callback: Optional[Callable[[float], None]] = None,
) -> np.ndarray:
"""Render a mesh file and return (H, W, 3) float32 RGB array."""
self._set_variant()
import mitsuba as mi
scene_dict = self._build_scene_dict(mesh_path, material, camera, settings)
scene = mi.load_dict(scene_dict)
logger.info(f"Rendering {settings.width}x{settings.height} @ {settings.spp} spp")
# Render
try:
image = mi.render(scene, spp=settings.spp, seed=int(settings.seed or 0)) # type: ignore[arg-type] # Mitsuba accepts int at runtime
except Exception as e:
logger.error(f"Mitsuba render failed: {e}")
raise
if progress_callback:
progress_callback(1.0)
# Convert to numpy (H, W, 3)
arr = np.array(image, dtype=np.float32)
# Apply approximate sRGB tonemapping
arr = np.clip(arr, 0.0, None)
arr = np.power(arr, 1.0 / 2.2) # gamma
arr = np.clip(arr, 0.0, 1.0)
return arr
def render_preview(
self,
mesh_path: str,
material: RenderMaterial,
camera: RenderCamera,
settings: RenderSettings,
) -> np.ndarray:
"""Quick low-quality preview (4x fewer spp)."""
preview_settings = RenderSettings(
width=settings.width // 2,
height=settings.height // 2,
spp=max(settings.spp // 4, 16),
max_depth=min(settings.max_depth, 4),
seed=settings.seed,
lighting=settings.lighting,
ground_plane=settings.ground_plane,
)
return self.render(mesh_path, material, camera, preview_settings)
# ── Export ──────────────────────────────────────────────────────
def export_image(self, image: np.ndarray, path: str) -> None:
"""Save a rendered image to PNG or EXR."""
from PIL import Image
ext = os.path.splitext(path)[1].lower()
if ext == ".exr":
# Save as EXR (HDR) — no tonemapping
try:
import OpenEXR # type: ignore[import-not-found]
import Imath # type: ignore[import-not-found]
h, w = image.shape[:2]
header = OpenEXR.Header(w, h)
header["channels"] = {
"R": Imath.PixelType(Imath.PixelType.FLOAT),
"G": Imath.PixelType(Imath.PixelType.FLOAT),
"B": Imath.PixelType(Imath.PixelType.FLOAT),
}
exr = OpenEXR.OutputFile(path, header)
exr.write(
{
"R": image[:, :, 0].tobytes(),
"G": image[:, :, 1].tobytes(),
"B": image[:, :, 2].tobytes(),
}
)
exr.close()
except ImportError:
# Fallback: save as 16-bit PNG
logger.warning("OpenEXR not available, saving as 16-bit PNG")
img = Image.fromarray((image * 65535).astype(np.uint16), "RGB")
img.save(path)
else:
# PNG / JPEG — already tonemapped
img = Image.fromarray((image * 255).astype(np.uint8), "RGB")
img.save(path)
logger.info(f"Exported render to {path}")
# ── Helpers ─────────────────────────────────────────────────────
def _set_variant(self) -> None:
"""Set the Mitsuba variant (called once)."""
import sys
import io
# Suppress the harmless "LLVM API initialization failed" warning
# that drjit emits on macOS ARM when scalar variant is used.
old_stderr = sys.stderr
sys.stderr = io.StringIO()
try:
import mitsuba as mi
mi.set_variant("scalar_rgb")
finally:
sys.stderr = old_stderr
+594 -115
View File
@@ -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 (10120 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.
@@ -1047,27 +1169,44 @@ class OCCRenderer(Renderer):
shape = self._context.DetectedShape()
if shape is None:
return None
return self._classify_detected_shape(shape)
results = self._classify_detected_shape(shape)
if not results:
return None
# For cylinders with two ends, pick the one closest to the camera.
if len(results) > 1:
eye = None
try:
if self._view is not None:
e = self._view.Eye()
eye = np.array([e.X(), e.Y(), e.Z()], dtype=float)
except Exception:
pass
if eye is not None:
results.sort(key=lambda c: float(np.linalg.norm(
np.array(c["position"]) - eye)))
return results[0]
def _classify_detected_shape(
self, shape: Any, owner_obj_id: Optional[str] = None,
) -> Optional[Dict[str, Any]]:
"""Classify a detected OCC sub-shape into a snap-candidate dict.
) -> List[Dict[str, Any]]:
"""Classify a detected OCC sub-shape into snap-candidate dicts.
Shared by ``pick_entity`` (single-pixel) and ``probe_snap_candidates``
(multi-pixel grid probing). Determines whether *shape* is a planar
face, cylindrical face (hole), edge, or vertex and returns the snap
info dict with ``position`` / ``normal`` / ``x_dir`` / ``type`` /
``owner_obj_id`` (+ ``radius`` for holes). When *owner_obj_id* is
omitted it is looked up from the context's currently-detected AIS.
face, cylindrical face (hole), edge, or vertex and returns a list of
snap info dicts with ``position`` / ``normal`` / ``x_dir`` / ``type`` /
``owner_obj_id`` (+ ``radius`` for holes). Cylindrical faces yield
two candidates (one per circular end) so the user can snap to either
opening. When *owner_obj_id* is omitted it is looked up from the
context's currently-detected AIS.
"""
if shape is None:
return None
return []
from OCP.TopoDS import TopoDS_Face, TopoDS_Edge, TopoDS_Vertex, TopoDS
from OCP.TopAbs import TopAbs_FACE, TopAbs_EDGE, TopAbs_VERTEX
from OCP.BRepAdaptor import BRepAdaptor_Surface, BRepAdaptor_Curve
from OCP.GeomAbs import GeomAbs_Plane, GeomAbs_Cylinder
from OCP.GeomAbs import GeomAbs_Plane, GeomAbs_Cylinder, GeomAbs_Circle
from OCP.BRep import BRep_Tool
from OCP.TopExp import TopExp_Explorer
from OCP.TopAbs import TopAbs_EDGE as TopAbs_EDGE_TYPE
@@ -1139,14 +1278,14 @@ class OCCRenderer(Renderer):
px = pln.XAxis().Direction()
x_dir = (px.X(), px.Y(), px.Z())
return {
return [{
"type": "planar_face",
"position": origin,
"normal": (nx, ny, nz),
"x_dir": x_dir,
"face": face,
"owner_obj_id": owner_obj_id,
}
}]
elif stype == GeomAbs_Cylinder:
cyl = adaptor.Cylinder()
@@ -1155,52 +1294,67 @@ class OCCRenderer(Renderer):
ax_pos = axis.Location()
radius = cyl.Radius()
# Parameter extents along the cylinder axis (v = height).
# BRepAdaptor_Surface exposes these via First/Last V
# *Parameter() — NOT a Bounds() method (that quirk crashed
# cylindrical-face picking).
vmin = adaptor.FirstVParameter()
vmax = adaptor.LastVParameter()
# Find the actual circular edge loops at each end of the
# cylinder face. This is more reliable than computing from
# vmin/vmax parameters, which can be offset depending on how
# the BRep was constructed (e.g. a hole drilled into a block).
# Two candidate snap points: the centers of the cylinder's
# two end circles (the hole openings). A bolt enters a
# hole from the camera-facing opening, so pick the END of
# the axis closest to the camera as the primary snap point.
# The axis location (ax_pos) is already on the cylinder axis
# at v=0; the other end is at v=vmax.
p0 = np.array([
ax_pos.X(), ax_pos.Y(), ax_pos.Z(),
], dtype=float)
p1 = np.array([
ax_pos.X() + ax_dir.X() * (vmax - vmin),
ax_pos.Y() + ax_dir.Y() * (vmax - vmin),
ax_pos.Z() + ax_dir.Z() * (vmax - vmin),
], dtype=float)
# Collect all edges of the face.
edge_explorer = TopExp_Explorer(face, TopAbs_EDGE_TYPE)
circle_centers: List[np.ndarray] = []
while edge_explorer.More():
edge = TopoDS.Edge_s(edge_explorer.Current())
try:
curve_adaptor = BRepAdaptor_Curve(edge)
if curve_adaptor.GetType() == GeomAbs_Circle:
circ = curve_adaptor.Circle()
center_pnt = circ.Location()
circle_centers.append(np.array([
center_pnt.X(), center_pnt.Y(), center_pnt.Z()
], dtype=float))
except Exception:
pass
edge_explorer.Next()
# Choose the camera-facing end. The camera looks FROM its
# eye TOWARD its target, so the camera direction is
# (target - eye). The end whose vector-from-camera is MOST
# OPPOSITE to (i.e. faces) the camera is the near opening.
cam_from = None
try:
if self._view is not None:
eye = self._view.Eye()
at = self._view.At()
cam_from = np.array([eye.X(), eye.Y(), eye.Z()], dtype=float)
cam_to = np.array([at.X(), at.Y(), at.Z()], dtype=float)
except Exception:
cam_from = None
if cam_from is not None:
# End closest to the camera eye is the visible opening.
d0 = float(np.linalg.norm(p0 - cam_from))
d1 = float(np.linalg.norm(p1 - cam_from))
near_end = p0 if d0 <= d1 else p1
# Group circle centers by their position along the axis.
# Two distinct groups = two end openings of the cylinder.
if len(circle_centers) >= 2:
# Project each center onto the axis direction to get a
# scalar "height" value. Cluster into two groups.
ax_dir_np = np.array([
ax_dir.X(), ax_dir.Y(), ax_dir.Z()
], dtype=float)
heights = [np.dot(c, ax_dir_np) for c in circle_centers]
# Sort by height (scalar) and split roughly in half.
indexed = list(enumerate(heights))
indexed.sort(key=lambda x: x[1])
mid = len(indexed) // 2
idx0 = [i for i, _ in indexed[:mid]] if mid > 0 else [indexed[0][0]]
idx1 = [i for i, _ in indexed[mid:]] if mid < len(indexed) else [indexed[-1][0]]
group0 = [circle_centers[i] for i in idx0]
group1 = [circle_centers[i] for i in idx1]
# Average each group to get the center of each end circle.
c0 = np.mean(group0, axis=0)
c1 = np.mean(group1, axis=0)
elif len(circle_centers) == 1:
# Only one circular edge found (e.g. open-ended cylinder).
c0 = circle_centers[0]
c1 = c0
else:
# Fallback: axial midpoint.
near_end = 0.5 * (p0 + p1)
# No circular edges found — fall back to parameter-based.
vmin = adaptor.FirstVParameter()
vmax = adaptor.LastVParameter()
c0 = np.array([
ax_pos.X() + ax_dir.X() * vmin,
ax_pos.Y() + ax_dir.Y() * vmin,
ax_pos.Z() + ax_dir.Z() * vmin,
], dtype=float)
c1 = np.array([
ax_pos.X() + ax_dir.X() * vmax,
ax_pos.Y() + ax_dir.Y() * vmax,
ax_pos.Z() + ax_dir.Z() * vmax,
], dtype=float)
origin = (float(near_end[0]), float(near_end[1]), float(near_end[2]))
# Normal = the cylinder axis direction. This is the "bolt
# axis": the direction a bolt would travel INTO the hole.
# (Sign is the cylinder's own axis direction; a later flip can
@@ -1216,15 +1370,22 @@ class OCCRenderer(Renderer):
except Exception:
x_dir = (1.0, 0.0, 0.0)
return {
"type": "cylindrical_face",
"position": origin,
"normal": normal,
"x_dir": x_dir,
"face": face,
"owner_obj_id": owner_obj_id,
"radius": radius,
}
# Return BOTH circular ends as snap candidates so the user can
# snap to either opening of a cylinder/hole (e.g. bolt-to-bore
# on the far side of a part).
results: List[Dict[str, Any]] = []
for end_center in [c0, c1]:
origin = (float(end_center[0]), float(end_center[1]), float(end_center[2]))
results.append({
"type": "cylindrical_face",
"position": origin,
"normal": normal,
"x_dir": x_dir,
"face": face,
"owner_obj_id": owner_obj_id,
"radius": radius,
})
return results
# Try edge.
edge = None
@@ -1267,14 +1428,14 @@ class OCCRenderer(Renderer):
x = x / xlen
x_dir = (float(x[0]), float(x[1]), float(x[2]))
return {
return [{
"type": "edge",
"position": position,
"normal": tangent,
"x_dir": x_dir,
"edge": edge,
"owner_obj_id": owner_obj_id,
}
}]
# Try vertex.
vertex = None
@@ -1282,52 +1443,33 @@ class OCCRenderer(Renderer):
vertex = TopoDS.Vertex_s(shape)
p = BRep_Tool.Pnt_s(vertex)
position = (p.X(), p.Y(), p.Z())
return {
return [{
"type": "vertex",
"position": position,
"normal": None,
"x_dir": None,
"vertex": vertex,
"owner_obj_id": owner_obj_id,
}
}]
except Exception:
pass
return None
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
return []
def probe_snap_candidates(
self, x: int, y: int, radius: int = 18,
self, x: int, y: int, radius: int = 30,
) -> List[Dict[str, Any]]:
"""Probe a pixel grid around (x, y) and return visible snap candidates.
Samples a small ring + centre around the cursor, runs OCC's
Samples a dense ring + centre around the cursor, runs OCC's
``MoveTo`` at each pixel, and classifies every distinct detected
sub-shape via :meth:`_classify_detected_shape`. Results are
deduplicated by (owner_obj_id, type, rounded position) and sorted by
screen-space distance to the cursor, nearest first.
This is the general hover snap indicator: it surfaces nearby
vertices, edge midpoints, hole centres, and face centres so the
user can see the snap targets in the cursor neighbourhood not
vertices, edge midpoints, hole centres, and face centres so that
the user can see the snap targets in the cursor neighbourhood not
just the single entity directly under the crosshair.
Each entry is the same dict shape returned by ``pick_entity`` plus an
@@ -1337,14 +1479,21 @@ class OCCRenderer(Renderer):
if self._view is None or self._context is None:
return []
# Sample pattern: the exact cursor pixel plus a small ring of
# offsets. The ring catches nearby vertices/edges/holes that sit a
# few pixels away from where the user is pointing.
# Dense sample pattern: centre + multiple rings at different radii
# to catch small features like hole openings that might be missed by
# a single sparse ring. Uses quarter, half, and full radius offsets.
q = radius // 4
h = radius // 2
ring_offsets = [
(0, 0),
# Full radius ring (cardinal + diagonal)
(-radius, 0), (radius, 0), (0, -radius), (0, radius),
(-radius, -radius), (radius, radius), (-radius, radius), (radius, -radius),
(-radius // 2, 0), (radius // 2, 0), (0, -radius // 2), (0, radius // 2),
# Half-radius ring
(-h, 0), (h, 0), (0, -h), (0, h),
(-h, -h), (h, h), (-h, h), (h, -h),
# Quarter-radius ring for small features
(-q, 0), (q, 0), (0, -q), (0, q),
]
candidates: Dict[Tuple[str, str, Tuple[int, int, int]], Dict[str, Any]] = {}
@@ -1359,30 +1508,32 @@ class OCCRenderer(Renderer):
shape = self._context.DetectedShape()
if shape is None:
continue
info = self._classify_detected_shape(shape)
if info is None:
infos = self._classify_detected_shape(shape)
if not infos:
continue
# Skip non-trackable hits (no owner — e.g. the workplane plane).
if not info.get("owner_obj_id"):
continue
pos = info.get("position") or (0.0, 0.0, 0.0)
# Dedupe key: owner + type + position rounded to 0.1 mm.
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"] = (sx, sy)
candidates[key] = info
# infos is a list; for cylinders it contains two ends.
for info in infos:
# Skip non-trackable hits (no owner — e.g. the workplane plane).
if not info.get("owner_obj_id"):
continue
pos = info.get("position") or (0.0, 0.0, 0.0)
# Dedupe key: owner + type + position rounded to 0.1 mm.
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"] = (sx, sy)
candidates[key] = info
# Sort by screen-space distance to the 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 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
@@ -1406,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}")
@@ -1548,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],
@@ -1642,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:
+252
View File
@@ -0,0 +1,252 @@
"""Convert OCC BRep shapes to mesh files for render backends.
Outputs PLY files (preferred by Mitsuba) or STL files.
"""
from __future__ import annotations
import logging
import os
import tempfile
from typing import List, Optional, Tuple
import numpy as np
logger = logging.getLogger(__name__)
def occ_shape_to_ply(
shape,
output_path: Optional[str] = None,
linear_deflection: float = 0.1,
angular_deflection: float = 0.15,
) -> str:
"""Tessellate an OCC TopoDS_Shape and write as PLY.
Returns the path to the written PLY file.
"""
from OCP.BRepMesh import BRepMesh_IncrementalMesh
from OCP.TopExp import TopExp_Explorer
from OCP.TopAbs import TopAbs_FACE
from OCP.TopoDS import TopoDS
from OCP.BRep import BRep_Tool
from OCP.TopLoc import TopLoc_Location
# Tessellate
tess = BRepMesh_IncrementalMesh(
shape, linear_deflection, False, angular_deflection, True
)
tess.Perform()
# Extract triangulation from all faces
all_vertices: List[List[float]] = []
all_faces: List[List[int]] = []
vertex_offset = 0
from OCP.TopAbs import TopAbs_FORWARD
explorer = TopExp_Explorer(shape, TopAbs_FACE)
while explorer.More():
face = TopoDS.Face_s(explorer.Current())
location = TopLoc_Location()
triangulation = BRep_Tool.Triangulation_s(face, location)
if triangulation is None:
explorer.Next()
continue
# Transform
trsf = location.Transformation()
# Check face orientation: FORWARD means the surface normal points
# outward from the solid; REVERSED means it points inward.
is_forward = (face.Orientation() == TopAbs_FORWARD)
# Extract vertices (apply location transform to positions)
nb_nodes = triangulation.NbNodes()
for i in range(1, nb_nodes + 1):
node = triangulation.Node(i)
pnt = node.Transformed(trsf)
all_vertices.append([pnt.X(), pnt.Y(), pnt.Z()])
# Extract triangles
# For REVERSED faces, swap winding order (n1, n3, n2) so that
# the computed normal points outward consistently.
nb_triangles = triangulation.NbTriangles()
for i in range(1, nb_triangles + 1):
tri = triangulation.Triangle(i)
n1, n2, n3 = tri.Get()
if is_forward:
all_faces.append([
n1 - 1 + vertex_offset,
n2 - 1 + vertex_offset,
n3 - 1 + vertex_offset,
])
else:
# Swap winding for REVERSED faces
all_faces.append([
n1 - 1 + vertex_offset,
n3 - 1 + vertex_offset,
n2 - 1 + vertex_offset,
])
vertex_offset += nb_nodes
explorer.Next()
if not all_vertices:
raise ValueError("Tessellation produced no vertices")
vertices = np.array(all_vertices, dtype=np.float32)
faces = np.array(all_faces, dtype=np.uint32)
logger.info(
f"Tessellation: {len(vertices)} vertices, {len(faces)} triangles"
)
# Compute smooth vertex normals from face normals.
# Winding is already corrected during tessellation using OCC face orientation.
normals, corrected_faces = _compute_outward_normals(vertices, faces, shape)
# Write PLY with corrected faces and normals
if output_path is None:
fd, output_path = tempfile.mkstemp(suffix=".ply", prefix="fluency_render_")
os.close(fd)
_write_ply(output_path, vertices, corrected_faces, normals)
logger.info(f"Wrote PLY: {output_path}")
return output_path
def _compute_outward_normals(
vertices: np.ndarray,
faces: np.ndarray,
shape,
) -> Tuple[np.ndarray, np.ndarray]:
"""Compute outward-facing vertex normals and correct face winding.
The winding is already corrected during tessellation using OCC's face
orientation (TopAbs_FORWARD/REVERSED). This function computes smooth
vertex normals by averaging face normals at shared vertices.
Returns (normals, corrected_faces) for PLY export.
"""
n_verts = len(vertices)
v_normals = np.zeros((n_verts, 3), dtype=np.float64)
# Ensure faces is 2D (numpy creates (3,) for single-face meshes)
if faces.ndim == 1:
faces = faces.reshape(1, -1)
# Winding is already correct from tessellation (face orientation check).
# Just compute face normals and accumulate to vertices.
v0 = vertices[faces[:, 0]]
v1 = vertices[faces[:, 1]]
v2 = vertices[faces[:, 2]]
edge1 = v1 - v0
edge2 = v2 - v0
fn = np.cross(edge1, edge2)
# Normalize face normals
lengths = np.linalg.norm(fn, axis=1, keepdims=True)
lengths[lengths < 1e-10] = 1.0
fn /= lengths
# Accumulate to vertices
for i in range(len(faces)):
idx = faces[i]
v_normals[idx[0]] += fn[i]
v_normals[idx[1]] += fn[i]
v_normals[idx[2]] += fn[i]
# Normalize vertex normals
v_lengths = np.linalg.norm(v_normals, axis=1, keepdims=True)
v_lengths[v_lengths < 1e-10] = 1.0
v_normals /= v_lengths
return v_normals.astype(np.float32), faces.astype(np.uint32)
def occ_shape_to_stl(
shape,
output_path: Optional[str] = None,
linear_deflection: float = 0.1,
) -> str:
"""Tessellate an OCC TopoDS_Shape and write as binary STL.
Returns the path to the written STL file.
"""
from OCP.BRepMesh import BRepMesh_IncrementalMesh
from OCP.StlAPI import StlAPI_Writer
# Tessellate
tess = BRepMesh_IncrementalMesh(shape, linear_deflection, False, 0.5, True)
tess.Perform()
if output_path is None:
fd, output_path = tempfile.mkstemp(suffix=".stl", prefix="fluency_render_")
os.close(fd)
writer = StlAPI_Writer()
writer.SetASCIIMode(False)
writer.Write(shape, output_path)
logger.info(f"Wrote STL: {output_path}")
return output_path
def occ_shape_bounds(shape) -> Tuple[Tuple[float, float, float], Tuple[float, float, float]]:
"""Return (min_xyz, max_xyz) bounding box of an OCC shape."""
from OCP.Bnd import Bnd_Box
from OCP.BRepBndLib import BRepBndLib
bbox = Bnd_Box()
BRepBndLib.Add_s(shape, bbox)
xmin, ymin, zmin, xmax, ymax, zmax = bbox.Get()
return (xmin, ymin, zmin), (xmax, ymax, zmax)
def _write_ply(
path: str,
vertices: np.ndarray,
faces: np.ndarray,
normals: Optional[np.ndarray] = None,
) -> None:
"""Write a binary PLY file (little-endian) with optional vertex normals."""
import struct
n_verts = len(vertices)
n_faces = len(faces)
has_normals = normals is not None and len(normals) == n_verts
with open(path, "wb") as f:
# Header
header_lines = [
"ply",
"format binary_little_endian 1.0",
f"element vertex {n_verts}",
"property float x",
"property float y",
"property float z",
]
if has_normals:
header_lines.extend([
"property float nx",
"property float ny",
"property float nz",
])
header_lines.append(f"element face {n_faces}")
header_lines.append("property list uchar int vertex_indices")
header_lines.append("end_header")
f.write(("\n".join(header_lines) + "\n").encode("ascii"))
# Vertex positions (+ normals if available)
for i in range(n_verts):
f.write(struct.pack("<fff", vertices[i, 0], vertices[i, 1], vertices[i, 2]))
if has_normals:
f.write(struct.pack("<fff", normals[i, 0], normals[i, 1], normals[i, 2]))
# Faces
for face in faces:
f.write(struct.pack("<B", 3))
f.write(struct.pack("<iii", int(face[0]), int(face[1]), int(face[2])))
+142
View File
@@ -0,0 +1,142 @@
"""Abstract render backend interface.
Any photorealistic renderer (Mitsuba, Blender, Cycles, ...) implements
:class:`RenderBackend`. The UI only talks to this ABC so backends can be
swapped by changing one import.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import Optional
@dataclass
class RenderMaterial:
"""PBR material description for the render backend."""
name: str = "Default"
color: tuple[float, float, float] = (0.7, 0.7, 0.7)
metallic: float = 0.0 # 0.0 = dielectric, 1.0 = metal
roughness: float = 0.5 # 0.0 = mirror, 1.0 = fully rough
bsdf_type: str = "diffuse" # diffuse | roughconductor | roughdielectric | plastic
# Optional: named metal preset (copper, aluminium, gold, chrome, steel)
metal_preset: Optional[str] = None
# For dielectric / plastic
int_ior: float = 1.5
@dataclass
class RenderCamera:
"""Camera parameters for the render."""
origin: tuple[float, float, float] = (100.0, 100.0, 100.0)
target: tuple[float, float, float] = (0.0, 0.0, 0.0)
up: tuple[float, float, float] = (0.0, 0.0, 1.0)
fov: float = 60.0 # vertical field of view in degrees
@dataclass
class LightingConfig:
"""Lighting configuration for the render scene."""
ambient_intensity: float = 0.3 # constant environment fill [0..1]
key_color: tuple[float, float, float] = (1.0, 0.98, 0.95) # RGB key light color
key_intensity: float = 3.5 # key light irradiance multiplier
fill_color: tuple[float, float, float] = (0.92, 0.94, 1.0) # RGB fill light color
fill_intensity: float = 1.5 # fill light irradiance multiplier
rim_color: tuple[float, float, float] = (1.0, 0.98, 0.96) # RGB rim light color
rim_intensity: float = 1.2 # rim light irradiance multiplier
@dataclass
class GroundPlaneConfig:
"""Ground plane configuration for the render scene."""
enabled: bool = False
color: tuple[float, float, float] = (0.5, 0.5, 0.5) # RGB diffuse color
roughness: float = 0.8 # surface roughness [0..1]
distance_below: float = 0.0 # mm below origin (positive = below)
curved_backdrop: bool = False # photo booth style curved leinwand
@dataclass
class RenderSettings:
"""Quality / resolution settings."""
width: int = 1920
height: int = 1080
spp: int = 256 # samples per pixel
max_depth: int = 8 # max bounces for path tracer
seed: int = 0 # random seed (0 = auto)
lighting: LightingConfig = field(default_factory=LightingConfig)
ground_plane: GroundPlaneConfig = field(default_factory=GroundPlaneConfig)
class RenderBackend(ABC):
"""Abstract photorealistic renderer.
Implementations live in separate modules so backends can be swapped
without touching the UI. Typical call::
backend = MitsubaBackend()
image = backend.render(obj_path, material, camera, settings)
"""
@abstractmethod
def name(self) -> str:
"""Human-readable backend name (shown in UI)."""
@abstractmethod
def is_available(self) -> bool:
"""Return True if this backend's dependencies are installed."""
@abstractmethod
def render(
self,
mesh_path: str,
material: RenderMaterial,
camera: RenderCamera,
settings: RenderSettings,
progress_callback=None,
) -> "np.ndarray":
"""Render a mesh file and return an (H, W, 3) float32 RGB array.
*mesh_path* is an STL or OBJ file on disk.
*progress_callback(fraction)* is called with 0.01.0 progress.
"""
@abstractmethod
def render_preview(
self,
mesh_path: str,
material: RenderMaterial,
camera: RenderCamera,
settings: RenderSettings,
) -> "np.ndarray":
"""Quick low-quality preview (fewer spp)."""
@abstractmethod
def export_image(self, image: "np.ndarray", path: str) -> None:
"""Save a rendered image to PNG / EXR."""
def default_camera_from_bounds(
self, bounds_min: tuple[float, float, float], bounds_max: tuple[float, float, float]
) -> RenderCamera:
"""Compute a sensible default camera looking at the bbox centre."""
import numpy as np
mn = np.asarray(bounds_min, dtype=float)
mx = np.asarray(bounds_max, dtype=float)
centre = (mn + mx) / 2.0
diag = float(np.linalg.norm(mx - mn))
# Place camera at iso-ish position, far enough to see everything.
eye = centre + np.array([0.7, -0.7, 0.5]) * diag * 0.8
return RenderCamera(
origin=tuple(eye.tolist()),
target=tuple(centre.tolist()),
up=(0.0, 0.0, 1.0),
fov=45.0,
)
+372
View File
@@ -0,0 +1,372 @@
"""
Undo/Redo manager for OCCSketch using snapshot-based approach.
python_solvespace has no per-entity delete API the solver is rebuilt from
scratch after every modification. This makes snapshot-based undo natural:
we capture the complete sketch state (entities, geometry, constraints) and
restore it by rebuilding the solver from the snapshot data.
Each snapshot is ~10-50 KB depending on sketch complexity, and we cap the
stack at a configurable depth (default 50).
"""
from __future__ import annotations
import copy
import logging
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional, Set, Tuple
logger = logging.getLogger(__name__)
@dataclass
class SketchSnapshot:
"""Immutable snapshot of sketch state for undo/redo.
Captures everything needed to fully reconstruct an OCCSketch:
entities, points, lines, circles, arcs, counter, constraint log,
and the special entity id sets (centerlines, external/underlay).
"""
# Entity registry: id → (entity_type, geometry, is_construction, is_external, constraints_list)
entities: Dict[int, Tuple[str, Any, bool, bool, List[str]]] = field(default_factory=dict)
# Geometry sub-indices
points: Dict[int, Tuple[float, float]] = field(default_factory=dict)
lines: Dict[int, Tuple[int, int]] = field(default_factory=dict)
circles: Dict[int, Tuple[int, float]] = field(default_factory=dict)
arcs: Dict[int, Dict[str, Any]] = field(default_factory=dict)
# Counters and flags
entity_counter: int = 0
constraint_count: int = 0
first_point_id: Optional[int] = None
# Constraint replay log
constraint_log: List[Dict[str, Any]] = field(default_factory=list)
# Special entity sets
centerline_ids: Set[int] = field(default_factory=set)
external_entity_ids: Set[int] = field(default_factory=set)
# Workplane (so undo doesn't lose the placement plane)
wp_origin: Tuple[float, float, float] = (0.0, 0.0, 0.0)
wp_normal: Tuple[float, float, float] = (0.0, 0.0, 1.0)
wp_x_dir: Tuple[float, float, float] = (1.0, 0.0, 0.0)
wp_y_dir: Tuple[float, float, float] = (0.0, 1.0, 0.0)
class SketchUndoManager:
"""Manages undo/redo stacks of SketchSnapshot for an OCCSketch.
Usage::
undo_mgr = SketchUndoManager(sketch)
# Before any modification:
undo_mgr.save_state()
# ... perform modification ...
# Ctrl+Z:
undo_mgr.undo()
# Ctrl+Y / Ctrl+Shift+Z:
undo_mgr.redo()
"""
def __init__(self, sketch: Any, max_stack_size: int = 50) -> None:
from fluency.geometry_occ.sketch import OCCSketch
self._sketch: OCCSketch = sketch
self._max_stack_size = max_stack_size
self._undo_stack: List[SketchSnapshot] = []
self._redo_stack: List[SketchSnapshot] = []
# ─── Public API ────────────────────────────────────────────────────────
@property
def can_undo(self) -> bool:
"""True if there is a state to undo to."""
return len(self._undo_stack) > 0
@property
def can_redo(self) -> bool:
"""True if there is a state to redo to."""
return len(self._redo_stack) > 0
@property
def undo_depth(self) -> int:
"""Number of undo levels available."""
return len(self._undo_stack)
@property
def redo_depth(self) -> int:
"""Number of redo levels available."""
return len(self._redo_stack)
def save_state(self) -> None:
"""Capture the current sketch state and push it onto the undo stack.
Call this **before** any modifying operation (draw, delete, move,
constraint add/remove, construction toggle, etc.).
The redo stack is cleared whenever a new state is saved (i.e. when
the user makes a new change after undoing).
"""
snapshot = self._capture()
self._undo_stack.append(snapshot)
# Cap the stack size.
if len(self._undo_stack) > self._max_stack_size:
self._undo_stack.pop(0)
# New mutation invalidates the redo history.
self._redo_stack.clear()
logger.debug(
f"save_state: undo_depth={len(self._undo_stack)} "
f"entities={len(snapshot.entities)}"
)
def undo(self) -> bool:
"""Restore the previous sketch state.
Returns True if a state was restored, False if the undo stack is empty.
"""
if not self._undo_stack:
logger.debug("undo: stack empty")
return False
# Save current state to redo stack before restoring.
current_snapshot = self._capture()
self._redo_stack.append(current_snapshot)
# Pop and restore.
snapshot = self._undo_stack.pop()
self._restore(snapshot)
logger.debug(
f"undo: restored state with {len(snapshot.entities)} entities, "
f"undo_depth={len(self._undo_stack)} redo_depth={len(self._redo_stack)}"
)
return True
def redo(self) -> bool:
"""Re-apply the most recently undone state.
Returns True if a state was restored, False if the redo stack is empty.
"""
if not self._redo_stack:
logger.debug("redo: stack empty")
return False
# Save current state to undo stack before restoring.
current_snapshot = self._capture()
self._undo_stack.append(current_snapshot)
# Pop and restore.
snapshot = self._redo_stack.pop()
self._restore(snapshot)
logger.debug(
f"redo: restored state with {len(snapshot.entities)} entities, "
f"undo_depth={len(self._undo_stack)} redo_depth={len(self._redo_stack)}"
)
return True
def clear(self) -> None:
"""Clear both stacks (e.g. when loading a new sketch)."""
self._undo_stack.clear()
self._redo_stack.clear()
logger.debug("undo stacks cleared")
# ─── Snapshot Capture ──────────────────────────────────────────────────
def _capture(self) -> SketchSnapshot:
"""Capture the current sketch state into a snapshot."""
sketch = self._sketch
# Capture entities: id → (type, geometry, is_construction, is_external, constraints)
entities: Dict[int, Tuple[str, Any, bool, bool, List[str]]] = {}
for eid, ent in sketch._entities.items():
entities[eid] = (
ent.entity_type,
ent.geometry,
ent.is_construction,
ent.is_external,
list(ent.constraints), # copy the constraints list
)
# Deep copy the mutable dicts (points coords are tuples, so shallow is fine,
# but arcs contain dicts so we deep-copy those).
points = dict(sketch._points)
lines = dict(sketch._lines)
circles = dict(sketch._circles)
arcs = {k: copy.deepcopy(v) for k, v in sketch._arcs.items()}
# Constraint log entries contain tuples and sets — need careful copy.
constraint_log = []
for entry in sketch._constraint_log:
copied = {
"type": entry["type"],
"ids": tuple(entry["ids"]),
"params": tuple(entry["params"]) if entry.get("params") else (),
"labels": set(entry["labels"]) if entry.get("labels") else set(),
}
constraint_log.append(copied)
return SketchSnapshot(
entities=entities,
points=points,
lines=lines,
circles=circles,
arcs=arcs,
entity_counter=sketch._entity_counter,
constraint_count=sketch._constraint_count,
first_point_id=sketch._first_point_id,
constraint_log=constraint_log,
centerline_ids=set(sketch._centerline_ids),
external_entity_ids=set(sketch._external_entity_ids),
wp_origin=sketch._wp_origin,
wp_normal=sketch._wp_normal,
wp_x_dir=sketch._wp_x_dir,
wp_y_dir=sketch._wp_y_dir,
)
# ─── Snapshot Restore ──────────────────────────────────────────────────
def _restore(self, snapshot: SketchSnapshot) -> None:
"""Restore the sketch to a previously captured snapshot state."""
from fluency.geometry_occ.sketch import OCCSketch, OCCSketchEntity
sketch = self._sketch
# Clear the current solver and rebuild from scratch.
sketch._solver = sketch._solver.__class__() # SolverSystem()
sketch._wp = sketch._solver.create_2d_base()
sketch._first_point_id = None
# Restore counters and flags.
sketch._entity_counter = snapshot.entity_counter
sketch._constraint_count = snapshot.constraint_count
sketch._centerline_ids = set(snapshot.centerline_ids)
sketch._external_entity_ids = set(snapshot.external_entity_ids)
# Restore workplane.
sketch._wp_origin = snapshot.wp_origin
sketch._wp_normal = snapshot.wp_normal
sketch._wp_x_dir = snapshot.wp_x_dir
sketch._wp_y_dir = snapshot.wp_y_dir
# Rebuild entity objects from the snapshot.
sketch._entities.clear()
sketch._points.clear()
sketch._lines.clear()
sketch._circles.clear()
sketch._arcs.clear()
# First pass: re-add all points to the solver.
for eid in sorted(snapshot.entities.keys()):
etype, geometry, is_constr, is_ext, constraints = snapshot.entities[eid]
if etype == "point" and eid in snapshot.points:
x, y = snapshot.points[eid]
solver_handle = sketch._solver.add_point_2d(x, y, sketch._wp)
ent = OCCSketchEntity(
entity_id=eid,
entity_type="point",
geometry=(x, y),
handle=solver_handle,
)
ent.is_construction = is_constr
ent.is_external = is_ext
ent.constraints = list(constraints)
sketch._entities[eid] = ent
sketch._points[eid] = (x, y)
# Anchor the first point (or first external point) for solver stability.
if sketch._first_point_id is None and not is_ext:
sketch._first_point_id = eid
sketch._solver.dragged(solver_handle, sketch._wp)
elif sketch._first_point_id is None and is_ext:
sketch._first_point_id = eid
sketch._solver.dragged(solver_handle, sketch._wp)
# Second pass: re-add all lines.
for lid in sorted(snapshot.lines.keys()):
sid, eid2 = snapshot.lines[lid]
s_ent = sketch._entities.get(sid)
e_ent = sketch._entities.get(eid2)
if s_ent is None or e_ent is None or s_ent.handle is None or e_ent.handle is None:
continue
solver_handle = sketch._solver.add_line_2d(s_ent.handle, e_ent.handle, sketch._wp)
etype, geometry, is_constr, is_ext, constraints = snapshot.entities[lid]
ent = OCCSketchEntity(
entity_id=lid,
entity_type="line",
geometry=geometry,
handle=solver_handle,
)
ent.is_construction = is_constr
ent.is_external = is_ext
ent.constraints = list(constraints)
sketch._entities[lid] = ent
sketch._lines[lid] = (sid, eid2)
# Restore circles (not in solver, just tracked).
for cid, (center_id, radius) in snapshot.circles.items():
if cid in snapshot.entities:
etype, geometry, is_constr, is_ext, constraints = snapshot.entities[cid]
center_ent = sketch._entities.get(center_id)
ent = OCCSketchEntity(
entity_id=cid,
entity_type="circle",
geometry=geometry,
handle=None,
)
ent.is_construction = is_constr
ent.is_external = is_ext
ent.constraints = list(constraints)
sketch._entities[cid] = ent
sketch._circles[cid] = (center_id, radius)
# Restore arcs (not in solver, just tracked).
for aid, arc_data in snapshot.arcs.items():
if aid in snapshot.entities:
etype, geometry, is_constr, is_ext, constraints = snapshot.entities[aid]
ent = OCCSketchEntity(
entity_id=aid,
entity_type="arc",
geometry=geometry,
handle=None,
)
ent.is_construction = is_constr
ent.is_external = is_ext
ent.constraints = list(constraints)
sketch._entities[aid] = ent
sketch._arcs[aid] = copy.deepcopy(arc_data)
# Rebuild the constraint log (entries were deep-copied on capture).
sketch._constraint_log = []
for entry in snapshot.constraint_log:
sketch._constraint_log.append({
"type": entry["type"],
"ids": tuple(entry["ids"]),
"params": tuple(entry["params"]) if entry.get("params") else (),
"labels": set(entry["labels"]) if entry.get("labels") else set(),
})
# Re-apply all constraints to the solver.
for entry in sketch._constraint_log:
sketch._apply_constraint_log(entry)
# Solve to update geometry positions.
sketch.solve()
logger.debug(
f"Restored snapshot: {len(sketch._entities)} entities, "
f"{len(sketch._constraint_log)} constraints"
)
+3
View File
@@ -9,6 +9,7 @@ from typing import Any, Dict, List, Optional, Tuple
from PySide6.QtCore import Qt, QPoint, QPointF
from PySide6.QtGui import QColor, QFont, QKeySequence
from PySide6.QtWidgets import (
QButtonGroup,
QCheckBox,
QComboBox,
QDialog,
@@ -16,8 +17,10 @@ from PySide6.QtWidgets import (
QDoubleSpinBox,
QFormLayout,
QFrame,
QGridLayout,
QHBoxLayout,
QLabel,
QLineEdit,
QPushButton,
QRadioButton,
QVBoxLayout,
+906
View File
@@ -0,0 +1,906 @@
# -*- coding: utf-8 -*-
################################################################################
## Form generated from reading UI file 'gui.ui'
##
## Created by: Qt User Interface Compiler version 6.10.2
##
## WARNING! All changes made in this file will be lost when recompiling UI file!
################################################################################
from PySide6.QtCore import (QCoreApplication, QDate, QDateTime, QLocale,
QMetaObject, QObject, QPoint, QRect,
QSize, QTime, QUrl, Qt)
from PySide6.QtGui import (QAction, QBrush, QColor, QConicalGradient,
QCursor, QFont, QFontDatabase, QGradient,
QIcon, QImage, QKeySequence, QLinearGradient,
QPainter, QPalette, QPixmap, QRadialGradient,
QTransform)
from PySide6.QtWidgets import (QApplication, QFrame, QGridLayout, QGroupBox,
QHBoxLayout, QLabel, QListWidget, QListWidgetItem,
QMainWindow, QMenu, QMenuBar, QPushButton,
QSizePolicy, QSpinBox, QStatusBar, QTabWidget,
QTextEdit, QVBoxLayout, QWidget)
class Ui_fluencyCAD(object):
def setupUi(self, fluencyCAD):
if not fluencyCAD.objectName():
fluencyCAD.setObjectName(u"fluencyCAD")
fluencyCAD.resize(2359, 1285)
self.actionNew_Project = QAction(fluencyCAD)
self.actionNew_Project.setObjectName(u"actionNew_Project")
self.actionOpen_Project = QAction(fluencyCAD)
self.actionOpen_Project.setObjectName(u"actionOpen_Project")
self.actionSave_Project = QAction(fluencyCAD)
self.actionSave_Project.setObjectName(u"actionSave_Project")
self.actionSave_Project_As = QAction(fluencyCAD)
self.actionSave_Project_As.setObjectName(u"actionSave_Project_As")
self.actionImport_File = QAction(fluencyCAD)
self.actionImport_File.setObjectName(u"actionImport_File")
self.actionExport_Step = QAction(fluencyCAD)
self.actionExport_Step.setObjectName(u"actionExport_Step")
self.actionExport_Iges = QAction(fluencyCAD)
self.actionExport_Iges.setObjectName(u"actionExport_Iges")
self.actionExport_Stl = QAction(fluencyCAD)
self.actionExport_Stl.setObjectName(u"actionExport_Stl")
self.actionExit = QAction(fluencyCAD)
self.actionExit.setObjectName(u"actionExit")
self.centralwidget = QWidget(fluencyCAD)
self.centralwidget.setObjectName(u"centralwidget")
self.gridLayout = QGridLayout(self.centralwidget)
self.gridLayout.setObjectName(u"gridLayout")
self.InputTab = QTabWidget(self.centralwidget)
self.InputTab.setObjectName(u"InputTab")
sizePolicy = QSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
sizePolicy.setHorizontalStretch(0)
sizePolicy.setVerticalStretch(0)
sizePolicy.setHeightForWidth(self.InputTab.sizePolicy().hasHeightForWidth())
self.InputTab.setSizePolicy(sizePolicy)
self.sketch_tab = QWidget()
self.sketch_tab.setObjectName(u"sketch_tab")
self.verticalLayout_4 = QVBoxLayout(self.sketch_tab)
self.verticalLayout_4.setObjectName(u"verticalLayout_4")
self.InputTab.addTab(self.sketch_tab, "")
self.code_tab = QWidget()
self.code_tab.setObjectName(u"code_tab")
self.verticalLayout = QVBoxLayout(self.code_tab)
self.verticalLayout.setObjectName(u"verticalLayout")
self.textEdit = QTextEdit(self.code_tab)
self.textEdit.setObjectName(u"textEdit")
self.verticalLayout.addWidget(self.textEdit)
self.groupBox_7 = QGroupBox(self.code_tab)
self.groupBox_7.setObjectName(u"groupBox_7")
self.gridLayout_5 = QGridLayout(self.groupBox_7)
self.gridLayout_5.setObjectName(u"gridLayout_5")
self.pushButton_5 = QPushButton(self.groupBox_7)
self.pushButton_5.setObjectName(u"pushButton_5")
self.gridLayout_5.addWidget(self.pushButton_5, 2, 0, 1, 1)
self.pushButton_4 = QPushButton(self.groupBox_7)
self.pushButton_4.setObjectName(u"pushButton_4")
self.gridLayout_5.addWidget(self.pushButton_4, 2, 1, 1, 1)
self.pb_apply_code = QPushButton(self.groupBox_7)
self.pb_apply_code.setObjectName(u"pb_apply_code")
self.gridLayout_5.addWidget(self.pb_apply_code, 1, 0, 1, 1)
self.pushButton = QPushButton(self.groupBox_7)
self.pushButton.setObjectName(u"pushButton")
self.gridLayout_5.addWidget(self.pushButton, 1, 1, 1, 1)
self.verticalLayout.addWidget(self.groupBox_7)
self.InputTab.addTab(self.code_tab, "")
self.gridLayout.addWidget(self.InputTab, 0, 1, 12, 1)
self.groupBox_9 = QGroupBox(self.centralwidget)
self.groupBox_9.setObjectName(u"groupBox_9")
self.groupBox_9.setMaximumSize(QSize(200, 16777215))
self.gridLayout_7 = QGridLayout(self.groupBox_9)
self.gridLayout_7.setObjectName(u"gridLayout_7")
self.pb_origin_wp = QPushButton(self.groupBox_9)
self.pb_origin_wp.setObjectName(u"pb_origin_wp")
self.gridLayout_7.addWidget(self.pb_origin_wp, 0, 0, 1, 1)
self.pb_origin_face = QPushButton(self.groupBox_9)
self.pb_origin_face.setObjectName(u"pb_origin_face")
self.pb_origin_face.setCheckable(True)
self.gridLayout_7.addWidget(self.pb_origin_face, 0, 1, 1, 1)
self.pb_flip_face = QPushButton(self.groupBox_9)
self.pb_flip_face.setObjectName(u"pb_flip_face")
self.gridLayout_7.addWidget(self.pb_flip_face, 1, 0, 1, 1)
self.pb_underlay = QPushButton(self.groupBox_9)
self.pb_underlay.setObjectName(u"pb_underlay")
self.pb_underlay.setEnabled(False)
self.pb_underlay.setCheckable(True)
self.pb_underlay.setChecked(True)
self.gridLayout_7.addWidget(self.pb_underlay, 3, 0, 1, 1)
self.pb_clr_face = QPushButton(self.groupBox_9)
self.pb_clr_face.setObjectName(u"pb_clr_face")
self.pb_clr_face.setEnabled(False)
self.gridLayout_7.addWidget(self.pb_clr_face, 3, 1, 1, 1)
self.pb_to_sketch = QPushButton(self.groupBox_9)
self.pb_to_sketch.setObjectName(u"pb_to_sketch")
self.pb_to_sketch.setEnabled(False)
self.gridLayout_7.addWidget(self.pb_to_sketch, 4, 0, 1, 2)
self.pb_wp_new = QPushButton(self.groupBox_9)
self.pb_wp_new.setObjectName(u"pb_wp_new")
self.gridLayout_7.addWidget(self.pb_wp_new, 1, 1, 1, 1)
self.gridLayout.addWidget(self.groupBox_9, 0, 0, 1, 1)
self.assembly_box = QGroupBox(self.centralwidget)
self.assembly_box.setObjectName(u"assembly_box")
self.assembly_box.setMinimumSize(QSize(0, 50))
self.gridLayout.addWidget(self.assembly_box, 13, 1, 1, 2)
self.joint_tools = QGroupBox(self.centralwidget)
self.joint_tools.setObjectName(u"joint_tools")
self.joint_tools.setMinimumSize(QSize(0, 50))
self.gridLayout_10 = QGridLayout(self.joint_tools)
self.gridLayout_10.setObjectName(u"gridLayout_10")
self.pb_remove_connector = QPushButton(self.joint_tools)
self.pb_remove_connector.setObjectName(u"pb_remove_connector")
self.pb_remove_connector.setMinimumSize(QSize(50, 50))
self.pb_remove_connector.setMaximumSize(QSize(50, 50))
self.gridLayout_10.addWidget(self.pb_remove_connector, 0, 2, 1, 1)
self.pb_add_connector = QPushButton(self.joint_tools)
self.pb_add_connector.setObjectName(u"pb_add_connector")
self.pb_add_connector.setMinimumSize(QSize(50, 50))
self.pb_add_connector.setMaximumSize(QSize(50, 50))
self.gridLayout_10.addWidget(self.pb_add_connector, 0, 1, 1, 1)
self.pb_add_connector_2 = QPushButton(self.joint_tools)
self.pb_add_connector_2.setObjectName(u"pb_add_connector_2")
self.pb_add_connector_2.setMinimumSize(QSize(50, 50))
self.pb_add_connector_2.setMaximumSize(QSize(50, 50))
self.gridLayout_10.addWidget(self.pb_add_connector_2, 1, 1, 1, 1)
self.pb_add_connector_3 = QPushButton(self.joint_tools)
self.pb_add_connector_3.setObjectName(u"pb_add_connector_3")
self.pb_add_connector_3.setMinimumSize(QSize(50, 50))
self.pb_add_connector_3.setMaximumSize(QSize(50, 50))
self.gridLayout_10.addWidget(self.pb_add_connector_3, 1, 2, 1, 1)
self.gridLayout.addWidget(self.joint_tools, 12, 3, 2, 1)
self.gl_box = QGroupBox(self.centralwidget)
self.gl_box.setObjectName(u"gl_box")
sizePolicy1 = QSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
sizePolicy1.setHorizontalStretch(0)
sizePolicy1.setVerticalStretch(4)
sizePolicy1.setHeightForWidth(self.gl_box.sizePolicy().hasHeightForWidth())
self.gl_box.setSizePolicy(sizePolicy1)
font = QFont()
font.setPointSize(12)
self.gl_box.setFont(font)
self.horizontalLayout_4 = QHBoxLayout(self.gl_box)
#ifndef Q_OS_MAC
self.horizontalLayout_4.setSpacing(-1)
#endif
self.horizontalLayout_4.setObjectName(u"horizontalLayout_4")
self.horizontalLayout_4.setContentsMargins(12, -1, -1, -1)
self.gridLayout.addWidget(self.gl_box, 0, 2, 12, 1)
self.groupBox_11 = QGroupBox(self.centralwidget)
self.groupBox_11.setObjectName(u"groupBox_11")
sizePolicy2 = QSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Expanding)
sizePolicy2.setHorizontalStretch(0)
sizePolicy2.setVerticalStretch(0)
sizePolicy2.setHeightForWidth(self.groupBox_11.sizePolicy().hasHeightForWidth())
self.groupBox_11.setSizePolicy(sizePolicy2)
self.groupBox_11.setMaximumSize(QSize(200, 16777215))
self.verticalLayout_7 = QVBoxLayout(self.groupBox_11)
self.verticalLayout_7.setObjectName(u"verticalLayout_7")
self.verticalLayout_7.setContentsMargins(5, 5, 5, 5)
self.sketch_list = QListWidget(self.groupBox_11)
self.sketch_list.setObjectName(u"sketch_list")
sizePolicy3 = QSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
sizePolicy3.setHorizontalStretch(0)
sizePolicy3.setVerticalStretch(0)
sizePolicy3.setHeightForWidth(self.sketch_list.sizePolicy().hasHeightForWidth())
self.sketch_list.setSizePolicy(sizePolicy3)
self.sketch_list.setSelectionRectVisible(True)
self.verticalLayout_7.addWidget(self.sketch_list)
self.groupBox_6 = QGroupBox(self.groupBox_11)
self.groupBox_6.setObjectName(u"groupBox_6")
sizePolicy4 = QSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Preferred)
sizePolicy4.setHorizontalStretch(0)
sizePolicy4.setVerticalStretch(0)
sizePolicy4.setHeightForWidth(self.groupBox_6.sizePolicy().hasHeightForWidth())
self.groupBox_6.setSizePolicy(sizePolicy4)
self.gridLayout_6 = QGridLayout(self.groupBox_6)
self.gridLayout_6.setObjectName(u"gridLayout_6")
self.gridLayout_6.setContentsMargins(2, 2, 2, 2)
self.pb_edt_sktch = QPushButton(self.groupBox_6)
self.pb_edt_sktch.setObjectName(u"pb_edt_sktch")
self.gridLayout_6.addWidget(self.pb_edt_sktch, 1, 1, 1, 1)
self.pb_nw_sktch = QPushButton(self.groupBox_6)
self.pb_nw_sktch.setObjectName(u"pb_nw_sktch")
self.gridLayout_6.addWidget(self.pb_nw_sktch, 1, 0, 1, 1)
self.pb_del_sketch = QPushButton(self.groupBox_6)
self.pb_del_sketch.setObjectName(u"pb_del_sketch")
self.gridLayout_6.addWidget(self.pb_del_sketch, 1, 2, 1, 1)
self.verticalLayout_7.addWidget(self.groupBox_6)
self.gridLayout.addWidget(self.groupBox_11, 6, 0, 6, 1)
self.assembly_tools = QGroupBox(self.centralwidget)
self.assembly_tools.setObjectName(u"assembly_tools")
self.assembly_tools.setMinimumSize(QSize(0, 50))
self.gridLayout_12 = QGridLayout(self.assembly_tools)
self.gridLayout_12.setObjectName(u"gridLayout_12")
self.pb_compo_to_assembly = QPushButton(self.assembly_tools)
self.pb_compo_to_assembly.setObjectName(u"pb_compo_to_assembly")
self.pb_compo_to_assembly.setMinimumSize(QSize(50, 50))
self.pb_compo_to_assembly.setMaximumSize(QSize(50, 50))
self.gridLayout_12.addWidget(self.pb_compo_to_assembly, 0, 0, 1, 1)
self.pb_remove_compo_from_assembly = QPushButton(self.assembly_tools)
self.pb_remove_compo_from_assembly.setObjectName(u"pb_remove_compo_from_assembly")
self.pb_remove_compo_from_assembly.setEnabled(True)
sizePolicy4.setHeightForWidth(self.pb_remove_compo_from_assembly.sizePolicy().hasHeightForWidth())
self.pb_remove_compo_from_assembly.setSizePolicy(sizePolicy4)
self.pb_remove_compo_from_assembly.setMinimumSize(QSize(50, 50))
self.pb_remove_compo_from_assembly.setMaximumSize(QSize(50, 50))
self.pb_remove_compo_from_assembly.setLayoutDirection(Qt.LeftToRight)
self.gridLayout_12.addWidget(self.pb_remove_compo_from_assembly, 0, 1, 1, 1)
self.gridLayout.addWidget(self.assembly_tools, 13, 0, 1, 1)
self.compo_tool_box = QGroupBox(self.centralwidget)
self.compo_tool_box.setObjectName(u"compo_tool_box")
self.compo_tool_box.setMinimumSize(QSize(0, 50))
self.gridLayout_9 = QGridLayout(self.compo_tool_box)
self.gridLayout_9.setObjectName(u"gridLayout_9")
self.pb_new_compo = QPushButton(self.compo_tool_box)
self.pb_new_compo.setObjectName(u"pb_new_compo")
self.pb_new_compo.setMinimumSize(QSize(50, 50))
self.pb_new_compo.setMaximumSize(QSize(50, 50))
self.gridLayout_9.addWidget(self.pb_new_compo, 0, 0, 1, 1)
self.pb_del_compo = QPushButton(self.compo_tool_box)
self.pb_del_compo.setObjectName(u"pb_del_compo")
self.pb_del_compo.setEnabled(True)
sizePolicy4.setHeightForWidth(self.pb_del_compo.sizePolicy().hasHeightForWidth())
self.pb_del_compo.setSizePolicy(sizePolicy4)
self.pb_del_compo.setMinimumSize(QSize(50, 50))
self.pb_del_compo.setMaximumSize(QSize(50, 50))
self.pb_del_compo.setLayoutDirection(Qt.LeftToRight)
self.gridLayout_9.addWidget(self.pb_del_compo, 0, 1, 1, 1)
self.gridLayout.addWidget(self.compo_tool_box, 12, 0, 1, 1)
self.compo_box = QGroupBox(self.centralwidget)
self.compo_box.setObjectName(u"compo_box")
self.compo_box.setMinimumSize(QSize(0, 50))
self.gridLayout.addWidget(self.compo_box, 12, 1, 1, 2)
self.groupBox_12 = QGroupBox(self.centralwidget)
self.groupBox_12.setObjectName(u"groupBox_12")
sizePolicy2.setHeightForWidth(self.groupBox_12.sizePolicy().hasHeightForWidth())
self.groupBox_12.setSizePolicy(sizePolicy2)
self.groupBox_12.setMaximumSize(QSize(200, 16777215))
self.verticalLayout_8 = QVBoxLayout(self.groupBox_12)
self.verticalLayout_8.setObjectName(u"verticalLayout_8")
self.verticalLayout_8.setContentsMargins(5, 5, 5, 5)
self.connection_list = QListWidget(self.groupBox_12)
self.connection_list.setObjectName(u"connection_list")
self.connection_list.setSelectionRectVisible(True)
self.verticalLayout_8.addWidget(self.connection_list)
self.groupBox_13 = QGroupBox(self.groupBox_12)
self.groupBox_13.setObjectName(u"groupBox_13")
sizePolicy4.setHeightForWidth(self.groupBox_13.sizePolicy().hasHeightForWidth())
self.groupBox_13.setSizePolicy(sizePolicy4)
self.groupBox_13.setMaximumSize(QSize(200, 16777215))
self.gridLayout_13 = QGridLayout(self.groupBox_13)
self.gridLayout_13.setObjectName(u"gridLayout_13")
self.gridLayout_13.setContentsMargins(2, 2, 2, 2)
self.pb_del_connection = QPushButton(self.groupBox_13)
self.pb_del_connection.setObjectName(u"pb_del_connection")
self.gridLayout_13.addWidget(self.pb_del_connection, 0, 2, 1, 1)
self.pb_update_connection = QPushButton(self.groupBox_13)
self.pb_update_connection.setObjectName(u"pb_update_connection")
self.gridLayout_13.addWidget(self.pb_update_connection, 0, 0, 1, 1)
self.pb_edt_sktch_4 = QPushButton(self.groupBox_13)
self.pb_edt_sktch_4.setObjectName(u"pb_edt_sktch_4")
self.gridLayout_13.addWidget(self.pb_edt_sktch_4, 0, 1, 1, 1)
self.verticalLayout_8.addWidget(self.groupBox_13)
self.gridLayout.addWidget(self.groupBox_12, 6, 3, 6, 1)
self.groupBox_10 = QGroupBox(self.centralwidget)
self.groupBox_10.setObjectName(u"groupBox_10")
sizePolicy2.setHeightForWidth(self.groupBox_10.sizePolicy().hasHeightForWidth())
self.groupBox_10.setSizePolicy(sizePolicy2)
self.groupBox_10.setMaximumSize(QSize(200, 16777215))
self.verticalLayout_6 = QVBoxLayout(self.groupBox_10)
self.verticalLayout_6.setObjectName(u"verticalLayout_6")
self.verticalLayout_6.setContentsMargins(5, 5, 5, 5)
self.body_list = QListWidget(self.groupBox_10)
self.body_list.setObjectName(u"body_list")
self.body_list.setSelectionRectVisible(True)
self.verticalLayout_6.addWidget(self.body_list)
self.groupBox_8 = QGroupBox(self.groupBox_10)
self.groupBox_8.setObjectName(u"groupBox_8")
sizePolicy4.setHeightForWidth(self.groupBox_8.sizePolicy().hasHeightForWidth())
self.groupBox_8.setSizePolicy(sizePolicy4)
self.groupBox_8.setMaximumSize(QSize(200, 16777215))
self.gridLayout_8 = QGridLayout(self.groupBox_8)
self.gridLayout_8.setObjectName(u"gridLayout_8")
self.gridLayout_8.setContentsMargins(2, 2, 2, 2)
self.pb_del_body = QPushButton(self.groupBox_8)
self.pb_del_body.setObjectName(u"pb_del_body")
self.gridLayout_8.addWidget(self.pb_del_body, 0, 2, 1, 1)
self.pb_update_body = QPushButton(self.groupBox_8)
self.pb_update_body.setObjectName(u"pb_update_body")
self.gridLayout_8.addWidget(self.pb_update_body, 0, 0, 1, 1)
self.pb_edt_sktch_3 = QPushButton(self.groupBox_8)
self.pb_edt_sktch_3.setObjectName(u"pb_edt_sktch_3")
self.gridLayout_8.addWidget(self.pb_edt_sktch_3, 0, 1, 1, 1)
self.verticalLayout_6.addWidget(self.groupBox_8)
self.gridLayout.addWidget(self.groupBox_10, 3, 3, 3, 1)
self.groupBox_2 = QGroupBox(self.centralwidget)
self.groupBox_2.setObjectName(u"groupBox_2")
sizePolicy4.setHeightForWidth(self.groupBox_2.sizePolicy().hasHeightForWidth())
self.groupBox_2.setSizePolicy(sizePolicy4)
self.groupBox_2.setMaximumSize(QSize(200, 16777215))
self.gridLayout_2 = QGridLayout(self.groupBox_2)
self.gridLayout_2.setObjectName(u"gridLayout_2")
self.gridLayout_2.setContentsMargins(10, -1, -1, -1)
self.pb_arc_tool = QPushButton(self.groupBox_2)
self.pb_arc_tool.setObjectName(u"pb_arc_tool")
self.pb_arc_tool.setCheckable(True)
self.gridLayout_2.addWidget(self.pb_arc_tool, 2, 0, 1, 1)
self.pb_rectool = QPushButton(self.groupBox_2)
self.pb_rectool.setObjectName(u"pb_rectool")
self.pb_rectool.setCheckable(True)
self.pb_rectool.setAutoExclusive(False)
self.gridLayout_2.addWidget(self.pb_rectool, 0, 1, 1, 1)
self.pb_circtool = QPushButton(self.groupBox_2)
self.pb_circtool.setObjectName(u"pb_circtool")
self.pb_circtool.setCheckable(True)
self.pb_circtool.setAutoExclusive(False)
self.gridLayout_2.addWidget(self.pb_circtool, 1, 0, 1, 1, Qt.AlignTop)
self.pb_enable_construct = QPushButton(self.groupBox_2)
self.pb_enable_construct.setObjectName(u"pb_enable_construct")
self.pb_enable_construct.setCheckable(True)
self.gridLayout_2.addWidget(self.pb_enable_construct, 4, 0, 1, 1)
self.pb_enable_snap = QPushButton(self.groupBox_2)
self.pb_enable_snap.setObjectName(u"pb_enable_snap")
self.pb_enable_snap.setIconSize(QSize(13, 16))
self.pb_enable_snap.setCheckable(True)
self.pb_enable_snap.setChecked(True)
self.gridLayout_2.addWidget(self.pb_enable_snap, 4, 1, 1, 1)
self.pb_linetool = QPushButton(self.groupBox_2)
self.pb_linetool.setObjectName(u"pb_linetool")
self.pb_linetool.setCheckable(True)
self.pb_linetool.setAutoExclusive(False)
self.gridLayout_2.addWidget(self.pb_linetool, 0, 0, 1, 1)
self.pb_slotool = QPushButton(self.groupBox_2)
self.pb_slotool.setObjectName(u"pb_slotool")
self.pb_slotool.setCheckable(True)
self.pb_slotool.setAutoExclusive(False)
self.gridLayout_2.addWidget(self.pb_slotool, 1, 1, 1, 1, Qt.AlignTop)
self.line = QFrame(self.groupBox_2)
self.line.setObjectName(u"line")
self.line.setFrameShape(QFrame.Shape.HLine)
self.line.setFrameShadow(QFrame.Shadow.Sunken)
self.gridLayout_2.addWidget(self.line, 3, 0, 1, 2)
self.pb_offset_tool = QPushButton(self.groupBox_2)
self.pb_offset_tool.setObjectName(u"pb_offset_tool")
self.gridLayout_2.addWidget(self.pb_offset_tool, 2, 1, 1, 1)
self.gridLayout.addWidget(self.groupBox_2, 1, 0, 1, 1)
self.groupBox_3 = QGroupBox(self.centralwidget)
self.groupBox_3.setObjectName(u"groupBox_3")
sizePolicy4.setHeightForWidth(self.groupBox_3.sizePolicy().hasHeightForWidth())
self.groupBox_3.setSizePolicy(sizePolicy4)
self.groupBox_3.setMaximumSize(QSize(200, 16777213))
self.gridLayout_4 = QGridLayout(self.groupBox_3)
self.gridLayout_4.setObjectName(u"gridLayout_4")
self.pb_con_ptpt = QPushButton(self.groupBox_3)
self.pb_con_ptpt.setObjectName(u"pb_con_ptpt")
self.pb_con_ptpt.setCheckable(True)
self.pb_con_ptpt.setAutoExclusive(False)
self.gridLayout_4.addWidget(self.pb_con_ptpt, 1, 0, 1, 1)
self.pb_con_vert = QPushButton(self.groupBox_3)
self.pb_con_vert.setObjectName(u"pb_con_vert")
self.pb_con_vert.setCheckable(True)
self.pb_con_vert.setAutoExclusive(False)
self.gridLayout_4.addWidget(self.pb_con_vert, 3, 1, 1, 1)
self.pb_con_sym = QPushButton(self.groupBox_3)
self.pb_con_sym.setObjectName(u"pb_con_sym")
self.pb_con_sym.setCheckable(True)
self.pb_con_sym.setAutoExclusive(False)
self.gridLayout_4.addWidget(self.pb_con_sym, 4, 1, 1, 1)
self.pb_con_mid = QPushButton(self.groupBox_3)
self.pb_con_mid.setObjectName(u"pb_con_mid")
self.pb_con_mid.setCheckable(True)
self.pb_con_mid.setAutoExclusive(False)
self.gridLayout_4.addWidget(self.pb_con_mid, 2, 0, 1, 1)
self.pb_con_line = QPushButton(self.groupBox_3)
self.pb_con_line.setObjectName(u"pb_con_line")
self.pb_con_line.setCheckable(True)
self.pb_con_line.setAutoExclusive(False)
self.gridLayout_4.addWidget(self.pb_con_line, 1, 1, 1, 1)
self.pb_con_horiz = QPushButton(self.groupBox_3)
self.pb_con_horiz.setObjectName(u"pb_con_horiz")
self.pb_con_horiz.setCheckable(True)
self.pb_con_horiz.setAutoExclusive(False)
self.gridLayout_4.addWidget(self.pb_con_horiz, 3, 0, 1, 1)
self.pb_con_dist = QPushButton(self.groupBox_3)
self.pb_con_dist.setObjectName(u"pb_con_dist")
self.pb_con_dist.setCheckable(True)
self.pb_con_dist.setAutoExclusive(False)
self.pb_con_dist.setAutoRepeatDelay(297)
self.gridLayout_4.addWidget(self.pb_con_dist, 4, 0, 1, 1)
self.pb_con_perp = QPushButton(self.groupBox_3)
self.pb_con_perp.setObjectName(u"pb_con_perp")
self.pb_con_perp.setCheckable(True)
self.pb_con_perp.setAutoExclusive(False)
self.gridLayout_4.addWidget(self.pb_con_perp, 2, 1, 1, 1)
self.pb_con_diameter = QPushButton(self.groupBox_3)
self.pb_con_diameter.setObjectName(u"pb_con_diameter")
self.gridLayout_4.addWidget(self.pb_con_diameter, 5, 0, 1, 1)
self.gridLayout.addWidget(self.groupBox_3, 2, 0, 1, 1)
self.groupBox_5 = QGroupBox(self.centralwidget)
self.groupBox_5.setObjectName(u"groupBox_5")
sizePolicy4.setHeightForWidth(self.groupBox_5.sizePolicy().hasHeightForWidth())
self.groupBox_5.setSizePolicy(sizePolicy4)
self.gridLayout_11 = QGridLayout(self.groupBox_5)
self.gridLayout_11.setObjectName(u"gridLayout_11")
self.gridLayout_11.setContentsMargins(12, 12, 12, 12)
self.label = QLabel(self.groupBox_5)
self.label.setObjectName(u"label")
self.gridLayout_11.addWidget(self.label, 5, 0, 1, 1)
self.pb_snap_vert = QPushButton(self.groupBox_5)
self.pb_snap_vert.setObjectName(u"pb_snap_vert")
self.pb_snap_vert.setCheckable(True)
self.pb_snap_vert.setAutoExclusive(False)
self.gridLayout_11.addWidget(self.pb_snap_vert, 2, 1, 1, 1)
self.line_2 = QFrame(self.groupBox_5)
self.line_2.setObjectName(u"line_2")
self.line_2.setFrameShape(QFrame.Shape.HLine)
self.line_2.setFrameShadow(QFrame.Shadow.Sunken)
self.gridLayout_11.addWidget(self.line_2, 4, 0, 1, 2)
self.label_2 = QLabel(self.groupBox_5)
self.label_2.setObjectName(u"label_2")
self.gridLayout_11.addWidget(self.label_2, 5, 1, 1, 1)
self.spinbox_snap_distance = QSpinBox(self.groupBox_5)
self.spinbox_snap_distance.setObjectName(u"spinbox_snap_distance")
self.spinbox_snap_distance.setMaximum(30)
self.spinbox_snap_distance.setValue(10)
self.gridLayout_11.addWidget(self.spinbox_snap_distance, 6, 0, 1, 1)
self.pushButton_7 = QPushButton(self.groupBox_5)
self.pushButton_7.setObjectName(u"pushButton_7")
self.pushButton_7.setCheckable(True)
self.pushButton_7.setAutoExclusive(False)
self.gridLayout_11.addWidget(self.pushButton_7, 3, 0, 1, 1)
self.pb_snap_horiz = QPushButton(self.groupBox_5)
self.pb_snap_horiz.setObjectName(u"pb_snap_horiz")
self.pb_snap_horiz.setCheckable(True)
self.pb_snap_horiz.setAutoExclusive(False)
self.gridLayout_11.addWidget(self.pb_snap_horiz, 2, 0, 1, 1)
self.spinbox_angle_steps = QSpinBox(self.groupBox_5)
self.spinbox_angle_steps.setObjectName(u"spinbox_angle_steps")
self.spinbox_angle_steps.setMaximum(180)
self.spinbox_angle_steps.setValue(15)
self.gridLayout_11.addWidget(self.spinbox_angle_steps, 6, 1, 1, 1)
self.pushButton_8 = QPushButton(self.groupBox_5)
self.pushButton_8.setObjectName(u"pushButton_8")
self.pushButton_8.setCheckable(True)
self.pushButton_8.setAutoExclusive(False)
self.gridLayout_11.addWidget(self.pushButton_8, 0, 0, 1, 1)
self.pb_snap_midp = QPushButton(self.groupBox_5)
self.pb_snap_midp.setObjectName(u"pb_snap_midp")
self.pb_snap_midp.setCheckable(True)
self.pb_snap_midp.setAutoExclusive(False)
self.gridLayout_11.addWidget(self.pb_snap_midp, 0, 1, 1, 1)
self.pb_snap_angle = QPushButton(self.groupBox_5)
self.pb_snap_angle.setObjectName(u"pb_snap_angle")
self.pb_snap_angle.setCheckable(True)
self.pb_snap_angle.setAutoExclusive(False)
self.gridLayout_11.addWidget(self.pb_snap_angle, 3, 1, 1, 1)
self.gridLayout.addWidget(self.groupBox_5, 3, 0, 1, 1)
self.groupBox = QGroupBox(self.centralwidget)
self.groupBox.setObjectName(u"groupBox")
self.gridLayout_3 = QGridLayout(self.groupBox)
self.gridLayout_3.setObjectName(u"gridLayout_3")
self.pb_revop = QPushButton(self.groupBox)
self.pb_revop.setObjectName(u"pb_revop")
self.gridLayout_3.addWidget(self.pb_revop, 2, 1, 1, 1)
self.pb_extrdop = QPushButton(self.groupBox)
self.pb_extrdop.setObjectName(u"pb_extrdop")
self.gridLayout_3.addWidget(self.pb_extrdop, 0, 0, 1, 1)
self.pb_arrayop = QPushButton(self.groupBox)
self.pb_arrayop.setObjectName(u"pb_arrayop")
self.gridLayout_3.addWidget(self.pb_arrayop, 2, 0, 1, 1)
self.pb_cutop = QPushButton(self.groupBox)
self.pb_cutop.setObjectName(u"pb_cutop")
self.gridLayout_3.addWidget(self.pb_cutop, 0, 1, 1, 1)
self.pb_combop = QPushButton(self.groupBox)
self.pb_combop.setObjectName(u"pb_combop")
self.gridLayout_3.addWidget(self.pb_combop, 1, 0, 1, 1)
self.pb_moveop = QPushButton(self.groupBox)
self.pb_moveop.setObjectName(u"pb_moveop")
self.gridLayout_3.addWidget(self.pb_moveop, 1, 1, 1, 1)
self.gridLayout.addWidget(self.groupBox, 0, 3, 1, 1)
self.groupBox_4 = QGroupBox(self.centralwidget)
self.groupBox_4.setObjectName(u"groupBox_4")
self.verticalLayout_2 = QVBoxLayout(self.groupBox_4)
self.verticalLayout_2.setObjectName(u"verticalLayout_2")
self.pushButton_2 = QPushButton(self.groupBox_4)
self.pushButton_2.setObjectName(u"pushButton_2")
self.verticalLayout_2.addWidget(self.pushButton_2)
self.pb_export_step = QPushButton(self.groupBox_4)
self.pb_export_step.setObjectName(u"pb_export_step")
self.verticalLayout_2.addWidget(self.pb_export_step)
self.pb_export_iges = QPushButton(self.groupBox_4)
self.pb_export_iges.setObjectName(u"pb_export_iges")
self.verticalLayout_2.addWidget(self.pb_export_iges)
self.gridLayout.addWidget(self.groupBox_4, 2, 3, 1, 1)
fluencyCAD.setCentralWidget(self.centralwidget)
self.menubar = QMenuBar(fluencyCAD)
self.menubar.setObjectName(u"menubar")
self.menubar.setGeometry(QRect(0, 0, 2359, 24))
self.menuFile = QMenu(self.menubar)
self.menuFile.setObjectName(u"menuFile")
self.menuSettings = QMenu(self.menubar)
self.menuSettings.setObjectName(u"menuSettings")
fluencyCAD.setMenuBar(self.menubar)
self.statusbar = QStatusBar(fluencyCAD)
self.statusbar.setObjectName(u"statusbar")
fluencyCAD.setStatusBar(self.statusbar)
self.menubar.addAction(self.menuFile.menuAction())
self.menubar.addAction(self.menuSettings.menuAction())
self.menuFile.addAction(self.actionNew_Project)
self.menuFile.addAction(self.actionOpen_Project)
self.menuFile.addAction(self.actionSave_Project)
self.menuFile.addAction(self.actionSave_Project_As)
self.menuFile.addSeparator()
self.menuFile.addAction(self.actionImport_File)
self.menuFile.addSeparator()
self.menuFile.addAction(self.actionExport_Step)
self.menuFile.addAction(self.actionExport_Iges)
self.menuFile.addAction(self.actionExport_Stl)
self.menuFile.addSeparator()
self.menuFile.addAction(self.actionExit)
self.retranslateUi(fluencyCAD)
self.InputTab.setCurrentIndex(0)
QMetaObject.connectSlotsByName(fluencyCAD)
# setupUi
def retranslateUi(self, fluencyCAD):
fluencyCAD.setWindowTitle(QCoreApplication.translate("fluencyCAD", u"fluencyCAD", None))
self.actionNew_Project.setText(QCoreApplication.translate("fluencyCAD", u"New Project", None))
#if QT_CONFIG(shortcut)
self.actionNew_Project.setShortcut(QCoreApplication.translate("fluencyCAD", u"Ctrl+N", None))
#endif // QT_CONFIG(shortcut)
self.actionOpen_Project.setText(QCoreApplication.translate("fluencyCAD", u"Open Project...", None))
#if QT_CONFIG(shortcut)
self.actionOpen_Project.setShortcut(QCoreApplication.translate("fluencyCAD", u"Ctrl+O", None))
#endif // QT_CONFIG(shortcut)
self.actionSave_Project.setText(QCoreApplication.translate("fluencyCAD", u"Save Project", None))
#if QT_CONFIG(shortcut)
self.actionSave_Project.setShortcut(QCoreApplication.translate("fluencyCAD", u"Ctrl+S", None))
#endif // QT_CONFIG(shortcut)
self.actionSave_Project_As.setText(QCoreApplication.translate("fluencyCAD", u"Save Project As...", None))
#if QT_CONFIG(shortcut)
self.actionSave_Project_As.setShortcut(QCoreApplication.translate("fluencyCAD", u"Ctrl+Shift+S", None))
#endif // QT_CONFIG(shortcut)
self.actionImport_File.setText(QCoreApplication.translate("fluencyCAD", u"Import STEP/IGES...", None))
self.actionExport_Step.setText(QCoreApplication.translate("fluencyCAD", u"Export STEP...", None))
self.actionExport_Iges.setText(QCoreApplication.translate("fluencyCAD", u"Export IGES...", None))
self.actionExport_Stl.setText(QCoreApplication.translate("fluencyCAD", u"Export STL...", None))
self.actionExit.setText(QCoreApplication.translate("fluencyCAD", u"Exit", None))
#if QT_CONFIG(shortcut)
self.actionExit.setShortcut(QCoreApplication.translate("fluencyCAD", u"Ctrl+Q", None))
#endif // QT_CONFIG(shortcut)
self.InputTab.setTabText(self.InputTab.indexOf(self.sketch_tab), QCoreApplication.translate("fluencyCAD", u"Sketch", None))
self.groupBox_7.setTitle(QCoreApplication.translate("fluencyCAD", u"Executive", None))
self.pushButton_5.setText(QCoreApplication.translate("fluencyCAD", u"Load Code", None))
self.pushButton_4.setText(QCoreApplication.translate("fluencyCAD", u"Save code", None))
self.pb_apply_code.setText(QCoreApplication.translate("fluencyCAD", u"Apply Code", None))
self.pushButton.setText(QCoreApplication.translate("fluencyCAD", u"Delete Code", None))
self.InputTab.setTabText(self.InputTab.indexOf(self.code_tab), QCoreApplication.translate("fluencyCAD", u"Code", None))
self.groupBox_9.setTitle(QCoreApplication.translate("fluencyCAD", u"Workplanes", None))
#if QT_CONFIG(tooltip)
self.pb_origin_wp.setToolTip(QCoreApplication.translate("fluencyCAD", u"<W>orking Plane at 0, 0, 0", None))
#endif // QT_CONFIG(tooltip)
self.pb_origin_wp.setText(QCoreApplication.translate("fluencyCAD", u"WP Origin", None))
#if QT_CONFIG(shortcut)
self.pb_origin_wp.setShortcut(QCoreApplication.translate("fluencyCAD", u"W", None))
#endif // QT_CONFIG(shortcut)
#if QT_CONFIG(tooltip)
self.pb_origin_face.setToolTip(QCoreApplication.translate("fluencyCAD", u"Working Plane >P<rojection at selected edges face", None))
#endif // QT_CONFIG(tooltip)
self.pb_origin_face.setText(QCoreApplication.translate("fluencyCAD", u" WP Face", None))
#if QT_CONFIG(shortcut)
self.pb_origin_face.setShortcut(QCoreApplication.translate("fluencyCAD", u"P", None))
#endif // QT_CONFIG(shortcut)
#if QT_CONFIG(tooltip)
self.pb_flip_face.setToolTip(QCoreApplication.translate("fluencyCAD", u"Flip >N<ormal of projected mesh.", None))
#endif // QT_CONFIG(tooltip)
self.pb_flip_face.setText(QCoreApplication.translate("fluencyCAD", u"WP Flip", None))
#if QT_CONFIG(shortcut)
self.pb_flip_face.setShortcut(QCoreApplication.translate("fluencyCAD", u"N", None))
#endif // QT_CONFIG(shortcut)
#if QT_CONFIG(tooltip)
self.pb_underlay.setToolTip(QCoreApplication.translate("fluencyCAD", u"Show / hide the construction lines projected from the source face", None))
#endif // QT_CONFIG(tooltip)
self.pb_underlay.setText(QCoreApplication.translate("fluencyCAD", u"Underlay", None))
#if QT_CONFIG(tooltip)
self.pb_clr_face.setToolTip(QCoreApplication.translate("fluencyCAD", u"Forget the picked source face (keep the workplane)", None))
#endif // QT_CONFIG(tooltip)
self.pb_clr_face.setText(QCoreApplication.translate("fluencyCAD", u"ClrFace", None))
#if QT_CONFIG(tooltip)
self.pb_to_sketch.setToolTip(QCoreApplication.translate("fluencyCAD", u"Convert projected construction lines into real sketch geometry", None))
#endif // QT_CONFIG(tooltip)
self.pb_to_sketch.setText(QCoreApplication.translate("fluencyCAD", u"ToSketch", None))
#if QT_CONFIG(tooltip)
self.pb_wp_new.setToolTip(QCoreApplication.translate("fluencyCAD", u"Create a new independent workplane (datum plane)", None))
#endif // QT_CONFIG(tooltip)
self.pb_wp_new.setText(QCoreApplication.translate("fluencyCAD", u"WP New", None))
#if QT_CONFIG(shortcut)
self.pb_wp_new.setShortcut(QCoreApplication.translate("fluencyCAD", u"Shift+W", None))
#endif // QT_CONFIG(shortcut)
self.assembly_box.setTitle(QCoreApplication.translate("fluencyCAD", u"Assembly", None))
self.joint_tools.setTitle(QCoreApplication.translate("fluencyCAD", u"Joint Tools", None))
self.pb_remove_connector.setText(QCoreApplication.translate("fluencyCAD", u"- Cnct", None))
self.pb_add_connector.setText(QCoreApplication.translate("fluencyCAD", u"+ Cnct", None))
self.pb_add_connector_2.setText(QCoreApplication.translate("fluencyCAD", u"+Jnt", None))
self.pb_add_connector_3.setText(QCoreApplication.translate("fluencyCAD", u"-Jnt", None))
self.gl_box.setTitle(QCoreApplication.translate("fluencyCAD", u"Model Viewer", None))
self.groupBox_11.setTitle(QCoreApplication.translate("fluencyCAD", u"Sketch", None))
self.groupBox_6.setTitle(QCoreApplication.translate("fluencyCAD", u"Tools", None))
self.pb_edt_sktch.setText(QCoreApplication.translate("fluencyCAD", u"Edt", None))
self.pb_nw_sktch.setText(QCoreApplication.translate("fluencyCAD", u"Add", None))
self.pb_del_sketch.setText(QCoreApplication.translate("fluencyCAD", u"Del", None))
self.assembly_tools.setTitle(QCoreApplication.translate("fluencyCAD", u"Assembly Tools", None))
self.pb_compo_to_assembly.setText(QCoreApplication.translate("fluencyCAD", u"Add", None))
self.pb_remove_compo_from_assembly.setText(QCoreApplication.translate("fluencyCAD", u"Rem", None))
self.compo_tool_box.setTitle(QCoreApplication.translate("fluencyCAD", u"Component Tools", None))
self.pb_new_compo.setText(QCoreApplication.translate("fluencyCAD", u"New", None))
self.pb_del_compo.setText(QCoreApplication.translate("fluencyCAD", u"Del", None))
self.compo_box.setTitle(QCoreApplication.translate("fluencyCAD", u"Components", None))
self.groupBox_12.setTitle(QCoreApplication.translate("fluencyCAD", u"Component Connections", None))
self.groupBox_13.setTitle(QCoreApplication.translate("fluencyCAD", u"Tools", None))
self.pb_del_connection.setText(QCoreApplication.translate("fluencyCAD", u"Del", None))
self.pb_update_connection.setText(QCoreApplication.translate("fluencyCAD", u"Upd", None))
self.pb_edt_sktch_4.setText(QCoreApplication.translate("fluencyCAD", u"Nothing", None))
self.groupBox_10.setTitle(QCoreApplication.translate("fluencyCAD", u"Bodys / Operations", None))
self.groupBox_8.setTitle(QCoreApplication.translate("fluencyCAD", u"Tools", None))
self.pb_del_body.setText(QCoreApplication.translate("fluencyCAD", u"Del", None))
self.pb_update_body.setText(QCoreApplication.translate("fluencyCAD", u"Upd", None))
self.pb_edt_sktch_3.setText(QCoreApplication.translate("fluencyCAD", u"Nothing", None))
self.groupBox_2.setTitle(QCoreApplication.translate("fluencyCAD", u"Drawing", None))
self.pb_arc_tool.setText(QCoreApplication.translate("fluencyCAD", u"Arc", None))
self.pb_rectool.setText(QCoreApplication.translate("fluencyCAD", u"Rctgl", None))
self.pb_circtool.setText(QCoreApplication.translate("fluencyCAD", u"Circle", None))
self.pb_enable_construct.setText(QCoreApplication.translate("fluencyCAD", u"Cstrct", None))
self.pb_enable_snap.setText(QCoreApplication.translate("fluencyCAD", u"Snap", None))
self.pb_linetool.setText(QCoreApplication.translate("fluencyCAD", u"Line", None))
#if QT_CONFIG(shortcut)
self.pb_linetool.setShortcut(QCoreApplication.translate("fluencyCAD", u"S", None))
#endif // QT_CONFIG(shortcut)
self.pb_slotool.setText(QCoreApplication.translate("fluencyCAD", u"Slot", None))
#if QT_CONFIG(tooltip)
self.pb_offset_tool.setToolTip(QCoreApplication.translate("fluencyCAD", u"Offset selected sketch face (duplicate + offset boundary)", None))
#endif // QT_CONFIG(tooltip)
self.pb_offset_tool.setText(QCoreApplication.translate("fluencyCAD", u"Offst", None))
self.groupBox_3.setTitle(QCoreApplication.translate("fluencyCAD", u"Constrain", None))
#if QT_CONFIG(tooltip)
self.pb_con_ptpt.setToolTip(QCoreApplication.translate("fluencyCAD", u"Poin to Point Constrain", None))
#endif // QT_CONFIG(tooltip)
self.pb_con_ptpt.setText(QCoreApplication.translate("fluencyCAD", u"Pt_Pt", None))
#if QT_CONFIG(tooltip)
self.pb_con_vert.setToolTip(QCoreApplication.translate("fluencyCAD", u"Vertical Constrain", None))
#endif // QT_CONFIG(tooltip)
self.pb_con_vert.setText(QCoreApplication.translate("fluencyCAD", u"Vert", None))
self.pb_con_sym.setText(QCoreApplication.translate("fluencyCAD", u"Symetrc", None))
#if QT_CONFIG(tooltip)
self.pb_con_mid.setToolTip(QCoreApplication.translate("fluencyCAD", u"Point to Middle Point Constrain", None))
#endif // QT_CONFIG(tooltip)
self.pb_con_mid.setText(QCoreApplication.translate("fluencyCAD", u"Pt_Mid_L", None))
#if QT_CONFIG(tooltip)
self.pb_con_line.setToolTip(QCoreApplication.translate("fluencyCAD", u"Point to Line Constrain", None))
#endif // QT_CONFIG(tooltip)
self.pb_con_line.setText(QCoreApplication.translate("fluencyCAD", u"Pt_Lne", None))
#if QT_CONFIG(tooltip)
self.pb_con_horiz.setToolTip(QCoreApplication.translate("fluencyCAD", u"Horizontal Constrain ", None))
#endif // QT_CONFIG(tooltip)
self.pb_con_horiz.setText(QCoreApplication.translate("fluencyCAD", u"Horiz", None))
#if QT_CONFIG(tooltip)
self.pb_con_dist.setToolTip(QCoreApplication.translate("fluencyCAD", u"Dimension of Line of Distance from Point to Line", None))
#endif // QT_CONFIG(tooltip)
self.pb_con_dist.setText(QCoreApplication.translate("fluencyCAD", u"Distnce", None))
#if QT_CONFIG(tooltip)
self.pb_con_perp.setToolTip(QCoreApplication.translate("fluencyCAD", u"Constrain Line perpendicular to another line.", None))
#endif // QT_CONFIG(tooltip)
self.pb_con_perp.setText(QCoreApplication.translate("fluencyCAD", u"Perp_Lne", None))
self.pb_con_diameter.setText(QCoreApplication.translate("fluencyCAD", u"Diameter", None))
self.groupBox_5.setTitle(QCoreApplication.translate("fluencyCAD", u"Snapping Points", None))
self.label.setText(QCoreApplication.translate("fluencyCAD", u"Snp Dst", None))
self.pb_snap_vert.setText(QCoreApplication.translate("fluencyCAD", u"Vert", None))
self.label_2.setText(QCoreApplication.translate("fluencyCAD", u"Angl Stps", None))
self.spinbox_snap_distance.setSuffix(QCoreApplication.translate("fluencyCAD", u"mm", None))
self.pushButton_7.setText(QCoreApplication.translate("fluencyCAD", u"Grid", None))
self.pb_snap_horiz.setText(QCoreApplication.translate("fluencyCAD", u"Horiz", None))
self.spinbox_angle_steps.setSuffix(QCoreApplication.translate("fluencyCAD", u"\u00b0", None))
self.pushButton_8.setText(QCoreApplication.translate("fluencyCAD", u"Pnt", None))
self.pb_snap_midp.setText(QCoreApplication.translate("fluencyCAD", u"MidP", None))
self.pb_snap_angle.setText(QCoreApplication.translate("fluencyCAD", u"Angles", None))
self.groupBox.setTitle(QCoreApplication.translate("fluencyCAD", u"Modify", None))
self.pb_revop.setText(QCoreApplication.translate("fluencyCAD", u"Rev", None))
self.pb_extrdop.setText(QCoreApplication.translate("fluencyCAD", u"Extrd", None))
self.pb_arrayop.setText(QCoreApplication.translate("fluencyCAD", u"Arry", None))
self.pb_cutop.setText(QCoreApplication.translate("fluencyCAD", u"Cut", None))
self.pb_combop.setText(QCoreApplication.translate("fluencyCAD", u"Comb", None))
self.pb_moveop.setText(QCoreApplication.translate("fluencyCAD", u"Mve", None))
self.groupBox_4.setTitle(QCoreApplication.translate("fluencyCAD", u"Export", None))
self.pushButton_2.setText(QCoreApplication.translate("fluencyCAD", u"STL", None))
self.pb_export_step.setText(QCoreApplication.translate("fluencyCAD", u"STEP", None))
self.pb_export_iges.setText(QCoreApplication.translate("fluencyCAD", u"IGES", None))
self.menuFile.setTitle(QCoreApplication.translate("fluencyCAD", u"File", None))
self.menuSettings.setTitle(QCoreApplication.translate("fluencyCAD", u"Settings", None))
# retranslateUi
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+238 -71
View File
@@ -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
View File
@@ -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)