- 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
+138 -43
View File
@@ -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()