377 lines
14 KiB
Python
377 lines
14 KiB
Python
# ── 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
|