fix(B07): 횡단면도 칸 크기 통일 — 축척 1/100 유지

실측 결과 축척은 문제가 아니었음. 실거리 3 m 가 모든 단면에서 30.0 mm
(mm/m = 10.0 단일값). 뒤죽박죽으로 보인 원인은 칸 크기 — 한 장 안에서
폭 116~218 mm, 높이 29~178 mm 로 제각각이었음.

- 장별 완전 통일: 한 장 안 모든 칸을 그 장 최대 블록 크기로 통일(_grid_for).
  테두리는 build_cross_drawing(cell_frame=...) 로 칸에 맞춰 그림.
- 장 경계는 전체 최소 장수가 되도록 동적계획으로 선택(_sheet_breaks).
  앞에서부터 채우면 바로 뒤의 큰 단면이 칸을 키워 6칸짜리 장에 1개만
  실리는 낭비가 있었음.
- 축척은 지식DB 「설계제원_총괄」 기준 1/100 고정 — 어떤 경우에도 줄이지 않고
  안 들어가면 장을 나눔. 장수는 세트마다 달라짐(2026-09-04 사용자 확정).

검증(용화_LAS): 장 21개 전부 칸 크기 단일값, mm/m = 10 단일, 측점 65개
누락 0, 칸 밖 이탈 0.000 mm. 실서버 API 로도 동일 확인.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-04 18:12:54 +09:00
co-authored by Claude Opus 5
parent 92441eb8c9
commit 022908abed
2 changed files with 96 additions and 64 deletions
+19 -15
View File
@@ -443,6 +443,7 @@ def build_cross_drawing(
design_elevation_m: float | None = None,
frame: dict[str, float] | None = None,
origin: tuple[float, float] = (0.0, 0.0),
cell_frame: tuple[float, float, float, float] | None = None,
) -> dict[str, Any]:
"""횡단도 한 장을 지표/설계/구조물(+암 경계) 레이어 + CAD 수량 산출표로 만든다.
@@ -450,6 +451,10 @@ def build_cross_drawing(
설계선·구조물이 원지반과 갈라지는 구간 + 여유다. 세로는 이 단면 선들의
bbox 중심을 0에 둔다(측점마다 화면 중앙 정렬). design_elevation_m는 현재
배치에 쓰지 않지만 향후 표고 주석용으로 시그니처를 유지한다.
cell_frame(왼쪽, 아래, 오른쪽, 위 — 종이 mm)을 주면 테두리를 그 칸에 맞춰
그린다. 장 배치에서 한 장 안의 칸을 같은 크기로 통일할 때 쓴다(2026-09-04
사용자 확정 — 축척 1/100은 그대로, 칸만 통일).
"""
ox, oy = origin
raw_ground = points_from_samples(source.get("samples", []), "offset_m")
@@ -511,16 +516,20 @@ def build_cross_drawing(
)
table_bottom = table_top - cross_table_height()
# 외곽 테두리: 단면 범위와 표를 함께 감싼다.
frame_x = max((x1 - x0) / 2.0 * CROSS_MM + 4.0, cross_table_width() / 2.0 + 4.0)
frame_top = oy + half_height + 4.0
frame_bottom = table_bottom - 4.0
# 외곽 테두리: 단면 범위와 표를 함께 감싼다. 칸 크기를 받았으면 그 칸에 맞춘다.
if cell_frame is not None:
frame_left, frame_bottom, frame_right, frame_top = cell_frame
else:
frame_x = max((x1 - x0) / 2.0 * CROSS_MM + 4.0, cross_table_width() / 2.0 + 4.0)
frame_left, frame_right = center_x - frame_x, center_x + frame_x
frame_top = oy + half_height + 4.0
frame_bottom = table_bottom - 4.0
corners = [
(center_x - frame_x, frame_bottom),
(center_x + frame_x, frame_bottom),
(center_x + frame_x, frame_top),
(center_x - frame_x, frame_top),
(center_x - frame_x, frame_bottom),
(frame_left, frame_bottom),
(frame_right, frame_bottom),
(frame_right, frame_top),
(frame_left, frame_top),
(frame_left, frame_bottom),
]
border = polyline_entity(drawing_id, corners, FRAME_LAYER_ID, TABLE_LINE_COLOR)
if border:
@@ -542,12 +551,7 @@ def build_cross_drawing(
"x1": x1,
# 블록 테두리(종이 mm). 프론트가 자기 그림을 이 안으로 자르고, 갈아 끼울
# 서버 설계선을 이 안에서만 골라내는 데 쓴다.
"frame": [
center_x - frame_x,
frame_bottom,
center_x + frame_x,
frame_top,
],
"frame": [frame_left, frame_bottom, frame_right, frame_top],
}
],
"layers": [
@@ -84,72 +84,85 @@ def section_block_size(
return (width, top + below)
def _pack(
blocks: list[tuple[int, float, float]], start: int, rows: int
) -> tuple[int, list[float], list[float]]:
"""blocks[start:]를 rows행 **열 우선**으로 담아 (담은 개수, 열폭, 행높이)를 낸다.
열폭은 그 열에 든 블록의 최대폭, 행높이는 그 행에 든 블록의 최대높이다 — 칸을
전체 최대치로 통일하지 않으면서 행·열은 맞춘다(2026-08-30 사용자 확정).
"""
def _grid_for(group: list[tuple[int, float, float]]) -> tuple[float, float, int, int]:
"""한 장에 담을 블록 묶음의 (칸폭, 칸높이, 열수, 행수) — 칸은 그 장 최대 블록 기준."""
usable_w, usable_h = usable_area()
col_widths: list[float] = []
row_heights: list[float] = [0.0] * rows
count = 0
for index, (_chainage, width, height) in enumerate(blocks[start:]):
column, row = divmod(index, rows)
current = col_widths[column] if column < len(col_widths) else 0.0
new_col = max(current, width + _BLOCK_GAP_MM)
new_row = max(row_heights[row], height + _BLOCK_GAP_MM)
if sum(col_widths[:column]) + new_col > usable_w:
break
if sum(row_heights) - row_heights[row] + new_row > usable_h:
break
if column < len(col_widths):
col_widths[column] = new_col
else:
col_widths.append(new_col)
row_heights[row] = new_row
count = index + 1
return count, col_widths, row_heights
cell_w = max(width for _c, width, _h in group) + _BLOCK_GAP_MM
cell_h = max(height for _c, _w, height in group) + _BLOCK_GAP_MM
return cell_w, cell_h, int(usable_w // cell_w), int(usable_h // cell_h)
def _slots(col_widths: list[float], row_heights: list[float], count: int) -> list[list[float]]:
def _max_take(blocks: list[tuple[int, float, float]], start: int) -> int:
"""blocks[start:] 를 한 장에 담을 수 있는 최대 개수(칸 통일 기준)."""
limit = 0
for take in range(1, len(blocks) - start + 1):
_cw, _ch, columns, rows = _grid_for(blocks[start : start + take])
if columns * rows < 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(cell_w: float, cell_h: float, rows: int, count: int) -> list[list[float]]:
"""칸의 (가로 중심, 아래 변) — 좌하단부터 아래→위로 채우고, 열이 차면 오른쪽 열.
세로는 중심이 아니라 **아래 변**을 준다. 수량표 높이는 모든 블록이 같으므로
아래를 맞추면 같은 행의 표가 한 줄로 선다(2026-08-30 사용자: 표는 행·열을 맞춘다).
칸이 모두 같은 크기이므로 격자 좌표만 계산하면 된다(2026-08-29 사용자: 채우는
순서는 좌하단부터 열 우선).
"""
usable_w, usable_h = usable_area()
rows = len(row_heights)
slots: list[list[float]] = []
for index in range(count):
column, row = divmod(index, rows)
x = -usable_w / 2.0 + sum(col_widths[:column]) + col_widths[column] / 2.0
y = -usable_h / 2.0 + sum(row_heights[:row])
slots.append([x, y])
slots.append(
[
-usable_w / 2.0 + column * cell_w + cell_w / 2.0,
-usable_h / 2.0 + row * cell_h,
]
)
return slots
def plan_cross_sheets(blocks: list[tuple[int, float, float]]) -> list[dict[str, Any]]:
"""(측점, 폭, 높이) 목록을 A1 장으로 나눈다.
블록 크기를 먼저 재서 **가장 많이 담기는 행 수**를 고르고, 그 행·열 격자에
담는다(2026-08-30 사용자 지시 — 전체 최대치 통일은 여백이 너무 많았다).
한 장 안의 칸은 모두 같은 크기(그 장 최대 블록 기준)이고 빈 곳은 여백으로 둔다.
작성 척도는 1/100 고정 — 안 들어가면 장을 나눌 뿐 줄이지 않는다(지식DB
「설계제원_총괄」 측량·도면 기준). 장에 담기는 측점 수는 세트마다 다르다
(2026-09-04 사용자 확정).
"""
sheets: list[dict[str, Any]] = []
start = 0
while start < len(blocks):
best: tuple[int, list[float], list[float]] = (0, [], [])
for rows in range(1, len(blocks) - start + 1):
packed = _pack(blocks, start, rows)
if packed[0] > best[0]:
best = packed
count, col_widths, row_heights = best
if count == 0: # 한 칸도 못 담을 만큼 큰 블록 — 그래도 한 장에 하나는 놓는다.
count, col_widths, row_heights = 1, [blocks[start][1]], [blocks[start][2]]
for count in _sheet_breaks(blocks):
group = blocks[start : start + count]
number = len(sheets) + 1
cell_w, cell_h, _columns, rows = _grid_for(group)
chainages = [chainage for chainage, _w, _h in group]
sheets.append(
{
@@ -157,10 +170,12 @@ def plan_cross_sheets(blocks: list[tuple[int, float, float]]) -> list[dict[str,
# 바뀌어 한 장에 담기는 측점 수가 달라졌을 때 같은 이름이 다른 구간을
# 가리키고, 옛 확정 표시가 그대로 새 구간에 붙는다(2026-09-01 지적).
"id": f"cross_s{chainages[0]:05d}m",
"number": number,
"number": len(sheets) + 1,
"chainages": chainages,
"rows": len(row_heights),
"slots": _slots(col_widths, row_heights, count),
"rows": max(rows, 1),
"cell_width": cell_w,
"cell_height": cell_h,
"slots": _slots(cell_w, cell_h, max(rows, 1), count),
}
)
start += count
@@ -172,6 +187,8 @@ def build_cross_sheet(sheet: dict[str, Any], sections: list[dict[str, Any]]) ->
entities: list[dict[str, Any]] = []
placements: list[dict[str, Any]] = []
slots = sheet.get("slots") or []
cell_w = float(sheet.get("cell_width") or 0.0)
cell_h = float(sheet.get("cell_height") or 0.0)
for index, section in enumerate(sections):
if index >= len(slots):
@@ -197,6 +214,16 @@ def build_cross_sheet(sheet: dict[str, Any], sections: list[dict[str, Any]]) ->
center_x - (min_x + max_x) / 2.0,
bottom_y + _BLOCK_GAP_MM / 2.0 - min_y,
)
# 3) 테두리는 칸 크기로 통일한다 — 단면 크기와 무관하게 한 장 안에서 같은 크기.
cell_frame = None
if cell_w > 0.0 and cell_h > 0.0:
half = (cell_w - _BLOCK_GAP_MM) / 2.0
cell_frame = (
center_x - half,
bottom_y + _BLOCK_GAP_MM / 2.0,
center_x + half,
bottom_y + cell_h - _BLOCK_GAP_MM / 2.0,
)
placed = build_cross_drawing(
section["source"],
seed_id,
@@ -205,6 +232,7 @@ def build_cross_sheet(sheet: dict[str, Any], sections: list[dict[str, Any]]) ->
section.get("quantity_table"),
section.get("title", ""),
origin=origin,
cell_frame=cell_frame,
)
entities.extend(placed["entities"])
placements.extend(placed.get("cross_placements") or [])