Files
Aislo/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Sheet.py
T
eomsangdonandClaude Opus 5.5 e4fa14564f fix(B07): 횡단면도 박스가 화면 덧그림(B06 설계선·구조물)까지 1 mm 로 감쌈
- 덧그림 범위를 서버가 화면과 같은 TS 로 셈 — Node 진입점 B07_DesignDetail_Cross_Extent_Node(최소 DOM 위에서 오프스크린 SVG 그리기 그대로) · 번들 build:b07-extent
- 화면 그리기·수확 부분을 B07_DesignDetail_Cross_Overlay 로 떼어 화면과 Node 가 한 벌로 씀
- 장 배치·블록 테두리가 측점별 덧그림 범위를 덮음(목록·장 열기·확정·수정 네 길 모두 같은 계획)
- 글자는 CAD 그리기대로 세로 가운데 · 폭은 글꼴 어림(한글 1.0 · 나머지 0.6 배)
- 번들 낡음 판정에 B07 맨 위 TS 포함
- 검증_sub4: 66개 박스 ↔ 그림(덧그림·글자 실폭 포함) 모두 1.0 mm · 잘림 0 · 겹침 0 · 20장 → 21장

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013rJwJMVL4EE31y77TB11AP
2026-10-06 23:33:53 +09:00

271 lines
11 KiB
Python

