10 Commits

Author SHA1 Message Date
bklronin 9938f4ddd4 - Basic operations 2026-06-29 23:30:02 +02:00
bklronin f6422e0847 - Tons of addtions 2026-06-28 22:51:52 +02:00
bklronin f8f16ea800 - Tons of addtions 2026-06-28 21:12:34 +02:00
bklronin 54ac2c098a - Improved sketching 2026-06-14 10:10:37 +02:00
bklronin ea34e7e29d - Improved sketching 2026-06-14 10:10:33 +02:00
bklronin 7091f530ee fix: use RenderWidget and add animation callback for camera controls
- Change from RenderCanvas to RenderWidget for embedded Qt widget
- Add _animate() callback for canvas.request_draw()
- Update render() to use request_draw() for continuous rendering
- This enables OrbitController to work properly with mouse events
2026-03-14 09:12:12 +01:00
bklronin 75d4820292 fix: register OrbitController events with renderer
Use controller.register_events(renderer) instead of canvas to properly
enable camera rotation/pan/zoom controls in the 3D viewer.
2026-03-14 09:09:32 +01:00
bklronin d52106a48a fix: update pygfx integration for current API
- Use rendercanvas.qt.RenderCanvas instead of wgpu.gui.qt.WgpuCanvas
- Fix camera position API: use camera.local.position instead of camera.position.set()
- Fix light position API: use light.local.position
- Fix PygfxRenderObject to properly inherit from RenderObject
- Change add_mesh/add_wireframe/add_points/add_grid/add_axes to return string ID
- Update base Renderer class to return str instead of RenderObject
- Add custom compute_normals() function for mesh normals
2026-03-14 09:06:38 +01:00
bklronin d7ebbf45d5 feat: add CLI logging for debugging
- Add logging module with DEBUG level
- Log MainWindow initialization
- Log Viewer3DWidget initialization and mesh operations
- Log mouse events in Sketch2DWidget
- Log extrude operations with detailed steps
- Log component and sketch management
2026-03-14 09:01:20 +01:00
bklronin daed79dac6 chore: update .gitignore and remove pycache from tracking 2026-03-14 08:59:25 +01:00
35 changed files with 6785 additions and 820 deletions
+35
View File
@@ -1,3 +1,38 @@
*.xml
*.iml
.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
-11
View File
@@ -1,11 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="PYTHON_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<sourceFolder url="file://$MODULE_DIR$/sdfcad" isTestSource="false" />
<excludeFolder url="file://$MODULE_DIR$/.venv" />
</content>
<orderEntry type="jdk" jdkName="Python 3.12 (fluency)" jdkType="Python SDK" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>
+1 -1
View File
@@ -2,7 +2,7 @@
<project version="4">
<component name="ProjectModuleManager">
<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>
</component>
</project>
+58 -58
View File
@@ -4,31 +4,13 @@
<option name="autoReloadType" value="SELECTIVE" />
</component>
<component name="ChangeListManager">
<list default="true" id="8f0bafd6-58a0-4b20-aa2b-ddc3ba278873" name="Changes" comment="- Improved sketching">
<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" />
<list default="true" id="8f0bafd6-58a0-4b20-aa2b-ddc3ba278873" name="Changes" comment="- Tons of addtions">
<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>
<option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" />
@@ -45,7 +27,7 @@
<component name="Git.Settings">
<option name="RECENT_BRANCH_BY_REPOSITORY">
<map>
<entry key="$PROJECT_DIR$" value="master" />
<entry key="$PROJECT_DIR$" value="single_window" />
</map>
</option>
<option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
@@ -65,32 +47,39 @@
<option name="hideEmptyMiddlePackages" value="true" />
<option name="showLibraryContents" value="true" />
</component>
<component name="PropertiesComponent"><![CDATA[{
"keyToString": {
"Python.2dtest.executor": "Run",
"Python.3d_windows.executor": "Run",
"Python.Unnamed.executor": "Run",
"Python.draw_widget2d.executor": "Run",
"Python.draw_widget_solve.executor": "Run",
"Python.fluency.executor": "Run",
"Python.fluencyb.executor": "Run",
"Python.gl_widget.executor": "Run",
"Python.main.executor": "Run",
"Python.meshtest.executor": "Run",
"Python.side_fluency.executor": "Run",
"Python.simple_mesh.executor": "Run",
"Python.test_vtk_sketch_widget.executor": "Run",
"Python.vtk_sketch_widget.executor": "Run",
"Python.vtk_widget.executor": "Run",
"Python.vulkan.executor": "Run",
"RunOnceActivity.OpenProjectViewOnStart": "true",
"RunOnceActivity.ShowReadmeOnStart": "true",
"RunOnceActivity.git.unshallow": "true",
"git-widget-placeholder": "single__window",
"last_opened_file_path": "/Volumes/Data_drive/Programming/fluency",
"settings.editor.selected.configurable": "project.propVCSSupport.DirectoryMappings"
<component name="PropertiesComponent">{
&quot;keyToString&quot;: {
&quot;Python.2dtest.executor&quot;: &quot;Run&quot;,
&quot;Python.3d_windows.executor&quot;: &quot;Run&quot;,
&quot;Python.Unnamed.executor&quot;: &quot;Run&quot;,
&quot;Python.draw_widget2d.executor&quot;: &quot;Run&quot;,
&quot;Python.draw_widget_solve.executor&quot;: &quot;Run&quot;,
&quot;Python.fluency.executor&quot;: &quot;Run&quot;,
&quot;Python.fluencyb.executor&quot;: &quot;Run&quot;,
&quot;Python.gl_widget.executor&quot;: &quot;Run&quot;,
&quot;Python.kernel.executor&quot;: &quot;Run&quot;,
&quot;Python.main.executor&quot;: &quot;Run&quot;,
&quot;Python.meshtest.executor&quot;: &quot;Run&quot;,
&quot;Python.occ_renderer.executor&quot;: &quot;Run&quot;,
&quot;Python.side_fluency.executor&quot;: &quot;Run&quot;,
&quot;Python.simple_mesh.executor&quot;: &quot;Run&quot;,
&quot;Python.vtk_widget.executor&quot;: &quot;Run&quot;,
&quot;Python.vulkan.executor&quot;: &quot;Run&quot;,
&quot;RunOnceActivity.OpenProjectViewOnStart&quot;: &quot;true&quot;,
&quot;RunOnceActivity.ShowReadmeOnStart&quot;: &quot;true&quot;,
&quot;RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252&quot;: &quot;true&quot;,
&quot;RunOnceActivity.git.unshallow&quot;: &quot;true&quot;,
&quot;RunOnceActivity.typescript.service.memoryLimit.init&quot;: &quot;true&quot;,
&quot;codeWithMe.voiceChat.enabledByDefault&quot;: &quot;false&quot;,
&quot;git-widget-placeholder&quot;: &quot;feature/occ-migration&quot;,
&quot;last_opened_file_path&quot;: &quot;/Volumes/Data_drive/Programming/fluency/src/fluency&quot;,
&quot;node.js.detected.package.eslint&quot;: &quot;true&quot;,
&quot;node.js.selected.package.eslint&quot;: &quot;(autodetect)&quot;,
&quot;node.js.selected.package.tslint&quot;: &quot;(autodetect)&quot;,
&quot;nodejs_package_manager_path&quot;: &quot;npm&quot;,
&quot;settings.editor.selected.configurable&quot;: &quot;project.propVCSSupport.DirectoryMappings&quot;
}
}]]></component>
}</component>
<component name="QodanaReportsService">
<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" />
@@ -98,19 +87,19 @@
</component>
<component name="RecentsManager">
<key name="CopyFile.RECENT_KEYS">
<recent name="$PROJECT_DIR$/src/fluency" />
<recent name="$PROJECT_DIR$" />
<recent name="$PROJECT_DIR$/drawing_modules" />
<recent name="$PROJECT_DIR$/modules" />
</key>
<key name="MoveFile.RECENT_KEYS">
<recent name="$PROJECT_DIR$/doc" />
<recent name="$PROJECT_DIR$" />
</key>
</component>
<component name="SharedIndexes">
<attachedChunks>
<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>
</attachedChunks>
</component>
@@ -275,17 +264,28 @@
<option name="project" value="LOCAL" />
<updated>1755369224187</updated>
</task>
<task id="LOCAL-00020" summary="- Improved sketching">
<task id="LOCAL-00020" summary="- Tons of addtions">
<option name="closed" value="true" />
<created>1763311700335</created>
<created>1782673954850</created>
<option name="number" value="00020" />
<option name="presentableId" value="LOCAL-00020" />
<option name="project" value="LOCAL" />
<updated>1763311700335</updated>
<updated>1782673954850</updated>
</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 />
</component>
<component name="TypeScriptGeneratedFilesManager">
<option name="version" value="3" />
</component>
<component name="Vcs.Log.Tabs.Properties">
<option name="TAB_STATES">
<map>
@@ -318,7 +318,7 @@
<MESSAGE value="- added MIT license" />
<MESSAGE value="- added screenshot" />
<MESSAGE value="- added sdf folder ( doesnt work via pip or git=)" />
<MESSAGE value="- Improved sketching" />
<option name="LAST_COMMIT_MESSAGE" value="- Improved sketching" />
<MESSAGE value="- Tons of addtions" />
<option name="LAST_COMMIT_MESSAGE" value="- Tons of addtions" />
</component>
</project>
+1
View File
@@ -33,6 +33,7 @@ dependencies = [
"numpy>=1.24.0",
"scipy>=1.10.0",
"pillow>=10.0.0",
"python_solvespace>=3.0.0",
]
[project.optional-dependencies]
Binary file not shown.
Binary file not shown.
+148 -35
View File
@@ -46,7 +46,11 @@ class OCGeometryKernel(GeometryKernel):
self._mesh_tolerance: float = 0.1
def _get_shape(self, obj: GeometryObject) -> Any:
"""Extract the underlying OCC shape from a GeometryObject."""
"""Extract the underlying OCC shape from a GeometryObject.
Returns *None* if the object carries no shape (e.g. an empty sketch) —
callers should check for None before using the result.
"""
import cadquery as cq
if isinstance(obj, OCCGeometryObject):
@@ -64,7 +68,13 @@ class OCGeometryKernel(GeometryKernel):
if hasattr(obj.shape, "wrapped"):
return obj.shape.wrapped
return obj.shape
return obj.shape if obj.shape else obj
# No cadquery obj and no raw shape → genuinely empty.
return None
# Non-OCCGeometryObject: return its shape if present, else None.
# (Use explicit identity/truth checks — some OCP TopoDS objects have a
# falsy __bool__, so ``obj.shape if obj.shape`` is unsafe.)
shape = getattr(obj, "shape", None)
return shape if shape is not None else None
def _get_cq_obj(self, obj: GeometryObject) -> Any:
"""Get CadQuery object from GeometryObject."""
@@ -157,24 +167,113 @@ class OCGeometryKernel(GeometryKernel):
direction: Tuple[float, float, float] = (0, 0, 1),
symmetric: bool = False,
) -> GeometryObject:
"""Extrude a 2D sketch into a 3D solid."""
import cadquery as cq
"""Extrude a sketch face into a 3D solid along the sketch plane normal.
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):
if symmetric:
solid = cq_obj.extrude(height / 2, both=True)
else:
solid = cq_obj.extrude(height)
# Defensive: figure out the actual shape from whatever the caller
# hands us, and surface a clear error if we can't get one.
# - If it's an OCCGeometryObject wrapper, unwrap via _get_shape.
# - If it's already a TopoDS_Shape (raw face/wire/etc.), use it.
# - 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:
wp = cq.Workplane("XY").add(cq_obj)
if symmetric:
solid = wp.extrude(height / 2, both=True)
else:
solid = wp.extrude(height)
face = self._get_shape(sketch)
if face is None:
raise ValueError(
"Cannot extrude: sketch has no geometry. "
"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(
self,
@@ -183,20 +282,24 @@ class OCGeometryKernel(GeometryKernel):
axis: Tuple[float, float, float] = (0, 0, 1),
origin: Tuple[float, float, float] = (0, 0, 0),
) -> GeometryObject:
"""Revolve a 2D sketch around an axis."""
import cadquery as cq
"""Revolve a sketch face around an axis."""
import math
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):
solid = cq_obj.revolve(angle)
else:
face = cq.Face.makeFromWires(cq_obj)
axis_vec = cq.Vector(*axis)
origin_vec = cq.Vector(*origin)
solid = face.revolve(axis_vec, origin_vec, angle)
from OCP.gp import gp_Ax1, gp_Pnt, gp_Dir
from OCP.BRepPrimAPI import BRepPrimAPI_MakeRevol
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:
"""Create a loft between multiple profiles."""
@@ -593,7 +696,9 @@ class OCGeometryKernel(GeometryKernel):
from OCP.BRep import BRep_Tool
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()
vertices_list: List[List[float]] = []
@@ -601,6 +706,7 @@ class OCGeometryKernel(GeometryKernel):
vertex_offset = 0
from OCP.TopoDS import TopoDS
from OCP.TopAbs import TopAbs_Orientation
explorer = TopExp_Explorer(shape, TopAbs_FACE)
while explorer.More():
@@ -615,15 +721,21 @@ class OCGeometryKernel(GeometryKernel):
vertices_list.append([p.X(), p.Y(), p.Z()])
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):
tri = triangulation.Triangle(i)
faces_list.append(
[
tri.Value(1) - 1 + vertex_offset,
tri.Value(2) - 1 + vertex_offset,
tri.Value(3) - 1 + vertex_offset,
]
v0, v1, v2 = (
tri.Value(1) - 1 + vertex_offset,
tri.Value(2) - 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
@@ -673,7 +785,8 @@ class OCGeometryKernel(GeometryKernel):
explorer = TopExp_Explorer(shape, TopAbs_EDGE)
while explorer.More():
edge = explorer.Current()
from OCP.TopoDS import TopoDS
edge = TopoDS.Edge_s(explorer.Current())
edge_points = discretize_edge(edge)
for i, pt in enumerate(edge_points):
File diff suppressed because it is too large Load Diff
+3033 -411
View File
File diff suppressed because it is too large Load Diff
+27 -1
View File
@@ -6,7 +6,7 @@ including projects, components, sketches, and bodies.
"""
from dataclasses import dataclass, field
from typing import Dict, List, Optional, Any
from typing import Dict, List, Optional, Any, Tuple
from datetime import datetime
import uuid
import numpy as np
@@ -46,6 +46,32 @@ class Sketch:
created_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:
"""Add a point to the sketch."""
self.modified_at = datetime.now()
+1
View File
@@ -6,6 +6,7 @@ from fluency.rendering.base import (
RenderColor,
)
from fluency.rendering.pygfx_renderer import PygfxRenderer, PygfxRenderObject
from fluency.rendering.occ_renderer import OCCRenderer, OCCRenderObject
__all__ = [
"Renderer",
+12 -12
View File
@@ -97,7 +97,7 @@ class Renderer(ABC):
faces: np.ndarray,
color: Tuple[float, float, float] = (0.2, 0.4, 0.8),
name: Optional[str] = None,
) -> RenderObject:
) -> str:
"""
Add a mesh to the scene.
@@ -108,7 +108,7 @@ class Renderer(ABC):
name: Optional name for the object
Returns:
RenderObject representing the mesh
String ID of the mesh
"""
pass
@@ -120,7 +120,7 @@ class Renderer(ABC):
color: Tuple[float, float, float] = (1.0, 1.0, 1.0),
line_width: float = 1.0,
name: Optional[str] = None,
) -> RenderObject:
) -> str:
"""
Add a wireframe to the scene.
@@ -132,7 +132,7 @@ class Renderer(ABC):
name: Optional name for the object
Returns:
RenderObject representing the wireframe
String ID of the wireframe
"""
pass
@@ -143,7 +143,7 @@ class Renderer(ABC):
color: Tuple[float, float, float] = (1.0, 0.0, 0.0),
size: float = 5.0,
name: Optional[str] = None,
) -> RenderObject:
) -> str:
"""
Add points to the scene.
@@ -154,7 +154,7 @@ class Renderer(ABC):
name: Optional name for the object
Returns:
RenderObject representing the points
String ID of the points
"""
pass
@@ -166,7 +166,7 @@ class Renderer(ABC):
color: Tuple[float, float, float] = (1.0, 1.0, 1.0),
line_width: float = 1.0,
name: Optional[str] = None,
) -> RenderObject:
) -> str:
"""
Add line segments to the scene.
@@ -178,7 +178,7 @@ class Renderer(ABC):
name: Optional name for the object
Returns:
RenderObject representing the lines
String ID of the lines
"""
pass
@@ -312,13 +312,13 @@ class Renderer(ABC):
size: float = 100.0,
divisions: int = 10,
color: Tuple[float, float, float] = (0.3, 0.3, 0.3),
) -> RenderObject:
"""Add a reference grid."""
) -> str:
"""Add a reference grid. Returns the grid ID."""
pass
@abstractmethod
def add_axes(self, size: float = 10.0, visible: bool = True) -> RenderObject:
"""Add coordinate axes."""
def add_axes(self, size: float = 10.0, visible: bool = True) -> str:
"""Add coordinate axes. Returns the axes ID."""
pass
@abstractmethod
+968
View File
@@ -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()
+70 -39
View File
@@ -55,9 +55,17 @@ class PygfxRenderObject(RenderObject):
geometry: Any = None
material: Any = None
def __post_init__(self) -> None:
if self.scene_node is not None:
self._scene_node = self.scene_node
def __init__(
self,
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):
@@ -84,17 +92,18 @@ class PygfxRenderer(Renderer):
"""Initialize pygfx with Qt widget."""
try:
import pygfx as gfx
from wgpu.gui.qt import WgpuCanvas
from rendercanvas.qt import RenderWidget
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._scene = gfx.Scene()
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._add_grid()
@@ -103,6 +112,8 @@ class PygfxRenderer(Renderer):
layout.setContentsMargins(0, 0, 0, 0)
layout.addWidget(self._canvas)
self._canvas.request_draw(self._animate)
self._setup_picking()
self._initialized = True
@@ -110,8 +121,16 @@ class PygfxRenderer(Renderer):
except Exception as e:
print(f"Failed to initialize pygfx: {e}")
import traceback
traceback.print_exc()
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:
"""Setup scene lighting."""
import pygfx as gfx
@@ -120,11 +139,11 @@ class PygfxRenderer(Renderer):
self._scene.add(ambient)
directional = gfx.DirectionalLight(intensity=1.0)
directional.position.set(100, 100, 100)
directional.local.position = (100, 100, 100)
self._scene.add(directional)
fill = gfx.DirectionalLight(intensity=0.5)
fill.position.set(-100, 50, 50)
fill.local.position = (-100, 50, 50)
self._scene.add(fill)
def _add_grid(self) -> None:
@@ -151,9 +170,10 @@ class PygfxRenderer(Renderer):
faces: np.ndarray,
color: Tuple[float, float, float] = (0.2, 0.4, 0.8),
name: Optional[str] = None,
) -> PygfxRenderObject:
"""Add a mesh to the scene."""
) -> str:
"""Add a mesh to the scene. Returns the mesh ID."""
import pygfx as gfx
import uuid
vertices = np.asarray(vertices, dtype=np.float32)
faces = np.asarray(faces, dtype=np.int32)
@@ -167,11 +187,12 @@ class PygfxRenderer(Renderer):
mesh = gfx.Mesh(geometry, material)
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)
self._objects.append(obj)
return obj
return mesh_id
def add_wireframe(
self,
@@ -180,9 +201,10 @@ class PygfxRenderer(Renderer):
color: Tuple[float, float, float] = (1.0, 1.0, 1.0),
line_width: float = 1.0,
name: Optional[str] = None,
) -> PygfxRenderObject:
"""Add a wireframe to the scene."""
) -> str:
"""Add a wireframe to the scene. Returns the wireframe ID."""
import pygfx as gfx
import uuid
positions: List[List[float]] = []
for edge in edges:
@@ -197,11 +219,14 @@ class PygfxRenderer(Renderer):
lines = gfx.Line(geometry, material)
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)
self._objects.append(obj)
return obj
return wireframe_id
def add_points(
self,
@@ -209,9 +234,10 @@ class PygfxRenderer(Renderer):
color: Tuple[float, float, float] = (1.0, 0.0, 0.0),
size: float = 5.0,
name: Optional[str] = None,
) -> PygfxRenderObject:
"""Add points to the scene."""
) -> str:
"""Add points to the scene. Returns the points ID."""
import pygfx as gfx
import uuid
points_arr = np.asarray(points, dtype=np.float32)
@@ -221,13 +247,14 @@ class PygfxRenderer(Renderer):
points_obj = gfx.Points(geometry, material)
self._scene.add(points_obj)
points_id = name or f"points_{uuid.uuid4().hex[:8]}"
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)
self._objects.append(obj)
return obj
return points_id
def add_lines(
self,
@@ -236,9 +263,10 @@ class PygfxRenderer(Renderer):
color: Tuple[float, float, float] = (1.0, 1.0, 1.0),
line_width: float = 1.0,
name: Optional[str] = None,
) -> PygfxRenderObject:
"""Add line segments to the scene."""
) -> str:
"""Add line segments to the scene. Returns the lines ID."""
import pygfx as gfx
import uuid
positions = np.hstack([start_points, end_points]).flatten()
positions = positions.reshape(-1, 3).astype(np.float32)
@@ -249,11 +277,14 @@ class PygfxRenderer(Renderer):
lines = gfx.Line(geometry, material)
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)
self._objects.append(obj)
return obj
return lines_id
def remove_object(self, obj: RenderObject) -> bool:
"""Remove an object from the scene."""
@@ -319,15 +350,14 @@ class PygfxRenderer(Renderer):
up: Tuple[float, float, float] = (0, 0, 1),
) -> None:
"""Set camera position and orientation."""
self._camera.position.set(*position)
self._camera.look_at(*target)
self._camera.up.set(*up)
self._camera.local.position = position
self._camera.look_at(target)
def get_camera_position(self) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
"""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])
up = np.array(self._camera.up.to_array())
up = np.array(self._camera.local.up)
return pos, target, up
def fit_camera(self, padding: float = 1.1) -> None:
@@ -348,8 +378,8 @@ class PygfxRenderer(Renderer):
center = (min_pos + max_pos) / 2
size = np.linalg.norm(max_pos - min_pos) * padding
self._camera.position.set(center[0] + size, center[1] + size, center[2] + size)
self._camera.look_at(*center)
self._camera.local.position = (center[0] + size, center[1] + size, center[2] + size)
self._camera.look_at(tuple(center))
def set_camera_perspective(
self, fov: float = 50.0, near: float = 0.1, far: float = 10000.0
@@ -371,7 +401,6 @@ class PygfxRenderer(Renderer):
def render(self) -> None:
"""Trigger a render."""
if self._initialized:
self._renderer.render(self._scene, self._camera)
self._canvas.request_draw()
def on_pick(self, callback: Callable[[Any], None]) -> None:
@@ -392,8 +421,8 @@ class PygfxRenderer(Renderer):
size: float = 100.0,
divisions: int = 10,
color: Tuple[float, float, float] = (0.3, 0.3, 0.3),
) -> PygfxRenderObject:
"""Add a reference grid."""
) -> str:
"""Add a reference grid. Returns the grid ID."""
import pygfx as gfx
grid = gfx.GridHelper(
@@ -402,10 +431,11 @@ class PygfxRenderer(Renderer):
self._scene.add(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:
"""Add coordinate axes."""
def add_axes(self, size: float = 10.0, visible: bool = True) -> str:
"""Add coordinate axes. Returns the axes ID."""
import pygfx as gfx
axes = gfx.AxesHelper(size=size)
@@ -413,7 +443,8 @@ class PygfxRenderer(Renderer):
self._scene.add(axes)
obj = PygfxRenderObject(name="axes", scene_node=axes)
return obj
self._objects.append(obj)
return "axes"
def get_screen_size(self) -> Tuple[int, int]:
"""Get the screen size in pixels."""
+326
View File
@@ -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
-146
View File
@@ -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
-23
View File
@@ -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
-13
View File
@@ -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
View File
@@ -1 +0,0 @@
fluency
+945
View File
@@ -182,6 +182,951 @@ class TestOCCSketch:
points = sketch.get_points()
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__":
pytest.main([__file__, "-v"])
Generated
+14
View File
@@ -638,6 +638,7 @@ dependencies = [
{ name = "pillow" },
{ name = "pygfx" },
{ 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.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
{ name = "wgpu" },
@@ -661,6 +662,7 @@ requires-dist = [
{ name = "pygfx", specifier = ">=0.1.0" },
{ name = "pyside6", specifier = ">=6.4.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 = "scipy", specifier = ">=1.10.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" },
]
[[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]]
name = "pytokens"
version = "0.4.1"