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:
@@ -0,0 +1,339 @@
|
||||
"""
|
||||
Data models for Fluency CAD.
|
||||
|
||||
This module defines the core data structures for the CAD application
|
||||
including projects, components, sketches, and bodies.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Dict, List, Optional, Any
|
||||
from datetime import datetime
|
||||
import uuid
|
||||
import numpy as np
|
||||
|
||||
from fluency.geometry.base import (
|
||||
Point2D,
|
||||
Point3D,
|
||||
GeometryObject,
|
||||
SketchInterface,
|
||||
)
|
||||
from fluency.geometry_occ.kernel import OCGeometryKernel, OCCGeometryObject
|
||||
from fluency.geometry_occ.sketch import OCCSketch
|
||||
|
||||
|
||||
@dataclass
|
||||
class Sketch:
|
||||
"""
|
||||
2D sketch with constraints.
|
||||
|
||||
A sketch contains 2D geometry on a workplane that can be
|
||||
extruded or revolved to create 3D bodies.
|
||||
"""
|
||||
|
||||
id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
||||
name: str = "Untitled Sketch"
|
||||
|
||||
workplane_origin: np.ndarray = field(default_factory=lambda: np.array([0.0, 0.0, 0.0]))
|
||||
workplane_normal: np.ndarray = field(default_factory=lambda: np.array([0.0, 0.0, 1.0]))
|
||||
workplane_x_dir: np.ndarray = field(default_factory=lambda: np.array([1.0, 0.0, 0.0]))
|
||||
|
||||
occ_sketch: Optional[OCCSketch] = field(default_factory=OCCSketch)
|
||||
geometry: Optional[OCCGeometryObject] = None
|
||||
|
||||
is_solved: bool = False
|
||||
is_fully_constrained: bool = False
|
||||
|
||||
created_at: datetime = field(default_factory=datetime.now)
|
||||
modified_at: datetime = field(default_factory=datetime.now)
|
||||
|
||||
def add_point(self, x: float, y: float) -> Any:
|
||||
"""Add a point to the sketch."""
|
||||
self.modified_at = datetime.now()
|
||||
self.is_solved = False
|
||||
if self.occ_sketch:
|
||||
return self.occ_sketch.add_point(x, y)
|
||||
return None
|
||||
|
||||
def add_line(self, start: Any, end: Any) -> Any:
|
||||
"""Add a line to the sketch."""
|
||||
self.modified_at = datetime.now()
|
||||
self.is_solved = False
|
||||
if self.occ_sketch:
|
||||
return self.occ_sketch.add_line(start, end)
|
||||
return None
|
||||
|
||||
def add_circle(self, center: Any, radius: float) -> Any:
|
||||
"""Add a circle to the sketch."""
|
||||
self.modified_at = datetime.now()
|
||||
self.is_solved = False
|
||||
if self.occ_sketch:
|
||||
return self.occ_sketch.add_circle(center, radius)
|
||||
return None
|
||||
|
||||
def add_rectangle(self, corner1: tuple, corner2: tuple) -> List[Any]:
|
||||
"""Add a rectangle to the sketch."""
|
||||
self.modified_at = datetime.now()
|
||||
self.is_solved = False
|
||||
if self.occ_sketch:
|
||||
return self.occ_sketch.add_rectangle(corner1, corner2)
|
||||
return []
|
||||
|
||||
def solve(self) -> bool:
|
||||
"""Solve all constraints."""
|
||||
if self.occ_sketch:
|
||||
result = self.occ_sketch.solve()
|
||||
self.is_solved = result
|
||||
self.is_fully_constrained = self.occ_sketch.is_fully_constrained()
|
||||
self.modified_at = datetime.now()
|
||||
return result
|
||||
return False
|
||||
|
||||
def get_geometry(self) -> Optional[GeometryObject]:
|
||||
"""Get the solved geometry."""
|
||||
if self.occ_sketch:
|
||||
return self.occ_sketch.get_geometry()
|
||||
return None
|
||||
|
||||
def get_polygon_points(self) -> List[Point2D]:
|
||||
"""Get ordered polygon points."""
|
||||
if self.occ_sketch:
|
||||
return self.occ_sketch.get_polygon_points()
|
||||
return []
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Clear all geometry."""
|
||||
if self.occ_sketch:
|
||||
self.occ_sketch.clear()
|
||||
self.geometry = None
|
||||
self.is_solved = False
|
||||
self.is_fully_constrained = False
|
||||
self.modified_at = datetime.now()
|
||||
|
||||
|
||||
@dataclass
|
||||
class Body:
|
||||
"""
|
||||
3D solid body.
|
||||
|
||||
A body is created from a sketch through operations like
|
||||
extrude, revolve, loft, or sweep.
|
||||
"""
|
||||
|
||||
id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
||||
name: str = "Untitled Body"
|
||||
|
||||
geometry: Optional[OCCGeometryObject] = None
|
||||
source_sketch: Optional[Sketch] = None
|
||||
source_operation: str = "extrude"
|
||||
|
||||
position: np.ndarray = field(default_factory=lambda: np.array([0.0, 0.0, 0.0]))
|
||||
rotation: np.ndarray = field(default_factory=lambda: np.eye(3))
|
||||
|
||||
color: tuple = (0.2, 0.4, 0.8)
|
||||
opacity: float = 1.0
|
||||
visible: bool = True
|
||||
|
||||
render_object: Any = None
|
||||
|
||||
created_at: datetime = field(default_factory=datetime.now)
|
||||
modified_at: datetime = field(default_factory=datetime.now)
|
||||
|
||||
def get_mesh(self, kernel: OCGeometryKernel, tolerance: float = 0.1) -> tuple:
|
||||
"""Get mesh for rendering."""
|
||||
if self.geometry and kernel:
|
||||
return kernel.get_mesh(self.geometry, tolerance)
|
||||
return np.array([]), np.array([])
|
||||
|
||||
def get_edges(self, kernel: OCGeometryKernel) -> tuple:
|
||||
"""Get edges for wireframe rendering."""
|
||||
if self.geometry and kernel:
|
||||
return kernel.get_edges(self.geometry)
|
||||
return np.array([]), np.array([])
|
||||
|
||||
|
||||
@dataclass
|
||||
class Component:
|
||||
"""
|
||||
Component containing sketches and bodies.
|
||||
|
||||
A component is a logical grouping of geometry, similar to
|
||||
a part in a CAD system.
|
||||
"""
|
||||
|
||||
id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
||||
name: str = "Untitled Component"
|
||||
description: str = ""
|
||||
|
||||
sketches: Dict[str, Sketch] = field(default_factory=dict)
|
||||
bodies: Dict[str, Body] = field(default_factory=dict)
|
||||
|
||||
active_sketch: Optional[str] = None
|
||||
|
||||
created_at: datetime = field(default_factory=datetime.now)
|
||||
modified_at: datetime = field(default_factory=datetime.now)
|
||||
|
||||
def add_sketch(self, sketch: Optional[Sketch] = None) -> Sketch:
|
||||
"""Add a sketch to the component."""
|
||||
if sketch is None:
|
||||
sketch = Sketch(name=f"Sketch {len(self.sketches) + 1}")
|
||||
self.sketches[sketch.id] = sketch
|
||||
self.modified_at = datetime.now()
|
||||
return sketch
|
||||
|
||||
def add_body(self, body: Optional[Body] = None) -> Body:
|
||||
"""Add a body to the component."""
|
||||
if body is None:
|
||||
body = Body(name=f"Body {len(self.bodies) + 1}")
|
||||
self.bodies[body.id] = body
|
||||
self.modified_at = datetime.now()
|
||||
return body
|
||||
|
||||
def remove_sketch(self, sketch_id: str) -> bool:
|
||||
"""Remove a sketch from the component."""
|
||||
if sketch_id in self.sketches:
|
||||
del self.sketches[sketch_id]
|
||||
if self.active_sketch == sketch_id:
|
||||
self.active_sketch = None
|
||||
self.modified_at = datetime.now()
|
||||
return True
|
||||
return False
|
||||
|
||||
def remove_body(self, body_id: str) -> bool:
|
||||
"""Remove a body from the component."""
|
||||
if body_id in self.bodies:
|
||||
del self.bodies[body_id]
|
||||
self.modified_at = datetime.now()
|
||||
return True
|
||||
return False
|
||||
|
||||
def get_active_sketch(self) -> Optional[Sketch]:
|
||||
"""Get the currently active sketch."""
|
||||
if self.active_sketch and self.active_sketch in self.sketches:
|
||||
return self.sketches[self.active_sketch]
|
||||
return None
|
||||
|
||||
def set_active_sketch(self, sketch_id: Optional[str]) -> None:
|
||||
"""Set the active sketch."""
|
||||
self.active_sketch = sketch_id
|
||||
self.modified_at = datetime.now()
|
||||
|
||||
|
||||
@dataclass
|
||||
class Project:
|
||||
"""
|
||||
Top-level project container.
|
||||
|
||||
A project contains components and provides access to the
|
||||
geometry kernel for operations.
|
||||
"""
|
||||
|
||||
name: str = "Untitled Project"
|
||||
description: str = ""
|
||||
|
||||
components: Dict[str, Component] = field(default_factory=dict)
|
||||
active_component: Optional[str] = None
|
||||
|
||||
kernel: OCGeometryKernel = field(default_factory=OCGeometryKernel)
|
||||
|
||||
created_at: datetime = field(default_factory=datetime.now)
|
||||
modified_at: datetime = field(default_factory=datetime.now)
|
||||
file_path: Optional[str] = None
|
||||
|
||||
def add_component(self, component: Optional[Component] = None) -> Component:
|
||||
"""Add a component to the project."""
|
||||
if component is None:
|
||||
component = Component(name=f"Component {len(self.components) + 1}")
|
||||
self.components[component.id] = component
|
||||
if self.active_component is None:
|
||||
self.active_component = component.id
|
||||
self.modified_at = datetime.now()
|
||||
return component
|
||||
|
||||
def remove_component(self, component_id: str) -> bool:
|
||||
"""Remove a component from the project."""
|
||||
if component_id in self.components:
|
||||
del self.components[component_id]
|
||||
if self.active_component == component_id:
|
||||
self.active_component = next(iter(self.components.keys()), None)
|
||||
self.modified_at = datetime.now()
|
||||
return True
|
||||
return False
|
||||
|
||||
def get_active_component(self) -> Optional[Component]:
|
||||
"""Get the currently active component."""
|
||||
if self.active_component and self.active_component in self.components:
|
||||
return self.components[self.active_component]
|
||||
return None
|
||||
|
||||
def set_active_component(self, component_id: Optional[str]) -> None:
|
||||
"""Set the active component."""
|
||||
self.active_component = component_id
|
||||
self.modified_at = datetime.now()
|
||||
|
||||
def export_step(self, filepath: str) -> bool:
|
||||
"""Export all visible bodies to STEP."""
|
||||
all_bodies: List[OCCGeometryObject] = []
|
||||
|
||||
for comp in self.components.values():
|
||||
for body in comp.bodies.values():
|
||||
if body.visible and body.geometry:
|
||||
all_bodies.append(body.geometry)
|
||||
|
||||
if not all_bodies:
|
||||
return False
|
||||
|
||||
if len(all_bodies) == 1:
|
||||
return self.kernel.export_step(all_bodies[0], filepath)
|
||||
|
||||
result = self.kernel.boolean_union(*all_bodies)
|
||||
return self.kernel.export_step(result, filepath)
|
||||
|
||||
def export_iges(self, filepath: str) -> bool:
|
||||
"""Export all visible bodies to IGES."""
|
||||
all_bodies: List[OCCGeometryObject] = []
|
||||
|
||||
for comp in self.components.values():
|
||||
for body in comp.bodies.values():
|
||||
if body.visible and body.geometry:
|
||||
all_bodies.append(body.geometry)
|
||||
|
||||
if not all_bodies:
|
||||
return False
|
||||
|
||||
if len(all_bodies) == 1:
|
||||
return self.kernel.export_iges(all_bodies[0], filepath)
|
||||
|
||||
result = self.kernel.boolean_union(*all_bodies)
|
||||
return self.kernel.export_iges(result, filepath)
|
||||
|
||||
def export_stl(self, filepath: str, tolerance: float = 0.1) -> bool:
|
||||
"""Export all visible bodies to STL."""
|
||||
all_bodies: List[OCCGeometryObject] = []
|
||||
|
||||
for comp in self.components.values():
|
||||
for body in comp.bodies.values():
|
||||
if body.visible and body.geometry:
|
||||
all_bodies.append(body.geometry)
|
||||
|
||||
if not all_bodies:
|
||||
return False
|
||||
|
||||
if len(all_bodies) == 1:
|
||||
return self.kernel.export_stl(all_bodies[0], filepath, tolerance)
|
||||
|
||||
result = self.kernel.boolean_union(*all_bodies)
|
||||
return self.kernel.export_stl(result, filepath, tolerance)
|
||||
|
||||
def get_all_bodies(self) -> List[Body]:
|
||||
"""Get all bodies from all components."""
|
||||
bodies: List[Body] = []
|
||||
for comp in self.components.values():
|
||||
bodies.extend(comp.bodies.values())
|
||||
return bodies
|
||||
|
||||
def get_all_sketches(self) -> List[Sketch]:
|
||||
"""Get all sketches from all components."""
|
||||
sketches: List[Sketch] = []
|
||||
for comp in self.components.values():
|
||||
sketches.extend(comp.sketches.values())
|
||||
return sketches
|
||||
Reference in New Issue
Block a user