From a504f561adb64ef070a1606f4da6e4db45a9f098 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 9 Oct 2026 14:56:34 +0900 Subject: [PATCH] =?UTF-8?q?feat(B07):=20=ED=9A=A1=EB=8B=A8=EB=A9=B4?= =?UTF-8?q?=EB=8F=84=20=EC=9E=A5=20=EB=B0=B0=EC=B9=98=20=EC=9E=A5=EB=A7=88?= =?UTF-8?q?=EB=8B=A4=20=EC=97=B4=20=C3=97=20=ED=96=89=20=EC=B9=B8=20?= =?UTF-8?q?=EA=B0=80=EC=9A=B4=EB=8D=B0=20=C2=B7=20=EB=8F=84=EA=B0=81=20?= =?UTF-8?q?=EA=B3=A0=EC=A0=95=20=EA=B7=B8=EB=A6=BC=EC=B9=B8(140-3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 쌓기 · 최소 장수 함수 지움 · grid_slots(n, 열, 행) 칸 가운데(좌하단 → 위 → 오른쪽) · 1/100 그대로 - 저장 B07_DesignDetail/cross_layout.json {default [4,4], sheets {장 번호: [열, 행]}} · GET · PUT cross-layout - 배치 바꾸면 담긴 측점이 바뀐 확정 장 풂 · 그 측점 설계 잠정 · CROSS_SHEET_LAYOUT = 3 - 장 나눔이 설계 · 덧그림과 무관 — 목록 · 확정 · 수정에서 덧그림 재기 빠짐 - 좌측 정보 칸 「배치 열 × 행 [적용] · [모든 장 기본]」 → 다시 세워 같은 장 번호 엶 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TNMXVCZXMpHpVvqmFkeChL --- .../B07_DesignDetail_Api_Fetch.ts | 18 +++ .../B07_DesignDetail_Engine_Cad_Sheet.py | 140 +++++------------ B07_DesignDetail/B07_DesignDetail_Router.py | 81 +++++++--- .../B07_DesignDetail_Router_Support.py | 94 +++++++---- B07_DesignDetail/B07_DesignDetail_UI_Page.ts | 51 +++++- .../B07_DesignDetail_UI_Panels.ts | 53 +++++++ .../B07_DesignDetail_UI_Style.css | 14 ++ resources/tester/test_140_b07_cross_grid.py | 148 ++++++++++++++++++ resources/tester/test_b07_cross_sheet_pad.py | 14 +- 9 files changed, 451 insertions(+), 162 deletions(-) create mode 100644 resources/tester/test_140_b07_cross_grid.py diff --git a/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts b/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts index 162b01c2c..4553de7c9 100644 --- a/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts +++ b/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts @@ -175,6 +175,24 @@ export function invalidateDesignDrawing(projectId: string, drawingId: string): P ); } +/** 횡단 장 배치 — 기본 [열, 행] · 장마다 {장 번호: [열, 행]}(140-3). */ +export interface CrossLayout { + default: [number, number]; + sheets: Record; +} + +export function fetchCrossLayout(projectId: string): Promise { + return requestJson(`/projects/${projectId}/cross-layout`); +} + +/** 배치 저장 — 담긴 측점이 바뀐 확정 장은 서버가 풂. */ +export function saveCrossLayout(projectId: string, layout: CrossLayout): Promise { + return requestJson(`/projects/${projectId}/cross-layout`, { + method: "PUT", + body: JSON.stringify(layout), + }); +} + /** 계획평면도 종류 · 고를 조각 · 대피소 자리 (PLAN 60장). */ export interface PlanKind { id: string; diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Sheet.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Sheet.py index aa460b9be..6155e953e 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Sheet.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Sheet.py @@ -1,9 +1,8 @@ -"""B07 횡단면도 장 배치 — A1 한 장에 들어가는 만큼 단면을 담는다. +"""B07 횡단면도 장 배치 — 장마다 열 × 행 칸(기본 4 × 4)에 단면을 하나씩 담는다(140-3). 채우는 순서는 **좌하단부터 아래에서 위로, 그다음 오른쪽 열**이다 -(2026-08-29 사용자 확정). 한 장에 몇 개가 들어가는지는 단면 블록의 크기가 -정하며, 블록 크기는 작성 척도(1/100)와 "설계선이 원지반과 갈라지는 구간"이 -정한다 — 즉 종이가 허용하는 만큼만 담고 남으면 다음 장으로 넘긴다. +(2026-08-29 사용자 확정). 칸 = 도각 그림칸(usable_area)을 똑같이 나눈 자리 · 단면은 +칸 가운데 · 작성 척도 1/100 그대로(큰 단면이 칸을 넘으면 겹침 — 사용자 배치로 고침). """ import re @@ -95,111 +94,52 @@ def section_block_size( 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 을 한 장에 담을 수 있나 — 열 폭 합이 가로를, 열마다 높이 합이 세로를 넘지 않는다.""" +def grid_slots(n: int, cols: int, rows: int) -> list[list[float]]: + """n 개 칸 가운데 — 그림칸(usable_area)을 열 × 행으로 똑같이 나눔 · 좌하단 → 위 → 오른쪽 열.""" 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 + cell_w, cell_h = usable_w / cols, usable_h / rows + return [ + [ + -usable_w / 2.0 + (index // rows + 0.5) * cell_w, + -usable_h / 2.0 + (index % rows + 0.5) * cell_h, + ] + for index in range(n) + ] -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_grid(layout: dict[str, Any] | None, number: int) -> tuple[int, int]: + """장 번호의 (열, 행) — 장마다 값이 없으면 기본(없으면 4 × 4).""" + layout = layout or {} + grid = (layout.get("sheets") or {}).get(str(number)) or layout.get("default") or (4, 4) + return int(grid[0]), int(grid[1]) -def _sheet_breaks(blocks: list[tuple[int, float, float]]) -> list[int]: - """장 경계를 **전체 최소 장수**가 되도록 고른다 (측점 순서는 유지). +def plan_cross_sheets( + chainages: list[int | float], layout: dict[str, Any] | None = None +) -> list[dict[str, Any]]: + """측점 목록을 A1 장으로 나눈다 — 장마다 열 × 행 칸(기본 4 × 4 · 140-3). - 앞에서부터 최대한 채우면 바로 뒤에 큰 단면이 오는 순간 칸이 그 단면 크기로 - 튀어 그 장이 통째로 비었다(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 사용자 확정). + 단면 크기와 무관하게 칸 수만큼 담는다(큰 단면이 칸을 넘는 겹침은 사용자가 배치로 고침). + 작성 척도는 1/100 고정 — 줄이지 않는다(지식DB 「설계제원_총괄」 측량·도면 기준). """ 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] + while start < len(chainages): + cols, rows = sheet_grid(layout, len(sheets) + 1) + group = list(chainages[start : start + cols * rows]) sheets.append( { - # id는 **시작 측점**으로 짓는다. 순번(`cross_s01`)으로 지으면 B06 설계가 + # id는 **시작 측점**으로 짓는다. 순번(`cross_s01`)으로 지으면 배치가 # 바뀌어 한 장에 담기는 측점 수가 달라졌을 때 같은 이름이 다른 구간을 # 가리키고, 옛 확정 표시가 그대로 새 구간에 붙는다(2026-09-01 지적). - "id": f"cross_s{cross_key(chainages[0])}m", + "id": f"cross_s{cross_key(group[0])}m", "number": len(sheets) + 1, - "chainages": chainages, - "slots": _slots(group), + "chainages": group, + "grid": [cols, rows], + "slots": grid_slots(len(group), cols, rows), } ) - start += count + start += len(group) return sheets @@ -227,10 +167,9 @@ def build_cross_sheet(sheet: dict[str, Any], sections: list[dict[str, Any]]) -> 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) + center_x, center_y = slots[index] + # 2) 블록(테두리 = 자기 그림 + 1 mm) 가운데를 칸 가운데에 맞춰 다시 만든다. + origin = (center_x - (min_x + max_x) / 2.0, center_y - (min_y + max_y) / 2.0) placed = build_cross_drawing( section["source"], seed_id, @@ -244,12 +183,13 @@ def build_cross_sheet(sheet: dict[str, Any], sections: list[dict[str, Any]]) -> entities.extend(placed["entities"]) placements.extend(placed.get("cross_placements") or []) - bbox = entities_bbox(entities) - if bbox: + if entities: + # 도각 = 고정 그림칸 — 내용 크기와 무관하게 칸 격자와 같은 자리(140-3). + usable_w, usable_h = usable_area() entities.extend( frame_entities( sheet["id"], - bbox, + (-usable_w / 2.0, -usable_h / 2.0, usable_w / 2.0, usable_h / 2.0), fit=False, fields={"도면명": "횡단면도", **scale_fields(("", DRAWING_SCALE_CROSS))}, ) diff --git a/B07_DesignDetail/B07_DesignDetail_Router.py b/B07_DesignDetail/B07_DesignDetail_Router.py index 776394703..523906bab 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router.py +++ b/B07_DesignDetail/B07_DesignDetail_Router.py @@ -11,6 +11,7 @@ from uuid import UUID from aiomysql import DictCursor from fastapi import APIRouter from fastapi.responses import JSONResponse +from pydantic import BaseModel, Field from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path from B05_Profile.B05_Profile_Engine_Sections import ( @@ -45,6 +46,8 @@ from B07_DesignDetail.B07_DesignDetail_Router_Support import ( _read_drawing, _read_json, _store_confirmed_drawing, + read_cross_layout, + write_cross_layout, ) from B07_DesignDetail.B07_DesignDetail_Router_Support_Overlay import cross_overlay_extents from B07_DesignDetail.B07_DesignDetail_Router_Support_Plan import PLAN_KIND_ID, plan_kind_source @@ -267,11 +270,7 @@ async def get_design_drawing_list( """B07 좌측 패널용 도면 메타데이터만 캐시한다.""" try: route_id, project_root, longitudinal_path, bypass = await _confirmed_source(project_id) - designs = await _designs_by_chainage(route_id) - overlays = await cross_overlay_extents(project_id, route_id) - drawings = await asyncio.to_thread( - _drawing_list, project_root, longitudinal_path, designs, overlays - ) + drawings = await asyncio.to_thread(_drawing_list, project_root, longitudinal_path) return DesignDrawingListResponse( project_id=str(project_id), route_id=route_id, drawings=drawings, dev_bypass=bypass ) @@ -394,18 +393,13 @@ async def confirm_design_drawing( try: route_id, project_root, longitudinal_path, bypass = await _confirmed_source(project_id) designs = await _designs_by_chainage(route_id) - overlays = await cross_overlay_extents(project_id, route_id) - items = await asyncio.to_thread( - _drawing_list, project_root, longitudinal_path, designs, overlays - ) + items = await asyncio.to_thread(_drawing_list, project_root, longitudinal_path) item = next((candidate for candidate in items if candidate.id == drawing_id), None) if not item: raise FileNotFoundError("확정할 도면을 찾을 수 없습니다.") sheet = None if CROSS_SHEET_ID.fullmatch(drawing_id): - plan = await asyncio.to_thread( - _cross_sheet_plan, project_root, longitudinal_path, designs, overlays - ) + plan = await asyncio.to_thread(_cross_sheet_plan, project_root, longitudinal_path) sheet = next((entry for entry in plan if entry["id"] == drawing_id), None) # 수량표는 CAD 테이블(Text 엔티티)에서 역추출을 우선하고, 없으면 요청 본문 폴백. # 장에는 측점이 여럿이라 측점별로 뽑는다 (엔티티 id 씨앗 = "{장id}:{측점}"). @@ -498,14 +492,8 @@ async def invalidate_design_drawing( """확정 도면 편집 시 B07 및 이후 단계를 미확정 상태로 되돌린다.""" try: route_id, project_root, longitudinal_path, bypass = await _confirmed_source(project_id) - # 목록은 **설계값을 넣어** 만든다 — 장 나눔이 측점 표시 폭에 따라 달라지므로, - # 설계 없이 만들면 방금 확정한 장 id 가 목록에 없어 [수정]이 404 로 막힌다 - # (2026-09-03 실측: `cross_s00220m` 확정 후 확정 해제 불가). - designs = await _designs_by_chainage(route_id) - overlays = await cross_overlay_extents(project_id, route_id) - items = await asyncio.to_thread( - _drawing_list, project_root, longitudinal_path, designs, overlays - ) + # 장 나눔은 배치(열 × 행)만 따름 — 설계 · 덧그림 없이 만들어도 같은 장 id(140-3). + items = await asyncio.to_thread(_drawing_list, project_root, longitudinal_path) if drawing_id not in {item.id for item in items}: raise FileNotFoundError("변경된 도면을 찾을 수 없습니다.") await asyncio.to_thread(_invalidate_drawing, project_root, drawing_id) @@ -515,9 +503,7 @@ async def invalidate_design_drawing( if cross_match: stale = [parse_cross_key(cross_match.group(1))] elif CROSS_SHEET_ID.fullmatch(drawing_id): - plan = await asyncio.to_thread( - _cross_sheet_plan, project_root, longitudinal_path, designs, overlays - ) + plan = await asyncio.to_thread(_cross_sheet_plan, project_root, longitudinal_path) sheet = next((entry for entry in plan if entry["id"] == drawing_id), None) stale = list(sheet["chainages"]) if sheet else [] pool = get_db_pool() @@ -554,3 +540,52 @@ async def invalidate_design_drawing( status_code=500, content={"status": "error", "message": "상세 설계 도면 상태를 되돌리지 못했습니다."}, ) + + +class CrossLayoutRequest(BaseModel): + """횡단 장 배치 — 기본 [열, 행] · 장마다 {장 번호: [열, 행]}(140-3).""" + + default: list[int] = Field(default_factory=lambda: [4, 4]) + sheets: dict[str, list[int]] = Field(default_factory=dict) + + +@router.get("/{project_id}/cross-layout") +async def get_cross_layout(project_id: UUID) -> Any: + """횡단 장 배치(파일 없으면 기본 4 × 4).""" + _route_id, project_root, _longitudinal_path, _bypass = await _confirmed_source(project_id) + return await asyncio.to_thread(read_cross_layout, project_root) + + +@router.put("/{project_id}/cross-layout") +async def put_cross_layout(project_id: UUID, request: CrossLayoutRequest) -> Any: + """횡단 장 배치를 쓴다 — 담긴 측점이 바뀐 확정 장은 풀고 그 측점 설계를 잠정으로([수정] 꼴).""" + try: + route_id, project_root, longitudinal_path, _bypass = await _confirmed_source(project_id) + stale = await asyncio.to_thread( + write_cross_layout, project_root, longitudinal_path, request.model_dump() + ) + if stale: + pool = get_db_pool() + async with pool.acquire() as connection: + await connection.begin() + try: + for chainage in stale: + await merge_cross_section_design_by_round( + connection, + route_id=route_id, + chainage=chainage, + patch={"status": "provisional"}, + ) + async with connection.cursor() as cursor: + await start_stage(cursor, str(project_id), 4) + await connection.commit() + except Exception: + await connection.rollback() + raise + return await asyncio.to_thread(read_cross_layout, project_root) + except ValueError as exc: + return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)}) + except FileNotFoundError as exc: + return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)}) + except PermissionError as exc: + return JSONResponse(status_code=409, content={"status": "error", "message": str(exc)}) diff --git a/B07_DesignDetail/B07_DesignDetail_Router_Support.py b/B07_DesignDetail/B07_DesignDetail_Router_Support.py index 1ddcb240a..ddf43b53b 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router_Support.py +++ b/B07_DesignDetail/B07_DesignDetail_Router_Support.py @@ -34,7 +34,6 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Sheet import ( CROSS_SHEET_ID, build_cross_sheet, plan_cross_sheets, - section_block_size, ) from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Standard import ( STANDARD_LABEL, @@ -82,6 +81,7 @@ from B07_DesignDetail.B07_DesignDetail_Router_Support_Plan import ( from B07_DesignDetail.B07_DesignDetail_Schema import ( DesignDrawingItem, ) +from common_util.common_util_json import atomic_write_json from common_util.common_util_route_profile import design_elevation_from_longitudinal from config.config_system import DRAWING_SCALE_PLAN @@ -97,16 +97,67 @@ MASS_HAUL_ID = "mass_haul" COVER_ID = "cover" # 표준 횡단면도 — 노선 자료가 아니라 B06 표준 횡단면 설정값으로 그리는 한 장. CROSS_STANDARD_ID = "cross_standard" -# 횡단 장 배치 판 — 단면마다 자기 그림 + 1 mm 박스(68-1). 이 판 전에 확정한 장은 옛 배치다. -CROSS_SHEET_LAYOUT = 2 +# 횡단 장 배치 판 — 장마다 열 × 행 칸 가운데(140-3). 이 판 전에 확정한 장은 옛 배치다. +CROSS_SHEET_LAYOUT = 3 +# 장마다 열 × 행 — {default: [열, 행], sheets: {장 번호: [열, 행]}}(140-3). +CROSS_LAYOUT_FILE = "cross_layout.json" +CROSS_GRID_DEFAULT = [4, 4] +_CROSS_GRID_MAX = 10 -def _drawing_list( - project_root: Path, - longitudinal_path: Path, - designs: dict[int | float, dict[str, Any]] | None = None, - overlays: dict[int | float, tuple[float, float, float, float]] | None = None, -) -> list[DesignDrawingItem]: +def _cross_grid(value: Any) -> list[int]: + if ( + not isinstance(value, (list, tuple)) + or len(value) != 2 + or not all(isinstance(n, int) and 1 <= n <= _CROSS_GRID_MAX for n in value) + ): + raise ValueError(f"배치는 열 · 행 1~{_CROSS_GRID_MAX} 사이 정수로 적어 주세요.") + return [int(value[0]), int(value[1])] + + +def read_cross_layout(project_root: Path) -> dict[str, Any]: + """횡단 장 배치 — 파일이 없거나 깨졌으면 기본 4 × 4 만.""" + path = _design_root(project_root) / CROSS_LAYOUT_FILE + try: + return checked_cross_layout(_read_json(path)) + except (OSError, ValueError, AttributeError): + return {"default": list(CROSS_GRID_DEFAULT), "sheets": {}} + + +def checked_cross_layout(layout: dict[str, Any]) -> dict[str, Any]: + sheets = layout.get("sheets") or {} + if not all(str(key).isdigit() and int(key) >= 1 for key in sheets): + raise ValueError("장 번호가 올바르지 않습니다.") + return { + "default": _cross_grid(layout.get("default") or CROSS_GRID_DEFAULT), + "sheets": {str(int(key)): _cross_grid(grid) for key, grid in sheets.items()}, + } + + +def write_cross_layout( + project_root: Path, longitudinal_path: Path, layout: dict[str, Any] +) -> list[int | float]: + """배치를 쓰고 담긴 측점이 바뀐 확정 장을 푼다 — 풀린 장의 옛 측점들(설계 상태를 되돌릴 몫).""" + checked = checked_cross_layout(layout) + before = _cross_sheet_plan(project_root, longitudinal_path) + atomic_write_json(_design_root(project_root) / CROSS_LAYOUT_FILE, checked) + after = { + sheet["id"]: sheet["chainages"] + for sheet in _cross_sheet_plan(project_root, longitudinal_path) + } + manifest = _read_manifest(project_root) + stale: list[int | float] = [] + for sheet in before: + entry = manifest["drawings"].get(sheet["id"]) + if entry and entry.get("confirmed") and after.get(sheet["id"]) != sheet["chainages"]: + entry["confirmed"] = False + stale.extend(sheet["chainages"]) + if stale: + _write_manifest(project_root, manifest) + return stale + + +def _drawing_list(project_root: Path, longitudinal_path: Path) -> list[DesignDrawingItem]: longitudinal = _read_json(longitudinal_path) station_by_chainage = _station_map(longitudinal) manifest_drawings = _read_manifest(project_root)["drawings"] @@ -123,7 +174,7 @@ def _drawing_list( # 횡단 장 계획이 실패해도 나머지 도면은 목록에 남긴다 — 예전에는 `cross_sections/` # 하나가 없으면 표지·종단면도·토적도·유역도까지 함께 사라졌다(2026-09-01 지적). try: - sheets = _cross_sheet_plan(project_root, longitudinal_path, designs, overlays) + sheets = _cross_sheet_plan(project_root, longitudinal_path) except (FileNotFoundError, ValueError, OSError) as exc: logger.warning("B07 횡단 장 계획 실패 — 나머지 도면만 싣는다: %s", exc) sheets = [] @@ -190,32 +241,21 @@ def _store_drawing_numbers(project_root: Path, drawings: list[DesignDrawingItem] _write_manifest(project_root, manifest) -def _cross_sheet_plan( - project_root: Path, - longitudinal_path: Path, - designs: dict[int | float, dict[str, Any]] | None = None, - overlays: dict[int | float, tuple[float, float, float, float]] | None = None, -) -> list[dict[str, Any]]: +def _cross_sheet_plan(project_root: Path, longitudinal_path: Path) -> list[dict[str, Any]]: """측점 도면을 A1 장으로 나눈 계획 — 목록과 도면 생성이 같은 결과를 쓴다. - 한 장에 몇 개가 들어가는지는 단면 블록 크기가 정한다(작성 척도 1/100 + - 설계선이 원지반과 갈라지는 구간). 설계 지정이 없으면 원지반 기준 폭이 된다. + 장마다 열 × 행 칸 수만큼 담는다(140-3 · 단면 크기와 무관 — 설계 · 덧그림에 장 id 가 안 흔들림). """ longitudinal = _read_json(longitudinal_path) - blocks: list[tuple[int, float, float]] = [] + chainages: list[int | float] = [] for path in sorted(_cross_files(longitudinal_path, longitudinal)): match = _CROSS_ID.fullmatch(path.stem) if not match: continue source = _read_json(path) # 측점은 파일 속 값으로 — 옛 프로젝트는 소수 측점 파일이 정수 m 이름이라 이름으로 못 앎 - 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) - overlay = (overlays or {}).get(chainage) # 화면 덧그림 범위(68-3) - width, height = section_block_size(source, design_line, design=design, overlay=overlay) - blocks.append((chainage, width, height)) - return plan_cross_sheets(blocks) + chainages.append(cross_station(source.get("chainage_m") or parse_cross_key(match.group(1)))) + return plan_cross_sheets(chainages, read_cross_layout(project_root)) def _quantity_table( @@ -353,7 +393,7 @@ def _read_cross_sheet( """횡단 장 하나를 만든다. stored_designs는 {측점: 설계 지정}, overlays는 {측점: 덧그림 범위}.""" designs = stored_designs if isinstance(stored_designs, dict) else {} overlays = overlays or {} - plan = _cross_sheet_plan(project_root, longitudinal_path, designs, overlays) + plan = _cross_sheet_plan(project_root, longitudinal_path) sheet = next((item for item in plan if item["id"] == drawing_id), None) if sheet is None: raise FileNotFoundError("요청한 횡단 장을 찾을 수 없습니다.") diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Page.ts b/B07_DesignDetail/B07_DesignDetail_UI_Page.ts index fdc495cc9..a85e64072 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Page.ts +++ b/B07_DesignDetail/B07_DesignDetail_UI_Page.ts @@ -22,6 +22,7 @@ import { createWorkflowLayout } from "@ui/ui_template_workflow_layout"; import { CURRENT_PROJECT_ID_KEY } from "@config/config_frontend"; // 좌측 패널(도면 목록·설계 정보)은 파일이 700줄을 넘어 떼어냈다(2026-09-04). import { + buildCrossLayoutControls, buildDesignInfoPanel, buildDrawingSidePanel, isCrossSheet, @@ -36,14 +37,17 @@ import { import { confirmDesignDrawing, exportDrawing, + fetchCrossLayout, fetchDesignDrawing, fetchDesignDrawingList, fetchFrameTemplate, importFrameTemplate, invalidateDesignDrawing, resetFrameTemplate, + saveCrossLayout, saveFrameTemplate, type CadDrawing, + type CrossLayout, type DesignDrawingItem, type DesignDrawingResponse, type QuantityTable, @@ -81,6 +85,8 @@ function L(key: keyof typeof ui_locales): string { * -------------------------------------------------------------------------- */ /** [평면도 관리]에서 고쳐 화면을 다시 세울 때 — 보던 장 id. 다시 세운 뒤 그 장을 연다(없으면 첫 장). */ let reopenDrawingId: string | undefined; +/** 횡단 장 배치를 바꿔 다시 세울 때 — 보던 장 번호(배치가 바뀌면 장 id 가 바뀜 · 140-3). */ +let reopenCrossSheet: number | undefined; export async function renderB07DesignDetail(root: HTMLElement): Promise { const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY); @@ -138,8 +144,42 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { // 장은 id 가 `cross_s…`(여러 측점 묶음), 측점 도면은 `cross_…m` 이다. `chainage_m` 은 // 장에도 첫 측점 값이 실려 오므로 그것으로는 갈리지 않는다. const scope = isCrossSheet(drawing) ? "sheet" : "station"; - infoPanelHost.replaceChildren( - buildDesignInfoPanel(drawing.label, response.design ?? null, scope), + const panel = buildDesignInfoPanel(drawing.label, response.design ?? null, scope); + infoPanelHost.replaceChildren(panel); + if (scope === "sheet") void appendCrossLayout(drawing, panel); + }; + + /** 장 배치 칸(140-3) — 적용 = 서버 쓰기 → 목록 다시 · 같은 장 번호를 다시 엶. */ + const appendCrossLayout = async (drawing: DesignDrawingItem, panel: HTMLElement) => { + if (!projectId) return; + const number = drawings.filter(isCrossSheet).findIndex((item) => item.id === drawing.id) + 1; + let layout: CrossLayout; + try { + layout = await fetchCrossLayout(projectId); + } catch { + return; // 배치 칸만 빠짐 — 도면 보기는 그대로 + } + if (currentDrawing?.id !== drawing.id || !panel.isConnected) return; + const save = async (next: CrossLayout) => { + if (!mayDiscardEdits()) return; + showLoadingOverlay(); + try { + await saveCrossLayout(projectId, next); + reopenCrossSheet = number; + cad.destroy(); + void renderB07DesignDetail(root); + } catch (error) { + showToast(error instanceof Error ? error.message : "배치를 저장하지 못했습니다.", "error"); + } finally { + hideLoadingOverlay(); + } + }; + panel.append( + buildCrossLayoutControls( + layout.sheets[String(number)] ?? layout.default, + (grid) => void save({ ...layout, sheets: { ...layout.sheets, [String(number)]: grid } }), + (grid) => void save({ default: grid, sheets: {} }), + ), ); }; @@ -523,8 +563,13 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { // 페이지 진입 시 첫 도면(종단도)을 자동 선택 — 빈 CAD 화면 방지. // CAD가 아직 준비 전이면 cad.load 가 붙들고 있다가 ready 때 보낸다. if (drawings.length > 0) { - const reopen = drawings.findIndex((item) => item.id === reopenDrawingId); + const crossSheets = drawings.filter(isCrossSheet); + const reopenId = reopenCrossSheet + ? crossSheets[Math.min(reopenCrossSheet, crossSheets.length) - 1]?.id + : reopenDrawingId; + const reopen = drawings.findIndex((item) => item.id === reopenId); reopenDrawingId = undefined; + reopenCrossSheet = undefined; const first = Math.max(reopen, 0); void openTab(drawings[first]).then(() => { // 첫 장을 띄운 뒤에 나머지를 받는다 — 진입 속도를 뺏지 않는다. diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Panels.ts b/B07_DesignDetail/B07_DesignDetail_UI_Panels.ts index 8e2aa3eb7..9fcda741d 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Panels.ts +++ b/B07_DesignDetail/B07_DesignDetail_UI_Panels.ts @@ -7,6 +7,7 @@ * ========================================================================== */ import { attachCollapsible } from "@ui/ui_template_collapsible"; +import { showToast } from "@ui/ui_template_elements"; import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; import type { CrossDesignInfo, DesignDrawingItem } from "./B07_DesignDetail_Api_Fetch"; import { buildPlanSection } from "./B07_DesignDetail_UI_PlanKinds"; @@ -171,6 +172,58 @@ export function isCrossSheet(drawing: DesignDrawingItem): boolean { return drawing.kind === "cross" && drawing.id.startsWith("cross_s"); } +/** 횡단 장 배치 칸(140-3) — 「배치 열 [4] × 행 [4] [적용]」 + [모든 장 기본]. */ +export function buildCrossLayoutControls( + grid: [number, number], + onApply: (grid: [number, number]) => void, + onAllDefault: (grid: [number, number]) => void, +): HTMLElement { + const box = document.createElement("div"); + box.className = "b07-info__layout"; + const number = (value: number, label: string): HTMLInputElement => { + const input = document.createElement("input"); + input.type = "number"; + input.min = "1"; + input.max = "10"; + input.step = "1"; + input.value = String(value); + input.ariaLabel = label; + return input; + }; + const cols = number(grid[0], "열"); + const rows = number(grid[1], "행"); + const read = (): [number, number] | null => { + const value: [number, number] = [Number(cols.value), Number(rows.value)]; + return value.every((n) => Number.isInteger(n) && n >= 1 && n <= 10) ? value : null; + }; + const action = (text: string, title: string, run: (grid: [number, number]) => void) => { + const node = document.createElement("button"); + node.type = "button"; + node.className = "b07-plan-tools__button"; + node.textContent = text; + node.title = title; + node.addEventListener("click", () => { + const value = read(); + if (value) run(value); + else showToast("열 · 행은 1~10 사이 정수로 적어 주세요.", "error"); + }); + return node; + }; + const label = document.createElement("span"); + label.textContent = "배치 열"; + const times = document.createElement("span"); + times.textContent = "× 행"; + box.append( + label, + cols, + times, + rows, + action("적용", "이 장만 이 배치로", onApply), + action("모든 장 기본", "모든 장을 이 배치로(장마다 값 지움)", onAllDefault), + ); + return box; +} + /** 측구 규격 표시 문자열 (design 신구조: 형식별 ditch spec, F-2 호환). */ function ditchLabel(design: CrossDesignInfo): string { const ditch = design.ditch; diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Style.css b/B07_DesignDetail/B07_DesignDetail_UI_Style.css index f691eabfd..3835e57aa 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Style.css +++ b/B07_DesignDetail/B07_DesignDetail_UI_Style.css @@ -225,6 +225,20 @@ color: var(--color-text-muted); } +/* 횡단 장 배치(140-3) — 배치 열 [4] × 행 [4] [적용] [모든 장 기본] */ +.b07-info__layout { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--spacing-4); + color: var(--color-text-muted); + font-size: var(--text-caption); +} + +.b07-info__layout input { + width: 44px; +} + /* 확정을 건너뛴 개발 상태 알림 — 눈에 띄되 도면 목록을 밀어내지 않게 한 줄만. */ .b07-drawing-list__bypass { margin: var(--spacing-4) 0 0; diff --git a/resources/tester/test_140_b07_cross_grid.py b/resources/tester/test_140_b07_cross_grid.py new file mode 100644 index 000000000..35eb4c799 --- /dev/null +++ b/resources/tester/test_140_b07_cross_grid.py @@ -0,0 +1,148 @@ +# -*- coding: utf-8 -*- +"""140-3 B07 횡단면도 장 배치 — 장마다 열 × 행 칸(기본 4 × 4). + +못박는 것 + - 칸 = 그림칸(usable_area)을 똑같이 나눈 자리 · 차례 = 좌하단 → 위 → 오른쪽 열. + - 측점 73개 · 4 × 4 → 16 · 16 · 16 · 16 · 9 · 17번째 측점 = 2장 첫 칸. + - 장마다 다른 배치(sheets {장 번호: [열, 행]}) · 기본 바꾸기. + - 단면 블록 가운데 = 칸 가운데 · 블록 크기 = 1/100 그대로(section_block_size). + - 도각 = 고정 그림칸 — 내용이 작아도 커도 같은 자리. + - 배치 저장 · 읽기(깨진 값 거절) · 담긴 측점이 바뀐 확정 장만 풂. +""" + +import json +from pathlib import Path + +import pytest + +from B07_DesignDetail.B07_DesignDetail_Engine_Cad import FRAME_LAYER_ID +from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Sheet import ( + build_cross_sheet, + grid_slots, + plan_cross_sheets, + section_block_size, +) +from B07_DesignDetail.B07_DesignDetail_Engine_Template import entities_bbox, usable_area +from B07_DesignDetail.B07_DesignDetail_Router_Support import ( + _cross_sheet_plan, + read_cross_layout, + write_cross_layout, +) +from B07_DesignDetail.B07_DesignDetail_Router_Support_Io import _read_manifest, _write_manifest + + +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 test_grid_slots_bottom_left_up_then_right() -> None: + w, h = usable_area() + slots = grid_slots(6, 4, 4) + cw, ch = w / 4, h / 4 + assert slots[0] == pytest.approx([-w / 2 + cw / 2, -h / 2 + ch / 2]) # 좌하단 + assert slots[1] == pytest.approx([-w / 2 + cw / 2, -h / 2 + 1.5 * ch]) # 그 위 + assert slots[4] == pytest.approx([-w / 2 + 1.5 * cw, -h / 2 + ch / 2]) # 다음 열 맨 아래 + + +def test_73_stations_default_grid_17th_is_sheet_two() -> None: + chainages = [i * 20.0 for i in range(73)] + sheets = plan_cross_sheets(chainages) + assert [len(s["chainages"]) for s in sheets] == [16, 16, 16, 16, 9] + assert sheets[1]["chainages"][0] == chainages[16] # 17번째 측점 = 2장 첫 칸 + assert sheets[1]["number"] == 2 and sheets[1]["grid"] == [4, 4] + assert sheets[1]["id"] == "cross_s00320m" # 장 id = 첫 측점 + + +def test_per_sheet_and_default_layout() -> None: + chainages = [float(i) for i in range(40)] + layout = {"default": [3, 3], "sheets": {"1": [2, 2], "3": [5, 4]}} + sheets = plan_cross_sheets(chainages, layout) + assert [s["grid"] for s in sheets] == [[2, 2], [3, 3], [5, 4], [3, 3]] + assert [len(s["chainages"]) for s in sheets] == [4, 9, 20, 7] + + +def _frame_bbox(drawing: dict) -> tuple: + return entities_bbox([e for e in drawing["entities"] if e.get("layerId") == FRAME_LAYER_ID]) + + +def test_block_centered_in_cell_at_scale_and_frame_fixed() -> None: + table = {"ground": 100.0, "planned": 100.0} + small = [_source(0.0, 0.05, 6)] + big = [_source(0.0, 0.6, 15), _source(20.0, 0.2, 8)] + drawings = [] + for sources in (small, big): + sheet = plan_cross_sheets([s["chainage_m"] for s in sources])[0] + sections = [ + {"chainage": s["chainage_m"], "source": s, "quantity_table": table, "title": ""} + for s in sources + ] + drawing = build_cross_sheet(sheet, sections) + for source, slot, placement in zip( + sources, sheet["slots"], drawing["cross_placements"], strict=True + ): + left, bottom, right, top = placement["frame"] + # 블록 가운데 = 칸 가운데 + assert (left + right) / 2 == pytest.approx(slot[0]) + assert (bottom + top) / 2 == pytest.approx(slot[1]) + # 1/100 그대로 — 블록 크기가 자기 치수 그대로(칸 맞춤으로 줄이지 않음) + width, height = section_block_size(source, None, design=None) + assert right - left == pytest.approx(width, abs=0.5) + drawings.append(drawing) + # 도각 = 고정 그림칸 — 내용 크기와 무관하게 같은 자리 + assert _frame_bbox(drawings[0]) == pytest.approx(_frame_bbox(drawings[1])) + + +def _project(tmp_path: Path, count: int) -> tuple[Path, Path]: + root = tmp_path / "project" + cross_dir = root / "B06_Section" / "cross_sections" + cross_dir.mkdir(parents=True) + longitudinal_path = root / "B06_Section" / "longitudinal" / "longitudinal.json" + longitudinal_path.parent.mkdir(parents=True) + stations = [] + for i in range(count): + chainage = i * 20 + stations.append({"chainage_m": float(chainage), "center_x": 0.0, "center_y": 0.0}) + (cross_dir / f"cross_{chainage:05d}m.json").write_text( + json.dumps(_source(float(chainage), 0.1)), "utf-8" + ) + longitudinal_path.write_text(json.dumps({"stations": stations}), "utf-8") + return root, longitudinal_path + + +def test_layout_store_and_release_changed_confirmed(tmp_path: Path) -> None: + root, longitudinal_path = _project(tmp_path, 20) + assert read_cross_layout(root) == {"default": [4, 4], "sheets": {}} # 파일 없으면 기본 + ids = [s["id"] for s in _cross_sheet_plan(root, longitudinal_path)] + assert ids == ["cross_s00000m", "cross_s00320m"] # 16 + 4 + manifest = _read_manifest(root) + manifest["drawings"] = {i: {"confirmed": True} for i in ids} + _write_manifest(root, manifest) + + # 2장만 3 × 3 — 1장 측점 그대로(확정 유지) · 2장 측점 그대로(4개 < 9칸) → 풀 장 없음 + assert ( + write_cross_layout(root, longitudinal_path, {"default": [4, 4], "sheets": {"2": [3, 3]}}) + == [] + ) + assert read_cross_layout(root)["sheets"] == {"2": [3, 3]} + + # 1장 3 × 3 — 1장 9개로 줄고 2장은 다른 측점에서 시작 → 1장 풂(옛 측점 16개 돌려줌) + stale = write_cross_layout( + root, longitudinal_path, {"default": [4, 4], "sheets": {"1": [3, 3]}} + ) + assert stale[:16] == [i * 20 for i in range(16)] + drawings = _read_manifest(root)["drawings"] + assert drawings["cross_s00000m"]["confirmed"] is False + assert drawings["cross_s00320m"]["confirmed"] is False # 사라진 장 id 도 풂 + new_ids = [s["id"] for s in _cross_sheet_plan(root, longitudinal_path)] + assert new_ids == ["cross_s00000m", "cross_s00180m"] + + with pytest.raises(ValueError): + write_cross_layout(root, longitudinal_path, {"default": [0, 4], "sheets": {}}) + with pytest.raises(ValueError): + write_cross_layout(root, longitudinal_path, {"default": [4, 4], "sheets": {"x": [2, 2]}}) diff --git a/resources/tester/test_b07_cross_sheet_pad.py b/resources/tester/test_b07_cross_sheet_pad.py index dcf0cb1f4..2a7fd9754 100644 --- a/resources/tester/test_b07_cross_sheet_pad.py +++ b/resources/tester/test_b07_cross_sheet_pad.py @@ -72,8 +72,7 @@ _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) + sheets = plan_cross_sheets([s["chainage_m"] for s in sources]) assert len(sheets) == 1 sections = [ {"chainage": s["chainage_m"], "source": s, "quantity_table": _TABLE, "title": ""} @@ -100,13 +99,10 @@ def test_each_block_hugs_its_own_drawing_by_one_mm() -> None: 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_sheets_take_grid_count_not_block_size() -> None: + """140-3 장 나눔 = 칸 수(기본 4 × 4) — 단면 크기와 무관 · 측점 30개면 16 + 14.""" + sheets = plan_cross_sheets([float(i) for i in range(30)]) + assert [len(s["chainages"]) for s in sheets] == [16, 14] def test_window_stops_where_lines_end() -> None: