917 lines
33 KiB
Python
917 lines
33 KiB
Python
"""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)
|
||
|