- Tons of addtions

This commit is contained in:
bklronin
2026-06-28 22:51:52 +02:00
parent f8f16ea800
commit f6422e0847
7 changed files with 1010 additions and 149 deletions
+15 -4
View File
@@ -4,12 +4,14 @@
<option name="autoReloadType" value="SELECTIVE" />
</component>
<component name="ChangeListManager">
<list default="true" id="8f0bafd6-58a0-4b20-aa2b-ddc3ba278873" name="Changes" comment="- Improved sketching">
<list default="true" id="8f0bafd6-58a0-4b20-aa2b-ddc3ba278873" name="Changes" comment="- Tons of addtions">
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" 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$/src/fluency/rendering/__init__.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/rendering/__init__.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/fluency/models/data_model.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/models/data_model.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$/tests/test_geometry.py" beforeDir="false" afterPath="$PROJECT_DIR$/tests/test_geometry.py" afterDir="false" />
</list>
<option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" />
@@ -263,7 +265,15 @@
<option name="project" value="LOCAL" />
<updated>1755369224187</updated>
</task>
<option name="localTasksCounter" value="20" />
<task id="LOCAL-00020" summary="- Tons of addtions">
<option name="closed" value="true" />
<created>1782673954850</created>
<option name="number" value="00020" />
<option name="presentableId" value="LOCAL-00020" />
<option name="project" value="LOCAL" />
<updated>1782673954850</updated>
</task>
<option name="localTasksCounter" value="21" />
<servers />
</component>
<component name="TypeScriptGeneratedFilesManager">
@@ -301,6 +311,7 @@
<MESSAGE value="- added MIT license" />
<MESSAGE value="- added screenshot" />
<MESSAGE value="- added sdf folder ( doesnt work via pip or git=)" />
<option name="LAST_COMMIT_MESSAGE" value="- added sdf folder ( doesnt work via pip or git=)" />
<MESSAGE value="- Tons of addtions" />
<option name="LAST_COMMIT_MESSAGE" value="- Tons of addtions" />
</component>
</project>
+98 -31
View File
@@ -46,7 +46,11 @@ class OCGeometryKernel(GeometryKernel):
self._mesh_tolerance: float = 0.1
def _get_shape(self, obj: GeometryObject) -> Any:
"""Extract the underlying OCC shape from a GeometryObject."""
"""Extract the underlying OCC shape from a GeometryObject.
Returns *None* if the object carries no shape (e.g. an empty sketch) —
callers should check for None before using the result.
"""
import cadquery as cq
if isinstance(obj, OCCGeometryObject):
@@ -64,7 +68,13 @@ class OCGeometryKernel(GeometryKernel):
if hasattr(obj.shape, "wrapped"):
return obj.shape.wrapped
return obj.shape
return obj.shape if obj.shape else obj
# No cadquery obj and no raw shape → genuinely empty.
return None
# Non-OCCGeometryObject: return its shape if present, else None.
# (Use explicit identity/truth checks — some OCP TopoDS objects have a
# falsy __bool__, so ``obj.shape if obj.shape`` is unsafe.)
shape = getattr(obj, "shape", None)
return shape if shape is not None else None
def _get_cq_obj(self, obj: GeometryObject) -> Any:
"""Get CadQuery object from GeometryObject."""
@@ -157,30 +167,94 @@ 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 sketch face into a 3D solid along the sketch plane normal.
*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).
The sketch's plane normal is read from ``sketch.metadata["normal"]``
(set by ``OCCSketch.build_face_geometry``); it defaults to +Z for
legacy objects that don't carry one. *direction* is accepted for API
compatibility but ignored — the plane normal is authoritative. A
negative *height* extrudes against the normal.
"""
import cadquery as cq
from OCP.gp import gp_Vec
from OCP.BRepPrimAPI import BRepPrimAPI_MakePrism
from OCP.BRepAlgoAPI import BRepAlgoAPI_Fuse
from OCP.TopoDS import TopoDS_Shape
cq_obj = self._get_cq_obj(sketch)
face = self._get_shape(sketch)
if face is None:
raise ValueError(
"Cannot extrude: sketch has no geometry. "
"Draw a closed profile before extruding."
)
# ``face`` may be a TopoDS_Face (new path) or a compound/wire from
# legacy cadquery objects. If it's not already a face, build one.
face = self._ensure_face(face)
if face is None:
raise ValueError(
"Cannot extrude: sketch geometry is not a valid face. "
"Ensure the profile is closed (no open ends)."
)
if isinstance(cq_obj, cq.Workplane):
if symmetric:
solid = cq_obj.extrude(height / 2, both=True)
else:
solid = cq_obj.extrude(height)
normal = self._sketch_normal(sketch)
nx, ny, nz = normal
def _prism(h: float):
vec = gp_Vec(nx * h, ny * h, nz * h)
maker = BRepPrimAPI_MakePrism(face, vec, False, True)
maker.Build()
return maker.Shape()
if symmetric:
half = height / 2.0
pos = _prism(half)
neg = _prism(-half)
fuse = BRepAlgoAPI_Fuse(pos, neg)
fuse.Build()
solid = fuse.Shape()
else:
wp = cq.Workplane("XY").add(cq_obj)
if symmetric:
solid = wp.extrude(height / 2, both=True)
else:
solid = wp.extrude(height)
solid = _prism(height)
return OCCGeometryObject(solid, {"type": "extrusion"})
return OCCGeometryObject(solid, {"type": "extrusion", "normal": normal})
@staticmethod
def _sketch_normal(obj: GeometryObject) -> Tuple[float, float, float]:
"""Return the normal stored on a sketch-derived geometry object, else +Z."""
import numpy as np
meta = getattr(obj, "metadata", None) or {}
n = meta.get("normal")
if n is None:
return (0.0, 0.0, 1.0)
arr = np.asarray(n, dtype=float)
norm = float(np.linalg.norm(arr))
if norm < 1e-12:
return (0.0, 0.0, 1.0)
arr = arr / norm
return (float(arr[0]), float(arr[1]), float(arr[2]))
@staticmethod
def _ensure_face(shape: Any) -> Any:
"""Return a ``TopoDS_Face`` from *shape*, or *None* if impossible.
If *shape* is already a face, return it unchanged; otherwise try to
build a planar face from it (wire/edge/compound). Returns *None* for
empty/invalid input so callers can surface a clear error instead of
feeding a non-face to ``BRepPrimAPI_MakePrism``.
"""
from OCP.TopoDS import TopoDS_Face
from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeFace
if shape is None:
return None
if isinstance(shape, TopoDS_Face):
return shape
try:
maker = BRepBuilderAPI_MakeFace(shape, True)
maker.Build()
if maker.IsDone():
return maker.Face()
except Exception:
pass
return None
def revolve(
self,
@@ -189,22 +263,16 @@ class OCGeometryKernel(GeometryKernel):
axis: Tuple[float, float, float] = (0, 0, 1),
origin: Tuple[float, float, float] = (0, 0, 0),
) -> GeometryObject:
"""Revolve a 2D sketch around an axis."""
import cadquery as cq
"""Revolve a sketch face around an axis."""
import math
# Get the OCC shape directly
# Get the OCC shape directly (a TopoDS_Face for new sketch geometry).
shape = self._get_shape(sketch)
face = self._ensure_face(shape)
from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeFace
from OCP.gp import gp_Ax1, gp_Pnt, gp_Dir
from OCP.BRepPrimAPI import BRepPrimAPI_MakeRevol
# Build a face from the wire/shape
face_maker = BRepBuilderAPI_MakeFace(shape, False)
face_maker.Build()
face = face_maker.Face()
# Revolve the face around the axis
revolve_axis = gp_Ax1(gp_Pnt(*origin), gp_Dir(*axis))
angle_rad = math.radians(angle)
@@ -212,8 +280,7 @@ class OCGeometryKernel(GeometryKernel):
revolver.Build()
solid_shape = revolver.Shape()
solid = cq.Shape(solid_shape)
return OCCGeometryObject(solid, {"type": "revolution"})
return OCCGeometryObject(solid_shape, {"type": "revolution"})
def loft(self, profiles: List[GeometryObject], ruled: bool = False) -> GeometryObject:
"""Create a loft between multiple profiles."""
+111 -36
View File
@@ -64,6 +64,78 @@ class OCCSketch(SketchInterface):
# Track first point as dragged/fixed for solver stability
self._first_point_id: Optional[int] = None
# ── Workplane ───────────────────────────────────────────────────
# The sketch lives in a 2D UV frame on this plane. UV coordinates
# map to world via: P = origin + u*x_dir + v*y_dir
# where y_dir = normal × x_dir. Defaults to the world XY plane so
# existing XY-only behaviour is unchanged.
self._wp_origin: Tuple[float, float, float] = (0.0, 0.0, 0.0)
self._wp_normal: Tuple[float, float, float] = (0.0, 0.0, 1.0)
self._wp_x_dir: Tuple[float, float, float] = (1.0, 0.0, 0.0)
self._wp_y_dir: Tuple[float, float, float] = (0.0, 1.0, 0.0)
# ─── Workplane management ──────────────────────────────────────────────
def set_workplane(
self,
origin: Tuple[float, float, float],
normal: Tuple[float, float, float],
x_dir: Tuple[float, float, float],
) -> None:
"""Place this sketch on an arbitrary plane in 3D.
*normal* and *x_dir* need not be unit/perpendicular — they are
orthonormalised here. ``y_dir`` is derived as ``normal × x_dir``.
Existing UV coordinates are unchanged; only their world mapping moves.
"""
import numpy as np
n = np.asarray(normal, dtype=float)
x = np.asarray(x_dir, dtype=float)
n = n / np.linalg.norm(n)
# Remove any component of x along n, then renormalise.
x = x - np.dot(x, n) * n
x_norm = np.linalg.norm(x)
if x_norm < 1e-9:
# x_dir is parallel to normal — pick any orthogonal basis vector.
fallback = np.array([1.0, 0.0, 0.0]) if abs(n[0]) < 0.9 else np.array([0.0, 1.0, 0.0])
x = fallback - np.dot(fallback, n) * n
x_norm = np.linalg.norm(x)
x = x / x_norm
y = np.cross(n, x)
y = y / np.linalg.norm(y)
self._wp_origin = tuple(float(v) for v in origin)
self._wp_normal = tuple(float(v) for v in n)
self._wp_x_dir = tuple(float(v) for v in x)
self._wp_y_dir = tuple(float(v) for v in y)
def get_workplane(self) -> Tuple[Tuple[float, float, float], ...]:
"""Return the (origin, normal, x_dir, y_dir) of this sketch's plane."""
return (self._wp_origin, self._wp_normal, self._wp_x_dir, self._wp_y_dir)
def _uv_to_world(self, u: float, v: float):
"""Map a UV point to a world ``gp_Pnt`` on the workplane."""
from OCP.gp import gp_Pnt
ox, oy, oz = self._wp_origin
xx, xy, xz = self._wp_x_dir
yx, yy, yz = self._wp_y_dir
return gp_Pnt(
ox + u * xx + v * yx,
oy + u * xy + v * yy,
oz + u * xz + v * yz,
)
def _circle_axis(self, u: float, v: float):
"""Return a ``gp_Ax2`` for a circle centred at UV on the workplane."""
from OCP.gp import gp_Ax2, gp_Dir
center = self._uv_to_world(u, v)
return gp_Ax2(
center,
gp_Dir(*self._wp_normal),
gp_Dir(*self._wp_x_dir),
)
@property
def solver(self) -> SolverSystem:
"""Access the underlying SolveSpace solver."""
@@ -71,7 +143,13 @@ class OCCSketch(SketchInterface):
@property
def workplane(self) -> Any:
"""Get the solver workplane entity."""
"""Get the SolveSpace 2D solver workplane entity.
Note: this is the solver's internal 2D base, not the 3D placement
plane — see :meth:`set_workplane` / :meth:`workplane` (no underscore)
for the 3D plane. The solver always runs in UV regardless of the
3D placement.
"""
return self._wp
def _next_id(self) -> int:
@@ -576,42 +654,39 @@ class OCCSketch(SketchInterface):
# ─── Geometry extraction for operations ────────────────────────────────
def get_geometry(self) -> GeometryObject:
"""Get the solved geometry for operations using CadQuery.
"""Get the solved geometry as an OCC ``TopoDS_Face`` on the workplane.
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.
holes) that face is returned. Otherwise falls back to returning a
single circle or polygon as a face. The returned object carries the
workplane normal in ``metadata["normal"]`` so the kernel can extrude
along the plane normal (not a hardcoded +Z).
"""
import cadquery as cq
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.
# Fallback: wrap the first circle, or the polygon, as a single-loop face.
if self._circles:
for entity_id, (center_id, radius) in self._circles.items():
center_entity = self._entities.get(center_id)
if center_entity and center_entity.geometry:
cx, cy = center_entity.geometry
wp = cq.Workplane("XY").center(cx, cy).circle(radius)
obj = OCCGeometryObject(wp.val())
obj._cadquery_obj = wp
return obj
face_dict = {
"outer": {"type": "circle", "center": (cx, cy), "radius": radius},
"holes": [],
}
return self.build_face_geometry(face_dict)
points = self.get_polygon_points()
if not points:
return OCCGeometryObject(None)
wp = cq.Workplane("XY").moveTo(points[0].x, points[0].y)
for pt in points[1:]:
wp = wp.lineTo(pt.x, pt.y)
wp = wp.close()
obj = OCCGeometryObject(wp.val())
obj._cadquery_obj = wp
return obj
face_dict = {
"outer": {"type": "polygon", "points": [(p.x, p.y) for p in points]},
"holes": [],
}
return self.build_face_geometry(face_dict)
def get_points(self) -> List[Point2D]:
"""Get all point positions from solved solver data."""
@@ -910,34 +985,33 @@ class OCCSketch(SketchInterface):
return best
def build_face_geometry(self, face: Dict[str, Any]) -> OCCGeometryObject:
"""Build an OCC face (outer boundary + inner holes) wrapped in a Workplane.
"""Build an OCC face (outer boundary + inner holes) on the 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.
Wires are constructed from UV coordinates mapped through
:meth:`_uv_to_world`, so the resulting ``TopoDS_Face`` lies on this
sketch's 3D plane (not necessarily XY). The returned object stores
the raw OCC face in ``.shape`` and the plane normal in
``metadata["normal"]`` for the extrude kernel.
"""
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))
for (pu, pv) in loop["points"]:
mp.Add(self._uv_to_world(pu, pv))
mp.Close()
mp.Build()
w = mp.Wire()
else:
cx, cy = loop["center"]
cu, cv = loop["center"]
r = loop["radius"]
circ = gp_Circ(gp_Ax2(gp_Pnt(cx, cy, 0.0), gp_Dir(0, 0, 1)), r)
circ = gp_Circ(self._circle_axis(cu, cv), r)
me = BRepBuilderAPI_MakeEdge(circ)
me.Build()
mw = BRepBuilderAPI_MakeWire()
@@ -945,7 +1019,7 @@ class OCCSketch(SketchInterface):
mw.Build()
w = mw.Wire()
if is_hole:
w = _TopoDS.Wire_s(w.Reversed()) # reverse orientation so OCC treats it as a hole
w = _TopoDS.Wire_s(w.Reversed()) # reverse so OCC treats it as a hole
return w
outer_wire = wire_loop(face["outer"], is_hole=False)
@@ -955,10 +1029,11 @@ class OCCSketch(SketchInterface):
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
obj = OCCGeometryObject(occ_face, {
"type": "sketch_face",
"normal": self._wp_normal,
"origin": self._wp_origin,
})
return obj
def get_solver_dof(self) -> int:
+349
View File
@@ -63,6 +63,7 @@ from PySide6.QtGui import (
QFont,
QFontMetrics,
QCursor,
QPolygonF,
)
from fluency.geometry_occ.kernel import OCGeometryKernel, OCCGeometryObject
@@ -72,6 +73,66 @@ from fluency.rendering.occ_renderer import OCCRenderer
from fluency.models.data_model import Project, Component, Sketch, Body
def _project_face_to_uv(
face: Any,
workplane: Tuple[Tuple[float, float, float], Tuple[float, float, float], Tuple[float, float, float]],
) -> List[List[Tuple[float, float]]]:
"""Project a planar ``TopoDS_Face``'s boundary edges into the UV frame.
*workplane* is (origin, normal, x_dir). Returns a list of polylines,
each a list of (u, v) points, one per boundary edge (lines → endpoints,
curves → sampled). Used by the 2D sketch widget to draw the face as an
underlay when sketching on a surface.
"""
import numpy as np
from OCP.TopExp import TopExp_Explorer
from OCP.TopAbs import TopAbs_EDGE, TopAbs_WIRE
from OCP.TopoDS import TopoDS
from OCP.BRepAdaptor import BRepAdaptor_Curve
from OCP.GeomAbs import GeomAbs_Line
from OCP.gp import gp_Pnt
origin = np.asarray(workplane[0], dtype=float) # (x,y,z)
normal = np.asarray(workplane[1], dtype=float) # plane normal
x_dir = np.asarray(workplane[2], dtype=float) # in-plane x axis
x_dir = x_dir / np.linalg.norm(x_dir)
normal = normal / np.linalg.norm(normal)
y_dir = np.cross(normal, x_dir)
y_dir = y_dir / np.linalg.norm(y_dir)
def world_to_uv(p: gp_Pnt) -> Tuple[float, float]:
v = np.array([p.X() - origin[0], p.Y() - origin[1], p.Z() - origin[2]])
return (float(np.dot(v, x_dir)), float(np.dot(v, y_dir)))
polylines: List[List[Tuple[float, float]]] = []
# Iterate wires of the face (outer + inner = holes), then edges.
wire_expl = TopExp_Explorer(face, TopAbs_WIRE)
while wire_expl.More():
wire = wire_expl.Current()
edge_expl = TopExp_Explorer(wire, TopAbs_EDGE)
while edge_expl.More():
edge = TopoDS.Edge_s(edge_expl.Current())
try:
crv = BRepAdaptor_Curve(edge)
f = crv.FirstParameter()
l = crv.LastParameter()
is_line = crv.GetType() == GeomAbs_Line
if is_line:
pts = [crv.Value(f), crv.Value(l)]
else:
# Sample 32 segments across the parameter range.
pts = [crv.Value(f + (l - f) * i / 32.0) for i in range(33)]
poly = [world_to_uv(p) for p in pts]
polylines.append(poly)
except Exception:
pass
edge_expl.Next()
wire_expl.Next()
return polylines
class ExtrudeDialog(QDialog):
"""Dialog for extrude options."""
@@ -196,6 +257,13 @@ class Sketch2DWidget(QWidget):
self._selected_face: Any = None
self._selected_entities: List[OCCSketchEntity] = []
# Source face for sketch-on-surface: the planar face the user picked
# in the 3D viewer, plus its workplane (origin/normal/x_dir). Phase 3
# projects this face's edges into UV and draws them as an underlay.
self._source_face: Any = None
self._source_workplane: Optional[Tuple[Tuple[float, float, float], ...]] = None
self._source_underlay_uv: List[Any] = [] # cached UV polylines for paintEvent
self._snap_mode: Dict[str, bool] = {
"point": True,
"mpoint": False,
@@ -237,6 +305,69 @@ class Sketch2DWidget(QWidget):
self._rebuild_from_sketch()
self._draw_buffer = []
self._clear_face_state()
# If the new sketch carries a workplane, refresh the source underlay.
self._refresh_source_underlay()
self.update()
def set_source_face(
self,
face: Any,
origin: Tuple[float, float, float],
normal: Tuple[float, float, float],
x_dir: Tuple[float, float, float],
) -> None:
"""Store the picked 3D face and reorient the 2D view to its plane.
Called by MainWindow after a face pick. Projects the face's boundary
edges into the sketch's UV frame and caches them for the paintEvent
underlay. Also re-centres/scales the 2D view to look down the plane.
"""
self._source_face = face
self._source_workplane = (tuple(origin), tuple(normal), tuple(x_dir))
# Ensure the OCCSketch shares the same workplane so UV↔world agrees.
if self._sketch is not None:
self._sketch.set_workplane(origin, normal, x_dir)
self._refresh_source_underlay()
self._orient_view_to_plane()
self.update()
def _refresh_source_underlay(self) -> None:
"""Project the source face's boundary edges into UV for the underlay."""
self._source_underlay_uv = []
if self._source_face is None or self._source_workplane is None:
return
if self._sketch is None:
return
try:
self._source_underlay_uv = _project_face_to_uv(
self._source_face, self._source_workplane
)
except Exception:
logger.debug("source underlay projection failed", exc_info=True)
def _orient_view_to_plane(self) -> None:
"""Centre & scale the 2D view to fit the source face's UV bounds."""
if not self._source_underlay_uv:
return
# Collect all UV points across all cached polylines.
all_pts = [pt for poly in self._source_underlay_uv for pt in poly]
if not all_pts:
return
us = [p[0] for p in all_pts]
vs = [p[1] for p in all_pts]
umin, umax = min(us), max(us)
vmin, vmax = min(vs), max(vs)
cu, cv = (umin + umax) / 2.0, (vmin + vmax) / 2.0
du, dv = max(umax - umin, 1e-6), max(vmax - vmin, 1e-6)
# Zoom so the face fits with ~20% margin; offset so the face centre
# maps to the widget centre (world (cu,cv) → screen centre).
w, h = max(self.width(), 100), max(self.height(), 100)
self._zoom = min(w / (du * 1.2), h / (dv * 1.2))
# _world_to_screen: screen = world*zoom + centre + offset.
# We want world (cu,cv) → screen (w/2, h/2). Solve for offset.
# screen_x = cu*zoom + w/2 + offset_x → offset_x = -cu*zoom
# screen_y = h/2 - cv*zoom + offset_y → offset_y = cv*zoom
self._offset = QPoint(int(-cu * self._zoom), int(cv * self._zoom))
self.update()
def _clear_face_state(self):
@@ -1536,6 +1667,36 @@ class Sketch2DWidget(QWidget):
painter.drawLine(origin.x() - 20, origin.y(), origin.x() + 20, origin.y())
painter.drawLine(origin.x(), origin.y() - 20, origin.x(), origin.y() + 20)
# ── Source-face underlay (sketch-on-surface) ──
# Draw the picked face's boundary edges in UV as a dashed guide so the
# user sees the face they're drawing on, aligned to the 2D frame.
if self._source_underlay_uv:
painter.setPen(QPen(QColor("#fab387"), 1, Qt.DashLine))
for poly in self._source_underlay_uv:
if len(poly) < 2:
continue
sp0 = self._world_to_screen(
QPoint(int(round(poly[0][0])), int(round(poly[0][1])))
)
prev = sp0
for (u, v) in poly[1:]:
sp = self._world_to_screen(
QPoint(int(round(u)), int(round(v)))
)
painter.drawLine(prev, sp)
prev = sp
# Close the polyline back to its start for a clean loop.
painter.drawLine(prev, sp0)
# Subtle fill hint over the first (outer) loop for readability.
if self._source_underlay_uv[0] and len(self._source_underlay_uv[0]) >= 3:
fill_poly = QPolygonF([
self._world_to_screen(QPoint(int(round(u)), int(round(v))))
for (u, v) in self._source_underlay_uv[0]
])
painter.setBrush(QBrush(QColor(250, 179, 135, 28)))
painter.setPen(Qt.NoPen)
painter.drawPolygon(fill_poly)
# ── Points ──
for entity in self._points:
if entity.geometry:
@@ -1721,12 +1882,20 @@ class Sketch2DWidget(QWidget):
class Viewer3DWidget(QWidget):
"""3D viewer widget using OCC's native AIS display."""
# Emitted when the user picks a planar face to sketch on.
# Payload: (origin, normal, x_dir, face_shape) — all tuples are (x,y,z).
facePicked = Signal(tuple, tuple, tuple, object)
# Emitted when face-pick mode is cancelled (Esc) so the host can uncheck.
pickFaceCancelled = Signal()
def __init__(self, parent=None):
super().__init__(parent)
# For OCC's direct OpenGL rendering we need Qt to not paint over it.
self.setAttribute(Qt.WA_PaintOnScreen)
self.setAttribute(Qt.WA_OpaquePaintEvent)
self.setAutoFillBackground(False)
# Accept keyboard focus so navigation shortcuts (F, R, 1-7, P, O) work.
self.setFocusPolicy(Qt.StrongFocus)
# Try OCC renderer first; fall back to pygfx if unavailable.
self._renderer: Any = None
self._initialized = False
@@ -1734,6 +1903,9 @@ class Viewer3DWidget(QWidget):
self._selected_normal: Optional[Tuple[float, float, float]] = None
self._centroid: Optional[Tuple[float, float, float]] = None
self._pending_meshes: List[Tuple] = []
# When True, a left-click picks a planar face (for sketch-on-surface)
# instead of orbiting the camera. Set via set_pick_face_mode().
self._pick_face_mode: bool = False
def _init_renderer(self) -> None:
"""Create the best available renderer."""
@@ -1863,11 +2035,22 @@ class Viewer3DWidget(QWidget):
def mousePressEvent(self, event):
self._ensure_initialized()
# Face-pick mode: a left-click selects a planar face to sketch on.
if self._pick_face_mode and event.button() == Qt.LeftButton:
self._handle_face_pick(event)
return
self._renderer.handle_mouse_press(event)
super().mousePressEvent(event)
def mouseMoveEvent(self, event):
self._ensure_initialized()
# In face-pick mode, keep dynamic highlighting but don't orbit.
if self._pick_face_mode:
if hasattr(self._renderer, "handle_mouse_move"):
# Still forward for hover-detect (no button held → detect only).
self._renderer.handle_mouse_move(event)
super().mouseMoveEvent(event)
return
self._renderer.handle_mouse_move(event)
super().mouseMoveEvent(event)
@@ -1899,7 +2082,52 @@ class Viewer3DWidget(QWidget):
self._renderer.set_camera_position(position, target)
self._renderer.render()
# ─── Face-pick mode (sketch-on-surface) ────────────────────────────────
def set_pick_face_mode(self, enabled: bool) -> None:
"""Toggle face-pick mode.
When enabled, the cursor selects planar faces for sketch placement
instead of orbiting the camera. Middle/right buttons still pan/zoom.
"""
self._pick_face_mode = bool(enabled)
if enabled:
self.setCursor(Qt.CrossCursor)
else:
self.unsetCursor()
def is_pick_face_mode(self) -> bool:
return self._pick_face_mode
def _handle_face_pick(self, event) -> None:
"""Detect a planar face under the click and emit facePicked."""
self._ensure_initialized()
picker = getattr(self._renderer, "pick_planar_face", None)
if picker is None:
logger.warning("Renderer has no pick_planar_face support")
return
# Qt6: prefer position().toPoint() over deprecated pos().
pos = event.position().toPoint() if hasattr(event, "position") else event.pos()
info = picker(pos.x(), pos.y())
if info is None:
logger.info("Face pick: no planar face under cursor")
return
self.facePicked.emit(
tuple(info["origin"]),
tuple(info["normal"]),
tuple(info["x_dir"]),
info["face"],
)
def set_view(self, view: str):
# Prefer the renderer's native orientation snap (preserves target,
# refits the scene). Falls back to absolute eye positions for
# renderers that don't implement set_view_orientation.
self._ensure_initialized()
if hasattr(self._renderer, "set_view_orientation"):
self._renderer.set_view_orientation(view)
self._renderer.render()
return
positions = {
"iso": ((100, 100, 100), (0, 0, 0)),
"top": ((0, 0, 200), (0, 0, 0)),
@@ -1913,6 +2141,58 @@ class Viewer3DWidget(QWidget):
pos, target = positions[view]
self.set_camera_position(pos, target)
def mouseDoubleClickEvent(self, event):
# Double-click → fit all (common CAD convention).
self._ensure_initialized()
if event.button() == Qt.LeftButton:
self.fit_camera()
super().mouseDoubleClickEvent(event)
def keyPressEvent(self, event):
# Esc cancels face-pick mode.
if self._pick_face_mode and event.key() == Qt.Key_Escape:
self.set_pick_face_mode(False)
self.pickFaceCancelled.emit()
return
# Navigation shortcuts (lowercase = view presets, F = fit,
# P/O = perspective/orthographic, R = reset).
self._ensure_initialized()
key = event.text().lower()
mapping = {
"f": "fit",
"r": "reset",
"1": "front",
"2": "back",
"3": "top",
"4": "bottom",
"5": "left",
"6": "right",
"7": "iso",
}
action = mapping.get(key)
if action == "fit":
self.fit_camera()
return
if action == "reset":
if hasattr(self._renderer, "reset_camera"):
self._renderer.reset_camera()
self._renderer.render()
else:
self.set_view("iso")
return
if action in ("front", "back", "top", "bottom", "left", "right", "iso"):
self.set_view(action)
return
if key == "p" and hasattr(self._renderer, "set_camera_perspective"):
self._renderer.set_camera_perspective()
self._renderer.render()
return
if key == "o" and hasattr(self._renderer, "set_camera_orthographic"):
self._renderer.set_camera_orthographic()
self._renderer.render()
return
super().keyPressEvent(event)
class MainWindow(QMainWindow):
"""Main application window."""
@@ -2198,6 +2478,12 @@ class MainWindow(QMainWindow):
sk_tools_grid.addWidget(self._btn_edit_sketch, 0, 1)
self._btn_del_sketch = QPushButton("Del")
sk_tools_grid.addWidget(self._btn_del_sketch, 0, 2)
self._btn_face_sketch = QPushButton("Face")
self._btn_face_sketch.setToolTip(
"Pick a planar face in the 3D viewer to sketch on"
)
self._btn_face_sketch.setCheckable(True)
sk_tools_grid.addWidget(self._btn_face_sketch, 1, 0, 1, 3)
sk_list_layout.addWidget(sk_tools)
grid.addWidget(sk_list_group, 6, 0, 3, 1)
@@ -2425,6 +2711,11 @@ class MainWindow(QMainWindow):
self._btn_add_sketch.clicked.connect(self._add_sketch_to_component)
self._btn_edit_sketch.clicked.connect(self._edit_sketch)
self._btn_del_sketch.clicked.connect(self._delete_sketch)
self._btn_face_sketch.toggled.connect(self._on_face_sketch_toggled)
self._viewer_3d.facePicked.connect(self._on_face_picked)
self._viewer_3d.pickFaceCancelled.connect(
lambda: self._btn_face_sketch.setChecked(False)
)
self._btn_new_compo.clicked.connect(self._new_component)
self._btn_del_compo.clicked.connect(self._delete_component)
@@ -2643,6 +2934,59 @@ class MainWindow(QMainWindow):
def _pattern_array(self):
logger.info("Pattern array not yet implemented")
# ─── Sketch-on-surface (face pick) ────────────────────────────────────
def _on_face_sketch_toggled(self, checked: bool) -> None:
"""Toggle the 3D viewer's face-pick mode."""
self._viewer_3d.set_pick_face_mode(checked)
if checked:
# Make sure the 3D viewer has focus so it receives the click.
self._viewer_3d.setFocus()
self._viewer_3d.activateWindow()
self.statusBar().showMessage(
"Pick a planar face in the 3D viewer to sketch on (Esc to cancel)",
8000,
)
def _on_face_picked(self, origin, normal, x_dir, face_shape) -> None:
"""Create a new sketch on the picked planar face and switch to 2D."""
logger.info(
f"Face picked: origin={origin}, normal={normal}, x_dir={x_dir}"
)
# Leave pick mode (the button stays toggled until we uncheck it).
self._btn_face_sketch.setChecked(False)
self._viewer_3d.set_pick_face_mode(False)
if not self._current_component:
self._current_component = self._project.add_component()
sketch = self._current_component.add_sketch()
sketch.name = f"Sketch on face {len(self._current_component.sketches)}"
# Place the sketch on the picked plane (sets fields + syncs occ_sketch).
sketch.set_workplane(origin, normal, x_dir)
# Keep the face reference for the projection underlay (Phase 3).
sketch._source_face = face_shape
# Hand the sketch to the 2D widget and focus the sketch panel.
if sketch.occ_sketch is None:
sketch.occ_sketch = self._sketch_widget.create_sketch()
sketch.apply_workplane()
self._sketch_widget.set_sketch(sketch.occ_sketch)
self._sketch_widget.set_source_face(face_shape, origin, normal, x_dir)
self._current_sketch = sketch
self._sketch_widget.set_mode("line")
self._btn_line.setChecked(True)
self._refresh_lists()
# Switch focus to the sketch panel so the user can draw immediately.
self._set_panel_focus("sketch")
self.statusBar().showMessage(
f"Sketch placed on face — drawing in 2D on that plane", 6000
)
def _pattern_array_placeholder(self):
pass
def _add_sketch_to_component(self):
logger.info("=== ADD SKETCH TO COMPONENT ===")
if not self._current_component:
@@ -2660,6 +3004,10 @@ class MainWindow(QMainWindow):
logger.info("Creating new sketch in widget")
sketch.occ_sketch = self._sketch_widget.create_sketch()
# Sync the sketch's workplane (origin/normal/x_dir) into the OCC sketch
# so geometry is built on the right plane.
sketch.apply_workplane()
self._current_sketch = sketch
self._refresh_lists()
self._sketch_widget.set_mode(None)
@@ -2676,6 +3024,7 @@ class MainWindow(QMainWindow):
if sketch.name == name:
self._current_sketch = sketch
if sketch.occ_sketch:
sketch.apply_workplane()
self._sketch_widget.set_sketch(sketch.occ_sketch)
self._sketch_widget.set_mode("line")
self._btn_line.setChecked(True)
+27 -1
View File
@@ -6,7 +6,7 @@ including projects, components, sketches, and bodies.
"""
from dataclasses import dataclass, field
from typing import Dict, List, Optional, Any
from typing import Dict, List, Optional, Any, Tuple
from datetime import datetime
import uuid
import numpy as np
@@ -46,6 +46,32 @@ class Sketch:
created_at: datetime = field(default_factory=datetime.now)
modified_at: datetime = field(default_factory=datetime.now)
def set_workplane(
self,
origin: Tuple[float, float, float],
normal: Tuple[float, float, float],
x_dir: Tuple[float, float, float],
) -> None:
"""Set this sketch's 3D workplane and sync it to the OCC sketch.
Call this when the sketch is placed on a face/datum plane. UV
coordinates are unchanged; only their world mapping moves.
"""
self.workplane_origin = np.asarray(origin, dtype=float)
self.workplane_normal = np.asarray(normal, dtype=float)
self.workplane_x_dir = np.asarray(x_dir, dtype=float)
self.apply_workplane()
self.modified_at = datetime.now()
def apply_workplane(self) -> None:
"""Push the stored workplane fields into the underlying OCCSketch."""
if self.occ_sketch is not None:
self.occ_sketch.set_workplane(
tuple(self.workplane_origin.tolist()),
tuple(self.workplane_normal.tolist()),
tuple(self.workplane_x_dir.tolist()),
)
def add_point(self, x: float, y: float) -> Any:
"""Add a point to the sketch."""
self.modified_at = datetime.now()
+356 -77
View File
@@ -41,6 +41,7 @@ class OCCRenderer(Renderer):
self._parent_widget: Any = None
self._last_mouse_x: int = 0
self._last_mouse_y: int = 0
self._nav_mode: Optional[str] = None # "rotate" | "pan" | "zoom" | None
def initialize(self, parent_widget: Any) -> bool:
"""Initialise OCC viewer inside *parent_widget* (a QWidget)."""
@@ -56,11 +57,29 @@ class OCCRenderer(Renderer):
Aspect_DisplayConnection,
Aspect_NeutralWindow,
Aspect_GFM_VER,
Aspect_TypeOfTriedronPosition,
)
from OCP.OpenGl import OpenGl_GraphicDriver
from OCP.V3d import V3d_Viewer, V3d_View, V3d_TypeOfView
from OCP.V3d import (
V3d_Viewer,
V3d_View,
V3d_TypeOfView,
V3d_DirectionalLight,
V3d_AmbientLight,
V3d_TypeOfOrientation,
)
from OCP.AIS import AIS_InteractiveContext
from OCP.Quantity import Quantity_Color, Quantity_TOC_RGB
from OCP.Graphic3d import (
Graphic3d_Camera,
Graphic3d_TypeOfShadingModel,
Graphic3d_MaterialAspect,
Graphic3d_NameOfMaterial,
)
from OCP.Quantity import (
Quantity_Color,
Quantity_TOC_RGB,
Quantity_NameOfColor,
)
logger.info("OCCRenderer imports complete")
hwnd = int(parent_widget.winId())
@@ -74,17 +93,68 @@ class OCCRenderer(Renderer):
display = Aspect_DisplayConnection()
driver = OpenGl_GraphicDriver(display)
viewer = V3d_Viewer(driver)
viewer.SetDefaultLights()
viewer.SetLightOn()
# ── Lighting: replace defaults with a tuned 3-light rig ───────
# Key light (warm directional from upper-front-right) + fill
# (cool, softer) + ambient for base lift. Gives proper shading
# on curved BRep surfaces (cylinders, spheres).
viewer.SetLightOff() # clear default lights first
key = V3d_DirectionalLight(
V3d_TypeOfOrientation.V3d_XposYnegZpos,
Quantity_Color(1.0, 0.96, 0.88, Quantity_TOC_RGB),
True,
)
fill = V3d_DirectionalLight(
V3d_TypeOfOrientation.V3d_XnegYnegZpos,
Quantity_Color(0.55, 0.62, 0.78, Quantity_TOC_RGB),
True,
)
rim = V3d_DirectionalLight(
V3d_TypeOfOrientation.V3d_XposYposZneg,
Quantity_Color(0.5, 0.5, 0.55, Quantity_TOC_RGB),
True,
)
ambient = V3d_AmbientLight(
Quantity_Color(0.35, 0.35, 0.4, Quantity_TOC_RGB)
)
for light in (key, fill, rim, ambient):
viewer.SetLightOn(light)
view = V3d_View(viewer, V3d_TypeOfView.V3d_ORTHOGRAPHIC)
# ── Background gradient (dark studio look) ───────────────────
viewer.SetDefaultBgGradientColors(
Quantity_Color(0.15, 0.15, 0.2, Quantity_TOC_RGB),
Quantity_Color(0.25, 0.25, 0.3, Quantity_TOC_RGB),
Quantity_Color(0.18, 0.20, 0.24, Quantity_TOC_RGB),
Quantity_Color(0.08, 0.09, 0.11, Quantity_TOC_RGB),
Aspect_GFM_VER,
)
view.SetBgGradientColors(
Quantity_Color(0.18, 0.20, 0.24, Quantity_TOC_RGB),
Quantity_Color(0.08, 0.09, 0.11, Quantity_TOC_RGB),
Aspect_GFM_VER,
True,
)
# ── Rendering quality: MSAA + Phong shading + AA ─────────────
params = view.ChangeRenderingParams()
params.NbMsaaSamples = 4 # 4x MSAA for smoother edges
params.IsAntialiasingEnabled = True
params.LineFeather = 0.6
view.SetShadingModel(Graphic3d_TypeOfShadingModel.Graphic3d_TypeOfShadingModel_Phong)
# ── Corner orientation trihedron (axis gizmo) ────────────────
try:
view.TriedronDisplay(
Aspect_TypeOfTriedronPosition.Aspect_TOTP_LEFT_LOWER,
Quantity_Color(Quantity_NameOfColor.Quantity_NOC_WHITE),
0.08,
)
except Exception:
logger.debug("TriedronDisplay unavailable", exc_info=True)
context = AIS_InteractiveContext(viewer)
# Default display mode = shaded (AIS_Shaded = 1)
context.SetDisplayMode(1, True)
# Attach OCC view to the Qt widget via the native window handle.
win = Aspect_NeutralWindow()
@@ -92,6 +162,8 @@ class OCCRenderer(Renderer):
w, h = parent_widget.width(), parent_widget.height()
win.SetSize(w, h)
view.SetWindow(win)
# Ensure the depth range follows the scene so nothing clips.
view.SetAutoZFitMode(True)
self._viewer = viewer
self._view = view
@@ -138,18 +210,39 @@ class OCCRenderer(Renderer):
ais = AIS_Shape(shape)
# Order matters: set material *before* color so the per-channel color
# overrides only the diffuse albedo and the plastic BRDF is retained.
ais.SetMaterial(self._default_material())
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
# Shaded display with boundary edges drawn on top for readability.
ais.SetDisplayMode(1) # 1 = AIS_Shaded
drawer = ais.Attributes()
try:
# Always draw face boundaries — makes solid edges crisp.
drawer.SetFaceBoundaryDraw(True)
except Exception:
logger.debug("boundary-draw attrs unavailable", exc_info=True)
# Slight polygon offset so boundary edges don't z-fight the surface.
# Mode 3 = Graphic3d_POM_Fill (offset filled polygons).
try:
ais.SetPolygonOffsets(3, 1.0, 0.0)
except Exception:
logger.debug("polygon offset unavailable", exc_info=True)
self._context.Display(ais, True)
self._context.Deactivate(ais)
# Activate selection modes so the viewer can detect/pick the whole
# shape (mode 0) and individual faces (mode for TopAbs_FACE) — needed
# for sketch-on-surface face picking. Left-click orbits the camera
# (see handle_mouse_press), so active selection doesn't interfere.
try:
from OCP.TopAbs import TopAbs_FACE
face_mode = AIS_Shape.SelectionMode_s(TopAbs_FACE)
self._context.Activate(ais, face_mode)
except Exception:
logger.debug("face selection mode activation failed", exc_info=True)
defcol = color or (0.5, 0.5, 0.5)
robj = OCCRenderObject(
@@ -170,12 +263,17 @@ class OCCRenderer(Renderer):
return obj_id
def _default_material(self):
"""Return a default Graphic3d_MaterialAspect for shading."""
"""Return a default Graphic3d_MaterialAspect for shading.
Plastic gives a clean, readable matte surface that responds well to
the 3-light rig set up in :meth:`initialize`.
"""
from OCP.Graphic3d import Graphic3d_MaterialAspect, Graphic3d_NameOfMaterial
return Graphic3d_MaterialAspect(
mat = Graphic3d_MaterialAspect(
Graphic3d_NameOfMaterial.Graphic3d_NOM_PLASTIC
)
return mat
# ─── Legacy mesh / wireframe (kept for backward compat) ────────────
@@ -278,12 +376,16 @@ class OCCRenderer(Renderer):
"""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)
# Remove every displayed AIS object. ``RemoveAll`` is the cleanest
# path; fall back to iterating the displayed list if unavailable.
try:
self._context.RemoveAll(True)
except Exception:
from OCP.AIS import AIS_ListOfInteractive, AIS_KindOfInteractive
lst = AIS_ListOfInteractive()
self._context.DisplayedObjects(AIS_KindOfInteractive.AIS_KOI_None, -1, lst)
for ais in lst:
self._context.Remove(ais, True)
self._objects.clear()
def update_mesh(
@@ -327,15 +429,14 @@ class OCCRenderer(Renderer):
target: Tuple[float, float, float] = (0, 0, 0),
up: Tuple[float, float, float] = (0, 0, 1),
) -> None:
"""Set camera position and orientation."""
"""Set camera eye, target (at) and up vectors."""
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))
self._view.SetEye(float(position[0]), float(position[1]), float(position[2]))
self._view.SetAt(float(target[0]), float(target[1]), float(target[2]))
self._view.SetUp(float(up[0]), float(up[1]), float(up[2]))
self._view.ZFitAll()
self._view.Redraw()
def get_camera_position(self) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
"""Get camera position, target, and up vector."""
@@ -346,47 +447,89 @@ class OCCRenderer(Renderer):
np.array([0, 0, 1]),
)
eye = self._view.Eye()
center = self._view.Center()
at = self._view.At()
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 _xyz(v):
if isinstance(v, (tuple, list)):
return np.array([float(v[0]), float(v[1]), float(v[2])])
return np.array([v.X(), v.Y(), v.Z()])
return (_xyz(eye), _xyz(at), _xyz(up))
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.
OCC's ``FitAll``. A small value like 0.05 adds 5% margin.
"""
if self._view is None:
return
margin = max(0.0, min(padding, 0.99))
self._view.FitAll(margin)
self._view.ZFitAll()
def set_view_orientation(self, orientation: str = "iso") -> None:
"""Snap the camera to a standard CAD view.
orientation {"front","back","top","bottom","left","right","iso"}.
Preserves the current target and distance, then refits.
"""
if self._view is None:
return
from OCP.V3d import V3d_TypeOfOrientation
mapping = {
"front": V3d_TypeOfOrientation.V3d_Yneg,
"back": V3d_TypeOfOrientation.V3d_Ypos,
"top": V3d_TypeOfOrientation.V3d_Zpos,
"bottom": V3d_TypeOfOrientation.V3d_Zneg,
"left": V3d_TypeOfOrientation.V3d_Xneg,
"right": V3d_TypeOfOrientation.V3d_Xpos,
"iso": V3d_TypeOfOrientation.V3d_XposYnegZpos,
}
orient = mapping.get(orientation.lower())
if orient is None:
logger.warning(f"unknown view orientation: {orientation}")
return
self._view.SetProj(orient)
self.fit_camera()
self._view.Redraw()
def reset_camera(self) -> None:
"""Reset to the default isometric view and fit all."""
self.set_view_orientation("iso")
def set_camera_perspective(
self, fov: float = 50.0, near: float = 0.1, far: float = 10000.0
self, fov: float = 45.0, near: float = 0.1, far: float = 100000.0
) -> None:
"""Set perspective camera."""
"""Switch to a perspective camera with the given FOV (degrees).
Near/far planes are auto-fit by OCC (``ZFitAll``) the *near*/*far*
args are accepted for API compatibility but ignored.
"""
if self._view is None:
return
from OCP.V3d import V3d_PERSPECTIVE
from OCP.Graphic3d import Graphic3d_Camera
self._view.SetComputedMode(False) # manual mode
self._view.ChangeRenderingParams()
# perspective/orthographic toggle handled in set_camera_orthographic
cam = self._view.Camera()
cam.SetProjectionType(Graphic3d_Camera.Projection_Perspective)
cam.SetFOVy(fov)
self._view.ZFitAll()
self._view.Redraw()
def set_camera_orthographic(
self, width: float = 100.0, near: float = 0.1, far: float = 10000.0
self, width: float = 100.0, near: float = 0.1, far: float = 100000.0
) -> None:
"""Set orthographic camera."""
"""Switch to an orthographic camera."""
if self._view is None:
return
from OCP.V3d import V3d_ORTHOGRAPHIC
from OCP.Graphic3d import Graphic3d_Camera
self._view.SetComputedMode(False)
cam = self._view.Camera()
cam.SetProjectionType(Graphic3d_Camera.Projection_Orthographic)
self._view.ZFitAll()
self._view.Redraw()
# ─── Rendering ─────────────────────────────────────────────────────
@@ -444,56 +587,192 @@ class OCCRenderer(Renderer):
) -> Tuple[float, float, float]:
return (0.0, 0.0, 0.0)
# ─── Mouse event forwarding ────────────────────────────────────────
# ─── Face picking (for sketch-on-surface) ────────────────────────────
def pick_planar_face(self, x: int, y: int) -> Optional[Dict[str, Any]]:
"""Pick the planar face under screen pixel (x, y).
Returns a dict with ``origin``, ``normal``, ``x_dir`` (a stable
in-plane axis) and ``face`` (the ``TopoDS_Face``), or *None* if the
hit is not a planar face. The plane is derived from the face's
underlying surface via ``BRepAdaptor_Surface``; *x_dir* is taken
from the face's first edge direction so the UV frame is aligned to
the face geometry.
"""
if self._view is None or self._context is None:
return None
from OCP.BRepAdaptor import BRepAdaptor_Surface
from OCP.GeomAbs import GeomAbs_Plane
from OCP.TopoDS import TopoDS_Face, TopoDS
from OCP.TopExp import TopExp_Explorer
from OCP.TopAbs import TopAbs_EDGE, TopAbs_FACE
from OCP.BRep import BRep_Tool
from OCP.gp import gp_Pln, gp_Dir, gp_Pnt
import numpy as np
# Detect what's under the cursor.
self._context.MoveTo(x, y, self._view, True)
if not self._context.HasDetected():
return None
shape = self._context.DetectedShape()
if shape is None:
return None
# The detected sub-shape is returned as a TopoDS_Shape; with face
# selection mode active it downcasts to a TopoDS_Face. Try the
# downcast; if it isn't a face, give up (non-planar/edge/vertex hits).
face = None
try:
candidate = TopoDS.Face_s(shape)
# Verify it really is a face by building an adaptor.
_ = BRepAdaptor_Surface(candidate)
face = candidate
except Exception:
face = None
if face is None:
return None
try:
adaptor = BRepAdaptor_Surface(face)
if adaptor.GetType() != GeomAbs_Plane:
return None # non-planar faces can't host a flat UV sketch
pln: gp_Pln = adaptor.Plane()
except Exception:
return None
# Plane origin: use the face's bounding-box centre projected onto the
# plane, so the UV frame is centred on the face (nicer for sketching).
from OCP.Bnd import Bnd_Box
from OCP.BRepBndLib import BRepBndLib
bbox = Bnd_Box()
BRepBndLib.Add_s(face, bbox)
xmin, ymin, zmin, xmax, ymax, zmax = bbox.Get()
cx, cy, cz = (xmin + xmax) / 2.0, (ymin + ymax) / 2.0, (zmin + zmax) / 2.0
# Project the bbox centre onto the plane.
pln_origin = pln.Location() # gp_Pnt
n = pln.Axis().Direction()
nx, ny, nz = n.X(), n.Y(), n.Z()
# signed distance from bbox centre to plane
d = (cx - pln_origin.X()) * nx + (cy - pln_origin.Y()) * ny + (cz - pln_origin.Z()) * nz
origin = (cx - d * nx, cy - d * ny, cz - d * nz)
# x_dir: direction of the face's first edge (stable, in-plane).
x_dir = None
try:
from OCP.TopExp import TopExp
from OCP.BRep import BRep_Tool
expl = TopExp_Explorer(face, TopAbs_EDGE)
if expl.More():
edge = TopoDS.Edge_s(expl.Current())
v1 = TopExp.FirstVertex_s(edge, True)
v2 = TopExp.LastVertex_s(edge, True)
p1 = BRep_Tool.Pnt_s(v1)
p2 = BRep_Tool.Pnt_s(v2)
ex, ey, ez = p2.X() - p1.X(), p2.Y() - p1.Y(), p2.Z() - p1.Z()
elen = (ex * ex + ey * ey + ez * ez) ** 0.5
if elen > 1e-9:
x_dir = (ex / elen, ey / elen, ez / elen)
except Exception:
pass
if x_dir is None:
# Fall back to the plane's own X axis.
px = pln.XAxis().Direction()
x_dir = (px.X(), px.Y(), px.Z())
return {
"origin": origin,
"normal": (nx, ny, nz),
"x_dir": x_dir,
"face": face,
}
# ─── Mouse / keyboard event forwarding ──────────────────────────────
#
# CAD-style navigation:
# • Left button drag → orbit (rotate around target)
# • Middle button drag → pan
# • Right button drag → dolly / zoom toward cursor
# • Wheel → zoom toward cursor
# • Double-click left → fit all (handled by the widget)
def _qt_buttons(self, event) -> Any:
"""Return the PySide6 Qt enum module lazily."""
from PySide6.QtCore import Qt
return Qt
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
"""Begin an orbit / pan / zoom gesture based on the pressed button."""
if self._view is None or self._context is None:
return
Qt = self._qt_buttons(event)
x, y = event.x(), event.y()
btn = event.button()
# 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)
if btn == Qt.LeftButton:
self._nav_mode = "rotate"
# zRotationThreshold=0.4 enables screen-Z spin outside the inner
# circle, matching the FreeCAD/OCC viewer feel.
self._view.StartRotation(x, y, 0.4)
elif btn == Qt.MiddleButton:
self._nav_mode = "pan"
# Pan uses deltas from this starting point.
self._view.Pan(0, 0, 1.0, True)
elif btn == Qt.RightButton:
self._nav_mode = "zoom"
self._view.StartZoomAtPoint(x, y)
else:
self._nav_mode = None
self._last_mouse_x = x
self._last_mouse_y = y
def handle_mouse_move(self, event) -> None:
"""Forward mouse motion to OCC view (rotation, dynamic highlighting)."""
from OCP.Aspect import Aspect_VKeyMouse
"""Continue the active gesture; otherwise just hover-detect."""
if self._view is None or self._context is None:
return
Qt = self._qt_buttons(event)
x, y = event.x(), event.y()
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()
if self._nav_mode == "rotate" and (buttons & Qt.LeftButton):
self._view.Rotation(x, y)
elif self._nav_mode == "pan" and (buttons & Qt.MiddleButton):
dx = x - self._last_mouse_x
dy = y - self._last_mouse_y
# dy negated because Qt y grows downward while OCC y grows upward.
self._view.Pan(dx, -dy, 1.0, False)
elif self._nav_mode == "zoom" and (buttons & Qt.RightButton):
self._view.ZoomAtPoint(self._last_mouse_x, self._last_mouse_y, x, y)
else:
# Idle: dynamic highlighting under the cursor.
self._context.MoveTo(x, y, self._view, True)
self._last_mouse_x = x
self._last_mouse_y = y
def handle_mouse_release(self, event) -> None:
"""End rotation/pan."""
pass
"""End the active gesture."""
Qt = self._qt_buttons(event)
if event.button() in (Qt.LeftButton, Qt.MiddleButton, Qt.RightButton):
self._nav_mode = None
def handle_wheel(self, event) -> None:
"""Zoom on scroll."""
"""Zoom toward the cursor on scroll."""
if self._view is None:
return
# Qt6: QWheelEvent has no .x()/.y(); use position().toPoint().
pos = event.position().toPoint() if hasattr(event, "position") else event.pos()
x, y = pos.x(), pos.y()
delta = event.angleDelta().y()
if delta > 0:
self._view.SetZoom(1.1)
else:
self._view.SetZoom(0.9)
if delta == 0:
return
# ZoomAtPoint(startX, startY, endX, endY): move the cursor anchor
# by a few pixels to drive a smooth zoom centered on the pointer.
step = 30 if delta > 0 else -30
self._view.ZoomAtPoint(x, y, x, y - step)
self._view.ZFitAll()
def handle_resize(self, w: int, h: int) -> None:
"""Resize the OCC view when the widget is resized."""
+54
View File
@@ -182,6 +182,60 @@ class TestOCCSketch:
points = sketch.get_points()
assert len(points) == 0
def test_workplane_extrude_along_normal(self):
"""A sketch on a tilted plane extrudes along that plane's normal."""
import math
from OCP.GProp import GProp_GProps
from OCP.BRepGProp import BRepGProp
ang = math.radians(35)
normal = (math.sin(ang), 0.0, math.cos(ang))
x_dir = (math.cos(ang), 0.0, -math.sin(ang))
sk = OCCSketch()
sk.set_workplane((10.0, 0.0, 5.0), normal, x_dir)
# 20x20 square in UV
p0 = sk.add_point(-10, -10); p1 = sk.add_point(10, -10)
p2 = sk.add_point(10, 10); p3 = sk.add_point(-10, 10)
sk.add_line(p0, p1); sk.add_line(p1, p2)
sk.add_line(p2, p3); sk.add_line(p3, p0)
geom = sk.get_geometry()
# The face must carry the plane normal for the kernel.
assert geom.metadata.get("normal") is not None
kernel = OCGeometryKernel()
solid = kernel.extrude(geom, 15.0)
s = kernel._get_shape(solid)
g = GProp_GProps()
BRepGProp.VolumeProperties_s(s, g)
# 20 * 20 * 15 = 6000 regardless of plane orientation.
assert abs(g.Mass() - 6000.0) < 0.1
def test_workplane_extrude_with_hole(self):
"""A square with a circular hole on a custom plane extrudes correctly."""
import math
from OCP.GProp import GProp_GProps
from OCP.BRepGProp import BRepGProp
sk = OCCSketch()
sk.set_workplane((0, 0, 0), (0, 0, 1), (1, 0, 0))
a = sk.add_point(-10, -10); b = sk.add_point(10, -10)
c = sk.add_point(10, 10); d = sk.add_point(-10, 10)
sk.add_line(a, b); sk.add_line(b, c)
sk.add_line(c, d); sk.add_line(d, a)
ctr = sk.add_point(0, 0)
sk.add_circle(ctr, 3.0)
geom = sk.get_geometry()
kernel = OCGeometryKernel()
solid = kernel.extrude(geom, 5.0)
s = kernel._get_shape(solid)
g = GProp_GProps()
BRepGProp.VolumeProperties_s(s, g)
expected = 20 * 20 * 5 - math.pi * 3 * 3 * 5
assert abs(g.Mass() - expected) < 0.1
if __name__ == "__main__":
pytest.main([__file__, "-v"])