fix(B07): 횡단면도 박스를 단면마다 자기 그림 + 1 mm 로 — 칸 통일 없앰

- 장 배치: 칸을 그 장 가장 큰 블록으로 통일하던 것을 없애고 블록을 자기 크기로 열마다 쌓음(좌하단부터 아래→위, 차면 오른쪽 열)
- 그리는 가로 범위를 선이 있는 데까지로 — 원지반 자료가 짧으면 빈 여유가 박스를 밀던 것
- 블록 크기에 암 경계선 포함(실제 그림과 같은 규칙)
- 옛 배치로 확정된 장은 저장본을 그대로 보이되 [수정] 안내 알림
- 검증_sub4: 박스 ↔ 그림 66개 모두 1.0 mm · 같은 조건 27장 → 20장

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013rJwJMVL4EE31y77TB11AP
This commit is contained in:
2026-10-06 23:11:14 +09:00
co-authored by Claude Opus 5.5
parent d42efb162b
commit 67bba96227
6 changed files with 196 additions and 70 deletions
@@ -36,6 +36,8 @@ export interface CadDrawing {
x0: number;
x1: number;
}[];
/** 횡단 장 — 옛 배치(칸 통일)로 확정된 저장본. [수정]을 누르면 새 배치로 다시 만든다. */
stale_layout?: boolean;
layers: {
id: string;
name: string;
+10 -14
View File
@@ -450,6 +450,10 @@ def _section_window(
if left is None or right is None:
xs = [x for x, _y in ground] or [-6.0, 6.0]
left, right = min(xs), max(xs)
# 여유는 선이 있는 데까지만 — 원지반 자료가 짧으면 빈 여유가 테두리를 밀어낸다(68-1).
drawn = [x for x, _y in ground] + [x for x, _y in design]
if drawn:
return (max(left - margin_m, min(drawn)), min(right + margin_m, max(drawn)))
return (left - margin_m, right + margin_m)
@@ -483,7 +487,6 @@ 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 수량 산출표로 만든다.
@@ -491,10 +494,6 @@ 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")
@@ -556,15 +555,12 @@ def build_cross_drawing(
)
table_bottom = table_top - cross_table_height()
# 외곽 테두리: 단면 범위와 표를 함께 감싼다. 칸 크기를 받았으면 그 칸에 맞춘다.
if cell_frame is not None:
frame_left, frame_bottom, frame_right, frame_top = cell_frame
else:
pad = CROSS_BORDER_PAD_MM
frame_x = max((x1 - x0) / 2.0 * CROSS_MM + pad, cross_table_width() / 2.0 + pad)
frame_left, frame_right = center_x - frame_x, center_x + frame_x
frame_top = oy + half_height + pad
frame_bottom = table_bottom - pad
# 외곽 테두리: 단면 범위와 표를 함께 감싸고 1 mm 띄운다.
pad = CROSS_BORDER_PAD_MM
frame_x = max((x1 - x0) / 2.0 * CROSS_MM + pad, cross_table_width() / 2.0 + pad)
frame_left, frame_right = center_x - frame_x, center_x + frame_x
frame_top = oy + half_height + pad
frame_bottom = table_bottom - pad
corners = [
(frame_left, frame_bottom),
(frame_right, frame_bottom),
@@ -42,9 +42,7 @@ from config.config_system import DRAWING_SCALE_CROSS
# 옛 순번 형식(cross_s01)도 읽어 준다 — 이미 확정한 매니페스트가 그 이름을 갖고 있다.
CROSS_SHEET_ID = re.compile(r"cross_s(\d{2,}(?:\.\d{2})?)m?")
# 블록 사이 여백(mm)과 테두리 여유 — build_cross_drawing의 테두리 값과 맞춘다.
# 여백 0 = 이웃 칸의 테두리가 맞닿는다(2026-09-04 사용자 지시 — 박스 사이 간격 제거).
_BLOCK_GAP_MM = 0.0
# 테두리 여유 — build_cross_drawing의 테두리 값과 맞춘다. 블록은 서로 맞닿게 쌓는다.
_BORDER_PAD_MM = CROSS_BORDER_PAD_MM
@@ -61,18 +59,25 @@ def _design_points(design_line: list[Any] | None) -> list[tuple[float, float]]:
def section_block_size(
source: dict[str, Any], design_line: list[Any] | None, with_table: bool = True
source: dict[str, Any],
design_line: list[Any] | None,
with_table: bool = True,
design: dict[str, Any] | 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)
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)
half_height = ((max(ys) - min(ys)) / 2.0 if ys else 5.0) * CROSS_MM
width = 2.0 * max(
@@ -87,20 +92,34 @@ def section_block_size(
return (width, top + below)
def _grid_for(group: list[tuple[int, float, float]]) -> tuple[float, float, int, int]:
"""한 장에 담을 블록 묶음의 (칸폭, 칸높이, 열수, 행수) — 칸은 그 장 최대 블록 기준."""
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()
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)
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]], start: int) -> int:
"""blocks[start:] 를 한 장에 담을 수 있는 최대 개수(칸 통일 기준)."""
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):
_cw, _ch, columns, rows = _grid_for(blocks[start : start + take])
if columns * rows < take:
if not _fits(blocks[start : start + take]):
break
limit = take
return limit
@@ -134,38 +153,37 @@ def _sheet_breaks(blocks: list[tuple[int, float, float]]) -> list[int]:
return breaks
def _slots(cell_w: float, cell_h: float, rows: int, count: int) -> list[list[float]]:
"""칸의 (가로 중심, 아래 변) — 좌하단부터 아래→위로 채우고, 열이 차면 오른쪽 열.
def _slots(group: list[tuple[int | float, float, float]]) -> list[list[float]]:
"""블록의 (가로 중심, 아래 변) — 좌하단부터 아래→위로 쌓고, 열이 차면 오른쪽 열.
칸이 모두 같은 크기이므로 격자 좌표만 계산하면 된다(2026-08-29 사용자: 채우는
순서는 좌하단부터 열 우선).
블록은 자기 크기 그대로 맞닿게 쌓는다 — 칸을 장 최대 블록으로 통일하면 작은
단면의 테두리가 그림에서 수십 mm 떨어졌다(68-1). 열 폭은 그 열 최대 블록 폭이고
블록은 열 가운데에 선다.
"""
usable_w, usable_h = usable_area()
slots: list[list[float]] = []
for index in range(count):
column, row = divmod(index, rows)
slots.append(
[
-usable_w / 2.0 + column * cell_w + cell_w / 2.0,
-usable_h / 2.0 + row * cell_h,
]
)
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/100 고정 — 안 들어가면 장을 나눌 뿐 줄이지 않는다(지식DB
「설계제원_총괄」 측량·도면 기준). 장에 담기는 측점 수는 세트마다 다르다
(2026-09-04 사용자 확정).
블록은 자기 크기(그림 + 테두리 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]
cell_w, cell_h, _columns, rows = _grid_for(group)
chainages = [chainage for chainage, _w, _h in group]
sheets.append(
{
@@ -175,10 +193,7 @@ def plan_cross_sheets(blocks: list[tuple[int | float, float, float]]) -> list[di
"id": f"cross_s{cross_key(chainages[0])}m",
"number": len(sheets) + 1,
"chainages": chainages,
"rows": max(rows, 1),
"cell_width": cell_w,
"cell_height": cell_h,
"slots": _slots(cell_w, cell_h, max(rows, 1), count),
"slots": _slots(group),
}
)
start += count
@@ -190,8 +205,6 @@ 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):
@@ -211,22 +224,9 @@ def build_cross_sheet(sheet: dict[str, Any], sections: list[dict[str, Any]]) ->
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,
)
# 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,
)
# 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,
@@ -235,7 +235,6 @@ 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 [])
@@ -103,6 +103,8 @@ WATERSHED_ID = "watershed"
COVER_ID = "cover"
# 표준 횡단면도 — 노선 자료가 아니라 B06 표준 횡단면 설정값으로 그리는 한 장.
CROSS_STANDARD_ID = "cross_standard"
# 횡단 장 배치 판 — 단면마다 자기 그림 + 1 mm 박스(68-1). 이 판 전에 확정한 장은 옛 배치다.
CROSS_SHEET_LAYOUT = 2
def _drawing_list(
@@ -229,7 +231,7 @@ def _cross_sheet_plan(
chainage = cross_station(source.get("chainage_m") or parse_cross_key(match.group(1)))
design = (designs or {}).get(chainage)
design_line = _cross_design_line(longitudinal_path, source, design)
width, height = section_block_size(source, design_line)
width, height = section_block_size(source, design_line, design=design)
blocks.append((chainage, width, height))
return plan_cross_sheets(blocks)
@@ -413,6 +415,8 @@ def _read_drawing(
else:
kind = "longitudinal" if _LONG_ID.fullmatch(drawing_id) else "cross"
label = str(manifest_entry.get("label") or drawing_id)
if kind == "cross" and manifest_entry.get("layout") != CROSS_SHEET_LAYOUT:
saved["stale_layout"] = True # 화면이 [수정] → 새 배치로 다시 만들기를 안내
stored_table = manifest_entry.get("quantity_table")
table = stored_table if kind == "cross" and isinstance(stored_table, dict) else None
return kind, label, saved, True, table
@@ -585,6 +589,8 @@ def _store_confirmed_drawing(
"confirmed": True,
"file": f"drawings/{item.id}.json",
}
if item.kind == "cross":
entry["layout"] = CROSS_SHEET_LAYOUT
if item.kind == "cross" and isinstance(quantity_table, dict):
entry["quantity_table"] = quantity_table
if isinstance(quantity_tables, dict) and quantity_tables:
@@ -272,6 +272,12 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
applyConfirmButtonState();
updateInfoPanel(drawing, response);
cad.load(response.drawing, buildMeta(drawing, response, index));
if (response.drawing.stale_layout) {
showToast(
"옛 배치로 확정된 장입니다. [수정]을 누르면 박스를 그림에 1 mm로 붙인 새 배치로 다시 만듭니다.",
"info",
);
}
} catch (error) {
cad.fail(error instanceof Error ? error.message : "CAD 도면을 불러오지 못했습니다.");
if (currentDrawing) highlightActive(currentDrawing.id);
@@ -32,3 +32,120 @@ def test_frame_hugs_section_by_one_mm() -> None:
width, height = section_block_size(_SOURCE, None, with_table=False)
assert height == pytest.approx(f_max_y - f_min_y)
assert width == pytest.approx(f_max_x - f_min_x)
# ── 68-1 장 배치: 단면마다 자기 박스(칸 통일 없음) ──────────────────────────────
from B07_DesignDetail.B07_DesignDetail_Engine_Cad import CROSS_SECTION_MARGIN_M # noqa: E402
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Sheet import ( # noqa: E402
build_cross_sheet,
plan_cross_sheets,
)
def _source(chainage: float, slope: float, half: int = 10) -> dict:
return {
"chainage_m": chainage,
"samples": [
{"valid": True, "offset_m": float(x), "elevation_m": 100.0 + slope * x}
for x in range(-half, half + 1)
],
}
def _box(entity: dict) -> tuple[float, float, float, float] | None:
if entity.get("type") == "Table":
shape = entity["shapeData"]
origin = shape["origin"]
return (
origin["x"],
origin["y"] - sum(shape["rowHeights"]),
origin["x"] + sum(shape["columnWidths"]),
origin["y"],
)
return entities_bbox([entity])
_TABLE = {"ground": 100.0, "planned": 100.0}
def test_each_block_hugs_its_own_drawing_by_one_mm() -> None:
"""크기 다른 단면 셋을 한 장에 — 박스마다 자기 그림(표 포함)에서 사방 1.0 mm."""
sources = [_source(0.0, 0.05), _source(20.0, 0.6, 15), _source(40.0, 0.2, 6)]
blocks = [(s["chainage_m"], *section_block_size(s, None, design=None)) for s in sources]
sheets = plan_cross_sheets(blocks)
assert len(sheets) == 1
sections = [
{"chainage": s["chainage_m"], "source": s, "quantity_table": _TABLE, "title": ""}
for s in sources
]
drawing = build_cross_sheet(sheets[0], sections)
inner = [_box(e) for e in drawing["entities"] if e.get("layerId") != FRAME_LAYER_ID]
frames = [p["frame"] for p in drawing["cross_placements"]]
sizes = {(round(f[2] - f[0], 1), round(f[3] - f[1], 1)) for f in frames}
assert len(sizes) == 3 # 칸 통일 없음 — 박스 크기가 단면마다 다르다
for left, bottom, right, top in frames:
mine = [
b
for b in inner
if b
and b[0] >= left - 1e-6
and b[2] <= right + 1e-6
and b[1] >= bottom - 1e-6
and b[3] <= top + 1e-6
]
assert min(b[0] for b in mine) - left == pytest.approx(1.0)
assert right - max(b[2] for b in mine) == pytest.approx(1.0)
assert min(b[1] for b in mine) - bottom == pytest.approx(1.0)
assert top - max(b[3] for b in mine) == pytest.approx(1.0)
def test_small_blocks_are_not_widened_by_a_big_one() -> None:
"""큰 단면 하나 + 작은 단면 여럿 — 칸을 큰 블록으로 통일하던 때보다 한 장에 더 담긴다."""
blocks = [(0.0, 300.0, 300.0)] + [(float(i), 110.0, 60.0) for i in range(1, 30)]
sheets = plan_cross_sheets(blocks)
# 옛 배치(칸 300×300 → A1 739×499 에 2×1)는 첫 장에 2개였다.
assert len(sheets[0]["chainages"]) > 2
assert sum(len(s["chainages"]) for s in sheets) == 30
def test_window_stops_where_lines_end() -> None:
"""원지반 자료가 여유(1.5 m)보다 짧으면 범위를 선 끝에서 멈춘다 — 빈 여유가 박스를 밀지 않음."""
source = _source(0.0, 0.1, 6)
design_line = [
{"offset_m": -6.0, "elevation_m": 99.4},
{"offset_m": -5.5, "elevation_m": 98.0},
{"offset_m": 5.5, "elevation_m": 98.0},
{"offset_m": 6.0, "elevation_m": 100.6},
]
drawing = build_cross_drawing(source, "cross_00000m", design_line, None, None, "")
placement = drawing["cross_placements"][0]
assert placement["x0"] == pytest.approx(-6.0)
assert placement["x1"] == pytest.approx(6.0)
assert CROSS_SECTION_MARGIN_M == 1.5
def test_old_confirmed_cross_sheet_is_flagged(tmp_path) -> None:
"""68-1 전에 확정한 횡단 장은 저장본을 그대로 보이되 「옛 배치」 표시를 단다."""
import json
from B07_DesignDetail.B07_DesignDetail_Engine_Cad import DRAWING_FORMAT
from B07_DesignDetail.B07_DesignDetail_Router_Support import (
CROSS_SHEET_LAYOUT,
_read_drawing,
)
stage = tmp_path / "B07_DesignDetail"
(stage / "drawings").mkdir(parents=True)
saved = {"format": DRAWING_FORMAT, "entities": [], "layers": []}
(stage / "drawings" / "cross_s00000m.json").write_text(json.dumps(saved), encoding="utf-8")
entry = {"kind": "cross", "label": "횡단면도 1장", "confirmed": True}
manifest = {"drawings": {"cross_s00000m": entry}}
(stage / "manifest.json").write_text(json.dumps(manifest), encoding="utf-8")
_k, _l, drawing, confirmed, _t = _read_drawing(tmp_path, tmp_path / "x.json", "cross_s00000m")
assert confirmed and drawing.get("stale_layout") is True
entry["layout"] = CROSS_SHEET_LAYOUT
(stage / "manifest.json").write_text(json.dumps(manifest), encoding="utf-8")
_k, _l, drawing, confirmed, _t = _read_drawing(tmp_path, tmp_path / "x.json", "cross_s00000m")
assert confirmed and "stale_layout" not in drawing