From 022908abed3ea66f8596042ccd5178f2135cc577 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 4 Sep 2026 18:12:54 +0900 Subject: [PATCH 01/11] =?UTF-8?q?fix(B07):=20=ED=9A=A1=EB=8B=A8=EB=A9=B4?= =?UTF-8?q?=EB=8F=84=20=EC=B9=B8=20=ED=81=AC=EA=B8=B0=20=ED=86=B5=EC=9D=BC?= =?UTF-8?q?=20=E2=80=94=20=EC=B6=95=EC=B2=99=201/100=20=EC=9C=A0=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 실측 결과 축척은 문제가 아니었음. 실거리 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) --- .../B07_DesignDetail_Engine_Cad.py | 34 ++--- .../B07_DesignDetail_Engine_Cad_Sheet.py | 126 +++++++++++------- 2 files changed, 96 insertions(+), 64 deletions(-) diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad.py index 93daca79..04b121aa 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cad.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad.py @@ -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": [ diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Sheet.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Sheet.py index 43a1e52d..cce9992e 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Sheet.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Sheet.py @@ -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 []) From b51c8875499e1f5cac0afec0865dbfda7df82166 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 4 Sep 2026 18:14:59 +0900 Subject: [PATCH 02/11] =?UTF-8?q?feat(B05/B06):=20=EC=A2=85=EB=8B=A8=20?= =?UTF-8?q?=EA=B7=B8=EB=9E=98=ED=94=84=20=EC=A4=8C=20=EB=B2=84=ED=8A=BC=20?= =?UTF-8?q?=EB=8B=A8=EC=88=9C=ED=99=94=20+=20=EC=84=B8=EB=A1=9C=20?= =?UTF-8?q?=EC=9E=90=EB=8F=99=20=EB=A7=9E=EC=B6=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 줌 버튼을 셋(줌인·줌아웃·초기화)으로 줄임. 세로 배율·창 이동 버튼 제거 - 배율 1 = 기본값이자 축소 한계 — 한계에 닿은 버튼은 흐리게 죽임 - 세로는 보이는 누가거리 구간의 지반·계획선 범위로 자동(공통 함수 windowElevationRange, B05·B06 종단이 함께 씀). 스크롤이 멈춘 뒤 0.16초에 갱신 - 계획고 편집 버튼을 누르고 있는 동안 Y 축 고정, 손을 떼면 다시 맞춤 - 유토곡선 Y 도 같은 창 기준(전 구간 ±200㎥ 고정 해제) - B06 종단이 쓰던 공통 Y 스케일(calculateYScale) 제거 — 횡단 카드는 원래 안 쓰던 값 Co-Authored-By: Claude Opus 5 (1M context) --- B05_Profile/B05_Profile_UI_Profile_Panel.ts | 44 +++++++++++++- B05_Profile/B05_Profile_UI_Profile_Render.ts | 41 +++++++++++-- B05_Profile/B05_Profile_UI_Profile_Zoom.ts | 59 ++++++++----------- B05_Profile/B05_Profile_UI_Style_Table.css | 8 ++- B06_Section/B06_Section_UI_Longitudinal.ts | 12 +++- B06_Section/B06_Section_UI_Section_Common.ts | 41 +++++++++++++ B06_Section/B06_Section_UI_Section_View.ts | 50 ++++++++++++++-- .../B06_Section_UI_Section_View_MassHaul.ts | 3 + common_util/common_util_mass_haul_view.ts | 45 +++++++++++++- 9 files changed, 249 insertions(+), 54 deletions(-) diff --git a/B05_Profile/B05_Profile_UI_Profile_Panel.ts b/B05_Profile/B05_Profile_UI_Profile_Panel.ts index 23da8778..a2580a54 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Panel.ts @@ -335,9 +335,35 @@ export function createRouteProfilePanel( }); } - /* 줌·Y레인지 조작구 — 상태를 페이지가 들고 있어 편집·재계산으로 다시 그려도 유지된다. */ + /* 줌 조작구(가로 배율) — 상태를 페이지가 들고 있어 편집·재계산으로 다시 그려도 유지된다. + 세로는 보이는 구간에 맞춰 자동이라 사람이 맞출 것이 없다(2026-09-04 사용자 확정). */ const profileZoom = createProfileZoom(() => draw()); + /** 세로 자동 맞춤이 지금 쓰는 Y 창. 계획고를 끄는 동안에는 이 값을 붙잡는다. */ + let elevationWindow: { min: number; max: number } | undefined; + /** 계획고 편집 버튼(▲▼)을 누르고 있는 중인가 — 그동안 Y 축을 고정한다. */ + let heightEditing = false; + const holdElevationRange = ( + next: { min: number; max: number } | null, + ): { min: number; max: number } | undefined => { + if (heightEditing) return elevationWindow; + elevationWindow = next ?? undefined; + return elevationWindow; + }; + // 끌어 올리는 동안 축까지 따라 움직이면 조작 감각이 깨진다 — 손을 뗀 뒤 한 번만 다시 맞춘다. + body.addEventListener("pointerdown", (event) => { + if (!(event.target as HTMLElement).closest(".b05-profile-edit__btn")) return; + heightEditing = true; + const release = (): void => { + heightEditing = false; + window.removeEventListener("pointerup", release); + window.removeEventListener("pointercancel", release); + draw(); + }; + window.addEventListener("pointerup", release); + window.addEventListener("pointercancel", release); + }); + /* [직선화]·[쉬프트]·되돌리기·방향키 배선은 `_Panel_Tools` 로 뺐다(700줄 한계). */ const { tools, history, handleToolPick } = createPanelTools({ root, @@ -474,6 +500,7 @@ export function createRouteProfilePanel( applyEdits, handleToolPick, zoom: profileZoom.state, + holdElevationRange, toolActive: () => tools.mode() !== "none", selectedRuns: () => tools.selectedRuns(), stationIdAtStructure, @@ -481,6 +508,21 @@ export function createRouteProfilePanel( }); } + /** 가로 스크롤이 이만큼 멈춰 있으면 보이는 구간이 정해진 것으로 보고 세로를 다시 맞춘다. */ + const SCROLL_SETTLE_MS = 160; + /** 마지막으로 세로를 맞춘 가로 위치 — 같은 자리면 다시 그리지 않는다(재구성 되먹임 차단). */ + let settledScrollLeft = 0; + let scrollSettleTimer = 0; + // 스크롤하는 내내 축이 출렁이면 어지럽다 — 멈춘 뒤에 한 번만 다시 맞춘다(2026-09-04). + body.addEventListener("scroll", () => { + window.clearTimeout(scrollSettleTimer); + scrollSettleTimer = window.setTimeout(() => { + if (heightEditing || Math.abs(body.scrollLeft - settledScrollLeft) < 1) return; + settledScrollLeft = body.scrollLeft; + draw(); + }, SCROLL_SETTLE_MS); + }); + // 종단면도는 가로로 매우 길다. 세로 휠을 가로 스크롤로 돌려 스크롤바를 잡지 않고도 // 노선을 훑을 수 있게 한다 (Shift+휠은 브라우저 기본 가로 스크롤이라 그대로 둔다). body.addEventListener( diff --git a/B05_Profile/B05_Profile_UI_Profile_Render.ts b/B05_Profile/B05_Profile_UI_Profile_Render.ts index 383a4cfe..6d4662c3 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Render.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Render.ts @@ -12,7 +12,11 @@ import { createLongitudinalProfile, longitudinalMinimumWidth, } from "../B06_Section/B06_Section_UI_Longitudinal"; -import { hasStaleDesigns, LONG_PAD } from "../B06_Section/B06_Section_UI_Section_Common"; +import { + hasStaleDesigns, + LONG_PAD, + windowElevationRange, +} from "../B06_Section/B06_Section_UI_Section_Common"; import type { SectionDetailResponse } from "../B06_Section/B06_Section_Api_Fetch"; import { normalizedLongitudinal, toDesignProfile } from "./B05_Profile_UI_Profile_Data"; import { @@ -84,8 +88,15 @@ export interface ProfileRenderContext { applyEdits: (next: AlignmentEdits) => void; /** [직선화]·[쉬프트] 도구가 그래프 클릭을 먼저 먹는지(먹었으면 기본 선택을 건너뛴다). */ handleToolPick: (chainageM: number | null) => boolean; - /** 가로 폭 배수·세로 표시 표고창(줌 조작구 상태) — 2026-09-04. */ + /** 가로 폭 배수(줌 조작구 상태) — 세로는 자동이라 배율이 없다(2026-09-04). */ zoom: () => ProfileZoomState; + /** + * 세로 자동 맞춤의 Y 창을 넘겨 주고 **실제로 쓸 창**을 돌려받는다. 계획고를 끌어 올리는 + * 동안에는 본체가 직전 창을 붙잡아 돌려준다 — 축이 손 따라 움직이면 조작 감각이 깨진다. + */ + holdElevationRange: ( + next: { min: number; max: number } | null, + ) => { min: number; max: number } | undefined; /** 그래프 x → chainage 역변환이 필요한 도구 판정용 — 클릭 지점의 누가거리. */ toolActive: () => boolean; /** [쉬프트]로 고른 직선 구간 — 그래프에 빨갛게 강조한다(2026-09-03). */ @@ -222,13 +233,26 @@ export function renderProfile(ctx: ProfileRenderContext): void { ...graphData, stations: [...regular, ...injected].sort((a, b) => a.chainage_m - b.chainage_m), }; + // 세로 자동 맞춤 — 지금 화면에 보이는 누가거리 구간만 보고 Y 창을 잡는다(2026-09-04 + // 사용자 확정). 가로 스크롤 위치(`scrollLeft`)와 본문 폭이 곧 보이는 구간이다. + const toChainage = chainageInverter(longitudinal, width, originOffset); + const maxChainageM = maxChainageOf(longitudinal); + const viewFromM = Math.max(0, toChainage(scrollLeft)); + const viewToM = Math.min(maxChainageM, toChainage(scrollLeft + body.clientWidth)); + const elevationRange = ctx.holdElevationRange( + windowElevationRange( + [graphLongitudinal.samples, ...designProfiles.map((profile) => profile.samples)], + viewFromM, + viewToM, + ) ?? null, + ); let yAxis: { padLeft: number; ticks: Array<{ y: number; label: string }> } | null = null; chartWrap.append( createLongitudinalProfile( graphLongitudinal, selectedStationId, - // 세로 배율 = 표시 표고창 높이의 역수(1 = 표고 전범위). - zoom.y, + // 세로 배율은 1 고정 — 확대·축소 몫은 아래 `elevationRange`(자동 맞춤)가 맡는다. + 1, undefined, ctx.selectStation, stationInterval, @@ -252,8 +276,10 @@ export function renderProfile(ctx: ProfileRenderContext): void { // 올린다. 19px는 라벨-버튼 사이가 너무 벌어져 70% 수준(15px)으로 줄였다 // (2026-08-04 사용자 지시). B06은 편집 버튼이 없어 0 유지. 15, - // 표시 표고창의 중심 이동(창 높이 대비 비율) — ▲▼ 버튼이 옮긴다. - zoom.offsetRatio, + // 창 중심 이동은 쓰지 않는다 — 보이는 구간에 맞춘 Y 창이 이미 가운데다. + 0, + // 보이는 구간의 지반·계획선 범위(위아래 10% 여유는 렌더러가 붙인다). + elevationRange, ), ); // 구조물 우클릭 메뉴 — 측점선 위면 삭제, 빈 자리면 배관 추가. 이동도 여기(측점선 끌기)서 한다. @@ -373,6 +399,9 @@ export function renderProfile(ctx: ProfileRenderContext): void { // 범례·기준 버튼 오버레이(top 34px)가 곡선 위에 떠서 그만큼 상단 여유를 준다 // (2026-08-05 사용자 보고: 버튼과 커브 겹침). padTop: 40, + // 유토곡선 Y 도 종단과 같은 창을 본다 — 전 구간 최대 토량으로 고정하면 확대해도 + // 곡선이 납작하게 눌린다(2026-09-04 사용자 지시). + viewRange: { fromM: viewFromM, toM: viewToM }, }, stationInterval: stationIntervalM ?? 1, widthPx: width, diff --git a/B05_Profile/B05_Profile_UI_Profile_Zoom.ts b/B05_Profile/B05_Profile_UI_Profile_Zoom.ts index b6d13d86..fb29af27 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Zoom.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Zoom.ts @@ -1,38 +1,30 @@ /* ============================================================================= * B05_Profile_UI_Profile_Zoom.ts - * 종단면도 줌·Y레인지 조작구 (2026-09-04 사용자 지시). + * 종단면도 줌 조작구 — 버튼 셋(줌인·줌아웃·초기화), 2026-09-04 사용자 확정. * - * 공사 범위가 넓고 고저차가 크면 종단 그래프가 눌려 읽히지 않는다. 조작은 두 갈래다. + * 공사 범위가 넓으면 종단 그래프가 눌려 읽히지 않는다. 사람이 맞출 것은 **가로 하나**다. * * X (가로) — **폭 배수**다. SVG transform 으로 늘리면 그래프만 커지고 측점 테이블· * 계획고 편집 버튼층·구조물 알약 레인이 어긋난다(넷이 같은 `chainageMapper` * 를 쓴다). 캔버스 폭 자체를 키우고 가로 스크롤로 훑는다. - * Y (세로) — **표시 표고창**이다(제안 A, 2026-09-04 사용자 확정). 창 높이 = 전범위 ÷ 배율, - * 창 중심은 창 높이 대비 비율로 위·아래로 옮긴다. 세로 스크롤이 생기지 않아 - * X축·측점 라벨·편집 버튼이 항상 바닥에 남는다. + * Y (세로) — **프로그램이 자동으로 맞춘다**. 보이는 구간의 지반·계획선 범위에 맞춰 + * 잡으므로(`windowElevationRange`) 세로 배율·창 이동 버튼이 필요 없어졌다. + * 옛 `⇕+`·`⇕−`·`▲`·`▼` 네 버튼은 그래서 없앴다. + * + * **배율 1 = 기본값이자 축소 한계**(사용자 확정) — 폭맞춤보다 더 줄이면 측점이 겹쳐 + * 읽을 수 없다. 한계에 닿은 버튼은 흐리게 죽인다. * * 배율은 페이지가 들고 있다 — 편집·재계산으로 다시 그려도 유지된다(B06 `cardZoomStates` 규칙). - * 버튼 양식은 횡단도 줌 버튼세트(`b06-cross-card__zoom-btn`)와 같고, 설명은 툴팁이다. * ========================================================================== */ /** 한 번 누를 때 배율 배수. 횡단도 줌(1/0.85)보다 성글게 — 폭 배수라 한 칸이 크게 느껴진다. */ const ZOOM_STEP = 1.25; /** 가로 폭 배수 상한 — 이 이상은 캔버스가 수만 px이 되어 브라우저가 버겁다. */ const MAX_X = 8; -/** 세로 배율 상한. 전범위의 1/20 까지 좁혀 본다. */ -const MAX_Y = 20; -/** 창 중심 이동 한 번의 몫 — 창 높이의 10%. */ -const OFFSET_STEP = 0.1; -/** 창 중심 이동 한계 — 전범위 밖으로 완전히 벗어나지 않게 창 높이의 ±2배까지. */ -const MAX_OFFSET = 2; export interface ProfileZoomState { - /** 가로 폭 배수(1 = 현행 폭맞춤). */ + /** 가로 폭 배수(1 = 현행 폭맞춤 = 기본값·축소 한계). */ x: number; - /** 세로 배율(1 = 표고 전범위). */ - y: number; - /** 창 중심 이동 — 창 높이 대비 비율(+ 가 위쪽). */ - offsetRatio: number; } export interface ProfileZoom { @@ -45,12 +37,12 @@ const clamp = (value: number, min: number, max: number): number => Math.min(max, Math.max(min, value)); export function createProfileZoom(onChange: () => void): ProfileZoom { - const state: ProfileZoomState = { x: 1, y: 1, offsetRatio: 0 }; + const state: ProfileZoomState = { x: 1 }; const bar = document.createElement("div"); bar.className = "b05-profile__zoom"; - function add(label: string, title: string, action: () => void): void { + function add(label: string, title: string, action: () => void): HTMLButtonElement { const button = document.createElement("button"); button.type = "button"; button.className = "b05-profile__zoom-btn"; @@ -60,34 +52,29 @@ export function createProfileZoom(onChange: () => void): ProfileZoom { // 카드·측점 선택으로 번지면 그래프를 다시 그리며 방금 맞춘 배율이 날아간다. event.stopPropagation(); action(); + syncDisabled(); onChange(); }); bar.append(button); + return button; } - add("+", "가로 확대 — 측점 간격을 넓혀 폅니다 (가로 스크롤로 훑음)", () => { + const zoomIn = add("+", "가로 확대 — 측점 간격을 넓혀 폅니다 (세로는 자동으로 맞춥니다)", () => { state.x = clamp(state.x * ZOOM_STEP, 1, MAX_X); }); - add("−", "가로 축소", () => { + const zoomOut = add("−", "가로 축소 — 기본 폭(화면 맞춤)까지만 줄어듭니다", () => { state.x = clamp(state.x / ZOOM_STEP, 1, MAX_X); }); - add("⇕+", "세로 확대 — 표시 표고 폭을 좁혀 고저차를 크게 봅니다", () => { - state.y = clamp(state.y * ZOOM_STEP, 1, MAX_Y); - }); - add("⇕−", "세로 축소", () => { - state.y = clamp(state.y / ZOOM_STEP, 1, MAX_Y); - }); - add("▲", "표시 표고창을 위로 (창 높이의 10%)", () => { - state.offsetRatio = clamp(state.offsetRatio + OFFSET_STEP, -MAX_OFFSET, MAX_OFFSET); - }); - add("▼", "표시 표고창을 아래로 (창 높이의 10%)", () => { - state.offsetRatio = clamp(state.offsetRatio - OFFSET_STEP, -MAX_OFFSET, MAX_OFFSET); - }); - add("⤢", "가로·세로 배율과 표시 표고창을 처음 상태로", () => { + add("⤢", "기본 상태로 — 가로 폭맞춤, 세로 자동", () => { state.x = 1; - state.y = 1; - state.offsetRatio = 0; }); + /** 한계에 닿은 버튼은 눌러도 변화가 없다 — 흐리게 죽여 그 사실을 보인다. */ + function syncDisabled(): void { + zoomOut.disabled = state.x <= 1 + 1e-9; + zoomIn.disabled = state.x >= MAX_X - 1e-9; + } + syncDisabled(); + return { bar, state: () => ({ ...state }) }; } diff --git a/B05_Profile/B05_Profile_UI_Style_Table.css b/B05_Profile/B05_Profile_UI_Style_Table.css index 85044eca..5b5fec88 100644 --- a/B05_Profile/B05_Profile_UI_Style_Table.css +++ b/B05_Profile/B05_Profile_UI_Style_Table.css @@ -509,7 +509,13 @@ border-left: none; } -.b05-profile__zoom-btn:hover { +.b05-profile__zoom-btn:hover:not(:disabled) { color: var(--color-text); background: var(--color-surface); } + +/* 배율 한계(축소는 폭맞춤, 확대는 8배)에 닿은 버튼 — 눌러도 변화가 없으니 흐리게 죽인다. */ +.b05-profile__zoom-btn:disabled { + opacity: 0.35; + cursor: default; +} diff --git a/B06_Section/B06_Section_UI_Longitudinal.ts b/B06_Section/B06_Section_UI_Longitudinal.ts index 6facaf19..d942ac73 100644 --- a/B06_Section/B06_Section_UI_Longitudinal.ts +++ b/B06_Section/B06_Section_UI_Longitudinal.ts @@ -174,6 +174,12 @@ export function createLongitudinalProfile( * 표고(m)가 아니라 비율이라 호출부가 노선 표고 범위를 몰라도 된다(2026-09-04). */ elevationOffsetRatio = 0, + /** + * **보이는 구간의 표고 범위**(자동 세로 맞춤, 2026-09-04 사용자 확정). 넘기면 전 구간 + * 최저~최고 대신 이 범위로 Y 창을 잡는다 — 가로로 확대했을 때 그 구간의 고저차가 + * 화면 높이를 채운다. 공통 Y 스케일(`yScaleOptions`)이 있으면 그쪽이 우선이다. + */ + elevationRange?: { min: number; max: number }, ): HTMLElement { const samples = data.samples.filter(validElevation); if (samples.length < 2) return emptyView(L("B06_Profile_View_NoLongitudinal")); @@ -198,8 +204,10 @@ export function createLongitudinalProfile( const elevations = samples .map((sample) => sample.elevation_m) .concat(designProfiles.flatMap((profile) => profile.samples.map((s) => s.elevation_m))); - const rawMin = yScaleOptions?.globalMinElevation ?? Math.min(...elevations); - const rawMax = yScaleOptions?.globalMaxElevation ?? Math.max(...elevations); + const rawMin = + yScaleOptions?.globalMinElevation ?? elevationRange?.min ?? Math.min(...elevations); + const rawMax = + yScaleOptions?.globalMaxElevation ?? elevationRange?.max ?? Math.max(...elevations); const elevationMid = (rawMin + rawMax) / 2; const exaggeration = Math.max(verticalExaggeration, 0.1); // 데이터 영역은 축 프레임(LONG_PAD)보다 originOffsetPx만큼 더 좁게 잡아, diff --git a/B06_Section/B06_Section_UI_Section_Common.ts b/B06_Section/B06_Section_UI_Section_Common.ts index ffa54a7a..f745e7b6 100644 --- a/B06_Section/B06_Section_UI_Section_Common.ts +++ b/B06_Section/B06_Section_UI_Section_Common.ts @@ -223,6 +223,47 @@ export function calculateYScale( }; } +/** + * **보이는 구간의 표고 최저·최고**(종단 그래프 세로 자동 맞춤, 2026-09-04 사용자 확정). + * + * 가로로 확대하면 화면에는 노선의 일부만 남는데 Y 축은 전 구간 범위로 잡혀 있어 곡선이 + * 납작하게 눌린다. 보이는 누가거리 구간만 훑어 그 구간의 범위를 돌려준다 — B05 종단과 + * B06 종단이 같은 함수를 쓴다. + * + * 창 밖 **이웃 한 점**까지 함께 본다. 창 경계를 걸친 선분이 창 안에서 위로 솟는데 그 + * 바깥 끝점을 빼면 선이 축 위로 삐져나온다. + */ +export function windowElevationRange( + series: ReadonlyArray>, + fromM: number, + toM: number, +): { min: number; max: number } | undefined { + let min = Infinity; + let max = -Infinity; + for (const list of series) { + let first = -1; + let last = -1; + for (let index = 0; index < list.length; index += 1) { + const chainage = list[index].chainage_m ?? 0; + if (chainage < fromM || chainage > toM) continue; + if (first < 0) first = index; + last = index; + } + if (first < 0) continue; + for ( + let index = Math.max(0, first - 1); + index <= Math.min(list.length - 1, last + 1); + index += 1 + ) { + const elevation = list[index].elevation_m; + if (typeof elevation !== "number" || !Number.isFinite(elevation)) continue; + if (elevation < min) min = elevation; + if (elevation > max) max = elevation; + } + } + return min <= max ? { min, max } : undefined; +} + export function emptyView(message: string): HTMLElement { const empty = document.createElement("div"); empty.className = "b06-section__empty"; diff --git a/B06_Section/B06_Section_UI_Section_View.ts b/B06_Section/B06_Section_UI_Section_View.ts index 2bf11752..8b445399 100644 --- a/B06_Section/B06_Section_UI_Section_View.ts +++ b/B06_Section/B06_Section_UI_Section_View.ts @@ -59,7 +59,6 @@ import { unwrapChart, } from "./B06_Section_UI_Section_View_Panel"; import { - calculateYScale, CROSS_GRID_GAP, CROSS_GRID_MIN_WIDTH, CROSS_WIDTH, @@ -69,7 +68,9 @@ import { emptyView, inferStationInterval, L, - type YScaleOptions, + LONG_PAD, + longitudinalMaxChainage, + windowElevationRange, } from "./B06_Section_UI_Section_Common"; export type { CrossDesignChange, DesignChangeHandler, RockBoundaryControl }; @@ -142,7 +143,6 @@ export function createSectionView( let lastChartAvailable = 0; let chartFitScheduled = false; // 카드 단위 재빌드에 재사용하는 렌더 컨텍스트 (draw에서 갱신) - let cachedYScale: YScaleOptions | undefined; let cachedStationInterval = 1; let cachedCardWidth = CROSS_WIDTH; // 같은 행 카드는 같은 높이가 되도록 draw에서 측점별 행 높이를 계산해 둔다(단건 갱신도 이 값 재사용). @@ -473,8 +473,24 @@ export function createSectionView( const heights = chartHeights(lastChartAvailable); const minWidth = longitudinalMinimumWidth(detail.longitudinal, cachedStationInterval); const chartWidth = Math.max(renderWidth, minWidth); - // 종단도 높이가 줄면 Y스케일도 그 높이로 다시 잡아야 표고가 잘리지 않는다. - cachedYScale = calculateYScale(detail, heights.long); + // 종단 그래프의 세로는 **보이는 구간에 자동으로 맞춘다**(2026-09-04 사용자 확정, + // B05 와 같은 규칙·같은 함수). 가로 스크롤 위치와 컨테이너 폭이 곧 보이는 구간이다. + const maxChainageM = longitudinalMaxChainage(detail.longitudinal); + const plotWidth = Math.max(1, chartWidth - LONG_PAD.left - LONG_PAD.right); + const toChainage = (px: number): number => ((px - LONG_PAD.left) / plotWidth) * maxChainageM; + const viewFromM = Math.max(0, toChainage(keepScrollLeft)); + const viewToM = Math.min( + maxChainageM, + toChainage(keepScrollLeft + (chartWrap.clientWidth || chartWidth)), + ); + const longElevationRange = windowElevationRange( + [ + detail.longitudinal.samples, + ...(detail.longitudinal.design_profiles ?? []).map((profile) => profile.samples), + ], + viewFromM, + viewToM, + ); // Y축 눈금을 렌더러에서 받아 가로 스크롤 고정 오버레이로 얹는다(2026-08-04 사용자 // 지시 — 테이블 행 이름표처럼 스크롤해도 계속 보이게, B05와 같은 방식). @@ -485,7 +501,8 @@ export function createSectionView( detail.longitudinal, selectedStationId, currentExaggeration, - cachedYScale, + // 공통 Y 스케일(횡단 카드 몫)을 여기 넘기면 종단이 전 구간 축에 묶여 눌린다. + undefined, (stationId) => selectStation(stationId, true), cachedStationInterval, chartWidth, @@ -496,6 +513,11 @@ export function createSectionView( (axis) => { longAxis = axis; }, + undefined, + undefined, + 0, + 0, + longElevationRange, ), ), ]; @@ -517,6 +539,8 @@ export function createSectionView( selectStation: (stationId) => selectStation(stationId, true), toggleSeries, redraw: drawPanel, + // 유토곡선도 종단과 같은 창을 본다(2026-09-04). + viewRange: { fromM: viewFromM, toM: viewToM }, }); if (massHaul.chart) nodes.push(massHaul.chart); chartWrap.replaceChildren(...nodes); @@ -553,6 +577,20 @@ export function createSectionView( } } + /** 가로 스크롤이 이만큼 멈춰 있으면 보이는 구간이 정해진 것으로 보고 세로를 다시 맞춘다. */ + const SCROLL_SETTLE_MS = 160; + let settledScrollLeft = 0; + let scrollSettleTimer = 0; + // 스크롤·팬 하는 내내 축이 출렁이면 어지럽다 — 멈춘 뒤 한 번만 다시 그린다(2026-09-04). + chartWrap.addEventListener("scroll", () => { + window.clearTimeout(scrollSettleTimer); + scrollSettleTimer = window.setTimeout(() => { + if (Math.abs(chartWrap.scrollLeft - settledScrollLeft) < 1) return; + settledScrollLeft = chartWrap.scrollLeft; + drawPanel(); + }, SCROLL_SETTLE_MS); + }); + const draw = (): void => { if (!currentDetail || !Number.isFinite(renderWidth) || renderWidth <= 0) return; const detail = currentDetail; diff --git a/B06_Section/B06_Section_UI_Section_View_MassHaul.ts b/B06_Section/B06_Section_UI_Section_View_MassHaul.ts index cdb9dd5e..0a728daf 100644 --- a/B06_Section/B06_Section_UI_Section_View_MassHaul.ts +++ b/B06_Section/B06_Section_UI_Section_View_MassHaul.ts @@ -56,6 +56,8 @@ export interface MassHaulPanelInput { toggleSeries: (key: string) => void; /** 범례에서 도형 위치를 초기화한 뒤 패널을 다시 그린다. */ redraw: () => void; + /** 화면에 보이는 누가거리 구간(m) — Y 를 이 구간의 누계 토량으로 잡는다(2026-09-04). */ + viewRange?: { fromM: number; toM: number }; } export interface MassHaulPanelResult { @@ -104,6 +106,7 @@ export function buildMassHaulPanel(input: MassHaulPanelInput): MassHaulPanelResu maxChainageM: longitudinalMaxChainage(detail.longitudinal), padLeft: LONG_PAD.left, padRight: LONG_PAD.right, + viewRange: input.viewRange, }, input.selectedStationId, input.stationInterval, diff --git a/common_util/common_util_mass_haul_view.ts b/common_util/common_util_mass_haul_view.ts index f981e629..d06e516f 100644 --- a/common_util/common_util_mass_haul_view.ts +++ b/common_util/common_util_mass_haul_view.ts @@ -43,6 +43,12 @@ export interface MassHaulAxis { axisX?: number; /** 그래프 위 여백(px). 생략하면 기본(10). B05는 범례 오버레이만큼 크게 준다. */ padTop?: number; + /** + * **화면에 보이는 누가거리 구간**(m). 넘기면 Y 범위를 이 구간의 누계 토량으로 잡는다 + * (2026-09-04 사용자 지시 — 종단 그래프의 세로 자동 맞춤과 같은 창). 생략하면 예전처럼 + * 전 구간 기준 ±200㎥ 고정이다. + */ + viewRange?: { fromM: number; toM: number }; } /** 축 눈금이 읽히는 최소 높이. 이보다 낮아지면 그래프가 뭉개진다. */ @@ -128,9 +134,44 @@ const VOLUME_RANGE_BASE_M3 = 200; * Y축 상·하한을 잡는다. 기본 −200~+200㎥ 고정(0선 항상 포함), 곡선이 넘치는 쪽만 * 데이터에 5% 여유를 더해 확장한다. B05·B06이 같은 함수를 쓰므로 두 화면이 함께 고정된다. */ -function volumeRange(series: MassHaulSeries[]): { min: number; max: number } { +function volumeRange( + series: MassHaulSeries[], + viewRange?: { fromM: number; toM: number }, +): { min: number; max: number } { let rawMin = 0; let rawMax = 0; + if (viewRange) { + // 보이는 구간만 훑는다. 창 경계를 걸친 선분이 안에서 솟구치므로 바깥 이웃 한 점도 본다. + let found = false; + for (const entry of series) { + const points = entry.result.points; + let first = -1; + let last = -1; + for (let index = 0; index < points.length; index += 1) { + const chainage = points[index].chainage_m; + if (chainage < viewRange.fromM || chainage > viewRange.toM) continue; + if (first < 0) first = index; + last = index; + } + if (first < 0) continue; + for ( + let index = Math.max(0, first - 1); + index <= Math.min(points.length - 1, last + 1); + index += 1 + ) { + const volume = points[index].cumulative_volume_m3; + if (!Number.isFinite(volume)) continue; + rawMin = found ? Math.min(rawMin, volume) : volume; + rawMax = found ? Math.max(rawMax, volume) : volume; + found = true; + } + } + if (found) { + // 창 안이 거의 평평하면(구간 토량 변화가 없으면) 최소 폭을 줘 선이 축에 붙지 않게 한다. + const padding = Math.max((rawMax - rawMin) * 0.05, 1); + return { min: rawMin - padding, max: rawMax + padding }; + } + } for (const entry of series) { rawMin = Math.min(rawMin, entry.result.min_cumulative_m3); rawMax = Math.max(rawMax, entry.result.max_cumulative_m3); @@ -323,7 +364,7 @@ export function createMassHaulChart( // 기준 버튼이 라디오가 되면서(택1 표시) Y 범위도 **표시 중인 곡선**으로 잡는다 — // 숨은 기준까지 합쳐 잡으면 선택한 그래프가 눌려 보인다. 아무것도 안 켰으면 전체로 폴백. const rangeSource = series.filter((entry) => visibleKeys.has(entry.key)); - const { min, max } = volumeRange(rangeSource.length ? rangeSource : series); + const { min, max } = volumeRange(rangeSource.length ? rangeSource : series, axis.viewRange); const span = Math.max(max - min, 1e-6); const y = (volume: number) => padTop + ((max - volume) / span) * plotHeight; From 6ff86cdd2059b760fd179614993a7d2390c40f7a Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 4 Sep 2026 18:26:26 +0900 Subject: [PATCH 03/11] =?UTF-8?q?feat(B05):=203D=20=EB=B7=B0=EC=96=B4=20?= =?UTF-8?q?=EC=9B=90=EA=B7=BC=20=EC=B9=B4=EB=A9=94=EB=9D=BC=20=EB=B3=B5?= =?UTF-8?q?=EA=B7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 사용자 지시(2026-09-04) — 「이전 방식이 좋았음」. 2026-08-25 `f6296fa7` 에서 직교로 바꿨던 것을 시야각 45° 원근으로 되돌림. - 직교 전용 모듈 `B05_Profile_UI_Viewer_Camera.ts` 삭제, 뷰어 4자리 복원 (카메라 생성 · 리사이즈 종횡비 · 화면맞춤 · 투영행렬). - 근평면 상한 0.4m 신설 — 휠 확대가 커서 아래 지점 0.5m 앞에서 멈추는데 근평면을 맞춤거리의 1/1000 로만 두면 긴 노선에서 그 0.5m 를 넘어 지형이 잘림 (용화_LAS 맞춤거리 1,208m → 상한 없으면 근평면 1.21m). 자체검증(공용 브라우저 5173, 용화_LAS) — 네 방향 모두 원근 45°, 확대 한계 0.516m > 근평면 0.4m 로 잘림 없음, 구조물 클릭 선택·해제 그대로. Co-Authored-By: Claude Opus 5 --- B05_Profile/B05_Profile_UI_Viewer.ts | 18 +++++---- B05_Profile/B05_Profile_UI_Viewer_Camera.ts | 42 --------------------- 2 files changed, 11 insertions(+), 49 deletions(-) delete mode 100644 B05_Profile/B05_Profile_UI_Viewer_Camera.ts diff --git a/B05_Profile/B05_Profile_UI_Viewer.ts b/B05_Profile/B05_Profile_UI_Viewer.ts index 04bcd643..8d067309 100644 --- a/B05_Profile/B05_Profile_UI_Viewer.ts +++ b/B05_Profile/B05_Profile_UI_Viewer.ts @@ -14,7 +14,6 @@ import { type RouteMarkers, type SectionStationMarker, } from "./B05_Profile_UI_Markers"; -import { createOrthoCameraRig } from "./B05_Profile_UI_Viewer_Camera"; import { bindMarkerPointerControls } from "./B05_Profile_UI_Viewer_Marker_Input"; import type { CorridorBuildResult } from "./B05_Profile_UI_Corridor_Build"; import { @@ -165,8 +164,10 @@ export function createRouteViewer(): RouteViewer { }); systemDarkTheme.addEventListener("change", updateSceneBackground); updateSceneBackground(); - const cameraRig = createOrthoCameraRig(); - const camera = cameraRig.camera; + // 원근 카메라(시야각 45°) — 2026-09-04 사용자 지시로 직교에서 되돌렸다. 직교가 + // 필요했던 탑뷰 구조물 투영 윤곽선은 2026-09-02에 숨김 처리되어 화면에 없다. + const camera = new THREE.PerspectiveCamera(45, 1, 0.1, 100000); + camera.position.set(100, 120, 100); const renderer = new THREE.WebGLRenderer({ canvas, antialias: true }); renderer.setPixelRatio(Math.min(devicePixelRatio, 2)); const controls = new OrbitControls(camera, canvas); @@ -275,7 +276,8 @@ export function createRouteViewer(): RouteViewer { const width = Math.max(1, root.clientWidth); const height = Math.max(1, root.clientHeight); renderer.setSize(width, height, false); - cameraRig.setAspect(width / height); + camera.aspect = width / height; + camera.updateProjectionMatrix(); } const resizeObserver = new ResizeObserver(resize); resizeObserver.observe(root); @@ -291,10 +293,12 @@ export function createRouteViewer(): RouteViewer { } as const; const [x, y, z] = positions[view]; camera.position.set(target.x + x, target.y + y, target.z + z); - camera.near = Math.max(0.1, distance / 1000); + // 근평면 상한 0.4m — 휠 확대는 커서 아래 지점 0.5m 앞에서 멈춘다(커서 피벗 유틸). + // 거리에만 비례시키면 긴 노선(맞춤 거리 1km 이상)에서 근평면이 그 0.5m를 넘어 + // 최대 확대 시 지형이 잘린다(2026-09-04 원근 복귀 실측: 400m 노선 여유 13mm). + camera.near = Math.max(0.1, Math.min(0.4, distance / 1000)); camera.far = distance * 10; - // 원근 45°(반각 tan ≈ 0.414)와 비슷한 화면 배율 — 뷰 전환 시 크기감이 유지된다. - cameraRig.setHalfHeight(distance * 0.42); + camera.updateProjectionMatrix(); controls.update(); } diff --git a/B05_Profile/B05_Profile_UI_Viewer_Camera.ts b/B05_Profile/B05_Profile_UI_Viewer_Camera.ts deleted file mode 100644 index 6e36d63b..00000000 --- a/B05_Profile/B05_Profile_UI_Viewer_Camera.ts +++ /dev/null @@ -1,42 +0,0 @@ -/* ============================================================================= - * B05_Profile_UI_Viewer_Camera.ts - * B05 뷰어의 **직교(원근 없음) 카메라**(2026-08-25 사용자 확정) — 탑뷰에서 구조물· - * 절단 경계가 원근으로 일그러지지 않는다. 화면 배율은 camera.zoom이 지고(커서 피벗 - * 유틸이 조작), 절두체 반높이는 fit()이 정한다. Viewer 700줄 제한으로 분리. - * ========================================================================== */ - -import * as THREE from "three"; - -export interface OrthoCameraRig { - camera: THREE.OrthographicCamera; - /** 뷰포트 종횡비 반영(리사이즈 시). */ - setAspect(aspect: number): void; - /** 절두체 반높이(월드 m) 지정 — fit()이 화면 배율을 잡을 때 쓴다. zoom은 1로 되돌린다. */ - setHalfHeight(halfHeight: number): void; -} - -export function createOrthoCameraRig(): OrthoCameraRig { - const camera = new THREE.OrthographicCamera(-100, 100, 100, -100, 0.1, 100000); - camera.position.set(100, 120, 100); - let halfHeight = 100; - let aspect = 1; - const apply = (): void => { - camera.left = -halfHeight * aspect; - camera.right = halfHeight * aspect; - camera.top = halfHeight; - camera.bottom = -halfHeight; - camera.updateProjectionMatrix(); - }; - return { - camera, - setAspect(value: number): void { - aspect = value; - apply(); - }, - setHalfHeight(value: number): void { - halfHeight = value; - camera.zoom = 1; - apply(); - }, - }; -} From 84042fe010ed4cbf602b96255a52991d45ea4998 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 4 Sep 2026 18:34:05 +0900 Subject: [PATCH 04/11] =?UTF-8?q?feat(B05):=203D=20=EC=A0=88=ED=86=A0=20?= =?UTF-8?q?=EA=B5=AC=EA=B0=84=20=EC=B8=A1=EC=A0=90=20=ED=91=9C=EA=B3=A0=20?= =?UTF-8?q?=C2=B7=20=EC=A0=84=20=EC=B8=A1=EC=A0=90=20=EB=9D=BC=EB=B2=A8=20?= =?UTF-8?q?=EA=B1=B0=EB=A6=AC=20=EC=86=8E=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 사용자 지적(2026-09-04) — 「절토가 된 경우 측점 표시가 안 따라감」, 「측점 라벨이 5측점 기준으로만 붙음, 전체 라벨이 있으면 좋겠음」. - 측점 막대 표고에서 `max(지반, 계획고)` 제거. 코리도가 켜져 있으면 계획고를 그대로 씀 — 절토는 계획고가 지반보다 아래라 max 로는 막대만 원지반에 떠 있었음 (실측 0측점 3.2m 공중). - 라벨을 규칙 측점 전부에 만들고(`STATION_LABEL_STEP` 제거), 카메라 거리로 솎음 (`LABEL_LOD` 150m 미만 전부 · 400m 미만 2칸 · 그 밖 5칸). 구조물·BP·EP 는 항상. - 뷰어 렌더 루프에서 카메라~시점 거리로 단계를 갱신(단계 불변이면 즉시 반환). 자체검증(공용 브라우저 5173, 용화_LAS) — 절토 3측점·성토 10측점 모두 막대와 코리도 표면 높이차 0.80m(띄움값). 라벨 66개 중 화면맞춤 23 · 중간 39 · 근접 66. Co-Authored-By: Claude Opus 5 --- B05_Profile/B05_Profile_UI_Markers.ts | 57 +++++++++++++++++++++------ B05_Profile/B05_Profile_UI_Page.ts | 8 ++-- B05_Profile/B05_Profile_UI_Viewer.ts | 2 + 3 files changed, 51 insertions(+), 16 deletions(-) diff --git a/B05_Profile/B05_Profile_UI_Markers.ts b/B05_Profile/B05_Profile_UI_Markers.ts index 371a9842..7e304fa5 100644 --- a/B05_Profile/B05_Profile_UI_Markers.ts +++ b/B05_Profile/B05_Profile_UI_Markers.ts @@ -43,8 +43,16 @@ export interface SectionStationMarker { structure?: string; } -/** 규칙 측점 라벨을 몇 칸마다 달지. 전부 달면 글자가 겹쳐 도면을 못 읽는다. */ -const STATION_LABEL_STEP = 5; +/** + * 규칙 측점 라벨 솎기 — 라벨은 **전 측점에 만들어 두고** 카메라 거리로 골라 보인다 + * (2026-09-04 사용자 지시 「전체 라벨이 있으면 좋겠음」). 멀면 글자가 겹치므로 + * 5칸 → 2칸 → 전부로 단계를 올린다. 경계는 카메라~시점거리(m). + */ +const LABEL_LOD: ReadonlyArray<{ within: number; step: number }> = [ + { within: 150, step: 1 }, + { within: 400, step: 2 }, + { within: Infinity, step: 5 }, +]; // 측점 바 양 끝 원형 램프 색: 상단(등고 높은 쪽) 예상측=주황, 반대측=회색. const UPHILL_LAMP_COLOR = 0xf97316; @@ -293,28 +301,42 @@ export function createRouteMarkers( * * BP·EP — 시·종점은 항상. 이름을 앞에 붙여 어느 끝인지 바로 읽히게 한다. * 구조물(비정규) — 측점번호 + 구조물 이름(배관 등). - * 5측점 배수 — 규칙 측점은 5칸마다만. 전부 달면 글자가 겹쳐 도면을 못 읽는다. + * 규칙 측점 — 전부 만든다. 몇 개를 보일지는 카메라 거리가 정한다(`LABEL_LOD`). * * 측점번호는 라벨 표기(`측점번호+잔여거리`)에서 되짚는다 — 측점간격은 렌더러가 모른다. - * 잔여거리가 남은 측점(예: `4+12.3`)은 규칙 격자가 아니므로 배수 판정에서 뺀다. + * 잔여거리가 남은 측점(예: `4+12.3`)은 규칙 격자가 아니므로 솎기 판정에서 뺀다 + * (`number: null` = 거리와 무관하게 항상 보임). */ - function stationLabelText(station: SectionStationMarker, intervalM: number): string | null { + function stationLabelText( + station: SectionStationMarker, + intervalM: number, + ): { text: string; number: number | null } | null { const chainage = station.chainage_m; if (!Number.isFinite(chainage)) return null; // 표기는 종단 그래프·도면 테이블과 **같은 규칙**(`측점번호+잔여거리`)을 쓴다. // 서버가 내려주는 `label`(`STA.0+000.000`)을 그대로 쓰면 화면마다 표기가 갈린다. const text = stationLabel(chainage as number, intervalM); - if (station.kind === "bp") return `BP ${text}`; - if (station.kind === "ep") return `EP ${text}`; + if (station.kind === "bp") return { text: `BP ${text}`, number: null }; + if (station.kind === "ep") return { text: `EP ${text}`, number: null }; if (station.kind === "irregular") { const structure = station.structure?.trim(); - return structure ? `${text} ${structure}` : text; + return { text: structure ? `${text} ${structure}` : text, number: null }; } const safeInterval = intervalM > 0 ? intervalM : 1; const stationNumber = Math.round((chainage as number) / safeInterval); const remainder = (chainage as number) - stationNumber * safeInterval; if (Math.abs(remainder) > 0.05) return null; - return stationNumber % STATION_LABEL_STEP === 0 ? text : null; + return { text, number: stationNumber }; + } + + /** 지금 솎기 단계(몇 칸마다 보일지). 카메라 거리로 바뀐다. */ + let labelStep = LABEL_LOD[LABEL_LOD.length - 1].step; + + function applyLabelStep(): void { + stationLabelGroup.children.forEach((child) => { + const number = (child.userData as { stationNumber?: number | null }).stationNumber; + child.visible = typeof number !== "number" || number % labelStep === 0; + }); } /** @@ -405,9 +427,11 @@ export function createRouteMarkers( // 측점 바 양 끝 원형 램프: 상단(등고 높은 쪽) 예상측 컬러, 반대측 회색. // 클릭하면 그 측을 상단측(=측구 방향)으로 지정한다(onUphillPick). - const labelText = stationLabelText(station, stationIntervalM); - if (labelText) { - stationLabelGroup.add(stationLabelSprite(labelText, modelToScene(center, bounds))); + const label = stationLabelText(station, stationIntervalM); + if (label) { + const sprite = stationLabelSprite(label.text, modelToScene(center, bounds)); + sprite.userData.stationNumber = label.number; + stationLabelGroup.add(sprite); } (["left", "right"] as const).forEach((side, endIndex) => { @@ -435,6 +459,8 @@ export function createRouteMarkers( stationGroup.add(lampHit); }); }); + // 새로 만든 라벨에도 지금 솎기 단계를 그대로 먹인다. + applyLabelStep(); // 재렌더로 좌표가 갱신됐으니 선택 핀도 그 자리로 다시 놓는다. syncSelectionPin(); } @@ -537,6 +563,13 @@ export function createRouteMarkers( setStationLabelsVisible(visible: boolean) { stationLabelGroup.visible = visible; }, + /** 카메라~시점 거리(m)로 규칙 측점 라벨을 솎는다. 구조물·BP·EP 는 늘 보인다. */ + updateLabelDetail(distanceM: number) { + const step = (LABEL_LOD.find((lod) => distanceM < lod.within) ?? LABEL_LOD[0]).step; + if (step === labelStep) return; + labelStep = step; + applyLabelStep(); + }, onChange(listener: (next: RouteDesignPoints) => void) { changeListener = listener; }, diff --git a/B05_Profile/B05_Profile_UI_Page.ts b/B05_Profile/B05_Profile_UI_Page.ts index ba1a5267..10bee531 100644 --- a/B05_Profile/B05_Profile_UI_Page.ts +++ b/B05_Profile/B05_Profile_UI_Page.ts @@ -423,10 +423,10 @@ export async function renderB05Route(root: HTMLElement): Promise { const design = designAt(station.chainage_m); return { ...station, - center_z: - design !== null && station.center_z !== null - ? Math.max(station.center_z, design) - : station.center_z, + // 절토 구간에서는 계획고가 지반보다 **아래**다(2026-09-04 사용자 지적). + // max 로 잡으면 코리도가 절취해 내려간 노면을 두고 막대만 원지반에 떠 있다. + // 코리도가 켜져 있으면(designAt 이 값을 줌) 계획고를 그대로 쓴다. + center_z: design !== null && station.center_z !== null ? design : station.center_z, uphill_side: uphillOverrides.get(uphillKey(station.chainage_m)) ?? station.uphill_side ?? null, }; diff --git a/B05_Profile/B05_Profile_UI_Viewer.ts b/B05_Profile/B05_Profile_UI_Viewer.ts index 8d067309..c09b1096 100644 --- a/B05_Profile/B05_Profile_UI_Viewer.ts +++ b/B05_Profile/B05_Profile_UI_Viewer.ts @@ -367,6 +367,8 @@ export function createRouteViewer(): RouteViewer { } else { compass.setVisible(false); } + // 측점 라벨 솎기 — 가까울수록 촘촘히 보인다(단계가 안 바뀌면 모듈 안에서 걸러낸다). + markers.updateLabelDetail(camera.position.distanceTo(controls.target)); renderer.render(scene, camera); } animate(); From 4be8c19e97f00df9263c7e201e61543e50ab3771 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 4 Sep 2026 18:36:46 +0900 Subject: [PATCH 05/11] =?UTF-8?q?feat(B06):=20=ED=91=9C=EC=A4=80=20?= =?UTF-8?q?=ED=9A=A1=EB=8B=A8=EB=A9=B4=20=EC=84=A4=EC=A0=95=EC=9D=84=20?= =?UTF-8?q?=E3=80=8C=ED=91=9C=EC=A4=80=ED=9A=A1=EB=8B=A8=EB=A9=B4=20?= =?UTF-8?q?=EC=83=81=EC=84=B8=EA=B0=92=E3=80=8D=20=ED=95=9C=20=EC=BB=A8?= =?UTF-8?q?=ED=85=8C=EC=9D=B4=EB=84=88=EB=A1=9C=20=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 토사/암/포장 3그룹이 같은 필드를 각각 갖던 화면을 공통 한 벌로 합침 (칸 31 → 15) - 공통 = 노폭·노견·측구·절토/성토 경사·횡단 경사, 구분선 뒤 암 = 절토 경사·L형 측구, 구분선 뒤 포장 = 횡단 경사 - 저장 구조는 3그룹 그대로 — 공통값은 고칠 때 세 그룹에 펼쳐 넣음 - 옛 프로젝트가 그룹마다 다른 공통값을 갖고 있으면 토사 값 기준으로 한 벌 통일 (사용자 확정 2026-09-04) Co-Authored-By: Claude Opus 5 (1M context) --- B06_Section/B06_Section_UI_Standard_Panel.ts | 201 ++++++++++++++----- B06_Section/B06_Section_UI_Style.css | 11 + ui_template/ui_template_locale_b2.ts | 4 + 3 files changed, 161 insertions(+), 55 deletions(-) diff --git a/B06_Section/B06_Section_UI_Standard_Panel.ts b/B06_Section/B06_Section_UI_Standard_Panel.ts index a74ccf3b..d2154fea 100644 --- a/B06_Section/B06_Section_UI_Standard_Panel.ts +++ b/B06_Section/B06_Section_UI_Standard_Panel.ts @@ -1,6 +1,12 @@ /* ============================================================================= * B06_Section_UI_Standard_Panel.ts - * 좌측 사이드 "표준 횡단면 설정" 패널 (토사 / 암 / 포장 3그룹). + * 좌측 사이드 "표준 횡단면 설정" 패널 — **「표준횡단면 상세값」 한 컨테이너**(2026-09-04 + * 사용자 지시). 토사·암·포장 3그룹이 같은 필드를 각각 갖던 화면을 공통 한 벌 + + * 구간별로 다른 값만 남겼다. 공통 = 노폭·노견·측구·절토/성토 경사·횡단 경사, + * 암 = 절토 경사·L형 측구, 포장 = 횡단 경사. + * + * **저장 구조는 그대로 3그룹**(`standard_cross_section`) — 화면만 합치고 저장할 때 + * 공통값을 세 그룹에 펼쳐 넣는다. 백엔드·기존 프로젝트가 그대로 동작한다. * * 각 그룹의 노폭·노견·측구 규격·경사값을 편집한다. 기본값은 백엔드 config * (STANDARD_CROSS_SECTION, context.standard_cross_section)에서 내려오고, 사용자가 @@ -26,12 +32,6 @@ function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; } -const GROUP_ORDER: Array<[StandardCrossKey, keyof typeof ui_locales]> = [ - ["soil", "B06_Std_Group_Soil"], - ["rock", "B06_Std_Group_Rock"], - ["paved", "B06_Std_Group_Paved"], -]; - const SESSION_PREFIX = "b06:std-cross:"; /** config 기본값 사본 — 편집값(`SESSION_PREFIX`)과 수명은 같되 용도가 다르다. */ const DEFAULTS_PREFIX = "b06:std-cross-default:"; @@ -151,49 +151,98 @@ export interface StandardPanelController { applyStored: (stored: StandardCrossSection) => void; } +/** 값을 어느 그룹에 쓸 것인가. `common` 은 세 그룹에 함께 펼쳐 넣는다. */ +type FieldScope = "common" | "rock" | "paved"; + interface NumberFieldSpec { - label: string; + label: keyof typeof ui_locales; + scope: FieldScope; + /** 화면에 보일 값을 읽는다 — 공통은 **토사 값이 기준**(2026-09-04 사용자 확정). */ get: (group: StandardCrossGroup) => number; set: (group: StandardCrossGroup, value: number) => void; - /** 암 그룹의 L형 측구처럼 특정 그룹에만 존재하는 필드는 조건으로 거른다. */ - only?: StandardCrossKey; + /** + * 공통값이지만 이 그룹은 따로 값을 갖는다 — 공통을 펼칠 때 건너뛴다. + * (절토 경사는 암이, 횡단 경사는 포장이 자기 값을 쓴다) + */ + exclude?: StandardCrossKey; } -/** 그룹 하나에 노출할 편집 필드 정의. 순서 = 화면 표기 순서. */ +/** 한 컨테이너에 늘어놓을 편집 필드. 순서 = 화면 표기 순서. */ const FIELD_SPECS: NumberFieldSpec[] = [ { label: "B06_Std_Field_RoadWidth", + scope: "common", get: (g) => g.road_width_m, set: (g, v) => (g.road_width_m = v), }, { label: "B06_Std_Field_ShoulderLeft", + scope: "common", get: (g) => g.shoulder_left_m, set: (g, v) => (g.shoulder_left_m = v), }, { label: "B06_Std_Field_ShoulderRight", + scope: "common", get: (g) => g.shoulder_right_m, set: (g, v) => (g.shoulder_right_m = v), }, { label: "B06_Std_Field_DitchTop", + scope: "common", get: (g) => g.ditch.top_width_m, set: (g, v) => (g.ditch.top_width_m = v), }, { label: "B06_Std_Field_DitchBottom", + scope: "common", get: (g) => g.ditch.bottom_width_m, set: (g, v) => (g.ditch.bottom_width_m = v), }, { label: "B06_Std_Field_DitchDepth", + scope: "common", get: (g) => g.ditch.depth_m, set: (g, v) => (g.ditch.depth_m = v), }, + { + // 암은 아래에서 자기 절토 경사를 따로 가진다 — 공통은 토사·포장 몫이다. + label: "B06_Std_Field_CutSlope", + scope: "common", + exclude: "rock", + get: (g) => g.cut_slope_ratio, + set: (g, v) => (g.cut_slope_ratio = v), + }, + { + label: "B06_Std_Field_FillSlope", + scope: "common", + get: (g) => g.fill_slope_ratio, + set: (g, v) => (g.fill_slope_ratio = v), + }, + { + // 포장은 아래에서 자기 횡단 경사를 따로 가진다. + label: "B06_Std_Field_CrossSlopeMin", + scope: "common", + exclude: "paved", + get: (g) => g.cross_slope_pct.min, + set: (g, v) => (g.cross_slope_pct.min = v), + }, + { + label: "B06_Std_Field_CrossSlopeMax", + scope: "common", + exclude: "paved", + get: (g) => g.cross_slope_pct.max, + set: (g, v) => (g.cross_slope_pct.max = v), + }, + { + label: "B06_Std_Field_CutSlope", + scope: "rock", + get: (g) => g.cut_slope_ratio, + set: (g, v) => (g.cut_slope_ratio = v), + }, { label: "B06_Std_Field_LDitchWidth", - only: "rock", + scope: "rock", get: (g) => g.ditch_l_type?.width_m ?? 0, set: (g, v) => { g.ditch_l_type = { width_m: v, depth_m: g.ditch_l_type?.depth_m ?? 0 }; @@ -201,34 +250,49 @@ const FIELD_SPECS: NumberFieldSpec[] = [ }, { label: "B06_Std_Field_LDitchDepth", - only: "rock", + scope: "rock", get: (g) => g.ditch_l_type?.depth_m ?? 0, set: (g, v) => { g.ditch_l_type = { width_m: g.ditch_l_type?.width_m ?? 0, depth_m: v }; }, }, - { - label: "B06_Std_Field_CutSlope", - get: (g) => g.cut_slope_ratio, - set: (g, v) => (g.cut_slope_ratio = v), - }, - { - label: "B06_Std_Field_FillSlope", - get: (g) => g.fill_slope_ratio, - set: (g, v) => (g.fill_slope_ratio = v), - }, { label: "B06_Std_Field_CrossSlopeMin", + scope: "paved", get: (g) => g.cross_slope_pct.min, set: (g, v) => (g.cross_slope_pct.min = v), }, { label: "B06_Std_Field_CrossSlopeMax", + scope: "paved", get: (g) => g.cross_slope_pct.max, set: (g, v) => (g.cross_slope_pct.max = v), }, ]; +/** 공통 필드 하나를 그룹들에 펼쳐 넣는다(자기 값을 갖는 그룹은 건너뛴다). */ +function spread(state: StandardCrossSection, spec: NumberFieldSpec, value: number): void { + for (const key of ["soil", "rock", "paved"] as StandardCrossKey[]) { + if (spec.exclude === key) continue; + const group = state[key]; + if (group) spec.set(group, value); + } +} + +/** + * 그룹마다 공통값이 다르게 저장돼 있을 수 있다(옛 프로젝트) — **토사 값을 기준**으로 + * 한 벌로 맞춘다(2026-09-04 사용자 확정). 화면은 값 하나를 보이는데 저장분이 셋으로 + * 갈려 있으면 어느 값이 나갔는지 알 수 없기 때문이다. + */ +function unifyCommon(state: StandardCrossSection): void { + const soil = state.soil; + if (!soil) return; + for (const spec of FIELD_SPECS) { + if (spec.scope !== "common") continue; + spread(state, spec, spec.get(soil)); + } +} + /** * 표준 횡단면 설정 패널을 만든다. * @param projectId 세션 캐시 스코프. @@ -258,49 +322,73 @@ export function createStandardPanel( const persist = (): void => writeSession(projectId, state); - const buildGroup = (key: StandardCrossKey, legendKey: keyof typeof ui_locales): HTMLElement => { - const group = state[key]; - // B05 "기준값 직접 지정"과 동일한 details/summary 패턴, 기본 접힘(N-4-1). - const fieldset = document.createElement("details"); - fieldset.className = "b06-std__group"; - const legend = document.createElement("summary"); - legend.className = "b06-std__legend"; - legend.textContent = L(legendKey); - fieldset.append(legend); + /** 필드 한 칸. 공통은 토사 값을 보이고, 고치면 세 그룹에 함께 펼친다. */ + const buildField = (spec: NumberFieldSpec, grid: HTMLElement): void => { + const source = spec.scope === "common" ? state.soil : state[spec.scope]; + if (!source) return; + const field = createInputField({ + label: L(spec.label), + type: "number", + value: String(spec.get(source)), + onInput: (raw) => { + const parsed = Number(raw); + if (!Number.isFinite(parsed)) return; + if (spec.scope === "common") spread(state, spec, parsed); + else spec.set(source, parsed); + persist(); + }, + }); + field.input.step = "0.1"; + field.input.min = "0"; + grid.append(field.root); + }; + /** 구분선 + 구간 이름 — 아래 값들이 그 구간에서만 쓰인다는 표시. */ + const buildDivider = (labelKey: keyof typeof ui_locales): HTMLElement => { + const divider = document.createElement("p"); + divider.className = "b06-std__divider"; + divider.textContent = L(labelKey); + return divider; + }; + + const buildScope = (scope: FieldScope): HTMLElement => { const grid = document.createElement("div"); grid.className = "b06-std__grid"; for (const spec of FIELD_SPECS) { - if (spec.only && spec.only !== key) continue; - const field = createInputField({ - label: L(spec.label as keyof typeof ui_locales), - type: "number", - value: String(spec.get(group)), - onInput: (raw) => { - const parsed = Number(raw); - if (!Number.isFinite(parsed)) return; - spec.set(group, parsed); - persist(); - }, - }); - field.input.step = "0.1"; - field.input.min = "0"; - grid.append(field.root); + if (spec.scope === scope) buildField(spec, grid); } - fieldset.append(grid); + return grid; + }; - if (key === "rock") { - const note = document.createElement("p"); - note.className = "b06-std__note"; - note.textContent = L("B06_Std_LType_Note"); - fieldset.append(note); - } + /** 「표준횡단면 상세값」 한 컨테이너 — 공통 → 암 → 포장 순, 구분선으로 나눈다. */ + const buildDetails = (): HTMLElement => { + const fieldset = document.createElement("details"); + fieldset.className = "b06-std__group"; + fieldset.open = true; + const legend = document.createElement("summary"); + legend.className = "b06-std__legend"; + legend.textContent = L("B06_Std_Detail_Title"); + const note = document.createElement("p"); + note.className = "b06-std__note"; + note.textContent = L("B06_Std_LType_Note"); + fieldset.append( + legend, + buildScope("common"), + buildDivider("B06_Std_Section_RockOnly"), + buildScope("rock"), + buildDivider("B06_Std_Section_PavedOnly"), + buildScope("paved"), + note, + ); return fieldset; }; const renderBody = (): void => { - body.replaceChildren(...GROUP_ORDER.map(([key, legendKey]) => buildGroup(key, legendKey))); + body.replaceChildren(buildDetails()); }; + // 옛 저장분이 그룹마다 다른 공통값을 갖고 있으면 토사 기준으로 한 벌로 맞춘다. + unifyCommon(state); + persist(); renderBody(); /** 소스 표준값을 현재 상태에 전부 덮어쓴다(사용자가 "적용"을 눌렀을 때만 호출). */ @@ -308,6 +396,7 @@ export function createStandardPanel( (Object.keys(source) as StandardCrossKey[]).forEach((key) => { if (source[key]) state[key] = JSON.parse(JSON.stringify(source[key])) as StandardCrossGroup; }); + unifyCommon(state); persist(); renderBody(); }; @@ -324,6 +413,7 @@ export function createStandardPanel( (Object.keys(fresh) as StandardCrossKey[]).forEach((key) => { state[key] = fresh[key]; }); + unifyCommon(state); persist(); renderBody(); }, @@ -354,6 +444,7 @@ export function createStandardPanel( (Object.keys(stored) as StandardCrossKey[]).forEach((key) => { if (stored[key]) state[key] = JSON.parse(JSON.stringify(stored[key])) as StandardCrossGroup; }); + unifyCommon(state); renderBody(); }, }; diff --git a/B06_Section/B06_Section_UI_Style.css b/B06_Section/B06_Section_UI_Style.css index bc473aed..e405ef0a 100644 --- a/B06_Section/B06_Section_UI_Style.css +++ b/B06_Section/B06_Section_UI_Style.css @@ -287,6 +287,17 @@ gap: var(--spacing-8); } +/* 구간 구분선 — 「표준횡단면 상세값」 한 컨테이너 안에서 공통 / 암 / 포장을 가른다 + (2026-09-04 사용자 지시: 공통 항목은 지우고 구분선을 쓸 것). */ +.b06-std__divider { + margin: var(--spacing-4) 0 0; + padding-top: var(--spacing-8); + border-top: 1px solid var(--color-border); + font-size: var(--text-caption); + font-weight: var(--font-weight-medium); + color: var(--color-text-secondary); +} + .b06-std__note { margin: 0; font-size: var(--text-caption); diff --git a/ui_template/ui_template_locale_b2.ts b/ui_template/ui_template_locale_b2.ts index 5877b4ab..3e21a402 100644 --- a/ui_template/ui_template_locale_b2.ts +++ b/ui_template/ui_template_locale_b2.ts @@ -506,6 +506,10 @@ export const ui_locales_b2 = { B06_Std_Group_Soil: ["토사 구간", "Soil section"], B06_Std_Group_Rock: ["암 구간 (리핑/발파)", "Rock section (ripping/blasting)"], B06_Std_Group_Paved: ["포장 구간", "Paved section"], + B06_Std_Detail_Title: ["표준횡단면 상세값", "Standard cross-section details"], + B06_Std_Section_Common: ["공통", "Common"], + B06_Std_Section_RockOnly: ["암 구간 — 다른 값만", "Rock section - differing values"], + B06_Std_Section_PavedOnly: ["포장 구간 — 다른 값만", "Paved section - differing values"], B06_Std_Field_RoadWidth: ["노폭(m)", "Road width (m)"], B06_Std_Field_ShoulderLeft: ["노견 좌(m)", "Shoulder L (m)"], B06_Std_Field_ShoulderRight: ["노견 우(m)", "Shoulder R (m)"], From 6dd0dec93a5c4b7083d528a12bba2d096d856328 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 4 Sep 2026 18:38:43 +0900 Subject: [PATCH 06/11] =?UTF-8?q?feat(B07):=20=EA=B3=84=ED=9A=8D=ED=8F=89?= =?UTF-8?q?=EB=A9=B4=EB=8F=84=203=EC=A2=85=20=E2=80=94=20=EC=88=98?= =?UTF-8?q?=EC=B9=98=EB=93=B1=EA=B3=A0=EC=84=A0=20=EB=B0=B0=EA=B2=BD=20?= =?UTF-8?q?=EC=9C=84=20=EB=85=B8=EC=84=A0=C2=B7=EC=B8=A1=EC=A0=90=C2=B7?= =?UTF-8?q?=EA=B5=AC=EC=A1=B0=EB=AC=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 빈 도각이던 계획평면도(지형·노선배치도·배치도)를 실제 도면으로 만듦. - 배경 공용화: map_background() 한 창구로 도엽 등고선·세류선 읽기·좌표 환산· 도곽 절취를 모음. 유역도와 계획평면도가 같은 것을 부르고, 환산 결과는 파일 mtime 을 키로 캐시(_metric_lines_cached). - 축척 1/1,200 고정(DRAWING_SCALE_PLAN) — 지식DB 「설계제원_총괄」 측량·도면 기준. 횡단면도와 같은 원칙으로 줄이지 않고 안 들어가면 장을 나눔(plan_chunks, 종단 측점 기준·경계 측점 1개 중복). - 세 장이 같은 배경·같은 도곽 배치를 쓰고 주제만 다름. 측점 눈금은 종단 측점 좌표로 찍고, 구조물은 pipe_points.json 정본을 읽어 마름모+이름으로 표기. - 도면 목록·단건 조회에 kind="plan" 추가. 화면 목록은 id 접두어로 묶어 장이 나뉘어도 한 그룹으로 보임. 검증(용화_LAS): 콘텐츠 734.3x489.2 mm ≤ A1 작도영역 739.2x499.2, 노선 실거리 630.1x214.2 m → 종이 525.12x178.46 mm(실측 0.83333 mm/m = 1/1,200 일치), 배경 등고선 180줄이 세 장 동일, 유역도 8.5초 → 계획평면도 1.3초(캐시 적중, 파일 재읽기 없음). Co-Authored-By: Claude Opus 5 (1M context) --- .../B07_DesignDetail_Api_Fetch.ts | 53 ++- .../B07_DesignDetail_Engine_Cad_Plan.py | 388 ++++++++++++++++++ B07_DesignDetail/B07_DesignDetail_Router.py | 10 + .../B07_DesignDetail_Router_Support.py | 59 ++- .../B07_DesignDetail_Router_Support_Basin.py | 248 ++++++++--- B07_DesignDetail/B07_DesignDetail_Schema.py | 4 +- .../B07_DesignDetail_UI_Panels.ts | 56 ++- config/config_system.py | 3 + 8 files changed, 729 insertions(+), 92 deletions(-) create mode 100644 B07_DesignDetail/B07_DesignDetail_Engine_Cad_Plan.py diff --git a/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts b/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts index accd314c..2a29b156 100644 --- a/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts +++ b/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts @@ -5,7 +5,14 @@ import { API_BASE_URL, API_TIMEOUT_MS } from "@config/config_frontend"; export interface DesignDrawingItem { id: string; // blank: 아직 내용을 만들지 않은 도면 — 도각만 실려 온다. - kind: "cover" | "longitudinal" | "cross" | "mass_haul" | "watershed" | "blank"; + kind: + | "cover" + | "longitudinal" + | "cross" + | "mass_haul" + | "watershed" + | "plan" + | "blank"; label: string; chainage_m: number | null; confirmed: boolean; @@ -67,7 +74,10 @@ export interface CrossDesignInfo { cross_slope_pct?: number; paved?: boolean; ditch: DitchSpec; - road_edges?: Record<"left" | "right", { offset_m: number; elevation_m: number }>; + road_edges?: Record< + "left" | "right", + { offset_m: number; elevation_m: number } + >; design_elevation_m: number; cut_area_m2: number; fill_area_m2: number; @@ -80,7 +90,14 @@ export interface DesignDrawingResponse { route_id: number; id: string; // blank: 아직 내용을 만들지 않은 도면 — 도각만 실려 온다. - kind: "cover" | "longitudinal" | "cross" | "mass_haul" | "watershed" | "blank"; + kind: + | "cover" + | "longitudinal" + | "cross" + | "mass_haul" + | "watershed" + | "plan" + | "blank"; label: string; drawing: CadDrawing; confirmed: boolean; @@ -97,7 +114,10 @@ export interface DesignDrawingConfirmResponse { design?: CrossDesignInfo | null; } -async function requestJson(path: string, init: RequestInit = {}): Promise { +async function requestJson( + path: string, + init: RequestInit = {}, +): Promise { const controller = new AbortController(); const timeoutId = window.setTimeout(() => controller.abort(), API_TIMEOUT_MS); try { @@ -108,14 +128,17 @@ async function requestJson(path: string, init: RequestInit = {}): Promise signal: controller.signal, }); const payload = (await response.json()) as T & { message?: string }; - if (!response.ok) throw new Error(payload.message ?? `HTTP ${response.status}`); + if (!response.ok) + throw new Error(payload.message ?? `HTTP ${response.status}`); return payload; } finally { window.clearTimeout(timeoutId); } } -export function fetchDesignDrawingList(projectId: string): Promise { +export function fetchDesignDrawingList( + projectId: string, +): Promise { return requestJson(`/projects/${projectId}/design-drawings`); } @@ -123,7 +146,9 @@ export function fetchDesignDrawing( projectId: string, drawingId: string, ): Promise { - return requestJson(`/projects/${projectId}/design-drawings/${encodeURIComponent(drawingId)}`); + return requestJson( + `/projects/${projectId}/design-drawings/${encodeURIComponent(drawingId)}`, + ); } export function confirmDesignDrawing( @@ -141,7 +166,10 @@ export function confirmDesignDrawing( ); } -export function invalidateDesignDrawing(projectId: string, drawingId: string): Promise { +export function invalidateDesignDrawing( + projectId: string, + drawingId: string, +): Promise { return requestJson( `/projects/${projectId}/design-drawings/${encodeURIComponent(drawingId)}/invalidate`, { method: "POST" }, @@ -157,11 +185,16 @@ export interface FrameTemplateResponse { customized: boolean; } -export function fetchFrameTemplate(projectId: string): Promise { +export function fetchFrameTemplate( + projectId: string, +): Promise { return requestJson(`/projects/${projectId}/frame-template`); } -export function saveFrameTemplate(projectId: string, drawing: CadDrawing): Promise { +export function saveFrameTemplate( + projectId: string, + drawing: CadDrawing, +): Promise { return requestJson(`/projects/${projectId}/frame-template`, { method: "PUT", body: JSON.stringify({ drawing }), diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Plan.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Plan.py new file mode 100644 index 00000000..18da7be6 --- /dev/null +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Plan.py @@ -0,0 +1,388 @@ +"""B07 계획평면도 CAD 조립 — 수치등고선 배경 위에 노선·측점·구조물을 얹는다. + +세 장이 같은 배경·같은 축척을 쓰고 주제만 다르다(2026-09-04 사용자 지시). + + - 계획평면도(지형) : 등고선·세류선만 + - 계획평면도(노선배치도): 배경 + 계획노선 + 측점 + - 계획평면도(배치도) : 배경 + 계획노선 + 구조물 배치 + +배경 자료는 유역도와 **같은 창구**(`B07_DesignDetail_Router_Support_Basin.map_background`) +에서 온다 — 도엽 GeoJSON 읽기·좌표 환산은 한 번뿐이고 여러 도면이 그 결과를 나눠 쓴다. + +축척은 지식DB 「설계제원_총괄」 측량·도면 기준 **1/1,200 고정**이다. 횡단면도와 같은 +원칙으로, 한 장에 안 들어가면 축척을 줄이지 않고 **장을 나눈다**. + +좌표 규약: 종이 mm = (사업지 좌표 m - 그 장 콘텐츠 최소점) x MM (1/1,200 -> 1 m = 5/6 mm). +""" + +import math +from typing import Any + +from B07_DesignDetail.B07_DesignDetail_Engine_Cad import ( + DRAWING_FORMAT, + FRAME_LAYER_ID, + TABLE_LABEL_COLOR, + _layer, + _text_entity, + polyline_entity, + station_plus_label, +) +from B07_DesignDetail.B07_DesignDetail_Engine_Template import ( + compass_entities, + entities_bbox, + frame_entities, + scale_fields, + usable_area, +) +from config.config_system import DRAWING_SCALE_PLAN + +# 도면 좌표 = 종이 mm. 실거리 1 m가 종이에서 차지하는 mm (1/1,200 -> 0.8333). +MM = 1000.0 / DRAWING_SCALE_PLAN + +CONTOUR_LAYER_ID = "b07-plan-contour" +CONTOUR_COLOR = "#6b7684" +STREAM_LAYER_ID = "b07-plan-stream" +STREAM_COLOR = "#4d9dff" +ROUTE_LAYER_ID = "b07-plan-route" +ROUTE_COLOR = "#ffe066" +STATION_LAYER_ID = "b07-plan-station" +STATION_COLOR = "#ff9d4d" +STRUCTURE_LAYER_ID = "b07-plan-structure" +STRUCTURE_COLOR = "#ff4d4d" +TITLE_LAYER_ID = "b07-plan-title" + +_ROUTE_WIDTH = 3 +_TITLE_FONT_SIZE = 7.0 +_FONT_SIZE = 2.2 +_STATION_FONT_SIZE = 2.0 +_STATION_TICK_MM = 2.5 # 측점 눈금 반길이(종이 mm) +_STRUCTURE_SIZE_MM = 3.0 # 구조물 기호 반크기(종이 mm) +_STRUCTURE_FONT_SIZE = 2.2 +_TITLE_BAND = 22.0 # 제목·척도가 차지하는 위쪽 띠(mm) +_COMPASS_SIZE = 26.0 +_COMPASS_MARGIN = 12.0 + +# 세 장의 주제 (id 접두어, 도면명, 노선·측점·구조물을 그리는지). +PLAN_KINDS: tuple[tuple[str, str, bool, bool, bool], ...] = ( + ("plan_terrain", "계획평면도(지형)", False, False, False), + ("plan_route", "계획평면도(노선배치도)", True, True, False), + ("plan_layout", "계획평면도(배치도)", True, False, True), +) +PLAN_KIND_LABELS: dict[str, str] = {kind: label for kind, label, *_rest in PLAN_KINDS} + +# 구조물 종류별 표기 — pipe_points.json 의 facility 값 기준. +_FACILITY_LABELS: dict[str, str] = { + "ford_bridge": "세월교", + "box_culvert": "BOX암거", + "bridge": "교량", +} + + +def plan_area_mm() -> tuple[float, float]: + """지형 배경이 차지할 수 있는 크기(mm) — A1 작도영역에서 방위표 칸과 제목 띠를 뺀다. + + 라우터는 이 크기를 축척으로 되돌려 등고선·세류선 절취 범위를 잡는다(정의처 한 곳). + """ + width, height = usable_area() + return (width - (_COMPASS_MARGIN + _COMPASS_SIZE), height - _TITLE_BAND) + + +def _chunk_span_m() -> tuple[float, float]: + """한 장이 담을 수 있는 실거리(m) — 도곽 지형 영역을 축척으로 되돌린 크기.""" + area_w, area_h = plan_area_mm() + return (area_w * DRAWING_SCALE_PLAN / 1000.0, area_h * DRAWING_SCALE_PLAN / 1000.0) + + +def plan_chunks(stations: list[tuple[float, float, float]]) -> list[dict[str, Any]]: + """노선을 한 장에 들어가는 구간으로 나눈다. 각 항목: {number, start_m, end_m}. + + 입력은 종단 측점의 (누가거리 m, x, y)다 — **도면 목록과 도면 생성이 같은 자료**를 + 보아야 장수가 어긋나지 않는다(종단도 분할과 같은 방식). + + 축척 1/1,200 은 고정이므로 한 장에 안 들어가면 **노선을 따라 장을 나눈다** + (2026-09-04 — 횡단면도와 같은 원칙). 경계 측점 1개를 중복시켜 장 사이에서 노선이 + 끊겨 보이지 않게 한다(납품 도면 관례). + """ + ordered = sorted(stations, key=lambda item: item[0]) + if len(ordered) < 2: + span = (ordered[0][0] if ordered else 0.0, ordered[0][0] if ordered else 0.0) + return [{"number": 1, "start_m": span[0], "end_m": span[1]}] + span_w, span_h = _chunk_span_m() + + def fits(part: list[tuple[float, float, float]]) -> bool: + width = max(x for _c, x, _y in part) - min(x for _c, x, _y in part) + height = max(y for _c, _x, y in part) - min(y for _c, _x, y in part) + # 가로로 길든 세로로 길든 도곽에만 들어가면 된다 — 두 방향 다 본다. + return (width <= span_w and height <= span_h) or (width <= span_h and height <= span_w) + + chunks: list[dict[str, Any]] = [] + start = 0 + while start < len(ordered) - 1: + end = start + 1 + while end + 1 < len(ordered) and fits(ordered[start : end + 2]): + end += 1 + chunks.append( + { + "number": len(chunks) + 1, + "start_m": float(ordered[start][0]), + "end_m": float(ordered[end][0]), + } + ) + start = end # 경계 측점 1개 중복 + return chunks + + +def plan_drawing_id(kind: str, chunk: dict[str, Any], total: int) -> str: + """장이 하나면 접두어 그대로, 여럿이면 `plan_route_2` 처럼 번호를 붙인다.""" + return kind if total <= 1 else f"{kind}_{chunk['number']}" + + +def plan_drawing_label(kind: str, chunk: dict[str, Any], total: int) -> str: + label = PLAN_KIND_LABELS.get(kind, kind) + return label if total <= 1 else f"{label} {chunk['number']}장" + + +def _structure_label(structure: dict[str, Any]) -> str: + """구조물 표기 — 세월교·BOX암거는 이름, 배수관은 관경(mm).""" + facility = structure.get("facility") + if isinstance(facility, str) and facility in _FACILITY_LABELS: + return _FACILITY_LABELS[facility] + options = structure.get("options") + diameter = options.get("pipe_diameter_mm") if isinstance(options, dict) else None + return f"D{int(diameter)}" if isinstance(diameter, (int, float)) else "배수시설" + + +def _station_entities( + drawing_id: str, + stations: list[tuple[float, float, float]], + interval_m: float, + paper: Any, +) -> list[dict[str, Any]]: + """측점 눈금과 이름(No.n+00)을 노선 위에 직각으로 세운다. + + 입력은 **종단 측점**(누가거리 m, x, y)이다 — 노선 정점은 수백 개라 전부 찍으면 + 뭉개지고, 정점의 누가거리는 측점 간격의 배수가 아니라 걸러지지도 않는다 + (2026-09-04 실측: 눈금이 2개만 찍혔음). + """ + entities: list[dict[str, Any]] = [] + for index, (chainage, x, y) in enumerate(stations): + point = (x, y) + before = stations[max(index - 1, 0)] + after = stations[min(index + 1, len(stations) - 1)] + dx, dy = after[1] - before[1], after[2] - before[2] + length = math.hypot(dx, dy) or 1.0 + # 노선 진행 방향의 법선 — 눈금을 노선과 직각으로 세운다. + nx, ny = -dy / length, dx / length + cx, cy = paper(point) + tick = polyline_entity( + drawing_id, + [ + (cx - nx * _STATION_TICK_MM, cy - ny * _STATION_TICK_MM), + (cx + nx * _STATION_TICK_MM, cy + ny * _STATION_TICK_MM), + ], + STATION_LAYER_ID, + STATION_COLOR, + suffix=f":tick:{index}", + ) + if tick: + entities.append(tick) + entities.append( + _text_entity( + f"{drawing_id}:station:{index}", + station_plus_label(chainage, interval_m), + cx + nx * (_STATION_TICK_MM + 1.5), + cy + ny * (_STATION_TICK_MM + 1.5), + STATION_LAYER_ID, + _STATION_FONT_SIZE, + STATION_COLOR, + ) + ) + return entities + + +def _structure_entities( + drawing_id: str, + structures: list[dict[str, Any]], + paper: Any, + box: tuple[float, float, float, float], +) -> list[dict[str, Any]]: + """구조물 위치를 마름모 기호 + 이름으로 찍는다. 이 장의 범위 밖은 건너뛴다.""" + entities: list[dict[str, Any]] = [] + min_x, min_y, max_x, max_y = box + for index, structure in enumerate(structures): + x, y = structure.get("x"), structure.get("y") + if not isinstance(x, (int, float)) or not isinstance(y, (int, float)): + continue + if not (min_x <= x <= max_x and min_y <= y <= max_y): + continue + cx, cy = paper((float(x), float(y))) + size = _STRUCTURE_SIZE_MM + marker = polyline_entity( + drawing_id, + [ + (cx, cy + size), + (cx + size, cy), + (cx, cy - size), + (cx - size, cy), + (cx, cy + size), + ], + STRUCTURE_LAYER_ID, + STRUCTURE_COLOR, + suffix=f":structure:{index}", + ) + if marker: + entities.append(marker) + entities.append( + _text_entity( + f"{drawing_id}:structure:label:{index}", + _structure_label(structure), + cx + size + 1.0, + cy, + STRUCTURE_LAYER_ID, + _STRUCTURE_FONT_SIZE, + STRUCTURE_COLOR, + ) + ) + return entities + + +def build_plan_drawing( + kind: str, + drawing_id: str, + label: str, + route_xy: list[tuple[float, float]], + stations: list[tuple[float, float, float]], + contours: list[list[tuple[float, float]]], + streams: list[list[tuple[float, float]]], + structures: list[dict[str, Any]], + interval_m: float = 20.0, +) -> dict[str, Any]: + """계획평면도 한 장을 만든다. 좌표는 모두 사업지 CRS(m)로 받아 종이 mm로만 옮긴다. + + `kind` 가 세 장 중 무엇을 그릴지 정한다(`PLAN_KINDS`). 배경은 세 장이 같다. + """ + with_route, with_station, with_structure = next( + ((r, s, t) for name, _label, r, s, t in PLAN_KINDS if name == kind), + (True, False, False), + ) + # 도곽 배치는 세 장이 같아야 한다 — 노선을 그리지 않는 지형도도 노선을 범위에 넣는다. + everything = [ + *route_xy, + *(point for line in contours for point in line), + *(point for line in streams for point in line), + ] + if not everything: + raise FileNotFoundError( + "계획평면도에 그릴 좌표가 없습니다. B04 전처리에서 수치지형도 도엽을 먼저 받으세요." + ) + min_x = min(x for x, _y in everything) + min_y = min(y for _x, y in everything) + max_x = max(x for x, _y in everything) + max_y = max(y for _x, y in everything) + + def paper(point: tuple[float, float]) -> tuple[float, float]: + return ((point[0] - min_x) * MM, (point[1] - min_y) * MM) + + entities: list[dict[str, Any]] = [] + for index, line in enumerate(contours): + contour = polyline_entity( + drawing_id, + [paper(point) for point in line], + CONTOUR_LAYER_ID, + CONTOUR_COLOR, + suffix=f":contour:{index}", + ) + if contour: + entities.append(contour) + for index, line in enumerate(streams): + stream = polyline_entity( + drawing_id, + [paper(point) for point in line], + STREAM_LAYER_ID, + STREAM_COLOR, + suffix=f":stream:{index}", + ) + if stream: + entities.append(stream) + + map_bbox = entities_bbox(entities) + + # 노선·측점·구조물은 배경 위에 얹는다 — 아래에 깔리면 등고선에 묻힌다. + if with_route: + route = polyline_entity( + drawing_id, + [paper(point) for point in route_xy], + ROUTE_LAYER_ID, + ROUTE_COLOR, + width=_ROUTE_WIDTH, + ) + if route: + entities.append(route) + if with_station and stations: + entities.extend(_station_entities(drawing_id, stations, interval_m, paper)) + if with_structure: + entities.extend( + _structure_entities(drawing_id, structures, paper, (min_x, min_y, max_x, max_y)) + ) + + # 방위표는 지형 오른쪽 칸 맨 위에 둔다(유역도와 같은 자리). + if map_bbox: + entities.extend( + compass_entities( + drawing_id, + ( + map_bbox[2] + _COMPASS_MARGIN + _COMPASS_SIZE / 2.0, + map_bbox[3] - _COMPASS_SIZE / 2.0, + ), + _COMPASS_SIZE, + ) + ) + + bbox = entities_bbox(entities) + if bbox: + min_bx, _min_by, max_bx, max_by = bbox + entities.append( + _text_entity( + f"{drawing_id}:title", + label, + (min_bx + max_bx) / 2.0, + max_by + 12.0, + TITLE_LAYER_ID, + _TITLE_FONT_SIZE, + TABLE_LABEL_COLOR, + ) + ) + entities.append( + _text_entity( + f"{drawing_id}:scale", + f"S = 1/{DRAWING_SCALE_PLAN:,}", + max_bx, + max_by + 5.0, + TITLE_LAYER_ID, + _FONT_SIZE, + TABLE_LABEL_COLOR, + align="right", + ) + ) + entities.extend( + frame_entities( + drawing_id, + entities_bbox(entities) or bbox, + fit=False, + fields={"도면명": label, **scale_fields(("", DRAWING_SCALE_PLAN))}, + ) + ) + + return { + "format": DRAWING_FORMAT, + "entities": entities, + "layers": [ + _layer(CONTOUR_LAYER_ID, "등고선", locked=True), + _layer(STREAM_LAYER_ID, "계류", locked=True), + _layer(ROUTE_LAYER_ID, "계획노선"), + _layer(STATION_LAYER_ID, "측점"), + _layer(STRUCTURE_LAYER_ID, "구조물"), + _layer(TITLE_LAYER_ID, "표제"), + _layer(FRAME_LAYER_ID, "도각", locked=True), + ], + } diff --git a/B07_DesignDetail/B07_DesignDetail_Router.py b/B07_DesignDetail/B07_DesignDetail_Router.py index a46bba9a..f4777153 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router.py +++ b/B07_DesignDetail/B07_DesignDetail_Router.py @@ -35,13 +35,16 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Template import ( ) from B07_DesignDetail.B07_DesignDetail_Router_Support import ( MASS_HAUL_ID, + PLAN_ID, WATERSHED_ID, _cross_sheet_plan, _drawing_list, _invalidate_drawing, _read_drawing, + _read_json, _recompute_confirmed_design, _store_confirmed_drawing, + plan_source, watershed_source, ) from B07_DesignDetail.B07_DesignDetail_Schema import ( @@ -300,6 +303,13 @@ async def get_design_drawing( if context is None: return JSONResponse(status_code=404, content={"status": "error", "message": reason}) source_design = await asyncio.to_thread(watershed_source, context) + elif PLAN_ID.fullmatch(drawing_id): + # 계획평면도는 유역도와 **같은 배경 창구**를 쓴다 — 자료 읽기·환산이 캐시된다. + context, reason = await load_drainage_context(project_id) + if context is None: + return JSONResponse(status_code=404, content={"status": "error", "message": reason}) + longitudinal = await asyncio.to_thread(_read_json, longitudinal_path) + source_design = await asyncio.to_thread(plan_source, context, longitudinal, drawing_id) kind, label, drawing, confirmed, quantity_table = await asyncio.to_thread( _read_drawing, project_root, longitudinal_path, drawing_id, source_design ) diff --git a/B07_DesignDetail/B07_DesignDetail_Router_Support.py b/B07_DesignDetail/B07_DesignDetail_Router_Support.py index 0381742e..ef362b2a 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router_Support.py +++ b/B07_DesignDetail/B07_DesignDetail_Router_Support.py @@ -26,6 +26,13 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Long import ( longitudinal_chunks, ) from B07_DesignDetail.B07_DesignDetail_Engine_Cad_MassHaul import build_mass_haul_drawing +from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Plan import ( + PLAN_KINDS, + build_plan_drawing, + plan_chunks, + plan_drawing_id, + plan_drawing_label, +) from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Sheet import ( CROSS_SHEET_ID, build_cross_sheet, @@ -45,6 +52,9 @@ from B07_DesignDetail.B07_DesignDetail_Router_Support_Basin import ( from B07_DesignDetail.B07_DesignDetail_Router_Support_Basin import ( CONTOUR_FILE as CONTOUR_FILE, ) +from B07_DesignDetail.B07_DesignDetail_Router_Support_Basin import ( + PLAN_ID as PLAN_ID, +) from B07_DesignDetail.B07_DesignDetail_Router_Support_Basin import ( STREAM_FILE as STREAM_FILE, ) @@ -69,6 +79,12 @@ from B07_DesignDetail.B07_DesignDetail_Router_Support_Basin import ( from B07_DesignDetail.B07_DesignDetail_Router_Support_Basin import ( clip_line_to_box as clip_line_to_box, ) +from B07_DesignDetail.B07_DesignDetail_Router_Support_Basin import ( + plan_source as plan_source, +) +from B07_DesignDetail.B07_DesignDetail_Router_Support_Basin import ( + plan_stations as plan_stations, +) from B07_DesignDetail.B07_DesignDetail_Router_Support_Basin import ( watershed_source as watershed_source, ) @@ -100,9 +116,6 @@ COVER_ID = "cover" # 아직 내용을 만들지 않은 도면 — 도각만 실어 연다(2026-09-01 사용자 지시). # 화면 순서(DRAWING_GROUPS)와 같은 이름을 쓴다. BLANK_DRAWINGS: tuple[tuple[str, str], ...] = ( - ("blank_plan_terrain", "계획평면도(지형)"), - ("blank_plan_route", "계획평면도(노선배치도)"), - ("blank_plan_layout", "계획평면도(배치도)"), ("blank_plan_lidar", "계획평면도(라이다)"), ("blank_cross_standard", "표준 횡단면도"), ("blank_standard", "표준도"), @@ -151,6 +164,19 @@ def _drawing_list( confirmed=bool(manifest_drawings.get(sheet["id"], {}).get("confirmed")), ) ) + # 계획평면도 3종 — 축척 1/1,200 고정이라 노선이 길면 장이 나뉜다(장수는 노선이 정한다). + plan_sheets = plan_chunks(plan_stations(longitudinal)) + for kind, _label, *_rest in PLAN_KINDS: + for chunk in plan_sheets: + drawing_id = plan_drawing_id(kind, chunk, len(plan_sheets)) + drawings.append( + DesignDrawingItem( + id=drawing_id, + kind="plan", + label=plan_drawing_label(kind, chunk, len(plan_sheets)), + confirmed=bool(manifest_drawings.get(drawing_id, {}).get("confirmed")), + ) + ) # 노선 전장 1장짜리 도면 — 자료가 없으면 여는 시점에 404로 알린다(목록에는 항상 둔다). for drawing_id, kind, label in ( (COVER_ID, "cover", "표지"), @@ -381,6 +407,8 @@ def _read_drawing( if saved.get("format") == DRAWING_FORMAT: if drawing_id in (COVER_ID, MASS_HAUL_ID, WATERSHED_ID): kind = drawing_id # id와 kind가 같은 단장 도면 + elif PLAN_ID.fullmatch(drawing_id): + kind = "plan" else: kind = "longitudinal" if _LONG_ID.fullmatch(drawing_id) else "cross" label = str(manifest_entry.get("label") or drawing_id) @@ -409,6 +437,31 @@ def _read_drawing( None, ) + if PLAN_ID.fullmatch(drawing_id): + # stored_design = plan_source()가 모아 준 노선·측점·배경·구조물 좌표(사업지 CRS). + if not isinstance(stored_design, dict): + raise FileNotFoundError("계획평면도 자료가 없습니다.") + longitudinal = _read_json(longitudinal_path) + interval = infer_station_interval(longitudinal.get("stations") or []) + label = str(stored_design.get("label") or drawing_id) + return ( + "plan", + label, + build_plan_drawing( + str(stored_design.get("kind") or "plan_terrain"), + drawing_id, + label, + stored_design.get("route_xy") or [], + stored_design.get("stations") or [], + stored_design.get("contours") or [], + stored_design.get("streams") or [], + stored_design.get("structures") or [], + interval, + ), + False, + None, + ) + if drawing_id == WATERSHED_ID: # stored_design = watershed_source()가 모아 준 노선·유역·배경 좌표(사업지 CRS). if not isinstance(stored_design, dict): diff --git a/B07_DesignDetail/B07_DesignDetail_Router_Support_Basin.py b/B07_DesignDetail/B07_DesignDetail_Router_Support_Basin.py index f229cf17..73c52af0 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router_Support_Basin.py +++ b/B07_DesignDetail/B07_DesignDetail_Router_Support_Basin.py @@ -7,6 +7,7 @@ import json import logging import math import re +from functools import lru_cache from pathlib import Path from typing import Any @@ -14,33 +15,19 @@ from pyproj import Transformer from B04_PreProcess.B04_PreProcess_Router_Watershed import CONTOUR_FILE, STREAM_FILE from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Basin import map_area_mm +from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Plan import ( + plan_area_mm, + plan_chunks, + plan_drawing_label, +) from common_util.common_util_drainage_pipes import detail_basins_path -from config.config_system import DRAWING_SCALE_BASIN +from config.config_system import DRAWING_SCALE_BASIN, DRAWING_SCALE_PLAN logger = logging.getLogger(__name__) -_STAGE_DIR = "B07_DesignDetail" - -_CROSS_ID = re.compile(r"^cross_(\d+)m$") -_LONG_ID = re.compile(r"^longitudinal(?:_(\d+))?$") -# 노선 전장에 한 장씩만 나오는 도면 — 라우터가 원본 자료를 따로 실어 넘긴다. -MASS_HAUL_ID = "mass_haul" -WATERSHED_ID = "watershed" -COVER_ID = "cover" - -# 아직 내용을 만들지 않은 도면 — 도각만 실어 연다(2026-09-01 사용자 지시). -# 화면 순서(DRAWING_GROUPS)와 같은 이름을 쓴다. -BLANK_DRAWINGS: tuple[tuple[str, str], ...] = ( - ("blank_plan_terrain", "계획평면도(지형)"), - ("blank_plan_route", "계획평면도(노선배치도)"), - ("blank_plan_layout", "계획평면도(배치도)"), - ("blank_plan_lidar", "계획평면도(라이다)"), - ("blank_cross_standard", "표준 횡단면도"), - ("blank_standard", "표준도"), - ("blank_landuse", "용지도"), -) -BLANK_LABELS: dict[str, str] = dict(BLANK_DRAWINGS) +# 도면 id 상수·빈 도면 목록은 `B07_DesignDetail_Router_Support` 한 곳이 정본이다. +# 이 모듈에 있던 같은 이름의 사본은 아무도 읽지 않으면서 값만 어긋나 지웠다(2026-09-04). def _geojson_payload(path: Path) -> dict[str, Any]: @@ -185,26 +172,186 @@ def _too_far_from_route( return min(math.hypot(cx - x, cy - y) for x, y in route_xy) > _BASIN_MAX_DISTANCE_M +@lru_cache(maxsize=8) +def _metric_lines_cached( + path_str: str, mtime_ns: int, crs: str +) -> tuple[tuple[tuple[float, float], ...], ...]: + """도엽 GeoJSON 한 벌을 사업지 좌표계(m) 선 목록으로 돌려 **캐시**한다. + + 같은 배경을 유역도와 계획평면도가 나눠 쓴다 — 도면마다 다시 읽고 다시 투영하면 + 한 장 여는 데 수 초가 걸린다(등고선 수만 점). 파일이 바뀌면 mtime 이 달라져 + 캐시가 저절로 갈린다(`_read_template` 와 같은 방식). + """ + to_metric = Transformer.from_crs("EPSG:4326", crs, always_xy=True) + lines: list[tuple[tuple[float, float], ...]] = [] + for feature in _geojson_features(Path(path_str)): + for part in _geometry_lines(feature.get("geometry")): + converted = tuple( + (float(x), float(y)) + for x, y in (to_metric.transform(point[0], point[1]) for point in part) + ) + if len(converted) >= 2: + lines.append(converted) + return tuple(lines) + + +def _metric_lines(path: Path, crs: str) -> list[list[tuple[float, float]]]: + """캐시된 배경 선을 쓰기 좋은 형태로 낸다. 파일이 없으면 빈 목록.""" + if not path.is_file(): + return [] + cached = _metric_lines_cached(str(path), path.stat().st_mtime_ns, crs) + return [list(line) for line in cached] + + +def map_background( + project_root: Path, + crs: str, + scale: int, + area_mm: tuple[float, float], + extent_points: list[tuple[float, float]], +) -> dict[str, list[list[tuple[float, float]]]]: + """도엽 등고선·세류선을 사업지 좌표계로 읽어 **그 도면의 도곽 크기로 절취**한다. + + 유역도·계획평면도·용지도가 같은 창구를 쓴다 — 자료 읽기·좌표 환산은 한 번뿐이고 + (`_metric_lines` 캐시), 도면마다 다른 것은 축척과 도곽 크기뿐이다. + + `extent_points` 는 그 도면의 주제(노선·유역 등) 좌표다. 도곽보다 크면 그쪽을 + 우선한다 — 배경만 잘리고 주제는 다 보인다. + """ + area_w_mm, area_h_mm = area_mm + half_w_m = area_w_mm / 2.0 * scale / 1000.0 + half_h_m = area_h_mm / 2.0 * scale / 1000.0 + if extent_points: + center_x = (min(x for x, _y in extent_points) + max(x for x, _y in extent_points)) / 2.0 + center_y = (min(y for _x, y in extent_points) + max(y for _x, y in extent_points)) / 2.0 + box = ( + min(center_x - half_w_m, min(x for x, _y in extent_points)), + min(center_y - half_h_m, min(y for _x, y in extent_points)), + max(center_x + half_w_m, max(x for x, _y in extent_points)), + max(center_y + half_h_m, max(y for _x, y in extent_points)), + ) + else: + box = (-math.inf, -math.inf, math.inf, math.inf) + + sheet_dir = Path(project_root) / "B04_PreProcess" / "processed" + background: dict[str, list[list[tuple[float, float]]]] = {} + for key, filename in (("contours", CONTOUR_FILE), ("streams", STREAM_FILE)): + lines: list[list[tuple[float, float]]] = [] + for line in _metric_lines(sheet_dir / filename, crs): + lines.extend(clip_line_to_box(line, box)) + background[key] = lines + return background + + +PLAN_ID = re.compile(r"^(plan_terrain|plan_route|plan_layout)(?:_(\d+))?$") + + +def plan_stations(longitudinal: dict[str, Any]) -> list[tuple[float, float, float]]: + """종단 측점의 (누가거리 m, x, y) — 계획평면도 장 나눔의 유일한 기준 자료.""" + stations: list[tuple[float, float, float]] = [] + for station in longitudinal.get("stations") or []: + if not isinstance(station, dict): + continue + chainage = station.get("chainage_m") + x, y = station.get("center_x"), station.get("center_y") + if all(isinstance(value, (int, float)) for value in (chainage, x, y)): + stations.append((float(chainage), float(x), float(y))) + return stations + + +def plan_chunk_for( + longitudinal: dict[str, Any], drawing_id: str +) -> tuple[str, dict[str, Any], int]: + """도면 id 에서 (주제, 그 장의 구간, 전체 장수)를 찾는다.""" + match = PLAN_ID.fullmatch(drawing_id) + if not match: + raise ValueError("올바르지 않은 계획평면도 ID입니다.") + kind = match.group(1) + chunks = plan_chunks(plan_stations(longitudinal)) + number = int(match.group(2)) if match.group(2) else 1 + chunk = next((item for item in chunks if item["number"] == number), None) + if chunk is None: + raise FileNotFoundError("요청한 계획평면도 장을 찾을 수 없습니다.") + return kind, chunk, len(chunks) + + +def plan_source(context: Any, longitudinal: dict[str, Any], drawing_id: str) -> dict[str, Any]: + """계획평면도 한 장의 입력(노선·측점·등고선·세류선·구조물)을 사업지 CRS(m)로 모은다. + + 배경은 유역도와 **같은 창구**(`map_background`)를 쓴다 — 도엽 GeoJSON 읽기·좌표 + 환산이 캐시돼 두 도면이 자료를 나눠 쓴다(2026-09-04 사용자 지시). + + 장이 여럿이면 그 장의 구간(누가거리)에 드는 노선·구조물만 싣고, 배경도 그 범위로 + 절취한다 — 축척 1/1,200 은 고정이므로 안 들어가면 장을 나눈다. + """ + kind, chunk, total = plan_chunk_for(longitudinal, drawing_id) + start_m, end_m = float(chunk["start_m"]), float(chunk["end_m"]) + + route_xy: list[tuple[float, float]] = [] + for vertex in context.vertices: + chainage = float(getattr(vertex, "chainage_m", 0.0) or 0.0) + if total > 1 and not (start_m <= chainage <= end_m): + continue + route_xy.append((vertex.x, vertex.y)) + # 측점 눈금은 **종단 측점**을 쓴다 — 노선 정점은 조밀하고 누가거리가 간격의 배수가 아니다. + stations = [ + station + for station in plan_stations(longitudinal) + if total <= 1 or start_m <= station[0] <= end_m + ] + + structures = [ + structure + for structure in _plan_structures(context) + if total <= 1 or start_m <= float(structure.get("chainage_m") or -1.0) <= end_m + ] + background = map_background( + Path(context.project_root), + context.crs, + DRAWING_SCALE_PLAN, + plan_area_mm(), + route_xy, + ) + return { + "kind": kind, + "label": plan_drawing_label(kind, chunk, total), + "route_xy": route_xy, + "stations": stations, + "contours": background["contours"], + "streams": background["streams"], + "structures": structures, + } + + +def _plan_structures(context: Any) -> list[dict[str, Any]]: + """배치도에 찍을 구조물 — B04 배수시설 정본(`pipe_points.json`)을 그대로 읽는다. + + 좌표는 이미 사업지 CRS(m)다(B04가 그렇게 쓴다). 없으면 빈 목록 — 배치도는 배경과 + 노선만으로도 열린다. + """ + path = Path(context.project_root) / "B04_PreProcess" / "drainage" / "edits" / "pipe_points.json" + points = _geojson_payload(path).get("points") + if not isinstance(points, list): + return [] + return [point for point in points if isinstance(point, dict)] + + def watershed_source(context: Any) -> dict[str, Any]: """유역도 입력(노선·세부유역·등고선·세류선)을 사업지 CRS(m)로 모은다. 저장본은 전부 WGS84라 여기서 미터 좌표로 되돌린다(B04가 저장할 때와 반대 방향). 되돌리는 좌표계가 둘이다. **배경(도엽 등고선·세류선)은 사업지 좌표계**로 돌린다 — - 노선(`context.vertices`)이 그 좌표계에 있으므로 같은 자리에 겹쳐야 한다. **세부유역은 - 그 파일을 쓸 때 쓴 좌표계**로 돌린다 — 좌표계 기록 이전 저장본은 노선 CSV의 EPSG - 라벨로 쓰였고, 그 라벨로 되돌려야 원래 미터 좌표가 나온다(2026-09-01). + 노선(`context.vertices`)이 그 좌표계에 있으므로 같은 자리에 겹쳐야 한다(그 환산은 + `map_background()` 안에 있다). **세부유역은 그 파일을 쓸 때 쓴 좌표계**로 돌린다 — + 좌표계 기록 이전 저장본은 노선 CSV의 EPSG 라벨로 쓰였고, 그 라벨로 되돌려야 원래 + 미터 좌표가 나온다(2026-09-01). """ basins_payload = _geojson_payload(detail_basins_path(context.stored_path)) - to_metric = Transformer.from_crs("EPSG:4326", context.crs, always_xy=True) to_basin_metric = Transformer.from_crs( "EPSG:4326", _basins_crs(context, basins_payload), always_xy=True ) - def metric(point: tuple[float, float]) -> tuple[float, float]: - x, y = to_metric.transform(point[0], point[1]) - return (float(x), float(y)) - def basin_metric(point: tuple[float, float]) -> tuple[float, float]: x, y = to_basin_metric.transform(point[0], point[1]) return (float(x), float(y)) @@ -237,39 +384,14 @@ def watershed_source(context: Any) -> dict[str, Any]: # 배경(등고선·세류선)은 **여러 도엽을 합쳐 받은 뒤 도곽 크기로 절취**한다 # (2026-08-30 사용자 지시 — 노선이 도엽 경계에 걸릴 수 있어 주변 도엽까지 받아 둔다). - # 도엽 등고선 한 줄은 도엽 끝까지 이어지므로 "걸치면 통째로"는 도면이 A1을 넘긴다 - # (실측 430x871 mm). 절취 범위 = 도곽 안 지형 영역(정보표·제목 제외)을 축척으로 되돌린 크기. - usable_w_mm, usable_h_mm = map_area_mm() - half_w_m = usable_w_mm / 2.0 * DRAWING_SCALE_BASIN / 1000.0 - half_h_m = usable_h_mm / 2.0 * DRAWING_SCALE_BASIN / 1000.0 - extent = [*route_xy, *(point for basin in basins for point in basin["ring"])] - if extent: - center_x = (min(x for x, _y in extent) + max(x for x, _y in extent)) / 2.0 - center_y = (min(y for _x, y in extent) + max(y for _x, y in extent)) / 2.0 - # 노선·유역이 도곽보다 크면 그쪽을 우선한다 — 배경만 잘리고 주제는 다 보인다. - min_x = min(center_x - half_w_m, min(x for x, _y in extent)) - max_x = max(center_x + half_w_m, max(x for x, _y in extent)) - min_y = min(center_y - half_h_m, min(y for _x, y in extent)) - max_y = max(center_y + half_h_m, max(y for _x, y in extent)) - else: - min_x = min_y = -math.inf - max_x = max_y = math.inf - - box = (min_x, min_y, max_x, max_y) - - def clip(line: list[tuple[float, float]]) -> list[list[tuple[float, float]]]: - return clip_line_to_box(line, box) - - sheet_dir = Path(context.project_root) / "B04_PreProcess" / "processed" - background: dict[str, list[list[tuple[float, float]]]] = {} - for key, filename in (("contours", CONTOUR_FILE), ("streams", STREAM_FILE)): - lines: list[list[tuple[float, float]]] = [] - for feature in _geojson_features(sheet_dir / filename): - for part in _geometry_lines(feature.get("geometry")): - converted = [metric(point) for point in part] - if len(converted) >= 2: - lines.extend(clip(converted)) - background[key] = lines + # 읽기·환산·절취는 `map_background()` 한 곳에 있고 계획평면도·용지도도 같은 것을 쓴다. + background = map_background( + Path(context.project_root), + context.crs, + DRAWING_SCALE_BASIN, + map_area_mm(), + [*route_xy, *(point for basin in basins for point in basin["ring"])], + ) return { "route_xy": route_xy, diff --git a/B07_DesignDetail/B07_DesignDetail_Schema.py b/B07_DesignDetail/B07_DesignDetail_Schema.py index f88fe71f..1fc0ce6d 100644 --- a/B07_DesignDetail/B07_DesignDetail_Schema.py +++ b/B07_DesignDetail/B07_DesignDetail_Schema.py @@ -10,7 +10,7 @@ class DesignDrawingItem(BaseModel): id: str # blank: 아직 내용을 만들지 않은 도면 — 도각만 실어 연다(2026-09-01 사용자 지시). - kind: Literal["cover", "longitudinal", "cross", "mass_haul", "watershed", "blank"] + kind: Literal["cover", "longitudinal", "cross", "mass_haul", "watershed", "plan", "blank"] label: str chainage_m: float | None = None confirmed: bool = False @@ -33,7 +33,7 @@ class DesignDrawingResponse(BaseModel): route_id: int id: str # blank: 아직 내용을 만들지 않은 도면 — 도각만 실어 연다(2026-09-01 사용자 지시). - kind: Literal["cover", "longitudinal", "cross", "mass_haul", "watershed", "blank"] + kind: Literal["cover", "longitudinal", "cross", "mass_haul", "watershed", "plan", "blank"] label: str drawing: dict[str, Any] confirmed: bool = False diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Panels.ts b/B07_DesignDetail/B07_DesignDetail_UI_Panels.ts index bf045511..9e8ef8ad 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Panels.ts +++ b/B07_DesignDetail/B07_DesignDetail_UI_Panels.ts @@ -8,7 +8,10 @@ import { attachCollapsible } from "@ui/ui_template_collapsible"; import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; -import type { CrossDesignInfo, DesignDrawingItem } from "./B07_DesignDetail_Api_Fetch"; +import type { + CrossDesignInfo, + DesignDrawingItem, +} from "./B07_DesignDetail_Api_Fetch"; function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; @@ -24,11 +27,13 @@ export const DRAWING_GROUPS: readonly { label: string; kind?: DesignDrawingItem["kind"]; blankId?: string; + /** 축척 고정으로 장이 나뉘는 도면 — `plan_route`, `plan_route_2` … 를 한 묶음으로 본다. */ + idPrefix?: string; }[] = [ { label: "표지", kind: "cover" }, - { label: "계획평면도(지형)", blankId: "blank_plan_terrain" }, - { label: "계획평면도(노선배치도)", blankId: "blank_plan_route" }, - { label: "계획평면도(배치도)", blankId: "blank_plan_layout" }, + { label: "계획평면도(지형)", idPrefix: "plan_terrain" }, + { label: "계획평면도(노선배치도)", idPrefix: "plan_route" }, + { label: "계획평면도(배치도)", idPrefix: "plan_layout" }, { label: "계획평면도(라이다)", blankId: "blank_plan_lidar" }, { label: "종단면도", kind: "longitudinal" }, { label: "표준 횡단면도", blankId: "blank_cross_standard" }, @@ -64,7 +69,10 @@ export function buildDrawingSidePanel( return panel; } - const drawingButton = (drawing: DesignDrawingItem, label: string): HTMLButtonElement => { + const drawingButton = ( + drawing: DesignDrawingItem, + label: string, + ): HTMLButtonElement => { const button = document.createElement("button"); button.type = "button"; button.className = "b07-drawing-button"; @@ -81,7 +89,13 @@ export function buildDrawingSidePanel( for (const group of DRAWING_GROUPS) { const items = group.kind ? drawings.filter((item) => item.kind === group.kind) - : drawings.filter((item) => item.id === group.blankId); + : group.idPrefix + ? drawings.filter( + (item) => + item.id === group.idPrefix || + item.id.startsWith(`${group.idPrefix}_`), + ) + : drawings.filter((item) => item.id === group.blankId); // 한 장짜리(와 아직 내용이 없는 도면)는 컨테이너 없이 버튼 하나로 둔다. if (items.length <= 1) { const [drawing] = items; @@ -103,7 +117,8 @@ export function buildDrawingSidePanel( const button = drawingButton(drawing, group.label); // 도각만 있는 도면은 그렇다고 알린다 — 빈 화면을 보고 오류로 오해하지 않게. button.dataset.pending = String(drawing.kind === "blank"); - if (drawing.kind === "blank") button.title = "준비 중 — 도각만 표시합니다"; + if (drawing.kind === "blank") + button.title = "준비 중 — 도각만 표시합니다"; panel.append(button); continue; } @@ -125,7 +140,10 @@ export function buildDrawingSidePanel( return panel; } -const GROUND_TYPE_LABEL: Record = { +const GROUND_TYPE_LABEL: Record< + CrossDesignInfo["ground_type"], + keyof typeof ui_locales +> = { soil: "B06_Design_Ground_Soil", ripping_rock: "B06_Design_Ground_Ripping", blasting_rock: "B06_Design_Ground_Blasting", @@ -147,7 +165,8 @@ export function isCrossSheet(drawing: DesignDrawingItem): boolean { /** 측구 규격 표시 문자열 (design 신구조: 형식별 ditch spec, F-2 호환). */ function ditchLabel(design: CrossDesignInfo): string { const ditch = design.ditch; - if (!ditch || ditch.type === "none" || design.ditch_enabled === false) return "없음"; + if (!ditch || ditch.type === "none" || design.ditch_enabled === false) + return "없음"; if (ditch.type === "l_type") return `L형 ${ditch.width_m.toFixed(2)}×${ditch.depth_m.toFixed(2)}m`; return `${ditch.top_width_m.toFixed(2)}×${ditch.depth_m.toFixed(2)}m`; @@ -183,19 +202,23 @@ export function buildDesignInfoPanel( const heading = document.createElement("div"); heading.className = "b07-info__heading"; const stationName = document.createElement("strong"); - const scopeLabel = scope === "sheet" ? L("B07_Info_Sheet") : L("B07_Info_Station"); + const scopeLabel = + scope === "sheet" ? L("B07_Info_Sheet") : L("B07_Info_Station"); stationName.textContent = `${scopeLabel} ${title}`; const confirmed = design?.status === "confirmed"; const badge = document.createElement("span"); badge.className = `b07-info__badge${confirmed ? " b07-info__badge--confirmed" : ""}`; - badge.textContent = confirmed ? L("B07_Info_Confirmed") : L("B07_Info_Provisional"); + badge.textContent = confirmed + ? L("B07_Info_Confirmed") + : L("B07_Info_Provisional"); heading.append(stationName, badge); panel.append(heading); if (!design) { const empty = document.createElement("p"); empty.className = "b07-info__empty"; - empty.textContent = scope === "sheet" ? L("B07_Info_SheetHint") : L("B07_Info_NoDesign"); + empty.textContent = + scope === "sheet" ? L("B07_Info_SheetHint") : L("B07_Info_NoDesign"); panel.append(empty); return panel; } @@ -210,7 +233,9 @@ export function buildDesignInfoPanel( infoRow(L("B07_Info_CutSide"), cutSideLabel(design.section_mode)), infoRow( L("B07_Info_DitchSide"), - design.ditch_side === "left" ? L("B06_Design_Ditch_Left") : L("B06_Design_Ditch_Right"), + design.ditch_side === "left" + ? L("B06_Design_Ditch_Left") + : L("B06_Design_Ditch_Right"), ), ); @@ -220,7 +245,10 @@ export function buildDesignInfoPanel( planTitle.textContent = L("B07_Info_Plan_Title"); plan.append( planTitle, - infoRow(L("B07_Info_DesignElevation"), `${design.design_elevation_m.toFixed(2)}m`), + infoRow( + L("B07_Info_DesignElevation"), + `${design.design_elevation_m.toFixed(2)}m`, + ), infoRow(L("B07_Info_CutSlope"), `1:${design.cut_slope_ratio}`), infoRow(L("B07_Info_FillSlope"), `1:${design.fill_slope_ratio}`), infoRow(L("B07_Info_RoadWidth"), `${design.roadbed_width_m.toFixed(2)}m`), diff --git a/config/config_system.py b/config/config_system.py index 50a826ca..de4780e4 100644 --- a/config/config_system.py +++ b/config/config_system.py @@ -175,6 +175,9 @@ DRAWING_SCALE_MASSHAUL_H_CANDIDATES = ( DRAWING_MASSHAUL_USABLE_WIDTH_MM = 700.0 DRAWING_SCALE_MASSHAUL_V_M3_MM = 50.0 # 유토곡선 세로 — 종이 1 mm 당 토량(㎥) DRAWING_SCALE_BASIN = 6000 # 유역도 평면 축척 분모 (실거리 1 m = 1/6 mm) +# 계획평면도·용지도 평면 축척 분모 — 지식DB 「설계제원_총괄」 측량·도면 기준 1/1,200. +# 횡단면도와 같은 원칙: 축척은 줄이지 않고, 한 장에 안 들어가면 **장을 나눈다**. +DRAWING_SCALE_PLAN = 1200 # 시스템 리소스 로그 (루트 log 폴더에 단일 파일, 1개월 보관) LOG_BASE_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "log") From 99fa98a0ddea88594eb620054e2ba58ebe4a94f9 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 4 Sep 2026 18:43:49 +0900 Subject: [PATCH 07/11] =?UTF-8?q?feat(B05):=20=EC=B4=88=EA=B8=B0=ED=99=94?= =?UTF-8?q?=20=EB=B2=94=EC=9C=84=20=ED=99=95=EB=8C=80=20=E2=80=94=20?= =?UTF-8?q?=EB=B0=B0=EC=88=98=EC=9C=A0=EC=97=AD=20=EC=82=B0=EC=B6=9C?= =?UTF-8?q?=EB=AC=BC=EA=B9=8C=EC=A7=80=20=EC=B4=88=EA=B8=B0=EA=B0=92?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EC=B4=AC=EC=98=81=C2=B7=EB=B3=B5=EC=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 스냅샷 대상이 B04_PreProcess/drainage/edits 에서 drainage 폴더 통째로 넓어짐 (00_watershed_response ~ 04_detailed_basins 포함). 관을 옮겨 다시 나뉜 세부유역이 초기화로 되돌아가지 않던 구멍을 막음 - 용량 실측: 배수유역 8.6MB, 스냅샷 전체 3.9MB → 약 12MB - 옛 스냅샷(edits 만 촬영) 호환 — 그 경우 예전처럼 관 지점 편집분만 복원 - 3D 코리도는 제외 — 스냅샷 시점(체인 직후)에 아직 없는 값이라 별도 판단 필요 Co-Authored-By: Claude Opus 5 (1M context) --- common_util/common_util_initial_snapshot.py | 23 ++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/common_util/common_util_initial_snapshot.py b/common_util/common_util_initial_snapshot.py index 90eadb9c..9b68e25c 100644 --- a/common_util/common_util_initial_snapshot.py +++ b/common_util/common_util_initial_snapshot.py @@ -30,13 +30,21 @@ DESIGNING_LOCK_NAME = "initial_design.lock" DESIGN_FAILED_NAME = "initial_design.failed" # 프로젝트 루트 기준 상대 경로 — 정본 파일이 사는 자리 전부. +# +# 배수유역은 `edits/`(관 지점 편집분)만 뜨다가 **폴더 통째**로 넓혔다(2026-09-04 사용자 +# 확정: 「초기값 = 파일 입력 직후 결과 전부」). 관을 옮기면 세부유역(`04_detailed_basins`)이 +# 다시 나뉘는데 그 산출물이 스냅샷 밖이라 [초기화]가 옛 유역도를 그대로 남겼다. +# 용량은 실측 8.6MB(스냅샷 전체 3.9MB → 약 12MB)로 감당할 만하다. _FILE_TREES = ( "B05_Profile/route", "B06_Section/longitudinal", "B06_Section/cross_sections", - "B04_PreProcess/drainage/edits", + "B04_PreProcess/drainage", ) +# 배수유역을 폴더 통째로 넓히기 전(2026-09-04)에 찍힌 스냅샷이 갖고 있는 자리. +_LEGACY_DRAINAGE_TREE = "B04_PreProcess__drainage__edits" + # `routes.id`를 참조하는 표는 스키마상 이 넷이 전부다(001_create_schema.sql:539~556). _CHILD_TABLES = ("route_points", "route_statistics", "longitudinal_sections", "cross_sections") @@ -238,11 +246,20 @@ def wipe_edited_masters(project_root: Path) -> list[str]: def restore_snapshot_files(project_root: Path) -> None: - """스냅샷 파일을 작업본 자리에 되돌린다. 스냅샷에 없던 트리는 비워 둔다.""" + """스냅샷 파일을 작업본 자리에 되돌린다. 스냅샷에 없던 트리는 비워 둔다. + + 배수유역 범위를 넓히기 전(2026-09-04)에 찍힌 스냅샷은 `edits/`만 갖고 있다 — + 그런 프로젝트는 예전처럼 그 자리만 되돌린다. 넓힌 트리를 못 찾았다고 그냥 넘어가면 + 관 지점 편집분이 초기화 뒤에도 남는다. + """ root = Path(project_root) source = snapshot_dir(root) for tree in _FILE_TREES: - _copy_tree(source / tree.replace("/", "__"), root / tree) + stored = source / tree.replace("/", "__") + if not stored.is_dir() and tree == "B04_PreProcess/drainage": + _copy_tree(source / _LEGACY_DRAINAGE_TREE, root / "B04_PreProcess/drainage/edits") + continue + _copy_tree(stored, root / tree) async def restore_initial_snapshot( From d662b0725db4648d60766f7aabfa7b42f9927d71 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 4 Sep 2026 18:51:23 +0900 Subject: [PATCH 08/11] =?UTF-8?q?feat(B07):=20=EC=9A=A9=EC=A7=80=EB=8F=84?= =?UTF-8?q?=20=E2=80=94=20=EB=93=B1=EA=B3=A0=EC=84=A0=20=EB=B0=B0=EA=B2=BD?= =?UTF-8?q?=20+=20=EC=97=B0=EC=86=8D=EC=A7=80=EC=A0=81=EB=8F=84=C2=B7?= =?UTF-8?q?=ED=96=89=EC=A0=95=EA=B5=AC=EC=97=AD=C2=B7=EB=B2=94=EB=A1=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 빈 도각이던 용지도를 실제 도면으로 만듦. 축척·도곽·장 나눔은 계획평면도와 같고(1/1,200) 배경도 같은 창구(map_background)를 씀. - 연속지적도 필지 경계 + 지번(jibun) 표기. 작은 필지는 솎고(종이 6x3 mm 미만), 도곽을 통째로 감싸는 임야 대필지는 도곽 중심에 지번만 적음 — 그러지 않으면 노선이 대필지 안에 들어앉을 때 지번이 통째로 사라짐. - 시군구·읍면동/리 경계를 파선·일점쇄선으로 구분. 지적·행정 경계는 도곽 범위로 절취 — 자르지 않으면 산지 대필지 하나가 도면을 10 km 로 벌림(실측 8,368 mm). - 도면용 색을 화면용과 따로 정함(등고선을 가장 옅게, 행정 경계를 굵고 진하게). - 범례를 유역도 정보표 자리(오른쪽 칸 방위표 아래)에 넣음. - _geometry_lines 가 MultiPolygon 을 편다 — 지적·행정구역이 그 형식임. 검증(용화_LAS): 콘텐츠 734.3x489.2 mm ≤ A1 작도영역, 지번 「산77-15임」이 지적 속성과 일치, 도면 목록에 landuse 가 실림. 이 노선은 임야 대필지 한 곳에 통째로 들어가 경계선이 도곽 안에 없음 — 자료대로의 정상 결과. Co-Authored-By: Claude Opus 5 (1M context) --- .../B07_DesignDetail_Api_Fetch.ts | 2 + .../B07_DesignDetail_Engine_Cad_Landuse.py | 339 ++++++++++++++++++ B07_DesignDetail/B07_DesignDetail_Router.py | 11 + .../B07_DesignDetail_Router_Support.py | 47 ++- .../B07_DesignDetail_Router_Support_Basin.py | 136 ++++++- B07_DesignDetail/B07_DesignDetail_Schema.py | 22 +- .../B07_DesignDetail_UI_Panels.ts | 2 +- 7 files changed, 554 insertions(+), 5 deletions(-) create mode 100644 B07_DesignDetail/B07_DesignDetail_Engine_Cad_Landuse.py diff --git a/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts b/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts index 2a29b156..38a360c9 100644 --- a/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts +++ b/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts @@ -12,6 +12,7 @@ export interface DesignDrawingItem { | "mass_haul" | "watershed" | "plan" + | "landuse" | "blank"; label: string; chainage_m: number | null; @@ -97,6 +98,7 @@ export interface DesignDrawingResponse { | "mass_haul" | "watershed" | "plan" + | "landuse" | "blank"; label: string; drawing: CadDrawing; diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Landuse.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Landuse.py new file mode 100644 index 00000000..1949a66f --- /dev/null +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Landuse.py @@ -0,0 +1,339 @@ +"""B07 용지도 CAD 조립 — 수치등고선 배경 위에 연속지적도·행정구역을 얹는다. + +사용자 지시(2026-09-04) — 「용지도는 계획평면도와 같이 수치등고선을 배경으로 하고 +연속지적도·시군구·읍면동을 얹을 것. 색상은 변경하고, 배수유역도의 표 자리에 범례를 +넣을 것. 연속지적도에 지번 정보가 있는지 확인하고, 없으면 일단 그림만」. + +지번은 있다(2026-09-04 실측: 저장된 연속지적도 GeoJSON 필지마다 `jibun`·`jimok`· +`parea`·`owner_nm` + 시도·시군구·읍면동·리 이름). 이번 판은 **지번만** 적는다 — +지목·면적·소유 구분은 용지 조서(표)에서 쓸 값이라 도면에는 넣지 않는다. + +축척·도곽·장 나눔은 계획평면도와 **같다**(1/1,200 고정). 배경도 같은 창구를 쓴다. + +좌표 규약: 종이 mm = (사업지 좌표 m - 그 장 콘텐츠 최소점) x MM (1/1,200 -> 1 m = 5/6 mm). +""" + +from typing import Any + +from B07_DesignDetail.B07_DesignDetail_Engine_Cad import ( + DRAWING_FORMAT, + FRAME_LAYER_ID, + TABLE_LABEL_COLOR, + _layer, + _text_entity, + polyline_entity, +) +from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Plan import ( + _COMPASS_MARGIN, + _COMPASS_SIZE, + _FONT_SIZE, + _TITLE_FONT_SIZE, + MM, + plan_area_mm, +) +from B07_DesignDetail.B07_DesignDetail_Engine_Template import ( + compass_entities, + entities_bbox, + frame_entities, + scale_fields, +) +from config.config_system import DRAWING_SCALE_PLAN + +LANDUSE_KIND = "landuse" +LANDUSE_LABEL = "용지도" + +CONTOUR_LAYER_ID = "b07-landuse-contour" +PARCEL_LAYER_ID = "b07-landuse-parcel" +JIBUN_LAYER_ID = "b07-landuse-jibun" +EMD_LAYER_ID = "b07-landuse-emd" +SGG_LAYER_ID = "b07-landuse-sgg" +ROUTE_LAYER_ID = "b07-landuse-route" +LEGEND_LAYER_ID = "b07-landuse-legend" +TITLE_LAYER_ID = "b07-landuse-title" + +# 도면용 색 — 화면용(유역도)보다 **가라앉힌** 색을 쓴다. 지적 경계가 주제이므로 배경 +# 등고선은 가장 옅게, 행정 경계는 굵고 진하게 가른다(2026-09-04 사용자 「색상은 변경」). +CONTOUR_COLOR = "#9aa3ad" +PARCEL_COLOR = "#8c6b4f" +JIBUN_COLOR = "#5c4632" +EMD_COLOR = "#2f7d4f" +SGG_COLOR = "#a63d3d" +ROUTE_COLOR = "#ffe066" +LEGEND_COLOR = TABLE_LABEL_COLOR + +_ROUTE_WIDTH = 3 +_SGG_WIDTH = 3 +_EMD_WIDTH = 2 +_JIBUN_FONT_SIZE = 1.8 +_LEGEND_FONT_SIZE = 2.4 +# 지번을 적을 최소 필지 크기(종이 mm) — 이보다 작으면 글자가 겹쳐 읽히지 않는다. +_JIBUN_MIN_W_MM = 6.0 +_JIBUN_MIN_H_MM = 3.0 + +_LEGEND_ROW_H = 6.0 +_LEGEND_SAMPLE_W = 12.0 +_LEGEND_GAP = 3.0 +_LEGEND_TOP_GAP = 8.0 + +# 범례 항목 (표기 이름, 색, 선굵기, 파선). +_LEGEND_ROWS: tuple[tuple[str, str, int, list[int] | None], ...] = ( + ("계획노선", ROUTE_COLOR, _ROUTE_WIDTH, None), + ("필지 경계", PARCEL_COLOR, 1, None), + ("읍면동·리 경계", EMD_COLOR, _EMD_WIDTH, [8, 4]), + ("시군구 경계", SGG_COLOR, _SGG_WIDTH, [14, 5, 3, 5]), + ("등고선", CONTOUR_COLOR, 1, None), +) + + +def _ring_center(ring: list[tuple[float, float]]) -> tuple[float, float]: + """고리의 bbox 중심 — 오목한 필지에서도 글자가 도면 밖으로 튀지 않는다.""" + xs = [x for x, _y in ring] + ys = [y for _x, y in ring] + return ((min(xs) + max(xs)) / 2.0, (min(ys) + max(ys)) / 2.0) + + +def _legend_entities(drawing_id: str, origin: tuple[float, float]) -> list[dict[str, Any]]: + """범례 — 유역도에서 유역 정보표가 있던 자리(오른쪽 칸)에 놓는다.""" + entities: list[dict[str, Any]] = [] + x, y = origin + entities.append( + _text_entity( + f"{drawing_id}:legend:title", + "범 례", + x + _LEGEND_SAMPLE_W / 2.0 + 6.0, + y, + LEGEND_LAYER_ID, + _LEGEND_FONT_SIZE + 0.6, + LEGEND_COLOR, + ) + ) + for index, (label, color, width, dash) in enumerate(_LEGEND_ROWS): + row_y = y - _LEGEND_TOP_GAP - index * _LEGEND_ROW_H + sample = polyline_entity( + drawing_id, + [(x, row_y), (x + _LEGEND_SAMPLE_W, row_y)], + LEGEND_LAYER_ID, + color, + suffix=f":legend:{index}", + dash=dash, + width=width, + ) + if sample: + entities.append(sample) + entities.append( + _text_entity( + f"{drawing_id}:legend:label:{index}", + label, + x + _LEGEND_SAMPLE_W + _LEGEND_GAP, + row_y, + LEGEND_LAYER_ID, + _LEGEND_FONT_SIZE, + LEGEND_COLOR, + align="left", + ) + ) + return entities + + +def _boundary_entities( + drawing_id: str, + rings: list[list[tuple[float, float]]], + layer_id: str, + color: str, + width: int, + dash: list[int] | None, + paper: Any, + tag: str, +) -> list[dict[str, Any]]: + entities: list[dict[str, Any]] = [] + for index, ring in enumerate(rings): + line = polyline_entity( + drawing_id, + [paper(point) for point in ring], + layer_id, + color, + suffix=f":{tag}:{index}", + dash=dash, + width=width, + ) + if line: + entities.append(line) + return entities + + +def build_landuse_drawing( + drawing_id: str, + label: str, + route_xy: list[tuple[float, float]], + contours: list[list[tuple[float, float]]], + parcels: list[dict[str, Any]], + emd_rings: list[list[tuple[float, float]]], + sgg_rings: list[list[tuple[float, float]]], +) -> dict[str, Any]: + """용지도 한 장을 만든다. 좌표는 모두 사업지 CRS(m)로 받아 종이 mm로만 옮긴다. + + `parcels`는 {"ring": [(x, y)...], "props": {지적 속성}} 목록이다. 도곽에 걸친 필지는 + 라우터가 잘라 넘기므로 고리가 아니라 **열린 선**일 수 있다. + """ + everything = [ + *route_xy, + *(point for line in contours for point in line), + *(point for parcel in parcels for point in parcel.get("ring") or []), + ] + if not everything: + raise FileNotFoundError( + "용지도에 그릴 좌표가 없습니다. B04 전처리에서 연속지적도·수치지형도를 먼저 받으세요." + ) + min_x = min(x for x, _y in everything) + min_y = min(y for _x, y in everything) + + def paper(point: tuple[float, float]) -> tuple[float, float]: + return ((point[0] - min_x) * MM, (point[1] - min_y) * MM) + + entities: list[dict[str, Any]] = [] + # 배경 등고선이 가장 아래 — 지적 경계가 주제라 옅게 깐다. + entities.extend( + _boundary_entities( + drawing_id, contours, CONTOUR_LAYER_ID, CONTOUR_COLOR, 1, None, paper, "contour" + ) + ) + # 필지 경계 + 지번. + jibun: list[dict[str, Any]] = [] + for index, parcel in enumerate(parcels): + ring = parcel.get("ring") or [] + label_at = parcel.get("label_at") + if len(ring) >= 2: + outline = polyline_entity( + drawing_id, + [paper(point) for point in ring], + PARCEL_LAYER_ID, + PARCEL_COLOR, + suffix=f":parcel:{index}", + ) + if outline: + entities.append(outline) + elif label_at is None: + continue + if label_at is not None: + # 도곽을 통째로 감싼 필지 — 경계선이 없으니 지정된 자리에 지번만 적는다. + center = paper(tuple(label_at)) + else: + paper_ring = [paper(point) for point in ring] + width = max(x for x, _y in paper_ring) - min(x for x, _y in paper_ring) + height = max(y for _x, y in paper_ring) - min(y for _x, y in paper_ring) + # 작은 필지는 지번을 솎는다 — 글자가 겹치면 큰 필지 것까지 못 읽는다. + if width < _JIBUN_MIN_W_MM or height < _JIBUN_MIN_H_MM: + continue + center = _ring_center(paper_ring) + text = (parcel.get("props") or {}).get("jibun") + if not isinstance(text, str) or not text: + continue + jibun.append( + _text_entity( + f"{drawing_id}:jibun:{index}", + text, + center[0], + center[1], + JIBUN_LAYER_ID, + _JIBUN_FONT_SIZE, + JIBUN_COLOR, + ) + ) + # 행정 경계는 필지 위에, 노선은 그 위에 — 아래에 깔리면 필지 선에 묻힌다. + entities.extend( + _boundary_entities( + drawing_id, emd_rings, EMD_LAYER_ID, EMD_COLOR, _EMD_WIDTH, [8, 4], paper, "emd" + ) + ) + entities.extend( + _boundary_entities( + drawing_id, + sgg_rings, + SGG_LAYER_ID, + SGG_COLOR, + _SGG_WIDTH, + [14, 5, 3, 5], + paper, + "sgg", + ) + ) + map_bbox = entities_bbox(entities) + route = polyline_entity( + drawing_id, + [paper(point) for point in route_xy], + ROUTE_LAYER_ID, + ROUTE_COLOR, + width=_ROUTE_WIDTH, + ) + if route: + entities.append(route) + entities.extend(jibun) # 지번은 가장 위 — 선에 가리면 못 읽는다. + + # 오른쪽 칸: 방위표가 맨 위, 그 아래로 범례(유역도에서 유역 정보표가 있던 자리). + if map_bbox: + column_x = map_bbox[2] + _COMPASS_MARGIN + column_top = map_bbox[3] + entities.extend( + compass_entities( + drawing_id, + (column_x + _COMPASS_SIZE / 2.0, column_top - _COMPASS_SIZE / 2.0), + _COMPASS_SIZE, + ) + ) + entities.extend(_legend_entities(drawing_id, (column_x, column_top - _COMPASS_SIZE - 10.0))) + + bbox = entities_bbox(entities) + if bbox: + min_bx, _min_by, max_bx, max_by = bbox + entities.append( + _text_entity( + f"{drawing_id}:title", + label, + (min_bx + max_bx) / 2.0, + max_by + 12.0, + TITLE_LAYER_ID, + _TITLE_FONT_SIZE, + TABLE_LABEL_COLOR, + ) + ) + entities.append( + _text_entity( + f"{drawing_id}:scale", + f"S = 1/{DRAWING_SCALE_PLAN:,}", + max_bx, + max_by + 5.0, + TITLE_LAYER_ID, + _FONT_SIZE, + TABLE_LABEL_COLOR, + align="right", + ) + ) + entities.extend( + frame_entities( + drawing_id, + entities_bbox(entities) or bbox, + fit=False, + fields={"도면명": label, **scale_fields(("", DRAWING_SCALE_PLAN))}, + ) + ) + + return { + "format": DRAWING_FORMAT, + "entities": entities, + "layers": [ + _layer(CONTOUR_LAYER_ID, "등고선", locked=True), + _layer(PARCEL_LAYER_ID, "필지 경계"), + _layer(JIBUN_LAYER_ID, "지번"), + _layer(EMD_LAYER_ID, "읍면동·리 경계"), + _layer(SGG_LAYER_ID, "시군구 경계"), + _layer(ROUTE_LAYER_ID, "계획노선"), + _layer(LEGEND_LAYER_ID, "범례"), + _layer(TITLE_LAYER_ID, "표제"), + _layer(FRAME_LAYER_ID, "도각", locked=True), + ], + } + + +def landuse_area_mm() -> tuple[float, float]: + """지적 배경이 차지할 수 있는 크기(mm) — 계획평면도와 같다(같은 축척·같은 도곽).""" + return plan_area_mm() diff --git a/B07_DesignDetail/B07_DesignDetail_Router.py b/B07_DesignDetail/B07_DesignDetail_Router.py index f4777153..aa8b3b5c 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router.py +++ b/B07_DesignDetail/B07_DesignDetail_Router.py @@ -34,6 +34,7 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Template import ( use_title_fields, ) from B07_DesignDetail.B07_DesignDetail_Router_Support import ( + LANDUSE_ID, MASS_HAUL_ID, PLAN_ID, WATERSHED_ID, @@ -44,6 +45,7 @@ from B07_DesignDetail.B07_DesignDetail_Router_Support import ( _read_json, _recompute_confirmed_design, _store_confirmed_drawing, + landuse_source, plan_source, watershed_source, ) @@ -303,6 +305,15 @@ async def get_design_drawing( if context is None: return JSONResponse(status_code=404, content={"status": "error", "message": reason}) source_design = await asyncio.to_thread(watershed_source, context) + elif LANDUSE_ID.fullmatch(drawing_id): + # 용지도도 같은 배경 창구를 쓴다 — 지적·행정 경계만 따로 읽는다. + context, reason = await load_drainage_context(project_id) + if context is None: + return JSONResponse(status_code=404, content={"status": "error", "message": reason}) + longitudinal = await asyncio.to_thread(_read_json, longitudinal_path) + source_design = await asyncio.to_thread( + landuse_source, context, longitudinal, drawing_id + ) elif PLAN_ID.fullmatch(drawing_id): # 계획평면도는 유역도와 **같은 배경 창구**를 쓴다 — 자료 읽기·환산이 캐시된다. context, reason = await load_drainage_context(project_id) diff --git a/B07_DesignDetail/B07_DesignDetail_Router_Support.py b/B07_DesignDetail/B07_DesignDetail_Router_Support.py index ef362b2a..9fb6f583 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router_Support.py +++ b/B07_DesignDetail/B07_DesignDetail_Router_Support.py @@ -21,6 +21,10 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Cover import ( build_blank_drawing, build_cover_drawing, ) +from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Landuse import ( + LANDUSE_LABEL, + build_landuse_drawing, +) from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Long import ( build_longitudinal_drawing, longitudinal_chunks, @@ -52,6 +56,9 @@ from B07_DesignDetail.B07_DesignDetail_Router_Support_Basin import ( from B07_DesignDetail.B07_DesignDetail_Router_Support_Basin import ( CONTOUR_FILE as CONTOUR_FILE, ) +from B07_DesignDetail.B07_DesignDetail_Router_Support_Basin import ( + LANDUSE_ID as LANDUSE_ID, +) from B07_DesignDetail.B07_DesignDetail_Router_Support_Basin import ( PLAN_ID as PLAN_ID, ) @@ -79,6 +86,9 @@ from B07_DesignDetail.B07_DesignDetail_Router_Support_Basin import ( from B07_DesignDetail.B07_DesignDetail_Router_Support_Basin import ( clip_line_to_box as clip_line_to_box, ) +from B07_DesignDetail.B07_DesignDetail_Router_Support_Basin import ( + landuse_source as landuse_source, +) from B07_DesignDetail.B07_DesignDetail_Router_Support_Basin import ( plan_source as plan_source, ) @@ -119,7 +129,6 @@ BLANK_DRAWINGS: tuple[tuple[str, str], ...] = ( ("blank_plan_lidar", "계획평면도(라이다)"), ("blank_cross_standard", "표준 횡단면도"), ("blank_standard", "표준도"), - ("blank_landuse", "용지도"), ) BLANK_LABELS: dict[str, str] = dict(BLANK_DRAWINGS) @@ -177,6 +186,19 @@ def _drawing_list( confirmed=bool(manifest_drawings.get(drawing_id, {}).get("confirmed")), ) ) + # 용지도 — 계획평면도와 같은 축척·같은 장 나눔을 쓴다. + for chunk in plan_sheets: + drawing_id = "landuse" if len(plan_sheets) <= 1 else f"landuse_{chunk['number']}" + drawings.append( + DesignDrawingItem( + id=drawing_id, + kind="landuse", + label=LANDUSE_LABEL + if len(plan_sheets) <= 1 + else f"{LANDUSE_LABEL} {chunk['number']}장", + confirmed=bool(manifest_drawings.get(drawing_id, {}).get("confirmed")), + ) + ) # 노선 전장 1장짜리 도면 — 자료가 없으면 여는 시점에 404로 알린다(목록에는 항상 둔다). for drawing_id, kind, label in ( (COVER_ID, "cover", "표지"), @@ -409,6 +431,8 @@ def _read_drawing( kind = drawing_id # id와 kind가 같은 단장 도면 elif PLAN_ID.fullmatch(drawing_id): kind = "plan" + elif LANDUSE_ID.fullmatch(drawing_id): + kind = "landuse" else: kind = "longitudinal" if _LONG_ID.fullmatch(drawing_id) else "cross" label = str(manifest_entry.get("label") or drawing_id) @@ -437,6 +461,27 @@ def _read_drawing( None, ) + if LANDUSE_ID.fullmatch(drawing_id): + # stored_design = landuse_source()가 모아 준 노선·등고선·지적·행정 경계(사업지 CRS). + if not isinstance(stored_design, dict): + raise FileNotFoundError("용지도 자료가 없습니다.") + label = str(stored_design.get("label") or LANDUSE_LABEL) + return ( + "landuse", + label, + build_landuse_drawing( + drawing_id, + label, + stored_design.get("route_xy") or [], + stored_design.get("contours") or [], + stored_design.get("parcels") or [], + stored_design.get("emd_rings") or [], + stored_design.get("sgg_rings") or [], + ), + False, + None, + ) + if PLAN_ID.fullmatch(drawing_id): # stored_design = plan_source()가 모아 준 노선·측점·배경·구조물 좌표(사업지 CRS). if not isinstance(stored_design, dict): diff --git a/B07_DesignDetail/B07_DesignDetail_Router_Support_Basin.py b/B07_DesignDetail/B07_DesignDetail_Router_Support_Basin.py index 73c52af0..3c104371 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router_Support_Basin.py +++ b/B07_DesignDetail/B07_DesignDetail_Router_Support_Basin.py @@ -15,6 +15,7 @@ from pyproj import Transformer from B04_PreProcess.B04_PreProcess_Router_Watershed import CONTOUR_FILE, STREAM_FILE from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Basin import map_area_mm +from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Landuse import LANDUSE_LABEL from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Plan import ( plan_area_mm, plan_chunks, @@ -76,7 +77,7 @@ def _basins_crs(context: Any, payload: dict[str, Any]) -> str: def _geometry_lines(geometry: Any) -> list[list[tuple[float, float]]]: - """LineString·MultiLineString·Polygon을 점열 목록으로 편다.""" + """LineString·MultiLineString·Polygon·MultiPolygon을 점열 목록으로 편다.""" if not isinstance(geometry, dict): return [] kind = geometry.get("type") @@ -91,6 +92,15 @@ def _geometry_lines(geometry: Any) -> list[list[tuple[float, float]]]: for part in coordinates if isinstance(part, list) ] + # 연속지적도·행정구역은 MultiPolygon 이다 — 폴리곤마다 고리를 모두 편다(2026-09-04). + if kind == "MultiPolygon": + return [ + [(float(p[0]), float(p[1])) for p in ring if isinstance(p, list)] + for polygon in coordinates + if isinstance(polygon, list) + for ring in polygon + if isinstance(ring, list) + ] return [] @@ -336,6 +346,130 @@ def _plan_structures(context: Any) -> list[dict[str, Any]]: return [point for point in points if isinstance(point, dict)] +LANDUSE_ID = re.compile(r"^landuse(?:_(\d+))?$") + +# B04 가 내려받아 저장하는 지적·행정구역 GeoJSON (전부 WGS84). +PARCEL_FILE = "연속지적도_bounds.geojson" +EMD_FILE = "행정구역_읍면동_bounds.geojson" +SGG_FILE = "행정구역_시군구_bounds.geojson" + + +def _clip_rings( + path: Path, crs: str, box: tuple[float, float, float, float] +) -> list[list[tuple[float, float]]]: + """행정구역 경계를 사업지 좌표계로 돌려 도곽 범위로 절취한다(속성은 안 씀).""" + rings: list[list[tuple[float, float]]] = [] + for ring in _metric_lines(path, crs): + rings.extend(clip_line_to_box(ring, box)) + return rings + + +def _contains(ring: list[tuple[float, float]], point: tuple[float, float]) -> bool: + """점이 고리 안에 드는지 (반직선 교차 판정).""" + x, y = point + inside = False + for index in range(len(ring)): + x1, y1 = ring[index - 1] + x2, y2 = ring[index] + if (y1 > y) != (y2 > y) and x < (x2 - x1) * (y - y1) / ((y2 - y1) or 1e-12) + x1: + inside = not inside + return inside + + +def _clip_parcels( + path: Path, crs: str, box: tuple[float, float, float, float] +) -> list[dict[str, Any]]: + """연속지적도를 사업지 좌표계로 돌려 도곽 안 필지만 남긴다 (지번 표기용 속성 포함). + + 필지는 지번을 적어야 하므로 경계선만 자르는 `_metric_lines` 캐시를 쓰지 못한다 — + 피처와 속성을 짝지어 읽는다. 도곽 밖 필지는 여기서 버려 도면이 무거워지지 않게 한다. + + 두 가지를 함께 낸다. + - `ring` : 도곽으로 자른 경계선(밖으로 나가는 부분은 버린다). 자르지 않으면 + 산지 대필지 하나가 도면을 10 km 로 벌린다(2026-09-04 실측: 콘텐츠 8,368 mm). + - `label_at` : 도곽을 **통째로 감싸는** 필지의 지번 자리. 임야 대필지 안에 노선이 + 들어앉으면 경계선이 도곽 안에 하나도 없어 지번이 사라진다(2026-09-04 실측: + 용화_LAS 노선이 「산77-1임 일월면 용화리」 한 필지 안에 통째로 들어감). + """ + if not path.is_file(): + return [] + transformer = Transformer.from_crs("EPSG:4326", crs, always_xy=True) + min_x, min_y, max_x, max_y = box + center = ((min_x + max_x) / 2.0, (min_y + max_y) / 2.0) + parcels: list[dict[str, Any]] = [] + for feature in _geojson_features(path): + properties = feature.get("properties") or {} + for ring in _geometry_lines(feature.get("geometry")): + converted = [ + (float(x), float(y)) + for x, y in (transformer.transform(point[0], point[1]) for point in ring) + ] + if len(converted) < 3: + continue + if max(x for x, _y in converted) < min_x or min(x for x, _y in converted) > max_x: + continue + if max(y for _x, y in converted) < min_y or min(y for _x, y in converted) > max_y: + continue + parts = [part for part in clip_line_to_box(converted, box) if len(part) >= 2] + for part in parts: + parcels.append({"ring": part, "props": properties}) + if not parts and _contains(converted, center): + parcels.append({"ring": [], "props": properties, "label_at": center}) + return parcels + + +def landuse_source(context: Any, longitudinal: dict[str, Any], drawing_id: str) -> dict[str, Any]: + """용지도 한 장의 입력(노선·등고선·연속지적도·행정구역)을 사업지 CRS(m)로 모은다. + + 축척·도곽·장 나눔은 계획평면도와 같다 — 배경도 같은 창구(`map_background`)를 쓴다. + """ + match = LANDUSE_ID.fullmatch(drawing_id) + if not match: + raise ValueError("올바르지 않은 용지도 ID입니다.") + chunks = plan_chunks(plan_stations(longitudinal)) + number = int(match.group(1)) if match.group(1) else 1 + chunk = next((item for item in chunks if item["number"] == number), None) + if chunk is None: + raise FileNotFoundError("요청한 용지도 장을 찾을 수 없습니다.") + total = len(chunks) + start_m, end_m = float(chunk["start_m"]), float(chunk["end_m"]) + + route_xy = [ + (vertex.x, vertex.y) + for vertex in context.vertices + if total <= 1 or start_m <= float(getattr(vertex, "chainage_m", 0.0) or 0.0) <= end_m + ] + background = map_background( + Path(context.project_root), + context.crs, + DRAWING_SCALE_PLAN, + plan_area_mm(), + route_xy, + ) + # 지적·행정 경계는 등고선과 **같은 범위**로 자른다 — 배경보다 넓으면 도면이 A1을 넘는다. + area_w_mm, area_h_mm = plan_area_mm() + half_w = area_w_mm / 2.0 * DRAWING_SCALE_PLAN / 1000.0 + half_h = area_h_mm / 2.0 * DRAWING_SCALE_PLAN / 1000.0 + center_x = (min(x for x, _y in route_xy) + max(x for x, _y in route_xy)) / 2.0 + center_y = (min(y for _x, y in route_xy) + max(y for _x, y in route_xy)) / 2.0 + box = ( + min(center_x - half_w, min(x for x, _y in route_xy)), + min(center_y - half_h, min(y for _x, y in route_xy)), + max(center_x + half_w, max(x for x, _y in route_xy)), + max(center_y + half_h, max(y for _x, y in route_xy)), + ) + sheet_dir = Path(context.project_root) / "B04_PreProcess" / "processed" + label = LANDUSE_LABEL if total <= 1 else f"{LANDUSE_LABEL} {number}장" + return { + "label": label, + "route_xy": route_xy, + "contours": background["contours"], + "parcels": _clip_parcels(sheet_dir / PARCEL_FILE, context.crs, box), + "emd_rings": _clip_rings(sheet_dir / EMD_FILE, context.crs, box), + "sgg_rings": _clip_rings(sheet_dir / SGG_FILE, context.crs, box), + } + + def watershed_source(context: Any) -> dict[str, Any]: """유역도 입력(노선·세부유역·등고선·세류선)을 사업지 CRS(m)로 모은다. diff --git a/B07_DesignDetail/B07_DesignDetail_Schema.py b/B07_DesignDetail/B07_DesignDetail_Schema.py index 1fc0ce6d..d4b7fddf 100644 --- a/B07_DesignDetail/B07_DesignDetail_Schema.py +++ b/B07_DesignDetail/B07_DesignDetail_Schema.py @@ -10,7 +10,16 @@ class DesignDrawingItem(BaseModel): id: str # blank: 아직 내용을 만들지 않은 도면 — 도각만 실어 연다(2026-09-01 사용자 지시). - kind: Literal["cover", "longitudinal", "cross", "mass_haul", "watershed", "plan", "blank"] + kind: Literal[ + "cover", + "longitudinal", + "cross", + "mass_haul", + "watershed", + "plan", + "landuse", + "blank", + ] label: str chainage_m: float | None = None confirmed: bool = False @@ -33,7 +42,16 @@ class DesignDrawingResponse(BaseModel): route_id: int id: str # blank: 아직 내용을 만들지 않은 도면 — 도각만 실어 연다(2026-09-01 사용자 지시). - kind: Literal["cover", "longitudinal", "cross", "mass_haul", "watershed", "plan", "blank"] + kind: Literal[ + "cover", + "longitudinal", + "cross", + "mass_haul", + "watershed", + "plan", + "landuse", + "blank", + ] label: str drawing: dict[str, Any] confirmed: bool = False diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Panels.ts b/B07_DesignDetail/B07_DesignDetail_UI_Panels.ts index 9e8ef8ad..74d19953 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Panels.ts +++ b/B07_DesignDetail/B07_DesignDetail_UI_Panels.ts @@ -41,7 +41,7 @@ export const DRAWING_GROUPS: readonly { { label: "토적도(유토곡선)", kind: "mass_haul" }, { label: "유역도(배수 유역도)", kind: "watershed" }, { label: "표준도", blankId: "blank_standard" }, - { label: "용지도", blankId: "blank_landuse" }, + { label: "용지도", idPrefix: "landuse" }, ]; /** B06 확정 산출물 기반 도면 목록 패널. */ From 18c174036b3a0de7053547bf9021aa0ecff5d16e Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 4 Sep 2026 19:00:38 +0900 Subject: [PATCH 09/11] =?UTF-8?q?feat(B07):=20=EA=B3=84=ED=9A=8D=ED=8F=89?= =?UTF-8?q?=EB=A9=B4=EB=8F=84(=EB=9D=BC=EC=9D=B4=EB=8B=A4)=20=E2=80=94=20?= =?UTF-8?q?=EC=A7=80=ED=91=9C=EB=A9=B4=20=EC=9D=8C=EC=98=81=EA=B8=B0?= =?UTF-8?q?=EB=B3=B5=20=EB=B0=B0=EA=B2=BD=201=EC=B0=A8=20=EB=B0=B0?= =?UTF-8?q?=EC=B9=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 빈 도각이던 라이다 계획평면도에 지표면 탑뷰 그림을 얹음. - 확정 DTM 격자를 도곽 범위로 잘라 음영기복 PNG 를 만들고 Image 엔티티로 실음 (북서 315도·고도 45도, 한 변 최대 1,600 px). 점구름 4,900만 점을 그대로 그리지 않음. - 어느 지표면을 쓸지는 1단계 확정값을 따름 — DrainageContext 에 surface_params 를 실어 전달. - 축척·도곽·장 나눔은 계획평면도와 같음(1/1,200) — 노선이 같은 자리에 섬. - entities_bbox 가 꼭짓점 배열(points)을 세도록 고침. 세지 않으면 그림이 도곽 계산에서 통째로 빠짐. 검증(용화_LAS): 콘텐츠 726.1x487.2 mm ≤ A1 작도영역, 그림 범위 안에 노선이 완전히 들어감, 음영기복 준비 0.4초·자료 214 KB. 능선·계곡이 눈으로 구분됨. Co-Authored-By: Claude Opus 5 (1M context) --- .../B07_DesignDetail_Api_Fetch.ts | 2 + .../B07_DesignDetail_Engine_Cad_Lidar.py | 220 ++++++++++++++++++ .../B07_DesignDetail_Engine_Template.py | 6 + B07_DesignDetail/B07_DesignDetail_Router.py | 9 + .../B07_DesignDetail_Router_Support.py | 45 +++- .../B07_DesignDetail_Router_Support_Basin.py | 114 +++++++++ B07_DesignDetail/B07_DesignDetail_Schema.py | 2 + .../B07_DesignDetail_UI_Panels.ts | 2 +- common_util/common_util_drainage_context.py | 4 + 9 files changed, 402 insertions(+), 2 deletions(-) create mode 100644 B07_DesignDetail/B07_DesignDetail_Engine_Cad_Lidar.py diff --git a/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts b/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts index 38a360c9..b51b8f63 100644 --- a/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts +++ b/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts @@ -13,6 +13,7 @@ export interface DesignDrawingItem { | "watershed" | "plan" | "landuse" + | "plan_lidar" | "blank"; label: string; chainage_m: number | null; @@ -99,6 +100,7 @@ export interface DesignDrawingResponse { | "watershed" | "plan" | "landuse" + | "plan_lidar" | "blank"; label: string; drawing: CadDrawing; diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Lidar.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Lidar.py new file mode 100644 index 00000000..726061c1 --- /dev/null +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Lidar.py @@ -0,0 +1,220 @@ +"""B07 계획평면도(라이다) CAD 조립 — 지표면 격자를 음영기복 그림으로 깔고 노선을 얹는다. + +사용자 지시(2026-09-04) — 「라이다 계획평면도는 3D 자료를 탑뷰에서 본 그림이 필요함. +가능한 범위에서 일단 배치해 주면 보고 개선하겠음」. + +점구름을 그대로 그리면 수천만 점이라 도면 만들기가 느려진다(용화_LAS 실측 4,900만 점). +이미 만들어 둔 **지표면 격자(DTM)** 로 음영기복 이미지를 서버에서 만들어 배경으로 깐다. +도면 틀이 이미지 요소를 받아 주므로(`Image` 엔티티) PNG 를 그대로 싣는다. + +축척·도곽·장 나눔은 계획평면도와 같다(1/1,200 고정) — 같은 자리에 노선이 서야 한다. +""" + +import base64 +import io +import math +from typing import Any + +import numpy as np + +from B07_DesignDetail.B07_DesignDetail_Engine_Cad import ( + DRAWING_FORMAT, + FRAME_LAYER_ID, + TABLE_LABEL_COLOR, + _layer, + _text_entity, + polyline_entity, +) +from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Plan import ( + _COMPASS_MARGIN, + _COMPASS_SIZE, + _FONT_SIZE, + _ROUTE_WIDTH, + _TITLE_FONT_SIZE, + MM, +) +from B07_DesignDetail.B07_DesignDetail_Engine_Template import ( + compass_entities, + entities_bbox, + frame_entities, + scale_fields, +) +from config.config_system import DRAWING_SCALE_PLAN + +LIDAR_KIND = "plan_lidar" +LIDAR_LABEL = "계획평면도(라이다)" + +SHADE_LAYER_ID = "b07-lidar-shade" +ROUTE_LAYER_ID = "b07-lidar-route" +TITLE_LAYER_ID = "b07-lidar-title" +ROUTE_COLOR = "#ffe066" + +# 음영기복 광원 — 도면 관행대로 북서(방위각 315°)에서 45° 높이로 비춘다. +_AZIMUTH_DEG = 315.0 +_ALTITUDE_DEG = 45.0 +# 그림이 지나치게 커지지 않도록 한 변 최대 픽셀 수 (A1 에 인쇄하면 1,200 px 이면 충분하다). +_MAX_PIXELS = 1600 + + +def hillshade_png(z: np.ndarray, valid: np.ndarray, resolution_m: float) -> tuple[str, int, int]: + """지표면 격자에서 음영기복 PNG(data URL)를 만든다. (data_url, 가로 px, 세로 px). + + 입력 `z`는 행이 남→북 순서(격자 y 오름차순)다. 그림은 위가 북이어야 하므로 뒤집는다. + 빈 칸(`valid`가 False)은 흰색으로 두어 도면에서 배경과 구분되게 한다. + """ + from PIL import Image + + grid = np.asarray(z, dtype=np.float64) + mask = np.asarray(valid, dtype=bool) + if grid.ndim != 2 or grid.size == 0: + raise ValueError("지표면 격자가 비어 있습니다.") + + # 큰 격자는 미리 솎는다 — A1 한 장에 1,600 px 이상은 눈으로 구분되지 않는다. + rows, columns = grid.shape + stride = max(1, math.ceil(max(rows, columns) / _MAX_PIXELS)) + if stride > 1: + grid = grid[::stride, ::stride] + mask = mask[::stride, ::stride] + resolution_m *= stride + + filled = np.where(mask, grid, np.nan) + # 빈 칸이 기울기를 망치지 않도록 평균으로 메운 뒤 기울기를 잰다. + mean = float(np.nanmean(filled)) if np.isfinite(filled).any() else 0.0 + filled = np.nan_to_num(filled, nan=mean) + dz_dy, dz_dx = np.gradient(filled, max(resolution_m, 1e-6)) + + slope = np.arctan(np.hypot(dz_dx, dz_dy)) + aspect = np.arctan2(-dz_dx, dz_dy) + azimuth = math.radians(360.0 - _AZIMUTH_DEG + 90.0) + altitude = math.radians(_ALTITUDE_DEG) + shade = np.sin(altitude) * np.cos(slope) + np.cos(altitude) * np.sin(slope) * np.cos( + azimuth - aspect + ) + shade = np.clip(shade, 0.0, 1.0) + # 배경이므로 완전히 검지 않게 누르되, 능선·계곡이 인쇄에서 보일 만큼은 대비를 준다 + # (2026-09-04 실측: 120~255 는 너무 흐렸음). + pixels = (90 + 160 * shade).astype(np.uint8) + pixels[~mask] = 255 + + image = Image.fromarray(np.flipud(pixels), mode="L") + buffer = io.BytesIO() + image.save(buffer, format="PNG", optimize=True) + data_url = "data:image/png;base64," + base64.b64encode(buffer.getvalue()).decode("ascii") + return (data_url, image.width, image.height) + + +def build_lidar_plan_drawing( + drawing_id: str, + label: str, + route_xy: list[tuple[float, float]], + shade_image: str | None, + shade_box: tuple[float, float, float, float] | None, +) -> dict[str, Any]: + """라이다 계획평면도 한 장을 만든다. + + `shade_box`는 음영기복 그림이 덮는 실좌표 범위(min_x, min_y, max_x, max_y)다 — + 그림 네 모서리를 그 범위 그대로 종이에 놓아야 노선과 좌표가 맞는다. + """ + everything = [*route_xy] + if shade_box: + everything.extend([(shade_box[0], shade_box[1]), (shade_box[2], shade_box[3])]) + if not everything: + raise FileNotFoundError( + "라이다 계획평면도에 그릴 자료가 없습니다. B04 전처리에서 지표면을 먼저 만드세요." + ) + min_x = min(x for x, _y in everything) + min_y = min(y for _x, y in everything) + + def paper(point: tuple[float, float]) -> tuple[float, float]: + return ((point[0] - min_x) * MM, (point[1] - min_y) * MM) + + entities: list[dict[str, Any]] = [] + if shade_image and shade_box: + left, bottom = paper((shade_box[0], shade_box[1])) + right, top = paper((shade_box[2], shade_box[3])) + entities.append( + { + "id": f"{drawing_id}:shade", + "type": "Image", + "lineColor": "#ffffff", + "lineWidth": 1, + "layerId": SHADE_LAYER_ID, + "shapeData": { + "points": [ + {"x": left, "y": bottom}, + {"x": right, "y": bottom}, + {"x": right, "y": top}, + {"x": left, "y": top}, + ], + "imageData": shade_image, + }, + } + ) + route = polyline_entity( + drawing_id, + [paper(point) for point in route_xy], + ROUTE_LAYER_ID, + ROUTE_COLOR, + width=_ROUTE_WIDTH, + ) + if route: + entities.append(route) + + map_bbox = entities_bbox(entities) + if map_bbox: + entities.extend( + compass_entities( + drawing_id, + ( + map_bbox[2] + _COMPASS_MARGIN + _COMPASS_SIZE / 2.0, + map_bbox[3] - _COMPASS_SIZE / 2.0, + ), + _COMPASS_SIZE, + ) + ) + + bbox = entities_bbox(entities) + if bbox: + min_bx, _min_by, max_bx, max_by = bbox + entities.append( + _text_entity( + f"{drawing_id}:title", + label, + (min_bx + max_bx) / 2.0, + max_by + 12.0, + TITLE_LAYER_ID, + _TITLE_FONT_SIZE, + TABLE_LABEL_COLOR, + ) + ) + entities.append( + _text_entity( + f"{drawing_id}:scale", + f"S = 1/{DRAWING_SCALE_PLAN:,}", + max_bx, + max_by + 5.0, + TITLE_LAYER_ID, + _FONT_SIZE, + TABLE_LABEL_COLOR, + align="right", + ) + ) + entities.extend( + frame_entities( + drawing_id, + entities_bbox(entities) or bbox, + fit=False, + fields={"도면명": label, **scale_fields(("", DRAWING_SCALE_PLAN))}, + ) + ) + + return { + "format": DRAWING_FORMAT, + "entities": entities, + "layers": [ + _layer(SHADE_LAYER_ID, "지표면 음영기복", locked=True), + _layer(ROUTE_LAYER_ID, "계획노선"), + _layer(TITLE_LAYER_ID, "표제"), + _layer(FRAME_LAYER_ID, "도각", locked=True), + ], + } diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Template.py b/B07_DesignDetail/B07_DesignDetail_Engine_Template.py index ecec48f0..6708d945 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Template.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Template.py @@ -194,6 +194,12 @@ def entities_bbox(entities: list[dict[str, Any]]) -> tuple[float, float, float, if isinstance(p, dict): xs.append(float(p["x"])) ys.append(float(p["y"])) + # 꼭짓점 배열을 쓰는 엔티티(Image·Hatch)도 범위에 넣는다 — 넣지 않으면 라이다 + # 음영기복 그림이 도곽 계산에서 통째로 빠진다(2026-09-04). + for vertex in shape.get("points") or []: + if isinstance(vertex, dict) and "x" in vertex and "y" in vertex: + xs.append(float(vertex["x"])) + ys.append(float(vertex["y"])) center = shape.get("center") if isinstance(center, dict): r = float(shape.get("radius", 0.0)) diff --git a/B07_DesignDetail/B07_DesignDetail_Router.py b/B07_DesignDetail/B07_DesignDetail_Router.py index aa8b3b5c..5cd8a010 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router.py +++ b/B07_DesignDetail/B07_DesignDetail_Router.py @@ -35,6 +35,7 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Template import ( ) from B07_DesignDetail.B07_DesignDetail_Router_Support import ( LANDUSE_ID, + LIDAR_ID, MASS_HAUL_ID, PLAN_ID, WATERSHED_ID, @@ -46,6 +47,7 @@ from B07_DesignDetail.B07_DesignDetail_Router_Support import ( _recompute_confirmed_design, _store_confirmed_drawing, landuse_source, + lidar_source, plan_source, watershed_source, ) @@ -305,6 +307,13 @@ async def get_design_drawing( if context is None: return JSONResponse(status_code=404, content={"status": "error", "message": reason}) source_design = await asyncio.to_thread(watershed_source, context) + elif LIDAR_ID.fullmatch(drawing_id): + # 라이다 계획평면도는 확정 DTM 격자로 음영기복 그림을 만들어 넘긴다. + context, reason = await load_drainage_context(project_id) + if context is None: + return JSONResponse(status_code=404, content={"status": "error", "message": reason}) + longitudinal = await asyncio.to_thread(_read_json, longitudinal_path) + source_design = await asyncio.to_thread(lidar_source, context, longitudinal, drawing_id) elif LANDUSE_ID.fullmatch(drawing_id): # 용지도도 같은 배경 창구를 쓴다 — 지적·행정 경계만 따로 읽는다. context, reason = await load_drainage_context(project_id) diff --git a/B07_DesignDetail/B07_DesignDetail_Router_Support.py b/B07_DesignDetail/B07_DesignDetail_Router_Support.py index 9fb6f583..93936c4f 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router_Support.py +++ b/B07_DesignDetail/B07_DesignDetail_Router_Support.py @@ -25,6 +25,10 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Landuse import ( LANDUSE_LABEL, build_landuse_drawing, ) +from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Lidar import ( + LIDAR_LABEL, + build_lidar_plan_drawing, +) from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Long import ( build_longitudinal_drawing, longitudinal_chunks, @@ -59,6 +63,9 @@ from B07_DesignDetail.B07_DesignDetail_Router_Support_Basin import ( from B07_DesignDetail.B07_DesignDetail_Router_Support_Basin import ( LANDUSE_ID as LANDUSE_ID, ) +from B07_DesignDetail.B07_DesignDetail_Router_Support_Basin import ( + LIDAR_ID as LIDAR_ID, +) from B07_DesignDetail.B07_DesignDetail_Router_Support_Basin import ( PLAN_ID as PLAN_ID, ) @@ -89,6 +96,9 @@ from B07_DesignDetail.B07_DesignDetail_Router_Support_Basin import ( from B07_DesignDetail.B07_DesignDetail_Router_Support_Basin import ( landuse_source as landuse_source, ) +from B07_DesignDetail.B07_DesignDetail_Router_Support_Basin import ( + lidar_source as lidar_source, +) from B07_DesignDetail.B07_DesignDetail_Router_Support_Basin import ( plan_source as plan_source, ) @@ -126,7 +136,6 @@ COVER_ID = "cover" # 아직 내용을 만들지 않은 도면 — 도각만 실어 연다(2026-09-01 사용자 지시). # 화면 순서(DRAWING_GROUPS)와 같은 이름을 쓴다. BLANK_DRAWINGS: tuple[tuple[str, str], ...] = ( - ("blank_plan_lidar", "계획평면도(라이다)"), ("blank_cross_standard", "표준 횡단면도"), ("blank_standard", "표준도"), ) @@ -186,6 +195,19 @@ def _drawing_list( confirmed=bool(manifest_drawings.get(drawing_id, {}).get("confirmed")), ) ) + # 계획평면도(라이다) — 지표면 음영기복 배경. 같은 축척·같은 장 나눔. + for chunk in plan_sheets: + drawing_id = "plan_lidar" if len(plan_sheets) <= 1 else f"plan_lidar_{chunk['number']}" + drawings.append( + DesignDrawingItem( + id=drawing_id, + kind="plan_lidar", + label=LIDAR_LABEL + if len(plan_sheets) <= 1 + else f"{LIDAR_LABEL} {chunk['number']}장", + confirmed=bool(manifest_drawings.get(drawing_id, {}).get("confirmed")), + ) + ) # 용지도 — 계획평면도와 같은 축척·같은 장 나눔을 쓴다. for chunk in plan_sheets: drawing_id = "landuse" if len(plan_sheets) <= 1 else f"landuse_{chunk['number']}" @@ -433,6 +455,8 @@ def _read_drawing( kind = "plan" elif LANDUSE_ID.fullmatch(drawing_id): kind = "landuse" + elif LIDAR_ID.fullmatch(drawing_id): + kind = "plan_lidar" else: kind = "longitudinal" if _LONG_ID.fullmatch(drawing_id) else "cross" label = str(manifest_entry.get("label") or drawing_id) @@ -461,6 +485,25 @@ def _read_drawing( None, ) + if LIDAR_ID.fullmatch(drawing_id): + # stored_design = lidar_source()가 만든 노선 + 지표면 음영기복 그림. + if not isinstance(stored_design, dict): + raise FileNotFoundError("라이다 계획평면도 자료가 없습니다.") + label = str(stored_design.get("label") or LIDAR_LABEL) + return ( + "plan_lidar", + label, + build_lidar_plan_drawing( + drawing_id, + label, + stored_design.get("route_xy") or [], + stored_design.get("shade_image"), + stored_design.get("shade_box"), + ), + False, + None, + ) + if LANDUSE_ID.fullmatch(drawing_id): # stored_design = landuse_source()가 모아 준 노선·등고선·지적·행정 경계(사업지 CRS). if not isinstance(stored_design, dict): diff --git a/B07_DesignDetail/B07_DesignDetail_Router_Support_Basin.py b/B07_DesignDetail/B07_DesignDetail_Router_Support_Basin.py index 3c104371..7858a53f 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router_Support_Basin.py +++ b/B07_DesignDetail/B07_DesignDetail_Router_Support_Basin.py @@ -16,6 +16,7 @@ from pyproj import Transformer from B04_PreProcess.B04_PreProcess_Router_Watershed import CONTOUR_FILE, STREAM_FILE from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Basin import map_area_mm from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Landuse import LANDUSE_LABEL +from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Lidar import LIDAR_LABEL, hillshade_png from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Plan import ( plan_area_mm, plan_chunks, @@ -470,6 +471,119 @@ def landuse_source(context: Any, longitudinal: dict[str, Any], drawing_id: str) } +LIDAR_ID = re.compile(r"^plan_lidar(?:_(\d+))?$") + + +def _sheet_box( + route_xy: list[tuple[float, float]], +) -> tuple[float, float, float, float]: + """그 장의 도곽 범위(실좌표 m) — 계획평면도·용지도·라이다가 같은 규칙을 쓴다.""" + area_w_mm, area_h_mm = plan_area_mm() + half_w = area_w_mm / 2.0 * DRAWING_SCALE_PLAN / 1000.0 + half_h = area_h_mm / 2.0 * DRAWING_SCALE_PLAN / 1000.0 + center_x = (min(x for x, _y in route_xy) + max(x for x, _y in route_xy)) / 2.0 + center_y = (min(y for _x, y in route_xy) + max(y for _x, y in route_xy)) / 2.0 + return ( + min(center_x - half_w, min(x for x, _y in route_xy)), + min(center_y - half_h, min(y for _x, y in route_xy)), + max(center_x + half_w, max(x for x, _y in route_xy)), + max(center_y + half_h, max(y for _x, y in route_xy)), + ) + + +def _chunk_route( + context: Any, longitudinal: dict[str, Any], number: int +) -> tuple[list[tuple[float, float]], dict[str, Any], int]: + """장 번호로 그 장의 노선 구간을 잘라 낸다 (계획평면도 장 나눔과 같은 기준).""" + chunks = plan_chunks(plan_stations(longitudinal)) + chunk = next((item for item in chunks if item["number"] == number), None) + if chunk is None: + raise FileNotFoundError("요청한 장을 찾을 수 없습니다.") + total = len(chunks) + start_m, end_m = float(chunk["start_m"]), float(chunk["end_m"]) + route_xy = [ + (vertex.x, vertex.y) + for vertex in context.vertices + if total <= 1 or start_m <= float(getattr(vertex, "chainage_m", 0.0) or 0.0) <= end_m + ] + return route_xy, chunk, total + + +def lidar_source(context: Any, longitudinal: dict[str, Any], drawing_id: str) -> dict[str, Any]: + """라이다 계획평면도 한 장의 입력(노선 + 지표면 음영기복 그림)을 모은다. + + 지표면은 확정 DTM 격자(`dtm_{필터}[_smooth].npz`)를 도곽 범위로 잘라 쓴다 — + 점구름을 그대로 그리면 수천만 점이라 도면 만들기가 느려진다(2026-09-04 사용자 지시). + """ + match = LIDAR_ID.fullmatch(drawing_id) + if not match: + raise ValueError("올바르지 않은 라이다 계획평면도 ID입니다.") + number = int(match.group(1)) if match.group(1) else 1 + route_xy, chunk, total = _chunk_route(context, longitudinal, number) + box = _sheet_box(route_xy) + label = LIDAR_LABEL if total <= 1 else f"{LIDAR_LABEL} {chunk['number']}장" + + shade_image: str | None = None + shade_box: tuple[float, float, float, float] | None = None + try: + shade_image, shade_box = _hillshade_for_box(context, box) + except (FileNotFoundError, ValueError, OSError) as exc: + # 지표면이 없어도 노선·도각은 그린다 — 빈 화면보다 낫다. + logger.warning("B07 라이다 계획평면도: 음영기복을 만들지 못했습니다 — %s", exc) + + return { + "label": label, + "route_xy": route_xy, + "shade_image": shade_image, + "shade_box": shade_box, + } + + +def _hillshade_for_box( + context: Any, box: tuple[float, float, float, float] +) -> tuple[str, tuple[float, float, float, float]]: + """확정 DTM 격자를 도곽 범위로 잘라 음영기복 PNG(data URL)와 실제 덮은 범위를 낸다.""" + import numpy as np + + params = getattr(context, "surface_params", None) or {} + source_filter = str(params.get("source_filter") or "csf") + smooth = bool(params.get("smooth", True)) + models_dir = Path(context.project_root) / "B04_PreProcess" / "models" + candidates = [models_dir / f"dtm_{source_filter}_smooth.npz"] if smooth else [] + candidates.append(models_dir / f"dtm_{source_filter}.npz") + candidates.extend(sorted(models_dir.glob("dtm_*_smooth.npz"))) + candidates.extend(sorted(models_dir.glob("dtm_*.npz"))) + path = next((item for item in candidates if item.is_file()), None) + if path is None: + raise FileNotFoundError("확정 지표면 격자(DTM)가 없습니다.") + + with np.load(path, allow_pickle=False) as data: + grid_x = np.asarray(data["x"], dtype=np.float64) + grid_y = np.asarray(data["y"], dtype=np.float64) + grid_z = np.asarray(data["z"], dtype=np.float64) + valid = np.asarray(data["valid_mask"], dtype=bool) + resolution = float(np.asarray(data["resolution"]).reshape(-1)[0]) + + min_x, min_y, max_x, max_y = box + columns = np.where((grid_x >= min_x) & (grid_x <= max_x))[0] + rows = np.where((grid_y >= min_y) & (grid_y <= max_y))[0] + if columns.size < 2 or rows.size < 2: + raise ValueError("도곽 안에 지표면 격자가 없습니다.") + sliced_z = grid_z[rows[0] : rows[-1] + 1, columns[0] : columns[-1] + 1] + sliced_valid = valid[rows[0] : rows[-1] + 1, columns[0] : columns[-1] + 1] + + data_url, _width, _height = hillshade_png(sliced_z, sliced_valid, resolution) + return ( + data_url, + ( + float(grid_x[columns[0]]), + float(grid_y[rows[0]]), + float(grid_x[columns[-1]]), + float(grid_y[rows[-1]]), + ), + ) + + def watershed_source(context: Any) -> dict[str, Any]: """유역도 입력(노선·세부유역·등고선·세류선)을 사업지 CRS(m)로 모은다. diff --git a/B07_DesignDetail/B07_DesignDetail_Schema.py b/B07_DesignDetail/B07_DesignDetail_Schema.py index d4b7fddf..dbe92954 100644 --- a/B07_DesignDetail/B07_DesignDetail_Schema.py +++ b/B07_DesignDetail/B07_DesignDetail_Schema.py @@ -18,6 +18,7 @@ class DesignDrawingItem(BaseModel): "watershed", "plan", "landuse", + "plan_lidar", "blank", ] label: str @@ -50,6 +51,7 @@ class DesignDrawingResponse(BaseModel): "watershed", "plan", "landuse", + "plan_lidar", "blank", ] label: str diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Panels.ts b/B07_DesignDetail/B07_DesignDetail_UI_Panels.ts index 74d19953..2eab42c6 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Panels.ts +++ b/B07_DesignDetail/B07_DesignDetail_UI_Panels.ts @@ -34,7 +34,7 @@ export const DRAWING_GROUPS: readonly { { label: "계획평면도(지형)", idPrefix: "plan_terrain" }, { label: "계획평면도(노선배치도)", idPrefix: "plan_route" }, { label: "계획평면도(배치도)", idPrefix: "plan_layout" }, - { label: "계획평면도(라이다)", blankId: "blank_plan_lidar" }, + { label: "계획평면도(라이다)", idPrefix: "plan_lidar" }, { label: "종단면도", kind: "longitudinal" }, { label: "표준 횡단면도", blankId: "blank_cross_standard" }, { label: "횡단면도", kind: "cross" }, diff --git a/common_util/common_util_drainage_context.py b/common_util/common_util_drainage_context.py index 2d023038..a785695f 100644 --- a/common_util/common_util_drainage_context.py +++ b/common_util/common_util_drainage_context.py @@ -58,6 +58,9 @@ class DrainageContext: crs: str = "EPSG:5186" route_id: int | None = None to_lonlat: Callable[[float, float], tuple[float, float]] = lambda x, y: (x, y) + # 1단계에서 확정한 지표면 선택(source_filter·method·smooth). B07 라이다 계획평면도가 + # 어느 DTM 격자로 음영기복을 만들지 고르는 데 쓴다(2026-09-04). + surface_params: dict[str, Any] = field(default_factory=dict) async def load_drainage_context(project_id: UUID) -> tuple[DrainageContext | None, str]: @@ -112,6 +115,7 @@ async def load_drainage_context(project_id: UUID) -> tuple[DrainageContext | Non crs=crs, route_id=int(route["id"]) if route else None, to_lonlat=lambda x, y: transformer.transform(x, y), + surface_params=dict(surface_params), ), "", ) From 02bd45bf3c7c76ff00414e4bc0e2e8ebc644f2de Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 4 Sep 2026 19:09:06 +0900 Subject: [PATCH 10/11] =?UTF-8?q?feat(B07):=20=ED=91=9C=EC=A4=80=20?= =?UTF-8?q?=ED=9A=A1=EB=8B=A8=EB=A9=B4=EB=8F=84=20=E2=80=94=20=EC=B9=98?= =?UTF-8?q?=EC=88=98=C2=B7=EC=B8=A1=EA=B5=AC=20=ED=99=95=EB=8C=80=EB=8F=84?= =?UTF-8?q?=C2=B7=EC=95=94=EB=B0=98=EC=84=A0=202=EB=8B=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 빈 도각이던 표준 횡단면도를 실제 도면으로 만듦. - 본 그림: B06 좌측 패널 모식도와 같은 배치를 실치수로 그림(1/50). 노폭·노견· 측구 상단폭·노면 전폭에 치수선(눈금+치수값)을 붙이고, 절토·성토 경사비와 횡단경사를 표기. 값은 B06 표준 횡단면 설정을 그대로 읽고 없으면 config 기본값. - 측구 부분 확대도(1/10): 상단폭·저폭·깊이 치수 + 확대 축척 표기. - 암반 2단 절토: 아래는 암반 경사(1:0.4), 위는 토사 경사(1:1), 갈리는 높이에 암반선을 파선으로 긋고 각도를 함께 적음. - 암 L형 측구·포장 횡단경사는 주기(※)로 적음 — 본 그림은 토사 기준. 곁들여: Router_Support 가 700줄을 넘어 재수출 import 를 한 문장으로 합쳐 681줄로 줄임(기능 변화 없음). 검증: 치수값이 STANDARD_CROSS_SECTION 과 일치(500·3000·500·900·4000), 확대도 900·300·300 + S=1/10, 2단 절토선 꺾임점 1개, 콘텐츠 358.2x172.6 mm ≤ A1 작도영역. Co-Authored-By: Claude Opus 5 (1M context) --- .../B07_DesignDetail_Api_Fetch.ts | 2 + .../B07_DesignDetail_Engine_Cad_Standard.py | 581 ++++++++++++++++++ B07_DesignDetail/B07_DesignDetail_Router.py | 17 + .../B07_DesignDetail_Router_Support.py | 97 ++- B07_DesignDetail/B07_DesignDetail_Schema.py | 2 + .../B07_DesignDetail_UI_Panels.ts | 2 +- config/config_system.py | 4 + 7 files changed, 646 insertions(+), 59 deletions(-) create mode 100644 B07_DesignDetail/B07_DesignDetail_Engine_Cad_Standard.py diff --git a/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts b/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts index b51b8f63..e842eae7 100644 --- a/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts +++ b/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts @@ -14,6 +14,7 @@ export interface DesignDrawingItem { | "plan" | "landuse" | "plan_lidar" + | "cross_standard" | "blank"; label: string; chainage_m: number | null; @@ -101,6 +102,7 @@ export interface DesignDrawingResponse { | "plan" | "landuse" | "plan_lidar" + | "cross_standard" | "blank"; label: string; drawing: CadDrawing; diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Standard.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Standard.py new file mode 100644 index 00000000..ce03210c --- /dev/null +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Standard.py @@ -0,0 +1,581 @@ +"""B07 표준 횡단면도 CAD 조립 — 변수 모식도에 치수를 넣고, 측구 확대도·암반선 2단을 함께 낸다. + +사용자 지시(2026-09-04) — 「표준 횡단면도 좌상단에 기본값으로 B06 좌측 패널의 변수 위치 +안내와 비슷한 그림을 넣고, 변수 이름 자리에 도면처럼 치수를 적을 것. 측구는 작으니 부분 +확대도로. 발파·암반이면 암반선과 각도를 넣어 절토측이 2단(토사 각도 + 암반 각도)으로 +표현될 것」. + +배치는 B06 좌측 패널 모식도(`B06_Section_UI_Standard_Diagram.ts`)와 같다 — 좌가 절토·측구, +우가 성토, 가운데가 계획고. 다른 점은 **실치수**라는 것이다. 모식도는 위치 안내라 비율이 +없지만 도면은 축척(본 그림 1/50, 측구 확대도 1/10)대로 그리고 치수선을 붙인다. + +값은 B06 「표준 횡단면 설정」(`standard_cross_section`)을 그대로 읽는다 — 여기서 기하를 +다시 정하지 않는다. 저장값이 없으면 config 기본값을 쓴다. + +좌표 규약: 종이 mm = 실거리 m x MM. 원점은 계획고(노면 중심). +""" + +import math +from typing import Any + +from B07_DesignDetail.B07_DesignDetail_Engine_Cad import ( + DRAWING_FORMAT, + FRAME_LAYER_ID, + TABLE_LABEL_COLOR, + _layer, + _text_entity, + polyline_entity, +) +from B07_DesignDetail.B07_DesignDetail_Engine_Template import ( + entities_bbox, + frame_entities, + scale_fields, +) +from config.config_system import ( + DRAWING_SCALE_CROSS_STANDARD, + DRAWING_SCALE_DITCH_DETAIL, + STANDARD_CROSS_SECTION, +) + +STANDARD_KIND = "cross_standard" +STANDARD_LABEL = "표준 횡단면도" + +# 도면 좌표 = 종이 mm. 본 그림 1/50 -> 1 m = 20 mm, 측구 확대도 1/10 -> 1 m = 100 mm. +MM = 1000.0 / DRAWING_SCALE_CROSS_STANDARD +DETAIL_MM = 1000.0 / DRAWING_SCALE_DITCH_DETAIL + +SECTION_LAYER_ID = "b07-std-section" +ROCK_LAYER_ID = "b07-std-rock" +DIM_LAYER_ID = "b07-std-dim" +DETAIL_LAYER_ID = "b07-std-detail" +NOTE_LAYER_ID = "b07-std-note" +TITLE_LAYER_ID = "b07-std-title" + +SECTION_COLOR = "#111111" +ROCK_COLOR = "#a63d3d" +DIM_COLOR = "#2f6fb0" +DETAIL_COLOR = "#111111" +NOTE_COLOR = "#333333" + +_LINE_WIDTH = 2 +_TITLE_FONT_SIZE = 7.0 +_LABEL_FONT_SIZE = 3.0 +_DIM_FONT_SIZE = 2.6 +_NOTE_FONT_SIZE = 2.8 + +# 그림에 세울 절·성토 높이(m) — 표준도는 실제 지형이 없으므로 대표 높이로 그린다. +_CUT_HEIGHT_M = 3.0 +_FILL_HEIGHT_M = 3.0 +# 암반 구간: 절토 밑에서 이만큼이 암반이고 그 위가 토사다(2단 절토). +_ROCK_HEIGHT_M = 1.5 + +_DIM_TICK_MM = 1.6 # 치수선 끝 눈금 반길이 +_DIM_OFFSET_MM = 8.0 # 치수선을 그림에서 띄우는 거리 +_DIM_GAP_MM = 6.0 # 치수선 단 사이 + + +def _ground(kind: str, standard: dict[str, Any] | None) -> dict[str, Any]: + """표준 횡단면 설정에서 한 지반유형 값을 꺼낸다. 없으면 config 기본값.""" + stored = (standard or {}).get(kind) + if isinstance(stored, dict) and stored: + merged = dict(STANDARD_CROSS_SECTION.get(kind) or {}) + merged.update(stored) + return merged + return dict(STANDARD_CROSS_SECTION.get(kind) or {}) + + +def _number(value: Any, fallback: float) -> float: + return float(value) if isinstance(value, (int, float)) else fallback + + +def _dim_entities( + drawing_id: str, + tag: str, + start: tuple[float, float], + end: tuple[float, float], + label: str, + layer_id: str = DIM_LAYER_ID, +) -> list[dict[str, Any]]: + """치수선 한 벌(치수선 + 양끝 눈금 + 치수값). 좌표는 종이 mm.""" + entities: list[dict[str, Any]] = [] + line = polyline_entity(drawing_id, [start, end], layer_id, DIM_COLOR, suffix=f":dim:{tag}") + if line: + entities.append(line) + dx, dy = end[0] - start[0], end[1] - start[1] + length = math.hypot(dx, dy) or 1.0 + nx, ny = -dy / length, dx / length + for index, point in enumerate((start, end)): + tick = polyline_entity( + drawing_id, + [ + (point[0] - nx * _DIM_TICK_MM, point[1] - ny * _DIM_TICK_MM), + (point[0] + nx * _DIM_TICK_MM, point[1] + ny * _DIM_TICK_MM), + ], + layer_id, + DIM_COLOR, + suffix=f":dim:{tag}:tick:{index}", + ) + if tick: + entities.append(tick) + entities.append( + _text_entity( + f"{drawing_id}:dim:{tag}:text", + label, + (start[0] + end[0]) / 2.0 + nx * 2.2, + (start[1] + end[1]) / 2.0 + ny * 2.2, + layer_id, + _DIM_FONT_SIZE, + DIM_COLOR, + ) + ) + return entities + + +def _section_geometry(values: dict[str, Any]) -> dict[str, Any]: + """표준 단면의 실좌표(m) 꼭짓점. 좌가 절토·측구, 우가 성토(B06 모식도와 같은 배치).""" + road = _number(values.get("road_width_m"), 3.0) + shoulder_left = _number(values.get("shoulder_left_m"), 0.5) + shoulder_right = _number(values.get("shoulder_right_m"), 0.5) + ditch = values.get("ditch") if isinstance(values.get("ditch"), dict) else {} + top_width = _number(ditch.get("top_width_m"), 0.9) + bottom_width = _number(ditch.get("bottom_width_m"), 0.3) + depth = _number(ditch.get("depth_m"), 0.3) + slope = values.get("cross_slope_pct") if isinstance(values.get("cross_slope_pct"), dict) else {} + cross_pct = _number(slope.get("max"), _number(slope.get("min"), 3.0)) + cut_ratio = _number(values.get("cut_slope_ratio"), 1.0) + fill_ratio = _number(values.get("fill_slope_ratio"), 1.2) + + road_left = -(road / 2.0 + shoulder_left) + road_right = road / 2.0 + shoulder_right + # 횡단경사는 측구(좌) 쪽으로 내려간다 — 노면 좌끝이 계획고보다 낮다. + drop = abs(road_left) * cross_pct / 100.0 + surface = [(road_right, 0.0), (road_left, -drop)] + + ditch_top_left = road_left - top_width + ditch_bottom_y = -drop - depth + inset = (top_width - bottom_width) / 2.0 + ditch_line = [ + (road_left, -drop), + (road_left - inset, ditch_bottom_y), + (ditch_top_left + inset, ditch_bottom_y), + (ditch_top_left, -drop), + ] + cut_top = (ditch_top_left - _CUT_HEIGHT_M * cut_ratio, -drop + _CUT_HEIGHT_M) + fill_toe = (road_right + _FILL_HEIGHT_M * fill_ratio, -_FILL_HEIGHT_M) + return { + "road": road, + "shoulder_left": shoulder_left, + "shoulder_right": shoulder_right, + "top_width": top_width, + "bottom_width": bottom_width, + "depth": depth, + "cross_pct": cross_pct, + "cut_ratio": cut_ratio, + "fill_ratio": fill_ratio, + "road_left": road_left, + "road_right": road_right, + "drop": drop, + "surface": surface, + "ditch_line": ditch_line, + "ditch_top_left": ditch_top_left, + "cut_start": (ditch_top_left, -drop), + "cut_top": cut_top, + "fill_toe": fill_toe, + } + + +def _rock_entities( + drawing_id: str, geometry: dict[str, Any], rock_ratio: float, paper: Any +) -> list[dict[str, Any]]: + """암반선과 2단 절토(아래=암반각, 위=토사각)를 절토측에 덧그린다.""" + entities: list[dict[str, Any]] = [] + start_x, start_y = geometry["cut_start"] + soil_ratio = geometry["cut_ratio"] + # 아래 단: 암반각으로 _ROCK_HEIGHT_M 만큼 올라간다. + bench = (start_x - _ROCK_HEIGHT_M * rock_ratio, start_y + _ROCK_HEIGHT_M) + # 위 단: 그 위는 토사각. + upper = ( + bench[0] - (_CUT_HEIGHT_M - _ROCK_HEIGHT_M) * soil_ratio, + bench[1] + (_CUT_HEIGHT_M - _ROCK_HEIGHT_M), + ) + two_stage = polyline_entity( + drawing_id, + [paper((start_x, start_y)), paper(bench), paper(upper)], + ROCK_LAYER_ID, + ROCK_COLOR, + suffix=":rock:cut", + width=_LINE_WIDTH, + ) + if two_stage: + entities.append(two_stage) + # 암반선 — 2단이 갈리는 높이의 수평 파선. + boundary = polyline_entity( + drawing_id, + [paper((bench[0] - 1.5, bench[1])), paper((geometry["road_right"], bench[1]))], + ROCK_LAYER_ID, + ROCK_COLOR, + suffix=":rock:boundary", + dash=[6, 4], + ) + if boundary: + entities.append(boundary) + label_x, label_y = paper((bench[0] - 1.6, bench[1])) + entities.append( + _text_entity( + f"{drawing_id}:rock:boundary:text", + "암반선", + label_x, + label_y + 2.5, + ROCK_LAYER_ID, + _LABEL_FONT_SIZE, + ROCK_COLOR, + align="right", + ) + ) + mid_lower = paper(((start_x + bench[0]) / 2.0, (start_y + bench[1]) / 2.0)) + mid_upper = paper(((bench[0] + upper[0]) / 2.0, (bench[1] + upper[1]) / 2.0)) + entities.append( + _text_entity( + f"{drawing_id}:rock:lower", + f"암반 1:{rock_ratio:g}", + mid_lower[0] - 6.0, + mid_lower[1], + ROCK_LAYER_ID, + _DIM_FONT_SIZE, + ROCK_COLOR, + align="right", + ) + ) + entities.append( + _text_entity( + f"{drawing_id}:rock:upper", + f"토사 1:{soil_ratio:g}", + mid_upper[0] - 6.0, + mid_upper[1], + ROCK_LAYER_ID, + _DIM_FONT_SIZE, + ROCK_COLOR, + align="right", + ) + ) + return entities + + +def _ditch_detail_entities( + drawing_id: str, geometry: dict[str, Any], origin: tuple[float, float] +) -> list[dict[str, Any]]: + """측구 부분 확대도(1/10) — 작아서 본 그림에서는 치수를 읽을 수 없다.""" + entities: list[dict[str, Any]] = [] + top_width = geometry["top_width"] + bottom_width = geometry["bottom_width"] + depth = geometry["depth"] + inset = (top_width - bottom_width) / 2.0 + ox, oy = origin + + def paper(point: tuple[float, float]) -> tuple[float, float]: + return (ox + point[0] * DETAIL_MM, oy + point[1] * DETAIL_MM) + + shape = [ + (0.0, 0.0), + (inset, -depth), + (inset + bottom_width, -depth), + (top_width, 0.0), + ] + outline = polyline_entity( + drawing_id, + [paper(point) for point in shape], + DETAIL_LAYER_ID, + DETAIL_COLOR, + suffix=":detail:ditch", + width=_LINE_WIDTH, + ) + if outline: + entities.append(outline) + entities.extend( + _dim_entities( + drawing_id, + "detail-top", + paper((0.0, 0.0 + 0.06)), + paper((top_width, 0.0 + 0.06)), + f"{top_width * 1000:.0f}", + DETAIL_LAYER_ID, + ) + ) + entities.extend( + _dim_entities( + drawing_id, + "detail-bottom", + paper((inset, -depth - 0.06)), + paper((inset + bottom_width, -depth - 0.06)), + f"{bottom_width * 1000:.0f}", + DETAIL_LAYER_ID, + ) + ) + entities.extend( + _dim_entities( + drawing_id, + "detail-depth", + paper((top_width + 0.08, 0.0)), + paper((top_width + 0.08, -depth)), + f"{depth * 1000:.0f}", + DETAIL_LAYER_ID, + ) + ) + title = paper((top_width / 2.0, 0.3)) + entities.append( + _text_entity( + f"{drawing_id}:detail:title", + f"측구 상세도 (S = 1/{DRAWING_SCALE_DITCH_DETAIL})", + title[0], + title[1], + DETAIL_LAYER_ID, + _LABEL_FONT_SIZE, + TABLE_LABEL_COLOR, + ) + ) + return entities + + +def build_standard_cross_drawing( + drawing_id: str, label: str, standard: dict[str, Any] | None = None +) -> dict[str, Any]: + """표준 횡단면도 한 장을 만든다 — 본 그림 + 치수 + 측구 확대도 + 암반 2단 + 주기.""" + soil = _ground("soil", standard) + rock = _ground("rock", standard) + paved = _ground("paved", standard) + geometry = _section_geometry(soil) + + def paper(point: tuple[float, float]) -> tuple[float, float]: + return (point[0] * MM, point[1] * MM) + + entities: list[dict[str, Any]] = [] + # 본 그림: 절토면 - 측구 - 노면 - 성토면을 한 줄로 잇는다. + outline = [ + geometry["cut_top"], + *geometry["ditch_line"][::-1], + *geometry["surface"][::-1], + geometry["fill_toe"], + ] + body = polyline_entity( + drawing_id, + [paper(point) for point in outline], + SECTION_LAYER_ID, + SECTION_COLOR, + suffix=":section", + width=_LINE_WIDTH, + ) + if body: + entities.append(body) + # 중심선(계획고). + center = polyline_entity( + drawing_id, + [paper((0.0, 1.2)), paper((0.0, -1.2))], + SECTION_LAYER_ID, + SECTION_COLOR, + suffix=":center", + dash=[10, 3, 2, 3], + ) + if center: + entities.append(center) + entities.append( + _text_entity( + f"{drawing_id}:center:text", + "계획고", + *paper((0.0, 1.45)), + SECTION_LAYER_ID, + _LABEL_FONT_SIZE, + SECTION_COLOR, + ) + ) + + # 치수선 — 노면 아래 두 단(위: 노견·노폭·노견, 아래: 노면 전폭). + base_y = min(geometry["fill_toe"][1], -geometry["drop"] - geometry["depth"]) + dim_y = base_y * MM - _DIM_OFFSET_MM + half = geometry["road"] / 2.0 + entities.extend( + _dim_entities( + drawing_id, + "shoulder-left", + (geometry["road_left"] * MM, dim_y), + (-half * MM, dim_y), + f"{geometry['shoulder_left'] * 1000:.0f}", + ) + ) + entities.extend( + _dim_entities( + drawing_id, + "road", + (-half * MM, dim_y), + (half * MM, dim_y), + f"{geometry['road'] * 1000:.0f}", + ) + ) + entities.extend( + _dim_entities( + drawing_id, + "shoulder-right", + (half * MM, dim_y), + (geometry["road_right"] * MM, dim_y), + f"{geometry['shoulder_right'] * 1000:.0f}", + ) + ) + entities.extend( + _dim_entities( + drawing_id, + "ditch-top", + (geometry["ditch_top_left"] * MM, dim_y), + (geometry["road_left"] * MM, dim_y), + f"{geometry['top_width'] * 1000:.0f}", + ) + ) + roadbed_width_m = geometry["road"] + geometry["shoulder_left"] + geometry["shoulder_right"] + entities.extend( + _dim_entities( + drawing_id, + "roadbed", + (geometry["road_left"] * MM, dim_y - _DIM_GAP_MM), + (geometry["road_right"] * MM, dim_y - _DIM_GAP_MM), + f"{roadbed_width_m * 1000:.0f}", + ) + ) + + # 경사·횡단경사 표기. + cut_mid = paper( + ( + (geometry["cut_start"][0] + geometry["cut_top"][0]) / 2.0, + (geometry["cut_start"][1] + geometry["cut_top"][1]) / 2.0, + ) + ) + fill_mid = paper( + ( + (geometry["road_right"] + geometry["fill_toe"][0]) / 2.0, + (0.0 + geometry["fill_toe"][1]) / 2.0, + ) + ) + entities.append( + _text_entity( + f"{drawing_id}:cut:text", + f"절토 1:{geometry['cut_ratio']:g}", + cut_mid[0] - 4.0, + cut_mid[1] + 3.0, + SECTION_LAYER_ID, + _DIM_FONT_SIZE, + SECTION_COLOR, + align="right", + ) + ) + entities.append( + _text_entity( + f"{drawing_id}:fill:text", + f"성토 1:{geometry['fill_ratio']:g}", + fill_mid[0] + 4.0, + fill_mid[1] + 3.0, + SECTION_LAYER_ID, + _DIM_FONT_SIZE, + SECTION_COLOR, + align="left", + ) + ) + entities.append( + _text_entity( + f"{drawing_id}:cross-slope:text", + f"횡단경사 {geometry['cross_pct']:g}%", + *paper((geometry["road_left"] / 2.0, 0.6)), + SECTION_LAYER_ID, + _DIM_FONT_SIZE, + SECTION_COLOR, + ) + ) + + # 암반 구간 2단 절토. + entities.extend( + _rock_entities(drawing_id, geometry, _number(rock.get("cut_slope_ratio"), 0.4), paper) + ) + + body_bbox = entities_bbox(entities) + right = body_bbox[2] if body_bbox else 0.0 + top = body_bbox[3] if body_bbox else 0.0 + + # 측구 부분 확대도 — 본 그림 오른쪽 위. + entities.extend(_ditch_detail_entities(drawing_id, geometry, (right + 28.0, top - 30.0))) + + # 주기: 구간별로 달라지는 값만 적는다(기본은 토사). + notes = [ + "※ 본 그림은 토사 구간 기준임.", + f"※ 암 구간 — 절토 1:{_number(rock.get('cut_slope_ratio'), 0.4):g}, " + f"L형 측구 {_number((rock.get('ditch_l_type') or {}).get('width_m'), 0.5) * 1000:.0f}" + f"×{_number((rock.get('ditch_l_type') or {}).get('depth_m'), 0.1) * 1000:.0f}mm " + "(횡단면도에서 일반·L형 중 선택).", + f"※ 포장 구간 — 절·성토 경사는 토사와 같고 횡단경사만 " + f"{_number((paved.get('cross_slope_pct') or {}).get('min'), 1.5):g}~" + f"{_number((paved.get('cross_slope_pct') or {}).get('max'), 2.0):g}% 임.", + "※ 치수 단위 mm.", + ] + note_bbox = entities_bbox(entities) + note_x = note_bbox[0] if note_bbox else 0.0 + note_y = (note_bbox[1] if note_bbox else 0.0) - 12.0 + for index, note in enumerate(notes): + entities.append( + _text_entity( + f"{drawing_id}:note:{index}", + note, + note_x, + note_y - index * 5.0, + NOTE_LAYER_ID, + _NOTE_FONT_SIZE, + NOTE_COLOR, + align="left", + ) + ) + + bbox = entities_bbox(entities) + if bbox: + min_bx, _min_by, max_bx, max_by = bbox + entities.append( + _text_entity( + f"{drawing_id}:title", + label, + (min_bx + max_bx) / 2.0, + max_by + 12.0, + TITLE_LAYER_ID, + _TITLE_FONT_SIZE, + TABLE_LABEL_COLOR, + ) + ) + entities.append( + _text_entity( + f"{drawing_id}:scale", + f"S = 1/{DRAWING_SCALE_CROSS_STANDARD}", + max_bx, + max_by + 5.0, + TITLE_LAYER_ID, + _DIM_FONT_SIZE, + TABLE_LABEL_COLOR, + align="right", + ) + ) + entities.extend( + frame_entities( + drawing_id, + entities_bbox(entities) or bbox, + fit=False, + fields={ + "도면명": label, + **scale_fields(("", DRAWING_SCALE_CROSS_STANDARD)), + }, + ) + ) + + return { + "format": DRAWING_FORMAT, + "entities": entities, + "layers": [ + _layer(SECTION_LAYER_ID, "표준 단면"), + _layer(ROCK_LAYER_ID, "암반선·2단 절토"), + _layer(DIM_LAYER_ID, "치수"), + _layer(DETAIL_LAYER_ID, "측구 상세도"), + _layer(NOTE_LAYER_ID, "주기"), + _layer(TITLE_LAYER_ID, "표제"), + _layer(FRAME_LAYER_ID, "도각", locked=True), + ], + } diff --git a/B07_DesignDetail/B07_DesignDetail_Router.py b/B07_DesignDetail/B07_DesignDetail_Router.py index 5cd8a010..f4bb7d50 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router.py +++ b/B07_DesignDetail/B07_DesignDetail_Router.py @@ -17,6 +17,7 @@ from B06_Section.B06_Section_Repository import ( get_cross_section_design, get_cross_section_designs, get_longitudinal_section, + get_project_standard_cross_section, merge_cross_section_design_by_round, ) from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Sheet import ( @@ -34,6 +35,7 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Template import ( use_title_fields, ) from B07_DesignDetail.B07_DesignDetail_Router_Support import ( + CROSS_STANDARD_ID, LANDUSE_ID, LIDAR_ID, MASS_HAUL_ID, @@ -307,6 +309,21 @@ async def get_design_drawing( if context is None: return JSONResponse(status_code=404, content={"status": "error", "message": reason}) source_design = await asyncio.to_thread(watershed_source, context) + elif drawing_id == CROSS_STANDARD_ID: + # 표준 횡단면도는 B06 「표준 횡단면 설정」 저장값으로 그린다(없으면 config 기본값). + pool = get_db_pool() + async with pool.acquire() as connection: + async with connection.cursor() as cursor: + await cursor.execute( + "SELECT company_id FROM projects WHERE id = %s AND deleted_at IS NULL", + (str(project_id),), + ) + row = await cursor.fetchone() + source_design = ( + await get_project_standard_cross_section(connection, int(row[0]), project_id) + if row + else None + ) elif LIDAR_ID.fullmatch(drawing_id): # 라이다 계획평면도는 확정 DTM 격자로 음영기복 그림을 만들어 넘긴다. context, reason = await load_drainage_context(project_id) diff --git a/B07_DesignDetail/B07_DesignDetail_Router_Support.py b/B07_DesignDetail/B07_DesignDetail_Router_Support.py index 93936c4f..76e1627c 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router_Support.py +++ b/B07_DesignDetail/B07_DesignDetail_Router_Support.py @@ -47,66 +47,36 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Sheet import ( plan_cross_sheets, section_block_size, ) +from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Standard import ( + STANDARD_LABEL, + build_standard_cross_drawing, +) from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Table import ( QUANTITY_VALUE_KEYS, ) from B07_DesignDetail.B07_DesignDetail_Engine_Template import add_title_fields -from B07_DesignDetail.B07_DesignDetail_Router_Support_Basin import ( - _BASIN_MAX_DISTANCE_M as _BASIN_MAX_DISTANCE_M, -) # 유역도 배경·파일 입출력 조각은 700줄 제한으로 떼어냈다(2026-09-04). # 여기서 그대로 다시 내보내 호출부(`B07_DesignDetail_Router.py`)의 import 경로는 불변이다. -from B07_DesignDetail.B07_DesignDetail_Router_Support_Basin import ( - CONTOUR_FILE as CONTOUR_FILE, -) -from B07_DesignDetail.B07_DesignDetail_Router_Support_Basin import ( - LANDUSE_ID as LANDUSE_ID, -) -from B07_DesignDetail.B07_DesignDetail_Router_Support_Basin import ( - LIDAR_ID as LIDAR_ID, -) -from B07_DesignDetail.B07_DesignDetail_Router_Support_Basin import ( - PLAN_ID as PLAN_ID, -) -from B07_DesignDetail.B07_DesignDetail_Router_Support_Basin import ( - STREAM_FILE as STREAM_FILE, -) -from B07_DesignDetail.B07_DesignDetail_Router_Support_Basin import ( - _basins_crs as _basins_crs, -) -from B07_DesignDetail.B07_DesignDetail_Router_Support_Basin import ( - _clip_segment as _clip_segment, -) -from B07_DesignDetail.B07_DesignDetail_Router_Support_Basin import ( - _geojson_features as _geojson_features, -) -from B07_DesignDetail.B07_DesignDetail_Router_Support_Basin import ( - _geojson_payload as _geojson_payload, -) -from B07_DesignDetail.B07_DesignDetail_Router_Support_Basin import ( - _geometry_lines as _geometry_lines, -) -from B07_DesignDetail.B07_DesignDetail_Router_Support_Basin import ( - _too_far_from_route as _too_far_from_route, -) -from B07_DesignDetail.B07_DesignDetail_Router_Support_Basin import ( - clip_line_to_box as clip_line_to_box, -) -from B07_DesignDetail.B07_DesignDetail_Router_Support_Basin import ( - landuse_source as landuse_source, -) -from B07_DesignDetail.B07_DesignDetail_Router_Support_Basin import ( - lidar_source as lidar_source, -) -from B07_DesignDetail.B07_DesignDetail_Router_Support_Basin import ( - plan_source as plan_source, -) -from B07_DesignDetail.B07_DesignDetail_Router_Support_Basin import ( - plan_stations as plan_stations, -) -from B07_DesignDetail.B07_DesignDetail_Router_Support_Basin import ( - watershed_source as watershed_source, +from B07_DesignDetail.B07_DesignDetail_Router_Support_Basin import ( # noqa: F401 + _BASIN_MAX_DISTANCE_M, + CONTOUR_FILE, + LANDUSE_ID, + LIDAR_ID, + PLAN_ID, + STREAM_FILE, + _basins_crs, + _clip_segment, + _geojson_features, + _geojson_payload, + _geometry_lines, + _too_far_from_route, + clip_line_to_box, + landuse_source, + lidar_source, + plan_source, + plan_stations, + watershed_source, ) from B07_DesignDetail.B07_DesignDetail_Router_Support_Io import ( _cross_files, @@ -132,13 +102,12 @@ _LONG_ID = re.compile(r"^longitudinal(?:_(\d+))?$") MASS_HAUL_ID = "mass_haul" WATERSHED_ID = "watershed" COVER_ID = "cover" +# 표준 횡단면도 — 노선 자료가 아니라 B06 표준 횡단면 설정값으로 그리는 한 장. +CROSS_STANDARD_ID = "cross_standard" # 아직 내용을 만들지 않은 도면 — 도각만 실어 연다(2026-09-01 사용자 지시). # 화면 순서(DRAWING_GROUPS)와 같은 이름을 쓴다. -BLANK_DRAWINGS: tuple[tuple[str, str], ...] = ( - ("blank_cross_standard", "표준 횡단면도"), - ("blank_standard", "표준도"), -) +BLANK_DRAWINGS: tuple[tuple[str, str], ...] = (("blank_standard", "표준도"),) BLANK_LABELS: dict[str, str] = dict(BLANK_DRAWINGS) @@ -224,6 +193,7 @@ def _drawing_list( # 노선 전장 1장짜리 도면 — 자료가 없으면 여는 시점에 404로 알린다(목록에는 항상 둔다). for drawing_id, kind, label in ( (COVER_ID, "cover", "표지"), + (CROSS_STANDARD_ID, "cross_standard", STANDARD_LABEL), (MASS_HAUL_ID, "mass_haul", "토적도(유토곡선)"), (WATERSHED_ID, "watershed", "유역도(배수 유역도)"), ): @@ -449,7 +419,7 @@ def _read_drawing( # 포맷 버전이 다르면(테이블·레이어 구성 변경 전 저장본) 캐시를 버리고 # 아래에서 원본 기준으로 재생성한다. 확정 상태도 무효로 응답해 재확정 유도. if saved.get("format") == DRAWING_FORMAT: - if drawing_id in (COVER_ID, MASS_HAUL_ID, WATERSHED_ID): + if drawing_id in (COVER_ID, MASS_HAUL_ID, WATERSHED_ID, CROSS_STANDARD_ID): kind = drawing_id # id와 kind가 같은 단장 도면 elif PLAN_ID.fullmatch(drawing_id): kind = "plan" @@ -485,6 +455,17 @@ def _read_drawing( None, ) + if drawing_id == CROSS_STANDARD_ID: + # stored_design = B06 표준 횡단면 설정값(라우터가 실어 준다). 없으면 config 기본값. + standard = stored_design if isinstance(stored_design, dict) else None + return ( + "cross_standard", + STANDARD_LABEL, + build_standard_cross_drawing(drawing_id, STANDARD_LABEL, standard), + False, + None, + ) + if LIDAR_ID.fullmatch(drawing_id): # stored_design = lidar_source()가 만든 노선 + 지표면 음영기복 그림. if not isinstance(stored_design, dict): diff --git a/B07_DesignDetail/B07_DesignDetail_Schema.py b/B07_DesignDetail/B07_DesignDetail_Schema.py index dbe92954..53abb805 100644 --- a/B07_DesignDetail/B07_DesignDetail_Schema.py +++ b/B07_DesignDetail/B07_DesignDetail_Schema.py @@ -19,6 +19,7 @@ class DesignDrawingItem(BaseModel): "plan", "landuse", "plan_lidar", + "cross_standard", "blank", ] label: str @@ -52,6 +53,7 @@ class DesignDrawingResponse(BaseModel): "plan", "landuse", "plan_lidar", + "cross_standard", "blank", ] label: str diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Panels.ts b/B07_DesignDetail/B07_DesignDetail_UI_Panels.ts index 2eab42c6..8de0f675 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Panels.ts +++ b/B07_DesignDetail/B07_DesignDetail_UI_Panels.ts @@ -36,7 +36,7 @@ export const DRAWING_GROUPS: readonly { { label: "계획평면도(배치도)", idPrefix: "plan_layout" }, { label: "계획평면도(라이다)", idPrefix: "plan_lidar" }, { label: "종단면도", kind: "longitudinal" }, - { label: "표준 횡단면도", blankId: "blank_cross_standard" }, + { label: "표준 횡단면도", kind: "cross_standard" }, { label: "횡단면도", kind: "cross" }, { label: "토적도(유토곡선)", kind: "mass_haul" }, { label: "유역도(배수 유역도)", kind: "watershed" }, diff --git a/config/config_system.py b/config/config_system.py index de4780e4..701e782e 100644 --- a/config/config_system.py +++ b/config/config_system.py @@ -178,6 +178,10 @@ DRAWING_SCALE_BASIN = 6000 # 유역도 평면 축척 분모 (실거리 1 m = 1/ # 계획평면도·용지도 평면 축척 분모 — 지식DB 「설계제원_총괄」 측량·도면 기준 1/1,200. # 횡단면도와 같은 원칙: 축척은 줄이지 않고, 한 장에 안 들어가면 **장을 나눈다**. DRAWING_SCALE_PLAN = 1200 +# 표준 횡단면도 — 상세도라 지식DB에 지정 축척이 없다. 본 그림 1/50, 측구 부분확대도 +# 1/10 (2026-09-04). 노폭 4 m 기준 본 그림이 A1 작도영역에 여유 있게 든다. +DRAWING_SCALE_CROSS_STANDARD = 50 +DRAWING_SCALE_DITCH_DETAIL = 10 # 시스템 리소스 로그 (루트 log 폴더에 단일 파일, 1개월 보관) LOG_BASE_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "log") From 52031917519d76f43bf232d44b76283da4331c18 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 4 Sep 2026 19:13:46 +0900 Subject: [PATCH 11/11] =?UTF-8?q?feat(B05):=20=EC=A7=84=EC=9E=85=20?= =?UTF-8?q?=EB=A1=9C=EB=94=A9=203D=20=ED=9B=84=EC=88=9C=EC=9C=84=20=C2=B7?= =?UTF-8?q?=20=EB=8F=84=EB=84=9B=20=ED=9A=8C=EC=A0=84=20=EB=B6=84=EB=A6=AC?= =?UTF-8?q?=20=C2=B7=20=EC=A7=81=EA=B5=90/=EC=9B=90=EA=B7=BC=20=EC=A0=84?= =?UTF-8?q?=ED=99=98=20=EB=8B=A8=EC=B6=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 사용자 지시(2026-09-04) — 「3D 는 보조라 후순위. 조용히 뒤에서 뜨고 다 되면 팝업으로 알릴 것. 로딩 도넛은 진행률과 무관하게 계속 돌고 숫자만 갱신」, 그리고 「직교/원근 전환 단추는 넣음」. 진입 로딩 - 단계 5 → 4. ④ 종·횡단 자료까지 끝나면 로딩 표시를 걷어 화면을 바로 쓰게 함. - 3D 지형은 finally 뒤 배경 작업으로 돌리고 끝나면 토스트 「3D 지형 준비 완료」. 실패해도 나머지 화면은 그대로 씀. [초기화]는 재진입이라 같은 규칙이 걸림. - 「확정 지표면 없음」 판정을 ③ 뒤로 앞당김. 진행 서클(공용) - 회전 껍데기(`__spin`)를 한 겹 두고 CSS 회전을 거기에만 검. 껍데기는 늘 돌고 안쪽 svg 는 12시 고정이라 호·숫자가 제자리에서 갱신됨. `is-indeterminate` 제거. 직교/원근 전환 - 카메라 두 벌을 `B05_Profile_UI_Viewer_Camera.ts` 가 쥐고 갈아 끼움. 위치·시선· 근평면·먼평면과 보이는 크기를 옮겨 화면이 튀지 않음. - 마커 입력·구조물 클릭·커서 피벗 유틸이 카메라를 함수로 받게 바꿈(공용 유틸은 값도 그대로 받아 B04 호출부는 무변경). 자체검증(공용 브라우저 5173, 용화_LAS) — 진행률 25→50→75%, 회전 애니메이션 running 유지(변환행렬 표본 전부 다름), 로딩 걷힌 순간 그래프선 2·좌측단추 61· 3D 메쉬 2, 1.8초 뒤 메쉬 489 + 토스트. 초기화 재진입도 같음. 전환 단추는 Ortho ↔ Perspective 왕복 후 값 완전 일치, B04 페이지 오류 0건. Co-Authored-By: Claude Opus 5 --- B04_PreProcess/B04_PreProcess_UI_Camera.ts | 16 +++- B05_Profile/B05_Profile_UI_Page.ts | 62 +++++++------- B05_Profile/B05_Profile_UI_Panel.ts | 15 ++++ B05_Profile/B05_Profile_UI_Viewer.ts | 40 +++++---- B05_Profile/B05_Profile_UI_Viewer_Camera.ts | 81 +++++++++++++++++++ .../B05_Profile_UI_Viewer_Marker_Input.ts | 7 +- .../B05_Profile_UI_Viewer_Structure_Pick.ts | 5 +- ui_template/ui_template_progress.css | 18 +++-- ui_template/ui_template_progress.ts | 13 ++- 9 files changed, 193 insertions(+), 64 deletions(-) create mode 100644 B05_Profile/B05_Profile_UI_Viewer_Camera.ts diff --git a/B04_PreProcess/B04_PreProcess_UI_Camera.ts b/B04_PreProcess/B04_PreProcess_UI_Camera.ts index 7a40a3a5..b6c20751 100644 --- a/B04_PreProcess/B04_PreProcess_UI_Camera.ts +++ b/B04_PreProcess/B04_PreProcess_UI_Camera.ts @@ -120,7 +120,11 @@ function pivotReticleTexture(): THREE.CanvasTexture { } export interface CursorPivotOptions { - camera: THREE.PerspectiveCamera | THREE.OrthographicCamera; + /** 카메라. **바뀔 수 있으면 함수로** 준다 — B05는 직교/원근을 갈아 끼운다(2026-09-04). */ + camera: + | THREE.PerspectiveCamera + | THREE.OrthographicCamera + | (() => THREE.PerspectiveCamera | THREE.OrthographicCamera); controls: OrbitControls; /** 포인터 이벤트를 받는 캔버스. */ element: HTMLElement; @@ -134,7 +138,9 @@ export interface CursorPivotOptions { /** 커서 기준 회전·줌을 붙이고, 해제 함수를 돌려준다. */ export function bindCursorPivotControls(options: CursorPivotOptions): () => void { - const { camera, controls, element } = options; + const { controls, element } = options; + const getCamera = (): THREE.PerspectiveCamera | THREE.OrthographicCamera => + typeof options.camera === "function" ? options.camera() : options.camera; // 회전·줌 모두 여기서 직접 처리한다(OrbitControls에는 휠 방향을 뒤집는 설정이 없다). controls.enableRotate = false; controls.enableZoom = false; @@ -165,6 +171,7 @@ export function bindCursorPivotControls(options: CursorPivotOptions): () => void /** 조준점을 현재 축 위치·크기로 맞춘다. 스프라이트 scale = 쿼드의 월드 폭. */ function syncPivotMarker(): void { + const camera = getCamera(); if (!pivotMarker || !pivotMarker.visible) return; pivotMarker.position.copy(pivot); const distance = camera.position.distanceTo(pivot); @@ -190,6 +197,7 @@ export function bindCursorPivotControls(options: CursorPivotOptions): () => void * 지형을 맞히면 그 점을 쓰고, 하늘·구멍이라 못 맞히면 시선에 수직이고 현재 target을 * 지나는 평면과 광선을 만나게 해 **커서 방향**의 점을 쓴다(화면 중앙으로 돌아가지 않는다). */ function pickPivot(event: { clientX: number; clientY: number }): void { + const camera = getCamera(); pivot.copy(controls.target); const rect = element.getBoundingClientRect(); if (rect.width <= 0 || rect.height <= 0) return; @@ -219,7 +227,7 @@ export function bindCursorPivotControls(options: CursorPivotOptions): () => void // 그랩 팬 시작 — 커서 아래 지형점을 잡고, 시선 수직 평면 위에서 따라오게 한다. pickPivot(event); panAnchor.copy(pivot); - camera.getWorldDirection(viewDirection); + getCamera().getWorldDirection(viewDirection); panPlane.setFromNormalAndCoplanarPoint(viewDirection, panAnchor); panPointerId = event.pointerId; element.setPointerCapture?.(event.pointerId); @@ -243,6 +251,7 @@ export function bindCursorPivotControls(options: CursorPivotOptions): () => void /** 휠 줌 — 커서가 가리키는 지점을 축으로 삼아 그 점이 화면에 고정된 채 멀어지고 가까워진다. * 휠을 위로 올리면 멀어진다(사용자 지시). */ function onWheel(event: WheelEvent): void { + const camera = getCamera(); if (!controls.enabled || options.blocked?.()) return; event.preventDefault(); pickPivot(event); @@ -265,6 +274,7 @@ export function bindCursorPivotControls(options: CursorPivotOptions): () => void } function onPointerMove(event: PointerEvent): void { + const camera = getCamera(); if (panPointerId === event.pointerId) { // 그랩 팬 — 잡은 점이 커서 아래에 계속 오도록 카메라·target을 평행 이동한다. const rect = element.getBoundingClientRect(); diff --git a/B05_Profile/B05_Profile_UI_Page.ts b/B05_Profile/B05_Profile_UI_Page.ts index 10bee531..e35323b4 100644 --- a/B05_Profile/B05_Profile_UI_Page.ts +++ b/B05_Profile/B05_Profile_UI_Page.ts @@ -257,6 +257,7 @@ export async function renderB05Route(root: HTMLElement): Promise { onSurfaceGrayscale: viewer.setSurfaceGrayscale, onView: viewer.setView, onResetView: () => viewer.setView("top"), + onProjection: viewer.setProjection, // [3D 업데이트](2026-09-01) — 밀린 계획선 편집을 예상형상·측점선에 한 번에 반영한다. onCorridorRefresh: async () => { if (!currentSectionDetail || !latest?.route?.id) return; @@ -581,12 +582,11 @@ export async function renderB05Route(root: HTMLElement): Promise { }; /* ── 진입 로딩 ───────────────────────────────────────────────────────── - * 전부 받아 놓고 한 번에 그리면 몇 초 동안 빈 화면만 보인다. 화면 틀을 먼저 띄우고 - * 자료가 끝나는 순서대로 채운다. 3D 지형이 가장 느리므로 맨 마지막에 올리고, 그동안 - * 3D 뷰포트에 공통 프로그레스 서클을 띄운다(2026-08-01 사용자 지시). */ - const LOAD_STEP_COUNT = 5; - // 3D 뷰포트 정중앙. 하단 종단 패널(z-index 3)보다 아래라 패널에 가려지는 것은 무방하다 - // (2026-08-01 사용자 지시). + * 화면 틀을 먼저 띄우고 자료가 끝나는 순서대로 채운다(2026-08-01 사용자 지시). + * 3D 지형은 **보조 자료라 로딩에 넣지 않는다**(2026-09-04 사용자 지시) — 네 단계가 + * 끝나면 로딩 표시를 걷어 화면을 바로 쓰게 하고, 3D는 뒤에서 올린 뒤 알린다. + * 서클은 3D 뷰포트 정중앙에 둔다(하단 종단 패널에 가려지는 것은 무방). */ + const LOAD_STEP_COUNT = 4; const progress = createProgressCircle({ label: "화면 틀을 준비하는 중…", overlay: true }); viewer.root.append(progress.root); let loadedSteps = 0; @@ -654,33 +654,15 @@ export async function renderB05Route(root: HTMLElement): Promise { // ③ 확정 지표면 모델 목록. const models = await listSurfaceModels(activeProjectId); confirmedSurface = models.models.find((model) => model.status === "CONFIRMED") ?? null; - advanceLoading("종단면 자료를 불러오는 중…"); - - // ④ 종단면·횡단 자료 — 하단 패널을 3D보다 먼저 채운다. - if (latestResponse.route) await restoreSections(latestResponse.route.id); - advanceLoading("3D 지형을 불러오는 중…"); - - // ⑤ 3D 지형 — 가장 무거우므로 맨 마지막. if (!confirmedSurface) { // 새 자료가 올라와 옛 결과가 지워진 상태 — 여기서 보여 줄 게 없다. leaveForDashboard(); return; - } else { - // 지형 가장자리만 필요하다 — 포인트클라우드 전체(수십 MB)는 받지 않는다. - const confirmed = await fetchConfirmedSurface(activeProjectId); - if (!confirmed.bounds) throw new Error("지표면 범위 정보를 찾을 수 없습니다."); - await viewer.loadSurface( - activeProjectId, - confirmedSurface.id, - latestResponse.surface_params.method, - latestResponse.surface_params.smooth, - latestResponse.surface_params.contour_interval_m, - toBounds(confirmed.bounds), - ); - // 지형이 올라온 뒤에야 마커·측점선이 지형 위에 놓인다. - renderLatest(latestResponse); - if (currentSectionDetail) renderStationLines(currentSectionDetail); } + advanceLoading("종단면 자료를 불러오는 중…"); + + // ④ 종단면·횡단 자료 — 하단 패널을 3D보다 먼저 채운다. + if (latestResponse.route) await restoreSections(latestResponse.route.id); // 구조물 타입 레지스트리·정본 — 노선이 없어도 목록은 보여 준다(추가는 노선 이후). await bridge.load(); advanceLoading(""); @@ -691,4 +673,28 @@ export async function renderB05Route(root: HTMLElement): Promise { restoring = false; restorePick(); // 관·구조물 목록 중 늦게 오는 쪽이 있어 여기서 한 번 더. } + + // ⑤ 3D 지형 — 화면을 잡지 않고 뒤에서 올린다. 실패해도 나머지는 그대로 쓴다. + void (async () => { + const [surface, current] = [confirmedSurface, latest]; + if (!surface || !current) return; + try { + // 가장자리만 받는다 — 포인트클라우드 전체(수십 MB)는 안 받는다. + const confirmed = await fetchConfirmedSurface(activeProjectId); + if (!confirmed.bounds) throw new Error("지표면 범위 정보를 찾을 수 없습니다."); + await viewer.loadSurface( + activeProjectId, + surface.id, + current.surface_params.method, + current.surface_params.smooth, + current.surface_params.contour_interval_m, + toBounds(confirmed.bounds), + ); + renderLatest(current); // 지형이 올라온 뒤에야 마커·측점선이 지형 위에 놓인다. + if (currentSectionDetail) renderStationLines(currentSectionDetail); + showToast("3D 지형 준비 완료", "success"); + } catch (error) { + showToast(error instanceof Error ? error.message : "3D 지형을 불러오지 못했습니다.", "error"); + } + })(); } diff --git a/B05_Profile/B05_Profile_UI_Panel.ts b/B05_Profile/B05_Profile_UI_Panel.ts index a9450ea7..905b0ca2 100644 --- a/B05_Profile/B05_Profile_UI_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Panel.ts @@ -104,6 +104,8 @@ interface PanelCallbacks { /** 지표면 흑백 표시 토글(기본 꺼짐 — 무지개 고도색). */ onSurfaceGrayscale: (grayscale: boolean) => void; onView: (view: "iso" | "top" | "front" | "side") => void; + /** 직교/원근 전환 — 탑뷰에서 크기를 정밀 대조할 때만 직교로 본다(2026-09-04 사용자 지시). */ + onProjection: (kind: "perspective" | "ortho") => void; onResetView: () => void; /** [3D 업데이트] — 계획선 편집을 3D 예상형상·측점선에 한 번에 반영(2026-09-01 사용자 * 지시). 편집마다 따라오던 자동 갱신을 없애고 이 버튼으로만 돌린다. */ @@ -220,6 +222,19 @@ export function createRoutePanel(callbacks: PanelCallbacks) { (["iso", "top", "front", "side"] as const).forEach((preset) => viewButtons.append(button(preset.toUpperCase(), () => callbacks.onView(preset), "glass")), ); + // 직교/원근 전환(2026-09-04 사용자 지시) — 기본은 원근이고, 단추 글자는 **바뀔 쪽**을 + // 가리킨다(누르면 그쪽으로 간다). + let projection: "perspective" | "ortho" = "perspective"; + const projectionButton = button( + "직교로", + () => { + projection = projection === "perspective" ? "ortho" : "perspective"; + projectionButton.textContent = projection === "perspective" ? "직교로" : "원근으로"; + callbacks.onProjection(projection); + }, + "glass", + ); + viewButtons.append(projectionButton); const visibilityButtons = document.createElement("div"); visibilityButtons.className = "b05-route__view-group"; visibilityButtons.append( diff --git a/B05_Profile/B05_Profile_UI_Viewer.ts b/B05_Profile/B05_Profile_UI_Viewer.ts index c09b1096..09b5674e 100644 --- a/B05_Profile/B05_Profile_UI_Viewer.ts +++ b/B05_Profile/B05_Profile_UI_Viewer.ts @@ -14,6 +14,7 @@ import { type RouteMarkers, type SectionStationMarker, } from "./B05_Profile_UI_Markers"; +import { createCameraRig, type ProjectionKind } from "./B05_Profile_UI_Viewer_Camera"; import { bindMarkerPointerControls } from "./B05_Profile_UI_Viewer_Marker_Input"; import type { CorridorBuildResult } from "./B05_Profile_UI_Corridor_Build"; import { @@ -117,6 +118,8 @@ export interface RouteViewer { setCorridorVisible: (visible: boolean) => void; renderStationLines: (stations: SectionStationMarker[], halfWidth: number) => void; setView: (view: "iso" | "top" | "front" | "side") => void; + /** 직교/원근 전환(2026-09-04) — 보이는 크기를 유지한 채 카메라만 갈아 끼운다. */ + setProjection: (kind: ProjectionKind) => void; beginMoveSelected: () => void; /** 화면(client) 좌표 아래 지형의 모델 좌표 — 3D 우클릭 구조물 배치용(2026-08-19). */ modelPointAt: (clientX: number, clientY: number) => { x: number; y: number; z: number } | null; @@ -164,13 +167,12 @@ export function createRouteViewer(): RouteViewer { }); systemDarkTheme.addEventListener("change", updateSceneBackground); updateSceneBackground(); - // 원근 카메라(시야각 45°) — 2026-09-04 사용자 지시로 직교에서 되돌렸다. 직교가 - // 필요했던 탑뷰 구조물 투영 윤곽선은 2026-09-02에 숨김 처리되어 화면에 없다. - const camera = new THREE.PerspectiveCamera(45, 1, 0.1, 100000); - camera.position.set(100, 120, 100); + // 카메라는 원근(기본)·직교 두 벌을 두고 갈아 끼운다 — 갈아 끼우면 **객체가 바뀌므로** + // 붙잡아 두지 말고 `cameraRig.camera()`로 그때그때 읽는다(2026-09-04 사용자 지시). + const cameraRig = createCameraRig(); const renderer = new THREE.WebGLRenderer({ canvas, antialias: true }); renderer.setPixelRatio(Math.min(devicePixelRatio, 2)); - const controls = new OrbitControls(camera, canvas); + const controls = new OrbitControls(cameraRig.camera(), canvas); controls.enableDamping = true; scene.add(new THREE.HemisphereLight(0xffffff, 0x64748b, 2.2)); const directional = new THREE.DirectionalLight(0xffffff, 2.2); @@ -216,7 +218,9 @@ export function createRouteViewer(): RouteViewer { THREE, // 카메라도 함께 낸다(2026-09-04) — 화면 좌표에서 레이캐스트로 무엇이 앞에 있는지 // 확인해야 3D 클릭 검증을 수치로 할 수 있다. - camera, + get camera() { + return cameraRig.camera(); + }, toScene: (x: number, y: number, z: number) => bounds ? modelToScene({ x, y, z }, bounds) : null, topZ: () => (bounds ? bounds.z[1] + 100 : null), @@ -231,7 +235,7 @@ export function createRouteViewer(): RouteViewer { // 눌러 검증할 때 쓴다. 캔버스가 아래 패널에 가려 중앙이 안 보이므로 자리를 직접 잰다. project: (x: number, y: number, z: number) => { if (!bounds) return null; - const point = modelToScene({ x, y, z }, bounds).project(camera); + const point = modelToScene({ x, y, z }, bounds).project(cameraRig.camera()); const rect = canvas.getBoundingClientRect(); return { x: rect.left + ((point.x + 1) / 2) * rect.width, @@ -243,7 +247,7 @@ export function createRouteViewer(): RouteViewer { // 캔버스 포인터 입력(마커 끌기·고르기·끌어놓기)은 따로 뗐다(2026-09-02, 700줄 제한). const markerInput = bindMarkerPointerControls({ canvas, - camera, + camera: cameraRig.camera, controls, markers, getTerrain: () => terrain, @@ -252,14 +256,14 @@ export function createRouteViewer(): RouteViewer { // 코리도 구조물 클릭 선택(2026-09-04) — 마커보다 뒤 순위다. const structurePick = bindStructurePick({ canvas, - camera, + camera: cameraRig.camera, group: () => corridorGroup, blocked: () => markerInput.blocked(), }); // 회전·줌 중심을 커서 아래 지형 지점으로 (B04 뷰어들과 공용 유틸). // 마커를 잡고 있는 동안에는 회전을 넘겨 드래그 이동이 우선하게 한다. const releaseCursorPivot = bindCursorPivotControls({ - camera, + camera: cameraRig.camera, controls, element: canvas, pickables: () => (terrain ? [terrain] : []), @@ -276,8 +280,7 @@ export function createRouteViewer(): RouteViewer { const width = Math.max(1, root.clientWidth); const height = Math.max(1, root.clientHeight); renderer.setSize(width, height, false); - camera.aspect = width / height; - camera.updateProjectionMatrix(); + cameraRig.setAspect(width / height); } const resizeObserver = new ResizeObserver(resize); resizeObserver.observe(root); @@ -292,6 +295,7 @@ export function createRouteViewer(): RouteViewer { side: [distance, distance * 0.25, 0], } as const; const [x, y, z] = positions[view]; + const camera = cameraRig.camera(); camera.position.set(target.x + x, target.y + y, target.z + z); // 근평면 상한 0.4m — 휠 확대는 커서 아래 지점 0.5m 앞에서 멈춘다(커서 피벗 유틸). // 거리에만 비례시키면 긴 노선(맞춤 거리 1km 이상)에서 근평면이 그 0.5m를 넘어 @@ -299,6 +303,7 @@ export function createRouteViewer(): RouteViewer { camera.near = Math.max(0.1, Math.min(0.4, distance / 1000)); camera.far = distance * 10; camera.updateProjectionMatrix(); + cameraRig.setFit(distance); controls.update(); } @@ -360,16 +365,16 @@ export function createRouteViewer(): RouteViewer { if (terrain) { compass.setVisible(true); compass.update( - camera.position.x - controls.target.x, - camera.position.y - controls.target.y, - camera.position.z - controls.target.z, + cameraRig.camera().position.x - controls.target.x, + cameraRig.camera().position.y - controls.target.y, + cameraRig.camera().position.z - controls.target.z, ); } else { compass.setVisible(false); } // 측점 라벨 솎기 — 가까울수록 촘촘히 보인다(단계가 안 바뀌면 모듈 안에서 걸러낸다). - markers.updateLabelDetail(camera.position.distanceTo(controls.target)); - renderer.render(scene, camera); + markers.updateLabelDetail(cameraRig.camera().position.distanceTo(controls.target)); + renderer.render(scene, cameraRig.camera()); } animate(); @@ -665,6 +670,7 @@ export function createRouteViewer(): RouteViewer { setStationLabelsVisible: markers.setStationLabelsVisible, renderStationLines: markers.renderStationLines, setView: fit, + setProjection: (kind) => cameraRig.setKind(kind, controls), beginMoveSelected() { markerInput.beginMoveSelected(); status.textContent = "선택한 포인트를 이동할 지형 위치를 클릭하세요."; diff --git a/B05_Profile/B05_Profile_UI_Viewer_Camera.ts b/B05_Profile/B05_Profile_UI_Viewer_Camera.ts new file mode 100644 index 00000000..32f94f3f --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Viewer_Camera.ts @@ -0,0 +1,81 @@ +/* ============================================================================= + * B05_Profile_UI_Viewer_Camera.ts + * B05 뷰어의 **원근/직교 두 카메라**와 그 사이 갈아 끼우기. + * + * 기본은 원근(시야각 45°) — B04 지표면 화면과 같은 조작감이다(2026-09-04 사용자 확정). + * 탑뷰에서 크기를 정밀하게 대조할 때만 직교로 바꾼다. 갈아 끼울 때 위치·시선·근평면· + * 먼평면과 **보이는 크기**를 그대로 옮기므로 화면이 튀지 않는다. + * + * 카메라 객체가 바뀌므로 쓰는 쪽은 붙잡아 두지 말고 `camera()`로 그때그때 읽을 것. + * ========================================================================== */ + +import * as THREE from "three"; +import type { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js"; + +export type ProjectionKind = "perspective" | "ortho"; + +const FOV = 45; +/** 원근 45°의 반각 tan — 직교 반높이를 같은 크기감으로 맞출 때 쓴다. */ +const HALF_TAN = Math.tan((FOV * Math.PI) / 360); + +export function createCameraRig() { + const perspective = new THREE.PerspectiveCamera(FOV, 1, 0.1, 100000); + perspective.position.set(100, 120, 100); + const ortho = new THREE.OrthographicCamera(-100, 100, 100, -100, 0.1, 100000); + let kind: ProjectionKind = "perspective"; + let aspect = 1; + let halfHeight = 100; + + const active = (): THREE.PerspectiveCamera | THREE.OrthographicCamera => + kind === "perspective" ? perspective : ortho; + + function apply(): void { + perspective.aspect = aspect; + perspective.updateProjectionMatrix(); + ortho.left = -halfHeight * aspect; + ortho.right = halfHeight * aspect; + ortho.top = halfHeight; + ortho.bottom = -halfHeight; + ortho.updateProjectionMatrix(); + } + + return { + camera: active, + kind: () => kind, + /** 뷰포트 종횡비(리사이즈 시). */ + setAspect(value: number): void { + aspect = value; + apply(); + }, + /** 화면맞춤 — 시점까지 거리로 직교 반높이를 잡는다(원근과 같은 크기감). */ + setFit(distance: number): void { + halfHeight = distance * HALF_TAN; + ortho.zoom = 1; + apply(); + }, + /** 투영 전환. 보이는 크기를 유지하며 OrbitControls의 대상 카메라도 갈아 끼운다. */ + setKind(next: ProjectionKind, controls: OrbitControls): void { + if (next === kind) return; + const from = active(); + kind = next; + const to = active(); + to.quaternion.copy(from.quaternion); + to.near = from.near; + to.far = from.far; + const offset = from.position.clone().sub(controls.target); + if (next === "ortho") { + halfHeight = offset.length() * HALF_TAN; + ortho.zoom = 1; + to.position.copy(from.position); + } else { + // 직교는 배율(zoom)로도 커지므로, 같은 크기로 보이는 거리까지 카메라를 물린다. + to.position.copy(controls.target).add(offset.setLength(halfHeight / ortho.zoom / HALF_TAN)); + } + apply(); + controls.object = to; + controls.update(); + }, + }; +} + +export type CameraRig = ReturnType; diff --git a/B05_Profile/B05_Profile_UI_Viewer_Marker_Input.ts b/B05_Profile/B05_Profile_UI_Viewer_Marker_Input.ts index 6ff036bb..afeb0ab9 100644 --- a/B05_Profile/B05_Profile_UI_Viewer_Marker_Input.ts +++ b/B05_Profile/B05_Profile_UI_Viewer_Marker_Input.ts @@ -30,7 +30,8 @@ export interface MarkerPointerControls { export function bindMarkerPointerControls(options: { canvas: HTMLCanvasElement; - camera: THREE.Camera; + /** 카메라 조회 — 뷰어가 원근/직교를 갈아 끼우므로 값이 아니라 함수로 받는다. */ + camera: () => THREE.Camera; controls: OrbitControls; markers: RouteMarkers; getTerrain: () => THREE.Object3D | null; @@ -60,7 +61,7 @@ export function bindMarkerPointerControls(options: { const bounds = getBounds(); if (!terrain || !bounds) return null; const raycaster = new THREE.Raycaster(); - raycaster.setFromCamera(pointerOf(clientX, clientY), camera); + raycaster.setFromCamera(pointerOf(clientX, clientY), camera()); const hit = raycaster.intersectObject(terrain, true)[0]; return hit ? sceneToModel(hit.point, bounds) : null; } @@ -84,7 +85,7 @@ export function bindMarkerPointerControls(options: { function markerHit(event: PointerEvent): THREE.Object3D | undefined { const raycaster = new THREE.Raycaster(); - raycaster.setFromCamera(pointerOf(event.clientX, event.clientY), camera); + raycaster.setFromCamera(pointerOf(event.clientX, event.clientY), camera()); return raycaster.intersectObject(markers.group, true)[0]?.object; } diff --git a/B05_Profile/B05_Profile_UI_Viewer_Structure_Pick.ts b/B05_Profile/B05_Profile_UI_Viewer_Structure_Pick.ts index 3e2688e4..4235b2e4 100644 --- a/B05_Profile/B05_Profile_UI_Viewer_Structure_Pick.ts +++ b/B05_Profile/B05_Profile_UI_Viewer_Structure_Pick.ts @@ -39,7 +39,8 @@ const CLICK_SLOP_PX = 3; export function bindStructurePick(options: { canvas: HTMLCanvasElement; - camera: THREE.Camera; + /** 카메라 조회 — 뷰어가 원근/직교를 갈아 끼우므로 값이 아니라 함수로 받는다. */ + camera: () => THREE.Camera; /** 코리도 그룹 — 없거나 꺼져 있으면 고르지 않는다. */ group: () => THREE.Object3D | null; /** 마커를 잡고 있거나 이동 대기 중인가 — 참이면 구조물 선택을 건너뛴다. */ @@ -102,7 +103,7 @@ export function bindStructurePick(options: { ((clientX - rect.left) / rect.width) * 2 - 1, -((clientY - rect.top) / rect.height) * 2 + 1, ), - camera, + camera(), ); // 모서리 선(LineSegments)은 뺀다 — 라인 레이캐스트 허용반경이 1m라 클릭을 가로챈다. const hit = raycaster diff --git a/ui_template/ui_template_progress.css b/ui_template/ui_template_progress.css index 9deb740c..498ad60b 100644 --- a/ui_template/ui_template_progress.css +++ b/ui_template/ui_template_progress.css @@ -25,6 +25,15 @@ height: var(--ui-progress-size); } +/* 회전 껍데기 — 진행률을 알든 모르든 **항상** 돈다(2026-09-04 사용자 지시). + 무거운 단계에서 호가 안 늘어도 도넛이 멈춰 보이지 않는다. 안쪽 svg는 12시 고정이라 + 호·숫자는 제자리에서 갱신된다. */ +.ui-progress-circle__spin { + width: 100%; + height: 100%; + animation: ui-progress-spin 1s linear infinite; +} + .ui-progress-circle__svg { width: 100%; height: 100%; @@ -46,17 +55,12 @@ transition: stroke-dashoffset var(--transition-base); } -/* 진행률을 모르는 구간 — 호 하나를 계속 돌린다. */ -.ui-progress-circle.is-indeterminate .ui-progress-circle__svg { - animation: ui-progress-spin 1s linear infinite; -} - @keyframes ui-progress-spin { from { - transform: rotate(-90deg); + transform: rotate(0deg); } to { - transform: rotate(270deg); + transform: rotate(360deg); } } diff --git a/ui_template/ui_template_progress.ts b/ui_template/ui_template_progress.ts index 00c12cfb..048116cc 100644 --- a/ui_template/ui_template_progress.ts +++ b/ui_template/ui_template_progress.ts @@ -61,22 +61,27 @@ export function createProgressCircle(options: ProgressCircleOptions = {}): Progr label.className = "ui-progress-circle__label"; label.textContent = options.label ?? ""; + // 회전은 진행률과 **따로 논다**(2026-09-04 사용자 지시) — 무거운 단계에서 호가 + // 안 늘어도 도넛은 계속 돌아야 "멈춘 것"으로 안 보인다. 바깥 껍데기만 CSS로 돌리고 + // 안쪽 svg는 12시 고정이라, 호·숫자는 제자리에서 갱신된다. + const spin = document.createElement("div"); + spin.className = "ui-progress-circle__spin"; + spin.append(svg); + const dial = document.createElement("div"); dial.className = "ui-progress-circle__dial"; - dial.append(svg, percent); + dial.append(spin, percent); root.append(dial, label); function set(ratio: number | null, nextLabel?: string): void { if (nextLabel !== undefined) label.textContent = nextLabel; if (ratio === null) { - // 진행률 미상 — 4분의 1 호를 돌려 "돌아가는 중"만 알린다. - root.classList.add("is-indeterminate"); + // 진행률 미상 — 4분의 1 호만 남긴다(회전은 껍데기가 늘 맡는다). bar.setAttribute("stroke-dashoffset", String(CIRCUMFERENCE * 0.75)); percent.textContent = ""; return; } const clamped = Math.min(1, Math.max(0, ratio)); - root.classList.remove("is-indeterminate"); bar.setAttribute("stroke-dashoffset", String(CIRCUMFERENCE * (1 - clamped))); percent.textContent = `${Math.round(clamped * 100)}%`; }