- Added save file foramt

- Split main.py refactor
This commit is contained in:
bklronin
2026-07-11 15:39:30 +02:00
parent b0aebdc04f
commit 2b2afbc479
2 changed files with 148 additions and 92 deletions
+10 -2
View File
@@ -6,7 +6,7 @@
<component name="ChangeListManager">
<list default="true" id="8f0bafd6-58a0-4b20-aa2b-ddc3ba278873" name="Changes" comment="- Added save file foramt&#10;- Split main.py refactor">
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/fluency/ui/dialogs.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/ui/dialogs.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/fluency/rendering/occ_renderer.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/rendering/occ_renderer.py" afterDir="false" />
</list>
<option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" />
@@ -359,7 +359,15 @@
<option name="project" value="LOCAL" />
<updated>1783456842297</updated>
</task>
<option name="localTasksCounter" value="32" />
<task id="LOCAL-00032" summary="- Added save file foramt&#10;- Split main.py refactor">
<option name="closed" value="true" />
<created>1783755278516</created>
<option name="number" value="00032" />
<option name="presentableId" value="LOCAL-00032" />
<option name="project" value="LOCAL" />
<updated>1783755278516</updated>
</task>
<option name="localTasksCounter" value="33" />
<servers />
</component>
<component name="TypeScriptGeneratedFilesManager">
+138 -90
View File
@@ -1047,27 +1047,44 @@ class OCCRenderer(Renderer):
shape = self._context.DetectedShape()
if shape is None:
return None
return self._classify_detected_shape(shape)
results = self._classify_detected_shape(shape)
if not results:
return None
# For cylinders with two ends, pick the one closest to the camera.
if len(results) > 1:
eye = None
try:
if self._view is not None:
e = self._view.Eye()
eye = np.array([e.X(), e.Y(), e.Z()], dtype=float)
except Exception:
pass
if eye is not None:
results.sort(key=lambda c: float(np.linalg.norm(
np.array(c["position"]) - eye)))
return results[0]
def _classify_detected_shape(
self, shape: Any, owner_obj_id: Optional[str] = None,
) -> Optional[Dict[str, Any]]:
"""Classify a detected OCC sub-shape into a snap-candidate dict.
) -> List[Dict[str, Any]]:
"""Classify a detected OCC sub-shape into snap-candidate dicts.
Shared by ``pick_entity`` (single-pixel) and ``probe_snap_candidates``
(multi-pixel grid probing). Determines whether *shape* is a planar
face, cylindrical face (hole), edge, or vertex and returns the snap
info dict with ``position`` / ``normal`` / ``x_dir`` / ``type`` /
``owner_obj_id`` (+ ``radius`` for holes). When *owner_obj_id* is
omitted it is looked up from the context's currently-detected AIS.
face, cylindrical face (hole), edge, or vertex and returns a list of
snap info dicts with ``position`` / ``normal`` / ``x_dir`` / ``type`` /
``owner_obj_id`` (+ ``radius`` for holes). Cylindrical faces yield
two candidates (one per circular end) so the user can snap to either
opening. When *owner_obj_id* is omitted it is looked up from the
context's currently-detected AIS.
"""
if shape is None:
return None
return []
from OCP.TopoDS import TopoDS_Face, TopoDS_Edge, TopoDS_Vertex, TopoDS
from OCP.TopAbs import TopAbs_FACE, TopAbs_EDGE, TopAbs_VERTEX
from OCP.BRepAdaptor import BRepAdaptor_Surface, BRepAdaptor_Curve
from OCP.GeomAbs import GeomAbs_Plane, GeomAbs_Cylinder
from OCP.GeomAbs import GeomAbs_Plane, GeomAbs_Cylinder, GeomAbs_Circle
from OCP.BRep import BRep_Tool
from OCP.TopExp import TopExp_Explorer
from OCP.TopAbs import TopAbs_EDGE as TopAbs_EDGE_TYPE
@@ -1139,14 +1156,14 @@ class OCCRenderer(Renderer):
px = pln.XAxis().Direction()
x_dir = (px.X(), px.Y(), px.Z())
return {
return [{
"type": "planar_face",
"position": origin,
"normal": (nx, ny, nz),
"x_dir": x_dir,
"face": face,
"owner_obj_id": owner_obj_id,
}
}]
elif stype == GeomAbs_Cylinder:
cyl = adaptor.Cylinder()
@@ -1155,52 +1172,67 @@ class OCCRenderer(Renderer):
ax_pos = axis.Location()
radius = cyl.Radius()
# Parameter extents along the cylinder axis (v = height).
# BRepAdaptor_Surface exposes these via First/Last V
# *Parameter() — NOT a Bounds() method (that quirk crashed
# cylindrical-face picking).
vmin = adaptor.FirstVParameter()
vmax = adaptor.LastVParameter()
# Find the actual circular edge loops at each end of the
# cylinder face. This is more reliable than computing from
# vmin/vmax parameters, which can be offset depending on how
# the BRep was constructed (e.g. a hole drilled into a block).
# Two candidate snap points: the centers of the cylinder's
# two end circles (the hole openings). A bolt enters a
# hole from the camera-facing opening, so pick the END of
# the axis closest to the camera as the primary snap point.
# The axis location (ax_pos) is already on the cylinder axis
# at v=0; the other end is at v=vmax.
p0 = np.array([
ax_pos.X(), ax_pos.Y(), ax_pos.Z(),
], dtype=float)
p1 = np.array([
ax_pos.X() + ax_dir.X() * (vmax - vmin),
ax_pos.Y() + ax_dir.Y() * (vmax - vmin),
ax_pos.Z() + ax_dir.Z() * (vmax - vmin),
], dtype=float)
# Collect all edges of the face.
edge_explorer = TopExp_Explorer(face, TopAbs_EDGE_TYPE)
circle_centers: List[np.ndarray] = []
while edge_explorer.More():
edge = TopoDS.Edge_s(edge_explorer.Current())
try:
curve_adaptor = BRepAdaptor_Curve(edge)
if curve_adaptor.GetType() == GeomAbs_Circle:
circ = curve_adaptor.Circle()
center_pnt = circ.Location()
circle_centers.append(np.array([
center_pnt.X(), center_pnt.Y(), center_pnt.Z()
], dtype=float))
except Exception:
pass
edge_explorer.Next()
# Choose the camera-facing end. The camera looks FROM its
# eye TOWARD its target, so the camera direction is
# (target - eye). The end whose vector-from-camera is MOST
# OPPOSITE to (i.e. faces) the camera is the near opening.
cam_from = None
try:
if self._view is not None:
eye = self._view.Eye()
at = self._view.At()
cam_from = np.array([eye.X(), eye.Y(), eye.Z()], dtype=float)
cam_to = np.array([at.X(), at.Y(), at.Z()], dtype=float)
except Exception:
cam_from = None
if cam_from is not None:
# End closest to the camera eye is the visible opening.
d0 = float(np.linalg.norm(p0 - cam_from))
d1 = float(np.linalg.norm(p1 - cam_from))
near_end = p0 if d0 <= d1 else p1
# Group circle centers by their position along the axis.
# Two distinct groups = two end openings of the cylinder.
if len(circle_centers) >= 2:
# Project each center onto the axis direction to get a
# scalar "height" value. Cluster into two groups.
ax_dir_np = np.array([
ax_dir.X(), ax_dir.Y(), ax_dir.Z()
], dtype=float)
heights = [np.dot(c, ax_dir_np) for c in circle_centers]
# Sort by height (scalar) and split roughly in half.
indexed = list(enumerate(heights))
indexed.sort(key=lambda x: x[1])
mid = len(indexed) // 2
idx0 = [i for i, _ in indexed[:mid]] if mid > 0 else [indexed[0][0]]
idx1 = [i for i, _ in indexed[mid:]] if mid < len(indexed) else [indexed[-1][0]]
group0 = [circle_centers[i] for i in idx0]
group1 = [circle_centers[i] for i in idx1]
# Average each group to get the center of each end circle.
c0 = np.mean(group0, axis=0)
c1 = np.mean(group1, axis=0)
elif len(circle_centers) == 1:
# Only one circular edge found (e.g. open-ended cylinder).
c0 = circle_centers[0]
c1 = c0
else:
# Fallback: axial midpoint.
near_end = 0.5 * (p0 + p1)
# No circular edges found — fall back to parameter-based.
vmin = adaptor.FirstVParameter()
vmax = adaptor.LastVParameter()
c0 = np.array([
ax_pos.X() + ax_dir.X() * vmin,
ax_pos.Y() + ax_dir.Y() * vmin,
ax_pos.Z() + ax_dir.Z() * vmin,
], dtype=float)
c1 = np.array([
ax_pos.X() + ax_dir.X() * vmax,
ax_pos.Y() + ax_dir.Y() * vmax,
ax_pos.Z() + ax_dir.Z() * vmax,
], dtype=float)
origin = (float(near_end[0]), float(near_end[1]), float(near_end[2]))
# Normal = the cylinder axis direction. This is the "bolt
# axis": the direction a bolt would travel INTO the hole.
# (Sign is the cylinder's own axis direction; a later flip can
@@ -1216,15 +1248,22 @@ class OCCRenderer(Renderer):
except Exception:
x_dir = (1.0, 0.0, 0.0)
return {
"type": "cylindrical_face",
"position": origin,
"normal": normal,
"x_dir": x_dir,
"face": face,
"owner_obj_id": owner_obj_id,
"radius": radius,
}
# Return BOTH circular ends as snap candidates so the user can
# snap to either opening of a cylinder/hole (e.g. bolt-to-bore
# on the far side of a part).
results: List[Dict[str, Any]] = []
for end_center in [c0, c1]:
origin = (float(end_center[0]), float(end_center[1]), float(end_center[2]))
results.append({
"type": "cylindrical_face",
"position": origin,
"normal": normal,
"x_dir": x_dir,
"face": face,
"owner_obj_id": owner_obj_id,
"radius": radius,
})
return results
# Try edge.
edge = None
@@ -1267,14 +1306,14 @@ class OCCRenderer(Renderer):
x = x / xlen
x_dir = (float(x[0]), float(x[1]), float(x[2]))
return {
return [{
"type": "edge",
"position": position,
"normal": tangent,
"x_dir": x_dir,
"edge": edge,
"owner_obj_id": owner_obj_id,
}
}]
# Try vertex.
vertex = None
@@ -1282,18 +1321,18 @@ class OCCRenderer(Renderer):
vertex = TopoDS.Vertex_s(shape)
p = BRep_Tool.Pnt_s(vertex)
position = (p.X(), p.Y(), p.Z())
return {
return [{
"type": "vertex",
"position": position,
"normal": None,
"x_dir": None,
"vertex": vertex,
"owner_obj_id": owner_obj_id,
}
}]
except Exception:
pass
return None
return []
def _project_to_screen(self, p3d: Tuple[float, float, float]) -> Optional[Tuple[int, int]]:
"""Project a 3D world point to (x, y) screen pixel.
@@ -1315,19 +1354,19 @@ class OCCRenderer(Renderer):
return None
def probe_snap_candidates(
self, x: int, y: int, radius: int = 18,
self, x: int, y: int, radius: int = 30,
) -> List[Dict[str, Any]]:
"""Probe a pixel grid around (x, y) and return visible snap candidates.
Samples a small ring + centre around the cursor, runs OCC's
Samples a dense ring + centre around the cursor, runs OCC's
``MoveTo`` at each pixel, and classifies every distinct detected
sub-shape via :meth:`_classify_detected_shape`. Results are
deduplicated by (owner_obj_id, type, rounded position) and sorted by
screen-space distance to the cursor, nearest first.
This is the general hover snap indicator: it surfaces nearby
vertices, edge midpoints, hole centres, and face centres so the
user can see the snap targets in the cursor neighbourhood — not
vertices, edge midpoints, hole centres, and face centres so that
the user can see the snap targets in the cursor neighbourhood — not
just the single entity directly under the crosshair.
Each entry is the same dict shape returned by ``pick_entity`` plus an
@@ -1337,14 +1376,21 @@ class OCCRenderer(Renderer):
if self._view is None or self._context is None:
return []
# Sample pattern: the exact cursor pixel plus a small ring of
# offsets. The ring catches nearby vertices/edges/holes that sit a
# few pixels away from where the user is pointing.
# Dense sample pattern: centre + multiple rings at different radii
# to catch small features like hole openings that might be missed by
# a single sparse ring. Uses quarter, half, and full radius offsets.
q = radius // 4
h = radius // 2
ring_offsets = [
(0, 0),
# Full radius ring (cardinal + diagonal)
(-radius, 0), (radius, 0), (0, -radius), (0, radius),
(-radius, -radius), (radius, radius), (-radius, radius), (radius, -radius),
(-radius // 2, 0), (radius // 2, 0), (0, -radius // 2), (0, radius // 2),
# Half-radius ring
(-h, 0), (h, 0), (0, -h), (0, h),
(-h, -h), (h, h), (-h, h), (h, -h),
# Quarter-radius ring for small features
(-q, 0), (q, 0), (0, -q), (0, q),
]
candidates: Dict[Tuple[str, str, Tuple[int, int, int]], Dict[str, Any]] = {}
@@ -1359,22 +1405,24 @@ class OCCRenderer(Renderer):
shape = self._context.DetectedShape()
if shape is None:
continue
info = self._classify_detected_shape(shape)
if info is None:
infos = self._classify_detected_shape(shape)
if not infos:
continue
# Skip non-trackable hits (no owner — e.g. the workplane plane).
if not info.get("owner_obj_id"):
continue
pos = info.get("position") or (0.0, 0.0, 0.0)
# Dedupe key: owner + type + position rounded to 0.1 mm.
key = (
info.get("owner_obj_id", ""),
info.get("type", ""),
(round(pos[0], 1), round(pos[1], 1), round(pos[2], 1)),
)
if key not in candidates:
info["screen"] = (sx, sy)
candidates[key] = info
# infos is a list; for cylinders it contains two ends.
for info in infos:
# Skip non-trackable hits (no owner — e.g. the workplane plane).
if not info.get("owner_obj_id"):
continue
pos = info.get("position") or (0.0, 0.0, 0.0)
# Dedupe key: owner + type + position rounded to 0.1 mm.
key = (
info.get("owner_obj_id", ""),
info.get("type", ""),
(round(pos[0], 1), round(pos[1], 1), round(pos[2], 1)),
)
if key not in candidates:
info["screen"] = (sx, sy)
candidates[key] = info
# Sort by screen-space distance to the cursor, nearest first.
results = list(candidates.values())