"""B07 횡단면도 장 배치 — A1 한 장에 들어가는 만큼 단면을 담는다.
채우는 순서는 **좌하단부터 아래에서 위로, 그다음 오른쪽 열**이다
(2026-08-29 사용자 확정). 한 장에 몇 개가 들어가는지는 단면 블록의 크기가
정하며, 블록 크기는 작성 척도(1/100)와 "설계선이 원지반과 갈라지는 구간"이
정한다 — 즉 종이가 허용하는 만큼만 담고 남으면 다음 장으로 넘긴다.
"""
import re
from typing import Any
from B05_Profile.B05_Profile_Engine_Sections import cross_key
from B07_DesignDetail.B07_DesignDetail_Engine_Cad import (
CROSS_BORDER_PAD_MM,
CROSS_MM,
CROSS_TABLE_LAYER_ID,
DESIGN_LAYER_ID,
DRAWING_FORMAT,
FRAME_LAYER_ID,
GROUND_LAYER_ID,
ROCK_LAYER_ID,
STRUCTURE_LAYER_ID,
_clip_polyline,
_layer,
_section_window,
build_cross_drawing,
points_from_samples,
)
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Table import (
cross_table_height,
cross_table_width,
)
from B07_DesignDetail.B07_DesignDetail_Engine_Template import (
entities_bbox,
frame_entities,
scale_fields,
usable_area,
)
from config.config_system import DRAWING_SCALE_CROSS
# 장 도면 id — 측점 도면(cross_00020m)과 겹치지 않게 `s`를 끼운다(cross_s00020m).
# 옛 순번 형식(cross_s01)도 읽어 준다 — 이미 확정한 매니페스트가 그 이름을 갖고 있다.
CROSS_SHEET_ID = re.compile(r"cross_s(\d{2,}(?:\.\d{2})?)m?")
# 테두리 여유 — build_cross_drawing의 테두리 값과 맞춘다. 블록은 서로 맞닿게 쌓는다.
_BORDER_PAD_MM = CROSS_BORDER_PAD_MM
def _design_points(design_line: list[Any] | None) -> list[tuple[float, float]]:
points: list[tuple[float, float]] = []
for point in design_line if isinstance(design_line, list) else []:
if not isinstance(point, dict):
continue
x = point.get("offset_m")
y = point.get("elevation_m")
if isinstance(x, (int, float)) and isinstance(y, (int, float)):
points.append((float(x), float(y)))
return points
def section_block_size(
source: dict[str, Any],
design_line: list[Any] | None,
with_table: bool = True,
design: dict[str, Any] | None = None,
overlay: tuple[float, float, float, float] | None = None,
) -> tuple[float, float]:
"""단면 블록 하나의 종이 크기(mm) — 엔티티를 만들지 않고 치수만 잰다.
build_cross_drawing의 테두리 계산과 같은 규칙을 쓴다: 가로는 그리는 범위와
수량표 중 넓은 쪽, 세로는 단면 높이(암 경계선 포함) + 수량표.
"""
design_spec = design if isinstance(design, dict) else None
ground = points_from_samples(source.get("samples", []), "offset_m")
design = _design_points(design_line)
x0, x1 = _section_window(ground, design, overlay=overlay)
ground_clipped = _clip_polyline(ground, x0, x1)
ys = [y for _x, y in ground_clipped] + [y for x, y in design if x0 <= x <= x1]
rock_offset = (design_spec or {}).get("rock_boundary_offset_m")
if isinstance(rock_offset, (int, float)):
ys.extend(y + float(rock_offset) for _x, y in ground_clipped)
if overlay is not None:
ys.extend(overlay[2:])
half_height = ((max(ys) - min(ys)) / 2.0 if ys else 5.0) * CROSS_MM
width = 2.0 * max(
(x1 - x0) / 2.0 * CROSS_MM + _BORDER_PAD_MM,
cross_table_width() / 2.0 + _BORDER_PAD_MM,
)
# 위: 단면 반높이 + 테두리 여유 / 아래: 반높이 + (표 간격 + 표) + 테두리 여유
top = half_height + _BORDER_PAD_MM
below = half_height + _BORDER_PAD_MM
if with_table:
below += 8.0 + cross_table_height()
return (width, top + below)
def _stack_columns(group: list[tuple[int | float, float, float]]) -> list[list[int]]:
"""블록 순번을 열로 묶는다 — 아래에서 위로 쌓다 높이가 넘치면 오른쪽 새 열."""
_usable_w, usable_h = usable_area()
columns: list[list[int]] = []
height = 0.0
for index, (_c, _w, block_h) in enumerate(group):
if not columns or height + block_h > usable_h + 1e-6:
columns.append([])
height = 0.0
columns[-1].append(index)
height += block_h
return columns
def _fits(group: list[tuple[int | float, float, float]]) -> bool:
"""group 을 한 장에 담을 수 있나 — 열 폭 합이 가로를, 열마다 높이 합이 세로를 넘지 않는다."""
usable_w, usable_h = usable_area()
columns = _stack_columns(group)
width = sum(max(group[i][1] for i in column) for column in columns)
tallest = max(sum(group[i][2] for i in column) for column in columns)
return width <= usable_w + 1e-6 and tallest <= usable_h + 1e-6
def _max_take(blocks: list[tuple[int | float, float, float]], start: int) -> int:
"""blocks[start:] 를 한 장에 담을 수 있는 최대 개수."""
limit = 0
for take in range(1, len(blocks) - start + 1):
if not _fits(blocks[start : start + take]):
break
limit = take
return limit
def _sheet_breaks(blocks: list[tuple[int, float, float]]) -> list[int]:
"""장 경계를 **전체 최소 장수**가 되도록 고른다 (측점 순서는 유지).
앞에서부터 최대한 채우면 바로 뒤에 큰 단면이 오는 순간 칸이 그 단면 크기로
튀어 그 장이 통째로 비었다(2026-09-04 실측: 6칸짜리 장에 1개만 배치). 단면
크기는 측점마다 원지반 기울기로 달라지므로, 경계를 뒤에서부터 훑어 최소 장수
조합을 고른다 — 큰 단면은 자기 장에 몰리고 비슷한 크기끼리 한 장에 모인다.
같은 장수면 **앞 장을 더 많이 채우는 쪽**을 고른다(뒷장에 여백을 몰아 준다).
"""
total = len(blocks)
best_sheets = [0] * (total + 1)
best_take = [0] * (total + 1)
for start in range(total - 1, -1, -1):
limit = max(_max_take(blocks, start), 1)
choice = (total + 1, 0)
for take in range(1, limit + 1):
candidate = (best_sheets[start + take] + 1, -take)
if candidate < choice:
choice = candidate
best_sheets[start], best_take[start] = choice[0], -choice[1]
breaks: list[int] = []
start = 0
while start < total:
breaks.append(best_take[start])
start += best_take[start]
return breaks
def _slots(group: list[tuple[int | float, float, float]]) -> list[list[float]]:
"""블록의 (가로 중심, 아래 변) — 좌하단부터 아래→위로 쌓고, 열이 차면 오른쪽 열.
블록은 자기 크기 그대로 맞닿게 쌓는다 — 칸을 장 최대 블록으로 통일하면 작은
단면의 테두리가 그림에서 수십 mm 떨어졌다(68-1). 열 폭은 그 열 최대 블록 폭이고
블록은 열 가운데에 선다.
"""
usable_w, usable_h = usable_area()
slots: list[list[float]] = [[0.0, 0.0] for _ in group]
left = -usable_w / 2.0
for column in _stack_columns(group):
column_w = max(group[i][1] for i in column)
bottom = -usable_h / 2.0
for i in column:
slots[i] = [left + column_w / 2.0, bottom]
bottom += group[i][2]
left += column_w
return slots
def plan_cross_sheets(blocks: list[tuple[int | float, float, float]]) -> list[dict[str, Any]]:
"""(측점, 폭, 높이) 목록을 A1 장으로 나눈다.
블록은 자기 크기(그림 + 테두리 1 mm)로 열마다 쌓는다. 작성 척도는 1/100 고정 — 안
들어가면 장을 나눌 뿐 줄이지 않는다(지식DB 「설계제원_총괄」 측량·도면 기준). 장에
담기는 측점 수는 세트마다 다르다(2026-09-04 사용자 확정).
"""
sheets: list[dict[str, Any]] = []
start = 0
for count in _sheet_breaks(blocks):
group = blocks[start : start + count]
chainages = [chainage for chainage, _w, _h in group]
sheets.append(
{
# id는 **시작 측점**으로 짓는다. 순번(`cross_s01`)으로 지으면 B06 설계가
# 바뀌어 한 장에 담기는 측점 수가 달라졌을 때 같은 이름이 다른 구간을
# 가리키고, 옛 확정 표시가 그대로 새 구간에 붙는다(2026-09-01 지적).
"id": f"cross_s{cross_key(chainages[0])}m",
"number": len(sheets) + 1,
"chainages": chainages,
"slots": _slots(group),
}
)
start += count
return sheets
def build_cross_sheet(sheet: dict[str, Any], sections: list[dict[str, Any]]) -> dict[str, Any]:
"""한 장을 만든다. sections는 이 장에 담을 측점 입력들(배치 순서대로)."""
entities: list[dict[str, Any]] = []
placements: list[dict[str, Any]] = []
slots = sheet.get("slots") or []
for index, section in enumerate(sections):
if index >= len(slots):
break
seed_id = f"{sheet['id']}:{section['chainage']}"
# 1) 원점(0,0)에 한 번 만들어 블록이 원점 대비 어디에 놓이는지 잰다.
probe = build_cross_drawing(
section["source"],
seed_id,
section.get("design_line"),
section.get("design"),
section.get("quantity_table"),
section.get("title", ""),
overlay=section.get("overlay"),
)
bbox = entities_bbox(probe["entities"])
if bbox is None:
continue
min_x, min_y, max_x, max_y = bbox
center_x, bottom_y = slots[index]
# 2) 가로는 칸 가운데, 세로는 칸 아래 변에 테두리 아래 변을 맞춰 다시 만든다.
# 테두리는 자기 그림 + 1 mm — 칸 크기로 넓히지 않는다(68-1).
origin = (center_x - (min_x + max_x) / 2.0, bottom_y - min_y)
placed = build_cross_drawing(
section["source"],
seed_id,
section.get("design_line"),
section.get("design"),
section.get("quantity_table"),
section.get("title", ""),
origin=origin,
overlay=section.get("overlay"),
)
entities.extend(placed["entities"])
placements.extend(placed.get("cross_placements") or [])
bbox = entities_bbox(entities)
if bbox:
entities.extend(
frame_entities(
sheet["id"],
bbox,
fit=False,
fields={"도면명": "횡단면도", **scale_fields(("", DRAWING_SCALE_CROSS))},
)
)
return {
"format": DRAWING_FORMAT,
"entities": entities,
"cross_placements": placements,
"layers": [
_layer(GROUND_LAYER_ID, "원지반", locked=True),
_layer(DESIGN_LAYER_ID, "계획선"),
_layer(STRUCTURE_LAYER_ID, "구조물"),
_layer(ROCK_LAYER_ID, "암 경계선"),
_layer(CROSS_TABLE_LAYER_ID, "수량 산출표"),
_layer(FRAME_LAYER_ID, "도각", locked=True),
],
}