Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9938f4ddd4 | |||
| f6422e0847 | |||
| f8f16ea800 | |||
| 54ac2c098a | |||
| ea34e7e29d | |||
| 7091f530ee | |||
| 75d4820292 | |||
| d52106a48a | |||
| d7ebbf45d5 | |||
| daed79dac6 |
+36
-1
@@ -1,3 +1,38 @@
|
|||||||
*.xml
|
*.xml
|
||||||
*.iml
|
*.iml
|
||||||
.idea
|
.idea
|
||||||
|
|
||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
.Python
|
||||||
|
build/
|
||||||
|
develop-eggs/
|
||||||
|
dist/
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
lib/
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
wheels/
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
|
||||||
|
# Virtual environments
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
|
||||||
|
# Lock files
|
||||||
|
uv.lock
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
Generated
-11
@@ -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>
|
|
||||||
Generated
+1
-1
@@ -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>
|
||||||
Generated
+58
-58
@@ -4,31 +4,13 @@
|
|||||||
<option name="autoReloadType" value="SELECTIVE" />
|
<option name="autoReloadType" value="SELECTIVE" />
|
||||||
</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="- Tons of addtions">
|
||||||
<change afterPath="$PROJECT_DIR$/README.md" afterDir="false" />
|
|
||||||
<change afterPath="$PROJECT_DIR$/doc/BUGFIX_DRAWING_ISSUES.md" 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$/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$/src/fluency/rendering/occ_renderer.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/rendering/occ_renderer.py" afterDir="false" />
|
||||||
|
<change beforePath="$PROJECT_DIR$/tests/test_geometry.py" beforeDir="false" afterPath="$PROJECT_DIR$/tests/test_geometry.py" 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 +27,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="single_window" />
|
||||||
</map>
|
</map>
|
||||||
</option>
|
</option>
|
||||||
<option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
|
<option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
|
||||||
@@ -65,32 +47,39 @@
|
|||||||
<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": {
|
"keyToString": {
|
||||||
"Python.2dtest.executor": "Run",
|
"Python.2dtest.executor": "Run",
|
||||||
"Python.3d_windows.executor": "Run",
|
"Python.3d_windows.executor": "Run",
|
||||||
"Python.Unnamed.executor": "Run",
|
"Python.Unnamed.executor": "Run",
|
||||||
"Python.draw_widget2d.executor": "Run",
|
"Python.draw_widget2d.executor": "Run",
|
||||||
"Python.draw_widget_solve.executor": "Run",
|
"Python.draw_widget_solve.executor": "Run",
|
||||||
"Python.fluency.executor": "Run",
|
"Python.fluency.executor": "Run",
|
||||||
"Python.fluencyb.executor": "Run",
|
"Python.fluencyb.executor": "Run",
|
||||||
"Python.gl_widget.executor": "Run",
|
"Python.gl_widget.executor": "Run",
|
||||||
"Python.main.executor": "Run",
|
"Python.kernel.executor": "Run",
|
||||||
"Python.meshtest.executor": "Run",
|
"Python.main.executor": "Run",
|
||||||
"Python.side_fluency.executor": "Run",
|
"Python.meshtest.executor": "Run",
|
||||||
"Python.simple_mesh.executor": "Run",
|
"Python.occ_renderer.executor": "Run",
|
||||||
"Python.test_vtk_sketch_widget.executor": "Run",
|
"Python.side_fluency.executor": "Run",
|
||||||
"Python.vtk_sketch_widget.executor": "Run",
|
"Python.simple_mesh.executor": "Run",
|
||||||
"Python.vtk_widget.executor": "Run",
|
"Python.vtk_widget.executor": "Run",
|
||||||
"Python.vulkan.executor": "Run",
|
"Python.vulkan.executor": "Run",
|
||||||
"RunOnceActivity.OpenProjectViewOnStart": "true",
|
"RunOnceActivity.OpenProjectViewOnStart": "true",
|
||||||
"RunOnceActivity.ShowReadmeOnStart": "true",
|
"RunOnceActivity.ShowReadmeOnStart": "true",
|
||||||
"RunOnceActivity.git.unshallow": "true",
|
"RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252": "true",
|
||||||
"git-widget-placeholder": "single__window",
|
"RunOnceActivity.git.unshallow": "true",
|
||||||
"last_opened_file_path": "/Volumes/Data_drive/Programming/fluency",
|
"RunOnceActivity.typescript.service.memoryLimit.init": "true",
|
||||||
"settings.editor.selected.configurable": "project.propVCSSupport.DirectoryMappings"
|
"codeWithMe.voiceChat.enabledByDefault": "false",
|
||||||
|
"git-widget-placeholder": "feature/occ-migration",
|
||||||
|
"last_opened_file_path": "/Volumes/Data_drive/Programming/fluency/src/fluency",
|
||||||
|
"node.js.detected.package.eslint": "true",
|
||||||
|
"node.js.selected.package.eslint": "(autodetect)",
|
||||||
|
"node.js.selected.package.tslint": "(autodetect)",
|
||||||
|
"nodejs_package_manager_path": "npm",
|
||||||
|
"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" />
|
||||||
@@ -98,19 +87,19 @@
|
|||||||
</component>
|
</component>
|
||||||
<component name="RecentsManager">
|
<component name="RecentsManager">
|
||||||
<key name="CopyFile.RECENT_KEYS">
|
<key name="CopyFile.RECENT_KEYS">
|
||||||
|
<recent name="$PROJECT_DIR$/src/fluency" />
|
||||||
<recent name="$PROJECT_DIR$" />
|
<recent name="$PROJECT_DIR$" />
|
||||||
<recent name="$PROJECT_DIR$/drawing_modules" />
|
<recent name="$PROJECT_DIR$/drawing_modules" />
|
||||||
<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-c59985aa861c-c2ffad84badb-com.jetbrains.pycharm.pro.sharedIndexes.bundled-PY-261.24374.152" />
|
||||||
</set>
|
</set>
|
||||||
</attachedChunks>
|
</attachedChunks>
|
||||||
</component>
|
</component>
|
||||||
@@ -275,17 +264,28 @@
|
|||||||
<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">
|
<task id="LOCAL-00020" summary="- Tons of addtions">
|
||||||
<option name="closed" value="true" />
|
<option name="closed" value="true" />
|
||||||
<created>1763311700335</created>
|
<created>1782673954850</created>
|
||||||
<option name="number" value="00020" />
|
<option name="number" value="00020" />
|
||||||
<option name="presentableId" value="LOCAL-00020" />
|
<option name="presentableId" value="LOCAL-00020" />
|
||||||
<option name="project" value="LOCAL" />
|
<option name="project" value="LOCAL" />
|
||||||
<updated>1763311700335</updated>
|
<updated>1782673954850</updated>
|
||||||
</task>
|
</task>
|
||||||
<option name="localTasksCounter" value="21" />
|
<task id="LOCAL-00021" summary="- Tons of addtions">
|
||||||
|
<option name="closed" value="true" />
|
||||||
|
<created>1782679912834</created>
|
||||||
|
<option name="number" value="00021" />
|
||||||
|
<option name="presentableId" value="LOCAL-00021" />
|
||||||
|
<option name="project" value="LOCAL" />
|
||||||
|
<updated>1782679912834</updated>
|
||||||
|
</task>
|
||||||
|
<option name="localTasksCounter" value="22" />
|
||||||
<servers />
|
<servers />
|
||||||
</component>
|
</component>
|
||||||
|
<component name="TypeScriptGeneratedFilesManager">
|
||||||
|
<option name="version" value="3" />
|
||||||
|
</component>
|
||||||
<component name="Vcs.Log.Tabs.Properties">
|
<component name="Vcs.Log.Tabs.Properties">
|
||||||
<option name="TAB_STATES">
|
<option name="TAB_STATES">
|
||||||
<map>
|
<map>
|
||||||
@@ -318,7 +318,7 @@
|
|||||||
<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" />
|
<MESSAGE value="- Tons of addtions" />
|
||||||
<option name="LAST_COMMIT_MESSAGE" value="- Improved sketching" />
|
<option name="LAST_COMMIT_MESSAGE" value="- Tons of addtions" />
|
||||||
</component>
|
</component>
|
||||||
</project>
|
</project>
|
||||||
@@ -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]
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -46,7 +46,11 @@ class OCGeometryKernel(GeometryKernel):
|
|||||||
self._mesh_tolerance: float = 0.1
|
self._mesh_tolerance: float = 0.1
|
||||||
|
|
||||||
def _get_shape(self, obj: GeometryObject) -> Any:
|
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
|
import cadquery as cq
|
||||||
|
|
||||||
if isinstance(obj, OCCGeometryObject):
|
if isinstance(obj, OCCGeometryObject):
|
||||||
@@ -64,7 +68,13 @@ class OCGeometryKernel(GeometryKernel):
|
|||||||
if hasattr(obj.shape, "wrapped"):
|
if hasattr(obj.shape, "wrapped"):
|
||||||
return obj.shape.wrapped
|
return obj.shape.wrapped
|
||||||
return obj.shape
|
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:
|
def _get_cq_obj(self, obj: GeometryObject) -> Any:
|
||||||
"""Get CadQuery object from GeometryObject."""
|
"""Get CadQuery object from GeometryObject."""
|
||||||
@@ -157,24 +167,113 @@ class OCGeometryKernel(GeometryKernel):
|
|||||||
direction: Tuple[float, float, float] = (0, 0, 1),
|
direction: Tuple[float, float, float] = (0, 0, 1),
|
||||||
symmetric: bool = False,
|
symmetric: bool = False,
|
||||||
) -> GeometryObject:
|
) -> GeometryObject:
|
||||||
"""Extrude a 2D sketch into a 3D solid."""
|
"""Extrude a sketch face into a 3D solid along the sketch plane normal.
|
||||||
import cadquery as cq
|
|
||||||
|
|
||||||
cq_obj = self._get_cq_obj(sketch)
|
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.
|
||||||
|
"""
|
||||||
|
from OCP.gp import gp_Vec
|
||||||
|
from OCP.BRepPrimAPI import BRepPrimAPI_MakePrism
|
||||||
|
from OCP.BRepAlgoAPI import BRepAlgoAPI_Fuse
|
||||||
|
from OCP.TopoDS import TopoDS_Shape
|
||||||
|
|
||||||
if isinstance(cq_obj, cq.Workplane):
|
# Defensive: figure out the actual shape from whatever the caller
|
||||||
if symmetric:
|
# hands us, and surface a clear error if we can't get one.
|
||||||
solid = cq_obj.extrude(height / 2, both=True)
|
# - If it's an OCCGeometryObject wrapper, unwrap via _get_shape.
|
||||||
else:
|
# - If it's already a TopoDS_Shape (raw face/wire/etc.), use it.
|
||||||
solid = cq_obj.extrude(height)
|
# - If it's a cadquery Workplane, unwrap that too.
|
||||||
|
# - If it's a cadquery Shape (cq.Shape), unwrap to TopoDS_Shape.
|
||||||
|
if isinstance(sketch, OCCGeometryObject):
|
||||||
|
face = self._get_shape(sketch)
|
||||||
|
elif isinstance(sketch, TopoDS_Shape):
|
||||||
|
face = sketch
|
||||||
else:
|
else:
|
||||||
wp = cq.Workplane("XY").add(cq_obj)
|
face = self._get_shape(sketch)
|
||||||
if symmetric:
|
if face is None:
|
||||||
solid = wp.extrude(height / 2, both=True)
|
raise ValueError(
|
||||||
else:
|
"Cannot extrude: sketch has no geometry. "
|
||||||
solid = wp.extrude(height)
|
"Draw a closed profile before extruding."
|
||||||
|
)
|
||||||
|
# If the wrapper class itself leaked through somehow, surface a
|
||||||
|
# clear error instead of letting BRepPrimAPI_MakePrism raise an
|
||||||
|
# opaque TypeError.
|
||||||
|
if isinstance(face, OCCGeometryObject):
|
||||||
|
raise ValueError(
|
||||||
|
"Cannot extrude: sketch geometry is a wrapper, not a shape. "
|
||||||
|
"This is a bug — please report it."
|
||||||
|
)
|
||||||
|
# ``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)."
|
||||||
|
)
|
||||||
|
|
||||||
return OCCGeometryObject(solid, {"type": "extrusion"})
|
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:
|
||||||
|
solid = _prism(height)
|
||||||
|
|
||||||
|
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(
|
def revolve(
|
||||||
self,
|
self,
|
||||||
@@ -183,20 +282,24 @@ class OCGeometryKernel(GeometryKernel):
|
|||||||
axis: Tuple[float, float, float] = (0, 0, 1),
|
axis: Tuple[float, float, float] = (0, 0, 1),
|
||||||
origin: Tuple[float, float, float] = (0, 0, 0),
|
origin: Tuple[float, float, float] = (0, 0, 0),
|
||||||
) -> GeometryObject:
|
) -> GeometryObject:
|
||||||
"""Revolve a 2D sketch around an axis."""
|
"""Revolve a sketch face around an axis."""
|
||||||
import cadquery as cq
|
import math
|
||||||
|
|
||||||
cq_obj = self._get_cq_obj(sketch)
|
# Get the OCC shape directly (a TopoDS_Face for new sketch geometry).
|
||||||
|
shape = self._get_shape(sketch)
|
||||||
|
face = self._ensure_face(shape)
|
||||||
|
|
||||||
if isinstance(cq_obj, cq.Workplane):
|
from OCP.gp import gp_Ax1, gp_Pnt, gp_Dir
|
||||||
solid = cq_obj.revolve(angle)
|
from OCP.BRepPrimAPI import BRepPrimAPI_MakeRevol
|
||||||
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"})
|
# 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()
|
||||||
|
|
||||||
|
return OCCGeometryObject(solid_shape, {"type": "revolution"})
|
||||||
|
|
||||||
def loft(self, profiles: List[GeometryObject], ruled: bool = False) -> GeometryObject:
|
def loft(self, profiles: List[GeometryObject], ruled: bool = False) -> GeometryObject:
|
||||||
"""Create a loft between multiple profiles."""
|
"""Create a loft between multiple profiles."""
|
||||||
@@ -593,7 +696,9 @@ class OCGeometryKernel(GeometryKernel):
|
|||||||
from OCP.BRep import BRep_Tool
|
from OCP.BRep import BRep_Tool
|
||||||
from OCP.TopLoc import TopLoc_Location
|
from OCP.TopLoc import TopLoc_Location
|
||||||
|
|
||||||
mesh = BRepMesh_IncrementalMesh(shape, tolerance, False, 0.5, False)
|
# Use finer angular deflection (0.15 rad ≈ 24 segments/circle) so
|
||||||
|
# curved surfaces like cylinders render smoothly instead of faceted.
|
||||||
|
mesh = BRepMesh_IncrementalMesh(shape, tolerance, False, 0.15, True)
|
||||||
mesh.Perform()
|
mesh.Perform()
|
||||||
|
|
||||||
vertices_list: List[List[float]] = []
|
vertices_list: List[List[float]] = []
|
||||||
@@ -601,6 +706,7 @@ class OCGeometryKernel(GeometryKernel):
|
|||||||
vertex_offset = 0
|
vertex_offset = 0
|
||||||
|
|
||||||
from OCP.TopoDS import TopoDS
|
from OCP.TopoDS import TopoDS
|
||||||
|
from OCP.TopAbs import TopAbs_Orientation
|
||||||
|
|
||||||
explorer = TopExp_Explorer(shape, TopAbs_FACE)
|
explorer = TopExp_Explorer(shape, TopAbs_FACE)
|
||||||
while explorer.More():
|
while explorer.More():
|
||||||
@@ -615,15 +721,21 @@ class OCGeometryKernel(GeometryKernel):
|
|||||||
vertices_list.append([p.X(), p.Y(), p.Z()])
|
vertices_list.append([p.X(), p.Y(), p.Z()])
|
||||||
|
|
||||||
n_triangles = triangulation.NbTriangles()
|
n_triangles = triangulation.NbTriangles()
|
||||||
|
# REVERSED faces store triangle winding in the natural (surface)
|
||||||
|
# orientation — we must flip it so the computed normals point
|
||||||
|
# outward (away from solid interior). TopAbs_REVERSED = 1.
|
||||||
|
reverse_winding = face.Orientation() == TopAbs_Orientation.TopAbs_REVERSED
|
||||||
for i in range(1, n_triangles + 1):
|
for i in range(1, n_triangles + 1):
|
||||||
tri = triangulation.Triangle(i)
|
tri = triangulation.Triangle(i)
|
||||||
faces_list.append(
|
v0, v1, v2 = (
|
||||||
[
|
tri.Value(1) - 1 + vertex_offset,
|
||||||
tri.Value(1) - 1 + vertex_offset,
|
tri.Value(2) - 1 + vertex_offset,
|
||||||
tri.Value(2) - 1 + vertex_offset,
|
tri.Value(3) - 1 + vertex_offset,
|
||||||
tri.Value(3) - 1 + vertex_offset,
|
|
||||||
]
|
|
||||||
)
|
)
|
||||||
|
if reverse_winding:
|
||||||
|
# Swap last two vertices to flip winding direction.
|
||||||
|
v1, v2 = v2, v1
|
||||||
|
faces_list.append([v0, v1, v2])
|
||||||
|
|
||||||
vertex_offset += n_vertices
|
vertex_offset += n_vertices
|
||||||
|
|
||||||
@@ -673,7 +785,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):
|
||||||
|
|||||||
+1145
-65
File diff suppressed because it is too large
Load Diff
+3033
-411
File diff suppressed because it is too large
Load Diff
Binary file not shown.
Binary file not shown.
@@ -6,7 +6,7 @@ including projects, components, sketches, and bodies.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Dict, List, Optional, Any
|
from typing import Dict, List, Optional, Any, Tuple
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import uuid
|
import uuid
|
||||||
import numpy as np
|
import numpy as np
|
||||||
@@ -46,6 +46,32 @@ class Sketch:
|
|||||||
created_at: datetime = field(default_factory=datetime.now)
|
created_at: datetime = field(default_factory=datetime.now)
|
||||||
modified_at: datetime = field(default_factory=datetime.now)
|
modified_at: datetime = field(default_factory=datetime.now)
|
||||||
|
|
||||||
|
def set_workplane(
|
||||||
|
self,
|
||||||
|
origin: Tuple[float, float, float],
|
||||||
|
normal: Tuple[float, float, float],
|
||||||
|
x_dir: Tuple[float, float, float],
|
||||||
|
) -> None:
|
||||||
|
"""Set this sketch's 3D workplane and sync it to the OCC sketch.
|
||||||
|
|
||||||
|
Call this when the sketch is placed on a face/datum plane. UV
|
||||||
|
coordinates are unchanged; only their world mapping moves.
|
||||||
|
"""
|
||||||
|
self.workplane_origin = np.asarray(origin, dtype=float)
|
||||||
|
self.workplane_normal = np.asarray(normal, dtype=float)
|
||||||
|
self.workplane_x_dir = np.asarray(x_dir, dtype=float)
|
||||||
|
self.apply_workplane()
|
||||||
|
self.modified_at = datetime.now()
|
||||||
|
|
||||||
|
def apply_workplane(self) -> None:
|
||||||
|
"""Push the stored workplane fields into the underlying OCCSketch."""
|
||||||
|
if self.occ_sketch is not None:
|
||||||
|
self.occ_sketch.set_workplane(
|
||||||
|
tuple(self.workplane_origin.tolist()),
|
||||||
|
tuple(self.workplane_normal.tolist()),
|
||||||
|
tuple(self.workplane_x_dir.tolist()),
|
||||||
|
)
|
||||||
|
|
||||||
def add_point(self, x: float, y: float) -> Any:
|
def add_point(self, x: float, y: float) -> Any:
|
||||||
"""Add a point to the sketch."""
|
"""Add a point to the sketch."""
|
||||||
self.modified_at = datetime.now()
|
self.modified_at = datetime.now()
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from fluency.rendering.base import (
|
|||||||
RenderColor,
|
RenderColor,
|
||||||
)
|
)
|
||||||
from fluency.rendering.pygfx_renderer import PygfxRenderer, PygfxRenderObject
|
from fluency.rendering.pygfx_renderer import PygfxRenderer, PygfxRenderObject
|
||||||
|
from fluency.rendering.occ_renderer import OCCRenderer, OCCRenderObject
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"Renderer",
|
"Renderer",
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -97,7 +97,7 @@ class Renderer(ABC):
|
|||||||
faces: np.ndarray,
|
faces: np.ndarray,
|
||||||
color: Tuple[float, float, float] = (0.2, 0.4, 0.8),
|
color: Tuple[float, float, float] = (0.2, 0.4, 0.8),
|
||||||
name: Optional[str] = None,
|
name: Optional[str] = None,
|
||||||
) -> RenderObject:
|
) -> str:
|
||||||
"""
|
"""
|
||||||
Add a mesh to the scene.
|
Add a mesh to the scene.
|
||||||
|
|
||||||
@@ -108,7 +108,7 @@ class Renderer(ABC):
|
|||||||
name: Optional name for the object
|
name: Optional name for the object
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
RenderObject representing the mesh
|
String ID of the mesh
|
||||||
"""
|
"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -120,7 +120,7 @@ class Renderer(ABC):
|
|||||||
color: Tuple[float, float, float] = (1.0, 1.0, 1.0),
|
color: Tuple[float, float, float] = (1.0, 1.0, 1.0),
|
||||||
line_width: float = 1.0,
|
line_width: float = 1.0,
|
||||||
name: Optional[str] = None,
|
name: Optional[str] = None,
|
||||||
) -> RenderObject:
|
) -> str:
|
||||||
"""
|
"""
|
||||||
Add a wireframe to the scene.
|
Add a wireframe to the scene.
|
||||||
|
|
||||||
@@ -132,7 +132,7 @@ class Renderer(ABC):
|
|||||||
name: Optional name for the object
|
name: Optional name for the object
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
RenderObject representing the wireframe
|
String ID of the wireframe
|
||||||
"""
|
"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -143,7 +143,7 @@ class Renderer(ABC):
|
|||||||
color: Tuple[float, float, float] = (1.0, 0.0, 0.0),
|
color: Tuple[float, float, float] = (1.0, 0.0, 0.0),
|
||||||
size: float = 5.0,
|
size: float = 5.0,
|
||||||
name: Optional[str] = None,
|
name: Optional[str] = None,
|
||||||
) -> RenderObject:
|
) -> str:
|
||||||
"""
|
"""
|
||||||
Add points to the scene.
|
Add points to the scene.
|
||||||
|
|
||||||
@@ -154,7 +154,7 @@ class Renderer(ABC):
|
|||||||
name: Optional name for the object
|
name: Optional name for the object
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
RenderObject representing the points
|
String ID of the points
|
||||||
"""
|
"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -166,7 +166,7 @@ class Renderer(ABC):
|
|||||||
color: Tuple[float, float, float] = (1.0, 1.0, 1.0),
|
color: Tuple[float, float, float] = (1.0, 1.0, 1.0),
|
||||||
line_width: float = 1.0,
|
line_width: float = 1.0,
|
||||||
name: Optional[str] = None,
|
name: Optional[str] = None,
|
||||||
) -> RenderObject:
|
) -> str:
|
||||||
"""
|
"""
|
||||||
Add line segments to the scene.
|
Add line segments to the scene.
|
||||||
|
|
||||||
@@ -178,7 +178,7 @@ class Renderer(ABC):
|
|||||||
name: Optional name for the object
|
name: Optional name for the object
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
RenderObject representing the lines
|
String ID of the lines
|
||||||
"""
|
"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -312,13 +312,13 @@ class Renderer(ABC):
|
|||||||
size: float = 100.0,
|
size: float = 100.0,
|
||||||
divisions: int = 10,
|
divisions: int = 10,
|
||||||
color: Tuple[float, float, float] = (0.3, 0.3, 0.3),
|
color: Tuple[float, float, float] = (0.3, 0.3, 0.3),
|
||||||
) -> RenderObject:
|
) -> str:
|
||||||
"""Add a reference grid."""
|
"""Add a reference grid. Returns the grid ID."""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def add_axes(self, size: float = 10.0, visible: bool = True) -> RenderObject:
|
def add_axes(self, size: float = 10.0, visible: bool = True) -> str:
|
||||||
"""Add coordinate axes."""
|
"""Add coordinate axes. Returns the axes ID."""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
|
|||||||
@@ -0,0 +1,968 @@
|
|||||||
|
"""OCC-based 3D renderer — displays BRep shapes directly on the GPU.
|
||||||
|
|
||||||
|
Uses OCC's ``AIS_Shape`` and ``V3d_Viewer`` so that curved surfaces (cylinders,
|
||||||
|
spheres, etc.) render smoothly without manual triangulation, and edges/faces are
|
||||||
|
natively selectable.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import uuid
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from .base import Renderer, RenderObject, RenderColor
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class OCCRenderObject(RenderObject):
|
||||||
|
"""Internal object state for the OCC renderer."""
|
||||||
|
|
||||||
|
obj_id: str = ""
|
||||||
|
ais_shape: Any = None # AIS_Shape
|
||||||
|
ais_type: str = "shape" # "shape" | "wireframe" | "points"
|
||||||
|
color: RenderColor = field(default_factory=lambda: RenderColor(0.5, 0.5, 0.5))
|
||||||
|
|
||||||
|
|
||||||
|
class OCCRenderer(Renderer):
|
||||||
|
"""Renderer that uses OCC's native AIS display for smooth BRep rendering."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self._viewer: Any = None
|
||||||
|
self._view: Any = None
|
||||||
|
self._context: Any = None
|
||||||
|
self._window: Any = None
|
||||||
|
self._initialized: bool = False
|
||||||
|
self._objects: Dict[str, OCCRenderObject] = {}
|
||||||
|
self._parent_widget: Any = None
|
||||||
|
self._last_mouse_x: int = 0
|
||||||
|
self._last_mouse_y: int = 0
|
||||||
|
self._pan_start_x: int = 0
|
||||||
|
self._pan_start_y: int = 0
|
||||||
|
self._nav_mode: Optional[str] = None # "rotate" | "pan" | None
|
||||||
|
# Persistent light-blue transparent overlay marking the selected face.
|
||||||
|
self._highlight_ais: Any = None
|
||||||
|
# Temporary transparent preview AIS for the live extrude/cut dialog.
|
||||||
|
self._preview_ais: Any = None
|
||||||
|
|
||||||
|
def initialize(self, parent_widget: Any) -> bool:
|
||||||
|
"""Initialise OCC viewer inside *parent_widget* (a QWidget)."""
|
||||||
|
self._parent_widget = parent_widget
|
||||||
|
|
||||||
|
import os as _os
|
||||||
|
if _os.environ.get("QT_QPA_PLATFORM") == "offscreen":
|
||||||
|
logger.warning("OCCRenderer skipped (QT_QPA_PLATFORM=offscreen)")
|
||||||
|
return False
|
||||||
|
|
||||||
|
logger.info("OCCRenderer imports starting...")
|
||||||
|
from OCP.Aspect import (
|
||||||
|
Aspect_DisplayConnection,
|
||||||
|
Aspect_NeutralWindow,
|
||||||
|
Aspect_GFM_VER,
|
||||||
|
Aspect_TypeOfTriedronPosition,
|
||||||
|
)
|
||||||
|
from OCP.OpenGl import OpenGl_GraphicDriver
|
||||||
|
from OCP.V3d import (
|
||||||
|
V3d_Viewer,
|
||||||
|
V3d_View,
|
||||||
|
V3d_TypeOfView,
|
||||||
|
V3d_DirectionalLight,
|
||||||
|
V3d_AmbientLight,
|
||||||
|
V3d_TypeOfOrientation,
|
||||||
|
)
|
||||||
|
from OCP.AIS import AIS_InteractiveContext
|
||||||
|
from OCP.Graphic3d import (
|
||||||
|
Graphic3d_Camera,
|
||||||
|
Graphic3d_TypeOfShadingModel,
|
||||||
|
Graphic3d_MaterialAspect,
|
||||||
|
Graphic3d_NameOfMaterial,
|
||||||
|
)
|
||||||
|
from OCP.Quantity import (
|
||||||
|
Quantity_Color,
|
||||||
|
Quantity_TOC_RGB,
|
||||||
|
Quantity_NameOfColor,
|
||||||
|
)
|
||||||
|
logger.info("OCCRenderer imports complete")
|
||||||
|
|
||||||
|
hwnd = int(parent_widget.winId())
|
||||||
|
if hwnd <= 1:
|
||||||
|
logger.warning("OCCRenderer skipped (no native window handle)")
|
||||||
|
return False
|
||||||
|
|
||||||
|
logger.info("OCCRenderer creating objects...")
|
||||||
|
|
||||||
|
try:
|
||||||
|
display = Aspect_DisplayConnection()
|
||||||
|
driver = OpenGl_GraphicDriver(display)
|
||||||
|
viewer = V3d_Viewer(driver)
|
||||||
|
|
||||||
|
# ── Lighting: replace defaults with a tuned 3-light rig ───────
|
||||||
|
# Key light (warm directional from upper-front-right) + fill
|
||||||
|
# (cool, softer) + ambient for base lift. Gives proper shading
|
||||||
|
# on curved BRep surfaces (cylinders, spheres).
|
||||||
|
viewer.SetLightOff() # clear default lights first
|
||||||
|
key = V3d_DirectionalLight(
|
||||||
|
V3d_TypeOfOrientation.V3d_XposYnegZpos,
|
||||||
|
Quantity_Color(1.0, 0.96, 0.88, Quantity_TOC_RGB),
|
||||||
|
True,
|
||||||
|
)
|
||||||
|
fill = V3d_DirectionalLight(
|
||||||
|
V3d_TypeOfOrientation.V3d_XnegYnegZpos,
|
||||||
|
Quantity_Color(0.55, 0.62, 0.78, Quantity_TOC_RGB),
|
||||||
|
True,
|
||||||
|
)
|
||||||
|
rim = V3d_DirectionalLight(
|
||||||
|
V3d_TypeOfOrientation.V3d_XposYposZneg,
|
||||||
|
Quantity_Color(0.5, 0.5, 0.55, Quantity_TOC_RGB),
|
||||||
|
True,
|
||||||
|
)
|
||||||
|
ambient = V3d_AmbientLight(
|
||||||
|
Quantity_Color(0.35, 0.35, 0.4, Quantity_TOC_RGB)
|
||||||
|
)
|
||||||
|
for light in (key, fill, rim, ambient):
|
||||||
|
viewer.SetLightOn(light)
|
||||||
|
|
||||||
|
view = V3d_View(viewer, V3d_TypeOfView.V3d_ORTHOGRAPHIC)
|
||||||
|
|
||||||
|
# ── Background gradient (dark studio look) ───────────────────
|
||||||
|
viewer.SetDefaultBgGradientColors(
|
||||||
|
Quantity_Color(0.18, 0.20, 0.24, Quantity_TOC_RGB),
|
||||||
|
Quantity_Color(0.08, 0.09, 0.11, Quantity_TOC_RGB),
|
||||||
|
Aspect_GFM_VER,
|
||||||
|
)
|
||||||
|
view.SetBgGradientColors(
|
||||||
|
Quantity_Color(0.18, 0.20, 0.24, Quantity_TOC_RGB),
|
||||||
|
Quantity_Color(0.08, 0.09, 0.11, Quantity_TOC_RGB),
|
||||||
|
Aspect_GFM_VER,
|
||||||
|
True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Rendering quality: MSAA + Phong shading + AA ─────────────
|
||||||
|
params = view.ChangeRenderingParams()
|
||||||
|
params.NbMsaaSamples = 4 # 4x MSAA for smoother edges
|
||||||
|
params.IsAntialiasingEnabled = True
|
||||||
|
params.LineFeather = 0.6
|
||||||
|
view.SetShadingModel(Graphic3d_TypeOfShadingModel.Graphic3d_TypeOfShadingModel_Phong)
|
||||||
|
|
||||||
|
# ── Corner orientation trihedron (axis gizmo) ────────────────
|
||||||
|
try:
|
||||||
|
view.TriedronDisplay(
|
||||||
|
Aspect_TypeOfTriedronPosition.Aspect_TOTP_LEFT_LOWER,
|
||||||
|
Quantity_Color(Quantity_NameOfColor.Quantity_NOC_WHITE),
|
||||||
|
0.08,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.debug("TriedronDisplay unavailable", exc_info=True)
|
||||||
|
|
||||||
|
context = AIS_InteractiveContext(viewer)
|
||||||
|
# Default display mode = shaded (AIS_Shaded = 1)
|
||||||
|
context.SetDisplayMode(1, True)
|
||||||
|
|
||||||
|
# Style the dynamic (hover) highlight as light-blue so the face
|
||||||
|
# pick preview matches the persistent selection overlay below.
|
||||||
|
try:
|
||||||
|
from OCP.Quantity import Quantity_Color, Quantity_TOC_RGB
|
||||||
|
# Modify the existing dynamic-highlight drawer in place (per
|
||||||
|
# OCC docs this is safer than building a fresh Prs3d_Drawer).
|
||||||
|
hd = context.HighlightStyle()
|
||||||
|
hd.SetColor(Quantity_Color(0.45, 0.75, 1.0, Quantity_TOC_RGB))
|
||||||
|
hd.SetDisplayMode(1)
|
||||||
|
except Exception:
|
||||||
|
logger.debug("dynamic highlight style unavailable", exc_info=True)
|
||||||
|
|
||||||
|
# Attach OCC view to the Qt widget via the native window handle.
|
||||||
|
win = Aspect_NeutralWindow()
|
||||||
|
win.SetNativeHandle(hwnd)
|
||||||
|
w, h = parent_widget.width(), parent_widget.height()
|
||||||
|
win.SetSize(w, h)
|
||||||
|
view.SetWindow(win)
|
||||||
|
# Ensure the depth range follows the scene so nothing clips.
|
||||||
|
view.SetAutoZFitMode(True)
|
||||||
|
|
||||||
|
self._viewer = viewer
|
||||||
|
self._view = view
|
||||||
|
self._context = context
|
||||||
|
self._window = win
|
||||||
|
self._initialized = True
|
||||||
|
|
||||||
|
logger.info("OCCRenderer initialised (native OCC display)")
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error(f"OCCRenderer initialisation failed: {exc}")
|
||||||
|
self._initialized = False
|
||||||
|
return False
|
||||||
|
|
||||||
|
def shutdown(self) -> None:
|
||||||
|
"""Clean up OCC viewer resources."""
|
||||||
|
self.clear_scene()
|
||||||
|
if self._view is not None:
|
||||||
|
self._view.Remove()
|
||||||
|
self._view = None
|
||||||
|
if self._viewer is not None:
|
||||||
|
self._viewer.Remove()
|
||||||
|
self._viewer = None
|
||||||
|
self._initialized = False
|
||||||
|
|
||||||
|
# ─── BRep shape display (primary entry point) ───────────────────────
|
||||||
|
|
||||||
|
def add_shape(
|
||||||
|
self,
|
||||||
|
shape: Any,
|
||||||
|
color: Optional[Tuple[float, float, float]] = None,
|
||||||
|
name: Optional[str] = None,
|
||||||
|
) -> str:
|
||||||
|
"""Display an OCC ``TopoDS_Shape`` directly via ``AIS_Shape``.
|
||||||
|
|
||||||
|
Returns a unique object ID (or *name* if provided).
|
||||||
|
"""
|
||||||
|
from OCP.AIS import AIS_Shape
|
||||||
|
from OCP.Quantity import Quantity_Color, Quantity_TOC_RGB
|
||||||
|
from OCP.Prs3d import Prs3d_Drawer
|
||||||
|
|
||||||
|
obj_id = name or f"shape_{uuid.uuid4().hex[:8]}"
|
||||||
|
|
||||||
|
ais = AIS_Shape(shape)
|
||||||
|
|
||||||
|
# Order matters: set material *before* color so the per-channel color
|
||||||
|
# overrides only the diffuse albedo and the plastic BRDF is retained.
|
||||||
|
ais.SetMaterial(self._default_material())
|
||||||
|
if color is not None:
|
||||||
|
qcol = Quantity_Color(*color, Quantity_TOC_RGB)
|
||||||
|
ais.SetColor(qcol)
|
||||||
|
|
||||||
|
# Shaded display with boundary edges drawn on top for readability.
|
||||||
|
ais.SetDisplayMode(1) # 1 = AIS_Shaded
|
||||||
|
drawer = ais.Attributes()
|
||||||
|
try:
|
||||||
|
# Always draw face boundaries — makes solid edges crisp.
|
||||||
|
drawer.SetFaceBoundaryDraw(True)
|
||||||
|
except Exception:
|
||||||
|
logger.debug("boundary-draw attrs unavailable", exc_info=True)
|
||||||
|
# Slight polygon offset so boundary edges don't z-fight the surface.
|
||||||
|
# Mode 3 = Graphic3d_POM_Fill (offset filled polygons).
|
||||||
|
try:
|
||||||
|
ais.SetPolygonOffsets(3, 1.0, 0.0)
|
||||||
|
except Exception:
|
||||||
|
logger.debug("polygon offset unavailable", exc_info=True)
|
||||||
|
|
||||||
|
self._context.Display(ais, True)
|
||||||
|
# Activate selection modes so the viewer can detect/pick the whole
|
||||||
|
# shape (mode 0) and individual faces (mode for TopAbs_FACE) — needed
|
||||||
|
# for sketch-on-surface face picking. Left-click orbits the camera
|
||||||
|
# (see handle_mouse_press), so active selection doesn't interfere.
|
||||||
|
try:
|
||||||
|
from OCP.TopAbs import TopAbs_FACE
|
||||||
|
face_mode = AIS_Shape.SelectionMode_s(TopAbs_FACE)
|
||||||
|
self._context.Activate(ais, face_mode)
|
||||||
|
except Exception:
|
||||||
|
logger.debug("face selection mode activation failed", exc_info=True)
|
||||||
|
|
||||||
|
defcol = color or (0.5, 0.5, 0.5)
|
||||||
|
robj = OCCRenderObject(
|
||||||
|
obj_id=obj_id,
|
||||||
|
ais_shape=ais,
|
||||||
|
ais_type="shape",
|
||||||
|
color=RenderColor(*defcol),
|
||||||
|
)
|
||||||
|
self._objects[obj_id] = robj
|
||||||
|
|
||||||
|
# Fit camera on first shape added.
|
||||||
|
if len(self._objects) == 1:
|
||||||
|
try:
|
||||||
|
self.fit_camera()
|
||||||
|
except Exception:
|
||||||
|
logger.warning("fit_camera failed on first shape", exc_info=True)
|
||||||
|
|
||||||
|
return obj_id
|
||||||
|
|
||||||
|
def _default_material(self):
|
||||||
|
"""Return a default Graphic3d_MaterialAspect for shading.
|
||||||
|
|
||||||
|
Plastic gives a clean, readable matte surface that responds well to
|
||||||
|
the 3-light rig set up in :meth:`initialize`.
|
||||||
|
"""
|
||||||
|
from OCP.Graphic3d import Graphic3d_MaterialAspect, Graphic3d_NameOfMaterial
|
||||||
|
|
||||||
|
mat = Graphic3d_MaterialAspect(
|
||||||
|
Graphic3d_NameOfMaterial.Graphic3d_NOM_PLASTIC
|
||||||
|
)
|
||||||
|
return mat
|
||||||
|
|
||||||
|
# ─── Legacy mesh / wireframe (kept for backward compat) ────────────
|
||||||
|
|
||||||
|
def add_mesh(
|
||||||
|
self,
|
||||||
|
vertices: np.ndarray,
|
||||||
|
faces: np.ndarray,
|
||||||
|
color: Tuple[float, float, float] = (0.2, 0.4, 0.8),
|
||||||
|
name: Optional[str] = None,
|
||||||
|
) -> str:
|
||||||
|
"""Add triangulated mesh by converting it to an OCC polygonal shape.
|
||||||
|
|
||||||
|
Prefer :meth:`add_shape` for native BRep display — it avoids meshing
|
||||||
|
artifacts on curved surfaces.
|
||||||
|
"""
|
||||||
|
from OCP.Poly import Poly_Triangulation, Poly_Triangle
|
||||||
|
from OCP.TopoDS import TopoDS_Face
|
||||||
|
from OCP.BRep import BRep_Builder
|
||||||
|
from OCP.gp import gp_Pnt
|
||||||
|
|
||||||
|
n_verts = len(vertices)
|
||||||
|
n_tris = len(faces)
|
||||||
|
|
||||||
|
# Build triangulation via (nbNodes, nbTriangles, hasUVNodes) constructor
|
||||||
|
tri = Poly_Triangulation(n_verts, n_tris, False)
|
||||||
|
|
||||||
|
for i, (x, y, z) in enumerate(vertices):
|
||||||
|
tri.SetNode(i + 1, gp_Pnt(float(x), float(y), float(z)))
|
||||||
|
|
||||||
|
for i, (a, b, cc) in enumerate(faces):
|
||||||
|
tri.SetTriangle(i + 1, Poly_Triangle(int(a) + 1, int(b) + 1, int(cc) + 1))
|
||||||
|
|
||||||
|
builder = BRep_Builder()
|
||||||
|
shape = TopoDS_Face()
|
||||||
|
builder.MakeFace(shape, tri)
|
||||||
|
return self.add_shape(shape, color, name)
|
||||||
|
|
||||||
|
def add_wireframe(
|
||||||
|
self,
|
||||||
|
vertices: np.ndarray,
|
||||||
|
edges: np.ndarray,
|
||||||
|
color: Tuple[float, float, float] = (1.0, 1.0, 1.0),
|
||||||
|
line_width: float = 1.0,
|
||||||
|
name: Optional[str] = None,
|
||||||
|
) -> str:
|
||||||
|
"""Add a wireframe from edge data (legacy, kept for compatibility).
|
||||||
|
|
||||||
|
For new code prefer :meth:`add_shape` — OCC's AIS displays the
|
||||||
|
shape boundary automatically.
|
||||||
|
"""
|
||||||
|
obj_id = name or f"wf_{uuid.uuid4().hex[:8]}"
|
||||||
|
logger.debug(f"add_wireframe {obj_id} — ignored (AIS draws edges natively)")
|
||||||
|
# Wireframes are already provided by the AIS shaded display, so we
|
||||||
|
# skip explicit line geometry unless there is a specific need.
|
||||||
|
return obj_id
|
||||||
|
|
||||||
|
def add_points(
|
||||||
|
self,
|
||||||
|
points: np.ndarray,
|
||||||
|
color: Tuple[float, float, float] = (1.0, 0.0, 0.0),
|
||||||
|
size: float = 5.0,
|
||||||
|
name: Optional[str] = None,
|
||||||
|
) -> str:
|
||||||
|
"""Add point cloud (not yet implemented with OCC renderer)."""
|
||||||
|
obj_id = name or f"pts_{uuid.uuid4().hex[:8]}"
|
||||||
|
logger.debug(f"add_points {obj_id} — not implemented in OCCRenderer")
|
||||||
|
return obj_id
|
||||||
|
|
||||||
|
def add_lines(
|
||||||
|
self,
|
||||||
|
start_points: np.ndarray,
|
||||||
|
end_points: np.ndarray,
|
||||||
|
color: Tuple[float, float, float] = (1.0, 1.0, 1.0),
|
||||||
|
line_width: float = 1.0,
|
||||||
|
name: Optional[str] = None,
|
||||||
|
) -> str:
|
||||||
|
"""Add line segments (not yet implemented with OCC renderer)."""
|
||||||
|
obj_id = name or f"ln_{uuid.uuid4().hex[:8]}"
|
||||||
|
logger.debug(f"add_lines {obj_id} — not implemented in OCCRenderer")
|
||||||
|
return obj_id
|
||||||
|
|
||||||
|
# ─── Object management ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
def remove_object(self, obj: OCCRenderObject) -> bool:
|
||||||
|
"""Remove an object from the scene."""
|
||||||
|
if obj.ais_shape is not None:
|
||||||
|
self._context.Remove(obj.ais_shape, True)
|
||||||
|
if obj.obj_id in self._objects:
|
||||||
|
del self._objects[obj.obj_id]
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def remove_mesh(self, obj_id: str) -> None:
|
||||||
|
"""Remove an object by ID (legacy compatibility)."""
|
||||||
|
obj = self._objects.get(obj_id)
|
||||||
|
if obj is not None:
|
||||||
|
self.remove_object(obj)
|
||||||
|
|
||||||
|
def set_visibility(self, obj_id: str, visible: bool) -> bool:
|
||||||
|
"""Show or hide an object by ID, preserving its place in the scene.
|
||||||
|
|
||||||
|
Unlike ``remove_mesh``, this doesn't free the object — the user can
|
||||||
|
toggle it back on later. Returns True on success, False if the
|
||||||
|
object isn't found (e.g. it was already removed).
|
||||||
|
"""
|
||||||
|
obj = self._objects.get(obj_id)
|
||||||
|
if obj is None:
|
||||||
|
return False
|
||||||
|
self.set_object_visible(obj, visible)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def set_object_transparency(self, obj_id: str, transparency: float) -> bool:
|
||||||
|
"""Set the transparency of an object by ID (0.0 opaque..1.0 invisible).
|
||||||
|
|
||||||
|
Used by the live extrude preview to dim the existing target body
|
||||||
|
so the user can see the previewed result through/over it. Returns
|
||||||
|
True on success, False if the object isn't found.
|
||||||
|
"""
|
||||||
|
obj = self._objects.get(obj_id)
|
||||||
|
if obj is None or obj.ais_shape is None:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
obj.ais_shape.SetTransparency(transparency)
|
||||||
|
self._context.RecomputePrsOnly(obj.ais_shape, True)
|
||||||
|
except Exception:
|
||||||
|
logger.debug("set_object_transparency failed", exc_info=True)
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
# ─── Live preview (extrude/cut preview) ──────────────────────────────
|
||||||
|
|
||||||
|
_PREVIEW_ID = "__extrude_preview__"
|
||||||
|
|
||||||
|
def preview_shape(
|
||||||
|
self,
|
||||||
|
shape: Any,
|
||||||
|
color: Optional[Tuple[float, float, float]] = None,
|
||||||
|
transparency: float = 0.60,
|
||||||
|
) -> None:
|
||||||
|
"""Display a temporary transparent preview of *shape* (TopoDS_Shape).
|
||||||
|
|
||||||
|
The preview lives under a fixed id (``__extrude_preview__``) so a
|
||||||
|
subsequent call replaces the previous preview in place. Call
|
||||||
|
:meth:`clear_preview` to remove it. This is independent of the
|
||||||
|
tracked ``_objects`` dict — the preview is NOT a body and won't
|
||||||
|
be returned by ``pick_planar_face``'s owner scan.
|
||||||
|
"""
|
||||||
|
if self._context is None:
|
||||||
|
return
|
||||||
|
# Clear any previous preview (uses the same id).
|
||||||
|
self.clear_preview()
|
||||||
|
from OCP.AIS import AIS_Shape
|
||||||
|
from OCP.Quantity import Quantity_Color, Quantity_TOC_RGB
|
||||||
|
|
||||||
|
ais = AIS_Shape(shape)
|
||||||
|
try:
|
||||||
|
ais.SetMaterial(self._default_material())
|
||||||
|
except Exception:
|
||||||
|
logger.debug("preview material set failed", exc_info=True)
|
||||||
|
col = color or (0.45, 0.80, 0.95) # cyan-ish for preview
|
||||||
|
ais.SetColor(Quantity_Color(*col, Quantity_TOC_RGB))
|
||||||
|
ais.SetDisplayMode(1) # shaded
|
||||||
|
try:
|
||||||
|
ais.SetTransparency(transparency)
|
||||||
|
except Exception:
|
||||||
|
logger.debug("preview transparency set failed", exc_info=True)
|
||||||
|
# Draw face boundaries so the preview shape reads cleanly.
|
||||||
|
try:
|
||||||
|
drawer = ais.Attributes()
|
||||||
|
drawer.SetFaceBoundaryDraw(True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self._context.Display(ais, True)
|
||||||
|
self._preview_ais = ais
|
||||||
|
if self._view is not None:
|
||||||
|
self._view.Repaint()
|
||||||
|
|
||||||
|
def clear_preview(self) -> None:
|
||||||
|
"""Remove the live extrude preview shape."""
|
||||||
|
if self._context is None or getattr(self, "_preview_ais", None) is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
self._context.Remove(self._preview_ais, True)
|
||||||
|
finally:
|
||||||
|
self._preview_ais = None
|
||||||
|
|
||||||
|
def clear_scene(self) -> None:
|
||||||
|
"""Remove all objects from the scene."""
|
||||||
|
if self._context is None:
|
||||||
|
return
|
||||||
|
self.clear_preview()
|
||||||
|
self.clear_face_highlight()
|
||||||
|
# Remove every displayed AIS object. ``RemoveAll`` is the cleanest
|
||||||
|
# path; fall back to iterating the displayed list if unavailable.
|
||||||
|
try:
|
||||||
|
self._context.RemoveAll(True)
|
||||||
|
except Exception:
|
||||||
|
from OCP.AIS import AIS_ListOfInteractive, AIS_KindOfInteractive
|
||||||
|
lst = AIS_ListOfInteractive()
|
||||||
|
self._context.DisplayedObjects(AIS_KindOfInteractive.AIS_KOI_None, -1, lst)
|
||||||
|
for ais in lst:
|
||||||
|
self._context.Remove(ais, True)
|
||||||
|
self._objects.clear()
|
||||||
|
|
||||||
|
def update_mesh(
|
||||||
|
self,
|
||||||
|
obj: OCCRenderObject,
|
||||||
|
vertices: np.ndarray,
|
||||||
|
faces: np.ndarray,
|
||||||
|
) -> bool:
|
||||||
|
"""Update mesh geometry (not supported for OCC shapes; re-add instead)."""
|
||||||
|
logger.warning("update_mesh not supported for OCC shapes — remove + re-add")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# ─── Display properties ────────────────────────────────────────────
|
||||||
|
|
||||||
|
def set_object_color(
|
||||||
|
self,
|
||||||
|
obj: OCCRenderObject,
|
||||||
|
color: Tuple[float, float, float],
|
||||||
|
) -> None:
|
||||||
|
"""Set the colour of an object."""
|
||||||
|
from OCP.Quantity import Quantity_Color, Quantity_TOC_RGB
|
||||||
|
|
||||||
|
if obj.ais_shape is not None:
|
||||||
|
qcol = Quantity_Color(*color, Quantity_TOC_RGB)
|
||||||
|
obj.ais_shape.SetColor(qcol)
|
||||||
|
self._context.RecomputePrsOnly(obj.ais_shape, True)
|
||||||
|
|
||||||
|
def set_object_visible(self, obj: OCCRenderObject, visible: bool) -> None:
|
||||||
|
"""Show or hide an object."""
|
||||||
|
if obj.ais_shape is not None:
|
||||||
|
if visible:
|
||||||
|
self._context.Display(obj.ais_shape, True)
|
||||||
|
else:
|
||||||
|
self._context.Erase(obj.ais_shape, True)
|
||||||
|
|
||||||
|
# ─── Camera ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def set_camera_position(
|
||||||
|
self,
|
||||||
|
position: Tuple[float, float, float],
|
||||||
|
target: Tuple[float, float, float] = (0, 0, 0),
|
||||||
|
up: Tuple[float, float, float] = (0, 0, 1),
|
||||||
|
) -> None:
|
||||||
|
"""Set camera eye, target (at) and up vectors."""
|
||||||
|
if self._view is None:
|
||||||
|
return
|
||||||
|
self._view.SetEye(float(position[0]), float(position[1]), float(position[2]))
|
||||||
|
self._view.SetAt(float(target[0]), float(target[1]), float(target[2]))
|
||||||
|
self._view.SetUp(float(up[0]), float(up[1]), float(up[2]))
|
||||||
|
self._view.ZFitAll()
|
||||||
|
self._view.Redraw()
|
||||||
|
|
||||||
|
def get_camera_position(self) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
|
||||||
|
"""Get camera position, target, and up vector."""
|
||||||
|
if self._view is None:
|
||||||
|
return (
|
||||||
|
np.array([1, 1, 1]),
|
||||||
|
np.array([0, 0, 0]),
|
||||||
|
np.array([0, 0, 1]),
|
||||||
|
)
|
||||||
|
eye = self._view.Eye()
|
||||||
|
at = self._view.At()
|
||||||
|
up = self._view.Up()
|
||||||
|
|
||||||
|
def _xyz(v):
|
||||||
|
if isinstance(v, (tuple, list)):
|
||||||
|
return np.array([float(v[0]), float(v[1]), float(v[2])])
|
||||||
|
return np.array([v.X(), v.Y(), v.Z()])
|
||||||
|
|
||||||
|
return (_xyz(eye), _xyz(at), _xyz(up))
|
||||||
|
|
||||||
|
def fit_camera(self, padding: float = 0.05) -> None:
|
||||||
|
"""Fit camera to show all displayed objects.
|
||||||
|
|
||||||
|
*padding* is the margin coefficient (0 ≤ padding < 1.0) passed to
|
||||||
|
OCC's ``FitAll``. A small value like 0.05 adds 5% margin.
|
||||||
|
"""
|
||||||
|
if self._view is None:
|
||||||
|
return
|
||||||
|
margin = max(0.0, min(padding, 0.99))
|
||||||
|
self._view.FitAll(margin)
|
||||||
|
self._view.ZFitAll()
|
||||||
|
|
||||||
|
def set_view_orientation(self, orientation: str = "iso") -> None:
|
||||||
|
"""Snap the camera to a standard CAD view.
|
||||||
|
|
||||||
|
orientation ∈ {"front","back","top","bottom","left","right","iso"}.
|
||||||
|
Preserves the current target and distance, then refits.
|
||||||
|
"""
|
||||||
|
if self._view is None:
|
||||||
|
return
|
||||||
|
from OCP.V3d import V3d_TypeOfOrientation
|
||||||
|
|
||||||
|
mapping = {
|
||||||
|
"front": V3d_TypeOfOrientation.V3d_Yneg,
|
||||||
|
"back": V3d_TypeOfOrientation.V3d_Ypos,
|
||||||
|
"top": V3d_TypeOfOrientation.V3d_Zpos,
|
||||||
|
"bottom": V3d_TypeOfOrientation.V3d_Zneg,
|
||||||
|
"left": V3d_TypeOfOrientation.V3d_Xneg,
|
||||||
|
"right": V3d_TypeOfOrientation.V3d_Xpos,
|
||||||
|
"iso": V3d_TypeOfOrientation.V3d_XposYnegZpos,
|
||||||
|
}
|
||||||
|
orient = mapping.get(orientation.lower())
|
||||||
|
if orient is None:
|
||||||
|
logger.warning(f"unknown view orientation: {orientation}")
|
||||||
|
return
|
||||||
|
self._view.SetProj(orient)
|
||||||
|
self.fit_camera()
|
||||||
|
self._view.Redraw()
|
||||||
|
|
||||||
|
def reset_camera(self) -> None:
|
||||||
|
"""Reset to the default isometric view and fit all."""
|
||||||
|
self.set_view_orientation("iso")
|
||||||
|
|
||||||
|
def set_camera_perspective(
|
||||||
|
self, fov: float = 45.0, near: float = 0.1, far: float = 100000.0
|
||||||
|
) -> None:
|
||||||
|
"""Switch to a perspective camera with the given FOV (degrees).
|
||||||
|
|
||||||
|
Near/far planes are auto-fit by OCC (``ZFitAll``) — the *near*/*far*
|
||||||
|
args are accepted for API compatibility but ignored.
|
||||||
|
"""
|
||||||
|
if self._view is None:
|
||||||
|
return
|
||||||
|
from OCP.Graphic3d import Graphic3d_Camera
|
||||||
|
|
||||||
|
cam = self._view.Camera()
|
||||||
|
cam.SetProjectionType(Graphic3d_Camera.Projection_Perspective)
|
||||||
|
cam.SetFOVy(fov)
|
||||||
|
self._view.ZFitAll()
|
||||||
|
self._view.Redraw()
|
||||||
|
|
||||||
|
def set_camera_orthographic(
|
||||||
|
self, width: float = 100.0, near: float = 0.1, far: float = 100000.0
|
||||||
|
) -> None:
|
||||||
|
"""Switch to an orthographic camera."""
|
||||||
|
if self._view is None:
|
||||||
|
return
|
||||||
|
from OCP.Graphic3d import Graphic3d_Camera
|
||||||
|
|
||||||
|
cam = self._view.Camera()
|
||||||
|
cam.SetProjectionType(Graphic3d_Camera.Projection_Orthographic)
|
||||||
|
self._view.ZFitAll()
|
||||||
|
self._view.Redraw()
|
||||||
|
|
||||||
|
# ─── Rendering ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def render(self) -> None:
|
||||||
|
"""Redraw the OCC view."""
|
||||||
|
if self._view is None:
|
||||||
|
return
|
||||||
|
self._view.Redraw()
|
||||||
|
|
||||||
|
def screenshot(self, width: int, height: int) -> bytes:
|
||||||
|
"""Take a screenshot."""
|
||||||
|
return b""
|
||||||
|
|
||||||
|
# ─── Stub implementations for remaining abstract methods ──────────
|
||||||
|
|
||||||
|
def add_axes(self, size: float = 100.0) -> None:
|
||||||
|
"""Add coordinate axes."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
def add_grid(self, size: float = 100.0) -> None:
|
||||||
|
"""Add a reference grid."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
def get_screen_size(self) -> Tuple[int, int]:
|
||||||
|
if self._parent_widget:
|
||||||
|
return self._parent_widget.width(), self._parent_widget.height()
|
||||||
|
return (800, 600)
|
||||||
|
|
||||||
|
def on_camera_change(self, callback: Any) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def on_pick(self, callback: Any) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def project_to_screen(
|
||||||
|
self, point: Tuple[float, float, float]
|
||||||
|
) -> Tuple[float, float]:
|
||||||
|
return (0.0, 0.0)
|
||||||
|
|
||||||
|
def save_screenshot(self, path: str, width: int = 1920, height: int = 1080) -> None:
|
||||||
|
logger.warning("save_screenshot not implemented for OCCRenderer")
|
||||||
|
|
||||||
|
def set_background_color(self, color: Tuple[float, float, float]) -> None:
|
||||||
|
if self._view is None:
|
||||||
|
return
|
||||||
|
from OCP.Quantity import Quantity_Color, Quantity_TOC_RGB
|
||||||
|
qcol = Quantity_Color(*color, Quantity_TOC_RGB)
|
||||||
|
self._view.SetBackgroundColor(qcol)
|
||||||
|
|
||||||
|
def take_screenshot(self) -> bytes:
|
||||||
|
return b""
|
||||||
|
|
||||||
|
def unproject_from_screen(
|
||||||
|
self, x: float, y: float
|
||||||
|
) -> Tuple[float, float, float]:
|
||||||
|
return (0.0, 0.0, 0.0)
|
||||||
|
|
||||||
|
# ─── Face picking (for sketch-on-surface) ────────────────────────────
|
||||||
|
|
||||||
|
def pick_planar_face(self, x: int, y: int) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Pick the planar face under screen pixel (x, y).
|
||||||
|
|
||||||
|
Returns a dict with ``origin``, ``normal``, ``x_dir`` (a stable
|
||||||
|
in-plane axis) and ``face`` (the ``TopoDS_Face``), or *None* if the
|
||||||
|
hit is not a planar face. The plane is derived from the face's
|
||||||
|
underlying surface via ``BRepAdaptor_Surface``; *x_dir* is taken
|
||||||
|
from the face's first edge direction so the UV frame is aligned to
|
||||||
|
the face geometry.
|
||||||
|
"""
|
||||||
|
if self._view is None or self._context is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
from OCP.BRepAdaptor import BRepAdaptor_Surface
|
||||||
|
from OCP.GeomAbs import GeomAbs_Plane
|
||||||
|
from OCP.TopoDS import TopoDS_Face, TopoDS
|
||||||
|
from OCP.TopExp import TopExp_Explorer
|
||||||
|
from OCP.TopAbs import TopAbs_EDGE, TopAbs_FACE
|
||||||
|
from OCP.BRep import BRep_Tool
|
||||||
|
from OCP.gp import gp_Pln, gp_Dir, gp_Pnt
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
# Detect what's under the cursor.
|
||||||
|
self._context.MoveTo(x, y, self._view, True)
|
||||||
|
if not self._context.HasDetected():
|
||||||
|
return None
|
||||||
|
|
||||||
|
shape = self._context.DetectedShape()
|
||||||
|
if shape is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# The detected sub-shape is returned as a TopoDS_Shape; with face
|
||||||
|
# selection mode active it downcasts to a TopoDS_Face. Try the
|
||||||
|
# downcast; if it isn't a face, give up (non-planar/edge/vertex hits).
|
||||||
|
face = None
|
||||||
|
try:
|
||||||
|
candidate = TopoDS.Face_s(shape)
|
||||||
|
# Verify it really is a face by building an adaptor.
|
||||||
|
_ = BRepAdaptor_Surface(candidate)
|
||||||
|
face = candidate
|
||||||
|
except Exception:
|
||||||
|
face = None
|
||||||
|
if face is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
adaptor = BRepAdaptor_Surface(face)
|
||||||
|
if adaptor.GetType() != GeomAbs_Plane:
|
||||||
|
return None # non-planar faces can't host a flat UV sketch
|
||||||
|
pln: gp_Pln = adaptor.Plane()
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Outward normal: the plane's geometric axis is independent of the
|
||||||
|
# face's orientation (TopAbs_FORWARD / TopAbs_REVERSED). For a face
|
||||||
|
# on a solid the TRUE outward normal is the axis when FORWARD and its
|
||||||
|
# negation when REVERSED. Without this correction a top face whose
|
||||||
|
# axis happens to point inward would return an inward normal, so a
|
||||||
|
# default (non-inverted) extrude would punch back into the body
|
||||||
|
# instead of building outward on top of it.
|
||||||
|
from OCP.TopAbs import TopAbs_REVERSED
|
||||||
|
n = pln.Axis().Direction()
|
||||||
|
if face.Orientation() == TopAbs_REVERSED:
|
||||||
|
n = n.Reversed()
|
||||||
|
|
||||||
|
# Plane origin: use the face's bounding-box centre projected onto the
|
||||||
|
# plane, so the UV frame is centred on the face (nicer for sketching).
|
||||||
|
from OCP.Bnd import Bnd_Box
|
||||||
|
from OCP.BRepBndLib import BRepBndLib
|
||||||
|
bbox = Bnd_Box()
|
||||||
|
BRepBndLib.Add_s(face, bbox)
|
||||||
|
xmin, ymin, zmin, xmax, ymax, zmax = bbox.Get()
|
||||||
|
cx, cy, cz = (xmin + xmax) / 2.0, (ymin + ymax) / 2.0, (zmin + zmax) / 2.0
|
||||||
|
# Project the bbox centre onto the plane.
|
||||||
|
pln_origin = pln.Location() # gp_Pnt
|
||||||
|
nx, ny, nz = n.X(), n.Y(), n.Z()
|
||||||
|
# signed distance from bbox centre to plane
|
||||||
|
d = (cx - pln_origin.X()) * nx + (cy - pln_origin.Y()) * ny + (cz - pln_origin.Z()) * nz
|
||||||
|
origin = (cx - d * nx, cy - d * ny, cz - d * nz)
|
||||||
|
|
||||||
|
# x_dir: direction of the face's first edge (stable, in-plane).
|
||||||
|
x_dir = None
|
||||||
|
try:
|
||||||
|
from OCP.TopExp import TopExp
|
||||||
|
from OCP.BRep import BRep_Tool
|
||||||
|
expl = TopExp_Explorer(face, TopAbs_EDGE)
|
||||||
|
if expl.More():
|
||||||
|
edge = TopoDS.Edge_s(expl.Current())
|
||||||
|
v1 = TopExp.FirstVertex_s(edge, True)
|
||||||
|
v2 = TopExp.LastVertex_s(edge, True)
|
||||||
|
p1 = BRep_Tool.Pnt_s(v1)
|
||||||
|
p2 = BRep_Tool.Pnt_s(v2)
|
||||||
|
ex, ey, ez = p2.X() - p1.X(), p2.Y() - p1.Y(), p2.Z() - p1.Z()
|
||||||
|
elen = (ex * ex + ey * ey + ez * ez) ** 0.5
|
||||||
|
if elen > 1e-9:
|
||||||
|
x_dir = (ex / elen, ey / elen, ez / elen)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if x_dir is None:
|
||||||
|
# Fall back to the plane's own X axis.
|
||||||
|
px = pln.XAxis().Direction()
|
||||||
|
x_dir = (px.X(), px.Y(), px.Z())
|
||||||
|
|
||||||
|
# Identify the displayed body that owns this face, so the host can
|
||||||
|
# auto-target it as the cut/union body when the user extrudes the
|
||||||
|
# sketch-on-face. ``DetectedInteractive`` returns the AIS_
|
||||||
|
# InteractiveObject that the picked sub-shape belongs to; we match
|
||||||
|
# it against the renderer's tracked objects by AIS identity.
|
||||||
|
owner_obj_id: Optional[str] = None
|
||||||
|
try:
|
||||||
|
owner_ais = self._context.DetectedInteractive()
|
||||||
|
except Exception:
|
||||||
|
owner_ais = None
|
||||||
|
if owner_ais is not None:
|
||||||
|
for oid, robj in self._objects.items():
|
||||||
|
if robj.ais_shape is owner_ais:
|
||||||
|
owner_obj_id = oid
|
||||||
|
break
|
||||||
|
|
||||||
|
return {
|
||||||
|
"origin": origin,
|
||||||
|
"normal": (nx, ny, nz),
|
||||||
|
"x_dir": x_dir,
|
||||||
|
"face": face,
|
||||||
|
"owner_obj_id": owner_obj_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
def highlight_face(self, face: Any) -> None:
|
||||||
|
"""Overlay a persistent, mostly-transparent light-blue tint on *face*.
|
||||||
|
|
||||||
|
Used to mark the planar face the user has selected for sketch-on-
|
||||||
|
surface. The overlay is an independent ``AIS_Shape`` so it survives
|
||||||
|
until :meth:`clear_face_highlight` is called.
|
||||||
|
"""
|
||||||
|
if self._context is None:
|
||||||
|
return
|
||||||
|
self.clear_face_highlight()
|
||||||
|
from OCP.AIS import AIS_Shape
|
||||||
|
from OCP.Quantity import Quantity_Color, Quantity_TOC_RGB
|
||||||
|
|
||||||
|
ais = AIS_Shape(face)
|
||||||
|
try:
|
||||||
|
ais.SetMaterial(self._default_material())
|
||||||
|
except Exception:
|
||||||
|
logger.debug("highlight material set failed", exc_info=True)
|
||||||
|
ais.SetColor(Quantity_Color(0.45, 0.75, 1.0, Quantity_TOC_RGB))
|
||||||
|
ais.SetDisplayMode(1) # shaded — tint the whole face, not just edges
|
||||||
|
try:
|
||||||
|
# Mostly transparent so the underlying face stays visible.
|
||||||
|
ais.SetTransparency(0.78)
|
||||||
|
except Exception:
|
||||||
|
logger.debug("highlight transparency set failed", exc_info=True)
|
||||||
|
try:
|
||||||
|
# Bias the overlay slightly toward the camera so it draws on top
|
||||||
|
# of the coincident face surface without z-fighting.
|
||||||
|
# mode 3 = Graphic3d_POM_Fill; negative units pull forward.
|
||||||
|
ais.SetPolygonOffsets(3, 1.0, -0.5)
|
||||||
|
except Exception:
|
||||||
|
logger.debug("highlight polygon offset failed", exc_info=True)
|
||||||
|
|
||||||
|
self._context.Display(ais, True)
|
||||||
|
self._highlight_ais = ais
|
||||||
|
if self._view is not None:
|
||||||
|
self._view.Update()
|
||||||
|
|
||||||
|
def clear_face_highlight(self) -> None:
|
||||||
|
"""Remove the persistent face-selection overlay, if any."""
|
||||||
|
if self._context is None or self._highlight_ais is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
self._context.Remove(self._highlight_ais, True)
|
||||||
|
except Exception:
|
||||||
|
logger.debug("clear_face_highlight remove failed", exc_info=True)
|
||||||
|
self._highlight_ais = None
|
||||||
|
|
||||||
|
# ─── Mouse / keyboard event forwarding ──────────────────────────────
|
||||||
|
#
|
||||||
|
# CAD-style navigation:
|
||||||
|
# • Left button drag → orbit (rotate around target)
|
||||||
|
# • Middle button drag → pan
|
||||||
|
# • Right button → (reserved for future use)
|
||||||
|
# • Wheel → zoom toward cursor
|
||||||
|
# • Double-click left → fit all (handled by the widget)
|
||||||
|
|
||||||
|
def _qt_buttons(self, event) -> Any:
|
||||||
|
"""Return the PySide6 Qt enum module lazily."""
|
||||||
|
from PySide6.QtCore import Qt
|
||||||
|
return Qt
|
||||||
|
|
||||||
|
def handle_mouse_press(self, event) -> None:
|
||||||
|
"""Begin an orbit / pan / zoom gesture based on the pressed button."""
|
||||||
|
if self._view is None or self._context is None:
|
||||||
|
return
|
||||||
|
Qt = self._qt_buttons(event)
|
||||||
|
x, y = event.x(), event.y()
|
||||||
|
btn = event.button()
|
||||||
|
|
||||||
|
if btn == Qt.LeftButton:
|
||||||
|
self._nav_mode = "rotate"
|
||||||
|
# zRotationThreshold=0.4 enables screen-Z spin outside the inner
|
||||||
|
# circle, matching the FreeCAD/OCC viewer feel.
|
||||||
|
self._view.StartRotation(x, y, 0.4)
|
||||||
|
elif btn == Qt.MiddleButton:
|
||||||
|
self._nav_mode = "pan"
|
||||||
|
# Record the gesture start; OCC's Pan(..., Start=False) expects
|
||||||
|
# deltas CUMULATIVE from this point, not per-frame deltas.
|
||||||
|
self._pan_start_x = x
|
||||||
|
self._pan_start_y = y
|
||||||
|
self._view.Pan(0, 0, 1.0, True)
|
||||||
|
else:
|
||||||
|
# Right button (and any other) is reserved — no gesture yet.
|
||||||
|
self._nav_mode = None
|
||||||
|
|
||||||
|
self._last_mouse_x = x
|
||||||
|
self._last_mouse_y = y
|
||||||
|
|
||||||
|
def handle_mouse_move(self, event) -> None:
|
||||||
|
"""Continue the active gesture; otherwise just hover-detect."""
|
||||||
|
if self._view is None or self._context is None:
|
||||||
|
return
|
||||||
|
Qt = self._qt_buttons(event)
|
||||||
|
x, y = event.x(), event.y()
|
||||||
|
buttons = event.buttons()
|
||||||
|
|
||||||
|
if self._nav_mode == "rotate" and (buttons & Qt.LeftButton):
|
||||||
|
self._view.Rotation(x, y)
|
||||||
|
elif self._nav_mode == "pan" and (buttons & Qt.MiddleButton):
|
||||||
|
# Cumulative delta from the gesture start — OCC interprets
|
||||||
|
# Pan(..., Start=False) as an absolute offset from the start point.
|
||||||
|
dx = x - self._pan_start_x
|
||||||
|
dy = y - self._pan_start_y
|
||||||
|
# dy negated because Qt y grows downward while OCC y grows upward.
|
||||||
|
self._view.Pan(dx, -dy, 1.0, False)
|
||||||
|
else:
|
||||||
|
# Idle: dynamic highlighting under the cursor.
|
||||||
|
self._context.MoveTo(x, y, self._view, True)
|
||||||
|
|
||||||
|
self._last_mouse_x = x
|
||||||
|
self._last_mouse_y = y
|
||||||
|
|
||||||
|
def handle_mouse_release(self, event) -> None:
|
||||||
|
"""End the active gesture."""
|
||||||
|
Qt = self._qt_buttons(event)
|
||||||
|
if event.button() in (Qt.LeftButton, Qt.MiddleButton, Qt.RightButton):
|
||||||
|
self._nav_mode = None
|
||||||
|
|
||||||
|
def handle_wheel(self, event) -> None:
|
||||||
|
"""Zoom toward the cursor on scroll."""
|
||||||
|
if self._view is None:
|
||||||
|
return
|
||||||
|
# Qt6: QWheelEvent has no .x()/.y(); use position().toPoint().
|
||||||
|
pos = event.position().toPoint() if hasattr(event, "position") else event.pos()
|
||||||
|
x, y = pos.x(), pos.y()
|
||||||
|
delta = event.angleDelta().y()
|
||||||
|
if delta == 0:
|
||||||
|
return
|
||||||
|
# ZoomAtPoint(startX, startY, endX, endY): move the cursor anchor
|
||||||
|
# by a few pixels to drive a smooth zoom centered on the pointer.
|
||||||
|
step = 30 if delta > 0 else -30
|
||||||
|
self._view.ZoomAtPoint(x, y, x, y - step)
|
||||||
|
self._view.ZFitAll()
|
||||||
|
|
||||||
|
def handle_resize(self, w: int, h: int) -> None:
|
||||||
|
"""Resize the OCC view when the widget is resized."""
|
||||||
|
if self._window is not None:
|
||||||
|
self._window.SetSize(w, h)
|
||||||
|
if self._view is not None:
|
||||||
|
self._view.MustBeResized()
|
||||||
|
self._view.Redraw()
|
||||||
@@ -55,9 +55,17 @@ class PygfxRenderObject(RenderObject):
|
|||||||
geometry: Any = None
|
geometry: Any = None
|
||||||
material: Any = None
|
material: Any = None
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
def __init__(
|
||||||
if self.scene_node is not None:
|
self,
|
||||||
self._scene_node = self.scene_node
|
scene_node: Any = None,
|
||||||
|
geometry: Any = None,
|
||||||
|
material: Any = None,
|
||||||
|
name: Optional[str] = None,
|
||||||
|
):
|
||||||
|
super().__init__(name=name)
|
||||||
|
self.scene_node = scene_node
|
||||||
|
self.geometry = geometry
|
||||||
|
self.material = material
|
||||||
|
|
||||||
|
|
||||||
class PygfxRenderer(Renderer):
|
class PygfxRenderer(Renderer):
|
||||||
@@ -84,17 +92,18 @@ class PygfxRenderer(Renderer):
|
|||||||
"""Initialize pygfx with Qt widget."""
|
"""Initialize pygfx with Qt widget."""
|
||||||
try:
|
try:
|
||||||
import pygfx as gfx
|
import pygfx as gfx
|
||||||
from wgpu.gui.qt import WgpuCanvas
|
from rendercanvas.qt import RenderWidget
|
||||||
from PySide6.QtWidgets import QVBoxLayout
|
from PySide6.QtWidgets import QVBoxLayout
|
||||||
|
|
||||||
self._canvas = WgpuCanvas(parent=parent_widget)
|
self._canvas = RenderWidget(parent=parent_widget)
|
||||||
self._renderer = gfx.renderers.WgpuRenderer(self._canvas)
|
self._renderer = gfx.renderers.WgpuRenderer(self._canvas)
|
||||||
self._scene = gfx.Scene()
|
self._scene = gfx.Scene()
|
||||||
|
|
||||||
self._camera = gfx.PerspectiveCamera(50, 16 / 9)
|
self._camera = gfx.PerspectiveCamera(50, 16 / 9)
|
||||||
self._camera.position.set(100, 100, 100)
|
self._camera.local.position = (100, 100, 100)
|
||||||
|
|
||||||
self._controller = gfx.OrbitController(self._camera, register_events=self._renderer)
|
self._controller = gfx.OrbitController(self._camera)
|
||||||
|
self._controller.register_events(self._renderer)
|
||||||
|
|
||||||
self._setup_lighting()
|
self._setup_lighting()
|
||||||
self._add_grid()
|
self._add_grid()
|
||||||
@@ -103,6 +112,8 @@ class PygfxRenderer(Renderer):
|
|||||||
layout.setContentsMargins(0, 0, 0, 0)
|
layout.setContentsMargins(0, 0, 0, 0)
|
||||||
layout.addWidget(self._canvas)
|
layout.addWidget(self._canvas)
|
||||||
|
|
||||||
|
self._canvas.request_draw(self._animate)
|
||||||
|
|
||||||
self._setup_picking()
|
self._setup_picking()
|
||||||
self._initialized = True
|
self._initialized = True
|
||||||
|
|
||||||
@@ -110,8 +121,16 @@ class PygfxRenderer(Renderer):
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Failed to initialize pygfx: {e}")
|
print(f"Failed to initialize pygfx: {e}")
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
traceback.print_exc()
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def _animate(self):
|
||||||
|
"""Animation callback for the canvas."""
|
||||||
|
if self._initialized:
|
||||||
|
self._renderer.render(self._scene, self._camera)
|
||||||
|
|
||||||
def _setup_lighting(self) -> None:
|
def _setup_lighting(self) -> None:
|
||||||
"""Setup scene lighting."""
|
"""Setup scene lighting."""
|
||||||
import pygfx as gfx
|
import pygfx as gfx
|
||||||
@@ -120,11 +139,11 @@ class PygfxRenderer(Renderer):
|
|||||||
self._scene.add(ambient)
|
self._scene.add(ambient)
|
||||||
|
|
||||||
directional = gfx.DirectionalLight(intensity=1.0)
|
directional = gfx.DirectionalLight(intensity=1.0)
|
||||||
directional.position.set(100, 100, 100)
|
directional.local.position = (100, 100, 100)
|
||||||
self._scene.add(directional)
|
self._scene.add(directional)
|
||||||
|
|
||||||
fill = gfx.DirectionalLight(intensity=0.5)
|
fill = gfx.DirectionalLight(intensity=0.5)
|
||||||
fill.position.set(-100, 50, 50)
|
fill.local.position = (-100, 50, 50)
|
||||||
self._scene.add(fill)
|
self._scene.add(fill)
|
||||||
|
|
||||||
def _add_grid(self) -> None:
|
def _add_grid(self) -> None:
|
||||||
@@ -151,9 +170,10 @@ class PygfxRenderer(Renderer):
|
|||||||
faces: np.ndarray,
|
faces: np.ndarray,
|
||||||
color: Tuple[float, float, float] = (0.2, 0.4, 0.8),
|
color: Tuple[float, float, float] = (0.2, 0.4, 0.8),
|
||||||
name: Optional[str] = None,
|
name: Optional[str] = None,
|
||||||
) -> PygfxRenderObject:
|
) -> str:
|
||||||
"""Add a mesh to the scene."""
|
"""Add a mesh to the scene. Returns the mesh ID."""
|
||||||
import pygfx as gfx
|
import pygfx as gfx
|
||||||
|
import uuid
|
||||||
|
|
||||||
vertices = np.asarray(vertices, dtype=np.float32)
|
vertices = np.asarray(vertices, dtype=np.float32)
|
||||||
faces = np.asarray(faces, dtype=np.int32)
|
faces = np.asarray(faces, dtype=np.int32)
|
||||||
@@ -167,11 +187,12 @@ class PygfxRenderer(Renderer):
|
|||||||
mesh = gfx.Mesh(geometry, material)
|
mesh = gfx.Mesh(geometry, material)
|
||||||
self._scene.add(mesh)
|
self._scene.add(mesh)
|
||||||
|
|
||||||
obj = PygfxRenderObject(name=name, scene_node=mesh, geometry=geometry, material=material)
|
mesh_id = name or f"mesh_{uuid.uuid4().hex[:8]}"
|
||||||
|
obj = PygfxRenderObject(name=mesh_id, scene_node=mesh, geometry=geometry, material=material)
|
||||||
obj.color = RenderColor(*color)
|
obj.color = RenderColor(*color)
|
||||||
self._objects.append(obj)
|
self._objects.append(obj)
|
||||||
|
|
||||||
return obj
|
return mesh_id
|
||||||
|
|
||||||
def add_wireframe(
|
def add_wireframe(
|
||||||
self,
|
self,
|
||||||
@@ -180,9 +201,10 @@ class PygfxRenderer(Renderer):
|
|||||||
color: Tuple[float, float, float] = (1.0, 1.0, 1.0),
|
color: Tuple[float, float, float] = (1.0, 1.0, 1.0),
|
||||||
line_width: float = 1.0,
|
line_width: float = 1.0,
|
||||||
name: Optional[str] = None,
|
name: Optional[str] = None,
|
||||||
) -> PygfxRenderObject:
|
) -> str:
|
||||||
"""Add a wireframe to the scene."""
|
"""Add a wireframe to the scene. Returns the wireframe ID."""
|
||||||
import pygfx as gfx
|
import pygfx as gfx
|
||||||
|
import uuid
|
||||||
|
|
||||||
positions: List[List[float]] = []
|
positions: List[List[float]] = []
|
||||||
for edge in edges:
|
for edge in edges:
|
||||||
@@ -197,11 +219,14 @@ class PygfxRenderer(Renderer):
|
|||||||
lines = gfx.Line(geometry, material)
|
lines = gfx.Line(geometry, material)
|
||||||
self._scene.add(lines)
|
self._scene.add(lines)
|
||||||
|
|
||||||
obj = PygfxRenderObject(name=name, scene_node=lines, geometry=geometry, material=material)
|
wireframe_id = name or f"wireframe_{uuid.uuid4().hex[:8]}"
|
||||||
|
obj = PygfxRenderObject(
|
||||||
|
name=wireframe_id, scene_node=lines, geometry=geometry, material=material
|
||||||
|
)
|
||||||
obj.color = RenderColor(*color)
|
obj.color = RenderColor(*color)
|
||||||
self._objects.append(obj)
|
self._objects.append(obj)
|
||||||
|
|
||||||
return obj
|
return wireframe_id
|
||||||
|
|
||||||
def add_points(
|
def add_points(
|
||||||
self,
|
self,
|
||||||
@@ -209,9 +234,10 @@ class PygfxRenderer(Renderer):
|
|||||||
color: Tuple[float, float, float] = (1.0, 0.0, 0.0),
|
color: Tuple[float, float, float] = (1.0, 0.0, 0.0),
|
||||||
size: float = 5.0,
|
size: float = 5.0,
|
||||||
name: Optional[str] = None,
|
name: Optional[str] = None,
|
||||||
) -> PygfxRenderObject:
|
) -> str:
|
||||||
"""Add points to the scene."""
|
"""Add points to the scene. Returns the points ID."""
|
||||||
import pygfx as gfx
|
import pygfx as gfx
|
||||||
|
import uuid
|
||||||
|
|
||||||
points_arr = np.asarray(points, dtype=np.float32)
|
points_arr = np.asarray(points, dtype=np.float32)
|
||||||
|
|
||||||
@@ -221,13 +247,14 @@ class PygfxRenderer(Renderer):
|
|||||||
points_obj = gfx.Points(geometry, material)
|
points_obj = gfx.Points(geometry, material)
|
||||||
self._scene.add(points_obj)
|
self._scene.add(points_obj)
|
||||||
|
|
||||||
|
points_id = name or f"points_{uuid.uuid4().hex[:8]}"
|
||||||
obj = PygfxRenderObject(
|
obj = PygfxRenderObject(
|
||||||
name=name, scene_node=points_obj, geometry=geometry, material=material
|
name=points_id, scene_node=points_obj, geometry=geometry, material=material
|
||||||
)
|
)
|
||||||
obj.color = RenderColor(*color)
|
obj.color = RenderColor(*color)
|
||||||
self._objects.append(obj)
|
self._objects.append(obj)
|
||||||
|
|
||||||
return obj
|
return points_id
|
||||||
|
|
||||||
def add_lines(
|
def add_lines(
|
||||||
self,
|
self,
|
||||||
@@ -236,9 +263,10 @@ class PygfxRenderer(Renderer):
|
|||||||
color: Tuple[float, float, float] = (1.0, 1.0, 1.0),
|
color: Tuple[float, float, float] = (1.0, 1.0, 1.0),
|
||||||
line_width: float = 1.0,
|
line_width: float = 1.0,
|
||||||
name: Optional[str] = None,
|
name: Optional[str] = None,
|
||||||
) -> PygfxRenderObject:
|
) -> str:
|
||||||
"""Add line segments to the scene."""
|
"""Add line segments to the scene. Returns the lines ID."""
|
||||||
import pygfx as gfx
|
import pygfx as gfx
|
||||||
|
import uuid
|
||||||
|
|
||||||
positions = np.hstack([start_points, end_points]).flatten()
|
positions = np.hstack([start_points, end_points]).flatten()
|
||||||
positions = positions.reshape(-1, 3).astype(np.float32)
|
positions = positions.reshape(-1, 3).astype(np.float32)
|
||||||
@@ -249,11 +277,14 @@ class PygfxRenderer(Renderer):
|
|||||||
lines = gfx.Line(geometry, material)
|
lines = gfx.Line(geometry, material)
|
||||||
self._scene.add(lines)
|
self._scene.add(lines)
|
||||||
|
|
||||||
obj = PygfxRenderObject(name=name, scene_node=lines, geometry=geometry, material=material)
|
lines_id = name or f"lines_{uuid.uuid4().hex[:8]}"
|
||||||
|
obj = PygfxRenderObject(
|
||||||
|
name=lines_id, scene_node=lines, geometry=geometry, material=material
|
||||||
|
)
|
||||||
obj.color = RenderColor(*color)
|
obj.color = RenderColor(*color)
|
||||||
self._objects.append(obj)
|
self._objects.append(obj)
|
||||||
|
|
||||||
return obj
|
return lines_id
|
||||||
|
|
||||||
def remove_object(self, obj: RenderObject) -> bool:
|
def remove_object(self, obj: RenderObject) -> bool:
|
||||||
"""Remove an object from the scene."""
|
"""Remove an object from the scene."""
|
||||||
@@ -319,15 +350,14 @@ class PygfxRenderer(Renderer):
|
|||||||
up: Tuple[float, float, float] = (0, 0, 1),
|
up: Tuple[float, float, float] = (0, 0, 1),
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Set camera position and orientation."""
|
"""Set camera position and orientation."""
|
||||||
self._camera.position.set(*position)
|
self._camera.local.position = position
|
||||||
self._camera.look_at(*target)
|
self._camera.look_at(target)
|
||||||
self._camera.up.set(*up)
|
|
||||||
|
|
||||||
def get_camera_position(self) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
|
def get_camera_position(self) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
|
||||||
"""Get camera position, target, and up vector."""
|
"""Get camera position, target, and up vector."""
|
||||||
pos = np.array(self._camera.position.to_array())
|
pos = np.array(self._camera.local.position)
|
||||||
target = np.array([0, 0, 0])
|
target = np.array([0, 0, 0])
|
||||||
up = np.array(self._camera.up.to_array())
|
up = np.array(self._camera.local.up)
|
||||||
return pos, target, up
|
return pos, target, up
|
||||||
|
|
||||||
def fit_camera(self, padding: float = 1.1) -> None:
|
def fit_camera(self, padding: float = 1.1) -> None:
|
||||||
@@ -348,8 +378,8 @@ class PygfxRenderer(Renderer):
|
|||||||
center = (min_pos + max_pos) / 2
|
center = (min_pos + max_pos) / 2
|
||||||
size = np.linalg.norm(max_pos - min_pos) * padding
|
size = np.linalg.norm(max_pos - min_pos) * padding
|
||||||
|
|
||||||
self._camera.position.set(center[0] + size, center[1] + size, center[2] + size)
|
self._camera.local.position = (center[0] + size, center[1] + size, center[2] + size)
|
||||||
self._camera.look_at(*center)
|
self._camera.look_at(tuple(center))
|
||||||
|
|
||||||
def set_camera_perspective(
|
def set_camera_perspective(
|
||||||
self, fov: float = 50.0, near: float = 0.1, far: float = 10000.0
|
self, fov: float = 50.0, near: float = 0.1, far: float = 10000.0
|
||||||
@@ -371,7 +401,6 @@ class PygfxRenderer(Renderer):
|
|||||||
def render(self) -> None:
|
def render(self) -> None:
|
||||||
"""Trigger a render."""
|
"""Trigger a render."""
|
||||||
if self._initialized:
|
if self._initialized:
|
||||||
self._renderer.render(self._scene, self._camera)
|
|
||||||
self._canvas.request_draw()
|
self._canvas.request_draw()
|
||||||
|
|
||||||
def on_pick(self, callback: Callable[[Any], None]) -> None:
|
def on_pick(self, callback: Callable[[Any], None]) -> None:
|
||||||
@@ -392,8 +421,8 @@ class PygfxRenderer(Renderer):
|
|||||||
size: float = 100.0,
|
size: float = 100.0,
|
||||||
divisions: int = 10,
|
divisions: int = 10,
|
||||||
color: Tuple[float, float, float] = (0.3, 0.3, 0.3),
|
color: Tuple[float, float, float] = (0.3, 0.3, 0.3),
|
||||||
) -> PygfxRenderObject:
|
) -> str:
|
||||||
"""Add a reference grid."""
|
"""Add a reference grid. Returns the grid ID."""
|
||||||
import pygfx as gfx
|
import pygfx as gfx
|
||||||
|
|
||||||
grid = gfx.GridHelper(
|
grid = gfx.GridHelper(
|
||||||
@@ -402,10 +431,11 @@ class PygfxRenderer(Renderer):
|
|||||||
self._scene.add(grid)
|
self._scene.add(grid)
|
||||||
|
|
||||||
obj = PygfxRenderObject(name="grid", scene_node=grid)
|
obj = PygfxRenderObject(name="grid", scene_node=grid)
|
||||||
return obj
|
self._objects.append(obj)
|
||||||
|
return "grid"
|
||||||
|
|
||||||
def add_axes(self, size: float = 10.0, visible: bool = True) -> PygfxRenderObject:
|
def add_axes(self, size: float = 10.0, visible: bool = True) -> str:
|
||||||
"""Add coordinate axes."""
|
"""Add coordinate axes. Returns the axes ID."""
|
||||||
import pygfx as gfx
|
import pygfx as gfx
|
||||||
|
|
||||||
axes = gfx.AxesHelper(size=size)
|
axes = gfx.AxesHelper(size=size)
|
||||||
@@ -413,7 +443,8 @@ class PygfxRenderer(Renderer):
|
|||||||
self._scene.add(axes)
|
self._scene.add(axes)
|
||||||
|
|
||||||
obj = PygfxRenderObject(name="axes", scene_node=axes)
|
obj = PygfxRenderObject(name="axes", scene_node=axes)
|
||||||
return obj
|
self._objects.append(obj)
|
||||||
|
return "axes"
|
||||||
|
|
||||||
def get_screen_size(self) -> Tuple[int, int]:
|
def get_screen_size(self) -> Tuple[int, int]:
|
||||||
"""Get the screen size in pixels."""
|
"""Get the screen size in pixels."""
|
||||||
|
|||||||
@@ -0,0 +1,326 @@
|
|||||||
|
"""
|
||||||
|
SolveSpace-based constraint solver for Fluency CAD.
|
||||||
|
|
||||||
|
Provides integration between python-solvespace (SolverSystem) and
|
||||||
|
the Fluency CAD sketch pipeline (OCCSketch). Drawing operations
|
||||||
|
add entities to BOTH the OCCSketch (for OCC->render pipeline) and
|
||||||
|
the SolverSketch (for constraint solving). After constraint solving,
|
||||||
|
solved positions are synced back to the OCCSketch.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
import re
|
||||||
|
import uuid
|
||||||
|
import logging
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import List, Optional, Tuple, Any, Dict
|
||||||
|
|
||||||
|
from python_solvespace import SolverSystem, ResultFlag
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Data classes ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SolverPoint:
|
||||||
|
"""A 2D point tracked by the solver system."""
|
||||||
|
|
||||||
|
x: float
|
||||||
|
y: float
|
||||||
|
handle: Any = None
|
||||||
|
handle_nr: int = 0
|
||||||
|
entity_id: int = -1 # Corresponding OCCSketch entity id
|
||||||
|
is_helper: bool = False
|
||||||
|
id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
||||||
|
|
||||||
|
def to_tuple(self) -> Tuple[float, float]:
|
||||||
|
return (self.x, self.y)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SolverLine:
|
||||||
|
"""A line segment tracked by the solver system."""
|
||||||
|
|
||||||
|
start: SolverPoint
|
||||||
|
end: SolverPoint
|
||||||
|
handle: Any = None
|
||||||
|
handle_nr: int = 0
|
||||||
|
entity_ids: Tuple[int, int] = (-1, -1) # Corresponding OCCSketch entity ids
|
||||||
|
is_helper: bool = False
|
||||||
|
constraints: List[str] = field(default_factory=list)
|
||||||
|
id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def length(self) -> float:
|
||||||
|
return math.sqrt(
|
||||||
|
(self.end.x - self.start.x) ** 2 + (self.end.y - self.start.y) ** 2
|
||||||
|
)
|
||||||
|
|
||||||
|
def midpoint(self) -> Tuple[float, float]:
|
||||||
|
return (
|
||||||
|
(self.start.x + self.end.x) / 2,
|
||||||
|
(self.start.y + self.end.y) / 2,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SolverCircle:
|
||||||
|
"""A circle tracked by the solver system."""
|
||||||
|
|
||||||
|
center: SolverPoint
|
||||||
|
radius: float
|
||||||
|
handle: Any = None
|
||||||
|
handle_nr: int = 0
|
||||||
|
entity_id: int = -1 # Corresponding OCCSketch entity id
|
||||||
|
is_helper: bool = False
|
||||||
|
id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
||||||
|
|
||||||
|
|
||||||
|
# ── Solver wrapper ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class SolverSketch(SolverSystem):
|
||||||
|
"""
|
||||||
|
Sketch that uses python-solvespace for parametric constraint solving.
|
||||||
|
|
||||||
|
Maintains its own lists of points, lines, and circles with solve-space
|
||||||
|
handles. Provides methods for creating geometry, applying constraints,
|
||||||
|
solving, and syncing solved positions back to an OCCSketch.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.id = str(uuid.uuid4())
|
||||||
|
self.wp = self.create_2d_base()
|
||||||
|
self.points: List[SolverPoint] = []
|
||||||
|
self.lines: List[SolverLine] = []
|
||||||
|
self.circles: List[SolverCircle] = []
|
||||||
|
self._last_solve_result: int = 0
|
||||||
|
|
||||||
|
# ── Geometry creation ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def add_solver_point(self, x: float, y: float, is_helper: bool = False) -> SolverPoint:
|
||||||
|
"""Add a point to the solver system and return a SolverPoint."""
|
||||||
|
handle = self.add_point_2d(x, y, self.wp)
|
||||||
|
handle_nr = _extract_handle_nr(str(handle))
|
||||||
|
point = SolverPoint(
|
||||||
|
x=x, y=y, handle=handle, handle_nr=handle_nr,
|
||||||
|
is_helper=is_helper,
|
||||||
|
)
|
||||||
|
self.points.append(point)
|
||||||
|
return point
|
||||||
|
|
||||||
|
def add_solver_line(
|
||||||
|
self, start: SolverPoint, end: SolverPoint, is_helper: bool = False
|
||||||
|
) -> SolverLine:
|
||||||
|
"""Add a line to the solver system and return a SolverLine."""
|
||||||
|
handle = self.add_line_2d(start.handle, end.handle, self.wp)
|
||||||
|
handle_nr = _extract_handle_nr(str(handle))
|
||||||
|
line = SolverLine(
|
||||||
|
start=start, end=end, handle=handle, handle_nr=handle_nr,
|
||||||
|
is_helper=is_helper,
|
||||||
|
)
|
||||||
|
self.lines.append(line)
|
||||||
|
return line
|
||||||
|
|
||||||
|
def add_solver_circle(
|
||||||
|
self, center: SolverPoint, radius: float, is_helper: bool = False
|
||||||
|
) -> SolverCircle:
|
||||||
|
"""Add a circle to the solver system and return a SolverCircle.
|
||||||
|
|
||||||
|
Note: python-solvespace handles circles via diameter, so we
|
||||||
|
store radius but pass 2*radius to the solver if needed.
|
||||||
|
"""
|
||||||
|
# For now, circles are tracked for OCC output but the solver
|
||||||
|
# doesn't have a dedicated add_circle_2d in the standard API.
|
||||||
|
# We'll handle radius/diameter constraints through the points.
|
||||||
|
circle = SolverCircle(
|
||||||
|
center=center, radius=radius,
|
||||||
|
is_helper=is_helper,
|
||||||
|
)
|
||||||
|
self.circles.append(circle)
|
||||||
|
return circle
|
||||||
|
|
||||||
|
# ── Constraint methods ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
def constrain_coincident(self, entity_a, entity_b) -> bool:
|
||||||
|
"""Make two entities coincident (point-point or point-line)."""
|
||||||
|
try:
|
||||||
|
if isinstance(entity_a, SolverPoint) and isinstance(entity_b, SolverPoint):
|
||||||
|
self.coincident(entity_a.handle, entity_b.handle, self.wp)
|
||||||
|
elif isinstance(entity_a, SolverPoint) and isinstance(entity_b, SolverLine):
|
||||||
|
self.coincident(entity_a.handle, entity_b.handle, self.wp)
|
||||||
|
|
||||||
|
elif isinstance(entity_a, SolverLine) and isinstance(entity_b, SolverPoint):
|
||||||
|
self.coincident(entity_b.handle, entity_a.handle, self.wp)
|
||||||
|
|
||||||
|
else:
|
||||||
|
logger.warning(f"coincident: unsupported types {type(entity_a)}, {type(entity_b)}")
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"coincident constraint failed: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def constrain_horizontal(self, line: SolverLine) -> bool:
|
||||||
|
"""Constrain a line to be horizontal."""
|
||||||
|
try:
|
||||||
|
self.horizontal(line.handle, self.wp)
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"horizontal constraint failed: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def constrain_vertical(self, line: SolverLine) -> bool:
|
||||||
|
"""Constrain a line to be vertical."""
|
||||||
|
try:
|
||||||
|
self.vertical(line.handle, self.wp)
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"vertical constraint failed: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def constrain_distance(
|
||||||
|
self, entity_a, entity_b, distance: float
|
||||||
|
) -> bool:
|
||||||
|
"""Constrain distance between point-point or point-line."""
|
||||||
|
try:
|
||||||
|
handle_a = entity_a.handle if isinstance(entity_a, SolverPoint) else entity_a.handle
|
||||||
|
handle_b = entity_b.handle if isinstance(entity_b, SolverPoint) else entity_b.handle
|
||||||
|
|
||||||
|
if isinstance(entity_a, SolverPoint) and isinstance(entity_b, SolverLine):
|
||||||
|
self.distance(handle_a, handle_b, distance, self.wp)
|
||||||
|
elif isinstance(entity_a, SolverLine) and isinstance(entity_b, SolverPoint):
|
||||||
|
self.distance(handle_b, handle_a, distance, self.wp)
|
||||||
|
elif isinstance(entity_a, SolverPoint) and isinstance(entity_b, SolverPoint):
|
||||||
|
self.distance(handle_a, handle_b, distance, self.wp)
|
||||||
|
elif isinstance(entity_a, SolverLine) and isinstance(entity_b, SolverLine):
|
||||||
|
self.distance(handle_a, handle_b, distance, self.wp)
|
||||||
|
else:
|
||||||
|
logger.warning(f"distance: unsupported types {type(entity_a)}, {type(entity_b)}")
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"distance constraint failed: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def constrain_midpoint(self, point: SolverPoint, line: SolverLine) -> bool:
|
||||||
|
"""Constrain a point to be at the midpoint of a line."""
|
||||||
|
try:
|
||||||
|
self.midpoint(point.handle, line.handle, self.wp)
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"midpoint constraint failed: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def constrain_parallel(self, line_a: SolverLine, line_b: SolverLine) -> bool:
|
||||||
|
"""Constrain two lines to be parallel."""
|
||||||
|
try:
|
||||||
|
self.parallel(line_a.handle, line_b.handle, self.wp)
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"parallel constraint failed: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def constrain_perpendicular(self, line_a: SolverLine, line_b: SolverLine) -> bool:
|
||||||
|
"""Constrain two lines to be perpendicular."""
|
||||||
|
try:
|
||||||
|
self.perpendicular(line_a.handle, line_b.handle, self.wp)
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"perpendicular constraint failed: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def constrain_angle(self, line_a: SolverLine, line_b: SolverLine, angle_deg: float) -> bool:
|
||||||
|
"""Constrain angle between two lines in degrees."""
|
||||||
|
try:
|
||||||
|
angle_rad = math.radians(angle_deg)
|
||||||
|
self.angle(line_a.handle, line_b.handle, angle_rad, self.wp)
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"angle constraint failed: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def constrain_equal_length(self, line_a: SolverLine, line_b: SolverLine) -> bool:
|
||||||
|
"""Constrain two lines to have equal length."""
|
||||||
|
try:
|
||||||
|
self.equal(line_a.handle, line_b.handle, self.wp)
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"equal length constraint failed: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# ── Solving ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def solve(self) -> int:
|
||||||
|
"""Solve all constraints. Returns ResultFlag as int."""
|
||||||
|
result = super().solve()
|
||||||
|
self._last_solve_result = result
|
||||||
|
|
||||||
|
if result == ResultFlag.OKAY:
|
||||||
|
# Update our stored point positions from solver params
|
||||||
|
self._sync_solved_positions()
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _sync_solved_positions(self) -> None:
|
||||||
|
"""Update SolverPoint coordinates from solver's solved params."""
|
||||||
|
for point in self.points:
|
||||||
|
if point.handle and self.params(point.handle.params):
|
||||||
|
x, y = self.params(point.handle.params)
|
||||||
|
point.x = x
|
||||||
|
point.y = y
|
||||||
|
|
||||||
|
# ── Query ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def get_solved_point_positions(self) -> Dict[int, Tuple[float, float]]:
|
||||||
|
"""Get map of entity_id -> (x, y) after solving."""
|
||||||
|
positions: Dict[int, Tuple[float, float]] = {}
|
||||||
|
for point in self.points:
|
||||||
|
if point.handle and self.params(point.handle.params):
|
||||||
|
x, y = self.params(point.handle.params)
|
||||||
|
positions[point.entity_id] = (x, y)
|
||||||
|
return positions
|
||||||
|
|
||||||
|
def is_point_on_line(
|
||||||
|
self, px: float, py: float, line: SolverLine, tolerance: float = 5.0
|
||||||
|
) -> bool:
|
||||||
|
"""Check if a point lies on a solver line (in world coords)."""
|
||||||
|
# Vector from start to point
|
||||||
|
ap_x = px - line.start.x
|
||||||
|
ap_y = py - line.start.y
|
||||||
|
# Vector from start to end
|
||||||
|
ab_x = line.end.x - line.start.x
|
||||||
|
ab_y = line.end.y - line.start.y
|
||||||
|
ab_len_sq = ab_x**2 + ab_y**2
|
||||||
|
if ab_len_sq == 0:
|
||||||
|
return False
|
||||||
|
# Project point onto line
|
||||||
|
t = (ap_x * ab_x + ap_y * ab_y) / ab_len_sq
|
||||||
|
t = max(0, min(1, t))
|
||||||
|
closest_x = line.start.x + t * ab_x
|
||||||
|
closest_y = line.start.y + t * ab_y
|
||||||
|
dist = math.sqrt((px - closest_x) ** 2 + (py - closest_y) ** 2)
|
||||||
|
return dist <= tolerance
|
||||||
|
|
||||||
|
# ── Clear / reset ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def clear(self) -> None:
|
||||||
|
"""Clear all geometry from this solver sketch."""
|
||||||
|
self.points.clear()
|
||||||
|
self.lines.clear()
|
||||||
|
self.circles.clear()
|
||||||
|
self.wp = self.create_2d_base()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Helpers ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_handle_nr(handle_str: str) -> int:
|
||||||
|
"""Extract numeric handle from string like 'Entity(handle=7, ...)'."""
|
||||||
|
match = re.search(r"handle=(\d+)", handle_str)
|
||||||
|
return int(match.group(1)) if match else 0
|
||||||
@@ -1,146 +0,0 @@
|
|||||||
Metadata-Version: 2.4
|
|
||||||
Name: fluency-cad
|
|
||||||
Version: 2.0.0
|
|
||||||
Summary: Parametric CAD application with OpenCASCADE geometry kernel
|
|
||||||
Author: Fluency CAD Team
|
|
||||||
License: MIT
|
|
||||||
Project-URL: Homepage, https://github.com/fluency-cad/fluency
|
|
||||||
Project-URL: Documentation, https://github.com/fluency-cad/fluency#readme
|
|
||||||
Project-URL: Repository, https://github.com/fluency-cad/fluency
|
|
||||||
Keywords: cad,parametric,opencascade,3d-modeling
|
|
||||||
Classifier: Development Status :: 4 - Beta
|
|
||||||
Classifier: Intended Audience :: Developers
|
|
||||||
Classifier: Intended Audience :: End Users/Desktop
|
|
||||||
Classifier: License :: OSI Approved :: MIT License
|
|
||||||
Classifier: Programming Language :: Python :: 3
|
|
||||||
Classifier: Programming Language :: Python :: 3.10
|
|
||||||
Classifier: Programming Language :: Python :: 3.11
|
|
||||||
Classifier: Programming Language :: Python :: 3.12
|
|
||||||
Classifier: Topic :: Scientific/Engineering :: CAD
|
|
||||||
Requires-Python: >=3.10
|
|
||||||
Description-Content-Type: text/markdown
|
|
||||||
Requires-Dist: cadquery>=2.4
|
|
||||||
Requires-Dist: pygfx>=0.1.0
|
|
||||||
Requires-Dist: wgpu>=0.1.0
|
|
||||||
Requires-Dist: PySide6>=6.4.0
|
|
||||||
Requires-Dist: numpy>=1.24.0
|
|
||||||
Requires-Dist: scipy>=1.10.0
|
|
||||||
Requires-Dist: pillow>=10.0.0
|
|
||||||
Provides-Extra: dev
|
|
||||||
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
||||||
Requires-Dist: black>=24.0; extra == "dev"
|
|
||||||
Requires-Dist: mypy>=1.8; extra == "dev"
|
|
||||||
Requires-Dist: ruff>=0.4.0; extra == "dev"
|
|
||||||
|
|
||||||
# Fluency CAD 2.0
|
|
||||||
|
|
||||||
A parametric CAD application built on OpenCASCADE Technology (OCCT) with a modern pygfx-based 3D renderer.
|
|
||||||
|
|
||||||
## Features
|
|
||||||
|
|
||||||
- **OpenCASCADE Geometry Kernel**: Industry-standard BRep geometry with exact precision
|
|
||||||
- **STEP/IGES Import/Export**: Full support for industry-standard CAD file formats
|
|
||||||
- **Parametric Sketching**: 2D sketching with constraint solving using CadQuery
|
|
||||||
- **Boolean Operations**: Union, difference, and intersection
|
|
||||||
- **Fillet & Chamfer**: Apply edge treatments to solid bodies
|
|
||||||
- **Modern Renderer**: WebGPU-based rendering with pygfx (smaller footprint than VTK)
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
```
|
|
||||||
fluency/
|
|
||||||
├── src/fluency/
|
|
||||||
│ ├── geometry/ # Geometry abstraction layer
|
|
||||||
│ │ └── base.py # Abstract interfaces
|
|
||||||
│ ├── geometry_occ/ # OpenCASCADE implementation
|
|
||||||
│ │ ├── kernel.py # OCGeometryKernel
|
|
||||||
│ │ └── sketch.py # OCCSketch with constraints
|
|
||||||
│ ├── rendering/ # Rendering abstraction
|
|
||||||
│ │ ├── base.py # Abstract renderer
|
|
||||||
│ │ └── pygfx_renderer.py
|
|
||||||
│ ├── models/ # Data models
|
|
||||||
│ │ └── data_model.py # Project, Component, Sketch, Body
|
|
||||||
│ └── main.py # Application entry point
|
|
||||||
├── tests/
|
|
||||||
│ └── test_geometry.py
|
|
||||||
└── pyproject.toml
|
|
||||||
```
|
|
||||||
|
|
||||||
## Installation
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Create virtual environment
|
|
||||||
python -m venv .venv
|
|
||||||
source .venv/bin/activate # On Windows: .venv\Scripts\activate
|
|
||||||
|
|
||||||
# Install dependencies
|
|
||||||
pip install -e ".[dev]"
|
|
||||||
```
|
|
||||||
|
|
||||||
## Dependencies
|
|
||||||
|
|
||||||
| Package | Purpose |
|
|
||||||
|---------|---------|
|
|
||||||
| cadquery | High-level OpenCASCADE Python bindings |
|
|
||||||
| ocp | Low-level OpenCASCADE Python bindings |
|
|
||||||
| pygfx | WebGPU-based 3D renderer |
|
|
||||||
| wgpu | WebGPU Python bindings |
|
|
||||||
| PySide6 | Qt GUI framework |
|
|
||||||
| numpy | Numerical computing |
|
|
||||||
| scipy | Scientific computing |
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Run the application
|
|
||||||
fluency-cad
|
|
||||||
|
|
||||||
# Or directly
|
|
||||||
python -m fluency.main
|
|
||||||
```
|
|
||||||
|
|
||||||
## API Example
|
|
||||||
|
|
||||||
```python
|
|
||||||
from fluency.geometry_occ.kernel import OCGeometryKernel
|
|
||||||
from fluency.geometry.base import Point2D
|
|
||||||
|
|
||||||
# Create kernel
|
|
||||||
kernel = OCGeometryKernel()
|
|
||||||
|
|
||||||
# Create a sketch
|
|
||||||
points = [
|
|
||||||
Point2D(0, 0),
|
|
||||||
Point2D(10, 0),
|
|
||||||
Point2D(10, 10),
|
|
||||||
Point2D(0, 10),
|
|
||||||
]
|
|
||||||
polygon = kernel.create_polygon(points)
|
|
||||||
|
|
||||||
# Extrude to 3D
|
|
||||||
body = kernel.extrude(polygon, height=20.0)
|
|
||||||
|
|
||||||
# Apply fillet
|
|
||||||
body = kernel.fillet(body, radius=2.0)
|
|
||||||
|
|
||||||
# Export to STEP
|
|
||||||
kernel.export_step(body, "part.step")
|
|
||||||
|
|
||||||
# Export to STL
|
|
||||||
kernel.export_stl(body, "part.stl")
|
|
||||||
```
|
|
||||||
|
|
||||||
## Comparison: Before vs After
|
|
||||||
|
|
||||||
| Aspect | Before (SDF + VTK) | After (OCC + pygfx) |
|
|
||||||
|--------|-------------------|---------------------|
|
|
||||||
| Geometry Precision | Approximate (mesh) | Exact (BRep) |
|
|
||||||
| Export Formats | STL only | STEP, IGES, STL, BREP |
|
|
||||||
| File Size | Large (mesh) | Small (BRep) |
|
|
||||||
| Fillet/Chamfer | Approximate | Exact |
|
|
||||||
| Dependency Size | ~200MB (VTK) | ~30MB (pygfx) |
|
|
||||||
| Constraint Solver | SolveSpace (separate) | CadQuery (integrated) |
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
MIT License
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
README.md
|
|
||||||
pyproject.toml
|
|
||||||
src/fluency/__init__.py
|
|
||||||
src/fluency/main.py
|
|
||||||
src/fluency/geometry/__init__.py
|
|
||||||
src/fluency/geometry/base.py
|
|
||||||
src/fluency/geometry_occ/__init__.py
|
|
||||||
src/fluency/geometry_occ/kernel.py
|
|
||||||
src/fluency/geometry_occ/sketch.py
|
|
||||||
src/fluency/models/__init__.py
|
|
||||||
src/fluency/models/data_model.py
|
|
||||||
src/fluency/rendering/__init__.py
|
|
||||||
src/fluency/rendering/base.py
|
|
||||||
src/fluency/rendering/pygfx_renderer.py
|
|
||||||
src/fluency/utils/__init__.py
|
|
||||||
src/fluency/widgets/__init__.py
|
|
||||||
src/fluency_cad.egg-info/PKG-INFO
|
|
||||||
src/fluency_cad.egg-info/SOURCES.txt
|
|
||||||
src/fluency_cad.egg-info/dependency_links.txt
|
|
||||||
src/fluency_cad.egg-info/entry_points.txt
|
|
||||||
src/fluency_cad.egg-info/requires.txt
|
|
||||||
src/fluency_cad.egg-info/top_level.txt
|
|
||||||
tests/test_geometry.py
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
[console_scripts]
|
|
||||||
fluency-cad = fluency.main:main
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
cadquery>=2.4
|
|
||||||
pygfx>=0.1.0
|
|
||||||
wgpu>=0.1.0
|
|
||||||
PySide6>=6.4.0
|
|
||||||
numpy>=1.24.0
|
|
||||||
scipy>=1.10.0
|
|
||||||
pillow>=10.0.0
|
|
||||||
|
|
||||||
[dev]
|
|
||||||
pytest>=8.0
|
|
||||||
black>=24.0
|
|
||||||
mypy>=1.8
|
|
||||||
ruff>=0.4.0
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
fluency
|
|
||||||
Binary file not shown.
@@ -182,6 +182,951 @@ class TestOCCSketch:
|
|||||||
points = sketch.get_points()
|
points = sketch.get_points()
|
||||||
assert len(points) == 0
|
assert len(points) == 0
|
||||||
|
|
||||||
|
def test_workplane_extrude_along_normal(self):
|
||||||
|
"""A sketch on a tilted plane extrudes along that plane's normal."""
|
||||||
|
import math
|
||||||
|
from OCP.GProp import GProp_GProps
|
||||||
|
from OCP.BRepGProp import BRepGProp
|
||||||
|
|
||||||
|
ang = math.radians(35)
|
||||||
|
normal = (math.sin(ang), 0.0, math.cos(ang))
|
||||||
|
x_dir = (math.cos(ang), 0.0, -math.sin(ang))
|
||||||
|
|
||||||
|
sk = OCCSketch()
|
||||||
|
sk.set_workplane((10.0, 0.0, 5.0), normal, x_dir)
|
||||||
|
# 20x20 square in UV
|
||||||
|
p0 = sk.add_point(-10, -10); p1 = sk.add_point(10, -10)
|
||||||
|
p2 = sk.add_point(10, 10); p3 = sk.add_point(-10, 10)
|
||||||
|
sk.add_line(p0, p1); sk.add_line(p1, p2)
|
||||||
|
sk.add_line(p2, p3); sk.add_line(p3, p0)
|
||||||
|
|
||||||
|
geom = sk.get_geometry()
|
||||||
|
# The face must carry the plane normal for the kernel.
|
||||||
|
assert geom.metadata.get("normal") is not None
|
||||||
|
|
||||||
|
kernel = OCGeometryKernel()
|
||||||
|
solid = kernel.extrude(geom, 15.0)
|
||||||
|
s = kernel._get_shape(solid)
|
||||||
|
g = GProp_GProps()
|
||||||
|
BRepGProp.VolumeProperties_s(s, g)
|
||||||
|
# 20 * 20 * 15 = 6000 regardless of plane orientation.
|
||||||
|
assert abs(g.Mass() - 6000.0) < 0.1
|
||||||
|
|
||||||
|
def test_workplane_extrude_with_hole(self):
|
||||||
|
"""A square with a circular hole on a custom plane extrudes correctly."""
|
||||||
|
import math
|
||||||
|
from OCP.GProp import GProp_GProps
|
||||||
|
from OCP.BRepGProp import BRepGProp
|
||||||
|
|
||||||
|
sk = OCCSketch()
|
||||||
|
sk.set_workplane((0, 0, 0), (0, 0, 1), (1, 0, 0))
|
||||||
|
a = sk.add_point(-10, -10); b = sk.add_point(10, -10)
|
||||||
|
c = sk.add_point(10, 10); d = sk.add_point(-10, 10)
|
||||||
|
sk.add_line(a, b); sk.add_line(b, c)
|
||||||
|
sk.add_line(c, d); sk.add_line(d, a)
|
||||||
|
ctr = sk.add_point(0, 0)
|
||||||
|
sk.add_circle(ctr, 3.0)
|
||||||
|
|
||||||
|
geom = sk.get_geometry()
|
||||||
|
kernel = OCGeometryKernel()
|
||||||
|
solid = kernel.extrude(geom, 5.0)
|
||||||
|
s = kernel._get_shape(solid)
|
||||||
|
g = GProp_GProps()
|
||||||
|
BRepGProp.VolumeProperties_s(s, g)
|
||||||
|
expected = 20 * 20 * 5 - math.pi * 3 * 3 * 5
|
||||||
|
assert abs(g.Mass() - expected) < 0.1
|
||||||
|
|
||||||
|
|
||||||
|
class TestExternalEntities:
|
||||||
|
"""Tests for the underlay / face-projected reference entity API.
|
||||||
|
|
||||||
|
External entities live in the solver so user constraints can reference
|
||||||
|
them (e.g. "hole center 50 mm from the body's top edge"), but they
|
||||||
|
are *not* part of the sketch profile and must be excluded from
|
||||||
|
detect_faces / get_geometry.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_add_external_point_flags_and_fixes(self):
|
||||||
|
sk = OCCSketch()
|
||||||
|
ep = sk.add_external_point(5.0, 7.0)
|
||||||
|
assert ep is not None
|
||||||
|
assert ep.is_external is True
|
||||||
|
assert ep.is_construction is True
|
||||||
|
# External point is in the solver, with a non-None handle.
|
||||||
|
assert ep.handle is not None
|
||||||
|
# The point is in the entities / points dicts.
|
||||||
|
assert ep.id in sk._entities
|
||||||
|
assert ep.id in sk._points
|
||||||
|
# It's tracked as external.
|
||||||
|
assert ep.id in sk.get_external_entity_ids()
|
||||||
|
|
||||||
|
def test_add_external_line_requires_external_endpoints(self):
|
||||||
|
sk = OCCSketch()
|
||||||
|
a = sk.add_external_point(0, 0)
|
||||||
|
b = sk.add_external_point(10, 0)
|
||||||
|
line = sk.add_external_line(a, b)
|
||||||
|
assert line is not None
|
||||||
|
assert line.is_external is True
|
||||||
|
assert line.is_construction is True
|
||||||
|
assert line.handle is not None
|
||||||
|
assert line.id in sk._lines
|
||||||
|
assert line.id in sk.get_external_entity_ids()
|
||||||
|
|
||||||
|
def test_add_external_polyline_shares_corners(self):
|
||||||
|
sk = OCCSketch()
|
||||||
|
# Closed rectangle: 4 unique corners reused at the joints.
|
||||||
|
pts = [(0, 0), (10, 0), (10, 10), (0, 10), (0, 0)]
|
||||||
|
points, lines = sk.add_external_polyline(pts)
|
||||||
|
# 4 segments, 5 UV samples but the 1st and last are the same corner.
|
||||||
|
assert len(lines) == 4
|
||||||
|
# The 5 samples share the rectangle's 4 corners → 4 unique point entities.
|
||||||
|
assert len(set(p.id for p in points)) == 4
|
||||||
|
# All are external.
|
||||||
|
assert all(p.is_external for p in points)
|
||||||
|
assert all(ln.is_external for ln in lines)
|
||||||
|
|
||||||
|
def test_external_entities_excluded_from_detect_faces(self):
|
||||||
|
sk = OCCSketch()
|
||||||
|
# Underlay: a 20x20 square projected from a face (closed polyline).
|
||||||
|
sk.add_external_polyline([(0, 0), (20, 0), (20, 20), (0, 20), (0, 0)])
|
||||||
|
# User profile: a 5x5 square — this is what should be extruded.
|
||||||
|
a = sk.add_point(2, 2); b = sk.add_point(8, 2)
|
||||||
|
c = sk.add_point(8, 8); d = sk.add_point(2, 8)
|
||||||
|
sk.add_line(a, b); sk.add_line(b, c)
|
||||||
|
sk.add_line(c, d); sk.add_line(d, a)
|
||||||
|
faces = sk.detect_faces()
|
||||||
|
# Only the user-drawn face (5x5 square) should be detected.
|
||||||
|
assert len(faces) == 1
|
||||||
|
outer = faces[0]["outer"]
|
||||||
|
assert outer["type"] == "polygon"
|
||||||
|
# 5 vertices on the outer loop (4 corners + closing point).
|
||||||
|
assert len(outer["points"]) == 5
|
||||||
|
# It must be the user square, not the underlay.
|
||||||
|
xs = [p[0] for p in outer["points"][:4]]
|
||||||
|
ys = [p[1] for p in outer["points"][:4]]
|
||||||
|
assert min(xs) >= 2 and max(xs) <= 8
|
||||||
|
assert min(ys) >= 2 and max(ys) <= 8
|
||||||
|
|
||||||
|
def test_external_entities_excluded_from_get_polygon_points(self):
|
||||||
|
sk = OCCSketch()
|
||||||
|
sk.add_external_polyline([(0, 0), (100, 0), (100, 100), (0, 100), (0, 0)])
|
||||||
|
a = sk.add_point(1, 1); b = sk.add_point(2, 1)
|
||||||
|
c = sk.add_point(2, 2); d = sk.add_point(1, 2)
|
||||||
|
sk.add_line(a, b); sk.add_line(b, c)
|
||||||
|
sk.add_line(c, d); sk.add_line(d, a)
|
||||||
|
poly = sk.get_polygon_points()
|
||||||
|
# The user square (1..2 range) should appear, not the 0..100 underlay.
|
||||||
|
assert all(1.0 <= p.x <= 2.0 for p in poly)
|
||||||
|
assert all(1.0 <= p.y <= 2.0 for p in poly)
|
||||||
|
|
||||||
|
def test_external_entities_excluded_from_get_geometry(self):
|
||||||
|
"""Underlay must never appear in the extruded face."""
|
||||||
|
from OCP.GProp import GProp_GProps
|
||||||
|
from OCP.BRepGProp import BRepGProp
|
||||||
|
|
||||||
|
sk = OCCSketch()
|
||||||
|
# Underlay (NOT to be extruded).
|
||||||
|
sk.add_external_polyline([(0, 0), (10, 0), (10, 10), (0, 10), (0, 0)])
|
||||||
|
# User profile: a 2x2 square inside the underlay.
|
||||||
|
a = sk.add_point(1, 1); b = sk.add_point(3, 1)
|
||||||
|
c = sk.add_point(3, 3); d = sk.add_point(1, 3)
|
||||||
|
sk.add_line(a, b); sk.add_line(b, c)
|
||||||
|
sk.add_line(c, d); sk.add_line(d, a)
|
||||||
|
geom = sk.get_geometry()
|
||||||
|
# Volume = 2 * 2 * 4 = 16, NOT 10 * 10 * 4 = 400.
|
||||||
|
kernel = OCGeometryKernel()
|
||||||
|
solid = kernel.extrude(geom, 4.0)
|
||||||
|
s = kernel._get_shape(solid)
|
||||||
|
g = GProp_GProps()
|
||||||
|
BRepGProp.VolumeProperties_s(s, g)
|
||||||
|
assert abs(g.Mass() - 16.0) < 0.5
|
||||||
|
|
||||||
|
def test_distance_to_external_point_constraint(self):
|
||||||
|
"""The headline use case: hole position fixed relative to a face edge.
|
||||||
|
|
||||||
|
User draws a circle (the hole) and a distance from its centre to
|
||||||
|
a face-projected point. After solve, the circle centre should be
|
||||||
|
exactly the requested distance from the external point.
|
||||||
|
"""
|
||||||
|
sk = OCCSketch()
|
||||||
|
# Underlay corner: pick a known anchor on the projected face.
|
||||||
|
anchor = sk.add_external_point(0.0, 0.0)
|
||||||
|
# User geometry: a 1mm circle for the hole.
|
||||||
|
hole_centre = sk.add_point(7.0, 4.0) # start position: 7 from anchor
|
||||||
|
sk.add_circle(hole_centre, 1.0)
|
||||||
|
# Constrain the hole centre 50 mm from the underlay corner.
|
||||||
|
ok = sk.constrain_distance(anchor, hole_centre, 50.0)
|
||||||
|
assert ok
|
||||||
|
assert sk.solve()
|
||||||
|
solved = sk.get_solved_point(hole_centre.id)
|
||||||
|
assert solved is not None
|
||||||
|
# The starting (7, 4) is well short of 50, so the constraint
|
||||||
|
# forces the centre out to a point on the 50mm circle around (0,0).
|
||||||
|
x, y = solved
|
||||||
|
assert abs(math_hypot(x, y) - 50.0) < 0.01
|
||||||
|
|
||||||
|
def test_remove_external_entities_clears_them(self):
|
||||||
|
sk = OCCSketch()
|
||||||
|
sk.add_external_polyline([(0, 0), (10, 0), (10, 10), (0, 10), (0, 0)])
|
||||||
|
assert len(sk.get_external_entity_ids()) > 0
|
||||||
|
sk.remove_external_entities()
|
||||||
|
assert len(sk.get_external_entity_ids()) == 0
|
||||||
|
# No external points/lines left in the tracking dicts.
|
||||||
|
for eid in sk._entities:
|
||||||
|
assert not getattr(sk._entities[eid], "is_external", False)
|
||||||
|
|
||||||
|
def test_remove_external_entities_prunes_related_constraints(self):
|
||||||
|
"""Constraints referencing external entities are pruned on removal.
|
||||||
|
|
||||||
|
A distance to an external point is recorded in the constraint log
|
||||||
|
on the ids of both endpoints. After remove_external_entities(),
|
||||||
|
those entries are gone and the solver rebuilds without them.
|
||||||
|
"""
|
||||||
|
sk = OCCSketch()
|
||||||
|
anchor = sk.add_external_point(0.0, 0.0)
|
||||||
|
user = sk.add_point(20.0, 0.0)
|
||||||
|
sk.constrain_distance(anchor, user, 5.0)
|
||||||
|
sk.solve()
|
||||||
|
# At least one log entry references the external anchor.
|
||||||
|
assert any(anchor.id in entry["ids"] for entry in sk._constraint_log)
|
||||||
|
# Now wipe the underlay.
|
||||||
|
sk.remove_external_entities()
|
||||||
|
# The distance constraint is gone, and the user point is free.
|
||||||
|
assert not any(anchor.id in entry["ids"] for entry in sk._constraint_log)
|
||||||
|
assert sk.solve()
|
||||||
|
|
||||||
|
def test_external_polyline_dedupes_close_points(self):
|
||||||
|
"""Co-located UV samples share a single point entity (closed loops)."""
|
||||||
|
sk = OCCSketch()
|
||||||
|
# Closed rectangle (closing point == start point).
|
||||||
|
pts = [(1.0, 1.0), (9.0, 1.0), (9.0, 9.0), (1.0, 9.0), (1.0, 1.0)]
|
||||||
|
points, lines = sk.add_external_polyline(pts)
|
||||||
|
# 5 samples → 4 unique points (start/end collapse).
|
||||||
|
assert len(set(p.id for p in points)) == 4
|
||||||
|
# 4 segments connect them.
|
||||||
|
assert len(lines) == 4
|
||||||
|
# Every line's endpoints are among the 4 points.
|
||||||
|
point_ids = {p.id for p in points}
|
||||||
|
for line_id, (sid, eid2) in sk._lines.items():
|
||||||
|
if line_id in sk.get_external_entity_ids():
|
||||||
|
assert sid in point_ids and eid2 in point_ids
|
||||||
|
|
||||||
|
def test_external_point_is_solver_fixed(self):
|
||||||
|
"""An external point's solver parameters must not change on re-solve.
|
||||||
|
|
||||||
|
python_solvespace drags the first user point; external points use
|
||||||
|
``dragged`` directly so dragging a user point near an external
|
||||||
|
reference doesn't shift the reference.
|
||||||
|
"""
|
||||||
|
sk = OCCSketch()
|
||||||
|
ep = sk.add_external_point(3.0, 4.0)
|
||||||
|
# Add a user point; solve; record the external point's solved
|
||||||
|
# params. Then delete the user point and add another one; the
|
||||||
|
# external point's params must not have moved.
|
||||||
|
sk.add_point(100.0, 0.0)
|
||||||
|
sk.solve()
|
||||||
|
x0, y0 = sk.solver.params(ep.handle.params)
|
||||||
|
for dx in range(-5, 6):
|
||||||
|
sk.add_point(100.0 + dx, 0.0)
|
||||||
|
sk.solve()
|
||||||
|
x1, y1 = sk.solver.params(ep.handle.params)
|
||||||
|
assert abs(x1 - x0) < 1e-6
|
||||||
|
assert abs(y1 - y0) < 1e-6
|
||||||
|
|
||||||
|
def test_horizontal_constraint_on_external_line(self):
|
||||||
|
"""Horizontal constraint involving a partly-external line is solvable.
|
||||||
|
|
||||||
|
Both external endpoints are dragged (fixed), so a horizontal
|
||||||
|
constraint between them is over-determined when their y values
|
||||||
|
differ. To make the system solvable we add a free user point
|
||||||
|
connected to one external point via a line, then constrain that
|
||||||
|
line horizontal — the user endpoint is dragged to a y that
|
||||||
|
matches the external one, satisfying the constraint.
|
||||||
|
"""
|
||||||
|
sk = OCCSketch()
|
||||||
|
a = sk.add_external_point(0.0, 0.0)
|
||||||
|
# Add a free user point first (skipped auto-drag because external
|
||||||
|
# points exist, so this one is free).
|
||||||
|
free = sk.add_point(7.0, 5.0)
|
||||||
|
# And an external endpoint to pair with the free point in a line.
|
||||||
|
b = sk.add_external_point(0.0, 0.0)
|
||||||
|
line = sk.add_external_line(b, free)
|
||||||
|
# Constrain it horizontal; the free point should drop to y=0.
|
||||||
|
sk.constrain_horizontal(line)
|
||||||
|
assert sk.solve()
|
||||||
|
sa = sk.get_solved_point(b.id)
|
||||||
|
sf = sk.get_solved_point(free.id)
|
||||||
|
assert sa is not None and sf is not None
|
||||||
|
assert abs(sa[1] - sf[1]) < 1e-6
|
||||||
|
|
||||||
|
def test_cleared_sketch_drops_external_entities(self):
|
||||||
|
sk = OCCSketch()
|
||||||
|
sk.add_external_polyline([(0, 0), (1, 0), (1, 1), (0, 1), (0, 0)])
|
||||||
|
sk.add_point(5, 5)
|
||||||
|
assert len(sk.get_external_entity_ids()) > 0
|
||||||
|
sk.clear()
|
||||||
|
assert len(sk.get_external_entity_ids()) == 0
|
||||||
|
assert sk.get_entity_count() == 0
|
||||||
|
|
||||||
|
|
||||||
|
class TestExtrudeCutFix:
|
||||||
|
"""Tests for the cut/union logic in MainWindow._extrude_sketch.
|
||||||
|
|
||||||
|
The old code stored the boolean result in the *tool* (newly extruded)
|
||||||
|
body, leaving the *target* body untouched — so the user would see a
|
||||||
|
separate "cavity-shaped" body next to the original instead of a
|
||||||
|
cavity in the original. After deleting that extra body, the next
|
||||||
|
extrude-cutter saw ``len(existing) <= 1`` and silently skipped the
|
||||||
|
cut, producing an unconstrained new body that looked "added without
|
||||||
|
cut". The fix:
|
||||||
|
|
||||||
|
1. Apply the boolean to the *target* (existing[0]) body.
|
||||||
|
2. Remove the tool body from the component.
|
||||||
|
3. Re-render the target in place.
|
||||||
|
|
||||||
|
These tests verify the boolean operation produces the right solid and
|
||||||
|
that the post-extrude bookkeeping leaves exactly the right bodies
|
||||||
|
in the component.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_boolean_difference_modifies_target_not_tool(self):
|
||||||
|
"""The fix: cut goes into the target, tool is removed.
|
||||||
|
|
||||||
|
Reproduces the cut/merge flow from ``_extrude_sketch`` without
|
||||||
|
spinning up the full MainWindow: build a target + tool body,
|
||||||
|
run boolean_difference, then verify the target's volume dropped
|
||||||
|
and the tool is no longer needed.
|
||||||
|
"""
|
||||||
|
from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox
|
||||||
|
from fluency.geometry_occ.kernel import OCGeometryKernel, OCCGeometryObject
|
||||||
|
from OCP.GProp import GProp_GProps
|
||||||
|
from OCP.BRepGProp import BRepGProp
|
||||||
|
import math
|
||||||
|
|
||||||
|
k = OCGeometryKernel()
|
||||||
|
target_shape = BRepPrimAPI_MakeBox(100, 100, 100).Shape()
|
||||||
|
target_obj = OCCGeometryObject(target_shape, {"type": "box"})
|
||||||
|
|
||||||
|
# Tool: a 20x20x200 cuboid at the corner of the box, to make the
|
||||||
|
# expected volume easy to compute.
|
||||||
|
from OCP.BRepPrimAPI import BRepPrimAPI_MakePrism
|
||||||
|
from OCP.gp import gp_Pnt, gp_Vec
|
||||||
|
# 20x20 square at (0,0,0), extruded along +Z by 200.
|
||||||
|
from OCP.BRepBuilderAPI import BRepBuilderAPI_MakePolygon
|
||||||
|
mp = BRepBuilderAPI_MakePolygon()
|
||||||
|
for (x, y) in [(0, 0), (20, 0), (20, 20), (0, 20)]:
|
||||||
|
mp.Add(gp_Pnt(x, y, 0))
|
||||||
|
mp.Close()
|
||||||
|
from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeFace
|
||||||
|
face = BRepBuilderAPI_MakeFace(mp.Wire()).Face()
|
||||||
|
tool_shape = BRepPrimAPI_MakePrism(
|
||||||
|
face, gp_Vec(0, 0, 200)
|
||||||
|
).Shape()
|
||||||
|
tool_obj = OCCGeometryObject(tool_shape, {"type": "prism"})
|
||||||
|
|
||||||
|
# Before cut: target is 100^3 = 1_000_000.
|
||||||
|
g0 = GProp_GProps()
|
||||||
|
BRepGProp.VolumeProperties_s(k._get_shape(target_obj), g0)
|
||||||
|
assert abs(g0.Mass() - 1_000_000.0) < 1.0
|
||||||
|
|
||||||
|
# Apply the fix: result goes to the target, not the tool.
|
||||||
|
result = k.boolean_difference(target_obj, tool_obj)
|
||||||
|
target_obj_geometry = result
|
||||||
|
|
||||||
|
# After cut: target is 1_000_000 - 20*20*100 = 960_000
|
||||||
|
# (the prism only intersects the box in z=[0,100], i.e. 100 deep).
|
||||||
|
g1 = GProp_GProps()
|
||||||
|
BRepGProp.VolumeProperties_s(
|
||||||
|
k._get_shape(target_obj_geometry), g1
|
||||||
|
)
|
||||||
|
assert abs(g1.Mass() - 960_000.0) < 1.0
|
||||||
|
|
||||||
|
def test_boolean_difference_does_not_leave_separate_cavity_body(self):
|
||||||
|
"""Sanity: the cut result is a single body (not two).
|
||||||
|
|
||||||
|
The OLD bug stored the cut result in a SECOND body, so after a
|
||||||
|
cut the user would see the original body PLUS a "cavity-shaped"
|
||||||
|
body — the user thought the cut worked but it was just two
|
||||||
|
separate solids. With the fix the cut is folded into the
|
||||||
|
target, so a single body remains.
|
||||||
|
"""
|
||||||
|
from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox
|
||||||
|
from OCP.BRepAlgoAPI import BRepAlgoAPI_Cut
|
||||||
|
from OCP.TopExp import TopExp_Explorer
|
||||||
|
from OCP.TopAbs import TopAbs_SOLID
|
||||||
|
from fluency.geometry_occ.kernel import OCGeometryKernel, OCCGeometryObject
|
||||||
|
|
||||||
|
k = OCGeometryKernel()
|
||||||
|
target_shape = BRepPrimAPI_MakeBox(100, 100, 100).Shape()
|
||||||
|
target_obj = OCCGeometryObject(target_shape, {})
|
||||||
|
|
||||||
|
# Tool: small box at the centre, fully inside the target.
|
||||||
|
from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox as BBox
|
||||||
|
tool_shape = BBox(20, 20, 20).Shape()
|
||||||
|
tool_obj = OCCGeometryObject(tool_shape, {})
|
||||||
|
|
||||||
|
# The fixed cut flow:
|
||||||
|
# 1. Apply boolean to target.
|
||||||
|
# 2. Remove the tool from the component dict.
|
||||||
|
result = k.boolean_difference(target_obj, tool_obj)
|
||||||
|
target_obj.geometry = result # the fix: result goes in target
|
||||||
|
# (the tool_obj is then discarded; the simulated flow above
|
||||||
|
# keeps it locally but doesn't use it for display).
|
||||||
|
|
||||||
|
# Count solids in the cut result. It should be exactly 1 (the
|
||||||
|
# target with a cavity), not 2 (target + cavity-shaped tool).
|
||||||
|
shape = k._get_shape(target_obj)
|
||||||
|
explorer = TopExp_Explorer(shape, TopAbs_SOLID)
|
||||||
|
n_solids = 0
|
||||||
|
while explorer.More():
|
||||||
|
n_solids += 1
|
||||||
|
explorer.Next()
|
||||||
|
assert n_solids == 1, f"Cut result has {n_solids} solids, expected 1"
|
||||||
|
|
||||||
|
|
||||||
|
class TestBodyVisibilityToggle:
|
||||||
|
"""Tests for the per-body visibility toggle on the right-hand body list.
|
||||||
|
|
||||||
|
The user asked for a visibility checkbox per body so they could
|
||||||
|
easily verify whether an operation (e.g. cut) had actually modified
|
||||||
|
a body. Hiding the second body and seeing whether the first still
|
||||||
|
has the cut shape is the intended workflow.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _make_window(self):
|
||||||
|
import os
|
||||||
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
from PySide6.QtWidgets import QApplication
|
||||||
|
app = QApplication.instance() or QApplication([])
|
||||||
|
from fluency.main import MainWindow
|
||||||
|
return MainWindow()
|
||||||
|
|
||||||
|
def test_body_list_uses_checkable_items(self):
|
||||||
|
"""Each body list item must be a checkable QListWidgetItem."""
|
||||||
|
from PySide6.QtCore import Qt
|
||||||
|
win = self._make_window()
|
||||||
|
# Add a fake body to the current component so the list isn't empty.
|
||||||
|
from fluency.models.data_model import Body
|
||||||
|
from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox
|
||||||
|
from fluency.geometry_occ.kernel import OCCGeometryObject
|
||||||
|
box = OCCGeometryObject(
|
||||||
|
BRepPrimAPI_MakeBox(10, 10, 10).Shape(), {}
|
||||||
|
)
|
||||||
|
win._current_component.bodies["a"] = Body(name="A", geometry=box)
|
||||||
|
win._refresh_lists()
|
||||||
|
items = win._body_list.findItems("A", Qt.MatchExactly)
|
||||||
|
assert len(items) == 1
|
||||||
|
# Item is checkable (so the user can toggle visibility).
|
||||||
|
assert items[0].flags() & Qt.ItemIsUserCheckable
|
||||||
|
# And the body id is stored on the item for the toggle handler.
|
||||||
|
assert items[0].data(Qt.UserRole) == "a"
|
||||||
|
# Default state is checked (= visible).
|
||||||
|
assert items[0].checkState() == Qt.Checked
|
||||||
|
|
||||||
|
def test_toggling_visibility_updates_body_model(self):
|
||||||
|
"""Flipping the checkbox should set body.visible accordingly."""
|
||||||
|
from PySide6.QtCore import Qt
|
||||||
|
win = self._make_window()
|
||||||
|
from fluency.models.data_model import Body
|
||||||
|
from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox
|
||||||
|
from fluency.geometry_occ.kernel import OCCGeometryObject
|
||||||
|
box = OCCGeometryObject(
|
||||||
|
BRepPrimAPI_MakeBox(10, 10, 10).Shape(), {}
|
||||||
|
)
|
||||||
|
win._current_component.bodies["a"] = Body(name="A", geometry=box)
|
||||||
|
win._refresh_lists()
|
||||||
|
item = win._body_list.findItems("A", Qt.MatchExactly)[0]
|
||||||
|
|
||||||
|
# Toggle off.
|
||||||
|
item.setCheckState(Qt.Unchecked)
|
||||||
|
win._on_body_visibility_changed(item)
|
||||||
|
assert win._current_component.bodies["a"].visible is False
|
||||||
|
|
||||||
|
# Toggle back on.
|
||||||
|
item.setCheckState(Qt.Checked)
|
||||||
|
win._on_body_visibility_changed(item)
|
||||||
|
assert win._current_component.bodies["a"].visible is True
|
||||||
|
|
||||||
|
def test_visibility_no_op_when_unchanged(self):
|
||||||
|
"""Re-emitting the same state must not trigger a viewer call.
|
||||||
|
|
||||||
|
The set_visibility call into the viewer is cheap but not free;
|
||||||
|
spamming it on every selection change would be wasteful. The
|
||||||
|
handler short-circuits when the new state matches the model's.
|
||||||
|
"""
|
||||||
|
from PySide6.QtCore import Qt
|
||||||
|
win = self._make_window()
|
||||||
|
from fluency.models.data_model import Body
|
||||||
|
from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox
|
||||||
|
from fluency.geometry_occ.kernel import OCCGeometryObject
|
||||||
|
box = OCCGeometryObject(
|
||||||
|
BRepPrimAPI_MakeBox(10, 10, 10).Shape(), {}
|
||||||
|
)
|
||||||
|
win._current_component.bodies["a"] = Body(name="A", geometry=box)
|
||||||
|
win._refresh_lists()
|
||||||
|
item = win._body_list.findItems("A", Qt.MatchExactly)[0]
|
||||||
|
|
||||||
|
# Force the model's visibility to False to mimic a desync.
|
||||||
|
win._current_component.bodies["a"].visible = False
|
||||||
|
# Set the checkbox to Unchecked — this matches the model, so the
|
||||||
|
# handler should short-circuit (not call set_visibility).
|
||||||
|
item.setCheckState(Qt.Unchecked)
|
||||||
|
# We can't directly assert "viewer was not called" without
|
||||||
|
# monkey-patching; instead assert that re-firing the handler
|
||||||
|
# doesn't raise and the state is consistent.
|
||||||
|
win._on_body_visibility_changed(item)
|
||||||
|
assert win._current_component.bodies["a"].visible is False
|
||||||
|
|
||||||
|
|
||||||
|
def math_hypot(x, y):
|
||||||
|
import math
|
||||||
|
return math.hypot(x, y)
|
||||||
|
|
||||||
|
|
||||||
|
class TestConstraintTagRendering:
|
||||||
|
"""Tests for constraint tag rendering when a tag references a line id.
|
||||||
|
|
||||||
|
The constraint log stores entity ids. A constraint that targets a
|
||||||
|
line (e.g. point-on-line coincident) puts a *line* id in the log,
|
||||||
|
and the tag rendering code used to naively unpack that line's
|
||||||
|
geometry ``((x1,y1), (x2,y2))`` as if it were a point's ``(x, y)``,
|
||||||
|
calling ``round()`` on a tuple and raising
|
||||||
|
``TypeError: type tuple doesn't define __round__ method``.
|
||||||
|
These tests pin the fix in ``Sketch2DWidget._compute_constraint_tags``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _make_widget_with_sketch(self, sk):
|
||||||
|
"""Build a Sketch2DWidget in offscreen mode and attach *sk* to it."""
|
||||||
|
import os
|
||||||
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
from PySide6.QtWidgets import QApplication
|
||||||
|
app = QApplication.instance() or QApplication([])
|
||||||
|
from fluency.main import Sketch2DWidget
|
||||||
|
w = Sketch2DWidget()
|
||||||
|
w.set_sketch(sk)
|
||||||
|
return w
|
||||||
|
|
||||||
|
def test_point_on_line_coincident_tag_renders(self):
|
||||||
|
"""A coincident between a point and a line must not crash the paint event.
|
||||||
|
|
||||||
|
Reproduces the user-reported error: ids[1] is a line id, the old
|
||||||
|
code unpacked the line's geometry ``((x1,y1), (x2,y2))`` as a
|
||||||
|
point and called ``round()`` on the inner tuple.
|
||||||
|
"""
|
||||||
|
sk = OCCSketch()
|
||||||
|
a = sk.add_point(0, 0)
|
||||||
|
b = sk.add_point(10, 0)
|
||||||
|
line = sk.add_line(a, b) # 3rd entity: the line itself
|
||||||
|
# Point-on-line: the line id is in the constraint log.
|
||||||
|
p3 = sk.add_point(5, 5)
|
||||||
|
sk.constrain_coincident(p3, line)
|
||||||
|
sk.solve()
|
||||||
|
w = self._make_widget_with_sketch(sk)
|
||||||
|
# Must not raise.
|
||||||
|
tags = w._compute_constraint_tags()
|
||||||
|
# One tag for the coincident.
|
||||||
|
coin_tags = [t for t in tags if "coin" in t["label"]]
|
||||||
|
assert len(coin_tags) == 1
|
||||||
|
# The tag was anchored (non-None center) and renders successfully.
|
||||||
|
assert coin_tags[0]["center"] is not None
|
||||||
|
|
||||||
|
def test_point_world_rejects_line_geometry(self):
|
||||||
|
"""_point_world must return None (not crash) when given a line id."""
|
||||||
|
sk = OCCSketch()
|
||||||
|
a = sk.add_point(0, 0)
|
||||||
|
b = sk.add_point(10, 0)
|
||||||
|
line = sk.add_line(a, b)
|
||||||
|
w = self._make_widget_with_sketch(sk)
|
||||||
|
# Old behaviour: round(<tuple>) raised TypeError.
|
||||||
|
# New behaviour: _point_world returns None for non-point entities.
|
||||||
|
result = w._point_world(line.id)
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
def test_point_world_rejects_circle_geometry(self):
|
||||||
|
"""_point_world must return None for circle entities too.
|
||||||
|
|
||||||
|
A circle's geometry is ``((cx, cy), radius)`` — also not a flat
|
||||||
|
2-tuple of numbers. Same shape check rejects it.
|
||||||
|
"""
|
||||||
|
sk = OCCSketch()
|
||||||
|
c = sk.add_point(0, 0)
|
||||||
|
circle = sk.add_circle(c, 5.0)
|
||||||
|
w = self._make_widget_with_sketch(sk)
|
||||||
|
result = w._point_world(circle.id)
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
def test_entity_anchor_routes_to_line_midpoint(self):
|
||||||
|
"""_entity_anchor returns the line midpoint for line ids."""
|
||||||
|
sk = OCCSketch()
|
||||||
|
a = sk.add_point(0, 0)
|
||||||
|
b = sk.add_point(10, 0)
|
||||||
|
line = sk.add_line(a, b)
|
||||||
|
w = self._make_widget_with_sketch(sk)
|
||||||
|
anchor = w._entity_anchor(line.id)
|
||||||
|
assert anchor is not None
|
||||||
|
# Midpoint of (0,0) and (10,0) is (5, 0).
|
||||||
|
assert anchor.x() == 5
|
||||||
|
assert anchor.y() == 0
|
||||||
|
|
||||||
|
def test_distance_constraint_with_line_id(self):
|
||||||
|
"""A distance constraint involving a line id must not crash.
|
||||||
|
|
||||||
|
Future enhancements might add a point-to-line distance; even
|
||||||
|
without that, the defensive routing through _entity_anchor
|
||||||
|
ensures the tag renders cleanly when such an entry is logged.
|
||||||
|
"""
|
||||||
|
sk = OCCSketch()
|
||||||
|
a = sk.add_point(0, 0)
|
||||||
|
b = sk.add_point(10, 0)
|
||||||
|
line = sk.add_line(a, b)
|
||||||
|
p3 = sk.add_point(15, 5)
|
||||||
|
# Simulate a point-to-line distance by directly appending a log
|
||||||
|
# entry — this matches the solver's surface (it would call
|
||||||
|
# _record_constraint with these ids once a point-to-line
|
||||||
|
# distance is added to the solver wrapper).
|
||||||
|
sk._record_constraint("distance", (p3.id, line.id), (12.0,))
|
||||||
|
w = self._make_widget_with_sketch(sk)
|
||||||
|
tags = w._compute_constraint_tags()
|
||||||
|
dst_tags = [t for t in tags if "dst" in t["label"]]
|
||||||
|
assert len(dst_tags) == 1
|
||||||
|
assert dst_tags[0]["center"] is not None
|
||||||
|
|
||||||
|
def test_paint_tolerates_corrupted_entity_geometry(self):
|
||||||
|
"""Paint must not crash if an entity's geometry is weird.
|
||||||
|
|
||||||
|
Simulates the user-reported case: after constraining many
|
||||||
|
points, the solver log still references an entity whose
|
||||||
|
geometry was corrupted (e.g. line-shape ``((x,y), r)`` on a
|
||||||
|
point entity, a 3-element list, or a value with a __round__
|
||||||
|
that raises). _compute_constraint_tags should drop the bad
|
||||||
|
tag and keep rendering the rest.
|
||||||
|
"""
|
||||||
|
sk = OCCSketch()
|
||||||
|
a = sk.add_point(0, 0)
|
||||||
|
b = sk.add_point(10, 0)
|
||||||
|
c = sk.add_point(5, 5)
|
||||||
|
sk.constrain_coincident(c, a)
|
||||||
|
sk.solve()
|
||||||
|
w = self._make_widget_with_sketch(sk)
|
||||||
|
|
||||||
|
# Case 1: point entity has line-shape geometry.
|
||||||
|
sk._entities[c.id].geometry = ((1.0, 2.0), 3.0)
|
||||||
|
tags = w._compute_constraint_tags()
|
||||||
|
# Bad entry is dropped; the good one still renders.
|
||||||
|
assert all(t["center"] is not None for t in tags)
|
||||||
|
|
||||||
|
# Case 2: wrong-shape geometry (3-element list).
|
||||||
|
sk._entities[c.id].geometry = [1.0, 2.0, 3.0]
|
||||||
|
tags = w._compute_constraint_tags()
|
||||||
|
assert all(t["center"] is not None for t in tags)
|
||||||
|
|
||||||
|
# Case 3: exotic type whose __round__ raises.
|
||||||
|
class _BadRound:
|
||||||
|
def __round__(self, ndigits=0):
|
||||||
|
raise TypeError("cannot round")
|
||||||
|
sk._entities[c.id].geometry = (_BadRound(), _BadRound())
|
||||||
|
tags = w._compute_constraint_tags()
|
||||||
|
assert all(t["center"] is not None for t in tags)
|
||||||
|
|
||||||
|
def test_paint_tolerates_dangling_constraint_ids(self):
|
||||||
|
"""Paint must not crash if the log references an entity that was deleted.
|
||||||
|
|
||||||
|
The log can briefly reference a stale id after a delete (e.g.
|
||||||
|
if a constraint handler logs first and deletes second). The
|
||||||
|
render path must skip such entries, not raise KeyError or
|
||||||
|
TypeError.
|
||||||
|
"""
|
||||||
|
sk = OCCSketch()
|
||||||
|
a = sk.add_point(0, 0)
|
||||||
|
sk.constrain_fixed(a)
|
||||||
|
sk.solve()
|
||||||
|
# Simulate the entity being removed without pruning the log.
|
||||||
|
sk._entities.pop(a.id)
|
||||||
|
w = self._make_widget_with_sketch(sk)
|
||||||
|
tags = w._compute_constraint_tags()
|
||||||
|
# No crash; the dangling tag is dropped.
|
||||||
|
assert isinstance(tags, list)
|
||||||
|
|
||||||
|
|
||||||
|
class TestExtrudeRedesign:
|
||||||
|
"""Tests for the cut-through / source-body auto-target / live-preview
|
||||||
|
redesign (2026-06-29).
|
||||||
|
|
||||||
|
Headline workflow: a sketch projected on a face of a body, plus "Perform
|
||||||
|
Cut"
|
||||||
|
1. auto-targets the body it was projected onto,
|
||||||
|
2. auto-directs the cut INTO the body (the picked face's outward normal
|
||||||
|
points away, so a plain cut would carve nothing),
|
||||||
|
3. with "Through All" ticked, fully passes through the body.
|
||||||
|
A live transparent preview is computed from the same shared helper, and
|
||||||
|
a freshly-projected sketch is auto-selected in the row-left list so the
|
||||||
|
user can Extrude/Cut without hunting for the row.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _make_window_with_box(self, box_side=100.0):
|
||||||
|
import os
|
||||||
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
from PySide6.QtWidgets import QApplication
|
||||||
|
app = QApplication.instance() or QApplication([])
|
||||||
|
from fluency.main import MainWindow
|
||||||
|
from fluency.models.data_model import Sketch, Body
|
||||||
|
from fluency.geometry_occ.kernel import OCCGeometryObject
|
||||||
|
from fluency.geometry_occ.sketch import OCCSketch
|
||||||
|
from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox
|
||||||
|
|
||||||
|
win = MainWindow()
|
||||||
|
k = win._kernel
|
||||||
|
box_shape = BRepPrimAPI_MakeBox(box_side, box_side, box_side).Shape()
|
||||||
|
box_obj = OCCGeometryObject(box_shape, {"type": "box"})
|
||||||
|
win._current_component.bodies["b1"] = Body(name="Box1", geometry=box_obj)
|
||||||
|
|
||||||
|
# Sketch on the TOP face of the box (normal +Z points outward).
|
||||||
|
sk = OCCSketch()
|
||||||
|
origin = (box_side / 2.0, box_side / 2.0, box_side)
|
||||||
|
normal = (0.0, 0.0, 1.0)
|
||||||
|
x_dir = (1.0, 0.0, 0.0)
|
||||||
|
sk.set_workplane(origin, normal, x_dir)
|
||||||
|
sketch = Sketch(name="S on top")
|
||||||
|
sketch.occ_sketch = sk
|
||||||
|
sketch.set_workplane(origin, normal, x_dir)
|
||||||
|
sketch._source_body_id = "b1"
|
||||||
|
win._current_component.sketches[sketch.id] = sketch
|
||||||
|
win._current_sketch = sketch
|
||||||
|
# Return all the fixtures.
|
||||||
|
return win, sketch, sk, box_obj
|
||||||
|
|
||||||
|
def _add_circle(self, sk, r=10.0):
|
||||||
|
from fluency.geometry_occ.sketch import OCCSketch
|
||||||
|
c = sk.add_point(0, 0)
|
||||||
|
sk.add_circle(c, r)
|
||||||
|
sk.solve()
|
||||||
|
return sk.get_geometry()
|
||||||
|
|
||||||
|
def _geometry_volume(self, win, geom):
|
||||||
|
from OCP.GProp import GProp_GProps
|
||||||
|
from OCP.BRepGProp import BRepGProp
|
||||||
|
sh = win._kernel._get_shape(geom)
|
||||||
|
g = GProp_GProps()
|
||||||
|
BRepGProp.VolumeProperties_s(sh, g)
|
||||||
|
return g.Mass()
|
||||||
|
|
||||||
|
def test_cut_auto_directs_into_body(self):
|
||||||
|
"""A plain "Perform Cut" on a sketch-on-top-of-body carves a pocket.
|
||||||
|
|
||||||
|
Without the redesign a non-inverted extrude goes *outward* (up),
|
||||||
|
missing the box and carving nothing. The redesign auto-flips the
|
||||||
|
extrusion to go *into* the body regardless of the Invert checkbox,
|
||||||
|
so a 5 mm cut makes a real 5 mm-deep pocket.
|
||||||
|
"""
|
||||||
|
import math
|
||||||
|
win, sketch, sk, box_obj = self._make_window_with_box(100.0)
|
||||||
|
face_geom = self._add_circle(sk, r=10.0)
|
||||||
|
# Plain cut, length=5, NOT inverted. Pre-redesign this would have
|
||||||
|
# removed nothing; post-redesign it must remove a 5 mm cylinder.
|
||||||
|
result = win._compute_extrude_result(
|
||||||
|
sketch, face_geom,
|
||||||
|
length=5.0, symmetric=False, invert=False,
|
||||||
|
cut=True, union=False, through_all=False,
|
||||||
|
)
|
||||||
|
assert result is not None
|
||||||
|
assert result["target_body"] is not None
|
||||||
|
assert result["target_body"].name == "Box1"
|
||||||
|
vol = self._geometry_volume(win, result["result_geom"])
|
||||||
|
expected = 100.0 ** 3 - math.pi * (10.0 ** 2) * 5.0
|
||||||
|
assert abs(vol - expected) < 1.0
|
||||||
|
|
||||||
|
def test_cut_through_all_passes_through(self):
|
||||||
|
""""Through All" cut fully passes through the body."""
|
||||||
|
import math
|
||||||
|
win, sketch, sk, box_obj = self._make_window_with_box(100.0)
|
||||||
|
face_geom = self._add_circle(sk, r=10.0)
|
||||||
|
result = win._compute_extrude_result(
|
||||||
|
sketch, face_geom,
|
||||||
|
length=5.0, # ignored when through_all
|
||||||
|
symmetric=False, invert=False,
|
||||||
|
cut=True, union=False, through_all=True,
|
||||||
|
)
|
||||||
|
assert result is not None
|
||||||
|
vol = self._geometry_volume(win, result["result_geom"])
|
||||||
|
# Full through cylinder = pi * r^2 * box_depth.
|
||||||
|
expected = 100.0 ** 3 - math.pi * (10.0 ** 2) * 100.0
|
||||||
|
assert abs(vol - expected) < 1.0
|
||||||
|
|
||||||
|
def test_cut_auto_targets_source_body_not_existing_zero(self):
|
||||||
|
"""Cut should target the source body, not the dict's first body.
|
||||||
|
|
||||||
|
Constructs a 2-body scenario where the first body in the dict is NOT
|
||||||
|
the source, and verifies the cut goes into the source.
|
||||||
|
"""
|
||||||
|
import math
|
||||||
|
import os
|
||||||
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
from PySide6.QtWidgets import QApplication
|
||||||
|
app = QApplication.instance() or QApplication([])
|
||||||
|
from fluency.main import MainWindow
|
||||||
|
from fluency.models.data_model import Sketch, Body
|
||||||
|
from fluency.geometry_occ.kernel import OCCGeometryObject
|
||||||
|
from fluency.geometry_occ.sketch import OCCSketch
|
||||||
|
from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox
|
||||||
|
|
||||||
|
win = MainWindow()
|
||||||
|
# First body in the dict: a 50-millimetre box ALSO.
|
||||||
|
first = OCCGeometryObject(
|
||||||
|
BRepPrimAPI_MakeBox(50, 50, 50).Shape(), {}
|
||||||
|
)
|
||||||
|
win._current_component.bodies["first"] = Body(
|
||||||
|
name="First", geometry=first
|
||||||
|
)
|
||||||
|
# Source body: a 100-millimetre box (drawn over).
|
||||||
|
src = OCCGeometryObject(
|
||||||
|
BRepPrimAPI_MakeBox(100, 100, 100).Shape(), {}
|
||||||
|
)
|
||||||
|
win._current_component.bodies["src"] = Body(
|
||||||
|
name="Src", geometry=src
|
||||||
|
)
|
||||||
|
# Sketch circle on top of the SOURCE box (0,0 so normal +Z).
|
||||||
|
sk = OCCSketch()
|
||||||
|
sk.set_workplane((50, 50, 100), (0, 0, 1), (1, 0, 0))
|
||||||
|
centre = sk.add_point(0, 0)
|
||||||
|
sk.add_circle(centre, 10.0)
|
||||||
|
sk.solve()
|
||||||
|
sketch = Sketch(name="S")
|
||||||
|
sketch.occ_sketch = sk
|
||||||
|
sketch.set_workplane((50, 50, 100), (0, 0, 1), (1, 0, 0))
|
||||||
|
sketch._source_body_id = "src" # explicitly the source box.
|
||||||
|
win._current_component.sketches[sketch.id] = sketch
|
||||||
|
win._current_sketch = sketch
|
||||||
|
|
||||||
|
face_geom = sk.get_geometry()
|
||||||
|
result = win._compute_extrude_result(
|
||||||
|
sketch, face_geom,
|
||||||
|
length=5.0, symmetric=False, invert=False,
|
||||||
|
cut=True, union=False, through_all=True,
|
||||||
|
)
|
||||||
|
assert result is not None
|
||||||
|
# Target is the source box, NOT the dict's first body.
|
||||||
|
assert result["target_body"].name == "Src"
|
||||||
|
vol = self._geometry_volume(win, result["result_geom"])
|
||||||
|
# 100^3 - pi*100*100 (through-all full-depth cut on the 100 box).
|
||||||
|
expected = 100.0 ** 3 - math.pi * (10.0 ** 2) * 100.0
|
||||||
|
assert abs(vol - expected) < 1.0
|
||||||
|
|
||||||
|
def test_union_default_builds_outward(self):
|
||||||
|
"""Combine (Union) builds a boss OUTWARD (no auto-into-body flip).
|
||||||
|
|
||||||
|
Union semantics: the new material adds on TOP of the face, not
|
||||||
|
into the body. So a 10 mm union adds a 10 mm cylinder of material
|
||||||
|
rather than "subtracting" from the existing box.
|
||||||
|
"""
|
||||||
|
import math
|
||||||
|
win, sketch, sk, box_obj = self._make_window_with_box(100.0)
|
||||||
|
face_geom = self._add_circle(sk, r=10.0)
|
||||||
|
result = win._compute_extrude_result(
|
||||||
|
sketch, face_geom,
|
||||||
|
length=10.0, symmetric=False, invert=False,
|
||||||
|
cut=False, union=True, through_all=False,
|
||||||
|
)
|
||||||
|
assert result is not None
|
||||||
|
vol = self._geometry_volume(win, result["result_geom"])
|
||||||
|
# 100^3 + pi*100*10 — material added on top.
|
||||||
|
expected = 100.0 ** 3 + math.pi * (10.0 ** 2) * 10.0
|
||||||
|
assert abs(vol - expected) < 1.0
|
||||||
|
|
||||||
|
def test_plain_extrude_untouched_by_source_body(self):
|
||||||
|
"""Without cut/union, the extrusion is a standalone new body."""
|
||||||
|
win, sketch, sk, box_obj = self._make_window_with_box(100.0)
|
||||||
|
face_geom = self._add_circle(sk, r=10.0)
|
||||||
|
result = win._compute_extrude_result(
|
||||||
|
sketch, face_geom,
|
||||||
|
length=10.0, symmetric=False, invert=False,
|
||||||
|
cut=False, union=False, through_all=False,
|
||||||
|
)
|
||||||
|
assert result is not None
|
||||||
|
# No boolean target; result is the standalone tool extrusion.
|
||||||
|
assert result["target_body"] is None
|
||||||
|
vol = self._geometry_volume(win, result["result_geom"])
|
||||||
|
# Standalone cylinder 10 mm tall.
|
||||||
|
import math
|
||||||
|
assert abs(vol - math.pi * (10.0 ** 2) * 10.0) < 1.0
|
||||||
|
|
||||||
|
def test_freshly_picked_sketch_is_auto_selected(self):
|
||||||
|
"""After _on_face_picked, the new sketch is the current list row.
|
||||||
|
|
||||||
|
The user should be able to click Extrude/Cut immediately without
|
||||||
|
first hunting for the new sketch in the left list.
|
||||||
|
"""
|
||||||
|
from fluency.geometry_occ.kernel import OCCGeometryObject
|
||||||
|
win, _, sk, box_obj = self._make_window_with_box(100.0)
|
||||||
|
# Simulate _on_face_picked by calling it through a fake face
|
||||||
|
# shape — but the simplest behavioural check is to call the
|
||||||
|
# bookkeeping directly: a new sketch matching src exists and is
|
||||||
|
# set as _current_sketch, and it appears (and is selected) in
|
||||||
|
# the list after _refresh_lists + setCurrentRow.
|
||||||
|
from fluency.models.data_model import Sketch
|
||||||
|
sketch = Sketch(name="Sketch on face 99")
|
||||||
|
sketch._source_body_id = "b1"
|
||||||
|
sketch.set_workplane((50, 50, 100), (0, 0, 1), (1, 0, 0))
|
||||||
|
win._current_component.sketches[sketch.id] = sketch
|
||||||
|
win._current_sketch = sketch
|
||||||
|
win._refresh_lists()
|
||||||
|
# The auto-select block from _on_face_picked — re-derive it
|
||||||
|
# here since we can't run the full pick path offscreen.
|
||||||
|
target_row = None
|
||||||
|
for row in range(win._sketch_list.count()):
|
||||||
|
if win._sketch_list.item(row).text() == sketch.name:
|
||||||
|
target_row = row
|
||||||
|
break
|
||||||
|
assert target_row is not None
|
||||||
|
win._sketch_list.setCurrentRow(target_row)
|
||||||
|
assert win._sketch_list.currentRow() == target_row
|
||||||
|
assert win._sketch_list.currentItem().text() == sketch.name
|
||||||
|
|
||||||
|
def test_preview_callback_invoked_on_value_change(self):
|
||||||
|
"""The live preview callback fires on spinbox/checkbox changes."""
|
||||||
|
import os
|
||||||
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
from PySide6.QtWidgets import QApplication
|
||||||
|
app = QApplication.instance() or QApplication([])
|
||||||
|
from fluency.main import ExtrudeDialog
|
||||||
|
|
||||||
|
calls = []
|
||||||
|
dialog = ExtrudeDialog()
|
||||||
|
dialog.set_preview_callback(lambda v: calls.append(v))
|
||||||
|
# set_preview_callback emits once for the initial state.
|
||||||
|
assert len(calls) == 1
|
||||||
|
# Changing the length should emit a new values tuple.
|
||||||
|
dialog.length_input.setValue(42.0)
|
||||||
|
assert len(calls) == 2
|
||||||
|
# Toggling "Through All" should emit again.
|
||||||
|
dialog.through_all_checkbox.setChecked(True)
|
||||||
|
assert len(calls) >= 3
|
||||||
|
# Passing None clears the preview (as the host does on close).
|
||||||
|
dialog.set_preview_callback(None)
|
||||||
|
# New callback None → no further emissions.
|
||||||
|
before = len(calls)
|
||||||
|
dialog.length_input.setValue(99.0)
|
||||||
|
assert len(calls) == before # callback gone → no emit
|
||||||
|
|
||||||
|
def test_preview_hidden_event_sends_none(self):
|
||||||
|
"""hideEvent should deliver None to the callback so the host clears."""
|
||||||
|
import os
|
||||||
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
from PySide6.QtWidgets import QApplication
|
||||||
|
app = QApplication.instance() or QApplication([])
|
||||||
|
from fluency.main import ExtrudeDialog
|
||||||
|
|
||||||
|
seen = []
|
||||||
|
dialog = ExtrudeDialog()
|
||||||
|
dialog.set_preview_callback(lambda v: seen.append(v))
|
||||||
|
# hideEvent only fires when the dialog was previously visible, so
|
||||||
|
# show it first (window system / offscreen both honour this) and
|
||||||
|
# then hide it — which is exactly what dialog.exec() does when the
|
||||||
|
# user accepts or cancels.
|
||||||
|
dialog.show()
|
||||||
|
dialog.hide()
|
||||||
|
# The last value emitted to the callback must be None (clear).
|
||||||
|
assert seen and seen[-1] is None
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
pytest.main([__file__, "-v"])
|
pytest.main([__file__, "-v"])
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user