diff --git a/.idea/workspace.xml b/.idea/workspace.xml index 43bd739..9af2b5c 100644 --- a/.idea/workspace.xml +++ b/.idea/workspace.xml @@ -6,7 +6,10 @@ + + + diff --git a/.layout_new.py b/.layout_new.py new file mode 100644 index 0000000..aa0ea41 --- /dev/null +++ b/.layout_new.py @@ -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 diff --git a/.render_check.py b/.render_check.py new file mode 100644 index 0000000..f2be9f4 --- /dev/null +++ b/.render_check.py @@ -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)) diff --git a/.smoke_layout.py b/.smoke_layout.py new file mode 100644 index 0000000..f38652b --- /dev/null +++ b/.smoke_layout.py @@ -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") diff --git a/src/fluency/geometry_occ/kernel.py b/src/fluency/geometry_occ/kernel.py index fdc5021..0aa4c05 100644 --- a/src/fluency/geometry_occ/kernel.py +++ b/src/fluency/geometry_occ/kernel.py @@ -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 diff --git a/src/fluency/technical_drawing.py b/src/fluency/technical_drawing.py index 89ce057..05e3d90 100644 --- a/src/fluency/technical_drawing.py +++ b/src/fluency/technical_drawing.py @@ -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}",) diff --git a/src/fluency/ui/main_window.py b/src/fluency/ui/main_window.py index ca42758..22a15d1 100644 --- a/src/fluency/ui/main_window.py +++ b/src/fluency/ui/main_window.py @@ -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 diff --git a/src/fluency/ui/render_window.py b/src/fluency/ui/render_window.py index 22667bd..08865c2 100644 --- a/src/fluency/ui/render_window.py +++ b/src/fluency/ui/render_window.py @@ -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 diff --git a/src/fluency/ui/technical_drawing_widget.py b/src/fluency/ui/technical_drawing_widget.py index 8715386..1a0d409 100644 --- a/src/fluency/ui/technical_drawing_widget.py +++ b/src/fluency/ui/technical_drawing_widget.py @@ -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() diff --git a/tests/test_technical_drawing.py b/tests/test_technical_drawing.py new file mode 100644 index 0000000..b13e8ff --- /dev/null +++ b/tests/test_technical_drawing.py @@ -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) +