diff --git a/.idea/workspace.xml b/.idea/workspace.xml
index 2b1ac63..43bd739 100644
--- a/.idea/workspace.xml
+++ b/.idea/workspace.xml
@@ -6,10 +6,7 @@
-
-
-
@@ -122,14 +119,6 @@
1703867682707
-
-
- 1735563255455
-
-
-
- 1735563255455
- 1735585968733
@@ -514,7 +503,15 @@
1786910497589
-
+
+
+ 1786984519780
+
+
+
+ 1786984519781
+
+
diff --git a/src/fluency/technical_drawing.py b/src/fluency/technical_drawing.py
index f779b6d..89ce057 100644
--- a/src/fluency/technical_drawing.py
+++ b/src/fluency/technical_drawing.py
@@ -85,7 +85,13 @@ class DrawingRenderResult:
warnings: Tuple[str, ...]
# Per-view model→sheet transform: view_id → (scale, offset_x, offset_y)
# with sheet(x, y) = (x*scale + offset_x, y*scale + offset_y).
- view_transforms: Dict[str, Tuple[float, float, float]] = field(default_factory=dict)
+ # view_id → (scale, sheet_cx, sheet_cy, angle_deg, cx, cy) with
+ # sheet(p) = scale · R(angle_deg) · (p − (cx, cy)) + (sheet_cx, sheet_cy),
+ # i.e. (sheet_cx, sheet_cy) is the sheet position of the view's
+ # geometry centre and angle_deg is the view's sheet rotation (0/90).
+ # Legacy 3-tuples (scale, offset_x, offset_y) — sheet(p) = p*scale +
+ # offset — are still accepted by the consumers.
+ view_transforms: Dict[str, Tuple[float, ...]] = field(default_factory=dict)
# ── View presets ───────────────────────────────────────────────────────────
@@ -325,7 +331,8 @@ def _assemble_view(
view: DrawingView,
slot: Optional[Tuple[float, float, float, float]] = None,
scale_override: Optional[float] = None,
- transforms: Optional[Dict[str, Tuple[float, float, float]]] = None,
+ transforms: Optional[Dict[str, Tuple[float, ...]]] = None,
+ rotation: float = 0.0,
) -> Tuple[Tuple[DrawingPrimitive, ...], Tuple[DrawingCandidate, ...], Tuple[str, ...]]:
"""Fit projected edges into *slot* and emit primitives + candidates.
@@ -334,7 +341,12 @@ def _assemble_view(
whole sheet. *scale_override* forces a specific model→sheet scale
(used to keep all orthographic views at one shared scale).
When *transforms* is given, the resolved
- ``(scale, offset_x, offset_y)`` is recorded under the view id.
+ transform is recorded under the view id as
+ ``(scale, sheet_cx, sheet_cy, angle_deg, cx, cy)`` — the sheet
+ position of the geometry centre plus the rotation actually applied.
+ *rotation* turns the projection 90° steps about its centre before
+ fitting (0 or 90), so a long thin view can be drawn sideways to make
+ the whole sheet layout fit better.
"""
warnings = list(warnings)
primitives: List[DrawingPrimitive] = []
@@ -344,6 +356,35 @@ def _assemble_view(
warnings.append(f"View '{view.name or view.kind}': no projected edges")
return tuple(primitives), tuple(candidates), tuple(warnings)
+ # Rotate the projection about its centre first: everything downstream
+ # (fitting, candidates, direction vectors) then works on the rotated
+ # frame with a plain uniform scale + translation, and rotation
+ # preserves all measured lengths.
+ min_x, min_y, max_x, max_y = _edges_bounds(edges)
+ rcx = (min_x + max_x) / 2.0
+ rcy = (min_y + max_y) / 2.0
+ if rotation:
+ th = math.radians(rotation)
+ cos_t, sin_t = math.cos(th), math.sin(th)
+
+ def _rot_pt(p: Tuple[float, float]) -> Tuple[float, float]:
+ dx, dy = p[0] - rcx, p[1] - rcy
+ return (
+ dx * cos_t - dy * sin_t + rcx,
+ dx * sin_t + dy * cos_t + rcy,
+ )
+
+ rotated_edges: List[Tuple[Tuple[float, float], Tuple[float, float], str, str]] = []
+ for p1, p2, curve_type, style in edges:
+ if curve_type == "circle_full":
+ # p2 only encodes the radius as the x-offset of p1.
+ r = p2[0] - p1[0]
+ nc = _rot_pt(p1)
+ rotated_edges.append((nc, (nc[0] + r, nc[1]), curve_type, style))
+ else:
+ rotated_edges.append((_rot_pt(p1), _rot_pt(p2), curve_type, style))
+ edges = rotated_edges
+
# Separate geometry by curve type (model units).
line_segments: List[Tuple[Tuple[float, float], Tuple[float, float]]] = []
circle_data: List[Tuple[float, float, float]] = [] # (cx, cy, r)
@@ -378,7 +419,17 @@ def _assemble_view(
# Use kind as view_id for readability (UUID is opaque to users).
view_id = view.kind if view.kind in _STANDARD_VIEWS else (view.name or view.id)
if transforms is not None:
- transforms[view_id] = (scale, offset_x, offset_y)
+ # Record o = offset + scale·c: the sheet position of the model
+ # centre, so consumers can invert via
+ # model = R(-angle)·(sheet − o)/scale + c.
+ transforms[view_id] = (
+ scale,
+ offset_x + scale * rcx,
+ offset_y + scale * rcy,
+ float(rotation),
+ rcx,
+ rcy,
+ )
def _to_sheet(x: float, y: float) -> Tuple[float, float]:
return (x * scale + offset_x, y * scale + offset_y)
@@ -400,6 +451,28 @@ def _assemble_view(
view_id=view_id,
)
)
+ # ISO 14128 centre mark: a thin cross extending just past the
+ # circle, so the centre is visible and usable as a dimension
+ # reference (centre-to-centre, centre-to-edge distances).
+ sc = _to_sheet(cx, cy)
+ sr = radius * scale
+ ext = sr + max(2.0, sr * 0.15)
+ primitives.append(
+ DrawingPrimitive(
+ kind="line",
+ points=((sc[0] - ext, sc[1]), (sc[0] + ext, sc[1])),
+ style="center",
+ view_id=view_id,
+ )
+ )
+ primitives.append(
+ DrawingPrimitive(
+ kind="line",
+ points=((sc[0], sc[1] - ext), (sc[0], sc[1] + ext)),
+ style="center",
+ view_id=view_id,
+ )
+ )
else:
primitives.append(
DrawingPrimitive(
@@ -786,161 +859,383 @@ def _extract_angle_candidates(
angle_count += 1
-# ── Drawing generation ─────────────────────────────────────────────────────
+# ── 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")
+
+# Leave room for dimension lines: the shared scale fits the views into
+# this fraction of the region, so extension lines and labels have space
+# between and around the views instead of colliding with neighbours.
+_DIM_ROOM_FACTOR = 0.8
+_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.
+
+ Every placer scales its output linearly with the input dims, so the
+ union size at scale s is s times its size at scale 1 — the fit is the
+ closed-form ``min(region_w / u_w, region_h / u_h)``.
+ """
+ _rx0, _ry0, rw, rh = region
+ rects = place(dims_m, _VIEW_GAP_MM)
+ if not rects:
+ return 0.0
+ 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)
+ uw = max(maxx - minx, 1e-9)
+ uh = max(maxy - miny, 1e-9)
+ return min(rw / uw, rh / uh)
+
+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]]:
- """Compute a slot rectangle for each view in standard orthographic layout.
+) -> 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)``:
- slots are ``(left, bottom, width, height)`` in sheet mm (origin at the
- sheet's bottom-left corner, +y up).
+ 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).
- Layout (third-angle projection, aligned projections)::
-
- top isometric
- left front right back
- bottom
-
- All orthographic views share one scale (the tightest fit that keeps
- every projection in its footprint) so the views stay mutually
- consistent, and each view is centred in its allotted space.
+ The sheet is filled, not just used: every candidate arrangement
+ (classic third-angle cross, the cross with the view families swapped,
+ and 2/3-row grids) is combined with every per-view 90° rotation
+ assignment (with 1–2 views the projections stay upright and only the
+ scale is optimised), 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. The
+ shared scale is further reduced to leave room for dimension lines
+ between and around the views. 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).
"""
- if not views:
- return {}, None
-
- margin = 10.0
- gap = 12.0
- title_block_h = 55.0
- full_w = _A3_WIDTH_MM - 2 * margin
- # The bottom view sits at the bottom of the projection column, so the
- # whole column stays clear of the title block (bottom-right corner).
- col_bottom = margin + title_block_h
- col_top = _A3_HEIGHT_MM - margin
- col_avail = col_top - col_bottom
-
- def dims(vid: str) -> Tuple[float, float]:
- b = bboxes.get(vid)
- if b is None:
- return 1.0, 1.0
- return max(b[2] - b[0], 1e-6), max(b[3] - b[1], 1e-6)
-
- ortho_kinds = [
- v.kind for v in views if v.kind in _STANDARD_VIEWS and v.kind != "isometric"
- ]
- mid_order = ["left", "front", "right", "back"]
- col_order = ["top", "front", "bottom"] # top → bottom
- present_mid = [k for k in mid_order if k in ortho_kinds]
- present_col = [k for k in col_order if k in ortho_kinds]
-
slots: Dict[str, Tuple[float, float, float, float]] = {}
+ rotations: Dict[str, float] = {}
common_scale: Optional[float] = None
- if present_mid or present_col:
- row_w = sum(dims(k)[0] for k in present_mid)
- col_h = sum(dims(k)[1] for k in present_col)
- scale_opts: List[float] = []
- if present_mid:
- scale_opts.append((full_w - gap * (len(present_mid) - 1)) / row_w)
- if present_col:
- scale_opts.append((col_avail - gap * (len(present_col) - 1)) / col_h)
- common_scale = min(scale_opts)
+ ortho = [
+ v for v in views if v.kind in _STANDARD_VIEWS and v.kind != "isometric"
+ ]
- # Middle row: left → front → right → back, centred on the sheet.
- total_row = row_w * common_scale + gap * (len(present_mid) - 1)
- x = margin + (full_w - total_row) / 2.0
- row_slots: Dict[str, Tuple[float, float, float]] = {}
- for k in present_mid:
- w, h = dims(k)
- row_slots[k] = (x, w * common_scale, h * common_scale)
- x += w * common_scale + gap
-
- # Column: top → front → bottom, stacked from the top edge down and
- # centred in the available column (which stays clear of the title
- # block).
- total_col = col_h * common_scale + gap * (len(present_col) - 1)
- y = col_bottom + col_avail - (col_avail - total_col) / 2.0
- col_slots: Dict[str, Tuple[float, float, float]] = {}
- for k in present_col:
- w, h = dims(k)
- sh = h * common_scale
- col_slots[k] = (y - sh, w * common_scale, sh)
- y -= sh + gap
-
- anchor = (
- "front"
- if "front" in present_mid
- else (present_mid[0] if present_mid else present_col[0])
- )
- if anchor in row_slots:
- ax, aw, ah = row_slots[anchor]
- ay = (
- col_slots[anchor][0]
- if anchor in col_slots
- else col_bottom + (col_avail - ah) / 2.0
- )
- else:
- ay, aw, ah = col_slots[anchor]
- ax = margin + (full_w - aw) / 2.0
- anchor_cx = ax + aw / 2.0
-
- for k in set(present_mid) | set(present_col):
- w, h = dims(k)
- sw, sh = w * common_scale, h * common_scale
- if k in row_slots and k in col_slots:
- sx = row_slots[k][0]
- sy = col_slots[k][0]
- elif k in row_slots:
- # Mid-row view without a column slot: centre on the anchor.
- sx = row_slots[k][0]
- sy = ay + (ah - sh) / 2.0
+ used_rects: List[Tuple[float, float, float, float]] = []
+ if ortho:
+ # Nominal (scale-1) sizes: the per-view ``scale`` factor is applied
+ # at assembly time on top of the shared scale, exactly as before.
+ dims0: Dict[str, Tuple[float, float]] = {}
+ for v in ortho:
+ b = bboxes.get(v.kind)
+ if b is None:
+ dims0[v.kind] = (1.0, 1.0)
else:
- # Column view without a mid slot: align with the anchor.
- sx = anchor_cx - sw / 2.0
- sy = col_slots[k][0]
- slots[k] = (sx, sy, sw, sh)
-
- # Isometric: free region to the right of the main block.
- if any(v.kind == "isometric" for v in views):
- iso_x0 = ax + aw + gap
- if "top" in col_slots:
- iso_x0 = max(iso_x0, anchor_cx + col_slots["top"][1] / 2.0 + gap)
- iso_y0 = ay + ah + gap
- iso_x1 = _A3_WIDTH_MM - margin
- iso_y1 = _A3_HEIGHT_MM - margin
- if iso_x1 - iso_x0 < 30.0 or iso_y1 - iso_y0 < 30.0:
- # No room at the right — fall back to the bottom-left corner.
- left_x = slots.get("left", (margin + full_w * 0.5,))[0]
- iso_x1 = min(iso_x1, left_x - gap)
- bottom_y = slots.get("bottom", (0.0, col_bottom + col_avail * 0.5, 0, 0))[1]
- iso_y1 = min(iso_y1, bottom_y - gap)
- slots["isometric"] = (
- iso_x0,
- iso_y0,
- max(iso_x1 - iso_x0, 10.0),
- max(iso_y1 - iso_y0, 10.0),
+ dims0[v.kind] = (
+ max(b[2] - b[0], 1e-6),
+ max(b[3] - b[1], 1e-6),
+ )
+ keys = list(dims0)
+ arrangements: Tuple[
+ Tuple[str, int, Callable[[Dict[str, Tuple[float, float]], float], _RectList]]
+ ] = (
+ ("cross", 0, _place_cross),
+ ("swapped", 1, _place_swapped),
+ ("grid2", 2, lambda d, g: _place_grid(d, g, 2)),
+ ("grid3", 3, 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]]
+ ] = []
+ # Rotated views are a packing tool for multi-view layouts; with
+ # 1–2 views the projection is kept upright and only scaled to fit.
+ n_masks = 1 << len(keys) if len(keys) >= 3 else 1
+ # Shrink each region for dimension clearance (views are centred in
+ # the full region, so this only reduces the scale).
+ upper = _REGION_UPPER[:2] + tuple(
+ d * _DIM_ROOM_FACTOR for d in _REGION_UPPER[2:]
+ )
+ left = _REGION_LEFT[:2] + tuple(
+ d * _DIM_ROOM_FACTOR for d in _REGION_LEFT[2:]
+ )
+ for mask in range(n_masks):
+ 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, upper)
+ s_left = _fit_scale(place, dims_m, 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]),
)
- else:
- # No standard ortho views — give the isometric most of the sheet.
- if any(v.kind == "isometric" for v in views):
- slots["isometric"] = (margin, margin, full_w * 0.55, col_avail)
+ 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)]
- # Custom (non-standard) views fill the bottom-left corner.
- custom = [v for v in views if v.kind not in _STANDARD_VIEWS]
- if custom:
- left_edge = slots.get("left", (margin + full_w * 0.4,))[0]
- bottom_edge = slots.get("bottom", (0.0, col_bottom + col_avail * 0.4, 0, 0))[1]
- cw = max(left_edge - margin - gap, 60.0)
- ch = max(bottom_edge - margin - gap, 60.0)
- for i, v in enumerate(custom):
- vid = v.name or v.id
- slots[vid] = (margin, margin + i * (ch + gap), cw, ch)
+ # 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
+ return slots, common_scale, rotations
# ── Dimension selection & placement ───────────────────────────────────────
@@ -1365,7 +1660,7 @@ _MANUAL_DIMENSION_KINDS = ("length", "diameter", "angle")
def build_manual_candidates(
drawing: TechnicalDrawing,
- view_transforms: Dict[str, Tuple[float, float, float]],
+ view_transforms: Dict[str, Tuple[float, ...]],
) -> Tuple[List[DrawingCandidate], List[str], List[str]]:
"""Convert user-placed annotations into renderable dimension candidates.
@@ -1389,10 +1684,33 @@ def build_manual_candidates(
if transform is None:
unresolved.append(ann.id)
continue
- scale, offset_x, offset_y = transform
+ scale, ox, oy = transform[0], transform[1], transform[2]
+ if len(transform) >= 6:
+ # New form: sheet(p) = s·R(θ)(p − c) + o, where o is the sheet
+ # position of the geometry centre c and θ the view rotation.
+ th = math.radians(transform[3])
+ cos_t, sin_t = math.cos(th), math.sin(th)
+ rcx, rcy = transform[4], transform[5]
- def to_sheet(pt: Tuple[float, float]) -> Tuple[float, float]:
- return (pt[0] * scale + offset_x, pt[1] * scale + offset_y)
+ def to_sheet(pt: Tuple[float, float]) -> Tuple[float, float]:
+ dx, dy = pt[0] - rcx, pt[1] - rcy
+ return (
+ (dx * cos_t - dy * sin_t) * scale + ox,
+ (dx * sin_t + dy * cos_t) * scale + oy,
+ )
+
+ def rot_dir(d: Tuple[float, float]) -> Tuple[float, float]:
+ return (
+ d[0] * cos_t - d[1] * sin_t,
+ d[0] * sin_t + d[1] * cos_t,
+ )
+ else:
+
+ def to_sheet(pt: Tuple[float, float]) -> Tuple[float, float]:
+ return (pt[0] * scale + ox, pt[1] * scale + oy)
+
+ def rot_dir(d: Tuple[float, float]) -> Tuple[float, float]:
+ return d
a0, a1 = ann.anchors[0], ann.anchors[1]
if ann.dimension_kind == "angle":
@@ -1439,7 +1757,7 @@ def build_manual_candidates(
dx, dy = a1[0] - a0[0], a1[1] - a0[1]
# value >= 1e-6 guarantees the fallback vector is non-zero.
mag = math.hypot(dx, dy)
- direction: Tuple[float, float] = (dx / mag, dy / mag)
+ direction: Tuple[float, float] = rot_dir((dx / mag, dy / mag))
label = f"{value:.{_DISPLAY_PRECISION}f}"
else: # diameter
direction = ()
@@ -1510,7 +1828,9 @@ def generate_drawing(
_vid_of(view): _edges_bounds(projections[_vid_of(view)][0])
for view in drawing.views
}
- view_slots, common_scale = _layout_views_on_sheet(drawing.views, bboxes)
+ view_slots, common_scale, view_rotations = _layout_views_on_sheet(
+ drawing.views, bboxes
+ )
ortho_vids = {
_vid_of(v)
@@ -1519,7 +1839,7 @@ def generate_drawing(
}
# Per-view model→sheet transforms, filled in by _assemble_view.
- transforms: Dict[str, Tuple[float, float, float]] = {}
+ transforms: Dict[str, Tuple[float, ...]] = {}
for view in drawing.views:
vid = _vid_of(view)
@@ -1529,7 +1849,13 @@ def generate_drawing(
common_scale * view.scale if vid in ortho_vids and common_scale else None
)
prims, cands, vwarns = _assemble_view(
- edges, view_warnings, view, slot, scale_override, transforms
+ edges,
+ view_warnings,
+ view,
+ slot,
+ scale_override,
+ transforms,
+ rotation=view_rotations.get(vid, 0.0),
)
all_primitives.extend(prims)
all_candidates.extend(cands)
@@ -1683,6 +2009,7 @@ def render_drawing(
style_pens = {
"visible": QPen(QColor(0, 0, 0), 1.5),
"hidden": QPen(QColor(128, 128, 128), 1.0),
+ "center": QPen(QColor(0, 0, 0), 0.35),
"construction": QPen(QColor(0, 0, 255), 0.5),
"dimension": QPen(QColor(0, 0, 0), 1.0),
}
diff --git a/src/fluency/ui/technical_drawing_widget.py b/src/fluency/ui/technical_drawing_widget.py
index 3abb8a7..8715386 100644
--- a/src/fluency/ui/technical_drawing_widget.py
+++ b/src/fluency/ui/technical_drawing_widget.py
@@ -243,7 +243,16 @@ class DrawingCanvas(QWidget):
t = self._render_result.view_transforms.get(view_id)
if t is None or t[0] <= 0:
return None
- scale, ox, oy = t
+ scale, ox, oy = t[0], t[1], t[2]
+ if len(t) >= 6:
+ # sheet(p) = s·R(θ)(p − c) + o → invert around the centre.
+ th = math.radians(t[3])
+ cos_t, sin_t = math.cos(th), math.sin(th)
+ sx, sy = (pt[0] - ox) / scale, (pt[1] - oy) / scale
+ return (
+ sx * cos_t + sy * sin_t + t[4],
+ -sx * sin_t + sy * cos_t + t[5],
+ )
return ((pt[0] - ox) / scale, (pt[1] - oy) / scale)
def _pick_feature(self, pos: QPointF) -> Optional[dict]:
@@ -283,12 +292,19 @@ class DrawingCanvas(QWidget):
"p2": m2,
}
elif prim.kind == "circle" and prim.center and prim.radius:
- d = abs(
- math.hypot(
- pt[0] - prim.center[0], pt[1] - prim.center[1]
- )
- - prim.radius
- )
+ dc = math.hypot(pt[0] - prim.center[0], pt[1] - prim.center[1])
+ if dc <= tol:
+ # Hit the circle's centre mark: a point feature — circle
+ # centres are first-class dimension references.
+ mc = self._sheet_to_model(prim.view_id, prim.center)
+ if mc:
+ return {
+ "kind": "point",
+ "view_id": prim.view_id,
+ "point": mc,
+ "radius": prim.radius / scale,
+ }
+ d = abs(dc - prim.radius)
if d < best_d:
best_d = d
mc = self._sheet_to_model(prim.view_id, prim.center)
@@ -506,19 +522,11 @@ class TechnicalDrawingWidget(QWidget):
(
"distance",
"Distance",
- "Pick two edges — the dimension line is placed "
- "perpendicular to them, measuring the distance between",
- ),
- (
- "diameter",
- "Diameter",
- "Pick a circle — its diameter is added",
- ),
- (
- "angle",
- "Angle",
- "Pick two edges — the angle between them is added",
+ "Pick two edges, an edge and a circle centre, or two "
+ "circle centres — measures the distance between them",
),
+ ("diameter", "Diameter", "Pick a circle — its diameter is added"),
+ ("angle", "Angle", "Pick two edges — the angle between them is added"),
):
btn = QPushButton(label)
btn.setCheckable(True)
@@ -818,7 +826,7 @@ class TechnicalDrawingWidget(QWidget):
self._first_pick = None
self._canvas.set_pick_mode(tool)
prompts = {
- "distance": "Distance: click the first edge",
+ "distance": "Distance: click an edge or a circle centre",
"diameter": "Diameter: click a circle",
"angle": "Angle: click the first edge",
}
@@ -883,6 +891,40 @@ class TechnicalDrawingWidget(QWidget):
elif mode in ("distance", "angle"):
self._on_edge_pick(info, mode)
+ @staticmethod
+ def _pick_point(info: dict) -> Optional[Tuple[float, float]]:
+ """The measurable point of a pick: a point pick is its point, a
+ circle pick counts as its centre; a bare segment is None."""
+ if info.get("kind") == "point":
+ return info["point"]
+ if info.get("kind") == "circle":
+ return info["center"]
+ return None
+
+ @classmethod
+ def _distance_anchors(
+ cls, first: dict, second: dict
+ ) -> Tuple[Tuple[float, float], Tuple[float, float]]:
+ """Anchor pair for a distance between two picks (model coords).
+
+ A pick may be a segment (edge), a circle (measured at its centre)
+ or a point (a picked circle centre). Point/segment mixes use the
+ closest point on the segment so the dimension lands perpendicular
+ to the edge, ISO style.
+ """
+ fp = cls._pick_point(first)
+ sp = cls._pick_point(second)
+ if fp is not None and sp is not None:
+ return fp, sp
+ if fp is not None:
+ return fp, _closest_point_on_segment(fp, second["p1"], second["p2"])
+ if sp is not None:
+ return sp, _closest_point_on_segment(sp, first["p1"], first["p2"])
+ q1, q2, _d = _closest_points_on_segments(
+ first["p1"], first["p2"], second["p1"], second["p2"]
+ )
+ return q1, q2
+
def _on_edge_pick(self, info: dict, mode: str) -> None:
if (
self._first_pick is None
@@ -891,16 +933,16 @@ class TechnicalDrawingWidget(QWidget):
# First edge (or picked in a different view: restart there).
self._first_pick = info
self._status_label.setText(
- "Select the second edge in the same view (Esc cancels)"
+ "Select the second feature in the same view (Esc cancels)"
)
return
- a1, a2 = self._first_pick["p1"], self._first_pick["p2"]
- b1, b2 = info["p1"], info["p2"]
+ first = self._first_pick
view_id = info["view_id"]
if mode == "distance":
- q1, q2, dist = _closest_points_on_segments(a1, a2, b1, b2)
+ p1, p2 = self._distance_anchors(first, info)
+ dist = math.hypot(p2[0] - p1[0], p2[1] - p1[1])
if dist < 0.01:
self._status_label.setText(
"The two edges coincide — no distance to measure"
@@ -908,12 +950,20 @@ class TechnicalDrawingWidget(QWidget):
return
self._add_manual_dimension(
"length",
- anchors=(q1, q2),
+ anchors=(p1, p2),
view_id=view_id,
- direction=((q2[0] - q1[0]) / dist, (q2[1] - q1[1]) / dist),
+ direction=((p2[0] - p1[0]) / dist, (p2[1] - p1[1]) / dist),
done_msg=f"Distance dimension added: {dist:.2f}",
)
else: # angle
+ if first.get("kind") != "segment" or info.get("kind") != "segment":
+ self._status_label.setText(
+ "Angle needs two edges — cancel and pick edge lines"
+ )
+ self._first_pick = None
+ return
+ a1, a2 = first["p1"], first["p2"]
+ b1, b2 = info["p1"], info["p2"]
vertex = _line_intersection(a1, a2, b1, b2)
if vertex is None:
self._status_label.setText(
@@ -937,6 +987,17 @@ class TechnicalDrawingWidget(QWidget):
)
def _on_diameter_pick(self, info: dict) -> None:
+ if info.get("kind") == "point" and info.get("radius"):
+ # Picked the centre mark of a circle.
+ cx, cy = info["point"]
+ r = info["radius"]
+ self._add_manual_dimension(
+ "diameter",
+ anchors=((cx - r, cy), (cx + r, cy)),
+ view_id=info["view_id"],
+ done_msg=f"Diameter dimension added: Ø{2 * r:.2f}",
+ )
+ return
if info.get("kind") != "circle":
return
cx, cy = info["center"]