- Tons of addtions

This commit is contained in:
bklronin
2026-06-28 22:51:52 +02:00
parent f8f16ea800
commit f6422e0847
7 changed files with 1010 additions and 149 deletions
+98 -31
View File
@@ -46,7 +46,11 @@ class OCGeometryKernel(GeometryKernel):
self._mesh_tolerance: float = 0.1
def _get_shape(self, obj: GeometryObject) -> Any:
"""Extract the underlying OCC shape from a GeometryObject."""
"""Extract the underlying OCC shape from a GeometryObject.
Returns *None* if the object carries no shape (e.g. an empty sketch) —
callers should check for None before using the result.
"""
import cadquery as cq
if isinstance(obj, OCCGeometryObject):
@@ -64,7 +68,13 @@ class OCGeometryKernel(GeometryKernel):
if hasattr(obj.shape, "wrapped"):
return obj.shape.wrapped
return obj.shape
return obj.shape if obj.shape else obj
# No cadquery obj and no raw shape → genuinely empty.
return None
# Non-OCCGeometryObject: return its shape if present, else None.
# (Use explicit identity/truth checks — some OCP TopoDS objects have a
# falsy __bool__, so ``obj.shape if obj.shape`` is unsafe.)
shape = getattr(obj, "shape", None)
return shape if shape is not None else None
def _get_cq_obj(self, obj: GeometryObject) -> Any:
"""Get CadQuery object from GeometryObject."""
@@ -157,30 +167,94 @@ class OCGeometryKernel(GeometryKernel):
direction: Tuple[float, float, float] = (0, 0, 1),
symmetric: bool = False,
) -> GeometryObject:
"""Extrude a 2D sketch into a 3D solid.
"""Extrude a sketch face into a 3D solid along the sketch plane normal.
*height* is extruded along *direction* (default +Z). A negative *height*
extrudes in the opposite direction. The *direction* argument is accepted
for API compatibility; currently only the sign of *height* is used for
direction (positive → +Z, negative → -Z).
The sketch's plane normal is read from ``sketch.metadata["normal"]``
(set by ``OCCSketch.build_face_geometry``); it defaults to +Z for
legacy objects that don't carry one. *direction* is accepted for API
compatibility but ignored — the plane normal is authoritative. A
negative *height* extrudes against the normal.
"""
import cadquery as cq
from OCP.gp import gp_Vec
from OCP.BRepPrimAPI import BRepPrimAPI_MakePrism
from OCP.BRepAlgoAPI import BRepAlgoAPI_Fuse
from OCP.TopoDS import TopoDS_Shape
cq_obj = self._get_cq_obj(sketch)
face = self._get_shape(sketch)
if face is None:
raise ValueError(
"Cannot extrude: sketch has no geometry. "
"Draw a closed profile before extruding."
)
# ``face`` may be a TopoDS_Face (new path) or a compound/wire from
# legacy cadquery objects. If it's not already a face, build one.
face = self._ensure_face(face)
if face is None:
raise ValueError(
"Cannot extrude: sketch geometry is not a valid face. "
"Ensure the profile is closed (no open ends)."
)
if isinstance(cq_obj, cq.Workplane):
if symmetric:
solid = cq_obj.extrude(height / 2, both=True)
else:
solid = cq_obj.extrude(height)
normal = self._sketch_normal(sketch)
nx, ny, nz = normal
def _prism(h: float):
vec = gp_Vec(nx * h, ny * h, nz * h)
maker = BRepPrimAPI_MakePrism(face, vec, False, True)
maker.Build()
return maker.Shape()
if symmetric:
half = height / 2.0
pos = _prism(half)
neg = _prism(-half)
fuse = BRepAlgoAPI_Fuse(pos, neg)
fuse.Build()
solid = fuse.Shape()
else:
wp = cq.Workplane("XY").add(cq_obj)
if symmetric:
solid = wp.extrude(height / 2, both=True)
else:
solid = wp.extrude(height)
solid = _prism(height)
return OCCGeometryObject(solid, {"type": "extrusion"})
return OCCGeometryObject(solid, {"type": "extrusion", "normal": normal})
@staticmethod
def _sketch_normal(obj: GeometryObject) -> Tuple[float, float, float]:
"""Return the normal stored on a sketch-derived geometry object, else +Z."""
import numpy as np
meta = getattr(obj, "metadata", None) or {}
n = meta.get("normal")
if n is None:
return (0.0, 0.0, 1.0)
arr = np.asarray(n, dtype=float)
norm = float(np.linalg.norm(arr))
if norm < 1e-12:
return (0.0, 0.0, 1.0)
arr = arr / norm
return (float(arr[0]), float(arr[1]), float(arr[2]))
@staticmethod
def _ensure_face(shape: Any) -> Any:
"""Return a ``TopoDS_Face`` from *shape*, or *None* if impossible.
If *shape* is already a face, return it unchanged; otherwise try to
build a planar face from it (wire/edge/compound). Returns *None* for
empty/invalid input so callers can surface a clear error instead of
feeding a non-face to ``BRepPrimAPI_MakePrism``.
"""
from OCP.TopoDS import TopoDS_Face
from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeFace
if shape is None:
return None
if isinstance(shape, TopoDS_Face):
return shape
try:
maker = BRepBuilderAPI_MakeFace(shape, True)
maker.Build()
if maker.IsDone():
return maker.Face()
except Exception:
pass
return None
def revolve(
self,
@@ -189,22 +263,16 @@ class OCGeometryKernel(GeometryKernel):
axis: Tuple[float, float, float] = (0, 0, 1),
origin: Tuple[float, float, float] = (0, 0, 0),
) -> GeometryObject:
"""Revolve a 2D sketch around an axis."""
import cadquery as cq
"""Revolve a sketch face around an axis."""
import math
# Get the OCC shape directly
# Get the OCC shape directly (a TopoDS_Face for new sketch geometry).
shape = self._get_shape(sketch)
face = self._ensure_face(shape)
from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeFace
from OCP.gp import gp_Ax1, gp_Pnt, gp_Dir
from OCP.BRepPrimAPI import BRepPrimAPI_MakeRevol
# Build a face from the wire/shape
face_maker = BRepBuilderAPI_MakeFace(shape, False)
face_maker.Build()
face = face_maker.Face()
# Revolve the face around the axis
revolve_axis = gp_Ax1(gp_Pnt(*origin), gp_Dir(*axis))
angle_rad = math.radians(angle)
@@ -212,8 +280,7 @@ class OCGeometryKernel(GeometryKernel):
revolver.Build()
solid_shape = revolver.Shape()
solid = cq.Shape(solid_shape)
return OCCGeometryObject(solid, {"type": "revolution"})
return OCCGeometryObject(solid_shape, {"type": "revolution"})
def loft(self, profiles: List[GeometryObject], ruled: bool = False) -> GeometryObject:
"""Create a loft between multiple profiles."""
+111 -36
View File
@@ -64,6 +64,78 @@ class OCCSketch(SketchInterface):
# Track first point as dragged/fixed for solver stability
self._first_point_id: Optional[int] = None
# ── Workplane ───────────────────────────────────────────────────
# The sketch lives in a 2D UV frame on this plane. UV coordinates
# map to world via: P = origin + u*x_dir + v*y_dir
# where y_dir = normal × x_dir. Defaults to the world XY plane so
# existing XY-only behaviour is unchanged.
self._wp_origin: Tuple[float, float, float] = (0.0, 0.0, 0.0)
self._wp_normal: Tuple[float, float, float] = (0.0, 0.0, 1.0)
self._wp_x_dir: Tuple[float, float, float] = (1.0, 0.0, 0.0)
self._wp_y_dir: Tuple[float, float, float] = (0.0, 1.0, 0.0)
# ─── Workplane management ──────────────────────────────────────────────
def set_workplane(
self,
origin: Tuple[float, float, float],
normal: Tuple[float, float, float],
x_dir: Tuple[float, float, float],
) -> None:
"""Place this sketch on an arbitrary plane in 3D.
*normal* and *x_dir* need not be unit/perpendicular — they are
orthonormalised here. ``y_dir`` is derived as ``normal × x_dir``.
Existing UV coordinates are unchanged; only their world mapping moves.
"""
import numpy as np
n = np.asarray(normal, dtype=float)
x = np.asarray(x_dir, dtype=float)
n = n / np.linalg.norm(n)
# 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:
# x_dir is parallel to normal — pick any orthogonal basis vector.
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._wp_origin = tuple(float(v) for v in origin)
self._wp_normal = tuple(float(v) for v in n)
self._wp_x_dir = tuple(float(v) for v in x)
self._wp_y_dir = tuple(float(v) for v in y)
def get_workplane(self) -> Tuple[Tuple[float, float, float], ...]:
"""Return the (origin, normal, x_dir, y_dir) of this sketch's plane."""
return (self._wp_origin, self._wp_normal, self._wp_x_dir, self._wp_y_dir)
def _uv_to_world(self, u: float, v: float):
"""Map a UV point to a world ``gp_Pnt`` on the workplane."""
from OCP.gp import gp_Pnt
ox, oy, oz = self._wp_origin
xx, xy, xz = self._wp_x_dir
yx, yy, yz = self._wp_y_dir
return gp_Pnt(
ox + u * xx + v * yx,
oy + u * xy + v * yy,
oz + u * xz + v * yz,
)
def _circle_axis(self, u: float, v: float):
"""Return a ``gp_Ax2`` for a circle centred at UV on the workplane."""
from OCP.gp import gp_Ax2, gp_Dir
center = self._uv_to_world(u, v)
return gp_Ax2(
center,
gp_Dir(*self._wp_normal),
gp_Dir(*self._wp_x_dir),
)
@property
def solver(self) -> SolverSystem:
"""Access the underlying SolveSpace solver."""
@@ -71,7 +143,13 @@ class OCCSketch(SketchInterface):
@property
def workplane(self) -> Any:
"""Get the solver workplane entity."""
"""Get the SolveSpace 2D solver workplane entity.
Note: this is the solver's internal 2D base, not the 3D placement
plane — see :meth:`set_workplane` / :meth:`workplane` (no underscore)
for the 3D plane. The solver always runs in UV regardless of the
3D placement.
"""
return self._wp
def _next_id(self) -> int:
@@ -576,42 +654,39 @@ class OCCSketch(SketchInterface):
# ─── Geometry extraction for operations ────────────────────────────────
def get_geometry(self) -> GeometryObject:
"""Get the solved geometry for operations using CadQuery.
"""Get the solved geometry as an OCC ``TopoDS_Face`` on the workplane.
If the sketch has exactly one detected face (outer boundary + optional
holes) that face is returned as a combined face-with-holes Workplane.
Otherwise falls back to returning a single circle or polygon suitable
for extrude/revolve.
holes) that face is returned. Otherwise falls back to returning a
single circle or polygon as a face. The returned object carries the
workplane normal in ``metadata["normal"]`` so the kernel can extrude
along the plane normal (not a hardcoded +Z).
"""
import cadquery as cq
faces = self.detect_faces()
if len(faces) == 1:
return self.build_face_geometry(faces[0])
# Fallback: return the first circle, or a polygon, or None.
# Fallback: wrap the first circle, or the polygon, as a single-loop face.
if self._circles:
for entity_id, (center_id, radius) in self._circles.items():
center_entity = self._entities.get(center_id)
if center_entity and center_entity.geometry:
cx, cy = center_entity.geometry
wp = cq.Workplane("XY").center(cx, cy).circle(radius)
obj = OCCGeometryObject(wp.val())
obj._cadquery_obj = wp
return obj
face_dict = {
"outer": {"type": "circle", "center": (cx, cy), "radius": radius},
"holes": [],
}
return self.build_face_geometry(face_dict)
points = self.get_polygon_points()
if not points:
return OCCGeometryObject(None)
wp = cq.Workplane("XY").moveTo(points[0].x, points[0].y)
for pt in points[1:]:
wp = wp.lineTo(pt.x, pt.y)
wp = wp.close()
obj = OCCGeometryObject(wp.val())
obj._cadquery_obj = wp
return obj
face_dict = {
"outer": {"type": "polygon", "points": [(p.x, p.y) for p in points]},
"holes": [],
}
return self.build_face_geometry(face_dict)
def get_points(self) -> List[Point2D]:
"""Get all point positions from solved solver data."""
@@ -910,34 +985,33 @@ class OCCSketch(SketchInterface):
return best
def build_face_geometry(self, face: Dict[str, Any]) -> OCCGeometryObject:
"""Build an OCC face (outer boundary + inner holes) wrapped in a Workplane.
"""Build an OCC face (outer boundary + inner holes) on the workplane.
The returned object feeds ``OCGeometryKernel.extrude`` directly: its
``_cadquery_obj`` is a Workplane whose stack holds the face, so cadquery's
``Workplane.extrude`` lifts it into a solid — inner wires become
through-holes.
Wires are constructed from UV coordinates mapped through
:meth:`_uv_to_world`, so the resulting ``TopoDS_Face`` lies on this
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.
"""
import cadquery as cq
from OCP.BRepBuilderAPI import (
BRepBuilderAPI_MakePolygon, BRepBuilderAPI_MakeFace,
BRepBuilderAPI_MakeWire, BRepBuilderAPI_MakeEdge,
)
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):
if loop["type"] == "polygon":
mp = BRepBuilderAPI_MakePolygon()
for (px, py) in loop["points"]:
mp.Add(gp_Pnt(px, py, 0.0))
for (pu, pv) in loop["points"]:
mp.Add(self._uv_to_world(pu, pv))
mp.Close()
mp.Build()
w = mp.Wire()
else:
cx, cy = loop["center"]
cu, cv = loop["center"]
r = loop["radius"]
circ = gp_Circ(gp_Ax2(gp_Pnt(cx, cy, 0.0), gp_Dir(0, 0, 1)), r)
circ = gp_Circ(self._circle_axis(cu, cv), r)
me = BRepBuilderAPI_MakeEdge(circ)
me.Build()
mw = BRepBuilderAPI_MakeWire()
@@ -945,7 +1019,7 @@ class OCCSketch(SketchInterface):
mw.Build()
w = mw.Wire()
if is_hole:
w = _TopoDS.Wire_s(w.Reversed()) # reverse orientation so OCC treats it as a hole
w = _TopoDS.Wire_s(w.Reversed()) # reverse so OCC treats it as a hole
return w
outer_wire = wire_loop(face["outer"], is_hole=False)
@@ -955,10 +1029,11 @@ class OCCSketch(SketchInterface):
face_maker.Build()
occ_face = face_maker.Face()
wp = cq.Workplane("XY")
wp = wp.add(cq.Face(occ_face))
obj = OCCGeometryObject(wp.val())
obj._cadquery_obj = wp
obj = OCCGeometryObject(occ_face, {
"type": "sketch_face",
"normal": self._wp_normal,
"origin": self._wp_origin,
})
return obj
def get_solver_dof(self) -> int: