- Basic operations
This commit is contained in:
Generated
+41
-34
@@ -9,7 +9,6 @@
|
|||||||
<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/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/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/main.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/main.py" afterDir="false" />
|
||||||
<change beforePath="$PROJECT_DIR$/src/fluency/models/data_model.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/models/data_model.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$/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" />
|
<change beforePath="$PROJECT_DIR$/tests/test_geometry.py" beforeDir="false" afterPath="$PROJECT_DIR$/tests/test_geometry.py" afterDir="false" />
|
||||||
</list>
|
</list>
|
||||||
@@ -48,39 +47,39 @@
|
|||||||
<option name="hideEmptyMiddlePackages" value="true" />
|
<option name="hideEmptyMiddlePackages" value="true" />
|
||||||
<option name="showLibraryContents" value="true" />
|
<option name="showLibraryContents" value="true" />
|
||||||
</component>
|
</component>
|
||||||
<component name="PropertiesComponent"><![CDATA[{
|
<component name="PropertiesComponent">{
|
||||||
"keyToString": {
|
"keyToString": {
|
||||||
"Python.2dtest.executor": "Run",
|
"Python.2dtest.executor": "Run",
|
||||||
"Python.3d_windows.executor": "Run",
|
"Python.3d_windows.executor": "Run",
|
||||||
"Python.Unnamed.executor": "Run",
|
"Python.Unnamed.executor": "Run",
|
||||||
"Python.draw_widget2d.executor": "Run",
|
"Python.draw_widget2d.executor": "Run",
|
||||||
"Python.draw_widget_solve.executor": "Run",
|
"Python.draw_widget_solve.executor": "Run",
|
||||||
"Python.fluency.executor": "Run",
|
"Python.fluency.executor": "Run",
|
||||||
"Python.fluencyb.executor": "Run",
|
"Python.fluencyb.executor": "Run",
|
||||||
"Python.gl_widget.executor": "Run",
|
"Python.gl_widget.executor": "Run",
|
||||||
"Python.kernel.executor": "Run",
|
"Python.kernel.executor": "Run",
|
||||||
"Python.main.executor": "Run",
|
"Python.main.executor": "Run",
|
||||||
"Python.meshtest.executor": "Run",
|
"Python.meshtest.executor": "Run",
|
||||||
"Python.occ_renderer.executor": "Run",
|
"Python.occ_renderer.executor": "Run",
|
||||||
"Python.side_fluency.executor": "Run",
|
"Python.side_fluency.executor": "Run",
|
||||||
"Python.simple_mesh.executor": "Run",
|
"Python.simple_mesh.executor": "Run",
|
||||||
"Python.vtk_widget.executor": "Run",
|
"Python.vtk_widget.executor": "Run",
|
||||||
"Python.vulkan.executor": "Run",
|
"Python.vulkan.executor": "Run",
|
||||||
"RunOnceActivity.OpenProjectViewOnStart": "true",
|
"RunOnceActivity.OpenProjectViewOnStart": "true",
|
||||||
"RunOnceActivity.ShowReadmeOnStart": "true",
|
"RunOnceActivity.ShowReadmeOnStart": "true",
|
||||||
"RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252": "true",
|
"RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252": "true",
|
||||||
"RunOnceActivity.git.unshallow": "true",
|
"RunOnceActivity.git.unshallow": "true",
|
||||||
"RunOnceActivity.typescript.service.memoryLimit.init": "true",
|
"RunOnceActivity.typescript.service.memoryLimit.init": "true",
|
||||||
"codeWithMe.voiceChat.enabledByDefault": "false",
|
"codeWithMe.voiceChat.enabledByDefault": "false",
|
||||||
"git-widget-placeholder": "feature/occ-migration",
|
"git-widget-placeholder": "feature/occ-migration",
|
||||||
"last_opened_file_path": "/Volumes/Data_drive/Programming/fluency/src/fluency",
|
"last_opened_file_path": "/Volumes/Data_drive/Programming/fluency/src/fluency",
|
||||||
"node.js.detected.package.eslint": "true",
|
"node.js.detected.package.eslint": "true",
|
||||||
"node.js.selected.package.eslint": "(autodetect)",
|
"node.js.selected.package.eslint": "(autodetect)",
|
||||||
"node.js.selected.package.tslint": "(autodetect)",
|
"node.js.selected.package.tslint": "(autodetect)",
|
||||||
"nodejs_package_manager_path": "npm",
|
"nodejs_package_manager_path": "npm",
|
||||||
"settings.editor.selected.configurable": "project.propVCSSupport.DirectoryMappings"
|
"settings.editor.selected.configurable": "project.propVCSSupport.DirectoryMappings"
|
||||||
}
|
}
|
||||||
}]]></component>
|
}</component>
|
||||||
<component name="QodanaReportsService">
|
<component name="QodanaReportsService">
|
||||||
<option name="descriptions">
|
<option name="descriptions">
|
||||||
<ReportDescription localRun="true" path="/private/var/folders/kg/zm48w_r96yb68mlbzvb9gtq40000gn/T/qodana_output/qodana.sarif.json" reportGuid="5f5b823c-c594-48c5-ae1f-062e30303918" reportId="fluency/qodana/2024-02-04" />
|
<ReportDescription localRun="true" path="/private/var/folders/kg/zm48w_r96yb68mlbzvb9gtq40000gn/T/qodana_output/qodana.sarif.json" reportGuid="5f5b823c-c594-48c5-ae1f-062e30303918" reportId="fluency/qodana/2024-02-04" />
|
||||||
@@ -273,7 +272,15 @@
|
|||||||
<option name="project" value="LOCAL" />
|
<option name="project" value="LOCAL" />
|
||||||
<updated>1782673954850</updated>
|
<updated>1782673954850</updated>
|
||||||
</task>
|
</task>
|
||||||
<option name="localTasksCounter" value="21" />
|
<task id="LOCAL-00021" summary="- Tons of addtions">
|
||||||
|
<option name="closed" value="true" />
|
||||||
|
<created>1782679912834</created>
|
||||||
|
<option name="number" value="00021" />
|
||||||
|
<option name="presentableId" value="LOCAL-00021" />
|
||||||
|
<option name="project" value="LOCAL" />
|
||||||
|
<updated>1782679912834</updated>
|
||||||
|
</task>
|
||||||
|
<option name="localTasksCounter" value="22" />
|
||||||
<servers />
|
<servers />
|
||||||
</component>
|
</component>
|
||||||
<component name="TypeScriptGeneratedFilesManager">
|
<component name="TypeScriptGeneratedFilesManager">
|
||||||
|
|||||||
@@ -180,12 +180,31 @@ class OCGeometryKernel(GeometryKernel):
|
|||||||
from OCP.BRepAlgoAPI import BRepAlgoAPI_Fuse
|
from OCP.BRepAlgoAPI import BRepAlgoAPI_Fuse
|
||||||
from OCP.TopoDS import TopoDS_Shape
|
from OCP.TopoDS import TopoDS_Shape
|
||||||
|
|
||||||
face = self._get_shape(sketch)
|
# 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:
|
||||||
|
face = self._get_shape(sketch)
|
||||||
if face is None:
|
if face is None:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
"Cannot extrude: sketch has no geometry. "
|
"Cannot extrude: sketch has no geometry. "
|
||||||
"Draw a closed profile before extruding."
|
"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
|
# ``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.
|
# legacy cadquery objects. If it's not already a face, build one.
|
||||||
face = self._ensure_face(face)
|
face = self._ensure_face(face)
|
||||||
|
|||||||
@@ -34,6 +34,12 @@ class OCCSketchEntity(SketchEntity):
|
|||||||
self.handle = handle # SolveSpace solver entity handle
|
self.handle = handle # SolveSpace solver entity handle
|
||||||
self.is_construction: bool = False
|
self.is_construction: bool = False
|
||||||
self.constraints: List[str] = [] # Track applied constraint names for UI
|
self.constraints: List[str] = [] # Track applied constraint names for UI
|
||||||
|
# External / underlay entities are reference geometry projected from
|
||||||
|
# a 3D face (or otherwise supplied from outside the sketch). They live
|
||||||
|
# in the solver so user constraints can reference them, but they are
|
||||||
|
# *not* user-drawn, *not* deletable, *not* moveable, and never
|
||||||
|
# contribute to the sketch profile (detect_faces / get_geometry).
|
||||||
|
self.is_external: bool = False
|
||||||
|
|
||||||
|
|
||||||
class OCCSketch(SketchInterface):
|
class OCCSketch(SketchInterface):
|
||||||
@@ -60,6 +66,13 @@ class OCCSketch(SketchInterface):
|
|||||||
# after deleting an entity (python_solvespace has no per-entity delete).
|
# after deleting an entity (python_solvespace has no per-entity delete).
|
||||||
# Each entry: {"type": str, "ids": (int, ...), "params": tuple, "labels": set[str]}
|
# Each entry: {"type": str, "ids": (int, ...), "params": tuple, "labels": set[str]}
|
||||||
self._constraint_log: List[Dict[str, Any]] = []
|
self._constraint_log: List[Dict[str, Any]] = []
|
||||||
|
# External / underlay entity ids (face-projected reference geometry).
|
||||||
|
# Kept in their own set so we can:
|
||||||
|
# • render them with a distinct style
|
||||||
|
# • filter them out of get_closed_loops / detect_faces
|
||||||
|
# • refuse to delete / move them
|
||||||
|
# • clear them as a group when the source face is removed
|
||||||
|
self._external_entity_ids: set = set()
|
||||||
|
|
||||||
# Track first point as dragged/fixed for solver stability
|
# Track first point as dragged/fixed for solver stability
|
||||||
self._first_point_id: Optional[int] = None
|
self._first_point_id: Optional[int] = None
|
||||||
@@ -161,12 +174,21 @@ class OCCSketch(SketchInterface):
|
|||||||
return int(match.group(1)) if match else 0
|
return int(match.group(1)) if match else 0
|
||||||
|
|
||||||
def add_point(self, x: float, y: float) -> OCCSketchEntity:
|
def add_point(self, x: float, y: float) -> OCCSketchEntity:
|
||||||
"""Add a point to the sketch (added to solver + tracked)."""
|
"""Add a point to the sketch (added to solver + tracked).
|
||||||
|
|
||||||
|
The very first point added to an empty solver is auto-anchored via
|
||||||
|
``dragged`` to give the solver a stable reference frame. If the
|
||||||
|
sketch already carries external / underlay points (those are
|
||||||
|
always dragged at creation), we skip this auto-anchor — the
|
||||||
|
external point is the natural reference, and a second dragged
|
||||||
|
point would over-constrain the system and make any
|
||||||
|
user-to-external distance constraint unsolvable.
|
||||||
|
"""
|
||||||
entity_id = self._next_id()
|
entity_id = self._next_id()
|
||||||
|
|
||||||
# Add to solver
|
# Add to solver
|
||||||
solver_handle = self._solver.add_point_2d(x, y, self._wp)
|
solver_handle = self._solver.add_point_2d(x, y, self._wp)
|
||||||
if self._first_point_id is None:
|
if self._first_point_id is None and not self._external_entity_ids:
|
||||||
self._first_point_id = entity_id
|
self._first_point_id = entity_id
|
||||||
# Fix first point so solver has a reference
|
# Fix first point so solver has a reference
|
||||||
self._solver.dragged(solver_handle, self._wp)
|
self._solver.dragged(solver_handle, self._wp)
|
||||||
@@ -268,6 +290,148 @@ class OCCSketch(SketchInterface):
|
|||||||
|
|
||||||
return entity
|
return entity
|
||||||
|
|
||||||
|
# ─── External / underlay entities (face-projected reference geometry) ───
|
||||||
|
|
||||||
|
def add_external_point(self, x: float, y: float) -> OCCSketchEntity:
|
||||||
|
"""Add a point that participates in the solver but is *not* user-drawn.
|
||||||
|
|
||||||
|
External points are used to anchor projected face edges (sketch-on-
|
||||||
|
surface underlay) so the user can snap to them and add constraints
|
||||||
|
like "hole center 50mm from the body's top edge". The point is
|
||||||
|
immediately marked *fixed* in the solver (via ``dragged``) so it never
|
||||||
|
moves when other entities are dragged or re-solved.
|
||||||
|
|
||||||
|
External entities are skipped by ``get_closed_loops`` /
|
||||||
|
``detect_faces`` / ``get_geometry`` so they never contribute to the
|
||||||
|
extruded profile — they're reference geometry only.
|
||||||
|
"""
|
||||||
|
entity_id = self._next_id()
|
||||||
|
solver_handle = self._solver.add_point_2d(x, y, self._wp)
|
||||||
|
# Always fix external points — they MUST NOT move when the solver
|
||||||
|
# adjusts other entities. We bypass the first-point auto-fix in
|
||||||
|
# ``add_point`` (which would also fix the very first one and leave
|
||||||
|
# the rest free), and we apply dragged() unconditionally here.
|
||||||
|
self._solver.dragged(solver_handle, self._wp)
|
||||||
|
|
||||||
|
entity = OCCSketchEntity(
|
||||||
|
entity_id=entity_id, entity_type="point",
|
||||||
|
geometry=(x, y), handle=solver_handle,
|
||||||
|
)
|
||||||
|
entity.is_external = True
|
||||||
|
entity.is_construction = True # external points are reference / dashed
|
||||||
|
self._entities[entity_id] = entity
|
||||||
|
self._points[entity_id] = (x, y)
|
||||||
|
self._external_entity_ids.add(entity_id)
|
||||||
|
return entity
|
||||||
|
|
||||||
|
def add_external_line(self, start: SketchEntity, end: SketchEntity) -> OCCSketchEntity:
|
||||||
|
"""Add a line between two existing external points.
|
||||||
|
|
||||||
|
Both endpoints must already be external points (created via
|
||||||
|
:meth:`add_external_point`). External lines are tagged ``is_external``
|
||||||
|
and are excluded from the sketch's profile-detect path so they don't
|
||||||
|
pollute the extruded face. Constraints applied to external lines
|
||||||
|
(horizontal, vertical, parallel, perpendicular, midpoint) work
|
||||||
|
normally — the line handle is real — but the line itself never moves.
|
||||||
|
"""
|
||||||
|
entity_id = self._next_id()
|
||||||
|
s_ent = self._entities.get(start.id)
|
||||||
|
e_ent = self._entities.get(end.id)
|
||||||
|
if s_ent is None or e_ent is None:
|
||||||
|
raise ValueError("Start or end point not found in sketch")
|
||||||
|
if s_ent.handle is None or e_ent.handle is None:
|
||||||
|
raise ValueError("External endpoints must have solver handles")
|
||||||
|
|
||||||
|
solver_handle = self._solver.add_line_2d(s_ent.handle, e_ent.handle, self._wp)
|
||||||
|
x1, y1 = s_ent.geometry
|
||||||
|
x2, y2 = e_ent.geometry
|
||||||
|
entity = OCCSketchEntity(
|
||||||
|
entity_id=entity_id, entity_type="line",
|
||||||
|
geometry=((x1, y1), (x2, y2)),
|
||||||
|
handle=solver_handle,
|
||||||
|
)
|
||||||
|
entity.is_external = True
|
||||||
|
entity.is_construction = True
|
||||||
|
self._entities[entity_id] = entity
|
||||||
|
self._lines[entity_id] = (start.id, end.id)
|
||||||
|
self._external_entity_ids.add(entity_id)
|
||||||
|
return entity
|
||||||
|
|
||||||
|
def add_external_polyline(
|
||||||
|
self, uv_points: List[Tuple[float, float]]
|
||||||
|
) -> Tuple[List[OCCSketchEntity], List[OCCSketchEntity]]:
|
||||||
|
"""Bulk-import a polyline of UV points as external (underlay) entities.
|
||||||
|
|
||||||
|
Creates one external point per unique UV position and one external
|
||||||
|
line segment between consecutive points. Returns
|
||||||
|
``(points, lines)`` in the order they were created so the caller can
|
||||||
|
keep references (e.g. for rendering or for toggling).
|
||||||
|
|
||||||
|
Points very close to each other (within 1e-6 UV units) are merged
|
||||||
|
into a single shared point, so a closed rectangle becomes 4 unique
|
||||||
|
points and 4 line segments (not 4 points and 4 lines + 4 duplicates
|
||||||
|
at the corners).
|
||||||
|
"""
|
||||||
|
if len(uv_points) < 2:
|
||||||
|
return [], []
|
||||||
|
# Deduplicate nearby points so shared corners (e.g. a rectangle's
|
||||||
|
# four vertices) are *one* point entity reused by two line segments.
|
||||||
|
eps = 1e-6
|
||||||
|
points: List[OCCSketchEntity] = []
|
||||||
|
coord_to_entity: Dict[Tuple[int, int], OCCSketchEntity] = {}
|
||||||
|
for (u, v) in uv_points:
|
||||||
|
key = (int(round(u / eps)), int(round(v / eps)))
|
||||||
|
ent = coord_to_entity.get(key)
|
||||||
|
if ent is None:
|
||||||
|
ent = self.add_external_point(float(u), float(v))
|
||||||
|
coord_to_entity[key] = ent
|
||||||
|
points.append(ent)
|
||||||
|
lines: List[OCCSketchEntity] = []
|
||||||
|
for i in range(len(points) - 1):
|
||||||
|
try:
|
||||||
|
ln = self.add_external_line(points[i], points[i + 1])
|
||||||
|
lines.append(ln)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
return points, lines
|
||||||
|
|
||||||
|
def remove_external_entities(self) -> None:
|
||||||
|
"""Remove every external / underlay entity and prune related constraints.
|
||||||
|
|
||||||
|
Used when the source face is removed (or rebinded). External
|
||||||
|
entities are *never* user-deletable; this is the only way to clear
|
||||||
|
them. Any constraint that references a removed external id is
|
||||||
|
pruned from the constraint log and the solver is rebuilt from the
|
||||||
|
surviving user geometry so the next solve is consistent.
|
||||||
|
"""
|
||||||
|
if not self._external_entity_ids:
|
||||||
|
return
|
||||||
|
# Wipe external entities from local tracking.
|
||||||
|
for eid in list(self._external_entity_ids):
|
||||||
|
if eid in self._entities:
|
||||||
|
del self._entities[eid]
|
||||||
|
self._points.pop(eid, None)
|
||||||
|
self._lines.pop(eid, None)
|
||||||
|
self._circles.pop(eid, None)
|
||||||
|
self._arcs.pop(eid, None)
|
||||||
|
# Also clean lines that USE an external point as an endpoint but
|
||||||
|
# somehow aren't themselves external (defensive — shouldn't happen
|
||||||
|
# via the public API, but rebuild_solver needs a clean graph).
|
||||||
|
for lid, (sid, eid2) in list(self._lines.items()):
|
||||||
|
if sid in self._external_entity_ids or eid2 in self._external_entity_ids:
|
||||||
|
del self._lines[lid]
|
||||||
|
if lid in self._entities:
|
||||||
|
del self._entities[lid]
|
||||||
|
removed = set(self._external_entity_ids)
|
||||||
|
self._external_entity_ids.clear()
|
||||||
|
self._prune_log_for(removed)
|
||||||
|
self._rebuild_solver()
|
||||||
|
self._rebuild_labels()
|
||||||
|
|
||||||
|
def get_external_entity_ids(self) -> set:
|
||||||
|
"""Return the set of external (underlay) entity ids currently in the sketch."""
|
||||||
|
return set(self._external_entity_ids)
|
||||||
|
|
||||||
def add_rectangle(
|
def add_rectangle(
|
||||||
self, corner1: Tuple[float, float], corner2: Tuple[float, float]
|
self, corner1: Tuple[float, float], corner2: Tuple[float, float]
|
||||||
) -> List[OCCSketchEntity]:
|
) -> List[OCCSketchEntity]:
|
||||||
@@ -666,11 +830,15 @@ class OCCSketch(SketchInterface):
|
|||||||
if len(faces) == 1:
|
if len(faces) == 1:
|
||||||
return self.build_face_geometry(faces[0])
|
return self.build_face_geometry(faces[0])
|
||||||
|
|
||||||
# Fallback: wrap the first circle, or the polygon, as a single-loop face.
|
# Fallback: wrap the first non-external circle, or the polygon, as a
|
||||||
|
# single-loop face. External (underlay) circles are reference geometry
|
||||||
|
# and must not be returned as the extruded profile.
|
||||||
if self._circles:
|
if self._circles:
|
||||||
for entity_id, (center_id, radius) in self._circles.items():
|
for entity_id, (center_id, radius) in self._circles.items():
|
||||||
|
if entity_id in self._external_entity_ids:
|
||||||
|
continue
|
||||||
center_entity = self._entities.get(center_id)
|
center_entity = self._entities.get(center_id)
|
||||||
if center_entity and center_entity.geometry:
|
if center_entity and center_entity.geometry and not center_entity.is_external:
|
||||||
cx, cy = center_entity.geometry
|
cx, cy = center_entity.geometry
|
||||||
face_dict = {
|
face_dict = {
|
||||||
"outer": {"type": "circle", "center": (cx, cy), "radius": radius},
|
"outer": {"type": "circle", "center": (cx, cy), "radius": radius},
|
||||||
@@ -710,11 +878,15 @@ class OCCSketch(SketchInterface):
|
|||||||
return points
|
return points
|
||||||
|
|
||||||
def get_polygon_points(self) -> List[Point2D]:
|
def get_polygon_points(self) -> List[Point2D]:
|
||||||
"""Get ordered polygon points from connected lines (uses solved positions)."""
|
"""Get ordered polygon points from connected lines (uses solved positions).
|
||||||
|
|
||||||
|
External (underlay) lines are skipped — they are reference geometry
|
||||||
|
only, not part of the sketch profile.
|
||||||
|
"""
|
||||||
adjacency: Dict[Tuple[float, float], List[Tuple[float, float]]] = {}
|
adjacency: Dict[Tuple[float, float], List[Tuple[float, float]]] = {}
|
||||||
|
|
||||||
for entity in self._entities.values():
|
for entity in self._entities.values():
|
||||||
if entity.entity_type == "line" and entity.geometry:
|
if entity.entity_type == "line" and entity.geometry and not entity.is_external:
|
||||||
p1, p2 = entity.geometry
|
p1, p2 = entity.geometry
|
||||||
if p1 not in adjacency:
|
if p1 not in adjacency:
|
||||||
adjacency[p1] = []
|
adjacency[p1] = []
|
||||||
@@ -752,9 +924,18 @@ class OCCSketch(SketchInterface):
|
|||||||
_SNAP_TOL: float = 1e-4 # world-unit tolerance for snapping line endpoints
|
_SNAP_TOL: float = 1e-4 # world-unit tolerance for snapping line endpoints
|
||||||
|
|
||||||
def _line_segments(self) -> List[Tuple[Tuple[float, float], Tuple[float, float]]]:
|
def _line_segments(self) -> List[Tuple[Tuple[float, float], Tuple[float, float]]]:
|
||||||
"""Current line segments as world-coordinate tuples (uses solved positions)."""
|
"""Current line segments as world-coordinate tuples (uses solved positions).
|
||||||
|
|
||||||
|
External (underlay) lines are excluded: they're reference geometry
|
||||||
|
projected from a 3D face, not user-drawn sketch profile, and must
|
||||||
|
not contribute to detect_faces / get_geometry / extrusion.
|
||||||
|
"""
|
||||||
segs: List[Tuple[Tuple[float, float], Tuple[float, float]]] = []
|
segs: List[Tuple[Tuple[float, float], Tuple[float, float]]] = []
|
||||||
for line_id, (sid, eid2) in self._lines.items():
|
for line_id, (sid, eid2) in self._lines.items():
|
||||||
|
# Skip external/underlay lines — they live in the solver for
|
||||||
|
# constraint referencing, but are not part of the sketch profile.
|
||||||
|
if line_id in self._external_entity_ids:
|
||||||
|
continue
|
||||||
s_ent = self._entities.get(sid)
|
s_ent = self._entities.get(sid)
|
||||||
e_ent = self._entities.get(eid2)
|
e_ent = self._entities.get(eid2)
|
||||||
if s_ent and e_ent and s_ent.geometry and e_ent.geometry:
|
if s_ent and e_ent and s_ent.geometry and e_ent.geometry:
|
||||||
@@ -1058,6 +1239,7 @@ class OCCSketch(SketchInterface):
|
|||||||
self._entity_counter = 0
|
self._entity_counter = 0
|
||||||
self._constraint_count = 0
|
self._constraint_count = 0
|
||||||
self._constraint_log.clear()
|
self._constraint_log.clear()
|
||||||
|
self._external_entity_ids.clear()
|
||||||
self._first_point_id = None
|
self._first_point_id = None
|
||||||
|
|
||||||
def _prune_log_for(self, removed_ids: set) -> None:
|
def _prune_log_for(self, removed_ids: set) -> None:
|
||||||
|
|||||||
+970
-190
File diff suppressed because it is too large
Load Diff
@@ -41,7 +41,13 @@ class OCCRenderer(Renderer):
|
|||||||
self._parent_widget: Any = None
|
self._parent_widget: Any = None
|
||||||
self._last_mouse_x: int = 0
|
self._last_mouse_x: int = 0
|
||||||
self._last_mouse_y: int = 0
|
self._last_mouse_y: int = 0
|
||||||
self._nav_mode: Optional[str] = None # "rotate" | "pan" | "zoom" | None
|
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:
|
def initialize(self, parent_widget: Any) -> bool:
|
||||||
"""Initialise OCC viewer inside *parent_widget* (a QWidget)."""
|
"""Initialise OCC viewer inside *parent_widget* (a QWidget)."""
|
||||||
@@ -156,6 +162,18 @@ class OCCRenderer(Renderer):
|
|||||||
# Default display mode = shaded (AIS_Shaded = 1)
|
# Default display mode = shaded (AIS_Shaded = 1)
|
||||||
context.SetDisplayMode(1, True)
|
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.
|
# Attach OCC view to the Qt widget via the native window handle.
|
||||||
win = Aspect_NeutralWindow()
|
win = Aspect_NeutralWindow()
|
||||||
win.SetNativeHandle(hwnd)
|
win.SetNativeHandle(hwnd)
|
||||||
@@ -372,10 +390,100 @@ class OCCRenderer(Renderer):
|
|||||||
if obj is not None:
|
if obj is not None:
|
||||||
self.remove_object(obj)
|
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:
|
def clear_scene(self) -> None:
|
||||||
"""Remove all objects from the scene."""
|
"""Remove all objects from the scene."""
|
||||||
if self._context is None:
|
if self._context is None:
|
||||||
return
|
return
|
||||||
|
self.clear_preview()
|
||||||
|
self.clear_face_highlight()
|
||||||
# Remove every displayed AIS object. ``RemoveAll`` is the cleanest
|
# Remove every displayed AIS object. ``RemoveAll`` is the cleanest
|
||||||
# path; fall back to iterating the displayed list if unavailable.
|
# path; fall back to iterating the displayed list if unavailable.
|
||||||
try:
|
try:
|
||||||
@@ -642,6 +750,18 @@ class OCCRenderer(Renderer):
|
|||||||
except Exception:
|
except Exception:
|
||||||
return None
|
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 origin: use the face's bounding-box centre projected onto the
|
||||||
# plane, so the UV frame is centred on the face (nicer for sketching).
|
# plane, so the UV frame is centred on the face (nicer for sketching).
|
||||||
from OCP.Bnd import Bnd_Box
|
from OCP.Bnd import Bnd_Box
|
||||||
@@ -652,7 +772,6 @@ class OCCRenderer(Renderer):
|
|||||||
cx, cy, cz = (xmin + xmax) / 2.0, (ymin + ymax) / 2.0, (zmin + zmax) / 2.0
|
cx, cy, cz = (xmin + xmax) / 2.0, (ymin + ymax) / 2.0, (zmin + zmax) / 2.0
|
||||||
# Project the bbox centre onto the plane.
|
# Project the bbox centre onto the plane.
|
||||||
pln_origin = pln.Location() # gp_Pnt
|
pln_origin = pln.Location() # gp_Pnt
|
||||||
n = pln.Axis().Direction()
|
|
||||||
nx, ny, nz = n.X(), n.Y(), n.Z()
|
nx, ny, nz = n.X(), n.Y(), n.Z()
|
||||||
# signed distance from bbox centre to plane
|
# signed distance from bbox centre to plane
|
||||||
d = (cx - pln_origin.X()) * nx + (cy - pln_origin.Y()) * ny + (cz - pln_origin.Z()) * nz
|
d = (cx - pln_origin.X()) * nx + (cy - pln_origin.Y()) * ny + (cz - pln_origin.Z()) * nz
|
||||||
@@ -681,19 +800,84 @@ class OCCRenderer(Renderer):
|
|||||||
px = pln.XAxis().Direction()
|
px = pln.XAxis().Direction()
|
||||||
x_dir = (px.X(), px.Y(), px.Z())
|
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 {
|
return {
|
||||||
"origin": origin,
|
"origin": origin,
|
||||||
"normal": (nx, ny, nz),
|
"normal": (nx, ny, nz),
|
||||||
"x_dir": x_dir,
|
"x_dir": x_dir,
|
||||||
"face": face,
|
"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 ──────────────────────────────
|
# ─── Mouse / keyboard event forwarding ──────────────────────────────
|
||||||
#
|
#
|
||||||
# CAD-style navigation:
|
# CAD-style navigation:
|
||||||
# • Left button drag → orbit (rotate around target)
|
# • Left button drag → orbit (rotate around target)
|
||||||
# • Middle button drag → pan
|
# • Middle button drag → pan
|
||||||
# • Right button drag → dolly / zoom toward cursor
|
# • Right button → (reserved for future use)
|
||||||
# • Wheel → zoom toward cursor
|
# • Wheel → zoom toward cursor
|
||||||
# • Double-click left → fit all (handled by the widget)
|
# • Double-click left → fit all (handled by the widget)
|
||||||
|
|
||||||
@@ -717,12 +901,13 @@ class OCCRenderer(Renderer):
|
|||||||
self._view.StartRotation(x, y, 0.4)
|
self._view.StartRotation(x, y, 0.4)
|
||||||
elif btn == Qt.MiddleButton:
|
elif btn == Qt.MiddleButton:
|
||||||
self._nav_mode = "pan"
|
self._nav_mode = "pan"
|
||||||
# Pan uses deltas from this starting point.
|
# 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)
|
self._view.Pan(0, 0, 1.0, True)
|
||||||
elif btn == Qt.RightButton:
|
|
||||||
self._nav_mode = "zoom"
|
|
||||||
self._view.StartZoomAtPoint(x, y)
|
|
||||||
else:
|
else:
|
||||||
|
# Right button (and any other) is reserved — no gesture yet.
|
||||||
self._nav_mode = None
|
self._nav_mode = None
|
||||||
|
|
||||||
self._last_mouse_x = x
|
self._last_mouse_x = x
|
||||||
@@ -739,12 +924,12 @@ class OCCRenderer(Renderer):
|
|||||||
if self._nav_mode == "rotate" and (buttons & Qt.LeftButton):
|
if self._nav_mode == "rotate" and (buttons & Qt.LeftButton):
|
||||||
self._view.Rotation(x, y)
|
self._view.Rotation(x, y)
|
||||||
elif self._nav_mode == "pan" and (buttons & Qt.MiddleButton):
|
elif self._nav_mode == "pan" and (buttons & Qt.MiddleButton):
|
||||||
dx = x - self._last_mouse_x
|
# Cumulative delta from the gesture start — OCC interprets
|
||||||
dy = y - self._last_mouse_y
|
# 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.
|
# dy negated because Qt y grows downward while OCC y grows upward.
|
||||||
self._view.Pan(dx, -dy, 1.0, False)
|
self._view.Pan(dx, -dy, 1.0, False)
|
||||||
elif self._nav_mode == "zoom" and (buttons & Qt.RightButton):
|
|
||||||
self._view.ZoomAtPoint(self._last_mouse_x, self._last_mouse_y, x, y)
|
|
||||||
else:
|
else:
|
||||||
# Idle: dynamic highlighting under the cursor.
|
# Idle: dynamic highlighting under the cursor.
|
||||||
self._context.MoveTo(x, y, self._view, True)
|
self._context.MoveTo(x, y, self._view, True)
|
||||||
|
|||||||
@@ -237,5 +237,896 @@ class TestOCCSketch:
|
|||||||
assert abs(g.Mass() - expected) < 0.1
|
assert abs(g.Mass() - expected) < 0.1
|
||||||
|
|
||||||
|
|
||||||
|
class TestExternalEntities:
|
||||||
|
"""Tests for the underlay / face-projected reference entity API.
|
||||||
|
|
||||||
|
External entities live in the solver so user constraints can reference
|
||||||
|
them (e.g. "hole center 50 mm from the body's top edge"), but they
|
||||||
|
are *not* part of the sketch profile and must be excluded from
|
||||||
|
detect_faces / get_geometry.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_add_external_point_flags_and_fixes(self):
|
||||||
|
sk = OCCSketch()
|
||||||
|
ep = sk.add_external_point(5.0, 7.0)
|
||||||
|
assert ep is not None
|
||||||
|
assert ep.is_external is True
|
||||||
|
assert ep.is_construction is True
|
||||||
|
# External point is in the solver, with a non-None handle.
|
||||||
|
assert ep.handle is not None
|
||||||
|
# The point is in the entities / points dicts.
|
||||||
|
assert ep.id in sk._entities
|
||||||
|
assert ep.id in sk._points
|
||||||
|
# It's tracked as external.
|
||||||
|
assert ep.id in sk.get_external_entity_ids()
|
||||||
|
|
||||||
|
def test_add_external_line_requires_external_endpoints(self):
|
||||||
|
sk = OCCSketch()
|
||||||
|
a = sk.add_external_point(0, 0)
|
||||||
|
b = sk.add_external_point(10, 0)
|
||||||
|
line = sk.add_external_line(a, b)
|
||||||
|
assert line is not None
|
||||||
|
assert line.is_external is True
|
||||||
|
assert line.is_construction is True
|
||||||
|
assert line.handle is not None
|
||||||
|
assert line.id in sk._lines
|
||||||
|
assert line.id in sk.get_external_entity_ids()
|
||||||
|
|
||||||
|
def test_add_external_polyline_shares_corners(self):
|
||||||
|
sk = OCCSketch()
|
||||||
|
# Closed rectangle: 4 unique corners reused at the joints.
|
||||||
|
pts = [(0, 0), (10, 0), (10, 10), (0, 10), (0, 0)]
|
||||||
|
points, lines = sk.add_external_polyline(pts)
|
||||||
|
# 4 segments, 5 UV samples but the 1st and last are the same corner.
|
||||||
|
assert len(lines) == 4
|
||||||
|
# The 5 samples share the rectangle's 4 corners → 4 unique point entities.
|
||||||
|
assert len(set(p.id for p in points)) == 4
|
||||||
|
# All are external.
|
||||||
|
assert all(p.is_external for p in points)
|
||||||
|
assert all(ln.is_external for ln in lines)
|
||||||
|
|
||||||
|
def test_external_entities_excluded_from_detect_faces(self):
|
||||||
|
sk = OCCSketch()
|
||||||
|
# Underlay: a 20x20 square projected from a face (closed polyline).
|
||||||
|
sk.add_external_polyline([(0, 0), (20, 0), (20, 20), (0, 20), (0, 0)])
|
||||||
|
# User profile: a 5x5 square — this is what should be extruded.
|
||||||
|
a = sk.add_point(2, 2); b = sk.add_point(8, 2)
|
||||||
|
c = sk.add_point(8, 8); d = sk.add_point(2, 8)
|
||||||
|
sk.add_line(a, b); sk.add_line(b, c)
|
||||||
|
sk.add_line(c, d); sk.add_line(d, a)
|
||||||
|
faces = sk.detect_faces()
|
||||||
|
# Only the user-drawn face (5x5 square) should be detected.
|
||||||
|
assert len(faces) == 1
|
||||||
|
outer = faces[0]["outer"]
|
||||||
|
assert outer["type"] == "polygon"
|
||||||
|
# 5 vertices on the outer loop (4 corners + closing point).
|
||||||
|
assert len(outer["points"]) == 5
|
||||||
|
# It must be the user square, not the underlay.
|
||||||
|
xs = [p[0] for p in outer["points"][:4]]
|
||||||
|
ys = [p[1] for p in outer["points"][:4]]
|
||||||
|
assert min(xs) >= 2 and max(xs) <= 8
|
||||||
|
assert min(ys) >= 2 and max(ys) <= 8
|
||||||
|
|
||||||
|
def test_external_entities_excluded_from_get_polygon_points(self):
|
||||||
|
sk = OCCSketch()
|
||||||
|
sk.add_external_polyline([(0, 0), (100, 0), (100, 100), (0, 100), (0, 0)])
|
||||||
|
a = sk.add_point(1, 1); b = sk.add_point(2, 1)
|
||||||
|
c = sk.add_point(2, 2); d = sk.add_point(1, 2)
|
||||||
|
sk.add_line(a, b); sk.add_line(b, c)
|
||||||
|
sk.add_line(c, d); sk.add_line(d, a)
|
||||||
|
poly = sk.get_polygon_points()
|
||||||
|
# The user square (1..2 range) should appear, not the 0..100 underlay.
|
||||||
|
assert all(1.0 <= p.x <= 2.0 for p in poly)
|
||||||
|
assert all(1.0 <= p.y <= 2.0 for p in poly)
|
||||||
|
|
||||||
|
def test_external_entities_excluded_from_get_geometry(self):
|
||||||
|
"""Underlay must never appear in the extruded face."""
|
||||||
|
from OCP.GProp import GProp_GProps
|
||||||
|
from OCP.BRepGProp import BRepGProp
|
||||||
|
|
||||||
|
sk = OCCSketch()
|
||||||
|
# Underlay (NOT to be extruded).
|
||||||
|
sk.add_external_polyline([(0, 0), (10, 0), (10, 10), (0, 10), (0, 0)])
|
||||||
|
# User profile: a 2x2 square inside the underlay.
|
||||||
|
a = sk.add_point(1, 1); b = sk.add_point(3, 1)
|
||||||
|
c = sk.add_point(3, 3); d = sk.add_point(1, 3)
|
||||||
|
sk.add_line(a, b); sk.add_line(b, c)
|
||||||
|
sk.add_line(c, d); sk.add_line(d, a)
|
||||||
|
geom = sk.get_geometry()
|
||||||
|
# Volume = 2 * 2 * 4 = 16, NOT 10 * 10 * 4 = 400.
|
||||||
|
kernel = OCGeometryKernel()
|
||||||
|
solid = kernel.extrude(geom, 4.0)
|
||||||
|
s = kernel._get_shape(solid)
|
||||||
|
g = GProp_GProps()
|
||||||
|
BRepGProp.VolumeProperties_s(s, g)
|
||||||
|
assert abs(g.Mass() - 16.0) < 0.5
|
||||||
|
|
||||||
|
def test_distance_to_external_point_constraint(self):
|
||||||
|
"""The headline use case: hole position fixed relative to a face edge.
|
||||||
|
|
||||||
|
User draws a circle (the hole) and a distance from its centre to
|
||||||
|
a face-projected point. After solve, the circle centre should be
|
||||||
|
exactly the requested distance from the external point.
|
||||||
|
"""
|
||||||
|
sk = OCCSketch()
|
||||||
|
# Underlay corner: pick a known anchor on the projected face.
|
||||||
|
anchor = sk.add_external_point(0.0, 0.0)
|
||||||
|
# User geometry: a 1mm circle for the hole.
|
||||||
|
hole_centre = sk.add_point(7.0, 4.0) # start position: 7 from anchor
|
||||||
|
sk.add_circle(hole_centre, 1.0)
|
||||||
|
# Constrain the hole centre 50 mm from the underlay corner.
|
||||||
|
ok = sk.constrain_distance(anchor, hole_centre, 50.0)
|
||||||
|
assert ok
|
||||||
|
assert sk.solve()
|
||||||
|
solved = sk.get_solved_point(hole_centre.id)
|
||||||
|
assert solved is not None
|
||||||
|
# The starting (7, 4) is well short of 50, so the constraint
|
||||||
|
# forces the centre out to a point on the 50mm circle around (0,0).
|
||||||
|
x, y = solved
|
||||||
|
assert abs(math_hypot(x, y) - 50.0) < 0.01
|
||||||
|
|
||||||
|
def test_remove_external_entities_clears_them(self):
|
||||||
|
sk = OCCSketch()
|
||||||
|
sk.add_external_polyline([(0, 0), (10, 0), (10, 10), (0, 10), (0, 0)])
|
||||||
|
assert len(sk.get_external_entity_ids()) > 0
|
||||||
|
sk.remove_external_entities()
|
||||||
|
assert len(sk.get_external_entity_ids()) == 0
|
||||||
|
# No external points/lines left in the tracking dicts.
|
||||||
|
for eid in sk._entities:
|
||||||
|
assert not getattr(sk._entities[eid], "is_external", False)
|
||||||
|
|
||||||
|
def test_remove_external_entities_prunes_related_constraints(self):
|
||||||
|
"""Constraints referencing external entities are pruned on removal.
|
||||||
|
|
||||||
|
A distance to an external point is recorded in the constraint log
|
||||||
|
on the ids of both endpoints. After remove_external_entities(),
|
||||||
|
those entries are gone and the solver rebuilds without them.
|
||||||
|
"""
|
||||||
|
sk = OCCSketch()
|
||||||
|
anchor = sk.add_external_point(0.0, 0.0)
|
||||||
|
user = sk.add_point(20.0, 0.0)
|
||||||
|
sk.constrain_distance(anchor, user, 5.0)
|
||||||
|
sk.solve()
|
||||||
|
# At least one log entry references the external anchor.
|
||||||
|
assert any(anchor.id in entry["ids"] for entry in sk._constraint_log)
|
||||||
|
# Now wipe the underlay.
|
||||||
|
sk.remove_external_entities()
|
||||||
|
# The distance constraint is gone, and the user point is free.
|
||||||
|
assert not any(anchor.id in entry["ids"] for entry in sk._constraint_log)
|
||||||
|
assert sk.solve()
|
||||||
|
|
||||||
|
def test_external_polyline_dedupes_close_points(self):
|
||||||
|
"""Co-located UV samples share a single point entity (closed loops)."""
|
||||||
|
sk = OCCSketch()
|
||||||
|
# Closed rectangle (closing point == start point).
|
||||||
|
pts = [(1.0, 1.0), (9.0, 1.0), (9.0, 9.0), (1.0, 9.0), (1.0, 1.0)]
|
||||||
|
points, lines = sk.add_external_polyline(pts)
|
||||||
|
# 5 samples → 4 unique points (start/end collapse).
|
||||||
|
assert len(set(p.id for p in points)) == 4
|
||||||
|
# 4 segments connect them.
|
||||||
|
assert len(lines) == 4
|
||||||
|
# Every line's endpoints are among the 4 points.
|
||||||
|
point_ids = {p.id for p in points}
|
||||||
|
for line_id, (sid, eid2) in sk._lines.items():
|
||||||
|
if line_id in sk.get_external_entity_ids():
|
||||||
|
assert sid in point_ids and eid2 in point_ids
|
||||||
|
|
||||||
|
def test_external_point_is_solver_fixed(self):
|
||||||
|
"""An external point's solver parameters must not change on re-solve.
|
||||||
|
|
||||||
|
python_solvespace drags the first user point; external points use
|
||||||
|
``dragged`` directly so dragging a user point near an external
|
||||||
|
reference doesn't shift the reference.
|
||||||
|
"""
|
||||||
|
sk = OCCSketch()
|
||||||
|
ep = sk.add_external_point(3.0, 4.0)
|
||||||
|
# Add a user point; solve; record the external point's solved
|
||||||
|
# params. Then delete the user point and add another one; the
|
||||||
|
# external point's params must not have moved.
|
||||||
|
sk.add_point(100.0, 0.0)
|
||||||
|
sk.solve()
|
||||||
|
x0, y0 = sk.solver.params(ep.handle.params)
|
||||||
|
for dx in range(-5, 6):
|
||||||
|
sk.add_point(100.0 + dx, 0.0)
|
||||||
|
sk.solve()
|
||||||
|
x1, y1 = sk.solver.params(ep.handle.params)
|
||||||
|
assert abs(x1 - x0) < 1e-6
|
||||||
|
assert abs(y1 - y0) < 1e-6
|
||||||
|
|
||||||
|
def test_horizontal_constraint_on_external_line(self):
|
||||||
|
"""Horizontal constraint involving a partly-external line is solvable.
|
||||||
|
|
||||||
|
Both external endpoints are dragged (fixed), so a horizontal
|
||||||
|
constraint between them is over-determined when their y values
|
||||||
|
differ. To make the system solvable we add a free user point
|
||||||
|
connected to one external point via a line, then constrain that
|
||||||
|
line horizontal — the user endpoint is dragged to a y that
|
||||||
|
matches the external one, satisfying the constraint.
|
||||||
|
"""
|
||||||
|
sk = OCCSketch()
|
||||||
|
a = sk.add_external_point(0.0, 0.0)
|
||||||
|
# Add a free user point first (skipped auto-drag because external
|
||||||
|
# points exist, so this one is free).
|
||||||
|
free = sk.add_point(7.0, 5.0)
|
||||||
|
# And an external endpoint to pair with the free point in a line.
|
||||||
|
b = sk.add_external_point(0.0, 0.0)
|
||||||
|
line = sk.add_external_line(b, free)
|
||||||
|
# Constrain it horizontal; the free point should drop to y=0.
|
||||||
|
sk.constrain_horizontal(line)
|
||||||
|
assert sk.solve()
|
||||||
|
sa = sk.get_solved_point(b.id)
|
||||||
|
sf = sk.get_solved_point(free.id)
|
||||||
|
assert sa is not None and sf is not None
|
||||||
|
assert abs(sa[1] - sf[1]) < 1e-6
|
||||||
|
|
||||||
|
def test_cleared_sketch_drops_external_entities(self):
|
||||||
|
sk = OCCSketch()
|
||||||
|
sk.add_external_polyline([(0, 0), (1, 0), (1, 1), (0, 1), (0, 0)])
|
||||||
|
sk.add_point(5, 5)
|
||||||
|
assert len(sk.get_external_entity_ids()) > 0
|
||||||
|
sk.clear()
|
||||||
|
assert len(sk.get_external_entity_ids()) == 0
|
||||||
|
assert sk.get_entity_count() == 0
|
||||||
|
|
||||||
|
|
||||||
|
class TestExtrudeCutFix:
|
||||||
|
"""Tests for the cut/union logic in MainWindow._extrude_sketch.
|
||||||
|
|
||||||
|
The old code stored the boolean result in the *tool* (newly extruded)
|
||||||
|
body, leaving the *target* body untouched — so the user would see a
|
||||||
|
separate "cavity-shaped" body next to the original instead of a
|
||||||
|
cavity in the original. After deleting that extra body, the next
|
||||||
|
extrude-cutter saw ``len(existing) <= 1`` and silently skipped the
|
||||||
|
cut, producing an unconstrained new body that looked "added without
|
||||||
|
cut". The fix:
|
||||||
|
|
||||||
|
1. Apply the boolean to the *target* (existing[0]) body.
|
||||||
|
2. Remove the tool body from the component.
|
||||||
|
3. Re-render the target in place.
|
||||||
|
|
||||||
|
These tests verify the boolean operation produces the right solid and
|
||||||
|
that the post-extrude bookkeeping leaves exactly the right bodies
|
||||||
|
in the component.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_boolean_difference_modifies_target_not_tool(self):
|
||||||
|
"""The fix: cut goes into the target, tool is removed.
|
||||||
|
|
||||||
|
Reproduces the cut/merge flow from ``_extrude_sketch`` without
|
||||||
|
spinning up the full MainWindow: build a target + tool body,
|
||||||
|
run boolean_difference, then verify the target's volume dropped
|
||||||
|
and the tool is no longer needed.
|
||||||
|
"""
|
||||||
|
from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox
|
||||||
|
from fluency.geometry_occ.kernel import OCGeometryKernel, OCCGeometryObject
|
||||||
|
from OCP.GProp import GProp_GProps
|
||||||
|
from OCP.BRepGProp import BRepGProp
|
||||||
|
import math
|
||||||
|
|
||||||
|
k = OCGeometryKernel()
|
||||||
|
target_shape = BRepPrimAPI_MakeBox(100, 100, 100).Shape()
|
||||||
|
target_obj = OCCGeometryObject(target_shape, {"type": "box"})
|
||||||
|
|
||||||
|
# Tool: a 20x20x200 cuboid at the corner of the box, to make the
|
||||||
|
# expected volume easy to compute.
|
||||||
|
from OCP.BRepPrimAPI import BRepPrimAPI_MakePrism
|
||||||
|
from OCP.gp import gp_Pnt, gp_Vec
|
||||||
|
# 20x20 square at (0,0,0), extruded along +Z by 200.
|
||||||
|
from OCP.BRepBuilderAPI import BRepBuilderAPI_MakePolygon
|
||||||
|
mp = BRepBuilderAPI_MakePolygon()
|
||||||
|
for (x, y) in [(0, 0), (20, 0), (20, 20), (0, 20)]:
|
||||||
|
mp.Add(gp_Pnt(x, y, 0))
|
||||||
|
mp.Close()
|
||||||
|
from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeFace
|
||||||
|
face = BRepBuilderAPI_MakeFace(mp.Wire()).Face()
|
||||||
|
tool_shape = BRepPrimAPI_MakePrism(
|
||||||
|
face, gp_Vec(0, 0, 200)
|
||||||
|
).Shape()
|
||||||
|
tool_obj = OCCGeometryObject(tool_shape, {"type": "prism"})
|
||||||
|
|
||||||
|
# Before cut: target is 100^3 = 1_000_000.
|
||||||
|
g0 = GProp_GProps()
|
||||||
|
BRepGProp.VolumeProperties_s(k._get_shape(target_obj), g0)
|
||||||
|
assert abs(g0.Mass() - 1_000_000.0) < 1.0
|
||||||
|
|
||||||
|
# Apply the fix: result goes to the target, not the tool.
|
||||||
|
result = k.boolean_difference(target_obj, tool_obj)
|
||||||
|
target_obj_geometry = result
|
||||||
|
|
||||||
|
# After cut: target is 1_000_000 - 20*20*100 = 960_000
|
||||||
|
# (the prism only intersects the box in z=[0,100], i.e. 100 deep).
|
||||||
|
g1 = GProp_GProps()
|
||||||
|
BRepGProp.VolumeProperties_s(
|
||||||
|
k._get_shape(target_obj_geometry), g1
|
||||||
|
)
|
||||||
|
assert abs(g1.Mass() - 960_000.0) < 1.0
|
||||||
|
|
||||||
|
def test_boolean_difference_does_not_leave_separate_cavity_body(self):
|
||||||
|
"""Sanity: the cut result is a single body (not two).
|
||||||
|
|
||||||
|
The OLD bug stored the cut result in a SECOND body, so after a
|
||||||
|
cut the user would see the original body PLUS a "cavity-shaped"
|
||||||
|
body — the user thought the cut worked but it was just two
|
||||||
|
separate solids. With the fix the cut is folded into the
|
||||||
|
target, so a single body remains.
|
||||||
|
"""
|
||||||
|
from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox
|
||||||
|
from OCP.BRepAlgoAPI import BRepAlgoAPI_Cut
|
||||||
|
from OCP.TopExp import TopExp_Explorer
|
||||||
|
from OCP.TopAbs import TopAbs_SOLID
|
||||||
|
from fluency.geometry_occ.kernel import OCGeometryKernel, OCCGeometryObject
|
||||||
|
|
||||||
|
k = OCGeometryKernel()
|
||||||
|
target_shape = BRepPrimAPI_MakeBox(100, 100, 100).Shape()
|
||||||
|
target_obj = OCCGeometryObject(target_shape, {})
|
||||||
|
|
||||||
|
# Tool: small box at the centre, fully inside the target.
|
||||||
|
from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox as BBox
|
||||||
|
tool_shape = BBox(20, 20, 20).Shape()
|
||||||
|
tool_obj = OCCGeometryObject(tool_shape, {})
|
||||||
|
|
||||||
|
# The fixed cut flow:
|
||||||
|
# 1. Apply boolean to target.
|
||||||
|
# 2. Remove the tool from the component dict.
|
||||||
|
result = k.boolean_difference(target_obj, tool_obj)
|
||||||
|
target_obj.geometry = result # the fix: result goes in target
|
||||||
|
# (the tool_obj is then discarded; the simulated flow above
|
||||||
|
# keeps it locally but doesn't use it for display).
|
||||||
|
|
||||||
|
# Count solids in the cut result. It should be exactly 1 (the
|
||||||
|
# target with a cavity), not 2 (target + cavity-shaped tool).
|
||||||
|
shape = k._get_shape(target_obj)
|
||||||
|
explorer = TopExp_Explorer(shape, TopAbs_SOLID)
|
||||||
|
n_solids = 0
|
||||||
|
while explorer.More():
|
||||||
|
n_solids += 1
|
||||||
|
explorer.Next()
|
||||||
|
assert n_solids == 1, f"Cut result has {n_solids} solids, expected 1"
|
||||||
|
|
||||||
|
|
||||||
|
class TestBodyVisibilityToggle:
|
||||||
|
"""Tests for the per-body visibility toggle on the right-hand body list.
|
||||||
|
|
||||||
|
The user asked for a visibility checkbox per body so they could
|
||||||
|
easily verify whether an operation (e.g. cut) had actually modified
|
||||||
|
a body. Hiding the second body and seeing whether the first still
|
||||||
|
has the cut shape is the intended workflow.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _make_window(self):
|
||||||
|
import os
|
||||||
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
from PySide6.QtWidgets import QApplication
|
||||||
|
app = QApplication.instance() or QApplication([])
|
||||||
|
from fluency.main import MainWindow
|
||||||
|
return MainWindow()
|
||||||
|
|
||||||
|
def test_body_list_uses_checkable_items(self):
|
||||||
|
"""Each body list item must be a checkable QListWidgetItem."""
|
||||||
|
from PySide6.QtCore import Qt
|
||||||
|
win = self._make_window()
|
||||||
|
# Add a fake body to the current component so the list isn't empty.
|
||||||
|
from fluency.models.data_model import Body
|
||||||
|
from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox
|
||||||
|
from fluency.geometry_occ.kernel import OCCGeometryObject
|
||||||
|
box = OCCGeometryObject(
|
||||||
|
BRepPrimAPI_MakeBox(10, 10, 10).Shape(), {}
|
||||||
|
)
|
||||||
|
win._current_component.bodies["a"] = Body(name="A", geometry=box)
|
||||||
|
win._refresh_lists()
|
||||||
|
items = win._body_list.findItems("A", Qt.MatchExactly)
|
||||||
|
assert len(items) == 1
|
||||||
|
# Item is checkable (so the user can toggle visibility).
|
||||||
|
assert items[0].flags() & Qt.ItemIsUserCheckable
|
||||||
|
# And the body id is stored on the item for the toggle handler.
|
||||||
|
assert items[0].data(Qt.UserRole) == "a"
|
||||||
|
# Default state is checked (= visible).
|
||||||
|
assert items[0].checkState() == Qt.Checked
|
||||||
|
|
||||||
|
def test_toggling_visibility_updates_body_model(self):
|
||||||
|
"""Flipping the checkbox should set body.visible accordingly."""
|
||||||
|
from PySide6.QtCore import Qt
|
||||||
|
win = self._make_window()
|
||||||
|
from fluency.models.data_model import Body
|
||||||
|
from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox
|
||||||
|
from fluency.geometry_occ.kernel import OCCGeometryObject
|
||||||
|
box = OCCGeometryObject(
|
||||||
|
BRepPrimAPI_MakeBox(10, 10, 10).Shape(), {}
|
||||||
|
)
|
||||||
|
win._current_component.bodies["a"] = Body(name="A", geometry=box)
|
||||||
|
win._refresh_lists()
|
||||||
|
item = win._body_list.findItems("A", Qt.MatchExactly)[0]
|
||||||
|
|
||||||
|
# Toggle off.
|
||||||
|
item.setCheckState(Qt.Unchecked)
|
||||||
|
win._on_body_visibility_changed(item)
|
||||||
|
assert win._current_component.bodies["a"].visible is False
|
||||||
|
|
||||||
|
# Toggle back on.
|
||||||
|
item.setCheckState(Qt.Checked)
|
||||||
|
win._on_body_visibility_changed(item)
|
||||||
|
assert win._current_component.bodies["a"].visible is True
|
||||||
|
|
||||||
|
def test_visibility_no_op_when_unchanged(self):
|
||||||
|
"""Re-emitting the same state must not trigger a viewer call.
|
||||||
|
|
||||||
|
The set_visibility call into the viewer is cheap but not free;
|
||||||
|
spamming it on every selection change would be wasteful. The
|
||||||
|
handler short-circuits when the new state matches the model's.
|
||||||
|
"""
|
||||||
|
from PySide6.QtCore import Qt
|
||||||
|
win = self._make_window()
|
||||||
|
from fluency.models.data_model import Body
|
||||||
|
from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox
|
||||||
|
from fluency.geometry_occ.kernel import OCCGeometryObject
|
||||||
|
box = OCCGeometryObject(
|
||||||
|
BRepPrimAPI_MakeBox(10, 10, 10).Shape(), {}
|
||||||
|
)
|
||||||
|
win._current_component.bodies["a"] = Body(name="A", geometry=box)
|
||||||
|
win._refresh_lists()
|
||||||
|
item = win._body_list.findItems("A", Qt.MatchExactly)[0]
|
||||||
|
|
||||||
|
# Force the model's visibility to False to mimic a desync.
|
||||||
|
win._current_component.bodies["a"].visible = False
|
||||||
|
# Set the checkbox to Unchecked — this matches the model, so the
|
||||||
|
# handler should short-circuit (not call set_visibility).
|
||||||
|
item.setCheckState(Qt.Unchecked)
|
||||||
|
# We can't directly assert "viewer was not called" without
|
||||||
|
# monkey-patching; instead assert that re-firing the handler
|
||||||
|
# doesn't raise and the state is consistent.
|
||||||
|
win._on_body_visibility_changed(item)
|
||||||
|
assert win._current_component.bodies["a"].visible is False
|
||||||
|
|
||||||
|
|
||||||
|
def math_hypot(x, y):
|
||||||
|
import math
|
||||||
|
return math.hypot(x, y)
|
||||||
|
|
||||||
|
|
||||||
|
class TestConstraintTagRendering:
|
||||||
|
"""Tests for constraint tag rendering when a tag references a line id.
|
||||||
|
|
||||||
|
The constraint log stores entity ids. A constraint that targets a
|
||||||
|
line (e.g. point-on-line coincident) puts a *line* id in the log,
|
||||||
|
and the tag rendering code used to naively unpack that line's
|
||||||
|
geometry ``((x1,y1), (x2,y2))`` as if it were a point's ``(x, y)``,
|
||||||
|
calling ``round()`` on a tuple and raising
|
||||||
|
``TypeError: type tuple doesn't define __round__ method``.
|
||||||
|
These tests pin the fix in ``Sketch2DWidget._compute_constraint_tags``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _make_widget_with_sketch(self, sk):
|
||||||
|
"""Build a Sketch2DWidget in offscreen mode and attach *sk* to it."""
|
||||||
|
import os
|
||||||
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
from PySide6.QtWidgets import QApplication
|
||||||
|
app = QApplication.instance() or QApplication([])
|
||||||
|
from fluency.main import Sketch2DWidget
|
||||||
|
w = Sketch2DWidget()
|
||||||
|
w.set_sketch(sk)
|
||||||
|
return w
|
||||||
|
|
||||||
|
def test_point_on_line_coincident_tag_renders(self):
|
||||||
|
"""A coincident between a point and a line must not crash the paint event.
|
||||||
|
|
||||||
|
Reproduces the user-reported error: ids[1] is a line id, the old
|
||||||
|
code unpacked the line's geometry ``((x1,y1), (x2,y2))`` as a
|
||||||
|
point and called ``round()`` on the inner tuple.
|
||||||
|
"""
|
||||||
|
sk = OCCSketch()
|
||||||
|
a = sk.add_point(0, 0)
|
||||||
|
b = sk.add_point(10, 0)
|
||||||
|
line = sk.add_line(a, b) # 3rd entity: the line itself
|
||||||
|
# Point-on-line: the line id is in the constraint log.
|
||||||
|
p3 = sk.add_point(5, 5)
|
||||||
|
sk.constrain_coincident(p3, line)
|
||||||
|
sk.solve()
|
||||||
|
w = self._make_widget_with_sketch(sk)
|
||||||
|
# Must not raise.
|
||||||
|
tags = w._compute_constraint_tags()
|
||||||
|
# One tag for the coincident.
|
||||||
|
coin_tags = [t for t in tags if "coin" in t["label"]]
|
||||||
|
assert len(coin_tags) == 1
|
||||||
|
# The tag was anchored (non-None center) and renders successfully.
|
||||||
|
assert coin_tags[0]["center"] is not None
|
||||||
|
|
||||||
|
def test_point_world_rejects_line_geometry(self):
|
||||||
|
"""_point_world must return None (not crash) when given a line id."""
|
||||||
|
sk = OCCSketch()
|
||||||
|
a = sk.add_point(0, 0)
|
||||||
|
b = sk.add_point(10, 0)
|
||||||
|
line = sk.add_line(a, b)
|
||||||
|
w = self._make_widget_with_sketch(sk)
|
||||||
|
# Old behaviour: round(<tuple>) raised TypeError.
|
||||||
|
# New behaviour: _point_world returns None for non-point entities.
|
||||||
|
result = w._point_world(line.id)
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
def test_point_world_rejects_circle_geometry(self):
|
||||||
|
"""_point_world must return None for circle entities too.
|
||||||
|
|
||||||
|
A circle's geometry is ``((cx, cy), radius)`` — also not a flat
|
||||||
|
2-tuple of numbers. Same shape check rejects it.
|
||||||
|
"""
|
||||||
|
sk = OCCSketch()
|
||||||
|
c = sk.add_point(0, 0)
|
||||||
|
circle = sk.add_circle(c, 5.0)
|
||||||
|
w = self._make_widget_with_sketch(sk)
|
||||||
|
result = w._point_world(circle.id)
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
def test_entity_anchor_routes_to_line_midpoint(self):
|
||||||
|
"""_entity_anchor returns the line midpoint for line ids."""
|
||||||
|
sk = OCCSketch()
|
||||||
|
a = sk.add_point(0, 0)
|
||||||
|
b = sk.add_point(10, 0)
|
||||||
|
line = sk.add_line(a, b)
|
||||||
|
w = self._make_widget_with_sketch(sk)
|
||||||
|
anchor = w._entity_anchor(line.id)
|
||||||
|
assert anchor is not None
|
||||||
|
# Midpoint of (0,0) and (10,0) is (5, 0).
|
||||||
|
assert anchor.x() == 5
|
||||||
|
assert anchor.y() == 0
|
||||||
|
|
||||||
|
def test_distance_constraint_with_line_id(self):
|
||||||
|
"""A distance constraint involving a line id must not crash.
|
||||||
|
|
||||||
|
Future enhancements might add a point-to-line distance; even
|
||||||
|
without that, the defensive routing through _entity_anchor
|
||||||
|
ensures the tag renders cleanly when such an entry is logged.
|
||||||
|
"""
|
||||||
|
sk = OCCSketch()
|
||||||
|
a = sk.add_point(0, 0)
|
||||||
|
b = sk.add_point(10, 0)
|
||||||
|
line = sk.add_line(a, b)
|
||||||
|
p3 = sk.add_point(15, 5)
|
||||||
|
# Simulate a point-to-line distance by directly appending a log
|
||||||
|
# entry — this matches the solver's surface (it would call
|
||||||
|
# _record_constraint with these ids once a point-to-line
|
||||||
|
# distance is added to the solver wrapper).
|
||||||
|
sk._record_constraint("distance", (p3.id, line.id), (12.0,))
|
||||||
|
w = self._make_widget_with_sketch(sk)
|
||||||
|
tags = w._compute_constraint_tags()
|
||||||
|
dst_tags = [t for t in tags if "dst" in t["label"]]
|
||||||
|
assert len(dst_tags) == 1
|
||||||
|
assert dst_tags[0]["center"] is not None
|
||||||
|
|
||||||
|
def test_paint_tolerates_corrupted_entity_geometry(self):
|
||||||
|
"""Paint must not crash if an entity's geometry is weird.
|
||||||
|
|
||||||
|
Simulates the user-reported case: after constraining many
|
||||||
|
points, the solver log still references an entity whose
|
||||||
|
geometry was corrupted (e.g. line-shape ``((x,y), r)`` on a
|
||||||
|
point entity, a 3-element list, or a value with a __round__
|
||||||
|
that raises). _compute_constraint_tags should drop the bad
|
||||||
|
tag and keep rendering the rest.
|
||||||
|
"""
|
||||||
|
sk = OCCSketch()
|
||||||
|
a = sk.add_point(0, 0)
|
||||||
|
b = sk.add_point(10, 0)
|
||||||
|
c = sk.add_point(5, 5)
|
||||||
|
sk.constrain_coincident(c, a)
|
||||||
|
sk.solve()
|
||||||
|
w = self._make_widget_with_sketch(sk)
|
||||||
|
|
||||||
|
# Case 1: point entity has line-shape geometry.
|
||||||
|
sk._entities[c.id].geometry = ((1.0, 2.0), 3.0)
|
||||||
|
tags = w._compute_constraint_tags()
|
||||||
|
# Bad entry is dropped; the good one still renders.
|
||||||
|
assert all(t["center"] is not None for t in tags)
|
||||||
|
|
||||||
|
# Case 2: wrong-shape geometry (3-element list).
|
||||||
|
sk._entities[c.id].geometry = [1.0, 2.0, 3.0]
|
||||||
|
tags = w._compute_constraint_tags()
|
||||||
|
assert all(t["center"] is not None for t in tags)
|
||||||
|
|
||||||
|
# Case 3: exotic type whose __round__ raises.
|
||||||
|
class _BadRound:
|
||||||
|
def __round__(self, ndigits=0):
|
||||||
|
raise TypeError("cannot round")
|
||||||
|
sk._entities[c.id].geometry = (_BadRound(), _BadRound())
|
||||||
|
tags = w._compute_constraint_tags()
|
||||||
|
assert all(t["center"] is not None for t in tags)
|
||||||
|
|
||||||
|
def test_paint_tolerates_dangling_constraint_ids(self):
|
||||||
|
"""Paint must not crash if the log references an entity that was deleted.
|
||||||
|
|
||||||
|
The log can briefly reference a stale id after a delete (e.g.
|
||||||
|
if a constraint handler logs first and deletes second). The
|
||||||
|
render path must skip such entries, not raise KeyError or
|
||||||
|
TypeError.
|
||||||
|
"""
|
||||||
|
sk = OCCSketch()
|
||||||
|
a = sk.add_point(0, 0)
|
||||||
|
sk.constrain_fixed(a)
|
||||||
|
sk.solve()
|
||||||
|
# Simulate the entity being removed without pruning the log.
|
||||||
|
sk._entities.pop(a.id)
|
||||||
|
w = self._make_widget_with_sketch(sk)
|
||||||
|
tags = w._compute_constraint_tags()
|
||||||
|
# No crash; the dangling tag is dropped.
|
||||||
|
assert isinstance(tags, list)
|
||||||
|
|
||||||
|
|
||||||
|
class TestExtrudeRedesign:
|
||||||
|
"""Tests for the cut-through / source-body auto-target / live-preview
|
||||||
|
redesign (2026-06-29).
|
||||||
|
|
||||||
|
Headline workflow: a sketch projected on a face of a body, plus "Perform
|
||||||
|
Cut"
|
||||||
|
1. auto-targets the body it was projected onto,
|
||||||
|
2. auto-directs the cut INTO the body (the picked face's outward normal
|
||||||
|
points away, so a plain cut would carve nothing),
|
||||||
|
3. with "Through All" ticked, fully passes through the body.
|
||||||
|
A live transparent preview is computed from the same shared helper, and
|
||||||
|
a freshly-projected sketch is auto-selected in the row-left list so the
|
||||||
|
user can Extrude/Cut without hunting for the row.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _make_window_with_box(self, box_side=100.0):
|
||||||
|
import os
|
||||||
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
from PySide6.QtWidgets import QApplication
|
||||||
|
app = QApplication.instance() or QApplication([])
|
||||||
|
from fluency.main import MainWindow
|
||||||
|
from fluency.models.data_model import Sketch, Body
|
||||||
|
from fluency.geometry_occ.kernel import OCCGeometryObject
|
||||||
|
from fluency.geometry_occ.sketch import OCCSketch
|
||||||
|
from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox
|
||||||
|
|
||||||
|
win = MainWindow()
|
||||||
|
k = win._kernel
|
||||||
|
box_shape = BRepPrimAPI_MakeBox(box_side, box_side, box_side).Shape()
|
||||||
|
box_obj = OCCGeometryObject(box_shape, {"type": "box"})
|
||||||
|
win._current_component.bodies["b1"] = Body(name="Box1", geometry=box_obj)
|
||||||
|
|
||||||
|
# Sketch on the TOP face of the box (normal +Z points outward).
|
||||||
|
sk = OCCSketch()
|
||||||
|
origin = (box_side / 2.0, box_side / 2.0, box_side)
|
||||||
|
normal = (0.0, 0.0, 1.0)
|
||||||
|
x_dir = (1.0, 0.0, 0.0)
|
||||||
|
sk.set_workplane(origin, normal, x_dir)
|
||||||
|
sketch = Sketch(name="S on top")
|
||||||
|
sketch.occ_sketch = sk
|
||||||
|
sketch.set_workplane(origin, normal, x_dir)
|
||||||
|
sketch._source_body_id = "b1"
|
||||||
|
win._current_component.sketches[sketch.id] = sketch
|
||||||
|
win._current_sketch = sketch
|
||||||
|
# Return all the fixtures.
|
||||||
|
return win, sketch, sk, box_obj
|
||||||
|
|
||||||
|
def _add_circle(self, sk, r=10.0):
|
||||||
|
from fluency.geometry_occ.sketch import OCCSketch
|
||||||
|
c = sk.add_point(0, 0)
|
||||||
|
sk.add_circle(c, r)
|
||||||
|
sk.solve()
|
||||||
|
return sk.get_geometry()
|
||||||
|
|
||||||
|
def _geometry_volume(self, win, geom):
|
||||||
|
from OCP.GProp import GProp_GProps
|
||||||
|
from OCP.BRepGProp import BRepGProp
|
||||||
|
sh = win._kernel._get_shape(geom)
|
||||||
|
g = GProp_GProps()
|
||||||
|
BRepGProp.VolumeProperties_s(sh, g)
|
||||||
|
return g.Mass()
|
||||||
|
|
||||||
|
def test_cut_auto_directs_into_body(self):
|
||||||
|
"""A plain "Perform Cut" on a sketch-on-top-of-body carves a pocket.
|
||||||
|
|
||||||
|
Without the redesign a non-inverted extrude goes *outward* (up),
|
||||||
|
missing the box and carving nothing. The redesign auto-flips the
|
||||||
|
extrusion to go *into* the body regardless of the Invert checkbox,
|
||||||
|
so a 5 mm cut makes a real 5 mm-deep pocket.
|
||||||
|
"""
|
||||||
|
import math
|
||||||
|
win, sketch, sk, box_obj = self._make_window_with_box(100.0)
|
||||||
|
face_geom = self._add_circle(sk, r=10.0)
|
||||||
|
# Plain cut, length=5, NOT inverted. Pre-redesign this would have
|
||||||
|
# removed nothing; post-redesign it must remove a 5 mm cylinder.
|
||||||
|
result = win._compute_extrude_result(
|
||||||
|
sketch, face_geom,
|
||||||
|
length=5.0, symmetric=False, invert=False,
|
||||||
|
cut=True, union=False, through_all=False,
|
||||||
|
)
|
||||||
|
assert result is not None
|
||||||
|
assert result["target_body"] is not None
|
||||||
|
assert result["target_body"].name == "Box1"
|
||||||
|
vol = self._geometry_volume(win, result["result_geom"])
|
||||||
|
expected = 100.0 ** 3 - math.pi * (10.0 ** 2) * 5.0
|
||||||
|
assert abs(vol - expected) < 1.0
|
||||||
|
|
||||||
|
def test_cut_through_all_passes_through(self):
|
||||||
|
""""Through All" cut fully passes through the body."""
|
||||||
|
import math
|
||||||
|
win, sketch, sk, box_obj = self._make_window_with_box(100.0)
|
||||||
|
face_geom = self._add_circle(sk, r=10.0)
|
||||||
|
result = win._compute_extrude_result(
|
||||||
|
sketch, face_geom,
|
||||||
|
length=5.0, # ignored when through_all
|
||||||
|
symmetric=False, invert=False,
|
||||||
|
cut=True, union=False, through_all=True,
|
||||||
|
)
|
||||||
|
assert result is not None
|
||||||
|
vol = self._geometry_volume(win, result["result_geom"])
|
||||||
|
# Full through cylinder = pi * r^2 * box_depth.
|
||||||
|
expected = 100.0 ** 3 - math.pi * (10.0 ** 2) * 100.0
|
||||||
|
assert abs(vol - expected) < 1.0
|
||||||
|
|
||||||
|
def test_cut_auto_targets_source_body_not_existing_zero(self):
|
||||||
|
"""Cut should target the source body, not the dict's first body.
|
||||||
|
|
||||||
|
Constructs a 2-body scenario where the first body in the dict is NOT
|
||||||
|
the source, and verifies the cut goes into the source.
|
||||||
|
"""
|
||||||
|
import math
|
||||||
|
import os
|
||||||
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
from PySide6.QtWidgets import QApplication
|
||||||
|
app = QApplication.instance() or QApplication([])
|
||||||
|
from fluency.main import MainWindow
|
||||||
|
from fluency.models.data_model import Sketch, Body
|
||||||
|
from fluency.geometry_occ.kernel import OCCGeometryObject
|
||||||
|
from fluency.geometry_occ.sketch import OCCSketch
|
||||||
|
from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox
|
||||||
|
|
||||||
|
win = MainWindow()
|
||||||
|
# First body in the dict: a 50-millimetre box ALSO.
|
||||||
|
first = OCCGeometryObject(
|
||||||
|
BRepPrimAPI_MakeBox(50, 50, 50).Shape(), {}
|
||||||
|
)
|
||||||
|
win._current_component.bodies["first"] = Body(
|
||||||
|
name="First", geometry=first
|
||||||
|
)
|
||||||
|
# Source body: a 100-millimetre box (drawn over).
|
||||||
|
src = OCCGeometryObject(
|
||||||
|
BRepPrimAPI_MakeBox(100, 100, 100).Shape(), {}
|
||||||
|
)
|
||||||
|
win._current_component.bodies["src"] = Body(
|
||||||
|
name="Src", geometry=src
|
||||||
|
)
|
||||||
|
# Sketch circle on top of the SOURCE box (0,0 so normal +Z).
|
||||||
|
sk = OCCSketch()
|
||||||
|
sk.set_workplane((50, 50, 100), (0, 0, 1), (1, 0, 0))
|
||||||
|
centre = sk.add_point(0, 0)
|
||||||
|
sk.add_circle(centre, 10.0)
|
||||||
|
sk.solve()
|
||||||
|
sketch = Sketch(name="S")
|
||||||
|
sketch.occ_sketch = sk
|
||||||
|
sketch.set_workplane((50, 50, 100), (0, 0, 1), (1, 0, 0))
|
||||||
|
sketch._source_body_id = "src" # explicitly the source box.
|
||||||
|
win._current_component.sketches[sketch.id] = sketch
|
||||||
|
win._current_sketch = sketch
|
||||||
|
|
||||||
|
face_geom = sk.get_geometry()
|
||||||
|
result = win._compute_extrude_result(
|
||||||
|
sketch, face_geom,
|
||||||
|
length=5.0, symmetric=False, invert=False,
|
||||||
|
cut=True, union=False, through_all=True,
|
||||||
|
)
|
||||||
|
assert result is not None
|
||||||
|
# Target is the source box, NOT the dict's first body.
|
||||||
|
assert result["target_body"].name == "Src"
|
||||||
|
vol = self._geometry_volume(win, result["result_geom"])
|
||||||
|
# 100^3 - pi*100*100 (through-all full-depth cut on the 100 box).
|
||||||
|
expected = 100.0 ** 3 - math.pi * (10.0 ** 2) * 100.0
|
||||||
|
assert abs(vol - expected) < 1.0
|
||||||
|
|
||||||
|
def test_union_default_builds_outward(self):
|
||||||
|
"""Combine (Union) builds a boss OUTWARD (no auto-into-body flip).
|
||||||
|
|
||||||
|
Union semantics: the new material adds on TOP of the face, not
|
||||||
|
into the body. So a 10 mm union adds a 10 mm cylinder of material
|
||||||
|
rather than "subtracting" from the existing box.
|
||||||
|
"""
|
||||||
|
import math
|
||||||
|
win, sketch, sk, box_obj = self._make_window_with_box(100.0)
|
||||||
|
face_geom = self._add_circle(sk, r=10.0)
|
||||||
|
result = win._compute_extrude_result(
|
||||||
|
sketch, face_geom,
|
||||||
|
length=10.0, symmetric=False, invert=False,
|
||||||
|
cut=False, union=True, through_all=False,
|
||||||
|
)
|
||||||
|
assert result is not None
|
||||||
|
vol = self._geometry_volume(win, result["result_geom"])
|
||||||
|
# 100^3 + pi*100*10 — material added on top.
|
||||||
|
expected = 100.0 ** 3 + math.pi * (10.0 ** 2) * 10.0
|
||||||
|
assert abs(vol - expected) < 1.0
|
||||||
|
|
||||||
|
def test_plain_extrude_untouched_by_source_body(self):
|
||||||
|
"""Without cut/union, the extrusion is a standalone new body."""
|
||||||
|
win, sketch, sk, box_obj = self._make_window_with_box(100.0)
|
||||||
|
face_geom = self._add_circle(sk, r=10.0)
|
||||||
|
result = win._compute_extrude_result(
|
||||||
|
sketch, face_geom,
|
||||||
|
length=10.0, symmetric=False, invert=False,
|
||||||
|
cut=False, union=False, through_all=False,
|
||||||
|
)
|
||||||
|
assert result is not None
|
||||||
|
# No boolean target; result is the standalone tool extrusion.
|
||||||
|
assert result["target_body"] is None
|
||||||
|
vol = self._geometry_volume(win, result["result_geom"])
|
||||||
|
# Standalone cylinder 10 mm tall.
|
||||||
|
import math
|
||||||
|
assert abs(vol - math.pi * (10.0 ** 2) * 10.0) < 1.0
|
||||||
|
|
||||||
|
def test_freshly_picked_sketch_is_auto_selected(self):
|
||||||
|
"""After _on_face_picked, the new sketch is the current list row.
|
||||||
|
|
||||||
|
The user should be able to click Extrude/Cut immediately without
|
||||||
|
first hunting for the new sketch in the left list.
|
||||||
|
"""
|
||||||
|
from fluency.geometry_occ.kernel import OCCGeometryObject
|
||||||
|
win, _, sk, box_obj = self._make_window_with_box(100.0)
|
||||||
|
# Simulate _on_face_picked by calling it through a fake face
|
||||||
|
# shape — but the simplest behavioural check is to call the
|
||||||
|
# bookkeeping directly: a new sketch matching src exists and is
|
||||||
|
# set as _current_sketch, and it appears (and is selected) in
|
||||||
|
# the list after _refresh_lists + setCurrentRow.
|
||||||
|
from fluency.models.data_model import Sketch
|
||||||
|
sketch = Sketch(name="Sketch on face 99")
|
||||||
|
sketch._source_body_id = "b1"
|
||||||
|
sketch.set_workplane((50, 50, 100), (0, 0, 1), (1, 0, 0))
|
||||||
|
win._current_component.sketches[sketch.id] = sketch
|
||||||
|
win._current_sketch = sketch
|
||||||
|
win._refresh_lists()
|
||||||
|
# The auto-select block from _on_face_picked — re-derive it
|
||||||
|
# here since we can't run the full pick path offscreen.
|
||||||
|
target_row = None
|
||||||
|
for row in range(win._sketch_list.count()):
|
||||||
|
if win._sketch_list.item(row).text() == sketch.name:
|
||||||
|
target_row = row
|
||||||
|
break
|
||||||
|
assert target_row is not None
|
||||||
|
win._sketch_list.setCurrentRow(target_row)
|
||||||
|
assert win._sketch_list.currentRow() == target_row
|
||||||
|
assert win._sketch_list.currentItem().text() == sketch.name
|
||||||
|
|
||||||
|
def test_preview_callback_invoked_on_value_change(self):
|
||||||
|
"""The live preview callback fires on spinbox/checkbox changes."""
|
||||||
|
import os
|
||||||
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
from PySide6.QtWidgets import QApplication
|
||||||
|
app = QApplication.instance() or QApplication([])
|
||||||
|
from fluency.main import ExtrudeDialog
|
||||||
|
|
||||||
|
calls = []
|
||||||
|
dialog = ExtrudeDialog()
|
||||||
|
dialog.set_preview_callback(lambda v: calls.append(v))
|
||||||
|
# set_preview_callback emits once for the initial state.
|
||||||
|
assert len(calls) == 1
|
||||||
|
# Changing the length should emit a new values tuple.
|
||||||
|
dialog.length_input.setValue(42.0)
|
||||||
|
assert len(calls) == 2
|
||||||
|
# Toggling "Through All" should emit again.
|
||||||
|
dialog.through_all_checkbox.setChecked(True)
|
||||||
|
assert len(calls) >= 3
|
||||||
|
# Passing None clears the preview (as the host does on close).
|
||||||
|
dialog.set_preview_callback(None)
|
||||||
|
# New callback None → no further emissions.
|
||||||
|
before = len(calls)
|
||||||
|
dialog.length_input.setValue(99.0)
|
||||||
|
assert len(calls) == before # callback gone → no emit
|
||||||
|
|
||||||
|
def test_preview_hidden_event_sends_none(self):
|
||||||
|
"""hideEvent should deliver None to the callback so the host clears."""
|
||||||
|
import os
|
||||||
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
from PySide6.QtWidgets import QApplication
|
||||||
|
app = QApplication.instance() or QApplication([])
|
||||||
|
from fluency.main import ExtrudeDialog
|
||||||
|
|
||||||
|
seen = []
|
||||||
|
dialog = ExtrudeDialog()
|
||||||
|
dialog.set_preview_callback(lambda v: seen.append(v))
|
||||||
|
# hideEvent only fires when the dialog was previously visible, so
|
||||||
|
# show it first (window system / offscreen both honour this) and
|
||||||
|
# then hide it — which is exactly what dialog.exec() does when the
|
||||||
|
# user accepts or cancels.
|
||||||
|
dialog.show()
|
||||||
|
dialog.hide()
|
||||||
|
# The last value emitted to the callback must be None (clear).
|
||||||
|
assert seen and seen[-1] is None
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
pytest.main([__file__, "-v"])
|
pytest.main([__file__, "-v"])
|
||||||
|
|||||||
Reference in New Issue
Block a user