2 Commits

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