"""Tests for the technical drawing workbench. Covers the manual-dimension pipeline (model-space annotations → sheet-space candidates → placed primitives), the 2D pick geometry helpers, and persistence of drawings (manual dimensions included) in the .fluency project file. """ import json import math import os os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") import pytest from fluency.models.data_model import ( Body, Component, DrawingAnnotation, DrawingView, Project, TechnicalDrawing, ) from fluency.technical_drawing import ( build_manual_candidates, generate_drawing, _layout_views_on_sheet, ) from fluency.io.project_io import ( _technical_drawing_from_dict, _technical_drawing_to_dict, load_project, save_project, ) from fluency.ui.technical_drawing_widget import ( _closest_point_on_segment, _closest_points_on_segments, _line_intersection, _point_to_segment, ) # ── Fixtures ─────────────────────────────────────────────────────────────── @pytest.fixture(scope="module") def qapp(): """Offscreen QApplication for widget-level tests.""" from PySide6.QtWidgets import QApplication app = QApplication.instance() or QApplication([]) yield app def _drawing_with_views(view_kinds=("front",)): drawing = TechnicalDrawing(source_kind="component", source_id="comp-1") for kind in view_kinds: drawing.views.append(DrawingView(kind=kind)) return drawing def _manual_annotation( dimension_kind: str, view_id: str, anchors, direction=None, ) -> DrawingAnnotation: return DrawingAnnotation( kind="dimension", dimension_kind=dimension_kind, view_id=view_id, anchors=list(anchors), direction=direction, ) # ── build_manual_candidates ──────────────────────────────────────────────── class TestBuildManualCandidates: def test_length_candidate_reprojects_anchors(self): drawing = _drawing_with_views() ann = _manual_annotation( "length", "front", ((0.0, 0.0), (0.0, 12.5)), direction=(0.0, 1.0) ) drawing.annotations.append(ann) cands, resolved, unresolved = build_manual_candidates( drawing, {"front": (2.0, 10.0, 20.0)} ) assert unresolved == [] assert resolved == [ann.id] c = cands[0] assert c.kind == "length" assert c.view_id == "front" assert c.key == f"manual:{ann.id}" assert c.references == (ann.id,) assert c.value == pytest.approx(12.5) assert c.label == "12.50" # sheet = model * scale + offset assert c.anchor_points[0] == pytest.approx((10.0, 20.0)) assert c.anchor_points[1] == pytest.approx((10.0, 45.0)) assert c.direction == (0.0, 1.0) def test_length_without_transform_is_unresolved(self): drawing = _drawing_with_views() ann = _manual_annotation("length", "front", ((0.0, 0.0), (3.0, 4.0))) drawing.annotations.append(ann) cands, resolved, unresolved = build_manual_candidates(drawing, {}) assert cands == [] assert unresolved == [ann.id] cands, resolved, unresolved = build_manual_candidates( drawing, {"front": (1.0, 0.0, 0.0)} ) assert unresolved == [] c = cands[0] assert c.value == pytest.approx(5.0) assert c.direction == pytest.approx((0.6, 0.8)) def test_diameter_candidate(self): drawing = _drawing_with_views() ann = _manual_annotation("diameter", "top", ((-5.0, 0.0), (5.0, 0.0))) drawing.annotations.append(ann) cands, resolved, unresolved = build_manual_candidates( drawing, {"top": (1.0, 0.0, 0.0)} ) assert unresolved == [] c = cands[0] assert c.kind == "diameter" assert c.value == pytest.approx(10.0) assert c.label == "Ø10.00" assert c.direction == () def test_angle_candidate(self): drawing = _drawing_with_views() ann = _manual_annotation( "angle", "front", ((0.0, 0.0), (10.0, 0.0), (0.0, 10.0)) ) drawing.annotations.append(ann) cands, resolved, unresolved = build_manual_candidates( drawing, {"front": (1.0, 0.0, 0.0)} ) assert unresolved == [] c = cands[0] assert c.kind == "angle" assert c.value == pytest.approx(90.0) assert c.label == "90.00°" assert len(c.anchor_points) == 3 def test_degenerate_angle_is_unresolved(self): # Collinear arms → 180° → not a usable angle dimension. drawing = _drawing_with_views() ann = _manual_annotation( "angle", "front", ((0.0, 0.0), (10.0, 0.0), (-10.0, 0.0)) ) drawing.annotations.append(ann) cands, resolved, unresolved = build_manual_candidates( drawing, {"front": (1.0, 0.0, 0.0)} ) assert cands == [] assert resolved == [] assert unresolved == [ann.id] def test_hidden_annotation_is_skipped(self): drawing = _drawing_with_views() ann = _manual_annotation("length", "front", ((0.0, 0.0), (1.0, 0.0))) ann.visible = False drawing.annotations.append(ann) cands, resolved, unresolved = build_manual_candidates( drawing, {"front": (1.0, 0.0, 0.0)} ) assert cands == [] assert resolved == [] assert unresolved == [] # ── generate_drawing: auto vs manual dimensions ─────────────────────────── @pytest.fixture(scope="module") def kernel(): from fluency.geometry_occ.kernel import OCGeometryKernel return OCGeometryKernel() @pytest.fixture(scope="module") def box_project(kernel): """Project with one 10 x 20 x 5 box (x: 0..10, y: 0..20, z: 0..5).""" from fluency.geometry.base import Point2D points = [Point2D(0, 0), Point2D(10, 0), Point2D(10, 20), Point2D(0, 20)] polygon = kernel.create_polygon(points) box = kernel.extrude(polygon, 5.0) body = Body(name="Box", geometry=box) comp = Component(name="BoxComp") comp.bodies[body.id] = body project = Project() project.components[comp.id] = comp project.active_component = comp.id return project, comp class TestGenerateDrawingManualDimensions: def test_manual_dimension_placed_with_auto_off(self, kernel, box_project): project, comp = box_project drawing = TechnicalDrawing( source_kind="component", source_id=comp.id, views=[DrawingView(kind="front")], auto_dimensions=False, ) # Distance between the two vertical edges of the box front face. ann = _manual_annotation( "length", "front", ((0.0, 0.0), (10.0, 0.0)), direction=(1.0, 0.0) ) drawing.annotations.append(ann) result = generate_drawing(drawing, project, kernel) assert result.view_transforms, "view transforms must be published" manual_keys = [ p.candidate_key for p in result.primitives if p.candidate_key ] assert f"manual:{ann.id}" in manual_keys label_texts = [ p.text for p in result.primitives if p.candidate_key == f"manual:{ann.id}" and p.kind == "text" ] assert label_texts == ["10.00"] # Auto off → no auto-placed dimensions. assert not any( k for k in manual_keys if not k.startswith("manual:") ), "auto dimensions must stay out while auto_dimensions is off" assert ann.id in result.resolved_annotation_ids def test_auto_off_places_no_auto_dimensions(self, kernel, box_project): project, comp = box_project drawing = TechnicalDrawing( source_kind="component", source_id=comp.id, views=[DrawingView(kind="front")], auto_dimensions=False, ) result = generate_drawing(drawing, project, kernel) dim_keys = [p.candidate_key for p in result.primitives if p.candidate_key] assert dim_keys == [], f"expected no dimensions, got {dim_keys}" def test_auto_on_places_auto_dimensions(self, kernel, box_project): project, comp = box_project drawing = TechnicalDrawing( source_kind="component", source_id=comp.id, views=[DrawingView(kind="front")], auto_dimensions=True, ) result = generate_drawing(drawing, project, kernel) dim_keys = [p.candidate_key for p in result.primitives if p.candidate_key] assert dim_keys, "auto dimensions expected with auto_dimensions on" assert all(not k.startswith("manual:") for k in dim_keys) def test_auto_and_manual_coexist(self, kernel, box_project): project, comp = box_project drawing = TechnicalDrawing( source_kind="component", source_id=comp.id, views=[DrawingView(kind="front")], auto_dimensions=True, ) ann = _manual_annotation( "length", "front", ((0.0, 0.0), (10.0, 0.0)), direction=(1.0, 0.0) ) drawing.annotations.append(ann) result = generate_drawing(drawing, project, kernel) dim_keys = {p.candidate_key for p in result.primitives if p.candidate_key} assert f"manual:{ann.id}" in dim_keys assert any(k for k in dim_keys if not k.startswith("manual:")) def test_diameter_manual_on_cylinder(self, kernel): from OCP.BRepPrimAPI import BRepPrimAPI_MakeCylinder from OCP.gp import gp_Ax2, gp_Dir, gp_Pnt from fluency.geometry_occ.kernel import OCCGeometryObject ax = gp_Ax2(gp_Pnt(0, 0, 0), gp_Dir(0, 0, 1)) cyl = OCCGeometryObject( BRepPrimAPI_MakeCylinder(ax, 4.0, 8.0).Shape(), {"type": "cylinder"}, ) body = Body(name="Cyl", geometry=cyl) comp = Component(name="CylComp") comp.bodies[body.id] = body project = Project() project.components[comp.id] = comp project.active_component = comp.id drawing = TechnicalDrawing( source_kind="component", source_id=comp.id, views=[DrawingView(kind="front")], auto_dimensions=False, ) ann = _manual_annotation("diameter", "front", ((-4.0, 0.0), (4.0, 0.0))) drawing.annotations.append(ann) result = generate_drawing(drawing, project, kernel) label_texts = [ p.text for p in result.primitives if p.candidate_key == f"manual:{ann.id}" and p.kind == "text" ] assert label_texts == ["Ø8.00"] # ── Circle centres: ISO center marks + centre-point dimensioning ────────── def _cylinder_project(kernel): """One Ø8 x 8 cylinder (axis +Z) as a draw-able component. HLR may split a circle's edge into sampled segments for some shapes, so tests that need a guaranteed circle primitive build it directly (see :class:`TestCircleCenterMarks` / :class:`TestCircleCenterPick`). """ from OCP.BRepPrimAPI import BRepPrimAPI_MakeCylinder from OCP.gp import gp_Ax2, gp_Dir, gp_Pnt from fluency.geometry_occ.kernel import OCCGeometryObject ax = gp_Ax2(gp_Pnt(0, 0, 0), gp_Dir(0, 0, 1)) cyl = OCCGeometryObject( BRepPrimAPI_MakeCylinder(ax, 4.0, 8.0).Shape(), {"type": "cylinder"}, ) body = Body(name="Cyl", geometry=cyl) comp = Component(name="CylComp") comp.bodies[body.id] = body project = Project() project.components[comp.id] = comp project.active_component = comp.id return project, comp class TestCircleCenterMarks: """ISO 14128 center marks: a thin cross at each projected circle's centre, crossing at the centre and extending past the circle edge.""" def test_center_marks_emitted_for_circles(self): from fluency.technical_drawing import _assemble_view # Synthetic view plane: a 20 × 16 box with a r4 circle at (10, 8). edges = [ ((0.0, 0.0), (20.0, 0.0), "line", "visible"), ((20.0, 0.0), (20.0, 16.0), "line", "visible"), ((20.0, 16.0), (0.0, 16.0), "line", "visible"), ((0.0, 16.0), (0.0, 0.0), "line", "visible"), ((10.0, 8.0), (14.0, 8.0), "circle_full", "visible"), ] view = DrawingView(kind="top") prims, _cands, _warns = _assemble_view(edges, [], view, (10, 10, 200, 150)) circles = [p for p in prims if p.kind == "circle"] assert len(circles) == 1 c = circles[0] cx, cy = c.center marks = [p for p in prims if p.kind == "line" and p.style == "center"] assert len(marks) == 2, "one horizontal and one vertical center mark" horiz = next(p for p in marks if p.points[0][1] == p.points[1][1]) vert = next(p for p in marks if p.points[0][0] == p.points[1][0]) # The marks cross at the circle centre. assert horiz.points[0][1] == cy and horiz.points[1][1] == cy assert vert.points[0][0] == cx and vert.points[1][0] == cx # And each extends past the circle edge. half_h = abs(horiz.points[1][0] - horiz.points[0][0]) / 2.0 half_v = abs(vert.points[1][1] - vert.points[0][1]) / 2.0 assert half_h > c.radius assert half_v > c.radius class TestCircleCenterAnchors: """Distance picks between circle centres, and between a centre and an edge, resolve to the right model-space anchor pair.""" def _w(self): from fluency.ui.technical_drawing_widget import TechnicalDrawingWidget return TechnicalDrawingWidget def test_pick_point_kinds(self): W = self._w() assert W._pick_point({"kind": "point", "point": (1.0, 2.0)}) == (1.0, 2.0) assert W._pick_point( {"kind": "circle", "center": (3.0, 4.0), "radius": 1.0} ) == (3.0, 4.0) assert W._pick_point({"kind": "segment", "p1": (0, 0), "p2": (1, 1)}) is None def test_center_to_center(self): W = self._w() a = {"kind": "circle", "view_id": "v", "center": (0.0, 0.0), "radius": 2.0} b = {"kind": "circle", "view_id": "v", "center": (5.0, 12.0), "radius": 3.0} p1, p2 = W._distance_anchors(a, b) assert p1 == (0.0, 0.0) assert p2 == (5.0, 12.0) def test_center_to_edge(self): W = self._w() a = {"kind": "point", "view_id": "v", "point": (4.0, 6.0)} b = {"kind": "segment", "view_id": "v", "p1": (0.0, 0.0), "p2": (10.0, 0.0)} p1, p2 = W._distance_anchors(a, b) assert p1 == (4.0, 6.0) # Closest point on the edge is straight below the centre. assert p2 == pytest.approx((4.0, 0.0)) def test_edge_to_edge_unchanged(self): W = self._w() a = {"kind": "segment", "view_id": "v", "p1": (0.0, 0.0), "p2": (10.0, 0.0)} b = {"kind": "segment", "view_id": "v", "p1": (2.0, 5.0), "p2": (8.0, 5.0)} p1, p2 = W._distance_anchors(a, b) assert p1 == pytest.approx((2.0, 0.0)) assert p2 == pytest.approx((2.0, 5.0)) # ── View layout: page fill, no overlaps, title-block clearance ─────────── class TestViewLayout: """_layout_views_on_sheet packs the views to fill the sheet, keeps them apart and clear of the title block, and rotates individual views when that makes the set fit more.""" A3W, A3H = 420.0, 297.0 # Title block box + 5 mm clearance zone (see _title_block_primitives). TB = (235.0, 0.0, 420.0, 62.0) @staticmethod def _bbox(w, h): return (0.0, 0.0, float(w), float(h)) def _layout(self, kinds, boxes): views = [DrawingView(kind=k) for k in kinds] return _layout_views_on_sheet(views, {k: boxes[k] for k in kinds}) def _assert_valid(self, slots): for k, s in slots.items(): assert s[0] >= 10.0 - 1e-6 and s[1] >= 10.0 - 1e-6, (k, s) assert s[0] + s[2] <= self.A3W - 10.0 + 1e-6, (k, s) assert s[1] + s[3] <= self.A3H - 10.0 + 1e-6, (k, s) x0, y0, w, h = s x1, y1, w2, h2 = self.TB assert x0 + w <= x1 or x1 + w2 <= x0 or y0 + h <= y1 or y1 + h2 <= y0, \ f"{k} intrudes title block: {s}" ks = list(slots) for i in range(len(ks)): for j in range(i + 1, len(ks)): a, b_ = slots[ks[i]], slots[ks[j]] sep = ( a[0] + a[2] <= b_[0] + 0.1 or b_[0] + b_[2] <= a[0] + 0.1 or a[1] + a[3] <= b_[1] + 0.1 or b_[1] + b_[3] <= a[1] + 0.1 ) assert sep, f"{ks[i]} overlaps {ks[j]}: {a} / {b_}" @staticmethod def _fill(slots): x0 = min(s[0] for s in slots.values()) y0 = min(s[1] for s in slots.values()) x1 = max(s[0] + s[2] for s in slots.values()) y1 = max(s[1] + s[3] for s in slots.values()) return (x1 - x0) * (y1 - y0) / (420.0 * 297.0) def test_single_view_fills_page(self): slots, scale, rots = self._layout(["front"], {"front": self._bbox(10, 20)}) self._assert_valid(slots) # 1–2 views stay upright — no sideways single view. assert rots == {"front": 0.0} # The tall 10 × 20 view is scaled until it touches the full-height # left strip's reduced height (0.8 × 277 mm). assert scale == pytest.approx(221.6 / 20.0) assert slots["front"][3] == pytest.approx(221.6) def test_two_views_share_page(self): slots, _scale, _rots = self._layout( ["front", "top"], {"front": self._bbox(40, 40), "top": self._bbox(40, 20)}, ) self._assert_valid(slots) assert self._fill(slots) > 0.30 def test_classic_three_view_keeps_cross(self): boxes = {k: self._bbox(40, 40) for k in ("front", "top", "right")} slots, _scale, rots = self._layout(list(boxes), boxes) self._assert_valid(slots) assert set(rots.values()) == {0.0}, "classic cross must not rotate" # Top sits directly above front; right directly to its right. assert abs(slots["top"][0] - slots["front"][0]) < 1e-6 assert slots["top"][1] > slots["front"][1] + slots["front"][3] assert slots["right"][0] > slots["front"][0] + slots["front"][2] assert abs(slots["right"][1] - slots["front"][1]) < 1e-6 def test_thin_part_gets_rotated_views(self): boxes = { "front": self._bbox(200, 30), "top": self._bbox(30, 50), "right": self._bbox(50, 30), "left": self._bbox(50, 30), "back": self._bbox(200, 30), "bottom": self._bbox(30, 50), } slots, scale, rots = self._layout(list(boxes), boxes) self._assert_valid(slots) assert any(r == 90.0 for r in rots.values()), "rotation must kick in" # Each slot is the (possibly swapped) model size times the scale. for k, s in slots.items(): w, h = boxes[k][2], boxes[k][3] sw, sh = s[2] / scale, s[3] / scale assert ( (sw == pytest.approx(w) and sh == pytest.approx(h)) or (sw == pytest.approx(h) and sh == pytest.approx(w)) ), (k, s, w, h) def test_all_views_plus_isometric_fill_page(self): boxes = { "front": self._bbox(80, 40), "top": self._bbox(80, 30), "right": self._bbox(30, 40), "left": self._bbox(30, 40), "back": self._bbox(80, 40), "bottom": self._bbox(80, 30), "isometric": self._bbox(60, 60), } slots, _scale, _rots = self._layout(list(boxes), boxes) self._assert_valid(slots) assert self._fill(slots) > 0.55 # ── Project drawing persistence ──────────────────────────────────────────── def _drawing_with_manual_dim(): drawing = TechnicalDrawing( source_kind="component", source_id="comp-42", views=[DrawingView(kind="front"), DrawingView(kind="top")], auto_dimensions=True, title="Persisted Drawing", revision="B", ) drawing.annotations.append( _manual_annotation( "length", "front", ((0.0, 0.0), (0.0, 12.5)), direction=(0.0, 1.0) ) ) drawing.annotations.append( _manual_annotation("diameter", "top", ((-5.0, 0.0), (5.0, 0.0))) ) return drawing class TestDrawingPersistence: def test_drawing_dict_roundtrip(self): drawing = _drawing_with_manual_dim() data = _technical_drawing_to_dict(drawing) restored = _technical_drawing_from_dict(json.loads(json.dumps(data))) assert restored.id == drawing.id assert restored.source_kind == "component" assert restored.source_id == "comp-42" assert restored.auto_dimensions is True assert [v.kind for v in restored.views] == ["front", "top"] assert len(restored.annotations) == 2 a = restored.annotations[0] assert a.dimension_kind == "length" assert a.view_id == "front" assert a.anchors == [(0.0, 0.0), (0.0, 12.5)] assert a.direction == (0.0, 1.0) b = restored.annotations[1] assert b.dimension_kind == "diameter" assert b.anchors == [(-5.0, 0.0), (5.0, 0.0)] def test_project_drawings_lookup(self): project = Project() drawing = _drawing_with_manual_dim() project.add_drawing(drawing) assert project.get_drawing_for("component", "comp-42") is drawing assert project.get_drawing_for("assembly", "comp-42") is None assert project.get_drawing_for("component", "other") is None def test_project_save_load_roundtrip(self, tmp_path): project = Project(name="Drawing Project") drawing = _drawing_with_manual_dim() project.add_drawing(drawing) path = save_project(project, str(tmp_path / "proj.fluency")) loaded, _view_state = load_project(path) assert len(loaded.drawings) == 1 restored = loaded.drawings[0] assert restored.source_id == "comp-42" assert restored.auto_dimensions is True assert restored.title == "Persisted Drawing" assert len(restored.annotations) == 2 a = restored.annotations[0] assert a.dimension_kind == "length" assert a.view_id == "front" assert a.anchors == [(0.0, 0.0), (0.0, 12.5)] assert a.direction == (0.0, 1.0) assert a.id == drawing.annotations[0].id # The restored drawing must still build the same candidates. cands, resolved, unresolved = build_manual_candidates( restored, {"front": (1.0, 0.0, 0.0), "top": (1.0, 0.0, 0.0)} ) assert unresolved == [] labels = sorted(c.label for c in cands) assert labels == ["12.50", "Ø10.00"] def test_load_ignores_corrupt_drawing_entry(self, tmp_path): import zipfile project = Project(name="Mixed") project.add_drawing(_drawing_with_manual_dim()) path = save_project(project, str(tmp_path / "proj.fluency")) with zipfile.ZipFile(path, "r") as zf: names = zf.namelist() contents = {n: zf.read(n) for n in names} manifest = json.loads(contents["project.json"]) manifest["drawings"].append({"id": "broken", "views": "not-a-list"}) contents["project.json"] = json.dumps(manifest).encode("utf-8") with zipfile.ZipFile(path, "w") as zf: for name in names: zf.writestr(name, contents[name]) loaded, _ = load_project(path) # Corrupt entry skipped, valid one kept. assert len(loaded.drawings) == 1 assert loaded.drawings[0].source_id == "comp-42" def test_auto_dimensions_default_off(self): assert TechnicalDrawing().auto_dimensions is False # ── Pick geometry helpers ────────────────────────────────────────────────── class TestPickGeometry: def test_point_to_segment_inside(self): q, d = _point_to_segment((5.0, 3.0), (0.0, 0.0), (10.0, 0.0)) assert q == pytest.approx((5.0, 0.0)) assert d == pytest.approx(3.0) def test_point_to_segment_clamps_at_endpoint(self): q, d = _point_to_segment((-2.0, 1.0), (0.0, 0.0), (10.0, 0.0)) assert q == pytest.approx((0.0, 0.0)) assert d == pytest.approx(math.hypot(2.0, 1.0)) def test_crossing_segments(self): q1, q2, d = _closest_points_on_segments( (0.0, 0.0), (10.0, 0.0), (4.0, -2.0), (4.0, 8.0) ) assert q1 == pytest.approx((4.0, 0.0)) assert q2 == pytest.approx((4.0, 0.0)) assert d == pytest.approx(0.0, abs=1e-9) def test_parallel_overlapping_segments(self): # The classic "distance between two parallel edges" pick: # result must be the true perpendicular distance. q1, q2, d = _closest_points_on_segments( (0.0, 0.0), (10.0, 0.0), (2.0, 5.0), (8.0, 5.0) ) assert d == pytest.approx(5.0) assert q1[1] == pytest.approx(0.0) assert q2[1] == pytest.approx(5.0) assert q1[0] == pytest.approx(q2[0]) def test_parallel_disjoint_segments(self): q1, q2, d = _closest_points_on_segments( (0.0, 0.0), (2.0, 0.0), (5.0, 3.0), (7.0, 3.0) ) assert d == pytest.approx(math.hypot(3.0, 3.0)) def test_line_intersection(self): pt = _line_intersection( (0.0, 0.0), (10.0, 0.0), (4.0, -2.0), (4.0, 8.0) ) assert pt == pytest.approx((4.0, 0.0)) def test_line_intersection_parallel_is_none(self): assert _line_intersection( (0.0, 0.0), (10.0, 0.0), (2.0, 5.0), (8.0, 5.0) ) is None def test_closest_point_on_segment(self): q = _closest_point_on_segment((4.0, 9.0), (0.0, 0.0), (10.0, 0.0)) assert q == pytest.approx((4.0, 0.0)) # ── Widget: dimension tool plumbing (offscreen) ──────────────────────────── class TestDrawingWidgetTools: def _widget(self, qapp): from fluency.ui.technical_drawing_widget import TechnicalDrawingWidget return TechnicalDrawingWidget() def test_widget_starts_without_pick_mode(self, qapp): w = self._widget(qapp) assert w._canvas._pick_mode == "" assert not w._auto_dim_check.isChecked() assert not any(b.isChecked() for b in w._tool_buttons.values()) def test_tool_toggle_enters_pick_mode(self, qapp): w = self._widget(qapp) btn = w._tool_buttons["distance"] btn.setChecked(True) assert w._canvas._pick_mode == "distance" assert "Distance" in w._status_label.text() assert "edge" in w._status_label.text() # Switching tools re-targets the canvas and unchecks the old tool. w._tool_buttons["diameter"].setChecked(True) assert btn.isChecked() is False assert w._canvas._pick_mode == "diameter" # Escape path: cancels the tool, clears all buttons and mode. w._cancel_pick() assert w._canvas._pick_mode == "" for other in w._tool_buttons.values(): assert not other.isChecked() def test_add_manual_dimension_appends_and_emits(self, qapp): w = self._widget(qapp) w.set_drawing( TechnicalDrawing(source_kind="component", source_id="c1") ) changes = [] w.drawing_changed.connect(lambda: changes.append(1)) w._add_manual_dimension( "length", anchors=((0.0, 0.0), (10.0, 0.0)), view_id="front", direction=(1.0, 0.0), ) anns = w._drawing.annotations assert len(anns) == 1 assert anns[0].dimension_kind == "length" assert anns[0].view_id == "front" assert anns[0].anchors == [(0.0, 0.0), (10.0, 0.0)] assert changes == [1] def test_clear_removes_manual_dimensions_only(self, qapp): w = self._widget(qapp) drawing = TechnicalDrawing(source_kind="component", source_id="c1") drawing.annotations.append( DrawingAnnotation(kind="note", text="keep me") ) drawing.annotations.append( _manual_annotation( "length", "front", ((0.0, 0.0), (5.0, 0.0)) ) ) w.set_drawing(drawing) w._on_clear_clicked() assert len(drawing.annotations) == 1 assert drawing.annotations[0].kind == "note" def test_adopt_stored_project_drawing(self, qapp, kernel): w = self._widget(qapp) project = Project() comp = Component(name="ExistingComp") project.components[comp.id] = comp stored = TechnicalDrawing( source_kind="component", source_id=comp.id ) stored.annotations.append( _manual_annotation( "diameter", "front", ((-4.0, 0.0), (4.0, 0.0)) ) ) project.add_drawing(stored) w.set_project(project, kernel) w.set_active_component(comp) # The stored drawing is re-adopted (not replaced). assert w._drawing is stored assert len(w._drawing.annotations) == 1 def test_new_source_creates_and_registers_drawing(self, qapp, kernel): w = self._widget(qapp) project = Project() comp = Component(name="NewComp") project.components[comp.id] = comp w.set_project(project, kernel) w.set_active_component(comp) assert w._drawing is not None assert w._drawing.source_kind == "component" assert w._drawing.source_id == comp.id assert len(project.drawings) == 1 assert project.drawings[0] is w._drawing class TestCircleCenterPick: """Clicking a circle's centre mark while a distance tool is active picks a point feature at the circle centre (model coords + radius). HLR only projects a true circle for some hole shapes (a plain cylinder discretises into segments), so the render result is built directly with one guaranteed circle primitive. """ def _canvas_with_circle(self, qapp): from PySide6.QtCore import QPointF from fluency.technical_drawing import ( DrawingPrimitive, DrawingRenderResult, ) from fluency.ui.technical_drawing_widget import DrawingCanvas # One r4 circle at model (20, 15), drawn at 10× scale at the # sheet centre: model (20,15) → sheet (200, 150). circle = DrawingPrimitive( kind="circle", points=(), style="visible", center=(200.0, 150.0), radius=40.0, view_id="top", ) result = DrawingRenderResult( primitives=(circle,), candidates=(), resolved_annotation_ids=(), unresolved_annotation_ids=(), source_fingerprint="", warnings=(), view_transforms={"top": (10.0, 0.0, 0.0)}, ) canvas = DrawingCanvas() canvas.resize(840, 600) canvas.set_render_result(result) canvas.set_pick_mode("distance") return canvas, QPointF def test_pick_center_mark_returns_point(self, qapp): canvas, QPointF = self._canvas_with_circle(qapp) # Sheet → device position of the circle centre. rect = canvas._sheet_rect() scale = rect.width() / 420.0 pos = QPointF(rect.x() + 200.0 * scale, rect.y() + (297.0 - 150.0) * scale) hit = canvas._pick_feature(pos) assert hit is not None, "clicking the centre mark must hit something" assert hit["kind"] == "point" assert hit["view_id"] == "top" assert hit["radius"] == pytest.approx(4.0, abs=1e-6) # The picked model point is the inverse-transformed sheet centre: # (200, 150) at 10× scale → (20, 15). assert hit["point"] == pytest.approx((20.0, 15.0), abs=1e-9) def test_distance_tool_accepts_center_then_edge(self, qapp, kernel): from fluency.ui.technical_drawing_widget import TechnicalDrawingWidget w = TechnicalDrawingWidget() project, comp = _cylinder_project(kernel) w.set_project(project, kernel) w.set_active_component(comp) assert w._drawing is not None # First pick: a circle centre at model (10, 12) — the dict a # centre-mark click produces (see test above). w._first_pick = { "kind": "point", "view_id": "top", "point": (10.0, 12.0), "radius": 4.0, } # Second pick: a horizontal edge 6 mm above the centre. second = { "kind": "segment", "view_id": "top", "p1": (0.0, 18.0), "p2": (20.0, 18.0), } w._on_edge_pick(second, "distance") assert w._drawing.annotations, "a manual dimension must be appended" ann = w._drawing.annotations[-1] assert ann.dimension_kind == "length" assert ann.view_id == "top" # First anchor is the picked centre; the second is the closest # point on the edge, straight above it. assert ann.anchors[0] == pytest.approx((10.0, 12.0), abs=1e-9) assert ann.anchors[1] == pytest.approx((10.0, 18.0), abs=1e-6)