- sketch enhacements
This commit is contained in:
Generated
+16
-3
@@ -4,10 +4,12 @@
|
||||
<option name="autoReloadType" value="SELECTIVE" />
|
||||
</component>
|
||||
<component name="ChangeListManager">
|
||||
<list default="true" id="8f0bafd6-58a0-4b20-aa2b-ddc3ba278873" name="Changes" comment="- removed cadquery deoendency">
|
||||
<list default="true" id="8f0bafd6-58a0-4b20-aa2b-ddc3ba278873" name="Changes" comment="- sketch enhacements">
|
||||
<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/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/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" />
|
||||
</list>
|
||||
<option name="SHOW_DIALOG" value="false" />
|
||||
<option name="HIGHLIGHT_CONFLICTS" value="true" />
|
||||
@@ -49,6 +51,8 @@
|
||||
"Python.2dtest.executor": "Run",
|
||||
"Python.3d_windows.executor": "Run",
|
||||
"Python.Unnamed.executor": "Run",
|
||||
"Python.base.executor": "Run",
|
||||
"Python.data_model.executor": "Run",
|
||||
"Python.draw_widget2d.executor": "Run",
|
||||
"Python.draw_widget_solve.executor": "Run",
|
||||
"Python.fluency.executor": "Run",
|
||||
@@ -294,7 +298,15 @@
|
||||
<option name="project" value="LOCAL" />
|
||||
<updated>1782928990792</updated>
|
||||
</task>
|
||||
<option name="localTasksCounter" value="24" />
|
||||
<task id="LOCAL-00024" summary="- sketch enhacements">
|
||||
<option name="closed" value="true" />
|
||||
<created>1783108151675</created>
|
||||
<option name="number" value="00024" />
|
||||
<option name="presentableId" value="LOCAL-00024" />
|
||||
<option name="project" value="LOCAL" />
|
||||
<updated>1783108151676</updated>
|
||||
</task>
|
||||
<option name="localTasksCounter" value="25" />
|
||||
<servers />
|
||||
</component>
|
||||
<component name="TypeScriptGeneratedFilesManager">
|
||||
@@ -335,6 +347,7 @@
|
||||
<MESSAGE value="- Tons of addtions" />
|
||||
<MESSAGE value="- Basic operations" />
|
||||
<MESSAGE value="- removed cadquery deoendency" />
|
||||
<option name="LAST_COMMIT_MESSAGE" value="- removed cadquery deoendency" />
|
||||
<MESSAGE value="- sketch enhacements" />
|
||||
<option name="LAST_COMMIT_MESSAGE" value="- sketch enhacements" />
|
||||
</component>
|
||||
</project>
|
||||
@@ -1159,16 +1159,24 @@ class OCCSketch(SketchInterface):
|
||||
return loops
|
||||
|
||||
@staticmethod
|
||||
def _point_in_polygon(pt: Tuple[float, float], poly: List[Tuple[float, float]]) -> bool:
|
||||
def _point_in_polygon(
|
||||
pt: Tuple[float, float],
|
||||
poly: List[Tuple[float, float]],
|
||||
margin: float = 0.0,
|
||||
) -> bool:
|
||||
"""Ray-casting point-in-polygon test.
|
||||
|
||||
Returns *True* only for strictly interior points. Points on the
|
||||
boundary (within 1e-9) are considered *outside* so that the outer
|
||||
boundary of a nested shape doesn't falsely contain another loop whose
|
||||
representative point happens to land on that boundary.
|
||||
Returns *True* for points strictly inside the polygon. Points on
|
||||
the boundary (within eps=1e-9) are *outside* by default so the
|
||||
outer boundary of a nested shape doesn't falsely contain a hole's
|
||||
rep point. When *margin* > 0, points that are within that many
|
||||
world-unit of the boundary are also treated as inside — used by
|
||||
``_loop_contains`` to prevent float rounding from breaking
|
||||
thin-wall nesting detection.
|
||||
"""
|
||||
x, y = pt
|
||||
eps = 1e-9
|
||||
eps = 1e-9 # strict boundary rejection
|
||||
margin = float(margin)
|
||||
n = len(poly)
|
||||
inside = False
|
||||
j = n - 1
|
||||
@@ -1177,11 +1185,17 @@ class OCCSketch(SketchInterface):
|
||||
xj, yj = poly[j]
|
||||
# Point-on-segment test — exclude strict boundary hits.
|
||||
# First check bounding box of the segment.
|
||||
if min(xi, xj) - eps <= x <= max(xi, xj) + eps and min(yi, yj) - eps <= y <= max(yi, yj) + eps:
|
||||
bbox_tol = max(eps, margin)
|
||||
if min(xi, xj) - bbox_tol <= x <= max(xi, xj) + bbox_tol and min(yi, yj) - bbox_tol <= y <= max(yi, yj) + bbox_tol:
|
||||
# Check collinearity
|
||||
cross = (x - xi) * (yj - yi) - (y - yi) * (xj - xi)
|
||||
if abs(cross) < eps:
|
||||
return False # on boundary
|
||||
abs_cross = abs(cross)
|
||||
if abs_cross < eps:
|
||||
# Strictly on boundary — return False unless margin says otherwise.
|
||||
if margin > 0 and abs_cross < margin:
|
||||
pass # fall through to ray-cast below
|
||||
else:
|
||||
return False
|
||||
if ((yi > y) != (yj > y)) and (x < (xj - xi) * (y - yi) / (yj - yi + 1e-30) + xi):
|
||||
inside = not inside
|
||||
j = i
|
||||
@@ -1189,34 +1203,78 @@ class OCCSketch(SketchInterface):
|
||||
|
||||
@staticmethod
|
||||
def _loop_contains(inner: Dict[str, Any], outer: Dict[str, Any]) -> bool:
|
||||
"""Does ``outer`` fully enclose ``inner``? Uses a representative point +
|
||||
boundary tests on ``outer`` (only valid when ``outer`` != ``inner``)."""
|
||||
rep = OCCSketch._loop_rep_point(inner)
|
||||
if outer["type"] == "polygon":
|
||||
return OCCSketch._point_in_polygon(rep, outer["points"])
|
||||
else: # circle
|
||||
cx, cy = outer["center"]
|
||||
return math.hypot(rep[0] - cx, rep[1] - cy) < outer["radius"]
|
||||
"""Does ``outer`` fully enclose ``inner``?
|
||||
|
||||
For polygon-polygon: checks that ALL vertices of ``inner`` are strictly
|
||||
inside ``outer`` using ray-casting. This is robust for convex polygons
|
||||
and avoids the representative-point issue where a large nested loop's
|
||||
centroid lands inside an inner loop.
|
||||
|
||||
For circle-in-polygon: checks the circle centre is inside the polygon
|
||||
(vertex check would be too strict for tessellated arc segments).
|
||||
|
||||
For circle-in-circle: checks distance between centres + inner radius
|
||||
< outer radius + margin.
|
||||
|
||||
For polygon-in-circle: checks all polygon vertices are inside the
|
||||
circle.
|
||||
"""
|
||||
eps = 1e-3
|
||||
|
||||
if outer["type"] == "circle":
|
||||
ox, oy = outer["center"]
|
||||
orad = outer["radius"]
|
||||
if inner["type"] == "circle":
|
||||
# Two circles: centre distance + inner radius < outer radius
|
||||
dx = inner["center"][0] - ox
|
||||
dy = inner["center"][1] - oy
|
||||
return math.hypot(dx, dy) + inner["radius"] < orad + eps
|
||||
else:
|
||||
# Polygon in circle: all vertices inside
|
||||
pts = inner["points"]
|
||||
if len(pts) > 1 and pts[0] == pts[-1]:
|
||||
pts = pts[:-1]
|
||||
for pt in pts:
|
||||
if math.hypot(pt[0] - ox, pt[1] - oy) > orad - eps:
|
||||
return False
|
||||
return True
|
||||
else:
|
||||
# outer is polygon
|
||||
if inner["type"] == "circle":
|
||||
# Circle in polygon: centre must be inside with margin
|
||||
cx, cy = inner["center"]
|
||||
return OCCSketch._point_in_polygon(
|
||||
(cx, cy), outer["points"], margin=1e-3
|
||||
)
|
||||
else:
|
||||
# Polygon in polygon: ALL inner vertices inside outer
|
||||
pts = inner["points"]
|
||||
if len(pts) > 1 and pts[0] == pts[-1]:
|
||||
pts = pts[:-1]
|
||||
for pt in pts:
|
||||
if not OCCSketch._point_in_polygon(
|
||||
pt, outer["points"], margin=eps
|
||||
):
|
||||
return False
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def _loop_rep_point(loop: Dict[str, Any]) -> Tuple[float, float]:
|
||||
"""An interior representative point inside a loop.
|
||||
|
||||
For polygons we use the midpoint between the centroid and the first
|
||||
vertex (而不是 centroid 本身): a nested shape centered on the polygon's
|
||||
centroid (e.g. a circle inside a rectangle, both centered on the same
|
||||
point) would otherwise make the polygon's rep point coincide with the
|
||||
hole and break containment tests. This midpoint stays inside convex
|
||||
loops and is unlikely to land on a nested feature's center.
|
||||
Only used for circle-in-polygon containment checks (polygon-in-polygon
|
||||
uses all-vertex containment). Returns the centroid for polygons and
|
||||
the centre for circles.
|
||||
"""
|
||||
if loop["type"] == "polygon":
|
||||
pts = loop["points"][:-1] if len(loop["points"]) > 1 and loop["points"][0] == loop["points"][-1] else loop["points"]
|
||||
n = len(pts)
|
||||
if n < 3:
|
||||
return loop.get("center", (0.0, 0.0))
|
||||
sx = sum(p[0] for p in pts) / n
|
||||
sy = sum(p[1] for p in pts) / n
|
||||
v0 = pts[0]
|
||||
return ((sx + v0[0]) / 2.0, (sy + v0[1]) / 2.0)
|
||||
return loop["center"]
|
||||
return (sx, sy)
|
||||
return loop.get("center", (0.0, 0.0))
|
||||
|
||||
@staticmethod
|
||||
def _loop_area(loop: Dict[str, Any]) -> float:
|
||||
@@ -1303,6 +1361,28 @@ class OCCSketch(SketchInterface):
|
||||
best = face
|
||||
return best
|
||||
|
||||
@staticmethod
|
||||
def _loop_signed_area(loop: Dict[str, Any]) -> float:
|
||||
"""Signed area of a loop. Positive = CCW, negative = CW.
|
||||
|
||||
Circles are treated as CCW (positive area) because
|
||||
``gp_Circ`` / ``gp_Ax2`` creates edges with CCW parametric
|
||||
direction when looking against the normal.
|
||||
"""
|
||||
if loop["type"] == "circle":
|
||||
r = loop.get("radius", 0.0)
|
||||
return math.pi * r * r # always positive (CCW)
|
||||
pts = loop["points"]
|
||||
if len(pts) < 3:
|
||||
return 0.0
|
||||
area = 0.0
|
||||
n = len(pts) - 1 # last point == first for closed loops
|
||||
for i in range(n):
|
||||
x1, y1 = pts[i]
|
||||
x2, y2 = pts[i + 1]
|
||||
area += x1 * y2 - x2 * y1
|
||||
return area / 2.0
|
||||
|
||||
def build_face_geometry(self, face: Dict[str, Any]) -> OCCGeometryObject:
|
||||
"""Build an OCC face (outer boundary + inner holes) on the workplane.
|
||||
|
||||
@@ -1311,6 +1391,13 @@ class OCCSketch(SketchInterface):
|
||||
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.
|
||||
|
||||
Hole wires are oriented to have OPPOSITE geometric winding relative
|
||||
to the outer wire, which is what OCC's face builder expects for
|
||||
proper hole treatment. Previous code unconditionally reversed ALL
|
||||
hole wires, which produced solid islands (not holes) whenever the
|
||||
outer loop had clockwise winding — e.g. after dragging a rectangle
|
||||
from top-left to bottom-right.
|
||||
"""
|
||||
from OCP.BRepBuilderAPI import (
|
||||
BRepBuilderAPI_MakePolygon, BRepBuilderAPI_MakeFace,
|
||||
@@ -1319,32 +1406,40 @@ class OCCSketch(SketchInterface):
|
||||
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):
|
||||
def _wire_from_loop(loop: Dict[str, Any]):
|
||||
"""Build a wire from a loop dict. No orientation adjustment."""
|
||||
if loop["type"] == "polygon":
|
||||
mp = BRepBuilderAPI_MakePolygon()
|
||||
for (pu, pv) in loop["points"]:
|
||||
mp.Add(self._uv_to_world(pu, pv))
|
||||
mp.Close()
|
||||
mp.Build()
|
||||
w = mp.Wire()
|
||||
else:
|
||||
cu, cv = loop["center"]
|
||||
r = loop["radius"]
|
||||
circ = gp_Circ(self._circle_axis(cu, cv), r)
|
||||
me = BRepBuilderAPI_MakeEdge(circ)
|
||||
me.Build()
|
||||
mw = BRepBuilderAPI_MakeWire()
|
||||
mw.Add(me.Edge())
|
||||
mw.Build()
|
||||
w = mw.Wire()
|
||||
if is_hole:
|
||||
w = _TopoDS.Wire_s(w.Reversed()) # reverse so OCC treats it as a hole
|
||||
return w
|
||||
return mp.Wire()
|
||||
cu, cv = loop["center"]
|
||||
r = loop["radius"]
|
||||
circ = gp_Circ(self._circle_axis(cu, cv), r)
|
||||
me = BRepBuilderAPI_MakeEdge(circ)
|
||||
me.Build()
|
||||
mw = BRepBuilderAPI_MakeWire()
|
||||
mw.Add(me.Edge())
|
||||
mw.Build()
|
||||
return mw.Wire()
|
||||
|
||||
outer_loop = face["outer"]
|
||||
outer_wire = _wire_from_loop(outer_loop)
|
||||
outer_winding = self._loop_signed_area(outer_loop)
|
||||
|
||||
outer_wire = wire_loop(face["outer"], is_hole=False)
|
||||
face_maker = BRepBuilderAPI_MakeFace(outer_wire, True)
|
||||
for h in face["holes"]:
|
||||
face_maker.Add(wire_loop(h, is_hole=True))
|
||||
hole_wire = _wire_from_loop(h)
|
||||
hole_winding = self._loop_signed_area(h)
|
||||
# OCC expects hole wires to have OPPOSITE winding to the outer
|
||||
# wire (material on the other side). We reverse the hole wire
|
||||
# only when its natural winding matches the outer's; if they
|
||||
# already differ the wire is left as-is.
|
||||
if (hole_winding >= 0 and outer_winding >= 0) or (hole_winding < 0 and outer_winding < 0):
|
||||
hole_wire = _TopoDS.Wire_s(hole_wire.Reversed())
|
||||
face_maker.Add(hole_wire)
|
||||
face_maker.Build()
|
||||
occ_face = face_maker.Face()
|
||||
|
||||
|
||||
+1271
-58
File diff suppressed because it is too large
Load Diff
@@ -21,6 +21,76 @@ from fluency.geometry_occ.kernel import OCGeometryKernel, OCCGeometryObject
|
||||
from fluency.geometry_occ.sketch import OCCSketch
|
||||
|
||||
|
||||
@dataclass
|
||||
class Workplane:
|
||||
"""
|
||||
An independent working plane (datum plane) not tied to a face.
|
||||
|
||||
Workplanes can be created at any time and serve as the foundation for
|
||||
sketching and subsequent 3D operations (extrude, cut, revolve, etc.).
|
||||
They are visible in the 3D view as a semi-transparent reference grid.
|
||||
"""
|
||||
|
||||
id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
||||
name: str = "Untitled Workplane"
|
||||
|
||||
origin: Tuple[float, float, float] = (0.0, 0.0, 0.0)
|
||||
normal: Tuple[float, float, float] = (0.0, 0.0, 1.0)
|
||||
x_dir: Tuple[float, float, float] = (1.0, 0.0, 0.0)
|
||||
|
||||
# OCC AIS shape (visual plane) object id in the renderer
|
||||
render_object: Any = None
|
||||
visible: bool = True
|
||||
|
||||
created_at: datetime = field(default_factory=datetime.now)
|
||||
modified_at: datetime = field(default_factory=datetime.now)
|
||||
|
||||
def __post_init__(self):
|
||||
# Normalise normal and x_dir on construction.
|
||||
import numpy as np
|
||||
n = np.asarray(self.normal, dtype=float)
|
||||
n = n / np.linalg.norm(n)
|
||||
x = np.asarray(self.x_dir, dtype=float)
|
||||
# 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:
|
||||
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.normal = tuple(float(v) for v in n)
|
||||
self.x_dir = tuple(float(v) for v in x)
|
||||
self._y_dir = tuple(float(v) for v in y)
|
||||
|
||||
@property
|
||||
def y_dir(self) -> Tuple[float, float, float]:
|
||||
"""Derived in-plane Y axis (normal × x_dir)."""
|
||||
return self._y_dir
|
||||
|
||||
def uv_to_world(self, u: float, v: float) -> Tuple[float, float, float]:
|
||||
"""Map a UV point to 3D world coordinates on this plane."""
|
||||
ox, oy, oz = self.origin
|
||||
xx, xy, xz = self.x_dir
|
||||
yx, yy, yz = self._y_dir
|
||||
return (
|
||||
ox + u * xx + v * yx,
|
||||
oy + u * xy + v * yy,
|
||||
oz + u * xz + v * yz,
|
||||
)
|
||||
|
||||
def world_to_uv(self, p: Tuple[float, float, float]) -> Tuple[float, float]:
|
||||
"""Map a 3D world point to UV coordinates on this plane."""
|
||||
import numpy as np
|
||||
ox, oy, oz = self.origin
|
||||
v = np.array([p[0] - ox, p[1] - oy, p[2] - oz])
|
||||
xd = np.array(self.x_dir, dtype=float)
|
||||
yd = np.array(self._y_dir, dtype=float)
|
||||
return (float(np.dot(v, xd)), float(np.dot(v, yd)))
|
||||
|
||||
|
||||
@dataclass
|
||||
class Sketch:
|
||||
"""
|
||||
@@ -192,12 +262,34 @@ class Component:
|
||||
|
||||
sketches: Dict[str, Sketch] = field(default_factory=dict)
|
||||
bodies: Dict[str, Body] = field(default_factory=dict)
|
||||
workplanes: Dict[str, Workplane] = field(default_factory=dict)
|
||||
|
||||
active_sketch: Optional[str] = None
|
||||
active_workplane: Optional[str] = None
|
||||
|
||||
created_at: datetime = field(default_factory=datetime.now)
|
||||
modified_at: datetime = field(default_factory=datetime.now)
|
||||
|
||||
def add_workplane(self, workplane: Optional[Workplane] = None) -> Workplane:
|
||||
"""Add an independent workplane to the component."""
|
||||
if workplane is None:
|
||||
workplane = Workplane(name=f"Workplane {len(self.workplanes) + 1}")
|
||||
self.workplanes[workplane.id] = workplane
|
||||
if self.active_workplane is None:
|
||||
self.active_workplane = workplane.id
|
||||
self.modified_at = datetime.now()
|
||||
return workplane
|
||||
|
||||
def remove_workplane(self, wp_id: str) -> bool:
|
||||
"""Remove a workplane from the component."""
|
||||
if wp_id in self.workplanes:
|
||||
del self.workplanes[wp_id]
|
||||
if self.active_workplane == wp_id:
|
||||
self.active_workplane = next(iter(self.workplanes.keys()), None)
|
||||
self.modified_at = datetime.now()
|
||||
return True
|
||||
return False
|
||||
|
||||
def add_sketch(self, sketch: Optional[Sketch] = None) -> Sketch:
|
||||
"""Add a sketch to the component."""
|
||||
if sketch is None:
|
||||
|
||||
@@ -687,6 +687,141 @@ class OCCRenderer(Renderer):
|
||||
qcol = Quantity_Color(*color, Quantity_TOC_RGB)
|
||||
self._view.SetBackgroundColor(qcol)
|
||||
|
||||
# ─── Workplane visualization ────────────────────────────────────────
|
||||
|
||||
_WORKPLANE_BASE_ID: str = "__workplane__"
|
||||
_WORKPLANE_GRID_SIZE: float = 200.0 # total extent of the visual plane
|
||||
|
||||
def show_workplane_plane(
|
||||
self,
|
||||
origin: Tuple[float, float, float] = (0, 0, 0),
|
||||
normal: Tuple[float, float, float] = (0, 0, 1),
|
||||
x_dir: Tuple[float, float, float] = (1, 0, 0),
|
||||
size: float = 200.0,
|
||||
name: Optional[str] = None,
|
||||
) -> str:
|
||||
"""Display a semi-transparent planar grid at the workplane location.
|
||||
|
||||
The plane is a single large rectangular face with a grid of lines
|
||||
drawn on it, rendered at low opacity so it does not obscure the
|
||||
existing bodies. Returns an object ID that can be passed to
|
||||
:meth:`remove_workplane_plane`.
|
||||
|
||||
If *name* is provided it is used as the object ID; otherwise a
|
||||
unique one is generated. Passing an existing workplane ID will
|
||||
replace that workplane visual in place.
|
||||
"""
|
||||
import numpy as np
|
||||
from OCP.gp import gp_Pnt, gp_Dir
|
||||
from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeFace
|
||||
from OCP.gp import gp_Pln
|
||||
from OCP.AIS import AIS_Shape
|
||||
from OCP.Quantity import Quantity_Color, Quantity_TOC_RGB
|
||||
from OCP.Graphic3d import Graphic3d_MaterialAspect, Graphic3d_NameOfMaterial
|
||||
|
||||
obj_id = name or f"{self._WORKPLANE_BASE_ID}_{uuid.uuid4().hex[:8]}"
|
||||
|
||||
# Compute orthonormal basis.
|
||||
n = np.asarray(normal, dtype=float)
|
||||
n = n / np.linalg.norm(n)
|
||||
x = np.asarray(x_dir, dtype=float)
|
||||
x = x - np.dot(x, n) * n
|
||||
x_norm = np.linalg.norm(x)
|
||||
if x_norm < 1e-9:
|
||||
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)
|
||||
|
||||
hs = size / 2.0
|
||||
# Four corners of the plane in local coords.
|
||||
corners_3d = [
|
||||
(
|
||||
origin[0] + (-hs) * x[0] + (-hs) * y[0],
|
||||
origin[1] + (-hs) * x[1] + (-hs) * y[1],
|
||||
origin[2] + (-hs) * x[2] + (-hs) * y[2],
|
||||
),
|
||||
(
|
||||
origin[0] + (hs) * x[0] + (-hs) * y[0],
|
||||
origin[1] + (hs) * x[1] + (-hs) * y[1],
|
||||
origin[2] + (hs) * x[2] + (-hs) * y[2],
|
||||
),
|
||||
(
|
||||
origin[0] + (hs) * x[0] + (hs) * y[0],
|
||||
origin[1] + (hs) * x[1] + (hs) * y[1],
|
||||
origin[2] + (hs) * x[2] + (hs) * y[2],
|
||||
),
|
||||
(
|
||||
origin[0] + (-hs) * x[0] + (hs) * y[0],
|
||||
origin[1] + (-hs) * x[1] + (hs) * y[1],
|
||||
origin[2] + (-hs) * x[2] + (hs) * y[2],
|
||||
),
|
||||
]
|
||||
|
||||
# Build the planar face: point + normal direction.
|
||||
pln = gp_Pln(
|
||||
gp_Pnt(*origin),
|
||||
gp_Dir(n[0], n[1], n[2]),
|
||||
)
|
||||
# Use gp_Pnt for the corners to make a bounded face.
|
||||
from OCP.BRepBuilderAPI import BRepBuilderAPI_MakePolygon
|
||||
mp = BRepBuilderAPI_MakePolygon()
|
||||
for c in corners_3d:
|
||||
mp.Add(gp_Pnt(*c))
|
||||
mp.Close()
|
||||
wire = mp.Wire()
|
||||
face_maker = BRepBuilderAPI_MakeFace(pln, wire)
|
||||
face = face_maker.Face()
|
||||
|
||||
# Check if a workplane with this id already exists and remove it.
|
||||
existing = self._objects.get(obj_id)
|
||||
if existing is not None:
|
||||
self.remove_object(existing)
|
||||
|
||||
ais = AIS_Shape(face)
|
||||
# Semi-transparent blue-ish tint.
|
||||
ais.SetColor(Quantity_Color(0.55, 0.75, 0.95, Quantity_TOC_RGB)) # light blue
|
||||
try:
|
||||
ais.SetTransparency(0.75)
|
||||
except Exception:
|
||||
pass
|
||||
ais.SetDisplayMode(1) # shaded
|
||||
|
||||
# Draw the plane with a slight polygon offset so it doesn't z-fight.
|
||||
try:
|
||||
ais.SetPolygonOffsets(3, 1.0, -1.0)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
self._context.Display(ais, True)
|
||||
# Deactivate selection on the workplane plane so that face-picking
|
||||
# (``pick_planar_face``) does NOT intercept the workplane's face
|
||||
# when the user clicks through it to select a body face underneath.
|
||||
# The workplane is decorative reference geometry — it must never
|
||||
# be a target of face-pick mode.
|
||||
try:
|
||||
self._context.Deactivate(ais)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
robj = OCCRenderObject(
|
||||
obj_id=obj_id,
|
||||
ais_shape=ais,
|
||||
ais_type="workplane",
|
||||
color=RenderColor(0.55, 0.75, 0.95),
|
||||
)
|
||||
self._objects[obj_id] = robj
|
||||
self._view.Redraw()
|
||||
return obj_id
|
||||
|
||||
def remove_workplane_plane(self, obj_id: str) -> bool:
|
||||
"""Remove a workplane plane visual by its object ID."""
|
||||
robj = self._objects.get(obj_id)
|
||||
if robj is not None:
|
||||
return self.remove_object(robj)
|
||||
return False
|
||||
|
||||
def take_screenshot(self) -> bytes:
|
||||
return b""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user