- tech drawing and render improv
This commit is contained in:
Generated
+12
-9
@@ -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
@@ -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
|
||||
@@ -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))
|
||||
@@ -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")
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
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,
|
||||
)
|
||||
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=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}",)
|
||||
|
||||
|
||||
@@ -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,26 @@ 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
|
||||
@@ -7814,6 +7840,18 @@ class MainWindow(QMainWindow):
|
||||
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
|
||||
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}")
|
||||
|
||||
if not assembly_parts:
|
||||
QMessageBox.information(
|
||||
@@ -7851,9 +7889,26 @@ 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
|
||||
@@ -7862,8 +7917,23 @@ class MainWindow(QMainWindow):
|
||||
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
|
||||
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}")
|
||||
|
||||
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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
# 1–2 views stay upright — no sideways single view.
|
||||
assert rots == {"front": 0.0}
|
||||
# The tall 10 × 20 view is scaled until it touches the full-height
|
||||
# left strip's reduced height (0.8 × 277 mm).
|
||||
assert scale == pytest.approx(221.6 / 20.0)
|
||||
assert slots["front"][3] == pytest.approx(221.6)
|
||||
|
||||
def test_two_views_share_page(self):
|
||||
slots, _scale, _rots = self._layout(
|
||||
["front", "top"],
|
||||
{"front": self._bbox(40, 40), "top": self._bbox(40, 20)},
|
||||
)
|
||||
self._assert_valid(slots)
|
||||
assert self._fill(slots) > 0.30
|
||||
|
||||
def test_classic_three_view_keeps_cross(self):
|
||||
boxes = {k: self._bbox(40, 40) for k in ("front", "top", "right")}
|
||||
slots, _scale, rots = self._layout(list(boxes), boxes)
|
||||
self._assert_valid(slots)
|
||||
assert set(rots.values()) == {0.0}, "classic cross must not rotate"
|
||||
# Top sits directly above front; right directly to its right.
|
||||
assert abs(slots["top"][0] - slots["front"][0]) < 1e-6
|
||||
assert slots["top"][1] > slots["front"][1] + slots["front"][3]
|
||||
assert slots["right"][0] > slots["front"][0] + slots["front"][2]
|
||||
assert abs(slots["right"][1] - slots["front"][1]) < 1e-6
|
||||
|
||||
def test_thin_part_gets_rotated_views(self):
|
||||
boxes = {
|
||||
"front": self._bbox(200, 30),
|
||||
"top": self._bbox(30, 50),
|
||||
"right": self._bbox(50, 30),
|
||||
"left": self._bbox(50, 30),
|
||||
"back": self._bbox(200, 30),
|
||||
"bottom": self._bbox(30, 50),
|
||||
}
|
||||
slots, scale, rots = self._layout(list(boxes), boxes)
|
||||
self._assert_valid(slots)
|
||||
assert any(r == 90.0 for r in rots.values()), "rotation must kick in"
|
||||
# Each slot is the (possibly swapped) model size times the scale.
|
||||
for k, s in slots.items():
|
||||
w, h = boxes[k][2], boxes[k][3]
|
||||
sw, sh = s[2] / scale, s[3] / scale
|
||||
assert (
|
||||
(sw == pytest.approx(w) and sh == pytest.approx(h))
|
||||
or (sw == pytest.approx(h) and sh == pytest.approx(w))
|
||||
), (k, s, w, h)
|
||||
|
||||
def test_all_views_plus_isometric_fill_page(self):
|
||||
boxes = {
|
||||
"front": self._bbox(80, 40),
|
||||
"top": self._bbox(80, 30),
|
||||
"right": self._bbox(30, 40),
|
||||
"left": self._bbox(30, 40),
|
||||
"back": self._bbox(80, 40),
|
||||
"bottom": self._bbox(80, 30),
|
||||
"isometric": self._bbox(60, 60),
|
||||
}
|
||||
slots, _scale, _rots = self._layout(list(boxes), boxes)
|
||||
self._assert_valid(slots)
|
||||
assert self._fill(slots) > 0.55
|
||||
|
||||
|
||||
# ── Project drawing persistence ────────────────────────────────────────────
|
||||
|
||||
|
||||
def _drawing_with_manual_dim():
|
||||
drawing = TechnicalDrawing(
|
||||
source_kind="component",
|
||||
source_id="comp-42",
|
||||
views=[DrawingView(kind="front"), DrawingView(kind="top")],
|
||||
auto_dimensions=True,
|
||||
title="Persisted Drawing",
|
||||
revision="B",
|
||||
)
|
||||
drawing.annotations.append(
|
||||
_manual_annotation(
|
||||
"length", "front", ((0.0, 0.0), (0.0, 12.5)), direction=(0.0, 1.0)
|
||||
)
|
||||
)
|
||||
drawing.annotations.append(
|
||||
_manual_annotation("diameter", "top", ((-5.0, 0.0), (5.0, 0.0)))
|
||||
)
|
||||
return drawing
|
||||
|
||||
|
||||
class TestDrawingPersistence:
|
||||
def test_drawing_dict_roundtrip(self):
|
||||
drawing = _drawing_with_manual_dim()
|
||||
data = _technical_drawing_to_dict(drawing)
|
||||
restored = _technical_drawing_from_dict(json.loads(json.dumps(data)))
|
||||
|
||||
assert restored.id == drawing.id
|
||||
assert restored.source_kind == "component"
|
||||
assert restored.source_id == "comp-42"
|
||||
assert restored.auto_dimensions is True
|
||||
assert [v.kind for v in restored.views] == ["front", "top"]
|
||||
assert len(restored.annotations) == 2
|
||||
|
||||
a = restored.annotations[0]
|
||||
assert a.dimension_kind == "length"
|
||||
assert a.view_id == "front"
|
||||
assert a.anchors == [(0.0, 0.0), (0.0, 12.5)]
|
||||
assert a.direction == (0.0, 1.0)
|
||||
|
||||
b = restored.annotations[1]
|
||||
assert b.dimension_kind == "diameter"
|
||||
assert b.anchors == [(-5.0, 0.0), (5.0, 0.0)]
|
||||
|
||||
def test_project_drawings_lookup(self):
|
||||
project = Project()
|
||||
drawing = _drawing_with_manual_dim()
|
||||
project.add_drawing(drawing)
|
||||
|
||||
assert project.get_drawing_for("component", "comp-42") is drawing
|
||||
assert project.get_drawing_for("assembly", "comp-42") is None
|
||||
assert project.get_drawing_for("component", "other") is None
|
||||
|
||||
def test_project_save_load_roundtrip(self, tmp_path):
|
||||
project = Project(name="Drawing Project")
|
||||
drawing = _drawing_with_manual_dim()
|
||||
project.add_drawing(drawing)
|
||||
|
||||
path = save_project(project, str(tmp_path / "proj.fluency"))
|
||||
loaded, _view_state = load_project(path)
|
||||
|
||||
assert len(loaded.drawings) == 1
|
||||
restored = loaded.drawings[0]
|
||||
assert restored.source_id == "comp-42"
|
||||
assert restored.auto_dimensions is True
|
||||
assert restored.title == "Persisted Drawing"
|
||||
assert len(restored.annotations) == 2
|
||||
|
||||
a = restored.annotations[0]
|
||||
assert a.dimension_kind == "length"
|
||||
assert a.view_id == "front"
|
||||
assert a.anchors == [(0.0, 0.0), (0.0, 12.5)]
|
||||
assert a.direction == (0.0, 1.0)
|
||||
assert a.id == drawing.annotations[0].id
|
||||
|
||||
# The restored drawing must still build the same candidates.
|
||||
cands, resolved, unresolved = build_manual_candidates(
|
||||
restored, {"front": (1.0, 0.0, 0.0), "top": (1.0, 0.0, 0.0)}
|
||||
)
|
||||
assert unresolved == []
|
||||
labels = sorted(c.label for c in cands)
|
||||
assert labels == ["12.50", "Ø10.00"]
|
||||
|
||||
def test_load_ignores_corrupt_drawing_entry(self, tmp_path):
|
||||
import zipfile
|
||||
|
||||
project = Project(name="Mixed")
|
||||
project.add_drawing(_drawing_with_manual_dim())
|
||||
path = save_project(project, str(tmp_path / "proj.fluency"))
|
||||
|
||||
with zipfile.ZipFile(path, "r") as zf:
|
||||
names = zf.namelist()
|
||||
contents = {n: zf.read(n) for n in names}
|
||||
manifest = json.loads(contents["project.json"])
|
||||
manifest["drawings"].append({"id": "broken", "views": "not-a-list"})
|
||||
contents["project.json"] = json.dumps(manifest).encode("utf-8")
|
||||
|
||||
with zipfile.ZipFile(path, "w") as zf:
|
||||
for name in names:
|
||||
zf.writestr(name, contents[name])
|
||||
|
||||
loaded, _ = load_project(path)
|
||||
# Corrupt entry skipped, valid one kept.
|
||||
assert len(loaded.drawings) == 1
|
||||
assert loaded.drawings[0].source_id == "comp-42"
|
||||
|
||||
def test_auto_dimensions_default_off(self):
|
||||
assert TechnicalDrawing().auto_dimensions is False
|
||||
|
||||
|
||||
# ── Pick geometry helpers ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestPickGeometry:
|
||||
def test_point_to_segment_inside(self):
|
||||
q, d = _point_to_segment((5.0, 3.0), (0.0, 0.0), (10.0, 0.0))
|
||||
assert q == pytest.approx((5.0, 0.0))
|
||||
assert d == pytest.approx(3.0)
|
||||
|
||||
def test_point_to_segment_clamps_at_endpoint(self):
|
||||
q, d = _point_to_segment((-2.0, 1.0), (0.0, 0.0), (10.0, 0.0))
|
||||
assert q == pytest.approx((0.0, 0.0))
|
||||
assert d == pytest.approx(math.hypot(2.0, 1.0))
|
||||
|
||||
def test_crossing_segments(self):
|
||||
q1, q2, d = _closest_points_on_segments(
|
||||
(0.0, 0.0), (10.0, 0.0), (4.0, -2.0), (4.0, 8.0)
|
||||
)
|
||||
assert q1 == pytest.approx((4.0, 0.0))
|
||||
assert q2 == pytest.approx((4.0, 0.0))
|
||||
assert d == pytest.approx(0.0, abs=1e-9)
|
||||
|
||||
def test_parallel_overlapping_segments(self):
|
||||
# The classic "distance between two parallel edges" pick:
|
||||
# result must be the true perpendicular distance.
|
||||
q1, q2, d = _closest_points_on_segments(
|
||||
(0.0, 0.0), (10.0, 0.0), (2.0, 5.0), (8.0, 5.0)
|
||||
)
|
||||
assert d == pytest.approx(5.0)
|
||||
assert q1[1] == pytest.approx(0.0)
|
||||
assert q2[1] == pytest.approx(5.0)
|
||||
assert q1[0] == pytest.approx(q2[0])
|
||||
|
||||
def test_parallel_disjoint_segments(self):
|
||||
q1, q2, d = _closest_points_on_segments(
|
||||
(0.0, 0.0), (2.0, 0.0), (5.0, 3.0), (7.0, 3.0)
|
||||
)
|
||||
assert d == pytest.approx(math.hypot(3.0, 3.0))
|
||||
|
||||
def test_line_intersection(self):
|
||||
pt = _line_intersection(
|
||||
(0.0, 0.0), (10.0, 0.0), (4.0, -2.0), (4.0, 8.0)
|
||||
)
|
||||
assert pt == pytest.approx((4.0, 0.0))
|
||||
|
||||
def test_line_intersection_parallel_is_none(self):
|
||||
assert _line_intersection(
|
||||
(0.0, 0.0), (10.0, 0.0), (2.0, 5.0), (8.0, 5.0)
|
||||
) is None
|
||||
|
||||
def test_closest_point_on_segment(self):
|
||||
q = _closest_point_on_segment((4.0, 9.0), (0.0, 0.0), (10.0, 0.0))
|
||||
assert q == pytest.approx((4.0, 0.0))
|
||||
|
||||
|
||||
# ── Widget: dimension tool plumbing (offscreen) ────────────────────────────
|
||||
|
||||
|
||||
class TestDrawingWidgetTools:
|
||||
def _widget(self, qapp):
|
||||
from fluency.ui.technical_drawing_widget import TechnicalDrawingWidget
|
||||
|
||||
return TechnicalDrawingWidget()
|
||||
|
||||
def test_widget_starts_without_pick_mode(self, qapp):
|
||||
w = self._widget(qapp)
|
||||
assert w._canvas._pick_mode == ""
|
||||
assert not w._auto_dim_check.isChecked()
|
||||
assert not any(b.isChecked() for b in w._tool_buttons.values())
|
||||
|
||||
def test_tool_toggle_enters_pick_mode(self, qapp):
|
||||
w = self._widget(qapp)
|
||||
btn = w._tool_buttons["distance"]
|
||||
btn.setChecked(True)
|
||||
assert w._canvas._pick_mode == "distance"
|
||||
assert "Distance" in w._status_label.text()
|
||||
assert "edge" in w._status_label.text()
|
||||
|
||||
# Switching tools re-targets the canvas and unchecks the old tool.
|
||||
w._tool_buttons["diameter"].setChecked(True)
|
||||
assert btn.isChecked() is False
|
||||
assert w._canvas._pick_mode == "diameter"
|
||||
|
||||
# Escape path: cancels the tool, clears all buttons and mode.
|
||||
w._cancel_pick()
|
||||
assert w._canvas._pick_mode == ""
|
||||
for other in w._tool_buttons.values():
|
||||
assert not other.isChecked()
|
||||
|
||||
def test_add_manual_dimension_appends_and_emits(self, qapp):
|
||||
w = self._widget(qapp)
|
||||
w.set_drawing(
|
||||
TechnicalDrawing(source_kind="component", source_id="c1")
|
||||
)
|
||||
changes = []
|
||||
w.drawing_changed.connect(lambda: changes.append(1))
|
||||
|
||||
w._add_manual_dimension(
|
||||
"length",
|
||||
anchors=((0.0, 0.0), (10.0, 0.0)),
|
||||
view_id="front",
|
||||
direction=(1.0, 0.0),
|
||||
)
|
||||
|
||||
anns = w._drawing.annotations
|
||||
assert len(anns) == 1
|
||||
assert anns[0].dimension_kind == "length"
|
||||
assert anns[0].view_id == "front"
|
||||
assert anns[0].anchors == [(0.0, 0.0), (10.0, 0.0)]
|
||||
assert changes == [1]
|
||||
|
||||
def test_clear_removes_manual_dimensions_only(self, qapp):
|
||||
w = self._widget(qapp)
|
||||
drawing = TechnicalDrawing(source_kind="component", source_id="c1")
|
||||
drawing.annotations.append(
|
||||
DrawingAnnotation(kind="note", text="keep me")
|
||||
)
|
||||
drawing.annotations.append(
|
||||
_manual_annotation(
|
||||
"length", "front", ((0.0, 0.0), (5.0, 0.0))
|
||||
)
|
||||
)
|
||||
w.set_drawing(drawing)
|
||||
|
||||
w._on_clear_clicked()
|
||||
|
||||
assert len(drawing.annotations) == 1
|
||||
assert drawing.annotations[0].kind == "note"
|
||||
|
||||
def test_adopt_stored_project_drawing(self, qapp, kernel):
|
||||
w = self._widget(qapp)
|
||||
project = Project()
|
||||
comp = Component(name="ExistingComp")
|
||||
project.components[comp.id] = comp
|
||||
stored = TechnicalDrawing(
|
||||
source_kind="component", source_id=comp.id
|
||||
)
|
||||
stored.annotations.append(
|
||||
_manual_annotation(
|
||||
"diameter", "front", ((-4.0, 0.0), (4.0, 0.0))
|
||||
)
|
||||
)
|
||||
project.add_drawing(stored)
|
||||
w.set_project(project, kernel)
|
||||
|
||||
w.set_active_component(comp)
|
||||
|
||||
# The stored drawing is re-adopted (not replaced).
|
||||
assert w._drawing is stored
|
||||
assert len(w._drawing.annotations) == 1
|
||||
|
||||
def test_new_source_creates_and_registers_drawing(self, qapp, kernel):
|
||||
w = self._widget(qapp)
|
||||
project = Project()
|
||||
comp = Component(name="NewComp")
|
||||
project.components[comp.id] = comp
|
||||
w.set_project(project, kernel)
|
||||
|
||||
w.set_active_component(comp)
|
||||
|
||||
assert w._drawing is not None
|
||||
assert w._drawing.source_kind == "component"
|
||||
assert w._drawing.source_id == comp.id
|
||||
assert len(project.drawings) == 1
|
||||
assert project.drawings[0] is w._drawing
|
||||
|
||||
|
||||
class TestCircleCenterPick:
|
||||
"""Clicking a circle's centre mark while a distance tool is active
|
||||
picks a point feature at the circle centre (model coords + radius).
|
||||
|
||||
HLR only projects a true circle for some hole shapes (a plain
|
||||
cylinder discretises into segments), so the render result is built
|
||||
directly with one guaranteed circle primitive.
|
||||
"""
|
||||
|
||||
def _canvas_with_circle(self, qapp):
|
||||
from PySide6.QtCore import QPointF
|
||||
|
||||
from fluency.technical_drawing import (
|
||||
DrawingPrimitive,
|
||||
DrawingRenderResult,
|
||||
)
|
||||
from fluency.ui.technical_drawing_widget import DrawingCanvas
|
||||
|
||||
# One r4 circle at model (20, 15), drawn at 10× scale at the
|
||||
# sheet centre: model (20,15) → sheet (200, 150).
|
||||
circle = DrawingPrimitive(
|
||||
kind="circle",
|
||||
points=(),
|
||||
style="visible",
|
||||
center=(200.0, 150.0),
|
||||
radius=40.0,
|
||||
view_id="top",
|
||||
)
|
||||
result = DrawingRenderResult(
|
||||
primitives=(circle,),
|
||||
candidates=(),
|
||||
resolved_annotation_ids=(),
|
||||
unresolved_annotation_ids=(),
|
||||
source_fingerprint="",
|
||||
warnings=(),
|
||||
view_transforms={"top": (10.0, 0.0, 0.0)},
|
||||
)
|
||||
canvas = DrawingCanvas()
|
||||
canvas.resize(840, 600)
|
||||
canvas.set_render_result(result)
|
||||
canvas.set_pick_mode("distance")
|
||||
return canvas, QPointF
|
||||
|
||||
def test_pick_center_mark_returns_point(self, qapp):
|
||||
canvas, QPointF = self._canvas_with_circle(qapp)
|
||||
# Sheet → device position of the circle centre.
|
||||
rect = canvas._sheet_rect()
|
||||
scale = rect.width() / 420.0
|
||||
pos = QPointF(rect.x() + 200.0 * scale, rect.y() + (297.0 - 150.0) * scale)
|
||||
|
||||
hit = canvas._pick_feature(pos)
|
||||
assert hit is not None, "clicking the centre mark must hit something"
|
||||
assert hit["kind"] == "point"
|
||||
assert hit["view_id"] == "top"
|
||||
assert hit["radius"] == pytest.approx(4.0, abs=1e-6)
|
||||
# The picked model point is the inverse-transformed sheet centre:
|
||||
# (200, 150) at 10× scale → (20, 15).
|
||||
assert hit["point"] == pytest.approx((20.0, 15.0), abs=1e-9)
|
||||
|
||||
def test_distance_tool_accepts_center_then_edge(self, qapp, kernel):
|
||||
from fluency.ui.technical_drawing_widget import TechnicalDrawingWidget
|
||||
|
||||
w = TechnicalDrawingWidget()
|
||||
project, comp = _cylinder_project(kernel)
|
||||
w.set_project(project, kernel)
|
||||
w.set_active_component(comp)
|
||||
assert w._drawing is not None
|
||||
|
||||
# First pick: a circle centre at model (10, 12) — the dict a
|
||||
# centre-mark click produces (see test above).
|
||||
w._first_pick = {
|
||||
"kind": "point",
|
||||
"view_id": "top",
|
||||
"point": (10.0, 12.0),
|
||||
"radius": 4.0,
|
||||
}
|
||||
# Second pick: a horizontal edge 6 mm above the centre.
|
||||
second = {
|
||||
"kind": "segment",
|
||||
"view_id": "top",
|
||||
"p1": (0.0, 18.0),
|
||||
"p2": (20.0, 18.0),
|
||||
}
|
||||
w._on_edge_pick(second, "distance")
|
||||
assert w._drawing.annotations, "a manual dimension must be appended"
|
||||
ann = w._drawing.annotations[-1]
|
||||
assert ann.dimension_kind == "length"
|
||||
assert ann.view_id == "top"
|
||||
# First anchor is the picked centre; the second is the closest
|
||||
# point on the edge, straight above it.
|
||||
assert ann.anchors[0] == pytest.approx((10.0, 12.0), abs=1e-9)
|
||||
assert ann.anchors[1] == pytest.approx((10.0, 18.0), abs=1e-6)
|
||||
|
||||
Reference in New Issue
Block a user