Files
Aislo/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Sheet.py
T
eomsangdonandClaude Fable 5 17e14a25ed fix(B07): 편집이 사라지지 않게 하고 확정한 도면을 잠근다
사용자 편의성 점검 15건 중 반영 확정분 12건.

편집 손실 방지
- CAD에 미저장 표시(drawingDirty)를 두고, 도면을 바꾸기 전에 묻는다.
  전에는 그은 선이 경고 없이 사라졌다(실측: 캔버스 서명이 원본과 동일).
- 도면 전환을 겹쳐 눌러도 늦게 온 응답이 화면을 덮지 않게 가드를 뒀다.
- 도면층 삭제·객체 이동을 되돌리기에 실어 Ctrl+Z로 돌아오게 했다.

확정한 도면은 읽기 전용
- runCommand 한 곳에서 보는 명령만 통과시킨다(허용 명시 방식).
- 도면을 실을 때 앞 도면의 그리기 도구를 내린다 — 켜 둔 도구가 확정본에도
  계속 그렸다.
- 확정 버튼 자리를 [현재 도면 확정] / [수정]으로 가른다. 확정 해제는 [수정]
  한 곳뿐 — 되돌리기·색 고르기로 확정이 풀리던 경로를 없앴다.

그 밖
- 잠금 도면층(도각·등고선·계류)은 마우스가 스쳐도 하이라이트하지 않는다.
- 준비 중 도면 7종도 빈 도각으로 열린다(눌리지 않는 회색 버튼 제거).
- 도면층 행이 잘리지 않게 패널을 300px로 넓히고 이름을 줄여 담는다.
- 기본 그리기 색을 검정으로 — 흰 종이에 흰 선이라 안 보였다.
- 색 고르는 동안 변경 통지가 연발하지 않는다.
- 수량표는 앞 단계 산출물이라 도면에서 고치지 못하게 막고 안내한다.
- 자동백업 칸을 도면별로 나눈다 — 되살리면 다른 도면에 붙었다.
- 횡단 장 id를 시작 측점 기준으로 바꿔 구간이 달라져도 옛 확정이 안 붙는다.
- 회사 도각 저장 전에 좌표를 검사하고, 기본 도각으로 되돌리기를 연다.
- 횡단 파일이 없어도 나머지 도면 목록은 남는다.
- 불러오는 중·실패를 화면에 표시한다.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 19:05:22 +09:00

227 lines
9.1 KiB
Python

"""B07 횡단면도 장 배치 — A1 한 장에 들어가는 만큼 단면을 담는다.
채우는 순서는 **좌하단부터 아래에서 위로, 그다음 오른쪽 열**이다
(2026-08-29 사용자 확정). 한 장에 몇 개가 들어가는지는 단면 블록의 크기가
정하며, 블록 크기는 작성 척도(1/100)와 "설계선이 원지반과 갈라지는 구간"이
정한다 — 즉 종이가 허용하는 만큼만 담고 남으면 다음 장으로 넘긴다.
"""
import re
from typing import Any
from B07_DesignDetail.B07_DesignDetail_Engine_Cad import (
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,
usable_area,
)
# 장 도면 id — 측점 도면(cross_00020m)과 겹치지 않게 `s`를 끼운다(cross_s00020m).
# 옛 순번 형식(cross_s01)도 읽어 준다 — 이미 확정한 매니페스트가 그 이름을 갖고 있다.
CROSS_SHEET_ID = re.compile(r"cross_s(\d{2,})m?")
# 블록 사이 여백(mm)과 테두리 여유 — build_cross_drawing의 테두리 값과 맞춘다.
_BLOCK_GAP_MM = 8.0
_BORDER_PAD_MM = 4.0
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
) -> tuple[float, float]:
"""단면 블록 하나의 종이 크기(mm) — 엔티티를 만들지 않고 치수만 잰다.
build_cross_drawing의 테두리 계산과 같은 규칙을 쓴다: 가로는 그리는 범위와
수량표 중 넓은 쪽, 세로는 단면 높이 + 수량표.
"""
ground = points_from_samples(source.get("samples", []), "offset_m")
design = _design_points(design_line)
x0, x1 = _section_window(ground, design)
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]
half_height = ((max(ys) - min(ys)) / 2.0 if ys else 5.0) * CROSS_MM + 5.0
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 _pack(
blocks: list[tuple[int, float, float]], start: int, rows: int
) -> tuple[int, list[float], list[float]]:
"""blocks[start:]를 rows행 **열 우선**으로 담아 (담은 개수, 열폭, 행높이)를 낸다.
열폭은 그 열에 든 블록의 최대폭, 행높이는 그 행에 든 블록의 최대높이다 — 칸을
전체 최대치로 통일하지 않으면서 행·열은 맞춘다(2026-08-30 사용자 확정).
"""
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
def _slots(col_widths: list[float], row_heights: list[float], count: int) -> list[list[float]]:
"""칸의 (가로 중심, 아래 변) — 좌하단부터 아래→위로 채우고, 열이 차면 오른쪽 열.
세로는 중심이 아니라 **아래 변**을 준다. 수량표 높이는 모든 블록이 같으므로
아래를 맞추면 같은 행의 표가 한 줄로 선다(2026-08-30 사용자: 표는 행·열을 맞춘다).
"""
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])
return slots
def plan_cross_sheets(blocks: list[tuple[int, float, float]]) -> list[dict[str, Any]]:
"""(측점, 폭, 높이) 목록을 A1 장으로 나눈다.
블록 크기를 먼저 재서 **가장 많이 담기는 행 수**를 고르고, 그 행·열 격자에
담는다(2026-08-30 사용자 지시 — 전체 최대치 통일은 여백이 너무 많았다).
"""
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]]
group = blocks[start : start + count]
number = len(sheets) + 1
chainages = [chainage for chainage, _w, _h in group]
sheets.append(
{
# id는 **시작 측점**으로 짓는다. 순번(`cross_s01`)으로 지으면 B06 설계가
# 바뀌어 한 장에 담기는 측점 수가 달라졌을 때 같은 이름이 다른 구간을
# 가리키고, 옛 확정 표시가 그대로 새 구간에 붙는다(2026-09-01 지적).
"id": f"cross_s{chainages[0]:05d}m",
"number": number,
"chainages": chainages,
"rows": len(row_heights),
"slots": _slots(col_widths, row_heights, count),
}
)
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", ""),
)
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) 가로는 칸 가운데, 세로는 칸 아래 변에 맞춰 원점을 옮겨 다시 만든다.
# 아래를 맞춰야 같은 행의 수량표가 한 줄로 선다.
origin = (
center_x - (min_x + max_x) / 2.0,
bottom_y + _BLOCK_GAP_MM / 2.0 - 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,
)
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={"도면명": "횡단면도"}))
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),
],
}