- sketch enhacements
This commit is contained in:
Generated
+16
-3
@@ -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 @@
|
|||||||
"Python.2dtest.executor": "Run",
|
"Python.2dtest.executor": "Run",
|
||||||
"Python.3d_windows.executor": "Run",
|
"Python.3d_windows.executor": "Run",
|
||||||
"Python.Unnamed.executor": "Run",
|
"Python.Unnamed.executor": "Run",
|
||||||
|
"Python.base.executor": "Run",
|
||||||
|
"Python.data_model.executor": "Run",
|
||||||
"Python.draw_widget2d.executor": "Run",
|
"Python.draw_widget2d.executor": "Run",
|
||||||
"Python.draw_widget_solve.executor": "Run",
|
"Python.draw_widget_solve.executor": "Run",
|
||||||
"Python.fluency.executor": "Run",
|
"Python.fluency.executor": "Run",
|
||||||
@@ -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>
|
||||||
@@ -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,15 +1406,15 @@ 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)
|
||||||
@@ -1336,15 +1423,23 @@ class OCCSketch(SketchInterface):
|
|||||||
mw = BRepBuilderAPI_MakeWire()
|
mw = BRepBuilderAPI_MakeWire()
|
||||||
mw.Add(me.Edge())
|
mw.Add(me.Edge())
|
||||||
mw.Build()
|
mw.Build()
|
||||||
w = mw.Wire()
|
return mw.Wire()
|
||||||
if is_hole:
|
|
||||||
w = _TopoDS.Wire_s(w.Reversed()) # reverse so OCC treats it as a hole
|
outer_loop = face["outer"]
|
||||||
return w
|
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)
|
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()
|
||||||
|
|
||||||
|
|||||||
+1245
-32
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
|
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:
|
||||||
|
|||||||
@@ -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""
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user