- Tons of addtions

This commit is contained in:
bklronin
2026-06-28 21:12:34 +02:00
parent 54ac2c098a
commit f8f16ea800
6 changed files with 2147 additions and 259 deletions
+39 -30
View File
@@ -5,14 +5,11 @@
</component>
<component name="ChangeListManager">
<list default="true" id="8f0bafd6-58a0-4b20-aa2b-ddc3ba278873" name="Changes" comment="- Improved sketching">
<change beforePath="$PROJECT_DIR$/.idea/fluency.iml" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/.idea/modules.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/modules.xml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/pyproject.toml" beforeDir="false" afterPath="$PROJECT_DIR$/pyproject.toml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/fluency/geometry_occ/kernel.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/geometry_occ/kernel.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/fluency/geometry_occ/sketch.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/geometry_occ/sketch.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/fluency/main.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/main.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/uv.lock" beforeDir="false" afterPath="$PROJECT_DIR$/uv.lock" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/fluency/rendering/__init__.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/rendering/__init__.py" afterDir="false" />
</list>
<option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" />
@@ -29,7 +26,7 @@
<component name="Git.Settings">
<option name="RECENT_BRANCH_BY_REPOSITORY">
<map>
<entry key="$PROJECT_DIR$" value="structure" />
<entry key="$PROJECT_DIR$" value="single_window" />
</map>
</option>
<option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
@@ -49,31 +46,39 @@
<option name="hideEmptyMiddlePackages" value="true" />
<option name="showLibraryContents" value="true" />
</component>
<component name="PropertiesComponent">{
&quot;keyToString&quot;: {
&quot;Python.2dtest.executor&quot;: &quot;Run&quot;,
&quot;Python.3d_windows.executor&quot;: &quot;Run&quot;,
&quot;Python.Unnamed.executor&quot;: &quot;Run&quot;,
&quot;Python.draw_widget2d.executor&quot;: &quot;Run&quot;,
&quot;Python.draw_widget_solve.executor&quot;: &quot;Run&quot;,
&quot;Python.fluency.executor&quot;: &quot;Run&quot;,
&quot;Python.fluencyb.executor&quot;: &quot;Run&quot;,
&quot;Python.gl_widget.executor&quot;: &quot;Run&quot;,
&quot;Python.main.executor&quot;: &quot;Run&quot;,
&quot;Python.meshtest.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.vtk_widget.executor&quot;: &quot;Run&quot;,
&quot;Python.vulkan.executor&quot;: &quot;Run&quot;,
&quot;RunOnceActivity.OpenProjectViewOnStart&quot;: &quot;true&quot;,
&quot;RunOnceActivity.ShowReadmeOnStart&quot;: &quot;true&quot;,
&quot;RunOnceActivity.git.unshallow&quot;: &quot;true&quot;,
&quot;git-widget-placeholder&quot;: &quot;feature/occ-migration&quot;,
&quot;last_opened_file_path&quot;: &quot;/Volumes/Data_drive/Programming/fluency&quot;,
&quot;node.js.selected.package.tslint&quot;: &quot;(autodetect)&quot;,
&quot;settings.editor.selected.configurable&quot;: &quot;project.propVCSSupport.DirectoryMappings&quot;
<component name="PropertiesComponent"><![CDATA[{
"keyToString": {
"Python.2dtest.executor": "Run",
"Python.3d_windows.executor": "Run",
"Python.Unnamed.executor": "Run",
"Python.draw_widget2d.executor": "Run",
"Python.draw_widget_solve.executor": "Run",
"Python.fluency.executor": "Run",
"Python.fluencyb.executor": "Run",
"Python.gl_widget.executor": "Run",
"Python.kernel.executor": "Run",
"Python.main.executor": "Run",
"Python.meshtest.executor": "Run",
"Python.occ_renderer.executor": "Run",
"Python.side_fluency.executor": "Run",
"Python.simple_mesh.executor": "Run",
"Python.vtk_widget.executor": "Run",
"Python.vulkan.executor": "Run",
"RunOnceActivity.OpenProjectViewOnStart": "true",
"RunOnceActivity.ShowReadmeOnStart": "true",
"RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252": "true",
"RunOnceActivity.git.unshallow": "true",
"RunOnceActivity.typescript.service.memoryLimit.init": "true",
"codeWithMe.voiceChat.enabledByDefault": "false",
"git-widget-placeholder": "feature/occ-migration",
"last_opened_file_path": "/Volumes/Data_drive/Programming/fluency/src/fluency",
"node.js.detected.package.eslint": "true",
"node.js.selected.package.eslint": "(autodetect)",
"node.js.selected.package.tslint": "(autodetect)",
"nodejs_package_manager_path": "npm",
"settings.editor.selected.configurable": "project.propVCSSupport.DirectoryMappings"
}
}</component>
}]]></component>
<component name="QodanaReportsService">
<option name="descriptions">
<ReportDescription localRun="true" path="/private/var/folders/kg/zm48w_r96yb68mlbzvb9gtq40000gn/T/qodana_output/qodana.sarif.json" reportGuid="5f5b823c-c594-48c5-ae1f-062e30303918" reportId="fluency/qodana/2024-02-04" />
@@ -81,6 +86,7 @@
</component>
<component name="RecentsManager">
<key name="CopyFile.RECENT_KEYS">
<recent name="$PROJECT_DIR$/src/fluency" />
<recent name="$PROJECT_DIR$" />
<recent name="$PROJECT_DIR$/drawing_modules" />
<recent name="$PROJECT_DIR$/modules" />
@@ -92,7 +98,7 @@
<component name="SharedIndexes">
<attachedChunks>
<set>
<option value="bundled-python-sdk-4c141bd692a7-e2d783800521-com.jetbrains.pycharm.community.sharedIndexes.bundled-PC-251.26927.90" />
<option value="bundled-python-sdk-c59985aa861c-c2ffad84badb-com.jetbrains.pycharm.pro.sharedIndexes.bundled-PY-261.24374.152" />
</set>
</attachedChunks>
</component>
@@ -260,6 +266,9 @@
<option name="localTasksCounter" value="20" />
<servers />
</component>
<component name="TypeScriptGeneratedFilesManager">
<option name="version" value="3" />
</component>
<component name="Vcs.Log.Tabs.Properties">
<option name="TAB_STATES">
<map>
+23 -8
View File
@@ -157,7 +157,13 @@ class OCGeometryKernel(GeometryKernel):
direction: Tuple[float, float, float] = (0, 0, 1),
symmetric: bool = False,
) -> GeometryObject:
"""Extrude a 2D sketch into a 3D solid."""
"""Extrude a 2D sketch into a 3D solid.
*height* is extruded along *direction* (default +Z). A negative *height*
extrudes in the opposite direction. The *direction* argument is accepted
for API compatibility; currently only the sign of *height* is used for
direction (positive → +Z, negative → -Z).
"""
import cadquery as cq
cq_obj = self._get_cq_obj(sketch)
@@ -604,7 +610,9 @@ class OCGeometryKernel(GeometryKernel):
from OCP.BRep import BRep_Tool
from OCP.TopLoc import TopLoc_Location
mesh = BRepMesh_IncrementalMesh(shape, tolerance, False, 0.5, False)
# Use finer angular deflection (0.15 rad ≈ 24 segments/circle) so
# curved surfaces like cylinders render smoothly instead of faceted.
mesh = BRepMesh_IncrementalMesh(shape, tolerance, False, 0.15, True)
mesh.Perform()
vertices_list: List[List[float]] = []
@@ -612,6 +620,7 @@ class OCGeometryKernel(GeometryKernel):
vertex_offset = 0
from OCP.TopoDS import TopoDS
from OCP.TopAbs import TopAbs_Orientation
explorer = TopExp_Explorer(shape, TopAbs_FACE)
while explorer.More():
@@ -626,15 +635,21 @@ class OCGeometryKernel(GeometryKernel):
vertices_list.append([p.X(), p.Y(), p.Z()])
n_triangles = triangulation.NbTriangles()
# REVERSED faces store triangle winding in the natural (surface)
# orientation — we must flip it so the computed normals point
# outward (away from solid interior). TopAbs_REVERSED = 1.
reverse_winding = face.Orientation() == TopAbs_Orientation.TopAbs_REVERSED
for i in range(1, n_triangles + 1):
tri = triangulation.Triangle(i)
faces_list.append(
[
tri.Value(1) - 1 + vertex_offset,
tri.Value(2) - 1 + vertex_offset,
tri.Value(3) - 1 + vertex_offset,
]
v0, v1, v2 = (
tri.Value(1) - 1 + vertex_offset,
tri.Value(2) - 1 + vertex_offset,
tri.Value(3) - 1 + vertex_offset,
)
if reverse_winding:
# Swap last two vertices to flip winding direction.
v1, v2 = v2, v1
faces_list.append([v0, v1, v2])
vertex_offset += n_vertices
+621 -17
View File
@@ -7,6 +7,7 @@ geometry generation from solved positions.
"""
from typing import List, Tuple, Optional, Dict, Any
import math
import numpy as np
import logging
import re
@@ -55,6 +56,10 @@ class OCCSketch(SketchInterface):
self._circles: Dict[int, Tuple[int, float]] = {}
self._arcs: Dict[int, Any] = {}
self._constraint_count: int = 0
# Re-appliable log of every constraint, so we can rebuild the solver
# after deleting an entity (python_solvespace has no per-entity delete).
# Each entry: {"type": str, "ids": (int, ...), "params": tuple, "labels": set[str]}
self._constraint_log: List[Dict[str, Any]] = []
# Track first point as dragged/fixed for solver stability
self._first_point_id: Optional[int] = None
@@ -212,8 +217,129 @@ class OCCSketch(SketchInterface):
# ─── Constraint methods (actual solver calls) ──────────────────────────
def _add_constraint_record(self) -> None:
def _record_constraint(
self, ctype: str, ids: Tuple[int, ...], params: Tuple = (), labels: Tuple[str, ...] = ()
) -> None:
"""Count and log a constraint so the solver can be rebuilt after deletions."""
self._constraint_count += 1
self._constraint_log.append(
{"type": ctype, "ids": tuple(int(i) for i in ids), "params": tuple(params), "labels": set(labels)}
)
def _apply_constraint_log(self, entry: Dict[str, Any]) -> bool:
"""Re-apply a single logged constraint to the current (rebuilt) solver.
Uses live solver handles looked up by entity id. Returns False silently if
any referenced entity is now gone (pruning should have removed it, but
this is defensive).
"""
ctype = entry["type"]
ids = entry["ids"]
params = entry["params"]
def h(i: int) -> Any:
ent = self._entities.get(i)
return ent.handle if ent is not None else None
if ctype == "coincident":
if h(ids[0]) is None or h(ids[1]) is None:
return False
self._solver.coincident(h(ids[0]), h(ids[1]), self._wp)
elif ctype == "horizontal":
if h(ids[0]) is None:
return False
self._solver.horizontal(h(ids[0]), self._wp)
elif ctype == "vertical":
if h(ids[0]) is None:
return False
self._solver.vertical(h(ids[0]), self._wp)
elif ctype == "distance":
if h(ids[0]) is None or h(ids[1]) is None:
return False
self._solver.distance(h(ids[0]), h(ids[1]), params[0], self._wp)
elif ctype == "angle":
if h(ids[0]) is None or h(ids[1]) is None:
return False
self._solver.angle(h(ids[0]), h(ids[1]), params[0], self._wp)
elif ctype == "parallel":
if h(ids[0]) is None or h(ids[1]) is None:
return False
self._solver.parallel(h(ids[0]), h(ids[1]), self._wp)
elif ctype == "perpendicular":
if h(ids[0]) is None or h(ids[1]) is None:
return False
self._solver.perpendicular(h(ids[0]), h(ids[1]), self._wp)
elif ctype == "midpoint":
if h(ids[0]) is None or h(ids[1]) is None:
return False
self._solver.midpoint(h(ids[0]), h(ids[1]), self._wp)
elif ctype == "tangent":
if h(ids[0]) is None or h(ids[1]) is None:
return False
self._solver.tangent(h(ids[0]), h(ids[1]), self._wp)
elif ctype == "equal":
if h(ids[0]) is None or h(ids[1]) is None:
return False
self._solver.equal(h(ids[0]), h(ids[1]), self._wp)
elif ctype == "fixed":
if h(ids[0]) is None:
return False
self._solver.dragged(h(ids[0]), self._wp)
elif ctype == "symmetric":
if h(ids[0]) is None or h(ids[1]) is None or h(ids[2]) is None:
return False
self._solver.symmetric(h(ids[0]), h(ids[1]), h(ids[2]), self._wp)
elif ctype == "equal_radius":
# tracked only (no solver entity)
pass
else:
return False
return True
def _rebuild_solver(self) -> None:
"""Recreate the SolveSpace system from current points/lines + log.
python_solvespace cannot remove individual entities/constraints, so
after deleting an entity we rebuild the whole system: re-add every
surviving point at its current position (first point re-fixed for
stability), re-add every surviving line, then re-apply the pruned
constraint log. Entity ids are preserved; only solver handles change.
"""
# Snapshot current point positions before resetting the solver.
saved_pos: Dict[int, Tuple[float, float]] = {}
for eid, ent in self._entities.items():
if ent.entity_type == "point" and ent.geometry is not None:
saved_pos[eid] = (float(ent.geometry[0]), float(ent.geometry[1]))
self._solver = SolverSystem()
self._wp = self._solver.create_2d_base()
self._first_point_id = None
# Re-add point entities in id order (preserves first-point-fixed).
for pid in sorted(eid for eid, e in self._entities.items() if e.entity_type == "point"):
ent = self._entities[pid]
x, y = saved_pos.get(pid, (0.0, 0.0))
new_handle = self._solver.add_point_2d(x, y, self._wp)
ent.handle = new_handle
if self._first_point_id is None:
self._first_point_id = pid
self._solver.dragged(new_handle, self._wp)
# Re-add line entities in id order, updating their solver handles.
for lid in sorted(self._lines.keys()):
sid, eid2 = self._lines[lid]
s_ent = self._entities.get(sid)
e_ent = self._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
new_handle = self._solver.add_line_2d(s_ent.handle, e_ent.handle, self._wp)
line_ent = self._entities.get(lid)
if line_ent is not None:
line_ent.handle = new_handle
# Re-apply every surviving logged constraint.
for entry in self._constraint_log:
self._apply_constraint_log(entry)
def constrain_coincident(self, *entities: SketchEntity) -> bool:
"""Make entities coincident via solver."""
@@ -224,7 +350,7 @@ class OCCSketch(SketchInterface):
if e1 is None or e2 is None or e1.handle is None or e2.handle is None:
return False
self._solver.coincident(e1.handle, e2.handle, self._wp)
self._add_constraint_record()
self._record_constraint("coincident", (entities[0].id, entities[1].id))
return True
def constrain_horizontal(self, line: SketchEntity) -> bool:
@@ -233,7 +359,7 @@ class OCCSketch(SketchInterface):
if entity is None or entity.handle is None:
return False
self._solver.horizontal(entity.handle, self._wp)
self._add_constraint_record()
self._record_constraint("horizontal", (line.id,), labels=("hrz",))
if "hrz" not in entity.constraints:
entity.constraints.append("hrz")
return True
@@ -244,7 +370,7 @@ class OCCSketch(SketchInterface):
if entity is None or entity.handle is None:
return False
self._solver.vertical(entity.handle, self._wp)
self._add_constraint_record()
self._record_constraint("vertical", (line.id,), labels=("vrt",))
if "vrt" not in entity.constraints:
entity.constraints.append("vrt")
return True
@@ -258,7 +384,7 @@ class OCCSketch(SketchInterface):
if e1 is None or e2 is None or e1.handle is None or e2.handle is None:
return False
self._solver.distance(e1.handle, e2.handle, distance, self._wp)
self._add_constraint_record()
self._record_constraint("distance", (entity1.id, entity2.id), (distance,))
return True
def constrain_angle(self, line1: SketchEntity, line2: SketchEntity, angle: float) -> bool:
@@ -268,7 +394,7 @@ class OCCSketch(SketchInterface):
if e1 is None or e2 is None or e1.handle is None or e2.handle is None:
return False
self._solver.angle(e1.handle, e2.handle, angle, self._wp)
self._add_constraint_record()
self._record_constraint("angle", (line1.id, line2.id), (angle,))
return True
def constrain_parallel(self, line1: SketchEntity, line2: SketchEntity) -> bool:
@@ -278,7 +404,7 @@ class OCCSketch(SketchInterface):
if e1 is None or e2 is None or e1.handle is None or e2.handle is None:
return False
self._solver.parallel(e1.handle, e2.handle, self._wp)
self._add_constraint_record()
self._record_constraint("parallel", (line1.id, line2.id))
return True
def constrain_perpendicular(self, line1: SketchEntity, line2: SketchEntity) -> bool:
@@ -288,7 +414,7 @@ class OCCSketch(SketchInterface):
if e1 is None or e2 is None or e1.handle is None or e2.handle is None:
return False
self._solver.perpendicular(e1.handle, e2.handle, self._wp)
self._add_constraint_record()
self._record_constraint("perpendicular", (line1.id, line2.id))
return True
def constrain_midpoint(self, point: SketchEntity, line: SketchEntity) -> bool:
@@ -298,7 +424,9 @@ class OCCSketch(SketchInterface):
if pt is None or ln is None or pt.handle is None or ln.handle is None:
return False
self._solver.midpoint(pt.handle, ln.handle, self._wp)
self._add_constraint_record()
self._record_constraint("midpoint", (point.id, line.id), labels=("mid",))
if "mid" not in ln.constraints:
ln.constraints.append("mid")
return True
def constrain_tangent(self, entity1: SketchEntity, entity2: SketchEntity) -> bool:
@@ -308,7 +436,7 @@ class OCCSketch(SketchInterface):
if e1 is None or e2 is None or e1.handle is None or e2.handle is None:
return False
self._solver.tangent(e1.handle, e2.handle, self._wp)
self._add_constraint_record()
self._record_constraint("tangent", (entity1.id, entity2.id))
return True
def constrain_equal_length(self, line1: SketchEntity, line2: SketchEntity) -> bool:
@@ -318,12 +446,12 @@ class OCCSketch(SketchInterface):
if e1 is None or e2 is None or e1.handle is None or e2.handle is None:
return False
self._solver.equal(e1.handle, e2.handle, self._wp)
self._add_constraint_record()
self._record_constraint("equal", (line1.id, line2.id), labels=("eql",))
return True
def constrain_equal_radius(self, circle1: SketchEntity, circle2: SketchEntity) -> bool:
"""Circle equal-radius (tracked only — solver limit)."""
self._add_constraint_record()
self._record_constraint("equal_radius", (circle1.id, circle2.id))
return True
def constrain_fixed(self, entity: SketchEntity) -> bool:
@@ -332,7 +460,7 @@ class OCCSketch(SketchInterface):
if ent is None or ent.handle is None:
return False
self._solver.dragged(ent.handle, self._wp)
self._add_constraint_record()
self._record_constraint("fixed", (entity.id,))
return True
def constrain_symmetric(
@@ -347,9 +475,49 @@ class OCCSketch(SketchInterface):
if e1.handle is None or e2.handle is None or ln.handle is None:
return False
self._solver.symmetric(e1.handle, e2.handle, ln.handle, self._wp)
self._add_constraint_record()
self._record_constraint("symmetric", (entity1.id, entity2.id, line.id))
return True
# ─── Position updates (for moving entities) ──────────────────────────
def set_entity_position(self, entity: SketchEntity, x: float, y: float) -> bool:
"""Move a point entity's position in BOTH the solver (params) and local tracking.
Updating only ``entity.geometry`` is not enough — ``solve()`` reads from
the solver's internal parameter values and would revert the move. We push
the new coordinates into the solver via ``set_params`` so unconstrained
points keep their dragged location and constrained ones are recomputed.
"""
ent = self._entities.get(entity.id)
if ent is None or ent.handle is None:
return False
try:
self._solver.set_params(ent.handle.params, (x, y))
except Exception as e:
logger.debug(f"set_params failed for entity {entity.id}: {e}")
return False
ent.geometry = (x, y)
if entity.id in self._points:
self._points[entity.id] = (x, y)
return True
def set_positions(self, positions: Dict[int, Tuple[float, float]]) -> bool:
"""Bulk-apply new positions for a set of point entities (entity_id -> (x, y))."""
ok = True
for eid, (x, y) in positions.items():
ent = self._entities.get(eid)
if ent is None or ent.handle is None:
continue
try:
self._solver.set_params(ent.handle.params, (x, y))
ent.geometry = (x, y)
if eid in self._points:
self._points[eid] = (x, y)
except Exception as e:
logger.debug(f"set_positions failed for entity {eid}: {e}")
ok = False
return ok
# ─── Solving ───────────────────────────────────────────────────────────
def solve(self) -> bool:
@@ -408,10 +576,20 @@ class OCCSketch(SketchInterface):
# ─── Geometry extraction for operations ────────────────────────────────
def get_geometry(self) -> GeometryObject:
"""Get the solved geometry for operations using CadQuery."""
"""Get the solved geometry for operations using CadQuery.
If the sketch has exactly one detected face (outer boundary + optional
holes) that face is returned as a combined face-with-holes Workplane.
Otherwise falls back to returning a single circle or polygon suitable
for extrude/revolve.
"""
import cadquery as cq
# Check for circles first
faces = self.detect_faces()
if len(faces) == 1:
return self.build_face_geometry(faces[0])
# Fallback: return the first circle, or a polygon, or None.
if self._circles:
for entity_id, (center_id, radius) in self._circles.items():
center_entity = self._entities.get(center_id)
@@ -494,6 +672,295 @@ class OCCSketch(SketchInterface):
return ordered
# ─── Closed-loop / face detection (for region selection + holes) ──────
_SNAP_TOL: float = 1e-4 # world-unit tolerance for snapping line endpoints
def _line_segments(self) -> List[Tuple[Tuple[float, float], Tuple[float, float]]]:
"""Current line segments as world-coordinate tuples (uses solved positions)."""
segs: List[Tuple[Tuple[float, float], Tuple[float, float]]] = []
for line_id, (sid, eid2) in self._lines.items():
s_ent = self._entities.get(sid)
e_ent = self._entities.get(eid2)
if s_ent and e_ent and s_ent.geometry and e_ent.geometry:
segs.append(((float(s_ent.geometry[0]), float(s_ent.geometry[1])),
(float(e_ent.geometry[0]), float(e_ent.geometry[1]))))
return segs
def get_closed_loops(self) -> List[Dict[str, Any]]:
"""Detect closed loops: polygon cycles from connected lines + each circle.
Each loop is one of:
{"type": "polygon", "points": [(x,y), ...]} (closed, last == first)
{"type": "circle", "center": (x,y), "radius": r}
Line endpoint coordinates are snapped to ``_SNAP_TOL`` so a closed
rectangle's four corners join into one cycle even after solver floating
point jitter. Only connected components where every node has degree 2
(a simple closed polyline) are accepted as polygon loops.
"""
loops: List[Dict[str, Any]] = []
segs = self._line_segments()
if segs:
# Snap endpoints to integer-ish keys to group coincident points.
def key(pt):
return (round(pt[0] / self._SNAP_TOL), round(pt[1] / self._SNAP_TOL))
reprs: Dict[Any, Tuple[float, float]] = {} # key -> averaged world pt
edges: List[Tuple[Any, Any]] = []
for p1, p2 in segs:
k1, k2 = key(p1), key(p2)
reprs.setdefault(k1, p1)
reprs.setdefault(k2, p2)
edges.append((k1, k2))
# Undirected adjacency.
adj: Dict[Any, List[Any]] = {}
for a, b in edges:
adj.setdefault(a, []).append(b)
adj.setdefault(b, []).append(a)
# Connected components (each node with degree 2 → closed loop).
seen: set = set()
for start in adj:
if start in seen or len(adj[start]) != 2:
continue
# Walk the component.
comp: List[Any] = []
stack = [start]
comp_seen: set = set()
while stack:
n = stack.pop()
if n in comp_seen:
continue
comp_seen.add(n)
comp.append(n)
for nb in adj.get(n, []):
if nb not in comp_seen:
stack.append(nb)
if all(len(adj[n]) == 2 for n in comp) and len(comp) >= 3:
# Order the cycle by following each node's neighbor not yet visited.
ordered: List[Any] = []
cur = comp[0]
prev = None
for _ in range(len(comp)):
ordered.append(cur)
nbrs = [nb for nb in adj[cur] if nb != prev]
if not nbrs:
break
prev = cur
cur = nbrs[0]
if len(ordered) == len(comp):
pts = [reprs[k] for k in ordered]
pts.append(pts[0])
loops.append({"type": "polygon", "points": pts})
seen |= comp_seen
# Circles are closed loops of their own.
for cid, (center_id, r) in self._circles.items():
c_ent = self._entities.get(center_id)
if c_ent and c_ent.geometry and r > 0:
loops.append({"type": "circle", "center": (float(c_ent.geometry[0]), float(c_ent.geometry[1])),
"radius": float(r)})
return loops
@staticmethod
def _point_in_polygon(pt: Tuple[float, float], poly: List[Tuple[float, float]]) -> bool:
"""Ray-casting point-in-polygon test.
Returns *True* only for strictly interior points. Points on the
boundary (within 1e-9) are considered *outside* so that the outer
boundary of a nested shape doesn't falsely contain another loop whose
representative point happens to land on that boundary.
"""
x, y = pt
eps = 1e-9
n = len(poly)
inside = False
j = n - 1
for i in range(n):
xi, yi = poly[i]
xj, yj = poly[j]
# Point-on-segment test — exclude strict boundary hits.
# First check bounding box of the segment.
if min(xi, xj) - eps <= x <= max(xi, xj) + eps and min(yi, yj) - eps <= y <= max(yi, yj) + eps:
# Check collinearity
cross = (x - xi) * (yj - yi) - (y - yi) * (xj - xi)
if abs(cross) < eps:
return False # on boundary
if ((yi > y) != (yj > y)) and (x < (xj - xi) * (y - yi) / (yj - yi + 1e-30) + xi):
inside = not inside
j = i
return inside
@staticmethod
def _loop_contains(inner: Dict[str, Any], outer: Dict[str, Any]) -> bool:
"""Does ``outer`` fully enclose ``inner``? Uses a representative point +
boundary tests on ``outer`` (only valid when ``outer`` != ``inner``)."""
rep = OCCSketch._loop_rep_point(inner)
if outer["type"] == "polygon":
return OCCSketch._point_in_polygon(rep, outer["points"])
else: # circle
cx, cy = outer["center"]
return math.hypot(rep[0] - cx, rep[1] - cy) < outer["radius"]
@staticmethod
def _loop_rep_point(loop: Dict[str, Any]) -> Tuple[float, float]:
"""An interior representative point inside a loop.
For polygons we use the midpoint between the centroid and the first
vertex (而不是 centroid 本身): a nested shape centered on the polygon's
centroid (e.g. a circle inside a rectangle, both centered on the same
point) would otherwise make the polygon's rep point coincide with the
hole and break containment tests. This midpoint stays inside convex
loops and is unlikely to land on a nested feature's center.
"""
if loop["type"] == "polygon":
pts = loop["points"][:-1] if len(loop["points"]) > 1 and loop["points"][0] == loop["points"][-1] else loop["points"]
n = len(pts)
sx = sum(p[0] for p in pts) / n
sy = sum(p[1] for p in pts) / n
v0 = pts[0]
return ((sx + v0[0]) / 2.0, (sy + v0[1]) / 2.0)
return loop["center"]
@staticmethod
def _loop_area(loop: Dict[str, Any]) -> float:
if loop["type"] == "circle":
return math.pi * loop["radius"] ** 2
pts = loop["points"]
if len(pts) < 4:
return 0.0
area = 0.0
n = len(pts) - 1 # last == first
for i in range(n):
x1, y1 = pts[i]
x2, y2 = pts[i + 1]
area += x1 * y2 - x2 * y1
return abs(area) / 2.0
def detect_faces(self) -> List[Dict[str, Any]]:
"""Build faces from closed loops using nesting depth.
Nesting rule (standard CAD even-odd): a loop's depth = number of other
loops that strictly contain it. Even-depth loops (0, 2, ...) are outer
boundaries (solid material); odd-depth loops directly inside them are
holes. So a rectangle (depth 0) wrapping a circle (depth 1) yields a face
that is the rectangle minus the circle — exactly the
"shape within a shape = closed without inner" behavior. A shape nested
inside a hole (depth 2) becomes its own solid face again.
Returns a list of ``{"outer": loop, "holes": [loop, ...], "depth": int}``.
"""
loops = self.get_closed_loops()
if not loops:
return []
depths: List[int] = []
for i, li in enumerate(loops):
d = 0
for j, lj in enumerate(loops):
if i != j and OCCSketch._loop_contains(li, lj):
d += 1
depths.append(d)
faces: List[Dict[str, Any]] = []
for i, outer in enumerate(loops):
if depths[i] % 2 != 0:
continue # only even-depth loops are outer boundaries
holes: List[Dict[str, Any]] = []
for j, inner in enumerate(loops):
if i == j:
continue
# directly nested: depth one greater, and outer contains inner.
if depths[j] == depths[i] + 1 and OCCSketch._loop_contains(inner, outer):
holes.append(inner)
faces.append({"outer": outer, "holes": holes, "depth": depths[i]})
return faces
def find_face_at(self, x: float, y: float) -> Optional[Dict[str, Any]]:
"""Return the face whose solid region (outer minus holes) contains (x, y)."""
pt = (x, y)
best: Optional[Dict[str, Any]] = None
best_area = float("inf")
for face in self.detect_faces():
outer = face["outer"]
if outer["type"] == "polygon":
if not OCCSketch._point_in_polygon(pt, outer["points"]):
continue
else:
cx, cy = outer["center"]
if not (math.hypot(pt[0] - cx, pt[1] - cy) < outer["radius"]):
continue
# Must not be inside any hole of this face.
in_hole = False
for h in face["holes"]:
if h["type"] == "polygon":
if OCCSketch._point_in_polygon(pt, h["points"]):
in_hole = True; break
else:
hcx, hcy = h["center"]
if math.hypot(pt[0] - hcx, pt[1] - hcy) < h["radius"]:
in_hole = True; break
if in_hole:
continue
area = OCCSketch._loop_area(outer)
if area < best_area:
best_area = area
best = face
return best
def build_face_geometry(self, face: Dict[str, Any]) -> OCCGeometryObject:
"""Build an OCC face (outer boundary + inner holes) wrapped in a Workplane.
The returned object feeds ``OCGeometryKernel.extrude`` directly: its
``_cadquery_obj`` is a Workplane whose stack holds the face, so cadquery's
``Workplane.extrude`` lifts it into a solid — inner wires become
through-holes.
"""
import cadquery as cq
from OCP.BRepBuilderAPI import (
BRepBuilderAPI_MakePolygon, BRepBuilderAPI_MakeFace,
BRepBuilderAPI_MakeWire, BRepBuilderAPI_MakeEdge,
)
from OCP.gp import gp_Pnt, gp_Circ, gp_Ax2, gp_Dir
from OCP.TopoDS import TopoDS as _TopoDS
def wire_loop(loop: Dict[str, Any], is_hole: bool = False):
if loop["type"] == "polygon":
mp = BRepBuilderAPI_MakePolygon()
for (px, py) in loop["points"]:
mp.Add(gp_Pnt(px, py, 0.0))
mp.Close()
mp.Build()
w = mp.Wire()
else:
cx, cy = loop["center"]
r = loop["radius"]
circ = gp_Circ(gp_Ax2(gp_Pnt(cx, cy, 0.0), gp_Dir(0, 0, 1)), r)
me = BRepBuilderAPI_MakeEdge(circ)
me.Build()
mw = BRepBuilderAPI_MakeWire()
mw.Add(me.Edge())
mw.Build()
w = mw.Wire()
if is_hole:
w = _TopoDS.Wire_s(w.Reversed()) # reverse orientation so OCC treats it as a hole
return w
outer_wire = wire_loop(face["outer"], is_hole=False)
face_maker = BRepBuilderAPI_MakeFace(outer_wire, True)
for h in face["holes"]:
face_maker.Add(wire_loop(h, is_hole=True))
face_maker.Build()
occ_face = face_maker.Face()
wp = cq.Workplane("XY")
wp = wp.add(cq.Face(occ_face))
obj = OCCGeometryObject(wp.val())
obj._cadquery_obj = wp
return obj
def get_solver_dof(self) -> int:
"""Get remaining degrees of freedom from solver."""
return self._solver.dof()
@@ -515,10 +982,147 @@ class OCCSketch(SketchInterface):
self._arcs.clear()
self._entity_counter = 0
self._constraint_count = 0
self._constraint_log.clear()
self._first_point_id = None
def _prune_log_for(self, removed_ids: set) -> None:
"""Drop constraint-log entries that reference any id in ``removed_ids``."""
kept_log: List[Dict[str, Any]] = []
for entry in self._constraint_log:
if not (set(entry["ids"]) & removed_ids):
kept_log.append(entry)
self._constraint_log = kept_log
self._constraint_count = len(kept_log)
def delete_line(self, line: SketchEntity) -> bool:
"""Delete a single line and recompute the surviving constraints.
python_solvespace has no API to remove an individual entity/constraint,
so this removes the line from local tracking, prunes any logged
constraint that referenced it, rebuilds the whole solver system from
the surviving points/lines + pruned log, and re-solves. The line's
endpoint points are NOT removed — only the line segment.
"""
if line.id not in self._lines or line.id not in self._entities:
return False
del self._lines[line.id]
if line.id in self._entities:
del self._entities[line.id]
# Prune log entries referencing the deleted line (labels are re-derived
# from the surviving log below, so no manual label stripping here).
self._prune_log_for({line.id})
self._rebuild_solver()
self._rebuild_labels()
return self.solve()
def remove_constraint_at(self, index: int) -> bool:
"""Remove a single constraint (by log index) and recompute the rest.
Used by the sketch widget when the user hovers a constraint tag and
presses Delete. Drops that one log entry, rebuilds the solver from the
surviving log, re-derives UI labels, and re-solves.
"""
if index < 0 or index >= len(self._constraint_log):
return False
del self._constraint_log[index]
self._constraint_count = len(self._constraint_log)
self._rebuild_solver()
self._rebuild_labels()
return self.solve()
def delete_point(self, point: SketchEntity) -> bool:
"""Delete a point, any lines that use it as an endpoint, and recompute.
Removing a point invalidates every line that references it (a line with
a missing endpoint is meaningless), so those lines are removed too.
All constraints that reference the point OR the removed lines are
pruned from the log, the solver is rebuilt from survivors, labels are
re-derived, and the system is re-solved.
"""
if point.id not in self._entities or point.id not in self._points:
return False
removed_ids: set = {point.id}
# Remove lines that use this point as an endpoint.
removed_line_keys: List[int] = [
lid for lid, (sid, eid2) in list(self._lines.items())
if sid == point.id or eid2 == point.id
]
for lid in removed_line_keys:
removed_ids.add(lid)
del self._lines[lid]
if lid in self._entities:
del self._entities[lid]
# Remove the point itself.
del self._points[point.id]
if point.id in self._entities:
del self._entities[point.id]
# Circles anchored on the point are also invalid.
removed_circle_keys: List[int] = [
cid for cid, (center_id, _r) in list(self._circles.items())
if center_id == point.id
]
for cid in removed_circle_keys:
removed_ids.add(cid)
del self._circles[cid]
if cid in self._entities:
del self._entities[cid]
self._prune_log_for(removed_ids)
self._rebuild_solver()
self._rebuild_labels()
return self.solve()
def _rebuild_labels(self) -> None:
"""Re-derive each entity's UI constraint labels from the surviving log.
paintEvent displays labels read off the endpoint POINT entities ("hrz",
"vrt", "mid", ...). After a delete, recompute them from scratch so a
removed line's labels don't linger on points that still belong to other
(unaffected) lines.
"""
for ent in self._entities.values():
ent.constraints = []
for entry in self._constraint_log:
labels = entry.get("labels") or set()
if not labels:
continue
ctype = entry["type"]
ids = entry["ids"]
targets: List[OCCSketchEntity] = []
if ctype in ("horizontal", "vertical"):
sid, eid2 = self._lines.get(ids[0], (None, None))
for pid in (sid, eid2):
if pid is not None and pid in self._entities:
targets.append(self._entities[pid])
elif ctype == "midpoint":
sid, eid2 = self._lines.get(ids[1], (None, None))
for pid in (sid, eid2):
if pid is not None and pid in self._entities:
targets.append(self._entities[pid])
if ids[0] in self._entities:
targets.append(self._entities[ids[0]])
else:
# distance / equal / parallel / etc.: tag referenced entities'
# endpoints (lines) or the points themselves.
for eid in ids:
if eid in self._lines:
sid, eid2 = self._lines[eid]
for pid in (sid, eid2):
if pid in self._entities:
targets.append(self._entities[pid])
elif eid in self._entities:
targets.append(self._entities[eid])
for t in targets:
for lbl in labels:
if lbl not in t.constraints:
t.constraints.append(lbl)
def delete_entity(self, entity: SketchEntity) -> bool:
"""Delete an entity and its constraints."""
"""Delete an entity and its constraints (no solver rebuild)."""
if entity.id not in self._entities:
return False
+959 -204
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -6,6 +6,7 @@ from fluency.rendering.base import (
RenderColor,
)
from fluency.rendering.pygfx_renderer import PygfxRenderer, PygfxRenderObject
from fluency.rendering.occ_renderer import OCCRenderer, OCCRenderObject
__all__ = [
"Renderer",
+504
View File
@@ -0,0 +1,504 @@
"""OCC-based 3D renderer — displays BRep shapes directly on the GPU.
Uses OCC's ``AIS_Shape`` and ``V3d_Viewer`` so that curved surfaces (cylinders,
spheres, etc.) render smoothly without manual triangulation, and edges/faces are
natively selectable.
"""
import logging
import uuid
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional, Tuple
import numpy as np
from .base import Renderer, RenderObject, RenderColor
logger = logging.getLogger(__name__)
@dataclass
class OCCRenderObject(RenderObject):
"""Internal object state for the OCC renderer."""
obj_id: str = ""
ais_shape: Any = None # AIS_Shape
ais_type: str = "shape" # "shape" | "wireframe" | "points"
color: RenderColor = field(default_factory=lambda: RenderColor(0.5, 0.5, 0.5))
class OCCRenderer(Renderer):
"""Renderer that uses OCC's native AIS display for smooth BRep rendering."""
def __init__(self) -> None:
super().__init__()
self._viewer: Any = None
self._view: Any = None
self._context: Any = None
self._window: Any = None
self._initialized: bool = False
self._objects: Dict[str, OCCRenderObject] = {}
self._parent_widget: Any = None
self._last_mouse_x: int = 0
self._last_mouse_y: int = 0
def initialize(self, parent_widget: Any) -> bool:
"""Initialise OCC viewer inside *parent_widget* (a QWidget)."""
self._parent_widget = parent_widget
import os as _os
if _os.environ.get("QT_QPA_PLATFORM") == "offscreen":
logger.warning("OCCRenderer skipped (QT_QPA_PLATFORM=offscreen)")
return False
logger.info("OCCRenderer imports starting...")
from OCP.Aspect import (
Aspect_DisplayConnection,
Aspect_NeutralWindow,
Aspect_GFM_VER,
)
from OCP.OpenGl import OpenGl_GraphicDriver
from OCP.V3d import V3d_Viewer, V3d_View, V3d_TypeOfView
from OCP.AIS import AIS_InteractiveContext
from OCP.Quantity import Quantity_Color, Quantity_TOC_RGB
logger.info("OCCRenderer imports complete")
hwnd = int(parent_widget.winId())
if hwnd <= 1:
logger.warning("OCCRenderer skipped (no native window handle)")
return False
logger.info("OCCRenderer creating objects...")
try:
display = Aspect_DisplayConnection()
driver = OpenGl_GraphicDriver(display)
viewer = V3d_Viewer(driver)
viewer.SetDefaultLights()
viewer.SetLightOn()
view = V3d_View(viewer, V3d_TypeOfView.V3d_ORTHOGRAPHIC)
viewer.SetDefaultBgGradientColors(
Quantity_Color(0.15, 0.15, 0.2, Quantity_TOC_RGB),
Quantity_Color(0.25, 0.25, 0.3, Quantity_TOC_RGB),
Aspect_GFM_VER,
)
context = AIS_InteractiveContext(viewer)
# Attach OCC view to the Qt widget via the native window handle.
win = Aspect_NeutralWindow()
win.SetNativeHandle(hwnd)
w, h = parent_widget.width(), parent_widget.height()
win.SetSize(w, h)
view.SetWindow(win)
self._viewer = viewer
self._view = view
self._context = context
self._window = win
self._initialized = True
logger.info("OCCRenderer initialised (native OCC display)")
return True
except Exception as exc:
logger.error(f"OCCRenderer initialisation failed: {exc}")
self._initialized = False
return False
def shutdown(self) -> None:
"""Clean up OCC viewer resources."""
self.clear_scene()
if self._view is not None:
self._view.Remove()
self._view = None
if self._viewer is not None:
self._viewer.Remove()
self._viewer = None
self._initialized = False
# ─── BRep shape display (primary entry point) ───────────────────────
def add_shape(
self,
shape: Any,
color: Optional[Tuple[float, float, float]] = None,
name: Optional[str] = None,
) -> str:
"""Display an OCC ``TopoDS_Shape`` directly via ``AIS_Shape``.
Returns a unique object ID (or *name* if provided).
"""
from OCP.AIS import AIS_Shape
from OCP.Quantity import Quantity_Color, Quantity_TOC_RGB
from OCP.Prs3d import Prs3d_Drawer
obj_id = name or f"shape_{uuid.uuid4().hex[:8]}"
ais = AIS_Shape(shape)
if color is not None:
qcol = Quantity_Color(*color, Quantity_TOC_RGB)
ais.SetColor(qcol)
# Enable selection of edges and faces.
ais.SetDisplayMode(1) # 0 = wireframe, 1 = shaded
ais.SetMaterial(
self._default_material()
) # use a helper to get the default material
self._context.Display(ais, True)
self._context.Deactivate(ais)
defcol = color or (0.5, 0.5, 0.5)
robj = OCCRenderObject(
obj_id=obj_id,
ais_shape=ais,
ais_type="shape",
color=RenderColor(*defcol),
)
self._objects[obj_id] = robj
# Fit camera on first shape added.
if len(self._objects) == 1:
try:
self.fit_camera()
except Exception:
logger.warning("fit_camera failed on first shape", exc_info=True)
return obj_id
def _default_material(self):
"""Return a default Graphic3d_MaterialAspect for shading."""
from OCP.Graphic3d import Graphic3d_MaterialAspect, Graphic3d_NameOfMaterial
return Graphic3d_MaterialAspect(
Graphic3d_NameOfMaterial.Graphic3d_NOM_PLASTIC
)
# ─── Legacy mesh / wireframe (kept for backward compat) ────────────
def add_mesh(
self,
vertices: np.ndarray,
faces: np.ndarray,
color: Tuple[float, float, float] = (0.2, 0.4, 0.8),
name: Optional[str] = None,
) -> str:
"""Add triangulated mesh by converting it to an OCC polygonal shape.
Prefer :meth:`add_shape` for native BRep display it avoids meshing
artifacts on curved surfaces.
"""
from OCP.Poly import Poly_Triangulation, Poly_Triangle
from OCP.TopoDS import TopoDS_Face
from OCP.BRep import BRep_Builder
from OCP.gp import gp_Pnt
n_verts = len(vertices)
n_tris = len(faces)
# Build triangulation via (nbNodes, nbTriangles, hasUVNodes) constructor
tri = Poly_Triangulation(n_verts, n_tris, False)
for i, (x, y, z) in enumerate(vertices):
tri.SetNode(i + 1, gp_Pnt(float(x), float(y), float(z)))
for i, (a, b, cc) in enumerate(faces):
tri.SetTriangle(i + 1, Poly_Triangle(int(a) + 1, int(b) + 1, int(cc) + 1))
builder = BRep_Builder()
shape = TopoDS_Face()
builder.MakeFace(shape, tri)
return self.add_shape(shape, color, name)
def add_wireframe(
self,
vertices: np.ndarray,
edges: np.ndarray,
color: Tuple[float, float, float] = (1.0, 1.0, 1.0),
line_width: float = 1.0,
name: Optional[str] = None,
) -> str:
"""Add a wireframe from edge data (legacy, kept for compatibility).
For new code prefer :meth:`add_shape` OCC's AIS displays the
shape boundary automatically.
"""
obj_id = name or f"wf_{uuid.uuid4().hex[:8]}"
logger.debug(f"add_wireframe {obj_id} — ignored (AIS draws edges natively)")
# Wireframes are already provided by the AIS shaded display, so we
# skip explicit line geometry unless there is a specific need.
return obj_id
def add_points(
self,
points: np.ndarray,
color: Tuple[float, float, float] = (1.0, 0.0, 0.0),
size: float = 5.0,
name: Optional[str] = None,
) -> str:
"""Add point cloud (not yet implemented with OCC renderer)."""
obj_id = name or f"pts_{uuid.uuid4().hex[:8]}"
logger.debug(f"add_points {obj_id} — not implemented in OCCRenderer")
return obj_id
def add_lines(
self,
start_points: np.ndarray,
end_points: np.ndarray,
color: Tuple[float, float, float] = (1.0, 1.0, 1.0),
line_width: float = 1.0,
name: Optional[str] = None,
) -> str:
"""Add line segments (not yet implemented with OCC renderer)."""
obj_id = name or f"ln_{uuid.uuid4().hex[:8]}"
logger.debug(f"add_lines {obj_id} — not implemented in OCCRenderer")
return obj_id
# ─── Object management ─────────────────────────────────────────────
def remove_object(self, obj: OCCRenderObject) -> bool:
"""Remove an object from the scene."""
if obj.ais_shape is not None:
self._context.Remove(obj.ais_shape, True)
if obj.obj_id in self._objects:
del self._objects[obj.obj_id]
return True
return False
def remove_mesh(self, obj_id: str) -> None:
"""Remove an object by ID (legacy compatibility)."""
obj = self._objects.get(obj_id)
if obj is not None:
self.remove_object(obj)
def clear_scene(self) -> None:
"""Remove all objects from the scene."""
if self._context is None:
return
# Remove all displayed AIS objects.
from OCP.AIS import AIS_KindOfInteractive
objs = self._context.DisplayedObjects()
for ais in objs:
self._context.Remove(ais, True)
self._objects.clear()
def update_mesh(
self,
obj: OCCRenderObject,
vertices: np.ndarray,
faces: np.ndarray,
) -> bool:
"""Update mesh geometry (not supported for OCC shapes; re-add instead)."""
logger.warning("update_mesh not supported for OCC shapes — remove + re-add")
return False
# ─── Display properties ────────────────────────────────────────────
def set_object_color(
self,
obj: OCCRenderObject,
color: Tuple[float, float, float],
) -> None:
"""Set the colour of an object."""
from OCP.Quantity import Quantity_Color, Quantity_TOC_RGB
if obj.ais_shape is not None:
qcol = Quantity_Color(*color, Quantity_TOC_RGB)
obj.ais_shape.SetColor(qcol)
self._context.RecomputePrsOnly(obj.ais_shape, True)
def set_object_visible(self, obj: OCCRenderObject, visible: bool) -> None:
"""Show or hide an object."""
if obj.ais_shape is not None:
if visible:
self._context.Display(obj.ais_shape, True)
else:
self._context.Erase(obj.ais_shape, True)
# ─── Camera ────────────────────────────────────────────────────────
def set_camera_position(
self,
position: Tuple[float, float, float],
target: Tuple[float, float, float] = (0, 0, 0),
up: Tuple[float, float, float] = (0, 0, 1),
) -> None:
"""Set camera position and orientation."""
if self._view is None:
return
from OCP.gp import gp_Pnt, gp_Dir, gp_Vec
self._view.SetProj(gp_Dir(position[0], position[1], position[2]))
self._view.SetEye(gp_Pnt(*position))
self._view.SetCenter(gp_Pnt(*target))
self._view.SetUp(gp_Dir(*up))
def get_camera_position(self) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
"""Get camera position, target, and up vector."""
if self._view is None:
return (
np.array([1, 1, 1]),
np.array([0, 0, 0]),
np.array([0, 0, 1]),
)
eye = self._view.Eye()
center = self._view.Center()
up = self._view.Up()
return (
np.array([eye.X(), eye.Y(), eye.Z()]),
np.array([center.X(), center.Y(), center.Z()]),
np.array([up.X(), up.Y(), up.Z()]),
)
def fit_camera(self, padding: float = 0.05) -> None:
"""Fit camera to show all displayed objects.
*padding* is the margin coefficient (0 padding < 1.0) passed to
OCC's ``FitAll``, not a multiplicative factor. A small value like
0.05 adds 5% margin around the bounding box.
"""
if self._view is None:
return
margin = max(0.0, min(padding, 0.99))
self._view.FitAll(margin)
def set_camera_perspective(
self, fov: float = 50.0, near: float = 0.1, far: float = 10000.0
) -> None:
"""Set perspective camera."""
if self._view is None:
return
from OCP.V3d import V3d_PERSPECTIVE
self._view.SetComputedMode(False) # manual mode
self._view.ChangeRenderingParams()
# perspective/orthographic toggle handled in set_camera_orthographic
def set_camera_orthographic(
self, width: float = 100.0, near: float = 0.1, far: float = 10000.0
) -> None:
"""Set orthographic camera."""
if self._view is None:
return
from OCP.V3d import V3d_ORTHOGRAPHIC
self._view.SetComputedMode(False)
# ─── Rendering ─────────────────────────────────────────────────────
def render(self) -> None:
"""Redraw the OCC view."""
if self._view is None:
return
self._view.Redraw()
def screenshot(self, width: int, height: int) -> bytes:
"""Take a screenshot."""
return b""
# ─── Stub implementations for remaining abstract methods ──────────
def add_axes(self, size: float = 100.0) -> None:
"""Add coordinate axes."""
pass
def add_grid(self, size: float = 100.0) -> None:
"""Add a reference grid."""
pass
def get_screen_size(self) -> Tuple[int, int]:
if self._parent_widget:
return self._parent_widget.width(), self._parent_widget.height()
return (800, 600)
def on_camera_change(self, callback: Any) -> None:
pass
def on_pick(self, callback: Any) -> None:
pass
def project_to_screen(
self, point: Tuple[float, float, float]
) -> Tuple[float, float]:
return (0.0, 0.0)
def save_screenshot(self, path: str, width: int = 1920, height: int = 1080) -> None:
logger.warning("save_screenshot not implemented for OCCRenderer")
def set_background_color(self, color: Tuple[float, float, float]) -> None:
if self._view is None:
return
from OCP.Quantity import Quantity_Color, Quantity_TOC_RGB
qcol = Quantity_Color(*color, Quantity_TOC_RGB)
self._view.SetBackgroundColor(qcol)
def take_screenshot(self) -> bytes:
return b""
def unproject_from_screen(
self, x: float, y: float
) -> Tuple[float, float, float]:
return (0.0, 0.0, 0.0)
# ─── Mouse event forwarding ────────────────────────────────────────
def handle_mouse_press(self, event) -> None:
"""Forward a QMouseEvent to the OCC view for orbit/pan/zoom/select."""
from OCP.Aspect import Aspect_VKeyMouse
from OCP.AIS import AIS_SelectionScheme
if self._view is None or self._context is None:
return
# Middle mouse → start rotation
if event.button() == 4: # Qt.MiddleButton
self._view.StartRotation(event.x(), event.y())
# Left mouse → try selection (OCC picks nearest shape)
elif event.button() == 1: # Qt.LeftButton
self._context.Select(True)
def handle_mouse_move(self, event) -> None:
"""Forward mouse motion to OCC view (rotation, dynamic highlighting)."""
from OCP.Aspect import Aspect_VKeyMouse
if self._view is None or self._context is None:
return
buttons = event.buttons()
if buttons & 4: # Qt.MiddleButton
self._view.Rotation(event.x(), event.y())
elif buttons & 2: # Qt.RightButton
dx = event.x() - self._last_mouse_x
dy = event.y() - self._last_mouse_y
self._view.Pan(dx, dy)
# Dynamic highlighting (detect)
self._context.MoveTo(event.x(), event.y(), self._view, True)
self._last_mouse_x = event.x()
self._last_mouse_y = event.y()
def handle_mouse_release(self, event) -> None:
"""End rotation/pan."""
pass
def handle_wheel(self, event) -> None:
"""Zoom on scroll."""
if self._view is None:
return
delta = event.angleDelta().y()
if delta > 0:
self._view.SetZoom(1.1)
else:
self._view.SetZoom(0.9)
def handle_resize(self, w: int, h: int) -> None:
"""Resize the OCC view when the widget is resized."""
if self._window is not None:
self._window.SetSize(w, h)
if self._view is not None:
self._view.MustBeResized()
self._view.Redraw()