- tech drawing and render improv

This commit is contained in:
bklronin
2026-08-18 15:06:51 +02:00
parent 813ddc3596
commit 67b73c13b8
10 changed files with 1886 additions and 54 deletions
+376
View File
@@ -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