- Tons of addtions
This commit is contained in:
Generated
+15
-4
@@ -4,12 +4,14 @@
|
|||||||
<option name="autoReloadType" value="SELECTIVE" />
|
<option name="autoReloadType" value="SELECTIVE" />
|
||||||
</component>
|
</component>
|
||||||
<component name="ChangeListManager">
|
<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$/.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/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/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/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>
|
</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" />
|
||||||
@@ -263,7 +265,15 @@
|
|||||||
<option name="project" value="LOCAL" />
|
<option name="project" value="LOCAL" />
|
||||||
<updated>1755369224187</updated>
|
<updated>1755369224187</updated>
|
||||||
</task>
|
</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 />
|
<servers />
|
||||||
</component>
|
</component>
|
||||||
<component name="TypeScriptGeneratedFilesManager">
|
<component name="TypeScriptGeneratedFilesManager">
|
||||||
@@ -301,6 +311,7 @@
|
|||||||
<MESSAGE value="- added MIT license" />
|
<MESSAGE value="- added MIT license" />
|
||||||
<MESSAGE value="- added screenshot" />
|
<MESSAGE value="- added screenshot" />
|
||||||
<MESSAGE value="- added sdf folder ( doesnt work via pip or git=)" />
|
<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>
|
</component>
|
||||||
</project>
|
</project>
|
||||||
@@ -46,7 +46,11 @@ class OCGeometryKernel(GeometryKernel):
|
|||||||
self._mesh_tolerance: float = 0.1
|
self._mesh_tolerance: float = 0.1
|
||||||
|
|
||||||
def _get_shape(self, obj: GeometryObject) -> Any:
|
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
|
import cadquery as cq
|
||||||
|
|
||||||
if isinstance(obj, OCCGeometryObject):
|
if isinstance(obj, OCCGeometryObject):
|
||||||
@@ -64,7 +68,13 @@ class OCGeometryKernel(GeometryKernel):
|
|||||||
if hasattr(obj.shape, "wrapped"):
|
if hasattr(obj.shape, "wrapped"):
|
||||||
return obj.shape.wrapped
|
return obj.shape.wrapped
|
||||||
return obj.shape
|
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:
|
def _get_cq_obj(self, obj: GeometryObject) -> Any:
|
||||||
"""Get CadQuery object from GeometryObject."""
|
"""Get CadQuery object from GeometryObject."""
|
||||||
@@ -157,30 +167,94 @@ class OCGeometryKernel(GeometryKernel):
|
|||||||
direction: Tuple[float, float, float] = (0, 0, 1),
|
direction: Tuple[float, float, float] = (0, 0, 1),
|
||||||
symmetric: bool = False,
|
symmetric: bool = False,
|
||||||
) -> GeometryObject:
|
) -> 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*
|
The sketch's plane normal is read from ``sketch.metadata["normal"]``
|
||||||
extrudes in the opposite direction. The *direction* argument is accepted
|
(set by ``OCCSketch.build_face_geometry``); it defaults to +Z for
|
||||||
for API compatibility; currently only the sign of *height* is used for
|
legacy objects that don't carry one. *direction* is accepted for API
|
||||||
direction (positive → +Z, negative → -Z).
|
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):
|
normal = self._sketch_normal(sketch)
|
||||||
if symmetric:
|
nx, ny, nz = normal
|
||||||
solid = cq_obj.extrude(height / 2, both=True)
|
|
||||||
else:
|
def _prism(h: float):
|
||||||
solid = cq_obj.extrude(height)
|
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:
|
else:
|
||||||
wp = cq.Workplane("XY").add(cq_obj)
|
solid = _prism(height)
|
||||||
if symmetric:
|
|
||||||
solid = wp.extrude(height / 2, both=True)
|
|
||||||
else:
|
|
||||||
solid = wp.extrude(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(
|
def revolve(
|
||||||
self,
|
self,
|
||||||
@@ -189,22 +263,16 @@ class OCGeometryKernel(GeometryKernel):
|
|||||||
axis: Tuple[float, float, float] = (0, 0, 1),
|
axis: Tuple[float, float, float] = (0, 0, 1),
|
||||||
origin: Tuple[float, float, float] = (0, 0, 0),
|
origin: Tuple[float, float, float] = (0, 0, 0),
|
||||||
) -> GeometryObject:
|
) -> GeometryObject:
|
||||||
"""Revolve a 2D sketch around an axis."""
|
"""Revolve a sketch face around an axis."""
|
||||||
import cadquery as cq
|
|
||||||
import math
|
import math
|
||||||
|
|
||||||
# Get the OCC shape directly
|
# Get the OCC shape directly (a TopoDS_Face for new sketch geometry).
|
||||||
shape = self._get_shape(sketch)
|
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.gp import gp_Ax1, gp_Pnt, gp_Dir
|
||||||
from OCP.BRepPrimAPI import BRepPrimAPI_MakeRevol
|
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 the face around the axis
|
||||||
revolve_axis = gp_Ax1(gp_Pnt(*origin), gp_Dir(*axis))
|
revolve_axis = gp_Ax1(gp_Pnt(*origin), gp_Dir(*axis))
|
||||||
angle_rad = math.radians(angle)
|
angle_rad = math.radians(angle)
|
||||||
@@ -212,8 +280,7 @@ class OCGeometryKernel(GeometryKernel):
|
|||||||
revolver.Build()
|
revolver.Build()
|
||||||
solid_shape = revolver.Shape()
|
solid_shape = revolver.Shape()
|
||||||
|
|
||||||
solid = cq.Shape(solid_shape)
|
return OCCGeometryObject(solid_shape, {"type": "revolution"})
|
||||||
return OCCGeometryObject(solid, {"type": "revolution"})
|
|
||||||
|
|
||||||
def loft(self, profiles: List[GeometryObject], ruled: bool = False) -> GeometryObject:
|
def loft(self, profiles: List[GeometryObject], ruled: bool = False) -> GeometryObject:
|
||||||
"""Create a loft between multiple profiles."""
|
"""Create a loft between multiple profiles."""
|
||||||
|
|||||||
@@ -64,6 +64,78 @@ class OCCSketch(SketchInterface):
|
|||||||
# Track first point as dragged/fixed for solver stability
|
# Track first point as dragged/fixed for solver stability
|
||||||
self._first_point_id: Optional[int] = None
|
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
|
@property
|
||||||
def solver(self) -> SolverSystem:
|
def solver(self) -> SolverSystem:
|
||||||
"""Access the underlying SolveSpace solver."""
|
"""Access the underlying SolveSpace solver."""
|
||||||
@@ -71,7 +143,13 @@ class OCCSketch(SketchInterface):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def workplane(self) -> Any:
|
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
|
return self._wp
|
||||||
|
|
||||||
def _next_id(self) -> int:
|
def _next_id(self) -> int:
|
||||||
@@ -576,42 +654,39 @@ class OCCSketch(SketchInterface):
|
|||||||
# ─── Geometry extraction for operations ────────────────────────────────
|
# ─── Geometry extraction for operations ────────────────────────────────
|
||||||
|
|
||||||
def get_geometry(self) -> GeometryObject:
|
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
|
If the sketch has exactly one detected face (outer boundary + optional
|
||||||
holes) that face is returned as a combined face-with-holes Workplane.
|
holes) that face is returned. Otherwise falls back to returning a
|
||||||
Otherwise falls back to returning a single circle or polygon suitable
|
single circle or polygon as a face. The returned object carries the
|
||||||
for extrude/revolve.
|
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()
|
faces = self.detect_faces()
|
||||||
if len(faces) == 1:
|
if len(faces) == 1:
|
||||||
return self.build_face_geometry(faces[0])
|
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:
|
if self._circles:
|
||||||
for entity_id, (center_id, radius) in self._circles.items():
|
for entity_id, (center_id, radius) in self._circles.items():
|
||||||
center_entity = self._entities.get(center_id)
|
center_entity = self._entities.get(center_id)
|
||||||
if center_entity and center_entity.geometry:
|
if center_entity and center_entity.geometry:
|
||||||
cx, cy = center_entity.geometry
|
cx, cy = center_entity.geometry
|
||||||
wp = cq.Workplane("XY").center(cx, cy).circle(radius)
|
face_dict = {
|
||||||
obj = OCCGeometryObject(wp.val())
|
"outer": {"type": "circle", "center": (cx, cy), "radius": radius},
|
||||||
obj._cadquery_obj = wp
|
"holes": [],
|
||||||
return obj
|
}
|
||||||
|
return self.build_face_geometry(face_dict)
|
||||||
|
|
||||||
points = self.get_polygon_points()
|
points = self.get_polygon_points()
|
||||||
if not points:
|
if not points:
|
||||||
return OCCGeometryObject(None)
|
return OCCGeometryObject(None)
|
||||||
|
|
||||||
wp = cq.Workplane("XY").moveTo(points[0].x, points[0].y)
|
face_dict = {
|
||||||
for pt in points[1:]:
|
"outer": {"type": "polygon", "points": [(p.x, p.y) for p in points]},
|
||||||
wp = wp.lineTo(pt.x, pt.y)
|
"holes": [],
|
||||||
wp = wp.close()
|
}
|
||||||
|
return self.build_face_geometry(face_dict)
|
||||||
obj = OCCGeometryObject(wp.val())
|
|
||||||
obj._cadquery_obj = wp
|
|
||||||
return obj
|
|
||||||
|
|
||||||
def get_points(self) -> List[Point2D]:
|
def get_points(self) -> List[Point2D]:
|
||||||
"""Get all point positions from solved solver data."""
|
"""Get all point positions from solved solver data."""
|
||||||
@@ -910,34 +985,33 @@ class OCCSketch(SketchInterface):
|
|||||||
return best
|
return best
|
||||||
|
|
||||||
def build_face_geometry(self, face: Dict[str, Any]) -> OCCGeometryObject:
|
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
|
Wires are constructed from UV coordinates mapped through
|
||||||
``_cadquery_obj`` is a Workplane whose stack holds the face, so cadquery's
|
:meth:`_uv_to_world`, so the resulting ``TopoDS_Face`` lies on this
|
||||||
``Workplane.extrude`` lifts it into a solid — inner wires become
|
sketch's 3D plane (not necessarily XY). The returned object stores
|
||||||
through-holes.
|
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 (
|
from OCP.BRepBuilderAPI import (
|
||||||
BRepBuilderAPI_MakePolygon, BRepBuilderAPI_MakeFace,
|
BRepBuilderAPI_MakePolygon, BRepBuilderAPI_MakeFace,
|
||||||
BRepBuilderAPI_MakeWire, BRepBuilderAPI_MakeEdge,
|
BRepBuilderAPI_MakeWire, BRepBuilderAPI_MakeEdge,
|
||||||
)
|
)
|
||||||
from OCP.gp import gp_Pnt, gp_Circ, gp_Ax2, gp_Dir
|
from OCP.gp import gp_Pnt, gp_Circ, gp_Ax2, gp_Dir
|
||||||
|
|
||||||
from OCP.TopoDS import TopoDS as _TopoDS
|
from OCP.TopoDS import TopoDS as _TopoDS
|
||||||
|
|
||||||
def wire_loop(loop: Dict[str, Any], is_hole: bool = False):
|
def wire_loop(loop: Dict[str, Any], is_hole: bool = False):
|
||||||
if loop["type"] == "polygon":
|
if loop["type"] == "polygon":
|
||||||
mp = BRepBuilderAPI_MakePolygon()
|
mp = BRepBuilderAPI_MakePolygon()
|
||||||
for (px, py) in loop["points"]:
|
for (pu, pv) in loop["points"]:
|
||||||
mp.Add(gp_Pnt(px, py, 0.0))
|
mp.Add(self._uv_to_world(pu, pv))
|
||||||
mp.Close()
|
mp.Close()
|
||||||
mp.Build()
|
mp.Build()
|
||||||
w = mp.Wire()
|
w = mp.Wire()
|
||||||
else:
|
else:
|
||||||
cx, cy = loop["center"]
|
cu, cv = loop["center"]
|
||||||
r = loop["radius"]
|
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 = BRepBuilderAPI_MakeEdge(circ)
|
||||||
me.Build()
|
me.Build()
|
||||||
mw = BRepBuilderAPI_MakeWire()
|
mw = BRepBuilderAPI_MakeWire()
|
||||||
@@ -945,7 +1019,7 @@ class OCCSketch(SketchInterface):
|
|||||||
mw.Build()
|
mw.Build()
|
||||||
w = mw.Wire()
|
w = mw.Wire()
|
||||||
if is_hole:
|
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
|
return w
|
||||||
|
|
||||||
outer_wire = wire_loop(face["outer"], is_hole=False)
|
outer_wire = wire_loop(face["outer"], is_hole=False)
|
||||||
@@ -955,10 +1029,11 @@ class OCCSketch(SketchInterface):
|
|||||||
face_maker.Build()
|
face_maker.Build()
|
||||||
occ_face = face_maker.Face()
|
occ_face = face_maker.Face()
|
||||||
|
|
||||||
wp = cq.Workplane("XY")
|
obj = OCCGeometryObject(occ_face, {
|
||||||
wp = wp.add(cq.Face(occ_face))
|
"type": "sketch_face",
|
||||||
obj = OCCGeometryObject(wp.val())
|
"normal": self._wp_normal,
|
||||||
obj._cadquery_obj = wp
|
"origin": self._wp_origin,
|
||||||
|
})
|
||||||
return obj
|
return obj
|
||||||
|
|
||||||
def get_solver_dof(self) -> int:
|
def get_solver_dof(self) -> int:
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ from PySide6.QtGui import (
|
|||||||
QFont,
|
QFont,
|
||||||
QFontMetrics,
|
QFontMetrics,
|
||||||
QCursor,
|
QCursor,
|
||||||
|
QPolygonF,
|
||||||
)
|
)
|
||||||
|
|
||||||
from fluency.geometry_occ.kernel import OCGeometryKernel, OCCGeometryObject
|
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
|
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):
|
class ExtrudeDialog(QDialog):
|
||||||
"""Dialog for extrude options."""
|
"""Dialog for extrude options."""
|
||||||
|
|
||||||
@@ -196,6 +257,13 @@ class Sketch2DWidget(QWidget):
|
|||||||
self._selected_face: Any = None
|
self._selected_face: Any = None
|
||||||
self._selected_entities: List[OCCSketchEntity] = []
|
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] = {
|
self._snap_mode: Dict[str, bool] = {
|
||||||
"point": True,
|
"point": True,
|
||||||
"mpoint": False,
|
"mpoint": False,
|
||||||
@@ -237,6 +305,69 @@ class Sketch2DWidget(QWidget):
|
|||||||
self._rebuild_from_sketch()
|
self._rebuild_from_sketch()
|
||||||
self._draw_buffer = []
|
self._draw_buffer = []
|
||||||
self._clear_face_state()
|
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()
|
self.update()
|
||||||
|
|
||||||
def _clear_face_state(self):
|
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() - 20, origin.y(), origin.x() + 20, origin.y())
|
||||||
painter.drawLine(origin.x(), origin.y() - 20, origin.x(), origin.y() + 20)
|
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 ──
|
# ── Points ──
|
||||||
for entity in self._points:
|
for entity in self._points:
|
||||||
if entity.geometry:
|
if entity.geometry:
|
||||||
@@ -1721,12 +1882,20 @@ class Sketch2DWidget(QWidget):
|
|||||||
class Viewer3DWidget(QWidget):
|
class Viewer3DWidget(QWidget):
|
||||||
"""3D viewer widget using OCC's native AIS display."""
|
"""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):
|
def __init__(self, parent=None):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
# For OCC's direct OpenGL rendering we need Qt to not paint over it.
|
# For OCC's direct OpenGL rendering we need Qt to not paint over it.
|
||||||
self.setAttribute(Qt.WA_PaintOnScreen)
|
self.setAttribute(Qt.WA_PaintOnScreen)
|
||||||
self.setAttribute(Qt.WA_OpaquePaintEvent)
|
self.setAttribute(Qt.WA_OpaquePaintEvent)
|
||||||
self.setAutoFillBackground(False)
|
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.
|
# Try OCC renderer first; fall back to pygfx if unavailable.
|
||||||
self._renderer: Any = None
|
self._renderer: Any = None
|
||||||
self._initialized = False
|
self._initialized = False
|
||||||
@@ -1734,6 +1903,9 @@ class Viewer3DWidget(QWidget):
|
|||||||
self._selected_normal: Optional[Tuple[float, float, float]] = None
|
self._selected_normal: Optional[Tuple[float, float, float]] = None
|
||||||
self._centroid: Optional[Tuple[float, float, float]] = None
|
self._centroid: Optional[Tuple[float, float, float]] = None
|
||||||
self._pending_meshes: List[Tuple] = []
|
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:
|
def _init_renderer(self) -> None:
|
||||||
"""Create the best available renderer."""
|
"""Create the best available renderer."""
|
||||||
@@ -1863,11 +2035,22 @@ class Viewer3DWidget(QWidget):
|
|||||||
|
|
||||||
def mousePressEvent(self, event):
|
def mousePressEvent(self, event):
|
||||||
self._ensure_initialized()
|
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)
|
self._renderer.handle_mouse_press(event)
|
||||||
super().mousePressEvent(event)
|
super().mousePressEvent(event)
|
||||||
|
|
||||||
def mouseMoveEvent(self, event):
|
def mouseMoveEvent(self, event):
|
||||||
self._ensure_initialized()
|
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)
|
self._renderer.handle_mouse_move(event)
|
||||||
super().mouseMoveEvent(event)
|
super().mouseMoveEvent(event)
|
||||||
|
|
||||||
@@ -1899,7 +2082,52 @@ class Viewer3DWidget(QWidget):
|
|||||||
self._renderer.set_camera_position(position, target)
|
self._renderer.set_camera_position(position, target)
|
||||||
self._renderer.render()
|
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):
|
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 = {
|
positions = {
|
||||||
"iso": ((100, 100, 100), (0, 0, 0)),
|
"iso": ((100, 100, 100), (0, 0, 0)),
|
||||||
"top": ((0, 0, 200), (0, 0, 0)),
|
"top": ((0, 0, 200), (0, 0, 0)),
|
||||||
@@ -1913,6 +2141,58 @@ class Viewer3DWidget(QWidget):
|
|||||||
pos, target = positions[view]
|
pos, target = positions[view]
|
||||||
self.set_camera_position(pos, target)
|
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):
|
class MainWindow(QMainWindow):
|
||||||
"""Main application window."""
|
"""Main application window."""
|
||||||
@@ -2198,6 +2478,12 @@ class MainWindow(QMainWindow):
|
|||||||
sk_tools_grid.addWidget(self._btn_edit_sketch, 0, 1)
|
sk_tools_grid.addWidget(self._btn_edit_sketch, 0, 1)
|
||||||
self._btn_del_sketch = QPushButton("Del")
|
self._btn_del_sketch = QPushButton("Del")
|
||||||
sk_tools_grid.addWidget(self._btn_del_sketch, 0, 2)
|
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)
|
sk_list_layout.addWidget(sk_tools)
|
||||||
grid.addWidget(sk_list_group, 6, 0, 3, 1)
|
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_add_sketch.clicked.connect(self._add_sketch_to_component)
|
||||||
self._btn_edit_sketch.clicked.connect(self._edit_sketch)
|
self._btn_edit_sketch.clicked.connect(self._edit_sketch)
|
||||||
self._btn_del_sketch.clicked.connect(self._delete_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_new_compo.clicked.connect(self._new_component)
|
||||||
self._btn_del_compo.clicked.connect(self._delete_component)
|
self._btn_del_compo.clicked.connect(self._delete_component)
|
||||||
@@ -2643,6 +2934,59 @@ class MainWindow(QMainWindow):
|
|||||||
def _pattern_array(self):
|
def _pattern_array(self):
|
||||||
logger.info("Pattern array not yet implemented")
|
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):
|
def _add_sketch_to_component(self):
|
||||||
logger.info("=== ADD SKETCH TO COMPONENT ===")
|
logger.info("=== ADD SKETCH TO COMPONENT ===")
|
||||||
if not self._current_component:
|
if not self._current_component:
|
||||||
@@ -2660,6 +3004,10 @@ class MainWindow(QMainWindow):
|
|||||||
logger.info("Creating new sketch in widget")
|
logger.info("Creating new sketch in widget")
|
||||||
sketch.occ_sketch = self._sketch_widget.create_sketch()
|
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._current_sketch = sketch
|
||||||
self._refresh_lists()
|
self._refresh_lists()
|
||||||
self._sketch_widget.set_mode(None)
|
self._sketch_widget.set_mode(None)
|
||||||
@@ -2676,6 +3024,7 @@ class MainWindow(QMainWindow):
|
|||||||
if sketch.name == name:
|
if sketch.name == name:
|
||||||
self._current_sketch = sketch
|
self._current_sketch = sketch
|
||||||
if sketch.occ_sketch:
|
if sketch.occ_sketch:
|
||||||
|
sketch.apply_workplane()
|
||||||
self._sketch_widget.set_sketch(sketch.occ_sketch)
|
self._sketch_widget.set_sketch(sketch.occ_sketch)
|
||||||
self._sketch_widget.set_mode("line")
|
self._sketch_widget.set_mode("line")
|
||||||
self._btn_line.setChecked(True)
|
self._btn_line.setChecked(True)
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ including projects, components, sketches, and bodies.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Dict, List, Optional, Any
|
from typing import Dict, List, Optional, Any, Tuple
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import uuid
|
import uuid
|
||||||
import numpy as np
|
import numpy as np
|
||||||
@@ -46,6 +46,32 @@ class Sketch:
|
|||||||
created_at: datetime = field(default_factory=datetime.now)
|
created_at: datetime = field(default_factory=datetime.now)
|
||||||
modified_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:
|
def add_point(self, x: float, y: float) -> Any:
|
||||||
"""Add a point to the sketch."""
|
"""Add a point to the sketch."""
|
||||||
self.modified_at = datetime.now()
|
self.modified_at = datetime.now()
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ class OCCRenderer(Renderer):
|
|||||||
self._parent_widget: Any = None
|
self._parent_widget: Any = None
|
||||||
self._last_mouse_x: int = 0
|
self._last_mouse_x: int = 0
|
||||||
self._last_mouse_y: 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:
|
def initialize(self, parent_widget: Any) -> bool:
|
||||||
"""Initialise OCC viewer inside *parent_widget* (a QWidget)."""
|
"""Initialise OCC viewer inside *parent_widget* (a QWidget)."""
|
||||||
@@ -56,11 +57,29 @@ class OCCRenderer(Renderer):
|
|||||||
Aspect_DisplayConnection,
|
Aspect_DisplayConnection,
|
||||||
Aspect_NeutralWindow,
|
Aspect_NeutralWindow,
|
||||||
Aspect_GFM_VER,
|
Aspect_GFM_VER,
|
||||||
|
Aspect_TypeOfTriedronPosition,
|
||||||
)
|
)
|
||||||
from OCP.OpenGl import OpenGl_GraphicDriver
|
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.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")
|
logger.info("OCCRenderer imports complete")
|
||||||
|
|
||||||
hwnd = int(parent_widget.winId())
|
hwnd = int(parent_widget.winId())
|
||||||
@@ -74,17 +93,68 @@ class OCCRenderer(Renderer):
|
|||||||
display = Aspect_DisplayConnection()
|
display = Aspect_DisplayConnection()
|
||||||
driver = OpenGl_GraphicDriver(display)
|
driver = OpenGl_GraphicDriver(display)
|
||||||
viewer = V3d_Viewer(driver)
|
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)
|
view = V3d_View(viewer, V3d_TypeOfView.V3d_ORTHOGRAPHIC)
|
||||||
|
|
||||||
|
# ── Background gradient (dark studio look) ───────────────────
|
||||||
viewer.SetDefaultBgGradientColors(
|
viewer.SetDefaultBgGradientColors(
|
||||||
Quantity_Color(0.15, 0.15, 0.2, Quantity_TOC_RGB),
|
Quantity_Color(0.18, 0.20, 0.24, Quantity_TOC_RGB),
|
||||||
Quantity_Color(0.25, 0.25, 0.3, Quantity_TOC_RGB),
|
Quantity_Color(0.08, 0.09, 0.11, Quantity_TOC_RGB),
|
||||||
Aspect_GFM_VER,
|
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)
|
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.
|
# Attach OCC view to the Qt widget via the native window handle.
|
||||||
win = Aspect_NeutralWindow()
|
win = Aspect_NeutralWindow()
|
||||||
@@ -92,6 +162,8 @@ class OCCRenderer(Renderer):
|
|||||||
w, h = parent_widget.width(), parent_widget.height()
|
w, h = parent_widget.width(), parent_widget.height()
|
||||||
win.SetSize(w, h)
|
win.SetSize(w, h)
|
||||||
view.SetWindow(win)
|
view.SetWindow(win)
|
||||||
|
# Ensure the depth range follows the scene so nothing clips.
|
||||||
|
view.SetAutoZFitMode(True)
|
||||||
|
|
||||||
self._viewer = viewer
|
self._viewer = viewer
|
||||||
self._view = view
|
self._view = view
|
||||||
@@ -138,18 +210,39 @@ class OCCRenderer(Renderer):
|
|||||||
|
|
||||||
ais = AIS_Shape(shape)
|
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:
|
if color is not None:
|
||||||
qcol = Quantity_Color(*color, Quantity_TOC_RGB)
|
qcol = Quantity_Color(*color, Quantity_TOC_RGB)
|
||||||
ais.SetColor(qcol)
|
ais.SetColor(qcol)
|
||||||
|
|
||||||
# Enable selection of edges and faces.
|
# Shaded display with boundary edges drawn on top for readability.
|
||||||
ais.SetDisplayMode(1) # 0 = wireframe, 1 = shaded
|
ais.SetDisplayMode(1) # 1 = AIS_Shaded
|
||||||
ais.SetMaterial(
|
drawer = ais.Attributes()
|
||||||
self._default_material()
|
try:
|
||||||
) # use a helper to get the default material
|
# 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.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)
|
defcol = color or (0.5, 0.5, 0.5)
|
||||||
robj = OCCRenderObject(
|
robj = OCCRenderObject(
|
||||||
@@ -170,12 +263,17 @@ class OCCRenderer(Renderer):
|
|||||||
return obj_id
|
return obj_id
|
||||||
|
|
||||||
def _default_material(self):
|
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
|
from OCP.Graphic3d import Graphic3d_MaterialAspect, Graphic3d_NameOfMaterial
|
||||||
|
|
||||||
return Graphic3d_MaterialAspect(
|
mat = Graphic3d_MaterialAspect(
|
||||||
Graphic3d_NameOfMaterial.Graphic3d_NOM_PLASTIC
|
Graphic3d_NameOfMaterial.Graphic3d_NOM_PLASTIC
|
||||||
)
|
)
|
||||||
|
return mat
|
||||||
|
|
||||||
# ─── Legacy mesh / wireframe (kept for backward compat) ────────────
|
# ─── Legacy mesh / wireframe (kept for backward compat) ────────────
|
||||||
|
|
||||||
@@ -278,12 +376,16 @@ class OCCRenderer(Renderer):
|
|||||||
"""Remove all objects from the scene."""
|
"""Remove all objects from the scene."""
|
||||||
if self._context is None:
|
if self._context is None:
|
||||||
return
|
return
|
||||||
# Remove all displayed AIS objects.
|
# Remove every displayed AIS object. ``RemoveAll`` is the cleanest
|
||||||
from OCP.AIS import AIS_KindOfInteractive
|
# path; fall back to iterating the displayed list if unavailable.
|
||||||
|
try:
|
||||||
objs = self._context.DisplayedObjects()
|
self._context.RemoveAll(True)
|
||||||
for ais in objs:
|
except Exception:
|
||||||
self._context.Remove(ais, True)
|
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()
|
self._objects.clear()
|
||||||
|
|
||||||
def update_mesh(
|
def update_mesh(
|
||||||
@@ -327,15 +429,14 @@ class OCCRenderer(Renderer):
|
|||||||
target: Tuple[float, float, float] = (0, 0, 0),
|
target: Tuple[float, float, float] = (0, 0, 0),
|
||||||
up: Tuple[float, float, float] = (0, 0, 1),
|
up: Tuple[float, float, float] = (0, 0, 1),
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Set camera position and orientation."""
|
"""Set camera eye, target (at) and up vectors."""
|
||||||
if self._view is None:
|
if self._view is None:
|
||||||
return
|
return
|
||||||
from OCP.gp import gp_Pnt, gp_Dir, gp_Vec
|
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.SetProj(gp_Dir(position[0], position[1], position[2]))
|
self._view.SetUp(float(up[0]), float(up[1]), float(up[2]))
|
||||||
self._view.SetEye(gp_Pnt(*position))
|
self._view.ZFitAll()
|
||||||
self._view.SetCenter(gp_Pnt(*target))
|
self._view.Redraw()
|
||||||
self._view.SetUp(gp_Dir(*up))
|
|
||||||
|
|
||||||
def get_camera_position(self) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
|
def get_camera_position(self) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
|
||||||
"""Get camera position, target, and up vector."""
|
"""Get camera position, target, and up vector."""
|
||||||
@@ -346,47 +447,89 @@ class OCCRenderer(Renderer):
|
|||||||
np.array([0, 0, 1]),
|
np.array([0, 0, 1]),
|
||||||
)
|
)
|
||||||
eye = self._view.Eye()
|
eye = self._view.Eye()
|
||||||
center = self._view.Center()
|
at = self._view.At()
|
||||||
up = self._view.Up()
|
up = self._view.Up()
|
||||||
return (
|
|
||||||
np.array([eye.X(), eye.Y(), eye.Z()]),
|
def _xyz(v):
|
||||||
np.array([center.X(), center.Y(), center.Z()]),
|
if isinstance(v, (tuple, list)):
|
||||||
np.array([up.X(), up.Y(), up.Z()]),
|
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:
|
def fit_camera(self, padding: float = 0.05) -> None:
|
||||||
"""Fit camera to show all displayed objects.
|
"""Fit camera to show all displayed objects.
|
||||||
|
|
||||||
*padding* is the margin coefficient (0 ≤ padding < 1.0) passed to
|
*padding* is the margin coefficient (0 ≤ padding < 1.0) passed to
|
||||||
OCC's ``FitAll``, not a multiplicative factor. A small value like
|
OCC's ``FitAll``. A small value like 0.05 adds 5% margin.
|
||||||
0.05 adds 5% margin around the bounding box.
|
|
||||||
"""
|
"""
|
||||||
if self._view is None:
|
if self._view is None:
|
||||||
return
|
return
|
||||||
margin = max(0.0, min(padding, 0.99))
|
margin = max(0.0, min(padding, 0.99))
|
||||||
self._view.FitAll(margin)
|
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(
|
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:
|
) -> 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:
|
if self._view is None:
|
||||||
return
|
return
|
||||||
from OCP.V3d import V3d_PERSPECTIVE
|
from OCP.Graphic3d import Graphic3d_Camera
|
||||||
|
|
||||||
self._view.SetComputedMode(False) # manual mode
|
cam = self._view.Camera()
|
||||||
self._view.ChangeRenderingParams()
|
cam.SetProjectionType(Graphic3d_Camera.Projection_Perspective)
|
||||||
# perspective/orthographic toggle handled in set_camera_orthographic
|
cam.SetFOVy(fov)
|
||||||
|
self._view.ZFitAll()
|
||||||
|
self._view.Redraw()
|
||||||
|
|
||||||
def set_camera_orthographic(
|
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:
|
) -> None:
|
||||||
"""Set orthographic camera."""
|
"""Switch to an orthographic camera."""
|
||||||
if self._view is None:
|
if self._view is None:
|
||||||
return
|
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 ─────────────────────────────────────────────────────
|
# ─── Rendering ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -444,56 +587,192 @@ class OCCRenderer(Renderer):
|
|||||||
) -> Tuple[float, float, float]:
|
) -> Tuple[float, float, float]:
|
||||||
return (0.0, 0.0, 0.0)
|
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:
|
def handle_mouse_press(self, event) -> None:
|
||||||
"""Forward a QMouseEvent to the OCC view for orbit/pan/zoom/select."""
|
"""Begin an orbit / pan / zoom gesture based on the pressed button."""
|
||||||
from OCP.Aspect import Aspect_VKeyMouse
|
|
||||||
from OCP.AIS import AIS_SelectionScheme
|
|
||||||
|
|
||||||
if self._view is None or self._context is None:
|
if self._view is None or self._context is None:
|
||||||
return
|
return
|
||||||
|
Qt = self._qt_buttons(event)
|
||||||
|
x, y = event.x(), event.y()
|
||||||
|
btn = event.button()
|
||||||
|
|
||||||
# Middle mouse → start rotation
|
if btn == Qt.LeftButton:
|
||||||
if event.button() == 4: # Qt.MiddleButton
|
self._nav_mode = "rotate"
|
||||||
self._view.StartRotation(event.x(), event.y())
|
# zRotationThreshold=0.4 enables screen-Z spin outside the inner
|
||||||
# Left mouse → try selection (OCC picks nearest shape)
|
# circle, matching the FreeCAD/OCC viewer feel.
|
||||||
elif event.button() == 1: # Qt.LeftButton
|
self._view.StartRotation(x, y, 0.4)
|
||||||
self._context.Select(True)
|
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:
|
def handle_mouse_move(self, event) -> None:
|
||||||
"""Forward mouse motion to OCC view (rotation, dynamic highlighting)."""
|
"""Continue the active gesture; otherwise just hover-detect."""
|
||||||
from OCP.Aspect import Aspect_VKeyMouse
|
|
||||||
|
|
||||||
if self._view is None or self._context is None:
|
if self._view is None or self._context is None:
|
||||||
return
|
return
|
||||||
|
Qt = self._qt_buttons(event)
|
||||||
|
x, y = event.x(), event.y()
|
||||||
buttons = event.buttons()
|
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()
|
if self._nav_mode == "rotate" and (buttons & Qt.LeftButton):
|
||||||
self._last_mouse_y = event.y()
|
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:
|
def handle_mouse_release(self, event) -> None:
|
||||||
"""End rotation/pan."""
|
"""End the active gesture."""
|
||||||
pass
|
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:
|
def handle_wheel(self, event) -> None:
|
||||||
"""Zoom on scroll."""
|
"""Zoom toward the cursor on scroll."""
|
||||||
if self._view is None:
|
if self._view is None:
|
||||||
return
|
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()
|
delta = event.angleDelta().y()
|
||||||
if delta > 0:
|
if delta == 0:
|
||||||
self._view.SetZoom(1.1)
|
return
|
||||||
else:
|
# ZoomAtPoint(startX, startY, endX, endY): move the cursor anchor
|
||||||
self._view.SetZoom(0.9)
|
# 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:
|
def handle_resize(self, w: int, h: int) -> None:
|
||||||
"""Resize the OCC view when the widget is resized."""
|
"""Resize the OCC view when the widget is resized."""
|
||||||
|
|||||||
@@ -182,6 +182,60 @@ class TestOCCSketch:
|
|||||||
points = sketch.get_points()
|
points = sketch.get_points()
|
||||||
assert len(points) == 0
|
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__":
|
if __name__ == "__main__":
|
||||||
pytest.main([__file__, "-v"])
|
pytest.main([__file__, "-v"])
|
||||||
|
|||||||
Reference in New Issue
Block a user