- sketch enhacements

This commit is contained in:
bklronin
2026-07-04 12:10:58 +02:00
parent 01833e4af2
commit 6ba742ddf5
5 changed files with 1652 additions and 104 deletions
+16 -3
View File
@@ -4,10 +4,12 @@
<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="- 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$/.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/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/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> </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" />
@@ -49,6 +51,8 @@
&quot;Python.2dtest.executor&quot;: &quot;Run&quot;, &quot;Python.2dtest.executor&quot;: &quot;Run&quot;,
&quot;Python.3d_windows.executor&quot;: &quot;Run&quot;, &quot;Python.3d_windows.executor&quot;: &quot;Run&quot;,
&quot;Python.Unnamed.executor&quot;: &quot;Run&quot;, &quot;Python.Unnamed.executor&quot;: &quot;Run&quot;,
&quot;Python.base.executor&quot;: &quot;Run&quot;,
&quot;Python.data_model.executor&quot;: &quot;Run&quot;,
&quot;Python.draw_widget2d.executor&quot;: &quot;Run&quot;, &quot;Python.draw_widget2d.executor&quot;: &quot;Run&quot;,
&quot;Python.draw_widget_solve.executor&quot;: &quot;Run&quot;, &quot;Python.draw_widget_solve.executor&quot;: &quot;Run&quot;,
&quot;Python.fluency.executor&quot;: &quot;Run&quot;, &quot;Python.fluency.executor&quot;: &quot;Run&quot;,
@@ -294,7 +298,15 @@
<option name="project" value="LOCAL" /> <option name="project" value="LOCAL" />
<updated>1782928990792</updated> <updated>1782928990792</updated>
</task> </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 /> <servers />
</component> </component>
<component name="TypeScriptGeneratedFilesManager"> <component name="TypeScriptGeneratedFilesManager">
@@ -335,6 +347,7 @@
<MESSAGE value="- Tons of addtions" /> <MESSAGE value="- Tons of addtions" />
<MESSAGE value="- Basic operations" /> <MESSAGE value="- Basic operations" />
<MESSAGE value="- removed cadquery deoendency" /> <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> </component>
</project> </project>
+138 -43
View File
@@ -1159,16 +1159,24 @@ class OCCSketch(SketchInterface):
return loops return loops
@staticmethod @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. """Ray-casting point-in-polygon test.
Returns *True* only for strictly interior points. Points on the Returns *True* for points strictly inside the polygon. Points on
boundary (within 1e-9) are considered *outside* so that the outer the boundary (within eps=1e-9) are *outside* by default so the
boundary of a nested shape doesn't falsely contain another loop whose outer boundary of a nested shape doesn't falsely contain a hole's
representative point happens to land on that boundary. 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 x, y = pt
eps = 1e-9 eps = 1e-9 # strict boundary rejection
margin = float(margin)
n = len(poly) n = len(poly)
inside = False inside = False
j = n - 1 j = n - 1
@@ -1177,11 +1185,17 @@ class OCCSketch(SketchInterface):
xj, yj = poly[j] xj, yj = poly[j]
# Point-on-segment test — exclude strict boundary hits. # Point-on-segment test — exclude strict boundary hits.
# First check bounding box of the segment. # 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 # Check collinearity
cross = (x - xi) * (yj - yi) - (y - yi) * (xj - xi) cross = (x - xi) * (yj - yi) - (y - yi) * (xj - xi)
if abs(cross) < eps: abs_cross = abs(cross)
return False # on boundary 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): if ((yi > y) != (yj > y)) and (x < (xj - xi) * (y - yi) / (yj - yi + 1e-30) + xi):
inside = not inside inside = not inside
j = i j = i
@@ -1189,34 +1203,78 @@ class OCCSketch(SketchInterface):
@staticmethod @staticmethod
def _loop_contains(inner: Dict[str, Any], outer: Dict[str, Any]) -> bool: def _loop_contains(inner: Dict[str, Any], outer: Dict[str, Any]) -> bool:
"""Does ``outer`` fully enclose ``inner``? Uses a representative point + """Does ``outer`` fully enclose ``inner``?
boundary tests on ``outer`` (only valid when ``outer`` != ``inner``)."""
rep = OCCSketch._loop_rep_point(inner) For polygon-polygon: checks that ALL vertices of ``inner`` are strictly
if outer["type"] == "polygon": inside ``outer`` using ray-casting. This is robust for convex polygons
return OCCSketch._point_in_polygon(rep, outer["points"]) and avoids the representative-point issue where a large nested loop's
else: # circle centroid lands inside an inner loop.
cx, cy = outer["center"]
return math.hypot(rep[0] - cx, rep[1] - cy) < outer["radius"] 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 @staticmethod
def _loop_rep_point(loop: Dict[str, Any]) -> Tuple[float, float]: def _loop_rep_point(loop: Dict[str, Any]) -> Tuple[float, float]:
"""An interior representative point inside a loop. """An interior representative point inside a loop.
For polygons we use the midpoint between the centroid and the first Only used for circle-in-polygon containment checks (polygon-in-polygon
vertex (而不是 centroid 本身): a nested shape centered on the polygon's uses all-vertex containment). Returns the centroid for polygons and
centroid (e.g. a circle inside a rectangle, both centered on the same the centre for circles.
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.
""" """
if loop["type"] == "polygon": if loop["type"] == "polygon":
pts = loop["points"][:-1] if len(loop["points"]) > 1 and loop["points"][0] == loop["points"][-1] else loop["points"] pts = loop["points"][:-1] if len(loop["points"]) > 1 and loop["points"][0] == loop["points"][-1] else loop["points"]
n = len(pts) n = len(pts)
if n < 3:
return loop.get("center", (0.0, 0.0))
sx = sum(p[0] for p in pts) / n sx = sum(p[0] for p in pts) / n
sy = sum(p[1] for p in pts) / n sy = sum(p[1] for p in pts) / n
v0 = pts[0] return (sx, sy)
return ((sx + v0[0]) / 2.0, (sy + v0[1]) / 2.0) return loop.get("center", (0.0, 0.0))
return loop["center"]
@staticmethod @staticmethod
def _loop_area(loop: Dict[str, Any]) -> float: def _loop_area(loop: Dict[str, Any]) -> float:
@@ -1303,6 +1361,28 @@ class OCCSketch(SketchInterface):
best = face best = face
return best 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: def build_face_geometry(self, face: Dict[str, Any]) -> OCCGeometryObject:
"""Build an OCC face (outer boundary + inner holes) on the workplane. """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 sketch's 3D plane (not necessarily XY). The returned object stores
the raw OCC face in ``.shape`` and the plane normal in the raw OCC face in ``.shape`` and the plane normal in
``metadata["normal"]`` for the extrude kernel. ``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 ( from OCP.BRepBuilderAPI import (
BRepBuilderAPI_MakePolygon, BRepBuilderAPI_MakeFace, 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.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_from_loop(loop: Dict[str, Any]):
"""Build a wire from a loop dict. No orientation adjustment."""
if loop["type"] == "polygon": if loop["type"] == "polygon":
mp = BRepBuilderAPI_MakePolygon() mp = BRepBuilderAPI_MakePolygon()
for (pu, pv) in loop["points"]: for (pu, pv) in loop["points"]:
mp.Add(self._uv_to_world(pu, pv)) mp.Add(self._uv_to_world(pu, pv))
mp.Close() mp.Close()
mp.Build() mp.Build()
w = mp.Wire() return mp.Wire()
else: cu, cv = loop["center"]
cu, cv = loop["center"] r = loop["radius"]
r = loop["radius"] circ = gp_Circ(self._circle_axis(cu, cv), 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() mw.Add(me.Edge())
mw.Add(me.Edge()) mw.Build()
mw.Build() return mw.Wire()
w = mw.Wire()
if is_hole: outer_loop = face["outer"]
w = _TopoDS.Wire_s(w.Reversed()) # reverse so OCC treats it as a hole outer_wire = _wire_from_loop(outer_loop)
return w outer_winding = self._loop_signed_area(outer_loop)
outer_wire = wire_loop(face["outer"], is_hole=False)
face_maker = BRepBuilderAPI_MakeFace(outer_wire, True) face_maker = BRepBuilderAPI_MakeFace(outer_wire, True)
for h in face["holes"]: 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() face_maker.Build()
occ_face = face_maker.Face() occ_face = face_maker.Face()
+1271 -58
View File
File diff suppressed because it is too large Load Diff
+92
View File
@@ -21,6 +21,76 @@ from fluency.geometry_occ.kernel import OCGeometryKernel, OCCGeometryObject
from fluency.geometry_occ.sketch import OCCSketch 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 @dataclass
class Sketch: class Sketch:
""" """
@@ -192,12 +262,34 @@ class Component:
sketches: Dict[str, Sketch] = field(default_factory=dict) sketches: Dict[str, Sketch] = field(default_factory=dict)
bodies: Dict[str, Body] = 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_sketch: Optional[str] = None
active_workplane: Optional[str] = None
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 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: def add_sketch(self, sketch: Optional[Sketch] = None) -> Sketch:
"""Add a sketch to the component.""" """Add a sketch to the component."""
if sketch is None: if sketch is None:
+135
View File
@@ -687,6 +687,141 @@ class OCCRenderer(Renderer):
qcol = Quantity_Color(*color, Quantity_TOC_RGB) qcol = Quantity_Color(*color, Quantity_TOC_RGB)
self._view.SetBackgroundColor(qcol) 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: def take_screenshot(self) -> bytes:
return b"" return b""