- Basic operations

This commit is contained in:
bklronin
2026-06-29 23:30:02 +02:00
parent f6422e0847
commit 9938f4ddd4
6 changed files with 2307 additions and 243 deletions
+41 -34
View File
@@ -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/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/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$/tests/test_geometry.py" beforeDir="false" afterPath="$PROJECT_DIR$/tests/test_geometry.py" afterDir="false" />
</list>
@@ -48,39 +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.kernel.executor": "Run",
"Python.main.executor": "Run",
"Python.meshtest.executor": "Run",
"Python.occ_renderer.executor": "Run",
"Python.side_fluency.executor": "Run",
"Python.simple_mesh.executor": "Run",
"Python.vtk_widget.executor": "Run",
"Python.vulkan.executor": "Run",
"RunOnceActivity.OpenProjectViewOnStart": "true",
"RunOnceActivity.ShowReadmeOnStart": "true",
"RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252": "true",
"RunOnceActivity.git.unshallow": "true",
"RunOnceActivity.typescript.service.memoryLimit.init": "true",
"codeWithMe.voiceChat.enabledByDefault": "false",
"git-widget-placeholder": "feature/occ-migration",
"last_opened_file_path": "/Volumes/Data_drive/Programming/fluency/src/fluency",
"node.js.detected.package.eslint": "true",
"node.js.selected.package.eslint": "(autodetect)",
"node.js.selected.package.tslint": "(autodetect)",
"nodejs_package_manager_path": "npm",
"settings.editor.selected.configurable": "project.propVCSSupport.DirectoryMappings"
<component 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" />
@@ -273,7 +272,15 @@
<option name="project" value="LOCAL" />
<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">
+20 -1
View File
@@ -180,12 +180,31 @@ class OCGeometryKernel(GeometryKernel):
from OCP.BRepAlgoAPI import BRepAlgoAPI_Fuse
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:
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)
+189 -7
View File
@@ -34,6 +34,12 @@ class OCCSketchEntity(SketchEntity):
self.handle = handle # SolveSpace solver entity handle
self.is_construction: bool = False
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):
@@ -60,6 +66,13 @@ class OCCSketch(SketchInterface):
# after deleting an entity (python_solvespace has no per-entity delete).
# Each entry: {"type": str, "ids": (int, ...), "params": tuple, "labels": set[str]}
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
self._first_point_id: Optional[int] = None
@@ -161,12 +174,21 @@ class OCCSketch(SketchInterface):
return int(match.group(1)) if match else 0
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()
# Add to solver
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
# Fix first point so solver has a reference
self._solver.dragged(solver_handle, self._wp)
@@ -268,6 +290,148 @@ class OCCSketch(SketchInterface):
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(
self, corner1: Tuple[float, float], corner2: Tuple[float, float]
) -> List[OCCSketchEntity]:
@@ -666,11 +830,15 @@ class OCCSketch(SketchInterface):
if len(faces) == 1:
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:
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)
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
face_dict = {
"outer": {"type": "circle", "center": (cx, cy), "radius": radius},
@@ -710,11 +878,15 @@ class OCCSketch(SketchInterface):
return points
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]]] = {}
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
if p1 not in adjacency:
adjacency[p1] = []
@@ -752,9 +924,18 @@ class OCCSketch(SketchInterface):
_SNAP_TOL: float = 1e-4 # world-unit tolerance for snapping line endpoints
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]]] = []
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)
e_ent = self._entities.get(eid2)
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._constraint_count = 0
self._constraint_log.clear()
self._external_entity_ids.clear()
self._first_point_id = None
def _prune_log_for(self, removed_ids: set) -> None:
+970 -190
View File
File diff suppressed because it is too large Load Diff
+196 -11
View File
@@ -41,7 +41,13 @@ class OCCRenderer(Renderer):
self._parent_widget: Any = None
self._last_mouse_x: 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:
"""Initialise OCC viewer inside *parent_widget* (a QWidget)."""
@@ -156,6 +162,18 @@ class OCCRenderer(Renderer):
# 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)
@@ -372,10 +390,100 @@ class OCCRenderer(Renderer):
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:
@@ -642,6 +750,18 @@ class OCCRenderer(Renderer):
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
@@ -652,7 +772,6 @@ class OCCRenderer(Renderer):
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
n = pln.Axis().Direction()
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
@@ -681,19 +800,84 @@ class OCCRenderer(Renderer):
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 drag → dolly / zoom toward cursor
# • Right button → (reserved for future use)
# • Wheel → zoom toward cursor
# • Double-click left → fit all (handled by the widget)
@@ -717,12 +901,13 @@ class OCCRenderer(Renderer):
self._view.StartRotation(x, y, 0.4)
elif btn == Qt.MiddleButton:
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)
elif btn == Qt.RightButton:
self._nav_mode = "zoom"
self._view.StartZoomAtPoint(x, y)
else:
# Right button (and any other) is reserved — no gesture yet.
self._nav_mode = None
self._last_mouse_x = x
@@ -739,12 +924,12 @@ class OCCRenderer(Renderer):
if self._nav_mode == "rotate" and (buttons & Qt.LeftButton):
self._view.Rotation(x, y)
elif self._nav_mode == "pan" and (buttons & Qt.MiddleButton):
dx = x - self._last_mouse_x
dy = y - self._last_mouse_y
# 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)
elif self._nav_mode == "zoom" and (buttons & Qt.RightButton):
self._view.ZoomAtPoint(self._last_mouse_x, self._last_mouse_y, x, y)
else:
# Idle: dynamic highlighting under the cursor.
self._context.MoveTo(x, y, self._view, True)
+891
View File
@@ -237,5 +237,896 @@ class TestOCCSketch:
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"])