- 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" />
</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 @@
&quot;Python.2dtest.executor&quot;: &quot;Run&quot;,
&quot;Python.3d_windows.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_widget_solve.executor&quot;: &quot;Run&quot;,
&quot;Python.fluency.executor&quot;: &quot;Run&quot;,
@@ -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>
+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()
+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
@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:
+135
View File
@@ -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""