- Tons of addtions
This commit is contained in:
Generated
+39
-30
@@ -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">{
|
||||
"keyToString": {
|
||||
"Python.2dtest.executor": "Run",
|
||||
"Python.3d_windows.executor": "Run",
|
||||
"Python.Unnamed.executor": "Run",
|
||||
"Python.draw_widget2d.executor": "Run",
|
||||
"Python.draw_widget_solve.executor": "Run",
|
||||
"Python.fluency.executor": "Run",
|
||||
"Python.fluencyb.executor": "Run",
|
||||
"Python.gl_widget.executor": "Run",
|
||||
"Python.main.executor": "Run",
|
||||
"Python.meshtest.executor": "Run",
|
||||
"Python.side_fluency.executor": "Run",
|
||||
"Python.simple_mesh.executor": "Run",
|
||||
"Python.vtk_widget.executor": "Run",
|
||||
"Python.vulkan.executor": "Run",
|
||||
"RunOnceActivity.OpenProjectViewOnStart": "true",
|
||||
"RunOnceActivity.ShowReadmeOnStart": "true",
|
||||
"RunOnceActivity.git.unshallow": "true",
|
||||
"git-widget-placeholder": "feature/occ-migration",
|
||||
"last_opened_file_path": "/Volumes/Data_drive/Programming/fluency",
|
||||
"node.js.selected.package.tslint": "(autodetect)",
|
||||
"settings.editor.selected.configurable": "project.propVCSSupport.DirectoryMappings"
|
||||
<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>
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user