Files
fluencyCAD/tests/test_technical_drawing.py
T
2026-08-18 15:06:51 +02:00

917 lines
33 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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)
# 12 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)