- tech drawing and render improv

This commit is contained in:
bklronin
2026-08-18 15:06:51 +02:00
parent 813ddc3596
commit 67b73c13b8
10 changed files with 1886 additions and 54 deletions
+12 -9
View File
@@ -6,7 +6,10 @@
<component name="ChangeListManager">
<list default="true" id="8f0bafd6-58a0-4b20-aa2b-ddc3ba278873" name="Changes" comment="- tech draw draft v2">
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/fluency/geometry_occ/kernel.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/geometry_occ/kernel.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/fluency/technical_drawing.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/technical_drawing.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/fluency/ui/main_window.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/ui/main_window.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/fluency/ui/render_window.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/ui/render_window.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/fluency/ui/technical_drawing_widget.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/ui/technical_drawing_widget.py" afterDir="false" />
</list>
<option name="SHOW_DIALOG" value="false" />
@@ -119,14 +122,6 @@
<option name="presentableId" value="Default" />
<updated>1703867682707</updated>
</task>
<task id="LOCAL-00004" summary="- Sketch projection partly works again :)">
<option name="closed" value="true" />
<created>1735585968733</created>
<option name="number" value="00004" />
<option name="presentableId" value="LOCAL-00004" />
<option name="project" value="LOCAL" />
<updated>1735585968733</updated>
</task>
<task id="LOCAL-00005" summary="- Added new componnt controls">
<option name="closed" value="true" />
<created>1735601610504</created>
@@ -511,7 +506,15 @@
<option name="project" value="LOCAL" />
<updated>1786984519781</updated>
</task>
<option name="localTasksCounter" value="53" />
<task id="LOCAL-00053" summary="- tech draw draft v2">
<option name="closed" value="true" />
<created>1786995561375</created>
<option name="number" value="00053" />
<option name="presentableId" value="LOCAL-00053" />
<option name="project" value="LOCAL" />
<updated>1786995561376</updated>
</task>
<option name="localTasksCounter" value="54" />
<servers />
</component>
<component name="TypeScriptGeneratedFilesManager">
+376
View File
@@ -0,0 +1,376 @@
# ── Sheet layout regions ────────────────────────────────────────────────────
_LAYOUT_MARGIN_MM = 10.0
_VIEW_GAP_MM = 12.0
# Title block box (see _title_block_primitives): 180 × 52 at the bottom-right
# corner with a 5 mm sheet margin. Views must clear it (plus clearance).
_TB_LEFT_MM = _A3_WIDTH_MM - 180.0 - 5.0
_TB_TOP_MM = 5.0 + 52.0
_TB_CLEARANCE_MM = 5.0
# Sheet interior (border margin) as (x0, y0, x1, y1) in sheet mm.
_SHEET_INNER = (
_LAYOUT_MARGIN_MM,
_LAYOUT_MARGIN_MM,
_A3_WIDTH_MM - _LAYOUT_MARGIN_MM,
_A3_HEIGHT_MM - _LAYOUT_MARGIN_MM,
)
# Regions the orthographic layout may occupy, as (x0, y0, w, h):
# - UPPER: full sheet width above the title block
# - LEFT: full sheet height in the left strip beside the title block
_REGION_UPPER = (
_LAYOUT_MARGIN_MM,
_TB_TOP_MM + _TB_CLEARANCE_MM,
_A3_WIDTH_MM - 2 * _LAYOUT_MARGIN_MM,
_A3_HEIGHT_MM - _LAYOUT_MARGIN_MM - (_TB_TOP_MM + _TB_CLEARANCE_MM),
)
_REGION_LEFT = (
_LAYOUT_MARGIN_MM,
_LAYOUT_MARGIN_MM,
_TB_LEFT_MM - _TB_CLEARANCE_MM - _LAYOUT_MARGIN_MM,
_A3_HEIGHT_MM - 2 * _LAYOUT_MARGIN_MM,
)
_MID_ORDER = ("left", "front", "right", "back")
_COL_ORDER = ("top", "front", "bottom") # sheet top → bottom
_GRID_ORDER = ("front", "right", "back", "top", "left", "bottom")
_RectList = List[Tuple[str, float, float, float, float]]
def _place_cross(
dims: Dict[str, Tuple[float, float]], gap: float
) -> _RectList:
"""Classic third-angle cross: mid views run left→right (left, front,
right, back), col views stack top→bottom (top, front, bottom), with the
anchor view (front, or the first present one) at the intersection.
*dims* maps view id → ``(w, h)`` in sheet units. Returns local
``(vid, x, y, w, h)`` rects (origin arbitrary — the caller centres the
union on the sheet).
"""
mid = [k for k in _MID_ORDER if k in dims]
col = [k for k in _COL_ORDER if k in dims]
if not mid and not col:
return []
rects: Dict[str, Tuple[float, float, float, float]] = {}
if col:
# Stack bottom → top.
y = 0.0
for k in reversed(col):
w, h = dims[k]
rects[k] = (0.0, y, w, h)
y += h + gap
col_h = y - gap
cx = max(dims[k][0] for k in col) / 2.0
for k in col:
_x, yy, w, h = rects[k]
rects[k] = (cx - w / 2.0, yy, w, h)
else:
col_h = 0.0
cx = 0.0
anchor = "front" if "front" in dims else (mid[0] if mid else col[0])
if anchor in rects:
ax, ay, aw, _ah = rects[anchor]
anchor_cy = ay + _ah / 2.0
else:
aw, ah = dims[anchor]
ax = cx - aw / 2.0
ay = col_h / 2.0 - ah / 2.0
rects[anchor] = (ax, ay, aw, ah)
anchor_cy = col_h / 2.0
ia = mid.index(anchor) if anchor in mid else -1
x = ax
for k in reversed(mid[:ia]):
w, h = dims[k]
x -= w + gap
rects[k] = (x, anchor_cy - h / 2.0, w, h)
x = ax + aw
for k in mid[ia + 1 :]:
w, h = dims[k]
x += gap
rects[k] = (x, anchor_cy - h / 2.0, w, h)
x += w
return [(k, *r) for k, r in rects.items()]
def _place_swapped(dims: Dict[str, Tuple[float, float]], gap: float) -> _RectList:
"""Cross with the view families swapped: the mid views stack vertically
(left, front, right, back from the top) and the col views run
horizontally (bottom, front, top from the left) — the classic cross
turned a quarter turn, for sheets where that orientation fits more.
"""
mid = [k for k in _MID_ORDER if k in dims]
col = [k for k in _COL_ORDER if k in dims]
if not mid or not col:
return []
rects: Dict[str, Tuple[float, float, float, float]] = {}
cx = max(dims[k][0] for k in mid) / 2.0
y = 0.0
for k in mid: # top → bottom
w, h = dims[k]
rects[k] = (cx - w / 2.0, y, w, h)
y += h + gap
anchor = "front" if "front" in dims else mid[0]
anchor_cy = rects[anchor][1] + dims[anchor][1] / 2.0
row: Dict[str, Tuple[float, float, float, float]] = {}
x = 0.0
x_anchor = 0.0
for k in reversed(col): # bottom, front, top → left to right
w, h = dims[k]
if k == anchor:
x_anchor = x
row[k] = (x, anchor_cy - h / 2.0, w, h)
x += w + gap
shift = rects[anchor][0] - x_anchor
for k, r in row.items():
if k == anchor:
continue
x0, y0, w, h = r
rects[k] = (x0 + shift, y0, w, h)
return [(k, *r) for k, r in rects.items()]
def _place_grid(
dims: Dict[str, Tuple[float, float]], gap: float, rows: int
) -> _RectList:
"""Wrap the present standard views into a grid of *rows* rows, filled
bottom→top and left→right (so the primary views sit near the bottom,
like in the cross)."""
order = [k for k in _GRID_ORDER if k in dims]
if not order:
return []
cols = max(1, -(-len(order) // rows))
rects: _RectList = []
y = 0.0
for r in range(rows):
chunk = order[r * cols : (r + 1) * cols]
if not chunk:
break
x = 0.0
row_h = 0.0
for k in chunk:
w, h = dims[k]
rects.append((k, x, y, w, h))
x += w + gap
row_h = max(row_h, h)
y += row_h + gap
return rects
def _fit_scale(
place: Callable[
[Dict[str, Tuple[float, float]], float], _RectList
],
dims_m: Dict[str, Tuple[float, float]],
region: Tuple[float, float, float, float],
) -> float:
"""Largest shared scale at which *dims_m* (model units) laid out by
*place* fits the ``(x0, y0, w, h)`` *region* of the sheet.
The union size grows monotonically with the scale, so a bisection
converges to the tight fit.
"""
_rx0, _ry0, rw, rh = region
def fits(s: float) -> bool:
rects = place(
{k: (w * s, h * s) for k, (w, h) in dims_m.items()}, _VIEW_GAP_MM
)
if not rects:
return True
minx = min(r[1] for r in rects)
miny = min(r[2] for r in rects)
maxx = max(r[1] + r[3] for r in rects)
maxy = max(r[2] + r[4] for r in rects)
return (maxx - minx) <= rw + 1e-9 and (maxy - miny) <= rh + 1e-9
s_lo, s_hi = 0.0, 1.0
if fits(s_hi):
s_lo = s_hi
while s_hi < 1.0e6 and fits(s_hi * 2.0):
s_hi *= 2.0
for _ in range(60):
mid = 0.5 * (s_lo + s_hi)
if fits(mid):
s_lo = mid
else:
s_hi = mid
return s_lo
def _free_rects(
used_rects: Sequence[Tuple[float, float, float, float]]
) -> List[Tuple[float, float, float, float]]:
"""Axis-aligned free rects ``(x0, y0, w, h)`` around *used_rects*,
clearing the sheet border and the title block zone."""
ix0, iy0, ix1, iy1 = _SHEET_INNER
if used_rects:
ux0 = min(r[0] for r in used_rects)
uy0 = min(r[1] for r in used_rects)
ux1 = max(r[0] + r[2] for r in used_rects)
uy1 = max(r[1] + r[3] for r in used_rects)
cands = [
(ux1 + _VIEW_GAP_MM, iy0, ix1, iy1), # right of the used block
(ix0, iy0, ux0 - _VIEW_GAP_MM, iy1), # left
(ix0, uy1 + _VIEW_GAP_MM, ix1, iy1), # above
(ix0, iy0, ix1, uy0 - _VIEW_GAP_MM), # below
]
else:
cands = [(ix0, iy0, ix1, iy1)]
tb = (
_TB_LEFT_MM - _TB_CLEARANCE_MM,
0.0,
_A3_WIDTH_MM - (_TB_LEFT_MM - _TB_CLEARANCE_MM),
_TB_TOP_MM + _TB_CLEARANCE_MM,
)
out: List[Tuple[float, float, float, float]] = []
for x0, y0, x1, y1 in cands:
x0, y0 = max(x0, ix0), max(y0, iy0)
x1, y1 = min(x1, ix1), min(y1, iy1)
if x1 - x0 < 1.0 or y1 - y0 < 1.0:
continue
if not (x1 <= tb[0] or tb[2] <= x0 or y1 <= tb[1] or tb[3] <= y0):
# Overlaps the title block zone — keep the parts above/left of it.
subs = [
(x0, max(y0, tb[3]), x1, y1),
(x0, y0, min(x1, tb[0]), y1),
]
else:
subs = [(x0, y0, x1, y1)]
for sx0, sy0, sx1, sy1 in subs:
if sx1 - sx0 > 1.0 and sy1 - sy0 > 1.0:
out.append((sx0, sy0, sx1 - sx0, sy1 - sy0))
return out
def _layout_views_on_sheet(
views: Sequence[DrawingView],
bboxes: Dict[str, Tuple[float, float, float, float]],
) -> Tuple[
Dict[str, Tuple[float, float, float, float]],
Optional[float],
Dict[str, float],
]:
"""Compute a slot rectangle and sheet rotation for each view.
*bboxes* maps view_id → ``(min_x, min_y, max_x, max_y)`` in model
units (from :func:`_edges_bounds`). Returns ``(slots, common_scale,
rotations)``: slots are ``(left, bottom, width, height)`` in sheet mm
(origin at the sheet's bottom-left corner, +y up), common_scale is the
shared model→sheet scale of the standard orthographic views, and
rotations maps view_id → sheet rotation in degrees (0 or 90).
The sheet is filled, not just used: every candidate arrangement
(classic third-angle cross, the cross with the view families swapped,
and 1/2/3-row grids) is combined with every per-view 90° rotation
assignment, and the candidate giving the largest shared scale is used.
Candidates within 0.5% of the best scale prefer the one with fewer
rotated views, then the more conventional arrangement, so layouts stay
stable and standard whenever they are already the best fit. All
orthographic views share one scale so the projections stay mutually
consistent. Isometric and custom views take the largest remaining
free rect (clearing the title block).
"""
slots: Dict[str, Tuple[float, float, float, float]] = {}
rotations: Dict[str, float] = {}
common_scale: Optional[float] = None
ortho = [
v for v in views if v.kind in _STANDARD_VIEWS and v.kind != "isometric"
]
used_rects: List[Tuple[float, float, float, float]] = []
if ortho:
dims0: Dict[str, Tuple[float, float]] = {}
for v in ortho:
b = bboxes.get(v.kind)
vs = max(v.scale, 1e-9)
if b is None:
dims0[v.kind] = (1.0 * vs, 1.0 * vs)
else:
dims0[v.kind] = (
max(b[2] - b[0], 1e-6) * vs,
max(b[3] - b[1], 1e-6) * vs,
)
keys = list(dims0)
arrangements: Tuple[
Tuple[str, int, Callable[[Dict[str, Tuple[float, float]], float], _RectList]]
] = (
("cross", 0, _place_cross),
("swapped", 1, _place_swapped),
("grid1", 2, lambda d, g: _place_grid(d, g, 1)),
("grid2", 3, lambda d, g: _place_grid(d, g, 2)),
("grid3", 4, lambda d, g: _place_grid(d, g, 3)),
)
cands: List[
Tuple[float, int, int, Tuple[float, float, float, float],
Dict[str, Tuple[float, float]],
Callable[[Dict[str, Tuple[float, float]], float], _RectList],
List[bool]]
] = []
for mask in range(1 << len(keys)):
rotated = [bool(mask & (1 << i)) for i in range(len(keys))]
dims_m = {
k: (
dims0[k][1] if rotated[i] else dims0[k][0],
dims0[k][0] if rotated[i] else dims0[k][1],
)
for i, k in enumerate(keys)
}
for _name, rank, place in arrangements:
s_up = _fit_scale(place, dims_m, _REGION_UPPER)
s_left = _fit_scale(place, dims_m, _REGION_LEFT)
if s_up >= s_left:
s, region = s_up, _REGION_UPPER
else:
s, region = s_left, _REGION_LEFT
if s <= 0.0:
continue
cands.append((s, sum(rotated), rank, region, dims_m, place, rotated))
if cands:
best_s = max(c[0] for c in cands)
s, _nrot, _rank, region, dims_m, place, rotated = min(
(c for c in cands if c[0] >= best_s * 0.995),
key=lambda c: (c[1], c[2], -c[0]),
)
rx0, _ry0, rw, rh = region
ds = {k: (w * s, h * s) for k, (w, h) in dims_m.items()}
rects = place(ds, _VIEW_GAP_MM)
minx = min(r[1] for r in rects)
miny = min(r[2] for r in rects)
maxx = max(r[1] + r[3] for r in rects)
maxy = max(r[2] + r[4] for r in rects)
ox = rx0 + (rw - (maxx - minx)) / 2.0
oy = _ry0 + (rh - (maxy - miny)) / 2.0
for vid, x, y, w, h in rects:
slots[vid] = (x - minx + ox, y - miny + oy, w, h)
rotations[vid] = 90.0 if rotated[keys.index(vid)] else 0.0
common_scale = s
used_rects = [(ox, oy, maxx - minx, maxy - miny)]
# Isometric and custom views: the largest remaining free rect each,
# clearing the title block.
extra = [
v for v in views
if v.kind == "isometric" or v.kind not in _STANDARD_VIEWS
]
assigned = list(used_rects)
for i, v in enumerate(extra):
vid = v.kind if v.kind in _STANDARD_VIEWS else (v.name or v.id)
free = _free_rects(assigned)
if free:
slot = max(free, key=lambda r: r[2] * r[3])
else:
# No free rect left — park in the bottom-left corner stack.
slot = (_LAYOUT_MARGIN_MM, _LAYOUT_MARGIN_MM + i * 60.0, 120.0, 50.0)
slots[vid] = slot
assigned.append(slot)
return slots, common_scale, rotations
+50
View File
@@ -0,0 +1,50 @@
import os, sys
os.environ["QT_QPA_PLATFORM"] = "offscreen"
sys.path.insert(0, "/Volumes/Data_drive/Programming/fluency/src")
from PySide6.QtWidgets import QApplication
from PySide6.QtGui import QPixmap, QPainter, QColor
from PySide6.QtCore import QRectF
import math
app = QApplication.instance() or QApplication([])
from fluency.geometry.base import Point2D
from fluency.geometry_occ.kernel import OCGeometryKernel
from fluency.models.data_model import Body, Component, Project, DrawingView, TechnicalDrawing
from fluency.technical_drawing import generate_drawing, render_drawing, _A3_WIDTH_MM, _A3_HEIGHT_MM
kernel = OCGeometryKernel()
# Long thin bar: 120 x 25 x 30 (matches the "wide bar" screenshot case).
points = [Point2D(0, 0), Point2D(120, 0), Point2D(120, 25), Point2D(0, 25)]
box = kernel.extrude(kernel.create_polygon(points), 30.0)
body = Body(name="Bar", geometry=box)
comp = Component(name="BarComp")
comp.bodies[body.id] = body
project = Project()
project.components[comp.id] = comp
project.active_component = comp.id
W = 2400
H = int(W * _A3_HEIGHT_MM / _A3_WIDTH_MM)
pm = QPixmap(W, H)
pm.fill(QColor(255, 255, 255))
for name, kinds in [
("four", ["front", "top", "right", "back"]),
("six", ["front", "top", "right", "left", "back", "bottom"]),
("sixiso", ["front", "top", "right", "left", "back", "bottom", "isometric"]),
]:
drawing = TechnicalDrawing(
source_kind="component", source_id=comp.id,
views=[DrawingView(kind=k) for k in kinds],
auto_dimensions=True, title=name,
)
result = generate_drawing(drawing, project, kernel)
p = QPainter(pm)
render_drawing(p, result, QRectF(0, 0, W, H))
p.end()
out = f"/tmp/drawing_{name}.png"
pm.save(out)
print(name, "saved", out, "prims", len(result.primitives), "scale",
round(result.view_transforms.get("front", (None,))[0] or 0, 3))
+159
View File
@@ -0,0 +1,159 @@
"""Smoke test: layout optimizer fills the page, no overlaps, title block clear."""
import math
import os
import sys
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
sys.path.insert(0, "/Volumes/Data_drive/Programming/fluency/src")
from fluency.models.data_model import DrawingView
from fluency.technical_drawing import (
_layout_views_on_sheet,
build_manual_candidates,
)
A3W, A3H = 420.0, 297.0
TB = (235.0, 0.0, 420.0, 62.0) # title block zone incl. clearance
def b(x, y, w, h):
return (x, y, x + w, y + h)
def overlaps(r1, r2, clear=0.0):
x0, y0, w, h = r1
x1, y1, w2, h2 = r2
return not (x0 + w <= x1 + clear or x1 + w2 <= x0 + clear
or y0 + h <= y1 + clear or y1 + h2 <= y0 + clear)
def tb_overlap(r, clear=5.0):
x0, y0, w, h = r
x1, y1, w2, h2 = TB
return not (x0 + w <= x1 + clear or x1 + w2 <= x0 + clear
or y0 + h <= y1 + clear or y1 + h2 <= y0 + clear)
def union_rect(rects):
x0 = min(r[0] for r in rects)
y0 = min(r[1] for r in rects)
x1 = max(r[0] + r[2] for r in rects)
y1 = max(r[1] + r[3] for r in rects)
return (x0, y0, x1 - x0, y1 - y0)
def check(name, kinds, boxes, expect_rot=None):
views = [DrawingView(kind=k) for k in kinds]
bboxes = {k: boxes[k] for k in kinds}
slots, scale, rots = _layout_views_on_sheet(views, bboxes)
print(f"--- {name}: scale={scale:.4f} rots={rots}")
# all slots within sheet
for k, s in slots.items():
assert 10 - 1e-6 <= s[0] and 10 - 1e-6 <= s[1], f"{k} outside sheet {s}"
assert s[0] + s[2] <= A3W - 10 + 1e-6, f"{k} beyond right {s}"
assert s[1] + s[3] <= A3H - 10 + 1e-6, f"{k} beyond top {s}"
# no overlaps between slots
ks = list(slots)
for i in range(len(ks)):
for j in range(i + 1, len(ks)):
assert not overlaps(slots[ks[i]], slots[ks[j]], 11.9), \
f"{ks[i]} overlaps {ks[j]}: {slots[ks[i]]} / {slots[ks[j]]}"
# title block clear
for k, s in slots.items():
assert not tb_overlap(s), f"{k} intrudes title block {s}"
# fill report
u = union_rect(list(slots.values()))
area = u[2] * u[3]
print(f" union: x0={u[0]:.1f} y0={u[1]:.1f} w={u[2]:.1f} h={u[3]:.1f} "
f"area={area:.0f}mm^2 ({100*area/(A3W*A3H):.0f}% of sheet)")
for k in ks:
print(f" {k}: {tuple(round(v,1) for v in slots[k])}")
if expect_rot is not None:
assert rots == expect_rot, f"expected {expect_rot}, got {rots}"
return slots, scale, rots
# 1. Two square views (front+top): should fill the page, no rotation.
check("two square", ["front", "top"],
{"front": b(0, 0, 40, 40), "top": b(0, 0, 40, 20)})
# 2. Wide bar, 4 views (old screenshot case): front+back wide, top+right.
check("wide bar 4", ["front", "top", "right", "back"],
{"front": b(0, 0, 120, 25), "top": b(0, 0, 25, 40),
"right": b(0, 0, 25, 40), "back": b(0, 0, 120, 25)})
# 3. Six views of a long thin part: rotation should kick in.
check("thin part 6", ["front", "top", "right", "left", "back", "bottom"],
{"front": b(0, 0, 200, 30), "top": b(0, 0, 30, 50),
"right": b(0, 0, 50, 30), "left": b(0, 0, 50, 30),
"back": b(0, 0, 200, 30), "bottom": b(0, 0, 30, 50)})
# 4. Single front view: fills the whole page.
check("single", ["front"], {"front": b(0, 0, 10, 20)})
# 5. All 6 + isometric + custom.
check("everything",
["front", "top", "right", "left", "back", "bottom", "isometric"],
{"front": b(0, 0, 80, 40), "top": b(0, 0, 80, 30),
"right": b(0, 0, 30, 40), "left": b(0, 0, 30, 40),
"back": b(0, 0, 80, 40), "bottom": b(0, 0, 80, 30),
"isometric": b(0, 0, 60, 60)})
# ── Inverse-transform roundtrip ─────────────────────────────────────────
# A 90°-rotated view: forward via _assemble_view's recorded 6-tuple,
# inverse via the widget's formula.
t = (2.0, 150.0, 80.0, 90.0, 10.0, 5.0) # s, o_x, o_y, deg, cx, cy
scale, ox, oy, deg, cx, cy = t
th = math.radians(deg)
cos_t, sin_t = math.cos(th), math.sin(th)
def fwd(p):
dx, dy = p[0] - cx, p[1] - cy
return ((dx * cos_t - dy * sin_t) * scale + ox,
(dx * sin_t + dy * cos_t) * scale + oy)
def inv(p):
sx, sy = (p[0] - ox) / scale, (p[1] - oy) / scale
return (sx * cos_t + sy * sin_t + cx, -sx * sin_t + sy * cos_t + cy)
for p in [(0, 0), (10, 5), (3, -7), (42.5, 11.25)]:
rt = inv(fwd(p))
assert abs(rt[0] - p[0]) < 1e-9 and abs(rt[1] - p[1]) < 1e-9, (p, rt)
print("inverse roundtrip OK")
# Legacy 3-tuple still works through build_manual_candidates.
from fluency.models.data_model import DrawingAnnotation, TechnicalDrawing
d = TechnicalDrawing(source_kind="component", source_id="c")
ann = DrawingAnnotation(kind="dimension", dimension_kind="length",
view_id="front", anchors=[(0.0, 0.0), (0.0, 12.5)],
direction=(0.0, 1.0))
d.annotations.append(ann)
cands, res, unres = build_manual_candidates(d, {"front": (2.0, 10.0, 20.0)})
assert unres == [] and cands[0].anchor_points[1] == (10.0, 45.0)
print("legacy 3-tuple OK")
# 6-tuple manual: rotated length direction must rotate too.
ann2 = DrawingAnnotation(kind="dimension", dimension_kind="length",
view_id="front",
anchors=[(0.0, 0.0), (0.0, 10.0)],
direction=(0.0, 1.0))
d2 = TechnicalDrawing(source_kind="component", source_id="c")
d2.annotations.append(ann2)
# 90° rotation about centre c=(5,5), scale 2, o=(100,80)
cands, res, unres = build_manual_candidates(
d2, {"front": (2.0, 100.0, 80.0, 90.0, 5.0, 5.0)}
)
c = cands[0]
# anchors: (0,0)->rot90 about (5,5) = (5-(0-5)*0 - ... compute: dx=-5,dy=-5
# fwd: (dx*cos - dy*sin)*2+100 = (0 - (-5))*2+100 = 110 ; (dx*sin+dy*cos)*2+80 = (-5)*2+80=70
# (0,10): dx=-5, dy=5 -> (0-5)*2+100=90 ; (5*1+0)*2+80=70
assert c.anchor_points[0] == (110.0, 70.0), c.anchor_points
assert c.anchor_points[1] == (90.0, 70.0), c.anchor_points
# direction (0,1) rotated 90° CCW -> (-1, 0)
assert c.direction[0] == -1.0 and abs(c.direction[1]) < 1e-9, c.direction
print("6-tuple manual (rotated) OK")
print("ALL SMOKE CHECKS PASSED")
+75 -18
View File
@@ -16,8 +16,35 @@ from fluency.geometry.base import (
Point3D,
)
logger = logging.getLogger(__name__)
def _curve_is_linear(occ_edge: Any) -> bool:
"""Return True if *occ_edge* has a linear or chamferable curve type.
``BRepFilletAPI_MakeChamfer`` and ``BRepFilletAPI_MakeFillet`` crash
(segfault) on circular/elliptical curves. We pre-filter those out.
"""
from OCP.BRepAdaptor import BRepAdaptor_Curve
from OCP.GeomAbs import GeomAbs_CurveType
try:
ad = BRepAdaptor_Curve(occ_edge)
ct = ad.GetType()
except Exception:
# If we can't classify, assume it's safe (will be caught later).
return True
# Chamfer/fillet only support linear curves reliably.
return ct in (
GeomAbs_CurveType.GeomAbs_Line,
GeomAbs_CurveType.GeomAbs_BSplineCurve,
GeomAbs_CurveType.GeomAbs_BezierCurve,
GeomAbs_CurveType.GeomAbs_OffsetCurve,
GeomAbs_CurveType.GeomAbs_Parabola,
GeomAbs_CurveType.GeomAbs_Hyperbola,
)
class OCCGeometryObject(GeometryObject):
"""Geometry object wrapper for OpenCASCADE shapes."""
@@ -446,52 +473,82 @@ class OCGeometryKernel(GeometryKernel):
def fillet(
self, body: GeometryObject, radius: float, edges: Optional[List[Any]] = None
) -> GeometryObject:
"""Apply fillet to edges."""
shape = self._get_shape(body)
"""Apply fillet to edges. Skips edges that cannot be filleted."""
from OCP.BRepFilletAPI import BRepFilletAPI_MakeFillet
fillet = BRepFilletAPI_MakeFillet(shape)
shape: Any = self._get_shape(body)
if shape is None:
return OCCGeometryObject(None, {"type": "fillet"})
if edges:
for edge in edges:
fillet.Add(radius, edge)
# Collect candidate edges
if edges is not None:
candidates = list(edges)
else:
from OCP.TopExp import TopExp_Explorer
from OCP.TopAbs import TopAbs_EDGE
from OCP.TopoDS import TopoDS
explorer = TopExp_Explorer(shape, TopAbs_EDGE)
candidates = []
while explorer.More():
fillet.Add(radius, TopoDS.Edge_s(explorer.Current()))
e = TopoDS.Edge_s(explorer.Current())
if _curve_is_linear(e):
candidates.append(e)
explorer.Next()
fillet.Build()
return OCCGeometryObject(fillet.Shape(), {"type": "fillet"})
# Add all edges, then Build once — avoids OCC internal crashes
fl = BRepFilletAPI_MakeFillet(shape)
for edge in candidates:
try:
fl.Add(radius, edge)
except Exception:
pass # skip edges that fail to add
fl.Build()
if fl.IsDone():
return OCCGeometryObject(fl.Shape(), {"type": "fillet"})
# If Build failed, return original shape (no fillet applied)
return OCCGeometryObject(shape, {"type": "fillet"})
def chamfer(
self, body: GeometryObject, size: float, edges: Optional[List[Any]] = None
) -> GeometryObject:
"""Apply chamfer to edges."""
shape = self._get_shape(body)
"""Apply chamfer to edges. Skips edges that cannot be chamfered."""
from OCP.BRepFilletAPI import BRepFilletAPI_MakeChamfer
chamfer = BRepFilletAPI_MakeChamfer(shape)
shape: Any = self._get_shape(body)
if shape is None:
return OCCGeometryObject(None, {"type": "chamfer"})
if edges:
for edge in edges:
chamfer.Add(size, edge)
# Collect candidate edges
if edges is not None:
candidates = list(edges)
else:
from OCP.TopExp import TopExp_Explorer
from OCP.TopAbs import TopAbs_EDGE
from OCP.TopoDS import TopoDS
explorer = TopExp_Explorer(shape, TopAbs_EDGE)
candidates = []
while explorer.More():
chamfer.Add(size, TopoDS.Edge_s(explorer.Current()))
e = TopoDS.Edge_s(explorer.Current())
if _curve_is_linear(e):
candidates.append(e)
explorer.Next()
chamfer.Build()
return OCCGeometryObject(chamfer.Shape(), {"type": "chamfer"})
# Add all edges, then Build once — avoids OCC internal crashes
mc = BRepFilletAPI_MakeChamfer(shape)
for edge in candidates:
try:
mc.Add(size, edge)
except Exception:
pass # skip edges that fail to add
mc.Build()
if mc.IsDone():
return OCCGeometryObject(mc.Shape(), {"type": "chamfer"})
# If Build failed, return original shape (no chamfer applied)
return OCCGeometryObject(shape, {"type": "chamfer"})
def shell(
self, body: GeometryObject, thickness: float, faces_to_remove: Optional[List[Any]] = None
+21 -9
View File
@@ -150,7 +150,8 @@ def build_source_parts(
) -> Tuple[Tuple[DrawingSourcePart, ...], Tuple[str, ...]]:
"""Collect visible solid bodies as source parts for projection.
Returns ``(parts, warnings)``.
Returns ``(parts, warnings)``. For assemblies all bodies are fused
into a single shape so the drawing treats the assembly as one part.
"""
warnings: List[str] = []
parts: List[DrawingSourcePart] = []
@@ -182,6 +183,8 @@ def build_source_parts(
asm = project.assemblies.get(source_id)
if asm is None:
return (), (f"Assembly {source_id} not found",)
# Collect all transformed shapes, then fuse into a single part.
shapes: List[Any] = []
for ac_id, ac in sorted(asm.components.items()):
comp = project.get_component_by_id(ac.component_id)
if comp is None:
@@ -194,20 +197,29 @@ def build_source_parts(
if shape is None:
warnings.append(f"Body {body.name} ({bid}) has no extractable shape")
continue
transformed = _apply_ocp_transform(shape, ac.position, ac.rotation)
shapes.append(_apply_ocp_transform(shape, ac.position, ac.rotation))
if shapes:
# Fuse all shapes into one solid.
fused = shapes[0]
for s in shapes[1:]:
from OCP.BRepAlgoAPI import BRepAlgoAPI_Fuse
fuse_op = BRepAlgoAPI_Fuse(fused, s)
fuse_op.Build()
if fuse_op.IsDone():
fused = fuse_op.Shape()
parts.append(
DrawingSourcePart(
part_id=f"{ac_id}/{bid}",
display_name=f"{comp.name}:{body.name}",
shape=transformed,
color=body.color,
component_id=ac.component_id,
assembly_instance_id=ac_id,
part_id=source_id,
display_name=asm.name,
shape=fused,
color=(0.5, 0.5, 0.5),
component_id=source_id,
)
)
if not parts:
warnings.append("Assembly has no visible solid geometry")
else:
return (), (f"Unknown source kind: {source_kind}",)
+76 -6
View File
@@ -1751,6 +1751,7 @@ class MainWindow(QMainWindow):
self._assembly_component_buttons: List[QPushButton] = []
self._assembly_component_group: Optional[QButtonGroup] = None
self._assembly_view_active: bool = False
self._render_mode: str = "component"
self._selected_assembly_component_id: Optional[str] = None
# Connector two-click state
@@ -2549,6 +2550,7 @@ class MainWindow(QMainWindow):
if idx < len(comp_ids):
self._current_component = self._project.components[comp_ids[idx]]
self._assembly_view_active = False
self._render_mode = "component"
self._refresh_lists()
self._redraw_bodies()
# Propagate the new selection to the drawing tab.
@@ -2557,6 +2559,8 @@ class MainWindow(QMainWindow):
self._drawing_tab.set_active_component(self._current_component)
except Exception as e:
logger.warning(f"Failed to update drawing tab source: {e}")
# Re-load the render tab to reflect the new selection.
self._load_render_tab_shape()
# Scroll to the selected button.
if 0 <= idx < len(self._component_buttons):
_scroll_to_button(self._component_buttons[idx], self._component_scroll)
@@ -3457,9 +3461,13 @@ class MainWindow(QMainWindow):
self._selected_assembly_component_id = active_id
self._assembly_view_active = True
self._render_mode = "assembly"
self._show_assembly_in_viewer(fit=True)
# Re-load the render tab to show the full assembly.
self._load_render_tab_shape()
# Scroll to the selected button.
for btn in self._assembly_component_buttons:
if getattr(btn, "_assembly_component_id", None) == active_id:
@@ -7299,6 +7307,7 @@ class MainWindow(QMainWindow):
self._selected_body = None
self._selected_assembly_component_id = None
self._assembly_view_active = False
self._render_mode = "component"
for btn in self._component_buttons:
btn.deleteLater()
@@ -7553,6 +7562,7 @@ class MainWindow(QMainWindow):
self._body_highlight_id = None
self._body_highlight_original_color = None
self._assembly_view_active = False
self._render_mode = "component"
self._sketch_widget.clear_source_face()
self._sketch_widget.set_sketch(None)
self._viewer_3d.clear_scene()
@@ -7652,6 +7662,7 @@ class MainWindow(QMainWindow):
self._show_assembly_in_viewer(fit=True)
else:
self._assembly_view_active = False
self._render_mode = "component"
self._redraw_bodies()
# Restore camera + active tab.
@@ -7801,11 +7812,38 @@ class MainWindow(QMainWindow):
def _open_render_window(self):
"""Populate the render tab with the selected body or assembly and switch to it."""
# Collect all visible bodies across all components
# Determine which bodies to render based on selection state.
assembly_parts = [] # list of (TopoDS_Shape, Optional[str])
single_shape = None
for comp in self._project.components.values():
if self._assembly_view_active:
# Assembly view: render the full assembly (all component instances).
assembly = self._get_assembly()
if assembly:
for ac in assembly.components.values():
comp = self._project.get_component_by_id(ac.component_id)
if comp:
for body in comp.bodies.values():
if not body.visible or not body.geometry:
continue
try:
occ_shape = self._kernel._get_shape(body.geometry)
assembly_parts.append((occ_shape, body.render_material))
except Exception as e:
logger.warning(f"Failed to get shape for render: {e}")
elif self._current_component:
# Single component selected via button.
comp = self._current_component
for body in comp.bodies.values():
if not body.visible or not body.geometry:
continue
try:
occ_shape = self._kernel._get_shape(body.geometry)
assembly_parts.append((occ_shape, body.render_material))
except Exception as e:
logger.warning(f"Failed to get shape for render: {e}")
else:
# Fallback: use the project's active component.
comp = self._project.get_active_component()
if comp:
for body in comp.bodies.values():
if not body.visible or not body.geometry:
continue
@@ -7851,9 +7889,38 @@ class MainWindow(QMainWindow):
def _load_render_tab_shape(self) -> None:
"""Auto-load the selected body or assembly component into the render tab."""
# Collect all visible bodies across all components
# Determine which bodies to render based on selection state.
assembly_parts = []
for comp in self._project.components.values():
if self._render_mode == "assembly":
# Assembly view: render the full assembly (all component instances).
assembly = self._get_assembly()
if assembly:
for ac in assembly.components.values():
comp = self._project.get_component_by_id(ac.component_id)
if comp:
for body in comp.bodies.values():
if not body.visible or not body.geometry:
continue
try:
occ_shape = self._kernel._get_shape(body.geometry)
assembly_parts.append((occ_shape, body.render_material))
except Exception as e:
logger.warning(f"Failed to get shape for render: {e}")
elif self._current_component:
# Single component selected via button.
comp = self._current_component
for body in comp.bodies.values():
if not body.visible or not body.geometry:
continue
try:
occ_shape = self._kernel._get_shape(body.geometry)
assembly_parts.append((occ_shape, body.render_material))
except Exception as e:
logger.warning(f"Failed to get shape for render: {e}")
else:
# Fallback: use the project's active component.
comp = self._project.get_active_component()
if comp:
for body in comp.bodies.values():
if not body.visible or not body.geometry:
continue
@@ -7864,6 +7931,9 @@ class MainWindow(QMainWindow):
logger.warning(f"Failed to get shape for render: {e}")
if not assembly_parts:
# No drawable geometry in the selection — keep the render tab in
# sync with the (empty) model view instead of showing a stale image.
self._render_tab.clear()
return
# Capture the viewport camera
+189 -9
View File
@@ -602,8 +602,6 @@ class RenderWindow(QMainWindow):
"""Reset camera parameters to match the 3D viewport."""
if self._camera is None:
return
o = self._camera.origin
t = self._camera.target
u = self._camera.up
self._cam_origin_x.setValue(o[0])
self._cam_origin_y.setValue(o[1])
@@ -683,8 +681,6 @@ class RenderWindow(QMainWindow):
"""Fill camera spinboxes from the current RenderCamera."""
if self._camera is None:
return
o = self._camera.origin
t = self._camera.target
u = self._camera.up
self._cam_origin_x.setValue(o[0])
self._cam_origin_y.setValue(o[1])
@@ -1009,6 +1005,9 @@ class RenderTabContent(QWidget):
self._backend = None
self._mesh_path: Optional[str] = None
# Framing: percentage of screen the part should occupy (10-100).
self._framing_percentage: float = 80.0
# Assembly support: list of (mesh_path, RenderMaterial)
self._assembly_parts: list = []
@@ -1020,6 +1019,10 @@ class RenderTabContent(QWidget):
self._camera: Optional[RenderCamera] = None
self._ground_color: tuple[float, float, float] = (0.5, 0.5, 0.5)
self._active_mode: Optional[str] = None
self._framing_slider: QSlider | None = None
self._framing_label: QLabel | None = None
# Combined bbox for assembly rendering (used by _compute_framed_origin)
self._assembly_bounds: Optional[tuple] = None
self._auto_preview_timer: Optional[QTimer] = None
self._init_ui()
@@ -1034,9 +1037,18 @@ class RenderTabContent(QWidget):
*camera* if provided, overrides the stored camera. Pass the
viewport\'s render camera to match the 3D view framing.
"""
# Cancel any in-progress render so the new shape gets a fresh preview.
self._cancel_active_thread()
# Drop any previously loaded assembly state so the single-shape
# render path is used (prevents re-rendering a stale assembly).
self._assembly_parts = []
self._assembly_bounds = None
# Reset the mesh path so a failed tessellation below cannot
# trigger an auto-preview of the previous shape's mesh.
self._mesh_path = None
self._shape = shape
if camera is not None:
self._camera = camera
self._camera = self._apply_framing(camera)
self._last_image = None
self._last_preview = None
self._image_label.setPixmap(QPixmap())
@@ -1062,18 +1074,23 @@ class RenderTabContent(QWidget):
*parts* is a list of ``(TopoDS_Shape, Optional[str])`` tuples
where the second element is an optional material preset name.
"""
# Cancel any in-progress render so the new assembly gets a fresh preview.
self._cancel_active_thread()
self._shape = None
self._mesh_path = None
self._assembly_parts = []
self._assembly_bounds = None
# Tessellate and compute combined bounds first so the framing below
# is based on this assembly, not a stale one.
self._prepare_assembly_mesh(parts)
if camera is not None:
self._camera = camera
self._camera = self._apply_framing(camera)
self._last_image = None
self._last_preview = None
self._image_label.setPixmap(QPixmap())
self._image_label.setText("Click Preview or Render to start")
self._status_badge.setText("")
self._export_btn.setEnabled(False)
self._prepare_assembly_mesh(parts)
self._populate_camera_controls()
self._schedule_auto_preview()
@@ -1086,7 +1103,7 @@ class RenderTabContent(QWidget):
"""
if camera is None:
return
self._camera = camera
self._camera = self._apply_framing(camera)
self._cam_fov_spin.blockSignals(True)
try:
self._cam_fov_spin.setValue(camera.fov)
@@ -1100,6 +1117,20 @@ class RenderTabContent(QWidget):
# For full renders or idle: schedule a preview if auto-preview is on.
self._schedule_auto_preview()
def clear(self) -> None:
"""Remove any loaded shape/assembly and reset the display."""
self._cancel_active_thread()
self._shape = None
self._mesh_path = None
self._assembly_parts = []
self._assembly_bounds = None
self._last_image = None
self._last_preview = None
self._image_label.setPixmap(QPixmap())
self._image_label.setText("Click Preview or Render to start")
self._status_badge.setText("")
self._export_btn.setEnabled(False)
def cleanup(self) -> None:
"""Stop threads and delete temp files. Call when the tab is hidden/closed."""
if self._auto_preview_timer and self._auto_preview_timer.isActive():
@@ -1248,6 +1279,23 @@ class RenderTabContent(QWidget):
layout.addWidget(camera_gb)
# ── Framing ───────────────────────────────────────────────
framing_gb = QGroupBox("Framing")
framing_layout = QVBoxLayout(framing_gb)
framing_layout.setSpacing(4)
self._framing_slider = QSlider(Qt.Horizontal)
self._framing_slider.setRange(10, 100)
self._framing_slider.setValue(80)
self._framing_slider.valueChanged.connect(self._on_framing_changed)
framing_layout.addWidget(self._framing_slider)
self._framing_label = QLabel("80 %")
self._framing_label.setAlignment(Qt.AlignCenter)
framing_layout.addWidget(self._framing_label)
layout.addWidget(framing_gb)
# ── Lighting ────────────────────────────────────────────────
light_gb = QGroupBox("Lighting")
light_layout = QVBoxLayout(light_gb)
@@ -1477,15 +1525,27 @@ class RenderTabContent(QWidget):
self._assembly_parts = []
first_bounds = None
all_mins: list[float] = []
all_maxs: list[float] = []
for shape, mat_name in parts:
try:
mesh_path = occ_shape_to_ply(shape, linear_deflection=0.1, angular_deflection=0.15)
material = get_preset(mat_name) if mat_name else get_preset("Brushed Steel")
self._assembly_parts.append((mesh_path, material))
bounds = occ_shape_bounds(shape)
all_mins.append(list(bounds[0]))
all_maxs.append(list(bounds[1]))
if first_bounds is None:
first_bounds = occ_shape_bounds(shape)
first_bounds = bounds
except Exception as e:
logger.warning(f"Failed to tessellate assembly part: {e}")
# Compute combined bounding box from all parts.
if all_mins and all_maxs:
combined_min = [min(a[i] for a in all_mins) for i in range(3)]
combined_max = [max(a[i] for a in all_maxs) for i in range(3)]
self._assembly_bounds = (combined_min, combined_max)
else:
self._assembly_bounds = None
if first_bounds and self._camera is None:
mn, mx = first_bounds
self._camera = self._backend.default_camera_from_bounds(mn, mx)
@@ -1503,6 +1563,126 @@ class RenderTabContent(QWidget):
return
self._cam_fov_spin.setValue(self._camera.fov)
# ── Framing ──────────────────────────────────────────────────
def _on_framing_changed(self, value: int) -> None:
"""Slider moved — update label and re-frame if we have a camera."""
self._framing_percentage = float(value)
self._framing_label.setText(f"{value} %")
# Re-apply framing with current direction
if self._camera is not None:
self._camera = self._apply_framing(self._camera)
# Sync the FOV spinbox to match (important for preview consistency)
self._cam_fov_spin.blockSignals(True)
try:
self._cam_fov_spin.setValue(self._camera.fov)
finally:
self._cam_fov_spin.blockSignals(False)
self._schedule_auto_preview()
def _apply_framing(self, camera: RenderCamera) -> RenderCamera:
"""Apply framing to a camera, returning a new one with adjusted origin.
Keeps the direction and target from *camera*, adjusts distance
so the part fills _framing_percentage of the screen.
"""
eye_dir = np.array(camera.origin) - np.array(camera.target)
diag = float(np.linalg.norm(eye_dir))
if diag > 1e-9:
eye_dir /= diag
target = camera.target
new_origin = self._compute_framed_origin(
eye_dir, target, camera.fov,
)
return RenderCamera(
origin=tuple(new_origin),
target=target,
up=camera.up,
fov=camera.fov,
)
return camera
def _compute_framed_origin(
self, eye_dir: np.ndarray, target: tuple[float, float, float], fov: float
) -> np.ndarray:
"""Compute camera origin so the part fills _framing_percentage of screen.
The viewing direction (eye_dir) and target define the line of sight.
The bounding box is projected onto the view plane and the distance
is chosen so that its larger projected dimension occupies exactly
``_framing_percentage`` of the corresponding screen axis.
"""
# Get bounding box — from assembly or single shape.
if self._assembly_bounds is not None:
mn, mx = self._assembly_bounds
elif self._shape is not None:
mn, mx = occ_shape_bounds(self._shape)
else:
# Fallback: place camera at a large but safe distance.
return np.array(target, dtype=float) + eye_dir * 1000.0
mn_arr = np.asarray(mn, dtype=float)
mx_arr = np.asarray(mx, dtype=float)
diag = float(np.linalg.norm(mx_arr - mn_arr))
# View-plane basis vectors.
up_world = np.array([0.0, 0.0, 1.0], dtype=float)
right = np.cross(up_world, eye_dir)
right_norm = float(np.linalg.norm(right))
if right_norm < 1e-9:
# Eye dir is parallel to world up — pick arbitrary right.
right = np.array([1.0, 0.0, 0.0], dtype=float)
else:
right /= right_norm
screen_up = np.cross(eye_dir, right)
# Project bbox axes onto view plane.
dx = mx_arr[0] - mn_arr[0]
dy = mx_arr[1] - mn_arr[1]
dz = mx_arr[2] - mn_arr[2]
# Project all 8 bbox corners onto the view plane
# to get the actual bounding-box extent.
half_x = dx / 2.0
half_y = dy / 2.0
half_z = dz / 2.0
# Corner offsets from centre in local axes.
corner_offsets = [
(sx * half_x, sy * half_y, sz * half_z)
for sx in (-1, 1) for sy in (-1, 1) for sz in (-1, 1)
]
# Project each corner onto view-plane basis vectors.
proj_right_vals = [
ox * right[0] + oy * right[1] + oz * right[2]
for ox, oy, oz in corner_offsets
]
proj_up_vals = [
ox * screen_up[0] + oy * screen_up[1] + oz * screen_up[2]
for ox, oy, oz in corner_offsets
]
proj_right = max(proj_right_vals) - min(proj_right_vals)
proj_up = max(proj_up_vals) - min(proj_up_vals)
half_fov_rad = np.radians(fov / 2.0)
tan_half_fov = float(np.tan(half_fov_rad))
if tan_half_fov < 1e-9:
return np.array(target, dtype=float) + eye_dir * 1000.0
# Distance: D = full_extent / (2 * pct * tan(fov/2))
pct = self._framing_percentage / 100.0
dist_x = proj_right / (2.0 * pct * tan_half_fov) if proj_right > 0 else float("inf")
dist_y = proj_up / (2.0 * pct * tan_half_fov) if proj_up > 0 else float("inf")
dist = min(dist_x, dist_y)
# Minimum distance to avoid camera inside the object.
if dist < diag * 0.1:
dist = diag * 0.1
return np.array(target, dtype=float) + eye_dir * dist
def _schedule_auto_preview(self):
if not self._auto_preview_cb.isChecked():
return
@@ -421,6 +421,15 @@ class TechnicalDrawingWidget(QWidget):
self._active_source_id = component.id
self._on_generate()
def set_active_assembly(self, assembly) -> None:
"""Use the given assembly as the drawing source and regenerate.
The assembly is treated as a single fused part (all bodies merged).
"""
self._active_source_kind = "assembly"
self._active_source_id = assembly.id
self._on_generate()
def generate(self) -> None:
"""Public entry point: generate for the current source."""
self._on_generate()
+916
View File
@@ -0,0 +1,916 @@
"""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)