# ── 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