- Working assembly multi :)

This commit is contained in:
bklronin
2026-07-11 21:29:58 +02:00
parent 2b2afbc479
commit d7e5929a13
5 changed files with 812 additions and 197 deletions
+334 -25
View File
@@ -1334,25 +1334,6 @@ class OCCRenderer(Renderer):
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.
Uses OCC's ``V3d_View.Convert`` (world → view coords). Returns None
if the projection fails (e.g. behind the camera).
"""
if self._view is None:
return None
try:
# OCC's Convert returns the window pixel coordinates.
xpix = self._view.Convert(float(p3d[0]), float(p3d[1]), float(p3d[2]))
# Some OCP builds return a tuple (x, y); others return two values.
if isinstance(xpix, (tuple, list)) and len(xpix) == 2:
return (int(xpix[0]), int(xpix[1]))
return None
except Exception:
# Fall back to ConvertWithProj or ProjTexte if Convert is unavailable.
return None
def probe_snap_candidates(
self, x: int, y: int, radius: int = 30,
) -> List[Dict[str, Any]]:
@@ -1429,8 +1410,8 @@ class OCCRenderer(Renderer):
results.sort(key=lambda c: (c.get("screen", (x, y))[0] - x) ** 2 + (c.get("screen", (x, y))[1] - y) ** 2)
return results
def highlight_snap(self, position, color=None, size=3.0) -> Optional[str]:
"""Show a small marker sphere at *position* as a snap indicator.
def highlight_snap(self, position, color=None, size=6.0) -> Optional[str]:
"""Show a marker sphere at *position* as a snap indicator.
Returns an object id that can be removed later.
The *size* is auto-scaled by camera distance so the marker stays
@@ -1454,7 +1435,7 @@ class OCCRenderer(Renderer):
self._view.Update()
# Track this as a temporary object; use a synthetic id.
oid = f"__snap_{id(ais)}"
self._objects[oid] = _RenderObject(oid, ais, None, None)
self._objects[oid] = OCCRenderObject(obj_id=oid, ais_shape=ais, ais_type="snap")
return oid
except Exception as exc:
logger.debug(f"highlight_snap failed: {exc}")
@@ -1596,13 +1577,13 @@ class OCCRenderer(Renderer):
):
continue
cc = default_colors.get(cand.get("type", ""), (0.7, 0.7, 0.7))
_make_sphere(cpos, cc, 1.4 * gizmo_scale) # dim, small
_make_sphere(cpos, cc, 2.8 * gizmo_scale) # dim, small
# ── 1. Bright primary marker (sphere) ──
_make_sphere(position, gizmo_color, 2.8 * gizmo_scale)
_make_sphere(position, gizmo_color, 5.6 * gizmo_scale)
# ── 2. Axis indicator lines (primary only) ──
axis_length = 15.0 * gizmo_scale
axis_length = 30.0 * gizmo_scale
def _make_axis_line(
origin: Tuple[float, float, float],
@@ -1690,6 +1671,334 @@ class OCCRenderer(Renderer):
if self._view is not None:
self._view.Update()
# ─── Selection mode control ───────────────────────────────────────────
#
# When connector gizmo mode is active, standard OCC face/edge/vertex
# selection is deactivated so dynamic highlighting does not interfere
# with the gizmo visuals. The geometric probing method below replaces
# the selection-system-based probe.
def deactivate_selection_modes(self) -> None:
"""Deactivate OCC face/edge/vertex selection on every tracked AIS shape.
Used when entering connector gizmo mode so that standard dynamic
highlighting (MoveTo) does not interfere with the gizmo visuals.
Call :meth:`activate_selection_modes` to restore.
"""
if self._context is None:
return
from OCP.TopAbs import TopAbs_FACE, TopAbs_EDGE, TopAbs_VERTEX
from OCP.AIS import AIS_Shape
for robj in self._objects.values():
if robj.ais_shape is not None:
for topo in (TopAbs_VERTEX, TopAbs_EDGE, TopAbs_FACE):
mode = AIS_Shape.SelectionMode_s(topo)
try:
self._context.Deactivate(robj.ais_shape, mode)
except Exception:
pass
logger.debug("Selection modes deactivated for all AIS shapes")
def activate_selection_modes(self) -> None:
"""Re-activate OCC face/edge/vertex selection on every tracked AIS shape.
Called when exiting connector gizmo mode to restore normal
dynamic highlighting and face-pick behaviour.
"""
if self._context is None:
return
from OCP.TopAbs import TopAbs_FACE, TopAbs_EDGE, TopAbs_VERTEX
from OCP.AIS import AIS_Shape
for robj in self._objects.values():
if robj.ais_shape is not None:
for topo in (TopAbs_VERTEX, TopAbs_EDGE, TopAbs_FACE):
mode = AIS_Shape.SelectionMode_s(topo)
try:
self._context.Activate(robj.ais_shape, mode)
except Exception:
pass
logger.debug("Selection modes re-activated for all AIS shapes")
# ─── Geometric snap probing (selection-system-independent) ────────────
#
# Walks every AIS shape's topology directly, projects each feature to
# screen, and returns candidates within *radius* pixels of the cursor.
# This replaces the MoveTo-based probe when selection modes are
# deactivated (connector gizmo mode).
def _project_to_screen(self, p3d: Tuple[float, float, float]) -> Optional[Tuple[int, int]]:
"""Project a 3D world point to (x, y) screen pixel.
Uses OCC's ``V3d_View.Convert`` (world → view coords). Returns None
if the projection fails (e.g. behind the camera).
"""
if self._view is None:
return None
try:
# OCC's Convert returns the window pixel coordinates.
xpix = self._view.Convert(float(p3d[0]), float(p3d[1]), float(p3d[2]))
# Some OCP builds return a tuple (x, y); others return two values.
if isinstance(xpix, (tuple, list)) and len(xpix) == 2:
return (int(xpix[0]), int(xpix[1]))
return None
except Exception:
# Fall back to ConvertWithProj or ProjTexte if Convert is unavailable.
return None
def probe_snap_candidates_geometric(
self, x: int, y: int, radius: int = 30,
) -> List[Dict[str, Any]]:
"""Probe snap candidates by iterating geometry directly (no selection system).
Uses a two-pass approach for performance:
1. **Bounding-box pre-filter**: projects each shape's 3D bbox to screen;
skips shapes whose screen bbox is far from the cursor.
2. **Feature iteration**: for nearby shapes only, walks faces/edges/vertices,
projects each feature to screen, and collects candidates within
*radius* pixels.
This replaces ``probe_snap_candidates`` when selection modes are
deactivated (connector gizmo mode).
"""
if self._view is None or self._context is None:
return []
from OCP.TopExp import TopExp_Explorer
from OCP.TopAbs import TopAbs_FACE, TopAbs_EDGE, TopAbs_VERTEX
from OCP.TopoDS import TopoDS
from OCP.Bnd import Bnd_Box
from OCP.BRepBndLib import BRepBndLib
import numpy as np
candidates: Dict[Tuple[str, str, Tuple[int, int, int]], Dict[str, Any]] = {}
# Expand the search radius for the bbox pre-filter so features near
# the screen edge of a shape are not missed.
margin = radius + 40
for robj in self._objects.values():
if robj.ais_shape is None:
continue
try:
shape = robj.ais_shape.Shape()
except Exception:
continue
if shape is None:
continue
# ── Pass 0: bounding-box pre-filter ──
# Project the shape's 3D AABB to screen. If the cursor is
# outside the screen bbox (with margin), skip this shape entirely.
try:
bbox = Bnd_Box()
BRepBndLib.Add_s(shape, bbox)
if bbox.IsVoid():
continue
bx0, by0, bz0, bx1, by1, bz1 = bbox.Get()
# Project the 8 AABB corners to screen.
corners = [
(bx0, by0, bz0), (bx1, by0, bz0),
(bx0, by1, bz0), (bx1, by1, bz0),
(bx0, by0, bz1), (bx1, by0, bz1),
(bx0, by1, bz1), (bx1, by1, bz1),
]
sx_min, sy_min = 99999, 99999
sx_max, sy_max = -99999, -99999
all_behind = True
for c in corners:
sp = self._project_to_screen(c)
if sp is not None:
all_behind = False
sx_min = min(sx_min, sp[0])
sy_min = min(sy_min, sp[1])
sx_max = max(sx_max, sp[0])
sy_max = max(sy_max, sp[1])
if all_behind:
continue
# Check if cursor is within margin of the screen bbox.
if (x < sx_min - margin or x > sx_max + margin or
y < sy_min - margin or y > sy_max + margin):
continue
except Exception:
pass # If bbox fails, fall through and try features.
# ── Pass 1: iterate only nearby shapes ──
# --- Faces ---
face_expl = TopExp_Explorer(shape, TopAbs_FACE)
while face_expl.More():
face = TopoDS.Face_s(face_expl.Current())
infos = self._classify_detected_shape(face, robj.obj_id)
for info in infos:
pos = info.get("position") or (0.0, 0.0, 0.0)
sp = self._project_to_screen(pos)
if sp is None:
continue
dist2 = (sp[0] - x) ** 2 + (sp[1] - y) ** 2
if dist2 <= radius * radius:
key = (
info.get("owner_obj_id", ""),
info.get("type", ""),
(round(pos[0], 1), round(pos[1], 1), round(pos[2], 1)),
)
if key not in candidates:
info["screen"] = sp
candidates[key] = info
face_expl.Next()
# --- Edges ---
edge_expl = TopExp_Explorer(shape, TopAbs_EDGE)
while edge_expl.More():
edge = TopoDS.Edge_s(edge_expl.Current())
infos = self._classify_detected_shape(edge, robj.obj_id)
for info in infos:
pos = info.get("position") or (0.0, 0.0, 0.0)
sp = self._project_to_screen(pos)
if sp is None:
continue
dist2 = (sp[0] - x) ** 2 + (sp[1] - y) ** 2
if dist2 <= radius * radius:
key = (
info.get("owner_obj_id", ""),
info.get("type", ""),
(round(pos[0], 1), round(pos[1], 1), round(pos[2], 1)),
)
if key not in candidates:
info["screen"] = sp
candidates[key] = info
edge_expl.Next()
# --- Vertices ---
vert_expl = TopExp_Explorer(shape, TopAbs_VERTEX)
while vert_expl.More():
vertex = TopoDS.Vertex_s(vert_expl.Current())
infos = self._classify_detected_shape(vertex, robj.obj_id)
for info in infos:
pos = info.get("position") or (0.0, 0.0, 0.0)
sp = self._project_to_screen(pos)
if sp is None:
continue
dist2 = (sp[0] - x) ** 2 + (sp[1] - y) ** 2
if dist2 <= radius * radius:
key = (
info.get("owner_obj_id", ""),
info.get("type", ""),
(round(pos[0], 1), round(pos[1], 1), round(pos[2], 1)),
)
if key not in candidates:
info["screen"] = sp
candidates[key] = info
vert_expl.Next()
# Sort by screen-space distance to cursor, nearest first.
results = list(candidates.values())
results.sort(
key=lambda c: (c.get("screen", (x, y))[0] - x) ** 2
+ (c.get("screen", (x, y))[1] - y) ** 2
)
return results
def recognize_composite_features(
self, candidates: List[Dict[str, Any]], x: int, y: int, radius: int = 30
) -> List[Dict[str, Any]]:
"""Enhance raw entity candidates with composite feature recognition.
Groups nearby entities and recognizes composite features like:
* **hole** — cylindrical face (bolt/shaft insertion point)
* **edge_loop** — circular edge loop (alignment target)
* **meeting_edges** — vertex shared by two edges (corner constraint)
* **mating_surface** — large planar face (assembly plane)
Each candidate gets additional fields:
* ``feature_type`` — composite feature name (e.g. "hole", "edge_loop")
* ``suggestion`` — human-readable snap suggestion
* ``feature_data`` — dict with feature-specific info (radius, axis, etc.)
"""
import numpy as np
from collections import defaultdict
# Group candidates by owner_obj_id.
by_owner: Dict[str, List[Dict[str, Any]]] = defaultdict(list)
for c in candidates:
owner = c.get("owner_obj_id", "")
if owner:
by_owner[owner].append(c)
enhanced: List[Dict[str, Any]] = []
for c in candidates:
ec = dict(c) # copy
etype = c.get("type", "")
pos = c.get("position", (0, 0, 0))
owner = c.get("owner_obj_id", "")
# ── Cylindrical face → hole / bolt insertion ──
if etype == "cylindrical_face":
ec["feature_type"] = "hole"
ec["suggestion"] = "Bolt / shaft insertion point"
ec["feature_data"] = {
"axis": c.get("normal"),
"radius": c.get("radius"),
"center": pos,
}
enhanced.append(ec)
continue
# ── Planar face → mating surface ──
if etype == "planar_face":
ec["feature_type"] = "mating_surface"
ec["suggestion"] = "Assembly mating plane"
ec["feature_data"] = {
"normal": c.get("normal"),
"center": pos,
}
enhanced.append(ec)
continue
# ── Edge → check for circular edge loop ──
if etype == "edge":
# Look for other edges nearby that might form a loop.
nearby_edges = [
n for n in candidates
if n.get("type") == "edge"
and n.get("owner_obj_id") == owner
and n is not c
]
# For now, mark as edge — loop detection is complex.
ec["feature_type"] = "edge"
ec["suggestion"] = "Edge midpoint snap"
ec["feature_data"] = {
"tangent": c.get("normal"),
"midpoint": pos,
}
enhanced.append(ec)
continue
# ── Vertex → check for meeting edges ──
if etype == "vertex":
# Look for edges that share this vertex (nearby edges).
nearby_edges = [
n for n in candidates
if n.get("type") == "edge"
and n.get("owner_obj_id") == owner
]
if len(nearby_edges) >= 2:
ec["feature_type"] = "meeting_edges"
ec["suggestion"] = "Corner constraint (vertex)"
ec["feature_data"] = {
"vertex": pos,
"edge_count": len(nearby_edges),
}
else:
ec["feature_type"] = "vertex"
ec["suggestion"] = "Vertex snap"
ec["feature_data"] = {"vertex": pos}
enhanced.append(ec)
continue
# Fallback: pass through unchanged.
enhanced.append(ec)
return enhanced
# ─── Mouse / keyboard event forwarding ──────────────────────────────
#
# CAD-style navigation: