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 @@
+
+
+
@@ -119,14 +122,6 @@
1703867682707
-
-
- 1735585968733
-
-
-
- 1735585968733
-
1735601610504
@@ -511,7 +506,15 @@
1786984519781
-
+
+
+ 1786995561375
+
+
+
+ 1786995561376
+
+
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)
+