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 @@ - - - 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"]