feat: Replace SDF kernel with OpenCASCADE, VTK with pygfx

Major architecture migration:

- Remove SDF-based geometry kernel (sdf/)
- Remove VTK renderer (drawing_modules/)
- Remove old mesh modules (mesh_modules/)

New components:
- geometry/base.py: Abstract geometry kernel interface
- geometry_occ/kernel.py: OpenCASCADE implementation via CadQuery/OCP
- geometry_occ/sketch.py: 2D sketching with constraint solving
- rendering/base.py: Abstract renderer interface
- rendering/pygfx_renderer.py: WebGPU-based renderer
- models/data_model.py: Project, Component, Sketch, Body classes
- main.py: New Qt-based application

Features:
- STEP/IGES import/export
- Exact BRep geometry (vs approximate SDF mesh)
- Parametric sketching with constraints
- Boolean operations (union, difference, intersection)
- Fillet and chamfer operations
- Modern pygfx renderer (~30MB vs VTK ~200MB)

Dependencies:
- cadquery >= 2.4
- ocp >= 7.9.3
- pygfx >= 0.7.0
- wgpu >= 0.19.0
- PySide6 >= 6.9.0
This commit is contained in:
bklronin
2026-03-14 08:45:07 +01:00
parent d6044e551a
commit fe23ca610c
90 changed files with 3737 additions and 14523 deletions
+11
View File
@@ -0,0 +1,11 @@
"""OpenCASCADE geometry module."""
from fluency.geometry_occ.kernel import OCGeometryKernel, OCCGeometryObject
from fluency.geometry_occ.sketch import OCCSketch, OCCSketchEntity
__all__ = [
"OCGeometryKernel",
"OCCGeometryObject",
"OCCSketch",
"OCCSketchEntity",
]
+726
View File
@@ -0,0 +1,726 @@
"""
OpenCASCADE-based geometry kernel for Fluency CAD.
This module provides a concrete implementation of the geometry kernel
using CadQuery and OCP (OpenCASCADE Python bindings).
"""
from typing import List, Tuple, Optional, Any, Dict
import numpy as np
from fluency.geometry.base import (
GeometryKernel,
GeometryObject,
Point2D,
Point3D,
)
class OCCGeometryObject(GeometryObject):
"""Geometry object wrapper for OpenCASCADE shapes."""
def __init__(self, shape: Any = None, metadata: Optional[Dict] = None):
super().__init__(shape, metadata)
self._cadquery_obj: Any = None
@property
def cq_obj(self) -> Any:
"""Get the CadQuery object if available."""
return self._cadquery_obj
@cq_obj.setter
def cq_obj(self, value: Any) -> None:
self._cadquery_obj = value
class OCGeometryKernel(GeometryKernel):
"""
OpenCASCADE-based geometry kernel implementation.
This kernel uses CadQuery for high-level operations and
OCP for direct OpenCASCADE access when needed.
"""
def __init__(self) -> None:
self._tolerance: float = 0.001
self._mesh_tolerance: float = 0.1
def _get_shape(self, obj: GeometryObject) -> Any:
"""Extract the underlying OCC shape from a GeometryObject."""
if isinstance(obj, OCCGeometryObject):
if obj._cadquery_obj is not None:
shape = obj._cadquery_obj.val()
if hasattr(shape, "wrapped"):
return shape.wrapped
return shape
if obj.shape is not None:
if hasattr(obj.shape, "wrapped"):
return obj.shape.wrapped
return obj.shape
return obj.shape if obj.shape else obj
def _get_cq_obj(self, obj: GeometryObject) -> Any:
"""Get CadQuery object from GeometryObject."""
if isinstance(obj, OCCGeometryObject) and obj._cadquery_obj is not None:
return obj._cadquery_obj
return obj.shape
def create_point(self, x: float, y: float) -> GeometryObject:
"""Create a 2D point."""
import cadquery as cq
point = cq.Vector(x, y, 0)
return OCCGeometryObject(point)
def create_line(self, start: Point2D, end: Point2D) -> GeometryObject:
"""Create a 2D line segment."""
import cadquery as cq
wire = cq.Workplane("XY").moveTo(start.x, start.y).lineTo(end.x, end.y)
return OCCGeometryObject(wire.val(), {"type": "line"})
def create_circle(self, center: Point2D, radius: float) -> GeometryObject:
"""Create a 2D circle."""
import cadquery as cq
wire = cq.Workplane("XY").center(center.x, center.y).circle(radius)
return OCCGeometryObject(wire.val(), {"type": "circle"})
def create_arc(
self, center: Point2D, radius: float, start_angle: float, end_angle: float
) -> GeometryObject:
"""Create a 2D arc."""
import cadquery as cq
import math
start_rad = math.radians(start_angle)
end_rad = math.radians(end_angle)
start_x = center.x + radius * math.cos(start_rad)
start_y = center.y + radius * math.sin(start_rad)
wire = (
cq.Workplane("XY")
.moveTo(start_x, start_y)
.radiusArc(
(center.x + radius * math.cos(end_rad), center.y + radius * math.sin(end_rad)),
radius,
)
)
return OCCGeometryObject(wire.val(), {"type": "arc"})
def create_polygon(self, points: List[Point2D]) -> GeometryObject:
"""Create a closed polygon from points."""
import cadquery as cq
if len(points) < 3:
raise ValueError("Polygon requires at least 3 points")
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()
return OCCGeometryObject(wp.val(), {"type": "polygon"})
def create_rectangle(
self, width: float, height: float, center: Optional[Point2D] = None
) -> GeometryObject:
"""Create a rectangle."""
import cadquery as cq
cx = center.x if center else 0
cy = center.y if center else 0
wire = cq.Workplane("XY").center(cx, cy).rect(width, height)
return OCCGeometryObject(wire.val(), {"type": "rectangle"})
def extrude(
self,
sketch: GeometryObject,
height: float,
direction: Tuple[float, float, float] = (0, 0, 1),
symmetric: bool = False,
) -> GeometryObject:
"""Extrude a 2D sketch into a 3D solid."""
import cadquery as cq
cq_obj = self._get_cq_obj(sketch)
if symmetric:
half_height = height / 2
if isinstance(cq_obj, cq.Workplane):
solid = cq_obj.extrude(half_height, both=True)
else:
face = cq.Face.makeFromWires(cq_obj)
solid = face.extrude(cq.Vector(0, 0, half_height) * 2)
else:
if isinstance(cq_obj, cq.Workplane):
solid = cq_obj.extrude(height)
else:
face = cq.Face.makeFromWires(cq_obj)
dir_vec = cq.Vector(*direction).normalized() * height
solid = face.extrude(dir_vec)
return OCCGeometryObject(solid, {"type": "extrusion"})
def revolve(
self,
sketch: GeometryObject,
angle: float = 360.0,
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
cq_obj = self._get_cq_obj(sketch)
if isinstance(cq_obj, cq.Workplane):
solid = cq_obj.revolve(angle)
else:
face = cq.Face.makeFromWires(cq_obj)
axis_vec = cq.Vector(*axis)
origin_vec = cq.Vector(*origin)
solid = face.revolve(axis_vec, origin_vec, angle)
return OCCGeometryObject(solid, {"type": "revolution"})
def loft(self, profiles: List[GeometryObject], ruled: bool = False) -> GeometryObject:
"""Create a loft between multiple profiles."""
import cadquery as cq
if len(profiles) < 2:
raise ValueError("Loft requires at least 2 profiles")
wires = []
for profile in profiles:
cq_obj = self._get_cq_obj(profile)
if isinstance(cq_obj, cq.Workplane):
wires.append(cq_obj.val())
else:
wires.append(cq_obj)
loft = cq.Solid.loft(wires, ruled)
return OCCGeometryObject(loft, {"type": "loft"})
def sweep(
self, profile: GeometryObject, path: GeometryObject, is_frenet: bool = False
) -> GeometryObject:
"""Sweep a profile along a path."""
import cadquery as cq
profile_obj = self._get_cq_obj(profile)
path_obj = self._get_cq_obj(path)
if isinstance(profile_obj, cq.Workplane):
profile_wire = profile_obj.val()
else:
profile_wire = profile_obj
if isinstance(path_obj, cq.Workplane):
path_wire = path_obj.val()
else:
path_wire = path_obj
solid = cq.Solid.sweep(profile_wire, path_wire, is_frenet)
return OCCGeometryObject(solid, {"type": "sweep"})
def boolean_union(self, *bodies: GeometryObject) -> GeometryObject:
"""Union multiple bodies."""
import cadquery as cq
if len(bodies) < 2:
return bodies[0] if bodies else OCCGeometryObject(None)
result = self._get_shape(bodies[0])
for body in bodies[1:]:
shape = self._get_shape(body)
from OCP.BRepAlgoAPI import BRepAlgoAPI_Fuse
fuse = BRepAlgoAPI_Fuse(result, shape)
fuse.Build()
result = fuse.Shape()
return OCCGeometryObject(cq.Shape(result), {"type": "union"})
def boolean_difference(self, base: GeometryObject, tool: GeometryObject) -> GeometryObject:
"""Subtract tool from base."""
import cadquery as cq
base_shape = self._get_shape(base)
tool_shape = self._get_shape(tool)
from OCP.BRepAlgoAPI import BRepAlgoAPI_Cut
cut = BRepAlgoAPI_Cut(base_shape, tool_shape)
cut.Build()
return OCCGeometryObject(cq.Shape(cut.Shape()), {"type": "difference"})
def boolean_intersection(self, body1: GeometryObject, body2: GeometryObject) -> GeometryObject:
"""Intersect two bodies."""
import cadquery as cq
shape1 = self._get_shape(body1)
shape2 = self._get_shape(body2)
from OCP.BRepAlgoAPI import BRepAlgoAPI_Common
common = BRepAlgoAPI_Common(shape1, shape2)
common.Build()
return OCCGeometryObject(cq.Shape(common.Shape()), {"type": "intersection"})
def fillet(
self, body: GeometryObject, radius: float, edges: Optional[List[Any]] = None
) -> GeometryObject:
"""Apply fillet to edges."""
import cadquery as cq
cq_obj = self._get_cq_obj(body)
if isinstance(cq_obj, cq.Workplane):
if edges:
result = cq_obj.edges(edges).fillet(radius)
else:
result = cq_obj.edges().fillet(radius)
else:
shape = self._get_shape(body)
from OCP.BRepFilletAPI import BRepFilletAPI_MakeFillet
fillet = BRepFilletAPI_MakeFillet(shape)
from OCP.TopExp import TopExp_Explorer
from OCP.TopAbs import TopAbs_EDGE
explorer = TopExp_Explorer(shape, TopAbs_EDGE)
while explorer.More():
fillet.Add(radius, explorer.Current())
explorer.Next()
result = cq.Shape(fillet.Shape())
return OCCGeometryObject(result, {"type": "fillet"})
def chamfer(
self, body: GeometryObject, size: float, edges: Optional[List[Any]] = None
) -> GeometryObject:
"""Apply chamfer to edges."""
import cadquery as cq
cq_obj = self._get_cq_obj(body)
if isinstance(cq_obj, cq.Workplane):
if edges:
result = cq_obj.edges(edges).chamfer(size)
else:
result = cq_obj.edges().chamfer(size)
else:
shape = self._get_shape(body)
from OCP.BRepFilletAPI import BRepFilletAPI_MakeChamfer
chamfer = BRepFilletAPI_MakeChamfer(shape)
from OCP.TopExp import TopExp_Explorer
from OCP.TopAbs import TopAbs_EDGE
explorer = TopExp_Explorer(shape, TopAbs_EDGE)
while explorer.More():
chamfer.Add(size, explorer.Current())
explorer.Next()
result = cq.Shape(chamfer.Shape())
return OCCGeometryObject(result, {"type": "chamfer"})
def shell(
self, body: GeometryObject, thickness: float, faces_to_remove: Optional[List[Any]] = None
) -> GeometryObject:
"""Create a shell (hollow body)."""
import cadquery as cq
cq_obj = self._get_cq_obj(body)
if isinstance(cq_obj, cq.Workplane):
if faces_to_remove:
result = cq_obj.faces(faces_to_remove).shell(thickness)
else:
result = cq_obj.shell(thickness)
else:
shape = self._get_shape(body)
from OCP.BRepOffsetAPI import BRepOffsetAPI_MakeThickSolid
from OCP.TopTools import TopTools_ListOfShape
faces_list = TopTools_ListOfShape()
if faces_to_remove:
for face in faces_to_remove:
faces_list.Append(face)
shell_maker = BRepOffsetAPI_MakeThickSolid()
shell_maker.MakeThickSolidByJoin(shape, faces_list, thickness, 0.001)
shell_maker.Build()
result = cq.Shape(shell_maker.Shape())
return OCCGeometryObject(result, {"type": "shell"})
def offset(self, face: GeometryObject, distance: float) -> GeometryObject:
"""Offset a face or surface."""
import cadquery as cq
shape = self._get_shape(face)
from OCP.BRepOffsetAPI import BRepOffsetAPI_MakeOffset
offset_maker = BRepOffsetAPI_MakeOffset(shape, False)
offset_maker.Perform(distance)
return OCCGeometryObject(cq.Shape(offset_maker.Shape()), {"type": "offset"})
def translate(self, body: GeometryObject, vector: Tuple[float, float, float]) -> GeometryObject:
"""Translate a body."""
import cadquery as cq
cq_obj = self._get_cq_obj(body)
if isinstance(cq_obj, cq.Workplane):
result = cq_obj.translate(vector)
else:
shape = self._get_shape(body)
from OCP.BRepBuilderAPI import BRepBuilderAPI_Transform
from OCP.gp import gp_Trsf, gp_Vec
transform = gp_Trsf()
transform.SetTranslation(gp_Vec(*vector))
transformer = BRepBuilderAPI_Transform(shape, transform)
result = cq.Shape(transformer.Shape())
return OCCGeometryObject(result, {"type": "translated"})
def rotate(
self,
body: GeometryObject,
axis: Tuple[float, float, float],
angle: float,
origin: Tuple[float, float, float] = (0, 0, 0),
) -> GeometryObject:
"""Rotate a body around an axis."""
import cadquery as cq
import math
cq_obj = self._get_cq_obj(body)
if isinstance(cq_obj, cq.Workplane):
result = cq_obj.rotate(origin, axis, math.degrees(angle))
else:
shape = self._get_shape(body)
from OCP.BRepBuilderAPI import BRepBuilderAPI_Transform
from OCP.gp import gp_Trsf, gp_Ax1, gp_Pnt, gp_Dir, gp_Vec
ax1 = gp_Ax1(gp_Pnt(*origin), gp_Dir(*axis))
transform = gp_Trsf()
transform.SetRotation(ax1, angle)
transformer = BRepBuilderAPI_Transform(shape, transform)
result = cq.Shape(transformer.Shape())
return OCCGeometryObject(result, {"type": "rotated"})
def scale(self, body: GeometryObject, factor: float) -> GeometryObject:
"""Scale a body uniformly."""
import cadquery as cq
shape = self._get_shape(body)
from OCP.BRepBuilderAPI import BRepBuilderAPI_Transform
from OCP.gp import gp_Trsf
transform = gp_Trsf()
transform.SetScale(factor)
transformer = BRepBuilderAPI_Transform(shape, transform)
return OCCGeometryObject(cq.Shape(transformer.Shape()), {"type": "scaled"})
def mirror(
self,
body: GeometryObject,
plane_normal: Tuple[float, float, float],
plane_origin: Tuple[float, float, float] = (0, 0, 0),
) -> GeometryObject:
"""Mirror a body across a plane."""
import cadquery as cq
shape = self._get_shape(body)
from OCP.BRepBuilderAPI import BRepBuilderAPI_Transform
from OCP.gp import gp_Trsf, gp_Ax2, gp_Pnt, gp_Dir
ax2 = gp_Ax2(gp_Pnt(*plane_origin), gp_Dir(*plane_normal))
transform = gp_Trsf()
transform.SetMirror(ax2)
transformer = BRepBuilderAPI_Transform(shape, transform)
return OCCGeometryObject(cq.Shape(transformer.Shape()), {"type": "mirrored"})
def export_step(self, body: GeometryObject, filepath: str, schema: str = "AP214") -> bool:
"""Export to STEP format."""
try:
import cadquery as cq
shape = self._get_shape(body)
if hasattr(shape, "exportStep"):
shape.exportStep(filepath)
return True
from OCP.STEPControl import STEPControl_Writer, STEPControl_AsIs
from OCP.Interface import Interface_Static
writer = STEPControl_Writer()
if schema == "AP214":
Interface_Static.SetCVal_s("write.step.schema", "AP214")
elif schema == "AP203":
Interface_Static.SetCVal_s("write.step.schema", "AP203")
writer.Transfer(shape, STEPControl_AsIs)
writer.Write(filepath)
return True
except Exception as e:
print(f"STEP export error: {e}")
return False
def export_iges(self, body: GeometryObject, filepath: str) -> bool:
"""Export to IGES format."""
try:
import cadquery as cq
shape = self._get_shape(body)
from OCP.IGESControl import IGESControl_Writer
from OCP.Interface import Interface_Static
Interface_Static.SetCVal_s("write.iges.schema", "5.3")
writer = IGESControl_Writer()
writer.AddShape(shape)
writer.Write(filepath)
return True
except Exception as e:
print(f"IGES export error: {e}")
return False
def export_stl(
self, body: GeometryObject, filepath: str, tolerance: float = 0.1, ascii_mode: bool = False
) -> bool:
"""Export to STL format."""
try:
import cadquery as cq
shape = self._get_shape(body)
if hasattr(shape, "exportStl"):
shape.exportStl(filepath, tolerance)
return True
from OCP.StlAPI import StlAPI_Writer
from OCP.BRepMesh import BRepMesh_IncrementalMesh
mesh = BRepMesh_IncrementalMesh(shape, tolerance)
mesh.Perform()
writer = StlAPI_Writer()
writer.ASCIIMode = ascii_mode
writer.Write(shape, filepath)
return True
except Exception as e:
print(f"STL export error: {e}")
return False
def import_step(self, filepath: str) -> GeometryObject:
"""Import from STEP format."""
import cadquery as cq
result = cq.importers.importStep(filepath)
return OCCGeometryObject(result, {"type": "imported_step"})
def import_iges(self, filepath: str) -> GeometryObject:
"""Import from IGES format."""
import cadquery as cq
from OCP.IGESControl import IGESControl_Reader
from OCP.IFSelect import IFSelect_RetDone
reader = IGESControl_Reader()
status = reader.ReadFile(filepath)
if status != IFSelect_RetDone:
raise ValueError(f"Failed to read IGES file: {filepath}")
reader.TransferRoots()
shape = reader.OneShape()
return OCCGeometryObject(cq.Shape(shape), {"type": "imported_iges"})
def get_mesh(
self, body: GeometryObject, tolerance: float = 0.1
) -> Tuple[np.ndarray, np.ndarray]:
"""Get triangulated mesh for rendering."""
import cadquery as cq
shape = self._get_shape(body)
if hasattr(shape, "tessellate"):
vertices, faces = shape.tessellate(tolerance)
return np.array(vertices), np.array(faces)
from OCP.BRepMesh import BRepMesh_IncrementalMesh
from OCP.TopExp import TopExp_Explorer
from OCP.TopAbs import TopAbs_FACE
from OCP.BRep import BRep_Tool
from OCP.Poly import Poly_Triangulation
from OCP.TopLoc import TopLoc_Location
mesh = BRepMesh_IncrementalMesh(shape, tolerance)
mesh.Perform()
vertices_list: List[List[float]] = []
faces_list: List[List[int]] = []
vertex_offset = 0
explorer = TopExp_Explorer(shape, TopAbs_FACE)
while explorer.More():
face = explorer.Current()
location = TopLoc_Location()
triangulation = BRep_Tool.Triangulation_s(face, location)
if triangulation is not None:
n_vertices = triangulation.NbNodes()
for i in range(1, n_vertices + 1):
p = triangulation.Node(i)
vertices_list.append([p.X(), p.Y(), p.Z()])
n_triangles = triangulation.NbTriangles()
for i in range(1, n_triangles + 1):
tri = triangulation.Triangle(i)
faces_list.append(
[
tri.Value(1) - 1 + vertex_offset,
tri.Value(2) - 1 + vertex_offset,
tri.Value(3) - 1 + vertex_offset,
]
)
vertex_offset += n_vertices
explorer.Next()
return np.array(vertices_list, dtype=np.float32), np.array(faces_list, dtype=np.int32)
def get_edges(self, body: GeometryObject) -> Tuple[np.ndarray, np.ndarray]:
"""Get edge wireframe for rendering."""
import cadquery as cq
shape = self._get_shape(body)
from OCP.TopExp import TopExp_Explorer
from OCP.TopAbs import TopAbs_EDGE
from OCP.BRep import BRep_Tool
from OCP.TopLoc import TopLoc_Location
from OCP.BRepAdaptor import BRepAdaptor_Curve
from OCP.GeomAbs import GeomAbs_Line, GeomAbs_Circle, GeomAbs_Ellipse, GeomAbs_BSplineCurve
vertices_list: List[List[float]] = []
edges_list: List[List[int]] = []
vertex_offset = 0
def discretize_edge(edge: Any, num_points: int = 20) -> List[List[float]]:
curve = BRepAdaptor_Curve(edge)
curve_type = curve.GetType()
points = []
if curve_type == GeomAbs_Line:
first = curve.FirstParameter()
last = curve.LastParameter()
p1 = curve.Value(first)
p2 = curve.Value(last)
points = [[p1.X(), p1.Y(), p1.Z()], [p2.X(), p2.Y(), p2.Z()]]
else:
first = curve.FirstParameter()
last = curve.LastParameter()
for i in range(num_points + 1):
t = first + (last - first) * i / num_points
p = curve.Value(t)
points.append([p.X(), p.Y(), p.Z()])
return points
explorer = TopExp_Explorer(shape, TopAbs_EDGE)
while explorer.More():
edge = explorer.Current()
edge_points = discretize_edge(edge)
for i, pt in enumerate(edge_points):
vertices_list.append(pt)
if i < len(edge_points) - 1:
edges_list.append([vertex_offset + i, vertex_offset + i + 1])
vertex_offset += len(edge_points)
explorer.Next()
return np.array(vertices_list, dtype=np.float32), np.array(edges_list, dtype=np.int32)
def get_bounding_box(self, body: GeometryObject) -> Tuple[Point3D, Point3D]:
"""Get the bounding box of a body."""
import cadquery as cq
shape = self._get_shape(body)
from OCP.Bnd import Bnd_Box
from OCP.BRepBndLib import BRepBndLib_AddClose
bbox = Bnd_Box()
BRepBndLib_AddClose(shape, bbox)
xmin, ymin, zmin, xmax, ymax, zmax = bbox.Get()
return Point3D(xmin, ymin, zmin), Point3D(xmax, ymax, zmax)
def get_volume(self, body: GeometryObject) -> float:
"""Calculate the volume of a solid body."""
import cadquery as cq
shape = self._get_shape(body)
from OCP.GProp import GProp_GProps
from OCP.BRepGProp import BRepGProp_VolumeProperties
props = GProp_GProps()
BRepGProp_VolumeProperties(shape, props)
return props.Mass()
def get_surface_area(self, body: GeometryObject) -> float:
"""Calculate the surface area of a body."""
import cadquery as cq
shape = self._get_shape(body)
from OCP.GProp import GProp_GProps
from OCP.BRepGProp import BRepGProp_SurfaceProperties
props = GProp_GProps()
BRepGProp_SurfaceProperties(shape, props)
return props.Mass()
def get_center_of_mass(self, body: GeometryObject) -> Point3D:
"""Calculate the center of mass of a solid body."""
import cadquery as cq
shape = self._get_shape(body)
from OCP.GProp import GProp_GProps
from OCP.BRepGProp import BRepGProp_VolumeProperties
props = GProp_GProps()
BRepGProp_VolumeProperties(shape, props)
cg = props.CentreOfMass()
return Point3D(cg.X(), cg.Y(), cg.Z())
+383
View File
@@ -0,0 +1,383 @@
"""
OpenCASCADE-based sketch with constraint solving for Fluency CAD.
This module provides 2D sketching with parametric constraints using
CadQuery's built-in constraint solver.
"""
from typing import List, Tuple, Optional, Dict, Any
from dataclasses import dataclass, field
import numpy as np
from fluency.geometry.base import (
SketchInterface,
SketchEntity,
GeometryObject,
Point2D,
)
from fluency.geometry_occ.kernel import OCCGeometryObject
@dataclass
class OCCSketchEntity(SketchEntity):
"""Sketch entity for OpenCASCADE-based sketch."""
geometry: Any = None
handle: Any = None
def __post_init__(self) -> None:
if self.constraints is None:
self.constraints = []
class OCCSketch(SketchInterface):
"""
CadQuery-based sketch with constraint solving.
This sketch uses CadQuery's Sketch class which provides
built-in constraint solving capabilities.
"""
def __init__(self) -> None:
import cadquery as cq
self._sketch = cq.Sketch()
self._entities: Dict[int, OCCSketchEntity] = {}
self._entity_counter: int = 0
self._points: Dict[int, Tuple[float, float]] = {}
self._lines: Dict[int, Tuple[int, int]] = {}
self._circles: Dict[int, Tuple[int, float]] = {}
self._arcs: Dict[int, Any] = {}
self._constraint_count: int = 0
def _next_id(self) -> int:
self._entity_counter += 1
return self._entity_counter
def add_point(self, x: float, y: float) -> OCCSketchEntity:
"""Add a point to the sketch."""
entity_id = self._next_id()
self._sketch = self._sketch.point(x, y)
entity = OCCSketchEntity(entity_id=entity_id, entity_type="point", geometry=(x, y))
self._entities[entity_id] = entity
self._points[entity_id] = (x, y)
return entity
def add_line(self, start: SketchEntity, end: SketchEntity) -> OCCSketchEntity:
"""Add a line between two points."""
entity_id = self._next_id()
start_geom = self._entities.get(start.id)
end_geom = self._entities.get(end.id)
if start_geom is None or end_geom is None:
raise ValueError("Start or end point not found in sketch")
x1, y1 = start_geom.geometry
x2, y2 = end_geom.geometry
self._sketch = self._sketch.line((x1, y1), (x2, y2))
entity = OCCSketchEntity(
entity_id=entity_id, entity_type="line", geometry=((x1, y1), (x2, y2))
)
self._entities[entity_id] = entity
self._lines[entity_id] = (start.id, end.id)
return entity
def add_circle(self, center: SketchEntity, radius: float) -> OCCSketchEntity:
"""Add a circle."""
entity_id = self._next_id()
center_entity = self._entities.get(center.id)
if center_entity is None:
raise ValueError("Center point not found in sketch")
cx, cy = center_entity.geometry
self._sketch = self._sketch.circle((cx, cy), radius)
entity = OCCSketchEntity(
entity_id=entity_id, entity_type="circle", geometry=((cx, cy), radius)
)
self._entities[entity_id] = entity
self._circles[entity_id] = (center.id, radius)
return entity
def add_arc(
self,
center: SketchEntity,
radius: float,
start_point: SketchEntity,
end_point: SketchEntity,
) -> OCCSketchEntity:
"""Add an arc."""
entity_id = self._next_id()
center_entity = self._entities.get(center.id)
start_entity = self._entities.get(start_point.id)
end_entity = self._entities.get(end_point.id)
if center_entity is None or start_entity is None or end_entity is None:
raise ValueError("Arc points not found in sketch")
cx, cy = center_entity.geometry
sx, sy = start_entity.geometry
ex, ey = end_entity.geometry
self._sketch = self._sketch.arc((sx, sy), (ex, ey), (cx, cy))
entity = OCCSketchEntity(
entity_id=entity_id,
entity_type="arc",
geometry={"center": (cx, cy), "radius": radius, "start": (sx, sy), "end": (ex, ey)},
)
self._entities[entity_id] = entity
self._arcs[entity_id] = {
"center": center.id,
"start": start_point.id,
"end": end_point.id,
"radius": radius,
}
return entity
def add_rectangle(
self, corner1: Tuple[float, float], corner2: Tuple[float, float]
) -> List[OCCSketchEntity]:
"""Add a rectangle, returning the created entities."""
x1, y1 = corner1
x2, y2 = corner2
entities: List[OCCSketchEntity] = []
p1 = self.add_point(x1, y1)
p2 = self.add_point(x2, y1)
p3 = self.add_point(x2, y2)
p4 = self.add_point(x1, y2)
entities.extend([p1, p2, p3, p4])
l1 = self.add_line(p1, p2)
l2 = self.add_line(p2, p3)
l3 = self.add_line(p3, p4)
l4 = self.add_line(p4, p1)
entities.extend([l1, l2, l3, l4])
return entities
def constrain_coincident(self, *entities: SketchEntity) -> bool:
"""Make entities coincident."""
if len(entities) < 2:
return False
ids = [e.id for e in entities]
self._sketch = self._sketch.constrain(ids[0], ids[1], "Coincident")
self._constraint_count += 1
return True
def constrain_horizontal(self, line: SketchEntity) -> bool:
"""Constrain a line to be horizontal."""
self._sketch = self._sketch.constrain(line.id, "Horizontal")
self._constraint_count += 1
return True
def constrain_vertical(self, line: SketchEntity) -> bool:
"""Constrain a line to be vertical."""
self._sketch = self._sketch.constrain(line.id, "Vertical")
self._constraint_count += 1
return True
def constrain_distance(
self, entity1: SketchEntity, entity2: SketchEntity, distance: float
) -> bool:
"""Constrain distance between two entities."""
self._sketch = self._sketch.constrain(entity1.id, entity2.id, "Distance", distance)
self._constraint_count += 1
return True
def constrain_angle(self, line1: SketchEntity, line2: SketchEntity, angle: float) -> bool:
"""Constrain angle between two lines."""
self._sketch = self._sketch.constrain(line1.id, line2.id, "Angle", angle)
self._constraint_count += 1
return True
def constrain_parallel(self, line1: SketchEntity, line2: SketchEntity) -> bool:
"""Constrain two lines to be parallel."""
self._sketch = self._sketch.constrain(line1.id, line2.id, "Parallel")
self._constraint_count += 1
return True
def constrain_perpendicular(self, line1: SketchEntity, line2: SketchEntity) -> bool:
"""Constrain two lines to be perpendicular."""
self._sketch = self._sketch.constrain(line1.id, line2.id, "Perpendicular")
self._constraint_count += 1
return True
def constrain_midpoint(self, point: SketchEntity, line: SketchEntity) -> bool:
"""Constrain a point to be at the midpoint of a line."""
self._sketch = self._sketch.constrain(point.id, line.id, "Midpoint")
self._constraint_count += 1
return True
def constrain_tangent(self, entity1: SketchEntity, entity2: SketchEntity) -> bool:
"""Constrain two entities to be tangent."""
self._sketch = self._sketch.constrain(entity1.id, entity2.id, "Tangent")
self._constraint_count += 1
return True
def constrain_equal_length(self, line1: SketchEntity, line2: SketchEntity) -> bool:
"""Constrain two lines to have equal length."""
self._sketch = self._sketch.constrain(line1.id, line2.id, "EqualLength")
self._constraint_count += 1
return True
def constrain_equal_radius(self, circle1: SketchEntity, circle2: SketchEntity) -> bool:
"""Constrain two circles to have equal radius."""
self._sketch = self._sketch.constrain(circle1.id, circle2.id, "EqualRadius")
self._constraint_count += 1
return True
def constrain_fixed(self, entity: SketchEntity) -> bool:
"""Fix an entity in place."""
self._sketch = self._sketch.constrain(entity.id, "Fixed")
self._constraint_count += 1
return True
def solve(self) -> bool:
"""Solve all constraints."""
try:
self._sketch = self._sketch.solve()
self._update_entity_geometry()
return True
except Exception as e:
print(f"Solver error: {e}")
return False
def _update_entity_geometry(self) -> None:
"""Update entity geometry after solving."""
pass
def get_geometry(self) -> GeometryObject:
"""Get the solved geometry for operations."""
return OCCGeometryObject(self._sketch.val())
def get_points(self) -> List[Point2D]:
"""Get all point positions."""
points: List[Point2D] = []
for entity_id, entity in self._entities.items():
if entity.entity_type == "point":
x, y = entity.geometry
points.append(Point2D(x, y))
return points
def get_polygon_points(self) -> List[Point2D]:
"""Get ordered polygon points from connected lines."""
adjacency: Dict[Tuple[float, float], List[Tuple[float, float]]] = {}
for entity in self._entities.values():
if entity.entity_type == "line":
p1, p2 = entity.geometry
if p1 not in adjacency:
adjacency[p1] = []
if p2 not in adjacency:
adjacency[p2] = []
adjacency[p1].append(p2)
adjacency[p2].append(p1)
if not adjacency:
return []
points: List[Point2D] = []
visited: set = set()
current = next(iter(adjacency.keys()))
while current and current not in visited:
points.append(Point2D(current[0], current[1]))
visited.add(current)
neighbors = adjacency.get(current, [])
next_point = None
for n in neighbors:
if n not in visited:
next_point = n
break
current = next_point
if len(points) > 2:
points.append(points[0])
return points
def clear(self) -> None:
"""Clear all geometry and constraints."""
import cadquery as cq
self._sketch = cq.Sketch()
self._entities.clear()
self._points.clear()
self._lines.clear()
self._circles.clear()
self._arcs.clear()
self._entity_counter = 0
self._constraint_count = 0
def delete_entity(self, entity: SketchEntity) -> bool:
"""Delete an entity and its constraints."""
if entity.id not in self._entities:
return False
del self._entities[entity.id]
if entity.id in self._points:
del self._points[entity.id]
if entity.id in self._lines:
del self._lines[entity.id]
if entity.id in self._circles:
del self._circles[entity.id]
if entity.id in self._arcs:
del self._arcs[entity.id]
return True
def get_sketch_object(self) -> Any:
"""Get the underlying CadQuery sketch object."""
return self._sketch
def get_entity_count(self) -> int:
"""Get the number of entities in the sketch."""
return len(self._entities)
def get_constraint_count(self) -> int:
"""Get the number of constraints in the sketch."""
return self._constraint_count
def is_fully_constrained(self) -> bool:
"""Check if the sketch is fully constrained."""
return self._sketch.is_fully_constrained()