- Improved sketching

This commit is contained in:
bklronin
2026-06-14 10:10:33 +02:00
parent 7091f530ee
commit ea34e7e29d
8 changed files with 1413 additions and 521 deletions
-11
View File
@@ -1,11 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="PYTHON_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<sourceFolder url="file://$MODULE_DIR$/sdfcad" isTestSource="false" />
<excludeFolder url="file://$MODULE_DIR$/.venv" />
</content>
<orderEntry type="jdk" jdkName="Python 3.12 (fluency)" jdkType="Python SDK" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>
+1 -1
View File
@@ -2,7 +2,7 @@
<project version="4"> <project version="4">
<component name="ProjectModuleManager"> <component name="ProjectModuleManager">
<modules> <modules>
<module fileurl="file://$PROJECT_DIR$/.idea/fluency.iml" filepath="$PROJECT_DIR$/.idea/fluency.iml" /> <module fileurl="file://$PROJECT_DIR$/.idea/fluency-cad.iml" filepath="$PROJECT_DIR$/.idea/fluency-cad.iml" />
</modules> </modules>
</component> </component>
</project> </project>
+35 -62
View File
@@ -5,30 +5,14 @@
</component> </component>
<component name="ChangeListManager"> <component name="ChangeListManager">
<list default="true" id="8f0bafd6-58a0-4b20-aa2b-ddc3ba278873" name="Changes" comment="- Improved sketching"> <list default="true" id="8f0bafd6-58a0-4b20-aa2b-ddc3ba278873" name="Changes" comment="- Improved sketching">
<change afterPath="$PROJECT_DIR$/README.md" afterDir="false" /> <change beforePath="$PROJECT_DIR$/.idea/fluency.iml" beforeDir="false" />
<change afterPath="$PROJECT_DIR$/doc/BUGFIX_DRAWING_ISSUES.md" afterDir="false" /> <change beforePath="$PROJECT_DIR$/.idea/modules.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/modules.xml" afterDir="false" />
<change afterPath="$PROJECT_DIR$/doc/IMPLEMENTATION_SUMMARY.md" afterDir="false" />
<change afterPath="$PROJECT_DIR$/doc/PHASE1_COMPLETE.md" afterDir="false" />
<change afterPath="$PROJECT_DIR$/doc/PHASE2_COMPLETE.md" afterDir="false" />
<change afterPath="$PROJECT_DIR$/doc/SELECTION_DELETION.md" afterDir="false" />
<change afterPath="$PROJECT_DIR$/doc/SINGLE_WINDOW_MIGRATION_PLAN.md" afterDir="false" />
<change afterPath="$PROJECT_DIR$/doc/TEST_REPORT_PHASE1.md" afterDir="false" />
<change afterPath="$PROJECT_DIR$/doc/changes_log.md" afterDir="false" />
<change afterPath="$PROJECT_DIR$/doc/current_state.md" afterDir="false" />
<change afterPath="$PROJECT_DIR$/doc/technical_documentation.md" afterDir="false" />
<change afterPath="$PROJECT_DIR$/drawing_modules/constraints_3d.py" afterDir="false" />
<change afterPath="$PROJECT_DIR$/drawing_modules/vtk_sketch_widget.py" afterDir="false" />
<change afterPath="$PROJECT_DIR$/drawing_modules/workplane.py" afterDir="false" />
<change afterPath="$PROJECT_DIR$/minimal_main.py" afterDir="false" />
<change afterPath="$PROJECT_DIR$/ouput_test.md" afterDir="false" />
<change afterPath="$PROJECT_DIR$/state.png" afterDir="false" />
<change afterPath="$PROJECT_DIR$/test_delete_functionality.py" afterDir="false" />
<change afterPath="$PROJECT_DIR$/test_minimal_vtk.py" afterDir="false" />
<change afterPath="$PROJECT_DIR$/test_projection.py" afterDir="false" />
<change afterPath="$PROJECT_DIR$/test_selection.py" afterDir="false" />
<change afterPath="$PROJECT_DIR$/test_vtk_sketch_widget.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" /> <change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/main.py" beforeDir="false" afterPath="$PROJECT_DIR$/main.py" afterDir="false" /> <change beforePath="$PROJECT_DIR$/pyproject.toml" beforeDir="false" afterPath="$PROJECT_DIR$/pyproject.toml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/fluency/geometry_occ/kernel.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/geometry_occ/kernel.py" 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$/uv.lock" beforeDir="false" afterPath="$PROJECT_DIR$/uv.lock" afterDir="false" />
</list> </list>
<option name="SHOW_DIALOG" value="false" /> <option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" /> <option name="HIGHLIGHT_CONFLICTS" value="true" />
@@ -45,7 +29,7 @@
<component name="Git.Settings"> <component name="Git.Settings">
<option name="RECENT_BRANCH_BY_REPOSITORY"> <option name="RECENT_BRANCH_BY_REPOSITORY">
<map> <map>
<entry key="$PROJECT_DIR$" value="master" /> <entry key="$PROJECT_DIR$" value="structure" />
</map> </map>
</option> </option>
<option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" /> <option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
@@ -65,32 +49,31 @@
<option name="hideEmptyMiddlePackages" value="true" /> <option name="hideEmptyMiddlePackages" value="true" />
<option name="showLibraryContents" value="true" /> <option name="showLibraryContents" value="true" />
</component> </component>
<component name="PropertiesComponent"><![CDATA[{ <component name="PropertiesComponent">{
"keyToString": { &quot;keyToString&quot;: {
"Python.2dtest.executor": "Run", &quot;Python.2dtest.executor&quot;: &quot;Run&quot;,
"Python.3d_windows.executor": "Run", &quot;Python.3d_windows.executor&quot;: &quot;Run&quot;,
"Python.Unnamed.executor": "Run", &quot;Python.Unnamed.executor&quot;: &quot;Run&quot;,
"Python.draw_widget2d.executor": "Run", &quot;Python.draw_widget2d.executor&quot;: &quot;Run&quot;,
"Python.draw_widget_solve.executor": "Run", &quot;Python.draw_widget_solve.executor&quot;: &quot;Run&quot;,
"Python.fluency.executor": "Run", &quot;Python.fluency.executor&quot;: &quot;Run&quot;,
"Python.fluencyb.executor": "Run", &quot;Python.fluencyb.executor&quot;: &quot;Run&quot;,
"Python.gl_widget.executor": "Run", &quot;Python.gl_widget.executor&quot;: &quot;Run&quot;,
"Python.main.executor": "Run", &quot;Python.main.executor&quot;: &quot;Run&quot;,
"Python.meshtest.executor": "Run", &quot;Python.meshtest.executor&quot;: &quot;Run&quot;,
"Python.side_fluency.executor": "Run", &quot;Python.side_fluency.executor&quot;: &quot;Run&quot;,
"Python.simple_mesh.executor": "Run", &quot;Python.simple_mesh.executor&quot;: &quot;Run&quot;,
"Python.test_vtk_sketch_widget.executor": "Run", &quot;Python.vtk_widget.executor&quot;: &quot;Run&quot;,
"Python.vtk_sketch_widget.executor": "Run", &quot;Python.vulkan.executor&quot;: &quot;Run&quot;,
"Python.vtk_widget.executor": "Run", &quot;RunOnceActivity.OpenProjectViewOnStart&quot;: &quot;true&quot;,
"Python.vulkan.executor": "Run", &quot;RunOnceActivity.ShowReadmeOnStart&quot;: &quot;true&quot;,
"RunOnceActivity.OpenProjectViewOnStart": "true", &quot;RunOnceActivity.git.unshallow&quot;: &quot;true&quot;,
"RunOnceActivity.ShowReadmeOnStart": "true", &quot;git-widget-placeholder&quot;: &quot;feature/occ-migration&quot;,
"RunOnceActivity.git.unshallow": "true", &quot;last_opened_file_path&quot;: &quot;/Volumes/Data_drive/Programming/fluency&quot;,
"git-widget-placeholder": "single__window", &quot;node.js.selected.package.tslint&quot;: &quot;(autodetect)&quot;,
"last_opened_file_path": "/Volumes/Data_drive/Programming/fluency", &quot;settings.editor.selected.configurable&quot;: &quot;project.propVCSSupport.DirectoryMappings&quot;
"settings.editor.selected.configurable": "project.propVCSSupport.DirectoryMappings"
} }
}]]></component> }</component>
<component name="QodanaReportsService"> <component name="QodanaReportsService">
<option name="descriptions"> <option name="descriptions">
<ReportDescription localRun="true" path="/private/var/folders/kg/zm48w_r96yb68mlbzvb9gtq40000gn/T/qodana_output/qodana.sarif.json" reportGuid="5f5b823c-c594-48c5-ae1f-062e30303918" reportId="fluency/qodana/2024-02-04" /> <ReportDescription localRun="true" path="/private/var/folders/kg/zm48w_r96yb68mlbzvb9gtq40000gn/T/qodana_output/qodana.sarif.json" reportGuid="5f5b823c-c594-48c5-ae1f-062e30303918" reportId="fluency/qodana/2024-02-04" />
@@ -103,14 +86,13 @@
<recent name="$PROJECT_DIR$/modules" /> <recent name="$PROJECT_DIR$/modules" />
</key> </key>
<key name="MoveFile.RECENT_KEYS"> <key name="MoveFile.RECENT_KEYS">
<recent name="$PROJECT_DIR$/doc" />
<recent name="$PROJECT_DIR$" /> <recent name="$PROJECT_DIR$" />
</key> </key>
</component> </component>
<component name="SharedIndexes"> <component name="SharedIndexes">
<attachedChunks> <attachedChunks>
<set> <set>
<option value="bundled-python-sdk-4762d8aabb82-6d6dccd035ac-com.jetbrains.pycharm.pro.sharedIndexes.bundled-PY-253.30387.173" /> <option value="bundled-python-sdk-4c141bd692a7-e2d783800521-com.jetbrains.pycharm.community.sharedIndexes.bundled-PC-251.26927.90" />
</set> </set>
</attachedChunks> </attachedChunks>
</component> </component>
@@ -275,15 +257,7 @@
<option name="project" value="LOCAL" /> <option name="project" value="LOCAL" />
<updated>1755369224187</updated> <updated>1755369224187</updated>
</task> </task>
<task id="LOCAL-00020" summary="- Improved sketching"> <option name="localTasksCounter" value="20" />
<option name="closed" value="true" />
<created>1763311700335</created>
<option name="number" value="00020" />
<option name="presentableId" value="LOCAL-00020" />
<option name="project" value="LOCAL" />
<updated>1763311700335</updated>
</task>
<option name="localTasksCounter" value="21" />
<servers /> <servers />
</component> </component>
<component name="Vcs.Log.Tabs.Properties"> <component name="Vcs.Log.Tabs.Properties">
@@ -318,7 +292,6 @@
<MESSAGE value="- added MIT license" /> <MESSAGE value="- added MIT license" />
<MESSAGE value="- added screenshot" /> <MESSAGE value="- added screenshot" />
<MESSAGE value="- added sdf folder ( doesnt work via pip or git=)" /> <MESSAGE value="- added sdf folder ( doesnt work via pip or git=)" />
<MESSAGE value="- Improved sketching" /> <option name="LAST_COMMIT_MESSAGE" value="- added sdf folder ( doesnt work via pip or git=)" />
<option name="LAST_COMMIT_MESSAGE" value="- Improved sketching" />
</component> </component>
</project> </project>
+1
View File
@@ -33,6 +33,7 @@ dependencies = [
"numpy>=1.24.0", "numpy>=1.24.0",
"scipy>=1.10.0", "scipy>=1.10.0",
"pillow>=10.0.0", "pillow>=10.0.0",
"python_solvespace>=3.0.0",
] ]
[project.optional-dependencies] [project.optional-dependencies]
+21 -9
View File
@@ -185,17 +185,28 @@ class OCGeometryKernel(GeometryKernel):
) -> GeometryObject: ) -> GeometryObject:
"""Revolve a 2D sketch around an axis.""" """Revolve a 2D sketch around an axis."""
import cadquery as cq import cadquery as cq
import math
cq_obj = self._get_cq_obj(sketch) # Get the OCC shape directly
shape = self._get_shape(sketch)
if isinstance(cq_obj, cq.Workplane): from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeFace
solid = cq_obj.revolve(angle) from OCP.gp import gp_Ax1, gp_Pnt, gp_Dir
else: from OCP.BRepPrimAPI import BRepPrimAPI_MakeRevol
face = cq.Face.makeFromWires(cq_obj)
axis_vec = cq.Vector(*axis)
origin_vec = cq.Vector(*origin)
solid = face.revolve(axis_vec, origin_vec, angle)
# 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)
revolver = BRepPrimAPI_MakeRevol(face, revolve_axis, angle_rad)
revolver.Build()
solid_shape = revolver.Shape()
solid = cq.Shape(solid_shape)
return OCCGeometryObject(solid, {"type": "revolution"}) return OCCGeometryObject(solid, {"type": "revolution"})
def loft(self, profiles: List[GeometryObject], ruled: bool = False) -> GeometryObject: def loft(self, profiles: List[GeometryObject], ruled: bool = False) -> GeometryObject:
@@ -673,7 +684,8 @@ class OCGeometryKernel(GeometryKernel):
explorer = TopExp_Explorer(shape, TopAbs_EDGE) explorer = TopExp_Explorer(shape, TopAbs_EDGE)
while explorer.More(): while explorer.More():
edge = explorer.Current() from OCP.TopoDS import TopoDS
edge = TopoDS.Edge_s(explorer.Current())
edge_points = discretize_edge(edge) edge_points = discretize_edge(edge)
for i, pt in enumerate(edge_points): for i, pt in enumerate(edge_points):
+270 -51
View File
@@ -1,12 +1,17 @@
""" """
OpenCASCADE-based sketch for Fluency CAD. OpenCASCADE-based sketch for Fluency CAD with SolveSpace constraint solver integration.
This module provides 2D sketching capabilities using CadQuery Workplanes. This module provides 2D sketching capabilities using the SolveSpace constraint
solver (via python_solvespace) for constraint management, and CadQuery for
geometry generation from solved positions.
""" """
from typing import List, Tuple, Optional, Dict, Any from typing import List, Tuple, Optional, Dict, Any
from dataclasses import dataclass, field
import numpy as np import numpy as np
import logging
import re
from python_solvespace import SolverSystem, ResultFlag
from fluency.geometry.base import ( from fluency.geometry.base import (
SketchInterface, SketchInterface,
@@ -16,24 +21,33 @@ from fluency.geometry.base import (
) )
from fluency.geometry_occ.kernel import OCCGeometryObject from fluency.geometry_occ.kernel import OCCGeometryObject
logger = logging.getLogger(__name__)
class OCCSketchEntity(SketchEntity): class OCCSketchEntity(SketchEntity):
"""Sketch entity for OpenCASCADE-based sketch.""" """Sketch entity for OpenCASCADE-based sketch with solver integration."""
def __init__(self, entity_id: int, entity_type: str, geometry: Any = None, handle: Any = None): def __init__(self, entity_id: int, entity_type: str, geometry: Any = None, handle: Any = None):
super().__init__(entity_id, entity_type) super().__init__(entity_id, entity_type)
self.geometry = geometry self.geometry = geometry
self.handle = handle self.handle = handle # SolveSpace solver entity handle
self.is_construction: bool = False
self.constraints: List[str] = [] # Track applied constraint names for UI
class OCCSketch(SketchInterface): class OCCSketch(SketchInterface):
""" """
CadQuery-based sketch for 2D geometry. Sketch with SolveSpace constraint solver integration.
This sketch uses CadQuery Workplanes for geometry creation. Uses python_solvespace as the constraint engine, allowing points and lines
to be parametrically constrained. After solving, positions are read from
the solver and used to build CadQuery geometry for extrusion.
""" """
def __init__(self) -> None: def __init__(self) -> None:
self._solver: SolverSystem = SolverSystem()
self._wp: Any = self._solver.create_2d_base()
self._entities: Dict[int, OCCSketchEntity] = {} self._entities: Dict[int, OCCSketchEntity] = {}
self._entity_counter: int = 0 self._entity_counter: int = 0
self._points: Dict[int, Tuple[float, float]] = {} self._points: Dict[int, Tuple[float, float]] = {}
@@ -41,17 +55,42 @@ class OCCSketch(SketchInterface):
self._circles: Dict[int, Tuple[int, float]] = {} self._circles: Dict[int, Tuple[int, float]] = {}
self._arcs: Dict[int, Any] = {} self._arcs: Dict[int, Any] = {}
self._constraint_count: int = 0 self._constraint_count: int = 0
self._workplane: Any = None
# Track first point as dragged/fixed for solver stability
self._first_point_id: Optional[int] = None
@property
def solver(self) -> SolverSystem:
"""Access the underlying SolveSpace solver."""
return self._solver
@property
def workplane(self) -> Any:
"""Get the solver workplane entity."""
return self._wp
def _next_id(self) -> int: def _next_id(self) -> int:
self._entity_counter += 1 self._entity_counter += 1
return self._entity_counter return self._entity_counter
def _get_handle_nr(self, handle_str: str) -> int:
match = re.search(r"handle=(\d+)", str(handle_str))
return int(match.group(1)) if match else 0
def add_point(self, x: float, y: float) -> OCCSketchEntity: def add_point(self, x: float, y: float) -> OCCSketchEntity:
"""Add a point to the sketch.""" """Add a point to the sketch (added to solver + tracked)."""
entity_id = self._next_id() entity_id = self._next_id()
entity = OCCSketchEntity(entity_id=entity_id, entity_type="point", geometry=(x, y)) # Add to solver
solver_handle = self._solver.add_point_2d(x, y, self._wp)
if self._first_point_id is None:
self._first_point_id = entity_id
# Fix first point so solver has a reference
self._solver.dragged(solver_handle, self._wp)
entity = OCCSketchEntity(
entity_id=entity_id, entity_type="point", geometry=(x, y), handle=solver_handle
)
self._entities[entity_id] = entity self._entities[entity_id] = entity
self._points[entity_id] = (x, y) self._points[entity_id] = (x, y)
@@ -59,20 +98,30 @@ class OCCSketch(SketchInterface):
return entity return entity
def add_line(self, start: SketchEntity, end: SketchEntity) -> OCCSketchEntity: def add_line(self, start: SketchEntity, end: SketchEntity) -> OCCSketchEntity:
"""Add a line between two points.""" """Add a line between two points (added to solver + tracked)."""
entity_id = self._next_id() entity_id = self._next_id()
start_geom = self._entities.get(start.id) start_entity = self._entities.get(start.id)
end_geom = self._entities.get(end.id) end_entity = self._entities.get(end.id)
if start_geom is None or end_geom is None: if start_entity is None or end_entity is None:
raise ValueError("Start or end point not found in sketch") raise ValueError("Start or end point not found in sketch")
x1, y1 = start_geom.geometry # Get solver handles
x2, y2 = end_geom.geometry s_handle = start_entity.handle
e_handle = end_entity.handle
# Add line to solver
solver_handle = self._solver.add_line_2d(s_handle, e_handle, self._wp)
x1, y1 = start_entity.geometry
x2, y2 = end_entity.geometry
entity = OCCSketchEntity( entity = OCCSketchEntity(
entity_id=entity_id, entity_type="line", geometry=((x1, y1), (x2, y2)) entity_id=entity_id,
entity_type="line",
geometry=((x1, y1), (x2, y2)),
handle=solver_handle,
) )
self._entities[entity_id] = entity self._entities[entity_id] = entity
@@ -81,7 +130,7 @@ class OCCSketch(SketchInterface):
return entity return entity
def add_circle(self, center: SketchEntity, radius: float) -> OCCSketchEntity: def add_circle(self, center: SketchEntity, radius: float) -> OCCSketchEntity:
"""Add a circle.""" """Add a circle (tracked only — solver has no native circle in this API)."""
entity_id = self._next_id() entity_id = self._next_id()
center_entity = self._entities.get(center.id) center_entity = self._entities.get(center.id)
@@ -106,7 +155,7 @@ class OCCSketch(SketchInterface):
start_point: SketchEntity, start_point: SketchEntity,
end_point: SketchEntity, end_point: SketchEntity,
) -> OCCSketchEntity: ) -> OCCSketchEntity:
"""Add an arc.""" """Add an arc (tracked only)."""
entity_id = self._next_id() entity_id = self._next_id()
center_entity = self._entities.get(center.id) center_entity = self._entities.get(center.id)
@@ -161,76 +210,218 @@ class OCCSketch(SketchInterface):
return entities return entities
def constrain_coincident(self, *entities: SketchEntity) -> bool: # ─── Constraint methods (actual solver calls) ──────────────────────────
"""Make entities coincident."""
def _add_constraint_record(self) -> None:
self._constraint_count += 1 self._constraint_count += 1
def constrain_coincident(self, *entities: SketchEntity) -> bool:
"""Make entities coincident via solver."""
if len(entities) < 2:
return False
e1 = self._entities.get(entities[0].id)
e2 = self._entities.get(entities[1].id)
if e1 is None or e2 is None or e1.handle is None or e2.handle is None:
return False
self._solver.coincident(e1.handle, e2.handle, self._wp)
self._add_constraint_record()
return True return True
def constrain_horizontal(self, line: SketchEntity) -> bool: def constrain_horizontal(self, line: SketchEntity) -> bool:
"""Constrain a line to be horizontal.""" """Constrain a line to be horizontal."""
self._constraint_count += 1 entity = self._entities.get(line.id)
if entity is None or entity.handle is None:
return False
self._solver.horizontal(entity.handle, self._wp)
self._add_constraint_record()
if "hrz" not in entity.constraints:
entity.constraints.append("hrz")
return True return True
def constrain_vertical(self, line: SketchEntity) -> bool: def constrain_vertical(self, line: SketchEntity) -> bool:
"""Constrain a line to be vertical.""" """Constrain a line to be vertical."""
self._constraint_count += 1 entity = self._entities.get(line.id)
if entity is None or entity.handle is None:
return False
self._solver.vertical(entity.handle, self._wp)
self._add_constraint_record()
if "vrt" not in entity.constraints:
entity.constraints.append("vrt")
return True return True
def constrain_distance( def constrain_distance(
self, entity1: SketchEntity, entity2: SketchEntity, distance: float self, entity1: SketchEntity, entity2: SketchEntity, distance: float
) -> bool: ) -> bool:
"""Constrain distance between two entities.""" """Constrain distance between two entities."""
self._constraint_count += 1 e1 = self._entities.get(entity1.id)
e2 = self._entities.get(entity2.id)
if e1 is None or e2 is None or e1.handle is None or e2.handle is None:
return False
self._solver.distance(e1.handle, e2.handle, distance, self._wp)
self._add_constraint_record()
return True return True
def constrain_angle(self, line1: SketchEntity, line2: SketchEntity, angle: float) -> bool: def constrain_angle(self, line1: SketchEntity, line2: SketchEntity, angle: float) -> bool:
"""Constrain angle between two lines.""" """Constrain angle between two lines."""
self._constraint_count += 1 e1 = self._entities.get(line1.id)
e2 = self._entities.get(line2.id)
if e1 is None or e2 is None or e1.handle is None or e2.handle is None:
return False
self._solver.angle(e1.handle, e2.handle, angle, self._wp)
self._add_constraint_record()
return True return True
def constrain_parallel(self, line1: SketchEntity, line2: SketchEntity) -> bool: def constrain_parallel(self, line1: SketchEntity, line2: SketchEntity) -> bool:
"""Constrain two lines to be parallel.""" """Constrain two lines to be parallel."""
self._constraint_count += 1 e1 = self._entities.get(line1.id)
e2 = self._entities.get(line2.id)
if e1 is None or e2 is None or e1.handle is None or e2.handle is None:
return False
self._solver.parallel(e1.handle, e2.handle, self._wp)
self._add_constraint_record()
return True return True
def constrain_perpendicular(self, line1: SketchEntity, line2: SketchEntity) -> bool: def constrain_perpendicular(self, line1: SketchEntity, line2: SketchEntity) -> bool:
"""Constrain two lines to be perpendicular.""" """Constrain two lines to be perpendicular."""
self._constraint_count += 1 e1 = self._entities.get(line1.id)
e2 = self._entities.get(line2.id)
if e1 is None or e2 is None or e1.handle is None or e2.handle is None:
return False
self._solver.perpendicular(e1.handle, e2.handle, self._wp)
self._add_constraint_record()
return True return True
def constrain_midpoint(self, point: SketchEntity, line: SketchEntity) -> bool: def constrain_midpoint(self, point: SketchEntity, line: SketchEntity) -> bool:
"""Constrain a point to be at the midpoint of a line.""" """Constrain a point to be at the midpoint of a line."""
self._constraint_count += 1 pt = self._entities.get(point.id)
ln = self._entities.get(line.id)
if pt is None or ln is None or pt.handle is None or ln.handle is None:
return False
self._solver.midpoint(pt.handle, ln.handle, self._wp)
self._add_constraint_record()
return True return True
def constrain_tangent(self, entity1: SketchEntity, entity2: SketchEntity) -> bool: def constrain_tangent(self, entity1: SketchEntity, entity2: SketchEntity) -> bool:
"""Constrain two entities to be tangent.""" """Constrain two entities to be tangent."""
self._constraint_count += 1 e1 = self._entities.get(entity1.id)
e2 = self._entities.get(entity2.id)
if e1 is None or e2 is None or e1.handle is None or e2.handle is None:
return False
self._solver.tangent(e1.handle, e2.handle, self._wp)
self._add_constraint_record()
return True return True
def constrain_equal_length(self, line1: SketchEntity, line2: SketchEntity) -> bool: def constrain_equal_length(self, line1: SketchEntity, line2: SketchEntity) -> bool:
"""Constrain two lines to have equal length.""" """Constrain two lines to have equal length."""
self._constraint_count += 1 e1 = self._entities.get(line1.id)
e2 = self._entities.get(line2.id)
if e1 is None or e2 is None or e1.handle is None or e2.handle is None:
return False
self._solver.equal(e1.handle, e2.handle, self._wp)
self._add_constraint_record()
return True return True
def constrain_equal_radius(self, circle1: SketchEntity, circle2: SketchEntity) -> bool: def constrain_equal_radius(self, circle1: SketchEntity, circle2: SketchEntity) -> bool:
"""Constrain two circles to have equal radius.""" """Circle equal-radius (tracked only — solver limit)."""
self._constraint_count += 1 self._add_constraint_record()
return True return True
def constrain_fixed(self, entity: SketchEntity) -> bool: def constrain_fixed(self, entity: SketchEntity) -> bool:
"""Fix an entity in place.""" """Fix an entity in place via dragged constraint."""
self._constraint_count += 1 ent = self._entities.get(entity.id)
if ent is None or ent.handle is None:
return False
self._solver.dragged(ent.handle, self._wp)
self._add_constraint_record()
return True return True
def constrain_symmetric(
self, entity1: SketchEntity, entity2: SketchEntity, line: SketchEntity
) -> bool:
"""Constrain symmetry about a line."""
e1 = self._entities.get(entity1.id)
e2 = self._entities.get(entity2.id)
ln = self._entities.get(line.id)
if e1 is None or e2 is None or ln is None:
return False
if e1.handle is None or e2.handle is None or ln.handle is None:
return False
self._solver.symmetric(e1.handle, e2.handle, ln.handle, self._wp)
self._add_constraint_record()
return True
# ─── Solving ───────────────────────────────────────────────────────────
def solve(self) -> bool: def solve(self) -> bool:
"""Solve all constraints.""" """Solve all constraints via SolveSpace solver."""
try:
result = self._solver.solve()
if result == ResultFlag.OKAY:
# Sync solved positions back to entity geometries
self._sync_solved_positions()
return True return True
else:
logger.warning(f"Solver returned: {result}")
return False
except Exception as e:
logger.error(f"Solver error: {e}")
return False
def _sync_solved_positions(self) -> None:
"""Read solved point positions from solver and update entity geometries."""
for entity_id, entity in list(self._entities.items()):
if entity.entity_type == "point" and entity.handle is not None:
try:
x, y = self._solver.params(entity.handle.params)
entity.geometry = (x, y)
if entity_id in self._points:
self._points[entity_id] = (x, y)
except Exception as e:
logger.debug(f"Could not sync point {entity_id}: {e}")
elif entity.entity_type == "line" and entity_id in self._lines:
start_id, end_id = self._lines[entity_id]
start_entity = self._entities.get(start_id)
end_entity = self._entities.get(end_id)
if start_entity and end_entity and start_entity.geometry and end_entity.geometry:
entity.geometry = (start_entity.geometry, end_entity.geometry)
def get_solved_point(self, entity_id: int) -> Optional[Tuple[float, float]]:
"""Get the solved position of a point entity."""
entity = self._entities.get(entity_id)
if entity and entity.entity_type == "point" and entity.handle is not None:
try:
x, y = self._solver.params(entity.handle.params)
return (float(x), float(y))
except Exception:
pass
return None
def get_solved_param(self, handle: Any) -> Optional[Tuple[float, float]]:
"""Get solved params for a solver entity handle."""
try:
x, y = self._solver.params(handle.params)
return (float(x), float(y))
except Exception:
return None
# ─── Geometry extraction for operations ────────────────────────────────
def get_geometry(self) -> GeometryObject: def get_geometry(self) -> GeometryObject:
"""Get the solved geometry for operations.""" """Get the solved geometry for operations using CadQuery."""
import cadquery as cq import cadquery as cq
# Check for circles first
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
points = self.get_polygon_points() points = self.get_polygon_points()
if not points: if not points:
return OCCGeometryObject(None) return OCCGeometryObject(None)
@@ -245,22 +436,32 @@ class OCCSketch(SketchInterface):
return obj return obj
def get_points(self) -> List[Point2D]: def get_points(self) -> List[Point2D]:
"""Get all point positions.""" """Get all point positions from solved solver data."""
points: List[Point2D] = [] points: List[Point2D] = []
for entity_id, entity in self._entities.items(): for entity_id, entity in self._entities.items():
if entity.entity_type == "point": if entity.entity_type == "point":
# Try to get solved position first
if entity.handle is not None:
try:
x, y = self._solver.params(entity.handle.params)
points.append(Point2D(x, y))
continue
except Exception:
pass
# Fall back to stored geometry
if entity.geometry:
x, y = entity.geometry x, y = entity.geometry
points.append(Point2D(x, y)) points.append(Point2D(x, y))
return points return points
def get_polygon_points(self) -> List[Point2D]: def get_polygon_points(self) -> List[Point2D]:
"""Get ordered polygon points from connected lines.""" """Get ordered polygon points from connected lines (uses solved positions)."""
adjacency: Dict[Tuple[float, float], List[Tuple[float, float]]] = {} adjacency: Dict[Tuple[float, float], List[Tuple[float, float]]] = {}
for entity in self._entities.values(): for entity in self._entities.values():
if entity.entity_type == "line": if entity.entity_type == "line" and entity.geometry:
p1, p2 = entity.geometry p1, p2 = entity.geometry
if p1 not in adjacency: if p1 not in adjacency:
adjacency[p1] = [] adjacency[p1] = []
@@ -272,30 +473,41 @@ class OCCSketch(SketchInterface):
if not adjacency: if not adjacency:
return [] return []
points: List[Point2D] = [] ordered: List[Point2D] = []
visited: set = set() visited: set = set()
current = next(iter(adjacency.keys())) current = next(iter(adjacency.keys()))
while current and current not in visited: while current and tuple(current) not in visited:
points.append(Point2D(current[0], current[1])) ordered.append(Point2D(current[0], current[1]))
visited.add(current) visited.add(tuple(current))
neighbors = adjacency.get(current, []) neighbors = adjacency.get(current, [])
next_point = None next_point = None
for n in neighbors: for n in neighbors:
if n not in visited: if tuple(n) not in visited:
next_point = n next_point = n
break break
current = next_point current = next_point
if len(points) > 2: if len(ordered) > 2:
points.append(points[0]) ordered.append(ordered[0])
return points return ordered
def get_solver_dof(self) -> int:
"""Get remaining degrees of freedom from solver."""
return self._solver.dof()
def get_solver_failures(self) -> List[Any]:
"""Get list of failed constraints."""
return self._solver.failures()
# ─── Management ────────────────────────────────────────────────────────
def clear(self) -> None: def clear(self) -> None:
"""Clear all geometry and constraints.""" """Clear all geometry and constraints from both solver and tracker."""
self._solver = SolverSystem()
self._wp = self._solver.create_2d_base()
self._entities.clear() self._entities.clear()
self._points.clear() self._points.clear()
self._lines.clear() self._lines.clear()
@@ -303,12 +515,16 @@ class OCCSketch(SketchInterface):
self._arcs.clear() self._arcs.clear()
self._entity_counter = 0 self._entity_counter = 0
self._constraint_count = 0 self._constraint_count = 0
self._first_point_id = None
def delete_entity(self, entity: SketchEntity) -> bool: def delete_entity(self, entity: SketchEntity) -> bool:
"""Delete an entity and its constraints.""" """Delete an entity and its constraints."""
if entity.id not in self._entities: if entity.id not in self._entities:
return False return False
# Remove from solver (clear + rebuild is simplest)
# For simplicity, we skip solver removal — on next solve, stale handles
# will be ignored. A full rebuild would need entity-by-entity solver removal.
del self._entities[entity.id] del self._entities[entity.id]
if entity.id in self._points: if entity.id in self._points:
@@ -327,9 +543,12 @@ class OCCSketch(SketchInterface):
return len(self._entities) return len(self._entities)
def get_constraint_count(self) -> int: def get_constraint_count(self) -> int:
"""Get the number of constraints in the sketch.""" """Get the number of constraints applied via solver."""
return self._constraint_count return self._constraint_count
def is_fully_constrained(self) -> bool: def is_fully_constrained(self) -> bool:
"""Check if the sketch is fully constrained.""" """Check if the sketch is fully constrained (0 DOF)."""
try:
return self._solver.dof() == 0
except Exception:
return False return False
+1066 -382
View File
File diff suppressed because it is too large Load Diff
Generated
+14
View File
@@ -638,6 +638,7 @@ dependencies = [
{ name = "pillow" }, { name = "pillow" },
{ name = "pygfx" }, { name = "pygfx" },
{ name = "pyside6" }, { name = "pyside6" },
{ name = "python-solvespace" },
{ name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
{ name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
{ name = "wgpu" }, { name = "wgpu" },
@@ -661,6 +662,7 @@ requires-dist = [
{ name = "pygfx", specifier = ">=0.1.0" }, { name = "pygfx", specifier = ">=0.1.0" },
{ name = "pyside6", specifier = ">=6.4.0" }, { name = "pyside6", specifier = ">=6.4.0" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" },
{ name = "python-solvespace", specifier = ">=3.0.0" },
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.4.0" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.4.0" },
{ name = "scipy", specifier = ">=1.10.0" }, { name = "scipy", specifier = ">=1.10.0" },
{ name = "wgpu", specifier = ">=0.1.0" }, { name = "wgpu", specifier = ">=0.1.0" },
@@ -2107,6 +2109,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" },
] ]
[[package]]
name = "python-solvespace"
version = "3.0.8"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/70/d9/edb532941527cfdbd22861ce574a57859952d21132f791a8706fafee9876/python_solvespace-3.0.8.tar.gz", hash = "sha256:c5c132c1151cfa4cc8719474bbbafedd109d7203317e531f9160a37aaae644b0", size = 1231985, upload-time = "2022-11-11T13:18:23.9Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1c/73/cfd7e631395b036052356517c2c8b8e7917308b4c397694da34260f7eff6/python_solvespace-3.0.8-cp310-cp310-macosx_10_15_x86_64.whl", hash = "sha256:bd3366cda1f3bac7c239c3fad98c6eee97c3d9299ee5c3a1ab3e4ea5e112b81a", size = 386490, upload-time = "2022-11-11T13:19:11.237Z" },
{ url = "https://files.pythonhosted.org/packages/c7/28/7e8ea55da42cd9e0ab9bdc607f5d57270c553baa9c4dab63866bba7c6e40/python_solvespace-3.0.8-cp310-cp310-win_amd64.whl", hash = "sha256:33c150f68c81addf8045e28ffeb28a828fcbbe9ab84cc4a9f1e50d0847b23bfb", size = 243413, upload-time = "2022-11-11T13:19:03.064Z" },
{ url = "https://files.pythonhosted.org/packages/e5/6a/3d3f3ffa52b4952a4d63506b20d55e23e5bf34dfd096c185cfd4b262d9a2/python_solvespace-3.0.8-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c899978359d95c274bbcc31405b080517af3839beca98e3342c52a37409bb916", size = 703966, upload-time = "2022-11-11T13:34:03.875Z" },
{ url = "https://files.pythonhosted.org/packages/b1/4c/0140a197e00be797f3ee26278a9205cccfb668b8c7d7746350bb72bff3c7/python_solvespace-3.0.8-cp311-cp311-win_amd64.whl", hash = "sha256:1191b004298d57936058d9dbdd395c44c1f46d5a9c854e36c507c49c3e6b6382", size = 242810, upload-time = "2022-11-11T13:19:55.656Z" },
]
[[package]] [[package]]
name = "pytokens" name = "pytokens"
version = "0.4.1" version = "0.4.1"