"""Tests for Fluency CAD geometry kernel.""" import pytest import numpy as np from fluency.geometry_occ.kernel import OCGeometryKernel, OCCGeometryObject from fluency.geometry_occ.sketch import OCCSketch from fluency.geometry.base import Point2D class TestOCGeometryKernel: """Tests for the OpenCASCADE geometry kernel.""" def test_kernel_creation(self): """Test kernel can be created.""" kernel = OCGeometryKernel() assert kernel is not None def test_create_point(self): """Test point creation.""" kernel = OCGeometryKernel() point = kernel.create_point(10.0, 20.0) assert point is not None def test_create_line(self): """Test line creation.""" kernel = OCGeometryKernel() start = Point2D(0, 0) end = Point2D(10, 10) line = kernel.create_line(start, end) assert line is not None def test_create_circle(self): """Test circle creation.""" kernel = OCGeometryKernel() center = Point2D(0, 0) circle = kernel.create_circle(center, 5.0) assert circle is not None def test_create_rectangle(self): """Test rectangle creation.""" kernel = OCGeometryKernel() rect = kernel.create_rectangle(10.0, 20.0) assert rect is not None def test_create_polygon(self): """Test polygon creation.""" kernel = OCGeometryKernel() points = [ Point2D(0, 0), Point2D(10, 0), Point2D(10, 10), Point2D(0, 10), ] polygon = kernel.create_polygon(points) assert polygon is not None def test_extrude_polygon(self): """Test extruding a polygon.""" kernel = OCGeometryKernel() points = [ Point2D(0, 0), Point2D(10, 0), Point2D(10, 10), Point2D(0, 10), ] polygon = kernel.create_polygon(points) extruded = kernel.extrude(polygon, 20.0) assert extruded is not None def test_get_mesh(self): """Test mesh generation.""" kernel = OCGeometryKernel() points = [ Point2D(0, 0), Point2D(10, 0), Point2D(10, 10), Point2D(0, 10), ] polygon = kernel.create_polygon(points) extruded = kernel.extrude(polygon, 20.0) vertices, faces = kernel.get_mesh(extruded) assert len(vertices) > 0 assert len(faces) > 0 assert vertices.shape[1] == 3 assert faces.shape[1] == 3 def test_get_bounding_box(self): """Test bounding box calculation.""" kernel = OCGeometryKernel() points = [ Point2D(0, 0), Point2D(10, 0), Point2D(10, 10), Point2D(0, 10), ] polygon = kernel.create_polygon(points) extruded = kernel.extrude(polygon, 20.0) min_pt, max_pt = kernel.get_bounding_box(extruded) assert min_pt.x <= max_pt.x assert min_pt.y <= max_pt.y assert min_pt.z <= max_pt.z def test_get_volume(self): """Test volume calculation.""" kernel = OCGeometryKernel() points = [ Point2D(0, 0), Point2D(10, 0), Point2D(10, 10), Point2D(0, 10), ] polygon = kernel.create_polygon(points) extruded = kernel.extrude(polygon, 20.0) volume = kernel.get_volume(extruded) assert volume > 0 assert abs(volume - 2000.0) < 0.1 class TestOCCSketch: """Tests for the OpenCASCADE sketch.""" def test_sketch_creation(self): """Test sketch can be created.""" sketch = OCCSketch() assert sketch is not None def test_add_point(self): """Test adding a point.""" sketch = OCCSketch() point = sketch.add_point(10.0, 20.0) assert point is not None assert point.entity_type == "point" def test_add_line(self): """Test adding a line.""" sketch = OCCSketch() p1 = sketch.add_point(0, 0) p2 = sketch.add_point(10, 10) line = sketch.add_line(p1, p2) assert line is not None assert line.entity_type == "line" def test_add_circle(self): """Test adding a circle.""" sketch = OCCSketch() center = sketch.add_point(0, 0) circle = sketch.add_circle(center, 5.0) assert circle is not None assert circle.entity_type == "circle" def test_add_rectangle(self): """Test adding a rectangle.""" sketch = OCCSketch() entities = sketch.add_rectangle((0, 0), (10, 10)) assert len(entities) == 8 def test_get_points(self): """Test getting points.""" sketch = OCCSketch() sketch.add_point(0, 0) sketch.add_point(10, 10) sketch.add_point(20, 20) points = sketch.get_points() assert len(points) == 3 def test_clear(self): """Test clearing sketch.""" sketch = OCCSketch() sketch.add_point(0, 0) sketch.add_point(10, 10) sketch.clear() points = sketch.get_points() assert len(points) == 0 def test_workplane_extrude_along_normal(self): """A sketch on a tilted plane extrudes along that plane's normal.""" import math from OCP.GProp import GProp_GProps from OCP.BRepGProp import BRepGProp ang = math.radians(35) normal = (math.sin(ang), 0.0, math.cos(ang)) x_dir = (math.cos(ang), 0.0, -math.sin(ang)) sk = OCCSketch() sk.set_workplane((10.0, 0.0, 5.0), normal, x_dir) # 20x20 square in UV p0 = sk.add_point(-10, -10); p1 = sk.add_point(10, -10) p2 = sk.add_point(10, 10); p3 = sk.add_point(-10, 10) sk.add_line(p0, p1); sk.add_line(p1, p2) sk.add_line(p2, p3); sk.add_line(p3, p0) geom = sk.get_geometry() # The face must carry the plane normal for the kernel. assert geom.metadata.get("normal") is not None kernel = OCGeometryKernel() solid = kernel.extrude(geom, 15.0) s = kernel._get_shape(solid) g = GProp_GProps() BRepGProp.VolumeProperties_s(s, g) # 20 * 20 * 15 = 6000 regardless of plane orientation. assert abs(g.Mass() - 6000.0) < 0.1 def test_workplane_extrude_with_hole(self): """A square with a circular hole on a custom plane extrudes correctly.""" import math from OCP.GProp import GProp_GProps from OCP.BRepGProp import BRepGProp sk = OCCSketch() sk.set_workplane((0, 0, 0), (0, 0, 1), (1, 0, 0)) a = sk.add_point(-10, -10); b = sk.add_point(10, -10) c = sk.add_point(10, 10); d = sk.add_point(-10, 10) sk.add_line(a, b); sk.add_line(b, c) sk.add_line(c, d); sk.add_line(d, a) ctr = sk.add_point(0, 0) sk.add_circle(ctr, 3.0) geom = sk.get_geometry() kernel = OCGeometryKernel() solid = kernel.extrude(geom, 5.0) s = kernel._get_shape(solid) g = GProp_GProps() BRepGProp.VolumeProperties_s(s, g) expected = 20 * 20 * 5 - math.pi * 3 * 3 * 5 assert abs(g.Mass() - expected) < 0.1 class TestExternalEntities: """Tests for the underlay / face-projected reference entity API. External entities live in the solver so user constraints can reference them (e.g. "hole center 50 mm from the body's top edge"), but they are *not* part of the sketch profile and must be excluded from detect_faces / get_geometry. """ def test_add_external_point_flags_and_fixes(self): sk = OCCSketch() ep = sk.add_external_point(5.0, 7.0) assert ep is not None assert ep.is_external is True assert ep.is_construction is True # External point is in the solver, with a non-None handle. assert ep.handle is not None # The point is in the entities / points dicts. assert ep.id in sk._entities assert ep.id in sk._points # It's tracked as external. assert ep.id in sk.get_external_entity_ids() def test_add_external_line_requires_external_endpoints(self): sk = OCCSketch() a = sk.add_external_point(0, 0) b = sk.add_external_point(10, 0) line = sk.add_external_line(a, b) assert line is not None assert line.is_external is True assert line.is_construction is True assert line.handle is not None assert line.id in sk._lines assert line.id in sk.get_external_entity_ids() def test_add_external_polyline_shares_corners(self): sk = OCCSketch() # Closed rectangle: 4 unique corners reused at the joints. pts = [(0, 0), (10, 0), (10, 10), (0, 10), (0, 0)] points, lines = sk.add_external_polyline(pts) # 4 segments, 5 UV samples but the 1st and last are the same corner. assert len(lines) == 4 # The 5 samples share the rectangle's 4 corners → 4 unique point entities. assert len(set(p.id for p in points)) == 4 # All are external. assert all(p.is_external for p in points) assert all(ln.is_external for ln in lines) def test_external_entities_excluded_from_detect_faces(self): sk = OCCSketch() # Underlay: a 20x20 square projected from a face (closed polyline). sk.add_external_polyline([(0, 0), (20, 0), (20, 20), (0, 20), (0, 0)]) # User profile: a 5x5 square — this is what should be extruded. a = sk.add_point(2, 2); b = sk.add_point(8, 2) c = sk.add_point(8, 8); d = sk.add_point(2, 8) sk.add_line(a, b); sk.add_line(b, c) sk.add_line(c, d); sk.add_line(d, a) faces = sk.detect_faces() # Only the user-drawn face (5x5 square) should be detected. assert len(faces) == 1 outer = faces[0]["outer"] assert outer["type"] == "polygon" # 5 vertices on the outer loop (4 corners + closing point). assert len(outer["points"]) == 5 # It must be the user square, not the underlay. xs = [p[0] for p in outer["points"][:4]] ys = [p[1] for p in outer["points"][:4]] assert min(xs) >= 2 and max(xs) <= 8 assert min(ys) >= 2 and max(ys) <= 8 def test_external_entities_excluded_from_get_polygon_points(self): sk = OCCSketch() sk.add_external_polyline([(0, 0), (100, 0), (100, 100), (0, 100), (0, 0)]) a = sk.add_point(1, 1); b = sk.add_point(2, 1) c = sk.add_point(2, 2); d = sk.add_point(1, 2) sk.add_line(a, b); sk.add_line(b, c) sk.add_line(c, d); sk.add_line(d, a) poly = sk.get_polygon_points() # The user square (1..2 range) should appear, not the 0..100 underlay. assert all(1.0 <= p.x <= 2.0 for p in poly) assert all(1.0 <= p.y <= 2.0 for p in poly) def test_external_entities_excluded_from_get_geometry(self): """Underlay must never appear in the extruded face.""" from OCP.GProp import GProp_GProps from OCP.BRepGProp import BRepGProp sk = OCCSketch() # Underlay (NOT to be extruded). sk.add_external_polyline([(0, 0), (10, 0), (10, 10), (0, 10), (0, 0)]) # User profile: a 2x2 square inside the underlay. a = sk.add_point(1, 1); b = sk.add_point(3, 1) c = sk.add_point(3, 3); d = sk.add_point(1, 3) sk.add_line(a, b); sk.add_line(b, c) sk.add_line(c, d); sk.add_line(d, a) geom = sk.get_geometry() # Volume = 2 * 2 * 4 = 16, NOT 10 * 10 * 4 = 400. kernel = OCGeometryKernel() solid = kernel.extrude(geom, 4.0) s = kernel._get_shape(solid) g = GProp_GProps() BRepGProp.VolumeProperties_s(s, g) assert abs(g.Mass() - 16.0) < 0.5 def test_distance_to_external_point_constraint(self): """The headline use case: hole position fixed relative to a face edge. User draws a circle (the hole) and a distance from its centre to a face-projected point. After solve, the circle centre should be exactly the requested distance from the external point. """ sk = OCCSketch() # Underlay corner: pick a known anchor on the projected face. anchor = sk.add_external_point(0.0, 0.0) # User geometry: a 1mm circle for the hole. hole_centre = sk.add_point(7.0, 4.0) # start position: 7 from anchor sk.add_circle(hole_centre, 1.0) # Constrain the hole centre 50 mm from the underlay corner. ok = sk.constrain_distance(anchor, hole_centre, 50.0) assert ok assert sk.solve() solved = sk.get_solved_point(hole_centre.id) assert solved is not None # The starting (7, 4) is well short of 50, so the constraint # forces the centre out to a point on the 50mm circle around (0,0). x, y = solved assert abs(math_hypot(x, y) - 50.0) < 0.01 def test_remove_external_entities_clears_them(self): sk = OCCSketch() sk.add_external_polyline([(0, 0), (10, 0), (10, 10), (0, 10), (0, 0)]) assert len(sk.get_external_entity_ids()) > 0 sk.remove_external_entities() assert len(sk.get_external_entity_ids()) == 0 # No external points/lines left in the tracking dicts. for eid in sk._entities: assert not getattr(sk._entities[eid], "is_external", False) def test_remove_external_entities_prunes_related_constraints(self): """Constraints referencing external entities are pruned on removal. A distance to an external point is recorded in the constraint log on the ids of both endpoints. After remove_external_entities(), those entries are gone and the solver rebuilds without them. """ sk = OCCSketch() anchor = sk.add_external_point(0.0, 0.0) user = sk.add_point(20.0, 0.0) sk.constrain_distance(anchor, user, 5.0) sk.solve() # At least one log entry references the external anchor. assert any(anchor.id in entry["ids"] for entry in sk._constraint_log) # Now wipe the underlay. sk.remove_external_entities() # The distance constraint is gone, and the user point is free. assert not any(anchor.id in entry["ids"] for entry in sk._constraint_log) assert sk.solve() def test_external_polyline_dedupes_close_points(self): """Co-located UV samples share a single point entity (closed loops).""" sk = OCCSketch() # Closed rectangle (closing point == start point). pts = [(1.0, 1.0), (9.0, 1.0), (9.0, 9.0), (1.0, 9.0), (1.0, 1.0)] points, lines = sk.add_external_polyline(pts) # 5 samples → 4 unique points (start/end collapse). assert len(set(p.id for p in points)) == 4 # 4 segments connect them. assert len(lines) == 4 # Every line's endpoints are among the 4 points. point_ids = {p.id for p in points} for line_id, (sid, eid2) in sk._lines.items(): if line_id in sk.get_external_entity_ids(): assert sid in point_ids and eid2 in point_ids def test_external_point_is_solver_fixed(self): """An external point's solver parameters must not change on re-solve. python_solvespace drags the first user point; external points use ``dragged`` directly so dragging a user point near an external reference doesn't shift the reference. """ sk = OCCSketch() ep = sk.add_external_point(3.0, 4.0) # Add a user point; solve; record the external point's solved # params. Then delete the user point and add another one; the # external point's params must not have moved. sk.add_point(100.0, 0.0) sk.solve() x0, y0 = sk.solver.params(ep.handle.params) for dx in range(-5, 6): sk.add_point(100.0 + dx, 0.0) sk.solve() x1, y1 = sk.solver.params(ep.handle.params) assert abs(x1 - x0) < 1e-6 assert abs(y1 - y0) < 1e-6 def test_horizontal_constraint_on_external_line(self): """Horizontal constraint involving a partly-external line is solvable. Both external endpoints are dragged (fixed), so a horizontal constraint between them is over-determined when their y values differ. To make the system solvable we add a free user point connected to one external point via a line, then constrain that line horizontal — the user endpoint is dragged to a y that matches the external one, satisfying the constraint. """ sk = OCCSketch() a = sk.add_external_point(0.0, 0.0) # Add a free user point first (skipped auto-drag because external # points exist, so this one is free). free = sk.add_point(7.0, 5.0) # And an external endpoint to pair with the free point in a line. b = sk.add_external_point(0.0, 0.0) line = sk.add_external_line(b, free) # Constrain it horizontal; the free point should drop to y=0. sk.constrain_horizontal(line) assert sk.solve() sa = sk.get_solved_point(b.id) sf = sk.get_solved_point(free.id) assert sa is not None and sf is not None assert abs(sa[1] - sf[1]) < 1e-6 def test_cleared_sketch_drops_external_entities(self): sk = OCCSketch() sk.add_external_polyline([(0, 0), (1, 0), (1, 1), (0, 1), (0, 0)]) sk.add_point(5, 5) assert len(sk.get_external_entity_ids()) > 0 sk.clear() assert len(sk.get_external_entity_ids()) == 0 assert sk.get_entity_count() == 0 class TestExtrudeCutFix: """Tests for the cut/union logic in MainWindow._extrude_sketch. The old code stored the boolean result in the *tool* (newly extruded) body, leaving the *target* body untouched — so the user would see a separate "cavity-shaped" body next to the original instead of a cavity in the original. After deleting that extra body, the next extrude-cutter saw ``len(existing) <= 1`` and silently skipped the cut, producing an unconstrained new body that looked "added without cut". The fix: 1. Apply the boolean to the *target* (existing[0]) body. 2. Remove the tool body from the component. 3. Re-render the target in place. These tests verify the boolean operation produces the right solid and that the post-extrude bookkeeping leaves exactly the right bodies in the component. """ def test_boolean_difference_modifies_target_not_tool(self): """The fix: cut goes into the target, tool is removed. Reproduces the cut/merge flow from ``_extrude_sketch`` without spinning up the full MainWindow: build a target + tool body, run boolean_difference, then verify the target's volume dropped and the tool is no longer needed. """ from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox from fluency.geometry_occ.kernel import OCGeometryKernel, OCCGeometryObject from OCP.GProp import GProp_GProps from OCP.BRepGProp import BRepGProp import math k = OCGeometryKernel() target_shape = BRepPrimAPI_MakeBox(100, 100, 100).Shape() target_obj = OCCGeometryObject(target_shape, {"type": "box"}) # Tool: a 20x20x200 cuboid at the corner of the box, to make the # expected volume easy to compute. from OCP.BRepPrimAPI import BRepPrimAPI_MakePrism from OCP.gp import gp_Pnt, gp_Vec # 20x20 square at (0,0,0), extruded along +Z by 200. from OCP.BRepBuilderAPI import BRepBuilderAPI_MakePolygon mp = BRepBuilderAPI_MakePolygon() for (x, y) in [(0, 0), (20, 0), (20, 20), (0, 20)]: mp.Add(gp_Pnt(x, y, 0)) mp.Close() from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeFace face = BRepBuilderAPI_MakeFace(mp.Wire()).Face() tool_shape = BRepPrimAPI_MakePrism( face, gp_Vec(0, 0, 200) ).Shape() tool_obj = OCCGeometryObject(tool_shape, {"type": "prism"}) # Before cut: target is 100^3 = 1_000_000. g0 = GProp_GProps() BRepGProp.VolumeProperties_s(k._get_shape(target_obj), g0) assert abs(g0.Mass() - 1_000_000.0) < 1.0 # Apply the fix: result goes to the target, not the tool. result = k.boolean_difference(target_obj, tool_obj) target_obj_geometry = result # After cut: target is 1_000_000 - 20*20*100 = 960_000 # (the prism only intersects the box in z=[0,100], i.e. 100 deep). g1 = GProp_GProps() BRepGProp.VolumeProperties_s( k._get_shape(target_obj_geometry), g1 ) assert abs(g1.Mass() - 960_000.0) < 1.0 def test_boolean_difference_does_not_leave_separate_cavity_body(self): """Sanity: the cut result is a single body (not two). The OLD bug stored the cut result in a SECOND body, so after a cut the user would see the original body PLUS a "cavity-shaped" body — the user thought the cut worked but it was just two separate solids. With the fix the cut is folded into the target, so a single body remains. """ from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox from OCP.BRepAlgoAPI import BRepAlgoAPI_Cut from OCP.TopExp import TopExp_Explorer from OCP.TopAbs import TopAbs_SOLID from fluency.geometry_occ.kernel import OCGeometryKernel, OCCGeometryObject k = OCGeometryKernel() target_shape = BRepPrimAPI_MakeBox(100, 100, 100).Shape() target_obj = OCCGeometryObject(target_shape, {}) # Tool: small box at the centre, fully inside the target. from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox as BBox tool_shape = BBox(20, 20, 20).Shape() tool_obj = OCCGeometryObject(tool_shape, {}) # The fixed cut flow: # 1. Apply boolean to target. # 2. Remove the tool from the component dict. result = k.boolean_difference(target_obj, tool_obj) target_obj.geometry = result # the fix: result goes in target # (the tool_obj is then discarded; the simulated flow above # keeps it locally but doesn't use it for display). # Count solids in the cut result. It should be exactly 1 (the # target with a cavity), not 2 (target + cavity-shaped tool). shape = k._get_shape(target_obj) explorer = TopExp_Explorer(shape, TopAbs_SOLID) n_solids = 0 while explorer.More(): n_solids += 1 explorer.Next() assert n_solids == 1, f"Cut result has {n_solids} solids, expected 1" class TestBodyVisibilityToggle: """Tests for the per-body visibility toggle on the right-hand body list. The user asked for a visibility checkbox per body so they could easily verify whether an operation (e.g. cut) had actually modified a body. Hiding the second body and seeing whether the first still has the cut shape is the intended workflow. """ def _make_window(self): import os os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") from PySide6.QtWidgets import QApplication app = QApplication.instance() or QApplication([]) from fluency.main import MainWindow return MainWindow() def test_body_list_uses_checkable_items(self): """Each body list item must be a checkable QListWidgetItem.""" from PySide6.QtCore import Qt win = self._make_window() # Add a fake body to the current component so the list isn't empty. from fluency.models.data_model import Body from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox from fluency.geometry_occ.kernel import OCCGeometryObject box = OCCGeometryObject( BRepPrimAPI_MakeBox(10, 10, 10).Shape(), {} ) win._current_component.bodies["a"] = Body(name="A", geometry=box) win._refresh_lists() items = win._body_list.findItems("A", Qt.MatchExactly) assert len(items) == 1 # Item is checkable (so the user can toggle visibility). assert items[0].flags() & Qt.ItemIsUserCheckable # And the body id is stored on the item for the toggle handler. assert items[0].data(Qt.UserRole) == "a" # Default state is checked (= visible). assert items[0].checkState() == Qt.Checked def test_toggling_visibility_updates_body_model(self): """Flipping the checkbox should set body.visible accordingly.""" from PySide6.QtCore import Qt win = self._make_window() from fluency.models.data_model import Body from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox from fluency.geometry_occ.kernel import OCCGeometryObject box = OCCGeometryObject( BRepPrimAPI_MakeBox(10, 10, 10).Shape(), {} ) win._current_component.bodies["a"] = Body(name="A", geometry=box) win._refresh_lists() item = win._body_list.findItems("A", Qt.MatchExactly)[0] # Toggle off. item.setCheckState(Qt.Unchecked) win._on_body_visibility_changed(item) assert win._current_component.bodies["a"].visible is False # Toggle back on. item.setCheckState(Qt.Checked) win._on_body_visibility_changed(item) assert win._current_component.bodies["a"].visible is True def test_visibility_no_op_when_unchanged(self): """Re-emitting the same state must not trigger a viewer call. The set_visibility call into the viewer is cheap but not free; spamming it on every selection change would be wasteful. The handler short-circuits when the new state matches the model's. """ from PySide6.QtCore import Qt win = self._make_window() from fluency.models.data_model import Body from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox from fluency.geometry_occ.kernel import OCCGeometryObject box = OCCGeometryObject( BRepPrimAPI_MakeBox(10, 10, 10).Shape(), {} ) win._current_component.bodies["a"] = Body(name="A", geometry=box) win._refresh_lists() item = win._body_list.findItems("A", Qt.MatchExactly)[0] # Force the model's visibility to False to mimic a desync. win._current_component.bodies["a"].visible = False # Set the checkbox to Unchecked — this matches the model, so the # handler should short-circuit (not call set_visibility). item.setCheckState(Qt.Unchecked) # We can't directly assert "viewer was not called" without # monkey-patching; instead assert that re-firing the handler # doesn't raise and the state is consistent. win._on_body_visibility_changed(item) assert win._current_component.bodies["a"].visible is False def math_hypot(x, y): import math return math.hypot(x, y) class TestConstraintTagRendering: """Tests for constraint tag rendering when a tag references a line id. The constraint log stores entity ids. A constraint that targets a line (e.g. point-on-line coincident) puts a *line* id in the log, and the tag rendering code used to naively unpack that line's geometry ``((x1,y1), (x2,y2))`` as if it were a point's ``(x, y)``, calling ``round()`` on a tuple and raising ``TypeError: type tuple doesn't define __round__ method``. These tests pin the fix in ``Sketch2DWidget._compute_constraint_tags``. """ def _make_widget_with_sketch(self, sk): """Build a Sketch2DWidget in offscreen mode and attach *sk* to it.""" import os os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") from PySide6.QtWidgets import QApplication app = QApplication.instance() or QApplication([]) from fluency.main import Sketch2DWidget w = Sketch2DWidget() w.set_sketch(sk) return w def test_point_on_line_coincident_tag_renders(self): """A coincident between a point and a line must not crash the paint event. Reproduces the user-reported error: ids[1] is a line id, the old code unpacked the line's geometry ``((x1,y1), (x2,y2))`` as a point and called ``round()`` on the inner tuple. """ sk = OCCSketch() a = sk.add_point(0, 0) b = sk.add_point(10, 0) line = sk.add_line(a, b) # 3rd entity: the line itself # Point-on-line: the line id is in the constraint log. p3 = sk.add_point(5, 5) sk.constrain_coincident(p3, line) sk.solve() w = self._make_widget_with_sketch(sk) # Must not raise. tags = w._compute_constraint_tags() # One tag for the coincident. coin_tags = [t for t in tags if "coin" in t["label"]] assert len(coin_tags) == 1 # The tag was anchored (non-None center) and renders successfully. assert coin_tags[0]["center"] is not None def test_point_world_rejects_line_geometry(self): """_point_world must return None (not crash) when given a line id.""" sk = OCCSketch() a = sk.add_point(0, 0) b = sk.add_point(10, 0) line = sk.add_line(a, b) w = self._make_widget_with_sketch(sk) # Old behaviour: round() 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"])